| 1 | // A very small PDF writer: enough for the Emergency Kit and nothing more. |
| 2 | // |
| 3 | // Written rather than vendored for the same reason the QR encoder is. These |
| 4 | // two documents are generated in the browser and must never touch the server, |
| 5 | // so the code that makes them ships to every user — and a PDF library is tens |
| 6 | // of thousands of lines nobody in this repo would ever read. |
| 7 | // |
| 8 | // What it supports: the base-14 fonts (so nothing is embedded), text, filled |
| 9 | // rectangles, and lines. That is the whole vocabulary the kit needs. Anything |
| 10 | // else belongs in a different file with a different argument behind it. |
| 11 | |
| 12 | const enc = new TextEncoder(); |
| 13 | |
| 14 | // PDF strings are parenthesised, so those and the escape character have to be |
| 15 | // escaped. Everything else stays literal, which is why the kit's copy is kept |
| 16 | // to ASCII — a base-14 font has no glyph for a curly quote in this encoding, |
| 17 | // and a document that renders "don’t" as "donÕt" is not one to hand somebody |
| 18 | // at the worst moment of their year. |
| 19 | function pdfString(s) { |
| 20 | return "(" + s.replace(/[\\()]/g, (c) => "\\" + c) + ")"; |
| 21 | } |
| 22 | |
| 23 | export function assertASCII(s, where) { |
| 24 | for (const ch of s) { |
| 25 | if (ch.charCodeAt(0) > 126 || ch.charCodeAt(0) < 32) { |
| 26 | throw new Error(`pdf: ${where} contains a character the base-14 fonts cannot draw: ` + |
| 27 | JSON.stringify(ch)); |
| 28 | } |
| 29 | } |
| 30 | return s; |
| 31 | } |
| 32 | |
| 33 | // textWidth is a deliberately crude estimate of a drawn string's width. The |
| 34 | // base-14 metrics are per-glyph and we do not ship the tables, so Helvetica is |
| 35 | // taken at 0.5 em and Courier is exact at 0.6 em, being monospaced. |
| 36 | // |
| 37 | // It errs wide for Helvetica, which is the safe direction: a layout that |
| 38 | // trusts it leaves a margin rather than running off the edge. It exists so |
| 39 | // that "does this fit inside the cut line?" is a test rather than something |
| 40 | // somebody spots in a screenshot — which is how the Account Key card first |
| 41 | // rendered with its code hanging over the edge. |
| 42 | export function textWidth(s, { font = "regular", size = 11 } = {}) { |
| 43 | return s.length * size * (font === "mono" ? 0.6 : 0.5); |
| 44 | } |
| 45 | |
| 46 | export const FONTS = { |
| 47 | regular: "Helvetica", |
| 48 | bold: "Helvetica-Bold", |
| 49 | mono: "Courier-Bold", |
| 50 | }; |
| 51 | |
| 52 | // A4 in points, and the ISO card the Account Key is cut down to. |
| 53 | export const A4 = { width: 595.28, height: 841.89 }; |
| 54 | export const CARD = { width: 242.65, height: 153.07 }; // 85.6 x 54 mm |
| 55 | |
| 56 | // Page collects drawing operations. Coordinates are PDF's: origin at the |
| 57 | // bottom left, y upward — kept rather than flipped, so that anyone reading |
| 58 | // this against a PDF reference is reading the same numbers. |
| 59 | export class Page { |
| 60 | constructor(width = A4.width, height = A4.height) { |
| 61 | this.width = width; |
| 62 | this.height = height; |
| 63 | this.ops = []; |
| 64 | } |
| 65 | |
| 66 | text(x, y, s, { font = "regular", size = 11, gray = 0 } = {}) { |
| 67 | assertASCII(s, "page text"); |
| 68 | this.ops.push( |
| 69 | `BT ${gray} g /${font} ${size} Tf 1 0 0 1 ${x.toFixed(2)} ${y.toFixed(2)} Tm ` + |
| 70 | `${pdfString(s)} Tj ET`); |
| 71 | return this; |
| 72 | } |
| 73 | |
| 74 | // wrap lays out a paragraph at a fixed width, measuring in the crude way |
| 75 | // the base-14 metrics allow: Helvetica averages about 0.5 em. It errs |
| 76 | // narrow, so a line may end early but never runs off the page. |
| 77 | wrap(x, y, maxWidth, s, { font = "regular", size = 10, leading = 13, gray = 0 } = {}) { |
| 78 | const perChar = size * (font === "mono" ? 0.6 : 0.5); |
| 79 | const perLine = Math.max(8, Math.floor(maxWidth / perChar)); |
| 80 | const words = assertASCII(s, "paragraph").split(/\s+/); |
| 81 | let line = ""; |
| 82 | let at = y; |
| 83 | for (const word of words) { |
| 84 | if (line && (line + " " + word).length > perLine) { |
| 85 | this.text(x, at, line, { font, size, gray }); |
| 86 | at -= leading; |
| 87 | line = word; |
| 88 | } else { |
| 89 | line = line ? line + " " + word : word; |
| 90 | } |
| 91 | } |
| 92 | if (line) { |
| 93 | this.text(x, at, line, { font, size, gray }); |
| 94 | at -= leading; |
| 95 | } |
| 96 | return at; |
| 97 | } |
| 98 | |
| 99 | rect(x, y, w, h, { gray = 0 } = {}) { |
| 100 | this.ops.push(`${gray} g ${x.toFixed(2)} ${y.toFixed(2)} ${w.toFixed(2)} ${h.toFixed(2)} re f`); |
| 101 | return this; |
| 102 | } |
| 103 | |
| 104 | // A dashed rectangle, for the cut line around the card. |
| 105 | dashedRect(x, y, w, h, { gray = 0.6, dash = 4 } = {}) { |
| 106 | this.ops.push( |
| 107 | `${gray} G [${dash} ${dash}] 0 d 0.7 w ` + |
| 108 | `${x.toFixed(2)} ${y.toFixed(2)} ${w.toFixed(2)} ${h.toFixed(2)} re S [] 0 d`); |
| 109 | return this; |
| 110 | } |
| 111 | |
| 112 | // qr draws a module matrix as filled squares. One rectangle per dark |
| 113 | // module: verbose in the file and exactly right on paper, which is the |
| 114 | // trade to make for something that has to scan in ten years. |
| 115 | qr(modules, x, y, moduleSize) { |
| 116 | const n = modules.length; |
| 117 | for (let r = 0; r < n; r++) { |
| 118 | for (let c = 0; c < n; c++) { |
| 119 | if (!modules[r][c]) continue; |
| 120 | // Row 0 is the top of the symbol; PDF's y grows upward. |
| 121 | this.rect(x + c * moduleSize, y + (n - 1 - r) * moduleSize, |
| 122 | moduleSize, moduleSize); |
| 123 | } |
| 124 | } |
| 125 | return this; |
| 126 | } |
| 127 | |
| 128 | get content() { return this.ops.join("\n"); } |
| 129 | } |
| 130 | |
| 131 | // document assembles pages into a PDF file, as bytes. |
| 132 | export function document(pages, { title = "" } = {}) { |
| 133 | const objects = []; |
| 134 | const add = (body) => { objects.push(body); return objects.length; }; // 1-based |
| 135 | |
| 136 | const catalogId = objects.length + 1; |
| 137 | add(null); // reserved: catalog, written once the pages tree id is known |
| 138 | const pagesId = add(null); |
| 139 | |
| 140 | const fontIds = {}; |
| 141 | for (const [name, base] of Object.entries(FONTS)) { |
| 142 | fontIds[name] = add(`<< /Type /Font /Subtype /Type1 /BaseFont /${base} ` + |
| 143 | `/Encoding /WinAnsiEncoding >>`); |
| 144 | } |
| 145 | |
| 146 | const resources = "<< /Font << " + |
| 147 | Object.entries(fontIds).map(([n, id]) => `/${n} ${id} 0 R`).join(" ") + " >> >>"; |
| 148 | |
| 149 | const pageIds = []; |
| 150 | for (const page of pages) { |
| 151 | const stream = page.content; |
| 152 | const contentId = add( |
| 153 | `<< /Length ${enc.encode(stream).length} >>\nstream\n${stream}\nendstream`); |
| 154 | pageIds.push(add( |
| 155 | `<< /Type /Page /Parent ${pagesId} 0 R ` + |
| 156 | `/MediaBox [0 0 ${page.width.toFixed(2)} ${page.height.toFixed(2)}] ` + |
| 157 | `/Resources ${resources} /Contents ${contentId} 0 R >>`)); |
| 158 | } |
| 159 | |
| 160 | objects[catalogId - 1] = `<< /Type /Catalog /Pages ${pagesId} 0 R >>`; |
| 161 | objects[pagesId - 1] = `<< /Type /Pages /Kids [${pageIds.map((i) => `${i} 0 R`).join(" ")}] ` + |
| 162 | `/Count ${pageIds.length} >>`; |
| 163 | |
| 164 | const infoId = add(`<< /Title ${pdfString(assertASCII(title, "title"))} ` + |
| 165 | `/Producer (CONTRA) >>`); |
| 166 | |
| 167 | let out = "%PDF-1.4\n"; |
| 168 | const offsets = []; |
| 169 | for (let i = 0; i < objects.length; i++) { |
| 170 | offsets.push(enc.encode(out).length); |
| 171 | out += `${i + 1} 0 obj\n${objects[i]}\nendobj\n`; |
| 172 | } |
| 173 | const xref = enc.encode(out).length; |
| 174 | out += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`; |
| 175 | for (const off of offsets) out += `${String(off).padStart(10, "0")} 00000 n \n`; |
| 176 | out += `trailer\n<< /Size ${objects.length + 1} /Root ${catalogId} 0 R ` + |
| 177 | `/Info ${infoId} 0 R >>\nstartxref\n${xref}\n%%EOF\n`; |
| 178 | |
| 179 | return enc.encode(out); |
| 180 | } |
| 181 | |