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.
- package/API.md +533 -0
- package/LICENSE +21 -0
- package/README.md +236 -0
- package/dist/browser.d.ts +34 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +35 -0
- package/dist/browser.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +118 -0
- package/dist/cli.js.map +1 -0
- package/dist/converter/bordered-block.d.ts +54 -0
- package/dist/converter/bordered-block.d.ts.map +1 -0
- package/dist/converter/bordered-block.js +124 -0
- package/dist/converter/bordered-block.js.map +1 -0
- package/dist/converter/build-docx.d.ts +46 -0
- package/dist/converter/build-docx.d.ts.map +1 -0
- package/dist/converter/build-docx.js +161 -0
- package/dist/converter/build-docx.js.map +1 -0
- package/dist/converter/computed-style-snapshot.browser.js +73 -0
- package/dist/converter/computed-style-snapshot.d.ts +10 -0
- package/dist/converter/computed-style-snapshot.d.ts.map +1 -0
- package/dist/converter/computed-style-snapshot.js +78 -0
- package/dist/converter/computed-style-snapshot.js.map +1 -0
- package/dist/converter/constants.d.ts +51 -0
- package/dist/converter/constants.d.ts.map +1 -0
- package/dist/converter/constants.js +163 -0
- package/dist/converter/constants.js.map +1 -0
- package/dist/converter/css.d.ts +112 -0
- package/dist/converter/css.d.ts.map +1 -0
- package/dist/converter/css.js +621 -0
- package/dist/converter/css.js.map +1 -0
- package/dist/converter/flex.d.ts +59 -0
- package/dist/converter/flex.d.ts.map +1 -0
- package/dist/converter/flex.js +252 -0
- package/dist/converter/flex.js.map +1 -0
- package/dist/converter/image.d.ts +38 -0
- package/dist/converter/image.d.ts.map +1 -0
- package/dist/converter/image.js +159 -0
- package/dist/converter/image.js.map +1 -0
- package/dist/converter/inline.d.ts +18 -0
- package/dist/converter/inline.d.ts.map +1 -0
- package/dist/converter/inline.js +213 -0
- package/dist/converter/inline.js.map +1 -0
- package/dist/converter/ooxml-patch.d.ts +23 -0
- package/dist/converter/ooxml-patch.d.ts.map +1 -0
- package/dist/converter/ooxml-patch.js +54 -0
- package/dist/converter/ooxml-patch.js.map +1 -0
- package/dist/converter/style-path.d.ts +4 -0
- package/dist/converter/style-path.d.ts.map +1 -0
- package/dist/converter/style-path.js +17 -0
- package/dist/converter/style-path.js.map +1 -0
- package/dist/converter/style-resolver-node.d.ts +7 -0
- package/dist/converter/style-resolver-node.d.ts.map +1 -0
- package/dist/converter/style-resolver-node.js +26 -0
- package/dist/converter/style-resolver-node.js.map +1 -0
- package/dist/converter/style-resolver.d.ts +24 -0
- package/dist/converter/style-resolver.d.ts.map +1 -0
- package/dist/converter/style-resolver.js +122 -0
- package/dist/converter/style-resolver.js.map +1 -0
- package/dist/converter/svg.d.ts +11 -0
- package/dist/converter/svg.d.ts.map +1 -0
- package/dist/converter/svg.js +116 -0
- package/dist/converter/svg.js.map +1 -0
- package/dist/converter/table.d.ts +8 -0
- package/dist/converter/table.d.ts.map +1 -0
- package/dist/converter/table.js +745 -0
- package/dist/converter/table.js.map +1 -0
- package/dist/converter/text-metrics.d.ts +17 -0
- package/dist/converter/text-metrics.d.ts.map +1 -0
- package/dist/converter/text-metrics.js +51 -0
- package/dist/converter/text-metrics.js.map +1 -0
- package/dist/converter/types.d.ts +82 -0
- package/dist/converter/types.d.ts.map +1 -0
- package/dist/converter/types.js +9 -0
- package/dist/converter/types.js.map +1 -0
- package/dist/converter/visitor.d.ts +11 -0
- package/dist/converter/visitor.d.ts.map +1 -0
- package/dist/converter/visitor.js +910 -0
- package/dist/converter/visitor.js.map +1 -0
- package/dist/converter.d.ts +28 -0
- package/dist/converter.d.ts.map +1 -0
- package/dist/converter.js +44 -0
- package/dist/converter.js.map +1 -0
- package/dist/html-wrap.d.ts +3 -0
- package/dist/html-wrap.d.ts.map +1 -0
- package/dist/html-wrap.js +26 -0
- package/dist/html-wrap.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/examples/README.md +39 -0
- package/examples/balance-sheet/compare_side_by_side.png +0 -0
- package/examples/balance-sheet/input.html +41 -0
- package/examples/balance-sheet/output.docx +0 -0
- package/examples/balance-sheet/preview.png +0 -0
- package/examples/invoice/compare_side_by_side.png +0 -0
- package/examples/invoice/input.html +88 -0
- package/examples/invoice/logo.png +0 -0
- package/examples/invoice/output.docx +0 -0
- package/examples/invoice/preview.png +0 -0
- package/examples/javascript-essay/compare_side_by_side.png +0 -0
- package/examples/javascript-essay/input.html +39 -0
- package/examples/javascript-essay/output.docx +0 -0
- package/examples/javascript-essay/preview.png +0 -0
- package/examples/product-launch-brief/compare_side_by_side.png +0 -0
- package/examples/product-launch-brief/input.html +120 -0
- package/examples/product-launch-brief/output.docx +0 -0
- package/examples/product-launch-brief/preview.png +0 -0
- package/examples/quarterly-financials/compare_side_by_side.png +0 -0
- package/examples/quarterly-financials/input.html +27 -0
- package/examples/quarterly-financials/output.docx +0 -0
- package/examples/quarterly-financials/preview.png +0 -0
- package/examples/react-dashboard/compare_side_by_side.png +0 -0
- package/examples/react-dashboard/input.html +1 -0
- package/examples/react-dashboard/output.docx +0 -0
- package/examples/react-dashboard/preview.html +107 -0
- package/examples/react-dashboard/preview.png +0 -0
- package/examples/regional-sales-dashboard/compare_side_by_side.png +0 -0
- package/examples/regional-sales-dashboard/input.html +129 -0
- package/examples/regional-sales-dashboard/output.docx +0 -0
- package/examples/regional-sales-dashboard/preview.png +0 -0
- package/examples/sales-contract/compare_side_by_side.png +0 -0
- package/examples/sales-contract/input.html +68 -0
- package/examples/sales-contract/output.docx +0 -0
- package/examples/sales-contract/preview.png +0 -0
- package/examples/sprint-retrospective/compare_side_by_side.png +0 -0
- package/examples/sprint-retrospective/input.html +51 -0
- package/examples/sprint-retrospective/output.docx +0 -0
- package/examples/sprint-retrospective/preview.png +0 -0
- 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
|