dom-to-pptx 1.0.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Atharva Dharmendra Jagtap and dom-to-pptx contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/Readme.md ADDED
@@ -0,0 +1,110 @@
1
+ # dom-to-pptx
2
+
3
+ **The High-Fidelity HTML to PowerPoint Converter.**
4
+
5
+ Most HTML-to-PPTX libraries fail when faced with modern web design. They break on gradients, misalign text, ignore rounded corners, or simply take a screenshot (which isn't editable).
6
+
7
+ **dom-to-pptx** is different. It is a **Coordinate Scraper & Style Engine** that traverses your DOM, calculates the exact computed styles of every element (Flexbox/Grid positions, complex gradients, shadows), and mathematically maps them to native PowerPoint shapes and text boxes. The result is a fully editable, vector-sharp presentation that looks exactly like your web view.
8
+
9
+ ## Features
10
+
11
+ ### 🎨 Advanced Visual Fidelity
12
+ - **Complex Gradients:** Includes a built-in CSS Gradient Parser that converts `linear-gradient` strings (with multiple stops, angles, and transparency) into vector SVGs for perfect rendering.
13
+ - **Mathematically Accurate Shadows:** Converts CSS Cartesian shadows (`x`, `y`, `blur`) into PowerPoint's Polar coordinate system (`angle`, `distance`) for 1:1 depth matching.
14
+ - **Anti-Halo Image Processing:** Uses off-screen HTML5 Canvas with `source-in` composite masking to render rounded images without the ugly white "halo" artifacts found in other libraries.
15
+
16
+ ### 📐 Smart Layout & Typography
17
+ - **Auto-Scaling Engine:** Build your slide in HTML at **1920x1080** (or any aspect ratio). The library automatically calculates the scaling factor to fit it perfectly into a standard 16:9 PowerPoint slide (10 x 5.625 inches) with auto-centering.
18
+ - **Rich Text Blocks:** Handles mixed-style text (e.g., **bold** spans inside a normal paragraph) while sanitizing HTML source code whitespace (newlines/tabs) to prevent jagged text alignment.
19
+ - **Font Stack Normalization:** Automatically maps web-only fonts (like `ui-sans-serif`, `system-ui`) to safe system fonts (`Arial`, `Calibri`) to ensure the file opens correctly on any computer.
20
+ - **Text Transformations:** Supports CSS `text-transform: uppercase/lowercase` and `letter-spacing` (converted to PT).
21
+
22
+ ### ⚡ Technical Capabilities
23
+ - **Z-Index Handling:** Respects DOM order for correct layering of elements.
24
+ - **Border Radius Math:** Calculates perfect corner rounding percentages based on element dimensions.
25
+ - **Client-Side:** Runs entirely in the browser. No server required.
26
+
27
+ ## Installation
28
+
29
+ ```bash
30
+ npm install dom-to-pptx
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ This library is intended for use in the browser (React, Vue, Svelte, Vanilla JS, etc.).
36
+
37
+ ### 1. Basic Example
38
+
39
+ ```javascript
40
+ import { exportToPptx } from 'dom-to-pptx';
41
+
42
+ document.getElementById('download-btn').addEventListener('click', async () => {
43
+ // Pass the CSS selector of the container you want to turn into a slide
44
+ await exportToPptx('#slide-container', {
45
+ fileName: 'dashboard-report.pptx'
46
+ });
47
+ });
48
+ ```
49
+
50
+ ### 2. Recommended HTML Structure
51
+ For the best results, treat your container as a fixed-size canvas. We recommend building your slide at **1920x1080px**. The library will handle the downscaling.
52
+
53
+ ```html
54
+ <!-- Container (16:9 Aspect Ratio) -->
55
+ <!-- The library will capture this background color/gradient automatically -->
56
+ <div id="slide-container" class="w-[1920px] h-[1080px] relative overflow-hidden bg-slate-50 font-sans">
57
+
58
+ <!-- Background Gradient -->
59
+ <div class="absolute inset-0 bg-gradient-to-br from-blue-100 to-purple-100"></div>
60
+
61
+ <!-- Content -->
62
+ <div class="absolute top-[100px] left-[100px] w-[800px]">
63
+ <h1 class="text-6xl font-bold text-slate-800 drop-shadow-md">
64
+ Quarterly Report
65
+ </h1>
66
+ <p class="text-2xl text-slate-600 mt-4">
67
+ Analysis of <span class="font-bold text-blue-600">renewable energy</span> trends.
68
+ </p>
69
+ </div>
70
+
71
+ <!-- Rounded Image with Shadow (Renders perfectly without white edges) -->
72
+ <div class="absolute top-[100px] right-[100px] w-[600px] h-[400px] rounded-2xl shadow-2xl overflow-hidden">
73
+ <img
74
+ src="https://example.com/image.jpg"
75
+ class="w-full h-full object-cover"
76
+ />
77
+ </div>
78
+
79
+ </div>
80
+ ```
81
+
82
+ ## API
83
+
84
+ ### `exportToPptx(elementOrSelector, options)`
85
+
86
+ | Parameter | Type | Description |
87
+ | :--- | :--- | :--- |
88
+ | `elementOrSelector` | `string` \| `HTMLElement` | The DOM node (or ID selector) to convert. |
89
+ | `options` | `object` | Configuration object. |
90
+
91
+ **Options Object:**
92
+
93
+ | Key | Type | Default | Description |
94
+ | :--- | :--- | :--- | :--- |
95
+ | `fileName` | `string` | `"slide.pptx"` | The name of the downloaded file. |
96
+ | `backgroundColor` | `string` | `null` | Force a background color for the slide (hex). |
97
+
98
+ ## Important Notes
99
+
100
+ 1. **CORS Images:** Because this library uses HTML5 Canvas to process rounded images, any external images must be served with `Access-Control-Allow-Origin: *` headers. If an image is "tainted" (CORS blocked), the browser will refuse to read its data, and it may appear blank in the PPTX.
101
+ 2. **Layout System:** The library does not "read" Flexbox or Grid definitions directly. Instead, it lets the browser render the layout, measures the final `x, y, width, height` (BoundingBox) of every element, and places them absolutely on the slide. This ensures 100% visual accuracy regardless of the layout method used.
102
+ 3. **Fonts:** PPTX files use the fonts installed on the viewer's OS. If you use a web font like "Inter", and the user doesn't have it installed, PowerPoint will fallback to Arial.
103
+
104
+ ## License
105
+
106
+ MIT © [Atharva Dharmendra Jagtap](https://github.com/atharva9167j)
107
+
108
+ ## Acknowledgements
109
+
110
+ This project is built on top of [PptxGenJS](https://github.com/gitbrent/PptxGenJS). Huge thanks to the PptxGenJS maintainers and all contributors — dom-to-pptx leverages and extends their excellent work on PPTX generation.
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "dom-to-pptx",
3
+ "version": "1.0.0",
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
+ "main": "src/index.js",
6
+ "type": "module",
7
+ "scripts": {
8
+ "test": "echo \"Error: no test specified\" && exit 1"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/atharva9167j/dom-to-pptx.git"
13
+ },
14
+ "keywords": [
15
+ "pptx",
16
+ "html-to-pptx",
17
+ "powerpoint",
18
+ "pptx-generator",
19
+ "slide-generator",
20
+ "presentation-generator",
21
+ "dom",
22
+ "dom-to-pptx",
23
+ "export",
24
+ "browser-export",
25
+ "client-side-pptx",
26
+ "html-to-powerpoint",
27
+ "convert-html",
28
+ "css-to-pptx",
29
+ "css-to-powerpoint",
30
+ "pptx-creator",
31
+ "presentation-tools",
32
+ "slides",
33
+ "slide-creation",
34
+ "web-to-pptx",
35
+ "web-to-powerpoint",
36
+ "frontend-pptx",
37
+ "html-rendering",
38
+ "layout-engine",
39
+ "gradient-support",
40
+ "flexbox",
41
+ "css-grid",
42
+ "typography"
43
+ ],
44
+ "author": "Atharva Dharmendra Jagtap <atharvaj321@gmail.com>",
45
+ "license": "MIT",
46
+ "bugs": {
47
+ "url": "https://github.com/atharva9167j/dom-to-pptx/issues"
48
+ },
49
+ "homepage": "https://github.com/atharva9167j/dom-to-pptx#readme",
50
+ "dependencies": {
51
+ "pptxgenjs": "^3.12.0"
52
+ }
53
+ }
@@ -0,0 +1,48 @@
1
+ // src/image-processor.js
2
+
3
+ export async function getProcessedImage(src, targetW, targetH, radius) {
4
+ return new Promise((resolve) => {
5
+ const img = new Image();
6
+ img.crossOrigin = "Anonymous"; // Critical for canvas manipulation
7
+
8
+ img.onload = () => {
9
+ const canvas = document.createElement('canvas');
10
+ // Double resolution for better quality
11
+ const scale = 2;
12
+ canvas.width = targetW * scale;
13
+ canvas.height = targetH * scale;
14
+ const ctx = canvas.getContext('2d');
15
+ ctx.scale(scale, scale);
16
+
17
+ // 1. Draw the Mask (Rounded Rect)
18
+ ctx.beginPath();
19
+ if (ctx.roundRect) {
20
+ ctx.roundRect(0, 0, targetW, targetH, radius);
21
+ } else {
22
+ // Fallback for older browsers if needed
23
+ ctx.rect(0, 0, targetW, targetH);
24
+ }
25
+ ctx.fillStyle = '#000';
26
+ ctx.fill();
27
+
28
+ // 2. Composite Source-In
29
+ ctx.globalCompositeOperation = 'source-in';
30
+
31
+ // 3. Draw Image (Object Cover Logic)
32
+ const wRatio = targetW / img.width;
33
+ const hRatio = targetH / img.height;
34
+ const maxRatio = Math.max(wRatio, hRatio);
35
+ const renderW = img.width * maxRatio;
36
+ const renderH = img.height * maxRatio;
37
+ const renderX = (targetW - renderW) / 2;
38
+ const renderY = (targetH - renderH) / 2;
39
+
40
+ ctx.drawImage(img, renderX, renderY, renderW, renderH);
41
+
42
+ resolve(canvas.toDataURL('image/png'));
43
+ };
44
+
45
+ img.onerror = () => resolve(null);
46
+ img.src = src;
47
+ });
48
+ }
package/src/index.js ADDED
@@ -0,0 +1,181 @@
1
+ // src/index.js
2
+ import PptxGenJS from "pptxgenjs";
3
+ import { parseColor, getTextStyle, isTextContainer, getVisibleShadow, generateGradientSVG } from "./utils.js";
4
+ import { getProcessedImage } from "./image-processor.js";
5
+
6
+ const PPI = 96;
7
+ const PX_TO_INCH = 1 / PPI;
8
+
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
+ export async function exportToPptx(elementOrSelector, options = {}) {
15
+ const root = typeof elementOrSelector === "string"
16
+ ? document.querySelector(elementOrSelector)
17
+ : elementOrSelector;
18
+
19
+ if (!root) throw new Error("Root element not found");
20
+
21
+ const pptx = new PptxGenJS();
22
+ pptx.layout = "LAYOUT_16x9"; // Default
23
+ const slide = pptx.addSlide();
24
+
25
+ // Use the dimensions of the root element to calculate scaling
26
+ const rootRect = root.getBoundingClientRect();
27
+
28
+ // Standard PPTX 16:9 dimensions in inches
29
+ const PPTX_WIDTH_IN = 10;
30
+ const PPTX_HEIGHT_IN = 5.625;
31
+
32
+ const contentWidthIn = rootRect.width * PX_TO_INCH;
33
+ 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
+ );
40
+
41
+ const layoutConfig = {
42
+ rootX: rootRect.x,
43
+ rootY: rootRect.y,
44
+ scale: scale,
45
+ // Center the content
46
+ offX: (PPTX_WIDTH_IN - contentWidthIn * scale) / 2,
47
+ offY: (PPTX_HEIGHT_IN - contentHeightIn * scale) / 2,
48
+ };
49
+
50
+ await processNode(root, pptx, slide, layoutConfig);
51
+
52
+ const fileName = options.fileName || "export.pptx";
53
+ pptx.writeFile({ fileName });
54
+ }
55
+
56
+ async function processNode(node, pptx, slide, config) {
57
+ if (node.nodeType !== 1) return; // Element nodes only
58
+
59
+ const style = window.getComputedStyle(node);
60
+ if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") return;
61
+
62
+ const rect = node.getBoundingClientRect();
63
+ if (rect.width === 0 || rect.height === 0) return;
64
+
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;
69
+
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;
77
+ }
78
+ }
79
+
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);
86
+ const borderWidth = parseFloat(style.borderWidth);
87
+ const hasBorder = borderWidth > 0 && borderColor;
88
+ const shadowStr = style.boxShadow;
89
+ const hasShadow = shadowStr && shadowStr !== "none";
90
+ const borderRadius = parseFloat(style.borderRadius) || 0;
91
+
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
+ }
113
+ }
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
+ }
126
+
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
+ }
137
+
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
143
+ }
144
+
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();
155
+
156
+ if (nodeStyle.textTransform === "uppercase") textVal = textVal.toUpperCase();
157
+ if (nodeStyle.textTransform === "lowercase") textVal = textVal.toLowerCase();
158
+
159
+ if (textVal.length > 0) {
160
+ textParts.push({ text: textVal, options: getTextStyle(nodeStyle, config.scale) });
161
+ }
162
+ });
163
+
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;
175
+ }
176
+
177
+ // Recursive call
178
+ for (const child of node.children) {
179
+ await processNode(child, pptx, slide, config);
180
+ }
181
+ }
package/src/utils.js ADDED
@@ -0,0 +1,114 @@
1
+ // src/utils.js
2
+
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();
10
+ }
11
+
12
+ export function getTextStyle(style, scale) {
13
+ return {
14
+ color: parseColor(style.color) || "000000",
15
+ fontFace: style.fontFamily.split(",")[0].replace(/['"]/g, ""),
16
+ fontSize: parseFloat(style.fontSize) * 0.75 * scale,
17
+ bold: parseInt(style.fontWeight) >= 600,
18
+ };
19
+ }
20
+
21
+ export function isTextContainer(node) {
22
+ const hasText = node.textContent.trim().length > 0;
23
+ if (!hasText) return false;
24
+ const children = Array.from(node.children);
25
+ if (children.length === 0) return true;
26
+ // Check if children are inline elements
27
+ const isInline = (el) =>
28
+ window.getComputedStyle(el).display.includes("inline") ||
29
+ ["SPAN", "B", "STRONG", "EM"].includes(el.tagName);
30
+ return children.every(isInline);
31
+ }
32
+
33
+ export function getVisibleShadow(shadowStr, scale) {
34
+ if (!shadowStr || shadowStr === "none") return null;
35
+ const shadows = shadowStr.split(/,(?![^(]*\))/);
36
+
37
+ for (let s of shadows) {
38
+ s = s.trim();
39
+ if (s.startsWith("rgba(0, 0, 0, 0)")) continue;
40
+
41
+ const match = s.match(/(rgba?\([^\)]+\)|#[0-9a-fA-F]+)\s+(-?[\d\.]+)px\s+(-?[\d\.]+)px\s+([\d\.]+)px/);
42
+ if (match) {
43
+ const colorStr = match[1];
44
+ const x = parseFloat(match[2]);
45
+ const y = parseFloat(match[3]);
46
+ const blur = parseFloat(match[4]);
47
+
48
+ const distance = Math.sqrt(x*x + y*y);
49
+ let angle = Math.atan2(y, x) * (180 / Math.PI);
50
+ 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
+
58
+ return {
59
+ type: "outer",
60
+ angle: angle,
61
+ blur: blur * 0.75 * scale,
62
+ offset: distance * 0.75 * scale,
63
+ color: parseColor(colorStr) || "000000",
64
+ opacity: opacity,
65
+ };
66
+ }
67
+ }
68
+ return null;
69
+ }
70
+
71
+ export function generateGradientSVG(w, h, bgString, radius, border) {
72
+ try {
73
+ const match = bgString.match(/linear-gradient\((.*)\)/);
74
+ if (!match) return null;
75
+ const content = match[1];
76
+ // Basic gradient parsing logic (simplified from your script)
77
+ const parts = content.split(/,(?![^(]*\))/).map((p) => p.trim());
78
+
79
+ let x1 = "0%", y1 = "0%", x2 = "0%", y2 = "100%";
80
+ let stopsStartIdx = 0;
81
+
82
+ if (parts[0].includes("to right")) { x1 = "0%"; x2 = "100%"; y2 = "0%"; stopsStartIdx = 1; }
83
+ else if (parts[0].includes("to left")) { x1 = "100%"; x2 = "0%"; y2 = "0%"; stopsStartIdx = 1; }
84
+ // Add other directions as needed...
85
+
86
+ let stopsXML = "";
87
+ const stopParts = parts.slice(stopsStartIdx);
88
+ stopParts.forEach((part, idx) => {
89
+ let color = part;
90
+ let offset = Math.round((idx / (stopParts.length - 1)) * 100) + "%";
91
+ // Simple regex to separate color from percentage
92
+ const posMatch = part.match(/(.*?)\s+(\d+(\.\d+)?%?)$/);
93
+ if (posMatch) { color = posMatch[1]; offset = posMatch[2]; }
94
+
95
+ let opacity = 1;
96
+ if (color.includes("rgba")) {
97
+ // extract alpha
98
+ const rgba = color.match(/[\d\.]+/g);
99
+ if(rgba && rgba.length > 3) { opacity = rgba[3]; color = `rgb(${rgba[0]},${rgba[1]},${rgba[2]})`; }
100
+ }
101
+ stopsXML += `<stop offset="${offset}" stop-color="${color}" stop-opacity="${opacity}"/>`;
102
+ });
103
+
104
+ let strokeAttr = "";
105
+ if (border) { strokeAttr = `stroke="#${border.color}" stroke-width="${border.width}"`; }
106
+
107
+ const svg = `
108
+ <svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
109
+ <defs><linearGradient id="grad" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}">${stopsXML}</linearGradient></defs>
110
+ <rect x="0" y="0" width="${w}" height="${h}" rx="${radius}" ry="${radius}" fill="url(#grad)" ${strokeAttr} />
111
+ </svg>`;
112
+ return "data:image/svg+xml;base64," + btoa(svg);
113
+ } catch (e) { return null; }
114
+ }