Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .changeset/full-fidelity-export.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
"@textcortex/slidewise": minor
---

Full-fidelity PPTX export — seven additive writer extensions so AI-authored and JSON-fed decks round-trip without losing structural content. Pristine source-XML preservation is unchanged; everything below only kicks in for edited / synthesised content the previous emitter would have silently dropped.

**PR 1 — `<a:custGeom>` writer.** Shapes with `el.path` are now emitted as `<p:sp>` containing a real `<a:custGeom><a:pathLst>` reconstructed from the SVG `d` string. M, L, H, V, C, Q, Z (absolute + relative) are translated into `<a:moveTo>` / `<a:lnTo>` / `<a:cubicBezTo>` / `<a:quadBezTo>` / `<a:close>` primitives; unsupported commands (arcs, smooth shorthands) fall through to a `<a:prstGeom prst="rect">` so the writer never throws.

**PR 2 — Gradient + image fills on shapes.** Shape `fill` strings of the form `linear-gradient(...)`, `radial-gradient(...)`, and `url(data:image/...)` now serialize to `<a:gradFill>` (with `<a:lin ang>` mapped back from CSS angle, plus `<a:path path="circle">` + `<a:fillToRect>` for radials) or `<a:blipFill>` with the bytes copied into `ppt/media/` and a fresh slide-rels entry. Solid `#hex` fills are unchanged.

**PR 3 — Slide background from JSON.** When `slide.background` is a gradient / `url(...)` string and there's no source PPTX to replay from, the writer overrides pptxgenjs's flat-hex `<p:bg>` with the synthesised gradient / image fill. Source-bytes preservation continues to win when present — no double-writes.

**PR 4 — In-app chart writer (partial).** `ChartElement` instances without `ooxmlXml` now generate a `ppt/charts/chartSW_<id>.xml` part covering bar / column / line / pie / doughnut / area with `grouping` support, plus the matching `<p:graphicFrame>` in the slide, the slide-rels entry, and the `[Content_Types].xml` override. Series + categories ship in `<c:numCache>` / `<c:strCache>` so PowerPoint renders the chart on open. **Deferred:** the embedded `xlsx` workbook — PowerPoint's "Edit Data" right-click won't show editable data until that lands. Charts re-imported from the saved PPTX still parse correctly since the importer reads the caches.

**PR 5 — `GroupElement` (writer + renderer).** New element `type: "group"` with `children: SlideElement[]`. The PPTX writer emits `<p:grpSp>` with `nvGrpSpPr` + `grpSpPr` and recurses on children; the renderer draws the group as a positioned wrapper that children render inside. **Deferred:** group-level drag / selection / resize (children remain individually draggable), and group children of element types other than `shape` / `group` round-trip lossy to PPTX (the renderer still draws them; the writer drops them) — that's the next slice of work.

**PR 6 — Embedded fonts in JSON.** New optional `Deck.fonts: FontAsset[]`. When set and no source PPTX is attached, the writer copies each font's bytes (data URL or http URL) into `ppt/fonts/`, registers the `.fntdata` extension in `[Content_Types].xml`, adds font rels to `presentation.xml.rels`, and writes a `<p:embeddedFontLst>` into `presentation.xml`. When a source PPTX with its own fonts is attached, chrome preservation carries the source's fonts through verbatim and `Deck.fonts` is ignored to avoid duplicate entries.

**PR 7 — Shadow / glow / dashed lines.** New optional fields on `ShapeElement`, `TextElement`, `LineElement`:

```ts
shadow?: { color: string; blur: number; offsetX: number; offsetY: number };
glow?: { color: string; radius: number };
dashType?: "solid" | "dash" | "dot" | "dashDot" | "lgDash" | "sysDash"; // shape + line
```

The renderer applies CSS `box-shadow` / `text-shadow` / `filter: drop-shadow` and `stroke-dasharray` / `border-style` accordingly. The writer emits `<a:effectLst><a:outerShdw>` / `<a:glow>` and `<a:prstDash val>` — for shapes that go through the synth path (gradients, paths) these are woven into the synthesised XML directly; for shapes still going through pptxgenjs, the post-processor splices the effect XML in by matching the `cNvPr/@name` we stamp on output.

**API additions** (additive, non-breaking):

- `SlideElement` union now includes `GroupElement`.
- `ShapeElement`: `shadow?`, `glow?`, `dashType?` added.
- `TextElement`: `shadow?`, `glow?` added.
- `LineElement`: `shadow?`, `glow?`, `dashType?` added (`dashed?` retained).
- `Deck.fonts?: FontAsset[]` added; `FontAsset` exported.
- `ShadowSpec`, `GlowSpec`, `DashType` exported from types.

