Skip to content

Commit 71f96cb

Browse files
fix(pptx): resolve theme colours in persisted custGeom so brand-coloured vectors round-trip cross-process (#71)
1 parent cf2ff19 commit 71f96cb

3 files changed

Lines changed: 81 additions & 14 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@textcortex/slidewise": patch
3+
---
4+
5+
fix(pptx): resolve theme colours when persisting verbatim custGeom, so brand-coloured vectors qualify for cross-process replay
6+
7+
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.
8+
9+
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.

packages/slidewise/src/lib/pptx/__tests__/roundtrip.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, it, expect } from "vitest";
22
import { parsePptx, serializeDeck } from "../index";
3+
import { resolveSchemeColorsInXml } from "../pptxToDeck";
34
import { CURRENT_DECK_VERSION } from "@/lib/schema/migrate";
45
import type { Deck } from "@/lib/types";
56

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

289+
it("resolves <a:schemeClr> to literal <a:srgbClr> so theme-coloured vectors self-contain", () => {
290+
const theme = {
291+
accent2: "#EA1B0A",
292+
tx1: "#0E1330",
293+
} as unknown as Parameters<typeof resolveSchemeColorsInXml>[1];
294+
// Self-closing, with-children, and closing-tag forms; an unresolvable
295+
// token (phClr) must be left intact so the caller can bail.
296+
const xml =
297+
`<a:solidFill><a:schemeClr val="accent2"/></a:solidFill>` +
298+
`<a:ln><a:solidFill><a:schemeClr val="tx1"><a:lumMod val="50000"/></a:schemeClr></a:solidFill></a:ln>` +
299+
`<a:fill><a:schemeClr val="phClr"/></a:fill>`;
300+
const out = resolveSchemeColorsInXml(xml, theme);
301+
expect(out).toContain('<a:srgbClr val="EA1B0A"/>');
302+
// Child transforms survive the swap.
303+
expect(out).toContain('<a:srgbClr val="0E1330"><a:lumMod val="50000"/></a:srgbClr>');
304+
// phClr (not in theme) is untouched.
305+
expect(out).toContain('<a:schemeClr val="phClr"/>');
306+
// accent2 / tx1 scheme refs are gone.
307+
expect(out).not.toContain('schemeClr val="accent2"');
308+
expect(out).not.toContain('schemeClr val="tx1"');
309+
});
310+
288311
it("preserves slide background colour", async () => {
289312
const deck: Deck = {
290313
version: CURRENT_DECK_VERSION,

packages/slidewise/src/lib/pptx/pptxToDeck.ts

Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -465,7 +465,8 @@ function snapshotFields(element: SlideElement): unknown {
465465
function registerElementSource(
466466
element: SlideElement,
467467
rawXml: string | undefined,
468-
slidePath: string
468+
slidePath: string,
469+
theme?: ThemeColors
469470
): void {
470471
if (!rawXml) return;
471472
// Skip elements whose source XML relies on placeholder geometry
@@ -480,7 +481,7 @@ function registerElementSource(
480481
snapshot: snapshotElement(element),
481482
slidePath,
482483
});
483-
stampPristineOoxml(element, rawXml);
484+
stampPristineOoxml(element, rawXml, theme);
484485
}
485486

486487
/**
@@ -491,14 +492,48 @@ function registerElementSource(
491492
* instead of re-synthesising from `path.d` — synthesis can't represent OOXML
492493
* even-odd winding, which is what blanks complex vectors like the eon bicycle.
493494
*
494-
* Restricted to shapes whose XML carries no external references
495-
* (`r:embed` / `r:id` / `r:link` images, `a:schemeClr` theme colours) so the
496-
* fragment stays valid without the source archive or its theme.
495+
* Theme colours (`<a:schemeClr>`) are resolved to literal `<a:srgbClr>` against
496+
* the slide's theme so brand-coloured vectors (the common case — e.g. E.ON red
497+
* is a theme accent) become self-contained and still qualify; the swap is
498+
* lossless because both elements accept the same child transforms. Shapes that
499+
* reference media (`r:embed` / `r:id` / `r:link`) or carry an unresolvable
500+
* colour are left to the synth path (they'd be invalid without the archive).
497501
*/
498-
function stampPristineOoxml(element: SlideElement, rawXml: string): void {
502+
function stampPristineOoxml(
503+
element: SlideElement,
504+
rawXml: string,
505+
theme?: ThemeColors
506+
): void {
499507
if (element.type !== "shape" || !element.path) return;
500-
if (/\br:(embed|id|link)=|<a:schemeClr\b/.test(rawXml)) return;
501-
element.pristineOoxml = { xml: rawXml, snapshot: snapshotElement(element) };
508+
if (/\br:(embed|id|link)=/.test(rawXml)) return;
509+
const xml = theme ? resolveSchemeColorsInXml(rawXml, theme) : rawXml;
510+
// Any unresolved scheme colour left over → not self-contained → synth.
511+
if (/<a:schemeClr\b/.test(xml)) return;
512+
element.pristineOoxml = { xml, snapshot: snapshotElement(element) };
513+
}
514+
515+
/**
516+
* Rewrite `<a:schemeClr val="accent2">` → `<a:srgbClr val="EA1B0A">` (and the
517+
* self-closing / closing-tag forms) using the baked theme. `schemeClr` and
518+
* `srgbClr` accept identical child transforms (`lumMod`, `alpha`, …), so the
519+
* swap preserves tints/shades exactly — only the colour source changes from a
520+
* theme reference to a literal. Tokens not present in the theme (e.g. `phClr`)
521+
* are left untouched so the caller can detect "still has schemeClr" and bail.
522+
*/
523+
export function resolveSchemeColorsInXml(xml: string, theme: ThemeColors): string {
524+
return xml.replace(
525+
/<a:schemeClr\b([^>]*?)\bval="([^"]+)"([^>]*?)(\/?)>/g,
526+
(whole, pre: string, token: string, post: string, selfClose: string) => {
527+
const hex = (theme as unknown as Record<string, string>)[token];
528+
// Only swap when the theme gives a literal #RRGGBB — anything else
529+
// (missing token, "transparent", …) is left so the caller bails out.
530+
if (!hex || !/^#[0-9a-fA-F]{6}$/.test(hex)) return whole;
531+
const val = hex.slice(1).toUpperCase();
532+
// `pre` already carries the whitespace that separated the tag from
533+
// `val=`, so don't add another space (would double it).
534+
return `<a:srgbClr${pre}val="${val}"${post}${selfClose}>`;
535+
}
536+
).replace(/<\/a:schemeClr>/g, "</a:srgbClr>");
502537
}
503538

504539
function hasExplicitXfrm(xml: string): boolean {
@@ -746,7 +781,7 @@ async function walkUnderlay(
746781
const registerFromNode = (node: any, el: SlideElement | null) => {
747782
if (!el) return;
748783
const rawSrc = (node as any)?._elementRawSrc as string | undefined;
749-
registerElementSource(el, rawSrc, ctx.slidePath);
784+
registerElementSource(el, rawSrc, ctx.slidePath, ctx.theme);
750785
out.push(el);
751786
};
752787
for (const sp of asArray(spTree["p:sp"])) {
@@ -923,25 +958,25 @@ async function parseSpTree(
923958
if (tag === "p:sp") {
924959
const el = await parseSpOrText(node, ctx, outer);
925960
if (el) {
926-
registerElementSource(el, rawSrc, ctx.slidePath);
961+
registerElementSource(el, rawSrc, ctx.slidePath, ctx.theme);
927962
out.push(el);
928963
}
929964
} else if (tag === "p:pic") {
930965
const el = await parsePic(node, ctx, outer);
931966
if (el) {
932-
registerElementSource(el, rawSrc, ctx.slidePath);
967+
registerElementSource(el, rawSrc, ctx.slidePath, ctx.theme);
933968
out.push(el);
934969
}
935970
} else if (tag === "p:cxnSp") {
936971
const el = parseCxn(node, ctx, outer);
937972
if (el) {
938-
registerElementSource(el, rawSrc, ctx.slidePath);
973+
registerElementSource(el, rawSrc, ctx.slidePath, ctx.theme);
939974
out.push(el);
940975
}
941976
} else if (tag === "p:graphicFrame") {
942977
const el = await parseGraphicFrame(node, ctx, outer);
943978
if (el) {
944-
registerElementSource(el, rawSrc, ctx.slidePath);
979+
registerElementSource(el, rawSrc, ctx.slidePath, ctx.theme);
945980
out.push(el);
946981
}
947982
} else if (tag === "p:grpSp") {
@@ -954,7 +989,7 @@ async function parseSpTree(
954989
// and image exactly, plus the group transform itself. Once any
955990
// descendant is edited the snapshot diverges (see snapshotElement)
956991
// and the synth path re-emits the group instead.
957-
registerElementSource(group, rawSrc, ctx.slidePath);
992+
registerElementSource(group, rawSrc, ctx.slidePath, ctx.theme);
958993
out.push(group);
959994
}
960995
}

0 commit comments

Comments
 (0)