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.
- package/dist/bundle.js +1282 -124
- package/dist/bundle.min.js +82 -80
- package/dist/cli.js +322 -29
- package/dist/packages/docs/convert.d.ts.map +1 -1
- package/dist/packages/docs/convert.js +18 -2
- package/dist/packages/docs/convert.js.map +1 -1
- package/dist/packages/slides/common.d.ts +17 -0
- package/dist/packages/slides/common.d.ts.map +1 -1
- package/dist/packages/slides/convert.d.ts +5 -2
- package/dist/packages/slides/convert.d.ts.map +1 -1
- package/dist/packages/slides/convert.js +122 -28
- package/dist/packages/slides/convert.js.map +1 -1
- package/dist/packages/slides/createPresentation.d.ts.map +1 -1
- package/dist/packages/slides/createPresentation.js +18 -1
- package/dist/packages/slides/createPresentation.js.map +1 -1
- package/dist/packages/slides/import-pptx.d.ts.map +1 -1
- package/dist/packages/slides/import-pptx.js +388 -25
- package/dist/packages/slides/import-pptx.js.map +1 -1
- package/dist/packages/slides/parse.d.ts.map +1 -1
- package/dist/packages/slides/parse.js +1103 -89
- package/dist/packages/slides/parse.js.map +1 -1
- package/dist/packages/slides/transform.d.ts.map +1 -1
- package/dist/packages/slides/transform.js +60 -10
- package/dist/packages/slides/transform.js.map +1 -1
- package/package.json +9 -4
|
@@ -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
|
-
|
|
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
|
-
|
|
758
|
-
|
|
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:
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
1061
|
-
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
|
-
|
|
1321
|
-
|
|
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
|
-
|
|
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 =
|
|
1917
|
+
const stroke = cloneEl.getAttribute('stroke');
|
|
1327
1918
|
const resolvedStroke = resolveColorValue(stroke);
|
|
1328
1919
|
if (resolvedStroke) {
|
|
1329
|
-
|
|
1920
|
+
cloneEl.setAttribute('stroke', resolvedStroke);
|
|
1330
1921
|
}
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
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(
|
|
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 —
|
|
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
|
|
1363
|
-
//
|
|
1364
|
-
//
|
|
1365
|
-
//
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
1547
|
-
// - it has a single <P> child with
|
|
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
|
|
1550
|
-
(tcChildren.length === 1 && tcChildren[0].tagName === 'P' &&
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
1901
|
-
//
|
|
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
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
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:
|
|
2921
|
+
text: textRuns,
|
|
1933
2922
|
position: {
|
|
1934
|
-
x: pxToInch(
|
|
1935
|
-
y: pxToInch(
|
|
1936
|
-
w: pxToInch(
|
|
1937
|
-
h: pxToInch(
|
|
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
|
|
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
|
-
//
|
|
1966
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
3141
|
+
fontFace: extractFontFace(computed.fontFamily),
|
|
2128
3142
|
color: rgbToHex(computed.color),
|
|
2129
3143
|
align: textAlign,
|
|
2130
3144
|
valign: valign,
|