@stll/folio-core 0.6.1 → 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.
@@ -44,9 +44,7 @@ function collectSectionConfigs(blocks, initialConfig, finalConfig) {
44
44
  }
45
45
  /**
46
46
  * Whether a paragraph block has no visible content (no runs, or a single
47
- * empty text run). Word collapses style-inherited spacing on empty
48
- * paragraphs (only direct `<w:pPr><w:spacing>` formatting survives) — see
49
- * eigenpal #402.
47
+ * empty text run).
50
48
  */
51
49
  function isEmptyParagraph(block) {
52
50
  if (block.runs.length === 0) return true;
@@ -68,16 +66,27 @@ function pageHasVisibleBodyContent(page, blocksById) {
68
66
  }
69
67
  return false;
70
68
  }
69
+ function continuesNumberedSequence(previous, current) {
70
+ if (previous?.kind !== "paragraph" || current.kind !== "paragraph") return false;
71
+ const previousNumPr = previous.attrs?.numPr;
72
+ const currentNumPr = current.attrs?.numPr;
73
+ if (previousNumPr?.numId === void 0 || currentNumPr?.numId === void 0) return false;
74
+ return previousNumPr.numId === currentNumPr.numId && previousNumPr.ilvl === currentNumPr.ilvl;
75
+ }
76
+ function pageStartsWithPreviousParagraphContinuation(page, previousBlock) {
77
+ if (previousBlock?.kind !== "paragraph") return false;
78
+ return page.fragments.some((fragment) => fragment.kind === "paragraph" && String(fragment.blockId) === String(previousBlock.id) && fragment.continuesFromPrev === true);
79
+ }
71
80
  /**
72
81
  * Get spacing before a paragraph block. Empty paragraphs whose
73
82
  * `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.
83
+ * zero. Reference layout keeps inherited spacing when the empty paragraph itself is
84
+ * authored through direct `w:pPr` formatting or an explicit `w:pStyle`.
76
85
  */
77
86
  function getSpacingBefore(block) {
78
87
  const value = block.attrs?.spacing?.before ?? 0;
79
88
  if (value === 0) return 0;
80
- if (isEmptyParagraph(block) && !block.attrs?.styleId && !block.attrs?.spacingExplicit?.before) return 0;
89
+ if (isEmptyParagraph(block) && !block.attrs?.styleId && !block.attrs?.hasDirectParagraphFormatting && !block.attrs?.spacingExplicit?.before) return 0;
81
90
  return value;
82
91
  }
