writeDescription/emitBlocks rendered each word as a separately positioned Tj, advancing by our approximate average-glyph-width estimate — which is much wider than real space/character widths, producing "This is bold" style gaps. Render each line as one continuous text object instead (PdfBuilder.textRun), so consecutive Tj operators advance using the viewer's real font metrics. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
295 lines
10 KiB
JavaScript
295 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, stepBlocks, 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 };
|
|
|
|
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;
|
|
|
|
emitBlocks(step, 'before-description');
|
|
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 stepBlocks(step).filter((item) => item.kind !== 'text')) {
|
|
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;
|
|
}
|
|
}
|
|
|
|
emitBlocks(step, 'after-description');
|
|
emitBlocks(step, 'after-image');
|
|
y += 10;
|
|
}
|
|
|
|
function emitBlocks(step, position) {
|
|
for (const tb of stepBlocks(step).filter((b) => b.kind === 'text' && b.position === position)) {
|
|
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;
|
|
ensure(blockH + 4);
|
|
pdf.rect(M, y, 3, blockH, { fill: tpl.accentColor });
|
|
pdf.text(label, M + 10, y + 2, { size: 9.5, font: 'F2' });
|
|
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 };
|