Render rich text, links, and special characters correctly in PDF exports
Template tests / tests (push) Successful in 1m40s
Template tests / tests (pull_request) Successful in 1m39s

PDF exports now preserve bold/italic/links/lists/blockquotes from the
description editor (via a new HTML block/run parser) instead of flattening
to plain text. Literal "[text](url)" links inserted by the editor's Link
button are converted to real <a> tags before rendering. Tabs and common
"smart typography" characters (smart quotes, dashes, ellipsis, bullet) are
mapped to their WinAnsiEncoding glyphs instead of rendering as "?".

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
2026-06-13 21:35:55 -05:00
co-authored by Claude Sonnet 4.6
parent 420b32c0f8
commit 0ead121327
5 changed files with 313 additions and 20 deletions
+125
View File
@@ -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
View File
@@ -9,19 +9,41 @@ const zlib = require('node:zlib');
* 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.
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) {
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) {
let out = '';
for (const ch of String(text)) {
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;
}
@@ -73,8 +95,10 @@ class PdfBuilder {
text(str, x, yTop, { size = 11, font = 'F1', color = [0, 0, 0] } = {}) {
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(
`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
View File
@@ -3,7 +3,7 @@
const fs = require('node:fs');
const path = require('node:path');
const { sanitizeHtml } = require('./sanitize');
const { htmlToText, deepClone } = require('./util');
const { htmlToText, linkifyMarkdownLinks, deepClone } = require('./util');
const { systemPlaceholders, resolveScopes, expandPlaceholders } = require('./placeholders');
const { decodePng } = require('./png');
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 }),
});
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 topCounter = { n: 0 };
@@ -66,15 +70,15 @@ function buildRenderAst(store, guideId, { globals = {}, now = new Date(), maxSte
skipped: step.skipped,
forceNewPage: Boolean(step.forceNewPage),
title: expand(step.title || ''),
descriptionHtml: sanitizeHtml(expand(step.descriptionHtml || '')),
descriptionText: htmlToText(expand(step.descriptionHtml || '')),
descriptionHtml: sanitizeHtml(expandDesc(step.descriptionHtml)),
descriptionText: htmlToText(expandDesc(step.descriptionHtml)),
focusedView: step.focusedView,
annotations: (step.annotations || []).map((a) => ({ ...a, text: expand(a.text || '') })),
textBlocks: (step.textBlocks || []).map((tb) => ({
...tb,
title: expand(tb.title || ''),
descriptionHtml: sanitizeHtml(expand(tb.descriptionHtml || '')),
descriptionText: htmlToText(expand(tb.descriptionHtml || '')),
descriptionHtml: sanitizeHtml(expandDesc(tb.descriptionHtml)),
descriptionText: htmlToText(expandDesc(tb.descriptionHtml)),
})),
codeBlocks: (step.codeBlocks || []).map((cb) => ({ ...cb, code: blockText(cb) })),
tableBlocks: (step.tableBlocks || []).map((tb) => ({
@@ -86,8 +90,8 @@ function buildRenderAst(store, guideId, { globals = {}, now = new Date(), maxSte
return {
...block,
title: expand(block.title || ''),
descriptionHtml: sanitizeHtml(expand(block.descriptionHtml || '')),
descriptionText: htmlToText(expand(block.descriptionHtml || '')),
descriptionHtml: sanitizeHtml(expandDesc(block.descriptionHtml)),
descriptionText: htmlToText(expandDesc(block.descriptionHtml)),
};
}
if (block.kind === 'code') return { ...block };
@@ -116,8 +120,8 @@ function buildRenderAst(store, guideId, { globals = {}, now = new Date(), maxSte
guide: {
id: guide.guideId,
title: expand(guide.title),
descriptionHtml: sanitizeHtml(expand(guide.descriptionHtml || '')),
descriptionText: htmlToText(expand(guide.descriptionHtml || '')),
descriptionHtml: sanitizeHtml(expandDesc(guide.descriptionHtml)),
descriptionText: htmlToText(expandDesc(guide.descriptionHtml)),
createdAt: guide.createdAt,
updatedAt: guide.updatedAt,
flags: guide.flags,
+15
View File
@@ -95,6 +95,20 @@ function clamp(v, min, max) {
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>. */
function slugify(text, fallback = 'untitled') {
const slug = String(text || '')
@@ -116,6 +130,7 @@ module.exports = {
readJsonSync,
readJsonIfExists,
htmlToText,
linkifyMarkdownLinks,
decodeEntities,
escapeHtml,
escapeXml,