docgen-utils 1.0.12 → 1.0.13

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 (65) hide show
  1. package/dist/bundle.js +3189 -1238
  2. package/dist/bundle.min.js +101 -99
  3. package/dist/cli.js +2653 -1117
  4. package/dist/packages/cli/commands/export-docs.d.ts.map +1 -1
  5. package/dist/packages/cli/commands/export-docs.js +131 -2
  6. package/dist/packages/cli/commands/export-docs.js.map +1 -1
  7. package/dist/packages/cli/commands/export-slides.d.ts.map +1 -1
  8. package/dist/packages/cli/commands/export-slides.js +25 -1
  9. package/dist/packages/cli/commands/export-slides.js.map +1 -1
  10. package/dist/packages/docs/common.d.ts +10 -0
  11. package/dist/packages/docs/common.d.ts.map +1 -1
  12. package/dist/packages/docs/common.js.map +1 -1
  13. package/dist/packages/docs/convert.d.ts.map +1 -1
  14. package/dist/packages/docs/convert.js +246 -218
  15. package/dist/packages/docs/convert.js.map +1 -1
  16. package/dist/packages/docs/create-document.d.ts.map +1 -1
  17. package/dist/packages/docs/create-document.js +43 -3
  18. package/dist/packages/docs/create-document.js.map +1 -1
  19. package/dist/packages/docs/export.d.ts +9 -8
  20. package/dist/packages/docs/export.d.ts.map +1 -1
  21. package/dist/packages/docs/export.js +23 -36
  22. package/dist/packages/docs/export.js.map +1 -1
  23. package/dist/packages/docs/parse-colors.d.ts +37 -0
  24. package/dist/packages/docs/parse-colors.d.ts.map +1 -0
  25. package/dist/packages/docs/parse-colors.js +507 -0
  26. package/dist/packages/docs/parse-colors.js.map +1 -0
  27. package/dist/packages/docs/parse-css.d.ts +98 -0
  28. package/dist/packages/docs/parse-css.d.ts.map +1 -0
  29. package/dist/packages/docs/parse-css.js +1592 -0
  30. package/dist/packages/docs/parse-css.js.map +1 -0
  31. package/dist/packages/docs/parse-helpers.d.ts +45 -0
  32. package/dist/packages/docs/parse-helpers.d.ts.map +1 -0
  33. package/dist/packages/docs/parse-helpers.js +214 -0
  34. package/dist/packages/docs/parse-helpers.js.map +1 -0
  35. package/dist/packages/docs/parse-inline.d.ts +41 -0
  36. package/dist/packages/docs/parse-inline.d.ts.map +1 -0
  37. package/dist/packages/docs/parse-inline.js +473 -0
  38. package/dist/packages/docs/parse-inline.js.map +1 -0
  39. package/dist/packages/docs/parse-layout.d.ts +57 -0
  40. package/dist/packages/docs/parse-layout.d.ts.map +1 -0
  41. package/dist/packages/docs/parse-layout.js +295 -0
  42. package/dist/packages/docs/parse-layout.js.map +1 -0
  43. package/dist/packages/docs/parse-special.d.ts +51 -0
  44. package/dist/packages/docs/parse-special.d.ts.map +1 -0
  45. package/dist/packages/docs/parse-special.js +251 -0
  46. package/dist/packages/docs/parse-special.js.map +1 -0
  47. package/dist/packages/docs/parse-units.d.ts +68 -0
  48. package/dist/packages/docs/parse-units.d.ts.map +1 -0
  49. package/dist/packages/docs/parse-units.js +275 -0
  50. package/dist/packages/docs/parse-units.js.map +1 -0
  51. package/dist/packages/docs/parse.d.ts.map +1 -1
  52. package/dist/packages/docs/parse.js +957 -2800
  53. package/dist/packages/docs/parse.js.map +1 -1
  54. package/dist/packages/slides/common.d.ts +7 -0
  55. package/dist/packages/slides/common.d.ts.map +1 -1
  56. package/dist/packages/slides/convert.d.ts.map +1 -1
  57. package/dist/packages/slides/convert.js +92 -7
  58. package/dist/packages/slides/convert.js.map +1 -1
  59. package/dist/packages/slides/parse.d.ts.map +1 -1
  60. package/dist/packages/slides/parse.js +723 -40
  61. package/dist/packages/slides/parse.js.map +1 -1
  62. package/dist/packages/slides/transform.d.ts.map +1 -1
  63. package/dist/packages/slides/transform.js +12 -7
  64. package/dist/packages/slides/transform.js.map +1 -1
  65. package/package.json +1 -1
