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,910 @@
|
|
|
1
|
+
import { AlignmentType, BorderStyle, LineRuleType, Paragraph, TabStopType, TextRun, } from "docx";
|
|
2
|
+
import { BODY_FONT_HALF_POINTS, BODY_LINE_EXACT_TWIPS, BODY_LINE_HEIGHT, BLOCKQUOTE_MARGIN_PX, BLOCKQUOTE_INDENT_PX, BLOCKQUOTE_UA_SIDE_MARGIN_PX, LIST_HANGING_TWIPS, LIST_LEVEL_LEFT_TWIPS, DEFAULT_PARAGRAPH_MARGIN_PX, HEADING_FONT_HALF_POINTS, HEADING_LEVELS, HEADING_MARGIN_EM, LIST_STYLE_REFERENCES, } from "./constants.js";
|
|
3
|
+
import { blockLayoutToParagraphProps, cssToBlockLayout, isBlockElement, isHiddenElement, layoutForNativeShadedBlock, layoutFromElement, pxPaddingToBorderSpace, pxToHalfPoints, pxToTwips, shadedBlockParagraphSpacing, typographyFromBlockElement, } from "./css.js";
|
|
4
|
+
import { makeBorderedTableBlock, makeShadedBlockTable, makeShadedContainerTable, needsShadedTableWrapper, shouldUseBorderedTable } from "./bordered-block.js";
|
|
5
|
+
import { flexItemElements, estimateFlexItemWidthTwips, isFlexContainer, makeFlexColumnTable, makeFlexRowTable, parseFlexLayoutFromCss, } from "./flex.js";
|
|
6
|
+
import { collectInlineRunsFromNodes, getDirectBlockChildren, getInlineOrTextNodes, hasDirectBlockChild, } from "./inline.js";
|
|
7
|
+
import { convertTable } from "./table.js";
|
|
8
|
+
import { convertSvg } from "./svg.js";
|
|
9
|
+
import { DEFAULT_VISITOR_CONTEXT } from "./types.js";
|
|
10
|
+
import { INLINE_STYLE_RESOLVER } from "./style-resolver.js";
|
|
11
|
+
function isElement(node) {
|
|
12
|
+
return node.type === "tag";
|
|
13
|
+
}
|
|
14
|
+
function mergeBlockLayouts(base, overlay) {
|
|
15
|
+
return {
|
|
16
|
+
alignment: overlay.alignment ?? base.alignment,
|
|
17
|
+
shading: overlay.shading ?? base.shading,
|
|
18
|
+
spacingBefore: overlay.spacingBefore ?? base.spacingBefore,
|
|
19
|
+
spacingAfter: overlay.spacingAfter ?? base.spacingAfter,
|
|
20
|
+
indentLeft: sumIndent(base.indentLeft, overlay.indentLeft),
|
|
21
|
+
indentRight: overlay.indentRight ?? base.indentRight,
|
|
22
|
+
borders: overlay.borders ?? base.borders,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function sumIndent(base, overlay) {
|
|
26
|
+
const total = (base ?? 0) + (overlay ?? 0);
|
|
27
|
+
return total > 0 ? total : undefined;
|
|
28
|
+
}
|
|
29
|
+
function sumTwips(...values) {
|
|
30
|
+
const total = values.reduce((acc, v) => acc + (v ?? 0), 0);
|
|
31
|
+
return total > 0 ? total : undefined;
|
|
32
|
+
}
|
|
33
|
+
function nextElementSibling(element) {
|
|
34
|
+
let next = element.next;
|
|
35
|
+
while (next) {
|
|
36
|
+
if (isElement(next))
|
|
37
|
+
return next;
|
|
38
|
+
next = next.next;
|
|
39
|
+
}
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
function prevElementSibling(element) {
|
|
43
|
+
let prev = element.prev;
|
|
44
|
+
while (prev) {
|
|
45
|
+
if (isElement(prev))
|
|
46
|
+
return prev;
|
|
47
|
+
prev = prev.prev;
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
/** Block containers never inherit shading — each block gets its own paragraph shading. */
|
|
52
|
+
function applyDefaultParagraphMargins(element, layout, ctx) {
|
|
53
|
+
if (element.name.toLowerCase() !== "p")
|
|
54
|
+
return layout;
|
|
55
|
+
// `<p>` inside blockquote — vertical margin lives on the blockquote container.
|
|
56
|
+
if (ctx.blockquoteDepth > 0)
|
|
57
|
+
return layout;
|
|
58
|
+
if (ctx.styleResolver.source === "computed")
|
|
59
|
+
return layout;
|
|
60
|
+
const css = ctx.styleResolver.getCss(element);
|
|
61
|
+
let defaultMargin = pxToTwips(DEFAULT_PARAGRAPH_MARGIN_PX);
|
|
62
|
+
if (css.fontSize) {
|
|
63
|
+
defaultMargin = pxToTwips(css.fontSize / 1.5);
|
|
64
|
+
}
|
|
65
|
+
const result = { ...layout };
|
|
66
|
+
if (css.marginBottom === undefined) {
|
|
67
|
+
result.marginBottom = (result.marginBottom ?? 0) + defaultMargin;
|
|
68
|
+
}
|
|
69
|
+
if (css.marginTop === undefined) {
|
|
70
|
+
// Collapse with previous `<p>` margin-bottom (HTML max-margin behavior).
|
|
71
|
+
const prev = prevElementSibling(element);
|
|
72
|
+
if (!(prev && isParagraphElement(prev))) {
|
|
73
|
+
result.marginTop = (result.marginTop ?? 0) + defaultMargin;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
...result,
|
|
78
|
+
spacingBefore: sumTwips(result.paddingTop, result.marginTop),
|
|
79
|
+
spacingAfter: sumTwips(result.paddingBottom, result.marginBottom),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function collapseMarginTopAfterBlock(element, layout, ctx) {
|
|
83
|
+
if (ctx.styleResolver.source === "computed")
|
|
84
|
+
return layout;
|
|
85
|
+
if (!layout.marginTop)
|
|
86
|
+
return layout;
|
|
87
|
+
const tag = element.name.toLowerCase();
|
|
88
|
+
if (tag !== "ul" && tag !== "ol" && tag !== "div" && tag !== "section" && tag !== "blockquote") {
|
|
89
|
+
return layout;
|
|
90
|
+
}
|
|
91
|
+
const prev = prevElementSibling(element);
|
|
92
|
+
if (!prev || !isElement(prev))
|
|
93
|
+
return layout;
|
|
94
|
+
const prevTag = prev.name.toLowerCase();
|
|
95
|
+
if (prevTag === "p" || /^h[1-6]$/.test(prevTag) || prevTag === "blockquote") {
|
|
96
|
+
return {
|
|
97
|
+
...layout,
|
|
98
|
+
marginTop: 0,
|
|
99
|
+
spacingBefore: layout.paddingTop,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return layout;
|
|
103
|
+
}
|
|
104
|
+
function collapseMarginTopAfterParagraph(element, layout, ctx) {
|
|
105
|
+
if (ctx.styleResolver.source === "computed")
|
|
106
|
+
return layout;
|
|
107
|
+
if (element.name.toLowerCase() !== "p")
|
|
108
|
+
return layout;
|
|
109
|
+
if (!layout.marginTop)
|
|
110
|
+
return layout;
|
|
111
|
+
const prev = prevElementSibling(element);
|
|
112
|
+
if (!prev || !isElement(prev))
|
|
113
|
+
return layout;
|
|
114
|
+
const prevTag = prev.name.toLowerCase();
|
|
115
|
+
if (prevTag === "p" || /^h[1-6]$/.test(prevTag) || prevTag === "blockquote") {
|
|
116
|
+
return {
|
|
117
|
+
...layout,
|
|
118
|
+
marginTop: 0,
|
|
119
|
+
spacingBefore: layout.paddingTop,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return layout;
|
|
123
|
+
}
|
|
124
|
+
function resolveBlockLayout(ctx, element) {
|
|
125
|
+
const local = collapseMarginTopAfterBlock(element, collapseMarginTopAfterParagraph(element, applyDefaultParagraphMargins(element, layoutFromElement(element, ctx.styleResolver), ctx), ctx), ctx);
|
|
126
|
+
const inherited = ctx.inheritedLayout ?? {};
|
|
127
|
+
return {
|
|
128
|
+
...local,
|
|
129
|
+
alignment: local.alignment ?? inherited.alignment,
|
|
130
|
+
indentLeft: sumIndent(inherited.indentLeft, local.indentLeft),
|
|
131
|
+
indentRight: local.indentRight ?? inherited.indentRight,
|
|
132
|
+
borders: local.borders ?? inherited.borders,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function emptyRun(size = BODY_FONT_HALF_POINTS) {
|
|
136
|
+
return new TextRun({ text: "", size });
|
|
137
|
+
}
|
|
138
|
+
/** Pin spacer paragraphs to ~1pt so LO does not add a full default line box before `w:after`. */
|
|
139
|
+
const MARGIN_SPACER_LINE_TWIPS = 20;
|
|
140
|
+
function marginSpacer(afterTwips, carry) {
|
|
141
|
+
if (!afterTwips)
|
|
142
|
+
return undefined;
|
|
143
|
+
// Inside a blockquote the spacer carries the quote's left border + indent so
|
|
144
|
+
// the vertical rule stays continuous across margin gaps (matches the browser).
|
|
145
|
+
const carryProps = carry && (carry.borders?.left || carry.indentLeft)
|
|
146
|
+
? blockLayoutToParagraphProps({
|
|
147
|
+
indentLeft: carry.indentLeft,
|
|
148
|
+
borders: carry.borders?.left ? { left: carry.borders.left } : undefined,
|
|
149
|
+
})
|
|
150
|
+
: {};
|
|
151
|
+
// Borders only paint along the line box, so a carried bar needs the gap's
|
|
152
|
+
// full height in the EXACT line (not in w:after) to span it.
|
|
153
|
+
const hasBar = Boolean(carry?.borders?.left);
|
|
154
|
+
return new Paragraph({
|
|
155
|
+
...carryProps,
|
|
156
|
+
spacing: hasBar
|
|
157
|
+
? {
|
|
158
|
+
after: 0,
|
|
159
|
+
before: 0,
|
|
160
|
+
line: afterTwips,
|
|
161
|
+
lineRule: LineRuleType.EXACT,
|
|
162
|
+
}
|
|
163
|
+
: {
|
|
164
|
+
after: afterTwips,
|
|
165
|
+
before: 0,
|
|
166
|
+
line: MARGIN_SPACER_LINE_TWIPS,
|
|
167
|
+
lineRule: LineRuleType.EXACT,
|
|
168
|
+
},
|
|
169
|
+
children: [emptyRun()],
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
function isParagraphElement(node) {
|
|
173
|
+
return isElement(node) && node.name.toLowerCase() === "p";
|
|
174
|
+
}
|
|
175
|
+
function nodesHaveLineBreaks(nodes) {
|
|
176
|
+
for (const node of nodes) {
|
|
177
|
+
if (!isElement(node))
|
|
178
|
+
continue;
|
|
179
|
+
if (node.name.toLowerCase() === "br")
|
|
180
|
+
return true;
|
|
181
|
+
if (nodesHaveLineBreaks(node.children ?? []))
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
/** HTML margin collapse with adjacent `<p>` — avoid double-counting in Word spacers. */
|
|
187
|
+
function shadedMarginTopSpacer(element, layout) {
|
|
188
|
+
if (!layout.marginTop)
|
|
189
|
+
return undefined;
|
|
190
|
+
const defaultPMargin = pxToTwips(DEFAULT_PARAGRAPH_MARGIN_PX);
|
|
191
|
+
const prev = element ? prevElementSibling(element) : undefined;
|
|
192
|
+
if (prev && isParagraphElement(prev) && layout.marginTop <= defaultPMargin) {
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
return marginSpacer(layout.marginTop);
|
|
196
|
+
}
|
|
197
|
+
function emitBlockContent(runs, layout, extra = {}, element) {
|
|
198
|
+
if (shouldUseBorderedTable(layout)) {
|
|
199
|
+
const blocks = [];
|
|
200
|
+
const topSpacer = marginSpacer(layout.marginTop);
|
|
201
|
+
if (topSpacer)
|
|
202
|
+
blocks.push(topSpacer);
|
|
203
|
+
blocks.push(makeBorderedTableBlock(runs, layout, extra));
|
|
204
|
+
const bottomSpacer = marginSpacer(layout.marginBottom);
|
|
205
|
+
if (bottomSpacer)
|
|
206
|
+
blocks.push(bottomSpacer);
|
|
207
|
+
return blocks;
|
|
208
|
+
}
|
|
209
|
+
if (layout.shading?.fill) {
|
|
210
|
+
const blocks = [];
|
|
211
|
+
const topSpacer = shadedMarginTopSpacer(element, layout);
|
|
212
|
+
if (topSpacer)
|
|
213
|
+
blocks.push(topSpacer);
|
|
214
|
+
blocks.push(needsShadedTableWrapper(layout)
|
|
215
|
+
? makeShadedBlockTable(runs, layoutForNativeShadedBlock(layout))
|
|
216
|
+
: makeParagraph(runs, layoutForNativeShadedBlock(layout), extra));
|
|
217
|
+
return blocks;
|
|
218
|
+
}
|
|
219
|
+
return [makeParagraph(runs, layout, extra)];
|
|
220
|
+
}
|
|
221
|
+
function makeParagraph(children, layout, extra = {}) {
|
|
222
|
+
const isShaded = Boolean(layout.shading?.fill);
|
|
223
|
+
const hasVerticalPad = (layout.paddingTop ?? 0) > 0 || (layout.paddingBottom ?? 0) > 0;
|
|
224
|
+
const padLeft = layout.paddingLeft ?? 0;
|
|
225
|
+
const padRight = layout.paddingRight ?? 0;
|
|
226
|
+
const isListItem = Boolean(extra.numbering);
|
|
227
|
+
const layoutProps = blockLayoutToParagraphProps(isShaded
|
|
228
|
+
? {
|
|
229
|
+
...layout,
|
|
230
|
+
spacingBefore: undefined,
|
|
231
|
+
spacingAfter: undefined,
|
|
232
|
+
indentLeft: undefined,
|
|
233
|
+
indentRight: padRight > 0 ? padRight : undefined,
|
|
234
|
+
}
|
|
235
|
+
: layout);
|
|
236
|
+
let paragraphChildren = children.length > 0 ? children : [emptyRun()];
|
|
237
|
+
const paragraphExtra = { ...extra };
|
|
238
|
+
if (isShaded && padLeft > 0) {
|
|
239
|
+
paragraphExtra.tabStops = [{ type: TabStopType.LEFT, position: padLeft }];
|
|
240
|
+
paragraphChildren = [
|
|
241
|
+
new TextRun({
|
|
242
|
+
text: "\t",
|
|
243
|
+
size: layout.shadedTabHalfPoints ?? BODY_FONT_HALF_POINTS,
|
|
244
|
+
}),
|
|
245
|
+
...paragraphChildren,
|
|
246
|
+
];
|
|
247
|
+
}
|
|
248
|
+
const shadedSpacing = isShaded && hasVerticalPad
|
|
249
|
+
? { spacing: shadedBlockParagraphSpacing(layout) }
|
|
250
|
+
: {};
|
|
251
|
+
const hasLineBreaks = Boolean(extra.hasLineBreaks);
|
|
252
|
+
const isFlexCompact = Boolean(extra.flexCompact);
|
|
253
|
+
const metricSpacing = hasLineBreaks || isFlexCompact
|
|
254
|
+
? {
|
|
255
|
+
spacing: {
|
|
256
|
+
...(layoutProps.spacing ?? {}),
|
|
257
|
+
line: BODY_LINE_HEIGHT,
|
|
258
|
+
lineRule: LineRuleType.EXACT,
|
|
259
|
+
before: layoutProps.spacing?.before ?? 0,
|
|
260
|
+
after: layoutProps.spacing?.after ?? 0,
|
|
261
|
+
},
|
|
262
|
+
}
|
|
263
|
+
: {};
|
|
264
|
+
const listItemSpacing = isListItem
|
|
265
|
+
? {
|
|
266
|
+
spacing: {
|
|
267
|
+
before: 0,
|
|
268
|
+
after: 0,
|
|
269
|
+
line: BODY_LINE_EXACT_TWIPS,
|
|
270
|
+
lineRule: LineRuleType.EXACT,
|
|
271
|
+
},
|
|
272
|
+
contextualSpacing: true,
|
|
273
|
+
}
|
|
274
|
+
: {};
|
|
275
|
+
// EXACT per-font line box for flex-item block children (card lines).
|
|
276
|
+
const exactFontLine = typeof extra.exactLineTwips === "number" ? extra.exactLineTwips : undefined;
|
|
277
|
+
const exactFontSpacing = exactFontLine
|
|
278
|
+
? {
|
|
279
|
+
spacing: {
|
|
280
|
+
...(layoutProps.spacing ?? {}),
|
|
281
|
+
line: exactFontLine,
|
|
282
|
+
lineRule: LineRuleType.EXACT,
|
|
283
|
+
before: layoutProps.spacing?.before ?? 0,
|
|
284
|
+
after: layoutProps.spacing?.after ?? 0,
|
|
285
|
+
},
|
|
286
|
+
}
|
|
287
|
+
: {};
|
|
288
|
+
// CSS line-height 1.4 → AT_LEAST font×1.4 (AUTO multiplies Word's natural
|
|
289
|
+
// ~1.15em line — too tall; EXACT clips taller inline content like images).
|
|
290
|
+
// Merged WITH the margin spacing — a separate spread would be overwritten.
|
|
291
|
+
const atLeastLine = typeof extra.atLeastLineTwips === "number"
|
|
292
|
+
? extra.atLeastLineTwips
|
|
293
|
+
: BODY_LINE_EXACT_TWIPS;
|
|
294
|
+
const defaultLineSpacing = {
|
|
295
|
+
spacing: {
|
|
296
|
+
...(layoutProps.spacing ?? {}),
|
|
297
|
+
line: atLeastLine,
|
|
298
|
+
lineRule: LineRuleType.AT_LEAST,
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
return new Paragraph({
|
|
302
|
+
...layoutProps,
|
|
303
|
+
...(isShaded && hasVerticalPad
|
|
304
|
+
? {}
|
|
305
|
+
: hasLineBreaks || isFlexCompact || isListItem || exactFontLine
|
|
306
|
+
? {}
|
|
307
|
+
: defaultLineSpacing),
|
|
308
|
+
...(isShaded && hasVerticalPad
|
|
309
|
+
? shadedSpacing
|
|
310
|
+
: isListItem
|
|
311
|
+
? listItemSpacing
|
|
312
|
+
: exactFontLine
|
|
313
|
+
? exactFontSpacing
|
|
314
|
+
: metricSpacing),
|
|
315
|
+
...(isShaded ? { contextualSpacing: false } : {}),
|
|
316
|
+
...paragraphExtra,
|
|
317
|
+
children: paragraphChildren,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
function blockquoteLayout(depth, element, styleResolver, parentIndentTwips = 0) {
|
|
321
|
+
const css = styleResolver.getCss(element);
|
|
322
|
+
let local = cssToBlockLayout(css);
|
|
323
|
+
const hasExplicitLeftBorder = Boolean(css.borderLeft ?? css.border);
|
|
324
|
+
let layout;
|
|
325
|
+
if (styleResolver.source === "computed") {
|
|
326
|
+
const inlineStyle = element.attribs?.style ?? "";
|
|
327
|
+
const hasInlineMargin = /\bmargin(?:\s|:|-)/i.test(inlineStyle);
|
|
328
|
+
if (!hasInlineMargin) {
|
|
329
|
+
local = {
|
|
330
|
+
...local,
|
|
331
|
+
marginTop: undefined,
|
|
332
|
+
marginBottom: undefined,
|
|
333
|
+
marginLeft: undefined,
|
|
334
|
+
marginRight: undefined,
|
|
335
|
+
spacingBefore: local.paddingTop,
|
|
336
|
+
spacingAfter: local.paddingBottom,
|
|
337
|
+
indentLeft: local.paddingLeft,
|
|
338
|
+
indentRight: local.paddingRight,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
local = {
|
|
343
|
+
...local,
|
|
344
|
+
marginLeft: undefined,
|
|
345
|
+
marginRight: undefined,
|
|
346
|
+
indentLeft: local.paddingLeft,
|
|
347
|
+
indentRight: local.paddingRight,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
layout = {
|
|
351
|
+
...local,
|
|
352
|
+
indentLeft: sumIndent(local.indentLeft, pxToTwips(BLOCKQUOTE_INDENT_PX * depth)),
|
|
353
|
+
marginTop: sumTwips(local.marginTop, pxToTwips(BLOCKQUOTE_MARGIN_PX)),
|
|
354
|
+
marginBottom: sumTwips(local.marginBottom, pxToTwips(BLOCKQUOTE_MARGIN_PX)),
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
// UA box model: margin 1em 40px unless the inline style says otherwise.
|
|
359
|
+
// Content indent accumulates parent quote indent + margin + border + padding.
|
|
360
|
+
const borderPx = (css.borderLeft ?? css.border)?.widthPx ?? 0;
|
|
361
|
+
const ownOffset = (css.marginLeft ?? pxToTwips(BLOCKQUOTE_UA_SIDE_MARGIN_PX)) +
|
|
362
|
+
pxToTwips(borderPx) +
|
|
363
|
+
(css.paddingLeft ?? 0);
|
|
364
|
+
layout = {
|
|
365
|
+
...local,
|
|
366
|
+
marginLeft: undefined,
|
|
367
|
+
marginRight: undefined,
|
|
368
|
+
marginTop: css.marginTop ?? pxToTwips(BLOCKQUOTE_MARGIN_PX),
|
|
369
|
+
marginBottom: css.marginBottom ?? pxToTwips(BLOCKQUOTE_MARGIN_PX),
|
|
370
|
+
spacingBefore: local.paddingTop,
|
|
371
|
+
spacingAfter: local.paddingBottom,
|
|
372
|
+
indentLeft: sumIndent(parentIndentTwips, ownOffset),
|
|
373
|
+
indentRight: local.paddingRight,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
if (hasExplicitLeftBorder) {
|
|
377
|
+
const border = local.borders?.left ?? {
|
|
378
|
+
color: depth > 1 ? "666666" : "333333",
|
|
379
|
+
size: 24,
|
|
380
|
+
space: pxPaddingToBorderSpace(css.paddingLeft),
|
|
381
|
+
};
|
|
382
|
+
layout.borders = { ...local.borders, left: border };
|
|
383
|
+
}
|
|
384
|
+
return layout;
|
|
385
|
+
}
|
|
386
|
+
/** Flush a fresh paragraph on block boundaries; inline backgrounds stay on TextRuns. */
|
|
387
|
+
function emitFlowBlocks($, nodes, blockLayout, inheritedTypography, ctx, extra = {}, containerElement) {
|
|
388
|
+
const blocks = [];
|
|
389
|
+
let pendingInline = [];
|
|
390
|
+
const flush = () => {
|
|
391
|
+
if (pendingInline.length === 0 && !blockLayout.shading)
|
|
392
|
+
return;
|
|
393
|
+
const hasLineBreaks = nodesHaveLineBreaks(pendingInline);
|
|
394
|
+
// In a flex-item's block children, size each line box to its own font (CSS
|
|
395
|
+
// line-height 1.4), not AUTO — halfPt/2 → pt × 1.4 × 20 twips = halfPt × 14.
|
|
396
|
+
const exactLineTwips = ctx.flexBlockContent
|
|
397
|
+
? (inheritedTypography.fontSize ?? ctx.defaultSizeHalfPoints) * 14
|
|
398
|
+
: undefined;
|
|
399
|
+
blocks.push(...emitBlockContent(collectInlineRunsFromNodes(pendingInline, inheritedTypography, undefined, ctx.styleResolver, ctx.defaultSizeHalfPoints), blockLayout, {
|
|
400
|
+
...extra,
|
|
401
|
+
hasLineBreaks,
|
|
402
|
+
exactLineTwips,
|
|
403
|
+
...(inheritedTypography.fontSize
|
|
404
|
+
? { atLeastLineTwips: inheritedTypography.fontSize * 14 }
|
|
405
|
+
: {}),
|
|
406
|
+
}, containerElement));
|
|
407
|
+
pendingInline = [];
|
|
408
|
+
};
|
|
409
|
+
for (const node of nodes) {
|
|
410
|
+
if (isElement(node) && isBlockElement(node, ctx.styleResolver)) {
|
|
411
|
+
flush();
|
|
412
|
+
blocks.push(...visitElement($, node, { ...ctx, inheritedLayout: undefined }));
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
pendingInline.push(node);
|
|
416
|
+
}
|
|
417
|
+
flush();
|
|
418
|
+
return blocks.length > 0 ? blocks : emitBlockContent([], blockLayout, extra, containerElement);
|
|
419
|
+
}
|
|
420
|
+
/** Tight content height (twips) of a card-style flex item: one EXACT line box per
|
|
421
|
+
* block child (fontSize × 14 = size × 1.4) plus the item's top/bottom padding. */
|
|
422
|
+
function estimateFlexItemContentHeight(item, itemLayout, ctx) {
|
|
423
|
+
let lines = 0;
|
|
424
|
+
for (const child of getDirectBlockChildren(item, ctx.styleResolver)) {
|
|
425
|
+
const fontSize = typographyFromBlockElement(child, ctx.styleResolver).fontSize ?? ctx.defaultSizeHalfPoints;
|
|
426
|
+
lines += fontSize * 14;
|
|
427
|
+
}
|
|
428
|
+
return lines + (itemLayout.paddingTop ?? 0) + (itemLayout.paddingBottom ?? 0);
|
|
429
|
+
}
|
|
430
|
+
function processFlexContainer($, element, ctx) {
|
|
431
|
+
const flex = parseFlexLayoutFromCss(ctx.styleResolver.getCss(element));
|
|
432
|
+
if (!flex)
|
|
433
|
+
return [];
|
|
434
|
+
const containerLayout = resolveBlockLayout(ctx, element);
|
|
435
|
+
const childCtx = { ...ctx, inheritedLayout: undefined };
|
|
436
|
+
const items = flexItemElements(element).map((item) => {
|
|
437
|
+
const itemCss = ctx.styleResolver.getCss(item);
|
|
438
|
+
const itemLayout = cssToBlockLayout(itemCss);
|
|
439
|
+
const typography = typographyFromBlockElement(item, ctx.styleResolver);
|
|
440
|
+
const intrinsicWidthTwips = flex.direction === "row"
|
|
441
|
+
? estimateFlexItemWidthTwips(item, itemLayout, ctx.styleResolver)
|
|
442
|
+
: undefined;
|
|
443
|
+
if (hasDirectBlockChild(item, ctx.styleResolver)) {
|
|
444
|
+
// Card-style flex items: tighten stacked lines to CSS line boxes (EXACT per font)
|
|
445
|
+
// and force an exact row height, since LibreOffice sizes rows by natural metrics.
|
|
446
|
+
const itemCtx = { ...childCtx, flexBlockContent: true };
|
|
447
|
+
const contentHeightTwips = estimateFlexItemContentHeight(item, itemLayout, ctx);
|
|
448
|
+
return {
|
|
449
|
+
layout: itemLayout,
|
|
450
|
+
blocks: visitElement($, item, itemCtx),
|
|
451
|
+
intrinsicWidthTwips,
|
|
452
|
+
contentHeightTwips,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
return {
|
|
456
|
+
layout: itemLayout,
|
|
457
|
+
intrinsicWidthTwips,
|
|
458
|
+
blocks: [
|
|
459
|
+
makeParagraph(collectInlineRunsFromNodes(item.children ?? [], typography, undefined, ctx.styleResolver, ctx.defaultSizeHalfPoints), { alignment: itemLayout.alignment }, { flexCompact: true }),
|
|
460
|
+
],
|
|
461
|
+
};
|
|
462
|
+
});
|
|
463
|
+
const table = flex.direction === "column"
|
|
464
|
+
? makeFlexColumnTable(items, flex.gap, containerLayout)
|
|
465
|
+
: makeFlexRowTable(items, flex.gap, containerLayout);
|
|
466
|
+
const wrapped = [];
|
|
467
|
+
const topSpacer = marginSpacer(containerLayout.marginTop);
|
|
468
|
+
if (topSpacer)
|
|
469
|
+
wrapped.push(topSpacer);
|
|
470
|
+
wrapped.push(table);
|
|
471
|
+
const bottomSpacer = marginSpacer(containerLayout.marginBottom);
|
|
472
|
+
if (bottomSpacer)
|
|
473
|
+
wrapped.push(bottomSpacer);
|
|
474
|
+
return wrapped;
|
|
475
|
+
}
|
|
476
|
+
function processBlockContainer($, element, ctx, extra = {}) {
|
|
477
|
+
if (isFlexContainer(element, ctx.styleResolver)) {
|
|
478
|
+
return processFlexContainer($, element, ctx);
|
|
479
|
+
}
|
|
480
|
+
const layout = resolveBlockLayout(ctx, element);
|
|
481
|
+
const typography = typographyFromBlockElement(element, ctx.styleResolver);
|
|
482
|
+
// text-align inherits (CSS): a container's alignment flows to block children
|
|
483
|
+
// (`<figure>` captions, `<center>`, `<div align=center>` wrappers).
|
|
484
|
+
const inheritAlignment = layout.alignment ? { alignment: layout.alignment } : undefined;
|
|
485
|
+
const childCtx = { ...ctx, inheritedLayout: inheritAlignment };
|
|
486
|
+
if (hasDirectBlockChild(element, ctx.styleResolver)) {
|
|
487
|
+
const children = getDirectBlockChildren(element, ctx.styleResolver);
|
|
488
|
+
if (layout.shading?.fill) {
|
|
489
|
+
const blocks = [];
|
|
490
|
+
const topSpacer = marginSpacer(layout.marginTop);
|
|
491
|
+
if (topSpacer)
|
|
492
|
+
blocks.push(topSpacer);
|
|
493
|
+
const childBlocks = [];
|
|
494
|
+
for (const child of children) {
|
|
495
|
+
childBlocks.push(...visitElement($, child, childCtx));
|
|
496
|
+
}
|
|
497
|
+
blocks.push(makeShadedContainerTable(childBlocks, layout));
|
|
498
|
+
const bottomSpacer = marginSpacer(layout.marginBottom);
|
|
499
|
+
if (bottomSpacer)
|
|
500
|
+
blocks.push(bottomSpacer);
|
|
501
|
+
return blocks;
|
|
502
|
+
}
|
|
503
|
+
// Walk children in order so inline content between block children (e.g. an
|
|
504
|
+
// `<img>` inside a `<figure>` alongside its `<figcaption>`) isn't dropped.
|
|
505
|
+
const blocks = [];
|
|
506
|
+
let pendingInline = [];
|
|
507
|
+
const flushInline = () => {
|
|
508
|
+
if (pendingInline.length === 0)
|
|
509
|
+
return;
|
|
510
|
+
const runs = collectInlineRunsFromNodes(pendingInline, typography, undefined, ctx.styleResolver, ctx.defaultSizeHalfPoints);
|
|
511
|
+
if (runs.length > 0) {
|
|
512
|
+
// First emitted block carries the container's top margin (e.g. `<figure margin:8px>`).
|
|
513
|
+
const isFirst = blocks.length === 0;
|
|
514
|
+
const flushLayout = {
|
|
515
|
+
alignment: layout.alignment,
|
|
516
|
+
spacingBefore: isFirst ? layout.marginTop : undefined,
|
|
517
|
+
};
|
|
518
|
+
blocks.push(...emitBlockContent(runs, flushLayout, {}, element));
|
|
519
|
+
}
|
|
520
|
+
pendingInline = [];
|
|
521
|
+
};
|
|
522
|
+
for (const child of element.children ?? []) {
|
|
523
|
+
if (isElement(child) && isBlockElement(child, ctx.styleResolver)) {
|
|
524
|
+
flushInline();
|
|
525
|
+
blocks.push(...visitElement($, child, childCtx));
|
|
526
|
+
}
|
|
527
|
+
else {
|
|
528
|
+
pendingInline.push(child);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
flushInline();
|
|
532
|
+
return blocks;
|
|
533
|
+
}
|
|
534
|
+
return emitFlowBlocks($, element.children ?? [], layout, typography, ctx, extra, element);
|
|
535
|
+
}
|
|
536
|
+
function listContainerMargins(listElement, styleResolver) {
|
|
537
|
+
const css = styleResolver.getCss(listElement);
|
|
538
|
+
if (styleResolver.source === "computed") {
|
|
539
|
+
return { marginTop: css.marginTop, marginBottom: css.marginBottom };
|
|
540
|
+
}
|
|
541
|
+
// UA: nested lists (`ul ul`, `ol ul`, …) have zero vertical margins.
|
|
542
|
+
const parent = listElement.parent;
|
|
543
|
+
if (parent && isElement(parent)) {
|
|
544
|
+
const parentTag = parent.name.toLowerCase();
|
|
545
|
+
if (parentTag === "li" || parentTag === "ul" || parentTag === "ol") {
|
|
546
|
+
return { marginTop: css.marginTop, marginBottom: css.marginBottom };
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
const defaultMargin = pxToTwips(DEFAULT_PARAGRAPH_MARGIN_PX);
|
|
550
|
+
let marginTop = css.marginTop ?? defaultMargin;
|
|
551
|
+
const prev = prevElementSibling(listElement);
|
|
552
|
+
if (prev && isElement(prev) && marginTop) {
|
|
553
|
+
const prevTag = prev.name.toLowerCase();
|
|
554
|
+
if (prevTag === "p" || /^h[1-6]$/.test(prevTag) || prevTag === "blockquote") {
|
|
555
|
+
marginTop = undefined;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return {
|
|
559
|
+
marginTop,
|
|
560
|
+
marginBottom: css.marginBottom ?? defaultMargin,
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
/** `<ol type="a">` / `<ul type="square">` → CSS list-style-type keyword. */
|
|
564
|
+
const LIST_TYPE_ATTR = {
|
|
565
|
+
"1": "decimal",
|
|
566
|
+
a: "lower-alpha",
|
|
567
|
+
A: "upper-alpha",
|
|
568
|
+
i: "lower-roman",
|
|
569
|
+
I: "upper-roman",
|
|
570
|
+
disc: "disc",
|
|
571
|
+
circle: "circle",
|
|
572
|
+
square: "square",
|
|
573
|
+
};
|
|
574
|
+
function resolveListReference(listElement, tag, styleResolver) {
|
|
575
|
+
const styleType = styleResolver.getCss(listElement).listStyleType ??
|
|
576
|
+
(listElement.attribs?.type ? LIST_TYPE_ATTR[listElement.attribs.type] : undefined);
|
|
577
|
+
if (styleType && LIST_STYLE_REFERENCES[styleType])
|
|
578
|
+
return LIST_STYLE_REFERENCES[styleType];
|
|
579
|
+
return tag === "ol" ? "numbers" : "bullets";
|
|
580
|
+
}
|
|
581
|
+
/** Exported for table cells — lists inside `<td>` use the same numbering path. */
|
|
582
|
+
export function processList($, listElement, ctx) {
|
|
583
|
+
const tag = listElement.name.toLowerCase();
|
|
584
|
+
const listRef = resolveListReference(listElement, tag, ctx.styleResolver);
|
|
585
|
+
const blocks = [];
|
|
586
|
+
const { marginTop, marginBottom } = listContainerMargins(listElement, ctx.styleResolver);
|
|
587
|
+
const topSpacer = marginSpacer(marginTop, ctx.blockquoteDepth > 0 ? ctx.inheritedLayout : undefined);
|
|
588
|
+
if (topSpacer)
|
|
589
|
+
blocks.push(topSpacer);
|
|
590
|
+
$(listElement)
|
|
591
|
+
.children("li")
|
|
592
|
+
.each((_, li) => {
|
|
593
|
+
blocks.push(...processListItem($, li, { ...ctx, listRef, listLevel: ctx.listLevel }));
|
|
594
|
+
});
|
|
595
|
+
const bottomSpacer = marginSpacer(marginBottom);
|
|
596
|
+
if (bottomSpacer)
|
|
597
|
+
blocks.push(bottomSpacer);
|
|
598
|
+
return blocks;
|
|
599
|
+
}
|
|
600
|
+
function listItemParagraphExtra(ctx) {
|
|
601
|
+
return {
|
|
602
|
+
style: ctx.listRef?.startsWith("numbers") ? "ListNumber" : "ListBullet",
|
|
603
|
+
numbering: { reference: ctx.listRef ?? "bullets", level: ctx.listLevel },
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
/** OOXML caps paragraph border `w:space` at 31pt. */
|
|
607
|
+
const MAX_BORDER_SPACE_PT = 31;
|
|
608
|
+
/**
|
|
609
|
+
* A direct `w:ind` on a numbered paragraph overrides the numbering style's indent.
|
|
610
|
+
* When a list item inherits an indent (blockquote), stack the list level indent on
|
|
611
|
+
* top of it, restore the hanging marker column, and pin the tab stop at the text
|
|
612
|
+
* start. The inherited quote bar is re-aimed at the quote content edge (clamped).
|
|
613
|
+
*/
|
|
614
|
+
function listItemLayout(layout, ctx) {
|
|
615
|
+
const extra = listItemParagraphExtra(ctx);
|
|
616
|
+
if (!layout.indentLeft)
|
|
617
|
+
return { layout, extra };
|
|
618
|
+
const left = layout.indentLeft + LIST_LEVEL_LEFT_TWIPS * (ctx.listLevel + 1);
|
|
619
|
+
const itemLayout = {
|
|
620
|
+
...layout,
|
|
621
|
+
indentLeft: left,
|
|
622
|
+
hangingIndent: LIST_HANGING_TWIPS,
|
|
623
|
+
};
|
|
624
|
+
const quoteBar = layout.borders?.left;
|
|
625
|
+
if (quoteBar) {
|
|
626
|
+
// The border anchors at the paragraph's leftmost extent (left − hanging).
|
|
627
|
+
// Aim the bar back at the quote content edge; if the OOXML space cap (31pt)
|
|
628
|
+
// would strand it far from the browser's bar, omit it instead.
|
|
629
|
+
const space = Math.round((left - LIST_HANGING_TWIPS - layout.indentLeft) / 20) +
|
|
630
|
+
quoteBar.space;
|
|
631
|
+
if (space <= MAX_BORDER_SPACE_PT) {
|
|
632
|
+
itemLayout.borders = { ...layout.borders, left: { ...quoteBar, space } };
|
|
633
|
+
}
|
|
634
|
+
else {
|
|
635
|
+
const { left: _left, ...rest } = layout.borders ?? {};
|
|
636
|
+
itemLayout.borders = Object.keys(rest).length > 0 ? rest : undefined;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
extra.tabStops = [{ type: TabStopType.LEFT, position: left }];
|
|
640
|
+
return { layout: itemLayout, extra };
|
|
641
|
+
}
|
|
642
|
+
function processListItem($, li, ctx) {
|
|
643
|
+
const blocks = [];
|
|
644
|
+
const inlineNodes = getInlineOrTextNodes(li, ctx.styleResolver);
|
|
645
|
+
const resolved = resolveBlockLayout(ctx, li);
|
|
646
|
+
const { layout, extra } = listItemLayout(resolved, ctx);
|
|
647
|
+
const typography = typographyFromBlockElement(li, ctx.styleResolver);
|
|
648
|
+
if (inlineNodes.length > 0) {
|
|
649
|
+
blocks.push(...emitBlockContent(collectInlineRunsFromNodes(inlineNodes, typography, undefined, ctx.styleResolver, ctx.defaultSizeHalfPoints), layout, extra));
|
|
650
|
+
}
|
|
651
|
+
for (const child of li.children ?? []) {
|
|
652
|
+
if (!isElement(child))
|
|
653
|
+
continue;
|
|
654
|
+
const tag = child.name.toLowerCase();
|
|
655
|
+
if (tag === "ul" || tag === "ol") {
|
|
656
|
+
blocks.push(...processList($, child, { ...ctx, listLevel: ctx.listLevel + 1 }));
|
|
657
|
+
}
|
|
658
|
+
else if (isBlockElement(child, ctx.styleResolver)) {
|
|
659
|
+
blocks.push(...visitElement($, child, ctx));
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
if (blocks.length === 0) {
|
|
663
|
+
blocks.push(...emitBlockContent([], layout, extra));
|
|
664
|
+
}
|
|
665
|
+
return blocks;
|
|
666
|
+
}
|
|
667
|
+
function applyDefaultHeadingMargins(element, layout, fontSizeHalfPoints, styleResolver) {
|
|
668
|
+
if (styleResolver.source === "computed")
|
|
669
|
+
return layout;
|
|
670
|
+
const inlineStyle = element.attribs?.style ?? "";
|
|
671
|
+
if (/\bmargin(?:\s|:|-)/i.test(inlineStyle))
|
|
672
|
+
return layout;
|
|
673
|
+
const tag = element.name.toLowerCase();
|
|
674
|
+
const factor = HEADING_MARGIN_EM[tag] ?? 1;
|
|
675
|
+
const fontPx = fontSizeHalfPoints / 1.5;
|
|
676
|
+
const margin = pxToTwips(fontPx * factor);
|
|
677
|
+
return {
|
|
678
|
+
...layout,
|
|
679
|
+
marginTop: layout.marginTop ?? margin,
|
|
680
|
+
marginBottom: layout.marginBottom ?? margin,
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
function processHeading($, element, ctx) {
|
|
684
|
+
let layout = resolveBlockLayout(ctx, element);
|
|
685
|
+
const tag = element.name.toLowerCase();
|
|
686
|
+
const fromElement = typographyFromBlockElement(element, ctx.styleResolver);
|
|
687
|
+
const fontSize = fromElement.fontSize ?? HEADING_FONT_HALF_POINTS[tag];
|
|
688
|
+
layout = applyDefaultHeadingMargins(element, layout, fontSize, ctx.styleResolver);
|
|
689
|
+
layout = {
|
|
690
|
+
...layout,
|
|
691
|
+
spacingBefore: sumTwips(layout.paddingTop, layout.marginTop),
|
|
692
|
+
spacingAfter: sumTwips(layout.paddingBottom, layout.marginBottom),
|
|
693
|
+
};
|
|
694
|
+
const typography = {
|
|
695
|
+
...fromElement,
|
|
696
|
+
bold: true,
|
|
697
|
+
fontSize,
|
|
698
|
+
};
|
|
699
|
+
const hasBlockShading = Boolean(layout.shading?.fill);
|
|
700
|
+
const fontSizePx = fontSize / 1.5;
|
|
701
|
+
const headingLayout = hasBlockShading
|
|
702
|
+
? {
|
|
703
|
+
...layout,
|
|
704
|
+
shadedContentLinePx: fontSizePx * 1.4,
|
|
705
|
+
shadedTabHalfPoints: fontSize,
|
|
706
|
+
}
|
|
707
|
+
: layout;
|
|
708
|
+
return emitBlockContent(collectInlineRunsFromNodes(element.children ?? [], typography, undefined, ctx.styleResolver, ctx.defaultSizeHalfPoints), headingLayout, hasBlockShading ? {} : { heading: HEADING_LEVELS[tag], atLeastLineTwips: fontSize * 14 }, element);
|
|
709
|
+
}
|
|
710
|
+
function processBlockquote($, element, ctx) {
|
|
711
|
+
const depth = ctx.blockquoteDepth + 1;
|
|
712
|
+
const parentIndent = ctx.blockquoteDepth > 0 ? (ctx.inheritedLayout?.indentLeft ?? 0) : 0;
|
|
713
|
+
const quoteLayout = blockquoteLayout(depth, element, ctx.styleResolver, parentIndent);
|
|
714
|
+
const childCtx = {
|
|
715
|
+
...ctx,
|
|
716
|
+
blockquoteDepth: depth,
|
|
717
|
+
inheritedLayout: quoteLayout,
|
|
718
|
+
};
|
|
719
|
+
let blocks;
|
|
720
|
+
if (hasDirectBlockChild(element)) {
|
|
721
|
+
blocks = visitNodes($, element.children ?? [], childCtx);
|
|
722
|
+
}
|
|
723
|
+
else {
|
|
724
|
+
const typography = typographyFromBlockElement(element, ctx.styleResolver);
|
|
725
|
+
blocks = emitBlockContent(collectInlineRunsFromNodes(element.children ?? [], typography, undefined, ctx.styleResolver, ctx.defaultSizeHalfPoints), quoteLayout, {}, element);
|
|
726
|
+
}
|
|
727
|
+
const wrapped = [];
|
|
728
|
+
const topSpacer = marginSpacer(quoteLayout.marginTop, ctx.blockquoteDepth > 0 ? ctx.inheritedLayout : undefined);
|
|
729
|
+
if (topSpacer)
|
|
730
|
+
wrapped.push(topSpacer);
|
|
731
|
+
wrapped.push(...blocks);
|
|
732
|
+
const bottomSpacer = marginSpacer(quoteLayout.marginBottom);
|
|
733
|
+
if (bottomSpacer)
|
|
734
|
+
wrapped.push(bottomSpacer);
|
|
735
|
+
return wrapped;
|
|
736
|
+
}
|
|
737
|
+
/** Chromium default monospace font size (`<pre>`/`<code>` with no explicit size). */
|
|
738
|
+
const MONOSPACE_DEFAULT_PX = 13;
|
|
739
|
+
/** Text content with literal newlines preserved (`white-space: pre`); `<br>` → newline. */
|
|
740
|
+
function rawPreText(element) {
|
|
741
|
+
const parts = [];
|
|
742
|
+
const walk = (nodes) => {
|
|
743
|
+
for (const node of nodes) {
|
|
744
|
+
if (node.type === "text")
|
|
745
|
+
parts.push(node.data ?? "");
|
|
746
|
+
else if (isElement(node)) {
|
|
747
|
+
if (node.name.toLowerCase() === "br")
|
|
748
|
+
parts.push("\n");
|
|
749
|
+
else
|
|
750
|
+
walk(node.children ?? []);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
};
|
|
754
|
+
walk(element.children ?? []);
|
|
755
|
+
return parts.join("").replace(/^\n+/, "").replace(/\s+$/, "");
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* `<pre>` → one run per line with explicit breaks (inline collection would
|
|
759
|
+
* collapse the newlines), sized per the element's font (UA monospace 13px).
|
|
760
|
+
*/
|
|
761
|
+
function processPreBlock(element, ctx) {
|
|
762
|
+
const css = ctx.styleResolver.getCss(element);
|
|
763
|
+
const layout = resolveBlockLayout(ctx, element);
|
|
764
|
+
const fontSize = css.fontSize ?? pxToHalfPoints(MONOSPACE_DEFAULT_PX);
|
|
765
|
+
// Own font-family only — UA `pre { font-family: monospace }` beats inheritance.
|
|
766
|
+
const font = css.fontFamily ?? "Consolas";
|
|
767
|
+
const runs = rawPreText(element)
|
|
768
|
+
.split("\n")
|
|
769
|
+
.map((line, index) => new TextRun({
|
|
770
|
+
text: line,
|
|
771
|
+
font,
|
|
772
|
+
size: fontSize,
|
|
773
|
+
...(index > 0 ? { break: 1 } : {}),
|
|
774
|
+
}));
|
|
775
|
+
const preLayout = {
|
|
776
|
+
...layout,
|
|
777
|
+
// EXACT per-line box matching the CSS line height of the pre's own font.
|
|
778
|
+
shadedContentLinePx: (fontSize / 1.5) * 1.4,
|
|
779
|
+
};
|
|
780
|
+
return emitBlockContent(runs, preLayout, {}, element);
|
|
781
|
+
}
|
|
782
|
+
/** Chromium `<hr>`: 1px inset border paints ~2px gray; margin 0.5em collapses with neighbors. */
|
|
783
|
+
const HR_DEFAULT_COLOR = "999999";
|
|
784
|
+
const HR_DEFAULT_SIZE_EIGHTHS = 8;
|
|
785
|
+
const HR_MARGIN_PX = 7;
|
|
786
|
+
/** EXACT line height (twips) placing the bottom border at the browser's rule y. */
|
|
787
|
+
const HR_LINE_TWIPS = 65;
|
|
788
|
+
/**
|
|
789
|
+
* `<hr>` → minimal-height paragraph with a bottom border (the Word idiom for a
|
|
790
|
+
* rule). The EXACT spacer line keeps the border at the browser's y-position
|
|
791
|
+
* instead of hanging below an empty full-height line box.
|
|
792
|
+
*/
|
|
793
|
+
function processHorizontalRule(element, ctx) {
|
|
794
|
+
const css = ctx.styleResolver.getCss(element);
|
|
795
|
+
const side = css.borderTop ?? css.borderBottom ?? css.border;
|
|
796
|
+
const color = side?.color ?? css.backgroundColor ?? HR_DEFAULT_COLOR;
|
|
797
|
+
const size = side
|
|
798
|
+
? Math.max(4, Math.round(side.widthPx * 6))
|
|
799
|
+
: HR_DEFAULT_SIZE_EIGHTHS;
|
|
800
|
+
// Adjacent paragraphs/headings/blockquotes already emit collapsed margins
|
|
801
|
+
// (which exceed the hr's own 0.5em); only provide a gap when they don't.
|
|
802
|
+
const prev = prevElementSibling(element);
|
|
803
|
+
const prevTag = prev?.name.toLowerCase() ?? "";
|
|
804
|
+
const prevProvidesGap = prevTag === "p" || /^h[1-6]$/.test(prevTag) || prevTag === "blockquote";
|
|
805
|
+
const before = css.marginTop ?? (prevProvidesGap ? 0 : pxToTwips(HR_MARGIN_PX));
|
|
806
|
+
const after = css.marginBottom ?? 0;
|
|
807
|
+
return new Paragraph({
|
|
808
|
+
spacing: {
|
|
809
|
+
before,
|
|
810
|
+
after,
|
|
811
|
+
line: HR_LINE_TWIPS,
|
|
812
|
+
lineRule: LineRuleType.EXACT,
|
|
813
|
+
},
|
|
814
|
+
border: {
|
|
815
|
+
bottom: { style: BorderStyle.SINGLE, size, space: 0, color },
|
|
816
|
+
},
|
|
817
|
+
children: [emptyRun()],
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
export function visitElement($, element, ctx = DEFAULT_VISITOR_CONTEXT) {
|
|
821
|
+
if (isHiddenElement(element, ctx.styleResolver))
|
|
822
|
+
return [];
|
|
823
|
+
const tag = element.name.toLowerCase();
|
|
824
|
+
switch (tag) {
|
|
825
|
+
case "h1":
|
|
826
|
+
case "h2":
|
|
827
|
+
case "h3":
|
|
828
|
+
case "h4":
|
|
829
|
+
case "h5":
|
|
830
|
+
case "h6":
|
|
831
|
+
return processHeading($, element, ctx);
|
|
832
|
+
case "p":
|
|
833
|
+
case "div":
|
|
834
|
+
case "section":
|
|
835
|
+
return processBlockContainer($, element, ctx);
|
|
836
|
+
case "blockquote":
|
|
837
|
+
return processBlockquote($, element, ctx);
|
|
838
|
+
case "ul":
|
|
839
|
+
case "ol":
|
|
840
|
+
return processList($, element, ctx);
|
|
841
|
+
case "table": {
|
|
842
|
+
// CSS margins on the table element → spacer paragraphs (also keeps
|
|
843
|
+
// adjacent tables from merging into one in Word).
|
|
844
|
+
const tableCss = ctx.styleResolver.getCss(element);
|
|
845
|
+
const blocks = [];
|
|
846
|
+
const topSpacer = marginSpacer(tableCss.marginTop);
|
|
847
|
+
if (topSpacer)
|
|
848
|
+
blocks.push(topSpacer);
|
|
849
|
+
// `<caption>` renders above the table, centered (UA default).
|
|
850
|
+
const caption = $(element).children("caption").first().toArray()[0];
|
|
851
|
+
if (caption) {
|
|
852
|
+
blocks.push(makeParagraph(collectInlineRunsFromNodes(caption.children ?? [], typographyFromBlockElement(caption, ctx.styleResolver), undefined, ctx.styleResolver, ctx.defaultSizeHalfPoints), { alignment: AlignmentType.CENTER }));
|
|
853
|
+
}
|
|
854
|
+
blocks.push(convertTable($, element, ctx.styleResolver));
|
|
855
|
+
const bottomSpacer = marginSpacer(tableCss.marginBottom);
|
|
856
|
+
if (bottomSpacer)
|
|
857
|
+
blocks.push(bottomSpacer);
|
|
858
|
+
return blocks;
|
|
859
|
+
}
|
|
860
|
+
case "svg":
|
|
861
|
+
return convertSvg(element);
|
|
862
|
+
case "pre":
|
|
863
|
+
return processPreBlock(element, ctx);
|
|
864
|
+
case "hr":
|
|
865
|
+
return [processHorizontalRule(element, ctx)];
|
|
866
|
+
default:
|
|
867
|
+
return processBlockContainer($, element, ctx);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
export function visitNodes($, nodes, ctx = DEFAULT_VISITOR_CONTEXT) {
|
|
871
|
+
const blocks = [];
|
|
872
|
+
let pendingInline = [];
|
|
873
|
+
const flushInline = () => {
|
|
874
|
+
if (pendingInline.length === 0)
|
|
875
|
+
return;
|
|
876
|
+
blocks.push(makeParagraph(collectInlineRunsFromNodes(pendingInline, {}, undefined, ctx.styleResolver, ctx.defaultSizeHalfPoints), ctx.inheritedLayout ?? {}));
|
|
877
|
+
pendingInline = [];
|
|
878
|
+
};
|
|
879
|
+
for (const node of nodes) {
|
|
880
|
+
if (node.type === "text") {
|
|
881
|
+
if ((node.data ?? "").trim())
|
|
882
|
+
pendingInline.push(node);
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
if (!isElement(node))
|
|
886
|
+
continue;
|
|
887
|
+
if (isBlockElement(node, ctx.styleResolver)) {
|
|
888
|
+
flushInline();
|
|
889
|
+
const childCtx = ctx.blockquoteDepth > 0 || ctx.inheritedLayout
|
|
890
|
+
? ctx
|
|
891
|
+
: { ...ctx, inheritedLayout: undefined };
|
|
892
|
+
blocks.push(...visitElement($, node, childCtx));
|
|
893
|
+
}
|
|
894
|
+
else {
|
|
895
|
+
pendingInline.push(node);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
flushInline();
|
|
899
|
+
return blocks;
|
|
900
|
+
}
|
|
901
|
+
export function htmlToDocxBlocks($, styleResolver = INLINE_STYLE_RESOLVER, defaultSizeHalfPoints = DEFAULT_VISITOR_CONTEXT.defaultSizeHalfPoints) {
|
|
902
|
+
const ctx = { ...DEFAULT_VISITOR_CONTEXT, styleResolver, defaultSizeHalfPoints };
|
|
903
|
+
const nodes = $("body").contents().toArray();
|
|
904
|
+
const blocks = visitNodes($, nodes, ctx);
|
|
905
|
+
if (blocks.length === 0) {
|
|
906
|
+
return [new Paragraph({ children: [new TextRun("")] })];
|
|
907
|
+
}
|
|
908
|
+
return blocks;
|
|
909
|
+
}
|
|
910
|
+
//# sourceMappingURL=visitor.js.map
|