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

478 lines (435 loc) 16.4 kB
// src/utils.js // Helper to save gradient text export 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', ...}} */ export 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. */ export 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). */ export 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. */ export 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. */ export 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. */ export 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. */ export 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. */ export 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. */ export 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. */ export 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. */ export 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. */ export 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, }; }