# SnapDOM PDF reference Version 1.0.0 reference. This is the canonical option and behaviour reference for the build beside it. SnapDOM PDF is a SnapDOM plugin. It does not expose a standalone `toPdf(element)` function. Register the `pdf()` factory before capture; the resulting capture then gains `toPdf()` and the generic `to('pdf')` export. ```js import { snapdom } from '@zumer/snapdom' import pdf from './snapdom-pdf.mjs' snapdom.plugins(pdf()) const shot = await snapdom(document.querySelector('#report'), { scale: 2, dpr: 1, }) const blob = await shot.toPdf({ page: 'a4' }) ``` See [README.md](./README.md) for the bundler and import-map installation recipes. Both builds export the same factory as the default export and as the named export `pdf`. The supported peer range is `@zumer/snapdom >=3.0.0 <4`. The plugin uses v3's immutable capture metadata, requested export options and pre-decode canvas crops. PDF declares `needs: 'render'` and uses the SVG path so the text layer and raster share one immutable artifact. ## The two option moments The plugin derives its geometry and document semantics lazily from SnapDOM's final serialized clone when the first PDF export runs, rather than reading the original live DOM. Five options control that capture model and therefore have to be fixed on `pdf()` before the capture is created: | Capture-time option | Default | Meaning | |---|---:|---| | `formValues` | `true` | Put `input`, `textarea` and selected `select` values in the text layer. Password values are replaced with bullets. | | `shadow` | `true` | Walk open shadow roots as a flattened tree. Closed roots are unreachable. | | `breakAvoid` | `true` | Measure rows, list items, figures and other atomic blocks so pagination can avoid splitting them. | | `outline` | `true` | Build bookmarks from `h1`–`h6` and `role="heading"`. A string is a custom CSS selector; `false` disables the outline. | | `forms` | `true` | Measure form controls (kind, name, value, options, geometry, painted style) so the export can write fillable fields. `false` skips the measurement entirely. | ```js snapdom.plugins(pdf({ shadow: true, outline: 'h1, h2, [role="heading"]', page: 'a4', // an export default may also live here margin: 36, })) const shot = await snapdom(report) await shot.toPdf({ margin: 48 }) // overrides only this export ``` Passing a capture-time option to `toPdf()` warns and ignores it. Every export option below may also be supplied to `pdf()` as a default; `toPdf()` wins. ## `toPdf()` ```ts shot.toPdf(options?: PdfExportOptions): Promise shot.to('pdf', options?: PdfExportOptions): Promise ``` `download` is an optional side effect. A `Blob` with type `application/pdf` is returned in every case. ### Page and pagination | Option | Default | Meaning | |---|---:|---| | `page` | `'fit'` | `'fit'`; `a3`, `a4`, `a5`, `a6`, `b4`, `b5`, `letter`, `legal`, `tabloid`, `executive`; or `[width, height]` in PDF points. | | `orientation` | `'portrait'` | `'portrait'` or `'landscape'`. It has no effect on `'fit'`. | | `margin` | `24` | Points on all sides. It has no effect on `'fit'`. | | `repeatHeaders` | `true` | On paginated output, repeat a table's `` wherever that table continues. | `page: 'fit'` creates one page at the captured element's size. Other sizes scale the capture to the printable width and paginate vertically. Declared `break-before`/`break-after` values `always`, `page`, `left`, `right`, `recto` and `verso` create plain breaks when they do not create an empty page. Atomic blocks include table rows, list items, `dt`/`dd`, figures, blockquotes, replaced content, `break-inside: avoid` content and short leaf blocks. A block taller than a page still has to be split. Repeated `` pixels and text are re-stamped from the body capture. Links inside the repeated copy are not repeated. A header taller than half a page is left where it originally occurs and produces a warning. ### Raster | Option | Default | Meaning | |---|---:|---| | `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. | | `codec` | `'auto'` | `'auto'` encodes JPEG and lossless Flate and keeps the smaller image. `'jpeg'` and `'flate'` pin the choice. | | `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. | | `backgroundColor` | capture background or `null` | Flatten this PDF export onto a colour. May be set on the capture, the plugin defaults or this export. | Resolution belongs to `snapdom(...)`: use `scale`, `dpr`, `width` and `height` there, not in `toPdf()`. On v3, an explicit width or height overrides `scale`; `dpr` still multiplies the raster resolution. Keep the capture aspect ratio when setting both dimensions. The exporter rasterizes page regions lazily and caches them on the capture for later exports. A different codec, quality or flattening choice may still require encoding work. **`dpr` is the same knob as `scale` here, and it defaults to the machine.** A PDF has no device to match, so neither option moves a point of geometry: the MediaBox, the text layer and every annotation are identical whatever they are set to. What they multiply is the page image's pixel count, and they multiply it together. Measured on an 8-page A4 report, `scale: 1, dpr: 2` and `scale: 2, dpr: 1` produce the same 1748px-wide raster and the same 3.18 MB file, against 1.63 MB at 1×1 and 6.93 MB at 2×2. The catch is the default. SnapDOM reads `dpr` from `window.devicePixelRatio`, which is right for a screenshot and surprising for a document: the same code exports 3.18 MB from a 2× laptop and 1.63 MB from a 1× monitor or a CI container, with no visible difference in the page and no warning. Set `dpr` explicitly on the capture (`1` for a light, screen-read file, `2` for one meant to be printed) and the export stops depending on who ran it. `codec: 'auto'` costs an extra encode. Pin a codec when latency matters more than choosing the smallest file. If `CompressionStream` is unavailable, `auto` uses JPEG and a requested `flate` warns before falling back to JPEG. Transparency uses a soft mask. `backgroundColor` removes that mask and usually produces a smaller file. Geometry comes from the same final serialized clone as the raster. Real removals/replacements made by `exclude`, `resolveNode`, `afterClone`, `clip`, or a geometry-preserving `afterRender` replacement of the final serialized tree/URL stay synchronized with the PDF layer. An `afterRender` replacement that changes the artifact's viewBox, root paint/layout attributes or intrinsic aspect/geometry incompatibly is rejected rather than exported with a misaligned layer. Uniform width/height scaling that preserves aspect is allowed. ### Text, links, navigation and accessibility | Option | Default | Meaning | |---|---:|---| | `text` | `true` | Emit selectable/searchable invisible text. | | `links` | `true` | Emit external URI and in-document link annotations. | | `tagged` | `true` | Emit a structure tree for headings, paragraphs, lists, tables, sections and figures. | | `lang` | DOM language or `null` | Set the PDF document language, such as `'en'` or `'es-MX'`. | | `embedFonts` | `true` | Embed the capture's `@font-face` binaries and point the text at them. | | `vectorText` | `false` | Paint eligible text as real vector glyphs instead of leaving it invisible over the raster. | | `debugText` | `false` | Paint the normally invisible layer in red and leave streams readable. | #### Embedded fonts The capture must CARRY its fonts for the export to embed them: pass `embedFonts: true` to `snapdom(…)` (the engine option that inlines `@font-face` into the capture), and the PDF side reads those rules, fetches each face the text actually painted with, and embeds a SUBSET of it: only the glyphs the document drew, renumbered into a dense space. On a Latin page a ~320 kB face travels as a few kB; the subset renders pixel-identical to the whole font (asserted by rendering both through pdf.js). A face travels once however many styles and pages use it, and the saving is declared per face (`font-embedded`). `embedFonts` on the PDF export controls this: `true` (default) subsets; `'full'` embeds the whole file unsubsetted (glyph ids stay the font's own); `false` embeds nothing. CFF/OpenType (`OTTO`) faces are always embedded whole (their charstrings are a different compiler), so `'full'` only differs for TrueType. The subsetter is fail-safe: a TrueType face it cannot subset falls back to a whole embed with an explicit `CIDToGIDMap`, never a corrupt glyph. #### Vector text By default the visible text is the raster: pixel-exact, but pixels. With `vectorText: true` the exporter PAINTS eligible runs as real vector glyphs from the embedded fonts, so the type is crisp at any zoom and the file is a fraction of the size. This is a different fidelity model from the pixel-exact capture, and it is opt-in for that reason. A run is painted only where no shaping stands between code points and glyphs: a left-to-right run, in a covering embedded face, with a resolved colour. RTL, complex scripts, uncovered characters and unresolved colours are NOT painted, because one CID per code point would place their glyphs wrong. Those runs stay on the raster; in an image-free export they are invisible and `vector-missed` says so. The two useful pairings: - `image: false, vectorText: true`: a small, fully-vector text PDF. Only the text and vector furniture travel; borders, checkboxes, background images and any un-paintable run are absent. Measured: the mega fixture is ~22 kB this way against ~420 kB with the raster. - `image: true, vectorText: true`: the raster plus crisp vector glyphs painted over it. Larger, but sharp under magnification. Interior glyph positions follow the embedded font's own advances scaled to the run's measured width (`Tz`), not the browser's kerned positions, so a heavily kerned pair can drift sub-pixel from the raster. Extraction is unchanged: the painted glyphs carry the same `/ToUnicode` as the invisible layer. Per run, the embedded face is used only when its real cmap covers every character of the run; a run it cannot carry falls back to the unembedded layer and is counted (`font-coverage`). System fonts have no bytes a page can hand over and travel by name as before. WOFF unpacks; WOFF2, TrueType collections and faces whose `OS/2 fsType` forbids embedding are refused with their reason (`font-fallback`). No shaping is performed, which is correct for the invisible layer, whose widths are pinned to the DOM measurement by `Tz`. Text extraction is byte-for-byte identical with and without embedding (asserted in the harness), because extraction runs off `/ToUnicode` either way. #### The earned PDF/UA-1 claim The exporter never takes a conformance claim as an option; it COMPUTES eligibility per export: every text-showing operation on an embedded face, `tagged` structure present, a document language, a `meta.title` to display, and no painted image without alternative text. When all of it holds, the XMP carries `pdfuaid:part 1` and the report says so (`ua-claimed`, `report.pdfua === true`). When fonts are clean but a blocker remains, the blockers are named (`ua-withheld`). Two blockers are worth knowing in advance. Fillable fields block the claim, because their editor-facing `/DA` types in unembedded Helvetica, so a UA-claiming export pairs with `fields: false`. A painted `` or `role="img"` element with no accessible name blocks it too, because its pixels live in the page raster, which is an artifact, and there is no `/Alt` to announce: give it `alt`, `aria-label` or `title`, or `alt=""` when it is decorative. Text is measured from the final serialized clone, one run per word. Text transforms are applied; open shadow roots, visible form values and bidi logical order are included. Text hidden by layout, ancestor opacity or rectangular overflow clipping is excluded. Base-14 fonts carry WinAnsi text. Other scripts use an invisible Type0 font with a `/ToUnicode` map. Without `vectorText`, the visible type remains the SnapDOM raster. Headings become bookmarks and named destinations. A same-document `#fragment` link becomes a PDF destination; an unresolved or out-of-capture fragment gets no annotation and produces a warning. External annotations are restricted to `http:`, `https:`, `mailto:` and `tel:`. Malformed URLs and other schemes such as `javascript:`, `data:`, `blob:` and `file:` are ignored and reported. A custom `outline` selector is evaluated against the final flattened artifact, so matching open-shadow and slotted headings participate too. #### Redaction and semantic visibility Visual occlusion is not redaction. A rectangle above a secret, blur, or arbitrary overlap can hide pixels without removing the underlying text from the serialized document. Remove or replace confidential nodes/text with `exclude`, `resolveNode` or a clone/render hook. Do not use overlays or blur as a redaction mechanism. For known clipping, masking and filter geometry, the collector omits text and links when it cannot prove they are visible. This is fail-safe: difficult visual effects may lose selectable semantics instead of leaking content. An `afterRender` hook is synchronized only when it replaces the final serialized tree/URL while preserving its geometry. A purely visual overlay is not evidence that semantic content vanished, and an incompatible viewBox, root paint/layout or intrinsic-aspect change fails instead of guessing new coordinates. Tagged output improves reading order and structure. It becomes a **PDF/UA-1 claim only when the export earns it** (see "The earned PDF/UA-1 claim" above); short of that, no `pdfuaid` identifier is written. Either way the tree is real: link annotations are tied in by `/Link` structure elements carrying `/OBJR` references, fillable fields by `/Form` elements, header cells carry `/Scope` (and spans), table row groups map to `THead`/`TBody`/`TFoot`, pages declare `/Tabs /S` so tab order follows the structure, and the parent tree resolves both marked content and annotations. Inline formatting elements are deliberately not expanded into a structure node each. ### Fillable form fields | Option | Default | Meaning | |---|---:|---| | `fields` | `true` | Emit measured form controls as real AcroForm fields: fillable, tabbable, extractable. `false` keeps their values in the invisible text layer instead. | A captured form should still BE a form. Text inputs, textareas, checkboxes, radio groups, and selects become widget annotations with their DOM name, value, options and checked state; `readonly`/`disabled` map to the read-only flag, `required` and `maxlength` travel too, and the accessible label (`aria-label`, `