Skip to content
397 linesCodeBlameRaw
1/**
2 * A PDF from Markdown, for `make_file` (files.ts): headings, paragraphs
3 * with bold, italic, code and links, lists, quotes, code blocks, rules and
4 * tables, laid out on US Letter pages with page numbers. Pure and small:
5 * the standard Helvetica and Courier fonts every reader has (so no fonts
6 * are embedded, and text is Latin script: what Windows-1252 holds),
7 * uncompressed content, links as link annotations.
8 */
9import { type Block, type Span, parseBlocks, spanText } from "./file-markdown.ts";
10
11const PAGE_W = 612;
12const PAGE_H = 792;
13const MARGIN_X = 64;
14const MARGIN_TOP = 64;
15const MARGIN_BOTTOM = 64;
16const WIDTH = PAGE_W - 2 * MARGIN_X;
17/** The most pages one file gets; the rest is cut, and said so. */
18export const MAX_PDF_PAGES = 300;
19
20type Font = "R" | "B" | "I" | "BI" | "M";
21const FONT_NAMES: Record<Font, string> = { R: "Helvetica", B: "Helvetica-Bold", I: "Helvetica-Oblique", BI: "Helvetica-BoldOblique", M: "Courier" };
22const FONT_IDS: Record<Font, string> = { R: "F1", B: "F2", I: "F3", BI: "F4", M: "F5" };
23
24// Advance widths, in thousandths of the size, of characters 32 to 126 (Adobe's AFM metrics).
25const REGULAR = "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"
26 .split(" ")
27 .map(Number);
28const 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"
29 .split(" ")
30 .map(Number);
31
32/** Windows-1252's characters 0x80 to 0x9F, by their Unicode code point. */
33const CP1252: Record<number, number> = {
34 0x20ac: 0x80, 0x201a: 0x82, 0x0192: 0x83, 0x201e: 0x84, 0x2026: 0x85, 0x2020: 0x86, 0x2021: 0x87, 0x02c6: 0x88, 0x2030: 0x89, 0x0160: 0x8a, 0x2039: 0x8b, 0x0152: 0x8c,
35 0x017d: 0x8e, 0x2018: 0x91, 0x2019: 0x92, 0x201c: 0x93, 0x201d: 0x94, 0x2022: 0x95, 0x2013: 0x96, 0x2014: 0x97, 0x02dc: 0x98, 0x2122: 0x99, 0x0161: 0x9a, 0x203a: 0x9b,
36 0x0153: 0x9c, 0x017e: 0x9e, 0x0178: 0x9f,
37};
38/** Characters outside Windows-1252 written as near equivalents. */
39const NEAR: Record<string, string> = { "→": "->", "←": "<-", "⇒": "=>", "≤": "<=", "≥": ">=", "≠": "!=", "≈": "~", "−": "-", "‑": "-", "‐": "-", "✓": "v", "✔": "v", "✗": "x", "✘": "x", "­": "" };
40
41/** Text as Windows-1252 codes: accents folded where it has no such letter, emoji dropped, anything else a question mark. */
42export function winAnsi(text: string): number[] {
43 const out: number[] = [];
44 for (const ch of text) {
45 const code = ch.codePointAt(0)!;
46 if (code === 9) out.push(32);
47 else if (code >= 32 && code <= 126) out.push(code);
48 else if (code >= 0xa0 && code <= 0xff) out.push(code);
49 else if (CP1252[code]) out.push(CP1252[code]!);
50 else if (NEAR[ch] !== undefined) for (const c of NEAR[ch]!) out.push(c.charCodeAt(0));
51 else if (/\p{Extended_Pictographic}|[​-‏️]/u.test(ch)) continue;
52 else {
53 const folded = ch.normalize("NFKD").replace(/[̀-ͯ]/g, "");
54 if (folded && [...folded].every((c) => c.charCodeAt(0) >= 32 && c.charCodeAt(0) <= 126)) for (const c of folded) out.push(c.charCodeAt(0));
55 else if (code >= 32) out.push(63);
56 }
57 }
58 return out;
59}
60
61function charWidth(code: number, font: Font): number {
62 if (font === "M") return 600;
63 const bold = font === "B" || font === "BI";
64 if (code >= 32 && code <= 126) return (bold ? BOLD : REGULAR)[code - 32]!;
65 if (code === 0xa0) return 278;
66 if (code === 0x95) return 350;
67 if (code === 0x97 || code === 0x85 || code === 0x89) return 1000;
68 if (code === 0x91 || code === 0x92) return bold ? 278 : 222;
69 if (code === 0x93 || code === 0x94) return bold ? 500 : 333;
70 if (code >= 0xc0 && code <= 0xde) return 722;
71 return 556;
72}
73
74/** How wide `text` is in `font` at `size`, in points. */
75export function measure(text: string, font: Font, size: number): number {
76 let w = 0;
77 for (const code of winAnsi(text)) w += charWidth(code, font);
78 return (w * size) / 1000;
79}
80
81/** A PDF literal string. */
82function pdfString(text: string): string {
83 let out = "(";
84 for (const code of winAnsi(text)) {
85 if (code === 40 || code === 41 || code === 92) out += `\\${String.fromCharCode(code)}`;
86 else if (code < 32 || code > 126) out += `\\${code.toString(8).padStart(3, "0")}`;
87 else out += String.fromCharCode(code);
88 }
89 return `${out})`;
90}
91
92const n = (v: number) => (Math.round(v * 100) / 100).toString();
93
94type Color = [number, number, number];
95const INK: Color = [0.1, 0.1, 0.12];
96const MUTED: Color = [0.38, 0.38, 0.42];
97const LINK: Color = [0.16, 0.36, 0.78];
98
99type Piece = { text: string; font: Font; size: number; x: number; width: number; color: Color; href?: string };
100
101function fontOf(span: Span, base: { bold?: boolean; italic?: boolean }): Font {
102 if (span.code) return "M";
103 const bold = !!(span.bold || base.bold);
104 const italic = !!(span.italic || base.italic);
105 return bold && italic ? "BI" : bold ? "B" : italic ? "I" : "R";
106}
107
108/** Spans wrapped to `width`: lines of pieces, x from the line's start. */
109function wrap(spans: Span[], size: number, width: number, base: { bold?: boolean; italic?: boolean; color?: Color } = {}): Piece[][] {
110 const lines: Piece[][] = [];
111 let line: Piece[] = [];
112 let x = 0;
113 let space: { width: number; font: Font } | null = null;
114 const newLine = () => {
115 lines.push(line);
116 line = [];
117 x = 0;
118 space = null;
119 };
120 const place = (text: string, font: Font, span: Span) => {
121 const pieceSize = font === "M" ? size * 0.92 : size;
122 const w = measure(text, font, pieceSize);
123 const gap = space && line.length ? space.width : 0;
124 if (line.length && x + gap + w > width) newLine();
125 else if (gap) {
126 const last = line[line.length - 1]!;
127 last.text += " ";
128 last.width += gap;
129 x += gap;
130 }
131 const color = span.href ? LINK : (base.color ?? INK);
132 const last = line[line.length - 1];
133 if (last && last.font === font && last.href === span.href && last.x + last.width === x) {
134 last.text += text;
135 last.width += w;
136 } else line.push({ text, font, size: pieceSize, x, width: w, color, ...(span.href ? { href: span.href } : {}) });
137 x += w;
138 space = null;
139 };
140 for (const span of spans) {
141 const font = fontOf(span, base);
142 for (const token of span.text.match(/\s+|\S+/g) ?? []) {
143 if (/^\s+$/.test(token)) {
144 space = { width: measure(" ", font, size), font };
145 continue;
146 }
147 let word = token;
148 // A word wider than the line is broken where it must be.
149 while (measure(word, font, size) > width) {
150 let cut = word.length - 1;
151 while (cut > 1 && measure(word.slice(0, cut), font, size) > width) cut--;
152 if (line.length) newLine();
153 place(word.slice(0, cut), font, span);
154 newLine();
155 word = word.slice(cut);
156 }
157 if (word) place(word, font, span);
158 }
159 }
160 if (line.length || !lines.length) lines.push(line);
161 return lines;
162}
163
164type Annot = { rect: [number, number, number, number]; uri: string };
165type Page = { ops: string[]; annots: Annot[] };
166
167/** The document being laid out: its pages, and where the next line goes. */
168class Layout {
169 pages: Page[] = [];
170 y = 0;
171 truncated = false;
172
173 constructor() {
174 this.newPage();
175 }
176
177 get page(): Page {
178 return this.pages[this.pages.length - 1]!;
179 }
180
181 newPage(): boolean {
182 if (this.pages.length >= MAX_PDF_PAGES) {
183 this.truncated = true;
184 return false;
185 }
186 this.pages.push({ ops: [], annots: [] });
187 this.y = PAGE_H - MARGIN_TOP;
188 return true;
189 }
190
191 /** Room for `height` more points on this page, or a new page; false when the document is full. */
192 room(height: number): boolean {
193 if (this.truncated) return false;
194 if (this.y - height >= MARGIN_BOTTOM) return true;
195 return this.newPage();
196 }
197
198 text(piece: Piece, x: number, baseline: number) {
199 const [r, g, b] = piece.color;
200 this.page.ops.push(`BT /${FONT_IDS[piece.font]} ${n(piece.size)} Tf ${n(r)} ${n(g)} ${n(b)} rg ${n(x)} ${n(baseline)} Td ${pdfString(piece.text)} Tj ET`);
201 if (piece.href && /^(https?:|mailto:)/i.test(piece.href)) {
202 // A space carried at the end of a link is not underlined.
203 const width = measure(piece.text.trimEnd(), piece.font, piece.size);
204 this.page.annots.push({ rect: [x, baseline - piece.size * 0.25, x + width, baseline + piece.size * 0.85], uri: piece.href });
205 this.page.ops.push(`${n(r)} ${n(g)} ${n(b)} RG 0.5 w ${n(x)} ${n(baseline - 1.5)} m ${n(x + width)} ${n(baseline - 1.5)} l S`);
206 }
207 }
208
209 fill(x: number, y: number, w: number, h: number, gray: number) {
210 this.page.ops.push(`${n(gray)} g ${n(x)} ${n(y)} ${n(w)} ${n(h)} re f`);
211 }
212
213 stroke(x1: number, y1: number, x2: number, y2: number, gray: number, width = 0.5) {
214 this.page.ops.push(`${n(gray)} G ${n(width)} w ${n(x1)} ${n(y1)} m ${n(x2)} ${n(y2)} l S`);
215 }
216
217 /** Lines of pieces from `left`, each `lead` tall. */
218 lines(lines: Piece[][], left: number, size: number, lead: number, before?: (first: boolean, top: number) => void): boolean {
219 let first = true;
220 for (const line of lines) {
221 if (!this.room(lead)) return false;
222 before?.(first, this.y);
223 const baseline = this.y - size;
224 for (const piece of line) this.text(piece, left + piece.x, baseline);
225 this.y -= lead;
226 first = false;
227 }
228 return true;
229 }
230}
231
232const BODY = 10.5;
233const LEAD = 15;
234const HEADINGS: Record<1 | 2 | 3, { size: number; before: number; after: number }> = {
235 1: { size: 20, before: 14, after: 8 },
236 2: { size: 15, before: 14, after: 6 },
237 3: { size: 12.5, before: 10, after: 4 },
238};
239
240function table(layout: Layout, block: Extract<Block, { kind: "table" }>) {
241 const columns = Math.max(block.header.length, ...block.rows.map((row) => row.length), 1);
242 const size = BODY - 1;
243 const lead = 13;
244 const pad = 5;
245 const natural = Array.from({ length: columns }, (_, c) =>
246 Math.min(260, Math.max(36, ...[block.header[c] ?? [], ...block.rows.map((row) => row[c] ?? [])].map((cell) => measure(spanText(cell), "B", size) + 2 * pad))),
247 );
248 const total = natural.reduce((a, b) => a + b, 0);
249 const widths = total > WIDTH ? natural.map((w) => (w / total) * WIDTH) : natural;
250 const right = MARGIN_X + widths.reduce((a, b) => a + b, 0);
251 const shape = (row: Span[][], header: boolean) => {
252 const wrapped = widths.map((w, c) => wrap(row[c] ?? [], size, w - 2 * pad, { bold: header }));
253 return { wrapped, height: Math.max(...wrapped.map((lines) => lines.length)) * lead + 2 * pad - 2 };
254 };
255 const draw = (row: { wrapped: Piece[][][]; height: number }, header: boolean) => {
256 const top = layout.y;
257 if (header) layout.fill(MARGIN_X, top - row.height, right - MARGIN_X, row.height, 0.94);
258 let x = MARGIN_X;
259 row.wrapped.forEach((lines, c) => {
260 lines.forEach((line, i) => {
261 for (const piece of line) layout.text(piece, x + pad + piece.x, top - pad - size + 1 - i * lead);
262 });
263 x += widths[c]!;
264 });
265 if (header) layout.stroke(MARGIN_X, top, right, top, 0.8);
266 layout.stroke(MARGIN_X, top - row.height, right, top - row.height, 0.8);
267 layout.y = top - row.height;
268 };
269 const header = block.header.length ? shape(block.header, true) : null;
270 if (header) {
271 if (!layout.room(header.height + lead * 2)) return;
272 draw(header, true);
273 }
274 for (const cells of block.rows) {
275 const row = shape(cells, false);
276 const before = layout.pages.length;
277 if (!layout.room(row.height)) return;
278 // A table carried onto a new page repeats its header there.
279 if (layout.pages.length !== before && header) draw(header, true);
280 draw(row, false);
281 }
282 layout.y -= 10;
283}
284
285/** The PDF of `markdown`, titled `title` (the first heading when the Markdown starts without one). */
286export function markdownPdf(title: string, markdown: string): { bytes: Uint8Array; pages: number; truncated: boolean } {
287 let blocks = parseBlocks(markdown);
288 if (title.trim() && !(blocks[0]?.kind === "heading" && blocks[0].level === 1)) blocks = [{ kind: "heading", level: 1, spans: [{ text: title.trim() }] }, ...blocks];
289 const layout = new Layout();
290 let previous: Block["kind"] | null = null;
291 for (const block of blocks) {
292 if (layout.truncated) break;
293 switch (block.kind) {
294 case "heading": {
295 const style = HEADINGS[block.level];
296 if (previous) layout.y -= style.before;
297 const lines = wrap(block.spans, style.size, WIDTH, { bold: true });
298 // Keep a heading with the line after it.
299 layout.room(lines.length * style.size * 1.25 + LEAD * 2);
300 layout.lines(lines, MARGIN_X, style.size, style.size * 1.25);
301 if (block.level === 1) layout.stroke(MARGIN_X, layout.y + 2, MARGIN_X + WIDTH, layout.y + 2, 0.85);
302 layout.y -= style.after;
303 break;
304 }
305 case "paragraph":
306 layout.lines(wrap(block.spans, BODY, WIDTH), MARGIN_X, BODY, LEAD);
307 layout.y -= 7;
308 break;
309 case "item": {
310 const indent = 16 + block.depth * 16;
311 const marker = block.ordered ? `${block.number}.` : block.depth % 2 ? "–" : "•";
312 const lines = wrap(block.spans, BODY, WIDTH - indent);
313 layout.lines(lines, MARGIN_X + indent, BODY, LEAD, (first, top) => {
314 if (!first) return;
315 const piece: Piece = { text: marker, font: "R", size: BODY, x: 0, width: measure(marker, "R", BODY), color: MUTED };
316 layout.text(piece, MARGIN_X + indent - 6 - piece.width, top - BODY);
317 });
318 layout.y -= 3;
319 break;
320 }
321 case "quote": {
322 const lines = wrap(block.spans, BODY, WIDTH - 16, { color: MUTED });
323 layout.lines(lines, MARGIN_X + 14, BODY, LEAD, (_first, top) => layout.fill(MARGIN_X, top - LEAD + 2, 2.5, LEAD, 0.75));
324 layout.y -= 7;
325 break;
326 }
327 case "code": {
328 const size = 8.6;
329 const lead = 11.5;
330 const perLine = Math.floor((WIDTH - 16) / ((600 * size) / 1000));
331 const rows = block.text.split("\n").flatMap((row) => {
332 if (!row) return [""];
333 const parts: string[] = [];
334 for (let i = 0; i < row.length; i += perLine) parts.push(row.slice(i, i + perLine));
335 return parts;
336 });
337 layout.y -= 2;
338 for (const row of rows) {
339 if (!layout.room(lead)) break;
340 layout.fill(MARGIN_X, layout.y - lead, WIDTH, lead, 0.955);
341 if (row) layout.text({ text: row, font: "M", size, x: 0, width: 0, color: INK }, MARGIN_X + 8, layout.y - size - 0.5);
342 layout.y -= lead;
343 }
344 layout.y -= 9;
345 break;
346 }
347 case "rule":
348 if (layout.room(14)) {
349 layout.stroke(MARGIN_X, layout.y - 6, MARGIN_X + WIDTH, layout.y - 6, 0.8);
350 layout.y -= 14;
351 }
352 break;
353 case "table":
354 table(layout, block);
355 break;
356 }
357 previous = block.kind;
358 }
359 return { bytes: writePdf(title, layout.pages), pages: layout.pages.length, truncated: layout.truncated };
360}
361
362/** The file: a catalog, the page tree, the fonts, then each page with its content and links. */
363function writePdf(title: string, pages: Page[]): Uint8Array {
364 const objects: string[] = [];
365 const add = (body: string) => {
366 objects.push(body);
367 return objects.length;
368 };
369 const catalog = add("");
370 const tree = add("");
371 const info = add(`<< /Title ${pdfString(title)} /Producer (g1t) /CreationDate (D:${new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14)}Z) >>`);
372 const fonts = (Object.keys(FONT_NAMES) as Font[]).map((font) => `/${FONT_IDS[font]} ${add(`<< /Type /Font /Subtype /Type1 /BaseFont /${FONT_NAMES[font]} /Encoding /WinAnsiEncoding >>`)} 0 R`);
373 const kids: number[] = [];
374 pages.forEach((page, index) => {
375 const footer = `BT /F1 8.5 Tf 0.55 0.55 0.6 rg ${n(PAGE_W / 2 - measure(`${index + 1} of ${pages.length}`, "R", 8.5) / 2)} 36 Td ${pdfString(`${index + 1} of ${pages.length}`)} Tj ET`;
376 const stream = [...page.ops, footer].join("\n");
377 const content = add(`<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`);
378 const annots = page.annots.map((a) => add(`<< /Type /Annot /Subtype /Link /Rect [${a.rect.map(n).join(" ")}] /Border [0 0 0] /A << /S /URI /URI ${pdfString(a.uri)} >> >>`));
379 kids.push(
380 add(
381 `<< /Type /Page /Parent ${tree} 0 R /MediaBox [0 0 ${PAGE_W} ${PAGE_H}] /Resources << /Font << ${fonts.join(" ")} >> >> /Contents ${content} 0 R${annots.length ? ` /Annots [${annots.map((id) => `${id} 0 R`).join(" ")}]` : ""} >>`,
382 ),
383 );
384 });
385 objects[catalog - 1] = `<< /Type /Catalog /Pages ${tree} 0 R >>`;
386 objects[tree - 1] = `<< /Type /Pages /Kids [${kids.map((id) => `${id} 0 R`).join(" ")}] /Count ${kids.length} >>`;
387 let out = "%PDF-1.4\n%âãÏÓ\n";
388 const offsets: number[] = [];
389 objects.forEach((body, i) => {
390 offsets.push(out.length);
391 out += `${i + 1} 0 obj\n${body}\nendobj\n`;
392 });
393 const xref = out.length;
394 out += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n${offsets.map((o) => `${String(o).padStart(10, "0")} 00000 n \n`).join("")}`;
395 out += `trailer\n<< /Size ${objects.length + 1} /Root ${catalog} 0 R /Info ${info} 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
396 return Uint8Array.from(out, (c) => c.charCodeAt(0));
397}