@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
JavaScript
// 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