diff --git a/core/htmlblocks.js b/core/htmlblocks.js new file mode 100644 index 0000000..5da76f6 --- /dev/null +++ b/core/htmlblocks.js @@ -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
+ + 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 }; diff --git a/core/pdf.js b/core/pdf.js index efcca80..89bc6f2 100644 --- a/core/pdf.js +++ b/core/pdf.js @@ -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` ); } diff --git a/core/renderast.js b/core/renderast.js index a7cecef..773f583 100644 --- a/core/renderast.js +++ b/core/renderast.js @@ -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 + // 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, diff --git a/core/util.js b/core/util.js index f94ee79..8e4d992 100644 --- a/core/util.js +++ b/core/util.js @@ -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 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) => `${label}`); +} + /** Filesystem-safe slug for export folder names like steps-. */ function slugify(text, fallback = 'untitled') { const slug = String(text || '') @@ -116,6 +130,7 @@ module.exports = { readJsonSync, readJsonIfExists, htmlToText, + linkifyMarkdownLinks, decodeEntities, escapeHtml, escapeXml, diff --git a/exporters/pdf.js b/exporters/pdf.js index 950c10e..e5eb121 100644 --- a/exporters/pdf.js +++ b/exporters/pdf.js @@ -5,6 +5,89 @@ 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 @@ -51,6 +134,31 @@ function exportPdf(ast, outDir, template = {}) { 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(); @@ -60,7 +168,7 @@ function exportPdf(ast, outDir, template = {}) { y += 6; writeLines(ast.guide.title, { size: 26, font: 'F2' }); 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; writeLines(`${ast.steps.length} steps — generated ${ast.generatedAt.slice(0, 10)}`, { size: 10, color: [120, 120, 120] }); pdf.addPage(); @@ -91,7 +199,7 @@ function exportPdf(ast, outDir, template = {}) { y += 8; emitBlocks(step, 'before-description'); - if (step.descriptionText) { writeLines(step.descriptionText); y += 4; } + if (step.descriptionHtml) writeDescription(step.descriptionHtml); const img = images.get(step.stepId); if (img) { @@ -146,15 +254,32 @@ function exportPdf(ast, outDir, template = {}) { 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 bodyLines = tb.descriptionText ? pdf.wrapText(tb.descriptionText, 9.5, usableW - 18) : []; - const blockH = 16 + bodyLines.length * 13; + 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 line of bodyLines) { - pdf.text(line, M + 10, by, { size: 9.5 }); - by += 13; + 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' }); + 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; }