diff --git a/.changeset/potx-template-support.md b/.changeset/potx-template-support.md new file mode 100644 index 0000000..bd271a0 --- /dev/null +++ b/.changeset/potx-template-support.md @@ -0,0 +1,20 @@ +--- +"@textcortex/slidewise": minor +--- + +feat(pptx): support PowerPoint templates (.potx) + +`.potx` and `.pptx` share an identical OOXML package; only the main part's +content type in `[Content_Types].xml` differs. This adds first-class template +support across import and export: + +- `parsePptx` already parsed `.potx` transparently (it reads parts by path, not + by content type) — now the rest of the pipeline preserves template-ness. +- New exported `isPptxTemplate(blob)` detects a template by inspecting the + package content type rather than trusting a filename extension (a mis-named + `.pptx` that is really a template is detected correctly). +- `serializeDeck` gains an `asTemplate?: boolean` option. When omitted, + template-ness is inherited from the source archive, so a parsed `.potx` + round-trips back to a `.potx`; pass `true`/`false` to force the output kind. + Templates are emitted with the `…presentationml.template.main+xml` main-part + content type and the `.potx` MIME type. diff --git a/packages/slidewise/src/index.ts b/packages/slidewise/src/index.ts index 6d5e7b0..64cd671 100644 --- a/packages/slidewise/src/index.ts +++ b/packages/slidewise/src/index.ts @@ -88,7 +88,7 @@ export { type SlideRailItemContextValue, } from "./compound"; -export { parsePptx, serializeDeck } from "./lib/pptx"; +export { parsePptx, isPptxTemplate, serializeDeck } from "./lib/pptx"; export type { ParseDiagnostics, ParseResult } from "./lib/pptx/types"; export { migrate, CURRENT_DECK_VERSION } from "./lib/schema/migrate"; diff --git a/packages/slidewise/src/lib/pptx/__tests__/template.test.ts b/packages/slidewise/src/lib/pptx/__tests__/template.test.ts new file mode 100644 index 0000000..a851843 --- /dev/null +++ b/packages/slidewise/src/lib/pptx/__tests__/template.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect } from "vitest"; +import JSZip from "jszip"; +import { parsePptx, isPptxTemplate, serializeDeck } from "../index"; +import { CURRENT_DECK_VERSION } from "@/lib/schema/migrate"; +import type { Deck } from "@/lib/types"; + +const PRESENTATION_MAIN_CT = + "application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"; +const TEMPLATE_MAIN_CT = + "application/vnd.openxmlformats-officedocument.presentationml.template.main+xml"; +const POTX_MIME = + "application/vnd.openxmlformats-officedocument.presentationml.template"; +const PPTX_MIME = + "application/vnd.openxmlformats-officedocument.presentationml.presentation"; + +function makeDeck(): Deck { + return { + version: CURRENT_DECK_VERSION, + title: "Template fixture", + slides: [ + { + id: "slide-1", + background: "#FFFFFF", + elements: [ + { + id: "t1", + type: "text", + rotation: 0, + z: 1, + x: 200, + y: 240, + w: 1200, + h: 200, + text: "Template slide", + fontFamily: "Inter", + fontSize: 48, + fontWeight: 400, + italic: false, + underline: false, + strike: false, + color: "#0E1330", + align: "left", + vAlign: "top", + lineHeight: 1.2, + letterSpacing: 0, + }, + ], + }, + ], + }; +} + +async function contentTypesXml(blob: Blob): Promise { + const zip = await JSZip.loadAsync(await blob.arrayBuffer()); + const file = zip.file("[Content_Types].xml"); + return file ? file.async("string") : ""; +} + +describe("pptx ↔ potx", () => { + it("emits the presentation content type by default", async () => { + const blob = await serializeDeck(makeDeck()); + expect(blob.type).toBe(PPTX_MIME); + const xml = await contentTypesXml(blob); + expect(xml).toContain(PRESENTATION_MAIN_CT); + expect(xml).not.toContain(TEMPLATE_MAIN_CT); + }); + + it("emits the template content type and MIME when asTemplate is true", async () => { + const blob = await serializeDeck(makeDeck(), { asTemplate: true }); + expect(blob.type).toBe(POTX_MIME); + const xml = await contentTypesXml(blob); + expect(xml).toContain(TEMPLATE_MAIN_CT); + // The presentation override must be gone — exactly one main part. + expect(xml).not.toContain(PRESENTATION_MAIN_CT); + }); + + it("isPptxTemplate detects templates by content type, not filename", async () => { + const template = await templateSourceZip().generateAsync({ + type: "arraybuffer", + }); + expect(await isPptxTemplate(template)).toBe(true); + // A presentation (the default serializer output) is not a template. + const presentation = await (await serializeDeck(makeDeck())).arrayBuffer(); + expect(await isPptxTemplate(presentation)).toBe(false); + // Garbage / non-zip input is reported as not-a-template rather than throwing. + expect(await isPptxTemplate(new Uint8Array([1, 2, 3]))).toBe(false); + }); + + it("parses a .potx package (same OOXML as .pptx)", async () => { + const zip = templateSourceZip(); + const buffer = await zip.generateAsync({ type: "arraybuffer" }); + const deck = await parsePptx(buffer); + expect(deck.slides.length).toBe(1); + }); + + it("round-trips a parsed template back to a template by default", async () => { + const zip = templateSourceZip(); + const source = await zip.generateAsync({ type: "arraybuffer" }); + const deck = await parsePptx(source); + // No asTemplate flag: template-ness is inherited from the source archive. + const blob = await serializeDeck(deck, { source }); + expect(blob.type).toBe(POTX_MIME); + const xml = await contentTypesXml(blob); + expect(xml).toContain(TEMPLATE_MAIN_CT); + expect(xml).not.toContain(PRESENTATION_MAIN_CT); + }); + + it("can force a parsed template back to a presentation", async () => { + const zip = templateSourceZip(); + const source = await zip.generateAsync({ type: "arraybuffer" }); + const deck = await parsePptx(source); + const blob = await serializeDeck(deck, { source, asTemplate: false }); + expect(blob.type).toBe(PPTX_MIME); + const xml = await contentTypesXml(blob); + expect(xml).toContain(PRESENTATION_MAIN_CT); + expect(xml).not.toContain(TEMPLATE_MAIN_CT); + }); +}); + +/** + * Minimal valid POTX package: identical layout to a PPTX, but the main part + * is declared as a template in [Content_Types].xml. + */ +function templateSourceZip(): JSZip { + const zip = new JSZip(); + zip.file( + "[Content_Types].xml", + ` + + + + + +` + ); + zip.file( + "_rels/.rels", + `` + ); + zip.file( + "ppt/presentation.xml", + `` + ); + zip.file( + "ppt/_rels/presentation.xml.rels", + `` + ); + zip.file( + "ppt/slides/slide1.xml", + `Hi` + ); + zip.file( + "ppt/slides/_rels/slide1.xml.rels", + `` + ); + return zip; +} diff --git a/packages/slidewise/src/lib/pptx/deckToPptx.ts b/packages/slidewise/src/lib/pptx/deckToPptx.ts index f4e3986..be8a411 100644 --- a/packages/slidewise/src/lib/pptx/deckToPptx.ts +++ b/packages/slidewise/src/lib/pptx/deckToPptx.ts @@ -67,6 +67,16 @@ import { */ export interface SerializeOptions { source?: Blob | ArrayBuffer | Uint8Array; + /** + * Emit a PowerPoint template (`.potx`) instead of a presentation (`.pptx`). + * The two share an identical OOXML package; the only on-disk difference is + * the main part's content type in `[Content_Types].xml` + * (`…presentationml.template.main+xml` vs `…presentation.main+xml`) and the + * Blob's MIME type. When left `undefined`, template-ness is inferred from the + * source archive so a `.potx` parsed by `parsePptx` round-trips back to a + * `.potx`; pass `true`/`false` to force the output kind. + */ + asTemplate?: boolean; } /** @@ -114,7 +124,7 @@ export async function serializeDeck( const generated = (await pptx.write({ outputType: "arraybuffer", })) as ArrayBuffer; - return preserveUnknowns(generated, deck, options.source); + return preserveUnknowns(generated, deck, options.source, options.asTemplate); } function addSlide(pptx: pptxgen, slide: Slide, slideIndex: number): void { @@ -651,7 +661,8 @@ function addEmbed(s: pptxgen.Slide, el: EmbedElement): void { async function preserveUnknowns( generated: ArrayBuffer, deck: Deck, - explicitSource?: Blob | ArrayBuffer | Uint8Array + explicitSource?: Blob | ArrayBuffer | Uint8Array, + asTemplate?: boolean ): Promise { // Prefer the caller-supplied source (survives state cloning / localStorage // rehydrate); fall back to the non-enumerable attachment from parsePptx @@ -682,7 +693,7 @@ async function preserveUnknowns( await sanitiseSlideXml(outZip); await sanitiseRels(outZip); pruneEmptyDirectories(outZip); - return outZip.generateAsync({ type: "blob", mimeType: PPTX_MIME }); + return finalizeOutput(outZip, asTemplate === true); } if (!sourceBuffer && hasSynth) { // No source: still run the synth-only post-process. The chrome / EMF / @@ -696,7 +707,7 @@ async function preserveUnknowns( await sanitiseSlideXml(outZip); await sanitiseRels(outZip); pruneEmptyDirectories(outZip); - return outZip.generateAsync({ type: "blob", mimeType: PPTX_MIME }); + return finalizeOutput(outZip, asTemplate === true); } const unknownsBySlide = collectUnknowns(deck); @@ -784,7 +795,10 @@ async function preserveUnknowns( pruneEmptyDirectories(outZip); // JSZip's blob output preserves the OOXML mime type set by pptxgenjs. - return outZip.generateAsync({ type: "blob", mimeType: PPTX_MIME }); + // When the caller didn't force the output kind, inherit it from the source + // so a parsed `.potx` round-trips back to a `.potx`. + const emitAsTemplate = asTemplate ?? (await isTemplateArchive(srcZip)); + return finalizeOutput(outZip, emitAsTemplate); } async function resolveSource( @@ -1833,6 +1847,49 @@ function parseSldSz(xml: string): { cx: number; cy: number } | null { const PPTX_MIME = "application/vnd.openxmlformats-officedocument.presentationml.presentation"; +const POTX_MIME = + "application/vnd.openxmlformats-officedocument.presentationml.template"; + +// Main-part content types declared in `[Content_Types].xml`. A presentation +// and a template share an otherwise-identical package; only this override +// distinguishes them. +const PRESENTATION_MAIN_CT = + "application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"; +const TEMPLATE_MAIN_CT = + "application/vnd.openxmlformats-officedocument.presentationml.template.main+xml"; + +/** Does this OOXML package declare its main part as a template (`.potx`)? */ +async function isTemplateArchive(zip: JSZip): Promise { + const file = zip.file("[Content_Types].xml"); + if (!file) return false; + const xml = await file.async("string"); + return xml.includes(TEMPLATE_MAIN_CT); +} + +/** + * Generate the final Blob, flipping the main-part content type to the template + * variant first when emitting a `.potx`. pptxgenjs always writes the + * presentation content type, so the template path rewrites it in place. + */ +async function finalizeOutput( + outZip: JSZip, + asTemplate: boolean +): Promise { + if (!asTemplate) { + return outZip.generateAsync({ type: "blob", mimeType: PPTX_MIME }); + } + const file = outZip.file("[Content_Types].xml"); + if (file) { + const xml = await file.async("string"); + if (xml.includes(PRESENTATION_MAIN_CT)) { + outZip.file( + "[Content_Types].xml", + xml.replace(PRESENTATION_MAIN_CT, TEMPLATE_MAIN_CT) + ); + } + } + return outZip.generateAsync({ type: "blob", mimeType: POTX_MIME }); +} // -- helpers ---------------------------------------------------------------- diff --git a/packages/slidewise/src/lib/pptx/index.ts b/packages/slidewise/src/lib/pptx/index.ts index 7432c25..25dc8ae 100644 --- a/packages/slidewise/src/lib/pptx/index.ts +++ b/packages/slidewise/src/lib/pptx/index.ts @@ -1,3 +1,3 @@ -export { parsePptx } from "./pptxToDeck"; +export { parsePptx, isPptxTemplate } from "./pptxToDeck"; export { serializeDeck } from "./deckToPptx"; export type { ParseDiagnostics, ParseResult } from "./types"; diff --git a/packages/slidewise/src/lib/pptx/pptxToDeck.ts b/packages/slidewise/src/lib/pptx/pptxToDeck.ts index 4912a49..49ffec4 100644 --- a/packages/slidewise/src/lib/pptx/pptxToDeck.ts +++ b/packages/slidewise/src/lib/pptx/pptxToDeck.ts @@ -254,6 +254,30 @@ const DEFAULT_THEME: ThemeColors = { * UnknownElement carrying its raw OOXML so a save round-trip can re-emit * it without data loss. */ +/** + * Detect whether an OOXML package is a PowerPoint template (`.potx`) rather + * than a presentation (`.pptx`). The two share an identical package layout; + * the only on-disk difference is the main part's content type in + * `[Content_Types].xml`. Prefer this over trusting a filename extension — a + * mis-named `.pptx` that is really a template is detected correctly, and a + * `.potx` round-trips back to a template on export. + */ +export async function isPptxTemplate( + blob: Blob | ArrayBuffer | Uint8Array +): Promise { + try { + const zip = await JSZip.loadAsync(await toArrayBuffer(blob)); + const xml = await zip.file("[Content_Types].xml")?.async("string"); + return xml + ? xml.includes( + "application/vnd.openxmlformats-officedocument.presentationml.template.main+xml" + ) + : false; + } catch { + return false; + } +} + export async function parsePptx( blob: Blob | ArrayBuffer | Uint8Array ): Promise { diff --git a/website/src/App.tsx b/website/src/App.tsx index c6a2d25..8718db2 100644 --- a/website/src/App.tsx +++ b/website/src/App.tsx @@ -21,6 +21,7 @@ import { SlidewiseEditor, type SlidewiseEditorHandle, parsePptx, + isPptxTemplate, serializeDeck, type Deck, Root, @@ -145,10 +146,16 @@ export function App() { // serializeDeck to round-trip per-element source XML (gradient panels, // custGeom marks, charts, SmartArt) without dropping them. const sourcePptxRef = useRef(undefined); + // Whether the last loaded file was a PowerPoint template (.potx). Tracked so + // export round-trips the same kind (template → template) and names the + // download accordingly. Same OOXML package as .pptx; only the main-part + // content type differs. + const sourceIsTemplateRef = useRef(false); const loadFromFile = async (file: File) => { - if (!file.name.toLowerCase().endsWith(".pptx")) { - setOverlay(`Not a .pptx file: ${file.name}`); + const name = file.name.toLowerCase(); + if (!name.endsWith(".pptx") && !name.endsWith(".potx")) { + setOverlay(`Not a .pptx or .potx file: ${file.name}`); setTimeout(() => setOverlay(null), 1800); return; } @@ -156,6 +163,9 @@ export function App() { setOverlay(`Loading ${file.name}…`); const buffer = await file.arrayBuffer(); sourcePptxRef.current = buffer; + // Trust the package's content type over the filename: a mis-named + // .pptx that is actually a template still round-trips as a template. + sourceIsTemplateRef.current = await isPptxTemplate(buffer); const next = await parsePptx(buffer); setDeck(next); setSourceLabel(file.name); @@ -163,7 +173,7 @@ export function App() { setTimeout(() => setOverlay(null), 1600); } catch (err) { console.error("[slidewise] PPTX parse failed:", err); - setOverlay("Failed to parse .pptx — see console"); + setOverlay("Failed to parse file — see console"); setTimeout(() => setOverlay(null), 2400); } }; @@ -173,7 +183,7 @@ export function App() { const onDragEnter = (e: DragEvent) => { if (!e.dataTransfer?.types.includes("Files")) return; dragDepth++; - setOverlay("Drop a .pptx to load it"); + setOverlay("Drop a .pptx or .potx to load it"); }; const onDragLeave = () => { dragDepth = Math.max(0, dragDepth - 1); @@ -216,13 +226,16 @@ export function App() { const handleExportPptx = async (current: Deck) => { try { + const asTemplate = sourceIsTemplateRef.current; const blob = await serializeDeck(current, { source: sourcePptxRef.current, + asTemplate, }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; - a.download = `${(current.title || "deck").replace(/[^a-z0-9-_]+/gi, "-")}.pptx`; + const ext = asTemplate ? "potx" : "pptx"; + a.download = `${(current.title || "deck").replace(/[^a-z0-9-_]+/gi, "-")}.${ext}`; a.click(); URL.revokeObjectURL(url); } catch (err) { @@ -305,12 +318,12 @@ export function App() { } > - Open .pptx + Open .pptx / .potx { const file = e.target.files?.[0]; if (file) await loadFromFile(file);