docxodus 9.8.0 → 10.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 (151) hide show
  1. package/README.md +178 -0
  2. package/dist/canonical.d.ts +7 -0
  3. package/dist/canonical.d.ts.map +1 -0
  4. package/dist/canonical.js +56 -0
  5. package/dist/canonical.js.map +1 -0
  6. package/dist/docxodus.worker.d.ts +2 -1
  7. package/dist/docxodus.worker.d.ts.map +1 -1
  8. package/dist/docxodus.worker.js +244 -3
  9. package/dist/docxodus.worker.js.map +1 -1
  10. package/dist/editor.bundle.js +2024 -388
  11. package/dist/editor.d.ts +22 -2
  12. package/dist/editor.d.ts.map +1 -1
  13. package/dist/editor.js +94 -9
  14. package/dist/editor.js.map +1 -1
  15. package/dist/embed.bundle.js +2912 -437
  16. package/dist/embed.iife.js +2911 -436
  17. package/dist/export-assets.json +283 -0
  18. package/dist/export-browser.bundle.js +7990 -0
  19. package/dist/export-browser.d.ts +266 -0
  20. package/dist/export-browser.d.ts.map +1 -0
  21. package/dist/export-browser.js +2915 -0
  22. package/dist/export-browser.js.map +1 -0
  23. package/dist/export-resource-limits-v1.json +57 -0
  24. package/dist/font-contract.d.ts +150 -0
  25. package/dist/font-contract.d.ts.map +1 -0
  26. package/dist/font-contract.js +59 -0
  27. package/dist/font-contract.js.map +1 -0
  28. package/dist/font-runtime.d.ts +33 -0
  29. package/dist/font-runtime.d.ts.map +1 -0
  30. package/dist/font-runtime.js +1100 -0
  31. package/dist/font-runtime.js.map +1 -0
  32. package/dist/index.d.ts +62 -5
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +130 -16
  35. package/dist/index.js.map +1 -1
  36. package/dist/page-geometry.d.ts +8 -1
  37. package/dist/page-geometry.d.ts.map +1 -1
  38. package/dist/page-geometry.js +37 -11
  39. package/dist/page-geometry.js.map +1 -1
  40. package/dist/pagination.bundle.js +1937 -380
  41. package/dist/pagination.d.ts +277 -15
  42. package/dist/pagination.d.ts.map +1 -1
  43. package/dist/pagination.js +2055 -438
  44. package/dist/pagination.js.map +1 -1
  45. package/dist/react.d.ts +11 -4
  46. package/dist/react.d.ts.map +1 -1
  47. package/dist/react.js +38 -5
  48. package/dist/react.js.map +1 -1
  49. package/dist/render-report-v2.schema.json +1581 -0
  50. package/dist/ribbon-chrome.d.ts +1 -1
  51. package/dist/ribbon-chrome.d.ts.map +1 -1
  52. package/dist/ribbon-chrome.js +46 -0
  53. package/dist/ribbon-chrome.js.map +1 -1
  54. package/dist/ribbon.js +70 -0
  55. package/dist/ribbon.js.map +1 -1
  56. package/dist/session.bundle.js +695 -29
  57. package/dist/session.d.ts +134 -19
  58. package/dist/session.d.ts.map +1 -1
  59. package/dist/session.js +562 -25
  60. package/dist/session.js.map +1 -1
  61. package/dist/types.d.ts +1207 -16
  62. package/dist/types.d.ts.map +1 -1
  63. package/dist/types.js.map +1 -1
  64. package/dist/wasm/_framework/DocumentFormat.OpenXml.Framework.wasm +0 -0
  65. package/dist/wasm/_framework/DocumentFormat.OpenXml.Framework.wasm.br +0 -0
  66. package/dist/wasm/_framework/DocumentFormat.OpenXml.wasm +0 -0
  67. package/dist/wasm/_framework/DocumentFormat.OpenXml.wasm.br +0 -0
  68. package/dist/wasm/_framework/Docxodus.wasm +0 -0
  69. package/dist/wasm/_framework/Docxodus.wasm.br +0 -0
  70. package/dist/wasm/_framework/DocxodusWasm.wasm +0 -0
  71. package/dist/wasm/_framework/DocxodusWasm.wasm.br +0 -0
  72. package/dist/wasm/_framework/System.Collections.Concurrent.wasm +0 -0
  73. package/dist/wasm/_framework/System.Collections.Concurrent.wasm.br +0 -0
  74. package/dist/wasm/_framework/System.Collections.Immutable.wasm +0 -0
  75. package/dist/wasm/_framework/System.Collections.Immutable.wasm.br +0 -0
  76. package/dist/wasm/_framework/System.Collections.NonGeneric.wasm +0 -0
  77. package/dist/wasm/_framework/System.Collections.NonGeneric.wasm.br +0 -0
  78. package/dist/wasm/_framework/System.Collections.Specialized.wasm +0 -0
  79. package/dist/wasm/_framework/System.Collections.Specialized.wasm.br +0 -0
  80. package/dist/wasm/_framework/System.Collections.wasm +0 -0
  81. package/dist/wasm/_framework/System.Collections.wasm.br +0 -0
  82. package/dist/wasm/_framework/System.ComponentModel.Primitives.wasm +0 -0
  83. package/dist/wasm/_framework/System.ComponentModel.Primitives.wasm.br +0 -0
  84. package/dist/wasm/_framework/System.ComponentModel.TypeConverter.wasm +0 -0
  85. package/dist/wasm/_framework/System.ComponentModel.TypeConverter.wasm.br +0 -0
  86. package/dist/wasm/_framework/System.ComponentModel.wasm +0 -0
  87. package/dist/wasm/_framework/System.ComponentModel.wasm.br +0 -0
  88. package/dist/wasm/_framework/System.Console.wasm +0 -0
  89. package/dist/wasm/_framework/System.Console.wasm.br +0 -0
  90. package/dist/wasm/_framework/System.Diagnostics.Process.wasm +0 -0
  91. package/dist/wasm/_framework/System.Diagnostics.Process.wasm.br +0 -0
  92. package/dist/wasm/_framework/System.IO.Compression.wasm +0 -0
  93. package/dist/wasm/_framework/System.IO.Compression.wasm.br +0 -0
  94. package/dist/wasm/_framework/System.IO.Packaging.wasm +0 -0
  95. package/dist/wasm/_framework/System.IO.Packaging.wasm.br +0 -0
  96. package/dist/wasm/_framework/System.IO.Pipelines.wasm +0 -0
  97. package/dist/wasm/_framework/System.IO.Pipelines.wasm.br +0 -0
  98. package/dist/wasm/_framework/System.Linq.Expressions.wasm +0 -0
  99. package/dist/wasm/_framework/System.Linq.Expressions.wasm.br +0 -0
  100. package/dist/wasm/_framework/System.Linq.wasm +0 -0
  101. package/dist/wasm/_framework/System.Linq.wasm.br +0 -0
  102. package/dist/wasm/_framework/System.Memory.wasm +0 -0
  103. package/dist/wasm/_framework/System.Memory.wasm.br +0 -0
  104. package/dist/wasm/_framework/System.Net.Http.wasm +0 -0
  105. package/dist/wasm/_framework/System.Net.Http.wasm.br +0 -0
  106. package/dist/wasm/_framework/System.Net.Primitives.wasm +0 -0
  107. package/dist/wasm/_framework/System.Net.Primitives.wasm.br +0 -0
  108. package/dist/wasm/_framework/System.ObjectModel.wasm +0 -0
  109. package/dist/wasm/_framework/System.ObjectModel.wasm.br +0 -0
  110. package/dist/wasm/_framework/System.Private.CoreLib.wasm +0 -0
  111. package/dist/wasm/_framework/System.Private.CoreLib.wasm.br +0 -0
  112. package/dist/wasm/_framework/System.Private.Uri.wasm +0 -0
  113. package/dist/wasm/_framework/System.Private.Uri.wasm.br +0 -0
  114. package/dist/wasm/_framework/System.Private.Xml.Linq.wasm +0 -0
  115. package/dist/wasm/_framework/System.Private.Xml.Linq.wasm.br +0 -0
  116. package/dist/wasm/_framework/System.Private.Xml.wasm +0 -0
  117. package/dist/wasm/_framework/System.Private.Xml.wasm.br +0 -0
  118. package/dist/wasm/_framework/System.Runtime.InteropServices.JavaScript.wasm +0 -0
  119. package/dist/wasm/_framework/System.Runtime.InteropServices.JavaScript.wasm.br +0 -0
  120. package/dist/wasm/_framework/System.Runtime.wasm +0 -0
  121. package/dist/wasm/_framework/System.Runtime.wasm.br +0 -0
  122. package/dist/wasm/_framework/System.Security.Cryptography.wasm +0 -0
  123. package/dist/wasm/_framework/System.Security.Cryptography.wasm.br +0 -0
  124. package/dist/wasm/_framework/System.Text.Encodings.Web.wasm +0 -0
  125. package/dist/wasm/_framework/System.Text.Encodings.Web.wasm.br +0 -0
  126. package/dist/wasm/_framework/System.Text.Json.wasm +0 -0
  127. package/dist/wasm/_framework/System.Text.Json.wasm.br +0 -0
  128. package/dist/wasm/_framework/System.Text.RegularExpressions.wasm +0 -0
  129. package/dist/wasm/_framework/System.Text.RegularExpressions.wasm.br +0 -0
  130. package/dist/wasm/_framework/System.Xml.Linq.wasm +0 -0
  131. package/dist/wasm/_framework/System.Xml.Linq.wasm.br +0 -0
  132. package/dist/wasm/_framework/System.Xml.XDocument.wasm +0 -0
  133. package/dist/wasm/_framework/System.Xml.XDocument.wasm.br +0 -0
  134. package/dist/wasm/_framework/System.wasm +0 -0
  135. package/dist/wasm/_framework/System.wasm.br +0 -0
  136. package/dist/wasm/_framework/dotnet.boot.js +38 -38
  137. package/dist/wasm/_framework/dotnet.boot.js.br +0 -0
  138. package/dist/wasm/_framework/dotnet.js +1 -1
  139. package/dist/wasm/_framework/dotnet.js.br +0 -0
  140. package/dist/wasm/_framework/dotnet.native.js +3 -3
  141. package/dist/wasm/_framework/dotnet.native.js.br +0 -0
  142. package/dist/wasm/_framework/dotnet.native.wasm +0 -0
  143. package/dist/wasm/_framework/dotnet.native.wasm.br +0 -0
  144. package/dist/wasm/_framework/dotnet.runtime.js +1 -1
  145. package/dist/wasm/_framework/dotnet.runtime.js.br +0 -0
  146. package/dist/worker-proxy.bundle.js +196 -29
  147. package/dist/worker-proxy.d.ts +36 -3
  148. package/dist/worker-proxy.d.ts.map +1 -1
  149. package/dist/worker-proxy.js +186 -33
  150. package/dist/worker-proxy.js.map +1 -1
  151. package/package.json +24 -7
@@ -6,12 +6,124 @@
6
6
  */
7
7
  import { formatPageNumber } from "./page-number-format.js";
8
8
  import { DEFAULT_MARGIN, DEFAULT_PAGE_WIDTH, parseSectionDimensions, ptToPx, pxToPt, resolvePageBands, } from "./page-geometry.js";
9
+ /**
10
+ * Whether two sections describe the same physical page box. A continuous section
11
+ * break is only honorable when it does — a changed page size or margin set forces
12
+ * a real page break, which is Word's behavior too.
13
+ */
14
+ function samePageBox(a, b) {
15
+ return (a.pageWidth === b.pageWidth &&
16
+ a.pageHeight === b.pageHeight &&
17
+ a.marginTop === b.marginTop &&
18
+ a.marginRight === b.marginRight &&
19
+ a.marginBottom === b.marginBottom &&
20
+ a.marginLeft === b.marginLeft);
21
+ }
22
+ /** Explicit no-pages contract for a continuous viewer. It never estimates page numbers. */
23
+ export function createUnavailablePageMap(documentVersion, rendererFingerprint, mode = "continuous") {
24
+ if (!rendererFingerprint)
25
+ throw new Error("rendererFingerprint must be non-empty");
26
+ return {
27
+ schemaVersion: 1,
28
+ mode,
29
+ availability: "unavailable",
30
+ documentVersion,
31
+ rendererFingerprint,
32
+ pages: [],
33
+ fragments: [],
34
+ };
35
+ }
36
+ const activePageCitationHighlights = new WeakMap();
37
+ /** Remove the citation highlight previously applied within this paginated root. */
38
+ export function clearPageCitationHighlight(root) {
39
+ const active = activePageCitationHighlights.get(root);
40
+ if (!active)
41
+ return;
42
+ if (active.highlightClass && active.addedClass) {
43
+ active.target.classList.remove(active.highlightClass);
44
+ }
45
+ for (const style of active.inlineStyles ?? []) {
46
+ if (style.value) {
47
+ active.target.style.setProperty(style.name, style.value, style.priority);
48
+ }
49
+ else {
50
+ active.target.style.removeProperty(style.name);
51
+ }
52
+ }
53
+ activePageCitationHighlights.delete(root);
54
+ }
55
+ /**
56
+ * Navigate an exact citation over an already-paginated DOM. The page-qualified fragment id is
57
+ * authoritative; page + canonical source identity is a compatibility fallback for older v1 DOMs.
58
+ */
59
+ export function navigateToPageCitation(root, citation, options = {}) {
60
+ clearPageCitationHighlight(root);
61
+ if (citation.availability !== "available" || citation.fragments.length === 0) {
62
+ return { navigated: false, unavailableReason: "citation_unavailable" };
63
+ }
64
+ const byAttribute = (name, value, within = root) => {
65
+ for (const node of Array.from(within.querySelectorAll(`[${name}]`))) {
66
+ if (node.getAttribute(name) === value)
67
+ return node;
68
+ }
69
+ return null;
70
+ };
71
+ const fragment = citation.fragments[0];
72
+ let target = byAttribute("data-page-fragment-id", fragment.fragmentId);
73
+ if (!target) {
74
+ const page = byAttribute("data-page-number", String(fragment.pageNumber));
75
+ if (page)
76
+ target = byAttribute("data-source-anchor-id", citation.anchorId, page) ?? page;
77
+ }
78
+ if (!target) {
79
+ return {
80
+ navigated: false,
81
+ pageNumber: fragment.pageNumber,
82
+ fragmentId: fragment.fragmentId,
83
+ unavailableReason: "fragment_not_found",
84
+ };
85
+ }
86
+ if (options.highlightClass) {
87
+ const addedClass = !target.classList.contains(options.highlightClass);
88
+ target.classList.add(options.highlightClass);
89
+ activePageCitationHighlights.set(root, {
90
+ target,
91
+ highlightClass: options.highlightClass,
92
+ addedClass,
93
+ });
94
+ }
95
+ else if (options.highlight !== false) {
96
+ const names = ["outline", "outline-offset", "background-color"];
97
+ const inlineStyles = names.map((name) => ({
98
+ name,
99
+ value: target.style.getPropertyValue(name),
100
+ priority: target.style.getPropertyPriority(name),
101
+ }));
102
+ target.style.setProperty("outline", "3px solid #f4b400", "important");
103
+ target.style.setProperty("outline-offset", "2px", "important");
104
+ target.style.setProperty("background-color", "rgba(255, 235, 59, .18)", "important");
105
+ activePageCitationHighlights.set(root, { target, inlineStyles });
106
+ }
107
+ target.scrollIntoView({
108
+ behavior: options.behavior ?? "smooth",
109
+ block: options.block ?? "center",
110
+ });
111
+ return {
112
+ navigated: true,
113
+ target,
114
+ pageNumber: fragment.pageNumber,
115
+ fragmentId: fragment.fragmentId,
116
+ };
117
+ }
9
118
  // Default letter size in points (612 x 792 = 8.5" x 11")
10
119
  // Maximum percentage of content height that footnotes can occupy
11
120
  // This allows footnotes to expand upward into body content space when needed
12
121
  const MAX_FOOTNOTE_AREA_RATIO = 0.6; // 60% of content height
