UNPKG

@imgly/pptx-importer

Version:

Import PowerPoint (PPTX) presentations into IMG.LY's Creative Engine SDK. Platform-agnostic TypeScript library for converting PPTX slides to CE.SDK scenes.

1,587 lines (1,579 loc) 180 kB
// src/lib/pptx-parser/converters/colors.ts var ColorConverter = class { /** * Convert PPTX color specification to RGBA object * * @param color - PPTX color spec * @param themeMap - Theme color map (required for theme colors) * @returns RGBA color object with values 0-1 (e.g., { r: 1, g: 0, b: 0, a: 1 }) * @throws {Error} If theme color requested but themeMap not provided */ toRGBA(color, themeMap) { const hex = this.toHex(color, themeMap); const rgba = this.hexToRGBA(hex); if (color.alpha !== void 0) { rgba.a = color.alpha / 1e5; } return rgba; } /** * Convert PPTX color specification to hex string * * @param color - PPTX color spec * @param themeMap - Theme color map (required for theme colors) * @returns Hex color string with # prefix (e.g., "#FF0000") * @throws {Error} If theme color requested but themeMap not provided */ toHex(color, themeMap) { switch (color.type) { case "rgb": return this.normalize(color.value); case "theme": if (!themeMap) { throw new Error( `Theme color '${color.value}' requested but no theme map provided` ); } const themeColor = themeMap.get(color.value); if (!themeColor) { console.warn( `Theme color '${color.value}' not found in theme map, falling back to black` ); return "#000000"; } return this.normalize(themeColor); case "system": return this.getSystemColor(color.value); case "preset": return this.getPresetColor(color.value); default: console.warn(`Unknown color type: ${color.type}, using black`); return "#000000"; } } /** * Load theme colors from PPTX theme XML * * Parses theme XML and extracts all scheme colors into a map. * Result should be cached by parser for reuse across elements. * * @param themeXml - Parsed theme XML from pptx2json output * @returns Map of theme color names to hex RGB values */ async loadTheme(themeXml) { const themeMap = /* @__PURE__ */ new Map(); try { const clrScheme = themeXml?.["a:theme"]?.[0]?.["a:themeElements"]?.[0]?.["a:clrScheme"]?.[0]; if (!clrScheme) { console.warn("No color scheme found in theme XML"); return themeMap; } const colorNames = [ "a:accent1", "a:accent2", "a:accent3", "a:accent4", "a:accent5", "a:accent6", "a:dk1", "a:dk2", "a:lt1", "a:lt2" ]; for (const colorName of colorNames) { const colorData = clrScheme[colorName]?.[0]; if (!colorData) continue; const srgbClr = colorData["a:srgbClr"]?.[0]?.["$"]?.val; const sysClr = colorData["a:sysClr"]?.[0]?.["$"]?.lastClr; const rgb = srgbClr || sysClr; if (rgb) { const simpleName = colorName.replace("a:", ""); themeMap.set(simpleName, rgb); } } } catch (error) { console.error("Error parsing theme XML:", error); } return themeMap; } /** * Convert hex color string to RGBA object * * @param hex - Hex color string (e.g., "#FF0000") * @returns RGBA color object with values 0-1 */ hexToRGBA(hex) { const normalized = this.normalize(hex); const r = parseInt(normalized.substring(1, 3), 16) / 255; const g = parseInt(normalized.substring(3, 5), 16) / 255; const b = parseInt(normalized.substring(5, 7), 16) / 255; return { r, g, b, a: 1 }; } /** * Normalize hex color string * * Ensures color is uppercase, has # prefix, and is 6-char format. * * @param hex - Hex color string (with or without #, 3 or 6 chars) * @returns Normalized hex string ("#RRGGBB") */ normalize(hex) { let normalized = hex.trim().toUpperCase(); if (normalized.startsWith("#")) { normalized = normalized.substring(1); } if (normalized.length === 3) { normalized = normalized.split("").map((char) => char + char).join(""); } if (normalized.length !== 6) { console.warn(`Invalid hex color length: ${hex}, using black`); return "#000000"; } return "#" + normalized; } /** * Get system color mapping * * @param systemColor - System color name (e.g., "windowText") * @returns Hex color string */ getSystemColor(systemColor) { const systemColors = { windowText: "#000000", window: "#FFFFFF", btnFace: "#F0F0F0", btnText: "#000000", highlight: "#0078D7", highlightText: "#FFFFFF" }; return systemColors[systemColor] || "#000000"; } /** * Get preset color mapping * * @param presetColor - Preset color name (e.g., "red", "blue") * @returns Hex color string */ getPresetColor(presetColor) { const presetColors = { black: "#000000", white: "#FFFFFF", red: "#FF0000", green: "#00FF00", blue: "#0000FF", yellow: "#FFFF00", cyan: "#00FFFF", magenta: "#FF00FF", gray: "#808080", silver: "#C0C0C0" }; return presetColors[presetColor.toLowerCase()] || "#000000"; } }; // src/lib/pptx-parser/converters/gradients.ts var GradientConverter = class { colorConverter; constructor() { this.colorConverter = new ColorConverter(); } /** * Extract gradient from PPTX XML * * Parses <a:gradFill> element and extracts gradient type, stops, and direction. * * @param gradFillXml - Raw gradient fill XML from PPTX * @returns Gradient specification or undefined if not a valid gradient */ extractGradient(gradFillXml) { if (!gradFillXml) return void 0; const gsLst = gradFillXml["a:gsLst"]?.[0]; if (!gsLst) return void 0; const stops = this.extractGradientStops(gsLst); if (stops.length === 0) return void 0; let type = "linear"; let angle; let direction; const lin = gradFillXml["a:lin"]?.[0]; if (lin) { type = "linear"; const ang = lin["$"]?.ang; if (ang) { angle = parseInt(ang, 10) / 6e4; angle = (angle % 360 + 360) % 360; } } const path = gradFillXml["a:path"]?.[0]; if (path) { const pathType = path["$"]?.path; if (pathType === "circle") { type = "radial"; } else if (pathType === "rect") { type = "rect"; } else if (pathType === "shape") { type = "path"; } } return { type, stops, angle, direction }; } /** * Extract gradient stops from gradient stop list * * Parses <a:gsLst> and extracts position and color for each stop. * * @param gsLst - Gradient stop list XML * @returns Array of gradient stops */ extractGradientStops(gsLst) { const stops = []; const gs = gsLst["a:gs"] || []; for (const stop of gs) { const position = parseInt(stop["$"]?.pos || "0", 10); const color = this.extractStopColor(stop); if (color) { stops.push({ position, color }); } } return stops; } /** * Extract color from gradient stop * * Parses color element inside gradient stop. * * @param stop - Gradient stop XML * @returns Color specification */ extractStopColor(stop) { if (stop["a:srgbClr"]) { const rgb = stop["a:srgbClr"][0]["$"]?.val; const alpha = this.extractAlpha(stop["a:srgbClr"][0]); return rgb ? { type: "rgb", value: rgb, alpha } : void 0; } if (stop["a:schemeClr"]) { const scheme = stop["a:schemeClr"][0]["$"]?.val; const alpha = this.extractAlpha(stop["a:schemeClr"][0]); return scheme ? { type: "theme", value: scheme, alpha } : void 0; } if (stop["a:sysClr"]) { const sys = stop["a:sysClr"][0]["$"]?.val; const alpha = this.extractAlpha(stop["a:sysClr"][0]); return sys ? { type: "system", value: sys, alpha } : void 0; } if (stop["a:prstClr"]) { const preset = stop["a:prstClr"][0]["$"]?.val; const alpha = this.extractAlpha(stop["a:prstClr"][0]); return preset ? { type: "preset", value: preset, alpha } : void 0; } return void 0; } /** * Extract alpha/transparency from color modifiers * * @param colorXml - Color XML element * @returns Alpha value (0-100000) or undefined */ extractAlpha(colorXml) { const alpha = colorXml["a:alpha"]?.[0]?.["$"]?.val; return alpha ? parseInt(alpha, 10) : void 0; } /** * Convert PPTX gradient to CE.SDK gradient format * * Maps PPTX gradient with theme color resolution to CE.SDK gradient structure. * * @param gradient - PPTX gradient specification * @param themeMap - Optional theme color map for resolving theme colors * @returns CE.SDK gradient structure */ toCESDK(gradient, themeMap) { const stops = gradient.stops.map((stop) => { const rgba = this.colorConverter.toRGBA(stop.color, themeMap); return { stop: stop.position / 1e5, // Convert from 0-100000 to 0-1 color: rgba }; }); return { type: this.mapGradientType(gradient.type), stops, rotation: gradient.angle ?? 0 }; } /** * Map PPTX gradient type to CE.SDK gradient type * * @param type - PPTX gradient type * @returns CE.SDK gradient type ('Linear' or 'Radial') */ mapGradientType(type) { switch (type) { case "linear": return "Linear"; case "radial": return "Radial"; case "path": console.warn(`Unsupported gradient type 'path' - falling back to radial gradient. Visual appearance may differ.`); return "Radial"; case "rect": console.warn(`Unsupported gradient type 'rect' - falling back to radial gradient. Visual appearance may differ.`); return "Radial"; default: console.warn(`Unknown gradient type '${type}' - falling back to linear gradient.`); return "Linear"; } } }; // src/lib/pptx-parser/parsers/slide-parser.ts var SlideParser = class { gradientConverter; constructor() { this.gradientConverter = new GradientConverter(); } /** * Parse slide data from pptx2json output * * Extracts slide dimensions, background, and all elements from a specific slide. * * @param pptxData - Full pptx2json output object * @param slideNumber - Slide number (1-indexed) * @param rawXml - Optional raw XML string for z-order preservation * @returns Parsed slide data with dimensions and elements * @throws {Error} If slide not found or invalid structure */ parseSlide(pptxData, slideNumber, rawXml) { const { width, height } = this.getSlideDimensions(pptxData); const slideKey = `ppt/slides/slide${slideNumber}.xml`; const slideXml = pptxData[slideKey]; if (!slideXml) { throw new Error(`Slide ${slideNumber} not found in PPTX data`); } const { background, backgroundGradient } = this.extractBackground(slideXml); const elements = this.extractElements(slideXml, rawXml); return { width, height, background, backgroundGradient, elements }; } /** * Get total slide count from PPTX data * * Counts slide{n}.xml entries in pptx2json output. * * @param pptxData - Full pptx2json output object * @returns Number of slides in presentation */ getSlideCount(pptxData) { const slideKeys = Object.keys(pptxData).filter( (key) => key.match(/^ppt\/slides\/slide\d+\.xml$/) ); return slideKeys.length; } /** * Get slide dimensions from presentation.xml * * Extracts slide width and height in EMUs from presentation settings. * * @param pptxData - Full pptx2json output object * @returns Slide dimensions in EMUs * @throws {Error} If presentation.xml not found or malformed */ getSlideDimensions(pptxData) { const presentationXml = pptxData["ppt/presentation.xml"]; if (!presentationXml) { throw new Error("presentation.xml not found in PPTX data"); } const presentation = Array.isArray(presentationXml["p:presentation"]) ? presentationXml["p:presentation"][0] : presentationXml["p:presentation"]; const sldSzArray = presentation?.["p:sldSz"]; const sldSz = Array.isArray(sldSzArray) ? sldSzArray[0]?.["$"] : sldSzArray?.["$"]; if (!sldSz) { throw new Error("Slide size not found in presentation.xml"); } const width = parseInt(sldSz.cx, 10); const height = parseInt(sldSz.cy, 10); if (isNaN(width) || isNaN(height)) { throw new Error("Invalid slide dimensions in presentation.xml"); } return { width, height }; } /** * Extract background from slide XML * * Looks for solid fill or gradient fill background in slide properties. * Returns undefined if no background specified (inherits from theme). * * @param slideXml - Parsed slide XML from pptx2json * @returns Object with background color and/or gradient specification */ extractBackground(slideXml) { const slide = Array.isArray(slideXml?.["p:sld"]) ? slideXml["p:sld"][0] : slideXml?.["p:sld"]; const cSld = Array.isArray(slide?.["p:cSld"]) ? slide["p:cSld"][0] : slide?.["p:cSld"]; const bg = Array.isArray(cSld?.["p:bg"]) ? cSld["p:bg"][0] : cSld?.["p:bg"]; const bgPr = Array.isArray(bg?.["p:bgPr"]) ? bg["p:bgPr"][0] : bg?.["p:bgPr"]; if (!bgPr) { return {}; } const gradFill = bgPr["a:gradFill"]?.[0]; if (gradFill) { const backgroundGradient = this.gradientConverter.extractGradient(gradFill); if (backgroundGradient) { return { backgroundGradient }; } } const solidFill = bgPr["a:solidFill"]?.[0]; if (solidFill) { if (solidFill["a:srgbClr"]) { const rgb = solidFill["a:srgbClr"][0]["$"]?.val; if (rgb) { return { background: { type: "rgb", value: rgb } }; } } if (solidFill["a:schemeClr"]) { const scheme = solidFill["a:schemeClr"][0]["$"]?.val; if (scheme) { return { background: { type: "theme", value: scheme } }; } } if (solidFill["a:sysClr"]) { const sys = solidFill["a:sysClr"][0]["$"]?.val; if (sys) { return { background: { type: "system", value: sys } }; } } if (solidFill["a:prstClr"]) { const preset = solidFill["a:prstClr"][0]["$"]?.val; if (preset) { return { background: { type: "preset", value: preset } }; } } } return {}; } /** * Extract all elements from slide XML * * Enumerates shapes, text, images, and groups from p:spTree. * Elements are returned in z-order (as they appear in XML). * Delegates detailed property extraction to ElementParser. * * IMPORTANT: We must maintain document order to preserve z-order. * The pptx2json parser groups elements by type, losing the original order. * If raw XML is provided, we parse it to get the true element order. * * @param slideXml - Parsed slide XML from pptx2json * @param rawXml - Optional raw XML string for accurate z-order * @returns Array of element references (with type and raw XML) */ extractElements(slideXml, rawXml) { const slide = Array.isArray(slideXml?.["p:sld"]) ? slideXml["p:sld"][0] : slideXml?.["p:sld"]; const cSld = Array.isArray(slide?.["p:cSld"]) ? slide["p:cSld"][0] : slide?.["p:cSld"]; const spTreeArray = cSld?.["p:spTree"]; const spTree = Array.isArray(spTreeArray) ? spTreeArray[0] : spTreeArray; if (!spTree) { console.warn("No shape tree found in slide, returning empty element list"); return []; } const shapes = spTree["p:sp"] || []; const pictures = spTree["p:pic"] || []; const groups = spTree["p:grpSp"] || []; const elements = []; if (rawXml) { const elementOrder = this.extractElementOrderFromRawXml(rawXml); const shapeMap = new Map(shapes.map( (s) => [s["p:nvSpPr"]?.[0]?.["p:cNvPr"]?.[0]?.["$"]?.name, s] )); const pictureMap = new Map(pictures.map( (p) => [p["p:nvPicPr"]?.[0]?.["p:cNvPr"]?.[0]?.["$"]?.name, p] )); const groupMap = new Map(groups.map( (g) => [g["p:nvGrpSpPr"]?.[0]?.["p:cNvPr"]?.[0]?.["$"]?.name, g] )); for (const { type, name } of elementOrder) { if (type === "shape" && shapeMap.has(name)) { elements.push({ _raw: shapeMap.get(name), _type: "shape" }); } else if (type === "picture" && pictureMap.has(name)) { elements.push({ _raw: pictureMap.get(name), _type: "picture" }); } else if (type === "group" && groupMap.has(name)) { elements.push({ _raw: groupMap.get(name), _type: "group" }); } } } else { const allElements = []; shapes.forEach((shape, i) => { allElements.push({ element: shape, type: "shape", index: this.getElementIndex(spTree, "p:sp", i) }); }); pictures.forEach((pic, i) => { allElements.push({ element: pic, type: "picture", index: this.getElementIndex(spTree, "p:pic", i) }); }); groups.forEach((grp, i) => { allElements.push({ element: grp, type: "group", index: this.getElementIndex(spTree, "p:grpSp", i) }); }); allElements.sort((a, b) => a.index - b.index); for (const { element, type } of allElements) { elements.push({ _raw: element, _type: type }); } } return elements; } /** * Get the document order index for an element * * Attempts to determine the original position of an element in the XML document. * Since pptx2json groups elements by type, we need to reconstruct the order. * * We use Object.keys(spTree) to get the keys in their original order, then calculate * the index based on which key the element belongs to and its position within that array. * * @param spTree - The shape tree object * @param elementKey - The key for the element type (e.g., 'p:sp', 'p:pic', 'p:grpSp') * @param arrayIndex - The index within the element type array * @returns Estimated document order index */ getElementIndex(spTree, elementKey, arrayIndex) { const keys = Object.keys(spTree); const keyIndex = keys.indexOf(elementKey); if (keyIndex === -1) { return arrayIndex + 1e3; } let index = 0; for (let i = 0; i < keyIndex; i++) { const key = keys[i]; const value = spTree[key]; if (Array.isArray(value) && (key === "p:sp" || key === "p:pic" || key === "p:grpSp")) { index += value.length; } } index += arrayIndex; return index; } /** * Extract element order from raw XML string * * Parses the raw XML to find all p:sp, p:pic, and p:grpSp elements in document order. * Returns an array of {type, name} objects representing the z-order. * * @param rawXml - Raw XML string from slide file * @returns Array of elements in document order */ extractElementOrderFromRawXml(rawXml) { const spTreeMatch = rawXml.match(/<p:spTree[^>]*>([\s\S]*)<\/p:spTree>/); if (!spTreeMatch) { return []; } const spTreeContent = spTreeMatch[1]; const matches = []; const openTagRegex = /<(p:sp|p:pic|p:grpSp)(?:\s|>)/g; let match; while ((match = openTagRegex.exec(spTreeContent)) !== null) { const tagType = match[1]; const tagPosition = match.index; const lookAhead = spTreeContent.substring(tagPosition, tagPosition + 500); const nameMatch = lookAhead.match(/<p:cNvPr[^>]*name="([^"]*)"/); if (nameMatch) { let type; if (tagType === "p:sp") { type = "shape"; } else if (tagType === "p:pic") { type = "picture"; } else { type = "group"; } matches.push({ type, name: nameMatch[1], position: tagPosition }); } } matches.sort((a, b) => a.position - b.position); return matches.map((m) => ({ type: m.type, name: m.name })); } }; // src/lib/pptx-parser/parsers/theme-parser.ts var ThemeParser = class { /** * Parse theme colors from theme XML * * Extracts all scheme colors from pptx2json theme XML output. * Builds map of theme color names (accent1, dk1, etc.) to RGB hex values. * * @param pptxData - Full pptx2json output object * @returns Map of theme color names to RGB hex strings */ parseThemeColors(pptxData) { const themeMap = /* @__PURE__ */ new Map(); const themeXml = pptxData["ppt/theme/theme1.xml"]; if (!themeXml) { console.warn("Theme XML not found in PPTX data, returning empty color map"); return themeMap; } try { const clrScheme = themeXml?.["a:theme"]?.[0]?.["a:themeElements"]?.[0]?.["a:clrScheme"]?.[0]; if (!clrScheme) { console.warn("Color scheme not found in theme XML"); return themeMap; } const colorNames = [ "a:accent1", "a:accent2", "a:accent3", "a:accent4", "a:accent5", "a:accent6", "a:dk1", "a:dk2", "a:lt1", "a:lt2", "a:folHlink", // Followed hyperlink "a:hlink" // Hyperlink ]; for (const colorName of colorNames) { const colorData = clrScheme[colorName]?.[0]; if (!colorData) continue; const srgbClr = colorData["a:srgbClr"]?.[0]?.["$"]?.val; const sysClr = colorData["a:sysClr"]?.[0]?.["$"]?.lastClr; const rgb = srgbClr || sysClr; if (rgb) { const simpleName = colorName.replace("a:", ""); themeMap.set(simpleName, rgb); } } } catch (error) { console.error("Error parsing theme XML:", error); } return themeMap; } /** * Resolve scheme color reference to RGB hex * * Looks up theme color name in provided theme map. * Returns black (#000000) as fallback if color not found. * * @param colorName - Theme color name (e.g., "accent1", "dk1") * @param themeMap - Theme color map from parseThemeColors * @returns RGB hex string (with # prefix) */ resolveSchemeColor(colorName, themeMap) { const rgb = themeMap.get(colorName); if (!rgb) { console.warn(`Theme color '${colorName}' not found in theme map, using black`); return "000000"; } return rgb; } /** * Get default theme color map * * Returns a minimal theme color map with Office default colors. * Used as fallback when theme XML not available. * * @returns Default theme color map */ getDefaultTheme() { const defaultMap = /* @__PURE__ */ new Map(); defaultMap.set("dk1", "000000"); defaultMap.set("lt1", "FFFFFF"); defaultMap.set("dk2", "44546A"); defaultMap.set("lt2", "E7E6E6"); defaultMap.set("accent1", "4472C4"); defaultMap.set("accent2", "ED7D31"); defaultMap.set("accent3", "A5A5A5"); defaultMap.set("accent4", "FFC000"); defaultMap.set("accent5", "5B9BD5"); defaultMap.set("accent6", "70AD47"); defaultMap.set("hlink", "0563C1"); defaultMap.set("folHlink", "954F72"); return defaultMap; } /** * Validate theme map completeness * * Checks if theme map contains all expected scheme colors. * Logs warnings for missing colors. * * @param themeMap - Theme color map to validate * @returns True if theme map contains all expected colors */ validateThemeMap(themeMap) { const requiredColors = ["dk1", "lt1", "dk2", "lt2", "accent1", "accent2", "accent3", "accent4", "accent5", "accent6"]; let isComplete = true; for (const colorName of requiredColors) { if (!themeMap.has(colorName)) { console.warn(`Theme map missing required color: ${colorName}`); isComplete = false; } } return isComplete; } }; // src/lib/pptx-parser/converters/units.ts var UnitConverter = class { /** * Convert EMUs (English Metric Units) to CE.SDK design units * * CE.SDK scene uses Pixel design unit at 300 DPI by default. * PPTX EMUs must be converted to pixels at this DPI. * * Formula: pixels = (emus / 914400) * 300 * Simplified: pixels = emus / 3048 * Where: * - 914400 = EMUs per inch (Office Open XML standard) * - 300 = DPI of CE.SDK scene (scene/dpi property) * * @param emus - Value in EMUs * @returns Value in pixels at 300 DPI (for CE.SDK design units) */ emuToPixels(emus) { return emus / 3048; } /** * Convert inches to pixels at 96 DPI * * Used in test helpers for fixture generation. * * Formula: pixels = inches * 96 * * @param inches - Value in inches * @returns Value in pixels */ inchesToPixels(inches) { return inches * 96; } /** * Convert EMUs to points * * Used for stroke width and other point-based properties. * * Formula: points = (emus / 914400) * 72 * Where 72 = points per inch * * @param emus - Value in EMUs * @returns Value in points */ emuToPoints(emus) { const inches = emus / 914400; return inches * 72; } /** * Convert PPTX font size (100ths of point) to points * * PPTX stores font size as integer in 100ths of a point. * Example: 4500 → 45pt * * Formula: points = pptxFontSize / 100 * * @param pptxFontSize - Font size in 100ths of a point * @returns Font size in points */ pptxFontSizeToPoints(pptxFontSize) { return pptxFontSize / 100; } /** * Convert PPTX rotation (60,000ths of degree) to degrees * * PPTX stores rotation as integer in 60,000ths of a degree. * Example: 900000 → 15° * * Formula: degrees = pptxRotation / 60000 * * @param pptxRotation - Rotation in 60,000ths of a degree * @returns Rotation in degrees */ pptxRotationToDegrees(pptxRotation) { return pptxRotation / 6e4; } /** * Convert degrees to radians * * CE.SDK uses radians for rotation, but PPTX uses degrees. * This converts degrees to radians for CE.SDK API calls. * * Formula: radians = degrees * (PI / 180) * * @param degrees - Rotation in degrees * @returns Rotation in radians */ degreesToRadians(degrees) { return degrees * (Math.PI / 180); } /** * Convert PPTX character spacing (points) to CE.SDK letter spacing (em units) * * PPTX stores character spacing in points (absolute spacing between characters). * CE.SDK uses em units (relative to font size) for letter spacing. * * Formula: letterSpacing = spacingInPoints / fontSize * Where: * - spacingInPoints is in points (already converted from 100ths of a point) * - fontSize is in points * - Result is in em units (1 em = 1 font size) * * @param spacingInPoints - Character spacing in points (can be negative) * @param fontSize - Font size in points * @returns Letter spacing in em units */ characterSpacingPointsToEm(spacingInPoints, fontSize) { return spacingInPoints / fontSize; } }; // src/lib/pptx-parser/interfaces.ts var CE_SDK_BLOCK_TYPES = { TEXT: "//ly.img.ubq/text", GRAPHIC: "//ly.img.ubq/graphic", IMAGE: "//ly.img.ubq/image", PAGE: "//ly.img.ubq/page", GROUP: "//ly.img.ubq/group" }; // src/lib/pptx-parser/converters/shadows.ts var ShadowConverter = class { colorConverter; unitConverter; constructor() { this.colorConverter = new ColorConverter(); this.unitConverter = new UnitConverter(); } /** * Extract shadow effect from PPTX XML * * Parses <a:effectLst> and extracts shadow properties. * * @param spPr - Shape properties XML containing effects * @returns Shadow specification or undefined if no shadow */ extractShadow(spPr) { const effectLst = spPr?.["a:effectLst"]?.[0]; if (!effectLst) return void 0; const outerShdw = effectLst["a:outerShdw"]?.[0]; if (outerShdw) { return this.extractOuterShadow(outerShdw); } const innerShdw = effectLst["a:innerShdw"]?.[0]; if (innerShdw) { return this.extractInnerShadow(innerShdw); } return void 0; } /** * Extract outer shadow properties * * Parses <a:outerShdw> element. * * @param outerShdw - Outer shadow XML * @returns Shadow specification */ extractOuterShadow(outerShdw) { const attrs = outerShdw["$"]; const blurRad = attrs?.blurRad ? parseInt(attrs.blurRad, 10) : void 0; const dist = attrs?.dist ? parseInt(attrs.dist, 10) : void 0; const dir = attrs?.dir ? parseInt(attrs.dir, 10) / 6e4 : void 0; const color = this.extractShadowColor(outerShdw); if (!color) return void 0; return { type: "outer", color, blur: blurRad, distance: dist, angle: dir }; } /** * Extract inner shadow properties * * Parses <a:innerShdw> element. * * @param innerShdw - Inner shadow XML * @returns Shadow specification */ extractInnerShadow(innerShdw) { const attrs = innerShdw["$"]; const blurRad = attrs?.blurRad ? parseInt(attrs.blurRad, 10) : void 0; const dist = attrs?.dist ? parseInt(attrs.dist, 10) : void 0; const dir = attrs?.dir ? parseInt(attrs.dir, 10) / 6e4 : void 0; const color = this.extractShadowColor(innerShdw); if (!color) return void 0; return { type: "inner", color, blur: blurRad, distance: dist, angle: dir }; } /** * Extract color from shadow element * * @param shadowXml - Shadow XML element * @returns Color specification */ extractShadowColor(shadowXml) { if (shadowXml["a:srgbClr"]) { const rgb = shadowXml["a:srgbClr"][0]["$"]?.val; const alpha = this.extractAlpha(shadowXml["a:srgbClr"][0]); return rgb ? { type: "rgb", value: rgb, alpha } : void 0; } if (shadowXml["a:schemeClr"]) { const scheme = shadowXml["a:schemeClr"][0]["$"]?.val; const alpha = this.extractAlpha(shadowXml["a:schemeClr"][0]); return scheme ? { type: "theme", value: scheme, alpha } : void 0; } if (shadowXml["a:sysClr"]) { const sys = shadowXml["a:sysClr"][0]["$"]?.val; const alpha = this.extractAlpha(shadowXml["a:sysClr"][0]); return sys ? { type: "system", value: sys, alpha } : void 0; } if (shadowXml["a:prstClr"]) { const preset = shadowXml["a:prstClr"][0]["$"]?.val; const alpha = this.extractAlpha(shadowXml["a:prstClr"][0]); return preset ? { type: "preset", value: preset, alpha } : void 0; } return { type: "rgb", value: "000000", alpha: 5e4 }; } /** * Extract alpha/transparency from color modifiers * * @param colorXml - Color XML element * @returns Alpha value (0-100000) or undefined */ extractAlpha(colorXml) { const alpha = colorXml["a:alpha"]?.[0]?.["$"]?.val; return alpha ? parseInt(alpha, 10) : void 0; } /** * Convert PPTX shadow to CE.SDK drop shadow format * * Maps PPTX shadow with theme color resolution to CE.SDK shadow properties. * * @param shadow - PPTX shadow specification * @param themeMap - Optional theme color map for resolving theme colors * @returns CE.SDK shadow properties */ toCESDK(shadow, themeMap) { if (shadow.type === "inner") { console.warn(`Unsupported shadow type 'inner' - converting to outer drop shadow. Visual appearance may differ.`); } const rgba = this.colorConverter.toRGBA(shadow.color, themeMap); let offsetX = 0; let offsetY = 0; if (shadow.distance && shadow.angle !== void 0) { const angleRad = shadow.angle * Math.PI / 180; const distancePixels = this.unitConverter.emuToPixels(shadow.distance); offsetX = Math.cos(angleRad) * distancePixels; offsetY = Math.sin(angleRad) * distancePixels; } const blurRadius = shadow.blur ? this.unitConverter.emuToPixels(shadow.blur) : 10; return { enabled: true, color: rgba, offsetX, offsetY, blurRadius }; } }; // src/lib/pptx-parser/parsers/element-parser.ts var ElementParser = class { gradientConverter; shadowConverter; constructor() { this.gradientConverter = new GradientConverter(); this.shadowConverter = new ShadowConverter(); } /** * Extract transform (position, dimensions, rotation) from element XML * * Reads p:spPr → a:xfrm for position (a:off) and size (a:ext). * All values returned in EMUs. * * @param element - Raw element XML from pptx2json * @returns Transform properties in EMUs * @throws {Error} If transform not found or malformed */ extractTransform(element) { const xfrm = element?.["p:spPr"]?.[0]?.["a:xfrm"]?.[0]; if (!xfrm) { throw new Error("Transform (a:xfrm) not found in element"); } const off = xfrm["a:off"]?.[0]?.["$"]; const ext = xfrm["a:ext"]?.[0]?.["$"]; if (!off || !ext) { throw new Error("Position (a:off) or extent (a:ext) missing in transform"); } const x = parseInt(off.x, 10); const y = parseInt(off.y, 10); const width = parseInt(ext.cx, 10); const height = parseInt(ext.cy, 10); if (isNaN(x) || isNaN(y) || isNaN(width) || isNaN(height)) { throw new Error("Invalid transform values (non-numeric)"); } let rotation; const rot = xfrm["$"]?.rot; if (rot !== void 0) { rotation = parseInt(rot, 10) / 6e4; if (isNaN(rotation)) { rotation = void 0; } } return { x, y, width, height, rotation }; } /** * Extract text element properties * * Reads text content from p:txBody, including font, size, color, alignment. * Assumes single run for simplicity - concatenates if multiple runs. * * @param shapeXml - Raw shape XML containing text * @returns Text element properties * @throws {Error} If required text properties missing */ extractText(shapeXml) { const transform = this.extractTransform(shapeXml); const name = this.extractName(shapeXml); const txBody = shapeXml?.["p:txBody"]?.[0]; if (!txBody) { throw new Error("Text body (p:txBody) not found in shape"); } const paragraphs = txBody["a:p"] || []; const paragraphTexts = []; const textRuns = []; for (let i = 0; i < paragraphs.length; i++) { const para = paragraphs[i]; let paraText = ""; const runs = para["a:r"] || []; for (const run of runs) { const t = run["a:t"]?.[0]; if (t) { paraText += t; const rPr = run["a:rPr"]?.[0] || {}; const sz = rPr["$"]?.sz; const runFontSize = sz ? parseInt(sz, 10) / 100 : 18; const runFontFamily = rPr["a:latin"]?.[0]?.["$"]?.typeface || "Arial"; let runBold = rPr["$"]?.b === "1"; let runItalic = rPr["$"]?.i === "1"; if (!runBold && /\s+Bold/i.test(runFontFamily)) { runBold = true; } if (!runItalic && /\s+Italic/i.test(runFontFamily)) { runItalic = true; } const runColor = this.extractColor(rPr) || { type: "rgb", value: "000000" }; const spc = rPr["$"]?.spc; const runCharacterSpacing = spc ? parseInt(spc, 10) / 100 : void 0; textRuns.push({ text: t, fontSize: runFontSize, fontFamily: runFontFamily, bold: runBold, italic: runItalic, color: runColor, characterSpacing: runCharacterSpacing }); } } if (paraText) { paragraphTexts.push(paraText); if (i < paragraphs.length - 1) { const lastRun = textRuns[textRuns.length - 1]; if (lastRun) { textRuns.push({ text: "\n", fontSize: lastRun.fontSize, fontFamily: lastRun.fontFamily, bold: lastRun.bold, italic: lastRun.italic, color: lastRun.color, characterSpacing: lastRun.characterSpacing }); } } } } const text = paragraphTexts.join("\n"); const firstRun = textRuns[0] || { fontSize: 18, fontFamily: "Arial", bold: false, italic: false, color: { type: "rgb", value: "000000" } }; const fontSize = firstRun.fontSize; const fontFamily = firstRun.fontFamily; const bold = firstRun.bold; const italic = firstRun.italic; const color = firstRun.color; const characterSpacing = firstRun.characterSpacing; const pPr = paragraphs[0]?.["a:pPr"]?.[0]; const algn = pPr?.["$"]?.algn || "left"; const horizontalAlignment = this.mapAlignment(algn); let lineHeight; const lnSpc = pPr?.["a:lnSpc"]?.[0]; if (lnSpc) { const spcPct = lnSpc["a:spcPct"]?.[0]?.["$"]?.val; const spcPts = lnSpc["a:spcPts"]?.[0]?.["$"]?.val; if (spcPct) { lineHeight = parseInt(spcPct, 10) / 1e5; } else if (spcPts && fontSize) { const lineHeightPts = parseInt(spcPts, 10) / 100; lineHeight = lineHeightPts / fontSize; } } const anchor = txBody["a:bodyPr"]?.[0]?.["$"]?.anchor || "top"; const verticalAlignment = this.mapVerticalAlignment(anchor); return { transform, name, text, textRuns: textRuns.length > 1 ? textRuns : void 0, // Only include if multiple runs fontSize, fontFamily, bold, italic, color, characterSpacing, lineHeight, horizontalAlignment, verticalAlignment }; } /** * Extract shape element properties * * Reads shape type, fill, stroke, corner radius. * * @param shapeXml - Raw shape XML * @returns Shape element properties * @throws {Error} If required shape properties missing */ extractShape(shapeXml) { const transform = this.extractTransform(shapeXml); const name = this.extractName(shapeXml); const spPr = shapeXml?.["p:spPr"]?.[0]; const custGeom = spPr?.["a:custGeom"]?.[0]; let shapeType = "rect"; if (custGeom) { shapeType = "custom"; } else { const prstGeom = spPr?.["a:prstGeom"]?.[0]; shapeType = prstGeom?.["$"]?.prst || "rect"; } const fill = this.extractFillColor(shapeXml); const gradient = this.extractGradientFill(shapeXml); const stroke = this.extractStroke(shapeXml); const shadow = this.extractShadowEffect(shapeXml); const cornerRadius = this.extractCornerRadius(shapeXml); return { transform, name, shapeType, fill, gradient, stroke, shadow, cornerRadius, customGeometry: custGeom // Include raw custom geometry for vector path conversion }; } /** * Extract image element properties * * Reads image reference and transform. * Note: Image data extraction requires relationship lookup (deferred to handler). * * @param picXml - Raw picture XML * @returns Image element properties (imageData is empty placeholder - populated by handler) * @throws {Error} If required image properties missing */ extractImage(picXml) { const transform = this.extractTransform(picXml); const name = this.extractName(picXml); return { transform, name, imageData: "" // Placeholder - populated by handler }; } /** * Extract group element properties * * Reads group transform and recursively extracts children. * * @param grpXml - Raw group XML * @returns Group element properties with children * @throws {Error} If required group properties missing */ extractGroup(grpXml) { const grpSpPr = grpXml?.["p:grpSpPr"]?.[0]; if (!grpSpPr) { throw new Error("Group properties (p:grpSpPr) not found"); } const xfrm = grpSpPr["a:xfrm"]?.[0]; if (!xfrm) { throw new Error("Transform not found in group properties"); } const off = xfrm["a:off"]?.[0]?.["$"]; const ext = xfrm["a:ext"]?.[0]?.["$"]; if (!off || !ext) { throw new Error("Position or extent missing in group transform"); } const transform = { x: parseInt(off.x, 10), y: parseInt(off.y, 10), width: parseInt(ext.cx, 10), height: parseInt(ext.cy, 10) }; const name = this.extractName(grpXml); const chOff = xfrm["a:chOff"]?.[0]?.["$"]; const chExt = xfrm["a:chExt"]?.[0]?.["$"]; let childOffset; let childExtent; if (chOff) { childOffset = { x: parseInt(chOff.x, 10), y: parseInt(chOff.y, 10) }; } if (chExt) { childExtent = { width: parseInt(chExt.cx, 10), height: parseInt(chExt.cy, 10) }; } const children = []; const shapes = grpXml["p:sp"] || []; for (const shape of shapes) { children.push({ _raw: shape, _type: "shape" }); } const pictures = grpXml["p:pic"] || []; for (const pic of pictures) { children.push({ _raw: pic, _type: "picture" }); } const groups = grpXml["p:grpSp"] || []; for (const grp of groups) { children.push({ _raw: grp, _type: "group" }); } return { transform, name, childOffset, childExtent, children }; } /** * Extract element name from cNvPr (non-visual properties) * * @param element - Raw element XML * @returns Element name or "Unnamed" */ extractName(element) { const cNvPr = element?.["p:nvSpPr"]?.[0]?.["p:cNvPr"]?.[0]?.["$"] || element?.["p:nvPicPr"]?.[0]?.["p:cNvPr"]?.[0]?.["$"] || element?.["p:nvGrpSpPr"]?.[0]?.["p:cNvPr"]?.[0]?.["$"]; return cNvPr?.name || "Unnamed"; } /** * Extract color from properties * * Handles srgbClr (RGB), schemeClr (theme), sysClr (system), prstClr (preset). * Also extracts alpha/transparency if present. * * @param props - Properties XML containing color spec * @returns Color specification or undefined */ extractColor(props) { if (props["a:solidFill"]) { const solidFill = props["a:solidFill"][0]; let color; let colorNode; if (solidFill["a:srgbClr"]) { colorNode = solidFill["a:srgbClr"][0]; const rgb = colorNode["$"]?.val; if (rgb) color = { type: "rgb", value: rgb }; } if (solidFill["a:schemeClr"]) { colorNode = solidFill["a:schemeClr"][0]; const scheme = colorNode["$"]?.val; if (scheme) color = { type: "theme", value: scheme }; } if (solidFill["a:sysClr"]) { colorNode = solidFill["a:sysClr"][0]; const sys = colorNode["$"]?.val; if (sys) color = { type: "system", value: sys }; } if (solidFill["a:prstClr"]) { colorNode = solidFill["a:prstClr"][0]; const preset = colorNode["$"]?.val; if (preset) color = { type: "preset", value: preset }; } if (color && colorNode?.["a:alpha"]) { const alphaVal = colorNode["a:alpha"][0]?.["$"]?.val; if (alphaVal !== void 0) { color.alpha = parseInt(alphaVal, 10); } } return color; } return void 0; } /** * Extract fill color from shape properties * * Checks for explicit no-fill (a:noFill) and returns undefined if present. * Otherwise extracts color with potential alpha/transparency. * * @param shapeXml - Raw shape XML * @returns Fill color or undefined (undefined means no fill) */ extractFillColor(shapeXml) { const spPr = shapeXml?.["p:spPr"]?.[0]; if (!spPr) return void 0; if (spPr["a:noFill"]) { return void 0; } if (spPr["a:gradFill"]) { return void 0; } return this.extractColor(spPr); } /** * Extract gradient fill from shape properties * * Parses <a:gradFill> element if present. * * @param shapeXml - Raw shape XML * @returns Gradient specification or undefined */ extractGradientFill(shapeXml) { const spPr = shapeXml?.["p:spPr"]?.[0]; if (!spPr) return void 0; const gradFill = spPr["a:gradFill"]?.[0]; if (!gradFill) return void 0; return this.gradientConverter.extractGradient(gradFill); } /** * Extract shadow effect from shape properties * * Parses <a:effectLst> for shadow effects. * * @param shapeXml - Raw shape XML * @returns Shadow specification or undefined */ extractShadowEffect(shapeXml) { const spPr = shapeXml?.["p:spPr"]?.[0]; if (!spPr) return void 0; return this.shadowConverter.extractShadow(spPr); } /** * Extract stroke properties from shape * * Checks for explicit no-stroke (a:noFill inside a:ln) and returns undefined if present. * Otherwise extracts stroke width, color, and style. * * @param shapeXml - Raw shape XML * @returns Stroke specification or undefined (undefined means no stroke) */ extractStroke(shapeXml) { const spPr = shapeXml?.["p:spPr"]?.[0]; if (!spPr) return void 0; const ln = spPr["a:ln"]?.[0]; if (!ln) return void 0; if (ln["a:noFill"]) { return void 0; } const lnKeys = Object.keys(ln); if (lnKeys.length === 0 || lnKeys.length === 1 && lnKeys[0] === "$" && !ln["$"]) { return void 0; } const w = ln["$"]?.w; const width = w ? parseInt(w, 10) : 12700; const color = this.extractColor(ln); if (!color) { return void 0; } const prstDash = ln["a:prstDash"]?.[0]?.["$"]?.val || "solid"; const dashType = this.mapDashType(prstDash); const cap = ln["$"]?.cap; let join; if (ln["a:bevel"]) { join = "bevel"; } else if (ln["a:miter"]) { join = "miter"; } else if (ln["a:round"]) { join = "round"; } return { width, color, dashType, cap, join }; } /** * Extract corner radius from shape * * @param shapeXml - Raw shape XML * @returns Corner radius in EMUs or undefined */ extractCornerRadius(shapeXml) { const prstGeom = shapeXml?.["p:spPr"]?.[0]?.["a:prstGeom"]?.[0]; if (!prstGeom) return void 0; const avLst = prstGeom["a:avLst"]?.[0]; if (!avLst) return void 0; const gd = avLst["a:gd"]?.[0]; if (!gd) return void 0; const fmla = gd["$"]?.fmla; if (!fmla) return void 0; const match = fmla.match(/val\s+(\d+)/); if (match) { const adjValue = parseInt(match[1], 10); return adjValue; } return void 0; } /** * Map PPTX alignment to CE.SDK alignment * * @param algn - PPTX alignment value * @returns CE.SDK alignment value */ mapAlignment(algn) { switch (algn) { case "l": return "left"; case "ctr": return "center"; case "r": return "right"; case "just": return "justify"; case "dist": return "justify"; default: return "left"; } } /** * Map PPTX vertical anchor to CE.SDK vertical alignment * * @param anchor - PPTX anchor value * @returns CE.SDK vertical alignment */ mapVerticalAlignment(anchor) { switch (anchor) { case "t": return "top"; case "ctr": return "middle"; case "b": return "bottom"; default: return "top"; } } /** * Map PPTX dash type to CE.SDK dash type * * @param prstDash - PPTX preset dash value * @returns CE.SDK dash type */ mapDashType(prstDash) { switch (prstDash) { case "solid": return "solid"; case "dash": return "dash"; case "dot": return "dot"; case "dashDot": return "dashDot"; case "lgDash": return "dash"; case "lgDashDot": return "dashDot"; case "sysDash": return "dash"; case "sysDot": return "dot"; case "sysDashDot": return "dashDot"; default: return "solid"; } } }; // src/lib/pptx-parser/converters/fonts.ts var FontConverter = class { webSafeFonts = [ "Arial", "Arial Black", "Comic Sans MS", "Courier New", "Georgia", "Impact", "Times New Roman", "Trebuchet MS", "Verdana", "Calibri", "Cambria", "Consolas", "Helvetica", "Tahoma" ]; /** * Map PPTX font family to CE.SDK font family * * Attempts to use original font. If unavailable, applies substitution rules. * Strips weight/style suffixes (e.g., "Roboto Bold" → "Roboto"). * Logs warning if substitution occurs (via warning system). * * @param pptxFontFamily - Font family from PPTX * @returns Font mapping with substitution info */ mapFontFamily(pptxFontFamily) { let cleanedFont = pptxFontFamily.replace(/\s+Bold\s+Italics?$/i, "").replace(/\s+Italics?\s+Bold$/i, "").replace(/\s+Bold$/i, "").replace(/\s+Italics?$/i, "").replace(/\s+Regular$/i, "").replace(/\s+Medium$/i, "").replace(/\s+Light$/i, "").replace(/\s+Thin$/i, "").replace(/\s+Black$/i, "").trim(); const substitutions = { // Common premium fonts → web-safe alternatives "Neue Machina": "Arial Black", "Proxima Nova": "Arial", "Futura": "Arial", "Gotham": "Arial", "Avenir": "Arial", "Brandon Grotesque": "Arial" // Add more as needed }; const substitute = substitutions[cleanedFont]; if (substitute) { return { pptxFont: pptxFontFamily, cesdkFont: substitute, substituted: true }; } const isWebSafe = this.isWebSafe(cleanedFont); return { pptxFont: pptxFontFamily, cesdkFont: cleanedFont, substituted: false }; } /** * Get common web-safe font list * * Returns list of fonts expected to be available in browsers. * Used for determining if warning needed for uncommon fonts. * * @returns Array of web-safe font family names */ getWebSafeFonts() { return [...this.webSafeFonts]; } /** * Check if font is web-safe * * @param fontFamily - Font family name * @returns True if font is commonly available */ isWebSafe(fontFamily) { return this.webSafeFonts.some( (webSafe) => webSafe.toL