83
92
  /**
@@ -87,9 +96,59 @@ function getSpacingBefore(block) {
87
96
  function getSpacingAfter(block) {
88
97
  const value = block.attrs?.spacing?.after ?? 0;
89
98
  if (value === 0) return 0;
90
- if (isEmptyParagraph(block) && !block.attrs?.styleId && !block.attrs?.spacingExplicit?.after) return 0;
99
+ if (isEmptyParagraph(block) && !block.attrs?.styleId && !block.attrs?.hasDirectParagraphFormatting && !block.attrs?.spacingExplicit?.after) return 0;
91
100
  return value;
92
101
  }
102
+ function balancedParagraphSectionHeight({ blocks, measures, startIndex, endIndex, incomingSpacing, columnCount, availableHeight }) {
103
+ if (columnCount <= 1 || startIndex >= endIndex || availableHeight <= 0) return;
104
+ const lineUnits = [];
105
+ let totalHeight = 0;
106
+ let tallestUnit = 0;
107
+ let trailingSpacing = incomingSpacing;
108
+ for (let index = startIndex; index < endIndex; index++) {
109
+ const block = blocks[index];
110
+ const measure = measures[index];
111
+ if (block?.kind !== "paragraph" || measure?.kind !== "paragraph") return;
112
+ if (block.attrs?.keepNext === true || block.attrs?.keepLines === true || block.runs.some((run) => run.kind === "text" && run.footnoteRefId !== void 0)) return;
113
+ const leadingSpacing = Math.max(getSpacingBefore(block), trailingSpacing);
114
+ if (measure.lines.length === 0) {
115
+ if (leadingSpacing > 0) {
116
+ lineUnits.push(leadingSpacing);
117
+ totalHeight += leadingSpacing;
118
+ tallestUnit = Math.max(tallestUnit, leadingSpacing);
119
+ }
120
+ }
121
+ for (let lineIndex = 0; lineIndex < measure.lines.length; lineIndex++) {
122
+ const line = measure.lines[lineIndex];
123
+ if (!line) continue;
124
+ const unitHeight = measuredLineAdvance(line) + (lineIndex === 0 ? leadingSpacing : 0);
125
+ lineUnits.push(unitHeight);
126
+ totalHeight += unitHeight;
127
+ tallestUnit = Math.max(tallestUnit, unitHeight);
128
+ }
129
+ if (tallestUnit > availableHeight || totalHeight > availableHeight * columnCount) return;
130
+ trailingSpacing = getSpacingAfter(block);
131
+ }
132
+ if (totalHeight <= 0) return;
133
+ const columnsNeeded = (targetHeight) => {
134
+ let usedHeight = 0;
135
+ let usedColumns = 1;
136
+ for (const unitHeight of lineUnits) if (usedHeight > 0 && usedHeight + unitHeight > targetHeight) {
137
+ usedColumns += 1;
138
+ usedHeight = unitHeight;
139
+ } else usedHeight += unitHeight;
140
+ return usedColumns;
141
+ };
142
+ let lower = Math.max(tallestUnit, totalHeight / columnCount);
143
+ let upper = Math.min(totalHeight, availableHeight);
144
+ if (columnsNeeded(upper) > columnCount) return;
145
+ for (let iteration = 0; iteration < 32; iteration++) {
146
+ const middle = (lower + upper) / 2;
147
+ if (columnsNeeded(middle) <= columnCount) upper = middle;
148
+ else lower = middle;
149
+ }
150
+ return Math.ceil(upper * 1e3) / 1e3;
151
+ }
93
152
  function hasWidowControl(block) {
94
153
  return block.attrs?.widowControl !== false;
95
154
  }
@@ -123,6 +182,24 @@ function applyContextualSpacing(blocks) {
123
182
  for (const block of blocks) if (block.kind === "table") for (const row of block.rows) for (const cell of row.cells) applyContextualSpacing(cell.blocks);
124
183
  else if (block.kind === "textBox") applyContextualSpacing(block.content);
125
184
  }
185
+ /** Suppress automatic inter-item spacing within one numbered sequence. */
186
+ const applyAutomaticListSpacing = (blocks) => {
187
+ for (let index = 1; index < blocks.length; index++) {
188
+ const previous = blocks[index - 1];
189
+ const current = blocks[index];
190
+ if (previous?.kind !== "paragraph" || current?.kind !== "paragraph" || !continuesNumberedSequence(previous, current)) continue;
191
+ if (previous.attrs?.automaticSpacing?.after && previous.attrs.spacing) previous.attrs.spacing = {
192
+ ...previous.attrs.spacing,
193
+ after: 0
194
+ };
195
+ if (current.attrs?.automaticSpacing?.before && current.attrs.spacing) current.attrs.spacing = {
196
+ ...current.attrs.spacing,
197
+ before: 0
198
+ };
199
+ }
200
+ for (const block of blocks) if (block.kind === "table") for (const row of block.rows) for (const cell of row.cells) applyAutomaticListSpacing(cell.blocks);
201
+ else if (block.kind === "textBox") applyAutomaticListSpacing(block.content);
202
+ };
126
203
  /**
127
204
  * Layout a document: convert blocks + measures into pages with positioned fragments.
128
205
  *
@@ -171,6 +248,7 @@ function layoutDocument(blocks, measures, options) {
171
248
  ...options.sectionHeaderFooterRefs !== void 0 ? { sectionHeaderFooterRefs: options.sectionHeaderFooterRefs } : {}
172
249
  });
173
250
  applyContextualSpacing(blocks);
251
+ applyAutomaticListSpacing(blocks);
174
252
  const keepNextChains = computeKeepNextChains(blocks);
175
253
  const midChainIndices = getMidChainIndices(keepNextChains);
176
254
  const blocksById = /* @__PURE__ */ new Map();
