diff --git a/exporters/pptx.js b/exporters/pptx.js
index f822a1f..2ddcd85 100644
--- a/exporters/pptx.js
+++ b/exporters/pptx.js
@@ -22,6 +22,14 @@ const DEFAULT_TEMPLATE = {
const SLIDE_W = 12192000; // EMU, 16:9
const SLIDE_H = 6858000;
const EMU_PER_PX = 9525;
+const TOC_ENTRY_START_Y = 2300000;
+const TOC_ENTRY_SPACING = 255000;
+const TOC_ENTRY_HEIGHT = 220000;
+const TOC_BOTTOM_MARGIN = 500000;
+const TOC_MAX_ENTRIES_PER_SLIDE = Math.max(
+ 1,
+ Math.floor((SLIDE_H - TOC_BOTTOM_MARGIN - TOC_ENTRY_START_Y - TOC_ENTRY_HEIGHT) / TOC_ENTRY_SPACING) + 1,
+);
let shapeIdCounter = 10; // reset per export for deterministic output
@@ -58,6 +66,33 @@ ${content}
`;
}
+function chunkArray(items, size) {
+ const chunks = [];
+ for (let i = 0; i < items.length; i += size) {
+ chunks.push(items.slice(i, i + size));
+ }
+ return chunks;
+}
+
+function tocSlideXml(ast, entries, { continued = false } = {}) {
+ let tocContent = rectShape(0, 0, SLIDE_W, 18000, '2563EB');
+ tocContent += textBox(914400, 760000, SLIDE_W - 1828800, 700000,
+ para(continued ? 'Contents (continued)' : 'Contents', { size: 3000, bold: true }));
+ tocContent += rectShape(914400, 1500000, 1600000, 14000, '2563EB');
+ tocContent += textBox(914400, 1680000, SLIDE_W - 1828800, 450000,
+ para(guideSummary(ast), { size: 1500, color: '6B7280' }));
+
+ entries.forEach((entry, index) => {
+ const x = 914400 + (entry.depth * 220000);
+ const y = TOC_ENTRY_START_Y + (index * TOC_ENTRY_SPACING);
+ tocContent += rectShape(x, y + 78000, 24000, 90000, '2563EB');
+ tocContent += textBox(x + 48000, y, SLIDE_W - x - 1200000, TOC_ENTRY_HEIGHT,
+ para(`${entry.number}. ${entry.title}`, { size: entry.depth === 0 ? 1550 : 1450, bold: entry.depth === 0 }));
+ });
+
+ return slideXml(tocContent);
+}
+
const THEME_XML = `
@@ -110,20 +145,12 @@ function exportPptx(ast, outDir, template = {}) {
}
if (toc.length) {
- let tocContent = rectShape(0, 0, SLIDE_W, 18000, '2563EB');
- tocContent += textBox(914400, 760000, SLIDE_W - 1828800, 700000, para('Contents', { size: 3000, bold: true }));
- tocContent += rectShape(914400, 1500000, 1600000, 14000, '2563EB');
- tocContent += textBox(914400, 1680000, SLIDE_W - 1828800, 450000, para(guideSummary(ast), { size: 1500, color: '6B7280' }));
- toc.forEach((entry, index) => {
- const x = 914400 + (entry.depth * 220000);
- const y = 2300000 + (index * 255000);
- tocContent += rectShape(x, y + 78000, 24000, 90000, '2563EB');
- tocContent += textBox(x + 48000, y, SLIDE_W - x - 1200000, 220000,
- para(`${entry.number}. ${entry.title}`, { size: entry.depth === 0 ? 1550 : 1450, bold: entry.depth === 0 }));
- });
- slides.push({
- xml: slideXml(tocContent),
- rels: [], media: [],
+ const tocPages = chunkArray(toc, TOC_MAX_ENTRIES_PER_SLIDE);
+ tocPages.forEach((page, index) => {
+ slides.push({
+ xml: tocSlideXml(ast, page, { continued: index > 0 }),
+ rels: [], media: [],
+ });
});
}
diff --git a/tests/unit/exporters-binary.test.js b/tests/unit/exporters-binary.test.js
index 80f69e6..9c2acea 100644
--- a/tests/unit/exporters-binary.test.js
+++ b/tests/unit/exporters-binary.test.js
@@ -370,6 +370,28 @@ test('PPTX export: slides per step, master/layout/theme present, rels resolve',
}
});
+test('PPTX export: TOC paginates onto additional slides before it would overflow', (t) => {
+ const root = makeTmpDir('pptxtoc');
+ t.after(() => rmrf(root));
+ const store = new GuideStore(path.join(root, 'data'));
+ const guide = store.createGuide({ title: 'Large TOC test' });
+
+ for (let i = 1; i <= 40; i++) {
+ store.addStep(guide.guideId, { kind: 'empty', title: `Step ${i}` });
+ }
+
+ const ast = buildRenderAst(store, guide.guideId);
+ const { file, slideCount } = exportPptx(ast, path.join(root, 'out'));
+ const entries = new Map(unzipSync(fs.readFileSync(file)).map((e) => [e.name, e.data]));
+ const slideXmls = Array.from({ length: slideCount }, (_, i) => entries.get(`ppt/slides/slide${i + 1}.xml`).toString('utf8'));
+ const tocSlides = slideXmls.filter((xml) => xml.includes('Contents'));
+
+ assert.ok(tocSlides.length >= 2, 'large TOC should span multiple slides');
+ assert.ok(tocSlides[0].includes('1. Step 1'));
+ assert.ok(!tocSlides[0].includes('40. Step 40'));
+ assert.ok(tocSlides.at(-1).includes('40. Step 40'));
+});
+
test('template manager: save/load/rename/duplicate/delete and .sfglt round-trip', (t) => {
const root = makeTmpDir('tpl');
t.after(() => rmrf(root));