diff --git a/exporters/common.js b/exporters/common.js index a0f1809..e1ef6bb 100644 --- a/exporters/common.js +++ b/exporters/common.js @@ -56,17 +56,52 @@ function stepBlocks(step) { } /** - * Split a step's blocks into the groups exporters lay out around the - * description/image: text blocks pinned to 'before-description', and - * everything else (code/table blocks plus 'after-description' and - * 'after-image' text blocks) in the same relative order they appear in the - * editor's Blocks list. + * Split a step's blocks into the layout buckets exporters use around the + * step title, description, and image. Text blocks keep their block-list + * ordering inside each bucket; code/table blocks stay in `rest`. */ function stepContentGroups(step) { const all = stepBlocks(step); - const before = all.filter((b) => b.kind === 'text' && b.position === 'before-description'); - const rest = all.filter((b) => !(b.kind === 'text' && b.position === 'before-description')); - return { before, rest }; + const groups = { + beforeTitle: [], + afterTitle: [], + beforeDescription: [], + afterDescription: [], + beforeImage: [], + afterImage: [], + rest: [], + }; + + for (const block of all) { + if (block.kind !== 'text') { + groups.rest.push(block); + continue; + } + + switch (block.position) { + case 'before-title': + groups.beforeTitle.push(block); + break; + case 'after-title': + groups.afterTitle.push(block); + break; + case 'before-image': + groups.beforeImage.push(block); + break; + case 'after-image': + groups.afterImage.push(block); + break; + case 'before-description': + groups.beforeDescription.push(block); + break; + case 'after-description': + default: + groups.afterDescription.push(block); + break; + } + } + + return groups; } function codeBlockText(block) { diff --git a/exporters/confluence.js b/exporters/confluence.js index 675ef15..79ce49e 100644 --- a/exporters/confluence.js +++ b/exporters/confluence.js @@ -62,11 +62,25 @@ function exportConfluence(ast, outDir, template = {}) { } const stepXml = ast.steps.map((step) => { - const parts = [``, `

${escapeXml(step.number)}. ${escapeXml(step.title || 'Untitled step')}

`]; + const { + beforeTitle, + afterTitle, + beforeDescription, + afterDescription, + beforeImage, + afterImage, + rest, + } = stepContentGroups(step); + const parts = [``]; + for (const tb of beforeTitle) { + parts.push(blockMacro(tb, ast)); + } + parts.push(`

${escapeXml(step.number)}. ${escapeXml(step.title || 'Untitled step')}

`); if (step.skipped) parts.push('

(skipped)

'); - - const { before, rest } = stepContentGroups(step); - for (const tb of before) { + for (const tb of afterTitle) { + parts.push(blockMacro(tb, ast)); + } + for (const tb of beforeDescription) { parts.push(blockMacro(tb, ast)); } @@ -74,11 +88,22 @@ function exportConfluence(ast, outDir, template = {}) { parts.push(`
${stepLinkRewrite(step.descriptionHtml, ast)}
`); } + for (const tb of afterDescription) { + parts.push(blockMacro(tb, ast)); + } + for (const tb of beforeImage) { + parts.push(blockMacro(tb, ast)); + } + const attachment = attachmentNames.get(step.stepId); if (attachment) { parts.push(`

`); } + for (const tb of afterImage) { + parts.push(blockMacro(tb, ast)); + } + for (const block of rest) { if (block.kind === 'text') { parts.push(blockMacro(block, ast)); diff --git a/exporters/docx.js b/exporters/docx.js index 3b44e00..5a4106d 100644 --- a/exporters/docx.js +++ b/exporters/docx.js @@ -10,8 +10,8 @@ const { guideMetaLines, guideSummary, tocEntries } = require('./document-layout' /** * DOCX exporter: WordprocessingML built directly (no dependency), one - * heading + description + screenshot per step, text blocks, code blocks - * (Courier), and tables. + * heading per step with positioned text blocks around the description and + * screenshot, plus code blocks (Courier) and tables. */ const DEFAULT_TEMPLATE = { @@ -274,16 +274,27 @@ function exportDocx(ast, outDir, template = {}) { const headSize = headingLevel === 1 ? 30 : headingLevel === 2 ? 26 : 22; const bookmarkId = ++bookmarkCounter; const anchor = bookmarkName(step); + const { + beforeTitle, + afterTitle, + beforeDescription, + afterDescription, + beforeImage, + afterImage, + rest, + } = stepContentGroups(step); + for (const tb of beforeTitle) emitTextBlock(tb); body.push(p( `` + run(`${step.number}. ${step.title || 'Untitled step'}`, { bold: true, size: headSize }) + ``, headingParagraphProps(step.depth, step.forceNewPage) )); - - const { before, rest } = stepContentGroups(step); - for (const tb of before) emitTextBlock(tb); + for (const tb of afterTitle) emitTextBlock(tb); + for (const tb of beforeDescription) emitTextBlock(tb); if (step.descriptionText) body.push(p(run(step.descriptionText, { size: 20, color: '1F2937' }))); + for (const tb of afterDescription) emitTextBlock(tb); + for (const tb of beforeImage) emitTextBlock(tb); const img = images.get(step.stepId); if (img) { @@ -295,6 +306,8 @@ function exportDocx(ast, outDir, template = {}) { stepImageCount += 1; } + for (const tb of afterImage) emitTextBlock(tb); + for (const block of rest) { if (block.kind === 'text') { emitTextBlock(block); diff --git a/exporters/html.js b/exporters/html.js index d0aaeb0..08e3247 100644 --- a/exporters/html.js +++ b/exporters/html.js @@ -40,6 +40,10 @@ function blockHtml(tb) { return `
${escapeHtml(LEVEL_LABEL[tb.level] || 'Note')}${tb.title ? `: ${escapeHtml(tb.title)}` : ''}${tb.descriptionHtml ? `
${tb.descriptionHtml}
` : ''}
`; } +function renderBlockListHtml(blocks) { + return blocks.map((block) => blockHtml(block)).join('\n'); +} + function renderMetaChips(ast) { return [ ...guideMetaLines(ast).map((line) => `${escapeHtml(line)}`), @@ -85,11 +89,22 @@ function renderStepCard(step, ast, images, tpl, { rich = false, selected = false ${escapeHtml(step.title || 'Untitled step')} ${escapeHtml(statusText)} ` : `

${title}

`; + const { + beforeTitle, + afterTitle, + beforeDescription, + afterDescription, + beforeImage, + afterImage, + rest, + } = stepContentGroups(step); return `
+ ${renderBlockListHtml(beforeTitle)} ${head} + ${renderBlockListHtml(afterTitle)}
- ${stepBodyHtml(step, ast, images, tpl)} + ${renderStepBodyHtml({ beforeDescription, afterDescription, beforeImage, afterImage, rest }, step, ast, images, tpl)}
`; } @@ -117,15 +132,24 @@ function bodyStyle(tpl) { return `--accent:${tpl.accentColor};--accent-rgb:${r},${g},${b};`; } -function stepBodyHtml(step, ast, images, tpl) { +function renderStepBodyHtml(groups, step, ast, images, tpl) { const parts = []; - const { before, rest } = stepContentGroups(step); - for (const tb of before) parts.push(blockHtml(tb)); + const { + beforeDescription, + afterDescription, + beforeImage, + afterImage, + rest, + } = groups; + for (const tb of beforeDescription) parts.push(blockHtml(tb)); if (step.descriptionHtml) parts.push(`
${stepLinkRewrite(step.descriptionHtml, ast)}
`); + for (const tb of afterDescription) parts.push(blockHtml(tb)); + for (const tb of beforeImage) parts.push(blockHtml(tb)); const img = images.get(step.stepId); if (img && tpl.includeImages) { parts.push(`Step ${escapeHtml(step.number)}`); } + for (const tb of afterImage) parts.push(blockHtml(tb)); for (const block of rest) { if (block.kind === 'text') { parts.push(blockHtml(block)); diff --git a/exporters/markdown-guide.js b/exporters/markdown-guide.js index 10e2a67..9034ba7 100644 --- a/exporters/markdown-guide.js +++ b/exporters/markdown-guide.js @@ -96,15 +96,25 @@ function renderMarkdownGuide(ast, outDir, template = {}, { for (const step of ast.steps) { const heading = step.depth > 0 ? '###' : '##'; + const { + beforeTitle, + afterTitle, + beforeDescription, + afterDescription, + beforeImage, + afterImage, + rest, + } = stepContentGroups(step); lines.push(``, ''); + for (const tb of beforeTitle) emitBlock(lines, tb, { alertStyle }); lines.push(`${heading} ${step.number}. ${step.title || 'Untitled step'}`, ''); if (step.skipped) lines.push('*(skipped)*', ''); - - const { before, rest } = stepContentGroups(step); - for (const tb of before) emitBlock(lines, tb, { alertStyle }); - + for (const tb of afterTitle) emitBlock(lines, tb, { alertStyle }); + for (const tb of beforeDescription) emitBlock(lines, tb, { alertStyle }); if (step.descriptionHtml) lines.push(htmlToMarkdown(step.descriptionHtml), ''); + for (const tb of afterDescription) emitBlock(lines, tb, { alertStyle }); + for (const tb of beforeImage) emitBlock(lines, tb, { alertStyle }); const img = images.get(step.stepId); if (img) { if (tpl.azureWiki && tpl.imageMaxWidth > 0) { @@ -113,6 +123,7 @@ function renderMarkdownGuide(ast, outDir, template = {}, { lines.push(`![Step ${step.number}](${img.relPath})`, ''); } } + for (const tb of afterImage) emitBlock(lines, tb, { alertStyle }); for (const block of rest) { if (block.kind === 'text') { diff --git a/exporters/pdf.js b/exporters/pdf.js index 34c997c..a2d53d2 100644 --- a/exporters/pdf.js +++ b/exporters/pdf.js @@ -203,10 +203,10 @@ function exportPdf(ast, outDir, template = {}) { /** * Compute the vertical space a step will need: `head` is the title, the - * accent rule, any before-description blocks, the description, and the - * image — kept together on one page — and `total` adds the remaining - * blocks plus the trailing gap, used to decide whether the whole step - * fits on a fresh page. + * accent rule, positioned text blocks, the description, and the image — + * kept together on one page — and `total` adds the remaining blocks plus + * the trailing gap, used to decide whether the whole step fits on a + * fresh page. */ const measureStep = (step) => { const headSize = step.depth > 0 ? 12 : 14; @@ -214,8 +214,18 @@ function exportPdf(ast, outDir, template = {}) { const titleLines = pdf.wrapText(titleText, headSize, usableW, 'F2'); let head = Math.max(40, titleLines.length * headSize * 1.35 + 8); - const { before, rest } = stepContentGroups(step); - for (const tb of before) head += measureBlock(tb); + const { + beforeTitle, + afterTitle, + beforeDescription, + afterDescription, + beforeImage, + afterImage, + rest, + } = stepContentGroups(step); + for (const tb of [...beforeTitle, ...afterTitle, ...beforeDescription, ...afterDescription, ...beforeImage, ...afterImage]) { + head += measureBlock(tb); + } if (step.descriptionHtml) head += layoutDescription(pdf, step.descriptionHtml, usableW, 10.5).height; head += measureImage(step.stepId); @@ -298,14 +308,26 @@ function exportPdf(ast, outDir, template = {}) { pdf.bookmark(`${step.number}. ${step.title || 'Untitled step'}`); const tocTarget = tocTargets.get(step.stepId); if (tocTarget) tocTarget.pageIndex = pdf.pages.length - 1; + const { + beforeTitle, + afterTitle, + beforeDescription, + afterDescription, + beforeImage, + afterImage, + rest, + } = stepContentGroups(step); + for (const tb of beforeTitle) emitBlock(tb); const headSize = step.depth > 0 ? 12 : 14; writeLines(`${step.number}. ${step.title || 'Untitled step'}${step.skipped ? ' (skipped)' : ''}`, { size: headSize, font: 'F2' }); pdf.rect(M, y, usableW, 0.8, { fill: [225, 228, 232] }); y += 8; - const { before, rest } = stepContentGroups(step); - for (const tb of before) emitBlock(tb); + for (const tb of afterTitle) emitBlock(tb); + for (const tb of beforeDescription) emitBlock(tb); if (step.descriptionHtml) writeDescription(step.descriptionHtml); + for (const tb of afterDescription) emitBlock(tb); + for (const tb of beforeImage) emitBlock(tb); const img = images.get(step.stepId); if (img) { @@ -317,6 +339,7 @@ function exportPdf(ast, outDir, template = {}) { pdf.image(img, M, y, w, h); y += h + 10; } + for (const tb of afterImage) emitBlock(tb); for (const block of rest) { if (block.kind === 'text') { diff --git a/exporters/pptx.js b/exporters/pptx.js index 6f40541..56f1361 100644 --- a/exporters/pptx.js +++ b/exporters/pptx.js @@ -9,8 +9,9 @@ const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups } = require(' const { tocEntries, guideMetaLines, guideSummary } = require('./document-layout'); /** - * PPTX exporter: a title slide plus one 16:9 slide per step (title bar + - * screenshot + description). PresentationML written directly. + * PPTX exporter: a title slide plus one 16:9 slide per step, with + * positioned text blocks laid out around the step title, description, and + * screenshot. PresentationML written directly. */ const DEFAULT_TEMPLATE = { @@ -208,24 +209,63 @@ 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'); - + const { + beforeTitle, + afterTitle, + beforeDescription, + afterDescription, + beforeImage, + afterImage, + } = stepContentGroups(step); let content = rectShape(0, 0, SLIDE_W, 18000, '2563EB'); - content += textBox(457200, TITLE_Y, SLIDE_W - 914400, TITLE_H, - para(`${step.number}. ${step.title || 'Untitled step'}`, { size: 2600, bold: true })); - content += rectShape(457200, TITLE_RULE_Y, 2400000, 12000, '2563EB'); + const beforeTitleReserve = beforeTitle.reduce((sum, tb) => sum + calloutHeight(tb) + CALL_OUT_GAP, 0); + const titleY = beforeTitle.length ? 220000 + beforeTitleReserve + 120000 : TITLE_Y; + const titleRuleY = beforeTitle.length ? titleY + TITLE_H + 120000 : TITLE_RULE_Y; + const contentY = beforeTitle.length ? Math.max(CONTENT_Y, titleRuleY + 180000) : CONTENT_Y; + const beforeTitleY = beforeTitle.length ? 220000 : CONTENT_Y; - let y = CONTENT_Y; - for (const tb of beforeBlocks) { + let y = beforeTitleY; + for (const tb of beforeTitle) { 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); + content += textBox(457200, titleY, SLIDE_W - 914400, TITLE_H, + para(`${step.number}. ${step.title || 'Untitled step'}`, { size: 2600, bold: true })); + content += rectShape(457200, titleRuleY, 2400000, 12000, '2563EB'); + + y = contentY; + for (const tb of afterTitle) { + const block = calloutXml(tb, 457200, y, SLIDE_W - 914400); + content += block.xml; + y += block.height + CALL_OUT_GAP; + } + for (const tb of beforeDescription) { + const block = calloutXml(tb, 457200, y, SLIDE_W - 914400); + content += block.xml; + y += block.height + CALL_OUT_GAP; + } + + if (step.descriptionText) { + const descH = descriptionHeight(step.descriptionText); + content += textBox(457200, y, SLIDE_W - 914400, Math.max(360000, descH || 420000), + para(step.descriptionText.slice(0, 300), { size: 1400, color: '374151' })); + y += Math.max(360000, descH || 420000) + 90000; + } + + for (const tb of afterDescription) { + const block = calloutXml(tb, 457200, y, SLIDE_W - 914400); + content += block.xml; + y += block.height + CALL_OUT_GAP; + } + for (const tb of beforeImage) { + const block = calloutXml(tb, 457200, y, SLIDE_W - 914400); + content += block.xml; + y += block.height + CALL_OUT_GAP; + } + + const postImageReserve = afterImage.reduce((sum, tb) => sum + calloutHeight(tb) + CALL_OUT_GAP, 0); const rels = []; const media = []; @@ -236,23 +276,17 @@ function exportPptx(ast, outDir, template = {}) { media.push({ name, data: encodePng(img) }); const relId = 2; // rId1 = layout, rId2 = image rels.push({ id: relId, name }); - // Fit image into a centered region below the title. + // Fit image into the remaining centered region before the trailing blocks. const maxW = SLIDE_W - 1219200; - const maxH = Math.max(0, SLIDE_H - y - descReserve - restReserve - 100000); + const maxH = Math.max(0, SLIDE_H - y - postImageReserve - 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), y, w, h); y += h + 100000; } - if (step.descriptionText) { - 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) { + for (const tb of afterImage) { const block = calloutXml(tb, 457200, y, SLIDE_W - 914400); content += block.xml; y += block.height + CALL_OUT_GAP; diff --git a/tests/unit/exporters-text.test.js b/tests/unit/exporters-text.test.js index 295cea9..ab5832a 100644 --- a/tests/unit/exporters-text.test.js +++ b/tests/unit/exporters-text.test.js @@ -12,6 +12,7 @@ const { exportWikiJs } = require('../../exporters/wikijs'); const { exportHtmlSimple, exportHtmlRich } = require('../../exporters/html'); const { exportConfluence } = require('../../exporters/confluence'); const { htmlToMarkdown } = require('../../exporters/htmlmd'); +const { stepContentGroups } = require('../../exporters/common'); const { decodePng } = require('../../core/png'); const { buildFixtureGuide } = require('./fixture-guide'); const { makeTmpDir, rmrf } = require('./helpers'); @@ -146,6 +147,70 @@ test('Markdown export: TOC anchors resolve, images exist, blocks rendered', (t) assert.ok(md.includes('

Admins only.

')); }); +test('text block positions render around the title, description, and image', (t) => { + const root = makeTmpDir('exppositions'); + t.after(() => rmrf(root)); + const { store, guide, s1 } = buildFixtureGuide(path.join(root, 'data')); + + const step = store.getStep(guide.guideId, s1.stepId); + step.textBlocks = [ + { id: 'tb-before-title', order: 1, position: 'before-title', level: 'info', title: 'Before title', descriptionHtml: '

Before title.

' }, + { id: 'tb-after-title', order: 2, position: 'after-title', level: 'info', title: 'After title', descriptionHtml: '

After title.

' }, + { id: 'tb-before-description', order: 3, position: 'before-description', level: 'info', title: 'Before description', descriptionHtml: '

Before description.

' }, + { id: 'tb-after-description', order: 4, position: 'after-description', level: 'info', title: 'After description', descriptionHtml: '

After description.

' }, + { id: 'tb-before-image', order: 5, position: 'before-image', level: 'info', title: 'Before image', descriptionHtml: '

Before image.

' }, + { id: 'tb-after-image', order: 6, position: 'after-image', level: 'info', title: 'After image', descriptionHtml: '

After image.

' }, + ]; + store.saveStep(guide.guideId, step); + + const ast = buildRenderAst(store, guide.guideId); + const groups = stepContentGroups(ast.steps[0]); + assert.equal(groups.beforeTitle.length, 1); + assert.equal(groups.afterTitle.length, 1); + assert.equal(groups.beforeDescription.length, 1); + assert.equal(groups.afterDescription.length, 1); + assert.equal(groups.beforeImage.length, 1); + assert.equal(groups.afterImage.length, 1); + + const assertIncreasingOrder = (text, markers) => { + let previous = -1; + for (const marker of markers) { + const index = text.indexOf(marker); + assert.ok(index >= 0, `missing marker: ${marker}`); + assert.ok(index > previous, `marker out of order: ${marker}`); + previous = index; + } + }; + + const mdOut = path.join(root, 'md'); + const md = fs.readFileSync(exportMarkdown(ast, mdOut).file, 'utf8'); + assertIncreasingOrder(md, [ + 'Before title', + '## 1. Open AcmeSync settings', + 'After title', + 'Before description', + 'docs.example.com', + 'After description', + 'Before image', + '![Step 1](', + 'After image', + ]); + + const htmlOut = path.join(root, 'html'); + const html = fs.readFileSync(exportHtmlSimple(ast, htmlOut).file, 'utf8'); + assertIncreasingOrder(html, [ + 'Before title', + '

1. Open AcmeSync settings

', + 'After title', + 'Before description', + 'docs.example.com', + 'After description', + 'Before image', + 'alt="Step 1"', + 'After image', + ]); +}); + test('Wiki.js export: TOC is included, wiki callouts render, images exist', (t) => { const root = makeTmpDir('expwikijs'); t.after(() => rmrf(root));