@stll/folio-core 0.28.0 → 0.28.1

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 (28) hide show
  1. package/dist/docx/styleParser.js +15 -14
  2. package/dist/layout-bridge/convert/toFlowBlocks.js +74 -23
  3. package/dist/layout-engine/footnoteColumnReflow.d.ts +15 -0
  4. package/dist/layout-engine/footnoteColumnReflow.js +74 -0
  5. package/dist/layout-engine/index.d.ts +3 -5
  6. package/dist/layout-engine/index.js +36 -15
  7. package/dist/layout-engine/measure/cache.d.ts +1 -1
  8. package/dist/layout-engine/measure/cache.js +11 -2
  9. package/dist/layout-engine/measure/listMarkerWidth.d.ts +3 -1
  10. package/dist/layout-engine/measure/listMarkerWidth.js +6 -4
  11. package/dist/layout-engine/paginator.d.ts +6 -0
  12. package/dist/layout-engine/paginator.js +30 -11
  13. package/dist/layout-engine/types.d.ts +11 -6
  14. package/dist/prosemirror/attrs/index.js +3 -2
  15. package/dist/prosemirror/commands/formatPainter.js +45 -1
  16. package/dist/prosemirror/conversion/fromProseDoc.d.ts +4 -1
  17. package/dist/prosemirror/conversion/fromProseDoc.js +59 -44
  18. package/dist/prosemirror/conversion/toProseDoc.d.ts +4 -1
  19. package/dist/prosemirror/conversion/toProseDoc.js +156 -33
  20. package/dist/prosemirror/extensions/marks/RunFormattingOverrideExtension.js +22 -22
  21. package/dist/prosemirror/schema/marks.d.ts +3 -1
  22. package/dist/prosemirror/styles/styleResolver.d.ts +0 -1
  23. package/dist/prosemirror/styles/styleResolver.js +22 -29
  24. package/dist/prosemirror/styles/styleToggleCascade.d.ts +37 -0
  25. package/dist/prosemirror/styles/styleToggleCascade.js +52 -0
  26. package/dist/utils/textFormattingMerge.d.ts +9 -1
  27. package/dist/utils/textFormattingMerge.js +32 -1
  28. package/package.json +1 -1
@@ -1,23 +1,24 @@
1
1
  import { isValidHexColor } from "../utils/colorResolver.js";
2
2
  import { mergeParagraphFormatting } from "../utils/paragraphFormattingMerge.js";
3
- import { mergeTextFormatting } from "../utils/textFormattingMerge.js";
3
+ import { mergeStyleTextFormatting } from "../utils/textFormattingMerge.js";
4
4
  import { BorderStyleSchema, ConditionalStyleTypeSchema, EmphasisMarkSchema, FontHintSchema, FontThemeSchema, HighlightColorSchema, LineSpacingRuleSchema, ParagraphAlignmentSchema, ShadingPatternSchema, StyleTypeSchema, TabLeaderSchema, TabStopAlignmentSchema, TableCellTextDirectionSchema, TableRowHeightRuleSchema, TableWidthTypeSchema, TextEffectSchema, ThemeColorSlotSchema, UnderlineStyleSchema, narrowEnum } from "./parserEnums.js";
5
5
  import { resolveThemeFontRef } from "./themeParser.js";
6
6
  import { findChild, findChildren, getAttribute, getLocalName, parseBooleanElement, parseNumberingLevelAttribute, parseNumericAttribute, parseOnOffValue, parseTableMeasurementValue, parseXmlDocument } from "./xmlParser.js";
7
7
  //#region src/docx/styleParser.ts
8
+ const findLastRunToggle = (rPr, localName) => findChildren(rPr, "w", localName).at(-1) ?? null;
8
9
  /**
9
10
  * Parse text formatting properties (w:rPr)
10
11
  */