Schema version is unchanged — all additions are optional. Existing decks parse, validate, render, and round-trip without modification.

**Companion fixes:**

- **`addText` now emits `TextElement.background` as a `fill`** on the pptxgenjs text frame. Tinted body boxes, boxed-bullet cards, and any layout-derived placeholder fill that used to disappear on export (because pptxgenjs got no `fill` option) now round-trip as a coloured rect behind the text.
- **Synth shapes inject at the low-z insertion point** (right after `<p:grpSpPr/>`) instead of being appended before `</p:spTree>`. Gradient panels, custGeom backdrops, and any other synth shape now sit beneath the text/images pptxgenjs already wrote, so "text above gradient" actually renders above the gradient instead of being covered by it.
- **`[Content_Types].xml` is pruned of dangling overrides** on every export path. pptxgenjs declares `slideMaster1..N` for every slide but only writes `slideMaster1.xml`; PowerPoint enforces the manifest strictly and refuses to open the file when declared parts are missing ("PowerPoint found a problem with content"). Keynote was lenient and just warned. The new `pruneDanglingContentTypes` pass drops `<Override>` entries whose `PartName` doesn't correspond to a real entry in the zip. Fires on the no-source / no-synth path, the no-source / with-synth path, and after `preserveDeckChrome` on the source-bytes path.
11 changes: 10 additions & 1 deletion packages/slidewise/src/SlidewiseEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import type { SlidewiseLabels } from "./compound/LabelsContext";
import type { SlidewiseSurfaces } from "./compound/SurfacesContext";
import type { SlidewiseCanvasConfig } from "./compound/CanvasContext";
import type { Transition } from "framer-motion";
import type { Deck } from "@/lib/types";
import type { Deck, WebFontAsset } from "@/lib/types";
import "./SlidewiseEditor.css";

export interface SlidewiseEditorProps {
Expand Down Expand Up @@ -143,6 +143,13 @@ export interface SlidewiseEditorProps {
* host chrome.
*/
canvas?: SlidewiseCanvasConfig;
/**
* Per-host web font registry. The editor injects `@font-face` rules so
* the canvas renders text in your brand typeface. Per-deck entries on
* `Deck.webFonts` override on family-name collisions. Has no effect on
* PPTX export — that path uses `Deck.fonts` (the embedded payload).
*/
fontRegistry?: WebFontAsset[];
/** Extra class names appended to the editor root. */
className?: string;
/** Inline style applied to the editor root. */
Expand Down Expand Up @@ -204,6 +211,7 @@ export const SlidewiseEditor = forwardRef<
labels,
surfaces,
canvas,
fontRegistry,
className,
style,
},
Expand Down Expand Up @@ -239,6 +247,7 @@ export const SlidewiseEditor = forwardRef<
labels,
surfaces,
canvas,
fontRegistry,
className,
style,
};
Expand Down
89 changes: 85 additions & 4 deletions packages/slidewise/src/components/editor/ElementView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import type {
IconElement,
EmbedElement,
ChartElement,
GroupElement,
UnknownElement,
ShadowSpec,
GlowSpec,
} from "@/lib/types";

