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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +54 -20
  3. package/agent-map.d.ts +32 -0
  4. package/agent-map.js +5 -3
  5. package/ascii-export.d.ts +10 -0
  6. package/ascii-export.js +21 -6
  7. package/capture-frames.js +5 -5
  8. package/color-tint.js +4 -0
  9. package/context-export.d.ts +23 -0
  10. package/context-export.js +4 -1
  11. package/filter.js +1 -1
  12. package/gif-export.d.ts +6 -0
  13. package/gif-export.js +3 -3
  14. package/html-export.d.ts +8 -0
  15. package/html-export.js +6 -4
  16. package/index.d.ts +2 -1
  17. package/index.js +2 -1
  18. package/package.json +17 -6
  19. package/pdf/LICENSE +21 -0
  20. package/pdf/README.md +22 -0
  21. package/pdf/REFERENCE.md +520 -0
  22. package/pdf/src/frontmatter.js +200 -0
  23. package/pdf/src/furniture.js +398 -0
  24. package/pdf/src/index.js +2415 -0
  25. package/pdf/src/mega/README.md +47 -0
  26. package/pdf/src/mega/diag.js +148 -0
  27. package/pdf/src/mega/flow.js +56 -0
  28. package/pdf/src/mega/fonts.js +287 -0
  29. package/pdf/src/mega/forms.js +479 -0
  30. package/pdf/src/mega/labels.js +43 -0
  31. package/pdf/src/mega/meta.js +145 -0
  32. package/pdf/src/nav.js +357 -0
  33. package/pdf/src/paginate.js +583 -0
  34. package/pdf/src/tagged.js +355 -0
  35. package/pdf/src/text-layer.js +1771 -0
  36. package/pdf/src/writer/encrypt.js +218 -0
  37. package/pdf/src/writer/font.js +507 -0
  38. package/pdf/src/writer/index.js +1135 -0
  39. package/pdf/src/writer/subset.js +387 -0
  40. package/pdf.d.ts +294 -0
  41. package/pdf.js +1 -0
  42. package/redact-inputs.js +6 -6
  43. package/vector/CONTRACT.md +906 -0
  44. package/vector/LICENSE +21 -0
  45. package/vector/README.md +35 -0
  46. package/vector/src/adapters/snapdom.js +795 -0
  47. package/vector/src/collect/box.js +1676 -0
  48. package/vector/src/collect/font.js +487 -0
  49. package/vector/src/collect/image.js +1458 -0
  50. package/vector/src/collect/mathml.js +272 -0
  51. package/vector/src/collect/text.js +3027 -0
  52. package/vector/src/css/color.js +823 -0
  53. package/vector/src/css/gradient.js +1126 -0
  54. package/vector/src/css/matrix.js +398 -0
  55. package/vector/src/css/radius.js +124 -0
  56. package/vector/src/css/shadow.js +201 -0
  57. package/vector/src/emit/figma-json.js +1995 -0
  58. package/vector/src/emit/h2d-vector.js +215 -0
  59. package/vector/src/emit/h2d.js +1147 -0
  60. package/vector/src/emit/pdf.js +2571 -0
  61. package/vector/src/emit/svg-flat.js +4842 -0
  62. package/vector/src/exclusion.js +13 -0
  63. package/vector/src/index.js +2910 -0
  64. package/vector/src/paint.js +2908 -0
  65. package/vector/src/public.js +57 -0
  66. package/vector/src/writer/encrypt.js +227 -0
  67. package/vector/src/writer/font.js +507 -0
  68. package/vector/src/writer/index.js +1148 -0
  69. package/vector/src/writer/subset.js +387 -0
  70. package/vector/svd/index.js +72 -0
  71. package/vector/svd/schema.js +985 -0
  72. package/vector.d.ts +28 -0
  73. package/vector.js +1 -0
  74. package/video-export.d.ts +6 -0
  75. package/video-export.js +7 -7
  76. package/pdf-image.d.ts +0 -18
  77. package/pdf-image.js +0 -114
