Skip to content

Commit 4e621f3

Browse files
feat(pptx): patch-mode saves + IndexedDB-backed source persistence
Two changes that make edits feel like editing the real PowerPoint, not regenerating from a stripped model. 1. Patch-mode saves: edited elements get their source <p:sp> spliced in place instead of regenerated via pptxgenjs, so themed colors / brand fonts / gradient fills / custGeom / effects / autofit / body padding on the unchanged parts of the element survive verbatim. Covers text content edits (splice <p:txBody> preserving first paragraph's pPr and first run's rPr) and geometry edits (splice <a:xfrm> or <p:xfrm> for <p:graphicFrame>). pptxgenjs remains the fallback for unpatchable cases. Placeholder-inherited shapes (no explicit xfrm in source) are now registered too — patch-mode always splices geometry into the patched output for them. 2. IndexedDB-backed source persistence: parsePptx mirrors source bytes to IndexedDB keyed by deck.sourcePptxId; serializeDeck reads through in-memory cache → IndexedDB → non-enumerable attachment → explicit options.source. The chrome / EMF / slide-bg preservation pipeline now survives page reloads on its own — host apps that persist deck JSON to localStorage no longer need to re-attach source bytes. Also: Export topbar button falls back to a real .pptx download (via serializeDeck) instead of a .slidewise.json dump when the host doesn't register an onExport callback. Makes local round-trip testing trivial. Validated end-to-end on KBC-More_sample_slides.pptx: after parsePptx → structuredClone + spread → serializeDeck(deck) with no options.source, the saved zip retains all 2 masters, 50 layouts, and 3 themes versus the 1/1/1 the previous build produced. New regression tests in patch-mode.test.ts and slide10-bg.test.ts cover the theme-ref and geometry-patch paths.
1 parent 6884d47 commit 4e621f3

7 files changed

