Skip to content

Commit 65eeac2

Browse files
fix(pptx): carry verbatim custGeom OOXML in deck JSON for cross-process serialize (#69)
1 parent c2614f7 commit 65eeac2

6 files changed

Lines changed: 183 additions & 1 deletion

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): carry verbatim custGeom OOXML in the deck JSON so vector shapes survive cross-process serialize
6+
7+
The high-fidelity replay of imported elements relies on two **module-global** registries (`sourceBufferCache`, `elementSourceRegistry`) populated only by `parsePptx` and never written to the deck JSON. In a pipeline that parses in one process and serializes in another (parse client-side → store deck JSON → serialize server-side), those registries are empty, so every element is re-synthesised from its deck fields. Synthesis can't represent OOXML even-odd / winding, so complex `custGeom` vectors (e.g. a bicycle diagram) render blank even though simpler ones (the brand logo) happen to survive.
8+
9+
The importer now stamps the verbatim `<p:sp>` of **self-contained** custGeom shapes (no `r:embed` / `r:id` / `a:schemeClr` references) onto the element as `pristineOoxml = { xml, snapshot }`, which rides along in the deck JSON. On serialize, an unedited such shape (snapshot still matches) is replayed verbatim — exact source geometry and winding — instead of being re-synthesised; its `cNvPr/@id` is rewritten to avoid spTree collisions. Edited shapes fall back to synthesis. This is the same persist-in-JSON pattern already used for embedded fonts (`deck.fonts`), scoped to vector shapes to keep JSON bloat negligible (~a few KB per deck).

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,44 @@ describe("pptx round-trip", () => {
247247
).toBe(true);
248248
});
249249

