@quillmark/quillify 0.0.0-stage → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +85 -0
  3. package/README.md +56 -2
  4. package/assets/LiberationSans-Bold.ttf +0 -0
  5. package/assets/LiberationSans-BoldItalic.ttf +0 -0
  6. package/assets/LiberationSans-Italic.ttf +0 -0
  7. package/assets/LiberationSans-Regular.ttf +0 -0
  8. package/assets/pdf.worker.js +64908 -0
  9. package/assets/qpdf.wasm +0 -0
  10. package/assets/qpdf.worker.js +155 -0
  11. package/assets/xfa-layer.d.ts +2 -0
  12. package/assets/xfa-layer.js +1 -0
  13. package/dist/api.d.ts +83 -0
  14. package/dist/api.js +1 -0
  15. package/dist/assemble.d.ts +21 -0
  16. package/dist/assemble.js +155 -0
  17. package/dist/coords.d.ts +7 -0
  18. package/dist/coords.js +28 -0
  19. package/dist/display-list.d.ts +87 -0
  20. package/dist/display-list.js +372 -0
  21. package/dist/index.d.ts +6 -0
  22. package/dist/index.js +4 -0
  23. package/dist/inventory.d.ts +4 -0
  24. package/dist/inventory.js +179 -0
  25. package/dist/naming.d.ts +3 -0
  26. package/dist/naming.js +116 -0
  27. package/dist/normalize.d.ts +39 -0
  28. package/dist/normalize.js +303 -0
  29. package/dist/pdfjs.d.ts +17 -0
  30. package/dist/pdfjs.js +96 -0
  31. package/dist/quillify.d.ts +13 -0
  32. package/dist/quillify.js +86 -0
  33. package/dist/refusal.d.ts +47 -0
  34. package/dist/refusal.js +8 -0
  35. package/dist/sanitize.d.ts +20 -0
  36. package/dist/sanitize.js +72 -0
  37. package/dist/strip.d.ts +82 -0
  38. package/dist/strip.js +533 -0
  39. package/dist/types.d.ts +125 -0
  40. package/dist/types.js +42 -0
  41. package/dist/xfa-layer.d.ts +40 -0
  42. package/dist/xfa-layer.js +143 -0
  43. package/dist/xfa-widgets.d.ts +36 -0
  44. package/dist/xfa-widgets.js +131 -0
  45. package/dist/xfa.d.ts +15 -0
  46. package/dist/xfa.js +371 -0
  47. package/licenses/LiberationSans-LICENSE +102 -0
  48. package/licenses/compiler-rt-LICENSE +311 -0
  49. package/licenses/emscripten-LICENSE +102 -0
  50. package/licenses/libcxx-LICENSE +311 -0
  51. package/licenses/libcxxabi-LICENSE +311 -0
  52. package/licenses/libjpeg-turbo-LICENSE +132 -0
  53. package/licenses/libjpeg-turbo-README.ijg +258 -0
  54. package/licenses/musl-COPYRIGHT +193 -0
  55. package/licenses/pdf.js-LICENSE +177 -0
  56. package/licenses/qpdf-LICENSE +202 -0
  57. package/licenses/qpdf-NOTICE +42 -0
  58. package/licenses/zlib-README +118 -0
  59. package/package.json +70 -5