13
- // Minimum body content height per page (to avoid pages with only footnotes)
14
- const MIN_BODY_CONTENT_HEIGHT = 72; // 1 inch minimum body content
122
+ // Hidden measurement and final absolutely-positioned note bands differ by sub-pixel border/margin
123
+ // rounding in Chromium. Reserve a small physical-unit guard so the last baseline stays visible.
124
+ const FOOTNOTE_MEASUREMENT_GUARD_PT = 2;
125
+ /** Internal provenance retained only while a note element waits between pages. */
126
+ const FOOTNOTE_SOURCE_POSITION_ATTR = "data-pagination-footnote-source-position";
15
127
  export class PaginationEngine {
16
128
  /**
17
129
  * Creates a new pagination engine.
@@ -21,16 +133,28 @@ export class PaginationEngine {
21
133
  * @param options - Pagination options
22
134
  */
23
135
  constructor(staging, container, options = {}) {
136
+ this.createdPageCount = 0;
137
+ this.footnoteSeparator = null;
138
+ this.footnoteContinuationSeparator = null;
139
+ this.footnoteLayoutLikeWord8 = false;
24
140
  this.pendingFootnoteContinuation = null;
25
141
  /** Per-section `w:pgNumType` (start / format), read off the section wrappers. */
26
142
  this.pageNumbering = new Map();
143
+ this.lastPages = [];
144
+ this.expectedPageMapAnchorIds = new Set();
145
+ this.state = "ready";
146
+ const ownerDocument = typeof staging !== "string"
147
+ ? staging.ownerDocument
148
+ : typeof container !== "string"
149
+ ? container.ownerDocument
150
+ : globalThis.document;
27
151
  this.stagingElement =
28
152
  typeof staging === "string"
29
- ? document.getElementById(staging)
153
+ ? ownerDocument.getElementById(staging)
30
154
  : staging;
31
155
  this.containerElement =
32
156
  typeof container === "string"
33
- ? document.getElementById(container)
157
+ ? ownerDocument.getElementById(container)
34
158
  : container;
35
159
  if (!this.stagingElement) {
36
160
  throw new Error("Staging element not found");
@@ -38,13 +162,27 @@ export class PaginationEngine {
38
162
  if (!this.containerElement) {
39
163
  throw new Error("Container element not found");
40
164
  }
165
+ if (this.stagingElement.ownerDocument !== this.containerElement.ownerDocument) {
166
+ throw new Error("Staging and container elements must belong to the same document");
167
+ }
168
+ const view = ownerDocument.defaultView;
169
+ if (!view) {
170
+ throw new Error("Pagination requires an attached document with a defaultView");
171
+ }
172
+ this.document = ownerDocument;
173
+ this.view = view;
41
174
  this.scale = options.scale ?? 1;
42
175
  this.cssPrefix = options.cssPrefix ?? "page-";
43
176
  this.showPageNumbers = options.showPageNumbers ?? true;
44
177
  this.pageGap = options.pageGap ?? 20;
45
178
  this.fragmentParagraphs = options.fragmentParagraphs ?? false;
179
+ this.deferFragmentIdentities = options.deferFragmentIdentities ?? false;
180
+ this.cancellationCheckpoint = options.checkCancellation;
181
+ this.pageCountCheckpoint = options.checkPageCount;
182
+ this.layoutToken = options.layoutToken;
46
183
  this.hfRegistry = new Map();
47
184
  this.footnoteRegistry = new Map();
185
+ this.commentMarginRegistry = new Map();
48
186
  }
49
187
  /**
50
188
  * Runs the pagination process.
@@ -52,39 +190,524 @@ export class PaginationEngine {
52
190
  * @returns PaginationResult with page information
53
191
  */
54
192
  paginate() {
55
- const pages = [];
56
- let pageNumber = 1;
57
- // Parse the header/footer registry if present
58
- this.hfRegistry = this.parseHeaderFooterRegistry();
59
- // Parse the footnote registry if present
60
- this.footnoteRegistry = this.parseFootnoteRegistry();
61
- // Find all section containers
62
- const sections = this.stagingElement.querySelectorAll("[data-section-index]");
63
- this.pageNumbering = this.parsePageNumbering(sections);
64
- // If no sections found, treat the entire staging content as one section
65
- const sectionsToProcess = sections.length > 0 ? Array.from(sections) : [this.stagingElement];
66
- for (const section of sectionsToProcess) {
67
- const sectionIndex = parseInt(section.dataset.sectionIndex || "0", 10);
68
- const dims = parseSectionDimensions(section);
69
- // Make staging visible for measurement
70
- this.stagingElement.style.visibility = "hidden";
71
- this.stagingElement.style.position = "absolute";
72
- this.stagingElement.style.left = "-9999px";
73
- this.stagingElement.style.display = "block";
74
- // Set width for accurate line wrapping
75
- section.style.width = `${dims.contentWidth}pt`;
76
- // Measure all blocks in this section
77
- const blocks = this.measureBlocks(section, dims);
78
- // Flow blocks into pages
79
- const sectionPages = this.flowToPages(blocks, dims, pageNumber, sectionIndex);
80
- pages.push(...sectionPages);
81
- pageNumber += sectionPages.length;
82
- }
83
- // Hide staging after measurement
84
- this.stagingElement.style.display = "none";
85
- // Every page box exists now, so NUMPAGES has an answer and each PAGE marker knows its page.
86
- this.substitutePageNumberFields(pages.length);
87
- return { totalPages: pages.length, pages };
193
+ this.checkpoint();
194
+ if (this.state !== "ready") {
195
+ throw new Error(`PaginationEngine is one-shot and is already ${this.state}`);
196
+ }
197
+ if (!this.stagingElement.isConnected || !this.containerElement.isConnected) {
198
+ throw new Error("Pagination requires staging and container elements attached to their document");
199
+ }
200
+ if (this.document.documentElement.getBoundingClientRect().width <= 0) {
201
+ throw new Error("Pagination requires a browsing context with non-zero layout");
202
+ }
203
+ this.state = "running";
204
+ try {
205
+ const pages = [];
206
+ let pageNumber = 1;
207
+ // Parse the header/footer registry if present
208
+ this.hfRegistry = this.parseHeaderFooterRegistry();
209
+ // Parse the footnote registry if present
210
+ this.footnoteRegistry = this.parseFootnoteRegistry();
211
+ ({
212
+ normal: this.footnoteSeparator,
213
+ continuation: this.footnoteContinuationSeparator,
214
+ } = this.parseFootnoteSeparators());
215
+ // Parse the margin-comment registry if present. Its entries are cloned into
216
+ // the side substrate of pages that contain the corresponding range marker.
217
+ this.commentMarginRegistry = this.parseCommentMarginRegistry();
218
+ this.footnoteLayoutLikeWord8 =
219
+ this.stagingElement.dataset.footnoteLayoutLikeWord8 === "true";
220
+ // Find all section containers
221
+ const sections = this.stagingElement.querySelectorAll("[data-section-index]");
222
+ this.pageNumbering = this.parsePageNumbering(sections);
223
+ // If no sections found, treat the entire staging content as one section
224
+ const sectionsToProcess = sections.length > 0 ? Array.from(sections) : [this.stagingElement];
225
+ // Snapshot the addressable SOURCE inventory before flow moves nodes out of staging. PageMap
226
+ // completeness cannot be inferred from whatever survives into page boxes: that would let a
227
+ // dropped block silently disappear from both the DOM and the supposedly authoritative map.
228
+ // Running-story variants and cited notes are inventoried from their source registries.
229
+ this.expectedPageMapAnchorIds = new Set();
230
+ const referencedFootnoteIds = new Set();
231
+ const referencedCommentIds = new Set();
232
+ for (const section of sectionsToProcess) {
233
+ this.checkpoint();
234
+ this.collectExpectedSourceAnchors(section, this.expectedPageMapAnchorIds, true);
235
+ for (const reference of Array.from(section.querySelectorAll("[data-footnote-id]"))) {
236
+ this.checkpoint();
237
+ if (reference.closest("#pagination-footnote-registry, #pagination-hf-registry"))
238
+ continue;
239
+ const id = reference.dataset.footnoteId;
240
+ if (id)
241
+ referencedFootnoteIds.add(id);
242
+ }
243
+ for (const reference of Array.from(section.querySelectorAll("[data-comment-id]"))) {
244
+ this.checkpoint();
245
+ if (reference.closest("#pagination-comment-margin-registry, #pagination-footnote-registry, #pagination-hf-registry"))
246
+ continue;
247
+ const id = reference.dataset.commentId;
248
+ if (id)
249
+ referencedCommentIds.add(id);
250
+ }
251
+ }
252
+ for (const id of referencedFootnoteIds) {
253
+ this.checkpoint();
254
+ const source = this.footnoteRegistry.get(id);
255
+ if (!source)
256
+ continue;
257
+ this.collectExpectedSourceAnchors(source, this.expectedPageMapAnchorIds);
258
+ // Margin comments are presentation stories selected by markers. Markers
259
+ // inside a cited footnote are just as visible/addressable as body markers.
260
+ for (const reference of Array.from(source.querySelectorAll("[data-comment-id]"))) {
261
+ this.checkpoint();
262
+ const commentId = reference.dataset.commentId;
263
+ if (commentId)
264
+ referencedCommentIds.add(commentId);
265
+ }
266
+ }
267
+ for (const id of referencedCommentIds) {
268
+ this.checkpoint();
269
+ const source = this.commentMarginRegistry.get(id);
270
+ if (source)
271
+ this.collectExpectedSourceAnchors(source, this.expectedPageMapAnchorIds);
272
+ }
273
+ const runs = [];
274
+ for (const section of sectionsToProcess) {
275
+ this.checkpoint();
276
+ const dims = parseSectionDimensions(section);
277
+ const previous = runs[runs.length - 1];
278
+ if (previous &&
279
+ section.dataset.sectionType === "continuous" &&
280
+ samePageBox(previous.dims, dims)) {
281
+ previous.sections.push(section);
282
+ previous.sectionDimensions.set(parseInt(section.dataset.sectionIndex || "0", 10), dims);
283
+ }
284
+ else {
285
+ const sectionIndex = parseInt(section.dataset.sectionIndex || "0", 10);
286
+ runs.push({
287
+ sections: [section],
288
+ sectionIndex,
289
+ sectionType: section.dataset.sectionType ?? "nextPage",
290
+ dims,
291
+ sectionDimensions: new Map([[sectionIndex, dims]]),
292
+ });
293
+ }
294
+ }
295
+ for (const run of runs) {
296
+ this.checkpoint();
297
+ // Odd/even section breaks begin the new section on the requested physical side. When the
298
+ // following page has the opposite parity, Word inserts one intentionally blank filler page.
299
+ // It belongs to the preceding section, advances physical/logical numbering, retains that
300
+ // section's paper geometry, and carries no running story.
301
+ const needsParityFiller = pages.length > 0
302
+ && ((run.sectionType === "oddPage" && pageNumber % 2 === 0)
303
+ || (run.sectionType === "evenPage" && pageNumber % 2 === 1));
304
+ if (needsParityFiller) {
305
+ const precedingPage = pages[pages.length - 1];
306
+ const precedingPageInSection = parseInt(precedingPage.element.dataset.pageInSection ?? "1", 10);
307
+ const precedingDisplayedPageNumber = parseInt(precedingPage.element.dataset.displayedPageNumber ?? String(precedingPage.pageNumber), 10);
308
+ const filler = this.createPage(precedingPage.dimensions, pageNumber, precedingPage.sectionIndex, precedingDisplayedPageNumber + 1, [], precedingPageInSection + 1, [], 0, null, undefined, true);
309
+ pages.push(filler);
310
+ pageNumber++;
311
+ }
312
+ // Make staging visible for measurement
313
+ this.stagingElement.style.visibility = "hidden";
314
+ this.stagingElement.style.position = "absolute";
315
+ this.stagingElement.style.left = "-9999px";
316
+ this.stagingElement.style.display = "block";
317
+ const blocks = [];
318
+ for (const section of run.sections) {
319
+ this.checkpoint();
320
+ const sectionIndex = parseInt(section.dataset.sectionIndex || "0", 10);
321
+ const sectionDims = run.sectionDimensions.get(sectionIndex) ?? run.dims;
322
+ // Set width for accurate line wrapping
323
+ section.style.width = `${sectionDims.contentWidth}pt`;
324
+ const columnCount = parseInt(section.dataset.cols || "1", 10);
325
+ if (columnCount > 1) {
326
+ const gap = parseFloat(section.dataset.colGap || "");
327
+ blocks.push(...this.buildColumnBlocks(section, sectionDims, sectionIndex, columnCount, Number.isFinite(gap) ? gap : 36));
328
+ }
329
+ else {
330
+ blocks.push(...this.measureBlocks(section, sectionDims, sectionIndex));
331
+ }
332
+ }
333
+ // Flow blocks into pages
334
+ const sectionPages = this.flowToPages(blocks, run.dims, pageNumber, run.sectionIndex, run.sectionDimensions);
335
+ this.checkpoint();
336
+ pages.push(...sectionPages);
337
+ pageNumber += sectionPages.length;
338
+ }
339
+ // Hide staging after measurement
340
+ this.stagingElement.style.display = "none";
341
+ // Every page box exists now, so NUMPAGES has an answer and each PAGE marker knows its page.
342
+ this.substitutePageNumberFields(pages.length);
343
+ // Only running-story variants selected by a real page are expected to materialize. Read IDs
344
+ // from registry sources, not presentation clones, so a failed clone remains detectable.
345
+ for (const page of pages) {
346
+ this.checkpoint();
347
+ if (page.element.dataset.sectionFiller === "true")
348
+ continue;
349
+ const pageInSection = parseInt(page.element.dataset.pageInSection || "1", 10);
350
+ const displayedPageNumber = parseInt(page.element.dataset.displayedPageNumber || String(page.pageNumber), 10);
351
+ const header = this.selectHeader(page.sectionIndex, pageInSection, displayedPageNumber);
352
+ const footer = this.selectFooter(page.sectionIndex, pageInSection, displayedPageNumber);
353
+ if (header)
354
+ this.collectExpectedSourceAnchors(header, this.expectedPageMapAnchorIds);
355
+ if (footer)
356
+ this.collectExpectedSourceAnchors(footer, this.expectedPageMapAnchorIds);
357
+ }
358
+ // Establish one active editor anchor and page-qualify every presentation fragment.
359
+ // Full canonical source identities remain on all clones, including table cells.
360
+ this.qualifyPageFragments(pages);
361
+ this.transferVisibleFragmentTargets();
362
+ if (!this.deferFragmentIdentities)
363
+ this.normalizeVisiblePageFragments(pages);
364
+ this.lastPages = pages;
365
+ const result = {
366
+ totalPages: pages.length,
367
+ pages,
368
+ pageMap: this.layoutToken
369
+ ? this.materializePageMap(this.layoutToken.documentVersion, this.layoutToken.rendererFingerprint)
370
+ : undefined,
371
+ };
372
+ this.state = "complete";
373
+ return result;
374
+ }
375
+ catch (error) {
376
+ this.state = "failed";
377
+ throw error;
378
+ }
379
+ }
380
+ checkpoint() {
381
+ this.cancellationCheckpoint?.();
382
+ }
383
+ /**
384
+ * Normalize visible fragment identities after a caller applies final standalone styles. This
385
+ * deliberately runs before the stability barrier; materializePageMap is read-only so PageMap
386
+ * measurement cannot mutate a tree after it was declared stable.
387
+ */
388
+ normalizePageMapFragmentIdentities() {
389
+ if (this.lastPages.length === 0) {
390
+ throw new Error("paginate() must complete before fragment identities can be normalized");
391
+ }
392
+ this.normalizeVisiblePageFragments(this.lastPages);
393
+ }
394
+ /**
395
+ * Materialize the last completed browser layout as portable page-relative point geometry.
396
+ * The caller supplies both invalidation tokens; this engine never guesses a document version
397
+ * or renderer fingerprint.
398
+ */
399
+ materializePageMap(documentVersion, rendererFingerprint) {
400
+ if (!Number.isSafeInteger(documentVersion) || documentVersion < 0) {
401
+ throw new Error("documentVersion must be a non-negative safe integer");
402
+ }
403
+ if (!rendererFingerprint)
404
+ throw new Error("rendererFingerprint must be non-empty");
405
+ if (this.lastPages.length === 0)
406
+ throw new Error("paginate() must complete before materializePageMap()");
407
+ const pages = this.lastPages.map((page) => ({
408
+ pageNumber: page.pageNumber,
409
+ pageInSection: parseInt(page.element.dataset.pageInSection || "1", 10),
410
+ width: page.dimensions.pageWidth,
411
+ height: page.dimensions.pageHeight,
412
+ sectionIndex: page.sectionIndex,
413
+ pageName: `docxodus-section-${page.sectionIndex}`,
414
+ }));
415
+ const fragments = [];
416
+ const requiredAnchorIds = new Set(this.expectedPageMapAnchorIds);
417
+ if (requiredAnchorIds.size === 0) {
418
+ throw new Error("cannot publish an available PageMap without canonical source inventory");
419
+ }
420
+ const measuredAnchorIds = new Set();
421
+ const emittedFragmentCounts = new Map();
422
+ for (const page of this.lastPages) {
423
+ this.checkpoint();
424
+ const pageRect = page.element.getBoundingClientRect();
425
+ if (pageRect.width <= 0 || pageRect.height <= 0) {
426
+ throw new Error(`page ${page.pageNumber} has no measurable geometry`);
427
+ }
428
+ // Ratio-to-known-page-size removes CSS px, zoom, and transform from the contract.
429
+ const pointPerRenderedX = page.dimensions.pageWidth / pageRect.width;
430
+ const pointPerRenderedY = page.dimensions.pageHeight / pageRect.height;
431
+ const nodes = page.element.querySelectorAll("[data-source-anchor-id]");
432
+ for (const element of Array.from(nodes)) {
433
+ this.checkpoint();
434
+ // Preserve the source-side exclusion contract on presentation clones as well. This
435
+ // covers the node itself and any excluded/hidden/aria-hidden ancestor within the page.
436
+ if (this.isDeliberatelyUnrenderedSource(element, page.element))
437
+ continue;
438
+ const anchorId = element.dataset.sourceAnchorId;
439
+ if (!anchorId || !element.dataset.pageFragmentId
440
+ || !Number.isInteger(parseInt(element.dataset.fragmentIndex || "", 10))) {
441
+ throw new Error(`page ${page.pageNumber} contains an unqualified source anchor`);
442
+ }
443
+ const rect = element.getBoundingClientRect();
444
+ const style = this.view.getComputedStyle(element);
445
+ const deliberatelyHidden = style.display === "none" || style.visibility === "hidden";
446
+ if (deliberatelyHidden)
447
+ continue;
448
+ requiredAnchorIds.add(anchorId);
449
+ const visibleRect = this.intersectWithClippingAncestors(element, page.element, pageRect, rect);
450
+ const left = visibleRect.left;
451
+ const top = visibleRect.top;
452
+ const right = visibleRect.right;
453
+ const bottom = visibleRect.bottom;
454
+ if (rect.width <= 0 || rect.height <= 0 || right <= left || bottom <= top) {
455
+ // A continued note/story clone can contain children clipped off this page which become
456
+ // measurable on its next clone. Enforce completeness once every page has been inspected.
457
+ continue;
458
+ }
459
+ measuredAnchorIds.add(anchorId);
460
+ // Clipped descendants in repeated note/story clones are deliberately omitted from the
461
+ // portable map. Re-number only the visible fragments so the emitted contract remains
462
+ // contiguous even when an earlier DOM clone carried no visible geometry on its page.
463
+ const fragmentIndex = emittedFragmentCounts.get(anchorId) ?? 0;
464
+ emittedFragmentCounts.set(anchorId, fragmentIndex + 1);
465
+ const fragmentId = `p${page.pageNumber}-f${fragmentIndex}-${anchorId}`;
466
+ if (element.dataset.fragmentIndex !== String(fragmentIndex)
467
+ || element.dataset.pageFragmentId !== fragmentId
468
+ || element.dataset.pageNumber !== String(page.pageNumber)) {
469
+ throw new Error(`page ${page.pageNumber} fragment identity changed after final-tree normalization`);
470
+ }
471
+ fragments.push({
472
+ fragmentId,
473
+ anchorId,
474
+ fragmentIndex,
475
+ pageNumber: page.pageNumber,
476
+ geometry: {
477
+ x: (left - pageRect.left) * pointPerRenderedX,
478
+ y: (top - pageRect.top) * pointPerRenderedY,
479
+ width: (right - left) * pointPerRenderedX,
480
+ height: (bottom - top) * pointPerRenderedY,
481
+ },
482
+ story: this.storyForCanonicalAnchor(anchorId),
483
+ inTableCell: element.matches("td,th") || element.closest("td,th") !== null,
484
+ });
485
+ }
486
+ }
487
+ const missingAnchor = Array.from(requiredAnchorIds).find((id) => !measuredAnchorIds.has(id));
488
+ if (missingAnchor) {
489
+ throw new Error(`source anchor ${missingAnchor} has no measurable fragment in the paginated layout`);
490
+ }
491
+ return {
492
+ schemaVersion: 1,
493
+ mode: "paginated",
494
+ availability: "available",
495
+ documentVersion,
496
+ rendererFingerprint,
497
+ pages,
498
+ fragments,
499
+ };
500
+ }
501
+ normalizeVisiblePageFragments(pages) {
502
+ const emittedFragmentCounts = new Map();
503
+ for (const page of pages) {
504
+ this.checkpoint();
505
+ const pageRect = page.element.getBoundingClientRect();
506
+ for (const element of Array.from(page.element.querySelectorAll("[data-source-anchor-id]"))) {
507
+ this.checkpoint();
508
+ if (this.isDeliberatelyUnrenderedSource(element, page.element))
509
+ continue;
510
+ const anchorId = element.dataset.sourceAnchorId;
511
+ if (!anchorId)
512
+ continue;
513
+ const style = this.view.getComputedStyle(element);
514
+ if (style.display === "none" || style.visibility === "hidden")
515
+ continue;
516
+ const rect = element.getBoundingClientRect();
517
+ const visible = this.intersectWithClippingAncestors(element, page.element, pageRect, rect);
518
+ if (rect.width <= 0 || rect.height <= 0
519
+ || visible.right <= visible.left || visible.bottom <= visible.top)
520
+ continue;
521
+ const fragmentIndex = emittedFragmentCounts.get(anchorId) ?? 0;
522
+ emittedFragmentCounts.set(anchorId, fragmentIndex + 1);
523
+ element.dataset.pageNumber = String(page.pageNumber);
524
+ element.dataset.fragmentIndex = String(fragmentIndex);
525
+ element.dataset.pageFragmentId = `p${page.pageNumber}-f${fragmentIndex}-${anchorId}`;
526
+ }
527
+ }
528
+ }
529
+ /**
530
+ * Intersect an element with every ancestor that establishes an overflow clip before the page
531
+ * root. getBoundingClientRect() reports layout outside those clips, which is not rendered and
532
+ * therefore must not satisfy PageMap completeness or inflate portable geometry.
533
+ */
534
+ intersectWithClippingAncestors(element, page, pageRect, rect) {
535
+ let left = Math.max(rect.left, pageRect.left);
536
+ let top = Math.max(rect.top, pageRect.top);
537
+ let right = Math.min(rect.right, pageRect.right);
538
+ let bottom = Math.min(rect.bottom, pageRect.bottom);
539
+ const clips = (value) => value === "hidden" || value === "clip" || value === "scroll" || value === "auto";
540
+ for (let ancestor = element.parentElement; ancestor && ancestor !== page; ancestor = ancestor.parentElement) {
541
+ const style = this.view.getComputedStyle(ancestor);
542
+ const clipsX = clips(style.overflowX);
543
+ const clipsY = clips(style.overflowY);
544
+ if (!clipsX && !clipsY)
545
+ continue;
546
+ const ancestorRect = ancestor.getBoundingClientRect();
547
+ if (clipsX) {
548
+ left = Math.max(left, ancestorRect.left);
549
+ right = Math.min(right, ancestorRect.right);
550
+ }
551
+ if (clipsY) {
552
+ top = Math.max(top, ancestorRect.top);
553
+ bottom = Math.min(bottom, ancestorRect.bottom);
554
+ }
555
+ }
556
+ return { left, top, right, bottom };
557
+ }
558
+ storyForCanonicalAnchor(anchorId) {
559
+ const first = anchorId.indexOf(":");
560
+ const second = first < 0 ? -1 : anchorId.indexOf(":", first + 1);
561
+ const scope = first >= 0 && second > first ? anchorId.slice(first + 1, second) : "body";
562
+ if (scope.startsWith("hdr"))
563
+ return "header";
564
+ if (scope.startsWith("ftr"))
565
+ return "footer";
566
+ if (scope === "fn")
567
+ return "footnote";
568
+ if (scope === "en")
569
+ return "endnote";
570
+ if (scope === "cmt")
571
+ return "comment";
572
+ return "body";
573
+ }
574
+ /**
575
+ * Add canonical IDs from an addressable source subtree to the pre-pagination inventory.
576
+ * Registry wrappers are excluded when scanning staging because selectable registry contents are
577
+ * inventoried separately. Producers may explicitly mark content that has no visual substrate
578
+ * with `data-page-map-exclude="true"`; native hidden semantics carry the same signal.
579
+ */
580
+ collectExpectedSourceAnchors(source, destination, excludeRegistries = false) {
581
+ const candidates = source.matches("[data-source-anchor-id]") ? [source] : [];
582
+ candidates.push(...Array.from(source.querySelectorAll("[data-source-anchor-id]")));
583
+ for (const element of candidates) {
584
+ if (excludeRegistries && element.closest("#pagination-hf-registry, #pagination-footnote-registry, #pagination-comment-margin-registry")) {
585
+ continue;
586
+ }
587
+ if (this.isZeroHeightExplicitBreakCarrier(element)) {
588
+ // Flow consumes the following break marker and clones this otherwise-empty paragraph.
589
+ // Persist the exclusion so that the clone cannot reintroduce the anchor during PageMap
590
+ // measurement after the marker itself has disappeared.
591
+ element.dataset.pageMapExclude = "true";
592
+ }
593
+ if (this.isDeliberatelyUnrenderedSource(element, source))
594
+ continue;
595
+ const anchorId = element.dataset.sourceAnchorId;
596
+ if (anchorId)
597
+ destination.add(anchorId);
598
+ }
599
+ }
600
+ isZeroHeightExplicitBreakCarrier(element) {
601
+ const next = element.nextElementSibling;
602
+ const bounds = element.getBoundingClientRect();
603
+ return element.hasAttribute("data-source-anchor-id")
604
+ && (element.textContent ?? "").replace(/\u00a0/g, "").trim() === ""
605
+ // Exclude only the converter's zero-height carrier. A blank paragraph with height from
606
+ // padding, borders, a background, or authored sizing still has a visible page substrate and
607
+ // must remain addressable under the authoritative PageMap contract.
608
+ && bounds.height <= 0.01
609
+ && !element.querySelector("[data-source-anchor-id]")
610
+ && !element.querySelector("img,svg,canvas,table,hr,input,textarea,select")
611
+ && (next?.dataset.pageBreak === "true"
612
+ || next?.classList.contains(`${this.cssPrefix}break`) === true);
613
+ }
614
+ isDeliberatelyUnrenderedSource(element, sourceRoot) {
615
+ for (let current = element; current; current = current.parentElement) {
616
+ const zeroHeightExplicitBreakCarrier = current === element
617
+ && this.isZeroHeightExplicitBreakCarrier(current);
618
+ if (current.dataset.pageMapExclude === "true"
619
+ // An explicit page-break marker controls flow but has no painted substrate. The converter
620
+ // intentionally emits it as an empty div, so requiring point geometry for its source
621
+ // identity would make every otherwise-valid document with w:br[type=page] fail closed.
622
+ || current.dataset.pageBreak === "true"
623
+ || current.classList.contains(`${this.cssPrefix}break`)
624
+ || zeroHeightExplicitBreakCarrier
625
+ || current.hidden
626
+ || current.getAttribute("aria-hidden") === "true"
627
+ || current.style.display === "none"
628
+ || current.style.visibility === "hidden") {
629
+ return true;
630
+ }
631
+ if (current === sourceRoot)
632
+ break;
633
+ }
634
+ return false;
635
+ }
636
+ /**
637
+ * Keep exactly one active bare-Unid editor anchor per source block. Presentation clones use
638
+ * canonical source identity plus page/fragment qualification instead.
639
+ */
640
+ qualifyPageFragments(pages) {
641
+ const fragmentCounts = new Map();
642
+ const activeCanonicalIds = new Set();
643
+ const activeBareAnchorIds = new Set();
644
+ const makeInactive = (element) => {
645
+ element.removeAttribute("data-anchor");
646
+ element.removeAttribute("data-committed-text");
647
+ if (element.hasAttribute("contenteditable"))
648
+ element.setAttribute("contenteditable", "false");
649
+ };
650
+ for (const page of pages) {
651
+ const nodes = page.element.querySelectorAll("[data-source-anchor-id]");
652
+ for (const element of Array.from(nodes)) {
653
+ const anchorId = element.dataset.sourceAnchorId;
654
+ if (!anchorId)
655
+ continue;
656
+ const fragmentIndex = fragmentCounts.get(anchorId) ?? 0;
657
+ fragmentCounts.set(anchorId, fragmentIndex + 1);
658
+ element.dataset.pageNumber = String(page.pageNumber);
659
+ element.dataset.fragmentIndex = String(fragmentIndex);
660
+ element.dataset.pageFragmentId = `p${page.pageNumber}-f${fragmentIndex}-${anchorId}`;
661
+ const story = this.storyForCanonicalAnchor(anchorId);
662
+ const mayOwnActiveEditorAnchor = story === "body" || story === "comment" || story === "footnote" || story === "endnote";
663
+ if (element.hasAttribute("data-anchor")) {
664
+ const bareAnchorId = element.dataset.anchor;
665
+ if (mayOwnActiveEditorAnchor
666
+ && !activeCanonicalIds.has(anchorId)
667
+ && !activeBareAnchorIds.has(bareAnchorId)) {
668
+ activeCanonicalIds.add(anchorId);
669
+ activeBareAnchorIds.add(bareAnchorId);
670
+ }
671
+ else {
672
+ makeInactive(element);
673
+ }
674
+ }
675
+ }
676
+ }
677
+ // Body/comment page nodes are the editable copies. A repeated header/footer registry entry can
678
+ // also render the same source story once per section/variant, so retain at most one active
679
+ // staging node per canonical source and make every presentation duplicate inert.
680
+ const activeStagingCanonicalIds = new Set();
681
+ for (const element of Array.from(this.stagingElement.querySelectorAll("[data-source-anchor-id][data-anchor]"))) {
682
+ const anchorId = element.dataset.sourceAnchorId;
683
+ if (!anchorId)
684
+ continue;
685
+ const bareAnchorId = element.dataset.anchor;
686
+ if (activeCanonicalIds.has(anchorId)
687
+ || activeStagingCanonicalIds.has(anchorId)
688
+ || activeBareAnchorIds.has(bareAnchorId)) {
689
+ makeInactive(element);
690
+ }
691
+ else {
692
+ activeStagingCanonicalIds.add(anchorId);
693
+ activeBareAnchorIds.add(bareAnchorId);
694
+ }
695
+ }
696
+ }
697
+ /**
698
+ * Page flow clones source blocks while the hidden staging tree stays in the document. Any HTML
699
+ * fragment target copied into a visible page would therefore resolve to its earlier hidden
700
+ * source. Transfer target ownership to the page presentation after flow is complete; registry
701
+ * and wrapper IDs that have no visible counterpart remain available to pagination internals.
702
+ */
703
+ transferVisibleFragmentTargets() {
704
+ const visibleIds = new Set(Array.from(this.containerElement.querySelectorAll("[id]")).map((element) => element.id).filter(Boolean));
705
+ if (visibleIds.size === 0)
706
+ return;
707
+ for (const source of Array.from(this.stagingElement.querySelectorAll("[id]"))) {
708
+ if (visibleIds.has(source.id))
709
+ source.removeAttribute("id");
710
+ }
88
711
  }
89
712
  /** Read each section's `w:pgNumType` off its wrapper (see {@link SectionPageNumbering}). */
90
713
  parsePageNumbering(sections) {
@@ -127,11 +750,8 @@ export class PaginationEngine {
127
750
  continue;
128
751
  const sectionIndex = parseInt(box.dataset.sectionIndex || "0", 10);
129
752
  const pageNumber = parseInt(box.dataset.pageNumber || "1", 10);
130
- const pageInSection = parseInt(box.dataset.pageInSection || "1", 10);
131
753
  const numbering = this.pageNumbering.get(sectionIndex) ?? {};
132
- // A section that restarts numbering counts from its own start; one that does not continues
133
- // the document-wide running number.
134
- const displayed = numbering.start !== undefined ? numbering.start + pageInSection - 1 : pageNumber;
754
+ const displayed = parseInt(box.dataset.displayedPageNumber || String(pageNumber), 10);
135
755
  for (const marker of Array.from(markers)) {
136
756
  const kind = marker.dataset.field;
137
757
  if (kind !== "PAGE" && kind !== "NUMPAGES")
@@ -144,22 +764,35 @@ export class PaginationEngine {
144
764
  /**
145
765
  * Measures all content blocks in a section.
146
766
  */
147
- measureBlocks(section, dims) {
767
+ measureBlocks(section, dims, sectionIndex) {
148
768
  const blocks = [];
149
769
  // Get direct children (paragraphs, tables, divs, etc.)
150
770
  const children = Array.from(section.children);
151
771
  for (const child of children) {
772
+ this.checkpoint();
152
773
  // Skip section dividers that are just wrappers
153
774
  if (child.dataset.sectionIndex !== undefined) {
154
775
  // Recursively get blocks from nested sections
155
- const nestedBlocks = this.measureBlocks(child, dims);
776
+ const nestedSectionIndex = parseInt(child.dataset.sectionIndex || String(sectionIndex), 10);
777
+ const nestedBlocks = this.measureBlocks(child, dims, nestedSectionIndex);
156
778
  blocks.push(...nestedBlocks);
157
779
  continue;
158
780
  }
781
+ // Converter-shaped endnotes are a safe nested block structure whose outer
782
+ // section/list wrappers must not make the complete endnote collection one
783
+ // indivisible page block. Flatten only the exact shape we understand; any
784
+ // richer author HTML retains the conservative whole-block fallback below.
785
+ if (child.matches("section.endnotes")) {
786
+ const endnoteBlocks = this.measureSafeEndnoteBlocks(child, dims, sectionIndex);
787
+ if (endnoteBlocks) {
788
+ blocks.push(...endnoteBlocks);
789
+ continue;
790
+ }
791
+ }
159
792
  // Measure height and margins separately for proper margin collapsing calculation
160
793
  // getBoundingClientRect() returns content+padding+border, not margins
161
794
  const rect = child.getBoundingClientRect();
162
- const style = window.getComputedStyle(child);
795
+ const style = this.view.getComputedStyle(child);
163
796
  const marginTopPx = parseFloat(style.marginTop) || 0;
164
797
  const marginBottomPx = parseFloat(style.marginBottom) || 0;
165
798
  const heightPt = pxToPt(rect.height);
@@ -169,6 +802,7 @@ export class PaginationEngine {
169
802
  child.classList.contains(`${this.cssPrefix}break`);
170
803
  blocks.push({
171
804
  element: child,
805
+ sectionIndex,
172
806
  heightPt,
173
807
  marginTopPt,
174
808
  marginBottomPt,
@@ -176,7 +810,168 @@ export class PaginationEngine {
176
810
  keepLines: child.dataset.keepLines === "true",
177
811
  pageBreakBefore: child.dataset.pageBreakBefore === "true",
178
812
  isPageBreak,
813
+ isWordParagraph: this.isWordParagraphElement(child),
814
+ });
815
+ }
816
+ return blocks;
817
+ }
818
+ /**
819
+ * Flatten the converter's `section.endnotes > ol > li > p` presentation into
820
+ * ordinary paragraph blocks. This preserves paragraph formatting and canonical
821
+ * p:en/en:en identities while allowing the existing paragraph fragmenter to
822
+ * split a long endnote across page boundaries.
823
+ */
824
+ measureSafeEndnoteBlocks(section, dims, sectionIndex) {
825
+ var _a, _b, _c;
826
+ const sectionChildren = Array.from(section.children);
827
+ const list = sectionChildren.find((child) => child.tagName === "OL");
828
+ if (!list
829
+ || sectionChildren.some((child) => child.tagName !== "HR" && child !== list)
830
+ || Array.from(list.children).some((child) => child.tagName !== "LI")) {
831
+ return null;
832
+ }
833
+ const items = Array.from(list.children);
834
+ if (items.length === 0 || items.some((item) => item.children.length === 0
835
+ || Array.from(item.children).some((child) => child.tagName !== "P"))) {
836
+ return null;
837
+ }
838
+ const blocks = [];
839
+ const sectionStyle = this.view.getComputedStyle(section);
840
+ for (const rule of sectionChildren.filter((child) => child.tagName === "HR")) {
841
+ this.checkpoint();
842
+ const clonedRule = rule.cloneNode(true);
843
+ if (blocks.length === 0)
844
+ clonedRule.style.marginTop = sectionStyle.marginTop;
845
+ blocks.push(this.measureElement(clonedRule, dims, sectionIndex));
846
+ }
847
+ const listStyle = this.view.getComputedStyle(list);
848
+ for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
849
+ this.checkpoint();
850
+ const item = items[itemIndex];
851
+ const ownerAnchorId = item.dataset.sourceAnchorId;
852
+ const paragraphs = Array.from(item.children);
853
+ for (let paragraphIndex = 0; paragraphIndex < paragraphs.length; paragraphIndex++) {
854
+ this.checkpoint();
855
+ const paragraph = paragraphs[paragraphIndex];
856
+ // The flattened clone no longer has the section/ol/li ancestors that supplied the
857
+ // source's computed layout. Validate while the real paragraph is still attached; the
858
+ // marker below records that completed check for canFragmentParagraph(), whose detached
859
+ // clone cannot obtain meaningful computed styles. A richer custom endnote falls back to
860
+ // the established indivisible section path instead of being range-split incorrectly.
861
+ if (!this.hasRangeFragmentSafeLayout(paragraph)) {
862
+ return null;
863
+ }
864
+ const clone = paragraph.cloneNode(true);
865
+ clone.dataset.paginationSafeEndnote = "true";
866
+ (_a = clone.style).fontSize || (_a.fontSize = sectionStyle.fontSize);
867
+ (_b = clone.style).lineHeight || (_b.lineHeight = sectionStyle.lineHeight);
868
+ (_c = clone.style).paddingLeft || (_c.paddingLeft = listStyle.paddingLeft);
869
+ // Older/custom producers may put the endnote identity only on the li.
870
+ // Mirror it into the visible paragraph without displacing the paragraph's
871
+ // own identity, matching current converter output.
872
+ if (ownerAnchorId && !Array.from(clone.querySelectorAll("[data-source-anchor-id]")).some((node) => node.dataset.sourceAnchorId === ownerAnchorId)) {
873
+ const owner = this.document.createElement("span");
874
+ owner.dataset.sourceAnchorId = ownerAnchorId;
875
+ while (clone.firstChild)
876
+ owner.appendChild(clone.firstChild);
877
+ clone.appendChild(owner);
878
+ }
879
+ if (paragraphIndex === 0) {
880
+ // Flattening must preserve both ends of the converter's endnote link and the list's
881
+ // numbering format. The outer id survives only on the leading range fragment; normal
882
+ // continuation cleanup removes it from every later fragment.
883
+ const itemId = item.id;
884
+ if (itemId) {
885
+ if (clone.id && clone.id !== itemId)
886
+ return null;
887
+ clone.id = itemId;
888
+ }
889
+ const value = parseInt(item.getAttribute("value") || String(itemIndex + 1), 10);
890
+ const marker = this.formatOrderedListMarker(Number.isFinite(value) ? value : itemIndex + 1, listStyle.listStyleType);
891
+ clone.insertBefore(this.document.createTextNode(`${marker}. `), clone.firstChild);
892
+ }
893
+ blocks.push(this.measureElement(clone, dims, sectionIndex));
894
+ }
895
+ }
896
+ return blocks;
897
+ }
898
+ /** Render the CSS ordered-list formats emitted by the converter after an endnote is flattened. */
899
+ formatOrderedListMarker(value, listStyleType) {
900
+ const format = (() => {
901
+ switch (listStyleType) {
902
+ case "lower-roman": return "lowerRoman";
903
+ case "upper-roman": return "upperRoman";
904
+ case "lower-alpha":
905
+ case "lower-latin": return "lowerLetter";
906
+ case "upper-alpha":
907
+ case "upper-latin": return "upperLetter";
908
+ default: return "decimal";
909
+ }
910
+ })();
911
+ return formatPageNumber(value, format);
912
+ }
913
+ /**
914
+ * Flows a multi-column (`w:cols`) section's children into CSS-multicol container
915
+ * blocks. Word lays such a section out as N columns inside the same body extent;
916
+ * a balanced `column-count` container reproduces that geometry, and the paginator
917
+ * then places each container as one ordinary measured block. A container grows
918
+ * greedily until its balanced height would exceed the smallest page body available
919
+ * to the section, so a long columned section still splits across pages at block
920
+ * boundaries. Each child lands in exactly one container, so anchors never
921
+ * duplicate. An explicit page break child passes through as its own block, which
922
+ * ends the current container and lets the normal flow logic turn the page.
923
+ */
924
+ buildColumnBlocks(section, dims, sectionIndex, columnCount, columnGapPt) {
925
+ const children = Array.from(section.children);
926
+ const blocks = [];
927
+ const maxFragmentHeight = this.smallestEffectiveContentHeight(dims, sectionIndex);
928
+ const isBreak = (child) => child.dataset.pageBreak === "true" ||
929
+ child.classList.contains(`${this.cssPrefix}break`);
930
+ const makeContainer = (slice) => {
931
+ const container = this.document.createElement("div");
932
+ container.style.columnCount = String(columnCount);
933
+ container.style.columnGap = `${columnGapPt}pt`;
934
+ for (const child of slice) {
935
+ container.appendChild(child.cloneNode(true));
936
+ }
937
+ return container;
938
+ };
939
+ let start = 0;
940
+ while (start < children.length) {
941
+ this.checkpoint();
942
+ if (isBreak(children[start])) {
943
+ blocks.push(this.measureElement(children[start], dims, sectionIndex));
944
+ start++;
945
+ continue;
946
+ }
947
+ // Grow the container one child at a time. Even a single oversized child is
948
+ // emitted alone, preserving the established oversized-block fallback.
949
+ let end = start + 1;
950
+ let container = makeContainer(children.slice(start, end));
951
+ let measured = this.measureElement(container, dims, sectionIndex);
952
+ while (end < children.length && !isBreak(children[end])) {
953
+ this.checkpoint();
954
+ const candidate = makeContainer(children.slice(start, end + 1));
955
+ const candidateMeasured = this.measureElement(candidate, dims, sectionIndex);
956
+ if (candidateMeasured.heightPt > maxFragmentHeight)
957
+ break;
958
+ container = candidate;
959
+ measured = candidateMeasured;
960
+ end++;
961
+ }
962
+ blocks.push({
963
+ element: container,
964
+ sectionIndex,
965
+ heightPt: measured.heightPt,
966
+ marginTopPt: measured.marginTopPt,
967
+ marginBottomPt: measured.marginBottomPt,
968
+ keepWithNext: false,
969
+ keepLines: false,
970
+ pageBreakBefore: false,
971
+ isPageBreak: false,
972
+ isWordParagraph: false,
179
973
  });
974
+ start = end;
180
975
  }
181
976
  return blocks;
182
977
  }
@@ -185,8 +980,8 @@ export class PaginationEngine {
185
980
  * This is intentionally DOM-based: table row heights cannot be inferred from individual
186
981
  * rows because wrapping and collapsed borders change the height of a fragment.
187
982
  */
188
- measureElement(element, dims) {
189
- const measurementHost = document.createElement("div");
983
+ measureElement(element, dims, sectionIndex) {
984
+ const measurementHost = this.document.createElement("div");
190
985
  measurementHost.style.position = "absolute";
191
986
  measurementHost.style.visibility = "hidden";
192
987
  measurementHost.style.left = "-9999px";
@@ -195,9 +990,10 @@ export class PaginationEngine {
195
990
  measurementHost.appendChild(measuredElement);
196
991
  this.stagingElement.appendChild(measurementHost);
197
992
  const rect = measuredElement.getBoundingClientRect();
198
- const style = window.getComputedStyle(measuredElement);
993
+ const style = this.view.getComputedStyle(measuredElement);
199
994
  const measured = {
200
995
  element,
996
+ sectionIndex,
201
997
  heightPt: pxToPt(rect.height),
202
998
  marginTopPt: pxToPt(parseFloat(style.marginTop) || 0),
203
999
  marginBottomPt: pxToPt(parseFloat(style.marginBottom) || 0),
@@ -206,6 +1002,7 @@ export class PaginationEngine {
206
1002
  pageBreakBefore: element.dataset.pageBreakBefore === "true",
207
1003
  isPageBreak: element.dataset.pageBreak === "true" ||
208
1004
  element.classList.contains(`${this.cssPrefix}break`),
1005
+ isWordParagraph: this.isWordParagraphElement(element),
209
1006
  };
210
1007
  this.stagingElement.removeChild(measurementHost);
211
1008
  return measured;
@@ -238,13 +1035,11 @@ export class PaginationEngine {
238
1035
  * collapsed-margin rules as normal block placement. The trailing margin is
239
1036
  * intentionally excluded, matching the individual block fit check.
240
1037
  */
241
- measureKeepWithNextChainBodyHeight(chain, previousMarginBottomPt, isFirstOnPage) {
1038
+ measureKeepWithNextChainBodyHeight(chain, previousMarginBottomPt, isFirstOnPage, pageInSection) {
242
1039
  const firstBlock = chain[0];
243
1040
  if (!firstBlock)
244
1041
  return 0;
245
- const firstMarginTop = isFirstOnPage
246
- ? firstBlock.marginTopPt
247
- : Math.max(firstBlock.marginTopPt, previousMarginBottomPt) - previousMarginBottomPt;
1042
+ const firstMarginTop = this.effectiveBlockMarginTop(firstBlock, previousMarginBottomPt, isFirstOnPage, pageInSection);
248
1043
  let bodyHeight = firstMarginTop + firstBlock.heightPt;
249
1044
  for (let index = 1; index < chain.length; index++) {
250
1045
  const previousBlock = chain[index - 1];
@@ -253,6 +1048,37 @@ export class PaginationEngine {
253
1048
  }
254
1049
  return bodyHeight;
255
1050
  }
1051
+ /** Word paragraphs render as `p`, or as `h1`–`h6` when their style has an outline level. */
1052
+ isWordParagraphElement(element) {
1053
+ return element.tagName === "P" || /^H[1-6]$/.test(element.tagName);
1054
+ }
1055
+ shouldSuppressPageTopSpacing(block, isFirstOnPage, pageInSection) {
1056
+ return isFirstOnPage && pageInSection > 1 && block.isWordParagraph === true;
1057
+ }
1058
+ /**
1059
+ * Resolves the part of a block's top margin that consumes the current page.
1060
+ *
1061
+ * In Word's native DOCX layout, paragraph space-before is suppressed when a paragraph is the
1062
+ * first body block on a later page of the SAME section. The first page of a document/section is
1063
+ * the exception and keeps its spacing. Tables and other block margins are not paragraph spacing,
1064
+ * so they continue to use the ordinary CSS collapsing rule.
1065
+ */
1066
+ effectiveBlockMarginTop(block, previousMarginBottomPt, isFirstOnPage, pageInSection) {
1067
+ if (this.shouldSuppressPageTopSpacing(block, isFirstOnPage, pageInSection)) {
1068
+ return 0;
1069
+ }
1070
+ return isFirstOnPage
1071
+ ? block.marginTopPt
1072
+ : Math.max(block.marginTopPt, previousMarginBottomPt) - previousMarginBottomPt;
1073
+ }
1074
+ /** Clone a source block with the same page-top spacing decision used by the height budget. */
1075
+ cloneBlockForPage(block, isFirstOnPage, pageInSection) {
1076
+ const clone = block.element.cloneNode(true);
1077
+ if (this.shouldSuppressPageTopSpacing(block, isFirstOnPage, pageInSection)) {
1078
+ clone.style.setProperty("margin-top", "0", "important");
1079
+ }
1080
+ return clone;
1081
+ }
256
1082
  /**
257
1083
  * Finds footnote references introduced by a sequence of blocks, preserving
258
1084
  * document order and excluding references already assigned to the page.
@@ -261,6 +1087,7 @@ export class PaginationEngine {
261
1087
  const knownIds = new Set(existingFootnoteIds);
262
1088
  const newIds = [];
263
1089
  for (const block of blocks) {
1090
+ this.checkpoint();
264
1091
  for (const id of this.extractFootnoteRefs(block.element)) {
265
1092
  if (!knownIds.has(id)) {
266
1093
  knownIds.add(id);
@@ -276,7 +1103,7 @@ export class PaginationEngine {
276
1103
  * send it through the oversized-block fallback again.
277
1104
  */
278
1105
  smallestEffectiveContentHeight(dims, sectionIndex) {
279
- return Math.min(this.getPageBands(dims, sectionIndex, 1, 1).bodyHeight, this.getPageBands(dims, sectionIndex, 2, 1).bodyHeight, this.getPageBands(dims, sectionIndex, 2, 2).bodyHeight);
1106
+ return Math.min(this.getPageBands(dims, sectionIndex, 1, 1).bodyHeight, this.getPageBands(dims, sectionIndex, 2, 2).bodyHeight, this.getPageBands(dims, sectionIndex, 3, 3).bodyHeight);
280
1107
  }
281
1108
  /**
282
1109
  * Builds a clone of a simple table wrapper containing a contiguous run of rows.
@@ -320,10 +1147,11 @@ export class PaginationEngine {
320
1147
  block.isPageBreak) {
321
1148
  return null;
322
1149
  }
323
- const table = wrapper.firstElementChild;
324
- if (!(table instanceof HTMLTableElement)) {
1150
+ const tableElement = wrapper.firstElementChild;
1151
+ if (!tableElement || tableElement.localName !== "table") {
325
1152
  return null;
326
1153
  }
1154
+ const table = tableElement;
327
1155
  const body = table.tBodies.length === 1 ? table.tBodies[0] : null;
328
1156
  if (!body ||
329
1157
  table.tHead ||
@@ -346,10 +1174,12 @@ export class PaginationEngine {
346
1174
  const groups = [];
347
1175
  let start = 0;
348
1176
  while (start < rows.length) {
1177
+ this.checkpoint();
349
1178
  let end = start;
350
1179
  while (end < rows.length) {
1180
+ this.checkpoint();
351
1181
  const candidate = this.createSimpleTableFragment(wrapper, table, body, rows.slice(start, end + 1), start === 0);
352
- const measured = this.measureElement(candidate, dims);
1182
+ const measured = this.measureElement(candidate, dims, block.sectionIndex);
353
1183
  if (measured.heightPt > maximumFragmentHeight) {
354
1184
  break;
355
1185
  }
@@ -380,7 +1210,7 @@ export class PaginationEngine {
380
1210
  if (!isLast) {
381
1211
  fragment.style.setProperty("margin-bottom", "0", "important");
382
1212
  }
383
- const measured = this.measureElement(fragment, dims);
1213
+ const measured = this.measureElement(fragment, dims, block.sectionIndex);
384
1214
  if (measured.heightPt + measured.marginTopPt + measured.marginBottomPt >
385
1215
  minimumContentHeight) {
386
1216
  return null;
@@ -396,40 +1226,223 @@ export class PaginationEngine {
396
1226
  return fragments;
397
1227
  }
398
1228
  /**
399
- * A DOM endpoint that can finish a paragraph fragment. Endpoints are chosen
400
- * after whitespace or at a run boundary so the paginator never deliberately
401
- * cuts through an ordinary word merely to fill a little more of a page.
1229
+ * DOM endpoints that can finish a paragraph fragment. The flattened UTF-16
1230
+ * offsets are checked against the browser's Unicode grapheme segmenter, so a
1231
+ * formatting-run boundary can never bisect a surrogate pair, combining
1232
+ * sequence, or joined emoji. NBSP/word-joiner boundaries remain indivisible.
1233
+ * PAGE/NUMPAGES field results are atomic because splitting their marker would
1234
+ * make later substitution duplicate or replace only half of the field.
402
1235
  */
403
- paragraphFragmentEndpoints(paragraph) {
404
- const endpoints = [];
405
- const walker = document.createTreeWalker(paragraph, NodeFilter.SHOW_TEXT);
1236
+ paragraphFragmentEndpoints(paragraph, emergencyGraphemeBreaks = false) {
1237
+ const textNodes = [];
1238
+ const textStarts = new Map();
1239
+ const lastTextByField = new Map();
1240
+ const walker = this.document.createTreeWalker(paragraph, this.view.NodeFilter.SHOW_TEXT);
1241
+ const flattenedChunks = [];
1242
+ let flattenedLength = 0;
406
1243
  let textNode;
407
1244
  while ((textNode = walker.nextNode())) {
408
- const text = textNode.data;
409
- if (text.length === 0)
1245
+ this.checkpoint();
1246
+ textStarts.set(textNode, flattenedLength);
1247
+ textNodes.push(textNode);
1248
+ flattenedChunks.push(textNode.data);
1249
+ flattenedLength += textNode.data.length;
1250
+ let field = textNode.parentElement?.closest("[data-field]") ?? null;
1251
+ while (field?.parentElement?.closest("[data-field]")) {
1252
+ field = field.parentElement.closest("[data-field]");
1253
+ }
1254
+ if (field && paragraph.contains(field))
1255
+ lastTextByField.set(field, textNode);
1256
+ }
1257
+ const flattenedText = flattenedChunks.join("");
1258
+ if (!this.hasVisibleText(flattenedText))
1259
+ return [];
1260
+ const invisible = /[\s\u200B-\u200F\uFEFF]/;
1261
+ let firstVisibleOffset = -1;
1262
+ let lastVisibleOffset = -1;
1263
+ for (let index = 0; index < flattenedText.length; index++) {
1264
+ if (index % 4096 === 0)
1265
+ this.checkpoint();
1266
+ if (invisible.test(flattenedText[index]))
410
1267
  continue;
411
- // A whitespace boundary preserves normal word wrapping. Always retain the
412
- // end of a run too: adjacent runs can change formatting without containing
413
- // a whitespace character between them.
414
- const whitespace = /\s+/g;
1268
+ if (firstVisibleOffset < 0)
1269
+ firstVisibleOffset = index;
1270
+ lastVisibleOffset = index;
1271
+ }
1272
+ const graphemeBoundaries = this.graphemeBoundaryOffsets(flattenedText);
1273
+ const candidates = [];
1274
+ const addCandidate = (candidate) => {
1275
+ const { textOffset } = candidate;
1276
+ if (textOffset <= 0 || textOffset >= flattenedText.length)
1277
+ return;
1278
+ if (textOffset <= firstVisibleOffset || textOffset > lastVisibleOffset)
1279
+ return;
1280
+ if (this.isNonBreakingTextBoundary(flattenedText, textOffset))
1281
+ return;
1282
+ if (!this.isLegalParagraphLineBoundary(paragraph, flattenedText, textOffset))
1283
+ return;
1284
+ if (graphemeBoundaries) {
1285
+ if (!graphemeBoundaries.has(textOffset))
1286
+ return;
1287
+ }
1288
+ else if (!this.isConservativeFallbackTextBoundary(flattenedText, textOffset)) {
1289
+ return;
1290
+ }
1291
+ candidates.push(candidate);
1292
+ };
1293
+ for (const node of textNodes) {
1294
+ this.checkpoint();
1295
+ const start = textStarts.get(node);
1296
+ // Field results and converter list markers are semantic atoms. A field
1297
+ // receives one boundary outside its wrapper below; a list marker stays
1298
+ // with the first real text fragment and is never emitted by itself.
1299
+ const atomic = node.parentElement?.closest("[data-field], [data-list-marker], a[data-comment-id]");
1300
+ if (atomic && paragraph.contains(atomic))
1301
+ continue;
1302
+ // Only CSS-collapsible ASCII whitespace is a wrapping opportunity. JS's
1303
+ // broader `\s` class includes NBSP and other explicitly non-breaking text.
1304
+ const whitespace = /[\u0009-\u000D\u0020]+/g;
415
1305
  let match;
416
- while ((match = whitespace.exec(text)) !== null) {
417
- endpoints.push({ node: textNode, offset: match.index + match[0].length });
1306
+ let matchesSinceCheckpoint = 0;
1307
+ while ((match = whitespace.exec(node.data)) !== null) {
1308
+ if (++matchesSinceCheckpoint % 256 === 0)
1309
+ this.checkpoint();
1310
+ const offset = match.index + match[0].length;
1311
+ addCandidate({ node, offset, textOffset: start + offset, priority: 0 });
1312
+ }
1313
+ addCandidate({
1314
+ node,
1315
+ offset: node.data.length,
1316
+ textOffset: start + node.data.length,
1317
+ priority: 0,
1318
+ });
1319
+ if (emergencyGraphemeBreaks) {
1320
+ for (let offset = 1; offset < node.data.length; offset++) {
1321
+ if (offset % 256 === 0)
1322
+ this.checkpoint();
1323
+ addCandidate({
1324
+ node,
1325
+ offset,
1326
+ textOffset: start + offset,
1327
+ priority: -1,
1328
+ });
1329
+ }
418
1330
  }
419
- if (endpoints.length === 0 || endpoints[endpoints.length - 1].node !== textNode ||
420
- endpoints[endpoints.length - 1].offset !== text.length) {
421
- endpoints.push({ node: textNode, offset: text.length });
1331
+ }
1332
+ // A field is splittable only immediately after its complete outer wrapper.
1333
+ // Starting the tail at the parent's child boundary prevents Range from
1334
+ // cloning an empty `[data-field]` shell that substitution could repopulate.
1335
+ for (const field of Array.from(paragraph.querySelectorAll("[data-field]"))) {
1336
+ this.checkpoint();
1337
+ if (field.parentElement?.closest("[data-field]"))
1338
+ continue;
1339
+ const last = lastTextByField.get(field);
1340
+ const parent = field.parentNode;
1341
+ if (!last || !parent)
1342
+ continue;
1343
+ const childIndex = Array.prototype.indexOf.call(parent.childNodes, field);
1344
+ if (childIndex < 0)
1345
+ continue;
1346
+ addCandidate({
1347
+ node: parent,
1348
+ offset: childIndex + 1,
1349
+ textOffset: textStarts.get(last) + last.data.length,
1350
+ priority: 1,
1351
+ });
1352
+ }
1353
+ // Several DOM boundaries can represent the same flattened offset. Prefer
1354
+ // the outer atomic boundary, then keep one stable document-order candidate.
1355
+ candidates.sort((left, right) => left.textOffset - right.textOffset || right.priority - left.priority);
1356
+ const endpoints = [];
1357
+ for (const candidate of candidates) {
1358
+ if (endpoints[endpoints.length - 1]?.textOffset !== candidate.textOffset) {
1359
+ endpoints.push(candidate);
422
1360
  }
423
1361
  }
424
1362
  return endpoints;
425
1363
  }
1364
+ /** Browser-native UAX #29 boundaries; null keeps older runtimes conservative. */
1365
+ graphemeBoundaryOffsets(text) {
1366
+ const SegmenterConstructor = this.view.Intl.Segmenter;
1367
+ if (!SegmenterConstructor)
1368
+ return null;
1369
+ const boundaries = new Set([0, text.length]);
1370
+ let segmentCount = 0;
1371
+ for (const part of new SegmenterConstructor("en", { granularity: "grapheme" }).segment(text)) {
1372
+ if (++segmentCount % 256 === 0)
1373
+ this.checkpoint();
1374
+ boundaries.add(part.index);
1375
+ boundaries.add(part.index + part.segment.length);
1376
+ }
1377
+ return boundaries;
1378
+ }
1379
+ /**
1380
+ * Conservative UAX #14/CSS wrapping opportunities used for synthetic page
1381
+ * boundaries. In particular, a formatting-run boundary is not itself a word
1382
+ * boundary, and Japanese opening/closing punctuation stays with its pair.
1383
+ * Arbitrary grapheme breaks are admitted only when the paragraph's CSS asks
1384
+ * the browser to wrap anywhere.
1385
+ */
1386
+ isLegalParagraphLineBoundary(paragraph, text, offset) {
1387
+ const lastCodeUnit = text.charCodeAt(offset - 1);
1388
+ const beforeOffset = lastCodeUnit >= 0xDC00 && lastCodeUnit <= 0xDFFF
1389
+ ? Math.max(0, offset - 2)
1390
+ : offset - 1;
1391
+ const beforeCodePoint = text.codePointAt(beforeOffset);
1392
+ const afterCodePoint = text.codePointAt(offset);
1393
+ const before = beforeCodePoint === undefined ? "" : String.fromCodePoint(beforeCodePoint);
1394
+ const after = afterCodePoint === undefined ? "" : String.fromCodePoint(afterCodePoint);
1395
+ if (/^[\u0009-\u000D\u0020]$/.test(before)
1396
+ || /^[\u0009-\u000D\u0020]$/.test(after))
1397
+ return true;
1398
+ if (/[-\u00AD\u2010]$/u.test(before))
1399
+ return true;
1400
+ const style = this.view.getComputedStyle(paragraph);
1401
+ if (style.wordBreak === "break-all"
1402
+ || style.overflowWrap === "anywhere"
1403
+ || style.overflowWrap === "break-word")
1404
+ return true;
1405
+ const eastAsian = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
1406
+ if (!eastAsian.test(before) && !eastAsian.test(after))
1407
+ return false;
1408
+ const opening = "([{<\u2018\u201C\u3008\u300A\u300C\u300E\u3010\u3014\u3016\u3018\u301A\uFF08\uFF3B\uFF5B";
1409
+ const closing = ")]}>\u2019\u201D\u3001\u3002\u3009\u300B\u300D\u300F\u3011\u3015\u3017\u3019\u301B\uFF01\uFF05\uFF09\uFF0C\uFF0E\uFF1A\uFF1B\uFF1F\uFF3D\uFF5D";
1410
+ return !opening.includes(before) && !closing.includes(after);
1411
+ }
1412
+ /** Never fragment across characters whose line-break meaning is explicitly non-breaking. */
1413
+ isNonBreakingTextBoundary(text, offset) {
1414
+ const codePointBefore = (index) => {
1415
+ if (index <= 0)
1416
+ return undefined;
1417
+ const last = text.charCodeAt(index - 1);
1418
+ const start = last >= 0xDC00 && last <= 0xDFFF ? index - 2 : index - 1;
1419
+ return text.codePointAt(Math.max(0, start));
1420
+ };
1421
+ const nonBreaking = new Set([
1422
+ 0x00A0, 0x200E, 0x200F, 0x2011, 0x202A, 0x202B, 0x202C,
1423
+ 0x202D, 0x202E, 0x202F, 0x2060, 0x2066, 0x2067, 0x2068,
1424
+ 0x2069, 0xFEFF,
1425
+ ]);
1426
+ return nonBreaking.has(codePointBefore(offset) ?? -1)
1427
+ || nonBreaking.has(text.codePointAt(offset) ?? -1);
1428
+ }
1429
+ /**
1430
+ * Without Intl.Segmenter, admit only an all-ASCII boundary. This still
1431
+ * fragments ordinary prose while refusing to guess about Unicode clusters.
1432
+ */
1433
+ isConservativeFallbackTextBoundary(text, offset) {
1434
+ return text.charCodeAt(offset - 1) <= 0x7F && text.charCodeAt(offset) <= 0x7F;
1435
+ }
426
1436
  /**
427
1437
  * Whether a range contains visible text after ignoring bidi/zero-width marks.
428
1438
  * Paragraph fragmentation deliberately excludes non-textual descendants, so
429
1439
  * this is enough to reject empty head or tail fragments.
430
1440
  */
431
1441
  hasVisibleFragmentText(fragment) {
432
- return (fragment.textContent || "")
1442
+ return this.hasVisibleText(fragment.textContent || "");
1443
+ }
1444
+ hasVisibleText(text) {
1445
+ return text
433
1446
  .replace(/[\u200B-\u200F\uFEFF]/g, "")
434
1447
  .trim()
435
1448
  .length > 0;
@@ -441,7 +1454,7 @@ export class PaginationEngine {
441
1454
  * fallback instead of risking broken content or duplicate anchors.
442
1455
  */
443
1456
  canFragmentParagraph(block) {
444
- if (!this.fragmentParagraphs || block.element.tagName !== "P") {
1457
+ if (!this.fragmentParagraphs) {
445
1458
  return false;
446
1459
  }
447
1460
  const paragraph = block.element;
@@ -453,8 +1466,26 @@ export class PaginationEngine {
453
1466
  paragraph.hasAttribute("contenteditable")) {
454
1467
  return false;
455
1468
  }
456
- // The outer paragraph may carry its source identity. Descendant identities
457
- // are not safe to duplicate in a continuation fragment, so reject them.
1469
+ return this.canRangeFragmentParagraph(paragraph);
1470
+ }
1471
+ /**
1472
+ * Shared structural gate for body and note paragraph fragmentation. Footnote
1473
+ * first paragraphs are inline beside their marker, so that one known layout
1474
+ * context may opt into an inline root while retaining every descendant and
1475
+ * break-safety restriction used for body text.
1476
+ */
1477
+ canRangeFragmentParagraph(paragraph, allowInlineRoot = false) {
1478
+ if (paragraph.tagName !== "P" ||
1479
+ paragraph.dataset.keepWithNext === "true" ||
1480
+ paragraph.dataset.keepLines === "true" ||
1481
+ paragraph.dataset.pageBreakBefore === "true" ||
1482
+ paragraph.dataset.widowControl === "true" ||
1483
+ paragraph.hasAttribute("contenteditable")) {
1484
+ return false;
1485
+ }
1486
+ // Non-textual/out-of-flow descendants still need dedicated fragmenters.
1487
+ // Inline ids/editor anchors are reconciled after Range cloning, and list
1488
+ // markers are kept atomically with the leading text fragment.
458
1489
  const unsupportedDescendants = [
459
1490
  "br",
460
1491
  "img",
@@ -485,19 +1516,26 @@ export class PaginationEngine {
485
1516
  "fieldset",
486
1517
  "details",
487
1518
  "[data-footnote-id]",
488
- "[data-list-marker]",
489
- "[data-anchor]",
490
- "[id]",
491
1519
  "[contenteditable]",
492
1520
  ].join(", ");
493
1521
  if (paragraph.querySelector(unsupportedDescendants)) {
494
1522
  return false;
495
1523
  }
496
- const paragraphStyle = window.getComputedStyle(paragraph);
497
- if (paragraphStyle.display !== "block" ||
1524
+ const isValidatedEndnote = paragraph.dataset.paginationSafeEndnote === "true";
1525
+ return isValidatedEndnote || this.hasRangeFragmentSafeLayout(paragraph, allowInlineRoot);
1526
+ }
1527
+ /**
1528
+ * A range clone preserves nested inline formatting exactly. Anything that establishes its own
1529
+ * box/layout context is deferred until a future fragmenter can model it accurately. Callers
1530
+ * must invoke this while the paragraph is attached to the styled document.
1531
+ */
1532
+ hasRangeFragmentSafeLayout(paragraph, allowInlineRoot = false) {
1533
+ const paragraphStyle = this.view.getComputedStyle(paragraph);
1534
+ if ((paragraphStyle.display !== "block" &&
1535
+ !(allowInlineRoot && paragraphStyle.display === "inline")) ||
498
1536
  paragraphStyle.position !== "static" ||
499
1537
  paragraphStyle.float !== "none" ||
500
- paragraphStyle.whiteSpace !== "normal" ||
1538
+ (paragraphStyle.whiteSpace !== "normal" && paragraphStyle.whiteSpace !== "pre-wrap") ||
501
1539
  paragraphStyle.breakBefore !== "auto" ||
502
1540
  paragraphStyle.breakAfter !== "auto" ||
503
1541
  paragraphStyle.breakInside === "avoid" ||
@@ -506,15 +1544,12 @@ export class PaginationEngine {
506
1544
  paragraphStyle.pageBreakInside === "avoid") {
507
1545
  return false;
508
1546
  }
509
- // A range clone preserves nested inline formatting exactly. Anything that
510
- // establishes its own box/layout context is intentionally deferred until a
511
- // future fragmenter can model it accurately.
512
1547
  for (const descendant of Array.from(paragraph.querySelectorAll("*"))) {
513
- const style = window.getComputedStyle(descendant);
1548
+ const style = this.view.getComputedStyle(descendant);
514
1549
  if (style.display !== "inline" ||
515
1550
  style.position !== "static" ||
516
1551
  style.float !== "none" ||
517
- style.whiteSpace !== "normal") {
1552
+ (style.whiteSpace !== "normal" && style.whiteSpace !== "pre-wrap")) {
518
1553
  return false;
519
1554
  }
520
1555
  }
@@ -545,63 +1580,134 @@ export class PaginationEngine {
545
1580
  return fragment;
546
1581
  }
547
1582
  /**
548
- * Splits a simple paragraph at the largest DOM Range endpoint that fits the
549
- * currently available body space. The caller then processes the tail normally,
550
- * allowing it to fragment again on later pages when necessary.
1583
+ * Range clones repeat an inline ancestor when the split lands inside it. Keep
1584
+ * semantic wrappers (links, comments, formatting) on both sides, but retain a
1585
+ * duplicated HTML/editor identity only on the leading fragment. Targets that
1586
+ * occur wholly after the split are absent from `head` and remain on `tail`.
551
1587
  */
552
- tryFragmentParagraph(block, dims, availableHeightPt, effectiveMarginTopPt) {
553
- if (!this.canFragmentParagraph(block) || availableHeightPt <= effectiveMarginTopPt) {
554
- return null;
555
- }
556
- const paragraph = block.element;
557
- const endpoints = this.paragraphFragmentEndpoints(paragraph);
558
- // The final endpoint is the full paragraph and cannot leave a tail. A
559
- // single text run with no earlier whitespace remains an overflow fallback.
560
- if (endpoints.length < 2) {
561
- return null;
562
- }
563
- let low = 0;
564
- let high = endpoints.length - 2;
565
- let best = null;
566
- // Fragment height is monotonic for the deliberately narrow eligible subset,
567
- // so binary search avoids measuring every word in long body paragraphs.
568
- while (low <= high) {
569
- const middle = Math.floor((low + high) / 2);
570
- const endpoint = endpoints[middle];
571
- const headRange = document.createRange();
572
- headRange.setStart(paragraph, 0);
573
- headRange.setEnd(endpoint.node, endpoint.offset);
574
- const headContents = headRange.cloneContents();
575
- if (!this.hasVisibleFragmentText(headContents)) {
576
- low = middle + 1;
1588
+ reconcileParagraphFragmentIdentities(head, tail) {
1589
+ const elements = (root, selector) => [
1590
+ ...(root.matches(selector) ? [root] : []),
1591
+ ...Array.from(root.querySelectorAll(selector)),
1592
+ ];
1593
+ const headIds = new Set(elements(head, "[id]").map((element) => element.id));
1594
+ for (const element of elements(tail, "[id]")) {
1595
+ if (headIds.has(element.id))
1596
+ element.removeAttribute("id");
1597
+ }
1598
+ const headAnchors = new Set(elements(head, "[data-anchor]")
1599
+ .map((element) => element.dataset.anchor)
1600
+ .filter((anchor) => Boolean(anchor)));
1601
+ for (const element of elements(tail, "[data-anchor]")) {
1602
+ if (!element.dataset.anchor || !headAnchors.has(element.dataset.anchor))
577
1603
  continue;
1604
+ element.removeAttribute("data-anchor");
1605
+ element.removeAttribute("data-committed-text");
1606
+ }
1607
+ }
1608
+ /**
1609
+ * Range-clone the largest safe prefix accepted by `fits`. The measurement
1610
+ * policy stays with the caller, allowing body blocks and note bands to share
1611
+ * one DOM fragmenter while measuring in their respective layout contexts.
1612
+ */
1613
+ splitParagraphAtLargestFit(paragraph, fits, options = {}) {
1614
+ const largestFit = (endpoints) => {
1615
+ // Prefix height is usually monotone, but selector-sensitive inline CSS
1616
+ // (`:last-child`, `:only-child`) can make a longer Range clone shorter.
1617
+ // Probe the largest legal endpoint first so the common non-monotone case
1618
+ // cannot be discarded by the binary search below.
1619
+ const lastEndpoint = endpoints[endpoints.length - 1];
1620
+ if (lastEndpoint) {
1621
+ const lastRange = this.document.createRange();
1622
+ lastRange.setStart(paragraph, 0);
1623
+ lastRange.setEnd(lastEndpoint.node, lastEndpoint.offset);
1624
+ const lastContents = lastRange.cloneContents();
1625
+ if (this.hasVisibleFragmentText(lastContents)) {
1626
+ const lastHead = this.createParagraphFragment(paragraph, lastRange, true, false);
1627
+ if (fits(lastHead))
1628
+ return { endpoint: lastEndpoint, head: lastHead };
1629
+ }
578
1630
  }
579
- const head = this.createParagraphFragment(paragraph, headRange, true, false);
580
- const measured = this.measureElement(head, dims);
581
- if (effectiveMarginTopPt + measured.heightPt <= availableHeightPt) {
582
- best = { endpoint, element: head, measured };
583
- low = middle + 1;
584
- }
585
- else {
586
- high = middle - 1;
1631
+ let low = 0;
1632
+ let high = endpoints.length - 2;
1633
+ let best = null;
1634
+ while (low <= high) {
1635
+ this.checkpoint();
1636
+ const middle = Math.floor((low + high) / 2);
1637
+ const endpoint = endpoints[middle];
1638
+ const headRange = this.document.createRange();
1639
+ headRange.setStart(paragraph, 0);
1640
+ headRange.setEnd(endpoint.node, endpoint.offset);
1641
+ const headContents = headRange.cloneContents();
1642
+ if (!this.hasVisibleFragmentText(headContents)) {
1643
+ low = middle + 1;
1644
+ continue;
1645
+ }
1646
+ const head = this.createParagraphFragment(paragraph, headRange, true, false);
1647
+ if (fits(head)) {
1648
+ best = { endpoint, head };
1649
+ low = middle + 1;
1650
+ }
1651
+ else {
1652
+ high = middle - 1;
1653
+ }
587
1654
  }
1655
+ return best;
1656
+ };
1657
+ let endpoints = this.paragraphFragmentEndpoints(paragraph);
1658
+ let best = largestFit(endpoints);
1659
+ if (!best && options.emergencyGraphemeBreaks) {
1660
+ endpoints = this.paragraphFragmentEndpoints(paragraph, true);
1661
+ best = largestFit(endpoints);
1662
+ }
1663
+ if (!best && options.forceFirstOnNoFit && endpoints.length > 0) {
1664
+ const endpoint = endpoints[0];
1665
+ const headRange = this.document.createRange();
1666
+ headRange.setStart(paragraph, 0);
1667
+ headRange.setEnd(endpoint.node, endpoint.offset);
1668
+ best = {
1669
+ endpoint,
1670
+ head: this.createParagraphFragment(paragraph, headRange, true, false),
1671
+ };
588
1672
  }
589
1673
  if (!best) {
590
- return null;
1674
+ return endpoints.length > 0 ? { kind: "no-fit" } : { kind: "indivisible" };
591
1675
  }
592
- const tailRange = document.createRange();
1676
+ const tailRange = this.document.createRange();
593
1677
  tailRange.setStart(best.endpoint.node, best.endpoint.offset);
594
1678
  tailRange.setEnd(paragraph, paragraph.childNodes.length);
595
1679
  const tailContents = tailRange.cloneContents();
596
- if (!this.hasVisibleFragmentText(tailContents)) {
1680
+ if (!this.hasVisibleFragmentText(tailContents))
1681
+ return { kind: "indivisible" };
1682
+ const tail = this.createParagraphFragment(paragraph, tailRange, false, true);
1683
+ this.reconcileParagraphFragmentIdentities(best.head, tail);
1684
+ return {
1685
+ kind: "split",
1686
+ head: best.head,
1687
+ tail,
1688
+ };
1689
+ }
1690
+ /**
1691
+ * Splits a simple paragraph at the largest DOM Range endpoint that fits the
1692
+ * currently available body space. The caller then processes the tail normally,
1693
+ * allowing it to fragment again on later pages when necessary.
1694
+ */
1695
+ tryFragmentParagraph(block, dims, availableHeightPt, effectiveMarginTopPt) {
1696
+ if (!this.canFragmentParagraph(block) || availableHeightPt <= effectiveMarginTopPt) {
597
1697
  return null;
598
1698
  }
599
- const tail = this.createParagraphFragment(paragraph, tailRange, false, true);
600
- const tailMeasured = this.measureElement(tail, dims);
1699
+ const split = this.splitParagraphAtLargestFit(block.element, (head) => {
1700
+ const measured = this.measureElement(head, dims, block.sectionIndex);
1701
+ return effectiveMarginTopPt + measured.heightPt <= availableHeightPt;
1702
+ });
1703
+ if (split.kind !== "split")
1704
+ return null;
1705
+ const headMeasured = this.measureElement(split.head, dims, block.sectionIndex);
1706
+ const tailMeasured = this.measureElement(split.tail, dims, block.sectionIndex);
601
1707
  return [
602
1708
  {
603
- ...best.measured,
604
- element: best.element,
1709
+ ...headMeasured,
1710
+ element: split.head,
605
1711
  keepWithNext: false,
606
1712
  keepLines: false,
607
1713
  pageBreakBefore: false,
@@ -609,7 +1715,7 @@ export class PaginationEngine {
609
1715
  },
610
1716
  {
611
1717
  ...tailMeasured,
612
- element: tail,
1718
+ element: split.tail,
613
1719
  keepWithNext: false,
614
1720
  keepLines: false,
615
1721
  pageBreakBefore: false,
@@ -687,7 +1793,11 @@ export class PaginationEngine {
687
1793
  const registryEl = this.stagingElement.querySelector("#pagination-footnote-registry");
688
1794
  if (!registryEl)
689
1795
  return registry;
690
- const entries = Array.from(registryEl.querySelectorAll("[data-footnote-id]"));
1796
+ // Only direct registry entries are definitions. Custom separator stories may
1797
+ // legitimately contain arbitrary converted markup, including stale semantic
1798
+ // attributes that must never be mistaken for another note definition.
1799
+ const entries = Array.from(registryEl.children)
1800
+ .filter((entry) => entry instanceof this.view.HTMLElement && entry.hasAttribute("data-footnote-id"));
691
1801
  for (const entry of entries) {
692
1802
  const footnoteId = entry.dataset.footnoteId;
693
1803
  if (footnoteId) {
@@ -697,6 +1807,69 @@ export class PaginationEngine {
697
1807
  }
698
1808
  return registry;
699
1809
  }
1810
+ /** Read Word's optional normal and continuation separator stories. */
1811
+ parseFootnoteSeparators() {
1812
+ const registry = this.stagingElement.querySelector("#pagination-footnote-registry");
1813
+ const clone = (kind) => {
1814
+ const source = Array.from(registry?.children ?? []).find((entry) => entry instanceof this.view.HTMLElement
1815
+ && entry.getAttribute("data-footnote-separator") === kind);
1816
+ return source?.cloneNode(true);
1817
+ };
1818
+ return {
1819
+ normal: clone("normal") ?? null,
1820
+ continuation: clone("continuation") ?? null,
1821
+ };
1822
+ }
1823
+ /** Append the exact separator story selected for this initial/continued note band. */
1824
+ appendFootnoteSeparator(container, continuation) {
1825
+ const kind = continuation ? "continuation" : "normal";
1826
+ const source = continuation ? this.footnoteContinuationSeparator : this.footnoteSeparator;
1827
+ if (!source) {
1828
+ const fallback = this.document.createElement("hr");
1829
+ fallback.dataset.footnoteSeparator = kind;
1830
+ container.appendChild(fallback);
1831
+ return;
1832
+ }
1833
+ const clone = source.cloneNode(true);
1834
+ for (const element of [clone, ...Array.from(clone.querySelectorAll("*"))]) {
1835
+ element.removeAttribute("id");
1836
+ element.removeAttribute("data-anchor");
1837
+ element.removeAttribute("data-committed-text");
1838
+ element.removeAttribute("data-source-anchor-id");
1839
+ element.removeAttribute("data-page-fragment-id");
1840
+ element.removeAttribute("data-fragment-index");
1841
+ element.removeAttribute("data-footnote-id");
1842
+ element.removeAttribute("data-comment-id");
1843
+ element.removeAttribute("name");
1844
+ if (element instanceof this.view.HTMLAnchorElement
1845
+ && element.getAttribute("href")?.startsWith("#")) {
1846
+ element.removeAttribute("href");
1847
+ }
1848
+ element.setAttribute("contenteditable", "false");
1849
+ }
1850
+ container.appendChild(clone);
1851
+ }
1852
+ /** Parses the hidden source notes used to render paginated margin comments. */
1853
+ parseCommentMarginRegistry() {
1854
+ const registry = new Map();
1855
+ const registryEl = this.stagingElement.querySelector("#pagination-comment-margin-registry");
1856
+ if (!registryEl)
1857
+ return registry;
1858
+ for (const entry of Array.from(registryEl.querySelectorAll("[data-comment-id]"))) {
1859
+ const commentId = entry.dataset.commentId;
1860
+ if (!commentId)
1861
+ continue;
1862
+ // A reply's note nests inside its thread root (issue #540), so every id in a thread
1863
+ // maps to the root's clone — a reply marker selects the whole thread, and the
1864
+ // placement loop dedupes by the clone's own id so the thread lands once per page.
1865
+ let root = entry;
1866
+ for (let parent = root.parentElement?.closest("[data-comment-id]"); parent && registryEl.contains(parent); parent = root.parentElement?.closest("[data-comment-id]")) {
1867
+ root = parent;
1868
+ }
1869
+ registry.set(commentId, root.cloneNode(true));
1870
+ }
1871
+ return registry;
1872
+ }
700
1873
  /**
701
1874
  * Extracts footnote reference IDs from an element.
702
1875
  */
@@ -711,6 +1884,103 @@ export class PaginationEngine {
711
1884
  }
712
1885
  return ids;
713
1886
  }
1887
+ /** Clone a note child for the continuation queue with its original selector position. */
1888
+ cloneFootnoteElementForContinuation(element, sourceIndex) {
1889
+ const clone = element.cloneNode(true);
1890
+ clone.setAttribute(FOOTNOTE_SOURCE_POSITION_ATTR, sourceIndex === 0 ? "first" : "later");
1891
+ return clone;
1892
+ }
1893
+ /** Remove identities that belong only to the initial registry presentation shell. */
1894
+ makeContinuationShellInert(element) {
1895
+ element.removeAttribute("id");
1896
+ element.removeAttribute("data-anchor");
1897
+ element.removeAttribute("data-committed-text");
1898
+ }
1899
+ /**
1900
+ * Build the exact continuation shape shared by measurement and paint.
1901
+ *
1902
+ * Keep the source `.footnote-item > .footnote-content` ancestry: document
1903
+ * CSS, inherited direction/language, and custom classes frequently target
1904
+ * those shells. Only the number and initial HTML/editor identities are
1905
+ * omitted. A hidden sentinel preserves `p:not(:first-of-type)` for a page
1906
+ * whose first carried element was a later source paragraph.
1907
+ */
1908
+ createFootnoteContinuationWrapper(continuation, elements = continuation.remainingElements) {
1909
+ const source = this.footnoteRegistry.get(continuation.footnoteId);
1910
+ const wrapper = source
1911
+ ? source.cloneNode(false)
1912
+ : this.document.createElement("div");
1913
+ this.makeContinuationShellInert(wrapper);
1914
+ wrapper.classList.add("footnote-continuation");
1915
+ wrapper.dataset.footnoteId = continuation.footnoteId;
1916
+ if (continuation.sourceAnchorId) {
1917
+ wrapper.dataset.sourceAnchorId = continuation.sourceAnchorId;
1918
+ }
1919
+ const sourceContent = source?.querySelector(".footnote-content");
1920
+ const content = sourceContent
1921
+ ? sourceContent.cloneNode(false)
1922
+ : this.document.createElement("span");
1923
+ this.makeContinuationShellInert(content);
1924
+ if (!sourceContent)
1925
+ content.className = "footnote-content";
1926
+ const first = elements[0];
1927
+ if (first?.tagName === "P"
1928
+ && first.getAttribute(FOOTNOTE_SOURCE_POSITION_ATTR) === "later") {
1929
+ const sentinel = this.document.createElement("p");
1930
+ sentinel.className = "footnote-continuation-position-sentinel";
1931
+ sentinel.setAttribute("aria-hidden", "true");
1932
+ sentinel.style.setProperty("display", "none", "important");
1933
+ content.appendChild(sentinel);
1934
+ }
1935
+ for (let index = 0; index < elements.length; index++) {
1936
+ this.checkpoint();
1937
+ const element = elements[index];
1938
+ const clone = element.cloneNode(true);
1939
+ clone.removeAttribute(FOOTNOTE_SOURCE_POSITION_ATTR);
1940
+ if (index === 0 && clone.tagName === "P") {
1941
+ // Without the initial number, a carried paragraph always starts a new
1942
+ // line even when source CSS made the note's first paragraph inline.
1943
+ clone.style.setProperty("display", "block", "important");
1944
+ }
1945
+ content.appendChild(clone);
1946
+ }
1947
+ wrapper.appendChild(content);
1948
+ return wrapper;
1949
+ }
1950
+ /** Build the exact partial-note item shape shared by measurement and paint. */
1951
+ createPartialFootnoteItem(footnote, fittingElements) {
1952
+ const item = footnote.cloneNode(false);
1953
+ const number = footnote.querySelector(".footnote-number");
1954
+ if (number)
1955
+ item.appendChild(number.cloneNode(true));
1956
+ const sourceContent = footnote.querySelector(".footnote-content");
1957
+ const content = sourceContent
1958
+ ? sourceContent.cloneNode(false)
1959
+ : this.document.createElement("span");
1960
+ if (!sourceContent)
1961
+ content.className = "footnote-content";
1962
+ for (const element of fittingElements) {
1963
+ this.checkpoint();
1964
+ content.appendChild(element.cloneNode(true));
1965
+ }
1966
+ item.appendChild(content);
1967
+ return item;
1968
+ }
1969
+ /**
1970
+ * Where a note measurement tree is attached.
1971
+ *
1972
+ * A note band is reserved from a measurement and then painted; if the two
1973
+ * happen under different inherited typography the painted notes overflow the
1974
+ * reserve, which is the invisible clipping this engine exists to avoid. The
1975
+ * registry lives in the staging tree but a band paints inside the output
1976
+ * container, so measure there. The host is never a page box, so a page's
1977
+ * `zoom` cannot scale a reserve that is accounted for in unscaled points.
1978
+ */
1979
+ noteMeasurementHost() {
1980
+ return this.containerElement.isConnected
1981
+ ? this.containerElement
1982
+ : this.stagingElement;
1983
+ }
714
1984
  /**
715
1985
  * Measures the height of footnotes for given IDs (in points).
716
1986
  * Creates a temporary container to measure the footnotes.
@@ -718,80 +1988,217 @@ export class PaginationEngine {
718
1988
  * @param contentWidth - Width for measurement
719
1989
  * @param continuation - Optional continuation content to include first
720
1990
  */
721
- measureFootnotesHeight(footnoteIds, contentWidth, continuation) {
1991
+ measureFootnotesHeight(footnoteIds, contentWidth, continuation, partialFootnotes) {
722
1992
  const hasContinuation = continuation && continuation.remainingElements.length > 0;
723
- if ((footnoteIds.length === 0 && !hasContinuation) || this.footnoteRegistry.size === 0) {
1993
+ if (footnoteIds.length === 0 && !hasContinuation) {
1994
+ return 0;
1995
+ }
1996
+ // Mirror addPageFootnotes: with no registry and nothing carried, no band is
1997
+ // painted, so reserving the separator's height would shrink every page's
1998
+ // body for a document whose note references resolve to nothing.
1999
+ if (this.footnoteRegistry.size === 0 && !hasContinuation) {
724
2000
  return 0;
725
2001
  }
726
2002
  // Measure in the SAME styling context the notes render in: `.page-footnotes` carries
727
2003
  // its own font-size and line-height, so measuring without the class sizes the note
728
2004
  // block against body type and the reserve can never match what is drawn.
729
2005
  // Create a temporary measurement container
730
- const measureContainer = document.createElement("div");
2006
+ const measureContainer = this.document.createElement("div");
731
2007
  measureContainer.style.position = "absolute";
732
2008
  measureContainer.style.visibility = "hidden";
733
2009
  measureContainer.style.width = `${contentWidth}pt`;
734
2010
  measureContainer.style.left = "-9999px";
735
2011
  measureContainer.className = this.cssPrefix + "footnotes";
736
- // Add separator line (same as will be rendered)
737
- const hr = document.createElement("hr");
738
- measureContainer.appendChild(hr);
2012
+ // Add the same normal/continuation separator story that will be painted.
2013
+ this.appendFootnoteSeparator(measureContainer, Boolean(hasContinuation));
739
2014
  // Add continuation content first (if any)
740
2015
  if (hasContinuation) {
741
- const contWrapper = document.createElement("div");
742
- contWrapper.className = "footnote-continuation";
743
- for (const el of continuation.remainingElements) {
744
- contWrapper.appendChild(el.cloneNode(true));
745
- }
746
- measureContainer.appendChild(contWrapper);
2016
+ measureContainer.appendChild(this.createFootnoteContinuationWrapper(continuation));
747
2017
  }
748
2018
  // Add footnotes
2019
+ const partialById = new Map(partialFootnotes?.map((partial) => [partial.footnoteId, partial]) ?? []);
749
2020
  for (const id of footnoteIds) {
2021
+ this.checkpoint();
750
2022
  const footnote = this.footnoteRegistry.get(id);
751
2023
  if (footnote) {
752
- measureContainer.appendChild(footnote.cloneNode(true));
2024
+ const partial = partialById.get(id);
2025
+ measureContainer.appendChild(partial
2026
+ ? this.createPartialFootnoteItem(footnote, partial.fittingElements)
2027
+ : footnote.cloneNode(true));
753
2028
  }
754
2029
  }
755
2030
  // Append to staging for measurement
756
- this.stagingElement.appendChild(measureContainer);
757
- // Measure
758
- const rect = measureContainer.getBoundingClientRect();
759
- const heightPt = pxToPt(rect.height);
760
- // Clean up
761
- this.stagingElement.removeChild(measureContainer);
762
- return heightPt;
2031
+ this.noteMeasurementHost().appendChild(measureContainer);
2032
+ try {
2033
+ return pxToPt(measureContainer.getBoundingClientRect().height);
2034
+ }
2035
+ finally {
2036
+ measureContainer.remove();
2037
+ }
763
2038
  }
764
2039
  /**
765
- * Measures the height of just the continuation content (in points).
2040
+ * Partition a continuation for one page's note band, preferring complete children
2041
+ * and range-fragmenting an eligible paragraph when necessary. Always advances by
2042
+ * at least one element so an indivisible oversized paragraph follows the established
2043
+ * clipped fallback without trapping pagination in a loop.
766
2044
  */
767
- measureContinuationHeight(continuation, contentWidth) {
768
- if (!continuation || continuation.remainingElements.length === 0) {
769
- return 0;
770
- }
771
- const measureContainer = document.createElement("div");
2045
+ splitContinuationForPage(continuation, availableHeightPt, contentWidth) {
2046
+ const fitting = [];
2047
+ let remaining = [];
2048
+ // Keep one live measurement tree and append each candidate exactly once.
2049
+ // Rebuilding `[...fitting, candidate]` for every child cloned 1+2+...+N
2050
+ // descendants before a page/resource cap could run, which is quadratic for
2051
+ // producer-authored notes containing thousands of tiny paragraphs/runs.
2052
+ const measureContainer = this.document.createElement("div");
772
2053
  measureContainer.style.position = "absolute";
773
2054
  measureContainer.style.visibility = "hidden";
774
2055
  measureContainer.style.width = `${contentWidth}pt`;
775
2056
  measureContainer.style.left = "-9999px";
776
2057
  measureContainer.className = this.cssPrefix + "footnotes";
777
- // Add separator line
778
- const hr = document.createElement("hr");
779
- measureContainer.appendChild(hr);
780
- // Add continuation content
781
- for (const el of continuation.remainingElements) {
782
- measureContainer.appendChild(el.cloneNode(true));
2058
+ this.appendFootnoteSeparator(measureContainer, true);
2059
+ const wrapper = this.createFootnoteContinuationWrapper(continuation, []);
2060
+ const content = wrapper.querySelector(":scope > .footnote-content");
2061
+ if (!content) {
2062
+ throw new Error("Footnote continuation is missing its content shell");
2063
+ }
2064
+ measureContainer.appendChild(wrapper);
2065
+ this.noteMeasurementHost().appendChild(measureContainer);
2066
+ try {
2067
+ for (let index = 0; index < continuation.remainingElements.length; index++) {
2068
+ this.checkpoint();
2069
+ const element = continuation.remainingElements[index];
2070
+ const sourcePosition = element.getAttribute(FOOTNOTE_SOURCE_POSITION_ATTR);
2071
+ if (fitting.length === 0
2072
+ && element.tagName === "P"
2073
+ && element.getAttribute(FOOTNOTE_SOURCE_POSITION_ATTR) === "later") {
2074
+ const sentinel = this.document.createElement("p");
2075
+ sentinel.className = "footnote-continuation-position-sentinel";
2076
+ sentinel.setAttribute("aria-hidden", "true");
2077
+ sentinel.style.setProperty("display", "none", "important");
2078
+ content.appendChild(sentinel);
2079
+ }
2080
+ const candidate = element.cloneNode(true);
2081
+ candidate.removeAttribute(FOOTNOTE_SOURCE_POSITION_ATTR);
2082
+ if (fitting.length === 0 && candidate.tagName === "P") {
2083
+ candidate.style.setProperty("display", "block", "important");
2084
+ }
2085
+ content.appendChild(candidate);
2086
+ const candidateHeight = pxToPt(measureContainer.getBoundingClientRect().height);
2087
+ if (candidateHeight <= availableHeightPt) {
2088
+ fitting.push(element);
2089
+ continue;
2090
+ }
2091
+ const canSplitCandidate = candidate.tagName === "P"
2092
+ && this.canRangeFragmentParagraph(candidate);
2093
+ candidate.remove();
2094
+ let split = null;
2095
+ if (canSplitCandidate) {
2096
+ split = this.splitParagraphAtLargestFit(candidate, (head) => {
2097
+ content.appendChild(head);
2098
+ try {
2099
+ return pxToPt(measureContainer.getBoundingClientRect().height) <= availableHeightPt;
2100
+ }
2101
+ finally {
2102
+ head.remove();
2103
+ }
2104
+ }, {
2105
+ // Both fallbacks below cut where ordinary line breaking would not,
2106
+ // so they are only ever right for an element that owns the whole
2107
+ // band: if this page already carries earlier content, a paragraph
2108
+ // that will not start here belongs intact in the next note band.
2109
+ emergencyGraphemeBreaks: fitting.length === 0,
2110
+ // A continuation page owns the full note band. If even one legal
2111
+ // grapheme is taller than it, clip only that unit and keep draining.
2112
+ forceFirstOnNoFit: fitting.length === 0,
2113
+ });
2114
+ }
2115
+ if (split?.kind === "split") {
2116
+ if (sourcePosition) {
2117
+ split.head.setAttribute(FOOTNOTE_SOURCE_POSITION_ATTR, sourcePosition);
2118
+ split.tail.setAttribute(FOOTNOTE_SOURCE_POSITION_ATTR, sourcePosition);
2119
+ }
2120
+ fitting.push(split.head);
2121
+ remaining = [split.tail, ...continuation.remainingElements.slice(index + 1)];
2122
+ }
2123
+ else if (fitting.length === 0) {
2124
+ // A genuinely indivisible first element retains the established visible
2125
+ // clipped fallback, but only for itself. Its siblings continue later.
2126
+ fitting.push(element);
2127
+ remaining = continuation.remainingElements.slice(index + 1);
2128
+ }
2129
+ else {
2130
+ remaining = continuation.remainingElements.slice(index);
2131
+ }
2132
+ break;
2133
+ }
783
2134
  }
784
- this.stagingElement.appendChild(measureContainer);
785
- const rect = measureContainer.getBoundingClientRect();
786
- const heightPt = pxToPt(rect.height);
787
- this.stagingElement.removeChild(measureContainer);
788
- return heightPt;
2135
+ finally {
2136
+ measureContainer.remove();
2137
+ }
2138
+ return {
2139
+ current: {
2140
+ footnoteId: continuation.footnoteId,
2141
+ sourceAnchorId: continuation.sourceAnchorId,
2142
+ remainingElements: fitting,
2143
+ },
2144
+ overflow: remaining.length > 0 ? {
2145
+ footnoteId: continuation.footnoteId,
2146
+ sourceAnchorId: continuation.sourceAnchorId,
2147
+ remainingElements: remaining,
2148
+ } : null,
2149
+ };
789
2150
  }
790
2151
  /**
791
2152
  * Splits a footnote element into parts that fit within the available height.
792
2153
  * Returns the elements that fit and the elements that need to continue.
793
2154
  */
794
- splitFootnoteToFit(footnoteElement, availableHeightPt, contentWidth) {
2155
+ splitFootnoteToFit(footnoteElement, availableHeightPt, contentWidth, forceProgress = false, existingPayload) {
2156
+ // Registry entries are detached clones, so getComputedStyle() cannot validate
2157
+ // their real paragraph layout. Attach an exact clone in the rendered note
2158
+ // context for the duration of the conservative range-fragmentation check.
2159
+ const layoutContext = this.document.createElement("div");
2160
+ layoutContext.style.position = "absolute";
2161
+ layoutContext.style.visibility = "hidden";
2162
+ layoutContext.style.width = `${contentWidth}pt`;
2163
+ layoutContext.style.left = "-9999px";
2164
+ layoutContext.className = this.cssPrefix + "footnotes";
2165
+ this.appendFootnoteSeparator(layoutContext, false);
2166
+ const attachedFootnote = footnoteElement.cloneNode(true);
2167
+ layoutContext.appendChild(attachedFootnote);
2168
+ this.noteMeasurementHost().appendChild(layoutContext);
2169
+ // A second live tree represents the exact already-packed page payload plus
2170
+ // an initially empty partial item. Candidates are appended once and layout
2171
+ // is read in place, avoiding cumulative prefix re-cloning.
2172
+ const measureContainer = this.document.createElement("div");
2173
+ measureContainer.style.position = "absolute";
2174
+ measureContainer.style.visibility = "hidden";
2175
+ measureContainer.style.width = `${contentWidth}pt`;
2176
+ measureContainer.style.left = "-9999px";
2177
+ measureContainer.className = this.cssPrefix + "footnotes";
2178
+ const hasContinuation = Boolean(existingPayload?.continuation?.remainingElements.length);
2179
+ this.appendFootnoteSeparator(measureContainer, hasContinuation);
2180
+ if (hasContinuation) {
2181
+ measureContainer.appendChild(this.createFootnoteContinuationWrapper(existingPayload.continuation));
2182
+ }
2183
+ const partialById = new Map(existingPayload?.partialFootnotes?.map((partial) => [partial.footnoteId, partial]) ?? []);
2184
+ for (const id of existingPayload?.footnoteIds ?? []) {
2185
+ this.checkpoint();
2186
+ const source = this.footnoteRegistry.get(id);
2187
+ if (!source)
2188
+ continue;
2189
+ const partial = partialById.get(id);
2190
+ measureContainer.appendChild(partial
2191
+ ? this.createPartialFootnoteItem(source, partial.fittingElements)
2192
+ : source.cloneNode(true));
2193
+ }
2194
+ const measuredPartial = this.createPartialFootnoteItem(attachedFootnote, []);
2195
+ const measuredContent = measuredPartial.querySelector(":scope > .footnote-content");
2196
+ if (!measuredContent) {
2197
+ layoutContext.remove();
2198
+ throw new Error("Footnote is missing its content shell");
2199
+ }
2200
+ measureContainer.appendChild(measuredPartial);
2201
+ this.noteMeasurementHost().appendChild(measureContainer);
795
2202
  // Get child elements (paragraphs) of the footnote content.
796
2203
  //
797
2204
  // `fits` is spliced into a freshly built `.footnote-item` > `.footnote-content` wrapper by
@@ -800,86 +2207,78 @@ export class PaginationEngine {
800
2207
  // inside another item's content span; the inner block-level div then broke the line, so the
801
2208
  // note's number rendered alone above its text — the same visible symptom as the escaped-CSS
802
2209
  // bug, from an unrelated cause, on the notes that happened to take a can't-split path.
803
- const footnoteContent = footnoteElement.querySelector(".footnote-content");
804
- if (!footnoteContent) {
805
- // No content structure to split — hand back the element's own children.
806
- return {
807
- fits: Array.from(footnoteElement.children).map((el) => el.cloneNode(true)),
808
- overflow: [],
809
- };
810
- }
811
- const children = Array.from(footnoteContent.children);
812
- if (children.length <= 1) {
813
- // Single paragraph: can't split at paragraph level, but the whole content still fits.
814
- return {
815
- fits: children.map((el) => el.cloneNode(true)),
816
- overflow: [],
817
- };
818
- }
819
- const fits = [];
820
- const overflow = [];
821
- let currentHeight = 0;
822
- // Measure separator line height
823
- const hrMeasure = document.createElement("div");
824
- hrMeasure.style.position = "absolute";
825
- hrMeasure.style.visibility = "hidden";
826
- hrMeasure.style.width = `${contentWidth}pt`;
827
- hrMeasure.style.left = "-9999px";
828
- hrMeasure.className = this.cssPrefix + "footnotes";
829
- const hr = document.createElement("hr");
830
- hrMeasure.appendChild(hr);
831
- this.stagingElement.appendChild(hrMeasure);
832
- const hrHeight = pxToPt(hrMeasure.getBoundingClientRect().height);
833
- this.stagingElement.removeChild(hrMeasure);
834
- currentHeight = hrHeight;
835
- // Also account for footnote number
836
- const footnoteNumber = footnoteElement.querySelector(".footnote-number");
837
- for (let i = 0; i < children.length; i++) {
838
- const child = children[i];
839
- // Measure this element
840
- const measureContainer = document.createElement("div");
841
- measureContainer.style.position = "absolute";
842
- measureContainer.style.visibility = "hidden";
843
- measureContainer.style.width = `${contentWidth}pt`;
844
- measureContainer.style.left = "-9999px";
845
- measureContainer.className = this.cssPrefix + "footnotes";
846
- measureContainer.appendChild(child.cloneNode(true));
847
- this.stagingElement.appendChild(measureContainer);
848
- const childHeight = pxToPt(measureContainer.getBoundingClientRect().height);
849
- this.stagingElement.removeChild(measureContainer);
850
- if (currentHeight + childHeight <= availableHeightPt) {
851
- fits.push(child.cloneNode(true));
852
- currentHeight += childHeight;
2210
+ try {
2211
+ const footnoteContent = attachedFootnote.querySelector(".footnote-content");
2212
+ if (!footnoteContent) {
2213
+ // No content structure to split — hand back the element's own children.
2214
+ return {
2215
+ fits: Array.from(attachedFootnote.children).map((el) => el.cloneNode(true)),
2216
+ overflow: [],
2217
+ };
853
2218
  }
854
- else {
855
- // This and remaining elements overflow
856
- for (let j = i; j < children.length; j++) {
857
- overflow.push(children[j].cloneNode(true));
2219
+ const children = Array.from(footnoteContent.children);
2220
+ const fits = [];
2221
+ let overflow = [];
2222
+ for (let i = 0; i < children.length; i++) {
2223
+ this.checkpoint();
2224
+ const child = children[i];
2225
+ const measuredCandidate = child.cloneNode(true);
2226
+ measuredContent.appendChild(measuredCandidate);
2227
+ const candidateFits = pxToPt(measureContainer.getBoundingClientRect().height)
2228
+ <= availableHeightPt;
2229
+ if (candidateFits) {
2230
+ fits.push(child.cloneNode(true));
2231
+ continue;
2232
+ }
2233
+ measuredCandidate.remove();
2234
+ const split = this.canRangeFragmentParagraph(child, true)
2235
+ ? this.splitParagraphAtLargestFit(child, (head) => {
2236
+ measuredContent.appendChild(head);
2237
+ try {
2238
+ return pxToPt(measureContainer.getBoundingClientRect().height)
2239
+ <= availableHeightPt;
2240
+ }
2241
+ finally {
2242
+ head.remove();
2243
+ }
2244
+ }, {
2245
+ // Both fallbacks cut where ordinary line breaking would not, so they
2246
+ // are only ever right for an element that owns the whole band: with
2247
+ // earlier siblings already packed here, a paragraph that will not
2248
+ // start belongs intact in the next note band.
2249
+ emergencyGraphemeBreaks: fits.length === 0 && forceProgress,
2250
+ forceFirstOnNoFit: fits.length === 0 && forceProgress,
2251
+ })
2252
+ : null;
2253
+ if (split?.kind === "split") {
2254
+ fits.push(split.head);
2255
+ split.tail.setAttribute(FOOTNOTE_SOURCE_POSITION_ATTR, i === 0 ? "first" : "later");
2256
+ overflow = [split.tail, ...children.slice(i + 1).map((element, offset) => this.cloneFootnoteElementForContinuation(element, i + 1 + offset))];
2257
+ }
2258
+ else if (split?.kind === "no-fit") {
2259
+ // The paragraph is splittable, but this citation page has less than
2260
+ // one line left. Defer it whole so a fresh note band can try again.
2261
+ overflow = children.slice(i).map((element, offset) => this.cloneFootnoteElementForContinuation(element, i + offset));
2262
+ }
2263
+ else if (fits.length === 0 && forceProgress) {
2264
+ // Preserve the one-element clipping fallback for content that cannot be
2265
+ // range-fragmented only on a dedicated full note band, while allowing
2266
+ // every later sibling to continue. A residual citation-page band must
2267
+ // defer the whole element: it may fit untouched on the next page.
2268
+ fits.push(child.cloneNode(true));
2269
+ overflow = children.slice(i + 1).map((element, offset) => this.cloneFootnoteElementForContinuation(element, i + 1 + offset));
2270
+ }
2271
+ else {
2272
+ overflow = children.slice(i).map((element, offset) => this.cloneFootnoteElementForContinuation(element, i + offset));
858
2273
  }
859
2274
  break;
860
2275
  }
2276
+ return { fits, overflow };
2277
+ }
2278
+ finally {
2279
+ measureContainer.remove();
2280
+ layoutContext.remove();
861
2281
  }
862
- return { fits, overflow };
863
- }
864
- /**
865
- * Measures a single footnote's height.
866
- */
867
- measureSingleFootnoteHeight(footnoteId, contentWidth) {
868
- const footnote = this.footnoteRegistry.get(footnoteId);
869
- if (!footnote)
870
- return 0;
871
- const measureContainer = document.createElement("div");
872
- measureContainer.style.position = "absolute";
873
- measureContainer.style.visibility = "hidden";
874
- measureContainer.style.width = `${contentWidth}pt`;
875
- measureContainer.style.left = "-9999px";
876
- measureContainer.className = this.cssPrefix + "footnotes";
877
- measureContainer.appendChild(footnote.cloneNode(true));
878
- this.stagingElement.appendChild(measureContainer);
879
- const rect = measureContainer.getBoundingClientRect();
880
- const heightPt = pxToPt(rect.height);
881
- this.stagingElement.removeChild(measureContainer);
882
- return heightPt;
883
2282
  }
884
2283
  /**
885
2284
  * Adds footnotes to a page container, including continuation content.
@@ -892,11 +2291,9 @@ export class PaginationEngine {
892
2291
  if (this.footnoteRegistry.size === 0 && !hasContinuation) {
893
2292
  return;
894
2293
  }
895
- // Create a set of partial footnote IDs for quick lookup
896
- const partialFootnoteIds = new Set(partialFootnotes?.map(p => p.footnoteId) || []);
897
2294
  // Calculate max height for footnotes area (content height minus margin for body content)
898
2295
  const maxFootnoteHeight = Math.min(footnoteHeight, bands.bodyHeight * MAX_FOOTNOTE_AREA_RATIO);
899
- const footnotesDiv = document.createElement("div");
2296
+ const footnotesDiv = this.document.createElement("div");
900
2297
  footnotesDiv.className = `${this.cssPrefix}footnotes`;
901
2298
  footnotesDiv.style.position = "absolute";
902
2299
  // Notes sit at the FOOT OF THE BODY BAND, not at the bottom margin: a footer taller than
@@ -908,17 +2305,10 @@ export class PaginationEngine {
908
2305
  // Constrain height and clip overflow to prevent footnotes covering body content
909
2306
  footnotesDiv.style.maxHeight = `${maxFootnoteHeight}pt`;
910
2307
  footnotesDiv.style.overflow = "hidden";
911
- // Add separator line
912
- const hr = document.createElement("hr");
913
- footnotesDiv.appendChild(hr);
2308
+ this.appendFootnoteSeparator(footnotesDiv, Boolean(hasContinuation));
914
2309
  // Add continuation content first (if any)
915
2310
  if (hasContinuation) {
916
- const contWrapper = document.createElement("div");
917
- contWrapper.className = "footnote-continuation";
918
- for (const el of continuation.remainingElements) {
919
- contWrapper.appendChild(el.cloneNode(true));
920
- }
921
- footnotesDiv.appendChild(contWrapper);
2311
+ footnotesDiv.appendChild(this.createFootnoteContinuationWrapper(continuation));
922
2312
  }
923
2313
  // Clone footnotes in order of appearance
924
2314
  for (const id of footnoteIds) {
@@ -928,22 +2318,7 @@ export class PaginationEngine {
928
2318
  // Render partial footnote (only the fitting elements)
929
2319
  const footnote = this.footnoteRegistry.get(id);
930
2320
  if (footnote) {
931
- const partialDiv = document.createElement("div");
932
- partialDiv.className = "footnote-item";
933
- partialDiv.dataset.footnoteId = id;
934
- // Add footnote number
935
- const numberSpan = footnote.querySelector(".footnote-number");
936
- if (numberSpan) {
937
- partialDiv.appendChild(numberSpan.cloneNode(true));
938
- }
939
- // Add only the fitting content
940
- const contentSpan = document.createElement("span");
941
- contentSpan.className = "footnote-content";
942
- for (const el of partial.fittingElements) {
943
- contentSpan.appendChild(el.cloneNode(true));
944
- }
945
- partialDiv.appendChild(contentSpan);
946
- footnotesDiv.appendChild(partialDiv);
2321
+ footnotesDiv.appendChild(this.createPartialFootnoteItem(footnote, partial.fittingElements));
947
2322
  }
948
2323
  }
949
2324
  else {
@@ -956,10 +2331,8 @@ export class PaginationEngine {
956
2331
  }
957
2332
  pageBox.appendChild(footnotesDiv);
958
2333
  }
959
- /**
960
- * Selects the appropriate header for a page based on section, page position, and page number.
961
- */
962
- selectHeader(sectionIndex, pageInSection, globalPageNumber) {
2334
+ /** Select the section's first/odd/even header from its one-based page position. */
2335
+ selectHeader(sectionIndex, pageInSection, displayedPageNumber) {
963
2336
  const sectionHf = this.hfRegistry.get(sectionIndex);
964
2337
  if (!sectionHf)
965
2338
  return undefined;
@@ -967,17 +2340,19 @@ export class PaginationEngine {
967
2340
  if (pageInSection === 1 && sectionHf.headerFirst) {
968
2341
  return sectionHf.headerFirst;
969
2342
  }
970
- // Even pages use even header if available
971
- if (globalPageNumber % 2 === 0 && sectionHf.headerEven) {
2343
+ // ECMA-376 §17.10.5 hangs the even story on "even numbered pages": the PAGE NUMBER,
2344
+ // which keeps counting across a section boundary unless w:pgNumType restarts it (and a
2345
+ // restart moves the parity with it). Word and LibreOffice both render DB001-Sections
2346
+ // this way; selecting by position-in-section flips every story of a section that
2347
+ // begins on an even page (issue #536).
2348
+ if (displayedPageNumber % 2 === 0 && sectionHf.headerEven) {
972
2349
  return sectionHf.headerEven;
973
2350
  }
974
2351
  // Default (odd) pages
975
2352
  return sectionHf.headerDefault;
976
2353
  }
977
- /**
978
- * Selects the appropriate footer for a page based on section, page position, and page number.
979
- */
980
- selectFooter(sectionIndex, pageInSection, globalPageNumber) {
2354
+ /** Select the section's first/odd/even footer from its one-based page position. */
2355
+ selectFooter(sectionIndex, pageInSection, displayedPageNumber) {
981
2356
  const sectionHf = this.hfRegistry.get(sectionIndex);
982
2357
  if (!sectionHf)
983
2358
  return undefined;
@@ -985,8 +2360,7 @@ export class PaginationEngine {
985
2360
  if (pageInSection === 1 && sectionHf.footerFirst) {
986
2361
  return sectionHf.footerFirst;
987
2362
  }
988
- // Even pages use even footer if available
989
- if (globalPageNumber % 2 === 0 && sectionHf.footerEven) {
2363
+ if (displayedPageNumber % 2 === 0 && sectionHf.footerEven) {
990
2364
  return sectionHf.footerEven;
991
2365
  }
992
2366
  // Default (odd) pages
@@ -1002,18 +2376,18 @@ export class PaginationEngine {
1002
2376
  * Deterministic: it depends only on the section's page setup and the registry's pre-measured
1003
2377
  * story heights, never on the page's content, which is what keeps it lazy-loading compatible.
1004
2378
  */
1005
- getPageBands(dims, sectionIndex, pageInSection, globalPageNumber) {
2379
+ getPageBands(dims, sectionIndex, pageInSection, displayedPageNumber) {
1006
2380
  const sectionHf = this.hfRegistry.get(sectionIndex);
1007
- return resolvePageBands(dims, this.selectStoryHeight(sectionHf?.headerFirstHeight, sectionHf?.headerEvenHeight, sectionHf?.headerDefaultHeight, pageInSection, globalPageNumber), this.selectStoryHeight(sectionHf?.footerFirstHeight, sectionHf?.footerEvenHeight, sectionHf?.footerDefaultHeight, pageInSection, globalPageNumber));
2381
+ return resolvePageBands(dims, this.selectStoryHeight(sectionHf?.headerFirstHeight, sectionHf?.headerEvenHeight, sectionHf?.headerDefaultHeight, pageInSection, displayedPageNumber), this.selectStoryHeight(sectionHf?.footerFirstHeight, sectionHf?.footerEvenHeight, sectionHf?.footerDefaultHeight, pageInSection, displayedPageNumber));
1008
2382
  }
1009
2383
  /**
1010
2384
  * The measured height of the running story this page position selects, mirroring
1011
2385
  * {@link selectHeader}/{@link selectFooter}. Zero when the page has no such story.
1012
2386
  */
1013
- selectStoryHeight(first, even, fallback, pageInSection, globalPageNumber) {
2387
+ selectStoryHeight(first, even, fallback, pageInSection, displayedPageNumber) {
1014
2388
  if (pageInSection === 1 && first != null)
1015
2389
  return first;
1016
- if (globalPageNumber % 2 === 0 && even != null)
2390
+ if (displayedPageNumber % 2 === 0 && even != null)
1017
2391
  return even;
1018
2392
  return fallback ?? 0;
1019
2393
  }
@@ -1027,7 +2401,7 @@ export class PaginationEngine {
1027
2401
  */
1028
2402
  measureHeaderFooterHeight(source, contentWidth) {
1029
2403
  // Create a temporary measurement container
1030
- const measureContainer = document.createElement("div");
2404
+ const measureContainer = this.document.createElement("div");
1031
2405
  measureContainer.style.position = "absolute";
1032
2406
  measureContainer.style.visibility = "hidden";
1033
2407
  measureContainer.style.width = `${contentWidth}pt`;
@@ -1050,25 +2424,49 @@ export class PaginationEngine {
1050
2424
  * Implements a single-pass, forward-only algorithm that is compatible with future lazy loading.
1051
2425
  * Supports footnote continuation - long footnotes can split across pages.
1052
2426
  */
1053
- flowToPages(blocks, dims, startPageNumber, sectionIndex) {
2427
+ flowToPages(blocks, dims, startPageNumber, sectionIndex, sectionDimensions) {
1054
2428
  const pages = [];
1055
2429
  let currentContent = [];
1056
2430
  let pageNumber = startPageNumber;
1057
- // Track page number within this section for first-page header/footer selection
1058
- let pageInSection = 1;
2431
+ let pageSectionIndex = sectionIndex;
2432
+ // A continuous section can begin on a page owned by its predecessor. That shared physical
2433
+ // page is still page 1 of the later section, so its first independently owned page is page 2.
2434
+ const sectionStartPages = new Map([[sectionIndex, startPageNumber]]);
2435
+ const pageInSection = (owner = pageSectionIndex, physicalPage = pageNumber) => physicalPage - (sectionStartPages.get(owner) ?? physicalPage) + 1;
2436
+ const dimensionsFor = (owner = pageSectionIndex) => sectionDimensions.get(owner) ?? dims;
2437
+ const markSectionPlaced = (owner) => {
2438
+ if (!sectionStartPages.has(owner))
2439
+ sectionStartPages.set(owner, pageNumber);
2440
+ };
2441
+ const previousBox = this.containerElement.querySelector(`.${this.cssPrefix}box:last-of-type`);
2442
+ let precedingDisplayedPageNumber = parseInt(previousBox?.dataset.displayedPageNumber ?? "0", 10);
2443
+ const displayedPageNumber = (owner = pageSectionIndex, ownerPageInSection = pageInSection(owner), physicalPage = pageNumber) => {
2444
+ const numbering = this.pageNumbering.get(owner) ?? {};
2445
+ if (numbering.start !== undefined) {
2446
+ return numbering.start + ownerPageInSection - 1;
2447
+ }
2448
+ const ownerNumbering = this.pageNumbering.get(pageSectionIndex) ?? {};
2449
+ const currentPhysicalNumber = ownerNumbering.start !== undefined
2450
+ ? ownerNumbering.start + pageInSection(pageSectionIndex) - 1
2451
+ : precedingDisplayedPageNumber + 1;
2452
+ return currentPhysicalNumber + physicalPage - pageNumber;
2453
+ };
1059
2454
  // Get effective content height for first page (accounts for header/footer sizes)
1060
- let { bodyHeight: effectiveContentHeight } = this.getPageBands(dims, sectionIndex, pageInSection, pageNumber);
2455
+ let { bodyHeight: effectiveContentHeight } = this.getPageBands(dimensionsFor(), pageSectionIndex, pageInSection(), displayedPageNumber());
1061
2456
  let remainingHeight = effectiveContentHeight;
1062
2457
  // Track the previous block's bottom margin for margin collapsing
1063
2458
  let prevMarginBottomPt = 0;
1064
2459
  // Track footnote IDs for the current page
1065
2460
  let currentFootnoteIds = [];
2461
+ let currentPageHasFootnoteReference = false;
1066
2462
  // Track height consumed by footnotes on current page
1067
2463
  let currentFootnoteHeight = 0;
1068
2464
  // Track footnote continuation for current page (from previous page)
1069
2465
  let currentContinuation = this.pendingFootnoteContinuation;
1070
2466
  // Track any new continuation that will carry to next page
1071
2467
  let nextPageContinuation = null;
2468
+ let currentContinuationPartitioned = false;
2469
+ let currentPageAdmitted = false;
1072
2470
  /**
1073
2471
  * Whole notes that could not be started on this page and must render on the next one.
1074
2472
  *
@@ -1083,29 +2481,133 @@ export class PaginationEngine {
1083
2481
  let deferredFootnoteIds = [];
1084
2482
  // Track partial footnotes for current page (footnotes that were split)
1085
2483
  let currentPartialFootnotes = [];
1086
- // Account for any continuation from previous section/page
1087
- if (currentContinuation && currentContinuation.remainingElements.length > 0) {
1088
- currentFootnoteHeight = this.measureContinuationHeight(currentContinuation, dims.contentWidth);
1089
- }
1090
- const finishPage = () => {
2484
+ const adoptEmptyPageOwner = (owner) => {
2485
+ pageSectionIndex = owner;
2486
+ const bands = this.getPageBands(dimensionsFor(owner), owner, pageInSection(owner), displayedPageNumber(owner));
2487
+ effectiveContentHeight = bands.bodyHeight;
2488
+ remainingHeight = effectiveContentHeight;
2489
+ };
2490
+ const admitCurrentPage = () => {
2491
+ if (currentPageAdmitted)
2492
+ return;
2493
+ this.admitPageAllocation();
2494
+ currentPageAdmitted = true;
2495
+ };
2496
+ const prepareCurrentContinuation = () => {
2497
+ if (currentContinuationPartitioned
2498
+ || (currentContinuation?.remainingElements.length ?? 0) === 0)
2499
+ return;
2500
+ // A continuation guarantees that this physical page will exist. Admit it
2501
+ // before touching the carried DOM, then partition once and retain that
2502
+ // exact head while body placement is decided.
2503
+ admitCurrentPage();
2504
+ const ownedDimensions = dimensionsFor();
2505
+ const pageBands = this.getPageBands(ownedDimensions, pageSectionIndex, pageInSection(), displayedPageNumber());
2506
+ const partition = this.splitContinuationForPage(currentContinuation, pageBands.bodyHeight * MAX_FOOTNOTE_AREA_RATIO, ownedDimensions.contentWidth);
2507
+ currentContinuation = partition.current;
2508
+ if (partition.overflow)
2509
+ nextPageContinuation = partition.overflow;
2510
+ currentContinuationPartitioned = true;
2511
+ currentFootnoteHeight = this.measureFootnotesHeight(currentFootnoteIds, ownedDimensions.contentWidth, currentContinuation, currentPartialFootnotes);
2512
+ };
2513
+ const finishPage = (nextPageSectionIndex = pageSectionIndex, forceEmptyPage = false) => {
1091
2514
  const hasCurrentContinuation = (currentContinuation?.remainingElements.length ?? 0) > 0;
1092
- if (currentContent.length === 0 && !hasCurrentContinuation)
2515
+ if (!forceEmptyPage && currentContent.length === 0 && currentFootnoteIds.length === 0
2516
+ && !hasCurrentContinuation) {
2517
+ adoptEmptyPageOwner(nextPageSectionIndex);
1093
2518
  return;
1094
- const page = this.createPage(dims, pageNumber, sectionIndex, currentContent, pageInSection, currentFootnoteIds, currentFootnoteHeight, currentContinuation, currentPartialFootnotes.length > 0 ? currentPartialFootnotes : undefined);
2519
+ }
2520
+ prepareCurrentContinuation();
2521
+ // Pages without a carried continuation are charged here. Continuation
2522
+ // pages were charged before their page-owned partition above.
2523
+ admitCurrentPage();
2524
+ let pageContinuation = currentContinuation;
2525
+ const ownedPageInSection = pageInSection();
2526
+ const ownedDimensions = dimensionsFor();
2527
+ const ownedDisplayedPageNumber = displayedPageNumber(pageSectionIndex, ownedPageInSection);
2528
+ const pageBands = this.getPageBands(ownedDimensions, pageSectionIndex, ownedPageInSection, ownedDisplayedPageNumber);
2529
+ const maxFootnoteHeight = pageBands.bodyHeight * MAX_FOOTNOTE_AREA_RATIO;
2530
+ // `prepareCurrentContinuation` has already packed the exact head for this
2531
+ // page. Keeping that partition stable lets body fit decisions share the
2532
+ // remaining band without rescanning the entire tail.
2533
+ pageContinuation = currentContinuation;
2534
+ // Recompute the entire painted payload after continuation partitioning.
2535
+ // Measuring only the carried head here discarded the reserve for a newer
2536
+ // whole/partial note and let the clipped note band silently consume it.
2537
+ currentFootnoteHeight = this.measureFootnotesHeight(currentFootnoteIds, ownedDimensions.contentWidth, pageContinuation, currentPartialFootnotes);
2538
+ // A final body page can seed the next page with several whole notes. Partition that queue
2539
+ // before materializing a note-only page: addPageFootnotes deliberately clips its band, so
2540
+ // placing the entire queue in one page would keep the DOM nodes while making later notes
2541
+ // invisible (and therefore absent from a geometry-clipped PageMap).
2542
+ if (currentContent.length === 0 && currentFootnoteIds.length > 0
2543
+ && currentPartialFootnotes.length === 0) {
2544
+ const fittingIds = [];
2545
+ for (let index = 0; index < currentFootnoteIds.length; index++) {
2546
+ const footnoteId = currentFootnoteIds[index];
2547
+ const candidateIds = [...fittingIds, footnoteId];
2548
+ const candidateHeight = this.measureFootnotesHeight(candidateIds, ownedDimensions.contentWidth, pageContinuation);
2549
+ const guardedCandidateHeight = candidateHeight + FOOTNOTE_MEASUREMENT_GUARD_PT;
2550
+ if (guardedCandidateHeight <= maxFootnoteHeight) {
2551
+ fittingIds.push(footnoteId);
2552
+ currentFootnoteHeight = guardedCandidateHeight;
2553
+ continue;
2554
+ }
2555
+ const hasPageContinuation = (pageContinuation?.remainingElements.length ?? 0) > 0;
2556
+ if (fittingIds.length === 0 && !hasPageContinuation) {
2557
+ // One note alone is taller than the note band. Split at the same safe paragraph
2558
+ // boundaries used during body flow; if it is indivisible, preserve the established
2559
+ // visible clipped fallback while still advancing the queue.
2560
+ const source = this.footnoteRegistry.get(footnoteId);
2561
+ const split = source
2562
+ ? this.splitFootnoteToFit(source, maxFootnoteHeight - FOOTNOTE_MEASUREMENT_GUARD_PT, ownedDimensions.contentWidth, true)
2563
+ : null;
2564
+ fittingIds.push(footnoteId);
2565
+ if (source && split && split.fits.length > 0 && split.overflow.length > 0) {
2566
+ currentPartialFootnotes.push({ footnoteId, fittingElements: split.fits });
2567
+ nextPageContinuation = {
2568
+ footnoteId,
2569
+ sourceAnchorId: source.dataset.sourceAnchorId,
2570
+ remainingElements: split.overflow,
2571
+ };
2572
+ currentFootnoteHeight = maxFootnoteHeight;
2573
+ }
2574
+ else {
2575
+ currentFootnoteHeight = guardedCandidateHeight;
2576
+ }
2577
+ deferredFootnoteIds.push(...currentFootnoteIds.slice(index + 1));
2578
+ }
2579
+ else {
2580
+ deferredFootnoteIds.push(...currentFootnoteIds.slice(index));
2581
+ }
2582
+ break;
2583
+ }
2584
+ currentFootnoteIds = fittingIds;
2585
+ }
2586
+ const page = this.createPage(ownedDimensions, pageNumber, pageSectionIndex, ownedDisplayedPageNumber, currentContent, ownedPageInSection, currentFootnoteIds, currentFootnoteHeight, pageContinuation, currentPartialFootnotes.length > 0 ? currentPartialFootnotes : undefined, false, currentPageAdmitted);
1095
2587
  pages.push(page);
2588
+ precedingDisplayedPageNumber = ownedDisplayedPageNumber;
1096
2589
  pageNumber++;
1097
- pageInSection++;
1098
2590
  currentContent = [];
2591
+ pageSectionIndex = nextPageSectionIndex;
2592
+ if (!sectionStartPages.has(pageSectionIndex)) {
2593
+ // A carried note can own pages before the first body block of a new
2594
+ // section lands. Count those physical pages in that section so first /
2595
+ // odd / even stories and restarted displayed numbering advance once.
2596
+ sectionStartPages.set(pageSectionIndex, pageNumber);
2597
+ }
1099
2598
  // Get effective content height for new page position
1100
- const newBands = this.getPageBands(dims, sectionIndex, pageInSection, pageNumber);
2599
+ const newBands = this.getPageBands(dimensionsFor(), pageSectionIndex, pageInSection(), displayedPageNumber());
1101
2600
  effectiveContentHeight = newBands.bodyHeight;
1102
2601
  remainingHeight = effectiveContentHeight;
1103
2602
  prevMarginBottomPt = 0; // Reset margin tracking for new page
1104
2603
  currentFootnoteIds = []; // Reset footnotes for new page
2604
+ currentPageHasFootnoteReference = false;
1105
2605
  currentPartialFootnotes = []; // Reset partial footnotes for new page
1106
2606
  // Carry over continuation to next page
1107
2607
  currentContinuation = nextPageContinuation;
1108
2608
  nextPageContinuation = null;
2609
+ currentContinuationPartitioned = false;
2610
+ currentPageAdmitted = false;
1109
2611
  // Notes that never got started land at the top of the new page's note area. They are
1110
2612
  // ordinary footnotes from here on, so the normal fitting path handles them — and because
1111
2613
  // this page is fresh, the space they were denied now exists.
@@ -1113,27 +2615,86 @@ export class PaginationEngine {
1113
2615
  currentFootnoteIds = [...deferredFootnoteIds];
1114
2616
  deferredFootnoteIds = [];
1115
2617
  }
1116
- // Account for continuation height on new page
1117
- if (currentContinuation && currentContinuation.remainingElements.length > 0) {
1118
- currentFootnoteHeight = this.measureContinuationHeight(currentContinuation, dims.contentWidth);
1119
- }
1120
- else {
1121
- currentFootnoteHeight = 0;
1122
- }
1123
- if (currentFootnoteIds.length > 0) {
1124
- currentFootnoteHeight += this.measureFootnotesHeight(currentFootnoteIds, dims.contentWidth, null);
1125
- }
2618
+ // Whole deferred notes have no carried DOM and can be measured directly.
2619
+ // A continuation is partitioned lazily after the prospective page has
2620
+ // passed its resource admission check, so never clone its entire tail here.
2621
+ const nextDimensions = dimensionsFor();
2622
+ currentFootnoteHeight = currentContinuation
2623
+ ? 0
2624
+ : this.measureFootnotesHeight(currentFootnoteIds, nextDimensions.contentWidth);
1126
2625
  };
2626
+ const pageHasPayload = () => currentContent.length > 0
2627
+ || currentFootnoteIds.length > 0
2628
+ || (currentContinuation?.remainingElements.length ?? 0) > 0;
1127
2629
  for (let i = 0; i < blocks.length; i++) {
2630
+ this.checkpoint();
1128
2631
  const block = blocks[i];
2632
+ const allBlockFootnoteIds = this.extractFootnoteRefs(block.element);
2633
+ // Word normally lets a same-box continuous section begin on its predecessor's page. By
2634
+ // default, a footnote reference before that boundary promotes it to a page break. The
2635
+ // footnoteLayoutLikeWW8 compatibility switch permits post-break paragraphs without their own
2636
+ // references to remain on the shared page; keep checking until the page turns so a later
2637
+ // referenced paragraph still begins on a fresh page.
2638
+ if (block.sectionIndex !== pageSectionIndex && currentPageHasFootnoteReference
2639
+ && (!this.footnoteLayoutLikeWord8 || allBlockFootnoteIds.length > 0)) {
2640
+ finishPage(block.sectionIndex);
2641
+ }
2642
+ const pageIsEmpty = currentContent.length === 0
2643
+ && currentFootnoteIds.length === 0
2644
+ && (currentContinuation?.remainingElements.length ?? 0) === 0;
2645
+ if (pageIsEmpty && block.sectionIndex !== pageSectionIndex) {
2646
+ adoptEmptyPageOwner(block.sectionIndex);
2647
+ }
2648
+ prepareCurrentContinuation();
2649
+ if (currentFootnoteHeight > 0) {
2650
+ const ownerDimensions = dimensionsFor();
2651
+ const ownerBands = this.getPageBands(ownerDimensions, pageSectionIndex, pageInSection(), displayedPageNumber());
2652
+ const ownerMaxFootnoteArea = ownerBands.bodyHeight * MAX_FOOTNOTE_AREA_RATIO;
2653
+ if (currentFootnoteHeight > ownerMaxFootnoteArea) {
2654
+ // A queue of whole notes larger than the band does not entitle it to
2655
+ // the whole page: the band is capped and the rest of the page still
2656
+ // belongs to body text. Keep the notes that fit here, defer the rest,
2657
+ // and only fall back to a note-owned page when not even one note fits
2658
+ // — that case needs the splitting finishPage performs.
2659
+ const fittingIds = [];
2660
+ let fittingHeight = 0;
2661
+ if ((currentContinuation?.remainingElements.length ?? 0) === 0
2662
+ && currentPartialFootnotes.length === 0) {
2663
+ for (const footnoteId of currentFootnoteIds) {
2664
+ this.checkpoint();
2665
+ const candidateHeight = this.measureFootnotesHeight([...fittingIds, footnoteId], ownerDimensions.contentWidth);
2666
+ if (candidateHeight + FOOTNOTE_MEASUREMENT_GUARD_PT > ownerMaxFootnoteArea)
2667
+ break;
2668
+ fittingIds.push(footnoteId);
2669
+ fittingHeight = candidateHeight;
2670
+ }
2671
+ }
2672
+ if (fittingIds.length > 0) {
2673
+ deferredFootnoteIds = [
2674
+ ...currentFootnoteIds.slice(fittingIds.length),
2675
+ ...deferredFootnoteIds,
2676
+ ];
2677
+ currentFootnoteIds = fittingIds;
2678
+ currentFootnoteHeight = fittingHeight;
2679
+ }
2680
+ else {
2681
+ finishPage(block.sectionIndex);
2682
+ i--;
2683
+ continue;
2684
+ }
2685
+ }
2686
+ }
2687
+ const blockDimensions = dimensionsFor(block.sectionIndex);
2688
+ const nextBlockPageInSection = pageInSection(block.sectionIndex, pageNumber + 1);
2689
+ const freshBlockPageBodyHeight = this.getPageBands(blockDimensions, block.sectionIndex, nextBlockPageInSection, displayedPageNumber(block.sectionIndex, nextBlockPageInSection, pageNumber + 1)).bodyHeight;
1129
2690
  // Handle explicit page breaks
1130
2691
  if (block.isPageBreak) {
1131
- finishPage();
2692
+ finishPage(block.sectionIndex);
1132
2693
  continue;
1133
2694
  }
1134
2695
  // Handle page break before
1135
2696
  if (block.pageBreakBefore && currentContent.length > 0) {
1136
- finishPage();
2697
+ finishPage(block.sectionIndex);
1137
2698
  }
1138
2699
  // A series of keep-with-next blocks is one indivisible placement unit
1139
2700
  // when it can fit a new page. The former one-block lookahead was never
@@ -1155,47 +2716,43 @@ export class PaginationEngine {
1155
2716
  const newChainFootnoteIds = this.collectNewFootnoteIds(keepChain, currentFootnoteIds);
1156
2717
  let additionalChainFootnoteHeight = 0;
1157
2718
  if (newChainFootnoteIds.length > 0 && this.footnoteRegistry.size > 0) {
1158
- const totalChainFootnoteHeight = this.measureFootnotesHeight([...currentFootnoteIds, ...newChainFootnoteIds], dims.contentWidth, currentContinuation);
2719
+ const totalChainFootnoteHeight = this.measureFootnotesHeight([...currentFootnoteIds, ...newChainFootnoteIds], blockDimensions.contentWidth, currentContinuation);
1159
2720
  additionalChainFootnoteHeight = Math.max(0, totalChainFootnoteHeight - currentFootnoteHeight);
1160
2721
  }
1161
- const currentChainHeight = this.measureKeepWithNextChainBodyHeight(keepChain, prevMarginBottomPt, currentContent.length === 0) +
2722
+ const currentChainHeight = this.measureKeepWithNextChainBodyHeight(keepChain, prevMarginBottomPt, currentContent.length === 0, pageInSection(block.sectionIndex)) +
1162
2723
  additionalChainFootnoteHeight;
1163
2724
  const currentAvailableHeight = remainingHeight - currentFootnoteHeight;
1164
2725
  if (currentChainHeight > currentAvailableHeight) {
1165
- const nextPageBands = this.getPageBands(dims, sectionIndex, pageInSection + 1, pageNumber + 1);
1166
- const freshChainBodyHeight = this.measureKeepWithNextChainBodyHeight(keepChain, 0, true);
2726
+ const nextPageBands = this.getPageBands(dimensionsFor(block.sectionIndex), block.sectionIndex, pageInSection(block.sectionIndex, pageNumber + 1), displayedPageNumber(block.sectionIndex, pageInSection(block.sectionIndex, pageNumber + 1), pageNumber + 1));
2727
+ const freshChainBodyHeight = this.measureKeepWithNextChainBodyHeight(keepChain, 0, true, pageInSection(block.sectionIndex, pageNumber + 1));
1167
2728
  // finishPage transfers this continuation to the new page's
1168
2729
  // currentContinuation state, so include it in the destination
1169
2730
  // page's footnote reservation before deciding to move the chain.
1170
- const freshChainFootnoteHeight = this.measureFootnotesHeight(newChainFootnoteIds, dims.contentWidth, nextPageContinuation);
2731
+ const freshChainFootnoteHeight = this.measureFootnotesHeight(newChainFootnoteIds, blockDimensions.contentWidth, nextPageContinuation);
1171
2732
  if (freshChainBodyHeight + freshChainFootnoteHeight <=
1172
2733
  nextPageBands.bodyHeight) {
1173
- finishPage();
2734
+ finishPage(block.sectionIndex);
1174
2735
  }
1175
2736
  }
1176
2737
  }
1177
2738
  }
1178
2739
  // Extract footnote references from this block
1179
- const allBlockFootnoteIds = this.extractFootnoteRefs(block.element);
1180
2740
  // Only count new footnotes (not already on this page)
1181
2741
  const newFootnoteIds = this.collectNewFootnoteIds([block], currentFootnoteIds);
1182
2742
  // Calculate additional footnote height if this block is added
2743
+ let combinedFootnoteHeight = currentFootnoteHeight;
1183
2744
  let additionalFootnoteHeight = 0;
1184
2745
  if (newFootnoteIds.length > 0 && this.footnoteRegistry.size > 0) {
1185
2746
  // Measure the combined height of all footnotes that would be on this page
1186
2747
  // (including any continuation)
1187
2748
  const combinedFootnoteIds = [...currentFootnoteIds, ...newFootnoteIds];
1188
- const totalFootnoteHeight = this.measureFootnotesHeight(combinedFootnoteIds, dims.contentWidth, currentContinuation);
1189
- additionalFootnoteHeight = totalFootnoteHeight - currentFootnoteHeight;
2749
+ combinedFootnoteHeight = this.measureFootnotesHeight(combinedFootnoteIds, blockDimensions.contentWidth, currentContinuation, currentPartialFootnotes);
2750
+ additionalFootnoteHeight = Math.max(0, combinedFootnoteHeight - currentFootnoteHeight);
1190
2751
  }
1191
2752
  // Calculate the effective height this block will consume
1192
2753
  // Account for margin collapsing: the gap between blocks is max(prevBottom, currTop), not sum
1193
2754
  const isFirstOnPage = currentContent.length === 0;
1194
- let effectiveMarginTop = block.marginTopPt;
1195
- if (!isFirstOnPage) {
1196
- // Margin collapsing: use the larger of the two adjacent margins
1197
- effectiveMarginTop = Math.max(block.marginTopPt, prevMarginBottomPt) - prevMarginBottomPt;
1198
- }
2755
+ const effectiveMarginTop = this.effectiveBlockMarginTop(block, prevMarginBottomPt, isFirstOnPage, pageInSection(block.sectionIndex));
1199
2756
  // Visible height = top margin gap + content + footnote space
1200
2757
  // Note: bottom margin is NOT included in the fit check because the last block's
1201
2758
  // bottom margin extends beyond the content area and is clipped by overflow:hidden.
@@ -1206,13 +2763,12 @@ export class PaginationEngine {
1206
2763
  // Calculate maximum footnote area for this page (can expand into body content space)
1207
2764
  const bodyContentUsed = effectiveContentHeight - remainingHeight;
1208
2765
  const maxFootnoteArea = effectiveContentHeight * MAX_FOOTNOTE_AREA_RATIO;
1209
- const maxFootnoteExpansion = Math.max(0, maxFootnoteArea - currentFootnoteHeight);
1210
2766
  // A paragraph that cannot fit as a whole may still have a simple text-only
1211
2767
  // prefix that fits this page. Fragment before the ordinary next-page or
1212
2768
  // oversized fallback so the cloned head participates in the same margin
1213
2769
  // and footnote accounting as every other block.
1214
2770
  if (blockSpace > effectiveRemainingHeight) {
1215
- const paragraphFragments = this.tryFragmentParagraph(block, dims, effectiveRemainingHeight, effectiveMarginTop);
2771
+ const paragraphFragments = this.tryFragmentParagraph(block, blockDimensions, effectiveRemainingHeight, effectiveMarginTop);
1216
2772
  if (paragraphFragments) {
1217
2773
  blocks.splice(i, 1, ...paragraphFragments);
1218
2774
  i--;
@@ -1220,48 +2776,69 @@ export class PaginationEngine {
1220
2776
  }
1221
2777
  }
1222
2778
  // Check if block fits on current page (including its footnotes)
1223
- if (blockSpace <= effectiveRemainingHeight) {
2779
+ if (blockSpace <= effectiveRemainingHeight
2780
+ && (combinedFootnoteHeight === 0
2781
+ || combinedFootnoteHeight + FOOTNOTE_MEASUREMENT_GUARD_PT <= maxFootnoteArea)) {
1224
2782
  // Block fits with current footnote allocation
1225
- currentContent.push(block.element.cloneNode(true));
2783
+ markSectionPlaced(block.sectionIndex);
2784
+ currentContent.push(this.cloneBlockForPage(block, isFirstOnPage, pageInSection(block.sectionIndex)));
1226
2785
  remainingHeight -= (effectiveMarginTop + block.heightPt + block.marginBottomPt);
1227
2786
  prevMarginBottomPt = block.marginBottomPt;
1228
2787
  // Add new footnotes to current page
1229
2788
  if (newFootnoteIds.length > 0) {
1230
2789
  currentFootnoteIds.push(...newFootnoteIds);
1231
- currentFootnoteHeight += additionalFootnoteHeight;
2790
+ currentFootnoteHeight = combinedFootnoteHeight;
1232
2791
  }
2792
+ currentPageHasFootnoteReference || (currentPageHasFootnoteReference = allBlockFootnoteIds.length > 0);
1233
2793
  }
1234
- else if (block.heightPt + block.marginTopPt <= effectiveContentHeight) {
2794
+ else if (block.heightPt + this.effectiveBlockMarginTop(block, 0, true, pageInSection(block.sectionIndex, pageNumber + 1)) <= freshBlockPageBodyHeight) {
1235
2795
  // Block doesn't fit with current allocation - try expanding footnote area
1236
2796
  const blockSpaceWithoutFootnotes = effectiveMarginTop + block.heightPt;
1237
- // Check if block fits if we expand footnote area
1238
- // We can expand footnotes up to maxFootnoteArea, leaving room for body content
1239
- const minBodySpaceNeeded = bodyContentUsed + blockSpaceWithoutFootnotes + MIN_BODY_CONTENT_HEIGHT;
1240
- const canExpandFootnotes = minBodySpaceNeeded <= effectiveContentHeight;
1241
2797
  if (newFootnoteIds.length > 0 && blockSpaceWithoutFootnotes <= effectiveRemainingHeight) {
1242
2798
  // Block itself fits, but footnotes don't - expand footnote area
1243
- currentContent.push(block.element.cloneNode(true));
2799
+ markSectionPlaced(block.sectionIndex);
2800
+ currentContent.push(this.cloneBlockForPage(block, isFirstOnPage, pageInSection(block.sectionIndex)));
1244
2801
  remainingHeight -= (effectiveMarginTop + block.heightPt + block.marginBottomPt);
1245
2802
  prevMarginBottomPt = block.marginBottomPt;
1246
2803
  // Calculate EXPANDED space available for footnotes
1247
2804
  // Footnotes can take up to maxFootnoteArea or all remaining space, whichever is less
1248
2805
  const availableForFootnotes = Math.min(maxFootnoteArea, effectiveContentHeight - bodyContentUsed - blockSpaceWithoutFootnotes);
2806
+ // Deferring a note costs a page turn, and it only buys anything if the
2807
+ // next page's band is larger than what this page can already offer.
2808
+ // When this page already offers the maximum band, a note that cannot
2809
+ // start here cannot start there either: keep the established clipped
2810
+ // fallback on the citing page rather than evacuating it.
2811
+ const nextPageMaxFootnoteArea = freshBlockPageBodyHeight * MAX_FOOTNOTE_AREA_RATIO;
2812
+ const deferralCannotHelp = availableForFootnotes >= nextPageMaxFootnoteArea;
1249
2813
  // Try to fit as much of each new footnote as possible in expanded area
1250
- for (const footnoteId of newFootnoteIds) {
2814
+ for (let noteIndex = 0; noteIndex < newFootnoteIds.length; noteIndex++) {
2815
+ this.checkpoint();
2816
+ const footnoteId = newFootnoteIds[noteIndex];
1251
2817
  const footnote = this.footnoteRegistry.get(footnoteId);
1252
2818
  if (!footnote)
1253
2819
  continue;
1254
- const footnoteHeight = this.measureSingleFootnoteHeight(footnoteId, dims.contentWidth);
2820
+ // One page can carry only one split tail. Once that slot is used,
2821
+ // keep every later note in source order on the deferral queue.
2822
+ if (nextPageContinuation) {
2823
+ deferredFootnoteIds.push(...newFootnoteIds.slice(noteIndex));
2824
+ break;
2825
+ }
2826
+ const candidateIds = [...currentFootnoteIds, footnoteId];
2827
+ const candidateHeight = this.measureFootnotesHeight(candidateIds, blockDimensions.contentWidth, currentContinuation, currentPartialFootnotes);
1255
2828
  const spaceLeftForFootnotes = availableForFootnotes - currentFootnoteHeight;
1256
- if (footnoteHeight <= spaceLeftForFootnotes) {
2829
+ if (candidateHeight + FOOTNOTE_MEASUREMENT_GUARD_PT <= availableForFootnotes) {
1257
2830
  // Whole footnote fits in expanded area
1258
2831
  currentFootnoteIds.push(footnoteId);
1259
- currentFootnoteHeight += footnoteHeight;
2832
+ currentFootnoteHeight = candidateHeight;
1260
2833
  }
1261
2834
  else {
1262
2835
  // Footnote needs to be split - use all available expanded space
1263
2836
  if (spaceLeftForFootnotes > 20) { // Minimum space to start a footnote
1264
- const { fits, overflow } = this.splitFootnoteToFit(footnote, spaceLeftForFootnotes, dims.contentWidth);
2837
+ const { fits, overflow } = this.splitFootnoteToFit(footnote, availableForFootnotes, blockDimensions.contentWidth, deferralCannotHelp, {
2838
+ footnoteIds: currentFootnoteIds,
2839
+ continuation: currentContinuation,
2840
+ partialFootnotes: currentPartialFootnotes,
2841
+ });
1265
2842
  if (fits.length > 0) {
1266
2843
  // Add partial footnote to current page
1267
2844
  currentFootnoteIds.push(footnoteId);
@@ -1272,87 +2849,48 @@ export class PaginationEngine {
1272
2849
  if (overflow.length > 0) {
1273
2850
  nextPageContinuation = {
1274
2851
  footnoteId,
2852
+ sourceAnchorId: footnote.dataset.sourceAnchorId,
1275
2853
  remainingElements: overflow
1276
2854
  };
1277
2855
  }
1278
- currentFootnoteHeight = availableForFootnotes;
2856
+ currentFootnoteHeight = this.measureFootnotesHeight(currentFootnoteIds, blockDimensions.contentWidth, currentContinuation, currentPartialFootnotes);
2857
+ if (overflow.length > 0) {
2858
+ deferredFootnoteIds.push(...newFootnoteIds.slice(noteIndex + 1));
2859
+ break;
2860
+ }
1279
2861
  }
1280
2862
  else {
1281
2863
  // Nothing of this note fits: defer the WHOLE note rather than assigning the
1282
2864
  // single continuation slot, which a later note on this page would overwrite.
1283
- deferredFootnoteIds.push(footnoteId);
2865
+ deferredFootnoteIds.push(...newFootnoteIds.slice(noteIndex));
2866
+ break;
1284
2867
  }
1285
2868
  }
1286
2869
  else {
1287
2870
  // Not enough space to even start the note — same deferral.
1288
- deferredFootnoteIds.push(footnoteId);
2871
+ deferredFootnoteIds.push(...newFootnoteIds.slice(noteIndex));
2872
+ break;
1289
2873
  }
1290
2874
  }
1291
2875
  }
1292
- }
1293
- else if (canExpandFootnotes && newFootnoteIds.length > 0) {
1294
- // Block doesn't fit with current layout, but might fit if we expand footnote area first
1295
- // This handles the case where we need to give footnotes more space BEFORE adding the block
1296
- // First, try to fit more of current footnotes by expanding the area
1297
- // Then check if the block fits in reduced body space
1298
- const expandedFootnoteSpace = Math.min(maxFootnoteArea, additionalFootnoteHeight + currentFootnoteHeight);
1299
- const bodySpaceAfterExpansion = effectiveContentHeight - expandedFootnoteSpace;
1300
- if (blockSpaceWithoutFootnotes <= bodySpaceAfterExpansion - bodyContentUsed) {
1301
- // Block fits after expanding footnote area.
1302
- currentContent.push(block.element.cloneNode(true));
1303
- // `remainingHeight` tracks BODY consumption only — every other branch maintains it
1304
- // that way, and the footnote reserve is applied separately via `effectiveRemainingHeight`
1305
- // at the top of each iteration. Assigning `bodySpaceAfterExpansion - …` here folded the
1306
- // reserve in a second time, so a later block would see a body budget short by the whole
1307
- // footnote area. Consistency fix: no measurable difference on the documents tested, but
1308
- // the two meanings must not coexist or the next change here inherits a latent bug.
1309
- remainingHeight = bodySpaceAfterExpansion - bodyContentUsed - blockSpaceWithoutFootnotes;
1310
- prevMarginBottomPt = block.marginBottomPt;
1311
- currentFootnoteIds.push(...newFootnoteIds);
1312
- currentFootnoteHeight = expandedFootnoteSpace;
1313
- }
1314
- else {
1315
- // Still doesn't fit - start new page
1316
- finishPage();
1317
- const newPageFootnoteHeight = allBlockFootnoteIds.length > 0
1318
- ? this.measureFootnotesHeight(allBlockFootnoteIds, dims.contentWidth, currentContinuation)
1319
- : (currentContinuation ? this.measureContinuationHeight(currentContinuation, dims.contentWidth) : 0);
1320
- const newPageSpace = block.marginTopPt + block.heightPt + block.marginBottomPt;
1321
- currentContent.push(block.element.cloneNode(true));
1322
- remainingHeight = effectiveContentHeight - newPageSpace;
1323
- prevMarginBottomPt = block.marginBottomPt;
1324
- // Merge, never replace: finishPage() may have just seeded this page with notes deferred
1325
- // from the previous one, and overwriting here dropped them from the document.
1326
- currentFootnoteIds = [...currentFootnoteIds, ...allBlockFootnoteIds];
1327
- currentFootnoteHeight = newPageFootnoteHeight;
1328
- }
2876
+ currentPageHasFootnoteReference || (currentPageHasFootnoteReference = allBlockFootnoteIds.length > 0);
1329
2877
  }
1330
2878
  else {
1331
- // Block itself doesn't fit - start new page
1332
- finishPage();
1333
- // On new page, recalculate footnote height for just this block's footnotes
1334
- // (plus any continuation from previous page)
1335
- const newPageFootnoteHeight = allBlockFootnoteIds.length > 0
1336
- ? this.measureFootnotesHeight(allBlockFootnoteIds, dims.contentWidth, currentContinuation)
1337
- : (currentContinuation ? this.measureContinuationHeight(currentContinuation, dims.contentWidth) : 0);
1338
- // Include full top margin
1339
- const newPageSpace = block.marginTopPt + block.heightPt + block.marginBottomPt;
1340
- currentContent.push(block.element.cloneNode(true));
1341
- remainingHeight = effectiveContentHeight - newPageSpace;
1342
- prevMarginBottomPt = block.marginBottomPt;
1343
- // Merge, never replace: finishPage() may have just seeded this page with notes deferred
1344
- // from the previous one, and overwriting here dropped them from the document.
1345
- currentFootnoteIds = [...currentFootnoteIds, ...allBlockFootnoteIds];
1346
- currentFootnoteHeight = newPageFootnoteHeight;
2879
+ // Block itself doesn't fit. Retry after the page turn so carried
2880
+ // continuations and deferred notes participate in the normal fit and
2881
+ // split decisions instead of being overwritten by forced placement.
2882
+ finishPage(block.sectionIndex, !pageHasPayload());
2883
+ i--;
2884
+ continue;
1347
2885
  }
1348
2886
  }
1349
2887
  else {
1350
2888
  // Block is taller than a page. Ordinary tables can be split at complete
1351
2889
  // row boundaries; every other block retains the established overflow path.
1352
- const tableFragments = this.trySplitSimpleOversizedTable(block, dims, sectionIndex);
2890
+ const tableFragments = this.trySplitSimpleOversizedTable(block, dimensionsFor(block.sectionIndex), block.sectionIndex);
1353
2891
  if (tableFragments) {
1354
2892
  if (currentContent.length > 0 || currentContinuation) {
1355
- finishPage();
2893
+ finishPage(block.sectionIndex);
1356
2894
  }
1357
2895
  blocks.splice(i, 1, ...tableFragments);
1358
2896
  i--;
@@ -1361,18 +2899,30 @@ export class PaginationEngine {
1361
2899
  // Unsupported oversized blocks are intentionally left intact. Splitting
1362
2900
  // arbitrary HTML, merged tables, or footnote-bearing tables would be less
1363
2901
  // correct than the prior clipped fallback.
1364
- if (currentContent.length > 0) {
1365
- finishPage();
2902
+ if (pageHasPayload()) {
2903
+ finishPage(block.sectionIndex);
2904
+ i--;
2905
+ continue;
1366
2906
  }
1367
- currentContent.push(block.element.cloneNode(true));
1368
- // Merge, never replace: finishPage() may have just seeded this page with notes deferred
1369
- // from the previous one, and overwriting here dropped them from the document.
1370
- currentFootnoteIds = [...currentFootnoteIds, ...allBlockFootnoteIds];
1371
- finishPage();
2907
+ markSectionPlaced(block.sectionIndex);
2908
+ currentContent.push(this.cloneBlockForPage(block, true, pageInSection(block.sectionIndex)));
2909
+ // An unsupported body block already occupies/clips its whole body band.
2910
+ // Preserve its notes losslessly on following note-only pages rather
2911
+ // than drawing them underneath that clipped fallback.
2912
+ deferredFootnoteIds.push(...newFootnoteIds);
2913
+ currentPageHasFootnoteReference || (currentPageHasFootnoteReference = allBlockFootnoteIds.length > 0);
2914
+ finishPage(block.sectionIndex);
1372
2915
  }
1373
2916
  }
1374
2917
  // Finish last page
1375
2918
  finishPage();
2919
+ // A split created while finishing the final body page still needs a page substrate.
2920
+ // Drain all remaining note paragraphs into footnote-only continuation pages.
2921
+ while (currentFootnoteIds.length > 0
2922
+ || deferredFootnoteIds.length > 0
2923
+ || (currentContinuation?.remainingElements.length ?? 0) > 0) {
2924
+ finishPage();
2925
+ }
1376
2926
  // Store any remaining continuation for next section
1377
2927
  this.pendingFootnoteContinuation = nextPageContinuation;
1378
2928
  return pages;
@@ -1399,6 +2949,19 @@ export class PaginationEngine {
1399
2949
  el.setAttribute("contenteditable", "false");
1400
2950
  }
1401
2951
  }
2952
+ /** A repeated margin note is presentation, not a second bookmark/link target. */
2953
+ makeClonedMarginCommentInert(root) {
2954
+ this.makeClonedStoryInert(root);
2955
+ const nodes = [root, ...Array.from(root.querySelectorAll("*"))];
2956
+ for (const element of nodes) {
2957
+ element.removeAttribute("id");
2958
+ if (element.localName === "a" && element.getAttribute("href")?.startsWith("#")) {
2959
+ element.removeAttribute("href");
2960
+ element.setAttribute("aria-disabled", "true");
2961
+ element.tabIndex = -1;
2962
+ }
2963
+ }
2964
+ }
1402
2965
  /**
1403
2966
  * Resolves floating DrawingML objects after their anchor paragraphs have landed on a page.
1404
2967
  *
@@ -1425,7 +2988,7 @@ export class PaginationEngine {
1425
2988
  staticLeft: toPageX(staticRect.left),
1426
2989
  staticTop: toPageY(staticRect.top),
1427
2990
  lineHeight: paragraph
1428
- ? (parseFloat(getComputedStyle(paragraph).lineHeight) || staticRect.height) / pixelsPerPoint
2991
+ ? (parseFloat(this.view.getComputedStyle(paragraph).lineHeight) || staticRect.height) / pixelsPerPoint
1429
2992
  : staticRect.height / pixelsPerPoint,
1430
2993
  paragraphLeft: toPageX(paragraphRect.left),
1431
2994
  paragraphTop: toPageY(paragraphRect.top),
@@ -1537,13 +3100,21 @@ export class PaginationEngine {
1537
3100
  fraction = pageNumber % 2 === 1 ? 1 : 0;
1538
3101
  return reference.start + (reference.size - objectSize) * fraction;
1539
3102
  }
3103
+ /** Charge a physical page before any page-owned partitioning or DOM allocation. */
3104
+ admitPageAllocation() {
3105
+ const prospectivePageCount = this.createdPageCount + 1;
3106
+ this.pageCountCheckpoint?.(prospectivePageCount);
3107
+ this.createdPageCount = prospectivePageCount;
3108
+ }
1540
3109
  /**
1541
3110
  * Creates a page container element.
1542
3111
  */
1543
- createPage(dims, pageNumber, sectionIndex, content, pageInSection, footnoteIds = [], footnoteHeight = 0, continuation, partialFootnotes) {
3112
+ createPage(dims, pageNumber, sectionIndex, displayedPageNumber, content, pageInSection, footnoteIds = [], footnoteHeight = 0, continuation, partialFootnotes, isSectionFiller = false, pageAlreadyAdmitted = false) {
3113
+ if (!pageAlreadyAdmitted)
3114
+ this.admitPageAllocation();
1544
3115
  // Create page box at full size, then scale the entire box
1545
3116
  // This ensures proper clipping and consistent scaling of all elements
1546
- const pageBox = document.createElement("div");
3117
+ const pageBox = this.document.createElement("div");
1547
3118
  pageBox.className = `${this.cssPrefix}box`;
1548
3119
  pageBox.style.width = `${dims.pageWidth}pt`;
1549
3120
  pageBox.style.height = `${dims.pageHeight}pt`;
@@ -1553,35 +3124,40 @@ export class PaginationEngine {
1553
3124
  // Zoom affects layout (no negative margin hack needed) and renders text more crisply
1554
3125
  // Note: zoom is non-standard but supported in all major browsers
1555
3126
  if (this.scale !== 1) {
1556
- // Try zoom first (better text quality), with transform as fallback
1557
- pageBox.style.zoom = String(this.scale);
1558
- // For browsers that don't support zoom, also set transform
1559
- // The zoom takes precedence in supporting browsers
1560
- pageBox.style.transform = `scale(${this.scale})`;
1561
- pageBox.style.transformOrigin = "top left";
1562
- // Compensate for transform not affecting layout (only needed if zoom not supported)
1563
- // Convert pt to px for consistent unit math
1564
- const heightReductionPt = dims.pageHeight * (1 - this.scale);
1565
- const widthReductionPt = dims.pageWidth * (1 - this.scale);
1566
- const heightReductionPx = ptToPx(heightReductionPt);
1567
- const widthReductionPx = ptToPx(widthReductionPt);
1568
- pageBox.style.marginRight = `-${widthReductionPx}px`;
1569
- pageBox.style.marginBottom = `${this.pageGap - heightReductionPx}px`;
3127
+ if (this.view.CSS?.supports("zoom", "1")) {
3128
+ pageBox.style.zoom = String(this.scale);
3129
+ }
3130
+ else {
3131
+ pageBox.style.transform = `scale(${this.scale})`;
3132
+ pageBox.style.transformOrigin = "top left";
3133
+ // Transform does not affect layout, so compensate for the natural box dimensions.
3134
+ const heightReductionPt = dims.pageHeight * (1 - this.scale);
3135
+ const widthReductionPt = dims.pageWidth * (1 - this.scale);
3136
+ const heightReductionPx = ptToPx(heightReductionPt);
3137
+ const widthReductionPx = ptToPx(widthReductionPt);
3138
+ pageBox.style.marginRight = `-${widthReductionPx}px`;
3139
+ pageBox.style.marginBottom = `${this.pageGap - heightReductionPx}px`;
3140
+ }
1570
3141
  }
1571
3142
  // Hint browser for GPU compositing and layout isolation
1572
3143
  pageBox.style.willChange = "transform";
1573
3144
  pageBox.style.contain = "layout paint";
1574
3145
  pageBox.dataset.pageNumber = String(pageNumber);
1575
3146
  pageBox.dataset.sectionIndex = String(sectionIndex);
3147
+ pageBox.dataset.displayedPageNumber = String(displayedPageNumber);
3148
+ if (isSectionFiller)
3149
+ pageBox.dataset.sectionFiller = "true";
1576
3150
  // Needed by substitutePageNumberFields: a section that restarts numbering counts from its own
1577
3151
  // first page, not from the document's.
1578
3152
  pageBox.dataset.pageInSection = String(pageInSection);
1579
3153
  // Where the three bands sit on this page (no re-measurement needed)
1580
- const bands = this.getPageBands(dims, sectionIndex, pageInSection, pageNumber);
3154
+ const bands = this.getPageBands(dims, sectionIndex, pageInSection, displayedPageNumber);
1581
3155
  // Add header if available for this section/page
1582
- const headerSource = this.selectHeader(sectionIndex, pageInSection, pageNumber);
3156
+ const headerSource = isSectionFiller
3157
+ ? undefined
3158
+ : this.selectHeader(sectionIndex, pageInSection, displayedPageNumber);
1583
3159
  if (headerSource) {
1584
- const headerDiv = document.createElement("div");
3160
+ const headerDiv = this.document.createElement("div");
1585
3161
  headerDiv.className = `${this.cssPrefix}header`;
1586
3162
  headerDiv.style.position = "absolute";
1587
3163
  // `w:header` is the distance to the TOP of the story, and the story grows downward from
@@ -1609,7 +3185,7 @@ export class PaginationEngine {
1609
3185
  // Create content area using pre-computed effective heights
1610
3186
  const contentAreaTop = bands.bodyTop;
1611
3187
  const contentAreaHeight = bands.bodyHeight;
1612
- const contentArea = document.createElement("div");
3188
+ const contentArea = this.document.createElement("div");
1613
3189
  contentArea.className = `${this.cssPrefix}content`;
1614
3190
  contentArea.style.position = "absolute";
1615
3191
  contentArea.style.top = `${contentAreaTop}pt`;
@@ -1624,13 +3200,53 @@ export class PaginationEngine {
1624
3200
  pageBox.appendChild(contentArea);
1625
3201
  // Add footnotes if any references appear on this page (or continuation from previous)
1626
3202
  const hasContinuation = continuation && continuation.remainingElements.length > 0;
1627
- if (footnoteIds.length > 0 || hasContinuation) {
3203
+ if (!isSectionFiller && (footnoteIds.length > 0 || hasContinuation)) {
1628
3204
  this.addPageFootnotes(pageBox, footnoteIds, dims, bands, footnoteHeight, continuation, partialFootnotes);
1629
3205
  }
3206
+ // Materialize margin comments after footnotes exist so markers inside a
3207
+ // note participate in the same page-owned comment story as body markers.
3208
+ const pageCommentIds = [];
3209
+ for (const marker of Array.from(pageBox.querySelectorAll("[data-comment-id]"))) {
3210
+ if (marker.closest(`.${this.cssPrefix}comment-margin`))
3211
+ continue;
3212
+ const id = marker.dataset.commentId;
3213
+ if (id && this.commentMarginRegistry.has(id) && !pageCommentIds.includes(id)) {
3214
+ pageCommentIds.push(id);
3215
+ }
3216
+ }
3217
+ if (pageCommentIds.length > 0) {
3218
+ const marginColumn = this.document.createElement("aside");
3219
+ marginColumn.className = `${this.cssPrefix}comment-margin`;
3220
+ marginColumn.style.position = "absolute";
3221
+ marginColumn.style.top = `${contentAreaTop}pt`;
3222
+ marginColumn.style.left = `${dims.marginLeft + dims.contentWidth + 3}pt`;
3223
+ marginColumn.style.width = `${Math.max(12, dims.marginRight - 6)}pt`;
3224
+ marginColumn.style.maxHeight = `${contentAreaHeight}pt`;
3225
+ marginColumn.style.overflow = "hidden";
3226
+ marginColumn.style.boxSizing = "border-box";
3227
+ const placedThreadRoots = new Set();
3228
+ for (const id of pageCommentIds) {
3229
+ const source = this.commentMarginRegistry.get(id);
3230
+ if (source) {
3231
+ // Every id in a nested thread maps to the same root note; place it once even
3232
+ // when the root's and a reply's markers share the page.
3233
+ const rootId = source.dataset.commentId ?? id;
3234
+ if (placedThreadRoots.has(rootId))
3235
+ continue;
3236
+ placedThreadRoots.add(rootId);
3237
+ const clone = source.cloneNode(true);
3238
+ this.makeClonedMarginCommentInert(clone);
3239
+ marginColumn.appendChild(clone);
3240
+ }
3241
+ }
3242
+ pageBox.appendChild(marginColumn);
3243
+ }
1630
3244
  // Add footer if available for this section/page
1631
- const footerSource = this.selectFooter(sectionIndex, pageInSection, pageNumber);
3245
+ const footerSource = isSectionFiller
3246
+ ? undefined
3247
+ : this.selectFooter(sectionIndex, pageInSection, displayedPageNumber);
1632
3248
  if (footerSource) {
1633
- const footerDiv = document.createElement("div");
3249
+ const footerDiv = this.document.createElement("div");
1634
3250
  footerDiv.className = `${this.cssPrefix}footer`;
1635
3251
  footerDiv.style.position = "absolute";
1636
3252
  // `w:footer` is the distance to the BOTTOM of the story, and the story grows upward from
@@ -1655,8 +3271,8 @@ export class PaginationEngine {
1655
3271
  pageBox.appendChild(footerDiv);
1656
3272
  }
1657
3273
  // Add page number (will be hidden by CSS if document has footer)
1658
- if (this.showPageNumbers) {
1659
- const pageNum = document.createElement("div");
3274
+ if (this.showPageNumbers && !isSectionFiller) {
3275
+ const pageNum = this.document.createElement("div");
1660
3276
  pageNum.className = `${this.cssPrefix}number`;
1661
3277
  pageNum.textContent = String(pageNumber);
1662
3278
  pageBox.appendChild(pageNum);
@@ -1719,8 +3335,9 @@ export class PaginationEngine {
1719
3335
  * ```
1720
3336
  */
1721
3337
  export function paginateHtml(html, container, options = {}) {
3338
+ const ownerDocument = typeof container === "string" ? globalThis.document : container.ownerDocument;
1722
3339
  const containerEl = typeof container === "string"
1723
- ? document.getElementById(container)
3340
+ ? ownerDocument.getElementById(container)
1724
3341
  : container;
1725
3342
  if (!containerEl) {
1726
3343
  throw new Error("Container element not found");