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.
@@ -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.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
- import { parseColor, getTextStyle, isTextContainer, getVisibleShadow, generateGradientSVG } from "./utils.js";
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"; // Default
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
- await processNode(root, pptx, slide, layoutConfig);
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 processNode(node, pptx, slide, config) {
57
- if (node.nodeType !== 1) return; // Element nodes only
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 x = config.offX + (rect.x - config.rootX) * PX_TO_INCH * config.scale;
66
- const y = config.offY + (rect.y - config.rootY) * PX_TO_INCH * config.scale;
67
- const w = rect.width * PX_TO_INCH * config.scale;
68
- const h = rect.height * PX_TO_INCH * config.scale;
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
- // --- 1. Detect Image Wrapper ---
71
- let isImageWrapper = false;
72
- const imgChild = Array.from(node.children).find(c => c.tagName === 'IMG');
73
- if (imgChild) {
74
- const imgRect = imgChild.getBoundingClientRect();
75
- if (Math.abs(imgRect.width - rect.width) < 2 && Math.abs(imgRect.height - rect.height) < 2) {
76
- isImageWrapper = true;
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
- // --- 2. Backgrounds & Borders ---
81
- let bgColor = parseColor(style.backgroundColor);
82
- if (isImageWrapper && bgColor) bgColor = null; // Prevent halo
83
-
84
- const hasGradient = style.backgroundImage && style.backgroundImage.includes("linear-gradient");
85
- const borderColor = parseColor(style.borderColor);
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 && borderColor;
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
- if (hasGradient) {
93
- const svgData = generateGradientSVG(rect.width, rect.height, style.backgroundImage, borderRadius, hasBorder ? { color: borderColor, width: borderWidth } : null);
94
- if (svgData) slide.addImage({ data: svgData, x, y, w, h });
95
- } else if (bgColor || hasBorder || hasShadow) {
96
- const isCircle = borderRadius >= Math.min(rect.width, rect.height) / 2 - 1;
97
-
98
- const shapeOpts = {
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
- // --- 3. Process Image ---
128
- if (node.tagName === "IMG") {
129
- let effectiveRadius = borderRadius;
130
- // Check parent clipping if current img has no radius
131
- if (effectiveRadius === 0) {
132
- const parentStyle = window.getComputedStyle(node.parentElement);
133
- if (parentStyle.overflow !== 'visible') {
134
- effectiveRadius = parseFloat(parentStyle.borderRadius) || 0;
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
- const processedImage = await getProcessedImage(node.src, rect.width, rect.height, effectiveRadius);
139
- if (processedImage) {
140
- slide.addImage({ data: processedImage, x, y, w, h });
141
- }
142
- return; // Don't process children of IMG
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
- // --- 4. Process Text ---
146
- if (isTextContainer(node)) {
147
- const textParts = [];
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
-
152
- textVal = textVal.replace(/[\n\r\t]+/g, " ").replace(/\s{2,}/g, " ");
153
- if (index === 0) textVal = textVal.trimStart();
154
- if (index === node.childNodes.length - 1) textVal = textVal.trimEnd();
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 (nodeStyle.textTransform === "uppercase") textVal = textVal.toUpperCase();
157
- if (nodeStyle.textTransform === "lowercase") textVal = textVal.toLowerCase();
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
- if (textVal.length > 0) {
160
- textParts.push({ text: textVal, options: getTextStyle(nodeStyle, config.scale) });
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
- if (textParts.length > 0) {
165
- let align = style.textAlign || "left";
166
- if (align === "start") align = "left";
167
- if (align === "end") align = "right";
168
-
169
- let valign = "top";
170
- if (style.alignItems === "center") valign = "middle"; // Flex approximation
171
-
172
- slide.addText(textParts, { x, y, w, h, align, valign, margin: 0, wrap: true, autoFit: false });
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
- // Recursive call
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)")) return null;
5
- const rgb = str.match(/\d+/g);
6
- if (!rgb || rgb.length < 3) return null;
7
- // Convert RGB to Hex
8
- return ((1 << 24) + (parseInt(rgb[0]) << 16) + (parseInt(rgb[1]) << 8) + parseInt(rgb[2]))
9
- .toString(16).slice(1).toUpperCase();
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: parseColor(style.color) || "000000",
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: parseColor(colorStr) || "000000",
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
- // Add other directions as needed...
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
  }