npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@c9up/vellum

v0.1.6

Published

PDF toolkit — convert PDF pages to images, author and inspect documents

Readme

@c9up/vellum

PDF toolkit — convert PDF pages to images, author and inspect documents.

Agnostic package: the service itself depends on no other part of the ecosystem. The provider and the configure hook integrate with a Ream host through an optional peer dependency.

Install

ream configure @c9up/vellum

That registers the provider and writes config/vellum.ts.

Usage

import vellum from '@c9up/vellum/services/main'

// A preview of the first page, 1200px wide
const preview = await vellum.render(pdf, { page: 1, width: 1200 })

// Every page as JPEG
const pages = await vellum.renderAll(pdf, { format: 'jpeg', quality: 82 })

// What is in the document
const { pageCount, version, encrypted } = await vellum.inspect(pdf)
const { title, author, createdAt } = await vellum.metadata(pdf)
const sizes = await vellum.dimensions(pdf)

// Its form
const fields = await vellum.formFields(mandate)
const filled = await vellum.fillForm(mandate, {
  'assure.nom': 'Amélie Durand',
  accepted: 'Yes',
})
const closed = await vellum.flattenForm(filled)

// Its text
const text = await vellum.extractText(pdf, { page: 1 })
const perPage = await vellum.extractTextAll(pdf)

// Reshaping it
const dossier = await vellum.merge([contract, annexe])
const extract = await vellum.selectPages(pdf, [1, 3, 4])
const parts = await vellum.split(pdf)
const upright = await vellum.rotate(scan, 90, { pages: [1] })

// Stamping it — a signature, a photo, a watermark
const signed = await vellum.stamp(workOrder, signature, {
  page: 1, x: 380, y: 690, width: 140,
})
const draft = await vellum.stamp(pdf, watermark, { opacity: 0.15 })

// Writing text onto it
const marked = await vellum.stampText(invoice, 'PAYÉ', {
  x: 400, y: 80, size: 24, color: '#c00', opacity: 0.6,
})

Pages are numbered from 1 — the number printed on the page, not an array index.

Rendering options

| Option | Meaning | | --- | --- | | page | Which page to render, from 1. Only on render. | | scale | Multiplier over natural size; 1 is 72 DPI. | | width | Target width in pixels. Takes precedence over scale. | | format | "png" (default) or "jpeg". | | quality | JPEG quality 1-100. Refused without format: 'jpeg'. | | background | #rgb, #rrggbb, #rrggbbaa or "transparent". Default opaque white. |

Every option can be defaulted in config/vellum.ts and overridden per call.

Configuration

// config/vellum.ts
import { defineConfig } from '@c9up/vellum'

export default defineConfig({
  format: 'jpeg',
  quality: 82,
  width: 1200,
})

Engine

The work happens in Rust, behind NAPI, because PDF has no adequate JavaScript implementation — this is a capability the platform lacks, not an optimisation of one it has.

| Crate | Role | | --- | --- | | hayro | Rasterising a page. Pure Rust, so the binary stays self-contained | | lopdf | Documents that already exist: object tree, pages, metadata, encryption | | krilla | Documents we author — and, through its pdf feature, re-embedding pages of an existing file | | image | JPEG encoding |

Text extraction rides on hayro's interpreter — it already resolves fonts, encodings and /ToUnicode maps. pdf-extract would have been the obvious choice but it pins lopdf ^0.42 against our 0.44, which would put two copies of the parser in the binary.

Rasterising an A4 page is around 30ms of pure computation, so it runs on the libuv thread pool rather than on the thread serving requests. Every method on the service is therefore asynchronous.

Reading text

Glyphs come back in the order the page draws them, with a line break where the baseline moves. That order is the reading order in practice; no reordering by coordinates is attempted, because doing it well needs column detection and doing it badly makes multi-column pages worse. No spaces are invented either — a PDF encodes its own, and guessing them from gaps duplicates them.

A scanned document with no text layer yields an empty string rather than an error: it has no text to give.

Reshaping documents

merge, selectPages, split and rotate all move pages between page trees, which is where PDF hides a trap: Resources, MediaBox, CropBox and Rotate may live on a parent node and be inherited by the page. Re-parent such a page naively and it loses its size — readers then fall back to Letter, quietly resizing an A4 document. Every operation materialises the inherited attributes onto the page first.

