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/src/index.js CHANGED
@@ -1,45 +1,99 @@
1
1
  // src/index.js
2
- import PptxGenJS from "pptxgenjs";
3
- // FIX: Added generateGradientTextSVG
4
- import { parseColor, getTextStyle, isTextContainer, getVisibleShadow, generateGradientSVG, getRotation, svgToPng, getPadding, getSoftEdges, generateBlurredSVG } from "./utils.js";
5
- import { getProcessedImage } from "./image-processor.js";
2
+ import * as PptxGenJSImport from 'pptxgenjs';
3
+ // Normalize import so consumers get the constructor whether `pptxgenjs`
4
+ // was published as a default export or CommonJS module with a `default` property.
5
+ const PptxGenJS = PptxGenJSImport?.default ?? PptxGenJSImport;
6
+ import {
7
+ parseColor,
8
+ getTextStyle,
9
+ isTextContainer,
10
+ getVisibleShadow,
11
+ generateGradientSVG,
12
+ getRotation,
13
+ svgToPng,
14
+ getPadding,
15
+ getSoftEdges,
16
+ generateBlurredSVG,
17
+ getBorderInfo,
18
+ generateCompositeBorderSVG,
19
+ } from './utils.js';
20
+ import { getProcessedImage } from './image-processor.js';
6
21
 
7
22
  const PPI = 96;
8
23
  const PX_TO_INCH = 1 / PPI;
9
24
 
10
- export async function exportToPptx(elementOrSelector, options = {}) {
11
- const root = typeof elementOrSelector === "string"
12
- ? document.querySelector(elementOrSelector)
13
- : elementOrSelector;
25
+ /**
26
+ * Main export function. Accepts single element or an array.
27
+ * @param {HTMLElement | string | Array<HTMLElement | string>} target - The root element(s) to convert.
28
+ * @param {Object} options - { fileName: string }
29
+ */
30
+ export async function exportToPptx(target, options = {}) {
31
+ // Resolve the actual constructor in case `pptxgenjs` was imported/required
32
+ // with different shapes (function, { default: fn }, or { PptxGenJS: fn }).
33
+ const resolvePptxConstructor = (pkg) => {
34
+ if (!pkg) return null;
35
+ if (typeof pkg === 'function') return pkg;
36
+ if (pkg && typeof pkg.default === 'function') return pkg.default;
37
+ if (pkg && typeof pkg.PptxGenJS === 'function') return pkg.PptxGenJS;
38
+ if (pkg && pkg.PptxGenJS && typeof pkg.PptxGenJS.default === 'function') return pkg.PptxGenJS.default;
39
+ return null;
40
+ };
41
+
42
+ const PptxConstructor = resolvePptxConstructor(PptxGenJS);
43
+ if (!PptxConstructor) throw new Error('PptxGenJS constructor not found. Ensure `pptxgenjs` is installed or included as a script.');
44
+ const pptx = new PptxConstructor();
45
+ pptx.layout = 'LAYOUT_16x9';
14
46
 
15
- if (!root) throw new Error("Root element not found");
47
+ // Standardize input to an array, ensuring single or multiple elements are handled consistently
48
+ const elements = Array.isArray(target) ? target : [target];
16
49
 
17
- const pptx = new PptxGenJS();
18
- pptx.layout = "LAYOUT_16x9";
19
- const slide = pptx.addSlide();
50
+ for (const el of elements) {
51
+ const root = typeof el === 'string' ? document.querySelector(el) : el;
52
+ if (!root) {
53
+ console.warn('Element not found, skipping slide:', el);
54
+ continue;
55
+ }
56
+
57
+ const slide = pptx.addSlide();
58
+ await processSlide(root, slide, pptx);
59
+ }
20
60
 
61
+ const fileName = options.fileName || 'export.pptx';
62
+ pptx.writeFile({ fileName });
63
+ }
64
+
65
+ /**
66
+ * Worker function to process a single DOM element into a single PPTX slide.
67
+ * @param {HTMLElement} root - The root element for this slide.
68
+ * @param {PptxGenJS.Slide} slide - The PPTX slide object to add content to.
69
+ * @param {PptxGenJS} pptx - The main PPTX instance.
70
+ */
71
+ async function processSlide(root, slide, pptx) {
21
72
  const rootRect = root.getBoundingClientRect();
22
73
  const PPTX_WIDTH_IN = 10;
23
74
  const PPTX_HEIGHT_IN = 5.625;
75
+
24
76
  const contentWidthIn = rootRect.width * PX_TO_INCH;
25
77
  const contentHeightIn = rootRect.height * PX_TO_INCH;
26
78
  const scale = Math.min(PPTX_WIDTH_IN / contentWidthIn, PPTX_HEIGHT_IN / contentHeightIn);
27
79
 
28
80
  const layoutConfig = {
29
- rootX: rootRect.x, rootY: rootRect.y, scale: scale,
81
+ rootX: rootRect.x,
82
+ rootY: rootRect.y,
83
+ scale: scale,
30
84
  offX: (PPTX_WIDTH_IN - contentWidthIn * scale) / 2,
31
85
  offY: (PPTX_HEIGHT_IN - contentHeightIn * scale) / 2,
32
86
  };
33
87
 
34
88
  const renderQueue = [];
35
89
  let domOrderCounter = 0;
36
-
90
+
37
91
  async function collect(node) {
38
- const order = domOrderCounter++;
92
+ const order = domOrderCounter++; // Assign a DOM order for z-index tie-breaking
39
93
  const result = await createRenderItem(node, layoutConfig, order, pptx);
40
94
  if (result) {
41
- if (result.items) renderQueue.push(...result.items);
42
- if (result.stopRecursion) return;
95
+ if (result.items) renderQueue.push(...result.items); // Add any generated render items to the queue
96
+ if (result.stopRecursion) return; // Stop processing children if the item fully consumed the node
43
97
  }
44
98
  for (const child of node.children) await collect(child);
45
99
  }
@@ -47,207 +101,402 @@ export async function exportToPptx(elementOrSelector, options = {}) {
47
101
  await collect(root);
48
102
 
49
103
  renderQueue.sort((a, b) => {
50
- if (a.zIndex !== b.zIndex) return a.zIndex - b.zIndex;
51
- return a.domOrder - b.domOrder;
104
+ if (a.zIndex !== b.zIndex) return a.zIndex - b.zIndex;
105
+ return a.domOrder - b.domOrder;
52
106
  });
53
107
 
54
108
  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);
109
+ if (item.type === 'shape') slide.addShape(item.shapeType, item.options);
110
+ if (item.type === 'image') slide.addImage(item.options);
111
+ if (item.type === 'text') slide.addText(item.textParts, item.options);
58
112
  }
59
-
60
- const fileName = options.fileName || "export.pptx";
61
- pptx.writeFile({ fileName });
62
113
  }
