@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
@@ -0,0 +1,154 @@
1
+ import { findAllDeep, findChild, findDeep, getAttributeAnyPrefix, getLocalName, getTextContent, parseXml } from "../xmlParser.js";
2
+ import { loadDocxArchive } from "./boundedArchive.js";
3
+ //#region src/docx/server/extractDocxText.ts
4
+ const HEADER_FOOTER_PATH = /^word\/(?:header|footer)\d+\.xml$/u;
5
+ const childElements = (element) => element.elements?.filter((child) => child.type === "element") ?? [];
6
+ const collectText = (element) => {
7
+ let text = "";
8
+ const walk = (node) => {
9
+ const localName = getLocalName(node.name ?? "");
10
+ if (localName === "t") {
11
+ text += getTextContent(node);
12
+ return;
13
+ }
14
+ if (localName === "br") {
15
+ text += "\n";
16
+ return;
17
+ }
18
+ if (localName === "tab") {
19
+ text += " ";
20
+ return;
21
+ }
22
+ if (localName === "del" || localName === "delText" || localName === "moveFrom") return;
23
+ for (const child of childElements(node)) walk(child);
24
+ };
25
+ walk(element);
26
+ return text;
27
+ };
28
+ const readParagraphProperties = (paragraph) => {
29
+ const properties = findChild(paragraph, "w", "pPr");
30
+ if (!properties) return {};
31
+ const result = {};
32
+ const styleValue = getAttributeAnyPrefix(findChild(properties, "w", "pStyle"), "val");
33
+ if (styleValue !== null) result.style = styleValue;
34
+ const alignment = getAttributeAnyPrefix(findChild(properties, "w", "jc"), "val");
35
+ if (alignment === "left" || alignment === "center" || alignment === "right" || alignment === "both") result.alignment = alignment;
36
+ return result;
37
+ };
38
+ const readRunMetrics = (paragraph) => {
39
+ const metrics = [];
40
+ for (const run of childElements(paragraph)) {
41
+ if (getLocalName(run.name ?? "") !== "r") continue;
42
+ const properties = findChild(run, "w", "rPr");
43
+ const boldProperty = findChild(properties, "w", "b");
44
+ const boldValue = getAttributeAnyPrefix(boldProperty, "val");
45
+ const bold = boldProperty !== null && boldValue !== "0" && boldValue !== "false";
46
+ const sizeValue = getAttributeAnyPrefix(findChild(properties, "w", "sz"), "val");
47
+ const parsedSize = sizeValue === null ? NaN : Number.parseInt(sizeValue, 10);
48
+ const fontSize = Number.isFinite(parsedSize) && parsedSize > 0 ? parsedSize : void 0;
49
+ let chars = 0;
50
+ for (const textNode of findAllDeep(run, "w", "t")) chars += getTextContent(textNode).length;
51
+ if (chars === 0) continue;
52
+ const entry = {
53
+ bold,
54
+ chars
55
+ };
56
+ if (fontSize !== void 0) entry.fontSize = fontSize;
57
+ metrics.push(entry);
58
+ }
59
+ return metrics;
60
+ };
61
+ const extractContainer = ({ container, source, startIndex }) => {
62
+ const paragraphs = [];
63
+ let charCount = 0;
64
+ for (const [offset, paragraph] of findAllDeep(container, "w", "p").entries()) {
65
+ const text = collectText(paragraph);
66
+ const entry = {
67
+ index: startIndex + offset,
68
+ text,
69
+ source
70
+ };
71
+ const { style, alignment } = readParagraphProperties(paragraph);
72
+ if (style !== void 0) entry.style = style;
73
+ if (alignment !== void 0) entry.alignment = alignment;
74
+ const runs = readRunMetrics(paragraph);
75
+ if (runs.length > 0) {
76
+ const totalChars = runs.reduce((sum, run) => sum + run.chars, 0);
77
+ if (runs.reduce((sum, run) => sum + (run.bold ? run.chars : 0), 0) > totalChars / 2) entry.bold = true;
78
+ const firstFontSize = runs.find((run) => run.fontSize !== void 0)?.fontSize;
79
+ if (firstFontSize !== void 0) entry.fontSize = firstFontSize;
80
+ }
81
+ paragraphs.push(entry);
82
+ charCount += text.length;
83
+ }
84
+ return {
85
+ paragraphs,
86
+ charCount
87
+ };
88
+ };
89
+ const extractParts = async ({ archive, source, rootName, startIndex }) => {
90
+ const paragraphs = [];
91
+ let charCount = 0;
92
+ let nextIndex = startIndex;
93
+ const prefix = `word/${source}`;
94
+ const paths = archive.entries.filter((path) => HEADER_FOOTER_PATH.test(path) && path.startsWith(prefix)).toSorted();
95
+ for (const path of paths) {
96
+ const xml = await archive.readEntryString(path);
97
+ if (xml === null) continue;
98
+ const container = findDeep(parseXml(xml), "w", rootName);
99
+ if (!container) continue;
100
+ const result = extractContainer({
101
+ container,
102
+ source,
103
+ startIndex: nextIndex
104
+ });
105
+ paragraphs.push(...result.paragraphs);
106
+ charCount += result.charCount;
107
+ nextIndex += result.paragraphs.length;
108
+ }
109
+ return {
110
+ paragraphs,
111
+ charCount
112
+ };
113
+ };
114
+ const createEmptyResult = () => ({
115
+ paragraphs: [],
116
+ charCount: 0,
117
+ view: "accepted"
118
+ });
119
+ /** Extract paragraph text and formatting metadata from a DOCX archive. */
120
+ const extractDocxText = async (bytes) => {
121
+ const archive = await loadDocxArchive(bytes);
122
+ const documentXml = await archive.readEntryString("word/document.xml");
123
+ if (documentXml === null) return createEmptyResult();
124
+ const body = findDeep(parseXml(documentXml), "w", "body");
125
+ if (!body) return createEmptyResult();
126
+ const headers = await extractParts({
127
+ archive,
128
+ source: "header",
129
+ rootName: "hdr",
130
+ startIndex: 0
131
+ });
132
+ const bodyResult = extractContainer({
133
+ container: body,
134
+ source: "body",
135
+ startIndex: headers.paragraphs.length
136
+ });
137
+ const footers = await extractParts({
138
+ archive,
139
+ source: "footer",
140
+ rootName: "ftr",
141
+ startIndex: headers.paragraphs.length + bodyResult.paragraphs.length
142
+ });
143
+ return {
144
+ paragraphs: [
145
+ ...headers.paragraphs,
146
+ ...bodyResult.paragraphs,
147
+ ...footers.paragraphs
148
+ ],
149
+ charCount: headers.charCount + bodyResult.charCount + footers.charCount,
150
+ view: "accepted"
151
+ };
152
+ };
153
+ //#endregion
154
+ export { extractDocxText };
@@ -3,6 +3,7 @@ import { parseBookmarkEnd, parseBookmarkStart } from "./bookmarkParser.js";
3
3
  import { BorderStyleSchema, FloatingTableXSpecSchema, FloatingTableYSpecSchema, ShadingPatternSchema, TableCellTextDirectionSchema, ThemeColorSlotSchema, narrowEnum } from "./parserEnums.js";
