@stll/folio-core 0.4.0 → 0.5.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 (99) hide show
  1. package/dist/ai-edits/apply.d.ts +3 -9
  2. package/dist/ai-edits/apply.js +94 -14
  3. package/dist/ai-edits/blockRange.d.ts +33 -0
  4. package/dist/ai-edits/blockRange.js +58 -0
  5. package/dist/ai-edits/headless.d.ts +33 -4
  6. package/dist/ai-edits/headless.js +83 -15
  7. package/dist/ai-edits/index.d.ts +5 -3
  8. package/dist/ai-edits/index.js +3 -2
  9. package/dist/ai-edits/snapshot.d.ts +14 -2
  10. package/dist/ai-edits/snapshot.js +12 -1
  11. package/dist/ai-edits/types.d.ts +42 -3
  12. package/dist/compat/eigenpal.d.ts +3 -2
  13. package/dist/compat/eigenpal.js +6 -5
  14. package/dist/controller/layoutPipeline.d.ts +1 -0
  15. package/dist/controller/layoutPipeline.js +35 -137
  16. package/dist/controller/layoutScheduler.d.ts +2 -1
  17. package/dist/controller/layoutScheduler.js +6 -0
  18. package/dist/document-operations.d.ts +122 -0
  19. package/dist/document-operations.js +575 -0
  20. package/dist/docx/blockContentParser.js +17 -7
  21. package/dist/docx/encryption/agileDecryption.js +2 -1
  22. package/dist/docx/encryption/compoundFile.js +4 -2
  23. package/dist/docx/footnoteParser.d.ts +1 -17
  24. package/dist/docx/footnoteParser.js +1 -27
  25. package/dist/docx/groupDrawingParser.d.ts +8 -0
  26. package/dist/docx/groupDrawingParser.js +131 -0
  27. package/dist/docx/numberingParser.d.ts +3 -1
  28. package/dist/docx/numberingParser.js +8 -2
  29. package/dist/docx/paragraphParser.js +9 -3
  30. package/dist/docx/runParser.js +8 -1
  31. package/dist/docx/selectiveXmlPatch.js +4 -10
  32. package/dist/docx/serializer/numberingSerializer.js +3 -1
  33. package/dist/docx/settingsParser.d.ts +6 -3
  34. package/dist/docx/settingsParser.js +2 -0
  35. package/dist/docx/xmlParser.js +11 -4
  36. package/dist/index.d.ts +3 -2
  37. package/dist/index.js +6 -5
  38. package/dist/layout-bridge/convert/footnoteLayout.d.ts +37 -1
  39. package/dist/layout-bridge/convert/footnoteLayout.js +138 -14
  40. package/dist/layout-bridge/convert/headerFooterLayout.d.ts +5 -7
  41. package/dist/layout-bridge/convert/headerFooterLayout.js +5 -7
  42. package/dist/layout-bridge/convert/toFlowBlocks.js +56 -19
  43. package/dist/layout-engine/index.d.ts +6 -5
  44. package/dist/layout-engine/index.js +89 -26
  45. package/dist/layout-engine/keep-together.d.ts +13 -8
  46. package/dist/layout-engine/keep-together.js +40 -25
  47. package/dist/layout-engine/layoutInstrumentation.d.ts +11 -1
  48. package/dist/layout-engine/layoutInstrumentation.js +10 -1
  49. package/dist/layout-engine/measure/measureBlocks.js +28 -18
  50. package/dist/layout-engine/measure/measureParagraph.js +61 -15
  51. package/dist/layout-engine/paginator.d.ts +2 -1
  52. package/dist/layout-engine/paginator.js +9 -3
  53. package/dist/layout-engine/textBoxGroup.d.ts +7 -0
  54. package/dist/layout-engine/textBoxGroup.js +8 -0
  55. package/dist/layout-engine/types.d.ts +30 -9
  56. package/dist/layout-engine/types.js +20 -2
  57. package/dist/layout-painter/renderPage.d.ts +15 -1
  58. package/dist/layout-painter/renderPage.js +27 -11
  59. package/dist/layout-painter/renderParagraph.js +18 -8
  60. package/dist/layout-painter/renderTable.js +10 -7
  61. package/dist/managers/DocumentLoaderManager.js +12 -5
  62. package/dist/managers/TableSelectionManager.js +1 -1
  63. package/dist/model.js +1 -1
  64. package/dist/paged-layout/pageText.d.ts +14 -0
  65. package/dist/paged-layout/pageText.js +21 -0
  66. package/dist/prosemirror/attrs/index.js +2 -0
  67. package/dist/prosemirror/commands/comments.js +75 -5
  68. package/dist/prosemirror/commands/hyperlink.js +4 -2
  69. package/dist/prosemirror/commands/image.js +2 -1
  70. package/dist/prosemirror/commands/index.js +1 -1
  71. package/dist/prosemirror/commands/propertyChangeScope.d.ts +68 -0
  72. package/dist/prosemirror/commands/propertyChangeScope.js +242 -0
  73. package/dist/prosemirror/conversion/fromProseDoc.js +14 -3
  74. package/dist/prosemirror/conversion/toProseDoc.js +29 -8
  75. package/dist/prosemirror/extensions/core/ParagraphExtension.js +2 -0
  76. package/dist/prosemirror/extensions/features/ListExtension.js +6 -0
  77. package/dist/prosemirror/extensions/nodes/TableExtension.js +10 -11
  78. package/dist/prosemirror/findReplaceSelection.js +2 -1
  79. package/dist/prosemirror/index.js +1 -1
  80. package/dist/prosemirror/insertOperations.js +1 -1
  81. package/dist/prosemirror/schema/nodes.d.ts +10 -5
  82. package/dist/prosemirror/styles/resolvedStyleAttrs.js +1 -0
  83. package/dist/prosemirror/utils/tabCalculator.js +4 -2
  84. package/dist/redline.d.ts +20 -0
  85. package/dist/redline.js +127 -0
  86. package/dist/server.d.ts +7 -3
  87. package/dist/server.js +6 -2
  88. package/dist/symbols.d.ts +35 -0
  89. package/dist/symbols.js +512 -0
  90. package/dist/utils/clipboard.js +3 -3
  91. package/dist/utils/fontLoader.js +3 -0
  92. package/dist/utils/fontResolver.js +48 -1
  93. package/dist/utils/zoom.d.ts +21 -0
  94. package/dist/utils/zoom.js +21 -0
  95. package/dist/version-comparison.d.ts +92 -0
  96. package/dist/version-comparison.js +436 -0
  97. package/package.json +3 -3
  98. package/dist/paged-layout/headerFooterMargins.d.ts +0 -75
  99. package/dist/paged-layout/headerFooterMargins.js +0 -122
