docxodus 9.9.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 (111) 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 +1914 -419
  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 +2803 -469
  16. package/dist/embed.iife.js +2801 -467
  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 +1827 -410
  41. package/dist/pagination.d.ts +249 -15
  42. package/dist/pagination.d.ts.map +1 -1
  43. package/dist/pagination.js +1942 -453
  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 +45 -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.wasm +0 -0
  77. package/dist/wasm/_framework/System.Collections.wasm.br +0 -0
  78. package/dist/wasm/_framework/System.IO.Compression.wasm +0 -0
  79. package/dist/wasm/_framework/System.IO.Compression.wasm.br +0 -0
  80. package/dist/wasm/_framework/System.IO.Packaging.wasm +0 -0
  81. package/dist/wasm/_framework/System.IO.Packaging.wasm.br +0 -0
  82. package/dist/wasm/_framework/System.Linq.wasm +0 -0
  83. package/dist/wasm/_framework/System.Linq.wasm.br +0 -0
  84. package/dist/wasm/_framework/System.Private.CoreLib.wasm +0 -0
  85. package/dist/wasm/_framework/System.Private.CoreLib.wasm.br +0 -0
  86. package/dist/wasm/_framework/System.Private.Uri.wasm +0 -0
  87. package/dist/wasm/_framework/System.Private.Uri.wasm.br +0 -0
  88. package/dist/wasm/_framework/System.Private.Xml.Linq.wasm +0 -0
  89. package/dist/wasm/_framework/System.Private.Xml.Linq.wasm.br +0 -0
  90. package/dist/wasm/_framework/System.Private.Xml.wasm +0 -0
  91. package/dist/wasm/_framework/System.Private.Xml.wasm.br +0 -0
  92. package/dist/wasm/_framework/System.Runtime.InteropServices.JavaScript.wasm +0 -0
  93. package/dist/wasm/_framework/System.Runtime.InteropServices.JavaScript.wasm.br +0 -0
  94. package/dist/wasm/_framework/System.Runtime.wasm +0 -0
  95. package/dist/wasm/_framework/System.Runtime.wasm.br +0 -0
  96. package/dist/wasm/_framework/System.Security.Cryptography.wasm +0 -0
  97. package/dist/wasm/_framework/System.Security.Cryptography.wasm.br +0 -0
  98. package/dist/wasm/_framework/System.Text.Json.wasm +0 -0
  99. package/dist/wasm/_framework/System.Text.Json.wasm.br +0 -0
  100. package/dist/wasm/_framework/System.Text.RegularExpressions.wasm +0 -0
  101. package/dist/wasm/_framework/System.Text.RegularExpressions.wasm.br +0 -0
  102. package/dist/wasm/_framework/dotnet.boot.js +21 -21
  103. package/dist/wasm/_framework/dotnet.boot.js.br +0 -0
  104. package/dist/wasm/_framework/dotnet.native.wasm +0 -0
  105. package/dist/wasm/_framework/dotnet.native.wasm.br +0 -0
  106. package/dist/worker-proxy.bundle.js +196 -29
  107. package/dist/worker-proxy.d.ts +36 -3
  108. package/dist/worker-proxy.d.ts.map +1 -1
  109. package/dist/worker-proxy.js +186 -33
  110. package/dist/worker-proxy.js.map +1 -1
  111. package/package.json +22 -6
