docxodus 9.7.0 → 9.9.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 (101) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +0 -1
  3. package/dist/editor.bundle.js +454 -99
  4. package/dist/editor.d.ts +4 -4
  5. package/dist/editor.d.ts.map +1 -1
  6. package/dist/editor.js +55 -8
  7. package/dist/editor.js.map +1 -1
  8. package/dist/embed.bundle.js +454 -99
  9. package/dist/embed.iife.js +454 -99
  10. package/dist/page-geometry.d.ts +50 -3
  11. package/dist/page-geometry.d.ts.map +1 -1
  12. package/dist/page-geometry.js +63 -6
  13. package/dist/page-geometry.js.map +1 -1
  14. package/dist/pagination.bundle.js +416 -91
  15. package/dist/pagination.d.ts +61 -8
  16. package/dist/pagination.d.ts.map +1 -1
  17. package/dist/pagination.js +353 -105
  18. package/dist/pagination.js.map +1 -1
  19. package/dist/ribbon-chrome.d.ts +1 -1
  20. package/dist/ribbon-chrome.d.ts.map +1 -1
  21. package/dist/ribbon-chrome.js +1 -0
  22. package/dist/ribbon-chrome.js.map +1 -1
  23. package/dist/wasm/_framework/Docxodus.wasm +0 -0
  24. package/dist/wasm/_framework/Docxodus.wasm.br +0 -0
  25. package/dist/wasm/_framework/DocxodusWasm.wasm +0 -0
  26. package/dist/wasm/_framework/DocxodusWasm.wasm.br +0 -0
  27. package/dist/wasm/_framework/System.Collections.Concurrent.wasm +0 -0
  28. package/dist/wasm/_framework/System.Collections.Concurrent.wasm.br +0 -0
  29. package/dist/wasm/_framework/System.Collections.Immutable.wasm +0 -0
  30. package/dist/wasm/_framework/System.Collections.Immutable.wasm.br +0 -0
  31. package/dist/wasm/_framework/System.Collections.NonGeneric.wasm +0 -0
  32. package/dist/wasm/_framework/System.Collections.NonGeneric.wasm.br +0 -0
  33. package/dist/wasm/_framework/System.Collections.Specialized.wasm +0 -0
  34. package/dist/wasm/_framework/System.Collections.Specialized.wasm.br +0 -0
  35. package/dist/wasm/_framework/System.Collections.wasm +0 -0
  36. package/dist/wasm/_framework/System.Collections.wasm.br +0 -0
  37. package/dist/wasm/_framework/System.ComponentModel.Primitives.wasm +0 -0
  38. package/dist/wasm/_framework/System.ComponentModel.Primitives.wasm.br +0 -0
  39. package/dist/wasm/_framework/System.ComponentModel.TypeConverter.wasm +0 -0
  40. package/dist/wasm/_framework/System.ComponentModel.TypeConverter.wasm.br +0 -0
  41. package/dist/wasm/_framework/System.ComponentModel.wasm +0 -0
  42. package/dist/wasm/_framework/System.ComponentModel.wasm.br +0 -0
  43. package/dist/wasm/_framework/System.Console.wasm +0 -0
  44. package/dist/wasm/_framework/System.Console.wasm.br +0 -0
  45. package/dist/wasm/_framework/System.Diagnostics.Process.wasm +0 -0
  46. package/dist/wasm/_framework/System.Diagnostics.Process.wasm.br +0 -0
  47. package/dist/wasm/_framework/System.IO.Compression.wasm +0 -0
  48. package/dist/wasm/_framework/System.IO.Compression.wasm.br +0 -0
  49. package/dist/wasm/_framework/System.IO.Pipelines.wasm +0 -0
  50. package/dist/wasm/_framework/System.IO.Pipelines.wasm.br +0 -0
  51. package/dist/wasm/_framework/System.Linq.Expressions.wasm +0 -0
  52. package/dist/wasm/_framework/System.Linq.Expressions.wasm.br +0 -0
  53. package/dist/wasm/_framework/System.Linq.wasm +0 -0
  54. package/dist/wasm/_framework/System.Linq.wasm.br +0 -0
  55. package/dist/wasm/_framework/System.Memory.wasm +0 -0
  56. package/dist/wasm/_framework/System.Memory.wasm.br +0 -0
  57. package/dist/wasm/_framework/System.Net.Http.wasm +0 -0
  58. package/dist/wasm/_framework/System.Net.Http.wasm.br +0 -0
  59. package/dist/wasm/_framework/System.Net.Primitives.wasm +0 -0
  60. package/dist/wasm/_framework/System.Net.Primitives.wasm.br +0 -0
  61. package/dist/wasm/_framework/System.ObjectModel.wasm +0 -0
  62. package/dist/wasm/_framework/System.ObjectModel.wasm.br +0 -0
  63. package/dist/wasm/_framework/System.Private.CoreLib.wasm +0 -0
  64. package/dist/wasm/_framework/System.Private.CoreLib.wasm.br +0 -0
  65. package/dist/wasm/_framework/System.Private.Uri.wasm +0 -0
  66. package/dist/wasm/_framework/System.Private.Uri.wasm.br +0 -0
  67. package/dist/wasm/_framework/System.Private.Xml.Linq.wasm +0 -0
  68. package/dist/wasm/_framework/System.Private.Xml.Linq.wasm.br +0 -0
  69. package/dist/wasm/_framework/System.Private.Xml.wasm +0 -0
  70. package/dist/wasm/_framework/System.Private.Xml.wasm.br +0 -0
  71. package/dist/wasm/_framework/System.Runtime.InteropServices.JavaScript.wasm +0 -0
  72. package/dist/wasm/_framework/System.Runtime.InteropServices.JavaScript.wasm.br +0 -0
  73. package/dist/wasm/_framework/System.Runtime.wasm +0 -0
  74. package/dist/wasm/_framework/System.Runtime.wasm.br +0 -0
  75. package/dist/wasm/_framework/System.Security.Cryptography.wasm +0 -0
  76. package/dist/wasm/_framework/System.Security.Cryptography.wasm.br +0 -0
  77. package/dist/wasm/_framework/System.Text.Encodings.Web.wasm +0 -0
  78. package/dist/wasm/_framework/System.Text.Encodings.Web.wasm.br +0 -0
  79. package/dist/wasm/_framework/System.Text.Json.wasm +0 -0
  80. package/dist/wasm/_framework/System.Text.Json.wasm.br +0 -0
  81. package/dist/wasm/_framework/System.Text.RegularExpressions.wasm +0 -0
  82. package/dist/wasm/_framework/System.Text.RegularExpressions.wasm.br +0 -0
  83. package/dist/wasm/_framework/System.Xml.Linq.wasm +0 -0
  84. package/dist/wasm/_framework/System.Xml.Linq.wasm.br +0 -0
  85. package/dist/wasm/_framework/System.Xml.XDocument.wasm +0 -0
  86. package/dist/wasm/_framework/System.Xml.XDocument.wasm.br +0 -0
  87. package/dist/wasm/_framework/System.wasm +0 -0
  88. package/dist/wasm/_framework/System.wasm.br +0 -0
  89. package/dist/wasm/_framework/dotnet.boot.js +35 -35
  90. package/dist/wasm/_framework/dotnet.boot.js.br +0 -0
  91. package/dist/wasm/_framework/dotnet.js +1 -1
  92. package/dist/wasm/_framework/dotnet.js.br +0 -0
  93. package/dist/wasm/_framework/dotnet.native.js +3 -3
  94. package/dist/wasm/_framework/dotnet.native.js.br +0 -0
  95. package/dist/wasm/_framework/dotnet.native.wasm +0 -0
  96. package/dist/wasm/_framework/dotnet.native.wasm.br +0 -0
  97. package/dist/wasm/_framework/dotnet.runtime.js +1 -1
  98. package/dist/wasm/_framework/dotnet.runtime.js.br +0 -0
  99. package/package.json +14 -5
  100. package/dist/wasm/index.html +0 -238
  101. package/dist/wasm/main.js +0 -26
