@stll/folio-core 0.4.0 → 0.6.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 (98) 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 +37 -4
  6. package/dist/ai-edits/headless.js +134 -16
  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 +139 -0
  19. package/dist/document-operations.js +578 -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/i18n/messages/catalogs.gen.d.ts +1054 -34
  37. package/dist/i18n/messages/catalogs.gen.js +1122 -68
  38. package/dist/i18n/messages/messages.gen.d.ts +62 -2
  39. package/dist/index.d.ts +3 -2
  40. package/dist/index.js +6 -5
  41. package/dist/layout-bridge/convert/footnoteLayout.d.ts +37 -1
  42. package/dist/layout-bridge/convert/footnoteLayout.js +138 -14
  43. package/dist/layout-bridge/convert/headerFooterLayout.d.ts +5 -7
  44. package/dist/layout-bridge/convert/headerFooterLayout.js +5 -7
  45. package/dist/layout-bridge/convert/toFlowBlocks.js +56 -19
  46. package/dist/layout-engine/index.d.ts +6 -5
  47. package/dist/layout-engine/index.js +143 -30
  48. package/dist/layout-engine/keep-together.d.ts +13 -8
  49. package/dist/layout-engine/keep-together.js +40 -25
  50. package/dist/layout-engine/layoutInstrumentation.d.ts +11 -1
  51. package/dist/layout-engine/layoutInstrumentation.js +10 -1
  52. package/dist/layout-engine/measure/measureBlocks.js +50 -18
  53. package/dist/layout-engine/measure/measureParagraph.js +61 -15
  54. package/dist/layout-engine/paginator.d.ts +2 -1
  55. package/dist/layout-engine/paginator.js +9 -3
  56. package/dist/layout-engine/textBoxGroup.d.ts +7 -0
  57. package/dist/layout-engine/textBoxGroup.js +8 -0
  58. package/dist/layout-engine/types.d.ts +30 -9
  59. package/dist/layout-engine/types.js +20 -2
  60. package/dist/layout-painter/renderPage.d.ts +15 -1
  61. package/dist/layout-painter/renderPage.js +27 -11
  62. package/dist/layout-painter/renderParagraph.js +18 -8
  63. package/dist/layout-painter/renderTable.js +10 -7
  64. package/dist/managers/DocumentLoaderManager.js +12 -5
  65. package/dist/model.js +1 -1
  66. package/dist/paged-layout/pageText.d.ts +14 -0
  67. package/dist/paged-layout/pageText.js +21 -0
  68. package/dist/prosemirror/attrs/index.js +2 -0
  69. package/dist/prosemirror/commands/comments.js +75 -5
  70. package/dist/prosemirror/commands/hyperlink.js +4 -2
  71. package/dist/prosemirror/commands/image.js +2 -1
  72. package/dist/prosemirror/commands/propertyChangeScope.d.ts +68 -0
  73. package/dist/prosemirror/commands/propertyChangeScope.js +242 -0
  74. package/dist/prosemirror/conversion/fromProseDoc.js +14 -3
  75. package/dist/prosemirror/conversion/toProseDoc.js +29 -8
  76. package/dist/prosemirror/extensions/core/ParagraphExtension.js +2 -0
  77. package/dist/prosemirror/extensions/features/ListExtension.js +6 -0
  78. package/dist/prosemirror/extensions/nodes/TableExtension.js +10 -11
  79. package/dist/prosemirror/findReplaceSelection.js +2 -1
  80. package/dist/prosemirror/schema/nodes.d.ts +10 -5
  81. package/dist/prosemirror/styles/resolvedStyleAttrs.js +1 -0
  82. package/dist/prosemirror/utils/tabCalculator.js +4 -2
  83. package/dist/redline.d.ts +20 -0
  84. package/dist/redline.js +127 -0
  85. package/dist/server.d.ts +7 -3
  86. package/dist/server.js +6 -2
  87. package/dist/symbols.d.ts +35 -0
  88. package/dist/symbols.js +512 -0
  89. package/dist/utils/clipboard.js +3 -3
  90. package/dist/utils/fontLoader.js +3 -0
  91. package/dist/utils/fontResolver.js +48 -1
  92. package/dist/utils/zoom.d.ts +21 -0
  93. package/dist/utils/zoom.js +21 -0
  94. package/dist/version-comparison.d.ts +92 -0
  95. package/dist/version-comparison.js +436 -0
  96. package/package.json +3 -3
  97. package/dist/paged-layout/headerFooterMargins.d.ts +0 -75
  98. package/dist/paged-layout/headerFooterMargins.js +0 -122
