dom-to-pptx 1.0.1 → 1.0.3
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/.prettierrc +6 -0
- package/CONTRIBUTING.md +72 -0
- package/Readme.md +204 -110
- package/dist/dom-to-pptx.cjs +1038 -0
- package/dist/dom-to-pptx.min.js +1042 -0
- package/dist/dom-to-pptx.mjs +1017 -0
- package/eslint.config.js +17 -0
- package/package.json +55 -37
- package/rollup.config.js +33 -0
- package/src/image-processor.js +48 -48
- package/src/index.js +409 -160
- package/src/utils.js +477 -256
- package/code_context.txt +0 -346
|
@@ -0,0 +1,1017 @@
|
|
|
1
|
+
import * as PptxGenJSImport from 'pptxgenjs';
|
|
2
|
+
|
|
3
|
+
// src/utils.js
|
|
4
|
+
|
|
5
|
+
// Helper to save gradient text
|
|
6
|
+
function getGradientFallbackColor(bgImage) {
|
|
7
|
+
if (!bgImage) return null;
|
|
8
|
+
// Extract first hex or rgb color
|
|
9
|
+
// linear-gradient(to right, #4f46e5, ...) -> #4f46e5
|
|
10
|
+
const hexMatch = bgImage.match(/#(?:[0-9a-fA-F]{3}){1,2}/);
|
|
11
|
+
if (hexMatch) return hexMatch[0];
|
|
12
|
+
|
|
13
|
+
const rgbMatch = bgImage.match(/rgba?\(.*?\)/);
|
|
14
|
+
if (rgbMatch) return rgbMatch[0];
|
|
15
|
+
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function mapDashType(style) {
|
|
20
|
+
if (style === 'dashed') return 'dash';
|
|
21
|
+
if (style === 'dotted') return 'dot';
|
|
22
|
+
// PPTX also supports 'lgDash', 'dashDot', 'lgDashDot', 'lgDashDotDot'
|
|
23
|
+
// but we'll stick to basics for now.
|
|
24
|
+
return 'solid';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Analyzes computed border styles and determines the rendering strategy.
|
|
29
|
+
* @returns {{type: 'uniform' | 'composite' | 'none', ...}}
|
|
30
|
+
*/
|
|
31
|
+
function getBorderInfo(style, scale) {
|
|
32
|
+
const top = {
|
|
33
|
+
width: parseFloat(style.borderTopWidth) || 0,
|
|
34
|
+
style: style.borderTopStyle,
|
|
35
|
+
color: parseColor(style.borderTopColor).hex,
|
|
36
|
+
};
|
|
37
|
+
const right = {
|
|
38
|
+
width: parseFloat(style.borderRightWidth) || 0,
|
|
39
|
+
style: style.borderRightStyle,
|
|
40
|
+
color: parseColor(style.borderRightColor).hex,
|
|
41
|
+
};
|
|
42
|
+
const bottom = {
|
|
43
|
+
width: parseFloat(style.borderBottomWidth) || 0,
|
|
44
|
+
style: style.borderBottomStyle,
|
|
45
|
+
color: parseColor(style.borderBottomColor).hex,
|
|
46
|
+
};
|
|
47
|
+
const left = {
|
|
48
|
+
width: parseFloat(style.borderLeftWidth) || 0,
|
|
49
|
+
style: style.borderLeftStyle,
|
|
50
|
+
color: parseColor(style.borderLeftColor).hex,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const hasAnyBorder = top.width > 0 || right.width > 0 || bottom.width > 0 || left.width > 0;
|
|
54
|
+
if (!hasAnyBorder) return { type: 'none' };
|
|
55
|
+
|
|
56
|
+
// Check if all sides are uniform
|
|
57
|
+
const isUniform =
|
|
58
|
+
top.width === right.width &&
|
|
59
|
+
top.width === bottom.width &&
|
|
60
|
+
top.width === left.width &&
|
|
61
|
+
top.style === right.style &&
|
|
62
|
+
top.style === bottom.style &&
|
|
63
|
+
top.style === left.style &&
|
|
64
|
+
top.color === right.color &&
|
|
65
|
+
top.color === bottom.color &&
|
|
66
|
+
top.color === left.color;
|
|
67
|
+
|
|
68
|
+
if (isUniform) {
|
|
69
|
+
return {
|
|
70
|
+
type: 'uniform',
|
|
71
|
+
options: {
|
|
72
|
+
width: top.width * 0.75 * scale, // Convert to points and scale
|
|
73
|
+
color: top.color,
|
|
74
|
+
dashType: mapDashType(top.style),
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
} else {
|
|
78
|
+
// Borders are different, must render as separate shapes
|
|
79
|
+
return {
|
|
80
|
+
type: 'composite',
|
|
81
|
+
sides: {
|
|
82
|
+
top,
|
|
83
|
+
right,
|
|
84
|
+
bottom,
|
|
85
|
+
left,
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Generates an SVG image for composite borders that respects border-radius.
|
|
93
|
+
*/
|
|
94
|
+
function generateCompositeBorderSVG(w, h, radius, sides) {
|
|
95
|
+
radius = radius / 2; // Adjust for SVG rendering
|
|
96
|
+
|
|
97
|
+
const clipId = 'clip_' + Math.random().toString(36).substr(2, 9);
|
|
98
|
+
|
|
99
|
+
let borderRects = '';
|
|
100
|
+
|
|
101
|
+
// TOP
|
|
102
|
+
if (sides.top.width > 0 && sides.top.color) {
|
|
103
|
+
borderRects += `<rect x="0" y="0" width="${w}" height="${sides.top.width}" fill="#${sides.top.color}" />`;
|
|
104
|
+
}
|
|
105
|
+
// RIGHT
|
|
106
|
+
if (sides.right.width > 0 && sides.right.color) {
|
|
107
|
+
borderRects += `<rect x="${w - sides.right.width}" y="0" width="${sides.right.width}" height="${h}" fill="#${sides.right.color}" />`;
|
|
108
|
+
}
|
|
109
|
+
// BOTTOM
|
|
110
|
+
if (sides.bottom.width > 0 && sides.bottom.color) {
|
|
111
|
+
borderRects += `<rect x="0" y="${h - sides.bottom.width}" width="${w}" height="${sides.bottom.width}" fill="#${sides.bottom.color}" />`;
|
|
112
|
+
}
|
|
113
|
+
// LEFT
|
|
114
|
+
if (sides.left.width > 0 && sides.left.color) {
|
|
115
|
+
borderRects += `<rect x="0" y="0" width="${sides.left.width}" height="${h}" fill="#${sides.left.color}" />`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const svg = `
|
|
119
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
|
|
120
|
+
<defs>
|
|
121
|
+
<clipPath id="${clipId}">
|
|
122
|
+
<rect x="0" y="0" width="${w}" height="${h}" rx="${radius}" ry="${radius}" />
|
|
123
|
+
</clipPath>
|
|
124
|
+
</defs>
|
|
125
|
+
<g clip-path="url(#${clipId})">
|
|
126
|
+
${borderRects}
|
|
127
|
+
</g>
|
|
128
|
+
</svg>`;
|
|
129
|
+
|
|
130
|
+
return 'data:image/svg+xml;base64,' + btoa(svg);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Parses a CSS color string (hex, rgb, rgba) into a hex code and opacity.
|
|
135
|
+
* @param {string} str - The CSS color string.
|
|
136
|
+
* @returns {{hex: string | null, opacity: number}} - Object with hex color (without #) and opacity (0-1).
|
|
137
|
+
*/
|
|
138
|
+
function parseColor(str) {
|
|
139
|
+
if (!str || str === 'transparent' || str.startsWith('rgba(0, 0, 0, 0)')) {
|
|
140
|
+
return { hex: null, opacity: 0 };
|
|
141
|
+
}
|
|
142
|
+
if (str.startsWith('#')) {
|
|
143
|
+
let hex = str.slice(1);
|
|
144
|
+
if (hex.length === 3)
|
|
145
|
+
hex = hex
|
|
146
|
+
.split('')
|
|
147
|
+
.map((c) => c + c)
|
|
148
|
+
.join('');
|
|
149
|
+
return { hex: hex.toUpperCase(), opacity: 1 };
|
|
150
|
+
}
|
|
151
|
+
const match = str.match(/[\d.]+/g);
|
|
152
|
+
if (match && match.length >= 3) {
|
|
153
|
+
const r = parseInt(match[0]);
|
|
154
|
+
const g = parseInt(match[1]);
|
|
155
|
+
const b = parseInt(match[2]);
|
|
156
|
+
const a = match.length > 3 ? parseFloat(match[3]) : 1;
|
|
157
|
+
const hex = ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase();
|
|
158
|
+
return { hex, opacity: a };
|
|
159
|
+
}
|
|
160
|
+
return { hex: null, opacity: 0 };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Calculates padding values from computed CSS styles, scaled to inches.
|
|
165
|
+
* @param {CSSStyleDeclaration} style - The computed CSS style of the element.
|
|
166
|
+
* @param {number} scale - The scaling factor for converting pixels to inches.
|
|
167
|
+
* @returns {number[]} - An array of padding values [top, right, bottom, left] in inches.
|
|
168
|
+
*/
|
|
169
|
+
function getPadding(style, scale) {
|
|
170
|
+
const pxToInch = 1 / 96;
|
|
171
|
+
return [
|
|
172
|
+
(parseFloat(style.paddingTop) || 0) * pxToInch * scale,
|
|
173
|
+
(parseFloat(style.paddingRight) || 0) * pxToInch * scale,
|
|
174
|
+
(parseFloat(style.paddingBottom) || 0) * pxToInch * scale,
|
|
175
|
+
(parseFloat(style.paddingLeft) || 0) * pxToInch * scale,
|
|
176
|
+
];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Extracts the blur radius for soft edges from a CSS filter string.
|
|
181
|
+
* @param {string} filterStr - The CSS filter string.
|
|
182
|
+
* @param {number} scale - The scaling factor.
|
|
183
|
+
* @returns {number | null} - The blur radius in points, or null if no blur is found.
|
|
184
|
+
*/
|
|
185
|
+
function getSoftEdges(filterStr, scale) {
|
|
186
|
+
if (!filterStr || filterStr === 'none') return null;
|
|
187
|
+
const match = filterStr.match(/blur\(([\d.]+)px\)/);
|
|
188
|
+
if (match) return parseFloat(match[1]) * 0.75 * scale;
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Generates text style options for PPTX from computed CSS styles.
|
|
194
|
+
* Handles font properties, color, and text transformations.
|
|
195
|
+
* @param {CSSStyleDeclaration} style - The computed CSS style of the element.
|
|
196
|
+
* @param {number} scale - The scaling factor for converting pixels to inches.
|
|
197
|
+
* @returns {PptxGenJS.TextOptions} - PPTX text style object.
|
|
198
|
+
*/
|
|
199
|
+
function getTextStyle(style, scale) {
|
|
200
|
+
let colorObj = parseColor(style.color);
|
|
201
|
+
|
|
202
|
+
const bgClip = style.webkitBackgroundClip || style.backgroundClip;
|
|
203
|
+
if (colorObj.opacity === 0 && bgClip === 'text') {
|
|
204
|
+
const fallback = getGradientFallbackColor(style.backgroundImage);
|
|
205
|
+
if (fallback) colorObj = parseColor(fallback);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
color: colorObj.hex || '000000',
|
|
210
|
+
fontFace: style.fontFamily.split(',')[0].replace(/['"]/g, ''),
|
|
211
|
+
fontSize: parseFloat(style.fontSize) * 0.75 * scale,
|
|
212
|
+
bold: parseInt(style.fontWeight) >= 600,
|
|
213
|
+
italic: style.fontStyle === 'italic',
|
|
214
|
+
underline: style.textDecoration.includes('underline'),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Determines if a given DOM node is primarily a text container.
|
|
220
|
+
* Checks if the node has text content and if its children are all inline elements.
|
|
221
|
+
* @param {HTMLElement} node - The DOM node to check.
|
|
222
|
+
* @returns {boolean} - True if the node is considered a text container, false otherwise.
|
|
223
|
+
*/
|
|
224
|
+
function isTextContainer(node) {
|
|
225
|
+
const hasText = node.textContent.trim().length > 0;
|
|
226
|
+
if (!hasText) return false;
|
|
227
|
+
const children = Array.from(node.children);
|
|
228
|
+
if (children.length === 0) return true;
|
|
229
|
+
const isInline = (el) =>
|
|
230
|
+
window.getComputedStyle(el).display.includes('inline') ||
|
|
231
|
+
['SPAN', 'B', 'STRONG', 'EM', 'I', 'A', 'SMALL'].includes(el.tagName);
|
|
232
|
+
return children.every(isInline);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Extracts the rotation angle in degrees from a CSS transform string.
|
|
237
|
+
* @param {string} transformStr - The CSS transform string.
|
|
238
|
+
* @returns {number} - The rotation angle in degrees.
|
|
239
|
+
*/
|
|
240
|
+
function getRotation(transformStr) {
|
|
241
|
+
if (!transformStr || transformStr === 'none') return 0;
|
|
242
|
+
const values = transformStr.split('(')[1].split(')')[0].split(',');
|
|
243
|
+
if (values.length < 4) return 0;
|
|
244
|
+
const a = parseFloat(values[0]);
|
|
245
|
+
const b = parseFloat(values[1]);
|
|
246
|
+
return Math.round(Math.atan2(b, a) * (180 / Math.PI));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Converts an SVG DOM node to a PNG data URL.
|
|
251
|
+
* Inlines styles to ensure accurate rendering in the PNG.
|
|
252
|
+
* @param {SVGElement} node - The SVG DOM node to convert.
|
|
253
|
+
* @returns {Promise<string | null>} - A Promise that resolves with the PNG data URL or null on error.
|
|
254
|
+
*/
|
|
255
|
+
function svgToPng(node) {
|
|
256
|
+
return new Promise((resolve) => {
|
|
257
|
+
const clone = node.cloneNode(true);
|
|
258
|
+
const rect = node.getBoundingClientRect();
|
|
259
|
+
const width = rect.width || 300;
|
|
260
|
+
const height = rect.height || 150;
|
|
261
|
+
|
|
262
|
+
function inlineStyles(source, target) {
|
|
263
|
+
const computed = window.getComputedStyle(source);
|
|
264
|
+
const properties = [
|
|
265
|
+
'fill',
|
|
266
|
+
'stroke',
|
|
267
|
+
'stroke-width',
|
|
268
|
+
'stroke-linecap',
|
|
269
|
+
'stroke-linejoin',
|
|
270
|
+
'opacity',
|
|
271
|
+
'font-family',
|
|
272
|
+
'font-size',
|
|
273
|
+
'font-weight',
|
|
274
|
+
];
|
|
275
|
+
|
|
276
|
+
if (computed.fill === 'none') target.setAttribute('fill', 'none');
|
|
277
|
+
else if (computed.fill) target.style.fill = computed.fill;
|
|
278
|
+
|
|
279
|
+
if (computed.stroke === 'none') target.setAttribute('stroke', 'none');
|
|
280
|
+
else if (computed.stroke) target.style.stroke = computed.stroke;
|
|
281
|
+
|
|
282
|
+
properties.forEach((prop) => {
|
|
283
|
+
if (prop !== 'fill' && prop !== 'stroke') {
|
|
284
|
+
const val = computed[prop];
|
|
285
|
+
if (val && val !== 'auto') target.style[prop] = val;
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
for (let i = 0; i < source.children.length; i++) {
|
|
290
|
+
if (target.children[i]) inlineStyles(source.children[i], target.children[i]);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
inlineStyles(node, clone);
|
|
295
|
+
|
|
296
|
+
clone.setAttribute('width', width);
|
|
297
|
+
clone.setAttribute('height', height);
|
|
298
|
+
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
|
299
|
+
|
|
300
|
+
const xml = new XMLSerializer().serializeToString(clone);
|
|
301
|
+
const svgUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(xml)}`;
|
|
302
|
+
|
|
303
|
+
const img = new Image();
|
|
304
|
+
img.crossOrigin = 'Anonymous';
|
|
305
|
+
img.onload = () => {
|
|
306
|
+
const canvas = document.createElement('canvas');
|
|
307
|
+
const scale = 3;
|
|
308
|
+
canvas.width = width * scale;
|
|
309
|
+
canvas.height = height * scale;
|
|
310
|
+
const ctx = canvas.getContext('2d');
|
|
311
|
+
ctx.scale(scale, scale);
|
|
312
|
+
ctx.drawImage(img, 0, 0, width, height);
|
|
313
|
+
resolve(canvas.toDataURL('image/png'));
|
|
314
|
+
};
|
|
315
|
+
img.onerror = () => resolve(null);
|
|
316
|
+
img.src = svgUrl;
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Parses CSS box-shadow properties and converts them into PPTX-compatible shadow options.
|
|
322
|
+
* Supports multiple shadows, prioritizing the first visible (non-transparent) outer shadow.
|
|
323
|
+
* @param {string} shadowStr - The CSS `box-shadow` string.
|
|
324
|
+
* @param {number} scale - The scaling factor.
|
|
325
|
+
* @returns {PptxGenJS.ShapeShadow | null} - PPTX shadow options, or null if no visible outer shadow.
|
|
326
|
+
*/
|
|
327
|
+
function getVisibleShadow(shadowStr, scale) {
|
|
328
|
+
if (!shadowStr || shadowStr === 'none') return null;
|
|
329
|
+
const shadows = shadowStr.split(/,(?![^()]*\))/);
|
|
330
|
+
for (let s of shadows) {
|
|
331
|
+
s = s.trim();
|
|
332
|
+
if (s.startsWith('rgba(0, 0, 0, 0)')) continue;
|
|
333
|
+
const match = s.match(
|
|
334
|
+
/(rgba?\([^)]+\)|#[0-9a-fA-F]+)\s+(-?[\d.]+)px\s+(-?[\d.]+)px\s+([\d.]+)px/
|
|
335
|
+
);
|
|
336
|
+
if (match) {
|
|
337
|
+
const colorStr = match[1];
|
|
338
|
+
const x = parseFloat(match[2]);
|
|
339
|
+
const y = parseFloat(match[3]);
|
|
340
|
+
const blur = parseFloat(match[4]);
|
|
341
|
+
const distance = Math.sqrt(x * x + y * y);
|
|
342
|
+
let angle = Math.atan2(y, x) * (180 / Math.PI);
|
|
343
|
+
if (angle < 0) angle += 360;
|
|
344
|
+
const colorObj = parseColor(colorStr);
|
|
345
|
+
return {
|
|
346
|
+
type: 'outer',
|
|
347
|
+
angle: angle,
|
|
348
|
+
blur: blur * 0.75 * scale,
|
|
349
|
+
offset: distance * 0.75 * scale,
|
|
350
|
+
color: colorObj.hex || '000000',
|
|
351
|
+
opacity: colorObj.opacity,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Generates an SVG data URL for a linear gradient background with optional border-radius and border.
|
|
360
|
+
* Parses CSS linear-gradient string to create SVG <linearGradient> and <rect> elements.
|
|
361
|
+
* @param {number} w - Width of the SVG.
|
|
362
|
+
* @param {number} h - Height of the SVG.
|
|
363
|
+
* @param {string} bgString - The CSS `background-image` string (e.g., `linear-gradient(...)`).
|
|
364
|
+
* @param {number} radius - Border radius for the rectangle.
|
|
365
|
+
* @param {{color: string, width: number} | null} border - Optional border object with color (hex) and width.
|
|
366
|
+
* @returns {string | null} - SVG data URL or null if parsing fails.
|
|
367
|
+
*/
|
|
368
|
+
function generateGradientSVG(w, h, bgString, radius, border) {
|
|
369
|
+
try {
|
|
370
|
+
const match = bgString.match(/linear-gradient\((.*)\)/);
|
|
371
|
+
if (!match) return null;
|
|
372
|
+
const content = match[1];
|
|
373
|
+
const parts = content.split(/,(?![^()]*\))/).map((p) => p.trim());
|
|
374
|
+
|
|
375
|
+
let x1 = '0%',
|
|
376
|
+
y1 = '0%',
|
|
377
|
+
x2 = '0%',
|
|
378
|
+
y2 = '100%';
|
|
379
|
+
let stopsStartIdx = 0;
|
|
380
|
+
if (parts[0].includes('to right')) {
|
|
381
|
+
x1 = '0%';
|
|
382
|
+
x2 = '100%';
|
|
383
|
+
y2 = '0%';
|
|
384
|
+
stopsStartIdx = 1;
|
|
385
|
+
} else if (parts[0].includes('to left')) {
|
|
386
|
+
x1 = '100%';
|
|
387
|
+
x2 = '0%';
|
|
388
|
+
y2 = '0%';
|
|
389
|
+
stopsStartIdx = 1;
|
|
390
|
+
} else if (parts[0].includes('to top')) {
|
|
391
|
+
y1 = '100%';
|
|
392
|
+
y2 = '0%';
|
|
393
|
+
stopsStartIdx = 1;
|
|
394
|
+
} else if (parts[0].includes('to bottom')) {
|
|
395
|
+
y1 = '0%';
|
|
396
|
+
y2 = '100%';
|
|
397
|
+
stopsStartIdx = 1;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
let stopsXML = '';
|
|
401
|
+
const stopParts = parts.slice(stopsStartIdx);
|
|
402
|
+
stopParts.forEach((part, idx) => {
|
|
403
|
+
let color = part;
|
|
404
|
+
let offset = Math.round((idx / (stopParts.length - 1)) * 100) + '%';
|
|
405
|
+
const posMatch = part.match(/(.*?)\s+(\d+(\.\d+)?%?)$/);
|
|
406
|
+
if (posMatch) {
|
|
407
|
+
color = posMatch[1];
|
|
408
|
+
offset = posMatch[2];
|
|
409
|
+
}
|
|
410
|
+
let opacity = 1;
|
|
411
|
+
if (color.includes('rgba')) {
|
|
412
|
+
const rgba = color.match(/[\d.]+/g);
|
|
413
|
+
if (rgba && rgba.length > 3) {
|
|
414
|
+
opacity = rgba[3];
|
|
415
|
+
color = `rgb(${rgba[0]},${rgba[1]},${rgba[2]})`;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
stopsXML += `<stop offset="${offset}" stop-color="${color}" stop-opacity="${opacity}"/>`;
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
let strokeAttr = '';
|
|
422
|
+
if (border) {
|
|
423
|
+
strokeAttr = `stroke="#${border.color}" stroke-width="${border.width}"`;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const svg = `
|
|
427
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
|
|
428
|
+
<defs><linearGradient id="grad" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}">${stopsXML}</linearGradient></defs>
|
|
429
|
+
<rect x="0" y="0" width="${w}" height="${h}" rx="${radius}" ry="${radius}" fill="url(#grad)" ${strokeAttr} />
|
|
430
|
+
</svg>`;
|
|
431
|
+
return 'data:image/svg+xml;base64,' + btoa(svg);
|
|
432
|
+
} catch {
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Generates an SVG data URL for a blurred rectangle or ellipse, used for soft-edge effects.
|
|
439
|
+
* @param {number} w - Original width of the element.
|
|
440
|
+
* @param {number} h - Original height of the element.
|
|
441
|
+
* @param {string} color - Hex color of the shape (without #).
|
|
442
|
+
* @param {number} radius - Border radius of the shape.
|
|
443
|
+
* @param {number} blurPx - Blur radius in pixels for the SVG filter.
|
|
444
|
+
* @returns {{data: string, padding: number}} - Object containing SVG data URL and calculated padding.
|
|
445
|
+
*/
|
|
446
|
+
function generateBlurredSVG(w, h, color, radius, blurPx) {
|
|
447
|
+
const padding = blurPx * 3;
|
|
448
|
+
const fullW = w + padding * 2;
|
|
449
|
+
const fullH = h + padding * 2;
|
|
450
|
+
const x = padding;
|
|
451
|
+
const y = padding;
|
|
452
|
+
let shapeTag = '';
|
|
453
|
+
const isCircle = radius >= Math.min(w, h) / 2 - 1 && Math.abs(w - h) < 2;
|
|
454
|
+
|
|
455
|
+
if (isCircle) {
|
|
456
|
+
const cx = x + w / 2;
|
|
457
|
+
const cy = y + h / 2;
|
|
458
|
+
const rx = w / 2;
|
|
459
|
+
const ry = h / 2;
|
|
460
|
+
shapeTag = `<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}" fill="#${color}" filter="url(#f1)" />`;
|
|
461
|
+
} else {
|
|
462
|
+
shapeTag = `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${radius}" ry="${radius}" fill="#${color}" filter="url(#f1)" />`;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const svg = `
|
|
466
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="${fullW}" height="${fullH}" viewBox="0 0 ${fullW} ${fullH}">
|
|
467
|
+
<defs>
|
|
468
|
+
<filter id="f1" x="-50%" y="-50%" width="200%" height="200%">
|
|
469
|
+
<feGaussianBlur in="SourceGraphic" stdDeviation="${blurPx}" />
|
|
470
|
+
</filter>
|
|
471
|
+
</defs>
|
|
472
|
+
${shapeTag}
|
|
473
|
+
</svg>`;
|
|
474
|
+
|
|
475
|
+
return {
|
|
476
|
+
data: 'data:image/svg+xml;base64,' + btoa(svg),
|
|
477
|
+
padding: padding,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// src/image-processor.js
|
|
482
|
+
|
|
483
|
+
async function getProcessedImage(src, targetW, targetH, radius) {
|
|
484
|
+
return new Promise((resolve) => {
|
|
485
|
+
const img = new Image();
|
|
486
|
+
img.crossOrigin = 'Anonymous'; // Critical for canvas manipulation
|
|
487
|
+
|
|
488
|
+
img.onload = () => {
|
|
489
|
+
const canvas = document.createElement('canvas');
|
|
490
|
+
// Double resolution for better quality
|
|
491
|
+
const scale = 2;
|
|
492
|
+
canvas.width = targetW * scale;
|
|
493
|
+
canvas.height = targetH * scale;
|
|
494
|
+
const ctx = canvas.getContext('2d');
|
|
495
|
+
ctx.scale(scale, scale);
|
|
496
|
+
|
|
497
|
+
// 1. Draw the Mask (Rounded Rect)
|
|
498
|
+
ctx.beginPath();
|
|
499
|
+
if (ctx.roundRect) {
|
|
500
|
+
ctx.roundRect(0, 0, targetW, targetH, radius);
|
|
501
|
+
} else {
|
|
502
|
+
// Fallback for older browsers if needed
|
|
503
|
+
ctx.rect(0, 0, targetW, targetH);
|
|
504
|
+
}
|
|
505
|
+
ctx.fillStyle = '#000';
|
|
506
|
+
ctx.fill();
|
|
507
|
+
|
|
508
|
+
// 2. Composite Source-In
|
|
509
|
+
ctx.globalCompositeOperation = 'source-in';
|
|
510
|
+
|
|
511
|
+
// 3. Draw Image (Object Cover Logic)
|
|
512
|
+
const wRatio = targetW / img.width;
|
|
513
|
+
const hRatio = targetH / img.height;
|
|
514
|
+
const maxRatio = Math.max(wRatio, hRatio);
|
|
515
|
+
const renderW = img.width * maxRatio;
|
|
516
|
+
const renderH = img.height * maxRatio;
|
|
517
|
+
const renderX = (targetW - renderW) / 2;
|
|
518
|
+
const renderY = (targetH - renderH) / 2;
|
|
519
|
+
|
|
520
|
+
ctx.drawImage(img, renderX, renderY, renderW, renderH);
|
|
521
|
+
|
|
522
|
+
resolve(canvas.toDataURL('image/png'));
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
img.onerror = () => resolve(null);
|
|
526
|
+
img.src = src;
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// src/index.js
|
|
531
|
+
// Normalize import so consumers get the constructor whether `pptxgenjs`
|
|
532
|
+
// was published as a default export or CommonJS module with a `default` property.
|
|
533
|
+
const PptxGenJS = PptxGenJSImport?.default ?? PptxGenJSImport;
|
|
534
|
+
|
|
535
|
+
const PPI = 96;
|
|
536
|
+
const PX_TO_INCH = 1 / PPI;
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Main export function. Accepts single element or an array.
|
|
540
|
+
* @param {HTMLElement | string | Array<HTMLElement | string>} target - The root element(s) to convert.
|
|
541
|
+
* @param {Object} options - { fileName: string }
|
|
542
|
+
*/
|
|
543
|
+
async function exportToPptx(target, options = {}) {
|
|
544
|
+
// Resolve the actual constructor in case `pptxgenjs` was imported/required
|
|
545
|
+
// with different shapes (function, { default: fn }, or { PptxGenJS: fn }).
|
|
546
|
+
const resolvePptxConstructor = (pkg) => {
|
|
547
|
+
if (!pkg) return null;
|
|
548
|
+
if (typeof pkg === 'function') return pkg;
|
|
549
|
+
if (pkg && typeof pkg.default === 'function') return pkg.default;
|
|
550
|
+
if (pkg && typeof pkg.PptxGenJS === 'function') return pkg.PptxGenJS;
|
|
551
|
+
if (pkg && pkg.PptxGenJS && typeof pkg.PptxGenJS.default === 'function') return pkg.PptxGenJS.default;
|
|
552
|
+
return null;
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
const PptxConstructor = resolvePptxConstructor(PptxGenJS);
|
|
556
|
+
if (!PptxConstructor) throw new Error('PptxGenJS constructor not found. Ensure `pptxgenjs` is installed or included as a script.');
|
|
557
|
+
const pptx = new PptxConstructor();
|
|
558
|
+
pptx.layout = 'LAYOUT_16x9';
|
|
559
|
+
|
|
560
|
+
// Standardize input to an array, ensuring single or multiple elements are handled consistently
|
|
561
|
+
const elements = Array.isArray(target) ? target : [target];
|
|
562
|
+
|
|
563
|
+
for (const el of elements) {
|
|
564
|
+
const root = typeof el === 'string' ? document.querySelector(el) : el;
|
|
565
|
+
if (!root) {
|
|
566
|
+
console.warn('Element not found, skipping slide:', el);
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const slide = pptx.addSlide();
|
|
571
|
+
await processSlide(root, slide, pptx);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const fileName = options.fileName || 'export.pptx';
|
|
575
|
+
pptx.writeFile({ fileName });
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Worker function to process a single DOM element into a single PPTX slide.
|
|
580
|
+
* @param {HTMLElement} root - The root element for this slide.
|
|
581
|
+
* @param {PptxGenJS.Slide} slide - The PPTX slide object to add content to.
|
|
582
|
+
* @param {PptxGenJS} pptx - The main PPTX instance.
|
|
583
|
+
*/
|
|
584
|
+
async function processSlide(root, slide, pptx) {
|
|
585
|
+
const rootRect = root.getBoundingClientRect();
|
|
586
|
+
const PPTX_WIDTH_IN = 10;
|
|
587
|
+
const PPTX_HEIGHT_IN = 5.625;
|
|
588
|
+
|
|
589
|
+
const contentWidthIn = rootRect.width * PX_TO_INCH;
|
|
590
|
+
const contentHeightIn = rootRect.height * PX_TO_INCH;
|
|
591
|
+
const scale = Math.min(PPTX_WIDTH_IN / contentWidthIn, PPTX_HEIGHT_IN / contentHeightIn);
|
|
592
|
+
|
|
593
|
+
const layoutConfig = {
|
|
594
|
+
rootX: rootRect.x,
|
|
595
|
+
rootY: rootRect.y,
|
|
596
|
+
scale: scale,
|
|
597
|
+
offX: (PPTX_WIDTH_IN - contentWidthIn * scale) / 2,
|
|
598
|
+
offY: (PPTX_HEIGHT_IN - contentHeightIn * scale) / 2,
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
const renderQueue = [];
|
|
602
|
+
let domOrderCounter = 0;
|
|
603
|
+
|
|
604
|
+
async function collect(node) {
|
|
605
|
+
const order = domOrderCounter++; // Assign a DOM order for z-index tie-breaking
|
|
606
|
+
const result = await createRenderItem(node, layoutConfig, order, pptx);
|
|
607
|
+
if (result) {
|
|
608
|
+
if (result.items) renderQueue.push(...result.items); // Add any generated render items to the queue
|
|
609
|
+
if (result.stopRecursion) return; // Stop processing children if the item fully consumed the node
|
|
610
|
+
}
|
|
611
|
+
for (const child of node.children) await collect(child);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
await collect(root);
|
|
615
|
+
|
|
616
|
+
renderQueue.sort((a, b) => {
|
|
617
|
+
if (a.zIndex !== b.zIndex) return a.zIndex - b.zIndex;
|
|
618
|
+
return a.domOrder - b.domOrder;
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
for (const item of renderQueue) {
|
|
622
|
+
if (item.type === 'shape') slide.addShape(item.shapeType, item.options);
|
|
623
|
+
if (item.type === 'image') slide.addImage(item.options);
|
|
624
|
+
if (item.type === 'text') slide.addText(item.textParts, item.options);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
async function createRenderItem(node, config, domOrder, pptx) {
|
|
629
|
+
if (node.nodeType !== 1) return null;
|
|
630
|
+
const style = window.getComputedStyle(node);
|
|
631
|
+
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0')
|
|
632
|
+
return null;
|
|
633
|
+
|
|
634
|
+
const rect = node.getBoundingClientRect();
|
|
635
|
+
if (rect.width === 0 || rect.height === 0) return null;
|
|
636
|
+
|
|
637
|
+
const zIndex = style.zIndex !== 'auto' ? parseInt(style.zIndex) : 0;
|
|
638
|
+
const rotation = getRotation(style.transform);
|
|
639
|
+
const elementOpacity = parseFloat(style.opacity);
|
|
640
|
+
|
|
641
|
+
const widthPx = node.offsetWidth || rect.width;
|
|
642
|
+
const heightPx = node.offsetHeight || rect.height;
|
|
643
|
+
const unrotatedW = widthPx * PX_TO_INCH * config.scale;
|
|
644
|
+
const unrotatedH = heightPx * PX_TO_INCH * config.scale;
|
|
645
|
+
const centerX = rect.left + rect.width / 2;
|
|
646
|
+
const centerY = rect.top + rect.height / 2;
|
|
647
|
+
|
|
648
|
+
let x = config.offX + (centerX - config.rootX) * PX_TO_INCH * config.scale - unrotatedW / 2;
|
|
649
|
+
let y = config.offY + (centerY - config.rootY) * PX_TO_INCH * config.scale - unrotatedH / 2;
|
|
650
|
+
let w = unrotatedW;
|
|
651
|
+
let h = unrotatedH;
|
|
652
|
+
|
|
653
|
+
const items = [];
|
|
654
|
+
|
|
655
|
+
// Image handling for SVG nodes directly
|
|
656
|
+
if (node.nodeName.toUpperCase() === 'SVG') {
|
|
657
|
+
const pngData = await svgToPng(node);
|
|
658
|
+
if (pngData)
|
|
659
|
+
items.push({
|
|
660
|
+
type: 'image',
|
|
661
|
+
zIndex,
|
|
662
|
+
domOrder,
|
|
663
|
+
options: { data: pngData, x, y, w, h, rotate: rotation },
|
|
664
|
+
});
|
|
665
|
+
return { items, stopRecursion: true };
|
|
666
|
+
}
|
|
667
|
+
// Image handling for <img> tags, including rounded corners
|
|
668
|
+
if (node.tagName === 'IMG') {
|
|
669
|
+
let borderRadius = parseFloat(style.borderRadius) || 0;
|
|
670
|
+
if (borderRadius === 0) {
|
|
671
|
+
const parentStyle = window.getComputedStyle(node.parentElement);
|
|
672
|
+
if (parentStyle.overflow !== 'visible')
|
|
673
|
+
borderRadius = parseFloat(parentStyle.borderRadius) || 0;
|
|
674
|
+
}
|
|
675
|
+
const processed = await getProcessedImage(node.src, widthPx, heightPx, borderRadius);
|
|
676
|
+
if (processed)
|
|
677
|
+
items.push({
|
|
678
|
+
type: 'image',
|
|
679
|
+
zIndex,
|
|
680
|
+
domOrder,
|
|
681
|
+
options: { data: processed, x, y, w, h, rotate: rotation },
|
|
682
|
+
});
|
|
683
|
+
return { items, stopRecursion: true };
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const bgColorObj = parseColor(style.backgroundColor);
|
|
687
|
+
const bgClip = style.webkitBackgroundClip || style.backgroundClip;
|
|
688
|
+
const isBgClipText = bgClip === 'text';
|
|
689
|
+
const hasGradient =
|
|
690
|
+
!isBgClipText && style.backgroundImage && style.backgroundImage.includes('linear-gradient');
|
|
691
|
+
|
|
692
|
+
const borderColorObj = parseColor(style.borderColor);
|
|
693
|
+
const borderWidth = parseFloat(style.borderWidth);
|
|
694
|
+
const hasBorder = borderWidth > 0 && borderColorObj.hex;
|
|
695
|
+
|
|
696
|
+
const borderInfo = getBorderInfo(style, config.scale);
|
|
697
|
+
const hasUniformBorder = borderInfo.type === 'uniform';
|
|
698
|
+
const hasCompositeBorder = borderInfo.type === 'composite';
|
|
699
|
+
|
|
700
|
+
const shadowStr = style.boxShadow;
|
|
701
|
+
const hasShadow = shadowStr && shadowStr !== 'none';
|
|
702
|
+
const borderRadius = parseFloat(style.borderRadius) || 0;
|
|
703
|
+
const softEdge = getSoftEdges(style.filter, config.scale);
|
|
704
|
+
|
|
705
|
+
let isImageWrapper = false;
|
|
706
|
+
const imgChild = Array.from(node.children).find((c) => c.tagName === 'IMG');
|
|
707
|
+
if (imgChild) {
|
|
708
|
+
const childW = imgChild.offsetWidth || imgChild.getBoundingClientRect().width;
|
|
709
|
+
const childH = imgChild.offsetHeight || imgChild.getBoundingClientRect().height;
|
|
710
|
+
if (childW >= widthPx - 2 && childH >= heightPx - 2) isImageWrapper = true;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
let textPayload = null;
|
|
714
|
+
const isText = isTextContainer(node);
|
|
715
|
+
|
|
716
|
+
if (isText) {
|
|
717
|
+
const textParts = [];
|
|
718
|
+
const isList = style.display === 'list-item';
|
|
719
|
+
if (isList) {
|
|
720
|
+
const fontSizePt = parseFloat(style.fontSize) * 0.75 * config.scale;
|
|
721
|
+
const bulletShift = (parseFloat(style.fontSize) || 16) * PX_TO_INCH * config.scale * 1.5;
|
|
722
|
+
x -= bulletShift;
|
|
723
|
+
w += bulletShift;
|
|
724
|
+
textParts.push({
|
|
725
|
+
text: '• ',
|
|
726
|
+
options: {
|
|
727
|
+
// Default bullet point styling
|
|
728
|
+
color: parseColor(style.color).hex || '000000',
|
|
729
|
+
fontSize: fontSizePt,
|
|
730
|
+
},
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
node.childNodes.forEach((child, index) => {
|
|
735
|
+
// Process text content, sanitizing whitespace and applying text transformations
|
|
736
|
+
let textVal = child.nodeType === 3 ? child.nodeValue : child.textContent;
|
|
737
|
+
let nodeStyle = child.nodeType === 1 ? window.getComputedStyle(child) : style;
|
|
738
|
+
textVal = textVal.replace(/[\n\r\t]+/g, ' ').replace(/\s{2,}/g, ' ');
|
|
739
|
+
if (index === 0 && !isList) textVal = textVal.trimStart();
|
|
740
|
+
else if (index === 0) textVal = textVal.trimStart();
|
|
741
|
+
if (index === node.childNodes.length - 1) textVal = textVal.trimEnd();
|
|
742
|
+
if (nodeStyle.textTransform === 'uppercase') textVal = textVal.toUpperCase();
|
|
743
|
+
if (nodeStyle.textTransform === 'lowercase') textVal = textVal.toLowerCase();
|
|
744
|
+
|
|
745
|
+
if (textVal.length > 0) {
|
|
746
|
+
textParts.push({
|
|
747
|
+
text: textVal,
|
|
748
|
+
options: getTextStyle(nodeStyle, config.scale),
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
if (textParts.length > 0) {
|
|
754
|
+
let align = style.textAlign || 'left';
|
|
755
|
+
if (align === 'start') align = 'left';
|
|
756
|
+
if (align === 'end') align = 'right';
|
|
757
|
+
let valign = 'top';
|
|
758
|
+
if (style.alignItems === 'center') valign = 'middle';
|
|
759
|
+
if (style.justifyContent === 'center' && style.display.includes('flex')) align = 'center';
|
|
760
|
+
|
|
761
|
+
const pt = parseFloat(style.paddingTop) || 0;
|
|
762
|
+
const pb = parseFloat(style.paddingBottom) || 0;
|
|
763
|
+
if (Math.abs(pt - pb) < 2 && bgColorObj.hex) valign = 'middle';
|
|
764
|
+
|
|
765
|
+
let padding = getPadding(style, config.scale);
|
|
766
|
+
if (align === 'center' && valign === 'middle') padding = [0, 0, 0, 0];
|
|
767
|
+
|
|
768
|
+
textPayload = { text: textParts, align, valign, inset: padding };
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
if (hasGradient || (softEdge && bgColorObj.hex && !isImageWrapper)) {
|
|
773
|
+
let bgData = null;
|
|
774
|
+
let padIn = 0;
|
|
775
|
+
if (softEdge) {
|
|
776
|
+
const svgInfo = generateBlurredSVG(widthPx, heightPx, bgColorObj.hex, borderRadius, softEdge);
|
|
777
|
+
bgData = svgInfo.data;
|
|
778
|
+
padIn = svgInfo.padding * PX_TO_INCH * config.scale;
|
|
779
|
+
} else {
|
|
780
|
+
bgData = generateGradientSVG(
|
|
781
|
+
widthPx,
|
|
782
|
+
heightPx,
|
|
783
|
+
style.backgroundImage,
|
|
784
|
+
borderRadius,
|
|
785
|
+
hasBorder ? { color: borderColorObj.hex, width: borderWidth } : null
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
if (bgData) {
|
|
790
|
+
items.push({
|
|
791
|
+
type: 'image',
|
|
792
|
+
zIndex,
|
|
793
|
+
domOrder,
|
|
794
|
+
options: {
|
|
795
|
+
data: bgData,
|
|
796
|
+
x: x - padIn,
|
|
797
|
+
y: y - padIn,
|
|
798
|
+
w: w + padIn * 2,
|
|
799
|
+
h: h + padIn * 2,
|
|
800
|
+
rotate: rotation,
|
|
801
|
+
},
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
if (textPayload) {
|
|
806
|
+
items.push({
|
|
807
|
+
type: 'text',
|
|
808
|
+
zIndex: zIndex + 1,
|
|
809
|
+
domOrder,
|
|
810
|
+
textParts: textPayload.text,
|
|
811
|
+
options: {
|
|
812
|
+
x,
|
|
813
|
+
y,
|
|
814
|
+
w,
|
|
815
|
+
h,
|
|
816
|
+
align: textPayload.align,
|
|
817
|
+
valign: textPayload.valign,
|
|
818
|
+
inset: textPayload.inset,
|
|
819
|
+
rotate: rotation,
|
|
820
|
+
margin: 0,
|
|
821
|
+
wrap: true,
|
|
822
|
+
autoFit: false,
|
|
823
|
+
},
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
if (hasCompositeBorder) {
|
|
827
|
+
// Add border shapes after the main background
|
|
828
|
+
const borderItems = createCompositeBorderItems(
|
|
829
|
+
borderInfo.sides,
|
|
830
|
+
x,
|
|
831
|
+
y,
|
|
832
|
+
w,
|
|
833
|
+
h,
|
|
834
|
+
config.scale,
|
|
835
|
+
zIndex,
|
|
836
|
+
domOrder
|
|
837
|
+
);
|
|
838
|
+
items.push(...borderItems);
|
|
839
|
+
}
|
|
840
|
+
} else if (
|
|
841
|
+
(bgColorObj.hex && !isImageWrapper) ||
|
|
842
|
+
hasUniformBorder ||
|
|
843
|
+
hasCompositeBorder ||
|
|
844
|
+
hasShadow ||
|
|
845
|
+
textPayload
|
|
846
|
+
) {
|
|
847
|
+
const finalAlpha = elementOpacity * bgColorObj.opacity;
|
|
848
|
+
const transparency = (1 - finalAlpha) * 100;
|
|
849
|
+
|
|
850
|
+
const shapeOpts = {
|
|
851
|
+
x,
|
|
852
|
+
y,
|
|
853
|
+
w,
|
|
854
|
+
h,
|
|
855
|
+
rotate: rotation,
|
|
856
|
+
fill:
|
|
857
|
+
bgColorObj.hex && !isImageWrapper
|
|
858
|
+
? { color: bgColorObj.hex, transparency: transparency }
|
|
859
|
+
: { type: 'none' },
|
|
860
|
+
// Only apply line if the border is uniform
|
|
861
|
+
line: hasUniformBorder ? borderInfo.options : null,
|
|
862
|
+
};
|
|
863
|
+
|
|
864
|
+
if (hasShadow) {
|
|
865
|
+
shapeOpts.shadow = getVisibleShadow(shadowStr, config.scale);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const borderRadius = parseFloat(style.borderRadius) || 0;
|
|
869
|
+
const widthPx = node.offsetWidth || rect.width;
|
|
870
|
+
const heightPx = node.offsetHeight || rect.height;
|
|
871
|
+
const isCircle =
|
|
872
|
+
borderRadius >= Math.min(widthPx, heightPx) / 2 - 1 && Math.abs(widthPx - heightPx) < 2;
|
|
873
|
+
|
|
874
|
+
let shapeType = pptx.ShapeType.rect;
|
|
875
|
+
if (isCircle) shapeType = pptx.ShapeType.ellipse;
|
|
876
|
+
else if (borderRadius > 0) {
|
|
877
|
+
shapeType = pptx.ShapeType.roundRect;
|
|
878
|
+
shapeOpts.rectRadius = Math.min(1, borderRadius / (Math.min(widthPx, heightPx) / 2));
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// MERGE TEXT INTO SHAPE (if text exists)
|
|
882
|
+
if (textPayload) {
|
|
883
|
+
const textOptions = {
|
|
884
|
+
shape: shapeType,
|
|
885
|
+
...shapeOpts,
|
|
886
|
+
align: textPayload.align,
|
|
887
|
+
valign: textPayload.valign,
|
|
888
|
+
inset: textPayload.inset,
|
|
889
|
+
margin: 0,
|
|
890
|
+
wrap: true,
|
|
891
|
+
autoFit: false,
|
|
892
|
+
};
|
|
893
|
+
items.push({
|
|
894
|
+
type: 'text',
|
|
895
|
+
zIndex,
|
|
896
|
+
domOrder,
|
|
897
|
+
textParts: textPayload.text,
|
|
898
|
+
options: textOptions,
|
|
899
|
+
});
|
|
900
|
+
// If no text, just draw the shape
|
|
901
|
+
} else {
|
|
902
|
+
items.push({
|
|
903
|
+
type: 'shape',
|
|
904
|
+
zIndex,
|
|
905
|
+
domOrder,
|
|
906
|
+
shapeType,
|
|
907
|
+
options: shapeOpts,
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// ADD COMPOSITE BORDERS (if they exist)
|
|
912
|
+
if (hasCompositeBorder) {
|
|
913
|
+
// Generate a single SVG image that contains all the rounded border sides
|
|
914
|
+
const borderSvgData = generateCompositeBorderSVG(
|
|
915
|
+
widthPx,
|
|
916
|
+
heightPx,
|
|
917
|
+
borderRadius,
|
|
918
|
+
borderInfo.sides
|
|
919
|
+
);
|
|
920
|
+
|
|
921
|
+
if (borderSvgData) {
|
|
922
|
+
items.push({
|
|
923
|
+
type: 'image',
|
|
924
|
+
zIndex: zIndex + 1,
|
|
925
|
+
domOrder,
|
|
926
|
+
options: {
|
|
927
|
+
data: borderSvgData,
|
|
928
|
+
x: x,
|
|
929
|
+
y: y,
|
|
930
|
+
w: w,
|
|
931
|
+
h: h,
|
|
932
|
+
rotate: rotation,
|
|
933
|
+
},
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
return { items, stopRecursion: !!textPayload };
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* Helper function to create individual border shapes
|
|
944
|
+
*/
|
|
945
|
+
function createCompositeBorderItems(sides, x, y, w, h, scale, zIndex, domOrder) {
|
|
946
|
+
const items = [];
|
|
947
|
+
const pxToInch = 1 / 96;
|
|
948
|
+
|
|
949
|
+
// TOP BORDER
|
|
950
|
+
if (sides.top.width > 0) {
|
|
951
|
+
items.push({
|
|
952
|
+
type: 'shape',
|
|
953
|
+
zIndex: zIndex + 1,
|
|
954
|
+
domOrder,
|
|
955
|
+
shapeType: 'rect',
|
|
956
|
+
options: {
|
|
957
|
+
x: x,
|
|
958
|
+
y: y,
|
|
959
|
+
w: w,
|
|
960
|
+
h: sides.top.width * pxToInch * scale,
|
|
961
|
+
fill: { color: sides.top.color },
|
|
962
|
+
},
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
// RIGHT BORDER
|
|
966
|
+
if (sides.right.width > 0) {
|
|
967
|
+
items.push({
|
|
968
|
+
type: 'shape',
|
|
969
|
+
zIndex: zIndex + 1,
|
|
970
|
+
domOrder,
|
|
971
|
+
shapeType: 'rect',
|
|
972
|
+
options: {
|
|
973
|
+
x: x + w - sides.right.width * pxToInch * scale,
|
|
974
|
+
y: y,
|
|
975
|
+
w: sides.right.width * pxToInch * scale,
|
|
976
|
+
h: h,
|
|
977
|
+
fill: { color: sides.right.color },
|
|
978
|
+
},
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
// BOTTOM BORDER
|
|
982
|
+
if (sides.bottom.width > 0) {
|
|
983
|
+
items.push({
|
|
984
|
+
type: 'shape',
|
|
985
|
+
zIndex: zIndex + 1,
|
|
986
|
+
domOrder,
|
|
987
|
+
shapeType: 'rect',
|
|
988
|
+
options: {
|
|
989
|
+
x: x,
|
|
990
|
+
y: y + h - sides.bottom.width * pxToInch * scale,
|
|
991
|
+
w: w,
|
|
992
|
+
h: sides.bottom.width * pxToInch * scale,
|
|
993
|
+
fill: { color: sides.bottom.color },
|
|
994
|
+
},
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
// LEFT BORDER
|
|
998
|
+
if (sides.left.width > 0) {
|
|
999
|
+
items.push({
|
|
1000
|
+
type: 'shape',
|
|
1001
|
+
zIndex: zIndex + 1,
|
|
1002
|
+
domOrder,
|
|
1003
|
+
shapeType: 'rect',
|
|
1004
|
+
options: {
|
|
1005
|
+
x: x,
|
|
1006
|
+
y: y,
|
|
1007
|
+
w: sides.left.width * pxToInch * scale,
|
|
1008
|
+
h: h,
|
|
1009
|
+
fill: { color: sides.left.color },
|
|
1010
|
+
},
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
return items;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
export { exportToPptx };
|