'use strict'; import {Parser} from 'htmlparser2'; import {PassThrough} from 'stream'; const PDFDocument = require('pdfkit'); interface InlineState { bold: boolean; italic: boolean; underline: boolean; strike: boolean; link?: string; fontSize?: number; align?: 'left' | 'center' | 'right' | 'justify'; mono?: boolean; } const parseAlign = (style: string | undefined): InlineState['align'] | undefined => { if (!style) return undefined; const m = /text-align\s*:\s*(left|center|right|justify)/i.exec(style); return m ? (m[1].toLowerCase() as InlineState['align']) : undefined; }; const HEADING_SIZES: Record = { h1: 24, h2: 20, h3: 16, h4: 14, h5: 12, h6: 11, }; // Tags whose text content must never appear in the rendered PDF (CSS, // scripts, document metadata). The walker maintains a depth counter so that // nested elements inside one of these are ignored too. const SKIP_TAGS = new Set(['head', 'style', 'script', 'title', 'meta', 'link', 'noscript']); const decodeDataUri = (src: string): Buffer | null => { const m = /^data:[^;,]+;base64,(.+)$/i.exec(src); if (!m) return null; try { return Buffer.from(m[1], 'base64'); } catch { return null; } }; export const htmlToPdfBuffer = (html: string): Promise => new Promise((resolve, reject) => { // compress:false keeps the content stream uncompressed. Pads are small // enough that the size cost is negligible, and it lets ops greppable PDFs // out of the box for accessibility / search-engine indexers that don't // FlateDecode. const doc = new PDFDocument({margin: 50, compress: false}); const stream = new PassThrough(); const chunks: Buffer[] = []; stream.on('data', (c: Buffer) => chunks.push(c)); stream.on('end', () => resolve(Buffer.concat(chunks))); stream.on('error', reject); doc.pipe(stream); const styleStack: InlineState[] = [{ bold: false, italic: false, underline: false, strike: false, }]; const listType: ('ul' | 'ol' | null)[] = []; const listIndex: number[] = []; let pendingNewline = false; let skipDepth = 0; const top = () => styleStack[styleStack.length - 1]; const applyFont = () => { const s = top(); let variant: string; if (s.mono) { variant = s.bold && s.italic ? 'Courier-BoldOblique' : s.bold ? 'Courier-Bold' : s.italic ? 'Courier-Oblique' : 'Courier'; } else { variant = s.bold && s.italic ? 'Helvetica-BoldOblique' : s.bold ? 'Helvetica-Bold' : s.italic ? 'Helvetica-Oblique' : 'Helvetica'; } doc.font(variant); doc.fontSize(s.fontSize || 11); }; // Track whether the current run started with an alignment override so // we apply `align` exactly once per pdfkit text() call (pdfkit uses the // align option of the first call in a continued run for the whole line). let runStartedAligned = false; const writeText = (raw: string) => { if (!raw) return; if (pendingNewline) { doc.moveDown(0.5); pendingNewline = false; } const s = top(); applyFont(); const opts: any = {continued: true}; if (s.underline) opts.underline = true; if (s.strike) opts.strike = true; if (s.link) opts.link = s.link; if (s.align && !runStartedAligned) { opts.align = s.align; runStartedAligned = true; } doc.text(raw, opts); }; // End the current `continued: true` text run. pdfkit's `text('', false)` // closes the run but does NOT advance the cursor — subsequent text would // overlay at the same y. Use `breakLine` whenever a true newline is // intended (br, end-of-block, list items). const flushLine = () => { doc.text('', {continued: false}); runStartedAligned = false; }; const breakLine = () => { flushLine(); doc.moveDown(1); }; const parser = new Parser({ onopentag(name, attribs) { if (SKIP_TAGS.has(name)) skipDepth += 1; if (skipDepth > 0) { styleStack.push({...top()}); return; } const cur = top(); const next: InlineState = {...cur}; switch (name) { case 'b': case 'strong': next.bold = true; break; case 'i': case 'em': next.italic = true; break; case 'u': next.underline = true; break; case 's': case 'strike': case 'del': next.strike = true; break; case 'a': next.link = attribs.href; next.underline = true; break; case 'code': case 'tt': case 'kbd': case 'samp': { next.mono = true; // ep_headings2 uses as a block- // styled "code" line, so read the alignment off the opening // tag too. parseAlign returns undefined when no text-align // is set, so this is a no-op for inline usage. const a = parseAlign(attribs.style); if (a) next.align = a; break; } case 'pre': { next.mono = true; const a = parseAlign(attribs.style); if (a) next.align = a; if (!pendingNewline) breakLine(); break; } case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6': { next.fontSize = HEADING_SIZES[name]; next.bold = true; const a = parseAlign(attribs.style); if (a) next.align = a; if (!pendingNewline) breakLine(); break; } case 'p': case 'div': { const a = parseAlign(attribs.style); if (a) next.align = a; if (!pendingNewline) breakLine(); break; } case 'ul': case 'ol': listType.push(name as 'ul' | 'ol'); listIndex.push(0); breakLine(); break; case 'li': { breakLine(); const t = listType[listType.length - 1] || 'ul'; if (t === 'ol') listIndex[listIndex.length - 1] += 1; const prefix = t === 'ul' ? '• ' : `${listIndex[listIndex.length - 1]}. `; const indent = ' '.repeat(Math.max(0, listType.length - 1)); applyFont(); doc.text(`${indent}${prefix}`, {continued: true}); break; } case 'br': breakLine(); break; case 'img': { const buf = decodeDataUri(attribs.src || ''); if (buf) { flushLine(); try { doc.image(buf, {fit: [400, 300]}); } catch { /* skip bad image */ } } break; } } styleStack.push(next); }, ontext(text) { if (skipDepth > 0) return; // Collapse consecutive whitespace to a single space, the way an // HTML renderer would. Without this, literal newlines and tabs in // pretty-printed source HTML show up as runs of " " in the PDF. const collapsed = text.replace(/[\s ]+/g, ' '); if (collapsed === ' ') return; // pure-whitespace runs are dropped writeText(collapsed); }, onclosetag(name) { if (skipDepth > 0) { if (SKIP_TAGS.has(name)) skipDepth -= 1; styleStack.pop(); if (styleStack.length === 0) { styleStack.push({bold: false, italic: false, underline: false, strike: false}); } return; } switch (name) { case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6': case 'p': case 'div': case 'pre': breakLine(); pendingNewline = true; break; case 'li': flushLine(); break; case 'ul': case 'ol': listType.pop(); listIndex.pop(); doc.moveDown(0.3); break; } styleStack.pop(); if (styleStack.length === 0) { styleStack.push({bold: false, italic: false, underline: false, strike: false}); } }, }, {decodeEntities: true, lowerCaseTags: true}); parser.write(html); parser.end(); flushLine(); doc.end(); });