@@ -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,14 +176,38 @@ 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
+ if ((sourceCell?.rowSpan ?? 1) > 1) continue;
180
+ const borderHeight = getTableCellVerticalBorderHeight(sourceCell, rowIdx === 0);
181
+ maxCellHeightWithBorders = Math.max(maxCellHeightWithBorders, cell.height + borderHeight);
182
+ maxBorderHeight = Math.max(maxBorderHeight, borderHeight);
177
183
  }
178
184
  const explicitHeight = sourceRow?.height;
179
185
  const heightRule = sourceRow?.heightRule;
180
186
  if (explicitHeight && heightRule === "exact") row.height = explicitHeight;
181
- else if (explicitHeight) row.height = Math.max(maxCellHeightWithBorders, explicitHeight);
187
+ else if (explicitHeight) row.height = Math.max(maxCellHeightWithBorders, explicitHeight + maxBorderHeight);
182
188
  else row.height = maxCellHeightWithBorders;
183
189
  }
190
+ for (let rowIdx = 0; rowIdx < rows.length; rowIdx++) {
191
+ const sourceRow = tableBlock.rows[rowIdx];
192
+ const measuredRow = rows[rowIdx];
193
+ if (!sourceRow || !measuredRow) continue;
194
+ for (let cellIdx = 0; cellIdx < sourceRow.cells.length; cellIdx++) {
195
+ const sourceCell = sourceRow.cells[cellIdx];
196
+ const measuredCell = measuredRow.cells[cellIdx];
197
+ const rowSpan = sourceCell?.rowSpan ?? 1;
198
+ if (!sourceCell || !measuredCell || rowSpan <= 1) continue;
199
+ const spanEnd = Math.min(rows.length, rowIdx + rowSpan);
200
+ let combinedHeight = 0;
201
+ for (let spannedRowIdx = rowIdx; spannedRowIdx < spanEnd; spannedRowIdx++) combinedHeight += rows[spannedRowIdx]?.height ?? 0;
202
+ const deficit = measuredCell.height + getTableCellVerticalBorderHeight(sourceCell, rowIdx === 0) - combinedHeight;
203
+ if (deficit <= 0) continue;
204
+ for (let spannedRowIdx = spanEnd - 1; spannedRowIdx >= rowIdx; spannedRowIdx--) {
205
+ if (tableBlock.rows[spannedRowIdx]?.heightRule === "exact") continue;
206
+ rows[spannedRowIdx].height += deficit;
207
+ break;
208
+ }
209
+ }
210
+ }
184
211
  const totalHeight = rows.reduce((h, r) => h + r.height, 0);
185
212
  const totalWidth = columnWidths.reduce((w, cw) => w + cw, 0) || explicitWidthPx || contentWidth;
