@microlee666/dom-to-pptx 1.1.4

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 ADDED
@@ -0,0 +1,1032 @@
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
+ function getTableBorder(style, side, scale) {
11
+ const widthStr = style[`border${side}Width`];
12
+ const styleStr = style[`border${side}Style`];
13
+ const colorStr = style[`border${side}Color`];
14
+
15
+ const width = parseFloat(widthStr) || 0;
16
+ if (width === 0 || styleStr === 'none' || styleStr === 'hidden') {
17
+ return null;
18
+ }
19
+
20
+ const color = parseColor(colorStr);
21
+ if (!color.hex || color.opacity === 0) return null;
22
+
23
+ let dash = 'solid';
24
+ if (styleStr === 'dashed') dash = 'dash';
25
+ if (styleStr === 'dotted') dash = 'dot';
26
+
27
+ return {
28
+ pt: width * 0.75 * scale, // Convert px to pt
29
+ color: color.hex,
30
+ style: dash,
31
+ };
32
+ }
33
+
34
+ /**
35
+ * Extracts native table data for PptxGenJS.
36
+ */
37
+ export function extractTableData(node, scale, options = {}) {
38
+ const rows = [];
39
+ const colWidths = [];
40
+ const root = options.root || null;
41
+
42
+ // 1. Calculate Column Widths based on the first row of cells
43
+ // We look at the first <tr>'s children to determine visual column widths.
44
+ // Note: This assumes a fixed grid. Complex colspan/rowspan on the first row
45
+ // might skew widths, but getBoundingClientRect captures the rendered result.
46
+ const firstRow = node.querySelector('tr');
47
+ if (firstRow) {
48
+ const cells = Array.from(firstRow.children);
49
+ cells.forEach((cell) => {
50
+ const rect = cell.getBoundingClientRect();
51
+ const wIn = rect.width * (1 / 96) * scale;
52
+ colWidths.push(wIn);
53
+ });
54
+ }
55
+
56
+ // 2. Iterate Rows
57
+ const trList = node.querySelectorAll('tr');
58
+ trList.forEach((tr) => {
59
+ const rowData = [];
60
+ const cellList = Array.from(tr.children).filter((c) =>
61
+ ['TD', 'TH'].includes(c.tagName)
62
+ );
63
+
64
+ cellList.forEach((cell) => {
65
+ const style = window.getComputedStyle(cell);
66
+ const cellText = cell.innerText.replace(/[\n\r\t]+/g, ' ').trim();
67
+
68
+ // A. Text Style
69
+ const textStyle = getTextStyle(style, scale, cellText, options);
70
+
71
+ // B. Cell Background
72
+ const fill = computeTableCellFill(style, cell, root, options);
73
+
74
+ // C. Alignment
75
+ let align = 'left';
76
+ if (style.textAlign === 'center') align = 'center';
77
+ if (style.textAlign === 'right' || style.textAlign === 'end') align = 'right';
78
+
79
+ let valign = 'top';
80
+ if (style.verticalAlign === 'middle') valign = 'middle';
81
+ if (style.verticalAlign === 'bottom') valign = 'bottom';
82
+
83
+ // D. Padding (Margins in PPTX)
84
+ // CSS Padding px -> PPTX Margin pt
85
+ const padding = getPadding(style, scale);
86
+ // getPadding returns [top, right, bottom, left] in inches relative to scale
87
+ // PptxGenJS expects points (pt) for margin: [t, r, b, l]
88
+ // or discrete properties. Let's use discrete for clarity.
89
+ const margin = [
90
+ padding[0] * 72, // top
91
+ padding[1] * 72, // right
92
+ padding[2] * 72, // bottom
93
+ padding[3] * 72 // left
94
+ ];
95
+
96
+ // E. Borders
97
+ const borderTop = getTableBorder(style, 'Top', scale);
98
+ const borderRight = getTableBorder(style, 'Right', scale);
99
+ const borderBottom = getTableBorder(style, 'Bottom', scale);
100
+ const borderLeft = getTableBorder(style, 'Left', scale);
101
+
102
+ // F. Construct Cell Object
103
+ rowData.push({
104
+ text: cellText,
105
+ options: {
106
+ color: textStyle.color,
107
+ fontFace: textStyle.fontFace,
108
+ fontSize: textStyle.fontSize,
109
+ bold: textStyle.bold,
110
+ italic: textStyle.italic,
111
+ underline: textStyle.underline,
112
+
113
+ fill: fill,
114
+ align: align,
115
+ valign: valign,
116
+ margin: margin,
117
+
118
+ rowspan: parseInt(cell.getAttribute('rowspan')) || null,
119
+ colspan: parseInt(cell.getAttribute('colspan')) || null,
120
+
121
+ border: {
122
+ pt: null, // trigger explicit object structure
123
+ top: borderTop,
124
+ right: borderRight,
125
+ bottom: borderBottom,
126
+ left: borderLeft
127
+ }
128
+ },
129
+ });
130
+ });
131
+
132
+ if (rowData.length > 0) {
133
+ rows.push(rowData);
134
+ }
135
+ });
136
+
137
+ return { rows, colWidths };
138
+ }
139
+
140
+ // Checks if any parent element has overflow: hidden which would clip this element
141
+ export function isClippedByParent(node) {
142
+ let parent = node.parentElement;
143
+ while (parent && parent !== document.body) {
144
+ const style = window.getComputedStyle(parent);
145
+ const overflow = style.overflow;
146
+ if (overflow === 'hidden' || overflow === 'clip') {
147
+ return true;
148
+ }
149
+ parent = parent.parentElement;
150
+ }
151
+ return false;
152
+ }
153
+
154
+ // Helper to save gradient text
155
+ // Helper to save gradient text: extracts the first color from a gradient string
156
+ export function getGradientFallbackColor(bgImage) {
157
+ if (!bgImage || bgImage === 'none') return null;
158
+
159
+ // 1. Extract content inside function(...)
160
+ // Handles linear-gradient(...), radial-gradient(...), repeating-linear-gradient(...)
161
+ const match = bgImage.match(/gradient\((.*)\)/);
162
+ if (!match) return null;
163
+
164
+ const content = match[1];
165
+
166
+ // 2. Split by comma, respecting parentheses (to avoid splitting inside rgb(), oklch(), etc.)
167
+ const parts = [];
168
+ let current = '';
169
+ let parenDepth = 0;
170
+
171
+ for (const char of content) {
172
+ if (char === '(') parenDepth++;
173
+ if (char === ')') parenDepth--;
174
+ if (char === ',' && parenDepth === 0) {
175
+ parts.push(current.trim());
176
+ current = '';
177
+ } else {
178
+ current += char;
179
+ }
180
+ }
181
+ if (current) parts.push(current.trim());
182
+
183
+ // 3. Find first part that is a color (skip angle/direction)
184
+ for (const part of parts) {
185
+ // Ignore directions (to right) or angles (90deg, 0.5turn)
186
+ if (/^(to\s|[\d\.]+(deg|rad|turn|grad))/.test(part)) continue;
187
+
188
+ // Extract color: Remove trailing position (e.g. "red 50%" -> "red")
189
+ // Regex matches whitespace + number + unit at end of string
190
+ const colorPart = part.replace(/\s+(-?[\d\.]+(%|px|em|rem|ch|vh|vw)?)$/, '');
191
+
192
+ // Check if it's not just a number (some gradients might have bare numbers? unlikely in standard syntax)
193
+ if (colorPart) return colorPart;
194
+ }
195
+
196
+ return null;
197
+ }
198
+
199
+ function mapDashType(style) {
200
+ if (style === 'dashed') return 'dash';
201
+ if (style === 'dotted') return 'dot';
202
+ return 'solid';
203
+ }
204
+
205
+ function hexToRgb(hex) {
206
+ const clean = (hex || '').replace('#', '').trim();
207
+ if (clean.length !== 6) return null;
208
+ const num = parseInt(clean, 16);
209
+ if (Number.isNaN(num)) return null;
210
+ return {
211
+ r: (num >> 16) & 255,
212
+ g: (num >> 8) & 255,
213
+ b: num & 255,
214
+ };
215
+ }
216
+
217
+ function rgbToHex(rgb) {
218
+ const toHex = (v) => v.toString(16).padStart(2, '0').toUpperCase();
219
+ return `${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}`;
220
+ }
221
+
222
+ function blendHex(baseHex, overlayHex, alpha) {
223
+ const base = hexToRgb(baseHex);
224
+ const over = hexToRgb(overlayHex);
225
+ if (!base || !over) return overlayHex;
226
+ const a = Math.max(0, Math.min(1, alpha));
227
+ const r = Math.round(base.r * (1 - a) + over.r * a);
228
+ const g = Math.round(base.g * (1 - a) + over.g * a);
229
+ const b = Math.round(base.b * (1 - a) + over.b * a);
230
+ return rgbToHex({ r, g, b });
231
+ }
232
+
233
+ function resolveTableBaseColor(node, root) {
234
+ // Start from parent to avoid picking the cell's own semi-transparent fill
235
+ let el = node?.parentElement || null;
236
+ while (el && el !== document.body) {
237
+ const style = window.getComputedStyle(el);
238
+ const bg = parseColor(style.backgroundColor);
239
+ if (bg.hex && bg.opacity > 0) return bg.hex;
240
+
241
+ const bgClip = style.webkitBackgroundClip || style.backgroundClip;
242
+ const bgImage = style.backgroundImage;
243
+ if (bgClip !== 'text' && bgImage && bgImage.includes('gradient')) {
244
+ const fallback = getGradientFallbackColor(bgImage);
245
+ if (fallback) {
246
+ const parsed = parseColor(fallback);
247
+ if (parsed.hex) return parsed.hex;
248
+ }
249
+ }
250
+
251
+ if (el === root) break;
252
+ el = el.parentElement;
253
+ }
254
+ return null;
255
+ }
256
+
257
+ export function computeTableCellFill(style, cell, root, options = {}) {
258
+ const bg = parseColor(style.backgroundColor);
259
+ if (!bg.hex || bg.opacity <= 0) return null;
260
+
261
+ const flatten = options.tableConfig?.flattenTransparentFill !== false;
262
+ if (bg.opacity < 1 && flatten) {
263
+ const baseHex = resolveTableBaseColor(cell, root) || 'FFFFFF';
264
+ const blended = blendHex(baseHex, bg.hex, bg.opacity);
265
+ return { color: blended };
266
+ }
267
+
268
+ const transparency = Math.max(0, Math.min(100, (1 - bg.opacity) * 100));
269
+ return transparency > 0 ? { color: bg.hex, transparency } : { color: bg.hex };
270
+ }
271
+
272
+ /**
273
+ * Analyzes computed border styles and determines the rendering strategy.
274
+ */
275
+ export function getBorderInfo(style, scale) {
276
+ const top = {
277
+ width: parseFloat(style.borderTopWidth) || 0,
278
+ style: style.borderTopStyle,
279
+ color: parseColor(style.borderTopColor).hex,
280
+ };
281
+ const right = {
282
+ width: parseFloat(style.borderRightWidth) || 0,
283
+ style: style.borderRightStyle,
284
+ color: parseColor(style.borderRightColor).hex,
285
+ };
286
+ const bottom = {
287
+ width: parseFloat(style.borderBottomWidth) || 0,
288
+ style: style.borderBottomStyle,
289
+ color: parseColor(style.borderBottomColor).hex,
290
+ };
291
+ const left = {
292
+ width: parseFloat(style.borderLeftWidth) || 0,
293
+ style: style.borderLeftStyle,
294
+ color: parseColor(style.borderLeftColor).hex,
295
+ };
296
+
297
+ const hasAnyBorder = top.width > 0 || right.width > 0 || bottom.width > 0 || left.width > 0;
298
+ if (!hasAnyBorder) return { type: 'none' };
299
+
300
+ // Check if all sides are uniform
301
+ const isUniform =
302
+ top.width === right.width &&
303
+ top.width === bottom.width &&
304
+ top.width === left.width &&
305
+ top.style === right.style &&
306
+ top.style === bottom.style &&
307
+ top.style === left.style &&
308
+ top.color === right.color &&
309
+ top.color === bottom.color &&
310
+ top.color === left.color;
311
+
312
+ if (isUniform) {
313
+ return {
314
+ type: 'uniform',
315
+ options: {
316
+ width: top.width * 0.75 * scale,
317
+ color: top.color,
318
+ transparency: (1 - parseColor(style.borderTopColor).opacity) * 100,
319
+ dashType: mapDashType(top.style),
320
+ },
321
+ };
322
+ } else {
323
+ return {
324
+ type: 'composite',
325
+ sides: { top, right, bottom, left },
326
+ };
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Generates an SVG image for composite borders that respects border-radius.
332
+ */
333
+ export function generateCompositeBorderSVG(w, h, radius, sides) {
334
+ radius = radius / 2; // Adjust for SVG rendering
335
+ const clipId = 'clip_' + Math.random().toString(36).substr(2, 9);
336
+ let borderRects = '';
337
+
338
+ if (sides.top.width > 0 && sides.top.color) {
339
+ borderRects += `<rect x="0" y="0" width="${w}" height="${sides.top.width}" fill="#${sides.top.color}" />`;
340
+ }
341
+ if (sides.right.width > 0 && sides.right.color) {
342
+ borderRects += `<rect x="${w - sides.right.width}" y="0" width="${sides.right.width}" height="${h}" fill="#${sides.right.color}" />`;
343
+ }
344
+ if (sides.bottom.width > 0 && sides.bottom.color) {
345
+ borderRects += `<rect x="0" y="${h - sides.bottom.width}" width="${w}" height="${sides.bottom.width}" fill="#${sides.bottom.color}" />`;
346
+ }
347
+ if (sides.left.width > 0 && sides.left.color) {
348
+ borderRects += `<rect x="0" y="0" width="${sides.left.width}" height="${h}" fill="#${sides.left.color}" />`;
349
+ }
350
+
351
+ const svg = `
352
+ <svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
353
+ <defs>
354
+ <clipPath id="${clipId}">
355
+ <rect x="0" y="0" width="${w}" height="${h}" rx="${radius}" ry="${radius}" />
356
+ </clipPath>
357
+ </defs>
358
+ <g clip-path="url(#${clipId})">
359
+ ${borderRects}
360
+ </g>
361
+ </svg>`;
362
+
363
+ return 'data:image/svg+xml;base64,' + btoa(svg);
364
+ }
365
+
366
+ /**
367
+ * Generates an SVG data URL for a solid shape with non-uniform corner radii.
368
+ */
369
+ export function generateCustomShapeSVG(w, h, color, opacity, radii) {
370
+ let { tl, tr, br, bl } = radii;
371
+
372
+ // Clamp radii using CSS spec logic (avoid overlap)
373
+ const factor = Math.min(
374
+ w / (tl + tr) || Infinity,
375
+ h / (tr + br) || Infinity,
376
+ w / (br + bl) || Infinity,
377
+ h / (bl + tl) || Infinity
378
+ );
379
+
380
+ if (factor < 1) {
381
+ tl *= factor;
382
+ tr *= factor;
383
+ br *= factor;
384
+ bl *= factor;
385
+ }
386
+
387
+ const path = `
388
+ M ${tl} 0
389
+ L ${w - tr} 0
390
+ A ${tr} ${tr} 0 0 1 ${w} ${tr}
391
+ L ${w} ${h - br}
392
+ A ${br} ${br} 0 0 1 ${w - br} ${h}
393
+ L ${bl} ${h}
394
+ A ${bl} ${bl} 0 0 1 0 ${h - bl}
395
+ L 0 ${tl}
396
+ A ${tl} ${tl} 0 0 1 ${tl} 0
397
+ Z
398
+ `;
399
+
400
+ const svg = `
401
+ <svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
402
+ <path d="${path}" fill="#${color}" fill-opacity="${opacity}" />
403
+ </svg>`;
404
+
405
+ return 'data:image/svg+xml;base64,' + btoa(svg);
406
+ }
407
+
408
+ // --- REPLACE THE EXISTING parseColor FUNCTION ---
409
+ export function parseColor(str) {
410
+ if (!str || str === 'transparent' || str.trim() === 'rgba(0, 0, 0, 0)') {
411
+ return { hex: null, opacity: 0 };
412
+ }
413
+
414
+ const ctx = getCtx();
415
+ ctx.fillStyle = str;
416
+ const computed = ctx.fillStyle;
417
+
418
+ // 1. Handle Hex Output (e.g. #ff0000) - Fast Path
419
+ if (computed.startsWith('#')) {
420
+ let hex = computed.slice(1);
421
+ let opacity = 1;
422
+ if (hex.length === 3) hex = hex.split('').map(c => c + c).join('');
423
+ if (hex.length === 4) hex = hex.split('').map(c => c + c).join('');
424
+ if (hex.length === 8) {
425
+ opacity = parseInt(hex.slice(6), 16) / 255;
426
+ hex = hex.slice(0, 6);
427
+ }
428
+ return { hex: hex.toUpperCase(), opacity };
429
+ }
430
+
431
+ // 2. Handle RGB/RGBA Output (standard) - Fast Path
432
+ if (computed.startsWith('rgb')) {
433
+ const match = computed.match(/[\d.]+/g);
434
+ if (match && match.length >= 3) {
435
+ const r = parseInt(match[0]);
436
+ const g = parseInt(match[1]);
437
+ const b = parseInt(match[2]);
438
+ const a = match.length > 3 ? parseFloat(match[3]) : 1;
439
+ const hex = ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase();
440
+ return { hex, opacity: a };
441
+ }
442
+ }
443
+
444
+ // 3. Fallback: Browser returned a format we don't parse (oklch, lab, color(srgb...), etc.)
445
+ // Use Canvas API to convert to sRGB
446
+ ctx.clearRect(0, 0, 1, 1);
447
+ ctx.fillRect(0, 0, 1, 1);
448
+ const data = ctx.getImageData(0, 0, 1, 1).data;
449
+ // data = [r, g, b, a]
450
+ const r = data[0];
451
+ const g = data[1];
452
+ const b = data[2];
453
+ const a = data[3] / 255;
454
+
455
+ if (a === 0) return { hex: null, opacity: 0 };
456
+
457
+ const hex = ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase();
458
+ return { hex, opacity: a };
459
+ }
460
+
461
+ export function getPadding(style, scale) {
462
+ const pxToInch = 1 / 96;
463
+ return [
464
+ (parseFloat(style.paddingTop) || 0) * pxToInch * scale,
465
+ (parseFloat(style.paddingRight) || 0) * pxToInch * scale,
466
+ (parseFloat(style.paddingBottom) || 0) * pxToInch * scale,
467
+ (parseFloat(style.paddingLeft) || 0) * pxToInch * scale,
468
+ ];
469
+ }
470
+
471
+ export function getSoftEdges(filterStr, scale) {
472
+ if (!filterStr || filterStr === 'none') return null;
473
+ const match = filterStr.match(/blur\(([\d.]+)px\)/);
474
+ if (match) return parseFloat(match[1]) * 0.75 * scale;
475
+ return null;
476
+ }
477
+
478
+ const DEFAULT_CJK_FONTS = [
479
+ 'PingFang SC',
480
+ 'Hiragino Sans GB',
481
+ 'Microsoft YaHei',
482
+ 'Noto Sans CJK SC',
483
+ 'Source Han Sans SC',
484
+ 'WenQuanYi Micro Hei',
485
+ 'SimHei',
486
+ 'SimSun',
487
+ 'STHeiti'
488
+ ];
489
+
490
+ function normalizeFontList(fontFamily) {
491
+ if (!fontFamily || typeof fontFamily !== 'string') return [];
492
+ return fontFamily
493
+ .split(',')
494
+ .map((f) => f.trim().replace(/['"]/g, ''))
495
+ .filter(Boolean);
496
+ }
497
+
498
+ function containsCjk(text) {
499
+ if (!text) return false;
500
+ return /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/.test(text);
501
+ }
502
+
503
+ function normalizeCjkFallbacks(options) {
504
+ if (!options) return [];
505
+ const raw =
506
+ options.fontFallbacks?.cjk ??
507
+ options.cjkFonts ??
508
+ options.cjkFont ??
509
+ [];
510
+ const list = Array.isArray(raw) ? raw : [raw];
511
+ return list
512
+ .map((f) => (typeof f === 'string' ? f.trim() : String(f)))
513
+ .filter(Boolean);
514
+ }
515
+
516
+ function pickFontFace(fontFamily, text, options) {
517
+ const fontList = normalizeFontList(fontFamily);
518
+ const primary = fontList[0] || 'Arial';
519
+
520
+ if (!containsCjk(text)) return primary;
521
+
522
+ const lowered = fontList.map((f) => f.toLowerCase());
523
+ const configured = normalizeCjkFallbacks(options);
524
+ if (configured.length > 0) {
525
+ const match = configured.find((f) => lowered.includes(f.toLowerCase()));
526
+ return match || configured[0];
527
+ }
528
+
529
+ const autoMatch = DEFAULT_CJK_FONTS.find((f) => lowered.includes(f.toLowerCase()));
530
+ return autoMatch || primary;
531
+ }
532
+
533
+ export function getTextStyle(style, scale, text = '', options = {}) {
534
+ let colorObj = parseColor(style.color);
535
+
536
+ const bgClip = style.webkitBackgroundClip || style.backgroundClip;
537
+ if (colorObj.opacity === 0 && bgClip === 'text') {
538
+ const fallback = getGradientFallbackColor(style.backgroundImage);
539
+ if (fallback) colorObj = parseColor(fallback);
540
+ }
541
+
542
+ let lineSpacing = null;
543
+ const fontSizePx = parseFloat(style.fontSize);
544
+ const lhStr = style.lineHeight;
545
+
546
+ if (lhStr && lhStr !== 'normal') {
547
+ let lhPx = parseFloat(lhStr);
548
+
549
+ // Edge Case: If browser returns a raw multiplier (e.g. "1.5")
550
+ // we must multiply by font size to get the height in pixels.
551
+ // (Note: getComputedStyle usually returns 'px', but inline styles might differ)
552
+ if (/^[0-9.]+$/.test(lhStr)) {
553
+ lhPx = lhPx * fontSizePx;
554
+ }
555
+
556
+ if (!isNaN(lhPx) && lhPx > 0) {
557
+ // Convert Pixel Height to Point Height (1px = 0.75pt)
558
+ // And apply the global layout scale.
559
+ lineSpacing = lhPx * 0.75 * scale;
560
+ }
561
+ }
562
+
563
+ // --- Spacing (Margins) ---
564
+ // Convert CSS margins (px) to PPTX Paragraph Spacing (pt).
565
+ let paraSpaceBefore = 0;
566
+ let paraSpaceAfter = 0;
567
+
568
+ const mt = parseFloat(style.marginTop) || 0;
569
+ const mb = parseFloat(style.marginBottom) || 0;
570
+
571
+ if (mt > 0) paraSpaceBefore = mt * 0.75 * scale;
572
+ if (mb > 0) paraSpaceAfter = mb * 0.75 * scale;
573
+
574
+ const fontFace = pickFontFace(style.fontFamily, text, options);
575
+
576
+ return {
577
+ color: colorObj.hex || '000000',
578
+ fontFace: fontFace,
579
+ fontSize: Math.floor(fontSizePx * 0.75 * scale),
580
+ bold: parseInt(style.fontWeight) >= 600,
581
+ italic: style.fontStyle === 'italic',
582
+ underline: style.textDecoration.includes('underline'),
583
+ // Only add if we have a valid value
584
+ ...(lineSpacing && { lineSpacing }),
585
+ ...(paraSpaceBefore > 0 && { paraSpaceBefore }),
586
+ ...(paraSpaceAfter > 0 && { paraSpaceAfter }),
587
+ // Map background color to highlight if present
588
+ ...(parseColor(style.backgroundColor).hex ? { highlight: parseColor(style.backgroundColor).hex } : {}),
589
+ };
590
+ }
591
+
592
+ /**
593
+ * Determines if a given DOM node is primarily a text container.
594
+ * Updated to correctly reject Icon elements so they are rendered as images.
595
+ */
596
+ export function isTextContainer(node) {
597
+ const hasText = node.textContent.trim().length > 0;
598
+ if (!hasText) return false;
599
+
600
+ const children = Array.from(node.children);
601
+ if (children.length === 0) return true;
602
+
603
+ const isSafeInline = (el) => {
604
+ // 1. Reject Web Components / Custom Elements
605
+ if (el.tagName.includes('-')) return false;
606
+ // 2. Reject Explicit Images/SVGs
607
+ if (el.tagName === 'IMG' || el.tagName === 'SVG') return false;
608
+
609
+ // 3. Reject Class-based Icons (FontAwesome, Material, Bootstrap, etc.)
610
+ // If an <i> or <span> has icon classes, it is a visual object, not text.
611
+ if (el.tagName === 'I' || el.tagName === 'SPAN') {
612
+ const cls = el.getAttribute('class') || '';
613
+ if (
614
+ cls.includes('fa-') ||
615
+ cls.includes('fas') ||
616
+ cls.includes('far') ||
617
+ cls.includes('fab') ||
618
+ cls.includes('material-icons') ||
619
+ cls.includes('bi-') ||
620
+ cls.includes('icon')
621
+ ) {
622
+ return false;
623
+ }
624
+ }
625
+
626
+ const style = window.getComputedStyle(el);
627
+ const display = style.display;
628
+
629
+ // 4. Standard Inline Tag Check
630
+ const isInlineTag = ['SPAN', 'B', 'STRONG', 'EM', 'I', 'A', 'SMALL', 'MARK'].includes(
631
+ el.tagName
632
+ );
633
+ const isInlineDisplay = display.includes('inline');
634
+
635
+ if (!isInlineTag && !isInlineDisplay) return false;
636
+
637
+ // 5. Structural Styling Check
638
+ // If a child has a background or border, it's a layout block, not a simple text span.
639
+ const bgColor = parseColor(style.backgroundColor);
640
+ const hasVisibleBg = bgColor.hex && bgColor.opacity > 0;
641
+ const hasBorder =
642
+ parseFloat(style.borderWidth) > 0 && parseColor(style.borderColor).opacity > 0;
643
+
644
+ // 4. Check for empty shapes (visual objects without text, like dots)
645
+ const hasContent = el.textContent.trim().length > 0;
646
+ if (!hasContent && (hasVisibleBg || hasBorder)) {
647
+ return false;
648
+ }
649
+
650
+ // If element has background/border and is not inline, treat as layout block
651
+ if ((hasVisibleBg || hasBorder) && !isInlineDisplay) {
652
+ return false;
653
+ }
654
+
655
+ return true;
656
+ };
657
+
658
+ return children.every(isSafeInline);
659
+ }
660
+
661
+ export function getRotation(transformStr) {
662
+ if (!transformStr || transformStr === 'none') return 0;
663
+ const values = transformStr.split('(')[1].split(')')[0].split(',');
664
+ if (values.length < 4) return 0;
665
+ const a = parseFloat(values[0]);
666
+ const b = parseFloat(values[1]);
667
+ return Math.round(Math.atan2(b, a) * (180 / Math.PI));
668
+ }
669
+
670
+ export function svgToPng(node) {
671
+ return new Promise((resolve) => {
672
+ const clone = node.cloneNode(true);
673
+ const rect = node.getBoundingClientRect();
674
+ const width = rect.width || 300;
675
+ const height = rect.height || 150;
676
+
677
+ function inlineStyles(source, target) {
678
+ const computed = window.getComputedStyle(source);
679
+ const properties = [
680
+ 'fill',
681
+ 'stroke',
682
+ 'stroke-width',
683
+ 'stroke-linecap',
684
+ 'stroke-linejoin',
685
+ 'opacity',
686
+ 'font-family',
687
+ 'font-size',
688
+ 'font-weight',
689
+ ];
690
+
691
+ if (computed.fill === 'none') target.setAttribute('fill', 'none');
692
+ else if (computed.fill) target.style.fill = computed.fill;
693
+
694
+ if (computed.stroke === 'none') target.setAttribute('stroke', 'none');
695
+ else if (computed.stroke) target.style.stroke = computed.stroke;
696
+
697
+ properties.forEach((prop) => {
698
+ if (prop !== 'fill' && prop !== 'stroke') {
699
+ const val = computed[prop];
700
+ if (val && val !== 'auto') target.style[prop] = val;
701
+ }
702
+ });
703
+
704
+ for (let i = 0; i < source.children.length; i++) {
705
+ if (target.children[i]) inlineStyles(source.children[i], target.children[i]);
706
+ }
707
+ }
708
+
709
+ inlineStyles(node, clone);
710
+ clone.setAttribute('width', width);
711
+ clone.setAttribute('height', height);
712
+ clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
713
+
714
+ const xml = new XMLSerializer().serializeToString(clone);
715
+ const svgUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(xml)}`;
716
+ const img = new Image();
717
+ img.crossOrigin = 'Anonymous';
718
+ img.onload = () => {
719
+ const canvas = document.createElement('canvas');
720
+ const scale = 3;
721
+ canvas.width = width * scale;
722
+ canvas.height = height * scale;
723
+ const ctx = canvas.getContext('2d');
724
+ ctx.scale(scale, scale);
725
+ ctx.drawImage(img, 0, 0, width, height);
726
+ resolve(canvas.toDataURL('image/png'));
727
+ };
728
+ img.onerror = () => resolve(null);
729
+ img.src = svgUrl;
730
+ });
731
+ }
732
+
733
+ export function getVisibleShadow(shadowStr, scale) {
734
+ if (!shadowStr || shadowStr === 'none') return null;
735
+ const shadows = shadowStr.split(/,(?![^()]*\))/);
736
+ for (let s of shadows) {
737
+ s = s.trim();
738
+ if (s.startsWith('rgba(0, 0, 0, 0)')) continue;
739
+ const match = s.match(
740
+ /(rgba?\([^)]+\)|#[0-9a-fA-F]+)\s+(-?[\d.]+)px\s+(-?[\d.]+)px\s+([\d.]+)px/
741
+ );
742
+ if (match) {
743
+ const colorStr = match[1];
744
+ const x = parseFloat(match[2]);
745
+ const y = parseFloat(match[3]);
746
+ const blur = parseFloat(match[4]);
747
+ const distance = Math.sqrt(x * x + y * y);
748
+ let angle = Math.atan2(y, x) * (180 / Math.PI);
749
+ if (angle < 0) angle += 360;
750
+ const colorObj = parseColor(colorStr);
751
+ return {
752
+ type: 'outer',
753
+ angle: angle,
754
+ blur: blur * 0.75 * scale,
755
+ offset: distance * 0.75 * scale,
756
+ color: colorObj.hex || '000000',
757
+ opacity: colorObj.opacity,
758
+ };
759
+ }
760
+ }
761
+ return null;
762
+ }
763
+
764
+ /**
765
+ * Generates an SVG image for gradients, supporting degrees and keywords.
766
+ */
767
+ export function generateGradientSVG(w, h, bgString, radius, border) {
768
+ try {
769
+ const match = bgString.match(/linear-gradient\((.*)\)/);
770
+ if (!match) return null;
771
+ const content = match[1];
772
+
773
+ // Split by comma, ignoring commas inside parentheses (e.g. rgba())
774
+ const parts = content.split(/,(?![^()]*\))/).map((p) => p.trim());
775
+ if (parts.length < 2) return null;
776
+
777
+ let x1 = '0%',
778
+ y1 = '0%',
779
+ x2 = '0%',
780
+ y2 = '100%';
781
+ let stopsStartIndex = 0;
782
+ const firstPart = parts[0].toLowerCase();
783
+
784
+ // 1. Check for Keywords (to right, etc.)
785
+ if (firstPart.startsWith('to ')) {
786
+ stopsStartIndex = 1;
787
+ const direction = firstPart.replace('to ', '').trim();
788
+ switch (direction) {
789
+ case 'top':
790
+ y1 = '100%';
791
+ y2 = '0%';
792
+ break;
793
+ case 'bottom':
794
+ y1 = '0%';
795
+ y2 = '100%';
796
+ break;
797
+ case 'left':
798
+ x1 = '100%';
799
+ x2 = '0%';
800
+ break;
801
+ case 'right':
802
+ x2 = '100%';
803
+ break;
804
+ case 'top right':
805
+ x1 = '0%';
806
+ y1 = '100%';
807
+ x2 = '100%';
808
+ y2 = '0%';
809
+ break;
810
+ case 'top left':
811
+ x1 = '100%';
812
+ y1 = '100%';
813
+ x2 = '0%';
814
+ y2 = '0%';
815
+ break;
816
+ case 'bottom right':
817
+ x2 = '100%';
818
+ y2 = '100%';
819
+ break;
820
+ case 'bottom left':
821
+ x1 = '100%';
822
+ y2 = '100%';
823
+ break;
824
+ }
825
+ }
826
+ // 2. Check for Degrees (45deg, 90deg, etc.)
827
+ else if (firstPart.match(/^-?[\d.]+(deg|rad|turn|grad)$/)) {
828
+ stopsStartIndex = 1;
829
+ const val = parseFloat(firstPart);
830
+ // CSS 0deg is Top (North), 90deg is Right (East), 180deg is Bottom (South)
831
+ // We convert this to SVG coordinates on a unit square (0-100%).
832
+ // Formula: Map angle to perimeter coordinates.
833
+ if (!isNaN(val)) {
834
+ const deg = firstPart.includes('rad') ? val * (180 / Math.PI) : val;
835
+ const cssRad = ((deg - 90) * Math.PI) / 180; // Correct CSS angle offset
836
+
837
+ // Calculate standard vector for rectangle center (50, 50)
838
+ const scale = 50; // Distance from center to edge (approx)
839
+ const cos = Math.cos(cssRad); // Y component (reversed in SVG)
840
+ const sin = Math.sin(cssRad); // X component
841
+
842
+ // Invert Y for SVG coordinate system
843
+ x1 = (50 - sin * scale).toFixed(1) + '%';
844
+ y1 = (50 + cos * scale).toFixed(1) + '%';
845
+ x2 = (50 + sin * scale).toFixed(1) + '%';
846
+ y2 = (50 - cos * scale).toFixed(1) + '%';
847
+ }
848
+ }
849
+
850
+ // 3. Process Color Stops
851
+ let stopsXML = '';
852
+ const stopParts = parts.slice(stopsStartIndex);
853
+
854
+ stopParts.forEach((part, idx) => {
855
+ // Parse "Color Position" (e.g., "red 50%")
856
+ // Regex looks for optional space + number + unit at the end of the string
857
+ let color = part;
858
+ let offset = Math.round((idx / (stopParts.length - 1)) * 100) + '%';
859
+
860
+ const posMatch = part.match(/^(.*?)\s+(-?[\d.]+(?:%|px)?)$/);
861
+ if (posMatch) {
862
+ color = posMatch[1];
863
+ offset = posMatch[2];
864
+ }
865
+
866
+ // Handle RGBA/RGB for SVG compatibility
867
+ let opacity = 1;
868
+ if (color.includes('rgba')) {
869
+ const rgbaMatch = color.match(/[\d.]+/g);
870
+ if (rgbaMatch && rgbaMatch.length >= 4) {
871
+ opacity = rgbaMatch[3];
872
+ color = `rgb(${rgbaMatch[0]},${rgbaMatch[1]},${rgbaMatch[2]})`;
873
+ }
874
+ }
875
+
876
+ stopsXML += `<stop offset="${offset}" stop-color="${color.trim()}" stop-opacity="${opacity}"/>`;
877
+ });
878
+
879
+ let strokeAttr = '';
880
+ if (border) {
881
+ strokeAttr = `stroke="#${border.color}" stroke-width="${border.width}"`;
882
+ }
883
+
884
+ const svg = `
885
+ <svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
886
+ <defs>
887
+ <linearGradient id="grad" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}">
888
+ ${stopsXML}
889
+ </linearGradient>
890
+ </defs>
891
+ <rect x="0" y="0" width="${w}" height="${h}" rx="${radius}" ry="${radius}" fill="url(#grad)" ${strokeAttr} />
892
+ </svg>`;
893
+
894
+ return 'data:image/svg+xml;base64,' + btoa(svg);
895
+ } catch (e) {
896
+ console.warn('Gradient generation failed:', e);
897
+ return null;
898
+ }
899
+ }
900
+
901
+ export function generateBlurredSVG(w, h, color, radius, blurPx) {
902
+ const padding = blurPx * 3;
903
+ const fullW = w + padding * 2;
904
+ const fullH = h + padding * 2;
905
+ const x = padding;
906
+ const y = padding;
907
+ let shapeTag = '';
908
+ const isCircle = radius >= Math.min(w, h) / 2 - 1 && Math.abs(w - h) < 2;
909
+
910
+ if (isCircle) {
911
+ const cx = x + w / 2;
912
+ const cy = y + h / 2;
913
+ const rx = w / 2;
914
+ const ry = h / 2;
915
+ shapeTag = `<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}" fill="#${color}" filter="url(#f1)" />`;
916
+ } else {
917
+ shapeTag = `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${radius}" ry="${radius}" fill="#${color}" filter="url(#f1)" />`;
918
+ }
919
+
920
+ const svg = `
921
+ <svg xmlns="http://www.w3.org/2000/svg" width="${fullW}" height="${fullH}" viewBox="0 0 ${fullW} ${fullH}">
922
+ <defs>
923
+ <filter id="f1" x="-50%" y="-50%" width="200%" height="200%">
924
+ <feGaussianBlur in="SourceGraphic" stdDeviation="${blurPx}" />
925
+ </filter>
926
+ </defs>
927
+ ${shapeTag}
928
+ </svg>`;
929
+
930
+ return {
931
+ data: 'data:image/svg+xml;base64,' + btoa(svg),
932
+ padding: padding,
933
+ };
934
+ }
935
+
936
+ // src/utils.js
937
+
938
+ // ... (keep all existing exports) ...
939
+
940
+ /**
941
+ * Traverses the target DOM and collects all unique font-family names used.
942
+ */
943
+ export function getUsedFontFamilies(root) {
944
+ const families = new Set();
945
+
946
+ function scan(node) {
947
+ if (node.nodeType === 1) {
948
+ // Element
949
+ const style = window.getComputedStyle(node);
950
+ const fontList = normalizeFontList(style.fontFamily);
951
+ fontList.forEach((f) => families.add(f));
952
+ }
953
+ for (const child of node.childNodes) {
954
+ scan(child);
955
+ }
956
+ }
957
+
958
+ // Handle array of roots or single root
959
+ const elements = Array.isArray(root) ? root : [root];
960
+ elements.forEach((el) => {
961
+ const node = typeof el === 'string' ? document.querySelector(el) : el;
962
+ if (node) scan(node);
963
+ });
964
+
965
+ return families;
966
+ }
967
+
968
+ /**
969
+ * Scans document.styleSheets to find @font-face URLs for the requested families.
970
+ * Returns an array of { name, url } objects.
971
+ */
972
+ export async function getAutoDetectedFonts(usedFamilies) {
973
+ const foundFonts = [];
974
+ const processedUrls = new Set();
975
+
976
+ // Helper to extract clean URL from CSS src string
977
+ const extractUrl = (srcStr) => {
978
+ // Look for url("...") or url('...') or url(...)
979
+ // Prioritize woff, ttf, otf. Avoid woff2 if possible as handling is harder,
980
+ // but if it's the only one, take it (convert logic handles it best effort).
981
+ const matches = srcStr.match(/url\((['"]?)(.*?)\1\)/g);
982
+ if (!matches) return null;
983
+
984
+ // Filter for preferred formats
985
+ let chosenUrl = null;
986
+ for (const match of matches) {
987
+ const urlRaw = match.replace(/url\((['"]?)(.*?)\1\)/, '$2');
988
+ // Skip data URIs for now (unless you want to support base64 embedding)
989
+ if (urlRaw.startsWith('data:')) continue;
990
+
991
+ if (urlRaw.includes('.ttf') || urlRaw.includes('.otf') || urlRaw.includes('.woff')) {
992
+ chosenUrl = urlRaw;
993
+ break; // Found a good one
994
+ }
995
+ // Fallback
996
+ if (!chosenUrl) chosenUrl = urlRaw;
997
+ }
998
+ return chosenUrl;
999
+ };
1000
+
1001
+ for (const sheet of Array.from(document.styleSheets)) {
1002
+ try {
1003
+ // Accessing cssRules on cross-origin sheets (like Google Fonts) might fail
1004
+ // if CORS headers aren't set. We wrap in try/catch.
1005
+ const rules = sheet.cssRules || sheet.rules;
1006
+ if (!rules) continue;
1007
+
1008
+ for (const rule of Array.from(rules)) {
1009
+ if (rule.constructor.name === 'CSSFontFaceRule' || rule.type === 5) {
1010
+ const familyName = rule.style.getPropertyValue('font-family').replace(/['"]/g, '').trim();
1011
+
1012
+ if (usedFamilies.has(familyName)) {
1013
+ const src = rule.style.getPropertyValue('src');
1014
+ const url = extractUrl(src);
1015
+
1016
+ if (url && !processedUrls.has(url)) {
1017
+ processedUrls.add(url);
1018
+ foundFonts.push({ name: familyName, url: url });
1019
+ }
1020
+ }
1021
+ }
1022
+ }
1023
+ } catch (e) {
1024
+ // SecurityError is common for external stylesheets (CORS).
1025
+ // We cannot scan those automatically via CSSOM.
1026
+ console.warn('error:', e);
1027
+ console.warn('Cannot scan stylesheet for fonts (CORS restriction):', sheet.href);
1028
+ }
1029
+ }
1030
+
1031
+ return foundFonts;
1032
+ }