250+
it("stamps verbatim pristineOoxml on imported self-contained custGeom shapes", async () => {
251+
// Round a custGeom shape (solid fill, no scheme/rId refs) through a real
252+
// serialize→parse. The importer should carry its verbatim <p:sp> in the
253+
// deck JSON so a later cross-process serialize can replay it.
254+
const deck = makeDeck([
255+
{
256+
...baseElement,
257+
id: "logo",
258+
type: "shape",
259+
x: 100,
260+
y: 100,
261+
w: 400,
262+
h: 300,
263+
shape: "rect",
264+
fill: "#EA1B0A",
265+
path: {
266+
d: "M 0 0 L 100 0 L 100 100 L 0 100 Z",
267+
viewW: 100,
268+
viewH: 100,
269+
fillRule: "evenodd",
270+
},
271+
},
272+
]);
273+
274+
const out = await roundtrip(deck);
275+
const shape = out.slides[0].elements.find(
276+
(e) => e.type === "shape" && e.path
277+
);
278+
expect(shape && shape.type === "shape").toBe(true);
279+
if (!shape || shape.type !== "shape") return;
280+
expect(shape.pristineOoxml).toBeTruthy();
281+
expect(shape.pristineOoxml?.xml).toContain("<a:custGeom>");
282+
// The stored snapshot matches the element as imported (so the serializer
283+
// treats it as pristine until edited).
284+
expect(typeof shape.pristineOoxml?.snapshot).toBe("string");
285+
expect(shape.pristineOoxml?.snapshot.length).toBeGreaterThan(0);
286+
});
287+
250288
it("preserves slide background colour", async () => {
251289
const deck: Deck = {
252290
version: CURRENT_DECK_VERSION,

packages/slidewise/src/lib/pptx/__tests__/synth-writers.test.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { describe, it, expect } from "vitest";
22
import JSZip from "jszip";
33
import { serializeDeck } from "../index";
4+
import { snapshotElement } from "../pptxToDeck";
45
import { CURRENT_DECK_VERSION } from "@/lib/schema/migrate";
5-
import type { Deck } from "@/lib/types";
6+
import type { Deck, ShapeElement } from "@/lib/types";
67

78
/**
89
* Tests for the synth-OOXML writers added in the full-fidelity export work:
@@ -102,6 +103,75 @@ describe("synth writers (PRs 1, 2, 3, 4, 5, 6, 7)", () => {
102103
expect(slide).not.toMatch(/<a:prstGeom prst="rect"/);
103104
});
104105

106+
it("replays verbatim custGeom OOXML from the deck JSON when present (cross-process)", async () => {
107+
const shape: ShapeElement = {
108+
...base,
109+
id: "bikeXYZ",
110+
type: "shape",
111+
shape: "rect",
112+
x: 100,
113+
y: 100,
114+
w: 400,
115+
h: 300,
116+
fill: "#EA1B0A",
117+
path: {
118+
d: "M 0 0 L 100 0 L 100 100 Z",
119+
viewW: 100,
120+
viewH: 100,
121+
fillRule: "evenodd",
122+
},
123+
};
124+
// Self-contained verbatim <p:sp> with a colliding low cNvPr id + a marker.
125+
const verbatim =
126+
`<p:sp><p:nvSpPr><p:cNvPr id="7" name="bike"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr>` +
127+
`<p:spPr><a:xfrm><a:off x="635000" y="635000"/><a:ext cx="2540000" cy="1905000"/></a:xfrm>` +
128+
`<a:custGeom data-marker="VERBATIM_BIKE"><a:pathLst><a:path w="100" h="100">` +
129+
`<a:moveTo><a:pt x="0" y="0"/></a:moveTo><a:lnTo><a:pt x="100" y="0"/></a:lnTo><a:close/>` +
130+
`</a:path></a:pathLst></a:custGeom>` +
131+
`<a:solidFill><a:srgbClr val="EA1B0A"/></a:solidFill></p:spPr>` +
132+
`<p:txBody><a:bodyPr/><a:lstStyle/><a:p/></p:txBody></p:sp>`;
133+
shape.pristineOoxml = { xml: verbatim, snapshot: snapshotElement(shape) };
134+
135+
const zip = await generate(
136+
makeDeck([{ id: "s", background: "#FFFFFF", elements: [shape] }])
137+
);
138+
const slide = await zip.file("ppt/slides/slide1.xml")?.async("string");
139+
// The exact source geometry was replayed (marker + source fill present)…
140+
expect(slide).toContain('data-marker="VERBATIM_BIKE"');
141+
expect(slide).toContain('<a:srgbClr val="EA1B0A"/>');
142+
// …and the colliding cNvPr id="7" was rewritten to a fresh high id.
143+
expect(slide).not.toMatch(/<p:cNvPr\b[^>]*\bid="7"/);
144+
});
145+
146+
it("falls back to synthesis when a pristine-OOXML shape was edited", async () => {
147+
const shape: ShapeElement = {
148+
...base,
149+
id: "bikeEdited",
150+
type: "shape",
151+
shape: "rect",
152+
x: 100,
153+
y: 100,
154+
w: 400,
155+
h: 300,
156+
fill: "#EA1B0A",
157+
path: { d: "M 0 0 L 100 0 L 100 100 Z", viewW: 100, viewH: 100 },
158+
};
159+
const verbatim =
160+
`<p:sp><p:nvSpPr><p:cNvPr id="7" name="bike"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr>` +
161+
`<p:spPr><a:custGeom data-marker="VERBATIM_BIKE"/></p:spPr></p:sp>`;
162+
shape.pristineOoxml = { xml: verbatim, snapshot: snapshotElement(shape) };
163+
// Edit the shape AFTER the snapshot was taken → snapshot diverges.
164+
shape.x = 999;
165+
166+
const zip = await generate(
167+
makeDeck([{ id: "s", background: "#FFFFFF", elements: [shape] }])
168+
);
169+
const slide = await zip.file("ppt/slides/slide1.xml")?.async("string");
170+
// Verbatim replay is rejected; the synth path emits the custGeom from path.d.
171+
expect(slide).not.toContain("VERBATIM_BIKE");
172+
expect(slide).toContain("<a:custGeom>");
173+
});
174+
105175
it("PR 2: emits <a:gradFill> for shapes with linear-gradient fill", async () => {
106176
const deck = makeDeck([
107177
{

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
synthesiseEmbeddedFonts,
3333
effectLstXml,
3434
parseFill,
35+
freshNvId,
3536
RID_MARKER_RE,
3637
slidewiseShapeName,
3738
type MediaPayload,
@@ -167,6 +168,16 @@ function shouldSynthesise(el: SlideElement): boolean {
167168

168169
function synthesiseInto(synth: SynthSlideEntry, el: SlideElement): void {
169170
if (el.type === "shape") {
171+
// Cross-process replay: an unedited custGeom shape carries its verbatim
172+
// source `<p:sp>` in the deck JSON (see `stampPristineOoxml`). Replaying
173+
// it preserves the exact source winding/geometry that synthesis can't —
174+
// this is what un-blanks complex vectors when the import-time source
175+
// registry isn't available (parse + serialize in different processes).
176+
const verbatim = pristineShapeXml(el);
177+
if (verbatim) {
178+
synth.shapeXml.push(verbatim);
179+
return;
180+
}
170181
const out = synthesiseShape(el);
171182
synth.shapeXml.push(out.xml);
172183
for (const m of out.media) synth.media.push(m);
@@ -187,6 +198,26 @@ function synthesiseInto(synth: SynthSlideEntry, el: SlideElement): void {
187198
}
188199
}
189200

201+
/**
202+
* Verbatim `<p:sp>` for a custGeom shape that carries deck-JSON-persisted
203+
* source OOXML and hasn't been edited. Returns null when there's no pristine
204+
* XML or the element diverged from its import snapshot (then the caller
205+
* synthesises from `path.d`). The source `cNvPr/@id` is rewritten to a fresh
206+
* high id so it can't collide with whatever pptxgenjs allocated in the spTree.
207+
*
208+
* NB: same-process serialize never reaches here for these shapes — they're
209+
* caught earlier by `isPristineImportedElement` (registry hit) and replayed
210+
* through the source archive. This path is the cross-process fallback.
211+
*/
212+
function pristineShapeXml(el: SlideElement): string | null {
213+
if (el.type !== "shape" || !el.pristineOoxml) return null;
214+
if (snapshotElement(el) !== el.pristineOoxml.snapshot) return null;
215+
return el.pristineOoxml.xml.replace(
216+
/(<p:cNvPr\b[^>]*\bid=")\d+(")/,
217+
`$1${freshNvId()}$2`
218+
);
219+
}
220+
190221
/**
191222
* Render a single child for `<p:grpSp>`. We only synthesise shapes/charts
192223
* inside groups for v1 — text / image / line children remain renderable

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,25 @@ function registerElementSource(
480480
snapshot: snapshotElement(element),
481481
slidePath,
482482
});
483+
stampPristineOoxml(element, rawXml);
484+
}
485+
486+
/**
487+
* For a self-contained custGeom (vector) shape, copy its verbatim `<p:sp>`
488+
* source XML onto the element so it survives JSON serialization (the
489+
* `elementSourceRegistry` above is module-global and lost across processes).
490+
* A serialize in a fresh process can then replay the exact source geometry
491+
* instead of re-synthesising from `path.d` — synthesis can't represent OOXML
492+
* even-odd winding, which is what blanks complex vectors like the eon bicycle.
493+
*
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.
497+
*/
498+
function stampPristineOoxml(element: SlideElement, rawXml: string): void {
499+
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) };
483502
}
484503

485504
function hasExplicitXfrm(xml: string): boolean {

packages/slidewise/src/lib/types.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,21 @@ export interface ShapeElement extends BaseElement {
205205
* `shape` field remains as a sensible fallback for older renderers.
206206
*/
207207
path?: ShapePath;
208+
/**
209+
* Verbatim `<p:sp>` OOXML captured at import for a self-contained custGeom
210+
* (vector) shape, carried *in the deck JSON* so a serialize running in a
211+
* different process from the import (parse client-side → store JSON →
212+
* serialize server-side) can replay the exact source geometry rather than
213+
* re-synthesising from `path.d`. Synthesis can't express OOXML even-odd /
214+
* winding faithfully, so complex vectors blank when the process-global
215+
* source registry isn't available. Only populated for shapes whose source
216+
* XML has no external references (`r:embed` / `r:id` / `a:schemeClr`), so it
217+
* stays valid without the source archive or theme. `snapshot` is the element
218+
* snapshot at import; the serializer replays the XML only while the element
219+
* is unedited (snapshot still matches), otherwise it falls back to synthesis.
220+
* Host-opaque — do not author by hand.
221+
*/
222+
pristineOoxml?: { xml: string; snapshot: string };
208223
}
209224

210225
export interface ShapePath {

0 commit comments

Comments
 (0)