Skip to content
Open
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
17 changes: 17 additions & 0 deletions .changeset/patch-mode-and-idb-source.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@textcortex/slidewise": minor
---

**Edits keep their context.** Two changes that make a small edit feel like editing the real PowerPoint, not regenerating it from a stripped model.

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.

- 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).
- 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>`.
- 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.

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).

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.

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.
31 changes: 25 additions & 6 deletions packages/slidewise/src/compound/topbar/Export.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { CSSProperties, ReactNode } from "react";
import { Download } from "lucide-react";
import { useEditorStore } from "@/lib/StoreProvider";
import { serializeDeck } from "@/lib/pptx";
import { useHostCallbacks } from "../HostContext";
import { useIcons } from "../IconContext";
import { useLabels } from "../LabelsContext";
Expand All @@ -9,7 +10,12 @@ import { primaryBtnStyle, primaryHoverHandlers } from "./styles";
/**
* Export button. Calls the host's `onExport` (from `<Slidewise.Root
* onExport>`) with the current deck. If no host callback is registered,
* falls back to downloading a `.slidewise.json` of the deck.
* falls back to downloading a real `.pptx` of the deck — serializeDeck
* resolves source bytes via the in-module cache keyed by
* `Deck.sourcePptxId`, so master / layout / theme / font / EMF / slide-bg
* preservation kicks in for any deck that was parsed via `parsePptx` in
* the same session. This lets hosts verify the full edit → save round
* trip without wiring `onExport` at all.
*
* Visually emphasized vs the chrome buttons — uses `--primary-bg` so hosts
* retheming the primary surface get a consistent affordance.
Expand All @@ -35,19 +41,32 @@ export function Export({
const labels = useLabels();
const resolved = label ?? labels.export;

const onClick = () => {
const onClick = async () => {
const deck = store.getState().deck;
if (onExportHost) {
onExportHost(deck);
return;
}
const blob = new Blob([JSON.stringify(deck, null, 2)], {
type: "application/json",
});
let blob: Blob;
let extension: string;
try {
blob = await serializeDeck(deck);
extension = "pptx";
} catch (err) {
// PPTX serialization shouldn't fail on a deck the editor already
// renders, but if pptxgenjs throws (corrupt media, unsupported
// shape, etc.) we still want the user to get *something* off their
// screen rather than an unrecoverable error — fall back to JSON.
console.error("[slidewise] PPTX export failed, falling back to JSON:", err);
blob = new Blob([JSON.stringify(deck, null, 2)], {
type: "application/json",
});
extension = "slidewise.json";
}
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${(deck.title || "deck").replace(/[^a-z0-9-_]+/gi, "-")}.slidewise.json`;
a.download = `${(deck.title || "deck").replace(/[^a-z0-9-_]+/gi, "-")}.${extension}`;
a.click();
URL.revokeObjectURL(url);
};
Expand Down
146 changes: 146 additions & 0 deletions packages/slidewise/src/lib/pptx/__tests__/patch-mode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { describe, it, expect } from "vitest";
import { readFile, access } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import path from "node:path";
import JSZip from "jszip";
import { parsePptx, serializeDeck } from "../index";
import type { TextElement } from "@/lib/types";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const attachmentsDir = path.resolve(
__dirname,
"../../../../../../.context/attachments"
);

async function fixtureExists(name: string): Promise<boolean> {
try {
await access(path.join(attachmentsDir, name));
return true;
} catch {
return false;
}
}

const hasEon = await fixtureExists("eon-deck-v1.pptx");

