@quillmark/quillify 0.3.0 → 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/README.md +2 -1
- package/assets/qpdf.worker.js +9 -2
- package/dist/display-list.d.ts +17 -14
- package/dist/display-list.js +152 -119
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -0
- package/dist/inventory.js +6 -2
- package/dist/normalize.js +158 -14
- package/dist/pdfjs.js +3 -2
- package/dist/strip.d.ts +11 -6
- package/dist/strip.js +30 -21
- package/dist/types.d.ts +18 -3
- package/dist/types.js +18 -3
- package/dist/xfa-layer.d.ts +4 -4
- package/dist/xfa-layer.js +8 -4
- package/dist/xfa-widgets.js +3 -0
- package/dist/xfa.js +86 -60
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Turn a published PDF form into a [Quillmark](https://github.com/borb-sh/quillmar
|
|
|
4
4
|
|
|
5
5
|
Published forms rarely come as a clean fillable PDF. Most are encrypted with an empty password, and many are dynamic XFA, which only Acrobat lays out. quillify reads either kind and returns the page as a background with its form layer stripped, and every field with its place.
|
|
6
6
|
|
|
7
|
-
57 of 59 federal forms and 1,
|
|
7
|
+
57 of 59 federal forms and 1,147 of 1,354 Air Force forms convert, with every field read back at its place.
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
@@ -45,6 +45,7 @@ The file is treated as hostile. The background comes back without its form layer
|
|
|
45
45
|
|
|
46
46
|
## Documentation
|
|
47
47
|
|
|
48
|
+
- [Example](examples/): a page that takes a PDF, lists its fields under the names `suggest` gives and renders a filled copy. In a clone: `npm ci`, `npm run build`, `npx vite examples`.
|
|
48
49
|
- [API](docs/api.md): what goes in, what comes back, every refusal and problem.
|
|
49
50
|
- [Guide](docs/guide.md): bundlers, content security policy, the engine, from a quill to a filled PDF.
|
|
50
51
|
- [Limits](docs/limits.md): what converts, what is refused, how a redrawn form can differ.
|
package/assets/qpdf.worker.js
CHANGED
|
@@ -126,14 +126,19 @@ onmessage = async ({ data: { bytes, flags, wasm, limit } }) => {
|
|
|
126
126
|
}
|
|
127
127
|
let out;
|
|
128
128
|
let written = 0;
|
|
129
|
+
let said = '';
|
|
129
130
|
try {
|
|
130
131
|
qpdf.FS.writeFile('/in.pdf', bytes);
|
|
131
132
|
// Nothing in qpdf bounds the file it writes: streams that lie inside one
|
|
132
133
|
// another are each written whole. The write that passes the limit ends
|
|
133
134
|
// the run.
|
|
134
135
|
const { write } = qpdf.FS;
|
|
136
|
+
const text = new TextDecoder();
|
|
135
137
|
qpdf.FS.write = (stream, buffer, offset, length, ...rest) => {
|
|
136
138
|
if ((written += length) > limit) throw new Error('qpdf wrote past the limit');
|
|
139
|
+
// Its error stream. A qpdf that ends by itself says last why it writes no file, so the end is kept.
|
|
140
|
+
if (stream.fd === 2)
|
|
141
|
+
said = (said + text.decode(buffer.subarray(offset, offset + length))).slice(-1024);
|
|
137
142
|
return write(stream, buffer, offset, length, ...rest);
|
|
138
143
|
};
|
|
139
144
|
// qpdf ends with 0, or with 3 when it warned. After an error the file is
|
|
@@ -141,8 +146,10 @@ onmessage = async ({ data: { bytes, flags, wasm, limit } }) => {
|
|
|
141
146
|
if ([0, 3].includes(qpdf.callMain(['/in.pdf', ...flags, '/out.pdf'])))
|
|
142
147
|
out = qpdf.FS.readFile('/out.pdf');
|
|
143
148
|
} catch {
|
|
144
|
-
// qpdf wrote nothing it could stand behind.
|
|
149
|
+
// qpdf wrote nothing it could stand behind. It did not end by itself, so
|
|
150
|
+
// its last line is no error of its own: a warning carries the file's words.
|
|
151
|
+
said = '';
|
|
145
152
|
}
|
|
146
153
|
const size = Math.max(written, qpdf.HEAPU8.byteLength);
|
|
147
|
-
postMessage({ loaded: true, out, size }, out ? [out.buffer] : []);
|
|
154
|
+
postMessage({ loaded: true, out, size, said }, out ? [out.buffer] : []);
|
|
148
155
|
};
|
package/dist/display-list.d.ts
CHANGED
|
@@ -14,7 +14,10 @@ export interface Box {
|
|
|
14
14
|
fill: Colour | null;
|
|
15
15
|
/** Top, right, bottom, left. A null edge is not drawn. */
|
|
16
16
|
edges: [Edge | null, Edge | null, Edge | null, Edge | null];
|
|
17
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* The radius of an SVG rect's corners, along the middle of its border. An
|
|
19
|
+
* ellipse has one that reaches the middle of its longer side.
|
|
20
|
+
*/
|
|
18
21
|
radius?: number;
|
|
19
22
|
}
|
|
20
23
|
/** A picture, at the box the browser paints its pixels in. */
|
|
@@ -45,16 +48,15 @@ export interface Run {
|
|
|
45
48
|
italic: boolean;
|
|
46
49
|
colour: Colour;
|
|
47
50
|
letterSpacing: number;
|
|
48
|
-
/** What the browser measured, which `
|
|
51
|
+
/** What the browser measured, which `drawRun` scales the run to. */
|
|
49
52
|
width: number;
|
|
50
53
|
/** Quarter turns counter-clockwise about the start of the baseline, when the run is turned. */
|
|
51
54
|
turn?: number;
|
|
52
55
|
}
|
|
56
|
+
export type Drawn = Box | Picture | Line | Run;
|
|
53
57
|
export interface DisplayList {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
lines: Line[];
|
|
57
|
-
runs: Run[];
|
|
58
|
+
/** What the page shows, in the order the browser paints it. */
|
|
59
|
+
drawn: Drawn[];
|
|
58
60
|
width: number;
|
|
59
61
|
height: number;
|
|
60
62
|
}
|
|
@@ -63,20 +65,21 @@ export interface DisplayList {
|
|
|
63
65
|
* the layout, and the rects here are read off it.
|
|
64
66
|
*
|
|
65
67
|
* What it can express is text, CSS backgrounds and borders, pictures, and
|
|
66
|
-
* inline SVG `line` and `
|
|
67
|
-
* image, a
|
|
68
|
-
* border style it cannot draw and a transform it cannot
|
|
69
|
-
* the construct named: a background that is silently
|
|
70
|
-
* not taking the form. An element hidden by its own
|
|
71
|
-
* no stroke, a
|
|
72
|
-
* 0.3 pt on a side are
|
|
68
|
+
* inline SVG `line`, `rect`, `ellipse` and `circle`. A picture that did not
|
|
69
|
+
* load, a background image, a shape filled with a gradient or a pattern,
|
|
70
|
+
* another SVG shape, a border style it cannot draw and a transform it cannot
|
|
71
|
+
* carry are refused with the construct named: a background that is silently
|
|
72
|
+
* incomplete is worse than not taking the form. An element hidden by its own
|
|
73
|
+
* style, an SVG `line` with no stroke, a shape with neither stroke nor fill
|
|
74
|
+
* or of no width or height, and an HTML element under 0.3 pt on a side are
|
|
75
|
+
* passed over.
|
|
73
76
|
*/
|
|
74
77
|
export declare function displayList(page: XfaPage, pageNumber: number): DisplayList;
|
|
75
78
|
/**
|
|
76
79
|
* True for a transform a run's box cannot carry. XFA offsets caption
|
|
77
80
|
* wrappers with a translation, which the rects already hold. A positive
|
|
78
81
|
* horizontal scale is carried too: `getClientRects` reports the scaled width and
|
|
79
|
-
* `
|
|
82
|
+
* `drawRun` puts it back as `Tz`, which is how LiveCycle's
|
|
80
83
|
* `xfa-font-horizontal-scale` reaches the page. A rotation, a skew, a mirror or
|
|
81
84
|
* a vertical scale changes glyph shape or height, and a run carries neither. A
|
|
82
85
|
* quarter turn is read apart, by `quarterTurns`.
|
package/dist/display-list.js
CHANGED
|
@@ -23,21 +23,19 @@ function stroked(style) {
|
|
|
23
23
|
* the layout, and the rects here are read off it.
|
|
24
24
|
*
|
|
25
25
|
* What it can express is text, CSS backgrounds and borders, pictures, and
|
|
26
|
-
* inline SVG `line` and `
|
|
27
|
-
* image, a
|
|
28
|
-
* border style it cannot draw and a transform it cannot
|
|
29
|
-
* the construct named: a background that is silently
|
|
30
|
-
* not taking the form. An element hidden by its own
|
|
31
|
-
* no stroke, a
|
|
32
|
-
* 0.3 pt on a side are
|
|
26
|
+
* inline SVG `line`, `rect`, `ellipse` and `circle`. A picture that did not
|
|
27
|
+
* load, a background image, a shape filled with a gradient or a pattern,
|
|
28
|
+
* another SVG shape, a border style it cannot draw and a transform it cannot
|
|
29
|
+
* carry are refused with the construct named: a background that is silently
|
|
30
|
+
* incomplete is worse than not taking the form. An element hidden by its own
|
|
31
|
+
* style, an SVG `line` with no stroke, a shape with neither stroke nor fill
|
|
32
|
+
* or of no width or height, and an HTML element under 0.3 pt on a side are
|
|
33
|
+
* passed over.
|
|
33
34
|
*/
|
|
34
35
|
export function displayList(page, pageNumber) {
|
|
35
36
|
const { layer, origin } = page;
|
|
36
37
|
const view = layer.ownerDocument.defaultView;
|
|
37
|
-
const
|
|
38
|
-
const pictures = [];
|
|
39
|
-
const lines = [];
|
|
40
|
-
const runs = [];
|
|
38
|
+
const drawn = [];
|
|
41
39
|
// The quarter turns each element is laid out under, its own and its ancestors'.
|
|
42
40
|
const turns = new Map([[layer, 0]]);
|
|
43
41
|
const X = (v) => +(v - origin.left).toFixed(2);
|
|
@@ -45,7 +43,43 @@ export function displayList(page, pageNumber) {
|
|
|
45
43
|
const refuse = (construct, detail) => {
|
|
46
44
|
throw new Refused({ code: 'unpaintable', page: pageNumber - 1, construct, detail });
|
|
47
45
|
};
|
|
48
|
-
|
|
46
|
+
// CSS paints the boxes of what is in the flow under a positioned element,
|
|
47
|
+
// then its text, pictures and SVG shapes, then the positioned elements
|
|
48
|
+
// under it in tree order, each the same way.
|
|
49
|
+
const order = [];
|
|
50
|
+
const under = (root) => {
|
|
51
|
+
const front = [];
|
|
52
|
+
const positioned = [];
|
|
53
|
+
const flow = (parent) => {
|
|
54
|
+
for (const node of parent.childNodes) {
|
|
55
|
+
if (node.nodeType === Node.TEXT_NODE)
|
|
56
|
+
front.push(node);
|
|
57
|
+
if (node.nodeType !== Node.ELEMENT_NODE)
|
|
58
|
+
continue;
|
|
59
|
+
const el = node;
|
|
60
|
+
if (view.getComputedStyle(el).position !== 'static')
|
|
61
|
+
positioned.push(el);
|
|
62
|
+
else {
|
|
63
|
+
(/^(img|line|rect|ellipse|circle|input)$/i.test(el.tagName) ? front : order).push(el);
|
|
64
|
+
flow(el);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
flow(root);
|
|
69
|
+
for (const node of front)
|
|
70
|
+
order.push(node);
|
|
71
|
+
for (const el of positioned) {
|
|
72
|
+
order.push(el);
|
|
73
|
+
under(el);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
under(layer);
|
|
77
|
+
for (const node of order) {
|
|
78
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
79
|
+
collectText(node, X, Y, drawn, turns);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const el = node;
|
|
49
83
|
const tag = el.tagName.toLowerCase();
|
|
50
84
|
const style = view.getComputedStyle(el);
|
|
51
85
|
const own = quarterTurns(style.transform);
|
|
@@ -70,7 +104,7 @@ export function displayList(page, pageNumber) {
|
|
|
70
104
|
const fit = style.objectFit === 'contain'
|
|
71
105
|
? Math.min(rect.width / image.naturalWidth, rect.height / image.naturalHeight)
|
|
72
106
|
: 0;
|
|
73
|
-
|
|
107
|
+
drawn.push({
|
|
74
108
|
x: X(rect.x),
|
|
75
109
|
y: Y(rect.y),
|
|
76
110
|
w: +(fit ? image.naturalWidth * fit : rect.width).toFixed(2),
|
|
@@ -79,7 +113,7 @@ export function displayList(page, pageNumber) {
|
|
|
79
113
|
});
|
|
80
114
|
continue;
|
|
81
115
|
}
|
|
82
|
-
if (tag === 'line' || tag === 'rect') {
|
|
116
|
+
if (tag === 'line' || tag === 'rect' || tag === 'ellipse' || tag === 'circle') {
|
|
83
117
|
const svg = el.ownerSVGElement;
|
|
84
118
|
if (!svg)
|
|
85
119
|
continue;
|
|
@@ -98,7 +132,7 @@ export function displayList(page, pageNumber) {
|
|
|
98
132
|
return 0;
|
|
99
133
|
return raw.trim().endsWith('%') ? (span * value) / 100 : value;
|
|
100
134
|
};
|
|
101
|
-
|
|
135
|
+
drawn.push({
|
|
102
136
|
x1: X(svgRect.x + at('x1', svgRect.width)),
|
|
103
137
|
y1: Y(svgRect.y + at('y1', svgRect.height)),
|
|
104
138
|
x2: X(svgRect.x + at('x2', svgRect.width)),
|
|
@@ -108,22 +142,26 @@ export function displayList(page, pageNumber) {
|
|
|
108
142
|
});
|
|
109
143
|
}
|
|
110
144
|
else {
|
|
111
|
-
// pdf.js writes every
|
|
112
|
-
//
|
|
145
|
+
// pdf.js writes every shape a fill, and the browser drops one that is no
|
|
146
|
+
// color: a gradient or a pattern. What is left computes to black.
|
|
113
147
|
if (!el.style.fill && !el.hasAttribute('fill'))
|
|
114
148
|
refuse('background_image');
|
|
115
|
-
// A
|
|
149
|
+
// A shape whose edge is hidden has a stroke of no width.
|
|
116
150
|
const fill = style.fill !== 'none' && opaque(style.fill) ? rgb(style.fill) : null;
|
|
117
151
|
if (!edge && !fill)
|
|
118
152
|
continue;
|
|
119
|
-
// SVG centers a stroke on the
|
|
153
|
+
// SVG centers a stroke on the shape's edge, and a box's border is
|
|
120
154
|
// drawn inside it, so the box is half a stroke larger all round. What
|
|
121
155
|
// that adds to the fill lies under the stroke.
|
|
122
156
|
const half = (edge?.width ?? 0) / 2;
|
|
123
157
|
const r = el.getBoundingClientRect();
|
|
124
|
-
// The browser
|
|
125
|
-
|
|
126
|
-
|
|
158
|
+
// The browser paints no shape of no width or height, whatever its stroke.
|
|
159
|
+
if (!r.width || !r.height)
|
|
160
|
+
continue;
|
|
161
|
+
// The browser draws a rect square when its rx is below zero. An ellipse
|
|
162
|
+
// is a rect whose corners meet.
|
|
163
|
+
const radius = tag === 'rect' ? parseFloat(style.rx) : Math.max(r.width, r.height) / 2;
|
|
164
|
+
drawn.push({
|
|
127
165
|
x: X(r.x - half),
|
|
128
166
|
y: Y(r.y - half),
|
|
129
167
|
w: +(r.width + 2 * half).toFixed(2),
|
|
@@ -152,7 +190,7 @@ export function displayList(page, pageNumber) {
|
|
|
152
190
|
if (type === 'checkbox' || type === 'radio') {
|
|
153
191
|
const painted = square(rect);
|
|
154
192
|
const hair = { width: 1, colour: [0, 0, 0] };
|
|
155
|
-
|
|
193
|
+
drawn.push({
|
|
156
194
|
x: X(painted.left),
|
|
157
195
|
y: Y(painted.top),
|
|
158
196
|
w: +painted.width.toFixed(2),
|
|
@@ -188,7 +226,7 @@ export function displayList(page, pageNumber) {
|
|
|
188
226
|
edges.push(edges.shift());
|
|
189
227
|
const fill = opaque(style.backgroundColor) ? rgb(style.backgroundColor) : null;
|
|
190
228
|
if (fill || edges.some(Boolean))
|
|
191
|
-
|
|
229
|
+
drawn.push({
|
|
192
230
|
x: X(rect.x),
|
|
193
231
|
y: Y(rect.y),
|
|
194
232
|
w: +rect.width.toFixed(2),
|
|
@@ -197,8 +235,7 @@ export function displayList(page, pageNumber) {
|
|
|
197
235
|
edges
|
|
198
236
|
});
|
|
199
237
|
}
|
|
200
|
-
|
|
201
|
-
return { boxes, pictures, lines, runs, width: page.width, height: page.height };
|
|
238
|
+
return { drawn, width: page.width, height: page.height };
|
|
202
239
|
}
|
|
203
240
|
/**
|
|
204
241
|
* How many quarter turns counter-clockwise a transform is, and 0 for any
|
|
@@ -215,7 +252,7 @@ function quarterTurns(transform) {
|
|
|
215
252
|
* True for a transform a run's box cannot carry. XFA offsets caption
|
|
216
253
|
* wrappers with a translation, which the rects already hold. A positive
|
|
217
254
|
* horizontal scale is carried too: `getClientRects` reports the scaled width and
|
|
218
|
-
* `
|
|
255
|
+
* `drawRun` puts it back as `Tz`, which is how LiveCycle's
|
|
219
256
|
* `xfa-font-horizontal-scale` reaches the page. A rotation, a skew, a mirror or
|
|
220
257
|
* a vertical scale changes glyph shape or height, and a run carries neither. A
|
|
221
258
|
* quarter turn is read apart, by `quarterTurns`.
|
|
@@ -231,7 +268,7 @@ export function unrepresentable(transform) {
|
|
|
231
268
|
}
|
|
232
269
|
/** SVG shapes pdf.js can emit that the primitives above have no form for. */
|
|
233
270
|
function svgOnly(tag) {
|
|
234
|
-
return ['path', 'polygon', 'polyline', '
|
|
271
|
+
return ['path', 'polygon', 'polyline', 'image', 'use', 'text'].includes(tag);
|
|
235
272
|
}
|
|
236
273
|
/**
|
|
237
274
|
* One run per visual line, with the box the browser measured it into. Splitting
|
|
@@ -239,101 +276,97 @@ function svgOnly(tag) {
|
|
|
239
276
|
* its own baseline. A tab ends a run too and is in none: the text after it
|
|
240
277
|
* starts a run at the box the layout moved it to.
|
|
241
278
|
*/
|
|
242
|
-
function collectText(
|
|
243
|
-
const
|
|
244
|
-
const
|
|
279
|
+
function collectText(node, X, Y, drawn, turns) {
|
|
280
|
+
const text = node.nodeValue;
|
|
281
|
+
const parent = node.parentElement;
|
|
282
|
+
if (!text || !text.trim() || !parent)
|
|
283
|
+
return;
|
|
284
|
+
// A <select> is de-chromed above; only the option the form shows is drawn.
|
|
285
|
+
if (parent.tagName === 'OPTION' && !parent.selected)
|
|
286
|
+
return;
|
|
287
|
+
const owner = node.ownerDocument;
|
|
288
|
+
const style = owner.defaultView.getComputedStyle(parent);
|
|
289
|
+
if (style.visibility === 'hidden' || style.display === 'none')
|
|
290
|
+
return;
|
|
245
291
|
const measure = owner.createElement('canvas').getContext('2d');
|
|
246
|
-
const walker = owner.createTreeWalker(layer, NodeFilter.SHOW_TEXT);
|
|
247
292
|
const range = owner.createRange();
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
293
|
+
const size = parseFloat(style.fontSize);
|
|
294
|
+
measure.font = `${style.fontStyle} ${style.fontWeight} ${size}px ${style.fontFamily}`;
|
|
295
|
+
const descent = measure.measureText('Hg').fontBoundingBoxDescent ?? size * 0.21;
|
|
296
|
+
const common = {
|
|
297
|
+
size: +size.toFixed(2),
|
|
298
|
+
family: style.fontFamily,
|
|
299
|
+
bold: parseInt(style.fontWeight, 10) >= 600 || style.fontWeight === 'bold',
|
|
300
|
+
italic: style.fontStyle === 'italic' || style.fontStyle === 'oblique',
|
|
301
|
+
colour: rgb(style.color),
|
|
302
|
+
letterSpacing: parseFloat(style.letterSpacing) || 0
|
|
303
|
+
};
|
|
304
|
+
// Under a turn each rect is read in the frame the text is upright in, and
|
|
305
|
+
// the start of each line's baseline is turned back to the page.
|
|
306
|
+
const turn = turns.get(parent);
|
|
307
|
+
const [cos, sin] = [
|
|
308
|
+
[1, 0],
|
|
309
|
+
[0, -1],
|
|
310
|
+
[-1, 0],
|
|
311
|
+
[0, 1]
|
|
312
|
+
][turn];
|
|
313
|
+
const upright = ({ left, right, top, bottom }) => {
|
|
314
|
+
const [x1, x2] = [cos * left + sin * top, cos * right + sin * bottom];
|
|
315
|
+
const [y1, y2] = [cos * top - sin * left, cos * bottom - sin * right];
|
|
316
|
+
return {
|
|
317
|
+
x: Math.min(x1, x2),
|
|
318
|
+
right: Math.max(x1, x2),
|
|
319
|
+
top: Math.min(y1, y2),
|
|
320
|
+
bottom: Math.max(y1, y2)
|
|
321
|
+
};
|
|
322
|
+
};
|
|
323
|
+
let start = 0;
|
|
324
|
+
let top = null;
|
|
325
|
+
let box = null;
|
|
326
|
+
const flush = (end) => {
|
|
327
|
+
const slice = text.slice(start, end);
|
|
328
|
+
if (end <= start || !box || !slice.trim())
|
|
329
|
+
return;
|
|
330
|
+
const base = box.bottom - descent;
|
|
331
|
+
drawn.push({
|
|
332
|
+
text: slice,
|
|
333
|
+
x: X(cos * box.x - sin * base),
|
|
334
|
+
baseline: Y(sin * box.x + cos * base),
|
|
335
|
+
width: +(box.right - box.x).toFixed(2),
|
|
336
|
+
turn: turn || undefined,
|
|
337
|
+
...common
|
|
338
|
+
});
|
|
339
|
+
};
|
|
340
|
+
for (let i = 0; i < text.length; i++) {
|
|
341
|
+
if (text[i] === '\t') {
|
|
342
|
+
flush(i);
|
|
343
|
+
start = i + 1;
|
|
344
|
+
top = null;
|
|
345
|
+
box = null;
|
|
255
346
|
continue;
|
|
256
|
-
|
|
257
|
-
|
|
347
|
+
}
|
|
348
|
+
range.setStart(node, i);
|
|
349
|
+
range.setEnd(node, i + 1);
|
|
350
|
+
const rect = range.getClientRects()[0];
|
|
351
|
+
if (!rect)
|
|
258
352
|
continue;
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
[0, -1],
|
|
276
|
-
[-1, 0],
|
|
277
|
-
[0, 1]
|
|
278
|
-
][turn];
|
|
279
|
-
const upright = ({ left, right, top, bottom }) => {
|
|
280
|
-
const [x1, x2] = [cos * left + sin * top, cos * right + sin * bottom];
|
|
281
|
-
const [y1, y2] = [cos * top - sin * left, cos * bottom - sin * right];
|
|
282
|
-
return {
|
|
283
|
-
x: Math.min(x1, x2),
|
|
284
|
-
right: Math.max(x1, x2),
|
|
285
|
-
top: Math.min(y1, y2),
|
|
286
|
-
bottom: Math.max(y1, y2)
|
|
287
|
-
};
|
|
288
|
-
};
|
|
289
|
-
let start = 0;
|
|
290
|
-
let top = null;
|
|
291
|
-
let box = null;
|
|
292
|
-
const flush = (end) => {
|
|
293
|
-
const slice = text.slice(start, end);
|
|
294
|
-
if (end <= start || !box || !slice.trim())
|
|
295
|
-
return;
|
|
296
|
-
const base = box.bottom - descent;
|
|
297
|
-
runs.push({
|
|
298
|
-
text: slice,
|
|
299
|
-
x: X(cos * box.x - sin * base),
|
|
300
|
-
baseline: Y(sin * box.x + cos * base),
|
|
301
|
-
width: +(box.right - box.x).toFixed(2),
|
|
302
|
-
turn: turn || undefined,
|
|
303
|
-
...common
|
|
304
|
-
});
|
|
305
|
-
};
|
|
306
|
-
for (let i = 0; i < text.length; i++) {
|
|
307
|
-
if (text[i] === '\t') {
|
|
308
|
-
flush(i);
|
|
309
|
-
start = i + 1;
|
|
310
|
-
top = null;
|
|
311
|
-
box = null;
|
|
312
|
-
continue;
|
|
313
|
-
}
|
|
314
|
-
range.setStart(node, i);
|
|
315
|
-
range.setEnd(node, i + 1);
|
|
316
|
-
const rect = range.getClientRects()[0];
|
|
317
|
-
if (!rect)
|
|
318
|
-
continue;
|
|
319
|
-
const glyph = upright(rect);
|
|
320
|
-
if (top === null || Math.abs(glyph.top - top) < 0.6) {
|
|
321
|
-
top ??= glyph.top;
|
|
322
|
-
box = box
|
|
323
|
-
? {
|
|
324
|
-
x: Math.min(box.x, glyph.x),
|
|
325
|
-
bottom: Math.max(box.bottom, glyph.bottom),
|
|
326
|
-
right: Math.max(box.right, glyph.right)
|
|
327
|
-
}
|
|
328
|
-
: { x: glyph.x, bottom: glyph.bottom, right: glyph.right };
|
|
329
|
-
}
|
|
330
|
-
else {
|
|
331
|
-
flush(i);
|
|
332
|
-
start = i;
|
|
333
|
-
top = glyph.top;
|
|
334
|
-
box = { x: glyph.x, bottom: glyph.bottom, right: glyph.right };
|
|
335
|
-
}
|
|
353
|
+
const glyph = upright(rect);
|
|
354
|
+
if (top === null || Math.abs(glyph.top - top) < 0.6) {
|
|
355
|
+
top ??= glyph.top;
|
|
356
|
+
box = box
|
|
357
|
+
? {
|
|
358
|
+
x: Math.min(box.x, glyph.x),
|
|
359
|
+
bottom: Math.max(box.bottom, glyph.bottom),
|
|
360
|
+
right: Math.max(box.right, glyph.right)
|
|
361
|
+
}
|
|
362
|
+
: { x: glyph.x, bottom: glyph.bottom, right: glyph.right };
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
flush(i);
|
|
366
|
+
start = i;
|
|
367
|
+
top = glyph.top;
|
|
368
|
+
box = { x: glyph.x, bottom: glyph.bottom, right: glyph.right };
|
|
336
369
|
}
|
|
337
|
-
flush(text.length);
|
|
338
370
|
}
|
|
371
|
+
flush(text.length);
|
|
339
372
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -3,4 +3,4 @@ export { quillify, type Quillified } from './quillify.js';
|
|
|
3
3
|
export { sanitize, type Sanitized } from './sanitize.js';
|
|
4
4
|
export type { Field, FieldNaming, Form, Naming, Place, Problem, Size } from './api.js';
|
|
5
5
|
export type { Refusal } from './refusal.js';
|
|
6
|
-
export type
|
|
6
|
+
export { limits, type Rect } from './types.js';
|
package/dist/index.js
CHANGED
package/dist/inventory.js
CHANGED
|
@@ -2,7 +2,7 @@ import { PDFArray, PDFDict, PDFDocument, PDFHexString, PDFName, PDFString, pdfDo
|
|
|
2
2
|
import { toTopLeftRect } from './coords.js';
|
|
3
3
|
import { openPdf } from './pdfjs.js';
|
|
4
4
|
import { Refused } from './refusal.js';
|
|
5
|
-
import { MAX_PAGES, MAX_WIDGETS, WIDGET_KINDS } from './types.js';
|
|
5
|
+
import { MAX_PAGES, MAX_SIDE, MAX_WIDGETS, WIDGET_KINDS } from './types.js';
|
|
6
6
|
/** pdf.js passes on whatever name a file gives `/FT`. */
|
|
7
7
|
const isWidgetKind = (type) => WIDGET_KINDS.includes(type);
|
|
8
8
|
export async function inventoryPdf(bytes, signal) {
|
|
@@ -49,7 +49,11 @@ export async function inventoryPdf(bytes, signal) {
|
|
|
49
49
|
throw new Refused({ code: 'rotated', page: index });
|
|
50
50
|
const view = page.view;
|
|
51
51
|
views.push({ ref: page.ref, box: view });
|
|
52
|
-
|
|
52
|
+
const size = { width: view[2] - view[0], height: view[3] - view[1] };
|
|
53
|
+
if (page.userUnit !== 1 ||
|
|
54
|
+
Math.max(size.width, size.height, ...view.map(Math.abs)) > MAX_SIDE)
|
|
55
|
+
throw new Refused({ code: 'damaged' });
|
|
56
|
+
dims.push(size);
|
|
53
57
|
const annotations = (await within(page.getAnnotations()));
|
|
54
58
|
for (const a of annotations) {
|
|
55
59
|
// A push button runs an action; it holds no value, so it is no field.
|
package/dist/normalize.js
CHANGED
|
@@ -27,8 +27,7 @@
|
|
|
27
27
|
* is ended at the write that passes it, and then in what qpdf wrote, on the
|
|
28
28
|
* page's thread and before any other reader opens it.
|
|
29
29
|
*/
|
|
30
|
-
import { ParseSpeeds, PDFArray, PDFDocument, PDFName, PDFRawStream } from 'pdf-lib';
|
|
31
|
-
import { openPdf } from './pdfjs.js';
|
|
30
|
+
import { ParseSpeeds, PDFArray, PDFBool, PDFDict, PDFDocument, PDFInvalidObject, PDFName, PDFNumber, PDFRawStream, PDFStream } from 'pdf-lib';
|
|
32
31
|
import { Refused } from './refusal.js';
|
|
33
32
|
import { MAX_BYTES, MAX_INFLATED_BYTES } from './types.js';
|
|
34
33
|
// Held in a constant so that Vite copies the worker byte for byte. Written
|
|
@@ -52,14 +51,141 @@ const TYPED = new RegExp(`/(?:${spelled('ObjStm')}|${spelled('XRef')})`);
|
|
|
52
51
|
* 1 MB handed over whole.
|
|
53
52
|
*/
|
|
54
53
|
const SLICE = 1024;
|
|
55
|
-
/** What qpdf leaves a stream under, alone or after one Flate layer. Each gives pixels
|
|
54
|
+
/** What qpdf leaves a stream under, alone or after one Flate layer. Each gives pixels. */
|
|
56
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
|
+
]);
|
|
57
67
|
/**
|
|
58
|
-
* The
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
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`.
|
|
63
189
|
*/
|
|
64
190
|
async function inflated(pdf) {
|
|
65
191
|
let total = pdf.byteLength;
|
|
@@ -72,6 +198,8 @@ async function inflated(pdf) {
|
|
|
72
198
|
parseSpeed: ParseSpeeds.Fastest
|
|
73
199
|
});
|
|
74
200
|
for (const [, object] of context.enumerateIndirectObjects()) {
|
|
201
|
+
if (object instanceof PDFInvalidObject)
|
|
202
|
+
throw new Refused({ code: 'damaged' });
|
|
75
203
|
if (!(object instanceof PDFRawStream))
|
|
76
204
|
continue;
|
|
77
205
|
const { dict, contents } = object;
|
|
@@ -85,10 +213,16 @@ async function inflated(pdf) {
|
|
|
85
213
|
const rest = chain.slice(flate ? 1 : 0);
|
|
86
214
|
if (rest.length > 1 || rest.some((name) => !CODECS.includes(name)))
|
|
87
215
|
throw new Refused({ code: 'damaged' });
|
|
216
|
+
const before = total;
|
|
217
|
+
const said = declared(dict, chain);
|
|
88
218
|
// An empty stream is one to every reader, and to the decoder a stream cut short.
|
|
89
|
-
if (!flate || !contents.length)
|
|
219
|
+
if (!flate || !contents.length) {
|
|
220
|
+
total += Math.max(said, headed(rest[0], contents));
|
|
90
221
|
continue;
|
|
222
|
+
}
|
|
91
223
|
let at = 0;
|
|
224
|
+
const keep = rest[0] === 'DCTDecode' || rest[0] === 'JPXDecode';
|
|
225
|
+
const under = [];
|
|
92
226
|
const reader = new ReadableStream({
|
|
93
227
|
pull(controller) {
|
|
94
228
|
if (at < contents.length)
|
|
@@ -104,6 +238,8 @@ async function inflated(pdf) {
|
|
|
104
238
|
total += read.value.byteLength;
|
|
105
239
|
if (total > MAX_INFLATED_BYTES)
|
|
106
240
|
return total;
|
|
241
|
+
if (keep)
|
|
242
|
+
under.push(read.value);
|
|
107
243
|
}
|
|
108
244
|
}
|
|
109
245
|
catch {
|
|
@@ -112,6 +248,8 @@ async function inflated(pdf) {
|
|
|
112
248
|
finally {
|
|
113
249
|
void reader.cancel().catch(() => { });
|
|
114
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));
|
|
115
253
|
}
|
|
116
254
|
return total;
|
|
117
255
|
}
|
|
@@ -145,15 +283,21 @@ export async function normalizePdf(bytes, signal) {
|
|
|
145
283
|
});
|
|
146
284
|
if (!answer.loaded)
|
|
147
285
|
throw new Error(`qpdf.wasm did not load from ${WASM}`);
|
|
148
|
-
const { out, size } = answer;
|
|
286
|
+
const { out, size, said } = answer;
|
|
149
287
|
// A file that came to more than the bound in qpdf is past it, whatever qpdf then wrote.
|
|
150
288
|
const total = size > MAX_INFLATED_BYTES ? size : out?.byteLength ? await inflated(out) : 0;
|
|
151
289
|
if (total > MAX_INFLATED_BYTES)
|
|
152
290
|
throw new Refused({ code: 'too_large', bytes: total, limit: MAX_INFLATED_BYTES });
|
|
153
291
|
if (out?.byteLength)
|
|
154
292
|
return out;
|
|
155
|
-
// qpdf
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
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' });
|
|
159
303
|
}
|
package/dist/pdfjs.js
CHANGED
|
@@ -2,8 +2,9 @@ import { Refused } from './refusal.js';
|
|
|
2
2
|
// Held in a constant so that Vite copies the worker byte for byte. Written
|
|
3
3
|
// inside `new Worker(...)`, the URL makes it bundle the file again.
|
|
4
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
|
|
6
|
-
// and
|
|
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.
|
|
7
8
|
const FACES = {
|
|
8
9
|
'LiberationSans-Regular.ttf': new URL('../assets/LiberationSans-Regular.ttf', import.meta.url),
|
|
9
10
|
'LiberationSans-Bold.ttf': new URL('../assets/LiberationSans-Bold.ttf', import.meta.url),
|
package/dist/strip.d.ts
CHANGED
|
@@ -67,11 +67,16 @@ export declare const PICK: Record<string, [key: string, kinds: Record<string, st
|
|
|
67
67
|
* and an object reference whose `/Obj` is not such a link is taken out of its
|
|
68
68
|
* element's `/K`.
|
|
69
69
|
*
|
|
70
|
-
* An object that
|
|
71
|
-
* `views` nor a link that stays,
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
70
|
+
* An object that says `/Type /Annot`, `/Subtype /Widget` or `/Type /Page`, and
|
|
71
|
+
* is neither a page of `views` nor a link that stays, has no kind. A file in
|
|
72
|
+
* which a key reaches one as a kind is refused as `damaged`, the catalog itself
|
|
73
|
+
* included, and so is a file whose catalog or page tree pdf-lib cannot read. A
|
|
74
|
+
* marked-content property list may say `/Type /Page`, and a `/Rect` says
|
|
75
|
+
* nothing: a kept object loses the keys its row does not list. So no object of
|
|
76
|
+
* a background is a widget or a page outside the page tree, and the fields and
|
|
77
|
+
* appearance streams that hung off one are out of the walk's reach: a
|
|
78
|
+
* background that still carries a form layer is one the renderer would stamp a
|
|
79
|
+
* second one over. A dictionary written in place is not an object: one by name
|
|
80
|
+
* keeps the plain values a file writes in it, a widget's among them.
|
|
76
81
|
*/
|
|
77
82
|
export declare function stripAcroForm(bytes: Uint8Array, views: View[]): Promise<Uint8Array>;
|
package/dist/strip.js
CHANGED
|
@@ -31,7 +31,7 @@ export const KINDS = {
|
|
|
31
31
|
catalog: 'Type Pages:tree OCProperties:layers StructTreeRoot:tags MarkInfo:marks Lang',
|
|
32
32
|
tree: 'Type Parent:^tree Kids:[node] Count Resources:resources Rotate',
|
|
33
33
|
page: 'Type Parent:^tree Resources:resources MediaBox Contents:[content] Rotate Group:group ' +
|
|
34
|
-
'
|
|
34
|
+
'StructParents Tabs Annots:[link]',
|
|
35
35
|
link: 'Type Subtype Rect Border BS:border C H QuadPoints F OC:oc Contents StructParent A:action ' +
|
|
36
36
|
'Dest:dest',
|
|
37
37
|
border: 'Type W S D',
|
|
@@ -239,22 +239,23 @@ const PICKS = new Map(Object.entries(PICK).map(([kind, [key, kinds, other]]) =>
|
|
|
239
239
|
* and an object reference whose `/Obj` is not such a link is taken out of its
|
|
240
240
|
* element's `/K`.
|
|
241
241
|
*
|
|
242
|
-
* An object that
|
|
243
|
-
* `views` nor a link that stays,
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
242
|
+
* An object that says `/Type /Annot`, `/Subtype /Widget` or `/Type /Page`, and
|
|
243
|
+
* is neither a page of `views` nor a link that stays, has no kind. A file in
|
|
244
|
+
* which a key reaches one as a kind is refused as `damaged`, the catalog itself
|
|
245
|
+
* included, and so is a file whose catalog or page tree pdf-lib cannot read. A
|
|
246
|
+
* marked-content property list may say `/Type /Page`, and a `/Rect` says
|
|
247
|
+
* nothing: a kept object loses the keys its row does not list. So no object of
|
|
248
|
+
* a background is a widget or a page outside the page tree, and the fields and
|
|
249
|
+
* appearance streams that hung off one are out of the walk's reach: a
|
|
250
|
+
* background that still carries a form layer is one the renderer would stamp a
|
|
251
|
+
* second one over. A dictionary written in place is not an object: one by name
|
|
252
|
+
* keeps the plain values a file writes in it, a widget's among them.
|
|
248
253
|
*/
|
|
249
254
|
export async function stripAcroForm(bytes, views) {
|
|
250
255
|
const doc = await PDFDocument.load(bytes, { updateMetadata: false });
|
|
251
256
|
const { context } = doc;
|
|
252
257
|
const at = (object, key) => object instanceof PDFDict ? object.lookup(PDFName.of(key)) : undefined;
|
|
253
258
|
const is = (object, key, name) => at(object, key) === PDFName.of(name);
|
|
254
|
-
/** An annotation need not say it is one: a `/Rect` or a `/Subtype /Widget` is enough. */
|
|
255
|
-
const annotation = (object) => is(object, 'Type', 'Annot') ||
|
|
256
|
-
at(object, 'Rect') !== undefined ||
|
|
257
|
-
is(object, 'Subtype', 'Widget');
|
|
258
259
|
/** The action a link that stays keeps, `null` when it holds its destination itself, `false` when it goes. */
|
|
259
260
|
const leads = (link) => {
|
|
260
261
|
if (!link.has(PDFName.of('A')))
|
|
@@ -274,7 +275,14 @@ export async function stripAcroForm(bytes, views) {
|
|
|
274
275
|
: '';
|
|
275
276
|
return /^https?:\/\//i.test(head) && action.clone();
|
|
276
277
|
};
|
|
277
|
-
|
|
278
|
+
// pdf-lib throws at a catalog or a page tree it does not read as one.
|
|
279
|
+
let pages;
|
|
280
|
+
try {
|
|
281
|
+
pages = doc.getPages();
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
throw new Refused({ code: 'damaged' });
|
|
285
|
+
}
|
|
278
286
|
const differs = pages.some(({ ref }, index) => {
|
|
279
287
|
const viewed = views[index]?.ref;
|
|
280
288
|
return viewed?.num !== ref.objectNumber || viewed.gen !== ref.generationNumber;
|
|
@@ -307,9 +315,6 @@ export async function stripAcroForm(bytes, views) {
|
|
|
307
315
|
}
|
|
308
316
|
context.trailerInfo.Info = undefined;
|
|
309
317
|
context.trailerInfo.ID = undefined;
|
|
310
|
-
for (const [ref, object] of context.enumerateIndirectObjects())
|
|
311
|
-
if (!kept.has(ref) && !linked.has(ref) && (is(object, 'Type', 'Page') || annotation(object)))
|
|
312
|
-
context.delete(ref);
|
|
313
318
|
const claimed = new Map();
|
|
314
319
|
const pending = [];
|
|
315
320
|
const whole = [];
|
|
@@ -353,11 +358,19 @@ export async function stripAcroForm(bytes, views) {
|
|
|
353
358
|
* Gives an object its kind and queues it, where this reach of it is as a kind.
|
|
354
359
|
* An object that has one is reached as that kind and no other, or the file is
|
|
355
360
|
* refused as `damaged`: the walk cuts it to the keys of the first, and a second
|
|
356
|
-
* would read what is left.
|
|
361
|
+
* would read what is left. So is a file that reaches as a kind an object that
|
|
362
|
+
* says it is an annotation or a page, and is neither a page of `views` nor a
|
|
363
|
+
* link that stays. Whether this reach of it stays.
|
|
357
364
|
*/
|
|
358
365
|
const claim = (raw, value, as) => {
|
|
359
366
|
const had = claimed.get(raw);
|
|
360
|
-
|
|
367
|
+
const posed = raw instanceof PDFRef &&
|
|
368
|
+
!kept.has(raw) &&
|
|
369
|
+
!linked.has(raw) &&
|
|
370
|
+
(is(value, 'Type', 'Annot') ||
|
|
371
|
+
is(value, 'Subtype', 'Widget') ||
|
|
372
|
+
(as !== slot('marked') && is(value, 'Type', 'Page')));
|
|
373
|
+
if ((had && had !== as) || (as && posed))
|
|
361
374
|
throw new Refused({ code: 'damaged' });
|
|
362
375
|
if (had || !as)
|
|
363
376
|
return as !== undefined;
|
|
@@ -438,10 +451,6 @@ export async function stripAcroForm(bytes, views) {
|
|
|
438
451
|
}
|
|
439
452
|
}
|
|
440
453
|
}
|
|
441
|
-
// A page tree node that reads as an annotation is deleted above, and the page
|
|
442
|
-
// tree holds the pages under it no more.
|
|
443
|
-
if (pages.some(({ ref }) => claimed.get(ref) !== slot('page')))
|
|
444
|
-
throw new Refused({ code: 'damaged' });
|
|
445
454
|
// A back reference stays when the walk reached what it names as its kind.
|
|
446
455
|
// Where the slot takes many, a value that leads nowhere stays too, and an
|
|
447
456
|
// array is read part by part, and so is each array in it. An array is read
|
package/dist/types.d.ts
CHANGED
|
@@ -88,14 +88,29 @@ export type QuillTree = Map<string, Uint8Array>;
|
|
|
88
88
|
export declare const MAX_BYTES: number;
|
|
89
89
|
/**
|
|
90
90
|
* The most a file may come to once qpdf has it: the file qpdf writes together
|
|
91
|
-
* with what its Flate streams decode to
|
|
92
|
-
* published files the largest comes to
|
|
93
|
-
* memory is 17,235,968 bytes after
|
|
91
|
+
* with what its Flate streams decode to and what its images declare, and
|
|
92
|
+
* qpdf's own memory. Of 1,423 published files the largest comes to
|
|
93
|
+
* 48,477,587 bytes (AF 4101), and qpdf's memory is 17,235,968 bytes after
|
|
94
|
+
* each. Their largest image is 6,026 by 4,523 pixels at a bit each, 3,410,342
|
|
95
|
+
* bytes (AF 1520), and the one that declares the most bytes is 2,322 by 2,916
|
|
96
|
+
* at three bytes each, 20,312,856 (AF 4101).
|
|
94
97
|
*/
|
|
95
98
|
export declare const MAX_INFLATED_BYTES: number;
|
|
96
99
|
/** The most pages and fields a form has and is taken. The engine takes at most 1,000 fields in a block. */
|
|
97
100
|
export declare const MAX_PAGES = 25;
|
|
98
101
|
export declare const MAX_WIDGETS = 1000;
|
|
102
|
+
/** The limits a consumer prints before a read. A refusal's own `limit` is the one to print after it: `too_large` carries `MAX_INFLATED_BYTES` for the decoded bound. */
|
|
103
|
+
export declare const limits: Readonly<{
|
|
104
|
+
bytes: number;
|
|
105
|
+
pages: number;
|
|
106
|
+
fields: number;
|
|
107
|
+
}>;
|
|
108
|
+
/**
|
|
109
|
+
* The longest side a page has and is taken, in points, and the farthest its
|
|
110
|
+
* box reaches from the origin: the PDF specification's limit for a side (ISO
|
|
111
|
+
* 32000-1, Annex C). Of 2,127 published pages the longest is 3,456 (AFMC 1656).
|
|
112
|
+
*/
|
|
113
|
+
export declare const MAX_SIDE = 14400;
|
|
99
114
|
/**
|
|
100
115
|
* The most pixels the pictures of a dynamic form come to, each use counted.
|
|
101
116
|
* At four bytes a pixel that is 32 MB on a canvas, and as much in the
|
package/dist/types.js
CHANGED
|
@@ -5,14 +5,29 @@ export const SNAKE_CASE = /^[a-z][a-z0-9_]*$/;
|
|
|
5
5
|
export const MAX_BYTES = 8 * 1024 * 1024;
|
|
6
6
|
/**
|
|
7
7
|
* The most a file may come to once qpdf has it: the file qpdf writes together
|
|
8
|
-
* with what its Flate streams decode to
|
|
9
|
-
* published files the largest comes to
|
|
10
|
-
* memory is 17,235,968 bytes after
|
|
8
|
+
* with what its Flate streams decode to and what its images declare, and
|
|
9
|
+
* qpdf's own memory. Of 1,423 published files the largest comes to
|
|
10
|
+
* 48,477,587 bytes (AF 4101), and qpdf's memory is 17,235,968 bytes after
|
|
11
|
+
* each. Their largest image is 6,026 by 4,523 pixels at a bit each, 3,410,342
|
|
12
|
+
* bytes (AF 1520), and the one that declares the most bytes is 2,322 by 2,916
|
|
13
|
+
* at three bytes each, 20,312,856 (AF 4101).
|
|
11
14
|
*/
|
|
12
15
|
export const MAX_INFLATED_BYTES = 64 * 1024 * 1024;
|
|
13
16
|
/** The most pages and fields a form has and is taken. The engine takes at most 1,000 fields in a block. */
|
|
14
17
|
export const MAX_PAGES = 25;
|
|
15
18
|
export const MAX_WIDGETS = 1000;
|
|
19
|
+
/** The limits a consumer prints before a read. A refusal's own `limit` is the one to print after it: `too_large` carries `MAX_INFLATED_BYTES` for the decoded bound. */
|
|
20
|
+
export const limits = Object.freeze({
|
|
21
|
+
bytes: MAX_BYTES,
|
|
22
|
+
pages: MAX_PAGES,
|
|
23
|
+
fields: MAX_WIDGETS
|
|
24
|
+
});
|
|
25
|
+
/**
|
|
26
|
+
* The longest side a page has and is taken, in points, and the farthest its
|
|
27
|
+
* box reaches from the origin: the PDF specification's limit for a side (ISO
|
|
28
|
+
* 32000-1, Annex C). Of 2,127 published pages the longest is 3,456 (AFMC 1656).
|
|
29
|
+
*/
|
|
30
|
+
export const MAX_SIDE = 14_400;
|
|
16
31
|
/**
|
|
17
32
|
* The most pixels the pictures of a dynamic form come to, each use counted.
|
|
18
33
|
* At four bytes a pixel that is 32 MB on a canvas, and as much in the
|
package/dist/xfa-layer.d.ts
CHANGED
|
@@ -19,10 +19,10 @@ export interface XfaPage {
|
|
|
19
19
|
* can zoom the frame, which the frame undoes, or `lay` throws.
|
|
20
20
|
*
|
|
21
21
|
* The frame runs no script, and its policy lets its document load only an
|
|
22
|
-
* image from `blob
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
22
|
+
* image from `blob:`, which is how pdf.js hands it a form's images, or from
|
|
23
|
+
* `data:`. A face of the form is not the document's to load: pdf.js makes it
|
|
24
|
+
* in the host page's realm, from the file's bytes, a `local()` name or a face
|
|
25
|
+
* the package ships.
|
|
26
26
|
*/
|
|
27
27
|
export declare class XfaStage {
|
|
28
28
|
#private;
|
package/dist/xfa-layer.js
CHANGED
|
@@ -21,10 +21,10 @@ const SHELL = `<meta http-equiv="Content-Security-Policy" content="default-src '
|
|
|
21
21
|
* can zoom the frame, which the frame undoes, or `lay` throws.
|
|
22
22
|
*
|
|
23
23
|
* The frame runs no script, and its policy lets its document load only an
|
|
24
|
-
* image from `blob
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
24
|
+
* image from `blob:`, which is how pdf.js hands it a form's images, or from
|
|
25
|
+
* `data:`. A face of the form is not the document's to load: pdf.js makes it
|
|
26
|
+
* in the host page's realm, from the file's bytes, a `local()` name or a face
|
|
27
|
+
* the package ships.
|
|
28
28
|
*/
|
|
29
29
|
export class XfaStage {
|
|
30
30
|
#frame = document.createElement('iframe');
|
|
@@ -61,6 +61,10 @@ export class XfaStage {
|
|
|
61
61
|
const view = frameDocument.defaultView;
|
|
62
62
|
const sheet = new view.CSSStyleSheet();
|
|
63
63
|
sheet.replaceSync(css);
|
|
64
|
+
// pdf.js's sheet gives a field out of focus a background from a data: URL,
|
|
65
|
+
// which nothing here reads and a host's policy may refuse, a logged error
|
|
66
|
+
// for each field.
|
|
67
|
+
sheet.insertRule(':root { --xfa-unfocused-field-background: none; }', sheet.cssRules.length);
|
|
64
68
|
frameDocument.adoptedStyleSheets = [sheet];
|
|
65
69
|
// A sheet that did not apply leaves every field at its content size,
|
|
66
70
|
// and the rects read off it are wrong with no error.
|
package/dist/xfa-widgets.js
CHANGED
|
@@ -68,6 +68,9 @@ export function harvestWidgets(page, pageNumber) {
|
|
|
68
68
|
const y = +(rect.top - origin.top).toFixed(2);
|
|
69
69
|
const w = +rect.width.toFixed(2);
|
|
70
70
|
const h = +rect.height.toFixed(2);
|
|
71
|
+
// pdf.js gives a control the place its layout comes to, on the page or not.
|
|
72
|
+
if (x >= page.width || y >= page.height || x + w <= 0 || y + h <= 0)
|
|
73
|
+
throw new Refused({ code: 'layout', detail: 'a field is off its page' });
|
|
71
74
|
const tooltip = tooltipOf(el);
|
|
72
75
|
const options = tag === 'select'
|
|
73
76
|
? [...el.options]
|
package/dist/xfa.js
CHANGED
|
@@ -44,8 +44,9 @@ export async function xfaToAcroForm(bytes, signal) {
|
|
|
44
44
|
for (const span of laid.layer.querySelectorAll('.xfaPageCount'))
|
|
45
45
|
span.textContent = String(pdf.numPages);
|
|
46
46
|
const list = displayList(laid, number);
|
|
47
|
-
for (const
|
|
48
|
-
|
|
47
|
+
for (const item of list.drawn)
|
|
48
|
+
if ('image' in item)
|
|
49
|
+
pixels += item.image.naturalWidth * item.image.naturalHeight;
|
|
49
50
|
if (pixels > MAX_PICTURE_PIXELS)
|
|
50
51
|
throw new Refused({
|
|
51
52
|
code: 'unpaintable',
|
|
@@ -73,7 +74,36 @@ export async function xfaToAcroForm(bytes, signal) {
|
|
|
73
74
|
/** PDF's origin is bottom-left; every primitive arrives top-left. */
|
|
74
75
|
async function paint(page, pageNumber, list, font) {
|
|
75
76
|
const H = list.height;
|
|
76
|
-
for (
|
|
77
|
+
for (const item of list.drawn) {
|
|
78
|
+
if ('text' in item) {
|
|
79
|
+
await drawRun(page, pageNumber, item, H, font);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if ('image' in item) {
|
|
83
|
+
const { x, y, w, h, image } = item;
|
|
84
|
+
// The pixels are read off a canvas of the host page's. The frame's policy
|
|
85
|
+
// governs what its document loads, and the picture is of the page's origin.
|
|
86
|
+
const canvas = document.createElement('canvas');
|
|
87
|
+
canvas.width = image.naturalWidth;
|
|
88
|
+
canvas.height = image.naturalHeight;
|
|
89
|
+
canvas.getContext('2d').drawImage(image, 0, 0);
|
|
90
|
+
const png = canvas.toDataURL('image/png');
|
|
91
|
+
// What a canvas with a side the browser will not make gives.
|
|
92
|
+
if (png === 'data:,')
|
|
93
|
+
throw new Refused({ code: 'unpaintable', page: pageNumber - 1, construct: 'image' });
|
|
94
|
+
page.drawImage(await page.doc.embedPng(png), { x, y: H - y - h, width: w, height: h });
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if ('x1' in item) {
|
|
98
|
+
page.drawLine({
|
|
99
|
+
start: { x: item.x1, y: H - item.y1 },
|
|
100
|
+
end: { x: item.x2, y: H - item.y2 },
|
|
101
|
+
thickness: item.width,
|
|
102
|
+
color: rgb(...item.colour)
|
|
103
|
+
});
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
let box = item;
|
|
77
107
|
if (box.radius) {
|
|
78
108
|
// An SVG rect's outline runs along the middle of its stroke, and each
|
|
79
109
|
// radius stops at half the side it lies along.
|
|
@@ -125,27 +155,6 @@ async function paint(page, pageNumber, list, font) {
|
|
|
125
155
|
if (right)
|
|
126
156
|
seg(box.x + box.w - right.width / 2, box.y, box.x + box.w - right.width / 2, box.y + box.h, right);
|
|
127
157
|
}
|
|
128
|
-
for (const { x, y, w, h, image } of list.pictures) {
|
|
129
|
-
// The pixels are read off a canvas of the host page's. The frame's policy
|
|
130
|
-
// governs what its document loads, and the picture is of the page's origin.
|
|
131
|
-
const canvas = document.createElement('canvas');
|
|
132
|
-
canvas.width = image.naturalWidth;
|
|
133
|
-
canvas.height = image.naturalHeight;
|
|
134
|
-
canvas.getContext('2d').drawImage(image, 0, 0);
|
|
135
|
-
const png = canvas.toDataURL('image/png');
|
|
136
|
-
// What a canvas with a side the browser will not make gives.
|
|
137
|
-
if (png === 'data:,')
|
|
138
|
-
throw new Refused({ code: 'unpaintable', page: pageNumber - 1, construct: 'image' });
|
|
139
|
-
page.drawImage(await page.doc.embedPng(png), { x, y: H - y - h, width: w, height: h });
|
|
140
|
-
}
|
|
141
|
-
for (const line of list.lines)
|
|
142
|
-
page.drawLine({
|
|
143
|
-
start: { x: line.x1, y: H - line.y1 },
|
|
144
|
-
end: { x: line.x2, y: H - line.y2 },
|
|
145
|
-
thickness: line.width,
|
|
146
|
-
color: rgb(...line.colour)
|
|
147
|
-
});
|
|
148
|
-
return drawRuns(page, pageNumber, list.runs, H, font);
|
|
149
158
|
}
|
|
150
159
|
/**
|
|
151
160
|
* The dash and the gap the browser paints along a dashed edge, or nothing for
|
|
@@ -168,53 +177,70 @@ function dashes(width, length) {
|
|
|
168
177
|
const [wide, narrow] = [fit(count), fit(count + 1)];
|
|
169
178
|
return [dash, narrow <= 0 || Math.abs(wide - gap) < Math.abs(narrow - gap) ? wide : narrow];
|
|
170
179
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
180
|
+
/** The standard fonts that hold what the text fonts lack, in the order a character is looked for. */
|
|
181
|
+
const SYMBOLS = [StandardFonts.Symbol, StandardFonts.ZapfDingbats];
|
|
182
|
+
async function drawRun(page, pageNumber, run, H, get) {
|
|
183
|
+
const font = await get(standard14(run));
|
|
184
|
+
// The standard-14 text fonts encode WinAnsi alone. U+2010 and U+2011 are
|
|
185
|
+
// drawn as its hyphen, and any other character outside it in the first of
|
|
186
|
+
// SYMBOLS that holds it. A soft hyphen is left out: WinAnsi has it as a
|
|
187
|
+
// hyphen, and the browser lays it out at no width and shows nothing.
|
|
188
|
+
// Dropping the run would lose a line of the form without anything
|
|
189
|
+
// reporting it.
|
|
190
|
+
// pdf-lib kerns the width it measures and writes no kerning, so the width
|
|
191
|
+
// it draws a run at is the sum of the widths of its characters.
|
|
192
|
+
const parts = [];
|
|
193
|
+
let natural = 0;
|
|
194
|
+
for (const char of run.text.replaceAll('\u00ad', '').replace(/[\u2010\u2011]/g, '-')) {
|
|
195
|
+
const code = char.codePointAt(0);
|
|
196
|
+
let face = font;
|
|
197
|
+
for (const name of SYMBOLS)
|
|
198
|
+
if (!face.getCharacterSet().includes(code))
|
|
199
|
+
face = await get(name);
|
|
200
|
+
if (!face.getCharacterSet().includes(code))
|
|
178
201
|
throw new Refused({
|
|
179
202
|
code: 'unpaintable',
|
|
180
203
|
page: pageNumber - 1,
|
|
181
204
|
construct: 'glyph',
|
|
182
205
|
detail: run.text.slice(0, 40)
|
|
183
206
|
});
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
207
|
+
const last = parts.at(-1);
|
|
208
|
+
if (last?.font === face)
|
|
209
|
+
last.text += char;
|
|
210
|
+
else
|
|
211
|
+
parts.push({ font: face, text: char, at: natural });
|
|
212
|
+
natural += face.widthOfTextAtSize(char, run.size) + run.letterSpacing;
|
|
213
|
+
}
|
|
214
|
+
// The standard-14 substitute's advances differ from the embedded face's.
|
|
215
|
+
// Letter spacing goes in first, since it is a real property of the run;
|
|
216
|
+
// horizontal scaling then pins the whole run to the width the browser
|
|
217
|
+
// measured, so the error stays inside the run instead of drifting across
|
|
218
|
+
// the line.
|
|
219
|
+
const spaced = natural - run.letterSpacing;
|
|
220
|
+
const scale = spaced > 0 && run.width > 0 ? Math.max(50, Math.min(200, (100 * run.width) / spaced)) : 100;
|
|
221
|
+
const scaled = Math.abs(scale - 100) > 3;
|
|
222
|
+
// Both are text-state parameters, so they persist across drawText's own
|
|
223
|
+
// BT/ET and have to be put back.
|
|
224
|
+
if (run.letterSpacing)
|
|
225
|
+
page.pushOperators(PDFOperator.of(PDFOperatorNames.SetCharacterSpacing, [String(+run.letterSpacing.toFixed(4))]));
|
|
226
|
+
if (scaled)
|
|
227
|
+
page.pushOperators(PDFOperator.of(PDFOperatorNames.SetTextHorizontalScaling, [String(+scale.toFixed(3))]));
|
|
228
|
+
const angle = (Math.PI / 2) * (run.turn ?? 0);
|
|
229
|
+
for (const part of parts) {
|
|
230
|
+
const along = scaled ? (part.at * scale) / 100 : part.at;
|
|
231
|
+
page.drawText(part.text, {
|
|
232
|
+
x: run.x + along * Math.cos(angle),
|
|
233
|
+
y: H - run.baseline + along * Math.sin(angle),
|
|
208
234
|
size: run.size,
|
|
209
|
-
font,
|
|
235
|
+
font: part.font,
|
|
210
236
|
color: rgb(...run.colour),
|
|
211
237
|
rotate: degrees(90 * (run.turn ?? 0))
|
|
212
238
|
});
|
|
213
|
-
if (scaled)
|
|
214
|
-
page.pushOperators(PDFOperator.of(PDFOperatorNames.SetTextHorizontalScaling, ['100']));
|
|
215
|
-
if (run.letterSpacing)
|
|
216
|
-
page.pushOperators(PDFOperator.of(PDFOperatorNames.SetCharacterSpacing, ['0']));
|
|
217
239
|
}
|
|
240
|
+
if (scaled)
|
|
241
|
+
page.pushOperators(PDFOperator.of(PDFOperatorNames.SetTextHorizontalScaling, ['100']));
|
|
242
|
+
if (run.letterSpacing)
|
|
243
|
+
page.pushOperators(PDFOperator.of(PDFOperatorNames.SetCharacterSpacing, ['0']));
|
|
218
244
|
}
|
|
219
245
|
function standard14(run) {
|
|
220
246
|
const family = run.family.toLowerCase();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quillmark/quillify",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Turn a published PDF form (AcroForm or XFA) into a Quillmark quill, in the browser.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"@quillmark/wasm": "0.124.0",
|
|
52
52
|
"@types/node": "24.19.0",
|
|
53
53
|
"@vitest/browser-playwright": "5.0.2",
|
|
54
|
+
"fast-check": "4.10.2",
|
|
54
55
|
"playwright": "1.63.0",
|
|
55
56
|
"prettier": "3.9.9",
|
|
56
57
|
"typescript": "6.0.3",
|