@@ -19,7 +19,92 @@ const HEADING_SPACING = {
19
19
  h1: { before: 160, after: 240 }, // 0.67em / 1em
20
20
  h2: { before: 480, after: 240 }, // margin-top: 2rem, margin-bottom: 1rem
21
21
  h3: { before: 360, after: 240 }, // margin-top: 1.5rem
22
+ h4: { before: 320, after: 200 }, // margin-top: 1.33em
23
+ h5: { before: 320, after: 200 }, // margin-top: 1.33em
24
+ h6: { before: 320, after: 200 }, // margin-top: 1.33em
22
25
  };
26
+ /** Invisible border used to suppress default DOCX borders. */
27
+ const NIL_BORDER = { style: BorderStyle.NIL, size: 0, color: "FFFFFF" };
28
+ /** All-sides invisible border for table cells and tables. */
29
+ const NIL_BORDERS = { top: NIL_BORDER, bottom: NIL_BORDER, left: NIL_BORDER, right: NIL_BORDER };
30
+ /** All-sides invisible border including inside borders for tables. */
31
+ const NIL_TABLE_BORDERS = { ...NIL_BORDERS, insideHorizontal: NIL_BORDER, insideVertical: NIL_BORDER };
32
+ /**
33
+ * Apply text-transform (uppercase, lowercase, capitalize) to a string.
34
+ */
35
+ function applyTextTransform(text, transform) {
36
+ if (!transform)
37
+ return text;
38
+ if (transform === "uppercase")
39
+ return text.toUpperCase();
40
+ if (transform === "lowercase")
41
+ return text.toLowerCase();
42
+ if (transform === "capitalize")
43
+ return text.replace(/\b\w/g, c => c.toUpperCase());
44
+ return text;
45
+ }
46
+ /**
47
+ * Convert underline type string to DOCX UnderlineType enum.
48
+ */
49
+ function getUnderlineType(type) {
50
+ switch (type) {
51
+ case "single": return UnderlineType.SINGLE;
52
+ case "dotted": return UnderlineType.DOTTED;
53
+ case "double": return UnderlineType.DOUBLE;
54
+ case "wave": return UnderlineType.WAVE;
55
+ default: return UnderlineType.SINGLE;
56
+ }
57
+ }
58
+ /**
59
+ * Create a chart/SVG placeholder paragraph (for charts that can't be rendered as images).
60
+ */
61
+ function createChartPlaceholderParagraph(title) {
62
+ const text = title ? `[Chart: ${title}]` : "[Chart]";
63
+ return new Paragraph({
64
+ children: [
65
+ new TextRun({
66
+ text,
67
+ italics: true,
68
+ color: "808080",
69
+ }),
70
+ ],
71
+ alignment: AlignmentType.CENTER,
72
+ border: {
73
+ top: { style: BorderStyle.SINGLE, size: 1, color: "E5E7EB" },
74
+ bottom: { style: BorderStyle.SINGLE, size: 1, color: "E5E7EB" },
75
+ left: { style: BorderStyle.SINGLE, size: 1, color: "E5E7EB" },
76
+ right: { style: BorderStyle.SINGLE, size: 1, color: "E5E7EB" },
77
+ },
78
+ shading: {
79
+ type: ShadingType.CLEAR,
80
+ color: "auto",
81
+ fill: "F9FAFB",
82
+ },
83
+ spacing: {
84
+ before: 200,
85
+ after: 200,
86
+ },
87
+ });
88
+ }
89
+ /**
90
+ * Create an image caption paragraph (italic, 10pt, gray, centered).
91
+ */
92
+ function createCaptionParagraph(caption) {
93
+ return new Paragraph({
94
+ children: [
95
+ new TextRun({
96
+ text: caption,
97
+ italics: true,
98
+ size: 20, // 10pt
99
+ color: "6B7280",
100
+ }),
101
+ ],
102
+ alignment: AlignmentType.CENTER,
103
+ spacing: {
104
+ after: 200,
105
+ },
106
+ });
107
+ }
23
108
  /**
24
109
  * Concrete XmlComponent implementation for building custom XML elements.
25
110
  */
