dom-docx 0.1.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 (132) hide show
  1. package/API.md +533 -0
  2. package/LICENSE +21 -0
  3. package/README.md +236 -0
  4. package/dist/browser.d.ts +34 -0
  5. package/dist/browser.d.ts.map +1 -0
  6. package/dist/browser.js +35 -0
  7. package/dist/browser.js.map +1 -0
  8. package/dist/cli.d.ts +3 -0
  9. package/dist/cli.d.ts.map +1 -0
  10. package/dist/cli.js +118 -0
  11. package/dist/cli.js.map +1 -0
  12. package/dist/converter/bordered-block.d.ts +54 -0
  13. package/dist/converter/bordered-block.d.ts.map +1 -0
  14. package/dist/converter/bordered-block.js +124 -0
  15. package/dist/converter/bordered-block.js.map +1 -0
  16. package/dist/converter/build-docx.d.ts +46 -0
  17. package/dist/converter/build-docx.d.ts.map +1 -0
  18. package/dist/converter/build-docx.js +161 -0
  19. package/dist/converter/build-docx.js.map +1 -0
  20. package/dist/converter/computed-style-snapshot.browser.js +73 -0
  21. package/dist/converter/computed-style-snapshot.d.ts +10 -0
  22. package/dist/converter/computed-style-snapshot.d.ts.map +1 -0
  23. package/dist/converter/computed-style-snapshot.js +78 -0
  24. package/dist/converter/computed-style-snapshot.js.map +1 -0
  25. package/dist/converter/constants.d.ts +51 -0
  26. package/dist/converter/constants.d.ts.map +1 -0
  27. package/dist/converter/constants.js +163 -0
  28. package/dist/converter/constants.js.map +1 -0
  29. package/dist/converter/css.d.ts +112 -0
  30. package/dist/converter/css.d.ts.map +1 -0
  31. package/dist/converter/css.js +621 -0
  32. package/dist/converter/css.js.map +1 -0
  33. package/dist/converter/flex.d.ts +59 -0
  34. package/dist/converter/flex.d.ts.map +1 -0
  35. package/dist/converter/flex.js +252 -0
  36. package/dist/converter/flex.js.map +1 -0
  37. package/dist/converter/image.d.ts +38 -0
  38. package/dist/converter/image.d.ts.map +1 -0
  39. package/dist/converter/image.js +159 -0
  40. package/dist/converter/image.js.map +1 -0
  41. package/dist/converter/inline.d.ts +18 -0
  42. package/dist/converter/inline.d.ts.map +1 -0
  43. package/dist/converter/inline.js +213 -0
  44. package/dist/converter/inline.js.map +1 -0
  45. package/dist/converter/ooxml-patch.d.ts +23 -0
  46. package/dist/converter/ooxml-patch.d.ts.map +1 -0
  47. package/dist/converter/ooxml-patch.js +54 -0
  48. package/dist/converter/ooxml-patch.js.map +1 -0
  49. package/dist/converter/style-path.d.ts +4 -0
  50. package/dist/converter/style-path.d.ts.map +1 -0
  51. package/dist/converter/style-path.js +17 -0
  52. package/dist/converter/style-path.js.map +1 -0
  53. package/dist/converter/style-resolver-node.d.ts +7 -0
  54. package/dist/converter/style-resolver-node.d.ts.map +1 -0
  55. package/dist/converter/style-resolver-node.js +26 -0
  56. package/dist/converter/style-resolver-node.js.map +1 -0
  57. package/dist/converter/style-resolver.d.ts +24 -0
  58. package/dist/converter/style-resolver.d.ts.map +1 -0
  59. package/dist/converter/style-resolver.js +122 -0
  60. package/dist/converter/style-resolver.js.map +1 -0
  61. package/dist/converter/svg.d.ts +11 -0
  62. package/dist/converter/svg.d.ts.map +1 -0
  63. package/dist/converter/svg.js +116 -0
  64. package/dist/converter/svg.js.map +1 -0
  65. package/dist/converter/table.d.ts +8 -0
  66. package/dist/converter/table.d.ts.map +1 -0
  67. package/dist/converter/table.js +745 -0
  68. package/dist/converter/table.js.map +1 -0
  69. package/dist/converter/text-metrics.d.ts +17 -0
  70. package/dist/converter/text-metrics.d.ts.map +1 -0
  71. package/dist/converter/text-metrics.js +51 -0
  72. package/dist/converter/text-metrics.js.map +1 -0
  73. package/dist/converter/types.d.ts +82 -0
  74. package/dist/converter/types.d.ts.map +1 -0
  75. package/dist/converter/types.js +9 -0
  76. package/dist/converter/types.js.map +1 -0
  77. package/dist/converter/visitor.d.ts +11 -0
  78. package/dist/converter/visitor.d.ts.map +1 -0
  79. package/dist/converter/visitor.js +910 -0
  80. package/dist/converter/visitor.js.map +1 -0
  81. package/dist/converter.d.ts +28 -0
  82. package/dist/converter.d.ts.map +1 -0
  83. package/dist/converter.js +44 -0
  84. package/dist/converter.js.map +1 -0
  85. package/dist/html-wrap.d.ts +3 -0
  86. package/dist/html-wrap.d.ts.map +1 -0
  87. package/dist/html-wrap.js +26 -0
  88. package/dist/html-wrap.js.map +1 -0
  89. package/dist/index.d.ts +17 -0
  90. package/dist/index.d.ts.map +1 -0
  91. package/dist/index.js +16 -0
  92. package/dist/index.js.map +1 -0
  93. package/examples/README.md +39 -0
  94. package/examples/balance-sheet/compare_side_by_side.png +0 -0
  95. package/examples/balance-sheet/input.html +41 -0
  96. package/examples/balance-sheet/output.docx +0 -0
  97. package/examples/balance-sheet/preview.png +0 -0
  98. package/examples/invoice/compare_side_by_side.png +0 -0
  99. package/examples/invoice/input.html +88 -0
  100. package/examples/invoice/logo.png +0 -0
  101. package/examples/invoice/output.docx +0 -0
  102. package/examples/invoice/preview.png +0 -0
  103. package/examples/javascript-essay/compare_side_by_side.png +0 -0
  104. package/examples/javascript-essay/input.html +39 -0
  105. package/examples/javascript-essay/output.docx +0 -0
  106. package/examples/javascript-essay/preview.png +0 -0
  107. package/examples/product-launch-brief/compare_side_by_side.png +0 -0
  108. package/examples/product-launch-brief/input.html +120 -0
  109. package/examples/product-launch-brief/output.docx +0 -0
  110. package/examples/product-launch-brief/preview.png +0 -0
  111. package/examples/quarterly-financials/compare_side_by_side.png +0 -0
  112. package/examples/quarterly-financials/input.html +27 -0
  113. package/examples/quarterly-financials/output.docx +0 -0
  114. package/examples/quarterly-financials/preview.png +0 -0
  115. package/examples/react-dashboard/compare_side_by_side.png +0 -0
  116. package/examples/react-dashboard/input.html +1 -0
  117. package/examples/react-dashboard/output.docx +0 -0
  118. package/examples/react-dashboard/preview.html +107 -0
  119. package/examples/react-dashboard/preview.png +0 -0
  120. package/examples/regional-sales-dashboard/compare_side_by_side.png +0 -0
  121. package/examples/regional-sales-dashboard/input.html +129 -0
  122. package/examples/regional-sales-dashboard/output.docx +0 -0
  123. package/examples/regional-sales-dashboard/preview.png +0 -0
  124. package/examples/sales-contract/compare_side_by_side.png +0 -0
  125. package/examples/sales-contract/input.html +68 -0
  126. package/examples/sales-contract/output.docx +0 -0
  127. package/examples/sales-contract/preview.png +0 -0
  128. package/examples/sprint-retrospective/compare_side_by_side.png +0 -0
  129. package/examples/sprint-retrospective/input.html +51 -0
  130. package/examples/sprint-retrospective/output.docx +0 -0
  131. package/examples/sprint-retrospective/preview.png +0 -0
  132. package/package.json +108 -0