@@ -1,5 +1,6 @@
1
+ import { emuToPixels } from "../utils/units.js";
1
2
  import { measuredLineAdvance } from "./lineFlow.js";
2
- import { DEFAULT_TEXTBOX_MARGINS, DEFAULT_TEXTBOX_WIDTH, FOOTNOTE_ENTRY_MARGIN_BOTTOM, FOOTNOTE_FALLBACK_LINE_HEIGHT, FOOTNOTE_SEPARATOR_HEIGHT, floatingTextBoxReservesBand, floatingTextBoxWrapsText, isFloatingImageRun, isFloatingTextBoxBlock, isTextWrappingFloatingImageRun } from "./types.js";
3
+ import { DEFAULT_TEXTBOX_MARGINS, DEFAULT_TEXTBOX_WIDTH, FOOTNOTE_ENTRY_MARGIN_BOTTOM, FOOTNOTE_FALLBACK_LINE_HEIGHT, FOOTNOTE_SEPARATOR_HEIGHT, floatingTextBoxReservesBand, floatingTextBoxWrapsText, isFloatingImageRun, isFloatingTextBoxBlock, isTextWrappingFloatingImageRun, tableColumnsArePinned } from "./types.js";
3
4
  import { calculateChainHeight, computeKeepNextChains, getMidChainIndices, hasKeepLines, hasPageBreakBefore } from "./keep-together.js";
4
5
  import { createPaginator } from "./paginator.js";
5
6
  import { getParagraphFragmentPmRange } from "./paragraphFragmentRange.js";
@@ -51,25 +52,42 @@ function isEmptyParagraph(block) {
51
52
  if (block.runs.length === 0) return true;
52
53
  if (block.runs.length !== 1) return false;
53
54
  const r = block.runs[0];
54
- return r?.kind === "text" && (r.text ?? "") === "";
55
+ return r?.kind === "text" && r.text === "";
56
+ }
57
+ function isPaginationEmptyParagraph(block) {
58
+ return block.runs.every((run) => {
59
+ if (run.kind === "text") return run.text.trim().length === 0;
60
+ return run.kind === "tab" || run.kind === "lineBreak";
61
+ });
62
+ }
63
+ function pageHasVisibleBodyContent(page, blocksById) {
64
+ for (const fragment of page.fragments) {
65
+ if (fragment.kind !== "paragraph") return true;
66
+ const block = blocksById.get(String(fragment.blockId));
67
+ if (block?.kind !== "paragraph" || !isPaginationEmptyParagraph(block)) return true;
68
+ }
69
+ return false;
55
70
  }
56
71
  /**
57
72
  * Get spacing before a paragraph block. Empty paragraphs whose
58
- * `before` was inherited from a paragraph style (not set inline) collapse
59
- * to zero Word fidelity for incidental empty separators.
73
+ * `before` came only from the implicit default paragraph style collapse to
74
+ * zero. An explicit `w:pStyle` selection is authored paragraph formatting,
75
+ * so Word keeps the selected style's spacing even when the paragraph is empty.
60
76
  */
61
77
  function getSpacingBefore(block) {
62
78
  const value = block.attrs?.spacing?.before ?? 0;
63
- if (isEmptyParagraph(block) && !block.attrs?.spacingExplicit?.before) return 0;
79
+ if (value === 0) return 0;
80
+ if (isEmptyParagraph(block) && !block.attrs?.styleId && !block.attrs?.spacingExplicit?.before) return 0;
64
81
  return value;
65
82
  }
66
83
  /**
67
- * Get spacing after a paragraph block. Same empty-paragraph collapse rule
68
- * as `getSpacingBefore`.
84
+ * Get spacing after a paragraph block. Same implicit-default-style collapse
85
+ * rule as `getSpacingBefore`.
69
86
  */
70
87
  function getSpacingAfter(block) {
71
88
  const value = block.attrs?.spacing?.after ?? 0;
72
- if (isEmptyParagraph(block) && !block.attrs?.spacingExplicit?.after) return 0;
89
+ if (value === 0) return 0;
90
+ if (isEmptyParagraph(block) && !block.attrs?.styleId && !block.attrs?.spacingExplicit?.after) return 0;
73
91
  return value;
74
92
  }
