From 7303b2f36b92ae59b1ac2a093280b744a4e74a8e Mon Sep 17 00:00:00 2001 From: karthikmudunuri <102793643+karthikmudunuri@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:50:01 +0530 Subject: [PATCH 1/2] feat(pptx): layout-instantiation in applyEdits (lossless scale-with-variety) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Support source:{layoutId,fills} in a PlannedSlide — instantiate a fresh slide from one of the template's OWN layouts inside the lossless byte-patch path. The layout is already a part of source, so the new slide binds to its ppt/slideLayouts/.xml (inheriting theme/master/background chrome) while every other part stays byte-identical. - Each layout placeholder becomes an addressable, positioned element keyed by the deterministic id layoutSlotElementId(layoutId, key) (exported). Text/obj slots fill from fills + edit via setText; picture slots get a transparent placeholder blip so setImage can repoint them; chart/table/other expose geometry for addChart/addDiagram. - Placeholder geometry is read EMU-native from the layout, falling back to the matching master slot — no canvas-px round-trip. - Unresolvable layoutId → onWarning + skip (never ship a wrong slide). Threads an instantiated-block map through the edit ops so edits resolve against the freshly-built slide XML. Tests: mixed clone+instantiate (3 slides, correct layout rel, chrome inherited, cloned parts byte-identical, zero dangling rels), master-geometry inheritance, unresolved-layout skip. --- .changeset/applyedits-layout-instantiation.md | 24 ++ README.md | 40 +++ packages/slidewise/src/index.ts | 8 +- .../pptx/__tests__/apply-edits-layout.test.ts | 319 ++++++++++++++++++ packages/slidewise/src/lib/pptx/applyEdits.ts | 317 +++++++++++++++-- packages/slidewise/src/lib/pptx/index.ts | 2 +- 6 files changed, 677 insertions(+), 33 deletions(-) create mode 100644 .changeset/applyedits-layout-instantiation.md create mode 100644 packages/slidewise/src/lib/pptx/__tests__/apply-edits-layout.test.ts diff --git a/.changeset/applyedits-layout-instantiation.md b/.changeset/applyedits-layout-instantiation.md new file mode 100644 index 0000000..648b986 --- /dev/null +++ b/.changeset/applyedits-layout-instantiation.md @@ -0,0 +1,24 @@ +--- +"@textcortex/slidewise": minor +--- + +feat(pptx): layout-instantiation in `applyEdits` (lossless scale-with-variety) + +`applyEdits` now supports `source: { layoutId, fills? }` in a `PlannedSlide` — +instantiating a fresh slide from one of the template's **own** layouts inside +the lossless byte-patch path. Because the layout is already a part of `source`, +the new slide binds to `ppt/slideLayouts/.xml` (inheriting theme / +master / background chrome) while every other part stays byte-identical. This +unlocks lossless **and** scale-with-variety in one deck: clone slides where you +want the exact thing, instantiate from layouts where you want variety. + +Each layout placeholder is materialised as an addressable, positioned element +with a deterministic id — `layoutSlotElementId(layoutId, key)` (exported) where +`key` is the `placeholderKey` / `summarizeLayouts` slot key. Text/`obj` slots +are populated from `fills` and editable via `setText`; picture slots become a +`` with a transparent placeholder blip so `setImage` can repoint them; +chart/table/other slots expose their geometry so the host fills them with +`addChart` / `addDiagram`. Placeholder geometry is read EMU-native from the +layout (falling back to the matching master slot), so it stays correct without a +canvas-px round-trip. An unresolvable `layoutId` is surfaced via `onWarning` and +the slide is skipped rather than shipped wrong. diff --git a/README.md b/README.md index 132c46b..71a59b5 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,46 @@ automatically. `serializeDeck` remains the path for the live editor and from-scratch decks; `applyEdits` is the lossless path for template-derived output. +**Scaling a deck with the template's own layouts.** A `PlannedSlide` can clone +a source slide (`{ slideIndex }`) **or** instantiate a fresh slide from one of +the template's layouts (`{ layoutId, fills? }`). Because the layout is already a +part of `source`, instantiation is still a lossless patch — the new slide binds +to `ppt/slideLayouts/.xml` and inherits its theme / master / +background chrome, while every other part stays byte-identical. This is how you +build a 35-slide deck from a 16-slide template without it looking repetitive: +clone where you want the exact slide, instantiate from layouts where you want +variety. + +```ts +import { applyEdits, layoutSlotElementId, summarizeLayouts } from "@textcortex/slidewise"; + +const layouts = summarizeLayouts(deck); // pick a layout id + its fillable slot keys +const layoutId = layouts[0].id; + +await applyEdits(source, { + slides: [ + { source: { slideIndex: 1 }, edits: [] }, // cloned, byte-identical + { + // Instantiate from a layout; `fills` populates text placeholders by key. + source: { layoutId, fills: { title: "Pipeline", "body:1": "Q3 → Q4" } }, + edits: [ + // Non-text slots are addressable by a deterministic id so edits can + // target them: fill the picture slot, draw a chart into the chart slot. + { op: "setImage", elementId: layoutSlotElementId(layoutId, "pic:2"), data: photoBytes }, + { op: "addChart", bounds: chartSlotBounds, kind: "column", categories, series }, + ], + }, + ], +}); +``` + +Each instantiated placeholder (text **and** non-text — picture / chart / table) +is materialised as a positioned element with the stable id +`layoutSlotElementId(layoutId, key)`, where `key` is the `placeholderKey` / +`summarizeLayouts` slot key (`"title"`, `"body:1"`, `"pic:2"`, …). An +unresolvable `layoutId` is reported via `onWarning` and the slide is skipped +(never shipped wrong). + ### Generating slides from the template's layouts `parsePptx` exposes the source template's master layouts on `deck.layouts`. diff --git a/packages/slidewise/src/index.ts b/packages/slidewise/src/index.ts index c8dfb0f..16007f4 100644 --- a/packages/slidewise/src/index.ts +++ b/packages/slidewise/src/index.ts @@ -88,7 +88,13 @@ export { type SlideRailItemContextValue, } from "./compound"; -export { parsePptx, isPptxTemplate, serializeDeck, applyEdits } from "./lib/pptx"; +export { + parsePptx, + isPptxTemplate, + serializeDeck, + applyEdits, + layoutSlotElementId, +} from "./lib/pptx"; export type { SerializeOptions, SerializeWarning, diff --git a/packages/slidewise/src/lib/pptx/__tests__/apply-edits-layout.test.ts b/packages/slidewise/src/lib/pptx/__tests__/apply-edits-layout.test.ts new file mode 100644 index 0000000..74ba82b --- /dev/null +++ b/packages/slidewise/src/lib/pptx/__tests__/apply-edits-layout.test.ts @@ -0,0 +1,319 @@ +import { describe, it, expect } from "vitest"; +import JSZip from "jszip"; +import { applyEdits, layoutSlotElementId, type EditPlan, type SerializeWarning } from "../index"; + +/** + * `applyEdits` layout-instantiation tests. A `source: { layoutId }` planned + * slide builds a fresh slide bound to one of the template's OWN layouts — still + * a lossless patch (the layout/master/theme are already parts of `source`), so + * the new slide inherits chrome while cloned/untouched parts stay byte-identical. + */ + +const NS_P = "http://schemas.openxmlformats.org/presentationml/2006/main"; +const NS_A = "http://schemas.openxmlformats.org/drawingml/2006/main"; +const NS_R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + +const ONE_PX_PNG = Uint8Array.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, + 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, + 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, + 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0d, + 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, + 0x60, 0x82, +]); + +function relsXml(entries: string): string { + return ( + `` + + `${entries}` + ); +} + +/** A `` placeholder, optionally carrying its own geometry. */ +function phSp(type: string, idx: number | null, geo: { x: number; y: number; w: number; h: number } | null): string { + const idxAttr = idx != null ? ` idx="${idx}"` : ""; + const xfrm = geo + ? `` + : ""; + return ( + `` + + `` + + `${xfrm}` + ); +} + +function slide(body: string): string { + return ( + `` + + `` + + `` + + `` + + `` + + `` + + `${body}` + + `` + ); +} + +/** A 2-slide template with a real master + layout + theme chrome stack. */ +async function buildLayoutTemplate(): Promise { + const zip = new JSZip(); + + zip.file( + "[Content_Types].xml", + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + ); + + zip.file( + "_rels/.rels", + relsXml(``) + ); + + zip.file( + "ppt/presentation.xml", + `` + + `` + + `` + + `` + + `` + ); + zip.file( + "ppt/_rels/presentation.xml.rels", + relsXml( + `` + + `` + + `` + ) + ); + + // Two content slides, each bound to the layout. + for (const n of [1, 2]) { + zip.file(`ppt/slides/slide${n}.xml`, slide(`Slide ${n}`)); + zip.file( + `ppt/slides/_rels/slide${n}.xml.rels`, + relsXml(``) + ); + } + + // Master: title + body (idx 1) placeholders, both with geometry. + zip.file( + "ppt/slideMasters/slideMaster1.xml", + `` + + `` + + `` + + phSp("title", null, { x: 838200, y: 365125, w: 10515600, h: 1325563 }) + + phSp("body", 1, { x: 838200, y: 1825625, w: 10515600, h: 4351338 }) + + `` + ); + zip.file( + "ppt/slideMasters/_rels/slideMaster1.xml.rels", + relsXml( + `` + + `` + ) + ); + + // Layout: title (own xfrm), body idx1 (NO xfrm → inherits master), pic, chart. + zip.file( + "ppt/slideLayouts/slideLayout1.xml", + `` + + `` + + `` + + phSp("title", null, { x: 838200, y: 365125, w: 10515600, h: 1325563 }) + + phSp("body", 1, null) + + phSp("pic", 2, { x: 838200, y: 1825625, w: 5000000, h: 4000000 }) + + phSp("chart", 3, { x: 6000000, y: 1825625, w: 5000000, h: 4000000 }) + + `` + ); + zip.file( + "ppt/slideLayouts/_rels/slideLayout1.xml.rels", + relsXml(``) + ); + + zip.file( + "ppt/theme/theme1.xml", + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + ); + + return zip.generateAsync({ type: "uint8array" }); +} + +async function loadZip(bytes: Uint8Array): Promise { + return JSZip.loadAsync(bytes); +} + +function normalise(target: string, baseDir: string): string { + if (target.startsWith("/")) return target.slice(1); + const segs = (baseDir ? baseDir.split("/") : []).concat(target.split("/")); + const out: string[] = []; + for (const s of segs) { + if (s === "" || s === ".") continue; + if (s === "..") out.pop(); + else out.push(s); + } + return out.join("/"); +} + +async function assertNoDanglingRels(zip: JSZip): Promise { + const present = new Set(); + zip.forEach((p, e) => { + if (!e.dir) present.add(p); + }); + const relsPaths: string[] = []; + zip.forEach((p, e) => { + if (!e.dir && p.endsWith(".rels")) relsPaths.push(p); + }); + for (const relsPath of relsPaths) { + const xml = await zip.file(relsPath)!.async("string"); + const ownerDir = relsPath.includes("/_rels/") ? relsPath.slice(0, relsPath.indexOf("/_rels/")) : ""; + for (const m of xml.matchAll(/]*\/?>/g)) { + const tag = m[0]; + const mode = /\bTargetMode="([^"]+)"/.exec(tag)?.[1]; + const target = /\bTarget="([^"]+)"/.exec(tag)?.[1]; + if (!target || mode === "External" || /^https?:/.test(target)) continue; + expect(present.has(normalise(target, ownerDir)), `${relsPath} → ${target}`).toBe(true); + } + } +} + +/** Find the output slide part that is neither slide1 nor slide2 (the new one). */ +function instantiatedSlidePath(zip: JSZip): string { + const paths: string[] = []; + zip.forEach((p) => { + if (/^ppt\/slides\/slide\w+\.xml$/.test(p)) paths.push(p); + }); + const p = paths.find((x) => x !== "ppt/slides/slide1.xml" && x !== "ppt/slides/slide2.xml"); + if (!p) throw new Error(`no instantiated slide among ${paths.join(", ")}`); + return p; +} + +describe("applyEdits — layout instantiation", () => { + it("mixes cloned + layout-instantiated slides losslessly", async () => { + const source = await buildLayoutTemplate(); + const warnings: SerializeWarning[] = []; + + const plan: EditPlan = { + slides: [ + { source: { slideIndex: 1 }, edits: [] }, + { source: { slideIndex: 2 }, edits: [] }, + { + source: { layoutId: "slideLayout1", fills: { title: "Hello", "body:1": "World" } }, + edits: [ + { op: "setImage", elementId: layoutSlotElementId("slideLayout1", "pic:2"), data: ONE_PX_PNG }, + { + op: "addChart", + bounds: { x: 600, y: 200, w: 500, h: 400 }, + kind: "column", + categories: ["A", "B"], + series: [{ name: "S", values: [1, 2] }], + }, + ], + }, + ], + }; + + const out = await applyEdits(source, plan, { onWarning: (w) => warnings.push(w) }); + expect(warnings).toEqual([]); + + const srcZip = await loadZip(source); + const outZip = await loadZip(out); + + // Three output slides, in order. + const pres = await outZip.file("ppt/presentation.xml")!.async("string"); + expect((pres.match(/Hello"); + expect(instXml).toContain("World"); + + // The picture placeholder is a whose blip was repointed by setImage. + expect(instXml).toContain(""); + const embed = /]*\br:embed="([^"]+)"/.exec(instXml)![1]; + const target = new RegExp(`Id="${embed}"[^>]*Target="([^"]+)"`).exec(instRels)![1]; + expect(await outZip.file(normalise(target, "ppt/slides"))!.async("uint8array")).toEqual(ONE_PX_PNG); + + // The added chart was spliced in + content-typed. + expect(instXml).toContain("drawingml/2006/chart"); + expect(await outZip.file("[Content_Types].xml")!.async("string")).toContain("drawingml.chart+xml"); + + // Chrome stack survives + structurally intact. + expect(outZip.file("ppt/slideLayouts/slideLayout1.xml")).not.toBeNull(); + expect(outZip.file("ppt/slideMasters/slideMaster1.xml")).not.toBeNull(); + expect(outZip.file("ppt/theme/theme1.xml")).not.toBeNull(); + expect(outZip.file("_rels/.rels")).not.toBeNull(); + await assertNoDanglingRels(outZip); + }); + + it("inherits placeholder geometry from the master when the layout omits it", async () => { + const source = await buildLayoutTemplate(); + const plan: EditPlan = { + slides: [ + { source: { layoutId: "slideLayout1", fills: { "body:1": "Body text" } }, edits: [] }, + ], + }; + const out = await applyEdits(source, plan); + const zip = await loadZip(out); + const instXml = await zip.file(instantiatedSlidePath(zip))!.async("string"); + // body had no layout xfrm → master geometry (off 838200,1825625) is used. + expect(instXml).toContain("Body text"); + expect(instXml).toContain('y="1825625"'); + await assertNoDanglingRels(zip); + }); + + it("warns + skips an unresolvable layoutId without shipping a wrong slide", async () => { + const source = await buildLayoutTemplate(); + const warnings: SerializeWarning[] = []; + const plan: EditPlan = { + slides: [ + { source: { slideIndex: 1 }, edits: [] }, + { source: { layoutId: "slideLayout999" }, edits: [] }, + ], + }; + const out = await applyEdits(source, plan, { onWarning: (w) => warnings.push(w) }); + const zip = await loadZip(out); + expect(warnings.some((w) => w.code === "layout-unresolved")).toBe(true); + // Only the resolvable slide ships. + const pres = await zip.file("ppt/presentation.xml")!.async("string"); + expect((pres.match(/, warn: (w: SerializeWarning) => void ): Promise { + // Instantiate from one of the source's own layouts. The layout is already a + // part of `source`, so binding a fresh slide to it is still a lossless patch + // (no chrome rewrite) — it inherits theme/master/background and its + // placeholders become addressable, fillable elements. if (!("slideIndex" in planned.source)) { - // Layout instantiation isn't part of the lossless patch path — that's the - // from-scratch `serializeDeck` flow's job. Surface it instead of silently - // emitting a wrong slide. - warn({ - code: "layout-unresolved", - message: - `slide ${outIndex + 1}: instantiating from layout "${planned.source.layoutId}" ` + - "is not supported by applyEdits; use serializeDeck for layout-from-scratch slides", - layoutId: planned.source.layoutId, - slideIndex: outIndex, - }); - return null; + const built = await instantiateLayoutSlide( + zip, + pristine, + planned.source.layoutId, + planned.source.fills, + outIndex, + warn + ); + if (!built) return null; // unresolved layout — warned, skip (don't ship wrong slide) + await applySlideEdits(zip, built.partPath, planned, outIndex, warn, built.instantiated); + return built.partPath; } const idx1 = planned.source.slideIndex; @@ -265,7 +269,7 @@ async function buildOutputSlide( } used.add(srcPath); - await applySlideEdits(zip, partPath, planned, outIndex, warn); + await applySlideEdits(zip, partPath, planned, outIndex, warn, new Map()); return partPath; } @@ -354,6 +358,225 @@ async function copyPartTree( return newPath; } +// ---------------------------------------------------------------------------- +// Layout instantiation (source: { layoutId, fills }) +// ---------------------------------------------------------------------------- + +/** + * Deterministic element id for a placeholder slot of a layout-instantiated + * slide. The host computes the same id (from `summarizeLayouts(deck)` keys) to + * target the slot with `setText` / `setImage` / `removeElement` edits. + */ +export function layoutSlotElementId(layoutId: string, placeholderKey: string): string { + return `${layoutId}::${placeholderKey}`; +} + +interface LayoutPh { + /** Raw `` (undefined => OOXML "body" default). */ + rawType: string | undefined; + idx: number | undefined; + /** `placeholderKey`-style: `type:idx` | `type` | `idx` | "". */ + key: string; + xEmu: number; + yEmu: number; + wEmu: number; + hEmu: number; + category: "text" | "picture" | "chart" | "table" | "other"; +} + +const TEXT_PH = new Set(["", "title", "ctrTitle", "subTitle", "body", "obj"]); +const SKIP_PH = new Set(["dt", "ftr", "sldNum"]); // auto chrome fields — inherit, don't instantiate + +function phCategory(rawType: string | undefined): LayoutPh["category"] { + const t = rawType ?? ""; + if (TEXT_PH.has(t)) return "text"; + if (t === "pic" || t === "clipArt") return "picture"; + if (t === "chart") return "chart"; + if (t === "tbl") return "table"; + return "other"; +} + +function phKey(rawType: string | undefined, idx: number | undefined): string { + const t = rawType ?? ""; + if (t && idx != null) return `${t}:${idx}`; + if (t) return t; + if (idx != null) return String(idx); + return ""; +} + +/** Resolve a `fills` entry for a placeholder, matching `placeholderKey` order. */ +function fillFor(rawType: string | undefined, idx: number | undefined, fills?: Record): string { + if (!fills) return ""; + const t = rawType ?? ""; + const byTypeIdx = t && idx != null ? fills[`${t}:${idx}`] : undefined; + const byType = t ? fills[t] : undefined; + const byIdx = idx != null ? fills[String(idx)] : undefined; + return byTypeIdx ?? byType ?? byIdx ?? ""; +} + +/** + * Read a layout's placeholders with EMU geometry, resolving the geometry from + * the layout's own `` and falling back to the matching master slot. + * EMU-native (no canvas-px fit round-trip), self-contained for the patch path. + */ +async function readLayoutPlaceholders(pristine: JSZip, layoutPath: string): Promise { + const layoutXml = await readText(pristine, layoutPath); + if (!layoutXml) return []; + + // Master fallback geometry, keyed by `type:idx` and `type`. + const masterGeo = new Map(); + const layoutRels = parseRels((await readText(pristine, relsPathFor(layoutPath))) ?? EMPTY_RELS); + const masterRel = layoutRels.find((r) => relTypeSuffix(r.type) === "slideMaster"); + if (masterRel) { + const masterPath = normalisePath(masterRel.target, dirOf(layoutPath)); + const masterXml = await readText(pristine, masterPath); + if (masterXml) { + for (const sp of masterXml.match(/[\s\S]*?<\/p:sp>/g) ?? []) { + const ph = /]*>/.exec(sp) ?? /]*\/>/.exec(sp); + if (!ph) continue; + const type = /\btype="([^"]+)"/.exec(ph[0])?.[1] ?? ""; + const idx = /\bidx="(\d+)"/.exec(ph[0])?.[1]; + const geo = readXfrmEmu(sp); + if (!geo) continue; + if (type && idx != null) masterGeo.set(`${type}:${idx}`, geo); + if (type) masterGeo.set(type, geo); + if (idx != null) masterGeo.set(idx, geo); + } + } + } + + const out: LayoutPh[] = []; + for (const sp of layoutXml.match(/[\s\S]*?<\/p:sp>/g) ?? []) { + const ph = /]*\/?>/.exec(sp); + if (!ph) continue; + const rawType = /\btype="([^"]+)"/.exec(ph[0])?.[1]; + if (rawType && SKIP_PH.has(rawType)) continue; + const idxStr = /\bidx="(\d+)"/.exec(ph[0])?.[1]; + const idx = idxStr != null ? Number(idxStr) : undefined; + + const geo = + readXfrmEmu(sp) ?? + (rawType && idx != null ? masterGeo.get(`${rawType}:${idx}`) : undefined) ?? + (rawType ? masterGeo.get(rawType) : undefined) ?? + (idx != null ? masterGeo.get(String(idx)) : undefined); + if (!geo) continue; // no resolvable geometry → not instantiable + + out.push({ + rawType, + idx, + key: phKey(rawType, idx), + xEmu: geo.x, + yEmu: geo.y, + wEmu: geo.w, + hEmu: geo.h, + category: phCategory(rawType), + }); + } + return out; +} + +function readXfrmEmu(sp: string): { x: number; y: number; w: number; h: number } | null { + const off = /]*\bx="(-?\d+)"[^>]*\by="(-?\d+)"[^>]*\/>/.exec(sp); + const ext = /]*\bcx="(\d+)"[^>]*\bcy="(\d+)"[^>]*\/>/.exec(sp); + if (!off || !ext) return null; + return { x: Number(off[1]), y: Number(off[2]), w: Number(ext[1]), h: Number(ext[2]) }; +} + +/** + * Build a fresh slide bound to a source layout. The new slide's `.rels` points + * at the existing `ppt/slideLayouts/.xml` (so it inherits theme / + * master / background chrome verbatim), and each layout placeholder becomes an + * addressable element keyed by `layoutSlotElementId(layoutId, key)`: + * text/`obj` → an `` with a `` (populated from `fills`), + * picture → a `` with a transparent placeholder blip (so `setImage` can + * repoint it), and chart/table/other → a positioned `` that exposes the + * slot geometry (the host fills it with `addChart`/`addDiagram`). + */ +async function instantiateLayoutSlide( + zip: JSZip, + pristine: JSZip, + layoutId: string, + fills: Record | undefined, + outIndex: number, + warn: (w: SerializeWarning) => void +): Promise<{ partPath: string; instantiated: Map } | null> { + const layoutPath = `ppt/slideLayouts/${layoutId}.xml`; + if (!pristine.file(layoutPath)) { + warn({ + code: "layout-unresolved", + message: `slide ${outIndex + 1}: layout "${layoutId}" not found at ${layoutPath}`, + layoutId, + slideIndex: outIndex, + }); + return null; + } + + const placeholders = await readLayoutPlaceholders(pristine, layoutPath); + const slidePath = freshPartPath(zip, "ppt/slides", "slide", "xml"); + const instantiated = new Map(); + + const rels: Rel[] = [ + { id: "rId1", type: SLIDE_LAYOUT_REL_TYPE, target: `../slideLayouts/${layoutId}.xml`, mode: undefined }, + ]; + let needsPlaceholderMedia = false; + const placeholderBlipRid = "rId2"; + + const children = placeholders.map((ph) => { + const elementId = layoutSlotElementId(layoutId, ph.key); + const xfrm = + `` + + ``; + const phTag = + ``; + const name = `${ph.rawType ?? "Body"} ${ph.idx ?? ""}`.trim(); + let block: string; + if (ph.category === "picture") { + needsPlaceholderMedia = true; + block = + `` + + `` + + `${phTag}` + + `` + + `${xfrm}` + + ``; + } else { + const fill = ph.category === "text" ? fillFor(ph.rawType, ph.idx, fills) : ""; + const para = fill + ? `${escapeText(fill)}` + : ``; + block = + `` + + `` + + `${phTag}` + + `${xfrm}` + + `${para}` + + ``; + } + instantiated.set(elementId, block); + return block; + }); + + const slideXml = + `` + + `` + + `` + + children.join("") + + ``; + + if (needsPlaceholderMedia) { + const mediaPath = baseOf(freshPartPath(zip, "ppt/media", "imageSWph", "png")); + const full = `ppt/media/${mediaPath}`; + zip.file(full, TRANSPARENT_PNG.slice()); + await ensureDefault(zip, "png", "image/png"); + rels.push({ id: placeholderBlipRid, type: IMAGE_REL_TYPE, target: `../media/${mediaPath}`, mode: undefined }); + } + + zip.file(slidePath, slideXml); + zip.file(relsPathFor(slidePath), serializeRels(rels)); + await ensureOverride(zip, "/" + slidePath, SLIDE_CONTENT_TYPE); + return { partPath: slidePath, instantiated }; +} + // ---------------------------------------------------------------------------- // Edit application // ---------------------------------------------------------------------------- @@ -363,7 +586,8 @@ async function applySlideEdits( slidePath: string, planned: PlannedSlide, outIndex: number, - warn: (w: SerializeWarning) => void + warn: (w: SerializeWarning) => void, + instantiated: Map ): Promise { let slideXml = (await readText(zip, slidePath)) ?? ""; const relsPath = relsPathFor(slidePath); @@ -380,26 +604,26 @@ async function applySlideEdits( try { switch (edit.op) { case "setText": - slideXml = editSetText(slideXml, edit.elementId, edit.text, edit.runs, slidePath, outIndex, warn); + slideXml = editSetText(slideXml, edit.elementId, edit.text, edit.runs, slidePath, outIndex, warn, instantiated); break; case "clearText": - slideXml = editSetText(slideXml, edit.elementId, "", undefined, slidePath, outIndex, warn); + slideXml = editSetText(slideXml, edit.elementId, "", undefined, slidePath, outIndex, warn, instantiated); break; case "removeElement": { - const r = editRemoveElement(slideXml, edit.elementId, rels, slidePath, outIndex, warn); + const r = editRemoveElement(slideXml, edit.elementId, rels, slidePath, outIndex, warn, instantiated); slideXml = r.slideXml; if (r.relsChanged) relsDirty = true; break; } case "setTableData": - slideXml = editSetTableData(slideXml, edit.elementId, edit.rows, slidePath, outIndex, warn); + slideXml = editSetTableData(slideXml, edit.elementId, edit.rows, slidePath, outIndex, warn, instantiated); break; case "setChartData": - await editSetChartData(zip, slideXml, edit, rels, slideDir, slidePath, outIndex, warn); + await editSetChartData(zip, slideXml, edit, rels, slideDir, slidePath, outIndex, warn, instantiated); break; case "setImage": { const rid = nextRid(); - const r = editSetImage(slideXml, edit, rid, slidePath, outIndex, warn); + const r = editSetImage(slideXml, edit, rid, slidePath, outIndex, warn, instantiated); if (r) { slideXml = r.slideXml; zip.file(r.media.fullPath, r.media.data); @@ -448,9 +672,10 @@ function editSetText( runs: Run[] | undefined, slidePath: string, outIndex: number, - warn: (w: SerializeWarning) => void + warn: (w: SerializeWarning) => void, + instantiated: Map ): string { - const block = locateBlock(slideXml, elementId, slidePath, outIndex, warn); + const block = locateBlock(slideXml, elementId, slidePath, outIndex, warn, instantiated); if (!block) return slideXml; const next = rewriteTextBody(block, text, runs); return slideXml.replace(block, next); @@ -519,9 +744,10 @@ function editRemoveElement( rels: Rel[], slidePath: string, outIndex: number, - warn: (w: SerializeWarning) => void + warn: (w: SerializeWarning) => void, + instantiated: Map ): { slideXml: string; relsChanged: boolean } { - const block = locateBlock(slideXml, elementId, slidePath, outIndex, warn); + const block = locateBlock(slideXml, elementId, slidePath, outIndex, warn, instantiated); if (!block) return { slideXml, relsChanged: false }; const without = slideXml.replace(block, ""); @@ -549,9 +775,10 @@ function editSetTableData( rows: string[][], slidePath: string, outIndex: number, - warn: (w: SerializeWarning) => void + warn: (w: SerializeWarning) => void, + instantiated: Map ): string { - const block = locateBlock(slideXml, elementId, slidePath, outIndex, warn); + const block = locateBlock(slideXml, elementId, slidePath, outIndex, warn, instantiated); if (!block) return slideXml; const tblMatch = /[\s\S]*<\/a:tbl>/.exec(block); if (!tblMatch) { @@ -603,9 +830,10 @@ async function editSetChartData( slideDir: string, slidePath: string, outIndex: number, - warn: (w: SerializeWarning) => void + warn: (w: SerializeWarning) => void, + instantiated: Map ): Promise { - const block = locateBlock(slideXml, edit.elementId, slidePath, outIndex, warn); + const block = locateBlock(slideXml, edit.elementId, slidePath, outIndex, warn, instantiated); if (!block) return; const chartRid = /]*\br:id="([^"]+)"/.exec(block)?.[1]; const rel = chartRid ? slideRels.find((r) => r.id === chartRid) : undefined; @@ -720,9 +948,10 @@ function editSetImage( newRid: string, slidePath: string, outIndex: number, - warn: (w: SerializeWarning) => void + warn: (w: SerializeWarning) => void, + instantiated: Map ): { slideXml: string; media: DecodedMedia } | null { - const block = locateBlock(slideXml, edit.elementId, slidePath, outIndex, warn); + const block = locateBlock(slideXml, edit.elementId, slidePath, outIndex, warn, instantiated); if (!block) return null; const blipMatch = /]*\br:embed="([^"]+)"/.exec(block); if (!blipMatch) { @@ -960,8 +1189,14 @@ function locateBlock( elementId: string, slidePath: string, outIndex: number, - warn: (w: SerializeWarning) => void + warn: (w: SerializeWarning) => void, + instantiated: Map ): string | null { + // Instantiated placeholders (layout-from-source slides) aren't in the parse + // registry — their block lives only in the freshly-built slide XML. + const fresh = instantiated.get(elementId); + if (fresh && slideXml.includes(fresh)) return fresh; + const loc = getElementLocation(elementId); if (!loc) { warn({ @@ -1285,6 +1520,11 @@ function hashString(s: string): number { return h; } +// OOXML namespaces (for synthesised slide parts). +const NS_P = "http://schemas.openxmlformats.org/presentationml/2006/main"; +const NS_A = "http://schemas.openxmlformats.org/drawingml/2006/main"; +const NS_R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + // Rel-type + content-type constants. const IMAGE_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"; @@ -1292,7 +1532,22 @@ const CHART_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart"; const PACKAGE_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/package"; +const SLIDE_LAYOUT_REL_TYPE = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout"; const CHART_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml"; const XLSX_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; +const SLIDE_CONTENT_TYPE = + "application/vnd.openxmlformats-officedocument.presentationml.slide+xml"; + +/** A CRC-correct 1×1 fully-transparent PNG, used as the placeholder blip for an + * instantiated picture slot until the host fills it via `setImage`. */ +const TRANSPARENT_PNG = Uint8Array.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, + 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, + 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, + 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0d, + 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, + 0x60, 0x82, +]); diff --git a/packages/slidewise/src/lib/pptx/index.ts b/packages/slidewise/src/lib/pptx/index.ts index 33ac9d7..ba4ff74 100644 --- a/packages/slidewise/src/lib/pptx/index.ts +++ b/packages/slidewise/src/lib/pptx/index.ts @@ -6,7 +6,7 @@ export type { SvgRasterizer, } from "./deckToPptx"; export type { ParseDiagnostics, ParseResult } from "./types"; -export { applyEdits } from "./applyEdits"; +export { applyEdits, layoutSlotElementId } from "./applyEdits"; export type { EditPlan, PlannedSlide, From 4a69622ab6364bcf9f0969cf90cf7a06e3efe1d1 Mon Sep 17 00:00:00 2001 From: karthikmudunuri <102793643+karthikmudunuri@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:13:58 +0530 Subject: [PATCH 2/2] feat(render): headless renderDeckToImages + deck.fontUsage font transparency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Headless render-to-image for a server-side visual-QA loop. Browser-free (no Playwright/Chromium/DOM): composes a deterministic SVG per slide that draws what the editor draws — native charts (buildChartOption + ECharts SSR), diagrams (layoutDiagram), text/shapes/images/backgrounds in z-order — not the OOXML raster fallbacks. - renderDeckToSvg / renderDeckToImages / renderSlideToImage / renderPptxToImages. - Rasterisation is an injected hook (opts.rasterizeSvg, e.g. @resvg/resvg-js); default tries a dynamic resvg import and throws a clear error otherwise — no hard native dep. ECharts is loaded on demand so it never bloats the editor bundle (main chunk stays ~1.1MB). - opts: slides (1-based subset), dpi, format, maxWidth. Font transparency: parsePptx stamps deck.fontUsage {family,embedded}[] — every family the text uses, flagged embedded (a real ppt/fonts/* part in ) vs only referenced, so the host can warn on missing brand fonts. Read-only diagnostic, distinct from deck.fonts. Tests: render-deck (chart colours, diagram, image, subset, injected rasteriser, maxWidth, browser-free) + font-usage (embed vs reference). --- .changeset/render-and-font-transparency.md | 31 + README.md | 51 ++ packages/slidewise/src/index.ts | 16 + .../src/lib/pptx/__tests__/font-usage.test.ts | 89 +++ packages/slidewise/src/lib/pptx/pptxToDeck.ts | 41 ++ .../lib/render/__tests__/render-deck.test.ts | 156 +++++ .../slidewise/src/lib/render/renderDeck.ts | 534 ++++++++++++++++++ packages/slidewise/src/lib/types.ts | 18 + 8 files changed, 936 insertions(+) create mode 100644 .changeset/render-and-font-transparency.md create mode 100644 packages/slidewise/src/lib/pptx/__tests__/font-usage.test.ts create mode 100644 packages/slidewise/src/lib/render/__tests__/render-deck.test.ts create mode 100644 packages/slidewise/src/lib/render/renderDeck.ts diff --git a/.changeset/render-and-font-transparency.md b/.changeset/render-and-font-transparency.md new file mode 100644 index 0000000..9787137 --- /dev/null +++ b/.changeset/render-and-font-transparency.md @@ -0,0 +1,31 @@ +--- +"@textcortex/slidewise": minor +--- + +feat(render): headless `renderDeckToImages` + `deck.fontUsage` font transparency + +**Headless render-to-image (visual-QA loop).** New browser-free renderer that +draws **what the editor draws** — native charts (`buildChartOption` + ECharts +SSR), diagrams (`layoutDiagram`), text/shapes/images/backgrounds in z-order — +*not* the OOXML raster fallbacks. No Playwright/Chromium/DOM. + +- `renderDeckToSvg(deck, opts?)` → one composed SVG per slide (ECharts is + loaded on demand, so it never bloats the editor bundle). +- `renderDeckToImages(deck, opts?)` / `renderSlideToImage(deck, i, opts?)` / + `renderPptxToImages(bytes, opts?)` → raster bytes. Rasterisation is an + injected hook (`opts.rasterizeSvg`, e.g. `@resvg/resvg-js`); when omitted the + default tries a dynamic `@resvg/resvg-js` import and throws a clear error if + it isn't installed — so there's no hard native dependency. +- `opts`: `slides` (1-based subset), `dpi` (canvas scales by `dpi/96`), + `format`, `maxWidth` (thumbnail cap). Deterministic (no animation). + +Enables the host's render → fresh-eyes inspect → targeted `applyEdits` fix → +re-render cycle, rendering a final `applyEdits` output directly. + +**Font transparency.** `parsePptx` now stamps `deck.fontUsage: +{ family, embedded }[]` — every font family the deck's text uses, flagged +whether the source PPTX actually **embeds** it (`` → a real +`ppt/fonts/*` part) or merely **references** it (system-fallback risk on +viewers that don't ship the brand font). Hosts use it to warn at generation +time ("missing fonts for some ppts"). It's a read-only diagnostic, distinct from +`deck.fonts` (the embeddable payloads). diff --git a/README.md b/README.md index 71a59b5..8310874 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,57 @@ is materialised as a positioned element with the stable id unresolvable `layoutId` is reported via `onWarning` and the slide is skipped (never shipped wrong). +### Headless render-to-image (visual QA) + +`renderDeckToImages` renders a deck to one image per slide **server-side, with +no browser** — drawing what the editor draws (native charts via ECharts SSR, +diagrams, text, shapes, images), not the OOXML raster fallbacks. It's built for +a render → inspect → fix → re-render QA loop, e.g. rendering a final `applyEdits` +output and having a model flag overflow / overlap / leftover text. + +```ts +import { + renderDeckToSvg, + renderDeckToImages, + renderPptxToImages, +} from "@textcortex/slidewise"; + +// SVGs only (no rasteriser needed) — rasterise yourself if you prefer. +const svgs: string[] = await renderDeckToSvg(deck, { slides: [1, 2] }); + +// Raster bytes. Rasterisation is an injected hook so there's no hard native dep +// — pass a @resvg/resvg-js wrapper (the default tries to import it on demand). +import { Resvg } from "@resvg/resvg-js"; +const pngs: Uint8Array[] = await renderDeckToImages(deck, { + dpi: 150, + rasterizeSvg: (svg, width) => new Resvg(svg, { fitTo: { mode: "width", value: width } }).render().asPng(), +}); + +// Render a final applyEdits output directly: +const shots = await renderPptxToImages(await applyEdits(source, plan)); +``` + +`opts`: `slides` (1-based subset), `dpi` (the 1920×1080 canvas scales by +`dpi/96`), `format`, `maxWidth` (thumbnail cap). Output is deterministic (no +animation). The renderer is browser-free and ECharts is loaded on demand, so it +never bloats the editor bundle. + +### Font transparency (missing-font warnings) + +`parsePptx` stamps `deck.fontUsage: { family, embedded }[]` — every font family +the deck's text uses, flagged whether the source PPTX actually **embeds** it +(`` → a real `ppt/fonts/*` part) or only **references** it (so +it falls back to a system font on viewers that don't ship the brand font). Use it +to warn at generation time: + +```ts +const missing = (deck.fontUsage ?? []).filter((f) => !f.embedded); +if (missing.length) warnHost(`not embedded: ${missing.map((f) => f.family).join(", ")}`); +``` + +This is a read-only diagnostic, distinct from `deck.fonts` (the embeddable +payloads the serializer writes back). + ### Generating slides from the template's layouts `parsePptx` exposes the source template's master layouts on `deck.layouts`. diff --git a/packages/slidewise/src/index.ts b/packages/slidewise/src/index.ts index 16007f4..d01a6b4 100644 --- a/packages/slidewise/src/index.ts +++ b/packages/slidewise/src/index.ts @@ -154,6 +154,21 @@ export { type DiagramArrowPrimitive, } from "./lib/diagram/layout"; +/** + * Headless deck → image rendering for a server-side visual-QA loop. Browser-free + * (no Playwright/DOM): composes a deterministic SVG per slide that draws what + * the editor draws — native charts, diagrams, text, shapes, images — then + * rasterises via an injected `rasterizeSvg` hook (e.g. `@resvg/resvg-js`). + * `renderDeckToSvg` returns the SVGs directly if you'd rather rasterise yourself. + */ +export { + renderDeckToSvg, + renderDeckToImages, + renderSlideToImage, + renderPptxToImages, + type RenderOptions, +} from "./lib/render/renderDeck"; + export type { Deck, Slide, @@ -190,6 +205,7 @@ export type { GlowSpec, DashType, FontAsset, + FontUsage, WebFontAsset, } from "./lib/types"; export { SLIDE_W, SLIDE_H } from "./lib/types"; diff --git a/packages/slidewise/src/lib/pptx/__tests__/font-usage.test.ts b/packages/slidewise/src/lib/pptx/__tests__/font-usage.test.ts new file mode 100644 index 0000000..f1828a6 --- /dev/null +++ b/packages/slidewise/src/lib/pptx/__tests__/font-usage.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from "vitest"; +import JSZip from "jszip"; +import { parsePptx } from "../index"; + +/** + * `deck.fontUsage` font-transparency report: every family the text uses, each + * flagged embedded (a real `ppt/fonts/*` part in ``) vs only + * referenced (system-fallback risk on viewers that don't ship it). + */ + +const NS_P = "http://schemas.openxmlformats.org/presentationml/2006/main"; +const NS_A = "http://schemas.openxmlformats.org/drawingml/2006/main"; +const NS_R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + +function rels(entries: string): string { + return ( + `` + + `${entries}` + ); +} + +function textSp(id: number, x: number, typeface: string, text: string): string { + return ( + `` + + `` + + `` + + `` + + `${text}` + ); +} + +async function buildFontTemplate(): Promise { + const zip = new JSZip(); + zip.file( + "[Content_Types].xml", + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + ); + zip.file("_rels/.rels", rels(``)); + + // Embed FontA only; FontB is referenced by text but not embedded. + zip.file( + "ppt/presentation.xml", + `` + + `` + + `` + + `` + + `` + + `` + ); + zip.file( + "ppt/_rels/presentation.xml.rels", + rels( + `` + + `` + ) + ); + zip.file("ppt/fonts/font1.fntdata", Uint8Array.from([0x00, 0x01, 0x00, 0x00, 0x00])); + + zip.file( + "ppt/slides/slide1.xml", + `` + + `` + + `` + + textSp(2, 0, "FontA", "Embedded brand text") + + textSp(3, 4000000, "FontB", "Referenced-only text") + + `` + ); + zip.file("ppt/slides/_rels/slide1.xml.rels", rels("")); + + return zip.generateAsync({ type: "uint8array" }); +} + +describe("deck.fontUsage", () => { + it("flags embedded vs referenced-only font families", async () => { + const deck = await parsePptx(await buildFontTemplate()); + const usage = deck.fontUsage ?? []; + const byFamily = new Map(usage.map((u) => [u.family, u.embedded])); + + expect(byFamily.get("FontA")).toBe(true); // embedded via + expect(byFamily.get("FontB")).toBe(false); // referenced by text, not embedded + }); +}); diff --git a/packages/slidewise/src/lib/pptx/pptxToDeck.ts b/packages/slidewise/src/lib/pptx/pptxToDeck.ts index 9bbd48e..a0b6f6d 100644 --- a/packages/slidewise/src/lib/pptx/pptxToDeck.ts +++ b/packages/slidewise/src/lib/pptx/pptxToDeck.ts @@ -21,6 +21,7 @@ import type { GroupElement, UnknownElement, FontAsset, + FontUsage, WebFontAsset, DeckLayout, LayoutPlaceholder, @@ -360,6 +361,9 @@ export async function parsePptx( ); // Master layouts exposed as instantiable templates (see addSlideFromLayout). const layouts = await parseLayouts(zip, fit); + // Font-transparency report: every family the text uses, flagged embedded + // (a real `ppt/fonts/*` part) vs only referenced (system-fallback risk). + const fontUsage = buildFontUsage(slides, fonts); const deck: Deck = { version: CURRENT_DECK_VERSION, title, @@ -368,6 +372,7 @@ export async function parsePptx( ...(layouts.length ? { layouts } : {}), ...(fonts.length ? { fonts } : {}), ...(webFonts.length ? { webFonts } : {}), + ...(fontUsage.length ? { fontUsage } : {}), }; Object.defineProperty(deck, SOURCE_PPTX, { value: sourceBuffer, @@ -380,6 +385,42 @@ export async function parsePptx( return deck; } +/** + * Build the font-transparency report: collect every family the deck's text + * uses (element + run + paragraph + table-cell + diagram fonts, recursing into + * groups), then flag each as embedded when the source's `` + * (→ `fonts`) carries it. Family matching is case-insensitive on a trimmed name + * since OOXML typeface casing isn't normalised. + */ +function buildFontUsage(slides: Slide[], fonts: FontAsset[]): FontUsage[] { + const used = new Map(); // lowercased -> first-seen display name + const note = (fam: string | undefined) => { + const name = (fam ?? "").trim(); + if (!name) return; + const key = name.toLowerCase(); + if (!used.has(key)) used.set(key, name); + }; + const noteRuns = (runs: TextRun[] | undefined) => runs?.forEach((r) => note(r.fontFamily)); + const walk = (el: SlideElement) => { + const e = el as Record & SlideElement; + note((e as { fontFamily?: string }).fontFamily); + noteRuns((e as { runs?: TextRun[] }).runs); + (e as { paragraphs?: Array<{ runs?: TextRun[] }> }).paragraphs?.forEach((p) => + noteRuns(p.runs) + ); + (e as { cellRuns?: (TextRun[] | null)[][] }).cellRuns?.forEach((row) => + row?.forEach((cell) => noteRuns(cell ?? undefined)) + ); + if (e.type === "group") e.children.forEach(walk); + }; + for (const slide of slides) slide.elements.forEach(walk); + + const embedded = new Set(fonts.map((f) => f.family.trim().toLowerCase())); + return [...used.values()] + .sort((a, b) => a.localeCompare(b)) + .map((family) => ({ family, embedded: embedded.has(family.toLowerCase()) })); +} + /** Non-enumerable property keys used to ferry the original archive * bytes from parse to serialize so we can round-trip the OOXML we * couldn't model. Internal — do not depend on these from outside the diff --git a/packages/slidewise/src/lib/render/__tests__/render-deck.test.ts b/packages/slidewise/src/lib/render/__tests__/render-deck.test.ts new file mode 100644 index 0000000..2f5edef --- /dev/null +++ b/packages/slidewise/src/lib/render/__tests__/render-deck.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import { + renderDeckToSvg, + renderDeckToImages, + renderSlideToImage, +} from "../renderDeck"; +import type { Deck, SlideElement } from "../../types"; + +/** + * Headless render tests. The renderer draws WHAT THE EDITOR DRAWS (native chart + * via ECharts SSR, diagram via layoutDiagram, text/shape/image), browser-free. + * We assert on the composed SVG (deterministic, no rasteriser needed) and drive + * `renderDeckToImages` through an injected rasterise hook. + */ + +const ONE_PX_PNG = Uint8Array.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, + 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, + 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, + 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0d, + 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, + 0x60, 0x82, +]); + +const IMG_SRC = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"; + +function base(type: SlideElement["type"], x: number, y: number, w: number, h: number) { + return { id: `${type}-${x}-${y}`, type, x, y, w, h, rotation: 0, z: 1 }; +} + +function buildDeck(): Deck { + const text: SlideElement = { + ...base("text", 100, 100, 800, 200), + text: "Quarterly Review", + fontFamily: "Arial", + fontSize: 48, + fontWeight: 700, + italic: false, + underline: false, + strike: false, + color: "#0E1330", + align: "left", + vAlign: "top", + lineHeight: 1.2, + letterSpacing: 0, + } as SlideElement; + const shape: SlideElement = { + ...base("shape", 100, 400, 300, 200), + shape: "rounded", + fill: "#EEEEEE", + } as SlideElement; + const chart: SlideElement = { + ...base("chart", 200, 150, 1200, 700), + kind: "column", + categories: ["Q1", "Q2", "Q3"], + series: [{ name: "Revenue", values: [10, 20, 30], color: "#EA1B0A" }], + } as SlideElement; + const image: SlideElement = { + ...base("image", 100, 100, 500, 400), + src: IMG_SRC, + fit: "cover", + } as SlideElement; + const diagram: SlideElement = { + ...base("diagram", 700, 200, 1000, 400), + kind: "process", + nodes: [ + { id: "n1", text: "Discover" }, + { id: "n2", text: "Design" }, + { id: "n3", text: "Deliver" }, + ], + } as SlideElement; + + return { + version: 1, + title: "Test", + slides: [ + { id: "s1", background: "#FFFFFF", elements: [text, shape] }, + { id: "s2", background: "#FFFFFF", elements: [chart] }, + { id: "s3", background: "#FFFFFF", elements: [image, diagram] }, + ], + } as Deck; +} + +describe("renderDeckToSvg / renderDeckToImages", () => { + it("renders one SVG per slide drawing what the editor draws", async () => { + const svgs = await renderDeckToSvg(buildDeck()); + expect(svgs).toHaveLength(3); + for (const s of svgs) expect(s.startsWith(" { + const svgs = await renderDeckToSvg(buildDeck(), { slides: [2] }); + expect(svgs).toHaveLength(1); + expect(svgs[0]).toContain("EA1B0A"); // the chart slide + }); + + it("rasterises each slide through the injected hook, in order", async () => { + const seen: { svg: string; w: number; h: number }[] = []; + const imgs = await renderDeckToImages(buildDeck(), { + dpi: 150, + rasterizeSvg: (svg, w, h) => { + seen.push({ svg, w, h }); + return ONE_PX_PNG; + }, + }); + expect(imgs).toHaveLength(3); + expect(imgs.every((b) => b instanceof Uint8Array)).toBe(true); + // dpi 150 → 1920 * 150/96 = 3000 px wide. + expect(seen[0].w).toBe(3000); + // The chart slide handed to the rasteriser carries the real chart colour. + expect(seen[1].svg).toContain("EA1B0A"); + }); + + it("renderSlideToImage returns a single slide's bytes", async () => { + const img = await renderSlideToImage(buildDeck(), 1, { + rasterizeSvg: () => ONE_PX_PNG, + }); + expect(img).toEqual(ONE_PX_PNG); + }); + + it("caps width with maxWidth for thumbnails", async () => { + let w = 0; + await renderDeckToImages(buildDeck(), { + slides: [1], + maxWidth: 480, + rasterizeSvg: (_svg, width) => { + w = width; + return ONE_PX_PNG; + }, + }); + expect(w).toBe(480); + }); + + it("stays browser-free (no Playwright/Puppeteer/jsdom in the source)", () => { + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const src = readFileSync(path.resolve(__dirname, "../renderDeck.ts"), "utf8"); + // No import of a headless-browser stack (a doc comment may name them). + expect(/from\s+["'](?:playwright|puppeteer|jsdom)/i.test(src)).toBe(false); + expect(/import\(["'](?:playwright|puppeteer|jsdom)/i.test(src)).toBe(false); + }); +}); diff --git a/packages/slidewise/src/lib/render/renderDeck.ts b/packages/slidewise/src/lib/render/renderDeck.ts new file mode 100644 index 0000000..f64a6b4 --- /dev/null +++ b/packages/slidewise/src/lib/render/renderDeck.ts @@ -0,0 +1,534 @@ +/** + * Headless deck → image rendering for a server-side visual-QA loop. + * + * `renderDeckToSvg` composes a deterministic SVG per slide that draws **what + * the editor draws** — native charts (`buildChartOption` + ECharts SSR), + * diagrams (`layoutDiagram`), text with the deck's runs/fonts, shapes, images, + * backgrounds, in z-order — NOT the OOXML raster fallbacks. `renderDeckToImages` + * rasterises those SVGs to PNG/JPEG. + * + * Browser-free: no Playwright / Chromium, no DOM. ECharts runs in SSR mode. + * Rasterisation is an injected hook (`opts.rasterizeSvg`) — pass + * `@resvg/resvg-js` (or any SVG→PNG) so the module stays isomorphic and free of + * a hard native dependency; when omitted the default tries a dynamic + * `@resvg/resvg-js` import and throws a clear error if it isn't installed. + */ +import type { Deck, Slide, SlideElement, TextRun } from "../types"; +import { SLIDE_W, SLIDE_H } from "../types"; +import { buildChartOption } from "../chart/chartOption"; +import { layoutDiagram } from "../diagram/layout"; +import { parsePptx } from "../pptx/pptxToDeck"; + +export interface RenderOptions { + /** 1-based slide subset to render; default all. */ + slides?: number[]; + /** Output resolution; the slide's 1920×1080 canvas scales by `dpi/96`. */ + dpi?: number; + /** Output image format. Default "png". */ + format?: "png" | "jpeg"; + /** Optional width cap (px) for thumbnails; preserves aspect ratio. */ + maxWidth?: number; + /** + * SVG → raster hook. `(svg, width, height, format) => bytes`. Injected so the + * module needs no native dep; pass e.g. a `@resvg/resvg-js` wrapper. The hook + * owns the output encoding (the built-in default emits PNG regardless of + * `format`; pass a custom hook for JPEG). + */ + rasterizeSvg?: ( + svg: string, + width: number, + height: number, + format: "png" | "jpeg" + ) => Promise | Uint8Array; +} + +const DEFAULT_DPI = 96; + +/** + * Compose one SVG string per requested slide — drawing what the editor draws. + * Async because ECharts is loaded on demand (dynamic import, so it never bloats + * the editor bundle); the SVG composition itself is synchronous. Usable on its + * own when the host rasterises the SVGs itself. + */ +export async function renderDeckToSvg(deck: Deck, opts: RenderOptions = {}): Promise { + const slides = pickSlides(deck, opts.slides); + const charts = await renderCharts(slides); + return slides.map((s) => renderSlideToSvg(s, charts)); +} + +/** Render requested slides to raster image bytes (one per slide, in order). */ +export async function renderDeckToImages( + deck: Deck, + opts: RenderOptions = {} +): Promise { + const svgs = await renderDeckToSvg(deck, opts); + const { width, height } = outputSize(opts); + const rasterize = opts.rasterizeSvg ?? defaultRasterize; + const format = opts.format ?? "png"; + const out: Uint8Array[] = []; + for (const svg of svgs) out.push(await rasterize(svg, width, height, format)); + return out; +} + +/** Convenience: render a single 1-based slide index to image bytes. */ +export async function renderSlideToImage( + deck: Deck, + slideIndex: number, + opts: RenderOptions = {} +): Promise { + const [img] = await renderDeckToImages(deck, { ...opts, slides: [slideIndex] }); + if (!img) throw new Error(`renderSlideToImage: slide ${slideIndex} out of range`); + return img; +} + +/** Parse a `.pptx` and render it — for rendering a final `applyEdits` output. */ +export async function renderPptxToImages( + bytes: Uint8Array | ArrayBuffer | Blob, + opts: RenderOptions = {} +): Promise { + const deck = await parsePptx(bytes); + return renderDeckToImages(deck, opts); +} + +// ---------------------------------------------------------------------------- + +function pickSlides(deck: Deck, subset: number[] | undefined): Slide[] { + if (!subset) return deck.slides; + return subset + .map((n) => deck.slides[n - 1]) + .filter((s): s is Slide => Boolean(s)); +} + +function outputSize(opts: RenderOptions): { width: number; height: number } { + const scale = (opts.dpi ?? DEFAULT_DPI) / DEFAULT_DPI; + let width = Math.round(SLIDE_W * scale); + let height = Math.round(SLIDE_H * scale); + if (opts.maxWidth && width > opts.maxWidth) { + height = Math.round((opts.maxWidth / width) * height); + width = opts.maxWidth; + } + return { width, height }; +} + +/** Pre-render every chart (recursing groups) to an SVG fragment, via a single + * on-demand ECharts SSR import — so the SVG composition stays synchronous and + * ECharts stays out of the editor bundle. */ +async function renderCharts(slides: Slide[]): Promise { + const cache: ChartCache = new Map(); + const charts: Extract[] = []; + const collect = (el: SlideElement) => { + if (el.type === "chart") charts.push(el); + else if (el.type === "group") el.children.forEach(collect); + }; + for (const s of slides) s.elements.forEach(collect); + if (!charts.length) return cache; + + const echarts = (await import("echarts")) as typeof import("echarts"); + for (const el of charts) { + const w = Math.max(1, Math.round(el.w)); + const h = Math.max(1, Math.round(el.h)); + const chart = echarts.init(null as unknown as HTMLElement, null, { + renderer: "svg", + ssr: true, + width: w, + height: h, + }); + try { + chart.setOption({ ...buildChartOption(el), animation: false }); + const svg = chart.renderToSVGString(); + const inner = svg.replace(/^[\s\S]*?]*>/, "").replace(/<\/svg>\s*$/, ""); + cache.set(el.id, `${inner}`); + } finally { + chart.dispose(); + } + } + return cache; +} + +type ChartCache = Map; + +function renderSlideToSvg(slide: Slide, charts: ChartCache): string { + const body: string[] = [renderBackground(slide.background)]; + const ordered = [...slide.elements].sort((a, b) => (a.z ?? 0) - (b.z ?? 0)); + for (const el of ordered) body.push(renderElement(el, charts)); + return ( + `` + + body.join("") + + `` + ); +} + +function renderBackground(background: string | undefined): string { + const fill = background ? solidFrom(background) : "#FFFFFF"; + if (isImageRef(background)) { + const href = imageHref(background!); + return ( + `` + + `` + ); + } + return ``; +} + +function renderElement(el: SlideElement, charts: ChartCache): string { + let inner: string; + switch (el.type) { + case "text": + inner = renderText(el); + break; + case "shape": + inner = renderShape(el); + break; + case "image": + inner = renderImage(el); + break; + case "line": + case "connector": + inner = renderLine(el); + break; + case "table": + inner = renderTable(el); + break; + case "chart": + inner = charts.get(el.id) ?? ""; + break; + case "diagram": + inner = renderDiagram(el); + break; + case "group": + inner = el.children + .slice() + .sort((a, b) => (a.z ?? 0) - (b.z ?? 0)) + .map((c) => renderElement(c, charts)) + .join(""); + break; + default: + inner = ""; // icon / embed / unknown — nothing visual to draw + } + if (!inner) return ""; + if (el.rotation) { + const cx = el.x + el.w / 2; + const cy = el.y + el.h / 2; + return `${inner}`; + } + return inner; +} + +// -- text -------------------------------------------------------------------- + +function renderText(el: Extract): string { + const parts: string[] = []; + if (el.background && solidFrom(el.background)) { + parts.push( + `` + ); + } + + const fontSize = el.fontSize || 18; + const lineH = (el.lineHeight && el.lineHeight > 0 ? el.lineHeight : 1.2) * fontSize; + const padL = el.padding?.l ?? 8; + const padR = el.padding?.r ?? 8; + const padT = el.padding?.t ?? 4; + const padB = el.padding?.b ?? 4; + const boxW = Math.max(1, el.w - padL - padR); + + // Build paragraphs → wrapped lines (paragraph-level styling; good enough for + // QA overflow/overlap detection while keeping all the text). + const paragraphs = textParagraphs(el); + const lines: { text: string; color: string; align: string }[] = []; + for (const p of paragraphs) { + const align = p.align ?? el.align ?? "left"; + const color = p.color ?? el.color ?? "#000000"; + const text = p.text; + if (text === "") { + lines.push({ text: "", color, align }); + continue; + } + for (const line of wrap(text, boxW, fontSize)) lines.push({ text: line, color, align }); + } + + const totalH = lines.length * lineH; + const innerTop = el.y + padT; + const innerBottom = el.y + el.h - padB; + let startY: number; + if (el.vAlign === "middle") startY = (innerTop + innerBottom) / 2 - totalH / 2 + fontSize * 0.8; + else if (el.vAlign === "bottom") startY = innerBottom - totalH + fontSize * 0.8; + else startY = innerTop + fontSize * 0.8; + + const weight = el.fontWeight && el.fontWeight >= 600 ? "bold" : "normal"; + const family = el.fontFamily || "sans-serif"; + lines.forEach((ln, i) => { + if (ln.text === "") return; + const y = startY + i * lineH; + let x: number; + let anchor: string; + if (ln.align === "center") { + x = el.x + el.w / 2; + anchor = "middle"; + } else if (ln.align === "right") { + x = el.x + el.w - padR; + anchor = "end"; + } else { + x = el.x + padL; + anchor = "start"; + } + parts.push( + `${escText(ln.text)}` + ); + }); + return parts.join(""); +} + +function textParagraphs( + el: Extract +): { text: string; align?: "left" | "center" | "right"; color?: string }[] { + if (el.paragraphs && el.paragraphs.length) { + return el.paragraphs.map((p) => ({ + text: p.runs?.length ? p.runs.map((r) => r.text).join("") : p.text, + align: p.align, + color: firstRunColor(p.runs), + })); + } + if (el.runs && el.runs.length) { + return [{ text: el.runs.map((r) => r.text).join(""), color: firstRunColor(el.runs) }]; + } + // Plain text may itself contain newlines. + return (el.text ?? "").split("\n").map((text) => ({ text })); +} + +function firstRunColor(runs: TextRun[] | undefined): string | undefined { + return runs?.find((r) => r.color)?.color; +} + +function wrap(text: string, boxW: number, fontSize: number): string[] { + const charW = fontSize * 0.52; // rough average glyph advance + const maxChars = Math.max(1, Math.floor(boxW / charW)); + const out: string[] = []; + for (const hardLine of text.split("\n")) { + if (hardLine.length <= maxChars) { + out.push(hardLine); + continue; + } + let cur = ""; + for (const word of hardLine.split(/\s+/)) { + const candidate = cur ? `${cur} ${word}` : word; + if (candidate.length > maxChars && cur) { + out.push(cur); + cur = word; + } else { + cur = candidate; + } + } + if (cur) out.push(cur); + } + return out; +} + +// -- shape ------------------------------------------------------------------- + +function renderShape(el: Extract): string { + const fill = solidFrom(el.fill) ?? "none"; + const stroke = el.stroke ? solidFrom(el.stroke) : undefined; + const strokeAttr = stroke + ? ` stroke="${stroke}" stroke-width="${round(el.strokeWidth ?? 1)}"` + : ""; + + if (el.path?.d) { + const sx = el.w / Math.max(1, el.path.viewW); + const sy = el.h / Math.max(1, el.path.viewH); + return ( + `` + + `` + + `` + ); + } + + const { x, y, w, h } = el; + switch (el.shape) { + case "circle": + return ``; + case "triangle": + return polygon([[x + w / 2, y], [x + w, y + h], [x, y + h]], fill, strokeAttr); + case "diamond": + return polygon([[x + w / 2, y], [x + w, y + h / 2], [x + w / 2, y + h], [x, y + h / 2]], fill, strokeAttr); + case "star": + return polygon(starPoints(x, y, w, h), fill, strokeAttr); + case "rounded": { + const r = el.radius ?? Math.min(w, h) * 0.12; + return ``; + } + default: + return ``; + } +} + +function polygon(pts: number[][], fill: string, strokeAttr: string): string { + const p = pts.map(([px, py]) => `${round(px)},${round(py)}`).join(" "); + return ``; +} + +function starPoints(x: number, y: number, w: number, h: number): number[][] { + const cx = x + w / 2; + const cy = y + h / 2; + const outer = Math.min(w, h) / 2; + const inner = outer * 0.4; + const pts: number[][] = []; + for (let i = 0; i < 10; i++) { + const r = i % 2 === 0 ? outer : inner; + const a = (Math.PI / 5) * i - Math.PI / 2; + pts.push([cx + r * Math.cos(a), cy + r * Math.sin(a)]); + } + return pts; +} + +// -- image ------------------------------------------------------------------- + +function renderImage(el: Extract): string { + const preserve = + el.fit === "contain" ? "xMidYMid meet" : el.fit === "fill" ? "none" : "xMidYMid slice"; + const clip = el.radius + ? ` clip-path="inset(0 round ${round(el.radius)}px)"` + : ""; + return ( + `` + ); +} + +// -- line / connector -------------------------------------------------------- + +function renderLine(el: Extract): string { + const stroke = solidFrom((el as { stroke?: string }).stroke ?? "#000000") ?? "#000000"; + const w = (el as { strokeWidth?: number }).strokeWidth ?? 2; + return ``; +} + +// -- table ------------------------------------------------------------------- + +function renderTable(el: Extract): string { + const rows = el.rows ?? []; + const nRows = rows.length; + const nCols = rows.reduce((m, r) => Math.max(m, r.length), 0); + if (!nRows || !nCols) return ""; + const colW = el.w / nCols; + const rowH = el.h / nRows; + const fontSize = el.fontSize || 14; + const parts: string[] = []; + for (let r = 0; r < nRows; r++) { + for (let c = 0; c < nCols; c++) { + const cx = el.x + c * colW; + const cy = el.y + r * rowH; + const isHeader = el.hasHeader && r === 0; + const fill = solidFrom(isHeader ? el.headerFill : el.rowFill) ?? "#FFFFFF"; + parts.push( + `` + ); + const text = rows[r]?.[c] ?? ""; + if (text) { + const color = solidFrom(isHeader ? el.headerTextColor ?? el.textColor : el.textColor) ?? "#000000"; + parts.push( + `${escText(text)}` + ); + } + } + } + return parts.join(""); +} + +// -- diagram ----------------------------------------------------------------- + +function renderDiagram(el: Extract): string { + const primitives = layoutDiagram(el); + const fontSize = el.fontSize ?? 18; + const parts: string[] = []; + for (const p of primitives) { + if (p.kind === "box") { + const rx = p.shape === "roundRect" ? 8 : p.shape === "ellipse" ? Math.min(p.w, p.h) / 2 : 0; + parts.push( + `` + ); + if (p.text) { + parts.push( + `${escText(p.text)}` + ); + } + } else { + parts.push( + `` + ); + } + } + return parts.join(""); +} + +// -- rasterisation ----------------------------------------------------------- + +async function defaultRasterize( + svg: string, + width: number, + _height: number, + _format: "png" | "jpeg" +): Promise { + try { + // Non-literal specifier so TS/bundlers don't hard-resolve the optional dep; + // it's only loaded when the host hasn't injected its own rasteriser. + const spec = ["@resvg", "resvg-js"].join("/"); + const mod = (await import(/* @vite-ignore */ spec)) as { + Resvg: new (svg: string, opts?: unknown) => { render(): { asPng(): Uint8Array } }; + }; + const resvg = new mod.Resvg(svg, { fitTo: { mode: "width", value: width } }); + return resvg.render().asPng(); + } catch { + throw new Error( + "renderDeckToImages: no SVG rasteriser available. Pass `opts.rasterizeSvg` " + + "(e.g. a @resvg/resvg-js wrapper), or install @resvg/resvg-js. " + + "`renderDeckToSvg` returns the SVGs directly if you want to rasterise yourself." + ); + } +} + +// -- colour / string helpers ------------------------------------------------- + +function isImageRef(s: string | undefined): boolean { + return !!s && (s.startsWith("data:image") || /^url\(/i.test(s) || /^https?:\/\//i.test(s)); +} + +function imageHref(s: string): string { + const m = /^url\(["']?(.*?)["']?\)$/i.exec(s); + return m ? m[1] : s; +} + +/** Best-effort single colour for SVG: pass hex through, pull the first hex out + * of a CSS gradient, map `transparent` → none, ignore image refs. */ +function solidFrom(value: string | undefined): string | undefined { + if (!value) return undefined; + const v = value.trim(); + if (v === "transparent" || v === "none") return "none"; + if (/^#[0-9a-fA-F]{3,8}$/.test(v)) return v; + if (/^rgb/i.test(v)) return v; + const hex = /#[0-9a-fA-F]{3,8}/.exec(v); + if (hex) return hex[0]; + if (isImageRef(v)) return undefined; + return v; // named colour (e.g. "white") +} + +function round(n: number): number { + return Math.round(n * 100) / 100; +} + +function escText(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">"); +} + +function escAttr(s: string): string { + return s.replace(/&/g, "&").replace(/`) or merely **references** it + * (so it falls back to a system font on viewers that don't ship it). Hosts + * use this to warn at generation time when a template's brand font isn't + * embedded. Distinct from `fonts` (the embeddable payloads) — this is a + * read-only diagnostic. + */ + fontUsage?: FontUsage[]; +} + +/** One entry of {@link Deck.fontUsage}. */ +export interface FontUsage { + /** Family name as it appears on `TextElement.fontFamily` / run fonts. */ + family: string; + /** True when the source PPTX embeds this family; false if only referenced. */ + embedded: boolean; } export type ElementDraft = T extends SlideElement