@stll/folio-core 0.6.0 → 0.7.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 (40) hide show
  1. package/dist/controller/layoutPipeline.js +33 -14
  2. package/dist/docx/blockContentParser.js +2 -100
  3. package/dist/docx/groupDrawingParser.d.ts +1 -1
  4. package/dist/docx/groupDrawingParser.js +49 -8
  5. package/dist/docx/paragraphTextBoxEnrichment.d.ts +9 -0
  6. package/dist/docx/paragraphTextBoxEnrichment.js +104 -0
  7. package/dist/docx/runParser.js +11 -2
  8. package/dist/docx/server/boundedArchive.d.ts +24 -0
  9. package/dist/docx/server/boundedArchive.js +106 -0
  10. package/dist/docx/server/extractDocxText.d.ts +23 -0
  11. package/dist/docx/server/extractDocxText.js +154 -0
  12. package/dist/docx/tableParser.js +2 -0
  13. package/dist/layout-bridge/convert/toFlowBlocks.js +75 -19
  14. package/dist/layout-bridge/sectionColumns.js +6 -1
  15. package/dist/layout-engine/index.js +120 -19
  16. package/dist/layout-engine/keep-together.d.ts +7 -5
  17. package/dist/layout-engine/keep-together.js +20 -4
  18. package/dist/layout-engine/measure/cache.js +2 -0
  19. package/dist/layout-engine/measure/measureBlocks.js +3 -2
  20. package/dist/layout-engine/measure/measureParagraph.js +30 -12
  21. package/dist/layout-engine/paginator.d.ts +2 -0
  22. package/dist/layout-engine/paginator.js +27 -15
  23. package/dist/layout-engine/tableRowBreak.js +3 -0
  24. package/dist/layout-engine/types.d.ts +20 -5
  25. package/dist/layout-painter/index.js +1 -1
  26. package/dist/layout-painter/renderPage.js +7 -2
  27. package/dist/layout-painter/renderParagraph.js +93 -9
  28. package/dist/layout-painter/renderTable.js +88 -10
  29. package/dist/paged-layout/sectionBlockWidths.js +11 -3
  30. package/dist/prosemirror/conversion/fromProseDoc.js +11 -2
  31. package/dist/prosemirror/conversion/toProseDoc.js +28 -20
  32. package/dist/prosemirror/extensions/nodes/TableExtension.js +3 -2
  33. package/dist/prosemirror/schema/nodes.d.ts +2 -1
  34. package/dist/prosemirror/utils/tabCalculator.js +1 -1
  35. package/dist/server.d.ts +3 -1
  36. package/dist/server.js +3 -1
  37. package/dist/utils/formatToStyle.js +3 -3
  38. package/dist/utils/units.d.ts +6 -6
  39. package/dist/utils/units.js +8 -8
  40. package/package.json +1 -1
@@ -5,12 +5,23 @@ function isVisuallyEmptyParagraph(block) {
5
5
  const run = block.runs.at(0);
6
6
  return run?.kind === "text" && run.text === "";
7
7
  }
8
+ function startsTrailingTableSeparatorChain(blocks, index) {
9
+ const block = blocks[index];
10
+ if (block?.kind !== "paragraph" || !isVisuallyEmptyParagraph(block) || blocks[index - 1]?.kind !== "table") return false;
11
+ for (let nextIndex = index + 1; nextIndex < blocks.length; nextIndex++) {
12
+ const nextBlock = blocks[nextIndex];
13
+ if (nextBlock?.kind !== "paragraph") return false;
14
+ if (!isVisuallyEmptyParagraph(nextBlock)) return true;
15
+ }
16
+ return false;
17
+ }
8
18
  /**
9
19
  * Pre-scan blocks to find all keepNext chains.
10
20
  *
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.
21
+ * A chain starts with a paragraph whose keepNext=true or with an empty
22
+ * separator immediately following a table. It continues through further
23
+ * keepNext paragraphs and structural empty separators. The first visible
24
+ * non-keepNext paragraph is its anchor.
14
25
  *
15
26
  * Returns a map from chain start index to chain info.
16
27
  */