@@ -172,17 +172,41 @@ var DocxodusEditor = (() => {
172
172
  function ptToPx(pt) {
173
173
  return pt / 0.75;
174
174
  }
175
+ function finiteDataNumber(value, fallback) {
176
+ if (value === void 0 || value.trim() === "") return fallback;
177
+ const parsed = Number(value);
178
+ return Number.isFinite(parsed) ? parsed : fallback;
179
+ }
180
+ function positiveDataNumber(value, fallback) {
181
+ const parsed = finiteDataNumber(value, fallback);
182
+ return parsed > 0 ? parsed : fallback;
183
+ }
184
+ function nonNegativeDataNumber(value, fallback) {
185
+ const parsed = finiteDataNumber(value, fallback);
186
+ return parsed >= 0 ? parsed : fallback;
187
+ }
188
+ function positiveFinite(value, fallback) {
189
+ return Number.isFinite(value) && value > 0 ? value : fallback;
190
+ }
175
191
  function parseSectionDimensions(section) {
176
- const pageWidth = parseFloat(section.dataset.pageWidth || "") || DEFAULT_PAGE_WIDTH;
177
- const pageHeight = parseFloat(section.dataset.pageHeight || "") || DEFAULT_PAGE_HEIGHT;
178
- const contentWidth = parseFloat(section.dataset.contentWidth || "") || pageWidth - 2 * DEFAULT_MARGIN;
179
- const contentHeight = parseFloat(section.dataset.contentHeight || "") || pageHeight - 2 * DEFAULT_MARGIN;
180
- const marginTop = parseFloat(section.dataset.marginTop || "") || DEFAULT_MARGIN;
181
- const marginRight = parseFloat(section.dataset.marginRight || "") || DEFAULT_MARGIN;
182
- const marginBottom = parseFloat(section.dataset.marginBottom || "") || DEFAULT_MARGIN;
183
- const marginLeft = parseFloat(section.dataset.marginLeft || "") || DEFAULT_MARGIN;
184
- const headerDistance = parseFloat(section.dataset.headerHeight || "") || DEFAULT_HEADER_FOOTER_DISTANCE;
185
- const footerDistance = parseFloat(section.dataset.footerHeight || "") || DEFAULT_HEADER_FOOTER_DISTANCE;
192
+ const pageWidth = positiveDataNumber(section.dataset.pageWidth, DEFAULT_PAGE_WIDTH);
193
+ const pageHeight = positiveDataNumber(section.dataset.pageHeight, DEFAULT_PAGE_HEIGHT);
194
+ const marginTop = finiteDataNumber(section.dataset.marginTop, DEFAULT_MARGIN);
195
+ const marginRight = finiteDataNumber(section.dataset.marginRight, DEFAULT_MARGIN);
196
+ const marginBottom = finiteDataNumber(section.dataset.marginBottom, DEFAULT_MARGIN);
197
+ const marginLeft = finiteDataNumber(section.dataset.marginLeft, DEFAULT_MARGIN);
198
+ const derivedContentWidth = positiveFinite(pageWidth - marginLeft - marginRight, pageWidth);
199
+ const derivedContentHeight = positiveFinite(pageHeight - marginTop - marginBottom, pageHeight);
200
+ const contentWidth = positiveDataNumber(section.dataset.contentWidth, derivedContentWidth);
201
+ const contentHeight = positiveDataNumber(section.dataset.contentHeight, derivedContentHeight);
202
+ const headerDistance = nonNegativeDataNumber(
203
+ section.dataset.headerHeight,
204
+ DEFAULT_HEADER_FOOTER_DISTANCE
205
+ );
206
+ const footerDistance = nonNegativeDataNumber(
207
+ section.dataset.footerHeight,
208
+ DEFAULT_HEADER_FOOTER_DISTANCE
209
+ );
186
210
  return {
187
211
  pageWidth,
188
212
  pageHeight,
@@ -263,7 +287,8 @@ var DocxodusEditor = (() => {
263
287
  return a.pageWidth === b.pageWidth && a.pageHeight === b.pageHeight && a.marginTop === b.marginTop && a.marginRight === b.marginRight && a.marginBottom === b.marginBottom && a.marginLeft === b.marginLeft;
264
288
  }
265
289
  var MAX_FOOTNOTE_AREA_RATIO = 0.6;
266
- var MIN_BODY_CONTENT_HEIGHT = 72;
290
+ var FOOTNOTE_MEASUREMENT_GUARD_PT = 2;
291
+ var FOOTNOTE_SOURCE_POSITION_ATTR = "data-pagination-footnote-source-position";
267
292
  var PaginationEngine = class {
268
293
  /**
269
294
  * Creates a new pagination engine.
@@ -273,24 +298,46 @@ var DocxodusEditor = (() => {
273
298
  * @param options - Pagination options
274
299
  */
275
300
  constructor(staging, container, options = {}) {
301
+ this.createdPageCount = 0;
302
+ this.footnoteSeparator = null;
303
+ this.footnoteContinuationSeparator = null;
304
+ this.footnoteLayoutLikeWord8 = false;
276
305
  this.pendingFootnoteContinuation = null;
277
306
  /** Per-section `w:pgNumType` (start / format), read off the section wrappers. */
278
307
  this.pageNumbering = /* @__PURE__ */ new Map();
279
- this.stagingElement = typeof staging === "string" ? document.getElementById(staging) : staging;
280
- this.containerElement = typeof container === "string" ? document.getElementById(container) : container;
308
+ this.lastPages = [];
309
+ this.expectedPageMapAnchorIds = /* @__PURE__ */ new Set();
310
+ this.state = "ready";
311
+ const ownerDocument = typeof staging !== "string" ? staging.ownerDocument : typeof container !== "string" ? container.ownerDocument : globalThis.document;
312
+ this.stagingElement = typeof staging === "string" ? ownerDocument.getElementById(staging) : staging;
313
+ this.containerElement = typeof container === "string" ? ownerDocument.getElementById(container) : container;
281
314
  if (!this.stagingElement) {
282
315
  throw new Error("Staging element not found");
283
316
  }
284
317
  if (!this.containerElement) {
285
318
  throw new Error("Container element not found");
286
319
  }
320
+ if (this.stagingElement.ownerDocument !== this.containerElement.ownerDocument) {
321
+ throw new Error("Staging and container elements must belong to the same document");
322
+ }
323
+ const view = ownerDocument.defaultView;
324
+ if (!view) {
325
+ throw new Error("Pagination requires an attached document with a defaultView");
326
+ }
327
+ this.document = ownerDocument;
328
+ this.view = view;
287
329
  this.scale = options.scale ?? 1;
288
330
  this.cssPrefix = options.cssPrefix ?? "page-";
289
331
  this.showPageNumbers = options.showPageNumbers ?? true;
290
332
  this.pageGap = options.pageGap ?? 20;
291
333
  this.fragmentParagraphs = options.fragmentParagraphs ?? false;
334
+ this.deferFragmentIdentities = options.deferFragmentIdentities ?? false;
335
+ this.cancellationCheckpoint = options.checkCancellation;
336
+ this.pageCountCheckpoint = options.checkPageCount;
337
+ this.layoutToken = options.layoutToken;
292
338
  this.hfRegistry = /* @__PURE__ */ new Map();
293
339
  this.footnoteRegistry = /* @__PURE__ */ new Map();
340
+ this.commentMarginRegistry = /* @__PURE__ */ new Map();
294
341
  }
295
342
  /**
296
343
  * Runs the pagination process.
@@ -298,58 +345,469 @@ var DocxodusEditor = (() => {
298
345
  * @returns PaginationResult with page information
299
346
  */
300
347
  paginate() {
301
- const pages = [];
302
- let pageNumber = 1;
303
- this.hfRegistry = this.parseHeaderFooterRegistry();
304
- this.footnoteRegistry = this.parseFootnoteRegistry();
305
- const sections = this.stagingElement.querySelectorAll(
306
- "[data-section-index]"
307
- );
308
- this.pageNumbering = this.parsePageNumbering(sections);
309
- const sectionsToProcess = sections.length > 0 ? Array.from(sections) : [this.stagingElement];
310
- const runs = [];
311
- for (const section of sectionsToProcess) {
312
- const dims = parseSectionDimensions(section);
313
- const previous = runs[runs.length - 1];
314
- if (previous && section.dataset.sectionType === "continuous" && samePageBox(previous.dims, dims)) {
315
- previous.sections.push(section);
316
- } else {
317
- runs.push({
318
- sections: [section],
319
- sectionIndex: parseInt(section.dataset.sectionIndex || "0", 10),
320
- dims
348
+ this.checkpoint();
349
+ if (this.state !== "ready") {
350
+ throw new Error(`PaginationEngine is one-shot and is already ${this.state}`);
351
+ }
352
+ if (!this.stagingElement.isConnected || !this.containerElement.isConnected) {
353
+ throw new Error("Pagination requires staging and container elements attached to their document");
354
+ }
355
+ if (this.document.documentElement.getBoundingClientRect().width <= 0) {
356
+ throw new Error("Pagination requires a browsing context with non-zero layout");
357
+ }
358
+ this.state = "running";
359
+ try {
360
+ const pages = [];
361
+ let pageNumber = 1;
362
+ this.hfRegistry = this.parseHeaderFooterRegistry();
363
+ this.footnoteRegistry = this.parseFootnoteRegistry();
364
+ ({
365
+ normal: this.footnoteSeparator,
366
+ continuation: this.footnoteContinuationSeparator
367
+ } = this.parseFootnoteSeparators());
368
+ this.commentMarginRegistry = this.parseCommentMarginRegistry();
369
+ this.footnoteLayoutLikeWord8 = this.stagingElement.dataset.footnoteLayoutLikeWord8 === "true";
370
+ const sections = this.stagingElement.querySelectorAll(
371
+ "[data-section-index]"
372
+ );
373
+ this.pageNumbering = this.parsePageNumbering(sections);
374
+ const sectionsToProcess = sections.length > 0 ? Array.from(sections) : [this.stagingElement];
375
+ this.expectedPageMapAnchorIds = /* @__PURE__ */ new Set();
376
+ const referencedFootnoteIds = /* @__PURE__ */ new Set();
377
+ const referencedCommentIds = /* @__PURE__ */ new Set();
378
+ for (const section of sectionsToProcess) {
379
+ this.checkpoint();
380
+ this.collectExpectedSourceAnchors(section, this.expectedPageMapAnchorIds, true);
381
+ for (const reference of Array.from(section.querySelectorAll("[data-footnote-id]"))) {
382
+ this.checkpoint();
383
+ if (reference.closest("#pagination-footnote-registry, #pagination-hf-registry")) continue;
384
+ const id = reference.dataset.footnoteId;
385
+ if (id) referencedFootnoteIds.add(id);
386
+ }
387
+ for (const reference of Array.from(section.querySelectorAll("[data-comment-id]"))) {
388
+ this.checkpoint();
389
+ if (reference.closest(
390
+ "#pagination-comment-margin-registry, #pagination-footnote-registry, #pagination-hf-registry"
391
+ )) continue;
392
+ const id = reference.dataset.commentId;
393
+ if (id) referencedCommentIds.add(id);
394
+ }
395
+ }
396
+ for (const id of referencedFootnoteIds) {
397
+ this.checkpoint();
398
+ const source = this.footnoteRegistry.get(id);
399
+ if (!source) continue;
400
+ this.collectExpectedSourceAnchors(source, this.expectedPageMapAnchorIds);
401
+ for (const reference of Array.from(
402
+ source.querySelectorAll("[data-comment-id]")
403
+ )) {
404
+ this.checkpoint();
405
+ const commentId = reference.dataset.commentId;
406
+ if (commentId) referencedCommentIds.add(commentId);
407
+ }
408
+ }
409
+ for (const id of referencedCommentIds) {
410
+ this.checkpoint();
411
+ const source = this.commentMarginRegistry.get(id);
412
+ if (source) this.collectExpectedSourceAnchors(source, this.expectedPageMapAnchorIds);
413
+ }
414
+ const runs = [];
415
+ for (const section of sectionsToProcess) {
416
+ this.checkpoint();
417
+ const dims = parseSectionDimensions(section);
418
+ const previous = runs[runs.length - 1];
419
+ if (previous && section.dataset.sectionType === "continuous" && samePageBox(previous.dims, dims)) {
420
+ previous.sections.push(section);
421
+ previous.sectionDimensions.set(
422
+ parseInt(section.dataset.sectionIndex || "0", 10),
423
+ dims
424
+ );
425
+ } else {
426
+ const sectionIndex = parseInt(section.dataset.sectionIndex || "0", 10);
427
+ runs.push({
428
+ sections: [section],
429
+ sectionIndex,
430
+ sectionType: section.dataset.sectionType ?? "nextPage",
431
+ dims,
432
+ sectionDimensions: /* @__PURE__ */ new Map([[sectionIndex, dims]])
433
+ });
434
+ }
435
+ }
436
+ for (const run of runs) {
437
+ this.checkpoint();
438
+ const needsParityFiller = pages.length > 0 && (run.sectionType === "oddPage" && pageNumber % 2 === 0 || run.sectionType === "evenPage" && pageNumber % 2 === 1);
439
+ if (needsParityFiller) {
440
+ const precedingPage = pages[pages.length - 1];
441
+ const precedingPageInSection = parseInt(
442
+ precedingPage.element.dataset.pageInSection ?? "1",
443
+ 10
444
+ );
445
+ const precedingDisplayedPageNumber = parseInt(
446
+ precedingPage.element.dataset.displayedPageNumber ?? String(precedingPage.pageNumber),
447
+ 10
448
+ );
449
+ const filler = this.createPage(
450
+ precedingPage.dimensions,
451
+ pageNumber,
452
+ precedingPage.sectionIndex,
453
+ precedingDisplayedPageNumber + 1,
454
+ [],
455
+ precedingPageInSection + 1,
456
+ [],
457
+ 0,
458
+ null,
459
+ void 0,
460
+ true
461
+ );
462
+ pages.push(filler);
463
+ pageNumber++;
464
+ }
465
+ this.stagingElement.style.visibility = "hidden";
466
+ this.stagingElement.style.position = "absolute";
467
+ this.stagingElement.style.left = "-9999px";
468
+ this.stagingElement.style.display = "block";
469
+ const blocks = [];
470
+ for (const section of run.sections) {
471
+ this.checkpoint();
472
+ const sectionIndex = parseInt(section.dataset.sectionIndex || "0", 10);
473
+ const sectionDims = run.sectionDimensions.get(sectionIndex) ?? run.dims;
474
+ section.style.width = `${sectionDims.contentWidth}pt`;
475
+ const columnCount = parseInt(section.dataset.cols || "1", 10);
476
+ if (columnCount > 1) {
477
+ const gap = parseFloat(section.dataset.colGap || "");
478
+ blocks.push(...this.buildColumnBlocks(
479
+ section,
480
+ sectionDims,
481
+ sectionIndex,
482
+ columnCount,
483
+ Number.isFinite(gap) ? gap : 36
484
+ ));
485
+ } else {
486
+ blocks.push(...this.measureBlocks(section, sectionDims, sectionIndex));
487
+ }
488
+ }
489
+ const sectionPages = this.flowToPages(
490
+ blocks,
491
+ run.dims,
492
+ pageNumber,
493
+ run.sectionIndex,
494
+ run.sectionDimensions
495
+ );
496
+ this.checkpoint();
497
+ pages.push(...sectionPages);
498
+ pageNumber += sectionPages.length;
499
+ }
500
+ this.stagingElement.style.display = "none";
501
+ this.substitutePageNumberFields(pages.length);
502
+ for (const page of pages) {
503
+ this.checkpoint();
504
+ if (page.element.dataset.sectionFiller === "true") continue;
505
+ const pageInSection = parseInt(page.element.dataset.pageInSection || "1", 10);
506
+ const displayedPageNumber = parseInt(
507
+ page.element.dataset.displayedPageNumber || String(page.pageNumber),
508
+ 10
509
+ );
510
+ const header = this.selectHeader(page.sectionIndex, pageInSection, displayedPageNumber);
511
+ const footer = this.selectFooter(page.sectionIndex, pageInSection, displayedPageNumber);
512
+ if (header) this.collectExpectedSourceAnchors(header, this.expectedPageMapAnchorIds);
513
+ if (footer) this.collectExpectedSourceAnchors(footer, this.expectedPageMapAnchorIds);
514
+ }
515
+ this.qualifyPageFragments(pages);
516
+ this.transferVisibleFragmentTargets();
517
+ if (!this.deferFragmentIdentities) this.normalizeVisiblePageFragments(pages);
518
+ this.lastPages = pages;
519
+ const result = {
520
+ totalPages: pages.length,
521
+ pages,
522
+ pageMap: this.layoutToken ? this.materializePageMap(
523
+ this.layoutToken.documentVersion,
524
+ this.layoutToken.rendererFingerprint
525
+ ) : void 0
526
+ };
527
+ this.state = "complete";
528
+ return result;
529
+ } catch (error) {
530
+ this.state = "failed";
531
+ throw error;
532
+ }
533
+ }
534
+ checkpoint() {
535
+ this.cancellationCheckpoint?.();
536
+ }
537
+ /**
538
+ * Normalize visible fragment identities after a caller applies final standalone styles. This
539
+ * deliberately runs before the stability barrier; materializePageMap is read-only so PageMap
540
+ * measurement cannot mutate a tree after it was declared stable.
541
+ */
542
+ normalizePageMapFragmentIdentities() {
543
+ if (this.lastPages.length === 0) {
544
+ throw new Error("paginate() must complete before fragment identities can be normalized");
545
+ }
546
+ this.normalizeVisiblePageFragments(this.lastPages);
547
+ }
548
+ /**
549
+ * Materialize the last completed browser layout as portable page-relative point geometry.
550
+ * The caller supplies both invalidation tokens; this engine never guesses a document version
551
+ * or renderer fingerprint.
552
+ */
553
+ materializePageMap(documentVersion, rendererFingerprint) {
554
+ if (!Number.isSafeInteger(documentVersion) || documentVersion < 0) {
555
+ throw new Error("documentVersion must be a non-negative safe integer");
556
+ }
557
+ if (!rendererFingerprint) throw new Error("rendererFingerprint must be non-empty");
558
+ if (this.lastPages.length === 0) throw new Error("paginate() must complete before materializePageMap()");
559
+ const pages = this.lastPages.map((page) => ({
560
+ pageNumber: page.pageNumber,
561
+ pageInSection: parseInt(page.element.dataset.pageInSection || "1", 10),
562
+ width: page.dimensions.pageWidth,
563
+ height: page.dimensions.pageHeight,
564
+ sectionIndex: page.sectionIndex,
565
+ pageName: `docxodus-section-${page.sectionIndex}`
566
+ }));
567
+ const fragments = [];
568
+ const requiredAnchorIds = new Set(this.expectedPageMapAnchorIds);
569
+ if (requiredAnchorIds.size === 0) {
570
+ throw new Error("cannot publish an available PageMap without canonical source inventory");
571
+ }
572
+ const measuredAnchorIds = /* @__PURE__ */ new Set();
573
+ const emittedFragmentCounts = /* @__PURE__ */ new Map();
574
+ for (const page of this.lastPages) {
575
+ this.checkpoint();
576
+ const pageRect = page.element.getBoundingClientRect();
577
+ if (pageRect.width <= 0 || pageRect.height <= 0) {
578
+ throw new Error(`page ${page.pageNumber} has no measurable geometry`);
579
+ }
580
+ const pointPerRenderedX = page.dimensions.pageWidth / pageRect.width;
581
+ const pointPerRenderedY = page.dimensions.pageHeight / pageRect.height;
582
+ const nodes = page.element.querySelectorAll("[data-source-anchor-id]");
583
+ for (const element of Array.from(nodes)) {
584
+ this.checkpoint();
585
+ if (this.isDeliberatelyUnrenderedSource(element, page.element)) continue;
586
+ const anchorId = element.dataset.sourceAnchorId;
587
+ if (!anchorId || !element.dataset.pageFragmentId || !Number.isInteger(parseInt(element.dataset.fragmentIndex || "", 10))) {
588
+ throw new Error(`page ${page.pageNumber} contains an unqualified source anchor`);
589
+ }
590
+ const rect = element.getBoundingClientRect();
591
+ const style = this.view.getComputedStyle(element);
592
+ const deliberatelyHidden = style.display === "none" || style.visibility === "hidden";
593
+ if (deliberatelyHidden) continue;
594
+ requiredAnchorIds.add(anchorId);
595
+ const visibleRect = this.intersectWithClippingAncestors(element, page.element, pageRect, rect);
596
+ const left2 = visibleRect.left;
597
+ const top2 = visibleRect.top;
598
+ const right2 = visibleRect.right;
599
+ const bottom2 = visibleRect.bottom;
600
+ if (rect.width <= 0 || rect.height <= 0 || right2 <= left2 || bottom2 <= top2) {
601
+ continue;
602
+ }
603
+ measuredAnchorIds.add(anchorId);
604
+ const fragmentIndex = emittedFragmentCounts.get(anchorId) ?? 0;
605
+ emittedFragmentCounts.set(anchorId, fragmentIndex + 1);
606
+ const fragmentId = `p${page.pageNumber}-f${fragmentIndex}-${anchorId}`;
607
+ if (element.dataset.fragmentIndex !== String(fragmentIndex) || element.dataset.pageFragmentId !== fragmentId || element.dataset.pageNumber !== String(page.pageNumber)) {
608
+ throw new Error(
609
+ `page ${page.pageNumber} fragment identity changed after final-tree normalization`
610
+ );
611
+ }
612
+ fragments.push({
613
+ fragmentId,
614
+ anchorId,
615
+ fragmentIndex,
616
+ pageNumber: page.pageNumber,
617
+ geometry: {
618
+ x: (left2 - pageRect.left) * pointPerRenderedX,
619
+ y: (top2 - pageRect.top) * pointPerRenderedY,
620
+ width: (right2 - left2) * pointPerRenderedX,
621
+ height: (bottom2 - top2) * pointPerRenderedY
622
+ },
623
+ story: this.storyForCanonicalAnchor(anchorId),
624
+ inTableCell: element.matches("td,th") || element.closest("td,th") !== null
321
625
  });
322
626
  }
323
627
  }
324
- for (const run of runs) {
325
- this.stagingElement.style.visibility = "hidden";
326
- this.stagingElement.style.position = "absolute";
327
- this.stagingElement.style.left = "-9999px";
328
- this.stagingElement.style.display = "block";
329
- const blocks = [];
330
- for (const section of run.sections) {
331
- section.style.width = `${run.dims.contentWidth}pt`;
332
- const columnCount = parseInt(section.dataset.cols || "1", 10);
333
- if (columnCount > 1) {
334
- const gap = parseFloat(section.dataset.colGap || "");
335
- blocks.push(...this.buildColumnBlocks(
336
- section,
337
- run.dims,
338
- run.sectionIndex,
339
- columnCount,
340
- Number.isFinite(gap) ? gap : 36
341
- ));
342
- } else {
343
- blocks.push(...this.measureBlocks(section, run.dims));
628
+ const missingAnchor = Array.from(requiredAnchorIds).find((id) => !measuredAnchorIds.has(id));
629
+ if (missingAnchor) {
630
+ throw new Error(`source anchor ${missingAnchor} has no measurable fragment in the paginated layout`);
631
+ }
632
+ return {
633
+ schemaVersion: 1,
634
+ mode: "paginated",
635
+ availability: "available",
636
+ documentVersion,
637
+ rendererFingerprint,
638
+ pages,
639
+ fragments
640
+ };
641
+ }
642
+ normalizeVisiblePageFragments(pages) {
643
+ const emittedFragmentCounts = /* @__PURE__ */ new Map();
644
+ for (const page of pages) {
645
+ this.checkpoint();
646
+ const pageRect = page.element.getBoundingClientRect();
647
+ for (const element of Array.from(
648
+ page.element.querySelectorAll("[data-source-anchor-id]")
649
+ )) {
650
+ this.checkpoint();
651
+ if (this.isDeliberatelyUnrenderedSource(element, page.element)) continue;
652
+ const anchorId = element.dataset.sourceAnchorId;
653
+ if (!anchorId) continue;
654
+ const style = this.view.getComputedStyle(element);
655
+ if (style.display === "none" || style.visibility === "hidden") continue;
656
+ const rect = element.getBoundingClientRect();
657
+ const visible = this.intersectWithClippingAncestors(element, page.element, pageRect, rect);
658
+ if (rect.width <= 0 || rect.height <= 0 || visible.right <= visible.left || visible.bottom <= visible.top) continue;
659
+ const fragmentIndex = emittedFragmentCounts.get(anchorId) ?? 0;
660
+ emittedFragmentCounts.set(anchorId, fragmentIndex + 1);
661
+ element.dataset.pageNumber = String(page.pageNumber);
662
+ element.dataset.fragmentIndex = String(fragmentIndex);
663
+ element.dataset.pageFragmentId = `p${page.pageNumber}-f${fragmentIndex}-${anchorId}`;
664
+ }
665
+ }
666
+ }
667
+ /**
668
+ * Intersect an element with every ancestor that establishes an overflow clip before the page
669
+ * root. getBoundingClientRect() reports layout outside those clips, which is not rendered and
670
+ * therefore must not satisfy PageMap completeness or inflate portable geometry.
671
+ */
672
+ intersectWithClippingAncestors(element, page, pageRect, rect) {
673
+ let left2 = Math.max(rect.left, pageRect.left);
674
+ let top2 = Math.max(rect.top, pageRect.top);
675
+ let right2 = Math.min(rect.right, pageRect.right);
676
+ let bottom2 = Math.min(rect.bottom, pageRect.bottom);
677
+ const clips = (value) => value === "hidden" || value === "clip" || value === "scroll" || value === "auto";
678
+ for (let ancestor = element.parentElement; ancestor && ancestor !== page; ancestor = ancestor.parentElement) {
679
+ const style = this.view.getComputedStyle(ancestor);
680
+ const clipsX = clips(style.overflowX);
681
+ const clipsY = clips(style.overflowY);
682
+ if (!clipsX && !clipsY) continue;
683
+ const ancestorRect = ancestor.getBoundingClientRect();
684
+ if (clipsX) {
685
+ left2 = Math.max(left2, ancestorRect.left);
686
+ right2 = Math.min(right2, ancestorRect.right);
687
+ }
688
+ if (clipsY) {
689
+ top2 = Math.max(top2, ancestorRect.top);
690
+ bottom2 = Math.min(bottom2, ancestorRect.bottom);
691
+ }
692
+ }
693
+ return { left: left2, top: top2, right: right2, bottom: bottom2 };
694
+ }
695
+ storyForCanonicalAnchor(anchorId) {
696
+ const first = anchorId.indexOf(":");
697
+ const second = first < 0 ? -1 : anchorId.indexOf(":", first + 1);
698
+ const scope = first >= 0 && second > first ? anchorId.slice(first + 1, second) : "body";
699
+ if (scope.startsWith("hdr")) return "header";
700
+ if (scope.startsWith("ftr")) return "footer";
701
+ if (scope === "fn") return "footnote";
702
+ if (scope === "en") return "endnote";
703
+ if (scope === "cmt") return "comment";
704
+ return "body";
705
+ }
706
+ /**
707
+ * Add canonical IDs from an addressable source subtree to the pre-pagination inventory.
708
+ * Registry wrappers are excluded when scanning staging because selectable registry contents are
709
+ * inventoried separately. Producers may explicitly mark content that has no visual substrate
710
+ * with `data-page-map-exclude="true"`; native hidden semantics carry the same signal.
711
+ */
712
+ collectExpectedSourceAnchors(source, destination, excludeRegistries = false) {
713
+ const candidates = source.matches("[data-source-anchor-id]") ? [source] : [];
714
+ candidates.push(...Array.from(source.querySelectorAll("[data-source-anchor-id]")));
715
+ for (const element of candidates) {
716
+ if (excludeRegistries && element.closest(
717
+ "#pagination-hf-registry, #pagination-footnote-registry, #pagination-comment-margin-registry"
718
+ )) {
719
+ continue;
720
+ }
721
+ if (this.isZeroHeightExplicitBreakCarrier(element)) {
722
+ element.dataset.pageMapExclude = "true";
723
+ }
724
+ if (this.isDeliberatelyUnrenderedSource(element, source)) continue;
725
+ const anchorId = element.dataset.sourceAnchorId;
726
+ if (anchorId) destination.add(anchorId);
727
+ }
728
+ }
729
+ isZeroHeightExplicitBreakCarrier(element) {
730
+ const next = element.nextElementSibling;
731
+ const bounds = element.getBoundingClientRect();
732
+ return element.hasAttribute("data-source-anchor-id") && (element.textContent ?? "").replace(/\u00a0/g, "").trim() === "" && bounds.height <= 0.01 && !element.querySelector("[data-source-anchor-id]") && !element.querySelector("img,svg,canvas,table,hr,input,textarea,select") && (next?.dataset.pageBreak === "true" || next?.classList.contains(`${this.cssPrefix}break`) === true);
733
+ }
734
+ isDeliberatelyUnrenderedSource(element, sourceRoot) {
735
+ for (let current = element; current; current = current.parentElement) {
736
+ const zeroHeightExplicitBreakCarrier = current === element && this.isZeroHeightExplicitBreakCarrier(current);
737
+ if (current.dataset.pageMapExclude === "true" || current.dataset.pageBreak === "true" || current.classList.contains(`${this.cssPrefix}break`) || zeroHeightExplicitBreakCarrier || current.hidden || current.getAttribute("aria-hidden") === "true" || current.style.display === "none" || current.style.visibility === "hidden") {
738
+ return true;
739
+ }
740
+ if (current === sourceRoot) break;
741
+ }
742
+ return false;
743
+ }
744
+ /**
745
+ * Keep exactly one active bare-Unid editor anchor per source block. Presentation clones use
746
+ * canonical source identity plus page/fragment qualification instead.
747
+ */
748
+ qualifyPageFragments(pages) {
749
+ const fragmentCounts = /* @__PURE__ */ new Map();
750
+ const activeCanonicalIds = /* @__PURE__ */ new Set();
751
+ const activeBareAnchorIds = /* @__PURE__ */ new Set();
752
+ const makeInactive = (element) => {
753
+ element.removeAttribute("data-anchor");
754
+ element.removeAttribute("data-committed-text");
755
+ if (element.hasAttribute("contenteditable")) element.setAttribute("contenteditable", "false");
756
+ };
757
+ for (const page of pages) {
758
+ const nodes = page.element.querySelectorAll("[data-source-anchor-id]");
759
+ for (const element of Array.from(nodes)) {
760
+ const anchorId = element.dataset.sourceAnchorId;
761
+ if (!anchorId) continue;
762
+ const fragmentIndex = fragmentCounts.get(anchorId) ?? 0;
763
+ fragmentCounts.set(anchorId, fragmentIndex + 1);
764
+ element.dataset.pageNumber = String(page.pageNumber);
765
+ element.dataset.fragmentIndex = String(fragmentIndex);
766
+ element.dataset.pageFragmentId = `p${page.pageNumber}-f${fragmentIndex}-${anchorId}`;
767
+ const story = this.storyForCanonicalAnchor(anchorId);
768
+ const mayOwnActiveEditorAnchor = story === "body" || story === "comment" || story === "footnote" || story === "endnote";
769
+ if (element.hasAttribute("data-anchor")) {
770
+ const bareAnchorId = element.dataset.anchor;
771
+ if (mayOwnActiveEditorAnchor && !activeCanonicalIds.has(anchorId) && !activeBareAnchorIds.has(bareAnchorId)) {
772
+ activeCanonicalIds.add(anchorId);
773
+ activeBareAnchorIds.add(bareAnchorId);
774
+ } else {
775
+ makeInactive(element);
776
+ }
344
777
  }
345
778
  }
346
- const sectionPages = this.flowToPages(blocks, run.dims, pageNumber, run.sectionIndex);
347
- pages.push(...sectionPages);
348
- pageNumber += sectionPages.length;
349
779
  }
350
- this.stagingElement.style.display = "none";
351
- this.substitutePageNumberFields(pages.length);
352
- return { totalPages: pages.length, pages };
780
+ const activeStagingCanonicalIds = /* @__PURE__ */ new Set();
781
+ for (const element of Array.from(
782
+ this.stagingElement.querySelectorAll("[data-source-anchor-id][data-anchor]")
783
+ )) {
784
+ const anchorId = element.dataset.sourceAnchorId;
785
+ if (!anchorId) continue;
786
+ const bareAnchorId = element.dataset.anchor;
787
+ if (activeCanonicalIds.has(anchorId) || activeStagingCanonicalIds.has(anchorId) || activeBareAnchorIds.has(bareAnchorId)) {
788
+ makeInactive(element);
789
+ } else {
790
+ activeStagingCanonicalIds.add(anchorId);
791
+ activeBareAnchorIds.add(bareAnchorId);
792
+ }
793
+ }
794
+ }
795
+ /**
796
+ * Page flow clones source blocks while the hidden staging tree stays in the document. Any HTML
797
+ * fragment target copied into a visible page would therefore resolve to its earlier hidden
798
+ * source. Transfer target ownership to the page presentation after flow is complete; registry
799
+ * and wrapper IDs that have no visible counterpart remain available to pagination internals.
800
+ */
801
+ transferVisibleFragmentTargets() {
802
+ const visibleIds = new Set(Array.from(
803
+ this.containerElement.querySelectorAll("[id]")
804
+ ).map((element) => element.id).filter(Boolean));
805
+ if (visibleIds.size === 0) return;
806
+ for (const source of Array.from(
807
+ this.stagingElement.querySelectorAll("[id]")
808
+ )) {
809
+ if (visibleIds.has(source.id)) source.removeAttribute("id");
810
+ }
353
811
  }
354
812
  /** Read each section's `w:pgNumType` off its wrapper (see {@link SectionPageNumbering}). */
355
813
  parsePageNumbering(sections) {
@@ -393,9 +851,8 @@ var DocxodusEditor = (() => {
393
851
  if (markers.length === 0) continue;
394
852
  const sectionIndex = parseInt(box.dataset.sectionIndex || "0", 10);
395
853
  const pageNumber = parseInt(box.dataset.pageNumber || "1", 10);
396
- const pageInSection = parseInt(box.dataset.pageInSection || "1", 10);
397
854
  const numbering = this.pageNumbering.get(sectionIndex) ?? {};
398
- const displayed = numbering.start !== void 0 ? numbering.start + pageInSection - 1 : pageNumber;
855
+ const displayed = parseInt(box.dataset.displayedPageNumber || String(pageNumber), 10);
399
856
  for (const marker of Array.from(markers)) {
400
857
  const kind = marker.dataset.field;
401
858
  if (kind !== "PAGE" && kind !== "NUMPAGES") continue;
@@ -407,17 +864,26 @@ var DocxodusEditor = (() => {
407
864
  /**
408
865
  * Measures all content blocks in a section.
409
866
  */
410
- measureBlocks(section, dims) {
867
+ measureBlocks(section, dims, sectionIndex) {
411
868
  const blocks = [];
412
869
  const children = Array.from(section.children);
413
870
  for (const child of children) {
871
+ this.checkpoint();
414
872
  if (child.dataset.sectionIndex !== void 0) {
415
- const nestedBlocks = this.measureBlocks(child, dims);
873
+ const nestedSectionIndex = parseInt(child.dataset.sectionIndex || String(sectionIndex), 10);
874
+ const nestedBlocks = this.measureBlocks(child, dims, nestedSectionIndex);
416
875
  blocks.push(...nestedBlocks);
417
876
  continue;
418
877
  }
878
+ if (child.matches("section.endnotes")) {
879
+ const endnoteBlocks = this.measureSafeEndnoteBlocks(child, dims, sectionIndex);
880
+ if (endnoteBlocks) {
881
+ blocks.push(...endnoteBlocks);
882
+ continue;
883
+ }
884
+ }
419
885
  const rect = child.getBoundingClientRect();
420
- const style = window.getComputedStyle(child);
886
+ const style = this.view.getComputedStyle(child);
421
887
  const marginTopPx = parseFloat(style.marginTop) || 0;
422
888
  const marginBottomPx = parseFloat(style.marginBottom) || 0;
423
889
  const heightPt = pxToPt(rect.height);
@@ -426,6 +892,7 @@ var DocxodusEditor = (() => {
426
892
  const isPageBreak = child.dataset.pageBreak === "true" || child.classList.contains(`${this.cssPrefix}break`);
427
893
  blocks.push({
428
894
  element: child,
895
+ sectionIndex,
429
896
  heightPt,
430
897
  marginTopPt,
431
898
  marginBottomPt,
@@ -438,6 +905,95 @@ var DocxodusEditor = (() => {
438
905
  }
439
906
  return blocks;
440
907
  }
908
+ /**
909
+ * Flatten the converter's `section.endnotes > ol > li > p` presentation into
910
+ * ordinary paragraph blocks. This preserves paragraph formatting and canonical
911
+ * p:en/en:en identities while allowing the existing paragraph fragmenter to
912
+ * split a long endnote across page boundaries.
913
+ */
914
+ measureSafeEndnoteBlocks(section, dims, sectionIndex) {
915
+ const sectionChildren = Array.from(section.children);
916
+ const list = sectionChildren.find((child) => child.tagName === "OL");
917
+ if (!list || sectionChildren.some((child) => child.tagName !== "HR" && child !== list) || Array.from(list.children).some((child) => child.tagName !== "LI")) {
918
+ return null;
919
+ }
920
+ const items = Array.from(list.children);
921
+ if (items.length === 0 || items.some(
922
+ (item) => item.children.length === 0 || Array.from(item.children).some((child) => child.tagName !== "P")
923
+ )) {
924
+ return null;
925
+ }
926
+ const blocks = [];
927
+ const sectionStyle = this.view.getComputedStyle(section);
928
+ for (const rule of sectionChildren.filter((child) => child.tagName === "HR")) {
929
+ this.checkpoint();
930
+ const clonedRule = rule.cloneNode(true);
931
+ if (blocks.length === 0) clonedRule.style.marginTop = sectionStyle.marginTop;
932
+ blocks.push(this.measureElement(clonedRule, dims, sectionIndex));
933
+ }
934
+ const listStyle = this.view.getComputedStyle(list);
935
+ for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
936
+ this.checkpoint();
937
+ const item = items[itemIndex];
938
+ const ownerAnchorId = item.dataset.sourceAnchorId;
939
+ const paragraphs = Array.from(item.children);
940
+ for (let paragraphIndex = 0; paragraphIndex < paragraphs.length; paragraphIndex++) {
941
+ this.checkpoint();
942
+ const paragraph = paragraphs[paragraphIndex];
943
+ if (!this.hasRangeFragmentSafeLayout(paragraph)) {
944
+ return null;
945
+ }
946
+ const clone = paragraph.cloneNode(true);
947
+ clone.dataset.paginationSafeEndnote = "true";
948
+ clone.style.fontSize ||= sectionStyle.fontSize;
949
+ clone.style.lineHeight ||= sectionStyle.lineHeight;
950
+ clone.style.paddingLeft ||= listStyle.paddingLeft;
951
+ if (ownerAnchorId && !Array.from(
952
+ clone.querySelectorAll("[data-source-anchor-id]")
953
+ ).some((node) => node.dataset.sourceAnchorId === ownerAnchorId)) {
954
+ const owner = this.document.createElement("span");
955
+ owner.dataset.sourceAnchorId = ownerAnchorId;
956
+ while (clone.firstChild) owner.appendChild(clone.firstChild);
957
+ clone.appendChild(owner);
958
+ }
959
+ if (paragraphIndex === 0) {
960
+ const itemId = item.id;
961
+ if (itemId) {
962
+ if (clone.id && clone.id !== itemId) return null;
963
+ clone.id = itemId;
964
+ }
965
+ const value = parseInt(item.getAttribute("value") || String(itemIndex + 1), 10);
966
+ const marker = this.formatOrderedListMarker(
967
+ Number.isFinite(value) ? value : itemIndex + 1,
968
+ listStyle.listStyleType
969
+ );
970
+ clone.insertBefore(this.document.createTextNode(`${marker}. `), clone.firstChild);
971
+ }
972
+ blocks.push(this.measureElement(clone, dims, sectionIndex));
973
+ }
974
+ }
975
+ return blocks;
976
+ }
977
+ /** Render the CSS ordered-list formats emitted by the converter after an endnote is flattened. */
978
+ formatOrderedListMarker(value, listStyleType) {
979
+ const format = (() => {
980
+ switch (listStyleType) {
981
+ case "lower-roman":
982
+ return "lowerRoman";
983
+ case "upper-roman":
984
+ return "upperRoman";
985
+ case "lower-alpha":
986
+ case "lower-latin":
987
+ return "lowerLetter";
988
+ case "upper-alpha":
989
+ case "upper-latin":
990
+ return "upperLetter";
991
+ default:
992
+ return "decimal";
993
+ }
994
+ })();
995
+ return formatPageNumber(value, format);
996
+ }
441
997
  /**
442
998
  * Flows a multi-column (`w:cols`) section's children into CSS-multicol container
443
999
  * blocks. Word lays such a section out as N columns inside the same body extent;
@@ -455,7 +1011,7 @@ var DocxodusEditor = (() => {
455
1011
  const maxFragmentHeight = this.smallestEffectiveContentHeight(dims, sectionIndex);
456
1012
  const isBreak = (child) => child.dataset.pageBreak === "true" || child.classList.contains(`${this.cssPrefix}break`);
457
1013
  const makeContainer = (slice) => {
458
- const container = document.createElement("div");
1014
+ const container = this.document.createElement("div");
459
1015
  container.style.columnCount = String(columnCount);
460
1016
  container.style.columnGap = `${columnGapPt}pt`;
461
1017
  for (const child of slice) {
@@ -465,17 +1021,19 @@ var DocxodusEditor = (() => {
465
1021
  };
466
1022
  let start2 = 0;
467
1023
  while (start2 < children.length) {
1024
+ this.checkpoint();
468
1025
  if (isBreak(children[start2])) {
469
- blocks.push(this.measureElement(children[start2], dims));
1026
+ blocks.push(this.measureElement(children[start2], dims, sectionIndex));
470
1027
  start2++;
471
1028
  continue;
472
1029
  }
473
1030
  let end = start2 + 1;
474
1031
  let container = makeContainer(children.slice(start2, end));
475
- let measured = this.measureElement(container, dims);
1032
+ let measured = this.measureElement(container, dims, sectionIndex);
476
1033
  while (end < children.length && !isBreak(children[end])) {
1034
+ this.checkpoint();
477
1035
  const candidate = makeContainer(children.slice(start2, end + 1));
478
- const candidateMeasured = this.measureElement(candidate, dims);
1036
+ const candidateMeasured = this.measureElement(candidate, dims, sectionIndex);
479
1037
  if (candidateMeasured.heightPt > maxFragmentHeight) break;
480
1038
  container = candidate;
481
1039
  measured = candidateMeasured;
@@ -483,6 +1041,7 @@ var DocxodusEditor = (() => {
483
1041
  }
484
1042
  blocks.push({
485
1043
  element: container,
1044
+ sectionIndex,
486
1045
  heightPt: measured.heightPt,
487
1046
  marginTopPt: measured.marginTopPt,
488
1047
  marginBottomPt: measured.marginBottomPt,
@@ -501,8 +1060,8 @@ var DocxodusEditor = (() => {
501
1060
  * This is intentionally DOM-based: table row heights cannot be inferred from individual
502
1061
  * rows because wrapping and collapsed borders change the height of a fragment.
503
1062
  */
504
- measureElement(element, dims) {
505
- const measurementHost = document.createElement("div");
1063
+ measureElement(element, dims, sectionIndex) {
1064
+ const measurementHost = this.document.createElement("div");
506
1065
  measurementHost.style.position = "absolute";
507
1066
  measurementHost.style.visibility = "hidden";
508
1067
  measurementHost.style.left = "-9999px";
@@ -511,9 +1070,10 @@ var DocxodusEditor = (() => {
511
1070
  measurementHost.appendChild(measuredElement);
512
1071
  this.stagingElement.appendChild(measurementHost);
513
1072
  const rect = measuredElement.getBoundingClientRect();
514
- const style = window.getComputedStyle(measuredElement);
1073
+ const style = this.view.getComputedStyle(measuredElement);
515
1074
  const measured = {
516
1075
  element,
1076
+ sectionIndex,
517
1077
  heightPt: pxToPt(rect.height),
518
1078
  marginTopPt: pxToPt(parseFloat(style.marginTop) || 0),
519
1079
  marginBottomPt: pxToPt(parseFloat(style.marginBottom) || 0),
@@ -607,6 +1167,7 @@ var DocxodusEditor = (() => {
607
1167
  const knownIds = new Set(existingFootnoteIds);
608
1168
  const newIds = [];
609
1169
  for (const block of blocks) {
1170
+ this.checkpoint();
610
1171
  for (const id of this.extractFootnoteRefs(block.element)) {
611
1172
  if (!knownIds.has(id)) {
612
1173
  knownIds.add(id);
@@ -624,8 +1185,8 @@ var DocxodusEditor = (() => {
624
1185
  smallestEffectiveContentHeight(dims, sectionIndex) {
625
1186
  return Math.min(
626
1187
  this.getPageBands(dims, sectionIndex, 1, 1).bodyHeight,
627
- this.getPageBands(dims, sectionIndex, 2, 1).bodyHeight,
628
- this.getPageBands(dims, sectionIndex, 2, 2).bodyHeight
1188
+ this.getPageBands(dims, sectionIndex, 2, 2).bodyHeight,
1189
+ this.getPageBands(dims, sectionIndex, 3, 3).bodyHeight
629
1190
  );
630
1191
  }
631
1192
  /**
@@ -663,10 +1224,11 @@ var DocxodusEditor = (() => {
663
1224
  if (wrapper.tagName !== "DIV" || wrapper.children.length !== 1 || block.keepWithNext || block.keepLines || block.pageBreakBefore || block.isPageBreak) {
664
1225
  return null;
665
1226
  }
666
- const table = wrapper.firstElementChild;
667
- if (!(table instanceof HTMLTableElement)) {
1227
+ const tableElement = wrapper.firstElementChild;
1228
+ if (!tableElement || tableElement.localName !== "table") {
668
1229
  return null;
669
1230
  }
1231
+ const table = tableElement;
670
1232
  const body = table.tBodies.length === 1 ? table.tBodies[0] : null;
671
1233
  if (!body || table.tHead || table.tFoot || body.rows.length < 2 || Array.from(table.children).some((child) => child !== body && child.tagName !== "COLGROUP") || table.querySelector("table, [rowspan], [colspan], [data-footnote-id]") || wrapper.querySelector("[data-footnote-id]")) {
672
1234
  return null;
@@ -680,8 +1242,10 @@ var DocxodusEditor = (() => {
680
1242
  const groups = [];
681
1243
  let start2 = 0;
682
1244
  while (start2 < rows.length) {
1245
+ this.checkpoint();
683
1246
  let end = start2;
684
1247
  while (end < rows.length) {
1248
+ this.checkpoint();
685
1249
  const candidate = this.createSimpleTableFragment(
686
1250
  wrapper,
687
1251
  table,
@@ -689,7 +1253,7 @@ var DocxodusEditor = (() => {
689
1253
  rows.slice(start2, end + 1),
690
1254
  start2 === 0
691
1255
  );
692
- const measured = this.measureElement(candidate, dims);
1256
+ const measured = this.measureElement(candidate, dims, block.sectionIndex);
693
1257
  if (measured.heightPt > maximumFragmentHeight) {
694
1258
  break;
695
1259
  }
@@ -721,7 +1285,7 @@ var DocxodusEditor = (() => {
721
1285
  if (!isLast) {
722
1286
  fragment.style.setProperty("margin-bottom", "0", "important");
723
1287
  }
724
- const measured = this.measureElement(fragment, dims);
1288
+ const measured = this.measureElement(fragment, dims, block.sectionIndex);
725
1289
  if (measured.heightPt + measured.marginTopPt + measured.marginBottomPt > minimumContentHeight) {
726
1290
  return null;
727
1291
  }
@@ -736,35 +1300,198 @@ var DocxodusEditor = (() => {
736
1300
  return fragments;
737
1301
  }
738
1302
  /**
739
- * A DOM endpoint that can finish a paragraph fragment. Endpoints are chosen
740
- * after whitespace or at a run boundary so the paginator never deliberately
741
- * cuts through an ordinary word merely to fill a little more of a page.
1303
+ * DOM endpoints that can finish a paragraph fragment. The flattened UTF-16
1304
+ * offsets are checked against the browser's Unicode grapheme segmenter, so a
1305
+ * formatting-run boundary can never bisect a surrogate pair, combining
1306
+ * sequence, or joined emoji. NBSP/word-joiner boundaries remain indivisible.
1307
+ * PAGE/NUMPAGES field results are atomic because splitting their marker would
1308
+ * make later substitution duplicate or replace only half of the field.
742
1309
  */
743
- paragraphFragmentEndpoints(paragraph) {
744
- const endpoints = [];
745
- const walker = document.createTreeWalker(paragraph, NodeFilter.SHOW_TEXT);
1310
+ paragraphFragmentEndpoints(paragraph, emergencyGraphemeBreaks = false) {
1311
+ const textNodes = [];
1312
+ const textStarts = /* @__PURE__ */ new Map();
1313
+ const lastTextByField = /* @__PURE__ */ new Map();
1314
+ const walker = this.document.createTreeWalker(paragraph, this.view.NodeFilter.SHOW_TEXT);
1315
+ const flattenedChunks = [];
1316
+ let flattenedLength = 0;
746
1317
  let textNode;
747
1318
  while (textNode = walker.nextNode()) {
748
- const text = textNode.data;
749
- if (text.length === 0) continue;
750
- const whitespace = /\s+/g;
1319
+ this.checkpoint();
1320
+ textStarts.set(textNode, flattenedLength);
1321
+ textNodes.push(textNode);
1322
+ flattenedChunks.push(textNode.data);
1323
+ flattenedLength += textNode.data.length;
1324
+ let field = textNode.parentElement?.closest("[data-field]") ?? null;
1325
+ while (field?.parentElement?.closest("[data-field]")) {
1326
+ field = field.parentElement.closest("[data-field]");
1327
+ }
1328
+ if (field && paragraph.contains(field)) lastTextByField.set(field, textNode);
1329
+ }
1330
+ const flattenedText = flattenedChunks.join("");
1331
+ if (!this.hasVisibleText(flattenedText)) return [];
1332
+ const invisible = /[\s\u200B-\u200F\uFEFF]/;
1333
+ let firstVisibleOffset = -1;
1334
+ let lastVisibleOffset = -1;
1335
+ for (let index = 0; index < flattenedText.length; index++) {
1336
+ if (index % 4096 === 0) this.checkpoint();
1337
+ if (invisible.test(flattenedText[index])) continue;
1338
+ if (firstVisibleOffset < 0) firstVisibleOffset = index;
1339
+ lastVisibleOffset = index;
1340
+ }
1341
+ const graphemeBoundaries = this.graphemeBoundaryOffsets(flattenedText);
1342
+ const candidates = [];
1343
+ const addCandidate = (candidate) => {
1344
+ const { textOffset } = candidate;
1345
+ if (textOffset <= 0 || textOffset >= flattenedText.length) return;
1346
+ if (textOffset <= firstVisibleOffset || textOffset > lastVisibleOffset) return;
1347
+ if (this.isNonBreakingTextBoundary(flattenedText, textOffset)) return;
1348
+ if (!this.isLegalParagraphLineBoundary(paragraph, flattenedText, textOffset)) return;
1349
+ if (graphemeBoundaries) {
1350
+ if (!graphemeBoundaries.has(textOffset)) return;
1351
+ } else if (!this.isConservativeFallbackTextBoundary(flattenedText, textOffset)) {
1352
+ return;
1353
+ }
1354
+ candidates.push(candidate);
1355
+ };
1356
+ for (const node of textNodes) {
1357
+ this.checkpoint();
1358
+ const start2 = textStarts.get(node);
1359
+ const atomic = node.parentElement?.closest(
1360
+ "[data-field], [data-list-marker], a[data-comment-id]"
1361
+ );
1362
+ if (atomic && paragraph.contains(atomic)) continue;
1363
+ const whitespace = /[\u0009-\u000D\u0020]+/g;
751
1364
  let match;
752
- while ((match = whitespace.exec(text)) !== null) {
753
- endpoints.push({ node: textNode, offset: match.index + match[0].length });
1365
+ let matchesSinceCheckpoint = 0;
1366
+ while ((match = whitespace.exec(node.data)) !== null) {
1367
+ if (++matchesSinceCheckpoint % 256 === 0) this.checkpoint();
1368
+ const offset = match.index + match[0].length;
1369
+ addCandidate({ node, offset, textOffset: start2 + offset, priority: 0 });
1370
+ }
1371
+ addCandidate({
1372
+ node,
1373
+ offset: node.data.length,
1374
+ textOffset: start2 + node.data.length,
1375
+ priority: 0
1376
+ });
1377
+ if (emergencyGraphemeBreaks) {
1378
+ for (let offset = 1; offset < node.data.length; offset++) {
1379
+ if (offset % 256 === 0) this.checkpoint();
1380
+ addCandidate({
1381
+ node,
1382
+ offset,
1383
+ textOffset: start2 + offset,
1384
+ priority: -1
1385
+ });
1386
+ }
754
1387
  }
755
- if (endpoints.length === 0 || endpoints[endpoints.length - 1].node !== textNode || endpoints[endpoints.length - 1].offset !== text.length) {
756
- endpoints.push({ node: textNode, offset: text.length });
1388
+ }
1389
+ for (const field of Array.from(paragraph.querySelectorAll("[data-field]"))) {
1390
+ this.checkpoint();
1391
+ if (field.parentElement?.closest("[data-field]")) continue;
1392
+ const last = lastTextByField.get(field);
1393
+ const parent = field.parentNode;
1394
+ if (!last || !parent) continue;
1395
+ const childIndex = Array.prototype.indexOf.call(parent.childNodes, field);
1396
+ if (childIndex < 0) continue;
1397
+ addCandidate({
1398
+ node: parent,
1399
+ offset: childIndex + 1,
1400
+ textOffset: textStarts.get(last) + last.data.length,
1401
+ priority: 1
1402
+ });
1403
+ }
1404
+ candidates.sort((left2, right2) => left2.textOffset - right2.textOffset || right2.priority - left2.priority);
1405
+ const endpoints = [];
1406
+ for (const candidate of candidates) {
1407
+ if (endpoints[endpoints.length - 1]?.textOffset !== candidate.textOffset) {
1408
+ endpoints.push(candidate);
757
1409
  }
758
1410
  }
759
1411
  return endpoints;
760
1412
  }
1413
+ /** Browser-native UAX #29 boundaries; null keeps older runtimes conservative. */
1414
+ graphemeBoundaryOffsets(text) {
1415
+ const SegmenterConstructor = this.view.Intl.Segmenter;
1416
+ if (!SegmenterConstructor) return null;
1417
+ const boundaries = /* @__PURE__ */ new Set([0, text.length]);
1418
+ let segmentCount = 0;
1419
+ for (const part of new SegmenterConstructor("en", { granularity: "grapheme" }).segment(text)) {
1420
+ if (++segmentCount % 256 === 0) this.checkpoint();
1421
+ boundaries.add(part.index);
1422
+ boundaries.add(part.index + part.segment.length);
1423
+ }
1424
+ return boundaries;
1425
+ }
1426
+ /**
1427
+ * Conservative UAX #14/CSS wrapping opportunities used for synthetic page
1428
+ * boundaries. In particular, a formatting-run boundary is not itself a word
1429
+ * boundary, and Japanese opening/closing punctuation stays with its pair.
1430
+ * Arbitrary grapheme breaks are admitted only when the paragraph's CSS asks
1431
+ * the browser to wrap anywhere.
1432
+ */
1433
+ isLegalParagraphLineBoundary(paragraph, text, offset) {
1434
+ const lastCodeUnit = text.charCodeAt(offset - 1);
1435
+ const beforeOffset = lastCodeUnit >= 56320 && lastCodeUnit <= 57343 ? Math.max(0, offset - 2) : offset - 1;
1436
+ const beforeCodePoint = text.codePointAt(beforeOffset);
1437
+ const afterCodePoint = text.codePointAt(offset);
1438
+ const before = beforeCodePoint === void 0 ? "" : String.fromCodePoint(beforeCodePoint);
1439
+ const after = afterCodePoint === void 0 ? "" : String.fromCodePoint(afterCodePoint);
1440
+ if (/^[\u0009-\u000D\u0020]$/.test(before) || /^[\u0009-\u000D\u0020]$/.test(after)) return true;
1441
+ if (/[-\u00AD\u2010]$/u.test(before)) return true;
1442
+ const style = this.view.getComputedStyle(paragraph);
1443
+ if (style.wordBreak === "break-all" || style.overflowWrap === "anywhere" || style.overflowWrap === "break-word") return true;
1444
+ const eastAsian = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
1445
+ if (!eastAsian.test(before) && !eastAsian.test(after)) return false;
1446
+ const opening = "([{<\u2018\u201C\u3008\u300A\u300C\u300E\u3010\u3014\u3016\u3018\u301A\uFF08\uFF3B\uFF5B";
1447
+ 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";
1448
+ return !opening.includes(before) && !closing.includes(after);
1449
+ }
1450
+ /** Never fragment across characters whose line-break meaning is explicitly non-breaking. */
1451
+ isNonBreakingTextBoundary(text, offset) {
1452
+ const codePointBefore = (index) => {
1453
+ if (index <= 0) return void 0;
1454
+ const last = text.charCodeAt(index - 1);
1455
+ const start2 = last >= 56320 && last <= 57343 ? index - 2 : index - 1;
1456
+ return text.codePointAt(Math.max(0, start2));
1457
+ };
1458
+ const nonBreaking = /* @__PURE__ */ new Set([
1459
+ 160,
1460
+ 8206,
1461
+ 8207,
1462
+ 8209,
1463
+ 8234,
1464
+ 8235,
1465
+ 8236,
1466
+ 8237,
1467
+ 8238,
1468
+ 8239,
1469
+ 8288,
1470
+ 8294,
1471
+ 8295,
1472
+ 8296,
1473
+ 8297,
1474
+ 65279
1475
+ ]);
1476
+ return nonBreaking.has(codePointBefore(offset) ?? -1) || nonBreaking.has(text.codePointAt(offset) ?? -1);
1477
+ }
1478
+ /**
1479
+ * Without Intl.Segmenter, admit only an all-ASCII boundary. This still
1480
+ * fragments ordinary prose while refusing to guess about Unicode clusters.
1481
+ */
1482
+ isConservativeFallbackTextBoundary(text, offset) {
1483
+ return text.charCodeAt(offset - 1) <= 127 && text.charCodeAt(offset) <= 127;
1484
+ }
761
1485
  /**
762
1486
  * Whether a range contains visible text after ignoring bidi/zero-width marks.
763
1487
  * Paragraph fragmentation deliberately excludes non-textual descendants, so
764
1488
  * this is enough to reject empty head or tail fragments.
765
1489
  */
766
1490
  hasVisibleFragmentText(fragment) {
767
- return (fragment.textContent || "").replace(/[\u200B-\u200F\uFEFF]/g, "").trim().length > 0;
1491
+ return this.hasVisibleText(fragment.textContent || "");
1492
+ }
1493
+ hasVisibleText(text) {
1494
+ return text.replace(/[\u200B-\u200F\uFEFF]/g, "").trim().length > 0;
768
1495
  }
769
1496
  /**
770
1497
  * Phase one only handles text-like paragraph descendants. Objects, explicit
@@ -773,13 +1500,25 @@ var DocxodusEditor = (() => {
773
1500
  * fallback instead of risking broken content or duplicate anchors.
774
1501
  */
775
1502
  canFragmentParagraph(block) {
776
- if (!this.fragmentParagraphs || block.element.tagName !== "P") {
1503
+ if (!this.fragmentParagraphs) {
777
1504
  return false;
778
1505
  }
779
1506
  const paragraph = block.element;
780
1507
  if (block.keepWithNext || block.keepLines || block.pageBreakBefore || block.isPageBreak || paragraph.dataset.widowControl === "true" || paragraph.hasAttribute("contenteditable")) {
781
1508
  return false;
782
1509
  }
1510
+ return this.canRangeFragmentParagraph(paragraph);
1511
+ }
1512
+ /**
1513
+ * Shared structural gate for body and note paragraph fragmentation. Footnote
1514
+ * first paragraphs are inline beside their marker, so that one known layout
1515
+ * context may opt into an inline root while retaining every descendant and
1516
+ * break-safety restriction used for body text.
1517
+ */
1518
+ canRangeFragmentParagraph(paragraph, allowInlineRoot = false) {
1519
+ if (paragraph.tagName !== "P" || paragraph.dataset.keepWithNext === "true" || paragraph.dataset.keepLines === "true" || paragraph.dataset.pageBreakBefore === "true" || paragraph.dataset.widowControl === "true" || paragraph.hasAttribute("contenteditable")) {
1520
+ return false;
1521
+ }
783
1522
  const unsupportedDescendants = [
784
1523
  "br",
785
1524
  "img",
@@ -810,21 +1549,27 @@ var DocxodusEditor = (() => {
810
1549
  "fieldset",
811
1550
  "details",
812
1551
  "[data-footnote-id]",
813
- "[data-list-marker]",
814
- "[data-anchor]",
815
- "[id]",
816
1552
  "[contenteditable]"
817
1553
  ].join(", ");
818
1554
  if (paragraph.querySelector(unsupportedDescendants)) {
819
1555
  return false;
820
1556
  }
821
- const paragraphStyle = window.getComputedStyle(paragraph);
822
- if (paragraphStyle.display !== "block" || paragraphStyle.position !== "static" || paragraphStyle.float !== "none" || paragraphStyle.whiteSpace !== "normal" || paragraphStyle.breakBefore !== "auto" || paragraphStyle.breakAfter !== "auto" || paragraphStyle.breakInside === "avoid" || paragraphStyle.pageBreakBefore !== "auto" || paragraphStyle.pageBreakAfter !== "auto" || paragraphStyle.pageBreakInside === "avoid") {
1557
+ const isValidatedEndnote = paragraph.dataset.paginationSafeEndnote === "true";
1558
+ return isValidatedEndnote || this.hasRangeFragmentSafeLayout(paragraph, allowInlineRoot);
1559
+ }
1560
+ /**
1561
+ * A range clone preserves nested inline formatting exactly. Anything that establishes its own
1562
+ * box/layout context is deferred until a future fragmenter can model it accurately. Callers
1563
+ * must invoke this while the paragraph is attached to the styled document.
1564
+ */
1565
+ hasRangeFragmentSafeLayout(paragraph, allowInlineRoot = false) {
1566
+ const paragraphStyle = this.view.getComputedStyle(paragraph);
1567
+ if (paragraphStyle.display !== "block" && !(allowInlineRoot && paragraphStyle.display === "inline") || paragraphStyle.position !== "static" || paragraphStyle.float !== "none" || paragraphStyle.whiteSpace !== "normal" && paragraphStyle.whiteSpace !== "pre-wrap" || paragraphStyle.breakBefore !== "auto" || paragraphStyle.breakAfter !== "auto" || paragraphStyle.breakInside === "avoid" || paragraphStyle.pageBreakBefore !== "auto" || paragraphStyle.pageBreakAfter !== "auto" || paragraphStyle.pageBreakInside === "avoid") {
823
1568
  return false;
824
1569
  }
825
1570
  for (const descendant of Array.from(paragraph.querySelectorAll("*"))) {
826
- const style = window.getComputedStyle(descendant);
827
- if (style.display !== "inline" || style.position !== "static" || style.float !== "none" || style.whiteSpace !== "normal") {
1571
+ const style = this.view.getComputedStyle(descendant);
1572
+ if (style.display !== "inline" || style.position !== "static" || style.float !== "none" || style.whiteSpace !== "normal" && style.whiteSpace !== "pre-wrap") {
828
1573
  return false;
829
1574
  }
830
1575
  }
@@ -851,58 +1596,122 @@ var DocxodusEditor = (() => {
851
1596
  return fragment;
852
1597
  }
853
1598
  /**
854
- * Splits a simple paragraph at the largest DOM Range endpoint that fits the
855
- * currently available body space. The caller then processes the tail normally,
856
- * allowing it to fragment again on later pages when necessary.
1599
+ * Range clones repeat an inline ancestor when the split lands inside it. Keep
1600
+ * semantic wrappers (links, comments, formatting) on both sides, but retain a
1601
+ * duplicated HTML/editor identity only on the leading fragment. Targets that
1602
+ * occur wholly after the split are absent from `head` and remain on `tail`.
857
1603
  */
858
- tryFragmentParagraph(block, dims, availableHeightPt, effectiveMarginTopPt) {
859
- if (!this.canFragmentParagraph(block) || availableHeightPt <= effectiveMarginTopPt) {
860
- return null;
1604
+ reconcileParagraphFragmentIdentities(head, tail) {
1605
+ const elements = (root, selector2) => [
1606
+ ...root.matches(selector2) ? [root] : [],
1607
+ ...Array.from(root.querySelectorAll(selector2))
1608
+ ];
1609
+ const headIds = new Set(elements(head, "[id]").map((element) => element.id));
1610
+ for (const element of elements(tail, "[id]")) {
1611
+ if (headIds.has(element.id)) element.removeAttribute("id");
861
1612
  }
862
- const paragraph = block.element;
863
- const endpoints = this.paragraphFragmentEndpoints(paragraph);
864
- if (endpoints.length < 2) {
865
- return null;
1613
+ const headAnchors = new Set(elements(head, "[data-anchor]").map((element) => element.dataset.anchor).filter((anchor) => Boolean(anchor)));
1614
+ for (const element of elements(tail, "[data-anchor]")) {
1615
+ if (!element.dataset.anchor || !headAnchors.has(element.dataset.anchor)) continue;
1616
+ element.removeAttribute("data-anchor");
1617
+ element.removeAttribute("data-committed-text");
866
1618
  }
867
- let low = 0;
868
- let high = endpoints.length - 2;
869
- let best = null;
870
- while (low <= high) {
871
- const middle = Math.floor((low + high) / 2);
872
- const endpoint = endpoints[middle];
873
- const headRange = document.createRange();
874
- headRange.setStart(paragraph, 0);
875
- headRange.setEnd(endpoint.node, endpoint.offset);
876
- const headContents = headRange.cloneContents();
877
- if (!this.hasVisibleFragmentText(headContents)) {
878
- low = middle + 1;
879
- continue;
1619
+ }
1620
+ /**
1621
+ * Range-clone the largest safe prefix accepted by `fits`. The measurement
1622
+ * policy stays with the caller, allowing body blocks and note bands to share
1623
+ * one DOM fragmenter while measuring in their respective layout contexts.
1624
+ */
1625
+ splitParagraphAtLargestFit(paragraph, fits, options = {}) {
1626
+ const largestFit = (endpoints2) => {
1627
+ const lastEndpoint = endpoints2[endpoints2.length - 1];
1628
+ if (lastEndpoint) {
1629
+ const lastRange = this.document.createRange();
1630
+ lastRange.setStart(paragraph, 0);
1631
+ lastRange.setEnd(lastEndpoint.node, lastEndpoint.offset);
1632
+ const lastContents = lastRange.cloneContents();
1633
+ if (this.hasVisibleFragmentText(lastContents)) {
1634
+ const lastHead = this.createParagraphFragment(paragraph, lastRange, true, false);
1635
+ if (fits(lastHead)) return { endpoint: lastEndpoint, head: lastHead };
1636
+ }
880
1637
  }
881
- const head = this.createParagraphFragment(paragraph, headRange, true, false);
882
- const measured = this.measureElement(head, dims);
883
- if (effectiveMarginTopPt + measured.heightPt <= availableHeightPt) {
884
- best = { endpoint, element: head, measured };
885
- low = middle + 1;
886
- } else {
887
- high = middle - 1;
1638
+ let low = 0;
1639
+ let high = endpoints2.length - 2;
1640
+ let best2 = null;
1641
+ while (low <= high) {
1642
+ this.checkpoint();
1643
+ const middle = Math.floor((low + high) / 2);
1644
+ const endpoint = endpoints2[middle];
1645
+ const headRange = this.document.createRange();
1646
+ headRange.setStart(paragraph, 0);
1647
+ headRange.setEnd(endpoint.node, endpoint.offset);
1648
+ const headContents = headRange.cloneContents();
1649
+ if (!this.hasVisibleFragmentText(headContents)) {
1650
+ low = middle + 1;
1651
+ continue;
1652
+ }
1653
+ const head = this.createParagraphFragment(paragraph, headRange, true, false);
1654
+ if (fits(head)) {
1655
+ best2 = { endpoint, head };
1656
+ low = middle + 1;
1657
+ } else {
1658
+ high = middle - 1;
1659
+ }
888
1660
  }
1661
+ return best2;
1662
+ };
1663
+ let endpoints = this.paragraphFragmentEndpoints(paragraph);
1664
+ let best = largestFit(endpoints);
1665
+ if (!best && options.emergencyGraphemeBreaks) {
1666
+ endpoints = this.paragraphFragmentEndpoints(paragraph, true);
1667
+ best = largestFit(endpoints);
1668
+ }
1669
+ if (!best && options.forceFirstOnNoFit && endpoints.length > 0) {
1670
+ const endpoint = endpoints[0];
1671
+ const headRange = this.document.createRange();
1672
+ headRange.setStart(paragraph, 0);
1673
+ headRange.setEnd(endpoint.node, endpoint.offset);
1674
+ best = {
1675
+ endpoint,
1676
+ head: this.createParagraphFragment(paragraph, headRange, true, false)
1677
+ };
889
1678
  }
890
1679
  if (!best) {
891
- return null;
1680
+ return endpoints.length > 0 ? { kind: "no-fit" } : { kind: "indivisible" };
892
1681
  }
893
- const tailRange = document.createRange();
1682
+ const tailRange = this.document.createRange();
894
1683
  tailRange.setStart(best.endpoint.node, best.endpoint.offset);
895
1684
  tailRange.setEnd(paragraph, paragraph.childNodes.length);
896
1685
  const tailContents = tailRange.cloneContents();
897
- if (!this.hasVisibleFragmentText(tailContents)) {
1686
+ if (!this.hasVisibleFragmentText(tailContents)) return { kind: "indivisible" };
1687
+ const tail = this.createParagraphFragment(paragraph, tailRange, false, true);
1688
+ this.reconcileParagraphFragmentIdentities(best.head, tail);
1689
+ return {
1690
+ kind: "split",
1691
+ head: best.head,
1692
+ tail
1693
+ };
1694
+ }
1695
+ /**
1696
+ * Splits a simple paragraph at the largest DOM Range endpoint that fits the
1697
+ * currently available body space. The caller then processes the tail normally,
1698
+ * allowing it to fragment again on later pages when necessary.
1699
+ */
1700
+ tryFragmentParagraph(block, dims, availableHeightPt, effectiveMarginTopPt) {
1701
+ if (!this.canFragmentParagraph(block) || availableHeightPt <= effectiveMarginTopPt) {
898
1702
  return null;
899
1703
  }
900
- const tail = this.createParagraphFragment(paragraph, tailRange, false, true);
901
- const tailMeasured = this.measureElement(tail, dims);
1704
+ const split = this.splitParagraphAtLargestFit(block.element, (head) => {
1705
+ const measured = this.measureElement(head, dims, block.sectionIndex);
1706
+ return effectiveMarginTopPt + measured.heightPt <= availableHeightPt;
1707
+ });
1708
+ if (split.kind !== "split") return null;
1709
+ const headMeasured = this.measureElement(split.head, dims, block.sectionIndex);
1710
+ const tailMeasured = this.measureElement(split.tail, dims, block.sectionIndex);
902
1711
  return [
903
1712
  {
904
- ...best.measured,
905
- element: best.element,
1713
+ ...headMeasured,
1714
+ element: split.head,
906
1715
  keepWithNext: false,
907
1716
  keepLines: false,
908
1717
  pageBreakBefore: false,
@@ -910,7 +1719,7 @@ var DocxodusEditor = (() => {
910
1719
  },
911
1720
  {
912
1721
  ...tailMeasured,
913
- element: tail,
1722
+ element: split.tail,
914
1723
  keepWithNext: false,
915
1724
  keepLines: false,
916
1725
  pageBreakBefore: false,
@@ -981,7 +1790,7 @@ var DocxodusEditor = (() => {
981
1790
  const registry = /* @__PURE__ */ new Map();
982
1791
  const registryEl = this.stagingElement.querySelector("#pagination-footnote-registry");
983
1792
  if (!registryEl) return registry;
984
- const entries = Array.from(registryEl.querySelectorAll("[data-footnote-id]"));
1793
+ const entries = Array.from(registryEl.children).filter((entry) => entry instanceof this.view.HTMLElement && entry.hasAttribute("data-footnote-id"));
985
1794
  for (const entry of entries) {
986
1795
  const footnoteId = entry.dataset.footnoteId;
987
1796
  if (footnoteId) {
@@ -990,6 +1799,68 @@ var DocxodusEditor = (() => {
990
1799
  }
991
1800
  return registry;
992
1801
  }
1802
+ /** Read Word's optional normal and continuation separator stories. */
1803
+ parseFootnoteSeparators() {
1804
+ const registry = this.stagingElement.querySelector("#pagination-footnote-registry");
1805
+ const clone = (kind) => {
1806
+ const source = Array.from(registry?.children ?? []).find(
1807
+ (entry) => entry instanceof this.view.HTMLElement && entry.getAttribute("data-footnote-separator") === kind
1808
+ );
1809
+ return source?.cloneNode(true);
1810
+ };
1811
+ return {
1812
+ normal: clone("normal") ?? null,
1813
+ continuation: clone("continuation") ?? null
1814
+ };
1815
+ }
1816
+ /** Append the exact separator story selected for this initial/continued note band. */
1817
+ appendFootnoteSeparator(container, continuation) {
1818
+ const kind = continuation ? "continuation" : "normal";
1819
+ const source = continuation ? this.footnoteContinuationSeparator : this.footnoteSeparator;
1820
+ if (!source) {
1821
+ const fallback = this.document.createElement("hr");
1822
+ fallback.dataset.footnoteSeparator = kind;
1823
+ container.appendChild(fallback);
1824
+ return;
1825
+ }
1826
+ const clone = source.cloneNode(true);
1827
+ for (const element of [clone, ...Array.from(clone.querySelectorAll("*"))]) {
1828
+ element.removeAttribute("id");
1829
+ element.removeAttribute("data-anchor");
1830
+ element.removeAttribute("data-committed-text");
1831
+ element.removeAttribute("data-source-anchor-id");
1832
+ element.removeAttribute("data-page-fragment-id");
1833
+ element.removeAttribute("data-fragment-index");
1834
+ element.removeAttribute("data-footnote-id");
1835
+ element.removeAttribute("data-comment-id");
1836
+ element.removeAttribute("name");
1837
+ if (element instanceof this.view.HTMLAnchorElement && element.getAttribute("href")?.startsWith("#")) {
1838
+ element.removeAttribute("href");
1839
+ }
1840
+ element.setAttribute("contenteditable", "false");
1841
+ }
1842
+ container.appendChild(clone);
1843
+ }
1844
+ /** Parses the hidden source notes used to render paginated margin comments. */
1845
+ parseCommentMarginRegistry() {
1846
+ const registry = /* @__PURE__ */ new Map();
1847
+ const registryEl = this.stagingElement.querySelector(
1848
+ "#pagination-comment-margin-registry"
1849
+ );
1850
+ if (!registryEl) return registry;
1851
+ for (const entry of Array.from(
1852
+ registryEl.querySelectorAll("[data-comment-id]")
1853
+ )) {
1854
+ const commentId = entry.dataset.commentId;
1855
+ if (!commentId) continue;
1856
+ let root = entry;
1857
+ for (let parent = root.parentElement?.closest("[data-comment-id]"); parent && registryEl.contains(parent); parent = root.parentElement?.closest("[data-comment-id]")) {
1858
+ root = parent;
1859
+ }
1860
+ registry.set(commentId, root.cloneNode(true));
1861
+ }
1862
+ return registry;
1863
+ }
993
1864
  /**
994
1865
  * Extracts footnote reference IDs from an element.
995
1866
  */
@@ -1004,6 +1875,92 @@ var DocxodusEditor = (() => {
1004
1875
  }
1005
1876
  return ids;
1006
1877
  }
1878
+ /** Clone a note child for the continuation queue with its original selector position. */
1879
+ cloneFootnoteElementForContinuation(element, sourceIndex) {
1880
+ const clone = element.cloneNode(true);
1881
+ clone.setAttribute(
1882
+ FOOTNOTE_SOURCE_POSITION_ATTR,
1883
+ sourceIndex === 0 ? "first" : "later"
1884
+ );
1885
+ return clone;
1886
+ }
1887
+ /** Remove identities that belong only to the initial registry presentation shell. */
1888
+ makeContinuationShellInert(element) {
1889
+ element.removeAttribute("id");
1890
+ element.removeAttribute("data-anchor");
1891
+ element.removeAttribute("data-committed-text");
1892
+ }
1893
+ /**
1894
+ * Build the exact continuation shape shared by measurement and paint.
1895
+ *
1896
+ * Keep the source `.footnote-item > .footnote-content` ancestry: document
1897
+ * CSS, inherited direction/language, and custom classes frequently target
1898
+ * those shells. Only the number and initial HTML/editor identities are
1899
+ * omitted. A hidden sentinel preserves `p:not(:first-of-type)` for a page
1900
+ * whose first carried element was a later source paragraph.
1901
+ */
1902
+ createFootnoteContinuationWrapper(continuation, elements = continuation.remainingElements) {
1903
+ const source = this.footnoteRegistry.get(continuation.footnoteId);
1904
+ const wrapper = source ? source.cloneNode(false) : this.document.createElement("div");
1905
+ this.makeContinuationShellInert(wrapper);
1906
+ wrapper.classList.add("footnote-continuation");
1907
+ wrapper.dataset.footnoteId = continuation.footnoteId;
1908
+ if (continuation.sourceAnchorId) {
1909
+ wrapper.dataset.sourceAnchorId = continuation.sourceAnchorId;
1910
+ }
1911
+ const sourceContent = source?.querySelector(".footnote-content");
1912
+ const content = sourceContent ? sourceContent.cloneNode(false) : this.document.createElement("span");
1913
+ this.makeContinuationShellInert(content);
1914
+ if (!sourceContent) content.className = "footnote-content";
1915
+ const first = elements[0];
1916
+ if (first?.tagName === "P" && first.getAttribute(FOOTNOTE_SOURCE_POSITION_ATTR) === "later") {
1917
+ const sentinel = this.document.createElement("p");
1918
+ sentinel.className = "footnote-continuation-position-sentinel";
1919
+ sentinel.setAttribute("aria-hidden", "true");
1920
+ sentinel.style.setProperty("display", "none", "important");
1921
+ content.appendChild(sentinel);
1922
+ }
1923
+ for (let index = 0; index < elements.length; index++) {
1924
+ this.checkpoint();
1925
+ const element = elements[index];
1926
+ const clone = element.cloneNode(true);
1927
+ clone.removeAttribute(FOOTNOTE_SOURCE_POSITION_ATTR);
1928
+ if (index === 0 && clone.tagName === "P") {
1929
+ clone.style.setProperty("display", "block", "important");
1930
+ }
1931
+ content.appendChild(clone);
1932
+ }
1933
+ wrapper.appendChild(content);
1934
+ return wrapper;
1935
+ }
1936
+ /** Build the exact partial-note item shape shared by measurement and paint. */
1937
+ createPartialFootnoteItem(footnote, fittingElements) {
1938
+ const item = footnote.cloneNode(false);
1939
+ const number = footnote.querySelector(".footnote-number");
1940
+ if (number) item.appendChild(number.cloneNode(true));
1941
+ const sourceContent = footnote.querySelector(".footnote-content");
1942
+ const content = sourceContent ? sourceContent.cloneNode(false) : this.document.createElement("span");
1943
+ if (!sourceContent) content.className = "footnote-content";
1944
+ for (const element of fittingElements) {
1945
+ this.checkpoint();
1946
+ content.appendChild(element.cloneNode(true));
1947
+ }
1948
+ item.appendChild(content);
1949
+ return item;
1950
+ }
1951
+ /**
1952
+ * Where a note measurement tree is attached.
1953
+ *
1954
+ * A note band is reserved from a measurement and then painted; if the two
1955
+ * happen under different inherited typography the painted notes overflow the
1956
+ * reserve, which is the invisible clipping this engine exists to avoid. The
1957
+ * registry lives in the staging tree but a band paints inside the output
1958
+ * container, so measure there. The host is never a page box, so a page's
1959
+ * `zoom` cannot scale a reserve that is accounted for in unscaled points.
1960
+ */
1961
+ noteMeasurementHost() {
1962
+ return this.containerElement.isConnected ? this.containerElement : this.stagingElement;
1963
+ }
1007
1964
  /**
1008
1965
  * Measures the height of footnotes for given IDs (in points).
1009
1966
  * Creates a temporary container to measure the footnotes.
@@ -1011,140 +1968,246 @@ var DocxodusEditor = (() => {
1011
1968
  * @param contentWidth - Width for measurement
1012
1969
  * @param continuation - Optional continuation content to include first
1013
1970
  */
1014
- measureFootnotesHeight(footnoteIds, contentWidth, continuation) {
1971
+ measureFootnotesHeight(footnoteIds, contentWidth, continuation, partialFootnotes) {
1015
1972
  const hasContinuation = continuation && continuation.remainingElements.length > 0;
1016
- if (footnoteIds.length === 0 && !hasContinuation || this.footnoteRegistry.size === 0) {
1973
+ if (footnoteIds.length === 0 && !hasContinuation) {
1017
1974
  return 0;
1018
1975
  }
1019
- const measureContainer = document.createElement("div");
1976
+ if (this.footnoteRegistry.size === 0 && !hasContinuation) {
1977
+ return 0;
1978
+ }
1979
+ const measureContainer = this.document.createElement("div");
1020
1980
  measureContainer.style.position = "absolute";
1021
1981
  measureContainer.style.visibility = "hidden";
1022
1982
  measureContainer.style.width = `${contentWidth}pt`;
1023
1983
  measureContainer.style.left = "-9999px";
1024
1984
  measureContainer.className = this.cssPrefix + "footnotes";
1025
- const hr = document.createElement("hr");
1026
- measureContainer.appendChild(hr);
1985
+ this.appendFootnoteSeparator(measureContainer, Boolean(hasContinuation));
1027
1986
  if (hasContinuation) {
1028
- const contWrapper = document.createElement("div");
1029
- contWrapper.className = "footnote-continuation";
1030
- for (const el of continuation.remainingElements) {
1031
- contWrapper.appendChild(el.cloneNode(true));
1032
- }
1033
- measureContainer.appendChild(contWrapper);
1987
+ measureContainer.appendChild(this.createFootnoteContinuationWrapper(continuation));
1034
1988
  }
1989
+ const partialById = new Map(
1990
+ partialFootnotes?.map((partial) => [partial.footnoteId, partial]) ?? []
1991
+ );
1035
1992
  for (const id of footnoteIds) {
1993
+ this.checkpoint();
1036
1994
  const footnote = this.footnoteRegistry.get(id);
1037
1995
  if (footnote) {
1038
- measureContainer.appendChild(footnote.cloneNode(true));
1996
+ const partial = partialById.get(id);
1997
+ measureContainer.appendChild(partial ? this.createPartialFootnoteItem(footnote, partial.fittingElements) : footnote.cloneNode(true));
1039
1998
  }
1040
1999
  }
1041
- this.stagingElement.appendChild(measureContainer);
1042
- const rect = measureContainer.getBoundingClientRect();
1043
- const heightPt = pxToPt(rect.height);
1044
- this.stagingElement.removeChild(measureContainer);
1045
- return heightPt;
2000
+ this.noteMeasurementHost().appendChild(measureContainer);
2001
+ try {
2002
+ return pxToPt(measureContainer.getBoundingClientRect().height);
2003
+ } finally {
2004
+ measureContainer.remove();
2005
+ }
1046
2006
  }
1047
2007
  /**
1048
- * Measures the height of just the continuation content (in points).
2008
+ * Partition a continuation for one page's note band, preferring complete children
2009
+ * and range-fragmenting an eligible paragraph when necessary. Always advances by
2010
+ * at least one element so an indivisible oversized paragraph follows the established
2011
+ * clipped fallback without trapping pagination in a loop.
1049
2012
  */
1050
- measureContinuationHeight(continuation, contentWidth) {
1051
- if (!continuation || continuation.remainingElements.length === 0) {
1052
- return 0;
1053
- }
1054
- const measureContainer = document.createElement("div");
2013
+ splitContinuationForPage(continuation, availableHeightPt, contentWidth) {
2014
+ const fitting = [];
2015
+ let remaining = [];
2016
+ const measureContainer = this.document.createElement("div");
1055
2017
  measureContainer.style.position = "absolute";
1056
2018
  measureContainer.style.visibility = "hidden";
1057
2019
  measureContainer.style.width = `${contentWidth}pt`;
1058
2020
  measureContainer.style.left = "-9999px";
1059
2021
  measureContainer.className = this.cssPrefix + "footnotes";
1060
- const hr = document.createElement("hr");
1061
- measureContainer.appendChild(hr);
1062
- for (const el of continuation.remainingElements) {
1063
- measureContainer.appendChild(el.cloneNode(true));
1064
- }
1065
- this.stagingElement.appendChild(measureContainer);
1066
- const rect = measureContainer.getBoundingClientRect();
1067
- const heightPt = pxToPt(rect.height);
1068
- this.stagingElement.removeChild(measureContainer);
1069
- return heightPt;
1070
- }
1071
- /**
1072
- * Splits a footnote element into parts that fit within the available height.
1073
- * Returns the elements that fit and the elements that need to continue.
1074
- */
1075
- splitFootnoteToFit(footnoteElement, availableHeightPt, contentWidth) {
1076
- const footnoteContent = footnoteElement.querySelector(".footnote-content");
1077
- if (!footnoteContent) {
1078
- return {
1079
- fits: Array.from(footnoteElement.children).map((el) => el.cloneNode(true)),
1080
- overflow: []
1081
- };
1082
- }
1083
- const children = Array.from(footnoteContent.children);
1084
- if (children.length <= 1) {
1085
- return {
1086
- fits: children.map((el) => el.cloneNode(true)),
1087
- overflow: []
1088
- };
1089
- }
1090
- const fits = [];
1091
- const overflow = [];
1092
- let currentHeight = 0;
1093
- const hrMeasure = document.createElement("div");
1094
- hrMeasure.style.position = "absolute";
1095
- hrMeasure.style.visibility = "hidden";
1096
- hrMeasure.style.width = `${contentWidth}pt`;
1097
- hrMeasure.style.left = "-9999px";
1098
- hrMeasure.className = this.cssPrefix + "footnotes";
1099
- const hr = document.createElement("hr");
1100
- hrMeasure.appendChild(hr);
1101
- this.stagingElement.appendChild(hrMeasure);
1102
- const hrHeight = pxToPt(hrMeasure.getBoundingClientRect().height);
1103
- this.stagingElement.removeChild(hrMeasure);
1104
- currentHeight = hrHeight;
1105
- const footnoteNumber = footnoteElement.querySelector(".footnote-number");
1106
- for (let i = 0; i < children.length; i++) {
1107
- const child = children[i];
1108
- const measureContainer = document.createElement("div");
1109
- measureContainer.style.position = "absolute";
1110
- measureContainer.style.visibility = "hidden";
1111
- measureContainer.style.width = `${contentWidth}pt`;
1112
- measureContainer.style.left = "-9999px";
1113
- measureContainer.className = this.cssPrefix + "footnotes";
1114
- measureContainer.appendChild(child.cloneNode(true));
1115
- this.stagingElement.appendChild(measureContainer);
1116
- const childHeight = pxToPt(measureContainer.getBoundingClientRect().height);
1117
- this.stagingElement.removeChild(measureContainer);
1118
- if (currentHeight + childHeight <= availableHeightPt) {
1119
- fits.push(child.cloneNode(true));
1120
- currentHeight += childHeight;
1121
- } else {
1122
- for (let j = i; j < children.length; j++) {
1123
- overflow.push(children[j].cloneNode(true));
2022
+ this.appendFootnoteSeparator(measureContainer, true);
2023
+ const wrapper = this.createFootnoteContinuationWrapper(continuation, []);
2024
+ const content = wrapper.querySelector(":scope > .footnote-content");
2025
+ if (!content) {
2026
+ throw new Error("Footnote continuation is missing its content shell");
2027
+ }
2028
+ measureContainer.appendChild(wrapper);
2029
+ this.noteMeasurementHost().appendChild(measureContainer);
2030
+ try {
2031
+ for (let index = 0; index < continuation.remainingElements.length; index++) {
2032
+ this.checkpoint();
2033
+ const element = continuation.remainingElements[index];
2034
+ const sourcePosition = element.getAttribute(FOOTNOTE_SOURCE_POSITION_ATTR);
2035
+ if (fitting.length === 0 && element.tagName === "P" && element.getAttribute(FOOTNOTE_SOURCE_POSITION_ATTR) === "later") {
2036
+ const sentinel = this.document.createElement("p");
2037
+ sentinel.className = "footnote-continuation-position-sentinel";
2038
+ sentinel.setAttribute("aria-hidden", "true");
2039
+ sentinel.style.setProperty("display", "none", "important");
2040
+ content.appendChild(sentinel);
2041
+ }
2042
+ const candidate = element.cloneNode(true);
2043
+ candidate.removeAttribute(FOOTNOTE_SOURCE_POSITION_ATTR);
2044
+ if (fitting.length === 0 && candidate.tagName === "P") {
2045
+ candidate.style.setProperty("display", "block", "important");
2046
+ }
2047
+ content.appendChild(candidate);
2048
+ const candidateHeight = pxToPt(measureContainer.getBoundingClientRect().height);
2049
+ if (candidateHeight <= availableHeightPt) {
2050
+ fitting.push(element);
2051
+ continue;
2052
+ }
2053
+ const canSplitCandidate = candidate.tagName === "P" && this.canRangeFragmentParagraph(candidate);
2054
+ candidate.remove();
2055
+ let split = null;
2056
+ if (canSplitCandidate) {
2057
+ split = this.splitParagraphAtLargestFit(candidate, (head) => {
2058
+ content.appendChild(head);
2059
+ try {
2060
+ return pxToPt(measureContainer.getBoundingClientRect().height) <= availableHeightPt;
2061
+ } finally {
2062
+ head.remove();
2063
+ }
2064
+ }, {
2065
+ // Both fallbacks below cut where ordinary line breaking would not,
2066
+ // so they are only ever right for an element that owns the whole
2067
+ // band: if this page already carries earlier content, a paragraph
2068
+ // that will not start here belongs intact in the next note band.
2069
+ emergencyGraphemeBreaks: fitting.length === 0,
2070
+ // A continuation page owns the full note band. If even one legal
2071
+ // grapheme is taller than it, clip only that unit and keep draining.
2072
+ forceFirstOnNoFit: fitting.length === 0
2073
+ });
2074
+ }
2075
+ if (split?.kind === "split") {
2076
+ if (sourcePosition) {
2077
+ split.head.setAttribute(FOOTNOTE_SOURCE_POSITION_ATTR, sourcePosition);
2078
+ split.tail.setAttribute(FOOTNOTE_SOURCE_POSITION_ATTR, sourcePosition);
2079
+ }
2080
+ fitting.push(split.head);
2081
+ remaining = [split.tail, ...continuation.remainingElements.slice(index + 1)];
2082
+ } else if (fitting.length === 0) {
2083
+ fitting.push(element);
2084
+ remaining = continuation.remainingElements.slice(index + 1);
2085
+ } else {
2086
+ remaining = continuation.remainingElements.slice(index);
1124
2087
  }
1125
2088
  break;
1126
2089
  }
2090
+ } finally {
2091
+ measureContainer.remove();
1127
2092
  }
1128
- return { fits, overflow };
2093
+ return {
2094
+ current: {
2095
+ footnoteId: continuation.footnoteId,
2096
+ sourceAnchorId: continuation.sourceAnchorId,
2097
+ remainingElements: fitting
2098
+ },
2099
+ overflow: remaining.length > 0 ? {
2100
+ footnoteId: continuation.footnoteId,
2101
+ sourceAnchorId: continuation.sourceAnchorId,
2102
+ remainingElements: remaining
2103
+ } : null
2104
+ };
1129
2105
  }
1130
2106
  /**
1131
- * Measures a single footnote's height.
2107
+ * Splits a footnote element into parts that fit within the available height.
2108
+ * Returns the elements that fit and the elements that need to continue.
1132
2109
  */
1133
- measureSingleFootnoteHeight(footnoteId, contentWidth) {
1134
- const footnote = this.footnoteRegistry.get(footnoteId);
1135
- if (!footnote) return 0;
1136
- const measureContainer = document.createElement("div");
2110
+ splitFootnoteToFit(footnoteElement, availableHeightPt, contentWidth, forceProgress = false, existingPayload) {
2111
+ const layoutContext = this.document.createElement("div");
2112
+ layoutContext.style.position = "absolute";
2113
+ layoutContext.style.visibility = "hidden";
2114
+ layoutContext.style.width = `${contentWidth}pt`;
2115
+ layoutContext.style.left = "-9999px";
2116
+ layoutContext.className = this.cssPrefix + "footnotes";
2117
+ this.appendFootnoteSeparator(layoutContext, false);
2118
+ const attachedFootnote = footnoteElement.cloneNode(true);
2119
+ layoutContext.appendChild(attachedFootnote);
2120
+ this.noteMeasurementHost().appendChild(layoutContext);
2121
+ const measureContainer = this.document.createElement("div");
1137
2122
  measureContainer.style.position = "absolute";
1138
2123
  measureContainer.style.visibility = "hidden";
1139
2124
  measureContainer.style.width = `${contentWidth}pt`;
1140
2125
  measureContainer.style.left = "-9999px";
1141
2126
  measureContainer.className = this.cssPrefix + "footnotes";
1142
- measureContainer.appendChild(footnote.cloneNode(true));
1143
- this.stagingElement.appendChild(measureContainer);
1144
- const rect = measureContainer.getBoundingClientRect();
1145
- const heightPt = pxToPt(rect.height);
1146
- this.stagingElement.removeChild(measureContainer);
1147
- return heightPt;
2127
+ const hasContinuation = Boolean(existingPayload?.continuation?.remainingElements.length);
2128
+ this.appendFootnoteSeparator(measureContainer, hasContinuation);
2129
+ if (hasContinuation) {
2130
+ measureContainer.appendChild(this.createFootnoteContinuationWrapper(
2131
+ existingPayload.continuation
2132
+ ));
2133
+ }
2134
+ const partialById = new Map(
2135
+ existingPayload?.partialFootnotes?.map((partial) => [partial.footnoteId, partial]) ?? []
2136
+ );
2137
+ for (const id of existingPayload?.footnoteIds ?? []) {
2138
+ this.checkpoint();
2139
+ const source = this.footnoteRegistry.get(id);
2140
+ if (!source) continue;
2141
+ const partial = partialById.get(id);
2142
+ measureContainer.appendChild(partial ? this.createPartialFootnoteItem(source, partial.fittingElements) : source.cloneNode(true));
2143
+ }
2144
+ const measuredPartial = this.createPartialFootnoteItem(attachedFootnote, []);
2145
+ const measuredContent = measuredPartial.querySelector(":scope > .footnote-content");
2146
+ if (!measuredContent) {
2147
+ layoutContext.remove();
2148
+ throw new Error("Footnote is missing its content shell");
2149
+ }
2150
+ measureContainer.appendChild(measuredPartial);
2151
+ this.noteMeasurementHost().appendChild(measureContainer);
2152
+ try {
2153
+ const footnoteContent = attachedFootnote.querySelector(".footnote-content");
2154
+ if (!footnoteContent) {
2155
+ return {
2156
+ fits: Array.from(attachedFootnote.children).map((el) => el.cloneNode(true)),
2157
+ overflow: []
2158
+ };
2159
+ }
2160
+ const children = Array.from(footnoteContent.children);
2161
+ const fits = [];
2162
+ let overflow = [];
2163
+ for (let i = 0; i < children.length; i++) {
2164
+ this.checkpoint();
2165
+ const child = children[i];
2166
+ const measuredCandidate = child.cloneNode(true);
2167
+ measuredContent.appendChild(measuredCandidate);
2168
+ const candidateFits = pxToPt(measureContainer.getBoundingClientRect().height) <= availableHeightPt;
2169
+ if (candidateFits) {
2170
+ fits.push(child.cloneNode(true));
2171
+ continue;
2172
+ }
2173
+ measuredCandidate.remove();
2174
+ const split = this.canRangeFragmentParagraph(child, true) ? this.splitParagraphAtLargestFit(child, (head) => {
2175
+ measuredContent.appendChild(head);
2176
+ try {
2177
+ return pxToPt(measureContainer.getBoundingClientRect().height) <= availableHeightPt;
2178
+ } finally {
2179
+ head.remove();
2180
+ }
2181
+ }, {
2182
+ // Both fallbacks cut where ordinary line breaking would not, so they
2183
+ // are only ever right for an element that owns the whole band: with
2184
+ // earlier siblings already packed here, a paragraph that will not
2185
+ // start belongs intact in the next note band.
2186
+ emergencyGraphemeBreaks: fits.length === 0 && forceProgress,
2187
+ forceFirstOnNoFit: fits.length === 0 && forceProgress
2188
+ }) : null;
2189
+ if (split?.kind === "split") {
2190
+ fits.push(split.head);
2191
+ split.tail.setAttribute(
2192
+ FOOTNOTE_SOURCE_POSITION_ATTR,
2193
+ i === 0 ? "first" : "later"
2194
+ );
2195
+ overflow = [split.tail, ...children.slice(i + 1).map((element, offset) => this.cloneFootnoteElementForContinuation(element, i + 1 + offset))];
2196
+ } else if (split?.kind === "no-fit") {
2197
+ overflow = children.slice(i).map((element, offset) => this.cloneFootnoteElementForContinuation(element, i + offset));
2198
+ } else if (fits.length === 0 && forceProgress) {
2199
+ fits.push(child.cloneNode(true));
2200
+ overflow = children.slice(i + 1).map((element, offset) => this.cloneFootnoteElementForContinuation(element, i + 1 + offset));
2201
+ } else {
2202
+ overflow = children.slice(i).map((element, offset) => this.cloneFootnoteElementForContinuation(element, i + offset));
2203
+ }
2204
+ break;
2205
+ }
2206
+ return { fits, overflow };
2207
+ } finally {
2208
+ measureContainer.remove();
2209
+ layoutContext.remove();
2210
+ }
1148
2211
  }
1149
2212
  /**
1150
2213
  * Adds footnotes to a page container, including continuation content.
@@ -1157,12 +2220,11 @@ var DocxodusEditor = (() => {
1157
2220
  if (this.footnoteRegistry.size === 0 && !hasContinuation) {
1158
2221
  return;
1159
2222
  }
1160
- const partialFootnoteIds = new Set(partialFootnotes?.map((p) => p.footnoteId) || []);
1161
2223
  const maxFootnoteHeight = Math.min(
1162
2224
  footnoteHeight,
1163
2225
  bands.bodyHeight * MAX_FOOTNOTE_AREA_RATIO
1164
2226
  );
1165
- const footnotesDiv = document.createElement("div");
2227
+ const footnotesDiv = this.document.createElement("div");
1166
2228
  footnotesDiv.className = `${this.cssPrefix}footnotes`;
1167
2229
  footnotesDiv.style.position = "absolute";
1168
2230
  footnotesDiv.style.bottom = `${dims.pageHeight - (bands.bodyTop + bands.bodyHeight)}pt`;
@@ -1171,35 +2233,18 @@ var DocxodusEditor = (() => {
1171
2233
  footnotesDiv.style.boxSizing = "border-box";
1172
2234
  footnotesDiv.style.maxHeight = `${maxFootnoteHeight}pt`;
1173
2235
  footnotesDiv.style.overflow = "hidden";
1174
- const hr = document.createElement("hr");
1175
- footnotesDiv.appendChild(hr);
2236
+ this.appendFootnoteSeparator(footnotesDiv, Boolean(hasContinuation));
1176
2237
  if (hasContinuation) {
1177
- const contWrapper = document.createElement("div");
1178
- contWrapper.className = "footnote-continuation";
1179
- for (const el of continuation.remainingElements) {
1180
- contWrapper.appendChild(el.cloneNode(true));
1181
- }
1182
- footnotesDiv.appendChild(contWrapper);
2238
+ footnotesDiv.appendChild(this.createFootnoteContinuationWrapper(continuation));
1183
2239
  }
1184
2240
  for (const id of footnoteIds) {
1185
2241
  const partial = partialFootnotes?.find((p) => p.footnoteId === id);
1186
2242
  if (partial) {
1187
2243
  const footnote = this.footnoteRegistry.get(id);
1188
2244
  if (footnote) {
1189
- const partialDiv = document.createElement("div");
1190
- partialDiv.className = "footnote-item";
1191
- partialDiv.dataset.footnoteId = id;
1192
- const numberSpan = footnote.querySelector(".footnote-number");
1193
- if (numberSpan) {
1194
- partialDiv.appendChild(numberSpan.cloneNode(true));
1195
- }
1196
- const contentSpan = document.createElement("span");
1197
- contentSpan.className = "footnote-content";
1198
- for (const el of partial.fittingElements) {
1199
- contentSpan.appendChild(el.cloneNode(true));
1200
- }
1201
- partialDiv.appendChild(contentSpan);
1202
- footnotesDiv.appendChild(partialDiv);
2245
+ footnotesDiv.appendChild(
2246
+ this.createPartialFootnoteItem(footnote, partial.fittingElements)
2247
+ );
1203
2248
  }
1204
2249
  } else {
1205
2250
  const footnote = this.footnoteRegistry.get(id);
@@ -1210,30 +2255,26 @@ var DocxodusEditor = (() => {
1210
2255
  }
1211
2256
  pageBox.appendChild(footnotesDiv);
1212
2257
  }
1213
- /**
1214
- * Selects the appropriate header for a page based on section, page position, and page number.
1215
- */
1216
- selectHeader(sectionIndex, pageInSection, globalPageNumber) {
2258
+ /** Select the section's first/odd/even header from its one-based page position. */
2259
+ selectHeader(sectionIndex, pageInSection, displayedPageNumber) {
1217
2260
  const sectionHf = this.hfRegistry.get(sectionIndex);
1218
2261
  if (!sectionHf) return void 0;
1219
2262
  if (pageInSection === 1 && sectionHf.headerFirst) {
1220
2263
  return sectionHf.headerFirst;
1221
2264
  }
1222
- if (globalPageNumber % 2 === 0 && sectionHf.headerEven) {
2265
+ if (displayedPageNumber % 2 === 0 && sectionHf.headerEven) {
1223
2266
  return sectionHf.headerEven;
1224
2267
  }
1225
2268
  return sectionHf.headerDefault;
1226
2269
  }
1227
- /**
1228
- * Selects the appropriate footer for a page based on section, page position, and page number.
1229
- */
1230
- selectFooter(sectionIndex, pageInSection, globalPageNumber) {
2270
+ /** Select the section's first/odd/even footer from its one-based page position. */
2271
+ selectFooter(sectionIndex, pageInSection, displayedPageNumber) {
1231
2272
  const sectionHf = this.hfRegistry.get(sectionIndex);
1232
2273
  if (!sectionHf) return void 0;
1233
2274
  if (pageInSection === 1 && sectionHf.footerFirst) {
1234
2275
  return sectionHf.footerFirst;
1235
2276
  }
1236
- if (globalPageNumber % 2 === 0 && sectionHf.footerEven) {
2277
+ if (displayedPageNumber % 2 === 0 && sectionHf.footerEven) {
1237
2278
  return sectionHf.footerEven;
1238
2279
  }
1239
2280
  return sectionHf.footerDefault;
@@ -1248,7 +2289,7 @@ var DocxodusEditor = (() => {
1248
2289
  * Deterministic: it depends only on the section's page setup and the registry's pre-measured
1249
2290
  * story heights, never on the page's content, which is what keeps it lazy-loading compatible.
1250
2291
  */
1251
- getPageBands(dims, sectionIndex, pageInSection, globalPageNumber) {
2292
+ getPageBands(dims, sectionIndex, pageInSection, displayedPageNumber) {
1252
2293
  const sectionHf = this.hfRegistry.get(sectionIndex);
1253
2294
  return resolvePageBands(
1254
2295
  dims,
@@ -1257,14 +2298,14 @@ var DocxodusEditor = (() => {
1257
2298
  sectionHf?.headerEvenHeight,
1258
2299
  sectionHf?.headerDefaultHeight,
1259
2300
  pageInSection,
1260
- globalPageNumber
2301
+ displayedPageNumber
1261
2302
  ),
1262
2303
  this.selectStoryHeight(
1263
2304
  sectionHf?.footerFirstHeight,
1264
2305
  sectionHf?.footerEvenHeight,
1265
2306
  sectionHf?.footerDefaultHeight,
1266
2307
  pageInSection,
1267
- globalPageNumber
2308
+ displayedPageNumber
1268
2309
  )
1269
2310
  );
1270
2311
  }
@@ -1272,9 +2313,9 @@ var DocxodusEditor = (() => {
1272
2313
  * The measured height of the running story this page position selects, mirroring
1273
2314
  * {@link selectHeader}/{@link selectFooter}. Zero when the page has no such story.
1274
2315
  */
1275
- selectStoryHeight(first, even, fallback, pageInSection, globalPageNumber) {
2316
+ selectStoryHeight(first, even, fallback, pageInSection, displayedPageNumber) {
1276
2317
  if (pageInSection === 1 && first != null) return first;
1277
- if (globalPageNumber % 2 === 0 && even != null) return even;
2318
+ if (displayedPageNumber % 2 === 0 && even != null) return even;
1278
2319
  return fallback ?? 0;
1279
2320
  }
1280
2321
  /**
@@ -1286,7 +2327,7 @@ var DocxodusEditor = (() => {
1286
2327
  * silently starts overlapping body text.
1287
2328
  */
1288
2329
  measureHeaderFooterHeight(source, contentWidth) {
1289
- const measureContainer = document.createElement("div");
2330
+ const measureContainer = this.document.createElement("div");
1290
2331
  measureContainer.style.position = "absolute";
1291
2332
  measureContainer.style.visibility = "hidden";
1292
2333
  measureContainer.style.width = `${contentWidth}pt`;
@@ -1305,79 +2346,275 @@ var DocxodusEditor = (() => {
1305
2346
  * Implements a single-pass, forward-only algorithm that is compatible with future lazy loading.
1306
2347
  * Supports footnote continuation - long footnotes can split across pages.
1307
2348
  */
1308
- flowToPages(blocks, dims, startPageNumber, sectionIndex) {
2349
+ flowToPages(blocks, dims, startPageNumber, sectionIndex, sectionDimensions) {
1309
2350
  const pages = [];
1310
2351
  let currentContent = [];
1311
2352
  let pageNumber = startPageNumber;
1312
- let pageInSection = 1;
2353
+ let pageSectionIndex = sectionIndex;
2354
+ const sectionStartPages = /* @__PURE__ */ new Map([[sectionIndex, startPageNumber]]);
2355
+ const pageInSection = (owner = pageSectionIndex, physicalPage = pageNumber) => physicalPage - (sectionStartPages.get(owner) ?? physicalPage) + 1;
2356
+ const dimensionsFor = (owner = pageSectionIndex) => sectionDimensions.get(owner) ?? dims;
2357
+ const markSectionPlaced = (owner) => {
2358
+ if (!sectionStartPages.has(owner)) sectionStartPages.set(owner, pageNumber);
2359
+ };
2360
+ const previousBox = this.containerElement.querySelector(
2361
+ `.${this.cssPrefix}box:last-of-type`
2362
+ );
2363
+ let precedingDisplayedPageNumber = parseInt(
2364
+ previousBox?.dataset.displayedPageNumber ?? "0",
2365
+ 10
2366
+ );
2367
+ const displayedPageNumber = (owner = pageSectionIndex, ownerPageInSection = pageInSection(owner), physicalPage = pageNumber) => {
2368
+ const numbering = this.pageNumbering.get(owner) ?? {};
2369
+ if (numbering.start !== void 0) {
2370
+ return numbering.start + ownerPageInSection - 1;
2371
+ }
2372
+ const ownerNumbering = this.pageNumbering.get(pageSectionIndex) ?? {};
2373
+ const currentPhysicalNumber = ownerNumbering.start !== void 0 ? ownerNumbering.start + pageInSection(pageSectionIndex) - 1 : precedingDisplayedPageNumber + 1;
2374
+ return currentPhysicalNumber + physicalPage - pageNumber;
2375
+ };
1313
2376
  let { bodyHeight: effectiveContentHeight } = this.getPageBands(
1314
- dims,
1315
- sectionIndex,
1316
- pageInSection,
1317
- pageNumber
2377
+ dimensionsFor(),
2378
+ pageSectionIndex,
2379
+ pageInSection(),
2380
+ displayedPageNumber()
1318
2381
  );
1319
2382
  let remainingHeight = effectiveContentHeight;
1320
2383
  let prevMarginBottomPt = 0;
1321
2384
  let currentFootnoteIds = [];
2385
+ let currentPageHasFootnoteReference = false;
1322
2386
  let currentFootnoteHeight = 0;
1323
2387
  let currentContinuation = this.pendingFootnoteContinuation;
1324
2388
  let nextPageContinuation = null;
2389
+ let currentContinuationPartitioned = false;
2390
+ let currentPageAdmitted = false;
1325
2391
  let deferredFootnoteIds = [];
1326
2392
  let currentPartialFootnotes = [];
1327
- if (currentContinuation && currentContinuation.remainingElements.length > 0) {
1328
- currentFootnoteHeight = this.measureContinuationHeight(currentContinuation, dims.contentWidth);
1329
- }
1330
- const finishPage = () => {
2393
+ const adoptEmptyPageOwner = (owner) => {
2394
+ pageSectionIndex = owner;
2395
+ const bands = this.getPageBands(
2396
+ dimensionsFor(owner),
2397
+ owner,
2398
+ pageInSection(owner),
2399
+ displayedPageNumber(owner)
2400
+ );
2401
+ effectiveContentHeight = bands.bodyHeight;
2402
+ remainingHeight = effectiveContentHeight;
2403
+ };
2404
+ const admitCurrentPage = () => {
2405
+ if (currentPageAdmitted) return;
2406
+ this.admitPageAllocation();
2407
+ currentPageAdmitted = true;
2408
+ };
2409
+ const prepareCurrentContinuation = () => {
2410
+ if (currentContinuationPartitioned || (currentContinuation?.remainingElements.length ?? 0) === 0) return;
2411
+ admitCurrentPage();
2412
+ const ownedDimensions = dimensionsFor();
2413
+ const pageBands = this.getPageBands(
2414
+ ownedDimensions,
2415
+ pageSectionIndex,
2416
+ pageInSection(),
2417
+ displayedPageNumber()
2418
+ );
2419
+ const partition = this.splitContinuationForPage(
2420
+ currentContinuation,
2421
+ pageBands.bodyHeight * MAX_FOOTNOTE_AREA_RATIO,
2422
+ ownedDimensions.contentWidth
2423
+ );
2424
+ currentContinuation = partition.current;
2425
+ if (partition.overflow) nextPageContinuation = partition.overflow;
2426
+ currentContinuationPartitioned = true;
2427
+ currentFootnoteHeight = this.measureFootnotesHeight(
2428
+ currentFootnoteIds,
2429
+ ownedDimensions.contentWidth,
2430
+ currentContinuation,
2431
+ currentPartialFootnotes
2432
+ );
2433
+ };
2434
+ const finishPage = (nextPageSectionIndex = pageSectionIndex, forceEmptyPage = false) => {
1331
2435
  const hasCurrentContinuation = (currentContinuation?.remainingElements.length ?? 0) > 0;
1332
- if (currentContent.length === 0 && !hasCurrentContinuation) return;
2436
+ if (!forceEmptyPage && currentContent.length === 0 && currentFootnoteIds.length === 0 && !hasCurrentContinuation) {
2437
+ adoptEmptyPageOwner(nextPageSectionIndex);
2438
+ return;
2439
+ }
2440
+ prepareCurrentContinuation();
2441
+ admitCurrentPage();
2442
+ let pageContinuation = currentContinuation;
2443
+ const ownedPageInSection = pageInSection();
2444
+ const ownedDimensions = dimensionsFor();
2445
+ const ownedDisplayedPageNumber = displayedPageNumber(
2446
+ pageSectionIndex,
2447
+ ownedPageInSection
2448
+ );
2449
+ const pageBands = this.getPageBands(
2450
+ ownedDimensions,
2451
+ pageSectionIndex,
2452
+ ownedPageInSection,
2453
+ ownedDisplayedPageNumber
2454
+ );
2455
+ const maxFootnoteHeight = pageBands.bodyHeight * MAX_FOOTNOTE_AREA_RATIO;
2456
+ pageContinuation = currentContinuation;
2457
+ currentFootnoteHeight = this.measureFootnotesHeight(
2458
+ currentFootnoteIds,
2459
+ ownedDimensions.contentWidth,
2460
+ pageContinuation,
2461
+ currentPartialFootnotes
2462
+ );
2463
+ if (currentContent.length === 0 && currentFootnoteIds.length > 0 && currentPartialFootnotes.length === 0) {
2464
+ const fittingIds = [];
2465
+ for (let index = 0; index < currentFootnoteIds.length; index++) {
2466
+ const footnoteId = currentFootnoteIds[index];
2467
+ const candidateIds = [...fittingIds, footnoteId];
2468
+ const candidateHeight = this.measureFootnotesHeight(
2469
+ candidateIds,
2470
+ ownedDimensions.contentWidth,
2471
+ pageContinuation
2472
+ );
2473
+ const guardedCandidateHeight = candidateHeight + FOOTNOTE_MEASUREMENT_GUARD_PT;
2474
+ if (guardedCandidateHeight <= maxFootnoteHeight) {
2475
+ fittingIds.push(footnoteId);
2476
+ currentFootnoteHeight = guardedCandidateHeight;
2477
+ continue;
2478
+ }
2479
+ const hasPageContinuation = (pageContinuation?.remainingElements.length ?? 0) > 0;
2480
+ if (fittingIds.length === 0 && !hasPageContinuation) {
2481
+ const source = this.footnoteRegistry.get(footnoteId);
2482
+ const split = source ? this.splitFootnoteToFit(
2483
+ source,
2484
+ maxFootnoteHeight - FOOTNOTE_MEASUREMENT_GUARD_PT,
2485
+ ownedDimensions.contentWidth,
2486
+ true
2487
+ ) : null;
2488
+ fittingIds.push(footnoteId);
2489
+ if (source && split && split.fits.length > 0 && split.overflow.length > 0) {
2490
+ currentPartialFootnotes.push({ footnoteId, fittingElements: split.fits });
2491
+ nextPageContinuation = {
2492
+ footnoteId,
2493
+ sourceAnchorId: source.dataset.sourceAnchorId,
2494
+ remainingElements: split.overflow
2495
+ };
2496
+ currentFootnoteHeight = maxFootnoteHeight;
2497
+ } else {
2498
+ currentFootnoteHeight = guardedCandidateHeight;
2499
+ }
2500
+ deferredFootnoteIds.push(...currentFootnoteIds.slice(index + 1));
2501
+ } else {
2502
+ deferredFootnoteIds.push(...currentFootnoteIds.slice(index));
2503
+ }
2504
+ break;
2505
+ }
2506
+ currentFootnoteIds = fittingIds;
2507
+ }
1333
2508
  const page = this.createPage(
1334
- dims,
2509
+ ownedDimensions,
1335
2510
  pageNumber,
1336
- sectionIndex,
2511
+ pageSectionIndex,
2512
+ ownedDisplayedPageNumber,
1337
2513
  currentContent,
1338
- pageInSection,
2514
+ ownedPageInSection,
1339
2515
  currentFootnoteIds,
1340
2516
  currentFootnoteHeight,
1341
- currentContinuation,
1342
- currentPartialFootnotes.length > 0 ? currentPartialFootnotes : void 0
2517
+ pageContinuation,
2518
+ currentPartialFootnotes.length > 0 ? currentPartialFootnotes : void 0,
2519
+ false,
2520
+ currentPageAdmitted
1343
2521
  );
1344
2522
  pages.push(page);
2523
+ precedingDisplayedPageNumber = ownedDisplayedPageNumber;
1345
2524
  pageNumber++;
1346
- pageInSection++;
1347
2525
  currentContent = [];
1348
- const newBands = this.getPageBands(dims, sectionIndex, pageInSection, pageNumber);
2526
+ pageSectionIndex = nextPageSectionIndex;
2527
+ if (!sectionStartPages.has(pageSectionIndex)) {
2528
+ sectionStartPages.set(pageSectionIndex, pageNumber);
2529
+ }
2530
+ const newBands = this.getPageBands(
2531
+ dimensionsFor(),
2532
+ pageSectionIndex,
2533
+ pageInSection(),
2534
+ displayedPageNumber()
2535
+ );
1349
2536
  effectiveContentHeight = newBands.bodyHeight;
1350
2537
  remainingHeight = effectiveContentHeight;
1351
2538
  prevMarginBottomPt = 0;
1352
2539
  currentFootnoteIds = [];
2540
+ currentPageHasFootnoteReference = false;
1353
2541
  currentPartialFootnotes = [];
1354
2542
  currentContinuation = nextPageContinuation;
1355
2543
  nextPageContinuation = null;
2544
+ currentContinuationPartitioned = false;
2545
+ currentPageAdmitted = false;
1356
2546
  if (deferredFootnoteIds.length > 0) {
1357
2547
  currentFootnoteIds = [...deferredFootnoteIds];
1358
2548
  deferredFootnoteIds = [];
1359
2549
  }
1360
- if (currentContinuation && currentContinuation.remainingElements.length > 0) {
1361
- currentFootnoteHeight = this.measureContinuationHeight(currentContinuation, dims.contentWidth);
1362
- } else {
1363
- currentFootnoteHeight = 0;
1364
- }
1365
- if (currentFootnoteIds.length > 0) {
1366
- currentFootnoteHeight += this.measureFootnotesHeight(
1367
- currentFootnoteIds,
1368
- dims.contentWidth,
1369
- null
1370
- );
1371
- }
2550
+ const nextDimensions = dimensionsFor();
2551
+ currentFootnoteHeight = currentContinuation ? 0 : this.measureFootnotesHeight(currentFootnoteIds, nextDimensions.contentWidth);
1372
2552
  };
2553
+ const pageHasPayload = () => currentContent.length > 0 || currentFootnoteIds.length > 0 || (currentContinuation?.remainingElements.length ?? 0) > 0;
1373
2554
  for (let i = 0; i < blocks.length; i++) {
2555
+ this.checkpoint();
1374
2556
  const block = blocks[i];
2557
+ const allBlockFootnoteIds = this.extractFootnoteRefs(block.element);
2558
+ if (block.sectionIndex !== pageSectionIndex && currentPageHasFootnoteReference && (!this.footnoteLayoutLikeWord8 || allBlockFootnoteIds.length > 0)) {
2559
+ finishPage(block.sectionIndex);
2560
+ }
2561
+ const pageIsEmpty = currentContent.length === 0 && currentFootnoteIds.length === 0 && (currentContinuation?.remainingElements.length ?? 0) === 0;
2562
+ if (pageIsEmpty && block.sectionIndex !== pageSectionIndex) {
2563
+ adoptEmptyPageOwner(block.sectionIndex);
2564
+ }
2565
+ prepareCurrentContinuation();
2566
+ if (currentFootnoteHeight > 0) {
2567
+ const ownerDimensions = dimensionsFor();
2568
+ const ownerBands = this.getPageBands(
2569
+ ownerDimensions,
2570
+ pageSectionIndex,
2571
+ pageInSection(),
2572
+ displayedPageNumber()
2573
+ );
2574
+ const ownerMaxFootnoteArea = ownerBands.bodyHeight * MAX_FOOTNOTE_AREA_RATIO;
2575
+ if (currentFootnoteHeight > ownerMaxFootnoteArea) {
2576
+ const fittingIds = [];
2577
+ let fittingHeight = 0;
2578
+ if ((currentContinuation?.remainingElements.length ?? 0) === 0 && currentPartialFootnotes.length === 0) {
2579
+ for (const footnoteId of currentFootnoteIds) {
2580
+ this.checkpoint();
2581
+ const candidateHeight = this.measureFootnotesHeight(
2582
+ [...fittingIds, footnoteId],
2583
+ ownerDimensions.contentWidth
2584
+ );
2585
+ if (candidateHeight + FOOTNOTE_MEASUREMENT_GUARD_PT > ownerMaxFootnoteArea) break;
2586
+ fittingIds.push(footnoteId);
2587
+ fittingHeight = candidateHeight;
2588
+ }
2589
+ }
2590
+ if (fittingIds.length > 0) {
2591
+ deferredFootnoteIds = [
2592
+ ...currentFootnoteIds.slice(fittingIds.length),
2593
+ ...deferredFootnoteIds
2594
+ ];
2595
+ currentFootnoteIds = fittingIds;
2596
+ currentFootnoteHeight = fittingHeight;
2597
+ } else {
2598
+ finishPage(block.sectionIndex);
2599
+ i--;
2600
+ continue;
2601
+ }
2602
+ }
2603
+ }
2604
+ const blockDimensions = dimensionsFor(block.sectionIndex);
2605
+ const nextBlockPageInSection = pageInSection(block.sectionIndex, pageNumber + 1);
2606
+ const freshBlockPageBodyHeight = this.getPageBands(
2607
+ blockDimensions,
2608
+ block.sectionIndex,
2609
+ nextBlockPageInSection,
2610
+ displayedPageNumber(block.sectionIndex, nextBlockPageInSection, pageNumber + 1)
2611
+ ).bodyHeight;
1375
2612
  if (block.isPageBreak) {
1376
- finishPage();
2613
+ finishPage(block.sectionIndex);
1377
2614
  continue;
1378
2615
  }
1379
2616
  if (block.pageBreakBefore && currentContent.length > 0) {
1380
- finishPage();
2617
+ finishPage(block.sectionIndex);
1381
2618
  }
1382
2619
  const previousBlock = blocks[i - 1];
1383
2620
  const startsKeepChain = !previousBlock || !previousBlock.keepWithNext || previousBlock.isPageBreak || block.pageBreakBefore;
@@ -1393,7 +2630,7 @@ var DocxodusEditor = (() => {
1393
2630
  if (newChainFootnoteIds.length > 0 && this.footnoteRegistry.size > 0) {
1394
2631
  const totalChainFootnoteHeight = this.measureFootnotesHeight(
1395
2632
  [...currentFootnoteIds, ...newChainFootnoteIds],
1396
- dims.contentWidth,
2633
+ blockDimensions.contentWidth,
1397
2634
  currentContinuation
1398
2635
  );
1399
2636
  additionalChainFootnoteHeight = Math.max(
@@ -1405,61 +2642,68 @@ var DocxodusEditor = (() => {
1405
2642
  keepChain,
1406
2643
  prevMarginBottomPt,
1407
2644
  currentContent.length === 0,
1408
- pageInSection
2645
+ pageInSection(block.sectionIndex)
1409
2646
  ) + additionalChainFootnoteHeight;
1410
2647
  const currentAvailableHeight = remainingHeight - currentFootnoteHeight;
1411
2648
  if (currentChainHeight > currentAvailableHeight) {
1412
2649
  const nextPageBands = this.getPageBands(
1413
- dims,
1414
- sectionIndex,
1415
- pageInSection + 1,
1416
- pageNumber + 1
2650
+ dimensionsFor(block.sectionIndex),
2651
+ block.sectionIndex,
2652
+ pageInSection(block.sectionIndex, pageNumber + 1),
2653
+ displayedPageNumber(
2654
+ block.sectionIndex,
2655
+ pageInSection(block.sectionIndex, pageNumber + 1),
2656
+ pageNumber + 1
2657
+ )
1417
2658
  );
1418
2659
  const freshChainBodyHeight = this.measureKeepWithNextChainBodyHeight(
1419
2660
  keepChain,
1420
2661
  0,
1421
2662
  true,
1422
- pageInSection + 1
2663
+ pageInSection(block.sectionIndex, pageNumber + 1)
1423
2664
  );
1424
2665
  const freshChainFootnoteHeight = this.measureFootnotesHeight(
1425
2666
  newChainFootnoteIds,
1426
- dims.contentWidth,
2667
+ blockDimensions.contentWidth,
1427
2668
  nextPageContinuation
1428
2669
  );
1429
2670
  if (freshChainBodyHeight + freshChainFootnoteHeight <= nextPageBands.bodyHeight) {
1430
- finishPage();
2671
+ finishPage(block.sectionIndex);
1431
2672
  }
1432
2673
  }
1433
2674
  }
1434
2675
  }
1435
- const allBlockFootnoteIds = this.extractFootnoteRefs(block.element);
1436
2676
  const newFootnoteIds = this.collectNewFootnoteIds([block], currentFootnoteIds);
2677
+ let combinedFootnoteHeight = currentFootnoteHeight;
1437
2678
  let additionalFootnoteHeight = 0;
1438
2679
  if (newFootnoteIds.length > 0 && this.footnoteRegistry.size > 0) {
1439
2680
  const combinedFootnoteIds = [...currentFootnoteIds, ...newFootnoteIds];
1440
- const totalFootnoteHeight = this.measureFootnotesHeight(
2681
+ combinedFootnoteHeight = this.measureFootnotesHeight(
1441
2682
  combinedFootnoteIds,
1442
- dims.contentWidth,
1443
- currentContinuation
2683
+ blockDimensions.contentWidth,
2684
+ currentContinuation,
2685
+ currentPartialFootnotes
2686
+ );
2687
+ additionalFootnoteHeight = Math.max(
2688
+ 0,
2689
+ combinedFootnoteHeight - currentFootnoteHeight
1444
2690
  );
1445
- additionalFootnoteHeight = totalFootnoteHeight - currentFootnoteHeight;
1446
2691
  }
1447
2692
  const isFirstOnPage = currentContent.length === 0;
1448
2693
  const effectiveMarginTop = this.effectiveBlockMarginTop(
1449
2694
  block,
1450
2695
  prevMarginBottomPt,
1451
2696
  isFirstOnPage,
1452
- pageInSection
2697
+ pageInSection(block.sectionIndex)
1453
2698
  );
1454
2699
  const blockSpace = effectiveMarginTop + block.heightPt + additionalFootnoteHeight;
1455
2700
  const effectiveRemainingHeight = remainingHeight - currentFootnoteHeight;
1456
2701
  const bodyContentUsed = effectiveContentHeight - remainingHeight;
1457
2702
  const maxFootnoteArea = effectiveContentHeight * MAX_FOOTNOTE_AREA_RATIO;
1458
- const maxFootnoteExpansion = Math.max(0, maxFootnoteArea - currentFootnoteHeight);
1459
2703
  if (blockSpace > effectiveRemainingHeight) {
1460
2704
  const paragraphFragments = this.tryFragmentParagraph(
1461
2705
  block,
1462
- dims,
2706
+ blockDimensions,
1463
2707
  effectiveRemainingHeight,
1464
2708
  effectiveMarginTop
1465
2709
  );
@@ -1469,40 +2713,74 @@ var DocxodusEditor = (() => {
1469
2713
  continue;
1470
2714
  }
1471
2715
  }
1472
- if (blockSpace <= effectiveRemainingHeight) {
1473
- currentContent.push(this.cloneBlockForPage(block, isFirstOnPage, pageInSection));
2716
+ if (blockSpace <= effectiveRemainingHeight && (combinedFootnoteHeight === 0 || combinedFootnoteHeight + FOOTNOTE_MEASUREMENT_GUARD_PT <= maxFootnoteArea)) {
2717
+ markSectionPlaced(block.sectionIndex);
2718
+ currentContent.push(this.cloneBlockForPage(
2719
+ block,
2720
+ isFirstOnPage,
2721
+ pageInSection(block.sectionIndex)
2722
+ ));
1474
2723
  remainingHeight -= effectiveMarginTop + block.heightPt + block.marginBottomPt;
1475
2724
  prevMarginBottomPt = block.marginBottomPt;
1476
2725
  if (newFootnoteIds.length > 0) {
1477
2726
  currentFootnoteIds.push(...newFootnoteIds);
1478
- currentFootnoteHeight += additionalFootnoteHeight;
2727
+ currentFootnoteHeight = combinedFootnoteHeight;
1479
2728
  }
1480
- } else if (block.heightPt + this.effectiveBlockMarginTop(block, 0, true, pageInSection + 1) <= effectiveContentHeight) {
2729
+ currentPageHasFootnoteReference ||= allBlockFootnoteIds.length > 0;
2730
+ } else if (block.heightPt + this.effectiveBlockMarginTop(
2731
+ block,
2732
+ 0,
2733
+ true,
2734
+ pageInSection(block.sectionIndex, pageNumber + 1)
2735
+ ) <= freshBlockPageBodyHeight) {
1481
2736
  const blockSpaceWithoutFootnotes = effectiveMarginTop + block.heightPt;
1482
- const minBodySpaceNeeded = bodyContentUsed + blockSpaceWithoutFootnotes + MIN_BODY_CONTENT_HEIGHT;
1483
- const canExpandFootnotes = minBodySpaceNeeded <= effectiveContentHeight;
1484
2737
  if (newFootnoteIds.length > 0 && blockSpaceWithoutFootnotes <= effectiveRemainingHeight) {
1485
- currentContent.push(this.cloneBlockForPage(block, isFirstOnPage, pageInSection));
2738
+ markSectionPlaced(block.sectionIndex);
2739
+ currentContent.push(this.cloneBlockForPage(
2740
+ block,
2741
+ isFirstOnPage,
2742
+ pageInSection(block.sectionIndex)
2743
+ ));
1486
2744
  remainingHeight -= effectiveMarginTop + block.heightPt + block.marginBottomPt;
1487
2745
  prevMarginBottomPt = block.marginBottomPt;
1488
2746
  const availableForFootnotes = Math.min(
1489
2747
  maxFootnoteArea,
1490
2748
  effectiveContentHeight - bodyContentUsed - blockSpaceWithoutFootnotes
1491
2749
  );
1492
- for (const footnoteId of newFootnoteIds) {
2750
+ const nextPageMaxFootnoteArea = freshBlockPageBodyHeight * MAX_FOOTNOTE_AREA_RATIO;
2751
+ const deferralCannotHelp = availableForFootnotes >= nextPageMaxFootnoteArea;
2752
+ for (let noteIndex = 0; noteIndex < newFootnoteIds.length; noteIndex++) {
2753
+ this.checkpoint();
2754
+ const footnoteId = newFootnoteIds[noteIndex];
1493
2755
  const footnote = this.footnoteRegistry.get(footnoteId);
1494
2756
  if (!footnote) continue;
1495
- const footnoteHeight = this.measureSingleFootnoteHeight(footnoteId, dims.contentWidth);
2757
+ if (nextPageContinuation) {
2758
+ deferredFootnoteIds.push(...newFootnoteIds.slice(noteIndex));
2759
+ break;
2760
+ }
2761
+ const candidateIds = [...currentFootnoteIds, footnoteId];
2762
+ const candidateHeight = this.measureFootnotesHeight(
2763
+ candidateIds,
2764
+ blockDimensions.contentWidth,
2765
+ currentContinuation,
2766
+ currentPartialFootnotes
2767
+ );
1496
2768
  const spaceLeftForFootnotes = availableForFootnotes - currentFootnoteHeight;
1497
- if (footnoteHeight <= spaceLeftForFootnotes) {
2769
+ if (candidateHeight + FOOTNOTE_MEASUREMENT_GUARD_PT <= availableForFootnotes) {
1498
2770
  currentFootnoteIds.push(footnoteId);
1499
- currentFootnoteHeight += footnoteHeight;
2771
+ currentFootnoteHeight = candidateHeight;
1500
2772
  } else {
1501
2773
  if (spaceLeftForFootnotes > 20) {
1502
2774
  const { fits, overflow } = this.splitFootnoteToFit(
1503
2775
  footnote,
1504
- spaceLeftForFootnotes,
1505
- dims.contentWidth
2776
+ availableForFootnotes,
2777
+ blockDimensions.contentWidth,
2778
+ deferralCannotHelp,
2779
+ {
2780
+ footnoteIds: currentFootnoteIds,
2781
+ continuation: currentContinuation,
2782
+ partialFootnotes: currentPartialFootnotes
2783
+ }
1506
2784
  );
1507
2785
  if (fits.length > 0) {
1508
2786
  currentFootnoteIds.push(footnoteId);
@@ -1513,78 +2791,70 @@ var DocxodusEditor = (() => {
1513
2791
  if (overflow.length > 0) {
1514
2792
  nextPageContinuation = {
1515
2793
  footnoteId,
2794
+ sourceAnchorId: footnote.dataset.sourceAnchorId,
1516
2795
  remainingElements: overflow
1517
2796
  };
1518
2797
  }
1519
- currentFootnoteHeight = availableForFootnotes;
2798
+ currentFootnoteHeight = this.measureFootnotesHeight(
2799
+ currentFootnoteIds,
2800
+ blockDimensions.contentWidth,
2801
+ currentContinuation,
2802
+ currentPartialFootnotes
2803
+ );
2804
+ if (overflow.length > 0) {
2805
+ deferredFootnoteIds.push(...newFootnoteIds.slice(noteIndex + 1));
2806
+ break;
2807
+ }
1520
2808
  } else {
1521
- deferredFootnoteIds.push(footnoteId);
2809
+ deferredFootnoteIds.push(...newFootnoteIds.slice(noteIndex));
2810
+ break;
1522
2811
  }
1523
2812
  } else {
1524
- deferredFootnoteIds.push(footnoteId);
2813
+ deferredFootnoteIds.push(...newFootnoteIds.slice(noteIndex));
2814
+ break;
1525
2815
  }
1526
2816
  }
1527
2817
  }
1528
- } else if (canExpandFootnotes && newFootnoteIds.length > 0) {
1529
- const expandedFootnoteSpace = Math.min(maxFootnoteArea, additionalFootnoteHeight + currentFootnoteHeight);
1530
- const bodySpaceAfterExpansion = effectiveContentHeight - expandedFootnoteSpace;
1531
- if (blockSpaceWithoutFootnotes <= bodySpaceAfterExpansion - bodyContentUsed) {
1532
- currentContent.push(this.cloneBlockForPage(block, isFirstOnPage, pageInSection));
1533
- remainingHeight = bodySpaceAfterExpansion - bodyContentUsed - blockSpaceWithoutFootnotes;
1534
- prevMarginBottomPt = block.marginBottomPt;
1535
- currentFootnoteIds.push(...newFootnoteIds);
1536
- currentFootnoteHeight = expandedFootnoteSpace;
1537
- } else {
1538
- finishPage();
1539
- const newPageFootnoteHeight = allBlockFootnoteIds.length > 0 ? this.measureFootnotesHeight(allBlockFootnoteIds, dims.contentWidth, currentContinuation) : currentContinuation ? this.measureContinuationHeight(currentContinuation, dims.contentWidth) : 0;
1540
- const newPageMarginTop = this.effectiveBlockMarginTop(
1541
- block,
1542
- 0,
1543
- true,
1544
- pageInSection
1545
- );
1546
- const newPageSpace = newPageMarginTop + block.heightPt + block.marginBottomPt;
1547
- currentContent.push(this.cloneBlockForPage(block, true, pageInSection));
1548
- remainingHeight = effectiveContentHeight - newPageSpace;
1549
- prevMarginBottomPt = block.marginBottomPt;
1550
- currentFootnoteIds = [...currentFootnoteIds, ...allBlockFootnoteIds];
1551
- currentFootnoteHeight = newPageFootnoteHeight;
1552
- }
2818
+ currentPageHasFootnoteReference ||= allBlockFootnoteIds.length > 0;
1553
2819
  } else {
1554
- finishPage();
1555
- const newPageFootnoteHeight = allBlockFootnoteIds.length > 0 ? this.measureFootnotesHeight(allBlockFootnoteIds, dims.contentWidth, currentContinuation) : currentContinuation ? this.measureContinuationHeight(currentContinuation, dims.contentWidth) : 0;
1556
- const newPageMarginTop = this.effectiveBlockMarginTop(
1557
- block,
1558
- 0,
1559
- true,
1560
- pageInSection
1561
- );
1562
- const newPageSpace = newPageMarginTop + block.heightPt + block.marginBottomPt;
1563
- currentContent.push(this.cloneBlockForPage(block, true, pageInSection));
1564
- remainingHeight = effectiveContentHeight - newPageSpace;
1565
- prevMarginBottomPt = block.marginBottomPt;
1566
- currentFootnoteIds = [...currentFootnoteIds, ...allBlockFootnoteIds];
1567
- currentFootnoteHeight = newPageFootnoteHeight;
2820
+ finishPage(block.sectionIndex, !pageHasPayload());
2821
+ i--;
2822
+ continue;
1568
2823
  }
1569
2824
  } else {
1570
- const tableFragments = this.trySplitSimpleOversizedTable(block, dims, sectionIndex);
2825
+ const tableFragments = this.trySplitSimpleOversizedTable(
2826
+ block,
2827
+ dimensionsFor(block.sectionIndex),
2828
+ block.sectionIndex
2829
+ );
1571
2830
  if (tableFragments) {
1572
2831
  if (currentContent.length > 0 || currentContinuation) {
1573
- finishPage();
2832
+ finishPage(block.sectionIndex);
1574
2833
  }
1575
2834
  blocks.splice(i, 1, ...tableFragments);
1576
2835
  i--;
1577
2836
  continue;
1578
2837
  }
1579
- if (currentContent.length > 0) {
1580
- finishPage();
2838
+ if (pageHasPayload()) {
2839
+ finishPage(block.sectionIndex);
2840
+ i--;
2841
+ continue;
1581
2842
  }
1582
- currentContent.push(this.cloneBlockForPage(block, true, pageInSection));
1583
- currentFootnoteIds = [...currentFootnoteIds, ...allBlockFootnoteIds];
1584
- finishPage();
2843
+ markSectionPlaced(block.sectionIndex);
2844
+ currentContent.push(this.cloneBlockForPage(
2845
+ block,
2846
+ true,
2847
+ pageInSection(block.sectionIndex)
2848
+ ));
2849
+ deferredFootnoteIds.push(...newFootnoteIds);
2850
+ currentPageHasFootnoteReference ||= allBlockFootnoteIds.length > 0;
2851
+ finishPage(block.sectionIndex);
1585
2852
  }
1586
2853
  }
1587
2854
  finishPage();
2855
+ while (currentFootnoteIds.length > 0 || deferredFootnoteIds.length > 0 || (currentContinuation?.remainingElements.length ?? 0) > 0) {
2856
+ finishPage();
2857
+ }
1588
2858
  this.pendingFootnoteContinuation = nextPageContinuation;
1589
2859
  return pages;
1590
2860
  }
@@ -1609,6 +2879,19 @@ var DocxodusEditor = (() => {
1609
2879
  if (el.getAttribute("contenteditable") !== null) el.setAttribute("contenteditable", "false");
1610
2880
  }
1611
2881
  }
2882
+ /** A repeated margin note is presentation, not a second bookmark/link target. */
2883
+ makeClonedMarginCommentInert(root) {
2884
+ this.makeClonedStoryInert(root);
2885
+ const nodes = [root, ...Array.from(root.querySelectorAll("*"))];
2886
+ for (const element of nodes) {
2887
+ element.removeAttribute("id");
2888
+ if (element.localName === "a" && element.getAttribute("href")?.startsWith("#")) {
2889
+ element.removeAttribute("href");
2890
+ element.setAttribute("aria-disabled", "true");
2891
+ element.tabIndex = -1;
2892
+ }
2893
+ }
2894
+ }
1612
2895
  /**
1613
2896
  * Resolves floating DrawingML objects after their anchor paragraphs have landed on a page.
1614
2897
  *
@@ -1634,7 +2917,7 @@ var DocxodusEditor = (() => {
1634
2917
  const context = {
1635
2918
  staticLeft: toPageX(staticRect.left),
1636
2919
  staticTop: toPageY(staticRect.top),
1637
- lineHeight: paragraph ? (parseFloat(getComputedStyle(paragraph).lineHeight) || staticRect.height) / pixelsPerPoint : staticRect.height / pixelsPerPoint,
2920
+ lineHeight: paragraph ? (parseFloat(this.view.getComputedStyle(paragraph).lineHeight) || staticRect.height) / pixelsPerPoint : staticRect.height / pixelsPerPoint,
1638
2921
  paragraphLeft: toPageX(paragraphRect.left),
1639
2922
  paragraphTop: toPageY(paragraphRect.top),
1640
2923
  paragraphWidth: paragraphRect.width / pixelsPerPoint,
@@ -1764,36 +3047,48 @@ var DocxodusEditor = (() => {
1764
3047
  else if (alignment === "outside") fraction = pageNumber % 2 === 1 ? 1 : 0;
1765
3048
  return reference.start + (reference.size - objectSize) * fraction;
1766
3049
  }
3050
+ /** Charge a physical page before any page-owned partitioning or DOM allocation. */
3051
+ admitPageAllocation() {
3052
+ const prospectivePageCount = this.createdPageCount + 1;
3053
+ this.pageCountCheckpoint?.(prospectivePageCount);
3054
+ this.createdPageCount = prospectivePageCount;
3055
+ }
1767
3056
  /**
1768
3057
  * Creates a page container element.
1769
3058
  */
1770
- createPage(dims, pageNumber, sectionIndex, content, pageInSection, footnoteIds = [], footnoteHeight = 0, continuation, partialFootnotes) {
1771
- const pageBox = document.createElement("div");
3059
+ createPage(dims, pageNumber, sectionIndex, displayedPageNumber, content, pageInSection, footnoteIds = [], footnoteHeight = 0, continuation, partialFootnotes, isSectionFiller = false, pageAlreadyAdmitted = false) {
3060
+ if (!pageAlreadyAdmitted) this.admitPageAllocation();
3061
+ const pageBox = this.document.createElement("div");
1772
3062
  pageBox.className = `${this.cssPrefix}box`;
1773
3063
  pageBox.style.width = `${dims.pageWidth}pt`;
1774
3064
  pageBox.style.height = `${dims.pageHeight}pt`;
1775
3065
  pageBox.style.overflow = "hidden";
1776
3066
  pageBox.style.position = "relative";
1777
3067
  if (this.scale !== 1) {
1778
- pageBox.style.zoom = String(this.scale);
1779
- pageBox.style.transform = `scale(${this.scale})`;
1780
- pageBox.style.transformOrigin = "top left";
1781
- const heightReductionPt = dims.pageHeight * (1 - this.scale);
1782
- const widthReductionPt = dims.pageWidth * (1 - this.scale);
1783
- const heightReductionPx = ptToPx(heightReductionPt);
1784
- const widthReductionPx = ptToPx(widthReductionPt);
1785
- pageBox.style.marginRight = `-${widthReductionPx}px`;
1786
- pageBox.style.marginBottom = `${this.pageGap - heightReductionPx}px`;
3068
+ if (this.view.CSS?.supports("zoom", "1")) {
3069
+ pageBox.style.zoom = String(this.scale);
3070
+ } else {
3071
+ pageBox.style.transform = `scale(${this.scale})`;
3072
+ pageBox.style.transformOrigin = "top left";
3073
+ const heightReductionPt = dims.pageHeight * (1 - this.scale);
3074
+ const widthReductionPt = dims.pageWidth * (1 - this.scale);
3075
+ const heightReductionPx = ptToPx(heightReductionPt);
3076
+ const widthReductionPx = ptToPx(widthReductionPt);
3077
+ pageBox.style.marginRight = `-${widthReductionPx}px`;
3078
+ pageBox.style.marginBottom = `${this.pageGap - heightReductionPx}px`;
3079
+ }
1787
3080
  }
1788
3081
  pageBox.style.willChange = "transform";
1789
3082
  pageBox.style.contain = "layout paint";
1790
3083
  pageBox.dataset.pageNumber = String(pageNumber);
1791
3084
  pageBox.dataset.sectionIndex = String(sectionIndex);
3085
+ pageBox.dataset.displayedPageNumber = String(displayedPageNumber);
3086
+ if (isSectionFiller) pageBox.dataset.sectionFiller = "true";
1792
3087
  pageBox.dataset.pageInSection = String(pageInSection);
1793
- const bands = this.getPageBands(dims, sectionIndex, pageInSection, pageNumber);
1794
- const headerSource = this.selectHeader(sectionIndex, pageInSection, pageNumber);
3088
+ const bands = this.getPageBands(dims, sectionIndex, pageInSection, displayedPageNumber);
3089
+ const headerSource = isSectionFiller ? void 0 : this.selectHeader(sectionIndex, pageInSection, displayedPageNumber);
1795
3090
  if (headerSource) {
1796
- const headerDiv = document.createElement("div");
3091
+ const headerDiv = this.document.createElement("div");
1797
3092
  headerDiv.className = `${this.cssPrefix}header`;
1798
3093
  headerDiv.style.position = "absolute";
1799
3094
  headerDiv.style.top = `${bands.headerTop}pt`;
@@ -1815,7 +3110,7 @@ var DocxodusEditor = (() => {
1815
3110
  }
1816
3111
  const contentAreaTop = bands.bodyTop;
1817
3112
  const contentAreaHeight = bands.bodyHeight;
1818
- const contentArea = document.createElement("div");
3113
+ const contentArea = this.document.createElement("div");
1819
3114
  contentArea.className = `${this.cssPrefix}content`;
1820
3115
  contentArea.style.position = "absolute";
1821
3116
  contentArea.style.top = `${contentAreaTop}pt`;
@@ -1828,12 +3123,46 @@ var DocxodusEditor = (() => {
1828
3123
  }
1829
3124
  pageBox.appendChild(contentArea);
1830
3125
  const hasContinuation = continuation && continuation.remainingElements.length > 0;
1831
- if (footnoteIds.length > 0 || hasContinuation) {
3126
+ if (!isSectionFiller && (footnoteIds.length > 0 || hasContinuation)) {
1832
3127
  this.addPageFootnotes(pageBox, footnoteIds, dims, bands, footnoteHeight, continuation, partialFootnotes);
1833
3128
  }
1834
- const footerSource = this.selectFooter(sectionIndex, pageInSection, pageNumber);
3129
+ const pageCommentIds = [];
3130
+ for (const marker of Array.from(
3131
+ pageBox.querySelectorAll("[data-comment-id]")
3132
+ )) {
3133
+ if (marker.closest(`.${this.cssPrefix}comment-margin`)) continue;
3134
+ const id = marker.dataset.commentId;
3135
+ if (id && this.commentMarginRegistry.has(id) && !pageCommentIds.includes(id)) {
3136
+ pageCommentIds.push(id);
3137
+ }
3138
+ }
3139
+ if (pageCommentIds.length > 0) {
3140
+ const marginColumn = this.document.createElement("aside");
3141
+ marginColumn.className = `${this.cssPrefix}comment-margin`;
3142
+ marginColumn.style.position = "absolute";
3143
+ marginColumn.style.top = `${contentAreaTop}pt`;
3144
+ marginColumn.style.left = `${dims.marginLeft + dims.contentWidth + 3}pt`;
3145
+ marginColumn.style.width = `${Math.max(12, dims.marginRight - 6)}pt`;
3146
+ marginColumn.style.maxHeight = `${contentAreaHeight}pt`;
3147
+ marginColumn.style.overflow = "hidden";
3148
+ marginColumn.style.boxSizing = "border-box";
3149
+ const placedThreadRoots = /* @__PURE__ */ new Set();
3150
+ for (const id of pageCommentIds) {
3151
+ const source = this.commentMarginRegistry.get(id);
3152
+ if (source) {
3153
+ const rootId = source.dataset.commentId ?? id;
3154
+ if (placedThreadRoots.has(rootId)) continue;
3155
+ placedThreadRoots.add(rootId);
3156
+ const clone = source.cloneNode(true);
3157
+ this.makeClonedMarginCommentInert(clone);
3158
+ marginColumn.appendChild(clone);
3159
+ }
3160
+ }
3161
+ pageBox.appendChild(marginColumn);
3162
+ }
3163
+ const footerSource = isSectionFiller ? void 0 : this.selectFooter(sectionIndex, pageInSection, displayedPageNumber);
1835
3164
  if (footerSource) {
1836
- const footerDiv = document.createElement("div");
3165
+ const footerDiv = this.document.createElement("div");
1837
3166
  footerDiv.className = `${this.cssPrefix}footer`;
1838
3167
  footerDiv.style.position = "absolute";
1839
3168
  footerDiv.style.top = "auto";
@@ -1853,8 +3182,8 @@ var DocxodusEditor = (() => {
1853
3182
  }
1854
3183
  pageBox.appendChild(footerDiv);
1855
3184
  }
1856
- if (this.showPageNumbers) {
1857
- const pageNum = document.createElement("div");
3185
+ if (this.showPageNumbers && !isSectionFiller) {
3186
+ const pageNum = this.document.createElement("div");
1858
3187
  pageNum.className = `${this.cssPrefix}number`;
1859
3188
  pageNum.textContent = String(pageNumber);
1860
3189
  pageBox.appendChild(pageNum);
@@ -1877,7 +3206,8 @@ var DocxodusEditor = (() => {
1877
3206
  }
1878
3207
  };
1879
3208
  function paginateHtml(html, container, options = {}) {
1880
- const containerEl = typeof container === "string" ? document.getElementById(container) : container;
3209
+ const ownerDocument = typeof container === "string" ? globalThis.document : container.ownerDocument;
3210
+ const containerEl = typeof container === "string" ? ownerDocument.getElementById(container) : container;
1881
3211
  if (!containerEl) {
1882
3212
  throw new Error("Container element not found");
1883
3213
  }
@@ -5179,6 +6509,13 @@ var DocxodusEditor = (() => {
5179
6509
  function escapeInlineMarkdown(text) {
5180
6510
  return text.replace(/([\\`*_[\]])/g, "\\$1");
5181
6511
  }
6512
+ function escapeLeadingBlockMarkers(md) {
6513
+ return md.split("\n").map((line) => {
6514
+ if (/^[#>|]/.test(line) || /^[-+] /.test(line)) return "\\" + line;
6515
+ if (/^\d+\. /.test(line)) return line.replace(".", "\\.");
6516
+ return line;
6517
+ }).join("\n");
6518
+ }
5182
6519
  function collectInlineSegments(node, out) {
5183
6520
  node.childNodes.forEach((child) => {
5184
6521
  if (isGeneratedChrome(child)) return;
@@ -5230,7 +6567,7 @@ var DocxodusEditor = (() => {
5230
6567
  merged.push({ ...s });
5231
6568
  }
5232
6569
  }
5233
- return merged.map(segToMarkdown).join("").trim();
6570
+ return escapeLeadingBlockMarkers(merged.map(segToMarkdown).join("").trim());
5234
6571
  }
5235
6572
  function isListBlock(block) {
5236
6573
  return !!block.querySelector(":scope > [data-list-marker]");
@@ -7265,17 +8602,19 @@ var DocxodusEditor = (() => {
7265
8602
  this.refreshAfter(block, idx, false);
7266
8603
  }
7267
8604
  // ─── Table row / column editing (active block must be inside a table cell) ──────────
7268
- /** Run a table-structure op on the active cell (a cell-paragraph block) and re-render. */
8605
+ /** Run a table-structure op on the active cell's canonical tc anchor and re-render. */
7269
8606
  tableEdit(run) {
7270
8607
  const block = this.activeBlock;
7271
- if (this.closed || !block || !block.closest("table")) return;
8608
+ const cell = block?.closest("td, th");
8609
+ if (this.closed || !block || !cell || !block.closest("table")) return;
7272
8610
  const unid = block.getAttribute("data-anchor");
7273
8611
  if (!unid) return;
7274
- let fullId = this.anchorIdOf(block);
7275
- if (!fullId) return;
8612
+ const paragraphId = this.anchorIdOf(block);
8613
+ const cellId = this.anchorIdOf(cell);
8614
+ if (!paragraphId || !cellId) return;
7276
8615
  const idx = this.blockIndex(block);
7277
- fullId = this.syncBlock(block, fullId);
7278
- const res = this.parseEdit(run(fullId));
8616
+ this.syncBlock(block, paragraphId);
8617
+ const res = this.parseEdit(run(cellId));
7279
8618
  if (!res.success) return;
7280
8619
  this.refreshAfter(block, idx, false);
7281
8620
  }
@@ -7428,6 +8767,59 @@ var DocxodusEditor = (() => {
7428
8767
  if (!res.success) return;
7429
8768
  this.reconcile(idx, false);
7430
8769
  }
8770
+ /**
8771
+ * Add a native Word comment on the current selection in the active block — or on the whole
8772
+ * block when the selection is collapsed (a null span comments the paragraph). The definition,
8773
+ * threading parts, and body-side range markers are written by the session's `AddComment`
8774
+ * (issue #300); the re-render shows the marker chrome the converter already draws. The
8775
+ * annotation type legal review runs on, finally authorable from the shipped surface
8776
+ * (issue #580).
8777
+ */
8778
+ addComment(markdown = "New comment.", author = "Reviewer") {
8779
+ const block = this.activeBlock;
8780
+ if (this.closed || !block) return;
8781
+ const bridge = this.exports.DocxSessionBridge;
8782
+ if (!bridge.AddComment) return;
8783
+ let fullId = this.anchorIdOf(block);
8784
+ if (!fullId) return;
8785
+ const idx = this.blockIndex(block);
8786
+ const span = selectionSpanIn(block);
8787
+ fullId = this.syncBlock(block, fullId);
8788
+ const res = this.parseEdit(
8789
+ bridge.AddComment(
8790
+ this.handle,
8791
+ fullId,
8792
+ span ? JSON.stringify(span) : "",
8793
+ author,
8794
+ "",
8795
+ "",
8796
+ markdown
8797
+ )
8798
+ );
8799
+ if (!res.success) return;
8800
+ this.reconcile(idx, false);
8801
+ }
8802
+ /** The document's native comment threads (session truth), for review UIs — flat entries
8803
+ * with `parentAnchorId` linking replies and `resolved` carrying thread state. */
8804
+ listComments() {
8805
+ if (this.closed) return [];
8806
+ const bridge = this.exports.DocxSessionBridge;
8807
+ if (!bridge.ListComments) return [];
8808
+ try {
8809
+ return JSON.parse(bridge.ListComments(this.handle));
8810
+ } catch {
8811
+ return [];
8812
+ }
8813
+ }
8814
+ /** Resolve or reopen a comment thread by its `cmt` anchor id (from {@link listComments}).
8815
+ * Resolution is thread metadata (`commentsExtended`), not body markup, so no re-render is
8816
+ * needed — a review UI re-reads {@link listComments} for the new state. */
8817
+ setCommentResolved(commentAnchorId, resolved) {
8818
+ if (this.closed) return;
8819
+ const bridge = this.exports.DocxSessionBridge;
8820
+ if (!bridge.SetCommentResolved) return;
8821
+ this.parseEdit(bridge.SetCommentResolved(this.handle, commentAnchorId, resolved));
8822
+ }
7431
8823
  applyParagraphFormat(op) {
7432
8824
  const block = this.activeBlock;
7433
8825
  if (this.closed || !block) return;
@@ -7955,8 +9347,8 @@ var DocxodusEditor = (() => {
7955
9347
  const backref = document.createElement("a");
7956
9348
  backref.setAttribute("class", `${prefix}-backref`);
7957
9349
  backref.setAttribute("contenteditable", "false");
7958
- backref.textContent = "\u21A9";
7959
- last.append(" ", backref);
9350
+ backref.textContent = " \u21A9";
9351
+ last.append(backref);
7960
9352
  }
7961
9353
  li.querySelectorAll("[data-anchor]").forEach((b) => this.wireBlock(b));
7962
9354
  return li;
@@ -8513,6 +9905,25 @@ var DocxodusEditor = (() => {
8513
9905
  box-shadow: 0 1px 3px rgba(15, 23, 42, .08), 0 12px 24px rgba(15, 23, 42, .06);
8514
9906
  }
8515
9907
 
9908
+ /* \u2500\u2500 Review: comment threads \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
9909
+ .dxr-threads {
9910
+ display: flex; flex-direction: column; gap: 4px;
9911
+ max-height: 92px; overflow-y: auto; min-width: 260px; padding-right: 2px;
9912
+ }
9913
+ .dxr-thread {
9914
+ display: flex; align-items: center; gap: 6px;
9915
+ font-size: 11px; line-height: 1.3; padding: 3px 6px;
9916
+ border: 1px solid var(--dxr-rule); border-radius: 4px; background: var(--dxr-chrome-sunk);
9917
+ }
9918
+ .dxr-thread[data-resolved] { opacity: 0.55; }
9919
+ .dxr-thread[data-reply] { margin-left: 14px; }
9920
+ .dxr-thread .dxr-tauthor { font-weight: 600; white-space: nowrap; }
9921
+ .dxr-thread .dxr-ttext {
9922
+ flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 220px;
9923
+ }
9924
+ .dxr-thread button { flex: none; }
9925
+ .dxr-group.dxr-grow { flex: 1; min-width: 0; }
9926
+
8516
9927
  /* \u2500\u2500 Table size picker \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
8517
9928
  .dxr-pop {
8518
9929
  display: none;
@@ -8831,6 +10242,7 @@ var DocxodusEditor = (() => {
8831
10242
  <button type="button" class="dxr-tab" role="tab" data-tab="home" aria-selected="true">Home</button>
8832
10243
  <button type="button" class="dxr-tab" role="tab" data-tab="insert" aria-selected="false">Insert</button>
8833
10244
  <button type="button" class="dxr-tab" role="tab" data-tab="layout" aria-selected="false">Layout</button>
10245
+ <button type="button" class="dxr-tab" role="tab" data-tab="review" aria-selected="false">Review</button>
8834
10246
  <button type="button" class="dxr-tab" role="tab" data-tab="table" data-contextual aria-selected="false" hidden>Table</button>
8835
10247
  </div>
8836
10248
 
@@ -8996,6 +10408,31 @@ var DocxodusEditor = (() => {
8996
10408
  </div>
8997
10409
  </div>
8998
10410
 
10411
+ <!-- REVIEW \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
10412
+ Comment authoring over the session's native comment family (issue #580):
10413
+ the button comments the selection (the whole block when collapsed), and
10414
+ the thread list is live session truth with resolve/reopen per thread. -->
10415
+ <div class="dxr-panel" data-panel="review">
10416
+ <div class="dxr-group">
10417
+ <span class="dxr-glabel">Comments</span>
10418
+ <div class="dxr-row">
10419
+ <input data-dxr="commenttext" type="text" placeholder="Comment text&#8230;"
10420
+ title="The comment body; markdown is honored" />
10421
+ <button type="button" data-dxr="comment"
10422
+ title="Comment on the selection, or on the whole block when nothing is selected">&#128172; Comment</button>
10423
+ </div>
10424
+ <div class="dxr-row">
10425
+ <span class="dxr-note">Comments the selected text; the whole block when the caret is collapsed.</span>
10426
+ </div>
10427
+ </div>
10428
+ <div class="dxr-group dxr-grow">
10429
+ <span class="dxr-glabel">Threads</span>
10430
+ <div class="dxr-threads" data-dxr="threads" aria-live="polite">
10431
+ <span class="dxr-note">No comments yet.</span>
10432
+ </div>
10433
+ </div>
10434
+ </div>
10435
+
8999
10436
  <!-- TABLE (contextual) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
9000
10437
  Row/column editing lives here rather than in a floating toolbar: a docked
9001
10438
  contextual tab cannot overlap the cell you are editing. -->
@@ -9503,6 +10940,18 @@ var DocxodusEditor = (() => {
9503
10940
  if (this.live && style.value) this.run("style", () => this.live.setParagraphStyle(style.value));
9504
10941
  style.value = "";
9505
10942
  });
10943
+ const commentButton = this.control("comment");
10944
+ if (commentButton) {
10945
+ this.keepSelection(commentButton);
10946
+ commentButton.addEventListener("click", () => {
10947
+ if (!this.live) return;
10948
+ const input = this.control("commenttext");
10949
+ const text = input?.value.trim() || "New comment.";
10950
+ this.run("comment", () => this.live.addComment(text, this.options.revisionAuthor ?? "Reviewer"));
10951
+ if (input) input.value = "";
10952
+ this.renderThreads();
10953
+ });
10954
+ }
9506
10955
  this.wireFileActions();
9507
10956
  this.wireLayout();
9508
10957
  this.wirePicker();
@@ -9625,6 +11074,52 @@ var DocxodusEditor = (() => {
9625
11074
  else panel.removeAttribute("data-active");
9626
11075
  }
9627
11076
  if (name === "layout") this.syncPageNumbering();
11077
+ if (name === "review") this.renderThreads();
11078
+ }
11079
+ /** Re-render the Review tab's thread list from session truth ({@link DocxEditor.listComments}):
11080
+ * one row per comment, replies indented under their thread root, resolve/reopen per root. */
11081
+ renderThreads() {
11082
+ const host = this.control("threads");
11083
+ if (!host || !this.live) return;
11084
+ const doc = this.element.ownerDocument ?? document;
11085
+ host.textContent = "";
11086
+ const comments = this.live.listComments();
11087
+ if (comments.length === 0) {
11088
+ const empty = doc.createElement("span");
11089
+ empty.className = "dxr-note";
11090
+ empty.textContent = "No comments yet.";
11091
+ host.appendChild(empty);
11092
+ return;
11093
+ }
11094
+ for (const entry of comments) {
11095
+ const row = doc.createElement("div");
11096
+ row.className = "dxr-thread";
11097
+ row.setAttribute("data-thread", entry.anchorId);
11098
+ if (entry.resolved) row.setAttribute("data-resolved", "");
11099
+ if (entry.parentAnchorId) row.setAttribute("data-reply", "");
11100
+ const author = doc.createElement("span");
11101
+ author.className = "dxr-tauthor";
11102
+ author.textContent = entry.author || "\u2014";
11103
+ row.appendChild(author);
11104
+ const text = doc.createElement("span");
11105
+ text.className = "dxr-ttext";
11106
+ text.textContent = entry.text;
11107
+ text.title = entry.text;
11108
+ row.appendChild(text);
11109
+ if (!entry.parentAnchorId) {
11110
+ const toggle = doc.createElement("button");
11111
+ toggle.type = "button";
11112
+ toggle.textContent = entry.resolved ? "Reopen" : "Resolve";
11113
+ toggle.title = entry.resolved ? "Reopen this comment thread" : "Mark this comment thread resolved";
11114
+ toggle.addEventListener("click", () => {
11115
+ if (!this.live) return;
11116
+ this.run(entry.resolved ? "reopen comment" : "resolve comment", () => this.live.setCommentResolved(entry.anchorId, !entry.resolved));
11117
+ this.renderThreads();
11118
+ });
11119
+ row.appendChild(toggle);
11120
+ }
11121
+ host.appendChild(row);
11122
+ }
9628
11123
  }
9629
11124
  // ── selection-driven state ──────────────────────────────────────────────────
9630
11125
  selectionElement() {