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.
Files changed (88) hide show
  1. package/README.md +118 -0
  2. package/dist/bundle.js +36086 -0
  3. package/dist/bundle.min.js +197 -0
  4. package/dist/cli.js +47432 -0
  5. package/dist/index.d.ts +9 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +9 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/packages/cli/commands/export-docs.d.ts +5 -0
  10. package/dist/packages/cli/commands/export-docs.d.ts.map +1 -0
  11. package/dist/packages/cli/commands/export-docs.js +24 -0
  12. package/dist/packages/cli/commands/export-docs.js.map +1 -0
  13. package/dist/packages/cli/commands/export-slides.d.ts +5 -0
  14. package/dist/packages/cli/commands/export-slides.d.ts.map +1 -0
  15. package/dist/packages/cli/commands/export-slides.js +86 -0
  16. package/dist/packages/cli/commands/export-slides.js.map +1 -0
  17. package/dist/packages/cli/commands/import-docx.d.ts +5 -0
  18. package/dist/packages/cli/commands/import-docx.d.ts.map +1 -0
  19. package/dist/packages/cli/commands/import-docx.js +27 -0
  20. package/dist/packages/cli/commands/import-docx.js.map +1 -0
  21. package/dist/packages/cli/commands/import-pptx.d.ts +5 -0
  22. package/dist/packages/cli/commands/import-pptx.d.ts.map +1 -0
  23. package/dist/packages/cli/commands/import-pptx.js +44 -0
  24. package/dist/packages/cli/commands/import-pptx.js.map +1 -0
  25. package/dist/packages/cli/index.d.ts +11 -0
  26. package/dist/packages/cli/index.d.ts.map +1 -0
  27. package/dist/packages/cli/index.js +103 -0
  28. package/dist/packages/cli/index.js.map +1 -0
  29. package/dist/packages/docs/common.d.ts +183 -0
  30. package/dist/packages/docs/common.d.ts.map +1 -0
  31. package/dist/packages/docs/common.js +27 -0
  32. package/dist/packages/docs/common.js.map +1 -0
  33. package/dist/packages/docs/convert.d.ts +7 -0
  34. package/dist/packages/docs/convert.d.ts.map +1 -0
  35. package/dist/packages/docs/convert.js +1399 -0
  36. package/dist/packages/docs/convert.js.map +1 -0
  37. package/dist/packages/docs/create-document.d.ts +30 -0
  38. package/dist/packages/docs/create-document.d.ts.map +1 -0
  39. package/dist/packages/docs/create-document.js +170 -0
  40. package/dist/packages/docs/create-document.js.map +1 -0
  41. package/dist/packages/docs/export.d.ts +57 -0
  42. package/dist/packages/docs/export.d.ts.map +1 -0
  43. package/dist/packages/docs/export.js +430 -0
  44. package/dist/packages/docs/export.js.map +1 -0
  45. package/dist/packages/docs/import-docx.d.ts +13 -0
  46. package/dist/packages/docs/import-docx.d.ts.map +1 -0
  47. package/dist/packages/docs/import-docx.js +2299 -0
  48. package/dist/packages/docs/import-docx.js.map +1 -0
  49. package/dist/packages/docs/parse.d.ts +6 -0
  50. package/dist/packages/docs/parse.d.ts.map +1 -0
  51. package/dist/packages/docs/parse.js +4253 -0
  52. package/dist/packages/docs/parse.js.map +1 -0
  53. package/dist/packages/shared/dom-parser-shim.d.ts +30 -0
  54. package/dist/packages/shared/dom-parser-shim.d.ts.map +1 -0
  55. package/dist/packages/shared/dom-parser-shim.js +152 -0
  56. package/dist/packages/shared/dom-parser-shim.js.map +1 -0
  57. package/dist/packages/slides/common.d.ts +325 -0
  58. package/dist/packages/slides/common.d.ts.map +1 -0
  59. package/dist/packages/slides/common.js +12 -0
  60. package/dist/packages/slides/common.js.map +1 -0
  61. package/dist/packages/slides/convert.d.ts +35 -0
  62. package/dist/packages/slides/convert.d.ts.map +1 -0
  63. package/dist/packages/slides/convert.js +308 -0
  64. package/dist/packages/slides/convert.js.map +1 -0
  65. package/dist/packages/slides/createPresentation.d.ts +51 -0
  66. package/dist/packages/slides/createPresentation.d.ts.map +1 -0
  67. package/dist/packages/slides/createPresentation.js +265 -0
  68. package/dist/packages/slides/createPresentation.js.map +1 -0
  69. package/dist/packages/slides/export.d.ts +24 -0
  70. package/dist/packages/slides/export.d.ts.map +1 -0
  71. package/dist/packages/slides/export.js +52 -0
  72. package/dist/packages/slides/export.js.map +1 -0
  73. package/dist/packages/slides/import-pptx.d.ts +13 -0
  74. package/dist/packages/slides/import-pptx.d.ts.map +1 -0
  75. package/dist/packages/slides/import-pptx.js +619 -0
  76. package/dist/packages/slides/import-pptx.js.map +1 -0
  77. package/dist/packages/slides/parse.d.ts +45 -0
  78. package/dist/packages/slides/parse.d.ts.map +1 -0
  79. package/dist/packages/slides/parse.js +1185 -0
  80. package/dist/packages/slides/parse.js.map +1 -0
  81. package/dist/packages/slides/transform.d.ts +37 -0
  82. package/dist/packages/slides/transform.d.ts.map +1 -0
  83. package/dist/packages/slides/transform.js +140 -0
  84. package/dist/packages/slides/transform.js.map +1 -0
  85. package/dist/packages/slides/vendor/VENDORING.md +58 -0
  86. package/dist/packages/slides/vendor/pptxgen.d.ts +805 -0
  87. package/dist/packages/slides/vendor/pptxgen.js +7442 -0
  88. 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