docxodus 12.6.4 → 13.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 (92) hide show
  1. package/README.md +8 -5
  2. package/dist/canonical.d.ts.map +1 -1
  3. package/dist/canonical.js +7 -3
  4. package/dist/canonical.js.map +1 -1
  5. package/dist/conversion-options.d.ts +16 -0
  6. package/dist/conversion-options.d.ts.map +1 -0
  7. package/dist/conversion-options.js +31 -0
  8. package/dist/conversion-options.js.map +1 -0
  9. package/dist/core.d.ts +17 -9
  10. package/dist/core.d.ts.map +1 -1
  11. package/dist/core.js +23 -24
  12. package/dist/core.js.map +1 -1
  13. package/dist/docxodus.worker.js +2 -1073
  14. package/dist/docxodus.worker.js.map +7 -1
  15. package/dist/editor.bundle.js +35 -14296
  16. package/dist/editor.bundle.js.map +7 -0
  17. package/dist/editor.d.ts +33 -2
  18. package/dist/editor.d.ts.map +1 -1
  19. package/dist/editor.js +6739 -4773
  20. package/dist/editor.js.map +7 -1
  21. package/dist/embed.bundle.js +159 -18703
  22. package/dist/embed.bundle.js.map +7 -0
  23. package/dist/embed.d.ts +1 -1
  24. package/dist/embed.iife.js +37 -18595
  25. package/dist/embed.iife.js.map +7 -0
  26. package/dist/embed.js +1 -1
  27. package/dist/export-assets.json +27 -27
  28. package/dist/export-browser.bundle.js +1388 -477
  29. package/dist/export-browser.d.ts +20 -0
  30. package/dist/export-browser.d.ts.map +1 -1
  31. package/dist/export-browser.js +132 -37
  32. package/dist/export-browser.js.map +1 -1
  33. package/dist/font-runtime.d.ts.map +1 -1
  34. package/dist/font-runtime.js +54 -0
  35. package/dist/font-runtime.js.map +1 -1
  36. package/dist/line-metrics.d.ts +60 -0
  37. package/dist/line-metrics.d.ts.map +1 -0
  38. package/dist/line-metrics.js +343 -0
  39. package/dist/line-metrics.js.map +1 -0
  40. package/dist/pagination.bundle.js +3 -3214
  41. package/dist/pagination.bundle.js.map +7 -0
  42. package/dist/pagination.d.ts +86 -9
  43. package/dist/pagination.d.ts.map +1 -1
  44. package/dist/pagination.js +358 -55
  45. package/dist/pagination.js.map +1 -1
  46. package/dist/react.d.ts.map +1 -1
  47. package/dist/react.js +63 -12
  48. package/dist/react.js.map +1 -1
  49. package/dist/render-report-v2.schema.json +29 -0
  50. package/dist/session.bundle.js +2 -2013
  51. package/dist/session.bundle.js.map +7 -0
  52. package/dist/session.d.ts +14 -5
  53. package/dist/session.d.ts.map +1 -1
  54. package/dist/session.js +62 -29
  55. package/dist/session.js.map +1 -1
  56. package/dist/types.d.ts +137 -21
  57. package/dist/types.d.ts.map +1 -1
  58. package/dist/types.js +14 -0
  59. package/dist/types.js.map +1 -1
  60. package/dist/wasm/_framework/Docxodus.wasm +0 -0
  61. package/dist/wasm/_framework/Docxodus.wasm.br +0 -0
  62. package/dist/wasm/_framework/DocxodusWasm.wasm +0 -0
  63. package/dist/wasm/_framework/DocxodusWasm.wasm.br +0 -0
  64. package/dist/wasm/_framework/System.Collections.wasm +0 -0
  65. package/dist/wasm/_framework/System.Collections.wasm.br +0 -0
  66. package/dist/wasm/_framework/System.IO.Compression.wasm +0 -0
  67. package/dist/wasm/_framework/System.IO.Compression.wasm.br +0 -0
  68. package/dist/wasm/_framework/System.Linq.wasm +0 -0
  69. package/dist/wasm/_framework/System.Linq.wasm.br +0 -0
  70. package/dist/wasm/_framework/System.Memory.wasm +0 -0
  71. package/dist/wasm/_framework/System.Memory.wasm.br +0 -0
  72. package/dist/wasm/_framework/System.Private.CoreLib.wasm +0 -0
  73. package/dist/wasm/_framework/System.Private.CoreLib.wasm.br +0 -0
  74. package/dist/wasm/_framework/System.Private.Xml.Linq.wasm +0 -0
  75. package/dist/wasm/_framework/System.Private.Xml.Linq.wasm.br +0 -0
  76. package/dist/wasm/_framework/System.Text.RegularExpressions.wasm +0 -0
  77. package/dist/wasm/_framework/System.Text.RegularExpressions.wasm.br +0 -0
  78. package/dist/wasm/_framework/dotnet.boot.js +11 -11
  79. package/dist/wasm/_framework/dotnet.boot.js.br +0 -0
  80. package/dist/wasm/_framework/dotnet.native.wasm +0 -0
  81. package/dist/wasm/_framework/dotnet.native.wasm.br +0 -0
  82. package/dist/word-text.d.ts +14 -0
  83. package/dist/word-text.d.ts.map +1 -0
  84. package/dist/word-text.js +377 -0
  85. package/dist/word-text.js.map +1 -0
  86. package/dist/worker-proxy.bundle.js +2 -536
  87. package/dist/worker-proxy.bundle.js.map +7 -0
  88. package/dist/worker-proxy.d.ts +14 -1
  89. package/dist/worker-proxy.d.ts.map +1 -1
  90. package/dist/worker-proxy.js +357 -339
  91. package/dist/worker-proxy.js.map +1 -1
  92. package/package.json +13 -12