4
4
  import { parseParagraph } from "./paragraphParser.js";
5
5
  import { appendBookmarkMarkerToLastParagraphInBlocks, appendBookmarkMarkerToLastParagraphInCells, prependBookmarkMarkersToFirstParagraphInBlocks, prependBookmarkMarkersToFirstParagraphInCell } from "./bookmarkPlacement.js";
6
+ import { enrichParagraphTextBoxes } from "./paragraphTextBoxEnrichment.js";
6
7
  //#region src/docx/tableParser.ts
7
8
  /**
8
9
  * Parse a table measurement (width, height, etc.)
@@ -489,6 +490,7 @@ function parseCellContent(tcElement, styles, theme, numbering, rels, media, opti
489
490
  const localName = getLocalName(child.name);
490
491
  if (localName === "p") {
491
492
  const para = parseParagraph(child, styles, theme, numbering, rels, media, options);
493
+ enrichParagraphTextBoxes(para, child, styles, theme, numbering, rels, media);
492
494
  prependPendingBookmarkMarkers(para, pendingBookmarkMarkers);
493
495
  content.push(para);
494
496
  } else if (localName === "tbl") {
@@ -1,13 +1,14 @@
1
1
  import { NUMBER_FORMAT_VALUES } from "../../types/documentEnumValues.js";
2
- import { halfPointsToPixels, halfPointsToPoints, pointsToPixels } from "../../utils/units.js";
2
+ import { AUTO_PARAGRAPH_SPACING_PX, halfPointsToPixels, halfPointsToPoints, pointsToPixels } from "../../utils/units.js";
3
3
  import { padDecimal } from "../../docx/numberingParser.js";
4
4
  import { DEFAULT_TEXTBOX_MARGINS } from "../../layout-engine/types.js";
5
5
  import { resolveColor, resolveHighlightToCss } from "../../utils/colorResolver.js";
6
6
  import { resolveShadingFill } from "../../utils/formatToStyle.js";
7
7
  import { convertBulletToUnicode } from "../../docx/bulletMarkers.js";
8
8
  import { setTextBoxGroupId } from "../../layout-engine/textBoxGroup.js";
9
+ import { getColumns } from "../sectionColumns.js";
9
10
  import { directionIsRtl } from "../../prosemirror/paragraphDirection.js";
10
- import { expectBlockSdtAttrs, expectCharacterSpacingMarkAttrs, expectCommentMarkAttrs, expectEmphasisMarkAttrs, expectFieldAttrs, expectFontFamilyMarkAttrs, expectFontSizeMarkAttrs, expectFootnoteRefMarkAttrs, expectHighlightMarkAttrs, expectHyperlinkMarkAttrs, expectImageAttrs, expectMathAttrs, expectParagraphAttrs, expectRunFormattingOverrideMarkAttrs, expectRunShadingMarkAttrs, expectTableAttrs, expectTableCellAttrs, expectTableRowAttrs, expectTextBoxAttrs, expectTextColorMarkAttrs, expectTextEffectMarkAttrs, expectTrackedChangeMarkAttrs, expectUnderlineMarkAttrs } from "../../prosemirror/attrs/index.js";
11
+ import { expectBlockSdtAttrs, expectCharacterSpacingMarkAttrs, expectCommentMarkAttrs, expectEmphasisMarkAttrs, expectFieldAttrs, expectFontFamilyMarkAttrs, expectFontSizeMarkAttrs, expectFootnoteRefMarkAttrs, expectHardBreakAttrs, expectHighlightMarkAttrs, expectHyperlinkMarkAttrs, expectImageAttrs, expectMathAttrs, expectParagraphAttrs, expectRunFormattingOverrideMarkAttrs, expectRunShadingMarkAttrs, expectTableAttrs, expectTableCellAttrs, expectTableRowAttrs, expectTextBoxAttrs, expectTextColorMarkAttrs, expectTextEffectMarkAttrs, expectTrackedChangeMarkAttrs, expectUnderlineMarkAttrs } from "../../prosemirror/attrs/index.js";
11
12
  import { autospacingMatchesBase } from "../../prosemirror/autospacingBase.js";
12
13
  import { runShadingAttrsToShading } from "../../prosemirror/conversion/runShadingMark.js";
13
14
  import { assertValidProseMirrorDocument } from "../../prosemirror/validation.js";
@@ -672,6 +673,7 @@ function convertParagraphAttrs(pmAttrs, theme, listCounters, listAbstractCounter
672
673
  else if (align === "center") attrs.alignment = "center";
673
674
  else if (align === "right") attrs.alignment = "right";
674
675
  }
676
+ if (typeof pmAttrs.outlineLevel === "number") attrs.outlineLevel = pmAttrs.outlineLevel;
675
677
  const spaceBefore = pmAttrs.spaceBefore;
676
678
  const spaceAfter = pmAttrs.spaceAfter;
677
679
  const lineSpacing = pmAttrs.lineSpacing;
@@ -679,10 +681,14 @@ function convertParagraphAttrs(pmAttrs, theme, listCounters, listAbstractCounter
679
681
  const autoAfter = autospacingMatchesBase(pmAttrs._autospacingBase, "after", spaceAfter);
680
682
  if (autoBefore || autoAfter || typeof spaceBefore === "number" || typeof spaceAfter === "number" || typeof lineSpacing === "number") {
681
683
  attrs.spacing = {};
682
- if (autoBefore) attrs.spacing.before = 14;
684
+ if (autoBefore) attrs.spacing.before = AUTO_PARAGRAPH_SPACING_PX;
683
685
  else if (typeof spaceBefore === "number") attrs.spacing.before = twipsToPixels(spaceBefore);
684
- if (autoAfter) attrs.spacing.after = 14;
686
+ if (autoAfter) attrs.spacing.after = AUTO_PARAGRAPH_SPACING_PX;
685
687
  else if (typeof spaceAfter === "number") attrs.spacing.after = twipsToPixels(spaceAfter);
688
+ if (autoBefore || autoAfter) attrs.automaticSpacing = {
689
+ ...autoBefore ? { before: true } : {},
690
+ ...autoAfter ? { after: true } : {}
691
+ };
686
692
  const pmSpacingExplicit = pmAttrs.spacingExplicit;
687
693
  const spacingFromDocDefaults = pmAttrs.spacingFromDocDefaults;
688
694
  const explicit = {};
@@ -702,7 +708,7 @@ function convertParagraphAttrs(pmAttrs, theme, listCounters, listAbstractCounter
702
708
  let indentLeft = typeof pmAttrs.indentLeft === "number" ? pmAttrs.indentLeft : void 0;
703
709
  let indentFirstLine = typeof pmAttrs.indentFirstLine === "number" ? pmAttrs.indentFirstLine : void 0;
704
710
  let hangingIndent = pmAttrs.hangingIndent;
705
- if (pmAttrs.numPr?.numId && indentLeft === void 0) {
711
+ if (pmAttrs.numPr?.numId && indentLeft === void 0 && indentFirstLine === void 0) {
706
712
  indentLeft = ((pmAttrs.numPr.ilvl ?? 0) + 1) * 720;
707
713
  if (indentFirstLine === void 0) {
708
714
  indentFirstLine = -360;
@@ -828,6 +834,7 @@ function convertParagraph(node, startPos, options) {
828
834
  const attrs = convertParagraphAttrs(pmAttrs, options.theme, options.listCounters, options.listAbstractCounters, options.listSeenNumIds, options.defaultTabStopTwips, options.originalListCounters, options.originalListAbstractCounters, options.originalListSeenNumIds);
829
835
  const defaultTextFormatting = pmAttrs.defaultTextFormatting;
830
836
  if (runs.length === 0) {
837
+ if (pmAttrs._originalFormatting && Object.entries(pmAttrs._originalFormatting).some(([key, value]) => key !== "runProperties" && value !== void 0 && value !== null)) attrs.hasDirectParagraphFormatting = true;
831
838
  const paragraphMarkFormatting = pmAttrs._originalFormatting?.runProperties;
832
839
  if (paragraphMarkFormatting?.fontSize !== void 0) attrs.defaultFontSize = paragraphMarkFormatting.fontSize / 2;
833
840
  const paragraphMarkFontFamily = paragraphMarkFormatting?.fontFamily?.ascii ?? paragraphMarkFormatting?.fontFamily?.hAnsi;
@@ -850,6 +857,37 @@ function convertParagraph(node, startPos, options) {
850
857
  };
851
858
  }
852
859
  /**
860
+ * Word keeps terminal empty body paragraphs after a final table as editable
861
+ * anchors, but they do not create a page of their own. Preserve every block
862
+ * and PM range while collapsing only the contiguous, run-free suffix; empty
863
+ * paragraphs elsewhere still retain their normal line height.
864
+ */
865
+ function isPaintlessTerminalParagraph(block) {
866
+ if (block?.kind !== "paragraph" || block.runs.length !== 0) return false;
867
+ const attrs = block.attrs;
868
+ return !(attrs?.listMarker !== void 0 && !attrs.listMarkerHidden || attrs?.borders?.top || attrs?.borders?.bottom || attrs?.borders?.left || attrs?.borders?.right || attrs?.borders?.between || attrs?.borders?.bar || attrs?.shading || attrs?.spacingExplicit?.before || attrs?.spacingExplicit?.after || attrs?.pageBreakBefore || attrs?.renderedPageBreakBefore);
869
+ }
870
+ function suppressTerminalEmptyParagraphsAfterTable(blocks) {
871
+ let suffixStart = blocks.length;
872
+ while (suffixStart > 0 && isPaintlessTerminalParagraph(blocks[suffixStart - 1])) suffixStart -= 1;
873
+ if (suffixStart === blocks.length || suffixStart === 0 || blocks[suffixStart - 1]?.kind !== "table") return;
874
+ for (let index = suffixStart; index < blocks.length; index += 1) {
875
+ const block = blocks[index];
876
+ if (isPaintlessTerminalParagraph(block)) block.attrs = {
877
+ ...block.attrs,
878
+ suppressEmptyParagraphHeight: true
879
+ };
880
+ }
881
+ }
882
+ function reserveLeadingEmptyOutlineHeight(blocks) {
883
+ const firstBlock = blocks.at(0);
884
+ if (firstBlock?.kind !== "paragraph" || firstBlock.runs.length !== 0 || firstBlock.attrs?.outlineLevel !== 0) return;
885
+ firstBlock.attrs = {
886
+ ...firstBlock.attrs,
887
+ reserveEmptyOutlineHeight: true
888
+ };
889
+ }
890
+ /**
853
891
  * Convert border width from eighths of a point to pixels.
854
892
  * OOXML stores border widths in eighths of a point.
855
893
  */
