Skip to content

Commit 017ce51

Browse files
fix(pptx): harden serialize output — media dedupe, DEFLATE, SVG fallback (#94)
serializeDeck round-trips produced invalid / oversized packages: - Media de-duplication: shared images (icons, logos, backgrounds) were copied once PER reference under slidewise_preserved_N_ names — a 1.5 MB deck ballooned to ~6 MB with one image written 9×. Copies are now keyed on the immutable source path and written once; every referencing rel points at that single copy (new PreservedPartRegistry / preserveSourcePart, threaded through injectIntoSlide, copyPartDependencies, injectSlideBg). - Compression: the package shipped with JSZip's default STORE (0%), so multi-MB slide XML went out raw. generateAsync now uses DEFLATE, matching the source archive. With dedupe: ~6 MB → ~1.1 MB on the repro deck. - SVG raster fallback (1.19.1 follow-ups): - F1: the last-resort transparent PNG had a bad IDAT CRC (decodes in lenient readers, rejected by strict PNG/OOXML validators). Replaced with a CRC-correct 1×1 transparent PNG. - F2: added an optional `rasterizeSvg` hook to SerializeOptions so the headless Node/SSR path can emit a faithful raster (host injects e.g. @resvg/resvg-js) instead of a blank transparent PNG. Resolution order: host hook → browser canvas → transparent fallback. Non-PNG/throwing hook output is ignored. Tests: media-dedup.test.ts (dedupe + DEFLATE) and svg-fallback.test.ts (F1 CRC + F2 hook); both verified to fail pre-fix. Full suite 165 passing; repo typecheck clean.
1 parent d330c77 commit 017ce51

5 files changed

Lines changed: 557 additions & 62 deletions

File tree

packages/slidewise/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ export { parsePptx, isPptxTemplate, serializeDeck } from "./lib/pptx";
9292
export type {
9393
SerializeOptions,
9494
SerializeWarning,
95+
SvgRasterizer,
9596
} from "./lib/pptx";
9697
export type { ParseDiagnostics, ParseResult } from "./lib/pptx/types";
9798

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
/**
2+
* Regression guard for the "a small edit balloons the file" bug.
3+
*
4+
* A real 1.5 MB deck came back ~6 MB after a single edit-and-save because of
5+
* two independent serializer defects:
6+
*
7+
* (A) Media de-duplication was missing. The same source image is routinely
8+
* referenced from many slides (icons, logos, backgrounds). The preserve
9+
* path copied it once *per reference* under `slidewise_preserved_N_`
10+
* names — one image was written nine times — because `uniqueTarget` only
11+
* avoided path collisions, not byte duplication.
12+
*
13+
* (B) The package shipped with `STORE` (no) compression. JSZip defaults to
14+
* STORE; `finalizeOutput` never asked for DEFLATE, so multi-megabyte
15+
* slide XML (which compresses ~90%) went out raw.
16+
*
17+
* These tests build minimal packages through the public parse/serialize API and
18+
* assert neither defect recurs.
19+
*/
20+
import { describe, it, expect } from "vitest";
21+
import JSZip from "jszip";
22+
import { parsePptx, serializeDeck } from "../index";
23+
import type { Deck } from "@/lib/types";
24+
25+
const NS =
26+
`xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" ` +
27+
`xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" ` +
28+
`xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"`;
29+
30+
// A valid 1×1 transparent PNG (CRC-correct chunks).
31+
const PNG = Uint8Array.from(
32+
atob(
33+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNgAAIAAAUAAen63NgAAAAASUVORK5CYII="
34+
),
35+
(c) => c.charCodeAt(0)
36+
);
37+
38+
/** A `<p:pic>` referencing the slide-rels image `rId10`. */
39+
function pic(id: number): string {
40+
return (
41+
`<p:pic><p:nvPicPr><p:cNvPr id="${id}" name="p${id}"/><p:cNvPicPr/><p:nvPr/></p:nvPicPr>` +
42+
`<p:blipFill><a:blip r:embed="rId10"/><a:stretch><a:fillRect/></a:stretch></p:blipFill>` +
43+
`<p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="1000000" cy="1000000"/></a:xfrm>` +
44+
`<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr></p:pic>`
45+
);
46+
}
47+
48+
function slideXml(picId: number): string {
49+
return (
50+
`<?xml version="1.0"?><p:sld ${NS}><p:cSld><p:spTree>` +
51+
`<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/>` +
52+
`${pic(picId)}</p:spTree></p:cSld></p:sld>`
53+
);
54+
}
55+
56+
const SLIDE_RELS =
57+
`<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
58+
`<Relationship Id="rId10" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/shared.png"/>` +
59+
`</Relationships>`;
60+
61+
/** A two-slide source deck where BOTH slides reference the same `shared.png`. */
62+
async function twoSlidesSharingOneImage(): Promise<ArrayBuffer> {
63+
const z = new JSZip();
64+
z.file(
65+
"[Content_Types].xml",
66+
`<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +
67+
`<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>` +
68+
`<Default Extension="xml" ContentType="application/xml"/>` +
69+
`<Default Extension="png" ContentType="image/png"/>` +
70+
`<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>` +
71+
`<Override PartName="/ppt/slides/slide1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>` +
72+
`<Override PartName="/ppt/slides/slide2.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>` +
73+
`</Types>`
74+
);
75+
z.file(
76+
"_rels/.rels",
77+
`<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
78+
`<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/></Relationships>`
79+
);
80+
z.file(
81+
"ppt/presentation.xml",
82+
`<?xml version="1.0"?><p:presentation ${NS}><p:sldIdLst>` +
83+
`<p:sldId id="256" r:id="rId2"/><p:sldId id="257" r:id="rId3"/>` +
84+
`</p:sldIdLst><p:sldSz cx="12192000" cy="6858000"/></p:presentation>`
85+
);
86+
z.file(
87+
"ppt/_rels/presentation.xml.rels",
88+
`<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
89+
`<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/>` +
90+
`<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide2.xml"/></Relationships>`
91+
);
92+
z.file("ppt/slides/slide1.xml", slideXml(2));
93+
z.file("ppt/slides/slide2.xml", slideXml(3));
94+
z.file("ppt/slides/_rels/slide1.xml.rels", SLIDE_RELS);
95+
z.file("ppt/slides/_rels/slide2.xml.rels", SLIDE_RELS);
96+
z.file("ppt/media/shared.png", PNG);
97+
return z.generateAsync({ type: "arraybuffer" });
98+
}
99+
100+
describe("serialize: media de-duplication", () => {
101+
it("writes a shared image ONCE no matter how many slides reference it", async () => {
102+
const src = await twoSlidesSharingOneImage();
103+
const deck = await parsePptx(src);
104+
expect(deck.slides).toHaveLength(2);
105+
106+
const out = await serializeDeck(deck, { source: src });
107+
const zip = await JSZip.loadAsync(await out.arrayBuffer());
108+
109+
// Exactly one media part survives — not one-per-reference.
110+
const media = Object.keys(zip.files).filter(
111+
(p) => p.startsWith("ppt/media/") && !zip.files[p].dir
112+
);
113+
expect(media).toHaveLength(1);
114+
115+
// And it must NOT carry a >0 preserve index (which would mean a duplicate
116+
// copy was written before it).
117+
for (const p of media) {
118+
expect(p).not.toMatch(/slidewise_preserved_[1-9]/);
119+
}
120+
121+
// Both slides' rels resolve to that single shared part, so the image still
122+
// renders on both — dedup must not orphan a reference.
123+
const target = media[0].slice("ppt/media/".length);
124+
for (const n of [1, 2]) {
125+
const relsXml = await zip
126+
.file(`ppt/slides/_rels/slide${n}.xml.rels`)!
127+
.async("string");
128+
expect(relsXml).toContain(target);
129+
}
130+
});
131+
});
132+
133+
describe("serialize: package compression", () => {
134+
it("DEFLATEs the package instead of storing it raw", async () => {
135+
// No source needed: every save flows through finalizeOutput.
136+
const deck = {
137+
version: 0,
138+
title: "compression",
139+
slides: [
140+
{ id: "s1", elements: [] },
141+
{ id: "s2", elements: [] },
142+
],
143+
} as unknown as Deck;
144+
145+
const blob = await serializeDeck(deck);
146+
const ab = await blob.arrayBuffer();
147+
const zip = await JSZip.loadAsync(ab);
148+
149+
let totalUncompressed = 0;
150+
for (const p of Object.keys(zip.files)) {
151+
const f = zip.files[p];
152+
if (f.dir) continue;
153+
totalUncompressed += (await f.async("uint8array")).byteLength;
154+
}
155+
156+
// With STORE the archive is >= the sum of its raw parts (plus per-entry
157+
// headers); DEFLATE drops it well below. The boilerplate OOXML here
158+
// compresses to roughly a third.
159+
expect(ab.byteLength).toBeLessThan(totalUncompressed * 0.8);
160+
});
161+
});
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/**
2+
* Regression guards for the 1.19.1 SVG-fallback follow-ups.
3+
*
4+
* pptxgenjs emits an SVG image as a dual blip: a `<a:blip>` raster fallback
5+
* (`*.png`) plus an `<asvg:svgBlip>` vector (`*.svg`). It writes the SVG SOURCE
6+
* into the `.png`, so `serializeDeck` rewrites that part to a real raster.
7+
*
8+
* F1 — the last-resort transparent PNG must have CRC-correct chunks. The old
9+
* constant had a bad IDAT CRC: it decodes in lenient readers but the
10+
* strict PNG/OOXML validators this fallback exists to satisfy reject it.
11+
*
12+
* F2 — on headless Node/SSR there is no canvas, so the fallback degrades to
13+
* the transparent PNG and SVG images go blank outside PowerPoint. The
14+
* `rasterizeSvg` option lets a host inject a rasterizer (e.g. resvg) so
15+
* the headless path emits a faithful raster — without the library taking
16+
* a native dependency.
17+
*
18+
* These run on Node (no canvas), which is exactly the path that was broken.
19+
*/
20+
import { describe, it, expect } from "vitest";
21+
import JSZip from "jszip";
22+
import { serializeDeck } from "../index";
23+
import type { SvgRasterizer } from "../index";
24+
import type { Deck } from "@/lib/types";
25+
26+
const SVG =
27+
`<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">` +
28+
`<rect width="64" height="64" fill="#3366cc"/></svg>`;
29+
const SVG_DATA_URL = "data:image/svg+xml;base64," + btoa(SVG);
30+
31+
/** A deck with one model SVG image — pptxgenjs gives it a dual blip whose
32+
* `.png` fallback `serializeDeck` must repair. No `source` needed. */
33+
function deckWithSvgImage(): Deck {
34+
return {
35+
version: 0,
36+
title: "svg-fallback",
37+
slides: [
38+
{
39+
id: "s1",
40+
elements: [
41+
{
42+
id: "img1",
43+
type: "image",
44+
x: 10,
45+
y: 10,
46+
w: 100,
47+
h: 100,
48+
fit: "contain",
49+
src: SVG_DATA_URL,
50+
},
51+
],
52+
},
53+
],
54+
} as unknown as Deck;
55+
}
56+
57+
const PNG_SIG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
58+
59+
function crc32(buf: Uint8Array): number {
60+
let crc = 0xffffffff;
61+
for (let i = 0; i < buf.length; i++) {
62+
crc ^= buf[i];
63+
for (let k = 0; k < 8; k++) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
64+
}
65+
return (crc ^ 0xffffffff) >>> 0;
66+
}
67+
68+
/** True only if the bytes are a PNG AND every chunk's stored CRC-32 matches —
69+
* the exact check a strict validator (and F1) cares about. */
70+
function pngChunkCrcsValid(bytes: Uint8Array): boolean {
71+
if (bytes.length < 8) return false;
72+
for (let i = 0; i < 8; i++) if (bytes[i] !== PNG_SIG[i]) return false;
73+
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
74+
let off = 8;
75+
let sawIend = false;
76+
while (off + 12 <= bytes.length) {
77+
const len = dv.getUint32(off);
78+
const typeStart = off + 4;
79+
const dataEnd = typeStart + 4 + len;
80+
if (dataEnd + 4 > bytes.length) return false;
81+
const stored = dv.getUint32(dataEnd);
82+
if (stored !== crc32(bytes.subarray(typeStart, dataEnd))) return false;
83+
const type = String.fromCharCode(
84+
bytes[typeStart],
85+
bytes[typeStart + 1],
86+
bytes[typeStart + 2],
87+
bytes[typeStart + 3]
88+
);
89+
off = dataEnd + 4;
90+
if (type === "IEND") {
91+
sawIend = true;
92+
break;
93+
}
94+
}
95+
return sawIend;
96+
}
97+
98+
async function mediaPngBytes(blob: Blob): Promise<Uint8Array[]> {
99+
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
100+
const out: Uint8Array[] = [];
101+
for (const p of Object.keys(zip.files)) {
102+
if (!/^ppt\/media\/.+\.png$/i.test(p) || zip.files[p].dir) continue;
103+
out.push(await zip.files[p].async("uint8array"));
104+
}
105+
return out;
106+
}
107+
108+
// 1×1 opaque-red PNG (CRC-correct) — a sentinel distinct from the transparent
109+
// fallback, so we can prove the host rasterizer's output is what got written.
110+
const SENTINEL = Uint8Array.from(
111+
atob(
112+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR42mP4z8AAAAMBAQD3A0FDAAAAAElFTkSuQmCC"
113+
),
114+
(c) => c.charCodeAt(0)
115+
);
116+
117+
describe("F1: transparent-PNG fallback", () => {
118+
it("emits a CRC-correct PNG (no SVG bytes, no bad CRC) on the headless path", async () => {
119+
const blob = await serializeDeck(deckWithSvgImage());
120+
const pngs = await mediaPngBytes(blob);
121+
122+
// There must be a `.png` fallback, and every one must be a real,
123+
// CRC-correct PNG — never SVG markup, never a bad-CRC raster.
124+
expect(pngs.length).toBeGreaterThan(0);
125+
for (const bytes of pngs) {
126+
expect(pngChunkCrcsValid(bytes)).toBe(true);
127+
}
128+
});
129+
});
130+
131+
describe("F2: host SVG rasterizer hook", () => {
132+
it("uses the provided rasterizer for the .png fallback", async () => {
133+
const seen: number[] = [];
134+
const rasterizeSvg: SvgRasterizer = (svg) => {
135+
seen.push(svg.byteLength); // confirms it received the SVG bytes
136+
return SENTINEL;
137+
};
138+
const blob = await serializeDeck(deckWithSvgImage(), { rasterizeSvg });
139+
const pngs = await mediaPngBytes(blob);
140+
141+
expect(seen.length).toBeGreaterThan(0);
142+
// The fallback is exactly the rasterizer's output.
143+
expect(pngs.some((b) => b.length === SENTINEL.length && b.every((v, i) => v === SENTINEL[i]))).toBe(true);
144+
});
145+
146+
it("ignores a rasterizer that throws or returns non-PNG, falling back to a valid PNG", async () => {
147+
const bogus: SvgRasterizer = () =>
148+
new TextEncoder().encode("<svg>not a png</svg>");
149+
const blob = await serializeDeck(deckWithSvgImage(), {
150+
rasterizeSvg: bogus,
151+
});
152+
const pngs = await mediaPngBytes(blob);
153+
expect(pngs.length).toBeGreaterThan(0);
154+
for (const bytes of pngs) {
155+
// Bogus output rejected; the part still ends up a valid PNG (never the
156+
// bogus bytes).
157+
expect(pngChunkCrcsValid(bytes)).toBe(true);
158+
}
159+
});
160+
});

0 commit comments

Comments
 (0)