Mass guide editor changes #18
@@ -0,0 +1,125 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { decodeEntities } = require('./util');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse sanitized description HTML (see core/sanitize.js) into a flat list
|
||||||
|
* of blocks with inline formatting runs, for renderers (PDF) that need to
|
||||||
|
* preserve bold/italic/links/lists rather than flattening to plain text.
|
||||||
|
*
|
||||||
|
* Each block: { type, runs, indent, n? }
|
||||||
|
* type: 'p' | 'h1'..'h4' | 'blockquote' | 'li' | 'oli' | 'hr'
|
||||||
|
* runs: [{ text, bold, italic, code, href }] (absent for 'hr')
|
||||||
|
* indent: list/quote nesting depth (0 = top level)
|
||||||
|
* n: list item number, only for 'oli'
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TAG_RE = /<\s*(\/?)\s*([a-zA-Z][a-zA-Z0-9]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/g;
|
||||||
|
const HREF_RE = /href\s*=\s*"([^"]*)"|href\s*=\s*'([^']*)'/i;
|
||||||
|
|
||||||
|
function htmlToBlocks(html) {
|
||||||
|
const blocks = [];
|
||||||
|
let current = null;
|
||||||
|
const styleStack = [{}];
|
||||||
|
const context = []; // nesting of <ul>/<ol>/<blockquote>
|
||||||
|
|
||||||
|
const indentLevel = () => context.length;
|
||||||
|
const currentStyle = () => styleStack[styleStack.length - 1];
|
||||||
|
|
||||||
|
const flushBlock = () => {
|
||||||
|
if (!current) return;
|
||||||
|
if (current.type === 'hr' || current.runs.some((r) => r.text.trim() !== '')) blocks.push(current);
|
||||||
|
current = null;
|
||||||
|
};
|
||||||
|
const startBlock = (type, extra = {}) => {
|
||||||
|
flushBlock();
|
||||||
|
current = { type, runs: [], indent: indentLevel(), ...extra };
|
||||||
|
};
|
||||||
|
const pushText = (text) => {
|
||||||
|
if (!text) return;
|
||||||
|
if (!current) current = { type: 'p', runs: [], indent: indentLevel() };
|
||||||
|
current.runs.push({ text, ...currentStyle() });
|
||||||
|
};
|
||||||
|
|
||||||
|
let last = 0;
|
||||||
|
let m;
|
||||||
|
while ((m = TAG_RE.exec(html)) !== null) {
|
||||||
|
if (m.index > last) pushText(decodeEntities(html.slice(last, m.index)));
|
||||||
|
last = TAG_RE.lastIndex;
|
||||||
|
const closing = Boolean(m[1]);
|
||||||
|
const tag = m[2].toLowerCase();
|
||||||
|
const rawAttrs = m[3];
|
||||||
|
|
||||||
|
switch (tag) {
|
||||||
|
case 'p':
|
||||||
|
case 'div':
|
||||||
|
if (closing) flushBlock();
|
||||||
|
else startBlock('p');
|
||||||
|
break;
|
||||||
|
case 'h1':
|
||||||
|
case 'h2':
|
||||||
|
case 'h3':
|
||||||
|
case 'h4':
|
||||||
|
if (closing) flushBlock();
|
||||||
|
else startBlock(tag);
|
||||||
|
break;
|
||||||
|
case 'blockquote':
|
||||||
|
if (closing) { flushBlock(); context.pop(); }
|
||||||
|
else { context.push({ kind: 'blockquote' }); startBlock('blockquote'); }
|
||||||
|
break;
|
||||||
|
case 'ul':
|
||||||
|
case 'ol':
|
||||||
|
if (closing) context.pop();
|
||||||
|
else context.push({ kind: tag, counter: 0 });
|
||||||
|
break;
|
||||||
|
case 'li':
|
||||||
|
if (closing) flushBlock();
|
||||||
|
else {
|
||||||
|
const ctx = context[context.length - 1];
|
||||||
|
const depth = Math.max(0, indentLevel() - 1);
|
||||||
|
if (ctx && ctx.kind === 'ol') { ctx.counter += 1; startBlock('oli', { n: ctx.counter, indent: depth }); }
|
||||||
|
else startBlock('li', { indent: depth });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'br':
|
||||||
|
flushBlock();
|
||||||
|
break;
|
||||||
|
case 'hr':
|
||||||
|
flushBlock();
|
||||||
|
blocks.push({ type: 'hr' });
|
||||||
|
break;
|
||||||
|
case 'b':
|
||||||
|
case 'strong':
|
||||||
|
if (closing) styleStack.pop();
|
||||||
|
else styleStack.push({ ...currentStyle(), bold: true });
|
||||||
|
break;
|
||||||
|
case 'i':
|
||||||
|
case 'em':
|
||||||
|
if (closing) styleStack.pop();
|
||||||
|
else styleStack.push({ ...currentStyle(), italic: true });
|
||||||
|
break;
|
||||||
|
case 'code':
|
||||||
|
case 'pre':
|
||||||
|
if (closing) styleStack.pop();
|
||||||
|
else styleStack.push({ ...currentStyle(), code: true });
|
||||||
|
break;
|
||||||
|
case 'a':
|
||||||
|
if (closing) styleStack.pop();
|
||||||
|
else {
|
||||||
|
const hrefM = HREF_RE.exec(rawAttrs);
|
||||||
|
styleStack.push({ ...currentStyle(), href: hrefM ? (hrefM[1] ?? hrefM[2]) : undefined });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// Unrecognized/transparent allowed tags (table*, span, u, s, sub, sup):
|
||||||
|
// no block or style change.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pushText(decodeEntities(html.slice(last)));
|
||||||
|
flushBlock();
|
||||||
|
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { htmlToBlocks };
|
||||||
+28
-4
@@ -9,19 +9,41 @@ const zlib = require('node:zlib');
|
|||||||
* points; converted to PDF's bottom-left space internally.
|
* points; converted to PDF's bottom-left space internally.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const FONTS = { F1: 'Helvetica', F2: 'Helvetica-Bold', F3: 'Courier' };
|
const FONTS = { F1: 'Helvetica', F2: 'Helvetica-Bold', F3: 'Courier', F4: 'Helvetica-Oblique', F5: 'Helvetica-BoldOblique' };
|
||||||
// Approximate average glyph width factors (per 1pt font size) for wrapping.
|
// Approximate average glyph width factors (per 1pt font size) for wrapping.
|
||||||
const FONT_WIDTH_FACTOR = { F1: 0.51, F2: 0.55, F3: 0.6 };
|
const FONT_WIDTH_FACTOR = { F1: 0.51, F2: 0.55, F3: 0.6, F4: 0.51, F5: 0.55 };
|
||||||
|
|
||||||
function esc(text) {
|
function esc(text) {
|
||||||
return String(text).replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
return String(text).replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "Smart typography" characters commonly produced by rich-text editors and
|
||||||
|
// pasted content (e.g. from Word/Google Docs) that have a printable glyph in
|
||||||
|
// WinAnsiEncoding (cp1252) outside the Latin-1 range; map to that byte
|
||||||
|
// instead of falling back to "?".
|
||||||
|
const WINANSI_EXTRA = {
|
||||||
|
0x20ac: 0x80, // €
|
||||||
|
0x2026: 0x85, // … ellipsis
|
||||||
|
0x2030: 0x89, // ‰
|
||||||
|
0x2039: 0x8b, // ‹
|
||||||
|
0x203a: 0x9b, // ›
|
||||||
|
0x2018: 0x91, // ' left single quote
|
||||||
|
0x2019: 0x92, // ' right single quote
|
||||||
|
0x201c: 0x93, // " left double quote
|
||||||
|
0x201d: 0x94, // " right double quote
|
||||||
|
0x2022: 0x95, // • bullet
|
||||||
|
0x2013: 0x96, // – en dash
|
||||||
|
0x2014: 0x97, // — em dash
|
||||||
|
0x2122: 0x99, // ™
|
||||||
|
};
|
||||||
|
|
||||||
function toLatin1(text) {
|
function toLatin1(text) {
|
||||||
let out = '';
|
let out = '';
|
||||||
for (const ch of String(text)) {
|
for (const ch of String(text)) {
|
||||||
const code = ch.codePointAt(0);
|
const code = ch.codePointAt(0);
|
||||||
out += code <= 0xff ? ch : '?';
|
if (code <= 0xff) out += ch;
|
||||||
|
else if (WINANSI_EXTRA[code] !== undefined) out += String.fromCharCode(WINANSI_EXTRA[code]);
|
||||||
|
else out += '?';
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -73,8 +95,10 @@ class PdfBuilder {
|
|||||||
|
|
||||||
text(str, x, yTop, { size = 11, font = 'F1', color = [0, 0, 0] } = {}) {
|
text(str, x, yTop, { size = 11, font = 'F1', color = [0, 0, 0] } = {}) {
|
||||||
const y = this.pageHeight - yTop - size;
|
const y = this.pageHeight - yTop - size;
|
||||||
|
// Tabs have no glyph in WinAnsiEncoding and render as "?"; expand to spaces.
|
||||||
|
const clean = String(str).replace(/\t/g, ' ');
|
||||||
this.currentPage.ops.push(
|
this.currentPage.ops.push(
|
||||||
`BT /${font} ${size} Tf ${col(color)} rg 1 0 0 1 ${x.toFixed(2)} ${y.toFixed(2)} Tm (${esc(toLatin1(str))}) Tj ET`
|
`BT /${font} ${size} Tf ${col(color)} rg 1 0 0 1 ${x.toFixed(2)} ${y.toFixed(2)} Tm (${esc(toLatin1(clean))}) Tj ET`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-9
@@ -3,7 +3,7 @@
|
|||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const { sanitizeHtml } = require('./sanitize');
|
const { sanitizeHtml } = require('./sanitize');
|
||||||
const { htmlToText, deepClone } = require('./util');
|
const { htmlToText, linkifyMarkdownLinks, deepClone } = require('./util');
|
||||||
const { systemPlaceholders, resolveScopes, expandPlaceholders } = require('./placeholders');
|
const { systemPlaceholders, resolveScopes, expandPlaceholders } = require('./placeholders');
|
||||||
const { decodePng } = require('./png');
|
const { decodePng } = require('./png');
|
||||||
const { renderAnnotations, applyFocusedView } = require('./raster');
|
const { renderAnnotations, applyFocusedView } = require('./raster');
|
||||||
@@ -32,6 +32,10 @@ function buildRenderAst(store, guideId, { globals = {}, now = new Date(), maxSte
|
|||||||
system: systemPlaceholders(guide, { now, stepCount: includedIds.length }),
|
system: systemPlaceholders(guide, { now, stepCount: includedIds.length }),
|
||||||
});
|
});
|
||||||
const expand = (text) => expandPlaceholders(text, values);
|
const expand = (text) => expandPlaceholders(text, values);
|
||||||
|
// Description fields additionally turn literal "[text](url)" markdown
|
||||||
|
// link syntax (as inserted by the editor's Link button) into real <a>
|
||||||
|
// tags before sanitizing, so exporters render an actual link.
|
||||||
|
const expandDesc = (html) => linkifyMarkdownLinks(expand(html || ''));
|
||||||
|
|
||||||
const steps = [];
|
const steps = [];
|
||||||
const topCounter = { n: 0 };
|
const topCounter = { n: 0 };
|
||||||
@@ -66,15 +70,15 @@ function buildRenderAst(store, guideId, { globals = {}, now = new Date(), maxSte
|
|||||||
skipped: step.skipped,
|
skipped: step.skipped,
|
||||||
forceNewPage: Boolean(step.forceNewPage),
|
forceNewPage: Boolean(step.forceNewPage),
|
||||||
title: expand(step.title || ''),
|
title: expand(step.title || ''),
|
||||||
descriptionHtml: sanitizeHtml(expand(step.descriptionHtml || '')),
|
descriptionHtml: sanitizeHtml(expandDesc(step.descriptionHtml)),
|
||||||
descriptionText: htmlToText(expand(step.descriptionHtml || '')),
|
descriptionText: htmlToText(expandDesc(step.descriptionHtml)),
|
||||||
focusedView: step.focusedView,
|
focusedView: step.focusedView,
|
||||||
annotations: (step.annotations || []).map((a) => ({ ...a, text: expand(a.text || '') })),
|
annotations: (step.annotations || []).map((a) => ({ ...a, text: expand(a.text || '') })),
|
||||||
textBlocks: (step.textBlocks || []).map((tb) => ({
|
textBlocks: (step.textBlocks || []).map((tb) => ({
|
||||||
...tb,
|
...tb,
|
||||||
title: expand(tb.title || ''),
|
title: expand(tb.title || ''),
|
||||||
descriptionHtml: sanitizeHtml(expand(tb.descriptionHtml || '')),
|
descriptionHtml: sanitizeHtml(expandDesc(tb.descriptionHtml)),
|
||||||
descriptionText: htmlToText(expand(tb.descriptionHtml || '')),
|
descriptionText: htmlToText(expandDesc(tb.descriptionHtml)),
|
||||||
})),
|
})),
|
||||||
codeBlocks: (step.codeBlocks || []).map((cb) => ({ ...cb, code: blockText(cb) })),
|
codeBlocks: (step.codeBlocks || []).map((cb) => ({ ...cb, code: blockText(cb) })),
|
||||||
tableBlocks: (step.tableBlocks || []).map((tb) => ({
|
tableBlocks: (step.tableBlocks || []).map((tb) => ({
|
||||||
@@ -86,8 +90,8 @@ function buildRenderAst(store, guideId, { globals = {}, now = new Date(), maxSte
|
|||||||
return {
|
return {
|
||||||
...block,
|
...block,
|
||||||
title: expand(block.title || ''),
|
title: expand(block.title || ''),
|
||||||
descriptionHtml: sanitizeHtml(expand(block.descriptionHtml || '')),
|
descriptionHtml: sanitizeHtml(expandDesc(block.descriptionHtml)),
|
||||||
descriptionText: htmlToText(expand(block.descriptionHtml || '')),
|
descriptionText: htmlToText(expandDesc(block.descriptionHtml)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (block.kind === 'code') return { ...block };
|
if (block.kind === 'code') return { ...block };
|
||||||
@@ -116,8 +120,8 @@ function buildRenderAst(store, guideId, { globals = {}, now = new Date(), maxSte
|
|||||||
guide: {
|
guide: {
|
||||||
id: guide.guideId,
|
id: guide.guideId,
|
||||||
title: expand(guide.title),
|
title: expand(guide.title),
|
||||||
descriptionHtml: sanitizeHtml(expand(guide.descriptionHtml || '')),
|
descriptionHtml: sanitizeHtml(expandDesc(guide.descriptionHtml)),
|
||||||
descriptionText: htmlToText(expand(guide.descriptionHtml || '')),
|
descriptionText: htmlToText(expandDesc(guide.descriptionHtml)),
|
||||||
createdAt: guide.createdAt,
|
createdAt: guide.createdAt,
|
||||||
updatedAt: guide.updatedAt,
|
updatedAt: guide.updatedAt,
|
||||||
flags: guide.flags,
|
flags: guide.flags,
|
||||||
|
|||||||
@@ -95,6 +95,20 @@ function clamp(v, min, max) {
|
|||||||
return Math.min(max, Math.max(min, v));
|
return Math.min(max, Math.max(min, v));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Matches literal markdown-style links typed in the description editor,
|
||||||
|
// e.g. "[Settings](https://example.com)". Excludes < > so it never spans
|
||||||
|
// across an existing HTML tag boundary.
|
||||||
|
const MD_LINK_RE = /\[([^[\]<>]*)\]\(([^()<>]+)\)/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn literal "[text](url)" markdown link syntax (as inserted by the
|
||||||
|
* description editor's Link button) into real <a href> tags, so exporters
|
||||||
|
* that consume HTML render an actual link instead of the raw brackets.
|
||||||
|
*/
|
||||||
|
function linkifyMarkdownLinks(html) {
|
||||||
|
return String(html || '').replace(MD_LINK_RE, (m, label, href) => `<a href="${escapeHtml(href)}">${label}</a>`);
|
||||||
|
}
|
||||||
|
|
||||||
/** Filesystem-safe slug for export folder names like steps-<title>. */
|
/** Filesystem-safe slug for export folder names like steps-<title>. */
|
||||||
function slugify(text, fallback = 'untitled') {
|
function slugify(text, fallback = 'untitled') {
|
||||||
const slug = String(text || '')
|
const slug = String(text || '')
|
||||||
@@ -116,6 +130,7 @@ module.exports = {
|
|||||||
readJsonSync,
|
readJsonSync,
|
||||||
readJsonIfExists,
|
readJsonIfExists,
|
||||||
htmlToText,
|
htmlToText,
|
||||||
|
linkifyMarkdownLinks,
|
||||||
decodeEntities,
|
decodeEntities,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
escapeXml,
|
escapeXml,
|
||||||
|
|||||||
+132
-7
@@ -5,6 +5,89 @@ const path = require('node:path');
|
|||||||
const { PdfBuilder } = require('../core/pdf');
|
const { PdfBuilder } = require('../core/pdf');
|
||||||
const { guideSlug, renderAllImages, LEVEL_LABEL, stepBlocks, codeBlockText } = require('./common');
|
const { guideSlug, renderAllImages, LEVEL_LABEL, stepBlocks, codeBlockText } = require('./common');
|
||||||
const { htmlToText } = require('../core/util');
|
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
|
* PDF exporter: cover block, optional TOC, one section per step with the
|
||||||
@@ -51,6 +134,31 @@ function exportPdf(ast, outDir, template = {}) {
|
|||||||
y += fs_ * leading;
|
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 });
|
||||||
|
let x = textX;
|
||||||
|
for (const word of line) {
|
||||||
|
const c = word.href ? tpl.accentColor : (item.muted ? [100, 100, 100] : color);
|
||||||
|
pdf.text(word.text, x, y, { size: item.size, font: word.font, color: c });
|
||||||
|
x += pdf.textWidth(word.text, item.size, word.font);
|
||||||
|
}
|
||||||
|
y += item.lineHeight;
|
||||||
|
});
|
||||||
|
y += 4;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
pdf.addPage();
|
pdf.addPage();
|
||||||
|
|
||||||
@@ -60,7 +168,7 @@ function exportPdf(ast, outDir, template = {}) {
|
|||||||
y += 6;
|
y += 6;
|
||||||
writeLines(ast.guide.title, { size: 26, font: 'F2' });
|
writeLines(ast.guide.title, { size: 26, font: 'F2' });
|
||||||
y += 8;
|
y += 8;
|
||||||
if (ast.guide.descriptionText) writeLines(ast.guide.descriptionText, { size: 12, color: [70, 70, 70] });
|
if (ast.guide.descriptionHtml) writeDescription(ast.guide.descriptionHtml, { size: 12, color: [70, 70, 70] });
|
||||||
y += 14;
|
y += 14;
|
||||||
writeLines(`${ast.steps.length} steps — generated ${ast.generatedAt.slice(0, 10)}`, { size: 10, color: [120, 120, 120] });
|
writeLines(`${ast.steps.length} steps — generated ${ast.generatedAt.slice(0, 10)}`, { size: 10, color: [120, 120, 120] });
|
||||||
pdf.addPage();
|
pdf.addPage();
|
||||||
@@ -91,7 +199,7 @@ function exportPdf(ast, outDir, template = {}) {
|
|||||||
y += 8;
|
y += 8;
|
||||||
|
|
||||||
emitBlocks(step, 'before-description');
|
emitBlocks(step, 'before-description');
|
||||||
if (step.descriptionText) { writeLines(step.descriptionText); y += 4; }
|
if (step.descriptionHtml) writeDescription(step.descriptionHtml);
|
||||||
|
|
||||||
const img = images.get(step.stepId);
|
const img = images.get(step.stepId);
|
||||||
if (img) {
|
if (img) {
|
||||||
@@ -146,15 +254,32 @@ function exportPdf(ast, outDir, template = {}) {
|
|||||||
function emitBlocks(step, position) {
|
function emitBlocks(step, position) {
|
||||||
for (const tb of stepBlocks(step).filter((b) => b.kind === 'text' && b.position === 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 label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`;
|
||||||
const bodyLines = tb.descriptionText ? pdf.wrapText(tb.descriptionText, 9.5, usableW - 18) : [];
|
const { items, height: bodyH } = tb.descriptionHtml
|
||||||
const blockH = 16 + bodyLines.length * 13;
|
? layoutDescription(pdf, tb.descriptionHtml, usableW - 18, 9.5)
|
||||||
|
: { items: [], height: 0 };
|
||||||
|
const blockH = 16 + bodyH;
|
||||||
ensure(blockH + 4);
|
ensure(blockH + 4);
|
||||||
pdf.rect(M, y, 3, blockH, { fill: tpl.accentColor });
|
pdf.rect(M, y, 3, blockH, { fill: tpl.accentColor });
|
||||||
pdf.text(label, M + 10, y + 2, { size: 9.5, font: 'F2' });
|
pdf.text(label, M + 10, y + 2, { size: 9.5, font: 'F2' });
|
||||||
let by = y + 16;
|
let by = y + 16;
|
||||||
for (const line of bodyLines) {
|
for (const item of items) {
|
||||||
pdf.text(line, M + 10, by, { size: 9.5 });
|
if (item.kind === 'hr') {
|
||||||
by += 13;
|
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' });
|
||||||
|
let x = textX;
|
||||||
|
for (const word of line) {
|
||||||
|
const c = word.href ? tpl.accentColor : (item.muted ? [100, 100, 100] : [0, 0, 0]);
|
||||||
|
pdf.text(word.text, x, by, { size: item.size, font: word.font, color: c });
|
||||||
|
x += pdf.textWidth(word.text, item.size, word.font);
|
||||||
|
}
|
||||||
|
by += item.lineHeight;
|
||||||
|
});
|
||||||
|
by += 4;
|
||||||
}
|
}
|
||||||
y += blockH + 6;
|
y += blockH + 6;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user