SnapDOM PDF
The complete reference for the SnapDOM plugin that adds
toPdf() to a capture. The versioned REFERENCE.md shipped beside the
module is canonical for that version.
The PDF exporter is MIT-licensed source in the SnapDOM monorepo. The plugin package is available on npm in @zumer/snapdom-plugins 4.x and requires SnapDOM 3.x.
Installation
Import pdf from @zumer/snapdom-plugins/pdf. For local development, use the monorepo workspace.
Bundler
import { snapdom } from '@zumer/snapdom'
import { pdf } from '@zumer/snapdom-plugins/pdf'
Browser without a bundler
<script type="importmap">
{
"imports": {
"@zumer/snapdom": "/vendor/snapdom.mjs",
"@zumer/snapdom-plugins/pdf": "/vendor/plugins/pdf.js"
}
}
</script>
<script type="module">
import { snapdom } from '@zumer/snapdom'
import { pdf } from '@zumer/snapdom-plugins/pdf'
</script>
Serve the plugin package with its pdf/src/ tree and a JavaScript MIME type. A global/IIFE SnapDOM script cannot satisfy
the plugin's ESM peer import. Requires @zumer/snapdom >=3.0.0 <4.
SnapDOM 3 is the floor. The exporter reads v3's immutable capture metadata, requested export options and pre-decode canvas crops, which 2.x does not expose. The fixture suite runs against the published 3.0.0.
Registering
The module exports the same pdf factory as default and named export. It does not
export a standalone toPdf(element) helper.
import { snapdom } from '@zumer/snapdom'
import pdf, { pdf as namedPdf } from '@zumer/snapdom-plugins/pdf'
snapdom.plugins(pdf()) // every later capture
const local = await snapdom(el, { plugins: [pdf()] }) // this capture
const shot = await snapdom(document.querySelector('#report'), { scale: 2, dpr: 1 })
const blob = await shot.toPdf({ page: 'a4' })
const same = await shot.to('pdf', { page: 'a4' })
Both exports return Promise<Blob> with type application/pdf.
TypeScript declarations add toPdf(options?: PdfExportOptions) to SnapDOM's
CaptureResult.
Two option moments
The PDF model is derived lazily from SnapDOM's final serialized clone on first export, not
from the original live DOM. Five options control that model and must be fixed on
pdf() before capture:
| Option | Default | Meaning |
|---|---|---|
| formValues | true | Include visible values of text controls and the selected option. Passwords become bullets. |
| shadow | true | Walk open shadow roots as the flattened tree. |
| breakAvoid | true | Measure atomic blocks for pagination. |
| outline | true | Headings become bookmarks. Use a selector string or false. |
| forms | true | Measure form controls (kind, name, value, options, geometry, painted style) so the export can write fillable fields. false skips the measurement. |
Every export option may also be a factory default. A value in toPdf() wins. A
capture-time option passed there warns and is ignored. A custom outline selector is
evaluated against the final flattened artifact, including matching open-shadow and slotted headings.
Page and pagination
| Option | Default | Meaning |
|---|---|---|
| page | 'fit' | fit; A3–A6; B4/B5; letter, legal, tabloid, executive; or [width,height] in points. |
| orientation | 'portrait' | portrait or landscape; irrelevant to fit. |
| margin | 24 | Points on all sides; irrelevant to fit. |
| repeatHeaders | true | Repeat a continuing table's <thead> pixels and selectable text. |
Breaks are pulled off table rows, list items, definitions, figures, blockquotes, replaced
content, short leaf blocks and break-inside:avoid content. Declared page breaks are
honoured unless they only create an empty page. A block taller than a page still has to split.
Only <thead> repeats. Its repeated copy has no repeated link annotations, and a
header taller than half a page is refused with a warning.
Raster
| Option | Default | Meaning |
|---|---|---|
| image | true | Draw the visible SnapDOM raster. false makes an image-free PDF; Element headers, footers and watermarks are omitted. |
| codec | 'auto' | Compare JPEG and lossless Flate per image and keep the
smaller; or pin jpeg/flate. |
| quality | capture / .92 | Quality of the JPEG candidate only, 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: on a 35-page report 0.92,
0.8 and 0.6 all produce a byte-identical 11.3 MB, 0.4
gives 10.8 MB and 0.2 gives 10.1 MB. An export whose lowered quality changed
nothing says so in the console. |
| scale / dpr | on the capture | Neither moves a point of geometry, because a PDF has no
device to match, so the page box and the text layer are identical whatever they are. They multiply
the page image's pixels, and they multiply together: on a 35-page report
scale:1, dpr:2 and scale:2, dpr:1 both give 11.3 MB, against
5.1 MB at 1×1 and 24.4 MB at 2×2. dpr defaults to the exporting machine's
devicePixelRatio, so pin it if you want the same bytes from a laptop and from CI. |
| backgroundColor | capture / null | Flatten this PDF export onto a colour instead of carrying alpha. |
Capture resolution belongs to snapdom(el,{ scale, dpr, width, height }). Page
regions are rasterized lazily and cached on the capture for later exports. A codec, quality or
flattening change may still require a new encode; codec:'auto' compares two candidates.
When CompressionStream is unavailable, auto uses JPEG and a pinned Flate request
warns before falling back. A transparent raster uses a soft mask; flattening usually makes the file
smaller.
The colour space is earned, not configured
Before a region is compressed it is inspected once, and the Flate candidate is written in the
narrowest PDF colour space that reproduces those pixels exactly: /DeviceGray
when every pixel has r == g == b, an /Indexed palette when the region drew
256 colours or fewer, and /DeviceRGB otherwise. The first two are one byte per pixel
instead of three.
Page artwork is overwhelmingly one of the first two. Across this project's fixtures, 22
of 29 page regions are grey in every pixel and 23 of 29 fit in a palette, so the effect on real
documents is large: a 30-page report drops from 12.4 MB to 8.7 MB, a page of prose by 37%,
a typography page by 43%. Regions that genuinely need /DeviceRGB are unchanged, and
nothing is approximated to earn the narrowing: the stream inflates back to the original pixels, which
the fixture suite asserts by decoding it and comparing pixel for pixel.
The narrowing also widens the gap Flate already had over DCT. On every fixture
in this project Flate now wins by between 1.7× and 3.6×, which is why quality so often
reaches nothing. It is not made to win everywhere. A photographic region is neither grey nor 256
colours, so it narrows to nothing and JPEG can still be the smaller answer. That is exactly why
auto still runs the comparison.
Text and links
| Option | Default | Meaning |
|---|---|---|
| text | true | Emit invisible selectable/searchable text. |
| links | true | Emit external URI and in-document annotations. |
| debugText | false | Paint the layer red and leave content streams readable. |
Runs are measured from SnapDOM's final serialized clone, one word at a time. Visible form values,
open shadow roots, text transforms and logical bidi text are included. Real removals/replacements from
exclude, filter, resolveNode, afterClone,
clip, or a geometry-preserving afterRender replacement of the final
serialized tree/URL stay aligned. A replacement that changes viewBox, root paint/layout attributes
or intrinsic aspect/geometry incompatibly fails instead of producing a misaligned layer. Uniform
width/height scaling that preserves aspect is allowed. Layout-hidden and transparent text is excluded.
Same-document fragments become PDF destinations. External annotations accept only
http:, https:, mailto: and tel:. Missing fragment
targets, malformed URLs and other schemes produce no annotation and a warning.
Visual occlusion is not redaction. Remove or replace confidential
nodes/text. Do not rely on an overlay rectangle, blur or arbitrary overlap: those can hide pixels
while leaving semantic text in the serialized document. Ambiguous clipping, masking and filter
geometry is omitted fail-safe, so selection may be lost rather than uncertain content exposed. A
purely visual afterRender change does not prove semantics changed; an incompatible change
to viewBox, root paint/layout or intrinsic aspect fails instead of guessing coordinates.
Where no font is embedded, base-14 fonts encode WinAnsi and other scripts use an invisible Type0
font with a /ToUnicode map, so extraction works for every script whether or not a glyph
travels. What the file can paint is the next section.
Fonts and vector text
| Option | Default | Meaning |
|---|---|---|
| embedFonts | true | Embed the capture's @font-face binaries and point the text at them. 'full' embeds unsubsetted; false embeds nothing. |
| vectorText | false | Paint eligible runs as real vector glyphs instead of leaving them invisible over the raster. |
Embedded fonts
The capture has to CARRY its fonts for the export to embed them: pass embedFonts: true
to snapdom(…) (the engine option that inlines @font-face), 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. A face travels once however many styles and pages use it, and the saving is
declared per face.
The subset renders pixel-identical to the whole font, which the suite asserts by rendering both
through pdf.js. It is fail-safe: a TrueType face it cannot subset falls back to a whole embed with an
explicit CIDToGIDMap, never a corrupt glyph. CFF/OpenType (OTTO) faces are
always embedded whole, so 'full' only differs for TrueType.
Per run, an embedded face is used only when its own cmap covers every character of that run; a run
it cannot carry falls back to the unembedded layer and is counted. 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 rather than embedded anyway.
Text extraction is byte-for-byte identical with and without embedding, because extraction runs off
/ToUnicode either way.
Vector text
By default the visible text is the raster, which is pixel-exact but still 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. That is a
different fidelity model from the pixel-exact capture, and it is opt-in for exactly that reason.
A run is painted only where no shaping stands between code points and glyphs: left-to-right, 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, and in an image-free export they are invisible and reported.
image:false, vectorText:truegives a small, fully vector text PDF. Only text and vector furniture travel; borders, checkboxes, background images and any unpaintable run are absent. Measured, the mega fixture is ~22 kB this way against ~420 kB with the raster.image:true, vectorText:truegives the raster plus crisp 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.
Navigation and tagging
| Option | Default | Meaning |
|---|---|---|
| outline | true | Capture-time bookmarks from headings; see the lifecycle table. |
| tagged | true | Structure headings, paragraphs, lists, tables, sections and figures. |
| lang | DOM / null | PDF document language, for example en or es-MX. |
The PDF/UA-1 claim is earned, never configured
There is no option that asserts conformance. The exporter computes eligibility per export and
writes the claim only when all of it holds: 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 it does, the XMP carries
pdfuaid:part 1 and the report says so (report.pdfua === true).
When the fonts are clean but a blocker remains, the export does not claim: it names the blockers, so the fix is a line of configuration or markup rather than a guess.
Two blockers to know 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 <img> or role="img"
element with no accessible name blocks it too: give it alt, aria-label or
title, or alt="" when it is decorative. Links are tagged as
/Link structure elements carrying /OBJR references.
Fillable form fields
| Option | Default | Meaning |
|---|---|---|
| fields | true | Emit measured controls as real AcroForm fields that are fillable, tabbable and 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 carrying their DOM name, value, options and checked state.
readonly/disabled map to the read-only flag; required and
maxlength travel; and the accessible label (aria-label,
<label for>, placeholder, title, in that order) becomes the field's tooltip, which
is what a screen reader speaks. Radios group by their HTML name; duplicate non-radio
names are uniquified and reported.
The page image yields each fielded control's area to its widget. A field holding a value paints it as real text, laid out from the control's own measured runs, so a textarea's wrap and an input's alignment are the page's rather than a guess. An empty field wears the control's own crop of the capture, with its border, corner radius and placeholder pixel-exact. That split is dictated by macOS Preview, which redraws a text field's value over any appearance carrying an image; text appearances render once everywhere.
A value that lives in a field leaves the invisible text layer, so extraction returns it once, not twice.
What deliberately does not happen. A password field is written empty with the password flag set, because a field value is plain text in the file, and the raster keeps its painted bullets. Buttons, sliders, colour wells and file pickers stay pixels: a control that only means something to a running page is not a field. No JavaScript actions and no submit URLs are ever written.
A rotated or clip-cut control is reported and left as invisible text; a control crossing a page
break is clamped to one page and reported. With image:false the appearance is
synthesized (background, border and a base-14 value line), and a value outside WinAnsi sets
/NeedAppearances so the viewer draws it.
Header, footer and page numbers
| Option | Default | Meaning |
|---|---|---|
| header | null | String/function, {left,center,right,size,font,color,rule,gap}, or Element. |
| footer | null | The same along the bottom. |
| pageNumbers | false | true or {format,align,size,font,color}. |
Text segments may be (page,pages)=>value. Text furniture is real PDF text, set in the
document's embedded face when it has one and in Base-14 otherwise. An Element's capturable content and styles are captured instead, but its text is not selectable
and the measurement sandbox does not execute scripts.
These features reserve page room and do nothing on page:'fit'. Text bands default to
size 9, the document face, a rule and a 10-point gap; page numbers default to centred page / pages.
colour is accepted as a compatibility alias for color. With
image:false, Element bands are omitted with a warning; text bands and page numbers remain.
Cover, contents, back and watermark
| Option | Default | Meaning |
|---|---|---|
| cover | null | Element captured as the first page, fitted without cropping and given a text layer. |
| toc | null | true, title string or {title,levels,size,font,indent,dots,label,color,leaderColor}. |
| back | null | Element captured as the last page with its own text layer. |
| watermark | null | String, Element, {text,opacity,angle,size,font,color} or {element,opacity,angle,fit}. |
Cover, contents and back require paginated paper. Front matter sits outside body numbering. The
contents links to body destinations; coordinate toc.label with a custom
pageNumbers.format. A watermark draws over every page and also works on
page:'fit'. Contents default to three outline depths. Text watermarks default to 12%
opacity and 45°; Element watermarks use 12%, 0° and fit:.6. Prefer
color/leaderColor; British-spelling aliases remain accepted. With
image:false, Element watermarks are omitted; cover/back pages keep their measured
semantics without a visible raster.
Metadata, identity and page labels
| Option | Default | Meaning |
|---|---|---|
| meta | null | {title, author, subject, keywords, creator, created, modified}: /Info plus a mirrored XMP packet. |
title also sets /ViewerPreferences << /DisplayDocTitle true >>,
so viewers show the document's name rather than its filename. keywords may be a string or
an array. /Producer is always snapdom-pdf and is not an option.
Dates are never invented. created/modified accept
a Date or an ISO-8601 string and are written only when you pass one. An invented "now"
would break byte-determinism, so pass new Date() yourself when you mean it. For the same
reason the trailer /ID is derived from the file's own content: identical unencrypted exports
share an ID, different documents differ, and no clock is involved.
Paginated documents with front matter get /PageLabels (cover and contents in lowercase
roman, body decimal from 1), so the viewer's page field agrees with the printed folio and with the
generated contents.
Encryption
| Option | Default | Meaning |
|---|---|---|
| encrypt | null | {userPassword, ownerPassword, permissions}. Encrypts the file with AES-256 (standard security handler, revision 6). |
The exported file is the delivery: it gets downloaded, attached and forwarded.
encrypt is what makes it unreadable once it has left, and it is the only protection
here that is not advisory.
await shot.toPdf({
page: 'a4',
encrypt: { userPassword: 'ada-1815', ownerPassword: 'owner-key' },
})
userPassword is required and must be non-empty, and an
export that omits it throws rather than producing a file. A PDF whose user password is ''
is genuinely encrypted (the bytes are unreadable to a text editor), and every viewer in the world
still opens it without asking. That is the exact shape of a document somebody believes is protected
and is not.
ownerPassword defaults to the user password; it exists to lift the permission flags
for whoever holds it.
AES-256 and nothing older
RC4 and revisions 2–4 are not offered at any setting. A 40-bit key falls in seconds, 128-bit RC4
is not far behind, and both need MD5, which the platform does not provide, so shipping them would
mean hand-writing a cipher to deliver a weaker product. Everything used here comes from the browser's
own crypto.subtle, so the plugin carries no cipher code and no dependency. Encryption
therefore needs a secure context: https, or localhost. A 14 MB document encrypts in about
34 ms.
| Written | Per | |
|---|---|---|
| /V 5 /R 6 | AESV3 | ISO 32000-2 §7.6.4, 256-bit key. |
| header | %PDF-1.7 | The file structure is 1.7; the catalog declares /ADBE /ExtensionLevel 3, the shape readers learned before ISO 32000-2 existed. |
| scope | everything | Every stream and every string, /Metadata included. Strings inside content streams ride the stream. |
Permissions are a request, not a protection
permissions accepts print, modify, copy,
annotate, fillForms, assemble and printHighRes;
each defaults to true, and setting one to false clears its /P
bit. Understand what that buys: a flag is something a viewer may honour and any other tool
can ignore. The password protects the document; the flags only express intent to software that has
already been given the key. The export says so in the report whenever any of them is set.
Extraction for accessibility is deliberately not exposed. PDF 2.0 deprecates that bit (a conforming reader must always allow it), and PDF/UA requires an encrypted file to keep permitting it, so an exporter whose selling point is an extractable text layer has no business offering to switch it off. A UA-claiming export and an encrypted one are compatible for exactly that reason.
Encrypted exports are not deterministic
The file key, the salts and every IV are random, drawn from crypto.getRandomValues for
each export. Encrypting the same document twice with the same password gives two different files that
open to the same content, so two encrypted files never reveal that their contents match. Unencrypted
exports stay byte-deterministic.
Output
| Option | Default | Meaning |
|---|---|---|
| deflate | true | Compress PDF content streams. |
| download | false | Filename, or true for snapdom.pdf. The Blob is still returned. |
Use deflate, not SnapDOM's compress; use download, not its
filename. PDF-side scale, legacy background, mode
and vector are rejected with a warning.
Progress, cancellation and the report
| Option | Default | Meaning |
|---|---|---|
| onProgress | null | ({phase, done, total}) per step: measure, page with counts, matter, assemble. |
| signal | null | An AbortSignal. Aborting rejects with AbortError between pages and produces no Blob, no download and no report. |
| onReport | null | Receives the structured diagnostics report once the Blob is finished. |
A forty-page report rasterizes forty regions through an image codec; that is seconds, and seconds
behind a mute button is how users learn to distrust a feature. total is the body page
count once pagination has run and null before anything knows it, which a progress bar
should read as indeterminate.
const report = await new Promise(async (resolve) => {
await shot.toPdf({
page: 'a4',
onProgress: ({ phase, done, total }) => ui.tick(phase, done, total),
signal: controller.signal,
onReport: resolve,
})
})
if (report.counts['link-unresolved']) throw new Error('a link lost its destination')
The report is {warnings, counts, pages, bytes, fields, annotations, fontsEmbedded, pdfua,
vectorText}, where each warning is {code, message} and counts tallies
by code. The codes are a stable contract (text-clipped, link-unresolved,
field-skipped, layout-split, ua-withheld, …), so a CI step can
gate on "no annotations lost" without parsing prose. Identical unencrypted exports produce identical reports. The
console keeps warning regardless, so nothing changes for callers who ignore all of this.
Errors and warnings
Register before capture. A capture made without the plugin has no toPdf() helper
and no generic PDF exporter. An available export rejects an incompatible SnapDOM contract, invalid
or empty capture geometry, an incompatible final-artifact viewBox/root paint-layout/intrinsic aspect,
invalid orientation/margins/custom page geometry and invalid active image codec/quality. Untyped
JavaScript with an unknown paper-name string warns and falls back to A4. Fidelity trade-offs use
console.warn under [snapdom-pdf].
- Atomic blocks split or declared breaks not honoured.
- Text/links clipped, invisible, off-page or under unsupported 3D geometry.
- Missing internal destinations or an invalid custom-outline selector.
- Missing document language on tagged output.
- A repeated table header too tall, or a contents request with no headings.
- Base-14 furniture unable to paint a script.
- Invalid/translucent flattening colours or unavailable Flate compression.
- Malformed links or URI schemes outside the safe allowlist.
Every one of these is also available as a structured diagnostic with a stable code (see the report), so a build can gate on them instead of reading the console.
Known limits
- Visible text is raster by default; high zoom exposes the capture resolution.
vectorTextpaints eligible runs as real glyphs, but RTL and complex scripts are never among them. - Textarea wrapping is greedy. Date-like controls contribute ISO rather than locale-painted values.
- Closed shadow roots are unreachable; upright vertical glyphs need a vertical font path.
- Ambiguous clipping, masking and filter geometry is omitted fail-safe; visible text can lose selection.
- Native select insets were measured on Chromium and vary by engine.
- Rotated links use an axis-aligned rectangle.
- Captured header/footer Elements have no text layer; repeated table headers have no repeated links.
image:falseomits Element headers, footers and watermarks; image-free cover/back pages keep semantics only.encryptneeds a secure context (https or localhost), because it runs oncrypto.subtle. Permission flags are advisory whatever the cipher.
Browser status
The artifact is an ES2022 ESM module. The complete PDF fixture suite runs in Chromium. A second release gate imports and executes both sold bundles in Chromium, Firefox and WebKit, covering registration, core-export coexistence, PDF creation, generic export, tagging, image-free output and safe links. A missing engine fails rather than skipping.
This is not a claim that the complete complex-layout matrix runs in all three engines. Engine- sensitive limits remain listed above, and only Chromium receives the full fixture suite.
Everything runs in the browser and the PDF Blob is not uploaded by this plugin. SnapDOM may still fetch page resources or use an application-configured proxy.