@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
@@ -90,6 +90,16 @@ function renderPageBorders(xml, borders) {
90
90
  /** Render w:sectPr element. */
91
91
  function renderSectionProperties(xml, sect, insidePropertyChange = false) {
92
92
  xml.openNode("w:sectPr");
93
+ // NOTE: Child element ordering below is dictated by the OOXML schema
94
+ // (ISO/IEC 29500 — CT_SectPr / EG_SectPrContents). Microsoft Word rejects
95
+ // (or silently "repairs") documents whose sectPr children are out of order,
96
+ // so the sequence here MUST stay:
97
+ // headerReference*, footerReference*, footnotePr, endnotePr, type, pgSz,
98
+ // pgMar, paperSrc, pgBorders, lnNumType, pgNumType, cols, formProt,
99
+ // vAlign, noEndnote, titlePg, textDirection, bidi, rtlGutter, docGrid,
100
+ // printerSettings
101
+ // (headerReference/footerReference come from EG_HdrFtrReferences, which the
102
+ // schema places ahead of EG_SectPrContents.)
93
103
  // Header references
94
104
  if (sect.headers) {
95
105
  for (const ref of sect.headers) {
@@ -102,6 +112,14 @@ function renderSectionProperties(xml, sect, insidePropertyChange = false) {
102
112
  renderHeaderFooterRef(xml, "w:footerReference", ref);
103
113
  }
104
114
  }
115
+ // Footnote properties
116
+ if (sect.footnoteProperties) {
117
+ renderNoteProperties(xml, "w:footnotePr", sect.footnoteProperties);
118
+ }
119
+ // Endnote properties
120
+ if (sect.endnoteProperties) {
121
+ renderNoteProperties(xml, "w:endnotePr", sect.endnoteProperties);
122
+ }
105
123
  // Section break type
106
124
  if (sect.breakType) {
107
125
  xml.leafNode("w:type", { "w:val": sect.breakType });
@@ -134,29 +152,7 @@ function renderSectionProperties(xml, sect, insidePropertyChange = false) {
134
152
  if (sect.pageBorders) {
135
153
  renderPageBorders(xml, sect.pageBorders);
136
154
  }
137
- // Columns
138
- if (sect.columns) {
139
- renderColumns(xml, sect.columns);
140
- }
141
- else {
142
- xml.leafNode("w:cols", { "w:space": String(constants_1.DEFAULT_COLUMN_SPACE) });
143
- }
144
- // Title page (different first page header/footer)
145
- if (sect.titlePage) {
146
- xml.leafNode("w:titlePg");
147
- }
148
- // Page numbering
149
- if (sect.pageNumbering) {
150
- const attrs = {};
151
- if (sect.pageNumbering.start !== undefined) {
152
- attrs["w:start"] = String(sect.pageNumbering.start);
153
- }
154
- if (sect.pageNumbering.format) {
155
- attrs["w:fmt"] = sect.pageNumbering.format;
156
- }
157
- xml.leafNode("w:pgNumType", attrs);
158
- }
159
- // Line numbers
155
+ // Line numbers (must precede pgNumType per schema)
160
156
  if (sect.lineNumbers) {
161
157
  const attrs = {};
162
158
  if (sect.lineNumbers.countBy !== undefined) {
@@ -173,10 +169,37 @@ function renderSectionProperties(xml, sect, insidePropertyChange = false) {
173
169
  }
174
170
  xml.leafNode("w:lnNumType", attrs);
175
171
  }
172
+ // Page numbering
173
+ if (sect.pageNumbering) {
174
+ const attrs = {};
175
+ if (sect.pageNumbering.start !== undefined) {
176
+ attrs["w:start"] = String(sect.pageNumbering.start);
177
+ }
178
+ if (sect.pageNumbering.format) {
179
+ attrs["w:fmt"] = sect.pageNumbering.format;
180
+ }
181
+ xml.leafNode("w:pgNumType", attrs);
182
+ }
183
+ // Columns
184
+ if (sect.columns) {
185
+ renderColumns(xml, sect.columns);
186
+ }
187
+ else {
188
+ xml.leafNode("w:cols", { "w:space": String(constants_1.DEFAULT_COLUMN_SPACE) });
189
+ }
190
+ // Form protection
191
+ if (sect.formProtection) {
192
+ xml.leafNode("w:formProt", { "w:val": "1" });
193
+ }
176
194
  // Vertical alignment
177
195
  if (sect.verticalAlign) {
178
196
  xml.leafNode("w:vAlign", { "w:val": sect.verticalAlign });
179
197
  }
198
+ // Title page (different first page header/footer) — schema places this
199
+ // after vAlign/noEndnote and before textDirection.
200
+ if (sect.titlePage) {
201
+ xml.leafNode("w:titlePg");
202
+ }
180
203
  // Text direction
181
204
  if (sect.textDirection) {
182
205
  xml.leafNode("w:textDirection", { "w:val": sect.textDirection });
@@ -189,18 +212,6 @@ function renderSectionProperties(xml, sect, insidePropertyChange = false) {
189
212
  if (sect.rtlGutter) {
190
213
  xml.leafNode("w:rtlGutter");
191
214
  }
192
- // Form protection
193
- if (sect.formProtection) {
194
- xml.leafNode("w:formProt", { "w:val": "1" });
195
- }
196
- // Footnote properties
197
- if (sect.footnoteProperties) {
198
- renderNoteProperties(xml, "w:footnotePr", sect.footnoteProperties);
199
- }
200
- // Endnote properties
201
- if (sect.endnoteProperties) {
202
- renderNoteProperties(xml, "w:endnotePr", sect.endnoteProperties);
203
- }
204
215
  // Document grid
205
216
  if (sect.docGrid) {
206
217
  const gridAttrs = {
@@ -904,6 +904,7 @@ class StreamingDocxWriter {
904
904
  addXmlFile(constants_1.PartPath.Comments, xml => (0, comment_writer_1.renderComments)(xml, this._options.comments, {
905
905
  imageRemap: this._renderCtx.imageRIdRemap,
906
906
  hyperlinkRIds: this._renderCtx.hyperlinkRIds,
907
+ nextDocPrId: this._renderCtx.ids.nextDocPrId,
907
908
  rawXmlPolicy: this._renderCtx.rawXmlPolicy
908
909
  }));
909
910
  // Also write commentsExtended if any have done/parentId
@@ -977,6 +978,7 @@ class StreamingDocxWriter {
977
978
  addXmlFile(headerPath, xml => (0, header_footer_writer_1.renderHeader)(xml, headerDef.content, {
978
979
  imageRemap: this._renderCtx.imageRIdRemap,
979
980
  hyperlinkRIds: this._renderCtx.hyperlinkRIds,
981
+ nextDocPrId: this._renderCtx.ids.nextDocPrId,
980
982
  rawXmlPolicy: this._renderCtx.rawXmlPolicy
981
983
  }));
982
984
  if ((0, relationships_1.getRelationshipCount)(hRels) > 0) {
@@ -1047,6 +1049,7 @@ class StreamingDocxWriter {
1047
1049
  addXmlFile(footerPath, xml => (0, header_footer_writer_1.renderFooter)(xml, footerDef.content, {
1048
1050
  imageRemap: this._renderCtx.imageRIdRemap,
1049
1051
  hyperlinkRIds: this._renderCtx.hyperlinkRIds,
1052
+ nextDocPrId: this._renderCtx.ids.nextDocPrId,
1050
1053
  rawXmlPolicy: this._renderCtx.rawXmlPolicy
1051
1054
  }));
1052
1055
  if ((0, relationships_1.getRelationshipCount)(fRels) > 0) {
@@ -1225,6 +1228,7 @@ class StreamingDocxWriter {
1225
1228
  notesHelpers: {
1226
1229
  imageRemap: this._renderCtx.imageRIdRemap,
1227
1230
  hyperlinkRIds: this._renderCtx.hyperlinkRIds,
1231
+ nextDocPrId: this._renderCtx.ids.nextDocPrId,
1228
1232
  rawXmlPolicy: this._renderCtx.rawXmlPolicy
1229
1233
  }
1230
1234
  });
@@ -173,6 +173,17 @@ function renderStyle(xml, style) {
173
173
  // For table styles
174
174
  if (style.tableProperties) {
175
175
  xml.openNode("w:tblPr");
176
+ // Per CT_TblPrBase order, band sizes precede w:tblW.
177
+ if (style.tableProperties.rowBandSize !== undefined) {
178
+ xml.leafNode("w:tblStyleRowBandSize", {
179
+ "w:val": String(style.tableProperties.rowBandSize)
180
+ });
181
+ }
182
+ if (style.tableProperties.colBandSize !== undefined) {
183
+ xml.leafNode("w:tblStyleColBandSize", {
184
+ "w:val": String(style.tableProperties.colBandSize)
185
+ });
186
+ }
176
187
  if (style.tableProperties.width) {
177
188
  xml.leafNode("w:tblW", {
178
189
  "w:w": String(style.tableProperties.width.value),
@@ -171,6 +171,12 @@ function renderTableProperties(xml, tPr, insidePropertyChange = false) {
171
171
  xml.leafNode("w:tblOverlap", { "w:val": tPr.float.overlap });
172
172
  }
173
173
  }
174
+ if (tPr.rowBandSize !== undefined) {
175
+ xml.leafNode("w:tblStyleRowBandSize", { "w:val": String(tPr.rowBandSize) });
176
+ }
177
+ if (tPr.colBandSize !== undefined) {
178
+ xml.leafNode("w:tblStyleColBandSize", { "w:val": String(tPr.colBandSize) });
179
+ }
174
180
  if (tPr.width) {
175
181
  renderTableWidth(xml, "w:tblW", tPr.width);
176
182
  }
@@ -1,5 +1,6 @@
1
1
  import { BaseXform } from "../base-xform.js";
2
2
  import { RichTextXform } from "../strings/rich-text-xform.js";
3
+ import { TextXform } from "../strings/text-xform.js";
3
4
  class CommentXform extends BaseXform {
4
5
  constructor(model) {
5
6
  super();
@@ -14,6 +15,12 @@ class CommentXform extends BaseXform {
14
15
  }
15
16
  return this._richTextXform;
16
17
  }
18
+ get textXform() {
19
+ if (!this._textXform) {
20
+ this._textXform = new TextXform();
21
+ }
22
+ return this._textXform;
23
+ }
17
24
  render(xmlStream, model) {
18
25
  const renderModel = model || this.model;
19
26
  xmlStream.openNode("comment", {
@@ -49,6 +56,13 @@ class CommentXform extends BaseXform {
49
56
  this.parser = this.richTextXform;
50
57
  this.parser.parseOpen(node);
51
58
  return true;
59
+ case "t":
60
+ // Legacy comments (e.g. produced by openpyxl/LibreOffice) may store the
61
+ // body as a bare <t> directly under <text> with no <r> run wrapper.
62
+ // This is valid for the CT_Rst type, so treat it like a run without font.
63
+ this.parser = this.textXform;
64
+ this.parser.parseOpen(node);
65
+ return true;
52
66
  default:
53
67
  return false;
54
68
  }
@@ -59,17 +73,26 @@ class CommentXform extends BaseXform {
59
73
  }
60
74
  }
61
75
  parseClose(name) {
76
+ if (this.parser) {
77
+ if (!this.parser.parseClose(name)) {
78
+ // The active sub-parser has finished. Collect its result.
79
+ if (this.parser === this._richTextXform) {
80
+ // <r> run: model is already a { font?, text } run.
81
+ this.model.note.texts.push(this.parser.model);
82
+ }
83
+ else {
84
+ // Bare <t> body (e.g. openpyxl/LibreOffice): wrap the plain string
85
+ // as a single run without font, mirroring a <r><t> run.
86
+ this.model.note.texts.push({ text: this.parser.model });
87
+ }
88
+ this.parser = undefined;
89
+ }
90
+ return true;
91
+ }
62
92
  switch (name) {
63
93
  case "comment":
64
94
  return false;
65
- case "r":
66
- this.model.note.texts.push(this.parser.model);
67
- this.parser = undefined;
68
- return true;
69
95
  default:
70
- if (this.parser) {
71
- this.parser.parseClose(name);
72
- }
73
96
  return true;
74
97
  }
75
98
  }
@@ -21,7 +21,7 @@
21
21
  // Consumers that convert workbooks with charts must call
22
22
  // `installChartSupport()` from `@cj-tech-master/excelts/chart` before
23
23
  // invoking `excelToPdf()`.
24
- import { getChartSupport } from "../excel/chart-host-registry.js";
24
+ import { getChartSupport, tryGetChartSupport } from "../excel/chart-host-registry.js";
25
25
  import { ValueType } from "../excel/enums.js";
26
26
  import { formatCellValue } from "../excel/utils/cell-format.js";
27
27
  import { tryInvokeFormulaEngine } from "../formula/host-registry.js";
@@ -1172,3 +1172,69 @@ export function createWordChartPdfRenderer() {
1172
1172
  support.drawChartPdf(page, model, rect);
1173
1173
  };
1174
1174
  }
1175
+ /**
1176
+ * Create a layout-aware chart renderer for use as the internal
1177
+ * `RenderLayoutOptions.chartRenderer` of the Word→PDF bridge.
1178
+ *
1179
+ * Unlike {@link createWordChartPdfRenderer} (which only sees the inner
1180
+ * classic `Chart` model), this renderer receives the full
1181
+ * {@link LayoutChart} and therefore handles **both** chart families
1182
+ * with the full Excel rendering engine:
1183
+ *
1184
+ * - Classic `<c:chart>` (`chartKind === "chart"`) → `wordChartToChartModel`
1185
+ * → `drawChartPdf` (vector).
1186
+ * - Modern `<cx:chartSpace>` ChartEx (`chartKind === "chartEx"`,
1187
+ * e.g. sunburst / treemap / waterfall / funnel / boxWhisker /
1188
+ * histogram / pareto / regionMap) → `parseChartEx` → `drawChartExPdf`
1189
+ * (vector) when the layout is vector-capable, otherwise the
1190
+ * pre-rendered SVG carried on the `LayoutChart` is left for the
1191
+ * translator's fallback.
1192
+ *
1193
+ * Returns `false` to decline a chart so the translator's built-in
1194
+ * fallback (inline SVG, then a titled placeholder box) takes over. This
1195
+ * keeps "fail soft" behaviour: a chart the engine can't draw still
1196
+ * renders *something* rather than a blank slot.
1197
+ *
1198
+ * Requires `installChartSupport()` to have been called.
1199
+ */
1200
+ export function createWordLayoutChartPdfRenderer() {
1201
+ return (layoutChart, page, rect) => {
1202
+ const support = tryGetChartSupport();
1203
+ if (!support) {
1204
+ // Chart support not installed — decline so the Word→PDF
1205
+ // translator falls back to the inline SVG / placeholder. This
1206
+ // mirrors the auto-detect contract in `word-bridge.ts`, where a
1207
+ // missing chart runtime must degrade gracefully rather than throw.
1208
+ return false;
1209
+ }
1210
+ const source = layoutChart.source;
1211
+ if (layoutChart.chartKind === "chart") {
1212
+ // Classic chart: prefer the structured source, fall back to nothing.
1213
+ if (source && source.type === "chart") {
1214
+ support.drawChartPdf(page, wordChartToChartModel(source.chart), rect);
1215
+ return;
1216
+ }
1217
+ return false;
1218
+ }
1219
+ // ChartEx. Parse the carried `cx:chartSpace` XML into a ChartExModel
1220
+ // and render it as vector PDF when the layout IDs are supported.
1221
+ if (source && source.type === "chartEx" && source.chartExXml) {
1222
+ let model;
1223
+ try {
1224
+ model = support.parseChartEx(source.chartExXml);
1225
+ }
1226
+ catch {
1227
+ return false; // Malformed XML — let the fallback path handle it.
1228
+ }
1229
+ if (model && support.canRenderChartExAsVectorPdf(model)) {
1230
+ support.drawChartExPdf(page, model, rect, {
1231
+ title: layoutChart.title
1232
+ });
1233
+ return;
1234
+ }
1235
+ }
1236
+ // Not vector-capable (or no source): decline so the translator
1237
+ // falls back to the inline SVG / placeholder.
1238
+ return false;
1239
+ };
1240
+ }
@@ -46,47 +46,59 @@ export async function docxToPdf(doc, options) {
46
46
  // caller explicitly overrode an axis. Margins are independent: the
47
47
  // section's margins are applied unless the caller overrode them.
48
48
  const layoutOptions = mapToLayoutOptions(doc, options);
49
- // 2. Auto-detect chart support: if no explicit chartRenderer is
50
- // provided, try to import the high-quality Excel-based renderer.
51
- let chartRendererForChart = options?.chartRenderer;
52
- if (!chartRendererForChart) {
53
- try {
54
- const mod = await import("./excel-bridge.js");
55
- if (typeof mod.createWordChartPdfRenderer === "function") {
56
- chartRendererForChart = mod.createWordChartPdfRenderer();
57
- }
58
- }
59
- catch {
60
- // Chart support not available — placeholder rendering takes over.
49
+ // 2. Try to obtain the built-in, layout-aware Excel chart renderer.
50
+ // It handles BOTH classic `<c:chart>` and modern `<cx:chartSpace>`
51
+ // ChartEx families (sunburst / treemap / waterfall / …). It is used
52
+ // directly when the caller supplies no `chartRenderer`, and as the
53
+ // fallback for ChartEx (which the public `Chart`-typed callback
54
+ // cannot express) or whenever a user callback declines a chart.
55
+ let builtInLayoutRenderer;
56
+ try {
57
+ const mod = await import("./excel-bridge.js");
58
+ if (typeof mod.createWordLayoutChartPdfRenderer === "function") {
59
+ builtInLayoutRenderer = mod.createWordLayoutChartPdfRenderer();
61
60
  }
62
61
  }
62
+ catch {
63
+ // Chart support not available — placeholder rendering takes over.
64
+ }
63
65
  // 3. Run the layout engine. Everything from line wrapping to page
64
66
  // breaks happens here; word-bridge no longer carries any of that.
65
67
  const layout = layoutDocumentFull(doc, layoutOptions);
66
68
  // 4. Build a render-options object for the PDF translator. The
67
- // chartRenderer adaptation: layout produces `LayoutChart` (which
68
- // contains the original `ChartContent` in `source`); the public
69
- // chartRenderer API takes the inner `Chart` model. We unwrap so
70
- // existing callers keep working unchanged.
69
+ // chart-rendering precedence is:
70
+ // a. classic chart + user callback → user callback (its `false`
71
+ // return falls through to the built-in layout renderer);
72
+ // b. ChartEx, or classic chart with no user callback, or a
73
+ // declined user callback → built-in layout-aware renderer;
74
+ // c. neither available / both decline → translator fallback
75
+ // (inline SVG, then a titled placeholder box).
76
+ const userChartRenderer = options?.chartRenderer;
71
77
  const renderOptions = {
72
78
  title: doc.coreProperties?.title,
73
79
  author: doc.coreProperties?.creator,
74
80
  subject: doc.coreProperties?.subject,
75
81
  defaultFont: options?.defaultFont ?? "Helvetica",
76
82
  defaultFontSize: options?.defaultFontSize ?? 11,
77
- chartRenderer: chartRendererForChart
83
+ chartRenderer: userChartRenderer || builtInLayoutRenderer
78
84
  ? (layoutChart, page, rect) => {
79
85
  const src = layoutChart.source;
80
- if (src && src.type === "chart") {
81
- // Forward the user's return value so a renderer that knows
82
- // how to draw classic charts but declines a particular
83
- // family (e.g. unsupported axis combination) gets the
84
- // translator's placeholder fallback.
85
- return chartRendererForChart(src.chart, page, rect);
86
+ // (a) Classic chart with a user-supplied callback: honour it
87
+ // first. Only fall through to the built-in renderer when
88
+ // it explicitly declines (`false`).
89
+ if (userChartRenderer && src && src.type === "chart") {
90
+ const handled = userChartRenderer(src.chart, page, rect);
91
+ if (handled !== false) {
92
+ return handled;
93
+ }
94
+ }
95
+ // (b) Built-in layout-aware renderer handles classic charts
96
+ // without a user callback AND all ChartEx charts.
97
+ if (builtInLayoutRenderer) {
98
+ return builtInLayoutRenderer(layoutChart, page, rect);
86
99
  }
87
- // ChartEx (or absent source) cannot be passed to the user's
88
- // `Chart`-typed callback. Decline so the translator's
89
- // placeholder runs instead of leaving a blank slot.
100
+ // (c) Decline so the translator's placeholder runs instead of
101
+ // leaving a blank slot.
90
102
  return false;
91
103
  }
92
104
  : undefined
@@ -105,13 +117,11 @@ export async function docxToPdf(doc, options) {
105
117
  * portrait-oriented numbers); otherwise the layout engine's defaults
106
118
  * (US Letter, 1-inch margins) take over.
107
119
  *
108
- * `headerMargin` / `footerMargin` are accepted for API compatibility
109
- * with the previous flow renderer but currently have no effect on the
110
- * layout-driven path: the layout engine does not yet position header /
111
- * footer paragraphs against custom inset values, and forwarding the
112
- * field would silently mislead callers. Pre-existing documents that
113
- * rely on those parameters still get the section's `headerMargin` /
114
- * `footerMargin` from sectionProperties when they exist.
120
+ * `headerMargin` / `footerMargin` are forwarded to the layout engine
121
+ * as header / footer band offsets (ECMA-376 `pgMar.header` /
122
+ * `pgMar.footer`). When omitted, the section's own header / footer
123
+ * margins apply; when neither exists the engine default of 36pt (0.5")
124
+ * is used.
115
125
  */
116
126
  function mapToLayoutOptions(doc, options) {
117
127
  const sectProps = doc.sectionProperties;
@@ -136,7 +146,12 @@ function mapToLayoutOptions(doc, options) {
136
146
  marginTop: options?.marginTop ?? sectionMarginTopPt,
137
147
  marginBottom: options?.marginBottom ?? sectionMarginBottomPt,
138
148
  marginLeft: options?.marginLeft ?? sectionMarginLeftPt,
139
- marginRight: options?.marginRight ?? sectionMarginRightPt
149
+ marginRight: options?.marginRight ?? sectionMarginRightPt,
150
+ // Header / footer offsets: only forward an explicit caller value.
151
+ // Leaving these undefined lets the layout engine fall back to the
152
+ // section's `pgMar.header` / `pgMar.footer` (then the 36pt default).
153
+ headerMargin: options?.headerMargin,
154
+ footerMargin: options?.footerMargin
140
155
  };
141
156
  const layoutOpts = {};
142
157
  // Only attach pageGeometry when at least one axis is actually
@@ -144,24 +144,136 @@ function computeDiff(oldTexts, newTexts) {
144
144
  }
145
145
  // Reverse since we built it backwards
146
146
  entries.reverse();
147
- // Post-process: pair adjacent delete+add as "modified" when they're at the same position
147
+ return pairModifications(entries);
148
+ }
149
+ /**
150
+ * Pair deletions with insertions that represent the *same* paragraph in a
151
+ * modified form, based on text similarity.
152
+ *
153
+ * The LCS pass only matches paragraphs whose text is byte-identical, so when
154
+ * every paragraph is lightly edited it produces all-deletions + all-insertions
155
+ * with no "modified" at all. Within each contiguous change block (a run of
156
+ * deletions/insertions bounded by unchanged entries), we greedily pair each
157
+ * deletion with the most similar insertion whose similarity clears
158
+ * {@link MODIFY_SIMILARITY_THRESHOLD}. Pairs become "modified"; anything left
159
+ * unpaired stays a pure deletion or insertion. This yields, e.g., a recipe
160
+ * whose steps were all tweaked → mostly "modified", with a removed step as a
161
+ * pure deletion and a brand-new step as a pure insertion.
162
+ */
163
+ function pairModifications(entries) {
148
164
  const result = [];
149
- for (let k = 0; k < entries.length; k++) {
150
- const curr = entries[k];
151
- const next = k + 1 < entries.length ? entries[k + 1] : undefined;
152
- if (curr.type === "deleted" && next?.type === "added") {
153
- result.push({
165
+ let k = 0;
166
+ while (k < entries.length) {
167
+ const entry = entries[k];
168
+ if (entry.type !== "deleted" && entry.type !== "added") {
169
+ result.push(entry);
170
+ k++;
171
+ continue;
172
+ }
173
+ // Collect the maximal contiguous run of deleted/added entries.
174
+ const dels = [];
175
+ const adds = [];
176
+ let j = k;
177
+ while (j < entries.length && (entries[j].type === "deleted" || entries[j].type === "added")) {
178
+ if (entries[j].type === "deleted") {
179
+ dels.push(entries[j]);
180
+ }
181
+ else {
182
+ adds.push(entries[j]);
183
+ }
184
+ j++;
185
+ }
186
+ result.push(...pairChangeBlock(dels, adds));
187
+ k = j;
188
+ }
189
+ return result;
190
+ }
191
+ /**
192
+ * Pair one change block's deletions and insertions by similarity. Greedy:
193
+ * process deletions in order, each claiming the most similar still-unclaimed
194
+ * insertion above the threshold. Emits entries in old-index / new-index order.
195
+ */
196
+ function pairChangeBlock(dels, adds) {
197
+ const usedAdd = new Array(adds.length).fill(false);
198
+ const out = [];
199
+ const leftoverAdds = [];
200
+ for (const del of dels) {
201
+ let bestIdx = -1;
202
+ let bestScore = MODIFY_SIMILARITY_THRESHOLD;
203
+ for (let a = 0; a < adds.length; a++) {
204
+ if (usedAdd[a]) {
205
+ continue;
206
+ }
207
+ const score = textSimilarity(del.oldText ?? "", adds[a].newText ?? "");
208
+ if (score >= bestScore) {
209
+ bestScore = score;
210
+ bestIdx = a;
211
+ }
212
+ }
213
+ if (bestIdx >= 0) {
214
+ usedAdd[bestIdx] = true;
215
+ out.push({
154
216
  type: "modified",
155
- oldIndex: curr.oldIndex,
156
- newIndex: next.newIndex,
157
- oldText: curr.oldText,
158
- newText: next.newText
217
+ oldIndex: del.oldIndex,
218
+ newIndex: adds[bestIdx].newIndex,
219
+ oldText: del.oldText,
220
+ newText: adds[bestIdx].newText
159
221
  });
160
- k++; // skip next
161
222
  }
162
223
  else {
163
- result.push(curr);
224
+ out.push(del); // pure deletion
164
225
  }
165
226
  }
166
- return result;
227
+ for (let a = 0; a < adds.length; a++) {
228
+ if (!usedAdd[a]) {
229
+ leftoverAdds.push(adds[a]); // pure insertion
230
+ }
231
+ }
232
+ // Order the block by position: modifications and deletions (old order)
233
+ // first, then the surviving pure insertions (new order). Both arrays are
234
+ // already in their natural index order from the LCS walk.
235
+ out.push(...leftoverAdds);
236
+ return out;
237
+ }
238
+ /** Minimum similarity (0..1) for a delete+add to be treated as a modification. */
239
+ const MODIFY_SIMILARITY_THRESHOLD = 0.5;
240
+ /**
241
+ * Similarity of two strings in [0, 1], combining word-set overlap (Jaccard)
242
+ * with a shared-prefix bonus. Cheap and dependency-free — good enough to tell
243
+ * "same paragraph, lightly edited" from "completely different paragraph".
244
+ */
245
+ function textSimilarity(a, b) {
246
+ if (a === b) {
247
+ return 1;
248
+ }
249
+ if (a.length === 0 || b.length === 0) {
250
+ return 0;
251
+ }
252
+ const wordsA = a.toLowerCase().split(/\s+/).filter(Boolean);
253
+ const wordsB = b.toLowerCase().split(/\s+/).filter(Boolean);
254
+ if (wordsA.length === 0 || wordsB.length === 0) {
255
+ return 0;
256
+ }
257
+ const setA = new Set(wordsA);
258
+ const setB = new Set(wordsB);
259
+ let intersection = 0;
260
+ for (const w of setA) {
261
+ if (setB.has(w)) {
262
+ intersection++;
263
+ }
264
+ }
265
+ const union = setA.size + setB.size - intersection;
266
+ const jaccard = union === 0 ? 0 : intersection / union;
267
+ // Shared-prefix bonus: paragraphs that begin the same ("Step 3: …") are very
268
+ // likely the same item edited, even if many words changed.
269
+ let prefix = 0;
270
+ const max = Math.min(a.length, b.length);
271
+ while (prefix < max && a[prefix] === b[prefix]) {
272
+ prefix++;
273
+ }
274
+ const prefixRatio = prefix / Math.max(a.length, b.length);
275
+ // Weight word overlap most, with shared prefix as a strong booster so
276
+ // "Hello World" → "Hello Earth" (half the words, same prefix) reads as a
277
+ // modification while unrelated text stays a delete+add.
278
+ return Math.min(1, jaccard * 0.7 + prefixRatio * 0.5);
167
279
  }
@@ -62,6 +62,7 @@ export function createShape(options) {
62
62
  outlineWidth,
63
63
  noOutline,
64
64
  textContent: options.textBody?.paragraphs,
65
+ textBodyAnchor: options.textBody?.anchor,
65
66
  altText: options.altText,
66
67
  name: options.name,
67
68
  horizontalPosition: options.horizontalPosition,
@@ -69,6 +70,8 @@ export function createShape(options) {
69
70
  wrap: options.wrap,
70
71
  behindDoc: options.behindDoc,
71
72
  rotation: options.rotation,
73
+ flipHorizontal: options.flipH,
74
+ flipVertical: options.flipV,
72
75
  rawXml: rawXml.length > 0 ? rawXml : undefined,
73
76
  _advancedFillXml: advanced.fillXml,
74
77
  _advancedEffectsXml: advanced.effectsXml
@@ -229,7 +229,7 @@ function convertCell(cell, opts) {
229
229
  const para = {
230
230
  type: "paragraph",
231
231
  properties: opts.preserveFormatting ? alignmentToParaProps(cell.alignment) : undefined,
232
- children: children.length > 0 ? children : [{ content: [{ type: "text", text: "" }] }]
232
+ children: wrapHyperlink(cell.hyperlink, children)
233
233
  };
234
234
  const cellProps = {};
235
235
  if (opts.preserveFormatting && cell.fill) {
@@ -249,6 +249,26 @@ function convertCell(cell, opts) {
249
249
  properties: Object.keys(cellProps).length > 0 ? cellProps : undefined
250
250
  };
251
251
  }
252
+ /**
253
+ * Build a paragraph's children for a converted cell, wrapping the runs
254
+ * in a Word {@link Hyperlink} when the source Excel cell carries one.
255
+ *
256
+ * Excel cell hyperlinks are external URLs (or `#Sheet!A1` internal
257
+ * references). We map `#…` targets to a Word anchor and everything else
258
+ * to an external `url`; the packager assigns the relationship id on
259
+ * write. An empty cell still produces a single empty run so the table
260
+ * structure stays intact.
261
+ */
262
+ function wrapHyperlink(hyperlink, runs) {
263
+ const children = runs.length > 0 ? [...runs] : [{ content: [{ type: "text", text: "" }] }];
264
+ if (!hyperlink) {
265
+ return children;
266
+ }
267
+ const link = hyperlink.startsWith("#")
268
+ ? { type: "hyperlink", anchor: hyperlink.slice(1), children }
269
+ : { type: "hyperlink", url: hyperlink, children };
270
+ return [link];
271
+ }
252
272
  function cellText(cell) {
253
273
  if (cell.type === ValueType.Null || cell.type === ValueType.Merge) {
254
274
  return "";