Honor text block positions in exports
This commit is contained in:
+43
-8
@@ -56,17 +56,52 @@ function stepBlocks(step) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Split a step's blocks into the groups exporters lay out around the
|
* Split a step's blocks into the layout buckets exporters use around the
|
||||||
* description/image: text blocks pinned to 'before-description', and
|
* step title, description, and image. Text blocks keep their block-list
|
||||||
* everything else (code/table blocks plus 'after-description' and
|
* ordering inside each bucket; code/table blocks stay in `rest`.
|
||||||
* 'after-image' text blocks) in the same relative order they appear in the
|
|
||||||
* editor's Blocks list.
|
|
||||||
*/
|
*/
|
||||||
function stepContentGroups(step) {
|
function stepContentGroups(step) {
|
||||||
const all = stepBlocks(step);
|
const all = stepBlocks(step);
|
||||||
const before = all.filter((b) => b.kind === 'text' && b.position === 'before-description');
|
const groups = {
|
||||||
const rest = all.filter((b) => !(b.kind === 'text' && b.position === 'before-description'));
|
beforeTitle: [],
|
||||||
return { before, rest };
|
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) {
|
function codeBlockText(block) {
|
||||||
|
|||||||
+29
-4
@@ -62,11 +62,25 @@ function exportConfluence(ast, outDir, template = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const stepXml = ast.steps.map((step) => {
|
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>');
|
if (step.skipped) parts.push('<p><em>(skipped)</em></p>');
|
||||||
|
for (const tb of afterTitle) {
|
||||||
const { before, rest } = stepContentGroups(step);
|
parts.push(blockMacro(tb, ast));
|
||||||
for (const tb of before) {
|
}
|
||||||
|
for (const tb of beforeDescription) {
|
||||||
parts.push(blockMacro(tb, ast));
|
parts.push(blockMacro(tb, ast));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,11 +88,22 @@ function exportConfluence(ast, outDir, template = {}) {
|
|||||||
parts.push(`<div>${stepLinkRewrite(step.descriptionHtml, ast)}</div>`);
|
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);
|
const attachment = attachmentNames.get(step.stepId);
|
||||||
if (attachment) {
|
if (attachment) {
|
||||||
parts.push(`<p><ac:image><ri:attachment ri:filename="${escapeXml(attachment)}" /></ac:image></p>`);
|
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) {
|
for (const block of rest) {
|
||||||
if (block.kind === 'text') {
|
if (block.kind === 'text') {
|
||||||
parts.push(blockMacro(block, ast));
|
parts.push(blockMacro(block, ast));
|
||||||
|
|||||||
+18
-5
@@ -10,8 +10,8 @@ const { guideMetaLines, guideSummary, tocEntries } = require('./document-layout'
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* DOCX exporter: WordprocessingML built directly (no dependency), one
|
* DOCX exporter: WordprocessingML built directly (no dependency), one
|
||||||
* heading + description + screenshot per step, text blocks, code blocks
|
* heading per step with positioned text blocks around the description and
|
||||||
* (Courier), and tables.
|
* screenshot, plus code blocks (Courier) and tables.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const DEFAULT_TEMPLATE = {
|
const DEFAULT_TEMPLATE = {
|
||||||
@@ -274,16 +274,27 @@ function exportDocx(ast, outDir, template = {}) {
|
|||||||
const headSize = headingLevel === 1 ? 30 : headingLevel === 2 ? 26 : 22;
|
const headSize = headingLevel === 1 ? 30 : headingLevel === 2 ? 26 : 22;
|
||||||
const bookmarkId = ++bookmarkCounter;
|
const bookmarkId = ++bookmarkCounter;
|
||||||
const anchor = bookmarkName(step);
|
const anchor = bookmarkName(step);
|
||||||
|
const {
|
||||||
|
beforeTitle,
|
||||||
|
afterTitle,
|
||||||
|
beforeDescription,
|
||||||
|
afterDescription,
|
||||||
|
beforeImage,
|
||||||
|
afterImage,
|
||||||
|
rest,
|
||||||
|
} = stepContentGroups(step);
|
||||||
|
for (const tb of beforeTitle) emitTextBlock(tb);
|
||||||
body.push(p(
|
body.push(p(
|
||||||
`<w:bookmarkStart w:id="${bookmarkId}" w:name="${anchor}"/>` +
|
`<w:bookmarkStart w:id="${bookmarkId}" w:name="${anchor}"/>` +
|
||||||
run(`${step.number}. ${step.title || 'Untitled step'}`, { bold: true, size: headSize }) +
|
run(`${step.number}. ${step.title || 'Untitled step'}`, { bold: true, size: headSize }) +
|
||||||
`<w:bookmarkEnd w:id="${bookmarkId}"/>`,
|
`<w:bookmarkEnd w:id="${bookmarkId}"/>`,
|
||||||
headingParagraphProps(step.depth, step.forceNewPage)
|
headingParagraphProps(step.depth, step.forceNewPage)
|
||||||
));
|
));
|
||||||
|
for (const tb of afterTitle) emitTextBlock(tb);
|
||||||
const { before, rest } = stepContentGroups(step);
|
for (const tb of beforeDescription) emitTextBlock(tb);
|
||||||
for (const tb of before) emitTextBlock(tb);
|
|
||||||
if (step.descriptionText) body.push(p(run(step.descriptionText, { size: 20, color: '1F2937' })));
|
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);
|
const img = images.get(step.stepId);
|
||||||
if (img) {
|
if (img) {
|
||||||
@@ -295,6 +306,8 @@ function exportDocx(ast, outDir, template = {}) {
|
|||||||
stepImageCount += 1;
|
stepImageCount += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const tb of afterImage) emitTextBlock(tb);
|
||||||
|
|
||||||
for (const block of rest) {
|
for (const block of rest) {
|
||||||
if (block.kind === 'text') {
|
if (block.kind === 'text') {
|
||||||
emitTextBlock(block);
|
emitTextBlock(block);
|
||||||
|
|||||||
+28
-4
@@ -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>`;
|
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) {
|
function renderMetaChips(ast) {
|
||||||
return [
|
return [
|
||||||
...guideMetaLines(ast).map((line) => `<span class="chip">${escapeHtml(line)}</span>`),
|
...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="step-title">${escapeHtml(step.title || 'Untitled step')}</span>
|
||||||
<span class="status-chip status-${step.status || 'todo'}${step.skipped ? ' skipped' : ''}">${escapeHtml(statusText)}</span>
|
<span class="status-chip status-${step.status || 'todo'}${step.skipped ? ' skipped' : ''}">${escapeHtml(statusText)}</span>
|
||||||
</h2>` : `<h2>${title}</h2>`;
|
</h2>` : `<h2>${title}</h2>`;
|
||||||
|
const {
|
||||||
|
beforeTitle,
|
||||||
|
afterTitle,
|
||||||
|
beforeDescription,
|
||||||
|
afterDescription,
|
||||||
|
beforeImage,
|
||||||
|
afterImage,
|
||||||
|
rest,
|
||||||
|
} = stepContentGroups(step);
|
||||||
return `
|
return `
|
||||||
<section class="step-card${step.skipped ? ' skipped' : ''}${rich && selected ? ' selected' : ''}" id="${anchorFor(step)}">
|
<section class="step-card${step.skipped ? ' skipped' : ''}${rich && selected ? ' selected' : ''}" id="${anchorFor(step)}">
|
||||||
|
${renderBlockListHtml(beforeTitle)}
|
||||||
${head}
|
${head}
|
||||||
|
${renderBlockListHtml(afterTitle)}
|
||||||
<div class="step-body">
|
<div class="step-body">
|
||||||
${stepBodyHtml(step, ast, images, tpl)}
|
${renderStepBodyHtml({ beforeDescription, afterDescription, beforeImage, afterImage, rest }, step, ast, images, tpl)}
|
||||||
</div>
|
</div>
|
||||||
</section>`;
|
</section>`;
|
||||||
}
|
}
|
||||||
@@ -117,15 +132,24 @@ function bodyStyle(tpl) {
|
|||||||
return `--accent:${tpl.accentColor};--accent-rgb:${r},${g},${b};`;
|
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 parts = [];
|
||||||
const { before, rest } = stepContentGroups(step);
|
const {
|
||||||
for (const tb of before) parts.push(blockHtml(tb));
|
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>`);
|
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);
|
const img = images.get(step.stepId);
|
||||||
if (img && tpl.includeImages) {
|
if (img && tpl.includeImages) {
|
||||||
parts.push(`<img class="shot" alt="Step ${escapeHtml(step.number)}" src="${dataUri(img)}" width="${img.width}">`);
|
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) {
|
for (const block of rest) {
|
||||||
if (block.kind === 'text') {
|
if (block.kind === 'text') {
|
||||||
parts.push(blockHtml(block));
|
parts.push(blockHtml(block));
|
||||||
|
|||||||
@@ -96,15 +96,25 @@ function renderMarkdownGuide(ast, outDir, template = {}, {
|
|||||||
|
|
||||||
for (const step of ast.steps) {
|
for (const step of ast.steps) {
|
||||||
const heading = step.depth > 0 ? '###' : '##';
|
const heading = step.depth > 0 ? '###' : '##';
|
||||||
|
const {
|
||||||
|
beforeTitle,
|
||||||
|
afterTitle,
|
||||||
|
beforeDescription,
|
||||||
|
afterDescription,
|
||||||
|
beforeImage,
|
||||||
|
afterImage,
|
||||||
|
rest,
|
||||||
|
} = stepContentGroups(step);
|
||||||
lines.push(`<a id="${anchorFor(step)}"></a>`, '');
|
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'}`, '');
|
lines.push(`${heading} ${step.number}. ${step.title || 'Untitled step'}`, '');
|
||||||
if (step.skipped) lines.push('*(skipped)*', '');
|
if (step.skipped) lines.push('*(skipped)*', '');
|
||||||
|
for (const tb of afterTitle) emitBlock(lines, tb, { alertStyle });
|
||||||
const { before, rest } = stepContentGroups(step);
|
for (const tb of beforeDescription) emitBlock(lines, tb, { alertStyle });
|
||||||
for (const tb of before) emitBlock(lines, tb, { alertStyle });
|
|
||||||
|
|
||||||
if (step.descriptionHtml) lines.push(htmlToMarkdown(step.descriptionHtml), '');
|
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);
|
const img = images.get(step.stepId);
|
||||||
if (img) {
|
if (img) {
|
||||||
if (tpl.azureWiki && tpl.imageMaxWidth > 0) {
|
if (tpl.azureWiki && tpl.imageMaxWidth > 0) {
|
||||||
@@ -113,6 +123,7 @@ function renderMarkdownGuide(ast, outDir, template = {}, {
|
|||||||
lines.push(``, '');
|
lines.push(``, '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const tb of afterImage) emitBlock(lines, tb, { alertStyle });
|
||||||
|
|
||||||
for (const block of rest) {
|
for (const block of rest) {
|
||||||
if (block.kind === 'text') {
|
if (block.kind === 'text') {
|
||||||
|
|||||||
+31
-8
@@ -203,10 +203,10 @@ function exportPdf(ast, outDir, template = {}) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute the vertical space a step will need: `head` is the title, the
|
* Compute the vertical space a step will need: `head` is the title, the
|
||||||
* accent rule, any before-description blocks, the description, and the
|
* accent rule, positioned text blocks, the description, and the image —
|
||||||
* image — kept together on one page — and `total` adds the remaining
|
* kept together on one page — and `total` adds the remaining blocks plus
|
||||||
* blocks plus the trailing gap, used to decide whether the whole step
|
* the trailing gap, used to decide whether the whole step fits on a
|
||||||
* fits on a fresh page.
|
* fresh page.
|
||||||
*/
|
*/
|
||||||
const measureStep = (step) => {
|
const measureStep = (step) => {
|
||||||
const headSize = step.depth > 0 ? 12 : 14;
|
const headSize = step.depth > 0 ? 12 : 14;
|
||||||
@@ -214,8 +214,18 @@ function exportPdf(ast, outDir, template = {}) {
|
|||||||
const titleLines = pdf.wrapText(titleText, headSize, usableW, 'F2');
|
const titleLines = pdf.wrapText(titleText, headSize, usableW, 'F2');
|
||||||
let head = Math.max(40, titleLines.length * headSize * 1.35 + 8);
|
let head = Math.max(40, titleLines.length * headSize * 1.35 + 8);
|
||||||
|
|
||||||
const { before, rest } = stepContentGroups(step);
|
const {
|
||||||
for (const tb of before) head += measureBlock(tb);
|
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;
|
if (step.descriptionHtml) head += layoutDescription(pdf, step.descriptionHtml, usableW, 10.5).height;
|
||||||
head += measureImage(step.stepId);
|
head += measureImage(step.stepId);
|
||||||
|
|
||||||
@@ -298,14 +308,26 @@ function exportPdf(ast, outDir, template = {}) {
|
|||||||
pdf.bookmark(`${step.number}. ${step.title || 'Untitled step'}`);
|
pdf.bookmark(`${step.number}. ${step.title || 'Untitled step'}`);
|
||||||
const tocTarget = tocTargets.get(step.stepId);
|
const tocTarget = tocTargets.get(step.stepId);
|
||||||
if (tocTarget) tocTarget.pageIndex = pdf.pages.length - 1;
|
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;
|
const headSize = step.depth > 0 ? 12 : 14;
|
||||||
writeLines(`${step.number}. ${step.title || 'Untitled step'}${step.skipped ? ' (skipped)' : ''}`, { size: headSize, font: 'F2' });
|
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] });
|
pdf.rect(M, y, usableW, 0.8, { fill: [225, 228, 232] });
|
||||||
y += 8;
|
y += 8;
|
||||||
|
|
||||||
const { before, rest } = stepContentGroups(step);
|
for (const tb of afterTitle) emitBlock(tb);
|
||||||
for (const tb of before) emitBlock(tb);
|
for (const tb of beforeDescription) emitBlock(tb);
|
||||||
if (step.descriptionHtml) writeDescription(step.descriptionHtml);
|
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);
|
const img = images.get(step.stepId);
|
||||||
if (img) {
|
if (img) {
|
||||||
@@ -317,6 +339,7 @@ function exportPdf(ast, outDir, template = {}) {
|
|||||||
pdf.image(img, M, y, w, h);
|
pdf.image(img, M, y, w, h);
|
||||||
y += h + 10;
|
y += h + 10;
|
||||||
}
|
}
|
||||||
|
for (const tb of afterImage) emitBlock(tb);
|
||||||
|
|
||||||
for (const block of rest) {
|
for (const block of rest) {
|
||||||
if (block.kind === 'text') {
|
if (block.kind === 'text') {
|
||||||
|
|||||||
+56
-22
@@ -9,8 +9,9 @@ const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups } = require('
|
|||||||
const { tocEntries, guideMetaLines, guideSummary } = require('./document-layout');
|
const { tocEntries, guideMetaLines, guideSummary } = require('./document-layout');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PPTX exporter: a title slide plus one 16:9 slide per step (title bar +
|
* PPTX exporter: a title slide plus one 16:9 slide per step, with
|
||||||
* screenshot + description). PresentationML written directly.
|
* positioned text blocks laid out around the step title, description, and
|
||||||
|
* screenshot. PresentationML written directly.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const DEFAULT_TEMPLATE = {
|
const DEFAULT_TEMPLATE = {
|
||||||
@@ -208,24 +209,63 @@ function exportPptx(ast, outDir, template = {}) {
|
|||||||
|
|
||||||
let mediaCounter = 0;
|
let mediaCounter = 0;
|
||||||
for (const step of ast.steps) {
|
for (const step of ast.steps) {
|
||||||
const { before, rest } = stepContentGroups(step);
|
const {
|
||||||
const beforeBlocks = before.filter((block) => block.kind === 'text');
|
beforeTitle,
|
||||||
const restBlocks = rest.filter((block) => block.kind === 'text');
|
afterTitle,
|
||||||
|
beforeDescription,
|
||||||
|
afterDescription,
|
||||||
|
beforeImage,
|
||||||
|
afterImage,
|
||||||
|
} = stepContentGroups(step);
|
||||||
let content = rectShape(0, 0, SLIDE_W, 18000, '2563EB');
|
let content = rectShape(0, 0, SLIDE_W, 18000, '2563EB');
|
||||||
content += textBox(457200, TITLE_Y, SLIDE_W - 914400, TITLE_H,
|
const beforeTitleReserve = beforeTitle.reduce((sum, tb) => sum + calloutHeight(tb) + CALL_OUT_GAP, 0);
|
||||||
para(`${step.number}. ${step.title || 'Untitled step'}`, { size: 2600, bold: true }));
|
const titleY = beforeTitle.length ? 220000 + beforeTitleReserve + 120000 : TITLE_Y;
|
||||||
content += rectShape(457200, TITLE_RULE_Y, 2400000, 12000, '2563EB');
|
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;
|
let y = beforeTitleY;
|
||||||
for (const tb of beforeBlocks) {
|
for (const tb of beforeTitle) {
|
||||||
const block = calloutXml(tb, 457200, y, SLIDE_W - 914400);
|
const block = calloutXml(tb, 457200, y, SLIDE_W - 914400);
|
||||||
content += block.xml;
|
content += block.xml;
|
||||||
y += block.height + CALL_OUT_GAP;
|
y += block.height + CALL_OUT_GAP;
|
||||||
}
|
}
|
||||||
|
|
||||||
const descReserve = step.descriptionText ? descriptionHeight(step.descriptionText) + 120000 : 0;
|
content += textBox(457200, titleY, SLIDE_W - 914400, TITLE_H,
|
||||||
const restReserve = restBlocks.reduce((sum, tb) => sum + calloutHeight(tb) + CALL_OUT_GAP, 0);
|
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 rels = [];
|
||||||
const media = [];
|
const media = [];
|
||||||
|
|
||||||
@@ -236,23 +276,17 @@ function exportPptx(ast, outDir, template = {}) {
|
|||||||
media.push({ name, data: encodePng(img) });
|
media.push({ name, data: encodePng(img) });
|
||||||
const relId = 2; // rId1 = layout, rId2 = image
|
const relId = 2; // rId1 = layout, rId2 = image
|
||||||
rels.push({ id: relId, name });
|
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 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;
|
let w = img.width * EMU_PER_PX, h = img.height * EMU_PER_PX;
|
||||||
const scale = Math.min(maxW / w, maxH / h, 1);
|
const scale = Math.min(maxW / w, maxH / h, 1);
|
||||||
w = Math.round(w * scale); h = Math.round(h * scale);
|
w = Math.round(w * scale); h = Math.round(h * scale);
|
||||||
content += picture(relId, Math.round((SLIDE_W - w) / 2), y, w, h);
|
content += picture(relId, Math.round((SLIDE_W - w) / 2), y, w, h);
|
||||||
y += h + 100000;
|
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);
|
const block = calloutXml(tb, 457200, y, SLIDE_W - 914400);
|
||||||
content += block.xml;
|
content += block.xml;
|
||||||
y += block.height + CALL_OUT_GAP;
|
y += block.height + CALL_OUT_GAP;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const { exportWikiJs } = require('../../exporters/wikijs');
|
|||||||
const { exportHtmlSimple, exportHtmlRich } = require('../../exporters/html');
|
const { exportHtmlSimple, exportHtmlRich } = require('../../exporters/html');
|
||||||
const { exportConfluence } = require('../../exporters/confluence');
|
const { exportConfluence } = require('../../exporters/confluence');
|
||||||
const { htmlToMarkdown } = require('../../exporters/htmlmd');
|
const { htmlToMarkdown } = require('../../exporters/htmlmd');
|
||||||
|
const { stepContentGroups } = require('../../exporters/common');
|
||||||
const { decodePng } = require('../../core/png');
|
const { decodePng } = require('../../core/png');
|
||||||
const { buildFixtureGuide } = require('./fixture-guide');
|
const { buildFixtureGuide } = require('./fixture-guide');
|
||||||
const { makeTmpDir, rmrf } = require('./helpers');
|
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>'));
|
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',
|
||||||
|
';
|
||||||
|
|
||||||
|
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) => {
|
test('Wiki.js export: TOC is included, wiki callouts render, images exist', (t) => {
|
||||||
const root = makeTmpDir('expwikijs');
|
const root = makeTmpDir('expwikijs');
|
||||||
t.after(() => rmrf(root));
|
t.after(() => rmrf(root));
|
||||||
|
|||||||
Reference in New Issue
Block a user