63
114
 
64
115
  async function createRenderItem(node, config, domOrder, pptx) {
65
116
  if (node.nodeType !== 1) return null;
66
117
  const style = window.getComputedStyle(node);
67
- if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") return null;
118
+ if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0')
119
+ return null;
68
120
 
69
121
  const rect = node.getBoundingClientRect();
70
122
  if (rect.width === 0 || rect.height === 0) return null;
71
123
 
72
124
  const zIndex = style.zIndex !== 'auto' ? parseInt(style.zIndex) : 0;
73
125
  const rotation = getRotation(style.transform);
74
- const elementOpacity = parseFloat(style.opacity);
75
-
126
+ const elementOpacity = parseFloat(style.opacity);
127
+
76
128
  const widthPx = node.offsetWidth || rect.width;
77
129
  const heightPx = node.offsetHeight || rect.height;
78
-
79
130
  const unrotatedW = widthPx * PX_TO_INCH * config.scale;
80
131
  const unrotatedH = heightPx * PX_TO_INCH * config.scale;
81
132
  const centerX = rect.left + rect.width / 2;
82
133
  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);
134
+
135
+ let x = config.offX + (centerX - config.rootX) * PX_TO_INCH * config.scale - unrotatedW / 2;
136
+ let y = config.offY + (centerY - config.rootY) * PX_TO_INCH * config.scale - unrotatedH / 2;
86
137
  let w = unrotatedW;
87
138
  let h = unrotatedH;
88
139
 
89
140
  const items = [];
90
141
 
91
- // --- SVG & IMG HANDLERS ---
142
+ // Image handling for SVG nodes directly
92
143
  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 };
