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(`
`);
}
+ 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(``, '');
}
}
+ 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',
+ ';
+
+ 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));