docgen-utils 1.0.5
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/README.md +118 -0
- package/dist/bundle.js +36086 -0
- package/dist/bundle.min.js +197 -0
- package/dist/cli.js +47432 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/packages/cli/commands/export-docs.d.ts +5 -0
- package/dist/packages/cli/commands/export-docs.d.ts.map +1 -0
- package/dist/packages/cli/commands/export-docs.js +24 -0
- package/dist/packages/cli/commands/export-docs.js.map +1 -0
- package/dist/packages/cli/commands/export-slides.d.ts +5 -0
- package/dist/packages/cli/commands/export-slides.d.ts.map +1 -0
- package/dist/packages/cli/commands/export-slides.js +86 -0
- package/dist/packages/cli/commands/export-slides.js.map +1 -0
- package/dist/packages/cli/commands/import-docx.d.ts +5 -0
- package/dist/packages/cli/commands/import-docx.d.ts.map +1 -0
- package/dist/packages/cli/commands/import-docx.js +27 -0
- package/dist/packages/cli/commands/import-docx.js.map +1 -0
- package/dist/packages/cli/commands/import-pptx.d.ts +5 -0
- package/dist/packages/cli/commands/import-pptx.d.ts.map +1 -0
- package/dist/packages/cli/commands/import-pptx.js +44 -0
- package/dist/packages/cli/commands/import-pptx.js.map +1 -0
- package/dist/packages/cli/index.d.ts +11 -0
- package/dist/packages/cli/index.d.ts.map +1 -0
- package/dist/packages/cli/index.js +103 -0
- package/dist/packages/cli/index.js.map +1 -0
- package/dist/packages/docs/common.d.ts +183 -0
- package/dist/packages/docs/common.d.ts.map +1 -0
- package/dist/packages/docs/common.js +27 -0
- package/dist/packages/docs/common.js.map +1 -0
- package/dist/packages/docs/convert.d.ts +7 -0
- package/dist/packages/docs/convert.d.ts.map +1 -0
- package/dist/packages/docs/convert.js +1399 -0
- package/dist/packages/docs/convert.js.map +1 -0
- package/dist/packages/docs/create-document.d.ts +30 -0
- package/dist/packages/docs/create-document.d.ts.map +1 -0
- package/dist/packages/docs/create-document.js +170 -0
- package/dist/packages/docs/create-document.js.map +1 -0
- package/dist/packages/docs/export.d.ts +57 -0
- package/dist/packages/docs/export.d.ts.map +1 -0
- package/dist/packages/docs/export.js +430 -0
- package/dist/packages/docs/export.js.map +1 -0
- package/dist/packages/docs/import-docx.d.ts +13 -0
- package/dist/packages/docs/import-docx.d.ts.map +1 -0
- package/dist/packages/docs/import-docx.js +2299 -0
- package/dist/packages/docs/import-docx.js.map +1 -0
- package/dist/packages/docs/parse.d.ts +6 -0
- package/dist/packages/docs/parse.d.ts.map +1 -0
- package/dist/packages/docs/parse.js +4253 -0
- package/dist/packages/docs/parse.js.map +1 -0
- package/dist/packages/shared/dom-parser-shim.d.ts +30 -0
- package/dist/packages/shared/dom-parser-shim.d.ts.map +1 -0
- package/dist/packages/shared/dom-parser-shim.js +152 -0
- package/dist/packages/shared/dom-parser-shim.js.map +1 -0
- package/dist/packages/slides/common.d.ts +325 -0
- package/dist/packages/slides/common.d.ts.map +1 -0
- package/dist/packages/slides/common.js +12 -0
- package/dist/packages/slides/common.js.map +1 -0
- package/dist/packages/slides/convert.d.ts +35 -0
- package/dist/packages/slides/convert.d.ts.map +1 -0
- package/dist/packages/slides/convert.js +308 -0
- package/dist/packages/slides/convert.js.map +1 -0
- package/dist/packages/slides/createPresentation.d.ts +51 -0
- package/dist/packages/slides/createPresentation.d.ts.map +1 -0
- package/dist/packages/slides/createPresentation.js +265 -0
- package/dist/packages/slides/createPresentation.js.map +1 -0
- package/dist/packages/slides/export.d.ts +24 -0
- package/dist/packages/slides/export.d.ts.map +1 -0
- package/dist/packages/slides/export.js +52 -0
- package/dist/packages/slides/export.js.map +1 -0
- package/dist/packages/slides/import-pptx.d.ts +13 -0
- package/dist/packages/slides/import-pptx.d.ts.map +1 -0
- package/dist/packages/slides/import-pptx.js +619 -0
- package/dist/packages/slides/import-pptx.js.map +1 -0
- package/dist/packages/slides/parse.d.ts +45 -0
- package/dist/packages/slides/parse.d.ts.map +1 -0
- package/dist/packages/slides/parse.js +1185 -0
- package/dist/packages/slides/parse.js.map +1 -0
- package/dist/packages/slides/transform.d.ts +37 -0
- package/dist/packages/slides/transform.d.ts.map +1 -0
- package/dist/packages/slides/transform.js +140 -0
- package/dist/packages/slides/transform.js.map +1 -0
- package/dist/packages/slides/vendor/VENDORING.md +58 -0
- package/dist/packages/slides/vendor/pptxgen.d.ts +805 -0
- package/dist/packages/slides/vendor/pptxgen.js +7442 -0
- package/package.json +57 -0
|
@@ -0,0 +1,1399 @@
|
|
|
1
|
+
import { AlignmentType, BorderStyle, ImageRun, LineRuleType, Paragraph, ShadingType, Table, TableCell as DocxTableCell, TableRow, TextRun, UnderlineType, WidthType, convertInchesToTwip, XmlComponent } from "docx";
|
|
2
|
+
import { HEADING_LEVEL_MAP } from "./common";
|
|
3
|
+
// Line spacing: HTML line-height 1.7
|
|
4
|
+
// Word LineRuleType.AUTO uses "240ths of a line" - so 240 = single spacing, 360 = 1.5, 480 = double
|
|
5
|
+
// For 1.7 line height: 240 * 1.7 = 408
|
|
6
|
+
const PARAGRAPH_SPACING = {
|
|
7
|
+
line: 408, // 1.7 line height (240 * 1.7)
|
|
8
|
+
lineRule: LineRuleType.AUTO,
|
|
9
|
+
after: 240, // 1em (1rem = 16px = 12pt) after paragraph
|
|
10
|
+
};
|
|
11
|
+
// Heading line-height is 1.3 (tighter than body text)
|
|
12
|
+
// 240 * 1.3 = 312
|
|
13
|
+
const HEADING_LINE_SPACING = {
|
|
14
|
+
line: 312, // 1.3 line height (240 * 1.3)
|
|
15
|
+
lineRule: LineRuleType.AUTO,
|
|
16
|
+
};
|
|
17
|
+
// Heading spacing based on HTML CSS
|
|
18
|
+
const HEADING_SPACING = {
|
|
19
|
+
h1: { before: 160, after: 240 }, // 0.67em / 1em
|
|
20
|
+
h2: { before: 480, after: 240 }, // margin-top: 2rem, margin-bottom: 1rem
|
|
21
|
+
h3: { before: 360, after: 240 }, // margin-top: 1.5rem
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Concrete XmlComponent implementation for building custom XML elements.
|
|
25
|
+
*/
|
|
26
|
+
class XmlElement extends XmlComponent {
|
|
27
|
+
constructor(name, attributes) {
|
|
28
|
+
super(name);
|
|
29
|
+
if (attributes) {
|
|
30
|
+
// Attributes must be added as a special _attr object
|
|
31
|
+
this.root.push({ _attr: attributes });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Add text content to this element */
|
|
35
|
+
addText(text) {
|
|
36
|
+
this.root.push(text);
|
|
37
|
+
return this;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Custom XmlComponent for gradient text fill (w14:textFill).
|
|
42
|
+
* This creates the OOXML structure for gradient text in Word.
|
|
43
|
+
*
|
|
44
|
+
* The gradient fill XML structure:
|
|
45
|
+
* <w14:textFill>
|
|
46
|
+
* <w14:gradFill>
|
|
47
|
+
* <w14:gsLst>
|
|
48
|
+
* <w14:gs w14:pos="0">
|
|
49
|
+
* <w14:srgbClr w14:val="7C3AED"/>
|
|
50
|
+
* </w14:gs>
|
|
51
|
+
* <w14:gs w14:pos="50000">
|
|
52
|
+
* <w14:srgbClr w14:val="A78BFA"/>
|
|
53
|
+
* </w14:gs>
|
|
54
|
+
* <w14:gs w14:pos="100000">
|
|
55
|
+
* <w14:srgbClr w14:val="C084FC"/>
|
|
56
|
+
* </w14:gs>
|
|
57
|
+
* </w14:gsLst>
|
|
58
|
+
* <w14:lin w14:ang="2700000" w14:scaled="0"/>
|
|
59
|
+
* </w14:gradFill>
|
|
60
|
+
* </w14:textFill>
|
|
61
|
+
*/
|
|
62
|
+
class GradientTextFill extends XmlElement {
|
|
63
|
+
constructor(gradient) {
|
|
64
|
+
super("w14:textFill");
|
|
65
|
+
// Create gradFill element
|
|
66
|
+
const gradFill = new XmlElement("w14:gradFill");
|
|
67
|
+
// Create gsLst (gradient stop list)
|
|
68
|
+
const gsLst = new XmlElement("w14:gsLst");
|
|
69
|
+
for (const stop of gradient.stops) {
|
|
70
|
+
// Position is in 1/1000ths of a percent (0-100000)
|
|
71
|
+
const pos = Math.round(stop.position * 1000);
|
|
72
|
+
const gs = new GradientStopXml(pos, stop.color);
|
|
73
|
+
gsLst.addChildElement(gs);
|
|
74
|
+
}
|
|
75
|
+
gradFill.addChildElement(gsLst);
|
|
76
|
+
// Create lin (linear gradient) element
|
|
77
|
+
// Angle is in 1/60000ths of a degree (so 135deg = 135 * 60000 = 8100000)
|
|
78
|
+
// Word uses a different angle system: 0 = right, 90 = down, etc.
|
|
79
|
+
// CSS: 0 = up, 90 = right, 180 = down, 270 = left
|
|
80
|
+
// Word: needs to be converted (Word 0 = right, going counterclockwise)
|
|
81
|
+
// Formula: wordAngle = (450 - cssAngle) % 360
|
|
82
|
+
const wordAngle = ((450 - gradient.angle) % 360) * 60000;
|
|
83
|
+
const lin = new XmlElement("w14:lin", {
|
|
84
|
+
"w14:ang": wordAngle.toString(),
|
|
85
|
+
"w14:scaled": "0",
|
|
86
|
+
});
|
|
87
|
+
gradFill.addChildElement(lin);
|
|
88
|
+
this.addChildElement(gradFill);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Custom XmlComponent for a gradient stop (w14:gs).
|
|
93
|
+
*/
|
|
94
|
+
class GradientStopXml extends XmlElement {
|
|
95
|
+
constructor(position, color) {
|
|
96
|
+
super("w14:gs", { "w14:pos": position.toString() });
|
|
97
|
+
const srgbClr = new XmlElement("w14:srgbClr", { "w14:val": color.toUpperCase() });
|
|
98
|
+
this.addChildElement(srgbClr);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Custom XmlComponent for a gradient stop in DrawingML (a:gs).
|
|
103
|
+
* Used for shape gradient fills in anchored drawings.
|
|
104
|
+
*/
|
|
105
|
+
class DrawingGradientStop extends XmlElement {
|
|
106
|
+
constructor(position, color) {
|
|
107
|
+
// Position is in 1/1000ths of a percent (0-100000)
|
|
108
|
+
super("a:gs", { pos: (position * 1000).toString() });
|
|
109
|
+
const srgbClr = new XmlElement("a:srgbClr", { val: color.toUpperCase() });
|
|
110
|
+
this.addChildElement(srgbClr);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Creates a VML (Vector Markup Language) gradient-filled rectangle wrapped in a run.
|
|
115
|
+
* VML is the legacy format that LibreOffice supports better than DrawingML.
|
|
116
|
+
*
|
|
117
|
+
* This gradient is designed to be placed BEFORE the content (in a spacing paragraph).
|
|
118
|
+
* It uses absolute positioning relative to the margin and a large height that
|
|
119
|
+
* visually covers the content below without affecting its layout.
|
|
120
|
+
*/
|
|
121
|
+
class VmlGradientBackground extends XmlElement {
|
|
122
|
+
constructor(gradient, widthInches) {
|
|
123
|
+
super("w:r");
|
|
124
|
+
// VML uses points
|
|
125
|
+
const widthPt = widthInches * 72;
|
|
126
|
+
// Use a large height - this won't affect layout because the shape is in a
|
|
127
|
+
// separate paragraph before the table and has wrap type "none"
|
|
128
|
+
const heightPt = 1000;
|
|
129
|
+
// VML gradient angle conversion
|
|
130
|
+
const vmlAngle = gradient.angle - 90;
|
|
131
|
+
// Get first and last gradient colors - swap them for VML
|
|
132
|
+
const sortedStops = [...gradient.stops].sort((a, b) => a.position - b.position);
|
|
133
|
+
const startColor = sortedStops[sortedStops.length - 1]?.color || "E9D5FF";
|
|
134
|
+
const endColor = sortedStops[0]?.color || "7C3AED";
|
|
135
|
+
// Create w:pict container
|
|
136
|
+
const pict = new XmlElement("w:pict");
|
|
137
|
+
// Create VML rect shape positioned relative to margin
|
|
138
|
+
// margin-top: small offset to align with table content below
|
|
139
|
+
const rect = new XmlElement("v:rect", {
|
|
140
|
+
"xmlns:v": "urn:schemas-microsoft-com:vml",
|
|
141
|
+
"xmlns:o": "urn:schemas-microsoft-com:office:office",
|
|
142
|
+
"style": `position:absolute;margin-left:0;margin-top:0.15in;width:${widthPt}pt;height:${heightPt}pt;z-index:-251658240`,
|
|
143
|
+
"filled": "t",
|
|
144
|
+
"stroked": "f",
|
|
145
|
+
});
|
|
146
|
+
// Add gradient fill
|
|
147
|
+
const fill = new XmlElement("v:fill", {
|
|
148
|
+
"type": "gradient",
|
|
149
|
+
"color": `#${startColor}`,
|
|
150
|
+
"color2": `#${endColor}`,
|
|
151
|
+
"angle": vmlAngle.toString(),
|
|
152
|
+
});
|
|
153
|
+
rect.addChildElement(fill);
|
|
154
|
+
// Wrap settings - anchored to margin, type "none" so text flows over
|
|
155
|
+
const wrap = new XmlElement("w10:wrap", {
|
|
156
|
+
"xmlns:w10": "urn:schemas-microsoft-com:office:word",
|
|
157
|
+
"type": "none",
|
|
158
|
+
"anchorx": "margin",
|
|
159
|
+
"anchory": "paragraph",
|
|
160
|
+
});
|
|
161
|
+
rect.addChildElement(wrap);
|
|
162
|
+
pict.addChildElement(rect);
|
|
163
|
+
this.addChildElement(pict);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Creates an anchored drawing with a gradient-filled rectangle.
|
|
168
|
+
* Uses wp:anchor with behindDoc="1" to position the shape behind text.
|
|
169
|
+
* This acts as a true background that content flows over.
|
|
170
|
+
*/
|
|
171
|
+
class GradientBackgroundDrawing extends XmlElement {
|
|
172
|
+
constructor(gradient, widthEmu, heightEmu, placement = "before-table") {
|
|
173
|
+
super("w:drawing");
|
|
174
|
+
// Calculate offsets based on placement strategy
|
|
175
|
+
// EMUs: 1 inch = 914400 EMUs
|
|
176
|
+
let leftOffsetEmu;
|
|
177
|
+
let topOffsetEmu;
|
|
178
|
+
if (placement === "inside-cell") {
|
|
179
|
+
// Old approach: negative offsets to compensate for table cell margins
|
|
180
|
+
// Cell margins are: top=0.25in, left=0.35in
|
|
181
|
+
leftOffsetEmu = Math.round(-0.35 * 914400); // -320040
|
|
182
|
+
topOffsetEmu = Math.round(-0.25 * 914400); // -228600
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
// New approach: position relative to the paragraph before the table
|
|
186
|
+
// No horizontal offset needed (starts at left margin)
|
|
187
|
+
// Small positive vertical offset to position just below the paragraph
|
|
188
|
+
leftOffsetEmu = 0;
|
|
189
|
+
topOffsetEmu = Math.round(0.15 * 914400); // ~0.15 inch below paragraph
|
|
190
|
+
}
|
|
191
|
+
// Use anchor positioning with behindDoc to place behind text
|
|
192
|
+
const anchor = new XmlElement("wp:anchor", {
|
|
193
|
+
distT: "0",
|
|
194
|
+
distB: "0",
|
|
195
|
+
distL: "0",
|
|
196
|
+
distR: "0",
|
|
197
|
+
simplePos: "0",
|
|
198
|
+
relativeHeight: "0", // Behind everything
|
|
199
|
+
behindDoc: "1", // Position behind text
|
|
200
|
+
locked: "0",
|
|
201
|
+
layoutInCell: "1", // Allow layout in cell (for LibreOffice compatibility)
|
|
202
|
+
allowOverlap: "1",
|
|
203
|
+
});
|
|
204
|
+
// Simple position (required but not used when simplePos="0")
|
|
205
|
+
anchor.addChildElement(new XmlElement("wp:simplePos", { x: "0", y: "0" }));
|
|
206
|
+
// Horizontal position - align to margin for full-width coverage
|
|
207
|
+
const posH = new XmlElement("wp:positionH", { relativeFrom: placement === "inside-cell" ? "column" : "margin" });
|
|
208
|
+
posH.addChildElement(new XmlElement("wp:posOffset").addText(leftOffsetEmu.toString()));
|
|
209
|
+
anchor.addChildElement(posH);
|
|
210
|
+
// Vertical position - align to paragraph
|
|
211
|
+
const posV = new XmlElement("wp:positionV", { relativeFrom: "paragraph" });
|
|
212
|
+
posV.addChildElement(new XmlElement("wp:posOffset").addText(topOffsetEmu.toString()));
|
|
213
|
+
anchor.addChildElement(posV);
|
|
214
|
+
// Extent (size of the drawing)
|
|
215
|
+
anchor.addChildElement(new XmlElement("wp:extent", {
|
|
216
|
+
cx: widthEmu.toString(),
|
|
217
|
+
cy: heightEmu.toString(),
|
|
218
|
+
}));
|
|
219
|
+
// Effect extent (no effects)
|
|
220
|
+
anchor.addChildElement(new XmlElement("wp:effectExtent", {
|
|
221
|
+
l: "0", t: "0", r: "0", b: "0",
|
|
222
|
+
}));
|
|
223
|
+
// Wrap none (allow text to flow over the shape)
|
|
224
|
+
anchor.addChildElement(new XmlElement("wp:wrapNone"));
|
|
225
|
+
// Document properties
|
|
226
|
+
anchor.addChildElement(new XmlElement("wp:docPr", {
|
|
227
|
+
id: "1",
|
|
228
|
+
name: "Gradient Background",
|
|
229
|
+
}));
|
|
230
|
+
// Non-visual properties
|
|
231
|
+
const cNvGraphicFramePr = new XmlElement("wp:cNvGraphicFramePr");
|
|
232
|
+
cNvGraphicFramePr.addChildElement(new XmlElement("a:graphicFrameLocks", {
|
|
233
|
+
"xmlns:a": "http://schemas.openxmlformats.org/drawingml/2006/main",
|
|
234
|
+
}));
|
|
235
|
+
anchor.addChildElement(cNvGraphicFramePr);
|
|
236
|
+
// Graphic element
|
|
237
|
+
const graphic = new XmlElement("a:graphic", {
|
|
238
|
+
"xmlns:a": "http://schemas.openxmlformats.org/drawingml/2006/main",
|
|
239
|
+
});
|
|
240
|
+
// Graphic data with wordprocessingShape
|
|
241
|
+
const graphicData = new XmlElement("a:graphicData", {
|
|
242
|
+
uri: "http://schemas.microsoft.com/office/word/2010/wordprocessingShape",
|
|
243
|
+
});
|
|
244
|
+
// WordprocessingShape
|
|
245
|
+
const wsp = new XmlElement("wps:wsp", {
|
|
246
|
+
"xmlns:wps": "http://schemas.microsoft.com/office/word/2010/wordprocessingShape",
|
|
247
|
+
});
|
|
248
|
+
// Non-visual shape properties
|
|
249
|
+
wsp.addChildElement(new XmlElement("wps:cNvSpPr"));
|
|
250
|
+
// Shape properties
|
|
251
|
+
const spPr = new XmlElement("wps:spPr");
|
|
252
|
+
// Transform
|
|
253
|
+
const xfrm = new XmlElement("a:xfrm");
|
|
254
|
+
xfrm.addChildElement(new XmlElement("a:off", { x: "0", y: "0" }));
|
|
255
|
+
xfrm.addChildElement(new XmlElement("a:ext", {
|
|
256
|
+
cx: widthEmu.toString(),
|
|
257
|
+
cy: heightEmu.toString(),
|
|
258
|
+
}));
|
|
259
|
+
spPr.addChildElement(xfrm);
|
|
260
|
+
// Preset geometry (rectangle)
|
|
261
|
+
const prstGeom = new XmlElement("a:prstGeom", { prst: "rect" });
|
|
262
|
+
prstGeom.addChildElement(new XmlElement("a:avLst"));
|
|
263
|
+
spPr.addChildElement(prstGeom);
|
|
264
|
+
// Gradient fill
|
|
265
|
+
const gradFill = new XmlElement("a:gradFill", { rotWithShape: "1" });
|
|
266
|
+
// Gradient stop list
|
|
267
|
+
const gsLst = new XmlElement("a:gsLst");
|
|
268
|
+
for (const stop of gradient.stops) {
|
|
269
|
+
gsLst.addChildElement(new DrawingGradientStop(stop.position, stop.color));
|
|
270
|
+
}
|
|
271
|
+
gradFill.addChildElement(gsLst);
|
|
272
|
+
// Linear gradient direction
|
|
273
|
+
// CSS angle: 0deg = to top, 90deg = to right, 135deg = to bottom-right
|
|
274
|
+
// DrawingML: 0 = left to right, angles in 1/60000ths of a degree
|
|
275
|
+
const normalizedAngle = ((90 - gradient.angle) % 360 + 360) % 360;
|
|
276
|
+
const drawingAngle = normalizedAngle * 60000;
|
|
277
|
+
gradFill.addChildElement(new XmlElement("a:lin", {
|
|
278
|
+
ang: drawingAngle.toString(),
|
|
279
|
+
scaled: "0",
|
|
280
|
+
}));
|
|
281
|
+
spPr.addChildElement(gradFill);
|
|
282
|
+
// No outline
|
|
283
|
+
const ln = new XmlElement("a:ln");
|
|
284
|
+
ln.addChildElement(new XmlElement("a:noFill"));
|
|
285
|
+
spPr.addChildElement(ln);
|
|
286
|
+
wsp.addChildElement(spPr);
|
|
287
|
+
// Body properties (required)
|
|
288
|
+
wsp.addChildElement(new XmlElement("wps:bodyPr"));
|
|
289
|
+
graphicData.addChildElement(wsp);
|
|
290
|
+
graphic.addChildElement(graphicData);
|
|
291
|
+
anchor.addChildElement(graphic);
|
|
292
|
+
this.addChildElement(anchor);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Custom TextRun with gradient fill support.
|
|
297
|
+
* Extends the standard TextRun by adding w14:textFill to the run properties.
|
|
298
|
+
*/
|
|
299
|
+
class GradientTextRun extends XmlElement {
|
|
300
|
+
constructor(options) {
|
|
301
|
+
super("w:r");
|
|
302
|
+
// Add run properties (w:rPr)
|
|
303
|
+
const rPr = new XmlElement("w:rPr");
|
|
304
|
+
// Bold
|
|
305
|
+
if (options.bold) {
|
|
306
|
+
rPr.addChildElement(new XmlElement("w:b"));
|
|
307
|
+
}
|
|
308
|
+
// Italics
|
|
309
|
+
if (options.italics) {
|
|
310
|
+
rPr.addChildElement(new XmlElement("w:i"));
|
|
311
|
+
}
|
|
312
|
+
// Font size (in half-points)
|
|
313
|
+
if (options.size) {
|
|
314
|
+
rPr.addChildElement(new XmlElement("w:sz", { "w:val": options.size.toString() }));
|
|
315
|
+
rPr.addChildElement(new XmlElement("w:szCs", { "w:val": options.size.toString() }));
|
|
316
|
+
}
|
|
317
|
+
// Font family
|
|
318
|
+
if (options.font) {
|
|
319
|
+
rPr.addChildElement(new XmlElement("w:rFonts", {
|
|
320
|
+
"w:ascii": options.font,
|
|
321
|
+
"w:hAnsi": options.font,
|
|
322
|
+
"w:cs": options.font,
|
|
323
|
+
}));
|
|
324
|
+
}
|
|
325
|
+
// Add solid color fallback for LibreOffice compatibility
|
|
326
|
+
// LibreOffice ignores w14:textFill gradients but renders w:color
|
|
327
|
+
// Word will use the gradient (w14:textFill takes precedence over w:color)
|
|
328
|
+
const fallbackColor = options.fallbackColor || options.gradient.stops[0]?.color;
|
|
329
|
+
if (fallbackColor) {
|
|
330
|
+
rPr.addChildElement(new XmlElement("w:color", { "w:val": fallbackColor.toUpperCase() }));
|
|
331
|
+
}
|
|
332
|
+
// Add gradient text fill (Word uses this, LibreOffice ignores it)
|
|
333
|
+
rPr.addChildElement(new GradientTextFill(options.gradient));
|
|
334
|
+
this.addChildElement(rPr);
|
|
335
|
+
// Add text element (w:t)
|
|
336
|
+
const t = new XmlElement("w:t", { "xml:space": "preserve" });
|
|
337
|
+
t.addText(options.text);
|
|
338
|
+
this.addChildElement(t);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Convert TextAlignment to AlignmentType.
|
|
343
|
+
*/
|
|
344
|
+
function textAlignmentToDocx(alignment) {
|
|
345
|
+
if (!alignment)
|
|
346
|
+
return undefined;
|
|
347
|
+
switch (alignment) {
|
|
348
|
+
case "left":
|
|
349
|
+
return AlignmentType.LEFT;
|
|
350
|
+
case "center":
|
|
351
|
+
return AlignmentType.CENTER;
|
|
352
|
+
case "right":
|
|
353
|
+
return AlignmentType.RIGHT;
|
|
354
|
+
case "justify":
|
|
355
|
+
return AlignmentType.JUSTIFIED;
|
|
356
|
+
default:
|
|
357
|
+
return undefined;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Convert InlineRun array to TextRun array for docx.
|
|
362
|
+
* Uses shading for exact background color matching from HTML.
|
|
363
|
+
* Note: shading uses the exact hex color from HTML (no approximation).
|
|
364
|
+
* Handles line breaks (\n) by splitting into separate TextRuns with break: true.
|
|
365
|
+
* Optionally applies text-transform to all text.
|
|
366
|
+
* Supports gradient text fills using custom XmlComponent.
|
|
367
|
+
*/
|
|
368
|
+
function inlineRunsToTextRuns(runs, textTransform) {
|
|
369
|
+
const result = [];
|
|
370
|
+
// Helper to apply text transform
|
|
371
|
+
const applyTransform = (text) => {
|
|
372
|
+
if (!textTransform)
|
|
373
|
+
return text;
|
|
374
|
+
if (textTransform === "uppercase")
|
|
375
|
+
return text.toUpperCase();
|
|
376
|
+
if (textTransform === "lowercase")
|
|
377
|
+
return text.toLowerCase();
|
|
378
|
+
if (textTransform === "capitalize")
|
|
379
|
+
return text.replace(/\b\w/g, c => c.toUpperCase());
|
|
380
|
+
return text;
|
|
381
|
+
};
|
|
382
|
+
// Helper to convert underline type
|
|
383
|
+
const getUnderlineType = (type) => {
|
|
384
|
+
switch (type) {
|
|
385
|
+
case "single": return UnderlineType.SINGLE;
|
|
386
|
+
case "dotted": return UnderlineType.DOTTED;
|
|
387
|
+
case "double": return UnderlineType.DOUBLE;
|
|
388
|
+
case "wave": return UnderlineType.WAVE;
|
|
389
|
+
default: return UnderlineType.SINGLE;
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
for (const run of runs) {
|
|
393
|
+
// Split text by newlines to handle <br> tags
|
|
394
|
+
const parts = run.text.split('\n');
|
|
395
|
+
// Build underline config if present
|
|
396
|
+
const underlineConfig = run.underline ? {
|
|
397
|
+
type: getUnderlineType(run.underline.type),
|
|
398
|
+
color: run.underline.color,
|
|
399
|
+
} : undefined;
|
|
400
|
+
for (let i = 0; i < parts.length; i++) {
|
|
401
|
+
const part = applyTransform(parts[i]);
|
|
402
|
+
// Add a line break before this part (except for the first part)
|
|
403
|
+
if (i > 0) {
|
|
404
|
+
result.push(new TextRun({
|
|
405
|
+
break: 1,
|
|
406
|
+
bold: run.bold,
|
|
407
|
+
italics: run.italic,
|
|
408
|
+
color: run.color,
|
|
409
|
+
size: run.size,
|
|
410
|
+
font: run.fontFamily,
|
|
411
|
+
superScript: run.superscript,
|
|
412
|
+
subScript: run.subscript,
|
|
413
|
+
underline: underlineConfig,
|
|
414
|
+
shading: run.backgroundColor ? {
|
|
415
|
+
type: ShadingType.SOLID,
|
|
416
|
+
fill: run.backgroundColor,
|
|
417
|
+
color: run.backgroundColor,
|
|
418
|
+
} : undefined,
|
|
419
|
+
}));
|
|
420
|
+
}
|
|
421
|
+
// Add the text part (skip empty parts)
|
|
422
|
+
if (part) {
|
|
423
|
+
// Use GradientTextRun for gradient fills
|
|
424
|
+
if (run.gradient) {
|
|
425
|
+
result.push(new GradientTextRun({
|
|
426
|
+
text: part,
|
|
427
|
+
gradient: run.gradient,
|
|
428
|
+
bold: run.bold,
|
|
429
|
+
italics: run.italic,
|
|
430
|
+
size: run.size,
|
|
431
|
+
font: run.fontFamily,
|
|
432
|
+
fallbackColor: run.color, // Solid color fallback for LibreOffice
|
|
433
|
+
}));
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
result.push(new TextRun({
|
|
437
|
+
text: part,
|
|
438
|
+
bold: run.bold,
|
|
439
|
+
italics: run.italic,
|
|
440
|
+
color: run.color,
|
|
441
|
+
size: run.size,
|
|
442
|
+
font: run.fontFamily,
|
|
443
|
+
superScript: run.superscript,
|
|
444
|
+
subScript: run.subscript,
|
|
445
|
+
underline: underlineConfig,
|
|
446
|
+
shading: run.backgroundColor ? {
|
|
447
|
+
type: ShadingType.SOLID,
|
|
448
|
+
fill: run.backgroundColor,
|
|
449
|
+
color: run.backgroundColor,
|
|
450
|
+
} : undefined,
|
|
451
|
+
}));
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return result;
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Create a table row from cells.
|
|
460
|
+
*/
|
|
461
|
+
function createTableRow(cells, isHeaderRow, columnCount, cellPadding, headerBackgroundColor, headerTextColor, rowBackgroundColor) {
|
|
462
|
+
// Helper to convert underline type for table cells
|
|
463
|
+
const getUnderlineTypeForTable = (type) => {
|
|
464
|
+
switch (type) {
|
|
465
|
+
case "single": return UnderlineType.SINGLE;
|
|
466
|
+
case "dotted": return UnderlineType.DOTTED;
|
|
467
|
+
case "double": return UnderlineType.DOUBLE;
|
|
468
|
+
case "wave": return UnderlineType.WAVE;
|
|
469
|
+
default: return UnderlineType.SINGLE;
|
|
470
|
+
}
|
|
471
|
+
};
|
|
472
|
+
return new TableRow({
|
|
473
|
+
tableHeader: isHeaderRow,
|
|
474
|
+
children: cells.map((cell) => {
|
|
475
|
+
// Build text runs with formatting preserved
|
|
476
|
+
// Apply header text color if specified, otherwise use run's color or default
|
|
477
|
+
const textRuns = typeof cell === "string"
|
|
478
|
+
? [new TextRun({
|
|
479
|
+
text: cell,
|
|
480
|
+
bold: isHeaderRow,
|
|
481
|
+
color: isHeaderRow && headerTextColor ? headerTextColor : undefined,
|
|
482
|
+
})]
|
|
483
|
+
: cell.map(run => new TextRun({
|
|
484
|
+
text: run.text,
|
|
485
|
+
bold: isHeaderRow || run.bold,
|
|
486
|
+
italics: run.italic,
|
|
487
|
+
// Use header text color for header rows, otherwise use run's color
|
|
488
|
+
color: isHeaderRow && headerTextColor ? headerTextColor : run.color,
|
|
489
|
+
size: run.size,
|
|
490
|
+
font: run.fontFamily,
|
|
491
|
+
superScript: run.superscript,
|
|
492
|
+
subScript: run.subscript,
|
|
493
|
+
underline: run.underline ? {
|
|
494
|
+
type: getUnderlineTypeForTable(run.underline.type),
|
|
495
|
+
color: run.underline.color,
|
|
496
|
+
} : undefined,
|
|
497
|
+
}));
|
|
498
|
+
// Determine cell shading: header row uses headerBackgroundColor, even rows use rowBackgroundColor
|
|
499
|
+
let shading;
|
|
500
|
+
if (isHeaderRow && headerBackgroundColor) {
|
|
501
|
+
shading = {
|
|
502
|
+
type: ShadingType.SOLID,
|
|
503
|
+
color: headerBackgroundColor,
|
|
504
|
+
fill: headerBackgroundColor,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
else if (!isHeaderRow && rowBackgroundColor) {
|
|
508
|
+
shading = {
|
|
509
|
+
type: ShadingType.SOLID,
|
|
510
|
+
color: rowBackgroundColor,
|
|
511
|
+
fill: rowBackgroundColor,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
else if (isHeaderRow) {
|
|
515
|
+
// Default header shading if no custom color specified
|
|
516
|
+
shading = {
|
|
517
|
+
type: ShadingType.SOLID,
|
|
518
|
+
color: "F9FAFB",
|
|
519
|
+
fill: "F9FAFB",
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
return new DocxTableCell({
|
|
523
|
+
children: [
|
|
524
|
+
new Paragraph({
|
|
525
|
+
children: textRuns,
|
|
526
|
+
}),
|
|
527
|
+
],
|
|
528
|
+
width: {
|
|
529
|
+
size: 100 / columnCount,
|
|
530
|
+
type: WidthType.PERCENTAGE,
|
|
531
|
+
},
|
|
532
|
+
shading,
|
|
533
|
+
// Apply cell padding from CSS
|
|
534
|
+
margins: cellPadding ? {
|
|
535
|
+
top: cellPadding.top,
|
|
536
|
+
bottom: cellPadding.bottom,
|
|
537
|
+
left: cellPadding.left,
|
|
538
|
+
right: cellPadding.right,
|
|
539
|
+
} : undefined,
|
|
540
|
+
});
|
|
541
|
+
}),
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Create a paragraph for a list item.
|
|
546
|
+
* Indentation increases with nesting level:
|
|
547
|
+
* - Level 0: 720 twips (0.5") left indent
|
|
548
|
+
* - Level 1: 1440 twips (1.0") left indent
|
|
549
|
+
* - etc.
|
|
550
|
+
*/
|
|
551
|
+
function createListItemParagraph(item, ordered, level = 0) {
|
|
552
|
+
const children = typeof item === "string"
|
|
553
|
+
? [new TextRun({ text: item })]
|
|
554
|
+
: inlineRunsToTextRuns(item);
|
|
555
|
+
// Indentation increases by 720 twips (0.5") per level
|
|
556
|
+
// Base indent: 720 + (level * 720)
|
|
557
|
+
const leftIndent = 720 + (level * 720);
|
|
558
|
+
const indent = { left: leftIndent, hanging: 360 };
|
|
559
|
+
// List items use body text line-height (1.7)
|
|
560
|
+
const listSpacing = {
|
|
561
|
+
line: PARAGRAPH_SPACING.line,
|
|
562
|
+
lineRule: PARAGRAPH_SPACING.lineRule,
|
|
563
|
+
after: 60, // Small spacing between list items (~0.25em)
|
|
564
|
+
};
|
|
565
|
+
if (ordered) {
|
|
566
|
+
return new Paragraph({
|
|
567
|
+
children,
|
|
568
|
+
numbering: {
|
|
569
|
+
reference: "default-numbering",
|
|
570
|
+
level,
|
|
571
|
+
},
|
|
572
|
+
indent,
|
|
573
|
+
spacing: listSpacing,
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
return new Paragraph({
|
|
577
|
+
children,
|
|
578
|
+
bullet: {
|
|
579
|
+
level,
|
|
580
|
+
},
|
|
581
|
+
indent,
|
|
582
|
+
spacing: listSpacing,
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Convert a parsed element to docx paragraphs or tables.
|
|
587
|
+
*/
|
|
588
|
+
export function convertElementToDocx(element) {
|
|
589
|
+
switch (element.type) {
|
|
590
|
+
case "heading": {
|
|
591
|
+
const headingLevel = HEADING_LEVEL_MAP[element.level];
|
|
592
|
+
const alignment = textAlignmentToDocx(element.alignment);
|
|
593
|
+
// Get heading-specific spacing
|
|
594
|
+
const spacingKey = `h${element.level}`;
|
|
595
|
+
const headingSpacing = HEADING_SPACING[spacingKey] || { before: 240, after: 240 };
|
|
596
|
+
// Apply text-transform if specified
|
|
597
|
+
let displayText = element.text;
|
|
598
|
+
if (element.textTransform === "uppercase") {
|
|
599
|
+
displayText = displayText.toUpperCase();
|
|
600
|
+
}
|
|
601
|
+
else if (element.textTransform === "lowercase") {
|
|
602
|
+
displayText = displayText.toLowerCase();
|
|
603
|
+
}
|
|
604
|
+
else if (element.textTransform === "capitalize") {
|
|
605
|
+
displayText = displayText.replace(/\b\w/g, c => c.toUpperCase());
|
|
606
|
+
}
|
|
607
|
+
// Use runs array if available (for headings with inline badges/badges)
|
|
608
|
+
// Otherwise fallback to simple text with color
|
|
609
|
+
let children;
|
|
610
|
+
if (element.runs) {
|
|
611
|
+
children = inlineRunsToTextRuns(element.runs, element.textTransform);
|
|
612
|
+
}
|
|
613
|
+
else {
|
|
614
|
+
// Use color extracted from HTML if available, otherwise let DOCX use its default
|
|
615
|
+
const textRunOptions = { text: displayText };
|
|
616
|
+
if (element.color) {
|
|
617
|
+
textRunOptions.color = element.color;
|
|
618
|
+
}
|
|
619
|
+
// Apply font-family extracted from CSS (e.g., h1 { font-family: 'Source Serif Pro'; })
|
|
620
|
+
if (element.fontFamily) {
|
|
621
|
+
textRunOptions.font = element.fontFamily;
|
|
622
|
+
}
|
|
623
|
+
children = [new TextRun(textRunOptions)];
|
|
624
|
+
}
|
|
625
|
+
// Build paragraph options with optional border-bottom
|
|
626
|
+
const paragraphOptions = {
|
|
627
|
+
children,
|
|
628
|
+
heading: headingLevel,
|
|
629
|
+
alignment,
|
|
630
|
+
spacing: {
|
|
631
|
+
// GENERALIZED: Use lineSpacing from CSS if available, otherwise use default
|
|
632
|
+
line: element.lineSpacing !== undefined ? element.lineSpacing : HEADING_LINE_SPACING.line,
|
|
633
|
+
lineRule: HEADING_LINE_SPACING.lineRule,
|
|
634
|
+
before: headingSpacing.before,
|
|
635
|
+
after: element.spacingAfter !== undefined ? element.spacingAfter : headingSpacing.after,
|
|
636
|
+
},
|
|
637
|
+
};
|
|
638
|
+
// Add border-bottom if present (heading underline style)
|
|
639
|
+
if (element.borderBottom) {
|
|
640
|
+
paragraphOptions.border = {
|
|
641
|
+
bottom: { style: BorderStyle.SINGLE, size: 6, color: element.borderBottom },
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
return [new Paragraph(paragraphOptions)];
|
|
645
|
+
}
|
|
646
|
+
case "paragraph": {
|
|
647
|
+
// Apply text-transform if specified
|
|
648
|
+
let displayText = element.text;
|
|
649
|
+
if (element.textTransform === "uppercase") {
|
|
650
|
+
displayText = displayText.toUpperCase();
|
|
651
|
+
}
|
|
652
|
+
else if (element.textTransform === "lowercase") {
|
|
653
|
+
displayText = displayText.toLowerCase();
|
|
654
|
+
}
|
|
655
|
+
else if (element.textTransform === "capitalize") {
|
|
656
|
+
displayText = displayText.replace(/\b\w/g, c => c.toUpperCase());
|
|
657
|
+
}
|
|
658
|
+
// Use runs array if available for inline formatting, otherwise fallback to simple text
|
|
659
|
+
const children = element.runs
|
|
660
|
+
? inlineRunsToTextRuns(element.runs, element.textTransform)
|
|
661
|
+
: [
|
|
662
|
+
new TextRun({
|
|
663
|
+
text: displayText,
|
|
664
|
+
bold: element.bold,
|
|
665
|
+
italics: element.italic,
|
|
666
|
+
color: element.color,
|
|
667
|
+
font: element.fontFamily,
|
|
668
|
+
}),
|
|
669
|
+
];
|
|
670
|
+
const alignment = textAlignmentToDocx(element.alignment);
|
|
671
|
+
// Build indent configuration
|
|
672
|
+
const indent = {};
|
|
673
|
+
if (element.firstLineIndent) {
|
|
674
|
+
indent.firstLine = element.firstLineIndent;
|
|
675
|
+
}
|
|
676
|
+
if (element.hangingIndent) {
|
|
677
|
+
// Hanging indent: left margin equals the hanging amount, first line outdented
|
|
678
|
+
indent.hanging = element.hangingIndent;
|
|
679
|
+
indent.left = element.hangingIndent;
|
|
680
|
+
}
|
|
681
|
+
// GENERALIZED: Use element's spacingAfter if provided, otherwise default
|
|
682
|
+
const afterSpacing = element.spacingAfter !== undefined ? element.spacingAfter : PARAGRAPH_SPACING.after;
|
|
683
|
+
// GENERALIZED: Use element's lineSpacing if provided, otherwise default
|
|
684
|
+
const lineSpacing = element.lineSpacing !== undefined ? element.lineSpacing : PARAGRAPH_SPACING.line;
|
|
685
|
+
return [
|
|
686
|
+
new Paragraph({
|
|
687
|
+
children,
|
|
688
|
+
alignment,
|
|
689
|
+
indent: Object.keys(indent).length > 0 ? indent : undefined,
|
|
690
|
+
spacing: {
|
|
691
|
+
line: lineSpacing,
|
|
692
|
+
lineRule: PARAGRAPH_SPACING.lineRule,
|
|
693
|
+
after: afterSpacing,
|
|
694
|
+
},
|
|
695
|
+
}),
|
|
696
|
+
];
|
|
697
|
+
}
|
|
698
|
+
case "list": {
|
|
699
|
+
return element.items.map((item) => {
|
|
700
|
+
// Check if item is a ListItem with level information
|
|
701
|
+
if (typeof item === "object" && "level" in item && "ordered" in item) {
|
|
702
|
+
const listItem = item;
|
|
703
|
+
return createListItemParagraph(listItem.content, listItem.ordered, listItem.level);
|
|
704
|
+
}
|
|
705
|
+
// Legacy format: string or InlineRun[] without level info
|
|
706
|
+
return createListItemParagraph(item, element.ordered, 0);
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
case "table": {
|
|
710
|
+
// For noBorders tables (like flex column layouts), don't apply header styling
|
|
711
|
+
// For regular tables (with borders), default to treating first row as header unless explicitly disabled
|
|
712
|
+
const useHeaderStyling = element.noBorders ? false : (element.hasHeader !== false);
|
|
713
|
+
const tableRows = element.rows.map((row, rowIndex) => createTableRow(row, useHeaderStyling && rowIndex === 0, row.length, element.cellPadding, element.headerBackgroundColor, element.headerTextColor,
|
|
714
|
+
// Apply even row background color for alternating row styling (rowIndex > 0 and odd = even rows in 0-indexed)
|
|
715
|
+
rowIndex > 0 && rowIndex % 2 === 0 ? element.evenRowBackgroundColor : undefined));
|
|
716
|
+
// Default table border color (light gray), or no borders
|
|
717
|
+
const borderColor = "E5E7EB";
|
|
718
|
+
const noBorderStyle = { style: BorderStyle.NONE, size: 0, color: "FFFFFF" };
|
|
719
|
+
const borderStyle = element.noBorders
|
|
720
|
+
? noBorderStyle
|
|
721
|
+
: { style: BorderStyle.SINGLE, size: 4, color: borderColor };
|
|
722
|
+
const dataTable = new Table({
|
|
723
|
+
rows: tableRows,
|
|
724
|
+
width: {
|
|
725
|
+
size: 100,
|
|
726
|
+
type: WidthType.PERCENTAGE,
|
|
727
|
+
},
|
|
728
|
+
borders: {
|
|
729
|
+
top: borderStyle,
|
|
730
|
+
bottom: borderStyle,
|
|
731
|
+
left: borderStyle,
|
|
732
|
+
right: borderStyle,
|
|
733
|
+
insideHorizontal: borderStyle,
|
|
734
|
+
insideVertical: borderStyle,
|
|
735
|
+
},
|
|
736
|
+
});
|
|
737
|
+
// Add spacing paragraphs before and after the table to ensure proper separation
|
|
738
|
+
// from surrounding content (especially other tables)
|
|
739
|
+
const spacingBefore = new Paragraph({
|
|
740
|
+
children: [],
|
|
741
|
+
spacing: { before: 200, after: 0 },
|
|
742
|
+
});
|
|
743
|
+
const spacingAfter = new Paragraph({
|
|
744
|
+
children: [],
|
|
745
|
+
spacing: { before: 0, after: 200 },
|
|
746
|
+
});
|
|
747
|
+
return [spacingBefore, dataTable, spacingAfter];
|
|
748
|
+
}
|
|
749
|
+
case "code": {
|
|
750
|
+
return [
|
|
751
|
+
new Paragraph({
|
|
752
|
+
children: [
|
|
753
|
+
new TextRun({
|
|
754
|
+
text: element.text,
|
|
755
|
+
font: "Courier New",
|
|
756
|
+
size: 20,
|
|
757
|
+
}),
|
|
758
|
+
],
|
|
759
|
+
border: {
|
|
760
|
+
top: { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" },
|
|
761
|
+
bottom: { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" },
|
|
762
|
+
left: { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" },
|
|
763
|
+
right: { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" },
|
|
764
|
+
},
|
|
765
|
+
shading: {
|
|
766
|
+
fill: "F5F5F5",
|
|
767
|
+
},
|
|
768
|
+
}),
|
|
769
|
+
];
|
|
770
|
+
}
|
|
771
|
+
case "blockquote": {
|
|
772
|
+
// Render blockquote/callout as a single-cell table to create a "box" effect
|
|
773
|
+
// This ensures consistent background shading and proper containment of content
|
|
774
|
+
// For gradient backgrounds, we use a DrawingML shape anchor with behindDoc="1"
|
|
775
|
+
const borderColor = element.borderColor || "CCCCCC";
|
|
776
|
+
const backgroundColor = element.backgroundColor || "FFFFFF";
|
|
777
|
+
const backgroundGradient = element.backgroundGradient;
|
|
778
|
+
const isCallout = element.variant === "callout";
|
|
779
|
+
const hasFullBorder = element.borderStyle === "full";
|
|
780
|
+
const hasNoBorder = element.borderStyle === "none";
|
|
781
|
+
// Callout label uses darker blue color (#1d4ed8)
|
|
782
|
+
const labelColor = isCallout ? "1D4ED8" : borderColor;
|
|
783
|
+
// Convert all inner content to paragraphs for the cell
|
|
784
|
+
const cellContent = [];
|
|
785
|
+
for (const innerElement of element.content) {
|
|
786
|
+
if (innerElement.type === "paragraph") {
|
|
787
|
+
// Use the paragraph's own color if available (from CSS like .cta p { color: white; })
|
|
788
|
+
// For bold paragraphs (like callout labels), fall back to label color
|
|
789
|
+
const shouldApplyLabelColor = innerElement.bold && !innerElement.color;
|
|
790
|
+
const paragraphColor = innerElement.color || (shouldApplyLabelColor ? labelColor : undefined);
|
|
791
|
+
const textChildren = innerElement.runs
|
|
792
|
+
? inlineRunsToTextRuns(innerElement.runs)
|
|
793
|
+
: [
|
|
794
|
+
new TextRun({
|
|
795
|
+
text: innerElement.text,
|
|
796
|
+
bold: innerElement.bold,
|
|
797
|
+
italics: innerElement.italic,
|
|
798
|
+
color: paragraphColor,
|
|
799
|
+
}),
|
|
800
|
+
];
|
|
801
|
+
cellContent.push(new Paragraph({
|
|
802
|
+
children: textChildren,
|
|
803
|
+
spacing: {
|
|
804
|
+
line: PARAGRAPH_SPACING.line,
|
|
805
|
+
lineRule: PARAGRAPH_SPACING.lineRule,
|
|
806
|
+
after: 120,
|
|
807
|
+
}, // Space between elements inside the box
|
|
808
|
+
}));
|
|
809
|
+
}
|
|
810
|
+
else if (innerElement.type === "list") {
|
|
811
|
+
// Render list items inside the cell
|
|
812
|
+
// GENERALIZED: Respect ordered vs unordered list type from HTML
|
|
813
|
+
for (let i = 0; i < innerElement.items.length; i++) {
|
|
814
|
+
const item = innerElement.items[i];
|
|
815
|
+
// Determine content and whether ordered based on item type
|
|
816
|
+
let content;
|
|
817
|
+
let isOrdered;
|
|
818
|
+
let level = 0;
|
|
819
|
+
if (typeof item === "object" && "level" in item && "ordered" in item) {
|
|
820
|
+
// ListItem with level info
|
|
821
|
+
const listItem = item;
|
|
822
|
+
content = listItem.content;
|
|
823
|
+
isOrdered = listItem.ordered;
|
|
824
|
+
level = listItem.level;
|
|
825
|
+
}
|
|
826
|
+
else {
|
|
827
|
+
// Legacy format: string or InlineRun[]
|
|
828
|
+
content = item;
|
|
829
|
+
isOrdered = innerElement.ordered;
|
|
830
|
+
}
|
|
831
|
+
const textChildren = typeof content === "string"
|
|
832
|
+
? [new TextRun({ text: content })]
|
|
833
|
+
: inlineRunsToTextRuns(content);
|
|
834
|
+
// Use number or bullet based on list type (extracted from HTML)
|
|
835
|
+
const prefix = isOrdered
|
|
836
|
+
? `${i + 1}. ` // Numbered: "1. ", "2. ", etc.
|
|
837
|
+
: "• "; // Bullet: "• "
|
|
838
|
+
const prefixRun = new TextRun({ text: prefix });
|
|
839
|
+
const children = [prefixRun, ...textChildren];
|
|
840
|
+
// Indent based on level
|
|
841
|
+
const leftIndent = 240 + (level * 240);
|
|
842
|
+
cellContent.push(new Paragraph({
|
|
843
|
+
children,
|
|
844
|
+
indent: { left: leftIndent }, // Indent list items within cell
|
|
845
|
+
spacing: {
|
|
846
|
+
line: PARAGRAPH_SPACING.line,
|
|
847
|
+
lineRule: PARAGRAPH_SPACING.lineRule,
|
|
848
|
+
after: 100,
|
|
849
|
+
}, // Space between list items
|
|
850
|
+
}));
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
else if (innerElement.type === "heading") {
|
|
854
|
+
// Handle headings inside blockquotes
|
|
855
|
+
// Use the heading's own color if available (from CSS like .key-takeaways h3 { color: ... })
|
|
856
|
+
// Otherwise fall back to the blockquote's border color
|
|
857
|
+
const headingColor = innerElement.color || borderColor;
|
|
858
|
+
const textChildren = innerElement.runs
|
|
859
|
+
? inlineRunsToTextRuns(innerElement.runs)
|
|
860
|
+
: [
|
|
861
|
+
new TextRun({
|
|
862
|
+
text: innerElement.text,
|
|
863
|
+
color: headingColor,
|
|
864
|
+
}),
|
|
865
|
+
];
|
|
866
|
+
cellContent.push(new Paragraph({
|
|
867
|
+
children: textChildren,
|
|
868
|
+
heading: HEADING_LEVEL_MAP[innerElement.level],
|
|
869
|
+
spacing: {
|
|
870
|
+
line: HEADING_LINE_SPACING.line,
|
|
871
|
+
lineRule: HEADING_LINE_SPACING.lineRule,
|
|
872
|
+
after: 120,
|
|
873
|
+
},
|
|
874
|
+
}));
|
|
875
|
+
}
|
|
876
|
+
else if (innerElement.type === "table") {
|
|
877
|
+
// GENERALIZED: Handle tables inside blockquotes (e.g., horizontal flex containers)
|
|
878
|
+
// For tables with noBorders (horizontal flex), render as inline text within a paragraph
|
|
879
|
+
// to maintain the horizontal layout without nested table complexity
|
|
880
|
+
if (innerElement.noBorders && innerElement.rows.length === 1) {
|
|
881
|
+
// Single-row borderless table = horizontal flex layout
|
|
882
|
+
// Render as a single paragraph with tab-separated content
|
|
883
|
+
const row = innerElement.rows[0];
|
|
884
|
+
const children = [];
|
|
885
|
+
for (let cellIndex = 0; cellIndex < row.length; cellIndex++) {
|
|
886
|
+
const cell = row[cellIndex];
|
|
887
|
+
if (typeof cell === "string") {
|
|
888
|
+
children.push(new TextRun({ text: cell }));
|
|
889
|
+
}
|
|
890
|
+
else {
|
|
891
|
+
// InlineRun array
|
|
892
|
+
children.push(...inlineRunsToTextRuns(cell));
|
|
893
|
+
}
|
|
894
|
+
// Add tab separator between cells (except after last cell)
|
|
895
|
+
if (cellIndex < row.length - 1) {
|
|
896
|
+
children.push(new TextRun({ text: "\t" }));
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
cellContent.push(new Paragraph({
|
|
900
|
+
children,
|
|
901
|
+
spacing: {
|
|
902
|
+
line: PARAGRAPH_SPACING.line,
|
|
903
|
+
lineRule: PARAGRAPH_SPACING.lineRule,
|
|
904
|
+
after: 120,
|
|
905
|
+
},
|
|
906
|
+
}));
|
|
907
|
+
}
|
|
908
|
+
// For regular tables inside blockquotes, we could add full table rendering,
|
|
909
|
+
// but for now we skip them as nested tables in Word can be problematic
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
// Create table to act as the box
|
|
913
|
+
// For gradient backgrounds, the DrawingML shape is already inserted in the first paragraph
|
|
914
|
+
// Cell margins provide the padding effect
|
|
915
|
+
// Cell shading provides the background color (solid fallback)
|
|
916
|
+
// Table/cell borders provide the border effect
|
|
917
|
+
// Build the table rows - always a single row
|
|
918
|
+
const tableRows = [];
|
|
919
|
+
const finalCellContent = cellContent.length > 0
|
|
920
|
+
? cellContent
|
|
921
|
+
: [new Paragraph({ children: [] })];
|
|
922
|
+
// Single row with solid background (unless we have a gradient, which provides its own fill)
|
|
923
|
+
// When using gradient, don't apply shading - the gradient drawing covers the cell
|
|
924
|
+
// For gradient backgrounds, use the midpoint color as a solid fallback
|
|
925
|
+
// LibreOffice doesn't support gradient cell backgrounds, and VML shapes
|
|
926
|
+
// can't auto-size to content height. A solid color ensures correct height behavior.
|
|
927
|
+
let effectiveBackgroundColor = backgroundColor;
|
|
928
|
+
if (backgroundGradient && hasNoBorder) {
|
|
929
|
+
// Use the color at ~40% position (second stop in typical gradients)
|
|
930
|
+
const sortedStops = [...backgroundGradient.stops].sort((a, b) => a.position - b.position);
|
|
931
|
+
if (sortedStops.length >= 2) {
|
|
932
|
+
effectiveBackgroundColor = sortedStops[1]?.color || sortedStops[0]?.color || backgroundColor;
|
|
933
|
+
}
|
|
934
|
+
else if (sortedStops.length === 1) {
|
|
935
|
+
effectiveBackgroundColor = sortedStops[0]?.color || backgroundColor;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
const cellShading = {
|
|
939
|
+
type: ShadingType.SOLID,
|
|
940
|
+
color: effectiveBackgroundColor,
|
|
941
|
+
fill: effectiveBackgroundColor,
|
|
942
|
+
};
|
|
943
|
+
tableRows.push(new TableRow({
|
|
944
|
+
children: [
|
|
945
|
+
new DocxTableCell({
|
|
946
|
+
children: finalCellContent,
|
|
947
|
+
shading: cellShading,
|
|
948
|
+
margins: {
|
|
949
|
+
top: convertInchesToTwip(0.25), // ~18pt padding top
|
|
950
|
+
bottom: convertInchesToTwip(0.25), // ~18pt padding bottom
|
|
951
|
+
left: convertInchesToTwip(0.35), // ~25pt padding left (after border)
|
|
952
|
+
right: convertInchesToTwip(0.35), // ~25pt padding right
|
|
953
|
+
},
|
|
954
|
+
borders: hasNoBorder
|
|
955
|
+
? {
|
|
956
|
+
// No border - just background color (for title blocks, hero sections)
|
|
957
|
+
top: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
|
|
958
|
+
bottom: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
|
|
959
|
+
left: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
|
|
960
|
+
right: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
|
|
961
|
+
}
|
|
962
|
+
: hasFullBorder
|
|
963
|
+
? {
|
|
964
|
+
// Full border on all sides (for image placeholders, etc.)
|
|
965
|
+
top: { style: BorderStyle.SINGLE, size: 6, color: borderColor },
|
|
966
|
+
bottom: { style: BorderStyle.SINGLE, size: 6, color: borderColor },
|
|
967
|
+
left: { style: BorderStyle.SINGLE, size: 6, color: borderColor },
|
|
968
|
+
right: { style: BorderStyle.SINGLE, size: 6, color: borderColor },
|
|
969
|
+
}
|
|
970
|
+
: {
|
|
971
|
+
// Left accent border only (callout style)
|
|
972
|
+
top: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
|
|
973
|
+
bottom: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
|
|
974
|
+
left: { style: BorderStyle.SINGLE, size: 24, color: borderColor }, // Thick left border
|
|
975
|
+
right: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
|
|
976
|
+
},
|
|
977
|
+
}),
|
|
978
|
+
],
|
|
979
|
+
}));
|
|
980
|
+
const boxTable = new Table({
|
|
981
|
+
rows: tableRows,
|
|
982
|
+
width: {
|
|
983
|
+
size: 100,
|
|
984
|
+
type: WidthType.PERCENTAGE,
|
|
985
|
+
},
|
|
986
|
+
// Remove outer table borders - we only want the cell's left border
|
|
987
|
+
borders: {
|
|
988
|
+
top: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
|
|
989
|
+
bottom: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
|
|
990
|
+
left: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
|
|
991
|
+
right: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
|
|
992
|
+
insideHorizontal: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
|
|
993
|
+
insideVertical: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
|
|
994
|
+
},
|
|
995
|
+
});
|
|
996
|
+
// Add spacing paragraphs before and after the table to ensure proper separation
|
|
997
|
+
// from surrounding content (especially other tables)
|
|
998
|
+
const spacingBefore = new Paragraph({
|
|
999
|
+
children: [],
|
|
1000
|
+
spacing: { before: 240, after: 0 }, // ~12pt spacing before the callout box
|
|
1001
|
+
});
|
|
1002
|
+
const spacingAfter = new Paragraph({
|
|
1003
|
+
children: [],
|
|
1004
|
+
spacing: { before: 0, after: 240 }, // ~12pt spacing after the callout box
|
|
1005
|
+
});
|
|
1006
|
+
return [spacingBefore, boxTable, spacingAfter];
|
|
1007
|
+
}
|
|
1008
|
+
case "chart-placeholder": {
|
|
1009
|
+
// Render a placeholder for charts that can't be converted
|
|
1010
|
+
const text = element.title ? `[Chart: ${element.title}]` : "[Chart]";
|
|
1011
|
+
return [
|
|
1012
|
+
new Paragraph({
|
|
1013
|
+
children: [
|
|
1014
|
+
new TextRun({
|
|
1015
|
+
text,
|
|
1016
|
+
italics: true,
|
|
1017
|
+
color: "808080",
|
|
1018
|
+
}),
|
|
1019
|
+
],
|
|
1020
|
+
alignment: AlignmentType.CENTER,
|
|
1021
|
+
border: {
|
|
1022
|
+
top: { style: BorderStyle.SINGLE, size: 1, color: "E5E7EB" },
|
|
1023
|
+
bottom: { style: BorderStyle.SINGLE, size: 1, color: "E5E7EB" },
|
|
1024
|
+
left: { style: BorderStyle.SINGLE, size: 1, color: "E5E7EB" },
|
|
1025
|
+
right: { style: BorderStyle.SINGLE, size: 1, color: "E5E7EB" },
|
|
1026
|
+
},
|
|
1027
|
+
shading: {
|
|
1028
|
+
type: ShadingType.SOLID,
|
|
1029
|
+
color: "F9FAFB",
|
|
1030
|
+
fill: "F9FAFB",
|
|
1031
|
+
},
|
|
1032
|
+
spacing: {
|
|
1033
|
+
before: 200,
|
|
1034
|
+
after: 200,
|
|
1035
|
+
},
|
|
1036
|
+
}),
|
|
1037
|
+
];
|
|
1038
|
+
}
|
|
1039
|
+
case "svg-chart": {
|
|
1040
|
+
// SVG charts need to be converted to images before reaching here
|
|
1041
|
+
// Fall back to placeholder if not converted
|
|
1042
|
+
const text = element.title ? `[Chart: ${element.title}]` : "[Chart]";
|
|
1043
|
+
return [
|
|
1044
|
+
new Paragraph({
|
|
1045
|
+
children: [
|
|
1046
|
+
new TextRun({
|
|
1047
|
+
text,
|
|
1048
|
+
italics: true,
|
|
1049
|
+
color: "808080",
|
|
1050
|
+
}),
|
|
1051
|
+
],
|
|
1052
|
+
alignment: AlignmentType.CENTER,
|
|
1053
|
+
border: {
|
|
1054
|
+
top: { style: BorderStyle.SINGLE, size: 1, color: "E5E7EB" },
|
|
1055
|
+
bottom: { style: BorderStyle.SINGLE, size: 1, color: "E5E7EB" },
|
|
1056
|
+
left: { style: BorderStyle.SINGLE, size: 1, color: "E5E7EB" },
|
|
1057
|
+
right: { style: BorderStyle.SINGLE, size: 1, color: "E5E7EB" },
|
|
1058
|
+
},
|
|
1059
|
+
shading: {
|
|
1060
|
+
type: ShadingType.SOLID,
|
|
1061
|
+
color: "F9FAFB",
|
|
1062
|
+
fill: "F9FAFB",
|
|
1063
|
+
},
|
|
1064
|
+
spacing: {
|
|
1065
|
+
before: 200,
|
|
1066
|
+
after: 200,
|
|
1067
|
+
},
|
|
1068
|
+
}),
|
|
1069
|
+
];
|
|
1070
|
+
}
|
|
1071
|
+
case "chart-image": {
|
|
1072
|
+
// Render chart as an embedded image
|
|
1073
|
+
const { imageData } = element;
|
|
1074
|
+
return [
|
|
1075
|
+
new Paragraph({
|
|
1076
|
+
children: [
|
|
1077
|
+
new ImageRun({
|
|
1078
|
+
data: imageData.data,
|
|
1079
|
+
transformation: {
|
|
1080
|
+
width: imageData.width,
|
|
1081
|
+
height: imageData.height,
|
|
1082
|
+
},
|
|
1083
|
+
type: "png",
|
|
1084
|
+
}),
|
|
1085
|
+
],
|
|
1086
|
+
alignment: AlignmentType.CENTER,
|
|
1087
|
+
spacing: {
|
|
1088
|
+
before: 200,
|
|
1089
|
+
after: 200,
|
|
1090
|
+
},
|
|
1091
|
+
}),
|
|
1092
|
+
];
|
|
1093
|
+
}
|
|
1094
|
+
case "image": {
|
|
1095
|
+
// Render external image as an embedded image
|
|
1096
|
+
// Images must have their imageData populated by the fetch step before conversion
|
|
1097
|
+
const { imageData, caption, alt } = element;
|
|
1098
|
+
// If imageData is not available, render a placeholder with the alt text
|
|
1099
|
+
if (!imageData) {
|
|
1100
|
+
const placeholderText = alt || "[Image]";
|
|
1101
|
+
return [
|
|
1102
|
+
new Paragraph({
|
|
1103
|
+
children: [
|
|
1104
|
+
new TextRun({
|
|
1105
|
+
text: placeholderText,
|
|
1106
|
+
italics: true,
|
|
1107
|
+
color: "6B7280", // Gray text for placeholder
|
|
1108
|
+
}),
|
|
1109
|
+
],
|
|
1110
|
+
alignment: AlignmentType.CENTER,
|
|
1111
|
+
spacing: {
|
|
1112
|
+
before: 200,
|
|
1113
|
+
after: caption ? 80 : 200,
|
|
1114
|
+
},
|
|
1115
|
+
}),
|
|
1116
|
+
...(caption
|
|
1117
|
+
? [
|
|
1118
|
+
new Paragraph({
|
|
1119
|
+
children: [
|
|
1120
|
+
new TextRun({
|
|
1121
|
+
text: caption,
|
|
1122
|
+
italics: true,
|
|
1123
|
+
size: 20, // 10pt
|
|
1124
|
+
color: "6B7280",
|
|
1125
|
+
}),
|
|
1126
|
+
],
|
|
1127
|
+
alignment: AlignmentType.CENTER,
|
|
1128
|
+
spacing: {
|
|
1129
|
+
after: 200,
|
|
1130
|
+
},
|
|
1131
|
+
}),
|
|
1132
|
+
]
|
|
1133
|
+
: []),
|
|
1134
|
+
];
|
|
1135
|
+
}
|
|
1136
|
+
// Render the actual image
|
|
1137
|
+
const paragraphs = [
|
|
1138
|
+
new Paragraph({
|
|
1139
|
+
children: [
|
|
1140
|
+
new ImageRun({
|
|
1141
|
+
data: imageData.data,
|
|
1142
|
+
transformation: {
|
|
1143
|
+
width: imageData.width,
|
|
1144
|
+
height: imageData.height,
|
|
1145
|
+
},
|
|
1146
|
+
type: "png",
|
|
1147
|
+
}),
|
|
1148
|
+
],
|
|
1149
|
+
alignment: AlignmentType.CENTER,
|
|
1150
|
+
spacing: {
|
|
1151
|
+
before: 200,
|
|
1152
|
+
after: caption ? 80 : 200,
|
|
1153
|
+
},
|
|
1154
|
+
}),
|
|
1155
|
+
];
|
|
1156
|
+
// Add caption if present
|
|
1157
|
+
if (caption) {
|
|
1158
|
+
paragraphs.push(new Paragraph({
|
|
1159
|
+
children: [
|
|
1160
|
+
new TextRun({
|
|
1161
|
+
text: caption,
|
|
1162
|
+
italics: true,
|
|
1163
|
+
size: 20, // 10pt
|
|
1164
|
+
color: "6B7280",
|
|
1165
|
+
}),
|
|
1166
|
+
],
|
|
1167
|
+
alignment: AlignmentType.CENTER,
|
|
1168
|
+
spacing: {
|
|
1169
|
+
after: 200,
|
|
1170
|
+
},
|
|
1171
|
+
}));
|
|
1172
|
+
}
|
|
1173
|
+
return paragraphs;
|
|
1174
|
+
}
|
|
1175
|
+
case "horizontal-rule": {
|
|
1176
|
+
// Render a horizontal rule as a paragraph with a border
|
|
1177
|
+
const borderColor = element.color || "E5E7EB"; // Default to light gray
|
|
1178
|
+
// Use spacingBefore/After if provided, default to 240 for standalone rules
|
|
1179
|
+
const spacingBefore = element.spacingBefore !== undefined ? element.spacingBefore : 240;
|
|
1180
|
+
const spacingAfter = element.spacingAfter !== undefined ? element.spacingAfter : 240;
|
|
1181
|
+
// Use top or bottom border based on position (default to bottom for standalone HR)
|
|
1182
|
+
const borderPosition = element.borderPosition || "bottom";
|
|
1183
|
+
const border = borderPosition === "top"
|
|
1184
|
+
? { top: { style: BorderStyle.SINGLE, size: 6, color: borderColor } }
|
|
1185
|
+
: { bottom: { style: BorderStyle.SINGLE, size: 6, color: borderColor } };
|
|
1186
|
+
return [
|
|
1187
|
+
new Paragraph({
|
|
1188
|
+
children: [],
|
|
1189
|
+
border,
|
|
1190
|
+
spacing: {
|
|
1191
|
+
before: spacingBefore,
|
|
1192
|
+
after: spacingAfter,
|
|
1193
|
+
},
|
|
1194
|
+
}),
|
|
1195
|
+
];
|
|
1196
|
+
}
|
|
1197
|
+
case "stats-grid": {
|
|
1198
|
+
// Render stats grid as a table with multiple columns
|
|
1199
|
+
// Each stat card becomes a cell with value (large, colored), label (muted), and optional change text (trend indicator)
|
|
1200
|
+
const { cards } = element;
|
|
1201
|
+
if (cards.length === 0)
|
|
1202
|
+
return [];
|
|
1203
|
+
// Create a single-row table with one cell per card
|
|
1204
|
+
const tableCells = cards.map((card) => {
|
|
1205
|
+
// Cell content: value paragraph + label paragraph + optional change paragraph
|
|
1206
|
+
const cellContent = [
|
|
1207
|
+
new Paragraph({
|
|
1208
|
+
children: [
|
|
1209
|
+
new TextRun({
|
|
1210
|
+
text: card.value,
|
|
1211
|
+
bold: true,
|
|
1212
|
+
size: 36, // Large font for stat value (18pt)
|
|
1213
|
+
color: card.valueColor || "2563EB", // Blue default
|
|
1214
|
+
}),
|
|
1215
|
+
],
|
|
1216
|
+
alignment: AlignmentType.CENTER,
|
|
1217
|
+
spacing: { after: 80 },
|
|
1218
|
+
}),
|
|
1219
|
+
new Paragraph({
|
|
1220
|
+
children: [
|
|
1221
|
+
new TextRun({
|
|
1222
|
+
text: card.label,
|
|
1223
|
+
size: 20, // Smaller font for label (10pt)
|
|
1224
|
+
color: card.labelColor || "6B7280", // Gray default
|
|
1225
|
+
}),
|
|
1226
|
+
],
|
|
1227
|
+
alignment: AlignmentType.CENTER,
|
|
1228
|
+
spacing: { after: card.change ? 60 : 0 },
|
|
1229
|
+
}),
|
|
1230
|
+
];
|
|
1231
|
+
// Add change text if present (e.g., "↑ 8% vs Q4 2024")
|
|
1232
|
+
if (card.change) {
|
|
1233
|
+
cellContent.push(new Paragraph({
|
|
1234
|
+
children: [
|
|
1235
|
+
new TextRun({
|
|
1236
|
+
text: card.change,
|
|
1237
|
+
size: 16, // Even smaller font for change indicator (8pt)
|
|
1238
|
+
bold: true,
|
|
1239
|
+
color: card.changeColor || "10B981", // Green default for positive changes
|
|
1240
|
+
}),
|
|
1241
|
+
],
|
|
1242
|
+
alignment: AlignmentType.CENTER,
|
|
1243
|
+
}));
|
|
1244
|
+
}
|
|
1245
|
+
return new DocxTableCell({
|
|
1246
|
+
children: cellContent,
|
|
1247
|
+
width: {
|
|
1248
|
+
size: Math.floor(100 / cards.length),
|
|
1249
|
+
type: WidthType.PERCENTAGE,
|
|
1250
|
+
},
|
|
1251
|
+
margins: {
|
|
1252
|
+
top: convertInchesToTwip(0.15),
|
|
1253
|
+
bottom: convertInchesToTwip(0.15),
|
|
1254
|
+
left: convertInchesToTwip(0.1),
|
|
1255
|
+
right: convertInchesToTwip(0.1),
|
|
1256
|
+
},
|
|
1257
|
+
shading: card.backgroundColor ? {
|
|
1258
|
+
type: ShadingType.SOLID,
|
|
1259
|
+
color: card.backgroundColor,
|
|
1260
|
+
fill: card.backgroundColor,
|
|
1261
|
+
} : undefined,
|
|
1262
|
+
borders: card.borderColor ? {
|
|
1263
|
+
top: { style: BorderStyle.SINGLE, size: 4, color: card.borderColor },
|
|
1264
|
+
bottom: { style: BorderStyle.SINGLE, size: 4, color: card.borderColor },
|
|
1265
|
+
left: { style: BorderStyle.SINGLE, size: 4, color: card.borderColor },
|
|
1266
|
+
right: { style: BorderStyle.SINGLE, size: 4, color: card.borderColor },
|
|
1267
|
+
} : {
|
|
1268
|
+
top: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
|
|
1269
|
+
bottom: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
|
|
1270
|
+
left: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
|
|
1271
|
+
right: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
|
|
1272
|
+
},
|
|
1273
|
+
});
|
|
1274
|
+
});
|
|
1275
|
+
const statsTable = new Table({
|
|
1276
|
+
rows: [
|
|
1277
|
+
new TableRow({
|
|
1278
|
+
children: tableCells,
|
|
1279
|
+
}),
|
|
1280
|
+
],
|
|
1281
|
+
width: {
|
|
1282
|
+
size: 100,
|
|
1283
|
+
type: WidthType.PERCENTAGE,
|
|
1284
|
+
},
|
|
1285
|
+
borders: {
|
|
1286
|
+
top: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
|
|
1287
|
+
bottom: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
|
|
1288
|
+
left: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
|
|
1289
|
+
right: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
|
|
1290
|
+
insideHorizontal: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
|
|
1291
|
+
insideVertical: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
|
|
1292
|
+
},
|
|
1293
|
+
});
|
|
1294
|
+
// Add spacing paragraphs before and after the stats grid to ensure proper separation
|
|
1295
|
+
// from surrounding content (especially other tables like blockquotes)
|
|
1296
|
+
const spacingBefore = new Paragraph({
|
|
1297
|
+
children: [],
|
|
1298
|
+
spacing: { before: 240, after: 0 }, // ~12pt spacing before the stats grid
|
|
1299
|
+
});
|
|
1300
|
+
const spacingAfter = new Paragraph({
|
|
1301
|
+
children: [],
|
|
1302
|
+
spacing: { before: 0, after: 240 }, // ~12pt spacing after the stats grid
|
|
1303
|
+
});
|
|
1304
|
+
return [spacingBefore, statsTable, spacingAfter];
|
|
1305
|
+
}
|
|
1306
|
+
case "two-column-layout": {
|
|
1307
|
+
// Render two-column layout as a table with sidebar and main content columns
|
|
1308
|
+
const { sidebar, main } = element;
|
|
1309
|
+
const sidebarWidth = sidebar.widthPercent || 25;
|
|
1310
|
+
const mainWidth = 100 - sidebarWidth;
|
|
1311
|
+
// Convert sidebar content to DOCX elements
|
|
1312
|
+
const sidebarContent = [];
|
|
1313
|
+
for (const el of sidebar.content) {
|
|
1314
|
+
// Apply sidebar text color to elements that don't have their own color
|
|
1315
|
+
if (el.type === "heading" && !el.color && sidebar.textColor) {
|
|
1316
|
+
el.color = sidebar.textColor;
|
|
1317
|
+
}
|
|
1318
|
+
else if (el.type === "paragraph" && !el.color && sidebar.textColor) {
|
|
1319
|
+
el.color = sidebar.textColor;
|
|
1320
|
+
}
|
|
1321
|
+
sidebarContent.push(...convertElementToDocx(el));
|
|
1322
|
+
}
|
|
1323
|
+
// Convert main content to DOCX elements
|
|
1324
|
+
const mainContent = [];
|
|
1325
|
+
for (const el of main.content) {
|
|
1326
|
+
mainContent.push(...convertElementToDocx(el));
|
|
1327
|
+
}
|
|
1328
|
+
// Create sidebar cell with background color
|
|
1329
|
+
const sidebarCell = new DocxTableCell({
|
|
1330
|
+
children: sidebarContent.length > 0 ? sidebarContent : [new Paragraph({ children: [] })],
|
|
1331
|
+
width: {
|
|
1332
|
+
size: sidebarWidth,
|
|
1333
|
+
type: WidthType.PERCENTAGE,
|
|
1334
|
+
},
|
|
1335
|
+
shading: sidebar.backgroundColor ? {
|
|
1336
|
+
type: ShadingType.SOLID,
|
|
1337
|
+
color: sidebar.backgroundColor,
|
|
1338
|
+
fill: sidebar.backgroundColor,
|
|
1339
|
+
} : undefined,
|
|
1340
|
+
margins: {
|
|
1341
|
+
top: convertInchesToTwip(0.25),
|
|
1342
|
+
bottom: convertInchesToTwip(0.25),
|
|
1343
|
+
left: convertInchesToTwip(0.2),
|
|
1344
|
+
right: convertInchesToTwip(0.2),
|
|
1345
|
+
},
|
|
1346
|
+
borders: {
|
|
1347
|
+
top: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
|
|
1348
|
+
bottom: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
|
|
1349
|
+
left: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
|
|
1350
|
+
right: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
|
|
1351
|
+
},
|
|
1352
|
+
});
|
|
1353
|
+
// Create main content cell
|
|
1354
|
+
const mainCell = new DocxTableCell({
|
|
1355
|
+
children: mainContent.length > 0 ? mainContent : [new Paragraph({ children: [] })],
|
|
1356
|
+
width: {
|
|
1357
|
+
size: mainWidth,
|
|
1358
|
+
type: WidthType.PERCENTAGE,
|
|
1359
|
+
},
|
|
1360
|
+
margins: {
|
|
1361
|
+
top: convertInchesToTwip(0.25),
|
|
1362
|
+
bottom: convertInchesToTwip(0.25),
|
|
1363
|
+
left: convertInchesToTwip(0.3),
|
|
1364
|
+
right: convertInchesToTwip(0.2),
|
|
1365
|
+
},
|
|
1366
|
+
borders: {
|
|
1367
|
+
top: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
|
|
1368
|
+
bottom: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
|
|
1369
|
+
left: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
|
|
1370
|
+
right: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
|
|
1371
|
+
},
|
|
1372
|
+
});
|
|
1373
|
+
// Create the two-column table
|
|
1374
|
+
const twoColumnTable = new Table({
|
|
1375
|
+
rows: [
|
|
1376
|
+
new TableRow({
|
|
1377
|
+
children: [sidebarCell, mainCell],
|
|
1378
|
+
}),
|
|
1379
|
+
],
|
|
1380
|
+
width: {
|
|
1381
|
+
size: 100,
|
|
1382
|
+
type: WidthType.PERCENTAGE,
|
|
1383
|
+
},
|
|
1384
|
+
borders: {
|
|
1385
|
+
top: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
|
|
1386
|
+
bottom: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
|
|
1387
|
+
left: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
|
|
1388
|
+
right: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
|
|
1389
|
+
insideHorizontal: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
|
|
1390
|
+
insideVertical: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
|
|
1391
|
+
},
|
|
1392
|
+
});
|
|
1393
|
+
return [twoColumnTable];
|
|
1394
|
+
}
|
|
1395
|
+
default:
|
|
1396
|
+
return [];
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
//# sourceMappingURL=convert.js.map
|