144
+ const pngData = await svgToPng(node);
145
+ if (pngData)
146
+ items.push({
147
+ type: 'image',
148
+ zIndex,
149
+ domOrder,
150
+ options: { data: pngData, x, y, w, h, rotate: rotation },
151
+ });
152
+ return { items, stopRecursion: true };
96
153
  }
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;
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 };
154
+ // Image handling for <img> tags, including rounded corners
155
+ if (node.tagName === 'IMG') {
156
+ let borderRadius = parseFloat(style.borderRadius) || 0;
157
+ if (borderRadius === 0) {
158
+ const parentStyle = window.getComputedStyle(node.parentElement);
159
+ if (parentStyle.overflow !== 'visible')
160
+ borderRadius = parseFloat(parentStyle.borderRadius) || 0;
161
+ }
162
+ const processed = await getProcessedImage(node.src, widthPx, heightPx, borderRadius);
163
+ if (processed)
164
+ items.push({
165
+ type: 'image',
166
+ zIndex,
167
+ domOrder,
168
+ options: { data: processed, x, y, w, h, rotate: rotation },
169
+ });
170
+ return { items, stopRecursion: true };
106
171
  }
107
172
 
108
- // --- PREPARE STYLES ---
109
173
  const bgColorObj = parseColor(style.backgroundColor);
110
174
  const bgClip = style.webkitBackgroundClip || style.backgroundClip;
111
175
  const isBgClipText = bgClip === 'text';
112
- const hasGradient = !isBgClipText && style.backgroundImage && style.backgroundImage.includes("linear-gradient");
113
-
176
+ const hasGradient =
177
+ !isBgClipText && style.backgroundImage && style.backgroundImage.includes('linear-gradient');
178
+
114
179
  const borderColorObj = parseColor(style.borderColor);
115
180
  const borderWidth = parseFloat(style.borderWidth);
116
181
  const hasBorder = borderWidth > 0 && borderColorObj.hex;
182
+
183
+ const borderInfo = getBorderInfo(style, config.scale);
184
+ const hasUniformBorder = borderInfo.type === 'uniform';
185
+ const hasCompositeBorder = borderInfo.type === 'composite';
186
+
117
187
  const shadowStr = style.boxShadow;
118
- const hasShadow = shadowStr && shadowStr !== "none";
188
+ const hasShadow = shadowStr && shadowStr !== 'none';
119
189
  const borderRadius = parseFloat(style.borderRadius) || 0;
120
190
  const softEdge = getSoftEdges(style.filter, config.scale);
121
191
 
122
192
  let isImageWrapper = false;
123
- const imgChild = Array.from(node.children).find(c => c.tagName === 'IMG');
193
+ const imgChild = Array.from(node.children).find((c) => c.tagName === 'IMG');
124
194
  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;
129
- }
195
+ const childW = imgChild.offsetWidth || imgChild.getBoundingClientRect().width;
196
+ const childH = imgChild.offsetHeight || imgChild.getBoundingClientRect().height;
197
+ if (childW >= widthPx - 2 && childH >= heightPx - 2) isImageWrapper = true;
130
198
  }
131
199
 
132
- // --- TEXT EXTRACTION ---
133
200
  let textPayload = null;
134
201
  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
- }
147
202
 
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
- }
203
+ if (isText) {
204
+ const textParts = [];
205
+ const isList = style.display === 'list-item';
206
+ if (isList) {
207
+ const fontSizePt = parseFloat(style.fontSize) * 0.75 * config.scale;
208
+ const bulletShift = (parseFloat(style.fontSize) || 16) * PX_TO_INCH * config.scale * 1.5;
209
+ x -= bulletShift;
210
+ w += bulletShift;
211
+ textParts.push({
212
+ text: '• ',
213
+ options: {
214
+ // Default bullet point styling
215
+ color: parseColor(style.color).hex || '000000',
216
+ fontSize: fontSizePt,
217
+ },
161
218
  });
219
+ }
220
+
221
+ node.childNodes.forEach((child, index) => {
222
+ // Process text content, sanitizing whitespace and applying text transformations
223
+ let textVal = child.nodeType === 3 ? child.nodeValue : child.textContent;
224
+ let nodeStyle = child.nodeType === 1 ? window.getComputedStyle(child) : style;
225
+ textVal = textVal.replace(/[\n\r\t]+/g, ' ').replace(/\s{2,}/g, ' ');
226
+ if (index === 0 && !isList) textVal = textVal.trimStart();
227
+ else if (index === 0) textVal = textVal.trimStart();
228
+ if (index === node.childNodes.length - 1) textVal = textVal.trimEnd();
229
+ if (nodeStyle.textTransform === 'uppercase') textVal = textVal.toUpperCase();
230
+ if (nodeStyle.textTransform === 'lowercase') textVal = textVal.toLowerCase();
162
231
 
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 };
232
+ if (textVal.length > 0) {
233
+ textParts.push({
234
+ text: textVal,
235
+ options: getTextStyle(nodeStyle, config.scale),
236
+ });
182
237
  }
238
+ });
239
+
240
+ if (textParts.length > 0) {
241
+ let align = style.textAlign || 'left';
242
+ if (align === 'start') align = 'left';
243
+ if (align === 'end') align = 'right';
244
+ let valign = 'top';
245
+ if (style.alignItems === 'center') valign = 'middle';
246
+ if (style.justifyContent === 'center' && style.display.includes('flex')) align = 'center';
247
+
248
+ const pt = parseFloat(style.paddingTop) || 0;
249
+ const pb = parseFloat(style.paddingBottom) || 0;
250
+ if (Math.abs(pt - pb) < 2 && bgColorObj.hex) valign = 'middle';
251
+
252
+ let padding = getPadding(style, config.scale);
253
+ if (align === 'center' && valign === 'middle') padding = [0, 0, 0, 0];
254
+
255
+ textPayload = { text: textParts, align, valign, inset: padding };
256
+ }
183
257
  }
