@trazum/core 2.4.1 → 2.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/pdf.ts ADDED
@@ -0,0 +1,356 @@
1
+ /**
2
+ * A PDF writer with nothing behind it.
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * The person who signs off a bill reads it as a PDF: attached to an email,
7
+ * filed beside an invoice, printed for a meeting. The HTML report renders the
8
+ * same everywhere a browser does, and a browser is still one step a finance
9
+ * reader should not have to take. This writes the file directly.
10
+ *
11
+ * ## Why it is small on purpose
12
+ *
13
+ * This package has no runtime dependencies, and a PDF library would be the
14
+ * largest thing in it by an order of magnitude. A bill report needs very little
15
+ * of the format: text in two weights, filled and stroked rectangles, lines, and
16
+ * pages. So this is exactly that, over the 14 standard fonts every PDF reader
17
+ * carries, which means no font is embedded and a three-page bill weighs a few
18
+ * kilobytes.
19
+ *
20
+ * ## What that costs, said plainly
21
+ *
22
+ * The standard fonts speak WinAnsiEncoding: Latin script, which covers every
23
+ * locale this product ships (English, Spanish, French, German, Portuguese)
24
+ * and nothing beyond it. A character outside it is written as its closest
25
+ * ASCII form when there is an obvious one (`→` as `->`) and as `?` when there
26
+ * is not, never dropped silently: a label in Japanese arrives as question
27
+ * marks, which a reader can see is wrong, rather than as an empty cell, which
28
+ * reads as nothing.
29
+ *
30
+ * ## Deterministic
31
+ *
32
+ * The same calls write the same bytes. No creation date unless one is passed,
33
+ * no random ids, content streams uncompressed, so a test can compare the file
34
+ * and a reader can open it in a text editor and find their figures. The file
35
+ * is plain ASCII: every byte past 127 is written as an octal escape.
36
+ */
37
+
38
+ export type PdfFont = 'regular' | 'bold' | 'mono';
39
+
40
+ /** A colour as `#rrggbb`. */
41
+ export type PdfColor = `#${string}`;
42
+
43
+ export interface PdfTextOptions {
44
+ font?: PdfFont;
45
+ size?: number;
46
+ color?: PdfColor;
47
+ }
48
+
49
+ export interface PdfShapeOptions {
50
+ fill?: PdfColor;
51
+ stroke?: PdfColor;
52
+ lineWidth?: number;
53
+ }
54
+
55
+ export interface PdfMetadata {
56
+ title?: string;
57
+ subject?: string;
58
+ author?: string;
59
+ /** Omitted unless given, so the same report writes the same bytes. */
60
+ createdAt?: Date;
61
+ }
62
+
63
+ /** A4, in points. Letter readers print it with a small margin and lose nothing. */
64
+ export const PDF_PAGE = { width: 595.28, height: 841.89 } as const;
65
+
66
+ const FONT_NAME: Readonly<Record<PdfFont, string>> = {
67
+ regular: 'Helvetica',
68
+ bold: 'Helvetica-Bold',
69
+ mono: 'Courier',
70
+ };
71
+
72
+ const FONT_KEY: Readonly<Record<PdfFont, string>> = { regular: 'F1', bold: 'F2', mono: 'F3' };
73
+
74
+ /*
75
+ Advance widths in thousandths of an em for character codes 32 to 126, from
76
+ the Adobe font metrics every reader ships with these fonts. Needed for one
77
+ thing: right-aligning a column of figures and wrapping a sentence, both of
78
+ which a report has on every page.
79
+ */
80
+ const HELVETICA = [
81
+ 278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278,
82
+ 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556,
83
+ 1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778,
84
+ 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556,
85
+ 333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556,
86
+ 556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584,
87
+ ];
88
+
89
+ const HELVETICA_BOLD = [
90
+ 278, 333, 474, 556, 556, 889, 722, 238, 333, 333, 389, 584, 278, 333, 278, 278,
91
+ 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611,
92
+ 975, 722, 722, 722, 722, 667, 611, 778, 722, 278, 556, 722, 611, 833, 722, 778,
93
+ 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 333, 278, 333, 584, 556,
94
+ 333, 556, 611, 556, 611, 556, 333, 611, 611, 278, 278, 556, 278, 889, 611, 611,
95
+ 611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584,
96
+ ];
97
+
98
+ /** The WinAnsi code points that are not Latin-1's, and the punctuation reports use. */
99
+ const WIN_ANSI_EXTRA: ReadonlyMap<string, number> = new Map([
100
+ ['€', 0x80], ['‚', 0x82], ['ƒ', 0x83], ['„', 0x84], ['…', 0x85], ['†', 0x86],
101
+ ['‡', 0x87], ['ˆ', 0x88], ['‰', 0x89], ['Š', 0x8a], ['‹', 0x8b], ['Œ', 0x8c],
102
+ ['Ž', 0x8e], ['‘', 0x91], ['’', 0x92], ['“', 0x93], ['”', 0x94], ['•', 0x95],
103
+ ['–', 0x96], ['—', 0x97], ['˜', 0x98], ['™', 0x99], ['š', 0x9a], ['›', 0x9b],
104
+ ['œ', 0x9c], ['ž', 0x9e], ['Ÿ', 0x9f],
105
+ ]);
106
+
107
+ /** Characters outside WinAnsi with an obvious ASCII reading. */
108
+ const TRANSLITERATE: ReadonlyMap<string, string> = new Map([
109
+ ['→', '->'], ['←', '<-'], ['↔', '<->'], ['≈', '~'], ['≤', '<='], ['≥', '>='],
110
+ ['−', '-'], ['‑', '-'], ['‐', '-'], ['≠', '!='], ['✓', 'v'], ['✗', 'x'],
111
+ // Thin and narrow spaces, which some locales use as a thousands separator.
112
+ [' ', ' '], [' ', ' '], [' ', ' '], ['​', ''],
113
+ ]);
114
+
115
+ /** Widths for the WinAnsi codes above 127 that are not an accented letter. */
116
+ const HIGH_WIDTH: ReadonlyMap<number, number> = new Map([
117
+ [0x80, 556], [0x85, 1000], [0x91, 222], [0x92, 222], [0x93, 333], [0x94, 333],
118
+ [0x95, 350], [0x96, 556], [0x97, 1000], [0x99, 1000], [0xa0, 278], [0xa1, 333],
119
+ [0xa2, 556], [0xa3, 556], [0xa5, 556], [0xa7, 556], [0xa9, 737], [0xab, 556],
120
+ [0xae, 737], [0xb0, 400], [0xb7, 278], [0xbb, 556], [0xbf, 611], [0xc6, 1000],
121
+ [0xd7, 584], [0xd8, 778], [0xdf, 611], [0xe6, 889], [0xf7, 584], [0xf8, 611],
122
+ ]);
123
+
124
+ /** One character as WinAnsi byte codes: one, several when transliterated, `?` when neither. */
125
+ function winAnsi(char: string): number[] {
126
+ const code = char.codePointAt(0) ?? 63;
127
+ if (code === 0x0a || code === 0x0d || code === 0x09) return [32];
128
+ if (code >= 32 && code <= 126) return [code];
129
+ if (code >= 0xa0 && code <= 0xff) return [code];
130
+ const extra = WIN_ANSI_EXTRA.get(char);
131
+ if (extra !== undefined) return [extra];
132
+ const ascii = TRANSLITERATE.get(char);
133
+ if (ascii !== undefined) return [...ascii].map((c) => c.charCodeAt(0));
134
+ return [63];
135
+ }
136
+
137
+ /** The text as WinAnsi codes, the form both the width and the stream are measured in. */
138
+ export function pdfEncode(text: string): number[] {
139
+ const codes: number[] = [];
140
+ for (const char of text) codes.push(...winAnsi(char));
141
+ return codes;
142
+ }
143
+
144
+ function codeWidth(code: number, font: PdfFont): number {
145
+ if (font === 'mono') return 600;
146
+ const table = font === 'bold' ? HELVETICA_BOLD : HELVETICA;
147
+ if (code >= 32 && code <= 126) return table[code - 32] ?? 556;
148
+ const high = HIGH_WIDTH.get(code);
149
+ if (high !== undefined) return high;
150
+ // An accented letter is as wide as the letter under the accent.
151
+ const base = String.fromCharCode(code).normalize('NFD').charCodeAt(0);
152
+ if (base >= 32 && base <= 126) return table[base - 32] ?? 556;
153
+ return 556;
154
+ }
155
+
156
+ /** How wide `text` sets in `font` at `size` points. */
157
+ export function pdfTextWidth(text: string, font: PdfFont, size: number): number {
158
+ let units = 0;
159
+ for (const code of pdfEncode(text)) units += codeWidth(code, font);
160
+ return (units * size) / 1000;
161
+ }
162
+
163
+ /**
164
+ * `text` broken into lines no wider than `maxWidth`, at spaces.
165
+ *
166
+ * A word wider than the line on its own (a model id, a path) is broken
167
+ * mid-word rather than left to run off the page, because a figure that leaves
168
+ * the paper is a figure nobody reads.
169
+ */
170
+ export function pdfWrap(text: string, font: PdfFont, size: number, maxWidth: number): string[] {
171
+ const lines: string[] = [];
172
+ for (const paragraph of text.split('\n')) {
173
+ let line = '';
174
+ for (const word of paragraph.split(/ +/)) {
175
+ const candidate = line === '' ? word : `${line} ${word}`;
176
+ if (pdfTextWidth(candidate, font, size) <= maxWidth) {
177
+ line = candidate;
178
+ continue;
179
+ }
180
+ if (line !== '') lines.push(line);
181
+ let rest = word;
182
+ while (pdfTextWidth(rest, font, size) > maxWidth && rest.length > 1) {
183
+ let cut = rest.length - 1;
184
+ while (cut > 1 && pdfTextWidth(rest.slice(0, cut), font, size) > maxWidth) cut -= 1;
185
+ lines.push(rest.slice(0, cut));
186
+ rest = rest.slice(cut);
187
+ }
188
+ line = rest;
189
+ }
190
+ lines.push(line);
191
+ }
192
+ return lines;
193
+ }
194
+
195
+ /** `text` cut to fit `maxWidth`, with an ellipsis saying it was cut. */
196
+ export function pdfFit(text: string, font: PdfFont, size: number, maxWidth: number): string {
197
+ if (pdfTextWidth(text, font, size) <= maxWidth) return text;
198
+ let cut = text.length;
199
+ while (cut > 0 && pdfTextWidth(`${text.slice(0, cut)}…`, font, size) > maxWidth) cut -= 1;
200
+ return `${text.slice(0, cut)}…`;
201
+ }
202
+
203
+ /** A PDF string literal: parentheses and backslashes escaped, every byte past 127 in octal. */
204
+ function literal(text: string): string {
205
+ let out = '(';
206
+ for (const code of pdfEncode(text)) {
207
+ if (code === 0x28 || code === 0x29 || code === 0x5c) out += `\\${String.fromCharCode(code)}`;
208
+ else if (code < 32 || code > 126) out += `\\${code.toString(8).padStart(3, '0')}`;
209
+ else out += String.fromCharCode(code);
210
+ }
211
+ return `${out})`;
212
+ }
213
+
214
+ /** Numbers as PDF writes them: at most two decimals, no exponent, no `-0`. */
215
+ function num(value: number): string {
216
+ const rounded = Math.round(value * 100) / 100;
217
+ return Object.is(rounded, -0) ? '0' : String(rounded);
218
+ }
219
+
220
+ function rgb(color: PdfColor): string {
221
+ const hex = color.slice(1);
222
+ const channel = (offset: number): string => num(parseInt(hex.slice(offset, offset + 2), 16) / 255);
223
+ return `${channel(0)} ${channel(2)} ${channel(4)}`;
224
+ }
225
+
226
+ /**
227
+ * Pages of text and shapes, written as one PDF.
228
+ *
229
+ * Coordinates are in points from the **top-left** corner, the way a layout is
230
+ * thought about, and converted to PDF's bottom-left origin on the way out. A
231
+ * text call's `y` is the baseline.
232
+ */
233
+ export class PdfDocument {
234
+ readonly width = PDF_PAGE.width;
235
+ readonly height = PDF_PAGE.height;
236
+ private readonly pages: string[][] = [];
237
+ private current = -1;
238
+
239
+ constructor() {
240
+ this.addPage();
241
+ }
242
+
243
+ /** Starts a new page and makes it the one drawn on. */
244
+ addPage(): number {
245
+ this.pages.push([]);
246
+ this.current = this.pages.length - 1;
247
+ return this.current;
248
+ }
249
+
250
+ get pageCount(): number {
251
+ return this.pages.length;
252
+ }
253
+
254
+ /** Draws on an earlier page, which is how a footer learns the page count. */
255
+ setPage(index: number): void {
256
+ if (!Number.isInteger(index) || index < 0 || index >= this.pages.length) {
257
+ throw new RangeError(`no page ${index}: the document has ${this.pages.length}`);
258
+ }
259
+ this.current = index;
260
+ }
261
+
262
+ private ops(): string[] {
263
+ const page = this.pages[this.current];
264
+ if (page === undefined) throw new RangeError('no page to draw on');
265
+ return page;
266
+ }
267
+
268
+ text(x: number, y: number, text: string, options: PdfTextOptions = {}): void {
269
+ const font = options.font ?? 'regular';
270
+ const size = options.size ?? 10;
271
+ const color = options.color ?? '#000000';
272
+ this.ops().push(
273
+ `BT /${FONT_KEY[font]} ${num(size)} Tf ${rgb(color)} rg ${num(x)} ${num(this.height - y)} Td ${literal(text)} Tj ET`,
274
+ );
275
+ }
276
+
277
+ /** Text ending at `right`, for a column of figures. */
278
+ textRight(right: number, y: number, text: string, options: PdfTextOptions = {}): void {
279
+ const width = pdfTextWidth(text, options.font ?? 'regular', options.size ?? 10);
280
+ this.text(right - width, y, text, options);
281
+ }
282
+
283
+ rect(x: number, y: number, width: number, height: number, options: PdfShapeOptions = {}): void {
284
+ const { fill, stroke } = options;
285
+ if (fill === undefined && stroke === undefined) return;
286
+ const parts: string[] = ['q'];
287
+ if (fill !== undefined) parts.push(`${rgb(fill)} rg`);
288
+ if (stroke !== undefined) parts.push(`${rgb(stroke)} RG ${num(options.lineWidth ?? 1)} w`);
289
+ parts.push(`${num(x)} ${num(this.height - y - height)} ${num(width)} ${num(height)} re`);
290
+ parts.push(fill !== undefined && stroke !== undefined ? 'B' : fill !== undefined ? 'f' : 'S');
291
+ parts.push('Q');
292
+ this.ops().push(parts.join(' '));
293
+ }
294
+
295
+ line(x1: number, y1: number, x2: number, y2: number, options: { color?: PdfColor; width?: number } = {}): void {
296
+ this.ops().push(
297
+ `q ${rgb(options.color ?? '#000000')} RG ${num(options.width ?? 1)} w ${num(x1)} ${num(this.height - y1)} m ${num(x2)} ${num(this.height - y2)} l S Q`,
298
+ );
299
+ }
300
+
301
+ /** The whole file, as ASCII bytes. */
302
+ toBytes(metadata: PdfMetadata = {}): Uint8Array {
303
+ const text = this.toString(metadata);
304
+ const bytes = new Uint8Array(text.length);
305
+ for (let i = 0; i < text.length; i += 1) bytes[i] = text.charCodeAt(i);
306
+ return bytes;
307
+ }
308
+
309
+ /** The whole file as a string; every character is ASCII, so its length is its byte count. */
310
+ toString(metadata: PdfMetadata = {}): string {
311
+ /*
312
+ Object numbers: 1 catalog, 2 page tree, 3 to 5 fonts, 6 info, then a page
313
+ and its content stream for each page in order.
314
+ */
315
+ const objects: string[] = [];
316
+ const pageIds = this.pages.map((_, i) => 7 + i * 2);
317
+ objects.push('<< /Type /Catalog /Pages 2 0 R >>');
318
+ objects.push(`<< /Type /Pages /Kids [${pageIds.map((id) => `${id} 0 R`).join(' ')}] /Count ${this.pages.length} >>`);
319
+ for (const font of ['regular', 'bold', 'mono'] as const) {
320
+ objects.push(`<< /Type /Font /Subtype /Type1 /BaseFont /${FONT_NAME[font]} /Encoding /WinAnsiEncoding >>`);
321
+ }
322
+ const info: string[] = ['/Producer (Trazum)'];
323
+ if (metadata.title !== undefined) info.push(`/Title ${literal(metadata.title)}`);
324
+ if (metadata.subject !== undefined) info.push(`/Subject ${literal(metadata.subject)}`);
325
+ if (metadata.author !== undefined) info.push(`/Author ${literal(metadata.author)}`);
326
+ if (metadata.createdAt !== undefined) info.push(`/CreationDate (${pdfDate(metadata.createdAt)})`);
327
+ objects.push(`<< ${info.join(' ')} >>`);
328
+ const resources = '<< /Font << /F1 3 0 R /F2 4 0 R /F3 5 0 R >> >>';
329
+ this.pages.forEach((ops, i) => {
330
+ const contentId = 8 + i * 2;
331
+ objects.push(
332
+ `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${num(this.width)} ${num(this.height)}] /Resources ${resources} /Contents ${contentId} 0 R >>`,
333
+ );
334
+ const stream = ops.join('\n');
335
+ objects.push(`<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`);
336
+ });
337
+
338
+ let out = '%PDF-1.4\n';
339
+ const offsets: number[] = [];
340
+ objects.forEach((body, i) => {
341
+ offsets.push(out.length);
342
+ out += `${i + 1} 0 obj\n${body}\nendobj\n`;
343
+ });
344
+ const xref = out.length;
345
+ out += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
346
+ for (const offset of offsets) out += `${String(offset).padStart(10, '0')} 00000 n \n`;
347
+ out += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R /Info 6 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
348
+ return out;
349
+ }
350
+ }
351
+
352
+ /** `D:YYYYMMDDHHmmSSZ`, in UTC. */
353
+ function pdfDate(date: Date): string {
354
+ const pad = (value: number): string => String(value).padStart(2, '0');
355
+ return `D:${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}Z`;
356
+ }