@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.
- package/CHANGELOG.md +13 -0
- package/README.md +54 -20
- package/agent-map.d.ts +32 -0
- package/agent-map.js +5 -3
- package/ascii-export.d.ts +10 -0
- package/ascii-export.js +21 -6
- package/capture-frames.js +5 -5
- package/color-tint.js +4 -0
- package/context-export.d.ts +23 -0
- package/context-export.js +4 -1
- package/filter.js +1 -1
- package/gif-export.d.ts +6 -0
- package/gif-export.js +3 -3
- package/html-export.d.ts +8 -0
- package/html-export.js +6 -4
- package/index.d.ts +2 -1
- package/index.js +2 -1
- package/package.json +17 -6
- package/pdf/LICENSE +21 -0
- package/pdf/README.md +22 -0
- package/pdf/REFERENCE.md +520 -0
- package/pdf/src/frontmatter.js +200 -0
- package/pdf/src/furniture.js +398 -0
- package/pdf/src/index.js +2415 -0
- package/pdf/src/mega/README.md +47 -0
- package/pdf/src/mega/diag.js +148 -0
- package/pdf/src/mega/flow.js +56 -0
- package/pdf/src/mega/fonts.js +287 -0
- package/pdf/src/mega/forms.js +479 -0
- package/pdf/src/mega/labels.js +43 -0
- package/pdf/src/mega/meta.js +145 -0
- package/pdf/src/nav.js +357 -0
- package/pdf/src/paginate.js +583 -0
- package/pdf/src/tagged.js +355 -0
- package/pdf/src/text-layer.js +1771 -0
- package/pdf/src/writer/encrypt.js +218 -0
- package/pdf/src/writer/font.js +507 -0
- package/pdf/src/writer/index.js +1135 -0
- package/pdf/src/writer/subset.js +387 -0
- package/pdf.d.ts +294 -0
- package/pdf.js +1 -0
- package/redact-inputs.js +6 -6
- package/vector/CONTRACT.md +906 -0
- package/vector/LICENSE +21 -0
- package/vector/README.md +35 -0
- package/vector/src/adapters/snapdom.js +795 -0
- package/vector/src/collect/box.js +1676 -0
- package/vector/src/collect/font.js +487 -0
- package/vector/src/collect/image.js +1458 -0
- package/vector/src/collect/mathml.js +272 -0
- package/vector/src/collect/text.js +3027 -0
- package/vector/src/css/color.js +823 -0
- package/vector/src/css/gradient.js +1126 -0
- package/vector/src/css/matrix.js +398 -0
- package/vector/src/css/radius.js +124 -0
- package/vector/src/css/shadow.js +201 -0
- package/vector/src/emit/figma-json.js +1995 -0
- package/vector/src/emit/h2d-vector.js +215 -0
- package/vector/src/emit/h2d.js +1147 -0
- package/vector/src/emit/pdf.js +2571 -0
- package/vector/src/emit/svg-flat.js +4842 -0
- package/vector/src/exclusion.js +13 -0
- package/vector/src/index.js +2910 -0
- package/vector/src/paint.js +2908 -0
- package/vector/src/public.js +57 -0
- package/vector/src/writer/encrypt.js +227 -0
- package/vector/src/writer/font.js +507 -0
- package/vector/src/writer/index.js +1148 -0
- package/vector/src/writer/subset.js +387 -0
- package/vector/svd/index.js +72 -0
- package/vector/svd/schema.js +985 -0
- package/vector.d.ts +28 -0
- package/vector.js +1 -0
- package/video-export.d.ts +6 -0
- package/video-export.js +7 -7
- package/pdf-image.d.ts +0 -18
- 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
|
+
}
|