From 4561003d4cf9584e595172b89cc20366d795bc27 Mon Sep 17 00:00:00 2001 From: karthikmudunuri <102793643+karthikmudunuri@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:11:30 +0530 Subject: [PATCH] =?UTF-8?q?fix(pptx):=20harden=20serialize=20output=20?= =?UTF-8?q?=E2=80=94=20media=20dedupe,=20DEFLATE,=20SVG=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/slidewise/src/index.ts | 1 + .../lib/pptx/__tests__/media-dedup.test.ts | 161 ++++++++++ .../lib/pptx/__tests__/svg-fallback.test.ts | 160 ++++++++++ packages/slidewise/src/lib/pptx/deckToPptx.ts | 291 ++++++++++++++---- packages/slidewise/src/lib/pptx/index.ts | 6 +- 5 files changed, 557 insertions(+), 62 deletions(-) create mode 100644 packages/slidewise/src/lib/pptx/__tests__/media-dedup.test.ts create mode 100644 packages/slidewise/src/lib/pptx/__tests__/svg-fallback.test.ts diff --git a/packages/slidewise/src/index.ts b/packages/slidewise/src/index.ts index 7aeff4c..fa9e09c 100644 --- a/packages/slidewise/src/index.ts +++ b/packages/slidewise/src/index.ts @@ -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"; diff --git a/packages/slidewise/src/lib/pptx/__tests__/media-dedup.test.ts b/packages/slidewise/src/lib/pptx/__tests__/media-dedup.test.ts new file mode 100644 index 0000000..fe44403 --- /dev/null +++ b/packages/slidewise/src/lib/pptx/__tests__/media-dedup.test.ts @@ -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 `` referencing the slide-rels image `rId10`. */ +function pic(id: number): string { + return ( + `` + + `` + + `` + + `` + ); +} + +function slideXml(picId: number): string { + return ( + `` + + `` + + `${pic(picId)}` + ); +} + +const SLIDE_RELS = + `` + + `` + + ``; + +/** A two-slide source deck where BOTH slides reference the same `shared.png`. */ +async function twoSlidesSharingOneImage(): Promise { + const z = new JSZip(); + z.file( + "[Content_Types].xml", + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + ); + z.file( + "_rels/.rels", + `` + + `` + ); + z.file( + "ppt/presentation.xml", + `` + + `` + + `` + ); + z.file( + "ppt/_rels/presentation.xml.rels", + `` + + `` + + `` + ); + 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); + }); +}); diff --git a/packages/slidewise/src/lib/pptx/__tests__/svg-fallback.test.ts b/packages/slidewise/src/lib/pptx/__tests__/svg-fallback.test.ts new file mode 100644 index 0000000..196270b --- /dev/null +++ b/packages/slidewise/src/lib/pptx/__tests__/svg-fallback.test.ts @@ -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 `` raster fallback + * (`*.png`) plus an `` 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 = + `` + + ``; +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 { + 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("not a png"); + 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); + } + }); +}); diff --git a/packages/slidewise/src/lib/pptx/deckToPptx.ts b/packages/slidewise/src/lib/pptx/deckToPptx.ts index 4a4bafd..17ee508 100644 --- a/packages/slidewise/src/lib/pptx/deckToPptx.ts +++ b/packages/slidewise/src/lib/pptx/deckToPptx.ts @@ -99,8 +99,42 @@ export interface SerializeOptions { * instead of only seeing it in the console. */ onWarning?: (warning: SerializeWarning) => void; + /** + * Rasterize an SVG to PNG bytes. pptxgenjs emits SVG images as a dual blip + * (`` raster + `` vector) but writes the SVG *source* + * into the `.png` raster fallback; the fallback must be a real PNG or strict + * consumers (Google Slides, LibreOffice, thumbnail/raster renderers, OOXML + * validators) reject the package. + * + * In the browser the fallback is rasterized via canvas automatically. On the + * headless Node/SSR path there is no canvas, so without this hook the + * fallback degrades to a 1×1 transparent PNG (valid, but the image is blank + * outside PowerPoint). Provide a rasterizer to emit a faithful fallback — + * the library stays dependency-free and the host chooses the engine: + * + * ```ts + * import { Resvg } from "@resvg/resvg-js"; + * serializeDeck(deck, { + * source, + * rasterizeSvg: (svg) => new Resvg(Buffer.from(svg)).render().asPng(), + * }); + * ``` + * + * Return `null`/`undefined` (or throw — it's caught) to defer to the next + * fallback. Output that isn't valid PNG bytes is ignored. May be sync or async. + */ + rasterizeSvg?: SvgRasterizer; } +/** + * Host-provided SVG→PNG rasterizer (see {@link SerializeOptions.rasterizeSvg}). + * Receives the SVG bytes, returns PNG bytes (or null to defer to the built-in + * fallback). Sync or async. + */ +export type SvgRasterizer = ( + svg: Uint8Array +) => Uint8Array | null | undefined | Promise; + /** A non-fatal serialization diagnostic delivered to `SerializeOptions.onWarning`. */ export interface SerializeWarning { /** @@ -211,7 +245,8 @@ export async function serializeDeck( deck, sourceBuffer, options.asTemplate, - options.onWarning + options.onWarning, + options.rasterizeSvg ); } @@ -975,7 +1010,8 @@ async function preserveUnknowns( deck: Deck, explicitSource?: Blob | ArrayBuffer | Uint8Array, asTemplate?: boolean, - onWarning?: (warning: SerializeWarning) => void + onWarning?: (warning: SerializeWarning) => void, + rasterizeSvg?: SvgRasterizer ): Promise { // Prefer the caller-supplied source (survives state cloning / localStorage // rehydrate); fall back to the non-enumerable attachment from parsePptx @@ -1001,7 +1037,7 @@ async function preserveUnknowns( // PowerPoint refuses to open the file when declared parts are missing // (Keynote is lenient and just warns). Always sanitise. const outZip = await JSZip.loadAsync(generated); - await fixSvgRasterFallbacks(outZip); + await fixSvgRasterFallbacks(outZip, rasterizeSvg); await pruneDanglingContentTypes(outZip); await sanitisePresentationXml(outZip); await sanitiseSlideXml(outZip); @@ -1017,7 +1053,7 @@ async function preserveUnknowns( await applySynth(outZip, deck); await applySynthSlideBackgrounds(outZip, deck); await applyEmbeddedFontsFromJson(outZip, deck); - await fixSvgRasterFallbacks(outZip); + await fixSvgRasterFallbacks(outZip, rasterizeSvg); await pruneDanglingContentTypes(outZip); await sanitisePresentationXml(outZip); await sanitiseSlideXml(outZip); @@ -1040,6 +1076,11 @@ async function preserveUnknowns( // cloning — we then map deck.slides[i] back to source slides[i]. const sourceSlidePaths = await readSourceSlidePaths(srcZip); + // One registry for the whole serialize: media/parts shared across slides + // (icons, logos, backgrounds, chart workbooks) are copied once and reused, + // instead of duplicating per reference. + const reg = createPreservedPartRegistry(); + const slideIndices = new Set([ ...unknownsBySlide.keys(), ...pristinesBySlide.keys(), @@ -1079,7 +1120,8 @@ async function preserveUnknowns( generatedSlidePath, generatedRelsPath, pristineGroup?.fragments ?? [], - unknownFragments + unknownFragments, + reg ); } @@ -1096,7 +1138,7 @@ async function preserveUnknowns( // backgrounds collapse to a flat hex through the model path. Replace // each output slide's `` with the source's verbatim XML when // available so gradients survive intact. - await preserveSlideBackgrounds(outZip, srcZip, deck, sourceSlidePaths); + await preserveSlideBackgrounds(outZip, srcZip, deck, sourceSlidePaths, reg); // Synthesised content (custGeom shapes, gradient fills, in-app charts, // groups, effect splices) — applied after source preservation so we never @@ -1110,7 +1152,7 @@ async function preserveUnknowns( // are set. await applyEmbeddedFontsFromJson(outZip, deck); // Replace pptxgenjs's SVG-bytes-in-.png raster fallbacks with valid PNGs. - await fixSvgRasterFallbacks(outZip); + await fixSvgRasterFallbacks(outZip, rasterizeSvg); // Strip Content_Types overrides for parts that don't exist. preserveDeckChrome // rewrites most, but pptxgenjs's stale slideMaster1..N / notesSlide overrides // can survive (and all of them survive when chrome preservation bails on an @@ -1279,7 +1321,8 @@ async function injectIntoSlide( generatedSlidePath: string, generatedRelsPath: string, pristineFragments: PristineFragment[], - unknownFragments: PristineFragment[] + unknownFragments: PristineFragment[], + reg: PreservedPartRegistry ): Promise { const slideXml = await outZip.file(generatedSlidePath)!.async("string"); const closeIdx = slideXml.lastIndexOf(""); @@ -1349,18 +1392,26 @@ async function injectIntoSlide( const isInternalPart = !isExternal && !target.startsWith("/"); if (isInternalPart) { const srcFullTarget = normalisePath(target, sourceDir); - const srcFile = srcZip.file(srcFullTarget); - if (srcFile) { - const newTarget = uniqueTarget(target, outZip, outDir); - const newFullTarget = normalisePath(newTarget, outDir); - outZip.file(newFullTarget, srcFile.async("uint8array"), { - binary: true, - }); - target = newTarget; - deepCopyJobs.push({ - srcFull: srcFullTarget, - outFull: newFullTarget, - }); + const copy = preserveSourcePart( + reg, + outZip, + srcZip, + srcFullTarget, + target, + outDir + ); + if (copy) { + target = copy.relTarget; + // Walk the dependency tree only the first time this part is + // copied — later references reuse the same output part, whose + // deps (chart workbook, colors/style, nested media) were already + // brought along on that first copy. + if (copy.firstCopy) { + deepCopyJobs.push({ + srcFull: srcFullTarget, + outFull: copy.outFull, + }); + } } } newRelLines.push(buildRelXml(newRid, srcRel.type, target)); @@ -1379,15 +1430,16 @@ async function injectIntoSlide( // Deep-copy each referenced part's dependency tree so e.g. a preserved // chart's embedded Excel workbook + colors/style parts (and the content // types declaring them) come along. Sequential so the shared - // `[Content_Types].xml` read/modify/write doesn't race. - const copied = new Set(); + // `[Content_Types].xml` read/modify/write doesn't race. The registry's + // `depsWalked` set spans the whole serialize, so a part shared across + // slides has its tree copied exactly once. for (const job of deepCopyJobs) { await copyPartDependencies( srcZip, outZip, job.srcFull, job.outFull, - copied + reg ); } @@ -1453,11 +1505,13 @@ async function copyPartDependencies( outZip: JSZip, srcPartPath: string, outPartPath: string, - copied: Set + reg: PreservedPartRegistry ): Promise { - // Guard against cycles and redundant work (two charts sharing one workbook). - if (copied.has(srcPartPath)) return; - copied.add(srcPartPath); + // Guard against cycles and redundant work (two charts sharing one workbook, + // or the same part referenced from several slides). The set spans the whole + // serialize, so each source part's tree is walked exactly once. + if (reg.depsWalked.has(srcPartPath)) return; + reg.depsWalked.add(srcPartPath); await ensureContentType(srcZip, outZip, srcPartPath, outPartPath); @@ -1467,7 +1521,6 @@ async function copyPartDependencies( const rels = parseRels(srcRelsXml); if (!rels.size) return; - const srcDir = dirOf(srcPartPath); const outDir = dirOf(outPartPath); const newRelLines: string[] = []; for (const [id, rel] of rels) { @@ -1478,26 +1531,32 @@ async function copyPartDependencies( newRelLines.push(buildRelXml(id, rel.type, rel.target)); continue; } - const childSrcFull = normalisePath(rel.target, srcDir); - const childSrcFile = srcZip.file(childSrcFull); - if (!childSrcFile) { + const childSrcFull = normalisePath(rel.target, dirOf(srcPartPath)); + const child = preserveSourcePart( + reg, + outZip, + srcZip, + childSrcFull, + rel.target, + outDir + ); + if (!child) { // Dangling in the source too — keep the rel verbatim rather than drop it. newRelLines.push(buildRelXml(id, rel.type, rel.target)); continue; } - const childOutTarget = uniqueTarget(rel.target, outZip, outDir); - const childOutFull = normalisePath(childOutTarget, outDir); - outZip.file(childOutFull, childSrcFile.async("uint8array"), { - binary: true, - }); - await copyPartDependencies( - srcZip, - outZip, - childSrcFull, - childOutFull, - copied - ); - newRelLines.push(buildRelXml(id, rel.type, childOutTarget)); + // Recurse only on the first copy; a child shared with another part already + // had its own tree brought along when it was first preserved. + if (child.firstCopy) { + await copyPartDependencies( + srcZip, + outZip, + childSrcFull, + child.outFull, + reg + ); + } + newRelLines.push(buildRelXml(id, rel.type, child.relTarget)); } const outRelsPath = relsPathFor(outPartPath); @@ -1766,6 +1825,67 @@ function uniqueTarget(originalTarget: string, outZip: JSZip, baseDir: string): s return candidate; } +/** + * Per-serialize ledger of source parts already copied into the output package, + * keyed by the part's immutable source path. + * + * The same media is routinely shared — one icon/logo/background is referenced + * from many slides, and a single slide's fragments can each reference it. The + * naive path keeps a *separate* copy per reference (`uniqueTarget` only avoids + * path collisions; it has no idea the bytes were already written), so a 1.5 MB + * deck ballooned to ~6 MB with one image written nine times as + * `slidewise_preserved_0..8_imageN`. Keying the copy on the source path + * collapses those back to a single shared part — exactly as the source authored + * it — and lets every referencing rel point at that one copy. + */ +interface PreservedPartRegistry { + /** source full path → output full path of the single copy we made for it. */ + readonly bySource: Map; + /** source full paths whose dependency tree has already been deep-copied. */ + readonly depsWalked: Set; +} + +function createPreservedPartRegistry(): PreservedPartRegistry { + return { bySource: new Map(), depsWalked: new Set() }; +} + +/** + * Copy a source part into the output zip at most once per serialize. + * + * Returns the rel `Target` the owner should reference (relative to + * `ownerOutDir`), the part's full output path, and whether THIS call performed + * the copy — so callers can run the dependency walk only on the first copy. + * Returns `null` when the source part is missing (caller leaves the rel as-is). + * + * On the first sighting we mint a collision-free `slidewise_preserved_N_` name + * and record it; every later reference — from any slide, layout, or sibling + * part — resolves the recorded copy and points at it instead of duplicating. + */ +function preserveSourcePart( + reg: PreservedPartRegistry, + outZip: JSZip, + srcZip: JSZip, + srcFullTarget: string, + preferredRelTarget: string, + ownerOutDir: string +): { relTarget: string; outFull: string; firstCopy: boolean } | null { + const srcFile = srcZip.file(srcFullTarget); + if (!srcFile) return null; + const existing = reg.bySource.get(srcFullTarget); + if (existing) { + return { + relTarget: relativeTarget(ownerOutDir, existing), + outFull: existing, + firstCopy: false, + }; + } + const newTarget = uniqueTarget(preferredRelTarget, outZip, ownerOutDir); + const outFull = normalisePath(newTarget, ownerOutDir); + outZip.file(outFull, srcFile.async("uint8array"), { binary: true }); + reg.bySource.set(srcFullTarget, outFull); + return { relTarget: newTarget, outFull, firstCopy: true }; +} + function dirOf(path: string): string { const i = path.lastIndexOf("/"); return i >= 0 ? path.slice(0, i) : ""; @@ -1927,7 +2047,8 @@ async function preserveSlideBackgrounds( outZip: JSZip, srcZip: JSZip, deck: Deck, - sourceSlidePaths: string[] + sourceSlidePaths: string[], + reg: PreservedPartRegistry ): Promise { for (let i = 0; i < deck.slides.length; i++) { const slide = deck.slides[i]; @@ -1952,7 +2073,7 @@ async function preserveSlideBackgrounds( await stripOutputBg(outZip, i); continue; } - await injectSlideBg(outZip, srcZip, i, sourceSlidePath, bgFragment); + await injectSlideBg(outZip, srcZip, i, sourceSlidePath, bgFragment, reg); } } @@ -1987,7 +2108,8 @@ async function injectSlideBg( srcZip: JSZip, slideIndex: number, sourceSlidePath: string, - bgFragment: string + bgFragment: string, + reg: PreservedPartRegistry ): Promise { const outSlidePath = `ppt/slides/slide${slideIndex + 1}.xml`; const outRelsPath = `ppt/slides/_rels/slide${slideIndex + 1}.xml.rels`; @@ -2034,15 +2156,15 @@ async function injectSlideBg( const isInternalPart = !isExternal && !target.startsWith("/"); if (isInternalPart) { const srcFullTarget = normalisePath(target, sourceDir); - const srcFile = srcZip.file(srcFullTarget); - if (srcFile) { - const newTarget = uniqueTarget(target, outZip, outDir); - const newFullTarget = normalisePath(newTarget, outDir); - outZip.file(newFullTarget, srcFile.async("uint8array"), { - binary: true, - }); - target = newTarget; - } + const copy = preserveSourcePart( + reg, + outZip, + srcZip, + srcFullTarget, + target, + outDir + ); + if (copy) target = copy.relTarget; } newRelLines.push(buildRelXml(newRid, srcRel.type, target)); return `${attr}="${newRid}"`; @@ -2561,6 +2683,21 @@ async function isTemplateArchive(zip: JSZip): Promise { return xml.includes(TEMPLATE_MAIN_CT); } +/** + * DEFLATE the package on write. JSZip defaults to `STORE` (no compression), so + * without this every part — including the slide XML, which routinely runs into + * the megabytes and compresses ~90% — ships uncompressed. That alone inflated a + * 1.5 MB source deck to ~6 MB on save. DEFLATE matches what PowerPoint itself + * (and the original archive) does. Level 6 is the standard speed/ratio balance; + * already-compressed media (PNG/JPEG) simply doesn't shrink further, at + * negligible CPU cost. + */ +const ZIP_OUTPUT_OPTIONS = { + type: "blob", + compression: "DEFLATE", + compressionOptions: { level: 6 }, +} as const; + /** * Generate the final Blob, flipping the main-part content type to the template * variant first when emitting a `.potx`. pptxgenjs always writes the @@ -2571,7 +2708,7 @@ async function finalizeOutput( asTemplate: boolean ): Promise { if (!asTemplate) { - return outZip.generateAsync({ type: "blob", mimeType: PPTX_MIME }); + return outZip.generateAsync({ ...ZIP_OUTPUT_OPTIONS, mimeType: PPTX_MIME }); } const file = outZip.file("[Content_Types].xml"); if (file) { @@ -2583,7 +2720,7 @@ async function finalizeOutput( ); } } - return outZip.generateAsync({ type: "blob", mimeType: POTX_MIME }); + return outZip.generateAsync({ ...ZIP_OUTPUT_OPTIONS, mimeType: POTX_MIME }); } // -- helpers ---------------------------------------------------------------- @@ -3365,9 +3502,14 @@ const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47] as const; * when no rasterizer is available (SSR / Node) — a degraded-but-VALID image * beats SVG-bytes-in-a-.png, which corrupts the whole package for strict * consumers (Google Slides, LibreOffice, thumbnail renderers, OOXML - * validators). PowerPoint itself renders from the `` regardless. */ + * validators). PowerPoint itself renders from the `` regardless. + * + * Every chunk's CRC-32 must be correct: the previous constant had a bad IDAT + * CRC, which decodes in lenient readers but is rejected by the strict PNG/OOXML + * validators this fallback exists to satisfy. Regenerate with a real encoder + * (and re-verify chunk CRCs) if you ever change it. */ const TRANSPARENT_PNG_BASE64 = - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="; + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNgAAIAAAUAAen63NgAAAAASUVORK5CYII="; function decodeBase64ToBytes(b64: string): Uint8Array { if (typeof atob === "function") { @@ -3488,7 +3630,10 @@ async function rasterizeSvgToPng(svgBytes: Uint8Array): Promise` .svg part is left intact. */ -async function fixSvgRasterFallbacks(outZip: JSZip): Promise { +async function fixSvgRasterFallbacks( + outZip: JSZip, + rasterizeSvg?: SvgRasterizer +): Promise { const pngPaths: string[] = []; outZip.forEach((path, entry) => { if (!entry.dir && /^ppt\/media\/.+\.png$/i.test(path)) pngPaths.push(path); @@ -3499,9 +3644,33 @@ async function fixSvgRasterFallbacks(outZip: JSZip): Promise { if (!file) continue; const bytes = await file.async("uint8array"); if (isPngBytes(bytes) || !looksLikeSvgBytes(bytes)) continue; + // Faithful first: a host-provided rasterizer (the only option on Node/SSR, + // where there's no canvas), then the browser canvas pipeline. Only if both + // decline do we degrade to a valid transparent PNG — never leave the part + // holding SVG bytes. const raster = + (await tryHostRasterizer(rasterizeSvg, bytes)) ?? (await rasterizeSvgToPng(bytes)) ?? (placeholder ??= decodeBase64ToBytes(TRANSPARENT_PNG_BASE64)); outZip.file(p, raster, { binary: true }); } } + +/** + * Invoke a host {@link SvgRasterizer}, returning its bytes only when it + * actually produced a valid PNG. Swallows throws and non-PNG output so a flaky + * host engine can never corrupt the part — the caller then falls through to the + * canvas pipeline or the transparent placeholder. + */ +async function tryHostRasterizer( + rasterizeSvg: SvgRasterizer | undefined, + svgBytes: Uint8Array +): Promise { + if (!rasterizeSvg) return null; + try { + const out = await rasterizeSvg(svgBytes); + return out && isPngBytes(out) ? out : null; + } catch { + return null; + } +} diff --git a/packages/slidewise/src/lib/pptx/index.ts b/packages/slidewise/src/lib/pptx/index.ts index 8e71bee..7c19533 100644 --- a/packages/slidewise/src/lib/pptx/index.ts +++ b/packages/slidewise/src/lib/pptx/index.ts @@ -1,4 +1,8 @@ export { parsePptx, isPptxTemplate } from "./pptxToDeck"; export { serializeDeck } from "./deckToPptx"; -export type { SerializeOptions, SerializeWarning } from "./deckToPptx"; +export type { + SerializeOptions, + SerializeWarning, + SvgRasterizer, +} from "./deckToPptx"; export type { ParseDiagnostics, ParseResult } from "./types";