dom-to-pptx 1.0.3 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/utils.js CHANGED
@@ -1,477 +1,452 @@
1
- // src/utils.js
2
-
3
- // Helper to save gradient text
4
- export function getGradientFallbackColor(bgImage) {
5
- if (!bgImage) return null;
6
- // Extract first hex or rgb color
7
- // linear-gradient(to right, #4f46e5, ...) -> #4f46e5
8
- const hexMatch = bgImage.match(/#(?:[0-9a-fA-F]{3}){1,2}/);
9
- if (hexMatch) return hexMatch[0];
10
-
11
- const rgbMatch = bgImage.match(/rgba?\(.*?\)/);
12
- if (rgbMatch) return rgbMatch[0];
13
-
14
- return null;
15
- }
16
-
17
- function mapDashType(style) {
18
- if (style === 'dashed') return 'dash';
19
- if (style === 'dotted') return 'dot';
20
- // PPTX also supports 'lgDash', 'dashDot', 'lgDashDot', 'lgDashDotDot'
21
- // but we'll stick to basics for now.
22
- return 'solid';
23
- }
24
-
25
- /**
26
- * Analyzes computed border styles and determines the rendering strategy.
27
- * @returns {{type: 'uniform' | 'composite' | 'none', ...}}
28
- */
29
- export function getBorderInfo(style, scale) {
30
- const top = {
31
- width: parseFloat(style.borderTopWidth) || 0,
32
- style: style.borderTopStyle,
33
- color: parseColor(style.borderTopColor).hex,
34
- };
35
- const right = {
36
- width: parseFloat(style.borderRightWidth) || 0,
37
- style: style.borderRightStyle,
38
- color: parseColor(style.borderRightColor).hex,
39
- };
40
- const bottom = {
41
- width: parseFloat(style.borderBottomWidth) || 0,
42
- style: style.borderBottomStyle,
43
- color: parseColor(style.borderBottomColor).hex,
44
- };
45
- const left = {
46
- width: parseFloat(style.borderLeftWidth) || 0,
47
- style: style.borderLeftStyle,
48
- color: parseColor(style.borderLeftColor).hex,
49
- };
50
-
51
- const hasAnyBorder = top.width > 0 || right.width > 0 || bottom.width > 0 || left.width > 0;
52
- if (!hasAnyBorder) return { type: 'none' };
53
-
54
- // Check if all sides are uniform
55
- const isUniform =
56
- top.width === right.width &&
57
- top.width === bottom.width &&
58
- top.width === left.width &&
59
- top.style === right.style &&
60
- top.style === bottom.style &&
61
- top.style === left.style &&
62
- top.color === right.color &&
63
- top.color === bottom.color &&
64
- top.color === left.color;
65
-
66
- if (isUniform) {
67
- return {
68
- type: 'uniform',
69
- options: {
70
- width: top.width * 0.75 * scale, // Convert to points and scale
71
- color: top.color,
72
- dashType: mapDashType(top.style),
73
- },
74
- };
75
- } else {
76
- // Borders are different, must render as separate shapes
77
- return {
78
- type: 'composite',
79
- sides: {
80
- top,
81
- right,
82
- bottom,
83
- left,
84
- },
85
- };
86
- }
87
- }
88
-
89
- /**
90
- * Generates an SVG image for composite borders that respects border-radius.
91
- */
92
- export function generateCompositeBorderSVG(w, h, radius, sides) {
93
- radius = radius / 2; // Adjust for SVG rendering
94
-
95
- const clipId = 'clip_' + Math.random().toString(36).substr(2, 9);
96
-
97
- let borderRects = '';
98
-
99
- // TOP
100
- if (sides.top.width > 0 && sides.top.color) {
101
- borderRects += `<rect x="0" y="0" width="${w}" height="${sides.top.width}" fill="#${sides.top.color}" />`;
102
- }
103
- // RIGHT
104
- if (sides.right.width > 0 && sides.right.color) {
105
- borderRects += `<rect x="${w - sides.right.width}" y="0" width="${sides.right.width}" height="${h}" fill="#${sides.right.color}" />`;
106
- }
107
- // BOTTOM
108
- if (sides.bottom.width > 0 && sides.bottom.color) {
109
- borderRects += `<rect x="0" y="${h - sides.bottom.width}" width="${w}" height="${sides.bottom.width}" fill="#${sides.bottom.color}" />`;
110
- }
111
- // LEFT
112
- if (sides.left.width > 0 && sides.left.color) {
113
- borderRects += `<rect x="0" y="0" width="${sides.left.width}" height="${h}" fill="#${sides.left.color}" />`;
114
- }
115
-
116
- const svg = `
117
- <svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
118
- <defs>
119
- <clipPath id="${clipId}">
120
- <rect x="0" y="0" width="${w}" height="${h}" rx="${radius}" ry="${radius}" />
121
- </clipPath>
122
- </defs>
123
- <g clip-path="url(#${clipId})">
124
- ${borderRects}
125
- </g>
126
- </svg>`;
127
-
128
- return 'data:image/svg+xml;base64,' + btoa(svg);
129
- }
130
-
131
- /**
132
- * Parses a CSS color string (hex, rgb, rgba) into a hex code and opacity.
133
- * @param {string} str - The CSS color string.
134
- * @returns {{hex: string | null, opacity: number}} - Object with hex color (without #) and opacity (0-1).
135
- */
136
- export function parseColor(str) {
137
- if (!str || str === 'transparent' || str.startsWith('rgba(0, 0, 0, 0)')) {
138
- return { hex: null, opacity: 0 };
139
- }
140
- if (str.startsWith('#')) {
141
- let hex = str.slice(1);
142
- if (hex.length === 3)
143
- hex = hex
144
- .split('')
145
- .map((c) => c + c)
146
- .join('');
147
- return { hex: hex.toUpperCase(), opacity: 1 };
148
- }
149
- const match = str.match(/[\d.]+/g);
150
- if (match && match.length >= 3) {
151
- const r = parseInt(match[0]);
152
- const g = parseInt(match[1]);
153
- const b = parseInt(match[2]);
154
- const a = match.length > 3 ? parseFloat(match[3]) : 1;
155
- const hex = ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase();
156
- return { hex, opacity: a };
157
- }
158
- return { hex: null, opacity: 0 };
159
- }
160
-
161
- /**
162
- * Calculates padding values from computed CSS styles, scaled to inches.
163
- * @param {CSSStyleDeclaration} style - The computed CSS style of the element.
164
- * @param {number} scale - The scaling factor for converting pixels to inches.
165
- * @returns {number[]} - An array of padding values [top, right, bottom, left] in inches.
166
- */
167
- export function getPadding(style, scale) {
168
- const pxToInch = 1 / 96;
169
- return [
170
- (parseFloat(style.paddingTop) || 0) * pxToInch * scale,
171
- (parseFloat(style.paddingRight) || 0) * pxToInch * scale,
172
- (parseFloat(style.paddingBottom) || 0) * pxToInch * scale,
173
- (parseFloat(style.paddingLeft) || 0) * pxToInch * scale,
174
- ];
175
- }
176
-
177
- /**
178
- * Extracts the blur radius for soft edges from a CSS filter string.
179
- * @param {string} filterStr - The CSS filter string.
180
- * @param {number} scale - The scaling factor.
181
- * @returns {number | null} - The blur radius in points, or null if no blur is found.
182
- */
183
- export function getSoftEdges(filterStr, scale) {
184
- if (!filterStr || filterStr === 'none') return null;
185
- const match = filterStr.match(/blur\(([\d.]+)px\)/);
186
- if (match) return parseFloat(match[1]) * 0.75 * scale;
187
- return null;
188
- }
189
-
190
- /**
191
- * Generates text style options for PPTX from computed CSS styles.
192
- * Handles font properties, color, and text transformations.
193
- * @param {CSSStyleDeclaration} style - The computed CSS style of the element.
194
- * @param {number} scale - The scaling factor for converting pixels to inches.
195
- * @returns {PptxGenJS.TextOptions} - PPTX text style object.
196
- */
197
- export function getTextStyle(style, scale) {
198
- let colorObj = parseColor(style.color);
199
-
200
- const bgClip = style.webkitBackgroundClip || style.backgroundClip;
201
- if (colorObj.opacity === 0 && bgClip === 'text') {
202
- const fallback = getGradientFallbackColor(style.backgroundImage);
203
- if (fallback) colorObj = parseColor(fallback);
204
- }
205
-
206
- return {
207
- color: colorObj.hex || '000000',
208
- fontFace: style.fontFamily.split(',')[0].replace(/['"]/g, ''),
209
- fontSize: parseFloat(style.fontSize) * 0.75 * scale,
210
- bold: parseInt(style.fontWeight) >= 600,
211
- italic: style.fontStyle === 'italic',
212
- underline: style.textDecoration.includes('underline'),
213
- };
214
- }
215
-
216
- /**
217
- * Determines if a given DOM node is primarily a text container.
218
- * Checks if the node has text content and if its children are all inline elements.
219
- * @param {HTMLElement} node - The DOM node to check.
220
- * @returns {boolean} - True if the node is considered a text container, false otherwise.
221
- */
222
- export function isTextContainer(node) {
223
- const hasText = node.textContent.trim().length > 0;
224
- if (!hasText) return false;
225
- const children = Array.from(node.children);
226
- if (children.length === 0) return true;
227
- const isInline = (el) =>
228
- window.getComputedStyle(el).display.includes('inline') ||
229
- ['SPAN', 'B', 'STRONG', 'EM', 'I', 'A', 'SMALL'].includes(el.tagName);
230
- return children.every(isInline);
231
- }
232
-
233
- /**
234
- * Extracts the rotation angle in degrees from a CSS transform string.
235
- * @param {string} transformStr - The CSS transform string.
236
- * @returns {number} - The rotation angle in degrees.
237
- */
238
- export function getRotation(transformStr) {
239
- if (!transformStr || transformStr === 'none') return 0;
240
- const values = transformStr.split('(')[1].split(')')[0].split(',');
241
- if (values.length < 4) return 0;
242
- const a = parseFloat(values[0]);
243
- const b = parseFloat(values[1]);
244
- return Math.round(Math.atan2(b, a) * (180 / Math.PI));
245
- }
246
-
247
- /**
248
- * Converts an SVG DOM node to a PNG data URL.
249
- * Inlines styles to ensure accurate rendering in the PNG.
250
- * @param {SVGElement} node - The SVG DOM node to convert.
251
- * @returns {Promise<string | null>} - A Promise that resolves with the PNG data URL or null on error.
252
- */
253
- export function svgToPng(node) {
254
- return new Promise((resolve) => {
255
- const clone = node.cloneNode(true);
256
- const rect = node.getBoundingClientRect();
257
- const width = rect.width || 300;
258
- const height = rect.height || 150;
259
-
260
- function inlineStyles(source, target) {
261
- const computed = window.getComputedStyle(source);
262
- const properties = [
263
- 'fill',
264
- 'stroke',
265
- 'stroke-width',
266
- 'stroke-linecap',
267
- 'stroke-linejoin',
268
- 'opacity',
269
- 'font-family',
270
- 'font-size',
271
- 'font-weight',
272
- ];
273
-
274
- if (computed.fill === 'none') target.setAttribute('fill', 'none');
275
- else if (computed.fill) target.style.fill = computed.fill;
276
-
277
- if (computed.stroke === 'none') target.setAttribute('stroke', 'none');
278
- else if (computed.stroke) target.style.stroke = computed.stroke;
279
-
280
- properties.forEach((prop) => {
281
- if (prop !== 'fill' && prop !== 'stroke') {
282
- const val = computed[prop];
283
- if (val && val !== 'auto') target.style[prop] = val;
284
- }
285
- });
286
-
287
- for (let i = 0; i < source.children.length; i++) {
288
- if (target.children[i]) inlineStyles(source.children[i], target.children[i]);
289
- }
290
- }
291
-
292
- inlineStyles(node, clone);
293
-
294
- clone.setAttribute('width', width);
295
- clone.setAttribute('height', height);
296
- clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
297
-
298
- const xml = new XMLSerializer().serializeToString(clone);
299
- const svgUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(xml)}`;
300
-
301
- const img = new Image();
302
- img.crossOrigin = 'Anonymous';
303
- img.onload = () => {
304
- const canvas = document.createElement('canvas');
305
- const scale = 3;
306
- canvas.width = width * scale;
307
- canvas.height = height * scale;
308
- const ctx = canvas.getContext('2d');
309
- ctx.scale(scale, scale);
310
- ctx.drawImage(img, 0, 0, width, height);
311
- resolve(canvas.toDataURL('image/png'));
312
- };
313
- img.onerror = () => resolve(null);
314
- img.src = svgUrl;
315
- });
316
- }
317
-
318
- /**
319
- * Parses CSS box-shadow properties and converts them into PPTX-compatible shadow options.
320
- * Supports multiple shadows, prioritizing the first visible (non-transparent) outer shadow.
321
- * @param {string} shadowStr - The CSS `box-shadow` string.
322
- * @param {number} scale - The scaling factor.
323
- * @returns {PptxGenJS.ShapeShadow | null} - PPTX shadow options, or null if no visible outer shadow.
324
- */
325
- export function getVisibleShadow(shadowStr, scale) {
326
- if (!shadowStr || shadowStr === 'none') return null;
327
- const shadows = shadowStr.split(/,(?![^()]*\))/);
328
- for (let s of shadows) {
329
- s = s.trim();
330
- if (s.startsWith('rgba(0, 0, 0, 0)')) continue;
331
- const match = s.match(
332
- /(rgba?\([^)]+\)|#[0-9a-fA-F]+)\s+(-?[\d.]+)px\s+(-?[\d.]+)px\s+([\d.]+)px/
333
- );
334
- if (match) {
335
- const colorStr = match[1];
336
- const x = parseFloat(match[2]);
337
- const y = parseFloat(match[3]);
338
- const blur = parseFloat(match[4]);
339
- const distance = Math.sqrt(x * x + y * y);
340
- let angle = Math.atan2(y, x) * (180 / Math.PI);
341
- if (angle < 0) angle += 360;
342
- const colorObj = parseColor(colorStr);
343
- return {
344
- type: 'outer',
345
- angle: angle,
346
- blur: blur * 0.75 * scale,
347
- offset: distance * 0.75 * scale,
348
- color: colorObj.hex || '000000',
349
- opacity: colorObj.opacity,
350
- };
351
- }
352
- }
353
- return null;
354
- }
355
-
356
- /**
357
- * Generates an SVG data URL for a linear gradient background with optional border-radius and border.
358
- * Parses CSS linear-gradient string to create SVG <linearGradient> and <rect> elements.
359
- * @param {number} w - Width of the SVG.
360
- * @param {number} h - Height of the SVG.
361
- * @param {string} bgString - The CSS `background-image` string (e.g., `linear-gradient(...)`).
362
- * @param {number} radius - Border radius for the rectangle.
363
- * @param {{color: string, width: number} | null} border - Optional border object with color (hex) and width.
364
- * @returns {string | null} - SVG data URL or null if parsing fails.
365
- */
366
- export function generateGradientSVG(w, h, bgString, radius, border) {
367
- try {
368
- const match = bgString.match(/linear-gradient\((.*)\)/);
369
- if (!match) return null;
370
- const content = match[1];
371
- const parts = content.split(/,(?![^()]*\))/).map((p) => p.trim());
372
-
373
- let x1 = '0%',
374
- y1 = '0%',
375
- x2 = '0%',
376
- y2 = '100%';
377
- let stopsStartIdx = 0;
378
- if (parts[0].includes('to right')) {
379
- x1 = '0%';
380
- x2 = '100%';
381
- y2 = '0%';
382
- stopsStartIdx = 1;
383
- } else if (parts[0].includes('to left')) {
384
- x1 = '100%';
385
- x2 = '0%';
386
- y2 = '0%';
387
- stopsStartIdx = 1;
388
- } else if (parts[0].includes('to top')) {
389
- y1 = '100%';
390
- y2 = '0%';
391
- stopsStartIdx = 1;
392
- } else if (parts[0].includes('to bottom')) {
393
- y1 = '0%';
394
- y2 = '100%';
395
- stopsStartIdx = 1;
396
- }
397
-
398
- let stopsXML = '';
399
- const stopParts = parts.slice(stopsStartIdx);
400
- stopParts.forEach((part, idx) => {
401
- let color = part;
402
- let offset = Math.round((idx / (stopParts.length - 1)) * 100) + '%';
403
- const posMatch = part.match(/(.*?)\s+(\d+(\.\d+)?%?)$/);
404
- if (posMatch) {
405
- color = posMatch[1];
406
- offset = posMatch[2];
407
- }
408
- let opacity = 1;
409
- if (color.includes('rgba')) {
410
- const rgba = color.match(/[\d.]+/g);
411
- if (rgba && rgba.length > 3) {
412
- opacity = rgba[3];
413
- color = `rgb(${rgba[0]},${rgba[1]},${rgba[2]})`;
414
- }
415
- }
416
- stopsXML += `<stop offset="${offset}" stop-color="${color}" stop-opacity="${opacity}"/>`;
417
- });
418
-
419
- let strokeAttr = '';
420
- if (border) {
421
- strokeAttr = `stroke="#${border.color}" stroke-width="${border.width}"`;
422
- }
423
-
424
- const svg = `
425
- <svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
426
- <defs><linearGradient id="grad" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}">${stopsXML}</linearGradient></defs>
427
- <rect x="0" y="0" width="${w}" height="${h}" rx="${radius}" ry="${radius}" fill="url(#grad)" ${strokeAttr} />
428
- </svg>`;
429
- return 'data:image/svg+xml;base64,' + btoa(svg);
430
- } catch {
431
- return null;
432
- }
433
- }
434
-
435
- /**
436
- * Generates an SVG data URL for a blurred rectangle or ellipse, used for soft-edge effects.
437
- * @param {number} w - Original width of the element.
438
- * @param {number} h - Original height of the element.
439
- * @param {string} color - Hex color of the shape (without #).
440
- * @param {number} radius - Border radius of the shape.
441
- * @param {number} blurPx - Blur radius in pixels for the SVG filter.
442
- * @returns {{data: string, padding: number}} - Object containing SVG data URL and calculated padding.
443
- */
444
- export function generateBlurredSVG(w, h, color, radius, blurPx) {
445
- const padding = blurPx * 3;
446
- const fullW = w + padding * 2;
447
- const fullH = h + padding * 2;
448
- const x = padding;
449
- const y = padding;
450
- let shapeTag = '';
451
- const isCircle = radius >= Math.min(w, h) / 2 - 1 && Math.abs(w - h) < 2;
452
-
453
- if (isCircle) {
454
- const cx = x + w / 2;
455
- const cy = y + h / 2;
456
- const rx = w / 2;
457
- const ry = h / 2;
458
- shapeTag = `<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}" fill="#${color}" filter="url(#f1)" />`;
459
- } else {
460
- shapeTag = `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${radius}" ry="${radius}" fill="#${color}" filter="url(#f1)" />`;
461
- }
462
-
463
- const svg = `
464
- <svg xmlns="http://www.w3.org/2000/svg" width="${fullW}" height="${fullH}" viewBox="0 0 ${fullW} ${fullH}">
465
- <defs>
466
- <filter id="f1" x="-50%" y="-50%" width="200%" height="200%">
467
- <feGaussianBlur in="SourceGraphic" stdDeviation="${blurPx}" />
468
- </filter>
469
- </defs>
470
- ${shapeTag}
471
- </svg>`;
472
-
473
- return {
474
- data: 'data:image/svg+xml;base64,' + btoa(svg),
475
- padding: padding,
476
- };
477
- }
1
+ // src/utils.js
2
+
3
+ /**
4
+ * Checks if any parent element has overflow: hidden which would clip this element
5
+ * @param {HTMLElement} node - The DOM node to check
6
+ * @returns {boolean} - True if a parent has overflow-hidden or overflow-clip
7
+ */
8
+ export function isClippedByParent(node) {
9
+ let parent = node.parentElement;
10
+ while (parent && parent !== document.body) {
11
+ const style = window.getComputedStyle(parent);
12
+ const overflow = style.overflow;
13
+ if (overflow === 'hidden' || overflow === 'clip') {
14
+ return true;
15
+ }
16
+ parent = parent.parentElement;
17
+ }
18
+ return false;
19
+ }
20
+
21
+ // Helper to save gradient text
22
+ export function getGradientFallbackColor(bgImage) {
23
+ if (!bgImage) return null;
24
+ const hexMatch = bgImage.match(/#(?:[0-9a-fA-F]{3}){1,2}/);
25
+ if (hexMatch) return hexMatch[0];
26
+ const rgbMatch = bgImage.match(/rgba?\(.*?\)/);
27
+ if (rgbMatch) return rgbMatch[0];
28
+ return null;
29
+ }
30
+
31
+ function mapDashType(style) {
32
+ if (style === 'dashed') return 'dash';
33
+ if (style === 'dotted') return 'dot';
34
+ return 'solid';
35
+ }
36
+
37
+ /**
38
+ * Analyzes computed border styles and determines the rendering strategy.
39
+ */
40
+ export function getBorderInfo(style, scale) {
41
+ const top = {
42
+ width: parseFloat(style.borderTopWidth) || 0,
43
+ style: style.borderTopStyle,
44
+ color: parseColor(style.borderTopColor).hex,
45
+ };
46
+ const right = {
47
+ width: parseFloat(style.borderRightWidth) || 0,
48
+ style: style.borderRightStyle,
49
+ color: parseColor(style.borderRightColor).hex,
50
+ };
51
+ const bottom = {
52
+ width: parseFloat(style.borderBottomWidth) || 0,
53
+ style: style.borderBottomStyle,
54
+ color: parseColor(style.borderBottomColor).hex,
55
+ };
56
+ const left = {
57
+ width: parseFloat(style.borderLeftWidth) || 0,
58
+ style: style.borderLeftStyle,
59
+ color: parseColor(style.borderLeftColor).hex,
60
+ };
61
+
62
+ const hasAnyBorder = top.width > 0 || right.width > 0 || bottom.width > 0 || left.width > 0;
63
+ if (!hasAnyBorder) return { type: 'none' };
64
+
65
+ // Check if all sides are uniform
66
+ const isUniform =
67
+ top.width === right.width &&
68
+ top.width === bottom.width &&
69
+ top.width === left.width &&
70
+ top.style === right.style &&
71
+ top.style === bottom.style &&
72
+ top.style === left.style &&
73
+ top.color === right.color &&
74
+ top.color === bottom.color &&
75
+ top.color === left.color;
76
+
77
+ if (isUniform) {
78
+ return {
79
+ type: 'uniform',
80
+ options: {
81
+ width: top.width * 0.75 * scale,
82
+ color: top.color,
83
+ transparency: (1 - parseColor(style.borderTopColor).opacity) * 100,
84
+ dashType: mapDashType(top.style),
85
+ },
86
+ };
87
+ } else {
88
+ return {
89
+ type: 'composite',
90
+ sides: { top, right, bottom, left },
91
+ };
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Generates an SVG image for composite borders that respects border-radius.
97
+ */
98
+ export function generateCompositeBorderSVG(w, h, radius, sides) {
99
+ radius = radius / 2; // Adjust for SVG rendering
100
+ const clipId = 'clip_' + Math.random().toString(36).substr(2, 9);
101
+ let borderRects = '';
102
+
103
+ if (sides.top.width > 0 && sides.top.color) {
104
+ borderRects += `<rect x="0" y="0" width="${w}" height="${sides.top.width}" fill="#${sides.top.color}" />`;
105
+ }
106
+ if (sides.right.width > 0 && sides.right.color) {
107
+ borderRects += `<rect x="${w - sides.right.width}" y="0" width="${sides.right.width}" height="${h}" fill="#${sides.right.color}" />`;
108
+ }
109
+ if (sides.bottom.width > 0 && sides.bottom.color) {
110
+ borderRects += `<rect x="0" y="${h - sides.bottom.width}" width="${w}" height="${sides.bottom.width}" fill="#${sides.bottom.color}" />`;
111
+ }
112
+ if (sides.left.width > 0 && sides.left.color) {
113
+ borderRects += `<rect x="0" y="0" width="${sides.left.width}" height="${h}" fill="#${sides.left.color}" />`;
114
+ }
115
+
116
+ const svg = `
117
+ <svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
118
+ <defs>
119
+ <clipPath id="${clipId}">
120
+ <rect x="0" y="0" width="${w}" height="${h}" rx="${radius}" ry="${radius}" />
121
+ </clipPath>
122
+ </defs>
123
+ <g clip-path="url(#${clipId})">
124
+ ${borderRects}
125
+ </g>
126
+ </svg>`;
127
+
128
+ return 'data:image/svg+xml;base64,' + btoa(svg);
129
+ }
130
+
131
+ /**
132
+ * Generates an SVG data URL for a solid shape with non-uniform corner radii.
133
+ */
134
+ export function generateCustomShapeSVG(w, h, color, opacity, radii) {
135
+ let { tl, tr, br, bl } = radii;
136
+
137
+ // Clamp radii using CSS spec logic (avoid overlap)
138
+ const factor = Math.min(
139
+ (w / (tl + tr)) || Infinity,
140
+ (h / (tr + br)) || Infinity,
141
+ (w / (br + bl)) || Infinity,
142
+ (h / (bl + tl)) || Infinity
143
+ );
144
+
145
+ if (factor < 1) {
146
+ tl *= factor; tr *= factor; br *= factor; bl *= factor;
147
+ }
148
+
149
+ const path = `
150
+ M ${tl} 0
151
+ L ${w - tr} 0
152
+ A ${tr} ${tr} 0 0 1 ${w} ${tr}
153
+ L ${w} ${h - br}
154
+ A ${br} ${br} 0 0 1 ${w - br} ${h}
155
+ L ${bl} ${h}
156
+ A ${bl} ${bl} 0 0 1 0 ${h - bl}
157
+ L 0 ${tl}
158
+ A ${tl} ${tl} 0 0 1 ${tl} 0
159
+ Z
160
+ `;
161
+
162
+ const svg = `
163
+ <svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
164
+ <path d="${path}" fill="#${color}" fill-opacity="${opacity}" />
165
+ </svg>`;
166
+
167
+ return 'data:image/svg+xml;base64,' + btoa(svg);
168
+ }
169
+
170
+ export function parseColor(str) {
171
+ if (!str || str === 'transparent' || str.startsWith('rgba(0, 0, 0, 0)')) {
172
+ return { hex: null, opacity: 0 };
173
+ }
174
+ if (str.startsWith('#')) {
175
+ let hex = str.slice(1);
176
+ if (hex.length === 3)
177
+ hex = hex.split('').map((c) => c + c).join('');
178
+ return { hex: hex.toUpperCase(), opacity: 1 };
179
+ }
180
+ const match = str.match(/[\d.]+/g);
181
+ if (match && match.length >= 3) {
182
+ const r = parseInt(match[0]);
183
+ const g = parseInt(match[1]);
184
+ const b = parseInt(match[2]);
185
+ const a = match.length > 3 ? parseFloat(match[3]) : 1;
186
+ const hex = ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase();
187
+ return { hex, opacity: a };
188
+ }
189
+ return { hex: null, opacity: 0 };
190
+ }
191
+
192
+ export function getPadding(style, scale) {
193
+ const pxToInch = 1 / 96;
194
+ return [
195
+ (parseFloat(style.paddingTop) || 0) * pxToInch * scale,
196
+ (parseFloat(style.paddingRight) || 0) * pxToInch * scale,
197
+ (parseFloat(style.paddingBottom) || 0) * pxToInch * scale,
198
+ (parseFloat(style.paddingLeft) || 0) * pxToInch * scale,
199
+ ];
200
+ }
201
+
202
+ export function getSoftEdges(filterStr, scale) {
203
+ if (!filterStr || filterStr === 'none') return null;
204
+ const match = filterStr.match(/blur\(([\d.]+)px\)/);
205
+ if (match) return parseFloat(match[1]) * 0.75 * scale;
206
+ return null;
207
+ }
208
+
209
+ export function getTextStyle(style, scale) {
210
+ let colorObj = parseColor(style.color);
211
+
212
+ const bgClip = style.webkitBackgroundClip || style.backgroundClip;
213
+ if (colorObj.opacity === 0 && bgClip === 'text') {
214
+ const fallback = getGradientFallbackColor(style.backgroundImage);
215
+ if (fallback) colorObj = parseColor(fallback);
216
+ }
217
+
218
+ return {
219
+ color: colorObj.hex || '000000',
220
+ fontFace: style.fontFamily.split(',')[0].replace(/['"]/g, ''),
221
+ fontSize: parseFloat(style.fontSize) * 0.75 * scale,
222
+ bold: parseInt(style.fontWeight) >= 600,
223
+ italic: style.fontStyle === 'italic',
224
+ underline: style.textDecoration.includes('underline'),
225
+ };
226
+ }
227
+
228
+ /**
229
+ * Determines if a given DOM node is primarily a text container.
230
+ */
231
+ export function isTextContainer(node) {
232
+ const hasText = node.textContent.trim().length > 0;
233
+ if (!hasText) return false;
234
+
235
+ const children = Array.from(node.children);
236
+ if (children.length === 0) return true;
237
+
238
+ // Check if children are purely inline text formatting or visual shapes
239
+ const isSafeInline = (el) => {
240
+ const style = window.getComputedStyle(el);
241
+ const display = style.display;
242
+
243
+ // If it's a standard inline element
244
+ const isInlineTag = ['SPAN', 'B', 'STRONG', 'EM', 'I', 'A', 'SMALL'].includes(el.tagName);
245
+ const isInlineDisplay = display.includes('inline');
246
+
247
+ if (!isInlineTag && !isInlineDisplay) return false;
248
+
249
+ // Check if element is a shape (visual object without text)
250
+ // If an element is empty but has a visible background/border, it's a shape (like a dot).
251
+ // We must return false so the parent isn't treated as a text-only container.
252
+ const hasContent = el.textContent.trim().length > 0;
253
+ const bgColor = parseColor(style.backgroundColor);
254
+ const hasVisibleBg = bgColor.hex && bgColor.opacity > 0;
255
+ const hasBorder = parseFloat(style.borderWidth) > 0 && parseColor(style.borderColor).opacity > 0;
256
+
257
+ if (!hasContent && (hasVisibleBg || hasBorder)) {
258
+ return false;
259
+ }
260
+
261
+ return true;
262
+ };
263
+
264
+ return children.every(isSafeInline);
265
+ }
266
+
267
+ export function getRotation(transformStr) {
268
+ if (!transformStr || transformStr === 'none') return 0;
269
+ const values = transformStr.split('(')[1].split(')')[0].split(',');
270
+ if (values.length < 4) return 0;
271
+ const a = parseFloat(values[0]);
272
+ const b = parseFloat(values[1]);
273
+ return Math.round(Math.atan2(b, a) * (180 / Math.PI));
274
+ }
275
+
276
+ export function svgToPng(node) {
277
+ return new Promise((resolve) => {
278
+ const clone = node.cloneNode(true);
279
+ const rect = node.getBoundingClientRect();
280
+ const width = rect.width || 300;
281
+ const height = rect.height || 150;
282
+
283
+ function inlineStyles(source, target) {
284
+ const computed = window.getComputedStyle(source);
285
+ const properties = [
286
+ 'fill', 'stroke', 'stroke-width', 'stroke-linecap',
287
+ 'stroke-linejoin', 'opacity', 'font-family', 'font-size', 'font-weight',
288
+ ];
289
+
290
+ if (computed.fill === 'none') target.setAttribute('fill', 'none');
291
+ else if (computed.fill) target.style.fill = computed.fill;
292
+
293
+ if (computed.stroke === 'none') target.setAttribute('stroke', 'none');
294
+ else if (computed.stroke) target.style.stroke = computed.stroke;
295
+
296
+ properties.forEach((prop) => {
297
+ if (prop !== 'fill' && prop !== 'stroke') {
298
+ const val = computed[prop];
299
+ if (val && val !== 'auto') target.style[prop] = val;
300
+ }
301
+ });
302
+
303
+ for (let i = 0; i < source.children.length; i++) {
304
+ if (target.children[i]) inlineStyles(source.children[i], target.children[i]);
305
+ }
306
+ }
307
+
308
+ inlineStyles(node, clone);
309
+ clone.setAttribute('width', width);
310
+ clone.setAttribute('height', height);
311
+ clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
312
+
313
+ const xml = new XMLSerializer().serializeToString(clone);
314
+ const svgUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(xml)}`;
315
+ const img = new Image();
316
+ img.crossOrigin = 'Anonymous';
317
+ img.onload = () => {
318
+ const canvas = document.createElement('canvas');
319
+ const scale = 3;
320
+ canvas.width = width * scale;
321
+ canvas.height = height * scale;
322
+ const ctx = canvas.getContext('2d');
323
+ ctx.scale(scale, scale);
324
+ ctx.drawImage(img, 0, 0, width, height);
325
+ resolve(canvas.toDataURL('image/png'));
326
+ };
327
+ img.onerror = () => resolve(null);
328
+ img.src = svgUrl;
329
+ });
330
+ }
331
+
332
+ export function getVisibleShadow(shadowStr, scale) {
333
+ if (!shadowStr || shadowStr === 'none') return null;
334
+ const shadows = shadowStr.split(/,(?![^()]*\))/);
335
+ for (let s of shadows) {
336
+ s = s.trim();
337
+ if (s.startsWith('rgba(0, 0, 0, 0)')) continue;
338
+ const match = s.match(
339
+ /(rgba?\([^)]+\)|#[0-9a-fA-F]+)\s+(-?[\d.]+)px\s+(-?[\d.]+)px\s+([\d.]+)px/
340
+ );
341
+ if (match) {
342
+ const colorStr = match[1];
343
+ const x = parseFloat(match[2]);
344
+ const y = parseFloat(match[3]);
345
+ const blur = parseFloat(match[4]);
346
+ const distance = Math.sqrt(x * x + y * y);
347
+ let angle = Math.atan2(y, x) * (180 / Math.PI);
348
+ if (angle < 0) angle += 360;
349
+ const colorObj = parseColor(colorStr);
350
+ return {
351
+ type: 'outer',
352
+ angle: angle,
353
+ blur: blur * 0.75 * scale,
354
+ offset: distance * 0.75 * scale,
355
+ color: colorObj.hex || '000000',
356
+ opacity: colorObj.opacity,
357
+ };
358
+ }
359
+ }
360
+ return null;
361
+ }
362
+
363
+ export function generateGradientSVG(w, h, bgString, radius, border) {
364
+ try {
365
+ const match = bgString.match(/linear-gradient\((.*)\)/);
366
+ if (!match) return null;
367
+ const content = match[1];
368
+ const parts = content.split(/,(?![^()]*\))/).map((p) => p.trim());
369
+
370
+ let x1 = '0%', y1 = '0%', x2 = '0%', y2 = '100%';
371
+ let stopsStartIdx = 0;
372
+ if (parts[0].includes('to right')) {
373
+ x1 = '0%'; x2 = '100%'; y2 = '0%'; stopsStartIdx = 1;
374
+ } else if (parts[0].includes('to left')) {
375
+ x1 = '100%'; x2 = '0%'; y2 = '0%'; stopsStartIdx = 1;
376
+ } else if (parts[0].includes('to top')) {
377
+ y1 = '100%'; y2 = '0%'; stopsStartIdx = 1;
378
+ } else if (parts[0].includes('to bottom')) {
379
+ y1 = '0%'; y2 = '100%'; stopsStartIdx = 1;
380
+ }
381
+
382
+ let stopsXML = '';
383
+ const stopParts = parts.slice(stopsStartIdx);
384
+ stopParts.forEach((part, idx) => {
385
+ let color = part;
386
+ let offset = Math.round((idx / (stopParts.length - 1)) * 100) + '%';
387
+ const posMatch = part.match(/(.*?)\s+(\d+(\.\d+)?%?)$/);
388
+ if (posMatch) {
389
+ color = posMatch[1];
390
+ offset = posMatch[2];
391
+ }
392
+ let opacity = 1;
393
+ if (color.includes('rgba')) {
394
+ const rgba = color.match(/[\d.]+/g);
395
+ if (rgba && rgba.length > 3) {
396
+ opacity = rgba[3];
397
+ color = `rgb(${rgba[0]},${rgba[1]},${rgba[2]})`;
398
+ }
399
+ }
400
+ stopsXML += `<stop offset="${offset}" stop-color="${color}" stop-opacity="${opacity}"/>`;
401
+ });
402
+
403
+ let strokeAttr = '';
404
+ if (border) {
405
+ strokeAttr = `stroke="#${border.color}" stroke-width="${border.width}"`;
406
+ }
407
+
408
+ const svg = `
409
+ <svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
410
+ <defs><linearGradient id="grad" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}">${stopsXML}</linearGradient></defs>
411
+ <rect x="0" y="0" width="${w}" height="${h}" rx="${radius}" ry="${radius}" fill="url(#grad)" ${strokeAttr} />
412
+ </svg>`;
413
+ return 'data:image/svg+xml;base64,' + btoa(svg);
414
+ } catch {
415
+ return null;
416
+ }
417
+ }
418
+
419
+ export function generateBlurredSVG(w, h, color, radius, blurPx) {
420
+ const padding = blurPx * 3;
421
+ const fullW = w + padding * 2;
422
+ const fullH = h + padding * 2;
423
+ const x = padding;
424
+ const y = padding;
425
+ let shapeTag = '';
426
+ const isCircle = radius >= Math.min(w, h) / 2 - 1 && Math.abs(w - h) < 2;
427
+
428
+ if (isCircle) {
429
+ const cx = x + w / 2;
430
+ const cy = y + h / 2;
431
+ const rx = w / 2;
432
+ const ry = h / 2;
433
+ shapeTag = `<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}" fill="#${color}" filter="url(#f1)" />`;
434
+ } else {
435
+ shapeTag = `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${radius}" ry="${radius}" fill="#${color}" filter="url(#f1)" />`;
436
+ }
437
+
438
+ const svg = `
439
+ <svg xmlns="http://www.w3.org/2000/svg" width="${fullW}" height="${fullH}" viewBox="0 0 ${fullW} ${fullH}">
440
+ <defs>
441
+ <filter id="f1" x="-50%" y="-50%" width="200%" height="200%">
442
+ <feGaussianBlur in="SourceGraphic" stdDeviation="${blurPx}" />
443
+ </filter>
444
+ </defs>
445
+ ${shapeTag}
446
+ </svg>`;
447
+
448
+ return {
449
+ data: 'data:image/svg+xml;base64,' + btoa(svg),
450
+ padding: padding,
451
+ };
452
+ }