186
213
  return {
@@ -197,6 +224,7 @@ function perBlockNumberValue(value, blockIndex, fallback) {
197
224
  }
198
225
  function extractFloatingZones(blocks, contentWidth, marginTop = 0, pageGeometry) {
199
226
  const zones = [];
227
+ const textBoxGroupAnchors = /* @__PURE__ */ new Map();
200
228
  const defaultMarginTop = Array.isArray(marginTop) ? marginTop[0] ?? 0 : marginTop;
201
229
  const pageHeightInput = pageGeometry?.pageHeight ?? 0;
202
230
  const marginBottomInput = pageGeometry?.marginBottom ?? 0;
@@ -286,28 +314,31 @@ function extractFloatingZones(blocks, contentWidth, marginTop = 0, pageGeometry)
286
314
  const block = blocks[blockIndex];
287
315
  if (block.kind !== "textBox") continue;
288
316
  const tb = block;
289
- if (!floatingTextBoxReservesBand(tb)) continue;
317
+ if (!floatingTextBoxReservesBand(tb) || tb.position === void 0) continue;
290
318
  const v = tb.position?.vertical;
291
- if (!isPageFrameRelativeAnchor(v?.relativeTo)) continue;
292
319
  const height = measureTextBoxBlock(tb).height;
293
320
  const distTop = tb.distTop ?? 0;
294
321
  const distBottom = tb.distBottom ?? 0;
295
322
  const blockMarginTop = perBlockNumberValue(marginTop, blockIndex, defaultMarginTop);
296
- const rawTop = bandTopContentY(v, {
323
+ const pageFrameRelative = isPageFrameRelativeAnchor(v?.relativeTo);
324
+ const rawTop = pageFrameRelative ? bandTopContentY(v, {
297
325
  pageHeight: perBlockNumberValue(pageHeightInput, blockIndex, defaultPageHeight),
298
326
  marginTop: blockMarginTop,
299
327
  marginBottom: perBlockNumberValue(marginBottomInput, blockIndex, defaultMarginBottom),
300
328
  boxHeight: height
301
- });
329
+ }) : emuToPixels(v?.posOffset ?? 0);
302
330
  const bottomY = rawTop + height + distBottom;
303
331
  if (bottomY <= 0) continue;
332
+ const groupId = getTextBoxGroupId(tb);
333
+ const anchorBlockIndex = groupId ? textBoxGroupAnchors.get(groupId) ?? blockIndex : blockIndex;
334
+ if (groupId && !textBoxGroupAnchors.has(groupId)) textBoxGroupAnchors.set(groupId, blockIndex);
304
335
  zones.push({
305
336
  leftMargin: 0,
306
337
  rightMargin: 0,
307
338
  topY: Math.max(0, rawTop - distTop),
308
339
  bottomY,
309
- anchorBlockIndex: blockIndex,
310
- isMarginRelative: true,
340
+ anchorBlockIndex,
341
+ ...pageFrameRelative ? { isMarginRelative: true } : {},
311
342
  fullWidthBlock: true
312
343
  });
313
344
  }
@@ -367,14 +398,15 @@ function measureTextBoxBlock(tb, fieldValues) {
367
398
  };
368
399
  }
369
400
  /**
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.
401
+ * `true` for a section break that explicitly starts a new page
402
+ * (`nextPage`/`evenPage`/`oddPage`). An absent type follows
403
+ * `scheduleSectionBreak` and stays continuous, so measurement must retain any
404
+ * active exclusion zones across it. Used by `measureBlocks` to reset the
405
+ * running Y for a page-pinned band that lands right after the break.
406
+ * eigenpal #694.
375
407
  */
376
408
  function isNextPageSectionBreak(block) {
377
- return block.kind === "sectionBreak" && block.type !== "continuous";
409
+ return block.kind === "sectionBreak" && block.type !== void 0 && block.type !== "continuous";
378
410
  }
