From 058026c79bb77f169c1ea86c4fba6423dadc9c78 Mon Sep 17 00:00:00 2001 From: karthikmudunuri <102793643+karthikmudunuri@users.noreply.github.com> Date: Sun, 31 May 2026 14:43:30 +0530 Subject: [PATCH] fix(pptx): resolve theme colours in persisted custGeom so brand-coloured vectors round-trip cross-process --- .changeset/custgeom-schemeclr-selfcontain.md | 9 +++ .../src/lib/pptx/__tests__/roundtrip.test.ts | 23 +++++++ packages/slidewise/src/lib/pptx/pptxToDeck.ts | 63 ++++++++++++++----- 3 files changed, 81 insertions(+), 14 deletions(-) create mode 100644 .changeset/custgeom-schemeclr-selfcontain.md diff --git a/.changeset/custgeom-schemeclr-selfcontain.md b/.changeset/custgeom-schemeclr-selfcontain.md new file mode 100644 index 0000000..6fed2ca --- /dev/null +++ b/.changeset/custgeom-schemeclr-selfcontain.md @@ -0,0 +1,9 @@ +--- +"@textcortex/slidewise": patch +--- + +fix(pptx): resolve theme colours when persisting verbatim custGeom, so brand-coloured vectors qualify for cross-process replay + +The cross-process verbatim-replay fix (1.16.1) only stamped a custGeom shape's source `` into the deck JSON when the XML was fully self-contained — and it *excluded* anything referencing a theme colour (``). Brand marks are almost always filled with a theme accent (e.g. E.ON red is `schemeClr val="accent2"`), so the very shapes this was meant to fix (the bicycle) were skipped and fell back to the lossy synth path — still blank. + +The importer now **resolves** `` references to literal `` against the slide's theme before persisting, instead of bailing. Both elements accept the same child transforms (`lumMod`, `alpha`, …) so the swap is lossless — only the colour source changes from a theme reference to a baked hex, making the fragment valid without the source theme. Shapes that still reference media (`r:embed`/`r:id`/`r:link`) or carry a colour token absent from the theme remain on the synth path. diff --git a/packages/slidewise/src/lib/pptx/__tests__/roundtrip.test.ts b/packages/slidewise/src/lib/pptx/__tests__/roundtrip.test.ts index c4ada28..ae98ee5 100644 --- a/packages/slidewise/src/lib/pptx/__tests__/roundtrip.test.ts +++ b/packages/slidewise/src/lib/pptx/__tests__/roundtrip.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { parsePptx, serializeDeck } from "../index"; +import { resolveSchemeColorsInXml } from "../pptxToDeck"; import { CURRENT_DECK_VERSION } from "@/lib/schema/migrate"; import type { Deck } from "@/lib/types"; @@ -285,6 +286,28 @@ describe("pptx round-trip", () => { expect(shape.pristineOoxml?.snapshot.length).toBeGreaterThan(0); }); + it("resolves to literal so theme-coloured vectors self-contain", () => { + const theme = { + accent2: "#EA1B0A", + tx1: "#0E1330", + } as unknown as Parameters[1]; + // Self-closing, with-children, and closing-tag forms; an unresolvable + // token (phClr) must be left intact so the caller can bail. + const xml = + `` + + `` + + ``; + const out = resolveSchemeColorsInXml(xml, theme); + expect(out).toContain(''); + // Child transforms survive the swap. + expect(out).toContain(''); + // phClr (not in theme) is untouched. + expect(out).toContain(''); + // accent2 / tx1 scheme refs are gone. + expect(out).not.toContain('schemeClr val="accent2"'); + expect(out).not.toContain('schemeClr val="tx1"'); + }); + it("preserves slide background colour", async () => { const deck: Deck = { version: CURRENT_DECK_VERSION, diff --git a/packages/slidewise/src/lib/pptx/pptxToDeck.ts b/packages/slidewise/src/lib/pptx/pptxToDeck.ts index a63e09e..a35229c 100644 --- a/packages/slidewise/src/lib/pptx/pptxToDeck.ts +++ b/packages/slidewise/src/lib/pptx/pptxToDeck.ts @@ -465,7 +465,8 @@ function snapshotFields(element: SlideElement): unknown { function registerElementSource( element: SlideElement, rawXml: string | undefined, - slidePath: string + slidePath: string, + theme?: ThemeColors ): void { if (!rawXml) return; // Skip elements whose source XML relies on placeholder geometry @@ -480,7 +481,7 @@ function registerElementSource( snapshot: snapshotElement(element), slidePath, }); - stampPristineOoxml(element, rawXml); + stampPristineOoxml(element, rawXml, theme); } /** @@ -491,14 +492,48 @@ function registerElementSource( * instead of re-synthesising from `path.d` — synthesis can't represent OOXML * even-odd winding, which is what blanks complex vectors like the eon bicycle. * - * Restricted to shapes whose XML carries no external references - * (`r:embed` / `r:id` / `r:link` images, `a:schemeClr` theme colours) so the - * fragment stays valid without the source archive or its theme. + * Theme colours (``) are resolved to literal `` against + * the slide's theme so brand-coloured vectors (the common case — e.g. E.ON red + * is a theme accent) become self-contained and still qualify; the swap is + * lossless because both elements accept the same child transforms. Shapes that + * reference media (`r:embed` / `r:id` / `r:link`) or carry an unresolvable + * colour are left to the synth path (they'd be invalid without the archive). */ -function stampPristineOoxml(element: SlideElement, rawXml: string): void { +function stampPristineOoxml( + element: SlideElement, + rawXml: string, + theme?: ThemeColors +): void { if (element.type !== "shape" || !element.path) return; - if (/\br:(embed|id|link)=|` → `` (and the + * self-closing / closing-tag forms) using the baked theme. `schemeClr` and + * `srgbClr` accept identical child transforms (`lumMod`, `alpha`, …), so the + * swap preserves tints/shades exactly — only the colour source changes from a + * theme reference to a literal. Tokens not present in the theme (e.g. `phClr`) + * are left untouched so the caller can detect "still has schemeClr" and bail. + */ +export function resolveSchemeColorsInXml(xml: string, theme: ThemeColors): string { + return xml.replace( + /]*?)\bval="([^"]+)"([^>]*?)(\/?)>/g, + (whole, pre: string, token: string, post: string, selfClose: string) => { + const hex = (theme as unknown as Record)[token]; + // Only swap when the theme gives a literal #RRGGBB — anything else + // (missing token, "transparent", …) is left so the caller bails out. + if (!hex || !/^#[0-9a-fA-F]{6}$/.test(hex)) return whole; + const val = hex.slice(1).toUpperCase(); + // `pre` already carries the whitespace that separated the tag from + // `val=`, so don't add another space (would double it). + return ``; + } + ).replace(/<\/a:schemeClr>/g, ""); } function hasExplicitXfrm(xml: string): boolean { @@ -746,7 +781,7 @@ async function walkUnderlay( const registerFromNode = (node: any, el: SlideElement | null) => { if (!el) return; const rawSrc = (node as any)?._elementRawSrc as string | undefined; - registerElementSource(el, rawSrc, ctx.slidePath); + registerElementSource(el, rawSrc, ctx.slidePath, ctx.theme); out.push(el); }; for (const sp of asArray(spTree["p:sp"])) { @@ -923,25 +958,25 @@ async function parseSpTree( if (tag === "p:sp") { const el = await parseSpOrText(node, ctx, outer); if (el) { - registerElementSource(el, rawSrc, ctx.slidePath); + registerElementSource(el, rawSrc, ctx.slidePath, ctx.theme); out.push(el); } } else if (tag === "p:pic") { const el = await parsePic(node, ctx, outer); if (el) { - registerElementSource(el, rawSrc, ctx.slidePath); + registerElementSource(el, rawSrc, ctx.slidePath, ctx.theme); out.push(el); } } else if (tag === "p:cxnSp") { const el = parseCxn(node, ctx, outer); if (el) { - registerElementSource(el, rawSrc, ctx.slidePath); + registerElementSource(el, rawSrc, ctx.slidePath, ctx.theme); out.push(el); } } else if (tag === "p:graphicFrame") { const el = await parseGraphicFrame(node, ctx, outer); if (el) { - registerElementSource(el, rawSrc, ctx.slidePath); + registerElementSource(el, rawSrc, ctx.slidePath, ctx.theme); out.push(el); } } else if (tag === "p:grpSp") { @@ -954,7 +989,7 @@ async function parseSpTree( // and image exactly, plus the group transform itself. Once any // descendant is edited the snapshot diverges (see snapshotElement) // and the synth path re-emits the group instead. - registerElementSource(group, rawSrc, ctx.slidePath); + registerElementSource(group, rawSrc, ctx.slidePath, ctx.theme); out.push(group); } }