postext-epub 0.1.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.
Files changed (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +194 -0
  3. package/dist/__tests__/sampleBook.d.ts +17 -0
  4. package/dist/__tests__/sampleBook.d.ts.map +1 -0
  5. package/dist/__tests__/sampleBook.js +103 -0
  6. package/dist/__tests__/sampleBook.js.map +1 -0
  7. package/dist/epubcheck.test.d.ts +2 -0
  8. package/dist/epubcheck.test.d.ts.map +1 -0
  9. package/dist/epubcheck.test.js +69 -0
  10. package/dist/epubcheck.test.js.map +1 -0
  11. package/dist/fixed/fixed.test.d.ts +2 -0
  12. package/dist/fixed/fixed.test.d.ts.map +1 -0
  13. package/dist/fixed/fixed.test.js +225 -0
  14. package/dist/fixed/fixed.test.js.map +1 -0
  15. package/dist/fixed/fontUse.d.ts +21 -0
  16. package/dist/fixed/fontUse.d.ts.map +1 -0
  17. package/dist/fixed/fontUse.js +82 -0
  18. package/dist/fixed/fontUse.js.map +1 -0
  19. package/dist/fixed/index.d.ts +3 -0
  20. package/dist/fixed/index.d.ts.map +1 -0
  21. package/dist/fixed/index.js +254 -0
  22. package/dist/fixed/index.js.map +1 -0
  23. package/dist/fixed/outline.d.ts +40 -0
  24. package/dist/fixed/outline.d.ts.map +1 -0
  25. package/dist/fixed/outline.js +88 -0
  26. package/dist/fixed/outline.js.map +1 -0
  27. package/dist/index.d.ts +17 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +29 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/package/pack.d.ts +25 -0
  32. package/dist/package/pack.d.ts.map +1 -0
  33. package/dist/package/pack.js +253 -0
  34. package/dist/package/pack.js.map +1 -0
  35. package/dist/package/pack.test.d.ts +2 -0
  36. package/dist/package/pack.test.d.ts.map +1 -0
  37. package/dist/package/pack.test.js +164 -0
  38. package/dist/package/pack.test.js.map +1 -0
  39. package/dist/package/read.d.ts +15 -0
  40. package/dist/package/read.d.ts.map +1 -0
  41. package/dist/package/read.js +198 -0
  42. package/dist/package/read.js.map +1 -0
  43. package/dist/package/strings.d.ts +17 -0
  44. package/dist/package/strings.d.ts.map +1 -0
  45. package/dist/package/strings.js +24 -0
  46. package/dist/package/strings.js.map +1 -0
  47. package/dist/reflowable/__tests__/vdt.d.ts +13 -0
  48. package/dist/reflowable/__tests__/vdt.d.ts.map +1 -0
  49. package/dist/reflowable/__tests__/vdt.js +55 -0
  50. package/dist/reflowable/__tests__/vdt.js.map +1 -0
  51. package/dist/reflowable/__tests__/xml.d.ts +8 -0
  52. package/dist/reflowable/__tests__/xml.d.ts.map +1 -0
  53. package/dist/reflowable/__tests__/xml.js +107 -0
  54. package/dist/reflowable/__tests__/xml.js.map +1 -0
  55. package/dist/reflowable/css.d.ts +45 -0
  56. package/dist/reflowable/css.d.ts.map +1 -0
  57. package/dist/reflowable/css.js +491 -0
  58. package/dist/reflowable/css.js.map +1 -0
  59. package/dist/reflowable/index.d.ts +4 -0
  60. package/dist/reflowable/index.d.ts.map +1 -0
  61. package/dist/reflowable/index.js +257 -0
  62. package/dist/reflowable/index.js.map +1 -0
  63. package/dist/reflowable/inline.d.ts +56 -0
  64. package/dist/reflowable/inline.d.ts.map +1 -0
  65. package/dist/reflowable/inline.js +334 -0
  66. package/dist/reflowable/inline.js.map +1 -0
  67. package/dist/reflowable/model.d.ts +261 -0
  68. package/dist/reflowable/model.d.ts.map +1 -0
  69. package/dist/reflowable/model.js +6 -0
  70. package/dist/reflowable/model.js.map +1 -0
  71. package/dist/reflowable/reflowable.test.d.ts +2 -0
  72. package/dist/reflowable/reflowable.test.d.ts.map +1 -0
  73. package/dist/reflowable/reflowable.test.js +469 -0
  74. package/dist/reflowable/reflowable.test.js.map +1 -0
  75. package/dist/reflowable/rtl.test.d.ts +2 -0
  76. package/dist/reflowable/rtl.test.d.ts.map +1 -0
  77. package/dist/reflowable/rtl.test.js +217 -0
  78. package/dist/reflowable/rtl.test.js.map +1 -0
  79. package/dist/reflowable/walk.d.ts +42 -0
  80. package/dist/reflowable/walk.d.ts.map +1 -0
  81. package/dist/reflowable/walk.js +1096 -0
  82. package/dist/reflowable/walk.js.map +1 -0
  83. package/dist/reflowable/xhtml.d.ts +33 -0
  84. package/dist/reflowable/xhtml.d.ts.map +1 -0
  85. package/dist/reflowable/xhtml.js +472 -0
  86. package/dist/reflowable/xhtml.js.map +1 -0
  87. package/dist/shared/accessibility.d.ts +20 -0
  88. package/dist/shared/accessibility.d.ts.map +1 -0
  89. package/dist/shared/accessibility.js +30 -0
  90. package/dist/shared/accessibility.js.map +1 -0
  91. package/dist/shared/assets.d.ts +46 -0
  92. package/dist/shared/assets.d.ts.map +1 -0
  93. package/dist/shared/assets.js +143 -0
  94. package/dist/shared/assets.js.map +1 -0
  95. package/dist/shared/assets.test.d.ts +2 -0
  96. package/dist/shared/assets.test.d.ts.map +1 -0
  97. package/dist/shared/assets.test.js +106 -0
  98. package/dist/shared/assets.test.js.map +1 -0
  99. package/dist/shared/media.d.ts +11 -0
  100. package/dist/shared/media.d.ts.map +1 -0
  101. package/dist/shared/media.js +52 -0
  102. package/dist/shared/media.js.map +1 -0
  103. package/dist/shared/uuid.d.ts +7 -0
  104. package/dist/shared/uuid.d.ts.map +1 -0
  105. package/dist/shared/uuid.js +63 -0
  106. package/dist/shared/uuid.js.map +1 -0
  107. package/dist/shared/xml.d.ts +54 -0
  108. package/dist/shared/xml.d.ts.map +1 -0
  109. package/dist/shared/xml.js +214 -0
  110. package/dist/shared/xml.js.map +1 -0
  111. package/dist/shared/xml.test.d.ts +2 -0
  112. package/dist/shared/xml.test.d.ts.map +1 -0
  113. package/dist/shared/xml.test.js +71 -0
  114. package/dist/shared/xml.test.js.map +1 -0
  115. package/dist/shared/xmlRead.d.ts +19 -0
  116. package/dist/shared/xmlRead.d.ts.map +1 -0
  117. package/dist/shared/xmlRead.js +85 -0
  118. package/dist/shared/xmlRead.js.map +1 -0
  119. package/dist/types.d.ts +205 -0
  120. package/dist/types.d.ts.map +1 -0
  121. package/dist/types.js +15 -0
  122. package/dist/types.js.map +1 -0
  123. package/dist/worker/client.d.ts +39 -0
  124. package/dist/worker/client.d.ts.map +1 -0
  125. package/dist/worker/client.js +184 -0
  126. package/dist/worker/client.js.map +1 -0
  127. package/dist/worker/epub.worker.d.ts +2 -0
  128. package/dist/worker/epub.worker.d.ts.map +1 -0
  129. package/dist/worker/epub.worker.js +7 -0
  130. package/dist/worker/epub.worker.js.map +1 -0
  131. package/dist/worker/protocol.d.ts +44 -0
  132. package/dist/worker/protocol.d.ts.map +1 -0
  133. package/dist/worker/protocol.js +6 -0
  134. package/dist/worker/protocol.js.map +1 -0
  135. package/dist/worker/serve.d.ts +11 -0
  136. package/dist/worker/serve.d.ts.map +1 -0
  137. package/dist/worker/serve.js +53 -0
  138. package/dist/worker/serve.js.map +1 -0
  139. package/dist/worker/worker.test.d.ts +2 -0
  140. package/dist/worker/worker.test.d.ts.map +1 -0
  141. package/dist/worker/worker.test.js +168 -0
  142. package/dist/worker/worker.test.js.map +1 -0
  143. package/package.json +68 -0
@@ -0,0 +1,1096 @@
1
+ // The resolved print documents back to a semantic book, in reading order.
2
+ //
3
+ // Pages are read as the tagged PDF reads them (postext-pdf
4
+ // `structureFlow.ts`): column by column, then the page's floats, each moved
5
+ // to where it is read — after the text block with the highest content
6
+ // index up to its own. On the way:
7
+ // - fragments of a block split across columns or pages (continuations
8
+ // carry the head's id plus `-cont-N`) are joined into one paragraph;
9
+ // - consecutive list items become nested lists by `listDepth`;
10
+ // - blocks sharing a callout's `containerId` go into one box, nested
11
+ // boxes by `calloutPath`; other containers (`:::paragraphs`) only lend
12
+ // their paragraphs a style class;
13
+ // - headings take levels that never skip one;
14
+ // - footnote paragraphs (`footnoteNote`) leave the flow and become the
15
+ // notes of the content document that cites them;
16
+ // - each printed page leaves a page-break marker where its text starts;
17
+ // - a part (a divider page, or `partMarks` without one) opens a
18
+ // document of its own and the chapter goes on in a new one.
19
+ import { canonicalLocaleTag, dimensionToPx, primaryFontFamily } from 'postext';
20
+ import { appendLine, appendLines, fontPx, idOf, mathSvg, plainText } from './inline';
21
+ const fragmentKey = (block) => block.id.replace(/-cont-\d+$/, '');
22
+ const isTocRow = (block) => block.tocEntry !== undefined || block.tocPart !== undefined;
23
+ /** The source range of a block, as a key. */
24
+ const rangeKey = (block) => `${block.sourceStart ?? ''}:${block.sourceEnd ?? ''}`;
25
+ /** Whether a block links page numbers: an entry of the index. */
26
+ const hasPageLink = (block) => block.lines.some((l) => l.segments?.some((g) => g.pageLink !== undefined));
27
+ const lineKey = (page, x, y) => `${page}|${x.toFixed(2)}|${y.toFixed(2)}`;
28
+ /** The language a chapter document is written in. */
29
+ export function docLanguage(config, fallback) {
30
+ const h = config.bodyText.hyphenation;
31
+ return canonicalLocaleTag(config.locale) ?? canonicalLocaleTag(h.tag) ?? canonicalLocaleTag(fallback) ?? 'en';
32
+ }
33
+ export function walkBook(docs, options) {
34
+ const book = {
35
+ files: [],
36
+ headings: [],
37
+ anchors: new Map(),
38
+ resources: new Map(),
39
+ pages: new Map(),
40
+ notes: new Map(),
41
+ images: new Set(),
42
+ altText: false,
43
+ missingAlt: false,
44
+ maths: false,
45
+ verse: false,
46
+ };
47
+ const counters = { heading: 0, block: 0 };
48
+ docs.forEach((doc, i) => new DocWalker(book, doc, i, options, counters).walk());
49
+ return book;
50
+ }
51
+ class DocWalker {
52
+ book;
53
+ doc;
54
+ index;
55
+ counters;
56
+ offset;
57
+ lang;
58
+ dir;
59
+ config;
60
+ bodyPx;
61
+ /** Ids of the boxes (`:::callout`) of the document. */
62
+ calloutIds = new Set();
63
+ /** Inline anchors by the line they sit on. */
64
+ lineAnchors = new Map();
65
+ /** Source ranges of the `:::index` directives: every entry block a
66
+ * directive expands into carries its range, the entries with only a
67
+ * cross-reference (`See …`) too, which have no page link. */
68
+ indexRanges = new Set();
69
+ partMarks;
70
+ /** Whether the engine named the paragraph styles of the blocks
71
+ * (`VDTBlock.paragraphStyleId`, `VDTBlock.indexLevel`): an older
72
+ * document's are told from their faces (`paragraphStyleOf`). */
73
+ styleHints;
74
+ file;
75
+ root;
76
+ lastLevel = new Map();
77
+ lastContainer;
78
+ callouts = new Map();
79
+ sinks = new Map();
80
+ verses = new Map();
81
+ tables = new Map();
82
+ figures = new Set();
83
+ /** Text blocks read so far, with their content index: where floats go. */
84
+ flow = [];
85
+ placedAfter = new Map();
86
+ notes = new Map();
87
+ seenNotes = new Set();
88
+ pendingPages = [];
89
+ floating = false;
90
+ partKey;
91
+ /** The palette overrides of the part in force. */
92
+ palette;
93
+ fileCount = 0;
94
+ constructor(book, doc, index, options, counters) {
95
+ this.book = book;
96
+ this.doc = doc;
97
+ this.index = index;
98
+ this.counters = counters;
99
+ this.offset = doc.pageIndexOffset ?? 0;
100
+ this.config = doc.config;
101
+ this.lang = docLanguage(doc.config, options.language);
102
+ if (doc.config.direction === 'rtl')
103
+ this.dir = 'rtl';
104
+ this.bodyPx = dimensionToPx(doc.config.bodyText.fontSize, doc.config.page.dpi);
105
+ this.partMarks = [...(doc.partMarks ?? [])].sort((a, b) => a.afterContentIndex - b.afterContentIndex);
106
+ this.palette = doc.partStart?.palette;
107
+ const frames = [...doc.blocks, ...doc.pages.flatMap((p) => p.floats ?? [])].filter((b) => b.type === 'callout');
108
+ for (const f of frames) {
109
+ if (f.containerId !== undefined)
110
+ this.calloutIds.add(f.containerId);
111
+ for (const id of f.calloutPath ?? [])
112
+ this.calloutIds.add(id);
113
+ }
114
+ for (const b of doc.blocks) {
115
+ if (b.sourceStart !== undefined && hasPageLink(b))
116
+ this.indexRanges.add(rangeKey(b));
117
+ }
118
+ this.styleHints = [...doc.blocks, ...doc.pages.flatMap((p) => p.floats ?? [])].some((b) => b.paragraphStyleId !== undefined || b.indexLevel !== undefined);
119
+ for (const a of doc.anchors ?? []) {
120
+ if (a.kind !== 'anchor')
121
+ continue;
122
+ const key = lineKey(a.pageIndex, a.x, a.y);
123
+ const ids = this.lineAnchors.get(key) ?? [];
124
+ ids.push(a.id);
125
+ this.lineAnchors.set(key, ids);
126
+ }
127
+ }
128
+ walk() {
129
+ const floats = [];
130
+ for (const page of this.doc.pages) {
131
+ const bookIndex = this.offset + page.index;
132
+ if (page.partInfo) {
133
+ const key = `${page.partInfo.number}\u0000${page.partInfo.title}`;
134
+ if (key !== this.partKey)
135
+ this.openPart(page.partInfo.number, page.partInfo.title, bookIndex, page.partInfo.palette);
136
+ }
137
+ else if (this.file?.kind === 'part') {
138
+ this.newFile('chapter');
139
+ }
140
+ if (!this.file)
141
+ this.newFile('chapter');
142
+ this.book.pages.set(bookIndex, { loc: { file: this.file, id: '' }, label: page.pageLabel || String(page.pageNumberValue) });
143
+ this.pendingPages.push(bookIndex);
144
+ for (const col of page.columns) {
145
+ for (const block of col.blocks)
146
+ this.place(block, this.root);
147
+ }
148
+ // Floats: footnotes are collected now; the rest is read later, in
149
+ // groups (a floated box with its content).
150
+ let group = [];
151
+ const flush = () => {
152
+ if (group.length > 0)
153
+ floats.push(group);
154
+ group = [];
155
+ };
156
+ for (const f of [...(page.floats ?? []), ...page.marginNotes]) {
157
+ if (f.footnoteNote !== undefined) {
158
+ this.note(f);
159
+ continue;
160
+ }
161
+ if (group.length > 0 && (f.containerId === undefined || f.containerId !== group[0].containerId))
162
+ flush();
163
+ group.push(f);
164
+ if (f.containerId === undefined)
165
+ flush();
166
+ }
167
+ flush();
168
+ }
169
+ for (const g of floats)
170
+ this.readFloat(g);
171
+ // Page starts with no text after them: at the end of the last file.
172
+ this.flushPages();
173
+ this.finishNotes();
174
+ this.markPullQuotes();
175
+ // Anchors no line holds: at the start of their page.
176
+ for (const a of this.doc.anchors ?? []) {
177
+ if (this.book.anchors.has(a.id))
178
+ continue;
179
+ const page = this.book.pages.get(this.offset + a.pageIndex);
180
+ if (page)
181
+ this.book.anchors.set(a.id, page.loc);
182
+ }
183
+ }
184
+ // --- files ---------------------------------------------------------------
185
+ newFile(kind) {
186
+ const file = {
187
+ href: '',
188
+ itemId: '',
189
+ kind,
190
+ doc: this.index,
191
+ nodes: [],
192
+ notes: [],
193
+ lang: this.lang,
194
+ ...(this.dir ? { dir: this.dir } : {}),
195
+ ...(this.palette && Object.keys(this.palette).length > 0 ? { palette: this.palette } : {}),
196
+ };
197
+ this.fileCount++;
198
+ this.book.files.push(file);
199
+ this.file = file;
200
+ this.root = { nodes: file.nodes, lists: [] };
201
+ this.lastContainer = undefined;
202
+ this.callouts = new Map();
203
+ return file;
204
+ }
205
+ /** A part opener: a document of its own with the part's title. A file
206
+ * holding nothing but page starts becomes the opener. */
207
+ openPart(number, title, bookIndex, palette) {
208
+ this.partKey = `${number}\u0000${title}`;
209
+ this.palette = palette;
210
+ const reuse = this.file && this.file.nodes.every((n) => n.k === 'marker');
211
+ const file = reuse ? this.file : this.newFile('part');
212
+ file.kind = 'part';
213
+ if (palette && Object.keys(palette).length > 0)
214
+ file.palette = palette;
215
+ else
216
+ delete file.palette;
217
+ const id = `part-${++this.counters.heading}`;
218
+ const label = [number, title].filter((s) => s.trim()).join(' ');
219
+ const inl = [...this.takePages()];
220
+ if (number.trim())
221
+ inl.push({ t: 'raw', xhtml: `<span class="pt-part-number">${escapeText(number)}</span>` }, { t: 'text', text: ' ', fmt: {} });
222
+ inl.push({ t: 'text', text: title, fmt: {} });
223
+ const node = { k: 'h', level: 1, id, inl, cls: ['pt-part-title'] };
224
+ file.nodes.push(node);
225
+ file.title ??= label;
226
+ this.lastLevel.set(file, 1);
227
+ this.book.headings.push({ file, id, level: 0, label, page: bookIndex, listed: true });
228
+ }
229
+ // --- page starts ---------------------------------------------------------
230
+ /** The page starts waiting for text, as items (none while a float is
231
+ * read: a float is no page's start). Registers where they landed. */
232
+ takePages() {
233
+ if (this.floating || this.pendingPages.length === 0)
234
+ return [];
235
+ const out = [];
236
+ for (const bookIndex of this.pendingPages) {
237
+ const page = this.book.pages.get(bookIndex);
238
+ page.loc = { file: this.file, id: `page-${bookIndex + 1}` };
239
+ out.push({ t: 'page', bookIndex });
240
+ }
241
+ this.pendingPages = [];
242
+ return out;
243
+ }
244
+ flushPages() {
245
+ const items = this.takePages();
246
+ if (items.length > 0)
247
+ this.file.nodes.push({ k: 'marker', inl: items });
248
+ }
249
+ // --- containers ----------------------------------------------------------
250
+ calloutState(cid, path, root) {
251
+ let entry = this.callouts.get(cid);
252
+ if (!entry) {
253
+ const node = { k: 'callout', children: [] };
254
+ root.nodes.push(node);
255
+ entry = { node, state: { nodes: node.children, lists: [], top: root.top ?? node } };
256
+ this.callouts.set(cid, entry);
257
+ }
258
+ for (const id of path) {
259
+ let inner = this.callouts.get(id);
260
+ if (!inner) {
261
+ const node = { k: 'callout', children: [] };
262
+ entry.state.nodes.push(node);
263
+ inner = { node, state: { nodes: node.children, lists: [], top: entry.state.top } };
264
+ this.callouts.set(id, inner);
265
+ }
266
+ entry = inner;
267
+ }
268
+ return entry.state;
269
+ }
270
+ /** The container a block goes into, its grouping state brought in step
271
+ * (lists, quotation and contents close on any other block). */
272
+ enter(block, root) {
273
+ const cid = block.containerId;
274
+ const state = cid !== undefined && this.calloutIds.has(cid) ? this.calloutState(cid, block.calloutPath ?? [], root) : root;
275
+ if (state !== this.lastContainer || block.type === 'callout') {
276
+ state.lists = [];
277
+ state.quote = undefined;
278
+ state.toc = undefined;
279
+ }
280
+ this.lastContainer = state;
281
+ const toc = isTocRow(block);
282
+ if (block.type !== 'listItem' || toc)
283
+ state.lists = [];
284
+ if (block.type !== 'blockquote')
285
+ state.quote = undefined;
286
+ if (!toc)
287
+ state.toc = undefined;
288
+ return state;
289
+ }
290
+ topOf(state, node) {
291
+ return state.top ?? node;
292
+ }
293
+ record(block, top) {
294
+ if (this.floating || block.contentIndex === undefined)
295
+ return;
296
+ this.flow.push({ index: block.contentIndex, top, file: this.file });
297
+ }
298
+ // --- blocks --------------------------------------------------------------
299
+ ctx(block) {
300
+ return {
301
+ doc: this.index,
302
+ lang: this.lang,
303
+ basePx: fontPx(block.fontString),
304
+ noteRef: (id) => {
305
+ const key = `${this.index}:${id}`;
306
+ if (!this.book.notes.has(key))
307
+ this.book.notes.set(key, { file: this.file, id: idOf('fn-', id) });
308
+ if (this.seenNotes.has(id))
309
+ return false;
310
+ this.seenNotes.add(id);
311
+ return true;
312
+ },
313
+ };
314
+ }
315
+ /** Items to set before a line: the anchors the line holds. */
316
+ anchorsAt(block, line) {
317
+ const ids = this.lineAnchors.get(lineKey(block.pageIndex, line.bbox.x, line.bbox.y));
318
+ if (!ids)
319
+ return [];
320
+ const out = [];
321
+ for (const id of ids) {
322
+ if (this.book.anchors.has(id))
323
+ continue;
324
+ const elemId = idOf('a-', id);
325
+ this.book.anchors.set(id, { file: this.file, id: elemId });
326
+ out.push({ t: 'anchor', id: elemId });
327
+ }
328
+ return out;
329
+ }
330
+ appendBlockLines(sink, block, lines = block.lines, before = []) {
331
+ const ctx = this.ctx(block);
332
+ appendLines(sink, lines, ctx, (line, i) => [...(i === 0 ? before : []), ...this.anchorsAt(block, line)]);
333
+ }
334
+ place(block, root) {
335
+ if (block.footnoteNote !== undefined) {
336
+ this.note(block);
337
+ return;
338
+ }
339
+ // A part set without a divider page opens with the first block after
340
+ // its fence.
341
+ if (!this.floating && block.contentIndex !== undefined) {
342
+ while (this.partMarks.length > 0 && block.contentIndex > this.partMarks[0].afterContentIndex) {
343
+ const mark = this.partMarks.shift();
344
+ this.openPart(mark.number, mark.title, this.offset + block.pageIndex, mark.palette);
345
+ this.newFile('chapter');
346
+ root = this.root;
347
+ }
348
+ }
349
+ if (block.type === 'callout') {
350
+ this.calloutFrame(block, root);
351
+ return;
352
+ }
353
+ const key = this.textKey(block);
354
+ const verse = this.verses.get(key);
355
+ if (verse) {
356
+ this.enter(block, root);
357
+ this.verseLines(verse, block);
358
+ this.record(block, verse.top);
359
+ return;
360
+ }
361
+ const sink = this.sinks.get(key);
362
+ if (sink && block.indexLevel !== undefined && block.lines.some((l) => l.indexLevel !== undefined)) {
363
+ // An index block's fragment that starts entries of its own.
364
+ this.indexEntries(block, this.enter(block, root), key, sink);
365
+ return;
366
+ }
367
+ if (sink) {
368
+ this.enter(block, root);
369
+ const pages = this.takePages();
370
+ this.appendBlockLines(sink, block, block.lines, pages);
371
+ this.record(block, sink.top);
372
+ return;
373
+ }
374
+ if (block.type === 'resource') {
375
+ this.resource(block, root);
376
+ return;
377
+ }
378
+ if (block.type === 'mathDisplay') {
379
+ this.math(block, root);
380
+ return;
381
+ }
382
+ if (isTocRow(block)) {
383
+ this.tocRow(block, root, key);
384
+ return;
385
+ }
386
+ const hasText = block.lines.some((l) => l.text.length > 0 || (l.segments?.length ?? 0) > 0);
387
+ if (!hasText && !(block.type === 'heading' && block.designOverlay))
388
+ return;
389
+ switch (block.type) {
390
+ case 'heading':
391
+ this.heading(block, root, key);
392
+ break;
393
+ case 'listItem':
394
+ this.listItem(block, root, key);
395
+ break;
396
+ case 'blockquote':
397
+ this.quote(block, root, key);
398
+ break;
399
+ default:
400
+ this.paragraph(block, root, key);
401
+ }
402
+ }
403
+ /** The key the fragments of one text block share: the head's id (a
404
+ * continuation adds `-cont-N`); inside a box split across pages, where
405
+ * each fragment numbers its children afresh, the box and the content
406
+ * index. */
407
+ textKey(block) {
408
+ const cid = block.containerId;
409
+ if (cid !== undefined && this.calloutIds.has(cid) && block.contentIndex !== undefined && block.type !== 'heading' && block.type !== 'resource') {
410
+ return `box:${cid}:${(block.calloutPath ?? []).join('.')}:${block.contentIndex}`;
411
+ }
412
+ return fragmentKey(block);
413
+ }
414
+ newSink(key, inl, top) {
415
+ const sink = { inl, top, file: this.file };
416
+ this.sinks.set(key, sink);
417
+ return sink;
418
+ }
419
+ calloutFrame(block, root) {
420
+ const cid = block.containerId;
421
+ if (cid === undefined)
422
+ return;
423
+ this.enter(block, root);
424
+ const path = block.calloutPath ?? [];
425
+ this.calloutState(cid, path, root);
426
+ const own = this.callouts.get(path.length > 0 ? path[path.length - 1] : cid).node;
427
+ if (block.callout?.styleId)
428
+ own.styleId = block.callout.styleId;
429
+ if ((block.callout?.part ?? 0) === 0 && own.title === undefined) {
430
+ const title = (block.designOverlay?.blocks ?? [])
431
+ .flatMap((b) => (b.kind === 'text' && !b.artifact ? [b.lines.map((l) => l.text).join(' ')] : []))
432
+ .join(' ')
433
+ .replace(/\s+/g, ' ')
434
+ .trim();
435
+ if (title)
436
+ own.title = title;
437
+ }
438
+ // The box's first content block starts its lists afresh.
439
+ this.lastContainer = undefined;
440
+ }
441
+ heading(block, root, key) {
442
+ const state = this.enter(block, root);
443
+ const file = this.file;
444
+ const last = this.lastLevel.get(file) ?? 0;
445
+ const level = Math.max(1, Math.min(6, block.headingLevel ?? 1, last + 1));
446
+ this.lastLevel.set(file, level);
447
+ const anchor = block.attrs?.id;
448
+ let id;
449
+ if (anchor && !this.book.anchors.has(anchor)) {
450
+ id = idOf('a-', anchor);
451
+ this.book.anchors.set(anchor, { file, id });
452
+ }
453
+ else {
454
+ id = `h-${++this.counters.heading}`;
455
+ }
456
+ const cls = [];
457
+ if (block.headingStyleId)
458
+ cls.push(idOf('hs-', block.headingStyleId));
459
+ // A structural heading (its level hidden) prints nothing but is part
460
+ // of the outline: kept for navigation, out of sight.
461
+ if (block.hidden && block.lines.every((l) => l.bbox.height <= 0))
462
+ cls.push('pt-hidden');
463
+ const node = { k: 'h', level, id, inl: [], ...(cls.length ? { cls } : {}), ...(block.direction ? { dir: block.direction } : {}) };
464
+ state.nodes.push(node);
465
+ const top = this.topOf(state, node);
466
+ const sink = this.newSink(key, node.inl, top);
467
+ const pages = this.takePages();
468
+ if (block.sourceTitle !== undefined) {
469
+ // The title as written (the lines print it transformed, upper case):
470
+ // the stylesheet transforms it again.
471
+ const prefix = block.numberPrefix ? `${block.numberPrefix}${block.numberSeparator ?? ' '}` : '';
472
+ node.inl.push(...pages, { t: 'text', text: prefix + block.sourceTitle, fmt: {} });
473
+ sink.prev = block.lines[block.lines.length - 1];
474
+ }
475
+ else if (block.lines.length > 0) {
476
+ this.appendBlockLines(sink, block, block.lines, pages);
477
+ }
478
+ else {
479
+ const text = (block.designOverlay?.blocks ?? [])
480
+ .flatMap((b) => (b.kind === 'text' && !b.artifact ? [b.lines.map((l) => l.text).join(' ')] : []))
481
+ .join(' ');
482
+ node.inl.push(...pages, { t: 'text', text, fmt: {} });
483
+ }
484
+ this.record(block, top);
485
+ const label = plainText(node.inl);
486
+ file.title ??= label;
487
+ const style = block.headingStyleId ? this.config.headingStyles.find((s) => s.id === block.headingStyleId) : undefined;
488
+ const navDepth = Math.max(3, ...this.config.toc.levels.map((l) => l.level));
489
+ this.book.headings.push({
490
+ file,
491
+ id,
492
+ level: block.headingLevel ?? 1,
493
+ label,
494
+ page: this.offset + block.pageIndex,
495
+ listed: style?.toc !== false && (block.headingLevel ?? 1) <= navDepth,
496
+ });
497
+ }
498
+ paragraph(block, root, key) {
499
+ const state = this.enter(block, root);
500
+ if (block.indexLevel !== undefined || hasPageLink(block) || (block.sourceStart !== undefined && this.indexRanges.has(rangeKey(block)))) {
501
+ this.indexEntries(block, state, key);
502
+ return;
503
+ }
504
+ if (block.lines.some((l) => l.verse !== undefined)) {
505
+ this.verse(block, state, key);
506
+ return;
507
+ }
508
+ const node = { k: 'p', inl: [], ...(block.direction ? { dir: block.direction } : {}) };
509
+ const cls = [];
510
+ if (block.bibEntry !== undefined) {
511
+ cls.push('pt-bib');
512
+ const anchor = `ref-${block.bibEntry}`;
513
+ if (!this.book.anchors.has(anchor)) {
514
+ node.id = idOf('a-', anchor);
515
+ this.book.anchors.set(anchor, { file: this.file, id: node.id });
516
+ }
517
+ }
518
+ else {
519
+ const style = this.paragraphStyle(block);
520
+ if (style)
521
+ cls.push(idOf('ps-', style));
522
+ }
523
+ if (cls.length)
524
+ node.cls = cls;
525
+ state.nodes.push(node);
526
+ const top = this.topOf(state, node);
527
+ const sink = this.newSink(key, node.inl, top);
528
+ this.appendBlockLines(sink, block, block.lines, this.takePages());
529
+ this.record(block, top);
530
+ if (!this.floating && block.bibEntry !== undefined && !this.book.bibliography && node.id)
531
+ this.book.bibliography = { file: this.file, id: node.id };
532
+ }
533
+ /** The paragraph style a block is set in: the one the engine names, or
534
+ * (an older document) the one its face tells, outside boxes. */
535
+ paragraphStyle(block) {
536
+ if (this.styleHints)
537
+ return block.paragraphStyleId;
538
+ if (block.containerId !== undefined && this.calloutIds.has(block.containerId))
539
+ return undefined;
540
+ return paragraphStyleOf(block, this.config, this.bodyPx);
541
+ }
542
+ /** A `:::verse` poem (#378): its lines back to bayts. */
543
+ verse(block, state, key) {
544
+ const style = this.styleHints ? block.paragraphStyleId : undefined;
545
+ const node = {
546
+ k: 'verse',
547
+ pre: this.takePages(),
548
+ bayts: [],
549
+ ...(style ? { cls: [idOf('ps-', style)] } : {}),
550
+ ...(block.direction ? { dir: block.direction } : {}),
551
+ };
552
+ state.nodes.push(node);
553
+ this.book.verse = true;
554
+ const open = { node, top: this.topOf(state, node) };
555
+ this.verses.set(key, open);
556
+ this.verseLines(open, block);
557
+ this.record(block, open.top);
558
+ }
559
+ /**
560
+ * The lines of a poem's fragment, bayt by bayt (`VDTLine.verse`). A
561
+ * whole bayt's line is cut at its gap (the `labelTab` space; with an
562
+ * ornament, the inserted mark and an empty space follow it); a
563
+ * staggered bayt comes as a ṣadr line and an ʿajuz line; a hemistich
564
+ * broken over lines is joined again. Page starts waiting for text open
565
+ * the next bayt.
566
+ */
567
+ verseLines(open, block) {
568
+ const ctx = this.ctx(block);
569
+ const bayts = open.node.bayts;
570
+ for (const line of block.lines) {
571
+ const before = [...this.takePages(), ...this.anchorsAt(block, line)];
572
+ const v = line.verse ?? { bayt: -1, part: 'single' };
573
+ const segs = line.segments ?? [];
574
+ if (v.part === 'bayt') {
575
+ const gap = segs.findIndex((g) => g.kind === 'space' && g.labelTab);
576
+ const sadr = gap >= 0 ? segs.slice(0, gap) : segs;
577
+ const rest = gap >= 0 ? segs.slice(gap + 1) : [];
578
+ const ornament = rest.find((g) => g.kind === 'text' && g.inserted)?.text;
579
+ const ajuz = rest.filter((g) => !g.inserted && !(g.kind === 'space' && g.labelTab));
580
+ while (ajuz[0]?.kind === 'space')
581
+ ajuz.shift();
582
+ const bayt = { sadr: [], ajuz: [], ...(ornament ? { ornament } : {}) };
583
+ bayts.push(bayt);
584
+ appendLine({ inl: bayt.sadr }, line, ctx, before, sadr);
585
+ open.sink = { inl: bayt.ajuz };
586
+ appendLine(open.sink, line, ctx, [], ajuz);
587
+ }
588
+ else if (open.sink && v.bayt === open.bayt && v.part === open.part) {
589
+ appendLine(open.sink, line, ctx, before, segs);
590
+ }
591
+ else {
592
+ let bayt = bayts[bayts.length - 1];
593
+ if (!(v.part === 'ajuz' && bayt && v.bayt === open.bayt && open.part === 'sadr')) {
594
+ bayt = { sadr: [], ajuz: [], ...(v.part === 'single' ? { single: true } : {}) };
595
+ bayts.push(bayt);
596
+ }
597
+ open.sink = { inl: v.part === 'ajuz' ? bayt.ajuz : bayt.sadr };
598
+ appendLine(open.sink, line, ctx, before, segs);
599
+ }
600
+ open.bayt = v.bayt;
601
+ open.part = v.part;
602
+ }
603
+ }
604
+ /**
605
+ * A block of the back-of-book index: its group letter, then each entry it
606
+ * holds (the entries with no page of their own that head a sub-entry, and
607
+ * the sub-entry) as a paragraph of its own, classed by depth
608
+ * (`pt-index-l1`, `pt-index-l2`…). The engine marks the first line of
609
+ * each entry with its level (`VDTLine.indexLevel`); in an older document
610
+ * an entry starts on a line set at its level's indent, its turnover lines
611
+ * hanging `turnoverIndent` further in (`indexDirective.ts`). `cont` is
612
+ * the sink of the entry a later fragment's first lines go on.
613
+ */
614
+ indexEntries(block, state, key, cont) {
615
+ let lines = block.lines;
616
+ const pages = this.takePages();
617
+ const hinted = block.indexLevel !== undefined;
618
+ const entries = [];
619
+ if (hinted) {
620
+ // Lines before the first entry: the run-on of the entry `cont` holds,
621
+ // or the group's letter.
622
+ const first = lines.findIndex((l) => l.indexLevel !== undefined);
623
+ const lead = first < 0 ? lines : lines.slice(0, first);
624
+ if (lead.length > 0 && cont) {
625
+ this.appendBlockLines(cont, block, lead, pages.splice(0));
626
+ }
627
+ else if (lead.length > 0) {
628
+ const group = { k: 'p', inl: [], cls: ['pt-index-group'] };
629
+ state.nodes.push(group);
630
+ this.appendBlockLines({ inl: group.inl }, block, lead, pages.splice(0));
631
+ }
632
+ for (const line of first < 0 ? [] : lines.slice(first)) {
633
+ if (line.indexLevel !== undefined)
634
+ entries.push({ level: line.indexLevel, lines: [line] });
635
+ else
636
+ entries[entries.length - 1].lines.push(line);
637
+ }
638
+ }
639
+ else {
640
+ // A group letter of the index, set as the first line of its first
641
+ // entry in a face of its own: a paragraph of its own.
642
+ const head = lines[0].segments ?? [];
643
+ const letter = lines.length > 1 && head.some((g) => g.kind === 'text') && head.every((g) => g.kind === 'space' || (g.fontString !== undefined && g.pageLink === undefined));
644
+ if (letter) {
645
+ const group = { k: 'p', inl: [], cls: ['pt-index-group'] };
646
+ state.nodes.push(group);
647
+ this.appendBlockLines({ inl: group.inl }, block, lines.slice(0, 1), pages.splice(0));
648
+ lines = lines.slice(1);
649
+ }
650
+ const cfg = this.config.index;
651
+ const dpi = this.config.page.dpi;
652
+ const sizePx = dimensionToPx(cfg.fontSize, dpi, this.bodyPx);
653
+ const indentPx = dimensionToPx(cfg.indent, dpi, sizePx);
654
+ const turnoverPx = dimensionToPx(cfg.turnoverIndent, dpi, sizePx);
655
+ let lastX = 0;
656
+ for (const line of lines) {
657
+ const x = line.bbox.x - block.bbox.x;
658
+ const last = entries[entries.length - 1];
659
+ if (last && Math.abs(x - (lastX + turnoverPx)) < 1) {
660
+ last.lines.push(line);
661
+ }
662
+ else {
663
+ entries.push({ level: indentPx > 0 ? Math.max(0, Math.round(x / indentPx)) : 0, lines: [line] });
664
+ lastX = x;
665
+ }
666
+ }
667
+ }
668
+ let top;
669
+ for (const [i, entry] of entries.entries()) {
670
+ const node = {
671
+ k: 'p',
672
+ inl: [],
673
+ cls: ['pt-index-entry', ...(entry.level > 0 ? [`pt-index-l${Math.min(entry.level, 3)}`] : [])],
674
+ ...(block.direction ? { dir: block.direction } : {}),
675
+ };
676
+ state.nodes.push(node);
677
+ top = this.topOf(state, node);
678
+ // The last entry takes the lines of the block's next fragment.
679
+ const sink = i === entries.length - 1 ? this.newSink(key, node.inl, top) : { inl: node.inl };
680
+ this.appendBlockLines(sink, block, entry.lines, i === 0 ? pages : []);
681
+ if (!this.floating && !this.book.index)
682
+ this.book.index = { file: this.file, id: (node.id ??= `b-${++this.counters.block}`) };
683
+ }
684
+ if (top)
685
+ this.record(block, top);
686
+ }
687
+ quote(block, root, key) {
688
+ const state = this.enter(block, root);
689
+ if (!state.quote) {
690
+ state.quote = { k: 'quote', children: [] };
691
+ state.nodes.push(state.quote);
692
+ }
693
+ const node = { k: 'p', inl: [], ...(block.direction ? { dir: block.direction } : {}) };
694
+ state.quote.children.push(node);
695
+ const top = this.topOf(state, state.quote);
696
+ const sink = this.newSink(key, node.inl, top);
697
+ this.appendBlockLines(sink, block, block.lines, this.takePages());
698
+ this.record(block, top);
699
+ }
700
+ listItem(block, root, key) {
701
+ const state = this.enter(block, root);
702
+ const depth = block.listDepth ?? 1;
703
+ const lists = state.lists;
704
+ while (lists.length > 0 && lists[lists.length - 1].depth > depth)
705
+ lists.pop();
706
+ let top = lists[lists.length - 1];
707
+ if (top && top.depth === depth && top.kind !== block.listKind) {
708
+ lists.pop();
709
+ top = lists[lists.length - 1];
710
+ }
711
+ if (!top || top.depth < depth) {
712
+ const node = { k: 'list', ordered: block.listKind === 'ordered', task: block.listKind === 'task', items: [] };
713
+ (top?.lastItem?.children ?? state.nodes).push(node);
714
+ top = { depth, kind: block.listKind, node };
715
+ lists.push(top);
716
+ }
717
+ const marker = [block.prefixText, block.bulletText, block.separatorText].filter(Boolean).join('');
718
+ const item = {
719
+ inl: [],
720
+ children: [],
721
+ ...(marker ? { marker } : {}),
722
+ ...(block.strikethroughText ? { done: true } : {}),
723
+ ...(block.direction ? { dir: block.direction } : {}),
724
+ };
725
+ top.node.items.push(item);
726
+ top.lastItem = item;
727
+ const topNode = this.topOf(state, lists[0].node);
728
+ const sink = this.newSink(key, item.inl, topNode);
729
+ this.appendBlockLines(sink, block, block.lines, this.takePages());
730
+ this.record(block, topNode);
731
+ }
732
+ /** A row of a `:::toc`: its number, its title (the leader and page
733
+ * number dropped: the row links to the heading instead) and the
734
+ * subtitle lines under it. */
735
+ tocRow(block, root, key) {
736
+ const state = this.enter(block, root);
737
+ if (!state.toc) {
738
+ state.toc = { k: 'toc', pre: this.takePages(), rows: [] };
739
+ state.nodes.push(state.toc);
740
+ if (!this.floating && !this.book.contents)
741
+ this.book.contents = { file: this.file, id: `toc-${++this.counters.block}` };
742
+ }
743
+ const top = this.topOf(state, state.toc);
744
+ if (block.tocPart) {
745
+ const p = block.tocPart;
746
+ const row = {
747
+ ...(p.number ? { number: p.number } : {}),
748
+ inl: [{ t: 'text', text: p.title, fmt: {} }],
749
+ subtitle: [],
750
+ ...(p.pageIndex !== undefined ? { page: p.pageIndex } : {}),
751
+ part: true,
752
+ title: p.title,
753
+ };
754
+ state.toc.rows.push(row);
755
+ this.record(block, top);
756
+ return;
757
+ }
758
+ const lines = block.lines;
759
+ const isLabel = (s) => s.kind === 'space' || (s.kind === 'text' && s.fontString !== undefined);
760
+ let labelLine = -1;
761
+ let cut = 0;
762
+ for (let i = 0; i < lines.length && labelLine < 0; i++) {
763
+ const segs = lines[i].segments ?? [];
764
+ const lastText = [...segs].reverse().find((s) => s.kind !== 'space');
765
+ if (lastText?.kind === 'text' && lastText.fontString !== undefined) {
766
+ labelLine = i;
767
+ cut = segs.length;
768
+ while (cut > 0 && isLabel(segs[cut - 1]))
769
+ cut--;
770
+ }
771
+ }
772
+ const ctx = this.ctx(block);
773
+ const title = { inl: [] };
774
+ const subtitle = { inl: [] };
775
+ lines.forEach((line, i) => {
776
+ if (labelLine >= 0 && i > labelLine)
777
+ appendLine(subtitle, line, ctx);
778
+ else
779
+ appendLine(title, line, ctx, [], i === labelLine ? (line.segments ?? []).slice(0, cut) : undefined);
780
+ });
781
+ const row = {
782
+ ...(block.bulletText ? { number: block.bulletText } : {}),
783
+ inl: title.inl,
784
+ subtitle: subtitle.inl,
785
+ ...(block.tocEntry?.pageIndex !== undefined ? { page: block.tocEntry.pageIndex } : {}),
786
+ title: plainText(title.inl),
787
+ };
788
+ state.toc.rows.push(row);
789
+ this.newSink(key, row.inl, top).prev = lines[labelLine >= 0 ? labelLine : lines.length - 1];
790
+ this.record(block, top);
791
+ }
792
+ math(block, root) {
793
+ const state = this.enter(block, root);
794
+ const render = block.mathRender;
795
+ const node = {
796
+ k: 'math',
797
+ pre: this.takePages(),
798
+ tex: render?.tex ?? block.tex ?? '',
799
+ svg: '',
800
+ };
801
+ if (render && !render.error) {
802
+ node.svg = mathSvg(render, fontPx(block.fontString), false);
803
+ this.book.maths = true;
804
+ }
805
+ state.nodes.push(node);
806
+ this.record(block, this.topOf(state, node));
807
+ }
808
+ resource(block, root) {
809
+ const rb = block.resourceBlock;
810
+ if (!rb)
811
+ return;
812
+ const res = rb.resource;
813
+ const state = this.enter(block, root);
814
+ const ctx = this.ctx({ fontString: rb.captionFontString });
815
+ const lines = (ls) => {
816
+ const sink = { inl: [] };
817
+ for (const l of ls)
818
+ appendLine(sink, l, ctx);
819
+ return sink.inl;
820
+ };
821
+ const captionAbove = this.config.captionStyle.position === 'above';
822
+ if (rb.kind === 'table' && rb.table) {
823
+ let node = this.tables.get(res.id);
824
+ if (!node) {
825
+ node = {
826
+ k: 'table',
827
+ id: idOf('res-', res.id),
828
+ pre: this.takePages(),
829
+ caption: lines(rb.captionLines),
830
+ captionAbove,
831
+ note: [],
832
+ headerRows: res.table?.model.headerRowCount ?? 0,
833
+ ...(res.table?.model.columnWidths ? { columnWidths: res.table.model.columnWidths } : {}),
834
+ cells: new Map(),
835
+ };
836
+ this.tables.set(res.id, node);
837
+ state.nodes.push(node);
838
+ if (!this.book.resources.has(res.id))
839
+ this.book.resources.set(res.id, { file: this.file, id: node.id });
840
+ this.record(block, this.topOf(state, node));
841
+ }
842
+ if (rb.noteLines.length > 0 && node.note.length === 0)
843
+ node.note = lines(rb.noteLines);
844
+ const cellCtx = this.ctx({ fontString: rb.table.fontString });
845
+ for (const cell of rb.table.cells) {
846
+ const k = `${cell.row}:${cell.col}`;
847
+ if (node.cells.has(k))
848
+ continue;
849
+ const sink = { inl: [] };
850
+ for (const l of cell.lines)
851
+ appendLine(sink, l, cellCtx);
852
+ const c = {
853
+ row: cell.row,
854
+ col: cell.col,
855
+ colSpan: cell.colSpan,
856
+ rowSpan: cell.rowSpan,
857
+ header: cell.isHeader,
858
+ align: cell.align,
859
+ verticalAlign: cell.verticalAlign,
860
+ inl: sink.inl,
861
+ ...(cell.background ? { background: cell.background } : {}),
862
+ ...(cell.alternate ? { alternate: true } : {}),
863
+ };
864
+ if (cell.image) {
865
+ const alt = cell.image.altText ?? '';
866
+ c.image = {
867
+ fileId: cell.image.fileId,
868
+ alt,
869
+ ...(cell.rect.width > 0 ? { width: Math.min(1, cell.image.rect.width / cell.rect.width) } : {}),
870
+ };
871
+ this.book.images.add(cell.image.fileId);
872
+ if (alt)
873
+ this.book.altText = true;
874
+ else
875
+ this.book.missingAlt = true;
876
+ }
877
+ node.cells.set(k, c);
878
+ }
879
+ return;
880
+ }
881
+ if (this.figures.has(res.id))
882
+ return;
883
+ this.figures.add(res.id);
884
+ const caption = lines(rb.captionLines);
885
+ const alt = (res.altText?.trim() || plainCaption(res.caption) || '').trim();
886
+ const node = {
887
+ k: 'figure',
888
+ id: idOf('res-', res.id),
889
+ pre: this.takePages(),
890
+ ...(rb.fileId ? { fileId: rb.fileId } : {}),
891
+ alt,
892
+ caption,
893
+ captionAbove,
894
+ note: lines(rb.noteLines),
895
+ };
896
+ if (rb.fileId)
897
+ this.book.images.add(rb.fileId);
898
+ if (alt)
899
+ this.book.altText = true;
900
+ else
901
+ this.book.missingAlt = true;
902
+ state.nodes.push(node);
903
+ if (!this.book.resources.has(res.id))
904
+ this.book.resources.set(res.id, { file: this.file, id: node.id });
905
+ this.record(block, this.topOf(state, node));
906
+ }
907
+ // --- floats --------------------------------------------------------------
908
+ /** Read a float group where it belongs: after the text block with the
909
+ * highest content index up to its own, after the floats already read
910
+ * there; ahead of the document's first block when none comes before. */
911
+ readFloat(group) {
912
+ const index = group[0].contentIndex;
913
+ let anchor;
914
+ if (index !== undefined) {
915
+ for (let i = this.flow.length - 1; i >= 0; i--) {
916
+ if (this.flow[i].index <= index) {
917
+ anchor = this.flow[i];
918
+ break;
919
+ }
920
+ }
921
+ }
922
+ // The float is read in the document its anchor is in: the ids it
923
+ // registers (a figure, an anchor in a floated box) belong there.
924
+ const first = this.flow[0];
925
+ const file = anchor?.file ?? first?.file ?? this.file;
926
+ const temp = { nodes: [], lists: [] };
927
+ const saved = { container: this.lastContainer, file: this.file };
928
+ this.floating = true;
929
+ this.lastContainer = undefined;
930
+ this.file = file;
931
+ try {
932
+ for (const block of group)
933
+ this.place(block, temp);
934
+ }
935
+ finally {
936
+ this.floating = false;
937
+ this.lastContainer = saved.container;
938
+ this.file = saved.file;
939
+ }
940
+ if (temp.nodes.length === 0)
941
+ return;
942
+ if (!anchor) {
943
+ const at = first ? Math.max(0, file.nodes.indexOf(first.top)) : file.nodes.length;
944
+ file.nodes.splice(at, 0, ...temp.nodes);
945
+ return;
946
+ }
947
+ const nodes = file.nodes;
948
+ const after = nodes.indexOf(this.placedAfter.get(anchor.top) ?? anchor.top);
949
+ nodes.splice(after >= 0 ? after + 1 : nodes.length, 0, ...temp.nodes);
950
+ this.placedAfter.set(anchor.top, temp.nodes[temp.nodes.length - 1]);
951
+ }
952
+ // --- pull quotes ---------------------------------------------------------
953
+ /**
954
+ * Pull quotes: a box of text alone (untitled, or titled with marks
955
+ * alone) whose words (twenty letters or more)
956
+ * the chapter's running text also reads (letters and digits compared,
957
+ * case aside; an ellipsis in the box may stand for words left out) is
958
+ * marked `'echo'`, and the
959
+ * serializer hides it from assistive technology, so its words are read
960
+ * once, where the text has them. A box whose style id names it a pull
961
+ * quote (`pullquote`, `pull-quote`…) but whose words are its own stays
962
+ * readable (`'own'`).
963
+ */
964
+ markPullQuotes() {
965
+ const files = this.book.files.filter((f) => f.doc === this.index);
966
+ const text = [];
967
+ const boxes = [];
968
+ const collect = (nodes) => {
969
+ for (const n of nodes) {
970
+ switch (n.k) {
971
+ case 'callout':
972
+ boxes.push(n);
973
+ break;
974
+ case 'p':
975
+ case 'h':
976
+ text.push(plainText(n.inl));
977
+ break;
978
+ case 'quote':
979
+ collect(n.children);
980
+ break;
981
+ case 'list': {
982
+ const items = (list) => {
983
+ for (const item of list.items) {
984
+ text.push(plainText(item.inl));
985
+ collect(item.children);
986
+ }
987
+ };
988
+ items(n);
989
+ break;
990
+ }
991
+ case 'verse':
992
+ for (const b of n.bayts)
993
+ text.push(plainText(b.sadr), plainText(b.ajuz));
994
+ break;
995
+ default:
996
+ break;
997
+ }
998
+ }
999
+ };
1000
+ for (const f of files)
1001
+ collect(f.nodes);
1002
+ if (boxes.length === 0)
1003
+ return;
1004
+ const body = letters(text.join(' '));
1005
+ for (const box of boxes) {
1006
+ const named = /pull[\s_-]?quote/i.test(box.styleId ?? '');
1007
+ // A title of marks alone (a hanging quotation mark) is decoration.
1008
+ const echoed = letters(box.title ?? '') === '' && box.children.length > 0 && box.children.every((c) => c.k === 'p' || c.k === 'quote') && echoes(box, body);
1009
+ if (echoed)
1010
+ box.pullQuote = 'echo';
1011
+ else if (named)
1012
+ box.pullQuote = 'own';
1013
+ }
1014
+ }
1015
+ // --- notes ---------------------------------------------------------------
1016
+ note(block) {
1017
+ const id = block.footnoteNote;
1018
+ let note = this.notes.get(id);
1019
+ if (!note) {
1020
+ note = { doc: this.index, id, inl: [], sink: { inl: [] }, ...(block.direction ? { dir: block.direction } : {}) };
1021
+ note.inl = note.sink.inl;
1022
+ this.notes.set(id, note);
1023
+ }
1024
+ const saved = this.floating;
1025
+ this.floating = true;
1026
+ try {
1027
+ appendLines(note.sink, block.lines, this.ctx(block));
1028
+ }
1029
+ finally {
1030
+ this.floating = saved;
1031
+ }
1032
+ }
1033
+ /** Each note goes to the document of its first marker (the last
1034
+ * document when no marker was read). */
1035
+ finishNotes() {
1036
+ for (const note of this.notes.values()) {
1037
+ const loc = this.book.notes.get(`${this.index}:${note.id}`);
1038
+ const file = loc?.file ?? this.file;
1039
+ if (!loc)
1040
+ this.book.notes.set(`${this.index}:${note.id}`, { file, id: idOf('fn-', note.id) });
1041
+ file.notes.push({ doc: note.doc, id: note.id, inl: note.inl, ...(note.dir ? { dir: note.dir } : {}) });
1042
+ }
1043
+ }
1044
+ }
1045
+ /** The letters and digits of a text, lower case: what two settings of
1046
+ * the same words share whatever their quotation marks, dashes and
1047
+ * spacing. */
1048
+ function letters(text) {
1049
+ return text.normalize('NFKC').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
1050
+ }
1051
+ /** Whether the words of a box are read in `body` (see `letters`): each run
1052
+ * between the ellipses that mark words left out, in order. */
1053
+ function echoes(box, body) {
1054
+ const words = box.children
1055
+ .flatMap((c) => (c.k === 'p' ? [c] : c.k === 'quote' ? c.children : []))
1056
+ .map((p) => plainText(p.inl))
1057
+ .join(' ');
1058
+ const runs = words.split(/\[?(?:…|\.\.\.)\]?/).map(letters).filter((r) => r.length > 0);
1059
+ // A few words can repeat the text by chance: a pull quote is a sentence.
1060
+ if (runs.reduce((n, r) => n + r.length, 0) < 20)
1061
+ return false;
1062
+ let at = 0;
1063
+ for (const run of runs) {
1064
+ const found = body.indexOf(run, at);
1065
+ if (found < 0)
1066
+ return false;
1067
+ at = found + run.length;
1068
+ }
1069
+ return true;
1070
+ }
1071
+ function escapeText(text) {
1072
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1073
+ }
1074
+ /** A resource caption as plain text (its Markdown marks dropped). */
1075
+ function plainCaption(caption) {
1076
+ return (caption ?? '').replace(/[*_`~^]/g, '').replace(/:\w+\[([^\]]*)\]\{[^}]*\}/g, '$1').replace(/:\w+\{[^}]*\}/g, '').trim();
1077
+ }
1078
+ /** The paragraph style a block of a `:::paragraphs` container was set in,
1079
+ * told by its type (the block keeps no style id): a block set otherwise
1080
+ * than the body text whose family, size, slant and alignment match one
1081
+ * style alone. */
1082
+ function paragraphStyleOf(block, config, bodyPx) {
1083
+ const px = fontPx(block.fontString);
1084
+ const italic = /\bitalic\b/.test(block.fontString);
1085
+ const familyOf = (f) => primaryFontFamily(f).replace(/^['"]|['"]$/g, '');
1086
+ const body = config.bodyText;
1087
+ const asBody = Math.abs(px - bodyPx) < 0.01 && !italic && block.textAlign === body.textAlign && block.fontString.includes(familyOf(body.fontFamily));
1088
+ if (asBody)
1089
+ return undefined;
1090
+ const matches = config.paragraphStyles.filter((s) => {
1091
+ const size = dimensionToPx(s.fontSize, config.page.dpi, bodyPx);
1092
+ return Math.abs(size - px) < 0.01 && s.italic === italic && s.textAlign === block.textAlign && block.fontString.includes(familyOf(s.fontFamily));
1093
+ });
1094
+ return matches.length === 1 ? matches[0].id : undefined;
1095
+ }
1096
+ //# sourceMappingURL=walk.js.map