379
411
  /**
380
412
  * Measure all blocks with floating image support.
@@ -22,8 +22,10 @@ const DEFAULT_FONT_FAMILY = "Calibri";
22
22
  const DEFAULT_LINE_HEIGHT_MULTIPLIER = 1;
23
23
  const WIDTH_TOLERANCE = .5;
24
24
  const JUSTIFY_SHRINK_TOLERANCE_RATIO = .016;
25
+ const JUSTIFY_LITERAL_TAB_CONTINUATION_SHRINK_TOLERANCE_RATIO = .017;
25
26
  const JUSTIFY_PROSE_SHRINK_TOLERANCE_RATIO = .025;
26
27
  const JUSTIFY_HANGING_TAB_SHRINK_TOLERANCE_RATIO = .021;
28
+ const DEFAULT_LIST_HANGING_INDENT_PX = 24;
27
29
  const ALL_CAPS_RATIO_THRESHOLD = .8;
28
30
  /**
29
31
  * Find the longest prefix of `text` that fits within `maxWidth` pixels.
@@ -270,8 +272,16 @@ function hasFollowingTabOnLine(runs, tabIndex) {
270
272
  }
271
273
  return false;
272
274
  }
273
- function canClampTabToRightEdge(alignment, currentLineWidth) {
274
- if (alignment === "start" || alignment === "default") return currentLineWidth > WIDTH_TOLERANCE;
275
+ function hasPriorTabOnLine(runs, tabIndex) {
276
+ for (let i = tabIndex - 1; i >= 0; i--) {
277
+ const prior = runs[i];
278
+ if (!prior || isLineBreakRun(prior) || isImageRun(prior) && isBlockLayoutImageRun(prior)) break;
279
+ if (isTabRun(prior)) return true;
280
+ }
281
+ return false;
282
+ }
283
+ function canClampTabToRightEdge(alignment, currentLineWidth, hasPriorTab, followingWidth, availableWidth) {
284
+ if (alignment === "start" || alignment === "default") return currentLineWidth > WIDTH_TOLERANCE && (hasPriorTab || followingWidth <= availableWidth);
275
285
  return true;
276
286
  }
277
287
  /**
@@ -319,10 +329,18 @@ function uppercaseLetterRatio(text) {
319
329
  }
320
330
  return letters === 0 ? 0 : uppercase / letters;
321
331
  }
322
- function justifyShrinkToleranceRatio(block) {
332
+ function justifyShrinkToleranceRatio(block, isFirstLine, regularSpaceCount, nonBreakingSpaceCount) {
333
+ if (block.attrs?.listMarker !== void 0) {
334
+ if ((block.attrs.indent?.hanging ?? 0) <= DEFAULT_LIST_HANGING_INDENT_PX) return JUSTIFY_SHRINK_TOLERANCE_RATIO;
335
+ if (isFirstLine) return JUSTIFY_HANGING_TAB_SHRINK_TOLERANCE_RATIO;
336
+ const totalSpaces = regularSpaceCount + nonBreakingSpaceCount;
337
+ if (totalSpaces === 0) return JUSTIFY_PROSE_SHRINK_TOLERANCE_RATIO;
338
+ return Math.max(JUSTIFY_SHRINK_TOLERANCE_RATIO, JUSTIFY_PROSE_SHRINK_TOLERANCE_RATIO * (regularSpaceCount / totalSpaces));
339
+ }
323
340
  const hasTabStops = (block.attrs?.tabs?.length ?? 0) > 0;
324
341
  const hasTabRuns = block.runs.some(isTabRun);
325
- if (hasTabStops || hasTabRuns) return (block.attrs?.indent?.firstLine ?? 0) === 0 ? JUSTIFY_HANGING_TAB_SHRINK_TOLERANCE_RATIO : JUSTIFY_SHRINK_TOLERANCE_RATIO;
342
+ if (!isFirstLine && hasTabRuns && !hasTabStops) return JUSTIFY_LITERAL_TAB_CONTINUATION_SHRINK_TOLERANCE_RATIO;
343
+ if ((isFirstLine || hasTabStops) && (hasTabStops || hasTabRuns)) return (block.attrs?.indent?.firstLine ?? 0) === 0 ? JUSTIFY_HANGING_TAB_SHRINK_TOLERANCE_RATIO : JUSTIFY_SHRINK_TOLERANCE_RATIO;
326
344
  if (uppercaseLetterRatio(block.runs.map((run) => isTextRun(run) ? run.text ?? "" : "").join("")) > ALL_CAPS_RATIO_THRESHOLD) return JUSTIFY_SHRINK_TOLERANCE_RATIO;
327
345
  return JUSTIFY_PROSE_SHRINK_TOLERANCE_RATIO;
328
346
  }
@@ -412,7 +430,6 @@ function measureParagraph(block, maxWidth, options) {
412
430
  const attrs = block.attrs;
413
431
  const spacing = attrs?.spacing;
414
432
  const isJustifiedParagraph = attrs?.alignment === "justify";
415
- const justifyToleranceRatio = justifyShrinkToleranceRatio(block);
416
433
  const floatingZones = options?.floatingZones;
417
434
  const paragraphYOffset = options?.paragraphYOffset ?? 0;
418
435
  const indent = attrs?.indent;
@@ -506,6 +523,9 @@ function measureParagraph(block, maxWidth, options) {
506
523
  toRun: 0,
507
524
  toChar: 0,
508
525
  width: 0,
526
+ trailingWhitespaceWidth: 0,
527
+ regularSpaceCount: 0,
528
+ nonBreakingSpaceCount: 0,
509
529
  maxFontSize: DEFAULT_FONT_SIZE,
510
530
  maxFontMetrics: null,
511
531
  maxImageHeightPx: 0,
@@ -552,7 +572,7 @@ function measureParagraph(block, maxWidth, options) {
552
572
  fromChar: currentLine.fromChar,
553
573
  toRun: currentLine.toRun,
554
574
  toChar: currentLine.toChar,
555
- width: currentLine.width,
575
+ width: Math.max(0, currentLine.width - currentLine.trailingWhitespaceWidth),
556
576
  ...finalTypography
557
577
  };
558
578
  if (currentLine.leftOffset > 0) line.leftOffset = currentLine.leftOffset;
@@ -572,17 +592,21 @@ function measureParagraph(block, maxWidth, options) {
572
592
  const estimatedLineHeight = ptToPx(DEFAULT_FONT_SIZE) * DEFAULT_LINE_HEIGHT_MULTIPLIER;
573
593
  skipObstructingFloats(estimatedLineHeight, bodyContentWidth);
574
594
  const floatingMargins = getFloatingMargins(cumulativeHeight, estimatedLineHeight, floatingZones, paragraphYOffset);
595
+ const adjustedWidth = Math.max(1, getFloatingAvailableWidth(floatingMargins, bodyContentWidth));
575
596
  currentLine = {
576
597
  fromRun: runIndex,
577
598
  fromChar: charIndex,
578
599
  toRun: runIndex,
579
600
  toChar: charIndex,
580
601
  width: 0,
602
+ trailingWhitespaceWidth: 0,
603
+ regularSpaceCount: 0,
604
+ nonBreakingSpaceCount: 0,
581
605
  maxFontSize: DEFAULT_FONT_SIZE,
582
606
  maxFontMetrics: null,
583
607
  maxImageHeightPx: 0,
584
608
  maxMathHeightPx: 0,
585
- availableWidth: Math.max(1, getFloatingAvailableWidth(floatingMargins, bodyContentWidth)),
609
+ availableWidth: adjustedWidth,
586
610
  leftOffset: floatingMargins.leftMargin,
587
611
  rightOffset: floatingMargins.rightMargin,
588
612
  ...floatingMargins.segments?.length ? { segmentZones: floatingMargins.segments } : {}
@@ -620,6 +644,7 @@ function measureParagraph(block, maxWidth, options) {
620
644
  const contentX = indentLeft + (isFirstLine ? firstLineOffset + markerInlineWidth : 0) + lineX;
621
645
  const tabResult = calculateTabWidth(contentX, {
622
646
  ...attrs?.tabs !== void 0 ? { explicitStops: attrs.tabs } : {},
647
+ ...attrs?.defaultTabStopTwips !== void 0 ? { defaultTabInterval: attrs.defaultTabStopTwips } : {},
623
648
  leftIndent: pixelsToTwips(indentLeft)
624
649
  }, {
625
650
  followingWidth,
@@ -627,12 +652,13 @@ function measureParagraph(block, maxWidth, options) {
627
652
  });
628
653
  let tabWidth = tabResult.width;
629
654
  const lineRightEdgeX = indentLeft + (isFirstLine ? firstLineOffset + markerInlineWidth : 0) + currentLine.availableWidth + currentLine.leftOffset;
630
- if (!hasFollowingTabOnLine(runs, runIndex) && canClampTabToRightEdge(tabResult.alignment, currentLine.width) && (tabWidth > 0 || followingWidth > 0) && contentX + tabWidth + followingWidth > lineRightEdgeX + WIDTH_TOLERANCE) tabWidth = Math.max(1, lineRightEdgeX - contentX - followingWidth);
655
+ if (!hasFollowingTabOnLine(runs, runIndex) && canClampTabToRightEdge(tabResult.alignment, currentLine.width, hasPriorTabOnLine(runs, runIndex), followingWidth, currentLine.availableWidth) && (tabWidth > 0 || followingWidth > 0) && contentX + tabWidth + followingWidth > lineRightEdgeX + WIDTH_TOLERANCE) tabWidth = Math.max(1, lineRightEdgeX - contentX - followingWidth);
631
656
  if (currentLine.width + tabWidth > currentLine.availableWidth + WIDTH_TOLERANCE) {
632
657
  startNewLine(runIndex, 0);
633
658
  updateMaxFont(style);
634
659
  }
635
660
  currentLine.width += tabWidth;
661
+ currentLine.trailingWhitespaceWidth = 0;
636
662
  currentLine.toRun = runIndex;
637
663
  currentLine.toChar = 1;
638
664
  continue;
@@ -662,6 +688,7 @@ function measureParagraph(block, maxWidth, options) {
662
688
  const imageFootprintPx = imageHeight + (run.distTop ?? 0) + (run.distBottom ?? 0);
663
689
  if (imageFootprintPx > currentLine.maxImageHeightPx) currentLine.maxImageHeightPx = imageFootprintPx;
664
690
  currentLine.width += imageWidth;
691
+ currentLine.trailingWhitespaceWidth = 0;
665
692
  currentLine.toRun = runIndex;
666
693
  currentLine.toChar = 1;
667
694
  continue;
@@ -676,6 +703,7 @@ function measureParagraph(block, maxWidth, options) {
676
703
  updateMaxFont(style);
677
704
  }
678
705
  currentLine.width += fieldWidth;
706
+ currentLine.trailingWhitespaceWidth = 0;
679
707
  currentLine.toRun = runIndex;
680
708
  currentLine.toChar = 1;
681
709
  continue;
@@ -696,6 +724,7 @@ function measureParagraph(block, maxWidth, options) {
696
724
  }
697
725
  if (mathFootprintPx > currentLine.maxMathHeightPx) currentLine.maxMathHeightPx = mathFootprintPx;
698
726
  currentLine.width += mathWidth;
727
+ currentLine.trailingWhitespaceWidth = 0;
699
728
  currentLine.toRun = runIndex;
700
729
  currentLine.toChar = 1;
701
730
  continue;
@@ -720,13 +749,17 @@ function measureParagraph(block, maxWidth, options) {
720
749
  break;
721
750
  }
722
751
  const word = text.slice(charIndex, nextBreak);
723
- const wordWidth = measureTextWidth(word, style);
724
- const widthTolerance = isJustifiedParagraph ? Math.max(WIDTH_TOLERANCE, currentLine.availableWidth * justifyToleranceRatio) : WIDTH_TOLERANCE;
752
+ const measuredWord = trimTrailingSpacesAndTabs(word);
753
+ const wordWidth = measureTextWidth(measuredWord, style);
754
+ const fullWordWidth = measureTextWidth(word, style);
755
+ const regularSpaces = measuredWord.split(" ").length - 1;
756
+ const nonBreakingSpaces = measuredWord.split("\xA0").length - 1;
757
+ const widthTolerance = isJustifiedParagraph ? Math.max(WIDTH_TOLERANCE, currentLine.availableWidth * justifyShrinkToleranceRatio(block, lines.length === 0, currentLine.regularSpaceCount + regularSpaces, currentLine.nonBreakingSpaceCount + nonBreakingSpaces)) : WIDTH_TOLERANCE;
725
758
  if (wordWidth > currentLine.availableWidth + widthTolerance) {
726
759
  let chunkStart = 0;
727
- while (chunkStart < word.length) {
760
+ while (chunkStart < measuredWord.length) {
728
761
  const spaceLeft = currentLine.availableWidth - currentLine.width + WIDTH_TOLERANCE;
729
- let bestEnd = findMaxFittingLength(word.slice(chunkStart), style, spaceLeft);
762
+ let bestEnd = findMaxFittingLength(measuredWord.slice(chunkStart), style, spaceLeft);
730
763
  if (bestEnd === 0) {
731
764
  if (currentLine.width > 0) {
732
765
  startNewLine(runIndex, charIndex + chunkStart);
@@ -736,16 +769,26 @@ function measureParagraph(block, maxWidth, options) {
736
769
  bestEnd = 1;
737
770
  }
738
771
  const chunkEnd = chunkStart + bestEnd;
739
- const chunkWidth = measureTextWidth(word.slice(chunkStart, chunkEnd), style);
772
+ const chunk = measuredWord.slice(chunkStart, chunkEnd);
773
+ const chunkWidth = measureTextWidth(chunk, style);
740
774
  currentLine.width += chunkWidth;
775
+ currentLine.trailingWhitespaceWidth = chunkWidth - measureTextWidth(trimTrailingSpacesAndTabs(chunk), style);
776
+ currentLine.regularSpaceCount += chunk.split(" ").length - 1;
777
+ currentLine.nonBreakingSpaceCount += chunk.split("\xA0").length - 1;
741
778
  currentLine.toRun = runIndex;
742
779
  currentLine.toChar = charIndex + chunkEnd;
743
780
  chunkStart = chunkEnd;
744
- if (chunkStart < word.length) {
781
+ if (chunkStart < measuredWord.length) {
745
782
  startNewLine(runIndex, charIndex + chunkStart);
746
783
  updateMaxFont(lineHeightStyle);
747
784
  }
748
785
  }
786
+ const trailingWhitespaceWidth = fullWordWidth - wordWidth;
787
+ currentLine.width += trailingWhitespaceWidth;
788
+ currentLine.trailingWhitespaceWidth = trailingWhitespaceWidth;
789
+ currentLine.regularSpaceCount += word.split(" ").length - 1;
790
+ currentLine.nonBreakingSpaceCount += word.split("\xA0").length - 1;
791
+ currentLine.toChar = nextBreak;
749
792
  charIndex = nextBreak;
750
793
  continue;
751
794
  }
@@ -755,7 +798,10 @@ function measureParagraph(block, maxWidth, options) {
755
798
  startNewLine(runIndex, charIndex);
756
799
  updateMaxFont(lineHeightStyle);
757
800
  }
758
- currentLine.width += wordWidth;
801
+ currentLine.width += fullWordWidth;
802
+ currentLine.trailingWhitespaceWidth = fullWordWidth - wordWidth;
803
+ currentLine.regularSpaceCount += word.split(" ").length - 1;
804
+ currentLine.nonBreakingSpaceCount += word.split("\xA0").length - 1;
759
805
  currentLine.toRun = runIndex;
760
806
  currentLine.toChar = nextBreak;
761
807
  charIndex = nextBreak;
@@ -27,7 +27,8 @@ type PaginatorOptions = {
27
27
  w: number;
28
28
  h: number;
29
29
  }; /** Page margins. */
