docxodus 7.1.0 → 9.0.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 (91) hide show
  1. package/README.md +65 -24
  2. package/dist/docxodus.worker.js +2 -2
  3. package/dist/docxodus.worker.js.map +1 -1
  4. package/dist/editor-headerfooter.d.ts +166 -0
  5. package/dist/editor-headerfooter.d.ts.map +1 -0
  6. package/dist/editor-headerfooter.js +530 -0
  7. package/dist/editor-headerfooter.js.map +1 -0
  8. package/dist/editor-reconcile.d.ts +75 -0
  9. package/dist/editor-reconcile.d.ts.map +1 -0
  10. package/dist/editor-reconcile.js +125 -0
  11. package/dist/editor-reconcile.js.map +1 -0
  12. package/dist/editor.bundle.js +1861 -110
  13. package/dist/editor.d.ts +200 -6
  14. package/dist/editor.d.ts.map +1 -1
  15. package/dist/editor.js +868 -86
  16. package/dist/editor.js.map +1 -1
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +3 -4
  20. package/dist/index.js.map +1 -1
  21. package/dist/page-number-format.d.ts +22 -0
  22. package/dist/page-number-format.d.ts.map +1 -0
  23. package/dist/page-number-format.js +60 -0
  24. package/dist/page-number-format.js.map +1 -0
  25. package/dist/pagination.bundle.js +635 -37
  26. package/dist/pagination.d.ts +119 -0
  27. package/dist/pagination.d.ts.map +1 -1
  28. package/dist/pagination.js +706 -52
  29. package/dist/pagination.js.map +1 -1
  30. package/dist/react.d.ts +3 -1
  31. package/dist/react.d.ts.map +1 -1
  32. package/dist/react.js +6 -4
  33. package/dist/react.js.map +1 -1
  34. package/dist/session.bundle.js +241 -4
  35. package/dist/session.d.ts +160 -6
  36. package/dist/session.d.ts.map +1 -1
  37. package/dist/session.js +202 -4
  38. package/dist/session.js.map +1 -1
  39. package/dist/types.d.ts +238 -14
  40. package/dist/types.d.ts.map +1 -1
  41. package/dist/types.js +9 -11
  42. package/dist/types.js.map +1 -1
  43. package/dist/wasm/_framework/Docxodus.wasm +0 -0
  44. package/dist/wasm/_framework/DocxodusWasm.wasm +0 -0
  45. package/dist/wasm/_framework/System.Collections.Concurrent.wasm +0 -0
  46. package/dist/wasm/_framework/System.Collections.Immutable.wasm +0 -0
  47. package/dist/wasm/_framework/System.Collections.NonGeneric.wasm +0 -0
  48. package/dist/wasm/_framework/System.Collections.Specialized.wasm +0 -0
  49. package/dist/wasm/_framework/System.Collections.wasm +0 -0
  50. package/dist/wasm/_framework/System.ComponentModel.Primitives.wasm +0 -0
  51. package/dist/wasm/_framework/System.ComponentModel.TypeConverter.wasm +0 -0
  52. package/dist/wasm/_framework/System.ComponentModel.wasm +0 -0
  53. package/dist/wasm/_framework/System.Console.wasm +0 -0
  54. package/dist/wasm/_framework/System.Diagnostics.Process.wasm +0 -0
  55. package/dist/wasm/_framework/System.IO.Compression.wasm +0 -0
  56. package/dist/wasm/_framework/System.IO.Pipelines.wasm +0 -0
  57. package/dist/wasm/_framework/System.Linq.Expressions.wasm +0 -0
  58. package/dist/wasm/_framework/System.Linq.wasm +0 -0
  59. package/dist/wasm/_framework/System.Memory.wasm +0 -0
  60. package/dist/wasm/_framework/System.Net.Http.wasm +0 -0
  61. package/dist/wasm/_framework/System.Net.Primitives.wasm +0 -0
  62. package/dist/wasm/_framework/System.ObjectModel.wasm +0 -0
  63. package/dist/wasm/_framework/System.Private.CoreLib.wasm +0 -0
  64. package/dist/wasm/_framework/System.Private.Uri.wasm +0 -0
  65. package/dist/wasm/_framework/System.Private.Xml.Linq.wasm +0 -0
  66. package/dist/wasm/_framework/System.Private.Xml.wasm +0 -0
  67. package/dist/wasm/_framework/System.Runtime.InteropServices.JavaScript.wasm +0 -0
  68. package/dist/wasm/_framework/System.Runtime.wasm +0 -0
  69. package/dist/wasm/_framework/System.Security.Cryptography.wasm +0 -0
  70. package/dist/wasm/_framework/System.Text.Encoding.Extensions.wasm +0 -0
  71. package/dist/wasm/_framework/System.Text.Encodings.Web.wasm +0 -0
  72. package/dist/wasm/_framework/System.Text.Json.wasm +0 -0
  73. package/dist/wasm/_framework/System.Text.RegularExpressions.wasm +0 -0
  74. package/dist/wasm/_framework/System.Threading.Thread.wasm +0 -0
  75. package/dist/wasm/_framework/System.Threading.wasm +0 -0
  76. package/dist/wasm/_framework/System.Xml.Linq.wasm +0 -0
  77. package/dist/wasm/_framework/System.Xml.ReaderWriter.wasm +0 -0
  78. package/dist/wasm/_framework/System.Xml.XDocument.wasm +0 -0
  79. package/dist/wasm/_framework/System.Xml.XPath.XDocument.wasm +0 -0
  80. package/dist/wasm/_framework/System.Xml.XPath.wasm +0 -0
  81. package/dist/wasm/_framework/System.wasm +0 -0
  82. package/dist/wasm/_framework/dotnet.boot.js +41 -41
  83. package/dist/wasm/_framework/dotnet.js +1 -1
  84. package/dist/wasm/_framework/dotnet.js.map +1 -1
  85. package/dist/wasm/_framework/dotnet.native.js +3 -3
  86. package/dist/wasm/_framework/dotnet.native.js.symbols +2102 -2101
  87. package/dist/wasm/_framework/dotnet.native.wasm +0 -0
  88. package/dist/wasm/_framework/dotnet.runtime.js +1 -1
  89. package/dist/wasm/_framework/dotnet.runtime.js.map +1 -1
  90. package/dist/wasm/index.html +2 -1
  91. package/package.json +17 -4
@@ -4,6 +4,7 @@
4
4
  * This module provides client-side pagination that measures rendered content
5
5
  * and flows it across fixed-size page containers based on document dimensions.
6
6
  */
7
+ import { formatPageNumber } from "./page-number-format.js";
7
8
  // Default letter size in points (612 x 792 = 8.5" x 11")
8
9
  const DEFAULT_PAGE_WIDTH = 612;
9
10
  const DEFAULT_PAGE_HEIGHT = 792;
