@cj-tech-master/excelts 9.6.0 → 9.6.1

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 (112) hide show
  1. package/dist/browser/modules/archive/io/random-access.d.ts +1 -1
  2. package/dist/browser/modules/excel/workbook.browser.d.ts +1 -1
  3. package/dist/browser/modules/excel/xlsx/xform/comment/comment-xform.d.ts +3 -0
  4. package/dist/browser/modules/excel/xlsx/xform/comment/comment-xform.js +30 -7
  5. package/dist/browser/modules/pdf/excel-bridge.d.ts +32 -0
  6. package/dist/browser/modules/pdf/excel-bridge.js +67 -1
  7. package/dist/browser/modules/pdf/word-bridge.d.ts +20 -15
  8. package/dist/browser/modules/pdf/word-bridge.js +49 -34
  9. package/dist/browser/modules/stream/common/consumers.d.ts +2 -1
  10. package/dist/browser/modules/word/advanced/diff.js +125 -13
  11. package/dist/browser/modules/word/advanced/drawing-shapes.js +3 -0
  12. package/dist/browser/modules/word/bridge/excel-bridge.js +21 -1
  13. package/dist/browser/modules/word/builder/document-handle.d.ts +2 -0
  14. package/dist/browser/modules/word/builder/document-handle.js +14 -2
  15. package/dist/browser/modules/word/builder/paragraph-builders.js +10 -1
  16. package/dist/browser/modules/word/builder/run-builders.d.ts +19 -2
  17. package/dist/browser/modules/word/builder/run-builders.js +2 -6
  18. package/dist/browser/modules/word/convert/odt/odt.js +6 -1
  19. package/dist/browser/modules/word/layout/layout-full.d.ts +12 -0
  20. package/dist/browser/modules/word/layout/layout-full.js +74 -9
  21. package/dist/browser/modules/word/layout/layout-model.d.ts +12 -0
  22. package/dist/browser/modules/word/query/merge.js +26 -10
  23. package/dist/browser/modules/word/query/split.js +68 -2
  24. package/dist/browser/modules/word/reader/docx-reader.js +23 -0
  25. package/dist/browser/modules/word/security/cfb-reader.d.ts +14 -3
  26. package/dist/browser/modules/word/security/cfb-reader.js +271 -153
  27. package/dist/browser/modules/word/security/document-protection.js +10 -4
  28. package/dist/browser/modules/word/security/encryption.js +194 -32
  29. package/dist/browser/modules/word/types.d.ts +17 -0
  30. package/dist/browser/modules/word/units.d.ts +10 -4
  31. package/dist/browser/modules/word/units.js +10 -4
  32. package/dist/browser/modules/word/writer/document-writer.js +28 -4
  33. package/dist/browser/modules/word/writer/docx-packager.js +45 -5
  34. package/dist/browser/modules/word/writer/image-writer.d.ts +1 -1
  35. package/dist/browser/modules/word/writer/image-writer.js +2 -2
  36. package/dist/browser/modules/word/writer/render-context.d.ts +15 -0
  37. package/dist/browser/modules/word/writer/run-writer.js +8 -4
  38. package/dist/browser/modules/word/writer/section-writer.js +46 -35
  39. package/dist/browser/modules/word/writer/streaming-writer.js +4 -0
  40. package/dist/browser/modules/word/writer/styles-writer.js +11 -0
  41. package/dist/browser/modules/word/writer/table-writer.js +6 -0
  42. package/dist/cjs/modules/excel/xlsx/xform/comment/comment-xform.js +30 -7
  43. package/dist/cjs/modules/pdf/excel-bridge.js +67 -0
  44. package/dist/cjs/modules/pdf/word-bridge.js +49 -34
  45. package/dist/cjs/modules/word/advanced/diff.js +125 -13
  46. package/dist/cjs/modules/word/advanced/drawing-shapes.js +3 -0
  47. package/dist/cjs/modules/word/bridge/excel-bridge.js +21 -1
  48. package/dist/cjs/modules/word/builder/document-handle.js +14 -2
  49. package/dist/cjs/modules/word/builder/paragraph-builders.js +10 -1
  50. package/dist/cjs/modules/word/builder/run-builders.js +2 -6
  51. package/dist/cjs/modules/word/convert/odt/odt.js +6 -1
  52. package/dist/cjs/modules/word/layout/layout-full.js +74 -9
  53. package/dist/cjs/modules/word/query/merge.js +26 -10
  54. package/dist/cjs/modules/word/query/split.js +68 -2
  55. package/dist/cjs/modules/word/reader/docx-reader.js +23 -0
  56. package/dist/cjs/modules/word/security/cfb-reader.js +271 -153
  57. package/dist/cjs/modules/word/security/document-protection.js +10 -4
  58. package/dist/cjs/modules/word/security/encryption.js +193 -31
  59. package/dist/cjs/modules/word/units.js +10 -4
  60. package/dist/cjs/modules/word/writer/document-writer.js +28 -4
  61. package/dist/cjs/modules/word/writer/docx-packager.js +45 -5
  62. package/dist/cjs/modules/word/writer/image-writer.js +2 -2
  63. package/dist/cjs/modules/word/writer/run-writer.js +8 -4
  64. package/dist/cjs/modules/word/writer/section-writer.js +46 -35
  65. package/dist/cjs/modules/word/writer/streaming-writer.js +4 -0
  66. package/dist/cjs/modules/word/writer/styles-writer.js +11 -0
  67. package/dist/cjs/modules/word/writer/table-writer.js +6 -0
  68. package/dist/esm/modules/excel/xlsx/xform/comment/comment-xform.js +30 -7
  69. package/dist/esm/modules/pdf/excel-bridge.js +67 -1
  70. package/dist/esm/modules/pdf/word-bridge.js +49 -34
  71. package/dist/esm/modules/word/advanced/diff.js +125 -13
  72. package/dist/esm/modules/word/advanced/drawing-shapes.js +3 -0
  73. package/dist/esm/modules/word/bridge/excel-bridge.js +21 -1
  74. package/dist/esm/modules/word/builder/document-handle.js +14 -2
  75. package/dist/esm/modules/word/builder/paragraph-builders.js +10 -1
  76. package/dist/esm/modules/word/builder/run-builders.js +2 -6
  77. package/dist/esm/modules/word/convert/odt/odt.js +6 -1
  78. package/dist/esm/modules/word/layout/layout-full.js +74 -9
  79. package/dist/esm/modules/word/query/merge.js +26 -10
  80. package/dist/esm/modules/word/query/split.js +68 -2
  81. package/dist/esm/modules/word/reader/docx-reader.js +23 -0
  82. package/dist/esm/modules/word/security/cfb-reader.js +271 -153
  83. package/dist/esm/modules/word/security/document-protection.js +10 -4
  84. package/dist/esm/modules/word/security/encryption.js +194 -32
  85. package/dist/esm/modules/word/units.js +10 -4
  86. package/dist/esm/modules/word/writer/document-writer.js +28 -4
  87. package/dist/esm/modules/word/writer/docx-packager.js +45 -5
  88. package/dist/esm/modules/word/writer/image-writer.js +2 -2
  89. package/dist/esm/modules/word/writer/run-writer.js +8 -4
  90. package/dist/esm/modules/word/writer/section-writer.js +46 -35
  91. package/dist/esm/modules/word/writer/streaming-writer.js +4 -0
  92. package/dist/esm/modules/word/writer/styles-writer.js +11 -0
  93. package/dist/esm/modules/word/writer/table-writer.js +6 -0
  94. package/dist/iife/excelts.iife.js +20 -8
  95. package/dist/iife/excelts.iife.js.map +1 -1
  96. package/dist/iife/excelts.iife.min.js +2 -2
  97. package/dist/types/modules/archive/io/random-access.d.ts +1 -1
  98. package/dist/types/modules/excel/workbook.browser.d.ts +1 -1
  99. package/dist/types/modules/excel/xlsx/xform/comment/comment-xform.d.ts +3 -0
  100. package/dist/types/modules/pdf/excel-bridge.d.ts +32 -0
  101. package/dist/types/modules/pdf/word-bridge.d.ts +20 -15
  102. package/dist/types/modules/stream/common/consumers.d.ts +2 -1
  103. package/dist/types/modules/word/builder/document-handle.d.ts +2 -0
  104. package/dist/types/modules/word/builder/run-builders.d.ts +19 -2
  105. package/dist/types/modules/word/layout/layout-full.d.ts +12 -0
  106. package/dist/types/modules/word/layout/layout-model.d.ts +12 -0
  107. package/dist/types/modules/word/security/cfb-reader.d.ts +14 -3
  108. package/dist/types/modules/word/types.d.ts +17 -0
  109. package/dist/types/modules/word/units.d.ts +10 -4
  110. package/dist/types/modules/word/writer/image-writer.d.ts +1 -1
  111. package/dist/types/modules/word/writer/render-context.d.ts +15 -0
  112. package/package.json +2 -2