184
258
 
185
- // --- RENDER LOGIC ---
186
259
  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
- }
260
+ let bgData = null;
261
+ let padIn = 0;
262
+ if (softEdge) {
263
+ const svgInfo = generateBlurredSVG(widthPx, heightPx, bgColorObj.hex, borderRadius, softEdge);
264
+ bgData = svgInfo.data;
265
+ padIn = svgInfo.padding * PX_TO_INCH * config.scale;
266
+ } else {
267
+ bgData = generateGradientSVG(
268
+ widthPx,
269
+ heightPx,
270
+ style.backgroundImage,
271
+ borderRadius,
272
+ hasBorder ? { color: borderColorObj.hex, width: borderWidth } : null
273
+ );
274
+ }
196
275
 
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
- };
276
+ if (bgData) {
277
+ items.push({
278
+ type: 'image',
279
+ zIndex,
280
+ domOrder,
281
+ options: {
282
+ data: bgData,
283
+ x: x - padIn,
284
+ y: y - padIn,
285
+ w: w + padIn * 2,
286
+ h: h + padIn * 2,
287
+ rotate: rotation,
288
+ },
289
+ });
290
+ }
223
291
 
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
- }
292
+ if (textPayload) {
293
+ items.push({
294
+ type: 'text',
295
+ zIndex: zIndex + 1,
296
+ domOrder,
297
+ textParts: textPayload.text,
298
+ options: {
299
+ x,
300
+ y,
301
+ w,
302
+ h,
303
+ align: textPayload.align,
304
+ valign: textPayload.valign,
305
+ inset: textPayload.inset,
306
+ rotate: rotation,
307
+ margin: 0,
308
+ wrap: true,
309
+ autoFit: false,
310
+ },
311
+ });
312
+ }
313
+ if (hasCompositeBorder) {
314
+ // Add border shapes after the main background
315
+ const borderItems = createCompositeBorderItems(
316
+ borderInfo.sides,
317
+ x,
318
+ y,
319
+ w,
320
+ h,
321
+ config.scale,
322
+ zIndex,
323
+ domOrder
324
+ );
325
+ items.push(...borderItems);
326
+ }
327
+ } else if (
328
+ (bgColorObj.hex && !isImageWrapper) ||
329
+ hasUniformBorder ||
330
+ hasCompositeBorder ||
331
+ hasShadow ||
332
+ textPayload
333
+ ) {
334
+ const finalAlpha = elementOpacity * bgColorObj.opacity;
335
+ const transparency = (1 - finalAlpha) * 100;
231
336
 
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));
238
- }
337
+ const shapeOpts = {
338
+ x,
339
+ y,
340
+ w,
341
+ h,
342
+ rotate: rotation,
343
+ fill:
344
+ bgColorObj.hex && !isImageWrapper
345
+ ? { color: bgColorObj.hex, transparency: transparency }
346
+ : { type: 'none' },
347
+ // Only apply line if the border is uniform
348
+ line: hasUniformBorder ? borderInfo.options : null,
349
+ };
350
+
351
+ if (hasShadow) {
352
+ shapeOpts.shadow = getVisibleShadow(shadowStr, config.scale);
353
+ }
239
354
 
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 });
355
+ const borderRadius = parseFloat(style.borderRadius) || 0;
356
+ const widthPx = node.offsetWidth || rect.width;
357
+ const heightPx = node.offsetHeight || rect.height;
358
+ const isCircle =
359
+ borderRadius >= Math.min(widthPx, heightPx) / 2 - 1 && Math.abs(widthPx - heightPx) < 2;
360
+
361
+ let shapeType = pptx.ShapeType.rect;
362
+ if (isCircle) shapeType = pptx.ShapeType.ellipse;
363
+ else if (borderRadius > 0) {
364
+ shapeType = pptx.ShapeType.roundRect;
365
+ shapeOpts.rectRadius = Math.min(1, borderRadius / (Math.min(widthPx, heightPx) / 2));
366
+ }
367
+
368
+ // MERGE TEXT INTO SHAPE (if text exists)
369
+ if (textPayload) {
370
+ const textOptions = {
371
+ shape: shapeType,
372
+ ...shapeOpts,
373
+ align: textPayload.align,
374
+ valign: textPayload.valign,
375
+ inset: textPayload.inset,
376
+ margin: 0,
377
+ wrap: true,
378
+ autoFit: false,
379
+ };
380
+ items.push({
381
+ type: 'text',
382
+ zIndex,
383
+ domOrder,
384
+ textParts: textPayload.text,
385
+ options: textOptions,
386
+ });
387
+ // If no text, just draw the shape
388
+ } else {
389
+ items.push({
390
+ type: 'shape',
391
+ zIndex,
392
+ domOrder,
393
+ shapeType,
394
+ options: shapeOpts,
395
+ });
396
+ }
397
+
398
+ // ADD COMPOSITE BORDERS (if they exist)
399
+ if (hasCompositeBorder) {
400
+ // Generate a single SVG image that contains all the rounded border sides
401
+ const borderSvgData = generateCompositeBorderSVG(
402
+ widthPx,
403
+ heightPx,
404
+ borderRadius,
405
+ borderInfo.sides
406
+ );
407
+
408
+ if (borderSvgData) {
409
+ items.push({
410
+ type: 'image',
411
+ zIndex: zIndex + 1,
412
+ domOrder,
413
+ options: {
414
+ data: borderSvgData,
415
+ x: x,
416
+ y: y,
417
+ w: w,
418
+ h: h,
419
+ rotate: rotation,
420
+ },
421
+ });
249
422
  }
423
+ }
250
424
  }
