Skip to content

Commit 019e000

Browse files
feat(pptx): support PowerPoint templates (.potx) (#77)
.potx and .pptx share an identical OOXML package; only the main part's content type in [Content_Types].xml differs. Extend the importer/exporter to preserve template-ness across the round-trip. - Add isPptxTemplate(blob): detect a template by package content type rather than filename extension - serializeDeck gains asTemplate?: boolean; when omitted, inherit from the source archive so a parsed .potx round-trips back to a .potx. Templates emit the template.main+xml main-part content type and .potx MIME - Website: accept .potx, detect template-ness from content, export .potx
1 parent a3bc116 commit 019e000

7 files changed

Lines changed: 285 additions & 14 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@textcortex/slidewise": minor
3+
---
4+
5+
feat(pptx): support PowerPoint templates (.potx)
6+
7+
`.potx` and `.pptx` share an identical OOXML package; only the main part's
8+
content type in `[Content_Types].xml` differs. This adds first-class template
9+
support across import and export:
10+
11+
- `parsePptx` already parsed `.potx` transparently (it reads parts by path, not
12+
by content type) — now the rest of the pipeline preserves template-ness.
13+
- New exported `isPptxTemplate(blob)` detects a template by inspecting the
14+
package content type rather than trusting a filename extension (a mis-named
15+
`.pptx` that is really a template is detected correctly).
16+
- `serializeDeck` gains an `asTemplate?: boolean` option. When omitted,
17+
template-ness is inherited from the source archive, so a parsed `.potx`
18+
round-trips back to a `.potx`; pass `true`/`false` to force the output kind.
19+
Templates are emitted with the `…presentationml.template.main+xml` main-part
20+
content type and the `.potx` MIME type.

packages/slidewise/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ export {
8888
type SlideRailItemContextValue,
8989
} from "./compound";
9090

91-
export { parsePptx, serializeDeck } from "./lib/pptx";
91+
export { parsePptx, isPptxTemplate, serializeDeck } from "./lib/pptx";
9292
export type { ParseDiagnostics, ParseResult } from "./lib/pptx/types";
9393

9494
export { migrate, CURRENT_DECK_VERSION } from "./lib/schema/migrate";
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { describe, it, expect } from "vitest";
2+
import JSZip from "jszip";
3+
import { parsePptx, isPptxTemplate, serializeDeck } from "../index";
4+
import { CURRENT_DECK_VERSION } from "@/lib/schema/migrate";
5+
import type { Deck } from "@/lib/types";
6+
7+
const PRESENTATION_MAIN_CT =
8+
"application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml";
9+
const TEMPLATE_MAIN_CT =
10+
"application/vnd.openxmlformats-officedocument.presentationml.template.main+xml";
11+
const POTX_MIME =
12+
"application/vnd.openxmlformats-officedocument.presentationml.template";
13+
const PPTX_MIME =
14+
"application/vnd.openxmlformats-officedocument.presentationml.presentation";
15+
16+
function makeDeck(): Deck {
17+
return {
18+
version: CURRENT_DECK_VERSION,
19+
title: "Template fixture",
20+
slides: [
21+
{
22+
id: "slide-1",
23+
background: "#FFFFFF",
24+
elements: [
25+
{
26+
id: "t1",
27+
type: "text",
28+
rotation: 0,
29+
z: 1,
30+
x: 200,
31+
y: 240,
32+
w: 1200,
33+
h: 200,
34+
text: "Template slide",
35+
fontFamily: "Inter",
36+
fontSize: 48,
37+
fontWeight: 400,
38+
italic: false,
39+
underline: false,
40+
strike: false,
41+
color: "#0E1330",
42+
align: "left",
43+
vAlign: "top",
44+
lineHeight: 1.2,
45+
letterSpacing: 0,
46+
},
47+
],
48+
},
49+
],
50+
};
51+
}
52+
53+
async function contentTypesXml(blob: Blob): Promise<string> {
54+
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
55+
const file = zip.file("[Content_Types].xml");
56+
return file ? file.async("string") : "";
57+
}
58+
59+
describe("pptx ↔ potx", () => {
60+
it("emits the presentation content type by default", async () => {
61+
const blob = await serializeDeck(makeDeck());
62+
expect(blob.type).toBe(PPTX_MIME);
63+
const xml = await contentTypesXml(blob);
64+
expect(xml).toContain(PRESENTATION_MAIN_CT);
65+
expect(xml).not.toContain(TEMPLATE_MAIN_CT);
66+
});
67+
68+
it("emits the template content type and MIME when asTemplate is true", async () => {
69+
const blob = await serializeDeck(makeDeck(), { asTemplate: true });
70+
expect(blob.type).toBe(POTX_MIME);
71+
const xml = await contentTypesXml(blob);
72+
expect(xml).toContain(TEMPLATE_MAIN_CT);
73+
// The presentation override must be gone — exactly one main part.
74+
expect(xml).not.toContain(PRESENTATION_MAIN_CT);
75+
});
76+
77+
it("isPptxTemplate detects templates by content type, not filename", async () => {
78+
const template = await templateSourceZip().generateAsync({
79+
type: "arraybuffer",
80+
});
81+
expect(await isPptxTemplate(template)).toBe(true);
82+
// A presentation (the default serializer output) is not a template.
83+
const presentation = await (await serializeDeck(makeDeck())).arrayBuffer();
84+
expect(await isPptxTemplate(presentation)).toBe(false);
85+
// Garbage / non-zip input is reported as not-a-template rather than throwing.
86+
expect(await isPptxTemplate(new Uint8Array([1, 2, 3]))).toBe(false);
87+
});
88+
89+
it("parses a .potx package (same OOXML as .pptx)", async () => {
90+
const zip = templateSourceZip();
91+
const buffer = await zip.generateAsync({ type: "arraybuffer" });
92+
const deck = await parsePptx(buffer);
93+
expect(deck.slides.length).toBe(1);
94+
});
95+
96+
it("round-trips a parsed template back to a template by default", async () => {
97+
const zip = templateSourceZip();
98+
const source = await zip.generateAsync({ type: "arraybuffer" });
99+
const deck = await parsePptx(source);
100+
// No asTemplate flag: template-ness is inherited from the source archive.
101+
const blob = await serializeDeck(deck, { source });
102+
expect(blob.type).toBe(POTX_MIME);
103+
const xml = await contentTypesXml(blob);
104+
expect(xml).toContain(TEMPLATE_MAIN_CT);
105+
expect(xml).not.toContain(PRESENTATION_MAIN_CT);
106+
});
107+
108+
it("can force a parsed template back to a presentation", async () => {
109+
const zip = templateSourceZip();
110+
const source = await zip.generateAsync({ type: "arraybuffer" });
111+
const deck = await parsePptx(source);
112+
const blob = await serializeDeck(deck, { source, asTemplate: false });
113+
expect(blob.type).toBe(PPTX_MIME);
114+
const xml = await contentTypesXml(blob);
115+
expect(xml).toContain(PRESENTATION_MAIN_CT);
116+
expect(xml).not.toContain(TEMPLATE_MAIN_CT);
117+
});
118+
});
119+
120+
/**
121+
* Minimal valid POTX package: identical layout to a PPTX, but the main part
122+
* is declared as a template in [Content_Types].xml.
123+
*/
124+
function templateSourceZip(): JSZip {
125+
const zip = new JSZip();
126+
zip.file(
127+
"[Content_Types].xml",
128+
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
129+
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
130+
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
131+
<Default Extension="xml" ContentType="application/xml"/>
132+
<Override PartName="/ppt/presentation.xml" ContentType="${TEMPLATE_MAIN_CT}"/>
133+
<Override PartName="/ppt/slides/slide1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>
134+
</Types>`
135+
);
136+
zip.file(
137+
"_rels/.rels",
138+
`<?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>`
139+
);
140+
zip.file(
141+
"ppt/presentation.xml",
142+
`<?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>`
143+
);
144+
zip.file(
145+
"ppt/_rels/presentation.xml.rels",
146+
`<?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>`
147+
);
148+
zip.file(
149+
"ppt/slides/slide1.xml",
150+
`<?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>`
151+
);
152+
zip.file(
153+
"ppt/slides/_rels/slide1.xml.rels",
154+
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>`
155+
);
156+
return zip;
157+
}

packages/slidewise/src/lib/pptx/deckToPptx.ts

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,16 @@ import {
6767
*/
6868
export interface SerializeOptions {
6969
source?: Blob | ArrayBuffer | Uint8Array;
70+
/**
71+
* Emit a PowerPoint template (`.potx`) instead of a presentation (`.pptx`).
72+
* The two share an identical OOXML package; the only on-disk difference is
73+
* the main part's content type in `[Content_Types].xml`
74+
* (`…presentationml.template.main+xml` vs `…presentation.main+xml`) and the
75+
* Blob's MIME type. When left `undefined`, template-ness is inferred from the
76+
* source archive so a `.potx` parsed by `parsePptx` round-trips back to a
77+
* `.potx`; pass `true`/`false` to force the output kind.
78+
*/
79+
asTemplate?: boolean;
7080
}
7181

7282
/**
@@ -114,7 +124,7 @@ export async function serializeDeck(
114124
const generated = (await pptx.write({
115125
outputType: "arraybuffer",
116126
})) as ArrayBuffer;
117-
return preserveUnknowns(generated, deck, options.source);
127+
return preserveUnknowns(generated, deck, options.source, options.asTemplate);
118128
}
119129

120130
function addSlide(pptx: pptxgen, slide: Slide, slideIndex: number): void {
@@ -651,7 +661,8 @@ function addEmbed(s: pptxgen.Slide, el: EmbedElement): void {
651661
async function preserveUnknowns(
652662
generated: ArrayBuffer,
653663
deck: Deck,
654-
explicitSource?: Blob | ArrayBuffer | Uint8Array
664+
explicitSource?: Blob | ArrayBuffer | Uint8Array,
665+
asTemplate?: boolean
655666
): Promise<Blob> {
656667
// Prefer the caller-supplied source (survives state cloning / localStorage
657668
// rehydrate); fall back to the non-enumerable attachment from parsePptx
@@ -682,7 +693,7 @@ async function preserveUnknowns(
682693
await sanitiseSlideXml(outZip);
683694
await sanitiseRels(outZip);
684695
pruneEmptyDirectories(outZip);
685-
return outZip.generateAsync({ type: "blob", mimeType: PPTX_MIME });
696+
return finalizeOutput(outZip, asTemplate === true);
686697
}
687698
if (!sourceBuffer && hasSynth) {
688699
// No source: still run the synth-only post-process. The chrome / EMF /
@@ -696,7 +707,7 @@ async function preserveUnknowns(
696707
await sanitiseSlideXml(outZip);
697708
await sanitiseRels(outZip);
698709
pruneEmptyDirectories(outZip);
699-
return outZip.generateAsync({ type: "blob", mimeType: PPTX_MIME });
710+
return finalizeOutput(outZip, asTemplate === true);
700711
}
701712

702713
const unknownsBySlide = collectUnknowns(deck);
@@ -784,7 +795,10 @@ async function preserveUnknowns(
784795
pruneEmptyDirectories(outZip);
785796

786797
// JSZip's blob output preserves the OOXML mime type set by pptxgenjs.
787-
return outZip.generateAsync({ type: "blob", mimeType: PPTX_MIME });
798+
// When the caller didn't force the output kind, inherit it from the source
799+
// so a parsed `.potx` round-trips back to a `.potx`.
800+
const emitAsTemplate = asTemplate ?? (await isTemplateArchive(srcZip));
801+
return finalizeOutput(outZip, emitAsTemplate);
788802
}
789803

790804
async function resolveSource(
@@ -1833,6 +1847,49 @@ function parseSldSz(xml: string): { cx: number; cy: number } | null {
18331847

18341848
const PPTX_MIME =
18351849
"application/vnd.openxmlformats-officedocument.presentationml.presentation";
1850+
const POTX_MIME =
1851+
"application/vnd.openxmlformats-officedocument.presentationml.template";
1852+
1853+
// Main-part content types declared in `[Content_Types].xml`. A presentation
1854+
// and a template share an otherwise-identical package; only this override
1855+
// distinguishes them.
1856+
const PRESENTATION_MAIN_CT =
1857+
"application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml";
1858+
const TEMPLATE_MAIN_CT =
1859+
"application/vnd.openxmlformats-officedocument.presentationml.template.main+xml";
1860+
1861+
/** Does this OOXML package declare its main part as a template (`.potx`)? */
1862+
async function isTemplateArchive(zip: JSZip): Promise<boolean> {
1863+
const file = zip.file("[Content_Types].xml");
1864+
if (!file) return false;
1865+
const xml = await file.async("string");
1866+
return xml.includes(TEMPLATE_MAIN_CT);
1867+
}
1868+
1869+
/**
1870+
* Generate the final Blob, flipping the main-part content type to the template
1871+
* variant first when emitting a `.potx`. pptxgenjs always writes the
1872+
* presentation content type, so the template path rewrites it in place.
1873+
*/
1874+
async function finalizeOutput(
1875+
outZip: JSZip,
1876+
asTemplate: boolean
1877+
): Promise<Blob> {
1878+
if (!asTemplate) {
1879+
return outZip.generateAsync({ type: "blob", mimeType: PPTX_MIME });
1880+
}
1881+
const file = outZip.file("[Content_Types].xml");
1882+
if (file) {
1883+
const xml = await file.async("string");
1884+
if (xml.includes(PRESENTATION_MAIN_CT)) {
1885+
outZip.file(
1886+
"[Content_Types].xml",
1887+
xml.replace(PRESENTATION_MAIN_CT, TEMPLATE_MAIN_CT)
1888+
);
1889+
}
1890+
}
1891+
return outZip.generateAsync({ type: "blob", mimeType: POTX_MIME });
1892+
}
18361893

18371894
// -- helpers ----------------------------------------------------------------
18381895

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
export { parsePptx } from "./pptxToDeck";
1+
export { parsePptx, isPptxTemplate } from "./pptxToDeck";
22
export { serializeDeck } from "./deckToPptx";
33
export type { ParseDiagnostics, ParseResult } from "./types";

packages/slidewise/src/lib/pptx/pptxToDeck.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,30 @@ const DEFAULT_THEME: ThemeColors = {
254254
* UnknownElement carrying its raw OOXML so a save round-trip can re-emit
255255
* it without data loss.
256256
*/
257+
/**
258+
* Detect whether an OOXML package is a PowerPoint template (`.potx`) rather
259+
* than a presentation (`.pptx`). The two share an identical package layout;
260+
* the only on-disk difference is the main part's content type in
261+
* `[Content_Types].xml`. Prefer this over trusting a filename extension — a
262+
* mis-named `.pptx` that is really a template is detected correctly, and a
263+
* `.potx` round-trips back to a template on export.
264+
*/
265+
export async function isPptxTemplate(
266+
blob: Blob | ArrayBuffer | Uint8Array
267+
): Promise<boolean> {
268+
try {
269+
const zip = await JSZip.loadAsync(await toArrayBuffer(blob));
270+
const xml = await zip.file("[Content_Types].xml")?.async("string");
271+
return xml
272+
? xml.includes(
273+
"application/vnd.openxmlformats-officedocument.presentationml.template.main+xml"
274+
)
275+
: false;
276+
} catch {
277+
return false;
278+
}
279+
}
280+
257281
export async function parsePptx(
258282
blob: Blob | ArrayBuffer | Uint8Array
259283
): Promise<Deck> {

0 commit comments

Comments
 (0)