dom-to-pptx 1.0.9 → 1.1.1

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,575 +1,711 @@
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;
147
- tr *= factor;
148
- br *= factor;
149
- bl *= factor;
150
- }
151
-
152
- const path = `
153
- M ${tl} 0
154
- L ${w - tr} 0
155
- A ${tr} ${tr} 0 0 1 ${w} ${tr}
156
- L ${w} ${h - br}
157
- A ${br} ${br} 0 0 1 ${w - br} ${h}
158
- L ${bl} ${h}
159
- A ${bl} ${bl} 0 0 1 0 ${h - bl}
160
- L 0 ${tl}
161
- A ${tl} ${tl} 0 0 1 ${tl} 0
162
- Z
163
- `;
164
-
165
- const svg = `
166
- <svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
167
- <path d="${path}" fill="#${color}" fill-opacity="${opacity}" />
168
- </svg>`;
169
-
170
- return 'data:image/svg+xml;base64,' + btoa(svg);
171
- }
172
-
173
- export function parseColor(str) {
174
- if (!str || str === 'transparent' || str.startsWith('rgba(0, 0, 0, 0)')) {
175
- return { hex: null, opacity: 0 };
176
- }
177
- if (str.startsWith('#')) {
178
- let hex = str.slice(1);
179
- if (hex.length === 3)
180
- hex = hex
181
- .split('')
182
- .map((c) => c + c)
183
- .join('');
184
- return { hex: hex.toUpperCase(), opacity: 1 };
185
- }
186
- const match = str.match(/[\d.]+/g);
187
- if (match && match.length >= 3) {
188
- const r = parseInt(match[0]);
189
- const g = parseInt(match[1]);
190
- const b = parseInt(match[2]);
191
- const a = match.length > 3 ? parseFloat(match[3]) : 1;
192
- const hex = ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase();
193
- return { hex, opacity: a };
194
- }
195
- return { hex: null, opacity: 0 };
196
- }
197
-
198
- export function getPadding(style, scale) {
199
- const pxToInch = 1 / 96;
200
- return [
201
- (parseFloat(style.paddingTop) || 0) * pxToInch * scale,
202
- (parseFloat(style.paddingRight) || 0) * pxToInch * scale,
203
- (parseFloat(style.paddingBottom) || 0) * pxToInch * scale,
204
- (parseFloat(style.paddingLeft) || 0) * pxToInch * scale,
205
- ];
206
- }
207
-
208
- export function getSoftEdges(filterStr, scale) {
209
- if (!filterStr || filterStr === 'none') return null;
210
- const match = filterStr.match(/blur\(([\d.]+)px\)/);
211
- if (match) return parseFloat(match[1]) * 0.75 * scale;
212
- return null;
213
- }
214
-
215
- export function getTextStyle(style, scale) {
216
- let colorObj = parseColor(style.color);
217
-
218
- const bgClip = style.webkitBackgroundClip || style.backgroundClip;
219
- if (colorObj.opacity === 0 && bgClip === 'text') {
220
- const fallback = getGradientFallbackColor(style.backgroundImage);
221
- if (fallback) colorObj = parseColor(fallback);
222
- }
223
-
224
- return {
225
- color: colorObj.hex || '000000',
226
- fontFace: style.fontFamily.split(',')[0].replace(/['"]/g, ''),
227
- fontSize: parseFloat(style.fontSize) * 0.75 * scale,
228
- bold: parseInt(style.fontWeight) >= 600,
229
- italic: style.fontStyle === 'italic',
230
- underline: style.textDecoration.includes('underline'),
231
- };
232
- }
233
-
234
- /**
235
- * Determines if a given DOM node is primarily a text container.
236
- * Updated to correctly reject Icon elements so they are rendered as images.
237
- */
238
- export function isTextContainer(node) {
239
- const hasText = node.textContent.trim().length > 0;
240
- if (!hasText) return false;
241
-
242
- const children = Array.from(node.children);
243
- if (children.length === 0) return true;
244
-
245
- const isSafeInline = (el) => {
246
- // 1. Reject Web Components / Custom Elements
247
- if (el.tagName.includes('-')) return false;
248
- // 2. Reject Explicit Images/SVGs
249
- if (el.tagName === 'IMG' || el.tagName === 'SVG') return false;
250
-
251
- // 3. Reject Class-based Icons (FontAwesome, Material, Bootstrap, etc.)
252
- // If an <i> or <span> has icon classes, it is a visual object, not text.
253
- if (el.tagName === 'I' || el.tagName === 'SPAN') {
254
- const cls = el.getAttribute('class') || '';
255
- if (
256
- cls.includes('fa-') ||
257
- cls.includes('fas') ||
258
- cls.includes('far') ||
259
- cls.includes('fab') ||
260
- cls.includes('material-icons') ||
261
- cls.includes('bi-') ||
262
- cls.includes('icon')
263
- ) {
264
- return false;
265
- }
266
- }
267
-
268
- const style = window.getComputedStyle(el);
269
- const display = style.display;
270
-
271
- // 4. Standard Inline Tag Check
272
- const isInlineTag = ['SPAN', 'B', 'STRONG', 'EM', 'I', 'A', 'SMALL', 'MARK'].includes(
273
- el.tagName
274
- );
275
- const isInlineDisplay = display.includes('inline');
276
-
277
- if (!isInlineTag && !isInlineDisplay) return false;
278
-
279
- // 5. Structural Styling Check
280
- // If a child has a background or border, it's a layout block, not a simple text span.
281
- const bgColor = parseColor(style.backgroundColor);
282
- const hasVisibleBg = bgColor.hex && bgColor.opacity > 0;
283
- const hasBorder =
284
- parseFloat(style.borderWidth) > 0 && parseColor(style.borderColor).opacity > 0;
285
-
286
- if (hasVisibleBg || hasBorder) {
287
- return false;
288
- }
289
-
290
- // 4. Check for empty shapes (visual objects without text, like dots)
291
- const hasContent = el.textContent.trim().length > 0;
292
- if (!hasContent && (hasVisibleBg || hasBorder)) {
293
- return false;
294
- }
295
-
296
- return true;
297
- };
298
-
299
- return children.every(isSafeInline);
300
- }
301
-
302
- export function getRotation(transformStr) {
303
- if (!transformStr || transformStr === 'none') return 0;
304
- const values = transformStr.split('(')[1].split(')')[0].split(',');
305
- if (values.length < 4) return 0;
306
- const a = parseFloat(values[0]);
307
- const b = parseFloat(values[1]);
308
- return Math.round(Math.atan2(b, a) * (180 / Math.PI));
309
- }
310
-
311
- export function svgToPng(node) {
312
- return new Promise((resolve) => {
313
- const clone = node.cloneNode(true);
314
- const rect = node.getBoundingClientRect();
315
- const width = rect.width || 300;
316
- const height = rect.height || 150;
317
-
318
- function inlineStyles(source, target) {
319
- const computed = window.getComputedStyle(source);
320
- const properties = [
321
- 'fill',
322
- 'stroke',
323
- 'stroke-width',
324
- 'stroke-linecap',
325
- 'stroke-linejoin',
326
- 'opacity',
327
- 'font-family',
328
- 'font-size',
329
- 'font-weight',
330
- ];
331
-
332
- if (computed.fill === 'none') target.setAttribute('fill', 'none');
333
- else if (computed.fill) target.style.fill = computed.fill;
334
-
335
- if (computed.stroke === 'none') target.setAttribute('stroke', 'none');
336
- else if (computed.stroke) target.style.stroke = computed.stroke;
337
-
338
- properties.forEach((prop) => {
339
- if (prop !== 'fill' && prop !== 'stroke') {
340
- const val = computed[prop];
341
- if (val && val !== 'auto') target.style[prop] = val;
342
- }
343
- });
344
-
345
- for (let i = 0; i < source.children.length; i++) {
346
- if (target.children[i]) inlineStyles(source.children[i], target.children[i]);
347
- }
348
- }
349
-
350
- inlineStyles(node, clone);
351
- clone.setAttribute('width', width);
352
- clone.setAttribute('height', height);
353
- clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
354
-
355
- const xml = new XMLSerializer().serializeToString(clone);
356
- const svgUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(xml)}`;
357
- const img = new Image();
358
- img.crossOrigin = 'Anonymous';
359
- img.onload = () => {
360
- const canvas = document.createElement('canvas');
361
- const scale = 3;
362
- canvas.width = width * scale;
363
- canvas.height = height * scale;
364
- const ctx = canvas.getContext('2d');
365
- ctx.scale(scale, scale);
366
- ctx.drawImage(img, 0, 0, width, height);
367
- resolve(canvas.toDataURL('image/png'));
368
- };
369
- img.onerror = () => resolve(null);
370
- img.src = svgUrl;
371
- });
372
- }
373
-
374
- export function getVisibleShadow(shadowStr, scale) {
375
- if (!shadowStr || shadowStr === 'none') return null;
376
- const shadows = shadowStr.split(/,(?![^()]*\))/);
377
- for (let s of shadows) {
378
- s = s.trim();
379
- if (s.startsWith('rgba(0, 0, 0, 0)')) continue;
380
- const match = s.match(
381
- /(rgba?\([^)]+\)|#[0-9a-fA-F]+)\s+(-?[\d.]+)px\s+(-?[\d.]+)px\s+([\d.]+)px/
382
- );
383
- if (match) {
384
- const colorStr = match[1];
385
- const x = parseFloat(match[2]);
386
- const y = parseFloat(match[3]);
387
- const blur = parseFloat(match[4]);
388
- const distance = Math.sqrt(x * x + y * y);
389
- let angle = Math.atan2(y, x) * (180 / Math.PI);
390
- if (angle < 0) angle += 360;
391
- const colorObj = parseColor(colorStr);
392
- return {
393
- type: 'outer',
394
- angle: angle,
395
- blur: blur * 0.75 * scale,
396
- offset: distance * 0.75 * scale,
397
- color: colorObj.hex || '000000',
398
- opacity: colorObj.opacity,
399
- };
400
- }
401
- }
402
- return null;
403
- }
404
-
405
- /**
406
- * Generates an SVG image for gradients, supporting degrees and keywords.
407
- */
408
- export function generateGradientSVG(w, h, bgString, radius, border) {
409
- try {
410
- const match = bgString.match(/linear-gradient\((.*)\)/);
411
- if (!match) return null;
412
- const content = match[1];
413
-
414
- // Split by comma, ignoring commas inside parentheses (e.g. rgba())
415
- const parts = content.split(/,(?![^()]*\))/).map((p) => p.trim());
416
- if (parts.length < 2) return null;
417
-
418
- let x1 = '0%',
419
- y1 = '0%',
420
- x2 = '0%',
421
- y2 = '100%';
422
- let stopsStartIndex = 0;
423
- const firstPart = parts[0].toLowerCase();
424
-
425
- // 1. Check for Keywords (to right, etc.)
426
- if (firstPart.startsWith('to ')) {
427
- stopsStartIndex = 1;
428
- const direction = firstPart.replace('to ', '').trim();
429
- switch (direction) {
430
- case 'top':
431
- y1 = '100%';
432
- y2 = '0%';
433
- break;
434
- case 'bottom':
435
- y1 = '0%';
436
- y2 = '100%';
437
- break;
438
- case 'left':
439
- x1 = '100%';
440
- x2 = '0%';
441
- break;
442
- case 'right':
443
- x2 = '100%';
444
- break;
445
- case 'top right':
446
- x1 = '0%';
447
- y1 = '100%';
448
- x2 = '100%';
449
- y2 = '0%';
450
- break;
451
- case 'top left':
452
- x1 = '100%';
453
- y1 = '100%';
454
- x2 = '0%';
455
- y2 = '0%';
456
- break;
457
- case 'bottom right':
458
- x2 = '100%';
459
- y2 = '100%';
460
- break;
461
- case 'bottom left':
462
- x1 = '100%';
463
- y2 = '100%';
464
- break;
465
- }
466
- }
467
- // 2. Check for Degrees (45deg, 90deg, etc.)
468
- else if (firstPart.match(/^-?[\d.]+(deg|rad|turn|grad)$/)) {
469
- stopsStartIndex = 1;
470
- const val = parseFloat(firstPart);
471
- // CSS 0deg is Top (North), 90deg is Right (East), 180deg is Bottom (South)
472
- // We convert this to SVG coordinates on a unit square (0-100%).
473
- // Formula: Map angle to perimeter coordinates.
474
- if (!isNaN(val)) {
475
- const deg = firstPart.includes('rad') ? val * (180 / Math.PI) : val;
476
- const cssRad = ((deg - 90) * Math.PI) / 180; // Correct CSS angle offset
477
-
478
- // Calculate standard vector for rectangle center (50, 50)
479
- const scale = 50; // Distance from center to edge (approx)
480
- const cos = Math.cos(cssRad); // Y component (reversed in SVG)
481
- const sin = Math.sin(cssRad); // X component
482
-
483
- // Invert Y for SVG coordinate system
484
- x1 = (50 - sin * scale).toFixed(1) + '%';
485
- y1 = (50 + cos * scale).toFixed(1) + '%';
486
- x2 = (50 + sin * scale).toFixed(1) + '%';
487
- y2 = (50 - cos * scale).toFixed(1) + '%';
488
- }
489
- }
490
-
491
- // 3. Process Color Stops
492
- let stopsXML = '';
493
- const stopParts = parts.slice(stopsStartIndex);
494
-
495
- stopParts.forEach((part, idx) => {
496
- // Parse "Color Position" (e.g., "red 50%")
497
- // Regex looks for optional space + number + unit at the end of the string
498
- let color = part;
499
- let offset = Math.round((idx / (stopParts.length - 1)) * 100) + '%';
500
-
501
- const posMatch = part.match(/^(.*?)\s+(-?[\d.]+(?:%|px)?)$/);
502
- if (posMatch) {
503
- color = posMatch[1];
504
- offset = posMatch[2];
505
- }
506
-
507
- // Handle RGBA/RGB for SVG compatibility
508
- let opacity = 1;
509
- if (color.includes('rgba')) {
510
- const rgbaMatch = color.match(/[\d.]+/g);
511
- if (rgbaMatch && rgbaMatch.length >= 4) {
512
- opacity = rgbaMatch[3];
513
- color = `rgb(${rgbaMatch[0]},${rgbaMatch[1]},${rgbaMatch[2]})`;
514
- }
515
- }
516
-
517
- stopsXML += `<stop offset="${offset}" stop-color="${color.trim()}" stop-opacity="${opacity}"/>`;
518
- });
519
-
520
- let strokeAttr = '';
521
- if (border) {
522
- strokeAttr = `stroke="#${border.color}" stroke-width="${border.width}"`;
523
- }
524
-
525
- const svg = `
526
- <svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
527
- <defs>
528
- <linearGradient id="grad" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}">
529
- ${stopsXML}
530
- </linearGradient>
531
- </defs>
532
- <rect x="0" y="0" width="${w}" height="${h}" rx="${radius}" ry="${radius}" fill="url(#grad)" ${strokeAttr} />
533
- </svg>`;
534
-
535
- return 'data:image/svg+xml;base64,' + btoa(svg);
536
- } catch (e) {
537
- console.warn('Gradient generation failed:', e);
538
- return null;
539
- }
540
- }
541
-
542
- export function generateBlurredSVG(w, h, color, radius, blurPx) {
543
- const padding = blurPx * 3;
544
- const fullW = w + padding * 2;
545
- const fullH = h + padding * 2;
546
- const x = padding;
547
- const y = padding;
548
- let shapeTag = '';
549
- const isCircle = radius >= Math.min(w, h) / 2 - 1 && Math.abs(w - h) < 2;
550
-
551
- if (isCircle) {
552
- const cx = x + w / 2;
553
- const cy = y + h / 2;
554
- const rx = w / 2;
555
- const ry = h / 2;
556
- shapeTag = `<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}" fill="#${color}" filter="url(#f1)" />`;
557
- } else {
558
- shapeTag = `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${radius}" ry="${radius}" fill="#${color}" filter="url(#f1)" />`;
559
- }
560
-
561
- const svg = `
562
- <svg xmlns="http://www.w3.org/2000/svg" width="${fullW}" height="${fullH}" viewBox="0 0 ${fullW} ${fullH}">
563
- <defs>
564
- <filter id="f1" x="-50%" y="-50%" width="200%" height="200%">
565
- <feGaussianBlur in="SourceGraphic" stdDeviation="${blurPx}" />
566
- </filter>
567
- </defs>
568
- ${shapeTag}
569
- </svg>`;
570
-
571
- return {
572
- data: 'data:image/svg+xml;base64,' + btoa(svg),
573
- padding: padding,
574
- };
575
- }
1
+ // src/utils.js
2
+
3
+ // canvas context for color normalization
4
+ let _ctx;
5
+ function getCtx() {
6
+ if (!_ctx) _ctx = document.createElement('canvas').getContext('2d', { willReadFrequently: true });
7
+ return _ctx;
8
+ }
9
+
10
+ // Checks if any parent element has overflow: hidden which would clip this element
11
+ export function isClippedByParent(node) {
12
+ let parent = node.parentElement;
13
+ while (parent && parent !== document.body) {
14
+ const style = window.getComputedStyle(parent);
15
+ const overflow = style.overflow;
16
+ if (overflow === 'hidden' || overflow === 'clip') {
17
+ return true;
18
+ }
19
+ parent = parent.parentElement;
20
+ }
21
+ return false;
22
+ }
23
+
24
+ // Helper to save gradient text
25
+ export function getGradientFallbackColor(bgImage) {
26
+ if (!bgImage) return null;
27
+ const hexMatch = bgImage.match(/#(?:[0-9a-fA-F]{3}){1,2}/);
28
+ if (hexMatch) return hexMatch[0];
29
+ const rgbMatch = bgImage.match(/rgba?\(.*?\)/);
30
+ if (rgbMatch) return rgbMatch[0];
31
+ return null;
32
+ }
33
+
34
+ function mapDashType(style) {
35
+ if (style === 'dashed') return 'dash';
36
+ if (style === 'dotted') return 'dot';
37
+ return 'solid';
38
+ }
39
+
40
+ /**
41
+ * Analyzes computed border styles and determines the rendering strategy.
42
+ */
43
+ export function getBorderInfo(style, scale) {
44
+ const top = {
45
+ width: parseFloat(style.borderTopWidth) || 0,
46
+ style: style.borderTopStyle,
47
+ color: parseColor(style.borderTopColor).hex,
48
+ };
49
+ const right = {
50
+ width: parseFloat(style.borderRightWidth) || 0,
51
+ style: style.borderRightStyle,
52
+ color: parseColor(style.borderRightColor).hex,
53
+ };
54
+ const bottom = {
55
+ width: parseFloat(style.borderBottomWidth) || 0,
56
+ style: style.borderBottomStyle,
57
+ color: parseColor(style.borderBottomColor).hex,
58
+ };
59
+ const left = {
60
+ width: parseFloat(style.borderLeftWidth) || 0,
61
+ style: style.borderLeftStyle,
62
+ color: parseColor(style.borderLeftColor).hex,
63
+ };
64
+
65
+ const hasAnyBorder = top.width > 0 || right.width > 0 || bottom.width > 0 || left.width > 0;
66
+ if (!hasAnyBorder) return { type: 'none' };
67
+
68
+ // Check if all sides are uniform
69
+ const isUniform =
70
+ top.width === right.width &&
71
+ top.width === bottom.width &&
72
+ top.width === left.width &&
73
+ top.style === right.style &&
74
+ top.style === bottom.style &&
75
+ top.style === left.style &&
76
+ top.color === right.color &&
77
+ top.color === bottom.color &&
78
+ top.color === left.color;
79
+
80
+ if (isUniform) {
81
+ return {
82
+ type: 'uniform',
83
+ options: {
84
+ width: top.width * 0.75 * scale,
85
+ color: top.color,
86
+ transparency: (1 - parseColor(style.borderTopColor).opacity) * 100,
87
+ dashType: mapDashType(top.style),
88
+ },
89
+ };
90
+ } else {
91
+ return {
92
+ type: 'composite',
93
+ sides: { top, right, bottom, left },
94
+ };
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Generates an SVG image for composite borders that respects border-radius.
100
+ */
101
+ export function generateCompositeBorderSVG(w, h, radius, sides) {
102
+ radius = radius / 2; // Adjust for SVG rendering
103
+ const clipId = 'clip_' + Math.random().toString(36).substr(2, 9);
104
+ let borderRects = '';
105
+
106
+ if (sides.top.width > 0 && sides.top.color) {
107
+ borderRects += `<rect x="0" y="0" width="${w}" height="${sides.top.width}" fill="#${sides.top.color}" />`;
108
+ }
109
+ if (sides.right.width > 0 && sides.right.color) {
110
+ borderRects += `<rect x="${w - sides.right.width}" y="0" width="${sides.right.width}" height="${h}" fill="#${sides.right.color}" />`;
111
+ }
112
+ if (sides.bottom.width > 0 && sides.bottom.color) {
113
+ borderRects += `<rect x="0" y="${h - sides.bottom.width}" width="${w}" height="${sides.bottom.width}" fill="#${sides.bottom.color}" />`;
114
+ }
115
+ if (sides.left.width > 0 && sides.left.color) {
116
+ borderRects += `<rect x="0" y="0" width="${sides.left.width}" height="${h}" fill="#${sides.left.color}" />`;
117
+ }
118
+
119
+ const svg = `
120
+ <svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
121
+ <defs>
122
+ <clipPath id="${clipId}">
123
+ <rect x="0" y="0" width="${w}" height="${h}" rx="${radius}" ry="${radius}" />
124
+ </clipPath>
125
+ </defs>
126
+ <g clip-path="url(#${clipId})">
127
+ ${borderRects}
128
+ </g>
129
+ </svg>`;
130
+
131
+ return 'data:image/svg+xml;base64,' + btoa(svg);
132
+ }
133
+
134
+ /**
135
+ * Generates an SVG data URL for a solid shape with non-uniform corner radii.
136
+ */
137
+ export function generateCustomShapeSVG(w, h, color, opacity, radii) {
138
+ let { tl, tr, br, bl } = radii;
139
+
140
+ // Clamp radii using CSS spec logic (avoid overlap)
141
+ const factor = Math.min(
142
+ w / (tl + tr) || Infinity,
143
+ h / (tr + br) || Infinity,
144
+ w / (br + bl) || Infinity,
145
+ h / (bl + tl) || Infinity
146
+ );
147
+
148
+ if (factor < 1) {
149
+ tl *= factor;
150
+ tr *= factor;
151
+ br *= factor;
152
+ bl *= factor;
153
+ }
154
+
155
+ const path = `
156
+ M ${tl} 0
157
+ L ${w - tr} 0
158
+ A ${tr} ${tr} 0 0 1 ${w} ${tr}
159
+ L ${w} ${h - br}
160
+ A ${br} ${br} 0 0 1 ${w - br} ${h}
161
+ L ${bl} ${h}
162
+ A ${bl} ${bl} 0 0 1 0 ${h - bl}
163
+ L 0 ${tl}
164
+ A ${tl} ${tl} 0 0 1 ${tl} 0
165
+ Z
166
+ `;
167
+
168
+ const svg = `
169
+ <svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
170
+ <path d="${path}" fill="#${color}" fill-opacity="${opacity}" />
171
+ </svg>`;
172
+
173
+ return 'data:image/svg+xml;base64,' + btoa(svg);
174
+ }
175
+
176
+ // --- REPLACE THE EXISTING parseColor FUNCTION ---
177
+ export function parseColor(str) {
178
+ if (!str || str === 'transparent' || str.trim() === 'rgba(0, 0, 0, 0)') {
179
+ return { hex: null, opacity: 0 };
180
+ }
181
+
182
+ const ctx = getCtx();
183
+ ctx.fillStyle = str;
184
+ // This forces the browser to resolve variables and convert formats (oklch -> rgb/hex)
185
+ const computed = ctx.fillStyle;
186
+
187
+ // 1. Handle Hex Output (e.g. #ff0000 or #ff0000ff)
188
+ if (computed.startsWith('#')) {
189
+ let hex = computed.slice(1); // Remove '#'
190
+ let opacity = 1;
191
+
192
+ // Expand shorthand #RGB -> #RRGGBB
193
+ if (hex.length === 3) {
194
+ hex = hex
195
+ .split('')
196
+ .map((c) => c + c)
197
+ .join('');
198
+ }
199
+ // Expand shorthand #RGBA -> #RRGGBBAA
200
+ else if (hex.length === 4) {
201
+ hex = hex
202
+ .split('')
203
+ .map((c) => c + c)
204
+ .join('');
205
+ }
206
+
207
+ // Handle 8-digit Hex (RRGGBBAA) - PptxGenJS fails if we send 8 digits
208
+ if (hex.length === 8) {
209
+ opacity = parseInt(hex.slice(6), 16) / 255;
210
+ hex = hex.slice(0, 6); // Keep only RRGGBB
211
+ }
212
+
213
+ return { hex: hex.toUpperCase(), opacity };
214
+ }
215
+
216
+ // 2. Handle RGB/RGBA Output (e.g. "rgb(55, 65, 81)" or "rgba(55, 65, 81, 1)")
217
+ const match = computed.match(/[\d.]+/g);
218
+ if (match && match.length >= 3) {
219
+ const r = parseInt(match[0]);
220
+ const g = parseInt(match[1]);
221
+ const b = parseInt(match[2]);
222
+ const a = match.length > 3 ? parseFloat(match[3]) : 1;
223
+
224
+ // Bitwise shift to get Hex
225
+ const hex = ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase();
226
+
227
+ return { hex, opacity: a };
228
+ }
229
+
230
+ // Fallback (Parsing failed)
231
+ return { hex: null, opacity: 0 };
232
+ }
233
+
234
+ export function getPadding(style, scale) {
235
+ const pxToInch = 1 / 96;
236
+ return [
237
+ (parseFloat(style.paddingTop) || 0) * pxToInch * scale,
238
+ (parseFloat(style.paddingRight) || 0) * pxToInch * scale,
239
+ (parseFloat(style.paddingBottom) || 0) * pxToInch * scale,
240
+ (parseFloat(style.paddingLeft) || 0) * pxToInch * scale,
241
+ ];
242
+ }
243
+
244
+ export function getSoftEdges(filterStr, scale) {
245
+ if (!filterStr || filterStr === 'none') return null;
246
+ const match = filterStr.match(/blur\(([\d.]+)px\)/);
247
+ if (match) return parseFloat(match[1]) * 0.75 * scale;
248
+ return null;
249
+ }
250
+
251
+ export function getTextStyle(style, scale) {
252
+ let colorObj = parseColor(style.color);
253
+
254
+ const bgClip = style.webkitBackgroundClip || style.backgroundClip;
255
+ if (colorObj.opacity === 0 && bgClip === 'text') {
256
+ const fallback = getGradientFallbackColor(style.backgroundImage);
257
+ if (fallback) colorObj = parseColor(fallback);
258
+ }
259
+
260
+ return {
261
+ color: colorObj.hex || '000000',
262
+ fontFace: style.fontFamily.split(',')[0].replace(/['"]/g, ''),
263
+ fontSize: parseFloat(style.fontSize) * 0.75 * scale,
264
+ bold: parseInt(style.fontWeight) >= 600,
265
+ italic: style.fontStyle === 'italic',
266
+ underline: style.textDecoration.includes('underline'),
267
+ };
268
+ }
269
+
270
+ /**
271
+ * Determines if a given DOM node is primarily a text container.
272
+ * Updated to correctly reject Icon elements so they are rendered as images.
273
+ */
274
+ export function isTextContainer(node) {
275
+ const hasText = node.textContent.trim().length > 0;
276
+ if (!hasText) return false;
277
+
278
+ const children = Array.from(node.children);
279
+ if (children.length === 0) return true;
280
+
281
+ const isSafeInline = (el) => {
282
+ // 1. Reject Web Components / Custom Elements
283
+ if (el.tagName.includes('-')) return false;
284
+ // 2. Reject Explicit Images/SVGs
285
+ if (el.tagName === 'IMG' || el.tagName === 'SVG') return false;
286
+
287
+ // 3. Reject Class-based Icons (FontAwesome, Material, Bootstrap, etc.)
288
+ // If an <i> or <span> has icon classes, it is a visual object, not text.
289
+ if (el.tagName === 'I' || el.tagName === 'SPAN') {
290
+ const cls = el.getAttribute('class') || '';
291
+ if (
292
+ cls.includes('fa-') ||
293
+ cls.includes('fas') ||
294
+ cls.includes('far') ||
295
+ cls.includes('fab') ||
296
+ cls.includes('material-icons') ||
297
+ cls.includes('bi-') ||
298
+ cls.includes('icon')
299
+ ) {
300
+ return false;
301
+ }
302
+ }
303
+
304
+ const style = window.getComputedStyle(el);
305
+ const display = style.display;
306
+
307
+ // 4. Standard Inline Tag Check
308
+ const isInlineTag = ['SPAN', 'B', 'STRONG', 'EM', 'I', 'A', 'SMALL', 'MARK'].includes(
309
+ el.tagName
310
+ );
311
+ const isInlineDisplay = display.includes('inline');
312
+
313
+ if (!isInlineTag && !isInlineDisplay) return false;
314
+
315
+ // 5. Structural Styling Check
316
+ // If a child has a background or border, it's a layout block, not a simple text span.
317
+ const bgColor = parseColor(style.backgroundColor);
318
+ const hasVisibleBg = bgColor.hex && bgColor.opacity > 0;
319
+ const hasBorder =
320
+ parseFloat(style.borderWidth) > 0 && parseColor(style.borderColor).opacity > 0;
321
+
322
+ if (hasVisibleBg || hasBorder) {
323
+ return false;
324
+ }
325
+
326
+ // 4. Check for empty shapes (visual objects without text, like dots)
327
+ const hasContent = el.textContent.trim().length > 0;
328
+ if (!hasContent && (hasVisibleBg || hasBorder)) {
329
+ return false;
330
+ }
331
+
332
+ return true;
333
+ };
334
+
335
+ return children.every(isSafeInline);
336
+ }
337
+
338
+ export function getRotation(transformStr) {
339
+ if (!transformStr || transformStr === 'none') return 0;
340
+ const values = transformStr.split('(')[1].split(')')[0].split(',');
341
+ if (values.length < 4) return 0;
342
+ const a = parseFloat(values[0]);
343
+ const b = parseFloat(values[1]);
344
+ return Math.round(Math.atan2(b, a) * (180 / Math.PI));
345
+ }
346
+
347
+ export function svgToPng(node) {
348
+ return new Promise((resolve) => {
349
+ const clone = node.cloneNode(true);
350
+ const rect = node.getBoundingClientRect();
351
+ const width = rect.width || 300;
352
+ const height = rect.height || 150;
353
+
354
+ function inlineStyles(source, target) {
355
+ const computed = window.getComputedStyle(source);
356
+ const properties = [
357
+ 'fill',
358
+ 'stroke',
359
+ 'stroke-width',
360
+ 'stroke-linecap',
361
+ 'stroke-linejoin',
362
+ 'opacity',
363
+ 'font-family',
364
+ 'font-size',
365
+ 'font-weight',
366
+ ];
367
+
368
+ if (computed.fill === 'none') target.setAttribute('fill', 'none');
369
+ else if (computed.fill) target.style.fill = computed.fill;
370
+
371
+ if (computed.stroke === 'none') target.setAttribute('stroke', 'none');
372
+ else if (computed.stroke) target.style.stroke = computed.stroke;
373
+
374
+ properties.forEach((prop) => {
375
+ if (prop !== 'fill' && prop !== 'stroke') {
376
+ const val = computed[prop];
377
+ if (val && val !== 'auto') target.style[prop] = val;
378
+ }
379
+ });
380
+
381
+ for (let i = 0; i < source.children.length; i++) {
382
+ if (target.children[i]) inlineStyles(source.children[i], target.children[i]);
383
+ }
384
+ }
385
+
386
+ inlineStyles(node, clone);
387
+ clone.setAttribute('width', width);
388
+ clone.setAttribute('height', height);
389
+ clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
390
+
391
+ const xml = new XMLSerializer().serializeToString(clone);
392
+ const svgUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(xml)}`;
393
+ const img = new Image();
394
+ img.crossOrigin = 'Anonymous';
395
+ img.onload = () => {
396
+ const canvas = document.createElement('canvas');
397
+ const scale = 3;
398
+ canvas.width = width * scale;
399
+ canvas.height = height * scale;
400
+ const ctx = canvas.getContext('2d');
401
+ ctx.scale(scale, scale);
402
+ ctx.drawImage(img, 0, 0, width, height);
403
+ resolve(canvas.toDataURL('image/png'));
404
+ };
405
+ img.onerror = () => resolve(null);
406
+ img.src = svgUrl;
407
+ });
408
+ }
409
+
410
+ export function getVisibleShadow(shadowStr, scale) {
411
+ if (!shadowStr || shadowStr === 'none') return null;
412
+ const shadows = shadowStr.split(/,(?![^()]*\))/);
413
+ for (let s of shadows) {
414
+ s = s.trim();
415
+ if (s.startsWith('rgba(0, 0, 0, 0)')) continue;
416
+ const match = s.match(
417
+ /(rgba?\([^)]+\)|#[0-9a-fA-F]+)\s+(-?[\d.]+)px\s+(-?[\d.]+)px\s+([\d.]+)px/
418
+ );
419
+ if (match) {
420
+ const colorStr = match[1];
421
+ const x = parseFloat(match[2]);
422
+ const y = parseFloat(match[3]);
423
+ const blur = parseFloat(match[4]);
424
+ const distance = Math.sqrt(x * x + y * y);
425
+ let angle = Math.atan2(y, x) * (180 / Math.PI);
426
+ if (angle < 0) angle += 360;
427
+ const colorObj = parseColor(colorStr);
428
+ return {
429
+ type: 'outer',
430
+ angle: angle,
431
+ blur: blur * 0.75 * scale,
432
+ offset: distance * 0.75 * scale,
433
+ color: colorObj.hex || '000000',
434
+ opacity: colorObj.opacity,
435
+ };
436
+ }
437
+ }
438
+ return null;
439
+ }
440
+
441
+ /**
442
+ * Generates an SVG image for gradients, supporting degrees and keywords.
443
+ */
444
+ export function generateGradientSVG(w, h, bgString, radius, border) {
445
+ try {
446
+ const match = bgString.match(/linear-gradient\((.*)\)/);
447
+ if (!match) return null;
448
+ const content = match[1];
449
+
450
+ // Split by comma, ignoring commas inside parentheses (e.g. rgba())
451
+ const parts = content.split(/,(?![^()]*\))/).map((p) => p.trim());
452
+ if (parts.length < 2) return null;
453
+
454
+ let x1 = '0%',
455
+ y1 = '0%',
456
+ x2 = '0%',
457
+ y2 = '100%';
458
+ let stopsStartIndex = 0;
459
+ const firstPart = parts[0].toLowerCase();
460
+
461
+ // 1. Check for Keywords (to right, etc.)
462
+ if (firstPart.startsWith('to ')) {
463
+ stopsStartIndex = 1;
464
+ const direction = firstPart.replace('to ', '').trim();
465
+ switch (direction) {
466
+ case 'top':
467
+ y1 = '100%';
468
+ y2 = '0%';
469
+ break;
470
+ case 'bottom':
471
+ y1 = '0%';
472
+ y2 = '100%';
473
+ break;
474
+ case 'left':
475
+ x1 = '100%';
476
+ x2 = '0%';
477
+ break;
478
+ case 'right':
479
+ x2 = '100%';
480
+ break;
481
+ case 'top right':
482
+ x1 = '0%';
483
+ y1 = '100%';
484
+ x2 = '100%';
485
+ y2 = '0%';
486
+ break;
487
+ case 'top left':
488
+ x1 = '100%';
489
+ y1 = '100%';
490
+ x2 = '0%';
491
+ y2 = '0%';
492
+ break;
493
+ case 'bottom right':
494
+ x2 = '100%';
495
+ y2 = '100%';
496
+ break;
497
+ case 'bottom left':
498
+ x1 = '100%';
499
+ y2 = '100%';
500
+ break;
501
+ }
502
+ }
503
+ // 2. Check for Degrees (45deg, 90deg, etc.)
504
+ else if (firstPart.match(/^-?[\d.]+(deg|rad|turn|grad)$/)) {
505
+ stopsStartIndex = 1;
506
+ const val = parseFloat(firstPart);
507
+ // CSS 0deg is Top (North), 90deg is Right (East), 180deg is Bottom (South)
508
+ // We convert this to SVG coordinates on a unit square (0-100%).
509
+ // Formula: Map angle to perimeter coordinates.
510
+ if (!isNaN(val)) {
511
+ const deg = firstPart.includes('rad') ? val * (180 / Math.PI) : val;
512
+ const cssRad = ((deg - 90) * Math.PI) / 180; // Correct CSS angle offset
513
+
514
+ // Calculate standard vector for rectangle center (50, 50)
515
+ const scale = 50; // Distance from center to edge (approx)
516
+ const cos = Math.cos(cssRad); // Y component (reversed in SVG)
517
+ const sin = Math.sin(cssRad); // X component
518
+
519
+ // Invert Y for SVG coordinate system
520
+ x1 = (50 - sin * scale).toFixed(1) + '%';
521
+ y1 = (50 + cos * scale).toFixed(1) + '%';
522
+ x2 = (50 + sin * scale).toFixed(1) + '%';
523
+ y2 = (50 - cos * scale).toFixed(1) + '%';
524
+ }
525
+ }
526
+
527
+ // 3. Process Color Stops
528
+ let stopsXML = '';
529
+ const stopParts = parts.slice(stopsStartIndex);
530
+
531
+ stopParts.forEach((part, idx) => {
532
+ // Parse "Color Position" (e.g., "red 50%")
533
+ // Regex looks for optional space + number + unit at the end of the string
534
+ let color = part;
535
+ let offset = Math.round((idx / (stopParts.length - 1)) * 100) + '%';
536
+
537
+ const posMatch = part.match(/^(.*?)\s+(-?[\d.]+(?:%|px)?)$/);
538
+ if (posMatch) {
539
+ color = posMatch[1];
540
+ offset = posMatch[2];
541
+ }
542
+
543
+ // Handle RGBA/RGB for SVG compatibility
544
+ let opacity = 1;
545
+ if (color.includes('rgba')) {
546
+ const rgbaMatch = color.match(/[\d.]+/g);
547
+ if (rgbaMatch && rgbaMatch.length >= 4) {
548
+ opacity = rgbaMatch[3];
549
+ color = `rgb(${rgbaMatch[0]},${rgbaMatch[1]},${rgbaMatch[2]})`;
550
+ }
551
+ }
552
+
553
+ stopsXML += `<stop offset="${offset}" stop-color="${color.trim()}" stop-opacity="${opacity}"/>`;
554
+ });
555
+
556
+ let strokeAttr = '';
557
+ if (border) {
558
+ strokeAttr = `stroke="#${border.color}" stroke-width="${border.width}"`;
559
+ }
560
+
561
+ const svg = `
562
+ <svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
563
+ <defs>
564
+ <linearGradient id="grad" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}">
565
+ ${stopsXML}
566
+ </linearGradient>
567
+ </defs>
568
+ <rect x="0" y="0" width="${w}" height="${h}" rx="${radius}" ry="${radius}" fill="url(#grad)" ${strokeAttr} />
569
+ </svg>`;
570
+
571
+ return 'data:image/svg+xml;base64,' + btoa(svg);
572
+ } catch (e) {
573
+ console.warn('Gradient generation failed:', e);
574
+ return null;
575
+ }
576
+ }
577
+
578
+ export function generateBlurredSVG(w, h, color, radius, blurPx) {
579
+ const padding = blurPx * 3;
580
+ const fullW = w + padding * 2;
581
+ const fullH = h + padding * 2;
582
+ const x = padding;
583
+ const y = padding;
584
+ let shapeTag = '';
585
+ const isCircle = radius >= Math.min(w, h) / 2 - 1 && Math.abs(w - h) < 2;
586
+
587
+ if (isCircle) {
588
+ const cx = x + w / 2;
589
+ const cy = y + h / 2;
590
+ const rx = w / 2;
591
+ const ry = h / 2;
592
+ shapeTag = `<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}" fill="#${color}" filter="url(#f1)" />`;
593
+ } else {
594
+ shapeTag = `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${radius}" ry="${radius}" fill="#${color}" filter="url(#f1)" />`;
595
+ }
596
+
597
+ const svg = `
598
+ <svg xmlns="http://www.w3.org/2000/svg" width="${fullW}" height="${fullH}" viewBox="0 0 ${fullW} ${fullH}">
599
+ <defs>
600
+ <filter id="f1" x="-50%" y="-50%" width="200%" height="200%">
601
+ <feGaussianBlur in="SourceGraphic" stdDeviation="${blurPx}" />
602
+ </filter>
603
+ </defs>
604
+ ${shapeTag}
605
+ </svg>`;
606
+
607
+ return {
608
+ data: 'data:image/svg+xml;base64,' + btoa(svg),
609
+ padding: padding,
610
+ };
611
+ }
612
+
613
+ // src/utils.js
614
+
615
+ // ... (keep all existing exports) ...
616
+
617
+ /**
618
+ * Traverses the target DOM and collects all unique font-family names used.
619
+ */
620
+ export function getUsedFontFamilies(root) {
621
+ const families = new Set();
622
+
623
+ function scan(node) {
624
+ if (node.nodeType === 1) {
625
+ // Element
626
+ const style = window.getComputedStyle(node);
627
+ const fontList = style.fontFamily.split(',');
628
+ // The first font in the stack is the primary one
629
+ const primary = fontList[0].trim().replace(/['"]/g, '');
630
+ if (primary) families.add(primary);
631
+ }
632
+ for (const child of node.childNodes) {
633
+ scan(child);
634
+ }
635
+ }
636
+
637
+ // Handle array of roots or single root
638
+ const elements = Array.isArray(root) ? root : [root];
639
+ elements.forEach((el) => {
640
+ const node = typeof el === 'string' ? document.querySelector(el) : el;
641
+ if (node) scan(node);
642
+ });
643
+
644
+ return families;
645
+ }
646
+
647
+ /**
648
+ * Scans document.styleSheets to find @font-face URLs for the requested families.
649
+ * Returns an array of { name, url } objects.
650
+ */
651
+ export async function getAutoDetectedFonts(usedFamilies) {
652
+ const foundFonts = [];
653
+ const processedUrls = new Set();
654
+
655
+ // Helper to extract clean URL from CSS src string
656
+ const extractUrl = (srcStr) => {
657
+ // Look for url("...") or url('...') or url(...)
658
+ // Prioritize woff, ttf, otf. Avoid woff2 if possible as handling is harder,
659
+ // but if it's the only one, take it (convert logic handles it best effort).
660
+ const matches = srcStr.match(/url\((['"]?)(.*?)\1\)/g);
661
+ if (!matches) return null;
662
+
663
+ // Filter for preferred formats
664
+ let chosenUrl = null;
665
+ for (const match of matches) {
666
+ const urlRaw = match.replace(/url\((['"]?)(.*?)\1\)/, '$2');
667
+ // Skip data URIs for now (unless you want to support base64 embedding)
668
+ if (urlRaw.startsWith('data:')) continue;
669
+
670
+ if (urlRaw.includes('.ttf') || urlRaw.includes('.otf') || urlRaw.includes('.woff')) {
671
+ chosenUrl = urlRaw;
672
+ break; // Found a good one
673
+ }
674
+ // Fallback
675
+ if (!chosenUrl) chosenUrl = urlRaw;
676
+ }
677
+ return chosenUrl;
678
+ };
679
+
680
+ for (const sheet of Array.from(document.styleSheets)) {
681
+ try {
682
+ // Accessing cssRules on cross-origin sheets (like Google Fonts) might fail
683
+ // if CORS headers aren't set. We wrap in try/catch.
684
+ const rules = sheet.cssRules || sheet.rules;
685
+ if (!rules) continue;
686
+
687
+ for (const rule of Array.from(rules)) {
688
+ if (rule.constructor.name === 'CSSFontFaceRule' || rule.type === 5) {
689
+ const familyName = rule.style.getPropertyValue('font-family').replace(/['"]/g, '').trim();
690
+
691
+ if (usedFamilies.has(familyName)) {
692
+ const src = rule.style.getPropertyValue('src');
693
+ const url = extractUrl(src);
694
+
695
+ if (url && !processedUrls.has(url)) {
696
+ processedUrls.add(url);
697
+ foundFonts.push({ name: familyName, url: url });
698
+ }
699
+ }
700
+ }
701
+ }
702
+ } catch (e) {
703
+ // SecurityError is common for external stylesheets (CORS).
704
+ // We cannot scan those automatically via CSSOM.
705
+ console.warn('error:', e);
706
+ console.warn('Cannot scan stylesheet for fonts (CORS restriction):', sheet.href);
707
+ }
708
+ }
709
+
710
+ return foundFonts;
711
+ }