From 45e4b28e267e94d1ac10f7fe77aa893873d07d17 Mon Sep 17 00:00:00 2001 From: karthikmudunuri <102793643+karthikmudunuri@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:50:45 +0530 Subject: [PATCH 1/3] feat(pptx): harden layout instantiation, add diagram primitive, crop/radius + non-16:9 diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 — layout instantiation hardening: - Fix: layout-instantiated slides now inherit their layout background (no spurious overriding master/theme chrome). - Add DeckLayout.type + summarizeLayouts()/placeholderKey() for model-facing layout selection. P2 — non-16:9 chrome diagnostics: - SerializeOptions.onWarning surfaces a machine-readable "chrome-skipped" warning when a source template's size is unreadable; 16:10 round-trip test. P3 — first-class DiagramElement: - process/timeline/funnel/matrix/cycle/list modelled as labelled nodes; shared layoutDiagram feeds renderer + a grouped, editable writer. P4 — round-trip fidelity: - Image crop () + corner radius (roundRect) now round-trip via a dedicated synth writer + radius parse. - Text-run letter-case (cap) re-applied on export (pptxgenjs has no cap option). --- .changeset/diagram-primitive.md | 14 + .changeset/image-crop-radius.md | 18 ++ .changeset/layout-instantiation-hardening.md | 8 + .changeset/non-16-9-diagnostics.md | 13 + README.md | 104 +++++++ .../src/components/editor/ElementView.tsx | 94 ++++++ .../editor/__tests__/diagram-render.test.tsx | 60 ++++ packages/slidewise/src/index.ts | 26 ++ .../src/lib/diagram/__tests__/layout.test.ts | 94 ++++++ packages/slidewise/src/lib/diagram/layout.ts | 286 ++++++++++++++++++ packages/slidewise/src/lib/layouts.ts | 179 ++++++++++- .../pptx/__tests__/crop-radius-runs.test.ts | 191 ++++++++++++ .../src/lib/pptx/__tests__/diagram.test.ts | 94 ++++++ .../__tests__/instantiable-layouts.test.ts | 203 ++++++++++++- .../src/lib/pptx/__tests__/non-16-9.test.ts | 68 ++++- packages/slidewise/src/lib/pptx/deckToPptx.ts | 172 ++++++++++- packages/slidewise/src/lib/pptx/index.ts | 1 + packages/slidewise/src/lib/pptx/pptxToDeck.ts | 21 ++ .../slidewise/src/lib/pptx/pptxWriters.ts | 185 ++++++++++- packages/slidewise/src/lib/types.ts | 63 ++++ 20 files changed, 1876 insertions(+), 18 deletions(-) create mode 100644 .changeset/diagram-primitive.md create mode 100644 .changeset/image-crop-radius.md create mode 100644 .changeset/layout-instantiation-hardening.md create mode 100644 .changeset/non-16-9-diagnostics.md create mode 100644 packages/slidewise/src/components/editor/__tests__/diagram-render.test.tsx create mode 100644 packages/slidewise/src/lib/diagram/__tests__/layout.test.ts create mode 100644 packages/slidewise/src/lib/diagram/layout.ts create mode 100644 packages/slidewise/src/lib/pptx/__tests__/crop-radius-runs.test.ts create mode 100644 packages/slidewise/src/lib/pptx/__tests__/diagram.test.ts diff --git a/.changeset/diagram-primitive.md b/.changeset/diagram-primitive.md new file mode 100644 index 0000000..ffd653f --- /dev/null +++ b/.changeset/diagram-primitive.md @@ -0,0 +1,14 @@ +--- +"@textcortex/slidewise": minor +--- + +First-class **diagram** element (P3 / F3). A new `DiagramElement` models +process / timeline / funnel / matrix / cycle / list visuals as an ordered set +of labelled `nodes` instead of a hand-placed cluster of shapes and lines. The +renderer and the PPTX writer share one layout function (`layoutDiagram`, also +exported), so the on-canvas preview and the saved file can't drift. On export a +diagram serialises to a single labelled `` of real shapes + connectors +— so it stays grouped and editable in PowerPoint (move/resize as one unit) +rather than collapsing to anonymous floating shapes. Exposed via the +`DiagramElement` / `DiagramNode` / `DiagramKind` types and the `layoutDiagram` +helper. diff --git a/.changeset/image-crop-radius.md b/.changeset/image-crop-radius.md new file mode 100644 index 0000000..e6af288 --- /dev/null +++ b/.changeset/image-crop-radius.md @@ -0,0 +1,18 @@ +--- +"@textcortex/slidewise": minor +--- + +Round-trip fidelity fixes (P4): + +- **Image `crop` / `radius`** now round-trip. Previously an image's `crop` + (``) was read on import but silently dropped on export, and corner + `radius` was neither parsed nor written. `serializeDeck` now routes any image + carrying a `crop` or `radius` through a dedicated `` writer (emitting + `` and `roundRect` geometry) instead of pptxgenjs — whose + cover/contain sizing emits its own `` and would fight a user crop — + and `parsePptx` reads a rounded picture's corner radius back. Plain + (uncropped, square-cornered) images keep the existing path unchanged. +- **Text-run letter-case (`cap`)** now round-trips. A run's `cap` + (`"all"` / `"small"`, OOXML ``) was parsed on import but dropped on + export (pptxgenjs has no `cap` option). It's now re-applied per run in + post-process, so all-caps / small-caps styling survives a save. diff --git a/.changeset/layout-instantiation-hardening.md b/.changeset/layout-instantiation-hardening.md new file mode 100644 index 0000000..eb08fb1 --- /dev/null +++ b/.changeset/layout-instantiation-hardening.md @@ -0,0 +1,8 @@ +--- +"@textcortex/slidewise": minor +--- + +Harden layout instantiation for AI deck generation (P1 / F1): + +- **Layout-instantiated slides now inherit their layout's background.** A slide minted by `addSlideFromLayout` with the default `transparent` background no longer serialises an explicit `` `` — that empty background was overriding the layout/master/theme inheritance, so instantiated slides lost their on-brand background. They now stay ``-less and paint from their `sourceLayoutId` layout's chrome (matching the source-slide guarantee for cloned/reordered slides). +- **Layout-selection metadata.** `DeckLayout.type` now carries the raw OOXML `` role, and the new `summarizeLayouts(deck)` returns a compact, model-context-friendly layout menu (friendly `role` label, fillable `fills` keys, per-placeholder kind/category/geometry) so a host can have a model pick a layout per slide. `placeholderKey(ph)` exposes the exact `fills` key for a placeholder. diff --git a/.changeset/non-16-9-diagnostics.md b/.changeset/non-16-9-diagnostics.md new file mode 100644 index 0000000..4cd9464 --- /dev/null +++ b/.changeset/non-16-9-diagnostics.md @@ -0,0 +1,13 @@ +--- +"@textcortex/slidewise": minor +--- + +Machine-readable serialization diagnostics (P2 / B3). `serializeDeck` now +accepts `SerializeOptions.onWarning`, a callback invoked with a structured +`SerializeWarning` when the output degrades. The key case is +`"chrome-skipped"` — emitted when a `source` template's masters / layouts / +theme / fonts can't be carried over because its slide size is unreadable, so +the deck falls back to generic chrome. Hosts can now detect and surface the +degradation instead of only seeing a console line. (Non-16:9 sizing for 4:3 / +16:10 / custom templates already drives the output slide size; this adds the +escape-hatch signal when it can't.) diff --git a/README.md b/README.md index d70e288..8602f79 100644 --- a/README.md +++ b/README.md @@ -83,12 +83,116 @@ const pptx: Blob = await serializeDeck(deck); // export const safe: Deck = migrate(unknownDeckJson); // normalise an external deck ``` +`serializeDeck(deck, { source })` reproduces a source template's slide size +(16:9, 4:3, 16:10, or custom) and carries over its masters / layouts / theme / +fonts. If that chrome can't be preserved — e.g. the source's slide size is +unreadable — it falls back to generic chrome and reports it through an optional +diagnostics sink so the host can surface the degradation rather than ship a +silently off-brand deck: + +```ts +await serializeDeck(deck, { + source, + onWarning: (w) => { + if (w.code === "chrome-skipped") notifyHost(w.message); + }, +}); +``` + `migrate()` runs every external deck (PPTX import, JSON import, localStorage hydration, host props) through the schema migration chain so the rest of the editor only sees current-shape decks. It throws if the input was written by a newer Slidewise than the host has installed — pin the version range you can support. +### Generating slides from the template's layouts + +`parsePptx` exposes the source template's master layouts on `deck.layouts`. +`addSlideFromLayout(deck, layoutId, opts)` mints a fresh slide bound to one of +them — the unlock for generating a deck with more slides than the template +hand-authored, using the template's own layout variety. The new slide carries +`sourceLayoutId`, so `serializeDeck(deck, { source })` paints its +background / fonts / theme / footer chrome from that layout (not from output +position), exactly like a cloned source slide. + +```ts +import { + parsePptx, + serializeDeck, + summarizeLayouts, + addSlideFromLayout, +} from "@textcortex/slidewise"; + +const deck = await parsePptx(blob); + +// 1. Show a model a compact menu of the available layouts. The shape is +// structured (not a string) so you can trim it to your context budget — +// e.g. keep only { id, role, fillable } and drop geometry. +const menu = summarizeLayouts(deck); +// [ +// { id: "slideLayout2", name: "Title and Content", type: "obj", +// role: "Title and content", fillable: ["title", "body:1"], +// placeholders: [ +// { key: "title", type: "title", category: "text", fillable: true, x, y, w, h }, +// { key: "body:1", type: "body", idx: 1, category: "text", fillable: true, x, y, w, h }, +// ] }, +// ... +// ] + +// 2. Instantiate a slide from the chosen layout, filling its text placeholders. +const next = addSlideFromLayout(deck, "slideLayout2", { + fills: { title: "Q3 Results", "body:1": "Revenue up 24%" }, +}); + +const pptx = await serializeDeck(next, { source: blob }); +``` + +**The `fills` contract.** `fills` is keyed by placeholder, resolved +most-specific-first: `"type:idx"` (e.g. `"body:1"`), then the bare `"type"` +(e.g. `"title"`), then the bare index as a string. `placeholderKey(ph)` (and +`LayoutSlotSummary.key` from `summarizeLayouts`) gives you the exact key for a +slot. Only **text** placeholders are fillable — `title`, `ctrTitle`, +`subTitle`, `body`, `obj`, and the untyped default (`LayoutSlotSummary.fillable +=== true`, `category === "text"`). Those become editable text elements +positioned per the layout. Non-text slots (pictures, tables, charts, and footer +chrome like date / slide-number / footer) are skipped — inherit them from the +master, or add real `image` / `table` / `chart` elements to the returned slide. +A placeholder with no matching `fills` entry becomes an empty, editable text +box. + +### Diagrams + +`DiagramElement` models a process / timeline / funnel / matrix / cycle / list +as an ordered set of labelled `nodes`, laid out by `kind`. It renders on the +canvas and serialises to a single grouped, editable `` of real shapes ++ connectors (not a flat pile of anonymous shapes). The renderer and writer +share `layoutDiagram`, exported so a host preview / server render stays in sync. + +```ts +const slide = { + id: "s1", + background: "transparent", + elements: [ + { + id: "d1", + type: "diagram", + kind: "process", + x: 120, + y: 240, + w: 1680, + h: 320, + rotation: 0, + z: 1, + nodes: [ + { id: "n1", text: "Discover" }, + { id: "n2", text: "Design" }, + { id: "n3", text: "Ship" }, + ], + }, + ], +}; +``` + ## Theming Slidewise exposes its surface colors and chrome metrics as CSS custom diff --git a/packages/slidewise/src/components/editor/ElementView.tsx b/packages/slidewise/src/components/editor/ElementView.tsx index 78626a8..d65cde8 100644 --- a/packages/slidewise/src/components/editor/ElementView.tsx +++ b/packages/slidewise/src/components/editor/ElementView.tsx @@ -13,11 +13,13 @@ import type { ChartElement, ConnectorElement, GroupElement, + DiagramElement, UnknownElement, ShadowSpec, GlowSpec, } from "@/lib/types"; import { buildChartOption } from "@/lib/chart/chartOption"; +import { layoutDiagram } from "@/lib/diagram/layout"; export function ElementView({ el, @@ -49,11 +51,103 @@ export function ElementView({ return ; case "group": return ; + case "diagram": + return ; case "unknown": return ; } } +/** + * Render a diagram from its shared layout (the same `layoutDiagram` the PPTX + * writer uses, so preview and export match). Boxes are absolutely-positioned + * divs; arrows are drawn in a single SVG overlay behind them. + */ +function DiagramView({ el }: { el: DiagramElement }) { + const markerId = useId().replace(/[:]/g, ""); + const primitives = layoutDiagram(el); + const boxes = primitives.filter((p) => p.kind === "box"); + const arrows = primitives.filter((p) => p.kind === "arrow"); + const fontSize = el.fontSize ?? 18; + const fontFamily = el.fontFamily ?? "Inter"; + return ( +
+ {arrows.length > 0 && ( + + + + + + + {arrows.map((a) => + a.kind === "arrow" ? ( + + ) : null + )} + + )} + {boxes.map((b) => + b.kind === "box" ? ( +
+ {b.text} +
+ ) : null + )} +
+ ); +} + /** * Render a group as a transparent wrapper sized by the parent positioner; * children carry slide-absolute coordinates so we translate the wrapper to diff --git a/packages/slidewise/src/components/editor/__tests__/diagram-render.test.tsx b/packages/slidewise/src/components/editor/__tests__/diagram-render.test.tsx new file mode 100644 index 0000000..ee86392 --- /dev/null +++ b/packages/slidewise/src/components/editor/__tests__/diagram-render.test.tsx @@ -0,0 +1,60 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { ElementView } from "../ElementView"; +import type { DiagramElement } from "@/lib/types"; + +/** + * P3 renderer: a diagram renders its node labels as positioned boxes (and + * arrows as an SVG overlay) using the same `layoutDiagram` the writer uses — + * so it shows on the canvas / host preview instead of an empty box. + */ + +const process: DiagramElement = { + id: "d1", + type: "diagram", + x: 0, + y: 0, + w: 900, + h: 300, + rotation: 0, + z: 1, + kind: "process", + nodes: [ + { id: "n0", text: "Alpha" }, + { id: "n1", text: "Beta" }, + { id: "n2", text: "Gamma" }, + ], +}; + +describe("P3: diagram renderer", () => { + it("renders a labelled box per node and an arrow overlay", () => { + const html = renderToStaticMarkup(); + expect(html).toContain("Alpha"); + expect(html).toContain("Beta"); + expect(html).toContain("Gamma"); + // Arrows between boxes → an SVG with a marker + lines. + expect(html).toContain(" { + const html = renderToStaticMarkup( + + ); + expect(html).toContain("Q1"); + expect(html).toContain("Q4"); + expect(html).not.toContain("`) — shared so a host preview / server render can't drift from the + * package's own layout. + */ +export { + layoutDiagram, + DEFAULT_DIAGRAM_PALETTE, + type DiagramPrimitive, + type DiagramBoxPrimitive, + type DiagramArrowPrimitive, +} from "./lib/diagram/layout"; + export type { Deck, Slide, @@ -141,6 +164,9 @@ export type { ConnectorKind, ArrowheadKind, GroupElement, + DiagramElement, + DiagramNode, + DiagramKind, UnknownElement, DeckLayout, LayoutPlaceholder, diff --git a/packages/slidewise/src/lib/diagram/__tests__/layout.test.ts b/packages/slidewise/src/lib/diagram/__tests__/layout.test.ts new file mode 100644 index 0000000..499b454 --- /dev/null +++ b/packages/slidewise/src/lib/diagram/__tests__/layout.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from "vitest"; +import { layoutDiagram } from "../layout"; +import type { DiagramElement, DiagramKind } from "@/lib/types"; + +function diagram(kind: DiagramKind, count: number): DiagramElement { + return { + id: "d1", + type: "diagram", + x: 100, + y: 80, + w: 1000, + h: 400, + rotation: 0, + z: 1, + kind, + nodes: Array.from({ length: count }, (_, i) => ({ + id: `n${i}`, + text: `Step ${i + 1}`, + })), + }; +} + +function boxes(kind: DiagramKind, count: number) { + return layoutDiagram(diagram(kind, count)).filter((p) => p.kind === "box"); +} +function arrows(kind: DiagramKind, count: number) { + return layoutDiagram(diagram(kind, count)).filter((p) => p.kind === "arrow"); +} + +describe("layoutDiagram", () => { + it("returns [] for a diagram with no nodes", () => { + expect(layoutDiagram(diagram("process", 0))).toEqual([]); + }); + + it("process: one box per node + connecting arrows between them", () => { + expect(boxes("process", 4)).toHaveLength(4); + expect(arrows("process", 4)).toHaveLength(3); + }); + + it("keeps every box inside the element's local bounds", () => { + for (const p of boxes("process", 5)) { + if (p.kind !== "box") continue; + expect(p.x).toBeGreaterThanOrEqual(0); + expect(p.y).toBeGreaterThanOrEqual(0); + expect(p.x + p.w).toBeLessThanOrEqual(1000 + 1); + expect(p.y + p.h).toBeLessThanOrEqual(400 + 1); + } + }); + + it("is deterministic (renderer and writer must agree)", () => { + const a = layoutDiagram(diagram("cycle", 5)); + const b = layoutDiagram(diagram("cycle", 5)); + expect(a).toEqual(b); + }); + + it("funnel: bars narrow toward the bottom", () => { + const bs = boxes("funnel", 4); + const widths = bs.map((p) => (p.kind === "box" ? p.w : 0)); + for (let i = 1; i < widths.length; i++) { + expect(widths[i]).toBeLessThan(widths[i - 1]); + } + }); + + it("matrix: lays N nodes into a near-square grid within bounds", () => { + const bs = boxes("matrix", 4); + expect(bs).toHaveLength(4); + // 4 → 2×2; two distinct columns, two distinct rows. + const xs = new Set(bs.map((p) => (p.kind === "box" ? p.x : 0))); + const ys = new Set(bs.map((p) => (p.kind === "box" ? p.y : 0))); + expect(xs.size).toBe(2); + expect(ys.size).toBe(2); + }); + + it("cycle: an arrow between every consecutive node (and a closing one)", () => { + expect(arrows("cycle", 5)).toHaveLength(5); + }); + + it("timeline: a non-arrow spine plus one box per node", () => { + const prims = layoutDiagram(diagram("timeline", 3)); + expect(prims.filter((p) => p.kind === "box")).toHaveLength(3); + const spine = prims.find((p) => p.kind === "arrow"); + expect(spine).toBeTruthy(); + expect(spine!.kind === "arrow" && spine!.arrow).toBe(false); + }); + + it("uses per-node fill/color overrides when present", () => { + const el = diagram("list", 2); + el.nodes[0].fill = "#123456"; + el.nodes[0].color = "#abcdef"; + const first = layoutDiagram(el).find((p) => p.kind === "box"); + expect(first!.kind === "box" && first!.fill).toBe("#123456"); + expect(first!.kind === "box" && first!.textColor).toBe("#abcdef"); + }); +}); diff --git a/packages/slidewise/src/lib/diagram/layout.ts b/packages/slidewise/src/lib/diagram/layout.ts new file mode 100644 index 0000000..17d3800 --- /dev/null +++ b/packages/slidewise/src/lib/diagram/layout.ts @@ -0,0 +1,286 @@ +import type { DiagramElement, DiagramNode } from "@/lib/types"; + +/** + * Diagram layout — the single source of truth shared by the renderer + * (`DiagramView`) and the PPTX writer (`synthesiseDiagram`). Given a + * `DiagramElement`, `layoutDiagram` returns positioned primitives in the + * diagram's LOCAL coordinate space (`0..w` × `0..h`, origin at the element's + * top-left). The renderer draws them inside the element container as-is; the + * writer wraps them in a `` whose child frame is `chOff=0,0` + * `chExt=w×h`, so the same local coordinates serialise unchanged. + * + * Keeping one layout function means the on-canvas preview and the saved + * grouped shape can never drift. + */ + +/** A labelled box (one node) in local diagram coordinates. */ +export interface DiagramBoxPrimitive { + kind: "box"; + id: string; + x: number; + y: number; + w: number; + h: number; + text: string; + /** Fill (CSS hex). */ + fill: string; + /** Label color (CSS hex). */ + textColor: string; + /** Preset geometry the box renders/serialises with. */ + shape: "rect" | "roundRect" | "ellipse"; +} + +/** A straight arrow from one anchor to another, in local diagram coordinates. */ +export interface DiagramArrowPrimitive { + kind: "arrow"; + id: string; + x1: number; + y1: number; + x2: number; + y2: number; + /** Whether the arrow carries a head at its end point. */ + arrow: boolean; + /** Stroke (CSS hex). */ + stroke: string; +} + +export type DiagramPrimitive = DiagramBoxPrimitive | DiagramArrowPrimitive; + +/** Built-in accent palette, cycled when a node/diagram sets no fill. */ +export const DEFAULT_DIAGRAM_PALETTE = [ + "#4472C4", + "#ED7D31", + "#A5A5A5", + "#FFC000", + "#5B9BD5", + "#70AD47", +]; + +const DEFAULT_TEXT = "#FFFFFF"; + +interface LayoutCtx { + el: DiagramElement; + w: number; + h: number; + palette: string[]; +} + +function fillFor(ctx: LayoutCtx, node: DiagramNode, i: number): string { + return node.fill ?? ctx.palette[i % ctx.palette.length]; +} + +function textFor(ctx: LayoutCtx, node: DiagramNode): string { + return node.color ?? ctx.el.color ?? DEFAULT_TEXT; +} + +/** + * Compute the laid-out primitives for a diagram. Pure and deterministic — no + * randomness, no time — so renderer and writer agree byte-for-byte. + */ +export function layoutDiagram(el: DiagramElement): DiagramPrimitive[] { + const w = Math.max(1, el.w); + const h = Math.max(1, el.h); + const palette = + el.palette && el.palette.length ? el.palette : DEFAULT_DIAGRAM_PALETTE; + const ctx: LayoutCtx = { el, w, h, palette }; + const nodes = el.nodes ?? []; + if (!nodes.length) return []; + + switch (el.kind) { + case "process": + return rowLayout(ctx, nodes, true); + case "timeline": + return timelineLayout(ctx, nodes); + case "list": + return listLayout(ctx, nodes); + case "funnel": + return funnelLayout(ctx, nodes); + case "matrix": + return matrixLayout(ctx, nodes); + case "cycle": + return cycleLayout(ctx, nodes); + default: + return rowLayout(ctx, nodes, true); + } +} + +/** Horizontal row of boxes; `withArrows` connects consecutive boxes. */ +function rowLayout( + ctx: LayoutCtx, + nodes: DiagramNode[], + withArrows: boolean +): DiagramPrimitive[] { + const n = nodes.length; + const gap = ctx.w * 0.04; + const boxW = (ctx.w - gap * (n - 1)) / n; + const boxH = Math.min(ctx.h, boxW * 0.7); + const top = (ctx.h - boxH) / 2; + const out: DiagramPrimitive[] = []; + for (let i = 0; i < n; i++) { + const x = i * (boxW + gap); + out.push(box(ctx, nodes[i], i, x, top, boxW, boxH, "roundRect")); + } + if (withArrows) { + const cy = top + boxH / 2; + for (let i = 0; i < n - 1; i++) { + const x1 = (i + 1) * boxW + i * gap; + const x2 = x1 + gap; + out.push(arrow(ctx, `${ctx.el.id}-a${i}`, x1, cy, x2, cy)); + } + } + return out; +} + +/** Row of boxes sitting on a horizontal spine line (no per-step arrows). */ +function timelineLayout( + ctx: LayoutCtx, + nodes: DiagramNode[] +): DiagramPrimitive[] { + const cy = ctx.h / 2; + const spine: DiagramArrowPrimitive = { + kind: "arrow", + id: `${ctx.el.id}-spine`, + x1: 0, + y1: cy, + x2: ctx.w, + y2: cy, + arrow: false, + stroke: ctx.palette[0], + }; + const n = nodes.length; + const gap = ctx.w * 0.04; + const boxW = (ctx.w - gap * (n - 1)) / n; + const boxH = Math.min(ctx.h * 0.6, boxW * 0.6); + const top = (ctx.h - boxH) / 2; + const boxes: DiagramPrimitive[] = []; + for (let i = 0; i < n; i++) { + const x = i * (boxW + gap); + boxes.push(box(ctx, nodes[i], i, x, top, boxW, boxH, "roundRect")); + } + // Spine first so the boxes paint on top of it. + return [spine, ...boxes]; +} + +/** Vertical stack of full-width boxes. */ +function listLayout(ctx: LayoutCtx, nodes: DiagramNode[]): DiagramPrimitive[] { + const n = nodes.length; + const gap = ctx.h * 0.04; + const boxH = (ctx.h - gap * (n - 1)) / n; + const out: DiagramPrimitive[] = []; + for (let i = 0; i < n; i++) { + const y = i * (boxH + gap); + out.push(box(ctx, nodes[i], i, 0, y, ctx.w, boxH, "roundRect")); + } + return out; +} + +/** Stacked horizontal bars narrowing toward the bottom (centered). */ +function funnelLayout(ctx: LayoutCtx, nodes: DiagramNode[]): DiagramPrimitive[] { + const n = nodes.length; + const gap = ctx.h * 0.03; + const barH = (ctx.h - gap * (n - 1)) / n; + const out: DiagramPrimitive[] = []; + for (let i = 0; i < n; i++) { + // Top bar full width; each subsequent bar narrower, down to 40%. + const frac = n > 1 ? 1 - (0.6 * i) / (n - 1) : 1; + const barW = ctx.w * frac; + const x = (ctx.w - barW) / 2; + const y = i * (barH + gap); + out.push(box(ctx, nodes[i], i, x, y, barW, barH, "rect")); + } + return out; +} + +/** Grid of boxes (≈ square). */ +function matrixLayout(ctx: LayoutCtx, nodes: DiagramNode[]): DiagramPrimitive[] { + const n = nodes.length; + const cols = Math.ceil(Math.sqrt(n)); + const rows = Math.ceil(n / cols); + const gap = Math.min(ctx.w, ctx.h) * 0.04; + const cellW = (ctx.w - gap * (cols - 1)) / cols; + const cellH = (ctx.h - gap * (rows - 1)) / rows; + const out: DiagramPrimitive[] = []; + for (let i = 0; i < n; i++) { + const col = i % cols; + const row = Math.floor(i / cols); + const x = col * (cellW + gap); + const y = row * (cellH + gap); + out.push(box(ctx, nodes[i], i, x, y, cellW, cellH, "rect")); + } + return out; +} + +/** Boxes arranged around a circle with arrows between consecutive nodes. */ +function cycleLayout(ctx: LayoutCtx, nodes: DiagramNode[]): DiagramPrimitive[] { + const n = nodes.length; + const cx = ctx.w / 2; + const cy = ctx.h / 2; + const ring = Math.min(ctx.w, ctx.h) * 0.35; + const boxW = ctx.w * 0.24; + const boxH = ctx.h * 0.18; + const centers: { x: number; y: number }[] = []; + const boxes: DiagramPrimitive[] = []; + for (let i = 0; i < n; i++) { + const ang = -Math.PI / 2 + (i * 2 * Math.PI) / n; + const px = cx + ring * Math.cos(ang); + const py = cy + ring * Math.sin(ang); + centers.push({ x: px, y: py }); + boxes.push( + box(ctx, nodes[i], i, px - boxW / 2, py - boxH / 2, boxW, boxH, "ellipse") + ); + } + const arrows: DiagramPrimitive[] = []; + if (n > 1) { + for (let i = 0; i < n; i++) { + const a = centers[i]; + const b = centers[(i + 1) % n]; + arrows.push(arrow(ctx, `${ctx.el.id}-a${i}`, a.x, a.y, b.x, b.y)); + } + } + // Arrows under the boxes. + return [...arrows, ...boxes]; +} + +function box( + ctx: LayoutCtx, + node: DiagramNode, + i: number, + x: number, + y: number, + w: number, + h: number, + shape: DiagramBoxPrimitive["shape"] +): DiagramBoxPrimitive { + return { + kind: "box", + id: node.id || `${ctx.el.id}-n${i}`, + x: Math.round(x), + y: Math.round(y), + w: Math.max(1, Math.round(w)), + h: Math.max(1, Math.round(h)), + text: node.text ?? "", + fill: fillFor(ctx, node, i), + textColor: textFor(ctx, node), + shape, + }; +} + +function arrow( + ctx: LayoutCtx, + id: string, + x1: number, + y1: number, + x2: number, + y2: number +): DiagramArrowPrimitive { + return { + kind: "arrow", + id, + x1: Math.round(x1), + y1: Math.round(y1), + x2: Math.round(x2), + y2: Math.round(y2), + arrow: true, + stroke: ctx.el.color ?? "#595959", + }; +} diff --git a/packages/slidewise/src/lib/layouts.ts b/packages/slidewise/src/lib/layouts.ts index 302de39..58e82d9 100644 --- a/packages/slidewise/src/lib/layouts.ts +++ b/packages/slidewise/src/lib/layouts.ts @@ -1,6 +1,7 @@ import { nanoid } from "nanoid"; import type { Deck, + DeckLayout, Slide, SlideElement, TextElement, @@ -27,7 +28,9 @@ export interface AddSlideFromLayoutOptions { * Text to drop into placeholders, keyed by placeholder `type` (e.g. * `"title"`), or `"type:idx"` when a layout repeats a type (e.g. * `"body:1"`), or the bare `idx` as a string. Placeholders without a match - * become empty, editable text boxes. + * become empty, editable text boxes. Only text placeholders are fillable — + * see {@link placeholderKey} for the canonical key of a slot and + * {@link summarizeLayouts} for the fillable keys a layout accepts. */ fills?: Record; /** Slide background; defaults to `"transparent"` so the layout/master @@ -95,6 +98,180 @@ function fillFor( return byTypeIdx ?? fills[ph.type] ?? byIdx ?? ""; } +/** + * The `fills` key that addresses this placeholder most specifically, matching + * the resolution order in `fillFor`: prefer `type:idx`, then bare `type`, then + * the bare index. This is the key a host should pass in + * `AddSlideFromLayoutOptions.fills` to populate the slot deterministically. + */ +export function placeholderKey(ph: LayoutPlaceholder): string { + if (ph.type && ph.idx != null) return `${ph.type}:${ph.idx}`; + if (ph.type) return ph.type; + if (ph.idx != null) return String(ph.idx); + return ""; +} + +/** Coarse content category for a placeholder, for host-side menus. */ +export type PlaceholderCategory = + | "text" + | "picture" + | "table" + | "chart" + | "media" + | "diagram" + | "chrome" + | "other"; + +function categoryFor(type: string): PlaceholderCategory { + if (TEXT_PLACEHOLDER_TYPES.has(type)) return "text"; + switch (type) { + case "pic": + case "clipArt": + return "picture"; + case "tbl": + return "table"; + case "chart": + return "chart"; + case "media": + return "media"; + case "dgm": + return "diagram"; + case "dt": + case "ftr": + case "sldNum": + return "chrome"; + default: + return "other"; + } +} + +/** + * Friendly purpose label per raw OOXML ``. The host can show + * these in a model-facing layout menu instead of the cryptic OOXML tokens. + */ +const ROLE_BY_TYPE: Record = { + title: "Title slide", + ctrTitle: "Title slide", + secHead: "Section header", + obj: "Title and content", + objTx: "Content with caption", + txAndObj: "Content with caption", + objAndTx: "Content with caption", + tx: "Title and text", + twoObj: "Two content", + twoTxTwoObj: "Comparison", + twoObjAndTx: "Comparison", + twoObjAndObj: "Comparison", + objAndTwoObj: "Comparison", + twoColTx: "Two columns of text", + fourObj: "Four content", + titleOnly: "Title only", + blank: "Blank", + pic: "Picture with caption", + picTx: "Picture with caption", + tbl: "Table", + chart: "Chart", + dgm: "Diagram", + clipArt: "Clip art and text", + media: "Media and text", + vertTx: "Vertical text", + vertTitleAndTx: "Vertical title and text", +}; + +/** + * Derive a role label without a `type` attribute by inspecting which kinds of + * placeholder the layout carries. + */ +function roleFromPlaceholders(phs: LayoutPlaceholder[]): string { + const types = new Set(phs.map((p) => p.type)); + const hasTitle = types.has("title") || types.has("ctrTitle"); + const bodyish = phs.filter((p) => + ["body", "obj", "subTitle", ""].includes(p.type) + ).length; + if (types.has("ctrTitle") || types.has("subTitle")) return "Title slide"; + if (hasTitle && bodyish >= 2) return "Two content"; + if (hasTitle && bodyish === 1) return "Title and content"; + if (hasTitle) return "Title only"; + if (phs.length === 0) return "Blank"; + return "Content"; +} + +/** Compact per-placeholder summary for a host-facing layout menu. */ +export interface LayoutSlotSummary { + /** The `fills` key to address this slot (see {@link placeholderKey}). */ + key: string; + /** OOXML placeholder role (`title`, `body`, `pic`, …). */ + type: string; + /** Placeholder index, when the layout disambiguates same-type slots. */ + idx?: number; + /** Coarse content category. */ + category: PlaceholderCategory; + /** + * Whether `addSlideFromLayout` turns this slot into an editable, fillable + * text element. Non-fillable slots (pictures, tables, charts, footer + * chrome) are inherited from the master or supplied by the host as real + * elements. + */ + fillable: boolean; + /** Canvas-px geometry (same coordinate space as `BaseElement`). */ + x: number; + y: number; + w: number; + h: number; +} + +/** Compact per-layout summary for a host-facing (model-facing) layout menu. */ +export interface LayoutSummary { + id: string; + name?: string; + /** Friendly purpose label (e.g. "Title and content", "Section header"). */ + role: string; + /** Raw OOXML ``, when present. */ + type?: string; + /** The `fills` keys this layout accepts, in document order. */ + fillable: string[]; + /** Every placeholder slot, in document order. */ + placeholders: LayoutSlotSummary[]; +} + +/** + * Summarise a deck's instantiable layouts into a compact menu a host can hand + * to a model when choosing which layout to instantiate for each slide. Returns + * a structured shape (not a string) so the host can trim it to its + * context-budget — e.g. drop geometry, or keep only `{id, role, fillable}` — + * before serialising. Pair each chosen `id` + `fills` with + * `addSlideFromLayout`. + * + * Returns `[]` when the deck has no layouts (not parsed from a real PPTX). + */ +export function summarizeLayouts(deck: Deck): LayoutSummary[] { + return (deck.layouts ?? []).map(summarizeLayout); +} + +function summarizeLayout(layout: DeckLayout): LayoutSummary { + const placeholders: LayoutSlotSummary[] = layout.placeholders.map((ph) => ({ + key: placeholderKey(ph), + type: ph.type, + ...(ph.idx != null ? { idx: ph.idx } : {}), + category: categoryFor(ph.type), + fillable: TEXT_PLACEHOLDER_TYPES.has(ph.type), + x: ph.x, + y: ph.y, + w: ph.w, + h: ph.h, + })); + return { + id: layout.id, + ...(layout.name ? { name: layout.name } : {}), + role: + (layout.type ? ROLE_BY_TYPE[layout.type] : undefined) ?? + roleFromPlaceholders(layout.placeholders), + ...(layout.type ? { type: layout.type } : {}), + fillable: placeholders.filter((p) => p.fillable).map((p) => p.key), + placeholders, + }; +} + function placeholderToText( ph: LayoutPlaceholder, text: string, diff --git a/packages/slidewise/src/lib/pptx/__tests__/crop-radius-runs.test.ts b/packages/slidewise/src/lib/pptx/__tests__/crop-radius-runs.test.ts new file mode 100644 index 0000000..986837f --- /dev/null +++ b/packages/slidewise/src/lib/pptx/__tests__/crop-radius-runs.test.ts @@ -0,0 +1,191 @@ +import { describe, it, expect } from "vitest"; +import { parsePptx, serializeDeck } from "../index"; +import { CURRENT_DECK_VERSION } from "@/lib/schema/migrate"; +import type { Deck, ImageElement, TextElement } from "@/lib/types"; + +/** + * P4 confirmations: + * - Image `crop` (``) and `radius` (rounded-corner geometry) now + * round-trip — previously `crop` was parsed on import but silently dropped + * on export, and `radius` was neither parsed nor written. + * - Rich text `runs` keep their per-run styling through serialize → parse + * (the host fills text without flattening existing runs). + */ + +// 1×1 transparent PNG. +const PNG_1x1 = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + +function deckWith(elements: Deck["slides"][number]["elements"]): Deck { + return { + version: CURRENT_DECK_VERSION, + title: "P4 fixture", + slides: [{ id: "s1", background: "#FFFFFF", elements }], + }; +} + +async function roundtrip(deck: Deck): Promise { + const blob = await serializeDeck(deck); + return parsePptx(await blob.arrayBuffer()); +} + +describe("P4: image crop + radius round-trip", () => { + it("preserves crop fractions and corner radius through serialize → parse", async () => { + const img: ImageElement = { + id: "img1", + type: "image", + x: 200, + y: 160, + w: 400, + h: 300, + rotation: 0, + z: 1, + src: PNG_1x1, + fit: "fill", + crop: { l: 0.1, t: 0.05, r: 0.2, b: 0.15 }, + radius: 24, + }; + + const out = await roundtrip(deckWith([img])); + const parsed = out.slides[0].elements.find( + (e): e is ImageElement => e.type === "image" + ); + expect(parsed).toBeTruthy(); + + // Crop survives (was dropped on write before the fix). + expect(parsed!.crop).toBeTruthy(); + expect(parsed!.crop!.l).toBeCloseTo(0.1, 4); + expect(parsed!.crop!.t).toBeCloseTo(0.05, 4); + expect(parsed!.crop!.r).toBeCloseTo(0.2, 4); + expect(parsed!.crop!.b).toBeCloseTo(0.15, 4); + + // Radius survives (neither parsed nor written before the fix). + // 24px on a 300px shorter side → adj 0.08 → back to 24px. + expect(parsed!.radius).toBe(24); + + // Geometry survives (within EMU rounding). + expect(parsed!.w).toBeGreaterThanOrEqual(399); + expect(parsed!.w).toBeLessThanOrEqual(401); + }); + + it("leaves a plain (uncropped, square) image on the pptxgenjs path", async () => { + const img: ImageElement = { + id: "img2", + type: "image", + x: 0, + y: 0, + w: 200, + h: 200, + rotation: 0, + z: 1, + src: PNG_1x1, + fit: "contain", + }; + const out = await roundtrip(deckWith([img])); + const parsed = out.slides[0].elements.find( + (e): e is ImageElement => e.type === "image" + ); + expect(parsed).toBeTruthy(); + expect(parsed!.crop).toBeUndefined(); + expect(parsed!.radius).toBeUndefined(); + }); +}); + +describe("P4: rich text runs are not flattened", () => { + it("keeps per-run styling through serialize → parse", async () => { + const text: TextElement = { + id: "t1", + type: "text", + x: 100, + y: 100, + w: 1200, + h: 200, + rotation: 0, + z: 1, + text: "Hello world", + fontFamily: "Inter", + fontSize: 40, + fontWeight: 400, + italic: false, + underline: false, + strike: false, + color: "#000000", + align: "left", + vAlign: "top", + lineHeight: 1.2, + letterSpacing: 0, + runs: [ + { + text: "Hello ", + fontWeight: 700, + color: "#FF0000", + fontFamily: "Inter", + fontSize: 40, + }, + { + text: "world", + italic: true, + color: "#0000FF", + fontFamily: "Inter", + fontSize: 40, + }, + ], + }; + + const out = await roundtrip(deckWith([text])); + const parsed = out.slides[0].elements.find( + (e): e is TextElement => e.type === "text" + ); + expect(parsed).toBeTruthy(); + // Mixed formatting → the importer keeps distinct runs (not one flat run). + expect(parsed!.runs && parsed!.runs.length).toBeGreaterThanOrEqual(2); + const joined = (parsed!.runs ?? []).map((r) => r.text).join(""); + expect(joined).toBe("Hello world"); + const bold = parsed!.runs!.find((r) => r.text.startsWith("Hello")); + const ital = parsed!.runs!.find((r) => r.text.includes("world")); + expect(bold!.color?.toUpperCase()).toBe("#FF0000"); + expect(bold!.fontWeight).toBeGreaterThanOrEqual(600); + expect(ital!.color?.toUpperCase()).toBe("#0000FF"); + expect(ital!.italic).toBe(true); + }); + + it("preserves per-run letter-case (cap) through serialize → parse", async () => { + const text: TextElement = { + id: "t2", + type: "text", + x: 100, + y: 100, + w: 1200, + h: 200, + rotation: 0, + z: 1, + text: "Loud quiet", + fontFamily: "Inter", + fontSize: 40, + fontWeight: 400, + italic: false, + underline: false, + strike: false, + color: "#000000", + align: "left", + vAlign: "top", + lineHeight: 1.2, + letterSpacing: 0, + runs: [ + { text: "Loud ", cap: "all", fontFamily: "Inter", fontSize: 40 }, + { text: "quiet", cap: "small", fontFamily: "Inter", fontSize: 40 }, + ], + }; + + const out = await roundtrip(deckWith([text])); + const parsed = out.slides[0].elements.find( + (e): e is TextElement => e.type === "text" + ); + expect(parsed).toBeTruthy(); + const loud = parsed!.runs?.find((r) => r.text.startsWith("Loud")); + const quiet = parsed!.runs?.find((r) => r.text.includes("quiet")); + // cap was dropped on write before the fix (pptxgenjs has no cap option). + expect(loud!.cap).toBe("all"); + expect(quiet!.cap).toBe("small"); + }); +}); diff --git a/packages/slidewise/src/lib/pptx/__tests__/diagram.test.ts b/packages/slidewise/src/lib/pptx/__tests__/diagram.test.ts new file mode 100644 index 0000000..0998bfc --- /dev/null +++ b/packages/slidewise/src/lib/pptx/__tests__/diagram.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from "vitest"; +import JSZip from "jszip"; +import { parsePptx, serializeDeck } from "../index"; +import { CURRENT_DECK_VERSION } from "@/lib/schema/migrate"; +import type { Deck, DiagramElement, GroupElement } from "@/lib/types"; + +/** + * P3 / F3: a first-class diagram serialises to a single labelled `` of + * real shapes + connectors — editable & grouped in PowerPoint, not a flat pile + * of anonymous shapes. On re-import the grouped structure round-trips as a + * GroupElement (we don't write SmartArt metadata, so it doesn't return as a + * DiagramElement — but it stays a cohesive, editable group). + */ + +function deckWith(el: DiagramElement): Deck { + return { + version: CURRENT_DECK_VERSION, + title: "Diagram", + slides: [{ id: "s1", background: "#FFFFFF", elements: [el] }], + }; +} + +const process: DiagramElement = { + id: "d1", + type: "diagram", + x: 120, + y: 200, + w: 1600, + h: 320, + rotation: 0, + z: 1, + kind: "process", + nodes: [ + { id: "n0", text: "Plan" }, + { id: "n1", text: "Build" }, + { id: "n2", text: "Ship" }, + ], +}; + +async function slide1Xml(deck: Deck): Promise { + const blob = await serializeDeck(deck); + const zip = await JSZip.loadAsync(await blob.arrayBuffer()); + return (await zip.file("ppt/slides/slide1.xml")?.async("string")) ?? ""; +} + +describe("P3: diagram primitive", () => { + it("emits one grouped with a labelled box per node and arrows", async () => { + const xml = await slide1Xml(deckWith(process)); + expect(xml).toContain(""); + // One labelled shape per node, with the node text. + expect((xml.match(//g) ?? []).length).toBe(3); + expect(xml).toContain("Plan"); + expect(xml).toContain("Build"); + expect(xml).toContain("Ship"); + // Two connectors (between the three boxes), with an arrowhead. + expect((xml.match(//g) ?? []).length).toBe(2); + expect(xml).toContain('prst="straightConnector1"'); + expect(xml).toContain(''); + // The group frame sits at the element position (120px,200px → EMU). + expect(xml).toContain(`x="${120 * 6350}"`); + // Child coordinate frame is local (chOff 0,0). + expect(xml).toContain(''); + }); + + it("round-trips to an editable group carrying the node labels", async () => { + const blob = await serializeDeck(deckWith(process)); + const reparsed = await parsePptx(await blob.arrayBuffer()); + const els = reparsed.slides[0].elements; + const group = els.find((e): e is GroupElement => e.type === "group"); + expect(group).toBeTruthy(); + // The three labelled boxes survive as children with their text. + const texts = JSON.stringify(group); + expect(texts).toContain("Plan"); + expect(texts).toContain("Build"); + expect(texts).toContain("Ship"); + }); + + it("matrix diagram emits a box per node and no connectors", async () => { + const xml = await slide1Xml( + deckWith({ + ...process, + kind: "matrix", + nodes: [ + { id: "a", text: "A" }, + { id: "b", text: "B" }, + { id: "c", text: "C" }, + { id: "d", text: "D" }, + ], + }) + ); + expect((xml.match(//g) ?? []).length).toBe(4); + expect(xml).not.toContain(""); + }); +}); diff --git a/packages/slidewise/src/lib/pptx/__tests__/instantiable-layouts.test.ts b/packages/slidewise/src/lib/pptx/__tests__/instantiable-layouts.test.ts index 32199e4..5f42b03 100644 --- a/packages/slidewise/src/lib/pptx/__tests__/instantiable-layouts.test.ts +++ b/packages/slidewise/src/lib/pptx/__tests__/instantiable-layouts.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect } from "vitest"; import JSZip from "jszip"; -import { parsePptx, serializeDeck, addSlideFromLayout } from "../../../index"; +import { + parsePptx, + serializeDeck, + addSlideFromLayout, + summarizeLayouts, +} from "../../../index"; /** * F1: master layouts are exposed as instantiable templates (Deck.layouts), and @@ -71,6 +76,80 @@ function sourceWithLayout(): JSZip { return zip; } +/** + * A source with two distinct layouts — a "title" cover (ctrTitle + subTitle) + * and an "obj" content layout (title + body) — so instantiated slides must be + * routed to the correct, per-layout chrome. + */ +function sourceWithTwoLayouts(): JSZip { + const zip = new JSZip(); + zip.file( + "[Content_Types].xml", + ` + + + + + + + + + +` + ); + zip.file( + "_rels/.rels", + `` + ); + zip.file( + "ppt/presentation.xml", + `` + ); + zip.file( + "ppt/_rels/presentation.xml.rels", + `` + ); + zip.file( + "ppt/slides/slide1.xml", + `` + ); + zip.file( + "ppt/slides/_rels/slide1.xml.rels", + `` + ); + // slideLayout1: a title cover (ctrTitle + subTitle). + zip.file( + "ppt/slideLayouts/slideLayout1.xml", + `` + ); + zip.file( + "ppt/slideLayouts/_rels/slideLayout1.xml.rels", + `` + ); + // slideLayout2: title + content. + zip.file( + "ppt/slideLayouts/slideLayout2.xml", + `` + ); + zip.file( + "ppt/slideLayouts/_rels/slideLayout2.xml.rels", + `` + ); + zip.file( + "ppt/slideMasters/slideMaster1.xml", + `` + ); + zip.file( + "ppt/slideMasters/_rels/slideMaster1.xml.rels", + `` + ); + zip.file( + "ppt/theme/theme1.xml", + `` + ); + return zip; +} + describe("F1: instantiable layouts", () => { it("exposes layouts with placeholder geometry + style", async () => { const source = await sourceWithLayout().generateAsync({ @@ -136,4 +215,126 @@ describe("F1: instantiable layouts", () => { const deck = await parsePptx(source); expect(() => addSlideFromLayout(deck, "nope")).toThrow(/no layout/); }); + + it("captures the layout role type and summarises layouts for selection", async () => { + const source = await sourceWithLayout().generateAsync({ + type: "arraybuffer", + }); + const deck = await parsePptx(source); + + // Raw OOXML role survives the import. + expect(deck.layouts![0].type).toBe("obj"); + + const menu = summarizeLayouts(deck); + expect(menu.length).toBe(1); + const [layout] = menu; + expect(layout.id).toBe("slideLayout1"); + expect(layout.name).toBe("Title and Content"); + expect(layout.type).toBe("obj"); + expect(layout.role).toBe("Title and content"); + // Both placeholders are text → fillable; keys match the `fills` contract. + expect(layout.fillable).toEqual(["title", "body:1"]); + const body = layout.placeholders.find((p) => p.type === "body")!; + expect(body.key).toBe("body:1"); + expect(body.idx).toBe(1); + expect(body.category).toBe("text"); + expect(body.fillable).toBe(true); + // Geometry comes along for layout-menu rendering. + expect(body.y).toBe(270); + }); +}); + +describe("F1: instantiated slides carry their layout's chrome", () => { + it("points each instantiated slide at its own layout and drops the flat bg", async () => { + const source = await sourceWithTwoLayouts().generateAsync({ + type: "arraybuffer", + }); + const deck = await parsePptx(source); + expect(deck.layouts!.map((l) => l.id)).toEqual([ + "slideLayout1", + "slideLayout2", + ]); + + // Roles distinguish the two layouts for a model menu. + const menu = summarizeLayouts(deck); + expect(menu.find((l) => l.id === "slideLayout1")!.role).toBe("Title slide"); + expect(menu.find((l) => l.id === "slideLayout2")!.role).toBe( + "Title and content" + ); + + // Instantiate a fresh slide from EACH layout (the 35-slide-scale use case: + // more slides than the template hand-authored, using its own variety). + let next = addSlideFromLayout(deck, "slideLayout1", { + fills: { ctrTitle: "Cover" }, + }); + next = addSlideFromLayout(next, "slideLayout2", { + fills: { title: "Agenda", "body:1": "Points" }, + }); + // [source, fromLayout1, fromLayout2] → output slide1 / slide2 / slide3. + expect(next.slides.length).toBe(3); + + const blob = await serializeDeck(next, { source }); + const out = await JSZip.loadAsync(await blob.arrayBuffer()); + + // Each instantiated slide's rels point at the SOURCE layout part it was + // minted from — not a shared default, not output position. + const rels2 = await out + .file("ppt/slides/_rels/slide2.xml.rels")! + .async("string"); + const rels3 = await out + .file("ppt/slides/_rels/slide3.xml.rels")! + .async("string"); + expect(rels2).toMatch(/slideLayouts\/slideLayout1\.xml/); + expect(rels2).not.toMatch(/slideLayout2\.xml/); + expect(rels3).toMatch(/slideLayouts\/slideLayout2\.xml/); + expect(rels3).not.toMatch(/slideLayout1\.xml/); + + // pptxgenjs's opaque flat-hex is stripped so the layout/master/theme + // background shows through (transparent default = inherited chrome). + const slide2 = await out.file("ppt/slides/slide2.xml")!.async("string"); + const slide3 = await out.file("ppt/slides/slide3.xml")!.async("string"); + expect(slide2).not.toMatch(/ { + const source = await sourceWithTwoLayouts().generateAsync({ + type: "arraybuffer", + }); + const deck = await parsePptx(source); + const next = addSlideFromLayout(deck, "slideLayout2", { + background: "#FF0000", + fills: { title: "Solid" }, + }); + const blob = await serializeDeck(next, { source }); + const out = await JSZip.loadAsync(await blob.arrayBuffer()); + // Host asked for an opaque fill, so the bg is kept (not stripped to inherit). + const slide2 = await out.file("ppt/slides/slide2.xml")!.async("string"); + expect(slide2).toMatch(/` + ); +} + +/** A 16:10 source (9144000 × 5715000 EMU = 10 × 6.25 in). */ +function sixteenTenSource(): JSZip { + return sizedSource( + `` + ); +} + +/** + * Same one-slide template, with a caller-supplied `` fragment so we + * can exercise different aspect ratios (and an unreadable size for the + * chrome-skipped diagnostic). + */ +function sizedSource(sldSzXml: string): JSZip { const zip = new JSZip(); zip.file( "[Content_Types].xml", @@ -41,7 +59,7 @@ function fourThreeSource(): JSZip { ); zip.file( "ppt/presentation.xml", - `` + `${sldSzXml}` ); zip.file( "ppt/_rels/presentation.xml.rels", @@ -154,4 +172,52 @@ describe("B3: non-16:9 source", () => { expect(slide1).toContain('cx="1828800"'); expect(slide1).not.toContain('x="1524000"'); }); + + it("preserves chrome and emits the 16:10 slide size", async () => { + const source = await sixteenTenSource().generateAsync({ + type: "arraybuffer", + }); + const deck = await parsePptx(source); + const warnings: { code: string }[] = []; + const blob = await serializeDeck(deck, { + source, + onWarning: (w) => warnings.push(w), + }); + const out = await load(blob); + + const pres = await out.file("ppt/presentation.xml")!.async("string"); + expect(pres).toMatch(/]*cx="9144000"/); + expect(pres).toMatch(/]*cy="5715000"/); + expect(pres).not.toContain('cx="12192000"'); + + const paths = Object.keys(out.files); + expect(paths).toContain("ppt/slideMasters/slideMaster1.xml"); + expect(paths).toContain("ppt/slideLayouts/slideLayout1.xml"); + expect(paths).toContain("ppt/theme/theme1.xml"); + + // A matchable aspect ratio means chrome was preserved — no diagnostic. + expect(warnings.some((w) => w.code === "chrome-skipped")).toBe(false); + }); + + it("reports a machine-readable chrome-skipped warning when the source size is unreadable", async () => { + // Valid deck, but a source whose can't be parsed → the chrome + // preserve can't match aspect ratios and bails to generic chrome. The host + // gets a structured warning instead of only a console line. + const deck = await parsePptx( + await fourThreeSource().generateAsync({ type: "arraybuffer" }) + ); + const brokenSource = await sizedSource( + `` + ).generateAsync({ type: "arraybuffer" }); + + const warnings: { code: string; message: string }[] = []; + await serializeDeck(deck, { + source: brokenSource, + onWarning: (w) => warnings.push(w), + }); + + const skipped = warnings.find((w) => w.code === "chrome-skipped"); + expect(skipped).toBeTruthy(); + expect(skipped!.message).toMatch(/generic chrome/); + }); }); diff --git a/packages/slidewise/src/lib/pptx/deckToPptx.ts b/packages/slidewise/src/lib/pptx/deckToPptx.ts index f873fe3..681e6e3 100644 --- a/packages/slidewise/src/lib/pptx/deckToPptx.ts +++ b/packages/slidewise/src/lib/pptx/deckToPptx.ts @@ -16,6 +16,7 @@ import type { ChartElement, ConnectorElement, GroupElement, + DiagramElement, UnknownElement, } from "@/lib/types"; import { pxToInches, pxToPoints } from "./units"; @@ -38,6 +39,8 @@ import { synthesiseGroup, synthesiseChart, synthesiseConnector, + synthesiseImage, + synthesiseDiagram, synthesiseSlideBg, synthesiseEmbeddedFonts, effectLstXml, @@ -86,6 +89,35 @@ export interface SerializeOptions { * `.potx`; pass `true`/`false` to force the output kind. */ asTemplate?: boolean; + /** + * Machine-readable diagnostics sink. Invoked (zero or more times) during + * serialization when something degrades the output in a way the host may + * want to surface — most importantly `"chrome-skipped"`, emitted when a + * `source` template's chrome (masters / layouts / theme / fonts) could not + * be carried over because its slide size was unreadable, so the deck falls + * back to generic pptxgenjs chrome. Lets a host detect the degradation + * instead of only seeing it in the console. + */ + onWarning?: (warning: SerializeWarning) => void; +} + +/** A non-fatal serialization diagnostic delivered to `SerializeOptions.onWarning`. */ +export interface SerializeWarning { + /** + * - `"chrome-skipped"` — the source template's chrome was not preserved + * (slide size unreadable); the deck uses generic chrome. + * - `"element-write-failed"` — a single element threw while being written + * and was skipped; the rest of the slide is intact. + */ + code: "chrome-skipped" | "element-write-failed"; + /** Human-readable explanation (also logged to the console). */ + message: string; + /** Output slide index, when the warning is element-scoped. */ + slideIndex?: number; + /** Element id, when the warning is element-scoped. */ + elementId?: string; + /** Element type, when the warning is element-scoped. */ + elementType?: string; } /** @@ -112,13 +144,25 @@ interface SynthSlideEntry { media: MediaPayload[]; /** Effect XML to splice into pptxgenjs-emitted shapes by `cNvPr.name`. */ effectsByName: Map; + /** + * Per-run letter-case (``) to splice into a pptxgenjs-emitted + * text shape by `cNvPr.name`. pptxgenjs has no `cap` option, so it's applied + * in post-process. One entry per emitted run (``), in document order; + * `null` leaves that run untouched. + */ + capRunsByName: Map; } const synthBySlide = new Map(); function synthForSlide(i: number): SynthSlideEntry { let e = synthBySlide.get(i); if (!e) { - e = { items: [], media: [], effectsByName: new Map() }; + e = { + items: [], + media: [], + effectsByName: new Map(), + capRunsByName: new Map(), + }; synthBySlide.set(i, e); } return e; @@ -143,7 +187,7 @@ export async function serializeDeck( applyLayout(pptx, transform); for (let i = 0; i < deck.slides.length; i++) { - addSlide(pptx, deck.slides[i], i, transform); + addSlide(pptx, deck.slides[i], i, transform, options.onWarning); } // Use arraybuffer (universal: works in Node + browser, accepted by JSZip @@ -151,14 +195,21 @@ export async function serializeDeck( const generated = (await pptx.write({ outputType: "arraybuffer", })) as ArrayBuffer; - return preserveUnknowns(generated, deck, sourceBuffer, options.asTemplate); + return preserveUnknowns( + generated, + deck, + sourceBuffer, + options.asTemplate, + options.onWarning + ); } function addSlide( pptx: pptxgen, slide: Slide, slideIndex: number, - transform: SerializeTransform + transform: SerializeTransform, + onWarning?: (warning: SerializeWarning) => void ): void { const s = pptx.addSlide(); // pptxgenjs only understands flat-hex slide backgrounds. For richer forms @@ -193,10 +244,15 @@ function addSlide( // node (chart-with-ooxml and unknown are no-ops handled elsewhere). if (emitsPptxgenjsNode(el)) lastNodeId = el.id; } catch (err) { - console.warn( - `[slidewise/pptx] failed to write element ${el.id} (${el.type}):`, - err - ); + const message = `[slidewise/pptx] failed to write element ${el.id} (${el.type}): ${String(err)}`; + console.warn(message, err); + onWarning?.({ + code: "element-write-failed", + message, + slideIndex, + elementId: el.id, + elementType: el.type, + }); } } } @@ -336,6 +392,9 @@ function untransformElement( } else if (next.type === "connector") { const c = next as ConnectorElement; c.strokeWidth = c.strokeWidth / t.scale; + } else if (next.type === "diagram") { + const d = next as DiagramElement; + if (d.fontSize != null) d.fontSize = d.fontSize / t.scale; } } return next; @@ -358,10 +417,28 @@ function shouldSynthesise(el: SlideElement): boolean { } if (el.type === "group") return true; if (el.type === "connector") return true; + if (el.type === "diagram") return true; if (el.type === "chart" && !el.ooxmlXml) return true; + // A cropped / rounded image needs a hand-written ``: pptxgenjs's + // sizing emits its own `` (fighting a user crop) and can't express + // a corner radius. Only inlineable data-URL sources take this path; remote + // URLs keep pptxgenjs's (crop/radius is dropped for those — see addImage). + if ( + el.type === "image" && + (hasCrop(el.crop) || (el.radius ?? 0) > 0) && + isDataUrl(el.src) + ) { + return true; + } return false; } +function hasCrop(crop: ImageElement["crop"] | undefined): boolean { + return ( + !!crop && (crop.l > 0 || crop.r > 0 || crop.t > 0 || crop.b > 0) + ); +} + function synthesiseInto( synth: SynthSlideEntry, el: SlideElement, @@ -396,6 +473,20 @@ function synthesiseInto( synth.items.push({ kind: "chart", result: out, after }); return; } + if (el.type === "image") { + const out = synthesiseImage(el as ImageElement); + if (out) { + synth.items.push({ kind: "shape", xml: out.xml, after }); + for (const m of out.media) synth.media.push(m); + } + return; + } + if (el.type === "diagram") { + const out = synthesiseDiagram(el as DiagramElement); + synth.items.push({ kind: "shape", xml: out.xml, after }); + for (const m of out.media) synth.media.push(m); + return; + } if (el.type === "connector") { const out = synthesiseConnector(el as ConnectorElement); synth.items.push({ kind: "shape", xml: out.xml, after }); @@ -577,10 +668,15 @@ function addText( // "\n" is split so each piece becomes its own paragraph (we use a per-run // `breakLine` flag on the trailing pieces). const items: pptxgen.TextProps[] = []; + // One cap entry per emitted run (pptxgenjs emits one `` per item), so + // the post-process splice can re-apply `` positionally — pptxgenjs + // drops the case transform otherwise. + const caps: (("all" | "small") | null)[] = []; for (const r of el.runs) { const pieces = r.text.split("\n"); for (let i = 0; i < pieces.length; i++) { const isLast = i === pieces.length - 1; + caps.push(r.cap ?? null); items.push({ text: pieces[i], options: { @@ -602,6 +698,9 @@ function addText( }); } } + if (caps.some((c) => c != null)) { + synth.capRunsByName.set(slidewiseShapeName(el.id), caps); + } s.addText(items, baseOpts); } @@ -864,7 +963,8 @@ async function preserveUnknowns( generated: ArrayBuffer, deck: Deck, explicitSource?: Blob | ArrayBuffer | Uint8Array, - asTemplate?: boolean + asTemplate?: boolean, + onWarning?: (warning: SerializeWarning) => void ): Promise { // Prefer the caller-supplied source (survives state cloning / localStorage // rehydrate); fall back to the non-enumerable attachment from parsePptx @@ -974,7 +1074,7 @@ async function preserveUnknowns( // on the master/layout disappears on save. Best-effort: bails when source // and output slide size don't match so 4:3 sources don't get their // masters stretched onto a 16:9 canvas. - await preserveDeckChrome(outZip, srcZip, deck, sourceSlidePaths); + await preserveDeckChrome(outZip, srcZip, deck, sourceSlidePaths, onWarning); // Per-slide `` preservation. pptxgenjs's slide.background only // emits solid colors, so gradient / image / theme-referenced @@ -1683,17 +1783,19 @@ async function preserveDeckChrome( outZip: JSZip, srcZip: JSZip, deck: Deck, - sourceSlidePaths: string[] + sourceSlidePaths: string[], + onWarning?: (warning: SerializeWarning) => void ): Promise { if (!(await aspectRatiosMatch(outZip, srcZip))) { // We size the output slide from the source (see computeSerializeTransform), // so ratios should match for any parseable source. If we still land here // the source's was unreadable — surface it rather than silently // shipping a generic-looking deck stripped of the template's chrome. - console.warn( + const message = "[slidewise/pptx] source slide size could not be matched; skipping " + - "master/layout/theme/font preservation (deck will use generic chrome)." - ); + "master/layout/theme/font preservation (deck will use generic chrome)."; + console.warn(message); + onWarning?.({ code: "chrome-skipped", message }); return; } @@ -2527,6 +2629,11 @@ async function applySynth(outZip: JSZip, deck: Deck): Promise { slideXml = spliceEffectsByName(slideXml, synth.effectsByName); } + // Re-apply per-run letter-case (``) pptxgenjs can't emit. + if (synth.capRunsByName.size) { + slideXml = spliceRunCapsByName(slideXml, synth.capRunsByName); + } + if (newRelLines.length) { const insertAt = relsXml.lastIndexOf(""); relsXml = @@ -2595,6 +2702,37 @@ function spliceEffectsByName( ); } +/** + * Re-apply per-run letter-case (``) into each + * pptxgenjs-emitted text `` whose `` matches a key. The + * value is one cap per emitted run (``) in document order — pptxgenjs has + * no `cap` option, so the importer's `TextRun.cap` would otherwise be lost on + * export. Runs already carry an `` (our run items are styled); the cap + * attribute is inserted into it (one is added if somehow absent). + */ +function spliceRunCapsByName( + slideXml: string, + capRunsByName: Map +): string { + return slideXml.replace(//g, (sp: string) => { + const nameMatch = /]*?name="([^"]*)"/.exec(sp); + if (!nameMatch || !nameMatch[1]) return sp; + const caps = capRunsByName.get(nameMatch[1]); + if (!caps || !caps.length) return sp; + let k = 0; + return sp.replace(/[\s\S]*?<\/a:r>/g, (run: string) => { + const cap = caps[k++]; + if (!cap) return run; + if (/]*\bcap=/.test(run)) return run; // already set + if (//, ``); + }); + }); +} + /** * Write JSON-defined gradient / image slide backgrounds (PR 3). Only fires * when the source-PPTX bg preservation pass left the slide's `` empty @@ -2609,6 +2747,12 @@ async function applySynthSlideBackgrounds( const parsed = parseFill(slide.background); if (!parsed) continue; if (parsed.kind === "solid") continue; + // A layout-instantiated slide with an inherited (transparent) background + // must stay ``-less so the layout / master / theme background shows + // through. `preserveSlideBackgrounds` already stripped pptxgenjs's bg for + // this slide; synthesising an explicit `` here would re-assert + // an empty background and override the inheritance the host is relying on. + if (parsed.kind === "transparent" && slide.sourceLayoutId) continue; const slidePath = `ppt/slides/slide${i + 1}.xml`; const relsPath = `ppt/slides/_rels/slide${i + 1}.xml.rels`; const slideFile = outZip.file(slidePath); diff --git a/packages/slidewise/src/lib/pptx/index.ts b/packages/slidewise/src/lib/pptx/index.ts index 25dc8ae..8e71bee 100644 --- a/packages/slidewise/src/lib/pptx/index.ts +++ b/packages/slidewise/src/lib/pptx/index.ts @@ -1,3 +1,4 @@ export { parsePptx, isPptxTemplate } from "./pptxToDeck"; export { serializeDeck } from "./deckToPptx"; +export type { SerializeOptions, SerializeWarning } from "./deckToPptx"; export type { ParseDiagnostics, ParseResult } from "./types"; diff --git a/packages/slidewise/src/lib/pptx/pptxToDeck.ts b/packages/slidewise/src/lib/pptx/pptxToDeck.ts index 769bd3d..50ef36d 100644 --- a/packages/slidewise/src/lib/pptx/pptxToDeck.ts +++ b/packages/slidewise/src/lib/pptx/pptxToDeck.ts @@ -1882,6 +1882,23 @@ async function parsePic( const hasCrop = crop && (crop.l > 0 || crop.r > 0 || crop.t > 0 || crop.b > 0); + // Rounded-corner picture: ``'s `adj` is a + // fraction (1000ths of a percent) of the shorter side. Convert back to a + // canvas-px radius so the renderer + writer can reproduce it. + const picGeom = pic?.["p:spPr"]?.["a:prstGeom"]; + let radius: number | undefined; + if (picGeom?.["@_prst"] === "roundRect") { + const adj = asArray(picGeom?.["a:avLst"]?.["a:gd"]).find( + (g: any) => g?.["@_name"] === "adj" + ); + const fmla = typeof adj?.["@_fmla"] === "string" ? adj["@_fmla"] : ""; + const m = /val\s+(\d+)/.exec(fmla); + // Default roundRect adj is 16667 (~1/6) when avLst omits it. + const frac = (m ? Number(m[1]) : 16667) / 100000; + const r = Math.round(frac * Math.min(geom.w, geom.h)); + if (r > 0) radius = r; + } + const image: ImageElement = { id: nanoid(8), type: "image", @@ -1890,6 +1907,7 @@ async function parsePic( src: `data:${mime};base64,${base64}`, fit: fitMode, ...(hasCrop ? { crop } : {}), + ...(radius ? { radius } : {}), }; return image; } @@ -2979,6 +2997,9 @@ async function parseLayouts(zip: JSZip, fit: Fit): Promise { ...(typeof layoutRoot?.["p:cSld"]?.["@_name"] === "string" ? { name: layoutRoot["p:cSld"]["@_name"] as string } : {}), + ...(typeof layoutRoot?.["@_type"] === "string" + ? { type: layoutRoot["@_type"] as string } + : {}), placeholders, sourcePath: path, }); diff --git a/packages/slidewise/src/lib/pptx/pptxWriters.ts b/packages/slidewise/src/lib/pptx/pptxWriters.ts index a5b1bec..8d3c745 100644 --- a/packages/slidewise/src/lib/pptx/pptxWriters.ts +++ b/packages/slidewise/src/lib/pptx/pptxWriters.ts @@ -25,13 +25,20 @@ import type { GroupElement, ChartElement, ConnectorElement, + ImageElement, + DiagramElement, ArrowheadKind, ShadowSpec, GlowSpec, FontAsset, Slide, } from "@/lib/types"; -import { pxToEmu, EMU_PER_POINT } from "./units"; +import { + layoutDiagram, + type DiagramBoxPrimitive, + type DiagramArrowPrimitive, +} from "@/lib/diagram/layout"; +import { pxToEmu, pxToPoints, EMU_PER_POINT } from "./units"; // -- Identifiers ------------------------------------------------------------ @@ -856,6 +863,182 @@ export function synthesiseConnector(el: ConnectorElement): { return { xml, media: [] }; } +// -- Image with crop / rounded corners -------------------------------------- + +/** + * Emit a synthesised `` for an image that carries a `crop` + * (``) and/or `radius` (rounded-corner `roundRect` geometry) — the + * forms pptxgenjs's `addImage` can't express (its `cover`/`contain` sizing + * emits its *own* ``, which would fight a user crop). Bypassing + * pptxgenjs for these keeps `crop` / `radius` round-tripping faithfully. The + * image bytes are returned as a media payload with a marker rId the + * orchestrator rewrites. Returns `null` when the source isn't an inlineable + * data URL (remote URLs keep the pptxgenjs path). + */ +export function synthesiseImage(el: ImageElement): SynthShapeResult | null { + const media = mediaFromUrl(el.src, `pic_${el.id}`); + if (!media) return null; + const id = freshNvId(); + const name = slidewiseShapeName(el.id); + const ridMarker = ridMarkerFor(`pic_${el.id}`, 0); + + // `` chops a fraction off each edge; OOXML values are 1000ths of + // a percent (100% = 100000), matching the importer's `/100000` decode. + const srcRect = cropSrcRectXml(el.crop); + // `fill` stretches the (already-cropped) image across the box; cover/contain + // are approximated by stretch here since the crop has been applied explicitly. + // The picture fill wrapper for `` is `` (presentationml), + // not `` — its inner blip / srcRect / stretch stay in `a:`. + const fill = + `` + + srcRect + + ``; + const geom = imageGeometryXml(el.radius, el.w, el.h); + const xfrm = xfrmXml(el.x, el.y, el.w, el.h, el.rotation); + + const xml = + `` + + `` + + `` + + `` + + `` + + `` + + fill + + `${xfrm}${geom}` + + ``; + return { xml, media: [media] }; +} + +function cropSrcRectXml( + crop: ImageElement["crop"] | undefined +): string { + if (!crop) return ""; + const pct = (v: number) => Math.round(Math.max(0, Math.min(1, v)) * 100000); + const l = pct(crop.l); + const t = pct(crop.t); + const r = pct(crop.r); + const b = pct(crop.b); + if (!l && !t && !r && !b) return ""; + return ``; +} + +function imageGeometryXml( + radius: number | undefined, + w: number, + h: number +): string { + if (!radius || radius <= 0) { + return ``; + } + // roundRect `adj` is a fraction of the shorter side (ST_Percentage, 1000ths + // of a percent), capped at 50% (a full pill). + const frac = Math.min(0.5, radius / Math.max(1, Math.min(w, h))); + const adj = Math.round(frac * 100000); + return ``; +} + +// -- Diagram (process / timeline / funnel / matrix / cycle / list) ---------- + +const DIAGRAM_PRESET: Record = { + rect: "rect", + roundRect: "roundRect", + ellipse: "ellipse", +}; + +/** + * Emit a `` for a diagram: one labelled `` per node plus + * `` arrows, laid out by `layoutDiagram` (shared with the renderer). + * The group's external frame is the element box (`off=el.x,el.y` `ext=w×h`) + * with a child frame of `chOff=0,0` `chExt=w×h`, so the layout's local + * coordinates serialise unchanged. The result stays grouped and editable in + * PowerPoint instead of collapsing to anonymous floating shapes. + */ +export function synthesiseDiagram(el: DiagramElement): { + xml: string; + media: MediaPayload[]; +} { + const primitives = layoutDiagram(el); + const w = Math.max(1, el.w); + const h = Math.max(1, el.h); + const fontPt = pxToPoints(el.fontSize ?? 18); + const fontFamily = el.fontFamily ?? "Inter"; + + const children = primitives + .map((p) => + p.kind === "box" + ? diagramBoxXml(p, fontPt, fontFamily) + : diagramArrowXml(p) + ) + .join(""); + + const xfrm = + `` + + `` + + `` + + `` + + `` + + ``; + const xml = + `` + + `` + + `${xfrm}` + + children + + ``; + return { xml, media: [] }; +} + +function diagramBoxXml( + b: DiagramBoxPrimitive, + fontPt: number, + fontFamily: string +): string { + const id = freshNvId(); + const preset = DIAGRAM_PRESET[b.shape] ?? "rect"; + const xfrm = + `` + + `` + + `` + + ``; + const sz = Math.max(100, Math.round(fontPt * 100)); + const run = b.text + ? `${escapeText(b.text)}` + : ``; + return ( + `` + + `` + + `${xfrm}${solidFillXml(b.fill)}` + + `${run}` + + `` + ); +} + +function diagramArrowXml(a: DiagramArrowPrimitive): string { + const id = freshNvId(); + const x = Math.min(a.x1, a.x2); + const y = Math.min(a.y1, a.y2); + const w = Math.abs(a.x2 - a.x1); + const h = Math.abs(a.y2 - a.y1); + const flipH = a.x2 < a.x1 ? ` flipH="1"` : ""; + const flipV = a.y2 < a.y1 ? ` flipV="1"` : ""; + const xfrm = + `` + + `` + + `` + + ``; + const head = a.arrow ? `` : ""; + const ln = + `` + + `` + + head + + ``; + return ( + `` + + `` + + `${xfrm}${ln}` + + `` + ); +} + // -- Slide background synthesis (PR 3) -------------------------------------- export function synthesiseSlideBg( diff --git a/packages/slidewise/src/lib/types.ts b/packages/slidewise/src/lib/types.ts index 90b581f..a44565f 100644 --- a/packages/slidewise/src/lib/types.ts +++ b/packages/slidewise/src/lib/types.ts @@ -12,6 +12,7 @@ export type ElementType = | "chart" | "connector" | "group" + | "diagram" | "unknown"; /** @@ -561,6 +562,59 @@ export interface ConnectorElement extends BaseElement { glow?: GlowSpec; } +/** + * Diagram families. A diagram is a *semantic* structure — an ordered set of + * labelled nodes the renderer lays out by `kind` (boxes + arrows for a + * process, stacked bars for a funnel, a 2×N grid for a matrix, …). Unlike a + * hand-placed cluster of shapes + lines, it stays editable as one unit and + * serialises to a single labelled `` of real shapes/connectors so + * PowerPoint keeps it grouped (move/resize as a whole) rather than as + * anonymous floating shapes. + */ +export type DiagramKind = + | "process" + | "timeline" + | "funnel" + | "matrix" + | "cycle" + | "list"; + +/** One labelled node in a {@link DiagramElement}. */ +export interface DiagramNode { + id: string; + /** Node label. */ + text: string; + /** Optional per-node fill (CSS hex); falls back to the diagram palette. */ + fill?: string; + /** Optional per-node label color (CSS hex); falls back to the diagram. */ + color?: string; +} + +/** + * A first-class, editable diagram. The renderer and the PPTX writer share one + * layout function (`layoutDiagram`) keyed off `kind`, so the on-canvas preview + * and the saved `` match. The unlock for AI-generated process / + * timeline / funnel / matrix / cycle visuals that round-trip as a grouped, + * editable object instead of a flat pile of shapes. + */ +export interface DiagramElement extends BaseElement { + type: "diagram"; + kind: DiagramKind; + /** Ordered nodes; the layout per `kind` decides how they're arranged. */ + nodes: DiagramNode[]; + /** + * Node fill palette (CSS hex), cycled across nodes when a node sets no + * `fill` of its own. Defaults to a built-in accent palette. + */ + palette?: string[]; + /** Default label color (CSS hex) for nodes that set none. */ + color?: string; + /** Label font family for all nodes. */ + fontFamily?: string; + /** Label font size (canvas px). */ + fontSize?: number; +} + export type SlideElement = | TextElement | ShapeElement @@ -572,6 +626,7 @@ export type SlideElement = | ChartElement | ConnectorElement | GroupElement + | DiagramElement | UnknownElement; export interface Slide { @@ -689,6 +744,14 @@ export interface DeckLayout { id: string; /** Human-readable name from ``, when present. */ name?: string; + /** + * Raw OOXML layout role from `` (e.g. `"title"`, + * `"obj"`, `"twoObj"`, `"secHead"`, `"pic"`, `"blank"`). The design intent + * of the layout, independent of its placeholder inventory. Absent when the + * source omits the attribute. `summarizeLayouts` maps this to a friendlier + * `role` label for model-facing layout menus. + */ + type?: string; /** Placeholder slots this layout defines, in document order. */ placeholders: LayoutPlaceholder[]; /** From f681091e56d36b128074ace657a68db8b4105330 Mon Sep 17 00:00:00 2001 From: karthikmudunuri <102793643+karthikmudunuri@users.noreply.github.com> Date: Wed, 10 Jun 2026 23:08:15 +0530 Subject: [PATCH 2/3] feat(pptx): host deck-generation ergonomics from PR #90 review - summarizeLayouts(deck, { compact, dedupe }) for 85-layout templates: compact drops geometry; dedupe collapses same role+fillable into aliases. - sourceLayoutId resolves from deck.layouts OR ppt/slideLayouts/.xml in the source archive; layout-unresolved onWarning when neither resolves. - chrome-skipped warning carries sourceAspect/outputAspect. - README: author-a-slide contract, non-text slot recipe, layoutDiagram server-render recipe + DOM-free guarantee. - Tests: compact/dedupe, filled-text round-trip, host-authored image slot, by-convention + unresolved layout resolution, multi-master instantiation, enriched chrome-skipped payload. --- .changeset/host-generation-feedback.md | 24 ++ README.md | 98 +++++++- packages/slidewise/src/index.ts | 1 + .../lib/__tests__/summarize-layouts.test.ts | 81 +++++++ packages/slidewise/src/lib/diagram/layout.ts | 7 +- packages/slidewise/src/lib/layouts.ts | 91 ++++++- .../__tests__/instantiable-layouts.test.ts | 229 ++++++++++++++++++ .../src/lib/pptx/__tests__/non-16-9.test.ts | 11 +- packages/slidewise/src/lib/pptx/deckToPptx.ts | 103 ++++++-- 9 files changed, 601 insertions(+), 44 deletions(-) create mode 100644 .changeset/host-generation-feedback.md create mode 100644 packages/slidewise/src/lib/__tests__/summarize-layouts.test.ts diff --git a/.changeset/host-generation-feedback.md b/.changeset/host-generation-feedback.md new file mode 100644 index 0000000..3656c45 --- /dev/null +++ b/.changeset/host-generation-feedback.md @@ -0,0 +1,24 @@ +--- +"@textcortex/slidewise": minor +--- + +Host deck-generation ergonomics (review follow-ups for layout instantiation): + +- **`summarizeLayouts(deck, options)`** — `{ compact: true }` returns the minimal + `{ id, name?, role, fillable }` shape (no geometry) for a tight model-context + budget; `{ dedupe: true }` collapses layouts that share a role + fillable + signature into one representative carrying the rest as `aliases`, so an + 85-layout template surfaces as its handful of distinct kinds. Composable. +- **Robust `sourceLayoutId` resolution** — a host-authored slide's + `sourceLayoutId` now resolves from `deck.layouts` **or**, when the host didn't + carry the layouts array, by the `ppt/slideLayouts/.xml` id convention + against the `{ source }` archive. When it resolves to nothing, `serializeDeck` + emits a structured `{ code: "layout-unresolved", slideIndex, layoutId }` + warning instead of silently falling back. +- **Richer `chrome-skipped` warning** — now carries the detected `sourceAspect` + and `outputAspect` so a host can explain *why* chrome was dropped. + +Plus README: the host "author-a-slide" contract (`{ sourceLayoutId, +background: "transparent", elements }` without a JS call), the recipe for +placing non-text slots from `summarizeLayouts` geometry, and the server-side +`layoutDiagram` render recipe with its DOM-free guarantee. diff --git a/README.md b/README.md index 8602f79..adfbc08 100644 --- a/README.md +++ b/README.md @@ -85,16 +85,25 @@ const safe: Deck = migrate(unknownDeckJson); // normalise an external deck `serializeDeck(deck, { source })` reproduces a source template's slide size (16:9, 4:3, 16:10, or custom) and carries over its masters / layouts / theme / -fonts. If that chrome can't be preserved — e.g. the source's slide size is -unreadable — it falls back to generic chrome and reports it through an optional -diagnostics sink so the host can surface the degradation rather than ship a -silently off-brand deck: +fonts. Degradations are reported through an optional `onWarning` diagnostics +sink (structured `SerializeWarning`s) so the host can surface them rather than +ship a silently off-brand deck: ```ts await serializeDeck(deck, { source, onWarning: (w) => { - if (w.code === "chrome-skipped") notifyHost(w.message); + switch (w.code) { + case "chrome-skipped": // size unreadable → generic chrome + notifyHost(`${w.message} (source ${w.sourceAspect}, output ${w.outputAspect})`); + break; + case "layout-unresolved": // a slide's sourceLayoutId matched nothing + notifyHost(`slide ${w.slideIndex}: layout ${w.layoutId} not found`); + break; + case "element-write-failed": // one element threw; rest of slide intact + notifyHost(`${w.elementType} ${w.elementId} skipped`); + break; + } }, }); ``` @@ -126,8 +135,11 @@ import { const deck = await parsePptx(blob); // 1. Show a model a compact menu of the available layouts. The shape is -// structured (not a string) so you can trim it to your context budget — -// e.g. keep only { id, role, fillable } and drop geometry. +// structured (not a string) so you can trim it to your context budget. +// For large templates (e.g. 85 layouts) pass options: +// summarizeLayouts(deck, { compact: true }) // { id, name?, role, fillable }, no geometry +// summarizeLayouts(deck, { dedupe: true }) // collapse same role+fillable; others → `aliases` +// summarizeLayouts(deck, { compact: true, dedupe: true }) // both const menu = summarizeLayouts(deck); // [ // { id: "slideLayout2", name: "Title and Content", type: "obj", @@ -160,6 +172,54 @@ master, or add real `image` / `table` / `chart` elements to the returned slide. A placeholder with no matching `fills` entry becomes an empty, editable text box. +#### Authoring slides directly (no `addSlideFromLayout`) + +`addSlideFromLayout` is a convenience; the underlying contract is just a slide +shape, so a host that builds deck JSON in another language (e.g. Python) can +author it directly and let `serializeDeck` paint the chrome: + +```jsonc +{ + "id": "slide-7", + "background": "transparent", // inherit the layout/master/theme background + "sourceLayoutId": "slideLayout12", + "elements": [ /* any text / image / chart / table / diagram elements */ ] +} +``` + +Contract: + +- **`sourceLayoutId` alone is enough.** No JS call is required — set it on the + slide JSON and `serializeDeck(deck, { source })` points the slide at that + layout's part and paints its background / fonts / theme / footer chrome. +- **Put arbitrary elements on the slide.** It is not limited to elements + `addSlideFromLayout` produced — your filled text, native charts, generated + images, tables, and diagrams all land normally, on top of the layout chrome. +- **Place non-text slots yourself from the layout geometry.** For an image / + chart / table slot, read its geometry from `summarizeLayouts` (every + placeholder is listed with `category` + `x/y/w/h`, fillable or not) and add a + real element at that box: + + ```ts + const slot = summarizeLayouts(deck) + .find((l) => l.id === "slideLayout12")! + .placeholders.find((p) => p.category === "picture")!; + authoredSlide.elements.push({ + id: "img-1", type: "image", src: generatedPhotoDataUrl, fit: "cover", + x: slot.x, y: slot.y, w: slot.w, h: slot.h, rotation: 0, z: 1, + }); + ``` + +- **Background:** keep `"transparent"` to inherit the layout/master background; + set an explicit hex to override it. +- **`sourceLayoutId` resolution** is by id, resolved from `deck.layouts` (if you + carried the array) **or** by the `ppt/slideLayouts/.xml` convention + against the `{ source }` archive — so you don't have to ship the `layouts` + array. If neither resolves, the slide falls back to the first source layout + and `serializeDeck` emits a `{ code: "layout-unresolved", slideIndex, + layoutId }` warning through `onWarning` (see below) rather than failing + silently. + ### Diagrams `DiagramElement` models a process / timeline / funnel / matrix / cycle / list @@ -188,11 +248,35 @@ const slide = { { id: "n2", text: "Design" }, { id: "n3", text: "Ship" }, ], + // optional: palette?: string[], color?, fontFamily?, fontSize?, + // and per-node fill? / color? overrides. }, ], }; ``` +`kind` is one of `"process" | "timeline" | "funnel" | "matrix" | "cycle" | +"list"`. The JSON shape (`DiagramElement` / `DiagramNode`) is stable — safe to +emit from another language. + +**Server-side rendering.** `layoutDiagram(el)` is **pure and DOM-free** (only +box/arrow arithmetic — no `window` / `document` / `canvas`), the same guarantee +`buildChartOption` gives for charts, and it's committed to staying that way. A +host QA renderer can draw a diagram to SVG/PNG without a browser by walking the +primitives: + +```ts +import { layoutDiagram } from "@textcortex/slidewise"; + +for (const p of layoutDiagram(el)) { + if (p.kind === "box") drawRect(p.x, p.y, p.w, p.h, p.fill, p.text, p.textColor); + else drawArrow(p.x1, p.y1, p.x2, p.y2, p.stroke, p.arrow); +} +``` + +Coordinates are local to the element box (`0..w` × `0..h`); offset by the +element's `x` / `y` to place them on the slide. + ## Theming Slidewise exposes its surface colors and chrome metrics as CSS custom diff --git a/packages/slidewise/src/index.ts b/packages/slidewise/src/index.ts index 23795d6..7aeff4c 100644 --- a/packages/slidewise/src/index.ts +++ b/packages/slidewise/src/index.ts @@ -109,6 +109,7 @@ export { summarizeLayouts, placeholderKey, type AddSlideFromLayoutOptions, + type SummarizeLayoutsOptions, type LayoutSummary, type LayoutSlotSummary, type PlaceholderCategory, diff --git a/packages/slidewise/src/lib/__tests__/summarize-layouts.test.ts b/packages/slidewise/src/lib/__tests__/summarize-layouts.test.ts new file mode 100644 index 0000000..5f1229d --- /dev/null +++ b/packages/slidewise/src/lib/__tests__/summarize-layouts.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from "vitest"; +import { summarizeLayouts } from "../layouts"; +import { CURRENT_DECK_VERSION } from "../schema/migrate"; +import type { Deck, DeckLayout } from "../types"; + +/** + * 1c: `summarizeLayouts` compact + dedupe — what makes an 85-layout template + * usable inside a host's tight model-context budget. + */ + +function ph(type: string, idx?: number) { + return { + type, + ...(idx != null ? { idx } : {}), + x: 0, + y: 0, + w: 100, + h: 50, + }; +} + +function layout(id: string, type: string, placeholders: DeckLayout["placeholders"]): DeckLayout { + return { id, type, placeholders, sourcePath: `ppt/slideLayouts/${id}.xml` }; +} + +function deckWith(layouts: DeckLayout[]): Deck { + return { version: CURRENT_DECK_VERSION, title: "t", slides: [], layouts }; +} + +describe("summarizeLayouts options", () => { + const deck = deckWith([ + layout("slideLayout1", "obj", [ph("title"), ph("body", 1)]), + layout("slideLayout2", "obj", [ph("title"), ph("body", 1)]), // dup of 1 + layout("slideLayout3", "title", [ph("ctrTitle"), ph("subTitle", 1)]), + ]); + + it("returns full per-placeholder geometry by default", () => { + const full = summarizeLayouts(deck); + expect(full).toHaveLength(3); + expect(full[0].placeholders).toHaveLength(2); + expect(full[0].placeholders[0]).toMatchObject({ key: "title", x: 0, w: 100 }); + expect(full[0].type).toBe("obj"); + expect(full[0].fillable).toEqual(["title", "body:1"]); + }); + + it("compact drops geometry + type for a budget-friendly menu", () => { + const compact = summarizeLayouts(deck, { compact: true }); + expect(compact[0].placeholders).toEqual([]); + expect(compact[0].type).toBeUndefined(); + // Keeps the model-meaningful selectors. + expect(compact[0]).toMatchObject({ + id: "slideLayout1", + role: "Title and content", + fillable: ["title", "body:1"], + }); + }); + + it("dedupe collapses same role + fillable signature, recording aliases", () => { + const deduped = summarizeLayouts(deck, { dedupe: true }); + // slideLayout1 & slideLayout2 are identical kinds → one representative. + expect(deduped).toHaveLength(2); + const obj = deduped.find((l) => l.id === "slideLayout1")!; + expect(obj.aliases).toEqual(["slideLayout2"]); + // The distinct "Title slide" layout stays on its own, no aliases. + const title = deduped.find((l) => l.role === "Title slide")!; + expect(title.id).toBe("slideLayout3"); + expect(title.aliases).toBeUndefined(); + }); + + it("compact + dedupe compose", () => { + const out = summarizeLayouts(deck, { compact: true, dedupe: true }); + expect(out).toHaveLength(2); + expect(out[0].placeholders).toEqual([]); + expect(out[0].aliases).toEqual(["slideLayout2"]); + }); + + it("returns [] for a deck with no layouts", () => { + expect(summarizeLayouts(deckWith([]))).toEqual([]); + expect(summarizeLayouts({ ...deckWith([]), layouts: undefined })).toEqual([]); + }); +}); diff --git a/packages/slidewise/src/lib/diagram/layout.ts b/packages/slidewise/src/lib/diagram/layout.ts index 17d3800..89811fa 100644 --- a/packages/slidewise/src/lib/diagram/layout.ts +++ b/packages/slidewise/src/lib/diagram/layout.ts @@ -74,8 +74,11 @@ function textFor(ctx: LayoutCtx, node: DiagramNode): string { } /** - * Compute the laid-out primitives for a diagram. Pure and deterministic — no - * randomness, no time — so renderer and writer agree byte-for-byte. + * Compute the laid-out primitives for a diagram. Pure, deterministic, and + * DOM-free — no randomness, no time, no `window` / `document` / `canvas` — so + * the renderer and writer agree byte-for-byte AND a host can render diagrams + * server-side without a browser (same guarantee `buildChartOption` gives for + * charts). This DOM-free contract is intentional; keep it that way. */ export function layoutDiagram(el: DiagramElement): DiagramPrimitive[] { const w = Math.max(1, el.w); diff --git a/packages/slidewise/src/lib/layouts.ts b/packages/slidewise/src/lib/layouts.ts index 58e82d9..040405e 100644 --- a/packages/slidewise/src/lib/layouts.ts +++ b/packages/slidewise/src/lib/layouts.ts @@ -226,29 +226,64 @@ export interface LayoutSummary { name?: string; /** Friendly purpose label (e.g. "Title and content", "Section header"). */ role: string; - /** Raw OOXML ``, when present. */ + /** Raw OOXML ``, when present. Omitted in compact mode. */ type?: string; /** The `fills` keys this layout accepts, in document order. */ fillable: string[]; - /** Every placeholder slot, in document order. */ + /** + * Every placeholder slot, in document order. Empty in `compact` mode (where + * geometry is dropped to save model-context budget). + */ placeholders: LayoutSlotSummary[]; + /** + * Under `dedupe`, the ids of the other layouts collapsed into this + * representative (same role + same fillable signature). Absent otherwise. + * Any alias id is still a valid `addSlideFromLayout` / `sourceLayoutId` + * target — they're interchangeable for instantiation purposes. + */ + aliases?: string[]; +} + +export interface SummarizeLayoutsOptions { + /** + * Drop per-placeholder geometry (`placeholders` comes back `[]`, `type` + * omitted) for a minimal `{ id, name?, role, fillable }` menu that fits a + * tight model-context budget. The host still gets the geometry from the full + * (non-compact) call when it needs to place elements. + */ + compact?: boolean; + /** + * Collapse layouts that share a role + fillable-key signature into a single + * representative carrying the rest as `aliases`, so a real 85-layout template + * surfaces as its handful of *distinct kinds* instead of 85 near-identical + * rows. Dedupe keys off the package's own role/shape knowledge, which a host + * can't reliably reconstruct from geometry alone. + */ + dedupe?: boolean; } /** * Summarise a deck's instantiable layouts into a compact menu a host can hand * to a model when choosing which layout to instantiate for each slide. Returns * a structured shape (not a string) so the host can trim it to its - * context-budget — e.g. drop geometry, or keep only `{id, role, fillable}` — - * before serialising. Pair each chosen `id` + `fills` with - * `addSlideFromLayout`. + * context-budget — or pass `{ compact: true }` for the minimal shape and/or + * `{ dedupe: true }` to collapse near-identical layouts. Pair each chosen `id` + * (or any `aliases` id) + `fills` with `addSlideFromLayout`, or set it as a + * slide's `sourceLayoutId`. * * Returns `[]` when the deck has no layouts (not parsed from a real PPTX). */ -export function summarizeLayouts(deck: Deck): LayoutSummary[] { - return (deck.layouts ?? []).map(summarizeLayout); +export function summarizeLayouts( + deck: Deck, + options: SummarizeLayoutsOptions = {} +): LayoutSummary[] { + const out = (deck.layouts ?? []).map((l) => + summarizeLayout(l, options.compact === true) + ); + return options.dedupe ? dedupeLayouts(out) : out; } -function summarizeLayout(layout: DeckLayout): LayoutSummary { +function summarizeLayout(layout: DeckLayout, compact: boolean): LayoutSummary { const placeholders: LayoutSlotSummary[] = layout.placeholders.map((ph) => ({ key: placeholderKey(ph), type: ph.type, @@ -260,18 +295,50 @@ function summarizeLayout(layout: DeckLayout): LayoutSummary { w: ph.w, h: ph.h, })); + const role = + (layout.type ? ROLE_BY_TYPE[layout.type] : undefined) ?? + roleFromPlaceholders(layout.placeholders); + const fillable = placeholders.filter((p) => p.fillable).map((p) => p.key); + if (compact) { + return { + id: layout.id, + ...(layout.name ? { name: layout.name } : {}), + role, + fillable, + placeholders: [], + }; + } return { id: layout.id, ...(layout.name ? { name: layout.name } : {}), - role: - (layout.type ? ROLE_BY_TYPE[layout.type] : undefined) ?? - roleFromPlaceholders(layout.placeholders), + role, ...(layout.type ? { type: layout.type } : {}), - fillable: placeholders.filter((p) => p.fillable).map((p) => p.key), + fillable, placeholders, }; } +/** + * Collapse layouts with an identical role + fillable signature into one + * representative (the first seen, in document order), recording the rest as + * `aliases`. Order of the surviving representatives is preserved. + */ +function dedupeLayouts(summaries: LayoutSummary[]): LayoutSummary[] { + const byKey = new Map(); + const order: string[] = []; + for (const s of summaries) { + const key = `${s.role}${s.fillable.join(",")}`; + const rep = byKey.get(key); + if (!rep) { + byKey.set(key, { ...s }); + order.push(key); + } else { + (rep.aliases ??= []).push(s.id); + } + } + return order.map((k) => byKey.get(k)!); +} + function placeholderToText( ph: LayoutPlaceholder, text: string, diff --git a/packages/slidewise/src/lib/pptx/__tests__/instantiable-layouts.test.ts b/packages/slidewise/src/lib/pptx/__tests__/instantiable-layouts.test.ts index 5f42b03..b500394 100644 --- a/packages/slidewise/src/lib/pptx/__tests__/instantiable-layouts.test.ts +++ b/packages/slidewise/src/lib/pptx/__tests__/instantiable-layouts.test.ts @@ -6,6 +6,11 @@ import { addSlideFromLayout, summarizeLayouts, } from "../../../index"; +import type { ImageElement, Slide } from "@/lib/types"; + +// 1×1 transparent PNG for host-authored image-slot tests. +const PNG_1x1 = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; /** * F1: master layouts are exposed as instantiable templates (Deck.layouts), and @@ -150,6 +155,78 @@ function sourceWithTwoLayouts(): JSZip { return zip; } +function themeXml(name: string): string { + return ``; +} + +/** + * A two-master source: master1 owns slideLayout1 + theme1, master2 owns + * slideLayout2 + theme2. Instantiating from a layout under master2 must route + * its chrome through master2 / theme2, not master1. + */ +function sourceWithTwoMasters(): JSZip { + const zip = new JSZip(); + zip.file( + "[Content_Types].xml", + ` + + + + + + + + + + + +` + ); + zip.file( + "_rels/.rels", + `` + ); + zip.file( + "ppt/presentation.xml", + `` + ); + zip.file( + "ppt/_rels/presentation.xml.rels", + `` + ); + zip.file( + "ppt/slides/slide1.xml", + `` + ); + zip.file( + "ppt/slides/_rels/slide1.xml.rels", + `` + ); + for (const [n, master] of [ + [1, 1], + [2, 2], + ]) { + zip.file( + `ppt/slideLayouts/slideLayout${n}.xml`, + `` + ); + zip.file( + `ppt/slideLayouts/_rels/slideLayout${n}.xml.rels`, + `` + ); + zip.file( + `ppt/slideMasters/slideMaster${n}.xml`, + `` + ); + zip.file( + `ppt/slideMasters/_rels/slideMaster${n}.xml.rels`, + `` + ); + zip.file(`ppt/theme/theme${n}.xml`, themeXml(`Office${n}`)); + } + return zip; +} + describe("F1: instantiable layouts", () => { it("exposes layouts with placeholder geometry + style", async () => { const source = await sourceWithLayout().generateAsync({ @@ -338,3 +415,155 @@ describe("F1: instantiated slides carry their layout's chrome", () => { expect(slide2).toMatch(/ { + it("filled placeholder text survives the round-trip and re-parses", async () => { + const source = await sourceWithLayout().generateAsync({ + type: "arraybuffer", + }); + const deck = await parsePptx(source); + const next = addSlideFromLayout(deck, "slideLayout1", { + fills: { title: "Quarterly Review", "body:1": "Revenue up 24%" }, + }); + + const blob = await serializeDeck(next, { source }); + const reparsed = await parsePptx(await blob.arrayBuffer()); + // The instantiated slide (index 1) carries its filled text. + const dump = JSON.stringify(reparsed.slides[1]); + expect(dump).toContain("Quarterly Review"); + expect(dump).toContain("Revenue up 24%"); + }); + + it("a host-authored element placed at a layout slot lands with layout chrome", async () => { + // The Python-host path: read a slot's geometry from summarizeLayouts, set + // sourceLayoutId, and drop a real element there — no addSlideFromLayout. + const source = await sourceWithLayout().generateAsync({ + type: "arraybuffer", + }); + const deck = await parsePptx(source); + const slot = summarizeLayouts(deck)[0].placeholders.find( + (p) => p.type === "body" + )!; + + const image: ImageElement = { + id: "hostimg", + type: "image", + x: slot.x, + y: slot.y, + w: slot.w, + h: slot.h, + rotation: 0, + z: 1, + src: PNG_1x1, + fit: "cover", + }; + const authored: Slide = { + id: "authored1", + background: "transparent", + sourceLayoutId: "slideLayout1", + elements: [image], + }; + const next = { ...deck, slides: [...deck.slides, authored] }; + + const blob = await serializeDeck(next, { source }); + const out = await JSZip.loadAsync(await blob.arrayBuffer()); + // The image landed on the instantiated slide… + const slide2 = await out.file("ppt/slides/slide2.xml")!.async("string"); + expect(slide2).toContain(""); + // …pointed at the layout's chrome, with no opaque bg blocking inheritance. + const rels2 = await out + .file("ppt/slides/_rels/slide2.xml.rels")! + .async("string"); + expect(rels2).toMatch(/slideLayout1\.xml/); + expect(slide2).not.toMatch(/ { + const source = await sourceWithLayout().generateAsync({ + type: "arraybuffer", + }); + const deck = await parsePptx(source); + // Host authored a deck WITHOUT carrying the layouts array — only the id. + const authored: Slide = { + id: "a1", + background: "transparent", + sourceLayoutId: "slideLayout1", + elements: [], + }; + const next = { ...deck, layouts: undefined, slides: [...deck.slides, authored] }; + + const warnings: { code: string }[] = []; + const blob = await serializeDeck(next, { + source, + onWarning: (w) => warnings.push(w), + }); + const out = await JSZip.loadAsync(await blob.arrayBuffer()); + const rels2 = await out + .file("ppt/slides/_rels/slide2.xml.rels")! + .async("string"); + // Resolved from the source archive by the slideLayoutN convention. + expect(rels2).toMatch(/slideLayout1\.xml/); + expect(warnings.some((w) => w.code === "layout-unresolved")).toBe(false); + }); + + it("warns (machine-readably) when sourceLayoutId resolves to nothing", async () => { + const source = await sourceWithLayout().generateAsync({ + type: "arraybuffer", + }); + const deck = await parsePptx(source); + const authored: Slide = { + id: "a1", + background: "transparent", + sourceLayoutId: "slideLayoutNope", + elements: [], + }; + const next = { ...deck, slides: [...deck.slides, authored] }; + + const warnings: { code: string; layoutId?: string; slideIndex?: number }[] = + []; + await serializeDeck(next, { source, onWarning: (w) => warnings.push(w) }); + const unresolved = warnings.find((w) => w.code === "layout-unresolved"); + expect(unresolved).toBeTruthy(); + expect(unresolved!.layoutId).toBe("slideLayoutNope"); + expect(unresolved!.slideIndex).toBe(1); + }); + + it("instantiating from a second master routes chrome through that master/theme", async () => { + const source = await sourceWithTwoMasters().generateAsync({ + type: "arraybuffer", + }); + const deck = await parsePptx(source); + expect(deck.layouts!.map((l) => l.id)).toEqual([ + "slideLayout1", + "slideLayout2", + ]); + + // Instantiate from slideLayout2, which lives under master2 / theme2. + const next = addSlideFromLayout(deck, "slideLayout2", { + fills: { title: "Under master 2" }, + }); + const blob = await serializeDeck(next, { source }); + const out = await JSZip.loadAsync(await blob.arrayBuffer()); + + // Both masters + both themes survived into the output. + const paths = Object.keys(out.files); + expect(paths).toContain("ppt/slideMasters/slideMaster1.xml"); + expect(paths).toContain("ppt/slideMasters/slideMaster2.xml"); + expect(paths).toContain("ppt/theme/theme1.xml"); + expect(paths).toContain("ppt/theme/theme2.xml"); + + // The instantiated slide → layout2 → master2 → theme2 chain resolves. + const slideRels = await out + .file("ppt/slides/_rels/slide2.xml.rels")! + .async("string"); + expect(slideRels).toMatch(/slideLayout2\.xml/); + const layoutRels = await out + .file("ppt/slideLayouts/_rels/slideLayout2.xml.rels")! + .async("string"); + expect(layoutRels).toMatch(/slideMaster2\.xml/); + const masterRels = await out + .file("ppt/slideMasters/_rels/slideMaster2.xml.rels")! + .async("string"); + expect(masterRels).toMatch(/theme2\.xml/); + }); +}); diff --git a/packages/slidewise/src/lib/pptx/__tests__/non-16-9.test.ts b/packages/slidewise/src/lib/pptx/__tests__/non-16-9.test.ts index 218e1c5..a597d13 100644 --- a/packages/slidewise/src/lib/pptx/__tests__/non-16-9.test.ts +++ b/packages/slidewise/src/lib/pptx/__tests__/non-16-9.test.ts @@ -210,7 +210,12 @@ describe("B3: non-16:9 source", () => { `` ).generateAsync({ type: "arraybuffer" }); - const warnings: { code: string; message: string }[] = []; + const warnings: { + code: string; + message: string; + sourceAspect?: number; + outputAspect?: number; + }[] = []; await serializeDeck(deck, { source: brokenSource, onWarning: (w) => warnings.push(w), @@ -219,5 +224,9 @@ describe("B3: non-16:9 source", () => { const skipped = warnings.find((w) => w.code === "chrome-skipped"); expect(skipped).toBeTruthy(); expect(skipped!.message).toMatch(/generic chrome/); + // Output ratio is known (16:9 fallback ≈ 1.333); source ratio was + // unreadable, so it's absent — the host can still report the output side. + expect(skipped!.outputAspect).toBeCloseTo(16 / 9, 2); + expect(skipped!.sourceAspect).toBeUndefined(); }); }); diff --git a/packages/slidewise/src/lib/pptx/deckToPptx.ts b/packages/slidewise/src/lib/pptx/deckToPptx.ts index 681e6e3..8235e94 100644 --- a/packages/slidewise/src/lib/pptx/deckToPptx.ts +++ b/packages/slidewise/src/lib/pptx/deckToPptx.ts @@ -105,19 +105,30 @@ export interface SerializeOptions { export interface SerializeWarning { /** * - `"chrome-skipped"` — the source template's chrome was not preserved - * (slide size unreadable); the deck uses generic chrome. + * (slide size unreadable / aspect mismatch); the deck uses generic chrome. + * `sourceAspect` / `outputAspect` carry the ratios when known. + * - `"layout-unresolved"` — a slide's `sourceLayoutId` matched no layout in + * `deck.layouts` *and* no `ppt/slideLayouts/.xml` in the source + * archive; the slide falls back to the first source layout. `slideIndex` + * and `layoutId` identify it. * - `"element-write-failed"` — a single element threw while being written * and was skipped; the rest of the slide is intact. */ - code: "chrome-skipped" | "element-write-failed"; + code: "chrome-skipped" | "layout-unresolved" | "element-write-failed"; /** Human-readable explanation (also logged to the console). */ message: string; - /** Output slide index, when the warning is element-scoped. */ + /** Output slide index, when the warning is slide- or element-scoped. */ slideIndex?: number; /** Element id, when the warning is element-scoped. */ elementId?: string; /** Element type, when the warning is element-scoped. */ elementType?: string; + /** Unresolved `sourceLayoutId`, for `"layout-unresolved"`. */ + layoutId?: string; + /** Source deck aspect ratio (cx/cy), for `"chrome-skipped"` when readable. */ + sourceAspect?: number; + /** Output deck aspect ratio (cx/cy), for `"chrome-skipped"` when readable. */ + outputAspect?: number; } /** @@ -1786,16 +1797,31 @@ async function preserveDeckChrome( sourceSlidePaths: string[], onWarning?: (warning: SerializeWarning) => void ): Promise { - if (!(await aspectRatiosMatch(outZip, srcZip))) { + const aspects = await readDeckAspects(outZip, srcZip); + if (!aspectsMatch(aspects)) { // We size the output slide from the source (see computeSerializeTransform), // so ratios should match for any parseable source. If we still land here - // the source's was unreadable — surface it rather than silently - // shipping a generic-looking deck stripped of the template's chrome. + // the source's was unreadable — surface it (with the ratios we + // could read) rather than silently shipping a generic-looking deck stripped + // of the template's chrome. + const fmt = (r?: number) => (r != null ? r.toFixed(3) : "unknown"); const message = - "[slidewise/pptx] source slide size could not be matched; skipping " + - "master/layout/theme/font preservation (deck will use generic chrome)."; + "[slidewise/pptx] source slide size could not be matched " + + `(source aspect ${fmt(aspects.sourceAspect)}, output aspect ${fmt( + aspects.outputAspect + )}); skipping master/layout/theme/font preservation (deck will use ` + + "generic chrome)."; console.warn(message); - onWarning?.({ code: "chrome-skipped", message }); + onWarning?.({ + code: "chrome-skipped", + message, + ...(aspects.sourceAspect != null + ? { sourceAspect: aspects.sourceAspect } + : {}), + ...(aspects.outputAspect != null + ? { outputAspect: aspects.outputAspect } + : {}), + }); return; } @@ -1867,7 +1893,7 @@ async function preserveDeckChrome( // which we just deleted. Re-point each slide at the original layout // its source counterpart used. New slides (added in-editor with no // source path) fall back to the first source layout. - await rewriteSlideLayoutRefs(outZip, srcZip, deck, sourceSlidePaths); + await rewriteSlideLayoutRefs(outZip, srcZip, deck, sourceSlidePaths, onWarning); } /** @@ -2364,7 +2390,8 @@ async function rewriteSlideLayoutRefs( outZip: JSZip, srcZip: JSZip, deck: Deck, - sourceSlidePaths: string[] + sourceSlidePaths: string[], + onWarning?: (warning: SerializeWarning) => void ): Promise { // Pre-compute a default fallback layout target for new slides that have // no source counterpart: the first slideLayout the source ships. @@ -2389,10 +2416,33 @@ async function rewriteSlideLayoutRefs( let layoutTargetFull: string | undefined; // A slide instantiated from a layout (see `addSlideFromLayout`) declares // its layout directly — point its rels at that layout's source part, - // bypassing the source-slide → layout inference below. + // bypassing the source-slide → layout inference below. The id resolves + // from `deck.layouts` (carried in the deck JSON) OR, when the host didn't + // ship the layouts array, by the `slideLayoutN` id convention against the + // source archive — so authoring `{ sourceLayoutId: "slideLayout7" }` works + // with just the `{ source }` bytes. if (slide.sourceLayoutId) { const layout = deck.layouts?.find((l) => l.id === slide.sourceLayoutId); - if (layout?.sourcePath) layoutTargetFull = layout.sourcePath; + if (layout?.sourcePath) { + layoutTargetFull = layout.sourcePath; + } else { + const byConvention = `ppt/slideLayouts/${slide.sourceLayoutId}.xml`; + if (srcZip.file(byConvention)) layoutTargetFull = byConvention; + } + if (!layoutTargetFull) { + const message = + `[slidewise/pptx] slide ${i + 1}: sourceLayoutId ` + + `"${slide.sourceLayoutId}" matched no layout in deck.layouts nor ` + + `ppt/slideLayouts/${slide.sourceLayoutId}.xml in the source; ` + + "falling back to the first source layout."; + console.warn(message); + onWarning?.({ + code: "layout-unresolved", + message, + slideIndex: i, + layoutId: slide.sourceLayoutId, + }); + } } const sourceSlidePath = resolveSourceSlidePath(slide, i, sourceSlidePaths); if (!layoutTargetFull && sourceSlidePath) { @@ -2435,23 +2485,32 @@ async function rewriteSlideLayoutRefs( } } -async function aspectRatiosMatch( +interface DeckAspects { + outputAspect?: number; + sourceAspect?: number; +} + +async function readDeckAspects( outZip: JSZip, srcZip: JSZip -): Promise { +): Promise { const [outPres, srcPres] = await Promise.all([ outZip.file("ppt/presentation.xml")?.async("string"), srcZip.file("ppt/presentation.xml")?.async("string"), ]); - if (!outPres || !srcPres) return false; - const outSz = parseSldSz(outPres); - const srcSz = parseSldSz(srcPres); - if (!outSz || !srcSz) return false; - const outRatio = outSz.cx / outSz.cy; - const srcRatio = srcSz.cx / srcSz.cy; + const outSz = outPres ? parseSldSz(outPres) : null; + const srcSz = srcPres ? parseSldSz(srcPres) : null; + return { + ...(outSz ? { outputAspect: outSz.cx / outSz.cy } : {}), + ...(srcSz ? { sourceAspect: srcSz.cx / srcSz.cy } : {}), + }; +} + +function aspectsMatch(a: DeckAspects): boolean { + if (a.outputAspect == null || a.sourceAspect == null) return false; // ~1% tolerance covers floating-point drift; PPTX aspect ratios are // exact integer EMU. - return Math.abs(outRatio - srcRatio) / outRatio < 0.01; + return Math.abs(a.outputAspect - a.sourceAspect) / a.outputAspect < 0.01; } function parseSldSz(xml: string): { cx: number; cy: number } | null { From 9d48836fc7cd4a90c70cc1abed1a32bee4df169b Mon Sep 17 00:00:00 2001 From: karthikmudunuri <102793643+karthikmudunuri@users.noreply.github.com> Date: Wed, 10 Jun 2026 23:23:09 +0530 Subject: [PATCH 3/3] fix(layouts): dedupe signature includes non-text slots summarizeLayouts({dedupe}) keyed off role + text-fillable keys only, so a chart-bearing layout could collapse into an otherwise-identical text-only twin and the host would lose the data-visual variant. Key off the full placeholder inventory (category:key over text AND chart/picture/table slots) instead. Dedupe now runs on full summaries before compacting, so compact+dedupe is safe. --- .changeset/host-generation-feedback.md | 8 +-- README.md | 5 +- .../lib/__tests__/summarize-layouts.test.ts | 19 +++++++ packages/slidewise/src/lib/layouts.ts | 54 +++++++++++++------ 4 files changed, 66 insertions(+), 20 deletions(-) diff --git a/.changeset/host-generation-feedback.md b/.changeset/host-generation-feedback.md index 3656c45..989a63a 100644 --- a/.changeset/host-generation-feedback.md +++ b/.changeset/host-generation-feedback.md @@ -6,9 +6,11 @@ Host deck-generation ergonomics (review follow-ups for layout instantiation): - **`summarizeLayouts(deck, options)`** — `{ compact: true }` returns the minimal `{ id, name?, role, fillable }` shape (no geometry) for a tight model-context - budget; `{ dedupe: true }` collapses layouts that share a role + fillable - signature into one representative carrying the rest as `aliases`, so an - 85-layout template surfaces as its handful of distinct kinds. Composable. + budget; `{ dedupe: true }` collapses layouts that share a role + full + placeholder-slot inventory (text **and** non-text chart/picture/table slots) + into one representative carrying the rest as `aliases`, so an 85-layout + template surfaces as its handful of distinct kinds — and a chart-bearing + layout never collapses into a text-only twin. Composable. - **Robust `sourceLayoutId` resolution** — a host-authored slide's `sourceLayoutId` now resolves from `deck.layouts` **or**, when the host didn't carry the layouts array, by the `ppt/slideLayouts/.xml` id convention diff --git a/README.md b/README.md index adfbc08..8233bdd 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,10 @@ const deck = await parsePptx(blob); // structured (not a string) so you can trim it to your context budget. // For large templates (e.g. 85 layouts) pass options: // summarizeLayouts(deck, { compact: true }) // { id, name?, role, fillable }, no geometry -// summarizeLayouts(deck, { dedupe: true }) // collapse same role+fillable; others → `aliases` +// summarizeLayouts(deck, { dedupe: true }) // collapse layouts with the same role + full +// // slot inventory (text AND chart/image/table); +// // others → `aliases`. A chart-bearing layout +// // never collapses into a text-only twin. // summarizeLayouts(deck, { compact: true, dedupe: true }) // both const menu = summarizeLayouts(deck); // [ diff --git a/packages/slidewise/src/lib/__tests__/summarize-layouts.test.ts b/packages/slidewise/src/lib/__tests__/summarize-layouts.test.ts index 5f1229d..77d5d0f 100644 --- a/packages/slidewise/src/lib/__tests__/summarize-layouts.test.ts +++ b/packages/slidewise/src/lib/__tests__/summarize-layouts.test.ts @@ -67,6 +67,25 @@ describe("summarizeLayouts options", () => { expect(title.aliases).toBeUndefined(); }); + it("dedupe does NOT collapse layouts that differ in a non-text slot", () => { + // Same text slots (title + body:1), but one also has a chart placeholder. + // A host placing a native chart must keep the chart-bearing variant. + const deck2 = deckWith([ + layout("slideLayout1", "obj", [ph("title"), ph("body", 1)]), + layout("slideLayout2", "obj", [ph("title"), ph("body", 1), ph("chart", 2)]), + ]); + const deduped = summarizeLayouts(deck2, { dedupe: true }); + // Distinct slot inventories → both survive, neither aliases the other. + expect(deduped).toHaveLength(2); + expect(deduped.map((l) => l.id)).toEqual(["slideLayout1", "slideLayout2"]); + expect(deduped.every((l) => l.aliases === undefined)).toBe(true); + // The chart-bearing variant still exposes its chart slot geometry. + const chartLayout = deduped.find((l) => l.id === "slideLayout2")!; + expect( + chartLayout.placeholders.some((p) => p.category === "chart") + ).toBe(true); + }); + it("compact + dedupe compose", () => { const out = summarizeLayouts(deck, { compact: true, dedupe: true }); expect(out).toHaveLength(2); diff --git a/packages/slidewise/src/lib/layouts.ts b/packages/slidewise/src/lib/layouts.ts index 040405e..56cf44a 100644 --- a/packages/slidewise/src/lib/layouts.ts +++ b/packages/slidewise/src/lib/layouts.ts @@ -277,13 +277,15 @@ export function summarizeLayouts( deck: Deck, options: SummarizeLayoutsOptions = {} ): LayoutSummary[] { - const out = (deck.layouts ?? []).map((l) => - summarizeLayout(l, options.compact === true) - ); - return options.dedupe ? dedupeLayouts(out) : out; + // Always build the FULL summary first — dedupe keys off the complete + // placeholder inventory (text *and* non-text slots), which the compact shape + // doesn't carry. Compact is applied last, to the survivors. + const full = (deck.layouts ?? []).map(summarizeLayout); + const out = options.dedupe ? dedupeLayouts(full) : full; + return options.compact ? out.map(toCompact) : out; } -function summarizeLayout(layout: DeckLayout, compact: boolean): LayoutSummary { +function summarizeLayout(layout: DeckLayout): LayoutSummary { const placeholders: LayoutSlotSummary[] = layout.placeholders.map((ph) => ({ key: placeholderKey(ph), type: ph.type, @@ -299,15 +301,6 @@ function summarizeLayout(layout: DeckLayout, compact: boolean): LayoutSummary { (layout.type ? ROLE_BY_TYPE[layout.type] : undefined) ?? roleFromPlaceholders(layout.placeholders); const fillable = placeholders.filter((p) => p.fillable).map((p) => p.key); - if (compact) { - return { - id: layout.id, - ...(layout.name ? { name: layout.name } : {}), - role, - fillable, - placeholders: [], - }; - } return { id: layout.id, ...(layout.name ? { name: layout.name } : {}), @@ -318,8 +311,37 @@ function summarizeLayout(layout: DeckLayout, compact: boolean): LayoutSummary { }; } +/** Strip a full summary down to the budget-friendly compact shape, keeping + * `aliases` (set by dedupe). */ +function toCompact(s: LayoutSummary): LayoutSummary { + return { + id: s.id, + ...(s.name ? { name: s.name } : {}), + role: s.role, + fillable: s.fillable, + placeholders: [], + ...(s.aliases ? { aliases: s.aliases } : {}), + }; +} + +/** + * Dedupe signature: the role plus EVERY placeholder slot (`category:key`) in a + * stable order — not just the text fillable keys. Including non-text slots + * (chart / picture / table) means a chart-bearing layout never collapses into + * an otherwise-identical text-only twin, so a host can't lose the data-visual + * variant. Geometry-only differences still collapse (that's the point), and a + * representative + its aliases always share the same slot kinds. + */ +function layoutSignature(s: LayoutSummary): string { + const slots = s.placeholders + .map((p) => `${p.category}:${p.key}`) + .sort() + .join(","); + return `${s.role} ${slots}`; +} + /** - * Collapse layouts with an identical role + fillable signature into one + * Collapse layouts with an identical {@link layoutSignature} into one * representative (the first seen, in document order), recording the rest as * `aliases`. Order of the surviving representatives is preserved. */ @@ -327,7 +349,7 @@ function dedupeLayouts(summaries: LayoutSummary[]): LayoutSummary[] { const byKey = new Map(); const order: string[] = []; for (const s of summaries) { - const key = `${s.role}${s.fillable.join(",")}`; + const key = layoutSignature(s); const rep = byKey.get(key); if (!rep) { byKey.set(key, { ...s });