75
93
  function hasWidowControl(block) {
@@ -78,9 +96,10 @@ function hasWidowControl(block) {
78
96
  /**
79
97
  * Apply contextual spacing suppression (OOXML §17.3.1.9).
80
98
  *
81
- * When two consecutive paragraph blocks both have `contextualSpacing: true`
82
- * and share the same `styleId`, the spaceAfter of the first paragraph and
83
- * the spaceBefore of the second paragraph are suppressed (set to 0).
99
+ * Contextual spacing applies independently to each paragraph: suppress the
100
+ * current paragraph's spaceAfter when it opts in, and the next paragraph's
101
+ * spaceBefore when it opts in, provided both paragraphs share a style. Two
102
+ * absent style ids both refer to the document's default paragraph style.
84
103
  *
85
104
  * This mutates the block attrs in-place before layout runs.
86
105
  */
@@ -91,16 +110,15 @@ function applyContextualSpacing(blocks) {
91
110
  if (curr.kind !== "paragraph" || next.kind !== "paragraph") continue;
92
111
  const currAttrs = curr.attrs;
93
112
  const nextAttrs = next.attrs;
94
- if (currAttrs?.contextualSpacing && nextAttrs?.contextualSpacing && currAttrs.styleId && currAttrs.styleId === nextAttrs.styleId) {
95
- if (currAttrs.spacing) currAttrs.spacing = {
96
- ...currAttrs.spacing,
97
- after: 0
98
- };
99
- if (nextAttrs.spacing) nextAttrs.spacing = {
100
- ...nextAttrs.spacing,
101
- before: 0
102
- };
103
- }
113
+ if (currAttrs?.styleId !== nextAttrs?.styleId) continue;
114
+ if (currAttrs?.contextualSpacing && currAttrs.spacing) currAttrs.spacing = {
115
+ ...currAttrs.spacing,
116
+ after: 0
117
+ };
118
+ if (nextAttrs?.contextualSpacing && nextAttrs.spacing) nextAttrs.spacing = {
119
+ ...nextAttrs.spacing,
120
+ before: 0
121
+ };
104
122
  }
105
123
  for (const block of blocks) if (block.kind === "table") for (const row of block.rows) for (const cell of row.cells) applyContextualSpacing(cell.blocks);
106
124
  else if (block.kind === "textBox") applyContextualSpacing(block.content);
@@ -145,6 +163,7 @@ function layoutDocument(blocks, measures, options) {
145
163
  const paginator = createPaginator({
146
164
  pageSize: initialConfig.pageSize,
147
165
  margins: initialConfig.margins,
166
+ ...options.mirrorMargins === true ? { mirrorMargins: true } : {},
148
167
  ...options.firstPageMargins !== void 0 ? { firstPageMargins: options.firstPageMargins } : {},
149
168
  columns: initialConfig.columns ?? DEFAULT_COLUMNS,
150
169
  ...options.footnoteReservedHeights !== void 0 ? { footnoteReservedHeights: options.footnoteReservedHeights } : {},
@@ -153,21 +172,29 @@ function layoutDocument(blocks, measures, options) {
153
172
  applyContextualSpacing(blocks);
154
173
  const keepNextChains = computeKeepNextChains(blocks);
155
174
  const midChainIndices = getMidChainIndices(keepNextChains);
175
+ const blocksById = /* @__PURE__ */ new Map();
176
+ for (const block of blocks) blocksById.set(String(block.id), block);
156
177
  let sectionIdx = 0;
157
178
  let activeSectionMarginTop = initialConfig.margins.top;
158
179
  let activeSectionPageHeight = initialConfig.pageSize.h;
159
180
  let activeSectionMarginBottom = initialConfig.margins.bottom;
181
+ let naturalPageAdvanceSinceRenderedBreak = false;
160
182
  for (let i = 0; i < blocks.length; i++) {
161
183
  const block = blocks[i];
162
184
  const measure = measures[i];
185
+ const hasRenderedPageBreak = block.kind === "paragraph" && block.attrs?.renderedPageBreakBefore === true;
163
186
  if (hasPageBreakBefore(block)) paginator.forcePageBreak();
187
+ else if (hasRenderedPageBreak) {
188
+ const state = paginator.getCurrentState();
189
+ if (!(block.kind === "paragraph" && isPaginationEmptyParagraph(block) && naturalPageAdvanceSinceRenderedBreak) && pageHasVisibleBodyContent(state.page, blocksById)) paginator.forcePageBreak();
190
+ }
191
+ if (hasRenderedPageBreak || hasPageBreakBefore(block)) naturalPageAdvanceSinceRenderedBreak = false;
164
192
  const chain = keepNextChains.get(i);
165
193
  if (chain && !midChainIndices.has(i)) {
166
194
  const chainHeight = calculateChainHeight(chain, blocks, measures);
167
- const state = paginator.getCurrentState();
168
- const availableHeight = paginator.getAvailableHeight();
169
- if (chainHeight <= state.contentBottom - state.topMargin && chainHeight > availableHeight && state.page.fragments.length > 0) paginator.forcePageBreak();
195
+ paginator.ensureFits(chainHeight);
170
196
  }
197
+ const pageBeforeBlockLayout = paginator.getCurrentState().page.number;
171
198
  switch (block.kind) {
172
199
  case "paragraph":
173
200
  layoutParagraph(block, measure, paginator, paginator.getContentWidth(), options.footnoteHeightById);
@@ -204,6 +231,9 @@ function layoutDocument(blocks, measures, options) {
204
231
  }
205
232
  default: break;
206
233
  }
234
+ const isVisibleBodyBlock = block.kind === "paragraph" ? !isEmptyParagraph(block) : block.kind !== "pageBreak" && block.kind !== "columnBreak" && block.kind !== "sectionBreak";
235
+ if (block.kind === "pageBreak" || block.kind === "columnBreak" || block.kind === "sectionBreak") naturalPageAdvanceSinceRenderedBreak = false;
236
+ else if (isVisibleBodyBlock && paginator.getCurrentState().page.number > pageBeforeBlockLayout) naturalPageAdvanceSinceRenderedBreak = true;
207
237
  }
208
238
  if (paginator.pages.length === 0) paginator.getCurrentState();
209
239
  return {
@@ -397,7 +427,10 @@ function layoutTable(block, measure, paginator) {
397
427
  let x = paginator.getColumnX(columnIndex);
398
428
  if (block.justification === "center") x += (paginator.columnWidth - measure.totalWidth) / 2;
399
429
  else if (block.justification === "right") x = x + paginator.columnWidth - measure.totalWidth;
400
- else if (block.indent) x += block.indent;
430
+ else if (block.indent !== void 0) {
431
+ const leadingCellMargin = block.rows[0]?.cells[0]?.padding?.left ?? 0;
432
+ x += block.indent - leadingCellMargin;
433
+ }
401
434
  return x;
402
435
  };
403
436
  const getCurrentRowCapacity = (state = paginator.getCurrentState()) => state.rawContentBottom - state.topMargin;
@@ -531,6 +564,7 @@ function layoutFloatingTable(block, measure, paginator, contentWidth) {
531
564
  let baseY = margins.top;
532
565
  if (floating?.horzAnchor === "page") baseX = 0;
533
566
  if (floating?.vertAnchor === "page") baseY = 0;
567
+ else if (floating?.vertAnchor === "text") baseY = state.cursorY;
534
568
  let x = paginator.getColumnX(state.columnIndex);
535
569
  if (floating?.tblpX !== void 0) x = baseX + floating.tblpX;
536
570
  else if (floating?.tblpXSpec) {
@@ -649,6 +683,35 @@ function layoutTextBox(block, measure, { paginator, sectionMarginTop, sectionPag
649
683
  state.page.fragments.push(fragment);
650
684
  return;
651
685
  }
686
+ if (block.position !== void 0) {
687
+ const state = paginator.getCurrentState();
688
+ const horizontal = block.position.horizontal;
689
+ const x = horizontal ? bandFragmentX(horizontal, {
690
+ pageWidth: state.page.size.w,
691
+ marginLeft: state.page.margins.left,
692
+ marginRight: state.page.margins.right,
693
+ boxWidth: measure.width
694
+ }) : paginator.getColumnX(state.columnIndex);
695
+ const vertical = block.position.vertical;
696
+ const y = isPageFrameRelativeAnchor(vertical?.relativeTo) ? state.topMargin + bandTopContentY(vertical, {
697
+ pageHeight: sectionPageHeight,
698
+ marginTop: sectionMarginTop,
699
+ marginBottom: sectionMarginBottom,
700
+ boxHeight: measure.height
701
+ }) : state.cursorY + emuToPixels(vertical?.posOffset ?? 0);
702
+ const fragment = {
703
+ kind: "textBox",
704
+ blockId: block.id,
705
+ x,
706
+ y,
707
+ width: measure.width,
708
+ height: measure.height,
709
+ ...block.pmStart !== void 0 ? { pmStart: block.pmStart } : {},
710
+ ...block.pmEnd !== void 0 ? { pmEnd: block.pmEnd } : {}
711
+ };
712
+ state.page.fragments.push(fragment);
713
+ return;
714
+ }
652
715
  const state = paginator.ensureFits(measure.height);
653
716
  const fragment = {
654
717
  kind: "textBox",
@@ -715,4 +778,4 @@ function handleSectionBreak(_block, paginator, nextSectionConfig, nextSectionTyp
715
778
  paginator.updateColumns(nextSectionConfig.columns ?? DEFAULT_COLUMNS);
716
779
  }
717
780
  //#endregion
718
- export { DEFAULT_TEXTBOX_MARGINS, DEFAULT_TEXTBOX_WIDTH, FOOTNOTE_ENTRY_MARGIN_BOTTOM, FOOTNOTE_FALLBACK_LINE_HEIGHT, FOOTNOTE_SEPARATOR_HEIGHT, applyContextualSpacing, applyPendingToActive, assertExhaustiveFlowBlock, calculateChainHeight, collectSectionConfigs, computeKeepNextChains, createInitialSectionState, createPaginator, findPageIndexContainingPmPos, floatingTextBoxReservesBand, floatingTextBoxWrapsText, getEffectiveColumns, getEffectiveMargins, getEffectivePageSize, getHeaderRowsHeight, getMidChainIndices, hasKeepLines, hasPageBreakBefore, isFloatingImageRun, isFloatingTextBoxBlock, isTextWrappingFloatingImageRun, layoutDocument, scheduleSectionBreak };
781
+ export { DEFAULT_TEXTBOX_MARGINS, DEFAULT_TEXTBOX_WIDTH, FOOTNOTE_ENTRY_MARGIN_BOTTOM, FOOTNOTE_FALLBACK_LINE_HEIGHT, FOOTNOTE_SEPARATOR_HEIGHT, applyContextualSpacing, applyPendingToActive, assertExhaustiveFlowBlock, calculateChainHeight, collectSectionConfigs, computeKeepNextChains, createInitialSectionState, createPaginator, findPageIndexContainingPmPos, floatingTextBoxReservesBand, floatingTextBoxWrapsText, getEffectiveColumns, getEffectiveMargins, getEffectivePageSize, getHeaderRowsHeight, getMidChainIndices, hasKeepLines, hasPageBreakBefore, isFloatingImageRun, isFloatingTextBoxBlock, isTextWrappingFloatingImageRun, layoutDocument, scheduleSectionBreak, tableColumnsArePinned };
@@ -2,28 +2,33 @@ import { FlowBlock, Measure } from "./types.js";
2
2
 
3
3
  //#region src/layout-engine/keep-together.d.ts
4
4
  /**
5
- * A chain of consecutive keepNext paragraphs.
5
+ * A chain of keepNext-linked paragraphs, including empty separators Word
6
+ * carries through to the next paragraph with visible content.
6
7
  */
7
8
  type KeepNextChain = {
8
- /** Index of the first paragraph in the chain. */startIndex: number; /** Index of the last paragraph in the chain. */
9
- endIndex: number; /** All paragraph indices in the chain. */
9
+ /** Index of the first paragraph in the chain. */startIndex: number; /** Index of the last keepNext or pass-through empty member. */
10
+ endIndex: number; /** All keepNext and pass-through empty paragraph indices in the chain. */
10
11
  memberIndices: number[]; /** Index of the anchor paragraph (first non-keepNext after chain), or -1 if none. */
11
12
  anchorIndex: number;
12
13
  };
13
14
  /**
14
15
  * Pre-scan blocks to find all keepNext chains.
15
16
  *
16
- * A keepNext chain is a sequence of consecutive paragraphs with keepNext=true,
17
- * followed by an anchor paragraph (the first non-keepNext paragraph).
18
- * The entire chain must stay on the same page as the anchor's first line.
17
+ * A keepNext chain starts with a paragraph whose keepNext=true and continues
18
+ * through further keepNext paragraphs and structural empty separators. The
19
+ * first visible non-keepNext paragraph is its anchor.
19
20
  *
20
21
  * Returns a map from chain start index to chain info.
21
22
  */
22
23
  declare function computeKeepNextChains(blocks: FlowBlock[]): Map<number, KeepNextChain>;
23
24
  /**
24
- * Calculate the total height needed to keep a chain together.
25
+ * Calculate the height needed to keep consecutive paragraph boundaries from
26
+ * breaking across pages.
25
27
  *
26
- * Includes all chain members plus the first line of the anchor paragraph.
28
+ * Single-line and `keepLines` members are indivisible, so the reservation must
29
+ * continue through them to the anchor's first line. A multi-line member without
30
+ * `keepLines` may itself split; only its first line is needed to satisfy the
31
+ * preceding member's `keepNext`, and the chain can stop there.
27
32
  */
28
33
  declare function calculateChainHeight(chain: KeepNextChain, blocks: FlowBlock[], measures: Measure[]): number;
29
34
  /**
@@ -1,10 +1,16 @@
1
1
  //#region src/layout-engine/keep-together.ts
2
+ function isVisuallyEmptyParagraph(block) {
3
+ if (block.runs.length === 0) return true;
4
+ if (block.runs.length !== 1) return false;
5
+ const run = block.runs.at(0);
6
+ return run?.kind === "text" && run.text === "";
7
+ }
2
8
  /**
3
9
  * Pre-scan blocks to find all keepNext chains.
4
10
  *
5
- * A keepNext chain is a sequence of consecutive paragraphs with keepNext=true,
6
- * followed by an anchor paragraph (the first non-keepNext paragraph).
7
- * The entire chain must stay on the same page as the anchor's first line.
11
+ * A keepNext chain starts with a paragraph whose keepNext=true and continues
12
+ * through further keepNext paragraphs and structural empty separators. The
13
+ * first visible non-keepNext paragraph is its anchor.
8
14
  *
9
15
  * Returns a map from chain start index to chain info.
10
16
  */
@@ -22,7 +28,8 @@ function computeKeepNextChains(blocks) {
22
28
  const nextBlock = blocks[j];
23
29
  if (nextBlock.kind === "sectionBreak" || nextBlock.kind === "pageBreak" || nextBlock.kind === "columnBreak") break;
24
30
  if (nextBlock.kind !== "paragraph") break;
25
- if (nextBlock.attrs?.keepNext) {
31
+ const nextPara = nextBlock;
32
+ if (nextPara.attrs?.keepNext || isVisuallyEmptyParagraph(nextPara)) {
26
33
  memberIndices.push(j);
27
34
  endIndex = j;
28
35
  processed.add(j);
@@ -44,30 +51,38 @@ function computeKeepNextChains(blocks) {
44
51
  return chains;
45
52
  }
46
53
  /**
47
- * Calculate the total height needed to keep a chain together.
54
+ * Calculate the height needed to keep consecutive paragraph boundaries from
55
+ * breaking across pages.
48
56
  *
49
- * Includes all chain members plus the first line of the anchor paragraph.
57
+ * Single-line and `keepLines` members are indivisible, so the reservation must
58
+ * continue through them to the anchor's first line. A multi-line member without
59
+ * `keepLines` may itself split; only its first line is needed to satisfy the
60
+ * preceding member's `keepNext`, and the chain can stop there.
50
61
  */
51
62
  function calculateChainHeight(chain, blocks, measures) {
52
- let totalHeight = 0;
53
- for (const memberIndex of chain.memberIndices) {
54
- const block = blocks[memberIndex];
55
- const measure = measures[memberIndex];
56
- if (!block || !measure || block.kind !== "paragraph" || measure.kind !== "paragraph") continue;
57
- const para = block;
58
- const paraMeasure = measure;
59
- const spacingBefore = para.attrs?.spacing?.before ?? 0;
60
- totalHeight += spacingBefore;
61
- totalHeight += paraMeasure.totalHeight;
62
- const spacingAfter = para.attrs?.spacing?.after ?? 0;
63
- totalHeight += spacingAfter;
64
- }
65
- if (chain.anchorIndex !== -1) {
66
- const anchorMeasure = measures[chain.anchorIndex];
67
- if (anchorMeasure?.kind === "paragraph") {
68
- const anchorPara = anchorMeasure;
69
- if (anchorPara.lines.length > 0) totalHeight += anchorPara.lines[0].lineHeight;
70
- }
63
+ const firstMemberIndex = chain.memberIndices.at(0);
64
+ if (firstMemberIndex === void 0) return 0;
65
+ const firstBlock = blocks[firstMemberIndex];
66
+ const firstMeasure = measures[firstMemberIndex];
67
+ if (firstBlock?.kind !== "paragraph" || firstMeasure?.kind !== "paragraph") return 0;
68
+ let totalHeight = (firstBlock.attrs?.spacing?.before ?? 0) + firstMeasure.totalHeight;
69
+ let trailingSpacing = firstBlock.attrs?.spacing?.after ?? 0;
70
+ const successorIndices = [...chain.memberIndices.slice(1)];
71
+ if (chain.anchorIndex !== -1) successorIndices.push(chain.anchorIndex);
72
+ for (let index = 0; index < successorIndices.length; index++) {
73
+ const successorIndex = successorIndices[index];
74
+ if (successorIndex === void 0) continue;
75
+ const successorBlock = blocks[successorIndex];
76
+ const successorMeasure = measures[successorIndex];
77
+ if (successorBlock?.kind !== "paragraph" || successorMeasure?.kind !== "paragraph") return totalHeight;
78
+ totalHeight += Math.max(trailingSpacing, successorBlock.attrs?.spacing?.before ?? 0);
79
+ const firstLine = successorMeasure.lines.at(0);
80
+ if (!firstLine) return totalHeight;
81
+ const isAnchor = index === successorIndices.length - 1 && chain.anchorIndex !== -1;
82
+ const isSplittable = successorMeasure.lines.length > 1 && !successorBlock.attrs?.keepLines;
83
+ if (isAnchor || isSplittable) return totalHeight + firstLine.lineHeight;
84
+ totalHeight += successorMeasure.totalHeight;
85
+ trailingSpacing = successorBlock.attrs?.spacing?.after ?? 0;
71
86
  }
72
87
  return totalHeight;
73
88
  }
@@ -5,7 +5,12 @@ type LayoutRunReason = "font-ready" | "initial" | "layout-input" | "manual" | "t
5
5
  type LayoutPhase = "flow-blocks" | "header-footer" | "initial-fonts" | "layout-document" | "measure-blocks" | "render-pages";
6
6
  type HiddenEditorStateReason = "external-document" | "mount";
7
7
  type HiddenEditorPhase = "editor-state" | "editor-view" | "to-prose-doc" | "update-state";
8
+ type DocumentLoadPhase = "docx-parse";
8
9
  type LayoutInstrumentation = {
10
+ onDocumentLoadPhase?: (event: {
11
+ durationMs: number;
12
+ phase: DocumentLoadPhase;
13
+ }) => void;
9
14
  onHiddenEditorPhase?: (event: {
10
15
  durationMs: number;
11
16
  phase: HiddenEditorPhase;
@@ -21,6 +26,9 @@ type LayoutInstrumentation = {
21
26
  message: string;
22
27
  reason: LayoutRunReason;
23
28
  }) => void;
29
+ onLayoutStart?: (event: {
30
+ reason: LayoutRunReason;
31
+ }) => void;
24
32
  onLayoutPhase?: (event: {
25
33
  durationMs: number;
26
34
  phase: LayoutPhase;
@@ -42,9 +50,11 @@ declare global {
42
50
  declare function recordMeasureBlock(blockIndex: number, block: FlowBlock): void;
43
51
  declare function recordMeasureBlockError(blockIndex: number, block: FlowBlock, error: unknown): void;
44
52
  declare function recordLayoutComplete(reason?: LayoutRunReason): void;
53
+ declare function recordLayoutStart(reason: LayoutRunReason): void;
45
54
  declare function recordLayoutError(reason: LayoutRunReason, error: unknown): void;
46
55
  declare function recordLayoutPhase(reason: LayoutRunReason, phase: LayoutPhase, durationMs: number): void;
47
56
  declare function recordHiddenEditorStateCreate(reason: HiddenEditorStateReason): void;
48
57
  declare function recordHiddenEditorPhase(reason: HiddenEditorStateReason, phase: HiddenEditorPhase, durationMs: number): void;
58
+ declare function recordDocumentLoadPhase(phase: DocumentLoadPhase, durationMs: number): void;
49
59
  //#endregion
50
- export { HiddenEditorPhase, HiddenEditorStateReason, LayoutInstrumentation, LayoutPhase, LayoutRunReason, recordHiddenEditorPhase, recordHiddenEditorStateCreate, recordLayoutComplete, recordLayoutError, recordLayoutPhase, recordMeasureBlock, recordMeasureBlockError };
60
+ export { DocumentLoadPhase, HiddenEditorPhase, HiddenEditorStateReason, LayoutInstrumentation, LayoutPhase, LayoutRunReason, recordDocumentLoadPhase, recordHiddenEditorPhase, recordHiddenEditorStateCreate, recordLayoutComplete, recordLayoutError, recordLayoutPhase, recordLayoutStart, recordMeasureBlock, recordMeasureBlockError };
@@ -15,6 +15,9 @@ function recordMeasureBlockError(blockIndex, block, error) {
15
15
  function recordLayoutComplete(reason = "manual") {
16
16
  globalThis.__folioLayoutInstrumentation?.onLayoutComplete?.({ reason });
17
17
  }
18
+ function recordLayoutStart(reason) {
19
+ globalThis.__folioLayoutInstrumentation?.onLayoutStart?.({ reason });
20
+ }
18
21
  function recordLayoutError(reason, error) {
19
22
  const message = error instanceof Error ? error.message : String(error);
20
23
  globalThis.__folioLayoutInstrumentation?.onLayoutError?.({
@@ -39,5 +42,11 @@ function recordHiddenEditorPhase(reason, phase, durationMs) {
39
42
  reason
40
43
  });
41
44
  }
45
+ function recordDocumentLoadPhase(phase, durationMs) {
46
+ globalThis.__folioLayoutInstrumentation?.onDocumentLoadPhase?.({
47
+ durationMs,
48
+ phase
49
+ });
50
+ }
42
51
  //#endregion
43
- export { recordHiddenEditorPhase, recordHiddenEditorStateCreate, recordLayoutComplete, recordLayoutError, recordLayoutPhase, recordMeasureBlock, recordMeasureBlockError };
52
+ export { recordDocumentLoadPhase, recordHiddenEditorPhase, recordHiddenEditorStateCreate, recordLayoutComplete, recordLayoutError, recordLayoutPhase, recordLayoutStart, recordMeasureBlock, recordMeasureBlockError };
@@ -1,6 +1,7 @@
1
- import { DEFAULT_TEXTBOX_MARGINS, floatingTextBoxReservesBand } from "../types.js";
1
+ import { DEFAULT_TEXTBOX_MARGINS, floatingTextBoxReservesBand, tableColumnsArePinned } from "../types.js";
2
2
  import { findClearLineY, measureParagraph } from "./measureParagraph.js";
3
3
  import { getCachedParagraphMeasure, setCachedParagraphMeasure } from "./cache.js";
4
+ import { getTextBoxGroupId } from "../textBoxGroup.js";
4
5
  import { recordMeasureBlock, recordMeasureBlockError } from "../layoutInstrumentation.js";
5
6
  import { bandTopContentY, isPageFrameRelativeAnchor } from "../textBoxFlow.js";
6
7
  //#region src/layout-engine/measure/measureBlocks.ts
@@ -80,8 +81,8 @@ function measureTableCellBlockVisualHeight(block, blockMeasure) {
80
81
  const spacingAfter = paragraphBlock.attrs?.spacing?.after ?? 0;
81
82
  return spacingBefore + maxImageHeight + spacingAfter;
82
83
  }
83
- function getTableCellVerticalBorderHeight(cell) {
84
- return (cell?.borders?.top?.width ?? 0) + (cell?.borders?.bottom?.width ?? 0);
84
+ function getTableCellVerticalBorderHeight(cell, isFirstRow) {
85
+ return (isFirstRow ? cell?.borders?.top?.width ?? 0 : 0) + (cell?.borders?.bottom?.width ?? 0);
85
86
  }
86
87
  function measureTableBlock(tableBlock, contentWidth, fieldValues) {
87
88
  const DEFAULT_CELL_PADDING_X = 7;
@@ -118,6 +119,7 @@ function measureTableBlock(tableBlock, contentWidth, fieldValues) {
118
119
  while (occupied.has(colIdx)) colIdx++;
119
120
  }
120
121
  }
122
+ const columnsPinned = tableColumnsArePinned(tableBlock);
121
123
  const rows = tableBlock.rows.map((row, rowIdx) => {
122
124
  let columnIndex = 0;
123
125
  const occupied = occupiedColumnsPerRow.get(rowIdx) ?? /* @__PURE__ */ new Set();
@@ -133,7 +135,7 @@ function measureTableBlock(tableBlock, contentWidth, fieldValues) {
133
135
  const padLeft = cell.padding?.left ?? DEFAULT_CELL_PADDING_X;
134
136
  const padRight = cell.padding?.right ?? DEFAULT_CELL_PADDING_X;
135
137
  const cellContentWidth = Math.max(1, cellWidth - padLeft - padRight);
136
- const measureWidth = cell.noWrap ? NO_WRAP_MEASURE_WIDTH : cellContentWidth;
138
+ const measureWidth = cell.noWrap === true && !columnsPinned ? NO_WRAP_MEASURE_WIDTH : cellContentWidth;
137
139
  const cellMeasure = {
138
140
  blocks: cell.blocks.map((b) => measureBlock(b, measureWidth, void 0, void 0, fieldValues)),
139
141
  width: cellWidth,
@@ -160,6 +162,7 @@ function measureTableBlock(tableBlock, contentWidth, fieldValues) {
160
162
  }
161
163
  const sourceRowCells = tableBlock.rows[rowIdx]?.cells;
162
164
  let maxCellHeightWithBorders = 0;
165
+ let maxBorderHeight = 0;
163
166
  for (let cellIdx = 0; cellIdx < row.cells.length; cellIdx++) {
164
167
  const cell = row.cells[cellIdx];
165
168
  const sourceCell = sourceRowCells?.[cellIdx];
@@ -173,12 +176,14 @@ function measureTableBlock(tableBlock, contentWidth, fieldValues) {
173
176
  const padTop = sourceCell?.padding?.top ?? DEFAULT_CELL_PADDING_Y;
174
177
  const padBottom = sourceCell?.padding?.bottom ?? DEFAULT_CELL_PADDING_Y;
175
178
  cell.height += padTop + padBottom;
176
- maxCellHeightWithBorders = Math.max(maxCellHeightWithBorders, cell.height + getTableCellVerticalBorderHeight(sourceCell));
179
+ const borderHeight = getTableCellVerticalBorderHeight(sourceCell, rowIdx === 0);
180
+ maxCellHeightWithBorders = Math.max(maxCellHeightWithBorders, cell.height + borderHeight);
181
+ maxBorderHeight = Math.max(maxBorderHeight, borderHeight);
177
182
  }
178
183
  const explicitHeight = sourceRow?.height;
179
184
  const heightRule = sourceRow?.heightRule;
180
185
  if (explicitHeight && heightRule === "exact") row.height = explicitHeight;
181
- else if (explicitHeight) row.height = Math.max(maxCellHeightWithBorders, explicitHeight);
186
+ else if (explicitHeight) row.height = Math.max(maxCellHeightWithBorders, explicitHeight + maxBorderHeight);
182
187
  else row.height = maxCellHeightWithBorders;
183
188
  }
184
189
  const totalHeight = rows.reduce((h, r) => h + r.height, 0);
@@ -197,6 +202,7 @@ function perBlockNumberValue(value, blockIndex, fallback) {
197
202
  }
198
203
  function extractFloatingZones(blocks, contentWidth, marginTop = 0, pageGeometry) {
199
204
  const zones = [];
205
+ const textBoxGroupAnchors = /* @__PURE__ */ new Map();
200
206
  const defaultMarginTop = Array.isArray(marginTop) ? marginTop[0] ?? 0 : marginTop;
201
207
  const pageHeightInput = pageGeometry?.pageHeight ?? 0;
202
208
  const marginBottomInput = pageGeometry?.marginBottom ?? 0;
@@ -286,28 +292,31 @@ function extractFloatingZones(blocks, contentWidth, marginTop = 0, pageGeometry)
286
292
  const block = blocks[blockIndex];
287
293
  if (block.kind !== "textBox") continue;
288
294
  const tb = block;
289
- if (!floatingTextBoxReservesBand(tb)) continue;
295
+ if (!floatingTextBoxReservesBand(tb) || tb.position === void 0) continue;
290
296
  const v = tb.position?.vertical;
291
- if (!isPageFrameRelativeAnchor(v?.relativeTo)) continue;
292
297
  const height = measureTextBoxBlock(tb).height;
293
298
  const distTop = tb.distTop ?? 0;
294
299
  const distBottom = tb.distBottom ?? 0;
295
300
  const blockMarginTop = perBlockNumberValue(marginTop, blockIndex, defaultMarginTop);
296
- const rawTop = bandTopContentY(v, {
301
+ const pageFrameRelative = isPageFrameRelativeAnchor(v?.relativeTo);
302
+ const rawTop = pageFrameRelative ? bandTopContentY(v, {
297
303
  pageHeight: perBlockNumberValue(pageHeightInput, blockIndex, defaultPageHeight),
298
304
  marginTop: blockMarginTop,
299
305
  marginBottom: perBlockNumberValue(marginBottomInput, blockIndex, defaultMarginBottom),
300
306
  boxHeight: height
301
- });
307
+ }) : emuToPixels(v?.posOffset ?? 0);
302
308
  const bottomY = rawTop + height + distBottom;
303
309
  if (bottomY <= 0) continue;
310
+ const groupId = getTextBoxGroupId(tb);
311
+ const anchorBlockIndex = groupId ? textBoxGroupAnchors.get(groupId) ?? blockIndex : blockIndex;
312
+ if (groupId && !textBoxGroupAnchors.has(groupId)) textBoxGroupAnchors.set(groupId, blockIndex);
304
313
  zones.push({
305
314
  leftMargin: 0,
306
315
  rightMargin: 0,
307
316
  topY: Math.max(0, rawTop - distTop),
308
317
  bottomY,
309
- anchorBlockIndex: blockIndex,
310
- isMarginRelative: true,
318
+ anchorBlockIndex,
319
+ ...pageFrameRelative ? { isMarginRelative: true } : {},
311
320
  fullWidthBlock: true
312
321
  });
313
322
  }
@@ -367,14 +376,15 @@ function measureTextBoxBlock(tb, fieldValues) {
367
376
  };
368
377
  }
369
378
  /**
370
- * `true` for a section break that starts a new page (`nextPage`/`evenPage`/
371
- * `oddPage`, or an unspecified type, which the layout treats as `nextPage`).
372
- * A `continuous` break stays on the current page, so it does not open a fresh
373
- * page frame. Used by `measureBlocks` to reset the running Y for a page-pinned
374
- * band that lands right after the break. eigenpal #694.
379
+ * `true` for a section break that explicitly starts a new page
380
+ * (`nextPage`/`evenPage`/`oddPage`). An absent type follows
381
+ * `scheduleSectionBreak` and stays continuous, so measurement must retain any
382
+ * active exclusion zones across it. Used by `measureBlocks` to reset the
383
+ * running Y for a page-pinned band that lands right after the break.
384
+ * eigenpal #694.
375
385
  */
376
386
  function isNextPageSectionBreak(block) {
377
- return block.kind === "sectionBreak" && block.type !== "continuous";
387
+ return block.kind === "sectionBreak" && block.type !== void 0 && block.type !== "continuous";
378
388
  }
379
389
  /**
380
390
  * Measure all blocks with floating image support.