@@ -919,6 +957,7 @@ function convertTableCell(node, startPos, options, tableCellMargins) {
919
957
  const block = convertParagraph(child, offset, options);
920
958
  blocks.push(block);
921
959
  } else if (child.type.name === "table") blocks.push(convertTable(child, offset, options));
960
+ else if (child.type.name === "textBox") blocks.push(convertTextBoxNode(child, offset, options));
922
961
  offset += child.nodeSize;
923
962
  });
924
963
  const trailingBlock = blocks.at(-1);
@@ -999,7 +1038,8 @@ function convertTable(node, startPos, options) {
999
1038
  }
1000
1039
  const justification = attrs.justification;
1001
1040
  const originalFormatting = attrs._originalFormatting;
1002
- const indentPx = originalFormatting?.indent?.value && originalFormatting.indent.type === "dxa" ? twipsToPixels(originalFormatting.indent.value) : void 0;
1041
+ const effectiveIndent = attrs._resolvedIndent ?? originalFormatting?.indent;
1042
+ const indentPx = effectiveIndent?.value !== void 0 && effectiveIndent?.type === "dxa" ? twipsToPixels(effectiveIndent.value) : void 0;
1003
1043
  const floating = attrs.floating;
1004
1044
  let floatingPx;
1005
1045
  if (floating) {
@@ -1213,11 +1253,33 @@ function toFlowBlocks(doc, options = {}) {
1213
1253
  }
1214
1254
  switch (node.type.name) {
1215
1255
  case "paragraph": {
1216
- const block = convertParagraph(node, pos, opts);
1217
1256
  const pmAttrs = expectParagraphAttrs(node);
1218
- trackedPush(block);
1219
1257
  const secProps = pmAttrs._sectionProperties;
1220
- if (secProps || pmAttrs.sectionBreakType) {
1258
+ const hasSectionBreak = secProps !== void 0 || pmAttrs.sectionBreakType !== null && pmAttrs.sectionBreakType !== void 0;
1259
+ const hasListFormatting = pmAttrs.numPr !== null && pmAttrs.numPr !== void 0 || pmAttrs.listMarker !== null && pmAttrs.listMarker !== void 0;
1260
+ const firstChild = node.firstChild;
1261
+ const startsWithColumnBreak = firstChild?.type.name === "hardBreak" && expectHardBreakAttrs(firstChild).breakType === "column";
1262
+ if (node.childCount === 1 && startsWithColumnBreak) {
1263
+ const columnBreak = {
1264
+ kind: "columnBreak",
1265
+ id: nextBlockId(),
1266
+ pmStart: pos,
1267
+ pmEnd: pos + node.nodeSize
1268
+ };
1269
+ trackedPush(columnBreak);
1270
+ } else if (startsWithColumnBreak && firstChild) {
1271
+ const columnBreak = {
1272
+ kind: "columnBreak",
1273
+ id: nextBlockId(),
1274
+ pmStart: pos + 1,
1275
+ pmEnd: pos + 1 + firstChild.nodeSize
1276
+ };
1277
+ trackedPush(columnBreak);
1278
+ const paragraph = convertParagraph(node, pos, opts);
1279
+ if (paragraph.runs.at(0)?.kind === "lineBreak") paragraph.runs.shift();
1280
+ trackedPush(paragraph);
1281
+ } else if (node.content.size > 0 || hasListFormatting || !hasSectionBreak) trackedPush(convertParagraph(node, pos, opts));
1282
+ if (hasSectionBreak) {
1221
1283
  const sectionBreak = {
1222
1284
  kind: "sectionBreak",
1223
1285
  id: nextBlockId()
@@ -1245,16 +1307,8 @@ function toFlowBlocks(doc, options = {}) {
1245
1307
  if (secProps.headerDistance !== void 0) sectionBreak.margins.header = twipsToPixels(secProps.headerDistance);
1246
1308
  if (secProps.footerDistance !== void 0) sectionBreak.margins.footer = twipsToPixels(secProps.footerDistance);
1247
1309
  }
1248
- const colCount = secProps.columnCount ?? 1;
1249
- if (colCount > 1) {
1250
- const cols = {
1251
- count: colCount,
1252
- gap: twipsToPixels(secProps.columnSpace ?? 720),
1253
- equalWidth: secProps.equalWidth ?? true
1254
- };
1255
- if (secProps.separator !== void 0) cols.separator = secProps.separator;
1256
- sectionBreak.columns = cols;
1257
- }
1310
+ const columns = getColumns(secProps);
1311
+ if (columns) sectionBreak.columns = columns;
1258
1312
  }
1259
1313
  trackedPush(sectionBreak);
1260
1314
  }
@@ -1286,6 +1340,8 @@ function toFlowBlocks(doc, options = {}) {
1286
1340
  doc.forEach((node, nodeOffset) => {
1287
1341
  visit(node, offset + nodeOffset);
1288
1342
  });
1343
+ reserveLeadingEmptyOutlineHeight(blocks);
1344
+ suppressTerminalEmptyParagraphsAfterTable(blocks);
1289
1345
  return mergeRunInParagraphs(blocks);
1290
1346
  }
1291
1347
  /**
@@ -7,13 +7,18 @@ const DEFAULT_COLUMN_SPACE_TWIPS = 720;
7
7
  * Returns undefined for single-column (default) to avoid unnecessary paginator overhead.
8
8
  */
9
9
  function getColumns(sectionProps) {
10
- const count = sectionProps?.columnCount ?? 1;
10
+ const authoredColumns = sectionProps?.columns;
11
+ const count = sectionProps?.columnCount ?? authoredColumns?.length ?? 1;
11
12
  if (count <= 1) return void 0;
12
13
  const columns = {
13
14
  count,
14
15
  gap: twipsToPixels(sectionProps?.columnSpace ?? DEFAULT_COLUMN_SPACE_TWIPS),
15
16
  equalWidth: sectionProps?.equalWidth ?? true
16
17
  };
18
+ if (sectionProps?.equalWidth === false && authoredColumns?.length === count && authoredColumns.every(({ width }) => width !== void 0)) {
19
+ columns.widths = authoredColumns.map(({ width }) => twipsToPixels(width ?? 0));
20
+ columns.gaps = authoredColumns.slice(0, -1).map(({ space }) => twipsToPixels(space ?? sectionProps.columnSpace ?? DEFAULT_COLUMN_SPACE_TWIPS));
21
+ }
17
22
  if (sectionProps?.separator !== void 0) columns.separator = sectionProps.separator;
18
23
  return columns;
19
24
  }
@@ -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
  *
@@ -156,7 +233,8 @@ function layoutDocument(blocks, measures, options) {
156
233
  pageSize: finalPageSize,
157
234
  margins: finalMargins
158
235
  };
159
- if (options.columns !== void 0) finalConfig.columns = options.columns;
236
+ const finalColumns = options.finalColumns ?? options.columns;
237
+ if (finalColumns !== void 0) finalConfig.columns = finalColumns;
160
238
  const { configs: sectionConfigs, breakIndices } = collectSectionConfigs(blocks, bodyConfig, finalConfig);
161
239
  const sectionBreakTypes = [...breakIndices.map((index) => blocks[index].type), options.bodyBreakType];
162
240
  const initialConfig = sectionConfigs.at(0) ?? bodyConfig;
@@ -170,6 +248,7 @@ function layoutDocument(blocks, measures, options) {
170
248
  ...options.sectionHeaderFooterRefs !== void 0 ? { sectionHeaderFooterRefs: options.sectionHeaderFooterRefs } : {}
171
249
  });
172
250
  applyContextualSpacing(blocks);
251
+ applyAutomaticListSpacing(blocks);
173
252
  const keepNextChains = computeKeepNextChains(blocks);
174
253
  const midChainIndices = getMidChainIndices(keepNextChains);
175
254
  const blocksById = /* @__PURE__ */ new Map();
@@ -178,7 +257,7 @@ function layoutDocument(blocks, measures, options) {
178
257
  let activeSectionMarginTop = initialConfig.margins.top;
179
258
  let activeSectionPageHeight = initialConfig.pageSize.h;
180
259
  let activeSectionMarginBottom = initialConfig.margins.bottom;
181
- let naturalPageAdvanceSinceRenderedBreak = false;
260
+ let naturalPageAdvanceSinceRenderedBreak = "none";
182
261
  for (let i = 0; i < blocks.length; i++) {
183
262
  const block = blocks[i];
184
263
  const measure = measures[i];
@@ -186,18 +265,20 @@ function layoutDocument(blocks, measures, options) {
186
265
  if (hasPageBreakBefore(block)) paginator.forcePageBreak();
187
266
  else if (hasRenderedPageBreak) {
188
267
  const state = paginator.getCurrentState();
189
- 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();
190
269
  }
191
- if (hasRenderedPageBreak || hasPageBreakBefore(block)) naturalPageAdvanceSinceRenderedBreak = false;
270
+ if (hasRenderedPageBreak || hasPageBreakBefore(block)) naturalPageAdvanceSinceRenderedBreak = "none";
192
271
  const chain = keepNextChains.get(i);
193
272
  if (chain && !midChainIndices.has(i)) {
194
273
  const chainHeight = calculateChainHeight(chain, blocks, measures);
274
+ const pageBeforeChainLayout = paginator.getCurrentState().page.number;
195
275
  paginator.ensureFits(chainHeight);
276
+ if (paginator.getCurrentState().page.number > pageBeforeChainLayout) naturalPageAdvanceSinceRenderedBreak = "reflowBoundary";
196
277
  }
197
278
  const pageBeforeBlockLayout = paginator.getCurrentState().page.number;
198
279
  switch (block.kind) {
199
280
  case "paragraph":
200
- layoutParagraph(block, measure, paginator, paginator.getContentWidth(), options.footnoteHeightById);
281
+ layoutParagraph(block, measure, paginator, paginator.columnWidth, options.footnoteHeightById);
201
282
  break;
202
283
  case "table":
203
284
  if (block.floating) layoutFloatingTable(block, measure, paginator, paginator.getContentWidth());
@@ -223,6 +304,23 @@ function layoutDocument(blocks, measures, options) {
223
304
  case "sectionBreak": {
224
305
  const nextSectionConfig = sectionConfigs[sectionIdx + 1] ?? initialConfig;
225
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
+ }
226
324
  activeSectionMarginTop = nextSectionConfig.margins.top;
227
325
  activeSectionPageHeight = nextSectionConfig.pageSize.h;
228
326
  activeSectionMarginBottom = nextSectionConfig.margins.bottom;
@@ -232,8 +330,8 @@ function layoutDocument(blocks, measures, options) {
232
330
  default: break;
233
331
  }
234
332
  const isVisibleBodyBlock = block.kind === "paragraph" ? !isEmptyParagraph(block) : block.kind !== "pageBreak" && block.kind !== "columnBreak" && block.kind !== "sectionBreak";
235
- if (block.kind === "pageBreak" || block.kind === "columnBreak" || block.kind === "sectionBreak") naturalPageAdvanceSinceRenderedBreak = false;
236
- else if (isVisibleBodyBlock && paginator.getCurrentState().page.number > pageBeforeBlockLayout) naturalPageAdvanceSinceRenderedBreak = true;
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";
237
335
  }
238
336
  if (paginator.pages.length === 0) paginator.getCurrentState();
239
337
  return {
@@ -428,9 +526,10 @@ function layoutTable(block, measure, paginator, footnoteHeightById) {
428
526
  let x = paginator.getColumnX(columnIndex);
429
527
  if (block.justification === "center") x += (paginator.columnWidth - measure.totalWidth) / 2;
430
528
  else if (block.justification === "right") x = x + paginator.columnWidth - measure.totalWidth;
431
- else if (block.indent !== void 0) {
432
- const leadingCellMargin = block.rows[0]?.cells[0]?.padding?.left ?? 0;
433
- x += block.indent - leadingCellMargin;
529
+ else if (block.indent !== void 0) x += block.indent;
530
+ else {
531
+ const leadingCellMargin = block.rows.at(0)?.cells.at(0)?.padding?.left ?? 0;
532
+ x -= leadingCellMargin;
434
533
  }
435
534
  return x;
436
535
  };
@@ -637,8 +736,10 @@ function layoutFloatingTable(block, measure, paginator, contentWidth) {
637
736
  else if (spec === "center") y = baseY + (contentHeight - tableHeight) / 2;
638
737
  }
639
738
  if (!usedExplicitY) y = paginator.ensureFits(tableHeight).cursorY;
640
- const minX = margins.left;
641
- const maxX = 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;
642
743
  if (Number.isFinite(maxX)) x = Math.max(minX, Math.min(x, maxX));
643
744
  const fragment = {
644
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
  */