@hanna84/mcp-writing 3.7.1 → 3.8.0

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.8.0](https://github.com/hannasdev/mcp-writing/compare/v3.7.1...v3.8.0)
8
+
9
+ - feat: add canonical chapter and epigraph indexing [`#193`](https://github.com/hannasdev/mcp-writing/pull/193)
10
+
7
11
  #### [v3.7.1](https://github.com/hannasdev/mcp-writing/compare/v3.7.0...v3.7.1)
8
12
 
13
+ > 14 May 2026
14
+
9
15
  - Docs/chapter epigraph migration gates [`#192`](https://github.com/hannasdev/mcp-writing/pull/192)
16
+ - Release 3.7.1 [`3516818`](https://github.com/hannasdev/mcp-writing/commit/3516818e333b4f6667116eb86657d07b31a9b761)
10
17
 
11
18
  #### [v3.7.0](https://github.com/hannasdev/mcp-writing/compare/v3.6.2...v3.7.0)
12
19
 
package/README.md CHANGED
@@ -28,9 +28,9 @@ Instead of feeding an entire manuscript to an AI and hoping it fits in the conte
28
28
 
29
29
  **Current status:**
30
30
  - **Core platform complete:** Metadata-first analysis, sidecar-backed metadata maintenance, AI-assisted prose editing with confirmation + git history, review bundles, and Scrivener Direct extraction are all implemented.
31
- - **Recently delivered:** Guideline generation is now delivered and tracked in done PRDs.
32
- - **Active development:** OpenClaw integration is the current focus area.
33
- - **Deferred backlog:** embeddings search is intentionally deferred for later exploration.
31
+ - **Active development:** OpenClaw integration, the client-agnostic setup contract, and chapter-structure follow-up work after the first-class chapter/epigraph rollout.
32
+ - **Deferred backlog:** embeddings search.
33
+ - **Ideas and open questions:** tracked separately so future exploration does not distort the active roadmap.
34
34
 
35
35
  ## Who it is for
36
36
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "3.7.1",
3
+ "version": "3.8.0",
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",
package/src/core/db.js CHANGED
@@ -2,6 +2,19 @@ import { DatabaseSync } from "node:sqlite";
2
2
 
3
3
  const dbStartupWarnings = [];
4
4
 
5
+ function slugifyLegacyChapterValue(value) {
6
+ return String(value ?? "")
7
+ .toLowerCase()
8
+ .replace(/[^a-z0-9]+/g, "-")
9
+ .replace(/^-+|-+$/g, "");
10
+ }
11
+
12
+ function formatLegacyChapterId(sortIndex, title) {
13
+ const chapterNumber = String(sortIndex).padStart(2, "0");
14
+ const chapterSlug = slugifyLegacyChapterValue(title) || `chapter-${sortIndex}`;
15
+ return `ch-${chapterNumber}-${chapterSlug}`;
16
+ }
17
+
5
18
  function cloneWarningDetails(details) {
6
19
  if (!details) return details;
7
20
 
@@ -46,6 +59,8 @@ export const SCHEMA = `
46
59
  CREATE TABLE IF NOT EXISTS scenes (
47
60
  scene_id TEXT NOT NULL,
48
61
  project_id TEXT NOT NULL REFERENCES projects(project_id),
62
+ chapter_id TEXT,
63
+ scene_role TEXT,
49
64
  title TEXT,
50
65
  part INTEGER,
51
66
  chapter INTEGER,
@@ -67,6 +82,47 @@ export const SCHEMA = `
67
82
  PRIMARY KEY (scene_id, project_id)
68
83
  );
69
84
 
85
+ CREATE TABLE IF NOT EXISTS chapters (
86
+ chapter_id TEXT NOT NULL,
87
+ project_id TEXT NOT NULL REFERENCES projects(project_id),
88
+ title TEXT NOT NULL,
89
+ sort_index INTEGER NOT NULL,
90
+ logline TEXT,
91
+ source_path TEXT,
92
+ source_checksum TEXT,
93
+ metadata_stale INTEGER NOT NULL DEFAULT 0,
94
+ updated_at TEXT NOT NULL,
95
+ PRIMARY KEY (chapter_id, project_id),
96
+ UNIQUE (project_id, sort_index)
97
+ );
98
+
99
+ CREATE TABLE IF NOT EXISTS epigraphs (
100
+ epigraph_id TEXT NOT NULL,
101
+ project_id TEXT NOT NULL REFERENCES projects(project_id),
102
+ chapter_id TEXT NOT NULL,
103
+ body TEXT NOT NULL,
104
+ file_path TEXT NOT NULL,
105
+ prose_checksum TEXT,
106
+ metadata_stale INTEGER NOT NULL DEFAULT 0,
107
+ updated_at TEXT NOT NULL,
108
+ PRIMARY KEY (epigraph_id, project_id),
109
+ UNIQUE (project_id, chapter_id)
110
+ );
111
+
112
+ CREATE TABLE IF NOT EXISTS epigraph_characters (
113
+ epigraph_id TEXT NOT NULL,
114
+ project_id TEXT NOT NULL,
115
+ character_id TEXT NOT NULL,
116
+ PRIMARY KEY (epigraph_id, project_id, character_id)
117
+ );
118
+
119
+ CREATE TABLE IF NOT EXISTS epigraph_tags (
120
+ epigraph_id TEXT NOT NULL,
121
+ project_id TEXT NOT NULL,
122
+ tag TEXT NOT NULL,
123
+ PRIMARY KEY (epigraph_id, project_id, tag)
124
+ );
125
+
70
126
  CREATE TABLE IF NOT EXISTS scene_characters (
71
127
  scene_id TEXT NOT NULL,
72
128
  project_id TEXT NOT NULL,
@@ -484,6 +540,141 @@ const MIGRATIONS = [
484
540
  });
485
541
  }
486
542
  },
543
+ // 8: add canonical chapter/epigraph entities and scene linkage fields
544
+ (db) => {
545
+ const sceneColumns = db.prepare(`PRAGMA table_info(scenes)`).all();
546
+ if (!sceneColumns.some(c => c.name === "chapter_id")) {
547
+ db.exec(`ALTER TABLE scenes ADD COLUMN chapter_id TEXT;`);
548
+ }
549
+ if (!sceneColumns.some(c => c.name === "scene_role")) {
550
+ db.exec(`ALTER TABLE scenes ADD COLUMN scene_role TEXT;`);
551
+ }
552
+
553
+ db.exec(`
554
+ CREATE TABLE IF NOT EXISTS chapters (
555
+ chapter_id TEXT NOT NULL,
556
+ project_id TEXT NOT NULL REFERENCES projects(project_id),
557
+ title TEXT NOT NULL,
558
+ sort_index INTEGER NOT NULL,
559
+ logline TEXT,
560
+ source_path TEXT,
561
+ source_checksum TEXT,
562
+ metadata_stale INTEGER NOT NULL DEFAULT 0,
563
+ updated_at TEXT NOT NULL,
564
+ PRIMARY KEY (chapter_id, project_id),
565
+ UNIQUE (project_id, sort_index)
566
+ );
567
+ `);
568
+
569
+ db.exec(`
570
+ CREATE TABLE IF NOT EXISTS epigraphs (
571
+ epigraph_id TEXT NOT NULL,
572
+ project_id TEXT NOT NULL REFERENCES projects(project_id),
573
+ chapter_id TEXT NOT NULL,
574
+ body TEXT NOT NULL,
575
+ file_path TEXT NOT NULL,
576
+ prose_checksum TEXT,
577
+ metadata_stale INTEGER NOT NULL DEFAULT 0,
578
+ updated_at TEXT NOT NULL,
579
+ PRIMARY KEY (epigraph_id, project_id),
580
+ UNIQUE (project_id, chapter_id)
581
+ );
582
+ `);
583
+
584
+ db.exec(`
585
+ CREATE TABLE IF NOT EXISTS epigraph_characters (
586
+ epigraph_id TEXT NOT NULL,
587
+ project_id TEXT NOT NULL,
588
+ character_id TEXT NOT NULL,
589
+ PRIMARY KEY (epigraph_id, project_id, character_id)
590
+ );
591
+ `);
592
+
593
+ db.exec(`
594
+ CREATE TABLE IF NOT EXISTS epigraph_tags (
595
+ epigraph_id TEXT NOT NULL,
596
+ project_id TEXT NOT NULL,
597
+ tag TEXT NOT NULL,
598
+ PRIMARY KEY (epigraph_id, project_id, tag)
599
+ );
600
+ `);
601
+ },
602
+ // 9: backfill canonical chapters and scene chapter_id values from legacy scene chapter fields
603
+ (db) => {
604
+ const sceneColumns = db.prepare(`PRAGMA table_info(scenes)`).all();
605
+ const hasChapterId = sceneColumns.some((column) => column.name === "chapter_id");
606
+ const hasLegacyChapter = sceneColumns.some((column) => column.name === "chapter");
607
+ const hasLegacyChapterTitle = sceneColumns.some((column) => column.name === "chapter_title");
608
+ if (!hasChapterId || !hasLegacyChapter || !hasLegacyChapterTitle) {
609
+ return;
610
+ }
611
+
612
+ const legacyChapters = db.prepare(`
613
+ SELECT
614
+ project_id,
615
+ chapter AS sort_index,
616
+ COALESCE(NULLIF(TRIM(chapter_title), ''), '') AS chapter_title,
617
+ COALESCE(NULLIF(TRIM(file_path), ''), '') AS file_path,
618
+ COALESCE(NULLIF(TRIM(updated_at), ''), '') AS updated_at
619
+ FROM scenes
620
+ WHERE chapter IS NOT NULL
621
+ ORDER BY project_id, chapter, timeline_position, scene_id
622
+ `).all();
623
+
624
+ const chapterRows = new Map();
625
+ for (const row of legacyChapters) {
626
+ const key = `${row.project_id}::${row.sort_index}`;
627
+ if (chapterRows.has(key)) continue;
628
+ const title = row.chapter_title || `Chapter ${row.sort_index}`;
629
+ chapterRows.set(key, {
630
+ chapter_id: formatLegacyChapterId(row.sort_index, title),
631
+ project_id: row.project_id,
632
+ title,
633
+ sort_index: row.sort_index,
634
+ source_path: row.file_path ? row.file_path.replace(/[\\/][^\\/]+$/, "") : null,
635
+ updated_at: row.updated_at || new Date().toISOString(),
636
+ });
637
+ }
638
+
639
+ const insertChapter = db.prepare(`
640
+ INSERT INTO chapters (
641
+ chapter_id, project_id, title, sort_index, logline, source_path, source_checksum, metadata_stale, updated_at
642
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
643
+ ON CONFLICT (project_id, sort_index) DO UPDATE SET
644
+ title = COALESCE(chapters.title, excluded.title),
645
+ source_path = COALESCE(chapters.source_path, excluded.source_path),
646
+ updated_at = CASE
647
+ WHEN chapters.updated_at IS NULL OR chapters.updated_at = '' THEN excluded.updated_at
648
+ ELSE chapters.updated_at
649
+ END
650
+ `);
651
+
652
+ for (const chapter of chapterRows.values()) {
653
+ insertChapter.run(
654
+ chapter.chapter_id,
655
+ chapter.project_id,
656
+ chapter.title,
657
+ chapter.sort_index,
658
+ null,
659
+ chapter.source_path,
660
+ null,
661
+ 0,
662
+ chapter.updated_at
663
+ );
664
+ }
665
+
666
+ db.exec(`
667
+ UPDATE scenes
668
+ SET chapter_id = (
669
+ SELECT c.chapter_id
670
+ FROM chapters c
671
+ WHERE c.project_id = scenes.project_id
672
+ AND c.sort_index = scenes.chapter
673
+ )
674
+ WHERE chapter IS NOT NULL
675
+ AND (chapter_id IS NULL OR chapter_id = '');
676
+ `);
677
+ },
487
678
  ];
488
679
 
489
680
  // The version every database should reach after openDb. Not the current DB value —
@@ -105,6 +105,7 @@ export function resolveBatchTargetScenes(dbHandle, {
105
105
  sceneIds,
106
106
  part,
107
107
  chapter,
108
+ chapterId,
108
109
  onlyStale,
109
110
  }) {
110
111
  const projectExists = Boolean(
@@ -139,6 +140,10 @@ export function resolveBatchTargetScenes(dbHandle, {
139
140
  conditions.push("chapter = ?");
140
141
  params.push(chapter);
141
142
  }
143
+ if (chapterId !== undefined) {
144
+ conditions.push("chapter_id = ?");
145
+ params.push(chapterId);
146
+ }
142
147
  if (onlyStale) {
143
148
  conditions.push("metadata_stale = 1");
144
149
  }
@@ -147,7 +152,7 @@ export function resolveBatchTargetScenes(dbHandle, {
147
152
  SELECT scene_id, project_id, file_path
148
153
  FROM scenes
149
154
  WHERE ${conditions.join(" AND ")}
150
- ORDER BY part, chapter, timeline_position
155
+ ORDER BY part, chapter, timeline_position, scene_id
151
156
  `;
152
157
 
153
158
  return {
@@ -125,6 +125,7 @@ export function buildReviewBundlePlan(dbHandle, {
125
125
  profile,
126
126
  part,
127
127
  chapter,
128
+ chapter_id,
128
129
  chapters,
129
130
  tag,
130
131
  scene_ids,
@@ -167,11 +168,12 @@ export function buildReviewBundlePlan(dbHandle, {
167
168
  assertProfile(profile);
168
169
  assertStrictness(strictness);
169
170
  assertFormat(format);
170
- if (chapter !== undefined && chapters !== undefined) {
171
+ const chapterFilterCount = [chapter !== undefined, chapter_id !== undefined, chapters !== undefined].filter(Boolean).length;
172
+ if (chapterFilterCount > 1) {
171
173
  throw new ReviewBundlePlanError(
172
174
  "INVALID_CHAPTER_FILTER",
173
- "Use either chapter or chapters, not both.",
174
- { chapter, chapters }
175
+ "Use one of chapter, chapter_id, or chapters.",
176
+ { chapter, chapter_id, chapters }
175
177
  );
176
178
  }
177
179
  let normalizedChapters;
@@ -222,6 +224,10 @@ export function buildReviewBundlePlan(dbHandle, {
222
224
  conditions.push("s.chapter = ?");
223
225
  conditionParams.push(chapter);
224
226
  }
227
+ if (chapter_id !== undefined) {
228
+ conditions.push("s.chapter_id = ?");
229
+ conditionParams.push(chapter_id);
230
+ }
225
231
  if (Array.isArray(normalizedChapters) && normalizedChapters.length > 0) {
226
232
  const placeholders = normalizedChapters.map(() => "?").join(",");
227
233
  conditions.push(`s.chapter IN (${placeholders})`);
@@ -259,6 +265,7 @@ export function buildReviewBundlePlan(dbHandle, {
259
265
  filters: {
260
266
  ...(part !== undefined ? { part } : {}),
261
267
  ...(chapter !== undefined ? { chapter } : {}),
268
+ ...(chapter_id !== undefined ? { chapter_id } : {}),
262
269
  ...(Array.isArray(normalizedChapters) ? { chapters: normalizedChapters } : {}),
263
270
  ...(tag ? { tag } : {}),
264
271
  ...(Array.isArray(scene_ids) ? { scene_ids } : {}),
@@ -345,6 +352,7 @@ export function buildReviewBundlePlan(dbHandle, {
345
352
  const appliedFilters = {
346
353
  ...(part !== undefined ? { part } : {}),
347
354
  ...(chapter !== undefined ? { chapter } : {}),
355
+ ...(chapter_id !== undefined ? { chapter_id } : {}),
348
356
  ...(Array.isArray(normalizedChapters) ? { chapters: normalizedChapters } : {}),
349
357
  ...(tag ? { tag } : {}),
350
358
  ...(Array.isArray(normalizedSceneIds) ? { scene_ids: normalizedSceneIds } : {}),
@@ -75,19 +75,23 @@ function loadBundleSceneRowsWithTags(dbHandle, projectId, sceneIds) {
75
75
  const placeholders = chunk.map(() => "?").join(",");
76
76
  const chunkRows = dbHandle.prepare(`
77
77
  SELECT
78
- scene_id,
79
- project_id,
80
- title,
81
- part,
82
- chapter,
83
- chapter_title,
84
- timeline_position,
85
- logline,
86
- pov,
87
- save_the_cat_beat,
88
- file_path
78
+ s.scene_id,
79
+ s.project_id,
80
+ s.chapter_id,
81
+ s.scene_role,
82
+ s.title,
83
+ s.part,
84
+ s.chapter,
85
+ COALESCE(c.title, s.chapter_title) AS chapter_title,
86
+ s.timeline_position,
87
+ s.logline,
88
+ s.pov,
89
+ s.save_the_cat_beat,
90
+ s.file_path
89
91
  FROM scenes
90
- WHERE project_id = ? AND scene_id IN (${placeholders})
92
+ s
93
+ LEFT JOIN chapters c ON c.chapter_id = s.chapter_id AND c.project_id = s.project_id
94
+ WHERE s.project_id = ? AND s.scene_id IN (${placeholders})
91
95
  `).all(projectId, ...chunk);
92
96
  rows.push(...chunkRows);
93
97
  }
@@ -145,6 +149,26 @@ function loadBundleSceneRowsWithTags(dbHandle, projectId, sceneIds) {
145
149
  return orderedRows;
146
150
  }
147
151
 
152
+ function loadEpigraphsByChapter(dbHandle, projectId) {
153
+ const rows = dbHandle.prepare(`
154
+ SELECT e.epigraph_id, e.chapter_id, e.body, c.title AS chapter_title, c.sort_index AS chapter
155
+ FROM epigraphs e
156
+ JOIN chapters c ON c.chapter_id = e.chapter_id AND c.project_id = e.project_id
157
+ WHERE e.project_id = ?
158
+ ORDER BY c.sort_index, e.epigraph_id
159
+ `).all(projectId);
160
+
161
+ return new Map(rows.map(row => [row.chapter_id, {
162
+ scene_id: row.epigraph_id,
163
+ chapter_id: row.chapter_id,
164
+ chapter: row.chapter,
165
+ chapter_title: row.chapter_title,
166
+ title: "Epigraph",
167
+ prose: String(row.body ?? "").trim(),
168
+ tags: ["epigraph"],
169
+ }]));
170
+ }
171
+
148
172
  function normalizeRelativePath(inputPath) {
149
173
  return String(inputPath).replace(/\\/g, "/").replace(/^\.\//, "");
150
174
  }
@@ -381,6 +405,7 @@ function renderProseWithInlineEmphasis(doc, prose, {
381
405
  }
382
406
 
383
407
  function isEpigraphScene(scene) {
408
+ if (scene?.entity_kind === "epigraph") return true;
384
409
  const tags = Array.isArray(scene?.tags)
385
410
  ? scene.tags
386
411
  .map(tag => String(tag ?? "").trim().toLowerCase())
@@ -392,6 +417,10 @@ function isEpigraphScene(scene) {
392
417
  return /^epigraph(?:\b|\s|[-:])/i.test(title);
393
418
  }
394
419
 
420
+ function isCanonicalEpigraphInsert(scene) {
421
+ return scene?.entity_kind === "epigraph";
422
+ }
423
+
395
424
  function renderSceneBlock(scene, options) {
396
425
  const {
397
426
  profile,
@@ -402,7 +431,9 @@ function renderSceneBlock(scene, options) {
402
431
  } = options;
403
432
 
404
433
  const isBetaProfile = profile === "beta_reader_personalized";
405
- const isEpigraph = isBetaProfile && isEpigraphScene(scene);
434
+ const isOutlineProfile = profile === "outline_discussion";
435
+ const isEpigraph = (isBetaProfile || isOutlineProfile) && isEpigraphScene(scene);
436
+ const isCanonicalEpigraph = isCanonicalEpigraphInsert(scene);
406
437
 
407
438
  const parts = [];
408
439
 
@@ -421,6 +452,11 @@ function renderSceneBlock(scene, options) {
421
452
  }
422
453
 
423
454
  if (profile === "outline_discussion") {
455
+ if (isCanonicalEpigraph) {
456
+ const prose = normalizeHardWrappedProse(scene.prose ?? "");
457
+ if (prose) parts.push(prose);
458
+ return parts.join("\n\n");
459
+ }
424
460
  const summaryParts = [];
425
461
  if (scene.pov) summaryParts.push(`POV: ${scene.pov}`);
426
462
  if (scene.save_the_cat_beat) summaryParts.push(`Beat: ${scene.save_the_cat_beat}`);
@@ -535,6 +571,7 @@ export function renderReviewBundleMarkdown(dbHandle, plan, { generatedAt, syncDi
535
571
 
536
572
  const sceneIds = plan.ordering.map(row => row.scene_id);
537
573
  const rows = loadBundleSceneRowsWithTags(dbHandle, plan.resolved_scope.project_id, sceneIds);
574
+ const epigraphsByChapter = loadEpigraphsByChapter(dbHandle, plan.resolved_scope.project_id);
538
575
  const sections = [];
539
576
  const recipientName = plan.resolved_scope?.options?.recipient_name;
540
577
  const recipientDisplayName = normalizeRecipientDisplayName(recipientName);
@@ -586,12 +623,24 @@ export function renderReviewBundleMarkdown(dbHandle, plan, { generatedAt, syncDi
586
623
  const showChapterHeading = isBetaProfile
587
624
  && Boolean(scene.chapter_title)
588
625
  && (!prevScene || prevScene.chapter !== scene.chapter);
626
+ if ((!prevScene || prevScene.chapter_id !== scene.chapter_id) && scene.chapter_id && epigraphsByChapter.has(scene.chapter_id)) {
627
+ sections.push(renderSceneBlock({
628
+ ...epigraphsByChapter.get(scene.chapter_id),
629
+ entity_kind: "epigraph",
630
+ }, {
631
+ profile,
632
+ includeSceneIds: false,
633
+ includeMetadataSidebar: false,
634
+ includeParagraphAnchors: false,
635
+ showChapterHeading,
636
+ }));
637
+ }
589
638
  sections.push(renderSceneBlock(withProse, {
590
639
  profile,
591
640
  includeSceneIds,
592
641
  includeMetadataSidebar,
593
642
  includeParagraphAnchors,
594
- showChapterHeading,
643
+ showChapterHeading: showChapterHeading && !epigraphsByChapter.has(scene.chapter_id),
595
644
  }));
596
645
  }
597
646
 
@@ -623,6 +672,7 @@ export function renderReviewBundlePdfWithMetadata(dbHandle, plan, { generatedAt,
623
672
 
624
673
  const sceneIds = plan.ordering.map(row => row.scene_id);
625
674
  const rows = loadBundleSceneRowsWithTags(dbHandle, plan.resolved_scope.project_id, sceneIds);
675
+ const epigraphsByChapter = loadEpigraphsByChapter(dbHandle, plan.resolved_scope.project_id);
626
676
  const recipientName = plan.resolved_scope?.options?.recipient_name;
627
677
  const recipientDisplayName = normalizeRecipientDisplayName(recipientName);
628
678
  const footerRecipientDisplayName = sanitizeFooterRecipientDisplayName(recipientDisplayName);
@@ -858,6 +908,28 @@ export function renderReviewBundlePdfWithMetadata(dbHandle, plan, { generatedAt,
858
908
  }
859
909
  }
860
910
 
911
+ if ((!prevScene || prevScene.chapter_id !== scene.chapter_id) && scene.chapter_id && epigraphsByChapter.has(scene.chapter_id)) {
912
+ const epigraph = epigraphsByChapter.get(scene.chapter_id);
913
+ const textWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
914
+ const epigraphWidth = Math.round(textWidth * 0.68);
915
+ const epigraphIndent = Math.round((textWidth - epigraphWidth) / 2);
916
+ const savedX = doc.x;
917
+ doc.moveDown(isBetaProfile ? 0.5 : 0.8);
918
+ doc.x = doc.page.margins.left + epigraphIndent;
919
+ renderProseWithInlineEmphasis(doc, epigraph.prose, {
920
+ bodyFont: italicFont,
921
+ italicFont: bodyFont,
922
+ fontSize: proseFontSize,
923
+ align: "left",
924
+ width: epigraphWidth,
925
+ lineGap: proseLineGap,
926
+ paragraphGap: 0,
927
+ blankLineMoveDown: 0.65,
928
+ });
929
+ doc.x = savedX;
930
+ doc.moveDown(1.0);
931
+ }
932
+
861
933
  // Skip title for epigraphs in beta and outline profiles.
862
934
  const isEpigraph = (isBetaProfile || isOutlineProfile) && isEpigraphScene(scene);
863
935
  if (!isEpigraph) {