Rotation adds to whatever a page already carries, because a scan can arrive already turned.

Stamping

stamp draws an image onto a document — the signature a technician traces on a tablet, a photo attached to a report, a watermark on a draft. PNG and JPEG are accepted, chosen by file signature rather than by name. Coordinates count from the top-left corner, the way a screen layout is written. Naming no page stamps every page, which is what a watermark wants.

It works by re-embedding each existing page as a Form XObject and drawing over it, through krilla's pdf feature. (Krilla's README says embedding existing pages is out of scope; its published manifest says otherwise.)

stampText writes a line of text. It uses the 14 standard fonts — Helvetica, Helvetica-Bold, Helvetica-Oblique, Times-Roman, Times-Bold, Times-Italic, Courier, Courier-Bold — which a PDF may reference without embedding: nothing is added to the file and no font has to be supplied. The trade-off is the WinAnsi character set. Western European text is covered, accents and typographic punctuation included; anything outside it is refused rather than mangled, because silently dropping a character from a contract is worse than failing. For stampText, y is the text's baseline.

Text written onto a page is escaped, so a document title cannot inject content stream operators.

Stamping and the document underneath

stamp and stampText write into the document that already exists: the picture becomes an image XObject named in the page's resources, the text a content stream appended to the page. Neither re-authors the file.

That is the whole point. Re-authoring — drawing each page onto a fresh one — loses everything the page structure carries: the interactive form, the annotations, the links. A signature is stamped onto exactly the kind of document that has all three.

A JPEG goes in untouched, as DCTDecode, so a photograph stays the size it arrived at. A PNG becomes raw samples, and its alpha channel becomes a soft mask — which is what makes a signature drawn on a tablet transparent everywhere but the stroke. A CMYK JPEG is refused rather than silently inverted.

Fonts

stampText uses the 14 standard fonts by default. A PDF may reference those without embedding them, so nothing is added to the file and no font has to be supplied — at the cost of the WinAnsi character set, outside which text is refused rather than mangled.

A font declared in config/vellum.ts is embedded instead:

// config/vellum.ts
export default defineConfig({
  fonts: { body: app.makePath('resources/fonts/Inter-Regular.ttf') },
})

await vellum.stampText(pdf, 'Uměl Řehoř', { font: 'body' })

It is subsetted to the characters actually written, because embedding a family whole would put megabytes into every stamped document, and a /ToUnicode table is written alongside it — without one the text is drawn correctly and cannot be selected, copied or searched, a loss that only shows up when someone tries to read the document back.

A configured name is looked up before the standard fonts, so calling one Helvetica shadows the standard one; a name that is not configured falls through, which is what keeps font: 'Times-Roman' working with no configuration at all. A character the supplied font has no glyph for is refused by name rather than dropped.

Interactive forms

formFields lists a document's AcroForm fields in declaration order. name is the fully qualified name — every ancestor's partial name joined with dots — which is the name a field is filled in by.

Two details of PDF 32000-1 §12.7.3 that a caller would otherwise trip on, and which this resolves for them: a field's type, flags and value are inherited down /Parent, so a field commonly declares none of them itself; and a checkbox or radio's "on" state is chosen by the DOCUMENT (/Yes, /On, /1, …), not fixed by the spec. Those accepted states are reported in options, because writing anything else leaves the control untouched.

For a choice field, options reports the exported values rather than the labels — the export value is what gets written back.

fillForm writes values by their qualified name and regenerates each filled field's appearance stream. That second half is the one that matters: most readers paint a field from its appearance, not from its value, so a document filled without it opens looking empty while holding every answer. A checkbox or radio already ships one appearance per state, so there only the widget's /AS is repointed.

Refusals are loud rather than silent, because a filled document quietly missing an answer is worse than a failure: an unknown field name, a read-only field, a value over the declared maximum length, a choice the form does not offer, and a checkbox state the document does not accept are all errors.

Text is laid out with the published widths of the standard fonts, because that is what the reader lays it out with. A field's /Q is honoured, a multiline field wraps at the width of its box, and a /DA asking for size 0 gets a size chosen to fit. A word too long for the line is broken across lines rather than left to run past the edge, where the appearance's bounding box would clip it away.

