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));