@@ -0,0 +1,372 @@
1
+ import { Refused } from './refusal.js';
2
+ import { square } from './xfa-widgets.js';
3
+ const TRANSPARENT = /rgba\(.*,\s*0\)$/;
4
+ const PAINTED_BORDER = new Set(['solid', 'double', 'groove', 'ridge', 'inset', 'outset']);
5
+ function opaque(colour) {
6
+ return !!colour && colour !== 'transparent' && !TRANSPARENT.test(colour);
7
+ }
8
+ function rgb(colour) {
9
+ const m = /rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(colour);
10
+ return m ? [+m[1] / 255, +m[2] / 255, +m[3] / 255] : [0, 0, 0];
11
+ }
12
+ /** A stroke the browser does not paint. `stroke-width: 0` computes to `0px`. */
13
+ function stroked(style) {
14
+ const width = parseFloat(style.strokeWidth);
15
+ if (!Number.isFinite(width) || width <= 0)
16
+ return null;
17
+ if (style.stroke === 'none' || !opaque(style.stroke))
18
+ return null;
19
+ return { width, colour: rgb(style.stroke) };
20
+ }
21
+ /**
22
+ * Turn a laid-out XFA page into primitives in page points. The browser solved
23
+ * the layout, and the rects here are read off it.
24
+ *
25
+ * What it can express is text, CSS backgrounds and borders, pictures, and
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.
34
+ */
35
+ export function displayList(page, pageNumber) {
36
+ const { layer, origin } = page;
37
+ const view = layer.ownerDocument.defaultView;
38
+ const drawn = [];
39
+ // The quarter turns each element is laid out under, its own and its ancestors'.
40
+ const turns = new Map([[layer, 0]]);
41
+ const X = (v) => +(v - origin.left).toFixed(2);
42
+ const Y = (v) => +(v - origin.top).toFixed(2);
43
+ const refuse = (construct, detail) => {
44
+ throw new Refused({ code: 'unpaintable', page: pageNumber - 1, construct, detail });
45
+ };
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;
83
+ const tag = el.tagName.toLowerCase();
84
+ const style = view.getComputedStyle(el);
85
+ const own = quarterTurns(style.transform);
86
+ const turn = (turns.get(el.parentElement) + own) % 4;
87
+ turns.set(el, turn);
88
+ if (tag === 'svg' || tag === 'option' || tag === 'g' || tag === 'defs')
89
+ continue;
90
+ if (style.visibility === 'hidden' || style.display === 'none')
91
+ continue;
92
+ if (tag === 'img' || el.classList.contains('xfaImage')) {
93
+ const image = el;
94
+ // A picture that did not load has no size.
95
+ if (!image.naturalWidth || !image.naturalHeight)
96
+ refuse('image');
97
+ if (turn || unrepresentable(style.transform))
98
+ refuse('transform');
99
+ const rect = image.getBoundingClientRect();
100
+ if (rect.width < 0.3 || rect.height < 0.3)
101
+ continue;
102
+ // pdf.js stretches a picture to its box where the template says so. Any
103
+ // other the stylesheet fits into the box whole, from the top left.
104
+ const fit = style.objectFit === 'contain'
105
+ ? Math.min(rect.width / image.naturalWidth, rect.height / image.naturalHeight)
106
+ : 0;
107
+ drawn.push({
108
+ x: X(rect.x),
109
+ y: Y(rect.y),
110
+ w: +(fit ? image.naturalWidth * fit : rect.width).toFixed(2),
111
+ h: +(fit ? image.naturalHeight * fit : rect.height).toFixed(2),
112
+ image
113
+ });
114
+ continue;
115
+ }
116
+ if (tag === 'line' || tag === 'rect' || tag === 'ellipse' || tag === 'circle') {
117
+ const svg = el.ownerSVGElement;
118
+ if (!svg)
119
+ continue;
120
+ const edge = stroked(style);
121
+ if (tag === 'line') {
122
+ if (!edge)
123
+ continue;
124
+ if (turn)
125
+ refuse('transform');
126
+ const svgRect = svg.getBoundingClientRect();
127
+ // pdf.js writes these as percentages of the svg box ("100%", "50%").
128
+ const at = (key, span) => {
129
+ const raw = el.getAttribute(key) ?? '0';
130
+ const value = parseFloat(raw);
131
+ if (!Number.isFinite(value))
132
+ return 0;
133
+ return raw.trim().endsWith('%') ? (span * value) / 100 : value;
134
+ };
135
+ drawn.push({
136
+ x1: X(svgRect.x + at('x1', svgRect.width)),
137
+ y1: Y(svgRect.y + at('y1', svgRect.height)),
138
+ x2: X(svgRect.x + at('x2', svgRect.width)),
139
+ y2: Y(svgRect.y + at('y2', svgRect.height)),
140
+ width: edge.width,
141
+ colour: edge.colour
142
+ });
143
+ }
144
+ else {
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.
147
+ if (!el.style.fill && !el.hasAttribute('fill'))
148
+ refuse('background_image');
149
+ // A shape whose edge is hidden has a stroke of no width.
150
+ const fill = style.fill !== 'none' && opaque(style.fill) ? rgb(style.fill) : null;
151
+ if (!edge && !fill)
152
+ continue;
153
+ // SVG centers a stroke on the shape's edge, and a box's border is
154
+ // drawn inside it, so the box is half a stroke larger all round. What
155
+ // that adds to the fill lies under the stroke.
156
+ const half = (edge?.width ?? 0) / 2;
157
+ const r = el.getBoundingClientRect();
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({
165
+ x: X(r.x - half),
166
+ y: Y(r.y - half),
167
+ w: +(r.width + 2 * half).toFixed(2),
168
+ h: +(r.height + 2 * half).toFixed(2),
169
+ fill,
170
+ edges: [edge, edge, edge, edge],
171
+ radius: radius > 0 ? radius : undefined
172
+ });
173
+ }
174
+ continue;
175
+ }
176
+ if (svgOnly(tag))
177
+ refuse('svg', tag);
178
+ const rect = el.getBoundingClientRect();
179
+ if (rect.width < 0.3 || rect.height < 0.3)
180
+ continue;
181
+ // A control's own chrome belongs to the browser, and an AcroForm widget is
182
+ // stamped where the control was. The printed square a checkmark goes in is
183
+ // the exception: nothing else draws it. It is drawn where the browser
184
+ // paints the control, which is where the widget is stamped.
185
+ const type = el.getAttribute('type')?.toLowerCase();
186
+ if (tag === 'input' || tag === 'textarea' || tag === 'select') {
187
+ // A field is stamped upright, so a turned one would fill the wrong way.
188
+ if (turn)
189
+ refuse('transform');
190
+ if (type === 'checkbox' || type === 'radio') {
191
+ const painted = square(rect);
192
+ const hair = { width: 1, colour: [0, 0, 0] };
193
+ drawn.push({
194
+ x: X(painted.left),
195
+ y: Y(painted.top),
196
+ w: +painted.width.toFixed(2),
197
+ h: +painted.height.toFixed(2),
198
+ fill: null,
199
+ edges: [hair, hair, hair, hair]
200
+ });
201
+ }
202
+ continue;
203
+ }
204
+ if (style.backgroundImage !== 'none')
205
+ refuse('background_image');
206
+ // pdf.js turns an element about its top left corner, which is where a
207
+ // template turns one it places. One a row or a column flows is left where
208
+ // it flowed, turned out of its cell.
209
+ if (own ? style.position !== 'absolute' : unrepresentable(style.transform))
210
+ refuse('transform');
211
+ const sides = ['Top', 'Right', 'Bottom', 'Left'];
212
+ const edges = sides.map((side) => {
213
+ const width = parseFloat(style.getPropertyValue(`border-${side.toLowerCase()}-width`)) || 0;
214
+ const kind = style.getPropertyValue(`border-${side.toLowerCase()}-style`);
215
+ const colour = style.getPropertyValue(`border-${side.toLowerCase()}-color`);
216
+ if (width <= 0 || kind === 'none' || kind === 'hidden' || !opaque(colour))
217
+ return null;
218
+ if (kind === 'dashed')
219
+ return { width, colour: rgb(colour), dashed: true };
220
+ if (!PAINTED_BORDER.has(kind))
221
+ refuse('border', kind);
222
+ return { width, colour: rgb(colour) };
223
+ });
224
+ // A quarter turn counter-clockwise has the element's right edge at the top.
225
+ for (let quarter = turn; quarter > 0; quarter--)
226
+ edges.push(edges.shift());
227
+ const fill = opaque(style.backgroundColor) ? rgb(style.backgroundColor) : null;
228
+ if (fill || edges.some(Boolean))
229
+ drawn.push({
230
+ x: X(rect.x),
231
+ y: Y(rect.y),
232
+ w: +rect.width.toFixed(2),
233
+ h: +rect.height.toFixed(2),
234
+ fill,
235
+ edges
236
+ });
237
+ }
238
+ return { drawn, width: page.width, height: page.height };
239
+ }
240
+ /**
241
+ * How many quarter turns counter-clockwise a transform is, and 0 for any
242
+ * other. pdf.js writes a template's `rotate="90"` as `rotate(-90deg)`.
243
+ */
244
+ function quarterTurns(transform) {
245
+ return ([
246
+ 'matrix(0, -1, 1, 0, 0, 0)',
247
+ 'matrix(-1, 0, 0, -1, 0, 0)',
248
+ 'matrix(0, 1, -1, 0, 0, 0)'
249
+ ].indexOf(transform) + 1);
250
+ }
251
+ /**
252
+ * True for a transform a run's box cannot carry. XFA offsets caption
253
+ * wrappers with a translation, which the rects already hold. A positive
254
+ * horizontal scale is carried too: `getClientRects` reports the scaled width and
255
+ * `drawRun` puts it back as `Tz`, which is how LiveCycle's
256
+ * `xfa-font-horizontal-scale` reaches the page. A rotation, a skew, a mirror or
257
+ * a vertical scale changes glyph shape or height, and a run carries neither. A
258
+ * quarter turn is read apart, by `quarterTurns`.
259
+ */
260
+ export function unrepresentable(transform) {
261
+ if (transform === 'none')
262
+ return false;
263
+ const parts = /^matrix\(([^)]*)\)$/.exec(transform)?.[1].split(',').map(Number);
264
+ if (!parts || parts.length !== 6)
265
+ return true;
266
+ const [a, b, c, d] = parts;
267
+ return b !== 0 || c !== 0 || d !== 1 || a <= 0;
268
+ }
269
+ /** SVG shapes pdf.js can emit that the primitives above have no form for. */
270
+ function svgOnly(tag) {
271
+ return ['path', 'polygon', 'polyline', 'image', 'use', 'text'].includes(tag);
272
+ }
273
+ /**
274
+ * One run per visual line, with the box the browser measured it into. Splitting
275
+ * where the rect top changes is what keeps a wrapped paragraph's second line at
276
+ * its own baseline. A tab ends a run too and is in none: the text after it
277
+ * starts a run at the box the layout moved it to.
278
+ */
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;
291
+ const measure = owner.createElement('canvas').getContext('2d');
292
+ const range = owner.createRange();
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;
346
+ continue;
347
+ }
348
+ range.setStart(node, i);
349
+ range.setEnd(node, i + 1);
350
+ const rect = range.getClientRects()[0];
351
+ if (!rect)
352
+ continue;
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 };
369
+ }
370
+ }
371
+ flush(text.length);
372
+ }
@@ -0,0 +1,6 @@
1
+ export { check, suggest } from './naming.js';
2
+ export { quillify, type Quillified } from './quillify.js';
3
+ export { sanitize, type Sanitized } from './sanitize.js';
4
+ export type { Field, FieldNaming, Form, Naming, Place, Problem, Size } from './api.js';
5
+ export type { Refusal } from './refusal.js';
6
+ export { limits, type Rect } from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { check, suggest } from './naming.js';
2
+ export { quillify } from './quillify.js';
3
+ export { sanitize } from './sanitize.js';
4
+ export { limits } from './types.js';
@@ -0,0 +1,4 @@
1
+ import type { FieldInventory, View } from './types.js';
2
+ export declare function inventoryPdf(bytes: Uint8Array, signal?: AbortSignal): Promise<FieldInventory & {
3
+ views: View[];
4
+ }>;
@@ -0,0 +1,179 @@
1
+ import { PDFArray, PDFDict, PDFDocument, PDFHexString, PDFName, PDFString, pdfDocEncodingDecode } from 'pdf-lib';
2
+ import { toTopLeftRect } from './coords.js';
3
+ import { openPdf } from './pdfjs.js';
4
+ import { Refused } from './refusal.js';
5
+ import { MAX_PAGES, MAX_SIDE, MAX_WIDGETS, WIDGET_KINDS } from './types.js';
6
+ /** pdf.js passes on whatever name a file gives `/FT`. */
7
+ const isWidgetKind = (type) => WIDGET_KINDS.includes(type);
8
+ export async function inventoryPdf(bytes, signal) {
9
+ const { pdf, close, within } = await openPdf(bytes, {}, signal);
10
+ try {
11
+ // XFA alone is not a refusal. A hybrid form carries its XFA template beside a
12
+ // complete AcroForm field tree, and pdf.js never XFA-renders one of those, so
13
+ // the widgets below are the real ones. A dynamic form has no field tree, and
14
+ // `xfaToAcroForm` has given it one by the time anything reaches here. What is
15
+ // left is a file that is neither, which has no field to read.
16
+ const { info } = (await within(pdf.getMetadata()));
17
+ const xfa = pdf.isPureXfa || !!info.IsXFAPresent;
18
+ // A catalog that declares a collection is a portfolio: its pages are a cover
19
+ // sheet, and the form is an attachment. AF 3070A is one, around a file named
20
+ // `MicrosoftIRMServices Protected PDF.pdf`. Counting the cover sheet's
21
+ // widgets would answer the wrong question.
22
+ if (info.IsCollectionPresent) {
23
+ const [attachment] = (await within(pdf.getAttachments()))?.keys() ?? [];
24
+ throw new Refused({ code: 'portfolio', attachment });
25
+ }
26
+ if (pdf.numPages > MAX_PAGES)
27
+ throw new Refused({ code: 'too_many_pages', pages: pdf.numPages, limit: MAX_PAGES });
28
+ const inherited = await tooltipsByField(bytes);
29
+ // The value of a checkbox field is the on-state of its ticked box, so boxes
30
+ // of one name that turn on as different values exclude each other. WH-380-E
31
+ // asks whether a job description `is` or `is not attached` that way. Each
32
+ // name's on-states are read ahead of the entries, since the first box of a
33
+ // name does not show what the next turns on as.
34
+ const states = new Map();
35
+ for (let index = 0; index < pdf.numPages; index++) {
36
+ const page = await within(pdf.getPage(index + 1));
37
+ for (const a of (await within(page.getAnnotations())))
38
+ if (a.checkBox && a.fieldName && !a.hidden)
39
+ states.set(a.fieldName, (states.get(a.fieldName) ?? new Set()).add(a.exportValue));
40
+ }
41
+ const widgets = [];
42
+ const dims = [];
43
+ const views = [];
44
+ const seen = new Map();
45
+ const byName = new Map();
46
+ for (let index = 0; index < pdf.numPages; index++) {
47
+ const page = await within(pdf.getPage(index + 1));
48
+ if (page.rotate % 360 !== 0)
49
+ throw new Refused({ code: 'rotated', page: index });
50
+ const view = page.view;
51
+ views.push({ ref: page.ref, box: view });
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);
57
+ const annotations = (await within(page.getAnnotations()));
58
+ for (const a of annotations) {
59
+ // A push button runs an action; it holds no value, so it is no field.
60
+ if (a.subtype !== 'Widget' || a.hidden || !isWidgetKind(a.fieldType) || a.pushButton)
61
+ continue;
62
+ const name = a.fieldName ?? '';
63
+ const place = { page: index, rect: toTopLeftRect(a.rect, view, page.rotate) };
64
+ // One name is one value in the file: typing into any widget that
65
+ // carries it fills every other. SF 1152 ships an original and a file
66
+ // copy sharing one field tree, so half its 60 field widgets are the
67
+ // other half painted again. A radio option is the exception, since
68
+ // its on-state is the whole point of being separate, and so is a
69
+ // checkbox whose name turns on as more than one value.
70
+ const option = !!a.checkBox && (states.get(name)?.size ?? 0) > 1;
71
+ const shared = a.radioButton || option || !name ? undefined : byName.get(name);
72
+ if (shared) {
73
+ shared.places.push(place);
74
+ continue;
75
+ }
76
+ const count = (seen.get(name) ?? 0) + 1;
77
+ seen.set(name, count);
78
+ const widget = {
79
+ id: count === 1 ? name : `${name}#${count}`,
80
+ name,
81
+ kind: a.fieldType,
82
+ places: [place],
83
+ multiLine: a.multiLine ?? false,
84
+ checkBox: !option && (a.checkBox ?? false),
85
+ radioButton: option || (a.radioButton ?? false),
86
+ combo: a.combo ?? false,
87
+ options: (a.options ?? [])
88
+ .map((o) => o.exportValue)
89
+ .filter((value) => typeof value === 'string'),
90
+ readOnly: a.readOnly ?? false,
91
+ required: a.required ?? false,
92
+ tooltip: a.alternativeText || inherited.get(name) || undefined,
93
+ maxLen: a.maxLen ?? 0,
94
+ buttonValue: (option ? a.exportValue : a.buttonValue) ?? undefined
95
+ };
96
+ widgets.push(widget);
97
+ if (!a.radioButton && !option && name)
98
+ byName.set(name, widget);
99
+ }
100
+ }
101
+ // The cap reads a complete count. Inside the page loop it would name a
102
+ // running partial.
103
+ if (widgets.length > MAX_WIDGETS)
104
+ throw new Refused({ code: 'too_many_fields', fields: widgets.length, limit: MAX_WIDGETS });
105
+ if (widgets.length === 0)
106
+ throw new Refused({ code: 'no_fields', xfa });
107
+ return { pages: dims, widgets, views };
108
+ }
109
+ finally {
110
+ await close();
111
+ }
112
+ }
113
+ /**
114
+ * Every field's `/TU` by its fully qualified name.
115
+ *
116
+ * Acrobat writes `/T` and `/TU` on the field and puts bare widget annotations in
117
+ * `/Kids`. pdf.js walks `/Parent` for `/T` and inherits `/FT`, but reads `/TU`
118
+ * straight off the annotation, so every kid of a split field arrives unlabeled:
119
+ * 16 of EOIR-28's 53 widgets, 32 of VA 26-1880's 81, 60 of SF 1152's 63. A
120
+ * widget's tooltip is its field's tooltip, which is the same parent walk pdf.js
121
+ * already does for the name.
122
+ *
123
+ * The walk goes down `/Fields` through `/Kids` and joins the `/T` of each node
124
+ * that has one with a period, as pdf.js builds a field's name. A `/T` the two
125
+ * libraries decode apart finds no widget. It reads the objects themselves,
126
+ * since pdf-lib's form API throws on a tree it cannot type. Whatever is not
127
+ * the type the walk wants is passed over, and a node is read once, so a tree
128
+ * that loops ends.
129
+ *
130
+ * pdf-lib carries no decryption, so this only reads what `normalizePdf` has
131
+ * already decrypted.
132
+ */
133
+ async function tooltipsByField(bytes) {
134
+ const found = new Map();
135
+ const doc = await PDFDocument.load(bytes, { updateMetadata: false });
136
+ const acroForm = doc.catalog.lookup(PDFName.of('AcroForm'));
137
+ const seen = new Set();
138
+ // Each list of nodes with the name of the field that holds it. An entry of
139
+ // `/Fields` is a field. A kid with no `/T` is a widget of the field above it.
140
+ const lists = [
141
+ [acroForm instanceof PDFDict ? acroForm.lookup(PDFName.of('Fields')) : undefined, undefined]
142
+ ];
143
+ for (let next = 0; next < lists.length; next++) {
144
+ const [nodes, above] = lists[next];
145
+ if (!(nodes instanceof PDFArray))
146
+ continue;
147
+ for (let index = 0; index < nodes.size(); index++) {
148
+ const node = nodes.lookup(index);
149
+ if (!(node instanceof PDFDict) || seen.has(node))
150
+ continue;
151
+ seen.add(node);
152
+ const part = text(node.lookup(PDFName.of('T')));
153
+ const name = part === undefined ? above : above === undefined ? part : `${above}.${part}`;
154
+ const tip = text(node.lookup(PDFName.of('TU')));
155
+ if (tip !== undefined && (part !== undefined || next === 0))
156
+ found.set(name ?? '', tip);
157
+ lists.push([node.lookup(PDFName.of('Kids')), name]);
158
+ }
159
+ }
160
+ return found;
161
+ }
162
+ /**
163
+ * A text string as pdf-lib's `decodeText` reads it: UTF-16 after a byte order
164
+ * mark, PDFDocEncoding without one. `decodeText` hands every character to one
165
+ * call, which overflows the stack near 130,000 of them.
166
+ */
167
+ function text(value) {
168
+ if (!(value instanceof PDFString || value instanceof PDFHexString))
169
+ return undefined;
170
+ const bytes = value.asBytes();
171
+ if (bytes[0] === 0xfe && bytes[1] === 0xff)
172
+ return new TextDecoder('utf-16be').decode(bytes);
173
+ if (bytes[0] === 0xff && bytes[1] === 0xfe)
174
+ return new TextDecoder('utf-16le').decode(bytes);
175
+ let decoded = '';
176
+ for (let at = 0; at < bytes.length; at += 8192)
177
+ decoded += pdfDocEncodingDecode(bytes.subarray(at, at + 8192));
178
+ return decoded;
179
+ }
@@ -0,0 +1,3 @@
1
+ import type { Field, FieldNaming, Naming, Problem } from './api.js';
2
+ export declare function suggest(fields: readonly Field[]): FieldNaming[];
3
+ export declare function check(fields: readonly Field[], naming: Naming): Problem[];