UNPKG

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
(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; }));