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