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
1 change: 1 addition & 0 deletions packages/slidewise/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export { parsePptx, isPptxTemplate, serializeDeck } from "./lib/pptx";
export type {
SerializeOptions,
SerializeWarning,
SvgRasterizer,
} from "./lib/pptx";
export type { ParseDiagnostics, ParseResult } from "./lib/pptx/types";

Expand Down
161 changes: 161 additions & 0 deletions packages/slidewise/src/lib/pptx/__tests__/media-dedup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/**
* Regression guard for the "a small edit balloons the file" bug.
*
* A real 1.5 MB deck came back ~6 MB after a single edit-and-save because of
* two independent serializer defects:
*
* (A) Media de-duplication was missing. The same source image is routinely
* referenced from many slides (icons, logos, backgrounds). The preserve
* path copied it once *per reference* under `slidewise_preserved_N_`
* names — one image was written nine times — because `uniqueTarget` only
* avoided path collisions, not byte duplication.
*
* (B) The package shipped with `STORE` (no) compression. JSZip defaults to
* STORE; `finalizeOutput` never asked for DEFLATE, so multi-megabyte
* slide XML (which compresses ~90%) went out raw.
*
* These tests build minimal packages through the public parse/serialize API and
* assert neither defect recurs.
*/
import { describe, it, expect } from "vitest";
import JSZip from "jszip";
import { parsePptx, serializeDeck } from "../index";
import type { Deck } from "@/lib/types";

const NS =
`xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" ` +
`xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" ` +
`xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"`;

// A valid 1×1 transparent PNG (CRC-correct chunks).
const PNG = Uint8Array.from(
atob(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNgAAIAAAUAAen63NgAAAAASUVORK5CYII="
),
(c) => c.charCodeAt(0)
);

/** A `<p:pic>` referencing the slide-rels image `rId10`. */
function pic(id: number): string {
return (
`<p:pic><p:nvPicPr><p:cNvPr id="${id}" name="p${id}"/><p:cNvPicPr/><p:nvPr/></p:nvPicPr>` +
`<p:blipFill><a:blip r:embed="rId10"/><a:stretch><a:fillRect/></a:stretch></p:blipFill>` +
`<p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="1000000" cy="1000000"/></a:xfrm>` +
`<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr></p:pic>`
);
}

function slideXml(picId: number): string {
return (
`<?xml version="1.0"?><p:sld ${NS}><p:cSld><p:spTree>` +
`<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/>` +
`${pic(picId)}</p:spTree></p:cSld></p:sld>`
);
}

const SLIDE_RELS =
`<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
`<Relationship Id="rId10" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/shared.png"/>` +
`</Relationships>`;

/** A two-slide source deck where BOTH slides reference the same `shared.png`. */
async function twoSlidesSharingOneImage(): Promise<ArrayBuffer> {
const z = new JSZip();
z.file(
"[Content_Types].xml",
`<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +
`<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>` +
`<Default Extension="xml" ContentType="application/xml"/>` +
`<Default Extension="png" ContentType="image/png"/>` +
`<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>` +
`<Override PartName="/ppt/slides/slide1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>` +
`<Override PartName="/ppt/slides/slide2.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>` +
`</Types>`
);
z.file(
"_rels/.rels",
`<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
`<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/></Relationships>`
);
z.file(
"ppt/presentation.xml",
`<?xml version="1.0"?><p:presentation ${NS}><p:sldIdLst>` +
`<p:sldId id="256" r:id="rId2"/><p:sldId id="257" r:id="rId3"/>` +
`</p:sldIdLst><p:sldSz cx="12192000" cy="6858000"/></p:presentation>`
);
z.file(
"ppt/_rels/presentation.xml.rels",
`<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
`<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/>` +
`<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide2.xml"/></Relationships>`
);
z.file("ppt/slides/slide1.xml", slideXml(2));
z.file("ppt/slides/slide2.xml", slideXml(3));
z.file("ppt/slides/_rels/slide1.xml.rels", SLIDE_RELS);
z.file("ppt/slides/_rels/slide2.xml.rels", SLIDE_RELS);
z.file("ppt/media/shared.png", PNG);
return z.generateAsync({ type: "arraybuffer" });
}