@@ -5,6 +5,7 @@
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 { alignExactLineBaselines, insetClipBounds, preserveExactLineInk } from "./line-metrics.js";
8
9
  import { DEFAULT_MARGIN, DEFAULT_PAGE_WIDTH, parseSectionDimensions, ptToPx, pxToPt, resolvePageBands, } from "./page-geometry.js";
9
10
  /**
10
11
  * Whether two sections describe the same physical page box. A continuous section
@@ -124,6 +125,198 @@ const MAX_FOOTNOTE_AREA_RATIO = 0.6; // 60% of content height
124
125
  const FOOTNOTE_MEASUREMENT_GUARD_PT = 2;
125
126
  /** Internal provenance retained only while a note element waits between pages. */
126
127
  const FOOTNOTE_SOURCE_POSITION_ATTR = "data-pagination-footnote-source-position";
128
+ /**
129
+ * The top and bottom margins a block occupies in its parent's flow, in CSS px, AFTER CSS margin
130
+ * collapsing through its first and last in-flow children (CSS 2.1 §8.3.1). getBoundingClientRect()
131
+ * excludes such a collapsed-through margin, and getComputedStyle() reports only the element's own:
132
+ * the converter wraps a table in an unstyled `div`, so the table's own top margin collapses
133
+ * through the wrapper and occupies page height that neither of those reads accounted for. The
134
+ * paginator's budget then fell a few pixels to a line short of the laid-out page, and the export
135
+ * refused the clipped page (issue #848).
136
+ *
137
+ * A margin collapses with a parent's while the parent is an ordinary block box (display block or
138
+ * list-item, overflow visible, not floated or absolutely positioned — anything else establishes
139
+ * its own formatting context) with no border or padding on that side, the child is the parent's
140
+ * first (last) in-flow block-level child, and no line content precedes (follows) it. For the
141
+ * bottom edge the parent must also have an automatic height. Positive margins combine by maximum
142
+ * and negative ones by minimum, as CSS collapses them.
143
+ */
144
+ export function collapsedBlockMarginsPx(view, element) {
145
+ return {
146
+ topPx: collapseMarginChain(view, element, "top"),
147
+ bottomPx: collapseMarginChain(view, element, "bottom"),
148
+ };
149
+ }
150
+ function collapseMarginChain(view, element, edge) {
151
+ const margins = [];
152
+ let current = element;
153
+ while (current) {
154
+ const style = view.getComputedStyle(current);
155
+ margins.push(parseFloat(edge === "top" ? style.marginTop : style.marginBottom) || 0);
156
+ current = collapsesThroughTo(view, current, style, edge);
157
+ }
158
+ const positive = Math.max(0, ...margins);
159
+ const negative = Math.min(0, ...margins);
160
+ return positive + negative;
161
+ }
162
+ /** The child whose `edge` margin collapses through `parent`'s, or null when none does. */
163
+ function collapsesThroughTo(view, parent, style, edge) {
164
+ const ordinaryBlock = (style.display === "block" || style.display === "list-item")
165
+ && style.overflowX === "visible" && style.overflowY === "visible"
166
+ && style.float === "none" && style.position !== "absolute" && style.position !== "fixed";
167
+ if (!ordinaryBlock)
168
+ return null;
169
+ // Column boxes establish a formatting context: child margins stay inside the multicol
170
+ // box and are already included in its measured height (CSS Multi-column Layout §2).
171
+ if (style.columnCount !== "auto" || style.columnWidth !== "auto")
172
+ return null;
173
+ const border = parseFloat(edge === "top" ? style.borderTopWidth : style.borderBottomWidth) || 0;
174
+ const padding = parseFloat(edge === "top" ? style.paddingTop : style.paddingBottom) || 0;
175
+ if (border !== 0 || padding !== 0)
176
+ return null;
177
+ if (edge === "bottom" && parent.style.height !== "")
178
+ return null;
179
+ const nodes = Array.from(parent.childNodes);
180
+ if (edge === "bottom")
181
+ nodes.reverse();
182
+ for (const node of nodes) {
183
+ if (node.nodeType === Node.TEXT_NODE) {
184
+ if ((node.textContent ?? "").trim() !== "")
185
+ return null; // line content comes first
186
+ continue;
187
+ }
188
+ if (node.nodeType !== Node.ELEMENT_NODE)
189
+ continue;
190
+ const child = node;
191
+ const childStyle = view.getComputedStyle(child);
192
+ if (childStyle.display === "none")
193
+ continue;
194
+ if (childStyle.position === "absolute" || childStyle.position === "fixed" || childStyle.float !== "none")
195
+ continue;
196
+ const blockLevel = childStyle.display === "block" || childStyle.display === "list-item"
197
+ || childStyle.display === "table" || childStyle.display === "flow-root"
198
+ || childStyle.display === "flex" || childStyle.display === "grid";
199
+ return blockLevel ? child : null; // an inline-level child starts a line box
200
+ }
201
+ return null;
202
+ }
203
+ /**
204
+ * Set by the paginator on a floating drawing it promotes out of its paragraph into the page box;
205
+ * the value is the `data-source-anchor-id` of the element that hosted it. It survives into the
206
+ * standalone export, whose offline re-verification measures through the same function.
207
+ */
208
+ export const DRAWING_HOST_ATTRIBUTE = "data-docx-anchor-host";
209
+ /**
210
+ * Set on a source-anchored element a floating drawing was promoted out of, so
211
+ * {@link measureRenderedSource} looks up promoted drawings only for elements that have them.
212
+ */
213
+ export const HOSTS_PROMOTED_DRAWING_ATTRIBUTE = "data-docx-hosts-promoted-drawing";
214
+ /** True when `element` shows no text (beyond white space) and no image, SVG or canvas. */
215
+ function rendersOnlyItsLine(element) {
216
+ return (element.textContent ?? '').replace(/[\s\u00a0]/g, '') === ''
217
+ && element.querySelector('img, svg, canvas, [data-docx-drawing-anchor]') === null;
218
+ }
219
+ /** Self-contained closure for trusted host evaluation in the script-disabled print context. */
220
+ function createRenderedSourceMeasurer(clipInset, drawingHostAttribute, hostsPromotedDrawingAttribute) {
221
+ /**
222
+ * Intersect a box with the page and with every ancestor that establishes an overflow clip before
223
+ * the page root. getBoundingClientRect() reports layout outside those clips, which is not rendered
224
+ * and therefore must not satisfy PageMap completeness or inflate portable geometry.
225
+ */
226
+ function visibleWithinPage(view, element, page, pageRect, rect) {
227
+ let left = Math.max(rect.left, pageRect.left);
228
+ let top = Math.max(rect.top, pageRect.top);
229
+ let right = Math.min(rect.right, pageRect.right);
230
+ let bottom = Math.min(rect.bottom, pageRect.bottom);
231
+ const clips = (value) => value === "hidden" || value === "clip" || value === "scroll" || value === "auto";
232
+ for (let ancestor = element.parentElement; ancestor && ancestor !== page; ancestor = ancestor.parentElement) {
233
+ const style = view.getComputedStyle(ancestor);
234
+ const clipsX = clips(style.overflowX);
235
+ const clipsY = clips(style.overflowY);
236
+ if (!clipsX && !clipsY && !style.clipPath.startsWith("inset("))
237
+ continue;
238
+ const ancestorRect = ancestor.getBoundingClientRect();
239
+ const scale = ancestorRect.width / ancestor.offsetWidth || 1;
240
+ const inset = clipInset(style.clipPath, ancestorRect, scale);
241
+ if (inset) {
242
+ left = Math.max(left, inset.left);
243
+ top = Math.max(top, inset.top);
244
+ right = Math.min(right, inset.right);
245
+ bottom = Math.min(bottom, inset.bottom);
246
+ }
247
+ if (clipsX) {
248
+ left = Math.max(left, ancestorRect.left);
249
+ right = Math.min(right, ancestorRect.right);
250
+ }
251
+ if (clipsY) {
252
+ top = Math.max(top, ancestorRect.top);
253
+ bottom = Math.min(bottom, ancestorRect.bottom);
254
+ }
255
+ }
256
+ return { left, top, right, bottom };
257
+ }
258
+ return function measureRenderedSource(view, element, page, pageRect = page.getBoundingClientRect()) {
259
+ const own = element.getBoundingClientRect();
260
+ const ownResult = () => ({ rect: own, visible: visibleWithinPage(view, element, page, pageRect, own) });
261
+ const ownMeasurable = own.width > 0 && own.height > 0;
262
+ const hostsPromotedDrawing = element.hasAttribute(hostsPromotedDrawingAttribute);
263
+ if (ownMeasurable && !hostsPromotedDrawing)
264
+ return ownResult();
265
+ const hostAnchorId = element.dataset.sourceAnchorId;
266
+ const promoted = hostAnchorId
267
+ ? Array.from(page.querySelectorAll(`[${drawingHostAttribute}]`))
268
+ .filter((drawing) => drawing.getAttribute(drawingHostAttribute) === hostAnchorId)
269
+ : [];
270
+ if (ownMeasurable && promoted.length === 0)
271
+ return ownResult();
272
+ const rendered = [
273
+ ...(ownMeasurable ? [element] : Array.from(element.querySelectorAll("*"))),
274
+ ...promoted.flatMap((drawing) => [drawing, ...Array.from(drawing.querySelectorAll("*"))]),
275
+ ];
276
+ let left = Infinity;
277
+ let top = Infinity;
278
+ let right = -Infinity;
279
+ let bottom = -Infinity;
280
+ for (const child of rendered) {
281
+ const style = view.getComputedStyle(child);
282
+ if (style.display === "none" || style.visibility === "hidden")
283
+ continue;
284
+ const childRect = child.getBoundingClientRect();
285
+ if (childRect.width <= 0 || childRect.height <= 0)
286
+ continue;
287
+ const visible = visibleWithinPage(view, child, page, pageRect, childRect);
288
+ if (visible.right <= visible.left || visible.bottom <= visible.top)
289
+ continue;
290
+ left = Math.min(left, visible.left);
291
+ top = Math.min(top, visible.top);
292
+ right = Math.max(right, visible.right);
293
+ bottom = Math.max(bottom, visible.bottom);
294
+ }
295
+ if (!(left < right && top < bottom))
296
+ return ownResult();
297
+ return { rect: new DOMRect(left, top, right - left, bottom - top), visible: { left, top, right, bottom } };
298
+ };
299
+ }
300
+ /**
301
+ * Where a source-anchored element renders on `page`: `rect` is its layout box and `visible` the
302
+ * part of it the page shows. Normally that is the element's own border box. An element the
303
+ * paginator promoted floating drawings out of measures as the union of what it renders — its own
304
+ * box (or, when that is empty, its descendants) and the drawings promoted out of it (linked by
305
+ * {@link DRAWING_HOST_ATTRIBUTE}) — each clipped along its OWN ancestor chain, since a promoted
306
+ * drawing sits outside the text column that clips its host (issue #849). Such a paragraph keeps a
307
+ * line of its own when the drawing was its only content (issue #880), and its fragment still
308
+ * encloses the drawing. Returns the element's own degenerate box when none of that renders.
309
+ *
310
+ * Fragment-identity normalization, PageMap materialization and the standalone export's offline
311
+ * re-verification all measure through this one function, so they agree on which elements are
312
+ * measurable and where.
313
+ */
314
+ export const measureRenderedSource = createRenderedSourceMeasurer(insetClipBounds, DRAWING_HOST_ATTRIBUTE, HOSTS_PROMOTED_DRAWING_ATTRIBUTE);
315
+ /** @internal Owned runtime code only; never inserts a script or enables document JavaScript. */
316
+ export function renderedSourceMeasurerExpression() {
317
+ return `(${createRenderedSourceMeasurer.toString()})(${insetClipBounds.toString()},
318
+ ${JSON.stringify(DRAWING_HOST_ATTRIBUTE)}, ${JSON.stringify(HOSTS_PROMOTED_DRAWING_ATTRIBUTE)})`;
319
+ }
127
320
  export class PaginationEngine {
128
321
  /**
129
322
  * Creates a new pagination engine.
@@ -177,6 +370,7 @@ export class PaginationEngine {
177
370
  this.pageGap = options.pageGap ?? 20;
178
371
  this.fragmentParagraphs = options.fragmentParagraphs ?? false;
179
372
  this.deferFragmentIdentities = options.deferFragmentIdentities ?? false;
373
+ this.bodyBudgetReductionsPt = options.bodyBudgetReductionsPt ?? {};
180
374
  this.cancellationCheckpoint = options.checkCancellation;
181
375
  this.pageCountCheckpoint = options.checkPageCount;
182
376
  this.layoutToken = options.layoutToken;
@@ -324,7 +518,8 @@ export class PaginationEngine {
324
518
  const columnCount = parseInt(section.dataset.cols || "1", 10);
325
519
  if (columnCount > 1) {
326
520
  const gap = parseFloat(section.dataset.colGap || "");
327
- blocks.push(...this.buildColumnBlocks(section, sectionDims, sectionIndex, columnCount, Number.isFinite(gap) ? gap : 36));
521
+ blocks.push(...this.buildColumnBlocks(section, sectionDims, sectionIndex, columnCount, Number.isFinite(gap) ? gap : 36, section.dataset.sectionType === "continuous"
522
+ && section === sectionsToProcess[sectionsToProcess.length - 1]));
328
523
  }
329
524
  else {
330
525
  blocks.push(...this.measureBlocks(section, sectionDims, sectionIndex));
@@ -440,13 +635,12 @@ export class PaginationEngine {
440
635
  || !Number.isInteger(parseInt(element.dataset.fragmentIndex || "", 10))) {
441
636
  throw new Error(`page ${page.pageNumber} contains an unqualified source anchor`);
442
637
  }
443
- const rect = element.getBoundingClientRect();
444
638
  const style = this.view.getComputedStyle(element);
445
639
  const deliberatelyHidden = style.display === "none" || style.visibility === "hidden";
446
640
  if (deliberatelyHidden)
447
641
  continue;
642
+ const { rect, visible: visibleRect } = measureRenderedSource(this.view, element, page.element, pageRect);
448
643
  requiredAnchorIds.add(anchorId);
449
- const visibleRect = this.intersectWithClippingAncestors(element, page.element, pageRect, rect);
450
644
  const left = visibleRect.left;
451
645
  const top = visibleRect.top;
452
646
  const right = visibleRect.right;
@@ -513,8 +707,7 @@ export class PaginationEngine {
513
707
  const style = this.view.getComputedStyle(element);
514
708
  if (style.display === "none" || style.visibility === "hidden")
515
709
  continue;
516
- const rect = element.getBoundingClientRect();
517
- const visible = this.intersectWithClippingAncestors(element, page.element, pageRect, rect);
710
+ const { rect, visible } = measureRenderedSource(this.view, element, page.element, pageRect);
518
711
  if (rect.width <= 0 || rect.height <= 0
519
712
  || visible.right <= visible.left || visible.bottom <= visible.top)
520
713
  continue;
@@ -526,35 +719,6 @@ export class PaginationEngine {
526
719
  }
527
720
  }
528
721
  }
529
- /**
530
- * Intersect an element with every ancestor that establishes an overflow clip before the page
531
- * root. getBoundingClientRect() reports layout outside those clips, which is not rendered and
532
- * therefore must not satisfy PageMap completeness or inflate portable geometry.
533
- */
534
- intersectWithClippingAncestors(element, page, pageRect, rect) {
535
- let left = Math.max(rect.left, pageRect.left);
536
- let top = Math.max(rect.top, pageRect.top);
537
- let right = Math.min(rect.right, pageRect.right);
538
- let bottom = Math.min(rect.bottom, pageRect.bottom);
539
- const clips = (value) => value === "hidden" || value === "clip" || value === "scroll" || value === "auto";
540
- for (let ancestor = element.parentElement; ancestor && ancestor !== page; ancestor = ancestor.parentElement) {
541
- const style = this.view.getComputedStyle(ancestor);
542
- const clipsX = clips(style.overflowX);
543
- const clipsY = clips(style.overflowY);
544
- if (!clipsX && !clipsY)
545
- continue;
546
- const ancestorRect = ancestor.getBoundingClientRect();
547
- if (clipsX) {
548
- left = Math.max(left, ancestorRect.left);
549
- right = Math.min(right, ancestorRect.right);
550
- }
551
- if (clipsY) {
552
- top = Math.max(top, ancestorRect.top);
553
- bottom = Math.min(bottom, ancestorRect.bottom);
554
- }
555
- }
556
- return { left, top, right, bottom };
557
- }
558
722
  storyForCanonicalAnchor(anchorId) {
559
723
  const first = anchorId.indexOf(":");
560
724
  const second = first < 0 ? -1 : anchorId.indexOf(":", first + 1);
@@ -792,9 +956,7 @@ export class PaginationEngine {
792
956
  // Measure height and margins separately for proper margin collapsing calculation
793
957
  // getBoundingClientRect() returns content+padding+border, not margins
794
958
  const rect = child.getBoundingClientRect();
795
- const style = this.view.getComputedStyle(child);
796
- const marginTopPx = parseFloat(style.marginTop) || 0;
797
- const marginBottomPx = parseFloat(style.marginBottom) || 0;
959
+ const { topPx: marginTopPx, bottomPx: marginBottomPx } = collapsedBlockMarginsPx(this.view, child);
798
960
  const heightPt = pxToPt(rect.height);
799
961
  const marginTopPt = pxToPt(marginTopPx);
800
962
  const marginBottomPt = pxToPt(marginBottomPx);
@@ -840,8 +1002,16 @@ export class PaginationEngine {
840
1002
  for (const rule of sectionChildren.filter((child) => child.tagName === "HR")) {
841
1003
  this.checkpoint();
842
1004
  const clonedRule = rule.cloneNode(true);
843
- if (blocks.length === 0)
844
- clonedRule.style.marginTop = sectionStyle.marginTop;
1005
+ // The rule leaves its section when flattened, so preserve the section-scoped
1006
+ // print styling before measuring and painting the detached clone.
1007
+ const ruleStyle = this.view.getComputedStyle(rule);
1008
+ for (const property of ["height", "width", "max-width", "border", "background-color", "color",
1009
+ "margin-top", "margin-right", "margin-bottom", "margin-left"]) {
1010
+ clonedRule.style.setProperty(property, ruleStyle.getPropertyValue(property));
1011
+ }
1012
+ if (blocks.length === 0) {
1013
+ clonedRule.style.marginTop = `calc(${sectionStyle.marginTop} + ${ruleStyle.marginTop})`;
1014
+ }
845
1015
  blocks.push(this.measureElement(clonedRule, dims, sectionIndex));
846
1016
  }
847
1017
  const listStyle = this.view.getComputedStyle(list);
@@ -886,9 +1056,11 @@ export class PaginationEngine {
886
1056
  return null;
887
1057
  clone.id = itemId;
888
1058
  }
889
- const value = parseInt(item.getAttribute("value") || String(itemIndex + 1), 10);
890
- const marker = this.formatOrderedListMarker(Number.isFinite(value) ? value : itemIndex + 1, listStyle.listStyleType);
891
- clone.insertBefore(this.document.createTextNode(`${marker}. `), clone.firstChild);
1059
+ if (!clone.querySelector('[data-endnote-number]')) {
1060
+ const value = parseInt(item.getAttribute("value") || String(itemIndex + 1), 10);
1061
+ const marker = this.formatOrderedListMarker(Number.isFinite(value) ? value : itemIndex + 1, listStyle.listStyleType);
1062
+ clone.insertBefore(this.document.createTextNode(`${marker}. `), clone.firstChild);
1063
+ }
892
1064
  }
893
1065
  blocks.push(this.measureElement(clone, dims, sectionIndex));
894
1066
  }
@@ -913,15 +1085,16 @@ export class PaginationEngine {
913
1085
  /**
914
1086
  * Flows a multi-column (`w:cols`) section's children into CSS-multicol container
915
1087
  * blocks. Word lays such a section out as N columns inside the same body extent;
916
- * a balanced `column-count` container reproduces that geometry, and the paginator
1088
+ * a `column-count` container reproduces that geometry, and the paginator
917
1089
  * then places each container as one ordinary measured block. A container grows
918
- * greedily until its balanced height would exceed the smallest page body available
919
- * to the section, so a long columned section still splits across pages at block
1090
+ * greedily until it would exceed the smallest page body available to the section.
1091
+ * Final continuous sections fill that height sequentially; other sections retain
1092
+ * balancing. A long columned section still splits across pages at block
920
1093
  * boundaries. Each child lands in exactly one container, so anchors never
921
1094
  * duplicate. An explicit page break child passes through as its own block, which
922
1095
  * ends the current container and lets the normal flow logic turn the page.
923
1096
  */
924
- buildColumnBlocks(section, dims, sectionIndex, columnCount, columnGapPt) {
1097
+ buildColumnBlocks(section, dims, sectionIndex, columnCount, columnGapPt, sequential = false) {
925
1098
  const children = Array.from(section.children);
926
1099
  const blocks = [];
927
1100
  const maxFragmentHeight = this.smallestEffectiveContentHeight(dims, sectionIndex);
@@ -931,9 +1104,16 @@ export class PaginationEngine {
931
1104
  const container = this.document.createElement("div");
932
1105
  container.style.columnCount = String(columnCount);
933
1106
  container.style.columnGap = `${columnGapPt}pt`;
1107
+ if (sequential) {
1108
+ container.dataset.paginationSequentialColumns = "true";
1109
+ container.style.columnFill = "auto";
1110
+ container.style.maxHeight = `${maxFragmentHeight}pt`;
1111
+ }
934
1112
  for (const child of slice) {
935
1113
  container.appendChild(child.cloneNode(true));
936
1114
  }
1115
+ if (sequential)
1116
+ this.liftColumnSpaceBefore(container, dims);
937
1117
  return container;
938
1118
  };
939
1119
  let start = 0;
@@ -948,12 +1128,20 @@ export class PaginationEngine {
948
1128
  // emitted alone, preserving the established oversized-block fallback.
949
1129
  let end = start + 1;
950
1130
  let container = makeContainer(children.slice(start, end));
1131
+ if (sequential && this.columnContainerOverflows(container, dims)) {
1132
+ // An indivisible child that exceeds all available columns keeps the established
1133
+ // oversized-block path; a constrained box must not hide it in an extra column.
1134
+ delete container.dataset.paginationSequentialColumns;
1135
+ container.style.columnFill = "balance";
1136
+ container.style.maxHeight = "";
1137
+ }
951
1138
  let measured = this.measureElement(container, dims, sectionIndex);
952
1139
  while (end < children.length && !isBreak(children[end])) {
953
1140
  this.checkpoint();
954
1141
  const candidate = makeContainer(children.slice(start, end + 1));
955
1142
  const candidateMeasured = this.measureElement(candidate, dims, sectionIndex);
956
- if (candidateMeasured.heightPt > maxFragmentHeight)
1143
+ if (candidateMeasured.heightPt > maxFragmentHeight
1144
+ || (sequential && this.columnContainerOverflows(candidate, dims)))
957
1145
  break;
958
1146
  container = candidate;
959
1147
  measured = candidateMeasured;
@@ -975,6 +1163,92 @@ export class PaginationEngine {
975
1163
  }
976
1164
  return blocks;
977
1165
  }
1166
+ /** Keep Word's paragraph space-before in the ordinary inter-block spacing budget. */
1167
+ liftColumnSpaceBefore(container, dims) {
1168
+ container.style.marginTop = "";
1169
+ const first = container.firstElementChild;
1170
+ if (!first || !this.isWordParagraphElement(first))
1171
+ return;
1172
+ const host = this.document.createElement("div");
1173
+ host.style.width = `${dims.contentWidth}pt`;
1174
+ host.appendChild(container);
1175
+ this.stagingElement.appendChild(host);
1176
+ try {
1177
+ const spaceBefore = first.dataset.paginationColumnSpaceBefore
1178
+ ?? this.view.getComputedStyle(first).marginTop;
1179
+ first.dataset.paginationColumnSpaceBefore = spaceBefore;
1180
+ first.style.marginTop = "0";
1181
+ container.style.marginTop = spaceBefore;
1182
+ }
1183
+ finally {
1184
+ container.remove();
1185
+ host.remove();
1186
+ }
1187
+ }
1188
+ /** A height-constrained multicol box overflows horizontally when it needs another column. */
1189
+ columnContainerOverflows(element, dims) {
1190
+ const host = this.document.createElement("div");
1191
+ host.style.width = `${dims.contentWidth}pt`;
1192
+ const clone = element.cloneNode(true);
1193
+ host.appendChild(clone);
1194
+ this.stagingElement.appendChild(host);
1195
+ try {
1196
+ if (clone.scrollWidth > clone.clientWidth + 1)
1197
+ return true;
1198
+ // Keep-lines paragraphs must fit as a whole. A constrained column container
1199
+ // can report only its first partial box, even when scrollWidth still fits.
1200
+ // Move that source paragraph to the next page before print clips its text.
1201
+ return Array.from(clone.querySelectorAll('[data-keep-lines="true"]'))
1202
+ .some(paragraph => paragraph.getClientRects().length > 1);
1203
+ }
1204
+ finally {
1205
+ host.remove();
1206
+ }
1207
+ }
1208
+ /**
1209
+ * Reflow a final continuous column section against the actual remaining body band.
1210
+ * A preceding title, running story or note reservation can make it shorter than the
1211
+ * fresh-page band used while collecting source blocks. Split at source-child boundaries
1212
+ * when the narrower band would create an extra horizontal column; the tail is reconsidered
1213
+ * normally on the following page. No source child is duplicated or discarded.
1214
+ */
1215
+ reflowSequentialColumns(block, dims, availableHeightPt) {
1216
+ if (block.element.dataset.paginationSequentialColumns !== "true"
1217
+ || availableHeightPt <= 0)
1218
+ return null;
1219
+ const maximumHeight = this.smallestEffectiveContentHeight(dims, block.sectionIndex);
1220
+ const height = Math.min(availableHeightPt, maximumHeight);
1221
+ if (Math.abs(parseFloat(block.element.style.maxHeight) - height) < 0.01)
1222
+ return null;
1223
+ const children = Array.from(block.element.children);
1224
+ const make = (slice, limit) => {
1225
+ const container = block.element.cloneNode(false);
1226
+ container.style.maxHeight = `${limit}pt`;
1227
+ for (const child of slice)
1228
+ container.appendChild(child.cloneNode(true));
1229
+ this.liftColumnSpaceBefore(container, dims);
1230
+ return container;
1231
+ };
1232
+ const whole = make(children, height);
1233
+ if (!this.columnContainerOverflows(whole, dims)) {
1234
+ return [this.measureElement(whole, dims, block.sectionIndex)];
1235
+ }
1236
+ let end = 0;
1237
+ for (let count = 1; count < children.length; count++) {
1238
+ this.checkpoint();
1239
+ const candidate = make(children.slice(0, count), height);
1240
+ if (this.columnContainerOverflows(candidate, dims))
1241
+ break;
1242
+ if (children[count - 1].dataset.keepWithNext !== "true")
1243
+ end = count;
1244
+ }
1245
+ if (end === 0)
1246
+ return null;
1247
+ return [
1248
+ this.measureElement(make(children.slice(0, end), height), dims, block.sectionIndex),
1249
+ this.measureElement(make(children.slice(end), maximumHeight), dims, block.sectionIndex),
1250
+ ];
1251
+ }
978
1252
  /**
979
1253
  * Measures one element in the same hidden staging context used for the source blocks.
980
1254
  * This is intentionally DOM-based: table row heights cannot be inferred from individual
@@ -990,13 +1264,13 @@ export class PaginationEngine {
990
1264
  measurementHost.appendChild(measuredElement);
991
1265
  this.stagingElement.appendChild(measurementHost);
992
1266
  const rect = measuredElement.getBoundingClientRect();
993
- const style = this.view.getComputedStyle(measuredElement);
1267
+ const margins = collapsedBlockMarginsPx(this.view, measuredElement);
994
1268
  const measured = {
995
1269
  element,
996
1270
  sectionIndex,
997
1271
  heightPt: pxToPt(rect.height),
998
- marginTopPt: pxToPt(parseFloat(style.marginTop) || 0),
999
- marginBottomPt: pxToPt(parseFloat(style.marginBottom) || 0),
1272
+ marginTopPt: pxToPt(margins.topPx),
1273
+ marginBottomPt: pxToPt(margins.bottomPx),
1000
1274
  keepWithNext: element.dataset.keepWithNext === "true",
1001
1275
  keepLines: element.dataset.keepLines === "true",
1002
1276
  pageBreakBefore: element.dataset.pageBreakBefore === "true",
@@ -2393,6 +2667,10 @@ export class PaginationEngine {
2393
2667
  * Deterministic: it depends only on the section's page setup and the registry's pre-measured
2394
2668
  * story heights, never on the page's content, which is what keeps it lazy-loading compatible.
2395
2669
  */
2670
+ /** The body height a physical page may fill: its body band less any caller-requested reduction. */
2671
+ bodyBudgetPt(bodyHeightPt, physicalPageNumber) {
2672
+ return Math.max(0, bodyHeightPt - (this.bodyBudgetReductionsPt[physicalPageNumber] ?? 0));
2673
+ }
2396
2674
  getPageBands(dims, sectionIndex, pageInSection, displayedPageNumber) {
2397
2675
  const sectionHf = this.hfRegistry.get(sectionIndex);
2398
2676
  return resolvePageBands(dims, this.selectStoryHeight(sectionHf?.headerFirstHeight, sectionHf?.headerEvenHeight, sectionHf?.headerDefaultHeight, pageInSection, displayedPageNumber), this.selectStoryHeight(sectionHf?.footerFirstHeight, sectionHf?.footerEvenHeight, sectionHf?.footerDefaultHeight, pageInSection, displayedPageNumber));
@@ -2469,7 +2747,7 @@ export class PaginationEngine {
2469
2747
  return currentPhysicalNumber + physicalPage - pageNumber;
2470
2748
  };
2471
2749
  // Get effective content height for first page (accounts for header/footer sizes)
2472
- let { bodyHeight: effectiveContentHeight } = this.getPageBands(dimensionsFor(), pageSectionIndex, pageInSection(), displayedPageNumber());
2750
+ let effectiveContentHeight = this.bodyBudgetPt(this.getPageBands(dimensionsFor(), pageSectionIndex, pageInSection(), displayedPageNumber()).bodyHeight, pageNumber);
2473
2751
  let remainingHeight = effectiveContentHeight;
2474
2752
  // Track the previous block's bottom margin for margin collapsing
2475
2753
  let prevMarginBottomPt = 0;
@@ -2501,7 +2779,7 @@ export class PaginationEngine {
2501
2779
  const adoptEmptyPageOwner = (owner) => {
2502
2780
  pageSectionIndex = owner;
2503
2781
  const bands = this.getPageBands(dimensionsFor(owner), owner, pageInSection(owner), displayedPageNumber(owner));
2504
- effectiveContentHeight = bands.bodyHeight;
2782
+ effectiveContentHeight = this.bodyBudgetPt(bands.bodyHeight, pageNumber);
2505
2783
  remainingHeight = effectiveContentHeight;
2506
2784
  };
2507
2785
  const admitCurrentPage = () => {
@@ -2614,7 +2892,7 @@ export class PaginationEngine {
2614
2892
  }
2615
2893
  // Get effective content height for new page position
2616
2894
  const newBands = this.getPageBands(dimensionsFor(), pageSectionIndex, pageInSection(), displayedPageNumber());
2617
- effectiveContentHeight = newBands.bodyHeight;
2895
+ effectiveContentHeight = this.bodyBudgetPt(newBands.bodyHeight, pageNumber);
2618
2896
  remainingHeight = effectiveContentHeight;
2619
2897
  prevMarginBottomPt = 0; // Reset margin tracking for new page
2620
2898
  currentFootnoteIds = []; // Reset footnotes for new page
@@ -2770,6 +3048,12 @@ export class PaginationEngine {
2770
3048
  // Account for margin collapsing: the gap between blocks is max(prevBottom, currTop), not sum
2771
3049
  const isFirstOnPage = currentContent.length === 0;
2772
3050
  const effectiveMarginTop = this.effectiveBlockMarginTop(block, prevMarginBottomPt, isFirstOnPage, pageInSection(block.sectionIndex));
3051
+ const columnFragments = this.reflowSequentialColumns(block, blockDimensions, remainingHeight - currentFootnoteHeight - additionalFootnoteHeight - effectiveMarginTop);
3052
+ if (columnFragments) {
3053
+ blocks.splice(i, 1, ...columnFragments);
3054
+ i--;
3055
+ continue;
3056
+ }
2773
3057
  // Visible height = top margin gap + content + footnote space
2774
3058
  // Note: bottom margin is NOT included in the fit check because the last block's
2775
3059
  // bottom margin extends beyond the content area and is clipped by overflow:hidden.
@@ -3014,12 +3298,14 @@ export class PaginationEngine {
3014
3298
  };
3015
3299
  const widthReference = this.horizontalAnchorReference(anchor.getAttribute('data-docx-anchor-width-relative') ?? 'margin', dims, pageNumber, context);
3016
3300
  const widthPercent = this.anchorNumber(anchor, 'width-percent');
3017
- if (widthPercent !== undefined) {
3301
+ // Word supports strictly positive relative dimensions (MS-ODRAWXML 2.18.3.1/2).
3302
+ // Zero is schema-valid but must not collapse a visible object with a stored extent.
3303
+ if (widthPercent !== undefined && widthPercent > 0) {
3018
3304
  anchor.style.width = `${widthReference.size * widthPercent / 100}pt`;
3019
3305
  }
3020
3306
  const heightReference = this.verticalAnchorReference(anchor.getAttribute('data-docx-anchor-height-relative') ?? 'margin', dims, pageNumber, context);
3021
3307
  const heightPercent = this.anchorNumber(anchor, 'height-percent');
3022
- if (heightPercent !== undefined && anchor.dataset.docxAnchorAutofit !== 'true') {
3308
+ if (heightPercent !== undefined && heightPercent > 0 && anchor.dataset.docxAnchorAutofit !== 'true') {
3023
3309
  anchor.style.height = `${heightReference.size * heightPercent / 100}pt`;
3024
3310
  }
3025
3311
  // Read the final border-box size after relative sizing and before reparenting. The stored
@@ -3031,7 +3317,17 @@ export class PaginationEngine {
3031
3317
  const vertical = this.verticalAnchorReference(anchor.getAttribute('data-docx-anchor-v-relative') ?? 'paragraph', dims, pageNumber, context);
3032
3318
  const left = this.resolveAnchorAxis(horizontal, width, anchor.getAttribute('data-docx-anchor-h-align'), this.anchorNumber(anchor, 'h-offset'), pageNumber);
3033
3319
  const top = this.resolveAnchorAxis(vertical, height, anchor.getAttribute('data-docx-anchor-v-align'), this.anchorNumber(anchor, 'v-offset'), pageNumber);
3320
+ // Leaving the paragraph must not orphan it: a paragraph whose only content was this drawing
3321
+ // renders as the drawing, and measureRenderedSource finds it through this link (issue #849).
3322
+ const hostElement = anchor.parentElement?.closest('[data-source-anchor-id]');
3323
+ const hostAnchorId = hostElement?.dataset.sourceAnchorId;
3324
+ if (hostAnchorId)
3325
+ anchor.setAttribute(DRAWING_HOST_ATTRIBUTE, hostAnchorId);
3034
3326
  pageBox.appendChild(anchor);
3327
+ // A host left with nothing but its paragraph-mark line (issue #880) renders as that line plus
3328
+ // the drawing; one with text of its own keeps measuring as its own box.
3329
+ if (hostElement && rendersOnlyItsLine(hostElement))
3330
+ hostElement.setAttribute(HOSTS_PROMOTED_DRAWING_ATTRIBUTE, 'true');
3035
3331
  anchor.style.position = 'absolute';
3036
3332
  anchor.style.left = `${left}pt`;
3037
3333
  anchor.style.top = `${top}pt`;
@@ -3325,6 +3621,13 @@ export class PaginationEngine {
3325
3621
  contentArea.style.height = `${Math.max(0, contentAreaHeight - notesHeightPt)}pt`;
3326
3622
  }
3327
3623
  }
3624
+ // Paint offsets must follow range fragmentation: positioned runs are deliberately excluded
3625
+ // from that structural algorithm, even though these offsets leave every line box unchanged.
3626
+ alignExactLineBaselines(contentArea);
3627
+ const paper = pageBox.getBoundingClientRect();
3628
+ const header = pageBox.querySelector(`.${this.cssPrefix}header`);
3629
+ const footer = pageBox.querySelector(`.${this.cssPrefix}footer`);
3630
+ preserveExactLineInk(contentArea, Math.max(paper.top, header?.getBoundingClientRect().bottom ?? paper.top), Math.min(paper.bottom, footer?.getBoundingClientRect().top ?? paper.bottom, notesEl?.getBoundingClientRect().top ?? paper.bottom), paper);
3328
3631
  return {
3329
3632
  pageNumber,
3330
3633
  sectionIndex,