11
12
  function parseRunProperties(rPr, theme) {
12
13
  if (!rPr) return;
13
14
  const formatting = {};
14
- const b = findChild(rPr, "w", "b");
15
+ const b = findLastRunToggle(rPr, "b");
15
16
  if (b) formatting.bold = parseBooleanElement(b);
16
- const bCs = findChild(rPr, "w", "bCs");
17
+ const bCs = findLastRunToggle(rPr, "bCs");
17
18
  if (bCs) formatting.boldCs = parseBooleanElement(bCs);
18
- const i = findChild(rPr, "w", "i");
19
+ const i = findLastRunToggle(rPr, "i");
19
20
  if (i) formatting.italic = parseBooleanElement(i);
20
- const iCs = findChild(rPr, "w", "iCs");
21
+ const iCs = findLastRunToggle(rPr, "iCs");
21
22
  if (iCs) formatting.italicCs = parseBooleanElement(iCs);
22
23
  const u = findChild(rPr, "w", "u");
23
24
  if (u) {
@@ -29,7 +30,7 @@ function parseRunProperties(rPr, theme) {
29
30
  if (colorVal || themeColor) formatting.underline.color = parseColorValue(colorVal, themeColor, getAttribute(u, "w", "themeTint"), getAttribute(u, "w", "themeShade"));
30
31
  }
31
32
  }
32
- const strike = findChild(rPr, "w", "strike");
33
+ const strike = findLastRunToggle(rPr, "strike");
33
34
  if (strike) formatting.strike = parseBooleanElement(strike);
34
35
  const dstrike = findChild(rPr, "w", "dstrike");
35
36
  if (dstrike) formatting.doubleStrike = parseBooleanElement(dstrike);
@@ -38,11 +39,11 @@ function parseRunProperties(rPr, theme) {
38
39
  const val = getAttribute(vertAlign, "w", "val");
39
40
  if (val === "superscript" || val === "subscript" || val === "baseline") formatting.vertAlign = val;
40
41
  }
41
- const smallCaps = findChild(rPr, "w", "smallCaps");
42
+ const smallCaps = findLastRunToggle(rPr, "smallCaps");
42
43
  if (smallCaps) formatting.smallCaps = parseBooleanElement(smallCaps);
43
- const caps = findChild(rPr, "w", "caps");
44
+ const caps = findLastRunToggle(rPr, "caps");
44
45
  if (caps) formatting.allCaps = parseBooleanElement(caps);
45
- const vanish = findChild(rPr, "w", "vanish");
46
+ const vanish = findLastRunToggle(rPr, "vanish");
46
47
  if (vanish) formatting.hidden = parseBooleanElement(vanish);
47
48
  const color = findChild(rPr, "w", "color");
48
49
  if (color) formatting.color = parseColorValue(getAttribute(color, "w", "val"), getAttribute(color, "w", "themeColor"), getAttribute(color, "w", "themeTint"), getAttribute(color, "w", "themeShade"));
@@ -154,13 +155,13 @@ function parseRunProperties(rPr, theme) {
154
155
  const val = narrowEnum(getAttribute(em, "w", "val"), EmphasisMarkSchema);
155
156
  if (val) formatting.emphasisMark = val;
156
157
  }
157
- const emboss = findChild(rPr, "w", "emboss");
158
+ const emboss = findLastRunToggle(rPr, "emboss");
158
159
  if (emboss) formatting.emboss = parseBooleanElement(emboss);
159
- const imprint = findChild(rPr, "w", "imprint");
160
+ const imprint = findLastRunToggle(rPr, "imprint");
160
161
  if (imprint) formatting.imprint = parseBooleanElement(imprint);
161
- const outline = findChild(rPr, "w", "outline");
162
+ const outline = findLastRunToggle(rPr, "outline");
162
163
  if (outline) formatting.outline = parseBooleanElement(outline);
163
- const shadow = findChild(rPr, "w", "shadow");
164
+ const shadow = findLastRunToggle(rPr, "shadow");
164
165
  if (shadow) formatting.shadow = parseBooleanElement(shadow);
165
166
  const rtl = findChild(rPr, "w", "rtl");
166
167
  if (rtl) formatting.rtl = parseBooleanElement(rtl);
@@ -807,7 +808,7 @@ function resolveStyleInheritance(style, styleMap, visited = /* @__PURE__ */ new
807
808
  const resolved = { ...style };
808
809
  const mergedPPr = mergeParagraphFormatting(resolvedParent.pPr, style.pPr);
809
810
  if (mergedPPr) resolved.pPr = mergedPPr;
810
- const mergedRPr = mergeTextFormatting(resolvedParent.rPr, style.rPr);
811
+ const mergedRPr = mergeStyleTextFormatting(resolvedParent.rPr, style.rPr);
811
812
  if (mergedRPr) resolved.rPr = mergedRPr;
812
813
  if (style.type === "table") {
813
814
  if (resolvedParent.tblPr || style.tblPr) resolved.tblPr = {
@@ -6,10 +6,11 @@ import { setParagraphFrame } from "../../layout-engine/paragraphFrame.js";
6
6
  import { setTextBoxGroupId } from "../../layout-engine/textBoxGroup.js";
7
7
  import { DEFAULT_TEXTBOX_MARGINS } from "../../layout-engine/types.js";
8
8
  import { getPageNumbering } from "../../paged-layout/sectionGeometry.js";
9
- import { expectBlockSdtAttrs, expectCharacterSpacingMarkAttrs, expectCommentMarkAttrs, expectEmphasisMarkAttrs, expectFieldAttrs, expectFontFamilyMarkAttrs, expectFontSizeMarkAttrs, expectFootnoteRefMarkAttrs, expectHardBreakAttrs, expectHighlightMarkAttrs, expectHyperlinkMarkAttrs, expectImageAttrs, expectLanguageMarkAttrs, expectMathAttrs, expectParagraphAttrs, expectRunFormattingOverrideMarkAttrs, expectRunShadingMarkAttrs, expectSymbolAttrs, expectTableAttrs, expectTableCellAttrs, expectTableRowAttrs, expectTextBoxAttrs, expectTextColorMarkAttrs, expectTextEffectMarkAttrs, expectTrackedChangeMarkAttrs, expectUnderlineMarkAttrs } from "../../prosemirror/attrs/index.js";
9
+ import { expectBlockSdtAttrs, expectCharacterSpacingMarkAttrs, expectCharacterStyleMarkAttrs, expectCommentMarkAttrs, expectEmphasisMarkAttrs, expectFieldAttrs, expectFontFamilyMarkAttrs, expectFontSizeMarkAttrs, expectFootnoteRefMarkAttrs, expectHardBreakAttrs, expectHighlightMarkAttrs, expectHyperlinkMarkAttrs, expectImageAttrs, expectLanguageMarkAttrs, expectMathAttrs, expectParagraphAttrs, expectRunFormattingOverrideMarkAttrs, expectRunShadingMarkAttrs, expectSymbolAttrs, expectTableAttrs, expectTableCellAttrs, expectTableRowAttrs, expectTextBoxAttrs, expectTextColorMarkAttrs, expectTextEffectMarkAttrs, expectTrackedChangeMarkAttrs, expectUnderlineMarkAttrs } from "../../prosemirror/attrs/index.js";
10
10
  import { autospacingMatchesBase } from "../../prosemirror/autospacingBase.js";
11
11
  import { runShadingAttrsToShading } from "../../prosemirror/conversion/runShadingMark.js";
12
12
  import { directionToBidi } from "../../prosemirror/paragraphDirection.js";
13
+ import { cascadeStyleTextFormatting } from "../../prosemirror/styles/styleToggleCascade.js";
13
14
  import { assertValidProseMirrorDocument } from "../../prosemirror/validation.js";
14
15
  import { resolveColor, resolveHighlightToCss } from "../../utils/colorResolver.js";
15
16
  import { resolveThemeFont } from "../../utils/fontResolver.js";
@@ -348,17 +349,49 @@ function mergeRunFormatting(paraDefaults, formatting) {
348
349
  if (merged.letterSpacing === 0) delete merged.letterSpacing;
349
350
  return merged;
350
351
  }
352
+ /** Restore character-style toggle values that plain visual marks cannot represent. */
353
+ function applyCharacterStyleToggleFormatting({ formatting, marks, paraDefaults }) {
354
+ const characterStyleMark = marks.find((mark) => mark.type.name === "characterStyle");
355
+ if (!characterStyleMark) return;
356
+ const styleRPr = expectCharacterStyleMarkAttrs(characterStyleMark)._styleRPr;
357
+ if (!styleRPr) return;
358
+ const effectiveStyleFormatting = cascadeStyleTextFormatting([{
359
+ formatting: {
360
+ bold: paraDefaults.bold ?? false,
361
+ boldCs: paraDefaults.complexScriptBold ?? false,
362
+ italic: paraDefaults.italic ?? false,
363
+ italicCs: paraDefaults.complexScriptItalic ?? false
364
+ },
365
+ type: "direct"
366
+ }, {
367
+ formatting: styleRPr,
368
+ type: "style"
369
+ }]).formatting;
370
+ if (styleRPr.bold !== void 0 && formatting.bold === void 0) formatting.bold = effectiveStyleFormatting?.bold ?? false;
371
+ if (styleRPr.boldCs !== void 0 && formatting.complexScriptBold === void 0) formatting.complexScriptBold = effectiveStyleFormatting?.boldCs ?? false;
372
+ if (styleRPr.italic !== void 0 && formatting.italic === void 0) formatting.italic = effectiveStyleFormatting?.italic ?? false;
373
+ if (styleRPr.italicCs !== void 0 && formatting.complexScriptItalic === void 0) formatting.complexScriptItalic = effectiveStyleFormatting?.italicCs ?? false;
374
+ if (styleRPr.allCaps === true && formatting.allCaps === void 0) formatting.allCaps = false;
375
+ if (styleRPr.emboss === true && formatting.emboss === void 0) formatting.emboss = false;
376
+ if (styleRPr.imprint === true && formatting.imprint === void 0) formatting.imprint = false;
377
+ if (styleRPr.outline === true && formatting.textOutline === void 0) formatting.textOutline = false;
378
+ if (styleRPr.shadow === true && formatting.textShadow === void 0) formatting.textShadow = false;
379
+ if (styleRPr.smallCaps === true && formatting.smallCaps === void 0) formatting.smallCaps = false;
380
+ if (styleRPr.strike === true && formatting.strike === void 0) formatting.strike = false;
381
+ if (styleRPr.hidden === true && formatting.hidden === void 0) formatting.hidden = false;
382
+ }
351
383
  function applyRunFormattingOverrides(formatting, attrs) {
352
- if (attrs.bold === false) formatting.bold = false;
353
- if (attrs.italic === false) formatting.italic = false;
384
+ if (attrs.bold !== void 0) formatting.bold = attrs.bold;
385
+ if (attrs.italic !== void 0) formatting.italic = attrs.italic;
354
386
  if (attrs.underline === "none") formatting.underline = false;
355
- if (attrs.strike === false) formatting.strike = false;
356
- if (attrs.allCaps === false) formatting.allCaps = false;
357
- if (attrs.smallCaps === false) formatting.smallCaps = false;
358
- if (attrs.emboss === false) formatting.emboss = false;
359
- if (attrs.imprint === false) formatting.imprint = false;
360
- if (attrs.shadow === false) formatting.textShadow = false;
361
- if (attrs.outline === false) formatting.textOutline = false;
387
+ if (attrs.strike !== void 0) formatting.strike = attrs.strike;
388
+ if (attrs.allCaps !== void 0) formatting.allCaps = attrs.allCaps;
389
+ if (attrs.smallCaps !== void 0) formatting.smallCaps = attrs.smallCaps;
390
+ if (attrs.hidden !== void 0) formatting.hidden = attrs.hidden;
391
+ if (attrs.emboss !== void 0) formatting.emboss = attrs.emboss;
392
+ if (attrs.imprint !== void 0) formatting.imprint = attrs.imprint;
393
+ if (attrs.shadow !== void 0) formatting.textShadow = attrs.shadow;
394
+ if (attrs.outline !== void 0) formatting.textOutline = attrs.outline;
362
395
  if (attrs.rtl === false) formatting.rtl = false;
363
396
  if (attrs.boldCs !== void 0) formatting.complexScriptBold = attrs.boldCs;
364
397
  if (attrs.italicCs !== void 0) formatting.complexScriptItalic = attrs.italicCs;
@@ -497,6 +530,11 @@ function paragraphToRuns(node, startPos, _options) {
497
530
  if (child.type.name !== "sdt") leadingRenderedPageBreakPending = false;
498
531
  if (child.isText && child.text) {
499
532
  const formatting = extractRunFormatting(child.marks, theme);
533
+ applyCharacterStyleToggleFormatting({
534
+ formatting,
535
+ marks: child.marks,
536
+ paraDefaults
537
+ });
500
538
  if (inTocParagraph) stripTocHyperlinkStyle(formatting);
501
539
  const run = {
502
540
  kind: "text",
@@ -513,6 +551,11 @@ function paragraphToRuns(node, startPos, _options) {
513
551
  const text = decodeOoxmlSymbolCharacter(attrs.char);
514
552
  if (text === null) return;
515
553
  const formatting = extractRunFormatting(child.marks, theme);
554
+ applyCharacterStyleToggleFormatting({
555
+ formatting,
556
+ marks: child.marks,
557
+ paraDefaults
558
+ });
516
559
  if (inTocParagraph) stripTocHyperlinkStyle(formatting);
517
560
  runs.push({
518
561
  kind: "text",
@@ -534,6 +577,11 @@ function paragraphToRuns(node, startPos, _options) {
534
577
  }
535
578
  if (child.type.name === "tab") {
536
579
  const formatting = extractRunFormatting(child.marks, theme);
580
+ applyCharacterStyleToggleFormatting({
581
+ formatting,
582
+ marks: child.marks,
583
+ paraDefaults
584
+ });
537
585
  const run = {
538
586
  kind: "tab",
539
587
  ...mergeRunFormatting(paraDefaults, formatting),
@@ -561,6 +609,11 @@ function paragraphToRuns(node, startPos, _options) {
561
609
  else if (ft === "DATE") mappedType = "DATE";
562
610
  else if (ft === "TIME") mappedType = "TIME";
563
611
  const extractedFieldFormatting = extractRunFormatting(child.marks, theme);
612
+ applyCharacterStyleToggleFormatting({
613
+ formatting: extractedFieldFormatting,
614
+ marks: child.marks,
615
+ paraDefaults
616
+ });
564
617
  if (inTocParagraph) stripTocHyperlinkStyle(extractedFieldFormatting);
565
618
  const fieldFormatting = markDefaultBlackTextColorSource(extractedFieldFormatting, paraDefaults);
566
619
  const run = {
@@ -902,27 +955,25 @@ function convertParagraph(node, startPos, options) {
902
955
  return block;
903
956
  }
904
957
  /**
905
- * Word keeps terminal empty body paragraphs after a final table as editable
906
- * anchors, but they do not create a page of their own. Preserve every block
907
- * and PM range while collapsing only the contiguous, run-free suffix; empty
908
- * paragraphs elsewhere still retain their normal line height.
958
+ * Word keeps a final empty body paragraph after a table as an editable anchor,
959
+ * but that final anchor does not create a page of its own. Earlier authored
960
+ * empty paragraphs retain their height and may carry the document onto a blank
961
+ * page. Preserve every block and PM range while collapsing only the final one.
909
962
  */
910
963
  function isPaintlessTerminalParagraph(block) {
911
964
  if (block?.kind !== "paragraph" || block.runs.length !== 0) return false;
912
965
  const attrs = block.attrs;
913
966
  return !(attrs?.listMarker !== void 0 && !attrs.listMarkerHidden || attrs?.borders?.top || attrs?.borders?.bottom || attrs?.borders?.left || attrs?.borders?.right || attrs?.borders?.between || attrs?.borders?.bar || attrs?.shading || attrs?.spacingExplicit?.before || attrs?.spacingExplicit?.after || attrs?.pageBreakBefore || attrs?.renderedPageBreakBefore);
914
967
  }
915
- function suppressTerminalEmptyParagraphsAfterTable(blocks) {
968
+ function suppressFinalEmptyParagraphAfterTable(blocks) {
916
969
  let suffixStart = blocks.length;
917
970
  while (suffixStart > 0 && isPaintlessTerminalParagraph(blocks[suffixStart - 1])) suffixStart -= 1;
918
971
  if (suffixStart === blocks.length || suffixStart === 0 || blocks[suffixStart - 1]?.kind !== "table") return;
919
- for (let index = suffixStart; index < blocks.length; index += 1) {
920
- const block = blocks[index];
921
- if (isPaintlessTerminalParagraph(block)) block.attrs = {
922
- ...block.attrs,
923
- suppressEmptyParagraphHeight: true
924
- };
925
- }
972
+ const finalBlock = blocks.at(-1);
973
+ if (isPaintlessTerminalParagraph(finalBlock)) finalBlock.attrs = {
974
+ ...finalBlock.attrs,
975
+ suppressEmptyParagraphHeight: true
976
+ };
926
977
  }
927
978
  function suppressFinalParagraphInRepeatedEmptySuffix(blocks) {
928
979
  let suffixStart = blocks.length;
@@ -1477,7 +1528,7 @@ function toFlowBlocks(doc, options = {}) {
1477
1528
  visit(node, offset + nodeOffset);
1478
1529
  });
1479
1530
  reserveLeadingEmptyOutlineHeight(blocks);
1480
- suppressTerminalEmptyParagraphsAfterTable(blocks);
1531
+ suppressFinalEmptyParagraphAfterTable(blocks);
1481
1532
  suppressFinalParagraphInRepeatedEmptySuffix(blocks);
1482
1533
  return groupParagraphFrames(applySectionDocumentGrid(mergeRunInParagraphs(blocks), opts.finalSectionDocumentGridLinePitchTwips), nextBlockId);
1483
1534
  }
@@ -0,0 +1,15 @@
1
+ import { Layout } from "./types.js";
2
+ //#region src/layout-engine/footnoteColumnReflow.d.ts
3
+ type ReflowFootnoteColumnsOptions = {
4
+ initialLayout: Layout;
5
+ initialReserveFloors?: ReadonlyMap<number, number>;
6
+ runLayout: (reserveFloors: Map<number, number>) => Layout;
7
+ };
8
+ /**
9
+ * Re-run a multi-column layout until its body flow clears the shared footnote band.
10
+ * Reserve floors follow the current page-to-footnote assignment. Repeated floor states
11
+ * and persistent overlap use a stable fallback layout instead of throwing.
12
+ */
13
+ declare function reflowFootnoteColumns({ initialLayout, initialReserveFloors, runLayout }: ReflowFootnoteColumnsOptions): Layout;
14
+ //#endregion
15
+ export { reflowFootnoteColumns };
@@ -0,0 +1,74 @@
1
+ //#region src/layout-engine/footnoteColumnReflow.ts
2
+ function isFlowFragment(fragment) {
3
+ switch (fragment.kind) {
4
+ case "paragraph": return true;
5
+ case "table": return !fragment.isFloating;
6
+ case "image": return !fragment.isAnchored;
7
+ case "textBox": return !fragment.isPositioned;
8
+ default: return fragment;
9
+ }
10
+ }
11
+ function hasFootnoteColumnOverlap(layout) {
12
+ for (const page of layout.pages) {
13
+ if ((page.columns?.count ?? 1) < 2 || !page.footnoteReservedHeight) continue;
14
+ const bodyBottom = page.size.h - page.margins.bottom - page.footnoteReservedHeight;
15
+ if (page.fragments.some((fragment) => isFlowFragment(fragment) && fragment.y + fragment.height > bodyBottom)) return true;
16
+ }
17
+ return false;
18
+ }
19
+ function growFootnoteReserveFloors(layout, initialReserveFloors) {
20
+ const next = /* @__PURE__ */ new Map();
21
+ for (const page of layout.pages) {
22
+ if ((page.footnoteIds?.length ?? 0) === 0) continue;
23
+ const observed = page.footnoteReservedHeight ?? 0;
24
+ const callerFloor = initialReserveFloors?.get(page.number) ?? 0;
25
+ const floor = Math.max(callerFloor, observed);
26
+ if (floor > 0) next.set(page.number, floor);
27
+ }
28
+ return next;
29
+ }
30
+ function reserveFloorFingerprint(floors) {
31
+ return JSON.stringify([...floors.entries()].sort(([leftPage], [rightPage]) => leftPage - rightPage));
32
+ }
33
+ function clearOrphanedFootnoteReservations(layout) {
34
+ let pagesChanged = false;
35
+ const pages = layout.pages.map((page) => {
36
+ if ((page.footnoteIds?.length ?? 0) > 0 || page.footnoteReservedHeight === void 0) return page;
37
+ pagesChanged = true;
38
+ const cleanedPage = { ...page };
39
+ delete cleanedPage.footnoteReservedHeight;
40
+ return cleanedPage;
41
+ });
42
+ if (!pagesChanged) return layout;
43
+ return {
44
+ ...layout,
45
+ pages
46
+ };
47
+ }
48
+ function getPassBudget(layout) {
49
+ const fragmentCount = layout.pages.reduce((count, page) => count + page.fragments.length, 0);
50
+ const footnoteCount = new Set(layout.pages.flatMap((page) => page.footnoteIds ?? [])).size;
51
+ return layout.pages.length + fragmentCount + footnoteCount + 1;
52
+ }
53
+ /**
54
+ * Re-run a multi-column layout until its body flow clears the shared footnote band.
55
+ * Reserve floors follow the current page-to-footnote assignment. Repeated floor states
56
+ * and persistent overlap use a stable fallback layout instead of throwing.
57
+ */
58
+ function reflowFootnoteColumns({ initialLayout, initialReserveFloors, runLayout }) {
59
+ let layout = initialLayout;
60
+ if (!hasFootnoteColumnOverlap(layout)) return clearOrphanedFootnoteReservations(layout);
61
+ const seenReserveStates = /* @__PURE__ */ new Set();
62
+ const passBudget = getPassBudget(layout);
63
+ for (let pass = 0; pass < passBudget; pass += 1) {
64
+ const reserveFloors = growFootnoteReserveFloors(layout, initialReserveFloors);
65
+ const reserveState = reserveFloorFingerprint(reserveFloors);
66
+ if (seenReserveStates.has(reserveState)) return clearOrphanedFootnoteReservations(layout);
67
+ seenReserveStates.add(reserveState);
68
+ layout = runLayout(reserveFloors);
69
+ if (!hasFootnoteColumnOverlap(layout)) return clearOrphanedFootnoteReservations(layout);
70
+ }
71
+ return clearOrphanedFootnoteReservations(layout);
72
+ }
73
+ //#endregion
74
+ export { reflowFootnoteColumns };
@@ -32,11 +32,9 @@ declare function applyContextualSpacing(blocks: FlowBlock[]): void;
32
32
  /**
33
33
  * Layout a document: convert blocks + measures into pages with positioned fragments.
34
34
  *
35
- * Algorithm:
36
- * 1. Walk blocks in order with their corresponding measures
37
- * 2. For each block, create appropriate fragment(s)
38
- * 3. Use paginator to manage page/column state
39
- * 4. Handle page breaks, section breaks, and keepNext chains
35
+ * A reference discovered after the first column has already been placed can shrink the
36
+ * shared body band below earlier-column fragments. Retry those documents with the observed
37
+ * reservations as page floors so every column participates in the same footnote geometry.
40
38
  */
41
39
  declare function layoutDocument(blocks: FlowBlock[], measures: Measure[], options: LayoutOptions): Layout;
42
40
  /**
@@ -1,4 +1,5 @@
1
1
  import { emuToPixels } from "../utils/units.js";
2
+ import { reflowFootnoteColumns } from "./footnoteColumnReflow.js";
2
3
  import { resolveSectionHeaderFooterRefs } from "./headerFooterRefs.js";
3
4
  import { calculateChainHeight, computeKeepNextChains, getMidChainIndices, hasKeepLines, hasPageBreakBefore } from "./keep-together.js";
4
5
  import { measuredLineAdvance } from "./lineFlow.js";
@@ -140,13 +141,23 @@ function applyContextualSpacing(blocks) {
140
141
  /**
141
142
  * Layout a document: convert blocks + measures into pages with positioned fragments.
142
143
  *
143
- * Algorithm:
144
- * 1. Walk blocks in order with their corresponding measures
145
- * 2. For each block, create appropriate fragment(s)
146
- * 3. Use paginator to manage page/column state
147
- * 4. Handle page breaks, section breaks, and keepNext chains
144
+ * A reference discovered after the first column has already been placed can shrink the
145
+ * shared body band below earlier-column fragments. Retry those documents with the observed
146
+ * reservations as page floors so every column participates in the same footnote geometry.
148
147
  */
149
148
  function layoutDocument(blocks, measures, options) {
149
+ const layout = layoutDocumentPass(blocks, measures, options);
150
+ if (!options.footnoteHeightById) return layout;
151
+ return reflowFootnoteColumns({
152
+ initialLayout: layout,
153
+ ...options.footnoteReservedHeights ? { initialReserveFloors: options.footnoteReservedHeights } : {},
154
+ runLayout: (reserveFloors) => layoutDocumentPass(blocks, measures, {
155
+ ...options,
156
+ footnoteReservedHeights: reserveFloors
157
+ })
158
+ });
159
+ }
160
+ function layoutDocumentPass(blocks, measures, options) {
150
161
  if (blocks.length !== measures.length) panic(`layoutDocument: expected one measure per block (blocks=${blocks.length}, measures=${measures.length})`);
151
162
  const pageSize = options.pageSize;
152
163
  const baseMargins = {
@@ -333,6 +344,10 @@ function getLineFootnoteRefs(block, fromRun, toRun, fnHeights) {
333
344
  height
334
345
  };
335
346
  }
347
+ function projectedFootnoteReserveGrowth(state, additionalDemandHeight) {
348
+ const projectedDemand = state.footnoteDemandHeight + additionalDemandHeight;
349
+ return Math.max(state.footnoteHeightFloor, projectedDemand) - state.footnoteHeight;
350
+ }
336
351
  function layoutParagraph({ block, measure, paginator, contentWidth, footnoteHeightById, suppressSpaceBefore }) {
337
352
  const lines = measure.lines;
338
353
  if (lines.length === 0) {
@@ -370,7 +385,7 @@ function layoutParagraph({ block, measure, paginator, contentWidth, footnoteHeig
370
385
  if (currentLineIndex === 0 && state.trailingSpacing > 0 && state.cursorY !== state.topMargin) {
371
386
  const firstLine = lines[0];
372
387
  const firstLineRefs = getLineFootnoteRefs(block, firstLine.fromRun, firstLine.toRun, footnoteHeightById);
373
- const firstLineHeight = measuredLineAdvance(firstLine) + firstLineRefs.height;
388
+ const firstLineHeight = measuredLineAdvance(firstLine) + projectedFootnoteReserveGrowth(state, firstLineRefs.height);
374
389
  const collapsedLead = collapseParagraphSpacing({
375
390
  before: spaceBefore,
376
391
  after: state.trailingSpacing
@@ -399,7 +414,8 @@ function layoutParagraph({ block, measure, paginator, contentWidth, footnoteHeig
399
414
  }
400
415
  const lineRefs = getLineFootnoteRefs(block, line.fromRun, line.toRun, footnoteHeightById);
401
416
  const totalWithLine = linesHeight + lineAdvance;
402
- if (totalWithLine + firstFragmentSpaceBefore + linesFnHeight + lineRefs.height <= availableHeight || fittingLines === 0) {
417
+ const footnoteGrowth = projectedFootnoteReserveGrowth(state, linesFnHeight + lineRefs.height);
418
+ if (totalWithLine + firstFragmentSpaceBefore + footnoteGrowth <= availableHeight || fittingLines === 0) {
403
419
  linesHeight = totalWithLine;
404
420
  linesFnHeight += lineRefs.height;
405
421
  for (const id of lineRefs.ids) linesFnIds.push(id);
@@ -449,8 +465,10 @@ function layoutParagraph({ block, measure, paginator, contentWidth, footnoteHeig
449
465
  ...block.sdtGroups ? { sdtGroups: block.sdtGroups } : {}
450
466
  };
451
467
  if (linesFnHeight > 0) {
452
- paginator.ensureFits(effectiveSpaceBefore + linesHeight + linesFnHeight);
453
- if (paginator.getCurrentState().footnoteHeight === 0) paginator.ensureFits(effectiveSpaceBefore + linesHeight + linesFnHeight + 12);
468
+ const stateBefore = paginator.getCurrentState();
469
+ paginator.ensureFits(effectiveSpaceBefore + linesHeight + projectedFootnoteReserveGrowth(stateBefore, linesFnHeight));
470
+ const stateAfter = paginator.getCurrentState();
471
+ if (stateAfter.footnoteDemandHeight === 0) paginator.ensureFits(effectiveSpaceBefore + linesHeight + projectedFootnoteReserveGrowth(stateAfter, linesFnHeight + 12));
454
472
  }
455
473
  fragment.y = paginator.addFragment(fragment, linesHeight, effectiveSpaceBefore, effectiveSpaceAfter).y;
456
474
  if (linesFnHeight > 0) paginator.addFootnoteHeight(linesFnHeight, linesFnIds);
@@ -650,8 +668,9 @@ function layoutTable(block, measure, paginator, footnoteHeightById) {
650
668
  fragmentFootnoteIds.push(id);
651
669
  rowFootnoteHeight += footnoteHeightById?.get(id) ?? 0;
652
670
  }
653
- const separatorHeight = pageFootnoteIds.size === 0 && fragmentFootnoteHeight + rowFootnoteHeight > 0 ? 12 : 0;
654
- if (rowsHeight + rowHeight + normalHeaderOverhead + fragmentFootnoteHeight + rowFootnoteHeight + separatorHeight <= availableHeight) {
671
+ const candidateFootnoteHeight = fragmentFootnoteHeight + rowFootnoteHeight;
672
+ const footnoteGrowth = projectedFootnoteReserveGrowth(state, candidateFootnoteHeight + (state.footnoteDemandHeight === 0 && candidateFootnoteHeight > 0 ? 12 : 0));
673
+ if (rowsHeight + rowHeight + normalHeaderOverhead + footnoteGrowth <= availableHeight) {
655
674
  rowsHeight += rowHeight;
656
675
  fragmentFootnoteHeight += rowFootnoteHeight;
657
676
  fittingRows++;
@@ -780,7 +799,7 @@ function layoutFloatingTable(block, measure, paginator, contentWidth) {
780
799
  isFloating: true,
781
800
  ...block.sdtGroups ? { sdtGroups: block.sdtGroups } : {}
782
801
  };
783
- state.page.fragments.push(fragment);
802
+ paginator.addUnflowedFragment(fragment);
784
803
  }
785
804
  /**
786
805
  * Layout an image block onto pages.
@@ -825,7 +844,7 @@ function layoutAnchoredImage(block, measure, paginator) {
825
844
  isAnchored: true,
826
845
  zIndex: anchor.behindDoc ? -1 : 1
827
846
  };
828
- state.page.fragments.push(fragment);
847
+ paginator.addUnflowedFragment(fragment);
829
848
  }
830
849
  /**
831
850
  * Layout a text box block onto pages.
@@ -853,10 +872,11 @@ function layoutTextBox(block, measure, { paginator, sectionMarginTop, sectionPag
853
872
  y: state.topMargin + bandTop,
854
873
  width: measure.width,
855
874
  height: measure.height,
875
+ isPositioned: true,
856
876
  ...block.pmStart !== void 0 ? { pmStart: block.pmStart } : {},
857
877
  ...block.pmEnd !== void 0 ? { pmEnd: block.pmEnd } : {}
858
878
  };
859
- state.page.fragments.push(fragment);
879
+ paginator.addUnflowedFragment(fragment);
860
880
  return;
861
881
  }
862
882
  if (block.position !== void 0) {
@@ -882,10 +902,11 @@ function layoutTextBox(block, measure, { paginator, sectionMarginTop, sectionPag
882
902
  y,
883
903
  width: measure.width,
884
904
  height: measure.height,
905
+ isPositioned: true,
885
906
  ...block.pmStart !== void 0 ? { pmStart: block.pmStart } : {},
886
907
  ...block.pmEnd !== void 0 ? { pmEnd: block.pmEnd } : {}
887
908
  };
888
- state.page.fragments.push(fragment);
909
+ paginator.addUnflowedFragment(fragment);
889
910
  return;
890
911
  }
891
912
  const state = paginator.ensureFits(measure.height);
@@ -54,7 +54,7 @@ declare function setFontCacheSize(size: number): void;
54
54
  * Get current font metrics cache size
55
55
  */
56
56
  declare function getFontCacheSize(): number;
57
- /** Serialize the complete paragraph measurement contract into a cache key. */
57
+ /** Serialize the complete, semantically normalized paragraph measurement contract into a cache key. */
58
58
  declare function hashParagraphBlock(block: ParagraphBlock): string;
59
59
  /**
60
60
  * Get cached paragraph measurement or return undefined
@@ -188,11 +188,20 @@ const runMeasureCacheInput = (run) => {
188
188
  default: return run;
189
189
  }
190
190
  };
191
- /** Serialize the complete paragraph measurement contract into a cache key. */
191
+ const paragraphAttrsMeasureCacheInput = (attrs) => {
192
+ if (attrs === void 0) return void 0;
193
+ const { snapToGrid, ...measurementAttrs } = attrs;
194
+ if (measurementAttrs.documentGridLinePitch === void 0) return measurementAttrs;
195
+ return {
196
+ ...measurementAttrs,
197
+ snapToGrid: snapToGrid !== false
198
+ };
199
+ };
200
+ /** Serialize the complete, semantically normalized paragraph measurement contract into a cache key. */
192
201
  function hashParagraphBlock(block) {
193
202
  return JSON.stringify({
194
203
  lineBreakProviderGeneration: getLineBreakProviderGeneration(),
195
- attrs: block.attrs,
204
+ attrs: paragraphAttrsMeasureCacheInput(block.attrs),
196
205
  runs: block.runs.map(runMeasureCacheInput)
197
206
  });
198
207
  }
@@ -27,7 +27,9 @@ declare function resolveListMarkerFont(block: ParagraphBlock): {
27
27
  * - hanging indent — body wraps at `indentLeft`, marker sits at
28
28
  * `indentLeft - hanging`. Width is `hanging` so the marker fills the slot.
29
29
  * - `w:suff` (§17.9.25): `nothing` → natural width, `space` → natural +
30
- * one space glyph, `tab` (default) → grow to the next tab stop.
30
+ * one space glyph, `tab` (default) → grow to the next tab stop. A hanging
31
+ * indent remains the minimum footprint so body text cannot enter the
32
+ * marker slot.
31
33
  * - `w:tabs` on the paragraph: non-`clear`/non-`bar` stops past the marker.
32
34
  * `bar` (§17.3.1.37) is a vertical line and doesn't advance the cursor.
33
35
  * - default tab grid: stops at multiples of `DEFAULT_TAB_STOP_TWIPS`,
@@ -50,7 +50,9 @@ function resolveListMarkerFont(block) {
50
50
  * - hanging indent — body wraps at `indentLeft`, marker sits at
51
51
  * `indentLeft - hanging`. Width is `hanging` so the marker fills the slot.
52
52
  * - `w:suff` (§17.9.25): `nothing` → natural width, `space` → natural +
53
- * one space glyph, `tab` (default) → grow to the next tab stop.
53
+ * one space glyph, `tab` (default) → grow to the next tab stop. A hanging
54
+ * indent remains the minimum footprint so body text cannot enter the
55
+ * marker slot.
54
56
  * - `w:tabs` on the paragraph: non-`clear`/non-`bar` stops past the marker.
55
57
  * `bar` (§17.3.1.37) is a vertical line and doesn't advance the cursor.
56
58
  * - default tab grid: stops at multiples of `DEFAULT_TAB_STOP_TWIPS`,
@@ -67,12 +69,12 @@ function getListMarkerInlineWidth(block) {
67
69
  const naturalWidth = measureTextWidth(attrs.listMarker, style);
68
70
  const markerEndOffset = getMarkerEndOffset(naturalWidth, attrs.listMarkerAlignment);
69
71
  const suffix = attrs.listMarkerSuffix ?? "tab";
70
- if (suffix === "nothing") return markerEndOffset;
71
- if (suffix === "space") return markerEndOffset + measureTextWidth(" ", style);
72
+ const hanging = attrs.indent?.hanging ?? 0;
73
+ if (suffix === "nothing") return Math.max(hanging, markerEndOffset);
74
+ if (suffix === "space") return Math.max(hanging, markerEndOffset + measureTextWidth(" ", style));
72
75
  const indent = attrs.indent;
73
76
  const indentLeft = indent?.left ?? 0;
74
77
  const firstLine = indent?.firstLine ?? 0;
75
- const hanging = indent?.hanging ?? 0;
76
78
  const markerStartPx = hanging > 0 ? indentLeft - hanging : indentLeft + firstLine;
77
79
  const minBodyStart = markerStartPx + markerEndOffset;
78
80
  const customTabs = (attrs.tabs ?? []).filter((t) => t.val !== "clear" && t.val !== "bar").map((t) => t.pos * TWIPS_TO_PX);
@@ -22,6 +22,10 @@ type PageState = {
22
22
  rawContentBottom: number;
23
23
  /** Total height reserved for footnotes on this page (grows as refs are placed). */
24
24
  footnoteHeight: number;
25
+ /** Static reservation supplied by a layout retry. */
26
+ footnoteHeightFloor: number;
27
+ /** Footnote demand discovered while placing reference-bearing content. */
28
+ footnoteDemandHeight: number;
25
29
  /** Accumulated trailing spacing (space after previous block). */
26
30
  trailingSpacing: number;
27
31
  };
@@ -98,6 +102,8 @@ declare function createPaginator(options: PaginatorOptions): {
98
102
  x: number;
99
103
  y: number;
100
104
  };
105
+ /** Add an already-positioned fragment without advancing normal flow. */
106
+ addUnflowedFragment: (fragment: Fragment) => PageState;
101
107
  /** Reserve additional footnote area on the current page. */
102
108
  addFootnoteHeight: (additionalHeight: number, footnoteIds?: number[]) => void;
103
109
  /** Force a page break. */