@c9up/vellum 0.1.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 (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +473 -0
  3. package/dist/Vellum.d.ts +460 -0
  4. package/dist/Vellum.d.ts.map +1 -0
  5. package/dist/Vellum.js +479 -0
  6. package/dist/Vellum.js.map +1 -0
  7. package/dist/VellumProvider.d.ts +28 -0
  8. package/dist/VellumProvider.d.ts.map +1 -0
  9. package/dist/VellumProvider.js +33 -0
  10. package/dist/VellumProvider.js.map +1 -0
  11. package/dist/augmentations.d.ts +22 -0
  12. package/dist/augmentations.d.ts.map +1 -0
  13. package/dist/augmentations.js +16 -0
  14. package/dist/augmentations.js.map +1 -0
  15. package/dist/config.d.ts +33 -0
  16. package/dist/config.d.ts.map +1 -0
  17. package/dist/config.js +31 -0
  18. package/dist/config.js.map +1 -0
  19. package/dist/configure.d.ts +17 -0
  20. package/dist/configure.d.ts.map +1 -0
  21. package/dist/configure.js +78 -0
  22. package/dist/configure.js.map +1 -0
  23. package/dist/errors.d.ts +9 -0
  24. package/dist/errors.d.ts.map +1 -0
  25. package/dist/errors.js +19 -0
  26. package/dist/errors.js.map +1 -0
  27. package/dist/index.d.ts +44 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +44 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/native/generated.d.ts +255 -0
  32. package/dist/native/generated.d.ts.map +1 -0
  33. package/dist/native/generated.js +7 -0
  34. package/dist/native/generated.js.map +1 -0
  35. package/dist/native.d.ts +50 -0
  36. package/dist/native.d.ts.map +1 -0
  37. package/dist/native.js +194 -0
  38. package/dist/native.js.map +1 -0
  39. package/dist/responder.d.ts +29 -0
  40. package/dist/responder.d.ts.map +1 -0
  41. package/dist/responder.js +101 -0
  42. package/dist/responder.js.map +1 -0
  43. package/dist/services/main.d.ts +15 -0
  44. package/dist/services/main.d.ts.map +1 -0
  45. package/dist/services/main.js +39 -0
  46. package/dist/services/main.js.map +1 -0
  47. package/dist/signers.d.ts +81 -0
  48. package/dist/signers.d.ts.map +1 -0
  49. package/dist/signers.js +98 -0
  50. package/dist/signers.js.map +1 -0
  51. package/index.darwin-arm64.node +0 -0
  52. package/index.darwin-x64.node +0 -0
  53. package/index.linux-arm64-gnu.node +0 -0
  54. package/index.linux-x64-gnu.node +0 -0
  55. package/index.win32-x64-msvc.node +0 -0
  56. package/package.json +67 -0
  57. package/scripts/build-napi-types.mjs +69 -0
  58. package/scripts/copy-napi.mjs +48 -0
  59. package/scripts/generate-metrics.py +150 -0
  60. package/scripts/generate-napi-types.mjs +156 -0
  61. package/src/Vellum.ts +829 -0
  62. package/src/VellumProvider.ts +51 -0
  63. package/src/augmentations.ts +25 -0
  64. package/src/config.ts +47 -0
  65. package/src/configure.ts +92 -0
  66. package/src/errors.ts +20 -0
  67. package/src/index.ts +74 -0
  68. package/src/native/generated.ts +375 -0
  69. package/src/native.ts +358 -0
  70. package/src/responder.ts +116 -0
  71. package/src/services/main.ts +48 -0
  72. package/src/signers.ts +149 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 C9up
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,473 @@
1
+ # @c9up/vellum
2
+
3
+ PDF toolkit — convert PDF pages to images, author and inspect documents.
4
+
5
+ Agnostic package: the service itself depends on no other part of the
6
+ ecosystem. The provider and the `configure` hook integrate with a Ream host
7
+ through an optional peer dependency.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ ream configure @c9up/vellum
13
+ ```
14
+
15
+ That registers the provider and writes `config/vellum.ts`.
16
+
17
+ ## Usage
18
+
19
+ ```ts
20
+ import vellum from '@c9up/vellum/services/main'
21
+
22
+ // A preview of the first page, 1200px wide
23
+ const preview = await vellum.render(pdf, { page: 1, width: 1200 })
24
+
25
+ // Every page as JPEG
26
+ const pages = await vellum.renderAll(pdf, { format: 'jpeg', quality: 82 })
27
+
28
+ // What is in the document
29
+ const { pageCount, version, encrypted } = await vellum.inspect(pdf)
30
+ const { title, author, createdAt } = await vellum.metadata(pdf)
31
+ const sizes = await vellum.dimensions(pdf)
32
+
33
+ // Its form
34
+ const fields = await vellum.formFields(mandate)
35
+ const filled = await vellum.fillForm(mandate, {
36
+ 'assure.nom': 'Amélie Durand',
37
+ accepted: 'Yes',
38
+ })
39
+ const closed = await vellum.flattenForm(filled)
40
+
41
+ // Its text
42
+ const text = await vellum.extractText(pdf, { page: 1 })
43
+ const perPage = await vellum.extractTextAll(pdf)
44
+
45
+ // Reshaping it
46
+ const dossier = await vellum.merge([contract, annexe])
47
+ const extract = await vellum.selectPages(pdf, [1, 3, 4])
48
+ const parts = await vellum.split(pdf)
49
+ const upright = await vellum.rotate(scan, 90, { pages: [1] })
50
+
51
+ // Stamping it — a signature, a photo, a watermark
52
+ const signed = await vellum.stamp(workOrder, signature, {
53
+ page: 1, x: 380, y: 690, width: 140,
54
+ })
55
+ const draft = await vellum.stamp(pdf, watermark, { opacity: 0.15 })
56
+
57
+ // Writing text onto it
58
+ const marked = await vellum.stampText(invoice, 'PAYÉ', {
59
+ x: 400, y: 80, size: 24, color: '#c00', opacity: 0.6,
60
+ })
61
+ ```
62
+
63
+ Pages are numbered from **1** — the number printed on the page, not an array
64
+ index.
65
+
66
+ ### Rendering options
67
+
68
+ | Option | Meaning |
69
+ | --- | --- |
70
+ | `page` | Which page to render, from 1. Only on `render`. |
71
+ | `scale` | Multiplier over natural size; 1 is 72 DPI. |
72
+ | `width` | Target width in pixels. Takes precedence over `scale`. |
73
+ | `format` | `"png"` (default) or `"jpeg"`. |
74
+ | `quality` | JPEG quality 1-100. Refused without `format: 'jpeg'`. |
75
+ | `background` | `#rgb`, `#rrggbb`, `#rrggbbaa` or `"transparent"`. Default opaque white. |
76
+
77
+ Every option can be defaulted in `config/vellum.ts` and overridden per call.
78
+
79
+ ## Configuration
80
+
81
+ ```ts
82
+ // config/vellum.ts
83
+ import { defineConfig } from '@c9up/vellum'
84
+
85
+ export default defineConfig({
86
+ format: 'jpeg',
87
+ quality: 82,
88
+ width: 1200,
89
+ })
90
+ ```
91
+
92
+ ## Engine
93
+
94
+ The work happens in Rust, behind NAPI, because PDF has no adequate JavaScript
95
+ implementation — this is a capability the platform lacks, not an optimisation
96
+ of one it has.
97
+
98
+ | Crate | Role |
99
+ | --- | --- |
100
+ | `hayro` | Rasterising a page. Pure Rust, so the binary stays self-contained |
101
+ | `lopdf` | Documents that already exist: object tree, pages, metadata, encryption |
102
+ | `krilla` | Documents we author — and, through its `pdf` feature, re-embedding pages of an existing file |
103
+ | `image` | JPEG encoding |
104
+
105
+ Text extraction rides on hayro's interpreter — it already resolves fonts,
106
+ encodings and `/ToUnicode` maps. `pdf-extract` would have been the obvious
107
+ choice but it pins `lopdf ^0.42` against our 0.44, which would put two copies
108
+ of the parser in the binary.
109
+
110
+ Rasterising an A4 page is around 30ms of pure computation, so it runs on the
111
+ libuv thread pool rather than on the thread serving requests. Every method on
112
+ the service is therefore asynchronous.
113
+
114
+ ## Reading text
115
+
116
+ Glyphs come back in the order the page draws them, with a line break where the
117
+ baseline moves. That order is the reading order in practice; no reordering by
118
+ coordinates is attempted, because doing it well needs column detection and
119
+ doing it badly makes multi-column pages worse. No spaces are invented either —
120
+ a PDF encodes its own, and guessing them from gaps duplicates them.
121
+
122
+ A scanned document with no text layer yields an empty string rather than an
123
+ error: it has no text to give.
124
+
125
+ ## Reshaping documents
126
+
127
+ `merge`, `selectPages`, `split` and `rotate` all move pages between page
128
+ trees, which is where PDF hides a trap: `Resources`, `MediaBox`, `CropBox` and
129
+ `Rotate` may live on a parent node and be *inherited* by the page. Re-parent
130
+ such a page naively and it loses its size — readers then fall back to Letter,
131
+ quietly resizing an A4 document. Every operation materialises the inherited
132
+ attributes onto the page first.
133
+
134
+ Rotation adds to whatever a page already carries, because a scan can arrive
135
+ already turned.
136
+
137
+ ## Stamping
138
+
139
+ `stamp` draws an image onto a document — the signature a technician traces on a
140
+ tablet, a photo attached to a report, a watermark on a draft. PNG and JPEG are
141
+ accepted, chosen by file signature rather than by name. Coordinates count from
142
+ the top-left corner, the way a screen layout is written. Naming no page stamps
143
+ every page, which is what a watermark wants.
144
+
145
+ It works by re-embedding each existing page as a Form XObject and drawing over
146
+ it, through krilla's `pdf` feature. (Krilla's README says embedding existing
147
+ pages is out of scope; its published manifest says otherwise.)
148
+
149
+ `stampText` writes a line of text. It uses the 14 standard fonts — `Helvetica`,
150
+ `Helvetica-Bold`, `Helvetica-Oblique`, `Times-Roman`, `Times-Bold`,
151
+ `Times-Italic`, `Courier`, `Courier-Bold` — which a PDF may reference *without
152
+ embedding*: nothing is added to the file and no font has to be supplied. The
153
+ trade-off is the WinAnsi character set. Western European text is covered,
154
+ accents and typographic punctuation included; anything outside it is refused
155
+ rather than mangled, because silently dropping a character from a contract is
156
+ worse than failing. For `stampText`, `y` is the text's baseline.
157
+
158
+ Text written onto a page is escaped, so a document title cannot inject content
159
+ stream operators.
160
+
161
+ ## Stamping and the document underneath
162
+
163
+ `stamp` and `stampText` write into the document that already exists: the
164
+ picture becomes an image XObject named in the page's resources, the text a
165
+ content stream appended to the page. Neither re-authors the file.
166
+
167
+ That is the whole point. Re-authoring — drawing each page onto a fresh one —
168
+ loses everything the page structure carries: the interactive form, the
169
+ annotations, the links. A signature is stamped onto exactly the kind of
170
+ document that has all three.
171
+
172
+ A JPEG goes in untouched, as `DCTDecode`, so a photograph stays the size it
173
+ arrived at. A PNG becomes raw samples, and its alpha channel becomes a soft
174
+ mask — which is what makes a signature drawn on a tablet transparent
175
+ everywhere but the stroke. A CMYK JPEG is refused rather than silently
176
+ inverted.
177
+
178
+ ## Fonts
179
+
180
+ `stampText` uses the 14 standard fonts by default. A PDF may reference those
181
+ without embedding them, so nothing is added to the file and no font has to be
182
+ supplied — at the cost of the WinAnsi character set, outside which text is
183
+ refused rather than mangled.
184
+
185
+ A font declared in `config/vellum.ts` is embedded instead:
186
+
187
+ ```ts
188
+ // config/vellum.ts
189
+ export default defineConfig({
190
+ fonts: { body: app.makePath('resources/fonts/Inter-Regular.ttf') },
191
+ })
192
+
193
+ await vellum.stampText(pdf, 'Uměl Řehoř', { font: 'body' })
194
+ ```
195
+
196
+ It is **subsetted to the characters actually written**, because embedding a
197
+ family whole would put megabytes into every stamped document, and a
198
+ `/ToUnicode` table is written alongside it — without one the text is drawn
199
+ correctly and cannot be selected, copied or searched, a loss that only shows up
200
+ when someone tries to read the document back.
201
+
202
+ A configured name is looked up before the standard fonts, so calling one
203
+ `Helvetica` shadows the standard one; a name that is not configured falls
204
+ through, which is what keeps `font: 'Times-Roman'` working with no
205
+ configuration at all. A character the supplied font has no glyph for is
206
+ refused by name rather than dropped.
207
+
208
+ ## Interactive forms
209
+
210
+ `formFields` lists a document's AcroForm fields in declaration order. `name` is
211
+ the fully qualified name — every ancestor's partial name joined with dots —
212
+ which is the name a field is filled in by.
213
+
214
+ Two details of PDF 32000-1 §12.7.3 that a caller would otherwise trip on, and
215
+ which this resolves for them: a field's type, flags and value are *inherited*
216
+ down `/Parent`, so a field commonly declares none of them itself; and a
217
+ checkbox or radio's "on" state is chosen by the DOCUMENT (`/Yes`, `/On`, `/1`,
218
+ …), not fixed by the spec. Those accepted states are reported in `options`,
219
+ because writing anything else leaves the control untouched.
220
+
221
+ For a choice field, `options` reports the *exported* values rather than the
222
+ labels — the export value is what gets written back.
223
+
224
+ `fillForm` writes values by their qualified name and **regenerates each filled
225
+ field's appearance stream**. That second half is the one that matters: most
226
+ readers paint a field from its appearance, not from its value, so a document
227
+ filled without it opens looking empty while holding every answer. A checkbox or
228
+ radio already ships one appearance per state, so there only the widget's `/AS`
229
+ is repointed.
230
+
231
+ Refusals are loud rather than silent, because a filled document quietly missing
232
+ an answer is worse than a failure: an unknown field name, a read-only field, a
233
+ value over the declared maximum length, a choice the form does not offer, and a
234
+ checkbox state the document does not accept are all errors.
235
+
236
+ Text is laid out with the **published widths of the standard fonts**, because
237
+ that is what the reader lays it out with. A field's `/Q` is honoured, a
238
+ multiline field wraps at the width of its box, and a `/DA` asking for size 0
239
+ gets a size chosen to fit. A word too long for the line is broken across lines
240
+ rather than left to run past the edge, where the appearance's bounding box
241
+ would clip it away.
242
+
243
+ The widths are generated from the URW base-35 metrics by
244
+ `scripts/generate-metrics.py` and cross-checked against published Adobe values
245
+ in the tests — a table that had drifted would not compile its way into a
246
+ release.
247
+
248
+ `flattenForm` closes the document: every widget's appearance becomes ordinary
249
+ page content, the widget annotations go, and the form itself is dropped. What
250
+ comes back looks the same and can no longer be edited back.
251
+
252
+ The placement follows §12.5.5 — the appearance's `/BBox` transformed by its
253
+ `/Matrix`, and the resulting box mapped onto the annotation's `/Rect`. Painting
254
+ at the rectangle's corner instead would misplace every appearance whose form
255
+ matrix is not the identity, which is most of the ones a real form ships. The
256
+ page's own content is wrapped in `q`/`Q` first: a `cm` outside any pair is
257
+ legal and never restored, so appended content would otherwise inherit a
258
+ transform it never asked for. `stampText` does the same, for the same reason.
259
+
260
+ Annotations that are not form widgets — links, notes — are left where they are;
261
+ flattening removes the form, not the document's other furniture. A hidden
262
+ widget is dropped without being painted, since making visible what a document
263
+ hid is not preservation. And a field holding a value that ships no appearance
264
+ to paint is an error rather than a silent erasure: the answer would vanish from
265
+ a document that still looks complete.
266
+
267
+ ## Signing
268
+
269
+ A PDF signature covers a byte range **of the document it lives in**, which
270
+ makes the usual order impossible: the value cannot be computed and then
271
+ assembled, because assembling it would change what it covers. The document is
272
+ written with a hole where the value goes, the `/ByteRange` records everything
273
+ but the hole, and the value is dropped into the reserved space without moving
274
+ another byte.
275
+
276
+ That is also what makes a key you hold and a key held by a certified provider
277
+ the same interface: **a signer never sees the document, only the digest of
278
+ it**.
279
+
280
+ ```ts
281
+ // config/vellum.ts
282
+ export default defineConfig({
283
+ signers: {
284
+ internal: myLocalSigner,
285
+ qualified: myProviderSigner,
286
+ },
287
+ })
288
+
289
+ const signed = await vellum.sign(mandate, {
290
+ signer: 'qualified',
291
+ reason: 'Mandat de prévoyance',
292
+ name: 'Amélie Durand',
293
+ })
294
+ ```
295
+
296
+ A `Signer` is anything with `sign(digest: Buffer): Promise<Buffer>`, returning
297
+ the CMS `SignedData`. Signing over the network belongs there rather than in the
298
+ engine, which does no I/O.
299
+
300
+ The signature is appended as an **incremental revision**: the original bytes
301
+ are preserved exactly. Rewriting the file would invalidate any signature
302
+ already on it and destroy the history a signature exists to establish.
303
+
304
+ A visible signature — a drawn one, an image — is a separate matter: `stamp` it
305
+ on first, then sign.
306
+
307
+ ### Signing with a key you hold
308
+
309
+ `pkcs8Signer` is the one signer that ships, because it is the one with no
310
+ vendor behind it:
311
+
312
+ ```ts
313
+ signers: {
314
+ internal: pkcs8Signer({
315
+ key: readFileSync(app.makePath('storage/signing.key.der')),
316
+ certificate: readFileSync(app.makePath('storage/signing.crt.der')),
317
+ }),
318
+ }
319
+ ```
320
+
321
+ It builds a CAdES `SignedData` whose signed attributes carry the content type,
322
+ the document's digest, the signing time and — as PAdES requires —
323
+ `signing-certificate-v2`. That last one is not decoration: without it a
324
+ signature is bound to a key but not to an identity.
325
+
326
+ PKCS#8 and DER rather than a `.p12`, because reading PKCS#12 in Rust is not
327
+ something to put underneath a signature, and
328
+ `openssl pkcs12 -in bundle.p12 -nodes` gets you there in one command.
329
+
330
+ This is an **advanced** signature: it proves the document has not changed since
331
+ a particular key signed it. Where the law requires a *qualified* one, the key
332
+ has to live with a certified provider — and that is an adapter, not this.
333
+
334
+ ### Adapters
335
+
336
+ A provider is a `Signer` and nothing more, so an adapter is a short function
337
+ returning one. None ship here: naming a vendor would tie an agnostic package to
338
+ one, and an HTTP client is not a dependency it should carry.
339
+
340
+ ### Timestamping
341
+
342
+ `timestamped` wraps any signer, the local one or a provider's:
343
+
344
+ ```ts
345
+ signers: {
346
+ internal: timestamped(pkcs8Signer({ key, certificate }), {
347
+ url: 'https://freetsa.org/tsr',
348
+ }),
349
+ }
350
+ ```
351
+
352
+ A signature proves a document has not changed since a key signed it, not
353
+ *when*. Once the certificate expires a verifier cannot tell a signature made
354
+ while it was valid from one forged afterwards, and stops accepting it. For a
355
+ document kept for years — which is most of the documents anyone bothers to
356
+ sign — this is what keeps it verifiable.
357
+
358
+ The token goes on as an **unsigned** attribute, which is what lets it be added
359
+ without disturbing the signature. What comes back is checked rather than
360
+ trusted: the authority's status, that it stamped the signature we actually
361
+ sent, and that it answered *this* request rather than replaying an older
362
+ answer. Those are read out of the token's structure, not looked for in its
363
+ bytes — a hash that happens to appear inside a certificate is not a hash the
364
+ authority stamped. A token that cannot be read is refused, which fails closed.
365
+
366
+ The signature grows by a few kilobytes, so a document prepared with a tight
367
+ `capacity` may need a larger one.
368
+
369
+ ### On the dependencies
370
+
371
+ The CMS stack sits on the older RustCrypto generation, which costs a second
372
+ copy of `digest` next to the one lopdf uses. The alternative was the matching
373
+ generation, where `cms` and `rsa` are still release candidates — and a release
374
+ candidate in the one path where correctness is legally load-bearing is a worse
375
+ trade than a duplicated hash, which produces identical bytes by definition.
376
+ One line of `Cargo.toml` to revisit when they ship.
377
+
378
+ ## Checking a signature
379
+
380
+ Signing is half the job. A document that arrives signed is worth nothing until
381
+ someone has checked it:
382
+
383
+ ```ts
384
+ for (const signature of await vellum.verifySignatures(mandate)) {
385
+ if (!signature.coversWholeDocument) reject('content was added after signing')
386
+ if (!signature.digestMatches) reject('the document has changed')
387
+ if (!signature.signatureVerifies) reject('the signature does not match')
388
+ }
389
+ ```
390
+
391
+ `coversWholeDocument` is the one that catches the trap everybody meets first:
392
+ **content appended after a signature is not covered by it**, and the arithmetic
393
+ over the covered part still checks out. A reader that verifies only the
394
+ digest will happily call such a document signed. A document whose second half
395
+ arrived later is not one.
396
+
397
+ The report also names the signer, the time they stated, and whether an
398
+ authority has timestamped it.
399
+
400
+ ### Trust
401
+
402
+ Checking that a signature matches the certificate it carries says nothing about
403
+ who that certificate belongs to: anyone can make one. Trust comes from a path
404
+ to an anchor you have decided to accept:
405
+
406
+ ```ts
407
+ // config/vellum.ts
408
+ export default defineConfig({
409
+ trustedAnchors: [readFileSync(app.makePath('storage/anchors/authority.pem'))],
410
+ })
411
+ ```
412
+
413
+ Supply the roots your jurisdiction's supervisory body publishes — they are
414
+ distributed as trusted lists in the ETSI TS 119 612 format — or your own
415
+ authority's, and `trusted` says whether a path was found. DER or PEM; PEM is
416
+ what an authority usually publishes. Supplying none is a position too: every
417
+ signature then comes back untrusted, which is the honest answer rather than a
418
+ comfortable one.
419
+
420
+ The path is judged **at the moment of signing**, not now: a certificate valid
421
+ when the document was signed and expired since did not retroactively unsign
422
+ anything. `moment` says where that instant came from — `"timestamp"` if an
423
+ authority vouched for it, `"claimed"` if it rests on the signer's own word.
424
+ That is the concrete reason timestamping is worth the round trip.
425
+
426
+ Every link is checked: each certificate is signed by the one above it, each
427
+ issuer says it is an authority, and the signing certificate is allowed to sign
428
+ at all. A path longer than eight is treated as a loop.
429
+
430
+ ### Revocation
431
+
432
+ A certificate can be valid on its face and worthless in fact: keys are lost,
433
+ people leave, authorities discover a mistake. Only the issuer knows, and only
434
+ if asked.
435
+
436
+ ```ts
437
+ const signatures = await vellum.verifySignatures(mandate, { checkRevocation: true })
438
+ // signatures[0].revocation → { status: 'good' | 'revoked' | 'unknown', detail? }
439
+ ```
440
+
441
+ That is a network call per signature, to the responder the certificate names,
442
+ so it is off unless asked for.
443
+
444
+ The answer has **three** values, not two. `unknown` covers everything else —
445
+ the responder was unreachable, answered about a different certificate, or could
446
+ not be believed. Collapsing it into either of the others is the mistake to
447
+ avoid: treating it as good waves a withdrawn certificate through, and treating
448
+ it as revoked rejects documents whenever a server is down. **Which to do is
449
+ your policy**, so it is reported rather than decided.
450
+
451
+ An answer is only read if the issuer, or somebody the issuer authorised to
452
+ answer for it, actually signed it — otherwise anyone could revoke anything. And
453
+ a certificate withdrawn **after** the document was signed does not taint it:
454
+ that is what a signing time, and better a timestamp, is for.
455
+
456
+ ## Status
457
+
458
+ Rendering to images, metadata, text extraction, document operations, stamping
459
+ — image and text — supplied fonts, interactive forms read, filled, laid out
460
+ and flattened, and signatures made, timestamped, checked, traced to an
461
+ authority you accept and asked about at their issuer, are complete.
462
+
463
+ What remains needs something this package cannot supply itself: an adapter for
464
+ a certified provider, which is a short function returning a `Signer` and
465
+ belongs to whoever has the account.
466
+
467
+ ## Building the native engine
468
+
469
+ ```bash
470
+ pnpm build:napi # cargo build --release + type generation + binary copy
471
+ pnpm test # TypeScript suite
472
+ pnpm test:rust # engine suite
473
+ ```