@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 CHANGED
@@ -1,5 +1,5 @@
1
1
  quillify
2
- Copyright 2026 Nibs
2
+ Copyright 2026 Enrique Oti
3
3
 
4
4
  quillify redistributes eight files that are other projects' work. Everything
5
5
  else it depends on is installed by its consumer and is not in the package.
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,143 of 1,354 Air Force forms convert, with every field read back at its place.
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.
@@ -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
@@ -41,6 +41,7 @@ export interface FieldNaming {
41
41
  /** A read-only field is left out whatever this says. */
42
42
  keep: boolean;
43
43
  name: string;
44
+ title?: string;
44
45
  description: string;
45
46
  example?: string;
46
47
  text?: 'line' | 'paragraph' | 'lines';
@@ -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)
@@ -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
- /** The radius of an SVG rect's corners, along the middle of its border. */
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 `drawRuns` scales the run to. */
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
- boxes: Box[];
55
- pictures: Picture[];
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 `rect`. A picture that did not load, a background
67
- * image, a `rect` filled with a gradient or a pattern, another SVG shape, a
68
- * border style it cannot draw and a transform it cannot carry are refused with
69
- * the construct named: a background that is silently incomplete is worse than
70
- * not taking the form. An element hidden by its own style, an SVG `line` with
71
- * no stroke, a `rect` with neither stroke nor fill and an HTML element under
72
- * 0.3 pt on a side are passed over.
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
- * `drawRuns` puts it back as `Tz`, which is how LiveCycle's
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`.
@@ -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 `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.
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 boxes = [];
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
- for (const el of layer.querySelectorAll('*')) {
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
- pictures.push({
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
- lines.push({
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 rectangle a fill, and the browser drops one that is
112
- // no color: a gradient or a pattern. What is left computes to black.
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 rectangle whose edge is hidden has a stroke of no width.
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 rectangle's edge, and a box's border is
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 draws a rect square when its rx is below zero.
125
- const radius = parseFloat(style.rx);
126
- boxes.push({
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
- boxes.push({
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
- boxes.push({
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
- collectText(layer, X, Y, runs, turns);
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
- * `drawRuns` puts it back as `Tz`, which is how LiveCycle's
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', 'circle', 'ellipse', 'image', 'use', 'text'].includes(tag);
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(layer, X, Y, runs, turns) {
243
- const owner = layer.ownerDocument;
244
- const view = owner.defaultView;
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
- 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)
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
- const style = view.getComputedStyle(parent);
257
- if (style.visibility === 'hidden' || style.display === 'none')
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 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
- }
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 { Rect } from './types.js';
6
+ export { limits, type Rect } from './types.js';
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export { check, suggest } from './naming.js';
2
2
  export { quillify } from './quillify.js';
3
3
  export { sanitize } from './sanitize.js';
4
+ export { limits } from './types.js';
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
- dims.push({ width: view[2] - view[0], height: view[3] - view[1] });
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))