@zumer/snapdom-plugins 3.0.0 → 4.0.1

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.
Files changed (77) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +50 -16
  3. package/agent-map.d.ts +32 -0
  4. package/agent-map.js +5 -3
  5. package/ascii-export.d.ts +10 -0
  6. package/ascii-export.js +21 -6
  7. package/capture-frames.js +5 -5
  8. package/color-tint.js +4 -0
  9. package/context-export.d.ts +23 -0
  10. package/context-export.js +4 -1
  11. package/filter.js +1 -1
  12. package/gif-export.d.ts +6 -0
  13. package/gif-export.js +3 -3
  14. package/html-export.d.ts +8 -0
  15. package/html-export.js +6 -4
  16. package/index.d.ts +2 -1
  17. package/index.js +2 -1
  18. package/package.json +22 -6
  19. package/pdf/LICENSE +21 -0
  20. package/pdf/README.md +22 -0
  21. package/pdf/REFERENCE.md +520 -0
  22. package/pdf/src/frontmatter.js +200 -0
  23. package/pdf/src/furniture.js +398 -0
  24. package/pdf/src/index.js +2415 -0
  25. package/pdf/src/mega/README.md +47 -0
  26. package/pdf/src/mega/diag.js +148 -0
  27. package/pdf/src/mega/flow.js +56 -0
  28. package/pdf/src/mega/fonts.js +287 -0
  29. package/pdf/src/mega/forms.js +479 -0
  30. package/pdf/src/mega/labels.js +43 -0
  31. package/pdf/src/mega/meta.js +145 -0
  32. package/pdf/src/nav.js +357 -0
  33. package/pdf/src/paginate.js +583 -0
  34. package/pdf/src/tagged.js +355 -0
  35. package/pdf/src/text-layer.js +1771 -0
  36. package/pdf/src/writer/encrypt.js +218 -0
  37. package/pdf/src/writer/font.js +507 -0
  38. package/pdf/src/writer/index.js +1135 -0
  39. package/pdf/src/writer/subset.js +387 -0
  40. package/pdf.d.ts +294 -0
  41. package/pdf.js +1 -0
  42. package/redact-inputs.js +6 -6
  43. package/vector/CONTRACT.md +906 -0
  44. package/vector/LICENSE +21 -0
  45. package/vector/README.md +35 -0
  46. package/vector/src/adapters/snapdom.js +795 -0
  47. package/vector/src/collect/box.js +1676 -0
  48. package/vector/src/collect/font.js +487 -0
  49. package/vector/src/collect/image.js +1458 -0
  50. package/vector/src/collect/mathml.js +272 -0
  51. package/vector/src/collect/text.js +3027 -0
  52. package/vector/src/css/color.js +823 -0
  53. package/vector/src/css/gradient.js +1126 -0
  54. package/vector/src/css/matrix.js +398 -0
  55. package/vector/src/css/radius.js +124 -0
  56. package/vector/src/css/shadow.js +201 -0
  57. package/vector/src/emit/figma-json.js +1995 -0
  58. package/vector/src/emit/h2d-vector.js +215 -0
  59. package/vector/src/emit/h2d.js +1147 -0
  60. package/vector/src/emit/pdf.js +2571 -0
  61. package/vector/src/emit/svg-flat.js +4842 -0
  62. package/vector/src/exclusion.js +13 -0
  63. package/vector/src/index.js +2910 -0
  64. package/vector/src/paint.js +2908 -0
  65. package/vector/src/public.js +57 -0
  66. package/vector/src/writer/encrypt.js +227 -0
  67. package/vector/src/writer/font.js +507 -0
  68. package/vector/src/writer/index.js +1148 -0
  69. package/vector/src/writer/subset.js +387 -0
  70. package/vector/svd/index.js +72 -0
  71. package/vector/svd/schema.js +985 -0
  72. package/vector.d.ts +28 -0
  73. package/vector.js +1 -0
  74. package/video-export.d.ts +6 -0
  75. package/video-export.js +7 -7
  76. package/pdf-image.d.ts +0 -18
  77. package/pdf-image.js +0 -114