The widths are generated from the URW base-35 metrics by scripts/generate-metrics.py and cross-checked against published Adobe values in the tests — a table that had drifted would not compile its way into a release.

flattenForm closes the document: every widget's appearance becomes ordinary page content, the widget annotations go, and the form itself is dropped. What comes back looks the same and can no longer be edited back.

The placement follows §12.5.5 — the appearance's /BBox transformed by its /Matrix, and the resulting box mapped onto the annotation's /Rect. Painting at the rectangle's corner instead would misplace every appearance whose form matrix is not the identity, which is most of the ones a real form ships. The page's own content is wrapped in q/Q first: a cm outside any pair is legal and never restored, so appended content would otherwise inherit a transform it never asked for. stampText does the same, for the same reason.

Annotations that are not form widgets — links, notes — are left where they are; flattening removes the form, not the document's other furniture. A hidden widget is dropped without being painted, since making visible what a document hid is not preservation. And a field holding a value that ships no appearance to paint is an error rather than a silent erasure: the answer would vanish from a document that still looks complete.

Signing

A PDF signature covers a byte range of the document it lives in, which makes the usual order impossible: the value cannot be computed and then assembled, because assembling it would change what it covers. The document is written with a hole where the value goes, the /ByteRange records everything but the hole, and the value is dropped into the reserved space without moving another byte.

That is also what makes a key you hold and a key held by a certified provider the same interface: a signer never sees the document, only the digest of it.

// config/vellum.ts
export default defineConfig({
  signers: {
    internal: myLocalSigner,
    qualified: myProviderSigner,
  },
})

const signed = await vellum.sign(mandate, {
  signer: 'qualified',
  reason: 'Mandat de prévoyance',
  name: 'Amélie Durand',
})

A Signer is anything with sign(digest: Buffer): Promise<Buffer>, returning the CMS SignedData. Signing over the network belongs there rather than in the engine, which does no I/O.

The signature is appended as an incremental revision: the original bytes are preserved exactly. Rewriting the file would invalidate any signature already on it and destroy the history a signature exists to establish.

A visible signature — a drawn one, an image — is a separate matter: stamp it on first, then sign.

Signing with a key you hold

pkcs8Signer is the one signer that ships, because it is the one with no vendor behind it:

signers: {
  internal: pkcs8Signer({
    key: readFileSync(app.makePath('storage/signing.key.der')),
    certificate: readFileSync(app.makePath('storage/signing.crt.der')),
  }),
}

Where this signer's limits are. It signs with rsa, whose modular exponentiation is not constant-time — RUSTSEC-2023-0071, which recognises no fixed release. Vellum blinds the operation, which is the mitigation the advisory itself points to, and that is enough for a key you hold and use on work your own application starts.

It is not enough when the signer is reachable from remote input: an HTTP endpoint that signs on request, or a queue a client can feed, hands an attacker both the timing and the repetition the attack needs. That deployment wants an HSM, a remote signing service, or an audited constant-time backend — Signer is an interface, so one can be dropped in without touching the rest.

It builds a CAdES SignedData whose signed attributes carry the content type, the document's digest, the signing time and — as PAdES requires — signing-certificate-v2. That last one is not decoration: without it a signature is bound to a key but not to an identity.

PKCS#8 and DER rather than a .p12, because reading PKCS#12 in Rust is not something to put underneath a signature, and openssl pkcs12 -in bundle.p12 -nodes gets you there in one command.

This is an advanced signature: it proves the document has not changed since a particular key signed it. Where the law requires a qualified one, the key has to live with a certified provider — and that is an adapter, not this.

Adapters

A provider is a Signer and nothing more, so an adapter is a short function returning one. None ship here: naming a vendor would tie an agnostic package to one, and an HTTP client is not a dependency it should carry.

Timestamping

timestamped wraps any signer, the local one or a provider's:

signers: {
  internal: timestamped(pkcs8Signer({ key, certificate }), {
    url: 'https://freetsa.org/tsr',
  }),
}

