dom-to-pptx
Version:
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 translate
1,008 lines (904 loc) • 34.2 kB
JavaScript
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('pptxgenjs')) :
typeof define === 'function' && define.amd ? define(['exports', 'pptxgenjs'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.domToPptx = {}, global.PptxGenJS));
})(this, (function (exports, PptxGenJS) { 'use strict';
// src/utils.js
// Helper to save gradient text
function getGradientFallbackColor(bgImage) {
if (!bgImage) return null;
// Extract first hex or rgb color
// linear-gradient(to right, #4f46e5, ...) -> #4f46e5
const hexMatch = bgImage.match(/#(?:[0-9a-fA-F]{3}){1,2}/);
if (hexMatch) return hexMatch[0];
const rgbMatch = bgImage.match(/rgba?\(.*?\)/);
if (rgbMatch) return rgbMatch[0];
return null;
}
function mapDashType(style) {
if (style === 'dashed') return 'dash';
if (style === 'dotted') return 'dot';
// PPTX also supports 'lgDash', 'dashDot', 'lgDashDot', 'lgDashDotDot'
// but we'll stick to basics for now.
return 'solid';
}
/**
* Analyzes computed border styles and determines the rendering strategy.
* @returns {{type: 'uniform' | 'composite' | 'none', ...}}
*/
function getBorderInfo(style, scale) {
const top = {
width: parseFloat(style.borderTopWidth) || 0,
style: style.borderTopStyle,
color: parseColor(style.borderTopColor).hex,
};
const right = {
width: parseFloat(style.borderRightWidth) || 0,
style: style.borderRightStyle,
color: parseColor(style.borderRightColor).hex,
};
const bottom = {
width: parseFloat(style.borderBottomWidth) || 0,
style: style.borderBottomStyle,
color: parseColor(style.borderBottomColor).hex,
};
const left = {
width: parseFloat(style.borderLeftWidth) || 0,
style: style.borderLeftStyle,
color: parseColor(style.borderLeftColor).hex,
};
const hasAnyBorder = top.width > 0 || right.width > 0 || bottom.width > 0 || left.width > 0;
if (!hasAnyBorder) return { type: 'none' };
// Check if all sides are uniform
const isUniform =
top.width === right.width &&
top.width === bottom.width &&
top.width === left.width &&
top.style === right.style &&
top.style === bottom.style &&
top.style === left.style &&
top.color === right.color &&
top.color === bottom.color &&
top.color === left.color;
if (isUniform) {
return {
type: 'uniform',
options: {
width: top.width * 0.75 * scale, // Convert to points and scale
color: top.color,
dashType: mapDashType(top.style),
},
};
} else {
// Borders are different, must render as separate shapes
return {
type: 'composite',
sides: {
top,
right,
bottom,
left,
},
};
}
}
/**
* Generates an SVG image for composite borders that respects border-radius.
*/
function generateCompositeBorderSVG(w, h, radius, sides) {
radius = radius / 2; // Adjust for SVG rendering
const clipId = 'clip_' + Math.random().toString(36).substr(2, 9);
let borderRects = '';
// TOP
if (sides.top.width > 0 && sides.top.color) {
borderRects += `<rect x="0" y="0" width="${w}" height="${sides.top.width}" fill="#${sides.top.color}" />`;
}
// RIGHT
if (sides.right.width > 0 && sides.right.color) {
borderRects += `<rect x="${w - sides.right.width}" y="0" width="${sides.right.width}" height="${h}" fill="#${sides.right.color}" />`;
}
// BOTTOM
if (sides.bottom.width > 0 && sides.bottom.color) {
borderRects += `<rect x="0" y="${h - sides.bottom.width}" width="${w}" height="${sides.bottom.width}" fill="#${sides.bottom.color}" />`;
}
// LEFT
if (sides.left.width > 0 && sides.left.color) {
borderRects += `<rect x="0" y="0" width="${sides.left.width}" height="${h}" fill="#${sides.left.color}" />`;
}
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
<defs>
<clipPath id="${clipId}">
<rect x="0" y="0" width="${w}" height="${h}" rx="${radius}" ry="${radius}" />
</clipPath>
</defs>
<g clip-path="url(#${clipId})">
${borderRects}
</g>
</svg>`;
return 'data:image/svg+xml;base64,' + btoa(svg);
}
/**
* Parses a CSS color string (hex, rgb, rgba) into a hex code and opacity.
* @param {string} str - The CSS color string.
* @returns {{hex: string | null, opacity: number}} - Object with hex color (without #) and opacity (0-1).
*/
function parseColor(str) {
if (!str || str === 'transparent' || str.startsWith('rgba(0, 0, 0, 0)')) {
return { hex: null, opacity: 0 };
}
if (str.startsWith('#')) {
let hex = str.slice(1);
if (hex.length === 3)
hex = hex
.split('')
.map((c) => c + c)
.join('');
return { hex: hex.toUpperCase(), opacity: 1 };
}
const match = str.match(/[\d.]+/g);
if (match && match.length >= 3) {
const r = parseInt(match[0]);
const g = parseInt(match[1]);
const b = parseInt(match[2]);
const a = match.length > 3 ? parseFloat(match[3]) : 1;
const hex = ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase();
return { hex, opacity: a };
}
return { hex: null, opacity: 0 };
}
/**
* Calculates padding values from computed CSS styles, scaled to inches.
* @param {CSSStyleDeclaration} style - The computed CSS style of the element.
* @param {number} scale - The scaling factor for converting pixels to inches.
* @returns {number[]} - An array of padding values [top, right, bottom, left] in inches.
*/
function getPadding(style, scale) {
const pxToInch = 1 / 96;
return [
(parseFloat(style.paddingTop) || 0) * pxToInch * scale,
(parseFloat(style.paddingRight) || 0) * pxToInch * scale,
(parseFloat(style.paddingBottom) || 0) * pxToInch * scale,
(parseFloat(style.paddingLeft) || 0) * pxToInch * scale,
];
}
/**
* Extracts the blur radius for soft edges from a CSS filter string.
* @param {string} filterStr - The CSS filter string.
* @param {number} scale - The scaling factor.
* @returns {number | null} - The blur radius in points, or null if no blur is found.
*/
function getSoftEdges(filterStr, scale) {
if (!filterStr || filterStr === 'none') return null;
const match = filterStr.match(/blur\(([\d.]+)px\)/);
if (match) return parseFloat(match[1]) * 0.75 * scale;
return null;
}
/**
* Generates text style options for PPTX from computed CSS styles.
* Handles font properties, color, and text transformations.
* @param {CSSStyleDeclaration} style - The computed CSS style of the element.
* @param {number} scale - The scaling factor for converting pixels to inches.
* @returns {PptxGenJS.TextOptions} - PPTX text style object.
*/
function getTextStyle(style, scale) {
let colorObj = parseColor(style.color);
const bgClip = style.webkitBackgroundClip || style.backgroundClip;
if (colorObj.opacity === 0 && bgClip === 'text') {
const fallback = getGradientFallbackColor(style.backgroundImage);
if (fallback) colorObj = parseColor(fallback);
}
return {
color: colorObj.hex || '000000',
fontFace: style.fontFamily.split(',')[0].replace(/['"]/g, ''),
fontSize: parseFloat(style.fontSize) * 0.75 * scale,
bold: parseInt(style.fontWeight) >= 600,
italic: style.fontStyle === 'italic',
underline: style.textDecoration.includes('underline'),
};
}
/**
* Determines if a given DOM node is primarily a text container.
* Checks if the node has text content and if its children are all inline elements.
* @param {HTMLElement} node - The DOM node to check.
* @returns {boolean} - True if the node is considered a text container, false otherwise.
*/
function isTextContainer(node) {
const hasText = node.textContent.trim().length > 0;
if (!hasText) return false;
const children = Array.from(node.children);
if (children.length === 0) return true;
const isInline = (el) =>
window.getComputedStyle(el).display.includes('inline') ||
['SPAN', 'B', 'STRONG', 'EM', 'I', 'A', 'SMALL'].includes(el.tagName);
return children.every(isInline);
}
/**
* Extracts the rotation angle in degrees from a CSS transform string.
* @param {string} transformStr - The CSS transform string.
* @returns {number} - The rotation angle in degrees.
*/
function getRotation(transformStr) {
if (!transformStr || transformStr === 'none') return 0;
const values = transformStr.split('(')[1].split(')')[0].split(',');
if (values.length < 4) return 0;
const a = parseFloat(values[0]);
const b = parseFloat(values[1]);
return Math.round(Math.atan2(b, a) * (180 / Math.PI));
}
/**
* Converts an SVG DOM node to a PNG data URL.
* Inlines styles to ensure accurate rendering in the PNG.
* @param {SVGElement} node - The SVG DOM node to convert.
* @returns {Promise<string | null>} - A Promise that resolves with the PNG data URL or null on error.
*/
function svgToPng(node) {
return new Promise((resolve) => {
const clone = node.cloneNode(true);
const rect = node.getBoundingClientRect();
const width = rect.width || 300;
const height = rect.height || 150;
function inlineStyles(source, target) {
const computed = window.getComputedStyle(source);
const properties = [
'fill',
'stroke',
'stroke-width',
'stroke-linecap',
'stroke-linejoin',
'opacity',
'font-family',
'font-size',
'font-weight',
];
if (computed.fill === 'none') target.setAttribute('fill', 'none');
else if (computed.fill) target.style.fill = computed.fill;
if (computed.stroke === 'none') target.setAttribute('stroke', 'none');
else if (computed.stroke) target.style.stroke = computed.stroke;
properties.forEach((prop) => {
if (prop !== 'fill' && prop !== 'stroke') {
const val = computed[prop];
if (val && val !== 'auto') target.style[prop] = val;
}
});
for (let i = 0; i < source.children.length; i++) {
if (target.children[i]) inlineStyles(source.children[i], target.children[i]);
}
}
inlineStyles(node, clone);
clone.setAttribute('width', width);
clone.setAttribute('height', height);
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
const xml = new XMLSerializer().serializeToString(clone);
const svgUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(xml)}`;
const img = new Image();
img.crossOrigin = 'Anonymous';
img.onload = () => {
const canvas = document.createElement('canvas');
const scale = 3;
canvas.width = width * scale;
canvas.height = height * scale;
const ctx = canvas.getContext('2d');
ctx.scale(scale, scale);
ctx.drawImage(img, 0, 0, width, height);
resolve(canvas.toDataURL('image/png'));
};
img.onerror = () => resolve(null);
img.src = svgUrl;
});
}
/**
* Parses CSS box-shadow properties and converts them into PPTX-compatible shadow options.
* Supports multiple shadows, prioritizing the first visible (non-transparent) outer shadow.
* @param {string} shadowStr - The CSS `box-shadow` string.
* @param {number} scale - The scaling factor.
* @returns {PptxGenJS.ShapeShadow | null} - PPTX shadow options, or null if no visible outer shadow.
*/
function getVisibleShadow(shadowStr, scale) {
if (!shadowStr || shadowStr === 'none') return null;
const shadows = shadowStr.split(/,(?![^()]*\))/);
for (let s of shadows) {
s = s.trim();
if (s.startsWith('rgba(0, 0, 0, 0)')) continue;
const match = s.match(
/(rgba?\([^)]+\)|#[0-9a-fA-F]+)\s+(-?[\d.]+)px\s+(-?[\d.]+)px\s+([\d.]+)px/
);
if (match) {
const colorStr = match[1];
const x = parseFloat(match[2]);
const y = parseFloat(match[3]);
const blur = parseFloat(match[4]);
const distance = Math.sqrt(x * x + y * y);
let angle = Math.atan2(y, x) * (180 / Math.PI);
if (angle < 0) angle += 360;
const colorObj = parseColor(colorStr);
return {
type: 'outer',
angle: angle,
blur: blur * 0.75 * scale,
offset: distance * 0.75 * scale,
color: colorObj.hex || '000000',
opacity: colorObj.opacity,
};
}
}
return null;
}
/**
* Generates an SVG data URL for a linear gradient background with optional border-radius and border.
* Parses CSS linear-gradient string to create SVG <linearGradient> and <rect> elements.
* @param {number} w - Width of the SVG.
* @param {number} h - Height of the SVG.
* @param {string} bgString - The CSS `background-image` string (e.g., `linear-gradient(...)`).
* @param {number} radius - Border radius for the rectangle.
* @param {{color: string, width: number} | null} border - Optional border object with color (hex) and width.
* @returns {string | null} - SVG data URL or null if parsing fails.
*/
function generateGradientSVG(w, h, bgString, radius, border) {
try {
const match = bgString.match(/linear-gradient\((.*)\)/);
if (!match) return null;
const content = match[1];
const parts = content.split(/,(?![^()]*\))/).map((p) => p.trim());
let x1 = '0%',
y1 = '0%',
x2 = '0%',
y2 = '100%';
let stopsStartIdx = 0;
if (parts[0].includes('to right')) {
x1 = '0%';
x2 = '100%';
y2 = '0%';
stopsStartIdx = 1;
} else if (parts[0].includes('to left')) {
x1 = '100%';
x2 = '0%';
y2 = '0%';
stopsStartIdx = 1;
} else if (parts[0].includes('to top')) {
y1 = '100%';
y2 = '0%';
stopsStartIdx = 1;
} else if (parts[0].includes('to bottom')) {
y1 = '0%';
y2 = '100%';
stopsStartIdx = 1;
}
let stopsXML = '';
const stopParts = parts.slice(stopsStartIdx);
stopParts.forEach((part, idx) => {
let color = part;
let offset = Math.round((idx / (stopParts.length - 1)) * 100) + '%';
const posMatch = part.match(/(.*?)\s+(\d+(\.\d+)?%?)$/);
if (posMatch) {
color = posMatch[1];
offset = posMatch[2];
}
let opacity = 1;
if (color.includes('rgba')) {
const rgba = color.match(/[\d.]+/g);
if (rgba && rgba.length > 3) {
opacity = rgba[3];
color = `rgb(${rgba[0]},${rgba[1]},${rgba[2]})`;
}
}
stopsXML += `<stop offset="${offset}" stop-color="${color}" stop-opacity="${opacity}"/>`;
});
let strokeAttr = '';
if (border) {
strokeAttr = `stroke="#${border.color}" stroke-width="${border.width}"`;
}
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
<defs><linearGradient id="grad" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}">${stopsXML}</linearGradient></defs>
<rect x="0" y="0" width="${w}" height="${h}" rx="${radius}" ry="${radius}" fill="url(#grad)" ${strokeAttr} />
</svg>`;
return 'data:image/svg+xml;base64,' + btoa(svg);
} catch {
return null;
}
}
/**
* Generates an SVG data URL for a blurred rectangle or ellipse, used for soft-edge effects.
* @param {number} w - Original width of the element.
* @param {number} h - Original height of the element.
* @param {string} color - Hex color of the shape (without #).
* @param {number} radius - Border radius of the shape.
* @param {number} blurPx - Blur radius in pixels for the SVG filter.
* @returns {{data: string, padding: number}} - Object containing SVG data URL and calculated padding.
*/
function generateBlurredSVG(w, h, color, radius, blurPx) {
const padding = blurPx * 3;
const fullW = w + padding * 2;
const fullH = h + padding * 2;
const x = padding;
const y = padding;
let shapeTag = '';
const isCircle = radius >= Math.min(w, h) / 2 - 1 && Math.abs(w - h) < 2;
if (isCircle) {
const cx = x + w / 2;
const cy = y + h / 2;
const rx = w / 2;
const ry = h / 2;
shapeTag = `<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}" fill="#${color}" filter="url(#f1)" />`;
} else {
shapeTag = `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${radius}" ry="${radius}" fill="#${color}" filter="url(#f1)" />`;
}
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="${fullW}" height="${fullH}" viewBox="0 0 ${fullW} ${fullH}">
<defs>
<filter id="f1" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceGraphic" stdDeviation="${blurPx}" />
</filter>
</defs>
${shapeTag}
</svg>`;
return {
data: 'data:image/svg+xml;base64,' + btoa(svg),
padding: padding,
};
}
// src/image-processor.js
async function getProcessedImage(src, targetW, targetH, radius) {
return new Promise((resolve) => {
const img = new Image();
img.crossOrigin = 'Anonymous'; // Critical for canvas manipulation
img.onload = () => {
const canvas = document.createElement('canvas');
// Double resolution for better quality
const scale = 2;
canvas.width = targetW * scale;
canvas.height = targetH * scale;
const ctx = canvas.getContext('2d');
ctx.scale(scale, scale);
// 1. Draw the Mask (Rounded Rect)
ctx.beginPath();
if (ctx.roundRect) {
ctx.roundRect(0, 0, targetW, targetH, radius);
} else {
// Fallback for older browsers if needed
ctx.rect(0, 0, targetW, targetH);
}
ctx.fillStyle = '#000';
ctx.fill();
// 2. Composite Source-In
ctx.globalCompositeOperation = 'source-in';
// 3. Draw Image (Object Cover Logic)
const wRatio = targetW / img.width;
const hRatio = targetH / img.height;
const maxRatio = Math.max(wRatio, hRatio);
const renderW = img.width * maxRatio;
const renderH = img.height * maxRatio;
const renderX = (targetW - renderW) / 2;
const renderY = (targetH - renderH) / 2;
ctx.drawImage(img, renderX, renderY, renderW, renderH);
resolve(canvas.toDataURL('image/png'));
};
img.onerror = () => resolve(null);
img.src = src;
});
}
// src/index.js
const PPI = 96;
const PX_TO_INCH = 1 / PPI;
/**
* Main export function. Accepts single element or an array.
* @param {HTMLElement | string | Array<HTMLElement | string>} target - The root element(s) to convert.
* @param {Object} options - { fileName: string }
*/
async function exportToPptx(target, options = {}) {
const pptx = new PptxGenJS();
pptx.layout = 'LAYOUT_16x9';
// Standardize input to an array, ensuring single or multiple elements are handled consistently
const elements = Array.isArray(target) ? target : [target];
for (const el of elements) {
const root = typeof el === 'string' ? document.querySelector(el) : el;
if (!root) {
console.warn('Element not found, skipping slide:', el);
continue;
}
const slide = pptx.addSlide();
await processSlide(root, slide, pptx);
}
const fileName = options.fileName || 'export.pptx';
pptx.writeFile({ fileName });
}
/**
* Worker function to process a single DOM element into a single PPTX slide.
* @param {HTMLElement} root - The root element for this slide.
* @param {PptxGenJS.Slide} slide - The PPTX slide object to add content to.
* @param {PptxGenJS} pptx - The main PPTX instance.
*/
async function processSlide(root, slide, pptx) {
const rootRect = root.getBoundingClientRect();
const PPTX_WIDTH_IN = 10;
const PPTX_HEIGHT_IN = 5.625;
const contentWidthIn = rootRect.width * PX_TO_INCH;
const contentHeightIn = rootRect.height * PX_TO_INCH;
const scale = Math.min(PPTX_WIDTH_IN / contentWidthIn, PPTX_HEIGHT_IN / contentHeightIn);
const layoutConfig = {
rootX: rootRect.x,
rootY: rootRect.y,
scale: scale,
offX: (PPTX_WIDTH_IN - contentWidthIn * scale) / 2,
offY: (PPTX_HEIGHT_IN - contentHeightIn * scale) / 2,
};
const renderQueue = [];
let domOrderCounter = 0;
async function collect(node) {
const order = domOrderCounter++; // Assign a DOM order for z-index tie-breaking
const result = await createRenderItem(node, layoutConfig, order, pptx);
if (result) {
if (result.items) renderQueue.push(...result.items); // Add any generated render items to the queue
if (result.stopRecursion) return; // Stop processing children if the item fully consumed the node
}
for (const child of node.children) await collect(child);
}
await collect(root);
renderQueue.sort((a, b) => {
if (a.zIndex !== b.zIndex) return a.zIndex - b.zIndex;
return a.domOrder - b.domOrder;
});
for (const item of renderQueue) {
if (item.type === 'shape') slide.addShape(item.shapeType, item.options);
if (item.type === 'image') slide.addImage(item.options);
if (item.type === 'text') slide.addText(item.textParts, item.options);
}
}
async function createRenderItem(node, config, domOrder, pptx) {
if (node.nodeType !== 1) return null;
const style = window.getComputedStyle(node);
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0')
return null;
const rect = node.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return null;
const zIndex = style.zIndex !== 'auto' ? parseInt(style.zIndex) : 0;
const rotation = getRotation(style.transform);
const elementOpacity = parseFloat(style.opacity);
const widthPx = node.offsetWidth || rect.width;
const heightPx = node.offsetHeight || rect.height;
const unrotatedW = widthPx * PX_TO_INCH * config.scale;
const unrotatedH = heightPx * PX_TO_INCH * config.scale;
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
let x = config.offX + (centerX - config.rootX) * PX_TO_INCH * config.scale - unrotatedW / 2;
let y = config.offY + (centerY - config.rootY) * PX_TO_INCH * config.scale - unrotatedH / 2;
let w = unrotatedW;
let h = unrotatedH;
const items = [];
// Image handling for SVG nodes directly
if (node.nodeName.toUpperCase() === 'SVG') {
const pngData = await svgToPng(node);
if (pngData)
items.push({
type: 'image',
zIndex,
domOrder,
options: { data: pngData, x, y, w, h, rotate: rotation },
});
return { items, stopRecursion: true };
}
// Image handling for <img> tags, including rounded corners
if (node.tagName === 'IMG') {
let borderRadius = parseFloat(style.borderRadius) || 0;
if (borderRadius === 0) {
const parentStyle = window.getComputedStyle(node.parentElement);
if (parentStyle.overflow !== 'visible')
borderRadius = parseFloat(parentStyle.borderRadius) || 0;
}
const processed = await getProcessedImage(node.src, widthPx, heightPx, borderRadius);
if (processed)
items.push({
type: 'image',
zIndex,
domOrder,
options: { data: processed, x, y, w, h, rotate: rotation },
});
return { items, stopRecursion: true };
}
const bgColorObj = parseColor(style.backgroundColor);
const bgClip = style.webkitBackgroundClip || style.backgroundClip;
const isBgClipText = bgClip === 'text';
const hasGradient =
!isBgClipText && style.backgroundImage && style.backgroundImage.includes('linear-gradient');
const borderColorObj = parseColor(style.borderColor);
const borderWidth = parseFloat(style.borderWidth);
const hasBorder = borderWidth > 0 && borderColorObj.hex;
const borderInfo = getBorderInfo(style, config.scale);
const hasUniformBorder = borderInfo.type === 'uniform';
const hasCompositeBorder = borderInfo.type === 'composite';
const shadowStr = style.boxShadow;
const hasShadow = shadowStr && shadowStr !== 'none';
const borderRadius = parseFloat(style.borderRadius) || 0;
const softEdge = getSoftEdges(style.filter, config.scale);
let isImageWrapper = false;
const imgChild = Array.from(node.children).find((c) => c.tagName === 'IMG');
if (imgChild) {
const childW = imgChild.offsetWidth || imgChild.getBoundingClientRect().width;
const childH = imgChild.offsetHeight || imgChild.getBoundingClientRect().height;
if (childW >= widthPx - 2 && childH >= heightPx - 2) isImageWrapper = true;
}
let textPayload = null;
const isText = isTextContainer(node);
if (isText) {
const textParts = [];
const isList = style.display === 'list-item';
if (isList) {
const fontSizePt = parseFloat(style.fontSize) * 0.75 * config.scale;
const bulletShift = (parseFloat(style.fontSize) || 16) * PX_TO_INCH * config.scale * 1.5;
x -= bulletShift;
w += bulletShift;
textParts.push({
text: '• ',
options: {
// Default bullet point styling
color: parseColor(style.color).hex || '000000',
fontSize: fontSizePt,
},
});
}
node.childNodes.forEach((child, index) => {
// Process text content, sanitizing whitespace and applying text transformations
let textVal = child.nodeType === 3 ? child.nodeValue : child.textContent;
let nodeStyle = child.nodeType === 1 ? window.getComputedStyle(child) : style;
textVal = textVal.replace(/[\n\r\t]+/g, ' ').replace(/\s{2,}/g, ' ');
if (index === 0 && !isList) textVal = textVal.trimStart();
else if (index === 0) textVal = textVal.trimStart();
if (index === node.childNodes.length - 1) textVal = textVal.trimEnd();
if (nodeStyle.textTransform === 'uppercase') textVal = textVal.toUpperCase();
if (nodeStyle.textTransform === 'lowercase') textVal = textVal.toLowerCase();
if (textVal.length > 0) {
textParts.push({
text: textVal,
options: getTextStyle(nodeStyle, config.scale),
});
}
});
if (textParts.length > 0) {
let align = style.textAlign || 'left';
if (align === 'start') align = 'left';
if (align === 'end') align = 'right';
let valign = 'top';
if (style.alignItems === 'center') valign = 'middle';
if (style.justifyContent === 'center' && style.display.includes('flex')) align = 'center';
const pt = parseFloat(style.paddingTop) || 0;
const pb = parseFloat(style.paddingBottom) || 0;
if (Math.abs(pt - pb) < 2 && bgColorObj.hex) valign = 'middle';
let padding = getPadding(style, config.scale);
if (align === 'center' && valign === 'middle') padding = [0, 0, 0, 0];
textPayload = { text: textParts, align, valign, inset: padding };
}
}
if (hasGradient || (softEdge && bgColorObj.hex && !isImageWrapper)) {
let bgData = null;
let padIn = 0;
if (softEdge) {
const svgInfo = generateBlurredSVG(widthPx, heightPx, bgColorObj.hex, borderRadius, softEdge);
bgData = svgInfo.data;
padIn = svgInfo.padding * PX_TO_INCH * config.scale;
} else {
bgData = generateGradientSVG(
widthPx,
heightPx,
style.backgroundImage,
borderRadius,
hasBorder ? { color: borderColorObj.hex, width: borderWidth } : null
);
}
if (bgData) {
items.push({
type: 'image',
zIndex,
domOrder,
options: {
data: bgData,
x: x - padIn,
y: y - padIn,
w: w + padIn * 2,
h: h + padIn * 2,
rotate: rotation,
},
});
}
if (textPayload) {
items.push({
type: 'text',
zIndex: zIndex + 1,
domOrder,
textParts: textPayload.text,
options: {
x,
y,
w,
h,
align: textPayload.align,
valign: textPayload.valign,
inset: textPayload.inset,
rotate: rotation,
margin: 0,
wrap: true,
autoFit: false,
},
});
}
if (hasCompositeBorder) {
// Add border shapes after the main background
const borderItems = createCompositeBorderItems(
borderInfo.sides,
x,
y,
w,
h,
config.scale,
zIndex,
domOrder
);
items.push(...borderItems);
}
} else if (
(bgColorObj.hex && !isImageWrapper) ||
hasUniformBorder ||
hasCompositeBorder ||
hasShadow ||
textPayload
) {
const finalAlpha = elementOpacity * bgColorObj.opacity;
const transparency = (1 - finalAlpha) * 100;
const shapeOpts = {
x,
y,
w,
h,
rotate: rotation,
fill:
bgColorObj.hex && !isImageWrapper
? { color: bgColorObj.hex, transparency: transparency }
: { type: 'none' },
// Only apply line if the border is uniform
line: hasUniformBorder ? borderInfo.options : null,
};
if (hasShadow) {
shapeOpts.shadow = getVisibleShadow(shadowStr, config.scale);
}
const borderRadius = parseFloat(style.borderRadius) || 0;
const widthPx = node.offsetWidth || rect.width;
const heightPx = node.offsetHeight || rect.height;
const isCircle =
borderRadius >= Math.min(widthPx, heightPx) / 2 - 1 && Math.abs(widthPx - heightPx) < 2;
let shapeType = pptx.ShapeType.rect;
if (isCircle) shapeType = pptx.ShapeType.ellipse;
else if (borderRadius > 0) {
shapeType = pptx.ShapeType.roundRect;
shapeOpts.rectRadius = Math.min(1, borderRadius / (Math.min(widthPx, heightPx) / 2));
}
// MERGE TEXT INTO SHAPE (if text exists)
if (textPayload) {
const textOptions = {
shape: shapeType,
...shapeOpts,
align: textPayload.align,
valign: textPayload.valign,
inset: textPayload.inset,
margin: 0,
wrap: true,
autoFit: false,
};
items.push({
type: 'text',
zIndex,
domOrder,
textParts: textPayload.text,
options: textOptions,
});
// If no text, just draw the shape
} else {
items.push({
type: 'shape',
zIndex,
domOrder,
shapeType,
options: shapeOpts,
});
}
// ADD COMPOSITE BORDERS (if they exist)
if (hasCompositeBorder) {
// Generate a single SVG image that contains all the rounded border sides
const borderSvgData = generateCompositeBorderSVG(
widthPx,
heightPx,
borderRadius,
borderInfo.sides
);
if (borderSvgData) {
items.push({
type: 'image',
zIndex: zIndex + 1,
domOrder,
options: {
data: borderSvgData,
x: x,
y: y,
w: w,
h: h,
rotate: rotation,
},
});
}
}
}
return { items, stopRecursion: !!textPayload };
}
/**
* Helper function to create individual border shapes
*/
function createCompositeBorderItems(sides, x, y, w, h, scale, zIndex, domOrder) {
const items = [];
const pxToInch = 1 / 96;
// TOP BORDER
if (sides.top.width > 0) {
items.push({
type: 'shape',
zIndex: zIndex + 1,
domOrder,
shapeType: 'rect',
options: {
x: x,
y: y,
w: w,
h: sides.top.width * pxToInch * scale,
fill: { color: sides.top.color },
},
});
}
// RIGHT BORDER
if (sides.right.width > 0) {
items.push({
type: 'shape',
zIndex: zIndex + 1,
domOrder,
shapeType: 'rect',
options: {
x: x + w - sides.right.width * pxToInch * scale,
y: y,
w: sides.right.width * pxToInch * scale,
h: h,
fill: { color: sides.right.color },
},
});
}
// BOTTOM BORDER
if (sides.bottom.width > 0) {
items.push({
type: 'shape',
zIndex: zIndex + 1,
domOrder,
shapeType: 'rect',
options: {
x: x,
y: y + h - sides.bottom.width * pxToInch * scale,
w: w,
h: sides.bottom.width * pxToInch * scale,
fill: { color: sides.bottom.color },
},
});
}
// LEFT BORDER
if (sides.left.width > 0) {
items.push({
type: 'shape',
zIndex: zIndex + 1,
domOrder,
shapeType: 'rect',
options: {
x: x,
y: y,
w: sides.left.width * pxToInch * scale,
h: h,
fill: { color: sides.left.color },
},
});
}
return items;
}
exports.exportToPptx = exportToPptx;
}));