From ae9a4741ba2088886d74de482dc6962b9fef1d80 Mon Sep 17 00:00:00 2001 From: karthikmudunuri <102793643+karthikmudunuri@users.noreply.github.com> Date: Fri, 22 May 2026 17:40:40 +0530 Subject: [PATCH 1/7] =?UTF-8?q?feat(pptx):=20full-fidelity=20export=20?= =?UTF-8?q?=E2=80=94=20custGeom,=20gradients,=20JSON=20bg,=20charts,=20gro?= =?UTF-8?q?ups,=20fonts,=20effects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds seven writer extensions so AI-authored and JSON-fed decks round-trip without losing structural content. Pristine source-XML preservation is unchanged; everything new only kicks in for edited / synthesised content the previous emitter would have silently dropped. - custGeom writer (paths emit real ) - Gradient + image fills on shapes (linear/radial/url → /) - Slide background from JSON (gradient/url overrides flat-hex ) - In-app chart writer (partial — cached values; embedded xlsx deferred) - GroupElement (writer + renderer; group-level select/resize deferred) - Embedded fonts via Deck.fonts (data URL / http URL → ppt/fonts/) - Shadow / glow / dashType (effectLst + prstDash; renderer CSS) Companion fixes: - addText now emits TextElement.background as fill so tinted boxes survive - Synth shapes prepend at low-z so text above gradients isn't covered - pruneDanglingContentTypes strips s for parts pptxgenjs declared but never wrote; PowerPoint enforces the manifest and refused to open the file ("PowerPoint found a problem with content") Schema is additive only; existing decks unchanged. 48 → 49 tests passing. --- .changeset/full-fidelity-export.md | 44 + .../src/components/editor/ElementView.tsx | 122 ++- packages/slidewise/src/index.ts | 11 + .../lib/pptx/__tests__/synth-writers.test.ts | 328 +++++++ packages/slidewise/src/lib/pptx/deckToPptx.ts | 640 +++++++++++- .../slidewise/src/lib/pptx/pptxWriters.ts | 920 ++++++++++++++++++ packages/slidewise/src/lib/types.ts | 103 ++ 7 files changed, 2146 insertions(+), 22 deletions(-) create mode 100644 .changeset/full-fidelity-export.md create mode 100644 packages/slidewise/src/lib/pptx/__tests__/synth-writers.test.ts create mode 100644 packages/slidewise/src/lib/pptx/pptxWriters.ts diff --git a/.changeset/full-fidelity-export.md b/.changeset/full-fidelity-export.md new file mode 100644 index 0000000..0b18704 --- /dev/null +++ b/.changeset/full-fidelity-export.md @@ -0,0 +1,44 @@ +--- +"@textcortex/slidewise": minor +--- + +Full-fidelity PPTX export — seven additive writer extensions so AI-authored and JSON-fed decks round-trip without losing structural content. Pristine source-XML preservation is unchanged; everything below only kicks in for edited / synthesised content the previous emitter would have silently dropped. + +**PR 1 — `` writer.** Shapes with `el.path` are now emitted as `` containing a real `` reconstructed from the SVG `d` string. M, L, H, V, C, Q, Z (absolute + relative) are translated into `` / `` / `` / `` / `` primitives; unsupported commands (arcs, smooth shorthands) fall through to a `` so the writer never throws. + +**PR 2 — Gradient + image fills on shapes.** Shape `fill` strings of the form `linear-gradient(...)`, `radial-gradient(...)`, and `url(data:image/...)` now serialize to `` (with `` mapped back from CSS angle, plus `` + `` for radials) or `` with the bytes copied into `ppt/media/` and a fresh slide-rels entry. Solid `#hex` fills are unchanged. + +**PR 3 — Slide background from JSON.** When `slide.background` is a gradient / `url(...)` string and there's no source PPTX to replay from, the writer overrides pptxgenjs's flat-hex `` with the synthesised gradient / image fill. Source-bytes preservation continues to win when present — no double-writes. + +**PR 4 — In-app chart writer (partial).** `ChartElement` instances without `ooxmlXml` now generate a `ppt/charts/chartSW_.xml` part covering bar / column / line / pie / doughnut / area with `grouping` support, plus the matching `` in the slide, the slide-rels entry, and the `[Content_Types].xml` override. Series + categories ship in `` / `` so PowerPoint renders the chart on open. **Deferred:** the embedded `xlsx` workbook — PowerPoint's "Edit Data" right-click won't show editable data until that lands. Charts re-imported from the saved PPTX still parse correctly since the importer reads the caches. + +**PR 5 — `GroupElement` (writer + renderer).** New element `type: "group"` with `children: SlideElement[]`. The PPTX writer emits `` with `nvGrpSpPr` + `grpSpPr` and recurses on children; the renderer draws the group as a positioned wrapper that children render inside. **Deferred:** group-level drag / selection / resize (children remain individually draggable), and group children of element types other than `shape` / `group` round-trip lossy to PPTX (the renderer still draws them; the writer drops them) — that's the next slice of work. + +**PR 6 — Embedded fonts in JSON.** New optional `Deck.fonts: FontAsset[]`. When set and no source PPTX is attached, the writer copies each font's bytes (data URL or http URL) into `ppt/fonts/`, registers the `.fntdata` extension in `[Content_Types].xml`, adds font rels to `presentation.xml.rels`, and writes a `` into `presentation.xml`. When a source PPTX with its own fonts is attached, chrome preservation carries the source's fonts through verbatim and `Deck.fonts` is ignored to avoid duplicate entries. + +**PR 7 — Shadow / glow / dashed lines.** New optional fields on `ShapeElement`, `TextElement`, `LineElement`: + +```ts +shadow?: { color: string; blur: number; offsetX: number; offsetY: number }; +glow?: { color: string; radius: number }; +dashType?: "solid" | "dash" | "dot" | "dashDot" | "lgDash" | "sysDash"; // shape + line +``` + +The renderer applies CSS `box-shadow` / `text-shadow` / `filter: drop-shadow` and `stroke-dasharray` / `border-style` accordingly. The writer emits `` / `` and `` — for shapes that go through the synth path (gradients, paths) these are woven into the synthesised XML directly; for shapes still going through pptxgenjs, the post-processor splices the effect XML in by matching the `cNvPr/@name` we stamp on output. + +**API additions** (additive, non-breaking): + +- `SlideElement` union now includes `GroupElement`. +- `ShapeElement`: `shadow?`, `glow?`, `dashType?` added. +- `TextElement`: `shadow?`, `glow?` added. +- `LineElement`: `shadow?`, `glow?`, `dashType?` added (`dashed?` retained). +- `Deck.fonts?: FontAsset[]` added; `FontAsset` exported. +- `ShadowSpec`, `GlowSpec`, `DashType` exported from types. + +Schema version is unchanged — all additions are optional. Existing decks parse, validate, render, and round-trip without modification. + +**Companion fixes:** + +- **`addText` now emits `TextElement.background` as a `fill`** on the pptxgenjs text frame. Tinted body boxes, boxed-bullet cards, and any layout-derived placeholder fill that used to disappear on export (because pptxgenjs got no `fill` option) now round-trip as a coloured rect behind the text. +- **Synth shapes inject at the low-z insertion point** (right after ``) instead of being appended before ``. Gradient panels, custGeom backdrops, and any other synth shape now sit beneath the text/images pptxgenjs already wrote, so "text above gradient" actually renders above the gradient instead of being covered by it. +- **`[Content_Types].xml` is pruned of dangling overrides** on every export path. pptxgenjs declares `slideMaster1..N` for every slide but only writes `slideMaster1.xml`; PowerPoint enforces the manifest strictly and refuses to open the file when declared parts are missing ("PowerPoint found a problem with content"). Keynote was lenient and just warned. The new `pruneDanglingContentTypes` pass drops `` entries whose `PartName` doesn't correspond to a real entry in the zip. Fires on the no-source / no-synth path, the no-source / with-synth path, and after `preserveDeckChrome` on the source-bytes path. diff --git a/packages/slidewise/src/components/editor/ElementView.tsx b/packages/slidewise/src/components/editor/ElementView.tsx index 1f889eb..22cad4b 100644 --- a/packages/slidewise/src/components/editor/ElementView.tsx +++ b/packages/slidewise/src/components/editor/ElementView.tsx @@ -10,7 +10,10 @@ import type { IconElement, EmbedElement, ChartElement, + GroupElement, UnknownElement, + ShadowSpec, + GlowSpec, } from "@/lib/types"; export function ElementView({ @@ -39,11 +42,72 @@ export function ElementView({ return ; case "chart": return ; + case "group": + return ; case "unknown": return ; } } +/** + * Render a group as a transparent wrapper sized by the parent positioner; + * children carry slide-absolute coordinates so we translate the wrapper to + * (0,0) and absolutely-position children at (child.x - group.x, child.y - group.y). + * Child elements remain individually selectable in v1 — group-level + * drag/selection is the PR-5 follow-up. + */ +function GroupView({ + el, + editing, + onTextCommit, +}: { + el: GroupElement; + editing?: boolean; + onTextCommit?: (text: string, runs?: TextRun[]) => void; +}) { + return ( +
+ {el.children.map((child) => ( +
+ +
+ ))} +
+ ); +} + +/** CSS for shadow/glow effects shared by Text / Shape / Line renderers. */ +function effectStyle( + shadow: ShadowSpec | undefined, + glow: GlowSpec | undefined, + kind: "box" | "text" | "filter" +): React.CSSProperties { + const parts: string[] = []; + if (shadow) { + parts.push(`${shadow.offsetX}px ${shadow.offsetY}px ${shadow.blur}px ${shadow.color}`); + } + if (glow) { + // CSS has no native "glow" — approximate as a zero-offset shadow with the + // glow radius as blur, doubled to render visibly without too much falloff. + parts.push(`0 0 ${glow.radius}px ${glow.color}`); + parts.push(`0 0 ${glow.radius * 2}px ${glow.color}`); + } + if (!parts.length) return {}; + if (kind === "box") return { boxShadow: parts.join(", ") }; + if (kind === "text") return { textShadow: parts.join(", ") }; + return { filter: parts.map((p) => `drop-shadow(${p})`).join(" ") }; +} + function TextView({ el, editing, @@ -78,6 +142,7 @@ function TextView({ const inner: React.CSSProperties = { width: "100%", color: el.color, + ...effectStyle(el.shadow, el.glow, "text"), fontFamily: withGenericFallback(el.fontFamily), fontSize: el.fontSize, fontWeight: el.fontWeight, @@ -378,6 +443,12 @@ function sameStyle(a: TextRun, b: TextRun): boolean { function ShapeView({ el }: { el: ShapeElement }) { const stroke = el.stroke ?? "transparent"; const sw = el.strokeWidth ?? 0; + // SVG dash patterns + CSS border styles. Both renderers need this — the + // path/preset SVG path gets `stroke-dasharray`, the rect/circle div gets + // `border-style`. + const dashArray = svgDashArray(el.dashType); + const borderStyle = cssDashStyle(el.dashType); + const effect = effectStyle(el.shadow, el.glow, "filter"); // Custom vector path (PPTX ) takes precedence over the preset // kind — the path coordinates already encode the actual silhouette. if (el.path) { @@ -387,6 +458,7 @@ function ShapeView({ el }: { el: ShapeElement }) { preserveAspectRatio="none" width="100%" height="100%" + style={effect} > @@ -407,7 +480,8 @@ function ShapeView({ el }: { el: ShapeElement }) { height: "100%", background: el.fill, borderRadius: el.shape === "rounded" ? (el.radius ?? 16) : 0, - border: sw ? `${sw}px solid ${stroke}` : undefined, + border: sw ? `${sw}px ${borderStyle} ${stroke}` : undefined, + ...effectStyle(el.shadow, el.glow, "box"), }} /> ); @@ -420,19 +494,27 @@ function ShapeView({ el }: { el: ShapeElement }) { height: "100%", background: el.fill, borderRadius: "50%", - border: sw ? `${sw}px solid ${stroke}` : undefined, + border: sw ? `${sw}px ${borderStyle} ${stroke}` : undefined, + ...effectStyle(el.shadow, el.glow, "box"), }} /> ); } return ( - + {el.shape === "triangle" && ( )} @@ -442,6 +524,7 @@ function ShapeView({ el }: { el: ShapeElement }) { fill={el.fill} stroke={stroke} strokeWidth={sw} + strokeDasharray={dashArray} vectorEffect="non-scaling-stroke" /> )} @@ -451,6 +534,7 @@ function ShapeView({ el }: { el: ShapeElement }) { fill={el.fill} stroke={stroke} strokeWidth={sw} + strokeDasharray={dashArray} vectorEffect="non-scaling-stroke" /> )} @@ -458,6 +542,32 @@ function ShapeView({ el }: { el: ShapeElement }) { ); } +function svgDashArray( + dt: ShapeElement["dashType"] | LineElement["dashType"] +): string | undefined { + switch (dt) { + case "dash": + return "8 4"; + case "dot": + return "2 4"; + case "dashDot": + return "8 4 2 4"; + case "lgDash": + return "16 6"; + case "sysDash": + return "5 3"; + default: + return undefined; + } +} + +function cssDashStyle(dt: ShapeElement["dashType"]): string { + if (dt === "dash" || dt === "lgDash" || dt === "sysDash") return "dashed"; + if (dt === "dot") return "dotted"; + if (dt === "dashDot") return "dashed"; + return "solid"; +} + function ImageView({ el }: { el: ImageElement }) { // When the source PPTX defined a crop (), render via // background-image so we can apply background-size/position to mimic @@ -532,7 +642,7 @@ function LineView({ el }: { el: LineElement }) { preserveAspectRatio="none" width="100%" height="100%" - style={{ overflow: "visible" }} + style={{ overflow: "visible", ...effectStyle(el.shadow, el.glow, "filter") }} > diff --git a/packages/slidewise/src/index.ts b/packages/slidewise/src/index.ts index 3ea03a4..ffb1e39 100644 --- a/packages/slidewise/src/index.ts +++ b/packages/slidewise/src/index.ts @@ -102,14 +102,25 @@ export type { EnterAnim, BaseElement, TextElement, + TextRun, ShapeElement, ShapeKind, + ShapePath, ImageElement, LineElement, TableElement, IconElement, EmbedElement, + ChartElement, + ChartKind, + ChartGrouping, + ChartSeries, + GroupElement, UnknownElement, ElementDraft, + ShadowSpec, + GlowSpec, + DashType, + FontAsset, } from "./lib/types"; export { SLIDE_W, SLIDE_H } from "./lib/types"; diff --git a/packages/slidewise/src/lib/pptx/__tests__/synth-writers.test.ts b/packages/slidewise/src/lib/pptx/__tests__/synth-writers.test.ts new file mode 100644 index 0000000..a420c78 --- /dev/null +++ b/packages/slidewise/src/lib/pptx/__tests__/synth-writers.test.ts @@ -0,0 +1,328 @@ +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"; + +/** + * Tests for the synth-OOXML writers added in the full-fidelity export work: + * one assertion per PR, each round-trips a tiny showcase deck and inspects + * the generated zip for the expected OOXML constructs. + */ + +const base = { rotation: 0, z: 1 }; + +function makeDeck(slides: Deck["slides"]): Deck { + return { version: CURRENT_DECK_VERSION, title: "Synth", slides }; +} + +async function generate(deck: Deck): Promise { + const blob = await serializeDeck(deck); + const buf = await blob.arrayBuffer(); + return JSZip.loadAsync(buf); +} + +describe("synth writers (PRs 1, 2, 3, 4, 5, 6, 7)", () => { + it("PR 1: emits for shapes carrying el.path", async () => { + const deck = makeDeck([ + { + id: "s", + background: "#FFFFFF", + elements: [ + { + ...base, + id: "logo", + type: "shape", + x: 100, + y: 100, + w: 400, + h: 400, + shape: "rect", + fill: "#FF0066", + path: { + d: "M 0 0 L 100 0 L 100 100 L 0 100 Z", + viewW: 100, + viewH: 100, + fillRule: "evenodd", + }, + }, + ], + }, + ]); + + const zip = await generate(deck); + const slide = await zip.file("ppt/slides/slide1.xml")?.async("string"); + expect(slide).toBeTruthy(); + expect(slide).toContain(""); + expect(slide).toContain(""); + expect(slide).toContain(""); + expect(slide).toContain(""); + // even-odd fill rule + expect(slide).toMatch(/]*fill="darken"/); + }); + + it("PR 2: emits for shapes with linear-gradient fill", async () => { + const deck = makeDeck([ + { + id: "s", + background: "#FFFFFF", + elements: [ + { + ...base, + id: "g", + type: "shape", + x: 100, + y: 100, + w: 400, + h: 400, + shape: "rect", + fill: "linear-gradient(45deg, #FF0000 0%, #0000FF 100%)", + }, + ], + }, + ]); + const zip = await generate(deck); + const slide = await zip.file("ppt/slides/slide1.xml")?.async("string"); + expect(slide).toContain(" + media for url(data:image/...) fills", async () => { + // 1x1 transparent PNG. + const dataUrl = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="; + const deck = makeDeck([ + { + id: "s", + background: "#FFFFFF", + elements: [ + { + ...base, + id: "img", + type: "shape", + x: 0, + y: 0, + w: 200, + h: 200, + shape: "rect", + fill: `url(${dataUrl})`, + }, + ], + }, + ]); + const zip = await generate(deck); + const slide = await zip.file("ppt/slides/slide1.xml")?.async("string"); + expect(slide).toContain(""); + const rels = await zip + .file("ppt/slides/_rels/slide1.xml.rels") + ?.async("string"); + expect(rels).toMatch(/Target="\.\.\/media\/imageSlidewise_img_img\./); + const media = Object.keys(zip.files).filter((p) => + p.startsWith("ppt/media/imageSlidewise_") + ); + expect(media.length).toBe(1); + }); + + it("PR 3: writes a gradient for slide.background = linear-gradient(...)", async () => { + const deck = makeDeck([ + { + id: "s", + background: "linear-gradient(180deg, #FF0066 0%, #5500AA 100%)", + elements: [], + }, + ]); + const zip = await generate(deck); + const slide = await zip.file("ppt/slides/slide1.xml")?.async("string"); + expect(slide).toMatch(/[\s\S]* { + const deck = makeDeck([ + { + id: "s", + background: "#FFFFFF", + elements: [ + { + ...base, + id: "c1", + type: "chart", + x: 100, + y: 100, + w: 800, + h: 400, + kind: "column", + categories: ["A", "B", "C"], + series: [ + { name: "Sales", values: [10, 20, 30], color: "#4F5BD5" }, + ], + }, + ], + }, + ]); + const zip = await generate(deck); + const chartPart = Object.keys(zip.files).find((p) => + p.startsWith("ppt/charts/chartSW_") + ); + expect(chartPart).toBeTruthy(); + const chartXml = await zip.file(chartPart!)?.async("string"); + expect(chartXml).toContain(""); + expect(chartXml).toMatch(/Sales<\/c:v>/); + expect(chartXml).toMatch(/10<\/c:v>/); + const ct = await zip.file("[Content_Types].xml")?.async("string"); + expect(ct).toMatch(/chartSW_[^"]+"\s+ContentType="[^"]*chart\+xml/); + const slide = await zip.file("ppt/slides/slide1.xml")?.async("string"); + expect(slide).toContain(""); + const rels = await zip + .file("ppt/slides/_rels/slide1.xml.rels") + ?.async("string"); + expect(rels).toMatch(/Type="[^"]*relationships\/chart"/); + }); + + it("PR 5: emits with nested children for group elements", async () => { + const deck = makeDeck([ + { + id: "s", + background: "#FFFFFF", + elements: [ + { + ...base, + id: "grp", + type: "group", + x: 0, + y: 0, + w: 800, + h: 400, + children: [ + { + ...base, + id: "child1", + type: "shape", + x: 0, + y: 0, + w: 200, + h: 200, + shape: "rect", + fill: "#FF0000", + }, + { + ...base, + id: "child2", + type: "shape", + x: 400, + y: 0, + w: 200, + h: 200, + shape: "circle", + fill: "#00FF00", + }, + ], + }, + ], + }, + ]); + const zip = await generate(deck); + const slide = await zip.file("ppt/slides/slide1.xml")?.async("string"); + expect(slide).toContain(""); + expect(slide).toContain(""); + expect(slide).toMatch(/ from Deck.fonts", async () => { + // 4-byte dummy "font" payload. PowerPoint won't recognise it as a real + // font, but the writer still has to copy bytes + declare the entry. + const bytes = Buffer.from([0x00, 0x01, 0x00, 0x00]).toString("base64"); + const deck: Deck = { + version: CURRENT_DECK_VERSION, + title: "Embedded", + slides: [{ id: "s", background: "#FFF", elements: [] }], + fonts: [ + { + family: "Brand Sans", + data: `data:font/ttf;base64,${bytes}`, + weight: 400, + }, + ], + }; + const zip = await generate(deck); + const fontPath = Object.keys(zip.files).find( + (p) => p.startsWith("ppt/fonts/") && p.endsWith(".fntdata") + ); + expect(fontPath).toBeTruthy(); + const pres = await zip.file("ppt/presentation.xml")?.async("string"); + expect(pres).toContain(""); + expect(pres).toMatch(/typeface="Brand Sans"/); + const ct = await zip.file("[Content_Types].xml")?.async("string"); + expect(ct).toMatch(/Extension="fntdata"/); + const presRels = await zip + .file("ppt/_rels/presentation.xml.rels") + ?.async("string"); + expect(presRels).toMatch(/Type="[^"]*relationships\/font"/); + }); + + it("PR 7: splices into shapes with shadow", async () => { + const deck = makeDeck([ + { + id: "s", + background: "#FFFFFF", + elements: [ + { + ...base, + id: "sh", + type: "shape", + x: 100, + y: 100, + w: 400, + h: 400, + shape: "rect", + fill: "#3366FF", + shadow: { color: "#000000", blur: 8, offsetX: 4, offsetY: 4 }, + }, + ], + }, + ]); + const zip = await generate(deck); + const slide = await zip.file("ppt/slides/slide1.xml")?.async("string"); + expect(slide).toContain(""); + expect(slide).toContain(" when glow is set, and for dashType", async () => { + const deck = makeDeck([ + { + id: "s", + background: "#FFFFFF", + elements: [ + { + ...base, + id: "g", + type: "shape", + x: 0, + y: 0, + w: 200, + h: 200, + shape: "rect", + // Use a gradient fill so this shape takes the synth path and + // emits prstDash through the synth line writer (the + // pptxgenjs path supports dashType too but writes via a + // different attribute). + fill: "linear-gradient(0deg, #fff 0%, #000 100%)", + stroke: "#222222", + strokeWidth: 2, + dashType: "dashDot", + glow: { color: "#FFAA00", radius: 10 }, + }, + ], + }, + ]); + const zip = await generate(deck); + const slide = await zip.file("ppt/slides/slide1.xml")?.async("string"); + expect(slide).toContain("; +} + +const synthBySlide = new Map(); +function synthForSlide(i: number): SynthSlideEntry { + let e = synthBySlide.get(i); + if (!e) { + e = { shapeXml: [], charts: [], media: [], effectsByName: new Map() }; + synthBySlide.set(i, e); + } + return e; +} + export async function serializeDeck( deck: Deck, options: SerializeOptions = {} ): Promise { + synthBySlide.clear(); + const pptx = new pptxgen(); pptx.title = deck.title || "Untitled"; pptx.layout = "LAYOUT_WIDE"; // 13.333 × 7.5 in - for (const slide of deck.slides) { - addSlide(pptx, slide); + for (let i = 0; i < deck.slides.length; i++) { + addSlide(pptx, deck.slides[i], i); } // Use arraybuffer (universal: works in Node + browser, accepted by JSZip @@ -73,10 +115,14 @@ export async function serializeDeck( return preserveUnknowns(generated, deck, options.source); } -function addSlide(pptx: pptxgen, slide: Slide): void { +function addSlide(pptx: pptxgen, slide: Slide, slideIndex: number): void { const s = pptx.addSlide(); - s.background = { color: hexNoHash(slide.background) }; + // pptxgenjs only understands flat-hex slide backgrounds. For richer forms + // (gradients / image fills) we leave a sentinel hex here and overwrite the + // emitted `` in post-process. + s.background = { color: hexNoHash(extractSolidColor(slide.background)) }; + const synth = synthForSlide(slideIndex); const sorted = [...slide.elements].sort((a, b) => a.z - b.z); for (const el of sorted) { // Skip elements whose imported OOXML survived this far AND haven't @@ -84,8 +130,12 @@ function addSlide(pptx: pptxgen, slide: Slide): void { // verbatim, sidestepping pptxgenjs's lossy translation of // gradient / custGeom / backing fields. if (isPristineImportedElement(el)) continue; + if (shouldSynthesise(el)) { + synthesiseInto(synth, el); + continue; + } try { - addElement(s, el); + addElement(s, el, synth); } catch (err) { console.warn( `[slidewise/pptx] failed to write element ${el.id} (${el.type}):`, @@ -95,25 +145,93 @@ function addSlide(pptx: pptxgen, slide: Slide): void { } } +/** + * Does this element need synthesised OOXML rather than pptxgenjs's emitter? + * The synth path handles gradient/image fills, custGeom paths, in-app charts, + * groups — everything the public API allows that pptxgenjs would silently + * collapse to a solid colour or drop entirely. + */ +function shouldSynthesise(el: SlideElement): boolean { + if (el.type === "shape") { + if (el.path) return true; + const parsed = parseFill(el.fill); + if (parsed && (parsed.kind === "linear" || parsed.kind === "radial" || parsed.kind === "image")) { + return true; + } + return false; + } + if (el.type === "group") return true; + if (el.type === "chart" && !el.ooxmlXml) return true; + return false; +} + +function synthesiseInto(synth: SynthSlideEntry, el: SlideElement): void { + if (el.type === "shape") { + const out = synthesiseShape(el); + synth.shapeXml.push(out.xml); + for (const m of out.media) synth.media.push(m); + return; + } + if (el.type === "group") { + const out = synthesiseGroup(el as GroupElement, (child) => + renderGroupChild(child) + ); + synth.shapeXml.push(out.xml); + for (const m of out.media) synth.media.push(m); + return; + } + if (el.type === "chart") { + const out = synthesiseChart(el as ChartElement); + synth.charts.push(out); + return; + } +} + +/** + * Render a single child for ``. We only synthesise shapes/charts + * inside groups for v1 — text / image / line children remain renderable + * inside the group at the *renderer* layer, but the PPTX writer emits them + * as solid-fill rect placeholders so the group has a valid spTree. This is + * the documented "deferred sub-case" for PR 5. + */ +function renderGroupChild( + child: SlideElement +): { xml: string; media: MediaPayload[] } | null { + if (child.type === "shape") { + return synthesiseShape(child); + } + if (child.type === "group") { + return synthesiseGroup(child as GroupElement, (c) => renderGroupChild(c)); + } + // Other element types inside a group: fall back to a transparent rect so + // the group's child list stays valid. The text/image content is lost on + // round-trip — that's the PR-5 follow-up. + return null; +} + function isPristineImportedElement(el: SlideElement): boolean { const src = getElementSource(el.id); if (!src) return false; return src.snapshot === snapshotElement(el); } -function addElement(s: pptxgen.Slide, el: SlideElement): void { +function addElement( + s: pptxgen.Slide, + el: SlideElement, + synth: SynthSlideEntry +): void { switch (el.type) { case "text": - addText(s, el); + addText(s, el, synth); return; case "shape": - addShape(s, el); + addShape(s, el, synth); return; case "image": addImage(s, el); return; case "line": - addLine(s, el); + addLine(s, el, synth); return; case "table": addTable(s, el); @@ -131,6 +249,9 @@ function addElement(s: pptxgen.Slide, el: SlideElement): void { // re-emit the source verbatim so the chart and its embedded Excel // survive open/save. return; + case "group": + // Groups are handled by the synth path before reaching here. + return; case "unknown": // Preserved by preserveUnknowns() after pptxgenjs writes the zip. // The post-process step injects el.ooxmlXml into the matching @@ -139,6 +260,15 @@ function addElement(s: pptxgen.Slide, el: SlideElement): void { } } +/** Pull a solid colour out of a fill string if there is one, otherwise + * `#FFFFFF` so the synth path's `` replacement has something benign + * to overwrite. */ +function extractSolidColor(fill: string | undefined): string { + const parsed = parseFill(fill); + if (parsed && parsed.kind === "solid") return parsed.color; + return "#FFFFFF"; +} + function geometry(el: SlideElement): { x: number; y: number; @@ -155,10 +285,32 @@ function geometry(el: SlideElement): { }; } -function addText(s: pptxgen.Slide, el: TextElement): void { +function addText( + s: pptxgen.Slide, + el: TextElement, + synth: SynthSlideEntry +): void { + // Tag the shape so the post-processor can splice effect XML by name. + if (el.shadow || el.glow) { + synth.effectsByName.set( + slidewiseShapeName(el.id), + effectLstXml(el.shadow, el.glow) + ); + } + // TextElement.background fills the text box's bounding rect (PPTX + // importer sets this from layout-placeholder fills, AI-authored decks + // use it for boxed-bullet / tinted-card layouts). pptxgenjs only + // accepts solid hex via `fill`. Skip non-hex strings (gradients, + // url(...)) — those rare cases need a synth shape underlay, which is + // PR 2 territory and not common on text boxes. + const bgHex = + el.background && /^#[0-9a-fA-F]{6}$/.test(el.background) + ? hexNoHash(el.background) + : undefined; const baseOpts = { ...geometry(el), fontFace: el.fontFamily, + objectName: el.shadow || el.glow ? slidewiseShapeName(el.id) : undefined, fontSize: pxToPoints(el.fontSize), color: hexNoHash(el.color), bold: el.fontWeight >= 600, @@ -167,6 +319,7 @@ function addText(s: pptxgen.Slide, el: TextElement): void { strike: el.strike ? ("sngStrike" as const) : undefined, align: el.align, valign: el.vAlign, + fill: bgHex ? { color: bgHex } : undefined, charSpacing: el.letterSpacing ? Math.round(el.letterSpacing * 100) : undefined, @@ -220,26 +373,56 @@ const SHAPE_MAP: Record = { star: "star5", }; -function addShape(s: pptxgen.Slide, el: ShapeElement): void { +function addShape( + s: pptxgen.Slide, + el: ShapeElement, + synth: SynthSlideEntry +): void { const shapeName = SHAPE_MAP[el.shape] ?? "rect"; + if (el.shadow || el.glow) { + synth.effectsByName.set( + slidewiseShapeName(el.id), + effectLstXml(el.shadow, el.glow) + ); + } // pptxgenjs accepts shape names as strings; the typed ShapeType enum is // also exposed. Pass via `as unknown as` to bypass strict enum typing. s.addShape(shapeName as unknown as Parameters[0], { ...geometry(el), - fill: { color: hexNoHash(el.fill) }, + fill: { color: hexNoHash(extractSolidColor(el.fill)) }, line: el.stroke ? { color: hexNoHash(el.stroke), width: el.strokeWidth ?? 1, + dashType: shapeDashType(el), } : { type: "none" }, rectRadius: el.shape === "rounded" && el.radius != null ? clamp01(el.radius / Math.min(el.w, el.h)) : undefined, + objectName: el.shadow || el.glow ? slidewiseShapeName(el.id) : undefined, }); } +function lineDashType( + el: LineElement +): "solid" | "dash" | "dashDot" | "lgDash" | "sysDash" | "sysDot" { + const dt = el.dashType ?? (el.dashed ? "dash" : "solid"); + if (dt === "dot") return "sysDot"; + return dt; +} + +function shapeDashType( + el: ShapeElement +): "solid" | "dash" | "dashDot" | "lgDash" | "sysDash" | "sysDot" | undefined { + // Our public `DashType` includes plain "dot" (OOXML allows it) but + // pptxgenjs's enum doesn't; alias dot → sysDot so the legacy enum is happy. + if (!el.dashType) return undefined; + if (el.dashType === "dot") return "sysDot"; + return el.dashType; +} + function addImage(s: pptxgen.Slide, el: ImageElement): void { const opts: Parameters[0] = { ...geometry(el), @@ -258,7 +441,17 @@ function addImage(s: pptxgen.Slide, el: ImageElement): void { s.addImage(opts); } -function addLine(s: pptxgen.Slide, el: LineElement): void { +function addLine( + s: pptxgen.Slide, + el: LineElement, + synth: SynthSlideEntry +): void { + if (el.shadow || el.glow) { + synth.effectsByName.set( + slidewiseShapeName(el.id), + effectLstXml(el.shadow, el.glow) + ); + } s.addShape( "line" as unknown as Parameters[0], { @@ -266,9 +459,10 @@ function addLine(s: pptxgen.Slide, el: LineElement): void { line: { color: hexNoHash(el.stroke), width: el.strokeWidth, - dashType: el.dashed ? "dash" : "solid", + dashType: lineDashType(el), endArrowType: el.arrow ? "triangle" : "none", }, + objectName: el.shadow || el.glow ? slidewiseShapeName(el.id) : undefined, } ); } @@ -343,19 +537,50 @@ async function preserveUnknowns( deck: Deck, explicitSource?: Blob | ArrayBuffer | Uint8Array ): Promise { - const wrapBlob = () => new Blob([generated], { type: PPTX_MIME }); // Prefer the caller-supplied source (survives state cloning / localStorage // rehydrate); fall back to the non-enumerable attachment from parsePptx // for the "parse → serialize" happy path with no state in between. const sourceBuffer = await resolveSource(deck, explicitSource); - if (!sourceBuffer) return wrapBlob(); + + // Synthesised OOXML from the writer always needs post-processing — even + // when there's no source PPTX, gradients / custGeom / charts / embedded + // fonts have to be spliced into the generated zip. + const hasSynth = + synthBySlide.size > 0 || + deck.slides.some( + (s) => { + const parsed = parseFill(s.background); + return parsed && parsed.kind !== "solid"; + } + ) || + (deck.fonts && deck.fonts.length > 0); + + if (!sourceBuffer && !hasSynth) { + // Still strip dangling Content_Types overrides — pptxgenjs declares + // slideMaster1..N for every slide but only writes slideMaster1.xml. + // 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 pruneDanglingContentTypes(outZip); + return outZip.generateAsync({ type: "blob", mimeType: PPTX_MIME }); + } + if (!sourceBuffer && hasSynth) { + // No source: still run the synth-only post-process. The chrome / EMF / + // slide-bg replay paths short-circuit on a null source archive. + const outZip = await JSZip.loadAsync(generated); + await applySynth(outZip, deck); + await applySynthSlideBackgrounds(outZip, deck); + await applyEmbeddedFontsFromJson(outZip, deck); + await pruneDanglingContentTypes(outZip); + return outZip.generateAsync({ type: "blob", mimeType: PPTX_MIME }); + } const unknownsBySlide = collectUnknowns(deck); const pristinesBySlide = collectPristineImports(deck); const [outZip, srcZip] = await Promise.all([ JSZip.loadAsync(generated), - JSZip.loadAsync(sourceBuffer), + JSZip.loadAsync(sourceBuffer as ArrayBuffer), ]); // The source's slide-XML paths (in deck order). Used as a fallback when @@ -418,6 +643,18 @@ async function preserveUnknowns( // available so gradients survive intact. await preserveSlideBackgrounds(outZip, srcZip, deck, sourceSlidePaths); + // Synthesised content (custGeom shapes, gradient fills, in-app charts, + // groups, effect splices) — applied after source preservation so we never + // overwrite the source's pristine fragments. JSON gradient backgrounds + // only fire when the source preserve found nothing (preserveSlideBackgrounds + // already wrote any source-provided bg). + await applySynth(outZip, deck); + await applySynthSlideBackgrounds(outZip, deck); + // Embedded fonts from deck.fonts only fire when chrome preservation didn't + // copy any — avoids duplicating font entries when both source + deck.fonts + // are set. + await applyEmbeddedFontsFromJson(outZip, deck); + // JSZip's blob output preserves the OOXML mime type set by pptxgenjs. return outZip.generateAsync({ type: "blob", mimeType: PPTX_MIME }); } @@ -1497,3 +1734,372 @@ function isDataUrl(src: string): boolean { function clamp01(n: number): number { return Math.max(0, Math.min(1, n)); } + +// -- Synthesised-OOXML post-process (PRs 1–7) ------------------------------ + +/** + * Splice the synthesised shape / group / chart OOXML accumulated in + * `synthBySlide` into the generated zip. Rewrites marker rIds to fresh + * per-slide rIds and writes media + chart parts as needed. + */ +async function applySynth(outZip: JSZip, deck: Deck): Promise { + for (let i = 0; i < deck.slides.length; i++) { + const synth = synthBySlide.get(i); + if (!synth) continue; + const slidePath = `ppt/slides/slide${i + 1}.xml`; + const relsPath = `ppt/slides/_rels/slide${i + 1}.xml.rels`; + const slideFile = outZip.file(slidePath); + if (!slideFile) continue; + let slideXml = await slideFile.async("string"); + + let relsXml = + (await outZip.file(relsPath)?.async("string")) ?? + `\n`; + + const rels = parseRels(relsXml); + let nextRid = highestRid(rels) + 1; + const newRelLines: string[] = []; + + // Combine shape/group XML with chart graphicFrames. Marker rIds embed + // their owning element id, so we walk shape XML in emission order and + // pair each *first-seen* marker with a media payload of the matching + // scope. That keeps multi-image shapes correct without a separate map. + const mediaQueue = [...synth.media]; + const consumeMedia = (): MediaPayload | undefined => mediaQueue.shift(); + const shapeBlobs: string[] = []; + for (const shapeXml of synth.shapeXml) { + let rewritten = shapeXml; + const markers = unique(shapeXml.match(RID_MARKER_RE) ?? []); + for (const marker of markers) { + const rid = `rId${nextRid++}`; + rewritten = rewritten.replaceAll(marker, rid); + const media = consumeMedia(); + if (media) { + outZip.file(media.fullPath, media.data, { binary: true }); + newRelLines.push(buildRelXml(rid, media.relType, media.relTarget)); + } + } + shapeBlobs.push(rewritten); + } + + // Charts: rewrite marker rIds, write part + rels, register Content_Types. + for (const chart of synth.charts) { + const ridMarkers = unique(chart.graphicFrameXml.match(RID_MARKER_RE) ?? []); + let frame = chart.graphicFrameXml; + for (const marker of ridMarkers) { + const rid = `rId${nextRid++}`; + frame = frame.replaceAll(marker, rid); + // Slide rels target is relative to ppt/slides. Chart part lives at + // ppt/charts/chartSW_X.xml. + const target = `../charts/${chart.partPath.replace(/^ppt\/charts\//, "")}`; + newRelLines.push( + buildRelXml( + rid, + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart", + target + ) + ); + } + outZip.file(chart.partPath, chart.chartXml); + outZip.file(chart.partRelsPath, chart.chartRelsXml); + await registerChartContentType(outZip, chart.partPath); + shapeBlobs.push(frame); + } + + if (shapeBlobs.length) { + // Prepend at the low-z position (right after ``) so + // synth shapes — gradient panels, custGeom backdrops, the underlay + // for text-on-tint cards — sit BELOW the text/images pptxgenjs + // already wrote. Appending at `` would put them on top + // and cover the very content they're supposed to back. + const insertAt = findSpTreeContentInsertionPoint(slideXml); + if (insertAt >= 0) { + slideXml = + slideXml.slice(0, insertAt) + + shapeBlobs.join("") + + slideXml.slice(insertAt); + } else { + const close = slideXml.lastIndexOf(""); + if (close >= 0) { + slideXml = + slideXml.slice(0, close) + + shapeBlobs.join("") + + slideXml.slice(close); + } + } + } + + // PR 7: splice `` into pptxgenjs-emitted shapes by name. + if (synth.effectsByName.size) { + slideXml = spliceEffectsByName(slideXml, synth.effectsByName); + } + + if (newRelLines.length) { + const insertAt = relsXml.lastIndexOf(""); + relsXml = + insertAt >= 0 + ? relsXml.slice(0, insertAt) + + newRelLines.join("") + + relsXml.slice(insertAt) + : relsXml.replace( + /]*>/, + (m) => `${m}${newRelLines.join("")}` + ); + outZip.file(relsPath, relsXml); + } + outZip.file(slidePath, slideXml); + } +} + +function unique(arr: T[]): T[] { + return Array.from(new Set(arr)); +} + +async function registerChartContentType( + outZip: JSZip, + partPath: string +): Promise { + const ctFile = outZip.file("[Content_Types].xml"); + if (!ctFile) return; + const xml = await ctFile.async("string"); + if (xml.includes(`PartName="/${partPath}"`)) return; + const override = ``; + const close = xml.lastIndexOf(""); + if (close < 0) return; + outZip.file( + "[Content_Types].xml", + xml.slice(0, close) + override + xml.slice(close) + ); +} + +/** + * Splice an `...` block into each `` / + * `` whose `` matches one of the keys. + * Inserts the block just before ``, replacing any existing + * effectLst inside the same spPr. + */ +function spliceEffectsByName( + slideXml: string, + effectsByName: Map +): string { + // Match whole `` blocks (so we don't accidentally consume + // the outer `` cNvPr's tail into the next shape's spPr). + return slideXml.replace( + //g, + (sp: string) => { + const nameMatch = /]*?name="([^"]*)"/.exec(sp); + if (!nameMatch || !nameMatch[1]) return sp; + const eff = effectsByName.get(nameMatch[1]); + if (!eff) return sp; + // Insert just before the FIRST `` inside the shape; that's + // the spPr that owns the visual properties. Strip any existing empty + // effectLst before splicing. + const cleaned = sp.replace(//, ""); + const at = cleaned.indexOf(""); + if (at < 0) return sp; + return cleaned.slice(0, at) + eff + cleaned.slice(at); + } + ); +} + +/** + * Write JSON-defined gradient / image slide backgrounds (PR 3). Only fires + * when the source-PPTX bg preservation pass left the slide's `` empty + * — that ensures source bytes always win. + */ +async function applySynthSlideBackgrounds( + outZip: JSZip, + deck: Deck +): Promise { + for (let i = 0; i < deck.slides.length; i++) { + const slide = deck.slides[i]; + const parsed = parseFill(slide.background); + if (!parsed) continue; + if (parsed.kind === "solid") continue; + const slidePath = `ppt/slides/slide${i + 1}.xml`; + const relsPath = `ppt/slides/_rels/slide${i + 1}.xml.rels`; + const slideFile = outZip.file(slidePath); + if (!slideFile) continue; + let slideXml = await slideFile.async("string"); + + // If the slide already has a non-solid `` (source preservation + // wrote one), leave it alone. Solid `` from pptxgenjs gets + // overwritten below — that's what we want. + const existing = /|]*\/\s*>/.exec(slideXml); + if (existing) { + // Detect "this is a richer bg than solid" by looking for gradFill / + // blipFill / pattFill — those come from source preservation. + if (/\n`; + if (synth.media.length) { + const rels = parseRels(relsXml); + let nextRid = highestRid(rels) + 1; + const newRelLines: string[] = []; + const markers = unique(bgXml.match(RID_MARKER_RE) ?? []); + for (let mi = 0; mi < markers.length; mi++) { + const rid = `rId${nextRid++}`; + bgXml = bgXml.replaceAll(markers[mi], rid); + const media = synth.media[mi]; + if (!media) break; + outZip.file(media.fullPath, media.data, { binary: true }); + newRelLines.push(buildRelXml(rid, media.relType, media.relTarget)); + } + if (newRelLines.length) { + const at = relsXml.lastIndexOf(""); + relsXml = + at >= 0 + ? relsXml.slice(0, at) + newRelLines.join("") + relsXml.slice(at) + : relsXml.replace( + /]*>/, + (m) => `${m}${newRelLines.join("")}` + ); + outZip.file(relsPath, relsXml); + } + } + + if (existing) { + slideXml = slideXml.replace( + /|]*\/\s*>/, + bgXml + ); + } else { + const cSldOpen = /]*>/.exec(slideXml); + if (cSldOpen) { + const at = cSldOpen.index + cSldOpen[0].length; + slideXml = slideXml.slice(0, at) + bgXml + slideXml.slice(at); + } + } + outZip.file(slidePath, slideXml); + } +} + +/** + * Write JSON-defined embedded fonts (PR 6). Only fires when chrome + * preservation didn't already copy fonts from a source — that's detected + * by checking whether `ppt/fonts/` is populated. + */ +async function applyEmbeddedFontsFromJson( + outZip: JSZip, + deck: Deck +): Promise { + if (!deck.fonts || !deck.fonts.length) return; + const existingFonts: string[] = []; + outZip.forEach((path) => { + if (path.startsWith("ppt/fonts/") && path.endsWith(".fntdata")) { + existingFonts.push(path); + } + }); + if (existingFonts.length) return; + + const descriptors = await synthesiseEmbeddedFonts(deck.fonts); + if (!descriptors.length) return; + + // Write font bytes. + for (const d of descriptors) { + for (const p of d.payloads) { + outZip.file(p.fullPath, p.data, { binary: true }); + } + } + + // Register `.fntdata` Default content type. + const ctFile = outZip.file("[Content_Types].xml"); + if (ctFile) { + let ct = await ctFile.async("string"); + if (!/Extension="fntdata"/i.test(ct)) { + ct = ct.replace( + /]*>/, + (m) => + `${m}` + ); + outZip.file("[Content_Types].xml", ct); + } + } + + // Add font rels to presentation.xml.rels and rewrite the marker rIds in + // each `` to those allocated rIds. + const presRelsFile = outZip.file("ppt/_rels/presentation.xml.rels"); + if (!presRelsFile) return; + let presRelsXml = await presRelsFile.async("string"); + const presRels = parseRels(presRelsXml); + let nextRid = highestRid(presRels) + 1; + const newRels: string[] = []; + const embeddedFontXml: string[] = []; + for (const d of descriptors) { + let xml = d.embeddedFontXml; + for (const r of d.rels) { + const rid = `rId${nextRid++}`; + xml = xml.replaceAll(r.ridMarker, rid); + newRels.push(buildRelXml(rid, r.relType, r.target)); + } + embeddedFontXml.push(xml); + } + const insertAt = presRelsXml.lastIndexOf(""); + presRelsXml = + insertAt >= 0 + ? presRelsXml.slice(0, insertAt) + + newRels.join("") + + presRelsXml.slice(insertAt) + : presRelsXml.replace( + /]*>/, + (m) => `${m}${newRels.join("")}` + ); + outZip.file("ppt/_rels/presentation.xml.rels", presRelsXml); + + // Splice `` into presentation.xml (after sldIdLst). + const presFile = outZip.file("ppt/presentation.xml"); + if (!presFile) return; + let presXml = await presFile.async("string"); + const fontLst = `${embeddedFontXml.join("")}`; + if (//, + fontLst + ); + } else { + const after = /<\/p:sldIdLst>/.exec(presXml); + if (after) { + const at = after.index + after[0].length; + presXml = presXml.slice(0, at) + fontLst + presXml.slice(at); + } + } + outZip.file("ppt/presentation.xml", presXml); +} + +/** + * Strip `` entries from `[Content_Types].xml` whose `PartName` + * doesn't correspond to a file actually present in the output zip. + * + * pptxgenjs has a long-standing quirk: it declares `slideMaster1.xml` + * through `slideMasterN.xml` (one per slide) even though it only ever + * writes `slideMaster1.xml`. PowerPoint enforces the manifest strictly + * and refuses to open the file when declared parts are missing + * ("PowerPoint found a problem with content"). Keynote is lenient and + * just emits the "may look different" warning. Removing the stale + * overrides makes the file legal for both apps. + */ +async function pruneDanglingContentTypes(outZip: JSZip): Promise { + const ctFile = outZip.file("[Content_Types].xml"); + if (!ctFile) return; + const xml = await ctFile.async("string"); + // Collect every actual file path in the zip so we can answer "does + // /foo/bar.xml exist?" in O(1). + const existing = new Set(); + outZip.forEach((path, entry) => { + if (!entry.dir) existing.add("/" + path); + }); + // `[^>]*` (not `[^/]*`) so ContentType values containing slashes — + // every PPTX MIME type does — don't break the match. + const pruned = xml.replace( + /]*PartName="([^"]+)"[^>]*\/>/g, + (match, partName: string) => (existing.has(partName) ? match : "") + ); + if (pruned !== xml) outZip.file("[Content_Types].xml", pruned); +} diff --git a/packages/slidewise/src/lib/pptx/pptxWriters.ts b/packages/slidewise/src/lib/pptx/pptxWriters.ts new file mode 100644 index 0000000..ab509a7 --- /dev/null +++ b/packages/slidewise/src/lib/pptx/pptxWriters.ts @@ -0,0 +1,920 @@ +/** + * Synthesised-OOXML writers for element forms pptxgenjs can't emit faithfully: + * + * - `` shapes (PR 1) — arbitrary SVG paths. + * - `` / `` shape fills (PR 2) — gradients + image fills. + * - Slide `` gradient / image backgrounds (PR 3). + * - In-app `` parts (PR 4) — partial: bar/column/line/pie/doughnut/area + * with cached values, no embedded xlsx. PowerPoint renders from the cache. + * - `` group shapes (PR 5) — writer-only. + * - `` + `ppt/fonts/` (PR 6). + * - `` shadow / glow (PR 7) — woven into shape XML; for shapes + * pptxgenjs still emits, spliced in via post-process by matching the + * `cNvPr.name` we tag on output. + * + * The orchestration lives in deckToPptx.ts. This module is intentionally + * stateless — every function takes its slide / deck inputs and returns XML + * strings + zip-side-effect descriptors, leaving the actual JSZip writes to + * the orchestrator. That keeps the OOXML emission unit-testable in isolation. + */ + +import type { + Deck, + SlideElement, + ShapeElement, + GroupElement, + ChartElement, + ShadowSpec, + GlowSpec, + FontAsset, + Slide, +} from "@/lib/types"; +import { pxToEmu, EMU_PER_POINT } from "./units"; + +// -- Identifiers ------------------------------------------------------------ + +/** + * `cNvPr` name we stamp on synthesised shapes so the post-processor (in + * `deckToPptx.ts`) can splice effects into pptxgenjs-emitted shapes by name + * match. Includes the element id so a deck with multiple identical shapes + * keeps its 1:1 mapping. + */ +export const slidewiseShapeName = (elementId: string): string => + `slidewise:${elementId}`; + +let nextNvId = 100000; +/** Returns a numeric id unique within one writer pass. PPTX needs `cNvPr/@id` + * to be unique per ``; we bias the counter high to avoid colliding + * with whatever pptxgenjs allocated for the same spTree. */ +export function freshNvId(): number { + return nextNvId++; +} + +// -- Fill parsing ----------------------------------------------------------- + +export type ParsedFill = + | { kind: "solid"; color: string; alpha?: number } + | { kind: "transparent" } + | { kind: "linear"; angle: number; stops: GradStop[] } + | { kind: "radial"; focusX: number; focusY: number; stops: GradStop[]; shape: "circle" | "ellipse" } + | { kind: "image"; src: string }; + +export interface GradStop { + /** Percentage 0..100. */ + pos: number; + /** `#RRGGBB`. */ + color: string; + /** 0..1, optional alpha. */ + alpha?: number; +} + +/** + * Classify a CSS-ish fill string. Mirrors the inverse of `extractShapeFill` + * in pptxToDeck.ts. Returns null when the form isn't one we synthesise (e.g. + * named colors, oklch(), gradients with unparseable stops). Unknown forms + * fall back to solid black so the writer never throws. + */ +export function parseFill(fill: string | undefined): ParsedFill | null { + if (!fill) return null; + const s = fill.trim(); + if (!s || s === "transparent") return { kind: "transparent" }; + if (s.startsWith("#")) { + return { kind: "solid", color: normaliseHex(s) }; + } + const rgb = /^rgba?\(([^)]+)\)$/i.exec(s); + if (rgb) { + const parts = rgb[1].split(",").map((p) => p.trim()); + const r = clampByte(parseInt(parts[0], 10)); + const g = clampByte(parseInt(parts[1], 10)); + const b = clampByte(parseInt(parts[2], 10)); + const a = parts[3] != null ? Number(parts[3]) : 1; + return { + kind: "solid", + color: `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase(), + alpha: Number.isFinite(a) ? a : 1, + }; + } + if (s.startsWith("linear-gradient(")) { + const inner = s.slice("linear-gradient(".length, s.lastIndexOf(")")); + const parts = splitTopLevelCommas(inner); + let angle = 90; + let rest = parts; + const angMatch = /^(-?\d+(?:\.\d+)?)deg$/.exec(parts[0]); + if (angMatch) { + angle = Number(angMatch[1]); + rest = parts.slice(1); + } + const stops = parseStops(rest); + if (!stops.length) return null; + return { kind: "linear", angle, stops }; + } + if (s.startsWith("radial-gradient(")) { + const inner = s.slice("radial-gradient(".length, s.lastIndexOf(")")); + const parts = splitTopLevelCommas(inner); + let focusX = 50; + let focusY = 50; + let shape: "circle" | "ellipse" = "ellipse"; + let stopParts = parts; + // First segment may be `circle at X% Y%` / `ellipse at X% Y%`. + const head = parts[0]?.trim() ?? ""; + if (/^(circle|ellipse)\b/i.test(head)) { + if (/^circle\b/i.test(head)) shape = "circle"; + const at = /at\s+(-?\d+(?:\.\d+)?)%\s+(-?\d+(?:\.\d+)?)%/.exec(head); + if (at) { + focusX = Number(at[1]); + focusY = Number(at[2]); + } + stopParts = parts.slice(1); + } + const stops = parseStops(stopParts); + if (!stops.length) return null; + return { kind: "radial", focusX, focusY, stops, shape }; + } + const url = /^url\(\s*['"]?([^'")\s]+)['"]?\s*\)$/.exec(s); + if (url) { + return { kind: "image", src: url[1] }; + } + return null; +} + +function parseStops(parts: string[]): GradStop[] { + const out: GradStop[] = []; + for (const p of parts) { + const t = p.trim(); + if (!t) continue; + // "#RRGGBB 12.34%" / "rgba(...) 0%" / "#fff" (no pos). + const posMatch = /\s+(-?\d+(?:\.\d+)?)%\s*$/.exec(t); + const colorRaw = posMatch ? t.slice(0, posMatch.index).trim() : t; + const fill = parseFill(colorRaw); + if (!fill) continue; + if (fill.kind !== "solid" && fill.kind !== "transparent") continue; + const color = fill.kind === "transparent" ? "#000000" : fill.color; + const alpha = fill.kind === "transparent" ? 0 : fill.alpha; + out.push({ + pos: posMatch ? Number(posMatch[1]) : (out.length === 0 ? 0 : 100), + color, + alpha, + }); + } + return out.sort((a, b) => a.pos - b.pos); +} + +function splitTopLevelCommas(s: string): string[] { + const out: string[] = []; + let depth = 0; + let start = 0; + for (let i = 0; i < s.length; i++) { + const c = s[i]; + if (c === "(") depth++; + else if (c === ")") depth--; + else if (c === "," && depth === 0) { + out.push(s.slice(start, i)); + start = i + 1; + } + } + out.push(s.slice(start)); + return out; +} + +// -- Color helpers (no `#`, uppercase) -------------------------------------- + +export function hexBare(color: string): string { + if (!color) return "000000"; + const c = color.trim(); + if (c.startsWith("#")) { + const body = c.slice(1); + if (body.length === 3) { + return body + .split("") + .map((ch) => `${ch}${ch}`) + .join("") + .toUpperCase(); + } + return body.toUpperCase().slice(0, 6); + } + return c.toUpperCase(); +} + +function normaliseHex(s: string): string { + return `#${hexBare(s)}`; +} + +function clampByte(n: number): number { + if (!Number.isFinite(n)) return 0; + return Math.max(0, Math.min(255, Math.round(n))); +} + +function toHex(n: number): string { + return n.toString(16).padStart(2, "0"); +} + +// -- Gradient angle: CSS → OOXML -------------------------------------------- + +/** + * CSS angles measure clockwise from "up"; OOXML `` measures + * clockwise from "right" in 60000ths of a degree. The importer applied + * `(xmlAng/60000 + 90) % 360`; the inverse is below. + */ +export function cssAngleToOoxml(cssDeg: number): number { + const norm = ((cssDeg - 90) % 360 + 360) % 360; + return Math.round(norm * 60000); +} + +// -- Path: SVG `d` → OOXML pathLst ----------------------------------------- + +/** + * Translate the absolute-coordinate subset of an SVG `d` attribute into + * ``. Supports M, L, C, Q, Z and the relative forms (m, l, c, q) + * by tracking the pen. Falls back to `null` for anything beyond that + * (arcs / smooth shorthands / formulas) so the caller can downgrade to a + * simple rect rather than emit broken geometry. + */ +export function svgPathToOoxml( + d: string, + viewW: number, + viewH: number, + fillRule: "nonzero" | "evenodd" = "nonzero" +): string | null { + const tokens = tokenisePath(d); + if (!tokens.length) return null; + let i = 0; + let penX = 0; + let penY = 0; + let startX = 0; + let startY = 0; + const cmds: string[] = []; + while (i < tokens.length) { + const t = tokens[i]; + if (typeof t !== "string") return null; // expected command letter + const cmd = t; + i++; + if (cmd === "M" || cmd === "m") { + const rel = cmd === "m"; + let first = true; + while (typeof tokens[i] === "number") { + const x = Number(tokens[i++]) + (rel ? penX : 0); + const y = Number(tokens[i++]) + (rel ? penY : 0); + if (first) { + cmds.push(`${ptXml(x, y)}`); + startX = x; + startY = y; + first = false; + } else { + // Subsequent coord pairs after M are implicit L per the SVG spec. + cmds.push(`${ptXml(x, y)}`); + } + penX = x; + penY = y; + } + } else if (cmd === "L" || cmd === "l") { + const rel = cmd === "l"; + while (typeof tokens[i] === "number") { + const x = Number(tokens[i++]) + (rel ? penX : 0); + const y = Number(tokens[i++]) + (rel ? penY : 0); + cmds.push(`${ptXml(x, y)}`); + penX = x; + penY = y; + } + } else if (cmd === "H" || cmd === "h") { + const rel = cmd === "h"; + while (typeof tokens[i] === "number") { + const x = Number(tokens[i++]) + (rel ? penX : 0); + cmds.push(`${ptXml(x, penY)}`); + penX = x; + } + } else if (cmd === "V" || cmd === "v") { + const rel = cmd === "v"; + while (typeof tokens[i] === "number") { + const y = Number(tokens[i++]) + (rel ? penY : 0); + cmds.push(`${ptXml(penX, y)}`); + penY = y; + } + } else if (cmd === "C" || cmd === "c") { + const rel = cmd === "c"; + while (typeof tokens[i] === "number") { + const x1 = Number(tokens[i++]) + (rel ? penX : 0); + const y1 = Number(tokens[i++]) + (rel ? penY : 0); + const x2 = Number(tokens[i++]) + (rel ? penX : 0); + const y2 = Number(tokens[i++]) + (rel ? penY : 0); + const x = Number(tokens[i++]) + (rel ? penX : 0); + const y = Number(tokens[i++]) + (rel ? penY : 0); + cmds.push( + `${ptXml(x1, y1)}${ptXml(x2, y2)}${ptXml(x, y)}` + ); + penX = x; + penY = y; + } + } else if (cmd === "Q" || cmd === "q") { + const rel = cmd === "q"; + while (typeof tokens[i] === "number") { + const x1 = Number(tokens[i++]) + (rel ? penX : 0); + const y1 = Number(tokens[i++]) + (rel ? penY : 0); + const x = Number(tokens[i++]) + (rel ? penX : 0); + const y = Number(tokens[i++]) + (rel ? penY : 0); + cmds.push(`${ptXml(x1, y1)}${ptXml(x, y)}`); + penX = x; + penY = y; + } + } else if (cmd === "Z" || cmd === "z") { + cmds.push(``); + penX = startX; + penY = startY; + } else { + // Unsupported command (A, S, T, …) — bail so caller can downgrade. + return null; + } + } + if (!cmds.length) return null; + return ( + `` + + `` + + cmds.join("") + + `` + + `` + ); +} + +function ptXml(x: number, y: number): string { + return ``; +} + +function tokenisePath(d: string): Array { + const out: Array = []; + const re = /([MmLlHhVvCcQqZzSsTtAa])|(-?\d*\.?\d+(?:[eE][-+]?\d+)?)/g; + let m: RegExpExecArray | null; + while ((m = re.exec(d))) { + if (m[1]) out.push(m[1]); + else out.push(Number(m[2])); + } + return out; +} + +// -- Shape XML synthesis ---------------------------------------------------- + +export interface MediaPayload { + /** Target path under the slide rels dir, e.g. `../media/imageSlidewise1.png`. */ + relTarget: string; + /** Absolute path inside the zip, e.g. `ppt/media/imageSlidewise1.png`. */ + fullPath: string; + /** Raw bytes. */ + data: Uint8Array; + /** OOXML rel type. */ + relType: string; +} + +export interface SynthShapeResult { + xml: string; + media: MediaPayload[]; + /** Suggested rel id allocation: writer will renumber at injection time. */ +} + +/** + * Emit a synthesised `` for one shape. The caller is responsible for + * splicing rIds and copying media into the output zip — we return the media + * payload alongside the XML and use marker rIds (`rIdSW__`) + * that the orchestrator rewrites. + */ +export function synthesiseShape(el: ShapeElement): SynthShapeResult { + const id = freshNvId(); + const name = slidewiseShapeName(el.id); + const xfrm = xfrmXml(el.x, el.y, el.w, el.h, el.rotation); + const geom = geometryXml(el); + const fill = shapeFillXml(el); + const stroke = lineXml(el); + const effects = effectLstXml(el.shadow, el.glow); + const sp = + `` + + `` + + `${xfrm}${geom}${fill.xml}${stroke}${effects}` + + `` + + ``; + return { xml: sp, media: fill.media }; +} + +function geometryXml(el: ShapeElement): string { + if (el.path) { + const pathLst = svgPathToOoxml( + el.path.d, + el.path.viewW, + el.path.viewH, + el.path.fillRule + ); + if (pathLst) { + return `${pathLst}`; + } + // Fall through to prstGeom rect if path is unparseable. + } + const preset = ( + { + rect: "rect", + rounded: "roundRect", + circle: "ellipse", + triangle: "triangle", + diamond: "diamond", + star: "star5", + } as const + )[el.shape]; + return ``; +} + +/** Build `` / `` / `` and accumulate + * media payloads when the fill references a data URL. */ +function shapeFillXml(el: ShapeElement): { xml: string; media: MediaPayload[] } { + const parsed = parseFill(el.fill); + if (!parsed) return { xml: ``, media: [] }; + if (parsed.kind === "transparent") return { xml: ``, media: [] }; + if (parsed.kind === "solid") { + return { xml: solidFillXml(parsed.color, parsed.alpha), media: [] }; + } + if (parsed.kind === "linear") { + return { xml: linearGradFillXml(parsed.angle, parsed.stops), media: [] }; + } + if (parsed.kind === "radial") { + return { + xml: radialGradFillXml(parsed.focusX, parsed.focusY, parsed.shape, parsed.stops), + media: [], + }; + } + // image + const media = mediaFromUrl(parsed.src, `img_${el.id}`); + if (!media) { + return { xml: ``, media: [] }; + } + // Marker rId — replaced at injection time. + const ridMarker = ridMarkerFor(el.id, 0); + return { + xml: ``, + media: [media], + }; +} + +export function solidFillXml(color: string, alpha?: number): string { + const inner = alpha != null && alpha < 1 + ? `` + : ``; + return `${inner}`; +} + +function linearGradFillXml(cssAng: number, stops: GradStop[]): string { + return ( + `` + + gsLstXml(stops) + + `` + + `` + ); +} + +function radialGradFillXml( + focusX: number, + focusY: number, + shape: "circle" | "ellipse", + stops: GradStop[] +): string { + // fillToRect insets are in thousandths of a percent (matching `pos` units). + // Mirror the importer's mapping: focus(X,Y) sits at the centre of the + // l/r/t/b rectangle. We use a zero-area rect at the focus point — that's + // the convention PowerPoint emits for "radial centred at point". + const l = clampInset(focusX); + const t = clampInset(focusY); + const r = clampInset(100 - focusX); + const b = clampInset(100 - focusY); + const path = shape === "circle" ? "circle" : "shape"; + return ( + `` + + gsLstXml(stops) + + `` + + `` + ); +} + +function clampInset(v: number): number { + return Math.round(Math.max(0, Math.min(100, v)) * 1000); +} + +function gsLstXml(stops: GradStop[]): string { + return ( + `` + + stops + .map((s) => { + const alpha = + s.alpha != null && s.alpha < 1 + ? `` + : ``; + return `${alpha}`; + }) + .join("") + + `` + ); +} + +function lineXml(el: ShapeElement): string { + if (!el.stroke && !el.strokeWidth) return ``; + const color = el.stroke ? hexBare(el.stroke) : `000000`; + const widthEmu = Math.max(1, Math.round((el.strokeWidth ?? 1) * EMU_PER_POINT)); + const dash = el.dashType ? `` : ``; + return `${dash}`; +} + +export function effectLstXml( + shadow?: ShadowSpec, + glow?: GlowSpec +): string { + if (!shadow && !glow) return ``; + const inner: string[] = []; + if (shadow) { + // OOXML `` distance/direction: distance is the radial offset + // length in EMU; direction is the angle clockwise from "right" in + // 60000ths of a degree. CSS shadow gives an (offsetX, offsetY) vector. + const dist = Math.round( + Math.hypot(shadow.offsetX, shadow.offsetY) * 9525 // ~px → EMU at 96dpi + ); + const ang = + Math.round( + ((Math.atan2(shadow.offsetY, shadow.offsetX) * 180) / Math.PI + 360) % + 360 * 60000 + ); + const blur = Math.max(0, Math.round(shadow.blur * 9525)); + inner.push( + `` + + `` + + `` + ); + } + if (glow) { + const rad = Math.max(0, Math.round(glow.radius * 9525)); + inner.push( + `` + ); + } + return `${inner.join("")}`; +} + +// -- Frame / xfrm helpers --------------------------------------------------- + +export function xfrmXml( + x: number, + y: number, + w: number, + h: number, + rotation: number +): string { + // PowerPoint rotation is in 60000ths of a degree, clockwise. + const rot = rotation ? ` rot="${Math.round(rotation * 60000)}"` : ``; + return ( + `` + + `` + + `` + + `` + ); +} + +// -- Group shape ------------------------------------------------------------ + +/** + * Emit a `` wrapping the synthesised XML of each child. Group XML + * needs a child `` and a `` with its own xfrm that + * declares both the group's external frame (`off/ext`) and the *child* + * coordinate frame (`chOff/chExt`). We set chOff/chExt equal to off/ext so + * children's absolute slide coordinates work as-is. + */ +export function synthesiseGroup( + el: GroupElement, + renderChild: (child: SlideElement) => { xml: string; media: MediaPayload[] } | null +): { xml: string; media: MediaPayload[] } { + const id = freshNvId(); + const childMedia: MediaPayload[] = []; + const childXml: string[] = []; + for (const child of el.children) { + const out = renderChild(child); + if (!out) continue; + childXml.push(out.xml); + for (const m of out.media) childMedia.push(m); + } + const xfrm = + `` + + `` + + `` + + `` + + `` + + ``; + const xml = + `` + + `` + + `${xfrm}` + + childXml.join("") + + ``; + return { xml, media: childMedia }; +} + +// -- Slide background synthesis (PR 3) -------------------------------------- + +export function synthesiseSlideBg( + slide: Slide +): { xml: string | null; media: MediaPayload[] } { + const parsed = parseFill(slide.background); + if (!parsed) return { xml: null, media: [] }; + if (parsed.kind === "transparent") { + return { xml: ``, media: [] }; + } + if (parsed.kind === "solid") { + // Solid is already handled by pptxgenjs — return null to leave its output + // in place. + return { xml: null, media: [] }; + } + if (parsed.kind === "linear") { + return { + xml: `${linearGradFillXml(parsed.angle, parsed.stops)}`, + media: [], + }; + } + if (parsed.kind === "radial") { + return { + xml: `${radialGradFillXml(parsed.focusX, parsed.focusY, parsed.shape, parsed.stops)}`, + media: [], + }; + } + // image + const media = mediaFromUrl(parsed.src, `bg_${slide.id}`); + if (!media) return { xml: null, media: [] }; + const ridMarker = ridMarkerFor(`bg_${slide.id}`, 0); + return { + xml: ``, + media: [media], + }; +} + +// -- Chart synthesis (PR 4, partial) --------------------------------------- + +export interface SynthChartResult { + /** OOXML for the `` to drop into the slide spTree. */ + graphicFrameXml: string; + /** Chart part XML body (full document). */ + chartXml: string; + /** Chart part rels XML (empty when no embedded workbook). */ + chartRelsXml: string; + /** Suggested chart part path, e.g. `ppt/charts/chartSW_.xml`. */ + partPath: string; + /** Suggested chart part rels path. */ + partRelsPath: string; +} + +/** + * Emit a minimal in-app chart. No embedded xlsx workbook — PowerPoint + * renders from the cached values declared in `` / ``. + * Right-click → Edit Data won't work in PowerPoint until we generate a real + * xlsx; that's the followup. The chart renders correctly on open. + */ +export function synthesiseChart(el: ChartElement): SynthChartResult { + const id = freshNvId(); + const partPath = `ppt/charts/chartSW_${sanitiseId(el.id)}.xml`; + const partRelsPath = `ppt/charts/_rels/chartSW_${sanitiseId(el.id)}.xml.rels`; + const ridMarker = ridMarkerFor(el.id, 0); + + const catCount = el.categories.length; + const grouping = el.grouping ?? "standard"; + const seriesXml = el.series.map((s, idx) => seriesXmlFor(el, s, idx)).join(""); + + let plotXml = ""; + if (el.kind === "bar") { + plotXml = `${seriesXml}${catAxisRef()}${valAxisRef()}${axesXml(true)}`; + } else if (el.kind === "column") { + plotXml = `${seriesXml}${catAxisRef()}${valAxisRef()}${axesXml(false)}`; + } else if (el.kind === "line") { + plotXml = `${seriesXml}${catAxisRef()}${valAxisRef()}${axesXml(false)}`; + } else if (el.kind === "area") { + plotXml = `${seriesXml}${catAxisRef()}${valAxisRef()}${axesXml(false)}`; + } else if (el.kind === "pie") { + plotXml = `${seriesXml}`; + } else if (el.kind === "doughnut") { + plotXml = `${seriesXml}`; + } + + const title = el.title + ? `${escapeText( + el.title + )}` + : ``; + + const chartXml = + `` + + `` + + `${title}${plotXml}` + + ``; + + const chartRelsXml = + `` + + ``; + + const graphicFrameXml = + `` + + `` + + `` + + `` + + ``; + + return { graphicFrameXml, chartXml, chartRelsXml, partPath, partRelsPath }; + + function seriesXmlFor( + chart: ChartElement, + s: { name: string; values: (number | null)[]; color?: string }, + idx: number + ): string { + const ptValsXml = s.values + .map((v, i) => + v == null ? `` : `${v}` + ) + .join(""); + const ptCatsXml = chart.categories + .map( + (c, i) => + `${escapeText(String(c))}` + ) + .join(""); + const colorXml = s.color + ? `` + : ``; + return ( + `` + + `` + + `${escapeText(s.name || `Series ${idx + 1}`)}` + + colorXml + + `cat${ptCatsXml}` + + `val${idx}General${ptValsXml}` + + `` + ); + } + function catAxisRef(): string { + return ``; + } + function valAxisRef(): string { + return ``; + } + function axesXml(barIsHoriz: boolean): string { + const catAxOrient = barIsHoriz ? "minMax" : "minMax"; + return ( + `` + + `` + ); + } +} + +// -- Embedded fonts (PR 6) -------------------------------------------------- + +export interface EmbeddedFontDescriptor { + family: string; + /** `` typeface XML — slotted into ``. */ + embeddedFontXml: string; + /** Bytes to write into ppt/fonts/. */ + payloads: { fullPath: string; data: Uint8Array }[]; + /** `` entries to add to presentation.xml.rels for each + * payload, with marker rIds that the orchestrator rewrites. */ + rels: { ridMarker: string; relType: string; target: string }[]; +} + +export async function synthesiseEmbeddedFonts( + fonts: FontAsset[] +): Promise { + const out: EmbeddedFontDescriptor[] = []; + for (let i = 0; i < fonts.length; i++) { + const font = fonts[i]; + const bytes = await fetchFontBytes(font.data); + if (!bytes) continue; + const fullPath = `ppt/fonts/slidewiseFont${i + 1}.fntdata`; + const ridMarker = ridMarkerFor(`font${i}`, 0); + const variant = font.italic + ? font.weight && font.weight >= 600 + ? `boldItalic` + : `italic` + : font.weight && font.weight >= 600 + ? `bold` + : `regular`; + const embeddedFontXml = + `` + + `` + + `` + + ``; + out.push({ + family: font.family, + embeddedFontXml, + payloads: [{ fullPath, data: bytes }], + rels: [ + { + ridMarker, + relType: + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/font", + target: `fonts/slidewiseFont${i + 1}.fntdata`, + }, + ], + }); + } + return out; +} + +async function fetchFontBytes(src: string): Promise { + if (src.startsWith("data:")) { + const comma = src.indexOf(","); + if (comma < 0) return null; + const header = src.slice(0, comma); + const body = src.slice(comma + 1); + if (header.includes(";base64")) { + return decodeBase64(body); + } + return new TextEncoder().encode(decodeURIComponent(body)); + } + if (/^https?:/i.test(src)) { + try { + const res = await fetch(src); + const buf = await res.arrayBuffer(); + return new Uint8Array(buf); + } catch { + return null; + } + } + return null; +} + +// -- Misc helpers ---------------------------------------------------------- + +/** + * Produce a "marker" rId that won't conflict with real rIds — the orchestrator + * scans for these markers and rewrites them to fresh rIds in the appropriate + * rels namespace. + */ +export function ridMarkerFor(scope: string, n: number): string { + return `rIdSW_${sanitiseId(scope)}_${n}`; +} + +/** + * Allocate media path + zip-write descriptor for a `url(...)` reference. + * Returns null when the URL isn't a data URL we can decode synchronously. + * Remote http(s) URLs are NOT inlined here (synchronous-only) — those keep + * their `url(...)` and won't round-trip; that's the limit for v1. + */ +export function mediaFromUrl(src: string, scope: string): MediaPayload | null { + if (!src.startsWith("data:")) return null; + const comma = src.indexOf(","); + if (comma < 0) return null; + const header = src.slice(0, comma); + const body = src.slice(comma + 1); + const mimeMatch = /^data:([^;,]+)/.exec(header); + const mime = mimeMatch ? mimeMatch[1] : "image/png"; + const ext = mime.split("/")[1]?.split("+")[0] ?? "png"; + const bytes = header.includes(";base64") + ? decodeBase64(body) + : new TextEncoder().encode(decodeURIComponent(body)); + const fullPath = `ppt/media/imageSlidewise_${sanitiseId(scope)}.${ext}`; + // Slide rels live in ppt/slides/_rels — targets are relative to ppt/slides. + const relTarget = `../media/imageSlidewise_${sanitiseId(scope)}.${ext}`; + return { + fullPath, + relTarget, + data: bytes, + relType: + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image", + }; +} + +function decodeBase64(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; + } + // Node fallback. Buffer is global in Node; cast through unknown so this + // module type-checks in browser-only configs without dom-buffer typings. + 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 escapeAttr(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">"); +} + +function sanitiseId(s: string): string { + return s.replace(/[^a-zA-Z0-9]+/g, "_"); +} + +// Re-export a marker matcher so the orchestrator can swap markers without +// duplicating the regex. +export const RID_MARKER_RE = /rIdSW_[A-Za-z0-9_]+/g; + +// Tests reach into these — keep them on the module namespace. +export const __internals = { + parseStops, + splitTopLevelCommas, + cssAngleToOoxml, + tokenisePath, +}; + +// Re-exports used in deckToPptx — explicit so TS sees them. +export type { Deck }; diff --git a/packages/slidewise/src/lib/types.ts b/packages/slidewise/src/lib/types.ts index 2b64ac5..8f6ad28 100644 --- a/packages/slidewise/src/lib/types.ts +++ b/packages/slidewise/src/lib/types.ts @@ -10,8 +10,44 @@ export type ElementType = | "icon" | "embed" | "chart" + | "group" | "unknown"; +/** + * Drop shadow descriptor — emitted as CSS `box-shadow`/`text-shadow` in the + * renderer and `` inside `` on save. Offsets and + * blur are in canvas pixels. + */ +export interface ShadowSpec { + color: string; + blur: number; + offsetX: number; + offsetY: number; +} + +/** + * Outer-glow descriptor — emitted as CSS `filter: drop-shadow(…)` / + * `text-shadow` (since CSS has no native glow primitive) and `` + * inside `` on save. + */ +export interface GlowSpec { + color: string; + radius: number; +} + +/** + * Dash pattern for stroked lines / shape outlines. Mirrors OOXML's + * `` value list — these are the patterns PowerPoint + * recognises and renders without falling back to a custom dash. + */ +export type DashType = + | "solid" + | "dash" + | "dot" + | "dashDot" + | "lgDash" + | "sysDash"; + export type EnterAnim = | "none" | "fade" @@ -55,6 +91,10 @@ export interface TextRun { export interface TextElement extends BaseElement { type: "text"; + /** Optional text shadow — CSS `text-shadow` / OOXML ``. */ + shadow?: ShadowSpec; + /** Optional outer glow — CSS `text-shadow` / OOXML ``. */ + glow?: GlowSpec; text: string; fontFamily: string; fontSize: number; @@ -115,10 +155,22 @@ export type ShapeKind = export interface ShapeElement extends BaseElement { type: "shape"; shape: ShapeKind; + /** + * Solid hex (`#RRGGBB`), `transparent`, a CSS `linear-gradient(...)` / + * `radial-gradient(...)` string (set by the PPTX importer from + * `` — see `pptxToDeck.ts`), or a `url(data:image/...)` / + * `url(https://...)` string. The writer detects each form by parsing. + */ fill: string; stroke?: string; strokeWidth?: number; + /** Optional dash pattern for the stroke. */ + dashType?: DashType; radius?: number; + /** Optional drop shadow — CSS `box-shadow` / OOXML ``. */ + shadow?: ShadowSpec; + /** Optional outer glow — CSS `filter: drop-shadow` / OOXML ``. */ + glow?: GlowSpec; /** * Optional vector path, set when the shape was imported from a PPTX * `` (logos, brand marks, hand-drawn shapes). The renderer @@ -159,7 +211,14 @@ export interface LineElement extends BaseElement { stroke: string; strokeWidth: number; arrow?: boolean; + /** Legacy convenience flag — equivalent to `dashType: "dash"`. */ dashed?: boolean; + /** Detailed dash pattern. When set, takes precedence over `dashed`. */ + dashType?: DashType; + /** Optional drop shadow. */ + shadow?: ShadowSpec; + /** Optional outer glow. */ + glow?: GlowSpec; } export interface TableElement extends BaseElement { @@ -275,6 +334,21 @@ export interface ChartElement extends BaseElement { ooxmlXml?: string; } +/** + * A grouped collection of slide elements. Position/size describe the group's + * own bounding box; children carry coordinates relative to the slide (NOT to + * the group). The renderer treats the group as a single z-stacked unit and + * the PPTX writer emits a `` containing the children's OOXML. + * + * NOTE: child drag-resize behaviour is unchanged in this release — selecting + * a child still moves the child individually. Group-level drag will follow + * in a later PR. + */ +export interface GroupElement extends BaseElement { + type: "group"; + children: SlideElement[]; +} + export type SlideElement = | TextElement | ShapeElement @@ -284,14 +358,36 @@ export type SlideElement = | IconElement | EmbedElement | ChartElement + | GroupElement | UnknownElement; export interface Slide { id: string; + /** + * Solid hex (`#RRGGBB`), CSS `linear-gradient(...)` / `radial-gradient(...)` + * string, or `url(data:image/...)` / `url(https://...)` string. The writer + * picks the right `` OOXML form by parsing. When a source PPTX is + * attached, the source's `` replay wins over whatever's in this field. + */ background: string; elements: SlideElement[]; } +/** + * A font the deck wants embedded into the saved PPTX so PowerPoint installs + * it on open. Bytes can be a data URL (`data:font/ttf;base64,...` or any + * `application/x-font-*` mime) or an http(s) URL the writer can fetch. + */ +export interface FontAsset { + /** Matches `TextElement.fontFamily` / run `fontFamily`. */ + family: string; + /** Data URL or http(s) URL. The writer copies the bytes into `ppt/fonts/`. */ + data: string; + /** Defaults to 400 (regular). */ + weight?: number; + italic?: boolean; +} + export interface Deck { /** * Schema version this deck conforms to. Stamped by `migrate()` and by @@ -313,6 +409,13 @@ export interface Deck { * needs the host to re-attach source bytes via `serializeDeck({ source })`. */ sourcePptxId?: string; + /** + * Optional list of fonts to embed into the saved PPTX. Honoured when no + * source PPTX is attached — when a source IS attached, chrome preservation + * carries the source's embedded fonts through verbatim and this field is + * ignored to avoid duplicate entries. + */ + fonts?: FontAsset[]; } export type ElementDraft = T extends SlideElement From f59ef3e8cc4168386ce6a0906dfe3b1312a4232f Mon Sep 17 00:00:00 2001 From: karthikmudunuri <102793643+karthikmudunuri@users.noreply.github.com> Date: Mon, 25 May 2026 16:33:25 +0530 Subject: [PATCH 2/7] fix(pptx): reorder presentation.xml top-level elements to match OOXML schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pptxgenjs emits sldMasterIdLst → sldIdLst → notesMasterIdLst, but CT_Presentation requires notesMasterIdLst BEFORE sldIdLst. PowerPoint enforces the order and surfaces the "found a problem with content" repair dialog even when the underlying content is valid. Keynote is lenient and renders without complaint. New sanitisePresentationXml() relocates notesMasterIdLst and handoutMasterIdLst to their schema slots, and moves embeddedFontLst (from PR 6) to its correct position after notesSz. Runs on every export path alongside pruneDanglingContentTypes. Verified end-to-end on the 25-slide round-trip test deck — element order now matches what PowerPoint's repair pass produces. --- packages/slidewise/src/lib/pptx/deckToPptx.ts | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/packages/slidewise/src/lib/pptx/deckToPptx.ts b/packages/slidewise/src/lib/pptx/deckToPptx.ts index 60df473..ebb0756 100644 --- a/packages/slidewise/src/lib/pptx/deckToPptx.ts +++ b/packages/slidewise/src/lib/pptx/deckToPptx.ts @@ -562,6 +562,7 @@ async function preserveUnknowns( // (Keynote is lenient and just warns). Always sanitise. const outZip = await JSZip.loadAsync(generated); await pruneDanglingContentTypes(outZip); + await sanitisePresentationXml(outZip); return outZip.generateAsync({ type: "blob", mimeType: PPTX_MIME }); } if (!sourceBuffer && hasSynth) { @@ -572,6 +573,7 @@ async function preserveUnknowns( await applySynthSlideBackgrounds(outZip, deck); await applyEmbeddedFontsFromJson(outZip, deck); await pruneDanglingContentTypes(outZip); + await sanitisePresentationXml(outZip); return outZip.generateAsync({ type: "blob", mimeType: PPTX_MIME }); } @@ -654,6 +656,7 @@ async function preserveUnknowns( // copy any — avoids duplicating font entries when both source + deck.fonts // are set. await applyEmbeddedFontsFromJson(outZip, deck); + await sanitisePresentationXml(outZip); // JSZip's blob output preserves the OOXML mime type set by pptxgenjs. return outZip.generateAsync({ type: "blob", mimeType: PPTX_MIME }); @@ -2103,3 +2106,77 @@ async function pruneDanglingContentTypes(outZip: JSZip): Promise { ); if (pruned !== xml) outZip.file("[Content_Types].xml", pruned); } + +/** + * Reorder top-level children of `` to match the OOXML + * schema's required sequence. pptxgenjs emits + * sldMasterIdLst → sldIdLst → notesMasterIdLst → sldSz → notesSz + * but CT_Presentation mandates + * sldMasterIdLst → notesMasterIdLst → handoutMasterIdLst → sldIdLst + * → sldSz → notesSz → … → embeddedFontLst → … + * Out-of-sequence `notesMasterIdLst` (and `embeddedFontLst` if PR 6 ran) + * triggers PowerPoint's "found a problem with content" repair dialog + * even though the underlying content is valid. Keynote is lenient and + * just renders. + * + * We don't rebuild the XML; we just relocate the affected elements, + * preserving their inner text verbatim. Safe to call when the elements + * are already in order — the function is a no-op. + */ +async function sanitisePresentationXml(outZip: JSZip): Promise { + const file = outZip.file("ppt/presentation.xml"); + if (!file) return; + let xml = await file.async("string"); + const original = xml; + + // The schema-correct slot for notesMasterIdLst is immediately after + // sldMasterIdLst and before sldIdLst. + const extractBlock = (tag: string): string | null => { + const re = new RegExp(`]*>[\\s\\S]*?`); + const m = re.exec(xml); + if (!m) return null; + xml = xml.slice(0, m.index) + xml.slice(m.index + m[0].length); + return m[0]; + }; + + const insertAfter = (anchorTag: string, block: string): void => { + const closeAnchor = ``; + const idx = xml.indexOf(closeAnchor); + if (idx < 0) return; + const at = idx + closeAnchor.length; + xml = xml.slice(0, at) + block + xml.slice(at); + }; + + const notesBlock = extractBlock("notesMasterIdLst"); + if (notesBlock) insertAfter("sldMasterIdLst", notesBlock); + + const handoutBlock = extractBlock("handoutMasterIdLst"); + if (handoutBlock) { + // handoutMasterIdLst sits between notesMasterIdLst (if present) and + // sldIdLst. If notes was just inserted, anchor on it; otherwise on + // sldMasterIdLst. + const anchor = /\<\/p:notesMasterIdLst\>/.test(xml) + ? "notesMasterIdLst" + : "sldMasterIdLst"; + insertAfter(anchor, handoutBlock); + } + + // embeddedFontLst belongs AFTER smartTags / before custShowLst — in + // practice, immediately after notesSz works for every deck we emit. + const fontLstBlock = extractBlock("embeddedFontLst"); + if (fontLstBlock) { + // notesSz is self-closing in pptxgenjs output, so we look for the + // self-closing tag end. + const m = /]*\/>/.exec(xml); + if (m) { + const at = m.index + m[0].length; + xml = xml.slice(0, at) + fontLstBlock + xml.slice(at); + } else { + // Fallback: put it back where we found it. Shouldn't happen — every + // pptxgenjs deck has notesSz. + insertAfter("sldIdLst", fontLstBlock); + } + } + + if (xml !== original) outZip.file("ppt/presentation.xml", xml); +} From bee2bd0a7bdad72f21621560b2c2d8b4e5ceedee Mon Sep 17 00:00:00 2001 From: karthikmudunuri <102793643+karthikmudunuri@users.noreply.github.com> Date: Mon, 25 May 2026 16:37:37 +0530 Subject: [PATCH 3/7] fix(pptx): collapse duplicate blocks pptxgenjs emits inside CT_TextParagraph allows only one per , but pptxgenjs sometimes emits two adjacent blocks (typically when a multi-run text item with breakLine lands at a paragraph boundary). PowerPoint flags the file as corrupt and offers to repair; Keynote silently merges. New sanitiseSlideXml() walks every ppt/slides/slideN.xml and drops the second of any consecutive pair, repeating until no more pairs remain so 3+ adjacent blocks collapse to one. Runs on every export path alongside sanitisePresentationXml and pruneDanglingContentTypes. Verified on the 25-slide round-trip deck: 0 consecutive remaining after the pass; PowerPoint's repair dialog no longer fires. --- packages/slidewise/src/lib/pptx/deckToPptx.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/packages/slidewise/src/lib/pptx/deckToPptx.ts b/packages/slidewise/src/lib/pptx/deckToPptx.ts index ebb0756..9d8bbd4 100644 --- a/packages/slidewise/src/lib/pptx/deckToPptx.ts +++ b/packages/slidewise/src/lib/pptx/deckToPptx.ts @@ -563,6 +563,7 @@ async function preserveUnknowns( const outZip = await JSZip.loadAsync(generated); await pruneDanglingContentTypes(outZip); await sanitisePresentationXml(outZip); + await sanitiseSlideXml(outZip); return outZip.generateAsync({ type: "blob", mimeType: PPTX_MIME }); } if (!sourceBuffer && hasSynth) { @@ -574,6 +575,7 @@ async function preserveUnknowns( await applyEmbeddedFontsFromJson(outZip, deck); await pruneDanglingContentTypes(outZip); await sanitisePresentationXml(outZip); + await sanitiseSlideXml(outZip); return outZip.generateAsync({ type: "blob", mimeType: PPTX_MIME }); } @@ -657,6 +659,7 @@ async function preserveUnknowns( // are set. await applyEmbeddedFontsFromJson(outZip, deck); await sanitisePresentationXml(outZip); + await sanitiseSlideXml(outZip); // JSZip's blob output preserves the OOXML mime type set by pptxgenjs. return outZip.generateAsync({ type: "blob", mimeType: PPTX_MIME }); @@ -2180,3 +2183,39 @@ async function sanitisePresentationXml(outZip: JSZip): Promise { if (xml !== original) outZip.file("ppt/presentation.xml", xml); } + +/** + * Walk every `ppt/slides/slideN.xml` and collapse consecutive `` + * elements inside the same `` to a single one. pptxgenjs sometimes + * emits two adjacent `` blocks (typically when a multi-run text + * item with `breakLine` lands at a paragraph boundary) — CT_TextParagraph + * permits only one `pPr` per ``, so PowerPoint flags the file as + * corrupt and offers to repair. Keynote is lenient and silently merges. + * + * The collapsed form keeps the FIRST occurrence (matches what PowerPoint + * resolves to in practice). When the two are identical it's a no-op + * semantically; when they differ, the first wins. + */ +async function sanitiseSlideXml(outZip: JSZip): Promise { + const slidePaths: string[] = []; + outZip.forEach((path) => { + if (/^ppt\/slides\/slide\d+\.xml$/.test(path)) slidePaths.push(path); + }); + for (const p of slidePaths) { + const file = outZip.file(p); + if (!file) continue; + const xml = await file.async("string"); + // Match a `` (with or without children) followed immediately + // by another `` and drop the second. Repeat until no more + // pairs remain so 3+ consecutive blocks collapse to one. + const pprPair = + /(]*(?:\/>|>[\s\S]*?<\/a:pPr>))(]*(?:\/>|>[\s\S]*?<\/a:pPr>))/g; + let updated = xml; + let prev; + do { + prev = updated; + updated = updated.replace(pprPair, "$1"); + } while (updated !== prev); + if (updated !== xml) outZip.file(p, updated); + } +} From 07539edb319bbe61d909dcf4c863f2b65a21fac2 Mon Sep 17 00:00:00 2001 From: karthikmudunuri <102793643+karthikmudunuri@users.noreply.github.com> Date: Mon, 25 May 2026 16:52:50 +0530 Subject: [PATCH 4/7] fix(pptx): prune empty zip directories + collapse whitespace-only empty elements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more pptxgenjs quirks PowerPoint enforces strictly: 1. pptxgenjs creates `ppt/charts/`, `ppt/charts/_rels/`, `ppt/embeddings/` as zip directory entries even when no chart / embedding ever lands. The empty `ppt/charts/_rels/` paired with no chart XML is one of the patterns PowerPoint's package validator flags. New `pruneEmptyDirectories` walks the zip and removes directory entries with no actual files under them. 2. pptxgenjs emits some nvSpPr children with whitespace text content (` `) — schema is element-only, no mixed content. `sanitiseSlideXml` now collapses these to self-closing for the allow-listed element-only types (cNvPr, nvPr, cNvSpPr, etc.). Both run on every export path. --- packages/slidewise/src/lib/pptx/deckToPptx.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/slidewise/src/lib/pptx/deckToPptx.ts b/packages/slidewise/src/lib/pptx/deckToPptx.ts index 9d8bbd4..d09bcd9 100644 --- a/packages/slidewise/src/lib/pptx/deckToPptx.ts +++ b/packages/slidewise/src/lib/pptx/deckToPptx.ts @@ -564,6 +564,7 @@ async function preserveUnknowns( await pruneDanglingContentTypes(outZip); await sanitisePresentationXml(outZip); await sanitiseSlideXml(outZip); + pruneEmptyDirectories(outZip); return outZip.generateAsync({ type: "blob", mimeType: PPTX_MIME }); } if (!sourceBuffer && hasSynth) { @@ -576,6 +577,7 @@ async function preserveUnknowns( await pruneDanglingContentTypes(outZip); await sanitisePresentationXml(outZip); await sanitiseSlideXml(outZip); + pruneEmptyDirectories(outZip); return outZip.generateAsync({ type: "blob", mimeType: PPTX_MIME }); } @@ -2184,6 +2186,29 @@ async function sanitisePresentationXml(outZip: JSZip): Promise { if (xml !== original) outZip.file("ppt/presentation.xml", xml); } +/** + * Drop empty directory entries from the zip. pptxgenjs adds + * `ppt/charts/`, `ppt/charts/_rels/`, `ppt/embeddings/` (and similar) + * as zip directory entries even when no chart / embedding ever lands. + * PowerPoint validates the package and the empty `ppt/charts/_rels/` + * with no `ppt/charts/*.xml` is one of the patterns it flags. + * + * Synchronous — JSZip's `forEach` + `remove` are both sync. + */ +function pruneEmptyDirectories(outZip: JSZip): void { + const filePaths: string[] = []; + const dirPaths: string[] = []; + outZip.forEach((path, entry) => { + if (entry.dir) dirPaths.push(path); + else filePaths.push(path); + }); + for (const dir of dirPaths) { + const prefix = dir.endsWith("/") ? dir : dir + "/"; + const hasContent = filePaths.some((f) => f.startsWith(prefix)); + if (!hasContent) outZip.remove(dir); + } +} + /** * Walk every `ppt/slides/slideN.xml` and collapse consecutive `` * elements inside the same `` to a single one. pptxgenjs sometimes @@ -2216,6 +2241,15 @@ async function sanitiseSlideXml(outZip: JSZip): Promise { prev = updated; updated = updated.replace(pprPair, "$1"); } while (updated !== prev); + // pptxgenjs writes some element-only nodes with whitespace text + // between open/close (` `) which PowerPoint + // flags. Collapse to self-closing for elements that only have + // whitespace content. Restricted to nvSpPr/cNvPr-style empties so we + // don't accidentally normalise text frames. + updated = updated.replace( + /<(p:cNvPr|p:nvPr|p:cNvSpPr|p:cNvGrpSpPr|p:cNvPicPr|a:ln|a:avLst|a:lstStyle|a:bodyPr|a:gdLst|a:ahLst|a:cxnLst)\b([^>]*)>\s+<\/\1>/g, + "<$1$2/>" + ); if (updated !== xml) outZip.file(p, updated); } } From 63e3cbbfee5a1e0c266d4d87373703eff231c5d8 Mon Sep 17 00:00:00 2001 From: karthikmudunuri <102793643+karthikmudunuri@users.noreply.github.com> Date: Mon, 25 May 2026 17:00:14 +0530 Subject: [PATCH 5/7] fix(pptx): strip insignificant whitespace from .rels files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pptxgenjs writes notesMaster + notesSlide rels with indentation including whitespace BETWEEN the XML declaration and the root, and between children. PowerPoint's strict package validator rejects this with the "found a problem with content" dialog even though plain XML 1.0 allows whitespace in the prolog. Keynote is lenient. New sanitiseRels() walks every .rels file and collapses whitespace between the prolog and root, between children, and around the root close tag. The Relationships content model is element-only — no text is semantic. Runs on every export path alongside the other sanitisers. Verified on the 25-slide round-trip deck: all rels files now match the compact single-line format PowerPoint's repair produces. --- packages/slidewise/src/lib/pptx/deckToPptx.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/packages/slidewise/src/lib/pptx/deckToPptx.ts b/packages/slidewise/src/lib/pptx/deckToPptx.ts index d09bcd9..ad58b65 100644 --- a/packages/slidewise/src/lib/pptx/deckToPptx.ts +++ b/packages/slidewise/src/lib/pptx/deckToPptx.ts @@ -564,6 +564,7 @@ async function preserveUnknowns( await pruneDanglingContentTypes(outZip); await sanitisePresentationXml(outZip); await sanitiseSlideXml(outZip); + await sanitiseRels(outZip); pruneEmptyDirectories(outZip); return outZip.generateAsync({ type: "blob", mimeType: PPTX_MIME }); } @@ -577,6 +578,7 @@ async function preserveUnknowns( await pruneDanglingContentTypes(outZip); await sanitisePresentationXml(outZip); await sanitiseSlideXml(outZip); + await sanitiseRels(outZip); pruneEmptyDirectories(outZip); return outZip.generateAsync({ type: "blob", mimeType: PPTX_MIME }); } @@ -662,6 +664,8 @@ async function preserveUnknowns( await applyEmbeddedFontsFromJson(outZip, deck); await sanitisePresentationXml(outZip); await sanitiseSlideXml(outZip); + await sanitiseRels(outZip); + pruneEmptyDirectories(outZip); // JSZip's blob output preserves the OOXML mime type set by pptxgenjs. return outZip.generateAsync({ type: "blob", mimeType: PPTX_MIME }); @@ -2186,6 +2190,43 @@ async function sanitisePresentationXml(outZip: JSZip): Promise { if (xml !== original) outZip.file("ppt/presentation.xml", xml); } +/** + * Strip insignificant whitespace from every `.rels` file in the zip. + * pptxgenjs writes some rels files (notesMaster1, notesSlideN) with + * pretty-printed indentation including whitespace BETWEEN the XML + * declaration and the `` root. PowerPoint's strict + * package validator rejects this even though plain XML 1.0 allows + * whitespace in the prolog. Keynote is lenient. + * + * Conservative: only collapses whitespace OUTSIDE the + * Relationships element and between its children — both are + * element-only content models with no semantic whitespace. + */ +async function sanitiseRels(outZip: JSZip): Promise { + const relsPaths: string[] = []; + outZip.forEach((path) => { + if (path.endsWith(".rels")) relsPaths.push(path); + }); + for (const p of relsPaths) { + const file = outZip.file(p); + if (!file) continue; + const xml = await file.async("string"); + let updated = xml; + // Drop whitespace between `?>` and `)\s+(` children. + updated = updated.replace( + /(]*\/>)\s+(<(?:Relationship\b|\/Relationships>))/g, + "$1$2" + ); + // Drop whitespace immediately before `` close. + updated = updated.replace(/\s+<\/Relationships>/, ""); + // Drop whitespace inside the `` open tag's content area. + updated = updated.replace(/(]*>)\s+( Date: Tue, 26 May 2026 22:47:19 +0530 Subject: [PATCH 6/7] feat(pptx): extract embedded fonts into Deck.fonts on import Closes the round-trip gap: PR 6 added the writer (applyEmbeddedFontsFromJson) but the importer never populated Deck.fonts, so embedded brand fonts survived only when the original .pptx bytes rode along via source preservation. JSON-only loads dropped them and PowerPoint fell back to Calibri. readEmbeddedFonts() walks in presentation.xml, pulls the four optional style rels (regular/bold/italic/boldItalic) per , base64s each ppt/fonts/*.fntdata payload, and stamps deck.fonts with one FontAsset per style. The serializer's existing applyEmbeddedFontsFromJson() picks them up on export. Verified against eon-deck.pptx: 5 FontAssets extracted (EON Brix Sans x4 weights, EON Office Head x1, ~370KB total). --- packages/slidewise/src/lib/pptx/pptxToDeck.ts | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/packages/slidewise/src/lib/pptx/pptxToDeck.ts b/packages/slidewise/src/lib/pptx/pptxToDeck.ts index 1e42d5e..c25208f 100644 --- a/packages/slidewise/src/lib/pptx/pptxToDeck.ts +++ b/packages/slidewise/src/lib/pptx/pptxToDeck.ts @@ -16,6 +16,7 @@ import type { ChartElement, ChartSeries, UnknownElement, + FontAsset, } from "@/lib/types"; import { SLIDE_W, SLIDE_H } from "@/lib/types"; import { CURRENT_DECK_VERSION } from "@/lib/schema/migrate"; @@ -313,11 +314,13 @@ export async function parsePptx( // a redundant fallback for callers that hold the deck object directly. const sourcePptxId = nanoid(12); sourceBufferCache.set(sourcePptxId, sourceBuffer); + const fonts = await readEmbeddedFonts(zip, presentationXml, presentationRels); const deck: Deck = { version: CURRENT_DECK_VERSION, title, slides, sourcePptxId, + ...(fonts.length ? { fonts } : {}), }; Object.defineProperty(deck, SOURCE_PPTX, { value: sourceBuffer, @@ -3883,6 +3886,78 @@ function relsPathFor(xmlPath: string): string { return xmlPath.replace(/([^/]+)\.xml$/, "_rels/$1.xml.rels"); } +/** + * Walk the source's `` and pull each referenced + * font part out of `ppt/fonts/`, base64-encode the bytes, and return + * a `FontAsset[]` the serializer can write back. This makes the JSON + * deck self-contained: hosts can save the deck to disk, reload from + * JSON-only (no source bytes), and the EON / Inter / brand fonts + * still come through on export. + * + * Each `` can carry up to four style rels — regular / + * bold / italic / boldItalic. We emit one `FontAsset` per style with + * `weight` and `italic` set accordingly. The serializer combines + * same-family assets back into a single `` entry. + * + * Best-effort: when `ppt/fonts/` is absent or a rel points at a + * missing target, that style is skipped silently. Won't throw on + * malformed input — diagnostic only. + */ +async function readEmbeddedFonts( + zip: JSZip, + presentationXml: any, + presentationRels: Rels +): Promise { + const list = presentationXml?.["p:presentation"]?.["p:embeddedFontLst"]; + if (!list) return []; + const fonts = asArray(list["p:embeddedFont"]); + const out: FontAsset[] = []; + const styles: Array<{ key: string; weight: number; italic: boolean }> = [ + { key: "p:regular", weight: 400, italic: false }, + { key: "p:bold", weight: 700, italic: false }, + { key: "p:italic", weight: 400, italic: true }, + { key: "p:boldItalic", weight: 700, italic: true }, + ]; + for (const entry of fonts) { + const family = entry?.["p:font"]?.["@_typeface"]; + if (!family) continue; + for (const style of styles) { + const rid = entry?.[style.key]?.["@_r:id"]; + if (!rid) continue; + const target = presentationRels.byId.get(rid)?.target; + if (!target) continue; + const fullPath = normalisePath(target, "ppt"); + const file = zip.file(fullPath); + if (!file) continue; + const bytes = await file.async("uint8array"); + const base64 = uint8ArrayToBase64(bytes); + out.push({ + family, + data: `data:application/x-fontdata;base64,${base64}`, + weight: style.weight, + italic: style.italic, + }); + } + } + return out; +} + +function uint8ArrayToBase64(bytes: Uint8Array): string { + // Stream-friendly base64 — typed-array → binary-string → btoa works + // for files up to a few MB without blowing the call stack. Fonts are + // typically 50–500 KB so this is comfortable. + let binary = ""; + const chunk = 0x8000; + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode( + ...bytes.subarray(i, Math.min(i + chunk, bytes.length)) + ); + } + return typeof btoa !== "undefined" + ? btoa(binary) + : Buffer.from(binary, "binary").toString("base64"); +} + async function readTitle(zip: JSZip): Promise { const file = zip.file("docProps/core.xml"); if (!file) return "Untitled"; From 4032193d63cc9d85a7c539c6752c9a00e8e0f01b Mon Sep 17 00:00:00 2001 From: karthikmudunuri <102793643+karthikmudunuri@users.noreply.github.com> Date: Tue, 26 May 2026 23:37:47 +0530 Subject: [PATCH 7/7] feat(slidewise): editor web-font registry for brand typeface preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slidewise's PPTX export already preserves embedded brand fonts via Deck.fonts (PR 6 + the importer side just landed). PowerPoint can render those MTX-compressed EOT payloads natively, but browsers can't — so the in-editor canvas falls back to system sans even when the deck declares "EON Brix Sans". Adds a parallel web-font surface for the editor preview: - WebFontAsset type (family, src, weight?, italic?). src is any browser-renderable URL — http(s), same-origin, or data:font/* URL. - Deck.webFonts?: WebFontAsset[] — per-deck list (AI-authored decks ship this alongside Deck.fonts; the exporter consults Deck.fonts, the editor consults Deck.webFonts). - fontRegistry?: WebFontAsset[] prop on Slidewise.Root / SlidewiseEditor — per-host list applied across every deck. Deck.webFonts wins on family-name collisions. - ensureWebFontsLoaded() injects an