@zumer/snapdom-plugins 3.0.0-beta.0 → 4.0.0

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