@quillmark/quillify 0.0.0-stage → 0.3.1
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 +56 -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 +155 -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 +87 -0
- package/dist/display-list.js +372 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +4 -0
- package/dist/inventory.d.ts +4 -0
- package/dist/inventory.js +179 -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 +303 -0
- package/dist/pdfjs.d.ts +17 -0
- package/dist/pdfjs.js +96 -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 +82 -0
- package/dist/strip.js +533 -0
- package/dist/types.d.ts +125 -0
- package/dist/types.js +42 -0
- package/dist/xfa-layer.d.ts +40 -0
- package/dist/xfa-layer.js +143 -0
- package/dist/xfa-widgets.d.ts +36 -0
- package/dist/xfa-widgets.js +131 -0
- package/dist/xfa.d.ts +15 -0
- package/dist/xfa.js +371 -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 +70 -5
package/dist/naming.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { SNAKE_CASE } from './types.js';
|
|
2
|
+
const MAX_LENGTH = 40;
|
|
3
|
+
const snake = (text) => text
|
|
4
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
5
|
+
.replace(/[^A-Za-z0-9]+/g, '_')
|
|
6
|
+
.replace(/^_+|_+$/g, '')
|
|
7
|
+
.toLowerCase();
|
|
8
|
+
/** The last segment of the LiveCycle id `form1[0].page1[0].Header[0].dateReq[0]` is `dateReq`. */
|
|
9
|
+
const lastSegment = (path) => path.slice(path.lastIndexOf('.') + 1).replace(/\[\d+\]$/, '');
|
|
10
|
+
const squeeze = (text) => text.trim().replace(/\s+/g, ' ');
|
|
11
|
+
const lead = (words) => (/^[a-z]/.test(words) ? words : words ? `field_${words}` : 'field');
|
|
12
|
+
/** The whole words of `words` that fit in `limit`, or nothing when no whole word does. */
|
|
13
|
+
function whole(words, limit) {
|
|
14
|
+
if (words.length <= limit)
|
|
15
|
+
return words;
|
|
16
|
+
const head = words.slice(0, Math.max(0, words.lastIndexOf('_', limit)));
|
|
17
|
+
return head === 'field' ? '' : head;
|
|
18
|
+
}
|
|
19
|
+
/** `words` cut to `limit`: the whole words that fit, or its first `limit` characters when no whole word does. */
|
|
20
|
+
const fit = (words, limit) => whole(words, limit) || words.slice(0, limit);
|
|
21
|
+
export function suggest(fields) {
|
|
22
|
+
// Options of one group whose tooltips read alike are told apart by their on-states.
|
|
23
|
+
const tooltips = fields.map((field) => snake(field.tooltip ?? ''));
|
|
24
|
+
const keys = fields.map((field, at) => field.kind === 'radio' && tooltips[at] ? `${tooltips[at]}\0${field.group}` : '');
|
|
25
|
+
const once = new Set();
|
|
26
|
+
const alike = new Set();
|
|
27
|
+
for (const key of keys)
|
|
28
|
+
if (key)
|
|
29
|
+
(once.has(key) ? alike : once).add(key);
|
|
30
|
+
const taken = new Set();
|
|
31
|
+
return fields.map((field, at) => {
|
|
32
|
+
const radio = field.kind === 'radio' ? field : undefined;
|
|
33
|
+
const said = radio && alike.has(keys[at]) ? squeeze(radio.on ?? '') : '';
|
|
34
|
+
const on = snake(said);
|
|
35
|
+
const head = on && whole(lead(tooltips[at]), MAX_LENGTH - 1 - on.length);
|
|
36
|
+
const words = on ||
|
|
37
|
+
tooltips[at] ||
|
|
38
|
+
snake(radio ? `${lastSegment(radio.group)} ${radio.on ?? ''}` : lastSegment(field.id));
|
|
39
|
+
const base = head ? `${head}_${on}` : fit(lead(words), MAX_LENGTH);
|
|
40
|
+
let name = base;
|
|
41
|
+
for (let n = 2; taken.has(name); n++)
|
|
42
|
+
name = `${base}_${n}`;
|
|
43
|
+
taken.add(name);
|
|
44
|
+
const tooltip = squeeze(field.tooltip ?? '');
|
|
45
|
+
const description = said ? `${tooltip} (${said})` : tooltip;
|
|
46
|
+
return { id: field.id, keep: !field.readOnly, name, description };
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
const TEXT = new Set(['line', 'paragraph', 'lines']);
|
|
50
|
+
/**
|
|
51
|
+
* A naming can be a model's JSON, which TypeScript never saw. A slot of the
|
|
52
|
+
* wrong type is a mistake in the call and no fault of a name, so it throws: a
|
|
53
|
+
* pattern reads `null` as the word, and the word is a name.
|
|
54
|
+
*/
|
|
55
|
+
function shaped({ name, description, fields }) {
|
|
56
|
+
const not = (slot, kind) => {
|
|
57
|
+
throw new TypeError(`naming.${slot} is not ${kind}.`);
|
|
58
|
+
};
|
|
59
|
+
if (typeof name !== 'string')
|
|
60
|
+
not('name', 'a string');
|
|
61
|
+
if (typeof description !== 'string')
|
|
62
|
+
not('description', 'a string');
|
|
63
|
+
if (!Array.isArray(fields))
|
|
64
|
+
not('fields', 'an array');
|
|
65
|
+
fields.forEach((entry, at) => {
|
|
66
|
+
const slot = `fields[${at}]`;
|
|
67
|
+
if (typeof entry !== 'object' || entry === null)
|
|
68
|
+
return not(slot, 'an object');
|
|
69
|
+
if (typeof entry.id !== 'string')
|
|
70
|
+
not(`${slot}.id`, 'a string');
|
|
71
|
+
if (typeof entry.keep !== 'boolean')
|
|
72
|
+
not(`${slot}.keep`, 'a boolean');
|
|
73
|
+
if (typeof entry.name !== 'string')
|
|
74
|
+
not(`${slot}.name`, 'a string');
|
|
75
|
+
if (typeof entry.description !== 'string')
|
|
76
|
+
not(`${slot}.description`, 'a string');
|
|
77
|
+
if (entry.example !== undefined && typeof entry.example !== 'string')
|
|
78
|
+
not(`${slot}.example`, 'a string');
|
|
79
|
+
if (entry.text !== undefined && !TEXT.has(entry.text))
|
|
80
|
+
not(`${slot}.text`, "'line', 'paragraph' or 'lines'");
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
export function check(fields, naming) {
|
|
84
|
+
shaped(naming);
|
|
85
|
+
const problems = [];
|
|
86
|
+
if (!SNAKE_CASE.test(naming.name))
|
|
87
|
+
problems.push({ code: 'quill_name' });
|
|
88
|
+
if (/^\p{White_Space}*$/u.test(naming.description))
|
|
89
|
+
problems.push({ code: 'quill_description' });
|
|
90
|
+
const ids = new Set(fields.map((field) => field.id));
|
|
91
|
+
const entries = Map.groupBy(naming.fields, (entry) => entry.id);
|
|
92
|
+
for (const id of entries.keys())
|
|
93
|
+
if (!ids.has(id))
|
|
94
|
+
problems.push({ code: 'unknown_field', id });
|
|
95
|
+
for (const [id, sharing] of entries)
|
|
96
|
+
if (sharing.length > 1)
|
|
97
|
+
problems.push({ code: 'repeated_field', id });
|
|
98
|
+
for (const id of ids)
|
|
99
|
+
if (!entries.has(id))
|
|
100
|
+
problems.push({ code: 'unnamed_field', id });
|
|
101
|
+
// A read-only field is left out whatever its entry says, so it counts as dropped.
|
|
102
|
+
const fillable = new Set(fields.filter((field) => !field.readOnly).map((field) => field.id));
|
|
103
|
+
const kept = naming.fields.filter((entry) => entry.keep && fillable.has(entry.id));
|
|
104
|
+
if (kept.length === 0)
|
|
105
|
+
problems.push({ code: 'nothing_kept' });
|
|
106
|
+
for (const { id, name } of kept)
|
|
107
|
+
if (!SNAKE_CASE.test(name))
|
|
108
|
+
problems.push({ code: 'field_name', id });
|
|
109
|
+
for (const [name, sharing] of Map.groupBy(kept, (entry) => entry.name))
|
|
110
|
+
if (sharing.length > 1)
|
|
111
|
+
problems.push({ code: 'duplicate_name', name });
|
|
112
|
+
for (const { id, kind } of fields)
|
|
113
|
+
if (kind !== 'text' && entries.get(id)?.some((entry) => entry.text))
|
|
114
|
+
problems.push({ code: 'text_on_non_text', id });
|
|
115
|
+
return problems;
|
|
116
|
+
}
|
|
@@ -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,303 @@
|
|
|
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, PDFBool, PDFDict, PDFDocument, PDFInvalidObject, PDFName, PDFNumber, PDFRawStream, PDFStream } from 'pdf-lib';
|
|
31
|
+
import { Refused } from './refusal.js';
|
|
32
|
+
import { MAX_BYTES, MAX_INFLATED_BYTES } from './types.js';
|
|
33
|
+
// Held in a constant so that Vite copies the worker byte for byte. Written
|
|
34
|
+
// inside `new Worker(...)`, the URL makes it bundle the file again.
|
|
35
|
+
const WORKER = new URL('../assets/qpdf.worker.js', import.meta.url);
|
|
36
|
+
// A bundler renames both files, so the worker is told where the binary is.
|
|
37
|
+
const WASM = new URL('../assets/qpdf.wasm', import.meta.url);
|
|
38
|
+
const FLAGS = ['--decrypt', '--flatten-rotation', '--object-streams=disable', '--recompress-flate'];
|
|
39
|
+
/**
|
|
40
|
+
* The two types of stream pdf-lib inflates whole as it parses a file, in every
|
|
41
|
+
* spelling it reads as their names: each letter as it is, or as `#` and its
|
|
42
|
+
* code.
|
|
43
|
+
*/
|
|
44
|
+
const spelled = (name) => [...name]
|
|
45
|
+
.map((letter) => `(?:${letter}|#${letter.charCodeAt(0).toString(16).toUpperCase()})`)
|
|
46
|
+
.join('');
|
|
47
|
+
const TYPED = new RegExp(`/(?:${spelled('ObjStm')}|${spelled('XRef')})`);
|
|
48
|
+
/**
|
|
49
|
+
* A Flate stream goes to the decoder this much at a time. The decoder inflates
|
|
50
|
+
* all of what it is handed before it gives any of it: 1 GiB, for a stream of
|
|
51
|
+
* 1 MB handed over whole.
|
|
52
|
+
*/
|
|
53
|
+
const SLICE = 1024;
|
|
54
|
+
/** What qpdf leaves a stream under, alone or after one Flate layer. Each gives pixels. */
|
|
55
|
+
const CODECS = ['DCTDecode', 'JPXDecode', 'CCITTFaxDecode', 'JBIG2Decode'];
|
|
56
|
+
/** The components of a pixel in a color space that is this name or an array that begins with it. */
|
|
57
|
+
const COMPONENTS = new Map([
|
|
58
|
+
['DeviceGray', 1],
|
|
59
|
+
['CalGray', 1],
|
|
60
|
+
['Indexed', 1],
|
|
61
|
+
['Separation', 1],
|
|
62
|
+
['DeviceRGB', 3],
|
|
63
|
+
['CalRGB', 3],
|
|
64
|
+
['Lab', 3],
|
|
65
|
+
['DeviceCMYK', 4]
|
|
66
|
+
]);
|
|
67
|
+
/**
|
|
68
|
+
* The bytes an image's pixels come to as its dictionary declares them: rows
|
|
69
|
+
* of whole bytes, at its bits to a component and the components of its color
|
|
70
|
+
* space, or at a bit to the pixel for a mask. A halftone's threshold array
|
|
71
|
+
* says a width and a height too, and neither bits nor a color space: a stream
|
|
72
|
+
* with a `/HalftoneType` and neither comes to two bytes to the pixel, the most
|
|
73
|
+
* such an array holds. A stream with no `/Width` and no `/Height` is no image
|
|
74
|
+
* and comes to none. One with either whose dictionary does not say what it
|
|
75
|
+
* comes to, in a component or more of a bit or more and in a number short of
|
|
76
|
+
* infinity, is refused as `damaged`.
|
|
77
|
+
*
|
|
78
|
+
* JPEG 2000 data says its own color space and bits, so an image under
|
|
79
|
+
* `JPXDecode` may leave either out, and counts at four components for the
|
|
80
|
+
* one and eight bits for the other, what a painter makes of a pixel. A
|
|
81
|
+
* CCITT decoder goes by the `/Columns` and `/Rows` of its `/DecodeParms`,
|
|
82
|
+
* so each counts where it is written and more than the image's own.
|
|
83
|
+
* `chain` is the stream's filters.
|
|
84
|
+
*/
|
|
85
|
+
function declared(dict, chain) {
|
|
86
|
+
if (!dict.has(PDFName.of('Width')) && !dict.has(PDFName.of('Height')))
|
|
87
|
+
return 0;
|
|
88
|
+
const number = (from, key) => {
|
|
89
|
+
const value = from.lookup(PDFName.of(key));
|
|
90
|
+
return value instanceof PDFNumber ? value.asNumber() : NaN;
|
|
91
|
+
};
|
|
92
|
+
const codec = chain.at(-1);
|
|
93
|
+
const jpx = codec === 'JPXDecode';
|
|
94
|
+
const parms = codec === 'CCITTFaxDecode' && dict.lookup(PDFName.of('DecodeParms'));
|
|
95
|
+
const fax = parms instanceof PDFArray ? parms.lookup(chain.length - 1) : parms;
|
|
96
|
+
const side = (key, said) => {
|
|
97
|
+
const own = number(dict, key);
|
|
98
|
+
const more = fax instanceof PDFDict ? number(fax, said) : NaN;
|
|
99
|
+
return more > own ? more : own;
|
|
100
|
+
};
|
|
101
|
+
const mask = dict.lookup(PDFName.of('ImageMask')) === PDFBool.True;
|
|
102
|
+
const space = dict.lookup(PDFName.of('ColorSpace'));
|
|
103
|
+
const threshold = dict.has(PDFName.of('HalftoneType')) && !space && !dict.has(PDFName.of('BitsPerComponent'));
|
|
104
|
+
const [family, part] = space instanceof PDFArray ? [space.lookup(0), space.lookup(1)] : [space];
|
|
105
|
+
const name = family instanceof PDFName ? family.decodeText() : '';
|
|
106
|
+
const components = mask || threshold
|
|
107
|
+
? 1
|
|
108
|
+
: name === 'ICCBased' && part instanceof PDFStream
|
|
109
|
+
? number(part.dict, 'N')
|
|
110
|
+
: name === 'DeviceN' && part instanceof PDFArray
|
|
111
|
+
? part.size()
|
|
112
|
+
: jpx && !space
|
|
113
|
+
? 4
|
|
114
|
+
: (COMPONENTS.get(name) ?? NaN);
|
|
115
|
+
const bits = mask
|
|
116
|
+
? 1
|
|
117
|
+
: threshold
|
|
118
|
+
? 16
|
|
119
|
+
: jpx && !dict.has(PDFName.of('BitsPerComponent'))
|
|
120
|
+
? 8
|
|
121
|
+
: number(dict, 'BitsPerComponent');
|
|
122
|
+
const bytes = Math.ceil((side('Width', 'Columns') * components * bits) / 8) * side('Height', 'Rows');
|
|
123
|
+
if (!(components >= 1 && bits >= 1 && bytes >= 0 && bytes < Infinity))
|
|
124
|
+
throw new Refused({ code: 'damaged' });
|
|
125
|
+
return bytes;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The bytes an image's pixels come to as its codec's own header says them,
|
|
129
|
+
* which is what a decoder that goes by the stream makes: the first frame
|
|
130
|
+
* header of a JPEG, or the SIZ segment of a JPEG 2000 codestream, bare or in
|
|
131
|
+
* the `jp2c` box of a JP2 file. None under any other codec, and none where the
|
|
132
|
+
* header is not there or is cut short, which is where the view throws.
|
|
133
|
+
*/
|
|
134
|
+
function headed(codec, data) {
|
|
135
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
136
|
+
const bytes = (width, height, components, bits) => Math.ceil((width * components * bits) / 8) * height;
|
|
137
|
+
try {
|
|
138
|
+
if (codec === 'DCTDecode') {
|
|
139
|
+
// From the first start of an image, where a reader that looks for one begins.
|
|
140
|
+
let at = 0;
|
|
141
|
+
while (view.getUint16(at) !== 0xffd8)
|
|
142
|
+
at += 1;
|
|
143
|
+
// As libjpeg finds a marker: past a byte that is none, a fill byte and a marker of no length.
|
|
144
|
+
for (at += 2;;) {
|
|
145
|
+
const marker = view.getUint8(at) === 0xff ? view.getUint8(at + 1) : 0xff;
|
|
146
|
+
if (marker === 0xff)
|
|
147
|
+
at += 1;
|
|
148
|
+
else if (marker >> 4 === 0xc && ![0xc4, 0xc8, 0xcc].includes(marker))
|
|
149
|
+
// A height of 0 is left to a DNL marker after the scan, which can say 65,535.
|
|
150
|
+
return bytes(view.getUint16(at + 7), view.getUint16(at + 5) || 0xffff, view.getUint8(at + 9), view.getUint8(at + 4));
|
|
151
|
+
else
|
|
152
|
+
at += marker <= 1 || (marker >= 0xd0 && marker <= 0xd9) ? 2 : 2 + view.getUint16(at + 2);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (codec === 'JPXDecode') {
|
|
156
|
+
let at = 0;
|
|
157
|
+
// A JP2 file is boxes: a length and a type, or a 1, a type and a length of eight bytes.
|
|
158
|
+
if (view.getUint32(4) === 0x6a502020) {
|
|
159
|
+
const wide = () => view.getUint32(at) === 1;
|
|
160
|
+
while (view.getUint32(at + 4) !== 0x6a703263)
|
|
161
|
+
at += (wide() ? Number(view.getBigUint64(at + 8)) : view.getUint32(at)) || Infinity;
|
|
162
|
+
at += wide() ? 16 : 8;
|
|
163
|
+
}
|
|
164
|
+
// The image is its grid less where it starts in it, at the most bits any component says.
|
|
165
|
+
if (view.getUint32(at) === 0xff4fff51) {
|
|
166
|
+
const components = view.getUint16(at + 40);
|
|
167
|
+
let bits = 0;
|
|
168
|
+
for (let each = 0; each < components; each++)
|
|
169
|
+
bits = Math.max(bits, (view.getUint8(at + 42 + 3 * each) & 127) + 1);
|
|
170
|
+
return bytes(view.getUint32(at + 8) - view.getUint32(at + 16), view.getUint32(at + 12) - view.getUint32(at + 20), components, bits);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// No header to go by.
|
|
176
|
+
}
|
|
177
|
+
return 0;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* The size of the file qpdf wrote and of what its streams decode to, added up
|
|
181
|
+
* no further than the bound. pdf-lib lists the streams and the browser
|
|
182
|
+
* inflates each Flate one, keeping only what lies under a JPEG or JPEG 2000
|
|
183
|
+
* codec. An image comes to the most of what its dictionary declares, what
|
|
184
|
+
* its codec's own header says and what its Flate layer gives. An object
|
|
185
|
+
* pdf-lib cannot read, as a number of 400 digits, is one it would not count.
|
|
186
|
+
* Such an object, a stream the browser's decoder rejects, or one under a
|
|
187
|
+
* filter qpdf has not taken off, is one two readers can read two ways, and
|
|
188
|
+
* the file is refused as `damaged`.
|
|
189
|
+
*/
|
|
190
|
+
async function inflated(pdf) {
|
|
191
|
+
let total = pdf.byteLength;
|
|
192
|
+
// qpdf has written no stream of either type, so one that is here came with the file.
|
|
193
|
+
if (TYPED.test(new TextDecoder('latin1').decode(pdf)))
|
|
194
|
+
throw new Refused({ code: 'damaged' });
|
|
195
|
+
// pdf-lib waits on a timer between batches of objects unless it is told not to.
|
|
196
|
+
const { context } = await PDFDocument.load(pdf, {
|
|
197
|
+
updateMetadata: false,
|
|
198
|
+
parseSpeed: ParseSpeeds.Fastest
|
|
199
|
+
});
|
|
200
|
+
for (const [, object] of context.enumerateIndirectObjects()) {
|
|
201
|
+
if (object instanceof PDFInvalidObject)
|
|
202
|
+
throw new Refused({ code: 'damaged' });
|
|
203
|
+
if (!(object instanceof PDFRawStream))
|
|
204
|
+
continue;
|
|
205
|
+
const { dict, contents } = object;
|
|
206
|
+
const filter = dict.lookup(PDFName.of('Filter'));
|
|
207
|
+
const chain = (filter instanceof PDFArray
|
|
208
|
+
? filter.asArray().map((entry) => context.lookup(entry))
|
|
209
|
+
: filter
|
|
210
|
+
? [filter]
|
|
211
|
+
: []).map((entry) => (entry instanceof PDFName ? entry.decodeText() : ''));
|
|
212
|
+
const flate = chain[0] === 'FlateDecode';
|
|
213
|
+
const rest = chain.slice(flate ? 1 : 0);
|
|
214
|
+
if (rest.length > 1 || rest.some((name) => !CODECS.includes(name)))
|
|
215
|
+
throw new Refused({ code: 'damaged' });
|
|
216
|
+
const before = total;
|
|
217
|
+
const said = declared(dict, chain);
|
|
218
|
+
// An empty stream is one to every reader, and to the decoder a stream cut short.
|
|
219
|
+
if (!flate || !contents.length) {
|
|
220
|
+
total += Math.max(said, headed(rest[0], contents));
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
let at = 0;
|
|
224
|
+
const keep = rest[0] === 'DCTDecode' || rest[0] === 'JPXDecode';
|
|
225
|
+
const under = [];
|
|
226
|
+
const reader = new ReadableStream({
|
|
227
|
+
pull(controller) {
|
|
228
|
+
if (at < contents.length)
|
|
229
|
+
controller.enqueue(contents.slice(at, (at += SLICE)));
|
|
230
|
+
else
|
|
231
|
+
controller.close();
|
|
232
|
+
}
|
|
233
|
+
})
|
|
234
|
+
.pipeThrough(new DecompressionStream('deflate'))
|
|
235
|
+
.getReader();
|
|
236
|
+
try {
|
|
237
|
+
for (let read = await reader.read(); !read.done; read = await reader.read()) {
|
|
238
|
+
total += read.value.byteLength;
|
|
239
|
+
if (total > MAX_INFLATED_BYTES)
|
|
240
|
+
return total;
|
|
241
|
+
if (keep)
|
|
242
|
+
under.push(read.value);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
throw new Refused({ code: 'damaged' });
|
|
247
|
+
}
|
|
248
|
+
finally {
|
|
249
|
+
void reader.cancel().catch(() => { });
|
|
250
|
+
}
|
|
251
|
+
const own = keep ? headed(rest[0], new Uint8Array(await new Blob(under).arrayBuffer())) : 0;
|
|
252
|
+
total = Math.max(total, before + Math.max(said, own));
|
|
253
|
+
}
|
|
254
|
+
return total;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* The same PDF with its encryption removed and a page's own quarter turn folded
|
|
258
|
+
* into the page. The worker answers with qpdf's file only when qpdf ended
|
|
259
|
+
* well, with or without warnings, since an error leaves the file cut short. A
|
|
260
|
+
* file it answers nothing for is refused: every later step reads these bytes
|
|
261
|
+
* as decrypted. So is a file that comes to more than `MAX_INFLATED_BYTES`, in
|
|
262
|
+
* qpdf or in what qpdf wrote. The worker is ended when it answers, or when
|
|
263
|
+
* `signal` fires before it has.
|
|
264
|
+
*/
|
|
265
|
+
export async function normalizePdf(bytes, signal) {
|
|
266
|
+
// Before a worker starts, because qpdf is the step that allocates.
|
|
267
|
+
if (bytes.byteLength > MAX_BYTES)
|
|
268
|
+
throw new Refused({ code: 'too_large', bytes: bytes.byteLength, limit: MAX_BYTES });
|
|
269
|
+
const worker = new Worker(WORKER);
|
|
270
|
+
// A copy, moved to the worker. A view posted as it is takes its whole buffer along.
|
|
271
|
+
const copy = bytes.slice();
|
|
272
|
+
const { promise, resolve, reject } = Promise.withResolvers();
|
|
273
|
+
const late = () => reject(signal?.reason);
|
|
274
|
+
signal?.addEventListener('abort', late);
|
|
275
|
+
worker.onmessage = ({ data }) => resolve(data);
|
|
276
|
+
worker.onerror = () => reject(new Error(`qpdf.worker.js did not load from ${WORKER}`));
|
|
277
|
+
worker.postMessage({ bytes: copy, flags: FLAGS, wasm: WASM.href, limit: MAX_INFLATED_BYTES }, [
|
|
278
|
+
copy.buffer
|
|
279
|
+
]);
|
|
280
|
+
const answer = await promise.finally(() => {
|
|
281
|
+
signal?.removeEventListener('abort', late);
|
|
282
|
+
worker.terminate();
|
|
283
|
+
});
|
|
284
|
+
if (!answer.loaded)
|
|
285
|
+
throw new Error(`qpdf.wasm did not load from ${WASM}`);
|
|
286
|
+
const { out, size, said } = answer;
|
|
287
|
+
// A file that came to more than the bound in qpdf is past it, whatever qpdf then wrote.
|
|
288
|
+
const total = size > MAX_INFLATED_BYTES ? size : out?.byteLength ? await inflated(out) : 0;
|
|
289
|
+
if (total > MAX_INFLATED_BYTES)
|
|
290
|
+
throw new Refused({ code: 'too_large', bytes: total, limit: MAX_INFLATED_BYTES });
|
|
291
|
+
if (out?.byteLength)
|
|
292
|
+
return out;
|
|
293
|
+
// A qpdf that ends by itself ends at its first error, and its last line says
|
|
294
|
+
// when that is the password or the encryption. No other reader opens the
|
|
295
|
+
// file, which the bound does not hold: a PDF has its header in its first
|
|
296
|
+
// 1,024 bytes.
|
|
297
|
+
if (/invalid password\n$/.test(said))
|
|
298
|
+
throw new Refused({ code: 'password' });
|
|
299
|
+
if (/encryption.*\n$/.test(said))
|
|
300
|
+
throw new Refused({ code: 'encryption' });
|
|
301
|
+
const head = new TextDecoder('latin1').decode(bytes.subarray(0, 1024));
|
|
302
|
+
throw new Refused({ code: head.includes('%PDF-') ? 'damaged' : 'not_pdf' });
|
|
303
|
+
}
|
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,96 @@
|
|
|
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 and
|
|
6
|
+
// Calibri out in, and any face the form's font list lacks, each named so that
|
|
7
|
+
// a bundler copies it.
|
|
8
|
+
const FACES = {
|
|
9
|
+
'LiberationSans-Regular.ttf': new URL('../assets/LiberationSans-Regular.ttf', import.meta.url),
|
|
10
|
+
'LiberationSans-Bold.ttf': new URL('../assets/LiberationSans-Bold.ttf', import.meta.url),
|
|
11
|
+
'LiberationSans-Italic.ttf': new URL('../assets/LiberationSans-Italic.ttf', import.meta.url),
|
|
12
|
+
'LiberationSans-BoldItalic.ttf': new URL('../assets/LiberationSans-BoldItalic.ttf', import.meta.url)
|
|
13
|
+
};
|
|
14
|
+
// The shipped worker rejects an open with what pdf.js's XFA layout threw, under
|
|
15
|
+
// this prefix.
|
|
16
|
+
const LAYOUT = 'XFA layout: ';
|
|
17
|
+
/**
|
|
18
|
+
* A document on a worker of its own, which `close` ends. A file pdf.js rejects
|
|
19
|
+
* is refused here, so it carries the same code whichever stage opens it.
|
|
20
|
+
*
|
|
21
|
+
* A worker that is busy when `signal` fires answers nothing more, so a
|
|
22
|
+
* question put to the document is awaited through `within`, which rejects
|
|
23
|
+
* with the signal's reason then.
|
|
24
|
+
*/
|
|
25
|
+
export async function openPdf(bytes, options = {}, signal) {
|
|
26
|
+
const pdfjs = await import('pdfjs-dist');
|
|
27
|
+
signal?.throwIfAborted();
|
|
28
|
+
const port = new Worker(WORKER, { type: 'module' });
|
|
29
|
+
const worker = pdfjs.PDFWorker.create({ port });
|
|
30
|
+
const task = pdfjs.getDocument({
|
|
31
|
+
data: bytes.slice(),
|
|
32
|
+
...options,
|
|
33
|
+
// What pdf.js asks the page for on its worker's behalf: a face of the
|
|
34
|
+
// package, and nothing else. pdf.js lays a form out without a face it did
|
|
35
|
+
// not get, so one that did not load ends the open: a request or a body
|
|
36
|
+
// that fails, and a status that is not ok.
|
|
37
|
+
BinaryDataFactory: class {
|
|
38
|
+
async fetch({ filename }) {
|
|
39
|
+
if (!Object.hasOwn(FACES, filename))
|
|
40
|
+
throw new Error(`The package ships no ${filename}.`);
|
|
41
|
+
try {
|
|
42
|
+
const response = await fetch(FACES[filename]);
|
|
43
|
+
if (response.ok)
|
|
44
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// Ends the open below, as a status that is not ok does.
|
|
48
|
+
}
|
|
49
|
+
const failed = new Error(`${filename} did not load from ${FACES[filename]}`);
|
|
50
|
+
give(failed);
|
|
51
|
+
throw failed;
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
worker
|
|
55
|
+
});
|
|
56
|
+
// A worker script that fails to load answers nothing, and neither does one
|
|
57
|
+
// that is busy when the signal fires. `task.destroy()` waits on an answer,
|
|
58
|
+
// and `close` ends the worker whatever that wait comes to.
|
|
59
|
+
let answers = true;
|
|
60
|
+
const silent = Promise.withResolvers();
|
|
61
|
+
const give = (reason) => {
|
|
62
|
+
answers = false;
|
|
63
|
+
silent.reject(reason);
|
|
64
|
+
};
|
|
65
|
+
port.onerror = () => give(new Error(`pdf.worker.js did not load from ${WORKER}`));
|
|
66
|
+
const late = () => give(signal?.reason);
|
|
67
|
+
signal?.addEventListener('abort', late);
|
|
68
|
+
const within = (asked) => Promise.race([asked, silent.promise]);
|
|
69
|
+
const close = async () => {
|
|
70
|
+
const destroyed = task.destroy();
|
|
71
|
+
if (answers)
|
|
72
|
+
await within(destroyed).catch(() => { });
|
|
73
|
+
signal?.removeEventListener('abort', late);
|
|
74
|
+
worker.destroy();
|
|
75
|
+
port.terminate();
|
|
76
|
+
};
|
|
77
|
+
try {
|
|
78
|
+
return { pdf: await within(task.promise), close, within };
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
await close();
|
|
82
|
+
if (!answers)
|
|
83
|
+
throw err;
|
|
84
|
+
if (err instanceof pdfjs.PasswordException)
|
|
85
|
+
throw new Refused({ code: 'password' });
|
|
86
|
+
if (err instanceof pdfjs.InvalidPDFException)
|
|
87
|
+
throw new Refused({ code: 'not_pdf' });
|
|
88
|
+
if (err instanceof Error && err.message.startsWith(LAYOUT))
|
|
89
|
+
throw new Refused({ code: 'layout', detail: err.message.slice(LAYOUT.length) });
|
|
90
|
+
// pdf.js raises a plain Error for an encryption it does not know, which is
|
|
91
|
+
// what a certificate-based PubSec file looks like.
|
|
92
|
+
if (err instanceof Error && /encryp/i.test(err.message))
|
|
93
|
+
throw new Refused({ code: 'encryption' });
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -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
|
+
}
|