@gentbajko/slopify 2.0.3 → 2.1.1

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.
Files changed (103) hide show
  1. package/SUBTITLES.md +1 -1
  2. package/dist/adapters/alignment/threads.js +11 -0
  3. package/dist/adapters/alignment/worker.js +38 -4
  4. package/dist/adapters/host-cli/index.js +25 -1
  5. package/dist/assets/document/SOURCE.txt +7 -0
  6. package/dist/assets/document/background.jpg +0 -0
  7. package/dist/assets/document/fonts/Cinzel-Black.ttf +0 -0
  8. package/dist/assets/document/fonts/Cinzel-Bold.ttf +0 -0
  9. package/dist/assets/document/fonts/Cinzel-Medium.ttf +0 -0
  10. package/dist/assets/document/fonts/Cinzel-Regular.ttf +0 -0
  11. package/dist/assets/document/fonts/OFL.txt +93 -0
  12. package/dist/assets/document/fonts/SOURCE.txt +11 -0
  13. package/dist/catalog/validate.js +3 -2
  14. package/dist/edge/docker-projects/state.js +5 -1
  15. package/dist/edge/host-cli.js +14 -0
  16. package/dist/edge/http/folder-location-schema.js +5 -8
  17. package/dist/edge/http/folder-location.js +10 -5
  18. package/dist/edge/http/host-cli.js +27 -2
  19. package/dist/edge/http/revision-files.js +5 -1
  20. package/dist/host-cli/open-folder.js +95 -0
  21. package/dist/host-cli/runtime.js +2 -0
  22. package/dist/kernel/db/migrate.js +25 -1
  23. package/dist/kernel/db/migrations/0014-document-stage.sql +44 -0
  24. package/dist/kernel/db/migrations/0015-description-prompts.sql +13 -0
  25. package/dist/kernel/pipeline.js +11 -1
  26. package/dist/kernel/ports/host-cli.js +14 -0
  27. package/dist/kernel/runner/graph.js +6 -1
  28. package/dist/main.js +6 -2
  29. package/dist/slices/admission/model.js +7 -1
  30. package/dist/slices/admission/rules.js +67 -3
  31. package/dist/slices/admission/schema.js +16 -3
  32. package/dist/slices/admission/start.js +3 -2
  33. package/dist/slices/document/blocks.js +129 -0
  34. package/dist/slices/document/flow.js +117 -0
  35. package/dist/slices/document/fonts.js +54 -0
  36. package/dist/slices/document/model.js +11 -0
  37. package/dist/slices/document/pages.js +198 -0
  38. package/dist/slices/document/render.js +242 -0
  39. package/dist/slices/document/sources.js +35 -0
  40. package/dist/slices/document/theme.js +158 -0
  41. package/dist/slices/document/writer.js +66 -0
  42. package/dist/slices/estimate/index.js +14 -2
  43. package/dist/slices/library/model.js +1 -1
  44. package/dist/slices/library/slots.js +4 -1
  45. package/dist/slices/play-drafts/convert.js +30 -1
  46. package/dist/slices/play-drafts/readiness.js +3 -2
  47. package/dist/slices/play-drafts/schema.js +22 -5
  48. package/dist/slices/project-templates/from-project.js +10 -0
  49. package/dist/slices/project-templates/setup.js +1 -0
  50. package/dist/slices/rebuild/preview-details.js +11 -1
  51. package/dist/slices/rebuild/recipe-audio.js +3 -0
  52. package/dist/slices/rebuild/recipe-build.js +6 -1
  53. package/dist/slices/rebuild/recipe-document.js +36 -0
  54. package/dist/slices/rebuild/recipe-exports.js +14 -2
  55. package/dist/slices/rebuild/recipe-fixture.js +4 -0
  56. package/dist/slices/rebuild/recipe-legacy.js +5 -0
  57. package/dist/slices/rebuild/recipe-model.js +2 -0
  58. package/dist/slices/rebuild/recipe-provider-choice.js +3 -0
  59. package/dist/slices/rebuild/recipe-save.js +15 -3
  60. package/dist/slices/rebuild/recipe-validation.js +15 -2
  61. package/dist/slices/rebuild/recipe-visual.js +10 -1
  62. package/dist/slices/rebuild/recipe-work.js +15 -0
  63. package/dist/slices/rebuild/recipe-youtube.js +33 -0
  64. package/dist/slices/rebuild/recovery-selection.js +2 -0
  65. package/dist/slices/rebuild/recovery.js +10 -5
  66. package/dist/slices/rebuild/runtime-actions.js +2 -0
  67. package/dist/slices/rebuild/runtime-document.js +85 -0
  68. package/dist/slices/rebuild/runtime-export-inputs.js +1 -0
  69. package/dist/slices/rebuild/runtime-export.js +35 -19
  70. package/dist/slices/rebuild/runtime-piece-label.js +4 -0
  71. package/dist/slices/rebuild/runtime-publication.js +1 -0
  72. package/dist/slices/rebuild/runtime-run.js +11 -3
  73. package/dist/slices/rebuild/runtime-store.js +7 -2
  74. package/dist/slices/rebuild/runtime-subtitles.js +6 -3
  75. package/dist/slices/rebuild/runtime-youtube.js +91 -0
  76. package/dist/slices/rebuild/transition-repo.js +2 -0
  77. package/dist/slices/reruns/cascade.js +6 -4
  78. package/dist/slices/revisions/mutation-cues.js +3 -1
  79. package/dist/slices/revisions/projection.js +2 -1
  80. package/dist/slices/storage/downloads.js +1 -0
  81. package/dist/slices/storage/layout.js +6 -0
  82. package/dist/slices/storage/model.js +3 -0
  83. package/dist/slices/subtitles/prepare.js +2 -1
  84. package/dist/slices/telemetry/model.js +4 -1
  85. package/dist/slices/video/audio-export.js +11 -3
  86. package/dist/slices/video/edit-list.js +73 -0
  87. package/dist/slices/video/ffmpeg.js +123 -57
  88. package/dist/slices/video/motion.js +51 -0
  89. package/dist/slices/video/plan.js +61 -27
  90. package/dist/slices/video/reuse-audio.js +2 -0
  91. package/dist/slices/video/run.js +21 -10
  92. package/dist/slices/video/slideshow.js +48 -0
  93. package/dist/slices/video/write-export.js +17 -25
  94. package/dist/slices/youtube/answer.js +199 -0
  95. package/dist/slices/youtube/model.js +27 -0
  96. package/dist/slices/youtube/timestamps.js +24 -0
  97. package/dist/slices/youtube/transcript.js +47 -0
  98. package/dist/web/assets/index-B6NbhpjD.css +1 -0
  99. package/dist/web/assets/index-DV01Cyjx.js +142 -0
  100. package/dist/web/index.html +2 -2
  101. package/package.json +4 -2
  102. package/dist/web/assets/index-Bwtmu9ei.css +0 -1
  103. package/dist/web/assets/index-DjhMJqMN.js +0 -141
