'use strict'; const zlib = require('node:zlib'); /** * Minimal PDF 1.4 writer: pages, Helvetica/Helvetica-Bold/Courier text, * rects/lines, deflated content streams, RGB images as XObjects, and a * simple outline (bookmarks). Coordinates passed in are top-left based in * points; converted to PDF's bottom-left space internally. */ const FONTS = { F1: 'Helvetica', F2: 'Helvetica-Bold', F3: 'Courier' }; // Approximate average glyph width factors (per 1pt font size) for wrapping. const FONT_WIDTH_FACTOR = { F1: 0.51, F2: 0.55, F3: 0.6 }; function esc(text) { return String(text).replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)'); } function toLatin1(text) { let out = ''; for (const ch of String(text)) { const code = ch.codePointAt(0); out += code <= 0xff ? ch : '?'; } return out; } function col(c) { return `${(c[0] / 255).toFixed(3)} ${(c[1] / 255).toFixed(3)} ${(c[2] / 255).toFixed(3)}`; } class PdfBuilder { constructor({ pageWidth = 595.28, pageHeight = 841.89 } = {}) { this.pageWidth = pageWidth; this.pageHeight = pageHeight; this.pages = []; this.images = []; // { name, width, height, data (deflated RGB), smask? } this.imageCache = new Map(); this.bookmarks = []; // { title, pageIndex, y } } addPage() { this.pages.push({ ops: [] }); return this.pages.length - 1; } get currentPage() { if (!this.pages.length) this.addPage(); return this.pages[this.pages.length - 1]; } textWidth(text, size, font = 'F1') { return String(text).length * size * (FONT_WIDTH_FACTOR[font] || 0.51); } /** Greedy word wrap to maxWidth points. */ wrapText(text, size, maxWidth, font = 'F1') { const lines = []; for (const para of String(text).split('\n')) { const words = para.split(/\s+/).filter(Boolean); if (!words.length) { lines.push(''); continue; } let line = ''; for (const word of words) { const candidate = line ? `${line} ${word}` : word; if (this.textWidth(candidate, size, font) <= maxWidth || !line) line = candidate; else { lines.push(line); line = word; } } lines.push(line); } return lines; } text(str, x, yTop, { size = 11, font = 'F1', color = [0, 0, 0] } = {}) { const y = this.pageHeight - yTop - size; 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` ); } rect(x, yTop, w, h, { fill = null, stroke = null, lineWidth = 1 } = {}) { const y = this.pageHeight - yTop - h; const ops = []; if (fill) ops.push(`${col(fill)} rg`); if (stroke) ops.push(`${col(stroke)} RG ${lineWidth} w`); ops.push(`${x.toFixed(2)} ${y.toFixed(2)} ${w.toFixed(2)} ${h.toFixed(2)} re`); ops.push(fill && stroke ? 'B' : fill ? 'f' : 'S'); this.currentPage.ops.push(ops.join(' ')); } line(x0, y0t, x1, y1t, { color = [0, 0, 0], width = 1 } = {}) { const y0 = this.pageHeight - y0t, y1 = this.pageHeight - y1t; this.currentPage.ops.push( `${col(color)} RG ${width} w ${x0.toFixed(2)} ${y0.toFixed(2)} m ${x1.toFixed(2)} ${y1.toFixed(2)} l S` ); } /** Draw an RGBA raster image; alpha is dropped (composited upstream). */ image(img, x, yTop, w, h) { let name = this.imageCache.get(img); if (!name) { name = `Im${this.images.length + 1}`; const rgb = Buffer.alloc(img.width * img.height * 3); for (let i = 0, n = img.width * img.height; i < n; i++) { rgb[i * 3] = img.data[i * 4]; rgb[i * 3 + 1] = img.data[i * 4 + 1]; rgb[i * 3 + 2] = img.data[i * 4 + 2]; } this.images.push({ name, width: img.width, height: img.height, data: zlib.deflateSync(rgb) }); this.imageCache.set(img, name); } const y = this.pageHeight - yTop - h; this.currentPage.ops.push( `q ${w.toFixed(2)} 0 0 ${h.toFixed(2)} ${x.toFixed(2)} ${y.toFixed(2)} cm /${name} Do Q` ); return name; } bookmark(title, pageIndex = this.pages.length - 1) { this.bookmarks.push({ title: toLatin1(title), pageIndex }); } build() { if (!this.pages.length) this.addPage(); const objects = []; // 1-based; objects[i] = body string|Buffer after header const addObj = (body) => { objects.push(body); return objects.length; }; // Reserve ids: 1 catalog, 2 pages tree (filled later) addObj(null); // 1: catalog placeholder addObj(null); // 2: pages placeholder const fontIds = {}; for (const [res, base] of Object.entries(FONTS)) { fontIds[res] = addObj(`<< /Type /Font /Subtype /Type1 /BaseFont /${base} /Encoding /WinAnsiEncoding >>`); } const imageIds = {}; for (const img of this.images) { imageIds[img.name] = addObj({ dict: `<< /Type /XObject /Subtype /Image /Width ${img.width} /Height ${img.height} ` + `/ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /FlateDecode /Length ${img.data.length} >>`, stream: img.data, }); } const fontRes = Object.entries(fontIds).map(([r, id]) => `/${r} ${id} 0 R`).join(' '); const imgRes = this.images.map((img) => `/${img.name} ${imageIds[img.name]} 0 R`).join(' '); const resources = `<< /Font << ${fontRes} >> ${this.images.length ? `/XObject << ${imgRes} >>` : ''} >>`; const pageIds = []; for (const page of this.pages) { const content = zlib.deflateSync(Buffer.from(page.ops.join('\n'), 'latin1')); const contentId = addObj({ dict: `<< /Filter /FlateDecode /Length ${content.length} >>`, stream: content, }); pageIds.push(addObj( `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${this.pageWidth} ${this.pageHeight}] ` + `/Resources ${resources} /Contents ${contentId} 0 R >>` )); } let outlinesRef = ''; if (this.bookmarks.length) { const outlineRootId = objects.length + 1; const itemIds = this.bookmarks.map((_, i) => outlineRootId + 1 + i); addObj(`<< /Type /Outlines /First ${itemIds[0]} 0 R /Last ${itemIds[itemIds.length - 1]} 0 R /Count ${itemIds.length} >>`); this.bookmarks.forEach((bm, i) => { const parts = [ `/Title (${esc(bm.title)})`, `/Parent ${outlineRootId} 0 R`, `/Dest [${pageIds[bm.pageIndex]} 0 R /Fit]`, ]; if (i > 0) parts.push(`/Prev ${itemIds[i - 1]} 0 R`); if (i < itemIds.length - 1) parts.push(`/Next ${itemIds[i + 1]} 0 R`); addObj(`<< ${parts.join(' ')} >>`); }); outlinesRef = `/Outlines ${outlineRootId} 0 R /PageMode /UseOutlines`; } objects[0] = `<< /Type /Catalog /Pages 2 0 R ${outlinesRef} >>`; objects[1] = `<< /Type /Pages /Kids [${pageIds.map((id) => `${id} 0 R`).join(' ')}] /Count ${pageIds.length} >>`; // Serialize with xref const chunks = [Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n', 'latin1')]; let offset = chunks[0].length; const offsets = [0]; objects.forEach((body, idx) => { offsets.push(offset); let objBuf; if (body && typeof body === 'object' && body.stream) { objBuf = Buffer.concat([ Buffer.from(`${idx + 1} 0 obj\n${body.dict}\nstream\n`, 'latin1'), body.stream, Buffer.from('\nendstream\nendobj\n', 'latin1'), ]); } else { objBuf = Buffer.from(`${idx + 1} 0 obj\n${body}\nendobj\n`, 'latin1'); } chunks.push(objBuf); offset += objBuf.length; }); const xrefStart = offset; let xref = `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`; for (let i = 1; i <= objects.length; i++) { xref += `${String(offsets[i]).padStart(10, '0')} 00000 n \n`; } xref += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefStart}\n%%EOF\n`; chunks.push(Buffer.from(xref, 'latin1')); return Buffer.concat(chunks); } } module.exports = { PdfBuilder, FONTS };