@@ -64,6 +65,8 @@ export class PaginationEngine {
64
65
  */
65
66
  constructor(staging, container, options = {}) {
66
67
  this.pendingFootnoteContinuation = null;
68
+ /** Per-section `w:pgNumType` (start / format), read off the section wrappers. */
69
+ this.pageNumbering = new Map();
67
70
  this.stagingElement =
68
71
  typeof staging === "string"
69
72
  ? document.getElementById(staging)
@@ -82,6 +85,7 @@ export class PaginationEngine {
82
85
  this.cssPrefix = options.cssPrefix ?? "page-";
83
86
  this.showPageNumbers = options.showPageNumbers ?? true;
84
87
  this.pageGap = options.pageGap ?? 20;
88
+ this.fragmentParagraphs = options.fragmentParagraphs ?? false;
85
89
  this.hfRegistry = new Map();
86
90
  this.footnoteRegistry = new Map();
87
91
  }
@@ -99,6 +103,7 @@ export class PaginationEngine {
99
103
  this.footnoteRegistry = this.parseFootnoteRegistry();
100
104
  // Find all section containers
101
105
  const sections = this.stagingElement.querySelectorAll("[data-section-index]");
106
+ this.pageNumbering = this.parsePageNumbering(sections);
102
107
  // If no sections found, treat the entire staging content as one section
103
108
  const sectionsToProcess = sections.length > 0 ? Array.from(sections) : [this.stagingElement];
104
109
  for (const section of sectionsToProcess) {
@@ -120,8 +125,65 @@ export class PaginationEngine {
120
125
  }
121
126
  // Hide staging after measurement
122
127
  this.stagingElement.style.display = "none";
128
+ // Every page box exists now, so NUMPAGES has an answer and each PAGE marker knows its page.
129
+ this.substitutePageNumberFields(pages.length);
123
130
  return { totalPages: pages.length, pages };
124
131
  }
132
+ /** Read each section's `w:pgNumType` off its wrapper (see {@link SectionPageNumbering}). */
133
+ parsePageNumbering(sections) {
134
+ const map = new Map();
135
+ for (const section of Array.from(sections)) {
136
+ const index = parseInt(section.dataset.sectionIndex || "0", 10);
137
+ const rawStart = section.dataset.pageNumStart;
138
+ const start = rawStart === undefined ? undefined : parseInt(rawStart, 10);
139
+ map.set(index, {
140
+ start: start !== undefined && Number.isFinite(start) ? start : undefined,
141
+ format: section.dataset.pageNumFmt,
142
+ });
143
+ }
144
+ return map;
145
+ }
146
+ /**
147
+ * Fill in the page-number fields inside every page's cloned header/footer.
148
+ *
149
+ * A header/footer is authored once and cloned onto each page, so a PAGE field's single cached
150
+ * result would otherwise show the same number on every page — the whole reason the converter
151
+ * marks these. `data-field-format` (the field's own `\*` switch) wins over the section's format
152
+ * when present, which is exactly how Word resolves the two.
153
+ *
154
+ * Runs after layout because NUMPAGES cannot be known before the last page exists. The
155
+ * substituted text can therefore be marginally wider than the cached result the header was
156
+ * measured with; the header band clips, so the failure mode is a hair of overflow rather than
157
+ * a layout that disagrees with itself.
158
+ *
159
+ * Scoped to the CLONED header/footer regions on purpose. A page-number field in body text is
160
+ * ordinary run content that the editor may make editable, and committing an edited block writes
161
+ * back whatever text the DOM holds — rewriting it here would mean a body field commits a number
162
+ * the document never contained. Body content is also not cloned, so it does not have the problem
163
+ * this method exists to solve.
164
+ */
165
+ substitutePageNumberFields(totalPages) {
166
+ const boxes = this.containerElement.querySelectorAll(`.${this.cssPrefix}box`);
167
+ for (const box of Array.from(boxes)) {
168
+ const markers = box.querySelectorAll(`.${this.cssPrefix}header [data-field], .${this.cssPrefix}footer [data-field]`);
169
+ if (markers.length === 0)
170
+ continue;
171
+ const sectionIndex = parseInt(box.dataset.sectionIndex || "0", 10);
172
+ const pageNumber = parseInt(box.dataset.pageNumber || "1", 10);
173
+ const pageInSection = parseInt(box.dataset.pageInSection || "1", 10);
174
+ const numbering = this.pageNumbering.get(sectionIndex) ?? {};
175
+ // A section that restarts numbering counts from its own start; one that does not continues
176
+ // the document-wide running number.
177
+ const displayed = numbering.start !== undefined ? numbering.start + pageInSection - 1 : pageNumber;
178
+ for (const marker of Array.from(markers)) {
179
+ const kind = marker.dataset.field;
180
+ if (kind !== "PAGE" && kind !== "NUMPAGES")
181
+ continue;
182
+ const format = marker.dataset.fieldFormat ?? numbering.format;
183
+ marker.textContent = formatPageNumber(kind === "PAGE" ? displayed : totalPages, format);
184
+ }
185
+ }
186
+ }
125
187
  /**
126
188
  * Measures all content blocks in a section.
127
189
  */
@@ -161,6 +223,443 @@ export class PaginationEngine {
161
223
  }
162
224
  return blocks;
163
225
  }
226
+ /**
227
+ * Measures one element in the same hidden staging context used for the source blocks.
228
+ * This is intentionally DOM-based: table row heights cannot be inferred from individual
229
+ * rows because wrapping and collapsed borders change the height of a fragment.
230
+ */
231
+ measureElement(element, dims) {
232
+ const measurementHost = document.createElement("div");
233
+ measurementHost.style.position = "absolute";
234
+ measurementHost.style.visibility = "hidden";
235
+ measurementHost.style.left = "-9999px";
236
+ measurementHost.style.width = `${dims.contentWidth}pt`;
237
+ const measuredElement = element.cloneNode(true);
238
+ measurementHost.appendChild(measuredElement);
239
+ this.stagingElement.appendChild(measurementHost);
240
+ const rect = measuredElement.getBoundingClientRect();
241
+ const style = window.getComputedStyle(measuredElement);
242
+ const measured = {
243
+ element,
244
+ heightPt: pxToPt(rect.height),
245
+ marginTopPt: pxToPt(parseFloat(style.marginTop) || 0),
246
+ marginBottomPt: pxToPt(parseFloat(style.marginBottom) || 0),
247
+ keepWithNext: element.dataset.keepWithNext === "true",
248
+ keepLines: element.dataset.keepLines === "true",
249
+ pageBreakBefore: element.dataset.pageBreakBefore === "true",
250
+ isPageBreak: element.dataset.pageBreak === "true" ||
251
+ element.classList.contains(`${this.cssPrefix}break`),
252
+ };
253
+ this.stagingElement.removeChild(measurementHost);
254
+ return measured;
255
+ }
256
+ /**
257
+ * Returns the contiguous keep-with-next chain beginning at a block.
258
+ *
259
+ * A hard page break or a page-break-before directive is stronger than a
260
+ * keep-with-next directive, so it terminates the chain. The caller only
261
+ * keeps a chain together when the whole chain can fit on a fresh page.
262
+ */
263
+ getKeepWithNextChain(blocks, startIndex) {
264
+ const firstBlock = blocks[startIndex];
265
+ if (!firstBlock)
266
+ return [];
267
+ const chain = [firstBlock];
268
+ let lastIndex = startIndex;
269
+ while (blocks[lastIndex].keepWithNext) {
270
+ const nextBlock = blocks[lastIndex + 1];
271
+ if (!nextBlock || nextBlock.isPageBreak || nextBlock.pageBreakBefore) {
272
+ break;
273
+ }
274
+ chain.push(nextBlock);
275
+ lastIndex++;
276
+ }
277
+ return chain;
278
+ }
279
+ /**
280
+ * Measures the visible body height of a keep-with-next chain using the same
281
+ * collapsed-margin rules as normal block placement. The trailing margin is
282
+ * intentionally excluded, matching the individual block fit check.
283
+ */
284
+ measureKeepWithNextChainBodyHeight(chain, previousMarginBottomPt, isFirstOnPage) {
285
+ const firstBlock = chain[0];
286
+ if (!firstBlock)
287
+ return 0;
288
+ const firstMarginTop = isFirstOnPage
289
+ ? firstBlock.marginTopPt
290
+ : Math.max(firstBlock.marginTopPt, previousMarginBottomPt) - previousMarginBottomPt;
291
+ let bodyHeight = firstMarginTop + firstBlock.heightPt;
292
+ for (let index = 1; index < chain.length; index++) {
293
+ const previousBlock = chain[index - 1];
294
+ const block = chain[index];
295
+ bodyHeight += Math.max(previousBlock.marginBottomPt, block.marginTopPt) + block.heightPt;
296
+ }
297
+ return bodyHeight;
298
+ }
299
+ /**
300
+ * Finds footnote references introduced by a sequence of blocks, preserving
301
+ * document order and excluding references already assigned to the page.
302
+ */
303
+ collectNewFootnoteIds(blocks, existingFootnoteIds) {
304
+ const knownIds = new Set(existingFootnoteIds);
305
+ const newIds = [];
306
+ for (const block of blocks) {
307
+ for (const id of this.extractFootnoteRefs(block.element)) {
308
+ if (!knownIds.has(id)) {
309
+ knownIds.add(id);
310
+ newIds.push(id);
311
+ }
312
+ }
313
+ }
314
+ return newIds;
315
+ }
316
+ /**
317
+ * The shortest body available to a section's first, default, or even page.
318
+ * A row fragment must fit every variant, otherwise a later header/footer could
319
+ * send it through the oversized-block fallback again.
320
+ */
321
+ smallestEffectiveContentHeight(dims, sectionIndex) {
322
+ return Math.min(this.getEffectiveHeights(dims, sectionIndex, 1, 1).contentHeight, this.getEffectiveHeights(dims, sectionIndex, 2, 1).contentHeight, this.getEffectiveHeights(dims, sectionIndex, 2, 2).contentHeight);
323
+ }
324
+ /**
325
+ * Builds a clone of a simple table wrapper containing a contiguous run of rows.
326
+ * Complex table features are deliberately rejected by the caller: a split across
327
+ * merged cells, nested tables, or footnotes cannot be made correct by cloning rows.
328
+ */
329
+ createSimpleTableFragment(wrapper, table, body, rows, retainAnchor) {
330
+ const wrapperClone = wrapper.cloneNode(false);
331
+ const tableClone = table.cloneNode(false);
332
+ // The eligibility gate permits only colgroups alongside the body. Keep each
333
+ // colgroup so fixed and proportional column widths remain stable per fragment.
334
+ for (const child of Array.from(table.children)) {
335
+ if (child !== body) {
336
+ tableClone.appendChild(child.cloneNode(true));
337
+ }
338
+ }
339
+ const bodyClone = body.cloneNode(false);
340
+ for (const row of rows) {
341
+ bodyClone.appendChild(row.cloneNode(true));
342
+ }
343
+ tableClone.appendChild(bodyClone);
344
+ if (!retainAnchor) {
345
+ wrapperClone.removeAttribute("data-anchor");
346
+ tableClone.removeAttribute("data-anchor");
347
+ }
348
+ wrapperClone.appendChild(tableClone);
349
+ return wrapperClone;
350
+ }
351
+ /**
352
+ * Splits an oversized, ordinary table at row boundaries. This only participates
353
+ * in the existing oversized-block fallback; unsupported tables keep the previous
354
+ * overflow behavior rather than risking broken table semantics.
355
+ */
356
+ trySplitSimpleOversizedTable(block, dims, sectionIndex) {
357
+ const wrapper = block.element;
358
+ if (wrapper.tagName !== "DIV" ||
359
+ wrapper.children.length !== 1 ||
360
+ block.keepWithNext ||
361
+ block.keepLines ||
362
+ block.pageBreakBefore ||
363
+ block.isPageBreak) {
364
+ return null;
365
+ }
366
+ const table = wrapper.firstElementChild;
367
+ if (!(table instanceof HTMLTableElement)) {
368
+ return null;
369
+ }
370
+ const body = table.tBodies.length === 1 ? table.tBodies[0] : null;
371
+ if (!body ||
372
+ table.tHead ||
373
+ table.tFoot ||
374
+ body.rows.length < 2 ||
375
+ Array.from(table.children).some(child => child !== body && child.tagName !== "COLGROUP") ||
376
+ table.querySelector("table, [rowspan], [colspan], [data-footnote-id]") ||
377
+ wrapper.querySelector("[data-footnote-id]")) {
378
+ return null;
379
+ }
380
+ const rows = Array.from(body.rows);
381
+ const minimumContentHeight = this.smallestEffectiveContentHeight(dims, sectionIndex);
382
+ // Use the source's full vertical margins while forming groups. Continuation
383
+ // fragments later clear their joining margins, so this conservative bound
384
+ // cannot create a fragment that overflows a header/footer variant.
385
+ const maximumFragmentHeight = minimumContentHeight - block.marginTopPt - block.marginBottomPt;
386
+ if (maximumFragmentHeight <= 0) {
387
+ return null;
388
+ }
389
+ const groups = [];
390
+ let start = 0;
391
+ while (start < rows.length) {
392
+ let end = start;
393
+ while (end < rows.length) {
394
+ const candidate = this.createSimpleTableFragment(wrapper, table, body, rows.slice(start, end + 1), start === 0);
395
+ const measured = this.measureElement(candidate, dims);
396
+ if (measured.heightPt > maximumFragmentHeight) {
397
+ break;
398
+ }
399
+ end++;
400
+ }
401
+ // Even a one-row fragment cannot fit. Preserve the established overflow
402
+ // fallback rather than looping or clipping a partially split row.
403
+ if (end === start) {
404
+ return null;
405
+ }
406
+ groups.push(rows.slice(start, end));
407
+ start = end;
408
+ }
409
+ if (groups.length < 2) {
410
+ return null;
411
+ }
412
+ const fragments = [];
413
+ for (let index = 0; index < groups.length; index++) {
414
+ const isFirst = index === 0;
415
+ const isLast = index === groups.length - 1;
416
+ const fragment = this.createSimpleTableFragment(wrapper, table, body, groups[index], isFirst);
417
+ // Keep the source's outer spacing only at the table's real boundaries.
418
+ // Continuation margins would otherwise add blank space at the top/bottom
419
+ // of every paginated fragment.
420
+ if (!isFirst) {
421
+ fragment.style.setProperty("margin-top", "0", "important");
422
+ }
423
+ if (!isLast) {
424
+ fragment.style.setProperty("margin-bottom", "0", "important");
425
+ }
426
+ const measured = this.measureElement(fragment, dims);
427
+ if (measured.heightPt + measured.marginTopPt + measured.marginBottomPt >
428
+ minimumContentHeight) {
429
+ return null;
430
+ }
431
+ fragments.push({
432
+ ...measured,
433
+ keepWithNext: false,
434
+ keepLines: false,
435
+ pageBreakBefore: false,
436
+ isPageBreak: false,
437
+ });
438
+ }
439
+ return fragments;
440
+ }
441
+ /**
442
+ * A DOM endpoint that can finish a paragraph fragment. Endpoints are chosen
443
+ * after whitespace or at a run boundary so the paginator never deliberately
444
+ * cuts through an ordinary word merely to fill a little more of a page.
445
+ */
446
+ paragraphFragmentEndpoints(paragraph) {
447
+ const endpoints = [];
448
+ const walker = document.createTreeWalker(paragraph, NodeFilter.SHOW_TEXT);
449
+ let textNode;
450
+ while ((textNode = walker.nextNode())) {
451
+ const text = textNode.data;
452
+ if (text.length === 0)
453
+ continue;
454
+ // A whitespace boundary preserves normal word wrapping. Always retain the
455
+ // end of a run too: adjacent runs can change formatting without containing
456
+ // a whitespace character between them.
457
+ const whitespace = /\s+/g;
458
+ let match;
459
+ while ((match = whitespace.exec(text)) !== null) {
460
+ endpoints.push({ node: textNode, offset: match.index + match[0].length });
461
+ }
462
+ if (endpoints.length === 0 || endpoints[endpoints.length - 1].node !== textNode ||
463
+ endpoints[endpoints.length - 1].offset !== text.length) {
464
+ endpoints.push({ node: textNode, offset: text.length });
465
+ }
466
+ }
467
+ return endpoints;
468
+ }
469
+ /**
470
+ * Whether a range contains visible text after ignoring bidi/zero-width marks.
471
+ * Paragraph fragmentation deliberately excludes non-textual descendants, so
472
+ * this is enough to reject empty head or tail fragments.
473
+ */
474
+ hasVisibleFragmentText(fragment) {
475
+ return (fragment.textContent || "")
476
+ .replace(/[\u200B-\u200F\uFEFF]/g, "")
477
+ .trim()
478
+ .length > 0;
479
+ }
480
+ /**
481
+ * Phase one only handles text-like paragraph descendants. Objects, explicit
482
+ * line breaks, list markers, notes, and out-of-flow/inline-block content all
483
+ * require their own line-layout rules and retain the established whole-block
484
+ * fallback instead of risking broken content or duplicate anchors.
485
+ */
486
+ canFragmentParagraph(block) {
487
+ if (!this.fragmentParagraphs || block.element.tagName !== "P") {
488
+ return false;
489
+ }
490
+ const paragraph = block.element;
491
+ if (block.keepWithNext ||
492
+ block.keepLines ||
493
+ block.pageBreakBefore ||
494
+ block.isPageBreak ||
495
+ paragraph.dataset.widowControl === "true" ||
496
+ paragraph.hasAttribute("contenteditable")) {
497
+ return false;
498
+ }
499
+ // The outer paragraph may carry its source identity. Descendant identities
500
+ // are not safe to duplicate in a continuation fragment, so reject them.
501
+ const unsupportedDescendants = [
502
+ "br",
503
+ "img",
504
+ "picture",
505
+ "svg",
506
+ "math",
507
+ "canvas",
508
+ "video",
509
+ "audio",
510
+ "iframe",
511
+ "object",
512
+ "embed",
513
+ "input",
514
+ "button",
515
+ "select",
516
+ "textarea",
517
+ "table",
518
+ "ol",
519
+ "ul",
520
+ "li",
521
+ "dl",
522
+ "div",
523
+ "p",
524
+ "section",
525
+ "article",
526
+ "aside",
527
+ "figure",
528
+ "fieldset",
529
+ "details",
530
+ "[data-footnote-id]",
531
+ "[data-list-marker]",
532
+ "[data-anchor]",
533
+ "[id]",
534
+ "[contenteditable]",
535
+ ].join(", ");
536
+ if (paragraph.querySelector(unsupportedDescendants)) {
537
+ return false;
538
+ }
539
+ const paragraphStyle = window.getComputedStyle(paragraph);
540
+ if (paragraphStyle.display !== "block" ||
541
+ paragraphStyle.position !== "static" ||
542
+ paragraphStyle.float !== "none" ||
543
+ paragraphStyle.whiteSpace !== "normal" ||
544
+ paragraphStyle.breakBefore !== "auto" ||
545
+ paragraphStyle.breakAfter !== "auto" ||
546
+ paragraphStyle.breakInside === "avoid" ||
547
+ paragraphStyle.pageBreakBefore !== "auto" ||
548
+ paragraphStyle.pageBreakAfter !== "auto" ||
549
+ paragraphStyle.pageBreakInside === "avoid") {
550
+ return false;
551
+ }
552
+ // A range clone preserves nested inline formatting exactly. Anything that
553
+ // establishes its own box/layout context is intentionally deferred until a
554
+ // future fragmenter can model it accurately.
555
+ for (const descendant of Array.from(paragraph.querySelectorAll("*"))) {
556
+ const style = window.getComputedStyle(descendant);
557
+ if (style.display !== "inline" ||
558
+ style.position !== "static" ||
559
+ style.float !== "none" ||
560
+ style.whiteSpace !== "normal") {
561
+ return false;
562
+ }
563
+ }
564
+ return true;
565
+ }
566
+ /**
567
+ * Builds one range-cloned paragraph fragment. Only the leading fragment keeps
568
+ * the source paragraph's addressability; continuations must not duplicate an
569
+ * id/data-anchor in the rendered document.
570
+ */
571
+ createParagraphFragment(paragraph, range, retainSourceIdentity, isFinalFragment) {
572
+ const fragment = paragraph.cloneNode(false);
573
+ const contents = range.cloneContents();
574
+ fragment.appendChild(contents);
575
+ if (!retainSourceIdentity) {
576
+ fragment.removeAttribute("id");
577
+ fragment.removeAttribute("data-anchor");
578
+ // A continuation starts with a normal line rather than repeating a first-
579
+ // line/hanging indent. Keep the side margin so paragraph alignment remains.
580
+ fragment.style.setProperty("margin-top", "0", "important");
581
+ fragment.style.setProperty("text-indent", "0", "important");
582
+ }
583
+ if (!isFinalFragment) {
584
+ // The original bottom spacing belongs after the complete paragraph, not
585
+ // between synthetic page fragments.
586
+ fragment.style.setProperty("margin-bottom", "0", "important");
587
+ }
588
+ return fragment;
589
+ }
590
+ /**
591
+ * Splits a simple paragraph at the largest DOM Range endpoint that fits the
592
+ * currently available body space. The caller then processes the tail normally,
593
+ * allowing it to fragment again on later pages when necessary.
594
+ */
595
+ tryFragmentParagraph(block, dims, availableHeightPt, effectiveMarginTopPt) {
596
+ if (!this.canFragmentParagraph(block) || availableHeightPt <= effectiveMarginTopPt) {
597
+ return null;
598
+ }
599
+ const paragraph = block.element;
600
+ const endpoints = this.paragraphFragmentEndpoints(paragraph);
601
+ // The final endpoint is the full paragraph and cannot leave a tail. A
602
+ // single text run with no earlier whitespace remains an overflow fallback.
603
+ if (endpoints.length < 2) {
604
+ return null;
605
+ }
606
+ let low = 0;
607
+ let high = endpoints.length - 2;
608
+ let best = null;
609
+ // Fragment height is monotonic for the deliberately narrow eligible subset,
610
+ // so binary search avoids measuring every word in long body paragraphs.
611
+ while (low <= high) {
612
+ const middle = Math.floor((low + high) / 2);
613
+ const endpoint = endpoints[middle];
614
+ const headRange = document.createRange();
615
+ headRange.setStart(paragraph, 0);
616
+ headRange.setEnd(endpoint.node, endpoint.offset);
617
+ const headContents = headRange.cloneContents();
618
+ if (!this.hasVisibleFragmentText(headContents)) {
619
+ low = middle + 1;
620
+ continue;
621
+ }
622
+ const head = this.createParagraphFragment(paragraph, headRange, true, false);
623
+ const measured = this.measureElement(head, dims);
624
+ if (effectiveMarginTopPt + measured.heightPt <= availableHeightPt) {
625
+ best = { endpoint, element: head, measured };
626
+ low = middle + 1;
627
+ }
628
+ else {
629
+ high = middle - 1;
630
+ }
631
+ }
632
+ if (!best) {
633
+ return null;
634
+ }
635
+ const tailRange = document.createRange();
636
+ tailRange.setStart(best.endpoint.node, best.endpoint.offset);
637
+ tailRange.setEnd(paragraph, paragraph.childNodes.length);
638
+ const tailContents = tailRange.cloneContents();
639
+ if (!this.hasVisibleFragmentText(tailContents)) {
640
+ return null;
641
+ }
642
+ const tail = this.createParagraphFragment(paragraph, tailRange, false, true);
643
+ const tailMeasured = this.measureElement(tail, dims);
644
+ return [
645
+ {
646
+ ...best.measured,
647
+ element: best.element,
648
+ keepWithNext: false,
649
+ keepLines: false,
650
+ pageBreakBefore: false,
651
+ isPageBreak: false,
652
+ },
653
+ {
654
+ ...tailMeasured,
655
+ element: tail,
656
+ keepWithNext: false,
657
+ keepLines: false,
658
+ pageBreakBefore: false,
659
+ isPageBreak: false,
660
+ },
661
+ ];
662
+ }
164
663
  /**
165
664
  * Parses the header/footer registry from the staging element.
166
665
  * Also measures heights during parsing for lazy-loading compatibility.
@@ -267,12 +766,16 @@ export class PaginationEngine {
267
766
  if ((footnoteIds.length === 0 && !hasContinuation) || this.footnoteRegistry.size === 0) {
268
767
  return 0;
269
768
  }
769
+ // Measure in the SAME styling context the notes render in: `.page-footnotes` carries
770
+ // font-size 0.85em and line-height 1.4, so measuring without the class sizes the note
771
+ // block against body type and the reserve can never match what is drawn.
270
772
  // Create a temporary measurement container
271
773
  const measureContainer = document.createElement("div");
272
774
  measureContainer.style.position = "absolute";
273
775
  measureContainer.style.visibility = "hidden";
274
776
  measureContainer.style.width = `${contentWidth}pt`;
275
777
  measureContainer.style.left = "-9999px";
778
+ measureContainer.className = this.cssPrefix + "footnotes";
276
779
  // Add separator line (same as will be rendered)
277
780
  const hr = document.createElement("hr");
278
781
  measureContainer.appendChild(hr);
@@ -313,6 +816,7 @@ export class PaginationEngine {
313
816
  measureContainer.style.visibility = "hidden";
314
817
  measureContainer.style.width = `${contentWidth}pt`;
315
818
  measureContainer.style.left = "-9999px";
819
+ measureContainer.className = this.cssPrefix + "footnotes";
316
820
  // Add separator line
317
821
  const hr = document.createElement("hr");
318
822
  measureContainer.appendChild(hr);
@@ -331,16 +835,29 @@ export class PaginationEngine {
331
835
  * Returns the elements that fit and the elements that need to continue.
332
836
  */
333
837
  splitFootnoteToFit(footnoteElement, availableHeightPt, contentWidth) {
334
- // Get child elements (paragraphs) of the footnote content
838
+ // Get child elements (paragraphs) of the footnote content.
839
+ //
840
+ // `fits` is spliced into a freshly built `.footnote-item` > `.footnote-content` wrapper by
841
+ // addPageFootnotes, so it must contain the note's CONTENT, never the note element itself.
842
+ // Returning the whole `.footnote-item` here nested a complete item (number span and all)
843
+ // inside another item's content span; the inner block-level div then broke the line, so the
844
+ // note's number rendered alone above its text — the same visible symptom as the escaped-CSS
845
+ // bug, from an unrelated cause, on the notes that happened to take a can't-split path.
335
846
  const footnoteContent = footnoteElement.querySelector(".footnote-content");
336
847
  if (!footnoteContent) {
337
- // No content structure, can't split - return whole footnote
338
- return { fits: [footnoteElement.cloneNode(true)], overflow: [] };
848
+ // No content structure to split — hand back the element's own children.
849
+ return {
850
+ fits: Array.from(footnoteElement.children).map((el) => el.cloneNode(true)),
851
+ overflow: [],
852
+ };
339
853
  }
340
854
  const children = Array.from(footnoteContent.children);
341
855
  if (children.length <= 1) {
342
- // Single paragraph, can't split at paragraph level
343
- return { fits: [footnoteElement.cloneNode(true)], overflow: [] };
856
+ // Single paragraph: can't split at paragraph level, but the whole content still fits.
857
+ return {
858
+ fits: children.map((el) => el.cloneNode(true)),
859
+ overflow: [],
860
+ };
344
861
  }
345
862
  const fits = [];
346
863
  const overflow = [];
@@ -351,6 +868,7 @@ export class PaginationEngine {
351
868
  hrMeasure.style.visibility = "hidden";
352
869
  hrMeasure.style.width = `${contentWidth}pt`;
353
870
  hrMeasure.style.left = "-9999px";
871
+ hrMeasure.className = this.cssPrefix + "footnotes";
354
872
  const hr = document.createElement("hr");
355
873
  hrMeasure.appendChild(hr);
356
874
  this.stagingElement.appendChild(hrMeasure);
@@ -367,6 +885,7 @@ export class PaginationEngine {
367
885
  measureContainer.style.visibility = "hidden";
368
886
  measureContainer.style.width = `${contentWidth}pt`;
369
887
  measureContainer.style.left = "-9999px";
888
+ measureContainer.className = this.cssPrefix + "footnotes";
370
889
  measureContainer.appendChild(child.cloneNode(true));
371
890
  this.stagingElement.appendChild(measureContainer);
372
891
  const childHeight = pxToPt(measureContainer.getBoundingClientRect().height);
@@ -397,6 +916,7 @@ export class PaginationEngine {
397
916
  measureContainer.style.visibility = "hidden";
398
917
  measureContainer.style.width = `${contentWidth}pt`;
399
918
  measureContainer.style.left = "-9999px";
919
+ measureContainer.className = this.cssPrefix + "footnotes";
400
920
  measureContainer.appendChild(footnote.cloneNode(true));
401
921
  this.stagingElement.appendChild(measureContainer);
402
922
  const rect = measureContainer.getBoundingClientRect();
@@ -618,6 +1138,18 @@ export class PaginationEngine {
618
1138
  let currentContinuation = this.pendingFootnoteContinuation;
619
1139
  // Track any new continuation that will carry to next page
620
1140
  let nextPageContinuation = null;
1141
+ /**
1142
+ * Whole notes that could not be started on this page and must render on the next one.
1143
+ *
1144
+ * `nextPageContinuation` is a single slot, but the code that fills it runs once per note in a
1145
+ * page's citation list — so when two notes on the same page both failed to fit, the second
1146
+ * overwrote the first and that note was never rendered anywhere. On a 94-footnote document
1147
+ * four notes disappeared from the output entirely. A deferral queue keeps the single-slot
1148
+ * continuation for its real meaning (the tail of a note that was SPLIT) and carries
1149
+ * never-started notes forward as ordinary footnote ids, which the next page already knows how
1150
+ * to lay out.
1151
+ */
1152
+ let deferredFootnoteIds = [];
621
1153
  // Track partial footnotes for current page (footnotes that were split)
622
1154
  let currentPartialFootnotes = [];
623
1155
  // Account for any continuation from previous section/page
@@ -625,7 +1157,8 @@ export class PaginationEngine {
625
1157
  currentFootnoteHeight = this.measureContinuationHeight(currentContinuation, dims.contentWidth);
626
1158
  }
627
1159
  const finishPage = () => {
628
- if (currentContent.length === 0 && !currentContinuation)
1160
+ const hasCurrentContinuation = (currentContinuation?.remainingElements.length ?? 0) > 0;
1161
+ if (currentContent.length === 0 && !hasCurrentContinuation)
629
1162
  return;
630
1163
  const page = this.createPage(dims, pageNumber, sectionIndex, currentContent, pageInSection, currentFootnoteIds, currentFootnoteHeight, currentContinuation, currentPartialFootnotes.length > 0 ? currentPartialFootnotes : undefined);
631
1164
  pages.push(page);
@@ -642,6 +1175,13 @@ export class PaginationEngine {
642
1175
  // Carry over continuation to next page
643
1176
  currentContinuation = nextPageContinuation;
644
1177
  nextPageContinuation = null;
1178
+ // Notes that never got started land at the top of the new page's note area. They are
1179
+ // ordinary footnotes from here on, so the normal fitting path handles them — and because
1180
+ // this page is fresh, the space they were denied now exists.
1181
+ if (deferredFootnoteIds.length > 0) {
1182
+ currentFootnoteIds = [...deferredFootnoteIds];
1183
+ deferredFootnoteIds = [];
1184
+ }
645
1185
  // Account for continuation height on new page
646
1186
  if (currentContinuation && currentContinuation.remainingElements.length > 0) {
647
1187
  currentFootnoteHeight = this.measureContinuationHeight(currentContinuation, dims.contentWidth);
@@ -649,10 +1189,12 @@ export class PaginationEngine {
649
1189
  else {
650
1190
  currentFootnoteHeight = 0;
651
1191
  }
1192
+ if (currentFootnoteIds.length > 0) {
1193
+ currentFootnoteHeight += this.measureFootnotesHeight(currentFootnoteIds, dims.contentWidth, null);
1194
+ }
652
1195
  };
653
1196
  for (let i = 0; i < blocks.length; i++) {
654
1197
  const block = blocks[i];
655
- const nextBlock = blocks[i + 1];
656
1198
  // Handle explicit page breaks
657
1199
  if (block.isPageBreak) {
658
1200
  finishPage();
@@ -662,10 +1204,50 @@ export class PaginationEngine {
662
1204
  if (block.pageBreakBefore && currentContent.length > 0) {
663
1205
  finishPage();
664
1206
  }
1207
+ // A series of keep-with-next blocks is one indivisible placement unit
1208
+ // when it can fit a new page. The former one-block lookahead was never
1209
+ // applied to the placement decision, and it could not preserve a chain of
1210
+ // headings/paragraphs. Do not force an oversized chain to a fresh page:
1211
+ // its members retain the established greedy/overflow behavior instead.
1212
+ const previousBlock = blocks[i - 1];
1213
+ const startsKeepChain = !previousBlock ||
1214
+ !previousBlock.keepWithNext ||
1215
+ previousBlock.isPageBreak ||
1216
+ block.pageBreakBefore;
1217
+ // A footnote continuation also occupies this page even when its body has
1218
+ // no blocks yet. In that case a feasible chain may need to move past the
1219
+ // continuation as a unit.
1220
+ const pageHasOccupiedSpace = currentContent.length > 0 || currentFootnoteHeight > 0;
1221
+ if (pageHasOccupiedSpace && block.keepWithNext && startsKeepChain) {
1222
+ const keepChain = this.getKeepWithNextChain(blocks, i);
1223
+ if (keepChain.length > 1) {
1224
+ const newChainFootnoteIds = this.collectNewFootnoteIds(keepChain, currentFootnoteIds);
1225
+ let additionalChainFootnoteHeight = 0;
1226
+ if (newChainFootnoteIds.length > 0 && this.footnoteRegistry.size > 0) {
1227
+ const totalChainFootnoteHeight = this.measureFootnotesHeight([...currentFootnoteIds, ...newChainFootnoteIds], dims.contentWidth, currentContinuation);
1228
+ additionalChainFootnoteHeight = Math.max(0, totalChainFootnoteHeight - currentFootnoteHeight);
1229
+ }
1230
+ const currentChainHeight = this.measureKeepWithNextChainBodyHeight(keepChain, prevMarginBottomPt, currentContent.length === 0) +
1231
+ additionalChainFootnoteHeight;
1232
+ const currentAvailableHeight = remainingHeight - currentFootnoteHeight;
1233
+ if (currentChainHeight > currentAvailableHeight) {
1234
+ const nextPageHeights = this.getEffectiveHeights(dims, sectionIndex, pageInSection + 1, pageNumber + 1);
1235
+ const freshChainBodyHeight = this.measureKeepWithNextChainBodyHeight(keepChain, 0, true);
1236
+ // finishPage transfers this continuation to the new page's
1237
+ // currentContinuation state, so include it in the destination
1238
+ // page's footnote reservation before deciding to move the chain.
1239
+ const freshChainFootnoteHeight = this.measureFootnotesHeight(newChainFootnoteIds, dims.contentWidth, nextPageContinuation);
1240
+ if (freshChainBodyHeight + freshChainFootnoteHeight <=
1241
+ nextPageHeights.contentHeight) {
1242
+ finishPage();
1243
+ }
1244
+ }
1245
+ }
1246
+ }
665
1247
  // Extract footnote references from this block
666
- const blockFootnoteIds = this.extractFootnoteRefs(block.element);
1248
+ const allBlockFootnoteIds = this.extractFootnoteRefs(block.element);
667
1249
  // Only count new footnotes (not already on this page)
668
- const newFootnoteIds = blockFootnoteIds.filter(id => !currentFootnoteIds.includes(id));
1250
+ const newFootnoteIds = this.collectNewFootnoteIds([block], currentFootnoteIds);
669
1251
  // Calculate additional footnote height if this block is added
670
1252
  let additionalFootnoteHeight = 0;
671
1253
  if (newFootnoteIds.length > 0 && this.footnoteRegistry.size > 0) {
@@ -688,20 +1270,24 @@ export class PaginationEngine {
688
1270
  // bottom margin extends beyond the content area and is clipped by overflow:hidden.
689
1271
  // It is still tracked in remainingHeight for correct margin collapsing with the next block.
690
1272
  const blockSpace = effectiveMarginTop + block.heightPt + additionalFootnoteHeight;
691
- // Calculate needed height (including keepWithNext)
692
- let neededHeight = blockSpace;
693
- if (block.keepWithNext && nextBlock && !nextBlock.isPageBreak) {
694
- // For keepWithNext, include the next block with collapsed margins
695
- const collapsedMargin = Math.max(block.marginBottomPt, nextBlock.marginTopPt);
696
- neededHeight = effectiveMarginTop + block.heightPt + collapsedMargin +
697
- nextBlock.heightPt + additionalFootnoteHeight;
698
- }
699
1273
  // Effective remaining height (content area minus footnotes already on page)
700
1274
  const effectiveRemainingHeight = remainingHeight - currentFootnoteHeight;
701
1275
  // Calculate maximum footnote area for this page (can expand into body content space)
702
1276
  const bodyContentUsed = effectiveContentHeight - remainingHeight;
703
1277
  const maxFootnoteArea = effectiveContentHeight * MAX_FOOTNOTE_AREA_RATIO;
704
1278
  const maxFootnoteExpansion = Math.max(0, maxFootnoteArea - currentFootnoteHeight);
1279
+ // A paragraph that cannot fit as a whole may still have a simple text-only
1280
+ // prefix that fits this page. Fragment before the ordinary next-page or
1281
+ // oversized fallback so the cloned head participates in the same margin
1282
+ // and footnote accounting as every other block.
1283
+ if (blockSpace > effectiveRemainingHeight) {
1284
+ const paragraphFragments = this.tryFragmentParagraph(block, dims, effectiveRemainingHeight, effectiveMarginTop);
1285
+ if (paragraphFragments) {
1286
+ blocks.splice(i, 1, ...paragraphFragments);
1287
+ i--;
1288
+ continue;
1289
+ }
1290
+ }
705
1291
  // Check if block fits on current page (including its footnotes)
706
1292
  if (blockSpace <= effectiveRemainingHeight) {
707
1293
  // Block fits with current footnote allocation
@@ -752,34 +1338,23 @@ export class PaginationEngine {
752
1338
  footnoteId,
753
1339
  fittingElements: fits
754
1340
  });
755
- nextPageContinuation = {
756
- footnoteId,
757
- remainingElements: overflow
758
- };
1341
+ if (overflow.length > 0) {
1342
+ nextPageContinuation = {
1343
+ footnoteId,
1344
+ remainingElements: overflow
1345
+ };
1346
+ }
759
1347
  currentFootnoteHeight = availableForFootnotes;
760
1348
  }
761
1349
  else {
762
- // Nothing fits, entire footnote continues to next page
763
- nextPageContinuation = {
764
- footnoteId,
765
- remainingElements: Array.from(footnote.querySelectorAll(".footnote-content > *"))
766
- .map(el => el.cloneNode(true))
767
- };
768
- if (nextPageContinuation.remainingElements.length === 0) {
769
- nextPageContinuation.remainingElements = [footnote.cloneNode(true)];
770
- }
1350
+ // Nothing of this note fits: defer the WHOLE note rather than assigning the
1351
+ // single continuation slot, which a later note on this page would overwrite.
1352
+ deferredFootnoteIds.push(footnoteId);
771
1353
  }
772
1354
  }
773
1355
  else {
774
- // Not enough space to start footnote - continue whole thing
775
- nextPageContinuation = {
776
- footnoteId,
777
- remainingElements: Array.from(footnote.querySelectorAll(".footnote-content > *"))
778
- .map(el => el.cloneNode(true))
779
- };
780
- if (nextPageContinuation.remainingElements.length === 0) {
781
- nextPageContinuation.remainingElements = [footnote.cloneNode(true)];
782
- }
1356
+ // Not enough space to even start the note — same deferral.
1357
+ deferredFootnoteIds.push(footnoteId);
783
1358
  }
784
1359
  }
785
1360
  }
@@ -792,8 +1367,14 @@ export class PaginationEngine {
792
1367
  const expandedFootnoteSpace = Math.min(maxFootnoteArea, additionalFootnoteHeight + currentFootnoteHeight);
793
1368
  const bodySpaceAfterExpansion = effectiveContentHeight - expandedFootnoteSpace;
794
1369
  if (blockSpaceWithoutFootnotes <= bodySpaceAfterExpansion - bodyContentUsed) {
795
- // Block fits after expanding footnote area
1370
+ // Block fits after expanding footnote area.
796
1371
  currentContent.push(block.element.cloneNode(true));
1372
+ // `remainingHeight` tracks BODY consumption only — every other branch maintains it
1373
+ // that way, and the footnote reserve is applied separately via `effectiveRemainingHeight`
1374
+ // at the top of each iteration. Assigning `bodySpaceAfterExpansion - …` here folded the
1375
+ // reserve in a second time, so a later block would see a body budget short by the whole
1376
+ // footnote area. Consistency fix: no measurable difference on the documents tested, but
1377
+ // the two meanings must not coexist or the next change here inherits a latent bug.
797
1378
  remainingHeight = bodySpaceAfterExpansion - bodyContentUsed - blockSpaceWithoutFootnotes;
798
1379
  prevMarginBottomPt = block.marginBottomPt;
799
1380
  currentFootnoteIds.push(...newFootnoteIds);
@@ -802,14 +1383,16 @@ export class PaginationEngine {
802
1383
  else {
803
1384
  // Still doesn't fit - start new page
804
1385
  finishPage();
805
- const newPageFootnoteHeight = blockFootnoteIds.length > 0
806
- ? this.measureFootnotesHeight(blockFootnoteIds, dims.contentWidth, currentContinuation)
1386
+ const newPageFootnoteHeight = allBlockFootnoteIds.length > 0
1387
+ ? this.measureFootnotesHeight(allBlockFootnoteIds, dims.contentWidth, currentContinuation)
807
1388
  : (currentContinuation ? this.measureContinuationHeight(currentContinuation, dims.contentWidth) : 0);
808
1389
  const newPageSpace = block.marginTopPt + block.heightPt + block.marginBottomPt;
809
1390
  currentContent.push(block.element.cloneNode(true));
810
1391
  remainingHeight = effectiveContentHeight - newPageSpace;
811
1392
  prevMarginBottomPt = block.marginBottomPt;
812
- currentFootnoteIds = [...blockFootnoteIds];
1393
+ // Merge, never replace: finishPage() may have just seeded this page with notes deferred
1394
+ // from the previous one, and overwriting here dropped them from the document.
1395
+ currentFootnoteIds = [...currentFootnoteIds, ...allBlockFootnoteIds];
813
1396
  currentFootnoteHeight = newPageFootnoteHeight;
814
1397
  }
815
1398
  }
@@ -818,26 +1401,42 @@ export class PaginationEngine {
818
1401
  finishPage();
819
1402
  // On new page, recalculate footnote height for just this block's footnotes
820
1403
  // (plus any continuation from previous page)
821
- const newPageFootnoteHeight = blockFootnoteIds.length > 0
822
- ? this.measureFootnotesHeight(blockFootnoteIds, dims.contentWidth, currentContinuation)
1404
+ const newPageFootnoteHeight = allBlockFootnoteIds.length > 0
1405
+ ? this.measureFootnotesHeight(allBlockFootnoteIds, dims.contentWidth, currentContinuation)
823
1406
  : (currentContinuation ? this.measureContinuationHeight(currentContinuation, dims.contentWidth) : 0);
824
1407
  // Include full top margin
825
1408
  const newPageSpace = block.marginTopPt + block.heightPt + block.marginBottomPt;
826
1409
  currentContent.push(block.element.cloneNode(true));
827
1410
  remainingHeight = effectiveContentHeight - newPageSpace;
828
1411
  prevMarginBottomPt = block.marginBottomPt;
829
- currentFootnoteIds = [...blockFootnoteIds];
1412
+ // Merge, never replace: finishPage() may have just seeded this page with notes deferred
1413
+ // from the previous one, and overwriting here dropped them from the document.
1414
+ currentFootnoteIds = [...currentFootnoteIds, ...allBlockFootnoteIds];
830
1415
  currentFootnoteHeight = newPageFootnoteHeight;
831
1416
  }
832
1417
  }
833
1418
  else {
834
- // Block is taller than a page - add it and let it overflow
835
- // (In a more sophisticated implementation, we would split the block)
1419
+ // Block is taller than a page. Ordinary tables can be split at complete
1420
+ // row boundaries; every other block retains the established overflow path.
1421
+ const tableFragments = this.trySplitSimpleOversizedTable(block, dims, sectionIndex);
1422
+ if (tableFragments) {
1423
+ if (currentContent.length > 0 || currentContinuation) {
1424
+ finishPage();
1425
+ }
1426
+ blocks.splice(i, 1, ...tableFragments);
1427
+ i--;
1428
+ continue;
1429
+ }
1430
+ // Unsupported oversized blocks are intentionally left intact. Splitting
1431
+ // arbitrary HTML, merged tables, or footnote-bearing tables would be less
1432
+ // correct than the prior clipped fallback.
836
1433
  if (currentContent.length > 0) {
837
1434
  finishPage();
838
1435
  }
839
1436
  currentContent.push(block.element.cloneNode(true));
840
- currentFootnoteIds = [...blockFootnoteIds];
1437
+ // Merge, never replace: finishPage() may have just seeded this page with notes deferred
1438
+ // from the previous one, and overwriting here dropped them from the document.
1439
+ currentFootnoteIds = [...currentFootnoteIds, ...allBlockFootnoteIds];
841
1440
  finishPage();
842
1441
  }
843
1442
  }
@@ -847,6 +1446,28 @@ export class PaginationEngine {
847
1446
  this.pendingFootnoteContinuation = nextPageContinuation;
848
1447
  return pages;
849
1448
  }
1449
+ /**
1450
+ * Strip block addressing from a header/footer node cloned into a page box.
1451
+ *
1452
+ * A running story is authored ONCE and cloned onto every page, so the clones all carry the same
1453
+ * `data-anchor` — on this document, 42 page boxes claiming one footer paragraph. Left editable,
1454
+ * committing any one of them writes back through that single shared anchor, and the per-page
1455
+ * page-number substitution makes it worse: each clone shows a DIFFERENT number, so a commit
1456
+ * writes that page's number into the story as literal text and destroys the PAGE field.
1457
+ *
1458
+ * Page-box header/footer content is presentation. The docked editing bands
1459
+ * (`editor-headerfooter.ts`) are the addressable affordance, and they exist precisely because a
1460
+ * cloned node cannot be uniquely addressed.
1461
+ */
1462
+ makeClonedStoryInert(root) {
1463
+ const nodes = [root, ...Array.from(root.querySelectorAll("*"))];
1464
+ for (const el of nodes) {
1465
+ el.removeAttribute("data-anchor");
1466
+ el.removeAttribute("data-committed-text");
1467
+ if (el.getAttribute("contenteditable") !== null)
1468
+ el.setAttribute("contenteditable", "false");
1469
+ }
1470
+ }
850
1471
  /**
851
1472
  * Creates a page container element.
852
1473
  */
@@ -883,6 +1504,9 @@ export class PaginationEngine {
883
1504
  pageBox.style.contain = "layout paint";
884
1505
  pageBox.dataset.pageNumber = String(pageNumber);
885
1506
  pageBox.dataset.sectionIndex = String(sectionIndex);
1507
+ // Needed by substitutePageNumberFields: a section that restarts numbering counts from its own
1508
+ // first page, not from the document's.
1509
+ pageBox.dataset.pageInSection = String(pageInSection);
886
1510
  // Get pre-computed effective heights for this page position (no re-measurement needed)
887
1511
  const effectiveHeights = this.getEffectiveHeights(dims, sectionIndex, pageInSection, pageNumber);
888
1512
  // Add header if available for this section/page
@@ -903,7 +1527,10 @@ export class PaginationEngine {
903
1527
  headerDiv.style.paddingBottom = "4pt"; // Small gap before content area
904
1528
  // Clone the header content (skip the wrapper div's data attributes)
905
1529
  for (const child of Array.from(headerSource.childNodes)) {
906
- headerDiv.appendChild(child.cloneNode(true));
1530
+ const clonedheaderDiv = child.cloneNode(true);
1531
+ if (clonedheaderDiv.nodeType === 1)
1532
+ this.makeClonedStoryInert(clonedheaderDiv);
1533
+ headerDiv.appendChild(clonedheaderDiv);
907
1534
  }
908
1535
  pageBox.appendChild(headerDiv);
909
1536
  }
@@ -946,7 +1573,10 @@ export class PaginationEngine {
946
1573
  footerDiv.style.paddingTop = "4pt"; // Small gap after content area
947
1574
  // Clone the footer content (skip the wrapper div's data attributes)
948
1575
  for (const child of Array.from(footerSource.childNodes)) {
949
- footerDiv.appendChild(child.cloneNode(true));
1576
+ const clonedfooterDiv = child.cloneNode(true);
1577
+ if (clonedfooterDiv.nodeType === 1)
1578
+ this.makeClonedStoryInert(clonedfooterDiv);
1579
+ footerDiv.appendChild(clonedfooterDiv);
950
1580
  }
951
1581
  pageBox.appendChild(footerDiv);
952
1582
  }
@@ -959,6 +1589,24 @@ export class PaginationEngine {
959
1589
  }
960
1590
  // Add to container
961
1591
  this.containerElement.appendChild(pageBox);
1592
+ // Body and notes must occupy DISJOINT bands. The note block is absolutely positioned against
1593
+ // the page bottom and grows upward, while the content area spans the whole text height — so
1594
+ // nothing but the layout loop's arithmetic keeps them apart, and any disagreement between the
1595
+ // height the loop reserved and the height the notes actually render at draws body text and note
1596
+ // text on top of each other (observed on a 94-footnote document: ~134pt of superimposed,
1597
+ // illegible glyphs). Shrinking the content area to the space the notes actually left removes
1598
+ // the failure mode by construction — the worst case becomes a clean clip by the content area's
1599
+ // existing `overflow: hidden`, which is obvious and recoverable rather than silent corruption.
1600
+ //
1601
+ // This runs AFTER the append on purpose: `getBoundingClientRect()` on a detached node is all
1602
+ // zeroes, so measuring while building the page silently did nothing.
1603
+ const notesEl = pageBox.querySelector(`.${this.cssPrefix}footnotes`);
1604
+ if (notesEl) {
1605
+ const notesHeightPt = pxToPt(notesEl.getBoundingClientRect().height);
1606
+ if (notesHeightPt > 0) {
1607
+ contentArea.style.height = `${Math.max(0, contentAreaHeight - notesHeightPt)}pt`;
1608
+ }
1609
+ }
962
1610
  return {
963
1611
  pageNumber,
964
1612
  sectionIndex,
@@ -1014,7 +1662,13 @@ export function paginateHtml(html, container, options = {}) {
1014
1662
  if (!pageContainer) {
1015
1663
  throw new Error("Pagination container element not found");
1016
1664
  }
1017
- const engine = new PaginationEngine(staging, pageContainer, options);
1665
+ // This convenience function is the read-only HTML viewer path. Keep the
1666
+ // lower-level engine conservative by default, while enabling paragraph
1667
+ // fragmentation here unless a caller deliberately opts out.
1668
+ const engine = new PaginationEngine(staging, pageContainer, {
1669
+ ...options,
1670
+ fragmentParagraphs: options.fragmentParagraphs ?? true,
1671
+ });
1018
1672
  return engine.paginate();
1019
1673
  }
1020
1674
  //# sourceMappingURL=pagination.js.map