30
- margins: PageMargins;
30
+ margins: PageMargins; /** Swap left/right margins on even physical pages. */
31
+ mirrorMargins?: boolean;
31
32
  /**
32
33
  * Margins applied only to the very first page (page 1) of the paginator.
33
34
  * Used when a `<w:titlePg/>`-enabled section needs different margins for
@@ -46,7 +46,7 @@ function createPaginator(options) {
46
46
  }
47
47
  function currentPageUsesActiveLayout(state) {
48
48
  if (pendingPageSize || pendingMargins) return false;
49
- return arePageSizesEqual(state.page.size, pageSize) && areMarginsEqual(state.page.margins, margins);
49
+ return arePageSizesEqual(state.page.size, pageSize) && areMarginsEqual(state.page.margins, getPageMargins(state.page.number));
50
50
  }
51
51
  if (getContentHeight() <= 0) panic("Paginator: page size and margins yield no content area");
52
52
  let columnWidth = calculateColumnWidth(pageSize.w, margins.left, margins.right, columns);
@@ -62,14 +62,20 @@ function createPaginator(options) {
62
62
  recalculateColumnWidth();
63
63
  }
64
64
  function getPageMargins(pageNumber) {
65
- return pageNumber === 1 && options.firstPageMargins ? { ...options.firstPageMargins } : { ...margins };
65
+ const pageMargins = pageNumber === 1 && options.firstPageMargins ? { ...options.firstPageMargins } : { ...margins };
66
+ if (options.mirrorMargins === true && pageNumber % 2 === 0) return {
67
+ ...pageMargins,
68
+ left: pageMargins.right,
69
+ right: pageMargins.left
70
+ };
71
+ return pageMargins;
66
72
  }
67
73
  let columnRegionTop = margins.top;
68
74
  /**
69
75
  * Get X position for a given column index.
70
76
  */