@@ -21,7 +32,7 @@ function computeKeepNextChains(blocks) {
21
32
  if (processed.has(i)) continue;
22
33
  const block = blocks[i];
23
34
  if (block.kind !== "paragraph") continue;
24
- if (!block.attrs?.keepNext) continue;
35
+ if (!block.attrs?.keepNext && !startsTrailingTableSeparatorChain(blocks, i)) continue;
25
36
  const memberIndices = [i];
26
37
  let endIndex = i;
27
38
  for (let j = i + 1; j < blocks.length; j++) {
@@ -67,6 +78,7 @@ function calculateChainHeight(chain, blocks, measures) {
67
78
  if (firstBlock?.kind !== "paragraph" || firstMeasure?.kind !== "paragraph") return 0;
68
79
  let totalHeight = (firstBlock.attrs?.spacing?.before ?? 0) + firstMeasure.totalHeight;
69
80
  let trailingSpacing = firstBlock.attrs?.spacing?.after ?? 0;
81
+ const startsWithTrailingTableSeparator = blocks[firstMemberIndex - 1]?.kind === "table" && isVisuallyEmptyParagraph(firstBlock) && !firstBlock.attrs?.keepNext;
70
82
  const successorIndices = [...chain.memberIndices.slice(1)];
71
83
  if (chain.anchorIndex !== -1) successorIndices.push(chain.anchorIndex);
72
84
  for (let index = 0; index < successorIndices.length; index++) {
@@ -80,6 +92,10 @@ function calculateChainHeight(chain, blocks, measures) {
80
92
  if (!firstLine) return totalHeight;
81
93
  const isAnchor = index === successorIndices.length - 1 && chain.anchorIndex !== -1;
82
94
  const isSplittable = successorMeasure.lines.length > 1 && !successorBlock.attrs?.keepLines;
95
+ if (isAnchor && startsWithTrailingTableSeparator && successorBlock.attrs?.widowControl !== false && successorMeasure.lines.length > 1) {
96
+ const secondLine = successorMeasure.lines.at(1);
97
+ return totalHeight + firstLine.lineHeight + (secondLine?.lineHeight ?? 0);
98
+ }
83
99
  if (isAnchor || isSplittable) return totalHeight + firstLine.lineHeight;
84
100
  totalHeight += successorMeasure.totalHeight;
85
101
  trailingSpacing = successorBlock.attrs?.spacing?.after ?? 0;
@@ -160,11 +160,13 @@ function hashParagraphBlock(block) {
160
160
  const attrs = block.attrs;
161
161
  if (attrs) {
162
162
  if (attrs.alignment) parts.push(`align:${attrs.alignment}`);
163
+ if (attrs.outlineLevel !== void 0) parts.push(`outline:${attrs.outlineLevel}`);
163
164
  if (attrs.indent) parts.push(`indent:${attrs.indent.left}|${attrs.indent.right}|${attrs.indent.firstLine}|${attrs.indent.hanging}`);
164
165
  if (attrs.spacing) parts.push(`spacing:${attrs.spacing.before}|${attrs.spacing.after}|${attrs.spacing.line}|${attrs.spacing.lineRule}`);
165
166
  if (attrs.defaultFontSize != null) parts.push(`dfs:${attrs.defaultFontSize}`);
166
167
  if (attrs.defaultFontFamily != null) parts.push(`dff:${attrs.defaultFontFamily}`);
167
168
  if (attrs.suppressEmptyParagraphHeight) parts.push("sup");
169
+ if (attrs.reserveEmptyOutlineHeight) parts.push("outline-empty-reserve");
168
170
  const borders = attrs.borders;
169
171
  if (borders) {
170
172
  const signature = (border) => border ? `${border.width ?? ""},${border.style ?? ""},${border.color ?? ""}` : "";
@@ -1,4 +1,4 @@
1
- import { DEFAULT_TEXTBOX_MARGINS, floatingTextBoxReservesBand, tableColumnsArePinned } from "../types.js";
1
+ import { DEFAULT_TEXTBOX_MARGINS, floatingTextBoxReservesBand, isFloatingTextBoxBlock, tableColumnsArePinned } from "../types.js";
2
2
  import { findClearLineY, measureParagraph } from "./measureParagraph.js";
3
3
  import { getCachedParagraphMeasure, setCachedParagraphMeasure } from "./cache.js";
4
4
  import { getTextBoxGroupId } from "../textBoxGroup.js";
@@ -56,6 +56,7 @@ function isInlineFlowImageRun(run) {
56
56
  return true;
57
57
  }
58
58
  function measureTableCellBlockVisualHeight(block, blockMeasure) {
59
+ if (block.kind === "textBox" && isFloatingTextBoxBlock(block)) return 0;
59
60
  if (block.kind !== "paragraph" || blockMeasure.kind !== "paragraph") {
60
61
  if ("totalHeight" in blockMeasure) return blockMeasure.totalHeight;
61
62
  if ("height" in blockMeasure) return blockMeasure.height;
@@ -95,7 +96,7 @@ function measureTableBlock(tableBlock, contentWidth, fieldValues) {
95
96
  columnWidths = Array.from({ length: colCount }, () => equalWidth);
96
97
  } else if (columnWidths.length > 0 && explicitWidthPx) {
97
98
  const totalWidth = columnWidths.reduce((sum, w) => sum + w, 0);
98
- if (totalWidth > 0 && Math.abs(totalWidth - explicitWidthPx) > 1) {
99
+ if (!(tableBlock.layout !== "fixed" && (tableBlock.widthType === void 0 || tableBlock.widthType === "dxa") && totalWidth - explicitWidthPx > 1) && totalWidth > 0 && Math.abs(totalWidth - explicitWidthPx) > 1) {
99
100
  const scale = explicitWidthPx / totalWidth;
100
101
  columnWidths = columnWidths.map((w) => w * scale);
101
102
  }
@@ -329,20 +329,34 @@ function uppercaseLetterRatio(text) {
329
329
  }
330
330
  return letters === 0 ? 0 : uppercase / letters;
331
331
  }
332
+ function fixedSpaceAdjustedShrinkTolerance({ tolerance, regularSpaceCount, nonBreakingSpaceCount }) {
333
+ const totalSpaces = regularSpaceCount + nonBreakingSpaceCount;
334
+ if (totalSpaces === 0) return tolerance;
335
+ return Math.max(JUSTIFY_SHRINK_TOLERANCE_RATIO, tolerance * (regularSpaceCount / totalSpaces));
336
+ }
332
337
  function justifyShrinkToleranceRatio(block, isFirstLine, regularSpaceCount, nonBreakingSpaceCount) {
333
338
  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
+ const hanging = block.attrs.indent?.hanging ?? 0;
340
+ if (isFirstLine) return hanging <= DEFAULT_LIST_HANGING_INDENT_PX ? JUSTIFY_SHRINK_TOLERANCE_RATIO : JUSTIFY_HANGING_TAB_SHRINK_TOLERANCE_RATIO;
341
+ const left = block.attrs.indent?.left ?? 0;
342
+ if (hanging > 0 && left > hanging) return JUSTIFY_SHRINK_TOLERANCE_RATIO;
343
+ return fixedSpaceAdjustedShrinkTolerance({
344
+ tolerance: JUSTIFY_PROSE_SHRINK_TOLERANCE_RATIO,
345
+ regularSpaceCount,
346
+ nonBreakingSpaceCount
347
+ });
339
348
  }
340
349
  const hasTabStops = (block.attrs?.tabs?.length ?? 0) > 0;
341
350
  const hasTabRuns = block.runs.some(isTabRun);
351
+ if (isFirstLine && hasTabRuns && (block.attrs?.indent?.hanging ?? 0) > 0) return JUSTIFY_SHRINK_TOLERANCE_RATIO;
342
352
  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;
353
+ if (hasTabRuns && (isFirstLine || hasTabStops)) return (block.attrs?.indent?.firstLine ?? 0) === 0 ? JUSTIFY_HANGING_TAB_SHRINK_TOLERANCE_RATIO : JUSTIFY_SHRINK_TOLERANCE_RATIO;
344
354
  if (uppercaseLetterRatio(block.runs.map((run) => isTextRun(run) ? run.text ?? "" : "").join("")) > ALL_CAPS_RATIO_THRESHOLD) return JUSTIFY_SHRINK_TOLERANCE_RATIO;
345
- return JUSTIFY_PROSE_SHRINK_TOLERANCE_RATIO;
355
+ return fixedSpaceAdjustedShrinkTolerance({
356
+ tolerance: JUSTIFY_PROSE_SHRINK_TOLERANCE_RATIO,
357
+ regularSpaceCount,
358
+ nonBreakingSpaceCount
359
+ });
346
360
  }
347
361
  function trimTrailingSpacesAndTabs(text) {
348
362
  let end = text.length;
@@ -479,15 +493,17 @@ function measureParagraph(block, maxWidth, options) {
479
493
  };
480
494
  }
481
495
  const emptyMetrics = calculateEmptyParagraphMetrics(attrs?.defaultFontSize ?? DEFAULT_FONT_SIZE, spacing, attrs?.defaultFontFamily ?? DEFAULT_FONT_FAMILY);
496
+ const outlineLineHeight = attrs?.reserveEmptyOutlineHeight ? emptyMetrics.lineHeight * 2 : emptyMetrics.lineHeight;
482
497
  lines.push({
483
498
  fromRun: 0,
484
499
  fromChar: 0,
485
500
  toRun: 0,
486
501
  toChar: 0,
487
502
  width: 0,
488
- ...emptyMetrics
503
+ ...emptyMetrics,
504
+ lineHeight: outlineLineHeight
489
505
  });
490
- let totalHeight = emptyMetrics.lineHeight;
506
+ let totalHeight = outlineLineHeight;
491
507
  if (spacing?.before) totalHeight += spacing.before;
492
508
  if (spacing?.after) totalHeight += spacing.after;
493
509
  return {
@@ -651,8 +667,9 @@ function measureParagraph(block, maxWidth, options) {
651
667
  decimalPrefixWidth
652
668
  });
653
669
  let tabWidth = tabResult.width;
670
+ const landsOnLeftIndent = tabResult.alignment === "start" && indentLeft > 0 && Math.abs(contentX + tabWidth - indentLeft) <= WIDTH_TOLERANCE;
654
671
  const lineRightEdgeX = indentLeft + (isFirstLine ? firstLineOffset + markerInlineWidth : 0) + currentLine.availableWidth + currentLine.leftOffset;
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);
672
+ if (!landsOnLeftIndent && !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);
656
673
  if (currentLine.width + tabWidth > currentLine.availableWidth + WIDTH_TOLERANCE) {
657
674
  startNewLine(runIndex, 0);
658
675
  updateMaxFont(style);
@@ -794,12 +811,13 @@ function measureParagraph(block, maxWidth, options) {
794
811
  }
795
812
  const rawGlueWidth = nextBreak === text.length && word.length > 0 && !isBreakChar(word[word.length - 1]) ? trailingGlueWidths[runIndex] ?? 0 : 0;
796
813
  const glueWidth = rawGlueWidth > 0 && wordWidth + rawGlueWidth <= getPostWrapAvailableWidth() + widthTolerance ? rawGlueWidth : 0;
797
- if (currentLine.width > 0 && currentLine.width + wordWidth + glueWidth > currentLine.availableWidth + widthTolerance) {
814
+ if (wordWidth > 0 && currentLine.width > 0 && currentLine.width + wordWidth + glueWidth > currentLine.availableWidth + widthTolerance) {
798
815
  startNewLine(runIndex, charIndex);
799
816
  updateMaxFont(lineHeightStyle);
800
817
  }
801
818
  currentLine.width += fullWordWidth;
802
- currentLine.trailingWhitespaceWidth = fullWordWidth - wordWidth;
819
+ const wordTrailingWhitespaceWidth = fullWordWidth - wordWidth;
820
+ currentLine.trailingWhitespaceWidth = wordWidth === 0 ? currentLine.trailingWhitespaceWidth + wordTrailingWhitespaceWidth : wordTrailingWhitespaceWidth;
803
821
  currentLine.regularSpaceCount += word.split(" ").length - 1;
804
822
  currentLine.nonBreakingSpaceCount += word.split("\xA0").length - 1;
805
823
  currentLine.toRun = runIndex;
@@ -55,6 +55,8 @@ declare function createPaginator(options: PaginatorOptions): {
55
55
  count: number;
56
56
  gap: number;
57
57
  equalWidth?: boolean;
58
+ widths?: number[];
59
+ gaps?: number[];
58
60
  separator?: boolean;
59
61
  }; /** Get current state. */
60
62
  getCurrentState: () => PageState; /** Get available height in current column. */
@@ -7,12 +7,13 @@ import { panic } from "better-result";
7
7
  * Tracks the current page, cursor position, and available space.
8
8
  * Creates new pages when content doesn't fit.
9
9
  */
10
- /**
11
- * Calculate the width of a single column.
12
- */
13
- function calculateColumnWidth(pageWidth, leftMargin, rightMargin, columns) {
14
- return (pageWidth - leftMargin - rightMargin - (columns.count - 1) * columns.gap) / columns.count;
10
+ /** Calculate active column widths, preferring authored unequal widths. */
11
+ function calculateColumnWidths(pageWidth, leftMargin, rightMargin, columns) {
12
+ if (columns.widths?.length === columns.count && columns.widths.every((width) => Number.isFinite(width) && width > 0)) return [...columns.widths];
13
+ const equalWidth = (pageWidth - leftMargin - rightMargin - (columns.count - 1) * columns.gap) / columns.count;
14
+ return Array.from({ length: columns.count }, () => equalWidth);
15
15
  }
16
+ const gapAfterColumn = (columns, columnIndex) => columns.gaps?.[columnIndex] ?? columns.gap;
16
17
  function arePageSizesEqual(left, right) {
17
18
  return left.w === right.w && left.h === right.h;
18
19
  }
@@ -49,9 +50,9 @@ function createPaginator(options) {
49
50
  return arePageSizesEqual(state.page.size, pageSize) && areMarginsEqual(state.page.margins, getPageMargins(state.page.number));
50
51
  }
51
52
  if (getContentHeight() <= 0) panic("Paginator: page size and margins yield no content area");
52
- let columnWidth = calculateColumnWidth(pageSize.w, margins.left, margins.right, columns);
53
- function recalculateColumnWidth() {
54
- columnWidth = calculateColumnWidth(pageSize.w, margins.left, margins.right, columns);
53
+ let columnWidths = calculateColumnWidths(pageSize.w, margins.left, margins.right, columns);
54
+ function recalculateColumnWidths() {
55
+ columnWidths = calculateColumnWidths(pageSize.w, margins.left, margins.right, columns);
55
56
  }
56
57
  function applyPendingLayout() {
57
58
  if (pendingPageSize) pageSize = pendingPageSize;
@@ -59,7 +60,7 @@ function createPaginator(options) {
59
60
  pendingPageSize = void 0;
60
61
  pendingMargins = void 0;
61
62
  if (getContentHeight() <= 0) panic("Paginator: section page size and margins yield no content area");
62
- recalculateColumnWidth();
63
+ recalculateColumnWidths();
63
64
  }
64
65
  function getPageMargins(pageNumber) {
65
66
  const pageMargins = pageNumber === 1 && options.firstPageMargins ? { ...options.firstPageMargins } : { ...margins };
@@ -71,11 +72,14 @@ function createPaginator(options) {
71
72
  return pageMargins;
72
73
  }
73
74
  let columnRegionTop = margins.top;
75
+ let columnRegionMaxBottom = margins.top;
74
76
  /**
75
77
  * Get X position for a given column index.
76
78
  */
77
79
  function getColumnX(columnIndex) {
78
- return (states.at(-1)?.page.margins.left ?? getPageMargins(1).left) + columnIndex * (columnWidth + columns.gap);
80
+ let x = states.at(-1)?.page.margins.left ?? getPageMargins(1).left;
81
+ for (let index = 0; index < columnIndex; index++) x += (columnWidths[index] ?? columnWidths[0] ?? 0) + gapAfterColumn(columns, index);
82
+ return x;
79
83
  }
80
84
  /**
81
85
  * Create a new page and add it to the list.
@@ -111,6 +115,7 @@ function createPaginator(options) {
111
115
  pages.push(page);
112
116
  states.push(state);
113
117
  columnRegionTop = topMargin;
118
+ columnRegionMaxBottom = topMargin;
114
119
  if (options.onNewPage) options.onNewPage(state);
115
120
  return state;
116
121
  }
@@ -140,6 +145,7 @@ function createPaginator(options) {
140
145
  */
141
146
  function advanceColumn(state) {
142
147
  if (state.columnIndex < columns.count - 1) {
148
+ columnRegionMaxBottom = Math.max(columnRegionMaxBottom, state.cursorY);
143
149
  state.columnIndex += 1;
144
150
  state.cursorY = columnRegionTop;
145
151
  state.trailingSpacing = 0;
@@ -156,7 +162,7 @@ function createPaginator(options) {
156
162
  let state = getCurrentState();
157
163
  const safeHeight = Number.isFinite(height) && height > 0 ? height : 0;
158
164
  while (!fits(safeHeight, state)) {
159
- if (safeHeight > state.contentBottom - state.topMargin) {
165
+ if (safeHeight > state.contentBottom - columnRegionTop) {
160
166
  if (state.cursorY !== state.topMargin) state = advanceColumn(state);
161
167
  return state;
162
168
  }
@@ -273,12 +279,17 @@ function createPaginator(options) {
273
279
  * column advancement stays below existing content (for continuous breaks).
274
280
  */
275
281
  function updateColumns(newColumns) {
276
- columns = newColumns;
277
- columnWidth = calculateColumnWidth(pageSize.w, margins.left, margins.right, columns);
282
+ const previousColumnCount = columns.count;
278
283
  const state = getCurrentState();
284
+ const previousRegionBottom = Math.max(columnRegionMaxBottom, state.cursorY);
285
+ columns = newColumns;
286
+ recalculateColumnWidths();
279
287
  if (columns.count > 1) state.page.columns = { ...columns };
280
288
  else delete state.page.columns;
289
+ state.contentBottom = state.rawContentBottom - state.footnoteHeight;
290
+ if (previousColumnCount > 1) state.cursorY = previousRegionBottom;
281
291
  columnRegionTop = state.cursorY;
292
+ columnRegionMaxBottom = state.cursorY;
282
293
  state.columnIndex = 0;
283
294
  }
284
295
  function updatePageLayout(newPageSize, newMargins, applyImmediately = true) {
@@ -290,7 +301,7 @@ function createPaginator(options) {
290
301
  if (newPageSize) pageSize = { ...newPageSize };
291
302
  if (newMargins) margins = { ...newMargins };
292
303
  if (getContentHeight() <= 0) panic("Paginator: section page size and margins yield no content area");
293
- recalculateColumnWidth();
304
+ recalculateColumnWidths();
294
305
  pendingPageSize = void 0;
295
306
  pendingMargins = void 0;
296
307
  }
@@ -312,7 +323,8 @@ function createPaginator(options) {
312
323
  states,
313
324
  /** Column width in pixels (use getColumnWidth() for current value after updates). */
314
325
  get columnWidth() {
315
- return columnWidth;
326
+ const columnIndex = states.at(-1)?.columnIndex ?? 0;
327
+ return columnWidths[columnIndex] ?? columnWidths[0] ?? getContentWidth();
316
328
  },
317
329
  /** Get current column layout (returns copy to prevent external mutation). */
318
330
  get columns() {
@@ -1,3 +1,4 @@
1
+ import { isFloatingTextBoxBlock } from "./types.js";
1
2
  import { measureParagraph } from "./measure/measureParagraph.js";
2
3
  import { buildTableCellFloatingZones, getTableCellContentWidth, getTableCellFloatingImages } from "./measure/tableCellFloating.js";
3
4
  //#region src/layout-engine/tableRowBreak.ts
@@ -74,6 +75,8 @@ function cellBreakGeometry(cell, measure) {
74
75
  y = blockTop + blockMeasure.totalHeight;
75
76
  paragraphY += blockMeasure.totalHeight;
76
77
  } else if (blockMeasure) {
78
+ const block = cellBlocks?.[i];
79
+ if (block?.kind === "textBox" && isFloatingTextBoxBlock(block)) continue;
77
80
  const blockHeight = getAtomicBlockHeight(blockMeasure);
78
81
  if (blockHeight > 0) {
79
82
  const top = y;
@@ -286,8 +286,13 @@ type ListNumPr = {
286
286
  * Paragraph block attributes.
287
287
  */
288
288
  type ParagraphAttrs = {
289
- alignment?: "left" | "center" | "right" | "justify";
290
- spacing?: ParagraphSpacing;
289
+ alignment?: "left" | "center" | "right" | "justify"; /** OOXML outline level (`w:outlineLvl`), where zero is the top level. */
290
+ outlineLevel?: number;
291
+ spacing?: ParagraphSpacing; /** Spacing sides resolved from OOXML automatic paragraph spacing. */
292
+ automaticSpacing?: {
293
+ before?: boolean;
294
+ after?: boolean;
295
+ };
291
296
  /**
292
297
  * Tracks which `spacing` sides came from inline (`<w:pPr><w:spacing>`)
293
298
  * formatting versus inherited via paragraph style. Word collapses
@@ -300,6 +305,12 @@ type ParagraphAttrs = {
300
305
  before?: boolean;
301
306
  after?: boolean;
302
307
  };
308
+ /**
309
+ * Whether an empty paragraph carries direct paragraph formatting in its
310
+ * source `w:pPr`. Word treats such a blank as authored and keeps its
311
+ * inherited spacing; a bare empty paragraph still collapses that spacing.
312
+ */
313
+ hasDirectParagraphFormatting?: boolean;
303
314
  indent?: ParagraphIndent;
304
315
  keepNext?: boolean;
305
316
  keepLines?: boolean;
@@ -319,7 +330,8 @@ type ParagraphAttrs = {
319
330
  borders?: ParagraphBorders;
320
331
  shading?: string;
321
332
  tabs?: TabStop[]; /** Render structural empty paragraphs as zero-height anchors. */
322
- suppressEmptyParagraphHeight?: boolean;
333
+ suppressEmptyParagraphHeight?: boolean; /** Reserve the reference extra line advance for a story-leading empty level-0 outline paragraph. */
334
+ reserveEmptyOutlineHeight?: boolean;
323
335
  numPr?: ListNumPr;
324
336
  listMarker?: string;
325
337
  listIsBullet?: boolean;
@@ -846,7 +858,9 @@ type Page = {
846
858
  type ColumnLayout = {
847
859
  count: number;
848
860
  gap: number;
849
- equalWidth?: boolean; /** Draw vertical separator line between columns (w:sep). */
861
+ equalWidth?: boolean; /** Authored widths for unequal-width section columns. */
862
+ widths?: number[]; /** Authored space after each column except the last. */
863
+ gaps?: number[]; /** Draw vertical separator line between columns (w:sep). */
850
864
  separator?: boolean;
851
865
  };
852
866
  /**
@@ -904,7 +918,8 @@ type LayoutOptions = {
904
918
  w: number;
905
919
  h: number;
906
920
  }; /** Body-level final section margins. */
907
- finalMargins?: PageMargins; /** Column configuration. */
921
+ finalMargins?: PageMargins; /** Body-level final section column configuration. */
922
+ finalColumns?: ColumnLayout; /** Column configuration. */
908
923
  columns?: ColumnLayout; /** Gap between rendered pages (for UI). */
909
924
  pageGap?: number; /** Default line height multiplier. */
910
925
  defaultLineHeight?: number; /** Header content heights by variant. */
@@ -1,8 +1,8 @@
1
1
  import { FRAGMENT_CLASS_NAMES, renderFragment } from "./renderFragment.js";
2
2
  import { IMAGE_CLASS_NAMES, renderImageFragment } from "./renderImage.js";
3
3
  import { renderLine, renderParagraphFragment, sliceRunsForLine } from "./renderParagraph.js";
4
- import { TABLE_CLASS_NAMES, renderTableFragment } from "./renderTable.js";
5
4
  import { TEXTBOX_CLASS_NAMES, renderTextBoxFragment } from "./renderTextBox.js";
5
+ import { TABLE_CLASS_NAMES, renderTableFragment } from "./renderTable.js";
6
6
  import { renderPage, renderPages } from "./renderPage.js";
7
7
  import { prefersReducedMotionBehavior } from "../paged-layout/scrollNavigation.js";
8
8
  import { createFeatureRegistry } from "./registry/registry.js";
@@ -8,8 +8,8 @@ import { renderFragment } from "./renderFragment.js";
8
8
  import { applyImageVisualAttrs, hasImageVisualAttrs, renderImageFragment } from "./renderImage.js";
9
9
  import { emuToPixels } from "./renderUtils.js";
10
10
  import { renderParagraphFragment } from "./renderParagraph.js";
11
- import { renderTableFragment } from "./renderTable.js";
12
11
  import { renderTextBoxFragment } from "./renderTextBox.js";
12
+ import { renderTableFragment } from "./renderTable.js";
13
13
  import { renderWatermarkLayer } from "./renderWatermark.js";
14
14
  import { panic } from "better-result";
15
15
  //#region src/layout-painter/renderPage.ts
@@ -560,7 +560,12 @@ function renderHeaderFooterContent(content, context, options, layout) {
560
560
  ...block.pmStart !== void 0 ? { pmStart: block.pmStart } : {},
561
561
  ...block.pmEnd !== void 0 ? { pmEnd: block.pmEnd } : {}
562
562
  }, block, measure, hfContext, { document: doc });
563
- fragEl.style.top = `${cursorY}px`;
563
+ const textBoxTop = block.position ? resolveHeaderFooterFloatTop({
564
+ height: measure.height,
565
+ paragraphY: cursorY,
566
+ position: block.position
567
+ }, layout) : cursorY;
568
+ fragEl.style.top = `${textBoxTop}px`;
564
569
  fragEl.style.left = resolveHeaderFooterFloatLeft(measure.width, block.position?.horizontal, layout);
565
570
  if (block.wrapType === "behind") fragEl.style.zIndex = "-1";
566
571
  containerEl.append(fragEl);
@@ -709,6 +709,75 @@ function splitTextRunsByEastAsia(runs) {
709
709
  }
710
710
  return result;
711
711
  }
712
+ const paintsLineEdgeSpaces = (run) => Boolean(run.underline || run.strike || run.highlight || run.shading || run.hyperlink || run.hidden || run.templatePreview || run.isInsertion || run.isDeletion || run.commentIds?.length || run.textEffect || run.emphasisMark);
713
+ const splitTextRunAt = (run, index) => {
714
+ const leading = {
715
+ ...run,
716
+ text: run.text.slice(0, index)
717
+ };
718
+ const trailing = {
719
+ ...run,
720
+ text: run.text.slice(index)
721
+ };
722
+ if (run.pmStart === void 0) return [leading, trailing];
723
+ const splitPosition = Math.min(run.pmStart + index, run.pmEnd ?? Number.POSITIVE_INFINITY);
724
+ leading.pmEnd = splitPosition;
725
+ trailing.pmStart = splitPosition;
726
+ return [leading, trailing];
727
+ };
728
+ /**
729
+ * Word keeps ordinary line-edge spaces addressable in the document model but
730
+ * gives them no painted advance. Collapsible trailing spaces always collapse;
731
+ * leading spaces collapse only after a soft wrap, not at the authored
732
+ * paragraph start or after a manual line break. Split paint runs to preserve
733
+ * exact PM ranges.
734
+ */
735
+ const splitCollapsibleLineEdgeSpaces = (sourceRuns, collapseLeading) => {
736
+ const runs = [...sourceRuns];
737
+ const collapsedLeadingRuns = /* @__PURE__ */ new Set();
738
+ const collapsedTrailingRuns = /* @__PURE__ */ new Set();
739
+ for (let index = runs.length - 1; index >= 0; index--) {
740
+ const run = runs[index];
741
+ if (!run || !isTextRun(run)) break;
742
+ if (run.text.length === 0) continue;
743
+ const trailingSpaces = / +$/u.exec(run.text);
744
+ if (!trailingSpaces || paintsLineEdgeSpaces(run)) break;
745
+ if (trailingSpaces.index === 0) {
746
+ collapsedTrailingRuns.add(run);
747
+ continue;
748
+ }
749
+ const [leading, trailing] = splitTextRunAt(run, trailingSpaces.index);
750
+ runs.splice(index, 1, leading, trailing);
751
+ collapsedTrailingRuns.add(trailing);
752
+ break;
753
+ }
754
+ if (collapseLeading) for (let index = 0; index < runs.length; index++) {
755
+ const run = runs[index];
756
+ if (!run || !isTextRun(run)) break;
757
+ if (run.text.length === 0) continue;
758
+ const leadingSpaces = /^ +/u.exec(run.text);
759
+ if (!leadingSpaces || paintsLineEdgeSpaces(run)) break;
760
+ if (leadingSpaces[0].length === run.text.length) {
761
+ collapsedLeadingRuns.add(run);
762
+ continue;
763
+ }
764
+ const [leading, trailing] = splitTextRunAt(run, leadingSpaces[0].length);
765
+ runs.splice(index, 1, leading, trailing);
766
+ collapsedLeadingRuns.add(leading);
767
+ break;
768
+ }
769
+ return {
770
+ runs,
771
+ collapsedLeadingRuns,
772
+ collapsedTrailingRuns
773
+ };
774
+ };
775
+ const startsAfterSoftWrap = (block, line) => {
776
+ if (line.fromChar > 0) return true;
777
+ if (line.fromRun === 0) return false;
778
+ const previousRun = block.runs.at(line.fromRun - 1);
779
+ return previousRun !== void 0 && !isLineBreakRun(previousRun);
780
+ };
712
781
  /**
713
782
  * Sub-pixel tolerance when comparing canvas-measured widths against the DOM's
714
783
  * actual right edge. Accumulated rounding from canvas measureText vs. browser
@@ -878,7 +947,20 @@ function renderLine(block, line, alignment, doc, options) {
878
947
  lineEl.style.boxSizing = "content-box";
879
948
  lineEl.style.height = `${line.lineHeight}px`;
880
949
  lineEl.style.lineHeight = `${line.lineHeight}px`;
881
- const runsForLine = splitTextRunsByEastAsia(sliceRunsForLine(block, line));
950
+ const splitRuns = splitTextRunsByEastAsia(sliceRunsForLine(block, line));
951
+ const { runs: runsForLine, collapsedLeadingRuns: collapsedLeadingSpaceRuns, collapsedTrailingRuns: collapsedTrailingSpaceRuns } = splitCollapsibleLineEdgeSpaces(splitRuns, startsAfterSoftWrap(block, line));
952
+ const isCollapsedLineEdgeSpaceRun = (run) => collapsedLeadingSpaceRuns.has(run) || collapsedTrailingSpaceRuns.has(run);
953
+ const renderLineTextRun = (run) => {
954
+ const runEl = renderTextRun(run, doc);
955
+ if (collapsedLeadingSpaceRuns.has(run)) runEl.dataset["collapsedLeadingSpaces"] = "true";
956
+ if (collapsedTrailingSpaceRuns.has(run)) runEl.dataset["collapsedTrailingSpaces"] = "true";
957
+ if (isCollapsedLineEdgeSpaceRun(run)) {
958
+ runEl.style.fontSize = "0";
959
+ runEl.style.letterSpacing = "0";
960
+ runEl.style.wordSpacing = "0";
961
+ }
962
+ return runEl;
963
+ };
882
964
  const onlyRun = runsForLine.length === 1 ? runsForLine[0] : void 0;
883
965
  if (onlyRun && isMathRun(onlyRun) && onlyRun.display === "block" && block.attrs?.alignment !== "right") lineEl.style.textAlign = "center";
884
966
  if (runsForLine.length === 1 && isImageRun(runsForLine[0])) {
@@ -910,9 +992,11 @@ function renderLine(block, line, alignment, doc, options) {
910
992
  if (!options.isLastLine || options.paragraphEndsWithLineBreak) {
911
993
  const firstLineIndentPx = options.isFirstLine ? options.firstLineIndentPx ?? 0 : 0;
912
994
  const firstLineHangingPx = Math.max(0, -firstLineIndentPx);
913
- const justifyCapacityPx = options.availableWidth + firstLineHangingPx;
995
+ const hasVisibleListMarker = options.isFirstLine && block.attrs?.listMarker && !block.attrs.listMarkerHidden;
996
+ const firstLineHangingExpansionPx = hasVisibleListMarker ? Math.min(firstLineHangingPx, Math.max(0, options.leftIndentPx ?? 0)) : firstLineHangingPx;
997
+ const justifyCapacityPx = options.availableWidth + firstLineHangingExpansionPx;
914
998
  const overfullPx = line.width - justifyCapacityPx;
915
- const shrinkableSpaces = countShrinkableSpaces(runsForLine);
999
+ const shrinkableSpaces = countShrinkableSpaces(runsForLine.filter((run) => !isTextRun(run) || !isCollapsedLineEdgeSpaceRun(run)));
916
1000
  if (overfullPx > RIGHT_EDGE_EPSILON_PX && shrinkableSpaces > 0) {
917
1001
  lineEl.style.textAlign = "left";
918
1002
  lineEl.style.textAlignLast = "auto";
@@ -925,7 +1009,7 @@ function renderLine(block, line, alignment, doc, options) {
925
1009
  lineEl.style.textAlign = "justify";
926
1010
  lineEl.style.textAlignLast = "justify";
927
1011
  }
928
- const listFirstLineOffset = options.isFirstLine && block.attrs?.listMarker && !block.attrs.listMarkerHidden ? options.firstLineIndentPx ?? 0 : 0;
1012
+ const listFirstLineOffset = hasVisibleListMarker ? Math.max(firstLineIndentPx, -firstLineHangingExpansionPx) : 0;
929
1013
  lineEl.style.width = `${options.availableWidth - listFirstLineOffset}px`;
930
1014
  }
931
1015
  }
@@ -987,7 +1071,7 @@ function renderLine(block, line, alignment, doc, options) {
987
1071
  for (let j = i + 1; j < runsForLine.length; j++) {
988
1072
  const next = runsForLine[j];
989
1073
  if (isTabRun(next) || isLineBreakRun(next)) break;
990
- if (isTextRun(next)) lineEl.append(renderTextRun(next, doc));
1074
+ if (isTextRun(next)) lineEl.append(renderLineTextRun(next));
991
1075
  else if (isFieldRun(next) && options?.context) lineEl.append(renderFieldRun(next, doc, options.context));
992
1076
  else if (isImageRun(next)) {
993
1077
  if (isFloatingImageRun(next)) continue;
@@ -1001,13 +1085,14 @@ function renderLine(block, line, alignment, doc, options) {
1001
1085
  break;
1002
1086
  }
1003
1087
  let tabWidth = tabResult.width;
1004
- if (lineRightEdgeX !== void 0 && canClampTabToRightEdge(tabResult.alignment, i > 0, runsForLine.slice(0, i).some(isTabRun), options?.isLastLine === true) && currentX + tabWidth + followingWidthForCheck > lineRightEdgeX) tabWidth = Math.max(1, lineRightEdgeX - currentX - followingWidthForCheck);
1088
+ if (!(tabResult.alignment === "start" && leftIndentPx > 0 && Math.abs(currentX + tabWidth - leftIndentPx) <= RIGHT_EDGE_EPSILON_PX) && lineRightEdgeX !== void 0 && canClampTabToRightEdge(tabResult.alignment, i > 0, runsForLine.slice(0, i).some(isTabRun), options?.isLastLine === true) && currentX + tabWidth + followingWidthForCheck > lineRightEdgeX) tabWidth = Math.max(1, lineRightEdgeX - currentX - followingWidthForCheck);
1005
1089
  const tabEl = renderTabRun(run, doc, tabWidth, tabResult.leader);
1006
1090
  lineEl.append(tabEl);
1007
1091
  currentX += tabWidth;
1008
1092
  } else if (isTextRun(run)) {
1009
- const runEl = renderTextRun(run, doc);
1093
+ const runEl = renderLineTextRun(run);
1010
1094
  lineEl.append(runEl);
1095
+ if (isCollapsedLineEdgeSpaceRun(run)) continue;
1011
1096
  const fontSize = run.fontSize || 11;
1012
1097
  const fontFamily = run.fontFamily || "Calibri";
1013
1098
  const measuredWidth = measureText(run.allCaps ? run.text.toLocaleUpperCase() : run.text, fontSize, fontFamily, {
@@ -1249,7 +1334,6 @@ function renderParagraphFragment(fragment, block, measure, context, options = {}
1249
1334
  } else if (indentLeft > 0) lineEl.style.paddingLeft = `${indentLeft}px`;
1250
1335
  else if (hasFirstLine && !isFlexLine) lineEl.style.textIndent = `${indent.firstLine ?? 0}px`;
1251
1336
  } else if (indentLeft > 0) lineEl.style.paddingLeft = `${indentLeft}px`;
1252
- else if (hasHanging && indentLeft === 0) lineEl.style.paddingLeft = `${indent.hanging ?? 0}px`;
1253
1337
  if (indentRight > 0) lineEl.style.paddingRight = `${indentRight}px`;
1254
1338
  if (isFirstLine && block.attrs?.listMarker && !block.attrs.listMarkerHidden) {
1255
1339
  const hanging = indent?.hanging ?? 0;
@@ -1269,7 +1353,7 @@ function renderParagraphFragment(fragment, block, measure, context, options = {}
1269
1353
  const markerFontSize = block.attrs.listMarkerFontSize ?? firstTextRun?.fontSize ?? block.attrs.defaultFontSize;
1270
1354
  const marker = renderListMarker(block.attrs.listMarker, getListMarkerInlineWidth(block), doc, markerFontFamily, markerFontSize, block.attrs.listMarkerRevision, block.attrs.listMarkerSecondSlotOffsetTwips);
1271
1355
  const markerMarginLeft = markerStart - Math.min(indentLeft, 0);
1272
- if (markerMarginLeft < 0 && indentLeft !== 0) marker.style.marginLeft = `${markerMarginLeft}px`;
1356
+ if (markerMarginLeft < 0) marker.style.marginLeft = `${markerMarginLeft}px`;
1273
1357
  lineEl.prepend(marker);
1274
1358
  }
1275
1359
  fragmentEl.append(lineEl);