Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/custgeom-schemeclr-selfcontain.md
Original file line number Diff line number Diff line change
@@ -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 `<p:sp>` into the deck JSON when the XML was fully self-contained — and it *excluded* anything referencing a theme colour (`<a:schemeClr>`). 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** `<a:schemeClr>` references to literal `<a:srgbClr>` 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.
23 changes: 23 additions & 0 deletions packages/slidewise/src/lib/pptx/__tests__/roundtrip.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -285,6 +286,28 @@ describe("pptx round-trip", () => {
expect(shape.pristineOoxml?.snapshot.length).toBeGreaterThan(0);
});

it("resolves <a:schemeClr> to literal <a:srgbClr> so theme-coloured vectors self-contain", () => {
const theme = {
accent2: "#EA1B0A",
tx1: "#0E1330",
} as unknown as Parameters<typeof resolveSchemeColorsInXml>[1];
// Self-closing, with-children, and closing-tag forms; an unresolvable
// token (phClr) must be left intact so the caller can bail.
const xml =
`<a:solidFill><a:schemeClr val="accent2"/></a:solidFill>` +
`<a:ln><a:solidFill><a:schemeClr val="tx1"><a:lumMod val="50000"/></a:schemeClr></a:solidFill></a:ln>` +
`<a:fill><a:schemeClr val="phClr"/></a:fill>`;
const out = resolveSchemeColorsInXml(xml, theme);
expect(out).toContain('<a:srgbClr val="EA1B0A"/>');
// Child transforms survive the swap.
expect(out).toContain('<a:srgbClr val="0E1330"><a:lumMod val="50000"/></a:srgbClr>');
// phClr (not in theme) is untouched.
expect(out).toContain('<a:schemeClr val="phClr"/>');
// 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,
Expand Down
63 changes: 49 additions & 14 deletions packages/slidewise/src/lib/pptx/pptxToDeck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -480,7 +481,7 @@ function registerElementSource(
snapshot: snapshotElement(element),
slidePath,
});
stampPristineOoxml(element, rawXml);
stampPristineOoxml(element, rawXml, theme);
}

/**
Expand All @@ -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 (`<a:schemeClr>`) are resolved to literal `<a:srgbClr>` 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)=|<a:schemeClr\b/.test(rawXml)) return;
element.pristineOoxml = { xml: rawXml, snapshot: snapshotElement(element) };
if (/\br:(embed|id|link)=/.test(rawXml)) return;
const xml = theme ? resolveSchemeColorsInXml(rawXml, theme) : rawXml;
// Any unresolved scheme colour left over → not self-contained → synth.
if (/<a:schemeClr\b/.test(xml)) return;
element.pristineOoxml = { xml, snapshot: snapshotElement(element) };
}

/**
* Rewrite `<a:schemeClr val="accent2">` → `<a:srgbClr val="EA1B0A">` (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(
/<a:schemeClr\b([^>]*?)\bval="([^"]+)"([^>]*?)(\/?)>/g,
(whole, pre: string, token: string, post: string, selfClose: string) => {
const hex = (theme as unknown as Record<string, string>)[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 `<a:srgbClr${pre}val="${val}"${post}${selfClose}>`;
}
).replace(/<\/a:schemeClr>/g, "</a:srgbClr>");
}

function hasExplicitXfrm(xml: string): boolean {
Expand Down Expand Up @@ -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"])) {
Expand Down Expand Up @@ -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") {
Expand All @@ -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);
}
}
Expand Down
Loading