A signature proves a document has not changed since a key signed it, not when. Once the certificate expires a verifier cannot tell a signature made while it was valid from one forged afterwards, and stops accepting it. For a document kept for years — which is most of the documents anyone bothers to sign — this is what keeps it verifiable.

The token goes on as an unsigned attribute, which is what lets it be added without disturbing the signature. What comes back is checked rather than trusted: the authority's status, that it stamped the signature we actually sent, and that it answered this request rather than replaying an older answer. Those are read out of the token's structure, not looked for in its bytes — a hash that happens to appear inside a certificate is not a hash the authority stamped. A token that cannot be read is refused, which fails closed.

The signature grows by a few kilobytes, so a document prepared with a tight capacity may need a larger one.

On the dependencies

The CMS stack sits on the older RustCrypto generation, which costs a second copy of digest next to the one lopdf uses. The alternative was the matching generation, where cms and rsa are still release candidates — and a release candidate in the one path where correctness is legally load-bearing is a worse trade than a duplicated hash, which produces identical bytes by definition. One line of Cargo.toml to revisit when they ship.

Checking a signature

Signing is half the job. A document that arrives signed is worth nothing until someone has checked it:

for (const signature of await vellum.verifySignatures(mandate)) {
  if (!signature.coversWholeDocument) reject('content was added after signing')
  if (!signature.digestMatches) reject('the document has changed')
  if (!signature.signatureVerifies) reject('the signature does not match')
}

coversWholeDocument is the one that catches the trap everybody meets first: content appended after a signature is not covered by it, and the arithmetic over the covered part still checks out. A reader that verifies only the digest will happily call such a document signed. A document whose second half arrived later is not one.

The report also names the signer, the time they stated, and whether an authority has timestamped it.

Trust

Checking that a signature matches the certificate it carries says nothing about who that certificate belongs to: anyone can make one. Trust comes from a path to an anchor you have decided to accept:

// config/vellum.ts
export default defineConfig({
  trustedAnchors: [readFileSync(app.makePath('storage/anchors/authority.pem'))],
})

Supply the roots your jurisdiction's supervisory body publishes — they are distributed as trusted lists in the ETSI TS 119 612 format — or your own authority's, and trusted says whether a path was found. DER or PEM; PEM is what an authority usually publishes. Supplying none is a position too: every signature then comes back untrusted, which is the honest answer rather than a comfortable one.

The path is judged at the moment of signing, not now: a certificate valid when the document was signed and expired since did not retroactively unsign anything. moment says where that instant came from — "timestamp" if an authority vouched for it, "claimed" if it rests on the signer's own word. That is the concrete reason timestamping is worth the round trip.

Every link is checked: each certificate is signed by the one above it, each issuer says it is an authority, and the signing certificate is allowed to sign at all. A path longer than eight is treated as a loop.

Revocation

A certificate can be valid on its face and worthless in fact: keys are lost, people leave, authorities discover a mistake. Only the issuer knows, and only if asked.

const signatures = await vellum.verifySignatures(mandate, { checkRevocation: true })
// signatures[0].revocation → { status: 'good' | 'revoked' | 'unknown', detail? }

That is a network call per signature, to the responder the certificate names, so it is off unless asked for.

The answer has three values, not two. unknown covers everything else — the responder was unreachable, answered about a different certificate, or could not be believed. Collapsing it into either of the others is the mistake to avoid: treating it as good waves a withdrawn certificate through, and treating it as revoked rejects documents whenever a server is down. Which to do is your policy, so it is reported rather than decided.

An answer is only read if the issuer, or somebody the issuer authorised to answer for it, actually signed it — otherwise anyone could revoke anything. And a certificate withdrawn after the document was signed does not taint it: that is what a signing time, and better a timestamp, is for.

Status

Rendering to images, metadata, text extraction, document operations, stamping — image and text — supplied fonts, interactive forms read, filled, laid out and flattened, and signatures made, timestamped, checked, traced to an authority you accept and asked about at their issuer, are complete.

What remains needs something this package cannot supply itself: an adapter for a certified provider, which is a short function returning a Signer and belongs to whoever has the account.

Building the native engine

pnpm build:napi   # cargo build --release + type generation + binary copy
pnpm test         # TypeScript suite
pnpm test:rust    # engine suite