Honor text block positions in exports

This commit is contained in:
2026-06-23 16:44:28 -05:00
parent 2b27428849
commit ffaa123893
8 changed files with 285 additions and 55 deletions
+43 -8
View File
@@ -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) {
+29 -4
View File
@@ -62,11 +62,25 @@ function exportConfluence(ast, outDir, template = {}) {
}
const stepXml = ast.steps.map((step) => {
const parts = [`<a id="${anchorFor(step)}"></a>`, `<h2>${escapeXml(step.number)}. ${escapeXml(step.title || 'Untitled step')}</h2>`];
const {
beforeTitle,
afterTitle,
beforeDescription,
afterDescription,
beforeImage,
afterImage,
rest,
} = stepContentGroups(step);
const parts = [`<a id="${anchorFor(step)}"></a>`];
for (const tb of beforeTitle) {
parts.push(blockMacro(tb, ast));
}
parts.push(`<h2>${escapeXml(step.number)}. ${escapeXml(step.title || 'Untitled step')}</h2>`);
if (step.skipped) parts.push('<p><em>(skipped)</em></p>');
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(`<div>${stepLinkRewrite(step.descriptionHtml, ast)}</div>`);
}
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(`<p><ac:image><ri:attachment ri:filename="${escapeXml(attachment)}" /></ac:image></p>`);
}
for (const tb of afterImage) {
parts.push(blockMacro(tb, ast));
}
for (const block of rest) {
if (block.kind === 'text') {
parts.push(blockMacro(block, ast));
+18 -5
View File
@@ -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(
`<w:bookmarkStart w:id="${bookmarkId}" w:name="${anchor}"/>` +
run(`${step.number}. ${step.title || 'Untitled step'}`, { bold: true, size: headSize }) +
`<w:bookmarkEnd w:id="${bookmarkId}"/>`,
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);
+28 -4
View File
@@ -40,6 +40,10 @@ function blockHtml(tb) {
return `<div class="block block-${tb.level}"><strong>${escapeHtml(LEVEL_LABEL[tb.level] || 'Note')}${tb.title ? `: ${escapeHtml(tb.title)}` : ''}</strong>${tb.descriptionHtml ? `<div>${tb.descriptionHtml}</div>` : ''}</div>`;
}
function renderBlockListHtml(blocks) {
return blocks.map((block) => blockHtml(block)).join('\n');
}
function renderMetaChips(ast) {
return [
...guideMetaLines(ast).map((line) => `<span class="chip">${escapeHtml(line)}</span>`),
@@ -85,11 +89,22 @@ function renderStepCard(step, ast, images, tpl, { rich = false, selected = false
<span class="step-title">${escapeHtml(step.title || 'Untitled step')}</span>
<span class="status-chip status-${step.status || 'todo'}${step.skipped ? ' skipped' : ''}">${escapeHtml(statusText)}</span>
</h2>` : `<h2>${title}</h2>`;
const {
beforeTitle,
afterTitle,
beforeDescription,
afterDescription,
beforeImage,
afterImage,
rest,
} = stepContentGroups(step);
return `
<section class="step-card${step.skipped ? ' skipped' : ''}${rich && selected ? ' selected' : ''}" id="${anchorFor(step)}">
${renderBlockListHtml(beforeTitle)}
${head}
${renderBlockListHtml(afterTitle)}
<div class="step-body">
${stepBodyHtml(step, ast, images, tpl)}
${renderStepBodyHtml({ beforeDescription, afterDescription, beforeImage, afterImage, rest }, step, ast, images, tpl)}
</div>
</section>`;
}
@@ -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(`<div class="desc">${stepLinkRewrite(step.descriptionHtml, ast)}</div>`);
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(`<img class="shot" alt="Step ${escapeHtml(step.number)}" src="${dataUri(img)}" width="${img.width}">`);
}
for (const tb of afterImage) parts.push(blockHtml(tb));
for (const block of rest) {
if (block.kind === 'text') {
parts.push(blockHtml(block));
+15 -4
View File
@@ -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(`<a id="${anchorFor(step)}"></a>`, '');
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') {
+31 -8
View File
@@ -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') {
+56 -22
View File
@@ -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;
+65
View File
@@ -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('<p>Admins only.</p>'));
});
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: '<p>Before title.</p>' },
{ id: 'tb-after-title', order: 2, position: 'after-title', level: 'info', title: 'After title', descriptionHtml: '<p>After title.</p>' },
{ id: 'tb-before-description', order: 3, position: 'before-description', level: 'info', title: 'Before description', descriptionHtml: '<p>Before description.</p>' },
{ id: 'tb-after-description', order: 4, position: 'after-description', level: 'info', title: 'After description', descriptionHtml: '<p>After description.</p>' },
{ id: 'tb-before-image', order: 5, position: 'before-image', level: 'info', title: 'Before image', descriptionHtml: '<p>Before image.</p>' },
{ id: 'tb-after-image', order: 6, position: 'after-image', level: 'info', title: 'After image', descriptionHtml: '<p>After image.</p>' },
];
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',
'<h2>1. Open AcmeSync settings</h2>',
'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));