@quillmark/quillify 0.0.0-stage → 0.3.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 +201 -0
- package/NOTICE +85 -0
- package/README.md +55 -2
- package/assets/LiberationSans-Bold.ttf +0 -0
- package/assets/LiberationSans-BoldItalic.ttf +0 -0
- package/assets/LiberationSans-Italic.ttf +0 -0
- package/assets/LiberationSans-Regular.ttf +0 -0
- package/assets/pdf.worker.js +64908 -0
- package/assets/qpdf.wasm +0 -0
- package/assets/qpdf.worker.js +148 -0
- package/assets/xfa-layer.d.ts +2 -0
- package/assets/xfa-layer.js +1 -0
- package/dist/api.d.ts +83 -0
- package/dist/api.js +1 -0
- package/dist/assemble.d.ts +21 -0
- package/dist/assemble.js +155 -0
- package/dist/coords.d.ts +7 -0
- package/dist/coords.js +28 -0
- package/dist/display-list.d.ts +84 -0
- package/dist/display-list.js +339 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +3 -0
- package/dist/inventory.d.ts +4 -0
- package/dist/inventory.js +175 -0
- package/dist/naming.d.ts +3 -0
- package/dist/naming.js +116 -0
- package/dist/normalize.d.ts +39 -0
- package/dist/normalize.js +159 -0
- package/dist/pdfjs.d.ts +17 -0
- package/dist/pdfjs.js +95 -0
- package/dist/quillify.d.ts +13 -0
- package/dist/quillify.js +86 -0
- package/dist/refusal.d.ts +47 -0
- package/dist/refusal.js +8 -0
- package/dist/sanitize.d.ts +20 -0
- package/dist/sanitize.js +72 -0
- package/dist/strip.d.ts +77 -0
- package/dist/strip.js +524 -0
- package/dist/types.d.ts +110 -0
- package/dist/types.js +27 -0
- package/dist/xfa-layer.d.ts +40 -0
- package/dist/xfa-layer.js +139 -0
- package/dist/xfa-widgets.d.ts +36 -0
- package/dist/xfa-widgets.js +128 -0
- package/dist/xfa.d.ts +15 -0
- package/dist/xfa.js +345 -0
- package/licenses/LiberationSans-LICENSE +102 -0
- package/licenses/compiler-rt-LICENSE +311 -0
- package/licenses/emscripten-LICENSE +102 -0
- package/licenses/libcxx-LICENSE +311 -0
- package/licenses/libcxxabi-LICENSE +311 -0
- package/licenses/libjpeg-turbo-LICENSE +132 -0
- package/licenses/libjpeg-turbo-README.ijg +258 -0
- package/licenses/musl-COPYRIGHT +193 -0
- package/licenses/pdf.js-LICENSE +177 -0
- package/licenses/qpdf-LICENSE +202 -0
- package/licenses/qpdf-NOTICE +42 -0
- package/licenses/zlib-README +118 -0
- package/package.json +69 -5
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every PDF the library reads goes through qpdf first. Published forms arrive
|
|
3
|
+
* locked (a permissions wrapper with an empty user password), and pdf-lib
|
|
4
|
+
* carries no decryption code at all: it would either throw or, with
|
|
5
|
+
* `ignoreEncryption`, write plaintext into a file that still declares itself
|
|
6
|
+
* encrypted.
|
|
7
|
+
*
|
|
8
|
+
* `--flatten-rotation` folds a page's own quarter turn into its content, since
|
|
9
|
+
* the engine rejects a page that carries `/Rotate`
|
|
10
|
+
* (`rotation.browser.test.ts`). `--object-streams=disable` writes every object
|
|
11
|
+
* in the open, so pdf.js and pdf-lib, which read what qpdf wrote, inflate no
|
|
12
|
+
* object stream. `--recompress-flate` has qpdf decode every stream under
|
|
13
|
+
* Flate, LZW, ASCII85 or ASCIIHex and write it back as one Flate stream of its
|
|
14
|
+
* own, so the readers after it have nothing to disagree on: a stream that
|
|
15
|
+
* ends in a wrong check is no longer one. A page's content streams stay
|
|
16
|
+
* apart, as the engine paints them (`background.browser.test.ts`).
|
|
17
|
+
*
|
|
18
|
+
* Unconditional, so there is one path and no guessing about which files need
|
|
19
|
+
* it. On a file that needs nothing it is a rewrite.
|
|
20
|
+
*
|
|
21
|
+
* qpdf runs in a worker of its own for each file: `assets/qpdf.worker.js`,
|
|
22
|
+
* which is qpdf-wasm's script with `qpdf-handler.js` after it. The worker ends
|
|
23
|
+
* with the call, so a trap, a grown heap and whatever qpdf's `main` leaves
|
|
24
|
+
* behind end with it, and the page's thread is free while qpdf runs.
|
|
25
|
+
*
|
|
26
|
+
* The file is held to `MAX_INFLATED_BYTES` twice: in the worker, where qpdf
|
|
27
|
+
* is ended at the write that passes it, and then in what qpdf wrote, on the
|
|
28
|
+
* page's thread and before any other reader opens it.
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* The same PDF with its encryption removed and a page's own quarter turn folded
|
|
32
|
+
* into the page. The worker answers with qpdf's file only when qpdf ended
|
|
33
|
+
* well, with or without warnings, since an error leaves the file cut short. A
|
|
34
|
+
* file it answers nothing for is refused: every later step reads these bytes
|
|
35
|
+
* as decrypted. So is a file that comes to more than `MAX_INFLATED_BYTES`, in
|
|
36
|
+
* qpdf or in what qpdf wrote. The worker is ended when it answers, or when
|
|
37
|
+
* `signal` fires before it has.
|
|
38
|
+
*/
|
|
39
|
+
export declare function normalizePdf(bytes: Uint8Array, signal?: AbortSignal): Promise<Uint8Array>;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every PDF the library reads goes through qpdf first. Published forms arrive
|
|
3
|
+
* locked (a permissions wrapper with an empty user password), and pdf-lib
|
|
4
|
+
* carries no decryption code at all: it would either throw or, with
|
|
5
|
+
* `ignoreEncryption`, write plaintext into a file that still declares itself
|
|
6
|
+
* encrypted.
|
|
7
|
+
*
|
|
8
|
+
* `--flatten-rotation` folds a page's own quarter turn into its content, since
|
|
9
|
+
* the engine rejects a page that carries `/Rotate`
|
|
10
|
+
* (`rotation.browser.test.ts`). `--object-streams=disable` writes every object
|
|
11
|
+
* in the open, so pdf.js and pdf-lib, which read what qpdf wrote, inflate no
|
|
12
|
+
* object stream. `--recompress-flate` has qpdf decode every stream under
|
|
13
|
+
* Flate, LZW, ASCII85 or ASCIIHex and write it back as one Flate stream of its
|
|
14
|
+
* own, so the readers after it have nothing to disagree on: a stream that
|
|
15
|
+
* ends in a wrong check is no longer one. A page's content streams stay
|
|
16
|
+
* apart, as the engine paints them (`background.browser.test.ts`).
|
|
17
|
+
*
|
|
18
|
+
* Unconditional, so there is one path and no guessing about which files need
|
|
19
|
+
* it. On a file that needs nothing it is a rewrite.
|
|
20
|
+
*
|
|
21
|
+
* qpdf runs in a worker of its own for each file: `assets/qpdf.worker.js`,
|
|
22
|
+
* which is qpdf-wasm's script with `qpdf-handler.js` after it. The worker ends
|
|
23
|
+
* with the call, so a trap, a grown heap and whatever qpdf's `main` leaves
|
|
24
|
+
* behind end with it, and the page's thread is free while qpdf runs.
|
|
25
|
+
*
|
|
26
|
+
* The file is held to `MAX_INFLATED_BYTES` twice: in the worker, where qpdf
|
|
27
|
+
* is ended at the write that passes it, and then in what qpdf wrote, on the
|
|
28
|
+
* page's thread and before any other reader opens it.
|
|
29
|
+
*/
|
|
30
|
+
import { ParseSpeeds, PDFArray, PDFDocument, PDFName, PDFRawStream } from 'pdf-lib';
|
|
31
|
+
import { openPdf } from './pdfjs.js';
|
|
32
|
+
import { Refused } from './refusal.js';
|
|
33
|
+
import { MAX_BYTES, MAX_INFLATED_BYTES } from './types.js';
|
|
34
|
+
// Held in a constant so that Vite copies the worker byte for byte. Written
|
|
35
|
+
// inside `new Worker(...)`, the URL makes it bundle the file again.
|
|
36
|
+
const WORKER = new URL('../assets/qpdf.worker.js', import.meta.url);
|
|
37
|
+
// A bundler renames both files, so the worker is told where the binary is.
|
|
38
|
+
const WASM = new URL('../assets/qpdf.wasm', import.meta.url);
|
|
39
|
+
const FLAGS = ['--decrypt', '--flatten-rotation', '--object-streams=disable', '--recompress-flate'];
|
|
40
|
+
/**
|
|
41
|
+
* The two types of stream pdf-lib inflates whole as it parses a file, in every
|
|
42
|
+
* spelling it reads as their names: each letter as it is, or as `#` and its
|
|
43
|
+
* code.
|
|
44
|
+
*/
|
|
45
|
+
const spelled = (name) => [...name]
|
|
46
|
+
.map((letter) => `(?:${letter}|#${letter.charCodeAt(0).toString(16).toUpperCase()})`)
|
|
47
|
+
.join('');
|
|
48
|
+
const TYPED = new RegExp(`/(?:${spelled('ObjStm')}|${spelled('XRef')})`);
|
|
49
|
+
/**
|
|
50
|
+
* A Flate stream goes to the decoder this much at a time. The decoder inflates
|
|
51
|
+
* all of what it is handed before it gives any of it: 1 GiB, for a stream of
|
|
52
|
+
* 1 MB handed over whole.
|
|
53
|
+
*/
|
|
54
|
+
const SLICE = 1024;
|
|
55
|
+
/** What qpdf leaves a stream under, alone or after one Flate layer. Each gives pixels, which are not counted. */
|
|
56
|
+
const CODECS = ['DCTDecode', 'JPXDecode', 'CCITTFaxDecode', 'JBIG2Decode'];
|
|
57
|
+
/**
|
|
58
|
+
* The size of the file qpdf wrote and of what its Flate streams decode to,
|
|
59
|
+
* added up no further than the bound. pdf-lib lists the streams and the
|
|
60
|
+
* browser inflates each, keeping nothing. A stream the browser's decoder
|
|
61
|
+
* rejects, or one under a filter qpdf has not taken off, is a stream two
|
|
62
|
+
* readers can read two ways, and the file is refused as `damaged`.
|
|
63
|
+
*/
|
|
64
|
+
async function inflated(pdf) {
|
|
65
|
+
let total = pdf.byteLength;
|
|
66
|
+
// qpdf has written no stream of either type, so one that is here came with the file.
|
|
67
|
+
if (TYPED.test(new TextDecoder('latin1').decode(pdf)))
|
|
68
|
+
throw new Refused({ code: 'damaged' });
|
|
69
|
+
// pdf-lib waits on a timer between batches of objects unless it is told not to.
|
|
70
|
+
const { context } = await PDFDocument.load(pdf, {
|
|
71
|
+
updateMetadata: false,
|
|
72
|
+
parseSpeed: ParseSpeeds.Fastest
|
|
73
|
+
});
|
|
74
|
+
for (const [, object] of context.enumerateIndirectObjects()) {
|
|
75
|
+
if (!(object instanceof PDFRawStream))
|
|
76
|
+
continue;
|
|
77
|
+
const { dict, contents } = object;
|
|
78
|
+
const filter = dict.lookup(PDFName.of('Filter'));
|
|
79
|
+
const chain = (filter instanceof PDFArray
|
|
80
|
+
? filter.asArray().map((entry) => context.lookup(entry))
|
|
81
|
+
: filter
|
|
82
|
+
? [filter]
|
|
83
|
+
: []).map((entry) => (entry instanceof PDFName ? entry.decodeText() : ''));
|
|
84
|
+
const flate = chain[0] === 'FlateDecode';
|
|
85
|
+
const rest = chain.slice(flate ? 1 : 0);
|
|
86
|
+
if (rest.length > 1 || rest.some((name) => !CODECS.includes(name)))
|
|
87
|
+
throw new Refused({ code: 'damaged' });
|
|
88
|
+
// An empty stream is one to every reader, and to the decoder a stream cut short.
|
|
89
|
+
if (!flate || !contents.length)
|
|
90
|
+
continue;
|
|
91
|
+
let at = 0;
|
|
92
|
+
const reader = new ReadableStream({
|
|
93
|
+
pull(controller) {
|
|
94
|
+
if (at < contents.length)
|
|
95
|
+
controller.enqueue(contents.slice(at, (at += SLICE)));
|
|
96
|
+
else
|
|
97
|
+
controller.close();
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
.pipeThrough(new DecompressionStream('deflate'))
|
|
101
|
+
.getReader();
|
|
102
|
+
try {
|
|
103
|
+
for (let read = await reader.read(); !read.done; read = await reader.read()) {
|
|
104
|
+
total += read.value.byteLength;
|
|
105
|
+
if (total > MAX_INFLATED_BYTES)
|
|
106
|
+
return total;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
throw new Refused({ code: 'damaged' });
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
void reader.cancel().catch(() => { });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return total;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The same PDF with its encryption removed and a page's own quarter turn folded
|
|
120
|
+
* into the page. The worker answers with qpdf's file only when qpdf ended
|
|
121
|
+
* well, with or without warnings, since an error leaves the file cut short. A
|
|
122
|
+
* file it answers nothing for is refused: every later step reads these bytes
|
|
123
|
+
* as decrypted. So is a file that comes to more than `MAX_INFLATED_BYTES`, in
|
|
124
|
+
* qpdf or in what qpdf wrote. The worker is ended when it answers, or when
|
|
125
|
+
* `signal` fires before it has.
|
|
126
|
+
*/
|
|
127
|
+
export async function normalizePdf(bytes, signal) {
|
|
128
|
+
// Before a worker starts, because qpdf is the step that allocates.
|
|
129
|
+
if (bytes.byteLength > MAX_BYTES)
|
|
130
|
+
throw new Refused({ code: 'too_large', bytes: bytes.byteLength, limit: MAX_BYTES });
|
|
131
|
+
const worker = new Worker(WORKER);
|
|
132
|
+
// A copy, moved to the worker. A view posted as it is takes its whole buffer along.
|
|
133
|
+
const copy = bytes.slice();
|
|
134
|
+
const { promise, resolve, reject } = Promise.withResolvers();
|
|
135
|
+
const late = () => reject(signal?.reason);
|
|
136
|
+
signal?.addEventListener('abort', late);
|
|
137
|
+
worker.onmessage = ({ data }) => resolve(data);
|
|
138
|
+
worker.onerror = () => reject(new Error(`qpdf.worker.js did not load from ${WORKER}`));
|
|
139
|
+
worker.postMessage({ bytes: copy, flags: FLAGS, wasm: WASM.href, limit: MAX_INFLATED_BYTES }, [
|
|
140
|
+
copy.buffer
|
|
141
|
+
]);
|
|
142
|
+
const answer = await promise.finally(() => {
|
|
143
|
+
signal?.removeEventListener('abort', late);
|
|
144
|
+
worker.terminate();
|
|
145
|
+
});
|
|
146
|
+
if (!answer.loaded)
|
|
147
|
+
throw new Error(`qpdf.wasm did not load from ${WASM}`);
|
|
148
|
+
const { out, size } = answer;
|
|
149
|
+
// A file that came to more than the bound in qpdf is past it, whatever qpdf then wrote.
|
|
150
|
+
const total = size > MAX_INFLATED_BYTES ? size : out?.byteLength ? await inflated(out) : 0;
|
|
151
|
+
if (total > MAX_INFLATED_BYTES)
|
|
152
|
+
throw new Refused({ code: 'too_large', bytes: total, limit: MAX_INFLATED_BYTES });
|
|
153
|
+
if (out?.byteLength)
|
|
154
|
+
return out;
|
|
155
|
+
// qpdf gives its reason on stderr alone. A file pdf.js cannot open is refused
|
|
156
|
+
// there, under its own code. One it opens is a file only qpdf rejects.
|
|
157
|
+
await (await openPdf(bytes, {}, signal)).close();
|
|
158
|
+
throw new Refused({ code: 'damaged' });
|
|
159
|
+
}
|
package/dist/pdfjs.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { PDFDocumentProxy } from 'pdfjs-dist';
|
|
2
|
+
/**
|
|
3
|
+
* A document on a worker of its own, which `close` ends. A file pdf.js rejects
|
|
4
|
+
* is refused here, so it carries the same code whichever stage opens it.
|
|
5
|
+
*
|
|
6
|
+
* A worker that is busy when `signal` fires answers nothing more, so a
|
|
7
|
+
* question put to the document is awaited through `within`, which rejects
|
|
8
|
+
* with the signal's reason then.
|
|
9
|
+
*/
|
|
10
|
+
export declare function openPdf(bytes: Uint8Array, options?: {
|
|
11
|
+
enableXfa?: boolean;
|
|
12
|
+
ownerDocument?: Document;
|
|
13
|
+
}, signal?: AbortSignal): Promise<{
|
|
14
|
+
pdf: PDFDocumentProxy;
|
|
15
|
+
close(): Promise<void>;
|
|
16
|
+
within<T>(asked: Promise<T>): Promise<T>;
|
|
17
|
+
}>;
|
package/dist/pdfjs.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { Refused } from './refusal.js';
|
|
2
|
+
// Held in a constant so that Vite copies the worker byte for byte. Written
|
|
3
|
+
// inside `new Worker(...)`, the URL makes it bundle the file again.
|
|
4
|
+
const WORKER = new URL('../assets/pdf.worker.js', import.meta.url);
|
|
5
|
+
// The faces pdf.js lays a dynamic form's Arial, Helvetica, Myriad Pro, Calibri
|
|
6
|
+
// and Segoe UI out in, each named so that a bundler copies it.
|
|
7
|
+
const FACES = {
|
|
8
|
+
'LiberationSans-Regular.ttf': new URL('../assets/LiberationSans-Regular.ttf', import.meta.url),
|
|
9
|
+
'LiberationSans-Bold.ttf': new URL('../assets/LiberationSans-Bold.ttf', import.meta.url),
|
|
10
|
+
'LiberationSans-Italic.ttf': new URL('../assets/LiberationSans-Italic.ttf', import.meta.url),
|
|
11
|
+
'LiberationSans-BoldItalic.ttf': new URL('../assets/LiberationSans-BoldItalic.ttf', import.meta.url)
|
|
12
|
+
};
|
|
13
|
+
// The shipped worker rejects an open with what pdf.js's XFA layout threw, under
|
|
14
|
+
// this prefix.
|
|
15
|
+
const LAYOUT = 'XFA layout: ';
|
|
16
|
+
/**
|
|
17
|
+
* A document on a worker of its own, which `close` ends. A file pdf.js rejects
|
|
18
|
+
* is refused here, so it carries the same code whichever stage opens it.
|
|
19
|
+
*
|
|
20
|
+
* A worker that is busy when `signal` fires answers nothing more, so a
|
|
21
|
+
* question put to the document is awaited through `within`, which rejects
|
|
22
|
+
* with the signal's reason then.
|
|
23
|
+
*/
|
|
24
|
+
export async function openPdf(bytes, options = {}, signal) {
|
|
25
|
+
const pdfjs = await import('pdfjs-dist');
|
|
26
|
+
signal?.throwIfAborted();
|
|
27
|
+
const port = new Worker(WORKER, { type: 'module' });
|
|
28
|
+
const worker = pdfjs.PDFWorker.create({ port });
|
|
29
|
+
const task = pdfjs.getDocument({
|
|
30
|
+
data: bytes.slice(),
|
|
31
|
+
...options,
|
|
32
|
+
// What pdf.js asks the page for on its worker's behalf: a face of the
|
|
33
|
+
// package, and nothing else. pdf.js lays a form out without a face it did
|
|
34
|
+
// not get, so one that did not load ends the open: a request or a body
|
|
35
|
+
// that fails, and a status that is not ok.
|
|
36
|
+
BinaryDataFactory: class {
|
|
37
|
+
async fetch({ filename }) {
|
|
38
|
+
if (!Object.hasOwn(FACES, filename))
|
|
39
|
+
throw new Error(`The package ships no ${filename}.`);
|
|
40
|
+
try {
|
|
41
|
+
const response = await fetch(FACES[filename]);
|
|
42
|
+
if (response.ok)
|
|
43
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// Ends the open below, as a status that is not ok does.
|
|
47
|
+
}
|
|
48
|
+
const failed = new Error(`${filename} did not load from ${FACES[filename]}`);
|
|
49
|
+
give(failed);
|
|
50
|
+
throw failed;
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
worker
|
|
54
|
+
});
|
|
55
|
+
// A worker script that fails to load answers nothing, and neither does one
|
|
56
|
+
// that is busy when the signal fires. `task.destroy()` waits on an answer,
|
|
57
|
+
// and `close` ends the worker whatever that wait comes to.
|
|
58
|
+
let answers = true;
|
|
59
|
+
const silent = Promise.withResolvers();
|
|
60
|
+
const give = (reason) => {
|
|
61
|
+
answers = false;
|
|
62
|
+
silent.reject(reason);
|
|
63
|
+
};
|
|
64
|
+
port.onerror = () => give(new Error(`pdf.worker.js did not load from ${WORKER}`));
|
|
65
|
+
const late = () => give(signal?.reason);
|
|
66
|
+
signal?.addEventListener('abort', late);
|
|
67
|
+
const within = (asked) => Promise.race([asked, silent.promise]);
|
|
68
|
+
const close = async () => {
|
|
69
|
+
const destroyed = task.destroy();
|
|
70
|
+
if (answers)
|
|
71
|
+
await within(destroyed).catch(() => { });
|
|
72
|
+
signal?.removeEventListener('abort', late);
|
|
73
|
+
worker.destroy();
|
|
74
|
+
port.terminate();
|
|
75
|
+
};
|
|
76
|
+
try {
|
|
77
|
+
return { pdf: await within(task.promise), close, within };
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
await close();
|
|
81
|
+
if (!answers)
|
|
82
|
+
throw err;
|
|
83
|
+
if (err instanceof pdfjs.PasswordException)
|
|
84
|
+
throw new Refused({ code: 'password' });
|
|
85
|
+
if (err instanceof pdfjs.InvalidPDFException)
|
|
86
|
+
throw new Refused({ code: 'not_pdf' });
|
|
87
|
+
if (err instanceof Error && err.message.startsWith(LAYOUT))
|
|
88
|
+
throw new Refused({ code: 'layout', detail: err.message.slice(LAYOUT.length) });
|
|
89
|
+
// pdf.js raises a plain Error for an encryption it does not know, which is
|
|
90
|
+
// what a certificate-based PubSec file looks like.
|
|
91
|
+
if (err instanceof Error && /encryp/i.test(err.message))
|
|
92
|
+
throw new Refused({ code: 'encryption' });
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Form, Naming, Problem } from './api.js';
|
|
2
|
+
export type Quillified = {
|
|
3
|
+
ok: true;
|
|
4
|
+
tree: Map<string, Uint8Array>;
|
|
5
|
+
} | {
|
|
6
|
+
ok: false;
|
|
7
|
+
problems: readonly Problem[];
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* A quill's files from a sanitized form and a naming of its fields, or the
|
|
11
|
+
* problems that stop one being built: `check`'s, and then the engine's.
|
|
12
|
+
*/
|
|
13
|
+
export declare function quillify(form: Form, naming: Naming): Promise<Quillified>;
|
package/dist/quillify.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { init, isQuillmarkError } from '@quillmark/wasm';
|
|
2
|
+
import { assembleQuill } from './assemble.js';
|
|
3
|
+
import { check } from './naming.js';
|
|
4
|
+
/** The inventory's code for each kind, which is how the assembly tells a signature from a field. */
|
|
5
|
+
const WIDGET = {
|
|
6
|
+
text: 'Tx',
|
|
7
|
+
checkbox: 'Btn',
|
|
8
|
+
radio: 'Btn',
|
|
9
|
+
choice: 'Ch',
|
|
10
|
+
signature: 'Sig'
|
|
11
|
+
};
|
|
12
|
+
function typed(field, text) {
|
|
13
|
+
switch (field.kind) {
|
|
14
|
+
case 'text':
|
|
15
|
+
if (text === 'lines')
|
|
16
|
+
return { type: 'array', multiline: true };
|
|
17
|
+
return { type: 'string', multiline: text ? text === 'paragraph' : field.multiline };
|
|
18
|
+
case 'choice':
|
|
19
|
+
return { type: 'enum', multiline: false, options: [...field.options] };
|
|
20
|
+
case 'checkbox':
|
|
21
|
+
case 'radio':
|
|
22
|
+
return { type: 'boolean', multiline: false };
|
|
23
|
+
case 'signature':
|
|
24
|
+
return { type: 'string', multiline: false };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** A choice shows one of its options even when the naming gives none, so its list is seen in the example. */
|
|
28
|
+
function shown(field, suggested) {
|
|
29
|
+
const value = suggested?.trim() || undefined;
|
|
30
|
+
if (field.kind === 'choice')
|
|
31
|
+
return value !== undefined && field.options.includes(value) ? value : field.options[0];
|
|
32
|
+
if (field.kind !== 'text')
|
|
33
|
+
return undefined;
|
|
34
|
+
return value && field.maxLength ? [...value].slice(0, field.maxLength).join('') : value;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* A quill's files from a sanitized form and a naming of its fields, or the
|
|
38
|
+
* problems that stop one being built: `check`'s, and then the engine's.
|
|
39
|
+
*/
|
|
40
|
+
export async function quillify(form, naming) {
|
|
41
|
+
const problems = check(form.fields, naming);
|
|
42
|
+
if (problems.length > 0)
|
|
43
|
+
return { ok: false, problems };
|
|
44
|
+
const byId = new Map(form.fields.map((field) => [field.id, field]));
|
|
45
|
+
const tree = assembleQuill({
|
|
46
|
+
quill: { name: naming.name, description: naming.description },
|
|
47
|
+
fields: naming.fields.map((entry) => {
|
|
48
|
+
const field = byId.get(entry.id);
|
|
49
|
+
const example = shown(field, entry.example);
|
|
50
|
+
return {
|
|
51
|
+
widget: entry.id,
|
|
52
|
+
name: entry.name,
|
|
53
|
+
description: entry.description,
|
|
54
|
+
...typed(field, entry.text),
|
|
55
|
+
...(example === undefined ? {} : { example }),
|
|
56
|
+
keep: entry.keep && !field.readOnly,
|
|
57
|
+
required: field.required,
|
|
58
|
+
...(field.kind === 'radio' ? { group: field.group } : {})
|
|
59
|
+
};
|
|
60
|
+
})
|
|
61
|
+
}, {
|
|
62
|
+
widgets: form.fields.map(({ id, kind, places, tooltip }) => ({
|
|
63
|
+
id,
|
|
64
|
+
kind: WIDGET[kind],
|
|
65
|
+
places: [...places],
|
|
66
|
+
tooltip
|
|
67
|
+
}))
|
|
68
|
+
}, form.pdf);
|
|
69
|
+
const { Quill } = await init();
|
|
70
|
+
try {
|
|
71
|
+
Quill.fromTree(tree).free();
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
if (!isQuillmarkError(err))
|
|
75
|
+
throw err;
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
problems: err.diagnostics.map(({ code, message }) => ({
|
|
79
|
+
code: 'engine',
|
|
80
|
+
diagnostic: code,
|
|
81
|
+
message
|
|
82
|
+
}))
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return { ok: true, tree };
|
|
86
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** Why a PDF is not taken. A `page` is zero-based, as a `Place`'s is. */
|
|
2
|
+
export type Refusal = {
|
|
3
|
+
code: 'too_large';
|
|
4
|
+
bytes: number;
|
|
5
|
+
limit: number;
|
|
6
|
+
} | {
|
|
7
|
+
code: 'not_pdf';
|
|
8
|
+
} | {
|
|
9
|
+
code: 'password';
|
|
10
|
+
} | {
|
|
11
|
+
code: 'encryption';
|
|
12
|
+
} | {
|
|
13
|
+
code: 'damaged';
|
|
14
|
+
} | {
|
|
15
|
+
code: 'rotated';
|
|
16
|
+
page: number;
|
|
17
|
+
} | {
|
|
18
|
+
code: 'portfolio';
|
|
19
|
+
attachment?: string;
|
|
20
|
+
} | {
|
|
21
|
+
code: 'no_fields';
|
|
22
|
+
xfa: boolean;
|
|
23
|
+
} | {
|
|
24
|
+
code: 'too_many_pages';
|
|
25
|
+
pages: number;
|
|
26
|
+
limit: number;
|
|
27
|
+
} | {
|
|
28
|
+
code: 'too_many_fields';
|
|
29
|
+
fields: number;
|
|
30
|
+
limit: number;
|
|
31
|
+
} | {
|
|
32
|
+
code: 'layout';
|
|
33
|
+
detail: string;
|
|
34
|
+
} | {
|
|
35
|
+
code: 'too_slow';
|
|
36
|
+
limit: number;
|
|
37
|
+
} | {
|
|
38
|
+
code: 'unpaintable';
|
|
39
|
+
page: number;
|
|
40
|
+
construct: 'image' | 'background_image' | 'svg' | 'border' | 'transform' | 'glyph' | 'barcode' | 'password';
|
|
41
|
+
detail?: string;
|
|
42
|
+
};
|
|
43
|
+
/** Thrown where a stage declines a PDF. */
|
|
44
|
+
export declare class Refused extends Error {
|
|
45
|
+
readonly refusal: Refusal;
|
|
46
|
+
constructor(refusal: Refusal);
|
|
47
|
+
}
|
package/dist/refusal.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type Refusal } from './refusal.js';
|
|
2
|
+
import type { Form } from './api.js';
|
|
3
|
+
export type Sanitized = {
|
|
4
|
+
ok: true;
|
|
5
|
+
form: Form;
|
|
6
|
+
} | {
|
|
7
|
+
ok: false;
|
|
8
|
+
refusal: Refusal;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* A PDF form as its fields and a background with no form layer, or the reason
|
|
12
|
+
* the file is not taken. Nothing else in the package reads the file.
|
|
13
|
+
*
|
|
14
|
+
* One signal fires `MAX_SECONDS` after the call starts. A stage that is
|
|
15
|
+
* waiting on a worker then ends the worker and throws the signal's reason,
|
|
16
|
+
* as does an open that begins after it, and that reason is the refusal
|
|
17
|
+
* `too_slow`. Work on this thread is not interrupted and not timed: the
|
|
18
|
+
* strip starts no worker, so a call that has reached it returns its form.
|
|
19
|
+
*/
|
|
20
|
+
export declare function sanitize(pdf: Uint8Array): Promise<Sanitized>;
|
package/dist/sanitize.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { inventoryPdf } from './inventory.js';
|
|
2
|
+
import { normalizePdf } from './normalize.js';
|
|
3
|
+
import { Refused } from './refusal.js';
|
|
4
|
+
import { stripAcroForm } from './strip.js';
|
|
5
|
+
import { xfaToAcroForm } from './xfa.js';
|
|
6
|
+
import { MAX_SECONDS } from './types.js';
|
|
7
|
+
function field(widget) {
|
|
8
|
+
const [place, ...more] = widget.places;
|
|
9
|
+
const { id, required, readOnly, tooltip } = widget;
|
|
10
|
+
const common = {
|
|
11
|
+
id,
|
|
12
|
+
places: [place, ...more],
|
|
13
|
+
...(tooltip === undefined ? {} : { tooltip }),
|
|
14
|
+
required,
|
|
15
|
+
readOnly
|
|
16
|
+
};
|
|
17
|
+
switch (widget.kind) {
|
|
18
|
+
case 'Tx':
|
|
19
|
+
return {
|
|
20
|
+
...common,
|
|
21
|
+
kind: 'text',
|
|
22
|
+
multiline: widget.multiLine,
|
|
23
|
+
...(widget.maxLen > 0 ? { maxLength: widget.maxLen } : {})
|
|
24
|
+
};
|
|
25
|
+
case 'Btn':
|
|
26
|
+
// The inventory keeps no push button, so a button that is no radio is a checkbox.
|
|
27
|
+
if (!widget.radioButton)
|
|
28
|
+
return { ...common, kind: 'checkbox' };
|
|
29
|
+
return {
|
|
30
|
+
...common,
|
|
31
|
+
kind: 'radio',
|
|
32
|
+
group: widget.name,
|
|
33
|
+
...(widget.buttonValue === undefined ? {} : { on: widget.buttonValue })
|
|
34
|
+
};
|
|
35
|
+
case 'Ch':
|
|
36
|
+
return { ...common, kind: 'choice', options: widget.options };
|
|
37
|
+
case 'Sig':
|
|
38
|
+
return { ...common, kind: 'signature' };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* A PDF form as its fields and a background with no form layer, or the reason
|
|
43
|
+
* the file is not taken. Nothing else in the package reads the file.
|
|
44
|
+
*
|
|
45
|
+
* One signal fires `MAX_SECONDS` after the call starts. A stage that is
|
|
46
|
+
* waiting on a worker then ends the worker and throws the signal's reason,
|
|
47
|
+
* as does an open that begins after it, and that reason is the refusal
|
|
48
|
+
* `too_slow`. Work on this thread is not interrupted and not timed: the
|
|
49
|
+
* strip starts no worker, so a call that has reached it returns its form.
|
|
50
|
+
*/
|
|
51
|
+
export async function sanitize(pdf) {
|
|
52
|
+
// Handing it anything but a Uint8Array is a mistake in the call and no fault of a file, so the call throws.
|
|
53
|
+
// The tag, not instanceof, so that a Uint8Array made in another window is taken.
|
|
54
|
+
if (Object.prototype.toString.call(pdf) !== '[object Uint8Array]')
|
|
55
|
+
throw new TypeError('sanitize takes a Uint8Array.');
|
|
56
|
+
const deadline = AbortSignal.timeout(MAX_SECONDS * 1000);
|
|
57
|
+
try {
|
|
58
|
+
const bytes = await xfaToAcroForm(await normalizePdf(pdf, deadline), deadline);
|
|
59
|
+
const { pages, widgets, views } = await inventoryPdf(bytes, deadline);
|
|
60
|
+
return {
|
|
61
|
+
ok: true,
|
|
62
|
+
form: { pdf: await stripAcroForm(bytes, views), pages, fields: widgets.map(field) }
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
if (deadline.aborted && err === deadline.reason)
|
|
67
|
+
return { ok: false, refusal: { code: 'too_slow', limit: MAX_SECONDS } };
|
|
68
|
+
if (err instanceof Refused)
|
|
69
|
+
return { ok: false, refusal: err.refusal };
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
72
|
+
}
|
package/dist/strip.d.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { View } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* What a background's dictionaries keep. A kind of dictionary lists its keys,
|
|
4
|
+
* and after a colon what a key leads to:
|
|
5
|
+
*
|
|
6
|
+
* Key a number, a name, a string, a boolean, null, or an array of those
|
|
7
|
+
* Key:kind a dictionary or a stream of that kind
|
|
8
|
+
* Key:[kind] one of them or an array of them
|
|
9
|
+
* Key:{kind} a dictionary of them by name
|
|
10
|
+
* Key:^kind a reference to one the walk reaches as that kind by another key
|
|
11
|
+
* Key:[^kind] such a reference, or an array of them and of arrays of them
|
|
12
|
+
* Key:* a value no row reads yet, kept whole
|
|
13
|
+
*
|
|
14
|
+
* A reference back can be to one of two kinds, `Key:^kind|kind`. A dictionary
|
|
15
|
+
* by name can hold one of a kind or an array of them, `Key:{[kind]}`, or values
|
|
16
|
+
* of the first sort and nothing else, `Key:{}`.
|
|
17
|
+
*
|
|
18
|
+
* A value of the first sort also stays where a kind is expected: a mask can be
|
|
19
|
+
* an array of numbers and a color space a name. A kind `STREAMS` names is a
|
|
20
|
+
* stream, which keeps the keys of `STREAM` beside its own, and no other kind is
|
|
21
|
+
* a stream. A kind whose keys are numbers is an array read by position, as a
|
|
22
|
+
* color space is: it loses every part past the last its row lists, and a part
|
|
23
|
+
* before that which is not what its position leads to becomes null. A kind
|
|
24
|
+
* `PICK` names is one of several, told apart by a key of the dictionary or a
|
|
25
|
+
* position of the array, which holds a name where `PICK` writes one and a
|
|
26
|
+
* number where it writes a number. Where `PICK` names a last kind, one that is
|
|
27
|
+
* told as none of the others is of that kind.
|
|
28
|
+
*/
|
|
29
|
+
export declare const KINDS: Record<string, string>;
|
|
30
|
+
export declare const STREAMS: string[];
|
|
31
|
+
export declare const STREAM = "Length Filter DecodeParms:[parms]";
|
|
32
|
+
export declare const PICK: Record<string, [key: string, kinds: Record<string, string>, other?: string]>;
|
|
33
|
+
/**
|
|
34
|
+
* The `acroform` background: the source file's pages, and of each dictionary
|
|
35
|
+
* they hold the keys `KINDS` lists for its kind. With the rest of the catalog
|
|
36
|
+
* go the form (`/AcroForm` with its XFA packet, `/NeedsRendering`), the XMP
|
|
37
|
+
* metadata, the scripts, attachments, portfolio and bookmarks, and the
|
|
38
|
+
* usage-rights signature under `/Perms`. The signature goes here because qpdf's
|
|
39
|
+
* `--remove-restrictions`, which takes it too, also strips `/FT /Sig` from
|
|
40
|
+
* every signature field. The trailer's `/Info` and `/ID` go too.
|
|
41
|
+
*
|
|
42
|
+
* Of a page's annotations a Link stays, when it leads to an http or https
|
|
43
|
+
* address or to an explicit destination in the file, and leads there one way.
|
|
44
|
+
* A named destination resolves through the catalog's `/Dests` or `/Names`,
|
|
45
|
+
* which go, so a link to one goes too, and so does a link with an action
|
|
46
|
+
* chained after its own. The action a link keeps is a copy, so nothing the file
|
|
47
|
+
* hangs on the same object is cut with it.
|
|
48
|
+
*
|
|
49
|
+
* Each page's MediaBox is set to the box of its entry of `views`, the frame the
|
|
50
|
+
* inventory measured that page's widgets in. No box of the file is read here. A
|
|
51
|
+
* file whose pages are not the objects `views` names, in that order, is one
|
|
52
|
+
* pdf.js and pdf-lib read differently, and is refused as `damaged`.
|
|
53
|
+
*
|
|
54
|
+
* A walk from the catalog, level by level, then gives each object one kind, by
|
|
55
|
+
* the key that reaches it. A dictionary loses every key its kind does not list
|
|
56
|
+
* and every value that is not what its key leads to, and an object the walk
|
|
57
|
+
* does not reach is deleted: a saved file carries every object the context
|
|
58
|
+
* holds, reachable or not. A file in which a key reaches an object as another
|
|
59
|
+
* kind than it has is refused as `damaged`. A reference back to an object the
|
|
60
|
+
* walk did not reach as its kind is deleted, or made null in an array: a
|
|
61
|
+
* destination's page that the page tree does not hold, and a layer the default
|
|
62
|
+
* configuration names that no other key reaches.
|
|
63
|
+
*
|
|
64
|
+
* The structure tree has rows as the rest has. Of what lies outside it, it
|
|
65
|
+
* names a page of the page tree and a link that stays, each by a reference
|
|
66
|
+
* back, and nothing else: an element's `/Pg` to any other object is deleted,
|
|
67
|
+
* and an object reference whose `/Obj` is not such a link is taken out of its
|
|
68
|
+
* element's `/K`.
|
|
69
|
+
*
|
|
70
|
+
* An object that reads as an annotation or as a page, and is neither a page of
|
|
71
|
+
* `views` nor a link that stays, is deleted outright before the walk. No widget
|
|
72
|
+
* and no page outside the page tree is then in the file, whatever names one, a
|
|
73
|
+
* dictionary by name included, and the fields and appearance streams that hung
|
|
74
|
+
* off it are out of the walk's reach: a background that still carries a form
|
|
75
|
+
* layer is one the renderer would stamp a second one over.
|
|
76
|
+
*/
|
|
77
|
+
export declare function stripAcroForm(bytes: Uint8Array, views: View[]): Promise<Uint8Array>;
|