diff --git a/.changeset/source-survives-state-cloning.md b/.changeset/source-survives-state-cloning.md new file mode 100644 index 0000000..11bdb8e --- /dev/null +++ b/.changeset/source-survives-state-cloning.md @@ -0,0 +1,13 @@ +--- +"@textcortex/slidewise": patch +--- + +**Fix: chrome / EMF / slide-bg preservation getting silently dropped after the first edit.** + +1.12.0 shipped the verbatim master / layout / theme / font / EMF / slide-bg preservation pipeline — but it never fired in practice. The pipeline relied on a non-enumerable `__slidewiseSourcePptx` attachment on the deck, which `structuredClone` (used by the store's `snap()` for history) AND every `{ ...deck, ... }` reducer spread silently strips. So the moment a user edited anything, `serializeDeck` had no source bytes to inject from and fell back to pptxgenjs's lossy emitter — exactly the regression the prior PR was supposed to fix. + +Fix: `parsePptx` now stamps the deck with an enumerable `Deck.sourcePptxId` and stashes the source bytes in a module-level cache keyed by that id. `serializeDeck` looks the bytes up by id when the caller didn't pass `options.source`. The id is a plain string field, so it survives `structuredClone`, object spread, and `JSON.parse(JSON.stringify(deck))` — any reducer-driven host (Zustand, Redux, useState, Immer) keeps the preservation pipeline alive across edits. + +The cache is in-memory only; for cross-session round-trips (page reload → rehydrate from localStorage) hosts still need to re-attach source bytes via `serializeDeck(deck, { source })`. + +New regression test in `chrome-preservation.test.ts` mirrors the broken scenario — `parsePptx` → `structuredClone` + spread → `serializeDeck()` with no explicit source — and asserts all 28 eon-deck layouts and 5 embedded fonts still make it into the saved file. diff --git a/packages/slidewise/src/lib/pptx/__tests__/chrome-preservation.test.ts b/packages/slidewise/src/lib/pptx/__tests__/chrome-preservation.test.ts index 094ae1d..cac7f59 100644 --- a/packages/slidewise/src/lib/pptx/__tests__/chrome-preservation.test.ts +++ b/packages/slidewise/src/lib/pptx/__tests__/chrome-preservation.test.ts @@ -138,4 +138,50 @@ describe("deck chrome preservation", () => { expect(slide2).toContain(''); expect(slide2).not.toContain(''); }); + + it.skipIf(!hasEon)( + "source bytes survive structuredClone (host state cloning)", + async () => { + // Mirrors what every editor reducer does: deck is spread / cloned on + // every edit. The non-enumerable SOURCE_PPTX attachment is stripped + // by structuredClone, so chrome / EMF preservation has to find the + // source via the enumerable `sourcePptxId` cache lookup instead. + const source = await loadFixture("eon-deck.pptx"); + const deck = await parsePptx(source); + expect(deck.sourcePptxId).toBeTruthy(); + + // Round-trip through structuredClone and an object spread — same + // operations the store's `snap()` and reducers perform. + const cloned = structuredClone(deck); + const edited = { + ...cloned, + title: cloned.title + " [edited]", + slides: cloned.slides.map((s) => ({ ...s })), + }; + // Non-enumerable attachments are gone; the enumerable id remains. + expect( + (edited as unknown as Record)["__slidewiseSourcePptx"] + ).toBeUndefined(); + expect(edited.sourcePptxId).toBe(deck.sourcePptxId); + + // Save with NO explicit source — preservation must still kick in + // via the module-level cache keyed by sourcePptxId. + const blob = await serializeDeck(edited); + const out = await JSZip.loadAsync(await blob.arrayBuffer()); + + let layoutCount = 0; + let fontCount = 0; + out.forEach((p) => { + if ( + p.startsWith("ppt/slideLayouts/") && + p.endsWith(".xml") && + !p.includes("/_rels/") + ) + layoutCount++; + if (p.startsWith("ppt/fonts/") && !p.endsWith("/")) fontCount++; + }); + expect(layoutCount).toBe(28); + expect(fontCount).toBe(5); + } + ); }); diff --git a/packages/slidewise/src/lib/pptx/deckToPptx.ts b/packages/slidewise/src/lib/pptx/deckToPptx.ts index eda2746..56d7fef 100644 --- a/packages/slidewise/src/lib/pptx/deckToPptx.ts +++ b/packages/slidewise/src/lib/pptx/deckToPptx.ts @@ -19,6 +19,7 @@ import { pxToInches, pxToPoints } from "./units"; import { SOURCE_PPTX, SOURCE_SLIDE_PATH, + getCachedSourceBuffer, getElementSource, snapshotElement, } from "./pptxToDeck"; @@ -434,6 +435,16 @@ async function resolveSource( } return explicit.arrayBuffer(); } + // 1. Module-level cache keyed by Deck.sourcePptxId — survives spread, + // structuredClone, and JSON round-trip within the session, so any + // reducer-driven host (Zustand, Redux, useState, etc.) keeps the + // chrome / EMF / slide-bg preservation pipeline alive. + if (deck.sourcePptxId) { + const cached = getCachedSourceBuffer(deck.sourcePptxId); + if (cached) return cached; + } + // 2. Legacy non-enumerable attachment from parsePptx. Only present when + // the deck object hasn't been spread / cloned since import. const attached = (deck as unknown as Record)[SOURCE_PPTX]; return attached instanceof ArrayBuffer ? attached : undefined; } diff --git a/packages/slidewise/src/lib/pptx/pptxToDeck.ts b/packages/slidewise/src/lib/pptx/pptxToDeck.ts index 7682f4d..1e42d5e 100644 --- a/packages/slidewise/src/lib/pptx/pptxToDeck.ts +++ b/packages/slidewise/src/lib/pptx/pptxToDeck.ts @@ -305,7 +305,20 @@ export async function parsePptx( diagnostics.warnings.push("PPTX contained no slides; created an empty one."); } - const deck: Deck = { version: CURRENT_DECK_VERSION, title, slides }; + // Stamp the deck with an enumerable id and stash the source bytes in a + // module-level cache keyed by that id. The id survives `{...deck}`, + // `structuredClone`, and `JSON.parse(JSON.stringify(deck))`, so any host + // state pipeline that does shallow / deep clones still resolves source + // bytes on save. The non-enumerable `SOURCE_PPTX` attachment is kept as + // a redundant fallback for callers that hold the deck object directly. + const sourcePptxId = nanoid(12); + sourceBufferCache.set(sourcePptxId, sourceBuffer); + const deck: Deck = { + version: CURRENT_DECK_VERSION, + title, + slides, + sourcePptxId, + }; Object.defineProperty(deck, SOURCE_PPTX, { value: sourceBuffer, enumerable: false, @@ -324,6 +337,20 @@ export async function parsePptx( export const SOURCE_PPTX = "__slidewiseSourcePptx"; export const SOURCE_SLIDE_PATH = "__slidewiseSourceSlidePath"; +/** + * Module-level cache of source PPTX bytes, keyed by `Deck.sourcePptxId`. + * Populated on `parsePptx`; read on `serializeDeck` when the caller didn't + * pass `options.source` and the non-enumerable `SOURCE_PPTX` attachment + * has been stripped (which happens the moment any reducer spreads the deck + * or any history snapshot is taken). In-memory only — survives clones + * within a session but not page reloads. + */ +const sourceBufferCache = new Map(); + +export function getCachedSourceBuffer(id: string): ArrayBuffer | undefined { + return sourceBufferCache.get(id); +} + /** * Per-element source-XML registry. Keyed by `SlideElement.id`, holds the * verbatim OOXML for every imported element + a snapshot of its semantic diff --git a/packages/slidewise/src/lib/types.ts b/packages/slidewise/src/lib/types.ts index 26479cf..2b64ac5 100644 --- a/packages/slidewise/src/lib/types.ts +++ b/packages/slidewise/src/lib/types.ts @@ -302,6 +302,17 @@ export interface Deck { version: number; title: string; slides: Slide[]; + /** + * Opaque identifier the importer stamps when a deck is parsed from a real + * PPTX. Slidewise keeps the source bytes in a module-level cache keyed by + * this id so verbatim master / layout / theme / font / EMF preservation + * still works after the host's state library has spread / cloned the deck + * (which strips non-enumerable attachments). This field is enumerable so + * it survives `structuredClone` and `JSON.parse(JSON.stringify(deck))`; + * the cache itself is in-memory only, so cross-session round-trip still + * needs the host to re-attach source bytes via `serializeDeck({ source })`. + */ + sourcePptxId?: string; } export type ElementDraft = T extends SlideElement