@@ -179,7 +257,7 @@ function layoutDocument(blocks, measures, options) {
179
257
  let activeSectionMarginTop = initialConfig.margins.top;
180
258
  let activeSectionPageHeight = initialConfig.pageSize.h;
181
259
  let activeSectionMarginBottom = initialConfig.margins.bottom;
182
- let naturalPageAdvanceSinceRenderedBreak = false;
260
+ let naturalPageAdvanceSinceRenderedBreak = "none";
183
261
  for (let i = 0; i < blocks.length; i++) {
184
262
  const block = blocks[i];
185
263
  const measure = measures[i];
@@ -187,13 +265,15 @@ function layoutDocument(blocks, measures, options) {
187
265
  if (hasPageBreakBefore(block)) paginator.forcePageBreak();
188
266
  else if (hasRenderedPageBreak) {
189
267
  const state = paginator.getCurrentState();
190
- if (!(block.kind === "paragraph" && isPaginationEmptyParagraph(block) && naturalPageAdvanceSinceRenderedBreak) && pageHasVisibleBodyContent(state.page, blocksById)) paginator.forcePageBreak();
268
+ if (!(pageStartsWithPreviousParagraphContinuation(state.page, blocks[i - 1]) || naturalPageAdvanceSinceRenderedBreak === "reflowBoundary" || block.kind === "paragraph" && isPaginationEmptyParagraph(block) && naturalPageAdvanceSinceRenderedBreak !== "none" || naturalPageAdvanceSinceRenderedBreak !== "none" && continuesNumberedSequence(blocks[i - 1], block)) && pageHasVisibleBodyContent(state.page, blocksById)) paginator.forcePageBreak();
191
269
  }
192
- if (hasRenderedPageBreak || hasPageBreakBefore(block)) naturalPageAdvanceSinceRenderedBreak = false;
270
+ if (hasRenderedPageBreak || hasPageBreakBefore(block)) naturalPageAdvanceSinceRenderedBreak = "none";
193
271
  const chain = keepNextChains.get(i);
194
272
  if (chain && !midChainIndices.has(i)) {
195
273
  const chainHeight = calculateChainHeight(chain, blocks, measures);
274
+ const pageBeforeChainLayout = paginator.getCurrentState().page.number;
196
275
  paginator.ensureFits(chainHeight);
276
+ if (paginator.getCurrentState().page.number > pageBeforeChainLayout) naturalPageAdvanceSinceRenderedBreak = "reflowBoundary";
197
277
  }
198
278
  const pageBeforeBlockLayout = paginator.getCurrentState().page.number;
199
279
  switch (block.kind) {
@@ -224,6 +304,23 @@ function layoutDocument(blocks, measures, options) {
224
304
  case "sectionBreak": {
225
305
  const nextSectionConfig = sectionConfigs[sectionIdx + 1] ?? initialConfig;
226
306
  handleSectionBreak(block, paginator, nextSectionConfig, sectionBreakTypes[sectionIdx + 1] ?? sectionBreakTypes[sectionIdx], sectionIdx + 1);
307
+ const nextColumns = nextSectionConfig.columns;
308
+ const nextBreakIndex = breakIndices[sectionIdx + 1] ?? blocks.length;
309
+ const nextBreak = blocks[nextBreakIndex];
310
+ const sectionEndsContinuously = nextBreakIndex === blocks.length || nextBreak?.kind === "sectionBreak" && nextBreak.type === "continuous";
311
+ if (nextColumns && sectionEndsContinuously) {
312
+ const state = paginator.getCurrentState();
313
+ const balancedHeight = balancedParagraphSectionHeight({
314
+ blocks,
315
+ measures,
316
+ startIndex: i + 1,
317
+ endIndex: nextBreakIndex,
318
+ incomingSpacing: state.trailingSpacing,
319
+ columnCount: nextColumns.count,
320
+ availableHeight: paginator.getAvailableHeight()
321
+ });
322
+ if (balancedHeight !== void 0) state.contentBottom = Math.min(state.contentBottom, state.cursorY + balancedHeight);
323
+ }
227
324
  activeSectionMarginTop = nextSectionConfig.margins.top;
228
325
  activeSectionPageHeight = nextSectionConfig.pageSize.h;
229
326
  activeSectionMarginBottom = nextSectionConfig.margins.bottom;
@@ -233,8 +330,8 @@ function layoutDocument(blocks, measures, options) {
233
330
  default: break;
234
331
  }
235
332
  const isVisibleBodyBlock = block.kind === "paragraph" ? !isEmptyParagraph(block) : block.kind !== "pageBreak" && block.kind !== "columnBreak" && block.kind !== "sectionBreak";
236
- if (block.kind === "pageBreak" || block.kind === "columnBreak" || block.kind === "sectionBreak") naturalPageAdvanceSinceRenderedBreak = false;
237
- else if (isVisibleBodyBlock && paginator.getCurrentState().page.number > pageBeforeBlockLayout) naturalPageAdvanceSinceRenderedBreak = true;
333
+ if (block.kind === "pageBreak" || block.kind === "columnBreak" || block.kind === "sectionBreak") naturalPageAdvanceSinceRenderedBreak = "none";
334
+ else if (isVisibleBodyBlock && paginator.getCurrentState().page.number > pageBeforeBlockLayout) naturalPageAdvanceSinceRenderedBreak = paginator.pages[pageBeforeBlockLayout - 1]?.fragments.some(({ blockId }) => blockId === block.id) ? "reflowBoundary" : "ordinary";
238
335
  }
239
336
  if (paginator.pages.length === 0) paginator.getCurrentState();
240
337
  return {
@@ -429,9 +526,10 @@ function layoutTable(block, measure, paginator, footnoteHeightById) {
429
526
  let x = paginator.getColumnX(columnIndex);
430
527
  if (block.justification === "center") x += (paginator.columnWidth - measure.totalWidth) / 2;
431
528
  else if (block.justification === "right") x = x + paginator.columnWidth - measure.totalWidth;
529
+ else if (block.indent !== void 0) x += block.indent;
432
530
  else {
433
531
  const leadingCellMargin = block.rows.at(0)?.cells.at(0)?.padding?.left ?? 0;
434
- x += (block.indent ?? 0) - leadingCellMargin;
532
+ x -= leadingCellMargin;
435
533
  }
436
534
  return x;
437
535
  };
@@ -638,9 +736,10 @@ function layoutFloatingTable(block, measure, paginator, contentWidth) {
638
736
  else if (spec === "center") y = baseY + (contentHeight - tableHeight) / 2;
639
737
  }
640
738
  if (!usedExplicitY) y = paginator.ensureFits(tableHeight).cursorY;
641
- const pageAnchored = floating?.horzAnchor === "page";
642
- const minX = pageAnchored ? 0 : margins.left;
643
- const maxX = pageAnchored ? page.size.w - tableWidth : margins.left + contentWidth - tableWidth;
739
+ const usesNumericOffset = floating?.tblpX !== void 0;
740
+ const clampToPage = floating?.horzAnchor === "page" || usesNumericOffset;
741
+ const minX = clampToPage ? 0 : margins.left;
742
+ const maxX = clampToPage ? page.size.w - tableWidth : margins.left + contentWidth - tableWidth;
644
743
  if (Number.isFinite(maxX)) x = Math.max(minX, Math.min(x, maxX));
645
744
  const fragment = {
646
745
  kind: "table",
@@ -2,8 +2,9 @@ import { FlowBlock, Measure } from "./types.js";
2
2
 
3
3
  //#region src/layout-engine/keep-together.d.ts
4
4
  /**
5
- * A chain of keepNext-linked paragraphs, including empty separators Word
6
- * carries through to the next paragraph with visible content.
5
+ * A chain of paragraphs that Word keeps with following content. This includes
6
+ * explicit keepNext links and a trailing table separator that would otherwise
7
+ * be stranded at the bottom of a page.
7
8
  */
8
9
  type KeepNextChain = {
9
10
  /** Index of the first paragraph in the chain. */startIndex: number; /** Index of the last keepNext or pass-through empty member. */
@@ -14,9 +15,10 @@ type KeepNextChain = {
14
15
  /**
15
16
  * Pre-scan blocks to find all keepNext chains.
16
17
  *
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.
18
+ * A chain starts with a paragraph whose keepNext=true or with an empty
19
+ * separator immediately following a table. It continues through further
20
+ * keepNext paragraphs and structural empty separators. The first visible
21
+ * non-keepNext paragraph is its anchor.
20
22
  *
21
23
  * Returns a map from chain start index to chain info.
22
24
  */
@@ -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 ?? ""}` : "";
@@ -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;
@@ -72,6 +72,7 @@ function createPaginator(options) {
72
72
  return pageMargins;
73
73
  }
74
74
  let columnRegionTop = margins.top;
75
+ let columnRegionMaxBottom = margins.top;
75
76
  /**
76
77
  * Get X position for a given column index.
77
78
  */
@@ -114,6 +115,7 @@ function createPaginator(options) {
114
115
  pages.push(page);
115
116
  states.push(state);
116
117
  columnRegionTop = topMargin;
118
+ columnRegionMaxBottom = topMargin;
117
119
  if (options.onNewPage) options.onNewPage(state);
118
120
  return state;
119
121
  }
@@ -143,6 +145,7 @@ function createPaginator(options) {
143
145
  */
144
146
  function advanceColumn(state) {
145
147
  if (state.columnIndex < columns.count - 1) {
148
+ columnRegionMaxBottom = Math.max(columnRegionMaxBottom, state.cursorY);
146
149
  state.columnIndex += 1;
147
150
  state.cursorY = columnRegionTop;
148
151
  state.trailingSpacing = 0;
@@ -159,7 +162,7 @@ function createPaginator(options) {
159
162
  let state = getCurrentState();
160
163
  const safeHeight = Number.isFinite(height) && height > 0 ? height : 0;
161
164
  while (!fits(safeHeight, state)) {
162
- if (safeHeight > state.contentBottom - state.topMargin) {
165
+ if (safeHeight > state.contentBottom - columnRegionTop) {
163
166
  if (state.cursorY !== state.topMargin) state = advanceColumn(state);
164
167
  return state;
165
168
  }
@@ -276,12 +279,17 @@ function createPaginator(options) {
276
279
  * column advancement stays below existing content (for continuous breaks).
277
280
  */
278
281
  function updateColumns(newColumns) {
282
+ const previousColumnCount = columns.count;
283
+ const state = getCurrentState();
284
+ const previousRegionBottom = Math.max(columnRegionMaxBottom, state.cursorY);
279
285
  columns = newColumns;
280
286
  recalculateColumnWidths();
281
- const state = getCurrentState();
282
287
  if (columns.count > 1) state.page.columns = { ...columns };
283
288
  else delete state.page.columns;
289
+ state.contentBottom = state.rawContentBottom - state.footnoteHeight;
290
+ if (previousColumnCount > 1) state.cursorY = previousRegionBottom;
284
291
  columnRegionTop = state.cursorY;
292
+ columnRegionMaxBottom = state.cursorY;
285
293
  state.columnIndex = 0;
286
294
  }
287
295
  function updatePageLayout(newPageSize, newMargins, applyImmediately = true) {
@@ -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;
@@ -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])) {
@@ -914,7 +996,7 @@ function renderLine(block, line, alignment, doc, options) {
914
996
  const firstLineHangingExpansionPx = hasVisibleListMarker ? Math.min(firstLineHangingPx, Math.max(0, options.leftIndentPx ?? 0)) : firstLineHangingPx;
915
997
  const justifyCapacityPx = options.availableWidth + firstLineHangingExpansionPx;
916
998
  const overfullPx = line.width - justifyCapacityPx;
917
- const shrinkableSpaces = countShrinkableSpaces(runsForLine);
999
+ const shrinkableSpaces = countShrinkableSpaces(runsForLine.filter((run) => !isTextRun(run) || !isCollapsedLineEdgeSpaceRun(run)));
918
1000
  if (overfullPx > RIGHT_EDGE_EPSILON_PX && shrinkableSpaces > 0) {
919
1001
  lineEl.style.textAlign = "left";
920
1002
  lineEl.style.textAlignLast = "auto";
@@ -989,7 +1071,7 @@ function renderLine(block, line, alignment, doc, options) {
989
1071
  for (let j = i + 1; j < runsForLine.length; j++) {
990
1072
  const next = runsForLine[j];
991
1073
  if (isTabRun(next) || isLineBreakRun(next)) break;
992
- if (isTextRun(next)) lineEl.append(renderTextRun(next, doc));
1074
+ if (isTextRun(next)) lineEl.append(renderLineTextRun(next));
993
1075
  else if (isFieldRun(next) && options?.context) lineEl.append(renderFieldRun(next, doc, options.context));
994
1076
  else if (isImageRun(next)) {
995
1077
  if (isFloatingImageRun(next)) continue;
@@ -1003,13 +1085,14 @@ function renderLine(block, line, alignment, doc, options) {
1003
1085
  break;
1004
1086
  }
1005
1087
  let tabWidth = tabResult.width;
1006
- 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);
1007
1089
  const tabEl = renderTabRun(run, doc, tabWidth, tabResult.leader);
1008
1090
  lineEl.append(tabEl);
1009
1091
  currentX += tabWidth;
1010
1092
  } else if (isTextRun(run)) {
1011
- const runEl = renderTextRun(run, doc);
1093
+ const runEl = renderLineTextRun(run);
1012
1094
  lineEl.append(runEl);
1095
+ if (isCollapsedLineEdgeSpaceRun(run)) continue;
1013
1096
  const fontSize = run.fontSize || 11;
1014
1097
  const fontFamily = run.fontFamily || "Calibri";
1015
1098
  const measuredWidth = measureText(run.allCaps ? run.text.toLocaleUpperCase() : run.text, fontSize, fontFamily, {
@@ -549,6 +549,7 @@ function convertTable(table, styleResolver, context) {
549
549
  const fallbackTableStyle = tableStyleId ? void 0 : defaultTableStyle;
550
550
  const conditionalTableStyleId = tableStyle?.styleId ?? fallbackTableStyle?.styleId;
551
551
  const resolvedTableBorders = table.formatting?.borders ?? tableStyle?.tblPr?.borders ?? fallbackTableStyle?.tblPr?.borders;
552
+ const resolvedTableIndent = table.formatting?.indent ?? tableStyle?.tblPr?.indent ?? fallbackTableStyle?.tblPr?.indent;
552
553
  const tableCellMargins = table.formatting?.cellMargins ?? tableStyle?.tblPr?.cellMargins ?? fallbackTableStyle?.tblPr?.cellMargins;
553
554
  let cellMarginsAttr;
554
555
  if (tableCellMargins) {
@@ -569,6 +570,7 @@ function convertTable(table, styleResolver, context) {
569
570
  if (cellMarginsAttr) attrs.cellMargins = cellMarginsAttr;
570
571
  if (table.formatting?.look) attrs.look = table.formatting.look;
571
572
  if (table.formatting?.borders) attrs.borders = table.formatting.borders;
573
+ if (resolvedTableIndent) attrs._resolvedIndent = resolvedTableIndent;
572
574
  if (table.formatting) attrs._originalFormatting = table.formatting;
573
575
  if (table.propertyChanges && table.propertyChanges.length > 0) attrs.tblPrChange = [...table.propertyChanges];
574
576
  const conditionalStyles = {};