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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/potx-template-support.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/slidewise/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
157 changes: 157 additions & 0 deletions packages/slidewise/src/lib/pptx/__tests__/template.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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",
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/ppt/presentation.xml" ContentType="${TEMPLATE_MAIN_CT}"/>
<Override PartName="/ppt/slides/slide1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>
</Types>`
);
zip.file(
"_rels/.rels",
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/></Relationships>`
);
zip.file(
"ppt/presentation.xml",
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:sldIdLst><p:sldId id="256" r:id="rId2"/></p:sldIdLst><p:sldSz cx="12192000" cy="6858000"/></p:presentation>`
);
zip.file(
"ppt/_rels/presentation.xml.rels",
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/></Relationships>`
);
zip.file(
"ppt/slides/slide1.xml",
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><p:cSld><p:spTree><p:sp><p:nvSpPr><p:cNvPr id="2" name="t"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="3000000" cy="500000"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr><p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:rPr lang="en-US"/><a:t>Hi</a:t></a:r></a:p></p:txBody></p:sp></p:spTree></p:cSld></p:sld>`
);
zip.file(
"ppt/slides/_rels/slide1.xml.rels",
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>`
);
return zip;
}
67 changes: 62 additions & 5 deletions packages/slidewise/src/lib/pptx/deckToPptx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<Blob> {
// Prefer the caller-supplied source (survives state cloning / localStorage
// rehydrate); fall back to the non-enumerable attachment from parsePptx
Expand Down Expand Up @@ -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 /
Expand All @@ -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);
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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<boolean> {
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<Blob> {
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 ----------------------------------------------------------------

Expand Down
2 changes: 1 addition & 1 deletion packages/slidewise/src/lib/pptx/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export { parsePptx } from "./pptxToDeck";
export { parsePptx, isPptxTemplate } from "./pptxToDeck";
export { serializeDeck } from "./deckToPptx";
export type { ParseDiagnostics, ParseResult } from "./types";
24 changes: 24 additions & 0 deletions packages/slidewise/src/lib/pptx/pptxToDeck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
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<Deck> {
Expand Down
Loading
Loading