describe("patch-mode saves preserve theme refs on text edits", () => {
it.skipIf(!hasEon)(
"edits text content without losing themed colors / fonts on slide 10 column 2",
async () => {
const buf = await readFile(path.join(attachmentsDir, "eon-deck-v1.pptx"));
const source = buf.buffer.slice(
buf.byteOffset,
buf.byteOffset + buf.byteLength
) as ArrayBuffer;
const deck = await parsePptx(source);

// Slide 10 col 2 number "2" — bg = accent1 (red), text color =
// schemeClr bg1 (white). The bg is on the slide-level <p:spPr>
// override, the text colour is in <a:rPr><a:solidFill><a:schemeClr>.
const slide10 = deck.slides[9];
const colTwo = slide10.elements.find(
(e) => e.type === "text" && (e as TextElement).text === "2"
) as TextElement | undefined;
expect(colTwo).toBeTruthy();

// Edit the text without touching any styling fields.
colTwo!.text = "II";

const blob = await serializeDeck(deck, { source });
const out = await JSZip.loadAsync(await blob.arrayBuffer());
const slide10Xml = await out
.file("ppt/slides/slide10.xml")!
.async("string");

// Edited text must be present.
expect(slide10Xml).toContain("<a:t>II</a:t>");

// The slide-level fill override (schemeClr accent1 → the red bg) must
// survive the patch path — pptxgenjs would have collapsed this to an
// inline srgbClr (or dropped it entirely on a placeholder shape).
expect(slide10Xml).toMatch(
/<p:spPr>[\s\S]*?<a:solidFill>[\s\S]*?<a:schemeClr val="accent1"\/>[\s\S]*?<\/a:solidFill>[\s\S]*?<\/p:spPr>/
);

// The themed text colour <a:rPr>…<a:schemeClr val="bg1"/> must
// survive — losing it would have rendered the "II" as the default
// body color (dark) instead of white-on-red.
expect(slide10Xml).toMatch(
/<a:rPr[\s\S]*?<a:solidFill>[\s\S]*?<a:schemeClr val="bg1"\/>[\s\S]*?<\/a:solidFill>[\s\S]*?<\/a:rPr>/
);
}
);

it.skipIf(!hasEon)(
"produces well-formed XML when the source has self-closing <p:spPr/>",
async () => {
// eon-deck slide 10 has placeholders whose slide-level spPr is empty
// (purely inheriting from layout). The importer registers them
// without an explicit xfrm; patch-mode has to splice xfrm INSIDE
// the spPr container, not after a self-closing tag. A previous
// version of this code emitted `<p:spPr/><a:xfrm>…` which is
// invalid OOXML — PowerPoint silently dropped the shape.
const buf = await readFile(path.join(attachmentsDir, "eon-deck-v1.pptx"));
const source = buf.buffer.slice(
buf.byteOffset,
buf.byteOffset + buf.byteLength
) as ArrayBuffer;
const deck = await parsePptx(source);
const slide10 = deck.slides[9];
// Edit every text element on the slide and confirm none of them
// produce malformed XML.
for (const el of slide10.elements) {
if (el.type === "text") (el as TextElement).text += "!";
}
const blob = await serializeDeck(deck, { source });
const out = await JSZip.loadAsync(await blob.arrayBuffer());
const xml = await out.file("ppt/slides/slide10.xml")!.async("string");
// Every shape's spPr must be either self-closing OR balanced.
// No `<p:spPr/><a:xfrm` anywhere — that's the malformed pattern.
expect(xml).not.toMatch(/<p:spPr\b[^>]*\/\s*>\s*<a:xfrm/);
// Sanity: opening and closing p:spPr counts must match (treating
// self-closing as a balanced pair on its own).
const open = (xml.match(/<p:spPr\b[^/]*>/g) ?? []).length;
const close = (xml.match(/<\/p:spPr>/g) ?? []).length;
expect(open).toBe(close);
}
);

it.skipIf(!hasEon)(
"moves an element via geometry-only patch, keeping fill / themed color verbatim",
async () => {
const buf = await readFile(path.join(attachmentsDir, "eon-deck-v1.pptx"));
const source = buf.buffer.slice(
buf.byteOffset,
buf.byteOffset + buf.byteLength
) as ArrayBuffer;
const deck = await parsePptx(source);

const slide10 = deck.slides[9];
const colTwo = slide10.elements.find(
(e) => e.type === "text" && (e as TextElement).text === "2"
) as TextElement | undefined;
expect(colTwo).toBeTruthy();
const originalX = colTwo!.x;
colTwo!.x = originalX + 100; // user dragged it right 100 px

const blob = await serializeDeck(deck, { source });
const out = await JSZip.loadAsync(await blob.arrayBuffer());
const slide10Xml = await out
.file("ppt/slides/slide10.xml")!
.async("string");

// The themed fill + text color must remain intact after the move.
expect(slide10Xml).toMatch(
/<p:spPr>[\s\S]*?<a:solidFill>[\s\S]*?<a:schemeClr val="accent1"\/>/
);
expect(slide10Xml).toMatch(
/<a:rPr[\s\S]*?<a:schemeClr val="bg1"\/>/
);
// The xfrm must reflect the new x.
const newOffX = Math.round((originalX + 100) * (914400 / 144));
expect(slide10Xml).toContain(`<a:off x="${newOffX}"`);
}
);
});
41 changes: 41 additions & 0 deletions packages/slidewise/src/lib/pptx/__tests__/slide10-bg.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, it, expect } from "vitest";
import { readFile, access } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import path from "node:path";
import { parsePptx } from "../index";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const attachmentsDir = path.resolve(
__dirname,
"../../../../../../.context/attachments"
);

async function fixtureExists(name: string): Promise<boolean> {
try {
await access(path.join(attachmentsDir, name));
return true;
} catch {
return false;
}
}

const has = await fixtureExists("eon-deck-v1.pptx");

describe("eon-deck slide 10 column 2 background", () => {
it.skipIf(!has)("imports column 2 number placeholder with red bg + white text", async () => {
const buf = await readFile(path.join(attachmentsDir, "eon-deck-v1.pptx"));
const deck = await parsePptx(
buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer
);
const slide10 = deck.slides[9];
const colTwo = slide10.elements.find(
(e) => e.type === "text" && (e as { text: string }).text === "2"
) as { background?: string; color?: string; w: number; h: number } | undefined;
expect(colTwo).toBeTruthy();
expect(colTwo!.background?.toUpperCase()).toBe("#EA1B0A");
expect(colTwo!.color?.toUpperCase()).toBe("#FFFFFF");
expect(colTwo!.w).toBeGreaterThan(300);
expect(colTwo!.h).toBeGreaterThan(700);
});
});
Loading
Loading