@@ -0,0 +1,520 @@
1
+ # SnapDOM PDF reference
2
+
3
+ Version 1.0.0 reference. This is the canonical option and behaviour reference
4
+ for the build beside it.
5
+
6
+ SnapDOM PDF is a SnapDOM plugin. It does not expose a standalone `toPdf(element)`
7
+ function. Register the `pdf()` factory before capture; the resulting capture
8
+ then gains `toPdf()` and the generic `to('pdf')` export.
9
+
10
+ ```js
11
+ import { snapdom } from '@zumer/snapdom'
12
+ import pdf from './snapdom-pdf.mjs'
13
+
14
+ snapdom.plugins(pdf())
15
+
16
+ const shot = await snapdom(document.querySelector('#report'), {
17
+ scale: 2,
18
+ dpr: 1,
19
+ })
20
+
21
+ const blob = await shot.toPdf({ page: 'a4' })
22
+ ```
23
+
24
+ See [README.md](./README.md) for the bundler and import-map installation
25
+ recipes. Both builds export the same factory as the default export and as the
26
+ named export `pdf`. The supported peer range is `@zumer/snapdom >=3.0.0 <4`.
27
+ The plugin uses v3's immutable capture metadata, requested export options and
28
+ pre-decode canvas crops. PDF declares `needs: 'render'` and uses the SVG path so
29
+ the text layer and raster share one immutable artifact.
30
+
31
+ ## The two option moments
32
+
33
+ The plugin derives its geometry and document semantics lazily from SnapDOM's
34
+ final serialized clone when the first PDF export runs, rather than reading the
35
+ original live DOM. Five options control that capture model and therefore have
36
+ to be fixed on `pdf()` before the capture is created:
37
+
38
+ | Capture-time option | Default | Meaning |
39
+ |---|---:|---|
40
+ | `formValues` | `true` | Put `input`, `textarea` and selected `select` values in the text layer. Password values are replaced with bullets. |
41
+ | `shadow` | `true` | Walk open shadow roots as a flattened tree. Closed roots are unreachable. |
42
+ | `breakAvoid` | `true` | Measure rows, list items, figures and other atomic blocks so pagination can avoid splitting them. |
43
+ | `outline` | `true` | Build bookmarks from `h1`–`h6` and `role="heading"`. A string is a custom CSS selector; `false` disables the outline. |
44
+ | `forms` | `true` | Measure form controls (kind, name, value, options, geometry, painted style) so the export can write fillable fields. `false` skips the measurement entirely. |
45
+
46
+ ```js
47
+ snapdom.plugins(pdf({
48
+ shadow: true,
49
+ outline: 'h1, h2, [role="heading"]',
50
+ page: 'a4', // an export default may also live here
51
+ margin: 36,
52
+ }))
53
+
54
+ const shot = await snapdom(report)
55
+ await shot.toPdf({ margin: 48 }) // overrides only this export
56
+ ```
57
+
58
+ Passing a capture-time option to `toPdf()` warns and ignores it. Every export
59
+ option below may also be supplied to `pdf()` as a default; `toPdf()` wins.
60
+
61
+ ## `toPdf()`
62
+
63
+ ```ts
64
+ shot.toPdf(options?: PdfExportOptions): Promise<Blob>
65
+ shot.to('pdf', options?: PdfExportOptions): Promise<Blob>
66
+ ```
67
+
68
+ `download` is an optional side effect. A `Blob` with type `application/pdf` is
69
+ returned in every case.
70
+
71
+ ### Page and pagination
72
+
73
+ | Option | Default | Meaning |
74
+ |---|---:|---|
75
+ | `page` | `'fit'` | `'fit'`; `a3`, `a4`, `a5`, `a6`, `b4`, `b5`, `letter`, `legal`, `tabloid`, `executive`; or `[width, height]` in PDF points. |
76
+ | `orientation` | `'portrait'` | `'portrait'` or `'landscape'`. It has no effect on `'fit'`. |
77
+ | `margin` | `24` | Points on all sides. It has no effect on `'fit'`. |
78
+ | `repeatHeaders` | `true` | On paginated output, repeat a table's `<thead>` wherever that table continues. |
79
+
80
+ `page: 'fit'` creates one page at the captured element's size. Other sizes scale
81
+ the capture to the printable width and paginate vertically. Declared
82
+ `break-before`/`break-after` values `always`, `page`, `left`, `right`, `recto`
83
+ and `verso` create plain breaks when they do not create an empty page.
84
+
85
+ Atomic blocks include table rows, list items, `dt`/`dd`, figures, blockquotes,
86
+ replaced content, `break-inside: avoid` content and short leaf blocks. A block
87
+ taller than a page still has to be split.
88
+
89
+ Repeated `<thead>` pixels and text are re-stamped from the body capture. Links
90
+ inside the repeated copy are not repeated. A header taller than half a page is
91
+ left where it originally occurs and produces a warning.
92
+
93
+ ### Raster
94
+
95
+ | Option | Default | Meaning |
96
+ |---|---:|---|
97
+ | `image` | `true` | Draw the visible page raster. `false` skips rasterization and produces an image-free PDF while the enabled text, links and structure remain. Element headers, footers and watermarks are omitted because they are images. |
98
+ | `codec` | `'auto'` | `'auto'` encodes JPEG and lossless Flate and keeps the smaller image. `'jpeg'` and `'flate'` pin the choice. |
99
+ | `quality` | capture quality or `0.92` | Quality of the JPEG candidate, taken from the capture when the export does not set one. It reaches nothing when Flate wins, and on ordinary page artwork Flate wins until roughly 0.5. Measured on a 37-page report: `0.92`, `0.8` and `0.6` all produce a byte-identical 16.1 MB, `0.4` gives 13.7 MB and `0.2` gives 10.1 MB. An export whose lowered quality changed nothing says so. |
100
+ | `backgroundColor` | capture background or `null` | Flatten this PDF export onto a colour. May be set on the capture, the plugin defaults or this export. |
101
+
102
+ Resolution belongs to `snapdom(...)`: use `scale`, `dpr`, `width` and `height`
103
+ there, not in `toPdf()`. On v3, an explicit width or height overrides `scale`;
104
+ `dpr` still multiplies the raster resolution. Keep the capture aspect ratio when
105
+ setting both dimensions. The exporter rasterizes page regions lazily and caches
106
+ them on the capture for later exports. A different codec, quality or flattening
107
+ choice may still require encoding work.
108
+
109
+ **`dpr` is the same knob as `scale` here, and it defaults to the machine.**
110
+ A PDF has no device to match, so neither option moves a point of geometry: the
111
+ MediaBox, the text layer and every annotation are identical whatever they are
112
+ set to. What they multiply is the page image's pixel count, and they multiply it
113
+ together. Measured on an 8-page A4 report, `scale: 1, dpr: 2` and
114
+ `scale: 2, dpr: 1` produce the same 1748px-wide raster and the same 3.18 MB
115
+ file, against 1.63 MB at 1×1 and 6.93 MB at 2×2.
116
+
117
+ The catch is the default. SnapDOM reads `dpr` from `window.devicePixelRatio`,
118
+ which is right for a screenshot and surprising for a document: the same code
119
+ exports 3.18 MB from a 2× laptop and 1.63 MB from a 1× monitor or a CI
120
+ container, with no visible difference in the page and no warning. Set `dpr`
121
+ explicitly on the capture (`1` for a light, screen-read file, `2` for one meant
122
+ to be printed) and the export stops depending on who ran it.
123
+
124
+ `codec: 'auto'` costs an extra encode. Pin a codec when latency matters more than
125
+ choosing the smallest file. If `CompressionStream` is unavailable, `auto` uses
126
+ JPEG and a requested `flate` warns before falling back to JPEG.
127
+
128
+ Transparency uses a soft mask. `backgroundColor` removes that mask and usually
129
+ produces a smaller file. Geometry comes from the same final serialized clone as
130
+ the raster. Real removals/replacements made by `exclude`, `resolveNode`,
131
+ `afterClone`, `clip`, or a geometry-preserving `afterRender` replacement of the
132
+ final serialized tree/URL stay synchronized with the PDF layer. An `afterRender`
133
+ replacement that changes the artifact's viewBox, root paint/layout attributes or
134
+ intrinsic aspect/geometry incompatibly is rejected rather than exported with a
135
+ misaligned layer. Uniform width/height scaling that preserves aspect is allowed.
136
+
137
+ ### Text, links, navigation and accessibility
138
+
139
+ | Option | Default | Meaning |
140
+ |---|---:|---|
141
+ | `text` | `true` | Emit selectable/searchable invisible text. |
142
+ | `links` | `true` | Emit external URI and in-document link annotations. |
143
+ | `tagged` | `true` | Emit a structure tree for headings, paragraphs, lists, tables, sections and figures. |
144
+ | `lang` | DOM language or `null` | Set the PDF document language, such as `'en'` or `'es-MX'`. |
145
+ | `embedFonts` | `true` | Embed the capture's `@font-face` binaries and point the text at them. |
146
+ | `vectorText` | `false` | Paint eligible text as real vector glyphs instead of leaving it invisible over the raster. |
147
+ | `debugText` | `false` | Paint the normally invisible layer in red and leave streams readable. |
148
+
149
+ #### Embedded fonts
150
+
151
+ The capture must CARRY its fonts for the export to embed them: pass
152
+ `embedFonts: true` to `snapdom(…)` (the engine option that inlines `@font-face`
153
+ into the capture), and the PDF side reads those rules, fetches each face the
154
+ text actually painted with, and embeds a SUBSET of it: only the glyphs the
155
+ document drew, renumbered into a dense space. On a Latin page a ~320 kB face
156
+ travels as a few kB; the subset renders pixel-identical to the whole font
157
+ (asserted by rendering both through pdf.js). A face travels once however many
158
+ styles and pages use it, and the saving is declared per face (`font-embedded`).
159
+
160
+ `embedFonts` on the PDF export controls this: `true` (default) subsets;
161
+ `'full'` embeds the whole file unsubsetted (glyph ids stay the font's own);
162
+ `false` embeds nothing. CFF/OpenType (`OTTO`) faces are always embedded whole
163
+ (their charstrings are a different compiler), so `'full'` only differs for
164
+ TrueType. The subsetter is fail-safe: a TrueType face it cannot subset falls
165
+ back to a whole embed with an explicit `CIDToGIDMap`, never a corrupt glyph.
166
+
167
+ #### Vector text
168
+
169
+ By default the visible text is the raster: pixel-exact, but pixels. With
170
+ `vectorText: true` the exporter PAINTS eligible runs as real vector glyphs from
171
+ the embedded fonts, so the type is crisp at any zoom and the file is a fraction
172
+ of the size. This is a different fidelity model from the pixel-exact capture,
173
+ and it is opt-in for that reason.
174
+
175
+ A run is painted only where no shaping stands between code points and glyphs: a
176
+ left-to-right run, in a covering embedded face, with a resolved colour. RTL,
177
+ complex scripts, uncovered characters and unresolved colours are NOT painted,
178
+ because one CID per code point would place their glyphs wrong. Those runs stay
179
+ on the raster; in an image-free export they are invisible and `vector-missed`
180
+ says so.
181
+
182
+ The two useful pairings:
183
+
184
+ - `image: false, vectorText: true`: a small, fully-vector text PDF. Only the
185
+ text and vector furniture travel; borders, checkboxes, background images and
186
+ any un-paintable run are absent. Measured: the mega fixture is ~22 kB this way
187
+ against ~420 kB with the raster.
188
+ - `image: true, vectorText: true`: the raster plus crisp vector glyphs painted
189
+ over it. Larger, but sharp under magnification.
190
+
191
+ Interior glyph positions follow the embedded font's own advances scaled to the
192
+ run's measured width (`Tz`), not the browser's kerned positions, so a heavily
193
+ kerned pair can drift sub-pixel from the raster. Extraction is unchanged: the
194
+ painted glyphs carry the same `/ToUnicode` as the invisible layer.
195
+
196
+ Per run, the embedded face is used only when its real cmap covers every
197
+ character of the run; a run it cannot carry falls back to the unembedded layer
198
+ and is counted (`font-coverage`). System fonts have no bytes a page can hand
199
+ over and travel by name as before. WOFF unpacks; WOFF2, TrueType collections
200
+ and faces whose `OS/2 fsType` forbids embedding are refused with their reason
201
+ (`font-fallback`). No shaping is performed, which is correct for the invisible
202
+ layer, whose widths are pinned to the DOM measurement by `Tz`.
203
+
204
+ Text extraction is byte-for-byte identical with and without embedding (asserted
205
+ in the harness), because extraction runs off `/ToUnicode` either way.
206
+
207
+ #### The earned PDF/UA-1 claim
208
+
209
+ The exporter never takes a conformance claim as an option; it COMPUTES
210
+ eligibility per export: every text-showing operation on an embedded face,
211
+ `tagged` structure present, a document language, a `meta.title` to display, and
212
+ no painted image without alternative text. When all of it holds, the XMP carries
213
+ `pdfuaid:part 1` and the report says so (`ua-claimed`, `report.pdfua === true`).
214
+ When fonts are clean but a blocker remains, the blockers are named
215
+ (`ua-withheld`).
216
+
217
+ Two blockers are worth knowing in advance. Fillable fields block the claim,
218
+ because their editor-facing `/DA` types in unembedded Helvetica, so a
219
+ UA-claiming export pairs with `fields: false`. A painted `<img>` or
220
+ `role="img"` element with no accessible name blocks it too, because its pixels
221
+ live in the page raster, which is an artifact, and there is no `/Alt` to
222
+ announce: give it `alt`, `aria-label` or `title`, or `alt=""` when it is
223
+ decorative.
224
+
225
+ Text is measured from the final serialized clone, one run per word. Text
226
+ transforms are applied; open shadow roots, visible form values and bidi logical
227
+ order are included. Text hidden by layout, ancestor opacity or rectangular
228
+ overflow clipping is excluded.
229
+
230
+ Base-14 fonts carry WinAnsi text. Other scripts use an invisible Type0 font with
231
+ a `/ToUnicode` map. Without `vectorText`, the visible type remains the SnapDOM
232
+ raster.
233
+
234
+ Headings become bookmarks and named destinations. A same-document `#fragment`
235
+ link becomes a PDF destination; an unresolved or out-of-capture fragment gets no
236
+ annotation and produces a warning. External annotations are restricted to
237
+ `http:`, `https:`, `mailto:` and `tel:`. Malformed URLs and other schemes such
238
+ as `javascript:`, `data:`, `blob:` and `file:` are ignored and reported. A
239
+ custom `outline` selector is evaluated against the final flattened artifact, so
240
+ matching open-shadow and slotted headings participate too.
241
+
242
+ #### Redaction and semantic visibility
243
+
244
+ Visual occlusion is not redaction. A rectangle above a secret, blur, or arbitrary
245
+ overlap can hide pixels without removing the underlying text from the serialized
246
+ document. Remove or replace confidential nodes/text with `exclude`,
247
+ `resolveNode` or a clone/render hook. Do not use overlays or blur as a redaction
248
+ mechanism.
249
+
250
+ For known clipping, masking and filter geometry, the collector omits text and
251
+ links when it cannot prove they are visible. This is fail-safe: difficult visual
252
+ effects may lose selectable semantics instead of leaking content. An
253
+ `afterRender` hook is synchronized only when it replaces the final serialized
254
+ tree/URL while preserving its geometry. A purely visual overlay is not evidence
255
+ that semantic content vanished, and an incompatible viewBox, root paint/layout
256
+ or intrinsic-aspect change fails instead of guessing new coordinates.
257
+
258
+ Tagged output improves reading order and structure. It becomes a **PDF/UA-1
259
+ claim only when the export earns it** (see "The earned PDF/UA-1 claim" above);
260
+ short of that, no `pdfuaid` identifier is written. Either way the tree is
261
+ real: link annotations are tied in by
262
+ `/Link` structure elements carrying `/OBJR` references, fillable fields by
263
+ `/Form` elements, header cells carry `/Scope` (and spans), table row groups map
264
+ to `THead`/`TBody`/`TFoot`, pages declare `/Tabs /S` so tab order follows the
265
+ structure, and the parent tree resolves both marked content and annotations.
266
+ Inline formatting elements are deliberately not expanded into a structure node
267
+ each.
268
+
269
+ ### Fillable form fields
270
+
271
+ | Option | Default | Meaning |
272
+ |---|---:|---|
273
+ | `fields` | `true` | Emit measured form controls as real AcroForm fields: fillable, tabbable, extractable. `false` keeps their values in the invisible text layer instead. |
274
+
275
+ A captured form should still BE a form. Text inputs, textareas, checkboxes,
276
+ radio groups, and selects become widget annotations with their DOM name, value,
277
+ options and checked state; `readonly`/`disabled` map to the read-only flag,
278
+ `required` and `maxlength` travel too, and the accessible label (`aria-label`,
279
+ `<label for>`, placeholder, title, in that order) becomes the field's tooltip,
280
+ which is what a screen reader speaks. Radios group by their HTML `name`;
281
+ duplicate non-radio names are uniquified with a suffix and reported.
282
+
283
+ The page image yields each fielded control's area to its widget, and the
284
+ widget's appearance follows one rule, dictated by macOS Preview: a field
285
+ HOLDING A VALUE paints it as real text, laid out from the control's own
286
+ measured runs so a textarea's wrap and an input's alignment are the page's
287
+ rather than a guess, while an EMPTY field wears the control's own crop of the
288
+ capture: border, corner radius and placeholder, pixel-exact. (Preview redraws a
289
+ text field's value over any appearance carrying an image, so a valued field's
290
+ crop would show the value twice there; text appearances render once everywhere:
291
+ Preview, Quick Look, Chrome, pdf.js, Acrobat, and every print/flatten path.)
292
+
293
+ What deliberately does not happen: a **password** field is written empty with
294
+ the password flag set, because a field value is plain text in the file; the
295
+ raster keeps its painted bullets. **Buttons, sliders, colour wells and file
296
+ pickers** stay pixels; a control that only means something to a running page is
297
+ not a field. **No JavaScript actions and no submit URLs** are ever written. A
298
+ rotated or clip-cut control is reported and left as invisible text, and a
299
+ control crossing a page break is clamped to one page and reported. With
300
+ `image: false` the appearance is synthesized (background, border and a base-14
301
+ value line), and a value outside WinAnsi sets `/NeedAppearances` so the viewer
302
+ draws it.
303
+
304
+ ### Document metadata, identity and page labels
305
+
306
+ | Option | Default | Meaning |
307
+ |---|---:|---|
308
+ | `meta` | `null` | `{ title, author, subject, keywords, creator, created, modified }`: /Info plus a mirrored XMP packet. |
309
+
310
+ `title` also sets `/ViewerPreferences << /DisplayDocTitle true >>`, so viewers
311
+ show the document's name rather than its filename. `keywords` may be a string or
312
+ an array. `created`/`modified` accept a `Date` or ISO-8601 string and are
313
+ written ONLY when given: an invented "now" would break the byte-determinism
314
+ promise, so pass `new Date()` yourself when you mean it. `/Producer` is always
315
+ `snapdom-pdf` and is not an option.
316
+
317
+ Every file carries a trailer `/ID` derived from its own content: identical
318
+ unencrypted exports share an ID, different documents differ, and no clock is
319
+ involved. Paginated documents with front matter get `/PageLabels` (cover and
320
+ contents in lowercase roman, body decimal from 1), so the viewer's page field
321
+ agrees with the printed folio and with the generated contents.
322
+
323
+ ### Encryption
324
+
325
+ | Option | Default | Meaning |
326
+ |---|---:|---|
327
+ | `encrypt` | `null` | `{ userPassword, ownerPassword, permissions }`: encrypt the file with AES-256 (standard security handler, revision 6). |
328
+
329
+ The exported file is the delivery: it gets downloaded, attached, and forwarded.
330
+ `encrypt` is what makes that file unreadable once it has left, and it is the
331
+ only protection here that is not advisory.
332
+
333
+ ```js
334
+ await shot.toPdf({
335
+ page: 'a4',
336
+ encrypt: { userPassword: 'ada-1815', ownerPassword: 'owner-key' },
337
+ })
338
+ ```
339
+
340
+ `userPassword` is **required and must be non-empty**, and an export that omits
341
+ it throws rather than producing a file. A PDF whose user password is `''` is
342
+ genuinely encrypted (the bytes are unreadable to a text editor), and every
343
+ viewer in the world still opens it without asking. That is the exact shape of a
344
+ document somebody believes is protected and is not. `ownerPassword` defaults to
345
+ the user password; it exists to lift the permission flags for whoever holds it.
346
+
347
+ **AES-256 and nothing older.** RC4 and revisions 2–4 are not offered at any
348
+ setting. A 40-bit key falls in seconds, 128-bit RC4 is not far behind, and both
349
+ need MD5, which the platform does not provide. Writing one by hand to ship a
350
+ weaker product is not a trade this exporter makes. Everything used here comes
351
+ from the browser's own `crypto.subtle`, so the plugin carries no cipher code and
352
+ no dependency. Encryption therefore needs a **secure context**: https, or
353
+ localhost. Encrypting a 14 MB document costs about 34 ms.
354
+
355
+ | | |
356
+ |---|---|
357
+ | `/Filter /Standard /V 5 /R 6`, `/CFM /AESV3`, 256-bit key | ISO 32000-2 §7.6.4 |
358
+ | Header stays `%PDF-1.7`; the catalog declares `/ADBE /ExtensionLevel 3` | the shape readers learned before ISO 32000-2 |
359
+ | Every stream and every string is encrypted, `/Metadata` included | strings inside content streams ride the stream |
360
+
361
+ #### Permissions are a request, not a protection
362
+
363
+ `permissions` accepts `print`, `modify`, `copy`, `annotate`, `fillForms`,
364
+ `assemble` and `printHighRes`; each defaults to `true`, and setting one to
365
+ `false` clears its `/P` bit. Understand what that buys: a flag is something a
366
+ viewer *may* honour and any other tool can ignore. The password is what protects
367
+ the document; the flags only express intent to software that has already been
368
+ given the key. The export says so in the report when any of them is set.
369
+
370
+ Extraction for accessibility is deliberately not exposed. PDF 2.0 deprecates
371
+ that bit (a conforming reader must always allow it), and PDF/UA requires an
372
+ encrypted file to keep permitting it, so an exporter whose selling point is an
373
+ extractable text layer has no business offering to switch it off. A UA-claiming
374
+ export and an encrypted one are compatible for exactly that reason.
375
+
376
+ #### Encrypted exports are not deterministic
377
+
378
+ The file key, the salts and every IV are random, drawn from
379
+ `crypto.getRandomValues` for each export. Encrypting the same document twice with
380
+ the same password gives two different files that open to the same content, so
381
+ two encrypted files never reveal that their contents match. Unencrypted exports
382
+ stay byte-deterministic.
383
+
384
+ ### Progress, cancellation and the report
385
+
386
+ | Option | Default | Meaning |
387
+ |---|---:|---|
388
+ | `onProgress` | `null` | `({ phase, done, total })` per step: `measure`, `page` (with counts), `matter`, `assemble`. |
389
+ | `signal` | `null` | An `AbortSignal`; aborting rejects with `AbortError` between pages. |
390
+ | `onReport` | `null` | Receives the structured diagnostics report once the Blob is finished. |
391
+
392
+ The report is `{ warnings, counts, pages, bytes, fields, annotations,
393
+ fontsEmbedded, pdfua, vectorText }`, where each warning is `{ code, message }`,
394
+ `counts` tallies by code and `vectorText` counts the runs painted as glyphs.
395
+ Codes are a stable contract (`text-clipped`, `link-unresolved`, `field-skipped`,
396
+ `layout-split`, …) so a CI step can gate on "no annotations lost" without
397
+ parsing prose. Identical unencrypted exports produce identical reports; the
398
+ console keeps warning regardless, so nothing changes for callers who ignore all
399
+ of this.
400
+
401
+ ### Header, footer and page numbers
402
+
403
+ | Option | Default | Meaning |
404
+ |---|---:|---|
405
+ | `header` | `null` | A string/function, a text-band object, or an Element. |
406
+ | `footer` | `null` | The same along the bottom. |
407
+ | `pageNumbers` | `false` | `true` or `{ format, align, size, font, color }`. |
408
+
409
+ Text bands accept `{ left, center, right, size, font, color, rule, gap }`.
410
+ Segments may be strings or `(page, pages) => value`. `font` is `document`,
411
+ `sans`, `serif` or `mono`; colours are three- or six-digit hex values.
412
+ `document`, the default, is the body's own embedded face when the export has
413
+ one, so a running head is set in the same type as the report it runs over, in
414
+ real embedded glyphs; per string it falls back to base-14 sans when nothing is
415
+ embedded or the face lacks a character. An explicit `sans`/`serif`/`mono` keeps
416
+ meaning base-14. Base-14 furniture remains limited to WinAnsi scripts.
417
+
418
+ Text-band defaults are `size: 9`, `font: 'document'`, `rule: true` and
419
+ `gap: 10`. Page numbers default to `(page, pages) => page + ' / ' + pages`,
420
+ centred at size 9 in the document face. `colour` is accepted as an alias for
421
+ `color`; prefer `color` in new code.
422
+
423
+ An Element band captures its capturable content and styles, but it is a picture
424
+ without its own selectable layer; scripts are not executed by the measurement
425
+ sandbox. Header, footer and numbers reserve space and only apply to paginated
426
+ page sizes. With `image: false`, Element bands are omitted with a warning; text
427
+ bands and page numbers still work.
428
+
429
+ ### Cover, generated contents, closing page and watermark
430
+
431
+ | Option | Default | Meaning |
432
+ |---|---:|---|
433
+ | `cover` | `null` | An Element captured as the first page. |
434
+ | `toc` | `null` | `true`, a title string, or `{ title, levels, size, font, indent, dots, label, color, leaderColor }`. |
435
+ | `back` | `null` | An Element captured as the last page. |
436
+ | `watermark` | `null` | A string, Element, text-watermark object or element-watermark object. |
437
+
438
+ Cover and back are scaled to fit a whole page without cropping and receive a
439
+ selectable text layer. The generated contents comes from the same outline as
440
+ the bookmarks; each row links to the body destination. Its page numbers refer
441
+ to body pages, excluding cover and contents pages. When a custom
442
+ `pageNumbers.format` changes numbering, use `toc.label` to keep both in step.
443
+
444
+ Text watermarks accept `{ text, opacity, angle, size, font, color }`; omitting
445
+ `size` auto-fits the rotated text. Element watermarks accept
446
+ `{ element, opacity, angle, fit }`. Watermarks draw over every page and also work
447
+ with `page: 'fit'`. With `image: false`, an Element watermark is omitted, while
448
+ text watermarks still work. Cover, contents and back require a paginated page
449
+ size; image-free cover/back pages retain their measured text, links and structure
450
+ but not their visible raster.
451
+
452
+ Contents default to title `Contents`, three outline depths, size 10, the
453
+ document face, 14-point indentation and leaders enabled. Text watermarks default
454
+ to 12% opacity and 45 degrees; Element watermarks default to 12% opacity, no
455
+ rotation and a 0.6 fit. `colour`/`leaderColour` are accepted aliases for
456
+ `color`/`leaderColor`.
457
+
458
+ ### Output
459
+
460
+ | Option | Default | Meaning |
461
+ |---|---:|---|
462
+ | `deflate` | `true` | Compress PDF content streams. `debugText` leaves them readable. |
463
+ | `download` | `false` | A filename, or `true` for `snapdom.pdf`. `.pdf` is appended when absent. |
464
+
465
+ Use `deflate`, not SnapDOM's `compress`; use `download`, not SnapDOM's
466
+ `filename`. `compress`, `filename`, PDF-side `scale`, legacy `background`,
467
+ `mode` and `vector` are not PDF options and are reported when passed.
468
+
469
+ ## Errors and warnings
470
+
471
+ Register the plugin before capture. A capture made without it has no `toPdf()`
472
+ helper, and its generic `to('pdf')` has no PDF exporter to run. An available PDF
473
+ export rejects an incompatible SnapDOM contract, invalid or empty capture
474
+ geometry, an incompatible final-artifact viewBox/root paint-layout/intrinsic
475
+ aspect, invalid orientation/margins/custom page geometry, and invalid active
476
+ image codec/quality.
477
+ An unknown string paper name warns and falls back to A4 for untyped JavaScript
478
+ callers. Other fidelity losses are reported under `[snapdom-pdf]`, including:
479
+
480
+ - page breaks that split atomic blocks or declared breaks that cannot be used;
481
+ - text or links clipped, invisible, outside the page, or under unsupported 3D geometry;
482
+ - unresolved internal links and invalid custom outline selectors;
483
+ - missing language on tagged output;
484
+ - a repeated table header too tall to repeat;
485
+ - a requested contents page with no headings;
486
+ - Base-14 page furniture that cannot represent its script;
487
+ - invalid or translucent flattening colours and unavailable Flate compression;
488
+ - malformed links or URI schemes outside the safe allowlist.
489
+
490
+ Every one of these is also a structured diagnostic with a stable code in the
491
+ report handed to `onReport`, so a build can gate on them instead of reading the
492
+ console.
493
+
494
+ ## Known limits
495
+
496
+ - Visible text is raster by default, so zooming beyond capture resolution exposes pixels. `vectorText` paints eligible runs as glyphs, never RTL or complex scripts.
497
+ - `textarea` wrapping is greedy and can drift for hyphenation, CJK, unbreakable strings and `line-height: normal`.
498
+ - Date-like controls contribute their ISO value rather than the locale-painted value. Multi-row selects contribute no value.
499
+ - Closed shadow roots are unreachable.
500
+ - Upright glyphs in vertical writing modes are placed square; sideways runs are supported.
501
+ - Ambiguous clipping, masking and filter geometry is omitted fail-safe, so some visibly painted text may not be selectable.
502
+ - Native `<select>` insets were measured on Chromium and can differ in other engines.
503
+ - Rotated links use an axis-aligned PDF rectangle.
504
+ - Captured header/footer Elements have no selectable text layer.
505
+ - `image: false` omits Element headers, footers and watermarks; cover/back pages
506
+ keep semantics but lose their visible raster.
507
+ - Only `<thead>` repeats, and its repeated copy has no repeated link annotations.
508
+
509
+ ## Browser status
510
+
511
+ The exporter is a browser ESM module. The complete PDF fixture suite runs in
512
+ Chromium through `npm run test:pdf`. The monorepo also tests registration,
513
+ searchable text, frozen capture state and redaction in Chromium and WebKit.
514
+ Firefox is not yet verified in this branch: its runner failed to connect.
515
+ The complete complex-layout matrix is not verified in every engine; known
516
+ engine-sensitive limits remain listed above.
517
+
518
+ The exporter runs locally in the page and returns a Blob. It does not upload the
519
+ PDF. SnapDOM may still fetch the page resources or use a proxy if the application
520
+ configured one.