@jasy/pdf 1.0.0-alpha.7 → 1.0.0-alpha.9
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/README.md +11 -10
- package/dist/api/content.d.ts +5 -4
- package/dist/api/content.js +13 -7
- package/dist/api/descriptor.js +23 -0
- package/dist/api/dimension.d.ts +56 -0
- package/dist/api/dimension.js +27 -0
- package/dist/api/font-url.d.ts +28 -0
- package/dist/api/font-url.js +134 -0
- package/dist/api/forms.d.ts +347 -0
- package/dist/api/forms.js +189 -0
- package/dist/api/gradient.d.ts +53 -0
- package/dist/api/gradient.js +97 -0
- package/dist/api/index.d.ts +3 -0
- package/dist/api/index.js +3 -0
- package/dist/api/insets.d.ts +19 -12
- package/dist/api/insets.js +18 -7
- package/dist/api/layout.d.ts +20 -6
- package/dist/api/layout.js +31 -6
- package/dist/api/page-builder.d.ts +8 -0
- package/dist/api/page-builder.js +12 -0
- package/dist/api/structure.d.ts +11 -0
- package/dist/api/structure.js +17 -4
- package/dist/api/text.d.ts +42 -6
- package/dist/api/text.js +29 -5
- package/dist/crypto/legacy-handler.d.ts +43 -0
- package/dist/crypto/legacy-handler.js +145 -0
- package/dist/crypto/security-handler.d.ts +16 -0
- package/dist/crypto/security-handler.js +26 -0
- package/dist/edit/acroform-reader.d.ts +70 -0
- package/dist/edit/acroform-reader.js +126 -0
- package/dist/edit/appearance.d.ts +47 -0
- package/dist/edit/appearance.js +171 -0
- package/dist/edit/document.d.ts +145 -0
- package/dist/edit/document.js +642 -0
- package/dist/edit/fill.d.ts +65 -0
- package/dist/edit/fill.js +424 -0
- package/dist/edit/flatten.d.ts +54 -0
- package/dist/edit/flatten.js +462 -0
- package/dist/edit/index.d.ts +20 -0
- package/dist/edit/index.js +18 -0
- package/dist/edit/lexer.d.ts +30 -0
- package/dist/edit/lexer.js +285 -0
- package/dist/edit/objects.d.ts +68 -0
- package/dist/edit/objects.js +83 -0
- package/dist/edit/writer.d.ts +60 -0
- package/dist/edit/writer.js +283 -0
- package/dist/elements/container-element.d.ts +21 -1
- package/dist/elements/container-element.js +25 -10
- package/dist/elements/forms/checkbox-element.d.ts +51 -0
- package/dist/elements/forms/checkbox-element.js +48 -0
- package/dist/elements/forms/choice-element.d.ts +53 -0
- package/dist/elements/forms/choice-element.js +59 -0
- package/dist/elements/forms/push-button-element.d.ts +56 -0
- package/dist/elements/forms/push-button-element.js +49 -0
- package/dist/elements/forms/radio-element.d.ts +52 -0
- package/dist/elements/forms/radio-element.js +49 -0
- package/dist/elements/forms/signature-element.d.ts +54 -0
- package/dist/elements/forms/signature-element.js +47 -0
- package/dist/elements/forms/text-field-element.d.ts +61 -0
- package/dist/elements/forms/text-field-element.js +54 -0
- package/dist/elements/image-element.d.ts +18 -4
- package/dist/elements/image-element.js +27 -12
- package/dist/elements/index.d.ts +6 -0
- package/dist/elements/index.js +6 -0
- package/dist/elements/layout/expanded-element.d.ts +3 -1
- package/dist/elements/layout/expanded-element.js +12 -3
- package/dist/elements/layout/padding-element.d.ts +4 -2
- package/dist/elements/layout/padding-element.js +4 -2
- package/dist/elements/layout/page-builder-element.js +2 -0
- package/dist/elements/pdf-element.d.ts +59 -1
- package/dist/elements/pdf-element.js +62 -1
- package/dist/elements/rectangle-element.d.ts +20 -6
- package/dist/elements/rectangle-element.js +25 -12
- package/dist/elements/row-element.d.ts +24 -1
- package/dist/elements/row-element.js +25 -10
- package/dist/elements/text-element.d.ts +60 -1
- package/dist/elements/text-element.js +132 -33
- package/dist/forms/acroform.d.ts +63 -0
- package/dist/forms/acroform.js +427 -0
- package/dist/forms/appearance.d.ts +58 -0
- package/dist/forms/appearance.js +204 -0
- package/dist/forms/field.d.ts +164 -0
- package/dist/forms/field.js +1 -0
- package/dist/forms/pdf.d.ts +19 -0
- package/dist/forms/pdf.js +28 -0
- package/dist/ir/display-list.d.ts +40 -7
- package/dist/ir/display-list.js +8 -1
- package/dist/layout/alignment.d.ts +10 -0
- package/dist/layout/alignment.js +6 -0
- package/dist/layout/box-constraints.d.ts +53 -0
- package/dist/layout/box-constraints.js +72 -0
- package/dist/layout/insets.d.ts +28 -0
- package/dist/layout/insets.js +20 -0
- package/dist/renderer/form-field-renderer.d.ts +20 -0
- package/dist/renderer/form-field-renderer.js +12 -0
- package/dist/renderer/image-renderer.js +2 -1
- package/dist/renderer/page-renderer.js +8 -2
- package/dist/renderer/pdf-backend.d.ts +6 -0
- package/dist/renderer/pdf-backend.js +86 -31
- package/dist/renderer/pdf-document-renderer.js +8 -1
- package/dist/renderer/pdf-renderer.js +19 -1
- package/dist/renderer/rectangle-renderer.js +8 -2
- package/dist/renderer/text-renderer.d.ts +2 -2
- package/dist/renderer/text-renderer.js +150 -42
- package/dist/text/advance.js +5 -2
- package/dist/text/arabic.d.ts +20 -0
- package/dist/text/arabic.js +101 -0
- package/dist/text/bidi.d.ts +33 -0
- package/dist/text/bidi.js +94 -0
- package/dist/text/font-fallback.d.ts +25 -0
- package/dist/text/font-fallback.js +38 -0
- package/dist/text/line-breaker.d.ts +35 -1
- package/dist/text/line-breaker.js +76 -16
- package/dist/text/orphans-widows.d.ts +27 -0
- package/dist/text/orphans-widows.js +53 -0
- package/dist/text/shape.d.ts +29 -0
- package/dist/text/shape.js +67 -0
- package/dist/text/text-style.d.ts +25 -0
- package/dist/text/text-style.js +44 -1
- package/dist/utils/dest-registry.d.ts +5 -2
- package/dist/utils/dest-registry.js +5 -5
- package/dist/utils/flex-layout.d.ts +24 -4
- package/dist/utils/flex-layout.js +169 -7
- package/dist/utils/font-metrics.d.ts +5 -0
- package/dist/utils/gsub.d.ts +36 -0
- package/dist/utils/gsub.js +178 -0
- package/dist/utils/inflate.d.ts +16 -0
- package/dist/utils/inflate.js +52 -0
- package/dist/utils/outline.js +1 -2
- package/dist/utils/pdf-object-manager.d.ts +39 -0
- package/dist/utils/pdf-object-manager.js +166 -15
- package/dist/utils/struct-tree.js +2 -3
- package/dist/utils/ttf-parser.d.ts +6 -0
- package/dist/utils/ttf-parser.js +19 -0
- package/dist/utils/utf8-to-windows1252-encoder.d.ts +6 -0
- package/dist/utils/utf8-to-windows1252-encoder.js +10 -0
- package/dist/utils/woff.d.ts +10 -0
- package/dist/utils/woff.js +116 -0
- package/dist/validators/element-validator.js +14 -3
- package/package.json +10 -2
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { type PdfObject } from "./objects.js";
|
|
2
|
+
/**
|
|
3
|
+
* Filling the form of an EXISTING PDF.
|
|
4
|
+
*
|
|
5
|
+
* The values are plain data - `{ name: "Ada", agree: true }` - not a sequence of calls on field objects.
|
|
6
|
+
* That is the same shape the rest of jasy takes: you say WHAT the document should hold, not how to poke
|
|
7
|
+
* it in.
|
|
8
|
+
*
|
|
9
|
+
* A form is treated as a CONTRACT. A name that is not in it, a value its type cannot hold, a string past
|
|
10
|
+
* `/MaxLen`, a choice outside `/Opt`, a read-only field - each is a named error, never a silent no-op.
|
|
11
|
+
* Getting back a file where half the values quietly did not apply is the worst possible outcome.
|
|
12
|
+
*/
|
|
13
|
+
/** What a field can be set to. `null` clears it; a boolean is for check boxes; an array is a
|
|
14
|
+
* multi-select choice. */
|
|
15
|
+
export type FieldValue = string | boolean | string[] | null;
|
|
16
|
+
export interface FillOptions {
|
|
17
|
+
/** The user password, for a document that is encrypted. Wrong or missing gives a named error. */
|
|
18
|
+
password?: string;
|
|
19
|
+
/** Ceiling on what ONE stream may inflate to, in bytes (default 64 MB) - the zip-bomb guard. */
|
|
20
|
+
maxStreamSize?: number;
|
|
21
|
+
/**
|
|
22
|
+
* Draw the new value into the field's appearance (default `true`), the same way jasy bakes one when it
|
|
23
|
+
* CREATES a field - so the value is visible in any viewer, not only in one that honours
|
|
24
|
+
* `/NeedAppearances`. Set `false` to leave the drawing to the viewer instead.
|
|
25
|
+
*/
|
|
26
|
+
fieldAppearances?: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Ask the viewer to draw the values itself, by setting `/NeedAppearances true` (default `true`).
|
|
29
|
+
*
|
|
30
|
+
* The two options answer the same question and exactly one of them ends up applying: when we DID
|
|
31
|
+
* bake, the flag is written as `false`, because a form that still asks for regeneration gets it and
|
|
32
|
+
* the viewer throws our drawing away. Turning BOTH off is refused - see `fillForm`.
|
|
33
|
+
*/
|
|
34
|
+
needAppearances?: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Flatten the form after filling it (default `false`): the values become ordinary page content and
|
|
37
|
+
* the fields stop being editable. The whole form is flattened, not only the names in `values` - a
|
|
38
|
+
* document where some fields are frozen and others are not is the surprising outcome, and
|
|
39
|
+
* `flattenForm(bytes, { fields })` is there for anyone who wants that on purpose.
|
|
40
|
+
*
|
|
41
|
+
* Filling and flattening in one call produces ONE incremental update rather than two, so the file
|
|
42
|
+
* stays smaller than calling them in sequence - and the result is identical either way.
|
|
43
|
+
*/
|
|
44
|
+
flatten?: boolean;
|
|
45
|
+
}
|
|
46
|
+
export interface FillResult {
|
|
47
|
+
bytes: Uint8Array;
|
|
48
|
+
/** Things worth knowing that are not errors - an XFA hybrid, a rebuilt index. */
|
|
49
|
+
warnings: string[];
|
|
50
|
+
/** The names actually written. */
|
|
51
|
+
filled: string[];
|
|
52
|
+
/** The names turned into page content, when `flatten` was asked for. Empty otherwise. */
|
|
53
|
+
flattened: string[];
|
|
54
|
+
}
|
|
55
|
+
declare class FillError extends Error {
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Fill the fields of an existing PDF and return the updated file.
|
|
59
|
+
*
|
|
60
|
+
* @param bytes the original document
|
|
61
|
+
* @param values field name -> value. Names are the fully qualified ones `readAcroForm` reports.
|
|
62
|
+
*/
|
|
63
|
+
export declare function fillForm(bytes: Uint8Array, values: Record<string, FieldValue>, options?: FillOptions): Promise<FillResult>;
|
|
64
|
+
export { FillError };
|
|
65
|
+
export type { PdfObject };
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import { PdfDocument } from "./document.js";
|
|
2
|
+
import { readAcroForm } from "./acroform-reader.js";
|
|
3
|
+
import { carryStrings, IncrementalWriter, serialize } from "./writer.js";
|
|
4
|
+
import { bakeAppearance, bakeButtonStates, fontMetrics, readLook } from "./appearance.js";
|
|
5
|
+
import { flattenInto } from "./flatten.js";
|
|
6
|
+
import { getArrayBuffer } from "../utils/utf8-to-windows1252-encoder.js";
|
|
7
|
+
import { get, isDict, isRef } from "./objects.js";
|
|
8
|
+
/** Field flags we have to honour when filling (see `forms/acroform.ts` for the writer's side). */
|
|
9
|
+
const FF_READ_ONLY = 1 << 0;
|
|
10
|
+
const FF_MULTI_SELECT = 1 << 21;
|
|
11
|
+
const FF_EDIT = 1 << 18;
|
|
12
|
+
const FF_PUSHBUTTON = 1 << 16;
|
|
13
|
+
const FF_RADIO = 1 << 15;
|
|
14
|
+
/**
|
|
15
|
+
* A text value in PDF form. Plain ASCII becomes a `(literal)`; anything else becomes a UTF-16BE `<hex>`
|
|
16
|
+
* string behind a byte-order mark - the encoding every producer uses for unicode, and the only one that
|
|
17
|
+
* carries an umlaut or an emoji unambiguously. Writing UTF-8 into a literal (the obvious mistake) reads
|
|
18
|
+
* back as mojibake, because a literal is PDFDocEncoded.
|
|
19
|
+
*/
|
|
20
|
+
const plainBytes = (s) => {
|
|
21
|
+
if (/^[\x20-\x7e]*$/.test(s))
|
|
22
|
+
return Uint8Array.from(s, (c) => c.charCodeAt(0));
|
|
23
|
+
const out = new Uint8Array(2 + s.length * 2);
|
|
24
|
+
out[0] = 0xfe;
|
|
25
|
+
out[1] = 0xff;
|
|
26
|
+
for (let i = 0; i < s.length; i++) {
|
|
27
|
+
out[2 + i * 2] = s.charCodeAt(i) >> 8;
|
|
28
|
+
out[3 + i * 2] = s.charCodeAt(i) & 0xff;
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
};
|
|
32
|
+
const hex = (b) => `<${Array.from(b, (x) => x.toString(16).padStart(2, "0").toUpperCase()).join("")}>`;
|
|
33
|
+
/** A value in PDF form. `enciphered` holds ciphertext for the strings of an encrypted document; without
|
|
34
|
+
* it the value is written in the clear, exactly as before. */
|
|
35
|
+
const pdfText = (s, enciphered) => {
|
|
36
|
+
const ready = enciphered?.get(s);
|
|
37
|
+
if (ready !== undefined)
|
|
38
|
+
return ready;
|
|
39
|
+
const ascii = /^[\x20-\x7e]*$/.test(s);
|
|
40
|
+
if (ascii) {
|
|
41
|
+
return `(${s.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)")})`;
|
|
42
|
+
}
|
|
43
|
+
let out = "FEFF";
|
|
44
|
+
for (let i = 0; i < s.length; i++)
|
|
45
|
+
out += s.charCodeAt(i).toString(16).padStart(4, "0").toUpperCase();
|
|
46
|
+
return `<${out}>`;
|
|
47
|
+
};
|
|
48
|
+
const escName = (s) => Array.from(new TextEncoder().encode(s))
|
|
49
|
+
.map((b) => b >= 0x21 && b <= 0x7e && !"()<>[]{}/%#".includes(String.fromCharCode(b))
|
|
50
|
+
? String.fromCharCode(b)
|
|
51
|
+
: "#" + b.toString(16).padStart(2, "0").toUpperCase())
|
|
52
|
+
.join("");
|
|
53
|
+
/** Rewrite a dictionary with some entries replaced or removed, keeping everything else byte-faithful. */
|
|
54
|
+
function withEntries(dict, changes, cipher) {
|
|
55
|
+
const parts = [];
|
|
56
|
+
for (const [k, v] of dict.map) {
|
|
57
|
+
// hasOwnProperty, not `in`: a PDF key may legitimately be called "constructor" or "toString", and
|
|
58
|
+
// `in` would find those on Object.prototype and silently drop the entry.
|
|
59
|
+
if (Object.prototype.hasOwnProperty.call(changes, k))
|
|
60
|
+
continue; // replaced or dropped below
|
|
61
|
+
parts.push(`/${escName(k)} ${serialize(v, cipher)}`);
|
|
62
|
+
}
|
|
63
|
+
for (const [k, v] of Object.entries(changes))
|
|
64
|
+
if (v !== undefined)
|
|
65
|
+
parts.push(`/${escName(k)} ${v}`);
|
|
66
|
+
return `<< ${parts.join(" ")} >>`;
|
|
67
|
+
}
|
|
68
|
+
/** Every string reachable inside an object, so a dictionary can be re-enciphered in one walk. */
|
|
69
|
+
/**
|
|
70
|
+
* Draw the field again for its NEW value and append the XObject, returning its object number. Falls back
|
|
71
|
+
* to `undefined` when the widget does not say enough about itself to draw it - then the caller drops the
|
|
72
|
+
* picture and the viewer takes over, which is always safe.
|
|
73
|
+
*/
|
|
74
|
+
async function bakeFor(doc, writer, field, widgetNum, change, metrics) {
|
|
75
|
+
const look = readLook(doc, doc.getObject(widgetNum), field);
|
|
76
|
+
if (look === undefined || look.width <= 0 || look.height <= 0)
|
|
77
|
+
return undefined;
|
|
78
|
+
const face = bakeAppearance(look, field, change.text, change.texts, metrics);
|
|
79
|
+
if (face === undefined)
|
|
80
|
+
return undefined;
|
|
81
|
+
const bbox = face.bbox.map((n) => Number(n.toFixed(2))).join(" ");
|
|
82
|
+
const encoded = new Uint8Array(getArrayBuffer(face.content));
|
|
83
|
+
const plain = (await doc.encryptForWrite(encoded)) ?? encoded;
|
|
84
|
+
return writer.add({
|
|
85
|
+
dict: `/Type /XObject /Subtype /Form /BBox [${bbox}] /Resources << /Font << /Helv ${writer.helv()} 0 R >> >>`,
|
|
86
|
+
// Windows-1252, NOT UTF-8: the appearance draws with /Helv, a WinAnsiEncoding font, so an "ä" is one
|
|
87
|
+
// byte 0xE4. Encoding it as UTF-8 puts two bytes in and the viewer faithfully draws two glyphs.
|
|
88
|
+
// And in an encrypted document the stream is a stream like any other - it has to be enciphered too.
|
|
89
|
+
data: plain,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/** The `/AP` dictionary for a button we had to draw ourselves, or `undefined` if we cannot. */
|
|
93
|
+
async function bakeStatesFor(doc, writer, field, widgetNum, state) {
|
|
94
|
+
const look = readLook(doc, doc.getObject(widgetNum), field);
|
|
95
|
+
if (look === undefined || look.width <= 0 || look.height <= 0)
|
|
96
|
+
return undefined;
|
|
97
|
+
const radio = (field.flags & FF_RADIO) !== 0;
|
|
98
|
+
const on = state === "Off" ? (field.onValues?.[0] ?? "Yes") : state;
|
|
99
|
+
const faces = bakeButtonStates(look, on, radio);
|
|
100
|
+
const bbox = faces.bbox.map((n) => Number(n.toFixed(2))).join(" ");
|
|
101
|
+
const write = async (content) => {
|
|
102
|
+
const encoded = new Uint8Array(getArrayBuffer(content));
|
|
103
|
+
return writer.add({
|
|
104
|
+
dict: `/Type /XObject /Subtype /Form /BBox [${bbox}]`,
|
|
105
|
+
data: (await doc.encryptForWrite(encoded)) ?? encoded,
|
|
106
|
+
});
|
|
107
|
+
};
|
|
108
|
+
// The object numbers come back too: a flatten in the same pass stamps one of these two states.
|
|
109
|
+
const onNum = await write(faces.on);
|
|
110
|
+
const offNum = await write(faces.off);
|
|
111
|
+
return {
|
|
112
|
+
ap: `<< /N << /${escName(on)} ${onNum} 0 R /Off ${offNum} 0 R >> >>`,
|
|
113
|
+
states: new Map([
|
|
114
|
+
[on, onNum],
|
|
115
|
+
["Off", offNum],
|
|
116
|
+
]),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** The appearance a widget ALREADY carries for one of its states, from `/AP /N /<state>`. */
|
|
120
|
+
function stateAppearance(doc, widgetNum, state) {
|
|
121
|
+
const states = doc.resolve(get(doc.resolve(get(doc.getObject(widgetNum), "AP")), "N"));
|
|
122
|
+
if (states === undefined || !isDict(states))
|
|
123
|
+
return undefined;
|
|
124
|
+
const chosen = states.map.get(state);
|
|
125
|
+
return isRef(chosen) ? chosen : undefined;
|
|
126
|
+
}
|
|
127
|
+
class FillError extends Error {
|
|
128
|
+
}
|
|
129
|
+
/** Check one value against what the field can actually hold, and return it in PDF terms. */
|
|
130
|
+
function plan(field, value, enciphered) {
|
|
131
|
+
const flags = field.flags;
|
|
132
|
+
if (flags & FF_READ_ONLY) {
|
|
133
|
+
throw new FillError(`field "${field.name}" is read-only`);
|
|
134
|
+
}
|
|
135
|
+
if (field.type === "Sig") {
|
|
136
|
+
throw new FillError(`field "${field.name}" is a signature field - jasy creates and reads them, but signing needs a certificate`);
|
|
137
|
+
}
|
|
138
|
+
if (field.type === "Btn" && flags & FF_PUSHBUTTON) {
|
|
139
|
+
throw new FillError(`field "${field.name}" is a push button and holds no value`);
|
|
140
|
+
}
|
|
141
|
+
// ---- buttons: check boxes and radio groups ----
|
|
142
|
+
if (field.type === "Btn") {
|
|
143
|
+
const states = field.onValues ?? [];
|
|
144
|
+
let target;
|
|
145
|
+
if (value === null || value === false)
|
|
146
|
+
target = "Off";
|
|
147
|
+
else if (value === true) {
|
|
148
|
+
if (flags & FF_RADIO) {
|
|
149
|
+
throw new FillError(`field "${field.name}" is a radio group - set it to one of ${states.map((s) => `"${s}"`).join(", ")}, not true`);
|
|
150
|
+
}
|
|
151
|
+
// A check box normally declares its "on" name through its appearance states. PDFKit writes no
|
|
152
|
+
// appearance at all, so there is nothing to read - fall back to the value the field already
|
|
153
|
+
// carries, and otherwise to `Yes`, which is the near-universal convention (and what PDFKit itself
|
|
154
|
+
// puts in /V). Refusing here would leave a perfectly fillable form unfillable.
|
|
155
|
+
target =
|
|
156
|
+
states[0] ?? (field.value !== undefined && field.value !== "Off" ? field.value : "Yes");
|
|
157
|
+
}
|
|
158
|
+
else if (typeof value === "string") {
|
|
159
|
+
// Only reject a state when the file actually declares some; a form without appearances (PDFKit)
|
|
160
|
+
// declares none, and there is then nothing to check the name against.
|
|
161
|
+
if (states.length > 0 && value !== "Off" && !states.includes(value)) {
|
|
162
|
+
throw new FillError(`"${value}" is not a state of "${field.name}"; it accepts ${["Off", ...states].map((s) => `"${s}"`).join(", ")}`);
|
|
163
|
+
}
|
|
164
|
+
target = value;
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
throw new FillError(`field "${field.name}" is a button; give it true, false or a state name`);
|
|
168
|
+
}
|
|
169
|
+
// Every widget shows the target when it owns that state, and Off otherwise - that is what makes a
|
|
170
|
+
// radio group mutually exclusive.
|
|
171
|
+
//
|
|
172
|
+
// A widget that declares NO states at all (PDFKit and react-pdf ship none) owns nothing, so the test
|
|
173
|
+
// above would leave every box Off however it was filled. There the target is simply the state, since
|
|
174
|
+
// it is the one being drawn. Not for a radio group: with no states there is no way to tell its
|
|
175
|
+
// buttons apart, and turning them all on would be worse than leaving them alone.
|
|
176
|
+
const declaresNothing = states.length === 0 && (flags & FF_RADIO) === 0;
|
|
177
|
+
const as = new Map();
|
|
178
|
+
for (const w of field.widgets) {
|
|
179
|
+
if (w.num === undefined)
|
|
180
|
+
continue;
|
|
181
|
+
as.set(w.num, declaresNothing || w.onValues.includes(target) ? target : "Off");
|
|
182
|
+
}
|
|
183
|
+
return { v: `/${escName(target)}`, as };
|
|
184
|
+
}
|
|
185
|
+
// ---- choice fields ----
|
|
186
|
+
if (field.type === "Ch") {
|
|
187
|
+
const options = field.options ?? [];
|
|
188
|
+
const allowed = new Set(options.map((o) => o.value));
|
|
189
|
+
const editable = (flags & FF_EDIT) !== 0;
|
|
190
|
+
const multi = (flags & FF_MULTI_SELECT) !== 0;
|
|
191
|
+
if (value === null)
|
|
192
|
+
return { v: undefined, i: undefined };
|
|
193
|
+
if (typeof value === "boolean") {
|
|
194
|
+
throw new FillError(`field "${field.name}" is a choice field; give it one of ${[...allowed].map((s) => `"${s}"`).join(", ")}, an array, or null`);
|
|
195
|
+
}
|
|
196
|
+
// An empty array is a well-defined request - select nothing - and means the same as null. Without
|
|
197
|
+
// this it fell through and wrote the literal text "undefined" into the field.
|
|
198
|
+
if (Array.isArray(value) && value.length === 0)
|
|
199
|
+
return { v: undefined, i: undefined };
|
|
200
|
+
const list = Array.isArray(value) ? value : [value];
|
|
201
|
+
if (!multi && list.length > 1) {
|
|
202
|
+
throw new FillError(`field "${field.name}" takes a single value, not ${list.length}`);
|
|
203
|
+
}
|
|
204
|
+
for (const v of list) {
|
|
205
|
+
if (!allowed.has(v) && !editable) {
|
|
206
|
+
throw new FillError(`"${v}" is not an option of "${field.name}"; it accepts ${[...allowed].map((s) => `"${s}"`).join(", ")}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const indices = list
|
|
210
|
+
.map((v) => options.findIndex((o) => o.value === v))
|
|
211
|
+
.filter((i) => i >= 0)
|
|
212
|
+
.sort((a, b) => a - b);
|
|
213
|
+
return {
|
|
214
|
+
v: multi
|
|
215
|
+
? `[${list.map((x) => pdfText(x, enciphered)).join(" ")}]`
|
|
216
|
+
: pdfText(list[0], enciphered),
|
|
217
|
+
i: indices.length > 0 ? `[${indices.join(" ")}]` : undefined,
|
|
218
|
+
text: list[0],
|
|
219
|
+
texts: list,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
// ---- text ----
|
|
223
|
+
if (typeof value === "boolean" || Array.isArray(value)) {
|
|
224
|
+
throw new FillError(`field "${field.name}" is a text field; give it a string or null`);
|
|
225
|
+
}
|
|
226
|
+
if (value === null)
|
|
227
|
+
return { v: undefined };
|
|
228
|
+
// /MaxLen is what the field itself promises to accept. A viewer enforces it while typing, so writing
|
|
229
|
+
// past it produces a value the user could never have entered - and a comb field draws exactly MaxLen
|
|
230
|
+
// cells, so the surplus has nowhere to go at all. Counting CODE POINTS, not UTF-16 units, or an emoji
|
|
231
|
+
// would count double.
|
|
232
|
+
const length = [...value].length;
|
|
233
|
+
if (field.maxLen !== undefined && length > field.maxLen) {
|
|
234
|
+
throw new FillError(`"${field.name}" holds at most ${field.maxLen} characters, but the value has ${length}`);
|
|
235
|
+
}
|
|
236
|
+
return { v: pdfText(value, enciphered), text: value };
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Fill the fields of an existing PDF and return the updated file.
|
|
240
|
+
*
|
|
241
|
+
* @param bytes the original document
|
|
242
|
+
* @param values field name -> value. Names are the fully qualified ones `readAcroForm` reports.
|
|
243
|
+
*/
|
|
244
|
+
export async function fillForm(bytes, values, options = {}) {
|
|
245
|
+
// Two option combinations describe a document nobody wants. Refused before the file is opened, since
|
|
246
|
+
// the answer does not depend on it.
|
|
247
|
+
if (options.fieldAppearances === false && options.needAppearances === false) {
|
|
248
|
+
throw new FillError("fieldAppearances and needAppearances are both false, which leaves the values invisible: nothing " +
|
|
249
|
+
"is drawn by jasy and the viewer is not asked to draw anything either. Turn one of them on");
|
|
250
|
+
}
|
|
251
|
+
if (options.flatten === true && options.fieldAppearances === false) {
|
|
252
|
+
throw new FillError("flatten needs fieldAppearances: flattening freezes the picture a field shows, and with " +
|
|
253
|
+
"fieldAppearances false there is no picture of the new value to freeze");
|
|
254
|
+
}
|
|
255
|
+
// Async because an encrypted document has to be deciphered before its field names mean anything, and
|
|
256
|
+
// because the values written back have to be enciphered again with the same file key.
|
|
257
|
+
const doc = await PdfDocument.open(bytes, {
|
|
258
|
+
password: options.password,
|
|
259
|
+
maxStreamSize: options.maxStreamSize,
|
|
260
|
+
});
|
|
261
|
+
if (!doc.canReEncrypt) {
|
|
262
|
+
throw new FillError("this PDF is encrypted with RC4 or AES-128; jasy can open it but writes only AES-256, so filling it " +
|
|
263
|
+
"would have to downgrade its protection - refused");
|
|
264
|
+
}
|
|
265
|
+
const form = readAcroForm(doc);
|
|
266
|
+
if (!form)
|
|
267
|
+
throw new FillError("this PDF has no AcroForm to fill");
|
|
268
|
+
const warnings = [];
|
|
269
|
+
if (form.hasXfa) {
|
|
270
|
+
warnings.push("This document is an AcroForm/XFA hybrid. The AcroForm side has been filled, but a viewer that " +
|
|
271
|
+
"prefers the XFA packet (Acrobat, in some configurations) may show the old values instead.");
|
|
272
|
+
}
|
|
273
|
+
if (form.recovered) {
|
|
274
|
+
warnings.push("The cross-reference table was unusable and had to be rebuilt by scanning; the file was already damaged.");
|
|
275
|
+
}
|
|
276
|
+
const byName = new Map(form.fields.map((f) => [f.name, f]));
|
|
277
|
+
const unknown = Object.keys(values).filter((n) => !byName.has(n));
|
|
278
|
+
if (unknown.length > 0) {
|
|
279
|
+
throw new FillError(`no such field: ${unknown.map((n) => `"${n}"`).join(", ")}. This form has: ` +
|
|
280
|
+
form.fields.map((f) => `"${f.name}"`).join(", "));
|
|
281
|
+
}
|
|
282
|
+
// Every string we are about to WRITE has to go in enciphered. They are known up front - they are the
|
|
283
|
+
// values passed in - so they are done in one pass here rather than threading async through planning.
|
|
284
|
+
const enciphered = new Map();
|
|
285
|
+
if (doc.isEncrypted) {
|
|
286
|
+
for (const v of Object.values(values)) {
|
|
287
|
+
for (const text of typeof v === "string" ? [v] : Array.isArray(v) ? v : []) {
|
|
288
|
+
if (enciphered.has(text))
|
|
289
|
+
continue;
|
|
290
|
+
const cipher = await doc.encryptForWrite(plainBytes(text));
|
|
291
|
+
if (cipher)
|
|
292
|
+
enciphered.set(text, hex(cipher));
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const writer = new IncrementalWriter(doc);
|
|
297
|
+
const bake = options.fieldAppearances !== false;
|
|
298
|
+
// One metrics instance for the whole fill, not one per field and not one at module scope.
|
|
299
|
+
const metrics = bake ? fontMetrics() : undefined;
|
|
300
|
+
// Only collected when a flatten follows in this same pass; see `FreshAppearances` for why it exists.
|
|
301
|
+
const fresh = options.flatten ? new Map() : undefined;
|
|
302
|
+
// A widget we could not draw has no picture at all, so the viewer must be asked after all.
|
|
303
|
+
let someBakeFailed = false;
|
|
304
|
+
const filled = [];
|
|
305
|
+
// Emission is deferred: the objects we rewrite carry strings we are NOT changing, and in an encrypted
|
|
306
|
+
// document those have to be enciphered again before they go back in.
|
|
307
|
+
const pending = [];
|
|
308
|
+
for (const [name, value] of Object.entries(values)) {
|
|
309
|
+
const field = byName.get(name);
|
|
310
|
+
const change = plan(field, value, enciphered);
|
|
311
|
+
if (field.objNum === undefined) {
|
|
312
|
+
throw new FillError(`field "${name}" is not stored as its own object and cannot be updated`);
|
|
313
|
+
}
|
|
314
|
+
const node = doc.getObject(field.objNum);
|
|
315
|
+
if (node === undefined || !isDict(node)) {
|
|
316
|
+
throw new FillError(`field "${name}" could not be re-read for updating`);
|
|
317
|
+
}
|
|
318
|
+
// Collect every change per OBJECT first, because a field and its widget are often the same object
|
|
319
|
+
// and must be rewritten once, not twice - the second write would drop the first one's entries.
|
|
320
|
+
const edits = new Map();
|
|
321
|
+
const editsFor = (n) => {
|
|
322
|
+
let e = edits.get(n);
|
|
323
|
+
if (!e)
|
|
324
|
+
edits.set(n, (e = {}));
|
|
325
|
+
return e;
|
|
326
|
+
};
|
|
327
|
+
Object.assign(editsFor(field.objNum), { V: change.v, I: change.i });
|
|
328
|
+
// A text or choice appearance IS a picture of the value, so the one drawn for the old value is now
|
|
329
|
+
// wrong. Leaving it behind is what made a filled field look empty: the viewer faithfully shows the
|
|
330
|
+
// stale drawing.
|
|
331
|
+
//
|
|
332
|
+
// So it is DRAWN AGAIN, from the widget's own style - which is what jasy does when it creates a
|
|
333
|
+
// field, and what makes the value visible without the viewer having to redraw anything. Opt out with
|
|
334
|
+
// `fieldAppearances: false` and the old behaviour returns: drop the picture, set /NeedAppearances,
|
|
335
|
+
// let the viewer draw.
|
|
336
|
+
// A BUTTON is different either way: its /AP holds the on/off STATES, both still valid, so it is kept
|
|
337
|
+
// and only /AS moves.
|
|
338
|
+
if (field.type === "Tx" || field.type === "Ch") {
|
|
339
|
+
for (const w of field.widgets) {
|
|
340
|
+
if (w.num === undefined)
|
|
341
|
+
continue;
|
|
342
|
+
const baked = bake && metrics ? await bakeFor(doc, writer, field, w.num, change, metrics) : undefined;
|
|
343
|
+
if (bake && baked === undefined)
|
|
344
|
+
someBakeFailed = true;
|
|
345
|
+
editsFor(w.num).AP = baked !== undefined ? `<< /N ${baked} 0 R >>` : undefined;
|
|
346
|
+
if (baked !== undefined)
|
|
347
|
+
fresh?.set(w.num, { kind: "ref", num: baked, gen: 0 });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
for (const [widgetNum, state] of change.as ?? []) {
|
|
351
|
+
editsFor(widgetNum).AS = `/${escName(state)}`;
|
|
352
|
+
// A button whose widget ships NO state pictures (PDFKit, react-pdf write none) stays an empty box
|
|
353
|
+
// in anything that does not redraw - and cannot be flattened at all. Draw the two states once.
|
|
354
|
+
if (bake && !field.widgets.find((w) => w.num === widgetNum)?.hasAppearance) {
|
|
355
|
+
const drawn = await bakeStatesFor(doc, writer, field, widgetNum, state);
|
|
356
|
+
if (drawn !== undefined) {
|
|
357
|
+
editsFor(widgetNum).AP = drawn.ap;
|
|
358
|
+
const num = drawn.states.get(state);
|
|
359
|
+
if (num !== undefined)
|
|
360
|
+
fresh?.set(widgetNum, { kind: "ref", num, gen: 0 });
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
// The widget keeps its own pictures and only /AS moves, so the state to stamp is the NEW one -
|
|
365
|
+
// reading /AS back out of the document would give the state before this fill.
|
|
366
|
+
const ref = stateAppearance(doc, widgetNum, state);
|
|
367
|
+
if (ref !== undefined)
|
|
368
|
+
fresh?.set(widgetNum, ref);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
for (const [objNum, changes] of edits) {
|
|
372
|
+
const target = objNum === field.objNum ? node : doc.getObject(objNum);
|
|
373
|
+
if (target === undefined || !isDict(target))
|
|
374
|
+
continue;
|
|
375
|
+
pending.push({ objNum, target, changes });
|
|
376
|
+
}
|
|
377
|
+
filled.push(name);
|
|
378
|
+
}
|
|
379
|
+
// The /AcroForm dict - and the catalog, when the form sits inline in it - are rewritten as well, so
|
|
380
|
+
// their strings are carried over too and belong in the same pass. NOT reachable today and therefore
|
|
381
|
+
// NOT covered by a test: the only encrypted files we can write into are our own R6 ones, and our
|
|
382
|
+
// /AcroForm dict holds no strings. It matters the moment either changes (a form-level /DA, or writing
|
|
383
|
+
// another scheme), which is why it is wired now rather than left as a trap.
|
|
384
|
+
const acroRef = get(doc.catalog, "AcroForm");
|
|
385
|
+
const acro = doc.resolve(acroRef);
|
|
386
|
+
const rootRef = doc.trailer.map.get("Root");
|
|
387
|
+
const catalog = doc.catalog;
|
|
388
|
+
const alsoRewritten = bake || options.needAppearances !== false ? [acro, catalog] : [];
|
|
389
|
+
// One pass over every string that will be carried over unchanged. Without this a preserved tooltip or
|
|
390
|
+
// a /DA would be written back in the clear, into a file where nothing else is.
|
|
391
|
+
const carried = await carryStrings(doc, [...pending.map((p) => p.target), ...alsoRewritten]);
|
|
392
|
+
for (const { objNum, target, changes } of pending) {
|
|
393
|
+
writer.update(objNum, withEntries(target, changes, carried));
|
|
394
|
+
}
|
|
395
|
+
// The flag and the pictures are two answers to the same question, so exactly one of them applies.
|
|
396
|
+
// When we DID draw, the flag has to go: a form that still asks for regeneration gets it, and the
|
|
397
|
+
// viewer throws our drawing away and redraws from /DA - which is how PDFKit's forms kept asking for a
|
|
398
|
+
// ZapfDingbats they do not ship.
|
|
399
|
+
// With a flatten following there is nothing left to answer it for, and that pass rewrites the same
|
|
400
|
+
// /AcroForm dictionary anyway.
|
|
401
|
+
const needAppearances = bake && !someBakeFailed ? "false" : "true";
|
|
402
|
+
if (!options.flatten && (bake || options.needAppearances !== false)) {
|
|
403
|
+
if (acro !== undefined && isDict(acro)) {
|
|
404
|
+
if (isRef(acroRef)) {
|
|
405
|
+
writer.update(acroRef.num, withEntries(acro, { NeedAppearances: needAppearances }, carried));
|
|
406
|
+
}
|
|
407
|
+
else {
|
|
408
|
+
// The form dict can also sit INLINE in the catalog (our own hand-written fixtures do). Then the
|
|
409
|
+
// catalog is what has to be rewritten - skipping it left the new values undrawn.
|
|
410
|
+
if (isRef(rootRef) && catalog !== undefined && isDict(catalog)) {
|
|
411
|
+
const inlined = withEntries(acro, { NeedAppearances: needAppearances }, carried);
|
|
412
|
+
writer.update(rootRef.num, withEntries(catalog, { AcroForm: inlined }, carried));
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
// Same writer, so the whole operation stays one incremental update. Last, because it freezes the
|
|
418
|
+
// pictures drawn above.
|
|
419
|
+
const flattened = options.flatten
|
|
420
|
+
? await flattenInto(doc, writer, form.fields, carried, fresh)
|
|
421
|
+
: [];
|
|
422
|
+
return { bytes: writer.save(), warnings, filled, flattened };
|
|
423
|
+
}
|
|
424
|
+
export { FillError };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { PdfDocument } from "./document.js";
|
|
2
|
+
import { type ReadField } from "./acroform-reader.js";
|
|
3
|
+
import { IncrementalWriter, type StringCipher } from "./writer.js";
|
|
4
|
+
import { type PdfRef } from "./objects.js";
|
|
5
|
+
/**
|
|
6
|
+
* Flattening a form: the values stop being editable fields and become part of the page.
|
|
7
|
+
*
|
|
8
|
+
* The trick is that there is nothing to DRAW. A field's appearance already is a Form XObject - the
|
|
9
|
+
* little picture the viewer shows - so flattening means stamping that XObject onto the page and taking
|
|
10
|
+
* the widget away. Nothing is re-rendered, so a flattened field looks exactly as it did before, down to
|
|
11
|
+
* the pixel. (pdf-lib does the same; react-pdf and PDFKit cannot flatten at all, they never read a file.)
|
|
12
|
+
*
|
|
13
|
+
* It stays an INCREMENTAL update: the new drawing goes into a NEW content stream appended to the page's
|
|
14
|
+
* `/Contents` array, never into the existing one. So the original bytes are still a literal prefix of the
|
|
15
|
+
* result.
|
|
16
|
+
*
|
|
17
|
+
* **A widget that ships no appearance gets one drawn** from its own style and current value, the same
|
|
18
|
+
* drawing the fill path bakes - PDFKit and react-pdf write none at all and leave every box to the
|
|
19
|
+
* viewer. Only a widget we cannot draw AT ALL, one without a usable `/Rect` or of a kind that has no
|
|
20
|
+
* picture of its own, is refused, and then by name.
|
|
21
|
+
*/
|
|
22
|
+
export interface FlattenOptions {
|
|
23
|
+
/** The user password, for a document that is encrypted. */
|
|
24
|
+
password?: string;
|
|
25
|
+
/** Ceiling on what ONE stream may inflate to, in bytes (default 64 MB) - the zip-bomb guard. */
|
|
26
|
+
maxStreamSize?: number;
|
|
27
|
+
/** Flatten only these fields (fully qualified names). Omit for all of them. */
|
|
28
|
+
fields?: string[];
|
|
29
|
+
}
|
|
30
|
+
export interface FlattenResult {
|
|
31
|
+
bytes: Uint8Array;
|
|
32
|
+
/** The names that were turned into page content. */
|
|
33
|
+
flattened: string[];
|
|
34
|
+
warnings: string[];
|
|
35
|
+
}
|
|
36
|
+
export declare class FlattenError extends Error {
|
|
37
|
+
constructor(message: string);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The appearance streams a fill has just written, keyed by widget object number.
|
|
41
|
+
*
|
|
42
|
+
* Flattening reads the picture a widget shows out of the DOCUMENT, but mid-pass the new one is still in
|
|
43
|
+
* the writer - without this map a filled-and-flattened form freezes the value from before the fill.
|
|
44
|
+
* Both widget kinds go stale, for different reasons: a text field's `/AP` is replaced, a button's `/AP`
|
|
45
|
+
* keeps every state and only `/AS` moves.
|
|
46
|
+
*/
|
|
47
|
+
export type FreshAppearances = ReadonlyMap<number, PdfRef>;
|
|
48
|
+
/**
|
|
49
|
+
* Stamp the fields into their pages and take the widgets away. Shared by `flattenForm` and
|
|
50
|
+
* `fillForm(..., { flatten: true })`, so filling and flattening produce ONE incremental update.
|
|
51
|
+
*/
|
|
52
|
+
export declare function flattenInto(doc: PdfDocument, writer: IncrementalWriter, fields: ReadField[], carriedIn?: StringCipher, fresh?: FreshAppearances): Promise<string[]>;
|
|
53
|
+
/** Flatten an existing form: its values become page content and stop being editable. */
|
|
54
|
+
export declare function flattenForm(bytes: Uint8Array, options?: FlattenOptions): Promise<FlattenResult>;
|