import { readFileSync } from 'node:fs'; import { deflateSync } from 'node:zlib'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; import { parseBlocks } from './markdown.js'; const HELV = [278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278, 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556, 1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778, 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556, 333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556, 556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584]; const HELV_BOLD = [278, 333, 474, 556, 556, 889, 722, 238, 333, 333, 389, 584, 278, 333, 278, 278, 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611, 975, 722, 722, 722, 722, 667, 611, 778, 722, 278, 556, 722, 611, 833, 722, 778, 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 333, 278, 333, 584, 556, 333, 556, 611, 556, 611, 556, 333, 611, 611, 278, 278, 556, 278, 889, 611, 611, 611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584]; const FONTS = { F1: { base: 'Helvetica', widths: HELV }, F2: { base: 'Helvetica-Bold', widths: HELV_BOLD }, F3: { base: 'Helvetica-Oblique', widths: HELV }, F4: { base: 'Courier', widths: null }, }; const TTF_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), 'vendor', 'fonts', 'DejaVuSans.ttf'); const MISSING_GLYPH_CP = 0x25a1; function parseTtf(buf) { const tables = {}; const numTables = buf.readUInt16BE(4); for (let i = 0; i < numTables; i++) { const o = 12 + i * 16; tables[buf.toString('ascii', o, o + 4)] = { off: buf.readUInt32BE(o + 8), len: buf.readUInt32BE(o + 12) }; } for (const t of ['head', 'hhea', 'maxp', 'cmap', 'hmtx']) { if (!tables[t]) throw new Error(`font missing required table: ${t}`); } const head = tables.head.off; const unitsPerEm = buf.readUInt16BE(head + 18); const scale = (v) => Math.round(v * 1000 / unitsPerEm); const bbox = [buf.readInt16BE(head + 36), buf.readInt16BE(head + 38), buf.readInt16BE(head + 40), buf.readInt16BE(head + 42)].map(scale); const hhea = tables.hhea.off; const ascent = scale(buf.readInt16BE(hhea + 4)); const descent = scale(buf.readInt16BE(hhea + 6)); const numHMetrics = buf.readUInt16BE(hhea + 34); const numGlyphs = buf.readUInt16BE(tables.maxp.off + 4); let capHeight = ascent; if (tables['OS/2'] && buf.readUInt16BE(tables['OS/2'].off) >= 2) { capHeight = scale(buf.readInt16BE(tables['OS/2'].off + 88)); } const cmap = tables.cmap.off; let f4 = 0, f12 = 0; const subCount = buf.readUInt16BE(cmap + 2); for (let i = 0; i < subCount; i++) { const o = cmap + 4 + i * 8; const pid = buf.readUInt16BE(o), eid = buf.readUInt16BE(o + 2); const sub = cmap + buf.readUInt32BE(o + 4); const fmt = buf.readUInt16BE(sub); if (fmt === 4 && (pid === 3 && eid === 1 || pid === 0)) f4 = sub; if (fmt === 12 && (pid === 3 && eid === 10 || pid === 0)) f12 = sub; } if (!f4 && !f12) throw new Error('font has no usable Unicode cmap subtable'); const gidCache = new Map(); const gidFor = (cp) => { if (gidCache.has(cp)) return gidCache.get(cp); let gid = 0; if (f12) { let lo = 0, hi = buf.readUInt32BE(f12 + 12) - 1; while (lo <= hi) { const mid = (lo + hi) >> 1; const g = f12 + 16 + mid * 12; const start = buf.readUInt32BE(g), end = buf.readUInt32BE(g + 4); if (cp < start) hi = mid - 1; else if (cp > end) lo = mid + 1; else { gid = buf.readUInt32BE(g + 8) + (cp - start); break; } } } if (!gid && f4 && cp <= 0xffff) { const segCount = buf.readUInt16BE(f4 + 6) / 2; const ends = f4 + 14, starts = ends + segCount * 2 + 2; const deltas = starts + segCount * 2, ranges = deltas + segCount * 2; for (let s = 0; s < segCount; s++) { if (cp > buf.readUInt16BE(ends + s * 2)) continue; const start = buf.readUInt16BE(starts + s * 2); if (cp < start) break; const rangeOff = buf.readUInt16BE(ranges + s * 2); if (rangeOff === 0) { gid = (cp + buf.readInt16BE(deltas + s * 2)) & 0xffff; } else { const g = buf.readUInt16BE(ranges + s * 2 + rangeOff + (cp - start) * 2); gid = g === 0 ? 0 : (g + buf.readInt16BE(deltas + s * 2)) & 0xffff; } break; } } gidCache.set(cp, gid); return gid; }; const hmtx = tables.hmtx.off; const widthFor = (gid) => { const i = Math.min(gid, numHMetrics - 1); return scale(buf.readUInt16BE(hmtx + i * 4)); }; return { data: buf, unitsPerEm, bbox, ascent, descent, capHeight, numGlyphs, gidFor, widthFor }; } let UNI = null; let UNI_FAILED = false; export function unicodeFont() { if (UNI) return UNI; if (UNI_FAILED) return null; try { UNI = parseTtf(readFileSync(TTF_PATH)); } catch { UNI_FAILED = true; return null; } return UNI; } function needsUnicode(text) { for (const ch of String(text)) { const cp = ch.codePointAt(0); if (cp > 255 && !WINANSI_EXTRA[cp]) return !!unicodeFont(); } return false; } function encodeUnicodeHex(text, used) { const uni = unicodeFont(); let hex = ''; for (const ch of String(text)) { let cp = ch.codePointAt(0); let gid = uni.gidFor(cp); if (!gid) { cp = MISSING_GLYPH_CP; gid = uni.gidFor(cp); } if (!used.has(gid)) used.set(gid, cp); hex += gid.toString(16).padStart(4, '0'); } return hex; } const PAGE_W = 612; const PAGE_H = 792; const MARGIN = 72; const CONTENT_W = PAGE_W - MARGIN * 2; function charWidth(font, code, size) { const f = FONTS[font]; if (!f.widths) return 600 * size / 1000; const w = code >= 32 && code <= 126 ? f.widths[code - 32] : 556; return w * size / 1000; } function textWidth(font, text, size) { if (needsUnicode(text)) { const uni = unicodeFont(); let w = 0; for (const ch of String(text)) { const gid = uni.gidFor(ch.codePointAt(0)) || uni.gidFor(MISSING_GLYPH_CP); w += uni.widthFor(gid) * size / 1000; } return w; } let w = 0; for (let i = 0; i < text.length; i++) w += charWidth(font, text.charCodeAt(i), size); return w; } export function stripInline(text) { return String(text) .replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, '$2') .replace(/\[\[([^\]]+)\]\]/g, '$1') .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') .replace(/\[([^\]]+)\]\(([^)]*)\)/g, '$1') .replace(/\*\*([^*]+)\*\*/g, '$1') .replace(/~~([^~]+)~~/g, '$1') .replace(/\*([^*]+)\*/g, '$1') .replace(/`([^`]+)`/g, '$1') .replace(/_([^_]+)_/g, '$1'); } function wrap(font, text, size, maxWidth) { const words = String(text).split(/\s+/).filter((w) => w.length); const lines = []; let line = ''; for (const word of words) { const candidate = line ? line + ' ' + word : word; if (textWidth(font, candidate, size) <= maxWidth || !line) { line = candidate; while (textWidth(font, line, size) > maxWidth && line.length > 1) { let cut = line.length - 1; while (cut > 1 && textWidth(font, line.slice(0, cut), size) > maxWidth) cut--; lines.push(line.slice(0, cut)); line = line.slice(cut); } } else { lines.push(line); line = word; } } if (line) lines.push(line); return lines.length ? lines : ['']; } const WINANSI_EXTRA = { 0x20ac: 128, 0x2026: 133, 0x2018: 145, 0x2019: 146, 0x201c: 147, 0x201d: 148, 0x2022: 149, 0x2013: 150, 0x2014: 151, 0x2122: 153, }; function escapePdfText(text) { let out = ''; for (const ch of String(text)) { const code = ch.codePointAt(0); if (ch === '\\' || ch === '(' || ch === ')') out += '\\' + ch; else if (code >= 32 && code <= 126) out += ch; else if (WINANSI_EXTRA[code]) out += '\\' + WINANSI_EXTRA[code].toString(8).padStart(3, '0'); else if (code <= 255) out += '\\' + code.toString(8).padStart(3, '0'); else out += '?'; } return out; } class PdfBuilder { constructor() { this.pages = []; this.used = new Map(); this.newPage(); } tj(str, font, size, x, y, rg) { const pos = `1 0 0 1 ${x.toFixed(1)} ${y.toFixed(1)} Tm`; if (needsUnicode(str)) { return `BT ${rg}/FU ${size} Tf ${pos} <${encodeUnicodeHex(str, this.used)}> Tj ET`; } return `BT ${rg}/${font} ${size} Tf ${pos} (${escapePdfText(str)}) Tj ET`; } newPage() { this.ops = []; this.pages.push(this.ops); this.y = PAGE_H - MARGIN; } ensure(height) { if (this.y - height < MARGIN) this.newPage(); } text(str, { font = 'F1', size = 11, x = MARGIN, color = null, lineGap = 1.35 } = {}) { const lh = size * lineGap; this.ensure(lh); this.y -= lh; const rg = color ? `${color.join(' ')} rg ` : '0 0 0 rg '; this.ops.push(this.tj(str, font, size, x, this.y, rg)); } paragraph(str, { font = 'F1', size = 11, indent = 0, color = null, gapAfter = 6 } = {}) { const lines = wrap(font, str, size, CONTENT_W - indent); for (const line of lines) this.text(line, { font, size, x: MARGIN + indent, color }); this.y -= gapAfter; } rule() { this.ensure(14); this.y -= 10; this.ops.push(`0.8 0.8 0.8 RG 0.7 w ${MARGIN} ${this.y.toFixed(1)} m ${(PAGE_W - MARGIN).toFixed(1)} ${this.y.toFixed(1)} l S`); this.y -= 6; } space(h) { this.y -= h; } } export function markdownToPdf(markdown, { title = 'Document', subtitle = '' } = {}) { const b = new PdfBuilder(); b.paragraph(title, { font: 'F2', size: 20, gapAfter: 2 }); if (subtitle) b.paragraph(subtitle, { size: 9.5, color: [0.45, 0.45, 0.5], gapAfter: 2 }); b.rule(); b.space(6); const blocks = parseBlocks(markdown); for (const block of blocks) { switch (block.type) { case 'heading': { const sizes = { 1: 17, 2: 14.5, 3: 12.5 }; b.space(8); b.paragraph(stripInline(block.text), { font: 'F2', size: sizes[block.level] ?? 11.5, gapAfter: 3 }); break; } case 'paragraph': b.paragraph(stripInline(block.text.replace(/\n/g, ' ')), {}); break; case 'code': { b.space(3); for (const line of block.text.split('\n')) { for (const wrapped of wrap('F4', line || ' ', 9, CONTENT_W - 12)) { b.text(wrapped, { font: 'F4', size: 9, x: MARGIN + 12, color: [0.2, 0.2, 0.3], lineGap: 1.3 }); } } b.space(8); break; } case 'quote': for (const line of block.text.split('\n')) { b.paragraph(stripInline(line), { font: 'F3', size: 11, indent: 16, color: [0.35, 0.35, 0.4], gapAfter: 2 }); } b.space(5); break; case 'hr': b.rule(); break; case 'html': if (/class="pagebreak"/.test(block.text)) b.newPage(); break; case 'mermaid': { b.space(3); for (const line of block.text.split('\n')) { for (const wrapped of wrap('F4', line || ' ', 9, CONTENT_W - 12)) { b.text(wrapped, { font: 'F4', size: 9, x: MARGIN + 12, color: [0.2, 0.2, 0.3], lineGap: 1.3 }); } } b.space(8); break; } case 'list': { let counter = 0; for (const item of block.items) { counter = item.depth === 0 && item.ordered ? counter + 1 : counter; const bullet = item.checked != null ? (item.checked ? '[x]' : '[ ]') : item.ordered ? `${counter}.` : '•'; b.paragraph(`${bullet} ${stripInline(item.text)}`, { indent: 14 + item.depth * 16, gapAfter: 2 }); } b.space(5); break; } case 'table': { const all = [block.header, ...block.rows]; const colCount = Math.max(...all.map((r) => r.length)); const widths = []; for (let c = 0; c < colCount; c++) { widths[c] = Math.max(...all.map((r) => textWidth('F1', stripInline(r[c] ?? ''), 9.5))) + 12; } const scale = Math.min(1, CONTENT_W / widths.reduce((a, x) => a + x, 0)); all.forEach((row, ri) => { b.ensure(14); b.y -= 13; let x = MARGIN; row.forEach((cell, c) => { const font = ri === 0 ? 'F2' : 'F1'; let text = stripInline(cell ?? ''); const maxW = widths[c] * scale - 6; while (text.length > 1 && textWidth(font, text, 9.5) > maxW) text = text.slice(0, -1); b.ops.push(b.tj(text, font, 9.5, x, b.y, '0 0 0 rg ')); x += widths[c] * scale; }); if (ri === 0) { b.y -= 4; b.ops.push(`0.75 0.75 0.75 RG 0.7 w ${MARGIN} ${b.y.toFixed(1)} m ${(PAGE_W - MARGIN).toFixed(1)} ${b.y.toFixed(1)} l S`); } }); b.space(10); break; } } } return assemble(b.pages, b.used); } function toUnicodeCMap(used) { const pairs = [...used.entries()].sort((a, b) => a[0] - b[0]); const chunks = []; for (let i = 0; i < pairs.length; i += 100) { const slice = pairs.slice(i, i + 100); const rows = slice.map(([gid, cp]) => { const s = String.fromCodePoint(cp); let units = ''; for (let j = 0; j < s.length; j++) units += s.charCodeAt(j).toString(16).padStart(4, '0'); return `<${gid.toString(16).padStart(4, '0')}> <${units}>`; }); chunks.push(`${slice.length} beginbfchar\n${rows.join('\n')}\nendbfchar`); } return [ '/CIDInit /ProcSet findresource begin', '12 dict begin', 'begincmap', '/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def', '/CMapName /Adobe-Identity-UCS def', '/CMapType 2 def', '1 begincodespacerange', '<0000> ', 'endcodespacerange', chunks.join('\n'), 'endcmap', 'CMapName currentdict /CMap defineresource pop', 'end', 'end', ].join('\n'); } function assemble(pages, used = new Map()) { const objects = []; const addObj = (body) => objects.push(body) && objects.length; const fontIds = {}; for (const [key, font] of Object.entries(FONTS)) { fontIds[key] = addObj(`<< /Type /Font /Subtype /Type1 /BaseFont /${font.base} /Encoding /WinAnsiEncoding >>`); } let fontRes = Object.entries(fontIds).map(([k, id]) => `/${k} ${id} 0 R`).join(' '); if (used.size) { const uni = unicodeFont(); const fontData = deflateSync(uni.data).toString('latin1'); const fileId = addObj(`<< /Length ${fontData.length} /Filter /FlateDecode /Length1 ${uni.data.length} >>\nstream\n${fontData}\nendstream`); const descId = addObj(`<< /Type /FontDescriptor /FontName /DejaVuSans /Flags 32 /FontBBox [${uni.bbox.join(' ')}] /ItalicAngle 0 /Ascent ${uni.ascent} /Descent ${uni.descent} /CapHeight ${uni.capHeight} /StemV 80 /FontFile2 ${fileId} 0 R >>`); const wArr = [...used.keys()].sort((a, b) => a - b).map((gid) => `${gid} [${uni.widthFor(gid)}]`).join(' '); const cidId = addObj(`<< /Type /Font /Subtype /CIDFontType2 /BaseFont /DejaVuSans /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /FontDescriptor ${descId} 0 R /DW 600 /W [${wArr}] /CIDToGIDMap /Identity >>`); const cmapStream = toUnicodeCMap(used); const tuId = addObj(`<< /Length ${Buffer.byteLength(cmapStream, 'latin1')} >>\nstream\n${cmapStream}\nendstream`); const type0Id = addObj(`<< /Type /Font /Subtype /Type0 /BaseFont /DejaVuSans /Encoding /Identity-H /DescendantFonts [${cidId} 0 R] /ToUnicode ${tuId} 0 R >>`); fontRes += ` /FU ${type0Id} 0 R`; } const pageIds = []; const pagesId = objects.length + pages.length * 2 + 1; for (const ops of pages) { const stream = ops.join('\n'); const contentId = addObj(`<< /Length ${Buffer.byteLength(stream, 'latin1')} >>\nstream\n${stream}\nendstream`); const pageId = addObj(`<< /Type /Page /Parent ${pagesId} 0 R /MediaBox [0 0 ${PAGE_W} ${PAGE_H}] /Resources << /Font << ${fontRes} >> >> /Contents ${contentId} 0 R >>`); pageIds.push(pageId); } const actualPagesId = addObj(`<< /Type /Pages /Kids [${pageIds.map((id) => `${id} 0 R`).join(' ')}] /Count ${pageIds.length} >>`); if (actualPagesId !== pagesId) throw new Error('PDF object id accounting error'); const catalogId = addObj(`<< /Type /Catalog /Pages ${pagesId} 0 R >>`); let out = '%PDF-1.4\n%âãÏÓ\n'; const offsets = [0]; objects.forEach((body, idx) => { offsets.push(Buffer.byteLength(out, 'latin1')); out += `${idx + 1} 0 obj\n${body}\nendobj\n`; }); const xrefStart = Buffer.byteLength(out, 'latin1'); out += `xref\n0 ${objects.length + 1}\n`; out += '0000000000 65535 f \n'; for (let i = 1; i <= objects.length; i++) { out += String(offsets[i]).padStart(10, '0') + ' 00000 n \n'; } out += `trailer\n<< /Size ${objects.length + 1} /Root ${catalogId} 0 R >>\nstartxref\n${xrefStart}\n%%EOF\n`; return Buffer.from(out, 'latin1'); }