diff --git a/.changeset/full-fidelity-export.md b/.changeset/full-fidelity-export.md new file mode 100644 index 0000000..0b18704 --- /dev/null +++ b/.changeset/full-fidelity-export.md @@ -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 — `` writer.** Shapes with `el.path` are now emitted as `` containing a real `` reconstructed from the SVG `d` string. M, L, H, V, C, Q, Z (absolute + relative) are translated into `` / `` / `` / `` / `` primitives; unsupported commands (arcs, smooth shorthands) fall through to a `` 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 `` (with `` mapped back from CSS angle, plus `` + `` for radials) or `` 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 `` 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_.xml` part covering bar / column / line / pie / doughnut / area with `grouping` support, plus the matching `` in the slide, the slide-rels entry, and the `[Content_Types].xml` override. Series + categories ship in `` / `` 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 `` 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 `` 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 `` / `` and `` — 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 ``) instead of being appended before ``. 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 `` 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. diff --git a/packages/slidewise/src/SlidewiseEditor.tsx b/packages/slidewise/src/SlidewiseEditor.tsx index 92b7f7d..4768685 100644 --- a/packages/slidewise/src/SlidewiseEditor.tsx +++ b/packages/slidewise/src/SlidewiseEditor.tsx @@ -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 { @@ -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. */ @@ -204,6 +211,7 @@ export const SlidewiseEditor = forwardRef< labels, surfaces, canvas, + fontRegistry, className, style, }, @@ -239,6 +247,7 @@ export const SlidewiseEditor = forwardRef< labels, surfaces, canvas, + fontRegistry, className, style, }; diff --git a/packages/slidewise/src/components/editor/ElementView.tsx b/packages/slidewise/src/components/editor/ElementView.tsx index c4b09f2..583832b 100644 --- a/packages/slidewise/src/components/editor/ElementView.tsx +++ b/packages/slidewise/src/components/editor/ElementView.tsx @@ -10,7 +10,10 @@ import type { IconElement, EmbedElement, ChartElement, + GroupElement, UnknownElement, + ShadowSpec, + GlowSpec, } from "@/lib/types"; export function ElementView({ @@ -39,11 +42,72 @@ export function ElementView({ return ; case "chart": return ; + case "group": + return ; case "unknown": return ; } } +/** + * 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 ( +
+ {el.children.map((child) => ( +
+ +
+ ))} +
+ ); +} + +/** 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, @@ -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, @@ -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 ) takes precedence over the preset // kind — the path coordinates already encode the actual silhouette. if (el.path) { @@ -462,6 +531,7 @@ function ShapeView({ el }: { el: ShapeElement }) { preserveAspectRatio="none" width="100%" height="100%" + style={effect} > ); @@ -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 ( - + {el.shape === "triangle" && ( diff --git a/packages/slidewise/src/compound/SlidewiseRoot.tsx b/packages/slidewise/src/compound/SlidewiseRoot.tsx index 061b484..a4cd9a6 100644 --- a/packages/slidewise/src/compound/SlidewiseRoot.tsx +++ b/packages/slidewise/src/compound/SlidewiseRoot.tsx @@ -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"; @@ -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. */ @@ -285,6 +302,7 @@ function RootInner({ labels, surfaces, canvas, + fontRegistry, className, style, children, @@ -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 @@ -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]); diff --git a/packages/slidewise/src/index.ts b/packages/slidewise/src/index.ts index 3ea03a4..6d5e7b0 100644 --- a/packages/slidewise/src/index.ts +++ b/packages/slidewise/src/index.ts @@ -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"; diff --git a/packages/slidewise/src/lib/fonts.ts b/packages/slidewise/src/lib/fonts.ts index 31fe3d2..5843508 100644 --- a/packages/slidewise/src/lib/fonts.ts +++ b/packages/slidewise/src/lib/fonts.ts @@ -1,4 +1,4 @@ -import type { Deck, TextElement } from "@/lib/types"; +import type { Deck, TextElement, WebFontAsset } from "@/lib/types"; /** * Best-effort web-font loader for typefaces referenced inside a Deck. @@ -61,11 +61,15 @@ export function collectFontFamilies(deck: Deck): string[] { return [...families]; } -export function buildGoogleFontsHref(families: string[]): string | null { +export function buildGoogleFontsHref( + families: string[], + excluded: Set = new Set() +): string | null { const candidates = families .map((f) => f.trim()) .filter((f) => f.length > 0) - .filter((f) => !SYSTEM_FAMILIES.has(f.toLowerCase())); + .filter((f) => !SYSTEM_FAMILIES.has(f.toLowerCase())) + .filter((f) => !excluded.has(f.toLowerCase())); if (!candidates.length) return null; // Google's css2 endpoint accepts `family=Name+With+Spaces` repeated. const params = candidates @@ -81,12 +85,13 @@ export function buildGoogleFontsHref(families: string[]): string | null { */ export function ensureGoogleFontsLoaded( instanceId: string, - families: string[] + families: string[], + excludedFamilies: Set = new Set() ): () => void { if (typeof document === "undefined") return () => {}; const id = STYLESHEET_ID_PREFIX + instanceId; const existing = document.getElementById(id) as HTMLLinkElement | null; - const href = buildGoogleFontsHref(families); + const href = buildGoogleFontsHref(families, excludedFamilies); if (!href) { if (existing) existing.remove(); return () => {}; @@ -102,3 +107,123 @@ export function ensureGoogleFontsLoaded( if (!existing) document.head.appendChild(link); return () => link.remove(); } + +/** + * Inject `@font-face` rules for a `WebFontAsset[]`. Sources are loaded + * directly by the browser (TTF / OTF / WOFF / WOFF2 or a `data:` URL). + * + * `WebFontAsset` is for the in-editor preview only — the PPTX exporter + * doesn't consult it; it consults `Deck.fonts` (the embedded payload). + * The two live side-by-side so AI-authored decks can ship a renderable + * font for the editor and the obfuscated MTX/EOT payload PowerPoint + * needs, without one stomping the other. + * + * Idempotent per `instanceId`. Returns a disposer. + */ +const WEB_FONTS_STYLE_ID_PREFIX = "slidewise-web-fonts-"; + +export function ensureWebFontsLoaded( + instanceId: string, + webFonts: WebFontAsset[] +): () => void { + if (typeof document === "undefined") return () => {}; + const id = WEB_FONTS_STYLE_ID_PREFIX + instanceId; + const existing = document.getElementById(id) as HTMLStyleElement | null; + if (!webFonts.length) { + if (existing) existing.remove(); + if ( + typeof window !== "undefined" && + (window as unknown as { __slidewiseFontDebug?: boolean }) + .__slidewiseFontDebug + ) { + console.debug("[slidewise/fonts] webfonts cleared for", instanceId); + } + return () => {}; + } + const css = webFonts.map(webFontToFontFace).filter(Boolean).join("\n"); + if (!css) { + if (existing) existing.remove(); + return () => {}; + } + const style = existing ?? document.createElement("style"); + style.id = id; + if (style.textContent !== css) style.textContent = css; + if (!existing) document.head.appendChild(style); + if ( + typeof window !== "undefined" && + (window as unknown as { __slidewiseFontDebug?: boolean }) + .__slidewiseFontDebug + ) { + console.debug( + "[slidewise/fonts] injected", + webFonts.length, + "@font-face rules for", + instanceId, + webFonts.map((f) => `${f.family}/w${f.weight ?? 400}/i${f.italic ?? 0}`) + ); + } + return () => style.remove(); +} + +function webFontToFontFace(f: WebFontAsset): string { + if (!f.family || !f.src) return ""; + // pptxgenjs writes `latin typeface="EON Brix Sans"` and the renderer + // sets `font-family: "EON Brix Sans", sans-serif`. We need to register + // EXACTLY the same family name (case-sensitive — CSS doesn't care + // about case but Safari/Chrome differ on quote handling for spaces). + const fmt = formatHint(f.src); + return `@font-face{font-family:"${escapeCss(f.family)}";` + + `font-weight:${f.weight ?? 400};` + + `font-style:${f.italic ? "italic" : "normal"};` + + `font-display:swap;` + + `src:url(${JSON.stringify(f.src)})${fmt ? ` format(${JSON.stringify(fmt)})` : ""};}`; +} + +function formatHint(src: string): string | undefined { + // `format()` is an optimisation hint; the browser falls back to + // sniffing if absent. We supply it for the common cases so the + // browser doesn't issue a HEAD request first. + const lower = src.toLowerCase(); + if (lower.includes(".woff2") || lower.startsWith("data:font/woff2")) + return "woff2"; + if (lower.includes(".woff") || lower.startsWith("data:font/woff")) + return "woff"; + if (lower.includes(".ttf") || lower.startsWith("data:font/ttf") || lower.startsWith("data:application/x-font-ttf")) + return "truetype"; + if (lower.includes(".otf") || lower.startsWith("data:font/otf") || lower.startsWith("data:application/x-font-otf")) + return "opentype"; + return undefined; +} + +function escapeCss(s: string): string { + // Only escape characters that would break the `font-family` declaration. + return s.replace(/["\\]/g, "\\$&"); +} + +/** + * Collect web fonts that should drive the editor preview, merging the + * deck's own list with a host-supplied registry. The deck wins on + * family-name collisions (the deck author knows best what they want). + */ +export function resolveWebFonts( + deck: Deck, + registry: WebFontAsset[] = [] +): WebFontAsset[] { + const seen = new Set(); + const out: WebFontAsset[] = []; + const key = (f: WebFontAsset) => + `${f.family.toLowerCase()}|${f.weight ?? 400}|${f.italic ? 1 : 0}`; + for (const f of deck.webFonts ?? []) { + const k = key(f); + if (seen.has(k)) continue; + seen.add(k); + out.push(f); + } + for (const f of registry) { + const k = key(f); + if (seen.has(k)) continue; + seen.add(k); + out.push(f); + } + return out; +} diff --git a/packages/slidewise/src/lib/pptx/__tests__/synth-writers.test.ts b/packages/slidewise/src/lib/pptx/__tests__/synth-writers.test.ts new file mode 100644 index 0000000..a420c78 --- /dev/null +++ b/packages/slidewise/src/lib/pptx/__tests__/synth-writers.test.ts @@ -0,0 +1,328 @@ +import { describe, it, expect } from "vitest"; +import JSZip from "jszip"; +import { serializeDeck } from "../index"; +import { CURRENT_DECK_VERSION } from "@/lib/schema/migrate"; +import type { Deck } from "@/lib/types"; + +/** + * Tests for the synth-OOXML writers added in the full-fidelity export work: + * one assertion per PR, each round-trips a tiny showcase deck and inspects + * the generated zip for the expected OOXML constructs. + */ + +const base = { rotation: 0, z: 1 }; + +function makeDeck(slides: Deck["slides"]): Deck { + return { version: CURRENT_DECK_VERSION, title: "Synth", slides }; +} + +async function generate(deck: Deck): Promise { + const blob = await serializeDeck(deck); + const buf = await blob.arrayBuffer(); + return JSZip.loadAsync(buf); +} + +describe("synth writers (PRs 1, 2, 3, 4, 5, 6, 7)", () => { + it("PR 1: emits for shapes carrying el.path", async () => { + const deck = makeDeck([ + { + id: "s", + background: "#FFFFFF", + elements: [ + { + ...base, + id: "logo", + type: "shape", + x: 100, + y: 100, + w: 400, + h: 400, + shape: "rect", + fill: "#FF0066", + path: { + d: "M 0 0 L 100 0 L 100 100 L 0 100 Z", + viewW: 100, + viewH: 100, + fillRule: "evenodd", + }, + }, + ], + }, + ]); + + const zip = await generate(deck); + const slide = await zip.file("ppt/slides/slide1.xml")?.async("string"); + expect(slide).toBeTruthy(); + expect(slide).toContain(""); + expect(slide).toContain(""); + expect(slide).toContain(""); + expect(slide).toContain(""); + // even-odd fill rule + expect(slide).toMatch(/]*fill="darken"/); + }); + + it("PR 2: emits for shapes with linear-gradient fill", async () => { + const deck = makeDeck([ + { + id: "s", + background: "#FFFFFF", + elements: [ + { + ...base, + id: "g", + type: "shape", + x: 100, + y: 100, + w: 400, + h: 400, + shape: "rect", + fill: "linear-gradient(45deg, #FF0000 0%, #0000FF 100%)", + }, + ], + }, + ]); + const zip = await generate(deck); + const slide = await zip.file("ppt/slides/slide1.xml")?.async("string"); + expect(slide).toContain(" + media for url(data:image/...) fills", async () => { + // 1x1 transparent PNG. + const dataUrl = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="; + const deck = makeDeck([ + { + id: "s", + background: "#FFFFFF", + elements: [ + { + ...base, + id: "img", + type: "shape", + x: 0, + y: 0, + w: 200, + h: 200, + shape: "rect", + fill: `url(${dataUrl})`, + }, + ], + }, + ]); + const zip = await generate(deck); + const slide = await zip.file("ppt/slides/slide1.xml")?.async("string"); + expect(slide).toContain(""); + const rels = await zip + .file("ppt/slides/_rels/slide1.xml.rels") + ?.async("string"); + expect(rels).toMatch(/Target="\.\.\/media\/imageSlidewise_img_img\./); + const media = Object.keys(zip.files).filter((p) => + p.startsWith("ppt/media/imageSlidewise_") + ); + expect(media.length).toBe(1); + }); + + it("PR 3: writes a gradient for slide.background = linear-gradient(...)", async () => { + const deck = makeDeck([ + { + id: "s", + background: "linear-gradient(180deg, #FF0066 0%, #5500AA 100%)", + elements: [], + }, + ]); + const zip = await generate(deck); + const slide = await zip.file("ppt/slides/slide1.xml")?.async("string"); + expect(slide).toMatch(/[\s\S]* { + const deck = makeDeck([ + { + id: "s", + background: "#FFFFFF", + elements: [ + { + ...base, + id: "c1", + type: "chart", + x: 100, + y: 100, + w: 800, + h: 400, + kind: "column", + categories: ["A", "B", "C"], + series: [ + { name: "Sales", values: [10, 20, 30], color: "#4F5BD5" }, + ], + }, + ], + }, + ]); + const zip = await generate(deck); + const chartPart = Object.keys(zip.files).find((p) => + p.startsWith("ppt/charts/chartSW_") + ); + expect(chartPart).toBeTruthy(); + const chartXml = await zip.file(chartPart!)?.async("string"); + expect(chartXml).toContain(""); + expect(chartXml).toMatch(/Sales<\/c:v>/); + expect(chartXml).toMatch(/10<\/c:v>/); + const ct = await zip.file("[Content_Types].xml")?.async("string"); + expect(ct).toMatch(/chartSW_[^"]+"\s+ContentType="[^"]*chart\+xml/); + const slide = await zip.file("ppt/slides/slide1.xml")?.async("string"); + expect(slide).toContain(""); + const rels = await zip + .file("ppt/slides/_rels/slide1.xml.rels") + ?.async("string"); + expect(rels).toMatch(/Type="[^"]*relationships\/chart"/); + }); + + it("PR 5: emits with nested children for group elements", async () => { + const deck = makeDeck([ + { + id: "s", + background: "#FFFFFF", + elements: [ + { + ...base, + id: "grp", + type: "group", + x: 0, + y: 0, + w: 800, + h: 400, + children: [ + { + ...base, + id: "child1", + type: "shape", + x: 0, + y: 0, + w: 200, + h: 200, + shape: "rect", + fill: "#FF0000", + }, + { + ...base, + id: "child2", + type: "shape", + x: 400, + y: 0, + w: 200, + h: 200, + shape: "circle", + fill: "#00FF00", + }, + ], + }, + ], + }, + ]); + const zip = await generate(deck); + const slide = await zip.file("ppt/slides/slide1.xml")?.async("string"); + expect(slide).toContain(""); + expect(slide).toContain(""); + expect(slide).toMatch(/ from Deck.fonts", async () => { + // 4-byte dummy "font" payload. PowerPoint won't recognise it as a real + // font, but the writer still has to copy bytes + declare the entry. + const bytes = Buffer.from([0x00, 0x01, 0x00, 0x00]).toString("base64"); + const deck: Deck = { + version: CURRENT_DECK_VERSION, + title: "Embedded", + slides: [{ id: "s", background: "#FFF", elements: [] }], + fonts: [ + { + family: "Brand Sans", + data: `data:font/ttf;base64,${bytes}`, + weight: 400, + }, + ], + }; + const zip = await generate(deck); + const fontPath = Object.keys(zip.files).find( + (p) => p.startsWith("ppt/fonts/") && p.endsWith(".fntdata") + ); + expect(fontPath).toBeTruthy(); + const pres = await zip.file("ppt/presentation.xml")?.async("string"); + expect(pres).toContain(""); + expect(pres).toMatch(/typeface="Brand Sans"/); + const ct = await zip.file("[Content_Types].xml")?.async("string"); + expect(ct).toMatch(/Extension="fntdata"/); + const presRels = await zip + .file("ppt/_rels/presentation.xml.rels") + ?.async("string"); + expect(presRels).toMatch(/Type="[^"]*relationships\/font"/); + }); + + it("PR 7: splices into shapes with shadow", async () => { + const deck = makeDeck([ + { + id: "s", + background: "#FFFFFF", + elements: [ + { + ...base, + id: "sh", + type: "shape", + x: 100, + y: 100, + w: 400, + h: 400, + shape: "rect", + fill: "#3366FF", + shadow: { color: "#000000", blur: 8, offsetX: 4, offsetY: 4 }, + }, + ], + }, + ]); + const zip = await generate(deck); + const slide = await zip.file("ppt/slides/slide1.xml")?.async("string"); + expect(slide).toContain(""); + expect(slide).toContain(" when glow is set, and for dashType", async () => { + const deck = makeDeck([ + { + id: "s", + background: "#FFFFFF", + elements: [ + { + ...base, + id: "g", + type: "shape", + x: 0, + y: 0, + w: 200, + h: 200, + shape: "rect", + // Use a gradient fill so this shape takes the synth path and + // emits prstDash through the synth line writer (the + // pptxgenjs path supports dashType too but writes via a + // different attribute). + fill: "linear-gradient(0deg, #fff 0%, #000 100%)", + stroke: "#222222", + strokeWidth: 2, + dashType: "dashDot", + glow: { color: "#FFAA00", radius: 10 }, + }, + ], + }, + ]); + const zip = await generate(deck); + const slide = await zip.file("ppt/slides/slide1.xml")?.async("string"); + expect(slide).toContain("; +} + +const synthBySlide = new Map(); +function synthForSlide(i: number): SynthSlideEntry { + let e = synthBySlide.get(i); + if (!e) { + e = { shapeXml: [], charts: [], media: [], effectsByName: new Map() }; + synthBySlide.set(i, e); + } + return e; +} + export async function serializeDeck( deck: Deck, options: SerializeOptions = {} ): Promise { + synthBySlide.clear(); + const pptx = new pptxgen(); pptx.title = deck.title || "Untitled"; pptx.layout = "LAYOUT_WIDE"; // 13.333 × 7.5 in - for (const slide of deck.slides) { - addSlide(pptx, slide); + for (let i = 0; i < deck.slides.length; i++) { + addSlide(pptx, deck.slides[i], i); } // Use arraybuffer (universal: works in Node + browser, accepted by JSZip @@ -73,10 +115,14 @@ export async function serializeDeck( return preserveUnknowns(generated, deck, options.source); } -function addSlide(pptx: pptxgen, slide: Slide): void { +function addSlide(pptx: pptxgen, slide: Slide, slideIndex: number): void { const s = pptx.addSlide(); - s.background = { color: hexNoHash(slide.background) }; + // pptxgenjs only understands flat-hex slide backgrounds. For richer forms + // (gradients / image fills) we leave a sentinel hex here and overwrite the + // emitted `` in post-process. + s.background = { color: hexNoHash(extractSolidColor(slide.background)) }; + const synth = synthForSlide(slideIndex); const sorted = [...slide.elements].sort((a, b) => a.z - b.z); for (const el of sorted) { // Skip elements whose imported OOXML survived this far AND haven't @@ -84,8 +130,12 @@ function addSlide(pptx: pptxgen, slide: Slide): void { // verbatim, sidestepping pptxgenjs's lossy translation of // gradient / custGeom / backing fields. if (isPristineImportedElement(el)) continue; + if (shouldSynthesise(el)) { + synthesiseInto(synth, el); + continue; + } try { - addElement(s, el); + addElement(s, el, synth); } catch (err) { console.warn( `[slidewise/pptx] failed to write element ${el.id} (${el.type}):`, @@ -95,25 +145,93 @@ function addSlide(pptx: pptxgen, slide: Slide): void { } } +/** + * Does this element need synthesised OOXML rather than pptxgenjs's emitter? + * The synth path handles gradient/image fills, custGeom paths, in-app charts, + * groups — everything the public API allows that pptxgenjs would silently + * collapse to a solid colour or drop entirely. + */ +function shouldSynthesise(el: SlideElement): boolean { + if (el.type === "shape") { + if (el.path) return true; + const parsed = parseFill(el.fill); + if (parsed && (parsed.kind === "linear" || parsed.kind === "radial" || parsed.kind === "image")) { + return true; + } + return false; + } + if (el.type === "group") return true; + if (el.type === "chart" && !el.ooxmlXml) return true; + return false; +} + +function synthesiseInto(synth: SynthSlideEntry, el: SlideElement): void { + if (el.type === "shape") { + const out = synthesiseShape(el); + synth.shapeXml.push(out.xml); + for (const m of out.media) synth.media.push(m); + return; + } + if (el.type === "group") { + const out = synthesiseGroup(el as GroupElement, (child) => + renderGroupChild(child) + ); + synth.shapeXml.push(out.xml); + for (const m of out.media) synth.media.push(m); + return; + } + if (el.type === "chart") { + const out = synthesiseChart(el as ChartElement); + synth.charts.push(out); + return; + } +} + +/** + * Render a single child for ``. We only synthesise shapes/charts + * inside groups for v1 — text / image / line children remain renderable + * inside the group at the *renderer* layer, but the PPTX writer emits them + * as solid-fill rect placeholders so the group has a valid spTree. This is + * the documented "deferred sub-case" for PR 5. + */ +function renderGroupChild( + child: SlideElement +): { xml: string; media: MediaPayload[] } | null { + if (child.type === "shape") { + return synthesiseShape(child); + } + if (child.type === "group") { + return synthesiseGroup(child as GroupElement, (c) => renderGroupChild(c)); + } + // Other element types inside a group: fall back to a transparent rect so + // the group's child list stays valid. The text/image content is lost on + // round-trip — that's the PR-5 follow-up. + return null; +} + function isPristineImportedElement(el: SlideElement): boolean { const src = getElementSource(el.id); if (!src) return false; return src.snapshot === snapshotElement(el); } -function addElement(s: pptxgen.Slide, el: SlideElement): void { +function addElement( + s: pptxgen.Slide, + el: SlideElement, + synth: SynthSlideEntry +): void { switch (el.type) { case "text": - addText(s, el); + addText(s, el, synth); return; case "shape": - addShape(s, el); + addShape(s, el, synth); return; case "image": addImage(s, el); return; case "line": - addLine(s, el); + addLine(s, el, synth); return; case "table": addTable(s, el); @@ -131,6 +249,9 @@ function addElement(s: pptxgen.Slide, el: SlideElement): void { // re-emit the source verbatim so the chart and its embedded Excel // survive open/save. return; + case "group": + // Groups are handled by the synth path before reaching here. + return; case "unknown": // Preserved by preserveUnknowns() after pptxgenjs writes the zip. // The post-process step injects el.ooxmlXml into the matching @@ -139,6 +260,15 @@ function addElement(s: pptxgen.Slide, el: SlideElement): void { } } +/** Pull a solid colour out of a fill string if there is one, otherwise + * `#FFFFFF` so the synth path's `` replacement has something benign + * to overwrite. */ +function extractSolidColor(fill: string | undefined): string { + const parsed = parseFill(fill); + if (parsed && parsed.kind === "solid") return parsed.color; + return "#FFFFFF"; +} + function geometry(el: SlideElement): { x: number; y: number; @@ -155,10 +285,32 @@ function geometry(el: SlideElement): { }; } -function addText(s: pptxgen.Slide, el: TextElement): void { +function addText( + s: pptxgen.Slide, + el: TextElement, + synth: SynthSlideEntry +): void { + // Tag the shape so the post-processor can splice effect XML by name. + if (el.shadow || el.glow) { + synth.effectsByName.set( + slidewiseShapeName(el.id), + effectLstXml(el.shadow, el.glow) + ); + } + // TextElement.background fills the text box's bounding rect (PPTX + // importer sets this from layout-placeholder fills, AI-authored decks + // use it for boxed-bullet / tinted-card layouts). pptxgenjs only + // accepts solid hex via `fill`. Skip non-hex strings (gradients, + // url(...)) — those rare cases need a synth shape underlay, which is + // PR 2 territory and not common on text boxes. + const bgHex = + el.background && /^#[0-9a-fA-F]{6}$/.test(el.background) + ? hexNoHash(el.background) + : undefined; const baseOpts = { ...geometry(el), fontFace: el.fontFamily, + objectName: el.shadow || el.glow ? slidewiseShapeName(el.id) : undefined, fontSize: pxToPoints(el.fontSize), color: hexNoHash(el.color), bold: el.fontWeight >= 600, @@ -167,6 +319,7 @@ function addText(s: pptxgen.Slide, el: TextElement): void { strike: el.strike ? ("sngStrike" as const) : undefined, align: el.align, valign: el.vAlign, + fill: bgHex ? { color: bgHex } : undefined, charSpacing: el.letterSpacing ? Math.round(el.letterSpacing * 100) : undefined, @@ -220,26 +373,56 @@ const SHAPE_MAP: Record = { star: "star5", }; -function addShape(s: pptxgen.Slide, el: ShapeElement): void { +function addShape( + s: pptxgen.Slide, + el: ShapeElement, + synth: SynthSlideEntry +): void { const shapeName = SHAPE_MAP[el.shape] ?? "rect"; + if (el.shadow || el.glow) { + synth.effectsByName.set( + slidewiseShapeName(el.id), + effectLstXml(el.shadow, el.glow) + ); + } // pptxgenjs accepts shape names as strings; the typed ShapeType enum is // also exposed. Pass via `as unknown as` to bypass strict enum typing. s.addShape(shapeName as unknown as Parameters[0], { ...geometry(el), - fill: { color: hexNoHash(el.fill) }, + fill: { color: hexNoHash(extractSolidColor(el.fill)) }, line: el.stroke ? { color: hexNoHash(el.stroke), width: el.strokeWidth ?? 1, + dashType: shapeDashType(el), } : { type: "none" }, rectRadius: el.shape === "rounded" && el.radius != null ? clamp01(el.radius / Math.min(el.w, el.h)) : undefined, + objectName: el.shadow || el.glow ? slidewiseShapeName(el.id) : undefined, }); } +function lineDashType( + el: LineElement +): "solid" | "dash" | "dashDot" | "lgDash" | "sysDash" | "sysDot" { + const dt = el.dashType ?? (el.dashed ? "dash" : "solid"); + if (dt === "dot") return "sysDot"; + return dt; +} + +function shapeDashType( + el: ShapeElement +): "solid" | "dash" | "dashDot" | "lgDash" | "sysDash" | "sysDot" | undefined { + // Our public `DashType` includes plain "dot" (OOXML allows it) but + // pptxgenjs's enum doesn't; alias dot → sysDot so the legacy enum is happy. + if (!el.dashType) return undefined; + if (el.dashType === "dot") return "sysDot"; + return el.dashType; +} + function addImage(s: pptxgen.Slide, el: ImageElement): void { const opts: Parameters[0] = { ...geometry(el), @@ -258,7 +441,17 @@ function addImage(s: pptxgen.Slide, el: ImageElement): void { s.addImage(opts); } -function addLine(s: pptxgen.Slide, el: LineElement): void { +function addLine( + s: pptxgen.Slide, + el: LineElement, + synth: SynthSlideEntry +): void { + if (el.shadow || el.glow) { + synth.effectsByName.set( + slidewiseShapeName(el.id), + effectLstXml(el.shadow, el.glow) + ); + } s.addShape( "line" as unknown as Parameters[0], { @@ -266,9 +459,10 @@ function addLine(s: pptxgen.Slide, el: LineElement): void { line: { color: hexNoHash(el.stroke), width: el.strokeWidth, - dashType: el.dashed ? "dash" : "solid", + dashType: lineDashType(el), endArrowType: el.arrow ? "triangle" : "none", }, + objectName: el.shadow || el.glow ? slidewiseShapeName(el.id) : undefined, } ); } @@ -343,19 +537,58 @@ async function preserveUnknowns( deck: Deck, explicitSource?: Blob | ArrayBuffer | Uint8Array ): Promise { - const wrapBlob = () => new Blob([generated], { type: PPTX_MIME }); // Prefer the caller-supplied source (survives state cloning / localStorage // rehydrate); fall back to the non-enumerable attachment from parsePptx // for the "parse → serialize" happy path with no state in between. const sourceBuffer = await resolveSource(deck, explicitSource); - if (!sourceBuffer) return wrapBlob(); + + // Synthesised OOXML from the writer always needs post-processing — even + // when there's no source PPTX, gradients / custGeom / charts / embedded + // fonts have to be spliced into the generated zip. + const hasSynth = + synthBySlide.size > 0 || + deck.slides.some( + (s) => { + const parsed = parseFill(s.background); + return parsed && parsed.kind !== "solid"; + } + ) || + (deck.fonts && deck.fonts.length > 0); + + if (!sourceBuffer && !hasSynth) { + // Still strip dangling Content_Types overrides — pptxgenjs declares + // slideMaster1..N for every slide but only writes slideMaster1.xml. + // PowerPoint refuses to open the file when declared parts are missing + // (Keynote is lenient and just warns). Always sanitise. + const outZip = await JSZip.loadAsync(generated); + await pruneDanglingContentTypes(outZip); + await sanitisePresentationXml(outZip); + await sanitiseSlideXml(outZip); + await sanitiseRels(outZip); + pruneEmptyDirectories(outZip); + return outZip.generateAsync({ type: "blob", mimeType: PPTX_MIME }); + } + if (!sourceBuffer && hasSynth) { + // No source: still run the synth-only post-process. The chrome / EMF / + // slide-bg replay paths short-circuit on a null source archive. + const outZip = await JSZip.loadAsync(generated); + await applySynth(outZip, deck); + await applySynthSlideBackgrounds(outZip, deck); + await applyEmbeddedFontsFromJson(outZip, deck); + await pruneDanglingContentTypes(outZip); + await sanitisePresentationXml(outZip); + await sanitiseSlideXml(outZip); + await sanitiseRels(outZip); + pruneEmptyDirectories(outZip); + return outZip.generateAsync({ type: "blob", mimeType: PPTX_MIME }); + } const unknownsBySlide = collectUnknowns(deck); const pristinesBySlide = collectPristineImports(deck); const [outZip, srcZip] = await Promise.all([ JSZip.loadAsync(generated), - JSZip.loadAsync(sourceBuffer), + JSZip.loadAsync(sourceBuffer as ArrayBuffer), ]); // The source's slide-XML paths (in deck order). Used as a fallback when @@ -418,6 +651,22 @@ async function preserveUnknowns( // available so gradients survive intact. await preserveSlideBackgrounds(outZip, srcZip, deck, sourceSlidePaths); + // Synthesised content (custGeom shapes, gradient fills, in-app charts, + // groups, effect splices) — applied after source preservation so we never + // overwrite the source's pristine fragments. JSON gradient backgrounds + // only fire when the source preserve found nothing (preserveSlideBackgrounds + // already wrote any source-provided bg). + await applySynth(outZip, deck); + await applySynthSlideBackgrounds(outZip, deck); + // Embedded fonts from deck.fonts only fire when chrome preservation didn't + // copy any — avoids duplicating font entries when both source + deck.fonts + // are set. + await applyEmbeddedFontsFromJson(outZip, deck); + await sanitisePresentationXml(outZip); + await sanitiseSlideXml(outZip); + await sanitiseRels(outZip); + pruneEmptyDirectories(outZip); + // JSZip's blob output preserves the OOXML mime type set by pptxgenjs. return outZip.generateAsync({ type: "blob", mimeType: PPTX_MIME }); } @@ -1497,3 +1746,551 @@ function isDataUrl(src: string): boolean { function clamp01(n: number): number { return Math.max(0, Math.min(1, n)); } + +// -- Synthesised-OOXML post-process (PRs 1–7) ------------------------------ + +/** + * Splice the synthesised shape / group / chart OOXML accumulated in + * `synthBySlide` into the generated zip. Rewrites marker rIds to fresh + * per-slide rIds and writes media + chart parts as needed. + */ +async function applySynth(outZip: JSZip, deck: Deck): Promise { + for (let i = 0; i < deck.slides.length; i++) { + const synth = synthBySlide.get(i); + if (!synth) continue; + const slidePath = `ppt/slides/slide${i + 1}.xml`; + const relsPath = `ppt/slides/_rels/slide${i + 1}.xml.rels`; + const slideFile = outZip.file(slidePath); + if (!slideFile) continue; + let slideXml = await slideFile.async("string"); + + let relsXml = + (await outZip.file(relsPath)?.async("string")) ?? + `\n`; + + const rels = parseRels(relsXml); + let nextRid = highestRid(rels) + 1; + const newRelLines: string[] = []; + + // Combine shape/group XML with chart graphicFrames. Marker rIds embed + // their owning element id, so we walk shape XML in emission order and + // pair each *first-seen* marker with a media payload of the matching + // scope. That keeps multi-image shapes correct without a separate map. + const mediaQueue = [...synth.media]; + const consumeMedia = (): MediaPayload | undefined => mediaQueue.shift(); + const shapeBlobs: string[] = []; + for (const shapeXml of synth.shapeXml) { + let rewritten = shapeXml; + const markers = unique(shapeXml.match(RID_MARKER_RE) ?? []); + for (const marker of markers) { + const rid = `rId${nextRid++}`; + rewritten = rewritten.replaceAll(marker, rid); + const media = consumeMedia(); + if (media) { + outZip.file(media.fullPath, media.data, { binary: true }); + newRelLines.push(buildRelXml(rid, media.relType, media.relTarget)); + } + } + shapeBlobs.push(rewritten); + } + + // Charts: rewrite marker rIds, write part + rels, register Content_Types. + for (const chart of synth.charts) { + const ridMarkers = unique(chart.graphicFrameXml.match(RID_MARKER_RE) ?? []); + let frame = chart.graphicFrameXml; + for (const marker of ridMarkers) { + const rid = `rId${nextRid++}`; + frame = frame.replaceAll(marker, rid); + // Slide rels target is relative to ppt/slides. Chart part lives at + // ppt/charts/chartSW_X.xml. + const target = `../charts/${chart.partPath.replace(/^ppt\/charts\//, "")}`; + newRelLines.push( + buildRelXml( + rid, + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart", + target + ) + ); + } + outZip.file(chart.partPath, chart.chartXml); + outZip.file(chart.partRelsPath, chart.chartRelsXml); + await registerChartContentType(outZip, chart.partPath); + shapeBlobs.push(frame); + } + + if (shapeBlobs.length) { + // Prepend at the low-z position (right after ``) so + // synth shapes — gradient panels, custGeom backdrops, the underlay + // for text-on-tint cards — sit BELOW the text/images pptxgenjs + // already wrote. Appending at `` would put them on top + // and cover the very content they're supposed to back. + const insertAt = findSpTreeContentInsertionPoint(slideXml); + if (insertAt >= 0) { + slideXml = + slideXml.slice(0, insertAt) + + shapeBlobs.join("") + + slideXml.slice(insertAt); + } else { + const close = slideXml.lastIndexOf(""); + if (close >= 0) { + slideXml = + slideXml.slice(0, close) + + shapeBlobs.join("") + + slideXml.slice(close); + } + } + } + + // PR 7: splice `` into pptxgenjs-emitted shapes by name. + if (synth.effectsByName.size) { + slideXml = spliceEffectsByName(slideXml, synth.effectsByName); + } + + if (newRelLines.length) { + const insertAt = relsXml.lastIndexOf(""); + relsXml = + insertAt >= 0 + ? relsXml.slice(0, insertAt) + + newRelLines.join("") + + relsXml.slice(insertAt) + : relsXml.replace( + /]*>/, + (m) => `${m}${newRelLines.join("")}` + ); + outZip.file(relsPath, relsXml); + } + outZip.file(slidePath, slideXml); + } +} + +function unique(arr: T[]): T[] { + return Array.from(new Set(arr)); +} + +async function registerChartContentType( + outZip: JSZip, + partPath: string +): Promise { + const ctFile = outZip.file("[Content_Types].xml"); + if (!ctFile) return; + const xml = await ctFile.async("string"); + if (xml.includes(`PartName="/${partPath}"`)) return; + const override = ``; + const close = xml.lastIndexOf(""); + if (close < 0) return; + outZip.file( + "[Content_Types].xml", + xml.slice(0, close) + override + xml.slice(close) + ); +} + +/** + * Splice an `...` block into each `` / + * `` whose `` matches one of the keys. + * Inserts the block just before ``, replacing any existing + * effectLst inside the same spPr. + */ +function spliceEffectsByName( + slideXml: string, + effectsByName: Map +): string { + // Match whole `` blocks (so we don't accidentally consume + // the outer `` cNvPr's tail into the next shape's spPr). + return slideXml.replace( + //g, + (sp: string) => { + const nameMatch = /]*?name="([^"]*)"/.exec(sp); + if (!nameMatch || !nameMatch[1]) return sp; + const eff = effectsByName.get(nameMatch[1]); + if (!eff) return sp; + // Insert just before the FIRST `` inside the shape; that's + // the spPr that owns the visual properties. Strip any existing empty + // effectLst before splicing. + const cleaned = sp.replace(//, ""); + const at = cleaned.indexOf(""); + if (at < 0) return sp; + return cleaned.slice(0, at) + eff + cleaned.slice(at); + } + ); +} + +/** + * Write JSON-defined gradient / image slide backgrounds (PR 3). Only fires + * when the source-PPTX bg preservation pass left the slide's `` empty + * — that ensures source bytes always win. + */ +async function applySynthSlideBackgrounds( + outZip: JSZip, + deck: Deck +): Promise { + for (let i = 0; i < deck.slides.length; i++) { + const slide = deck.slides[i]; + const parsed = parseFill(slide.background); + if (!parsed) continue; + if (parsed.kind === "solid") continue; + const slidePath = `ppt/slides/slide${i + 1}.xml`; + const relsPath = `ppt/slides/_rels/slide${i + 1}.xml.rels`; + const slideFile = outZip.file(slidePath); + if (!slideFile) continue; + let slideXml = await slideFile.async("string"); + + // If the slide already has a non-solid `` (source preservation + // wrote one), leave it alone. Solid `` from pptxgenjs gets + // overwritten below — that's what we want. + const existing = /|]*\/\s*>/.exec(slideXml); + if (existing) { + // Detect "this is a richer bg than solid" by looking for gradFill / + // blipFill / pattFill — those come from source preservation. + if (/\n`; + if (synth.media.length) { + const rels = parseRels(relsXml); + let nextRid = highestRid(rels) + 1; + const newRelLines: string[] = []; + const markers = unique(bgXml.match(RID_MARKER_RE) ?? []); + for (let mi = 0; mi < markers.length; mi++) { + const rid = `rId${nextRid++}`; + bgXml = bgXml.replaceAll(markers[mi], rid); + const media = synth.media[mi]; + if (!media) break; + outZip.file(media.fullPath, media.data, { binary: true }); + newRelLines.push(buildRelXml(rid, media.relType, media.relTarget)); + } + if (newRelLines.length) { + const at = relsXml.lastIndexOf(""); + relsXml = + at >= 0 + ? relsXml.slice(0, at) + newRelLines.join("") + relsXml.slice(at) + : relsXml.replace( + /]*>/, + (m) => `${m}${newRelLines.join("")}` + ); + outZip.file(relsPath, relsXml); + } + } + + if (existing) { + slideXml = slideXml.replace( + /|]*\/\s*>/, + bgXml + ); + } else { + const cSldOpen = /]*>/.exec(slideXml); + if (cSldOpen) { + const at = cSldOpen.index + cSldOpen[0].length; + slideXml = slideXml.slice(0, at) + bgXml + slideXml.slice(at); + } + } + outZip.file(slidePath, slideXml); + } +} + +/** + * Write JSON-defined embedded fonts (PR 6). Only fires when chrome + * preservation didn't already copy fonts from a source — that's detected + * by checking whether `ppt/fonts/` is populated. + */ +async function applyEmbeddedFontsFromJson( + outZip: JSZip, + deck: Deck +): Promise { + if (!deck.fonts || !deck.fonts.length) return; + const existingFonts: string[] = []; + outZip.forEach((path) => { + if (path.startsWith("ppt/fonts/") && path.endsWith(".fntdata")) { + existingFonts.push(path); + } + }); + if (existingFonts.length) return; + + const descriptors = await synthesiseEmbeddedFonts(deck.fonts); + if (!descriptors.length) return; + + // Write font bytes. + for (const d of descriptors) { + for (const p of d.payloads) { + outZip.file(p.fullPath, p.data, { binary: true }); + } + } + + // Register `.fntdata` Default content type. + const ctFile = outZip.file("[Content_Types].xml"); + if (ctFile) { + let ct = await ctFile.async("string"); + if (!/Extension="fntdata"/i.test(ct)) { + ct = ct.replace( + /]*>/, + (m) => + `${m}` + ); + outZip.file("[Content_Types].xml", ct); + } + } + + // Add font rels to presentation.xml.rels and rewrite the marker rIds in + // each `` to those allocated rIds. + const presRelsFile = outZip.file("ppt/_rels/presentation.xml.rels"); + if (!presRelsFile) return; + let presRelsXml = await presRelsFile.async("string"); + const presRels = parseRels(presRelsXml); + let nextRid = highestRid(presRels) + 1; + const newRels: string[] = []; + const embeddedFontXml: string[] = []; + for (const d of descriptors) { + let xml = d.embeddedFontXml; + for (const r of d.rels) { + const rid = `rId${nextRid++}`; + xml = xml.replaceAll(r.ridMarker, rid); + newRels.push(buildRelXml(rid, r.relType, r.target)); + } + embeddedFontXml.push(xml); + } + const insertAt = presRelsXml.lastIndexOf(""); + presRelsXml = + insertAt >= 0 + ? presRelsXml.slice(0, insertAt) + + newRels.join("") + + presRelsXml.slice(insertAt) + : presRelsXml.replace( + /]*>/, + (m) => `${m}${newRels.join("")}` + ); + outZip.file("ppt/_rels/presentation.xml.rels", presRelsXml); + + // Splice `` into presentation.xml (after sldIdLst). + const presFile = outZip.file("ppt/presentation.xml"); + if (!presFile) return; + let presXml = await presFile.async("string"); + const fontLst = `${embeddedFontXml.join("")}`; + if (//, + fontLst + ); + } else { + const after = /<\/p:sldIdLst>/.exec(presXml); + if (after) { + const at = after.index + after[0].length; + presXml = presXml.slice(0, at) + fontLst + presXml.slice(at); + } + } + outZip.file("ppt/presentation.xml", presXml); +} + +/** + * Strip `` entries from `[Content_Types].xml` whose `PartName` + * doesn't correspond to a file actually present in the output zip. + * + * pptxgenjs has a long-standing quirk: it declares `slideMaster1.xml` + * through `slideMasterN.xml` (one per slide) even though it only ever + * 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 is lenient and + * just emits the "may look different" warning. Removing the stale + * overrides makes the file legal for both apps. + */ +async function pruneDanglingContentTypes(outZip: JSZip): Promise { + const ctFile = outZip.file("[Content_Types].xml"); + if (!ctFile) return; + const xml = await ctFile.async("string"); + // Collect every actual file path in the zip so we can answer "does + // /foo/bar.xml exist?" in O(1). + const existing = new Set(); + outZip.forEach((path, entry) => { + if (!entry.dir) existing.add("/" + path); + }); + // `[^>]*` (not `[^/]*`) so ContentType values containing slashes — + // every PPTX MIME type does — don't break the match. + const pruned = xml.replace( + /]*PartName="([^"]+)"[^>]*\/>/g, + (match, partName: string) => (existing.has(partName) ? match : "") + ); + if (pruned !== xml) outZip.file("[Content_Types].xml", pruned); +} + +/** + * Reorder top-level children of `` to match the OOXML + * schema's required sequence. pptxgenjs emits + * sldMasterIdLst → sldIdLst → notesMasterIdLst → sldSz → notesSz + * but CT_Presentation mandates + * sldMasterIdLst → notesMasterIdLst → handoutMasterIdLst → sldIdLst + * → sldSz → notesSz → … → embeddedFontLst → … + * Out-of-sequence `notesMasterIdLst` (and `embeddedFontLst` if PR 6 ran) + * triggers PowerPoint's "found a problem with content" repair dialog + * even though the underlying content is valid. Keynote is lenient and + * just renders. + * + * We don't rebuild the XML; we just relocate the affected elements, + * preserving their inner text verbatim. Safe to call when the elements + * are already in order — the function is a no-op. + */ +async function sanitisePresentationXml(outZip: JSZip): Promise { + const file = outZip.file("ppt/presentation.xml"); + if (!file) return; + let xml = await file.async("string"); + const original = xml; + + // The schema-correct slot for notesMasterIdLst is immediately after + // sldMasterIdLst and before sldIdLst. + const extractBlock = (tag: string): string | null => { + const re = new RegExp(`]*>[\\s\\S]*?`); + const m = re.exec(xml); + if (!m) return null; + xml = xml.slice(0, m.index) + xml.slice(m.index + m[0].length); + return m[0]; + }; + + const insertAfter = (anchorTag: string, block: string): void => { + const closeAnchor = ``; + const idx = xml.indexOf(closeAnchor); + if (idx < 0) return; + const at = idx + closeAnchor.length; + xml = xml.slice(0, at) + block + xml.slice(at); + }; + + const notesBlock = extractBlock("notesMasterIdLst"); + if (notesBlock) insertAfter("sldMasterIdLst", notesBlock); + + const handoutBlock = extractBlock("handoutMasterIdLst"); + if (handoutBlock) { + // handoutMasterIdLst sits between notesMasterIdLst (if present) and + // sldIdLst. If notes was just inserted, anchor on it; otherwise on + // sldMasterIdLst. + const anchor = /\<\/p:notesMasterIdLst\>/.test(xml) + ? "notesMasterIdLst" + : "sldMasterIdLst"; + insertAfter(anchor, handoutBlock); + } + + // embeddedFontLst belongs AFTER smartTags / before custShowLst — in + // practice, immediately after notesSz works for every deck we emit. + const fontLstBlock = extractBlock("embeddedFontLst"); + if (fontLstBlock) { + // notesSz is self-closing in pptxgenjs output, so we look for the + // self-closing tag end. + const m = /]*\/>/.exec(xml); + if (m) { + const at = m.index + m[0].length; + xml = xml.slice(0, at) + fontLstBlock + xml.slice(at); + } else { + // Fallback: put it back where we found it. Shouldn't happen — every + // pptxgenjs deck has notesSz. + insertAfter("sldIdLst", fontLstBlock); + } + } + + if (xml !== original) outZip.file("ppt/presentation.xml", xml); +} + +/** + * Strip insignificant whitespace from every `.rels` file in the zip. + * pptxgenjs writes some rels files (notesMaster1, notesSlideN) with + * pretty-printed indentation including whitespace BETWEEN the XML + * declaration and the `` root. PowerPoint's strict + * package validator rejects this even though plain XML 1.0 allows + * whitespace in the prolog. Keynote is lenient. + * + * Conservative: only collapses whitespace OUTSIDE the + * Relationships element and between its children — both are + * element-only content models with no semantic whitespace. + */ +async function sanitiseRels(outZip: JSZip): Promise { + const relsPaths: string[] = []; + outZip.forEach((path) => { + if (path.endsWith(".rels")) relsPaths.push(path); + }); + for (const p of relsPaths) { + const file = outZip.file(p); + if (!file) continue; + const xml = await file.async("string"); + let updated = xml; + // Drop whitespace between `?>` and `)\s+(` children. + updated = updated.replace( + /(]*\/>)\s+(<(?:Relationship\b|\/Relationships>))/g, + "$1$2" + ); + // Drop whitespace immediately before `` close. + updated = updated.replace(/\s+<\/Relationships>/, ""); + // Drop whitespace inside the `` open tag's content area. + updated = updated.replace(/(]*>)\s+( { + if (entry.dir) dirPaths.push(path); + else filePaths.push(path); + }); + for (const dir of dirPaths) { + const prefix = dir.endsWith("/") ? dir : dir + "/"; + const hasContent = filePaths.some((f) => f.startsWith(prefix)); + if (!hasContent) outZip.remove(dir); + } +} + +/** + * Walk every `ppt/slides/slideN.xml` and collapse consecutive `` + * elements inside the same `` to a single one. pptxgenjs sometimes + * emits two adjacent `` blocks (typically when a multi-run text + * item with `breakLine` lands at a paragraph boundary) — CT_TextParagraph + * permits only one `pPr` per ``, so PowerPoint flags the file as + * corrupt and offers to repair. Keynote is lenient and silently merges. + * + * The collapsed form keeps the FIRST occurrence (matches what PowerPoint + * resolves to in practice). When the two are identical it's a no-op + * semantically; when they differ, the first wins. + */ +async function sanitiseSlideXml(outZip: JSZip): Promise { + const slidePaths: string[] = []; + outZip.forEach((path) => { + if (/^ppt\/slides\/slide\d+\.xml$/.test(path)) slidePaths.push(path); + }); + for (const p of slidePaths) { + const file = outZip.file(p); + if (!file) continue; + const xml = await file.async("string"); + // Match a `` (with or without children) followed immediately + // by another `` and drop the second. Repeat until no more + // pairs remain so 3+ consecutive blocks collapse to one. + const pprPair = + /(]*(?:\/>|>[\s\S]*?<\/a:pPr>))(]*(?:\/>|>[\s\S]*?<\/a:pPr>))/g; + let updated = xml; + let prev; + do { + prev = updated; + updated = updated.replace(pprPair, "$1"); + } while (updated !== prev); + // pptxgenjs writes some element-only nodes with whitespace text + // between open/close (` `) which PowerPoint + // flags. Collapse to self-closing for elements that only have + // whitespace content. Restricted to nvSpPr/cNvPr-style empties so we + // don't accidentally normalise text frames. + updated = updated.replace( + /<(p:cNvPr|p:nvPr|p:cNvSpPr|p:cNvGrpSpPr|p:cNvPicPr|a:ln|a:avLst|a:lstStyle|a:bodyPr|a:gdLst|a:ahLst|a:cxnLst)\b([^>]*)>\s+<\/\1>/g, + "<$1$2/>" + ); + if (updated !== xml) outZip.file(p, updated); + } +} diff --git a/packages/slidewise/src/lib/pptx/pptxToDeck.ts b/packages/slidewise/src/lib/pptx/pptxToDeck.ts index 9b767bb..7608c76 100644 --- a/packages/slidewise/src/lib/pptx/pptxToDeck.ts +++ b/packages/slidewise/src/lib/pptx/pptxToDeck.ts @@ -16,6 +16,7 @@ import type { ChartElement, ChartSeries, UnknownElement, + FontAsset, } from "@/lib/types"; import { SLIDE_W, SLIDE_H } from "@/lib/types"; import { CURRENT_DECK_VERSION } from "@/lib/schema/migrate"; @@ -321,11 +322,13 @@ export async function parsePptx( // a redundant fallback for callers that hold the deck object directly. const sourcePptxId = nanoid(12); sourceBufferCache.set(sourcePptxId, sourceBuffer); + const fonts = await readEmbeddedFonts(zip, presentationXml, presentationRels); const deck: Deck = { version: CURRENT_DECK_VERSION, title, slides, sourcePptxId, + ...(fonts.length ? { fonts } : {}), }; Object.defineProperty(deck, SOURCE_PPTX, { value: sourceBuffer, @@ -4368,6 +4371,78 @@ function relsPathFor(xmlPath: string): string { return xmlPath.replace(/([^/]+)\.xml$/, "_rels/$1.xml.rels"); } +/** + * Walk the source's `` and pull each referenced + * font part out of `ppt/fonts/`, base64-encode the bytes, and return + * a `FontAsset[]` the serializer can write back. This makes the JSON + * deck self-contained: hosts can save the deck to disk, reload from + * JSON-only (no source bytes), and the EON / Inter / brand fonts + * still come through on export. + * + * Each `` can carry up to four style rels — regular / + * bold / italic / boldItalic. We emit one `FontAsset` per style with + * `weight` and `italic` set accordingly. The serializer combines + * same-family assets back into a single `` entry. + * + * Best-effort: when `ppt/fonts/` is absent or a rel points at a + * missing target, that style is skipped silently. Won't throw on + * malformed input — diagnostic only. + */ +async function readEmbeddedFonts( + zip: JSZip, + presentationXml: any, + presentationRels: Rels +): Promise { + const list = presentationXml?.["p:presentation"]?.["p:embeddedFontLst"]; + if (!list) return []; + const fonts = asArray(list["p:embeddedFont"]); + const out: FontAsset[] = []; + const styles: Array<{ key: string; weight: number; italic: boolean }> = [ + { key: "p:regular", weight: 400, italic: false }, + { key: "p:bold", weight: 700, italic: false }, + { key: "p:italic", weight: 400, italic: true }, + { key: "p:boldItalic", weight: 700, italic: true }, + ]; + for (const entry of fonts) { + const family = entry?.["p:font"]?.["@_typeface"]; + if (!family) continue; + for (const style of styles) { + const rid = entry?.[style.key]?.["@_r:id"]; + if (!rid) continue; + const target = presentationRels.byId.get(rid)?.target; + if (!target) continue; + const fullPath = normalisePath(target, "ppt"); + const file = zip.file(fullPath); + if (!file) continue; + const bytes = await file.async("uint8array"); + const base64 = uint8ArrayToBase64(bytes); + out.push({ + family, + data: `data:application/x-fontdata;base64,${base64}`, + weight: style.weight, + italic: style.italic, + }); + } + } + return out; +} + +function uint8ArrayToBase64(bytes: Uint8Array): string { + // Stream-friendly base64 — typed-array → binary-string → btoa works + // for files up to a few MB without blowing the call stack. Fonts are + // typically 50–500 KB so this is comfortable. + let binary = ""; + const chunk = 0x8000; + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode( + ...bytes.subarray(i, Math.min(i + chunk, bytes.length)) + ); + } + return typeof btoa !== "undefined" + ? btoa(binary) + : Buffer.from(binary, "binary").toString("base64"); +} + async function readTitle(zip: JSZip): Promise { const file = zip.file("docProps/core.xml"); if (!file) return "Untitled"; diff --git a/packages/slidewise/src/lib/pptx/pptxWriters.ts b/packages/slidewise/src/lib/pptx/pptxWriters.ts new file mode 100644 index 0000000..ab509a7 --- /dev/null +++ b/packages/slidewise/src/lib/pptx/pptxWriters.ts @@ -0,0 +1,920 @@ +/** + * Synthesised-OOXML writers for element forms pptxgenjs can't emit faithfully: + * + * - `` shapes (PR 1) — arbitrary SVG paths. + * - `` / `` shape fills (PR 2) — gradients + image fills. + * - Slide `` gradient / image backgrounds (PR 3). + * - In-app `` parts (PR 4) — partial: bar/column/line/pie/doughnut/area + * with cached values, no embedded xlsx. PowerPoint renders from the cache. + * - `` group shapes (PR 5) — writer-only. + * - `` + `ppt/fonts/` (PR 6). + * - `` shadow / glow (PR 7) — woven into shape XML; for shapes + * pptxgenjs still emits, spliced in via post-process by matching the + * `cNvPr.name` we tag on output. + * + * The orchestration lives in deckToPptx.ts. This module is intentionally + * stateless — every function takes its slide / deck inputs and returns XML + * strings + zip-side-effect descriptors, leaving the actual JSZip writes to + * the orchestrator. That keeps the OOXML emission unit-testable in isolation. + */ + +import type { + Deck, + SlideElement, + ShapeElement, + GroupElement, + ChartElement, + ShadowSpec, + GlowSpec, + FontAsset, + Slide, +} from "@/lib/types"; +import { pxToEmu, EMU_PER_POINT } from "./units"; + +// -- Identifiers ------------------------------------------------------------ + +/** + * `cNvPr` name we stamp on synthesised shapes so the post-processor (in + * `deckToPptx.ts`) can splice effects into pptxgenjs-emitted shapes by name + * match. Includes the element id so a deck with multiple identical shapes + * keeps its 1:1 mapping. + */ +export const slidewiseShapeName = (elementId: string): string => + `slidewise:${elementId}`; + +let nextNvId = 100000; +/** Returns a numeric id unique within one writer pass. PPTX needs `cNvPr/@id` + * to be unique per ``; we bias the counter high to avoid colliding + * with whatever pptxgenjs allocated for the same spTree. */ +export function freshNvId(): number { + return nextNvId++; +} + +// -- Fill parsing ----------------------------------------------------------- + +export type ParsedFill = + | { kind: "solid"; color: string; alpha?: number } + | { kind: "transparent" } + | { kind: "linear"; angle: number; stops: GradStop[] } + | { kind: "radial"; focusX: number; focusY: number; stops: GradStop[]; shape: "circle" | "ellipse" } + | { kind: "image"; src: string }; + +export interface GradStop { + /** Percentage 0..100. */ + pos: number; + /** `#RRGGBB`. */ + color: string; + /** 0..1, optional alpha. */ + alpha?: number; +} + +/** + * Classify a CSS-ish fill string. Mirrors the inverse of `extractShapeFill` + * in pptxToDeck.ts. Returns null when the form isn't one we synthesise (e.g. + * named colors, oklch(), gradients with unparseable stops). Unknown forms + * fall back to solid black so the writer never throws. + */ +export function parseFill(fill: string | undefined): ParsedFill | null { + if (!fill) return null; + const s = fill.trim(); + if (!s || s === "transparent") return { kind: "transparent" }; + if (s.startsWith("#")) { + return { kind: "solid", color: normaliseHex(s) }; + } + const rgb = /^rgba?\(([^)]+)\)$/i.exec(s); + if (rgb) { + const parts = rgb[1].split(",").map((p) => p.trim()); + const r = clampByte(parseInt(parts[0], 10)); + const g = clampByte(parseInt(parts[1], 10)); + const b = clampByte(parseInt(parts[2], 10)); + const a = parts[3] != null ? Number(parts[3]) : 1; + return { + kind: "solid", + color: `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase(), + alpha: Number.isFinite(a) ? a : 1, + }; + } + if (s.startsWith("linear-gradient(")) { + const inner = s.slice("linear-gradient(".length, s.lastIndexOf(")")); + const parts = splitTopLevelCommas(inner); + let angle = 90; + let rest = parts; + const angMatch = /^(-?\d+(?:\.\d+)?)deg$/.exec(parts[0]); + if (angMatch) { + angle = Number(angMatch[1]); + rest = parts.slice(1); + } + const stops = parseStops(rest); + if (!stops.length) return null; + return { kind: "linear", angle, stops }; + } + if (s.startsWith("radial-gradient(")) { + const inner = s.slice("radial-gradient(".length, s.lastIndexOf(")")); + const parts = splitTopLevelCommas(inner); + let focusX = 50; + let focusY = 50; + let shape: "circle" | "ellipse" = "ellipse"; + let stopParts = parts; + // First segment may be `circle at X% Y%` / `ellipse at X% Y%`. + const head = parts[0]?.trim() ?? ""; + if (/^(circle|ellipse)\b/i.test(head)) { + if (/^circle\b/i.test(head)) shape = "circle"; + const at = /at\s+(-?\d+(?:\.\d+)?)%\s+(-?\d+(?:\.\d+)?)%/.exec(head); + if (at) { + focusX = Number(at[1]); + focusY = Number(at[2]); + } + stopParts = parts.slice(1); + } + const stops = parseStops(stopParts); + if (!stops.length) return null; + return { kind: "radial", focusX, focusY, stops, shape }; + } + const url = /^url\(\s*['"]?([^'")\s]+)['"]?\s*\)$/.exec(s); + if (url) { + return { kind: "image", src: url[1] }; + } + return null; +} + +function parseStops(parts: string[]): GradStop[] { + const out: GradStop[] = []; + for (const p of parts) { + const t = p.trim(); + if (!t) continue; + // "#RRGGBB 12.34%" / "rgba(...) 0%" / "#fff" (no pos). + const posMatch = /\s+(-?\d+(?:\.\d+)?)%\s*$/.exec(t); + const colorRaw = posMatch ? t.slice(0, posMatch.index).trim() : t; + const fill = parseFill(colorRaw); + if (!fill) continue; + if (fill.kind !== "solid" && fill.kind !== "transparent") continue; + const color = fill.kind === "transparent" ? "#000000" : fill.color; + const alpha = fill.kind === "transparent" ? 0 : fill.alpha; + out.push({ + pos: posMatch ? Number(posMatch[1]) : (out.length === 0 ? 0 : 100), + color, + alpha, + }); + } + return out.sort((a, b) => a.pos - b.pos); +} + +function splitTopLevelCommas(s: string): string[] { + const out: string[] = []; + let depth = 0; + let start = 0; + for (let i = 0; i < s.length; i++) { + const c = s[i]; + if (c === "(") depth++; + else if (c === ")") depth--; + else if (c === "," && depth === 0) { + out.push(s.slice(start, i)); + start = i + 1; + } + } + out.push(s.slice(start)); + return out; +} + +// -- Color helpers (no `#`, uppercase) -------------------------------------- + +export function hexBare(color: string): string { + if (!color) return "000000"; + const c = color.trim(); + if (c.startsWith("#")) { + const body = c.slice(1); + if (body.length === 3) { + return body + .split("") + .map((ch) => `${ch}${ch}`) + .join("") + .toUpperCase(); + } + return body.toUpperCase().slice(0, 6); + } + return c.toUpperCase(); +} + +function normaliseHex(s: string): string { + return `#${hexBare(s)}`; +} + +function clampByte(n: number): number { + if (!Number.isFinite(n)) return 0; + return Math.max(0, Math.min(255, Math.round(n))); +} + +function toHex(n: number): string { + return n.toString(16).padStart(2, "0"); +} + +// -- Gradient angle: CSS → OOXML -------------------------------------------- + +/** + * CSS angles measure clockwise from "up"; OOXML `` measures + * clockwise from "right" in 60000ths of a degree. The importer applied + * `(xmlAng/60000 + 90) % 360`; the inverse is below. + */ +export function cssAngleToOoxml(cssDeg: number): number { + const norm = ((cssDeg - 90) % 360 + 360) % 360; + return Math.round(norm * 60000); +} + +// -- Path: SVG `d` → OOXML pathLst ----------------------------------------- + +/** + * Translate the absolute-coordinate subset of an SVG `d` attribute into + * ``. Supports M, L, C, Q, Z and the relative forms (m, l, c, q) + * by tracking the pen. Falls back to `null` for anything beyond that + * (arcs / smooth shorthands / formulas) so the caller can downgrade to a + * simple rect rather than emit broken geometry. + */ +export function svgPathToOoxml( + d: string, + viewW: number, + viewH: number, + fillRule: "nonzero" | "evenodd" = "nonzero" +): string | null { + const tokens = tokenisePath(d); + if (!tokens.length) return null; + let i = 0; + let penX = 0; + let penY = 0; + let startX = 0; + let startY = 0; + const cmds: string[] = []; + while (i < tokens.length) { + const t = tokens[i]; + if (typeof t !== "string") return null; // expected command letter + const cmd = t; + i++; + if (cmd === "M" || cmd === "m") { + const rel = cmd === "m"; + let first = true; + while (typeof tokens[i] === "number") { + const x = Number(tokens[i++]) + (rel ? penX : 0); + const y = Number(tokens[i++]) + (rel ? penY : 0); + if (first) { + cmds.push(`${ptXml(x, y)}`); + startX = x; + startY = y; + first = false; + } else { + // Subsequent coord pairs after M are implicit L per the SVG spec. + cmds.push(`${ptXml(x, y)}`); + } + penX = x; + penY = y; + } + } else if (cmd === "L" || cmd === "l") { + const rel = cmd === "l"; + while (typeof tokens[i] === "number") { + const x = Number(tokens[i++]) + (rel ? penX : 0); + const y = Number(tokens[i++]) + (rel ? penY : 0); + cmds.push(`${ptXml(x, y)}`); + penX = x; + penY = y; + } + } else if (cmd === "H" || cmd === "h") { + const rel = cmd === "h"; + while (typeof tokens[i] === "number") { + const x = Number(tokens[i++]) + (rel ? penX : 0); + cmds.push(`${ptXml(x, penY)}`); + penX = x; + } + } else if (cmd === "V" || cmd === "v") { + const rel = cmd === "v"; + while (typeof tokens[i] === "number") { + const y = Number(tokens[i++]) + (rel ? penY : 0); + cmds.push(`${ptXml(penX, y)}`); + penY = y; + } + } else if (cmd === "C" || cmd === "c") { + const rel = cmd === "c"; + while (typeof tokens[i] === "number") { + const x1 = Number(tokens[i++]) + (rel ? penX : 0); + const y1 = Number(tokens[i++]) + (rel ? penY : 0); + const x2 = Number(tokens[i++]) + (rel ? penX : 0); + const y2 = Number(tokens[i++]) + (rel ? penY : 0); + const x = Number(tokens[i++]) + (rel ? penX : 0); + const y = Number(tokens[i++]) + (rel ? penY : 0); + cmds.push( + `${ptXml(x1, y1)}${ptXml(x2, y2)}${ptXml(x, y)}` + ); + penX = x; + penY = y; + } + } else if (cmd === "Q" || cmd === "q") { + const rel = cmd === "q"; + while (typeof tokens[i] === "number") { + const x1 = Number(tokens[i++]) + (rel ? penX : 0); + const y1 = Number(tokens[i++]) + (rel ? penY : 0); + const x = Number(tokens[i++]) + (rel ? penX : 0); + const y = Number(tokens[i++]) + (rel ? penY : 0); + cmds.push(`${ptXml(x1, y1)}${ptXml(x, y)}`); + penX = x; + penY = y; + } + } else if (cmd === "Z" || cmd === "z") { + cmds.push(``); + penX = startX; + penY = startY; + } else { + // Unsupported command (A, S, T, …) — bail so caller can downgrade. + return null; + } + } + if (!cmds.length) return null; + return ( + `` + + `` + + cmds.join("") + + `` + + `` + ); +} + +function ptXml(x: number, y: number): string { + return ``; +} + +function tokenisePath(d: string): Array { + const out: Array = []; + const re = /([MmLlHhVvCcQqZzSsTtAa])|(-?\d*\.?\d+(?:[eE][-+]?\d+)?)/g; + let m: RegExpExecArray | null; + while ((m = re.exec(d))) { + if (m[1]) out.push(m[1]); + else out.push(Number(m[2])); + } + return out; +} + +// -- Shape XML synthesis ---------------------------------------------------- + +export interface MediaPayload { + /** Target path under the slide rels dir, e.g. `../media/imageSlidewise1.png`. */ + relTarget: string; + /** Absolute path inside the zip, e.g. `ppt/media/imageSlidewise1.png`. */ + fullPath: string; + /** Raw bytes. */ + data: Uint8Array; + /** OOXML rel type. */ + relType: string; +} + +export interface SynthShapeResult { + xml: string; + media: MediaPayload[]; + /** Suggested rel id allocation: writer will renumber at injection time. */ +} + +/** + * Emit a synthesised `` for one shape. The caller is responsible for + * splicing rIds and copying media into the output zip — we return the media + * payload alongside the XML and use marker rIds (`rIdSW__`) + * that the orchestrator rewrites. + */ +export function synthesiseShape(el: ShapeElement): SynthShapeResult { + const id = freshNvId(); + const name = slidewiseShapeName(el.id); + const xfrm = xfrmXml(el.x, el.y, el.w, el.h, el.rotation); + const geom = geometryXml(el); + const fill = shapeFillXml(el); + const stroke = lineXml(el); + const effects = effectLstXml(el.shadow, el.glow); + const sp = + `` + + `` + + `${xfrm}${geom}${fill.xml}${stroke}${effects}` + + `` + + ``; + return { xml: sp, media: fill.media }; +} + +function geometryXml(el: ShapeElement): string { + if (el.path) { + const pathLst = svgPathToOoxml( + el.path.d, + el.path.viewW, + el.path.viewH, + el.path.fillRule + ); + if (pathLst) { + return `${pathLst}`; + } + // Fall through to prstGeom rect if path is unparseable. + } + const preset = ( + { + rect: "rect", + rounded: "roundRect", + circle: "ellipse", + triangle: "triangle", + diamond: "diamond", + star: "star5", + } as const + )[el.shape]; + return ``; +} + +/** Build `` / `` / `` and accumulate + * media payloads when the fill references a data URL. */ +function shapeFillXml(el: ShapeElement): { xml: string; media: MediaPayload[] } { + const parsed = parseFill(el.fill); + if (!parsed) return { xml: ``, media: [] }; + if (parsed.kind === "transparent") return { xml: ``, media: [] }; + if (parsed.kind === "solid") { + return { xml: solidFillXml(parsed.color, parsed.alpha), media: [] }; + } + if (parsed.kind === "linear") { + return { xml: linearGradFillXml(parsed.angle, parsed.stops), media: [] }; + } + if (parsed.kind === "radial") { + return { + xml: radialGradFillXml(parsed.focusX, parsed.focusY, parsed.shape, parsed.stops), + media: [], + }; + } + // image + const media = mediaFromUrl(parsed.src, `img_${el.id}`); + if (!media) { + return { xml: ``, media: [] }; + } + // Marker rId — replaced at injection time. + const ridMarker = ridMarkerFor(el.id, 0); + return { + xml: ``, + media: [media], + }; +} + +export function solidFillXml(color: string, alpha?: number): string { + const inner = alpha != null && alpha < 1 + ? `` + : ``; + return `${inner}`; +} + +function linearGradFillXml(cssAng: number, stops: GradStop[]): string { + return ( + `` + + gsLstXml(stops) + + `` + + `` + ); +} + +function radialGradFillXml( + focusX: number, + focusY: number, + shape: "circle" | "ellipse", + stops: GradStop[] +): string { + // fillToRect insets are in thousandths of a percent (matching `pos` units). + // Mirror the importer's mapping: focus(X,Y) sits at the centre of the + // l/r/t/b rectangle. We use a zero-area rect at the focus point — that's + // the convention PowerPoint emits for "radial centred at point". + const l = clampInset(focusX); + const t = clampInset(focusY); + const r = clampInset(100 - focusX); + const b = clampInset(100 - focusY); + const path = shape === "circle" ? "circle" : "shape"; + return ( + `` + + gsLstXml(stops) + + `` + + `` + ); +} + +function clampInset(v: number): number { + return Math.round(Math.max(0, Math.min(100, v)) * 1000); +} + +function gsLstXml(stops: GradStop[]): string { + return ( + `` + + stops + .map((s) => { + const alpha = + s.alpha != null && s.alpha < 1 + ? `` + : ``; + return `${alpha}`; + }) + .join("") + + `` + ); +} + +function lineXml(el: ShapeElement): string { + if (!el.stroke && !el.strokeWidth) return ``; + const color = el.stroke ? hexBare(el.stroke) : `000000`; + const widthEmu = Math.max(1, Math.round((el.strokeWidth ?? 1) * EMU_PER_POINT)); + const dash = el.dashType ? `` : ``; + return `${dash}`; +} + +export function effectLstXml( + shadow?: ShadowSpec, + glow?: GlowSpec +): string { + if (!shadow && !glow) return ``; + const inner: string[] = []; + if (shadow) { + // OOXML `` distance/direction: distance is the radial offset + // length in EMU; direction is the angle clockwise from "right" in + // 60000ths of a degree. CSS shadow gives an (offsetX, offsetY) vector. + const dist = Math.round( + Math.hypot(shadow.offsetX, shadow.offsetY) * 9525 // ~px → EMU at 96dpi + ); + const ang = + Math.round( + ((Math.atan2(shadow.offsetY, shadow.offsetX) * 180) / Math.PI + 360) % + 360 * 60000 + ); + const blur = Math.max(0, Math.round(shadow.blur * 9525)); + inner.push( + `` + + `` + + `` + ); + } + if (glow) { + const rad = Math.max(0, Math.round(glow.radius * 9525)); + inner.push( + `` + ); + } + return `${inner.join("")}`; +} + +// -- Frame / xfrm helpers --------------------------------------------------- + +export function xfrmXml( + x: number, + y: number, + w: number, + h: number, + rotation: number +): string { + // PowerPoint rotation is in 60000ths of a degree, clockwise. + const rot = rotation ? ` rot="${Math.round(rotation * 60000)}"` : ``; + return ( + `` + + `` + + `` + + `` + ); +} + +// -- Group shape ------------------------------------------------------------ + +/** + * Emit a `` wrapping the synthesised XML of each child. Group XML + * needs a child `` and a `` with its own xfrm that + * declares both the group's external frame (`off/ext`) and the *child* + * coordinate frame (`chOff/chExt`). We set chOff/chExt equal to off/ext so + * children's absolute slide coordinates work as-is. + */ +export function synthesiseGroup( + el: GroupElement, + renderChild: (child: SlideElement) => { xml: string; media: MediaPayload[] } | null +): { xml: string; media: MediaPayload[] } { + const id = freshNvId(); + const childMedia: MediaPayload[] = []; + const childXml: string[] = []; + for (const child of el.children) { + const out = renderChild(child); + if (!out) continue; + childXml.push(out.xml); + for (const m of out.media) childMedia.push(m); + } + const xfrm = + `` + + `` + + `` + + `` + + `` + + ``; + const xml = + `` + + `` + + `${xfrm}` + + childXml.join("") + + ``; + return { xml, media: childMedia }; +} + +// -- Slide background synthesis (PR 3) -------------------------------------- + +export function synthesiseSlideBg( + slide: Slide +): { xml: string | null; media: MediaPayload[] } { + const parsed = parseFill(slide.background); + if (!parsed) return { xml: null, media: [] }; + if (parsed.kind === "transparent") { + return { xml: ``, media: [] }; + } + if (parsed.kind === "solid") { + // Solid is already handled by pptxgenjs — return null to leave its output + // in place. + return { xml: null, media: [] }; + } + if (parsed.kind === "linear") { + return { + xml: `${linearGradFillXml(parsed.angle, parsed.stops)}`, + media: [], + }; + } + if (parsed.kind === "radial") { + return { + xml: `${radialGradFillXml(parsed.focusX, parsed.focusY, parsed.shape, parsed.stops)}`, + media: [], + }; + } + // image + const media = mediaFromUrl(parsed.src, `bg_${slide.id}`); + if (!media) return { xml: null, media: [] }; + const ridMarker = ridMarkerFor(`bg_${slide.id}`, 0); + return { + xml: ``, + media: [media], + }; +} + +// -- Chart synthesis (PR 4, partial) --------------------------------------- + +export interface SynthChartResult { + /** OOXML for the `` to drop into the slide spTree. */ + graphicFrameXml: string; + /** Chart part XML body (full document). */ + chartXml: string; + /** Chart part rels XML (empty when no embedded workbook). */ + chartRelsXml: string; + /** Suggested chart part path, e.g. `ppt/charts/chartSW_.xml`. */ + partPath: string; + /** Suggested chart part rels path. */ + partRelsPath: string; +} + +/** + * Emit a minimal in-app chart. No embedded xlsx workbook — PowerPoint + * renders from the cached values declared in `` / ``. + * Right-click → Edit Data won't work in PowerPoint until we generate a real + * xlsx; that's the followup. The chart renders correctly on open. + */ +export function synthesiseChart(el: ChartElement): SynthChartResult { + const id = freshNvId(); + const partPath = `ppt/charts/chartSW_${sanitiseId(el.id)}.xml`; + const partRelsPath = `ppt/charts/_rels/chartSW_${sanitiseId(el.id)}.xml.rels`; + const ridMarker = ridMarkerFor(el.id, 0); + + const catCount = el.categories.length; + const grouping = el.grouping ?? "standard"; + const seriesXml = el.series.map((s, idx) => seriesXmlFor(el, s, idx)).join(""); + + let plotXml = ""; + if (el.kind === "bar") { + plotXml = `${seriesXml}${catAxisRef()}${valAxisRef()}${axesXml(true)}`; + } else if (el.kind === "column") { + plotXml = `${seriesXml}${catAxisRef()}${valAxisRef()}${axesXml(false)}`; + } else if (el.kind === "line") { + plotXml = `${seriesXml}${catAxisRef()}${valAxisRef()}${axesXml(false)}`; + } else if (el.kind === "area") { + plotXml = `${seriesXml}${catAxisRef()}${valAxisRef()}${axesXml(false)}`; + } else if (el.kind === "pie") { + plotXml = `${seriesXml}`; + } else if (el.kind === "doughnut") { + plotXml = `${seriesXml}`; + } + + const title = el.title + ? `${escapeText( + el.title + )}` + : ``; + + const chartXml = + `` + + `` + + `${title}${plotXml}` + + ``; + + const chartRelsXml = + `` + + ``; + + const graphicFrameXml = + `` + + `` + + `` + + `` + + ``; + + return { graphicFrameXml, chartXml, chartRelsXml, partPath, partRelsPath }; + + function seriesXmlFor( + chart: ChartElement, + s: { name: string; values: (number | null)[]; color?: string }, + idx: number + ): string { + const ptValsXml = s.values + .map((v, i) => + v == null ? `` : `${v}` + ) + .join(""); + const ptCatsXml = chart.categories + .map( + (c, i) => + `${escapeText(String(c))}` + ) + .join(""); + const colorXml = s.color + ? `` + : ``; + return ( + `` + + `` + + `${escapeText(s.name || `Series ${idx + 1}`)}` + + colorXml + + `cat${ptCatsXml}` + + `val${idx}General${ptValsXml}` + + `` + ); + } + function catAxisRef(): string { + return ``; + } + function valAxisRef(): string { + return ``; + } + function axesXml(barIsHoriz: boolean): string { + const catAxOrient = barIsHoriz ? "minMax" : "minMax"; + return ( + `` + + `` + ); + } +} + +// -- Embedded fonts (PR 6) -------------------------------------------------- + +export interface EmbeddedFontDescriptor { + family: string; + /** `` typeface XML — slotted into ``. */ + embeddedFontXml: string; + /** Bytes to write into ppt/fonts/. */ + payloads: { fullPath: string; data: Uint8Array }[]; + /** `` entries to add to presentation.xml.rels for each + * payload, with marker rIds that the orchestrator rewrites. */ + rels: { ridMarker: string; relType: string; target: string }[]; +} + +export async function synthesiseEmbeddedFonts( + fonts: FontAsset[] +): Promise { + const out: EmbeddedFontDescriptor[] = []; + for (let i = 0; i < fonts.length; i++) { + const font = fonts[i]; + const bytes = await fetchFontBytes(font.data); + if (!bytes) continue; + const fullPath = `ppt/fonts/slidewiseFont${i + 1}.fntdata`; + const ridMarker = ridMarkerFor(`font${i}`, 0); + const variant = font.italic + ? font.weight && font.weight >= 600 + ? `boldItalic` + : `italic` + : font.weight && font.weight >= 600 + ? `bold` + : `regular`; + const embeddedFontXml = + `` + + `` + + `` + + ``; + out.push({ + family: font.family, + embeddedFontXml, + payloads: [{ fullPath, data: bytes }], + rels: [ + { + ridMarker, + relType: + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/font", + target: `fonts/slidewiseFont${i + 1}.fntdata`, + }, + ], + }); + } + return out; +} + +async function fetchFontBytes(src: string): Promise { + if (src.startsWith("data:")) { + const comma = src.indexOf(","); + if (comma < 0) return null; + const header = src.slice(0, comma); + const body = src.slice(comma + 1); + if (header.includes(";base64")) { + return decodeBase64(body); + } + return new TextEncoder().encode(decodeURIComponent(body)); + } + if (/^https?:/i.test(src)) { + try { + const res = await fetch(src); + const buf = await res.arrayBuffer(); + return new Uint8Array(buf); + } catch { + return null; + } + } + return null; +} + +// -- Misc helpers ---------------------------------------------------------- + +/** + * Produce a "marker" rId that won't conflict with real rIds — the orchestrator + * scans for these markers and rewrites them to fresh rIds in the appropriate + * rels namespace. + */ +export function ridMarkerFor(scope: string, n: number): string { + return `rIdSW_${sanitiseId(scope)}_${n}`; +} + +/** + * Allocate media path + zip-write descriptor for a `url(...)` reference. + * Returns null when the URL isn't a data URL we can decode synchronously. + * Remote http(s) URLs are NOT inlined here (synchronous-only) — those keep + * their `url(...)` and won't round-trip; that's the limit for v1. + */ +export function mediaFromUrl(src: string, scope: string): MediaPayload | null { + if (!src.startsWith("data:")) return null; + const comma = src.indexOf(","); + if (comma < 0) return null; + const header = src.slice(0, comma); + const body = src.slice(comma + 1); + const mimeMatch = /^data:([^;,]+)/.exec(header); + const mime = mimeMatch ? mimeMatch[1] : "image/png"; + const ext = mime.split("/")[1]?.split("+")[0] ?? "png"; + const bytes = header.includes(";base64") + ? decodeBase64(body) + : new TextEncoder().encode(decodeURIComponent(body)); + const fullPath = `ppt/media/imageSlidewise_${sanitiseId(scope)}.${ext}`; + // Slide rels live in ppt/slides/_rels — targets are relative to ppt/slides. + const relTarget = `../media/imageSlidewise_${sanitiseId(scope)}.${ext}`; + return { + fullPath, + relTarget, + data: bytes, + relType: + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image", + }; +} + +function decodeBase64(b64: string): Uint8Array { + if (typeof atob === "function") { + const bin = atob(b64.replace(/\s+/g, "")); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; + } + // Node fallback. Buffer is global in Node; cast through unknown so this + // module type-checks in browser-only configs without dom-buffer typings. + const B = (globalThis as unknown as { Buffer?: { from(b: string, e: string): Uint8Array } }).Buffer; + if (B) return B.from(b64, "base64"); + throw new Error("[slidewise] no base64 decoder available"); +} + +function escapeAttr(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">"); +} + +function sanitiseId(s: string): string { + return s.replace(/[^a-zA-Z0-9]+/g, "_"); +} + +// Re-export a marker matcher so the orchestrator can swap markers without +// duplicating the regex. +export const RID_MARKER_RE = /rIdSW_[A-Za-z0-9_]+/g; + +// Tests reach into these — keep them on the module namespace. +export const __internals = { + parseStops, + splitTopLevelCommas, + cssAngleToOoxml, + tokenisePath, +}; + +// Re-exports used in deckToPptx — explicit so TS sees them. +export type { Deck }; diff --git a/packages/slidewise/src/lib/types.ts b/packages/slidewise/src/lib/types.ts index 92ba773..2201d01 100644 --- a/packages/slidewise/src/lib/types.ts +++ b/packages/slidewise/src/lib/types.ts @@ -10,8 +10,44 @@ export type ElementType = | "icon" | "embed" | "chart" + | "group" | "unknown"; +/** + * Drop shadow descriptor — emitted as CSS `box-shadow`/`text-shadow` in the + * renderer and `` inside `` on save. Offsets and + * blur are in canvas pixels. + */ +export interface ShadowSpec { + color: string; + blur: number; + offsetX: number; + offsetY: number; +} + +/** + * Outer-glow descriptor — emitted as CSS `filter: drop-shadow(…)` / + * `text-shadow` (since CSS has no native glow primitive) and `` + * inside `` on save. + */ +export interface GlowSpec { + color: string; + radius: number; +} + +/** + * Dash pattern for stroked lines / shape outlines. Mirrors OOXML's + * `` value list — these are the patterns PowerPoint + * recognises and renders without falling back to a custom dash. + */ +export type DashType = + | "solid" + | "dash" + | "dot" + | "dashDot" + | "lgDash" + | "sysDash"; + export type EnterAnim = | "none" | "fade" @@ -55,6 +91,10 @@ export interface TextRun { export interface TextElement extends BaseElement { type: "text"; + /** Optional text shadow — CSS `text-shadow` / OOXML ``. */ + shadow?: ShadowSpec; + /** Optional outer glow — CSS `text-shadow` / OOXML ``. */ + glow?: GlowSpec; text: string; fontFamily: string; fontSize: number; @@ -131,9 +171,22 @@ export type ShapeKind = export interface ShapeElement extends BaseElement { type: "shape"; shape: ShapeKind; + /** + * Solid hex (`#RRGGBB`), `transparent`, a CSS `linear-gradient(...)` / + * `radial-gradient(...)` string (set by the PPTX importer from + * `` — see `pptxToDeck.ts`), or a `url(data:image/...)` / + * `url(https://...)` string. The writer detects each form by parsing. + */ fill: string; stroke?: string; strokeWidth?: number; + /** + * Typed dash pattern for the stroke — AI-authored and host-supplied + * decks set this. `strokeDash` is the raw OOXML value coming out of + * the importer; the renderer accepts either (`strokeDash` wins when + * both are set, since it preserves the exact PPTX intent). + */ + dashType?: DashType; /** * Raw PPTX `` style for the stroke (e.g. "dot", "dash", * "dashDot", "lgDash", "sysDot"). Only the patterned values are honoured; @@ -141,6 +194,10 @@ export interface ShapeElement extends BaseElement { */ strokeDash?: string; radius?: number; + /** Optional drop shadow — CSS `box-shadow` / OOXML ``. */ + shadow?: ShadowSpec; + /** Optional outer glow — CSS `filter: drop-shadow` / OOXML ``. */ + glow?: GlowSpec; /** * Optional vector path, set when the shape was imported from a PPTX * `` (logos, brand marks, hand-drawn shapes). The renderer @@ -181,7 +238,14 @@ export interface LineElement extends BaseElement { stroke: string; strokeWidth: number; arrow?: boolean; + /** Legacy convenience flag — equivalent to `dashType: "dash"`. */ dashed?: boolean; + /** Detailed dash pattern. When set, takes precedence over `dashed`. */ + dashType?: DashType; + /** Optional drop shadow. */ + shadow?: ShadowSpec; + /** Optional outer glow. */ + glow?: GlowSpec; } export interface TableElement extends BaseElement { @@ -297,6 +361,21 @@ export interface ChartElement extends BaseElement { ooxmlXml?: string; } +/** + * A grouped collection of slide elements. Position/size describe the group's + * own bounding box; children carry coordinates relative to the slide (NOT to + * the group). The renderer treats the group as a single z-stacked unit and + * the PPTX writer emits a `` containing the children's OOXML. + * + * NOTE: child drag-resize behaviour is unchanged in this release — selecting + * a child still moves the child individually. Group-level drag will follow + * in a later PR. + */ +export interface GroupElement extends BaseElement { + type: "group"; + children: SlideElement[]; +} + export type SlideElement = | TextElement | ShapeElement @@ -306,14 +385,58 @@ export type SlideElement = | IconElement | EmbedElement | ChartElement + | GroupElement | UnknownElement; export interface Slide { id: string; + /** + * Solid hex (`#RRGGBB`), CSS `linear-gradient(...)` / `radial-gradient(...)` + * string, or `url(data:image/...)` / `url(https://...)` string. The writer + * picks the right `` OOXML form by parsing. When a source PPTX is + * attached, the source's `` replay wins over whatever's in this field. + */ background: string; elements: SlideElement[]; } +/** + * A font the deck wants embedded into the saved PPTX so PowerPoint installs + * it on open. Bytes can be a data URL (`data:font/ttf;base64,...` or any + * `application/x-font-*` mime) or an http(s) URL the writer can fetch. + */ +export interface FontAsset { + /** Matches `TextElement.fontFamily` / run `fontFamily`. */ + family: string; + /** Data URL or http(s) URL. The writer copies the bytes into `ppt/fonts/`. */ + data: string; + /** Defaults to 400 (regular). */ + weight?: number; + italic?: boolean; +} + +/** + * A browser-renderable font file for the editor preview. + * + * `FontAsset.data` carries the PPTX-embedded payload (typically MTX-compressed + * EOT) which PowerPoint can render but browsers cannot. `WebFontAsset` is the + * accompanying TTF / OTF / WOFF / WOFF2 the host supplies so the in-editor + * canvas renders the actual typeface instead of a system fallback. Optional — + * when absent the renderer falls back through Google Fonts and then system. + */ +export interface WebFontAsset { + /** Matches `TextElement.fontFamily` / run `fontFamily`. */ + family: string; + /** + * Same-origin URL, http(s) URL, or `data:font/*` data URL pointing at a + * browser-renderable font file (ttf / otf / woff / woff2). + */ + src: string; + /** Defaults to 400 (regular). */ + weight?: number; + italic?: boolean; +} + export interface Deck { /** * Schema version this deck conforms to. Stamped by `migrate()` and by @@ -335,6 +458,20 @@ export interface Deck { * needs the host to re-attach source bytes via `serializeDeck({ source })`. */ sourcePptxId?: string; + /** + * Optional list of fonts to embed into the saved PPTX. Honoured when no + * source PPTX is attached — when a source IS attached, chrome preservation + * carries the source's embedded fonts through verbatim and this field is + * ignored to avoid duplicate entries. + */ + fonts?: FontAsset[]; + /** + * Browser-renderable font files for the editor preview. The PPTX + * exporter only consults `fonts` (the embedded payload); `webFonts` + * is for the in-editor canvas. Hosts populate this when they have + * licensed copies of the brand font in a web-friendly format. + */ + webFonts?: WebFontAsset[]; } export type ElementDraft = T extends SlideElement