SnapDOMGitHub★ 8K

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:

OptionDefaultMeaning
formValuestrueInclude visible values of text controls and the selected option. Passwords become bullets.
shadowtrueWalk open shadow roots as the flattened tree.
breakAvoidtrueMeasure atomic blocks for pagination.
outlinetrueHeadings become bookmarks. Use a selector string or false.
formstrueMeasure 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

OptionDefaultMeaning
page'fit'fit; A3–A6; B4/B5; letter, legal, tabloid, executive; or [width,height] in points.
orientation'portrait'portrait or landscape; irrelevant to fit.
margin24Points on all sides; irrelevant to fit.
repeatHeaderstrueRepeat 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

OptionDefaultMeaning
imagetrueDraw 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.
qualitycapture / .92Quality 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 / dpron the captureNeither 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.
backgroundColorcapture / nullFlatten 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

OptionDefaultMeaning
texttrueEmit invisible selectable/searchable text.
linkstrueEmit external URI and in-document annotations.
debugTextfalsePaint 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

OptionDefaultMeaning
embedFontstrueEmbed the capture's @font-face binaries and point the text at them. 'full' embeds unsubsetted; false embeds nothing.
vectorTextfalsePaint 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:true gives 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:true gives 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

OptionDefaultMeaning
outlinetrueCapture-time bookmarks from headings; see the lifecycle table.
taggedtrueStructure headings, paragraphs, lists, tables, sections and figures.
langDOM / nullPDF 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

OptionDefaultMeaning
fieldstrueEmit 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

OptionDefaultMeaning
headernullString/function, {left,center,right,size,font,color,rule,gap}, or Element.
footernullThe same along the bottom.
pageNumbersfalsetrue 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

OptionDefaultMeaning
covernullElement captured as the first page, fitted without cropping and given a text layer.
tocnulltrue, title string or {title,levels,size,font,indent,dots,label,color,leaderColor}.
backnullElement captured as the last page with its own text layer.
watermarknullString, 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

OptionDefaultMeaning
metanull{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

OptionDefaultMeaning
encryptnull{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.

WrittenPer
/V 5 /R 6AESV3ISO 32000-2 §7.6.4, 256-bit key.
header%PDF-1.7The file structure is 1.7; the catalog declares /ADBE /ExtensionLevel 3, the shape readers learned before ISO 32000-2 existed.
scopeeverythingEvery 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

OptionDefaultMeaning
deflatetrueCompress PDF content streams.
downloadfalseFilename, 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

OptionDefaultMeaning
onProgressnull({phase, done, total}) per step: measure, page with counts, matter, assemble.
signalnullAn AbortSignal. Aborting rejects with AbortError between pages and produces no Blob, no download and no report.
onReportnullReceives 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. vectorText paints 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:false omits Element headers, footers and watermarks; image-free cover/back pages keep semantics only.
  • encrypt needs a secure context (https or localhost), because it runs on crypto.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.