@quillmark/quillify 0.0.0-stage → 0.3.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.
Files changed (59) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +85 -0
  3. package/README.md +55 -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 +148 -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 +84 -0
  20. package/dist/display-list.js +339 -0
  21. package/dist/index.d.ts +6 -0
  22. package/dist/index.js +3 -0
  23. package/dist/inventory.d.ts +4 -0
  24. package/dist/inventory.js +175 -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 +159 -0
  29. package/dist/pdfjs.d.ts +17 -0
  30. package/dist/pdfjs.js +95 -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 +77 -0
  38. package/dist/strip.js +524 -0
  39. package/dist/types.d.ts +110 -0
  40. package/dist/types.js +27 -0
  41. package/dist/xfa-layer.d.ts +40 -0
  42. package/dist/xfa-layer.js +139 -0
  43. package/dist/xfa-widgets.d.ts +36 -0
  44. package/dist/xfa-widgets.js +128 -0
  45. package/dist/xfa.d.ts +15 -0
  46. package/dist/xfa.js +345 -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 +69 -5
@@ -0,0 +1,339 @@
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` and `rect`. A picture that did not load, a background
27
+ * image, a `rect` filled with a gradient or a pattern, another SVG shape, a
28
+ * border style it cannot draw and a transform it cannot carry are refused with
29
+ * the construct named: a background that is silently incomplete is worse than
30
+ * not taking the form. An element hidden by its own style, an SVG `line` with
31
+ * no stroke, a `rect` with neither stroke nor fill and an HTML element under
32
+ * 0.3 pt on a side are passed over.
33
+ */
34
+ export function displayList(page, pageNumber) {
35
+ const { layer, origin } = page;
36
+ const view = layer.ownerDocument.defaultView;
37
+ const boxes = [];
38
+ const pictures = [];
39
+ const lines = [];
40
+ const runs = [];
41
+ // The quarter turns each element is laid out under, its own and its ancestors'.
42
+ const turns = new Map([[layer, 0]]);
43
+ const X = (v) => +(v - origin.left).toFixed(2);
44
+ const Y = (v) => +(v - origin.top).toFixed(2);
45
+ const refuse = (construct, detail) => {
46
+ throw new Refused({ code: 'unpaintable', page: pageNumber - 1, construct, detail });
47
+ };
48
+ for (const el of layer.querySelectorAll('*')) {
49
+ const tag = el.tagName.toLowerCase();
50
+ const style = view.getComputedStyle(el);
51
+ const own = quarterTurns(style.transform);
52
+ const turn = (turns.get(el.parentElement) + own) % 4;
53
+ turns.set(el, turn);
54
+ if (tag === 'svg' || tag === 'option' || tag === 'g' || tag === 'defs')
55
+ continue;
56
+ if (style.visibility === 'hidden' || style.display === 'none')
57
+ continue;
58
+ if (tag === 'img' || el.classList.contains('xfaImage')) {
59
+ const image = el;
60
+ // A picture that did not load has no size.
61
+ if (!image.naturalWidth || !image.naturalHeight)
62
+ refuse('image');
63
+ if (turn || unrepresentable(style.transform))
64
+ refuse('transform');
65
+ const rect = image.getBoundingClientRect();
66
+ if (rect.width < 0.3 || rect.height < 0.3)
67
+ continue;
68
+ // pdf.js stretches a picture to its box where the template says so. Any
69
+ // other the stylesheet fits into the box whole, from the top left.
70
+ const fit = style.objectFit === 'contain'
71
+ ? Math.min(rect.width / image.naturalWidth, rect.height / image.naturalHeight)
72
+ : 0;
73
+ pictures.push({
74
+ x: X(rect.x),
75
+ y: Y(rect.y),
76
+ w: +(fit ? image.naturalWidth * fit : rect.width).toFixed(2),
77
+ h: +(fit ? image.naturalHeight * fit : rect.height).toFixed(2),
78
+ image
79
+ });
80
+ continue;
81
+ }
82
+ if (tag === 'line' || tag === 'rect') {
83
+ const svg = el.ownerSVGElement;
84
+ if (!svg)
85
+ continue;
86
+ const edge = stroked(style);
87
+ if (tag === 'line') {
88
+ if (!edge)
89
+ continue;
90
+ if (turn)
91
+ refuse('transform');
92
+ const svgRect = svg.getBoundingClientRect();
93
+ // pdf.js writes these as percentages of the svg box ("100%", "50%").
94
+ const at = (key, span) => {
95
+ const raw = el.getAttribute(key) ?? '0';
96
+ const value = parseFloat(raw);
97
+ if (!Number.isFinite(value))
98
+ return 0;
99
+ return raw.trim().endsWith('%') ? (span * value) / 100 : value;
100
+ };
101
+ lines.push({
102
+ x1: X(svgRect.x + at('x1', svgRect.width)),
103
+ y1: Y(svgRect.y + at('y1', svgRect.height)),
104
+ x2: X(svgRect.x + at('x2', svgRect.width)),
105
+ y2: Y(svgRect.y + at('y2', svgRect.height)),
106
+ width: edge.width,
107
+ colour: edge.colour
108
+ });
109
+ }
110
+ else {
111
+ // pdf.js writes every rectangle a fill, and the browser drops one that is
112
+ // no color: a gradient or a pattern. What is left computes to black.
113
+ if (!el.style.fill && !el.hasAttribute('fill'))
114
+ refuse('background_image');
115
+ // A rectangle whose edge is hidden has a stroke of no width.
116
+ const fill = style.fill !== 'none' && opaque(style.fill) ? rgb(style.fill) : null;
117
+ if (!edge && !fill)
118
+ continue;
119
+ // SVG centers a stroke on the rectangle's edge, and a box's border is
120
+ // drawn inside it, so the box is half a stroke larger all round. What
121
+ // that adds to the fill lies under the stroke.
122
+ const half = (edge?.width ?? 0) / 2;
123
+ const r = el.getBoundingClientRect();
124
+ // The browser draws a rect square when its rx is below zero.
125
+ const radius = parseFloat(style.rx);
126
+ boxes.push({
127
+ x: X(r.x - half),
128
+ y: Y(r.y - half),
129
+ w: +(r.width + 2 * half).toFixed(2),
130
+ h: +(r.height + 2 * half).toFixed(2),
131
+ fill,
132
+ edges: [edge, edge, edge, edge],
133
+ radius: radius > 0 ? radius : undefined
134
+ });
135
+ }
136
+ continue;
137
+ }
138
+ if (svgOnly(tag))
139
+ refuse('svg', tag);
140
+ const rect = el.getBoundingClientRect();
141
+ if (rect.width < 0.3 || rect.height < 0.3)
142
+ continue;
143
+ // A control's own chrome belongs to the browser, and an AcroForm widget is
144
+ // stamped where the control was. The printed square a checkmark goes in is
145
+ // the exception: nothing else draws it. It is drawn where the browser
146
+ // paints the control, which is where the widget is stamped.
147
+ const type = el.getAttribute('type')?.toLowerCase();
148
+ if (tag === 'input' || tag === 'textarea' || tag === 'select') {
149
+ // A field is stamped upright, so a turned one would fill the wrong way.
150
+ if (turn)
151
+ refuse('transform');
152
+ if (type === 'checkbox' || type === 'radio') {
153
+ const painted = square(rect);
154
+ const hair = { width: 1, colour: [0, 0, 0] };
155
+ boxes.push({
156
+ x: X(painted.left),
157
+ y: Y(painted.top),
158
+ w: +painted.width.toFixed(2),
159
+ h: +painted.height.toFixed(2),
160
+ fill: null,
161
+ edges: [hair, hair, hair, hair]
162
+ });
163
+ }
164
+ continue;
165
+ }
166
+ if (style.backgroundImage !== 'none')
167
+ refuse('background_image');
168
+ // pdf.js turns an element about its top left corner, which is where a
169
+ // template turns one it places. One a row or a column flows is left where
170
+ // it flowed, turned out of its cell.
171
+ if (own ? style.position !== 'absolute' : unrepresentable(style.transform))
172
+ refuse('transform');
173
+ const sides = ['Top', 'Right', 'Bottom', 'Left'];
174
+ const edges = sides.map((side) => {
175
+ const width = parseFloat(style.getPropertyValue(`border-${side.toLowerCase()}-width`)) || 0;
176
+ const kind = style.getPropertyValue(`border-${side.toLowerCase()}-style`);
177
+ const colour = style.getPropertyValue(`border-${side.toLowerCase()}-color`);
178
+ if (width <= 0 || kind === 'none' || kind === 'hidden' || !opaque(colour))
179
+ return null;
180
+ if (kind === 'dashed')
181
+ return { width, colour: rgb(colour), dashed: true };
182
+ if (!PAINTED_BORDER.has(kind))
183
+ refuse('border', kind);
184
+ return { width, colour: rgb(colour) };
185
+ });
186
+ // A quarter turn counter-clockwise has the element's right edge at the top.
187
+ for (let quarter = turn; quarter > 0; quarter--)
188
+ edges.push(edges.shift());
189
+ const fill = opaque(style.backgroundColor) ? rgb(style.backgroundColor) : null;
190
+ if (fill || edges.some(Boolean))
191
+ boxes.push({
192
+ x: X(rect.x),
193
+ y: Y(rect.y),
194
+ w: +rect.width.toFixed(2),
195
+ h: +rect.height.toFixed(2),
196
+ fill,
197
+ edges
198
+ });
199
+ }
200
+ collectText(layer, X, Y, runs, turns);
201
+ return { boxes, pictures, lines, runs, width: page.width, height: page.height };
202
+ }
203
+ /**
204
+ * How many quarter turns counter-clockwise a transform is, and 0 for any
205
+ * other. pdf.js writes a template's `rotate="90"` as `rotate(-90deg)`.
206
+ */
207
+ function quarterTurns(transform) {
208
+ return ([
209
+ 'matrix(0, -1, 1, 0, 0, 0)',
210
+ 'matrix(-1, 0, 0, -1, 0, 0)',
211
+ 'matrix(0, 1, -1, 0, 0, 0)'
212
+ ].indexOf(transform) + 1);
213
+ }
214
+ /**
215
+ * True for a transform a run's box cannot carry. XFA offsets caption
216
+ * wrappers with a translation, which the rects already hold. A positive
217
+ * horizontal scale is carried too: `getClientRects` reports the scaled width and
218
+ * `drawRuns` puts it back as `Tz`, which is how LiveCycle's
219
+ * `xfa-font-horizontal-scale` reaches the page. A rotation, a skew, a mirror or
220
+ * a vertical scale changes glyph shape or height, and a run carries neither. A
221
+ * quarter turn is read apart, by `quarterTurns`.
222
+ */
223
+ export function unrepresentable(transform) {
224
+ if (transform === 'none')
225
+ return false;
226
+ const parts = /^matrix\(([^)]*)\)$/.exec(transform)?.[1].split(',').map(Number);
227
+ if (!parts || parts.length !== 6)
228
+ return true;
229
+ const [a, b, c, d] = parts;
230
+ return b !== 0 || c !== 0 || d !== 1 || a <= 0;
231
+ }
232
+ /** SVG shapes pdf.js can emit that the primitives above have no form for. */
233
+ function svgOnly(tag) {
234
+ return ['path', 'polygon', 'polyline', 'circle', 'ellipse', 'image', 'use', 'text'].includes(tag);
235
+ }
236
+ /**
237
+ * One run per visual line, with the box the browser measured it into. Splitting
238
+ * where the rect top changes is what keeps a wrapped paragraph's second line at
239
+ * 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.
241
+ */
242
+ function collectText(layer, X, Y, runs, turns) {
243
+ const owner = layer.ownerDocument;
244
+ const view = owner.defaultView;
245
+ const measure = owner.createElement('canvas').getContext('2d');
246
+ const walker = owner.createTreeWalker(layer, NodeFilter.SHOW_TEXT);
247
+ const range = owner.createRange();
248
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
249
+ const text = node.nodeValue;
250
+ const parent = node.parentElement;
251
+ if (!text || !text.trim() || !parent)
252
+ continue;
253
+ // A <select> is de-chromed above; only the option the form shows is drawn.
254
+ if (parent.tagName === 'OPTION' && !parent.selected)
255
+ continue;
256
+ const style = view.getComputedStyle(parent);
257
+ if (style.visibility === 'hidden' || style.display === 'none')
258
+ continue;
259
+ const size = parseFloat(style.fontSize);
260
+ measure.font = `${style.fontStyle} ${style.fontWeight} ${size}px ${style.fontFamily}`;
261
+ const descent = measure.measureText('Hg').fontBoundingBoxDescent ?? size * 0.21;
262
+ const common = {
263
+ size: +size.toFixed(2),
264
+ family: style.fontFamily,
265
+ bold: parseInt(style.fontWeight, 10) >= 600 || style.fontWeight === 'bold',
266
+ italic: style.fontStyle === 'italic' || style.fontStyle === 'oblique',
267
+ colour: rgb(style.color),
268
+ letterSpacing: parseFloat(style.letterSpacing) || 0
269
+ };
270
+ // Under a turn each rect is read in the frame the text is upright in, and
271
+ // the start of each line's baseline is turned back to the page.
272
+ const turn = turns.get(parent);
273
+ const [cos, sin] = [
274
+ [1, 0],
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
+ }
336
+ }
337
+ flush(text.length);
338
+ }
339
+ }
@@ -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 type { Rect } from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { check, suggest } from './naming.js';
2
+ export { quillify } from './quillify.js';
3
+ export { sanitize } from './sanitize.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,175 @@
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_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
+ dims.push({ width: view[2] - view[0], height: view[3] - view[1] });
53
+ const annotations = (await within(page.getAnnotations()));
54
+ for (const a of annotations) {
55
+ // A push button runs an action; it holds no value, so it is no field.
56
+ if (a.subtype !== 'Widget' || a.hidden || !isWidgetKind(a.fieldType) || a.pushButton)
57
+ continue;
58
+ const name = a.fieldName ?? '';
59
+ const place = { page: index, rect: toTopLeftRect(a.rect, view, page.rotate) };
60
+ // One name is one value in the file: typing into any widget that
61
+ // carries it fills every other. SF 1152 ships an original and a file
62
+ // copy sharing one field tree, so half its 60 field widgets are the
63
+ // other half painted again. A radio option is the exception, since
64
+ // its on-state is the whole point of being separate, and so is a
65
+ // checkbox whose name turns on as more than one value.
66
+ const option = !!a.checkBox && (states.get(name)?.size ?? 0) > 1;
67
+ const shared = a.radioButton || option || !name ? undefined : byName.get(name);
68
+ if (shared) {
69
+ shared.places.push(place);
70
+ continue;
71
+ }
72
+ const count = (seen.get(name) ?? 0) + 1;
73
+ seen.set(name, count);
74
+ const widget = {
75
+ id: count === 1 ? name : `${name}#${count}`,
76
+ name,
77
+ kind: a.fieldType,
78
+ places: [place],
79
+ multiLine: a.multiLine ?? false,
80
+ checkBox: !option && (a.checkBox ?? false),
81
+ radioButton: option || (a.radioButton ?? false),
82
+ combo: a.combo ?? false,
83
+ options: (a.options ?? [])
84
+ .map((o) => o.exportValue)
85
+ .filter((value) => typeof value === 'string'),
86
+ readOnly: a.readOnly ?? false,
87
+ required: a.required ?? false,
88
+ tooltip: a.alternativeText || inherited.get(name) || undefined,
89
+ maxLen: a.maxLen ?? 0,
90
+ buttonValue: (option ? a.exportValue : a.buttonValue) ?? undefined
91
+ };
92
+ widgets.push(widget);
93
+ if (!a.radioButton && !option && name)
94
+ byName.set(name, widget);
95
+ }
96
+ }
97
+ // The cap reads a complete count. Inside the page loop it would name a
98
+ // running partial.
99
+ if (widgets.length > MAX_WIDGETS)
100
+ throw new Refused({ code: 'too_many_fields', fields: widgets.length, limit: MAX_WIDGETS });
101
+ if (widgets.length === 0)
102
+ throw new Refused({ code: 'no_fields', xfa });
103
+ return { pages: dims, widgets, views };
104
+ }
105
+ finally {
106
+ await close();
107
+ }
108
+ }
109
+ /**
110
+ * Every field's `/TU` by its fully qualified name.
111
+ *
112
+ * Acrobat writes `/T` and `/TU` on the field and puts bare widget annotations in
113
+ * `/Kids`. pdf.js walks `/Parent` for `/T` and inherits `/FT`, but reads `/TU`
114
+ * straight off the annotation, so every kid of a split field arrives unlabeled:
115
+ * 16 of EOIR-28's 53 widgets, 32 of VA 26-1880's 81, 60 of SF 1152's 63. A
116
+ * widget's tooltip is its field's tooltip, which is the same parent walk pdf.js
117
+ * already does for the name.
118
+ *
119
+ * The walk goes down `/Fields` through `/Kids` and joins the `/T` of each node
120
+ * that has one with a period, as pdf.js builds a field's name. A `/T` the two
121
+ * libraries decode apart finds no widget. It reads the objects themselves,
122
+ * since pdf-lib's form API throws on a tree it cannot type. Whatever is not
123
+ * the type the walk wants is passed over, and a node is read once, so a tree
124
+ * that loops ends.
125
+ *
126
+ * pdf-lib carries no decryption, so this only reads what `normalizePdf` has
127
+ * already decrypted.
128
+ */
129
+ async function tooltipsByField(bytes) {
130
+ const found = new Map();
131
+ const doc = await PDFDocument.load(bytes, { updateMetadata: false });
132
+ const acroForm = doc.catalog.lookup(PDFName.of('AcroForm'));
133
+ const seen = new Set();
134
+ // Each list of nodes with the name of the field that holds it. An entry of
135
+ // `/Fields` is a field. A kid with no `/T` is a widget of the field above it.
136
+ const lists = [
137
+ [acroForm instanceof PDFDict ? acroForm.lookup(PDFName.of('Fields')) : undefined, undefined]
138
+ ];
139
+ for (let next = 0; next < lists.length; next++) {
140
+ const [nodes, above] = lists[next];
141
+ if (!(nodes instanceof PDFArray))
142
+ continue;
143
+ for (let index = 0; index < nodes.size(); index++) {
144
+ const node = nodes.lookup(index);
145
+ if (!(node instanceof PDFDict) || seen.has(node))
146
+ continue;
147
+ seen.add(node);
148
+ const part = text(node.lookup(PDFName.of('T')));
149
+ const name = part === undefined ? above : above === undefined ? part : `${above}.${part}`;
150
+ const tip = text(node.lookup(PDFName.of('TU')));
151
+ if (tip !== undefined && (part !== undefined || next === 0))
152
+ found.set(name ?? '', tip);
153
+ lists.push([node.lookup(PDFName.of('Kids')), name]);
154
+ }
155
+ }
156
+ return found;
157
+ }
158
+ /**
159
+ * A text string as pdf-lib's `decodeText` reads it: UTF-16 after a byte order
160
+ * mark, PDFDocEncoding without one. `decodeText` hands every character to one
161
+ * call, which overflows the stack near 130,000 of them.
162
+ */
163
+ function text(value) {
164
+ if (!(value instanceof PDFString || value instanceof PDFHexString))
165
+ return undefined;
166
+ const bytes = value.asBytes();
167
+ if (bytes[0] === 0xfe && bytes[1] === 0xff)
168
+ return new TextDecoder('utf-16be').decode(bytes);
169
+ if (bytes[0] === 0xff && bytes[1] === 0xfe)
170
+ return new TextDecoder('utf-16le').decode(bytes);
171
+ let decoded = '';
172
+ for (let at = 0; at < bytes.length; at += 8192)
173
+ decoded += pdfDocEncodingDecode(bytes.subarray(at, at + 8192));
174
+ return decoded;
175
+ }
@@ -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[];
package/dist/naming.js ADDED
@@ -0,0 +1,116 @@
1
+ import { SNAKE_CASE } from './types.js';
2
+ const MAX_LENGTH = 40;
3
+ const snake = (text) => text
4
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
5
+ .replace(/[^A-Za-z0-9]+/g, '_')
6
+ .replace(/^_+|_+$/g, '')
7
+ .toLowerCase();
8
+ /** The last segment of the LiveCycle id `form1[0].page1[0].Header[0].dateReq[0]` is `dateReq`. */
9
+ const lastSegment = (path) => path.slice(path.lastIndexOf('.') + 1).replace(/\[\d+\]$/, '');
10
+ const squeeze = (text) => text.trim().replace(/\s+/g, ' ');
11
+ const lead = (words) => (/^[a-z]/.test(words) ? words : words ? `field_${words}` : 'field');
12
+ /** The whole words of `words` that fit in `limit`, or nothing when no whole word does. */
13
+ function whole(words, limit) {
14
+ if (words.length <= limit)
15
+ return words;
16
+ const head = words.slice(0, Math.max(0, words.lastIndexOf('_', limit)));
17
+ return head === 'field' ? '' : head;
18
+ }
19
+ /** `words` cut to `limit`: the whole words that fit, or its first `limit` characters when no whole word does. */
20
+ const fit = (words, limit) => whole(words, limit) || words.slice(0, limit);
21
+ export function suggest(fields) {
22
+ // Options of one group whose tooltips read alike are told apart by their on-states.
23
+ const tooltips = fields.map((field) => snake(field.tooltip ?? ''));
24
+ const keys = fields.map((field, at) => field.kind === 'radio' && tooltips[at] ? `${tooltips[at]}\0${field.group}` : '');
25
+ const once = new Set();
26
+ const alike = new Set();
27
+ for (const key of keys)
28
+ if (key)
29
+ (once.has(key) ? alike : once).add(key);
30
+ const taken = new Set();
31
+ return fields.map((field, at) => {
32
+ const radio = field.kind === 'radio' ? field : undefined;
33
+ const said = radio && alike.has(keys[at]) ? squeeze(radio.on ?? '') : '';
34
+ const on = snake(said);
35
+ const head = on && whole(lead(tooltips[at]), MAX_LENGTH - 1 - on.length);
36
+ const words = on ||
37
+ tooltips[at] ||
38
+ snake(radio ? `${lastSegment(radio.group)} ${radio.on ?? ''}` : lastSegment(field.id));
39
+ const base = head ? `${head}_${on}` : fit(lead(words), MAX_LENGTH);
40
+ let name = base;
41
+ for (let n = 2; taken.has(name); n++)
42
+ name = `${base}_${n}`;
43
+ taken.add(name);
44
+ const tooltip = squeeze(field.tooltip ?? '');
45
+ const description = said ? `${tooltip} (${said})` : tooltip;
46
+ return { id: field.id, keep: !field.readOnly, name, description };
47
+ });
48
+ }
49
+ const TEXT = new Set(['line', 'paragraph', 'lines']);
50
+ /**
51
+ * A naming can be a model's JSON, which TypeScript never saw. A slot of the
52
+ * wrong type is a mistake in the call and no fault of a name, so it throws: a
53
+ * pattern reads `null` as the word, and the word is a name.
54
+ */
55
+ function shaped({ name, description, fields }) {
56
+ const not = (slot, kind) => {
57
+ throw new TypeError(`naming.${slot} is not ${kind}.`);
58
+ };
59
+ if (typeof name !== 'string')
60
+ not('name', 'a string');
61
+ if (typeof description !== 'string')
62
+ not('description', 'a string');
63
+ if (!Array.isArray(fields))
64
+ not('fields', 'an array');
65
+ fields.forEach((entry, at) => {
66
+ const slot = `fields[${at}]`;
67
+ if (typeof entry !== 'object' || entry === null)
68
+ return not(slot, 'an object');
69
+ if (typeof entry.id !== 'string')
70
+ not(`${slot}.id`, 'a string');
71
+ if (typeof entry.keep !== 'boolean')
72
+ not(`${slot}.keep`, 'a boolean');
73
+ if (typeof entry.name !== 'string')
74
+ not(`${slot}.name`, 'a string');
75
+ if (typeof entry.description !== 'string')
76
+ not(`${slot}.description`, 'a string');
77
+ if (entry.example !== undefined && typeof entry.example !== 'string')
78
+ not(`${slot}.example`, 'a string');
79
+ if (entry.text !== undefined && !TEXT.has(entry.text))
80
+ not(`${slot}.text`, "'line', 'paragraph' or 'lines'");
81
+ });
82
+ }
83
+ export function check(fields, naming) {
84
+ shaped(naming);
85
+ const problems = [];
86
+ if (!SNAKE_CASE.test(naming.name))
87
+ problems.push({ code: 'quill_name' });
88
+ if (/^\p{White_Space}*$/u.test(naming.description))
89
+ problems.push({ code: 'quill_description' });
90
+ const ids = new Set(fields.map((field) => field.id));
91
+ const entries = Map.groupBy(naming.fields, (entry) => entry.id);
92
+ for (const id of entries.keys())
93
+ if (!ids.has(id))
94
+ problems.push({ code: 'unknown_field', id });
95
+ for (const [id, sharing] of entries)
96
+ if (sharing.length > 1)
97
+ problems.push({ code: 'repeated_field', id });
98
+ for (const id of ids)
99
+ if (!entries.has(id))
100
+ problems.push({ code: 'unnamed_field', id });
101
+ // A read-only field is left out whatever its entry says, so it counts as dropped.
102
+ const fillable = new Set(fields.filter((field) => !field.readOnly).map((field) => field.id));
103
+ const kept = naming.fields.filter((entry) => entry.keep && fillable.has(entry.id));
104
+ if (kept.length === 0)
105
+ problems.push({ code: 'nothing_kept' });
106
+ for (const { id, name } of kept)
107
+ if (!SNAKE_CASE.test(name))
108
+ problems.push({ code: 'field_name', id });
109
+ for (const [name, sharing] of Map.groupBy(kept, (entry) => entry.name))
110
+ if (sharing.length > 1)
111
+ problems.push({ code: 'duplicate_name', name });
112
+ for (const { id, kind } of fields)
113
+ if (kind !== 'text' && entries.get(id)?.some((entry) => entry.text))
114
+ problems.push({ code: 'text_on_non_text', id });
115
+ return problems;
116
+ }