carlos / contra Public

Clone
git clone https://amadan.net/carlos/contra

Plain git — no account needed to clone.

Download

Download this file

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
12const 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.
19function pdfString(s) {
20 return "(" + s.replace(/[\\()]/g, (c) => "\\" + c) + ")";
21}
22
23export 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.
42export function textWidth(s, { font = "regular", size = 11 } = {}) {
43 return s.length * size * (font === "mono" ? 0.6 : 0.5);
44}
45
46export 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.
53export const A4 = { width: 595.28, height: 841.89 };
54export 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.
59export 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.
132export 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