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 (
+