paginate pptx toc slides
This commit is contained in:
+41
-14
@@ -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}
|
||||
</p:spTree></p:cSld><p:clrMapOvr><a:overrideClrMapping bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/></p:clrMapOvr></p:sld>`;
|
||||
}
|
||||
|
||||
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 = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="StepForge">
|
||||
<a:themeElements>
|
||||
@@ -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: [],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user