251
425
 
252
426
  return { items, stopRecursion: !!textPayload };
253
- }
427
+ }
428
+
429
+ /**
430
+ * Helper function to create individual border shapes
431
+ */
432
+ function createCompositeBorderItems(sides, x, y, w, h, scale, zIndex, domOrder) {
433
+ const items = [];
434
+ const pxToInch = 1 / 96;
435
+
436
+ // TOP BORDER
437
+ if (sides.top.width > 0) {
438
+ items.push({
439
+ type: 'shape',
440
+ zIndex: zIndex + 1,
441
+ domOrder,
442
+ shapeType: 'rect',
443
+ options: {
444
+ x: x,
445
+ y: y,
446
+ w: w,
447
+ h: sides.top.width * pxToInch * scale,
448
+ fill: { color: sides.top.color },
449
+ },
450
+ });
451
+ }
452
+ // RIGHT BORDER
453
+ if (sides.right.width > 0) {
454
+ items.push({
455
+ type: 'shape',
456
+ zIndex: zIndex + 1,
457
+ domOrder,
458
+ shapeType: 'rect',
459
+ options: {
460
+ x: x + w - sides.right.width * pxToInch * scale,
461
+ y: y,
462
+ w: sides.right.width * pxToInch * scale,
463
+ h: h,
464
+ fill: { color: sides.right.color },
465
+ },
466
+ });
467
+ }
468
+ // BOTTOM BORDER
469
+ if (sides.bottom.width > 0) {
470
+ items.push({
471
+ type: 'shape',
472
+ zIndex: zIndex + 1,
473
+ domOrder,
474
+ shapeType: 'rect',
475
+ options: {
476
+ x: x,
477
+ y: y + h - sides.bottom.width * pxToInch * scale,
478
+ w: w,
479
+ h: sides.bottom.width * pxToInch * scale,
480
+ fill: { color: sides.bottom.color },
481
+ },
482
+ });
483
+ }
484
+ // LEFT BORDER
485
+ if (sides.left.width > 0) {
486
+ items.push({
487
+ type: 'shape',
488
+ zIndex: zIndex + 1,
489
+ domOrder,
490
+ shapeType: 'rect',
491
+ options: {
492
+ x: x,
493
+ y: y,
494
+ w: sides.left.width * pxToInch * scale,
495
+ h: h,
496
+ fill: { color: sides.left.color },
497
+ },
498
+ });
499
+ }
500
+
501
+ return items;
502
+ }