(blocks: PdfBlock[])
| 72 | * with `Buffer.from(pdf, 'latin1')`. PURE + deterministic (no clock, no randomness). |
| 73 | */ |
| 74 | export function buildPdf(blocks: PdfBlock[]): string { |
| 75 | // 1. Lay blocks out into pages of positioned elements (text lines + bar-chart rows). |
| 76 | interface Line { kind: 'text'; x: number; y: number; size: number; font: 'F1' | 'F2' | 'F3'; text: string } |
| 77 | interface BarsRow { kind: 'bars'; x: number; y: number; values: number[]; label: string; size: number } |
| 78 | type El = Line | BarsRow; |
| 79 | const BAR_H = 36; const BAR_W = 14; const BAR_GAP = 4; |
| 80 | const pages: El[][] = [[]]; |
| 81 | let y = PAGE_H - MARGIN; |
| 82 | for (const b of blocks) { |
| 83 | const size = b.size ?? 11; |
| 84 | const x = MARGIN + (b.indent ?? 0); |
| 85 | y -= b.spaceBefore ?? 0; |
| 86 | if (b.bars) { |
| 87 | if (y < MARGIN + BAR_H + size) { pages.push([]); y = PAGE_H - MARGIN; } |
| 88 | y -= BAR_H + Math.round(size * 0.6); |
| 89 | pages[pages.length - 1]!.push({ kind: 'bars', x, y, values: b.bars, label: pdfSanitize(b.text), size }); |
| 90 | continue; |
| 91 | } |
| 92 | const gap = Math.round(size * 1.45); |
| 93 | const font = b.mono ? 'F3' : b.bold ? 'F2' : 'F1'; |
| 94 | for (const lineText of wrap(pdfSanitize(b.text), size, !!b.mono, PAGE_W - x - MARGIN)) { |
| 95 | if (y < MARGIN + size) { pages.push([]); y = PAGE_H - MARGIN; } |
| 96 | y -= gap; |
| 97 | pages[pages.length - 1]!.push({ kind: 'text', x, y, size, font, text: lineText }); |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | // 2. Emit objects, tracking byte offsets for the xref table. |
| 102 | // Layout: 1 Catalog · 2 Pages · 3 F1 · 4 F2 · 5 F3 · then per page: Page, Contents. |
| 103 | const objects: string[] = []; |
| 104 | const firstPageObj = 6; |
| 105 | const kids = pages.map((_, i) => `${firstPageObj + i * 2} 0 R`).join(' '); |
| 106 | objects.push(`1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n`); |
| 107 | objects.push(`2 0 obj\n<< /Type /Pages /Kids [${kids}] /Count ${pages.length} >>\nendobj\n`); |
| 108 | objects.push(`3 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n`); |
| 109 | objects.push(`4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>\nendobj\n`); |
| 110 | objects.push(`5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>\nendobj\n`); |
| 111 | pages.forEach((lines, i) => { |
| 112 | // Page-number footer on multi-page documents (a one-pager stays clean). |
| 113 | if (pages.length > 1) { |
| 114 | lines.push({ kind: 'text', x: PAGE_W / 2 - 24, y: MARGIN - 24, size: 8, font: 'F1', text: `Page ${i + 1} of ${pages.length}` }); |
| 115 | } |
| 116 | const pageNum = firstPageObj + i * 2; |
| 117 | const contentNum = pageNum + 1; |
| 118 | objects.push( |
| 119 | `${pageNum} 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${PAGE_W} ${PAGE_H}] ` + |
| 120 | `/Resources << /Font << /F1 3 0 R /F2 4 0 R /F3 5 0 R >> >> /Contents ${contentNum} 0 R >>\nendobj\n`, |
| 121 | ); |
| 122 | const stream = lines |
| 123 | .map(l => { |
| 124 | if (l.kind === 'text') return `BT /${l.font} ${l.size} Tf 1 0 0 1 ${l.x} ${l.y} Tm (${pdfEscape(l.text)}) Tj ET`; |
| 125 | // Bar row: label text, then normalized filled rects (q/Q so the gray fill never leaks into text). |
| 126 | const max = Math.max(1, ...l.values); |
| 127 | const label = l.label ? `BT /F1 ${l.size} Tf 1 0 0 1 ${l.x} ${l.y + 2} Tm (${pdfEscape(l.label)}) Tj ET\n` : ''; |
| 128 | const bx0 = l.x + (l.label ? Math.min(150, l.label.length * l.size * 0.55 + 12) : 0); |
| 129 | const rects = l.values.map((v, i) => { |
| 130 | const h = Math.max(1, Math.round((v / max) * BAR_H)); |
| 131 | return `${bx0 + i * (BAR_W + BAR_GAP)} ${l.y} ${BAR_W} ${h} re f`; |
no test coverage detected