@@ -5,7 +5,20 @@
5
5
  * and flows it across fixed-size page containers based on document dimensions.
6
6
  */
7
7
  import { formatPageNumber } from "./page-number-format.js";
8
- import { DEFAULT_MARGIN, DEFAULT_PAGE_WIDTH, parseSectionDimensions, ptToPx, pxToPt, } from "./page-geometry.js";
8
+ import { DEFAULT_MARGIN, DEFAULT_PAGE_WIDTH, parseSectionDimensions, ptToPx, pxToPt, resolvePageBands, } from "./page-geometry.js";
9
+ /**
10
+ * Whether two sections describe the same physical page box. A continuous section
11
+ * break is only honorable when it does — a changed page size or margin set forces
12
+ * a real page break, which is Word's behavior too.
13
+ */
14
+ function samePageBox(a, b) {
15
+ return (a.pageWidth === b.pageWidth &&
16
+ a.pageHeight === b.pageHeight &&
17
+ a.marginTop === b.marginTop &&
18
+ a.marginRight === b.marginRight &&
19
+ a.marginBottom === b.marginBottom &&
20
+ a.marginLeft === b.marginLeft);
21
+ }
9
22
  // Default letter size in points (612 x 792 = 8.5" x 11")
10
23
  // Maximum percentage of content height that footnotes can occupy
11
24
  // This allows footnotes to expand upward into body content space when needed
@@ -63,20 +76,44 @@ export class PaginationEngine {
63
76
  this.pageNumbering = this.parsePageNumbering(sections);
64
77
  // If no sections found, treat the entire staging content as one section
65
78
  const sectionsToProcess = sections.length > 0 ? Array.from(sections) : [this.stagingElement];
79
+ const runs = [];
66
80
  for (const section of sectionsToProcess) {
67
- const sectionIndex = parseInt(section.dataset.sectionIndex || "0", 10);
68
81
  const dims = parseSectionDimensions(section);
82
+ const previous = runs[runs.length - 1];
83
+ if (previous &&
84
+ section.dataset.sectionType === "continuous" &&
85
+ samePageBox(previous.dims, dims)) {
86
+ previous.sections.push(section);
87
+ }
88
+ else {
89
+ runs.push({
90
+ sections: [section],
91
+ sectionIndex: parseInt(section.dataset.sectionIndex || "0", 10),
92
+ dims,
93
+ });
94
+ }
95
+ }
96
+ for (const run of runs) {
69
97
  // Make staging visible for measurement
70
98
  this.stagingElement.style.visibility = "hidden";
71
99
  this.stagingElement.style.position = "absolute";
72
100
  this.stagingElement.style.left = "-9999px";
73
101
  this.stagingElement.style.display = "block";
74
- // Set width for accurate line wrapping
75
- section.style.width = `${dims.contentWidth}pt`;
76
- // Measure all blocks in this section
77
- const blocks = this.measureBlocks(section, dims);
102
+ const blocks = [];
103
+ for (const section of run.sections) {
104
+ // Set width for accurate line wrapping
105
+ section.style.width = `${run.dims.contentWidth}pt`;
106
+ const columnCount = parseInt(section.dataset.cols || "1", 10);
107
+ if (columnCount > 1) {
108
+ const gap = parseFloat(section.dataset.colGap || "");
109
+ blocks.push(...this.buildColumnBlocks(section, run.dims, run.sectionIndex, columnCount, Number.isFinite(gap) ? gap : 36));
110
+ }
111
+ else {
112
+ blocks.push(...this.measureBlocks(section, run.dims));
113
+ }
114
+ }
78
115
  // Flow blocks into pages
79
- const sectionPages = this.flowToPages(blocks, dims, pageNumber, sectionIndex);
116
+ const sectionPages = this.flowToPages(blocks, run.dims, pageNumber, run.sectionIndex);
80
117
  pages.push(...sectionPages);
81
118
  pageNumber += sectionPages.length;
82
119
  }
@@ -176,7 +213,70 @@ export class PaginationEngine {
176
213
  keepLines: child.dataset.keepLines === "true",
177
214
  pageBreakBefore: child.dataset.pageBreakBefore === "true",
178
215
  isPageBreak,
216
+ isWordParagraph: this.isWordParagraphElement(child),
217
+ });
218
+ }
219
+ return blocks;
220
+ }
221
+ /**
222
+ * Flows a multi-column (`w:cols`) section's children into CSS-multicol container
223
+ * blocks. Word lays such a section out as N columns inside the same body extent;
224
+ * a balanced `column-count` container reproduces that geometry, and the paginator
225
+ * then places each container as one ordinary measured block. A container grows
226
+ * greedily until its balanced height would exceed the smallest page body available
227
+ * to the section, so a long columned section still splits across pages at block
228
+ * boundaries. Each child lands in exactly one container, so anchors never
229
+ * duplicate. An explicit page break child passes through as its own block, which
230
+ * ends the current container and lets the normal flow logic turn the page.
231
+ */
232
+ buildColumnBlocks(section, dims, sectionIndex, columnCount, columnGapPt) {
233
+ const children = Array.from(section.children);
234
+ const blocks = [];
235
+ const maxFragmentHeight = this.smallestEffectiveContentHeight(dims, sectionIndex);
236
+ const isBreak = (child) => child.dataset.pageBreak === "true" ||
237
+ child.classList.contains(`${this.cssPrefix}break`);
238
+ const makeContainer = (slice) => {
239
+ const container = document.createElement("div");
240
+ container.style.columnCount = String(columnCount);
241
+ container.style.columnGap = `${columnGapPt}pt`;
242
+ for (const child of slice) {
243
+ container.appendChild(child.cloneNode(true));
244
+ }
245
+ return container;
246
+ };
247
+ let start = 0;
248
+ while (start < children.length) {
249
+ if (isBreak(children[start])) {
250
+ blocks.push(this.measureElement(children[start], dims));
251
+ start++;
252
+ continue;
253
+ }
254
+ // Grow the container one child at a time. Even a single oversized child is
255
+ // emitted alone, preserving the established oversized-block fallback.
256
+ let end = start + 1;
257
+ let container = makeContainer(children.slice(start, end));
258
+ let measured = this.measureElement(container, dims);
259
+ while (end < children.length && !isBreak(children[end])) {
260
+ const candidate = makeContainer(children.slice(start, end + 1));
261
+ const candidateMeasured = this.measureElement(candidate, dims);
262
+ if (candidateMeasured.heightPt > maxFragmentHeight)
263
+ break;
264
+ container = candidate;
265
+ measured = candidateMeasured;
266
+ end++;
267
+ }
268
+ blocks.push({
269
+ element: container,
270
+ heightPt: measured.heightPt,
271
+ marginTopPt: measured.marginTopPt,
272
+ marginBottomPt: measured.marginBottomPt,
273
+ keepWithNext: false,
274
+ keepLines: false,
275
+ pageBreakBefore: false,
276
+ isPageBreak: false,
277
+ isWordParagraph: false,
179
278
  });
279
+ start = end;
180
280
  }
181
281
  return blocks;
182
282
  }
