From 3de8ec978b4f2ff916fe4f4b912ee7e545f3dcbd Mon Sep 17 00:00:00 2001 From: Twest2 Date: Mon, 15 Jun 2026 14:58:16 -0500 Subject: [PATCH] Render PPTX callouts and normalize markdown text --- exporters/markdown-guide.js | 2 +- exporters/pptx.js | 94 ++++++++++++++++++++++++++--- tests/unit/exporters-binary.test.js | 6 ++ tests/unit/exporters-text.test.js | 1 + 4 files changed, 93 insertions(+), 10 deletions(-) diff --git a/exporters/markdown-guide.js b/exporters/markdown-guide.js index 7abfc8a..10e2a67 100644 --- a/exporters/markdown-guide.js +++ b/exporters/markdown-guide.js @@ -47,7 +47,7 @@ function emitBlock(lines, tb, { alertStyle = 'gfm' } = {}) { `
`, `
${escapeHtml(title)}
`, ); - if (body) lines.push(`
${tb.descriptionHtml || ''}
`); + if (body) lines.push(`
${tb.descriptionHtml || ''}
`); lines.push('
', ''); return; } diff --git a/exporters/pptx.js b/exporters/pptx.js index 2ddcd85..6f40541 100644 --- a/exporters/pptx.js +++ b/exporters/pptx.js @@ -5,7 +5,7 @@ const path = require('node:path'); const { zipSync } = require('../core/zip'); const { escapeXml } = require('../core/util'); const { encodePng } = require('../core/png'); -const { guideSlug, renderAllImages } = require('./common'); +const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups } = require('./common'); const { tocEntries, guideMetaLines, guideSummary } = require('./document-layout'); /** @@ -22,6 +22,15 @@ const DEFAULT_TEMPLATE = { const SLIDE_W = 12192000; // EMU, 16:9 const SLIDE_H = 6858000; const EMU_PER_PX = 9525; +const SLIDE_MARGIN = 914400; +const TITLE_Y = 420000; +const TITLE_H = 620000; +const TITLE_RULE_Y = 1120000; +const CONTENT_Y = 1500000; +const CONTENT_FOOTER_Y = 720000; +const CALL_OUT_HEIGHT = 620000; +const CALL_OUT_GAP = 90000; +const CALL_OUT_BAR_W = 24000; const TOC_ENTRY_START_Y = 2300000; const TOC_ENTRY_SPACING = 255000; const TOC_ENTRY_HEIGHT = 220000; @@ -55,6 +64,49 @@ function picture(relId, x, y, w, h) { ``; } +const CALLOUT_STYLE = { + info: { fill: 'EFF6FF', accent: '2563EB', label: 'Note', color: '1D4ED8' }, + success: { fill: 'ECFDF5', accent: '10B981', label: 'Tip', color: '047857' }, + warn: { fill: 'FFFBEB', accent: 'F59E0B', label: 'Warning', color: 'B45309' }, + error: { fill: 'FEF2F2', accent: 'EF4444', label: 'Important', color: 'B91C1C' }, +}; + +function estimateWrappedLines(text, charsPerLine) { + const raw = String(text || '').trim(); + if (!raw) return 0; + return raw.split(/\n+/).reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / charsPerLine)), 0); +} + +function calloutHeight(tb) { + const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`; + const lines = estimateWrappedLines(label, 46) + estimateWrappedLines(tb.descriptionText, 72); + return Math.max(CALL_OUT_HEIGHT, 360000 + (lines * 150000)); +} + +function calloutXml(tb, x, y, w) { + const style = CALLOUT_STYLE[tb.level] || CALLOUT_STYLE.info; + const height = calloutHeight(tb); + const label = `${style.label}${tb.title ? `: ${tb.title}` : ''}`; + const titlePara = para(label, { size: 1400, bold: true, color: style.color }); + const bodyPara = tb.descriptionText ? para(tb.descriptionText.slice(0, 400), { size: 1250, color: '374151' }) : ''; + const innerX = x + CALL_OUT_BAR_W + 24000; + const innerW = Math.max(0, w - CALL_OUT_BAR_W - 72000); + return { + height, + xml: [ + rectShape(x, y, w, height, style.fill), + rectShape(x, y, CALL_OUT_BAR_W, height, style.accent), + textBox(innerX, y + 12000, innerW, Math.max(0, height - 24000), `${titlePara}${bodyPara}`), + ].join(''), + }; +} + +function descriptionHeight(text) { + const lines = estimateWrappedLines(text, 95); + if (!lines) return 0; + return Math.max(360000, 260000 + (lines * 130000)); +} + function slideXml(content) { return ` para(line, { size: 1500, color: '6B7280' }))].join('')); slides.push({ @@ -156,10 +208,24 @@ function exportPptx(ast, outDir, template = {}) { let mediaCounter = 0; for (const step of ast.steps) { + const { before, rest } = stepContentGroups(step); + const beforeBlocks = before.filter((block) => block.kind === 'text'); + const restBlocks = rest.filter((block) => block.kind === 'text'); + let content = rectShape(0, 0, SLIDE_W, 18000, '2563EB'); - content += textBox(457200, 420000, SLIDE_W - 914400, 620000, + content += textBox(457200, TITLE_Y, SLIDE_W - 914400, TITLE_H, para(`${step.number}. ${step.title || 'Untitled step'}`, { size: 2600, bold: true })); - content += rectShape(457200, 1120000, 2400000, 12000, '2563EB'); + content += rectShape(457200, TITLE_RULE_Y, 2400000, 12000, '2563EB'); + + let y = CONTENT_Y; + for (const tb of beforeBlocks) { + const block = calloutXml(tb, 457200, y, SLIDE_W - 914400); + content += block.xml; + y += block.height + CALL_OUT_GAP; + } + + const descReserve = step.descriptionText ? descriptionHeight(step.descriptionText) + 120000 : 0; + const restReserve = restBlocks.reduce((sum, tb) => sum + calloutHeight(tb) + CALL_OUT_GAP, 0); const rels = []; const media = []; @@ -171,15 +237,25 @@ function exportPptx(ast, outDir, template = {}) { const relId = 2; // rId1 = layout, rId2 = image rels.push({ id: relId, name }); // Fit image into a centered region below the title. - const maxW = SLIDE_W - 1219200, maxH = SLIDE_H - 1870000 - (step.descriptionText ? 650000 : 260000); + const maxW = SLIDE_W - 1219200; + const maxH = Math.max(0, SLIDE_H - y - descReserve - restReserve - 100000); let w = img.width * EMU_PER_PX, h = img.height * EMU_PER_PX; const scale = Math.min(maxW / w, maxH / h, 1); w = Math.round(w * scale); h = Math.round(h * scale); - content += picture(relId, Math.round((SLIDE_W - w) / 2), 1500000, w, h); + content += picture(relId, Math.round((SLIDE_W - w) / 2), y, w, h); + y += h + 100000; } if (step.descriptionText) { - content += textBox(457200, SLIDE_H - 720000, SLIDE_W - 914400, 600000, + const descH = Math.max(360000, descReserve || 420000); + content += textBox(457200, Math.max(y, SLIDE_H - CONTENT_FOOTER_Y - descH), SLIDE_W - 914400, descH, para(step.descriptionText.slice(0, 300), { size: 1400, color: '374151' })); + y = Math.max(y, SLIDE_H - CONTENT_FOOTER_Y - descH) + descH + 90000; + } + + for (const tb of restBlocks) { + const block = calloutXml(tb, 457200, y, SLIDE_W - 914400); + content += block.xml; + y += block.height + CALL_OUT_GAP; } slides.push({ xml: slideXml(content), rels, media }); } diff --git a/tests/unit/exporters-binary.test.js b/tests/unit/exporters-binary.test.js index 3c9896c..a82659c 100644 --- a/tests/unit/exporters-binary.test.js +++ b/tests/unit/exporters-binary.test.js @@ -362,6 +362,9 @@ test('PPTX export: slides per step, master/layout/theme present, rels resolve', const pres = entries.get('ppt/presentation.xml').toString('utf8'); assert.equal((pres.match(/ name.startsWith('ppt/media/')); + assert.equal(mediaTargets.length, 2); + assert.ok(!mediaTargets.some((name) => name.includes('callout-'))); }); test('PPTX export: TOC paginates onto additional slides before it would overflow', (t) => { diff --git a/tests/unit/exporters-text.test.js b/tests/unit/exporters-text.test.js index b823d98..295cea9 100644 --- a/tests/unit/exporters-text.test.js +++ b/tests/unit/exporters-text.test.js @@ -141,6 +141,7 @@ test('Markdown export: TOC anchors resolve, images exist, blocks rendered', (t) assert.ok(md.includes('
Admins only.

')); });