Files
StepForge/exporters/pdf.js
T
Tyler 5d4925dee4
Template tests / tests (push) Successful in 1m42s
Template tests / tests (pull_request) Successful in 1m41s
Fix export block ordering, stale per-step UI, and cross-step block loss
Exporters now interleave text/code/table blocks in the same order they
appear in the editor's Blocks panel (via a shared stepContentGroups
helper) instead of grouping by kind, so exported docs match the guide
editor's ordering.

selectStep() now also refreshes the Focused View controls and Blocks
panel (previously only done by renderAll), so switching steps no longer
leaves the previous step's blocks/focused-view sliders on screen. It
also flushes any pending edits on the outgoing step before switching, so
a later guide-wide reload (e.g. applying an annotation style to the
whole guide) can't discard unsaved text-block edits on other steps.
2026-06-14 09:11:00 -05:00

305 lines
10 KiB
JavaScript

'use strict';
const fs = require('node:fs');
const path = require('node:path');
const { PdfBuilder } = require('../core/pdf');
const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups, codeBlockText } = require('./common');
const { htmlToText } = require('../core/util');
const { htmlToBlocks } = require('../core/htmlblocks');
const LIST_INDENT = 14;
const QUOTE_INDENT = 10;
const HEADING_BUMP = { h1: 2.5, h2: 2, h3: 1.5, h4: 1 };
// Callout styling per text-block level, matching the colors used in the
// HTML/editor UI so a "Tip" looks distinct from a "Warning" at a glance.
const LEVEL_STYLE = {
info: { accent: [59, 130, 246], tint: [239, 246, 255] }, // blue — Note
success: { accent: [16, 185, 129], tint: [236, 253, 245] }, // green — Tip
warn: { accent: [245, 158, 11], tint: [255, 251, 235] }, // amber — Warning
error: { accent: [239, 68, 68], tint: [254, 242, 242] }, // red — Important
};
function fontForRun(run) {
if (run.code) return 'F3';
if (run.bold && run.italic) return 'F5';
if (run.bold) return 'F2';
if (run.italic) return 'F4';
return 'F1';
}
/** Split formatted runs into words (font/link tagged) and greedily wrap to maxWidth. */
function wrapRuns(pdf, runs, size, maxWidth) {
const words = [];
for (const run of runs) {
const font = fontForRun(run);
for (const part of run.text.split(/(\s+)/)) {
if (part === '') continue;
words.push({ text: part, font, href: run.href });
}
}
const lines = [];
let line = [];
let w = 0;
for (const word of words) {
const isSpace = /^\s+$/.test(word.text);
const ww = pdf.textWidth(word.text, size, word.font);
if (!isSpace && line.length && w + ww > maxWidth) {
lines.push(line);
line = [];
w = 0;
}
if (isSpace && !line.length) continue;
line.push(word);
w += ww;
}
if (line.length) lines.push(line);
return lines;
}
/**
* Lay out description HTML into render-ready items, preserving bold,
* italic, links, lists, blockquotes and headings.
* Returns { items, height }; items: { kind: 'hr', width } | { kind: 'text',
* lines, size, lineHeight, indent, prefix, muted }.
*/
function layoutDescription(pdf, html, maxWidth, baseSize) {
const items = [];
let height = 0;
for (const block of htmlToBlocks(html || '')) {
if (block.type === 'hr') {
items.push({ kind: 'hr', width: maxWidth });
height += 12;
continue;
}
let size = baseSize;
let runs = block.runs;
let indent = (block.indent || 0) * LIST_INDENT;
let prefix = null;
let muted = false;
if (HEADING_BUMP[block.type]) {
size = baseSize + HEADING_BUMP[block.type];
runs = runs.map((r) => ({ ...r, bold: true }));
} else if (block.type === 'li') {
prefix = '•';
indent += LIST_INDENT;
} else if (block.type === 'oli') {
prefix = `${block.n}.`;
indent += LIST_INDENT;
} else if (block.type === 'blockquote') {
indent += QUOTE_INDENT;
muted = true;
}
const lines = wrapRuns(pdf, runs, size, maxWidth - indent);
const lineHeight = size * 1.35;
items.push({ kind: 'text', lines, size, lineHeight, indent, prefix, muted });
height += lines.length * lineHeight + 4;
}
return { items, height };
}
/**
* PDF exporter: cover block, optional TOC, one section per step with the
* annotated screenshot, text blocks, code blocks, and tables. Generated
* natively from the Render AST (see build/agent_audit.md for the fallback
* rationale). Bookmarks navigate to each step.
*/
const PAGE_SIZES = {
a4: { width: 595.28, height: 841.89 },
letter: { width: 612, height: 792 },
};
const DEFAULT_TEMPLATE = {
pageSize: 'a4',
margin: 48,
includeCover: true,
includeToc: true,
includeImages: true,
imageMaxHeightRatio: 0.55, // of usable page height
accentColor: [37, 99, 235],
};
function exportPdf(ast, outDir, template = {}) {
const tpl = { ...DEFAULT_TEMPLATE, ...template };
const size = PAGE_SIZES[tpl.pageSize] || PAGE_SIZES.a4;
const pdf = new PdfBuilder({ pageWidth: size.width, pageHeight: size.height });
const M = tpl.margin;
const usableW = size.width - 2 * M;
const usableH = size.height - 2 * M;
const images = tpl.includeImages ? renderAllImages(ast) : new Map();
let y = M;
const ensure = (needed) => {
if (y + needed > size.height - M) {
pdf.addPage();
y = M;
}
};
const writeLines = (text, { size: fs_ = 10.5, font = 'F1', color = [0, 0, 0], leading = 1.35, indent = 0 } = {}) => {
for (const line of pdf.wrapText(text, fs_, usableW - indent, font)) {
ensure(fs_ * leading);
pdf.text(line, M + indent, y, { size: fs_, font, color });
y += fs_ * leading;
}
};
/** Render laid-out description items, preserving bold/italic/links/lists. */
const writeDescription = (html, { size: baseSize = 10.5, color = [0, 0, 0], indent: indentBase = 0 } = {}) => {
const { items } = layoutDescription(pdf, html, usableW - indentBase, baseSize);
for (const item of items) {
if (item.kind === 'hr') {
ensure(12);
pdf.rect(M + indentBase, y + 5, item.width, 0.8, { fill: [225, 228, 232] });
y += 12;
continue;
}
item.lines.forEach((line, idx) => {
ensure(item.lineHeight);
const textX = M + indentBase + item.indent;
if (idx === 0 && item.prefix) pdf.text(item.prefix, textX - LIST_INDENT, y, { size: item.size, font: 'F1', color });
const parts = line.map((word) => ({
text: word.text,
font: word.font,
color: word.href ? tpl.accentColor : (item.muted ? [100, 100, 100] : color),
}));
pdf.textRun(parts, textX, y, item.size);
y += item.lineHeight;
});
y += 4;
}
};
pdf.addPage();
if (tpl.includeCover) {
y = M + usableH * 0.18;
pdf.rect(M, y - 18, usableW, 3, { fill: tpl.accentColor });
y += 6;
writeLines(ast.guide.title, { size: 26, font: 'F2' });
y += 8;
if (ast.guide.descriptionHtml) writeDescription(ast.guide.descriptionHtml, { size: 12, color: [70, 70, 70] });
y += 14;
writeLines(`${ast.steps.length} steps — generated ${ast.generatedAt.slice(0, 10)}`, { size: 10, color: [120, 120, 120] });
pdf.addPage();
y = M;
}
if (tpl.includeToc && ast.steps.length > 1) {
writeLines('Contents', { size: 16, font: 'F2' });
y += 4;
for (const step of ast.steps) {
writeLines(`${step.number}. ${step.title || 'Untitled step'}`, {
size: 10.5, indent: 14 * step.depth,
});
}
pdf.addPage();
y = M;
}
let first = true;
for (const step of ast.steps) {
if (step.forceNewPage && !first) { pdf.addPage(); y = M; }
first = false;
ensure(40);
pdf.bookmark(`${step.number}. ${step.title || 'Untitled step'}`);
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);
if (step.descriptionHtml) writeDescription(step.descriptionHtml);
const img = images.get(step.stepId);
if (img) {
const maxH = usableH * tpl.imageMaxHeightRatio;
let w = usableW;
let h = (img.height / img.width) * w;
if (h > maxH) { h = maxH; w = (img.width / img.height) * h; }
ensure(h + 6);
pdf.image(img, M, y, w, h);
y += h + 10;
}
for (const block of rest) {
if (block.kind === 'text') {
emitBlock(block);
} else if (block.kind === 'code') {
const lines = String(codeBlockText(block) || '').split('\n');
const lineH = 9 * 1.3;
ensure(Math.min(lines.length, 4) * lineH + 12);
const boxH = lines.length * lineH + 10;
pdf.rect(M, y, usableW, Math.min(boxH, size.height - M - y), { fill: [243, 244, 246] });
y += 6;
for (const line of lines) {
ensure(lineH);
pdf.text(line.slice(0, 95), M + 8, y, { size: 9, font: 'F3', color: [31, 41, 55] });
y += lineH;
}
y += 10;
} else if (block.kind === 'table') {
if (!block.rows || !block.rows.length) continue;
const cols = Math.max(...block.rows.map((r) => r.length));
const colW = usableW / cols;
for (let r = 0; r < block.rows.length; r++) {
const rowH = 16;
ensure(rowH + 2);
if (r === 0) pdf.rect(M, y, usableW, rowH, { fill: [238, 240, 244] });
pdf.rect(M, y, usableW, rowH, { stroke: [200, 204, 210], lineWidth: 0.6 });
for (let c = 0; c < cols; c++) {
pdf.text(String(block.rows[r][c] ?? '').slice(0, Math.floor(colW / 5)), M + c * colW + 4, y + 3, {
size: 9, font: r === 0 ? 'F2' : 'F1',
});
}
y += rowH;
}
y += 8;
}
}
y += 10;
}
function emitBlock(tb) {
const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`;
const { items, height: bodyH } = tb.descriptionHtml
? layoutDescription(pdf, tb.descriptionHtml, usableW - 18, 9.5)
: { items: [], height: 0 };
const blockH = 16 + bodyH;
const style = LEVEL_STYLE[tb.level] || LEVEL_STYLE.info;
ensure(blockH + 4);
pdf.rect(M, y, usableW, blockH, { fill: style.tint });
pdf.rect(M, y, 3, blockH, { fill: style.accent });
pdf.text(label, M + 10, y + 2, { size: 9.5, font: 'F2', color: style.accent });
let by = y + 16;
for (const item of items) {
if (item.kind === 'hr') {
pdf.rect(M + 10, by + 5, item.width, 0.8, { fill: [225, 228, 232] });
by += 12;
continue;
}
item.lines.forEach((line, idx) => {
const textX = M + 10 + item.indent;
if (idx === 0 && item.prefix) pdf.text(item.prefix, textX - LIST_INDENT, by, { size: item.size, font: 'F1' });
const parts = line.map((word) => ({
text: word.text,
font: word.font,
color: word.href ? tpl.accentColor : (item.muted ? [100, 100, 100] : [0, 0, 0]),
}));
pdf.textRun(parts, textX, by, item.size);
by += item.lineHeight;
});
by += 4;
}
y += blockH + 6;
}
fs.mkdirSync(outDir, { recursive: true });
const file = path.join(outDir, `${guideSlug(ast)}.pdf`);
fs.writeFileSync(file, pdf.build());
return { file, imageCount: images.size, pageCount: pdf.pages.length };
}
module.exports = { exportPdf, DEFAULT_TEMPLATE };