describe("serialize: media de-duplication", () => {
it("writes a shared image ONCE no matter how many slides reference it", async () => {
const src = await twoSlidesSharingOneImage();
const deck = await parsePptx(src);
expect(deck.slides).toHaveLength(2);

const out = await serializeDeck(deck, { source: src });
const zip = await JSZip.loadAsync(await out.arrayBuffer());

// Exactly one media part survives — not one-per-reference.
const media = Object.keys(zip.files).filter(
(p) => p.startsWith("ppt/media/") && !zip.files[p].dir
);
expect(media).toHaveLength(1);

// And it must NOT carry a >0 preserve index (which would mean a duplicate
// copy was written before it).
for (const p of media) {
expect(p).not.toMatch(/slidewise_preserved_[1-9]/);
}

// Both slides' rels resolve to that single shared part, so the image still
// renders on both — dedup must not orphan a reference.
const target = media[0].slice("ppt/media/".length);
for (const n of [1, 2]) {
const relsXml = await zip
.file(`ppt/slides/_rels/slide${n}.xml.rels`)!
.async("string");
expect(relsXml).toContain(target);
}
});
});

describe("serialize: package compression", () => {
it("DEFLATEs the package instead of storing it raw", async () => {
// No source needed: every save flows through finalizeOutput.
const deck = {
version: 0,
title: "compression",
slides: [
{ id: "s1", elements: [] },
{ id: "s2", elements: [] },
],
} as unknown as Deck;

const blob = await serializeDeck(deck);
const ab = await blob.arrayBuffer();
const zip = await JSZip.loadAsync(ab);

let totalUncompressed = 0;
for (const p of Object.keys(zip.files)) {
const f = zip.files[p];
if (f.dir) continue;
totalUncompressed += (await f.async("uint8array")).byteLength;
}

// With STORE the archive is >= the sum of its raw parts (plus per-entry
// headers); DEFLATE drops it well below. The boilerplate OOXML here
// compresses to roughly a third.
expect(ab.byteLength).toBeLessThan(totalUncompressed * 0.8);
});
});
160 changes: 160 additions & 0 deletions packages/slidewise/src/lib/pptx/__tests__/svg-fallback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/**
* Regression guards for the 1.19.1 SVG-fallback follow-ups.
*
* pptxgenjs emits an SVG image as a dual blip: a `<a:blip>` raster fallback
* (`*.png`) plus an `<asvg:svgBlip>` vector (`*.svg`). It writes the SVG SOURCE
* into the `.png`, so `serializeDeck` rewrites that part to a real raster.
*
* F1 — the last-resort transparent PNG must have CRC-correct chunks. The old
* constant had a bad IDAT CRC: it decodes in lenient readers but the
* strict PNG/OOXML validators this fallback exists to satisfy reject it.
*
* F2 — on headless Node/SSR there is no canvas, so the fallback degrades to
* the transparent PNG and SVG images go blank outside PowerPoint. The
* `rasterizeSvg` option lets a host inject a rasterizer (e.g. resvg) so
* the headless path emits a faithful raster — without the library taking
* a native dependency.
*
* These run on Node (no canvas), which is exactly the path that was broken.
*/
import { describe, it, expect } from "vitest";
import JSZip from "jszip";
import { serializeDeck } from "../index";
import type { SvgRasterizer } from "../index";
import type { Deck } from "@/lib/types";

const SVG =
`<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">` +
`<rect width="64" height="64" fill="#3366cc"/></svg>`;
const SVG_DATA_URL = "data:image/svg+xml;base64," + btoa(SVG);

/** A deck with one model SVG image — pptxgenjs gives it a dual blip whose
* `.png` fallback `serializeDeck` must repair. No `source` needed. */
function deckWithSvgImage(): Deck {
return {
version: 0,
title: "svg-fallback",
slides: [
{
id: "s1",
elements: [
{
id: "img1",
type: "image",
x: 10,
y: 10,
w: 100,
h: 100,
fit: "contain",
src: SVG_DATA_URL,
},
],
},
],
} as unknown as Deck;
}

