@jasy/pdf 1.0.0-alpha.6 → 1.0.0-alpha.8
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 +7 -7
- package/dist/api/descriptor.js +26 -1
- package/dist/api/forms.d.ts +347 -0
- package/dist/api/forms.js +189 -0
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/api/layout.d.ts +47 -14
- package/dist/api/layout.js +47 -11
- package/dist/api/structure.d.ts +11 -0
- package/dist/api/structure.js +2 -0
- package/dist/api/text.d.ts +18 -2
- package/dist/api/text.js +15 -0
- 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 +135 -0
- package/dist/edit/document.js +649 -0
- package/dist/edit/fill.d.ts +48 -0
- package/dist/edit/fill.js +395 -0
- package/dist/edit/flatten.d.ts +42 -0
- package/dist/edit/flatten.js +437 -0
- package/dist/edit/index.d.ts +18 -0
- package/dist/edit/index.js +16 -0
- package/dist/edit/lexer.d.ts +30 -0
- package/dist/edit/lexer.js +285 -0
- package/dist/edit/objects.d.ts +63 -0
- package/dist/edit/objects.js +56 -0
- package/dist/edit/writer.d.ts +53 -0
- package/dist/edit/writer.js +261 -0
- package/dist/elements/container-element.d.ts +10 -1
- package/dist/elements/container-element.js +26 -8
- 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/index.d.ts +8 -0
- package/dist/elements/index.js +8 -0
- package/dist/elements/layout/keep-together-element.d.ts +42 -0
- package/dist/elements/layout/keep-together-element.js +85 -0
- package/dist/elements/layout/padding-element.d.ts +3 -0
- package/dist/elements/layout/padding-element.js +12 -0
- package/dist/elements/layout/page-break-element.d.ts +17 -0
- package/dist/elements/layout/page-break-element.js +24 -0
- package/dist/elements/layout/positioned-element.d.ts +10 -7
- package/dist/elements/layout/positioned-element.js +21 -9
- package/dist/elements/page-element.d.ts +11 -1
- package/dist/elements/page-element.js +41 -16
- package/dist/elements/pdf-element.d.ts +28 -0
- package/dist/elements/pdf-element.js +32 -0
- package/dist/elements/rectangle-element.d.ts +10 -1
- package/dist/elements/rectangle-element.js +27 -6
- package/dist/elements/row-element.d.ts +9 -1
- package/dist/elements/row-element.js +9 -1
- package/dist/elements/text-element.d.ts +30 -4
- package/dist/elements/text-element.js +40 -17
- 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 +18 -1
- package/dist/layout/fragmentation.d.ts +12 -0
- package/dist/layout/fragmentation.js +45 -8
- package/dist/renderer/form-field-renderer.d.ts +20 -0
- package/dist/renderer/form-field-renderer.js +12 -0
- package/dist/renderer/keep-together-renderer.d.ts +10 -0
- package/dist/renderer/keep-together-renderer.js +12 -0
- package/dist/renderer/page-break-renderer.d.ts +14 -0
- package/dist/renderer/page-break-renderer.js +15 -0
- package/dist/renderer/page-renderer.js +8 -2
- package/dist/renderer/pdf-backend.d.ts +7 -0
- package/dist/renderer/pdf-backend.js +48 -6
- package/dist/renderer/pdf-document-class.js +3 -0
- package/dist/renderer/pdf-document-renderer.js +26 -7
- package/dist/renderer/pdf-renderer.js +25 -1
- package/dist/renderer/text-renderer.d.ts +1 -1
- package/dist/renderer/text-renderer.js +107 -59
- package/dist/text/advance.d.ts +34 -0
- package/dist/text/advance.js +42 -0
- package/dist/text/line-breaker.d.ts +9 -8
- package/dist/text/line-breaker.js +28 -21
- package/dist/text/line-metrics.d.ts +49 -0
- package/dist/text/line-metrics.js +57 -0
- package/dist/text/text-decoration.d.ts +50 -0
- package/dist/text/text-decoration.js +62 -0
- package/dist/text/text-style.d.ts +13 -3
- package/dist/text/text-style.js +10 -3
- package/dist/utils/afm-parser.d.ts +34 -0
- package/dist/utils/afm-parser.js +67 -0
- package/dist/utils/dest-registry.d.ts +5 -2
- package/dist/utils/dest-registry.js +5 -5
- package/dist/utils/font-metrics.d.ts +14 -0
- package/dist/utils/outline.js +1 -2
- package/dist/utils/pdf-object-manager.d.ts +45 -1
- package/dist/utils/pdf-object-manager.js +193 -26
- package/dist/utils/struct-tree.js +2 -3
- package/dist/utils/ttf-parser.d.ts +53 -0
- package/dist/utils/ttf-parser.js +368 -0
- package/dist/validators/element-validator.js +7 -3
- package/package.json +9 -2
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import { PdfDocument } from "./document.js";
|
|
2
|
+
import { readAcroForm } from "./acroform-reader.js";
|
|
3
|
+
import { bakeAppearance, bakeButtonStates, readLook } from "./appearance.js";
|
|
4
|
+
import { getArrayBuffer } from "../utils/utf8-to-windows1252-encoder.js";
|
|
5
|
+
import { latin1FromBytes } from "../utils/bytes.js";
|
|
6
|
+
import { IncrementalWriter, serialize } from "./writer.js";
|
|
7
|
+
import { get, isDict, isRef, isStream, nameOf, } from "./objects.js";
|
|
8
|
+
export class FlattenError extends Error {
|
|
9
|
+
constructor(message) {
|
|
10
|
+
super(`@jasy/pdf: ${message}`);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
// Field flags we have to read here (see `forms/acroform.ts` for the writer's side).
|
|
14
|
+
const FF_PUSHBUTTON = 1 << 16;
|
|
15
|
+
const FF_RADIO = 1 << 15;
|
|
16
|
+
const numbers = (o, n) => {
|
|
17
|
+
if (!Array.isArray(o) || o.length < n)
|
|
18
|
+
return undefined;
|
|
19
|
+
const out = o.slice(0, n).map((x) => (typeof x === "number" ? x : NaN));
|
|
20
|
+
return out.some(Number.isNaN) ? undefined : out;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Where a form XObject has to be placed so its box lands on the annotation's rectangle (PDF 12.5.5).
|
|
24
|
+
*
|
|
25
|
+
* Not just a translation: the appearance declares its own `/BBox` and an optional `/Matrix`, and the
|
|
26
|
+
* result has to be mapped onto `/Rect`. For everything we and pdf-lib produce the BBox starts at the
|
|
27
|
+
* origin and matches the rect, so this collapses to a plain shift - but a foreign file may differ, and
|
|
28
|
+
* then a plain shift puts the drawing in the wrong place at the wrong size.
|
|
29
|
+
*/
|
|
30
|
+
function placement(stamp) {
|
|
31
|
+
const [bx0, by0, bx1, by1] = stamp.bbox;
|
|
32
|
+
const m = stamp.matrix ?? [1, 0, 0, 1, 0, 0];
|
|
33
|
+
// The four corners of the BBox through /Matrix, then their bounding box.
|
|
34
|
+
const xs = [];
|
|
35
|
+
const ys = [];
|
|
36
|
+
for (const [x, y] of [
|
|
37
|
+
[bx0, by0],
|
|
38
|
+
[bx1, by0],
|
|
39
|
+
[bx1, by1],
|
|
40
|
+
[bx0, by1],
|
|
41
|
+
]) {
|
|
42
|
+
xs.push(m[0] * x + m[2] * y + m[4]);
|
|
43
|
+
ys.push(m[1] * x + m[3] * y + m[5]);
|
|
44
|
+
}
|
|
45
|
+
const tx0 = Math.min(...xs);
|
|
46
|
+
const ty0 = Math.min(...ys);
|
|
47
|
+
const tw = Math.max(...xs) - tx0;
|
|
48
|
+
const th = Math.max(...ys) - ty0;
|
|
49
|
+
const [rx0, ry0, rx1, ry1] = stamp.rect;
|
|
50
|
+
const rw = Math.abs(rx1 - rx0);
|
|
51
|
+
const rh = Math.abs(ry1 - ry0);
|
|
52
|
+
const sx = tw === 0 ? 1 : rw / tw;
|
|
53
|
+
const sy = th === 0 ? 1 : rh / th;
|
|
54
|
+
return [sx, 0, 0, sy, Math.min(rx0, rx1) - tx0 * sx, Math.min(ry0, ry1) - ty0 * sy];
|
|
55
|
+
}
|
|
56
|
+
const fmt = (n) => (Number.isInteger(n) ? String(n) : String(Number(n.toFixed(6))));
|
|
57
|
+
/** Map every widget object number to the page that lists it in `/Annots`. */
|
|
58
|
+
function widgetPages(doc) {
|
|
59
|
+
const out = new Map();
|
|
60
|
+
const visit = (node, depth) => {
|
|
61
|
+
if (depth > 64)
|
|
62
|
+
return;
|
|
63
|
+
const dict = doc.resolve(node);
|
|
64
|
+
if (dict === undefined || !isDict(dict))
|
|
65
|
+
return;
|
|
66
|
+
const kids = doc.lookup(dict, "Kids");
|
|
67
|
+
if (Array.isArray(kids)) {
|
|
68
|
+
for (const k of kids)
|
|
69
|
+
visit(k, depth + 1);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const pageNum = isRef(node) ? node.num : undefined;
|
|
73
|
+
if (pageNum === undefined)
|
|
74
|
+
return;
|
|
75
|
+
const annots = doc.lookup(dict, "Annots");
|
|
76
|
+
if (!Array.isArray(annots))
|
|
77
|
+
return;
|
|
78
|
+
for (const a of annots)
|
|
79
|
+
if (isRef(a))
|
|
80
|
+
out.set(a.num, pageNum);
|
|
81
|
+
};
|
|
82
|
+
visit(doc.trailer.map.get("Root") && get(doc.catalog, "Pages"), 0);
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The appearance to stamp for one widget. A button's `/AP /N` is a dictionary of STATES (`/Yes`, `/Off`)
|
|
87
|
+
* and the one to keep is whichever `/AS` currently shows - flattening a check box has to freeze what the
|
|
88
|
+
* reader sees, not some other state.
|
|
89
|
+
*/
|
|
90
|
+
function appearanceOf(doc, widget) {
|
|
91
|
+
const n = get(doc.resolve(get(widget, "AP")), "N");
|
|
92
|
+
if (isRef(n)) {
|
|
93
|
+
const target = doc.resolve(n);
|
|
94
|
+
return isStream(target) ? n : undefined;
|
|
95
|
+
}
|
|
96
|
+
const states = doc.resolve(n);
|
|
97
|
+
if (states === undefined || !isDict(states))
|
|
98
|
+
return undefined;
|
|
99
|
+
const as = nameOf(doc.lookup(widget, "AS"));
|
|
100
|
+
const chosen = as !== undefined ? states.map.get(as) : undefined;
|
|
101
|
+
return isRef(chosen) ? chosen : undefined;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Collect what has to happen. A widget that ships NO appearance gets one drawn now, from its own style
|
|
105
|
+
* and current value - the same drawing the fill path bakes. Without that, flattening a form from a
|
|
106
|
+
* producer that writes no appearances (PDFKit, react-pdf) could only ever refuse.
|
|
107
|
+
*/
|
|
108
|
+
async function planStamps(doc, writer, fields) {
|
|
109
|
+
const pages = widgetPages(doc);
|
|
110
|
+
const stamps = [];
|
|
111
|
+
// Widgets that leave the page without leaving a picture: a push button with no face of its own. It
|
|
112
|
+
// draws nothing and holds no value, and after flattening it could not do anything anyway, so keeping
|
|
113
|
+
// it would leave a dead control on a document that is no longer a form.
|
|
114
|
+
const removeOnly = new Map();
|
|
115
|
+
const missing = [];
|
|
116
|
+
const offPage = [];
|
|
117
|
+
for (const field of fields) {
|
|
118
|
+
for (const w of field.widgets) {
|
|
119
|
+
if (w.num === undefined)
|
|
120
|
+
continue;
|
|
121
|
+
const widget = doc.getObject(w.num);
|
|
122
|
+
let appearance = appearanceOf(doc, widget);
|
|
123
|
+
if (appearance === undefined) {
|
|
124
|
+
const drawnNum = await drawMissing(doc, writer, field, w.num);
|
|
125
|
+
if (drawnNum === undefined) {
|
|
126
|
+
const page = pages.get(w.num);
|
|
127
|
+
if ((field.flags & FF_PUSHBUTTON) !== 0 && page !== undefined) {
|
|
128
|
+
removeOnly.set(w.num, page);
|
|
129
|
+
}
|
|
130
|
+
else if (!missing.includes(field.name)) {
|
|
131
|
+
missing.push(field.name);
|
|
132
|
+
}
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
appearance = { kind: "ref", num: drawnNum, gen: 0 };
|
|
136
|
+
}
|
|
137
|
+
const pageNum = pages.get(w.num);
|
|
138
|
+
if (pageNum === undefined) {
|
|
139
|
+
if (!offPage.includes(field.name))
|
|
140
|
+
offPage.push(field.name);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const rect = numbers(doc.lookup(widget, "Rect"), 4);
|
|
144
|
+
const stream = doc.resolve(appearance);
|
|
145
|
+
const bbox = numbers(doc.lookup(stream, "BBox"), 4) ?? [
|
|
146
|
+
0,
|
|
147
|
+
0,
|
|
148
|
+
rect ? Math.abs(rect[2] - rect[0]) : 0,
|
|
149
|
+
rect ? Math.abs(rect[3] - rect[1]) : 0,
|
|
150
|
+
];
|
|
151
|
+
if (rect === undefined || bbox === undefined) {
|
|
152
|
+
if (!missing.includes(field.name))
|
|
153
|
+
missing.push(field.name);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
stamps.push({
|
|
157
|
+
pageNum,
|
|
158
|
+
appearance,
|
|
159
|
+
rect: rect,
|
|
160
|
+
bbox: bbox,
|
|
161
|
+
matrix: numbers(doc.lookup(stream, "Matrix"), 6),
|
|
162
|
+
widget: { kind: "ref", num: w.num, gen: 0 },
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (missing.length > 0) {
|
|
167
|
+
throw new FlattenError(`these fields could not be drawn, so flattening them would leave an empty box: ` +
|
|
168
|
+
`${missing.map((n) => `"${n}"`).join(", ")}. A widget needs a /Rect and a kind we can draw`);
|
|
169
|
+
}
|
|
170
|
+
if (offPage.length > 0) {
|
|
171
|
+
throw new FlattenError(`these fields have a widget that no page lists in /Annots, so there is nowhere to stamp it: ` +
|
|
172
|
+
offPage.map((n) => `"${n}"`).join(", "));
|
|
173
|
+
}
|
|
174
|
+
return { stamps, removeOnly };
|
|
175
|
+
}
|
|
176
|
+
/** The page's existing content, decoded, or undefined when it cannot be read. */
|
|
177
|
+
function existingContent(doc, contents) {
|
|
178
|
+
const resolved = doc.resolve(contents);
|
|
179
|
+
const parts = Array.isArray(resolved) ? resolved : [contents];
|
|
180
|
+
let out = "";
|
|
181
|
+
for (const p of parts) {
|
|
182
|
+
const stream = doc.resolve(p);
|
|
183
|
+
if (stream === undefined || !isStream(stream))
|
|
184
|
+
return undefined;
|
|
185
|
+
out += latin1FromBytes(doc.streamData(stream)) + "\n";
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
/** Draw a widget that has no appearance, and return the object number of the new XObject. */
|
|
190
|
+
async function drawMissing(doc, writer, field, widgetNum) {
|
|
191
|
+
const widget = doc.getObject(widgetNum);
|
|
192
|
+
const look = readLook(doc, widget, field);
|
|
193
|
+
if (look === undefined || look.width <= 0 || look.height <= 0)
|
|
194
|
+
return undefined;
|
|
195
|
+
let content;
|
|
196
|
+
let bbox;
|
|
197
|
+
if (field.type === "Btn") {
|
|
198
|
+
// A PUSH button has no on/off states and no value - it is a control, not a picture of anything. It
|
|
199
|
+
// carries whatever face its author drew, and if there is none there is nothing to freeze.
|
|
200
|
+
if ((field.flags & FF_PUSHBUTTON) !== 0)
|
|
201
|
+
return undefined;
|
|
202
|
+
// Freeze the state the widget is CURRENTLY showing - flattening captures what the reader sees.
|
|
203
|
+
const as = nameOf(doc.lookup(widget, "AS")) ?? field.value ?? "Off";
|
|
204
|
+
const radio = (field.flags & FF_RADIO) !== 0;
|
|
205
|
+
const faces = bakeButtonStates(look, as === "Off" ? (field.onValues?.[0] ?? "Yes") : as, radio);
|
|
206
|
+
content = as === "Off" ? faces.off : faces.on;
|
|
207
|
+
bbox = faces.bbox;
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
const face = bakeAppearance(look, field, field.value, field.values);
|
|
211
|
+
if (face === undefined)
|
|
212
|
+
return undefined;
|
|
213
|
+
content = face.content;
|
|
214
|
+
bbox = face.bbox;
|
|
215
|
+
}
|
|
216
|
+
const encoded = new Uint8Array(getArrayBuffer(content));
|
|
217
|
+
return writer.add({
|
|
218
|
+
dict: `/Type /XObject /Subtype /Form /BBox [${bbox.map((n) => Number(n.toFixed(2))).join(" ")}] ` +
|
|
219
|
+
`/Resources << /Font << /Helv ${writer.helv()} 0 R >> >>`,
|
|
220
|
+
data: (await doc.encryptForWrite(encoded)) ?? encoded,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Does the page's own content leave the graphics state changed?
|
|
225
|
+
*
|
|
226
|
+
* When `/Contents` holds several streams the spec treats them as ONE - so whatever the existing content
|
|
227
|
+
* leaves in effect applies to anything appended. That is not a bug in the producer: nothing follows a
|
|
228
|
+
* page, so nothing has to be restored. But PDFKit and react-pdf both open with `1 0 0 -1 0 <h> cm`,
|
|
229
|
+
* flipping the page to a top-left origin and never undoing it, and our stamps would land upside down.
|
|
230
|
+
*
|
|
231
|
+
* Answered CONSERVATIVELY: only a stream we can prove to be state-neutral - balanced `q`/`Q` and no
|
|
232
|
+
* state operator outside them - is left alone. Anything else, including anything we cannot scan
|
|
233
|
+
* reliably, gets wrapped.
|
|
234
|
+
*/
|
|
235
|
+
function leavesStateDirty(content) {
|
|
236
|
+
// Strings can contain anything, including something that looks like an operator.
|
|
237
|
+
const code = content.replace(/\((?:\\.|[^\\)])*\)/g, " ").replace(/<[0-9A-Fa-f\s]*>/g, " ");
|
|
238
|
+
if (/\bBI\b/.test(code))
|
|
239
|
+
return true; // an inline image swallows arbitrary bytes - do not guess
|
|
240
|
+
let depth = 0;
|
|
241
|
+
for (const token of code.split(/\s+/)) {
|
|
242
|
+
if (token === "q")
|
|
243
|
+
depth++;
|
|
244
|
+
else if (token === "Q") {
|
|
245
|
+
if (--depth < 0)
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
else if (depth === 0 && ["cm", "gs", "W", "W*", "cs", "CS", "sh"].includes(token)) {
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return depth !== 0;
|
|
253
|
+
}
|
|
254
|
+
/** A resource name for the stamped XObject that does not collide with one the page already uses. */
|
|
255
|
+
function freeName(taken, i) {
|
|
256
|
+
let name = `JasyFlat${i}`;
|
|
257
|
+
let n = i;
|
|
258
|
+
while (taken.has(name))
|
|
259
|
+
name = `JasyFlat${i}_${++n}`;
|
|
260
|
+
taken.add(name);
|
|
261
|
+
return name;
|
|
262
|
+
}
|
|
263
|
+
/** Rewrite a dictionary with entries replaced or dropped, like `fill.ts` does. */
|
|
264
|
+
function withEntries(dict, changes, cipher) {
|
|
265
|
+
const parts = [];
|
|
266
|
+
for (const [k, v] of dict.map) {
|
|
267
|
+
if (Object.prototype.hasOwnProperty.call(changes, k))
|
|
268
|
+
continue;
|
|
269
|
+
parts.push(`/${k} ${serialize(v, cipher)}`);
|
|
270
|
+
}
|
|
271
|
+
for (const [k, v] of Object.entries(changes))
|
|
272
|
+
if (v !== undefined)
|
|
273
|
+
parts.push(`/${k} ${v}`);
|
|
274
|
+
return `<< ${parts.join(" ")} >>`;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Stamp the fields into their pages and take the widgets away. Shared by `flattenForm` and
|
|
278
|
+
* `fillForm(..., { flatten: true })`, so filling and flattening produce ONE incremental update.
|
|
279
|
+
*/
|
|
280
|
+
export async function flattenInto(doc, writer, fields, cipher) {
|
|
281
|
+
if (fields.length === 0)
|
|
282
|
+
return [];
|
|
283
|
+
const { stamps, removeOnly } = await planStamps(doc, writer, fields);
|
|
284
|
+
// Group by page: one appended content stream per page, not one per widget.
|
|
285
|
+
const byPage = new Map();
|
|
286
|
+
for (const s of stamps)
|
|
287
|
+
(byPage.get(s.pageNum) ?? byPage.set(s.pageNum, []).get(s.pageNum)).push(s);
|
|
288
|
+
for (const [pageNum, pageStamps] of byPage) {
|
|
289
|
+
const page = doc.getObject(pageNum);
|
|
290
|
+
if (page === undefined || !isDict(page))
|
|
291
|
+
continue;
|
|
292
|
+
// The XObject resources the page already has, so a new name cannot shadow one.
|
|
293
|
+
const resources = doc.resolve(get(page, "Resources"));
|
|
294
|
+
const xobjects = isDict(resources) ? doc.resolve(get(resources, "XObject")) : undefined;
|
|
295
|
+
const taken = new Set(isDict(xobjects) ? [...xobjects.map.keys()] : []);
|
|
296
|
+
let ops = "";
|
|
297
|
+
const added = [];
|
|
298
|
+
pageStamps.forEach((s, i) => {
|
|
299
|
+
const name = freeName(taken, i + 1);
|
|
300
|
+
added.push(`/${name} ${s.appearance.num} ${s.appearance.gen} R`);
|
|
301
|
+
const m = placement(s).map(fmt).join(" ");
|
|
302
|
+
ops += `q ${m} cm /${name} Do Q\n`;
|
|
303
|
+
});
|
|
304
|
+
// /Contents becomes an ARRAY with our stream appended - the existing one is never touched.
|
|
305
|
+
const contents = get(page, "Contents");
|
|
306
|
+
const existing = Array.isArray(doc.resolve(contents))
|
|
307
|
+
? doc.resolve(contents).map((c) => serialize(c, cipher))
|
|
308
|
+
: contents !== undefined
|
|
309
|
+
? [serialize(contents, cipher)]
|
|
310
|
+
: [];
|
|
311
|
+
// If the page leaves the graphics state changed, bracket its content in q/Q so our stamps start
|
|
312
|
+
// from the default state. Only then - a well-behaved page pays nothing.
|
|
313
|
+
const own = existingContent(doc, contents);
|
|
314
|
+
const wrap = own !== undefined && leavesStateDirty(own);
|
|
315
|
+
const front = [];
|
|
316
|
+
if (wrap) {
|
|
317
|
+
const q = new Uint8Array(getArrayBuffer("q\n"));
|
|
318
|
+
front.push(`${writer.add({ dict: "", data: (await doc.encryptForWrite(q)) ?? q })} 0 R`);
|
|
319
|
+
}
|
|
320
|
+
let newContents;
|
|
321
|
+
if (ops !== "") {
|
|
322
|
+
const body = (wrap ? "Q\n" : "") + ops;
|
|
323
|
+
const data = new Uint8Array(getArrayBuffer(body));
|
|
324
|
+
const streamNum = writer.add({ dict: "", data: (await doc.encryptForWrite(data)) ?? data });
|
|
325
|
+
newContents = `[${[...front, ...existing, `${streamNum} 0 R`].join(" ")}]`;
|
|
326
|
+
}
|
|
327
|
+
// The widgets we stamped leave /Annots; anything else on the page stays.
|
|
328
|
+
const flattenedRefs = new Set(pageStamps.map((s) => s.widget.num));
|
|
329
|
+
for (const [widget, page] of removeOnly)
|
|
330
|
+
if (page === pageNum)
|
|
331
|
+
flattenedRefs.add(widget);
|
|
332
|
+
const annots = doc.resolve(get(page, "Annots"));
|
|
333
|
+
const keptAnnots = Array.isArray(annots)
|
|
334
|
+
? annots.filter((a) => !(isRef(a) && flattenedRefs.has(a.num)))
|
|
335
|
+
: [];
|
|
336
|
+
const newXObjects = isDict(xobjects)
|
|
337
|
+
? `<< ${[...xobjects.map].map(([k, v]) => `/${k} ${serialize(v, cipher)}`).join(" ")} ${added.join(" ")} >>`
|
|
338
|
+
: `<< ${added.join(" ")} >>`;
|
|
339
|
+
const newResources = isDict(resources)
|
|
340
|
+
? withEntries(resources, { XObject: newXObjects }, cipher)
|
|
341
|
+
: `<< /XObject ${newXObjects} >>`;
|
|
342
|
+
writer.update(pageNum, withEntries(page, {
|
|
343
|
+
...(newContents !== undefined ? { Contents: newContents } : {}),
|
|
344
|
+
Annots: `[${keptAnnots.map((a) => serialize(a, cipher)).join(" ")}]`,
|
|
345
|
+
Resources: newResources,
|
|
346
|
+
}, cipher));
|
|
347
|
+
}
|
|
348
|
+
// The fields are gone from the form. Their objects stay in the file, simply unreferenced - an
|
|
349
|
+
// incremental update appends, it cannot delete.
|
|
350
|
+
const acroRef = get(doc.catalog, "AcroForm");
|
|
351
|
+
const acro = doc.resolve(acroRef);
|
|
352
|
+
if (acro !== undefined && isDict(acro)) {
|
|
353
|
+
const gone = new Set(fields.map((f) => f.objNum).filter((n) => n !== undefined));
|
|
354
|
+
// A flattened field may hang in a PARENT's /Kids rather than in the form's /Fields. Dropping it only
|
|
355
|
+
// from the root leaves it reachable, so `readAcroForm` still reports a field with no widget left.
|
|
356
|
+
pruneKids(doc, writer, get(acro, "Fields"), gone, cipher);
|
|
357
|
+
const roots = doc.resolve(get(acro, "Fields"));
|
|
358
|
+
const kept = Array.isArray(roots) ? roots.filter((r) => !(isRef(r) && gone.has(r.num))) : [];
|
|
359
|
+
const newAcro = withEntries(acro, { Fields: `[${kept.map((r) => serialize(r, cipher)).join(" ")}]` }, cipher);
|
|
360
|
+
if (isRef(acroRef)) {
|
|
361
|
+
writer.update(acroRef.num, newAcro);
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
// The form dict can sit INLINE in the catalog - our own writer puts it there - and then the
|
|
365
|
+
// CATALOG is what has to be rewritten. Skipping it left every field listed in a form that no
|
|
366
|
+
// longer has any widgets: a document contradicting itself.
|
|
367
|
+
const rootRef = doc.trailer.map.get("Root");
|
|
368
|
+
const catalog = doc.catalog;
|
|
369
|
+
if (isRef(rootRef) && catalog !== undefined && isDict(catalog)) {
|
|
370
|
+
writer.update(rootRef.num, withEntries(catalog, { AcroForm: newAcro }, cipher));
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return fields.map((f) => f.name);
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Walk the field tree and rewrite every `/Kids` that still points at a flattened field. A branch left
|
|
378
|
+
* with no children at all is added to `gone` so its own owner drops it too.
|
|
379
|
+
*/
|
|
380
|
+
function pruneKids(doc, writer, list, gone, cipher, depth = 0) {
|
|
381
|
+
if (depth > 32)
|
|
382
|
+
return;
|
|
383
|
+
const entries = doc.resolve(list);
|
|
384
|
+
if (!Array.isArray(entries))
|
|
385
|
+
return;
|
|
386
|
+
for (const ref of entries) {
|
|
387
|
+
if (!isRef(ref) || gone.has(ref.num))
|
|
388
|
+
continue;
|
|
389
|
+
const node = doc.resolve(ref);
|
|
390
|
+
if (node === undefined || !isDict(node))
|
|
391
|
+
continue;
|
|
392
|
+
const kids = get(node, "Kids");
|
|
393
|
+
if (kids === undefined)
|
|
394
|
+
continue;
|
|
395
|
+
pruneKids(doc, writer, kids, gone, cipher, depth + 1);
|
|
396
|
+
const resolved = doc.resolve(kids);
|
|
397
|
+
if (!Array.isArray(resolved))
|
|
398
|
+
continue;
|
|
399
|
+
const kept = resolved.filter((k) => !(isRef(k) && gone.has(k.num)));
|
|
400
|
+
if (kept.length === resolved.length)
|
|
401
|
+
continue;
|
|
402
|
+
if (kept.length === 0) {
|
|
403
|
+
gone.add(ref.num); // an empty branch is a field with nothing left to show
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
writer.update(ref.num, withEntries(node, { Kids: `[${kept.map((k) => serialize(k, cipher)).join(" ")}]` }, cipher));
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
/** Flatten an existing form: its values become page content and stop being editable. */
|
|
410
|
+
export async function flattenForm(bytes, options = {}) {
|
|
411
|
+
const doc = await PdfDocument.open(bytes, { password: options.password });
|
|
412
|
+
if (!doc.canReEncrypt) {
|
|
413
|
+
throw new FlattenError("this PDF is encrypted with RC4 or AES-128; jasy can open it but writes only AES-256, so " +
|
|
414
|
+
"flattening it would have to downgrade its protection - refused");
|
|
415
|
+
}
|
|
416
|
+
const form = readAcroForm(doc);
|
|
417
|
+
if (!form)
|
|
418
|
+
throw new FlattenError("this PDF has no AcroForm to flatten");
|
|
419
|
+
const warnings = [];
|
|
420
|
+
if (form.hasXfa) {
|
|
421
|
+
warnings.push("This document is an AcroForm/XFA hybrid; a viewer that prefers the XFA packet may still show " +
|
|
422
|
+
"editable fields.");
|
|
423
|
+
}
|
|
424
|
+
const byName = new Map(form.fields.map((f) => [f.name, f]));
|
|
425
|
+
const wanted = options.fields;
|
|
426
|
+
if (wanted) {
|
|
427
|
+
const unknown = wanted.filter((n) => !byName.has(n));
|
|
428
|
+
if (unknown.length > 0) {
|
|
429
|
+
throw new FlattenError(`no such field: ${unknown.map((n) => `"${n}"`).join(", ")}. This form has: ` +
|
|
430
|
+
form.fields.map((f) => `"${f.name}"`).join(", "));
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
const fields = wanted ? wanted.map((n) => byName.get(n)) : form.fields;
|
|
434
|
+
const writer = new IncrementalWriter(doc);
|
|
435
|
+
const flattened = await flattenInto(doc, writer, fields);
|
|
436
|
+
return { bytes: writer.save(), flattened, warnings };
|
|
437
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@jasy/pdf/edit` - reading and filling the form of an EXISTING PDF.
|
|
3
|
+
*
|
|
4
|
+
* A separate entry point on purpose. Creating a document never needs the parser, and a browser bundle
|
|
5
|
+
* that only generates should not carry it - the same reason jimp is kept out of the text path. Importing
|
|
6
|
+
* `@jasy/pdf` alone pulls in none of this.
|
|
7
|
+
*
|
|
8
|
+
* The scope is deliberately narrow: AcroForm fields, and nothing else. This is not a PDF editor. What we
|
|
9
|
+
* do not understand, we leave untouched, and what we cannot do safely we refuse by name.
|
|
10
|
+
*/
|
|
11
|
+
export { readAcroForm } from "./acroform-reader.js";
|
|
12
|
+
export type { ReadField, ReadForm, ReadWidget } from "./acroform-reader.js";
|
|
13
|
+
export { fillForm, FillError } from "./fill.js";
|
|
14
|
+
export { flattenForm, FlattenError } from "./flatten.js";
|
|
15
|
+
export type { FlattenOptions, FlattenResult } from "./flatten.js";
|
|
16
|
+
export type { FieldValue, FillOptions, FillResult } from "./fill.js";
|
|
17
|
+
export { PdfDocument, PdfEncryptedError } from "./document.js";
|
|
18
|
+
export type { PdfDict, PdfName, PdfObject, PdfRef, PdfStream, PdfString } from "./objects.js";
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@jasy/pdf/edit` - reading and filling the form of an EXISTING PDF.
|
|
3
|
+
*
|
|
4
|
+
* A separate entry point on purpose. Creating a document never needs the parser, and a browser bundle
|
|
5
|
+
* that only generates should not carry it - the same reason jimp is kept out of the text path. Importing
|
|
6
|
+
* `@jasy/pdf` alone pulls in none of this.
|
|
7
|
+
*
|
|
8
|
+
* The scope is deliberately narrow: AcroForm fields, and nothing else. This is not a PDF editor. What we
|
|
9
|
+
* do not understand, we leave untouched, and what we cannot do safely we refuse by name.
|
|
10
|
+
*/
|
|
11
|
+
export { readAcroForm } from "./acroform-reader.js";
|
|
12
|
+
export { fillForm, FillError } from "./fill.js";
|
|
13
|
+
export { flattenForm, FlattenError } from "./flatten.js";
|
|
14
|
+
// The document itself, for callers who want to look further than the form.
|
|
15
|
+
// PdfEncryptedError is what a caller catches to tell "wrong password" apart from a broken file.
|
|
16
|
+
export { PdfDocument, PdfEncryptedError } from "./document.js";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { PdfObject } from "./objects.js";
|
|
2
|
+
export declare class Lexer {
|
|
3
|
+
readonly bytes: Uint8Array;
|
|
4
|
+
pos: number;
|
|
5
|
+
constructor(bytes: Uint8Array, pos?: number);
|
|
6
|
+
/** Skip whitespace and comments (`%` to end of line). */
|
|
7
|
+
skip(): void;
|
|
8
|
+
/** The next bare token (a keyword like `obj`, `R`, `endstream`), without consuming delimiters. */
|
|
9
|
+
private word;
|
|
10
|
+
/** True when the bytes at the cursor spell `word` (after whitespace). Does not consume. */
|
|
11
|
+
peekWord(word: string): boolean;
|
|
12
|
+
/** Consume `word` if present; report whether it was. */
|
|
13
|
+
eatWord(word: string): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Parse one object at the cursor. Returns `undefined` at end of input or on a token this reader does
|
|
16
|
+
* not model - the caller treats that as "not something I can work with" rather than crashing.
|
|
17
|
+
*/
|
|
18
|
+
parse(): PdfObject | undefined;
|
|
19
|
+
/** `/Name`, with `#XX` escapes decoded (a font or field name may hold `#20` for a space). */
|
|
20
|
+
private name;
|
|
21
|
+
/** `(text)`: balanced nested parens, backslash escapes, and the line continuation. */
|
|
22
|
+
private literalString;
|
|
23
|
+
/** `<48656C6C6F>`: hex digits, whitespace ignored, an odd final digit padded with 0. */
|
|
24
|
+
private hexString;
|
|
25
|
+
private array;
|
|
26
|
+
/** `<< … >>`, followed by `stream` when it is a stream object. */
|
|
27
|
+
private dictOrStream;
|
|
28
|
+
/** A number, or an indirect reference when the shape is `int int R`. */
|
|
29
|
+
private numberOrRef;
|
|
30
|
+
}
|