@@ -0,0 +1,745 @@
1
+ import { AlignmentType, BorderStyle, LineRuleType, Paragraph, ShadingType, Table, TableCell, TableRow, TextRun, WidthType, } from "docx";
2
+ import { BODY_LINE_EXACT_TWIPS, HEADING_FONT_HALF_POINTS } from "./constants.js";
3
+ import { cssToBlockTypography, inheritedFontFamily, isHiddenElement, mapTextAlign, pxToTwips, } from "./css.js";
4
+ import { INLINE_STYLE_RESOLVER } from "./style-resolver.js";
5
+ import { collectInlineRunsFromNodes } from "./inline.js";
6
+ import { estimateTextWidthTwips, minContentWidthTwips, } from "./text-metrics.js";
7
+ import { DEFAULT_VISITOR_CONTEXT } from "./types.js";
8
+ // Runtime-only cycle with visitor.ts (function declarations hoist): cells reuse
9
+ // the body list-numbering path for `<ul>`/`<ol>` children.
10
+ import { processList } from "./visitor.js";
11
+ /** Usable content width: 8.5" page − 2 × 1" margins = 6.5" → 9360 twips. */
12
+ const CONTENT_WIDTH_TWIPS = 9360;
13
+ // Chromium renders `border="1"` collapsed cell borders as a 1px #eee hairline;
14
+ // 0.5pt strokes rasterize 2px wide in LibreOffice and double the border ink.
15
+ const BORDER_COLOR = "eeeeee";
16
+ const BORDER_SIZE = 2;
17
+ function parseColspan(cell) {
18
+ return Math.max(1, parseInt(cell.attribs?.colspan ?? "1", 10) || 1);
19
+ }
20
+ function parseRowspan(cell) {
21
+ return Math.max(1, parseInt(cell.attribs?.rowspan ?? "1", 10) || 1);
22
+ }
23
+ /**
24
+ * Resolve each cell's grid column, skipping columns occupied by rowspanning
25
+ * cells from earlier rows (HTML grid placement).
26
+ */
27
+ function placeCells(rows) {
28
+ const placedRows = [];
29
+ const carry = [];
30
+ let maxColumns = 1;
31
+ for (const row of rows) {
32
+ const occupied = new Set();
33
+ for (const c of carry) {
34
+ if (c.rowsLeft > 0)
35
+ for (let i = 0; i < c.span; i++)
36
+ occupied.add(c.col + i);
37
+ }
38
+ const placed = [];
39
+ const newCarries = [];
40
+ let col = 0;
41
+ for (const cell of row) {
42
+ while (occupied.has(col))
43
+ col += 1;
44
+ placed.push({ cell, columnIndex: col });
45
+ if (cell.rowspan > 1) {
46
+ newCarries.push({ col, span: cell.colspan, rowsLeft: cell.rowspan - 1 });
47
+ }
48
+ col += cell.colspan;
49
+ }
50
+ maxColumns = Math.max(maxColumns, col, ...[...occupied].map((c) => c + 1));
51
+ placedRows.push(placed);
52
+ // Decrement only pre-existing spans — a span opened THIS row still covers
53
+ // all of its rowspan-1 following rows.
54
+ for (const c of carry)
55
+ c.rowsLeft -= 1;
56
+ carry.push(...newCarries);
57
+ }
58
+ return { placedRows, maxColumns };
59
+ }
60
+ function collectRowElements($, table) {
61
+ const trElements = [];
62
+ for (const child of $(table).children("thead, tbody, tfoot, tr").toArray()) {
63
+ if (child.name.toLowerCase() === "tr") {
64
+ trElements.push(child);
65
+ }
66
+ else {
67
+ trElements.push(...$(child).children("tr").toArray());
68
+ }
69
+ }
70
+ return trElements;
71
+ }
72
+ function parseRows($, trElements, table, styleResolver) {
73
+ return trElements
74
+ .filter((tr) => !isHiddenElement(tr, styleResolver))
75
+ .map((tr) => $(tr)
76
+ .children("td, th")
77
+ .toArray()
78
+ .filter((cell) => !isHiddenElement(cell, styleResolver))
79
+ .map((cell) => ({
80
+ element: cell,
81
+ row: tr,
82
+ table,
83
+ colspan: parseColspan(cell),
84
+ rowspan: parseRowspan(cell),
85
+ })));
86
+ }
87
+ /** Cell styles override row, row overrides inherited table styles (font, color, bg). */
88
+ function resolveCellCss(cell, styleResolver) {
89
+ const tableCss = styleResolver.getCss(cell.table);
90
+ const rowCss = styleResolver.getCss(cell.row);
91
+ const cellCss = styleResolver.getCss(cell.element);
92
+ return {
93
+ ...rowCss,
94
+ ...cellCss,
95
+ color: cellCss.color ?? rowCss.color ?? tableCss.color,
96
+ fontSize: cellCss.fontSize ?? rowCss.fontSize ?? tableCss.fontSize,
97
+ fontWeight: cellCss.fontWeight ?? rowCss.fontWeight ?? tableCss.fontWeight,
98
+ fontStyle: cellCss.fontStyle ?? rowCss.fontStyle ?? tableCss.fontStyle,
99
+ textAlign: cellCss.textAlign ?? rowCss.textAlign ?? tableCss.textAlign,
100
+ // Table background shows through cells with no fill of their own.
101
+ backgroundColor: cellCss.backgroundColor ?? rowCss.backgroundColor ?? tableCss.backgroundColor,
102
+ };
103
+ }
104
+ function cellTypography(cell, styleResolver) {
105
+ const typography = cssToBlockTypography(resolveCellCss(cell, styleResolver));
106
+ if (!typography.font) {
107
+ // Walks cell → row → table → wrapper (CSS font-family inheritance).
108
+ const font = inheritedFontFamily(cell.element, styleResolver);
109
+ if (font)
110
+ typography.font = font;
111
+ }
112
+ if (cell.element.name.toLowerCase() === "th") {
113
+ typography.bold = true;
114
+ }
115
+ return typography;
116
+ }
117
+ function cellShading(cell, styleResolver) {
118
+ const { backgroundColor } = resolveCellCss(cell, styleResolver);
119
+ if (!backgroundColor)
120
+ return undefined;
121
+ return {
122
+ type: ShadingType.CLEAR,
123
+ fill: backgroundColor,
124
+ color: "auto",
125
+ };
126
+ }
127
+ function elementPlainText(element) {
128
+ const parts = [];
129
+ function walk(nodes) {
130
+ for (const node of nodes) {
131
+ if (node.type === "text")
132
+ parts.push(node.data ?? "");
133
+ else if (node.type === "tag")
134
+ walk(node.children ?? []);
135
+ }
136
+ }
137
+ walk(element.children ?? []);
138
+ return parts.join("").replace(/\s+/g, " ").trim();
139
+ }
140
+ function cellContentIsBold(cell, styleResolver) {
141
+ if (cell.element.name.toLowerCase() === "th")
142
+ return true;
143
+ const css = resolveCellCss(cell, styleResolver);
144
+ if (css.fontWeight === "bold" || Number(css.fontWeight) >= 600)
145
+ return true;
146
+ return Boolean(cell.element.children?.some((node) => node.type === "tag" && node.name.toLowerCase() === "strong"));
147
+ }
148
+ function cellTextMeasureOptions(cell, styleResolver) {
149
+ const css = resolveCellCss(cell, styleResolver);
150
+ const bold = cellContentIsBold(cell, styleResolver) ||
151
+ css.fontWeight === "bold" ||
152
+ Number(css.fontWeight) >= 600;
153
+ return {
154
+ bold,
155
+ fontSizeHalfPoints: css.fontSize,
156
+ };
157
+ }
158
+ /** Minimal width for layout-only columns (Word/LibreOffice reject true zero-width grid cols). */
159
+ const LAYOUT_GUTTER_TWIPS = pxToTwips(2);
160
+ function estimateCellTextWidthTwips(cell, styleResolver) {
161
+ const text = elementPlainText(cell.element);
162
+ return Math.max(minContentWidthTwips(), estimateTextWidthTwips(text, cellTextMeasureOptions(cell, styleResolver)));
163
+ }
164
+ function estimateCellContentWidthTwips(cell, styleResolver, cellPadding) {
165
+ const padX = cellPadding ? cellPadding * 2 : pxToTwips(16);
166
+ return padX + estimateCellTextWidthTwips(cell, styleResolver);
167
+ }
168
+ /**
169
+ * Empty cells used only for table-layout alignment (vote gutters, rank columns,
170
+ * indent spacers). Collapse unless the author declared a width via CSS or attrs.
171
+ */
172
+ function isDecorativeSpacerCell(cell) {
173
+ if (elementPlainText(cell.element).length > 0)
174
+ return false;
175
+ return !cell.element.children?.some((node) => node.type === "tag" && node.name.toLowerCase() === "table");
176
+ }
177
+ function columnsWithContent(placedRows) {
178
+ let maxCol = 0;
179
+ for (const row of placedRows) {
180
+ for (const { cell, columnIndex } of row) {
181
+ maxCol = Math.max(maxCol, columnIndex + cell.colspan);
182
+ }
183
+ }
184
+ const has = Array.from({ length: maxCol }, () => false);
185
+ for (const row of placedRows) {
186
+ for (const { cell, columnIndex } of row) {
187
+ if (isDecorativeSpacerCell(cell))
188
+ continue;
189
+ for (let i = 0; i < cell.colspan; i++)
190
+ has[columnIndex + i] = true;
191
+ }
192
+ }
193
+ return has;
194
+ }
195
+ /** Explicit `width` on a single-column cell (style px/% or legacy attr) → pinned column twips. */
196
+ function explicitCellWidthTwips(cell, styleResolver, contentWidthTwips) {
197
+ if (cell.colspan !== 1)
198
+ return undefined;
199
+ const css = styleResolver.getCss(cell.element);
200
+ if (css.widthTwips !== undefined)
201
+ return css.widthTwips;
202
+ if (css.widthPercent !== undefined) {
203
+ return Math.round((css.widthPercent / 100) * contentWidthTwips);
204
+ }
205
+ const attr = cell.element.attribs?.width?.trim();
206
+ if (attr) {
207
+ const percent = attr.match(/^(\d+(?:\.\d+)?)%$/);
208
+ if (percent)
209
+ return Math.round((parseFloat(percent[1]) / 100) * contentWidthTwips);
210
+ const px = parseFloat(attr);
211
+ if (Number.isFinite(px))
212
+ return pxToTwips(px);
213
+ }
214
+ return undefined;
215
+ }
216
+ /**
217
+ * Pinned columns keep their explicit width; the rest share the remaining width
218
+ * by content weight. Falls back to pure content weighting when the pins leave
219
+ * no room for the unpinned columns.
220
+ */
221
+ function distributeWithPinnedColumns(weights, pinned, total) {
222
+ const unpinnedIdx = pinned
223
+ .map((p, i) => (p === undefined ? i : -1))
224
+ .filter((i) => i >= 0);
225
+ if (unpinnedIdx.length === weights.length)
226
+ return distributeColumnWidths(weights, total);
227
+ if (unpinnedIdx.length === 0) {
228
+ return distributeColumnWidths(pinned.map((p) => p ?? 0), total);
229
+ }
230
+ const pinnedSum = pinned.reduce((acc, p) => acc + (p ?? 0), 0);
231
+ const free = total - pinnedSum;
232
+ if (free < unpinnedIdx.length * pxToTwips(32)) {
233
+ return distributeColumnWidths(weights, total);
234
+ }
235
+ const freeWidths = distributeColumnWidths(unpinnedIdx.map((i) => weights[i]), free);
236
+ const out = pinned.map((p) => p ?? 0);
237
+ unpinnedIdx.forEach((i, k) => {
238
+ out[i] = freeWidths[k];
239
+ });
240
+ return out;
241
+ }
242
+ /** Scale column weights to exactly `total` twips (table is width:100%). */
243
+ function distributeColumnWidths(weights, total) {
244
+ if (weights.length === 0)
245
+ return [];
246
+ const sum = weights.reduce((acc, w) => acc + w, 0);
247
+ if (sum <= 0) {
248
+ const base = Math.floor(total / weights.length);
249
+ const equal = Array.from({ length: weights.length }, () => base);
250
+ for (let i = 0; i < total - base * weights.length; i++) {
251
+ equal[i] += 1;
252
+ }
253
+ return equal;
254
+ }
255
+ const scaled = weights.map((w) => (w / sum) * total);
256
+ const columnWidths = scaled.map((w) => Math.floor(w));
257
+ let remainder = total - columnWidths.reduce((acc, w) => acc + w, 0);
258
+ const fractional = scaled
259
+ .map((w, index) => ({ index, fraction: w - Math.floor(w) }))
260
+ .sort((a, b) => b.fraction - a.fraction);
261
+ for (let i = 0; remainder > 0; i++, remainder--) {
262
+ columnWidths[fractional[i % fractional.length].index] += 1;
263
+ }
264
+ return columnWidths;
265
+ }
266
+ /** Pass 1 — derive max grid columns and content-weighted column widths. */
267
+ function analyzeGrid(rows, styleResolver, cellPadding, contentWidthTwips, declaredWidthTwips, fillParent = false) {
268
+ const { placedRows, maxColumns } = placeCells(rows);
269
+ const columnHasContent = columnsWithContent(placedRows);
270
+ const columnMinWidths = Array.from({ length: maxColumns }, (_, i) => columnHasContent[i] ? pxToTwips(32) : LAYOUT_GUTTER_TWIPS);
271
+ const pinnedWidths = Array.from({ length: maxColumns }, () => undefined);
272
+ for (const row of placedRows) {
273
+ for (const { cell, columnIndex } of row) {
274
+ const span = cell.colspan;
275
+ const explicit = explicitCellWidthTwips(cell, styleResolver, contentWidthTwips);
276
+ if (explicit !== undefined) {
277
+ pinnedWidths[columnIndex] = Math.max(pinnedWidths[columnIndex] ?? 0, explicit);
278
+ }
279
+ if (isDecorativeSpacerCell(cell))
280
+ continue;
281
+ const need = estimateCellContentWidthTwips(cell, styleResolver, cellPadding);
282
+ const currentSpanWidth = sumColumnWidths(columnMinWidths, columnIndex, span);
283
+ if (need > currentSpanWidth) {
284
+ const perCol = (need - currentSpanWidth) / span;
285
+ for (let i = 0; i < span; i++) {
286
+ columnMinWidths[columnIndex + i] += perCol;
287
+ }
288
+ }
289
+ }
290
+ }
291
+ // HTML tables shrink-to-fit by default — only a declared width stretches
292
+ // them (agent HTML habitually writes width:100%; wild HTML often doesn't).
293
+ const naturalWidth = Math.round(columnMinWidths.reduce((sum, w, i) => sum + (pinnedWidths[i] ?? w), 0));
294
+ const totalWidth = Math.min(contentWidthTwips, declaredWidthTwips ?? (fillParent ? contentWidthTwips : naturalWidth));
295
+ const columnWidths = distributeWithPinnedColumns(columnMinWidths, pinnedWidths, totalWidth);
296
+ return { maxColumns, placedRows, columnWidths, totalWidth };
297
+ }
298
+ function sumColumnWidths(columnWidths, start, span) {
299
+ return columnWidths.slice(start, start + span).reduce((total, w) => total + w, 0);
300
+ }
301
+ function parseCellPadding(table) {
302
+ const cellpadding = table.attribs?.cellpadding;
303
+ if (!cellpadding)
304
+ return undefined;
305
+ const px = parseFloat(cellpadding);
306
+ return Number.isFinite(px) ? pxToTwips(px) : undefined;
307
+ }
308
+ /** Declared table width from CSS or the legacy `width` attribute. */
309
+ function tableDeclaredWidth(table, styleResolver) {
310
+ const css = styleResolver.getCss(table);
311
+ if (css.widthPercent !== undefined)
312
+ return { percent: css.widthPercent };
313
+ if (css.widthTwips !== undefined)
314
+ return { twips: css.widthTwips };
315
+ const maxWidth = css.maxWidthTwips;
316
+ const attr = table.attribs?.width?.trim();
317
+ if (attr) {
318
+ const pct = attr.match(/^(\d+(?:\.\d+)?)%$/);
319
+ if (pct)
320
+ return { percent: parseFloat(pct[1]) };
321
+ const px = parseFloat(attr);
322
+ if (Number.isFinite(px))
323
+ return { twips: pxToTwips(px) };
324
+ }
325
+ // Email idiom: fluid width capped by max-width (`width:100%;max-width:600px`
326
+ // parses as percent above; bare max-width acts as the effective width).
327
+ if (maxWidth !== undefined)
328
+ return { twips: maxWidth };
329
+ return {};
330
+ }
331
+ /** Table positioning: `align` attribute or CSS auto margins. */
332
+ function tableAlignment(table) {
333
+ const align = table.attribs?.align?.trim().toLowerCase();
334
+ if (align === "center")
335
+ return AlignmentType.CENTER;
336
+ if (align === "right")
337
+ return AlignmentType.RIGHT;
338
+ const style = table.attribs?.style ?? "";
339
+ const leftAuto = /margin-left\s*:\s*auto/i.test(style);
340
+ const rightAuto = /margin-right\s*:\s*auto/i.test(style);
341
+ const shorthandAuto = /margin\s*:\s*[^;]*\bauto\b/i.test(style);
342
+ if (shorthandAuto || (leftAuto && rightAuto))
343
+ return AlignmentType.CENTER;
344
+ if (leftAuto)
345
+ return AlignmentType.RIGHT;
346
+ return undefined;
347
+ }
348
+ /** `border` attribute ≥ 1 → Chromium paints every cell border (full grid). */
349
+ function hasAttrGrid(table) {
350
+ const borderAttr = table.attribs?.border;
351
+ return borderAttr !== undefined && borderAttr !== "0";
352
+ }
353
+ /**
354
+ * HTML separate-borders model: without `border-collapse:collapse`, cells sit
355
+ * 2px apart (UA border-spacing) unless `cellspacing` says otherwise, and the
356
+ * page background shows through — visible as white lines across shaded rows.
357
+ * (Word's native w:tblCellSpacing is ignored by LibreOffice, so the gaps are
358
+ * emulated with white borders instead.)
359
+ */
360
+ function separateBorderSpacingPx(table, styleResolver) {
361
+ if (styleResolver.getCss(table).borderCollapse === "collapse")
362
+ return 0;
363
+ // Layout tables (`border="0"`) have no visible cell grid in browsers even when
364
+ // cellspacing defaults to 2 — emulating gaps as white borders shows as gridlines
365
+ // on shaded backgrounds (HN, email templates).
366
+ if (table.attribs?.border === "0")
367
+ return 0;
368
+ const spacingAttr = table.attribs?.cellspacing;
369
+ if (spacingAttr !== undefined)
370
+ return parseFloat(spacingAttr) || 0;
371
+ return 2;
372
+ }
373
+ const NO_BORDER = { style: BorderStyle.NONE, size: 0, color: "auto" };
374
+ /**
375
+ * Border plan: the `border` attr means a full hairline cell grid; CSS borders on
376
+ * the table element style only the OUTER frame (per-side width/color), never the
377
+ * inside gridlines.
378
+ */
379
+ function tableBorderPlan(table, styleResolver) {
380
+ const css = styleResolver.getCss(table);
381
+ const gridColor = css.borderColor ?? css.border?.color ?? BORDER_COLOR;
382
+ if (hasAttrGrid(table)) {
383
+ const side = { style: BorderStyle.SINGLE, size: BORDER_SIZE, color: gridColor };
384
+ return {
385
+ grid: true,
386
+ gridColor,
387
+ borders: {
388
+ top: side,
389
+ bottom: side,
390
+ left: side,
391
+ right: side,
392
+ insideHorizontal: side,
393
+ insideVertical: side,
394
+ },
395
+ };
396
+ }
397
+ const toSide = (b) => b
398
+ ? {
399
+ style: BorderStyle.SINGLE,
400
+ size: Math.max(2, Math.round(b.widthPx * 6)),
401
+ color: b.color ?? css.borderColor ?? "000000",
402
+ }
403
+ : undefined;
404
+ const frame = {
405
+ top: toSide(css.borderTop ?? css.border),
406
+ right: toSide(css.borderRight ?? css.border),
407
+ bottom: toSide(css.borderBottom ?? css.border),
408
+ left: toSide(css.borderLeft ?? css.border),
409
+ };
410
+ const spacingPx = separateBorderSpacingPx(table, styleResolver);
411
+ const hasVisibleFrame = Boolean(frame.top || frame.right || frame.bottom || frame.left);
412
+ const gap = spacingPx > 0 && (hasAttrGrid(table) || hasVisibleFrame)
413
+ ? {
414
+ style: BorderStyle.SINGLE,
415
+ size: Math.max(2, Math.round(spacingPx * 6)),
416
+ color: "FFFFFF",
417
+ }
418
+ : NO_BORDER;
419
+ return {
420
+ grid: false,
421
+ gridColor,
422
+ borders: {
423
+ top: frame.top ?? gap,
424
+ right: frame.right ?? gap,
425
+ bottom: frame.bottom ?? gap,
426
+ left: frame.left ?? gap,
427
+ insideHorizontal: gap,
428
+ insideVertical: gap,
429
+ },
430
+ };
431
+ }
432
+ /** EXACT line twips for a font size (half-points): px × 1.4 line-height × 15. */
433
+ function exactLineForFontSize(fontSizeHalfPoints) {
434
+ if (!fontSizeHalfPoints)
435
+ return BODY_LINE_EXACT_TWIPS;
436
+ return Math.round(fontSizeHalfPoints * 14);
437
+ }
438
+ /** EXACT line boxes clip inline images — image-bearing paragraphs need AUTO. */
439
+ function nodesContainImage(nodes) {
440
+ for (const node of nodes) {
441
+ if (node.type !== "tag")
442
+ continue;
443
+ const el = node;
444
+ if (el.name.toLowerCase() === "img")
445
+ return true;
446
+ if (nodesContainImage(el.children ?? []))
447
+ return true;
448
+ }
449
+ return false;
450
+ }
451
+ function cellParagraph(cell, styleResolver, nodes) {
452
+ const css = resolveCellCss(cell, styleResolver);
453
+ const typography = cellTypography(cell, styleResolver);
454
+ const content = nodes ?? cell.element.children ?? [];
455
+ const children = content.length
456
+ ? collectInlineRunsFromNodes(content, typography, undefined, styleResolver)
457
+ : [new TextRun("")];
458
+ const hasImage = nodesContainImage(content);
459
+ return new Paragraph({
460
+ ...(css.textAlign ? { alignment: mapTextAlign(css.textAlign) } : {}),
461
+ spacing: hasImage
462
+ ? { before: 0, after: 0 }
463
+ : {
464
+ before: 0,
465
+ after: 0,
466
+ line: exactLineForFontSize(typography.fontSize),
467
+ lineRule: LineRuleType.EXACT,
468
+ },
469
+ children,
470
+ });
471
+ }
472
+ /**
473
+ * Block child of a cell (`<p>`, `<h1>`–`<h6>`) → its own paragraph with the
474
+ * block's typography, margins, and a line box sized to ITS font (a 28px heading
475
+ * squeezed into the body EXACT line overlaps the paragraph above).
476
+ */
477
+ function cellBlockParagraph(cell, styleResolver, element) {
478
+ const cellCss = resolveCellCss(cell, styleResolver);
479
+ const blockCss = styleResolver.getCss(element);
480
+ const tag = element.name.toLowerCase();
481
+ const isHeading = /^h[1-6]$/.test(tag);
482
+ const fontSize = blockCss.fontSize ??
483
+ (isHeading ? HEADING_FONT_HALF_POINTS[tag] : undefined) ??
484
+ cellCss.fontSize;
485
+ const typography = {
486
+ ...cellTypography(cell, styleResolver),
487
+ ...cssToBlockTypography(blockCss),
488
+ ...(isHeading ? { bold: true } : {}),
489
+ fontSize,
490
+ };
491
+ const align = blockCss.textAlign ?? cellCss.textAlign;
492
+ const hasImage = nodesContainImage(element.children ?? []);
493
+ return new Paragraph({
494
+ ...(align ? { alignment: mapTextAlign(align) } : {}),
495
+ spacing: hasImage
496
+ ? { before: blockCss.marginTop ?? 0, after: blockCss.marginBottom ?? 0 }
497
+ : {
498
+ before: blockCss.marginTop ?? 0,
499
+ after: blockCss.marginBottom ?? 0,
500
+ line: exactLineForFontSize(fontSize),
501
+ lineRule: LineRuleType.EXACT,
502
+ },
503
+ children: collectInlineRunsFromNodes(element.children ?? [], typography, undefined, styleResolver),
504
+ });
505
+ }
506
+ const DEFAULT_BAR_HEIGHT_TWIPS = pxToTwips(12);
507
+ /**
508
+ * Empty block with a background fill and an explicit height/width — a CSS "trend bar"
509
+ * (e.g. `<div style="background:#457b9d;height:14px;width:76%">`). These carry no text,
510
+ * so the inline-run path drops them; render as a shaded band instead.
511
+ */
512
+ function colorBarCss(node, styleResolver) {
513
+ if (node.type !== "tag")
514
+ return null;
515
+ const element = node;
516
+ if (elementPlainText(element).length > 0)
517
+ return null;
518
+ if (element.children?.some((child) => child.type === "tag"))
519
+ return null;
520
+ const css = styleResolver.getCss(element);
521
+ if (!css.backgroundColor)
522
+ return null;
523
+ if (css.heightTwips === undefined &&
524
+ css.widthPercent === undefined &&
525
+ css.widthTwips === undefined) {
526
+ return null;
527
+ }
528
+ return css;
529
+ }
530
+ /**
531
+ * Shaded band sized like the source bar: EXACT line height from CSS `height`, and the
532
+ * paragraph's right indent trims the shading to `width` (percent of the cell's content
533
+ * width, capped by `max-width`). Same pattern as the SVG bar renderer — native
534
+ * paragraph shading, no nested layout tables.
535
+ */
536
+ function barParagraph(css, contentWidthTwips) {
537
+ const height = css.heightTwips ?? DEFAULT_BAR_HEIGHT_TWIPS;
538
+ let barWidth = css.widthPercent !== undefined
539
+ ? Math.round((css.widthPercent / 100) * contentWidthTwips)
540
+ : (css.widthTwips ?? contentWidthTwips);
541
+ if (css.maxWidthTwips !== undefined)
542
+ barWidth = Math.min(barWidth, css.maxWidthTwips);
543
+ barWidth = Math.max(0, Math.min(barWidth, contentWidthTwips));
544
+ return new Paragraph({
545
+ shading: { type: ShadingType.CLEAR, fill: css.backgroundColor, color: "auto" },
546
+ indent: { right: contentWidthTwips - barWidth },
547
+ spacing: { before: 0, after: 0, line: height, lineRule: LineRuleType.EXACT },
548
+ children: [new TextRun("")],
549
+ });
550
+ }
551
+ function nodeHasInlineContent(node) {
552
+ if (node.type === "text")
553
+ return (node.data ?? "").trim().length > 0;
554
+ return node.type === "tag";
555
+ }
556
+ /** Cell content: inline runs, shaded bars, nested tables, and explicit `<p>` blocks. */
557
+ function cellBlocks($, cell, columnIndex, columnWidths, styleResolver, cellPadding) {
558
+ const nodes = cell.element.children ?? [];
559
+ const hasBars = nodes.some((node) => colorBarCss(node, styleResolver) !== null);
560
+ const hasNestedTable = nodes.some((node) => node.type === "tag" && node.name.toLowerCase() === "table");
561
+ const hasBlockChildren = nodes.some((node) => node.type === "tag" && /^(?:p|h[1-6]|ul|ol)$/.test(node.name.toLowerCase()));
562
+ if (!hasBars && !hasNestedTable && !hasBlockChildren) {
563
+ return [cellParagraph(cell, styleResolver)];
564
+ }
565
+ const cellWidth = sumColumnWidths(columnWidths, columnIndex, cell.colspan);
566
+ const contentWidth = Math.max(0, cellWidth - (cellPadding ?? 0) * 2);
567
+ const blocks = [];
568
+ let pending = [];
569
+ const flushInline = () => {
570
+ if (pending.some(nodeHasInlineContent)) {
571
+ blocks.push(cellParagraph(cell, styleResolver, pending));
572
+ }
573
+ pending = [];
574
+ };
575
+ for (const node of nodes) {
576
+ if (node.type === "tag") {
577
+ const tag = node.name.toLowerCase();
578
+ if (tag === "table") {
579
+ flushInline();
580
+ blocks.push(convertTable($, node, styleResolver, contentWidth, true));
581
+ continue;
582
+ }
583
+ if (/^(?:p|h[1-6])$/.test(tag)) {
584
+ flushInline();
585
+ blocks.push(cellBlockParagraph(cell, styleResolver, node));
586
+ continue;
587
+ }
588
+ if (tag === "ul" || tag === "ol") {
589
+ flushInline();
590
+ blocks.push(...processList($, node, { ...DEFAULT_VISITOR_CONTEXT, styleResolver }));
591
+ continue;
592
+ }
593
+ }
594
+ const barCss = colorBarCss(node, styleResolver);
595
+ if (barCss) {
596
+ flushInline();
597
+ blocks.push(barParagraph(barCss, contentWidth));
598
+ }
599
+ else {
600
+ pending.push(node);
601
+ }
602
+ }
603
+ flushInline();
604
+ return blocks.length > 0 ? blocks : [cellParagraph(cell, styleResolver)];
605
+ }
606
+ /** Explicit style borders on the cell itself (e.g. `border-right:1px solid #e2e8f0`). */
607
+ function cellStyleBorders(cell, styleResolver) {
608
+ // Computed styles report UA-default cell borders (`border="1"` tables) as
609
+ // concrete near-black values on EVERY cell — the table-level grid already
610
+ // paints those edges. Only honor borders the author declared on the cell.
611
+ if (styleResolver.source === "computed") {
612
+ const inline = cell.element.attribs?.style ?? "";
613
+ if (!/\bborder(?:-top|-right|-bottom|-left)?\s*:/i.test(inline))
614
+ return undefined;
615
+ }
616
+ const css = styleResolver.getCss(cell.element);
617
+ const toSide = (b) => b
618
+ ? {
619
+ style: BorderStyle.SINGLE,
620
+ size: Math.max(2, Math.round(b.widthPx * 6)),
621
+ color: b.color ?? css.borderColor ?? "000000",
622
+ }
623
+ : undefined;
624
+ const sides = {
625
+ top: toSide(css.borderTop ?? css.border),
626
+ right: toSide(css.borderRight ?? css.border),
627
+ bottom: toSide(css.borderBottom ?? css.border),
628
+ left: toSide(css.borderLeft ?? css.border),
629
+ };
630
+ const declared = Object.fromEntries(Object.entries(sides).filter(([, side]) => side !== undefined));
631
+ return Object.keys(declared).length > 0 ? declared : undefined;
632
+ }
633
+ function buildTableCell($, cell, columnIndex, columnWidths, styleResolver, cellPadding) {
634
+ const span = cell.colspan;
635
+ const borders = cellStyleBorders(cell, styleResolver);
636
+ return new TableCell({
637
+ columnSpan: span > 1 ? span : undefined,
638
+ rowSpan: cell.rowspan > 1 ? cell.rowspan : undefined,
639
+ width: {
640
+ size: sumColumnWidths(columnWidths, columnIndex, span),
641
+ type: WidthType.DXA,
642
+ },
643
+ margins: cellPadding
644
+ ? { top: cellPadding, bottom: cellPadding, left: cellPadding, right: cellPadding }
645
+ : undefined,
646
+ shading: cellShading(cell, styleResolver),
647
+ ...(borders ? { borders } : {}),
648
+ children: cellBlocks($, cell, columnIndex, columnWidths, styleResolver, cellPadding),
649
+ });
650
+ }
651
+ function buildPaddingCell(columnIndex, columnWidths, cellPadding, edges) {
652
+ // Browsers leave missing-cell regions as borderless "holes" while still
653
+ // painting the table's outer frame. Suppress the grid inside the hole and
654
+ // keep only the frame edges this cell sits on.
655
+ const none = { style: BorderStyle.NONE, size: 0, color: "auto" };
656
+ const frame = { style: BorderStyle.SINGLE, size: BORDER_SIZE, color: edges.gridColor };
657
+ return new TableCell({
658
+ width: { size: columnWidths[columnIndex], type: WidthType.DXA },
659
+ margins: cellPadding
660
+ ? { top: cellPadding, bottom: cellPadding, left: cellPadding, right: cellPadding }
661
+ : undefined,
662
+ ...(edges.bordered
663
+ ? {
664
+ borders: {
665
+ top: edges.isFirstRow ? frame : none,
666
+ bottom: edges.isLastRow ? frame : none,
667
+ left: none,
668
+ right: edges.isLastColumn ? frame : none,
669
+ },
670
+ }
671
+ : {}),
672
+ children: [new Paragraph({ children: [new TextRun("")] })],
673
+ });
674
+ }
675
+ /** Pass 2 — emit docx rows aligned to the grid matrix with explicit spans and widths. */
676
+ function buildTableRows($, analysis, styleResolver, cellPadding, bordered, gridColor) {
677
+ const { maxColumns, placedRows, columnWidths } = analysis;
678
+ // Columns covered by a rowspan from an earlier row: the docx library emits the
679
+ // vertical-merge continuation cells itself, so those columns must stay empty.
680
+ const occupied = placedRows.map(() => new Set());
681
+ placedRows.forEach((row, rowIndex) => {
682
+ for (const { cell, columnIndex } of row) {
683
+ const lastRow = Math.min(rowIndex + cell.rowspan, placedRows.length);
684
+ for (let r = rowIndex + 1; r < lastRow; r++) {
685
+ for (let i = 0; i < cell.colspan; i++)
686
+ occupied[r].add(columnIndex + i);
687
+ }
688
+ }
689
+ });
690
+ return placedRows.map((row, rowIndex) => {
691
+ const byColumn = new Map(row.map((p) => [p.columnIndex, p.cell]));
692
+ const docxCells = [];
693
+ let columnIndex = 0;
694
+ while (columnIndex < maxColumns) {
695
+ const cell = byColumn.get(columnIndex);
696
+ if (cell) {
697
+ docxCells.push(buildTableCell($, cell, columnIndex, columnWidths, styleResolver, cellPadding));
698
+ columnIndex += cell.colspan;
699
+ continue;
700
+ }
701
+ if (occupied[rowIndex].has(columnIndex)) {
702
+ columnIndex += 1;
703
+ continue;
704
+ }
705
+ docxCells.push(buildPaddingCell(columnIndex, columnWidths, cellPadding, {
706
+ bordered,
707
+ gridColor,
708
+ isFirstRow: rowIndex === 0,
709
+ isLastRow: rowIndex === placedRows.length - 1,
710
+ isLastColumn: columnIndex === maxColumns - 1,
711
+ }));
712
+ columnIndex += 1;
713
+ }
714
+ return new TableRow({ children: docxCells });
715
+ });
716
+ }
717
+ export function convertTable($, table, styleResolver = INLINE_STYLE_RESOLVER, contentWidthTwips = CONTENT_WIDTH_TWIPS, fillParent = false) {
718
+ const trElements = collectRowElements($, table);
719
+ const parsedRows = parseRows($, trElements, table, styleResolver);
720
+ const cellPadding = parseCellPadding(table);
721
+ const declared = tableDeclaredWidth(table, styleResolver);
722
+ const declaredTwips = declared.percent !== undefined
723
+ ? Math.round((declared.percent / 100) * contentWidthTwips)
724
+ : declared.twips;
725
+ const analysis = analyzeGrid(parsedRows, styleResolver, cellPadding, contentWidthTwips, declaredTwips, fillParent);
726
+ const plan = tableBorderPlan(table, styleResolver);
727
+ const alignment = tableAlignment(table);
728
+ return new Table({
729
+ width: declared.percent !== undefined
730
+ ? { size: declared.percent, type: WidthType.PERCENTAGE }
731
+ : { size: analysis.totalWidth, type: WidthType.DXA },
732
+ // Positioning only matters when the table is narrower than its container.
733
+ ...(alignment && analysis.totalWidth < contentWidthTwips ? { alignment } : {}),
734
+ columnWidths: analysis.columnWidths,
735
+ layout: "fixed",
736
+ // `borders: undefined` means docx-library defaults (a black grid) — always
737
+ // pass an explicit plan (grid, frame-only, or all-NONE).
738
+ borders: plan.borders,
739
+ rows: buildTableRows($, analysis, styleResolver, cellPadding, plan.grid, plan.gridColor),
740
+ });
741
+ }
742
+ export function convertTableBlock($, table, styleResolver = INLINE_STYLE_RESOLVER) {
743
+ return convertTable($, table, styleResolver);
744
+ }
745
+ //# sourceMappingURL=table.js.map