diff --git a/.changeset/serialize-dangling-rels-and-svg-fallback.md b/.changeset/serialize-dangling-rels-and-svg-fallback.md new file mode 100644 index 0000000..e11f68b --- /dev/null +++ b/.changeset/serialize-dangling-rels-and-svg-fallback.md @@ -0,0 +1,31 @@ +--- +"@textcortex/slidewise": patch +--- + +fix(pptx): emit a structurally valid package on serialize + +Three `serializeDeck` bugs corrupted the generated `.pptx` (missing parts / +invalid image bytes) even from clean source templates, triggering a PowerPoint +repair prompt and outright rejection by stricter consumers (Google Slides, +LibreOffice, OOXML validators): + +- **Dangling `tags` relationships:** the chrome-preserve path re-pointed a + slide's tag rel at a `slidewise_preserved_*` name, then clobbered that part by + re-copying the source tags under their original names. The rel now resolves + to the de-prefixed part it should always have pointed at. +- **Dangling `notesMaster` relationships:** pptxgenjs writes a notesSlide per + slide linked to a notes master, which chrome preservation removed without a + source replacement. The orphaned (implicit, non-body-referenced) relationship + is now dropped. +- **SVG markup in `.png` raster fallbacks:** dual SVG images (`` raster + + `` vector) had the SVG source written into the `.png` + fallback. The fallback is now a real rasterized PNG (browser) or a valid + transparent PNG (SSR/Node); the vector `svgBlip` part is untouched. + +Adds a final `reconcileDanglingRels` invariant guard — every internal +relationship target must resolve to a shipped part — that backstops both +dangling-rel shapes (repairing recoverable targets, dropping only +safe-to-remove optional ones, and leaving critical rels untouched). Also runs +`pruneDanglingContentTypes` on the source-preservation path so stale +`[Content_Types]` overrides (pptxgenjs's `slideMaster1..N`, leftover notes +overrides) can't invalidate the package either. diff --git a/packages/slidewise/src/lib/pptx/__tests__/chrome-preservation.test.ts b/packages/slidewise/src/lib/pptx/__tests__/chrome-preservation.test.ts index cac7f59..4849b74 100644 --- a/packages/slidewise/src/lib/pptx/__tests__/chrome-preservation.test.ts +++ b/packages/slidewise/src/lib/pptx/__tests__/chrome-preservation.test.ts @@ -35,6 +35,22 @@ async function loadFixture(name: string): Promise { const hasEon = await fixtureExists("eon-deck.pptx"); const hasDickinson = await fixtureExists("Dickinson_Sample_Slides.pptx"); +// Templates from the dangling-rel bug report (1a tags / 1b notesMaster). +const DANGLING_FIXTURES = [ + "Intero Master Template.pptx", + "Intero.pptx", + "44 - Education.pptx", + "eon-deck.pptx", + "Dickinson_Sample_Slides.pptx", +] as const; +const danglingFixtures = ( + await Promise.all( + DANGLING_FIXTURES.map(async (name) => + (await fixtureExists(name)) ? name : null + ) + ) +).filter((n): n is (typeof DANGLING_FIXTURES)[number] => n !== null); + async function listZipPaths(buf: ArrayBuffer | Blob): Promise> { const ab = buf instanceof Blob ? await buf.arrayBuffer() : buf; const zip = await JSZip.loadAsync(ab); @@ -43,6 +59,52 @@ async function listZipPaths(buf: ArrayBuffer | Blob): Promise> { return paths; } +/** + * Walk every `*.rels` in the package and return the internal relationship + * targets that don't resolve to a shipped part. A non-empty result means the + * output would trigger a PowerPoint repair prompt / strict-consumer rejection. + */ +async function findDanglingRels( + buf: Blob | ArrayBuffer +): Promise> { + const ab = buf instanceof Blob ? await buf.arrayBuffer() : buf; + const zip = await JSZip.loadAsync(ab); + const present = new Set(); + const relsPaths: string[] = []; + zip.forEach((p, e) => { + if (e.dir) return; + present.add(p); + if (p.endsWith(".rels")) relsPaths.push(p); + }); + const normalise = (target: string, base: string): string => { + if (target.startsWith("/")) return target.slice(1); + let t = target; + const segs = base.split("/").filter(Boolean); + while (t.startsWith("../")) { + segs.pop(); + t = t.slice(3); + } + return [...segs, t].filter(Boolean).join("/"); + }; + const dangling: Array<{ rels: string; id: string; target: string }> = []; + for (const relsPath of relsPaths) { + const xml = await zip.file(relsPath)!.async("string"); + const ownerDir = relsPath.replace(/(^|\/)_rels\/[^/]+$/, ""); + const re = /]*?\/>/g; + let m: RegExpExecArray | null; + while ((m = re.exec(xml))) { + const tag = m[0]; + const mode = /\bTargetMode="([^"]+)"/.exec(tag)?.[1]; + const target = /\bTarget="([^"]+)"/.exec(tag)?.[1]; + const id = /\bId="([^"]+)"/.exec(tag)?.[1] ?? "?"; + if (!target || mode === "External" || /^https?:\/\//i.test(target)) continue; + const full = normalise(target, ownerDir === relsPath ? "" : ownerDir); + if (!present.has(full)) dangling.push({ rels: relsPath, id, target }); + } + } + return dangling; +} + async function countSlidesWithSpTreeChildren( buf: Blob ): Promise { @@ -184,4 +246,23 @@ describe("deck chrome preservation", () => { expect(fontCount).toBe(5); } ); + + // Package invariant: no internal relationship may point at a missing part. + // Catches the tags (1a) and notesMaster (1b) danglers the chrome-preserve + // path used to emit. Runs per available fixture (skipped in CI where the + // branded decks aren't committed). + for (const name of danglingFixtures) { + it(`emits no dangling internal relationships (${name})`, async () => { + const source = await loadFixture(name); + const deck = await parsePptx(source); + const blob = await serializeDeck(deck, { source }); + const dangling = await findDanglingRels(blob); + expect( + dangling, + `dangling rels:\n${dangling + .map((d) => ` ${d.rels} ${d.id} → ${d.target}`) + .join("\n")}` + ).toEqual([]); + }); + } }); diff --git a/packages/slidewise/src/lib/pptx/__tests__/corpus-validity.test.ts b/packages/slidewise/src/lib/pptx/__tests__/corpus-validity.test.ts new file mode 100644 index 0000000..e488483 --- /dev/null +++ b/packages/slidewise/src/lib/pptx/__tests__/corpus-validity.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect } from "vitest"; +import { readFile, readdir } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import JSZip from "jszip"; +import { parsePptx, serializeDeck } from "../index"; + +/** + * Whole-corpus structural-validity net. Drop any number of `.pptx` files into + * the gitignored `.context/attachments/` dir (the same place the other fixture + * tests read from) and this round-trips EVERY one through + * `parsePptx → serializeDeck`, asserting the output is a structurally valid + * OOXML package: + * + * 1. every internal relationship target resolves to a shipped part, + * 2. every `[Content_Types]` Override points at a part that exists, + * 3. every shipped part has a declared content type (Default by extension + * or an explicit Override), + * 4. every `ppt/media/*.png` holds real PNG bytes (never SVG markup). + * + * These are exactly the four ways the reported bugs corrupted the artifact. + * The whole suite skips when no decks are present, so CI stays green for + * outside contributors while it scans the full corpus locally. + */ + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const attachmentsDir = path.resolve( + __dirname, + "../../../../../../.context/attachments" +); + +async function findPptx(dir: string): Promise { + const out: string[] = []; + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return out; + } + for (const e of entries) { + const full = path.join(dir, e.name); + if (e.isDirectory()) out.push(...(await findPptx(full))); + else if (/\.(pptx|potx)$/i.test(e.name)) out.push(full); + } + return out; +} + +function normalise(target: string, base: string): string { + if (target.startsWith("/")) return target.slice(1); + let t = target; + const segs = base.split("/").filter(Boolean); + while (t.startsWith("../")) { + segs.pop(); + t = t.slice(3); + } + return [...segs, t].filter(Boolean).join("/"); +} + +const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47]; + +interface Problems { + danglingRels: string[]; + danglingOverrides: string[]; + undeclaredParts: string[]; + invalidPngs: string[]; +} + +async function validate(buf: ArrayBuffer): Promise { + const zip = await JSZip.loadAsync(buf); + const present = new Set(); + const relsPaths: string[] = []; + const mediaPngs: string[] = []; + zip.forEach((p, e) => { + if (e.dir) return; + present.add(p); + if (p.endsWith(".rels")) relsPaths.push(p); + if (/^ppt\/media\/.+\.png$/i.test(p)) mediaPngs.push(p); + }); + + const problems: Problems = { + danglingRels: [], + danglingOverrides: [], + undeclaredParts: [], + invalidPngs: [], + }; + + // 1. Relationship targets. + for (const relsPath of relsPaths) { + const xml = await zip.file(relsPath)!.async("string"); + const ownerDir = relsPath.replace(/(^|\/)_rels\/[^/]+$/, ""); + const base = ownerDir === relsPath ? "" : ownerDir; + const re = /]*?\/>/g; + let m: RegExpExecArray | null; + while ((m = re.exec(xml))) { + const tag = m[0]; + const mode = /\bTargetMode="([^"]+)"/.exec(tag)?.[1]; + const target = /\bTarget="([^"]+)"/.exec(tag)?.[1]; + const id = /\bId="([^"]+)"/.exec(tag)?.[1] ?? "?"; + if (!target || mode === "External" || /^https?:\/\//i.test(target)) continue; + if (!present.has(normalise(target, base))) { + problems.danglingRels.push(`${relsPath} ${id} → ${target}`); + } + } + } + + // 2 + 3. Content types. + const ct = await zip.file("[Content_Types].xml")?.async("string"); + if (ct) { + const defaults = new Set(); + const overrides = new Set(); + let dm: RegExpExecArray | null; + const defRe = /]*Extension="([^"]+)"[^>]*\/>/g; + while ((dm = defRe.exec(ct))) defaults.add(dm[1].toLowerCase()); + const ovRe = /]*PartName="([^"]+)"[^>]*\/>/g; + let om: RegExpExecArray | null; + while ((om = ovRe.exec(ct))) { + overrides.add(om[1]); + if (!present.has(om[1].replace(/^\//, ""))) { + problems.danglingOverrides.push(om[1]); + } + } + for (const p of present) { + if (p === "[Content_Types].xml" || p.endsWith(".rels")) continue; + const ext = (p.split(".").pop() ?? "").toLowerCase(); + if (defaults.has(ext) || overrides.has("/" + p)) continue; + problems.undeclaredParts.push(p); + } + } + + // 4. PNG media bytes. + for (const p of mediaPngs) { + const bytes = await zip.file(p)!.async("uint8array"); + const ok = + bytes.length >= 4 && PNG_MAGIC.every((b, i) => bytes[i] === b); + if (!ok) problems.invalidPngs.push(p); + } + + return problems; +} + +const decks = await findPptx(attachmentsDir); + +describe.skipIf(decks.length === 0)("corpus structural validity", () => { + it.each(decks)("serializes a valid package: %s", async (deckPath) => { + const file = await readFile(deckPath); + const source = file.buffer.slice( + file.byteOffset, + file.byteOffset + file.byteLength + ) as ArrayBuffer; + + const deck = await parsePptx(source); + const blob = await serializeDeck(deck, { source }); + const problems = await validate(await blob.arrayBuffer()); + + const summary = [ + problems.danglingRels.length + ? `dangling rels:\n ${problems.danglingRels.join("\n ")}` + : "", + problems.danglingOverrides.length + ? `Content_Types overrides with no part:\n ${problems.danglingOverrides.join("\n ")}` + : "", + problems.undeclaredParts.length + ? `parts with no content type:\n ${problems.undeclaredParts.join("\n ")}` + : "", + problems.invalidPngs.length + ? `.png parts holding non-PNG bytes:\n ${problems.invalidPngs.join("\n ")}` + : "", + ] + .filter(Boolean) + .join("\n"); + + expect(summary, summary || undefined).toBe(""); + }); +}); diff --git a/packages/slidewise/src/lib/pptx/__tests__/dangling-rels.test.ts b/packages/slidewise/src/lib/pptx/__tests__/dangling-rels.test.ts new file mode 100644 index 0000000..01ad7c8 --- /dev/null +++ b/packages/slidewise/src/lib/pptx/__tests__/dangling-rels.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect } from "vitest"; +import JSZip from "jszip"; +import { reconcileDanglingRels } from "../deckToPptx"; + +/** + * Bug: the chrome-preserve path can leave `.rels` pointing at parts that never + * ship. `reconcileDanglingRels` is the final invariant guard — every internal + * relationship target must resolve to a part in the package. Two shapes: + * + * 1a. A `tags` rel re-pointed to a `slidewise_preserved_N_` name whose part + * was clobbered by chrome preservation and re-copied under its ORIGINAL + * name → repair by de-prefixing. + * 1b. A `notesMaster` rel whose part was removed with no source replacement + * and isn't referenced by the owner body → drop the relationship. + */ + +const RELS_NS = + 'xmlns="http://schemas.openxmlformats.org/package/2006/relationships"'; + +function rels(...lines: string[]): string { + return ( + `` + + `${lines.join("")}` + ); +} + +function rel(id: string, type: string, target: string, mode?: string): string { + const m = mode ? ` TargetMode="${mode}"` : ""; + return ``; +} + +async function parse(zip: JSZip, path: string): Promise { + return zip.file(path)!.async("string"); +} + +describe("reconcileDanglingRels", () => { + it("repairs a slidewise_preserved_ tag target to its de-prefixed original part", async () => { + const zip = new JSZip(); + // The verbatim-copied tag part exists under its ORIGINAL name only. + zip.file("ppt/tags/tag104.xml", ""); + // The slide body references rId3 (so the rel must NOT be dropped). + zip.file( + "ppt/slides/slide1.xml", + '' + ); + zip.file( + "ppt/slides/_rels/slide1.xml.rels", + rels( + rel("rId1", "slideLayout", "../slideLayouts/slideLayout1.xml"), + rel("rId3", "tags", "../tags/slidewise_preserved_0_tag104.xml") + ) + ); + // The referenced layout exists so it stays untouched. + zip.file("ppt/slideLayouts/slideLayout1.xml", ""); + + await reconcileDanglingRels(zip); + + const out = await parse(zip, "ppt/slides/_rels/slide1.xml.rels"); + // rId3 now resolves to the real, de-prefixed part. + expect(out).toContain('Target="../tags/tag104.xml"'); + expect(out).not.toContain("slidewise_preserved_0_tag104.xml"); + // The healthy layout rel is preserved. + expect(out).toContain('Id="rId1"'); + expect(out).toContain("slideLayout1.xml"); + }); + + it("drops a notesMaster rel that has no part and isn't body-referenced", async () => { + const zip = new JSZip(); + // notesSlide body never references rId1 (notesMaster is an implicit rel). + zip.file("ppt/notesSlides/notesSlide1.xml", ""); + zip.file( + "ppt/notesSlides/_rels/notesSlide1.xml.rels", + rels( + rel("rId1", "notesMaster", "../notesMasters/notesMaster1.xml"), + rel("rId2", "slide", "../slides/slide1.xml") + ) + ); + zip.file("ppt/slides/slide1.xml", ""); + + await reconcileDanglingRels(zip); + + const out = await parse(zip, "ppt/notesSlides/_rels/notesSlide1.xml.rels"); + // The dangling notesMaster rel is gone. + expect(out).not.toContain("notesMaster1.xml"); + expect(out).not.toContain('Id="rId1"'); + // The valid back-reference to the slide survives. + expect(out).toContain('Id="rId2"'); + expect(out).toContain("../slides/slide1.xml"); + }); + + it("leaves External and resolvable rels untouched", async () => { + const zip = new JSZip(); + zip.file("ppt/media/image1.png", "x"); + zip.file("ppt/slides/slide1.xml", ""); + const original = rels( + rel("rId1", "image", "../media/image1.png"), + rel("rId2", "hyperlink", "https://example.com", "External") + ); + zip.file("ppt/slides/_rels/slide1.xml.rels", original); + + await reconcileDanglingRels(zip); + + const out = await parse(zip, "ppt/slides/_rels/slide1.xml.rels"); + expect(out).toContain("../media/image1.png"); + expect(out).toContain("https://example.com"); + }); + + it("keeps a dangling critical rel (slideLayout) rather than dropping it", async () => { + // Dropping a slide's only layout rel would make it just as invalid as a + // dangling one — so a missing, non-droppable type is kept (and warned). + const zip = new JSZip(); + zip.file("ppt/slides/slide1.xml", ""); + zip.file( + "ppt/slides/_rels/slide1.xml.rels", + rels(rel("rId1", "slideLayout", "../slideLayouts/slideLayout7.xml")) + ); + + await reconcileDanglingRels(zip); + + const out = await parse(zip, "ppt/slides/_rels/slide1.xml.rels"); + expect(out).toContain('Id="rId1"'); + expect(out).toContain("slideLayout7.xml"); + }); + + it("keeps a genuinely-dangling rel when the body references its rId", async () => { + // No way to drop safely without corrupting the body — leave it as-is. + const zip = new JSZip(); + zip.file( + "ppt/slides/slide1.xml", + '' + ); + zip.file( + "ppt/slides/_rels/slide1.xml.rels", + rels(rel("rId5", "image", "../media/missing.png")) + ); + + await reconcileDanglingRels(zip); + + const out = await parse(zip, "ppt/slides/_rels/slide1.xml.rels"); + expect(out).toContain('Id="rId5"'); + }); +}); diff --git a/packages/slidewise/src/lib/pptx/__tests__/svg-raster-fallback.test.ts b/packages/slidewise/src/lib/pptx/__tests__/svg-raster-fallback.test.ts new file mode 100644 index 0000000..ba8c4ea --- /dev/null +++ b/packages/slidewise/src/lib/pptx/__tests__/svg-raster-fallback.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from "vitest"; +import JSZip from "jszip"; +import { serializeDeck } from "../index"; +import { CURRENT_DECK_VERSION } from "@/lib/schema/migrate"; +import type { Deck } from "@/lib/types"; + +/** + * Bug: pptxgenjs emits a dual-blip for SVG images (`` raster + + * `` vector) but writes the SVG SOURCE into the `.png` raster + * fallback part rather than a rasterized PNG — so the `.png` is invalid bytes + * that strict consumers (Google Slides, LibreOffice, OOXML validators) reject. + * + * `serializeDeck` must leave every `ppt/media/*.png` holding real PNG bytes. + */ + +const SVG_MARKUP = + '' + + ''; + +function svgDataUrl(markup: string): string { + const b64 = + typeof btoa === "function" + ? btoa(markup) + : Buffer.from(markup, "utf-8").toString("base64"); + return `data:image/svg+xml;base64,${b64}`; +} + +function deckWithSvgImage(): Deck { + return { + version: CURRENT_DECK_VERSION, + title: "SVG fallback", + slides: [ + { + id: "s1", + background: "#FFFFFF", + elements: [ + { + id: "logo", + type: "image", + x: 100, + y: 100, + w: 200, + h: 200, + rotation: 0, + z: 1, + src: svgDataUrl(SVG_MARKUP), + fit: "contain", + }, + ], + }, + ], + }; +} + +const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47]; + +function isPng(bytes: Uint8Array): boolean { + return ( + bytes.length >= 4 && + PNG_MAGIC.every((b, i) => bytes[i] === b) + ); +} + +describe("SVG dual-blip raster fallback", () => { + it("writes valid PNG bytes into the .png fallback of an SVG image", async () => { + const blob = await serializeDeck(deckWithSvgImage()); + const zip = await JSZip.loadAsync(await blob.arrayBuffer()); + + const pngPaths: string[] = []; + const svgPaths: string[] = []; + zip.forEach((p, e) => { + if (e.dir) return; + if (/^ppt\/media\/.+\.png$/i.test(p)) pngPaths.push(p); + if (/^ppt\/media\/.+\.svg$/i.test(p)) svgPaths.push(p); + }); + + // The dual-blip means at least one .png fallback and one .svg vector part. + expect(pngPaths.length).toBeGreaterThan(0); + expect(svgPaths.length).toBeGreaterThan(0); + + // Every png part must be a real PNG, never SVG markup. + for (const p of pngPaths) { + const bytes = await zip.file(p)!.async("uint8array"); + expect(isPng(bytes), `${p} should be a valid PNG`).toBe(true); + } + + // The vector part keeps the SVG markup intact. + const svg = await zip.file(svgPaths[0])!.async("string"); + expect(svg).toContain(" { if (updated !== xml) outZip.file(p, updated); } } + +// -- Package invariant: every internal rel target resolves to a part -------- + +/** + * Basename prefix the preserve paths stamp onto copied-in parts to dodge + * collisions with whatever pptxgenjs already wrote (`uniqueTarget`, + * `slidewise_chrome_*`). Used by the dangling-rel repair below to recover the + * original part name a rel should resolve to. + */ +const PRESERVE_PREFIX_RE = /^slidewise_(?:preserved|chrome)_\d+_/; + +/** + * Relationship types that are SAFE to drop when their target part is missing: + * each is optional in the package and removing it leaves a still-valid file. + * Critical *implicit* rels (slideLayout, slideMaster, theme) are deliberately + * absent — a slide without its layout (or a layout without its master) is just + * as invalid as one pointing at a missing part, so dropping those would only + * trade one corruption for another. Those shouldn't dangle anyway + * (rewriteSlideLayoutRefs / preserveDeckChrome repoint them); if one ever does + * we keep it and warn rather than make the package worse. + * + * Matched against the last path segment of the relationship Type URI. + */ +const DROPPABLE_REL_TYPES = new Set([ + "notesMaster", + "notesSlide", + "tags", + "comments", + "commentAuthors", + "glossaryDocument", +]); + +function relTypeSuffix(type: string): string { + const slash = type.lastIndexOf("/"); + return slash >= 0 ? type.slice(slash + 1) : type; +} + +/** The owning part directory for a `*.rels` file, e.g. + * `ppt/slides/_rels/slide1.xml.rels` → `ppt/slides`, `_rels/.rels` → ``. */ +function ownerDirForRels(relsPath: string): string { + const stripped = relsPath.replace(/(^|\/)_rels\/[^/]+$/, ""); + return stripped === relsPath ? dirOf(relsPath) : stripped; +} + +/** + * Final guard: every INTERNAL relationship target must resolve to a part that + * actually ships in the package. PowerPoint flags a repair (and stricter + * consumers reject outright) when a `.rels` points at a missing part. + * + * Two danglers slip past the individual preserve paths: + * + * - **Renamed-but-clobbered** (e.g. `tags`): `injectIntoSlide` copies a + * slide-referenced part under a `slidewise_preserved_N_` name and points the + * slide rel there, but `preserveDeckChrome` later wipes the whole + * `ppt/tags/` (a chrome prefix) and re-copies the source parts under their + * ORIGINAL names. The prefixed copy is gone; the rel dangles. Here the part + * still exists under its de-prefixed name, so we re-point the rel at it. + * + * - **Genuinely absent** (e.g. `notesMaster`): pptxgenjs writes a notesSlide + * per slide, each rel-linked to `notesMasters/notesMaster1.xml`, then + * `preserveDeckChrome` removes that part (chrome prefix) without a source + * replacement when the source has no notes master. Nothing resolves it, so + * we drop the relationship — but only when the slide/notes body doesn't + * reference its rId (implicit rels like `notesMaster` never do; dropping a + * body-referenced rId would just move the corruption into the XML). + * + * Exported for direct unit testing. + */ +export async function reconcileDanglingRels(outZip: JSZip): Promise { + const present = new Set(); + outZip.forEach((path, entry) => { + if (!entry.dir) present.add(path); + }); + + const relsPaths: string[] = []; + outZip.forEach((path, entry) => { + if (!entry.dir && path.endsWith(".rels")) relsPaths.push(path); + }); + + for (const relsPath of relsPaths) { + const file = outZip.file(relsPath); + if (!file) continue; + const xml = await file.async("string"); + const ownerDir = ownerDirForRels(relsPath); + + // The body of the part this rels file describes — read lazily, only when a + // drop candidate appears, so we can check whether its rId is referenced. + const ownerPath = relsPath.replace(/(^|\/)_rels\/([^/]+)\.rels$/, "$1$2"); + let ownerBody: string | null | undefined; + const ownerReferences = async (rid: string): Promise => { + if (ownerBody === undefined) { + ownerBody = (await outZip.file(ownerPath)?.async("string")) ?? null; + } + return ownerBody != null && new RegExp(`"${rid}"`).test(ownerBody); + }; + + let changed = false; + const rewritten: string[] = []; + let last = 0; + const re = /]*?\/>/g; + let m: RegExpExecArray | null; + while ((m = re.exec(xml))) { + const tag = m[0]; + rewritten.push(xml.slice(last, m.index)); + last = m.index + tag.length; + + const mode = /\bTargetMode="([^"]+)"/.exec(tag)?.[1]; + const target = /\bTarget="([^"]+)"/.exec(tag)?.[1]; + const id = /\bId="([^"]+)"/.exec(tag)?.[1]; + const type = /\bType="([^"]+)"/.exec(tag)?.[1]; + const isExternal = + mode === "External" || (target ? /^https?:\/\//i.test(target) : false); + if (!target || !id || isExternal) { + rewritten.push(tag); + continue; + } + const full = normalisePath(target, ownerDir); + if (present.has(full)) { + rewritten.push(tag); + continue; + } + + // Try to recover the part under its de-prefixed (original) name. + const slash = target.lastIndexOf("/"); + const dir = slash >= 0 ? target.slice(0, slash + 1) : ""; + const base = slash >= 0 ? target.slice(slash + 1) : target; + const deprefixed = base.replace(PRESERVE_PREFIX_RE, ""); + if (deprefixed !== base) { + const repairedTarget = `${dir}${deprefixed}`; + if (present.has(normalisePath(repairedTarget, ownerDir))) { + rewritten.push( + tag.replace(/\bTarget="[^"]+"/, `Target="${repairedTarget}"`) + ); + changed = true; + continue; + } + } + + // Genuinely missing and unrepairable. Drop only when (a) the type is + // safe to remove and (b) the owner body doesn't reference its rId — + // dropping a body-referenced rId would just move the dangle into the + // XML. Anything else we keep and warn about: there's no safe action, + // and dropping a critical rel (e.g. a slide's only layout) would make + // the package no less broken. + const droppable = type ? DROPPABLE_REL_TYPES.has(relTypeSuffix(type)) : false; + if (droppable && !(await ownerReferences(id))) { + changed = true; + continue; // (tag intentionally omitted — relationship removed.) + } + console.warn( + `[slidewise/pptx] ${relsPath}: relationship ${id} → ${target} ` + + "resolves to a missing part and cannot be safely repaired or dropped; " + + "leaving it in place." + ); + rewritten.push(tag); + } + if (!changed) continue; + rewritten.push(xml.slice(last)); + outZip.file(relsPath, rewritten.join("")); + } +} + +// -- Bug fix: SVG markup written into the .png raster fallback ---------------- + +const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47] as const; + +/** A valid 1×1 fully-transparent PNG, used as a last-resort raster fallback + * 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. */ +const TRANSPARENT_PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="; + +function decodeBase64ToBytes(b64: string): Uint8Array { + if (typeof atob === "function") { + const bin = atob(b64.replace(/\s+/g, "")); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; + } + const B = ( + globalThis as unknown as { Buffer?: { from(b: string, e: string): Uint8Array } } + ).Buffer; + if (B) return B.from(b64, "base64"); + throw new Error("[slidewise] no base64 decoder available"); +} + +function isPngBytes(bytes: Uint8Array): boolean { + return ( + bytes.length >= 4 && + bytes[0] === PNG_MAGIC[0] && + bytes[1] === PNG_MAGIC[1] && + bytes[2] === PNG_MAGIC[2] && + bytes[3] === PNG_MAGIC[3] + ); +} + +/** True when the bytes are XML/SVG markup rather than a raster — i.e. the + * first non-whitespace byte is `<` and an `= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) { + i = 3; + } + while ( + i < bytes.length && + (bytes[i] === 0x20 || bytes[i] === 0x09 || bytes[i] === 0x0a || bytes[i] === 0x0d) + ) { + i++; + } + if (bytes[i] !== 0x3c /* '<' */) return false; + const head = new TextDecoder() + .decode(bytes.subarray(i, Math.min(bytes.length, i + 512))) + .toLowerCase(); + return head.includes(" { + try { + if ( + typeof Image === "undefined" || + typeof URL === "undefined" || + typeof URL.createObjectURL !== "function" + ) { + return null; + } + const copy = new Uint8Array(svgBytes); + const blob = new Blob([copy.buffer as ArrayBuffer], { type: "image/svg+xml" }); + const url = URL.createObjectURL(blob); + try { + const img = new Image(); + await new Promise((resolve, reject) => { + img.onload = () => resolve(); + img.onerror = () => reject(new Error("svg decode failed")); + img.src = url; + }); + const w = Math.max(1, Math.round(img.naturalWidth || img.width || 512)); + const h = Math.max(1, Math.round(img.naturalHeight || img.height || 512)); + let canvas: HTMLCanvasElement | OffscreenCanvas; + if (typeof OffscreenCanvas !== "undefined") { + canvas = new OffscreenCanvas(w, h); + } else if (typeof document !== "undefined") { + const c = document.createElement("canvas"); + c.width = w; + c.height = h; + canvas = c; + } else { + return null; + } + const ctx = canvas.getContext("2d") as + | CanvasRenderingContext2D + | OffscreenCanvasRenderingContext2D + | null; + if (!ctx) return null; + ctx.drawImage(img, 0, 0, w, h); + let outBlob: Blob | null; + if ("convertToBlob" in canvas) { + outBlob = await canvas.convertToBlob({ type: "image/png" }); + } else { + outBlob = await new Promise((resolve) => + (canvas as HTMLCanvasElement).toBlob((b) => resolve(b), "image/png") + ); + } + if (!outBlob) return null; + const out = new Uint8Array(await outBlob.arrayBuffer()); + return isPngBytes(out) ? out : null; + } finally { + URL.revokeObjectURL(url); + } + } catch { + return null; + } +} + +/** + * pptxgenjs emits a dual-blip for SVG images (`` raster + an + * `` vector) but writes the SVG SOURCE into the `.png` raster + * fallback part instead of a rasterized PNG (its `isSvgPng` rel carries the + * SVG `data:` URL verbatim). The `.png` extension + `image/png` content type + * are correct — only the bytes are wrong, so PowerPoint renders fine off the + * svgBlip while every strict consumer rejects the bogus PNG. + * + * Replace those bytes with a real rasterized PNG when a canvas is available, + * else a valid transparent PNG. The `` .svg part is left intact. + */ +async function fixSvgRasterFallbacks(outZip: JSZip): Promise { + const pngPaths: string[] = []; + outZip.forEach((path, entry) => { + if (!entry.dir && /^ppt\/media\/.+\.png$/i.test(path)) pngPaths.push(path); + }); + let placeholder: Uint8Array | null = null; + for (const p of pngPaths) { + const file = outZip.file(p); + if (!file) continue; + const bytes = await file.async("uint8array"); + if (isPngBytes(bytes) || !looksLikeSvgBytes(bytes)) continue; + const raster = + (await rasterizeSvgToPng(bytes)) ?? + (placeholder ??= decodeBase64ToBytes(TRANSPARENT_PNG_BASE64)); + outZip.file(p, raster, { binary: true }); + } +}