@@ -0,0 +1,198 @@
1
+ import { useFace } from "./fonts.js";
2
+ const dateFormat = new Intl.DateTimeFormat("en-US", {
3
+ year: "numeric",
4
+ month: "long",
5
+ day: "numeric",
6
+ });
7
+ export function writtenOn(date) {
8
+ return dateFormat.format(date);
9
+ }
10
+ // Brand, tagline, title, the cover when there is one, then the date, word count and link.
11
+ export function titlePage(w, title, words, date, cover) {
12
+ const { theme, doc } = w;
13
+ const { fonts, sizes, titlePage: layout, brand } = theme;
14
+ const centre = w.width / 2;
15
+ if (brand.name !== null) {
16
+ w.write(brand.name, centre, layout.brandY, fonts.dramatic, sizes.brand, "heading", "center");
17
+ if (brand.url !== null) {
18
+ const width = doc.getTextWidth(brand.name);
19
+ doc.link(centre - width / 2, layout.brandY - 8, width, 10, { url: brand.url });
20
+ }
21
+ }
22
+ if (brand.tagline !== null)
23
+ w.write(brand.tagline, centre, layout.brandY + layout.taglineOffset, fonts.decorative, sizes.body, "heading", "center");
24
+ useFace(doc, fonts.heading, sizes.title);
25
+ let y = layout.titleY;
26
+ for (const line of doc.splitTextToSize(title, w.contentWidth)) {
27
+ w.write(line, centre, y, fonts.heading, sizes.title, "text", "center");
28
+ y += layout.titleLine;
29
+ }
30
+ // Without a cover the details sit where lore2script2 put them; with one, just under it.
31
+ let meta = y + layout.metaOffset;
32
+ if (cover !== null && layout.cover.enabled) {
33
+ const top = y - layout.titleLine + layout.cover.gap;
34
+ const below = layout.cover.gap + layout.metaLine * 3;
35
+ const room = Math.min(layout.cover.maxHeight, w.bottom - below - top);
36
+ const { width, height } = doc.getImageProperties(cover.data);
37
+ if (room > 0 && width > 0 && height > 0) {
38
+ const scale = Math.min(w.contentWidth / width, room / height);
39
+ doc.addImage(cover.data, cover.format, centre - (width * scale) / 2, top, width * scale, height * scale);
40
+ meta = top + height * scale + layout.cover.gap + layout.metaLine;
41
+ }
42
+ }
43
+ const line = (text, color, decorative = false) => {
44
+ w.write(text, centre, meta, decorative ? fonts.decorative : fonts.body, sizes.meta, color, "center");
45
+ meta += layout.metaLine;
46
+ };
47
+ if (layout.showDate)
48
+ line(`Written on ${writtenOn(date)}`, "muted");
49
+ if (layout.showWordCount)
50
+ line(`Word Count: ${words.toLocaleString("en-US")}`, "muted");
51
+ if (brand.linkLabel !== null) {
52
+ const at = meta;
53
+ line(brand.linkLabel, "heading", true);
54
+ if (brand.url !== null) {
55
+ const width = doc.getTextWidth(brand.linkLabel);
56
+ doc.link(centre - width / 2, at - 8, width, 10, { url: brand.url });
57
+ }
58
+ }
59
+ }
60
+ // How many pages the contents need for `entries` one-line entries, laid out exactly as
61
+ // fillContents lays them out, so they can be kept free before the body is written.
62
+ export function contentsPages(w, entries) {
63
+ const { contents } = w.theme;
64
+ if (!contents.enabled || entries === 0)
65
+ return 0;
66
+ let pages = 1;
67
+ let y = w.left + contents.firstEntryOffset;
68
+ for (let entry = 0; entry < entries; entry += 1) {
69
+ if (y + contents.line > w.bottom) {
70
+ pages += 1;
71
+ y = w.theme.header.top + contents.titleOffset;
72
+ }
73
+ y += contents.line;
74
+ }
75
+ return pages;
76
+ }
77
+ export function fillContents(w, first, entries) {
78
+ const { doc, theme } = w;
79
+ const { contents, fonts, sizes } = theme;
80
+ if (!contents.enabled || entries.length === 0)
81
+ return;
82
+ let page = first;
83
+ doc.setPage(page);
84
+ w.write(contents.title, w.width / 2, w.left + contents.titleOffset, fonts.heading, sizes.section, "heading", "center");
85
+ const top = Math.min(...entries.map((entry) => entry.level));
86
+ let y = w.left + contents.firstEntryOffset;
87
+ for (const entry of entries) {
88
+ if (y + contents.line > w.bottom) {
89
+ page += 1;
90
+ doc.setPage(page);
91
+ y = theme.header.top + contents.titleOffset;
92
+ }
93
+ const indent = (entry.level - top) * contents.indent;
94
+ const number = String(entry.page);
95
+ useFace(doc, fonts.body, sizes.body);
96
+ const numberX = w.width - w.left - doc.getTextWidth(number);
97
+ const room = numberX - (w.left + indent) - 10;
98
+ let label = entry.title;
99
+ if (doc.getTextWidth(label) > room) {
100
+ while (label.length > 1 && doc.getTextWidth(`${label}...`) > room)
101
+ label = label.slice(0, -1);
102
+ label = `${label.trimEnd()}...`;
103
+ }
104
+ w.write(label, w.left + indent, y, fonts.body, sizes.body, "text");
105
+ const labelEnd = w.left + indent + doc.getTextWidth(label) + 3;
106
+ w.write(number, numberX, y, fonts.body, sizes.body, "text");
107
+ doc.link(w.left, y - 6, w.contentWidth, 8, { pageNumber: entry.page });
108
+ const dot = doc.getTextWidth(".") * 1.5;
109
+ for (let x = labelEnd; x + dot < numberX - 3; x += dot)
110
+ w.write(".", x, y, fonts.body, sizes.body, "muted");
111
+ y += contents.line;
112
+ }
113
+ }
114
+ export function sourcesPage(w, items) {
115
+ const { doc, theme } = w;
116
+ const { fonts, sizes, sources } = theme;
117
+ if (!sources.enabled || items.length === 0)
118
+ return null;
119
+ w.addPage();
120
+ w.write(sources.title, w.left, w.left + sources.titleOffset, fonts.heading, sizes.section, "heading");
121
+ const entry = { title: sources.title, level: 1, page: w.page() };
122
+ w.y = w.left + sources.bodyOffset;
123
+ useFace(doc, fonts.body, sizes.body);
124
+ const bullet = doc.getTextWidth("• ");
125
+ for (const item of items) {
126
+ useFace(doc, fonts.body, sizes.body);
127
+ const lines = doc.splitTextToSize(item.text, w.contentWidth - bullet - 5);
128
+ w.ensure(sources.line);
129
+ w.write("•", w.left, w.y, fonts.body, sizes.body, "muted");
130
+ for (const line of lines) {
131
+ w.ensure(sources.line);
132
+ w.write(line, w.left + bullet, w.y, fonts.body, sizes.body, item.href === null ? "text" : "heading");
133
+ if (item.href !== null)
134
+ doc.link(w.left + bullet, w.y - 5, doc.getTextWidth(line), 7, { url: item.href });
135
+ w.y += sources.line;
136
+ }
137
+ w.y += sources.gap;
138
+ }
139
+ return entry;
140
+ }
141
+ // The closing page: the theme's lines, then the document's details and the call to action.
142
+ export function endPage(w, words, date) {
143
+ const { doc, theme } = w;
144
+ const { fonts, sizes, endPage: end } = theme;
145
+ if (!end.enabled)
146
+ return null;
147
+ w.addPage();
148
+ w.write(end.title, w.left, w.left + end.titleOffset, fonts.heading, sizes.section, "heading");
149
+ const entry = { title: end.title, level: 1, page: w.page() };
150
+ const lines = [...end.lines];
151
+ if (end.showDocumentDetails) {
152
+ if (lines.length > 0)
153
+ lines.push("");
154
+ lines.push("Document Details:", `• Written On: ${writtenOn(date)}`, `• Total Words: ${words.toLocaleString("en-US")}`, `• Total Pages: ${doc.getNumberOfPages()}`);
155
+ }
156
+ const line = theme.spacing.bodyLine;
157
+ let y = w.left + end.bodyOffset;
158
+ for (const text of lines) {
159
+ if (text.startsWith("•"))
160
+ w.write(text, w.left + 5, y, fonts.body, sizes.body, "muted");
161
+ else if (text.endsWith(":"))
162
+ w.write(text, w.left, y, fonts.strong, sizes.body, "text");
163
+ else if (text !== "")
164
+ w.write(text, w.left, y, fonts.body, sizes.body, "text");
165
+ y += line;
166
+ }
167
+ const calls = [end.link?.text, end.closing].filter((text) => Boolean(text));
168
+ if (calls.length > 0)
169
+ y += line;
170
+ for (const text of calls) {
171
+ w.write(text, w.left, y, fonts.decorative, sizes.body, "heading");
172
+ if (text === end.link?.text && end.link.url !== null)
173
+ doc.link(w.left, y - 6, doc.getTextWidth(text), 8, { url: end.link.url });
174
+ y += line;
175
+ }
176
+ return entry;
177
+ }
178
+ // The running header ("Brand | Title") and page number on every page after the title page.
179
+ export function headsAndFeet(w, title) {
180
+ const { doc, theme } = w;
181
+ const { fonts, sizes, header, footer, brand } = theme;
182
+ for (let page = 2; page <= doc.getNumberOfPages(); page += 1) {
183
+ doc.setPage(page);
184
+ if (header.enabled) {
185
+ useFace(doc, fonts.body, sizes.footer);
186
+ const shown = doc.getTextWidth(title) > w.contentWidth - 40
187
+ ? `${title.slice(0, header.maxTitleCharacters)}...`
188
+ : title;
189
+ const text = [brand.name, shown].filter(Boolean).join(" | ");
190
+ if (text !== "")
191
+ w.write(text, w.left, header.top, fonts.body, sizes.footer, "faint");
192
+ if (brand.name !== null && brand.url !== null)
193
+ doc.link(w.left, header.top - 6, doc.getTextWidth(brand.name), 8, { url: brand.url });
194
+ }
195
+ if (footer.enabled)
196
+ w.write(footer.text.replaceAll("{page}", String(page)), w.width / 2, w.height - footer.bottom, fonts.footer, sizes.footer, "faint", "center");
197
+ }
198
+ }
@@ -0,0 +1,242 @@
1
+ import { jsPDF } from "jspdf";
2
+ import { runsText } from "./blocks.js";
3
+ import { drawLine, wrapRuns } from "./flow.js";
4
+ import { faceWidth, registerFonts, useFace } from "./fonts.js";
5
+ import { contentsPages, endPage, fillContents, headsAndFeet, sourcesPage, titlePage, } from "./pages.js";
6
+ import { documentText } from "./sources.js";
7
+ import { createWriter } from "./writer.js";
8
+ export function renderDocument(input) {
9
+ const { theme } = input;
10
+ const text = documentText(input.articleMarkdown, input.researchNotes);
11
+ const blocks = withoutTitle(text.blocks, input.title);
12
+ if (blocks.length === 0)
13
+ throw new Error("The article has no text to put in the document. Write or regenerate the article (Edit project → Article), then use Retry stage on Document.");
14
+ const words = blocks
15
+ .map((block) => block.kind === "heading" ? block.text : "runs" in block ? runsText(block.runs) : "")
16
+ .join(" ")
17
+ .split(/\s+/)
18
+ .filter((word) => word !== "").length;
19
+ const doc = new jsPDF({
20
+ orientation: "portrait",
21
+ unit: "mm",
22
+ format: theme.page.format,
23
+ compress: true,
24
+ });
25
+ registerFonts(doc, input.assets.cinzel);
26
+ const fill = (template) => template.replaceAll("{title}", input.title).trim();
27
+ doc.setProperties({
28
+ title: input.title,
29
+ author: fill(theme.metadata.author),
30
+ subject: fill(theme.metadata.subject),
31
+ keywords: fill(theme.metadata.keywords),
32
+ creator: fill(theme.metadata.creator),
33
+ });
34
+ const w = createWriter(doc, theme, input.assets);
35
+ const cover = coverImage(input.cover);
36
+ titlePage(w, input.title, words, input.writtenOn, cover === "unsupported" ? null : cover);
37
+ const listed = blocks.filter((block) => block.kind === "heading" && block.level <= theme.contents.depth).length;
38
+ const extra = (theme.sources.enabled && text.sources.length > 0 ? 1 : 0) + (theme.endPage.enabled ? 1 : 0);
39
+ const reserved = contentsPages(w, listed + extra);
40
+ for (let page = 0; page < reserved; page += 1)
41
+ w.addPage();
42
+ w.addPage();
43
+ const contents = [];
44
+ writeBody(w, blocks, contents);
45
+ // The sources and closing pages line up with the article's outermost headings.
46
+ const level = Math.min(3, ...contents.map((entry) => entry.level));
47
+ for (const page of [sourcesPage(w, text.sources), endPage(w, words, input.writtenOn)])
48
+ if (page !== null)
49
+ contents.push({ ...page, level });
50
+ if (reserved > 0)
51
+ fillContents(w, 2, contents);
52
+ headsAndFeet(w, input.title);
53
+ return {
54
+ bytes: new Uint8Array(doc.output("arraybuffer")),
55
+ pages: doc.getNumberOfPages(),
56
+ words,
57
+ contents,
58
+ sources: text.sources.length,
59
+ cover: cover === null ? "none" : cover === "unsupported" ? "unsupported" : "placed",
60
+ };
61
+ }
62
+ // The title page already carries the title, so an article that opens by repeating it
63
+ // starts with its first real section instead.
64
+ function withoutTitle(blocks, title) {
65
+ const at = blocks
66
+ .slice(0, 3)
67
+ .findIndex((block) => block.kind === "heading" && block.text.trim().toLowerCase() === title.trim().toLowerCase());
68
+ return at === -1 ? blocks : blocks.filter((_, index) => index !== at);
69
+ }
70
+ function coverImage(data) {
71
+ if (data === null)
72
+ return null;
73
+ const bytes = (...values) => values.every((value, index) => data[index] === value);
74
+ if (bytes(0x89, 0x50, 0x4e, 0x47))
75
+ return { data, format: "PNG" };
76
+ if (bytes(0xff, 0xd8, 0xff))
77
+ return { data, format: "JPEG" };
78
+ if (bytes(0x52, 0x49, 0x46, 0x46) && String.fromCharCode(...data.subarray(8, 12)) === "WEBP")
79
+ return { data, format: "WEBP" };
80
+ return "unsupported";
81
+ }
82
+ function writeBody(w, blocks, contents) {
83
+ const { theme } = w;
84
+ const line = theme.spacing.bodyLine;
85
+ const style = {
86
+ size: theme.sizes.body,
87
+ body: theme.fonts.body,
88
+ strong: theme.fonts.strong,
89
+ emphasis: theme.fonts.emphasis,
90
+ color: w.colors.text,
91
+ linkColor: w.colors.heading,
92
+ };
93
+ let afterHeading = false;
94
+ for (const [index, block] of blocks.entries()) {
95
+ if (block.kind === "heading") {
96
+ // A heading keeps the start of what follows it on its page: two lines, or all the
97
+ // room a drop-capped paragraph asks for, so the heading is never left at the foot.
98
+ const next = blocks[index + 1];
99
+ const keep = next?.kind === "paragraph" && dropCapLetter(theme, next.runs) !== null
100
+ ? dropCapRoom(w)
101
+ : theme.spacing.bodyLine * 2;
102
+ const entry = writeHeading(w, block, keep);
103
+ if (block.level <= theme.contents.depth)
104
+ contents.push(entry);
105
+ afterHeading = true;
106
+ continue;
107
+ }
108
+ if (block.kind === "rule") {
109
+ w.ensure(line);
110
+ const rgb = w.colors.muted;
111
+ w.doc.setDrawColor(rgb.r, rgb.g, rgb.b);
112
+ w.doc.setLineWidth(0.3);
113
+ const half = theme.spacing.ruleWidth / 2;
114
+ w.doc.line(w.width / 2 - half, w.y - line / 3, w.width / 2 + half, w.y - line / 3);
115
+ w.y += line;
116
+ continue;
117
+ }
118
+ if (block.kind === "paragraph") {
119
+ const cap = afterHeading ? dropCapLetter(theme, block.runs) : null;
120
+ if (cap === null)
121
+ writeLines(w, block.runs, style, 0, w.contentWidth);
122
+ else
123
+ writeDropCap(w, block.runs, style, cap);
124
+ w.y += line * theme.spacing.paragraphGap;
125
+ }
126
+ else if (block.kind === "item") {
127
+ const indent = block.depth * theme.spacing.listIndent;
128
+ w.ensure(line);
129
+ if (block.marker !== "")
130
+ w.write(block.marker, w.left + indent, w.y, theme.fonts.body, theme.sizes.body, "muted");
131
+ const text = indent + theme.spacing.listIndent;
132
+ writeLines(w, block.runs, style, text, w.contentWidth - text);
133
+ w.y += line * theme.spacing.itemGap;
134
+ }
135
+ else {
136
+ const indent = theme.spacing.quoteIndent;
137
+ const quote = { ...style, color: w.colors.muted };
138
+ const start = { page: w.page(), y: w.y };
139
+ writeLines(w, block.runs, quote, indent, w.contentWidth - indent * 2);
140
+ if (w.page() === start.page) {
141
+ const rgb = w.colors.heading;
142
+ w.doc.setDrawColor(rgb.r, rgb.g, rgb.b);
143
+ w.doc.setLineWidth(0.6);
144
+ const x = w.left + indent / 2;
145
+ w.doc.line(x, start.y - line * 0.6, x, w.y - line * 0.8);
146
+ }
147
+ w.y += line * theme.spacing.paragraphGap;
148
+ }
149
+ afterHeading = false;
150
+ }
151
+ }
152
+ function writeLines(w, runs, style, indent, width) {
153
+ const lines = wrapRuns(w.doc, runs, style, () => ({ indent, width }));
154
+ for (const one of lines) {
155
+ w.ensure(w.theme.spacing.bodyLine);
156
+ drawLine(w.doc, one, w.left, w.y, style.size);
157
+ w.y += w.theme.spacing.bodyLine;
158
+ }
159
+ }
160
+ const chapterHeading = /^(Chapter|Section|Part)\s+(\d+):?\s*([\s\S]*)$/i;
161
+ // Level 1 and 2 are centred in the heading face at the section and heading sizes; a
162
+ // "Chapter 3: Title – Subtitle" heading shows its number large with each part of the title
163
+ // under it. Level 3 sits on the left, smaller.
164
+ function writeHeading(w, block, keep) {
165
+ const { theme, doc } = w;
166
+ const { fonts, sizes, spacing } = theme;
167
+ if (w.y > theme.page.contentTop)
168
+ w.y += spacing.bodyLine * spacing.headingGap;
169
+ const sub = block.level === 3;
170
+ const size = block.level === 1 ? sizes.section : sub ? sizes.subheading : sizes.heading;
171
+ const lineHeight = sub ? spacing.subheadingLine : spacing.headingLine;
172
+ const chapter = sub ? null : chapterHeading.exec(block.text);
173
+ const parts = chapter === null
174
+ ? [block.text]
175
+ : (chapter[3] ?? "")
176
+ .split(/\s+[–—]\s+/)
177
+ .map((part) => part.trim())
178
+ .filter((part) => part !== "");
179
+ useFace(doc, fonts.heading, size);
180
+ const lines = parts.flatMap((part) => doc.splitTextToSize(part, w.contentWidth - (chapter === null ? 0 : 20)));
181
+ // The heading and the first lines under it stay on one page.
182
+ const height = lines.length * lineHeight + (chapter === null ? 0 : lineHeight * 1.5);
183
+ w.ensure(height + spacing.bodyLine * spacing.paragraphGap + keep);
184
+ const entry = { title: block.text, level: block.level, page: w.page() };
185
+ if (chapter !== null) {
186
+ w.write(chapter[2] ?? "", w.width / 2, w.y, fonts.dramatic, sizes.section, "heading", "center");
187
+ w.y += lineHeight * 1.5;
188
+ }
189
+ for (const text of lines) {
190
+ if (sub)
191
+ w.write(text, w.left, w.y, fonts.heading, size, "heading");
192
+ else
193
+ w.write(text, w.width / 2, w.y, fonts.heading, size, "heading", "center");
194
+ w.y += lineHeight;
195
+ }
196
+ w.y += spacing.bodyLine * spacing.paragraphGap;
197
+ return entry;
198
+ }
199
+ function dropCapLetter(theme, runs) {
200
+ const first = runs[0];
201
+ if (!theme.dropCap.enabled || first === undefined || first.href !== null)
202
+ return null;
203
+ if (runsText(runs).length < theme.dropCap.minLength)
204
+ return null;
205
+ const letter = [...first.text][0] ?? "";
206
+ return /\p{L}/u.test(letter) ? letter : null;
207
+ }
208
+ // The first letter set large across `lines` body lines, the text beside it narrowed, then
209
+ // full width below it.
210
+ function writeDropCap(w, runs, style, letter) {
211
+ const { theme, doc } = w;
212
+ const { dropCap } = theme;
213
+ const line = theme.spacing.bodyLine;
214
+ w.ensure(dropCapRoom(w));
215
+ const size = line * dropCap.lines * dropCap.scale;
216
+ const capital = letter.toUpperCase();
217
+ const capWidth = faceWidth(doc, theme.fonts.dropCap, size, capital) + dropCap.gap;
218
+ w.write(capital, w.left, w.y + line * (dropCap.lines - 1), theme.fonts.dropCap, size, "heading");
219
+ const [first, ...rest] = runs;
220
+ const remaining = first === undefined ? [] : [{ ...first, text: [...first.text].slice(1).join("") }, ...rest];
221
+ const top = w.y;
222
+ const lines = wrapRuns(doc, remaining, style, (index) => index < dropCap.lines
223
+ ? { indent: capWidth, width: w.contentWidth - capWidth }
224
+ : { indent: 0, width: w.contentWidth });
225
+ for (const [index, one] of lines.entries()) {
226
+ if (index >= dropCap.lines)
227
+ w.ensure(line);
228
+ drawLine(doc, one, w.left, w.y, style.size);
229
+ w.y += line;
230
+ }
231
+ // Text shorter than the initial still leaves its full height free.
232
+ w.y = Math.max(w.y, top + line * dropCap.lines);
233
+ }
234
+ // The room a drop-capped paragraph needs left on the page: the theme's share of it, and at
235
+ // least the initial itself, which reaches about `lines * scale` body lines below its first
236
+ // baseline.
237
+ function dropCapRoom(w) {
238
+ const { theme } = w;
239
+ const { dropCap } = theme;
240
+ const usable = w.height - theme.page.margin - theme.header.top - theme.footer.bottom - theme.footer.reserve;
241
+ return Math.max(usable * dropCap.minRoom, theme.spacing.bodyLine * dropCap.lines * dropCap.scale);
242
+ }
@@ -0,0 +1,35 @@
1
+ import { splitEndMatter } from "../article/split.js";
2
+ import { markdownBlocks, markdownLinks, runsText } from "./blocks.js";
3
+ // The pronunciation glossary is narration's business and is left out; the sources section
4
+ // becomes the Sources page instead of being printed as body text.
5
+ export function documentText(articleMarkdown, researchNotes) {
6
+ const parts = splitEndMatter(articleMarkdown);
7
+ const listed = sourceItems(parts.sources);
8
+ const seen = new Set(listed.flatMap((item) => (item.href === null ? [] : [item.href])));
9
+ const cited = [];
10
+ for (const link of researchNotes === null ? [] : markdownLinks(researchNotes)) {
11
+ if (seen.has(link.href) || !/^https?:\/\//i.test(link.href))
12
+ continue;
13
+ seen.add(link.href);
14
+ cited.push(link.text === link.href ? { text: link.href, href: link.href } : link);
15
+ }
16
+ return { blocks: markdownBlocks(parts.body), sources: [...listed, ...cited] };
17
+ }
18
+ function sourceItems(section) {
19
+ if (section.trim() === "")
20
+ return [];
21
+ // The section's own heading, whether a `#` line or a bold paragraph, is its first block.
22
+ return markdownBlocks(section)
23
+ .slice(1)
24
+ .flatMap((block) => {
25
+ if (block.kind !== "item" && block.kind !== "paragraph" && block.kind !== "quote")
26
+ return [];
27
+ const text = runsText(block.runs).trim();
28
+ if (text === "")
29
+ return [];
30
+ const href = block.runs.find((run) => run.href !== null)?.href ??
31
+ /https?:\/\/[^\s)>\]]+/.exec(text)?.[0] ??
32
+ null;
33
+ return [{ text, href }];
34
+ });
35
+ }
@@ -0,0 +1,158 @@
1
+ import { defaultDocumentTheme } from "./model.js";
2
+ // "Cinzel" is bundled (assets/document/fonts) in the styles below; the other three are the
3
+ // PDF standard fonts every reader has.
4
+ export const fontFamilies = ["Cinzel", "times", "helvetica", "courier"];
5
+ export const fontStyles = ["normal", "medium", "bold", "black", "italic", "bolditalic"];
6
+ const cinzel = (style, letterSpacing) => ({
7
+ family: "Cinzel",
8
+ style,
9
+ letterSpacing,
10
+ });
11
+ const dicemaster = {
12
+ page: { format: "a4", margin: 22, contentTop: 32 },
13
+ background: { image: "parchment", color: "#f5ebd7" },
14
+ colors: { heading: "#8c1e14", text: "#000000", muted: "#50463c", faint: "#786e5f" },
15
+ fonts: {
16
+ body: cinzel("normal", 0.01),
17
+ strong: cinzel("bold", 0.02),
18
+ // Cinzel has no italic, so emphasis borrows the standard serif's.
19
+ emphasis: { family: "times", style: "italic", letterSpacing: 0 },
20
+ heading: cinzel("bold", 0.02),
21
+ dramatic: cinzel("black", 0.03),
22
+ decorative: cinzel("medium", 0.015),
23
+ dropCap: cinzel("black", 0),
24
+ footer: { family: "times", style: "normal", letterSpacing: 0 },
25
+ },
26
+ // lore2script2 scaled its Cinzel sizes and line heights (by 1.1, 1.05 and 1.2) and
27
+ // rounded; these are the results.
28
+ sizes: {
29
+ brand: 31,
30
+ title: 22,
31
+ section: 19,
32
+ heading: 17,
33
+ subheading: 13,
34
+ body: 10.5,
35
+ footer: 9,
36
+ meta: 10,
37
+ },
38
+ spacing: {
39
+ bodyLine: 8,
40
+ headingLine: 11,
41
+ subheadingLine: 9,
42
+ paragraphGap: 0.5,
43
+ headingGap: 1,
44
+ itemGap: 0.2,
45
+ listIndent: 6,
46
+ quoteIndent: 8,
47
+ ruleWidth: 30,
48
+ },
49
+ dropCap: { enabled: true, lines: 3, scale: 3, gap: 4, minLength: 50, minRoom: 0.25 },
50
+ titlePage: {
51
+ brandY: 60,
52
+ taglineOffset: 10,
53
+ titleY: 80,
54
+ titleLine: 10,
55
+ metaOffset: 20,
56
+ metaLine: 10,
57
+ showDate: true,
58
+ showWordCount: true,
59
+ cover: { enabled: true, gap: 6, maxHeight: 110 },
60
+ },
61
+ contents: {
62
+ enabled: true,
63
+ title: "Table of Contents",
64
+ depth: 2,
65
+ titleOffset: 20,
66
+ firstEntryOffset: 40,
67
+ line: 8,
68
+ indent: 5,
69
+ },
70
+ header: { enabled: true, top: 15, maxTitleCharacters: 35 },
71
+ footer: { enabled: true, bottom: 10, reserve: 15, text: "Page {page}" },
72
+ brand: {
73
+ name: "DiceMaster.io",
74
+ url: "https://dicemaster.io/",
75
+ tagline: "Let us Handle the Crunch, You Focus on the Story",
76
+ linkLabel: "Visit DiceMaster.io",
77
+ },
78
+ metadata: {
79
+ author: "Created with DiceMaster.io",
80
+ subject: "{title} - Powered by DiceMaster.io",
81
+ keywords: "D&D, DnD, Dungeons and Dragons, lore, script, tabletop, RPG, TTRPG, DiceMaster.io",
82
+ creator: "DiceMaster.io - The AI-Powered Virtual Tabletop",
83
+ },
84
+ sources: {
85
+ enabled: true,
86
+ title: "Sources Consulted",
87
+ titleOffset: 20,
88
+ bodyOffset: 40,
89
+ line: 8,
90
+ gap: 2,
91
+ },
92
+ endPage: {
93
+ enabled: true,
94
+ title: "About DiceMaster.io",
95
+ titleOffset: 20,
96
+ bodyOffset: 40,
97
+ lines: [
98
+ "About DiceMaster.io:",
99
+ "• Character Engine - Form-driven creation with automatic leveling",
100
+ "• Automated Rules Engine - D&D mechanics and spells run automatically",
101
+ "• AI-Generated NPCs - Instant personalities with full stat blocks",
102
+ "• Granular Inventory Tracking - Live gear & condition management",
103
+ "• Real-Time Rule Guidance - Context-aware clarifications",
104
+ "• Dynamic Storytelling AI - On-the-fly narration & dialogue",
105
+ "• True-Physics Dice - RNG-driven rolls with animated suspense",
106
+ "• Cinematic Combat Module - Positional tracking & narrative summaries",
107
+ "• Voice Commands - Seamless STT/TTS multimodal interaction",
108
+ "• AI Dungeon Master Layer - Rules adjudication & encounter balancing",
109
+ ],
110
+ showDocumentDetails: true,
111
+ link: { text: "Visit DiceMaster.io", url: "https://dicemaster.io/" },
112
+ closing: "Experience the future of tabletop RPGs!",
113
+ },
114
+ };
115
+ const plain = {
116
+ background: { image: null, color: "#fdfaf3" },
117
+ colors: { heading: "#1f3a5f", text: "#1a1a1a", muted: "#555555", faint: "#888888" },
118
+ brand: { name: null, url: null, tagline: null, linkLabel: null },
119
+ metadata: { author: "", subject: "{title}", keywords: "D&D, lore", creator: "" },
120
+ endPage: { enabled: false },
121
+ };
122
+ export function builtInTheme(name) {
123
+ return name === "plain" ? resolveTheme(plain) : resolveTheme();
124
+ }
125
+ export function defaultTheme() {
126
+ return builtInTheme(defaultDocumentTheme);
127
+ }
128
+ // Unknown sections and keys throw, so a typo in a saved theme can't pass silently.
129
+ export function resolveTheme(overrides = {}, base = dicemaster) {
130
+ const theme = structuredClone(base);
131
+ for (const [section, values] of Object.entries(overrides)) {
132
+ const target = theme[section];
133
+ if (target === undefined)
134
+ throw new Error(`Unknown document theme section "${section}" (expected one of: ${Object.keys(theme).join(", ")})`);
135
+ for (const [key, value] of Object.entries(values ?? {})) {
136
+ if (!(key in target))
137
+ throw new Error(`Unknown document theme setting "${section}.${key}" (expected one of: ${Object.keys(target).join(", ")})`);
138
+ target[key] = value;
139
+ }
140
+ }
141
+ const resolved = theme;
142
+ for (const [key, value] of Object.entries(resolved.colors))
143
+ parseHexColor(value, `colors.${key}`);
144
+ parseHexColor(resolved.background.color, "background.color");
145
+ return resolved;
146
+ }
147
+ export function parseHexColor(hex, label = "color") {
148
+ const match = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
149
+ const digits = match?.[1];
150
+ if (digits === undefined)
151
+ throw new Error(`Invalid document theme ${label} "${hex}": expected a hex color like "#8c1e14"`);
152
+ const full = digits.length === 3 ? [...digits].map((digit) => digit + digit).join("") : digits;
153
+ return {
154
+ r: Number.parseInt(full.slice(0, 2), 16),
155
+ g: Number.parseInt(full.slice(2, 4), 16),
156
+ b: Number.parseInt(full.slice(4, 6), 16),
157
+ };
158
+ }