| 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 | */ |
| 9 | import { type Block, type Span, parseBlocks, spanText } from "./file-markdown.ts"; |
| 10 | |
| 11 | const PAGE_W = 612; |
| 12 | const PAGE_H = 792; |
| 13 | const MARGIN_X = 64; |
| 14 | const MARGIN_TOP = 64; |
| 15 | const MARGIN_BOTTOM = 64; |
| 16 | const WIDTH = PAGE_W - 2 * MARGIN_X; |
| 17 | /** The most pages one file gets; the rest is cut, and said so. */ |
| 18 | export const MAX_PDF_PAGES = 300; |
| 19 | |
| 20 | type Font = "R" | "B" | "I" | "BI" | "M"; |
| 21 | const FONT_NAMES: Record<Font, string> = { R: "Helvetica", B: "Helvetica-Bold", I: "Helvetica-Oblique", BI: "Helvetica-BoldOblique", M: "Courier" }; |
| 22 | const 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). |
| 25 | const 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); |
| 28 | const 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. */ |
| 33 | const 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. */ |
| 39 | const 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. */ |
| 42 | export 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 | |
| 61 | function 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. */ |
| 75 | export 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. */ |
| 82 | function 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 | |
| 92 | const n = (v: number) => (Math.round(v * 100) / 100).toString(); |
| 93 | |
| 94 | type Color = [number, number, number]; |
| 95 | const INK: Color = [0.1, 0.1, 0.12]; |
| 96 | const MUTED: Color = [0.38, 0.38, 0.42]; |
| 97 | const LINK: Color = [0.16, 0.36, 0.78]; |
| 98 | |
| 99 | type Piece = { text: string; font: Font; size: number; x: number; width: number; color: Color; href?: string }; |
| 100 | |
| 101 | function 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. */ |
| 109 | function 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 | |
| 164 | type Annot = { rect: [number, number, number, number]; uri: string }; |
| 165 | type Page = { ops: string[]; annots: Annot[] }; |
| 166 | |
| 167 | /** The document being laid out: its pages, and where the next line goes. */ |
| 168 | class 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 | |
| 232 | const BODY = 10.5; |
| 233 | const LEAD = 15; |
| 234 | const 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 | |
| 240 | function 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). */ |
| 286 | export 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. */ |
| 363 | function 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 | } |