const PNG_SIG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];

function crc32(buf: Uint8Array): number {
let crc = 0xffffffff;
for (let i = 0; i < buf.length; i++) {
crc ^= buf[i];
for (let k = 0; k < 8; k++) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
}
return (crc ^ 0xffffffff) >>> 0;
}

/** True only if the bytes are a PNG AND every chunk's stored CRC-32 matches —
* the exact check a strict validator (and F1) cares about. */
function pngChunkCrcsValid(bytes: Uint8Array): boolean {
if (bytes.length < 8) return false;
for (let i = 0; i < 8; i++) if (bytes[i] !== PNG_SIG[i]) return false;
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
let off = 8;
let sawIend = false;
while (off + 12 <= bytes.length) {
const len = dv.getUint32(off);
const typeStart = off + 4;
const dataEnd = typeStart + 4 + len;
if (dataEnd + 4 > bytes.length) return false;
const stored = dv.getUint32(dataEnd);
if (stored !== crc32(bytes.subarray(typeStart, dataEnd))) return false;
const type = String.fromCharCode(
bytes[typeStart],
bytes[typeStart + 1],
bytes[typeStart + 2],
bytes[typeStart + 3]
);
off = dataEnd + 4;
if (type === "IEND") {
sawIend = true;
break;
}
}
return sawIend;
}

async function mediaPngBytes(blob: Blob): Promise<Uint8Array[]> {
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const out: Uint8Array[] = [];
for (const p of Object.keys(zip.files)) {
if (!/^ppt\/media\/.+\.png$/i.test(p) || zip.files[p].dir) continue;
out.push(await zip.files[p].async("uint8array"));
}
return out;
}

// 1×1 opaque-red PNG (CRC-correct) — a sentinel distinct from the transparent
// fallback, so we can prove the host rasterizer's output is what got written.
const SENTINEL = Uint8Array.from(
atob(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR42mP4z8AAAAMBAQD3A0FDAAAAAElFTkSuQmCC"
),
(c) => c.charCodeAt(0)
);

describe("F1: transparent-PNG fallback", () => {
it("emits a CRC-correct PNG (no SVG bytes, no bad CRC) on the headless path", async () => {
const blob = await serializeDeck(deckWithSvgImage());
const pngs = await mediaPngBytes(blob);

// There must be a `.png` fallback, and every one must be a real,
// CRC-correct PNG — never SVG markup, never a bad-CRC raster.
expect(pngs.length).toBeGreaterThan(0);
for (const bytes of pngs) {
expect(pngChunkCrcsValid(bytes)).toBe(true);
}
});
});

describe("F2: host SVG rasterizer hook", () => {
it("uses the provided rasterizer for the .png fallback", async () => {
const seen: number[] = [];
const rasterizeSvg: SvgRasterizer = (svg) => {
seen.push(svg.byteLength); // confirms it received the SVG bytes
return SENTINEL;
};
const blob = await serializeDeck(deckWithSvgImage(), { rasterizeSvg });
const pngs = await mediaPngBytes(blob);

expect(seen.length).toBeGreaterThan(0);
// The fallback is exactly the rasterizer's output.
expect(pngs.some((b) => b.length === SENTINEL.length && b.every((v, i) => v === SENTINEL[i]))).toBe(true);
});

it("ignores a rasterizer that throws or returns non-PNG, falling back to a valid PNG", async () => {
const bogus: SvgRasterizer = () =>
new TextEncoder().encode("<svg>not a png</svg>");
const blob = await serializeDeck(deckWithSvgImage(), {
rasterizeSvg: bogus,
});
const pngs = await mediaPngBytes(blob);
expect(pngs.length).toBeGreaterThan(0);
for (const bytes of pngs) {
// Bogus output rejected; the part still ends up a valid PNG (never the
// bogus bytes).
expect(pngChunkCrcsValid(bytes)).toBe(true);
}
});
});
Loading
Loading