docgen-utils 1.0.9 → 1.0.10

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.
@@ -18,6 +18,57 @@ const PX_PER_IN = 96;
18
18
  // ---------------------------------------------------------------------------
19
19
  const SINGLE_WEIGHT_FONTS = ['impact'];
20
20
  // ---------------------------------------------------------------------------
21
+ // Font family mapping for Office compatibility
22
+ // Maps web fonts to their closest Office-installed equivalents
23
+ // ---------------------------------------------------------------------------
24
+ const FONT_FAMILY_MAP = {
25
+ // Common sans-serif web fonts → Calibri (default Office sans-serif)
26
+ // Fonts that are commonly available via Google Fonts CDN are NOT mapped here,
27
+ // to preserve original font names for systems that have them installed.
28
+ 'roboto': 'Calibri',
29
+ 'open sans': 'Calibri',
30
+ 'lato': 'Calibri',
31
+ 'montserrat': 'Calibri',
32
+ 'poppins': 'Calibri',
33
+ 'source sans pro': 'Calibri',
34
+ 'nunito': 'Calibri',
35
+ 'raleway': 'Calibri',
36
+ 'ubuntu': 'Calibri',
37
+ 'pt sans': 'Calibri',
38
+ 'noto sans': 'Calibri',
39
+ 'fira sans': 'Calibri',
40
+ 'work sans': 'Calibri',
41
+ // Serif fonts → Georgia (closest web-safe serif)
42
+ 'playfair display': 'Georgia',
43
+ 'merriweather': 'Georgia',
44
+ 'libre baskerville': 'Georgia',
45
+ 'pt serif': 'Georgia',
46
+ 'noto serif': 'Georgia',
47
+ 'lora': 'Georgia',
48
+ // Monospace fonts → Courier New (standard monospace)
49
+ 'fira code': 'Courier New',
50
+ 'source code pro': 'Courier New',
51
+ 'jetbrains mono': 'Courier New',
52
+ };
53
+ /**
54
+ * Map a font family to its Office-compatible equivalent.
55
+ * Returns the mapped font if found, or the original font if no mapping exists.
56
+ */
57
+ function mapFontFamily(fontFamily) {
58
+ const normalized = fontFamily.toLowerCase().trim();
59
+ return FONT_FAMILY_MAP[normalized] || fontFamily;
60
+ }
61
+ /**
62
+ * Extract and map fontFace from a computed style or font-family string.
63
+ * Takes the first font in the stack and maps it to an Office-compatible equivalent.
64
+ */
65
+ function extractFontFace(fontFamily) {
66
+ if (!fontFamily)
67
+ return undefined;
68
+ const firstFont = fontFamily.split(',')[0].replace(/['"]/g, '').trim();
69
+ return mapFontFamily(firstFont);
70
+ }
71
+ // ---------------------------------------------------------------------------
21
72
  // Exported helper functions
22
73
  // ---------------------------------------------------------------------------
23
74
  /** Convert pixel value to inches. */
@@ -33,6 +84,8 @@ export function pxToPoints(pxStr) {
33
84
  * Returns `'FFFFFF'` for transparent or unparseable values.
34
85
  */
35
86
  export function rgbToHex(rgbStr) {
87
+ // Remove !important suffix if present
88
+ rgbStr = rgbStr.replace(/\s*!important\s*$/i, '').trim();
36
89
  if (rgbStr === 'rgba(0, 0, 0, 0)' || rgbStr === 'transparent')
37
90
  return 'FFFFFF';
38
91
  const match = rgbStr.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
@@ -68,6 +121,21 @@ export function extractAlpha(rgbStr) {
68
121
  const alpha = parseFloat(match[4]);
69
122
  return Math.round((1 - alpha) * 100);
70
123
  }
124
+ /**
125
+ * Convert CSS border-style to PPTX dashType.
126
+ * Returns undefined for solid borders (the default).
127
+ */
128
+ function extractDashType(borderStyle) {
129
+ switch (borderStyle.toLowerCase()) {
130
+ case 'dashed':
131
+ return 'dash';
132
+ case 'dotted':
133
+ return 'sysDot';
134
+ case 'solid':
135
+ default:
136
+ return undefined; // solid is the default, no need to specify
137
+ }
138
+ }
71
139
  // ---------------------------------------------------------------------------
72
140
  // parseCssGradient (exported, used by other modules)
73
141
  // ---------------------------------------------------------------------------
@@ -80,12 +148,16 @@ export function extractAlpha(rgbStr) {
80
148
  export function parseCssGradient(gradientStr) {
81
149
  const colorToHex = (colorStr) => {
82
150
  colorStr = colorStr.trim().toLowerCase();
151
+ // Remove !important suffix if present
152
+ colorStr = colorStr.replace(/\s*!important\s*$/i, '');
83
153
  // Handle 'transparent' keyword (equivalent to rgba(0,0,0,0))
84
154
  if (colorStr === 'transparent') {
85
155
  return '000000';
86
156
  }
87
157
  if (colorStr.startsWith('#')) {
88
158
  let hex = colorStr.slice(1);
159
+ // Remove any trailing non-hex characters (like space + other text)
160
+ hex = hex.replace(/[^0-9a-f]/gi, '');
89
161
  if (hex.length === 3)
90
162
  hex = hex.split('').map((c) => c + c).join('');
91
163
  return hex.toUpperCase();
@@ -102,6 +174,8 @@ export function parseCssGradient(gradientStr) {
102
174
  };
103
175
  const extractTransparency = (colorStr) => {
104
176
  colorStr = colorStr.trim().toLowerCase();
177
+ // Remove !important suffix if present
178
+ colorStr = colorStr.replace(/\s*!important\s*$/i, '');
105
179
  // Handle 'transparent' keyword (equivalent to rgba(0,0,0,0) - fully transparent)
106
180
  if (colorStr === 'transparent') {
107
181
  return 100; // 100% transparency = fully transparent
@@ -371,6 +445,19 @@ function applyTextTransform(text, textTransform) {
371
445
  }
372
446
  return text;
373
447
  }
448
+ /**
449
+ * Extract text content from an element with text-transform CSS applied.
450
+ * This ensures that CSS text-transform (uppercase, lowercase, capitalize)
451
+ * is baked into the exported text since PPTX doesn't support text-transform.
452
+ */
453
+ function getTransformedText(element, computed) {
454
+ const rawText = element.textContent?.trim() || '';
455
+ const textTransform = computed.textTransform;
456
+ if (textTransform && textTransform !== 'none') {
457
+ return applyTextTransform(rawText, textTransform);
458
+ }
459
+ return rawText;
460
+ }
374
461
  function getRotation(transform, writingMode) {
375
462
  let angle = 0;
376
463
  if (writingMode === 'vertical-rl') {
@@ -668,6 +755,413 @@ function parseBoxShadow(boxShadow) {
668
755
  opacity,
669
756
  };
670
757
  }
758
+ /**
759
+ * Parse CSS `clip-path: polygon(...)` into an array of {x, y} coordinate pairs.
760
+ * Coordinates are normalized (0-1 range) relative to the element's bounding box.
761
+ * Returns null if the value is not a polygon clip-path or cannot be parsed.
762
+ */
763
+ function parseClipPathPolygon(clipPath) {
764
+ if (!clipPath || clipPath === 'none')
765
+ return null;
766
+ const polygonMatch = clipPath.match(/polygon\(([^)]+)\)/);
767
+ if (!polygonMatch)
768
+ return null;
769
+ const points = [];
770
+ const pairs = polygonMatch[1].split(',');
771
+ for (const pair of pairs) {
772
+ const coords = pair.trim().split(/\s+/);
773
+ if (coords.length < 2)
774
+ continue;
775
+ const x = parseFloat(coords[0]) / 100; // Convert percentage to 0-1
776
+ const y = parseFloat(coords[1]) / 100;
777
+ if (isNaN(x) || isNaN(y))
778
+ continue;
779
+ points.push({ x, y });
780
+ }
781
+ return points.length >= 3 ? points : null;
782
+ }
783
+ /**
784
+ * Render a conic-gradient as a canvas image and return a data URI.
785
+ * Conic gradients have no PPTX equivalent, so we pre-render them as images.
786
+ * Uses Canvas 2D createConicGradient() for native smooth interpolation.
787
+ *
788
+ * Handles:
789
+ * - `from Xdeg` start angle prefix
790
+ * - `at X% Y%` center position
791
+ * - Color stops with `%`, `deg`, or no explicit position (evenly distributed)
792
+ * - rgba/rgb/hex/named colors with nested parentheses
793
+ * - Two-position stops (e.g., `red 0% 50%`)
794
+ *
795
+ * @param conicGradientCSS - The CSS conic-gradient() string
796
+ * @param width - Element width in pixels
797
+ * @param height - Element height in pixels
798
+ * @param isCircle - Whether the element has border-radius: 50%
799
+ * @param doc - The document to create the canvas on
800
+ * @returns PNG data URI, or null if rendering fails
801
+ */
802
+ function renderConicGradientAsImage(conicGradientCSS, width, height, isCircle, doc) {
803
+ try {
804
+ const canvas = doc.createElement('canvas');
805
+ const scale = 2; // 2x for quality
806
+ canvas.width = Math.round(width * scale);
807
+ canvas.height = Math.round(height * scale);
808
+ const ctx = canvas.getContext('2d');
809
+ if (!ctx)
810
+ return null;
811
+ // Extract content between balanced parentheses of conic-gradient(...)
812
+ const conicIdx = conicGradientCSS.indexOf('conic-gradient(');
813
+ if (conicIdx === -1)
814
+ return null;
815
+ let depth = 0;
816
+ let startIdx = -1;
817
+ let endIdx = -1;
818
+ for (let i = conicIdx + 'conic-gradient'.length; i < conicGradientCSS.length; i++) {
819
+ if (conicGradientCSS[i] === '(') {
820
+ if (depth === 0)
821
+ startIdx = i + 1;
822
+ depth++;
823
+ }
824
+ else if (conicGradientCSS[i] === ')') {
825
+ depth--;
826
+ if (depth === 0) {
827
+ endIdx = i;
828
+ break;
829
+ }
830
+ }
831
+ }
832
+ if (startIdx === -1 || endIdx === -1)
833
+ return null;
834
+ let innerContent = conicGradientCSS.substring(startIdx, endIdx).trim();
835
+ // --- Parse optional "from <angle>" prefix ---
836
+ let startAngleDeg = 0;
837
+ const fromMatch = innerContent.match(/^from\s+([\d.]+)(deg|rad|turn|grad)/i);
838
+ if (fromMatch) {
839
+ const val = parseFloat(fromMatch[1]);
840
+ const unit = fromMatch[2].toLowerCase();
841
+ if (unit === 'deg')
842
+ startAngleDeg = val;
843
+ else if (unit === 'rad')
844
+ startAngleDeg = val * (180 / Math.PI);
845
+ else if (unit === 'turn')
846
+ startAngleDeg = val * 360;
847
+ else if (unit === 'grad')
848
+ startAngleDeg = val * 0.9;
849
+ // Remove "from Xdeg" and any following comma
850
+ innerContent = innerContent.substring(fromMatch[0].length).replace(/^\s*,\s*/, '');
851
+ }
852
+ // --- Parse optional "at <x> <y>" center position ---
853
+ let centerXFrac = 0.5;
854
+ let centerYFrac = 0.5;
855
+ const atMatch = innerContent.match(/^at\s+([\d.]+)(%|px)?\s+([\d.]+)(%|px)?/i);
856
+ if (atMatch) {
857
+ const xVal = parseFloat(atMatch[1]);
858
+ const xUnit = atMatch[2] || '%';
859
+ const yVal = parseFloat(atMatch[3]);
860
+ const yUnit = atMatch[4] || '%';
861
+ centerXFrac = xUnit === '%' ? xVal / 100 : xVal / width;
862
+ centerYFrac = yUnit === '%' ? yVal / 100 : yVal / height;
863
+ innerContent = innerContent.substring(atMatch[0].length).replace(/^\s*,\s*/, '');
864
+ }
865
+ // --- Split remaining content by top-level commas ---
866
+ const stopParts = [];
867
+ let current = '';
868
+ let parenDepth = 0;
869
+ for (let i = 0; i < innerContent.length; i++) {
870
+ const ch = innerContent[i];
871
+ if (ch === '(')
872
+ parenDepth++;
873
+ else if (ch === ')')
874
+ parenDepth--;
875
+ else if (ch === ',' && parenDepth === 0) {
876
+ stopParts.push(current.trim());
877
+ current = '';
878
+ continue;
879
+ }
880
+ current += ch;
881
+ }
882
+ if (current.trim())
883
+ stopParts.push(current.trim());
884
+ if (stopParts.length === 0)
885
+ return null;
886
+ const rawStops = [];
887
+ for (const part of stopParts) {
888
+ // Separate color from position(s)
889
+ // Strategy: find rightmost position tokens (deg/% numbers at end)
890
+ // Color can contain parentheses (rgb/rgba), so work from the end
891
+ const trimmed = part.trim();
892
+ // Match position tokens at the end: e.g., "0%", "45deg", "0.5turn"
893
+ // Could be one or two positions
894
+ const posPattern = /([\d.]+)(deg|%|turn|rad|grad)/gi;
895
+ const positions = [];
896
+ let m;
897
+ while ((m = posPattern.exec(trimmed)) !== null) {
898
+ positions.push({ value: parseFloat(m[1]), unit: m[2].toLowerCase(), index: m.index });
899
+ }
900
+ // Determine which positions belong to the stop (they must be at the end of the string)
901
+ // Filter to positions that are at the tail end after the color
902
+ const trailingPositions = [];
903
+ if (positions.length > 0) {
904
+ // Check if the last position token ends at the string end
905
+ const lastPos = positions[positions.length - 1];
906
+ const lastEnd = lastPos.index + `${lastPos.value}${lastPos.unit}`.length;
907
+ if (Math.abs(lastEnd - trimmed.length) <= 1) {
908
+ // Work backwards to find trailing position tokens
909
+ for (let i = positions.length - 1; i >= 0; i--) {
910
+ const p = positions[i];
911
+ // Check if this position is NOT inside a color function (rgb/rgba parens)
912
+ // by ensuring it's after the last closing paren
913
+ const lastParen = trimmed.lastIndexOf(')');
914
+ if (p.index > lastParen) {
915
+ trailingPositions.unshift({ value: p.value, unit: p.unit });
916
+ }
917
+ }
918
+ }
919
+ }
920
+ // Extract color: everything before the trailing positions
921
+ let color;
922
+ if (trailingPositions.length > 0) {
923
+ const firstPosIdx = positions.find(p => trailingPositions.some(tp => tp.value === p.value && tp.unit === p.unit))?.index ?? trimmed.length;
924
+ color = trimmed.substring(0, firstPosIdx).trim();
925
+ }
926
+ else {
927
+ color = trimmed;
928
+ }
929
+ // Convert positions to fractions (0-1)
930
+ const toFraction = (val, unit) => {
931
+ switch (unit) {
932
+ case '%': return val / 100;
933
+ case 'deg': return val / 360;
934
+ case 'turn': return val;
935
+ case 'rad': return val / (Math.PI * 2);
936
+ case 'grad': return val / 400;
937
+ default: return val / 100;
938
+ }
939
+ };
940
+ if (trailingPositions.length >= 2) {
941
+ // Two-position stop: color startPos endPos → two entries
942
+ rawStops.push({ color, position: toFraction(trailingPositions[0].value, trailingPositions[0].unit) });
943
+ rawStops.push({ color, position: toFraction(trailingPositions[1].value, trailingPositions[1].unit) });
944
+ }
945
+ else if (trailingPositions.length === 1) {
946
+ rawStops.push({ color, position: toFraction(trailingPositions[0].value, trailingPositions[0].unit) });
947
+ }
948
+ else {
949
+ // No explicit position — will be evenly distributed
950
+ rawStops.push({ color, position: null });
951
+ }
952
+ }
953
+ if (rawStops.length === 0)
954
+ return null;
955
+ // --- Fill in missing positions (CSS even distribution rules) ---
956
+ // First and last stops default to 0 and 1 if missing
957
+ if (rawStops[0].position === null)
958
+ rawStops[0].position = 0;
959
+ if (rawStops[rawStops.length - 1].position === null)
960
+ rawStops[rawStops.length - 1].position = 1;
961
+ // Interpolate missing positions between known ones
962
+ let lastKnownIdx = 0;
963
+ for (let i = 1; i < rawStops.length; i++) {
964
+ if (rawStops[i].position !== null) {
965
+ // Fill gaps between lastKnownIdx and i
966
+ const gapCount = i - lastKnownIdx;
967
+ if (gapCount > 1) {
968
+ const startVal = rawStops[lastKnownIdx].position;
969
+ const endVal = rawStops[i].position;
970
+ for (let j = lastKnownIdx + 1; j < i; j++) {
971
+ rawStops[j].position = startVal + (endVal - startVal) * ((j - lastKnownIdx) / gapCount);
972
+ }
973
+ }
974
+ lastKnownIdx = i;
975
+ }
976
+ }
977
+ // --- Create conic gradient using Canvas 2D API ---
978
+ ctx.scale(scale, scale);
979
+ const cx = width * centerXFrac;
980
+ const cy = height * centerYFrac;
981
+ const startAngleRad = (startAngleDeg * Math.PI) / 180;
982
+ // Use createConicGradient if available (Chromium supports it)
983
+ if (typeof ctx.createConicGradient === 'function') {
984
+ const grad = ctx.createConicGradient(startAngleRad, cx, cy);
985
+ for (const stop of rawStops) {
986
+ try {
987
+ grad.addColorStop(Math.max(0, Math.min(1, stop.position)), stop.color);
988
+ }
989
+ catch {
990
+ // Skip invalid color values
991
+ }
992
+ }
993
+ ctx.fillStyle = grad;
994
+ ctx.fillRect(0, 0, width, height);
995
+ }
996
+ else {
997
+ // Fallback: draw arc segments manually (no smooth interpolation)
998
+ const scaledCx = cx;
999
+ const scaledCy = cy;
1000
+ const radius = Math.max(width, height) * 1.5;
1001
+ const angleOffset = startAngleRad - Math.PI / 2; // CSS conic starts at top
1002
+ for (let i = 0; i < rawStops.length - 1; i++) {
1003
+ const startFrac = rawStops[i].position;
1004
+ const endFrac = rawStops[i + 1].position;
1005
+ if (endFrac <= startFrac)
1006
+ continue;
1007
+ // Draw many small slices for smooth color interpolation
1008
+ const slices = Math.max(1, Math.ceil((endFrac - startFrac) * 360));
1009
+ for (let s = 0; s < slices; s++) {
1010
+ const t = s / slices;
1011
+ const fracStart = startFrac + (endFrac - startFrac) * t;
1012
+ const fracEnd = startFrac + (endFrac - startFrac) * ((s + 1) / slices);
1013
+ const a1 = fracStart * Math.PI * 2 + angleOffset;
1014
+ const a2 = fracEnd * Math.PI * 2 + angleOffset;
1015
+ // Interpolate color (simple RGB lerp)
1016
+ const c1 = rawStops[i].color;
1017
+ const c2 = rawStops[i + 1].color;
1018
+ // Use start color for first half, end color for second half (rough lerp)
1019
+ const blendColor = t < 0.5 ? c1 : c2;
1020
+ ctx.beginPath();
1021
+ ctx.moveTo(scaledCx, scaledCy);
1022
+ ctx.arc(scaledCx, scaledCy, radius, a1, a2);
1023
+ ctx.closePath();
1024
+ ctx.fillStyle = blendColor;
1025
+ ctx.fill();
1026
+ }
1027
+ }
1028
+ }
1029
+ // Apply circular/rounded mask if the element is circular
1030
+ if (isCircle) {
1031
+ ctx.globalCompositeOperation = 'destination-in';
1032
+ ctx.beginPath();
1033
+ ctx.arc(cx, cy, Math.min(cx, cy), 0, Math.PI * 2);
1034
+ ctx.fill();
1035
+ ctx.globalCompositeOperation = 'source-over';
1036
+ }
1037
+ return canvas.toDataURL('image/png');
1038
+ }
1039
+ catch {
1040
+ return null;
1041
+ }
1042
+ }
1043
+ /**
1044
+ * Render an element with CSS filter:blur() as a canvas image.
1045
+ * CSS blur applies a Gaussian blur to the entire element (expanding outward),
1046
+ * which differs from OOXML softEdge (which only fades edges inward).
1047
+ * For large blur values (>20px), rendering as an image produces much better results.
1048
+ *
1049
+ * @param el - The HTML element to render
1050
+ * @param blurPx - The blur radius in pixels
1051
+ * @param win - The window context (for getComputedStyle)
1052
+ * @returns PNG data URI with position/size info, or null if rendering fails
1053
+ */
1054
+ function renderBlurredElementAsImage(el, blurPx, win) {
1055
+ try {
1056
+ const rect = el.getBoundingClientRect();
1057
+ if (rect.width <= 0 || rect.height <= 0)
1058
+ return null;
1059
+ const doc = win.document;
1060
+ const computed = win.getComputedStyle(el);
1061
+ // Expand canvas to account for blur spread (3x blur radius on each side)
1062
+ const spread = blurPx * 3;
1063
+ const canvasW = rect.width + spread * 2;
1064
+ const canvasH = rect.height + spread * 2;
1065
+ const scale = 2; // 2x for quality
1066
+ const canvas = doc.createElement('canvas');
1067
+ canvas.width = Math.round(canvasW * scale);
1068
+ canvas.height = Math.round(canvasH * scale);
1069
+ const ctx = canvas.getContext('2d');
1070
+ if (!ctx)
1071
+ return null;
1072
+ ctx.scale(scale, scale);
1073
+ // Apply CSS opacity
1074
+ const opacity = parseFloat(computed.opacity);
1075
+ if (!isNaN(opacity) && opacity < 1) {
1076
+ ctx.globalAlpha = opacity;
1077
+ }
1078
+ // Apply blur BEFORE drawing so all subsequent operations are blurred
1079
+ ctx.filter = `blur(${blurPx}px)`;
1080
+ // Parse visual properties
1081
+ const bgImage = computed.backgroundImage;
1082
+ const bgColor = computed.backgroundColor;
1083
+ const borderRadius = parseFloat(computed.borderRadius) || 0;
1084
+ const isCircle = borderRadius >= Math.min(rect.width, rect.height) / 2 - 1;
1085
+ // Position content at center (with spread margin)
1086
+ const offsetX = spread;
1087
+ const offsetY = spread;
1088
+ // Set fill style
1089
+ if (bgImage && bgImage !== 'none' && bgImage.includes('gradient')) {
1090
+ if (bgImage.includes('radial-gradient')) {
1091
+ const centerX = offsetX + rect.width / 2;
1092
+ const centerY = offsetY + rect.height / 2;
1093
+ const gradientRadius = Math.max(rect.width, rect.height) / 2;
1094
+ const canvasGrad = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, gradientRadius);
1095
+ // Parse color stops - handle nested parentheses for rgba
1096
+ const colorRegex = /rgba?\([^)]+\)|transparent|#[0-9a-fA-F]+/g;
1097
+ const pctRegex = /([\d.]+)%/g;
1098
+ const colors = Array.from(bgImage.matchAll(colorRegex)).map(m => m[0]);
1099
+ const percents = Array.from(bgImage.matchAll(pctRegex)).map(m => parseFloat(m[1]) / 100);
1100
+ for (let i = 0; i < colors.length; i++) {
1101
+ const pct = percents[i] !== undefined ? percents[i] : i / Math.max(colors.length - 1, 1);
1102
+ try {
1103
+ canvasGrad.addColorStop(Math.min(1, Math.max(0, pct)), colors[i]);
1104
+ }
1105
+ catch { /* skip */ }
1106
+ }
1107
+ ctx.fillStyle = canvasGrad;
1108
+ }
1109
+ else if (bgImage.includes('linear-gradient')) {
1110
+ const canvasGrad = ctx.createLinearGradient(offsetX, offsetY, offsetX + rect.width, offsetY + rect.height);
1111
+ const colorRegex = /rgba?\([^)]+\)|transparent|#[0-9a-fA-F]+/g;
1112
+ const pctRegex = /([\d.]+)%/g;
1113
+ const colors = Array.from(bgImage.matchAll(colorRegex)).map(m => m[0]);
1114
+ const percents = Array.from(bgImage.matchAll(pctRegex)).map(m => parseFloat(m[1]) / 100);
1115
+ for (let i = 0; i < colors.length; i++) {
1116
+ const pct = percents[i] !== undefined ? percents[i] : i / Math.max(colors.length - 1, 1);
1117
+ try {
1118
+ canvasGrad.addColorStop(Math.min(1, Math.max(0, pct)), colors[i]);
1119
+ }
1120
+ catch { /* skip */ }
1121
+ }
1122
+ ctx.fillStyle = canvasGrad;
1123
+ }
1124
+ }
1125
+ else if (bgColor && bgColor !== 'rgba(0, 0, 0, 0)') {
1126
+ ctx.fillStyle = bgColor;
1127
+ }
1128
+ // Draw shape with blur filter active
1129
+ if (isCircle) {
1130
+ ctx.beginPath();
1131
+ ctx.arc(offsetX + rect.width / 2, offsetY + rect.height / 2, Math.min(rect.width, rect.height) / 2, 0, Math.PI * 2);
1132
+ ctx.fill();
1133
+ }
1134
+ else if (borderRadius > 0) {
1135
+ const r = Math.min(borderRadius, rect.width / 2, rect.height / 2);
1136
+ ctx.beginPath();
1137
+ ctx.moveTo(offsetX + r, offsetY);
1138
+ ctx.lineTo(offsetX + rect.width - r, offsetY);
1139
+ ctx.quadraticCurveTo(offsetX + rect.width, offsetY, offsetX + rect.width, offsetY + r);
1140
+ ctx.lineTo(offsetX + rect.width, offsetY + rect.height - r);
1141
+ ctx.quadraticCurveTo(offsetX + rect.width, offsetY + rect.height, offsetX + rect.width - r, offsetY + rect.height);
1142
+ ctx.lineTo(offsetX + r, offsetY + rect.height);
1143
+ ctx.quadraticCurveTo(offsetX, offsetY + rect.height, offsetX, offsetY + rect.height - r);
1144
+ ctx.lineTo(offsetX, offsetY + r);
1145
+ ctx.quadraticCurveTo(offsetX, offsetY, offsetX + r, offsetY);
1146
+ ctx.closePath();
1147
+ ctx.fill();
1148
+ }
1149
+ else {
1150
+ ctx.fillRect(offsetX, offsetY, rect.width, rect.height);
1151
+ }
1152
+ const dataUri = canvas.toDataURL('image/png');
1153
+ return {
1154
+ dataUri,
1155
+ x: rect.left - spread,
1156
+ y: rect.top - spread,
1157
+ w: canvasW,
1158
+ h: canvasH,
1159
+ };
1160
+ }
1161
+ catch {
1162
+ return null;
1163
+ }
1164
+ }
671
1165
  /**
672
1166
  * Extract visible CSS pseudo-elements (::before, ::after) from an element.
673
1167
  *
@@ -748,19 +1242,20 @@ function extractPseudoElements(el, win) {
748
1242
  // Parse opacity
749
1243
  const elementOpacity = parseFloat(pComputed.opacity);
750
1244
  const hasOpacity = !isNaN(elementOpacity) && elementOpacity < 1;
751
- // Parse border-radius
1245
+ // Parse border-radius and detect ellipse shape
752
1246
  let rectRadius = 0;
753
1247
  const borderRadius = pComputed.borderRadius;
754
1248
  const radiusValue = parseFloat(borderRadius);
755
- if (radiusValue > 0) {
1249
+ // Detect ellipse: border-radius >= 50% on roughly square elements
1250
+ const isCircularRadius = borderRadius.includes('%')
1251
+ ? radiusValue >= 50
1252
+ : (radiusValue > 0 && radiusValue >= Math.min(pWidth, pHeight) / 2 - 1);
1253
+ const aspectRatio = pWidth / pHeight;
1254
+ const isEllipse = isCircularRadius && aspectRatio > 0.5 && aspectRatio < 2.0;
1255
+ if (radiusValue > 0 && !isEllipse) {
756
1256
  if (borderRadius.includes('%')) {
757
- if (radiusValue >= 50) {
758
- rectRadius = 1; // Fully rounded (pill shape)
759
- }
760
- else {
761
- const minDim = Math.min(pWidth, pHeight);
762
- rectRadius = (radiusValue / 100) * pxToInch(minDim);
763
- }
1257
+ const minDim = Math.min(pWidth, pHeight);
1258
+ rectRadius = (radiusValue / 100) * pxToInch(minDim);
764
1259
  }
765
1260
  else {
766
1261
  rectRadius = pxToInch(radiusValue);
@@ -785,11 +1280,12 @@ function extractPseudoElements(el, win) {
785
1280
  gradient: gradient,
786
1281
  transparency: hasBg ? extractAlpha(pComputed.backgroundColor) : null,
787
1282
  line: null,
788
- rectRadius: rectRadius,
1283
+ rectRadius: isEllipse ? 0 : rectRadius,
789
1284
  shadow: shadow,
790
1285
  opacity: hasOpacity ? elementOpacity : null,
791
- isEllipse: false,
1286
+ isEllipse: isEllipse,
792
1287
  softEdge: null,
1288
+ customGeometry: null,
793
1289
  },
794
1290
  };
795
1291
  results.push(shapeElement);
@@ -845,10 +1341,27 @@ function parseInlineFormatting(element, baseOptions, runs, baseTextTransform, wi
845
1341
  if (transparency !== null)
846
1342
  options.transparency = transparency;
847
1343
  }
1344
+ // Check for gradient text fill (-webkit-background-clip: text + transparent text-fill-color)
1345
+ const bgClip = computed.webkitBackgroundClip || computed.backgroundClip;
1346
+ const textFillColor = computed.webkitTextFillColor;
1347
+ const isTextFillTransparent = textFillColor === 'transparent' ||
1348
+ textFillColor === 'rgba(0, 0, 0, 0)' ||
1349
+ (textFillColor && textFillColor.includes('rgba') && textFillColor.endsWith(', 0)'));
1350
+ if (bgClip === 'text' && isTextFillTransparent) {
1351
+ const bgImage = computed.backgroundImage;
1352
+ if (bgImage && bgImage !== 'none' &&
1353
+ (bgImage.includes('linear-gradient') || bgImage.includes('radial-gradient'))) {
1354
+ const textGradient = parseCssGradient(bgImage);
1355
+ if (textGradient) {
1356
+ options.fontFill = { type: 'gradient', gradient: textGradient };
1357
+ delete options.color; // Gradient fill takes priority over solid color
1358
+ }
1359
+ }
1360
+ }
848
1361
  if (computed.fontSize)
849
1362
  options.fontSize = pxToPoints(computed.fontSize);
850
1363
  if (computed.fontFamily) {
851
- options.fontFace = computed.fontFamily.split(',')[0].replace(/['"]/g, '').trim();
1364
+ options.fontFace = extractFontFace(computed.fontFamily);
852
1365
  }
853
1366
  const runLetterSpacing = extractLetterSpacing(computed);
854
1367
  if (runLetterSpacing !== null)
@@ -959,10 +1472,23 @@ export function parseSlideHtml(doc) {
959
1472
  const hasGradientBg = spanBgImage &&
960
1473
  spanBgImage !== 'none' &&
961
1474
  (spanBgImage.includes('linear-gradient') || spanBgImage.includes('radial-gradient'));
962
- if (hasBg || hasBorder || hasGradientBg) {
1475
+ // Check if this gradient is used for gradient text fill (background-clip: text)
1476
+ // In that case, we should NOT create a shape element - the gradient fills the text itself
1477
+ const spanBgClip = computed.webkitBackgroundClip || computed.backgroundClip;
1478
+ const spanTextFillColor = computed.webkitTextFillColor;
1479
+ const isGradientTextFill = hasGradientBg &&
1480
+ spanBgClip === 'text' &&
1481
+ (spanTextFillColor === 'transparent' ||
1482
+ spanTextFillColor === 'rgba(0, 0, 0, 0)' ||
1483
+ (spanTextFillColor && spanTextFillColor.includes('rgba') && spanTextFillColor.endsWith(', 0)')));
1484
+ // Skip creating shape for gradient text fills - let text element handler create text with gradient fill
1485
+ if (isGradientTextFill) {
1486
+ // Don't process as shape, let it fall through to text handling
1487
+ }
1488
+ else if (hasBg || hasBorder || hasGradientBg) {
963
1489
  const rect = htmlEl.getBoundingClientRect();
964
1490
  if (rect.width > 0 && rect.height > 0) {
965
- const text = el.textContent.trim();
1491
+ const text = getTransformedText(htmlEl, computed);
966
1492
  const bgGradient = parseCssGradient(computed.backgroundImage);
967
1493
  const borderRadius = computed.borderRadius;
968
1494
  const radiusValue = parseFloat(borderRadius);
@@ -995,6 +1521,12 @@ export function parseSlideHtml(doc) {
995
1521
  const hasSpanOpacity = !isNaN(spanOpacity) && spanOpacity < 1;
996
1522
  // Extract box-shadow
997
1523
  const spanShadow = parseBoxShadow(computed.boxShadow);
1524
+ // For small elements (badges, labels), disable text wrapping to prevent overflow
1525
+ const spanWhiteSpace = computed.whiteSpace;
1526
+ const spanShouldNotWrap = spanWhiteSpace === 'nowrap' ||
1527
+ spanWhiteSpace === 'pre' ||
1528
+ rect.width < 100 ||
1529
+ rect.height < 50;
998
1530
  const shapeElement = {
999
1531
  type: 'shape',
1000
1532
  position: {
@@ -1007,11 +1539,12 @@ export function parseSlideHtml(doc) {
1007
1539
  textRuns: null,
1008
1540
  style: text ? {
1009
1541
  fontSize: pxToPoints(computed.fontSize),
1010
- fontFace: computed.fontFamily.split(',')[0].replace(/['"]/g, '').trim(),
1542
+ fontFace: extractFontFace(computed.fontFamily),
1011
1543
  color: rgbToHex(computed.color),
1012
1544
  bold: parseInt(computed.fontWeight) >= 600,
1013
1545
  align: 'center',
1014
1546
  valign: 'middle',
1547
+ wrap: !spanShouldNotWrap,
1015
1548
  } : null,
1016
1549
  shape: {
1017
1550
  fill: hasBg ? rgbToHex(computed.backgroundColor) : null,
@@ -1022,6 +1555,7 @@ export function parseSlideHtml(doc) {
1022
1555
  color: rgbToHex(computed.borderColor),
1023
1556
  width: pxToPoints(borderTop),
1024
1557
  transparency: extractAlpha(computed.borderColor),
1558
+ dashType: extractDashType(computed.borderStyle),
1025
1559
  }
1026
1560
  : null,
1027
1561
  rectRadius: spanIsEllipse ? 0 : rectRadius,
@@ -1029,6 +1563,7 @@ export function parseSlideHtml(doc) {
1029
1563
  opacity: hasSpanOpacity ? spanOpacity : null,
1030
1564
  isEllipse: spanIsEllipse,
1031
1565
  softEdge: null,
1566
+ customGeometry: null,
1032
1567
  },
1033
1568
  };
1034
1569
  elements.push(shapeElement);
@@ -1040,15 +1575,35 @@ export function parseSlideHtml(doc) {
1040
1575
  const parent = el.parentElement;
1041
1576
  if (parent && parent.tagName === 'DIV') {
1042
1577
  const rect = htmlEl.getBoundingClientRect();
1043
- const text = el.textContent.trim();
1578
+ const computed2 = win.getComputedStyle(el);
1579
+ const text = getTransformedText(htmlEl, computed2);
1044
1580
  if (rect.width > 0 && rect.height > 0 && text) {
1045
- const computed2 = win.getComputedStyle(el);
1046
1581
  const fontSizePx = parseFloat(computed2.fontSize);
1047
1582
  const lineHeightPx = parseFloat(computed2.lineHeight);
1048
1583
  const lineHeightMultiplier = fontSizePx > 0 && !isNaN(lineHeightPx) ? lineHeightPx / fontSizePx : 1.0;
1584
+ // Check for gradient text fill on plain SPAN elements
1585
+ const span2BgClip = computed2.webkitBackgroundClip || computed2.backgroundClip;
1586
+ const span2TextFillColor = computed2.webkitTextFillColor;
1587
+ const span2IsGradientText = span2BgClip === 'text' &&
1588
+ (span2TextFillColor === 'transparent' ||
1589
+ span2TextFillColor === 'rgba(0, 0, 0, 0)' ||
1590
+ (span2TextFillColor && span2TextFillColor.includes('rgba') && span2TextFillColor.endsWith(', 0)')));
1591
+ const span2BgImage = computed2.backgroundImage;
1592
+ const span2HasGradientBg = span2BgImage &&
1593
+ span2BgImage !== 'none' &&
1594
+ (span2BgImage.includes('linear-gradient') || span2BgImage.includes('radial-gradient'));
1595
+ let spanFontFill = undefined;
1596
+ let spanTextColor = rgbToHex(computed2.color);
1597
+ if (span2IsGradientText && span2HasGradientBg) {
1598
+ const spanGradient = parseCssGradient(span2BgImage);
1599
+ if (spanGradient) {
1600
+ spanFontFill = { type: 'gradient', gradient: spanGradient };
1601
+ spanTextColor = null; // Gradient fill takes priority
1602
+ }
1603
+ }
1049
1604
  const textElement = {
1050
1605
  type: 'p',
1051
- text: [{ text: text, options: {} }],
1606
+ text: [{ text: text, options: spanFontFill ? { fontFill: spanFontFill } : {} }],
1052
1607
  position: {
1053
1608
  x: pxToInch(rect.left),
1054
1609
  y: pxToInch(rect.top),
@@ -1057,8 +1612,8 @@ export function parseSlideHtml(doc) {
1057
1612
  },
1058
1613
  style: {
1059
1614
  fontSize: pxToPoints(computed2.fontSize),
1060
- fontFace: computed2.fontFamily.split(',')[0].replace(/['"]/g, '').trim(),
1061
- color: rgbToHex(computed2.color),
1615
+ fontFace: extractFontFace(computed2.fontFamily),
1616
+ color: spanTextColor,
1062
1617
  bold: parseInt(computed2.fontWeight) >= 600,
1063
1618
  italic: computed2.fontStyle === 'italic',
1064
1619
  align: computed2.textAlign === 'center'
@@ -1068,6 +1623,7 @@ export function parseSlideHtml(doc) {
1068
1623
  : 'left',
1069
1624
  valign: 'middle',
1070
1625
  lineSpacing: lineHeightMultiplier * pxToPoints(computed2.fontSize),
1626
+ fontFill: spanFontFill,
1071
1627
  },
1072
1628
  };
1073
1629
  elements.push(textElement);
@@ -1245,7 +1801,7 @@ export function parseSlideHtml(doc) {
1245
1801
  // Extract inline SVG elements as images
1246
1802
  // Inline SVGs are converted to data URI images for PPTX embedding.
1247
1803
  // This handles icons, illustrations, and other vector graphics in the HTML.
1248
- if (el.tagName === 'svg') {
1804
+ if (el.tagName.toLowerCase() === 'svg') {
1249
1805
  const rect = htmlEl.getBoundingClientRect();
1250
1806
  if (rect.width > 0 && rect.height > 0) {
1251
1807
  // Skip SVGs with very low opacity - these are usually decorative overlays
@@ -1316,34 +1872,97 @@ export function parseSlideHtml(doc) {
1316
1872
  }
1317
1873
  return null; // Not a dynamic value, keep as-is
1318
1874
  };
1875
+ // Build a map of computed styles from ORIGINAL SVG elements (which are in the DOM)
1876
+ // We need to do this BEFORE working with the clone, since the clone isn't attached
1877
+ // to the document and won't have computed CSS styles from stylesheets.
1878
+ const originalElements = [el, ...Array.from(el.querySelectorAll('*'))];
1879
+ const computedStylesMap = new Map();
1880
+ originalElements.forEach((origEl, index) => {
1881
+ // Use namespaceURI check instead of instanceof SVGElement since we're in an iframe context
1882
+ // where instanceof checks may fail due to different global objects
1883
+ const isSvgElement = origEl.namespaceURI === 'http://www.w3.org/2000/svg';
1884
+ if (isSvgElement) {
1885
+ const style = win.getComputedStyle(origEl);
1886
+ computedStylesMap.set(index, {
1887
+ fill: style.fill || '',
1888
+ stroke: style.stroke || '',
1889
+ strokeWidth: style.strokeWidth || '',
1890
+ });
1891
+ }
1892
+ });
1319
1893
  // Replace dynamic color values (currentColor, var()) in fill and stroke attributes
1320
- const resolveDynamicColors = (element) => {
1321
- const fill = element.getAttribute('fill');
1894
+ // Also bake in CSS-inherited fill/stroke when no attribute is present
1895
+ const cloneElements = [svgClone, ...Array.from(svgClone.querySelectorAll('*'))];
1896
+ cloneElements.forEach((cloneEl, index) => {
1897
+ const isSvgElement = cloneEl.namespaceURI === 'http://www.w3.org/2000/svg';
1898
+ const fill = cloneEl.getAttribute('fill');
1322
1899
  const resolvedFill = resolveColorValue(fill);
1323
1900
  if (resolvedFill) {
1324
- element.setAttribute('fill', resolvedFill);
1901
+ cloneEl.setAttribute('fill', resolvedFill);
1902
+ }
1903
+ else if (!fill && isSvgElement) {
1904
+ // No fill attribute - use computed CSS value from original element
1905
+ const computed = computedStylesMap.get(index);
1906
+ if (computed && computed.fill) {
1907
+ // If CSS says fill: none, explicitly set fill="none" to prevent default black fill
1908
+ if (computed.fill === 'none') {
1909
+ cloneEl.setAttribute('fill', 'none');
1910
+ }
1911
+ else if (computed.fill !== 'rgb(0, 0, 0)') {
1912
+ // Apply non-black fill colors
1913
+ cloneEl.setAttribute('fill', computed.fill);
1914
+ }
1915
+ }
1325
1916
  }
1326
- const stroke = element.getAttribute('stroke');
1917
+ const stroke = cloneEl.getAttribute('stroke');
1327
1918
  const resolvedStroke = resolveColorValue(stroke);
1328
1919
  if (resolvedStroke) {
1329
- element.setAttribute('stroke', resolvedStroke);
1920
+ cloneEl.setAttribute('stroke', resolvedStroke);
1330
1921
  }
1331
- // Recurse into child elements
1332
- element.querySelectorAll('*').forEach(resolveDynamicColors);
1333
- };
1334
- resolveDynamicColors(svgClone);
1922
+ else if (!stroke && isSvgElement) {
1923
+ // No stroke attribute - use computed CSS value from original element
1924
+ const computed = computedStylesMap.get(index);
1925
+ if (computed && computed.stroke && computed.stroke !== 'none') {
1926
+ cloneEl.setAttribute('stroke', computed.stroke);
1927
+ }
1928
+ // Also bake in stroke-width if CSS provides it
1929
+ if (computed && computed.strokeWidth && computed.strokeWidth !== '1px' && !cloneEl.getAttribute('stroke-width')) {
1930
+ cloneEl.setAttribute('stroke-width', computed.strokeWidth);
1931
+ }
1932
+ }
1933
+ });
1335
1934
  // Serialize SVG to string
1336
1935
  const serializer = new XMLSerializer();
1337
1936
  const svgString = serializer.serializeToString(svgClone);
1338
1937
  // Convert to data URI (base64 encoded for better compatibility)
1339
1938
  const svgBase64 = btoa(unescape(encodeURIComponent(svgString)));
1340
1939
  const dataUri = `data:image/svg+xml;base64,${svgBase64}`;
1940
+ // Calculate SVG position, accounting for vertical-align
1941
+ // NOTE: getBoundingClientRect() already returns the correct position when the
1942
+ // parent uses flexbox layout. Only apply vertical-align adjustment for non-flex
1943
+ // parents where inline vertical-align actually affects positioning.
1944
+ let svgY = rect.top;
1945
+ const computedAlign = win.getComputedStyle(htmlEl);
1946
+ const verticalAlign = computedAlign.verticalAlign;
1947
+ // If SVG has vertical-align: middle and a parent, center it within parent's height
1948
+ // BUT skip this if parent is a flex container (flexbox already handles positioning)
1949
+ if (verticalAlign === 'middle' && el.parentElement) {
1950
+ const parentComputed = win.getComputedStyle(el.parentElement);
1951
+ const parentIsFlex = parentComputed.display === 'flex' || parentComputed.display === 'inline-flex';
1952
+ if (!parentIsFlex) {
1953
+ const parentRect = el.parentElement.getBoundingClientRect();
1954
+ if (parentRect.height > rect.height) {
1955
+ // Center the SVG vertically within the parent
1956
+ svgY = parentRect.top + (parentRect.height - rect.height) / 2;
1957
+ }
1958
+ }
1959
+ }
1341
1960
  const imageElement = {
1342
1961
  type: 'image',
1343
1962
  src: dataUri,
1344
1963
  position: {
1345
1964
  x: pxToInch(rect.left),
1346
- y: pxToInch(rect.top),
1965
+ y: pxToInch(svgY),
1347
1966
  w: pxToInch(rect.width),
1348
1967
  h: pxToInch(rect.height),
1349
1968
  },
@@ -1356,14 +1975,98 @@ export function parseSlideHtml(doc) {
1356
1975
  return;
1357
1976
  }
1358
1977
  }
1359
- // Handle <i> elements — skip Font Awesome / icon font elements cleanly.
1978
+ // Handle <i> elements — render Font Awesome / icon font elements as images.
1360
1979
  // Font Awesome uses ::before pseudo-elements with font-family "Font Awesome ..." to render icons
1361
1980
  // as PUA (Private Use Area) Unicode characters. These PUA characters don't have visual
1362
- // representations in standard fonts used by PowerPoint/LibreOffice, and Unicode symbol
1363
- // approximations (⊞, ↗, etc.) look too different from the original FA icons, causing
1364
- // pixel regressions. The icon container shapes (icon-circle DIVs) still render as shapes,
1365
- // providing visual context. We simply skip the <i> elements entirely.
1981
+ // representations in standard fonts used by PowerPoint/LibreOffice.
1982
+ //
1983
+ // Solution: Render the icon to a canvas and convert to a data URI image.
1984
+ // This preserves the visual appearance regardless of font availability.
1366
1985
  if (el.tagName === 'I') {
1986
+ const rect = htmlEl.getBoundingClientRect();
1987
+ if (rect.width > 0 && rect.height > 0) {
1988
+ const computed = win.getComputedStyle(htmlEl);
1989
+ const beforeComputed = win.getComputedStyle(htmlEl, '::before');
1990
+ // Check if this is a Font Awesome or icon font element
1991
+ const fontFamily = beforeComputed.fontFamily || computed.fontFamily;
1992
+ const content = beforeComputed.content;
1993
+ const isFontIcon = fontFamily.toLowerCase().includes('font awesome') ||
1994
+ fontFamily.toLowerCase().includes('fontawesome') ||
1995
+ fontFamily.toLowerCase().includes('fa ') ||
1996
+ fontFamily.toLowerCase().includes('material') ||
1997
+ fontFamily.toLowerCase().includes('icon');
1998
+ if (isFontIcon && content && content !== 'none' && content !== 'normal') {
1999
+ // Render the icon to a canvas image.
2000
+ // Icon fonts (Font Awesome, Material Icons, etc.) use Private Use Area (PUA)
2001
+ // Unicode characters that won't render in PowerPoint. We capture them as images.
2002
+ //
2003
+ // IMPORTANT: Use the iframe's document (via win.document) for canvas creation
2004
+ // and font checking, since that's where the @font-face fonts are loaded.
2005
+ try {
2006
+ const scale = 8;
2007
+ const w = Math.ceil(rect.width);
2008
+ const h = Math.ceil(rect.height);
2009
+ // Create canvas in the iframe's document context where fonts are loaded
2010
+ const iframeDoc = win.document;
2011
+ const canvas = iframeDoc.createElement('canvas');
2012
+ canvas.width = w * scale;
2013
+ canvas.height = h * scale;
2014
+ const ctx = canvas.getContext('2d');
2015
+ if (ctx) {
2016
+ ctx.scale(scale, scale);
2017
+ // Get the icon character from content (strip quotes)
2018
+ let iconChar = content.replace(/['"]/g, '');
2019
+ // Handle CSS escape sequences like \f015
2020
+ if (iconChar.startsWith('\\')) {
2021
+ const codePoint = parseInt(iconChar.slice(1), 16);
2022
+ if (!isNaN(codePoint)) {
2023
+ iconChar = String.fromCodePoint(codePoint);
2024
+ }
2025
+ }
2026
+ // Set up the font
2027
+ const fontSize = parseFloat(beforeComputed.fontSize) || parseFloat(computed.fontSize) || 16;
2028
+ const fontWeight = beforeComputed.fontWeight || computed.fontWeight || '900';
2029
+ // Try each font family in the list until one is verified available
2030
+ const fontFamilies = fontFamily.split(',').map(f => f.trim().replace(/['"]/g, ''));
2031
+ let fontSet = false;
2032
+ for (const ff of fontFamilies) {
2033
+ const fontSpec = `${fontWeight} ${fontSize}px "${ff}"`;
2034
+ // Use the iframe's document.fonts to check (that's where @font-face rules live)
2035
+ if (iframeDoc.fonts && iframeDoc.fonts.check(fontSpec)) {
2036
+ ctx.font = fontSpec;
2037
+ fontSet = true;
2038
+ break;
2039
+ }
2040
+ }
2041
+ if (!fontSet) {
2042
+ // Fallback: use the full font-family string
2043
+ ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
2044
+ }
2045
+ ctx.fillStyle = computed.color || 'white';
2046
+ ctx.textAlign = 'center';
2047
+ ctx.textBaseline = 'middle';
2048
+ // Draw the icon character centered
2049
+ ctx.fillText(iconChar, w / 2, h / 2);
2050
+ const dataUri = canvas.toDataURL('image/png');
2051
+ const imageElement = {
2052
+ type: 'image',
2053
+ src: dataUri,
2054
+ position: {
2055
+ x: pxToInch(rect.left),
2056
+ y: pxToInch(rect.top),
2057
+ w: pxToInch(rect.width),
2058
+ h: pxToInch(rect.height),
2059
+ },
2060
+ sizing: null,
2061
+ };
2062
+ elements.push(imageElement);
2063
+ }
2064
+ }
2065
+ catch {
2066
+ // If rendering fails, skip the icon
2067
+ }
2068
+ }
2069
+ }
1367
2070
  processed.add(el);
1368
2071
  return;
1369
2072
  }
@@ -1371,7 +2074,73 @@ export function parseSlideHtml(doc) {
1371
2074
  const isContainer = el.tagName === 'DIV' && !textTags.includes(el.tagName);
1372
2075
  if (isContainer) {
1373
2076
  const computed = win.getComputedStyle(el);
2077
+ // === LARGE BLUR ELEMENTS: render as image ===
2078
+ // CSS filter:blur() does a Gaussian blur expanding outward. OOXML softEdge only
2079
+ // fades edges inward. For blur > 20px (common on decorative orbs), capture the
2080
+ // blurred result as an image for much better visual fidelity.
2081
+ const divFilterStr = computed.filter;
2082
+ if (divFilterStr && divFilterStr !== 'none') {
2083
+ const divBlurMatch = divFilterStr.match(/blur\(([\d.]+)px\)/);
2084
+ if (divBlurMatch) {
2085
+ const divBlurPx = parseFloat(divBlurMatch[1]);
2086
+ if (divBlurPx > 20) {
2087
+ const blurResult = renderBlurredElementAsImage(htmlEl, divBlurPx, win);
2088
+ if (blurResult) {
2089
+ const imgElement = {
2090
+ type: 'image',
2091
+ src: blurResult.dataUri,
2092
+ position: {
2093
+ x: pxToInch(blurResult.x),
2094
+ y: pxToInch(blurResult.y),
2095
+ w: pxToInch(blurResult.w),
2096
+ h: pxToInch(blurResult.h),
2097
+ },
2098
+ sizing: null,
2099
+ rectRadius: 0,
2100
+ };
2101
+ elements.push(imgElement);
2102
+ processed.add(el);
2103
+ el.querySelectorAll('*').forEach((child) => processed.add(child));
2104
+ return;
2105
+ }
2106
+ }
2107
+ }
2108
+ }
2109
+ // === CONIC GRADIENT: render as image ===
2110
+ // PPTX has no native conic-gradient support. Render as canvas image.
2111
+ const divBgImageForConic = computed.backgroundImage;
2112
+ if (divBgImageForConic && divBgImageForConic.includes('conic-gradient')) {
2113
+ const rect = htmlEl.getBoundingClientRect();
2114
+ if (rect.width > 0 && rect.height > 0) {
2115
+ const borderRadiusStr = computed.borderRadius;
2116
+ const radiusVal = parseFloat(borderRadiusStr);
2117
+ const isCircular = borderRadiusStr.includes('%')
2118
+ ? radiusVal >= 50
2119
+ : (radiusVal > 0 && radiusVal >= Math.min(rect.width, rect.height) / 2 - 1);
2120
+ const conicDataUri = renderConicGradientAsImage(divBgImageForConic, rect.width, rect.height, isCircular, win.document);
2121
+ if (conicDataUri) {
2122
+ const imgElement = {
2123
+ type: 'image',
2124
+ src: conicDataUri,
2125
+ position: {
2126
+ x: pxToInch(rect.left),
2127
+ y: pxToInch(rect.top),
2128
+ w: pxToInch(rect.width),
2129
+ h: pxToInch(rect.height),
2130
+ },
2131
+ sizing: null,
2132
+ rectRadius: 0,
2133
+ };
2134
+ elements.push(imgElement);
2135
+ // Don't mark as fully processed — children (text, pseudo-elements) still need extraction
2136
+ // Just skip the shape creation for this element's background
2137
+ }
2138
+ }
2139
+ }
1374
2140
  const hasBg = computed.backgroundColor && computed.backgroundColor !== 'rgba(0, 0, 0, 0)';
2141
+ // Check for clip-path: polygon() — store for custom geometry
2142
+ const clipPathValue = computed.clipPath || computed.webkitClipPath;
2143
+ const clipPathPolygon = parseClipPathPolygon(clipPathValue);
1375
2144
  // Check for background images or gradients
1376
2145
  const elBgImage = computed.backgroundImage;
1377
2146
  let bgImageUrl = null;
@@ -1497,27 +2266,44 @@ export function parseSlideHtml(doc) {
1497
2266
  const textChildren = allChildren.filter((child) => {
1498
2267
  if (textTagSet.has(child.tagName))
1499
2268
  return true;
1500
- // Include text-only DIVs: leaf (no child elements) OR only <br>/<p> children
2269
+ // Include text-only DIVs: DIVs that contain only inline/text content
1501
2270
  // The transformer wraps bare text in <p> tags, so a DIV with a single <p> child
1502
2271
  // is still effectively a text-only DIV. Examples after transformation:
1503
2272
  // <div id="stat-value-1"><p>1.1°C</p></div>
1504
2273
  // <div id="stat-label-1">Global temperature rise since<br>pre-industrial era</div>
2274
+ // <div class="bullet-desc">Text with <strong>bold</strong> content</div>
1505
2275
  if (child.tagName === 'DIV') {
2276
+ const childComputed = win.getComputedStyle(child);
2277
+ // DIVs that use flex-row or grid layout with multiple children are
2278
+ // layout containers — their children are positioned spatially (side
2279
+ // by side) which a PPTX text box cannot reproduce. Don't merge them
2280
+ // as text; let their children be extracted with independent positions.
2281
+ const childDisplay = childComputed.display;
2282
+ const childFlexDir = childComputed.flexDirection || 'row';
2283
+ const childChildCount = child.children.length;
2284
+ const isFlexRowLayout = (childDisplay === 'flex' || childDisplay === 'inline-flex') &&
2285
+ (childFlexDir === 'row' || childFlexDir === 'row-reverse') &&
2286
+ childChildCount > 1;
2287
+ const isGridLayout = childDisplay === 'grid' || childDisplay === 'inline-grid';
2288
+ if (isFlexRowLayout || isGridLayout)
2289
+ return false;
1506
2290
  const childElements = Array.from(child.children);
2291
+ // Inline text elements that don't break text-only status
2292
+ const inlineTextTags = new Set(['STRONG', 'EM', 'B', 'I', 'A', 'BR', 'SPAN', 'MARK', 'SMALL', 'SUB', 'SUP', 'CODE', 'U', 'S', 'Q', 'CITE', 'ABBR', 'TIME', 'DATA']);
1507
2293
  // A text-only DIV has:
1508
2294
  // - no child elements, OR
1509
- // - only <br> children (inline breaks), OR
2295
+ // - only inline text elements (strong, em, br, span, etc.), OR
1510
2296
  // - a single <p> child (injected by transform.ts text wrapping)
1511
2297
  const isTextOnlyDiv = childElements.length === 0 ||
1512
- childElements.every(ce => ce.tagName === 'BR') ||
2298
+ childElements.every(ce => inlineTextTags.has(ce.tagName)) ||
1513
2299
  (childElements.length === 1 && childElements[0].tagName === 'P' &&
1514
- childElements[0].children.length === 0);
2300
+ (childElements[0].children.length === 0 ||
2301
+ Array.from(childElements[0].children).every(ce => inlineTextTags.has(ce.tagName))));
1515
2302
  if (!isTextOnlyDiv)
1516
2303
  return false;
1517
2304
  const childText = child.textContent?.trim();
1518
2305
  if (!childText)
1519
2306
  return false;
1520
- const childComputed = win.getComputedStyle(child);
1521
2307
  const childHasBg = childComputed.backgroundColor && childComputed.backgroundColor !== 'rgba(0, 0, 0, 0)';
1522
2308
  const childHasBgImg = childComputed.backgroundImage && childComputed.backgroundImage !== 'none';
1523
2309
  const childBorders = [
@@ -1537,17 +2323,21 @@ export function parseSlideHtml(doc) {
1537
2323
  const nonTextChildren = allChildren.filter((child) => !textTagSet.has(child.tagName) &&
1538
2324
  !decorativeTags.has(child.tagName) &&
1539
2325
  !(child.tagName === 'DIV' && textChildren.includes(child)));
2326
+ // Inline text elements that don't break "simple" text child status
2327
+ const inlineTextTagsForSimple = new Set(['STRONG', 'EM', 'B', 'I', 'A', 'BR', 'SPAN', 'MARK', 'SMALL', 'SUB', 'SUP', 'CODE', 'U', 'S', 'Q', 'CITE', 'ABBR', 'TIME', 'DATA']);
1540
2328
  const isSingleTextChild = textChildren.length === 1 &&
1541
2329
  (() => {
1542
2330
  const tc = textChildren[0];
1543
2331
  const tcChildren = Array.from(tc.children);
1544
2332
  // A text child is "simple" (no further structural content) if:
1545
2333
  // - it has no child elements, OR
1546
- // - all children are <BR> tags (inline breaks), OR
1547
- // - it has a single <P> child with no further child elements (from transform.ts wrapping)
2334
+ // - all children are inline text elements (BR, STRONG, EM, etc.), OR
2335
+ // - it has a single <P> child with only inline text elements (from transform.ts wrapping)
1548
2336
  return tcChildren.length === 0 ||
1549
- tcChildren.every(ce => ce.tagName === 'BR') ||
1550
- (tcChildren.length === 1 && tcChildren[0].tagName === 'P' && tcChildren[0].children.length === 0);
2337
+ tcChildren.every(ce => inlineTextTagsForSimple.has(ce.tagName)) ||
2338
+ (tcChildren.length === 1 && tcChildren[0].tagName === 'P' &&
2339
+ (tcChildren[0].children.length === 0 ||
2340
+ Array.from(tcChildren[0].children).every(ce => inlineTextTagsForSimple.has(ce.tagName))));
1551
2341
  })() &&
1552
2342
  nonTextChildren.length === 0;
1553
2343
  // Detect flexbox alignment
@@ -1597,34 +2387,44 @@ export function parseSlideHtml(doc) {
1597
2387
  align = 'right';
1598
2388
  }
1599
2389
  else {
1600
- const paddingLeft = parseFloat(computed.paddingLeft) || 0;
1601
- const paddingRight = parseFloat(computed.paddingRight) || 0;
1602
- const paddingDiff = Math.abs(paddingLeft - paddingRight);
1603
- if (paddingLeft > 0 && paddingDiff < 2) {
1604
- align = 'center';
1605
- }
1606
- else {
1607
- align = 'left';
1608
- }
2390
+ align = 'left';
1609
2391
  }
1610
2392
  }
1611
2393
  let shapeText = '';
1612
2394
  let shapeTextRuns = null;
1613
2395
  let shapeStyle = null;
1614
2396
  const hasTextChildren = textChildren.length > 0;
2397
+ // Check for direct text nodes (not wrapped in elements) — handles badges like:
2398
+ // <div class="badge"><svg>icon</svg> Heated stone floors</div>
2399
+ // where "Heated stone floors" is a direct text node, not a child element
2400
+ let directTextContent = '';
2401
+ if (!hasTextChildren) {
2402
+ for (const node of Array.from(el.childNodes)) {
2403
+ if (node.nodeType === Node.TEXT_NODE) {
2404
+ directTextContent += node.textContent || '';
2405
+ }
2406
+ }
2407
+ directTextContent = directTextContent.trim();
2408
+ // Apply text-transform from parent element to direct text nodes
2409
+ if (directTextContent && computed.textTransform && computed.textTransform !== 'none') {
2410
+ directTextContent = applyTextTransform(directTextContent, computed.textTransform);
2411
+ }
2412
+ }
2413
+ const hasDirectText = directTextContent.length > 0;
1615
2414
  // When a container has BOTH non-text children (styled shapes like icon-circles)
1616
2415
  // AND multiple text children, don't merge the text into the parent shape.
1617
2416
  // The text children have their own positioning via flex/grid layout and should
1618
2417
  // remain as standalone elements for better fidelity. Only merge text when:
1619
2418
  // - It's a single text child (isSingleTextChild), OR
1620
- // - There are ONLY text children (no non-text siblings to compete with for space)
2419
+ // - There are ONLY text children (no non-text siblings to compete with for space), OR
2420
+ // - There's direct text content (text nodes, not child elements)
1621
2421
  const shouldMergeText = hasTextChildren && (isSingleTextChild ||
1622
- nonTextChildren.length === 0);
2422
+ nonTextChildren.length === 0) || hasDirectText;
1623
2423
  if (shouldMergeText) {
1624
2424
  if (isSingleTextChild) {
1625
2425
  const textEl = textChildren[0];
1626
2426
  const textComputed = win.getComputedStyle(textEl);
1627
- shapeText = textEl.textContent.trim();
2427
+ shapeText = getTransformedText(textEl, textComputed);
1628
2428
  let fontFill = null;
1629
2429
  const textBgClip = textComputed.webkitBackgroundClip ||
1630
2430
  textComputed.backgroundClip;
@@ -1679,10 +2479,7 @@ export function parseSlideHtml(doc) {
1679
2479
  rect.height < 50;
1680
2480
  shapeStyle = {
1681
2481
  fontSize: pxToPoints(textComputed.fontSize || computed.fontSize),
1682
- fontFace: (textComputed.fontFamily || computed.fontFamily)
1683
- .split(',')[0]
1684
- .replace(/['"]/g, '')
1685
- .trim(),
2482
+ fontFace: extractFontFace(textComputed.fontFamily || computed.fontFamily),
1686
2483
  color: fontFill ? null : rgbToHex(effectiveColor),
1687
2484
  fontFill: fontFill,
1688
2485
  bold: isBold,
@@ -1704,12 +2501,12 @@ export function parseSlideHtml(doc) {
1704
2501
  // Also mark descendants as processed (e.g., <p> from transform.ts wrapping)
1705
2502
  textEl.querySelectorAll('*').forEach(desc => processed.add(desc));
1706
2503
  }
1707
- else {
2504
+ else if (hasTextChildren) {
1708
2505
  shapeTextRuns = [];
1709
2506
  textChildren.forEach((textChild, idx) => {
1710
2507
  const textEl = textChild;
1711
2508
  const textComputed = win.getComputedStyle(textEl);
1712
- const fullText = textEl.textContent.trim();
2509
+ const fullText = getTransformedText(textEl, textComputed);
1713
2510
  if (!fullText)
1714
2511
  return;
1715
2512
  const isBold = textComputed.fontWeight === 'bold' ||
@@ -1719,10 +2516,7 @@ export function parseSlideHtml(doc) {
1719
2516
  textComputed.textDecoration.includes('underline');
1720
2517
  const baseRunOptions = {
1721
2518
  fontSize: pxToPoints(textComputed.fontSize),
1722
- fontFace: textComputed.fontFamily
1723
- .split(',')[0]
1724
- .replace(/['"]/g, '')
1725
- .trim(),
2519
+ fontFace: extractFontFace(textComputed.fontFamily),
1726
2520
  color: rgbToHex(textComputed.color),
1727
2521
  bold: isBold,
1728
2522
  italic: isItalic,
@@ -1751,6 +2545,10 @@ export function parseSlideHtml(doc) {
1751
2545
  if (currentSegment.trim())
1752
2546
  segments.push(currentSegment.trim());
1753
2547
  segments = segments.filter(s => s.length > 0);
2548
+ // Apply text-transform to each segment
2549
+ if (textComputed.textTransform && textComputed.textTransform !== 'none') {
2550
+ segments = segments.map(s => applyTextTransform(s, textComputed.textTransform));
2551
+ }
1754
2552
  segments.forEach((segment, segIdx) => {
1755
2553
  const prefix = (segIdx === 0 && idx > 0 && shapeTextRuns.length > 0) ? '\n' : '';
1756
2554
  const runText = prefix + segment;
@@ -1779,6 +2577,34 @@ export function parseSlideHtml(doc) {
1779
2577
  inset: 0,
1780
2578
  };
1781
2579
  }
2580
+ else if (hasDirectText) {
2581
+ // Handle direct text nodes (e.g., badge text alongside SVG icons)
2582
+ // Use directTextContent which was already extracted above
2583
+ shapeText = directTextContent;
2584
+ const isBold = parseInt(computed.fontWeight) >= 600;
2585
+ const isItalic = computed.fontStyle === 'italic';
2586
+ // For small elements (badges, labels), disable text wrapping to prevent overflow
2587
+ const whiteSpace = computed.whiteSpace;
2588
+ const shouldNotWrap = whiteSpace === 'nowrap' ||
2589
+ whiteSpace === 'pre' ||
2590
+ rect.width < 100 ||
2591
+ rect.height < 50;
2592
+ shapeStyle = {
2593
+ fontSize: pxToPoints(computed.fontSize),
2594
+ fontFace: extractFontFace(computed.fontFamily),
2595
+ color: rgbToHex(computed.color),
2596
+ bold: isBold,
2597
+ italic: isItalic,
2598
+ align: align,
2599
+ valign: valign,
2600
+ inset: 0,
2601
+ wrap: !shouldNotWrap,
2602
+ };
2603
+ // Extract letter-spacing for direct text
2604
+ const ls = extractLetterSpacing(computed);
2605
+ if (ls !== null)
2606
+ shapeStyle.charSpacing = ls;
2607
+ }
1782
2608
  }
1783
2609
  if (hasBg || hasUniformBorder || bgGradient) {
1784
2610
  // Detect element-level opacity
@@ -1837,6 +2663,7 @@ export function parseSlideHtml(doc) {
1837
2663
  color: rgbToHex(computed.borderColor),
1838
2664
  width: pxToPoints(computed.borderWidth),
1839
2665
  transparency: extractAlpha(computed.borderColor),
2666
+ dashType: extractDashType(computed.borderStyle),
1840
2667
  }
1841
2668
  : null,
1842
2669
  rectRadius: (() => {
@@ -1860,12 +2687,51 @@ export function parseSlideHtml(doc) {
1860
2687
  opacity: hasOpacity ? elementOpacity : null,
1861
2688
  isEllipse: isEllipse,
1862
2689
  softEdge: softEdgePt,
2690
+ customGeometry: clipPathPolygon ? (() => {
2691
+ // Convert percentage-based polygon points to EMU coordinates
2692
+ // relative to the shape's bounding box.
2693
+ // PptxGenJS custGeom uses the cx/cy (extent) as the path coordinate space.
2694
+ const EMU = 914400; // EMUs per inch
2695
+ const pathW = Math.round(rect.width / PX_PER_IN * EMU);
2696
+ const pathH = Math.round(rect.height / PX_PER_IN * EMU);
2697
+ const points = [];
2698
+ clipPathPolygon.forEach((pt, i) => {
2699
+ points.push({
2700
+ x: Math.round(pt.x * pathW),
2701
+ y: Math.round(pt.y * pathH),
2702
+ ...(i === 0 ? { moveTo: true } : {}),
2703
+ });
2704
+ });
2705
+ points.push({ x: 0, y: 0, close: true });
2706
+ return points;
2707
+ })() : null,
1863
2708
  },
1864
2709
  };
1865
2710
  // Apply CSS padding as text body insets when shape has text content
1866
2711
  if (hasPadding && shapeElement.style && (shapeText || (shapeTextRuns && shapeTextRuns.length > 0))) {
2712
+ // When there's direct text with sibling elements (e.g., SVG icons),
2713
+ // we need to increase the left inset to account for the sibling element width + gap
2714
+ let effectiveLeftPadding = paddingLeft;
2715
+ if (hasDirectText && allChildren.length > 0) {
2716
+ // Find preceding sibling elements and calculate total width + gap
2717
+ for (const child of allChildren) {
2718
+ if (child.nodeType === Node.ELEMENT_NODE) {
2719
+ const childEl = child;
2720
+ const childRect = childEl.getBoundingClientRect();
2721
+ const childComputed = win.getComputedStyle(childEl);
2722
+ const marginRight = parseFloat(childComputed.marginRight) || 0;
2723
+ // Add child width + its margin to effective left padding
2724
+ effectiveLeftPadding += childRect.width + marginRight;
2725
+ }
2726
+ }
2727
+ // Also account for the gap property on flex containers
2728
+ const gap = parseFloat(computed.gap) || 0;
2729
+ if (gap > 0 && allChildren.length > 0) {
2730
+ effectiveLeftPadding += gap;
2731
+ }
2732
+ }
1867
2733
  shapeElement.style.margin = [
1868
- paddingLeft * PT_PER_PX, // left
2734
+ effectiveLeftPadding * PT_PER_PX, // left
1869
2735
  paddingRight * PT_PER_PX, // right
1870
2736
  paddingBottom * PT_PER_PX, // bottom
1871
2737
  paddingTop * PT_PER_PX, // top
@@ -1890,6 +2756,15 @@ export function parseSlideHtml(doc) {
1890
2756
  else if (isSingleTextChild) {
1891
2757
  processed.delete(textChildren[0]);
1892
2758
  }
2759
+ else if (textChildren.length > 0) {
2760
+ // No shape was created (no bg/border/gradient) but text children were
2761
+ // collected and marked as processed. Un-process them so they can be
2762
+ // handled by their own tag handlers (e.g., SPAN handler for badge tags).
2763
+ textChildren.forEach(tc => {
2764
+ processed.delete(tc);
2765
+ tc.querySelectorAll('*').forEach(desc => processed.delete(desc));
2766
+ });
2767
+ }
1893
2768
  elements.push(...borderLines);
1894
2769
  processed.add(el);
1895
2770
  return;
@@ -1897,11 +2772,16 @@ export function parseSlideHtml(doc) {
1897
2772
  }
1898
2773
  // Plain DIVs with DIRECT text node content but no visual styling
1899
2774
  // (e.g. slide-footer "6 / 6", slide-number "03 / 06", stat values/labels)
1900
- // CRITICAL: Only extract if the DIV has meaningful direct text nodes,
1901
- // NOT just text inherited from descendant elements.
2775
+ // Also handles DIVs containing only inline text elements like <strong>, <em>, etc.
2776
+ // (e.g. bullet descriptions with highlighted text)
1902
2777
  if (isContainer) {
1903
2778
  const rect = htmlEl.getBoundingClientRect();
1904
2779
  if (rect.width > 0 && rect.height > 0) {
2780
+ // Inline text elements that don't break text-only status
2781
+ // Note: P is NOT included here — it's a block-level element with independent
2782
+ // styling (font size, weight, margins). The single-P wrapper case (from
2783
+ // transform.ts wrapping bare text) is handled separately below.
2784
+ const inlineTextTagsSet = new Set(['STRONG', 'EM', 'B', 'I', 'A', 'BR', 'SPAN', 'MARK', 'SMALL', 'SUB', 'SUP', 'CODE', 'U', 'S', 'Q', 'CITE', 'ABBR', 'TIME', 'DATA']);
1905
2785
  // Collect direct text nodes (not from children)
1906
2786
  // Also handle <p>-wrapped text from transform.ts which wraps bare text in <p> tags
1907
2787
  let directText = '';
@@ -1912,33 +2792,142 @@ export function parseSlideHtml(doc) {
1912
2792
  }
1913
2793
  directText = directText.trim();
1914
2794
  // If no direct text nodes, check for a single <p> wrapper (from transform.ts)
2795
+ // This handles both plain <p>text</p> and <p>text with <strong>bold</strong></p>
1915
2796
  const childElements = Array.from(el.children);
2797
+ const singlePInlineCheck = new Set(['STRONG', 'EM', 'B', 'I', 'A', 'BR', 'SPAN', 'MARK', 'SMALL', 'SUB', 'SUP', 'CODE', 'U', 'S']);
1916
2798
  if (!directText && childElements.length === 1 && childElements[0].tagName === 'P' &&
1917
- childElements[0].children.length === 0) {
2799
+ (childElements[0].children.length === 0 ||
2800
+ Array.from(childElements[0].children).every(ce => singlePInlineCheck.has(ce.tagName)))) {
1918
2801
  directText = childElements[0].textContent?.trim() || '';
1919
2802
  }
2803
+ // Apply text-transform from CSS (PPTX doesn't support CSS text-transform)
2804
+ if (directText) {
2805
+ const directTextComputed = win.getComputedStyle(el);
2806
+ if (directTextComputed.textTransform && directTextComputed.textTransform !== 'none') {
2807
+ directText = applyTextTransform(directText, directTextComputed.textTransform);
2808
+ }
2809
+ }
2810
+ // Check if this DIV contains only inline text elements (like <strong>, <em>, etc.)
2811
+ // mixed with text nodes. If so, we should extract the full textContent.
2812
+ // BUT: DIVs with flex-row or grid layout containing multiple children are
2813
+ // layout containers — their children are positioned spatially and must be
2814
+ // extracted independently to preserve horizontal positioning.
2815
+ const plainDivComputed = win.getComputedStyle(el);
2816
+ const plainDivDisplay = plainDivComputed.display;
2817
+ const plainDivFlexDir = plainDivComputed.flexDirection || 'row';
2818
+ const plainDivChildCount = el.children.length;
2819
+ const isPlainDivFlexRow = (plainDivDisplay === 'flex' || plainDivDisplay === 'inline-flex') &&
2820
+ (plainDivFlexDir === 'row' || plainDivFlexDir === 'row-reverse') &&
2821
+ plainDivChildCount > 1;
2822
+ const isPlainDivGrid = plainDivDisplay === 'grid' || plainDivDisplay === 'inline-grid';
2823
+ const allChildrenAreInlineText = !isPlainDivFlexRow && !isPlainDivGrid && (childElements.length === 0 ||
2824
+ childElements.every(ce => inlineTextTagsSet.has(ce.tagName.toUpperCase())));
1920
2825
  // Only proceed if this DIV has meaningful text content
1921
2826
  // AND has no structural child elements that would be extracted separately
1922
- const hasStructuralChildren = childElements.length > 0 &&
1923
- !childElements.every(ce => ce.tagName === 'BR') &&
1924
- !(childElements.length === 1 && childElements[0].tagName === 'P' && childElements[0].children.length === 0);
1925
- if (directText && !hasStructuralChildren) {
2827
+ // SVG children are decorative (already extracted as images) and shouldn't block text extraction
2828
+ const structuralChildElements = childElements.filter(ce => {
2829
+ const tagName = ce.tagName.toUpperCase();
2830
+ return tagName !== 'BR' &&
2831
+ tagName !== 'SVG' &&
2832
+ !inlineTextTagsSet.has(tagName);
2833
+ });
2834
+ const hasStructuralChildren = structuralChildElements.length > 0;
2835
+ // If all children are inline text elements (or no children), use the full textContent
2836
+ // This handles cases like: "Text with <strong>bold</strong> content"
2837
+ // where we need to capture both direct text nodes and inline element content
2838
+ let extractedText = '';
2839
+ if (allChildrenAreInlineText) {
2840
+ extractedText = el.textContent?.trim() || '';
2841
+ // Apply text-transform
2842
+ const inlineTextComputed = win.getComputedStyle(el);
2843
+ if (extractedText && inlineTextComputed.textTransform && inlineTextComputed.textTransform !== 'none') {
2844
+ extractedText = applyTextTransform(extractedText, inlineTextComputed.textTransform);
2845
+ }
2846
+ }
2847
+ else if (directText) {
2848
+ // Fallback to direct text only if there are structural children
2849
+ extractedText = directText;
2850
+ }
2851
+ if (extractedText && !hasStructuralChildren) {
1926
2852
  const computed2 = win.getComputedStyle(el);
1927
2853
  const fontSizePx = parseFloat(computed2.fontSize);
1928
2854
  const lineHeightPx = parseFloat(computed2.lineHeight);
1929
2855
  const lineHeightMultiplier = fontSizePx > 0 && !isNaN(lineHeightPx) ? lineHeightPx / fontSizePx : 1.0;
2856
+ // Calculate text position
2857
+ // For inline text elements (allChildrenAreInlineText), always use the full container bounds
2858
+ // For direct text only (no inline children), use Range API to get precise bounds
2859
+ let textLeft = rect.left;
2860
+ let textWidth = rect.width;
2861
+ // Only use Range API positioning for pure direct text (no inline element children)
2862
+ // When there are inline elements like <strong>, <em>, etc., the text is spread across
2863
+ // multiple nodes and we need the full container bounds
2864
+ if (directText && !allChildrenAreInlineText) {
2865
+ // Find the first non-empty text node and get its bounding rect
2866
+ for (const child of Array.from(el.childNodes)) {
2867
+ if (child.nodeType === Node.TEXT_NODE && (child.textContent || '').trim()) {
2868
+ const range = document.createRange();
2869
+ range.selectNodeContents(child);
2870
+ const textRect = range.getBoundingClientRect();
2871
+ if (textRect.width > 0) {
2872
+ textLeft = textRect.left;
2873
+ textWidth = textRect.width;
2874
+ }
2875
+ break;
2876
+ }
2877
+ }
2878
+ }
2879
+ // For allChildrenAreInlineText, we use the full container bounds
2880
+ // which is already set as textLeft = rect.left and textWidth = rect.width
2881
+ // Use container's Y position for vertical alignment with SVG siblings
2882
+ // (Range API may return different Y due to line-height/baseline differences)
2883
+ const textTop = rect.top;
2884
+ const textHeight = rect.height;
2885
+ // When the div contains inline text elements (strong, em, etc.),
2886
+ // use parseInlineFormatting to preserve per-run styling (color, bold, etc.).
2887
+ // Otherwise, create a single text run.
2888
+ let textRuns;
2889
+ if (allChildrenAreInlineText && childElements.length > 0) {
2890
+ const baseRunOptions = {
2891
+ fontSize: pxToPoints(computed2.fontSize),
2892
+ fontFace: extractFontFace(computed2.fontFamily),
2893
+ color: rgbToHex(computed2.color),
2894
+ };
2895
+ if (parseInt(computed2.fontWeight) >= 600)
2896
+ baseRunOptions.bold = true;
2897
+ if (computed2.fontStyle === 'italic')
2898
+ baseRunOptions.italic = true;
2899
+ // Apply text-transform function
2900
+ let textTransformFn = (s) => s;
2901
+ if (computed2.textTransform && computed2.textTransform !== 'none') {
2902
+ textTransformFn = (text) => applyTextTransform(text, computed2.textTransform);
2903
+ }
2904
+ textRuns = parseInlineFormatting(el, baseRunOptions, [], textTransformFn, win);
2905
+ // Fallback to single run if parseInlineFormatting produced nothing
2906
+ if (textRuns.length === 0) {
2907
+ textRuns = [{ text: extractedText, options: {} }];
2908
+ }
2909
+ }
2910
+ else {
2911
+ textRuns = [{ text: extractedText, options: {} }];
2912
+ }
2913
+ // Extract padding from computed style for PPTX margin/inset
2914
+ const paddingTop = parseFloat(computed2.paddingTop) || 0;
2915
+ const paddingRight = parseFloat(computed2.paddingRight) || 0;
2916
+ const paddingBottom = parseFloat(computed2.paddingBottom) || 0;
2917
+ const paddingLeft = parseFloat(computed2.paddingLeft) || 0;
2918
+ const hasPadding = paddingTop > 0 || paddingRight > 0 || paddingBottom > 0 || paddingLeft > 0;
1930
2919
  const textElement = {
1931
2920
  type: 'p',
1932
- text: [{ text: directText, options: {} }],
2921
+ text: textRuns,
1933
2922
  position: {
1934
- x: pxToInch(rect.left),
1935
- y: pxToInch(rect.top),
1936
- w: pxToInch(rect.width),
1937
- h: pxToInch(rect.height),
2923
+ x: pxToInch(textLeft),
2924
+ y: pxToInch(textTop),
2925
+ w: pxToInch(textWidth),
2926
+ h: pxToInch(textHeight),
1938
2927
  },
1939
2928
  style: {
1940
2929
  fontSize: pxToPoints(computed2.fontSize),
1941
- fontFace: computed2.fontFamily.split(',')[0].replace(/['"]/g, '').trim(),
2930
+ fontFace: extractFontFace(computed2.fontFamily),
1942
2931
  color: rgbToHex(computed2.color),
1943
2932
  bold: parseInt(computed2.fontWeight) >= 600,
1944
2933
  italic: computed2.fontStyle === 'italic',
@@ -1947,8 +2936,11 @@ export function parseSlideHtml(doc) {
1947
2936
  : computed2.textAlign === 'right' || computed2.textAlign === 'end'
1948
2937
  ? 'right'
1949
2938
  : 'left',
1950
- valign: 'middle',
2939
+ valign: hasPadding ? 'top' : 'middle',
1951
2940
  lineSpacing: lineHeightMultiplier * pxToPoints(computed2.fontSize),
2941
+ margin: hasPadding
2942
+ ? [paddingLeft * PT_PER_PX, paddingRight * PT_PER_PX, paddingBottom * PT_PER_PX, paddingTop * PT_PER_PX]
2943
+ : undefined,
1952
2944
  },
1953
2945
  };
1954
2946
  // Check for text transparency
@@ -1962,8 +2954,13 @@ export function parseSlideHtml(doc) {
1962
2954
  textElement.style.charSpacing = ls;
1963
2955
  elements.push(textElement);
1964
2956
  processed.add(el);
1965
- // Also mark <p> or <br> children as processed
1966
- el.querySelectorAll('*').forEach(desc => processed.add(desc));
2957
+ // Mark text-related children as processed, but NOT SVG elements
2958
+ // (SVGs should be extracted separately as images)
2959
+ el.querySelectorAll('*').forEach(desc => {
2960
+ if (desc.tagName.toUpperCase() !== 'SVG') {
2961
+ processed.add(desc);
2962
+ }
2963
+ });
1967
2964
  return;
1968
2965
  }
1969
2966
  }
@@ -2034,7 +3031,7 @@ export function parseSlideHtml(doc) {
2034
3031
  },
2035
3032
  style: {
2036
3033
  fontSize: pxToPoints(liComputed.fontSize),
2037
- fontFace: liComputed.fontFamily.split(',')[0].replace(/['"]/g, '').trim(),
3034
+ fontFace: extractFontFace(liComputed.fontFamily),
2038
3035
  color: rgbToHex(liComputed.color),
2039
3036
  transparency: extractAlpha(liComputed.color),
2040
3037
  align: liComputed.textAlign === 'start'
@@ -2055,10 +3052,10 @@ export function parseSlideHtml(doc) {
2055
3052
  if (!textTags.includes(el.tagName) || el.tagName === 'SPAN')
2056
3053
  return;
2057
3054
  let rect = htmlEl.getBoundingClientRect();
2058
- let text = el.textContent.trim();
3055
+ const computed = win.getComputedStyle(el);
3056
+ let text = getTransformedText(htmlEl, computed);
2059
3057
  if (rect.width === 0 || rect.height === 0 || !text)
2060
3058
  return;
2061
- const computed = win.getComputedStyle(el);
2062
3059
  // For flex containers with multiple children (e.g., LI with icon + text),
2063
3060
  // find the actual text position using a Range on the text nodes.
2064
3061
  // This ensures text is positioned at its visual location, not the container's.
@@ -2080,7 +3077,24 @@ export function parseSlideHtml(doc) {
2080
3077
  // Use the text's actual position if it differs from the element's
2081
3078
  if (textRect.width > 0 && textRect.height > 0) {
2082
3079
  rect = textRect;
2083
- text = textNodes.map((n) => n.textContent.trim()).join(' ').trim();
3080
+ // Apply text-transform from parent element to text nodes
3081
+ const rawText = textNodes.map((n) => n.textContent.trim()).join(' ').trim();
3082
+ text = applyTextTransform(rawText, computed.textTransform || 'none');
3083
+ }
3084
+ }
3085
+ else {
3086
+ // No direct text nodes — check for text inside child SPAN elements
3087
+ // This handles flex LI items like: <li><span class="icon"></span><span class="text">...</span></li>
3088
+ for (const child of Array.from(el.children)) {
3089
+ if (child.tagName === 'SPAN' && child.textContent?.trim()) {
3090
+ const childRect = child.getBoundingClientRect();
3091
+ if (childRect.width > 0 && childRect.height > 0) {
3092
+ rect = childRect;
3093
+ const childComputed = win.getComputedStyle(child);
3094
+ text = getTransformedText(child, childComputed);
3095
+ break;
3096
+ }
3097
+ }
2084
3098
  }
2085
3099
  }
2086
3100
  }
@@ -2124,7 +3138,7 @@ export function parseSlideHtml(doc) {
2124
3138
  }
2125
3139
  const baseStyle = {
2126
3140
  fontSize: pxToPoints(computed.fontSize),
2127
- fontFace: computed.fontFamily.split(',')[0].replace(/['"]/g, '').trim(),
3141
+ fontFace: extractFontFace(computed.fontFamily),
2128
3142
  color: rgbToHex(computed.color),
2129
3143
  align: textAlign,
2130
3144
  valign: valign,