From 3eee64afa334c5547d6028bd565f7966a58fd521 Mon Sep 17 00:00:00 2001 From: Sidhaarth Krishnan <161455529+RealSid08@users.noreply.github.com> Date: Sun, 29 Mar 2026 23:31:28 +1100 Subject: [PATCH 1/2] Improve text/shape parsing fidelity for real fixtures --- src/parser.ts | 361 ++++++++++++++++++++------ src/types.ts | 2 + test/fixtures.test.ts | 36 +-- test/fixtures/real/basic-preview.pptx | Bin 4057 -> 4057 bytes test/index.test.ts | 340 ++++++++++++++++++++++++ 5 files changed, 636 insertions(+), 103 deletions(-) diff --git a/src/parser.ts b/src/parser.ts index d76a52b..f719e8b 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -335,6 +335,15 @@ type ElementTransform = { scaleY: number; }; +const DEFAULT_TEXT_INSET_EMU = { + left: 91440, + right: 91440, + top: 45720, + bottom: 45720, +}; + +const EMPTY_TEXT_SHAPE_FALLBACK_MIN_AREA_PX = 650000; + type ElementContext = { relationships: Map; imageMap: Map; @@ -410,7 +419,21 @@ function parseShape( // Check if it's a text element const txBody = sp.querySelector("txBody"); if (txBody) { - return parseTextElement(txBody, position, zIndex, themeColors); + if (hasRenderableTextContent(txBody)) { + return parseTextElement(txBody, position, zIndex, themeColors); + } + + if (isPlaceholderShape(sp)) { + return parseTextElement(txBody, position, zIndex, themeColors); + } + + if (!hasExplicitShapeVisualStyle(sp)) { + return parseTextElement(txBody, position, zIndex, themeColors); + } + + if (!isLargeVisualShape(position)) { + return parseTextElement(txBody, position, zIndex, themeColors); + } } // Check if it's a table @@ -420,8 +443,27 @@ function parseShape( } // Otherwise it's a shape - const prstGeom = sp.querySelector("prstGeom"); - const shapeType = prstGeom?.getAttribute("prst") || "rect"; + const prstGeom = sp.querySelector(":scope > spPr > prstGeom"); + const custGeom = sp.querySelector(":scope > spPr > custGeom"); + const shapeType = prstGeom?.getAttribute("prst") || (custGeom ? "custom" : "rect"); + + let pathData: import("./types").ShapePath[] | undefined; + let viewBox: { x: number; y: number; width: number; height: number } | undefined; + + if (custGeom) { + const pathLst = custGeom.querySelector(":scope > pathLst"); + if (pathLst) { + pathData = parsePathList(pathLst, themeColors); + + const firstPath = pathLst.querySelector(":scope > path"); + const viewWidth = firstPath ? parseFloat(firstPath.getAttribute("w") || "0") : 0; + const viewHeight = firstPath ? parseFloat(firstPath.getAttribute("h") || "0") : 0; + + if (viewWidth > 0 && viewHeight > 0) { + viewBox = { x: 0, y: 0, width: viewWidth, height: viewHeight }; + } + } + } const shapeElement: ShapeElement = { type: "shape", @@ -431,11 +473,55 @@ function parseShape( fillColor: parseFillColor(sp, themeColors), strokeColor: parseStrokeColor(sp, themeColors), strokeWidth: parseStrokeWidth(sp), + pathData, + viewBox, }; return shapeElement; } +function hasRenderableTextContent(txBody: Element): boolean { + for (const node of Array.from(txBody.querySelectorAll("t"))) { + const text = node.textContent || ""; + if (text.trim().length > 0) return true; + } + + if (txBody.querySelector("fld") || txBody.querySelector("br")) { + return true; + } + + return false; +} + +function hasExplicitShapeVisualStyle(sp: Element): boolean { + const spPr = sp.querySelector(":scope > spPr"); + if (!spPr) return false; + + if (spPr.querySelector(":scope > solidFill, :scope > gradFill, :scope > pattFill, :scope > blipFill")) { + return true; + } + + const ln = spPr.querySelector(":scope > ln"); + if (ln?.querySelector(":scope > solidFill, :scope > gradFill, :scope > noFill")) { + return true; + } + + return false; +} + +function isPlaceholderShape(sp: Element): boolean { + return sp.querySelector(":scope > nvSpPr > nvPr > ph") !== null; +} + +function isLargeVisualShape(position: { + x: number; + y: number; + width: number; + height: number; +}): boolean { + return position.width * position.height >= EMPTY_TEXT_SHAPE_FALLBACK_MIN_AREA_PX; +} + /** * Parse text element with rich formatting */ @@ -485,6 +571,11 @@ function parseTextElement( const rIns = bodyPr?.getAttribute("rIns"); const bIns = bodyPr?.getAttribute("bIns"); + const leftInsetEmu = parseOptionalInt(lIns) ?? DEFAULT_TEXT_INSET_EMU.left; + const topInsetEmu = parseOptionalInt(tIns) ?? DEFAULT_TEXT_INSET_EMU.top; + const rightInsetEmu = parseOptionalInt(rIns) ?? DEFAULT_TEXT_INSET_EMU.right; + const bottomInsetEmu = parseOptionalInt(bIns) ?? DEFAULT_TEXT_INSET_EMU.bottom; + return { type: "text", ...position, @@ -503,10 +594,10 @@ function parseTextElement( wrap: wrap !== "none", vertOverflow: vertOverflow || "overflow", horzOverflow: horzOverflow || "overflow", - leftInset: lIns ? parseInt(lIns, 10) / 12700 : undefined, - topInset: tIns ? parseInt(tIns, 10) / 12700 : undefined, - rightInset: rIns ? parseInt(rIns, 10) / 12700 : undefined, - bottomInset: bIns ? parseInt(bIns, 10) / 12700 : undefined, + leftInset: leftInsetEmu / 12700, + topInset: topInsetEmu / 12700, + rightInset: rightInsetEmu / 12700, + bottomInset: bottomInsetEmu / 12700, }; } @@ -1449,7 +1540,7 @@ function parseFillColor(sp: Element, themeColors: Map): string | const solidFill = sp.querySelector("spPr > solidFill"); if (!solidFill) return undefined; - return parseColorValue(solidFill, themeColors); + return parseColorValue(solidFill, themeColors, { includeAlpha: true }); } /** @@ -1462,7 +1553,7 @@ function parseStrokeColor(sp: Element, themeColors: Map): string const solidFill = ln.querySelector("solidFill"); if (!solidFill) return undefined; - return parseColorValue(solidFill, themeColors); + return parseColorValue(solidFill, themeColors, { includeAlpha: true }); } /** @@ -1630,12 +1721,18 @@ async function loadDiagramColors( /** * Parse color value from element (srgbClr or schemeClr) */ -function parseColorValue(element: Element, themeColors: Map): string | undefined { +function parseColorValue( + element: Element, + themeColors: Map, + options?: { includeAlpha?: boolean }, +): string | undefined { + const includeAlpha = options?.includeAlpha === true; + // Check for srgbClr (direct RGB) const srgbClr = element.querySelector("srgbClr"); if (srgbClr) { const val = srgbClr.getAttribute("val"); - if (val) return applyColorTransforms(`#${val}`, srgbClr); + if (val) return applyColorTransforms(`#${val}`, srgbClr, includeAlpha); } // Check for schemeClr (theme color reference) @@ -1645,7 +1742,11 @@ function parseColorValue(element: Element, themeColors: Map): st if (val) { // Look up in theme colors const resolved = themeColors.get(val) || val; - return applyColorTransforms(resolved.startsWith("#") ? resolved : `#${resolved}`, schemeClr); + return applyColorTransforms( + resolved.startsWith("#") ? resolved : `#${resolved}`, + schemeClr, + includeAlpha, + ); } } @@ -1653,24 +1754,31 @@ function parseColorValue(element: Element, themeColors: Map): st const tagName = element.tagName.toLowerCase(); if (tagName === "srgbclr" || tagName.endsWith(":srgbClr")) { const val = element.getAttribute("val"); - if (val) return applyColorTransforms(`#${val}`, element); + if (val) return applyColorTransforms(`#${val}`, element, includeAlpha); } if (tagName === "schemeclr" || tagName.endsWith(":schemeClr")) { const val = element.getAttribute("val"); if (val) { const resolved = themeColors.get(val) || val; - return applyColorTransforms(resolved.startsWith("#") ? resolved : `#${resolved}`, element); + return applyColorTransforms( + resolved.startsWith("#") ? resolved : `#${resolved}`, + element, + includeAlpha, + ); } } return undefined; } -function applyColorTransforms(baseColor: string, colorElement: Element): string { +function applyColorTransforms(baseColor: string, colorElement: Element, includeAlpha = false): string { const rgb = hexToRgb(baseColor); if (!rgb) return baseColor; - let [r, g, b] = rgb; + const hsl = rgbToHsl(rgb[0], rgb[1], rgb[2]); + if (!hsl) return baseColor; + + let [h, s, l] = hsl; const lumMod = parseOptionalInt(colorElement.querySelector(":scope > lumMod")?.getAttribute("val")); const lumOff = parseOptionalInt(colorElement.querySelector(":scope > lumOff")?.getAttribute("val")); @@ -1680,28 +1788,51 @@ function applyColorTransforms(baseColor: string, colorElement: Element): string if (lumMod !== undefined || lumOff !== undefined) { const mod = (lumMod ?? 100000) / 100000; const off = (lumOff ?? 0) / 100000; - r = clampColor(Math.round(r * mod + 255 * off)); - g = clampColor(Math.round(g * mod + 255 * off)); - b = clampColor(Math.round(b * mod + 255 * off)); + l = clampUnit(l * mod + off); } if (tint !== undefined) { const factor = tint / 100000; - r = clampColor(Math.round(r + (255 - r) * factor)); - g = clampColor(Math.round(g + (255 - g) * factor)); - b = clampColor(Math.round(b + (255 - b) * factor)); + l = clampUnit(l + (1 - l) * factor); } if (shade !== undefined) { const factor = shade / 100000; - r = clampColor(Math.round(r * factor)); - g = clampColor(Math.round(g * factor)); - b = clampColor(Math.round(b * factor)); + l = clampUnit(l * factor); + } + + const [r, g, b] = hslToRgb(h, s, l); + + if (includeAlpha) { + const alpha = parseAlphaTransform(colorElement); + if (alpha < 1) { + return `rgba(${r}, ${g}, ${b}, ${formatAlpha(alpha)})`; + } } return rgbToHex(r, g, b); } +function parseAlphaTransform(colorElement: Element): number { + const alpha = parseOptionalInt(colorElement.querySelector(":scope > alpha")?.getAttribute("val")); + const alphaMod = parseOptionalInt( + colorElement.querySelector(":scope > alphaMod")?.getAttribute("val"), + ); + const alphaOff = parseOptionalInt( + colorElement.querySelector(":scope > alphaOff")?.getAttribute("val"), + ); + + const base = (alpha ?? 100000) / 100000; + const mod = (alphaMod ?? 100000) / 100000; + const off = (alphaOff ?? 0) / 100000; + + return clampUnit(base * mod + off); +} + +function formatAlpha(alpha: number): string { + return (Math.round(alpha * 1000) / 1000).toString(); +} + function hexToRgb(color: string): [number, number, number] | undefined { const hex = color.replace("#", ""); if (hex.length !== 6) return undefined; @@ -1718,6 +1849,68 @@ function rgbToHex(r: number, g: number, b: number): string { .join("")}`; } +function rgbToHsl(r: number, g: number, b: number): [number, number, number] | undefined { + const rn = r / 255; + const gn = g / 255; + const bn = b / 255; + const max = Math.max(rn, gn, bn); + const min = Math.min(rn, gn, bn); + const delta = max - min; + + let h = 0; + let s = 0; + const l = (max + min) / 2; + + if (delta !== 0) { + s = l > 0.5 ? delta / (2 - max - min) : delta / (max + min); + + if (max === rn) { + h = (gn - bn) / delta + (gn < bn ? 6 : 0); + } else if (max === gn) { + h = (bn - rn) / delta + 2; + } else { + h = (rn - gn) / delta + 4; + } + + h /= 6; + } + + if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(l)) { + return undefined; + } + + return [h, s, l]; +} + +function hueToRgb(p: number, q: number, t: number): number { + let adjusted = t; + if (adjusted < 0) adjusted += 1; + if (adjusted > 1) adjusted -= 1; + if (adjusted < 1 / 6) return p + (q - p) * 6 * adjusted; + if (adjusted < 1 / 2) return q; + if (adjusted < 2 / 3) return p + (q - p) * (2 / 3 - adjusted) * 6; + return p; +} + +function hslToRgb(h: number, s: number, l: number): [number, number, number] { + if (s === 0) { + const gray = Math.round(l * 255); + return [gray, gray, gray]; + } + + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + const r = hueToRgb(p, q, h + 1 / 3); + const g = hueToRgb(p, q, h); + const b = hueToRgb(p, q, h - 1 / 3); + + return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]; +} + +function clampUnit(value: number): number { + return Math.max(0, Math.min(1, value)); +} + function clampColor(value: number): number { return Math.max(0, Math.min(255, value)); } @@ -1950,67 +2143,65 @@ function parsePathList( pathLst.querySelectorAll("path").forEach((pathEl) => { const commands: import("./types").PathCommand[] = []; - // Parse move command - const moveTo = pathEl.querySelector("moveTo"); - if (moveTo) { - const pt = moveTo.querySelector("pt"); - if (pt) { - const x = parseFloat(pt.getAttribute("x") || "0"); - const y = parseFloat(pt.getAttribute("y") || "0"); - commands.push({ type: "M", x, y }); - } - } - - // Parse line commands - pathEl.querySelectorAll("lnTo").forEach((lnTo) => { - const pt = lnTo.querySelector("pt"); - if (pt) { - const x = parseFloat(pt.getAttribute("x") || "0"); - const y = parseFloat(pt.getAttribute("y") || "0"); - commands.push({ type: "L", x, y }); - } - }); - - // Parse cubic bezier commands - pathEl.querySelectorAll("cubicBezTo").forEach((cubic) => { - const pts = cubic.querySelectorAll("pt"); - if (pts.length >= 3) { - commands.push({ - type: "C", - cp1x: parseFloat(pts[0].getAttribute("x") || "0"), - cp1y: parseFloat(pts[0].getAttribute("y") || "0"), - cp2x: parseFloat(pts[1].getAttribute("x") || "0"), - cp2y: parseFloat(pts[1].getAttribute("y") || "0"), - x: parseFloat(pts[2].getAttribute("x") || "0"), - y: parseFloat(pts[2].getAttribute("y") || "0"), - }); - } - }); - - // Parse quadratic bezier commands - pathEl.querySelectorAll("quadBezTo").forEach((quad) => { - const pts = quad.querySelectorAll("pt"); - if (pts.length >= 2) { - commands.push({ - type: "Q", - cpx: parseFloat(pts[0].getAttribute("x") || "0"), - cpy: parseFloat(pts[0].getAttribute("y") || "0"), - x: parseFloat(pts[1].getAttribute("x") || "0"), - y: parseFloat(pts[1].getAttribute("y") || "0"), - }); + for (const child of Array.from(pathEl.children)) { + switch (child.localName) { + case "moveTo": { + const pt = child.querySelector(":scope > pt"); + if (!pt) break; + commands.push({ + type: "M", + x: parseFloat(pt.getAttribute("x") || "0"), + y: parseFloat(pt.getAttribute("y") || "0"), + }); + break; + } + case "lnTo": { + const pt = child.querySelector(":scope > pt"); + if (!pt) break; + commands.push({ + type: "L", + x: parseFloat(pt.getAttribute("x") || "0"), + y: parseFloat(pt.getAttribute("y") || "0"), + }); + break; + } + case "cubicBezTo": { + const pts = Array.from(child.querySelectorAll(":scope > pt")); + if (pts.length < 3) break; + commands.push({ + type: "C", + cp1x: parseFloat(pts[0].getAttribute("x") || "0"), + cp1y: parseFloat(pts[0].getAttribute("y") || "0"), + cp2x: parseFloat(pts[1].getAttribute("x") || "0"), + cp2y: parseFloat(pts[1].getAttribute("y") || "0"), + x: parseFloat(pts[2].getAttribute("x") || "0"), + y: parseFloat(pts[2].getAttribute("y") || "0"), + }); + break; + } + case "quadBezTo": { + const pts = Array.from(child.querySelectorAll(":scope > pt")); + if (pts.length < 2) break; + commands.push({ + type: "Q", + cpx: parseFloat(pts[0].getAttribute("x") || "0"), + cpy: parseFloat(pts[0].getAttribute("y") || "0"), + x: parseFloat(pts[1].getAttribute("x") || "0"), + y: parseFloat(pts[1].getAttribute("y") || "0"), + }); + break; + } + case "arcTo": { + // TODO: Implement arcTo conversion to SVG path arcs. + break; + } + case "close": { + commands.push({ type: "Z" }); + break; + } + default: + break; } - }); - - // Parse arc commands - pathEl.querySelectorAll("arcTo").forEach((_arc) => { - // Arc parsing would require more complex coordinate conversion - // For now, we'll skip arc commands or approximate with curves - }); - - // Parse close command - const close = pathEl.querySelector("close"); - if (close) { - commands.push({ type: "Z" }); } // Parse fill/stroke for this path diff --git a/src/types.ts b/src/types.ts index a3808f5..31322b1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -140,6 +140,8 @@ export interface ShapeElement extends BaseElement { strokeWidth?: number; strokeDash?: string; // dashed, dotted, etc. roundedCorners?: number; // border radius + pathData?: ShapePath[]; + viewBox?: { x: number; y: number; width: number; height: number }; } export interface GradientFill { diff --git a/test/fixtures.test.ts b/test/fixtures.test.ts index 83d9d6e..8afff67 100644 --- a/test/fixtures.test.ts +++ b/test/fixtures.test.ts @@ -147,7 +147,7 @@ describe("real PPTX fixtures", () => { }); expect(normalizeRenderedHtml(container.innerHTML)).toMatchInlineSnapshot( - `"
Slide 1

Quarterly Review

•Revenue is up 18%
1

Slide 2

Grouped Card

📊 Chart
"` + `"
Slide 1

Quarterly Review

•Revenue is up 18%
1

Slide 2

Grouped Card

📊 Chart
"` ); }); @@ -187,9 +187,9 @@ describe("real PPTX fixtures", () => { "group": 0, "image": 1, "number": 1, - "shape": 0, + "shape": 2, "table": 0, - "text": 6, + "text": 4, "textPreview": [ "OpenBridge", ":", @@ -205,9 +205,9 @@ describe("real PPTX fixtures", () => { "group": 0, "image": 1, "number": 2, - "shape": 0, + "shape": 1, "table": 0, - "text": 3, + "text": 2, "textPreview": [ "The Challenge:", "The Care Continuity Gap", @@ -235,9 +235,9 @@ describe("real PPTX fixtures", () => { "group": 0, "image": 0, "number": 4, - "shape": 0, + "shape": 1, "table": 1, - "text": 5, + "text": 4, "textPreview": [ "Evaluation Matrix", "A single app or gadget only solves part of the experience. The", @@ -251,9 +251,9 @@ describe("real PPTX fixtures", () => { "group": 0, "image": 1, "number": 5, - "shape": 0, + "shape": 1, "table": 1, - "text": 6, + "text": 5, "textPreview": [ "Landscape Review:", "Most products solve one slice of the problem.", @@ -266,9 +266,9 @@ describe("real PPTX fixtures", () => { "group": 0, "image": 1, "number": 6, - "shape": 0, + "shape": 1, "table": 0, - "text": 4, + "text": 3, "textPreview": [ "Why OpenBridge is the Stronger Choice.", "The", @@ -294,9 +294,9 @@ describe("real PPTX fixtures", () => { "group": 0, "image": 1, "number": 8, - "shape": 0, + "shape": 2, "table": 1, - "text": 3, + "text": 1, "textPreview": [ "Road to Prototype:", "Skills & Resources", @@ -326,9 +326,9 @@ describe("real PPTX fixtures", () => { "group": 0, "image": 1, "number": 10, - "shape": 0, + "shape": 1, "table": 0, - "text": 3, + "text": 2, "textPreview": [ "Who benefits?", ], @@ -339,9 +339,9 @@ describe("real PPTX fixtures", () => { "group": 2, "image": 1, "number": 11, - "shape": 0, + "shape": 1, "table": 0, - "text": 18, + "text": 17, "textPreview": [ "Build the Network.", "OpenBridge", @@ -392,7 +392,7 @@ describe("real PPTX fixtures", () => { "hasOpenBridge": true, "imageElements": 16, "slides": 12, - "textElements": 70, + "textElements": 60, } `); }); diff --git a/test/fixtures/real/basic-preview.pptx b/test/fixtures/real/basic-preview.pptx index 99b109c255ed70bd6f3cc69dbc5f3cc2e97948a7..ff059dae7f89949209450fab66746c0ee6db9953 100644 GIT binary patch delta 264 zcmca9e^Z_}z?+$ci-CcGgW*AP?MB`}MrI&AIgwEwOgA&00n*5`!Ge#OH#31%&1BO7(ur!L&CYM7)m= X!oS031{N3Phloe;gN>X#f!`AVx-e!$ delta 264 zcmca9e^Z_}z?+$ci-CcGgTYd$awBgbBQubmoX98-rkfehfaxG6RS>X0qvk=@V?vz!DqTnIIC|IA4Gz7ILY9=~G;*!E^;T*x1PnxC_91 zVIDg$9m1mzrrUVz!So3pQxH9g7s9>6Cp|fqR~jr;&FcWB_wm|*>A$?*VA`7xBHqUb X;ospi1B;9DL&PKa!A4G=!0!nFCoD+3 diff --git a/test/index.test.ts b/test/index.test.ts index 022909b..2a6f308 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -132,6 +132,106 @@ describe("pptx-react-renderer", () => { ]); }); + it("applies OOXML default text insets when bodyPr omits inset attributes", async () => { + const buffer = await createTestPptx([ + { + rawXml: ` + + + + + + + + + + + + + + + Default inset text + + + + +`, + }, + ]); + + const { slides } = await parsePptx(buffer); + const textElement = slides[0].elements[0]; + + if (textElement.type !== "text") { + throw new Error("Expected a parsed text element"); + } + + expect(textElement.leftInset).toBeCloseTo(7.2, 4); + expect(textElement.rightInset).toBeCloseTo(7.2, 4); + expect(textElement.topInset).toBeCloseTo(3.6, 4); + expect(textElement.bottomInset).toBeCloseTo(3.6, 4); + }); + + it("parses empty txBody shape placeholders as visual shapes", async () => { + const buffer = await createTestPptx([ + { + rawXml: ` + + + + + + + + + + + + + + + + + +`, + }, + ]); + + const { slides } = await parsePptx(buffer); + expect(slides[0].elements[0]).toMatchObject({ + type: "shape", + shapeType: "rect", + fillColor: "#000000", + }); + }); + + it("keeps empty txBody placeholders as text when no explicit visual style exists", async () => { + const buffer = await createTestPptx([ + { + rawXml: ` + + + + + + + + + + + + + + + +`, + }, + ]); + + const { slides } = await parsePptx(buffer); + expect(slides[0].elements[0]).toMatchObject({ type: "text" }); + }); + it("falls back to a placeholder for unresolved images", async () => { const buffer = await createTestPptx([ { @@ -387,6 +487,122 @@ describe("pptx-react-renderer", () => { expect(container.querySelector(".pptx-group-element .pptx-text-element")).not.toBeNull(); }); + it("applies scheme color luminance transforms using HSL lightness", async () => { + const baseAccent = "#4472c4"; + const expectedLumMod = applyExpectedLightnessTransform(baseAccent, { lumMod: 70000 }); + const expectedLumOff = applyExpectedLightnessTransform(baseAccent, { lumOff: 20000 }); + const expectedTint = applyExpectedLightnessTransform(baseAccent, { tint: 25000 }); + const expectedShade = applyExpectedLightnessTransform(baseAccent, { shade: 60000 }); + + const buffer = await createTestPptx([ + { + rawXml: ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`, + }, + ]); + + const { slides } = await parsePptx(buffer); + const shapes = slides[0].elements.filter((element) => element.type === "shape"); + + expect(shapes).toHaveLength(4); + expect(shapes[0]).toMatchObject({ fillColor: expectedLumMod }); + expect(shapes[1]).toMatchObject({ fillColor: expectedLumOff }); + expect(shapes[2]).toMatchObject({ fillColor: expectedTint }); + expect(shapes[3]).toMatchObject({ fillColor: expectedShade }); + }); + + it("parses custom geometry shapes with path data and alpha-aware fills", async () => { + const buffer = await createTestPptx([ + { + rawXml: ` + + + + + + + + + + + + + + + + + + + + + + + + +`, + }, + ]); + + const { slides } = await parsePptx(buffer); + const customShape = slides[0].elements[0]; + + expect(customShape).toMatchObject({ + type: "shape", + shapeType: "custom", + fillColor: "rgba(68, 114, 196, 0.3)", + strokeColor: "#ffffff", + strokeWidth: 1, + viewBox: { x: 0, y: 0, width: 1000, height: 500 }, + }); + + if (customShape.type !== "shape") { + throw new Error("Expected a parsed shape element"); + } + + expect(customShape.pathData).toHaveLength(1); + expect(customShape.pathData?.[0].commands).toEqual([ + { type: "M", x: 0, y: 0 }, + { type: "L", x: 1000, y: 0 }, + { type: "L", x: 1000, y: 500 }, + { type: "L", x: 0, y: 500 }, + { type: "Z" }, + ]); + + }); + it("throws a clear error when browser parsing APIs are unavailable", async () => { const buffer = await createTestPptx([{ text: ["Guard"] }]); const originalDomParser = globalThis.DOMParser; @@ -443,3 +659,127 @@ describe("pptx-react-renderer", () => { } }); }); + +function applyExpectedLightnessTransform( + baseColor: string, + transform: { + lumMod?: number; + lumOff?: number; + tint?: number; + shade?: number; + }, +): string { + const rgb = hexToRgb(baseColor); + if (!rgb) { + throw new Error(`Invalid expected base color: ${baseColor}`); + } + + const hsl = rgbToHsl(rgb[0], rgb[1], rgb[2]); + if (!hsl) { + throw new Error(`Failed to convert expected color to HSL: ${baseColor}`); + } + + const [h, s, initialL] = hsl; + let l = initialL; + + if (transform.lumMod !== undefined || transform.lumOff !== undefined) { + const mod = (transform.lumMod ?? 100000) / 100000; + const off = (transform.lumOff ?? 0) / 100000; + l = clampUnit(l * mod + off); + } + + if (transform.tint !== undefined) { + const tintFactor = transform.tint / 100000; + l = clampUnit(l + (1 - l) * tintFactor); + } + + if (transform.shade !== undefined) { + const shadeFactor = transform.shade / 100000; + l = clampUnit(l * shadeFactor); + } + + const [r, g, b] = hslToRgb(h, s, l); + return rgbToHex(r, g, b); +} + +function hexToRgb(color: string): [number, number, number] | undefined { + const hex = color.replace("#", ""); + if (hex.length !== 6) return undefined; + return [ + parseInt(hex.slice(0, 2), 16), + parseInt(hex.slice(2, 4), 16), + parseInt(hex.slice(4, 6), 16), + ]; +} + +function rgbToHex(r: number, g: number, b: number): string { + return `#${[r, g, b] + .map((channel) => clampChannel(channel).toString(16).padStart(2, "0")) + .join("")}`; +} + +function rgbToHsl(r: number, g: number, b: number): [number, number, number] | undefined { + const rn = r / 255; + const gn = g / 255; + const bn = b / 255; + const max = Math.max(rn, gn, bn); + const min = Math.min(rn, gn, bn); + const delta = max - min; + const l = (max + min) / 2; + + let h = 0; + let s = 0; + + if (delta !== 0) { + s = l > 0.5 ? delta / (2 - max - min) : delta / (max + min); + + if (max === rn) { + h = (gn - bn) / delta + (gn < bn ? 6 : 0); + } else if (max === gn) { + h = (bn - rn) / delta + 2; + } else { + h = (rn - gn) / delta + 4; + } + + h /= 6; + } + + if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(l)) { + return undefined; + } + + return [h, s, l]; +} + +function hueToRgb(p: number, q: number, t: number): number { + let adjusted = t; + if (adjusted < 0) adjusted += 1; + if (adjusted > 1) adjusted -= 1; + if (adjusted < 1 / 6) return p + (q - p) * 6 * adjusted; + if (adjusted < 1 / 2) return q; + if (adjusted < 2 / 3) return p + (q - p) * (2 / 3 - adjusted) * 6; + return p; +} + +function hslToRgb(h: number, s: number, l: number): [number, number, number] { + if (s === 0) { + const gray = Math.round(l * 255); + return [gray, gray, gray]; + } + + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + const r = hueToRgb(p, q, h + 1 / 3); + const g = hueToRgb(p, q, h); + const b = hueToRgb(p, q, h - 1 / 3); + + return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]; +} + +function clampUnit(value: number): number { + return Math.max(0, Math.min(1, value)); +} + +function clampChannel(value: number): number { + return Math.max(0, Math.min(255, Math.round(value))); +} From 6edc1e0623ad7b30278e48a5eb85acec80c931ac Mon Sep 17 00:00:00 2001 From: Sidhaarth Krishnan <161455529+RealSid08@users.noreply.github.com> Date: Sun, 29 Mar 2026 23:36:12 +1100 Subject: [PATCH 2/2] Fix CI TypeScript 6 deprecation guard --- tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index b66f224..608d245 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,7 @@ "declaration": true, "declarationMap": true, "esModuleInterop": true, - "ignoreDeprecations": "5.0", + "ignoreDeprecations": "6.0", "isolatedModules": true, "skipLibCheck": true, "strict": false,