Embeddable React PPTX editor. PPTX import + canvas editor + PPTX export, in one component.
pnpm add @textcortex/slidewisePeer dependencies: react >=19, react-dom >=19.
SlidewiseFileEditor wraps the editor with PPTX load/save plumbing — give it
async loadBlob and saveBlob callbacks and it handles parsing, dirty
tracking, and serialisation.
import {
SlidewiseFileEditor,
type SlidewiseFileEditorApi,
} from "@textcortex/slidewise";
import "@textcortex/slidewise/style.css";
import { useRef } from "react";
export function PresentationsRoute({ fileId }: { fileId: string }) {
const apiRef = useRef<SlidewiseFileEditorApi | null>(null);
return (
<SlidewiseFileEditor
onEditorApiChange={(api) => (apiRef.current = api)}
loadBlob={async () => {
const res = await fetch(`/api/files/${fileId}`);
return res.blob();
}}
saveBlob={async (pptx) => {
await fetch(`/api/files/${fileId}`, { method: "PUT", body: pptx });
}}
/>
);
}The host owns transport and conflict detection; Slidewise owns parsing,
editing, and serialisation. Call apiRef.current.save() to trigger a save
from outside the editor's top bar; call apiRef.current.isDirty() to gate
"unsaved changes" UI.
If your host already has a Deck in memory (e.g. you're storing the JSON
shape in your own database rather than .pptx blobs), mount
SlidewiseEditor directly:
import { SlidewiseEditor, type Deck } from "@textcortex/slidewise";
import "@textcortex/slidewise/style.css";
<SlidewiseEditor
deck={deck}
onChange={(next) => setDeck(next)}
onSave={(next) => persist(next)}
/>;Slidewise persists slides as a versioned JSON Deck. The schema is the
canonical contract — undo/redo, exports, AI features, and persistence all
key off it.
import {
parsePptx,
serializeDeck,
migrate,
CURRENT_DECK_VERSION,
type Deck,
} from "@textcortex/slidewise";
const deck: Deck = await parsePptx(blob); // import
const pptx: Blob = await serializeDeck(deck); // export
const safe: Deck = migrate(unknownDeckJson); // normalise an external deckserializeDeck(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. Degradations are reported through an optional onWarning diagnostics
sink (structured SerializeWarnings) so the host can surface them rather than
ship a silently off-brand deck:
await serializeDeck(deck, {
source,
onWarning: (w) => {
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;
}
},
});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.
When you start from a branded template and only need to change a few things
(swap some text, fill a chart, drop a sample element), a full
serializeDeck round-trip is overkill — and re-rendering unedited elements is
where fidelity bugs come from. applyEdits(source, plan) instead patches the
original bytes: everything not named by an edit comes out byte-identical to
the source (masters, layouts, theme, embedded fonts, ppt/tags/*, notes,
embeddings, and any untouched element), and the result opens in PowerPoint with
no repair.
import { parsePptx, applyEdits, type EditPlan } from "@textcortex/slidewise";
const deck = await parsePptx(source); // address elements by deck ids
const plan: EditPlan = {
title: "Q3 Results",
// Output order = this list. Slides are the source's 1-based template index;
// a source slide may repeat for controlled reuse.
slides: [
{
source: { slideIndex: 1 },
edits: [{ op: "setText", elementId: titleId, text: "Q3 Results" }],
},
{
source: { slideIndex: 3 },
edits: [
// Repopulate a native chart in place — type/colours and the embedded
// workbook are preserved, so PowerPoint's Edit-Data still works.
{ op: "setChartData", elementId: chartId, categories: ["Jan", "Feb", "Mar"], series: [{ name: "Revenue", values: [10, 20, 30] }] },
{ op: "removeElement", elementId: sampleChartId },
],
},
{ source: { slideIndex: 4 }, edits: [] }, // kept byte-identical
],
};
const out: Uint8Array = await applyEdits(source, plan, {
onWarning: (w) => notifyHost(w.message), // unresolved id / unsupported op
});Ops: setText / clearText, setChartData, setTableData, setImage,
removeElement, addChart, addDiagram, plus per-slide background and the
deck title. Elements are addressed by the same stable ids parsePptx returns,
so call applyEdits in the same process as the parsePptx that produced the
plan. Removed slides and any parts exclusive to them are reclaimed
automatically. serializeDeck remains the path for the live editor and
from-scratch decks; applyEdits is the lossless path for template-derived
output.
Scaling a deck with the template's own layouts. A PlannedSlide can clone
a source slide ({ slideIndex }) or instantiate a fresh slide from one of
the template's layouts ({ layoutId, fills? }). Because the layout is already a
part of source, instantiation is still a lossless patch — the new slide binds
to ppt/slideLayouts/<layoutId>.xml and inherits its theme / master /
background chrome, while every other part stays byte-identical. This is how you
build a 35-slide deck from a 16-slide template without it looking repetitive:
clone where you want the exact slide, instantiate from layouts where you want
variety.
import { applyEdits, layoutSlotElementId, summarizeLayouts } from "@textcortex/slidewise";
const layouts = summarizeLayouts(deck); // pick a layout id + its fillable slot keys
const layoutId = layouts[0].id;
await applyEdits(source, {
slides: [
{ source: { slideIndex: 1 }, edits: [] }, // cloned, byte-identical
{
// Instantiate from a layout; `fills` populates text placeholders by key.
source: { layoutId, fills: { title: "Pipeline", "body:1": "Q3 → Q4" } },
edits: [
// Non-text slots are addressable by a deterministic id so edits can
// target them: fill the picture slot, draw a chart into the chart slot.
{ op: "setImage", elementId: layoutSlotElementId(layoutId, "pic:2"), data: photoBytes },
{ op: "addChart", bounds: chartSlotBounds, kind: "column", categories, series },
],
},
],
});Each instantiated placeholder (text and non-text — picture / chart / table)
is materialised as a positioned element with the stable id
layoutSlotElementId(layoutId, key), where key is the placeholderKey /
summarizeLayouts slot key ("title", "body:1", "pic:2", …). An
unresolvable layoutId is reported via onWarning and the slide is skipped
(never shipped wrong).
renderDeckToImages renders a deck to one image per slide server-side, with
no browser — drawing what the editor draws (native charts via ECharts SSR,
diagrams, text, shapes, images), not the OOXML raster fallbacks. It's built for
a render → inspect → fix → re-render QA loop, e.g. rendering a final applyEdits
output and having a model flag overflow / overlap / leftover text.
import {
renderDeckToSvg,
renderDeckToImages,
renderPptxToImages,
} from "@textcortex/slidewise";
// SVGs only (no rasteriser needed) — rasterise yourself if you prefer.
const svgs: string[] = await renderDeckToSvg(deck, { slides: [1, 2] });
// Raster bytes. Rasterisation is an injected hook so there's no hard native dep
// — pass a @resvg/resvg-js wrapper (the default tries to import it on demand).
import { Resvg } from "@resvg/resvg-js";
const pngs: Uint8Array[] = await renderDeckToImages(deck, {
dpi: 150,
rasterizeSvg: (svg, width) => new Resvg(svg, { fitTo: { mode: "width", value: width } }).render().asPng(),
});
// Render a final applyEdits output directly:
const shots = await renderPptxToImages(await applyEdits(source, plan));opts: slides (1-based subset), dpi (the 1920×1080 canvas scales by
dpi/96), format, maxWidth (thumbnail cap). Output is deterministic (no
animation). The renderer is browser-free and ECharts is loaded on demand, so it
never bloats the editor bundle.
parsePptx stamps deck.fontUsage: { family, embedded }[] — every font family
the deck's text uses, flagged whether the source PPTX actually embeds it
(<p:embeddedFontLst> → a real ppt/fonts/* part) or only references it (so
it falls back to a system font on viewers that don't ship the brand font). Use it
to warn at generation time:
const missing = (deck.fontUsage ?? []).filter((f) => !f.embedded);
if (missing.length) warnHost(`not embedded: ${missing.map((f) => f.family).join(", ")}`);This is a read-only diagnostic, distinct from deck.fonts (the embeddable
payloads the serializer writes back).
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.
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.
// For large templates (e.g. 85 layouts) pass options:
// summarizeLayouts(deck, { compact: true }) // { id, name?, role, fillable }, no geometry
// 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);
// [
// { 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.
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:
Contract:
-
sourceLayoutIdalone is enough. No JS call is required — set it on the slide JSON andserializeDeck(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
addSlideFromLayoutproduced — 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 withcategory+x/y/w/h, fillable or not) and add a real element at that box: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. -
sourceLayoutIdresolution is by id, resolved fromdeck.layouts(if you carried the array) or by theppt/slideLayouts/<id>.xmlconvention against the{ source }archive — so you don't have to ship thelayoutsarray. If neither resolves, the slide falls back to the first source layout andserializeDeckemits a{ code: "layout-unresolved", slideIndex, layoutId }warning throughonWarning(see below) rather than failing silently.
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 <p:grpSp> 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.
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" },
],
// 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:
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.
Slidewise exposes its surface colors and chrome metrics as CSS custom
properties, all namespaced under --slidewise-*. Override any subset on the
style prop of <Slidewise.Root> (or in a stylesheet that targets the
.slidewise-editor class) to retheme without forking.
<Slidewise.Root
style={{
"--slidewise-bg-app": "#0a0a0e",
"--slidewise-bg-rail": "#1c1c22",
"--slidewise-bg-topbar": "linear-gradient(180deg, #1c1c22, #14141a)",
"--slidewise-radius": "8px",
} as React.CSSProperties}
>
...
</Slidewise.Root>For the most-customized surfaces there's also a typed prop equivalent:
<Slidewise.Root
surfaces={{
app: "#0a0a0e",
rail: "#1c1c22",
canvasFrom: "#16181c",
canvasTo: "#0f0f12",
button: "transparent",
buttonHover: "rgba(255,255,255,0.06)",
}}
>| Variable | What it controls | Default |
|---|---|---|
--slidewise-radius |
Primary chrome button border-radius. | 10px |
--slidewise-bar-bg |
Top-bar background (alias kept for v1.1 hosts). | var(--app-bg) |
--slidewise-accent |
Accent color used for focus rings, the Smart pill, hover affordances. | var(--accent) |
--slidewise-bg-app |
Outermost app shell background. | var(--app-bg) |
--slidewise-bg-topbar |
Top bar surface. | var(--app-bg) |
--slidewise-bg-rail |
Slide-rail container. | var(--rail-bg) |
--slidewise-bg-rail-item |
Idle slide-rail item. | transparent |
--slidewise-bg-rail-item-active |
Active/selected rail item. | var(--accent-soft) |
--slidewise-bg-canvas-frame |
Frame around the canvas. | transparent |
--slidewise-bg-canvas-from / --slidewise-bg-canvas-to |
Canvas gradient stops. | var(--canvas-bg-from) / var(--canvas-bg-to) |
--slidewise-bg-bottom-toolbar |
Floating tool selector. | var(--toolbar-bg) |
--slidewise-bg-right-panel |
<Slidewise.RightPanel> surface. |
var(--rail-bg) |
--slidewise-bg-menu / --slidewise-bg-tooltip / --slidewise-bg-popover / --slidewise-bg-dialog |
Floating UI surfaces. | var(--menu-bg) |
--slidewise-bg-slide |
Slide paper. | #ffffff |
--slidewise-bg-selection |
Selection overlay tint. | var(--accent-soft) |
--slidewise-bg-hover / --slidewise-bg-active |
Interactive state tints. | var(--hover) / var(--active) |
--slidewise-bg-input |
Form input background. | var(--input-bg) |
--slidewise-bg-button / --slidewise-bg-button-hover |
Chrome button surfaces. | transparent / var(--hover) |
--slidewise-bg-pill |
Smart pill background. | var(--smart-grad) |
--slidewise-bg-unsaved-badge |
Unsaved-changes badge. | rgba(232, 80, 76, 0.12) |
The library also ships a smaller --surface-* token family for ad-hoc
card/panel surfaces (--surface-bg, --surface-ring, --surface-shadow,
plus their -hover variants) and a .slidewise-surface utility class that
applies all three together.
Every user-visible string in the chrome is overridable through the labels
prop:
<Slidewise.Root
labels={{
save: { idle: "Speichern", saving: "Wird gespeichert…", saved: "Gespeichert" },
play: "Wiedergabe",
export: "Exportieren",
undo: "Rückgängig",
redo: "Wiederherstellen",
themeToggle: { toDark: "Dunkler Modus", toLight: "Heller Modus" },
smart: "Smart",
unsavedBadge: "Nicht gespeicherte Änderungen",
fileLoadError: (msg) => `Datei konnte nicht geöffnet werden: ${msg}`,
fileLoading: "Wird geladen…",
}}
>Missing entries fall back to the English defaults (DEFAULT_LABELS).
Versioning and publishing run through changesets.
pnpm changeset # describe the impact of your change
pnpm version-packages # bump versions + update CHANGELOG (CI usually does this)
pnpm release # build + publish (CI does this on merge)CI (.github/workflows/release.yml) opens a "Version Packages" PR whenever
there are pending changesets and publishes to npm when that PR merges.
src/SlidewiseEditor.tsx/src/SlidewiseFileEditor.tsx— public entry componentssrc/components/editor/— top bar, slide rail, canvas, panelssrc/lib/pptx/— PPTX import (pptxToDeck) and export (deckToPptx)src/lib/schema/—Deckschema versioning + migratorsrc/lib/types.ts—Deck/Slide/SlideElementshapes (the contract)
{ "id": "slide-7", "background": "transparent", // inherit the layout/master/theme background "sourceLayoutId": "slideLayout12", "elements": [ /* any text / image / chart / table / diagram elements */ ] }