@hanna84/mcp-writing 3.5.1 → 3.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,9 +4,16 @@ All notable changes to this project will be documented in this file. Dates are d
4
4
 
5
5
  Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
6
6
 
7
+ #### [v3.5.2](https://github.com/hannasdev/mcp-writing/compare/v3.5.1...v3.5.2)
8
+
9
+ - Improve beta prose flow and centered scene dateline [`#184`](https://github.com/hannasdev/mcp-writing/pull/184)
10
+
7
11
  #### [v3.5.1](https://github.com/hannasdev/mcp-writing/compare/v3.5.0...v3.5.1)
8
12
 
13
+ > 8 May 2026
14
+
9
15
  - Fix beta accountability follow-ups and test coverage [`#183`](https://github.com/hannasdev/mcp-writing/pull/183)
16
+ - Release 3.5.1 [`28d77f7`](https://github.com/hannasdev/mcp-writing/commit/28d77f755708dc8e987caa69dfcb6f3c5408b916)
10
17
 
11
18
  #### [v3.5.0](https://github.com/hannasdev/mcp-writing/compare/v3.4.4...v3.5.0)
12
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "3.5.1",
3
+ "version": "3.5.2",
4
4
  "description": "MCP service for AI-assisted reasoning and editing on long-form fiction projects",
5
5
  "homepage": "https://hannasdev.github.io/mcp-writing/",
6
6
  "type": "module",
@@ -233,6 +233,122 @@ function readProse(filePath, { syncDir } = {}) {
233
233
  }
234
234
  }
235
235
 
236
+ function normalizeHardWrappedProse(rawProse) {
237
+ const prose = String(rawProse ?? "").replace(/\r\n?/g, "\n").trim();
238
+ if (!prose) return "";
239
+ const paragraphs = prose
240
+ .split(/\n\s*\n/g)
241
+ .map(paragraph => paragraph.replace(/\s*\n\s*/g, " ").trim())
242
+ .filter(Boolean);
243
+ return paragraphs.join("\n\n");
244
+ }
245
+
246
+ function extractSceneDateline(prose) {
247
+ const normalized = String(prose ?? "").replace(/\r\n?/g, "\n").trim();
248
+ if (!normalized) {
249
+ return { dateline: null, body: "" };
250
+ }
251
+
252
+ const lines = normalized
253
+ .split("\n")
254
+ .map(line => line.trim())
255
+ .filter(Boolean);
256
+ if (lines.length === 0) {
257
+ return { dateline: null, body: "" };
258
+ }
259
+
260
+ const firstParagraph = lines[0];
261
+ const dashMatch = firstParagraph.match(/^(.+?)\s*[–-]\s*(.+)$/);
262
+ const left = dashMatch?.[1]?.trim() ?? "";
263
+ const right = dashMatch?.[2]?.trim() ?? "";
264
+ const totalWords = firstParagraph.split(/\s+/).filter(Boolean).length;
265
+ const looksLikeDateline = (
266
+ firstParagraph.length >= 6
267
+ && firstParagraph.length <= 90
268
+ && Boolean(dashMatch)
269
+ && left.length >= 2
270
+ && right.length >= 2
271
+ && totalWords <= 14
272
+ && !/[!?]/.test(firstParagraph)
273
+ && !/[“”"']/.test(firstParagraph)
274
+ );
275
+
276
+ if (!looksLikeDateline) {
277
+ return { dateline: null, body: normalized };
278
+ }
279
+
280
+ return {
281
+ dateline: firstParagraph,
282
+ body: lines.slice(1).join("\n"),
283
+ };
284
+ }
285
+
286
+ function normalizeBetaProseFlow(prose) {
287
+ const normalized = String(prose ?? "").replace(/\r\n?/g, "\n").trim();
288
+ if (!normalized) return "";
289
+ const paragraphs = normalized
290
+ .split(/\n\s*\n/g)
291
+ .map(paragraph => paragraph
292
+ .split("\n")
293
+ .map(line => line.trim())
294
+ .filter(Boolean)
295
+ .join("\n"))
296
+ .filter(Boolean);
297
+ // For beta exports, convert paragraph blocks into regular line breaks so the
298
+ // reading flow stays continuous without large section gaps.
299
+ return paragraphs.join("\n");
300
+ }
301
+
302
+ function normalizeBetaTypography(prose) {
303
+ return String(prose ?? "")
304
+ .replace(/(^|\s)--(\s|$)/g, "$1—$2");
305
+ }
306
+
307
+ function renderProseWithInlineEmphasis(doc, prose, {
308
+ bodyFont,
309
+ italicFont,
310
+ fontSize,
311
+ width,
312
+ align = "left",
313
+ lineGap = 0,
314
+ paragraphGap = 0,
315
+ blankLineMoveDown = 0.15,
316
+ }) {
317
+ const lines = String(prose ?? "").split("\n");
318
+ for (const line of lines) {
319
+ if (line.length === 0) {
320
+ doc.moveDown(blankLineMoveDown);
321
+ continue;
322
+ }
323
+
324
+ if (line.trim() === "***") {
325
+ doc.moveDown(0.5);
326
+ doc.fontSize(fontSize).font(bodyFont);
327
+ doc.text("***", { align: "center", width, lineGap, paragraphGap: 0 });
328
+ doc.moveDown(0.5);
329
+ continue;
330
+ }
331
+
332
+ const segments = line.split(/(\*[^*\n]+\*)/g).filter(Boolean);
333
+
334
+ for (let index = 0; index < segments.length; index += 1) {
335
+ const segment = segments[index];
336
+ const isItalic = /^\*[^*\n]+\*$/.test(segment);
337
+ const text = isItalic ? segment.slice(1, -1) : segment;
338
+ if (!text) continue;
339
+ doc.fontSize(fontSize).font(isItalic ? italicFont : bodyFont);
340
+ doc.text(text, {
341
+ align,
342
+ width,
343
+ lineGap,
344
+ paragraphGap,
345
+ continued: index < segments.length - 1,
346
+ });
347
+ }
348
+ }
349
+ doc.font(bodyFont).fontSize(fontSize);
350
+ }
351
+
236
352
  function renderSceneBlock(scene, options) {
237
353
  const {
238
354
  profile,
@@ -278,11 +394,11 @@ function renderSceneBlock(scene, options) {
278
394
 
279
395
  const prose = scene.prose ?? "";
280
396
  if (!includeParagraphAnchors || prose.length === 0) {
281
- parts.push(prose);
397
+ parts.push(normalizeHardWrappedProse(prose));
282
398
  return parts.join("\n\n");
283
399
  }
284
400
 
285
- const paragraphs = prose
401
+ const paragraphs = normalizeHardWrappedProse(prose)
286
402
  .split(/\n\s*\n/g)
287
403
  .map(p => p.trim())
288
404
  .filter(Boolean);
@@ -433,12 +549,13 @@ export function renderReviewBundlePdfWithMetadata(dbHandle, plan, { generatedAt,
433
549
  const syncDir = syncDirOpt ?? process.env.WRITING_SYNC_DIR ?? null;
434
550
  const isBetaProfile = profile === "beta_reader_personalized";
435
551
  const proseFontSize = isBetaProfile ? 8 : 10;
436
- const proseLineGap = isBetaProfile ? 3.2 : 3;
552
+ const proseLineGap = isBetaProfile ? 1.6 : 3;
437
553
  const bodyFont = profile === "beta_reader_personalized" ? "Times-Roman" : "Helvetica";
438
554
  const coverHeadingFont = profile === "beta_reader_personalized" ? "Times-Bold" : "Helvetica-Bold";
439
555
  // Beta scene headings intentionally use body font (non-bold) per product direction.
440
556
  const sceneHeadingFont = isBetaProfile ? bodyFont : coverHeadingFont;
441
- const metaFont = profile === "beta_reader_personalized" ? "Times-Italic" : "Helvetica-Oblique";
557
+ const italicFont = profile === "beta_reader_personalized" ? "Times-Italic" : "Helvetica-Oblique";
558
+ const metaFont = italicFont;
442
559
 
443
560
  const sceneIds = plan.ordering.map(row => row.scene_id);
444
561
  const rows = loadBundleSceneRows(dbHandle, plan.resolved_scope.project_id, sceneIds);
@@ -494,7 +611,7 @@ export function renderReviewBundlePdfWithMetadata(dbHandle, plan, { generatedAt,
494
611
  doc.restore();
495
612
  // Restore prose style so auto-flowed text keeps consistent typography
496
613
  // on pages added during long text rendering.
497
- doc.font(bodyFont).fontSize(proseFontSize);
614
+ doc.font(bodyFont).fontSize(proseFontSize).fillColor("#000000");
498
615
  doc.x = previousX;
499
616
  doc.y = previousY;
500
617
  };
@@ -530,8 +647,9 @@ export function renderReviewBundlePdfWithMetadata(dbHandle, plan, { generatedAt,
530
647
 
531
648
  try {
532
649
  doc.addPage();
533
- doc.fontSize(24).font(coverHeadingFont).text(`Review Bundle: ${plan.resolved_scope.project_id}`, { align: "left" });
534
- doc.moveDown(0.5);
650
+ const coverLabel = `Review Bundle: ${plan.resolved_scope.project_id}`;
651
+ doc.fontSize(isBetaProfile ? 11 : 24).font(coverHeadingFont).text(coverLabel, { align: "left" });
652
+ doc.moveDown(isBetaProfile ? 0.2 : 0.5);
535
653
  doc.fontSize(11).font(bodyFont);
536
654
  if (profile !== "beta_reader_personalized") {
537
655
  doc.text(`Profile: ${profile}`, { align: "left" });
@@ -604,14 +722,35 @@ export function renderReviewBundlePdfWithMetadata(dbHandle, plan, { generatedAt,
604
722
  }
605
723
  );
606
724
  }
607
- prose = resolved;
725
+ let sceneDateline = null;
726
+ if (isBetaProfile) {
727
+ prose = normalizeBetaProseFlow(resolved);
728
+ const extracted = extractSceneDateline(prose);
729
+ sceneDateline = extracted.dateline ? normalizeBetaTypography(extracted.dateline) : null;
730
+ prose = normalizeBetaTypography(extracted.body);
731
+ } else {
732
+ prose = normalizeHardWrappedProse(resolved);
733
+ }
734
+
735
+ if (sceneDateline) {
736
+ doc.fontSize(10).font(metaFont);
737
+ doc.text(sceneDateline, {
738
+ align: "center",
739
+ width: doc.page.width - doc.page.margins.left - doc.page.margins.right,
740
+ });
741
+ doc.moveDown(1.0);
742
+ }
608
743
 
609
744
  const textWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
610
- doc.fontSize(proseFontSize).font(bodyFont);
611
- doc.text(prose, {
745
+ renderProseWithInlineEmphasis(doc, prose, {
746
+ bodyFont,
747
+ italicFont,
748
+ fontSize: proseFontSize,
612
749
  align: "left",
613
750
  width: textWidth,
614
751
  lineGap: proseLineGap,
752
+ paragraphGap: 0,
753
+ blankLineMoveDown: isBetaProfile ? 0.15 : 0.65,
615
754
  });
616
755
  }
617
756
 
@@ -642,4 +781,5 @@ export {
642
781
  buildPageFingerprintToken,
643
782
  buildFingerprintSeed,
644
783
  buildFingerprintSeedHash,
784
+ extractSceneDateline,
645
785
  };
@@ -13,6 +13,7 @@ export {
13
13
  buildPageFingerprintToken,
14
14
  buildFingerprintSeed,
15
15
  buildFingerprintSeedHash,
16
+ extractSceneDateline,
16
17
  } from "./review-bundles-renderer.js";
17
18
 
18
19
  export { createReviewBundleArtifacts } from "./review-bundles-writer.js";