@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.
- package/LICENSE +21 -0
- package/README.md +473 -0
- package/dist/Vellum.d.ts +460 -0
- package/dist/Vellum.d.ts.map +1 -0
- package/dist/Vellum.js +479 -0
- package/dist/Vellum.js.map +1 -0
- package/dist/VellumProvider.d.ts +28 -0
- package/dist/VellumProvider.d.ts.map +1 -0
- package/dist/VellumProvider.js +33 -0
- package/dist/VellumProvider.js.map +1 -0
- package/dist/augmentations.d.ts +22 -0
- package/dist/augmentations.d.ts.map +1 -0
- package/dist/augmentations.js +16 -0
- package/dist/augmentations.js.map +1 -0
- package/dist/config.d.ts +33 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +31 -0
- package/dist/config.js.map +1 -0
- package/dist/configure.d.ts +17 -0
- package/dist/configure.d.ts.map +1 -0
- package/dist/configure.js +78 -0
- package/dist/configure.js.map +1 -0
- package/dist/errors.d.ts +9 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +19 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +44 -0
- package/dist/index.js.map +1 -0
- package/dist/native/generated.d.ts +255 -0
- package/dist/native/generated.d.ts.map +1 -0
- package/dist/native/generated.js +7 -0
- package/dist/native/generated.js.map +1 -0
- package/dist/native.d.ts +50 -0
- package/dist/native.d.ts.map +1 -0
- package/dist/native.js +194 -0
- package/dist/native.js.map +1 -0
- package/dist/responder.d.ts +29 -0
- package/dist/responder.d.ts.map +1 -0
- package/dist/responder.js +101 -0
- package/dist/responder.js.map +1 -0
- package/dist/services/main.d.ts +15 -0
- package/dist/services/main.d.ts.map +1 -0
- package/dist/services/main.js +39 -0
- package/dist/services/main.js.map +1 -0
- package/dist/signers.d.ts +81 -0
- package/dist/signers.d.ts.map +1 -0
- package/dist/signers.js +98 -0
- package/dist/signers.js.map +1 -0
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +67 -0
- package/scripts/build-napi-types.mjs +69 -0
- package/scripts/copy-napi.mjs +48 -0
- package/scripts/generate-metrics.py +150 -0
- package/scripts/generate-napi-types.mjs +156 -0
- package/src/Vellum.ts +829 -0
- package/src/VellumProvider.ts +51 -0
- package/src/augmentations.ts +25 -0
- package/src/config.ts +47 -0
- package/src/configure.ts +92 -0
- package/src/errors.ts +20 -0
- package/src/index.ts +74 -0
- package/src/native/generated.ts +375 -0
- package/src/native.ts +358 -0
- package/src/responder.ts +116 -0
- package/src/services/main.ts +48 -0
- package/src/signers.ts +149 -0
package/src/Vellum.ts
ADDED
|
@@ -0,0 +1,829 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vellum — the PDF service.
|
|
3
|
+
*
|
|
4
|
+
* Resolved from the container as `vellum`, or imported directly from
|
|
5
|
+
* `@c9up/vellum/services/main`. Every method is asynchronous: rasterising is
|
|
6
|
+
* real computation, and it runs on the libuv thread pool rather than on the
|
|
7
|
+
* thread serving requests.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFile } from "node:fs/promises";
|
|
11
|
+
|
|
12
|
+
import { VellumError } from "./errors.js";
|
|
13
|
+
import type {
|
|
14
|
+
DocumentInfo,
|
|
15
|
+
DocumentMetadata,
|
|
16
|
+
FormField,
|
|
17
|
+
PageDimensions,
|
|
18
|
+
RevocationAnswer,
|
|
19
|
+
SignatureReport,
|
|
20
|
+
} from "./native.js";
|
|
21
|
+
import {
|
|
22
|
+
embedSignatureNative,
|
|
23
|
+
extractTextAllNative,
|
|
24
|
+
extractTextNative,
|
|
25
|
+
fillFormNative,
|
|
26
|
+
flattenFormNative,
|
|
27
|
+
formFieldsNative,
|
|
28
|
+
inspectNative,
|
|
29
|
+
mergeNative,
|
|
30
|
+
metadataNative,
|
|
31
|
+
pageDimensionsNative,
|
|
32
|
+
prepareSignatureNative,
|
|
33
|
+
readRevocationNative,
|
|
34
|
+
renderAllNative,
|
|
35
|
+
renderPageNative,
|
|
36
|
+
responderUrlNative,
|
|
37
|
+
revocationQueryNative,
|
|
38
|
+
rotateNative,
|
|
39
|
+
selectPagesNative,
|
|
40
|
+
splitNative,
|
|
41
|
+
stampNative,
|
|
42
|
+
stampTextNative,
|
|
43
|
+
verifySignaturesNative,
|
|
44
|
+
} from "./native.js";
|
|
45
|
+
import { mayAsk, type ResponderPolicy } from "./responder.js";
|
|
46
|
+
|
|
47
|
+
/** Image encodings a page can be rasterised to. */
|
|
48
|
+
export type ImageFormat = "png" | "jpeg";
|
|
49
|
+
|
|
50
|
+
/** Rendering defaults, set in `config/vellum.ts`. */
|
|
51
|
+
export interface VellumConfig {
|
|
52
|
+
/** Default encoding. `"png"` unless set. */
|
|
53
|
+
format?: ImageFormat;
|
|
54
|
+
/** Default multiplier over the page's natural size, 1 being 72 DPI. */
|
|
55
|
+
scale?: number;
|
|
56
|
+
/** Default target width in pixels. Takes precedence over `scale`. */
|
|
57
|
+
width?: number;
|
|
58
|
+
/** Default JPEG quality, 1-100. */
|
|
59
|
+
quality?: number;
|
|
60
|
+
/** Default background: `#rgb`, `#rrggbb`, `#rrggbbaa` or `"transparent"`. */
|
|
61
|
+
background?: string;
|
|
62
|
+
/**
|
|
63
|
+
* The most pixels one page may rasterise to. 50 million by default — room
|
|
64
|
+
* for A4 at 600 DPI, and A3 at 400.
|
|
65
|
+
*
|
|
66
|
+
* A page declares its own size, so without a ceiling a document alone was
|
|
67
|
+
* enough to ask for gigabytes: bounding each side to 65535 still leaves 16
|
|
68
|
+
* GiB of RGBA between two of them. Raise it knowingly when you render
|
|
69
|
+
* something genuinely large.
|
|
70
|
+
*/
|
|
71
|
+
maxPixels?: number;
|
|
72
|
+
/**
|
|
73
|
+
* Fonts to write text with, by the name a caller asks for them by. Values
|
|
74
|
+
* are paths to TrueType or OpenType files.
|
|
75
|
+
*
|
|
76
|
+
* ```ts
|
|
77
|
+
* fonts: { body: app.makePath('resources/fonts/Inter-Regular.ttf') }
|
|
78
|
+
* ```
|
|
79
|
+
*
|
|
80
|
+
* A name declared here is looked up before the standard fonts, so calling
|
|
81
|
+
* one `Helvetica` shadows the standard one — deliberately, since a project
|
|
82
|
+
* that ships its own Helvetica means that one.
|
|
83
|
+
*/
|
|
84
|
+
fonts?: Record<string, string>;
|
|
85
|
+
/**
|
|
86
|
+
* Who may sign a document, by the name a caller asks for.
|
|
87
|
+
*
|
|
88
|
+
* ```ts
|
|
89
|
+
* signers: {
|
|
90
|
+
* internal: myLocalSigner,
|
|
91
|
+
* qualified: myProviderSigner,
|
|
92
|
+
* }
|
|
93
|
+
* ```
|
|
94
|
+
*
|
|
95
|
+
* A key held here and a key held by a certified provider are the same
|
|
96
|
+
* thing to this package, because a signer never sees the document — only
|
|
97
|
+
* the digest of it. Which one signs is therefore a line of configuration.
|
|
98
|
+
*/
|
|
99
|
+
signers?: Record<string, Signer>;
|
|
100
|
+
/**
|
|
101
|
+
* Certificates to trust when checking a signature, DER or PEM.
|
|
102
|
+
*
|
|
103
|
+
* Typically the roots of the authorities your jurisdiction recognises,
|
|
104
|
+
* which supervisory bodies publish as a trusted list. Supplying none is a
|
|
105
|
+
* position too: every signature then comes back untrusted, which is the
|
|
106
|
+
* honest answer rather than a comfortable one.
|
|
107
|
+
*/
|
|
108
|
+
trustedAnchors?: ReadonlyArray<Buffer>;
|
|
109
|
+
/**
|
|
110
|
+
* Which revocation responders may be contacted.
|
|
111
|
+
*
|
|
112
|
+
* The address comes out of the certificate inside the document being
|
|
113
|
+
* checked — from whoever sent it. Left unset, only public hosts over
|
|
114
|
+
* http/https are asked, which is what a real certificate authority is. Set
|
|
115
|
+
* it to a list of hostnames, or a predicate, when your authority answers
|
|
116
|
+
* somewhere those rules exclude.
|
|
117
|
+
*/
|
|
118
|
+
allowedResponders?: ResponderPolicy;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Whatever turns a digest into a signature.
|
|
123
|
+
*
|
|
124
|
+
* It is given the SHA-256 of the byte range the signature covers, and returns
|
|
125
|
+
* the CMS `SignedData` to put in the document. It is never given the document:
|
|
126
|
+
* a PDF signature covers a byte range of the file it lives in, so the value
|
|
127
|
+
* has to be computed over a digest and dropped into space reserved for it.
|
|
128
|
+
*
|
|
129
|
+
* That is what lets a key in a file and a certified provider's API be the same
|
|
130
|
+
* interface — and why signing over the network belongs here rather than in the
|
|
131
|
+
* engine, which does no I/O.
|
|
132
|
+
*/
|
|
133
|
+
export interface Signer {
|
|
134
|
+
sign(digest: Buffer): Promise<Buffer>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* A signature report, plus what the issuer said about the certificate when
|
|
139
|
+
* `checkRevocation` asked.
|
|
140
|
+
*/
|
|
141
|
+
export type CheckedSignature = SignatureReport & {
|
|
142
|
+
revocation?: RevocationAnswer;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** What to check a signature against. */
|
|
146
|
+
export interface VerifyOptions {
|
|
147
|
+
/**
|
|
148
|
+
* Certificates to trust as roots, DER or PEM. Falls back to
|
|
149
|
+
* `trustedAnchors` in `config/vellum.ts`.
|
|
150
|
+
*/
|
|
151
|
+
anchors?: ReadonlyArray<Buffer>;
|
|
152
|
+
/** Which responders may be contacted, overriding `config/vellum.ts`. */
|
|
153
|
+
allowedResponders?: ResponderPolicy;
|
|
154
|
+
/**
|
|
155
|
+
* Ask each certificate's issuer whether it still stands.
|
|
156
|
+
*
|
|
157
|
+
* A network call per signature, to the responder the certificate names.
|
|
158
|
+
* The answer has **three** values, not two: `"good"`, `"revoked"`, and
|
|
159
|
+
* `"unknown"` for everything else — the responder was unreachable,
|
|
160
|
+
* answered about something else, or could not be believed. Treating
|
|
161
|
+
* `"unknown"` as good waves through a withdrawn certificate; treating it
|
|
162
|
+
* as revoked rejects documents whenever a server is down. Which to do is
|
|
163
|
+
* your policy, so it is reported rather than decided.
|
|
164
|
+
*/
|
|
165
|
+
checkRevocation?: boolean;
|
|
166
|
+
/** How long to wait on a responder. Default 10 seconds. */
|
|
167
|
+
revocationTimeoutMs?: number;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** What the signature says about itself. */
|
|
171
|
+
export interface SignOptions {
|
|
172
|
+
/** Which signer, by the name it has in `config/vellum.ts`. */
|
|
173
|
+
signer: string;
|
|
174
|
+
/** Why the document was signed. */
|
|
175
|
+
reason?: string;
|
|
176
|
+
/** Where it was signed. */
|
|
177
|
+
location?: string;
|
|
178
|
+
/** How to reach the signatory. */
|
|
179
|
+
contact?: string;
|
|
180
|
+
/** Who signed, as it should be displayed. */
|
|
181
|
+
name?: string;
|
|
182
|
+
/** When. Defaults to now. */
|
|
183
|
+
signedAt?: Date;
|
|
184
|
+
/**
|
|
185
|
+
* Bytes reserved for the signature value. Default 16384, comfortable for a
|
|
186
|
+
* timestamped signature. The room cannot be found afterwards, so a signer
|
|
187
|
+
* that returns more than fits has to be given more here.
|
|
188
|
+
*/
|
|
189
|
+
capacity?: number;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* One of the 14 fonts every PDF reader is required to have.
|
|
194
|
+
*
|
|
195
|
+
* They can be referenced without being embedded, which is why writing text
|
|
196
|
+
* adds nothing to the file and needs no font to be supplied.
|
|
197
|
+
*/
|
|
198
|
+
export type StandardFont =
|
|
199
|
+
| "Helvetica"
|
|
200
|
+
| "Helvetica-Bold"
|
|
201
|
+
| "Helvetica-Oblique"
|
|
202
|
+
| "Times-Roman"
|
|
203
|
+
| "Times-Bold"
|
|
204
|
+
| "Times-Italic"
|
|
205
|
+
| "Courier"
|
|
206
|
+
| "Courier-Bold";
|
|
207
|
+
|
|
208
|
+
/** Where and how a line of text is written onto a page. */
|
|
209
|
+
export interface TextStampOptions extends PageOptions {
|
|
210
|
+
/** Points from the left edge. Default 0. */
|
|
211
|
+
x?: number;
|
|
212
|
+
/**
|
|
213
|
+
* Points from the top edge, to the text's BASELINE — the line the letters
|
|
214
|
+
* sit on, not the top of their bounding box.
|
|
215
|
+
*/
|
|
216
|
+
y?: number;
|
|
217
|
+
/** Type size in points. Default 12. */
|
|
218
|
+
size?: number;
|
|
219
|
+
/**
|
|
220
|
+
* A font named in `config/vellum.ts`, or one of the 14 standard fonts.
|
|
221
|
+
* Default `"Helvetica"`.
|
|
222
|
+
*/
|
|
223
|
+
// The intersection keeps the standard names suggested while still
|
|
224
|
+
// accepting a configured one.
|
|
225
|
+
font?: StandardFont | (string & {});
|
|
226
|
+
/** `#rgb` or `#rrggbb`. Default black. */
|
|
227
|
+
color?: string;
|
|
228
|
+
/** 0 is invisible, 1 is opaque. */
|
|
229
|
+
opacity?: number;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Where and how an image is laid onto a page. */
|
|
233
|
+
export interface StampOptions extends PageOptions {
|
|
234
|
+
/**
|
|
235
|
+
* Points from the left edge. Default 0.
|
|
236
|
+
*
|
|
237
|
+
* Coordinates count from the TOP-LEFT corner, the way a screen layout is
|
|
238
|
+
* written.
|
|
239
|
+
*/
|
|
240
|
+
x?: number;
|
|
241
|
+
/** Points from the top edge. Default 0. */
|
|
242
|
+
y?: number;
|
|
243
|
+
/** Drawn width in points. With `height` absent, the ratio is kept. */
|
|
244
|
+
width?: number;
|
|
245
|
+
/** Drawn height in points. With `width` absent, the ratio is kept. */
|
|
246
|
+
height?: number;
|
|
247
|
+
/** 0 is invisible, 1 is opaque. A watermark usually wants about 0.15. */
|
|
248
|
+
opacity?: number;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Options that address a single page. */
|
|
252
|
+
export interface PageOptions {
|
|
253
|
+
/**
|
|
254
|
+
* Which page, counting from 1 — the number printed on the page, not an
|
|
255
|
+
* array index.
|
|
256
|
+
*/
|
|
257
|
+
page?: number;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Per-call rendering options. Anything omitted falls back to the config. */
|
|
261
|
+
export interface RenderOptions extends VellumConfig, PageOptions {}
|
|
262
|
+
|
|
263
|
+
export class Vellum {
|
|
264
|
+
readonly #config: VellumConfig;
|
|
265
|
+
/** Configured fonts, read once each. They do not change under us. */
|
|
266
|
+
readonly #fonts = new Map<string, Buffer>();
|
|
267
|
+
|
|
268
|
+
constructor(config: VellumConfig = {}) {
|
|
269
|
+
this.#config = config;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The bytes of a configured font, or `undefined` when the name is not one.
|
|
274
|
+
*
|
|
275
|
+
* A name that is not configured falls through to the standard fonts, which
|
|
276
|
+
* is what makes `font: 'Times-Roman'` keep working with no configuration
|
|
277
|
+
* at all.
|
|
278
|
+
*/
|
|
279
|
+
async #font(name: string | undefined): Promise<Buffer | undefined> {
|
|
280
|
+
if (name === undefined) return undefined;
|
|
281
|
+
const path = this.#config.fonts?.[name];
|
|
282
|
+
if (path === undefined) return undefined;
|
|
283
|
+
|
|
284
|
+
const loaded = this.#fonts.get(path);
|
|
285
|
+
if (loaded !== undefined) return loaded;
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
const data = await readFile(path);
|
|
289
|
+
this.#fonts.set(path, data);
|
|
290
|
+
return data;
|
|
291
|
+
} catch (error) {
|
|
292
|
+
throw new VellumError(
|
|
293
|
+
"FONT_UNREADABLE",
|
|
294
|
+
`The font ${name} is configured as ${path}, which cannot be read.`,
|
|
295
|
+
{ cause: error },
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** The configured defaults, as the service resolved them. */
|
|
301
|
+
get config(): Readonly<VellumConfig> {
|
|
302
|
+
return this.#config;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Rasterise a single page to an image.
|
|
307
|
+
*
|
|
308
|
+
* ```ts
|
|
309
|
+
* const preview = await vellum.render(pdf, { page: 1, width: 1200 })
|
|
310
|
+
* ```
|
|
311
|
+
*/
|
|
312
|
+
async render(pdf: Buffer, options: RenderOptions = {}): Promise<Buffer> {
|
|
313
|
+
return renderPageNative(
|
|
314
|
+
pdf,
|
|
315
|
+
this.#pageIndex(options),
|
|
316
|
+
this.#merge(options),
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Rasterise every page, in document order.
|
|
322
|
+
*
|
|
323
|
+
* ```ts
|
|
324
|
+
* const pages = await vellum.renderAll(pdf, { format: 'jpeg' })
|
|
325
|
+
* ```
|
|
326
|
+
*/
|
|
327
|
+
async renderAll(pdf: Buffer, options: RenderOptions = {}): Promise<Buffer[]> {
|
|
328
|
+
return renderAllNative(pdf, this.#merge(options));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** The natural size of every page, in points, before any scaling. */
|
|
332
|
+
async dimensions(pdf: Buffer): Promise<PageDimensions[]> {
|
|
333
|
+
return pageDimensionsNative(pdf);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** How many pages the document has, which format version, and whether it is encrypted. */
|
|
337
|
+
async inspect(pdf: Buffer): Promise<DocumentInfo> {
|
|
338
|
+
return inspectNative(pdf);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* What the document says about itself: title, author, subject, keywords,
|
|
343
|
+
* the applications involved, and the dates.
|
|
344
|
+
*
|
|
345
|
+
* Every field is optional, because a PDF is valid with no `/Info` at all
|
|
346
|
+
* and producers fill in whichever ones they like. Dates come back as ISO
|
|
347
|
+
* 8601 when the producer wrote a conforming one, otherwise as the raw
|
|
348
|
+
* string it did write.
|
|
349
|
+
*/
|
|
350
|
+
async metadata(pdf: Buffer): Promise<DocumentMetadata> {
|
|
351
|
+
return metadataNative(pdf);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* The text of a single page.
|
|
356
|
+
*
|
|
357
|
+
* ```ts
|
|
358
|
+
* const text = await vellum.extractText(pdf, { page: 1 })
|
|
359
|
+
* ```
|
|
360
|
+
*
|
|
361
|
+
* Glyphs come back in the order the page draws them, with a line break
|
|
362
|
+
* where the baseline moves. A scanned document with no text layer yields
|
|
363
|
+
* an empty string rather than an error — it has no text to give.
|
|
364
|
+
*/
|
|
365
|
+
async extractText(pdf: Buffer, options: PageOptions = {}): Promise<string> {
|
|
366
|
+
return extractTextNative(pdf, this.#pageIndex(options));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** The text of every page, in document order. */
|
|
370
|
+
async extractTextAll(pdf: Buffer): Promise<string[]> {
|
|
371
|
+
return extractTextAllNative(pdf);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Join documents end to end, in the order given.
|
|
376
|
+
*
|
|
377
|
+
* ```ts
|
|
378
|
+
* const dossier = await vellum.merge([contract, annexe, signature])
|
|
379
|
+
* ```
|
|
380
|
+
*
|
|
381
|
+
* Attributes a page inherits from its parent — size, resources — are
|
|
382
|
+
* materialised onto it first, so a page keeps its own size instead of
|
|
383
|
+
* falling back to Letter.
|
|
384
|
+
*/
|
|
385
|
+
async merge(pdfs: ReadonlyArray<Buffer>): Promise<Buffer> {
|
|
386
|
+
if (pdfs.length === 0) {
|
|
387
|
+
throw new VellumError(
|
|
388
|
+
"EMPTY_MERGE",
|
|
389
|
+
"Merging needs at least one document.",
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
return mergeNative(pdfs);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Keep only the pages listed, counting from 1, in document order.
|
|
397
|
+
*
|
|
398
|
+
* ```ts
|
|
399
|
+
* const extract = await vellum.selectPages(pdf, [1, 3, 4])
|
|
400
|
+
* ```
|
|
401
|
+
*/
|
|
402
|
+
async selectPages(
|
|
403
|
+
pdf: Buffer,
|
|
404
|
+
pages: ReadonlyArray<number>,
|
|
405
|
+
): Promise<Buffer> {
|
|
406
|
+
if (pages.length === 0) {
|
|
407
|
+
throw new VellumError(
|
|
408
|
+
"EMPTY_SELECTION",
|
|
409
|
+
"Selecting needs at least one page.",
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
return selectPagesNative(pdf, this.#pageIndexes(pages));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** One single-page document per page, in document order. */
|
|
416
|
+
async split(pdf: Buffer): Promise<Buffer[]> {
|
|
417
|
+
return splitNative(pdf);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Rotate pages clockwise by `degrees`, a multiple of 90.
|
|
422
|
+
*
|
|
423
|
+
* ```ts
|
|
424
|
+
* const upright = await vellum.rotate(scan, 90, { pages: [1] })
|
|
425
|
+
* ```
|
|
426
|
+
*
|
|
427
|
+
* The rotation is added to what a page already carries, because a scan can
|
|
428
|
+
* arrive already turned.
|
|
429
|
+
*/
|
|
430
|
+
async rotate(
|
|
431
|
+
pdf: Buffer,
|
|
432
|
+
degrees: number,
|
|
433
|
+
options: { pages?: ReadonlyArray<number> } = {},
|
|
434
|
+
): Promise<Buffer> {
|
|
435
|
+
if (!Number.isInteger(degrees) || degrees % 90 !== 0) {
|
|
436
|
+
throw new VellumError(
|
|
437
|
+
"INVALID_ROTATION",
|
|
438
|
+
`Rotation must be a whole multiple of 90, got ${degrees}.`,
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
return rotateNative(
|
|
442
|
+
pdf,
|
|
443
|
+
degrees,
|
|
444
|
+
options.pages ? this.#pageIndexes(options.pages) : undefined,
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Draw an image onto the document — a signature, a photo, a watermark.
|
|
450
|
+
*
|
|
451
|
+
* ```ts
|
|
452
|
+
* const signed = await vellum.stamp(workOrder, signature, {
|
|
453
|
+
* page: 1, x: 380, y: 690, width: 140,
|
|
454
|
+
* })
|
|
455
|
+
* ```
|
|
456
|
+
*
|
|
457
|
+
* PNG and JPEG are accepted, chosen by signature rather than by file name.
|
|
458
|
+
* Omitting `page` stamps every page, which is what a watermark wants.
|
|
459
|
+
*/
|
|
460
|
+
async stamp(
|
|
461
|
+
pdf: Buffer,
|
|
462
|
+
image: Buffer,
|
|
463
|
+
options: StampOptions = {},
|
|
464
|
+
): Promise<Buffer> {
|
|
465
|
+
return stampNative(pdf, image, {
|
|
466
|
+
// Absent means every page here, so the 1-based conversion only
|
|
467
|
+
// applies when a page was actually named.
|
|
468
|
+
page: options.page === undefined ? undefined : this.#pageIndex(options),
|
|
469
|
+
x: options.x,
|
|
470
|
+
y: options.y,
|
|
471
|
+
width: options.width,
|
|
472
|
+
height: options.height,
|
|
473
|
+
opacity: options.opacity,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Write a line of text onto the document.
|
|
479
|
+
*
|
|
480
|
+
* ```ts
|
|
481
|
+
* const marked = await vellum.stampText(invoice, 'PAYÉ', {
|
|
482
|
+
* page: 1, x: 400, y: 80, size: 24, color: '#c00', opacity: 0.6,
|
|
483
|
+
* })
|
|
484
|
+
* ```
|
|
485
|
+
*
|
|
486
|
+
* `font` names one of the 14 standard fonts by default. A PDF may
|
|
487
|
+
* reference those without embedding them, so nothing is added to the file
|
|
488
|
+
* — at the cost of the WinAnsi character set, outside which text is
|
|
489
|
+
* refused rather than mangled.
|
|
490
|
+
*
|
|
491
|
+
* Naming a font declared in `config/vellum.ts` instead embeds it,
|
|
492
|
+
* subsetted to the characters actually written, and lifts that limit:
|
|
493
|
+
*
|
|
494
|
+
* ```ts
|
|
495
|
+
* // config/vellum.ts
|
|
496
|
+
* fonts: { body: app.makePath('resources/fonts/Inter-Regular.ttf') }
|
|
497
|
+
*
|
|
498
|
+
* await vellum.stampText(pdf, 'Uměl Řehoř', { font: 'body' })
|
|
499
|
+
* ```
|
|
500
|
+
*
|
|
501
|
+
* Naming no page writes on every page, which is what a draft marking
|
|
502
|
+
* wants.
|
|
503
|
+
*/
|
|
504
|
+
async stampText(
|
|
505
|
+
pdf: Buffer,
|
|
506
|
+
text: string,
|
|
507
|
+
options: TextStampOptions = {},
|
|
508
|
+
): Promise<Buffer> {
|
|
509
|
+
const fontData = await this.#font(options.font);
|
|
510
|
+
return stampTextNative(pdf, text, {
|
|
511
|
+
page: options.page === undefined ? undefined : this.#pageIndex(options),
|
|
512
|
+
x: options.x,
|
|
513
|
+
y: options.y,
|
|
514
|
+
size: options.size,
|
|
515
|
+
font: fontData === undefined ? options.font : undefined,
|
|
516
|
+
fontData,
|
|
517
|
+
color: options.color,
|
|
518
|
+
opacity: options.opacity,
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* The interactive fields of the document's form, in declaration order.
|
|
524
|
+
*
|
|
525
|
+
* ```ts
|
|
526
|
+
* for (const field of await vellum.formFields(mandate)) {
|
|
527
|
+
* console.log(field.name, field.kind, field.value)
|
|
528
|
+
* }
|
|
529
|
+
* ```
|
|
530
|
+
*
|
|
531
|
+
* `name` is the fully qualified name — the one used to fill the field in.
|
|
532
|
+
* For a checkbox or a radio group, `options` lists the states the DOCUMENT
|
|
533
|
+
* accepts: their "on" state is not a fixed name, and writing anything else
|
|
534
|
+
* leaves the control untouched.
|
|
535
|
+
*
|
|
536
|
+
* A document with no form yields an empty list rather than an error.
|
|
537
|
+
*/
|
|
538
|
+
async formFields(pdf: Buffer): Promise<FormField[]> {
|
|
539
|
+
return formFieldsNative(pdf);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Fill the named fields of the document's form.
|
|
544
|
+
*
|
|
545
|
+
* ```ts
|
|
546
|
+
* const filled = await vellum.fillForm(mandate, {
|
|
547
|
+
* 'assure.nom': 'Amélie Durand',
|
|
548
|
+
* accepted: 'Yes',
|
|
549
|
+
* country: 'CH',
|
|
550
|
+
* })
|
|
551
|
+
* ```
|
|
552
|
+
*
|
|
553
|
+
* Keys are the fully qualified names {@link Vellum.formFields} reports.
|
|
554
|
+
*
|
|
555
|
+
* Each filled field's **appearance stream is regenerated**. Writing the
|
|
556
|
+
* value alone is not enough: most readers paint a field from its
|
|
557
|
+
* appearance, not from its value, so a document filled without that opens
|
|
558
|
+
* looking empty while holding every answer.
|
|
559
|
+
*
|
|
560
|
+
* A name the form does not have is an error rather than a silent no-op —
|
|
561
|
+
* a filled document missing an answer nobody noticed is worse than a
|
|
562
|
+
* failure. The same goes for a read-only field, a value over the field's
|
|
563
|
+
* maximum length, and a checkbox state the document does not accept.
|
|
564
|
+
*/
|
|
565
|
+
async fillForm(pdf: Buffer, values: Record<string, string>): Promise<Buffer> {
|
|
566
|
+
return fillFormNative(pdf, values);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Paint the form into the page and remove it.
|
|
571
|
+
*
|
|
572
|
+
* ```ts
|
|
573
|
+
* const signed = await vellum.flattenForm(
|
|
574
|
+
* await vellum.fillForm(mandate, { 'assure.nom': 'Amélie Durand' }),
|
|
575
|
+
* )
|
|
576
|
+
* ```
|
|
577
|
+
*
|
|
578
|
+
* The document keeps its look and loses its fields: every widget's
|
|
579
|
+
* appearance becomes ordinary page content, the widget annotations go, and
|
|
580
|
+
* the form itself is dropped. This is what turns a filled document into
|
|
581
|
+
* one nobody can edit back.
|
|
582
|
+
*
|
|
583
|
+
* Annotations that are not form widgets — links, notes — are left where
|
|
584
|
+
* they are. A field holding a value that ships no appearance to paint is
|
|
585
|
+
* an error: the answer would vanish from a document that still looks
|
|
586
|
+
* complete.
|
|
587
|
+
*/
|
|
588
|
+
async flattenForm(pdf: Buffer): Promise<Buffer> {
|
|
589
|
+
return flattenFormNative(pdf);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Sign the document with one of the configured signers.
|
|
594
|
+
*
|
|
595
|
+
* ```ts
|
|
596
|
+
* const signed = await vellum.sign(mandate, {
|
|
597
|
+
* signer: 'qualified',
|
|
598
|
+
* reason: 'Mandat de prévoyance',
|
|
599
|
+
* name: 'Amélie Durand',
|
|
600
|
+
* })
|
|
601
|
+
* ```
|
|
602
|
+
*
|
|
603
|
+
* The signature is appended as an **incremental revision**: the bytes it
|
|
604
|
+
* signs are preserved exactly, and nothing already in the document is
|
|
605
|
+
* rewritten. Rewriting would invalidate any signature already on it and
|
|
606
|
+
* destroy the history a signature exists to establish.
|
|
607
|
+
*
|
|
608
|
+
* The signer is handed the digest of what the signature covers and returns
|
|
609
|
+
* the CMS to embed. It never sees the document, which is what makes a
|
|
610
|
+
* local key and a certified provider interchangeable.
|
|
611
|
+
*
|
|
612
|
+
* A visible signature — a drawn one, an image — is a separate matter:
|
|
613
|
+
* {@link Vellum.stamp} it on first, then sign.
|
|
614
|
+
*/
|
|
615
|
+
async sign(pdf: Buffer, options: SignOptions): Promise<Buffer> {
|
|
616
|
+
const signer = this.#config.signers?.[options.signer];
|
|
617
|
+
if (signer === undefined) {
|
|
618
|
+
const known = Object.keys(this.#config.signers ?? {});
|
|
619
|
+
throw new VellumError(
|
|
620
|
+
"UNKNOWN_SIGNER",
|
|
621
|
+
`No signer named ${options.signer} is configured — ` +
|
|
622
|
+
(known.length === 0
|
|
623
|
+
? "config/vellum.ts declares none."
|
|
624
|
+
: `config/vellum.ts declares ${known.join(", ")}.`),
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const prepared = await prepareSignatureNative(pdf, {
|
|
629
|
+
reason: options.reason,
|
|
630
|
+
location: options.location,
|
|
631
|
+
contact: options.contact,
|
|
632
|
+
name: options.name,
|
|
633
|
+
signedAt: (options.signedAt ?? new Date()).toISOString(),
|
|
634
|
+
capacity: options.capacity,
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
const value = await signer.sign(prepared.digest);
|
|
638
|
+
return embedSignatureNative(prepared.document, value);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Report on every signature the document carries.
|
|
643
|
+
*
|
|
644
|
+
* ```ts
|
|
645
|
+
* for (const signature of await vellum.verifySignatures(mandate)) {
|
|
646
|
+
* if (!signature.coversWholeDocument) reject('content was added after signing')
|
|
647
|
+
* if (!signature.digestMatches) reject('the document has changed')
|
|
648
|
+
* }
|
|
649
|
+
* ```
|
|
650
|
+
*
|
|
651
|
+
* `coversWholeDocument` is the one that catches the trap everybody meets
|
|
652
|
+
* first: content appended after a signature is not covered by it, and the
|
|
653
|
+
* arithmetic over the covered part still checks out. A document whose
|
|
654
|
+
* second half arrived later is not a signed document.
|
|
655
|
+
*
|
|
656
|
+
* This establishes **integrity and authorship, not trust**. It does not ask
|
|
657
|
+
* whether the certificate comes from an authority you accept, nor whether
|
|
658
|
+
* it has since been revoked: that needs a trust store and a live revocation
|
|
659
|
+
* check, neither of which belongs in a PDF engine.
|
|
660
|
+
*
|
|
661
|
+
* `trusted` says a path was found from the signer's certificate to one of
|
|
662
|
+
* the anchors you accept, judged **at the moment of signing** rather than
|
|
663
|
+
* now — a certificate that has since expired did not retroactively unsign
|
|
664
|
+
* anything. `moment` says where that instant came from: a timestamp is
|
|
665
|
+
* worth having because it makes it something other than the signer's word.
|
|
666
|
+
*
|
|
667
|
+
* **Revocation is not checked** unless `checkRevocation` asks for it. A
|
|
668
|
+
* certificate withdrawn after it was issued otherwise still looks valid
|
|
669
|
+
* here.
|
|
670
|
+
*
|
|
671
|
+
* When it is asked for, the responder's address comes out of the
|
|
672
|
+
* certificate inside the document — from whoever sent it. Only public hosts
|
|
673
|
+
* over http/https are contacted; a document does not get to point your
|
|
674
|
+
* server at your own network. Name your authority in `allowedResponders`
|
|
675
|
+
* when it answers somewhere that rule excludes.
|
|
676
|
+
*
|
|
677
|
+
* A document with no signatures reports none. That is an answer, not a
|
|
678
|
+
* failure.
|
|
679
|
+
*/
|
|
680
|
+
async verifySignatures(
|
|
681
|
+
pdf: Buffer,
|
|
682
|
+
options: VerifyOptions = {},
|
|
683
|
+
): Promise<CheckedSignature[]> {
|
|
684
|
+
const reports = await verifySignaturesNative(pdf, {
|
|
685
|
+
anchors: [...(options.anchors ?? this.#config.trustedAnchors ?? [])],
|
|
686
|
+
});
|
|
687
|
+
if (options.checkRevocation !== true) return reports;
|
|
688
|
+
|
|
689
|
+
return Promise.all(
|
|
690
|
+
reports.map(async (report) => ({
|
|
691
|
+
...report,
|
|
692
|
+
revocation: await this.#revocationOf(
|
|
693
|
+
report,
|
|
694
|
+
options.revocationTimeoutMs ?? 10_000,
|
|
695
|
+
options.allowedResponders ?? this.#config.allowedResponders,
|
|
696
|
+
),
|
|
697
|
+
})),
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Ask a certificate's issuer whether it still stands.
|
|
703
|
+
*
|
|
704
|
+
* Everything that can go wrong answers `"unknown"`, never `"good"`: a
|
|
705
|
+
* responder that cannot be reached has told us nothing, and pretending
|
|
706
|
+
* otherwise is how a withdrawn certificate gets waved through.
|
|
707
|
+
*/
|
|
708
|
+
async #revocationOf(
|
|
709
|
+
report: SignatureReport,
|
|
710
|
+
timeoutMs: number,
|
|
711
|
+
policy: ResponderPolicy | undefined,
|
|
712
|
+
): Promise<RevocationAnswer> {
|
|
713
|
+
const { signerCertificate, issuerCertificate } = report;
|
|
714
|
+
if (signerCertificate === undefined || issuerCertificate === undefined) {
|
|
715
|
+
return {
|
|
716
|
+
status: "unknown",
|
|
717
|
+
detail:
|
|
718
|
+
"the issuer of this certificate is not known, so nobody can be asked",
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const named = responderUrlNative(signerCertificate);
|
|
723
|
+
if (named === null || named === undefined) {
|
|
724
|
+
return {
|
|
725
|
+
status: "unknown",
|
|
726
|
+
detail: "the certificate names no responder to ask",
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// The address is the document's, not ours. Asking it unconditionally
|
|
731
|
+
// turns this into "make my server issue a request wherever this stranger
|
|
732
|
+
// points" — the cloud metadata endpoint, a service on loopback, or a URL
|
|
733
|
+
// whose only purpose is to report that the document was opened.
|
|
734
|
+
const allowed = await mayAsk(named, policy);
|
|
735
|
+
if ("refused" in allowed) {
|
|
736
|
+
return { status: "unknown", detail: allowed.refused };
|
|
737
|
+
}
|
|
738
|
+
const url = allowed.href;
|
|
739
|
+
|
|
740
|
+
let answer: Response;
|
|
741
|
+
try {
|
|
742
|
+
answer = await fetch(url, {
|
|
743
|
+
method: "POST",
|
|
744
|
+
headers: { "content-type": "application/ocsp-request" },
|
|
745
|
+
body: new Uint8Array(
|
|
746
|
+
revocationQueryNative(signerCertificate, issuerCertificate),
|
|
747
|
+
),
|
|
748
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
749
|
+
});
|
|
750
|
+
} catch (error) {
|
|
751
|
+
return {
|
|
752
|
+
status: "unknown",
|
|
753
|
+
detail: `the responder at ${url} could not be reached: ${
|
|
754
|
+
error instanceof Error ? error.message : String(error)
|
|
755
|
+
}`,
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
if (!answer.ok) {
|
|
759
|
+
return {
|
|
760
|
+
status: "unknown",
|
|
761
|
+
detail: `the responder at ${url} answered ${answer.status}`,
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
return readRevocationNative(
|
|
766
|
+
Buffer.from(await answer.arrayBuffer()),
|
|
767
|
+
signerCertificate,
|
|
768
|
+
issuerCertificate,
|
|
769
|
+
report.momentAt ?? null,
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/** How many pages the document has. */
|
|
774
|
+
async pageCount(pdf: Buffer): Promise<number> {
|
|
775
|
+
return (await this.inspect(pdf)).pageCount;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Resolve a 1-based page number to the engine's 0-based index.
|
|
780
|
+
*
|
|
781
|
+
* Shared by every page-addressing method: converted in exactly one place
|
|
782
|
+
* so the public numbering and the engine's can never drift apart.
|
|
783
|
+
*/
|
|
784
|
+
#pageIndex(options: PageOptions): number {
|
|
785
|
+
const page = options.page ?? 1;
|
|
786
|
+
if (!Number.isInteger(page) || page < 1) {
|
|
787
|
+
throw new VellumError(
|
|
788
|
+
"INVALID_PAGE",
|
|
789
|
+
`Page numbers start at 1, got ${page}.`,
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
return page - 1;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Resolve several 1-based page numbers to the engine's 0-based indexes.
|
|
797
|
+
*
|
|
798
|
+
* Routed through {@link Vellum.#pageIndex} so one method decides what a
|
|
799
|
+
* page number means for the whole service.
|
|
800
|
+
*/
|
|
801
|
+
#pageIndexes(pages: ReadonlyArray<number>): number[] {
|
|
802
|
+
return pages.map((page) => this.#pageIndex({ page }));
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Fold the per-call options onto the configured defaults.
|
|
807
|
+
*
|
|
808
|
+
* `page` is dropped: it addresses the document, not the raster, and the
|
|
809
|
+
* engine takes it as a separate argument.
|
|
810
|
+
*/
|
|
811
|
+
#merge(options: RenderOptions): {
|
|
812
|
+
scale?: number;
|
|
813
|
+
width?: number;
|
|
814
|
+
format?: string;
|
|
815
|
+
quality?: number;
|
|
816
|
+
background?: string;
|
|
817
|
+
maxPixels?: number;
|
|
818
|
+
} {
|
|
819
|
+
const merged = { ...this.#config, ...options };
|
|
820
|
+
return {
|
|
821
|
+
scale: merged.scale,
|
|
822
|
+
width: merged.width,
|
|
823
|
+
format: merged.format,
|
|
824
|
+
quality: merged.quality,
|
|
825
|
+
background: merged.background,
|
|
826
|
+
maxPixels: merged.maxPixels,
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
}
|