export function ElementView({
Expand Down Expand Up @@ -39,11 +42,72 @@ export function ElementView({
return <EmbedView el={el} />;
case "chart":
return <ChartView el={el} />;
case "group":
return <GroupView el={el} editing={editing} onTextCommit={onTextCommit} />;
case "unknown":
return <UnknownView el={el} />;
}
}

/**
* Render a group as a transparent wrapper sized by the parent positioner;
* children carry slide-absolute coordinates so we translate the wrapper to
* (0,0) and absolutely-position children at (child.x - group.x, child.y - group.y).
* Child elements remain individually selectable in v1 — group-level
* drag/selection is the PR-5 follow-up.
*/
function GroupView({
el,
editing,
onTextCommit,
}: {
el: GroupElement;
editing?: boolean;
onTextCommit?: (text: string, runs?: TextRun[]) => void;
}) {
return (
<div style={{ position: "relative", width: "100%", height: "100%" }}>
{el.children.map((child) => (
<div
key={child.id}
style={{
position: "absolute",
left: child.x - el.x,
top: child.y - el.y,
width: child.w,
height: child.h,
transform: child.rotation ? `rotate(${child.rotation}deg)` : undefined,
}}
>
<ElementView el={child} editing={editing} onTextCommit={onTextCommit} />
</div>
))}
</div>
);
}

/** CSS for shadow/glow effects shared by Text / Shape / Line renderers. */
function effectStyle(
shadow: ShadowSpec | undefined,
glow: GlowSpec | undefined,
kind: "box" | "text" | "filter"
): React.CSSProperties {
const parts: string[] = [];
if (shadow) {
parts.push(`${shadow.offsetX}px ${shadow.offsetY}px ${shadow.blur}px ${shadow.color}`);
}
if (glow) {
// CSS has no native "glow" — approximate as a zero-offset shadow with the
// glow radius as blur, doubled to render visibly without too much falloff.
parts.push(`0 0 ${glow.radius}px ${glow.color}`);
parts.push(`0 0 ${glow.radius * 2}px ${glow.color}`);
}
if (!parts.length) return {};
if (kind === "box") return { boxShadow: parts.join(", ") };
if (kind === "text") return { textShadow: parts.join(", ") };
return { filter: parts.map((p) => `drop-shadow(${p})`).join(" ") };
}

function TextView({
el,
editing,
Expand Down Expand Up @@ -78,6 +142,7 @@ function TextView({
const inner: React.CSSProperties = {
width: "100%",
color: el.color,
...effectStyle(el.shadow, el.glow, "text"),
fontFamily: withGenericFallback(el.fontFamily),
fontSize: el.fontSize,
fontWeight: el.fontWeight,
Expand Down Expand Up @@ -452,7 +517,11 @@ function dashStyleFor(
function ShapeView({ el }: { el: ShapeElement }) {
const stroke = el.stroke ?? "transparent";
const sw = el.strokeWidth ?? 0;
const dash = dashStyleFor(el.strokeDash, sw);
// Accept either `strokeDash` (raw OOXML, set by the importer) or
// `dashType` (typed enum, set by AI-authored / host-supplied decks).
// Raw wins when both are set — it preserves PPTX intent exactly.
const dash = dashStyleFor(el.strokeDash ?? el.dashType, sw);
const effect = effectStyle(el.shadow, el.glow, "filter");
// Custom vector path (PPTX <a:custGeom>) takes precedence over the preset
// kind — the path coordinates already encode the actual silhouette.
if (el.path) {
Expand All @@ -462,6 +531,7 @@ function ShapeView({ el }: { el: ShapeElement }) {
preserveAspectRatio="none"
width="100%"
height="100%"
style={effect}
>
<path
d={el.path.d}
Expand All @@ -484,6 +554,7 @@ function ShapeView({ el }: { el: ShapeElement }) {
background: el.fill,
borderRadius: el.shape === "rounded" ? (el.radius ?? 16) : 0,
border: sw ? `${sw}px ${dash.borderStyle} ${stroke}` : undefined,
...effectStyle(el.shadow, el.glow, "box"),
}}
/>
);
Expand All @@ -497,12 +568,19 @@ function ShapeView({ el }: { el: ShapeElement }) {
background: el.fill,
borderRadius: "50%",
border: sw ? `${sw}px ${dash.borderStyle} ${stroke}` : undefined,
...effectStyle(el.shadow, el.glow, "box"),
}}
/>
);
}
return (
<svg viewBox="0 0 100 100" preserveAspectRatio="none" width="100%" height="100%">
<svg
viewBox="0 0 100 100"
preserveAspectRatio="none"
width="100%"
height="100%"
style={effect}
>
{el.shape === "triangle" && (
<polygon
points="50,3 97,97 3,97"
Expand Down Expand Up @@ -611,7 +689,7 @@ function LineView({ el }: { el: LineElement }) {
preserveAspectRatio="none"
width="100%"
height="100%"
style={{ overflow: "visible" }}
style={{ overflow: "visible", ...effectStyle(el.shadow, el.glow, "filter") }}
>
<line
x1={x1}
Expand All @@ -620,7 +698,10 @@ function LineView({ el }: { el: LineElement }) {
y2={y2}
stroke={el.stroke}
strokeWidth={el.strokeWidth}
strokeDasharray={el.dashed ? "12 8" : undefined}
strokeDasharray={
dashStyleFor(el.dashType, el.strokeWidth).dasharray ??
(el.dashed ? "12 8" : undefined)
}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
Expand Down
60 changes: 56 additions & 4 deletions packages/slidewise/src/compound/SlidewiseRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,14 @@ import {
useEditor,
useEditorStore,
} from "@/lib/StoreProvider";
import { collectFontFamilies, ensureGoogleFontsLoaded } from "@/lib/fonts";
import {
collectFontFamilies,
ensureGoogleFontsLoaded,
ensureWebFontsLoaded,
resolveWebFonts,
} from "@/lib/fonts";
import { resolveJsonDeck } from "@/lib/schema/json";
import type { Deck } from "@/lib/types";
import type { Deck, WebFontAsset } from "@/lib/types";
import { GridView } from "@/components/editor/GridView";
import { PlayMode } from "@/components/editor/PlayMode";
import { MotionConfig, type Transition } from "framer-motion";
Expand Down Expand Up @@ -146,6 +151,18 @@ export interface SlidewiseRootProps {
* `resolveSlideBackground` let hosts override per-slide fills.
*/
canvas?: SlidewiseCanvasConfig;
/**
* Per-host web font registry. The editor injects an `@font-face` rule
* for every entry so the canvas can render text in the brand typeface
* before a deck supplies its own `Deck.webFonts`. Use this for fonts
* licensed across all decks the host serves (e.g. Inter, your brand
* sans). Per-deck entries in `Deck.webFonts` take precedence on
* family-name collisions.
*
* Has no effect on PPTX export — the writer consults `Deck.fonts`
* (the embedded binary payload) for that path.
*/
fontRegistry?: WebFontAsset[];
/** Extra class names appended to the root. */
className?: string;
/** Inline style applied to the root. */
Expand Down Expand Up @@ -285,6 +302,7 @@ function RootInner({
labels,
surfaces,
canvas,
fontRegistry,
className,
style,
children,
Expand Down Expand Up @@ -376,11 +394,34 @@ function RootInner({
}, [deck, store]);

const instanceId = useId().replace(/[^a-z0-9]/gi, "");
const fontRegistryRef = useRef(fontRegistry);
useEffect(() => {
fontRegistryRef.current = fontRegistry;
const resolved = resolveWebFonts(store.getState().deck, fontRegistry ?? []);
ensureWebFontsLoaded(instanceId, resolved);
// Re-issue the Google Fonts link too so families covered by the
// registry no longer hit the Google endpoint (which 404s on
// private/brand families and surfaces a noisy CORS error).
const excluded = new Set(resolved.map((f) => f.family.toLowerCase()));
ensureGoogleFontsLoaded(
instanceId,
collectFontFamilies(store.getState().deck),
excluded
);
}, [fontRegistry, instanceId, store]);

useEffect(() => {
const resolved = resolveWebFonts(
store.getState().deck,
fontRegistryRef.current ?? []
);
const excluded = new Set(resolved.map((f) => f.family.toLowerCase()));
ensureGoogleFontsLoaded(
instanceId,
collectFontFamilies(store.getState().deck)
collectFontFamilies(store.getState().deck),
excluded
);
ensureWebFontsLoaded(instanceId, resolved);
return store.subscribe((state, prev) => {
// Fire onHistoryChange whenever stack depths change. Independent of
// deck identity so undo/redo always emit, even if the resulting deck
Expand Down Expand Up @@ -428,13 +469,24 @@ function RootInner({
setDirty(nextDirty);
onDirtyChangeRef.current?.(nextDirty);
}
ensureGoogleFontsLoaded(instanceId, collectFontFamilies(state.deck));
const resolved = resolveWebFonts(
state.deck,
fontRegistryRef.current ?? []
);
const excluded = new Set(resolved.map((f) => f.family.toLowerCase()));
ensureGoogleFontsLoaded(
instanceId,
collectFontFamilies(state.deck),
excluded
);
ensureWebFontsLoaded(instanceId, resolved);
});
}, [store, instanceId]);

useEffect(() => {
return () => {
ensureGoogleFontsLoaded(instanceId, []);
ensureWebFontsLoaded(instanceId, []);
};
}, [instanceId]);

Expand Down
12 changes: 12 additions & 0 deletions packages/slidewise/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,14 +102,26 @@ export type {
EnterAnim,
BaseElement,
TextElement,
TextRun,
ShapeElement,
ShapeKind,
ShapePath,
ImageElement,
LineElement,
TableElement,
IconElement,
EmbedElement,
ChartElement,
ChartKind,
ChartGrouping,
ChartSeries,
GroupElement,
UnknownElement,
ElementDraft,
ShadowSpec,
GlowSpec,
DashType,
FontAsset,
WebFontAsset,
} from "./lib/types";
export { SLIDE_W, SLIDE_H } from "./lib/types";
Loading
Loading