@@ -129,7 +129,7 @@ export const Document = {
129
129
  const fileName = `image${s.nextImageId}.${mediaType}`;
130
130
  const rId = `__img_${s.nextImageId}`;
131
131
  const drawingId = s.nextDrawingId++;
132
- s.images.push({ data, mediaType, fileName, rId });
132
+ s.images.push({ data, mediaType, fileName, rId, fallbackData: options?.fallbackData });
133
133
  s.body.push(paragraph([
134
134
  {
135
135
  content: [
@@ -153,7 +153,7 @@ export const Document = {
153
153
  const s = _toState(doc);
154
154
  const fileName = `image${s.nextImageId}.${mediaType}`;
155
155
  const rId = `__img_${s.nextImageId}`;
156
- s.images.push({ data, mediaType, fileName, rId });
156
+ s.images.push({ data, mediaType, fileName, rId, fallbackData: options?.fallbackData });
157
157
  s.body.push(floatingImage({
158
158
  rId,
159
159
  width,
@@ -490,6 +490,18 @@ export const Document = {
490
490
  uiPriority: 99,
491
491
  unhideWhenUsed: true,
492
492
  runProperties: { color: "0563C1", underline: "single" }
493
+ }, {
494
+ // Word's built-in "visited hyperlink" character style. Referenced by
495
+ // hyperlinks created with `history: true` so visited links render in
496
+ // the standard purple instead of the unvisited blue.
497
+ type: "character",
498
+ styleId: "FollowedHyperlink",
499
+ name: "FollowedHyperlink",
500
+ basedOn: "DefaultParagraphFont",
501
+ uiPriority: 99,
502
+ semiHidden: true,
503
+ unhideWhenUsed: true,
504
+ runProperties: { color: "954F72", underline: "single" }
493
505
  }, {
494
506
  // Word's built-in zero-formatting base table style. Required so
495
507
  // that styles like TableGrid (which sets `basedOn: "TableNormal"`)
@@ -24,6 +24,15 @@ export function heading(content, level) {
24
24
  }
25
25
  /** Create a hyperlink. */
26
26
  export function hyperlink(linkText, options) {
27
+ // Reference the built-in character style so the colour is governed by the
28
+ // style table (and follows the theme) — Hyperlink for unvisited links,
29
+ // FollowedHyperlink for visited ones (`history: true`). We also emit the
30
+ // matching colour + underline as direct formatting so the link still renders
31
+ // correctly when the document has no style table (Word does the same).
32
+ const visited = options.history === true;
33
+ const defaultProps = visited
34
+ ? { style: "FollowedHyperlink", color: "954F72", underline: "single" }
35
+ : { style: "Hyperlink", color: "0563C1", underline: "single" };
27
36
  return {
28
37
  type: "hyperlink",
29
38
  rId: options.rId,
@@ -33,7 +42,7 @@ export function hyperlink(linkText, options) {
33
42
  docLocation: options.docLocation,
34
43
  tgtFrame: options.tgtFrame,
35
44
  history: options.history,
36
- children: [text(linkText, options.properties ?? { color: "0563C1", underline: "single" })]
45
+ children: [text(linkText, options.properties ?? defaultProps)]
37
46
  };
38
47
  }
39
48
  /** Create a bookmark start. */
@@ -271,12 +271,8 @@ export function tocField(options) {
271
271
  if (options?.hyperlink) {
272
272
  instruction += "\\h ";
273
273
  }
274
- if (options?.rightAlignedPageNumbers) {
275
- instruction += "\\z ";
276
- }
277
- if (options?.tabLeader) {
278
- instruction += `\\p "${options.tabLeader}" `;
279
- }
274
+ // Intentionally NOT emitting `\z` for rightAlignedPageNumbers — see note above.
275
+ // Intentionally NOT emitting `\p` for tabLeader — see the field note above.
280
276
  if (options?.noPageNumbers) {
281
277
  instruction += "\\n ";
282
278
  }
@@ -1168,7 +1168,12 @@ function sanitizeOdtPictureName(raw) {
1168
1168
  */
1169
1169
  export async function writeOdt(doc) {
1170
1170
  const encoder = utf8Encoder;
1171
- const archive = zip();
1171
+ // `noSort: true` preserves insertion order. The ODF spec (OpenDocument
1172
+ // v1.2 part 3, §3.3) requires the `mimetype` entry to be the FIRST entry in
1173
+ // the package and STORED (uncompressed) so the file type can be detected by
1174
+ // magic bytes. The default ZipArchive behaviour sorts entries alphabetically,
1175
+ // which would push `mimetype` after `content.xml` and break ODF detection.
1176
+ const archive = zip({ noSort: true });
1172
1177
  // Mimetype MUST be the first entry in the ZIP, uncompressed (ODF spec requirement)
1173
1178
  archive.add("mimetype", encoder.encode("application/vnd.oasis.opendocument.text"), { level: 0 });
1174
1179
  // Compute a sanitised rId → Pictures/<safe>.<ext> map up front so the
@@ -370,7 +370,7 @@ function layoutFootnotes(doc, ids, geometry, options, bodyBottomPageY, imageMap)
370
370
  noteById.set(note.id, note);
371
371
  }
372
372
  }
373
- const footerOffsetPt = geometry.height - twipsToPt(doc.sectionProperties?.margins?.footer ?? 720);
373
+ const footerOffsetPt = geometry.height - geometry.footerOffset;
374
374
  /**
375
375
  * Vertical room available for the footnote stack on this page.
376
376
  * The stack must sit between `bodyBottomPageY` (top) and
@@ -604,7 +604,7 @@ function layoutHeader(doc, pageNumber, geometry, options, imageMap) {
604
604
  if (!part) {
605
605
  return [];
606
606
  }
607
- const headerOffsetPt = twipsToPt(doc.sectionProperties?.margins?.header ?? 720);
607
+ const headerOffsetPt = geometry.headerOffset;
608
608
  return layoutHeaderFooterChildren(part.content.children, headerOffsetPt, geometry, options, imageMap);
609
609
  }
610
610
  function layoutFooter(doc, pageNumber, geometry, options, imageMap) {
@@ -627,7 +627,7 @@ function layoutFooter(doc, pageNumber, geometry, options, imageMap) {
627
627
  // where `pgMar.header` is the absolute offset of the band from the
628
628
  // page top). Renderers consume both bands with the same
629
629
  // "treat layout-y as page-y" rule.
630
- const footerOffsetPt = geometry.height - twipsToPt(doc.sectionProperties?.margins?.footer ?? 720);
630
+ const footerOffsetPt = geometry.height - geometry.footerOffset;
631
631
  return layoutHeaderFooterChildren(part.content.children, footerOffsetPt, geometry, options, imageMap);
632
632
  }
633
633
  function layoutHeaderFooterChildren(children, initialCursorY, geometry, options, imageMap) {
@@ -657,6 +657,11 @@ function computePageGeometry(sectionProps, override) {
657
657
  const sectionMarginBottom = twipsToPt(sectionProps?.margins?.bottom ?? DEFAULT_PAGE_MARGIN_TWIPS);
658
658
  const sectionMarginLeft = twipsToPt(sectionProps?.margins?.left ?? DEFAULT_PAGE_MARGIN_TWIPS);
659
659
  const sectionMarginRight = twipsToPt(sectionProps?.margins?.right ?? DEFAULT_PAGE_MARGIN_TWIPS);
660
+ // Header / footer band offsets. Word's default `pgMar.header` /
661
+ // `pgMar.footer` is 720 twips (0.5") — the same default used by the
662
+ // header / footer layout helpers historically.
663
+ const sectionHeaderOffset = twipsToPt(sectionProps?.margins?.header ?? 720);
664
+ const sectionFooterOffset = twipsToPt(sectionProps?.margins?.footer ?? 720);
660
665
  // Per-axis override: callers (PDF bridge, custom hosts) may want to
661
666
  // pin the page size or margin on one axis without disturbing the
662
667
  // others — `pageWidth` doesn't imply overriding margins, etc.
@@ -666,6 +671,8 @@ function computePageGeometry(sectionProps, override) {
666
671
  const marginBottom = override?.marginBottom ?? sectionMarginBottom;
667
672
  const marginLeft = override?.marginLeft ?? sectionMarginLeft;
668
673
  const marginRight = override?.marginRight ?? sectionMarginRight;
674
+ const headerOffset = override?.headerMargin ?? sectionHeaderOffset;
675
+ const footerOffset = override?.footerMargin ?? sectionFooterOffset;
669
676
  return {
670
677
  width,
671
678
  height,
@@ -674,7 +681,9 @@ function computePageGeometry(sectionProps, override) {
674
681
  marginLeft,
675
682
  marginRight,
676
683
  contentWidth: width - marginLeft - marginRight,
677
- contentHeight: height - marginTop - marginBottom
684
+ contentHeight: height - marginTop - marginBottom,
685
+ headerOffset,
686
+ footerOffset
678
687
  };
679
688
  }
680
689
  function computeSectionBreaks(layout) {
@@ -869,35 +878,64 @@ function layoutParagraph(para, startY, contentWidth, options, pageContext, image
869
878
  // =============================================================================
870
879
  function layoutTable(table, startY, contentWidth, sourceIndex, options, imageMap) {
871
880
  const numCols = table.rows.length > 0 ? table.rows[0].cells.length : 0;
872
- const colWidth = numCols > 0 ? contentWidth / numCols : contentWidth;
881
+ // Resolve per-column widths (in points). Prefer the table's explicit
882
+ // `columnWidths` (twips) — populated e.g. by the Excel→Word bridge —
883
+ // scaled to fit the available content width so a table authored wider
884
+ // than the page still renders proportionally. Fall back to equal
885
+ // division when no column widths are declared. This mirrors the
886
+ // sister layout engine in `layout.ts` (which also honours
887
+ // `columnWidths` + `gridSpan`).
888
+ const colWidths = resolveColumnWidthsPt(table, numCols, contentWidth);
889
+ // Prefix sums so a cell at column `ci` starts at `colOffsets[ci]` and
890
+ // a `gridSpan` cell can sum the widths it covers.
891
+ const colOffsets = [0];
892
+ for (let i = 0; i < colWidths.length; i++) {
893
+ colOffsets.push(colOffsets[i] + colWidths[i]);
894
+ }
873
895
  const cells = [];
874
896
  let cursorY = 0;
875
897
  for (let ri = 0; ri < table.rows.length; ri++) {
876
898
  const row = table.rows[ri];
877
899
  let maxRowHeight = DEFAULT_FONT_SIZE_PT * 1.5; // minimum row height
900
+ // Track the grid column each cell occupies, honouring gridSpan so a
901
+ // 2-wide cell pushes the next cell two grid columns to the right.
902
+ let gridCol = 0;
878
903
  for (let ci = 0; ci < row.cells.length; ci++) {
879
904
  const cell = row.cells[ci];
880
- const cellX = ci * colWidth;
905
+ const span = Math.max(1, cell.properties?.gridSpan ?? 1);
906
+ const startCol = Math.min(gridCol, colWidths.length - 1);
907
+ const endCol = Math.min(gridCol + span, colWidths.length);
908
+ const cellX = colOffsets[startCol] ?? 0;
909
+ const cellWidth = (colOffsets[endCol] ?? contentWidth) - cellX;
881
910
  const cellContent = [];
882
911
  let cellCursorY = 2; // cell padding top
883
912
  for (const block of cell.content) {
884
913
  if (block.type === "paragraph") {
885
- const laid = layoutParagraph(block, cellCursorY, colWidth - 4, options, undefined, imageMap);
914
+ const laid = layoutParagraph(block, cellCursorY, cellWidth - 4, options, undefined, imageMap);
886
915
  cellContent.push({ ...laid, sourceIndex: -1 });
887
916
  cellCursorY = laid.rect.y + laid.rect.height;
888
917
  }
889
- // nested tables skipped for simplicity
918
+ else if (block.type === "table") {
919
+ // Nested table: lay it out within the cell's content width and
920
+ // stack it below preceding content. The PDF/SVG renderers
921
+ // already translate nested `LayoutTable` rects by the cell
922
+ // origin, so emitting it here is all that's needed.
923
+ const laidNested = layoutTable(block, cellCursorY, cellWidth - 4, -1, options, imageMap);
924
+ cellContent.push(laidNested);
925
+ cellCursorY = laidNested.rect.y + laidNested.rect.height;
926
+ }
890
927
  }
891
928
  const cellHeight = cellCursorY + 2; // cell padding bottom
892
929
  if (cellHeight > maxRowHeight) {
893
930
  maxRowHeight = cellHeight;
894
931
  }
895
932
  cells.push({
896
- rect: { x: cellX, y: startY + cursorY, width: colWidth, height: cellHeight },
933
+ rect: { x: cellX, y: startY + cursorY, width: cellWidth, height: cellHeight },
897
934
  row: ri,
898
935
  col: ci,
899
936
  content: cellContent
900
937
  });
938
+ gridCol += span;
901
939
  }
902
940
  // Normalize cell heights to row max
903
941
  for (const c of cells) {
@@ -914,6 +952,33 @@ function layoutTable(table, startY, contentWidth, sourceIndex, options, imageMap
914
952
  sourceIndex
915
953
  };
916
954
  }
955
+ /**
956
+ * Resolve a table's per-column widths in points.
957
+ *
958
+ * If `table.columnWidths` (twips) is present and covers all columns, it
959
+ * is used and proportionally scaled to fit `contentWidth` (so a table
960
+ * authored wider than the page shrinks to fit rather than overflowing).
961
+ * Otherwise the content width is divided equally among the columns.
962
+ */
963
+ function resolveColumnWidthsPt(table, numCols, contentWidth) {
964
+ if (numCols <= 0) {
965
+ return [];
966
+ }
967
+ const declared = table.columnWidths;
968
+ if (declared && declared.length >= numCols) {
969
+ const pts = declared.slice(0, numCols).map(twipsToPt);
970
+ const total = pts.reduce((a, b) => a + b, 0);
971
+ if (total > 0) {
972
+ // Scale to fit the content width (shrink overflow, expand
973
+ // under-wide tables to use the full measure — matching how Word
974
+ // distributes a table set to a percentage / auto width).
975
+ const scale = contentWidth / total;
976
+ return pts.map(w => w * scale);
977
+ }
978
+ }
979
+ const equal = contentWidth / numCols;
980
+ return new Array(numCols).fill(equal);
981
+ }
917
982
  /**
918
983
  * Walk a paragraph's children and emit a flat sequence of paragraph
919
984
  * segments — text runs preserve their formatting; inline images become
@@ -68,17 +68,33 @@ export function mergeDocuments(documents, options) {
68
68
  const mergedComments = base.comments ? [...base.comments] : [];
69
69
  for (let i = 1; i < documents.length; i++) {
70
70
  const doc = documents[i];
71
- // Insert section break before appending next document
72
- const sectionBreakPara = {
73
- type: "paragraph",
74
- properties: {
75
- sectionProperties: {
76
- breakType
71
+ // Mark a section break BEFORE appending the next document. In OOXML a
72
+ // section break is carried by the `sectPr` of the LAST paragraph of the
73
+ // preceding section — NOT by an extra empty paragraph. Appending an empty
74
+ // <w:p> with only a sectPr makes Word render a stray blank line / blank
75
+ // page. So we attach the break to the last paragraph already in the body;
76
+ // only if the body currently ends with a non-paragraph block (e.g. a
77
+ // table, which cannot carry a sectPr directly) do we fall back to a
78
+ // minimal carrier paragraph.
79
+ const lastBlock = mergedBody[mergedBody.length - 1];
80
+ if (lastBlock && lastBlock.type === "paragraph") {
81
+ const para = lastBlock;
82
+ mergedBody[mergedBody.length - 1] = {
83
+ ...para,
84
+ properties: {
85
+ ...para.properties,
86
+ sectionProperties: { ...para.properties?.sectionProperties, breakType }
77
87
  }
78
- },
79
- children: []
80
- };
81
- mergedBody.push(sectionBreakPara);
88
+ };
89
+ }
90
+ else {
91
+ const sectionBreakPara = {
92
+ type: "paragraph",
93
+ properties: { sectionProperties: { breakType } },
94
+ children: []
95
+ };
96
+ mergedBody.push(sectionBreakPara);
97
+ }
82
98
  // Compute id remappings BEFORE cloning the body so we can rewrite refs
83
99
  // during the clone in a single pass.
84
100
  const numMaps = mergeNumberingDefinitions(doc, mergedAbstractNums, mergedNumInstances);
@@ -119,17 +119,83 @@ function paragraphHasExplicitPageBreakRun(para) {
119
119
  }
120
120
  return false;
121
121
  }
122
+ /**
123
+ * Return a copy of `para` with every explicit page-break run content removed.
124
+ * Runs left empty by the removal are dropped. Used when splitting by page
125
+ * break so the trailing break that separated this segment from the next does
126
+ * not render as a blank page in the standalone split document.
127
+ */
128
+ function stripPageBreakRuns(para) {
129
+ const newChildren = [];
130
+ for (const child of para.children) {
131
+ if (isRun(child)) {
132
+ const content = child.content.filter(c => !(c.type === "break" && c.breakType === "page"));
133
+ if (content.length > 0) {
134
+ newChildren.push({ ...child, content });
135
+ }
136
+ // Drop runs that became empty after removing the page break.
137
+ }
138
+ else {
139
+ newChildren.push(child);
140
+ }
141
+ }
142
+ return { ...para, children: newChildren };
143
+ }
144
+ /** True when a paragraph has no children (after page-break stripping). */
145
+ function paragraphIsEmpty(para) {
146
+ return para.children.length === 0;
147
+ }
122
148
  function buildSplitDoc(source, segment, opts) {
149
+ // When splitting by section, the segment's last paragraph carries the
150
+ // section break (often `nextPage`) that originally separated it from the
151
+ // following section. In a standalone split document that break has nothing
152
+ // after it, so Word renders a trailing blank page. Strip the paragraph-level
153
+ // sectPr and promote its page setup to the document's own section
154
+ // properties instead.
155
+ let body = segment;
156
+ let promotedSectPr;
157
+ if (opts.by === "section" && segment.length > 0) {
158
+ const last = segment[segment.length - 1];
159
+ if (last.type === "paragraph" && last.properties?.sectionProperties) {
160
+ const para = last;
161
+ const props = para.properties;
162
+ const sectionProperties = props.sectionProperties;
163
+ const restProps = { ...props };
164
+ delete restProps.sectionProperties;
165
+ // Drop the inter-section break type: in a standalone document this is
166
+ // the (only/first) section, so a "nextPage" break would just push all
167
+ // content onto page 2, leaving page 1 blank.
168
+ const { breakType: _drop, ...sectWithoutBreak } = sectionProperties;
169
+ promotedSectPr = sectWithoutBreak;
170
+ const cleaned = { ...para, properties: restProps };
171
+ body = [...segment.slice(0, -1), cleaned];
172
+ }
173
+ }
174
+ else if (opts.by === "pageBreak" && segment.length > 0) {
175
+ // The segment ends with the paragraph that held the splitting page break.
176
+ // In a standalone document that trailing page break has nothing after it
177
+ // and renders a blank page, so remove it. If the paragraph held only the
178
+ // page break, drop the now-empty paragraph entirely.
179
+ const last = segment[segment.length - 1];
180
+ if (last.type === "paragraph" && paragraphHasExplicitPageBreakRun(last)) {
181
+ const stripped = stripPageBreakRuns(last);
182
+ body = paragraphIsEmpty(stripped)
183
+ ? segment.slice(0, -1)
184
+ : [...segment.slice(0, -1), stripped];
185
+ }
186
+ }
123
187
  if (!opts.preserveSharedParts) {
124
188
  // Minimal split: just body + docType
125
189
  return {
126
190
  docType: source.docType,
127
- body: segment
191
+ body,
192
+ ...(promotedSectPr ? { sectionProperties: promotedSectPr } : {})
128
193
  };
129
194
  }
130
195
  // Full split: preserve all shared parts
131
196
  return {
132
197
  ...source,
133
- body: segment
198
+ body,
199
+ ...(promotedSectPr ? { sectionProperties: promotedSectPr } : {})
134
200
  };
135
201
  }
@@ -246,6 +246,23 @@ function parseDrawingShape(anchorEl, wspEl, ctx) {
246
246
  shape.noOutline = true;
247
247
  }
248
248
  }
249
+ // Parse transform (rotation / flip) from a:xfrm
250
+ const xfrmEl = findChild(spPrEl, "a:xfrm") ?? findChildNs(spPrEl, "xfrm");
251
+ if (xfrmEl) {
252
+ const rot = xfrmEl.attributes["rot"];
253
+ if (rot) {
254
+ const n = parseInt(rot, 10);
255
+ if (!Number.isNaN(n)) {
256
+ shape.rotation = n;
257
+ }
258
+ }
259
+ if (xfrmEl.attributes["flipH"] === "1") {
260
+ shape.flipHorizontal = true;
261
+ }
262
+ if (xfrmEl.attributes["flipV"] === "1") {
263
+ shape.flipVertical = true;
264
+ }
265
+ }
249
266
  }
250
267
  // Parse text content (wps:txbx > w:txbxContent)
251
268
  const txbxEl = findChild(wspEl, "wps:txbx") ?? findChildNs(wspEl, "txbx");
@@ -263,6 +280,12 @@ function parseDrawingShape(anchorEl, wspEl, ctx) {
263
280
  shape.textContent = paras;
264
281
  }
265
282
  }
283
+ // Parse text body vertical anchor (wps:bodyPr/@anchor)
284
+ const bodyPrEl = findChild(wspEl, "wps:bodyPr") ?? findChildNs(wspEl, "bodyPr");
285
+ const anchorAttr = bodyPrEl?.attributes["anchor"];
286
+ if (anchorAttr === "t" || anchorAttr === "ctr" || anchorAttr === "b") {
287
+ shape.textBodyAnchor = anchorAttr;
288
+ }
266
289
  // Parse positioning
267
290
  const posH = findChild(anchorEl, "wp:positionH");
268
291
  if (posH) {