Lines changed: 683 additions & 29 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@textcortex/slidewise": minor
3+
---
4+
5+
**Edits keep their context.** Two changes that make a small edit feel like editing the real PowerPoint, not regenerating it from a stripped model.
6+
7+
1. **Patch-mode saves** — when an edit only touches fields the importer knows how to splice back into the source OOXML (text content, geometry), the source `<p:sp>` / `<p:pic>` / `<p:graphicFrame>` is patched in place instead of being regenerated via pptxgenjs. Everything else on that element — themed colors (`<a:schemeClr>`), brand fonts (`<a:latin>` / `<a:ea>` / `<a:cs>`), gradient and image fills, `<a:custGeom>` silhouettes, body padding, autofit hints, line styling, `<a:effectLst>` shadows — survives verbatim because it was never touched. Modelled after Univer's "edit the source doc tree, never round-trip through a lossy intermediate model" approach.
8+
9+
- Text content edits: splice the new text into the source `<p:txBody>` preserving the first paragraph's `<a:pPr>` and the first run's `<a:rPr>` so themed colors / fonts / bullets / alignment carry through. Multi-line text becomes multi-paragraph; mixed-style runs still fall back to pptxgenjs (future work).
10+
- Geometry edits (drag / resize / rotate): splice `<a:xfrm>` (or `<p:xfrm>` for `<p:graphicFrame>`) and keep everything else verbatim. Works on `<p:sp>`, `<p:pic>`, `<p:cxnSp>`, `<p:graphicFrame>`.
11+
- Placeholder-inherited shapes (no explicit xfrm in source) are now registered too. Patch-mode handles them by always splicing the current geometry into the patched output, so text edits on title / body / content placeholders keep their themed styling.
12+
13+
pptxgenjs remains the fallback emitter for unpatchable cases (newly added elements, font / color changes via the editor's pickers, mixed-style run restyling, shape kind changes).
14+
15+
2. **IndexedDB-backed source persistence**`parsePptx` now mirrors source bytes to IndexedDB keyed by `Deck.sourcePptxId`. `serializeDeck`'s source resolution checks the in-memory cache first, then IndexedDB, then the legacy non-enumerable attachment, then the host-supplied `options.source`. This means the chrome / EMF / slide-bg preservation pipeline survives full page reloads on its own — host apps that persist the deck JSON in localStorage and rehydrate on reload no longer need to also re-attach the original bytes by hand. Falls back cleanly in SSR / Node environments where IndexedDB is undefined.
16+
17+
Validated on `KBC-More_sample_slides.pptx`: after `parsePptx → structuredClone + spread → serializeDeck(deck)` (no `source` passed), the saved zip retains all **2 masters, 50 layouts, and 3 themes** vs the 1/1/1 the broken 1.12.1 build produced. New regression tests in `patch-mode.test.ts` confirm a text edit on `eon-deck.pptx` slide 10 column 2 keeps the source `<a:schemeClr val="accent1"/>` fill and the `<a:schemeClr val="bg1"/>` text color, and a position drag preserves both.

packages/slidewise/src/compound/topbar/Export.tsx

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { CSSProperties, ReactNode } from "react";
22
import { Download } from "lucide-react";
33
import { useEditorStore } from "@/lib/StoreProvider";
4+
import { serializeDeck } from "@/lib/pptx";
45
import { useHostCallbacks } from "../HostContext";
56
import { useIcons } from "../IconContext";
67
import { useLabels } from "../LabelsContext";
@@ -9,7 +10,12 @@ import { primaryBtnStyle, primaryHoverHandlers } from "./styles";
910
/**
1011
* Export button. Calls the host's `onExport` (from `<Slidewise.Root
1112
* onExport>`) with the current deck. If no host callback is registered,
12-
* falls back to downloading a `.slidewise.json` of the deck.
13+
* falls back to downloading a real `.pptx` of the deck — serializeDeck
14+
* resolves source bytes via the in-module cache keyed by
15+
* `Deck.sourcePptxId`, so master / layout / theme / font / EMF / slide-bg
16+
* preservation kicks in for any deck that was parsed via `parsePptx` in
17+
* the same session. This lets hosts verify the full edit → save round
18+
* trip without wiring `onExport` at all.
1319
*
1420
* Visually emphasized vs the chrome buttons — uses `--primary-bg` so hosts
1521
* retheming the primary surface get a consistent affordance.
@@ -35,19 +41,32 @@ export function Export({
3541
const labels = useLabels();
3642
const resolved = label ?? labels.export;
3743

38-
const onClick = () => {
44+
const onClick = async () => {
3945
const deck = store.getState().deck;
4046
if (onExportHost) {
4147
onExportHost(deck);
4248
return;
4349
}
44-
const blob = new Blob([JSON.stringify(deck, null, 2)], {
45-
type: "application/json",
46-
});
50+
let blob: Blob;
51+
let extension: string;
52+
try {
53+
blob = await serializeDeck(deck);
54+
extension = "pptx";
55+
} catch (err) {
56+
// PPTX serialization shouldn't fail on a deck the editor already
57+
// renders, but if pptxgenjs throws (corrupt media, unsupported
58+
// shape, etc.) we still want the user to get *something* off their
59+
// screen rather than an unrecoverable error — fall back to JSON.
60+
console.error("[slidewise] PPTX export failed, falling back to JSON:", err);
61+
blob = new Blob([JSON.stringify(deck, null, 2)], {
62+
type: "application/json",
63+
});
64+
extension = "slidewise.json";
65+
}
4766
const url = URL.createObjectURL(blob);
4867
const a = document.createElement("a");
4968
a.href = url;
50-
a.download = `${(deck.title || "deck").replace(/[^a-z0-9-_]+/gi, "-")}.slidewise.json`;
69+
a.download = `${(deck.title || "deck").replace(/[^a-z0-9-_]+/gi, "-")}.${extension}`;
5170
a.click();
5271
URL.revokeObjectURL(url);
5372
};
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { describe, it, expect } from "vitest";
2+
import { readFile, access } from "node:fs/promises";
3+
import { fileURLToPath } from "node:url";
4+
import path from "node:path";
5+
import JSZip from "jszip";
6+
import { parsePptx, serializeDeck } from "../index";
7+
import type { TextElement } from "@/lib/types";
8+
9+
const __filename = fileURLToPath(import.meta.url);
10+
const __dirname = path.dirname(__filename);
11+
const attachmentsDir = path.resolve(
12+
__dirname,
13+
"../../../../../../.context/attachments"
14+
);
15+
16+
async function fixtureExists(name: string): Promise<boolean> {
17+
try {
18+
await access(path.join(attachmentsDir, name));
19+
return true;
20+
} catch {
21+
return false;
22+
}
23+
}
24+
25+
const hasEon = await fixtureExists("eon-deck-v1.pptx");
26+
27+
describe("patch-mode saves preserve theme refs on text edits", () => {
28+
it.skipIf(!hasEon)(
29+
"edits text content without losing themed colors / fonts on slide 10 column 2",
30+
async () => {
31+
const buf = await readFile(path.join(attachmentsDir, "eon-deck-v1.pptx"));
32+
const source = buf.buffer.slice(
33+
buf.byteOffset,
34+
buf.byteOffset + buf.byteLength
35+
) as ArrayBuffer;
36+
const deck = await parsePptx(source);
37+
38+
// Slide 10 col 2 number "2" — bg = accent1 (red), text color =
39+
// schemeClr bg1 (white). The bg is on the slide-level <p:spPr>
40+
// override, the text colour is in <a:rPr><a:solidFill><a:schemeClr>.
41+
const slide10 = deck.slides[9];
42+
const colTwo = slide10.elements.find(
43+
(e) => e.type === "text" && (e as TextElement).text === "2"
44+
) as TextElement | undefined;
45+
expect(colTwo).toBeTruthy();
46+
47+
// Edit the text without touching any styling fields.
48+
colTwo!.text = "II";
49+
50+
const blob = await serializeDeck(deck, { source });
51+
const out = await JSZip.loadAsync(await blob.arrayBuffer());
52+
const slide10Xml = await out
53+
.file("ppt/slides/slide10.xml")!
54+
.async("string");
55+
56+
// Edited text must be present.
57+
expect(slide10Xml).toContain("<a:t>II</a:t>");
58+
59+
// The slide-level fill override (schemeClr accent1 → the red bg) must
60+
// survive the patch path — pptxgenjs would have collapsed this to an
61+
// inline srgbClr (or dropped it entirely on a placeholder shape).
62+
expect(slide10Xml).toMatch(
63+
/<p:spPr>[\s\S]*?<a:solidFill>[\s\S]*?<a:schemeClr val="accent1"\/>[\s\S]*?<\/a:solidFill>[\s\S]*?<\/p:spPr>/
64+
);
65+
66+
// The themed text colour <a:rPr>…<a:schemeClr val="bg1"/> must
67+
// survive — losing it would have rendered the "II" as the default
68+
// body color (dark) instead of white-on-red.
69+
expect(slide10Xml).toMatch(
70+
/<a:rPr[\s\S]*?<a:solidFill>[\s\S]*?<a:schemeClr val="bg1"\/>[\s\S]*?<\/a:solidFill>[\s\S]*?<\/a:rPr>/
71+
);
72+
}
73+
);
74+
75+
it.skipIf(!hasEon)(
76+
"moves an element via geometry-only patch, keeping fill / themed color verbatim",
77+
async () => {
78+
const buf = await readFile(path.join(attachmentsDir, "eon-deck-v1.pptx"));
79+
const source = buf.buffer.slice(
80+
buf.byteOffset,
81+
buf.byteOffset + buf.byteLength
82+
) as ArrayBuffer;
83+
const deck = await parsePptx(source);
84+
85+
const slide10 = deck.slides[9];
86+
const colTwo = slide10.elements.find(
87+
(e) => e.type === "text" && (e as TextElement).text === "2"
88+
) as TextElement | undefined;
89+
expect(colTwo).toBeTruthy();
90+
const originalX = colTwo!.x;
91+
colTwo!.x = originalX + 100; // user dragged it right 100 px
92+
93+
const blob = await serializeDeck(deck, { source });
94+
const out = await JSZip.loadAsync(await blob.arrayBuffer());
95+
const slide10Xml = await out
96+
.file("ppt/slides/slide10.xml")!
97+
.async("string");
98+
99+
// The themed fill + text color must remain intact after the move.
100+
expect(slide10Xml).toMatch(
101+
/<p:spPr>[\s\S]*?<a:solidFill>[\s\S]*?<a:schemeClr val="accent1"\/>/
102+
);
103+
expect(slide10Xml).toMatch(
104+
/<a:rPr[\s\S]*?<a:schemeClr val="bg1"\/>/
105+
);
106+
// The xfrm must reflect the new x.
107+
const newOffX = Math.round((originalX + 100) * (914400 / 144));
108+
expect(slide10Xml).toContain(`<a:off x="${newOffX}"`);
109+
}
110+
);
111+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { describe, it, expect } from "vitest";
2+
import { readFile, access } from "node:fs/promises";
3+
import { fileURLToPath } from "node:url";
4+
import path from "node:path";
5+
import { parsePptx } from "../index";
6+
7+
const __filename = fileURLToPath(import.meta.url);
8+
const __dirname = path.dirname(__filename);
9+
const attachmentsDir = path.resolve(
10+
__dirname,
11+
"../../../../../../.context/attachments"
12+
);
13+
14+
async function fixtureExists(name: string): Promise<boolean> {
15+
try {
16+
await access(path.join(attachmentsDir, name));
17+
return true;
18+
} catch {
19+
return false;
20+
}
21+
}
22+
23+
const has = await fixtureExists("eon-deck-v1.pptx");
24+
25+
describe("eon-deck slide 10 column 2 background", () => {
26+
it.skipIf(!has)("imports column 2 number placeholder with red bg + white text", async () => {
27+
const buf = await readFile(path.join(attachmentsDir, "eon-deck-v1.pptx"));
28+
const deck = await parsePptx(
29+
buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer
30+
);
31+
const slide10 = deck.slides[9];
32+
const colTwo = slide10.elements.find(
33+
(e) => e.type === "text" && (e as { text: string }).text === "2"
34+
) as { background?: string; color?: string; w: number; h: number } | undefined;
35+
expect(colTwo).toBeTruthy();
36+
expect(colTwo!.background?.toUpperCase()).toBe("#EA1B0A");
37+
expect(colTwo!.color?.toUpperCase()).toBe("#FFFFFF");
38+
expect(colTwo!.w).toBeGreaterThan(300);
39+
expect(colTwo!.h).toBeGreaterThan(700);
40+
});
41+
});

0 commit comments

Comments
 (0)