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
15 changes: 15 additions & 0 deletions .changeset/preserve-chrome-and-emf-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@textcortex/slidewise": minor
---

**Stop losing slide masters, layouts, themes, embedded fonts, slide backgrounds, and EMF-bearing slides on save.**

Three stacked regressions made client decks lose huge chunks of content after a single text edit:

- pptxgenjs regenerates its own `ppt/slideMasters/`, `ppt/slideLayouts/`, `ppt/theme/`, and never emits `ppt/fonts/`. On save the original chrome was thrown out — taking master-level backgrounds, brand bars, page numbers, footers, theme palettes, and embedded brand fonts with it. `preserveUnknowns` now copies these directories (plus `notesMasters`, `handoutMasters`, `tags`) from the source PPTX into the generated zip, splices the source's `<p:sldMasterIdLst>` / `<p:notesMasterIdLst>` / `<p:embeddedFontLst>` into `presentation.xml`, rewrites `presentation.xml.rels` and each slide's rels to point at the original layouts, updates `[Content_Types].xml`, and copies referenced master/layout media (renamed on collision with pptxgenjs's own media). Bails safely when source and output slide-size aspect ratios differ so 4:3 sources don't get their masters stretched onto a 16:9 canvas.

- pptxgenjs's `slide.background` only emits a flat-hex `<a:solidFill>`, collapsing gradient / image-fill / theme-referenced backgrounds (e.g. `<a:schemeClr val="tx1"/>` → `<a:srgbClr val="151515"/>`). A new per-slide pass copies the source slide's `<p:bg>` element verbatim into the output, rewriting r:id references for image-fill backgrounds and dropping the output's flat-hex stand-in when the source inherits from layout / master.

- EMF/WMF decode failures used to return `null` from the picture parser. Combined with upstream catches, a single un-decodable metafile could wipe every other element on the same slide (Dickinson sample slides 2, 3, 9 — title + subtitle + logo all gone after one text edit). The fallback now returns an `UnknownElement` so the source `<p:pic>` is re-injected verbatim and the EMF reference survives for PowerPoint to render natively.

Validated on `Dickinson_Sample_Slides.pptx` (9/9 slides retain content + slide 2's `<a:schemeClr val="tx1"/>` theme bg survives, vs 5/9 empty slides before) and `eon-deck.pptx` (28 layouts, 5 embedded fonts, and 3 themes preserved, vs 1/0/1 before).
141 changes: 141 additions & 0 deletions packages/slidewise/src/lib/pptx/__tests__/chrome-preservation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
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";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Real client decks (Dickinson, eon-deck) live in the gitignored
// `.context/attachments/` Conductor workspace dir — they're branded
// samples we can't commit publicly. Tests `it.skipIf` themselves when
// the fixture isn't on disk so CI stays green for outside contributors
// while the regression guards run locally / on workspaces that have
// the fixtures available.
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;
}
}

async function loadFixture(name: string): Promise<ArrayBuffer> {
const buf = await readFile(path.join(attachmentsDir, name));
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer;
}

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

async function listZipPaths(buf: ArrayBuffer | Blob): Promise<Set<string>> {
const ab = buf instanceof Blob ? await buf.arrayBuffer() : buf;
const zip = await JSZip.loadAsync(ab);
const paths = new Set<string>();
zip.forEach((p) => paths.add(p));
return paths;
}

async function countSlidesWithSpTreeChildren(
buf: Blob
): Promise<number> {
const zip = await JSZip.loadAsync(await buf.arrayBuffer());
let count = 0;
const slidePaths: string[] = [];
zip.forEach((p) => {
if (
p.startsWith("ppt/slides/slide") &&
p.endsWith(".xml") &&
!p.includes("/_rels/")
) {
slidePaths.push(p);
}
});
for (const p of slidePaths) {
const xml = await zip.file(p)!.async("string");
// Anything inside spTree beyond the bookkeeping group counts.
if (
/<p:sp\b/.test(xml) ||
/<p:pic\b/.test(xml) ||
/<p:graphicFrame\b/.test(xml) ||
/<p:cxnSp\b/.test(xml)
) {
count++;
}
}
return count;
}

describe("deck chrome preservation", () => {
it.skipIf(!hasEon)("preserves slide masters / layouts / theme / fonts on a 16:9 source (eon-deck)", async () => {
const source = await loadFixture("eon-deck.pptx");

const deck = await parsePptx(source);
const blob = await serializeDeck(deck, { source });

const outPaths = await listZipPaths(blob);
const srcPaths = await listZipPaths(source);

// Every master, layout, and font from the source should survive.
const srcLayouts = [...srcPaths].filter(
(p) =>
p.startsWith("ppt/slideLayouts/") &&
p.endsWith(".xml") &&
!p.includes("/_rels/")
);
const outLayouts = [...outPaths].filter(
(p) =>
p.startsWith("ppt/slideLayouts/") &&
p.endsWith(".xml") &&
!p.includes("/_rels/")
);
expect(outLayouts.length).toBe(srcLayouts.length);

const srcFonts = [...srcPaths].filter(
(p) => p.startsWith("ppt/fonts/") && !p.endsWith("/")
);
const outFonts = [...outPaths].filter(
(p) => p.startsWith("ppt/fonts/") && !p.endsWith("/")
);
expect(outFonts.length).toBe(srcFonts.length);
for (const f of srcFonts) expect(outFonts).toContain(f);

// Theme should round-trip.
expect(outPaths.has("ppt/theme/theme1.xml")).toBe(true);
});

it.skipIf(!hasDickinson)("keeps slide content intact when the source has EMF pictures (Dickinson)", async () => {
const source = await loadFixture("Dickinson_Sample_Slides.pptx");

const deck = await parsePptx(source);
expect(deck.slides.length).toBe(9);

// Slides 2, 3, 9 in the source carry EMF logos. After the EMF-decode fix
// they should still ship element content (either re-rendered images or
// UnknownElement placeholders that round-trip verbatim) rather than
// dropping their entire spTree.
for (const slideIndex of [1, 2, 8]) {
expect(deck.slides[slideIndex].elements.length).toBeGreaterThan(0);
}

const blob = await serializeDeck(deck, { source });
const nonEmptySlides = await countSlidesWithSpTreeChildren(blob);
// All 9 slides should have visible content after save.
expect(nonEmptySlides).toBe(9);

// Slide 2's background is `<a:schemeClr val="tx1"/>` in the source. After
// save it should still reference the theme color, not collapse to the
// flat `<a:srgbClr val="151515"/>` pptxgenjs would have written.
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const slide2 = await zip.file("ppt/slides/slide2.xml")!.async("string");
expect(slide2).toContain('<a:schemeClr val="tx1"/>');
expect(slide2).not.toContain('<a:srgbClr val="151515"/>');
});
});
Loading
Loading