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
13 changes: 13 additions & 0 deletions .changeset/source-survives-state-cloning.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -138,4 +138,50 @@ describe("deck chrome preservation", () => {
expect(slide2).toContain('<a:schemeClr val="tx1"/>');
expect(slide2).not.toContain('<a:srgbClr val="151515"/>');
});

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<string, unknown>)["__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);
}
);
});
11 changes: 11 additions & 0 deletions packages/slidewise/src/lib/pptx/deckToPptx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { pxToInches, pxToPoints } from "./units";
import {
SOURCE_PPTX,
SOURCE_SLIDE_PATH,
getCachedSourceBuffer,
getElementSource,
snapshotElement,
} from "./pptxToDeck";
Expand Down Expand Up @@ -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<string, unknown>)[SOURCE_PPTX];
return attached instanceof ArrayBuffer ? attached : undefined;
}
Expand Down
29 changes: 28 additions & 1 deletion packages/slidewise/src/lib/pptx/pptxToDeck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<string, ArrayBuffer>();

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
Expand Down
11 changes: 11 additions & 0 deletions packages/slidewise/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = SlideElement> = T extends SlideElement
Expand Down
Loading