71
77
  function getColumnX(columnIndex) {
72
- return margins.left + columnIndex * (columnWidth + columns.gap);
78
+ return (states.at(-1)?.page.margins.left ?? getPageMargins(1).left) + columnIndex * (columnWidth + columns.gap);
73
79
  }
74
80
  /**
75
81
  * Create a new page and add it to the list.
@@ -0,0 +1,7 @@
1
+ import { TextBoxBlock } from "./types.js";
2
+
3
+ //#region src/layout-engine/textBoxGroup.d.ts
4
+ declare const setTextBoxGroupId: (textBox: TextBoxBlock, groupId: string) => void;
5
+ declare const getTextBoxGroupId: (textBox: TextBoxBlock) => string | undefined;
6
+ //#endregion
7
+ export { getTextBoxGroupId, setTextBoxGroupId };
@@ -0,0 +1,8 @@
1
+ //#region src/layout-engine/textBoxGroup.ts
2
+ const textBoxGroupIds = /* @__PURE__ */ new WeakMap();
3
+ const setTextBoxGroupId = (textBox, groupId) => {
4
+ textBoxGroupIds.set(textBox, groupId);
5
+ };
6
+ const getTextBoxGroupId = (textBox) => textBoxGroupIds.get(textBox);
7
+ //#endregion
8
+ export { getTextBoxGroupId, setTextBoxGroupId };