@@ -206,6 +306,7 @@ export class PaginationEngine {
206
306
  pageBreakBefore: element.dataset.pageBreakBefore === "true",
207
307
  isPageBreak: element.dataset.pageBreak === "true" ||
208
308
  element.classList.contains(`${this.cssPrefix}break`),
309
+ isWordParagraph: this.isWordParagraphElement(element),
209
310
  };
210
311
  this.stagingElement.removeChild(measurementHost);
211
312
  return measured;
@@ -238,13 +339,11 @@ export class PaginationEngine {
238
339
  * collapsed-margin rules as normal block placement. The trailing margin is
239
340
  * intentionally excluded, matching the individual block fit check.
240
341
  */
241
- measureKeepWithNextChainBodyHeight(chain, previousMarginBottomPt, isFirstOnPage) {
342
+ measureKeepWithNextChainBodyHeight(chain, previousMarginBottomPt, isFirstOnPage, pageInSection) {
242
343
  const firstBlock = chain[0];
243
344
  if (!firstBlock)
244
345
  return 0;
245
- const firstMarginTop = isFirstOnPage
246
- ? firstBlock.marginTopPt
247
- : Math.max(firstBlock.marginTopPt, previousMarginBottomPt) - previousMarginBottomPt;
346
+ const firstMarginTop = this.effectiveBlockMarginTop(firstBlock, previousMarginBottomPt, isFirstOnPage, pageInSection);
248
347
  let bodyHeight = firstMarginTop + firstBlock.heightPt;
249
348
  for (let index = 1; index < chain.length; index++) {
250
349
  const previousBlock = chain[index - 1];
@@ -253,6 +352,37 @@ export class PaginationEngine {
253
352
  }
254
353
  return bodyHeight;
255
354
  }
355
+ /** Word paragraphs render as `p`, or as `h1`–`h6` when their style has an outline level. */
356
+ isWordParagraphElement(element) {
357
+ return element.tagName === "P" || /^H[1-6]$/.test(element.tagName);
358
+ }
359
+ shouldSuppressPageTopSpacing(block, isFirstOnPage, pageInSection) {
360
+ return isFirstOnPage && pageInSection > 1 && block.isWordParagraph === true;
361
+ }
362
+ /**
363
+ * Resolves the part of a block's top margin that consumes the current page.
364
+ *
365
+ * In Word's native DOCX layout, paragraph space-before is suppressed when a paragraph is the
366
+ * first body block on a later page of the SAME section. The first page of a document/section is
367
+ * the exception and keeps its spacing. Tables and other block margins are not paragraph spacing,
368
+ * so they continue to use the ordinary CSS collapsing rule.
369
+ */
370
+ effectiveBlockMarginTop(block, previousMarginBottomPt, isFirstOnPage, pageInSection) {
371
+ if (this.shouldSuppressPageTopSpacing(block, isFirstOnPage, pageInSection)) {
372
+ return 0;
373
+ }
374
+ return isFirstOnPage
375
+ ? block.marginTopPt
376
+ : Math.max(block.marginTopPt, previousMarginBottomPt) - previousMarginBottomPt;
377
+ }
378
+ /** Clone a source block with the same page-top spacing decision used by the height budget. */
379
+ cloneBlockForPage(block, isFirstOnPage, pageInSection) {
380
+ const clone = block.element.cloneNode(true);
381
+ if (this.shouldSuppressPageTopSpacing(block, isFirstOnPage, pageInSection)) {
382
+ clone.style.setProperty("margin-top", "0", "important");
383
+ }
384
+ return clone;
385
+ }
256
386
  /**
257
387
  * Finds footnote references introduced by a sequence of blocks, preserving
258
388
  * document order and excluding references already assigned to the page.
@@ -276,7 +406,7 @@ export class PaginationEngine {
276
406
  * send it through the oversized-block fallback again.
277
407
  */
278
408
  smallestEffectiveContentHeight(dims, sectionIndex) {
279
- return Math.min(this.getEffectiveHeights(dims, sectionIndex, 1, 1).contentHeight, this.getEffectiveHeights(dims, sectionIndex, 2, 1).contentHeight, this.getEffectiveHeights(dims, sectionIndex, 2, 2).contentHeight);
409
+ return Math.min(this.getPageBands(dims, sectionIndex, 1, 1).bodyHeight, this.getPageBands(dims, sectionIndex, 2, 1).bodyHeight, this.getPageBands(dims, sectionIndex, 2, 2).bodyHeight);
280
410
  }
281
411
  /**
282
412
  * Builds a clone of a simple table wrapper containing a contiguous run of rows.
@@ -724,7 +854,7 @@ export class PaginationEngine {
724
854
  return 0;
725
855
  }
726
856
  // Measure in the SAME styling context the notes render in: `.page-footnotes` carries
727
- // font-size 0.85em and line-height 1.4, so measuring without the class sizes the note
857
+ // its own font-size and line-height, so measuring without the class sizes the note
728
858
  // block against body type and the reserve can never match what is drawn.
729
859
  // Create a temporary measurement container
730
860
  const measureContainer = document.createElement("div");
@@ -884,7 +1014,7 @@ export class PaginationEngine {
884
1014
  /**
885
1015
  * Adds footnotes to a page container, including continuation content.
886
1016
  */
887
- addPageFootnotes(pageBox, footnoteIds, dims, footnoteHeight, continuation, partialFootnotes) {
1017
+ addPageFootnotes(pageBox, footnoteIds, dims, bands, footnoteHeight, continuation, partialFootnotes) {
888
1018
  const hasContinuation = continuation && continuation.remainingElements.length > 0;
889
1019
  if (footnoteIds.length === 0 && !hasContinuation) {
890
1020
  return;
@@ -895,11 +1025,13 @@ export class PaginationEngine {
895
1025
  // Create a set of partial footnote IDs for quick lookup
896
1026
  const partialFootnoteIds = new Set(partialFootnotes?.map(p => p.footnoteId) || []);
897
1027
  // Calculate max height for footnotes area (content height minus margin for body content)
898
- const maxFootnoteHeight = Math.min(footnoteHeight, dims.contentHeight * MAX_FOOTNOTE_AREA_RATIO);
1028
+ const maxFootnoteHeight = Math.min(footnoteHeight, bands.bodyHeight * MAX_FOOTNOTE_AREA_RATIO);
899
1029
  const footnotesDiv = document.createElement("div");
900
1030
  footnotesDiv.className = `${this.cssPrefix}footnotes`;
901
1031
  footnotesDiv.style.position = "absolute";
902
- footnotesDiv.style.bottom = `${dims.marginBottom}pt`; // Above footer area
1032
+ // Notes sit at the FOOT OF THE BODY BAND, not at the bottom margin: a footer taller than
1033
+ // its margin raises that edge, and anchoring to the raw margin would draw notes over it.
1034
+ footnotesDiv.style.bottom = `${dims.pageHeight - (bands.bodyTop + bands.bodyHeight)}pt`;
903
1035
  footnotesDiv.style.left = `${dims.marginLeft}pt`;
904
1036
  footnotesDiv.style.width = `${dims.contentWidth}pt`;
905
1037
  footnotesDiv.style.boxSizing = "border-box";
@@ -991,63 +1123,37 @@ export class PaginationEngine {
991
1123
  return sectionHf.footerDefault;
992
1124
  }
993
1125
  /**
994
- * Computes effective header, footer, and content heights for a specific page position.
995
- * Uses pre-measured header/footer heights from the registry.
996
- * This method is deterministic - same inputs always produce same outputs.
997
- * This enables lazy loading compatibility since available height can be computed
998
- * for any page position without knowing the page's content.
1126
+ * The header, body, and footer bands for one page position.
1127
+ *
1128
+ * The single owner of "where does anything sit vertically on this page" — placement in
1129
+ * {@link createPage}, the body budget the flow loop spends, and the note area's anchor all
1130
+ * read it, so those three cannot disagree about where the body ends.
1131
+ *
1132
+ * Deterministic: it depends only on the section's page setup and the registry's pre-measured
1133
+ * story heights, never on the page's content, which is what keeps it lazy-loading compatible.
999
1134
  */
1000
- getEffectiveHeights(dims, sectionIndex, pageInSection, globalPageNumber) {
1135
+ getPageBands(dims, sectionIndex, pageInSection, globalPageNumber) {
1001
1136
  const sectionHf = this.hfRegistry.get(sectionIndex);
1002
- // Determine effective header height
1003
- let headerHeight = dims.marginTop;
1004
- if (sectionHf) {
1005
- let measuredHeaderHeight;
1006
- // Select the appropriate header height based on page position
1007
- if (pageInSection === 1 && sectionHf.headerFirstHeight != null) {
1008
- measuredHeaderHeight = sectionHf.headerFirstHeight;
1009
- }
1010
- else if (globalPageNumber % 2 === 0 && sectionHf.headerEvenHeight != null) {
1011
- measuredHeaderHeight = sectionHf.headerEvenHeight;
1012
- }
1013
- else if (sectionHf.headerDefaultHeight != null) {
1014
- measuredHeaderHeight = sectionHf.headerDefaultHeight;
1015
- }
1016
- if (measuredHeaderHeight != null) {
1017
- // Use the larger of margin height or measured content height
1018
- headerHeight = Math.max(dims.marginTop, measuredHeaderHeight);
1019
- }
1020
- }
1021
- // Determine effective footer height
1022
- let footerHeight = dims.marginBottom;
1023
- if (sectionHf) {
1024
- let measuredFooterHeight;
1025
- // Select the appropriate footer height based on page position
1026
- if (pageInSection === 1 && sectionHf.footerFirstHeight != null) {
1027
- measuredFooterHeight = sectionHf.footerFirstHeight;
1028
- }
1029
- else if (globalPageNumber % 2 === 0 && sectionHf.footerEvenHeight != null) {
1030
- measuredFooterHeight = sectionHf.footerEvenHeight;
1031
- }
1032
- else if (sectionHf.footerDefaultHeight != null) {
1033
- measuredFooterHeight = sectionHf.footerDefaultHeight;
1034
- }
1035
- if (measuredFooterHeight != null) {
1036
- // Use the larger of margin height or measured content height
1037
- footerHeight = Math.max(dims.marginBottom, measuredFooterHeight);
1038
- }
1039
- }
1040
- // Calculate effective content height
1041
- // contentHeight from dims is: pageHeight - marginTop - marginBottom
1042
- // We need to adjust for any header/footer expansion beyond margins
1043
- const headerExpansion = headerHeight - dims.marginTop;
1044
- const footerExpansion = footerHeight - dims.marginBottom;
1045
- const contentHeight = dims.contentHeight - headerExpansion - footerExpansion;
1046
- return { headerHeight, footerHeight, contentHeight };
1137
+ return resolvePageBands(dims, this.selectStoryHeight(sectionHf?.headerFirstHeight, sectionHf?.headerEvenHeight, sectionHf?.headerDefaultHeight, pageInSection, globalPageNumber), this.selectStoryHeight(sectionHf?.footerFirstHeight, sectionHf?.footerEvenHeight, sectionHf?.footerDefaultHeight, pageInSection, globalPageNumber));
1138
+ }
1139
+ /**
1140
+ * The measured height of the running story this page position selects, mirroring
1141
+ * {@link selectHeader}/{@link selectFooter}. Zero when the page has no such story.
1142
+ */
1143
+ selectStoryHeight(first, even, fallback, pageInSection, globalPageNumber) {
1144
+ if (pageInSection === 1 && first != null)
1145
+ return first;
1146
+ if (globalPageNumber % 2 === 0 && even != null)
1147
+ return even;
1148
+ return fallback ?? 0;
1047
1149
  }
1048
1150
  /**
1049
1151
  * Measures the content height of a header or footer element.
1050
- * This is needed because headers/footers can contain more content than fits in the margin area.
1152
+ *
1153
+ * This is what tells {@link resolvePageBands} whether the story stays inside its margin or
1154
+ * pushes the body, so it must measure the story ALONE — any padding added here would have to
1155
+ * be added to the rendered band too, and the two drifting apart is exactly how a header
1156
+ * silently starts overlapping body text.
1051
1157
  */
1052
1158
  measureHeaderFooterHeight(source, contentWidth) {
1053
1159
  // Create a temporary measurement container
@@ -1056,8 +1162,6 @@ export class PaginationEngine {
1056
1162
  measureContainer.style.visibility = "hidden";
1057
1163
  measureContainer.style.width = `${contentWidth}pt`;
1058
1164
  measureContainer.style.left = "-9999px";
1059
- // Add padding to match the actual rendering
1060
- measureContainer.style.paddingBottom = "4pt";
1061
1165
  // Clone and add the header/footer content
1062
1166
  for (const child of Array.from(source.childNodes)) {
1063
1167
  measureContainer.appendChild(child.cloneNode(true));
@@ -1083,7 +1187,7 @@ export class PaginationEngine {
1083
1187
  // Track page number within this section for first-page header/footer selection
1084
1188
  let pageInSection = 1;
1085
1189
  // Get effective content height for first page (accounts for header/footer sizes)
1086
- let { contentHeight: effectiveContentHeight } = this.getEffectiveHeights(dims, sectionIndex, pageInSection, pageNumber);
1190
+ let { bodyHeight: effectiveContentHeight } = this.getPageBands(dims, sectionIndex, pageInSection, pageNumber);
1087
1191
  let remainingHeight = effectiveContentHeight;
1088
1192
  // Track the previous block's bottom margin for margin collapsing
1089
1193
  let prevMarginBottomPt = 0;
@@ -1123,8 +1227,8 @@ export class PaginationEngine {
1123
1227
  pageInSection++;
1124
1228
  currentContent = [];
1125
1229
  // Get effective content height for new page position
1126
- const newHeights = this.getEffectiveHeights(dims, sectionIndex, pageInSection, pageNumber);
1127
- effectiveContentHeight = newHeights.contentHeight;
1230
+ const newBands = this.getPageBands(dims, sectionIndex, pageInSection, pageNumber);
1231
+ effectiveContentHeight = newBands.bodyHeight;
1128
1232
  remainingHeight = effectiveContentHeight;
1129
1233
  prevMarginBottomPt = 0; // Reset margin tracking for new page
1130
1234
  currentFootnoteIds = []; // Reset footnotes for new page
@@ -1184,18 +1288,18 @@ export class PaginationEngine {
1184
1288
  const totalChainFootnoteHeight = this.measureFootnotesHeight([...currentFootnoteIds, ...newChainFootnoteIds], dims.contentWidth, currentContinuation);
1185
1289
  additionalChainFootnoteHeight = Math.max(0, totalChainFootnoteHeight - currentFootnoteHeight);
1186
1290
  }
1187
- const currentChainHeight = this.measureKeepWithNextChainBodyHeight(keepChain, prevMarginBottomPt, currentContent.length === 0) +
1291
+ const currentChainHeight = this.measureKeepWithNextChainBodyHeight(keepChain, prevMarginBottomPt, currentContent.length === 0, pageInSection) +
1188
1292
  additionalChainFootnoteHeight;
1189
1293
  const currentAvailableHeight = remainingHeight - currentFootnoteHeight;
1190
1294
  if (currentChainHeight > currentAvailableHeight) {
1191
- const nextPageHeights = this.getEffectiveHeights(dims, sectionIndex, pageInSection + 1, pageNumber + 1);
1192
- const freshChainBodyHeight = this.measureKeepWithNextChainBodyHeight(keepChain, 0, true);
1295
+ const nextPageBands = this.getPageBands(dims, sectionIndex, pageInSection + 1, pageNumber + 1);
1296
+ const freshChainBodyHeight = this.measureKeepWithNextChainBodyHeight(keepChain, 0, true, pageInSection + 1);
1193
1297
  // finishPage transfers this continuation to the new page's
1194
1298
  // currentContinuation state, so include it in the destination
1195
1299
  // page's footnote reservation before deciding to move the chain.
1196
1300
  const freshChainFootnoteHeight = this.measureFootnotesHeight(newChainFootnoteIds, dims.contentWidth, nextPageContinuation);
1197
1301
  if (freshChainBodyHeight + freshChainFootnoteHeight <=
1198
- nextPageHeights.contentHeight) {
1302
+ nextPageBands.bodyHeight) {
1199
1303
  finishPage();
1200
1304
  }
1201
1305
  }
@@ -1217,11 +1321,7 @@ export class PaginationEngine {
1217
1321
  // Calculate the effective height this block will consume
1218
1322
  // Account for margin collapsing: the gap between blocks is max(prevBottom, currTop), not sum
1219
1323
  const isFirstOnPage = currentContent.length === 0;
1220
- let effectiveMarginTop = block.marginTopPt;
1221
- if (!isFirstOnPage) {
1222
- // Margin collapsing: use the larger of the two adjacent margins
1223
- effectiveMarginTop = Math.max(block.marginTopPt, prevMarginBottomPt) - prevMarginBottomPt;
1224
- }
1324
+ const effectiveMarginTop = this.effectiveBlockMarginTop(block, prevMarginBottomPt, isFirstOnPage, pageInSection);
1225
1325
  // Visible height = top margin gap + content + footnote space
1226
1326
  // Note: bottom margin is NOT included in the fit check because the last block's
1227
1327
  // bottom margin extends beyond the content area and is clipped by overflow:hidden.
@@ -1248,7 +1348,7 @@ export class PaginationEngine {
1248
1348
  // Check if block fits on current page (including its footnotes)
1249
1349
  if (blockSpace <= effectiveRemainingHeight) {
1250
1350
  // Block fits with current footnote allocation
1251
- currentContent.push(block.element.cloneNode(true));
1351
+ currentContent.push(this.cloneBlockForPage(block, isFirstOnPage, pageInSection));
1252
1352
  remainingHeight -= (effectiveMarginTop + block.heightPt + block.marginBottomPt);
1253
1353
  prevMarginBottomPt = block.marginBottomPt;
1254
1354
  // Add new footnotes to current page
@@ -1257,7 +1357,8 @@ export class PaginationEngine {
1257
1357
  currentFootnoteHeight += additionalFootnoteHeight;
1258
1358
  }
1259
1359
  }
1260
- else if (block.heightPt + block.marginTopPt <= effectiveContentHeight) {
1360
+ else if (block.heightPt + this.effectiveBlockMarginTop(block, 0, true, pageInSection + 1) <=
1361
+ effectiveContentHeight) {
1261
1362
  // Block doesn't fit with current allocation - try expanding footnote area
1262
1363
  const blockSpaceWithoutFootnotes = effectiveMarginTop + block.heightPt;
1263
1364
  // Check if block fits if we expand footnote area
@@ -1266,7 +1367,7 @@ export class PaginationEngine {
1266
1367
  const canExpandFootnotes = minBodySpaceNeeded <= effectiveContentHeight;
1267
1368
  if (newFootnoteIds.length > 0 && blockSpaceWithoutFootnotes <= effectiveRemainingHeight) {
1268
1369
  // Block itself fits, but footnotes don't - expand footnote area
1269
- currentContent.push(block.element.cloneNode(true));
1370
+ currentContent.push(this.cloneBlockForPage(block, isFirstOnPage, pageInSection));
1270
1371
  remainingHeight -= (effectiveMarginTop + block.heightPt + block.marginBottomPt);
1271
1372
  prevMarginBottomPt = block.marginBottomPt;
1272
1373
  // Calculate EXPANDED space available for footnotes
@@ -1325,7 +1426,7 @@ export class PaginationEngine {
1325
1426
  const bodySpaceAfterExpansion = effectiveContentHeight - expandedFootnoteSpace;
1326
1427
  if (blockSpaceWithoutFootnotes <= bodySpaceAfterExpansion - bodyContentUsed) {
1327
1428
  // Block fits after expanding footnote area.
1328
- currentContent.push(block.element.cloneNode(true));
1429
+ currentContent.push(this.cloneBlockForPage(block, isFirstOnPage, pageInSection));
1329
1430
  // `remainingHeight` tracks BODY consumption only — every other branch maintains it
1330
1431
  // that way, and the footnote reserve is applied separately via `effectiveRemainingHeight`
1331
1432
  // at the top of each iteration. Assigning `bodySpaceAfterExpansion - …` here folded the
@@ -1343,8 +1444,9 @@ export class PaginationEngine {
1343
1444
  const newPageFootnoteHeight = allBlockFootnoteIds.length > 0
1344
1445
  ? this.measureFootnotesHeight(allBlockFootnoteIds, dims.contentWidth, currentContinuation)
1345
1446
  : (currentContinuation ? this.measureContinuationHeight(currentContinuation, dims.contentWidth) : 0);
1346
- const newPageSpace = block.marginTopPt + block.heightPt + block.marginBottomPt;
1347
- currentContent.push(block.element.cloneNode(true));
1447
+ const newPageMarginTop = this.effectiveBlockMarginTop(block, 0, true, pageInSection);
1448
+ const newPageSpace = newPageMarginTop + block.heightPt + block.marginBottomPt;
1449
+ currentContent.push(this.cloneBlockForPage(block, true, pageInSection));
1348
1450
  remainingHeight = effectiveContentHeight - newPageSpace;
1349
1451
  prevMarginBottomPt = block.marginBottomPt;
1350
1452
  // Merge, never replace: finishPage() may have just seeded this page with notes deferred
@@ -1361,9 +1463,9 @@ export class PaginationEngine {
1361
1463
  const newPageFootnoteHeight = allBlockFootnoteIds.length > 0
1362
1464
  ? this.measureFootnotesHeight(allBlockFootnoteIds, dims.contentWidth, currentContinuation)
1363
1465
  : (currentContinuation ? this.measureContinuationHeight(currentContinuation, dims.contentWidth) : 0);
1364
- // Include full top margin
1365
- const newPageSpace = block.marginTopPt + block.heightPt + block.marginBottomPt;
1366
- currentContent.push(block.element.cloneNode(true));
1466
+ const newPageMarginTop = this.effectiveBlockMarginTop(block, 0, true, pageInSection);
1467
+ const newPageSpace = newPageMarginTop + block.heightPt + block.marginBottomPt;
1468
+ currentContent.push(this.cloneBlockForPage(block, true, pageInSection));
1367
1469
  remainingHeight = effectiveContentHeight - newPageSpace;
1368
1470
  prevMarginBottomPt = block.marginBottomPt;
1369
1471
  // Merge, never replace: finishPage() may have just seeded this page with notes deferred
@@ -1390,7 +1492,7 @@ export class PaginationEngine {
1390
1492
  if (currentContent.length > 0) {
1391
1493
  finishPage();
1392
1494
  }
1393
- currentContent.push(block.element.cloneNode(true));
1495
+ currentContent.push(this.cloneBlockForPage(block, true, pageInSection));
1394
1496
  // Merge, never replace: finishPage() may have just seeded this page with notes deferred
1395
1497
  // from the previous one, and overwriting here dropped them from the document.
1396
1498
  currentFootnoteIds = [...currentFootnoteIds, ...allBlockFootnoteIds];
@@ -1425,6 +1527,144 @@ export class PaginationEngine {
1425
1527
  el.setAttribute("contenteditable", "false");
1426
1528
  }
1427
1529
  }
1530
+ /**
1531
+ * Resolves floating DrawingML objects after their anchor paragraphs have landed on a page.
1532
+ *
1533
+ * The converter deliberately carries the OOXML bases and offsets as data instead of flattening
1534
+ * them into CSS: page/margin/column bases belong to the page, while paragraph/line/character
1535
+ * bases belong to the laid-out anchor. Once both coordinate systems exist, promote the object
1536
+ * from the clipped text column into the page box and position it in one shared point space.
1537
+ */
1538
+ positionDrawingAnchors(pageBox, contentArea, dims, pageNumber) {
1539
+ const anchors = Array.from(contentArea.querySelectorAll('[data-docx-drawing-anchor="true"]'));
1540
+ if (anchors.length === 0)
1541
+ return;
1542
+ const pageRect = pageBox.getBoundingClientRect();
1543
+ const pixelsPerPoint = pageRect.width / dims.pageWidth;
1544
+ if (!(pixelsPerPoint > 0))
1545
+ return;
1546
+ for (const anchor of anchors) {
1547
+ const staticRect = anchor.getBoundingClientRect();
1548
+ const paragraph = anchor.closest('p, h1, h2, h3, h4, h5, h6');
1549
+ const paragraphRect = paragraph?.getBoundingClientRect() ?? staticRect;
1550
+ const toPageX = (x) => (x - pageRect.left) / pixelsPerPoint;
1551
+ const toPageY = (y) => (y - pageRect.top) / pixelsPerPoint;
1552
+ const context = {
1553
+ staticLeft: toPageX(staticRect.left),
1554
+ staticTop: toPageY(staticRect.top),
1555
+ lineHeight: paragraph
1556
+ ? (parseFloat(getComputedStyle(paragraph).lineHeight) || staticRect.height) / pixelsPerPoint
1557
+ : staticRect.height / pixelsPerPoint,
1558
+ paragraphLeft: toPageX(paragraphRect.left),
1559
+ paragraphTop: toPageY(paragraphRect.top),
1560
+ paragraphWidth: paragraphRect.width / pixelsPerPoint,
1561
+ paragraphHeight: paragraphRect.height / pixelsPerPoint,
1562
+ };
1563
+ const widthReference = this.horizontalAnchorReference(anchor.getAttribute('data-docx-anchor-width-relative') ?? 'margin', dims, pageNumber, context);
1564
+ const widthPercent = this.anchorNumber(anchor, 'width-percent');
1565
+ if (widthPercent !== undefined) {
1566
+ anchor.style.width = `${widthReference.size * widthPercent / 100}pt`;
1567
+ }
1568
+ const heightReference = this.verticalAnchorReference(anchor.getAttribute('data-docx-anchor-height-relative') ?? 'margin', dims, pageNumber, context);
1569
+ const heightPercent = this.anchorNumber(anchor, 'height-percent');
1570
+ if (heightPercent !== undefined && anchor.dataset.docxAnchorAutofit !== 'true') {
1571
+ anchor.style.height = `${heightReference.size * heightPercent / 100}pt`;
1572
+ }
1573
+ // Read the final border-box size after relative sizing and before reparenting. The stored
1574
+ // wp:extent remains the fallback; wps:bodyPr insets stay inside it through border-box sizing.
1575
+ const sizedRect = anchor.getBoundingClientRect();
1576
+ const width = sizedRect.width / pixelsPerPoint;
1577
+ const height = sizedRect.height / pixelsPerPoint;
1578
+ const horizontal = this.horizontalAnchorReference(anchor.getAttribute('data-docx-anchor-h-relative') ?? 'column', dims, pageNumber, context);
1579
+ const vertical = this.verticalAnchorReference(anchor.getAttribute('data-docx-anchor-v-relative') ?? 'paragraph', dims, pageNumber, context);
1580
+ const left = this.resolveAnchorAxis(horizontal, width, anchor.getAttribute('data-docx-anchor-h-align'), this.anchorNumber(anchor, 'h-offset'), pageNumber);
1581
+ const top = this.resolveAnchorAxis(vertical, height, anchor.getAttribute('data-docx-anchor-v-align'), this.anchorNumber(anchor, 'v-offset'), pageNumber);
1582
+ pageBox.appendChild(anchor);
1583
+ anchor.style.position = 'absolute';
1584
+ anchor.style.left = `${left}pt`;
1585
+ anchor.style.top = `${top}pt`;
1586
+ anchor.style.margin = '0';
1587
+ }
1588
+ }
1589
+ anchorNumber(anchor, suffix) {
1590
+ const raw = anchor.getAttribute(`data-docx-anchor-${suffix}`);
1591
+ if (raw === null)
1592
+ return undefined;
1593
+ const value = Number(raw);
1594
+ return Number.isFinite(value) ? value : undefined;
1595
+ }
1596
+ horizontalAnchorReference(relativeFrom, dims, pageNumber, context) {
1597
+ const leftMargin = { start: 0, size: dims.marginLeft };
1598
+ const rightMargin = {
1599
+ start: dims.marginLeft + dims.contentWidth,
1600
+ size: dims.marginRight,
1601
+ };
1602
+ switch (relativeFrom) {
1603
+ case 'page':
1604
+ return { start: 0, size: dims.pageWidth };
1605
+ case 'character':
1606
+ return { start: context.staticLeft, size: 0 };
1607
+ case 'leftMargin':
1608
+ return leftMargin;
1609
+ case 'rightMargin':
1610
+ return rightMargin;
1611
+ case 'insideMargin':
1612
+ return pageNumber % 2 === 1 ? leftMargin : rightMargin;
1613
+ case 'outsideMargin':
1614
+ return pageNumber % 2 === 1 ? rightMargin : leftMargin;
1615
+ case 'paragraph':
1616
+ // Not part of ST_RelFromH, but accepting it gives malformed/legacy producers the
1617
+ // intuitive base without conflating it with the whole text column.
1618
+ return { start: context.paragraphLeft, size: context.paragraphWidth };
1619
+ case 'column':
1620
+ case 'margin':
1621
+ default:
1622
+ // The paginator currently lays out one text column per section, so the column and page
1623
+ // margin boxes coincide. Keeping the names distinct here leaves multi-column geometry
1624
+ // localized to this resolver when that layout support is added.
1625
+ return { start: dims.marginLeft, size: dims.contentWidth };
1626
+ }
1627
+ }
1628
+ verticalAnchorReference(relativeFrom, dims, pageNumber, context) {
1629
+ const topMargin = { start: 0, size: dims.marginTop };
1630
+ const bottomMargin = {
1631
+ start: dims.marginTop + dims.contentHeight,
1632
+ size: dims.marginBottom,
1633
+ };
1634
+ switch (relativeFrom) {
1635
+ case 'page':
1636
+ return { start: 0, size: dims.pageHeight };
1637
+ case 'paragraph':
1638
+ return { start: context.paragraphTop, size: context.paragraphHeight };
1639
+ case 'line':
1640
+ return { start: context.staticTop, size: context.lineHeight };
1641
+ case 'topMargin':
1642
+ return topMargin;
1643
+ case 'bottomMargin':
1644
+ return bottomMargin;
1645
+ case 'insideMargin':
1646
+ return pageNumber % 2 === 1 ? topMargin : bottomMargin;
1647
+ case 'outsideMargin':
1648
+ return pageNumber % 2 === 1 ? bottomMargin : topMargin;
1649
+ case 'margin':
1650
+ default:
1651
+ return { start: dims.marginTop, size: dims.contentHeight };
1652
+ }
1653
+ }
1654
+ resolveAnchorAxis(reference, objectSize, alignment, offset, pageNumber) {
1655
+ if (offset !== undefined)
1656
+ return reference.start + offset;
1657
+ let fraction = 0;
1658
+ if (alignment === 'center')
1659
+ fraction = 0.5;
1660
+ else if (alignment === 'right' || alignment === 'bottom')
1661
+ fraction = 1;
1662
+ else if (alignment === 'inside')
1663
+ fraction = pageNumber % 2 === 1 ? 0 : 1;
1664
+ else if (alignment === 'outside')
1665
+ fraction = pageNumber % 2 === 1 ? 1 : 0;
1666
+ return reference.start + (reference.size - objectSize) * fraction;
1667
+ }
1428
1668
  /**
1429
1669
  * Creates a page container element.
1430
1670
  */
@@ -1464,24 +1704,27 @@ export class PaginationEngine {
1464
1704
  // Needed by substitutePageNumberFields: a section that restarts numbering counts from its own
1465
1705
  // first page, not from the document's.
1466
1706
  pageBox.dataset.pageInSection = String(pageInSection);
1467
- // Get pre-computed effective heights for this page position (no re-measurement needed)
1468
- const effectiveHeights = this.getEffectiveHeights(dims, sectionIndex, pageInSection, pageNumber);
1707
+ // Where the three bands sit on this page (no re-measurement needed)
1708
+ const bands = this.getPageBands(dims, sectionIndex, pageInSection, pageNumber);
1469
1709
  // Add header if available for this section/page
1470
1710
  const headerSource = this.selectHeader(sectionIndex, pageInSection, pageNumber);
1471
1711
  if (headerSource) {
1472
1712
  const headerDiv = document.createElement("div");
1473
1713
  headerDiv.className = `${this.cssPrefix}header`;
1474
1714
  headerDiv.style.position = "absolute";
1475
- headerDiv.style.top = "0"; // Start at page top
1715
+ // `w:header` is the distance to the TOP of the story, and the story grows downward from
1716
+ // there; `flex-start` therefore pins the edge the OOXML actually declares, and content
1717
+ // taller than the measurement clips at the bottom rather than sliding up the page.
1718
+ headerDiv.style.top = `${bands.headerTop}pt`;
1719
+ headerDiv.style.bottom = "auto";
1476
1720
  headerDiv.style.left = `${dims.marginLeft}pt`;
1477
1721
  headerDiv.style.width = `${dims.contentWidth}pt`;
1478
- headerDiv.style.height = `${effectiveHeights.headerHeight}pt`; // Use pre-computed effective height
1722
+ headerDiv.style.height = `${bands.headerHeight}pt`;
1479
1723
  headerDiv.style.overflow = "hidden";
1480
1724
  headerDiv.style.boxSizing = "border-box";
1481
1725
  headerDiv.style.display = "flex";
1482
1726
  headerDiv.style.flexDirection = "column";
1483
- headerDiv.style.justifyContent = "flex-end"; // Align content to bottom of header area
1484
- headerDiv.style.paddingBottom = "4pt"; // Small gap before content area
1727
+ headerDiv.style.justifyContent = "flex-start";
1485
1728
  // Clone the header content (skip the wrapper div's data attributes)
1486
1729
  for (const child of Array.from(headerSource.childNodes)) {
1487
1730
  const clonedheaderDiv = child.cloneNode(true);
@@ -1492,8 +1735,8 @@ export class PaginationEngine {
1492
1735
  pageBox.appendChild(headerDiv);
1493
1736
  }
1494
1737
  // Create content area using pre-computed effective heights
1495
- const contentAreaTop = effectiveHeights.headerHeight;
1496
- const contentAreaHeight = effectiveHeights.contentHeight;
1738
+ const contentAreaTop = bands.bodyTop;
1739
+ const contentAreaHeight = bands.bodyHeight;
1497
1740
  const contentArea = document.createElement("div");
1498
1741
  contentArea.className = `${this.cssPrefix}content`;
1499
1742
  contentArea.style.position = "absolute";
@@ -1510,7 +1753,7 @@ export class PaginationEngine {
1510
1753
  // Add footnotes if any references appear on this page (or continuation from previous)
1511
1754
  const hasContinuation = continuation && continuation.remainingElements.length > 0;
1512
1755
  if (footnoteIds.length > 0 || hasContinuation) {
1513
- this.addPageFootnotes(pageBox, footnoteIds, dims, footnoteHeight, continuation, partialFootnotes);
1756
+ this.addPageFootnotes(pageBox, footnoteIds, dims, bands, footnoteHeight, continuation, partialFootnotes);
1514
1757
  }
1515
1758
  // Add footer if available for this section/page
1516
1759
  const footerSource = this.selectFooter(sectionIndex, pageInSection, pageNumber);
@@ -1518,16 +1761,18 @@ export class PaginationEngine {
1518
1761
  const footerDiv = document.createElement("div");
1519
1762
  footerDiv.className = `${this.cssPrefix}footer`;
1520
1763
  footerDiv.style.position = "absolute";
1521
- footerDiv.style.bottom = "0"; // Start at page bottom
1764
+ // `w:footer` is the distance to the BOTTOM of the story, and the story grows upward from
1765
+ // there — the mirror of the header, so `flex-end` and a bottom anchor.
1766
+ footerDiv.style.top = "auto";
1767
+ footerDiv.style.bottom = `${dims.footerDistance}pt`;
1522
1768
  footerDiv.style.left = `${dims.marginLeft}pt`;
1523
1769
  footerDiv.style.width = `${dims.contentWidth}pt`;
1524
- footerDiv.style.height = `${effectiveHeights.footerHeight}pt`; // Use pre-computed effective height
1770
+ footerDiv.style.height = `${bands.footerHeight}pt`;
1525
1771
  footerDiv.style.overflow = "hidden";
1526
1772
  footerDiv.style.boxSizing = "border-box";
1527
1773
  footerDiv.style.display = "flex";
1528
1774
  footerDiv.style.flexDirection = "column";
1529
- footerDiv.style.justifyContent = "flex-start"; // Align content to top of footer area
1530
- footerDiv.style.paddingTop = "4pt"; // Small gap after content area
1775
+ footerDiv.style.justifyContent = "flex-end";
1531
1776
  // Clone the footer content (skip the wrapper div's data attributes)
1532
1777
  for (const child of Array.from(footerSource.childNodes)) {
1533
1778
  const clonedfooterDiv = child.cloneNode(true);
@@ -1546,6 +1791,9 @@ export class PaginationEngine {
1546
1791
  }
1547
1792
  // Add to container
1548
1793
  this.containerElement.appendChild(pageBox);
1794
+ // Floating objects need the final page and anchor-paragraph boxes. Resolve them only after
1795
+ // append, when getBoundingClientRect() is meaningful, and lift them out of content clipping.
1796
+ this.positionDrawingAnchors(pageBox, contentArea, dims, pageNumber);
1549
1797
  // Body and notes must occupy DISJOINT bands. The note block is absolutely positioned against
1550
1798
  // the page bottom and grows upward, while the content area spans the whole text height — so
1551
1799
  // nothing but the layout loop's arithmetic keeps them apart, and any disagreement between the