@@ -75,11 +160,11 @@ class GradientTextFill extends XmlElement {
75
160
  gradFill.addChildElement(gsLst);
76
161
  // Create lin (linear gradient) element
77
162
  // 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;
163
+ // Word w14 uses the same angle system as DrawingML:
164
+ // 0 = left-to-right (east), 90 = top-to-bottom (south), clockwise
165
+ // CSS angles: 0 = bottom-to-top (north), 90 = left-to-right (east), clockwise
166
+ // Conversion: DML_angle = CSS_angle - 90 (both clockwise, just different zero point)
167
+ const wordAngle = ((gradient.angle - 90 + 360) % 360) * 60000;
83
168
  const lin = new XmlElement("w14:lin", {
84
169
  "w14:ang": wordAngle.toString(),
85
170
  "w14:scaled": "0",
@@ -271,8 +356,9 @@ class GradientBackgroundDrawing extends XmlElement {
271
356
  gradFill.addChildElement(gsLst);
272
357
  // Linear gradient direction
273
358
  // 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;
359
+ // DrawingML: 0 = left to right (east), angles in 1/60000ths of a degree, clockwise
360
+ // Conversion: DML_angle = CSS_angle - 90 (both clockwise, just different zero point)
361
+ const normalizedAngle = ((gradient.angle - 90 + 360) % 360);
276
362
  const drawingAngle = normalizedAngle * 60000;
277
363
  gradFill.addChildElement(new XmlElement("a:lin", {
278
364
  ang: drawingAngle.toString(),
@@ -367,28 +453,6 @@ function textAlignmentToDocx(alignment) {
367
453
  */
368
454
  function inlineRunsToTextRuns(runs, textTransform) {
369
455
  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
456
  for (const run of runs) {
393
457
  // Split text by newlines to handle <br> tags
394
458
  const parts = run.text.split('\n');
@@ -398,7 +462,7 @@ function inlineRunsToTextRuns(runs, textTransform) {
398
462
  color: run.underline.color,
399
463
  } : undefined;
400
464
  for (let i = 0; i < parts.length; i++) {
401
- const part = applyTransform(parts[i]);
465
+ const part = applyTextTransform(parts[i], textTransform);
402
466
  // Add a line break before this part (except for the first part)
403
467
  if (i > 0) {
404
468
  result.push(new TextRun({
@@ -410,11 +474,13 @@ function inlineRunsToTextRuns(runs, textTransform) {
410
474
  font: run.fontFamily,
411
475
  superScript: run.superscript,
412
476
  subScript: run.subscript,
477
+ strike: run.strike,
478
+ characterSpacing: run.letterSpacing,
413
479
  underline: underlineConfig,
414
480
  shading: run.backgroundColor ? {
415
- type: ShadingType.SOLID,
481
+ type: ShadingType.CLEAR,
416
482
  fill: run.backgroundColor,
417
- color: run.backgroundColor,
483
+ color: "auto",
418
484
  } : undefined,
419
485
  }));
420
486
  }
@@ -442,11 +508,13 @@ function inlineRunsToTextRuns(runs, textTransform) {
442
508
  font: run.fontFamily,
443
509
  superScript: run.superscript,
444
510
  subScript: run.subscript,
511
+ strike: run.strike,
512
+ characterSpacing: run.letterSpacing,
445
513
  underline: underlineConfig,
446
514
  shading: run.backgroundColor ? {
447
- type: ShadingType.SOLID,
515
+ type: ShadingType.CLEAR,
448
516
  fill: run.backgroundColor,
449
- color: run.backgroundColor,
517
+ color: "auto",
450
518
  } : undefined,
451
519
  }));
452
520
  }
@@ -490,35 +558,38 @@ function createTableRow(cells, isHeaderRow, columnCount, cellPadding, headerBack
490
558
  font: run.fontFamily,
491
559
  superScript: run.superscript,
492
560
  subScript: run.subscript,
561
+ strike: run.strike,
562
+ characterSpacing: run.letterSpacing,
493
563
  underline: run.underline ? {
494
564
  type: getUnderlineTypeForTable(run.underline.type),
495
565
  color: run.underline.color,
496
566
  } : undefined,
567
+ // Preserve inline background colors for badge/pill styling in table cells
568
+ shading: run.backgroundColor ? {
569
+ type: ShadingType.CLEAR,
570
+ fill: run.backgroundColor,
571
+ color: "auto",
572
+ } : undefined,
497
573
  }));
498
574
  // Determine cell shading: header row uses headerBackgroundColor, even rows use rowBackgroundColor
499
575
  let shading;
500
576
  if (isHeaderRow && headerBackgroundColor) {
501
577
  shading = {
502
- type: ShadingType.SOLID,
503
- color: headerBackgroundColor,
578
+ type: ShadingType.CLEAR,
579
+ color: "auto",
504
580
  fill: headerBackgroundColor,
505
581
  };
506
582
  }
507
583
  else if (!isHeaderRow && rowBackgroundColor) {
508
584
  shading = {
509
- type: ShadingType.SOLID,
510
- color: rowBackgroundColor,
585
+ type: ShadingType.CLEAR,
586
+ color: "auto",
511
587
  fill: rowBackgroundColor,
512
588
  };
513
589
  }
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
- }
590
+ // Note: No default header shading is applied when the CSS doesn't specify one.
591
+ // Previously, a light gray F9FAFB was applied to all header rows, but this added
592
+ // unwanted shading to tables in clean/minimal designs.
522
593
  // When this row has fewer cells than the table's column count,
523
594
  // the last cell must span the remaining grid columns via columnSpan.
524
595
  // Without this, the OOXML is structurally invalid (cells don't cover
@@ -604,16 +675,7 @@ export function convertElementToDocx(element) {
604
675
  const spacingKey = `h${element.level}`;
605
676
  const headingSpacing = HEADING_SPACING[spacingKey] || { before: 240, after: 240 };
606
677
  // Apply text-transform if specified
607
- let displayText = element.text;
608
- if (element.textTransform === "uppercase") {
609
- displayText = displayText.toUpperCase();
610
- }
611
- else if (element.textTransform === "lowercase") {
612
- displayText = displayText.toLowerCase();
613
- }
614
- else if (element.textTransform === "capitalize") {
615
- displayText = displayText.replace(/\b\w/g, c => c.toUpperCase());
616
- }
678
+ const displayText = applyTextTransform(element.text, element.textTransform);
617
679
  // Use runs array if available (for headings with inline badges/badges)
618
680
  // Otherwise fallback to simple text with color
619
681
  let children;
@@ -630,9 +692,17 @@ export function convertElementToDocx(element) {
630
692
  if (element.fontFamily) {
631
693
  textRunOptions.font = element.fontFamily;
632
694
  }
695
+ // Apply font-size from CSS (overrides document style defaults)
696
+ if (element.fontSize) {
697
+ textRunOptions.size = element.fontSize;
698
+ }
699
+ // Apply letter-spacing from CSS
700
+ if (element.letterSpacing) {
701
+ textRunOptions.characterSpacing = element.letterSpacing;
702
+ }
633
703
  children = [new TextRun(textRunOptions)];
634
704
  }
635
- // Build paragraph options with optional border-bottom
705
+ // Build paragraph options with optional border-bottom and border-left
636
706
  const paragraphOptions = {
637
707
  children,
638
708
  heading: headingLevel,
@@ -641,30 +711,42 @@ export function convertElementToDocx(element) {
641
711
  // GENERALIZED: Use lineSpacing from CSS if available, otherwise use default
642
712
  line: element.lineSpacing !== undefined ? element.lineSpacing : HEADING_LINE_SPACING.line,
643
713
  lineRule: HEADING_LINE_SPACING.lineRule,
644
- before: headingSpacing.before,
714
+ before: element.spacingBefore !== undefined ? element.spacingBefore : headingSpacing.before,
645
715
  after: element.spacingAfter !== undefined ? element.spacingAfter : headingSpacing.after,
646
716
  },
647
717
  };
648
718
  // Add border-bottom if present (heading underline style)
649
719
  if (element.borderBottom) {
650
720
  paragraphOptions.border = {
721
+ ...paragraphOptions.border,
651
722
  bottom: { style: BorderStyle.SINGLE, size: 6, color: element.borderBottom },
652
723
  };
653
724
  }
725
+ // Add border-left if present (heading left accent bar)
726
+ if (element.borderLeft) {
727
+ // Use CSS-extracted border width if available, otherwise default to 24 (3pt ≈ 4px)
728
+ const borderLeftSize = element.borderLeftWidth || 24;
729
+ paragraphOptions.border = {
730
+ ...paragraphOptions.border,
731
+ left: { style: BorderStyle.SINGLE, size: borderLeftSize, color: element.borderLeft, space: 8 },
732
+ };
733
+ // Add left indent to give space for the accent bar
734
+ paragraphOptions.indent = { left: convertInchesToTwip(0.15) };
735
+ }
736
+ // Add background color if present (heading with highlight background)
737
+ if (element.backgroundColor) {
738
+ const bgColor = element.backgroundColor.replace("#", "");
739
+ paragraphOptions.shading = {
740
+ type: ShadingType.CLEAR,
741
+ color: "auto",
742
+ fill: bgColor,
743
+ };
744
+ }
654
745
  return [new Paragraph(paragraphOptions)];
655
746
  }
656
747
  case "paragraph": {
657
748
  // Apply text-transform if specified
658
- let displayText = element.text;
659
- if (element.textTransform === "uppercase") {
660
- displayText = displayText.toUpperCase();
661
- }
662
- else if (element.textTransform === "lowercase") {
663
- displayText = displayText.toLowerCase();
664
- }
665
- else if (element.textTransform === "capitalize") {
666
- displayText = displayText.replace(/\b\w/g, c => c.toUpperCase());
667
- }
749
+ const displayText = applyTextTransform(element.text, element.textTransform);
668
750
  // Use runs array if available for inline formatting, otherwise fallback to simple text
669
751
  const children = element.runs
670
752
  ? inlineRunsToTextRuns(element.runs, element.textTransform)
@@ -686,12 +768,18 @@ export function convertElementToDocx(element) {
686
768
  if (element.hangingIndent) {
687
769
  // Hanging indent: left margin equals the hanging amount, first line outdented
688
770
  indent.hanging = element.hangingIndent;
689
- indent.left = element.hangingIndent;
771
+ indent.left = element.leftIndent || element.hangingIndent;
772
+ }
773
+ else if (element.leftIndent) {
774
+ // Left indent without hanging (plain padding-left)
775
+ indent.left = element.leftIndent;
690
776
  }
691
777
  // GENERALIZED: Use element's spacingAfter if provided, otherwise default
692
778
  const afterSpacing = element.spacingAfter !== undefined ? element.spacingAfter : PARAGRAPH_SPACING.after;
693
779
  // GENERALIZED: Use element's lineSpacing if provided, otherwise default
694
780
  const lineSpacing = element.lineSpacing !== undefined ? element.lineSpacing : PARAGRAPH_SPACING.line;
781
+ // GENERALIZED: Use element's spacingBefore if provided
782
+ const beforeSpacing = element.spacingBefore !== undefined ? element.spacingBefore : undefined;
695
783
  return [
696
784
  new Paragraph({
697
785
  children,
@@ -701,6 +789,7 @@ export function convertElementToDocx(element) {
701
789
  line: lineSpacing,
702
790
  lineRule: PARAGRAPH_SPACING.lineRule,
703
791
  after: afterSpacing,
792
+ before: beforeSpacing,
704
793
  },
705
794
  }),
706
795
  ];
@@ -727,7 +816,9 @@ export function convertElementToDocx(element) {
727
816
  // strict parsers like Azure Document Intelligence reject.
728
817
  const maxColumnCount = Math.max(...element.rows.map(row => row.length));
729
818
  const tableRows = element.rows.map((row, rowIndex) => createTableRow(row, useHeaderStyling && rowIndex === 0, maxColumnCount, element.cellPadding, element.headerBackgroundColor, element.headerTextColor,
730
- // Apply even row background color for alternating row styling (rowIndex > 0 and odd = even rows in 0-indexed)
819
+ // Apply even row background color for alternating row styling
820
+ // Header is at rowIndex 0, so body rows start at rowIndex 1.
821
+ // CSS nth-child(even) targets the 2nd,4th,6th body rows = rowIndex 2,4,6 (even indices)
731
822
  rowIndex > 0 && rowIndex % 2 === 0 ? element.evenRowBackgroundColor : undefined));
732
823
  // Default table border color (light gray), or no borders
733
824
  const borderColor = "E5E7EB";
@@ -735,6 +826,9 @@ export function convertElementToDocx(element) {
735
826
  const borderStyle = element.noBorders
736
827
  ? noBorderStyle
737
828
  : { style: BorderStyle.SINGLE, size: 4, color: borderColor };
829
+ // Support horizontal-only borders (no vertical grid lines)
830
+ const verticalBorderStyle = element.horizontalBordersOnly ? noBorderStyle : borderStyle;
831
+ const sideBorderStyle = element.horizontalBordersOnly ? noBorderStyle : borderStyle;
738
832
  const dataTable = new Table({
739
833
  rows: tableRows,
740
834
  width: {
@@ -744,10 +838,10 @@ export function convertElementToDocx(element) {
744
838
  borders: {
745
839
  top: borderStyle,
746
840
  bottom: borderStyle,
747
- left: borderStyle,
748
- right: borderStyle,
841
+ left: sideBorderStyle,
842
+ right: sideBorderStyle,
749
843
  insideHorizontal: borderStyle,
750
- insideVertical: borderStyle,
844
+ insideVertical: verticalBorderStyle,
751
845
  },
752
846
  });
753
847
  // Add spacing paragraphs before and after the table to ensure proper separation
@@ -779,6 +873,8 @@ export function convertElementToDocx(element) {
779
873
  right: { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" },
780
874
  },
781
875
  shading: {
876
+ type: ShadingType.CLEAR,
877
+ color: "auto",
782
878
  fill: "F5F5F5",
783
879
  },
784
880
  }),
@@ -794,9 +890,13 @@ export function convertElementToDocx(element) {
794
890
  const isCallout = element.variant === "callout";
795
891
  const hasFullBorder = element.borderStyle === "full";
796
892
  const hasNoBorder = element.borderStyle === "none";
797
- // Callout label uses darker blue color (#1d4ed8)
798
- const labelColor = isCallout ? "1D4ED8" : borderColor;
799
- // Convert all inner content to paragraphs for the cell
893
+ // Callout label uses the border color to match the accent style
894
+ // Previously hardcoded to blue (#1d4ed8), now uses the actual border color
895
+ // so labels match their callout's theme (brown for objectives, blue for examples, etc.)
896
+ // For borderless callouts (style="none"), don't use the gray fallback — let the
897
+ // heading/paragraph use its own color or the document default.
898
+ const labelColor = hasNoBorder ? undefined : borderColor;
899
+ // Convert all inner content to paragraphs (and tables for nested blockquotes) for the cell
800
900
  const cellContent = [];
801
901
  for (const innerElement of element.content) {
802
902
  if (innerElement.type === "paragraph") {
@@ -812,14 +912,19 @@ export function convertElementToDocx(element) {
812
912
  bold: innerElement.bold,
813
913
  italics: innerElement.italic,
814
914
  color: paragraphColor,
915
+ font: innerElement.fontFamily,
815
916
  }),
816
917
  ];
817
918
  cellContent.push(new Paragraph({
818
919
  children: textChildren,
920
+ alignment: innerElement.alignment ? textAlignmentToDocx(innerElement.alignment) : undefined,
819
921
  spacing: {
820
- line: PARAGRAPH_SPACING.line,
922
+ // Use paragraph's CSS line-height if available, otherwise default
923
+ line: innerElement.lineSpacing !== undefined ? innerElement.lineSpacing : PARAGRAPH_SPACING.line,
821
924
  lineRule: PARAGRAPH_SPACING.lineRule,
822
- after: 120,
925
+ // Use paragraph's CSS margin-bottom if available, otherwise compact spacing for boxes
926
+ after: innerElement.spacingAfter !== undefined ? innerElement.spacingAfter : 120,
927
+ before: innerElement.spacingBefore,
823
928
  }, // Space between elements inside the box
824
929
  }));
825
930
  }
@@ -869,8 +974,10 @@ export function convertElementToDocx(element) {
869
974
  else if (innerElement.type === "heading") {
870
975
  // Handle headings inside blockquotes
871
976
  // Use the heading's own color if available (from CSS like .key-takeaways h3 { color: ... })
872
- // Otherwise fall back to the blockquote's border color
873
- const headingColor = innerElement.color || borderColor;
977
+ // Only fall back to blockquote's border color when the blockquote has a visible border
978
+ // (left accent or full border). For borderless callouts (style="none"), the heading
979
+ // should use its own color or default — not the gray fallback "CCCCCC".
980
+ const headingColor = innerElement.color || (hasNoBorder ? undefined : borderColor);
874
981
  const textChildren = innerElement.runs
875
982
  ? inlineRunsToTextRuns(innerElement.runs)
876
983
  : [
@@ -924,6 +1031,43 @@ export function convertElementToDocx(element) {
924
1031
  // For regular tables inside blockquotes, we could add full table rendering,
925
1032
  // but for now we skip them as nested tables in Word can be problematic
926
1033
  }
1034
+ else if (innerElement.type === "image") {
1035
+ // Render images inside blockquote/callout cells
1036
+ if (innerElement.imageData) {
1037
+ // Scale image to fit within the blockquote cell width (approx 580px after padding)
1038
+ const maxCellWidth = 580;
1039
+ let imgWidth = innerElement.imageData.width;
1040
+ let imgHeight = innerElement.imageData.height;
1041
+ if (imgWidth > maxCellWidth) {
1042
+ const scale = maxCellWidth / imgWidth;
1043
+ imgWidth = maxCellWidth;
1044
+ imgHeight = Math.round(imgHeight * scale);
1045
+ }
1046
+ cellContent.push(new Paragraph({
1047
+ children: [
1048
+ new ImageRun({
1049
+ data: innerElement.imageData.data,
1050
+ transformation: {
1051
+ width: imgWidth,
1052
+ height: imgHeight,
1053
+ },
1054
+ type: "png",
1055
+ }),
1056
+ ],
1057
+ alignment: AlignmentType.CENTER,
1058
+ spacing: {
1059
+ before: 120,
1060
+ after: 120,
1061
+ },
1062
+ }));
1063
+ }
1064
+ }
1065
+ else if (innerElement.type === "blockquote") {
1066
+ // Handle nested blockquotes/callouts (e.g., .famous-for inside .country-section)
1067
+ // Render the nested blockquote as its own table-based box within the parent cell
1068
+ const nestedDocx = convertElementToDocx(innerElement);
1069
+ cellContent.push(...nestedDocx);
1070
+ }
927
1071
  }
928
1072
  // Create table to act as the box
929
1073
  // For gradient backgrounds, the DrawingML shape is already inserted in the first paragraph
@@ -943,6 +1087,7 @@ export function convertElementToDocx(element) {
943
1087
  let effectiveBackgroundColor = backgroundColor;
944
1088
  if (backgroundGradient && hasNoBorder) {
945
1089
  // Use the color at ~40% position (second stop in typical gradients)
1090
+ // This provides a mid-tone that better approximates the gradient's overall appearance
946
1091
  const sortedStops = [...backgroundGradient.stops].sort((a, b) => a.position - b.position);
947
1092
  if (sortedStops.length >= 2) {
948
1093
  effectiveBackgroundColor = sortedStops[1]?.color || sortedStops[0]?.color || backgroundColor;
@@ -952,8 +1097,8 @@ export function convertElementToDocx(element) {
952
1097
  }
953
1098
  }
954
1099
  const cellShading = {
955
- type: ShadingType.SOLID,
956
- color: effectiveBackgroundColor,
1100
+ type: ShadingType.CLEAR,
1101
+ color: "auto",
957
1102
  fill: effectiveBackgroundColor,
958
1103
  };
959
1104
  tableRows.push(new TableRow({
@@ -962,18 +1107,15 @@ export function convertElementToDocx(element) {
962
1107
  children: finalCellContent,
963
1108
  shading: cellShading,
964
1109
  margins: {
965
- top: convertInchesToTwip(0.25), // ~18pt padding top
966
- bottom: convertInchesToTwip(0.25), // ~18pt padding bottom
967
- left: convertInchesToTwip(0.35), // ~25pt padding left (after border)
968
- right: convertInchesToTwip(0.35), // ~25pt padding right
1110
+ top: element.padding?.top ?? convertInchesToTwip(0.25), // ~18pt padding top
1111
+ bottom: element.padding?.bottom ?? convertInchesToTwip(0.25), // ~18pt padding bottom
1112
+ left: element.padding?.left ?? convertInchesToTwip(0.35), // ~25pt padding left (after border)
1113
+ right: element.padding?.right ?? convertInchesToTwip(0.35), // ~25pt padding right
969
1114
  },
970
1115
  borders: hasNoBorder
971
1116
  ? {
972
1117
  // No border - just background color (for title blocks, hero sections)
973
- top: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
974
- bottom: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
975
- left: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
976
- right: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
1118
+ ...NIL_BORDERS,
977
1119
  }
978
1120
  : hasFullBorder
979
1121
  ? {
@@ -985,10 +1127,8 @@ export function convertElementToDocx(element) {
985
1127
  }
986
1128
  : {
987
1129
  // Left accent border only (callout style)
988
- top: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
989
- bottom: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
1130
+ ...NIL_BORDERS,
990
1131
  left: { style: BorderStyle.SINGLE, size: 24, color: borderColor }, // Thick left border
991
- right: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
992
1132
  },
993
1133
  }),
994
1134
  ],
@@ -1000,14 +1140,7 @@ export function convertElementToDocx(element) {
1000
1140
  type: WidthType.PERCENTAGE,
1001
1141
  },
1002
1142
  // Remove outer table borders - we only want the cell's left border
1003
- borders: {
1004
- top: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
1005
- bottom: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
1006
- left: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
1007
- right: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
1008
- insideHorizontal: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
1009
- insideVertical: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
1010
- },
1143
+ borders: NIL_TABLE_BORDERS,
1011
1144
  });
1012
1145
  // Add spacing paragraphs before and after the table to ensure proper separation
1013
1146
  // from surrounding content (especially other tables)
@@ -1021,68 +1154,10 @@ export function convertElementToDocx(element) {
1021
1154
  });
1022
1155
  return [spacingBefore, boxTable, spacingAfter];
1023
1156
  }
1024
- case "chart-placeholder": {
1025
- // Render a placeholder for charts that can't be converted
1026
- const text = element.title ? `[Chart: ${element.title}]` : "[Chart]";
1027
- return [
1028
- new Paragraph({
1029
- children: [
1030
- new TextRun({
1031
- text,
1032
- italics: true,
1033
- color: "808080",
1034
- }),
1035
- ],
1036
- alignment: AlignmentType.CENTER,
1037
- border: {
1038
- top: { style: BorderStyle.SINGLE, size: 1, color: "E5E7EB" },
1039
- bottom: { style: BorderStyle.SINGLE, size: 1, color: "E5E7EB" },
1040
- left: { style: BorderStyle.SINGLE, size: 1, color: "E5E7EB" },
1041
- right: { style: BorderStyle.SINGLE, size: 1, color: "E5E7EB" },
1042
- },
1043
- shading: {
1044
- type: ShadingType.SOLID,
1045
- color: "F9FAFB",
1046
- fill: "F9FAFB",
1047
- },
1048
- spacing: {
1049
- before: 200,
1050
- after: 200,
1051
- },
1052
- }),
1053
- ];
1054
- }
1157
+ case "chart-placeholder":
1055
1158
  case "svg-chart": {
1056
- // SVG charts need to be converted to images before reaching here
1057
- // Fall back to placeholder if not converted
1058
- const text = element.title ? `[Chart: ${element.title}]` : "[Chart]";
1059
- return [
1060
- new Paragraph({
1061
- children: [
1062
- new TextRun({
1063
- text,
1064
- italics: true,
1065
- color: "808080",
1066
- }),
1067
- ],
1068
- alignment: AlignmentType.CENTER,
1069
- border: {
1070
- top: { style: BorderStyle.SINGLE, size: 1, color: "E5E7EB" },
1071
- bottom: { style: BorderStyle.SINGLE, size: 1, color: "E5E7EB" },
1072
- left: { style: BorderStyle.SINGLE, size: 1, color: "E5E7EB" },
1073
- right: { style: BorderStyle.SINGLE, size: 1, color: "E5E7EB" },
1074
- },
1075
- shading: {
1076
- type: ShadingType.SOLID,
1077
- color: "F9FAFB",
1078
- fill: "F9FAFB",
1079
- },
1080
- spacing: {
1081
- before: 200,
1082
- after: 200,
1083
- },
1084
- }),
1085
- ];
1159
+ // Render a placeholder for charts that can't be converted (or SVGs not yet rendered)
1160
+ return [createChartPlaceholderParagraph(element.title)];
1086
1161
  }
1087
1162
  case "chart-image": {
1088
1163
  // Render chart as an embedded image
@@ -1129,24 +1204,7 @@ export function convertElementToDocx(element) {
1129
1204
  after: caption ? 80 : 200,
1130
1205
  },
1131
1206
  }),
1132
- ...(caption
1133
- ? [
1134
- new Paragraph({
1135
- children: [
1136
- new TextRun({
1137
- text: caption,
1138
- italics: true,
1139
- size: 20, // 10pt
1140
- color: "6B7280",
1141
- }),
1142
- ],
1143
- alignment: AlignmentType.CENTER,
1144
- spacing: {
1145
- after: 200,
1146
- },
1147
- }),
1148
- ]
1149
- : []),
1207
+ ...(caption ? [createCaptionParagraph(caption)] : []),
1150
1208
  ];
1151
1209
  }
1152
1210
  // Render the actual image
@@ -1171,20 +1229,7 @@ export function convertElementToDocx(element) {
1171
1229
  ];
1172
1230
  // Add caption if present
1173
1231
  if (caption) {
1174
- paragraphs.push(new Paragraph({
1175
- children: [
1176
- new TextRun({
1177
- text: caption,
1178
- italics: true,
1179
- size: 20, // 10pt
1180
- color: "6B7280",
1181
- }),
1182
- ],
1183
- alignment: AlignmentType.CENTER,
1184
- spacing: {
1185
- after: 200,
1186
- },
1187
- }));
1232
+ paragraphs.push(createCaptionParagraph(caption));
1188
1233
  }
1189
1234
  return paragraphs;
1190
1235
  }
@@ -1271,8 +1316,8 @@ export function convertElementToDocx(element) {
1271
1316
  right: convertInchesToTwip(0.1),
1272
1317
  },
1273
1318
  shading: card.backgroundColor ? {
1274
- type: ShadingType.SOLID,
1275
- color: card.backgroundColor,
1319
+ type: ShadingType.CLEAR,
1320
+ color: "auto",
1276
1321
  fill: card.backgroundColor,
1277
1322
  } : undefined,
1278
1323
  borders: card.borderColor ? {
@@ -1349,8 +1394,8 @@ export function convertElementToDocx(element) {
1349
1394
  type: WidthType.PERCENTAGE,
1350
1395
  },
1351
1396
  shading: sidebar.backgroundColor ? {
1352
- type: ShadingType.SOLID,
1353
- color: sidebar.backgroundColor,
1397
+ type: ShadingType.CLEAR,
1398
+ color: "auto",
1354
1399
  fill: sidebar.backgroundColor,
1355
1400
  } : undefined,
1356
1401
  margins: {
@@ -1359,12 +1404,7 @@ export function convertElementToDocx(element) {
1359
1404
  left: convertInchesToTwip(0.2),
1360
1405
  right: convertInchesToTwip(0.2),
1361
1406
  },
1362
- borders: {
1363
- top: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
1364
- bottom: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
1365
- left: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
1366
- right: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
1367
- },
1407
+ borders: NIL_BORDERS,
1368
1408
  });
1369
1409
  // Create main content cell
1370
1410
  const mainCell = new DocxTableCell({
@@ -1379,12 +1419,7 @@ export function convertElementToDocx(element) {
1379
1419
  left: convertInchesToTwip(0.3),
1380
1420
  right: convertInchesToTwip(0.2),
1381
1421
  },
1382
- borders: {
1383
- top: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
1384
- bottom: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
1385
- left: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
1386
- right: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
1387
- },
1422
+ borders: NIL_BORDERS,
1388
1423
  });
1389
1424
  // Create the two-column table
1390
1425
  const twoColumnTable = new Table({
@@ -1397,14 +1432,7 @@ export function convertElementToDocx(element) {
1397
1432
  size: 100,
1398
1433
  type: WidthType.PERCENTAGE,
1399
1434
  },
1400
- borders: {
1401
- top: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
1402
- bottom: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
1403
- left: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
1404
- right: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
1405
- insideHorizontal: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
1406
- insideVertical: { style: BorderStyle.NIL, size: 0, color: "FFFFFF" },
1407
- },
1435
+ borders: NIL_TABLE_BORDERS,
1408
1436
  });
1409
1437
  return [twoColumnTable];
1410
1438
  }