@@ -0,0 +1,1135 @@
1
+ /**
2
+ * Minimal PDF 1.7 writer: page tree, image XObjects, fonts (base-14 plus one
3
+ * Type0 for everything else), link and widget annotations. 1.7 because it is
4
+ * ISO 32000-1 — the last numbered version every reader in use parses — and
5
+ * because the structure types the table model needs (`THead`, `TBody`, `TFoot`)
6
+ * arrived in 1.5; nothing here emits a 1.5+ FILE STRUCTURE feature (no object
7
+ * streams, no xref streams), so a 1.4-era parser still reads these bytes.
8
+ *
9
+ * **This is the byte level, and it stays there.** Nothing in this directory may
10
+ * know what it is drawing: no text layers, no page model, no layout. If a helper
11
+ * needs to know who called it, it belongs in the caller. That line is what keeps
12
+ * the writer testable on its own and the exporter free to change above it.
13
+ *
14
+ * It began life as `@zumer/pdf-writer`, a package shared with the sibling vector
15
+ * product inside a monorepo. This repository owns its copy outright: the two
16
+ * are free to diverge, and neither can break the other. `setWarnPrefix` survives
17
+ * from that arrangement and is still worth having — it is what puts the product's
18
+ * own name in front of a warning.
19
+ *
20
+ * createPdfDoc itself never compresses: it builds dictionaries and counts
21
+ * /Length over whatever bytes it is handed, which is what keeps it synchronous
22
+ * and lets a debug build stay readable with `strings`. Compression is opt-in and
23
+ * visible. deflate() is exported for the caller to run over its content streams,
24
+ * and encodeImage deflates the /SMask plane, which is opaque binary either way.
25
+ */
26
+
27
+ /**
28
+ * Whose name goes in front of a warning from this file. Neutral by default; each
29
+ * plugin sets its own on load, so a message always names the product the reader
30
+ * actually bought.
31
+ */
32
+ import { buildEncryption, AES256_EXTENSION } from './encrypt.js'
33
+
34
+ let prefix = '[pdf-writer]'
35
+
36
+ /** @param {string} name e.g. `[snapdom-pdf]` */
37
+ export function setWarnPrefix (name) {
38
+ if (typeof name === 'string' && name) prefix = name
39
+ }
40
+
41
+ const warn = (msg) => console.warn(`${prefix} ${msg}`)
42
+
43
+ /**
44
+ * 128 bits of file identity from the file's own bytes: four FNV-1a 32-bit lanes
45
+ * over the same stream, differently seeded. Not cryptographic and not trying to
46
+ * be — a trailer /ID exists so tools can tell "same document" from "different
47
+ * document", and a content hash answers exactly that while keeping the bytes
48
+ * deterministic. Plain 32-bit arithmetic so a 16 MB file hashes in milliseconds.
49
+ */
50
+ function contentHash(chunks) {
51
+ // Lane 0 is the standard FNV-1a offset basis; the rest are its successive
52
+ // FNV-mixes, so no two lanes ever start equal.
53
+ const seeds = [0x811c9dc5, 0xaf63bd4c, 0x84222325, 0xcbf29ce4]
54
+ const h = seeds.slice()
55
+ for (const chunk of chunks) {
56
+ for (let i = 0; i < chunk.length; i++) {
57
+ const byte = chunk[i]
58
+ for (let lane = 0; lane < 4; lane++) {
59
+ // FNV-1a: xor the byte, multiply by the 32-bit FNV prime 16777619.
60
+ const x = h[lane] ^ byte
61
+ h[lane] = (x + (x << 1) + (x << 4) + (x << 7) + (x << 8) + (x << 24)) >>> 0
62
+ }
63
+ }
64
+ }
65
+ return h.map(n => n.toString(16).padStart(8, '0')).join('').toUpperCase()
66
+ }
67
+
68
+ /** PDF syntax is ASCII and PDF strings are byte strings — never UTF-8 encode. */
69
+ function latin1(str) {
70
+ const out = new Uint8Array(str.length)
71
+ for (let i = 0; i < str.length; i++) out[i] = str.charCodeAt(i) & 0xff
72
+ return out
73
+ }
74
+
75
+ /**
76
+ * WinAnsi holds the typographic punctuation real copy is full of at 0x80–0x9F,
77
+ * outside Latin-1. Without this, curly quotes and dashes silently drop out of
78
+ * the text layer — which is most of the punctuation on a designed page.
79
+ */
80
+ const WINANSI = new Map(Object.entries({
81
+ 8364: 0x80, 8218: 0x82, 402: 0x83, 8222: 0x84, 8230: 0x85, 8224: 0x86, 8225: 0x87,
82
+ 710: 0x88, 8240: 0x89, 352: 0x8a, 8249: 0x8b, 338: 0x8c, 381: 0x8e, 8216: 0x91,
83
+ 8217: 0x92, 8220: 0x93, 8221: 0x94, 8226: 0x95, 8211: 0x96, 8212: 0x97, 732: 0x98,
84
+ 8482: 0x99, 353: 0x9a, 8250: 0x9b, 339: 0x9c, 382: 0x9e, 376: 0x9f,
85
+ }).map(([k, v]) => [Number(k), v]))
86
+
87
+ /**
88
+ * Escape a JS string into a PDF literal string.
89
+ * Returns null when the text carries a codepoint the base-14 WinAnsi fonts
90
+ * cannot address — the caller drops that run rather than writing mojibake.
91
+ */
92
+ export function pdfString(text) {
93
+ let out = '('
94
+ for (const ch of text) {
95
+ let code = ch.codePointAt(0)
96
+ if (code > 255) {
97
+ const mapped = WINANSI.get(code)
98
+ if (mapped === undefined) return null
99
+ code = mapped
100
+ }
101
+ if (ch === '(' || ch === ')' || ch === '\\') out += '\\' + ch
102
+ else if (code < 32) out += ' '
103
+ else out += String.fromCharCode(code)
104
+ }
105
+ return out + ')'
106
+ }
107
+
108
+ /**
109
+ * Escape a JS string into a PDF **text string** — the kind that goes in a
110
+ * `/Title`, not in a content stream.
111
+ *
112
+ * Unlike `pdfString` this never returns null and never drops a codepoint: what
113
+ * WinAnsi cannot address goes out as UTF-16BE hex with a BOM, which every PDF
114
+ * consumer since 1.2 reads. It is a different job from `pdfString`, which writes
115
+ * the bytes a base-14 font will SHOW and therefore has to fail when no glyph
116
+ * exists. Nothing paints a text string, so there is no glyph to be missing.
117
+ *
118
+ * ASCII stays a literal string, so an outline of Latin headings reads in the
119
+ * clear with `strings` and costs no more bytes than it did before.
120
+ */
121
+ export function pdfTextString(text) {
122
+ const str = String(text)
123
+ // eslint-disable-next-line no-control-regex
124
+ if (!/[^\x20-\x7e]/.test(str)) {
125
+ return '(' + str.replace(/[\\()]/g, (c) => '\\' + c) + ')'
126
+ }
127
+ let hex = 'FEFF'
128
+ for (let i = 0; i < str.length; i++) hex += hex4(str.charCodeAt(i))
129
+ return `<${hex}>`
130
+ }
131
+
132
+ /**
133
+ * Every PDF string inside one object body, as `[start, end)` offsets.
134
+ *
135
+ * Encryption has to find them all, and "all" is the operative word: a string
136
+ * left in the clear is not a smaller feature, it is a document whose title or
137
+ * link comes back as mojibake, because the reader decrypts what was never
138
+ * encrypted. Marking them at each call site was the other design and it was
139
+ * rejected for exactly that reason — `/URI` and `/DA` do not go through the same
140
+ * helper as `/Title`, and the next one added would not either.
141
+ *
142
+ * So this reads the grammar instead of trusting a convention. Object bodies at
143
+ * this level are dictionaries, names, numbers, arrays and the two string forms;
144
+ * a scanner that knows `<<` from `<` cannot be fooled by output this writer
145
+ * produced, and it finds strings nobody remembered to enumerate.
146
+ */
147
+ function scanStrings(text) {
148
+ const found = []
149
+ for (let i = 0; i < text.length; i++) {
150
+ const c = text[i]
151
+ if (c === '<') {
152
+ if (text[i + 1] === '<') { i++; continue } // dictionary, not a string
153
+ const end = text.indexOf('>', i + 1)
154
+ if (end < 0) break
155
+ found.push([i, end + 1])
156
+ i = end
157
+ } else if (c === '(') {
158
+ // Literal strings nest, and a backslash escapes the next byte whatever it is.
159
+ let depth = 1
160
+ let j = i + 1
161
+ for (; j < text.length && depth; j++) {
162
+ if (text[j] === '\\') j++
163
+ else if (text[j] === '(') depth++
164
+ else if (text[j] === ')') depth--
165
+ }
166
+ found.push([i, j])
167
+ i = j - 1
168
+ }
169
+ }
170
+ return found
171
+ }
172
+
173
+ /** A literal or hex PDF string, back to the bytes it stands for. */
174
+ function stringBytes(token) {
175
+ if (token[0] === '<') {
176
+ const hex = token.slice(1, -1).replace(/[^0-9a-fA-F]/g, '')
177
+ const even = hex.length % 2 ? hex + '0' : hex
178
+ const out = new Uint8Array(even.length / 2)
179
+ for (let i = 0; i < out.length; i++) out[i] = parseInt(even.substr(i * 2, 2), 16)
180
+ return out
181
+ }
182
+ const body = token.slice(1, -1)
183
+ const out = []
184
+ for (let i = 0; i < body.length; i++) {
185
+ if (body[i] !== '\\') { out.push(body.charCodeAt(i) & 0xff); continue }
186
+ const n = body[++i]
187
+ const simple = { n: 10, r: 13, t: 9, b: 8, f: 12 }[n]
188
+ if (simple !== undefined) out.push(simple)
189
+ else if (n >= '0' && n <= '7') {
190
+ let oct = n
191
+ while (oct.length < 3 && body[i + 1] >= '0' && body[i + 1] <= '7') oct += body[++i]
192
+ out.push(parseInt(oct, 8) & 0xff)
193
+ } else if (n === '\n') { /* line continuation: nothing */ }
194
+ else out.push(n.charCodeAt(0) & 0xff)
195
+ }
196
+ return new Uint8Array(out)
197
+ }
198
+
199
+ export function createPdfDoc() {
200
+ // 1-indexed: object 0 is the free-list head and never written.
201
+ const objects = [null]
202
+ /** The /Encrypt object once `encrypt()` has run: the trailer has to point at it. */
203
+ let encryptId = null
204
+
205
+ function add(parts) {
206
+ objects.push(Array.isArray(parts) ? parts : [parts])
207
+ return objects.length - 1
208
+ }
209
+
210
+ /** Reserve an id before its body exists (page ↔ pages tree is circular). */
211
+ function reserve() {
212
+ objects.push(undefined)
213
+ return objects.length - 1
214
+ }
215
+
216
+ function fill(id, parts) {
217
+ objects[id] = Array.isArray(parts) ? parts : [parts]
218
+ }
219
+
220
+ /**
221
+ * @param {string} dict - dictionary WITHOUT /Length, which is computed here.
222
+ * /Length counts the bytes actually written, so a pre-deflated payload needs
223
+ * no special handling — pass the compressed bytes and put /Filter in `dict`.
224
+ */
225
+ function streamParts(dict, data) {
226
+ const bytes = typeof data === 'string' ? latin1(data) : data
227
+ return [`<< ${dict} /Length ${bytes.length} >>\nstream\n`, bytes, '\nendstream']
228
+ }
229
+
230
+ function addStream(dict, data) {
231
+ return add(streamParts(dict, data))
232
+ }
233
+
234
+ /** A stream whose id had to exist before its bytes did (fonts reference theirs). */
235
+ function fillStream(id, dict, data) {
236
+ fill(id, streamParts(dict, data))
237
+ }
238
+
239
+ /**
240
+ * Encrypt the whole document in place, then hand back the catalog entry it
241
+ * needs. Call it once, AFTER every object is filled and BEFORE `build()`:
242
+ * an object written afterwards would travel in the clear inside a file that
243
+ * says everything is encrypted, and no reader would recover it.
244
+ *
245
+ * Revision 6 uses the file key for every object, so nothing here depends on
246
+ * object numbers — the classic way to corrupt an encrypted PDF is to derive a
247
+ * per-object key from the wrong one, and that mistake is not available.
248
+ *
249
+ * @param {number} rootId the /Catalog, which this also stamps with the
250
+ * extension level that makes AES-256 legible to an ISO 32000-1 reader. It is
251
+ * done here rather than by the caller because the stamp has to land AFTER
252
+ * the walk — the catalog carries strings of its own (`/Lang`), and one filled
253
+ * in afterwards would sit in the clear inside a file that says it is not.
254
+ * @param {object} spec `{userPassword, ownerPassword, permissions}`
255
+ * @returns {Promise<{p: number}>} the permission value actually written.
256
+ */
257
+ async function encrypt(rootId, spec) {
258
+ if (encryptId !== null) throw new Error(`${prefix} the document is already encrypted`)
259
+ if (typeof crypto?.subtle?.encrypt !== 'function') {
260
+ throw new Error(`${prefix} encryption needs WebCrypto, which needs a secure ` +
261
+ 'context — serve the page over https or localhost')
262
+ }
263
+ const handler = await buildEncryption({ ...spec, warn })
264
+
265
+ // Its own strings are the way IN to the file and are never encrypted, so the
266
+ // dictionary is added after the walk rather than exempted during it.
267
+ for (let id = 1; id < objects.length; id++) {
268
+ const parts = objects[id]
269
+ if (!parts) continue
270
+ for (let i = 0; i < parts.length; i++) {
271
+ const part = parts[i]
272
+ if (typeof part !== 'string') {
273
+ // A non-string part is a stream payload, and `streamParts` always put
274
+ // the dictionary carrying its /Length immediately before it.
275
+ parts[i] = await handler.encrypt(part)
276
+ parts[i - 1] = String(parts[i - 1]).replace(
277
+ /\/Length \d+ >>\nstream\n$/, `/Length ${parts[i].length} >>\nstream\n`)
278
+ continue
279
+ }
280
+ const found = scanStrings(part)
281
+ if (!found.length) continue
282
+ let out = ''
283
+ let at = 0
284
+ for (const [from, to] of found) {
285
+ const bytes = await handler.encrypt(stringBytes(part.slice(from, to)))
286
+ out += part.slice(at, from) + '<' +
287
+ [...bytes].map(b => b.toString(16).padStart(2, '0')).join('') + '>'
288
+ at = to
289
+ }
290
+ parts[i] = out + part.slice(at)
291
+ }
292
+ }
293
+ const catalog = objects[rootId]
294
+ if (!catalog) throw new Error(`${prefix} the catalog must be filled before encrypting`)
295
+ const last = catalog.length - 1
296
+ catalog[last] = String(catalog[last]).replace(/>>\s*$/, `${AES256_EXTENSION} >>`)
297
+ encryptId = add(`<< ${handler.dict} >>`)
298
+ return { p: handler.p }
299
+ }
300
+
301
+ /**
302
+ * @param {number} rootId the /Catalog object id
303
+ * @param {object} [trailer]
304
+ * @param {number|null} [trailer.infoId] a /Info dictionary's object id
305
+ * @param {boolean} [trailer.fileId] write a trailer /ID derived from the
306
+ * file's own bytes. Deterministic on purpose: the same unencrypted document
307
+ * produces the same ID, which is the byte-determinism contract extended to
308
+ * the one field the spec suggests randomising. An encrypted file carries
309
+ * random IVs, so its ID differs per export as well. Both halves are equal
310
+ * because this writer never updates a file incrementally, so there is no
311
+ * "original" to differ from.
312
+ */
313
+ function build(rootId, { infoId = null, fileId = false } = {}) {
314
+ const chunks = []
315
+ let offset = 0
316
+ const push = (d) => {
317
+ const u8 = typeof d === 'string' ? latin1(d) : d
318
+ chunks.push(u8)
319
+ offset += u8.length
320
+ }
321
+
322
+ // The binary comment marks the file as non-ASCII so transfers stay in binary mode.
323
+ push('%PDF-1.7\n%\xE2\xE3\xCF\xD3\n')
324
+
325
+ const offsets = []
326
+ for (let id = 1; id < objects.length; id++) {
327
+ offsets[id] = offset
328
+ push(`${id} 0 obj\n`)
329
+ for (const part of objects[id]) push(part)
330
+ push('\nendobj\n')
331
+ }
332
+
333
+ const xref = offset
334
+ push(`xref\n0 ${objects.length}\n0000000000 65535 f \n`)
335
+ for (let id = 1; id < objects.length; id++) {
336
+ push(`${String(offsets[id]).padStart(10, '0')} 00000 n \n`)
337
+ }
338
+ // Hashed over everything above the trailer — header, objects and xref — so
339
+ // any content change moves the ID and identical exports share one.
340
+ const id = fileId ? contentHash(chunks) : null
341
+ push(`trailer\n<< /Size ${objects.length} /Root ${rootId} 0 R` +
342
+ (infoId ? ` /Info ${infoId} 0 R` : '') +
343
+ (encryptId ? ` /Encrypt ${encryptId} 0 R` : '') +
344
+ (id ? ` /ID [<${id}> <${id}>]` : '') +
345
+ ` >>\nstartxref\n${xref}\n%%EOF\n`)
346
+
347
+ const out = new Uint8Array(offset)
348
+ let at = 0
349
+ for (const c of chunks) { out.set(c, at); at += c.length }
350
+ return out
351
+ }
352
+
353
+ return { add, reserve, fill, addStream, fillStream, encrypt, build }
354
+ }
355
+
356
+ // ——— document structure ———————————————————————————————————————
357
+ //
358
+ // Outlines and the destination name tree are the two places where a PDF says
359
+ // something about the SHAPE of the document rather than about its ink, which is
360
+ // why they live here and not in a plugin: neither one knows or cares whether the
361
+ // page under it is a raster with a text layer over it or a page of drawn paths.
362
+ // Both take destinations as finished PDF array strings, because only the caller
363
+ // knows where its pages ended up.
364
+
365
+ /**
366
+ * One level of the outline, written bottom-up so every sibling knows its
367
+ * neighbours. Reserving the whole level's ids first is what makes /Prev and /Next
368
+ * writable in one pass.
369
+ *
370
+ * @returns {{first:number, last:number, open:number}} `open` is the number of
371
+ * entries a viewer will SHOW with this level expanded — the level's own count
372
+ * plus every open descendant, which is what a parent's /Count has to carry.
373
+ */
374
+ function outlineLevel(doc, nodes, parentId) {
375
+ const ids = nodes.map(() => doc.reserve())
376
+ let open = nodes.length
377
+ nodes.forEach((node, i) => {
378
+ let kids = ''
379
+ if (node.children && node.children.length) {
380
+ const sub = outlineLevel(doc, node.children, ids[i])
381
+ kids = ` /First ${sub.first} 0 R /Last ${sub.last} 0 R /Count ${sub.open}`
382
+ open += sub.open
383
+ }
384
+ doc.fill(ids[i],
385
+ `<< /Title ${pdfTextString(node.title)} /Parent ${parentId} 0 R` +
386
+ (i > 0 ? ` /Prev ${ids[i - 1]} 0 R` : '') +
387
+ (i < ids.length - 1 ? ` /Next ${ids[i + 1]} 0 R` : '') +
388
+ kids + (node.dest ? ` /Dest ${node.dest}` : '') + ' >>')
389
+ })
390
+ return { first: ids[0], last: ids[ids.length - 1], open }
391
+ }
392
+
393
+ /**
394
+ * Write the outline tree — what a viewer's sidebar calls bookmarks.
395
+ *
396
+ * Every level ships EXPANDED (a positive /Count): an outline exists to show the
397
+ * reader the shape of the document, and one that opens collapsed to a single row
398
+ * shows them nothing.
399
+ *
400
+ * @param {ReturnType<createPdfDoc>} doc
401
+ * @param {Array<{title:string, dest:(string|null), children:Array}>} nodes
402
+ * @returns {number|null} the /Outlines object id, or null for an empty tree
403
+ */
404
+ export function writeOutlines(doc, nodes) {
405
+ if (!nodes || !nodes.length) return null
406
+ const rootId = doc.reserve()
407
+ const { first, last, open } = outlineLevel(doc, nodes, rootId)
408
+ doc.fill(rootId, `<< /Type /Outlines /First ${first} 0 R /Last ${last} 0 R /Count ${open} >>`)
409
+ return rootId
410
+ }
411
+
412
+ /**
413
+ * Write a `/Dests` name tree — the named destinations that make `file.pdf#name`
414
+ * work from outside the document, and that a cross-reference can point at.
415
+ *
416
+ * One node, no /Limits: the spec only requires balancing for trees big enough to
417
+ * need it, and a document's headings do not reach that. Names MUST come out
418
+ * sorted or a conforming reader binary-searches its way to the wrong entry —
419
+ * sorted here by UTF-16 code unit, which is byte order for every name an `id`
420
+ * attribute realistically holds.
421
+ *
422
+ * @param {ReturnType<createPdfDoc>} doc
423
+ * @param {Array<[string, string]>} entries `[name, destination array string]`
424
+ * @returns {number|null} the name-tree object id, or null when there is nothing
425
+ */
426
+ export function writeNameTree(doc, entries) {
427
+ const list = [...entries].filter(([name]) => name).sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
428
+ if (!list.length) return null
429
+ return doc.add(`<< /Names [${list.map(([name, dest]) => `${pdfTextString(name)} ${dest}`).join(' ')}] >>`)
430
+ }
431
+
432
+ /**
433
+ * Write the structure tree of a TAGGED PDF: `/StructTreeRoot`, one `/StructElem`
434
+ * per node, and the `/ParentTree` that maps marked content back to it.
435
+ *
436
+ * The tree is the caller's — this file has no idea what an `H2` is or why one
437
+ * paragraph follows another. What it owns is the shape PDF demands, and that
438
+ * shape has one trap worth naming: **the parent tree is not optional and not a
439
+ * convenience.** A reader walks structure downwards through `/K`, but it resolves
440
+ * a click, a selection or a screen-reader cursor upwards from a marked-content id
441
+ * on a page, and the only route back up is this tree. Written wrong, the file
442
+ * looks perfectly tagged in a structure inspector and does nothing for the reader
443
+ * it was made for.
444
+ *
445
+ * Content is referenced with explicit `/MCR` dictionaries rather than bare
446
+ * integers, because a single structure element routinely spans pages here — a
447
+ * paragraph cut by a page break is one paragraph — and a bare integer can only
448
+ * mean "on this element's one `/Pg`".
449
+ *
450
+ * Annotations join the tree through the same two routes marked content does: an
451
+ * `/OBJR` in the owning node's `/K` (downwards), and a `/StructParent` integer on
452
+ * the annotation resolved through the `/ParentTree` (upwards). A mark entry of
453
+ * the shape `{ objr, page }` — `objr` being the ANNOTATION's object id — writes
454
+ * the first; the `annots` argument writes the second. Both are per-export state
455
+ * and live outside the tree, for the same reason `marksOf` does.
456
+ *
457
+ * @param {ReturnType<createPdfDoc>} doc
458
+ * @param {object} tree the root node: `{ type, alt, lang, attrs, children }`,
459
+ * where a child is either another node or a mark `{ mcid, page }`, `page` being
460
+ * a page OBJECT ID. Marks and nodes share one list, in reading order. `attrs`
461
+ * is an optional `{ scope, colSpan, rowSpan }` written as a table-owner `/A`
462
+ * dictionary — `TH` scope is the reason it exists.
463
+ * @param {number[]} structParents per page index, the `/StructParents` key that
464
+ * page was given. Its position in this array is that page's index.
465
+ * @param {Array<Array<object>>} owners `owners[pageIndex][mcid]` is the node that
466
+ * owns that marked-content id on that page.
467
+ * @param {Map<object, Array<{mcid:number, page:number}|{objr:number, page:number}>>}
468
+ * marksOf the content each node owns, held OUTSIDE the tree on purpose: the
469
+ * tree is measured once per capture and a capture may be exported more than
470
+ * once, so writing marks into it would make the second export carry the first
471
+ * one's content as well.
472
+ * @param {Array<{key:number, node:object}>} [annots] `/StructParent` keys handed
473
+ * to annotations, each resolving to its owning node. Keys MUST continue past
474
+ * every page's `/StructParents` key: the parent tree is one number tree and a
475
+ * collision would route a widget's click to a paragraph.
476
+ * @returns {number|null} the `/StructTreeRoot` id, or null when nothing was marked
477
+ */
478
+ export function writeStructTree(doc, tree, structParents, owners, marksOf, annots = []) {
479
+ if (!tree) return null
480
+ const marks = (node) => marksOf.get(node) || []
481
+ // A branch nobody marked describes nothing, and a reader that walks into one
482
+ // finds an empty room. Pruned by reading, never by mutating the caller's tree.
483
+ // Some semantic objects (notably an image-only Figure with /Alt) have no marked
484
+ // text content by design. `keep` lets the structure collector retain those
485
+ // meaningful leaves without inventing an MCID or a broken ParentTree entry.
486
+ const alive = (node) => marks(node).length > 0 || node.keep === true || node.children.some(alive)
487
+ const kept = tree.children.filter(alive)
488
+ if (!kept.length) return null
489
+
490
+ const rootId = doc.reserve()
491
+ const ids = new Map()
492
+
493
+ /** Depth-first, ids reserved before bodies so /P can point back at the parent. */
494
+ const assign = (node) => {
495
+ ids.set(node, doc.reserve())
496
+ for (const child of node.children) if (alive(child)) assign(child)
497
+ }
498
+ for (const child of kept) assign(child)
499
+
500
+ /** The table-owner attribute dictionary, when the node carries table facts. */
501
+ const attrsOf = (node) => {
502
+ const a = node.attrs
503
+ if (!a) return ''
504
+ const parts = []
505
+ if (a.scope) parts.push(`/Scope /${a.scope}`)
506
+ if (a.colSpan > 1) parts.push(`/ColSpan ${a.colSpan}`)
507
+ if (a.rowSpan > 1) parts.push(`/RowSpan ${a.rowSpan}`)
508
+ return parts.length ? ` /A << /O /Table ${parts.join(' ')} >>` : ''
509
+ }
510
+
511
+ const fill = (node, parentId) => {
512
+ const id = ids.get(node)
513
+ // The node's own words come before its nested structure. `buildStructure`
514
+ // guarantees this is the whole story: a container that also carries loose text
515
+ // gets an implicit paragraph, so no node ever interleaves the two.
516
+ const kids = [
517
+ ...marks(node).map(m => m.objr
518
+ ? `<< /Type /OBJR /Pg ${m.page} 0 R /Obj ${m.objr} 0 R >>`
519
+ : `<< /Type /MCR /Pg ${m.page} 0 R /MCID ${m.mcid} >>`),
520
+ ...node.children.filter(alive).map(child => `${ids.get(child)} 0 R`),
521
+ ]
522
+ doc.fill(id,
523
+ `<< /Type /StructElem /S /${node.type} /P ${parentId} 0 R` +
524
+ (node.alt ? ` /Alt ${pdfTextString(node.alt)}` : '') +
525
+ (node.lang ? ` /Lang ${pdfTextString(node.lang)}` : '') +
526
+ attrsOf(node) +
527
+ ` /K [${kids.join(' ')}] >>`)
528
+ for (const child of node.children) if (alive(child)) fill(child, id)
529
+ }
530
+ for (const child of kept) fill(child, rootId)
531
+
532
+ // The number tree, one entry per page that carries marked content. The array at
533
+ // each key is indexed BY MCID, so a gap would misalign every id after it — the
534
+ // caller hands these in already dense. Annotation keys follow the page keys —
535
+ // a number tree's keys must come out ascending, and the caller allocates annot
536
+ // keys past every page key, so appending keeps the order.
537
+ const nums = []
538
+ for (let p = 0; p < owners.length; p++) {
539
+ const list = owners[p]
540
+ if (!list || !list.length) continue
541
+ nums.push(`${structParents[p]} [${list.map(n => `${ids.get(n)} 0 R`).join(' ')}]`)
542
+ }
543
+ let nextKey = structParents.length
544
+ for (const { key, node } of annots) {
545
+ if (!ids.has(node)) continue
546
+ nums.push(`${key} ${ids.get(node)} 0 R`)
547
+ if (key >= nextKey) nextKey = key + 1
548
+ }
549
+ const parentTree = doc.add(`<< /Nums [${nums.join(' ')}] >>`)
550
+
551
+ doc.fill(rootId,
552
+ `<< /Type /StructTreeRoot /K [${kept.map(c => `${ids.get(c)} 0 R`).join(' ')}] ` +
553
+ `/ParentTree ${parentTree} 0 R /ParentTreeNextKey ${nextKey} >>`)
554
+ return rootId
555
+ }
556
+
557
+ /**
558
+ * False on runtimes without CompressionStream. The caller must consult it: with
559
+ * no compression deflate() passes the bytes through, and a /Filter /FlateDecode
560
+ * written over uncompressed bytes is an unreadable file, not a bigger one.
561
+ */
562
+ export const canDeflate = typeof CompressionStream !== 'undefined'
563
+
564
+ /**
565
+ * /FlateDecode reads a zlib stream — header and Adler-32 checksum included.
566
+ * CompressionStream('deflate') emits exactly that; 'deflate-raw' emits the bare
567
+ * deflate blocks and would corrupt every stream in the file, so the format name
568
+ * here is load-bearing.
569
+ */
570
+ export async function deflate(data) {
571
+ const bytes = typeof data === 'string' ? latin1(data) : data
572
+ if (!canDeflate) return bytes
573
+ const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream('deflate'))
574
+ return new Uint8Array(await new Response(stream).arrayBuffer())
575
+ }
576
+
577
+ /** Base-14 font resources. Invisible text still needs plausible metrics for Tz. */
578
+ export const BASE14 = {
579
+ 'sans': 'Helvetica',
580
+ 'sans-bold': 'Helvetica-Bold',
581
+ 'sans-italic': 'Helvetica-Oblique',
582
+ 'sans-bolditalic': 'Helvetica-BoldOblique',
583
+ 'serif': 'Times-Roman',
584
+ 'serif-bold': 'Times-Bold',
585
+ 'serif-italic': 'Times-Italic',
586
+ 'serif-bolditalic': 'Times-BoldItalic',
587
+ 'mono': 'Courier',
588
+ 'mono-bold': 'Courier-Bold',
589
+ 'mono-italic': 'Courier-Oblique',
590
+ 'mono-bolditalic': 'Courier-BoldOblique',
591
+ }
592
+
593
+ /**
594
+ * The Unicode font's /DW, in 1/1000 em: every CID advances exactly one em, so a
595
+ * run's natural width is `codepoints × fontSize` and the caller can work out Tz
596
+ * without measuring anything. Do not emit /W — per-CID widths would break that
597
+ * identity and make pdf.js remeasure each glyph. Not exported: the identity is
598
+ * the contract, and any value other than one em would be a different contract.
599
+ */
600
+ const UNICODE_ADVANCE = 1000
601
+
602
+ /**
603
+ * An invented name on purpose. Any name a viewer recognises as standard (Arial,
604
+ * Helvetica, Times…) sends pdf.js down its standard-font branch, which rebuilds
605
+ * ToUnicode with numeric values and truncates every astral codepoint — U+1F600
606
+ * comes back as U+F600. PascalCase also keeps the viewer's "cannot load system
607
+ * font" notice out of verify.mjs's lowercase-'snapdom' console filter.
608
+ */
609
+ const UNICODE_NAME = 'SnapdomUnicode'
610
+
611
+ const hex4 = (n) => n.toString(16).toUpperCase().padStart(4, '0')
612
+
613
+ /** ToUnicode destinations are UTF-16BE, so astral codepoints go out as a surrogate pair. */
614
+ function utf16be(cp) {
615
+ if (cp <= 0xffff) return hex4(cp)
616
+ const v = cp - 0x10000
617
+ return hex4(0xd800 | (v >> 10)) + hex4(0xdc00 | (v & 0x3ff))
618
+ }
619
+
620
+ /**
621
+ * One document-wide Type0 font for everything WinAnsi cannot address: CJK,
622
+ * Cyrillic, Greek, Arabic, emoji. Nothing is embedded — the layer is invisible,
623
+ * so no glyph is ever painted and extraction runs entirely off /ToUnicode.
624
+ * Bold/serif/mono variants would be meaningless here and would cost one CID
625
+ * table each, hence a single font.
626
+ */
627
+ export function createUnicodeFont(doc) {
628
+ const cids = new Map()
629
+ let id = null
630
+ let descId, cidId, toUniId
631
+
632
+ // Registered on first use so a Latin-only document carries no dead objects.
633
+ function ensure() {
634
+ if (id !== null) return
635
+ descId = doc.reserve()
636
+ cidId = doc.reserve()
637
+ toUniId = doc.reserve()
638
+ // /ToUnicode belongs on the Type0; the descriptor belongs on the descendant.
639
+ // Swapping them is the classic way to get "Base font is not specified".
640
+ id = doc.add(
641
+ `<< /Type /Font /Subtype /Type0 /BaseFont /${UNICODE_NAME} /Encoding /Identity-H ` +
642
+ `/DescendantFonts [${cidId} 0 R] /ToUnicode ${toUniId} 0 R >>`
643
+ )
644
+ }
645
+
646
+ /**
647
+ * Returns a PDF hex string ready for `Tj`, or null when there is nothing to
648
+ * write or the text cannot be represented at all — Identity-H's codespace is
649
+ * two bytes wide. The empty case has to answer null before ensure(): `<>` is a
650
+ * value the caller would happily emit, and it would drag a Type0 font, a
651
+ * CIDFont, a descriptor and an empty CMap into a document that shows nothing.
652
+ */
653
+ function encode(text) {
654
+ if (!text) return null
655
+ ensure()
656
+ let out = '<'
657
+ const added = []
658
+ // Codepoint-wise: a surrogate pair is one CID, which is what makes emoji work.
659
+ for (const ch of text) {
660
+ const cp = ch.codePointAt(0)
661
+ let cid = cids.get(cp)
662
+ if (cid === undefined) {
663
+ if (cids.size >= 0xfffe) {
664
+ // The caller drops the whole run, so nothing this run allocated may
665
+ // survive: otherwise the same text is droppable or not depending on
666
+ // what preceded it, and the CMap carries entries no Tj references.
667
+ for (const done of added) cids.delete(done)
668
+ return null
669
+ }
670
+ cid = cids.size + 1 // never 0 — CID 0 is .notdef and extracts as U+0000
671
+ cids.set(cp, cid)
672
+ added.push(cp)
673
+ }
674
+ out += hex4(cid)
675
+ }
676
+ return out + '>'
677
+ }
678
+
679
+ /** Call exactly once, after the last encode() and before doc.build(). */
680
+ function finalize() {
681
+ if (id === null) return
682
+
683
+ doc.fill(cidId,
684
+ `<< /Type /Font /Subtype /CIDFontType2 /BaseFont /${UNICODE_NAME} ` +
685
+ // Adobe-Identity-0 keeps pdf.js off the UCS2 branch, which would want a
686
+ // CMap file fetched from disk and would fight our own ToUnicode.
687
+ '/CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> ' +
688
+ `/FontDescriptor ${descId} 0 R /DW ${UNICODE_ADVANCE} /CIDToGIDMap /Identity >>`
689
+ )
690
+
691
+ // All nine keys of PDF 1.7 Table 122. pdf.js needs none of them; Acrobat's
692
+ // preflight rejects the font without them, and nothing reads the values
693
+ // because no glyph is ever drawn. /Flags 4 = Symbolic, so viewers do not try
694
+ // to reinterpret our CIDs through a standard encoding.
695
+ doc.fill(descId,
696
+ `<< /Type /FontDescriptor /FontName /${UNICODE_NAME} /Flags 4 ` +
697
+ '/FontBBox [0 -250 1000 1000] /ItalicAngle 0 /Ascent 900 /Descent -250 ' +
698
+ '/CapHeight 700 /StemV 80 >>'
699
+ )
700
+
701
+ const lines = [
702
+ '/CIDInit /ProcSet findresource begin',
703
+ '12 dict begin',
704
+ 'begincmap',
705
+ '/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def',
706
+ '/CMapName /Adobe-Identity-UCS def',
707
+ '/CMapType 2 def',
708
+ '1 begincodespacerange',
709
+ '<0000> <FFFF>',
710
+ 'endcodespacerange',
711
+ ]
712
+ const entries = [...cids].map(([cp, cid]) => `<${hex4(cid)}> <${utf16be(cp)}>`)
713
+ // 100 per section is the spec's hard limit. pdf.js ignores it, Acrobat does not.
714
+ for (let i = 0; i < entries.length; i += 100) {
715
+ const chunk = entries.slice(i, i + 100)
716
+ lines.push(`${chunk.length} beginbfchar`, ...chunk, 'endbfchar')
717
+ }
718
+ lines.push('endcmap', 'CMapName currentdict /CMap defineresource pop', 'end', 'end')
719
+
720
+ // Left uncompressed on purpose. ~14 bytes per codepoint raw, so even an
721
+ // 800-glyph CJK page is 11 KB and deflates to 3 KB — under 1% of a page
722
+ // image, against which finalize() would have to be async and every caller
723
+ // would have to await it.
724
+ doc.fillStream(toUniId, '', lines.join('\n'))
725
+ }
726
+
727
+ // 'FU' cannot collide with the caller's base-14 names, which are F0, F1, …
728
+ return { name: 'FU', get id() { return id }, encode, finalize }
729
+ }
730
+
731
+ /**
732
+ * How a pixel sits inside one 32-bit word of `ImageData`.
733
+ *
734
+ * Reading a pixel as a word instead of four bytes is what makes `survey` cheap,
735
+ * and the byte order that word implies is the platform's, not the format's. Every
736
+ * engine this ships to is little-endian; the big-endian constants exist so that a
737
+ * platform which is not cannot read colour out of the wrong end and produce a
738
+ * file whose pixels are silently rotated.
739
+ */
740
+ const LITTLE_ENDIAN = new Uint8Array(new Uint32Array([1]).buffer)[0] === 1
741
+ const R_SHIFT = LITTLE_ENDIAN ? 0 : 24
742
+ const G_SHIFT = 8 + (LITTLE_ENDIAN ? 0 : 8)
743
+ const B_SHIFT = LITTLE_ENDIAN ? 16 : 8
744
+ const A_SHIFT = LITTLE_ENDIAN ? 24 : 0
745
+ /** `level * GREY_STEP | OPAQUE` is the word an opaque r = g = b pixel has to be. */
746
+ const GREY_STEP = LITTLE_ENDIAN ? 0x010101 : 0x01010100
747
+ const OPAQUE = LITTLE_ENDIAN ? 0xff000000 : 0x000000ff
748
+
749
+ /** ~4 MB of RGBA per read: enough to amortise the readback, small enough not to spike. */
750
+ function bandRows(width) {
751
+ return Math.max(1, Math.floor((1 << 20) / Math.max(1, width)))
752
+ }
753
+
754
+ /**
755
+ * Everything the encoder needs to know about a surface's pixels, learned in ONE
756
+ * banded pass: whether anything is translucent, and the narrowest colour space
757
+ * that reproduces the region exactly — together with the plane already written in
758
+ * it, so the encoding pass has nothing left to read back.
759
+ *
760
+ * This used to be the alpha question alone, asked in its own full readback while
761
+ * `toFlate` did a second one for the colour plane. Both walks visit the same
762
+ * bytes, so they are one walk now — and what it comes back with is what lets the
763
+ * colour plane shrink. MEASURED over this repo's fixtures, 22 of 29 page regions
764
+ * are grey in EVERY pixel and 23 of 29 fit in 256 colours; either one is a THIRD
765
+ * of the bytes of /DeviceRGB, losslessly, because 8-bit grey and a 256-entry
766
+ * palette reproduce those exact pixels.
767
+ *
768
+ * **The walk narrows, it never widens back.** It starts assuming grey, which is
769
+ * the cheapest thing to be — one comparison and one bit in a 32-byte table of the
770
+ * levels seen — and pays for a palette only from the first coloured pixel
771
+ * onwards. That order is the whole performance story: a first cut kept the
772
+ * palette from pixel zero and cost 909 ms across a 46-region export, against
773
+ * 348 ms for the alpha scan it replaced; paying for it only when a region is
774
+ * actually coloured brings it back down, because most regions never are. When
775
+ * colour does arrive, the greys already seen seed the palette in level order —
776
+ * any deterministic order will do, since nothing outside this pass ever sees an
777
+ * index — and the plane written so far is remapped in place.
778
+ *
779
+ * Every pixel is inspected. Sampling could miss the one hole a page really has
780
+ * and flatten it silently, and it could equally miss the one coloured pixel that
781
+ * makes a page not grey — a wrong answer there is not a slower export, it is a
782
+ * different one, so `colorspace.html` fixes exactly that case.
783
+ *
784
+ * Peak memory is one band of RGBA plus the planes: at 3300×4678 that is 15 MB of
785
+ * one-byte colour plane and, only when the capture has holes, 15 MB of alpha —
786
+ * against the 62 MB of RGBA this deliberately never materialises, and the 46 MB
787
+ * of /DeviceRGB the plane replaces. The palette's lookup is two-level for the
788
+ * same reason: a flat 2^24 table is 16 MB, while 256 colours reach at most 256 of
789
+ * its 4096 pages and a real page touches a handful.
790
+ *
791
+ * @param {boolean} [wantPlane=true] false when the caller has pinned a codec that
792
+ * reads no plane, so a region is not walked for an answer nobody will use.
793
+ * @returns {{alpha: Uint8Array|null, space: 'gray'|'indexed'|'rgb',
794
+ * plane: Uint8Array|null, palette: number[]|null}} `plane` is one byte per pixel
795
+ * — a grey level or a palette index — and is null exactly when `space` is
796
+ * 'rgb', which is the one case `toFlate` still has pixels left to read.
797
+ */
798
+ function survey(ctx, width, height, wantPlane = true) {
799
+ const count = width * height
800
+ const rows = bandRows(width)
801
+ let alpha = null
802
+ let at = 0
803
+
804
+ let space = wantPlane ? 'gray' : 'rgb'
805
+ let plane = wantPlane && count ? new Uint8Array(count) : null
806
+ /** Which of the 256 grey levels this region has drawn, for seeding a palette. */
807
+ const greys = wantPlane ? new Uint8Array(256) : null
808
+ /** 0xRRGGBB in assignment order; index 0 is the first entry. */
809
+ let palette = null
810
+ /** key >>> 12 → page holding (index + 1); 0 means "not seen in this region". */
811
+ let pages = null
812
+
813
+ /** Give up on one byte per pixel: the region drew more than a palette holds. */
814
+ const widen = () => { space = 'rgb'; plane = null; palette = null; pages = null }
815
+
816
+ /** Remember a colour, or answer that there is no room left for it. */
817
+ const intern = (key) => {
818
+ const page = pages[key >>> 12] || (pages[key >>> 12] = new Uint16Array(1 << 12))
819
+ const slot = key & 0xfff
820
+ if (page[slot]) return page[slot] - 1
821
+ if (palette.length === 256) return -1
822
+ palette.push(key)
823
+ page[slot] = palette.length
824
+ return palette.length - 1
825
+ }
826
+
827
+ /**
828
+ * The first coloured pixel of a region that was grey until now. The greys
829
+ * already drawn become the palette's opening entries and the plane, which holds
830
+ * levels, is rewritten to hold their indices.
831
+ */
832
+ const openPalette = () => {
833
+ const levels = []
834
+ for (let v = 0; v < 256; v++) if (greys[v]) levels.push(v)
835
+ // 256 greys already, plus the colour that got us here: nothing to gain.
836
+ if (levels.length >= 256) return widen()
837
+ space = 'indexed'
838
+ palette = []
839
+ pages = []
840
+ const index = new Uint8Array(256)
841
+ for (const v of levels) index[v] = intern((v << 16) | (v << 8) | v)
842
+ for (let p = 0; p < at; p++) plane[p] = index[plane[p]]
843
+ }
844
+
845
+ for (let y = 0; y < height; y += rows) {
846
+ const { data } = ctx.getImageData(0, y, width, Math.min(rows, height - y))
847
+ // One word per pixel. `ImageData` has always handed back its own buffer from
848
+ // offset zero, so the copy is a guard against a runtime that stops doing that
849
+ // rather than a path anything has been seen to take.
850
+ const px = data.byteOffset % 4 === 0
851
+ ? new Uint32Array(data.buffer, data.byteOffset, data.byteLength >>> 2)
852
+ : new Uint32Array(data.slice().buffer)
853
+ for (let k = 0; k < px.length; k++, at++) {
854
+ const v = px[k]
855
+ const r = (v >>> R_SHIFT) & 255
856
+ // The whole common case in one comparison: opaque, and grey at this level.
857
+ // It answers the alpha question and the colour question at once, which is
858
+ // why it is worth writing out rather than reading four bytes and asking
859
+ // twice. Once a hole has been found the plane must record every alpha, so
860
+ // the shortcut retires for the rest of the region.
861
+ if (alpha === null && space === 'gray' && v === ((r * GREY_STEP | OPAQUE) >>> 0)) {
862
+ greys[r] = 1
863
+ plane[at] = r
864
+ continue
865
+ }
866
+ const a = (v >>> A_SHIFT) & 255
867
+ if (alpha) alpha[at] = a
868
+ else if (a !== 255) {
869
+ alpha = new Uint8Array(count).fill(255, 0, at)
870
+ alpha[at] = a
871
+ }
872
+ if (space === 'rgb') continue
873
+ const g = (v >>> G_SHIFT) & 255, b = (v >>> B_SHIFT) & 255
874
+ if (space === 'gray') {
875
+ if (r === g && g === b) { greys[r] = 1; plane[at] = r; continue }
876
+ openPalette()
877
+ if (space === 'rgb') continue
878
+ }
879
+ const slot = intern((r << 16) | (g << 8) | b)
880
+ if (slot < 0) { widen(); continue }
881
+ plane[at] = slot
882
+ }
883
+ }
884
+ return { alpha, space, plane, palette }
885
+ }
886
+
887
+ /** What a surface looks like when nothing was surveyed: the widest space, no mask. */
888
+ const UNSURVEYED = { alpha: null, space: 'rgb', plane: null, palette: null }
889
+
890
+ /** The /ColorSpace a survey earned, written the way the image dictionary wants it. */
891
+ function flateSpace(seen) {
892
+ if (seen.space === 'gray') return '/DeviceGray'
893
+ if (seen.space !== 'indexed') return '/DeviceRGB'
894
+ let hex = ''
895
+ for (const key of seen.palette) hex += key.toString(16).padStart(6, '0')
896
+ return `[/Indexed /DeviceRGB ${seen.palette.length - 1} <${hex}>]`
897
+ }
898
+
899
+ function blank(width, height) {
900
+ return typeof OffscreenCanvas === 'function'
901
+ ? new OffscreenCanvas(width, height)
902
+ : Object.assign(document.createElement('canvas'), { width, height })
903
+ }
904
+
905
+ /**
906
+ * A background is only an escape hatch if it actually covers what it replaces.
907
+ * A translucent one leaves the JPEG encoder compositing the remainder onto black,
908
+ * and a value the CSS parser rejects leaves fillStyle wherever it was — either
909
+ * way the user asked for a light page and gets black bleed with no explanation.
910
+ * The canvas is the only colour parser in reach, so probe it from two different
911
+ * sentinels: a real colour normalises to the same string from both, an invalid
912
+ * one comes back as whichever sentinel preceded it.
913
+ */
914
+ function flatten(canvas, background) {
915
+ const out = blank(canvas.width, canvas.height)
916
+ const ctx = out.getContext('2d')
917
+ ctx.fillStyle = '#000000'
918
+ ctx.fillStyle = background
919
+ const parsed = ctx.fillStyle
920
+ ctx.fillStyle = '#ffffff'
921
+ ctx.fillStyle = background
922
+ if (parsed !== ctx.fillStyle) {
923
+ // The rejected value never took, so fillStyle is still the white sentinel.
924
+ warn(`background ${background} is not a colour — falling back to white.`)
925
+ } else {
926
+ // Not a serialisation test: oklch(), color(), lab() and a gradient all come
927
+ // back as themselves, and any pattern over the string calls them translucent.
928
+ // One painted pixel answers for every syntax, and clearing it again keeps the
929
+ // corner from carrying a second coat of a translucent fill.
930
+ ctx.fillRect(0, 0, 1, 1)
931
+ const opaque = ctx.getImageData(0, 0, 1, 1).data[3] === 255
932
+ ctx.clearRect(0, 0, 1, 1)
933
+ if (!opaque) {
934
+ warn(`background ${background} is not opaque — what it does not cover composites onto black.`)
935
+ }
936
+ }
937
+ ctx.fillRect(0, 0, out.width, out.height)
938
+ ctx.drawImage(canvas, 0, 0)
939
+ return out
940
+ }
941
+
942
+ /**
943
+ * JPEG has no alpha, so every transparent pixel encodes as black and DCT ringing
944
+ * drags that black back across the edge into pixels the /SMask will keep — a dark
945
+ * halo the mask cannot remove, because there alpha is already 1. Smearing the
946
+ * capture outwards first gives those pixels a neighbouring colour to ring against;
947
+ * everything outside the shape is masked away regardless, so only the ringing
948
+ * changes and the opaque body of the page is untouched by the final redraw.
949
+ *
950
+ * Smearing `out` into itself rather than `canvas` compounds the reach: every draw
951
+ * in a round lands on the surface the earlier ones already extended, so a round of
952
+ * radius r adds 3r and these three reach 3, 9 and 21px — measured, and past an 8×8
953
+ * DCT block plus its 2× subsampled chroma, which is what bounds the ringing; a
954
+ * fourth round at r=8 reaches 45px and measures the same inside the shape. It also
955
+ * keeps every copy on one surface instead of re-uploading the capture 27 times.
956
+ *
957
+ * The centre offset is in the grid on purpose: compositing a translucent region
958
+ * over itself converges on its un-premultiplied colour — the colour /SMask is
959
+ * about to multiply the alpha back into — for anything but near-zero alpha, where
960
+ * 8-bit premultiplied rounding drifts instead (from about α=0.25 down, and the
961
+ * mask scales that error back down by the same α).
962
+ */
963
+ function bleedEdges(canvas) {
964
+ const out = blank(canvas.width, canvas.height)
965
+ const ctx = out.getContext('2d')
966
+ ctx.drawImage(canvas, 0, 0)
967
+ for (const r of [1, 2, 4]) {
968
+ for (const dx of [-1, 0, 1]) for (const dy of [-1, 0, 1]) ctx.drawImage(out, dx * r, dy * r)
969
+ }
970
+ ctx.drawImage(canvas, 0, 0)
971
+ return out
972
+ }
973
+
974
+ async function toJpeg(canvas, quality) {
975
+ const blob = canvas.convertToBlob
976
+ ? await canvas.convertToBlob({ type: 'image/jpeg', quality })
977
+ : await new Promise((resolve, reject) => canvas.toBlob(
978
+ (b) => (b ? resolve(b) : reject(new Error('toBlob returned null'))), 'image/jpeg', quality
979
+ ))
980
+ return new Uint8Array(await blob.arrayBuffer())
981
+ }
982
+
983
+ /**
984
+ * The colour plane as a losslessly deflated stream, in the narrowest colour space
985
+ * the survey proved the region actually needs.
986
+ *
987
+ * A narrowed region arrives with its plane already built — `survey` wrote a grey
988
+ * level or a palette index per pixel as it walked — so there is nothing here to
989
+ * read back or repack: the bytes go straight into the compressor. That plane is
990
+ * ONE byte per pixel instead of three, and the third is a third everywhere it
991
+ * counts: a third to hand the compressor, and MEASURED over this repo's fixtures
992
+ * 41% off the deflated bytes of a grey page and 48% off an indexed one. Nothing
993
+ * is approximated to get it — `survey` says grey only when r === g === b in every
994
+ * pixel, and offers a palette only when the region drew 256 colours or fewer — so
995
+ * both planes reconstruct the original exactly, which `colorspace.html` asserts by
996
+ * inflating them back and comparing pixel for pixel.
997
+ *
998
+ * /DeviceRGB is the remaining case and the only one that still reads pixels. It
999
+ * is streamed band by band rather than built as a single w·h·3 buffer: at
1000
+ * 3300×4678 that buffer is 46 MB, and this file goes out of its way elsewhere not
1001
+ * to allocate a third of that. Peak there is one band.
1002
+ *
1003
+ * No /DecodeParms — that key describes a PNG predictor, and there is none. A
1004
+ * predictor was measured and it LOST on every page tested (prose 971 KB with an
1005
+ * up-filter against 711 KB without): page artwork is flat runs, which raw deflate
1006
+ * already eats, and the filter turns those runs into noise.
1007
+ */
1008
+ async function toFlate(ctx, width, height, seen) {
1009
+ const cs = new CompressionStream('deflate')
1010
+ const out = new Response(cs.readable).arrayBuffer()
1011
+ const writer = cs.writable.getWriter()
1012
+ if (seen.plane) {
1013
+ await writer.ready
1014
+ writer.write(seen.plane)
1015
+ } else {
1016
+ const rows = bandRows(width)
1017
+ for (let y = 0; y < height; y += rows) {
1018
+ const h = Math.min(rows, height - y)
1019
+ const { data } = ctx.getImageData(0, y, width, h)
1020
+ const rgb = new Uint8Array(width * h * 3)
1021
+ for (let i = 0, j = 0; i < data.length; i += 4, j += 3) {
1022
+ rgb[j] = data[i]; rgb[j + 1] = data[i + 1]; rgb[j + 2] = data[i + 2]
1023
+ }
1024
+ await writer.ready
1025
+ writer.write(rgb)
1026
+ }
1027
+ }
1028
+ await writer.close()
1029
+ return new Uint8Array(await out)
1030
+ }
1031
+
1032
+ /**
1033
+ * Encode a canvas as an image XObject.
1034
+ *
1035
+ * `dict` is the XObject dictionary body without /Length. When `smask` is set the
1036
+ * caller must write it as its own stream object and append ` /SMask <id> 0 R` to
1037
+ * `dict` — this file mints no object ids of its own.
1038
+ *
1039
+ * **Which codec wins is content, not preference.** JPEG is the right answer for a
1040
+ * page of artwork and the wrong one for a page of prose, where DCT both inflates
1041
+ * the file and rings around every glyph. MEASURED on this repo's own fixtures at
1042
+ * scale 2, Flate in the space `survey` earned against JPEG q0.92: prose 473 KB vs
1043
+ * 1.69 MB, a 30-page report 8.7 MB vs 21.4, typography 19 KB vs 41, table 137 vs
1044
+ * 237, geometry 29 vs 41. The default therefore encodes BOTH and keeps the
1045
+ * smaller, which costs one extra encode and can never lose to either fixed
1046
+ * choice. `codec: 'jpeg' | 'flate'` pins it.
1047
+ *
1048
+ * Narrowing widened that gap rather than closing it: Flate now wins every fixture
1049
+ * in this repo, by between 1.7x and 3.6x. It has not been made to win everywhere
1050
+ * — a photographic region is neither grey nor 256 colours, so it narrows to
1051
+ * nothing and DCT can still be the smaller answer, which is exactly why the
1052
+ * comparison is still run.
1053
+ *
1054
+ * Flate is also LOSSLESS, so when it wins, `quality` stopped meaning anything —
1055
+ * the glyph edges in the page image are exactly the ones snapdom drew.
1056
+ *
1057
+ * @param {object} [options]
1058
+ * @param {number} [options.quality=0.92] JPEG quality; ignored if Flate wins
1059
+ * @param {string|null} [options.background=null]
1060
+ * @param {'auto'|'jpeg'|'flate'} [options.codec='auto']
1061
+ */
1062
+ export async function encodeImage(canvas, options = {}) {
1063
+ const { quality = 0.92, background = null, codec = 'auto' } = options
1064
+ const { width, height } = canvas
1065
+ const base = `/Type /XObject /Subtype /Image /Width ${width} /Height ${height} /BitsPerComponent 8`
1066
+ const jpegDict = `${base} /ColorSpace /DeviceRGB /Filter /DCTDecode`
1067
+ const flateDict = (seen) => `${base} /ColorSpace ${flateSpace(seen)} /Filter /FlateDecode`
1068
+
1069
+ // A runtime with no CompressionStream cannot write the Flate candidate at all,
1070
+ // and a caller asking for it there gets told rather than handed a JPEG that
1071
+ // silently is not what they asked for.
1072
+ let want = codec === 'jpeg' || codec === 'flate' ? codec : 'auto'
1073
+ if (want === 'flate' && !canDeflate) {
1074
+ warn('codec "flate" needs CompressionStream, which this runtime has not — falling back to JPEG.')
1075
+ want = 'jpeg'
1076
+ }
1077
+ if (want === 'auto' && !canDeflate) want = 'jpeg'
1078
+
1079
+ /**
1080
+ * The smaller of the two encodings of one surface, or the one that was asked for.
1081
+ *
1082
+ * The two candidates are run one after the other, and it is worth saying that
1083
+ * this was tried the other way and reverted. `Promise.all` over them is the
1084
+ * obvious optimisation — one hands the surface to the browser's DCT encoder,
1085
+ * the other reads its pixels and deflates them, and both look like work for
1086
+ * other threads. MEASURED on 1748x1497 regions: 75 ms sequential, 73 ms
1087
+ * concurrent. They do not overlap, because both begin by reading back the same
1088
+ * canvas and that readback serialises them.
1089
+ *
1090
+ * What `auto` costs and what it buys, MEASURED on a 44-page A4 report at
1091
+ * scale 2: the discarded JPEG candidate is 818 ms of a 4.0 s export, about a
1092
+ * fifth, and Flate won all 46 regions by 2.5x — 8.7 MB against 21.4. Page
1093
+ * artwork is flat runs and sharp edges, which is the worst case for DCT and the
1094
+ * best for deflate. Pinning `codec: 'jpeg'` cuts that fifth and multiplies the
1095
+ * file; that is the caller's trade to make, and it is why the default does not
1096
+ * make it for them.
1097
+ */
1098
+ const best = async (surface, forJpeg, seen) => {
1099
+ const asFlate = async () => ({
1100
+ dict: flateDict(seen),
1101
+ bytes: await toFlate(surface.getContext('2d'), width, height, seen),
1102
+ })
1103
+ if (want === 'flate') return asFlate()
1104
+ const jpeg = { dict: jpegDict, bytes: await toJpeg(forJpeg || surface, quality) }
1105
+ if (want === 'jpeg') return jpeg
1106
+ const flate = await asFlate()
1107
+ return flate.bytes.length < jpeg.bytes.length ? flate : jpeg
1108
+ }
1109
+
1110
+ // Before the alpha pipeline: flattening settles the alpha question by itself,
1111
+ // and the smear that dominates this file's cost is skipped whole. The FLATTENED
1112
+ // surface is the one surveyed, because it is the one that will be encoded — a
1113
+ // background is exactly the kind of thing that turns a grey page colourful.
1114
+ // A pinned JPEG reads none of those answers, so it is not asked for them.
1115
+ if (background) {
1116
+ const flat = flatten(canvas, background)
1117
+ const seen = want === 'jpeg' ? UNSURVEYED : survey(flat.getContext('2d'), width, height)
1118
+ return { ...(await best(flat, null, seen)), smask: null }
1119
+ }
1120
+
1121
+ const seen = survey(canvas.getContext('2d'), width, height, want !== 'jpeg')
1122
+ if (!seen.alpha) return { ...(await best(canvas, null, seen)), smask: null }
1123
+
1124
+ // Transparency, so the colour plane needs a companion /SMask whichever codec
1125
+ // carries it. Only the JPEG candidate needs the smear: it exists to give DCT
1126
+ // ringing a neighbouring colour to ring against, and Flate does not ring.
1127
+ const colour = await best(canvas, want === 'flate' ? null : bleedEdges(canvas), seen)
1128
+ return {
1129
+ ...colour,
1130
+ smask: {
1131
+ dict: `${base} /ColorSpace /DeviceGray${canDeflate ? ' /Filter /FlateDecode' : ''}`,
1132
+ bytes: await deflate(seen.alpha),
1133
+ },
1134
+ }
1135
+ }