dom-to-pptx 1.0.0 → 1.0.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/code_context.txt +346 -0
- package/package.json +1 -1
- package/src/index.js +194 -122
- package/src/utils.js +169 -27
package/code_context.txt
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
// File: src/image-processor.js
|
|
2
|
+
// src/image-processor.js
|
|
3
|
+
|
|
4
|
+
export async function getProcessedImage(src, targetW, targetH, radius) {
|
|
5
|
+
return new Promise((resolve) => {
|
|
6
|
+
const img = new Image();
|
|
7
|
+
img.crossOrigin = "Anonymous"; // Critical for canvas manipulation
|
|
8
|
+
|
|
9
|
+
img.onload = () => {
|
|
10
|
+
const canvas = document.createElement('canvas');
|
|
11
|
+
// Double resolution for better quality
|
|
12
|
+
const scale = 2;
|
|
13
|
+
canvas.width = targetW * scale;
|
|
14
|
+
canvas.height = targetH * scale;
|
|
15
|
+
const ctx = canvas.getContext('2d');
|
|
16
|
+
ctx.scale(scale, scale);
|
|
17
|
+
|
|
18
|
+
// 1. Draw the Mask (Rounded Rect)
|
|
19
|
+
ctx.beginPath();
|
|
20
|
+
if (ctx.roundRect) {
|
|
21
|
+
ctx.roundRect(0, 0, targetW, targetH, radius);
|
|
22
|
+
} else {
|
|
23
|
+
// Fallback for older browsers if needed
|
|
24
|
+
ctx.rect(0, 0, targetW, targetH);
|
|
25
|
+
}
|
|
26
|
+
ctx.fillStyle = '#000';
|
|
27
|
+
ctx.fill();
|
|
28
|
+
|
|
29
|
+
// 2. Composite Source-In
|
|
30
|
+
ctx.globalCompositeOperation = 'source-in';
|
|
31
|
+
|
|
32
|
+
// 3. Draw Image (Object Cover Logic)
|
|
33
|
+
const wRatio = targetW / img.width;
|
|
34
|
+
const hRatio = targetH / img.height;
|
|
35
|
+
const maxRatio = Math.max(wRatio, hRatio);
|
|
36
|
+
const renderW = img.width * maxRatio;
|
|
37
|
+
const renderH = img.height * maxRatio;
|
|
38
|
+
const renderX = (targetW - renderW) / 2;
|
|
39
|
+
const renderY = (targetH - renderH) / 2;
|
|
40
|
+
|
|
41
|
+
ctx.drawImage(img, renderX, renderY, renderW, renderH);
|
|
42
|
+
|
|
43
|
+
resolve(canvas.toDataURL('image/png'));
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
img.onerror = () => resolve(null);
|
|
47
|
+
img.src = src;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
// File: src/index.js
|
|
51
|
+
// src/index.js
|
|
52
|
+
import PptxGenJS from "pptxgenjs";
|
|
53
|
+
import { parseColor, getTextStyle, isTextContainer, getVisibleShadow, generateGradientSVG } from "./utils.js";
|
|
54
|
+
import { getProcessedImage } from "./image-processor.js";
|
|
55
|
+
|
|
56
|
+
const PPI = 96;
|
|
57
|
+
const PX_TO_INCH = 1 / PPI;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Converts a DOM element to a PPTX file.
|
|
61
|
+
* @param {HTMLElement | string} elementOrSelector - The root element to convert.
|
|
62
|
+
* @param {Object} options - { fileName: string }
|
|
63
|
+
*/
|
|
64
|
+
export async function exportToPptx(elementOrSelector, options = {}) {
|
|
65
|
+
const root = typeof elementOrSelector === "string"
|
|
66
|
+
? document.querySelector(elementOrSelector)
|
|
67
|
+
: elementOrSelector;
|
|
68
|
+
|
|
69
|
+
if (!root) throw new Error("Root element not found");
|
|
70
|
+
|
|
71
|
+
const pptx = new PptxGenJS();
|
|
72
|
+
pptx.layout = "LAYOUT_16x9"; // Default
|
|
73
|
+
const slide = pptx.addSlide();
|
|
74
|
+
|
|
75
|
+
// Use the dimensions of the root element to calculate scaling
|
|
76
|
+
const rootRect = root.getBoundingClientRect();
|
|
77
|
+
|
|
78
|
+
// Standard PPTX 16:9 dimensions in inches
|
|
79
|
+
const PPTX_WIDTH_IN = 10;
|
|
80
|
+
const PPTX_HEIGHT_IN = 5.625;
|
|
81
|
+
|
|
82
|
+
const contentWidthIn = rootRect.width * PX_TO_INCH;
|
|
83
|
+
const contentHeightIn = rootRect.height * PX_TO_INCH;
|
|
84
|
+
|
|
85
|
+
// Scale content to fit within the slide
|
|
86
|
+
const scale = Math.min(
|
|
87
|
+
PPTX_WIDTH_IN / contentWidthIn,
|
|
88
|
+
PPTX_HEIGHT_IN / contentHeightIn
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const layoutConfig = {
|
|
92
|
+
rootX: rootRect.x,
|
|
93
|
+
rootY: rootRect.y,
|
|
94
|
+
scale: scale,
|
|
95
|
+
// Center the content
|
|
96
|
+
offX: (PPTX_WIDTH_IN - contentWidthIn * scale) / 2,
|
|
97
|
+
offY: (PPTX_HEIGHT_IN - contentHeightIn * scale) / 2,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
await processNode(root, pptx, slide, layoutConfig);
|
|
101
|
+
|
|
102
|
+
const fileName = options.fileName || "export.pptx";
|
|
103
|
+
pptx.writeFile({ fileName });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function processNode(node, pptx, slide, config) {
|
|
107
|
+
if (node.nodeType !== 1) return; // Element nodes only
|
|
108
|
+
|
|
109
|
+
const style = window.getComputedStyle(node);
|
|
110
|
+
if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") return;
|
|
111
|
+
|
|
112
|
+
const rect = node.getBoundingClientRect();
|
|
113
|
+
if (rect.width === 0 || rect.height === 0) return;
|
|
114
|
+
|
|
115
|
+
const x = config.offX + (rect.x - config.rootX) * PX_TO_INCH * config.scale;
|
|
116
|
+
const y = config.offY + (rect.y - config.rootY) * PX_TO_INCH * config.scale;
|
|
117
|
+
const w = rect.width * PX_TO_INCH * config.scale;
|
|
118
|
+
const h = rect.height * PX_TO_INCH * config.scale;
|
|
119
|
+
|
|
120
|
+
// --- 1. Detect Image Wrapper ---
|
|
121
|
+
let isImageWrapper = false;
|
|
122
|
+
const imgChild = Array.from(node.children).find(c => c.tagName === 'IMG');
|
|
123
|
+
if (imgChild) {
|
|
124
|
+
const imgRect = imgChild.getBoundingClientRect();
|
|
125
|
+
if (Math.abs(imgRect.width - rect.width) < 2 && Math.abs(imgRect.height - rect.height) < 2) {
|
|
126
|
+
isImageWrapper = true;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// --- 2. Backgrounds & Borders ---
|
|
131
|
+
let bgColor = parseColor(style.backgroundColor);
|
|
132
|
+
if (isImageWrapper && bgColor) bgColor = null; // Prevent halo
|
|
133
|
+
|
|
134
|
+
const hasGradient = style.backgroundImage && style.backgroundImage.includes("linear-gradient");
|
|
135
|
+
const borderColor = parseColor(style.borderColor);
|
|
136
|
+
const borderWidth = parseFloat(style.borderWidth);
|
|
137
|
+
const hasBorder = borderWidth > 0 && borderColor;
|
|
138
|
+
const shadowStr = style.boxShadow;
|
|
139
|
+
const hasShadow = shadowStr && shadowStr !== "none";
|
|
140
|
+
const borderRadius = parseFloat(style.borderRadius) || 0;
|
|
141
|
+
|
|
142
|
+
if (hasGradient) {
|
|
143
|
+
const svgData = generateGradientSVG(rect.width, rect.height, style.backgroundImage, borderRadius, hasBorder ? { color: borderColor, width: borderWidth } : null);
|
|
144
|
+
if (svgData) slide.addImage({ data: svgData, x, y, w, h });
|
|
145
|
+
} else if (bgColor || hasBorder || hasShadow) {
|
|
146
|
+
const isCircle = borderRadius >= Math.min(rect.width, rect.height) / 2 - 1;
|
|
147
|
+
|
|
148
|
+
const shapeOpts = {
|
|
149
|
+
x, y, w, h,
|
|
150
|
+
fill: bgColor ? { color: bgColor } : null,
|
|
151
|
+
line: hasBorder ? { color: borderColor, width: borderWidth * 0.75 * config.scale } : null,
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
if (hasShadow) {
|
|
155
|
+
const shadow = getVisibleShadow(shadowStr, config.scale);
|
|
156
|
+
if (shadow) shapeOpts.shadow = shadow;
|
|
157
|
+
|
|
158
|
+
// Fix for shadow on transparent background (needed for PPTX to render shadow)
|
|
159
|
+
if (!bgColor && !hasBorder) {
|
|
160
|
+
if (style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)') {
|
|
161
|
+
shapeOpts.fill = { color: parseColor(style.backgroundColor) };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (isCircle) {
|
|
167
|
+
slide.addShape(pptx.ShapeType.ellipse, shapeOpts);
|
|
168
|
+
} else if (borderRadius > 0) {
|
|
169
|
+
const radiusFactor = Math.min(1, borderRadius / (Math.min(rect.width, rect.height) / 1.75));
|
|
170
|
+
shapeOpts.rectRadius = radiusFactor;
|
|
171
|
+
slide.addShape(pptx.ShapeType.roundRect, shapeOpts);
|
|
172
|
+
} else {
|
|
173
|
+
slide.addShape(pptx.ShapeType.rect, shapeOpts);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// --- 3. Process Image ---
|
|
178
|
+
if (node.tagName === "IMG") {
|
|
179
|
+
let effectiveRadius = borderRadius;
|
|
180
|
+
// Check parent clipping if current img has no radius
|
|
181
|
+
if (effectiveRadius === 0) {
|
|
182
|
+
const parentStyle = window.getComputedStyle(node.parentElement);
|
|
183
|
+
if (parentStyle.overflow !== 'visible') {
|
|
184
|
+
effectiveRadius = parseFloat(parentStyle.borderRadius) || 0;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const processedImage = await getProcessedImage(node.src, rect.width, rect.height, effectiveRadius);
|
|
189
|
+
if (processedImage) {
|
|
190
|
+
slide.addImage({ data: processedImage, x, y, w, h });
|
|
191
|
+
}
|
|
192
|
+
return; // Don't process children of IMG
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// --- 4. Process Text ---
|
|
196
|
+
if (isTextContainer(node)) {
|
|
197
|
+
const textParts = [];
|
|
198
|
+
node.childNodes.forEach((child, index) => {
|
|
199
|
+
let textVal = child.nodeType === 3 ? child.nodeValue : child.textContent;
|
|
200
|
+
let nodeStyle = child.nodeType === 1 ? window.getComputedStyle(child) : style;
|
|
201
|
+
|
|
202
|
+
textVal = textVal.replace(/[\n\r\t]+/g, " ").replace(/\s{2,}/g, " ");
|
|
203
|
+
if (index === 0) textVal = textVal.trimStart();
|
|
204
|
+
if (index === node.childNodes.length - 1) textVal = textVal.trimEnd();
|
|
205
|
+
|
|
206
|
+
if (nodeStyle.textTransform === "uppercase") textVal = textVal.toUpperCase();
|
|
207
|
+
if (nodeStyle.textTransform === "lowercase") textVal = textVal.toLowerCase();
|
|
208
|
+
|
|
209
|
+
if (textVal.length > 0) {
|
|
210
|
+
textParts.push({ text: textVal, options: getTextStyle(nodeStyle, config.scale) });
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
if (textParts.length > 0) {
|
|
215
|
+
let align = style.textAlign || "left";
|
|
216
|
+
if (align === "start") align = "left";
|
|
217
|
+
if (align === "end") align = "right";
|
|
218
|
+
|
|
219
|
+
let valign = "top";
|
|
220
|
+
if (style.alignItems === "center") valign = "middle"; // Flex approximation
|
|
221
|
+
|
|
222
|
+
slide.addText(textParts, { x, y, w, h, align, valign, margin: 0, wrap: true, autoFit: false });
|
|
223
|
+
}
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Recursive call
|
|
228
|
+
for (const child of node.children) {
|
|
229
|
+
await processNode(child, pptx, slide, config);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// File: src/utils.js
|
|
233
|
+
// src/utils.js
|
|
234
|
+
|
|
235
|
+
export function parseColor(str) {
|
|
236
|
+
if (!str || str === "transparent" || str.startsWith("rgba(0, 0, 0, 0)")) return null;
|
|
237
|
+
const rgb = str.match(/\d+/g);
|
|
238
|
+
if (!rgb || rgb.length < 3) return null;
|
|
239
|
+
// Convert RGB to Hex
|
|
240
|
+
return ((1 << 24) + (parseInt(rgb[0]) << 16) + (parseInt(rgb[1]) << 8) + parseInt(rgb[2]))
|
|
241
|
+
.toString(16).slice(1).toUpperCase();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function getTextStyle(style, scale) {
|
|
245
|
+
return {
|
|
246
|
+
color: parseColor(style.color) || "000000",
|
|
247
|
+
fontFace: style.fontFamily.split(",")[0].replace(/['"]/g, ""),
|
|
248
|
+
fontSize: parseFloat(style.fontSize) * 0.75 * scale,
|
|
249
|
+
bold: parseInt(style.fontWeight) >= 600,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function isTextContainer(node) {
|
|
254
|
+
const hasText = node.textContent.trim().length > 0;
|
|
255
|
+
if (!hasText) return false;
|
|
256
|
+
const children = Array.from(node.children);
|
|
257
|
+
if (children.length === 0) return true;
|
|
258
|
+
// Check if children are inline elements
|
|
259
|
+
const isInline = (el) =>
|
|
260
|
+
window.getComputedStyle(el).display.includes("inline") ||
|
|
261
|
+
["SPAN", "B", "STRONG", "EM"].includes(el.tagName);
|
|
262
|
+
return children.every(isInline);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function getVisibleShadow(shadowStr, scale) {
|
|
266
|
+
if (!shadowStr || shadowStr === "none") return null;
|
|
267
|
+
const shadows = shadowStr.split(/,(?![^(]*\))/);
|
|
268
|
+
|
|
269
|
+
for (let s of shadows) {
|
|
270
|
+
s = s.trim();
|
|
271
|
+
if (s.startsWith("rgba(0, 0, 0, 0)")) continue;
|
|
272
|
+
|
|
273
|
+
const match = s.match(/(rgba?\([^\)]+\)|#[0-9a-fA-F]+)\s+(-?[\d\.]+)px\s+(-?[\d\.]+)px\s+([\d\.]+)px/);
|
|
274
|
+
if (match) {
|
|
275
|
+
const colorStr = match[1];
|
|
276
|
+
const x = parseFloat(match[2]);
|
|
277
|
+
const y = parseFloat(match[3]);
|
|
278
|
+
const blur = parseFloat(match[4]);
|
|
279
|
+
|
|
280
|
+
const distance = Math.sqrt(x*x + y*y);
|
|
281
|
+
let angle = Math.atan2(y, x) * (180 / Math.PI);
|
|
282
|
+
if (angle < 0) angle += 360;
|
|
283
|
+
|
|
284
|
+
let opacity = 0.4;
|
|
285
|
+
if (colorStr.includes('rgba')) {
|
|
286
|
+
const alphaMatch = colorStr.match(/, ([0-9.]+)\)/);
|
|
287
|
+
if (alphaMatch) opacity = parseFloat(alphaMatch[1]);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
type: "outer",
|
|
292
|
+
angle: angle,
|
|
293
|
+
blur: blur * 0.75 * scale,
|
|
294
|
+
offset: distance * 0.75 * scale,
|
|
295
|
+
color: parseColor(colorStr) || "000000",
|
|
296
|
+
opacity: opacity,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function generateGradientSVG(w, h, bgString, radius, border) {
|
|
304
|
+
try {
|
|
305
|
+
const match = bgString.match(/linear-gradient\((.*)\)/);
|
|
306
|
+
if (!match) return null;
|
|
307
|
+
const content = match[1];
|
|
308
|
+
// Basic gradient parsing logic (simplified from your script)
|
|
309
|
+
const parts = content.split(/,(?![^(]*\))/).map((p) => p.trim());
|
|
310
|
+
|
|
311
|
+
let x1 = "0%", y1 = "0%", x2 = "0%", y2 = "100%";
|
|
312
|
+
let stopsStartIdx = 0;
|
|
313
|
+
|
|
314
|
+
if (parts[0].includes("to right")) { x1 = "0%"; x2 = "100%"; y2 = "0%"; stopsStartIdx = 1; }
|
|
315
|
+
else if (parts[0].includes("to left")) { x1 = "100%"; x2 = "0%"; y2 = "0%"; stopsStartIdx = 1; }
|
|
316
|
+
// Add other directions as needed...
|
|
317
|
+
|
|
318
|
+
let stopsXML = "";
|
|
319
|
+
const stopParts = parts.slice(stopsStartIdx);
|
|
320
|
+
stopParts.forEach((part, idx) => {
|
|
321
|
+
let color = part;
|
|
322
|
+
let offset = Math.round((idx / (stopParts.length - 1)) * 100) + "%";
|
|
323
|
+
// Simple regex to separate color from percentage
|
|
324
|
+
const posMatch = part.match(/(.*?)\s+(\d+(\.\d+)?%?)$/);
|
|
325
|
+
if (posMatch) { color = posMatch[1]; offset = posMatch[2]; }
|
|
326
|
+
|
|
327
|
+
let opacity = 1;
|
|
328
|
+
if (color.includes("rgba")) {
|
|
329
|
+
// extract alpha
|
|
330
|
+
const rgba = color.match(/[\d\.]+/g);
|
|
331
|
+
if(rgba && rgba.length > 3) { opacity = rgba[3]; color = `rgb(${rgba[0]},${rgba[1]},${rgba[2]})`; }
|
|
332
|
+
}
|
|
333
|
+
stopsXML += `<stop offset="${offset}" stop-color="${color}" stop-opacity="${opacity}"/>`;
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
let strokeAttr = "";
|
|
337
|
+
if (border) { strokeAttr = `stroke="#${border.color}" stroke-width="${border.width}"`; }
|
|
338
|
+
|
|
339
|
+
const svg = `
|
|
340
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
|
|
341
|
+
<defs><linearGradient id="grad" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}">${stopsXML}</linearGradient></defs>
|
|
342
|
+
<rect x="0" y="0" width="${w}" height="${h}" rx="${radius}" ry="${radius}" fill="url(#grad)" ${strokeAttr} />
|
|
343
|
+
</svg>`;
|
|
344
|
+
return "data:image/svg+xml;base64," + btoa(svg);
|
|
345
|
+
} catch (e) { return null; }
|
|
346
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dom-to-pptx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "A client-side library that converts any HTML element into a fully editable PowerPoint slide. **dom-to-pptx** transforms DOM structures into pixel-accurate `.pptx` content, preserving gradients, shadows, rounded images, and responsive layouts. It translates CSS Flexbox/Grid, linear-gradients, box-shadows, and typography into native PowerPoint shapes, enabling precise, design-faithful slide generation directly from the browser.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"type": "module",
|
package/src/index.js
CHANGED
|
@@ -1,16 +1,12 @@
|
|
|
1
1
|
// src/index.js
|
|
2
2
|
import PptxGenJS from "pptxgenjs";
|
|
3
|
-
|
|
3
|
+
// FIX: Added generateGradientTextSVG
|
|
4
|
+
import { parseColor, getTextStyle, isTextContainer, getVisibleShadow, generateGradientSVG, getRotation, svgToPng, getPadding, getSoftEdges, generateBlurredSVG } from "./utils.js";
|
|
4
5
|
import { getProcessedImage } from "./image-processor.js";
|
|
5
6
|
|
|
6
7
|
const PPI = 96;
|
|
7
8
|
const PX_TO_INCH = 1 / PPI;
|
|
8
9
|
|
|
9
|
-
/**
|
|
10
|
-
* Converts a DOM element to a PPTX file.
|
|
11
|
-
* @param {HTMLElement | string} elementOrSelector - The root element to convert.
|
|
12
|
-
* @param {Object} options - { fileName: string }
|
|
13
|
-
*/
|
|
14
10
|
export async function exportToPptx(elementOrSelector, options = {}) {
|
|
15
11
|
const root = typeof elementOrSelector === "string"
|
|
16
12
|
? document.querySelector(elementOrSelector)
|
|
@@ -19,163 +15,239 @@ export async function exportToPptx(elementOrSelector, options = {}) {
|
|
|
19
15
|
if (!root) throw new Error("Root element not found");
|
|
20
16
|
|
|
21
17
|
const pptx = new PptxGenJS();
|
|
22
|
-
pptx.layout = "LAYOUT_16x9";
|
|
18
|
+
pptx.layout = "LAYOUT_16x9";
|
|
23
19
|
const slide = pptx.addSlide();
|
|
24
20
|
|
|
25
|
-
// Use the dimensions of the root element to calculate scaling
|
|
26
21
|
const rootRect = root.getBoundingClientRect();
|
|
27
|
-
|
|
28
|
-
// Standard PPTX 16:9 dimensions in inches
|
|
29
22
|
const PPTX_WIDTH_IN = 10;
|
|
30
23
|
const PPTX_HEIGHT_IN = 5.625;
|
|
31
|
-
|
|
32
24
|
const contentWidthIn = rootRect.width * PX_TO_INCH;
|
|
33
25
|
const contentHeightIn = rootRect.height * PX_TO_INCH;
|
|
34
|
-
|
|
35
|
-
// Scale content to fit within the slide
|
|
36
|
-
const scale = Math.min(
|
|
37
|
-
PPTX_WIDTH_IN / contentWidthIn,
|
|
38
|
-
PPTX_HEIGHT_IN / contentHeightIn
|
|
39
|
-
);
|
|
26
|
+
const scale = Math.min(PPTX_WIDTH_IN / contentWidthIn, PPTX_HEIGHT_IN / contentHeightIn);
|
|
40
27
|
|
|
41
28
|
const layoutConfig = {
|
|
42
|
-
rootX: rootRect.x,
|
|
43
|
-
rootY: rootRect.y,
|
|
44
|
-
scale: scale,
|
|
45
|
-
// Center the content
|
|
29
|
+
rootX: rootRect.x, rootY: rootRect.y, scale: scale,
|
|
46
30
|
offX: (PPTX_WIDTH_IN - contentWidthIn * scale) / 2,
|
|
47
31
|
offY: (PPTX_HEIGHT_IN - contentHeightIn * scale) / 2,
|
|
48
32
|
};
|
|
49
33
|
|
|
50
|
-
|
|
34
|
+
const renderQueue = [];
|
|
35
|
+
let domOrderCounter = 0;
|
|
36
|
+
|
|
37
|
+
async function collect(node) {
|
|
38
|
+
const order = domOrderCounter++;
|
|
39
|
+
const result = await createRenderItem(node, layoutConfig, order, pptx);
|
|
40
|
+
if (result) {
|
|
41
|
+
if (result.items) renderQueue.push(...result.items);
|
|
42
|
+
if (result.stopRecursion) return;
|
|
43
|
+
}
|
|
44
|
+
for (const child of node.children) await collect(child);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
await collect(root);
|
|
48
|
+
|
|
49
|
+
renderQueue.sort((a, b) => {
|
|
50
|
+
if (a.zIndex !== b.zIndex) return a.zIndex - b.zIndex;
|
|
51
|
+
return a.domOrder - b.domOrder;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
for (const item of renderQueue) {
|
|
55
|
+
if (item.type === 'shape') slide.addShape(item.shapeType, item.options);
|
|
56
|
+
if (item.type === 'image') slide.addImage(item.options);
|
|
57
|
+
if (item.type === 'text') slide.addText(item.textParts, item.options);
|
|
58
|
+
}
|
|
51
59
|
|
|
52
60
|
const fileName = options.fileName || "export.pptx";
|
|
53
61
|
pptx.writeFile({ fileName });
|
|
54
62
|
}
|
|
55
63
|
|
|
56
|
-
async function
|
|
57
|
-
if (node.nodeType !== 1) return;
|
|
58
|
-
|
|
64
|
+
async function createRenderItem(node, config, domOrder, pptx) {
|
|
65
|
+
if (node.nodeType !== 1) return null;
|
|
59
66
|
const style = window.getComputedStyle(node);
|
|
60
|
-
if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") return;
|
|
67
|
+
if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") return null;
|
|
61
68
|
|
|
62
69
|
const rect = node.getBoundingClientRect();
|
|
63
|
-
if (rect.width === 0 || rect.height === 0) return;
|
|
70
|
+
if (rect.width === 0 || rect.height === 0) return null;
|
|
64
71
|
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
|
|
72
|
+
const zIndex = style.zIndex !== 'auto' ? parseInt(style.zIndex) : 0;
|
|
73
|
+
const rotation = getRotation(style.transform);
|
|
74
|
+
const elementOpacity = parseFloat(style.opacity);
|
|
75
|
+
|
|
76
|
+
const widthPx = node.offsetWidth || rect.width;
|
|
77
|
+
const heightPx = node.offsetHeight || rect.height;
|
|
69
78
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
79
|
+
const unrotatedW = widthPx * PX_TO_INCH * config.scale;
|
|
80
|
+
const unrotatedH = heightPx * PX_TO_INCH * config.scale;
|
|
81
|
+
const centerX = rect.left + rect.width / 2;
|
|
82
|
+
const centerY = rect.top + rect.height / 2;
|
|
83
|
+
|
|
84
|
+
let x = config.offX + ((centerX - config.rootX) * PX_TO_INCH * config.scale) - (unrotatedW / 2);
|
|
85
|
+
let y = config.offY + ((centerY - config.rootY) * PX_TO_INCH * config.scale) - (unrotatedH / 2);
|
|
86
|
+
let w = unrotatedW;
|
|
87
|
+
let h = unrotatedH;
|
|
88
|
+
|
|
89
|
+
const items = [];
|
|
90
|
+
|
|
91
|
+
// --- SVG & IMG HANDLERS ---
|
|
92
|
+
if (node.nodeName.toUpperCase() === 'SVG') {
|
|
93
|
+
const pngData = await svgToPng(node);
|
|
94
|
+
if (pngData) items.push({ type: 'image', zIndex, domOrder, options: { data: pngData, x, y, w, h, rotate: rotation } });
|
|
95
|
+
return { items, stopRecursion: true };
|
|
96
|
+
}
|
|
97
|
+
if (node.tagName === "IMG") {
|
|
98
|
+
let borderRadius = parseFloat(style.borderRadius) || 0;
|
|
99
|
+
if (borderRadius === 0) {
|
|
100
|
+
const parentStyle = window.getComputedStyle(node.parentElement);
|
|
101
|
+
if (parentStyle.overflow !== 'visible') borderRadius = parseFloat(parentStyle.borderRadius) || 0;
|
|
77
102
|
}
|
|
103
|
+
const processed = await getProcessedImage(node.src, widthPx, heightPx, borderRadius);
|
|
104
|
+
if (processed) items.push({ type: 'image', zIndex, domOrder, options: { data: processed, x, y, w, h, rotate: rotation } });
|
|
105
|
+
return { items, stopRecursion: true };
|
|
78
106
|
}
|
|
79
107
|
|
|
80
|
-
// ---
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const hasGradient = style.backgroundImage && style.backgroundImage.includes("linear-gradient");
|
|
85
|
-
|
|
108
|
+
// --- PREPARE STYLES ---
|
|
109
|
+
const bgColorObj = parseColor(style.backgroundColor);
|
|
110
|
+
const bgClip = style.webkitBackgroundClip || style.backgroundClip;
|
|
111
|
+
const isBgClipText = bgClip === 'text';
|
|
112
|
+
const hasGradient = !isBgClipText && style.backgroundImage && style.backgroundImage.includes("linear-gradient");
|
|
113
|
+
|
|
114
|
+
const borderColorObj = parseColor(style.borderColor);
|
|
86
115
|
const borderWidth = parseFloat(style.borderWidth);
|
|
87
|
-
const hasBorder = borderWidth > 0 &&
|
|
116
|
+
const hasBorder = borderWidth > 0 && borderColorObj.hex;
|
|
88
117
|
const shadowStr = style.boxShadow;
|
|
89
118
|
const hasShadow = shadowStr && shadowStr !== "none";
|
|
90
119
|
const borderRadius = parseFloat(style.borderRadius) || 0;
|
|
120
|
+
const softEdge = getSoftEdges(style.filter, config.scale);
|
|
91
121
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
x, y, w, h,
|
|
100
|
-
fill: bgColor ? { color: bgColor } : null,
|
|
101
|
-
line: hasBorder ? { color: borderColor, width: borderWidth * 0.75 * config.scale } : null,
|
|
102
|
-
};
|
|
103
|
-
|
|
104
|
-
if (hasShadow) {
|
|
105
|
-
const shadow = getVisibleShadow(shadowStr, config.scale);
|
|
106
|
-
if (shadow) shapeOpts.shadow = shadow;
|
|
107
|
-
|
|
108
|
-
// Fix for shadow on transparent background (needed for PPTX to render shadow)
|
|
109
|
-
if (!bgColor && !hasBorder) {
|
|
110
|
-
if (style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)') {
|
|
111
|
-
shapeOpts.fill = { color: parseColor(style.backgroundColor) };
|
|
112
|
-
}
|
|
122
|
+
let isImageWrapper = false;
|
|
123
|
+
const imgChild = Array.from(node.children).find(c => c.tagName === 'IMG');
|
|
124
|
+
if (imgChild) {
|
|
125
|
+
const childW = imgChild.offsetWidth || imgChild.getBoundingClientRect().width;
|
|
126
|
+
const childH = imgChild.offsetHeight || imgChild.getBoundingClientRect().height;
|
|
127
|
+
if (childW >= widthPx - 2 && childH >= heightPx - 2) {
|
|
128
|
+
isImageWrapper = true;
|
|
113
129
|
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
if (isCircle) {
|
|
117
|
-
slide.addShape(pptx.ShapeType.ellipse, shapeOpts);
|
|
118
|
-
} else if (borderRadius > 0) {
|
|
119
|
-
const radiusFactor = Math.min(1, borderRadius / (Math.min(rect.width, rect.height) / 1.75));
|
|
120
|
-
shapeOpts.rectRadius = radiusFactor;
|
|
121
|
-
slide.addShape(pptx.ShapeType.roundRect, shapeOpts);
|
|
122
|
-
} else {
|
|
123
|
-
slide.addShape(pptx.ShapeType.rect, shapeOpts);
|
|
124
|
-
}
|
|
125
130
|
}
|
|
126
131
|
|
|
127
|
-
// ---
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
132
|
+
// --- TEXT EXTRACTION ---
|
|
133
|
+
let textPayload = null;
|
|
134
|
+
const isText = isTextContainer(node);
|
|
135
|
+
|
|
136
|
+
if (isText) {
|
|
137
|
+
const textParts = [];
|
|
138
|
+
const isList = style.display === 'list-item';
|
|
139
|
+
|
|
140
|
+
if (isList) {
|
|
141
|
+
const fontSizePt = parseFloat(style.fontSize) * 0.75 * config.scale;
|
|
142
|
+
const bulletShift = (parseFloat(style.fontSize) || 16) * PX_TO_INCH * config.scale * 1.5;
|
|
143
|
+
x -= bulletShift;
|
|
144
|
+
w += bulletShift;
|
|
145
|
+
textParts.push({ text: "• ", options: { color: parseColor(style.color).hex || '000000', fontSize: fontSizePt } });
|
|
146
|
+
}
|
|
137
147
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
148
|
+
node.childNodes.forEach((child, index) => {
|
|
149
|
+
let textVal = child.nodeType === 3 ? child.nodeValue : child.textContent;
|
|
150
|
+
let nodeStyle = child.nodeType === 1 ? window.getComputedStyle(child) : style;
|
|
151
|
+
textVal = textVal.replace(/[\n\r\t]+/g, " ").replace(/\s{2,}/g, " ");
|
|
152
|
+
if (index === 0 && !isList) textVal = textVal.trimStart();
|
|
153
|
+
else if (index === 0) textVal = textVal.trimStart();
|
|
154
|
+
if (index === node.childNodes.length - 1) textVal = textVal.trimEnd();
|
|
155
|
+
if (nodeStyle.textTransform === "uppercase") textVal = textVal.toUpperCase();
|
|
156
|
+
if (nodeStyle.textTransform === "lowercase") textVal = textVal.toLowerCase();
|
|
157
|
+
|
|
158
|
+
if (textVal.length > 0) {
|
|
159
|
+
textParts.push({ text: textVal, options: getTextStyle(nodeStyle, config.scale) });
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
if (textParts.length > 0) {
|
|
164
|
+
let align = style.textAlign || "left";
|
|
165
|
+
if (align === "start") align = "left"; if (align === "end") align = "right";
|
|
166
|
+
|
|
167
|
+
let valign = "top";
|
|
168
|
+
// Standard Flex Alignment
|
|
169
|
+
if (style.alignItems === "center") valign = "middle";
|
|
170
|
+
if (style.justifyContent === "center" && style.display.includes("flex")) align = "center";
|
|
171
|
+
|
|
172
|
+
// FIX: Improved Alignment Detection for "Button" Badges
|
|
173
|
+
// If Top/Bottom Padding is equal, and it's a "Shape" (has bg), default to Middle
|
|
174
|
+
const pt = parseFloat(style.paddingTop) || 0;
|
|
175
|
+
const pb = parseFloat(style.paddingBottom) || 0;
|
|
176
|
+
if (Math.abs(pt - pb) < 2 && bgColorObj.hex) {
|
|
177
|
+
valign = "middle";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const padding = getPadding(style, config.scale);
|
|
181
|
+
textPayload = { text: textParts, align, valign, inset: padding };
|
|
182
|
+
}
|
|
143
183
|
}
|
|
144
184
|
|
|
145
|
-
// ---
|
|
146
|
-
if (
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
185
|
+
// --- RENDER LOGIC ---
|
|
186
|
+
if (hasGradient || (softEdge && bgColorObj.hex && !isImageWrapper)) {
|
|
187
|
+
let bgData = null;
|
|
188
|
+
let padIn = 0;
|
|
189
|
+
if (softEdge) {
|
|
190
|
+
const svgInfo = generateBlurredSVG(widthPx, heightPx, bgColorObj.hex, borderRadius, softEdge);
|
|
191
|
+
bgData = svgInfo.data;
|
|
192
|
+
padIn = svgInfo.padding * PX_TO_INCH * config.scale;
|
|
193
|
+
} else {
|
|
194
|
+
bgData = generateGradientSVG(widthPx, heightPx, style.backgroundImage, borderRadius, hasBorder ? { color: borderColorObj.hex, width: borderWidth } : null);
|
|
195
|
+
}
|
|
155
196
|
|
|
156
|
-
if (
|
|
157
|
-
|
|
197
|
+
if (bgData) {
|
|
198
|
+
items.push({
|
|
199
|
+
type: 'image', zIndex, domOrder,
|
|
200
|
+
options: { data: bgData, x: x - padIn, y: y - padIn, w: w + (padIn * 2), h: h + (padIn * 2), rotate: rotation }
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (textPayload) {
|
|
205
|
+
items.push({
|
|
206
|
+
type: 'text', zIndex: zIndex + 1, domOrder,
|
|
207
|
+
textParts: textPayload.text,
|
|
208
|
+
options: {
|
|
209
|
+
x, y, w, h, align: textPayload.align, valign: textPayload.valign, inset: textPayload.inset, rotate: rotation, margin: 0, wrap: true, autoFit: false
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
else if ((bgColorObj.hex && !isImageWrapper) || hasBorder || hasShadow || textPayload) {
|
|
215
|
+
const finalAlpha = elementOpacity * bgColorObj.opacity;
|
|
216
|
+
const transparency = (1 - finalAlpha) * 100;
|
|
217
|
+
|
|
218
|
+
const shapeOpts = {
|
|
219
|
+
x, y, w, h, rotate: rotation,
|
|
220
|
+
fill: (bgColorObj.hex && !isImageWrapper) ? { color: bgColorObj.hex, transparency: transparency } : { type: 'none' },
|
|
221
|
+
line: hasBorder ? { color: borderColorObj.hex, width: borderWidth * 0.75 * config.scale } : null,
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
if (hasShadow) {
|
|
225
|
+
const shadow = getVisibleShadow(shadowStr, config.scale);
|
|
226
|
+
if (shadow) shapeOpts.shadow = shadow;
|
|
227
|
+
if (shapeOpts.fill.type === 'none' && !hasBorder && style.backgroundColor) {
|
|
228
|
+
shapeOpts.fill = { color: 'FFFFFF', transparency: 99 };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
158
231
|
|
|
159
|
-
|
|
160
|
-
|
|
232
|
+
const isCircle = borderRadius >= Math.min(widthPx, heightPx) / 2 - 1 && Math.abs(widthPx - heightPx) < 2;
|
|
233
|
+
let shapeType = pptx.ShapeType.rect;
|
|
234
|
+
if (isCircle) shapeType = pptx.ShapeType.ellipse;
|
|
235
|
+
else if (borderRadius > 0) {
|
|
236
|
+
shapeType = pptx.ShapeType.roundRect;
|
|
237
|
+
shapeOpts.rectRadius = Math.min(1, borderRadius / (Math.min(widthPx, heightPx) / 2));
|
|
161
238
|
}
|
|
162
|
-
});
|
|
163
239
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
return;
|
|
240
|
+
if (textPayload) {
|
|
241
|
+
const textOptions = {
|
|
242
|
+
shape: shapeType, ...shapeOpts,
|
|
243
|
+
align: textPayload.align, valign: textPayload.valign, inset: textPayload.inset,
|
|
244
|
+
margin: 0, wrap: true, autoFit: false
|
|
245
|
+
};
|
|
246
|
+
items.push({ type: 'text', zIndex, domOrder, textParts: textPayload.text, options: textOptions });
|
|
247
|
+
} else {
|
|
248
|
+
items.push({ type: 'shape', zIndex, domOrder, shapeType, options: shapeOpts });
|
|
249
|
+
}
|
|
175
250
|
}
|
|
176
251
|
|
|
177
|
-
|
|
178
|
-
for (const child of node.children) {
|
|
179
|
-
await processNode(child, pptx, slide, config);
|
|
180
|
-
}
|
|
252
|
+
return { items, stopRecursion: !!textPayload };
|
|
181
253
|
}
|
package/src/utils.js
CHANGED
|
@@ -1,20 +1,76 @@
|
|
|
1
1
|
// src/utils.js
|
|
2
2
|
|
|
3
3
|
export function parseColor(str) {
|
|
4
|
-
if (!str || str === "transparent" || str.startsWith("rgba(0, 0, 0, 0)"))
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
if (!str || str === "transparent" || str.startsWith("rgba(0, 0, 0, 0)")) {
|
|
5
|
+
return { hex: null, opacity: 0 };
|
|
6
|
+
}
|
|
7
|
+
if (str.startsWith('#')) {
|
|
8
|
+
let hex = str.slice(1);
|
|
9
|
+
if (hex.length === 3) hex = hex.split('').map(c => c+c).join('');
|
|
10
|
+
return { hex: hex.toUpperCase(), opacity: 1 };
|
|
11
|
+
}
|
|
12
|
+
const match = str.match(/[\d\.]+/g);
|
|
13
|
+
if (match && match.length >= 3) {
|
|
14
|
+
const r = parseInt(match[0]);
|
|
15
|
+
const g = parseInt(match[1]);
|
|
16
|
+
const b = parseInt(match[2]);
|
|
17
|
+
const a = match.length > 3 ? parseFloat(match[3]) : 1;
|
|
18
|
+
const hex = ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase();
|
|
19
|
+
return { hex, opacity: a };
|
|
20
|
+
}
|
|
21
|
+
return { hex: null, opacity: 0 };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// FIX: New helper to save gradient text
|
|
25
|
+
export function getGradientFallbackColor(bgImage) {
|
|
26
|
+
if (!bgImage) return null;
|
|
27
|
+
// Extract first hex or rgb color
|
|
28
|
+
// linear-gradient(to right, #4f46e5, ...) -> #4f46e5
|
|
29
|
+
const hexMatch = bgImage.match(/#(?:[0-9a-fA-F]{3}){1,2}/);
|
|
30
|
+
if (hexMatch) return hexMatch[0];
|
|
31
|
+
|
|
32
|
+
const rgbMatch = bgImage.match(/rgba?\(.*?\)/);
|
|
33
|
+
if (rgbMatch) return rgbMatch[0];
|
|
34
|
+
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getPadding(style, scale) {
|
|
39
|
+
const pxToInch = 1 / 96;
|
|
40
|
+
return [
|
|
41
|
+
(parseFloat(style.paddingTop) || 0) * pxToInch * scale,
|
|
42
|
+
(parseFloat(style.paddingRight) || 0) * pxToInch * scale,
|
|
43
|
+
(parseFloat(style.paddingBottom) || 0) * pxToInch * scale,
|
|
44
|
+
(parseFloat(style.paddingLeft) || 0) * pxToInch * scale
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function getSoftEdges(filterStr, scale) {
|
|
49
|
+
if (!filterStr || filterStr === "none") return null;
|
|
50
|
+
const match = filterStr.match(/blur\(([\d\.]+)px\)/);
|
|
51
|
+
if (match) return parseFloat(match[1]) * 0.75 * scale;
|
|
52
|
+
return null;
|
|
10
53
|
}
|
|
11
54
|
|
|
12
55
|
export function getTextStyle(style, scale) {
|
|
56
|
+
let colorObj = parseColor(style.color);
|
|
57
|
+
|
|
58
|
+
// FIX: Handle text-transparent used in gradients
|
|
59
|
+
// If text is transparent but has background-clip: text, we can't render gradient text easily.
|
|
60
|
+
// We fallback to the gradient's first color so it's at least visible.
|
|
61
|
+
const bgClip = style.webkitBackgroundClip || style.backgroundClip;
|
|
62
|
+
if (colorObj.opacity === 0 && bgClip === 'text') {
|
|
63
|
+
const fallback = getGradientFallbackColor(style.backgroundImage);
|
|
64
|
+
if (fallback) colorObj = parseColor(fallback);
|
|
65
|
+
}
|
|
66
|
+
|
|
13
67
|
return {
|
|
14
|
-
color:
|
|
68
|
+
color: colorObj.hex || "000000",
|
|
15
69
|
fontFace: style.fontFamily.split(",")[0].replace(/['"]/g, ""),
|
|
16
70
|
fontSize: parseFloat(style.fontSize) * 0.75 * scale,
|
|
17
71
|
bold: parseInt(style.fontWeight) >= 600,
|
|
72
|
+
italic: style.fontStyle === "italic",
|
|
73
|
+
underline: style.textDecoration.includes("underline"),
|
|
18
74
|
};
|
|
19
75
|
}
|
|
20
76
|
|
|
@@ -23,45 +79,99 @@ export function isTextContainer(node) {
|
|
|
23
79
|
if (!hasText) return false;
|
|
24
80
|
const children = Array.from(node.children);
|
|
25
81
|
if (children.length === 0) return true;
|
|
26
|
-
// Check if children are inline elements
|
|
27
82
|
const isInline = (el) =>
|
|
28
83
|
window.getComputedStyle(el).display.includes("inline") ||
|
|
29
|
-
["SPAN", "B", "STRONG", "EM"].includes(el.tagName);
|
|
84
|
+
["SPAN", "B", "STRONG", "EM", "I", "A", "SMALL"].includes(el.tagName);
|
|
30
85
|
return children.every(isInline);
|
|
31
86
|
}
|
|
32
87
|
|
|
88
|
+
export function getRotation(transformStr) {
|
|
89
|
+
if (!transformStr || transformStr === 'none') return 0;
|
|
90
|
+
const values = transformStr.split('(')[1].split(')')[0].split(',');
|
|
91
|
+
if (values.length < 4) return 0;
|
|
92
|
+
const a = parseFloat(values[0]);
|
|
93
|
+
const b = parseFloat(values[1]);
|
|
94
|
+
return Math.round(Math.atan2(b, a) * (180 / Math.PI));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function svgToPng(node) {
|
|
98
|
+
return new Promise((resolve) => {
|
|
99
|
+
const clone = node.cloneNode(true);
|
|
100
|
+
const rect = node.getBoundingClientRect();
|
|
101
|
+
const width = rect.width || 300;
|
|
102
|
+
const height = rect.height || 150;
|
|
103
|
+
|
|
104
|
+
function inlineStyles(source, target) {
|
|
105
|
+
const computed = window.getComputedStyle(source);
|
|
106
|
+
const properties = ['fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'opacity', 'font-family', 'font-size', 'font-weight'];
|
|
107
|
+
|
|
108
|
+
if (computed.fill === 'none') target.setAttribute('fill', 'none');
|
|
109
|
+
else if (computed.fill) target.style.fill = computed.fill;
|
|
110
|
+
|
|
111
|
+
if (computed.stroke === 'none') target.setAttribute('stroke', 'none');
|
|
112
|
+
else if (computed.stroke) target.style.stroke = computed.stroke;
|
|
113
|
+
|
|
114
|
+
properties.forEach(prop => {
|
|
115
|
+
if (prop !== 'fill' && prop !== 'stroke') {
|
|
116
|
+
const val = computed[prop];
|
|
117
|
+
if (val && val !== 'auto') target.style[prop] = val;
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
for (let i = 0; i < source.children.length; i++) {
|
|
122
|
+
if (target.children[i]) inlineStyles(source.children[i], target.children[i]);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
inlineStyles(node, clone);
|
|
127
|
+
|
|
128
|
+
clone.setAttribute('width', width);
|
|
129
|
+
clone.setAttribute('height', height);
|
|
130
|
+
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
|
131
|
+
|
|
132
|
+
const xml = new XMLSerializer().serializeToString(clone);
|
|
133
|
+
const svgUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(xml)}`;
|
|
134
|
+
|
|
135
|
+
const img = new Image();
|
|
136
|
+
img.crossOrigin = "Anonymous";
|
|
137
|
+
img.onload = () => {
|
|
138
|
+
const canvas = document.createElement('canvas');
|
|
139
|
+
const scale = 3;
|
|
140
|
+
canvas.width = width * scale;
|
|
141
|
+
canvas.height = height * scale;
|
|
142
|
+
const ctx = canvas.getContext('2d');
|
|
143
|
+
ctx.scale(scale, scale);
|
|
144
|
+
ctx.drawImage(img, 0, 0, width, height);
|
|
145
|
+
resolve(canvas.toDataURL('image/png'));
|
|
146
|
+
};
|
|
147
|
+
img.onerror = () => resolve(null);
|
|
148
|
+
img.src = svgUrl;
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
33
152
|
export function getVisibleShadow(shadowStr, scale) {
|
|
34
153
|
if (!shadowStr || shadowStr === "none") return null;
|
|
35
154
|
const shadows = shadowStr.split(/,(?![^(]*\))/);
|
|
36
|
-
|
|
37
155
|
for (let s of shadows) {
|
|
38
156
|
s = s.trim();
|
|
39
157
|
if (s.startsWith("rgba(0, 0, 0, 0)")) continue;
|
|
40
|
-
|
|
41
158
|
const match = s.match(/(rgba?\([^\)]+\)|#[0-9a-fA-F]+)\s+(-?[\d\.]+)px\s+(-?[\d\.]+)px\s+([\d\.]+)px/);
|
|
42
159
|
if (match) {
|
|
43
160
|
const colorStr = match[1];
|
|
44
161
|
const x = parseFloat(match[2]);
|
|
45
162
|
const y = parseFloat(match[3]);
|
|
46
163
|
const blur = parseFloat(match[4]);
|
|
47
|
-
|
|
48
164
|
const distance = Math.sqrt(x*x + y*y);
|
|
49
165
|
let angle = Math.atan2(y, x) * (180 / Math.PI);
|
|
50
166
|
if (angle < 0) angle += 360;
|
|
51
|
-
|
|
52
|
-
let opacity = 0.4;
|
|
53
|
-
if (colorStr.includes('rgba')) {
|
|
54
|
-
const alphaMatch = colorStr.match(/, ([0-9.]+)\)/);
|
|
55
|
-
if (alphaMatch) opacity = parseFloat(alphaMatch[1]);
|
|
56
|
-
}
|
|
57
|
-
|
|
167
|
+
const colorObj = parseColor(colorStr);
|
|
58
168
|
return {
|
|
59
169
|
type: "outer",
|
|
60
170
|
angle: angle,
|
|
61
171
|
blur: blur * 0.75 * scale,
|
|
62
172
|
offset: distance * 0.75 * scale,
|
|
63
|
-
color:
|
|
64
|
-
opacity: opacity
|
|
173
|
+
color: colorObj.hex || "000000",
|
|
174
|
+
opacity: colorObj.opacity
|
|
65
175
|
};
|
|
66
176
|
}
|
|
67
177
|
}
|
|
@@ -73,28 +183,24 @@ export function generateGradientSVG(w, h, bgString, radius, border) {
|
|
|
73
183
|
const match = bgString.match(/linear-gradient\((.*)\)/);
|
|
74
184
|
if (!match) return null;
|
|
75
185
|
const content = match[1];
|
|
76
|
-
// Basic gradient parsing logic (simplified from your script)
|
|
77
186
|
const parts = content.split(/,(?![^(]*\))/).map((p) => p.trim());
|
|
78
187
|
|
|
79
188
|
let x1 = "0%", y1 = "0%", x2 = "0%", y2 = "100%";
|
|
80
189
|
let stopsStartIdx = 0;
|
|
81
|
-
|
|
82
190
|
if (parts[0].includes("to right")) { x1 = "0%"; x2 = "100%"; y2 = "0%"; stopsStartIdx = 1; }
|
|
83
191
|
else if (parts[0].includes("to left")) { x1 = "100%"; x2 = "0%"; y2 = "0%"; stopsStartIdx = 1; }
|
|
84
|
-
|
|
192
|
+
else if (parts[0].includes("to top")) { y1 = "100%"; y2 = "0%"; stopsStartIdx = 1; }
|
|
193
|
+
else if (parts[0].includes("to bottom")) { y1 = "0%"; y2 = "100%"; stopsStartIdx = 1; }
|
|
85
194
|
|
|
86
195
|
let stopsXML = "";
|
|
87
196
|
const stopParts = parts.slice(stopsStartIdx);
|
|
88
197
|
stopParts.forEach((part, idx) => {
|
|
89
198
|
let color = part;
|
|
90
199
|
let offset = Math.round((idx / (stopParts.length - 1)) * 100) + "%";
|
|
91
|
-
// Simple regex to separate color from percentage
|
|
92
200
|
const posMatch = part.match(/(.*?)\s+(\d+(\.\d+)?%?)$/);
|
|
93
201
|
if (posMatch) { color = posMatch[1]; offset = posMatch[2]; }
|
|
94
|
-
|
|
95
202
|
let opacity = 1;
|
|
96
203
|
if (color.includes("rgba")) {
|
|
97
|
-
// extract alpha
|
|
98
204
|
const rgba = color.match(/[\d\.]+/g);
|
|
99
205
|
if(rgba && rgba.length > 3) { opacity = rgba[3]; color = `rgb(${rgba[0]},${rgba[1]},${rgba[2]})`; }
|
|
100
206
|
}
|
|
@@ -111,4 +217,40 @@ export function generateGradientSVG(w, h, bgString, radius, border) {
|
|
|
111
217
|
</svg>`;
|
|
112
218
|
return "data:image/svg+xml;base64," + btoa(svg);
|
|
113
219
|
} catch (e) { return null; }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function generateBlurredSVG(w, h, color, radius, blurPx) {
|
|
223
|
+
const padding = blurPx * 3;
|
|
224
|
+
const fullW = w + (padding * 2);
|
|
225
|
+
const fullH = h + (padding * 2);
|
|
226
|
+
const x = padding;
|
|
227
|
+
const y = padding;
|
|
228
|
+
let shapeTag = "";
|
|
229
|
+
// Is it a Circle? Use strict check
|
|
230
|
+
const isCircle = radius >= (Math.min(w, h) / 2) - 1 && Math.abs(w - h) < 2;
|
|
231
|
+
|
|
232
|
+
if (isCircle) {
|
|
233
|
+
const cx = x + w/2;
|
|
234
|
+
const cy = y + h/2;
|
|
235
|
+
const rx = w/2;
|
|
236
|
+
const ry = h/2;
|
|
237
|
+
shapeTag = `<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}" fill="#${color}" filter="url(#f1)" />`;
|
|
238
|
+
} else {
|
|
239
|
+
shapeTag = `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${radius}" ry="${radius}" fill="#${color}" filter="url(#f1)" />`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const svg = `
|
|
243
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="${fullW}" height="${fullH}" viewBox="0 0 ${fullW} ${fullH}">
|
|
244
|
+
<defs>
|
|
245
|
+
<filter id="f1" x="-50%" y="-50%" width="200%" height="200%">
|
|
246
|
+
<feGaussianBlur in="SourceGraphic" stdDeviation="${blurPx}" />
|
|
247
|
+
</filter>
|
|
248
|
+
</defs>
|
|
249
|
+
${shapeTag}
|
|
250
|
+
</svg>`;
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
data: 'data:image/svg+xml;base64,' + btoa(svg),
|
|
254
|
+
padding: padding
|
|
255
|
+
};
|
|
114
256
|
}
|