@zumer/snapdom-plugins 3.0.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 +50 -16
  3. package/agent-map.d.ts +32 -0
  4. package/agent-map.js +5 -3
  5. package/ascii-export.d.ts +10 -0
  6. package/ascii-export.js +21 -6
  7. package/capture-frames.js +5 -5
  8. package/color-tint.js +4 -0
  9. package/context-export.d.ts +23 -0
  10. package/context-export.js +4 -1
  11. package/filter.js +1 -1
  12. package/gif-export.d.ts +6 -0
  13. package/gif-export.js +3 -3
  14. package/html-export.d.ts +8 -0
  15. package/html-export.js +6 -4
  16. package/index.d.ts +2 -1
  17. package/index.js +2 -1
  18. package/package.json +16 -5
  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,2415 @@
1
+ /**
2
+ * SnapDOM — PDF export: snapdom's raster plus an invisible text layer.
3
+ *
4
+ * The page image is snapdom's own capture, and over it goes an INVISIBLE text
5
+ * layer (Tr 3) positioned from snapdom's final serialized clone, which makes the file
6
+ * selectable, searchable and copy-pasteable, plus real link annotations.
7
+ *
8
+ * The raster is the path because fidelity is not approximated here: the pixels
9
+ * ARE snapdom's, so nothing in the file can disagree with what was on screen —
10
+ * nothing is re-drawn, no CSS is re-modelled, no font is substituted and no
11
+ * layout is recomputed. The text layer buys back everything a picture loses,
12
+ * without touching a pixel of it.
13
+ *
14
+ * This is a snapdom PLUGIN, so a PDF is one more thing a capture can be:
15
+ *
16
+ * snapdom.plugins(pdf())
17
+ * const shot = await snapdom(el)
18
+ * await shot.toPdf({ page: 'a4' }) // or shot.to('pdf', …)
19
+ *
20
+ * The split between `pdf(…)` and `toPdf(…)` is the engine's own split, not a
21
+ * quirk: the final capture artifact is MEASURED lazily and the page is DRAWN at export.
22
+ * Anything that changes what gets measured belongs in `pdf(…)`; anything about
23
+ * the paper belongs in `toPdf(…)`. Passing a capture-time option at export time
24
+ * is reported rather than silently ignored.
25
+ *
26
+ * What is about the PAGE rather than the paint is kept out of the drawing code:
27
+ * the coordinate contract (`PT`, the element's border box as content origin),
28
+ * `pageBox`, and `collectBlocks` + `paginate`, which read that isolated clone and know
29
+ * nothing about how the page is painted.
30
+ *
31
+ * Requires the SnapDOM plugin contract that exposes immutable capture metadata,
32
+ * raw export options and pre-decode canvas crops (SnapDOM v3).
33
+ */
34
+ // `src/writer/` is the byte-level half, and it is this product's own source. It
35
+ // was a package shared with a sibling product once, which is why it still knows
36
+ // nothing about text layers or page models and why `setWarnPrefix` exists — that
37
+ // is what makes a warning raised down there say `[snapdom-pdf]` to the person who
38
+ // bought this. Nothing of it reaches the buyer as a separate file; it is bundled.
39
+ import {
40
+ createPdfDoc, pdfString, pdfTextString, BASE14,
41
+ deflate, canDeflate, encodeImage, createUnicodeFont, setWarnPrefix,
42
+ writeOutlines, writeNameTree, writeStructTree,
43
+ } from './writer/index.js'
44
+ import { collectTextLayer, measureBase14 } from './text-layer.js'
45
+ import { collectNav, outlineTree, fragmentOf } from './nav.js'
46
+ import { buildStructure } from './tagged.js'
47
+ import { createEmbeddedFont } from './writer/font.js'
48
+ import { metaSpec, infoDict, xmpPacket } from './mega/meta.js'
49
+ import { collectFaceRules, resolveFaces } from './mega/fonts.js'
50
+ import { buildReport, reportSpec } from './mega/diag.js'
51
+ import { flowSpec } from './mega/flow.js'
52
+ import { pageLabels } from './mega/labels.js'
53
+ import { planFields, createFieldWriter, wantsCropAP } from './mega/forms.js'
54
+ import { collectBlocks, collectRepeats, paginate, headerReserve, repeatsAt } from './paginate.js'
55
+ import {
56
+ captureBand, captureElement, numberSpec, numberOps, bandOps,
57
+ bandSpec, textBandHeight, textBandOps, watermarkSpec, watermarkOps,
58
+ } from './furniture.js'
59
+ import { fullPageOps, tocSpec, tocLayout, fitText } from './frontmatter.js'
60
+
61
+ // Module-level, and safe because each product is bundled on its own: the writer
62
+ // inlined here is this build's private copy, so a page running both plugins has
63
+ // two of them and neither can rename the other's warnings. In the workspace,
64
+ // where Node resolves one shared module, the last plugin loaded wins the prefix —
65
+ // that is a harness cosmetic and it is the only place it can happen.
66
+ setWarnPrefix('[snapdom-pdf]')
67
+
68
+ /** CSS px → PDF points (96dpi → 72dpi), so printed size matches on-screen size. */
69
+ const PT = 0.75
70
+
71
+ /** Browser bitmap ceilings mirrored from SnapDOM's canvas exporter. */
72
+ const MAX_RASTER_SIDE = 16384
73
+ const MAX_RASTER_AREA = MAX_RASTER_SIDE * MAX_RASTER_SIDE
74
+
75
+ /** Points, portrait. ISO A and B, plus the North American sizes people actually ask for. */
76
+ const PAGE_SIZES = {
77
+ a3: [841.89, 1190.55],
78
+ a4: [595.28, 841.89],
79
+ a5: [419.53, 595.28],
80
+ a6: [297.64, 419.53],
81
+ b4: [708.66, 1000.63],
82
+ b5: [498.9, 708.66],
83
+ letter: [612, 792],
84
+ legal: [612, 1008],
85
+ tabloid: [792, 1224],
86
+ executive: [521.86, 756],
87
+ }
88
+
89
+ /**
90
+ * Options that decide what gets MEASURED, so they are read when the capture runs
91
+ * and cannot be changed at export time. Naming them lets the exporter say so
92
+ * instead of accepting them and quietly doing something else.
93
+ */
94
+ const CAPTURE_TIME = ['formValues', 'shadow', 'breakAvoid', 'outline', 'forms']
95
+
96
+ const warn = (msg) => console.warn(`[snapdom-pdf] ${msg}`)
97
+
98
+ /** Snapshot declarative option containers while preserving identity-bearing DOM
99
+ * Elements and callbacks. Defaults are read lazily, so freezing only their top
100
+ * level would still let `page`, `toc`, bands or watermarks change after pdf(). */
101
+ function snapshotOptions(value, seen = new WeakMap()) {
102
+ if (!value || typeof value !== 'object') return value
103
+ const proto = Object.getPrototypeOf(value)
104
+ if (!Array.isArray(value) && proto !== Object.prototype && proto !== null) return value
105
+ if (seen.has(value)) return seen.get(value)
106
+ const copy = Array.isArray(value) ? [] : Object.create(proto)
107
+ seen.set(value, copy)
108
+ for (const key of Object.keys(value)) copy[key] = snapshotOptions(value[key], seen)
109
+ return Object.freeze(copy)
110
+ }
111
+
112
+ /**
113
+ * `page` is a keyword, `'fit'`, or a `[width, height]` pair in points — the escape
114
+ * hatch for the sizes nobody standardised, like a receipt roll or a card.
115
+ */
116
+ function pageSize(page, report) {
117
+ if (Array.isArray(page)) {
118
+ const [w, h] = page
119
+ if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) return [w, h]
120
+ report(`page [${page}] is not a usable size in points — falling back to a4.`)
121
+ return PAGE_SIZES.a4
122
+ }
123
+ if (PAGE_SIZES[page]) return PAGE_SIZES[page]
124
+ report(`unknown page size "${page}" — falling back to a4.`)
125
+ return PAGE_SIZES.a4
126
+ }
127
+
128
+ /**
129
+ * Where the element's border box sits inside the capture's viewBox, in CSS px.
130
+ *
131
+ * snapdom sizes its raster from a viewBox that can be larger than the element —
132
+ * blur bleed, a rotated bbox, the anti-shaving pad. v3 publishes the exact
133
+ * logical origin; export validation requires those fields before any work.
134
+ */
135
+ function bleedOffset(meta) {
136
+ return {
137
+ offX: meta.contentX,
138
+ offY: meta.contentY,
139
+ }
140
+ }
141
+
142
+ /**
143
+ * A diagnostic for roots whose own transform can make a text layer surprising.
144
+ * about a corner grows its bounding box on one side only, so half the difference
145
+ * is not where the element starts. Exact placement comes from `meta.contentX/Y`;
146
+ * this warning remains useful when inspecting the isolated final artifact.
147
+ */
148
+ function offCentreTurn(element, rect) {
149
+ const style = getComputedStyle(element)
150
+ const turned = (style.transform && style.transform !== 'none') ||
151
+ (style.rotate && style.rotate !== 'none') ||
152
+ (style.scale && style.scale !== 'none')
153
+ if (!turned) return false
154
+ const [ox, oy] = String(style.transformOrigin || '').split(' ').map(parseFloat)
155
+ if (!Number.isFinite(ox) || !Number.isFinite(oy)) return false
156
+ return Math.abs(ox - rect.width / 2) > 0.5 || Math.abs(oy - rect.height / 2) > 0.5
157
+ }
158
+
159
+ /**
160
+ * The page box, in points, and the factor that maps content space onto it.
161
+ *
162
+ * Kept apart from the drawing because it is about the PAPER: what an A4 page is
163
+ * and where its margins fall does not depend on how the page gets painted. The
164
+ * one thing a caller can get wrong here — a `page` keyword nothing knows — is
165
+ * reported rather than silently taken as A4.
166
+ *
167
+ * @returns {{pageW:number, pageH:number, marginX:number, marginY:number,
168
+ * drawScale:number, availW:number, availH:number, k:number, scaledH:number}}
169
+ * `k` is CSS px → page points including the fit scale; `scaledH` is the whole
170
+ * content height in page points, which is what `paginate` cuts.
171
+ */
172
+ function pageBox({ page, orientation, margin, contentW, contentH, headerH = 0, footerH = 0, report }) {
173
+ if (![contentW, contentH, margin, headerH, footerH].every(Number.isFinite) ||
174
+ contentW <= 0 || contentH <= 0 || margin < 0 || headerH < 0 || footerH < 0) {
175
+ throw new RangeError('[snapdom-pdf] page geometry must use finite positive dimensions and non-negative margins')
176
+ }
177
+ let pageW, pageH, marginX, marginY, drawScale
178
+ if (page === 'fit') {
179
+ pageW = contentW
180
+ pageH = contentH
181
+ marginX = marginY = 0
182
+ drawScale = 1
183
+ } else {
184
+ const size = pageSize(page, report)
185
+ ;[pageW, pageH] = orientation === 'landscape' ? [size[1], size[0]] : size
186
+ marginX = marginY = margin
187
+ drawScale = (pageW - marginX * 2) / contentW
188
+ }
189
+ const availW = pageW - marginX * 2
190
+ const availH = pageH - marginY * 2 - headerH - footerH
191
+ if (![pageW, pageH, availW, availH, drawScale].every(Number.isFinite) ||
192
+ pageW <= 0 || pageH <= 0 || availW <= 0 || availH <= 0 || drawScale <= 0) {
193
+ throw new RangeError('[snapdom-pdf] margins and page furniture leave no positive printable area')
194
+ }
195
+ return {
196
+ pageW, pageH, marginX, marginY, drawScale,
197
+ availW,
198
+ // A header and a footer are not decoration over the page, they take room from
199
+ // it — so the cut height shrinks and the content starts lower.
200
+ availH,
201
+ contentTop: pageH - marginY - headerH,
202
+ k: PT * drawScale,
203
+ scaledH: contentH * drawScale,
204
+ }
205
+ }
206
+
207
+ /** Reject impossible work before mounting the snapshot, allocating a canvas or
208
+ * running an image codec. Unknown named paper still follows pageSize's documented
209
+ * a4 fallback, but malformed numeric geometry is never guessed. */
210
+ function validateExport(options, meta) {
211
+ for (const key of ['w0', 'h0', 'vbW', 'vbH', 'targetW', 'targetH', 'contentX', 'contentY']) {
212
+ if (!Number.isFinite(meta?.[key])) {
213
+ throw new RangeError(`[snapdom-pdf] snapdom capture metadata has no finite ${key}`)
214
+ }
215
+ }
216
+ if (meta.w0 <= 0 || meta.h0 <= 0 || meta.vbW <= 0 || meta.vbH <= 0 ||
217
+ meta.targetW <= 0 || meta.targetH <= 0) {
218
+ throw new RangeError('[snapdom-pdf] snapdom capture dimensions must be positive')
219
+ }
220
+ if (!Number.isFinite(options.scale) || options.scale <= 0 ||
221
+ !Number.isFinite(options.dpr) || options.dpr <= 0) {
222
+ throw new RangeError('[snapdom-pdf] capture scale and dpr must be finite positive numbers')
223
+ }
224
+ const margin = options.margin ?? 24
225
+ if (!Number.isFinite(margin) || margin < 0) {
226
+ throw new RangeError('[snapdom-pdf] margin must be a finite non-negative number of points')
227
+ }
228
+ const orientation = options.orientation ?? 'portrait'
229
+ if (orientation !== 'portrait' && orientation !== 'landscape') {
230
+ throw new TypeError('[snapdom-pdf] orientation must be "portrait" or "landscape"')
231
+ }
232
+ const page = options.page ?? 'fit'
233
+ if (Array.isArray(page)) {
234
+ if (page.length !== 2 || !page.every(n => Number.isFinite(n) && n > 0)) {
235
+ throw new RangeError('[snapdom-pdf] a custom page must be [positiveWidth, positiveHeight] in points')
236
+ }
237
+ } else if (typeof page !== 'string') {
238
+ throw new TypeError('[snapdom-pdf] page must be a paper name, "fit", or [width, height]')
239
+ }
240
+ if (page !== 'fit') {
241
+ // Silent here on purpose: `pageBox` reports the a4 fallback when it takes it,
242
+ // and reporting from both put the same sentence in the console twice.
243
+ const size = pageSize(page, () => {})
244
+ const [w, h] = orientation === 'landscape' ? [size[1], size[0]] : size
245
+ if (margin * 2 >= w || margin * 2 >= h) {
246
+ throw new RangeError('[snapdom-pdf] margin leaves no positive page area')
247
+ }
248
+ }
249
+ if (options.image !== false) {
250
+ const quality = options.quality ?? 0.92
251
+ if (!Number.isFinite(quality) || quality < 0 || quality > 1) {
252
+ throw new RangeError('[snapdom-pdf] quality must be between 0 and 1')
253
+ }
254
+ if (!['auto', 'jpeg', 'flate'].includes(options.codec ?? 'auto')) {
255
+ throw new TypeError('[snapdom-pdf] codec must be "auto", "jpeg" or "flate"')
256
+ }
257
+ }
258
+ if (options.encrypt != null) validateEncrypt(options.encrypt)
259
+ }
260
+
261
+ /**
262
+ * Everything about `encrypt` that can be wrong before any work happens.
263
+ *
264
+ * The empty user password is the one that gets its own error rather than a
265
+ * warning. A PDF whose user password is '' opens with no prompt at all: the
266
+ * file is encrypted, the bytes are unreadable to a text editor, and every
267
+ * viewer in the world walks straight in. That is the exact shape of a document
268
+ * somebody believes is protected and is not, so it is refused instead of
269
+ * produced. `permissions` alone is the same mistake wearing a different hat —
270
+ * a flag is a request, and there is nothing behind it without a password.
271
+ */
272
+ function validateEncrypt(spec) {
273
+ if (typeof spec !== 'object' || Array.isArray(spec)) {
274
+ throw new TypeError('[snapdom-pdf] encrypt must be an object, ' +
275
+ 'for example { userPassword: "…" }')
276
+ }
277
+ const { userPassword, ownerPassword, permissions } = spec
278
+ if (typeof userPassword !== 'string' || !userPassword) {
279
+ throw new TypeError('[snapdom-pdf] encrypt.userPassword must be a non-empty string — ' +
280
+ 'a document with an empty user password is encrypted but opens without asking, ' +
281
+ 'which protects nothing')
282
+ }
283
+ if (ownerPassword != null && typeof ownerPassword !== 'string') {
284
+ throw new TypeError('[snapdom-pdf] encrypt.ownerPassword must be a string')
285
+ }
286
+ if (permissions != null && (typeof permissions !== 'object' || Array.isArray(permissions))) {
287
+ throw new TypeError('[snapdom-pdf] encrypt.permissions must be an object of booleans')
288
+ }
289
+ for (const key of Object.keys(permissions || {})) {
290
+ if (typeof permissions[key] !== 'boolean') {
291
+ throw new TypeError(`[snapdom-pdf] encrypt.permissions.${key} must be a boolean`)
292
+ }
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Cut the content into page slices. Knows nothing about the paint either:
298
+ * `collectBlocks` reads the isolated clone and `paginate` is pure geometry in content
299
+ * points.
300
+ */
301
+ function slicePages({ blocks, toContentY, scaledH, availH, single, report, reserve = null }) {
302
+ if (single) return [{ top: 0, height: scaledH, cut: 0 }]
303
+ const { slices, forcedIgnored } = paginate({
304
+ blocks: blocks.map(b => ({ ...b, top: toContentY(b.top), bottom: toContentY(b.bottom) })),
305
+ contentHeight: scaledH,
306
+ availHeight: availH,
307
+ reserve,
308
+ })
309
+ const cutRows = slices.reduce((n, s) => n + (s.cut || 0), 0)
310
+ if (cutRows) report(`${cutRows} block(s) had to be split across a page break.`)
311
+ if (forcedIgnored) report(`${forcedIgnored} declared page break(s) could not be honoured.`)
312
+ return slices
313
+ }
314
+
315
+ /** A download is a side effect of `download`, never of producing the blob. */
316
+ function deliver(blob, name) {
317
+ if (!name) return blob
318
+ const file = name === true ? 'snapdom.pdf' : String(name)
319
+ const url = URL.createObjectURL(blob)
320
+ const a = document.createElement('a')
321
+ a.href = url
322
+ a.download = file.endsWith('.pdf') ? file : `${file}.pdf`
323
+ a.click()
324
+ setTimeout(() => URL.revokeObjectURL(url), 1000)
325
+ return blob
326
+ }
327
+
328
+ const rect = (left, top, width, height) => ({
329
+ left, top, width, height, right: left + width, bottom: top + height,
330
+ })
331
+
332
+ const overlaps = (a, b) =>
333
+ a.right > b.left && a.left < b.right && a.bottom > b.top && a.top < b.bottom
334
+
335
+ const contains = (outer, inner) =>
336
+ inner.left >= outer.left - 0.01 && inner.top >= outer.top - 0.01 &&
337
+ inner.right <= outer.right + 0.01 && inner.bottom <= outer.bottom + 0.01
338
+
339
+ /** Conservative AABB for one text run. A partly clipped word is dropped whole:
340
+ * keeping its full string would make characters outside a snapdom clip searchable. */
341
+ function runBox(run) {
342
+ const points = [[0, -run.ascent], [run.width, -run.ascent],
343
+ [0, run.height - run.ascent], [run.width, run.height - run.ascent]]
344
+ const m = run.matrix
345
+ const mapped = points.map(([x, y]) => ({
346
+ x: run.originX + (m ? m.a * x + m.c * y : x),
347
+ y: run.originY + (m ? m.b * x + m.d * y : y),
348
+ }))
349
+ const xs = mapped.map(p => p.x)
350
+ const ys = mapped.map(p => p.y)
351
+ return rect(Math.min(...xs), Math.min(...ys), Math.max(...xs) - Math.min(...xs),
352
+ Math.max(...ys) - Math.min(...ys))
353
+ }
354
+
355
+ /** Apply the capture window to every semantic surface, not only to pixels. */
356
+ function clipMeasurement(layer, blocks, repeats, nav, frame, notes) {
357
+ const runs = []
358
+ const owners = []
359
+ let clippedRuns = 0
360
+ for (let i = 0; i < layer.runs.length; i++) {
361
+ if (!contains(frame, runBox(layer.runs[i]))) { clippedRuns++; continue }
362
+ runs.push(layer.runs[i])
363
+ owners.push(layer.owners[i])
364
+ }
365
+ if (clippedRuns) {
366
+ notes.push(`${clippedRuns} text run(s) crossing the snapdom clip edge were omitted ` +
367
+ 'from the searchable layer so clipped text cannot leak through extraction.')
368
+ }
369
+ const links = []
370
+ for (const link of layer.links) {
371
+ const box = rect(link.x, link.y, link.width, link.height)
372
+ if (!overlaps(frame, box)) continue
373
+ const left = Math.max(frame.left, box.left)
374
+ const top = Math.max(frame.top, box.top)
375
+ const right = Math.min(frame.right, box.right)
376
+ const bottom = Math.min(frame.bottom, box.bottom)
377
+ links.push({ ...link, x: left, y: top, width: right - left, height: bottom - top })
378
+ }
379
+ layer = { ...layer, runs, owners, links }
380
+
381
+ blocks = blocks.flatMap(b => {
382
+ const top = Math.max(frame.top, b.top)
383
+ const bottom = Math.min(frame.bottom, b.bottom)
384
+ return bottom >= top ? [{ ...b, top, bottom }] : []
385
+ })
386
+ repeats = repeats.filter(b => b.bottom > frame.top && b.top < frame.bottom)
387
+
388
+ const dests = new Map([...nav.dests].filter(([, at]) => at.top || (
389
+ at.x >= frame.left && at.x <= frame.right && at.y >= frame.top && at.y <= frame.bottom
390
+ )))
391
+ const outline = nav.outline.filter(item => !item.box || contains(frame, {
392
+ left: item.box.x, top: item.box.y, right: item.box.x + item.box.w,
393
+ bottom: item.box.y + item.box.h,
394
+ }))
395
+ return { layer, blocks, repeats, nav: { ...nav, dests, outline } }
396
+ }
397
+
398
+ function walkElements(root, visit) {
399
+ const stack = [root]
400
+ while (stack.length) {
401
+ const el = stack.pop()
402
+ visit(el)
403
+ if (el.shadowRoot) for (let i = el.shadowRoot.children.length - 1; i >= 0; i--) {
404
+ stack.push(el.shadowRoot.children[i])
405
+ }
406
+ for (let i = el.children.length - 1; i >= 0; i--) stack.push(el.children[i])
407
+ }
408
+ }
409
+
410
+ /** Resolve fragments inside the mounted snapshot. ownerDocument.getElementById()
411
+ * cannot do this across the measurement ShadowRoot and may find the live twin. */
412
+ function addSnapshotDestinations(root, links, nav, frame, clipped, documentURL) {
413
+ const wanted = new Set()
414
+ for (const link of links) {
415
+ const name = fragmentOf(link.href, documentURL)
416
+ if (name !== null) wanted.add(name)
417
+ }
418
+ const targets = new Map()
419
+ walkElements(root, (el) => {
420
+ if (el.id && !targets.has(el.id)) targets.set(el.id, el)
421
+ if (el.tagName === 'A') {
422
+ const name = el.getAttribute('name')
423
+ if (name && !targets.has(name)) targets.set(name, el)
424
+ }
425
+ })
426
+ let unresolved = 0
427
+ for (const name of wanted) {
428
+ if (name === '') { nav.dests.set('', { x: frame.left, y: frame.top, top: true }); continue }
429
+ if (nav.dests.has(name)) continue
430
+ const el = targets.get(name)
431
+ if (!el) { unresolved++; continue }
432
+ const r = el.getBoundingClientRect()
433
+ const at = rect(r.left, r.top, r.width, r.height)
434
+ if (clipped && !overlaps(frame, at)) { unresolved++; continue }
435
+ nav.dests.set(name, { x: r.left, y: r.top })
436
+ }
437
+ if (unresolved) {
438
+ nav.warnings.push(`${unresolved} internal link(s) point outside the final captured tree or clip — ` +
439
+ 'written with no annotation rather than a destination that could expose excluded content')
440
+ }
441
+ }
442
+
443
+ /** Measure only the final serialized clone. Never fall back to the live source: a
444
+ * fallback would re-introduce excluded/redacted text into a nominally safe PDF. */
445
+ function measure(element, opts, frame, clipped = false, documentURL = location.href) {
446
+ const notes = []
447
+ const ownRect = frame || element.getBoundingClientRect()
448
+ if (!ownRect.width || !ownRect.height) {
449
+ notes.push('the capture element has no layout box — there is nothing to lay out a page from.')
450
+ return {
451
+ rect: ownRect, layer: { runs: [], owners: [], links: [], warnings: [] }, blocks: [], notes,
452
+ nav: { dests: new Map(), outline: [] }, repeats: [], controls: [],
453
+ structure: {
454
+ root: { type: 'Document', children: [] }, ofRun: [], ofLink: [], ofControl: [],
455
+ lang: null, warnings: [],
456
+ },
457
+ }
458
+ }
459
+
460
+ if (offCentreTurn(element, ownRect)) {
461
+ notes.push('the capture root is turned about an off-centre origin, so its bounding box grew ' +
462
+ 'on one side only and the whole layer may be offset — capture an untransformed wrapper instead.')
463
+ }
464
+
465
+ const formValues = opts.formValues !== false
466
+ const shadow = opts.shadow !== false
467
+ const forms = opts.forms !== false
468
+ let layer = collectTextLayer(element, { formValues, shadow, documentURL, forms })
469
+ notes.push(...layer.warnings)
470
+
471
+ // Navigation reads the mounted final-artifact layout AFTER the links exist:
472
+ // the set of destinations worth resolving is exactly the set something points
473
+ // at, so the text layer's links are its input.
474
+ // Link targets are resolved locally below. Passing the links here would make
475
+ // getElementById find the live page's duplicate IDs outside this ShadowRoot.
476
+ let nav = collectNav(element, {
477
+ links: [],
478
+ shadow,
479
+ outline: opts.outline !== false,
480
+ headings: typeof opts.outline === 'string' ? opts.outline : null,
481
+ })
482
+ notes.push(...nav.warnings)
483
+ const navNotes = nav.warnings.length
484
+
485
+ // Block geometry only matters where a page break can happen, but the page size
486
+ // is not known yet — collecting is cheap next to being unable to.
487
+ let blocks = opts.breakAvoid === false ? [] : collectBlocks(element, { shadow })
488
+ // Always measured, never gated here: whether a header repeats is about the
489
+ // PAPER — it does nothing on `page: 'fit'` — so the decision belongs to the
490
+ // export, and measuring a document's theads costs one rect each.
491
+ let repeats = collectRepeats(element, { shadow })
492
+ addSnapshotDestinations(element, layer.links, nav, ownRect, clipped, documentURL)
493
+ notes.push(...nav.warnings.slice(navNotes))
494
+ if (clipped) {
495
+ const cut = clipMeasurement(layer, blocks, repeats, nav, ownRect, notes)
496
+ layer = cut.layer
497
+ blocks = cut.blocks
498
+ repeats = cut.repeats
499
+ nav = cut.nav
500
+ }
501
+ // The capture window applies to fields like everything semantic: a control the
502
+ // clip cuts must not become a widget reaching outside what the pixels show.
503
+ const controls = layer.controls || []
504
+ if (clipped) {
505
+ for (const c of controls) {
506
+ if (c.skip) continue
507
+ const box = rect(c.rect.left, c.rect.top, c.rect.width, c.rect.height)
508
+ if (!contains(ownRect, box)) c.skip = 'clipped'
509
+ }
510
+ }
511
+ // Which text-layer runs carry each control's VALUE — a control owns its value
512
+ // runs directly (see pushLocalRun), so index equality is the whole join. The
513
+ // field planner suppresses these from the invisible layer: a value must not
514
+ // extract twice, once from the field and once from under it.
515
+ const controlByEl = new Map(controls.map(c => [c.el, c]))
516
+ layer.owners.forEach((owner, i) => {
517
+ const c = controlByEl.get(owner)
518
+ if (c) c.runs.push(i)
519
+ })
520
+ // Built here for the same reason everything else is: the structure of a document
521
+ // is a fact about the DOM, and the DOM is only this document while the capture
522
+ // is running. Whether it reaches the FILE is the export's decision, so its
523
+ // warnings are held back rather than pushed into `notes`.
524
+ const structure = buildStructure(element, layer.owners, {
525
+ lang: opts.lang,
526
+ links: layer.links.map(l => l.el),
527
+ controls: controls.map(c => c.el),
528
+ })
529
+
530
+ // buildStructure has already converted ownership to plain structure nodes.
531
+ // Keeping owner Elements here — or on a link or control record — would retain
532
+ // the isolated iframe Document through measuredPromise for the lifetime of the
533
+ // capture result.
534
+ layer = {
535
+ ...layer,
536
+ owners: [],
537
+ links: layer.links.map(({ el, ...link }) => link),
538
+ controls: undefined,
539
+ }
540
+
541
+ return {
542
+ rect: ownRect, layer, blocks, nav, repeats, structure, notes, formValues, shadow,
543
+ documentURL,
544
+ controls: controls.map(({ el, ...control }) => control),
545
+ }
546
+ }
547
+
548
+ function svgTextFromDataURL(url) {
549
+ if (typeof url !== 'string' || !/^data:image\/svg\+xml/i.test(url)) {
550
+ throw new Error('[snapdom-pdf] the final capture artifact is not an SVG data URL')
551
+ }
552
+ const comma = url.indexOf(',')
553
+ if (comma < 0) throw new Error('[snapdom-pdf] the final SVG data URL is malformed')
554
+ const header = url.slice(0, comma)
555
+ const payload = url.slice(comma + 1)
556
+ if (/;base64/i.test(header)) {
557
+ const bytes = Uint8Array.from(atob(payload), c => c.charCodeAt(0))
558
+ return new TextDecoder().decode(bytes)
559
+ }
560
+ return decodeURIComponent(payload)
561
+ }
562
+
563
+ async function measureArtifact(url, meta, opts, source = {}) {
564
+ const svgText = svgTextFromDataURL(url)
565
+ const parsed = new DOMParser().parseFromString(svgText, 'image/svg+xml')
566
+ if (parsed.querySelector('parsererror') || parsed.documentElement.localName !== 'svg') {
567
+ throw new Error('[snapdom-pdf] the final SVG artifact could not be parsed safely')
568
+ }
569
+ const viewBox = String(parsed.documentElement.getAttribute('viewBox') || '')
570
+ .trim().split(/[\s,]+/).map(Number)
571
+ const expectedViewBox = [0, 0, meta.vbW, meta.vbH]
572
+ if (viewBox.length !== 4 || !viewBox.every(Number.isFinite) ||
573
+ viewBox.some((n, i) => Math.abs(n - expectedViewBox[i]) > 1e-4)) {
574
+ // Metadata and pixels form one coordinate contract. Continuing after an
575
+ // afterRender hook windows only the SVG would let clipped pixels retain text,
576
+ // links or tags measured with the old frame.
577
+ throw new Error('[snapdom-pdf] final SVG viewBox no longer matches immutable capture metadata')
578
+ }
579
+ const svgRoot = parsed.documentElement
580
+ const allowedRootAttrs = new Set(['xmlns', 'width', 'height', 'viewBox', 'font-size'])
581
+ const unexpectedRootAttrs = [...svgRoot.attributes]
582
+ .map(attr => attr.name)
583
+ .filter(name => !allowedRootAttrs.has(name))
584
+ if (unexpectedRootAttrs.length) {
585
+ throw new Error('[snapdom-pdf] final SVG root has paint/layout attributes outside the capture contract: ' +
586
+ unexpectedRootAttrs.join(', '))
587
+ }
588
+ const intrinsicW = Number(svgRoot.getAttribute('width'))
589
+ const intrinsicH = Number(svgRoot.getAttribute('height'))
590
+ if (!(intrinsicW > 0) || !(intrinsicH > 0)) {
591
+ throw new Error('[snapdom-pdf] final SVG has no finite positive intrinsic dimensions')
592
+ }
593
+ const intrinsicAspect = intrinsicW / intrinsicH
594
+ const viewBoxAspect = meta.vbW / meta.vbH
595
+ if (!Number.isFinite(intrinsicAspect) ||
596
+ Math.abs(intrinsicAspect / viewBoxAspect - 1) > 1e-4) {
597
+ throw new Error('[snapdom-pdf] final SVG intrinsic aspect no longer matches its viewBox')
598
+ }
599
+ const doc = document
600
+ if (!doc.body) throw new Error('[snapdom-pdf] a document body is required to measure the final capture')
601
+ // Never reconnect serialized page markup to the application document. Event
602
+ // handlers, custom-element callbacks and loadable elements could otherwise run
603
+ // again merely because somebody asked for a PDF. A same-origin sandbox gives us
604
+ // layout access but, without allow-scripts, no script execution; CSP closes the
605
+ // remaining network/object/frame channels while permitting embedded assets.
606
+ const sandboxFrame = doc.createElement('iframe')
607
+ sandboxFrame.setAttribute('data-snapdom-pdf-measure', '')
608
+ sandboxFrame.setAttribute('sandbox', 'allow-same-origin')
609
+ sandboxFrame.style.cssText =
610
+ `position:fixed!important;left:0!important;top:0!important;opacity:0!important;` +
611
+ `width:${meta.vbW}px!important;height:${meta.vbH}px!important;` +
612
+ 'display:block!important;border:0!important;pointer-events:none!important;z-index:-2147483648!important;'
613
+ const loaded = new Promise((resolve, reject) => {
614
+ sandboxFrame.onload = resolve
615
+ sandboxFrame.onerror = () => reject(new Error('[snapdom-pdf] isolated measurement frame failed to load'))
616
+ })
617
+ sandboxFrame.srcdoc = '<!doctype html><meta charset="utf-8">' +
618
+ '<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; ' +
619
+ 'style-src \'unsafe-inline\'; img-src data:; font-src data:; media-src data:; ' +
620
+ 'connect-src \'none\'; object-src \'none\'; frame-src \'none\'">' +
621
+ '<style>html,body{margin:0;padding:0;overflow:visible}</style><body></body>'
622
+ doc.body.appendChild(sandboxFrame)
623
+ try {
624
+ await loaded
625
+ const frameDoc = sandboxFrame.contentDocument
626
+ if (!frameDoc?.body) throw new Error('[snapdom-pdf] isolated measurement document is unavailable')
627
+ frameDoc.documentElement.lang = String(source.documentLang || '')
628
+ const base = frameDoc.createElement('base')
629
+ base.href = String(source.baseURL || source.documentURL || doc.baseURI)
630
+ frameDoc.head.prepend(base)
631
+ const svg = frameDoc.importNode(parsed.documentElement, true)
632
+ svg.style.cssText = `display:block;width:${meta.vbW}px;height:${meta.vbH}px;max-width:none;max-height:none;overflow:visible;`
633
+ frameDoc.body.appendChild(svg)
634
+ if (frameDoc.fonts?.ready) await frameDoc.fonts.ready
635
+ const fo = svg.getElementsByTagNameNS('http://www.w3.org/2000/svg', 'foreignObject')[0] ||
636
+ svg.querySelector('foreignObject')
637
+ const container = fo && [...fo.children].find(el => el.localName === 'div')
638
+ const root = container?.firstElementChild
639
+ if (!root) throw new Error('[snapdom-pdf] the final SVG has no measurable capture root')
640
+ const svgRect = svg.getBoundingClientRect()
641
+ const contentFrame = rect(
642
+ svgRect.left + meta.contentX, svgRect.top + meta.contentY, meta.w0, meta.h0
643
+ )
644
+ const measured = measure(
645
+ root, opts, contentFrame, !!meta.clip,
646
+ String(source.documentURL || source.baseURL || doc.location?.href || doc.baseURI)
647
+ )
648
+ return {
649
+ ...measured,
650
+ // The @font-face rules behind the text, read NOW: this CSSOM dies with the
651
+ // frame, and the export that wants the bytes runs after it is gone.
652
+ fontRules: collectFaceRules(frameDoc),
653
+ intrinsic: {
654
+ width: Number.isFinite(intrinsicW) && intrinsicW > 0 ? intrinsicW : meta.vbW,
655
+ height: Number.isFinite(intrinsicH) && intrinsicH > 0 ? intrinsicH : meta.vbH,
656
+ },
657
+ }
658
+ } finally {
659
+ sandboxFrame.remove()
660
+ }
661
+ }
662
+
663
+ /**
664
+ * Produce the document from a finished capture: snapdom's raster, then the
665
+ * invisible text layer over it.
666
+ *
667
+ * @param {object} args
668
+ * @param {{rect: DOMRect, layer: object, blocks: Array}} args.measured
669
+ * @param {(crop?: object) => Promise<object>} [args.raster] lazy encoded capture regions
670
+ * @param {{w0:number, h0:number, vbW:number, vbH:number}} args.meta
671
+ * @param {object} args.options
672
+ * @param {(msg: string) => void} args.report
673
+ * @param {{check: Function, tick: Function}|null} [args.flow]
674
+ * @returns {Promise<{blob: Blob, stats: object}>}
675
+ */
676
+ async function draw({ measured, raster, meta, options, report, flow = null }) {
677
+ const {
678
+ quality = 0.92,
679
+ page = 'fit',
680
+ orientation = 'portrait',
681
+ margin = 24,
682
+ text = true,
683
+ links = true,
684
+ image = true,
685
+ deflate: wantDeflate = true,
686
+ header = null,
687
+ footer = null,
688
+ pageNumbers = false,
689
+ debugText = false,
690
+ backgroundColor = null,
691
+ repeatHeaders = true,
692
+ tagged = true,
693
+ lang = null,
694
+ codec = 'auto',
695
+ cover = null,
696
+ back = null,
697
+ toc = null,
698
+ watermark = null,
699
+ fields = true,
700
+ embedFonts = true,
701
+ vectorText = false,
702
+ encrypt: encryptSpec = null,
703
+ meta: docMeta = null,
704
+ } = options
705
+
706
+ const { rect, layer, blocks, nav } = measured
707
+ const { offX, offY } = bleedOffset(meta)
708
+ const { vbW, vbH } = meta
709
+
710
+ // Content space: points from the element's border-box top-left. The raster can
711
+ // start above/left of that (bleed) and end below it, and pagination has to
712
+ // cover what is drawn, not just the element box.
713
+ const contentW = rect.width * PT
714
+ const contentH = (page === 'fit' ? rect.height : Math.max(rect.height, vbH - offY)) * PT
715
+
716
+ // Text bands have intrinsic metrics. Element bands are captured below before
717
+ // pagination; their final artifact aspect determines the strip they reserve.
718
+ if (page === 'fit' && (header || footer || pageNumbers)) {
719
+ report("header, footer and pageNumbers need a paginated page size — they do nothing on page:'fit'.")
720
+ }
721
+ // Orientation is a fact about the PAPER, and `page: 'fit'` has none — the page
722
+ // IS the element, whatever shape that element happens to be.
723
+ if (page === 'fit' && orientation === 'landscape') {
724
+ report("orientation does nothing on page:'fit' — the page is the element's own box, " +
725
+ 'so there is no paper to turn.')
726
+ }
727
+ const rawBands = page === 'fit'
728
+ ? { header: null, footer: null }
729
+ : { header: bandSpec(header), footer: bandSpec(footer) }
730
+ const bands = { ...rawBands }
731
+ if (!image) {
732
+ for (const where of ['header', 'footer']) {
733
+ if (bands[where]?.kind === 'element') {
734
+ report(`${where} element omitted because image:false forbids raster/XObject work.`)
735
+ bands[where] = null
736
+ }
737
+ }
738
+ }
739
+ const provisional = page === 'fit' ? [0, 0] : pageSize(page, () => {})
740
+ const provisionalW = (orientation === 'landscape' ? provisional[1] : provisional[0]) - margin * 2
741
+ const encodeOpaque = (surface, encodeOptions) =>
742
+ encodeImage(surface, { ...encodeOptions, background: backgroundColor })
743
+ const capturedBands = new Map()
744
+ if (image) {
745
+ for (const [where, spec] of [['header', bands.header], ['footer', bands.footer]]) {
746
+ if (!spec || spec.kind !== 'element') continue
747
+ const band = await captureBand(spec.el, {
748
+ scale: options.scale, dpr: options.dpr, backgroundColor: null, quality, codec,
749
+ availW: provisionalW, captureOptions: options.captureOptions,
750
+ }, encodeOpaque)
751
+ capturedBands.set(where, band)
752
+ }
753
+ }
754
+ // Final-artifact aspect is authoritative for element bands. The pageBox call
755
+ // below also catches a render hook that enlarged a band until no printable area
756
+ // remains.
757
+ const bandRoom = (b, where) => !b ? 0
758
+ : b.kind === 'text' ? textBandHeight(b) : capturedBands.get(where)?.heightPt || 0
759
+ const headerH = bandRoom(bands.header, 'header')
760
+ const footerH = bandRoom(bands.footer, 'footer')
761
+
762
+ const geo = pageBox({ page, orientation, margin, contentW, contentH, headerH, footerH, report })
763
+ const { pageW, pageH, marginX, marginY, availW, availH, contentTop, k, scaledH } = geo
764
+
765
+ // Viewport CSS px → content points.
766
+ const toContentX = (vx) => (vx - rect.left) * k
767
+ const toContentY = (vy) => (vy - rect.top) * k
768
+
769
+ // A repeated table header is not drawn over the page, it takes room from it, so
770
+ // pagination has to see it before it cuts anything. `page: 'fit'` has one page
771
+ // and nothing to repeat onto.
772
+ const repeatBands = (repeatHeaders !== false && page !== 'fit')
773
+ ? (measured.repeats || []).map(b => ({
774
+ top: toContentY(b.top), bottom: toContentY(b.bottom), bodyBottom: toContentY(b.bodyBottom),
775
+ }))
776
+ : []
777
+ const maxRepeat = availH * 0.5
778
+
779
+ const slices = slicePages({
780
+ blocks, toContentY, scaledH, availH, report,
781
+ single: page === 'fit',
782
+ reserve: headerReserve(repeatBands, maxRepeat),
783
+ })
784
+
785
+ const doc = createPdfDoc()
786
+ const catalogId = doc.reserve()
787
+ const pagesId = doc.reserve()
788
+
789
+ // ——— fillable fields ———
790
+ //
791
+ // Planned before any page is drawn because the plan reaches BACKWARDS into the
792
+ // text layer: a value that lives in a field must leave the invisible layer, or
793
+ // every extraction returns it twice — once from the field, once from under it.
794
+ const fieldPlan = (fields !== false && (measured.controls || []).length)
795
+ ? planFields(measured.controls, report)
796
+ : null
797
+ const fieldWriter = fieldPlan && fieldPlan.fields.length
798
+ ? createFieldWriter(doc, { report })
799
+ : null
800
+ const suppressedRuns = fieldWriter ? fieldPlan.suppressed : new Set()
801
+ const docMetaSpec = metaSpec(docMeta, report)
802
+
803
+ // One encoded region may be painted more than once (repeated headers) but is
804
+ // registered as one PDF object. The raster provider itself caches canvas and
805
+ // codec work across repeated exports of this capture.
806
+ // Which candidate `codec: 'auto'` actually kept, per region. It decides by
807
+ // SIZE, so on a page of flat runs and sharp edges Flate wins and the JPEG —
808
+ // the only one `quality` has any say over — is discarded. Counted because a
809
+ // caller who lowers quality and gets a byte-identical file deserves to be told
810
+ // why rather than left to conclude the option is broken.
811
+ let jpegRegions = 0
812
+ let losslessRegions = 0
813
+ const imageObjects = new WeakMap()
814
+ const imageObject = async (crop = null) => {
815
+ if (!raster) throw new Error('[snapdom-pdf] raster requested while image:false')
816
+ const encoded = await raster(crop)
817
+ let saved = imageObjects.get(encoded)
818
+ if (saved) return saved
819
+ if (/DCTDecode/.test(encoded.dict)) jpegRegions++
820
+ else losslessRegions++
821
+ const smaskRef = encoded.smask
822
+ ? ` /SMask ${doc.addStream(encoded.smask.dict, encoded.smask.bytes)} 0 R`
823
+ : ''
824
+ saved = { id: doc.addStream(encoded.dict + smaskRef, encoded.bytes), crop }
825
+ imageObjects.set(encoded, saved)
826
+ return saved
827
+ }
828
+ // Every body image is requested as a pre-decode region, including page:'fit'.
829
+ // A fit page may still be a very long document; it is composed from several
830
+ // bands on one MediaBox rather than allocating one document-height bitmap.
831
+
832
+ // Captured bands become one XObject each, reused by every page: a band is an
833
+ // element and an element does not change between pages. Text bands are drawn
834
+ // per page, because their segments may depend on the page number.
835
+ const furniture = []
836
+ for (const [where, spec, name] of [['header', bands.header, 'Ih'], ['footer', bands.footer, 'If']]) {
837
+ if (!spec || spec.kind !== 'element') continue
838
+ const band = capturedBands.get(where)
839
+ if (!band) continue
840
+ const mask = band.smask ? ` /SMask ${doc.addStream(band.smask.dict, band.smask.bytes)} 0 R` : ''
841
+ furniture.push({ where, name, id: doc.addStream(band.dict + mask, band.bytes), heightPt: band.heightPt })
842
+ }
843
+ const numbers = numberSpec(page === 'fit' ? false : pageNumbers)
844
+
845
+ // The watermark's translucency is graphics state, so it needs one ExtGState the
846
+ // pages share, and an element watermark needs its own XObject. Both are built
847
+ // once: a stamp does not change between pages.
848
+ let mark = watermarkSpec(watermark)
849
+ if (!image && mark?.kind === 'element') {
850
+ report('element watermark omitted because image:false forbids raster/XObject work.')
851
+ mark = null
852
+ }
853
+ let markGs = null
854
+ let markImage = null
855
+ let markAspect = 1
856
+ if (mark) {
857
+ markGs = { name: 'GSw', id: doc.add(`<< /Type /ExtGState /ca ${mark.opacity} /CA ${mark.opacity} >>`) }
858
+ if (mark.kind === 'element') {
859
+ const stamp = await captureElement(mark.el, {
860
+ scale: options.scale, dpr: options.dpr, backgroundColor: null, quality, codec,
861
+ captureOptions: options.captureOptions,
862
+ }, encodeImage)
863
+ const maskRef = stamp.smask ? ` /SMask ${doc.addStream(stamp.smask.dict, stamp.smask.bytes)} 0 R` : ''
864
+ markImage = { name: 'Iw', id: doc.addStream(stamp.dict + maskRef, stamp.bytes) }
865
+ markAspect = stamp.aspect
866
+ }
867
+ }
868
+ /**
869
+ * One page dictionary. Every page in the file — body, cover, index, colophon —
870
+ * goes through here, so a resource the watermark needs cannot be present on the
871
+ * pages somebody remembered and missing from the ones they did not.
872
+ */
873
+ const pageDict = ({ contentId, annots = [], fonts, xobjects = [], structParents = null }) => {
874
+ const all = [...xobjects]
875
+ if (markImage) all.push(`/${markImage.name} ${markImage.id} 0 R`)
876
+ const res = [
877
+ all.length ? `/XObject << ${all.join(' ')} >>` : '',
878
+ fonts.size ? `/Font << ${[...fonts].map(f => `/${f.name} ${f.id} 0 R`).join(' ')} >>` : '',
879
+ markGs ? `/ExtGState << /${markGs.name} ${markGs.id} 0 R >>` : '',
880
+ ].filter(Boolean).join(' ')
881
+ return `<< /Type /Page /Parent ${pagesId} 0 R ` +
882
+ `/MediaBox [0 0 ${pageW.toFixed(3)} ${pageH.toFixed(3)}] /Resources << ${res} >> ` +
883
+ (structParents === null ? '' : `/StructParents ${structParents} `) +
884
+ // Structure order is the tab order — the only honest /Tabs value here,
885
+ // since widgets and links join the tree through /OBJR.
886
+ (struct ? '/Tabs /S ' : '') +
887
+ `/Contents ${contentId} 0 R${annots.length ? ` /Annots [${annots.join(' ')}]` : ''} >>`
888
+ }
889
+
890
+ /** The same stamp on every page, front matter included — including the cover. */
891
+ const markOps = (pageFonts) => {
892
+ if (!mark) return []
893
+ const out = watermarkOps({
894
+ spec: mark, pageW, pageH, gs: markGs.name,
895
+ image: markImage && markImage.name, imageAspect: markAspect, pdfText,
896
+ })
897
+ if (!out) return []
898
+ if (out.font) pageFonts.add(out.font)
899
+ return out.ops
900
+ }
901
+
902
+ // Only emit fonts the document actually uses.
903
+ const usedFonts = new Map()
904
+ const fontRef = (key) => {
905
+ if (!usedFonts.has(key)) {
906
+ const name = `F${usedFonts.size}`
907
+ const id = doc.add(
908
+ `<< /Type /Font /Subtype /Type1 /BaseFont /${BASE14[key] || BASE14.sans} /Encoding /WinAnsiEncoding >>`
909
+ )
910
+ usedFonts.set(key, { name, id })
911
+ }
912
+ return usedFonts.get(key)
913
+ }
914
+ // Registers no objects until a run needs it, so a Latin-only document is
915
+ // byte-identical to the base-14-only build.
916
+ const unicodeFont = createUnicodeFont(doc)
917
+
918
+ // ——— embedded fonts ———
919
+ //
920
+ // The faces behind the capture's own @font-face rules, resolved per font key
921
+ // and embedded WHOLE (see pdf-writer/font.js for what that buys and what it
922
+ // refuses). A run rides its embedded face only when the face's real cmap has
923
+ // every glyph the run paints; anything else keeps the base-14/Type0 path it
924
+ // always had, and is counted. Parsed binaries cache on the capture, so a
925
+ // second export re-encodes nothing.
926
+ const fontCache = measured.fontCache || (measured.fontCache = new Map())
927
+ const facesFor = (fontKeys, runs, fontRules) => {
928
+ if (!embedFonts || !fontKeys?.length || !fontRules?.length) return Promise.resolve([])
929
+ const points = fontKeys.map(() => new Set())
930
+ for (const run of runs) {
931
+ const set = run.fk >= 0 ? points[run.fk] : null
932
+ if (set) for (const ch of run.text) set.add(ch.codePointAt(0))
933
+ }
934
+ return resolveFaces(fontKeys, points, fontRules, fontCache, report)
935
+ }
936
+ const bodyFaces = await facesFor(layer.fontKeys, layer.runs, measured.fontRules)
937
+ // The page has loaded webfonts, but the capture carries no @font-face for the
938
+ // export to read: snapdom strips them unless the CAPTURE inlines them. Said
939
+ // here once, because the alternative is fonts that silently never embed.
940
+ if (embedFonts && !(measured.fontRules?.length) &&
941
+ typeof document !== 'undefined' && document.fonts?.size) {
942
+ report('the page declares webfonts but none reached the capture, so nothing can be embedded — ' +
943
+ 'pass `embedFonts: true` to snapdom(…) so the capture carries its @font-face rules.')
944
+ }
945
+
946
+ // Keyed by the PARSED BINARY, not the face record: the cover and the body
947
+ // each resolve their own faces, and when both land on the same file — the
948
+ // cache hands both the same parsed object — the document must carry that
949
+ // program once, not once per page that asked.
950
+ const embeddedFonts = new Map()
951
+ const embeddedOf = (face) => {
952
+ let f = embeddedFonts.get(face.parsed)
953
+ if (!f) {
954
+ f = createEmbeddedFont(doc, face.parsed, face.sfnt, String(embeddedFonts.size),
955
+ { subset: embedFonts !== 'full' })
956
+ f.label = face.label
957
+ f.wholeKB = Math.round(face.sfnt.length / 1024)
958
+ embeddedFonts.set(face.parsed, f)
959
+ }
960
+ return f
961
+ }
962
+
963
+ // The document's OWN face: the one that painted the most body text. Furniture
964
+ // and generated pages default to it, so a running head is set in the same
965
+ // type as the report it runs over — and set in REAL embedded glyphs.
966
+ let primaryFace = null
967
+ {
968
+ const painted = new Map()
969
+ for (const run of layer.runs) {
970
+ const face = run.fk >= 0 ? bodyFaces[run.fk] : null
971
+ if (face) painted.set(face, (painted.get(face) || 0) + run.text.length)
972
+ }
973
+ for (const [face, n] of painted) {
974
+ if (!primaryFace || n > painted.get(primaryFace)) primaryFace = face
975
+ }
976
+ }
977
+ let coverageFallbacks = 0
978
+ const systemStacks = new Set()
979
+
980
+ /**
981
+ * One short string of the PLUGIN's own text — a page number, a running head, a
982
+ * watermark, a line of the generated index — encoded and measured.
983
+ *
984
+ * Base-14 where WinAnsi can address it; the document's Type0 font otherwise, so
985
+ * a Japanese heading reaches the index instead of being silently dropped the way
986
+ * a base-14-only path drops it. The width comes back with it because every one
987
+ * of these callers has to align or centre what it just encoded, and the standing
988
+ * `length × size × 0.5` guess puts a right-aligned string off its own margin.
989
+ *
990
+ * @returns {{str: string, font: object, width: number}|null}
991
+ */
992
+ let unpaintable = 0
993
+ const pdfText = (text, key = 'sans', size = 10, { paint = true } = {}) => {
994
+ const body = String(text)
995
+ // 'document' is the body's own embedded face — real glyphs, any script its
996
+ // cmap reaches, PAINTABLE. It falls back to base-14 sans per string, so one
997
+ // heading the face lacks does not cost the rest of the furniture its type.
998
+ if (key === 'document') {
999
+ if (primaryFace) {
1000
+ const f = embeddedOf(primaryFace)
1001
+ const str = f.encode(body)
1002
+ if (str) {
1003
+ return { str, font: f, width: f.width(body, size), base14: false, paintable: true }
1004
+ }
1005
+ }
1006
+ key = 'sans'
1007
+ }
1008
+ const str = pdfString(body)
1009
+ if (str) {
1010
+ return { str, font: fontRef(key), width: measureBase14(body, key, size), base14: true, paintable: true }
1011
+ }
1012
+ // The Type0 font carries NO GLYPHS — that is its whole design, and it is why
1013
+ // extraction can run off the CMap alone. So it may be written invisibly and
1014
+ // must never be PAINTED: a viewer with nothing to draw draws nothing, or
1015
+ // notdef boxes, and which of the two you get depends on the viewer. Refusing
1016
+ // here is what turns that into a warning instead of a blank line in somebody's
1017
+ // report.
1018
+ if (paint) { unpaintable++; return null }
1019
+ const encoded = unicodeFont.encode(body)
1020
+ if (!encoded) return null
1021
+ // A font with no glyphs has no metrics either: one em per codepoint is the
1022
+ // same estimate the run path uses for its own Tz denominator.
1023
+ return { str: encoded, font: unicodeFont, width: [...body].length * size, base14: false, paintable: false }
1024
+ }
1025
+
1026
+ // Every run belongs to exactly ONE page. Choosing by baseline and then
1027
+ // clamping is what keeps a word that straddles a slice from vanishing off
1028
+ // both pages, which is how the fixed-band version lost content.
1029
+ const sliceOf = (cy) => {
1030
+ for (let i = 0; i < slices.length; i++) {
1031
+ if (cy < slices[i].top + slices[i].height) return i
1032
+ }
1033
+ return slices.length - 1
1034
+ }
1035
+ // The centre of the painted box, not the baseline: a line sitting on a slice
1036
+ // edge belongs to the page that shows most of its glyphs.
1037
+ const runPages = layer.runs.map((run) => {
1038
+ const dx = run.width / 2
1039
+ const dy = run.height / 2 - run.ascent
1040
+ const m = run.matrix
1041
+ const cy = toContentY(run.originY) + (m ? (m.b * dx + m.d * dy) : dy) * k
1042
+ return sliceOf(Math.max(0, cy))
1043
+ })
1044
+
1045
+ // Reserved, not appended as each page finishes: a link on page 1 may point at
1046
+ // page 9, and a destination cannot name a page object that does not exist yet.
1047
+ const pageIds = slices.map(() => doc.reserve())
1048
+
1049
+ /**
1050
+ * A measured point → the PDF destination that lands on it: `/XYZ`, so the
1051
+ * viewer keeps the reader's zoom and only scrolls. The point is the TOP-LEFT of
1052
+ * the target, which is what puts the heading itself at the top of the window
1053
+ * rather than just off it.
1054
+ *
1055
+ * Clamped to the page box because a destination outside it is not a scroll
1056
+ * position any viewer can honour — it silently becomes the origin, which sends
1057
+ * the reader to the wrong end of the page instead of the wrong place on it.
1058
+ */
1059
+ // What each page gives up to repeated headers, and therefore where its own
1060
+ // content starts. Read once, here, by everything that places anything: the
1061
+ // destinations below, the page image, the runs and the links.
1062
+ const repeated = slices.map(s => repeatsAt(repeatBands, s.top, maxRepeat))
1063
+ const pageTopOf = (p) => contentTop - repeated[p].height
1064
+ if (repeated.some(r => r.refused)) {
1065
+ report('a table header is more than half a page tall — it is drawn once, where it is, ' +
1066
+ 'and not repeated: a page that is mostly header carries no rows.')
1067
+ }
1068
+
1069
+ const destOf = (at) => {
1070
+ if (at.top) return { page: 0, ref: `[${pageIds[0]} 0 R /XYZ 0 ${pageH.toFixed(3)} null]` }
1071
+ const cy = Math.max(0, toContentY(at.y))
1072
+ const p = sliceOf(cy)
1073
+ const x = Math.min(Math.max(0, marginX + toContentX(at.x)), pageW)
1074
+ const y = Math.min(Math.max(0, pageTopOf(p) - (cy - slices[p].top)), pageH)
1075
+ return { page: p, ref: `[${pageIds[p]} 0 R /XYZ ${x.toFixed(3)} ${y.toFixed(3)} null]` }
1076
+ }
1077
+ const dests = new Map()
1078
+ /** Which BODY page a destination is on, which is the number the index prints. */
1079
+ const destPage = new Map()
1080
+ for (const [name, at] of (nav?.dests || new Map())) {
1081
+ const d = destOf(at)
1082
+ dests.set(name, d.ref)
1083
+ destPage.set(name, d.page)
1084
+ }
1085
+
1086
+ let dropped = 0
1087
+ let offPage = 0
1088
+ let unlinked = 0
1089
+ let vectorPainted = 0
1090
+ let vectorMissed = 0
1091
+ // Painted images nobody named, on body and matter pages alike. Any one of them
1092
+ // withholds the PDF/UA-1 claim.
1093
+ let unnamedImages = layer.unnamedImages
1094
+
1095
+ const actionFor = (href, documentURL = measured.documentURL) => {
1096
+ const name = fragmentOf(href, documentURL)
1097
+ if (name !== null) {
1098
+ if (!dests.has(name)) { unlinked++; return null }
1099
+ return `/Dest ${dests.get(name)}`
1100
+ }
1101
+ return `/A << /Type /Action /S /URI /URI ${pdfString(href) || '()'} >>`
1102
+ }
1103
+
1104
+ /**
1105
+ * Annotations that belong to the structure tree — a link's `/Link`, a widget's
1106
+ * `/Form` — cannot be finished while pages are still being drawn: their
1107
+ * `/StructParent` keys must come AFTER every page's `/StructParents` key, and
1108
+ * pages (cover, contents) are still being created. So a structured annotation
1109
+ * reserves its id now and is filled in one pass before the tree is written.
1110
+ */
1111
+ const annotStruct = []
1112
+ let annotCount = 0
1113
+
1114
+ const addLinkAnnot = (box, action, owner = null) => {
1115
+ const left = Math.max(0, box.left)
1116
+ const bottom = Math.max(0, box.bottom)
1117
+ const right = Math.min(pageW, box.right)
1118
+ const top = Math.min(pageH, box.top)
1119
+ if (![left, bottom, right, top].every(Number.isFinite) || right <= left || top <= bottom) return null
1120
+ annotCount++
1121
+ const dict =
1122
+ `<< /Type /Annot /Subtype /Link /Border [0 0 0] ` +
1123
+ `/Rect [${left.toFixed(3)} ${bottom.toFixed(3)} ${right.toFixed(3)} ${top.toFixed(3)}] ` +
1124
+ `${action}`
1125
+ if (struct && owner && owner.node) {
1126
+ const id = doc.reserve()
1127
+ annotStruct.push({ id, dict, node: owner.node, pageId: owner.pageId })
1128
+ return id
1129
+ }
1130
+ return doc.add(dict + ' >>')
1131
+ }
1132
+
1133
+ /**
1134
+ * Where content y = 0 sits on the page, for one draw. Every placement below is
1135
+ * `origin - contentY`, so a repeated header band and the page's own slice differ
1136
+ * in exactly this number and in nothing else — which is why the header lands on
1137
+ * the same pixels twice instead of nearly the same.
1138
+ */
1139
+ const originFor = (pageY, contentY) => pageY + contentY
1140
+
1141
+ /**
1142
+ * The map `drawRun` takes for the CAPTURED BODY: content space is the element's
1143
+ * border box scaled by `k`, and `origin` is where content y = 0 sits.
1144
+ */
1145
+ const bodyMap = (origin) => ({
1146
+ ax: marginX - rect.left * k,
1147
+ ay: origin + rect.top * k,
1148
+ s: k,
1149
+ })
1150
+
1151
+ const cropFor = (top, height) => {
1152
+ const y = Math.max(0, Math.min(vbH, offY + top / k))
1153
+ const bottom = Math.max(y, Math.min(vbH, offY + (top + height) / k))
1154
+ if (!(bottom > y)) throw new RangeError('[snapdom-pdf] page slice maps outside the raster viewBox')
1155
+ return { x: 0, y, width: vbW, height: bottom - y }
1156
+ }
1157
+
1158
+ // Core crops before decode, then applies the capture scale and dpr to the
1159
+ // canvas. Bound both stages with the same side/area ceilings as toCanvas.
1160
+ const intrinsicW = measured.intrinsic?.width > 0 ? measured.intrinsic.width : vbW
1161
+ const intrinsicH = measured.intrinsic?.height > 0 ? measured.intrinsic.height : vbH
1162
+ const pixelFactor = options.scale * options.dpr
1163
+ const densityX = options.rasterDensity != null
1164
+ ? Math.max(intrinsicW / vbW, options.rasterDensity * options.dpr)
1165
+ : intrinsicW / vbW * pixelFactor
1166
+ const densityY = options.rasterDensity != null
1167
+ ? Math.max(intrinsicH / vbH, options.rasterDensity * options.dpr)
1168
+ : intrinsicH / vbH * pixelFactor
1169
+ const regionPixelW = vbW * densityX
1170
+ const maxRegionH = Math.max(1, Math.min(
1171
+ vbH,
1172
+ MAX_RASTER_SIDE / Math.max(densityY, Number.EPSILON),
1173
+ MAX_RASTER_AREA / Math.max(regionPixelW * densityY, Number.EPSILON)
1174
+ ))
1175
+ if (image && regionPixelW > MAX_RASTER_SIDE) {
1176
+ report(`the capture is ${Math.ceil(regionPixelW)} raster pixels wide, above the browser's ` +
1177
+ `${MAX_RASTER_SIDE}px image limit — vertical regions avoid a giant document bitmap, but ` +
1178
+ 'SnapDOM may still downscale their width. Lower scale/dpr for full resolution.')
1179
+ }
1180
+ const cropsFor = (top, height) => {
1181
+ const whole = cropFor(top, height)
1182
+ const out = []
1183
+ const end = whole.y + whole.height
1184
+ for (let y = whole.y; y < end - 1e-7;) {
1185
+ const h = Math.min(maxRegionH, end - y)
1186
+ out.push({ x: whole.x, y, width: whole.width, height: h })
1187
+ y += h
1188
+ }
1189
+ return out
1190
+ }
1191
+
1192
+ /** A full image or a pre-decode SVG crop, clipped to the page's content band. */
1193
+ const drawImage = (ops, name, origin, top, height, crop = null) => {
1194
+ const box = crop || { x: 0, y: 0, width: vbW, height: vbH }
1195
+ const imgW = box.width * k
1196
+ const imgH = box.height * k
1197
+ const imageTop = origin + offY * k - box.y * k
1198
+ ops.push('q')
1199
+ ops.push(`${marginX} ${(top - height).toFixed(3)} ${availW} ${height.toFixed(3)} re W n`)
1200
+ ops.push(`${imgW.toFixed(3)} 0 0 ${imgH.toFixed(3)} ` +
1201
+ `${(marginX - offX * k + box.x * k).toFixed(3)} ${(imageTop - imgH).toFixed(3)} cm`)
1202
+ ops.push(`/${name} Do`)
1203
+ ops.push('Q')
1204
+ }
1205
+
1206
+ /**
1207
+ * One run, placed by an AFFINE MAP from viewport CSS px onto the page:
1208
+ * `x = ax + vx·s`, `y = ay − vy·s`. Three callers need three different maps —
1209
+ * the page's own slice, a repeated header band, and a cover or colophon drawn
1210
+ * at its own scale — and they differ in nothing but those three numbers.
1211
+ *
1212
+ * Returns false when the run carries a codepoint the Type0 font has no room
1213
+ * left for; the caller counts it.
1214
+ */
1215
+ const drawRun = (ops, pageFonts, run, { ax, ay, s }, faces = bodyFaces) => {
1216
+ // The run's own embedded face first — real glyphs, real advances, so Tz is
1217
+ // correcting sub-pixel truth rather than substituting Helvetica's. Base-14
1218
+ // is the fallback for system-font runs; the Type0 font carries everything
1219
+ // WinAnsi cannot address. The three disagree on advance, which is why the
1220
+ // caller — not the text layer — computes Tz.
1221
+ let str = null
1222
+ let font, natural
1223
+ let embeddedUsed = false
1224
+ const face = run.fk >= 0 ? faces[run.fk]
1225
+ // A synthetic marker run has no style of its own; it rides the document's
1226
+ // face when one exists, so an otherwise fully-embedded export does not
1227
+ // drag Helvetica back in for a figure's placeholder space.
1228
+ : (run.fk === -1 && primaryFace ? primaryFace : null)
1229
+ if (face) {
1230
+ const f = embeddedOf(face)
1231
+ str = f.encode(run.text)
1232
+ if (str) {
1233
+ font = f
1234
+ natural = f.width(run.text, run.size)
1235
+ embeddedUsed = true
1236
+ } else {
1237
+ coverageFallbacks++
1238
+ }
1239
+ }
1240
+ if (!str) {
1241
+ str = pdfString(run.text)
1242
+ if (str) {
1243
+ font = fontRef(run.font)
1244
+ natural = run.naturalWidth
1245
+ } else {
1246
+ str = unicodeFont.encode(run.text)
1247
+ if (!str) return false
1248
+ font = unicodeFont
1249
+ natural = [...run.text].length * run.size
1250
+ }
1251
+ }
1252
+ pageFonts.add(font)
1253
+
1254
+ const px = ax + run.originX * s
1255
+ const py = ay - run.originY * s
1256
+ // Content painted outside the capture's own border box — a rotated leaf
1257
+ // overhanging its parent, say — lands off the MediaBox. It is written, but
1258
+ // no viewer will ever show it and no extractor will return it, so it is a
1259
+ // silent loss unless it is counted.
1260
+ if (px < 0 || px > pageW || py < 0 || py > pageH) offPage++
1261
+ const size = (run.size * s).toFixed(3)
1262
+ // CSS y grows down and PDF y grows up, so the linear part is conjugated
1263
+ // by the flip: b and c change sign, a and d do not.
1264
+ const m = run.matrix
1265
+ const tm = m
1266
+ ? `${m.a.toFixed(5)} ${(-m.b).toFixed(5)} ${(-m.c).toFixed(5)} ${m.d.toFixed(5)}`
1267
+ : '1 0 0 1'
1268
+ // Vector text PAINTS the run as real filled glyphs from the embedded face,
1269
+ // instead of leaving it invisible over the raster. It is faithful only where
1270
+ // no shaping stands between codepoints and glyphs: a left-to-right run in an
1271
+ // embedded face, with a resolved, opaque colour. Everything else — RTL, a
1272
+ // face that did not cover the run, a translucent or unresolved colour, a
1273
+ // word-spacing Type0 cannot express — stays as it was, shown by the raster
1274
+ // or, in an image-free export, lost and counted.
1275
+ const paint = vectorText && embeddedUsed && run.color && !run.rtl
1276
+ let tr = debugText ? 0 : 3
1277
+ let colorOp = ''
1278
+ // Letter-spacing rides `Tc`, NOT Tz: folding it into the horizontal scale
1279
+ // would fatten every glyph (measured 15–31% at 1–4px). Only a painted run
1280
+ // applies it — an invisible run keeps its width-matching Tz untouched, so
1281
+ // selection geometry is unchanged. Tc is unscaled text space, hence ×s.
1282
+ let natEff = natural
1283
+ let tc = 0
1284
+ if (paint) {
1285
+ tr = 0
1286
+ colorOp = `${run.color.map(c => c.toFixed(3)).join(' ')} rg `
1287
+ if (run.tracking) {
1288
+ tc = run.tracking * s
1289
+ natEff = natural + [...run.text].length * run.tracking
1290
+ }
1291
+ vectorPainted++
1292
+ } else if (vectorText && !image && tr === 3) {
1293
+ // Nothing will show this run: no raster beneath it, and it was not painted.
1294
+ vectorMissed++
1295
+ }
1296
+ const tz = (natEff > 0 ? Math.max(1, Math.min(1000, (run.width / natEff) * 100)) : 100).toFixed(2)
1297
+ // Tc is written EVERY run, and BEFORE `BT` where it is still a legal text-
1298
+ // state op: it persists across runs in a stream, so a tracked run would
1299
+ // otherwise leak its spacing onto the next — including invisible ones, whose
1300
+ // selection width it would widen. Placing it before BT also keeps it out of
1301
+ // the `BT … Tr` shape the geometry oracle matches.
1302
+ ops.push(
1303
+ `${colorOp}${tc.toFixed(4)} Tc BT ${tr} Tr /${font.name} ${size} Tf ${tz} Tz ${tm} ` +
1304
+ `${px.toFixed(3)} ${py.toFixed(3)} Tm ${str} Tj ET`
1305
+ )
1306
+ return true
1307
+ }
1308
+
1309
+ // Which runs live in which header band, so a repeated header carries the same
1310
+ // SELECTABLE text as the one it was cut from — a header that is only pixels on
1311
+ // page 2 is a header a search cannot find.
1312
+ const bandRuns = repeatBands.map((b) => {
1313
+ const list = []
1314
+ for (let i = 0; i < layer.runs.length; i++) {
1315
+ const cy = toContentY(layer.runs[i].originY)
1316
+ if (cy > b.top - 0.5 && cy <= b.bottom + 0.5) list.push(i)
1317
+ }
1318
+ return list
1319
+ })
1320
+
1321
+ // ——— tagging ———
1322
+ //
1323
+ // The structure tree covers the TEXT LAYER and nothing else. Every pixel this
1324
+ // plugin draws is an /Artifact: the page image is a picture of the same words
1325
+ // the layer carries, so tagging it as content would have a screen reader
1326
+ // announce a full-page image over the text it duplicates — and a repeated table
1327
+ // header would be read once per page it repeats onto.
1328
+ const struct = tagged ? measured.structure : null
1329
+ if (struct) for (const note of struct.warnings) {
1330
+ // buildStructure is cached per capture while `lang` is an export option. A
1331
+ // per-export language fully satisfies the document-level requirement.
1332
+ if (lang && /no `lang`/i.test(note)) continue
1333
+ report(note)
1334
+ }
1335
+ /**
1336
+ * `markOwners[key][mcid]` — what the parent tree is built from. Indexed by
1337
+ * /StructParents KEY, not by body page: a cover and an index are pages too, and
1338
+ * they are created after the body.
1339
+ */
1340
+ const markOwners = slices.map(() => [])
1341
+ // Held beside the tree, never in it: `measured.structure` belongs to the
1342
+ // CAPTURE and a capture can be exported more than once. Writing marks into the
1343
+ // nodes made the second export inherit the first one's content — caught by the
1344
+ // plugin fixture, which exports the same capture twice and compares sizes.
1345
+ const marksOf = new Map()
1346
+
1347
+ const artifact = (ops, body) => {
1348
+ if (!body.length) return
1349
+ if (struct) ops.push('/Artifact BMC')
1350
+ ops.push(...body)
1351
+ if (struct) ops.push('EMC')
1352
+ }
1353
+
1354
+ /** Structure grafted in from pages that are not the capture. */
1355
+ const frontStruct = []
1356
+ const backStruct = []
1357
+
1358
+ /**
1359
+ * A /StructParents key for a page built outside the body loop, plus the marker
1360
+ * its content goes through. Returns null when tagging is off, so a caller can
1361
+ * pass the result straight to `pageDict`.
1362
+ */
1363
+ const matterMarks = () => {
1364
+ if (!struct) return null
1365
+ const key = markOwners.length
1366
+ markOwners.push([])
1367
+ return {
1368
+ key,
1369
+ mark: (node, pageId, body) => {
1370
+ const id = markOwners[key].length
1371
+ markOwners[key].push(node)
1372
+ const list = marksOf.get(node) || []
1373
+ list.push({ mcid: id, page: pageId })
1374
+ marksOf.set(node, list)
1375
+ return [`/${node.type} <</MCID ${id}>> BDC`, ...body, 'EMC']
1376
+ },
1377
+ }
1378
+ }
1379
+
1380
+ /**
1381
+ * Where each fillable control lands, computed once for two consumers that must
1382
+ * agree to the point: the page image, which STOPS painting the control's area,
1383
+ * and the annotation pass, which puts the widget on exactly that rectangle.
1384
+ *
1385
+ * The page image yields because two renderers exist. Viewers that honour a
1386
+ * widget's appearance stream (pdf.js, PDFium, Acrobat) draw our AP — the
1387
+ * control's own crop of the capture — so the field still looks pixel-exact.
1388
+ * Viewers that REGENERATE widget appearances from /V, /DA and /MK (macOS
1389
+ * Preview and everything on PDFKit) draw their own field instead; with the
1390
+ * raster still painting the old value underneath, every such viewer showed the
1391
+ * value twice, slightly offset — MEASURED with a headless PDFKit render. A
1392
+ * field's pixels must have one owner, and the field is it.
1393
+ */
1394
+ const fieldPlacements = slices.map(() => [])
1395
+ if (fieldWriter) {
1396
+ for (const field of fieldPlan.fields) {
1397
+ for (const member of field.members) {
1398
+ const c = member.control
1399
+ const cy = toContentY(c.rect.top)
1400
+ const h = c.rect.height * k
1401
+ const w = c.rect.width * k
1402
+ const p = sliceOf(Math.max(0, cy + h / 2))
1403
+ const top = pageTopOf(p) - (cy - slices[p].top)
1404
+ const cx = toContentX(c.rect.left)
1405
+ if (cy < slices[p].top - 0.01 || cy + h > slices[p].top + slices[p].height + 0.01) {
1406
+ fieldWriter.straddle()
1407
+ }
1408
+ const llx = Math.max(0, Math.min(pageW, marginX + cx))
1409
+ const urx = Math.max(0, Math.min(pageW, marginX + cx + w))
1410
+ const ury = Math.max(0, Math.min(pageH, top))
1411
+ const lly = Math.max(0, Math.min(pageH, top - h))
1412
+ if (urx <= llx || ury <= lly) continue
1413
+ fieldPlacements[p].push({ field, member, rect: [llx, lly, urx, ury] })
1414
+ }
1415
+ }
1416
+ }
1417
+
1418
+ for (let p = 0; p < slices.length; p++) {
1419
+ flow?.check()
1420
+ const slice = slices[p]
1421
+ const rep = repeated[p]
1422
+ const pageTop = pageTopOf(p)
1423
+ const ops = []
1424
+ const pageXObjects = furniture.map(f => `/${f.name} ${f.id} 0 R`)
1425
+
1426
+ /** One tagged run: marked content the structure tree can be walked back from. */
1427
+ const marked = (node, body) => {
1428
+ if (!node || !body.length) { ops.push(...body); return }
1429
+ const id = markOwners[p].length
1430
+ markOwners[p].push(node)
1431
+ const list = marksOf.get(node) || []
1432
+ list.push({ mcid: id, page: pageIds[p] })
1433
+ marksOf.set(node, list)
1434
+ ops.push(`/${node.type} <</MCID ${id}>> BDC`, ...body, 'EMC')
1435
+ }
1436
+
1437
+ // Page image: drawn whole at 1:1 CSS px, clipped to this page's slice. The
1438
+ // bleed spills into the margin and the clip crops it.
1439
+ if (image) {
1440
+ const body = []
1441
+ const regions = cropsFor(slice.top, slice.height)
1442
+ for (let j = 0; j < regions.length; j++) {
1443
+ const crop = regions[j]
1444
+ const ref = await imageObject(crop)
1445
+ const name = page === 'fit'
1446
+ ? `Im${j}`
1447
+ : `Ip${p}${j ? `_${j}` : ''}`
1448
+ pageXObjects.push(`/${name} ${ref.id} 0 R`)
1449
+ const contentY = (crop.y - offY) * k
1450
+ const regionTop = pageTop - (contentY - slice.top)
1451
+ drawImage(body, name, originFor(pageTop, slice.top), regionTop, crop.height * k, crop)
1452
+ }
1453
+ // A control that became a field leaves the page image: its area is filled
1454
+ // with the control's own background, so the widget — crop AP here, a
1455
+ // PDFKit-regenerated face on macOS — is the ONLY thing painting its value.
1456
+ for (const pl of fieldPlacements[p]) {
1457
+ const [llx, lly, urx, ury] = pl.rect
1458
+ const bg = pl.member.control.style.bg || [1, 1, 1]
1459
+ body.push(`${bg.map(v => v.toFixed(3)).join(' ')} rg ` +
1460
+ `${llx.toFixed(3)} ${lly.toFixed(3)} ${(urx - llx).toFixed(3)} ${(ury - lly).toFixed(3)} re f`)
1461
+ }
1462
+ artifact(ops, body)
1463
+ }
1464
+ if (debugText) ops.push('1 0 0 rg')
1465
+
1466
+ const pageFonts = new Set()
1467
+
1468
+ // Repeated headers, stacked down from the top margin — the same pixels of the
1469
+ // same raster, re-stamped. Nothing is re-drawn and nothing is synthesized:
1470
+ // what a reader sees at the top of page 2 is the crop of page 1 they already
1471
+ // saw, which is the only way this can keep the plugin's one promise.
1472
+ let bandTop = contentTop
1473
+ for (const b of rep.bands) {
1474
+ const height = b.bottom - b.top
1475
+ const origin = originFor(bandTop, b.top)
1476
+ // Wholly an artifact, pixels AND words: this header was already read on the
1477
+ // page it belongs to, and a reader that meets it again on every page is
1478
+ // being read a table's header five times.
1479
+ const body = []
1480
+ if (image) {
1481
+ const index = repeatBands.indexOf(b)
1482
+ const regions = cropsFor(b.top, height)
1483
+ for (let j = 0; j < regions.length; j++) {
1484
+ const crop = regions[j]
1485
+ const ref = await imageObject(crop)
1486
+ const name = `Ir${index}${j ? `_${j}` : ''}`
1487
+ pageXObjects.push(`/${name} ${ref.id} 0 R`)
1488
+ const contentY = (crop.y - offY) * k
1489
+ const regionTop = bandTop - (contentY - b.top)
1490
+ drawImage(body, name, origin, regionTop, crop.height * k, crop)
1491
+ }
1492
+ }
1493
+ if (text) {
1494
+ for (const i of bandRuns[repeatBands.indexOf(b)]) {
1495
+ if (suppressedRuns.has(i)) continue
1496
+ if (!drawRun(body, pageFonts, layer.runs[i], bodyMap(origin))) dropped++
1497
+ }
1498
+ }
1499
+ artifact(ops, body)
1500
+ bandTop -= height
1501
+ }
1502
+
1503
+ if (text) {
1504
+ for (let i = 0; i < layer.runs.length; i++) {
1505
+ if (runPages[i] !== p) continue
1506
+ // A value that became a fillable field's /V leaves the invisible layer:
1507
+ // extraction must return it once, from the field.
1508
+ if (suppressedRuns.has(i)) continue
1509
+ const body = []
1510
+ if (!drawRun(body, pageFonts, layer.runs[i], bodyMap(originFor(pageTop, slice.top)))) {
1511
+ dropped++
1512
+ continue
1513
+ }
1514
+ marked(struct && struct.ofRun[i], body)
1515
+ }
1516
+ }
1517
+
1518
+ // Running heads, page numbers and the watermark are pagination furniture by
1519
+ // definition — they belong to the paper, not to the document — so every one of
1520
+ // them is an artifact and none reaches the structure tree.
1521
+ const trim = []
1522
+ for (const f of furniture) {
1523
+ trim.push(...bandOps({ where: f.where, name: f.name, heightPt: f.heightPt,
1524
+ pageH, marginX, marginY, availW }))
1525
+ }
1526
+ for (const [where, spec] of [['header', bands.header], ['footer', bands.footer]]) {
1527
+ if (!spec || spec.kind !== 'text') continue
1528
+ const band = textBandOps({ spec, where, page: p + 1, pages: slices.length,
1529
+ pageH, marginX, marginY, availW, pdfText })
1530
+ if (band) { for (const f of band.fonts) pageFonts.add(f); trim.push(...band.ops) }
1531
+ }
1532
+ if (numbers) {
1533
+ const n = numberOps({ spec: numbers, page: p + 1, pages: slices.length,
1534
+ pageW, marginX, marginY, availW, pdfText })
1535
+ if (n) { pageFonts.add(n.font); trim.push(...n.ops) }
1536
+ }
1537
+ // Last, so it is over everything: a watermark under the page image would be
1538
+ // invisible on any page with a background.
1539
+ trim.push(...markOps(pageFonts))
1540
+ artifact(ops, trim)
1541
+
1542
+ const annots = []
1543
+ if (links) {
1544
+ for (let j = 0; j < layer.links.length; j++) {
1545
+ const link = layer.links[j]
1546
+ const cy = toContentY(link.y)
1547
+ const h = link.height * k
1548
+ const w = link.width * k
1549
+ // A link may legitimately appear on both pages it spans.
1550
+ if (cy + h <= slice.top || cy >= slice.top + slice.height) continue
1551
+ const top = pageTop - (cy - slice.top)
1552
+ const cx = toContentX(link.x)
1553
+ const action = actionFor(link.href)
1554
+ if (!action) continue
1555
+ // Intersect with both the slice's painted content window and MediaBox.
1556
+ // A link that straddles a break becomes two trimmed annotations, never a
1557
+ // rectangle reaching into the next page or into margins/furniture.
1558
+ const id = addLinkAnnot({
1559
+ left: Math.max(marginX, marginX + cx),
1560
+ right: Math.min(marginX + availW, marginX + cx + w),
1561
+ bottom: Math.max(pageTop - slice.height, top - h),
1562
+ top: Math.min(pageTop, top),
1563
+ }, action, { node: struct && struct.ofLink && struct.ofLink[j], pageId: pageIds[p] })
1564
+ if (id) annots.push(`${id} 0 R`)
1565
+ }
1566
+ }
1567
+
1568
+ // Fillable fields whose control lands on this page, on the same rectangle
1569
+ // the page image just yielded. A valued field's appearance is real text
1570
+ // built from the control's own measured runs; an empty field's is the
1571
+ // control's crop of the capture. The split is PDFKit's doing — see forms.js.
1572
+ for (const pl of fieldPlacements[p]) {
1573
+ const { field, member } = pl
1574
+ const c = member.control
1575
+ let cropId = null
1576
+ // Only an EMPTY field's appearance is the control's raster crop — a valued
1577
+ // one gets real text (see forms.js) — so the encode runs only when the
1578
+ // appearance will reference it.
1579
+ if (image && wantsCropAP(field, c)) {
1580
+ const cropX = Math.max(0, Math.min(vbW, offX + (c.rect.left - rect.left)))
1581
+ const cropY = Math.max(0, Math.min(vbH, offY + (c.rect.top - rect.top)))
1582
+ const cropW = Math.min(vbW - cropX, c.rect.width)
1583
+ const cropH = Math.min(vbH - cropY, c.rect.height)
1584
+ if (cropW > 0 && cropH > 0) {
1585
+ const ref = await imageObject({ x: cropX, y: cropY, width: cropW, height: cropH })
1586
+ cropId = ref.id
1587
+ }
1588
+ }
1589
+ // The control's own value runs, re-based into the widget's box: the same
1590
+ // wrap, alignment and scroll the page painted, now drawn as the field's
1591
+ // appearance text. A password's runs are its bullets, and an EMPTY field
1592
+ // must look empty — so it contributes none.
1593
+ const lines = field.kind === 'password' ? []
1594
+ : (c.runs || []).map(i => layer.runs[i]).filter(Boolean).map(run => ({
1595
+ text: run.text,
1596
+ x: (run.originX - c.rect.left) * k,
1597
+ y: (c.rect.top + c.rect.height - run.originY) * k,
1598
+ size: run.size * k,
1599
+ }))
1600
+ const id = fieldWriter.widget(field, member, pl.rect, c.style.size * k, cropId, lines)
1601
+ annots.push(`${id} 0 R`)
1602
+ if (struct && struct.ofControl && struct.ofControl[member.i]) {
1603
+ annotStruct.push({
1604
+ id, dict: null, member,
1605
+ node: struct.ofControl[member.i], pageId: pageIds[p],
1606
+ })
1607
+ }
1608
+ annotCount++
1609
+ }
1610
+
1611
+ // debugText exists to be read, so it keeps its content stream in the clear.
1612
+ const body = ops.join('\n')
1613
+ const deflated = wantDeflate && canDeflate && !debugText
1614
+ const contentId = doc.addStream(
1615
+ deflated ? '/Filter /FlateDecode' : '',
1616
+ deflated ? await deflate(body) : body
1617
+ )
1618
+ doc.fill(pageIds[p],
1619
+ pageDict({
1620
+ contentId, annots, fonts: pageFonts, structParents: struct ? p : null,
1621
+ xobjects: pageXObjects,
1622
+ })
1623
+ )
1624
+ flow?.tick('page', p + 1, slices.length)
1625
+ }
1626
+
1627
+ // ——— front and back matter ———
1628
+ //
1629
+ // Built AFTER the body, because none of it can be built before: an index needs
1630
+ // to know which page each heading landed on, and that is not knowable until the
1631
+ // body has been cut. Page objects are added in whatever order suits; only the
1632
+ // /Kids array below decides what comes first.
1633
+ if (page === 'fit' && (cover || back || toc)) {
1634
+ report("cover, back and toc need a paginated page size — they do nothing on page:'fit'.")
1635
+ }
1636
+ const paginated = page !== 'fit'
1637
+
1638
+ /** One finished page from a list of ops — the same stream handling the body gets. */
1639
+ const addPage = async (ops, { annots = [], fonts, xobjects = [] }) => {
1640
+ const body = ops.join('\n')
1641
+ const deflated = wantDeflate && canDeflate && !debugText
1642
+ const contentId = doc.addStream(
1643
+ deflated ? '/Filter /FlateDecode' : '',
1644
+ deflated ? await deflate(body) : body
1645
+ )
1646
+ return doc.add(pageDict({ contentId, annots, fonts, xobjects }))
1647
+ }
1648
+
1649
+ /**
1650
+ * A cover or a colophon: your own element, captured, one to a page — and given
1651
+ * the SAME invisible text layer the body gets.
1652
+ *
1653
+ * That layer is not a nicety here. A closing page is where the legal text goes,
1654
+ * and legal text nobody can search or copy is a picture of a contract. It is
1655
+ * measured from that element's own final SnapDOM artifact at export, so the
1656
+ * same exclusion, resolver and clone-plugin policy governs pixels and semantics.
1657
+ */
1658
+ const matterPage = async (el, name, where) => {
1659
+ // Auxiliary pages get their own final SnapDOM artifact. Measuring `el`
1660
+ // directly would bypass exclude/filter/resolveNode/afterClone and could put a
1661
+ // redacted secret back into text, links, outline or structure.
1662
+ const shot = await captureElement(el, {
1663
+ scale: options.scale, dpr: options.dpr, backgroundColor: null, quality, codec,
1664
+ captureOptions: options.captureOptions,
1665
+ image,
1666
+ }, image ? encodeOpaque : null)
1667
+ const subMeasured = await measureArtifact(shot.url, shot.meta, {
1668
+ formValues: measured.formValues !== false,
1669
+ shadow: measured.shadow !== false,
1670
+ breakAvoid: false,
1671
+ outline: false,
1672
+ // A cover or colophon is furniture around the document; a fillable field
1673
+ // on one would be a form nobody expects to submit from a title page.
1674
+ forms: false,
1675
+ lang: lang || undefined,
1676
+ }, shot.source)
1677
+ // A matter page is measured in isolation, so a valid link to the body appears
1678
+ // unresolved here. `actionFor` below resolves it against the combined document
1679
+ // and reports only links that are still genuinely unresolved.
1680
+ for (const note of subMeasured.notes) {
1681
+ if (!/^\d+ internal link\(s\) point outside the final captured tree or clip/.test(note)) {
1682
+ report(`${where}: ${note}`)
1683
+ }
1684
+ }
1685
+ let xid = null
1686
+ if (image) {
1687
+ const mask = shot.smask ? ` /SMask ${doc.addStream(shot.smask.dict, shot.smask.bytes)} 0 R` : ''
1688
+ xid = doc.addStream(shot.dict + mask, shot.bytes)
1689
+ }
1690
+ const fonts = new Set()
1691
+ const marks = matterMarks()
1692
+ const pageId = doc.reserve()
1693
+ const ops = []
1694
+ if (image) artifact(ops, fullPageOps({ name, pageW, pageH, aspect: shot.aspect }))
1695
+
1696
+ const annots = []
1697
+ if (text || links || struct) {
1698
+ // The same fit `fullPageOps` computed, recovered rather than passed: one of
1699
+ // them would drift the day the other changed.
1700
+ const ratio = shot.aspect > 0 ? shot.aspect : 1
1701
+ let w = pageW
1702
+ let h = w * ratio
1703
+ if (h > pageH) { h = pageH; w = h / ratio }
1704
+ const s = shot.vbW > 0 ? w / shot.vbW : 1
1705
+ const box = subMeasured.rect
1706
+ const map = {
1707
+ ax: (pageW - w) / 2 + (shot.offX - box.left) * s,
1708
+ ay: (pageH - h) / 2 + h + (box.top - shot.offY) * s,
1709
+ s,
1710
+ }
1711
+ let collisions = 0
1712
+ for (const [destName, at] of subMeasured.nav?.dests || []) {
1713
+ // Body destinations are established first and always win. This keeps a
1714
+ // cover accidentally reusing `#intro` from retargeting every body link.
1715
+ if (dests.has(destName)) { collisions++; continue }
1716
+ const x = at.top ? 0 : Math.min(pageW, Math.max(0, map.ax + at.x * map.s))
1717
+ const y = at.top ? pageH : Math.min(pageH, Math.max(0, map.ay - at.y * map.s))
1718
+ dests.set(destName,
1719
+ `[${pageId} 0 R /XYZ ${x.toFixed(3)} ${y.toFixed(3)} null]`)
1720
+ }
1721
+ if (collisions) {
1722
+ report(`${where}: ${collisions} destination name collision(s) kept the body destination.`)
1723
+ }
1724
+ if (debugText) ops.push('1 0 0 rg')
1725
+ const sub = subMeasured.layer
1726
+ unnamedImages += sub.unnamedImages
1727
+ // A matter page was measured from its own artifact, so its font keys index
1728
+ // its own table — resolved here against its own rules, sharing the byte
1729
+ // cache (a cover set in the body's face parses the binary once).
1730
+ const subFaces = await facesFor(sub.fontKeys, sub.runs, subMeasured.fontRules)
1731
+ // Its own structure, grafted under a Sect: a colophon is a section of the
1732
+ // document, and untagged content inside a tagged file is content a reader is
1733
+ // entitled to skip.
1734
+ const subStruct = struct ? subMeasured.structure : null
1735
+ const section = subStruct && { type: 'Sect', alt: null, lang: null, children: [] }
1736
+ if (text) {
1737
+ for (let i = 0; i < sub.runs.length; i++) {
1738
+ const body = []
1739
+ if (!drawRun(body, fonts, sub.runs[i], map, subFaces)) { dropped++; continue }
1740
+ const node = subStruct && subStruct.ofRun[i]
1741
+ if (node && marks) ops.push(...marks.mark(node, pageId, body))
1742
+ else ops.push(...body)
1743
+ }
1744
+ }
1745
+ if (section) {
1746
+ section.children = subStruct.root.children
1747
+ ;(where === 'cover' ? frontStruct : backStruct).push(section)
1748
+ }
1749
+
1750
+ if (links) {
1751
+ for (let j = 0; j < sub.links.length; j++) {
1752
+ const link = sub.links[j]
1753
+ const action = actionFor(link.href, subMeasured.documentURL)
1754
+ if (!action) continue
1755
+ const id = addLinkAnnot({
1756
+ left: map.ax + link.x * map.s,
1757
+ bottom: map.ay - (link.y + link.height) * map.s,
1758
+ right: map.ax + (link.x + link.width) * map.s,
1759
+ top: map.ay - link.y * map.s,
1760
+ }, action, {
1761
+ node: subStruct && subStruct.ofLink && subStruct.ofLink[j],
1762
+ pageId,
1763
+ })
1764
+ if (id) annots.push(`${id} 0 R`)
1765
+ }
1766
+ }
1767
+ }
1768
+
1769
+ artifact(ops, markOps(fonts))
1770
+ const body = ops.join('\n')
1771
+ const deflated = wantDeflate && canDeflate && !debugText
1772
+ const contentId = doc.addStream(
1773
+ deflated ? '/Filter /FlateDecode' : '',
1774
+ deflated ? await deflate(body) : body
1775
+ )
1776
+ doc.fill(pageId, pageDict({
1777
+ contentId, annots, fonts, xobjects: image ? [`/${name} ${xid} 0 R`] : [],
1778
+ structParents: marks ? marks.key : null,
1779
+ }))
1780
+ return pageId
1781
+ }
1782
+
1783
+ const frontIds = []
1784
+ const backIds = []
1785
+ if (paginated && cover && cover.nodeType === 1) {
1786
+ flow?.check()
1787
+ const id = await matterPage(cover, 'Ic', 'cover')
1788
+ if (id) frontIds.push(id)
1789
+ flow?.tick('matter')
1790
+ }
1791
+
1792
+ /**
1793
+ * One index line's title.
1794
+ *
1795
+ * Base-14 text when WinAnsi can address it — crisp at any zoom, a few dozen
1796
+ * bytes. When it cannot, the answer is NOT to paint the glyph-less Type0 font:
1797
+ * it is to CROP THE HEADING OUT OF THE PAGE RASTER, the same re-stamp a
1798
+ * repeated table header uses, scaled down to the line. The heading's pixels
1799
+ * already exist in the file; a Japanese index reads because snapdom drew it,
1800
+ * not because a font was embedded. The invisible text goes over the crop, so
1801
+ * the line is still searchable and copyable.
1802
+ *
1803
+ * @returns {{ops: string[], width: number, fonts: object[]}|null}
1804
+ */
1805
+ let cropped = 0
1806
+ const tocImages = new Map()
1807
+ /**
1808
+ * How far the glyphs inside a heading's box actually reach, in viewport CSS px.
1809
+ * Falls back to the box when nothing in the layer sits there — a heading that
1810
+ * is an image, or one whose runs were dropped as clipped.
1811
+ */
1812
+ const inkWidth = (box) => {
1813
+ let right = box.x
1814
+ for (const run of layer.runs) {
1815
+ if (run.originY < box.y - 1 || run.originY > box.y + box.h + 1) continue
1816
+ if (run.originX < box.x - 1) continue
1817
+ const edge = run.originX + run.width
1818
+ if (edge <= box.x + box.w + 1 && edge > right) right = edge
1819
+ }
1820
+ return right > box.x ? right - box.x : box.w
1821
+ }
1822
+ const drawTitle = ({ entry, key, size, x, baseline, room, colour }) => {
1823
+ const flat = pdfText(entry.title, key, size, { paint: false })
1824
+ if (flat && flat.paintable) {
1825
+ const title = fitText(entry.title, room, (t) => {
1826
+ const m = pdfText(t, key, size, { paint: false })
1827
+ return m ? m.width : Infinity
1828
+ })
1829
+ const drawn = title ? pdfText(title, key, size, { paint: false }) : null
1830
+ if (!drawn) return null
1831
+ return {
1832
+ width: drawn.width,
1833
+ fonts: [drawn.font],
1834
+ ops: [
1835
+ `${colour.map(c => c.toFixed(3)).join(' ')} rg`,
1836
+ `BT 0 Tr /${drawn.font.name} ${size} Tf 1 0 0 1 ` +
1837
+ `${x.toFixed(3)} ${baseline.toFixed(3)} Tm ${drawn.str} Tj ET`,
1838
+ ],
1839
+ }
1840
+ }
1841
+
1842
+ // Everything WinAnsi cannot address. Without the raster there is nothing to
1843
+ // crop from, and nothing this plugin can honestly paint.
1844
+ const box = entry.box
1845
+ const cropImage = tocImages.get(entry)
1846
+ if (!image || !box || !cropImage || !(box.w > 0) || !(box.h > 0)) { unpaintable++; return null }
1847
+ // The heading's INK, not its box: a block-level heading's box is the whole
1848
+ // column, and cropping that would leave the dot leader starting a third of
1849
+ // the way across the page with nothing before it. The runs already know where
1850
+ // the glyphs stop.
1851
+ const bw = inkWidth(box) * k
1852
+ const bh = box.h * k
1853
+ // Scale the heading down to the index line's own type size.
1854
+ const scale = (size * 1.2) / bh
1855
+ const naturalWidth = bw * scale
1856
+ const width = Math.min(naturalWidth, room)
1857
+ const height = bh * scale
1858
+ const top = baseline + size
1859
+
1860
+ // This XObject is already the heading's viewBox crop, so it can be placed
1861
+ // directly. No full-document raster is allocated merely to paint one title.
1862
+ const ops = [
1863
+ 'q',
1864
+ `${x.toFixed(3)} ${(top - height).toFixed(3)} ${width.toFixed(3)} ${height.toFixed(3)} re W n`,
1865
+ `${naturalWidth.toFixed(3)} 0 0 ${height.toFixed(3)} ${x.toFixed(3)} ${(top - height).toFixed(3)} cm`,
1866
+ `/${cropImage.name} Do`,
1867
+ 'Q',
1868
+ ]
1869
+ const fonts = []
1870
+ // Invisible, so the line is searchable and copyable — the sanctioned use of
1871
+ // the glyph-less font, and the only one.
1872
+ const hidden = pdfText(entry.title, key, size, { paint: false })
1873
+ if (hidden) {
1874
+ fonts.push(hidden.font)
1875
+ const tz = hidden.width > 0
1876
+ ? Math.max(1, Math.min(1000, (width / hidden.width) * 100)).toFixed(2)
1877
+ : '100'
1878
+ ops.push(`BT 3 Tr /${hidden.font.name} ${size} Tf ${tz} Tz 1 0 0 1 ` +
1879
+ `${x.toFixed(3)} ${baseline.toFixed(3)} Tm ${hidden.str} Tj ET`)
1880
+ }
1881
+ cropped++
1882
+ return { ops, width, fonts, xobjects: [`/${cropImage.name} ${cropImage.id} 0 R`] }
1883
+ }
1884
+
1885
+ const index = tocSpec(paginated ? toc : null)
1886
+ if (index) {
1887
+ // Depth from the outline TREE, not the heading level: a document that starts
1888
+ // at h2 should still show its two tiers, and a level gap is not an indent.
1889
+ const entries = []
1890
+ const gather = (nodes, depth) => {
1891
+ if (depth >= index.levels) return
1892
+ for (const node of nodes) {
1893
+ entries.push({
1894
+ depth,
1895
+ title: node.title,
1896
+ // Carried all the way from `collectNav`: without the heading's own box
1897
+ // there is nothing for a non-Latin line to crop.
1898
+ box: node.box || null,
1899
+ dest: dests.get(node.dest) || null,
1900
+ label: index.label(destPage.has(node.dest) ? destPage.get(node.dest) + 1 : 1, slices.length),
1901
+ })
1902
+ gather(node.children, depth + 1)
1903
+ }
1904
+ }
1905
+ gather(outlineTree(nav?.outline || []), 0)
1906
+
1907
+ // Pre-decode one small crop for each non-WinAnsi title. tocLayout/drawTitle
1908
+ // stay synchronous, while the expensive raster work remains explicit here.
1909
+ if (image) {
1910
+ for (let i = 0; i < entries.length; i++) {
1911
+ const entry = entries[i]
1912
+ const sample = pdfText(entry.title, index.font, index.size, { paint: false })
1913
+ if (sample?.paintable || !entry.box) continue
1914
+ const box = entry.box
1915
+ const left = Math.max(0, offX + (box.x - rect.left))
1916
+ const top = Math.max(0, offY + (box.y - rect.top))
1917
+ const right = Math.min(vbW, left + inkWidth(box))
1918
+ const bottom = Math.min(vbH, top + box.h)
1919
+ if (!(right > left) || !(bottom > top)) continue
1920
+ const ref = await imageObject({ x: left, y: top, width: right - left, height: bottom - top })
1921
+ tocImages.set(entry, { name: `It${i}`, id: ref.id })
1922
+ }
1923
+ }
1924
+
1925
+ if (!entries.length) {
1926
+ report('toc was asked for but the document has no headings to put in one — no index page written.')
1927
+ } else {
1928
+ // One /TOC element for the whole index, however many pages it runs to. Its
1929
+ // lines are generated navigation rather than prose, so they go out as ONE
1930
+ // marked sequence per page instead of one per line: a reader is told "this
1931
+ // is a table of contents" and then reads it in order, which is what a table
1932
+ // of contents is for.
1933
+ const tocNode = struct ? { type: 'TOC', alt: null, lang: null, children: [] } : null
1934
+ if (tocNode) frontStruct.push(tocNode)
1935
+
1936
+ for (const built of tocLayout({
1937
+ entries, spec: index, pdfText, drawTitle, marginX, availW, contentTop, availH,
1938
+ })) {
1939
+ const annots = links ? built.annots.map(a => {
1940
+ annotCount++
1941
+ return `${doc.add(
1942
+ `<< /Type /Annot /Subtype /Link /Border [0 0 0] ` +
1943
+ `/Rect [${a.rect.map(n => n.toFixed(3)).join(' ')}] /Dest ${a.dest} >>`
1944
+ )} 0 R`
1945
+ }) : []
1946
+ const marks = matterMarks()
1947
+ const pageId = doc.reserve()
1948
+ const ops = marks
1949
+ ? marks.mark(tocNode, pageId, built.ops)
1950
+ : [...built.ops]
1951
+ artifact(ops, markOps(built.fonts))
1952
+ const body = ops.join('\n')
1953
+ const deflated = wantDeflate && canDeflate && !debugText
1954
+ const contentId = doc.addStream(
1955
+ deflated ? '/Filter /FlateDecode' : '',
1956
+ deflated ? await deflate(body) : body
1957
+ )
1958
+ doc.fill(pageId, pageDict({
1959
+ contentId, annots, fonts: built.fonts, xobjects: [...built.xobjects],
1960
+ structParents: marks ? marks.key : null,
1961
+ }))
1962
+ frontIds.push(pageId)
1963
+ flow?.tick('matter')
1964
+ }
1965
+ }
1966
+ }
1967
+
1968
+ if (paginated && back && back.nodeType === 1) {
1969
+ flow?.check()
1970
+ const id = await matterPage(back, 'Ib', 'back')
1971
+ if (id) backIds.push(id)
1972
+ flow?.tick('matter')
1973
+ }
1974
+
1975
+ if (unlinked) {
1976
+ report(`${unlinked} in-document link(s) got no annotation — their fragment has no destination here.`)
1977
+ }
1978
+
1979
+ // `quality` only ever reaches the JPEG candidate. When every region compressed
1980
+ // smaller losslessly, a lowered quality changed nothing at all — same bytes,
1981
+ // same size — and saying so is the difference between a knob that looks broken
1982
+ // and one that was simply not the knob for this document.
1983
+ if (codec === 'auto' && quality < 0.92 && losslessRegions && !jpegRegions) {
1984
+ report(`quality ${quality} changed nothing: all ${losslessRegions} page image(s) compressed ` +
1985
+ 'smaller LOSSLESSLY than any JPEG, so the JPEG quality controls was discarded. Page artwork ' +
1986
+ 'is flat runs and sharp edges, which deflate handles better than DCT. For a lighter file ' +
1987
+ "lower the capture's `scale`/`dpr`, or force `codec: 'jpeg'` and accept the artefacts.")
1988
+ }
1989
+
1990
+ // Counted across every page in the file, front matter included, and therefore
1991
+ // reported only once everything has been drawn.
1992
+ if (offPage) {
1993
+ report(`${offPage} text run(s) land outside the page box and will not be selectable — ` +
1994
+ 'content painted outside the capture element is not in the raster either.')
1995
+ }
1996
+ if (dropped) {
1997
+ report(`${dropped} text run(s) left out of the text layer (more than 65,534 distinct codepoints).`)
1998
+ }
1999
+ if (vectorText && vectorPainted) {
2000
+ report(`${vectorPainted} text run(s) painted as real vector glyphs from the embedded fonts — ` +
2001
+ 'crisp at any zoom' + (image ? ', over the raster.' : ', and the raster is off.'))
2002
+ }
2003
+ if (vectorMissed) {
2004
+ report(`${vectorMissed} text run(s) could not be painted as vector (RTL, no embedded face, or an ` +
2005
+ 'unresolved colour) and there is no raster to show them — they are searchable but not visible. ' +
2006
+ 'Keep image:true for those, or embed a covering font.')
2007
+ }
2008
+ if (cropped) {
2009
+ report(`${cropped} index line(s) show the heading's own pixels rather than typeset text — ` +
2010
+ 'their script is outside base-14, and this plugin embeds no fonts. They stay searchable.')
2011
+ }
2012
+ if (unpaintable) {
2013
+ report(`${unpaintable} piece(s) of page furniture could not be drawn: the plugin's own text is ` +
2014
+ 'base-14 only and cannot address that script. Put it in an element instead — header, footer ' +
2015
+ 'and watermark all take one, and elements are captured.')
2016
+ }
2017
+
2018
+ flow?.check()
2019
+ flow?.tick('assemble')
2020
+
2021
+ // ——— structured annotations and fields ———
2022
+ //
2023
+ // Only now can /StructParent keys exist: every page — body, cover, contents,
2024
+ // colophon — has claimed its /StructParents key, and annotation keys must
2025
+ // continue past ALL of them in the one shared number tree. Links reserved
2026
+ // their dictionaries; widgets waited for their parent fields. Both get their
2027
+ // key, their bytes, and their /OBJR back into the owning node here.
2028
+ const widgetKeys = new Map()
2029
+ const annotRefs = []
2030
+ {
2031
+ let nextKey = markOwners.length
2032
+ for (const entry of annotStruct) {
2033
+ const key = nextKey++
2034
+ if (entry.dict !== null) doc.fill(entry.id, entry.dict + ` /StructParent ${key} >>`)
2035
+ else widgetKeys.set(entry.member, key)
2036
+ const list = marksOf.get(entry.node) || []
2037
+ list.push({ objr: entry.id, page: entry.pageId })
2038
+ marksOf.set(entry.node, list)
2039
+ annotRefs.push({ key, node: entry.node })
2040
+ }
2041
+ }
2042
+ const acroForm = fieldWriter
2043
+ ? fieldWriter.finish(member => widgetKeys.get(member) ?? null, fieldPlan.needAppearances)
2044
+ : null
2045
+
2046
+ // Writes the ToUnicode CMap, so it has to run after the last encode().
2047
+ unicodeFont.finalize()
2048
+ // The embedded programs deflate here — after the last encode named its glyphs,
2049
+ // before the build wants their bytes.
2050
+ for (const f of embeddedFonts.values()) await f.finalize()
2051
+ if (embeddedFonts.size) {
2052
+ const faces = [...embeddedFonts.values()].filter(f => f.active)
2053
+ if (faces.length) {
2054
+ const subset = faces.every(f => f.subset)
2055
+ report(`fonts embedded: ${faces.map(f =>
2056
+ `${f.label} (${f.kB} kB${f.subset ? ` subset of ${f.wholeKB} kB` : ' whole'})`).join(', ')} — ` +
2057
+ (subset
2058
+ ? 'only the glyphs the page drew travel, renumbered into a dense subset.'
2059
+ : 'the whole file travels; glyphs are the ones the page was painted with.'))
2060
+ }
2061
+ }
2062
+ if (coverageFallbacks) {
2063
+ report(`${coverageFallbacks} run(s) paint characters their embedded face lacks — those runs ` +
2064
+ 'fall back to the unembedded layer and stay searchable.')
2065
+ }
2066
+
2067
+ // Bookmarks and named destinations. The name tree is written whether or not
2068
+ // there is an outline, because it is what makes `report.pdf#intro` work from a
2069
+ // href, an email or another PDF — a cross-reference into this file, from
2070
+ // outside it, without anything here knowing who is pointing.
2071
+ // `collectNav` speaks in destination NAMES, because a name is all it can know
2072
+ // before pagination exists. Only here is a name a point on a page, so this is
2073
+ // where the tree stops naming and starts pointing.
2074
+ const pointAt = (nodes) => nodes.map(node => ({
2075
+ title: node.title,
2076
+ dest: dests.get(node.dest) || null,
2077
+ children: pointAt(node.children),
2078
+ }))
2079
+ const outlineId = writeOutlines(doc, pointAt(outlineTree(nav?.outline || [])))
2080
+ const namesId = writeNameTree(doc, dests)
2081
+
2082
+ // Structure last: a node only earns its place once something on a page pointed
2083
+ // back at it, and that is only known after every page has been drawn.
2084
+ // Front matter reads first, back matter last — the order of the PAGES, which is
2085
+ // not the order they were built in.
2086
+ const structRoot = struct && {
2087
+ type: 'Document', alt: null, lang: null,
2088
+ children: [...frontStruct, ...struct.root.children, ...backStruct],
2089
+ }
2090
+ const structId = struct
2091
+ ? writeStructTree(doc, structRoot, markOwners.map((_, i) => i), markOwners, marksOf, annotRefs)
2092
+ : null
2093
+
2094
+ // The only place page ORDER is decided. Everything above added its objects
2095
+ // whenever it happened to be built.
2096
+ const kids = [...frontIds, ...pageIds, ...backIds]
2097
+ doc.fill(pagesId, `<< /Type /Pages /Kids [${kids.map(id => `${id} 0 R`).join(' ')}] /Count ${kids.length} >>`)
2098
+
2099
+ // PDF/UA-1 eligibility, COMPUTED rather than configured. The claim is written
2100
+ // only when this export can show its work: every text-showing operation rode
2101
+ // an embedded face, the structure tree exists, the document says its language
2102
+ // and displays its title. Anything short of that is a named blocker — and the
2103
+ // absence of a claim, never a lie in the XMP.
2104
+ const fieldsWritten = fieldWriter ? fieldPlan.fields.length : 0
2105
+ const uaBlockers = []
2106
+ if (!embedFonts) uaBlockers.push('embedFonts: false')
2107
+ if (!struct) uaBlockers.push('tagged: false')
2108
+ if (!(lang || struct?.lang)) uaBlockers.push('no document language')
2109
+ if (!docMetaSpec?.title) uaBlockers.push('no meta.title to display')
2110
+ if (!embeddedFonts.size) uaBlockers.push('no embeddable @font-face behind the text')
2111
+ if (usedFonts.size) {
2112
+ const stacks = new Set()
2113
+ for (const run of layer.runs) {
2114
+ if (run.fk >= 0 && !bodyFaces[run.fk] && layer.fontKeys[run.fk]) {
2115
+ stacks.add(layer.fontKeys[run.fk].stack.split(',')[0].trim().replace(/^["']|["']$/g, ''))
2116
+ }
2117
+ }
2118
+ uaBlockers.push(`base-14 text remains${stacks.size ? ` (system or unfetched: ${[...stacks].slice(0, 4).join(', ')})` : ''}`)
2119
+ }
2120
+ if (unicodeFont.id !== null) uaBlockers.push('glyph-less fallback text remains')
2121
+ if (fieldsWritten) uaBlockers.push('fillable fields type in unembedded Helvetica')
2122
+ if (unnamedImages) uaBlockers.push(`${unnamedImages} image(s) without alternative text`)
2123
+ const claimUA = uaBlockers.length === 0
2124
+ if (claimUA) {
2125
+ report('document claims PDF/UA-1: every text operation uses an embedded face, structure is ' +
2126
+ 'tagged, language and displayed title are set.')
2127
+ } else if (embedFonts && struct && embeddedFonts.size && !usedFonts.size && unicodeFont.id === null) {
2128
+ // Fonts are fully clean — the remaining blockers are one option away, so
2129
+ // they are worth a sentence. A plain default export stays quiet.
2130
+ report(`PDF/UA-1 claim withheld: ${uaBlockers.join('; ')}.`)
2131
+ }
2132
+
2133
+ // Document identity. /Info is written always — /Producer is how a support
2134
+ // case tells this exporter's files apart — while the XMP packet exists only
2135
+ // when the caller said something worth mirroring into it.
2136
+ const infoId = doc.add(infoDict(docMetaSpec))
2137
+ // ENCODED, not passed as a string: the packet opens with the U+FEFF the XMP
2138
+ // spec demands and may carry any script the metadata does. The writer's
2139
+ // string path is Latin-1 and would truncate both — the BOM became a lone
2140
+ // 0xFF, malformed UTF-8 inside the very stream a validator reads first.
2141
+ const xmpId = docMetaSpec
2142
+ ? doc.addStream('/Type /Metadata /Subtype /XML', new TextEncoder().encode(
2143
+ xmpPacket(docMetaSpec, lang || struct?.lang || null, { pdfua: claimUA })))
2144
+ : null
2145
+ const labels = pageLabels({
2146
+ front: frontIds.length, body: pageIds.length, back: backIds.length,
2147
+ })
2148
+
2149
+ doc.fill(catalogId,
2150
+ `<< /Type /Catalog /Pages ${pagesId} 0 R` +
2151
+ (outlineId ? ` /Outlines ${outlineId} 0 R` : '') +
2152
+ // Opening the sidebar is right for a document and wrong for a picture, and
2153
+ // the page count is what tells them apart: a one-page `page: 'fit'` capture
2154
+ // with an outline is still a picture, and a panel over it is just chrome.
2155
+ (outlineId && kids.length > 1 ? ' /PageMode /UseOutlines' : '') +
2156
+ (namesId ? ` /Names << /Dests ${namesId} 0 R >>` : '') +
2157
+ (structId ? ` /StructTreeRoot ${structId} 0 R /MarkInfo << /Marked true >>` : '') +
2158
+ // The language of the document, which is what decides how a screen reader
2159
+ // PRONOUNCES it. A tagged file without one is tagged for a reader that has to
2160
+ // guess.
2161
+ (struct && (lang || struct.lang) ? ` /Lang ${pdfTextString(lang || struct.lang)}` : '') +
2162
+ (acroForm ? ` /AcroForm ${doc.add(acroForm)} 0 R` : '') +
2163
+ (xmpId ? ` /Metadata ${xmpId} 0 R` : '') +
2164
+ (labels ? ` /PageLabels ${labels}` : '') +
2165
+ // Without this, a viewer shows the FILENAME over a document that took the
2166
+ // trouble to say its title.
2167
+ (docMetaSpec?.title ? ' /ViewerPreferences << /DisplayDocTitle true >>' : '') +
2168
+ ' >>')
2169
+
2170
+ // ——— encryption ———
2171
+ //
2172
+ // Dead last, and after the catalog fill above: the pass rewrites every object
2173
+ // that exists, so anything written afterwards would travel in the clear inside
2174
+ // a file that says nothing is. Nothing may be added to `doc` below this line.
2175
+ if (encryptSpec) {
2176
+ const { p } = await doc.encrypt(catalogId, encryptSpec)
2177
+ report('the document is encrypted with AES-256 (standard security handler, revision 6). ' +
2178
+ 'Without the user password the bytes cannot be read at all.' + (p === -1 ? '' :
2179
+ ' Permission flags are written too, and a flag is a REQUEST a viewer may honour — ' +
2180
+ 'any tool can ignore it. Only the password protects anything.'))
2181
+ }
2182
+
2183
+ const blob = new Blob([doc.build(catalogId, { infoId, fileId: true })], { type: 'application/pdf' })
2184
+ return {
2185
+ blob,
2186
+ stats: {
2187
+ pages: kids.length,
2188
+ fields: fieldWriter ? fieldPlan.fields.length : 0,
2189
+ annotations: annotCount,
2190
+ fontsEmbedded: [...embeddedFonts.values()].filter(f => f.active)
2191
+ .map(f => ({ name: f.baseName, label: f.label, kB: f.kB, subset: f.subset })),
2192
+ pdfua: claimUA,
2193
+ vectorText: vectorPainted,
2194
+ },
2195
+ }
2196
+ }
2197
+
2198
+ /**
2199
+ * The plugin. Register it once and every capture gains a `toPdf()`.
2200
+ *
2201
+ * ```js
2202
+ * * import pdf from './snapdom-pdf.mjs'
2203
+ *
2204
+ * snapdom.plugins(pdf()) // or snapdom(el, { plugins: [pdf()] })
2205
+ * const blob = await (await snapdom(report)).toPdf({ page: 'a4' })
2206
+ * ```
2207
+ *
2208
+ * @param {object} [defaults] Options for every export this plugin serves. The
2209
+ * four CAPTURE-TIME ones can ONLY be set here, because they decide what the
2210
+ * capture measures:
2211
+ * @param {boolean} [defaults.formValues=true] Put input/textarea/select values in the layer.
2212
+ * @param {boolean} [defaults.shadow=true] Walk open shadow roots.
2213
+ * @param {boolean} [defaults.breakAvoid=true] Keep rows, list items and cards off page breaks.
2214
+ * @param {boolean|string} [defaults.outline=true] Build bookmarks from `h1`–`h6`
2215
+ * (and `role="heading"`). A CSS selector uses that instead; `false` writes no
2216
+ * outline. Named destinations and in-document links do not depend on it.
2217
+ * @returns {object} a snapdom plugin
2218
+ */
2219
+ export default function pdf(defaults = {}) {
2220
+ // Export is lazy and may happen long after plugin registration. Keep the
2221
+ // factory call deterministic even if its caller later reuses/mutates `defaults`.
2222
+ const pluginDefaults = snapshotOptions({ ...(defaults || {}) })
2223
+ return {
2224
+ name: 'snapdom-pdf',
2225
+ needs: 'render',
2226
+ pure: true,
2227
+
2228
+ // The searchable layer measures the final serialized SVG. A render-boundary
2229
+ // hook asks v3 to use that artifact even when html-in-canvas was requested.
2230
+ // This hook changes nothing, so repeated captures can still use burst/diff.
2231
+ beforeRender() {},
2232
+
2233
+ /**
2234
+ * defineExports runs after every afterRender hook. Closing over its canonical
2235
+ * URL therefore captures the final composed artifact, including another
2236
+ * plugin's redactions/replacements, without measuring every PNG capture.
2237
+ */
2238
+ defineExports(ctx) {
2239
+ const facade = ctx.exports
2240
+ const artifactURL = ctx.export?.url
2241
+ const rawMeta = ctx.meta || {}
2242
+ const meta = Object.freeze({
2243
+ w0: rawMeta.w0,
2244
+ h0: rawMeta.h0,
2245
+ vbW: rawMeta.vbW,
2246
+ vbH: rawMeta.vbH,
2247
+ targetW: rawMeta.targetW,
2248
+ targetH: rawMeta.targetH,
2249
+ contentX: rawMeta.contentX,
2250
+ contentY: rawMeta.contentY,
2251
+ clip: rawMeta.clip ? Object.freeze({ ...rawMeta.clip }) : null,
2252
+ })
2253
+ // v3 width/height specify absolute output pixels and suppress scale. Crops
2254
+ // inherit that density in their SVG header; clearing width/height at the
2255
+ // canvas facade must not make the user's ignored scale apply again.
2256
+ const absoluteSize = Number.isFinite(ctx.width) || Number.isFinite(ctx.height)
2257
+ const rasterScale = absoluteSize ? 1 : ctx.scale
2258
+ const captureDensity = absoluteSize
2259
+ ? (Number.isFinite(ctx.width) ? meta.targetW / meta.vbW : meta.targetH / meta.vbH)
2260
+ : ctx.scale
2261
+ const source = Object.freeze({
2262
+ baseURL: String(ctx.element?.ownerDocument?.baseURI || document.baseURI),
2263
+ documentURL: String(ctx.element?.ownerDocument?.location?.href ||
2264
+ ctx.element?.ownerDocument?.baseURI || location.href),
2265
+ documentLang: String(ctx.element?.ownerDocument?.documentElement?.lang ||
2266
+ document.documentElement?.lang || ''),
2267
+ })
2268
+ let measuredPromise = null
2269
+ // The ENCODED bytes are cached for the life of the capture, and the canvas
2270
+ // they came from is not. That asymmetry is the whole point: a page's canvas
2271
+ // is megabytes of RGBA and its JPEG is a couple of hundred KB, so keeping
2272
+ // every region's canvas alive — one per page — held a 40-page export's worth
2273
+ // of bitmaps on an object the caller is still holding. Nothing pays for the
2274
+ // drop: within one export every repeat of a region (a repeated table header,
2275
+ // a heading cropped for the index) hits this cache before a canvas is ever
2276
+ // asked for, and only re-exporting the SAME capture at a different quality,
2277
+ // codec or background rasterizes twice.
2278
+ const imageCache = new Map()
2279
+
2280
+ const cropKey = (crop) => crop
2281
+ ? ['crop', crop.x, crop.y, crop.width, crop.height].map(String).join(':')
2282
+ : 'full'
2283
+ const rasterFor = (options) => async (crop = null) => {
2284
+ const key = JSON.stringify([
2285
+ cropKey(crop), options.quality, options.backgroundColor, options.codec,
2286
+ ])
2287
+ if (imageCache.has(key)) return imageCache.get(key)
2288
+ const promise = Promise.resolve().then(async () => {
2289
+ // Alpha is kept here; background flattening belongs to encodeImage.
2290
+ const canvas = await facade.canvas({
2291
+ backgroundColor: null,
2292
+ width: absoluteSize ? (crop?.width ?? meta.vbW) * captureDensity : null,
2293
+ height: absoluteSize ? (crop?.height ?? meta.vbH) * captureDensity : null,
2294
+ scale: rasterScale,
2295
+ ...(crop ? { crop } : {}),
2296
+ })
2297
+ return encodeImage(canvas, {
2298
+ quality: options.quality,
2299
+ background: options.backgroundColor,
2300
+ codec: options.codec,
2301
+ })
2302
+ })
2303
+ imageCache.set(key, promise)
2304
+ promise.catch(() => { if (imageCache.get(key) === promise) imageCache.delete(key) })
2305
+ return promise
2306
+ }
2307
+ const measuredForCapture = () => {
2308
+ if (!measuredPromise) measuredPromise = measureArtifact(artifactURL, meta, pluginDefaults, source)
2309
+ return measuredPromise
2310
+ }
2311
+
2312
+ return {
2313
+ pdf: async (exportCtx, exportOptions = exportCtx.export?.options || {}) => {
2314
+ const requested = exportCtx.export?.requestedOptions
2315
+ if (!requested) {
2316
+ throw new Error('[snapdom-pdf] this build requires @zumer/snapdom >=3.0.0-beta.0 <4 ' +
2317
+ '(missing export.requestedOptions)')
2318
+ }
2319
+ const opts = { ...requested }
2320
+ // The raw request records explicit keys so normalized core defaults do
2321
+ // not overwrite pdf() defaults. beforeExport can still steer this export
2322
+ // by mutating its effective option bag, including removing request keys.
2323
+ for (const key of Object.keys(opts)) {
2324
+ if (!Object.hasOwn(exportOptions, key)) delete opts[key]
2325
+ }
2326
+ for (const [key, value] of Object.entries(exportOptions)) {
2327
+ if (Object.hasOwn(requested, key) || !Object.is(value, exportCtx[key])) opts[key] = value
2328
+ }
2329
+ if (Array.isArray(opts.page)) opts.page = [...opts.page]
2330
+ const notes = []
2331
+ const report = (msg) => notes.push(msg)
2332
+
2333
+ for (const key of CAPTURE_TIME) {
2334
+ if (key in opts && opts[key] !== pluginDefaults[key]) {
2335
+ report(`\`${key}\` is decided when the capture measures the DOM — set it on ` +
2336
+ `pdf({ ${key}: … }), not on the export. Ignored here.`)
2337
+ }
2338
+ }
2339
+ // Names that no longer mean anything here, and the two that would have
2340
+ // collided with the engine's own: `compress` downsamples inlined images
2341
+ // and `filename` defaults to 'snapDOM', which as a download trigger would
2342
+ // have made every PDF save itself. Hence `deflate` and `download`, which
2343
+ // the engine has no opinion about.
2344
+ for (const gone of ['mode', 'vector', 'scale', 'background', 'filename', 'compress']) {
2345
+ if (gone in opts) {
2346
+ report(`\`${gone}\` is not a PDF option — see the reference at ` +
2347
+ 'https://snapdom.dev/pro/pdf/docs.html. Ignored.')
2348
+ }
2349
+ }
2350
+
2351
+ // The capture owns its own resolution and its own flattening; the PDF side
2352
+ // reads them rather than re-deciding them.
2353
+ const options = {
2354
+ ...pluginDefaults, ...opts, scale: captureDensity, dpr: exportCtx.dpr,
2355
+ rasterDensity: absoluteSize ? captureDensity : null,
2356
+ }
2357
+ if (!Object.hasOwn(options, 'quality')) options.quality = exportCtx.quality ?? 0.92
2358
+ if (!Object.hasOwn(options, 'backgroundColor')) {
2359
+ options.backgroundColor = exportCtx.backgroundColor ?? null
2360
+ }
2361
+ if (!Object.hasOwn(options, 'codec')) options.codec = 'auto'
2362
+ options.captureOptions = {
2363
+ exclude: exportCtx.excludePredicates?.length
2364
+ ? [...(exportCtx.exclude || []), ...exportCtx.excludePredicates]
2365
+ : exportCtx.exclude,
2366
+ excludeMode: exportCtx.excludeMode,
2367
+ plugins: exportCtx.plugins,
2368
+ reconcile: exportCtx.reconcile,
2369
+ embedFonts: exportCtx.embedFonts,
2370
+ localFonts: exportCtx.localFonts,
2371
+ excludeFonts: exportCtx.excludeFonts,
2372
+ fontStylesheetDomains: exportCtx.fontStylesheetDomains,
2373
+ useProxy: exportCtx.useProxy,
2374
+ fallbackURL: exportCtx.fallbackURL,
2375
+ compress: exportCtx.compress,
2376
+ excludeStyleProps: exportCtx.excludeStyleProps,
2377
+ outerTransforms: exportCtx.outerTransforms,
2378
+ outerShadows: exportCtx.outerShadows,
2379
+ captureSelection: exportCtx.captureSelection,
2380
+ iconFonts: exportCtx.iconFonts,
2381
+ placeholders: exportCtx.placeholders,
2382
+ }
2383
+
2384
+ // All cheap validation precedes snapshot mount, canvas allocation and codec work.
2385
+ validateExport(options, meta)
2386
+ const flow = flowSpec({ signal: options.signal, onProgress: options.onProgress })
2387
+ const onReport = reportSpec(options.onReport)
2388
+ if (options.meta != null && (typeof options.meta !== 'object' || Array.isArray(options.meta))) {
2389
+ throw new TypeError('[snapdom-pdf] meta must be an object of document properties')
2390
+ }
2391
+ flow?.check()
2392
+ flow?.tick('measure')
2393
+ const measured = await measuredForCapture()
2394
+ if (!measured.rect.width || !measured.rect.height) {
2395
+ throw new Error('[snapdom-pdf] the final captured tree has no layout box')
2396
+ }
2397
+ notes.push(...measured.notes)
2398
+
2399
+ const raster = options.image === false ? null : rasterFor(options)
2400
+ const { blob, stats } = await draw({ measured, raster, meta, options, report, flow })
2401
+ for (const n of notes) warn(n)
2402
+ onReport?.(buildReport({
2403
+ messages: notes, pages: stats.pages, bytes: blob.size,
2404
+ fields: stats.fields, annotations: stats.annotations,
2405
+ fontsEmbedded: stats.fontsEmbedded, pdfua: stats.pdfua,
2406
+ vectorText: stats.vectorText,
2407
+ }))
2408
+ return deliver(blob, options.download)
2409
+ },
2410
+ }
2411
+ },
2412
+ }
2413
+ }
2414
+
2415
+ export { pdf }