@quillmark/quillify 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +1 -1
- package/README.md +2 -1
- package/assets/qpdf.worker.js +9 -2
- package/dist/api.d.ts +1 -0
- package/dist/assemble.d.ts +1 -1
- package/dist/assemble.js +2 -13
- package/dist/display-list.d.ts +17 -14
- package/dist/display-list.js +159 -120
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -0
- package/dist/inventory.js +6 -2
- package/dist/naming.js +2 -0
- package/dist/normalize.js +158 -14
- package/dist/pdfjs.js +3 -2
- package/dist/quillify.js +2 -2
- package/dist/strip.d.ts +11 -6
- package/dist/strip.js +30 -21
- package/dist/types.d.ts +20 -6
- 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 +87 -60
- package/package.json +3 -2
package/NOTICE
CHANGED
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/api.d.ts
CHANGED
package/dist/assemble.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { InventoryWidget, QualifiedField, QualifiedForm, QuillTree } from './types.js';
|
|
2
|
-
/** What the assembly reads of a field, of the form and of a widget. Its tests hand it a title, which it does not read. */
|
|
2
|
+
/** What the assembly reads of a field, of the form and of a widget. Its tests hand it a quill title, which it does not read. */
|
|
3
3
|
type Named = Omit<QualifiedField, 'label'>;
|
|
4
4
|
type Qualified = {
|
|
5
5
|
quill: Pick<QualifiedForm['quill'], 'name' | 'description'> & {
|
package/dist/assemble.js
CHANGED
|
@@ -6,23 +6,15 @@ export const QUILL_VERSION = '0.1.0';
|
|
|
6
6
|
* (U+007F to U+009F, U+FFFE and U+FFFF) are written as `\u` escapes.
|
|
7
7
|
*/
|
|
8
8
|
const q = (s) => JSON.stringify(s.toWellFormed()).replace(/[\u007f-\u009f\ufffe\uffff]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
|
9
|
-
/** A group id is snake_case; a radio group's PDF name is whatever its author typed. */
|
|
10
|
-
function groupId(name) {
|
|
11
|
-
const id = name
|
|
12
|
-
.toLowerCase()
|
|
13
|
-
.replace(/[^a-z0-9]+/g, '_')
|
|
14
|
-
.replace(/^_+|_+$/g, '');
|
|
15
|
-
return /^[a-z]/.test(id) ? id : `group_${id}`;
|
|
16
|
-
}
|
|
17
9
|
/**
|
|
18
10
|
* A `default:` says nobody need answer the field. A required field declares
|
|
19
11
|
* none, a boolean apart: it defaults to false either way.
|
|
20
12
|
*/
|
|
21
13
|
function fieldYaml(field) {
|
|
22
14
|
const lines = [` ${q(field.name)}:`];
|
|
15
|
+
if (field.title !== undefined)
|
|
16
|
+
lines.push(` title: ${q(field.title)}`);
|
|
23
17
|
const ui = [];
|
|
24
|
-
if (field.group)
|
|
25
|
-
ui.push(` group: ${q(groupId(field.group))}`);
|
|
26
18
|
switch (field.type) {
|
|
27
19
|
case 'string':
|
|
28
20
|
lines.push(' type: string');
|
|
@@ -74,9 +66,6 @@ export function quillYaml(form, fields) {
|
|
|
74
66
|
' body:',
|
|
75
67
|
' enabled: false'
|
|
76
68
|
];
|
|
77
|
-
const groups = [...new Set(fields.flatMap((f) => (f.group ? [groupId(f.group)] : [])))];
|
|
78
|
-
if (groups.length > 0)
|
|
79
|
-
lines.push(' ui:', ` groups: [${groups.map(q).join(', ')}]`);
|
|
80
69
|
if (fields.length > 0) {
|
|
81
70
|
lines.push(' fields:');
|
|
82
71
|
for (const field of fields)
|
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,109 +268,111 @@ 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
|
|
238
275
|
* where the rect top changes is what keeps a wrapped paragraph's second line at
|
|
239
276
|
* its own baseline. A tab ends a run too and is in none: the text after it
|
|
240
|
-
* starts a run at the box the layout moved it to.
|
|
277
|
+
* starts a run at the box the layout moved it to. A soft hyphen the layout
|
|
278
|
+
* breaks a line at is a hyphen in the run it ends, which is measured with it.
|
|
241
279
|
*/
|
|
242
|
-
function collectText(
|
|
243
|
-
|
|
244
|
-
const
|
|
280
|
+
function collectText(node, X, Y, drawn, turns) {
|
|
281
|
+
let text = node.nodeValue ?? '';
|
|
282
|
+
const parent = node.parentElement;
|
|
283
|
+
if (!text.trim() || !parent)
|
|
284
|
+
return;
|
|
285
|
+
// A <select> is de-chromed above; only the option the form shows is drawn.
|
|
286
|
+
if (parent.tagName === 'OPTION' && !parent.selected)
|
|
287
|
+
return;
|
|
288
|
+
const owner = node.ownerDocument;
|
|
289
|
+
const style = owner.defaultView.getComputedStyle(parent);
|
|
290
|
+
if (style.visibility === 'hidden' || style.display === 'none')
|
|
291
|
+
return;
|
|
245
292
|
const measure = owner.createElement('canvas').getContext('2d');
|
|
246
|
-
const walker = owner.createTreeWalker(layer, NodeFilter.SHOW_TEXT);
|
|
247
293
|
const range = owner.createRange();
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
294
|
+
const size = parseFloat(style.fontSize);
|
|
295
|
+
measure.font = `${style.fontStyle} ${style.fontWeight} ${size}px ${style.fontFamily}`;
|
|
296
|
+
const descent = measure.measureText('Hg').fontBoundingBoxDescent ?? size * 0.21;
|
|
297
|
+
const common = {
|
|
298
|
+
size: +size.toFixed(2),
|
|
299
|
+
family: style.fontFamily,
|
|
300
|
+
bold: parseInt(style.fontWeight, 10) >= 600 || style.fontWeight === 'bold',
|
|
301
|
+
italic: style.fontStyle === 'italic' || style.fontStyle === 'oblique',
|
|
302
|
+
colour: rgb(style.color),
|
|
303
|
+
letterSpacing: parseFloat(style.letterSpacing) || 0
|
|
304
|
+
};
|
|
305
|
+
// Under a turn each rect is read in the frame the text is upright in, and
|
|
306
|
+
// the start of each line's baseline is turned back to the page.
|
|
307
|
+
const turn = turns.get(parent);
|
|
308
|
+
const [cos, sin] = [
|
|
309
|
+
[1, 0],
|
|
310
|
+
[0, -1],
|
|
311
|
+
[-1, 0],
|
|
312
|
+
[0, 1]
|
|
313
|
+
][turn];
|
|
314
|
+
const upright = ({ left, right, top, bottom }) => {
|
|
315
|
+
const [x1, x2] = [cos * left + sin * top, cos * right + sin * bottom];
|
|
316
|
+
const [y1, y2] = [cos * top - sin * left, cos * bottom - sin * right];
|
|
317
|
+
return {
|
|
318
|
+
x: Math.min(x1, x2),
|
|
319
|
+
right: Math.max(x1, x2),
|
|
320
|
+
top: Math.min(y1, y2),
|
|
321
|
+
bottom: Math.max(y1, y2)
|
|
322
|
+
};
|
|
323
|
+
};
|
|
324
|
+
let start = 0;
|
|
325
|
+
let top = null;
|
|
326
|
+
let box = null;
|
|
327
|
+
const flush = (end) => {
|
|
328
|
+
const slice = text.slice(start, end);
|
|
329
|
+
if (end <= start || !box || !slice.trim())
|
|
330
|
+
return;
|
|
331
|
+
const base = box.bottom - descent;
|
|
332
|
+
drawn.push({
|
|
333
|
+
text: slice,
|
|
334
|
+
x: X(cos * box.x - sin * base),
|
|
335
|
+
baseline: Y(sin * box.x + cos * base),
|
|
336
|
+
width: +(box.right - box.x).toFixed(2),
|
|
337
|
+
turn: turn || undefined,
|
|
338
|
+
...common
|
|
339
|
+
});
|
|
340
|
+
};
|
|
341
|
+
for (let i = 0; i < text.length; i++) {
|
|
342
|
+
if (text[i] === '\t') {
|
|
343
|
+
flush(i);
|
|
344
|
+
start = i + 1;
|
|
345
|
+
top = null;
|
|
346
|
+
box = null;
|
|
255
347
|
continue;
|
|
256
|
-
|
|
257
|
-
|
|
348
|
+
}
|
|
349
|
+
range.setStart(node, i);
|
|
350
|
+
range.setEnd(node, i + 1);
|
|
351
|
+
// At a break the soft hyphen has an empty rect and then the box of the
|
|
352
|
+
// hyphen shown, and the letter after it has that box and then its own.
|
|
353
|
+
const rects = range.getClientRects();
|
|
354
|
+
const rect = rects[rects.length - 1];
|
|
355
|
+
if (!rect)
|
|
258
356
|
continue;
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
}
|
|
357
|
+
const glyph = upright(rect);
|
|
358
|
+
if (text[i] === '\u00ad' && glyph.right > glyph.x)
|
|
359
|
+
text = `${text.slice(0, i)}-${text.slice(i + 1)}`;
|
|
360
|
+
if (top === null || Math.abs(glyph.top - top) < 0.6) {
|
|
361
|
+
top ??= glyph.top;
|
|
362
|
+
box = box
|
|
363
|
+
? {
|
|
364
|
+
x: Math.min(box.x, glyph.x),
|
|
365
|
+
bottom: Math.max(box.bottom, glyph.bottom),
|
|
366
|
+
right: Math.max(box.right, glyph.right)
|
|
367
|
+
}
|
|
368
|
+
: { x: glyph.x, bottom: glyph.bottom, right: glyph.right };
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
flush(i);
|
|
372
|
+
start = i;
|
|
373
|
+
top = glyph.top;
|
|
374
|
+
box = { x: glyph.x, bottom: glyph.bottom, right: glyph.right };
|
|
336
375
|
}
|
|
337
|
-
flush(text.length);
|
|
338
376
|
}
|
|
377
|
+
flush(text.length);
|
|
339
378
|
}
|
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/naming.js
CHANGED
|
@@ -74,6 +74,8 @@ function shaped({ name, description, fields }) {
|
|
|
74
74
|
not(`${slot}.name`, 'a string');
|
|
75
75
|
if (typeof entry.description !== 'string')
|
|
76
76
|
not(`${slot}.description`, 'a string');
|
|
77
|
+
if (entry.title !== undefined && typeof entry.title !== 'string')
|
|
78
|
+
not(`${slot}.title`, 'a string');
|
|
77
79
|
if (entry.example !== undefined && typeof entry.example !== 'string')
|
|
78
80
|
not(`${slot}.example`, 'a string');
|
|
79
81
|
if (entry.text !== undefined && !TEXT.has(entry.text))
|