@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/dist/normalize.js CHANGED
@@ -27,8 +27,7 @@
27
27
  * is ended at the write that passes it, and then in what qpdf wrote, on the
28
28
  * page's thread and before any other reader opens it.
29
29
  */
30
- import { ParseSpeeds, PDFArray, PDFDocument, PDFName, PDFRawStream } from 'pdf-lib';
31
- import { openPdf } from './pdfjs.js';
30
+ import { ParseSpeeds, PDFArray, PDFBool, PDFDict, PDFDocument, PDFInvalidObject, PDFName, PDFNumber, PDFRawStream, PDFStream } from 'pdf-lib';
32
31
  import { Refused } from './refusal.js';
33
32
  import { MAX_BYTES, MAX_INFLATED_BYTES } from './types.js';
34
33
  // Held in a constant so that Vite copies the worker byte for byte. Written
@@ -52,14 +51,141 @@ const TYPED = new RegExp(`/(?:${spelled('ObjStm')}|${spelled('XRef')})`);
52
51
  * 1 MB handed over whole.
53
52
  */
54
53
  const SLICE = 1024;
55
- /** What qpdf leaves a stream under, alone or after one Flate layer. Each gives pixels, which are not counted. */
54
+ /** What qpdf leaves a stream under, alone or after one Flate layer. Each gives pixels. */
56
55
  const CODECS = ['DCTDecode', 'JPXDecode', 'CCITTFaxDecode', 'JBIG2Decode'];
56
+ /** The components of a pixel in a color space that is this name or an array that begins with it. */
57
+ const COMPONENTS = new Map([
58
+ ['DeviceGray', 1],
59
+ ['CalGray', 1],
60
+ ['Indexed', 1],
61
+ ['Separation', 1],
62
+ ['DeviceRGB', 3],
63
+ ['CalRGB', 3],
64
+ ['Lab', 3],
65
+ ['DeviceCMYK', 4]
66
+ ]);
57
67
  /**
58
- * The size of the file qpdf wrote and of what its Flate streams decode to,
59
- * added up no further than the bound. pdf-lib lists the streams and the
60
- * browser inflates each, keeping nothing. A stream the browser's decoder
61
- * rejects, or one under a filter qpdf has not taken off, is a stream two
62
- * readers can read two ways, and the file is refused as `damaged`.
68
+ * The bytes an image's pixels come to as its dictionary declares them: rows
69
+ * of whole bytes, at its bits to a component and the components of its color
70
+ * space, or at a bit to the pixel for a mask. A halftone's threshold array
71
+ * says a width and a height too, and neither bits nor a color space: a stream
72
+ * with a `/HalftoneType` and neither comes to two bytes to the pixel, the most
73
+ * such an array holds. A stream with no `/Width` and no `/Height` is no image
74
+ * and comes to none. One with either whose dictionary does not say what it
75
+ * comes to, in a component or more of a bit or more and in a number short of
76
+ * infinity, is refused as `damaged`.
77
+ *
78
+ * JPEG 2000 data says its own color space and bits, so an image under
79
+ * `JPXDecode` may leave either out, and counts at four components for the
80
+ * one and eight bits for the other, what a painter makes of a pixel. A
81
+ * CCITT decoder goes by the `/Columns` and `/Rows` of its `/DecodeParms`,
82
+ * so each counts where it is written and more than the image's own.
83
+ * `chain` is the stream's filters.
84
+ */
85
+ function declared(dict, chain) {
86
+ if (!dict.has(PDFName.of('Width')) && !dict.has(PDFName.of('Height')))
87
+ return 0;
88
+ const number = (from, key) => {
89
+ const value = from.lookup(PDFName.of(key));
90
+ return value instanceof PDFNumber ? value.asNumber() : NaN;
91
+ };
92
+ const codec = chain.at(-1);
93
+ const jpx = codec === 'JPXDecode';
94
+ const parms = codec === 'CCITTFaxDecode' && dict.lookup(PDFName.of('DecodeParms'));
95
+ const fax = parms instanceof PDFArray ? parms.lookup(chain.length - 1) : parms;
96
+ const side = (key, said) => {
97
+ const own = number(dict, key);
98
+ const more = fax instanceof PDFDict ? number(fax, said) : NaN;
99
+ return more > own ? more : own;
100
+ };
101
+ const mask = dict.lookup(PDFName.of('ImageMask')) === PDFBool.True;
102
+ const space = dict.lookup(PDFName.of('ColorSpace'));
103
+ const threshold = dict.has(PDFName.of('HalftoneType')) && !space && !dict.has(PDFName.of('BitsPerComponent'));
104
+ const [family, part] = space instanceof PDFArray ? [space.lookup(0), space.lookup(1)] : [space];
105
+ const name = family instanceof PDFName ? family.decodeText() : '';
106
+ const components = mask || threshold
107
+ ? 1
108
+ : name === 'ICCBased' && part instanceof PDFStream
109
+ ? number(part.dict, 'N')
110
+ : name === 'DeviceN' && part instanceof PDFArray
111
+ ? part.size()
112
+ : jpx && !space
113
+ ? 4
114
+ : (COMPONENTS.get(name) ?? NaN);
115
+ const bits = mask
116
+ ? 1
117
+ : threshold
118
+ ? 16
119
+ : jpx && !dict.has(PDFName.of('BitsPerComponent'))
120
+ ? 8
121
+ : number(dict, 'BitsPerComponent');
122
+ const bytes = Math.ceil((side('Width', 'Columns') * components * bits) / 8) * side('Height', 'Rows');
123
+ if (!(components >= 1 && bits >= 1 && bytes >= 0 && bytes < Infinity))
124
+ throw new Refused({ code: 'damaged' });
125
+ return bytes;
126
+ }
127
+ /**
128
+ * The bytes an image's pixels come to as its codec's own header says them,
129
+ * which is what a decoder that goes by the stream makes: the first frame
130
+ * header of a JPEG, or the SIZ segment of a JPEG 2000 codestream, bare or in
131
+ * the `jp2c` box of a JP2 file. None under any other codec, and none where the
132
+ * header is not there or is cut short, which is where the view throws.
133
+ */
134
+ function headed(codec, data) {
135
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
136
+ const bytes = (width, height, components, bits) => Math.ceil((width * components * bits) / 8) * height;
137
+ try {
138
+ if (codec === 'DCTDecode') {
139
+ // From the first start of an image, where a reader that looks for one begins.
140
+ let at = 0;
141
+ while (view.getUint16(at) !== 0xffd8)
142
+ at += 1;
143
+ // As libjpeg finds a marker: past a byte that is none, a fill byte and a marker of no length.
144
+ for (at += 2;;) {
145
+ const marker = view.getUint8(at) === 0xff ? view.getUint8(at + 1) : 0xff;
146
+ if (marker === 0xff)
147
+ at += 1;
148
+ else if (marker >> 4 === 0xc && ![0xc4, 0xc8, 0xcc].includes(marker))
149
+ // A height of 0 is left to a DNL marker after the scan, which can say 65,535.
150
+ return bytes(view.getUint16(at + 7), view.getUint16(at + 5) || 0xffff, view.getUint8(at + 9), view.getUint8(at + 4));
151
+ else
152
+ at += marker <= 1 || (marker >= 0xd0 && marker <= 0xd9) ? 2 : 2 + view.getUint16(at + 2);
153
+ }
154
+ }
155
+ if (codec === 'JPXDecode') {
156
+ let at = 0;
157
+ // A JP2 file is boxes: a length and a type, or a 1, a type and a length of eight bytes.
158
+ if (view.getUint32(4) === 0x6a502020) {
159
+ const wide = () => view.getUint32(at) === 1;
160
+ while (view.getUint32(at + 4) !== 0x6a703263)
161
+ at += (wide() ? Number(view.getBigUint64(at + 8)) : view.getUint32(at)) || Infinity;
162
+ at += wide() ? 16 : 8;
163
+ }
164
+ // The image is its grid less where it starts in it, at the most bits any component says.
165
+ if (view.getUint32(at) === 0xff4fff51) {
166
+ const components = view.getUint16(at + 40);
167
+ let bits = 0;
168
+ for (let each = 0; each < components; each++)
169
+ bits = Math.max(bits, (view.getUint8(at + 42 + 3 * each) & 127) + 1);
170
+ return bytes(view.getUint32(at + 8) - view.getUint32(at + 16), view.getUint32(at + 12) - view.getUint32(at + 20), components, bits);
171
+ }
172
+ }
173
+ }
174
+ catch {
175
+ // No header to go by.
176
+ }
177
+ return 0;
178
+ }
179
+ /**
180
+ * The size of the file qpdf wrote and of what its streams decode to, added up
181
+ * no further than the bound. pdf-lib lists the streams and the browser
182
+ * inflates each Flate one, keeping only what lies under a JPEG or JPEG 2000
183
+ * codec. An image comes to the most of what its dictionary declares, what
184
+ * its codec's own header says and what its Flate layer gives. An object
185
+ * pdf-lib cannot read, as a number of 400 digits, is one it would not count.
186
+ * Such an object, a stream the browser's decoder rejects, or one under a
187
+ * filter qpdf has not taken off, is one two readers can read two ways, and
188
+ * the file is refused as `damaged`.
63
189
  */
64
190
  async function inflated(pdf) {
65
191
  let total = pdf.byteLength;
@@ -72,6 +198,8 @@ async function inflated(pdf) {
72
198
  parseSpeed: ParseSpeeds.Fastest
73
199
  });
74
200
  for (const [, object] of context.enumerateIndirectObjects()) {
201
+ if (object instanceof PDFInvalidObject)
202
+ throw new Refused({ code: 'damaged' });
75
203
  if (!(object instanceof PDFRawStream))
76
204
  continue;
77
205
  const { dict, contents } = object;
@@ -85,10 +213,16 @@ async function inflated(pdf) {
85
213
  const rest = chain.slice(flate ? 1 : 0);
86
214
  if (rest.length > 1 || rest.some((name) => !CODECS.includes(name)))
87
215
  throw new Refused({ code: 'damaged' });
216
+ const before = total;
217
+ const said = declared(dict, chain);
88
218
  // An empty stream is one to every reader, and to the decoder a stream cut short.
89
- if (!flate || !contents.length)
219
+ if (!flate || !contents.length) {
220
+ total += Math.max(said, headed(rest[0], contents));
90
221
  continue;
222
+ }
91
223
  let at = 0;
224
+ const keep = rest[0] === 'DCTDecode' || rest[0] === 'JPXDecode';
225
+ const under = [];
92
226
  const reader = new ReadableStream({
93
227
  pull(controller) {
94
228
  if (at < contents.length)
@@ -104,6 +238,8 @@ async function inflated(pdf) {
104
238
  total += read.value.byteLength;
105
239
  if (total > MAX_INFLATED_BYTES)
106
240
  return total;
241
+ if (keep)
242
+ under.push(read.value);
107
243
  }
108
244
  }
109
245
  catch {
@@ -112,6 +248,8 @@ async function inflated(pdf) {
112
248
  finally {
113
249
  void reader.cancel().catch(() => { });
114
250
  }
251
+ const own = keep ? headed(rest[0], new Uint8Array(await new Blob(under).arrayBuffer())) : 0;
252
+ total = Math.max(total, before + Math.max(said, own));
115
253
  }
116
254
  return total;
117
255
  }
@@ -145,15 +283,21 @@ export async function normalizePdf(bytes, signal) {
145
283
  });
146
284
  if (!answer.loaded)
147
285
  throw new Error(`qpdf.wasm did not load from ${WASM}`);
148
- const { out, size } = answer;
286
+ const { out, size, said } = answer;
149
287
  // A file that came to more than the bound in qpdf is past it, whatever qpdf then wrote.
150
288
  const total = size > MAX_INFLATED_BYTES ? size : out?.byteLength ? await inflated(out) : 0;
151
289
  if (total > MAX_INFLATED_BYTES)
152
290
  throw new Refused({ code: 'too_large', bytes: total, limit: MAX_INFLATED_BYTES });
153
291
  if (out?.byteLength)
154
292
  return out;
155
- // qpdf gives its reason on stderr alone. A file pdf.js cannot open is refused
156
- // there, under its own code. One it opens is a file only qpdf rejects.
157
- await (await openPdf(bytes, {}, signal)).close();
158
- throw new Refused({ code: 'damaged' });
293
+ // A qpdf that ends by itself ends at its first error, and its last line says
294
+ // when that is the password or the encryption. No other reader opens the
295
+ // file, which the bound does not hold: a PDF has its header in its first
296
+ // 1,024 bytes.
297
+ if (/invalid password\n$/.test(said))
298
+ throw new Refused({ code: 'password' });
299
+ if (/encryption.*\n$/.test(said))
300
+ throw new Refused({ code: 'encryption' });
301
+ const head = new TextDecoder('latin1').decode(bytes.subarray(0, 1024));
302
+ throw new Refused({ code: head.includes('%PDF-') ? 'damaged' : 'not_pdf' });
159
303
  }
package/dist/pdfjs.js CHANGED
@@ -2,8 +2,9 @@ import { Refused } from './refusal.js';
2
2
  // Held in a constant so that Vite copies the worker byte for byte. Written
3
3
  // inside `new Worker(...)`, the URL makes it bundle the file again.
4
4
  const WORKER = new URL('../assets/pdf.worker.js', import.meta.url);
5
- // The faces pdf.js lays a dynamic form's Arial, Helvetica, Myriad Pro, Calibri
6
- // and Segoe UI out in, each named so that a bundler copies it.
5
+ // The faces pdf.js lays a dynamic form's Arial, Helvetica, Myriad Pro and
6
+ // Calibri out in, and any face the form's font list lacks, each named so that
7
+ // a bundler copies it.
7
8
  const FACES = {
8
9
  'LiberationSans-Regular.ttf': new URL('../assets/LiberationSans-Regular.ttf', import.meta.url),
9
10
  'LiberationSans-Bold.ttf': new URL('../assets/LiberationSans-Bold.ttf', import.meta.url),
package/dist/quillify.js CHANGED
@@ -50,12 +50,12 @@ export async function quillify(form, naming) {
50
50
  return {
51
51
  widget: entry.id,
52
52
  name: entry.name,
53
+ ...(entry.title === undefined ? {} : { title: entry.title }),
53
54
  description: entry.description,
54
55
  ...typed(field, entry.text),
55
56
  ...(example === undefined ? {} : { example }),
56
57
  keep: entry.keep && !field.readOnly,
57
- required: field.required,
58
- ...(field.kind === 'radio' ? { group: field.group } : {})
58
+ required: field.required
59
59
  };
60
60
  })
61
61
  }, {
package/dist/strip.d.ts CHANGED
@@ -67,11 +67,16 @@ export declare const PICK: Record<string, [key: string, kinds: Record<string, st
67
67
  * and an object reference whose `/Obj` is not such a link is taken out of its
68
68
  * element's `/K`.
69
69
  *
70
- * An object that reads as an annotation or as a page, and is neither a page of
71
- * `views` nor a link that stays, is deleted outright before the walk. No widget
72
- * and no page outside the page tree is then in the file, whatever names one, a
73
- * dictionary by name included, and the fields and appearance streams that hung
74
- * off it are out of the walk's reach: a background that still carries a form
75
- * layer is one the renderer would stamp a second one over.
70
+ * An object that says `/Type /Annot`, `/Subtype /Widget` or `/Type /Page`, and
71
+ * is neither a page of `views` nor a link that stays, has no kind. A file in
72
+ * which a key reaches one as a kind is refused as `damaged`, the catalog itself
73
+ * included, and so is a file whose catalog or page tree pdf-lib cannot read. A
74
+ * marked-content property list may say `/Type /Page`, and a `/Rect` says
75
+ * nothing: a kept object loses the keys its row does not list. So no object of
76
+ * a background is a widget or a page outside the page tree, and the fields and
77
+ * appearance streams that hung off one are out of the walk's reach: a
78
+ * background that still carries a form layer is one the renderer would stamp a
79
+ * second one over. A dictionary written in place is not an object: one by name
80
+ * keeps the plain values a file writes in it, a widget's among them.
76
81
  */
77
82
  export declare function stripAcroForm(bytes: Uint8Array, views: View[]): Promise<Uint8Array>;
package/dist/strip.js CHANGED
@@ -31,7 +31,7 @@ export const KINDS = {
31
31
  catalog: 'Type Pages:tree OCProperties:layers StructTreeRoot:tags MarkInfo:marks Lang',
32
32
  tree: 'Type Parent:^tree Kids:[node] Count Resources:resources Rotate',
33
33
  page: 'Type Parent:^tree Resources:resources MediaBox Contents:[content] Rotate Group:group ' +
34
- 'UserUnit StructParents Tabs Annots:[link]',
34
+ 'StructParents Tabs Annots:[link]',
35
35
  link: 'Type Subtype Rect Border BS:border C H QuadPoints F OC:oc Contents StructParent A:action ' +
36
36
  'Dest:dest',
37
37
  border: 'Type W S D',
@@ -239,22 +239,23 @@ const PICKS = new Map(Object.entries(PICK).map(([kind, [key, kinds, other]]) =>
239
239
  * and an object reference whose `/Obj` is not such a link is taken out of its
240
240
  * element's `/K`.
241
241
  *
242
- * An object that reads as an annotation or as a page, and is neither a page of
243
- * `views` nor a link that stays, is deleted outright before the walk. No widget
244
- * and no page outside the page tree is then in the file, whatever names one, a
245
- * dictionary by name included, and the fields and appearance streams that hung
246
- * off it are out of the walk's reach: a background that still carries a form
247
- * layer is one the renderer would stamp a second one over.
242
+ * An object that says `/Type /Annot`, `/Subtype /Widget` or `/Type /Page`, and
243
+ * is neither a page of `views` nor a link that stays, has no kind. A file in
244
+ * which a key reaches one as a kind is refused as `damaged`, the catalog itself
245
+ * included, and so is a file whose catalog or page tree pdf-lib cannot read. A
246
+ * marked-content property list may say `/Type /Page`, and a `/Rect` says
247
+ * nothing: a kept object loses the keys its row does not list. So no object of
248
+ * a background is a widget or a page outside the page tree, and the fields and
249
+ * appearance streams that hung off one are out of the walk's reach: a
250
+ * background that still carries a form layer is one the renderer would stamp a
251
+ * second one over. A dictionary written in place is not an object: one by name
252
+ * keeps the plain values a file writes in it, a widget's among them.
248
253
  */
249
254
  export async function stripAcroForm(bytes, views) {
250
255
  const doc = await PDFDocument.load(bytes, { updateMetadata: false });
251
256
  const { context } = doc;
252
257
  const at = (object, key) => object instanceof PDFDict ? object.lookup(PDFName.of(key)) : undefined;
253
258
  const is = (object, key, name) => at(object, key) === PDFName.of(name);
254
- /** An annotation need not say it is one: a `/Rect` or a `/Subtype /Widget` is enough. */
255
- const annotation = (object) => is(object, 'Type', 'Annot') ||
256
- at(object, 'Rect') !== undefined ||
257
- is(object, 'Subtype', 'Widget');
258
259
  /** The action a link that stays keeps, `null` when it holds its destination itself, `false` when it goes. */
259
260
  const leads = (link) => {
260
261
  if (!link.has(PDFName.of('A')))
@@ -274,7 +275,14 @@ export async function stripAcroForm(bytes, views) {
274
275
  : '';
275
276
  return /^https?:\/\//i.test(head) && action.clone();
276
277
  };
277
- const pages = doc.getPages();
278
+ // pdf-lib throws at a catalog or a page tree it does not read as one.
279
+ let pages;
280
+ try {
281
+ pages = doc.getPages();
282
+ }
283
+ catch {
284
+ throw new Refused({ code: 'damaged' });
285
+ }
278
286
  const differs = pages.some(({ ref }, index) => {
279
287
  const viewed = views[index]?.ref;
280
288
  return viewed?.num !== ref.objectNumber || viewed.gen !== ref.generationNumber;
@@ -307,9 +315,6 @@ export async function stripAcroForm(bytes, views) {
307
315
  }
308
316
  context.trailerInfo.Info = undefined;
309
317
  context.trailerInfo.ID = undefined;
310
- for (const [ref, object] of context.enumerateIndirectObjects())
311
- if (!kept.has(ref) && !linked.has(ref) && (is(object, 'Type', 'Page') || annotation(object)))
312
- context.delete(ref);
313
318
  const claimed = new Map();
314
319
  const pending = [];
315
320
  const whole = [];
@@ -353,11 +358,19 @@ export async function stripAcroForm(bytes, views) {
353
358
  * Gives an object its kind and queues it, where this reach of it is as a kind.
354
359
  * An object that has one is reached as that kind and no other, or the file is
355
360
  * refused as `damaged`: the walk cuts it to the keys of the first, and a second
356
- * would read what is left. Whether this reach of it stays.
361
+ * would read what is left. So is a file that reaches as a kind an object that
362
+ * says it is an annotation or a page, and is neither a page of `views` nor a
363
+ * link that stays. Whether this reach of it stays.
357
364
  */
358
365
  const claim = (raw, value, as) => {
359
366
  const had = claimed.get(raw);
360
- if (had && had !== as)
367
+ const posed = raw instanceof PDFRef &&
368
+ !kept.has(raw) &&
369
+ !linked.has(raw) &&
370
+ (is(value, 'Type', 'Annot') ||
371
+ is(value, 'Subtype', 'Widget') ||
372
+ (as !== slot('marked') && is(value, 'Type', 'Page')));
373
+ if ((had && had !== as) || (as && posed))
361
374
  throw new Refused({ code: 'damaged' });
362
375
  if (had || !as)
363
376
  return as !== undefined;
@@ -438,10 +451,6 @@ export async function stripAcroForm(bytes, views) {
438
451
  }
439
452
  }
440
453
  }
441
- // A page tree node that reads as an annotation is deleted above, and the page
442
- // tree holds the pages under it no more.
443
- if (pages.some(({ ref }) => claimed.get(ref) !== slot('page')))
444
- throw new Refused({ code: 'damaged' });
445
454
  // A back reference stays when the walk reached what it names as its kind.
446
455
  // Where the slot takes many, a value that leads nowhere stays too, and an
447
456
  // array is read part by part, and so is each array in it. An array is read
package/dist/types.d.ts CHANGED
@@ -62,6 +62,7 @@ export interface QualifiedField {
62
62
  /** snake_case schema field name */
63
63
  name: string;
64
64
  label: string;
65
+ title?: string;
65
66
  description: string;
66
67
  type: FieldType;
67
68
  multiline: boolean;
@@ -70,8 +71,6 @@ export interface QualifiedField {
70
71
  keep: boolean;
71
72
  /** `/Ff` bit 2: the form says this one has to be filled. */
72
73
  required: boolean;
73
- /** The radio group this option belongs to, when it is one. */
74
- group?: string;
75
74
  }
76
75
  export interface QualifiedForm {
77
76
  quill: {
@@ -82,20 +81,35 @@ export interface QualifiedForm {
82
81
  fields: QualifiedField[];
83
82
  }
84
83
  export declare const SNAKE_CASE: RegExp;
85
- /** A quill as files, the shape `Quill.fromTree` and a quiver both take. */
84
+ /** A quill as files, the shape `Quill.fromTree` takes. */
86
85
  export type QuillTree = Map<string, Uint8Array>;
87
86
  /** The largest file `sanitize` opens. It bounds the compressed input. */
88
87
  export declare const MAX_BYTES: number;
89
88
  /**
90
89
  * The most a file may come to once qpdf has it: the file qpdf writes together
91
- * with what its Flate streams decode to, and qpdf's own memory. Of 1,423
92
- * published files the largest comes to 15,046,490 bytes (DAF 57), and qpdf's
93
- * memory is 17,235,968 bytes after each.
90
+ * with what its Flate streams decode to and what its images declare, and
91
+ * qpdf's own memory. Of 1,423 published files the largest comes to
92
+ * 48,477,587 bytes (AF 4101), and qpdf's memory is 17,235,968 bytes after
93
+ * each. Their largest image is 6,026 by 4,523 pixels at a bit each, 3,410,342
94
+ * bytes (AF 1520), and the one that declares the most bytes is 2,322 by 2,916
95
+ * at three bytes each, 20,312,856 (AF 4101).
94
96
  */
95
97
  export declare const MAX_INFLATED_BYTES: number;
96
98
  /** The most pages and fields a form has and is taken. The engine takes at most 1,000 fields in a block. */
97
99
  export declare const MAX_PAGES = 25;
98
100
  export declare const MAX_WIDGETS = 1000;
101
+ /** The limits a consumer prints before a read. A refusal's own `limit` is the one to print after it: `too_large` carries `MAX_INFLATED_BYTES` for the decoded bound. */
102
+ export declare const limits: Readonly<{
103
+ bytes: number;
104
+ pages: number;
105
+ fields: number;
106
+ }>;
107
+ /**
108
+ * The longest side a page has and is taken, in points, and the farthest its
109
+ * box reaches from the origin: the PDF specification's limit for a side (ISO
110
+ * 32000-1, Annex C). Of 2,127 published pages the longest is 3,456 (AFMC 1656).
111
+ */
112
+ export declare const MAX_SIDE = 14400;
99
113
  /**
100
114
  * The most pixels the pictures of a dynamic form come to, each use counted.
101
115
  * At four bytes a pixel that is 32 MB on a canvas, and as much in the
package/dist/types.js CHANGED
@@ -5,14 +5,29 @@ export const SNAKE_CASE = /^[a-z][a-z0-9_]*$/;
5
5
  export const MAX_BYTES = 8 * 1024 * 1024;
6
6
  /**
7
7
  * The most a file may come to once qpdf has it: the file qpdf writes together
8
- * with what its Flate streams decode to, and qpdf's own memory. Of 1,423
9
- * published files the largest comes to 15,046,490 bytes (DAF 57), and qpdf's
10
- * memory is 17,235,968 bytes after each.
8
+ * with what its Flate streams decode to and what its images declare, and
9
+ * qpdf's own memory. Of 1,423 published files the largest comes to
10
+ * 48,477,587 bytes (AF 4101), and qpdf's memory is 17,235,968 bytes after
11
+ * each. Their largest image is 6,026 by 4,523 pixels at a bit each, 3,410,342
12
+ * bytes (AF 1520), and the one that declares the most bytes is 2,322 by 2,916
13
+ * at three bytes each, 20,312,856 (AF 4101).
11
14
  */
12
15
  export const MAX_INFLATED_BYTES = 64 * 1024 * 1024;
13
16
  /** The most pages and fields a form has and is taken. The engine takes at most 1,000 fields in a block. */
14
17
  export const MAX_PAGES = 25;
15
18
  export const MAX_WIDGETS = 1000;
19
+ /** The limits a consumer prints before a read. A refusal's own `limit` is the one to print after it: `too_large` carries `MAX_INFLATED_BYTES` for the decoded bound. */
20
+ export const limits = Object.freeze({
21
+ bytes: MAX_BYTES,
22
+ pages: MAX_PAGES,
23
+ fields: MAX_WIDGETS
24
+ });
25
+ /**
26
+ * The longest side a page has and is taken, in points, and the farthest its
27
+ * box reaches from the origin: the PDF specification's limit for a side (ISO
28
+ * 32000-1, Annex C). Of 2,127 published pages the longest is 3,456 (AFMC 1656).
29
+ */
30
+ export const MAX_SIDE = 14_400;
16
31
  /**
17
32
  * The most pixels the pictures of a dynamic form come to, each use counted.
18
33
  * At four bytes a pixel that is 32 MB on a canvas, and as much in the
@@ -19,10 +19,10 @@ export interface XfaPage {
19
19
  * can zoom the frame, which the frame undoes, or `lay` throws.
20
20
  *
21
21
  * The frame runs no script, and its policy lets its document load only an
22
- * image from `blob:` or `data:`, which is how pdf.js hands it a form's images
23
- * and the stylesheet its field background. A face of the form is not the
24
- * document's to load: pdf.js makes it in the host page's realm, from the
25
- * file's bytes, a `local()` name or a face the package ships.
22
+ * image from `blob:`, which is how pdf.js hands it a form's images, or from
23
+ * `data:`. A face of the form is not the document's to load: pdf.js makes it
24
+ * in the host page's realm, from the file's bytes, a `local()` name or a face
25
+ * the package ships.
26
26
  */
27
27
  export declare class XfaStage {
28
28
  #private;
package/dist/xfa-layer.js CHANGED
@@ -21,10 +21,10 @@ const SHELL = `<meta http-equiv="Content-Security-Policy" content="default-src '
21
21
  * can zoom the frame, which the frame undoes, or `lay` throws.
22
22
  *
23
23
  * The frame runs no script, and its policy lets its document load only an
24
- * image from `blob:` or `data:`, which is how pdf.js hands it a form's images
25
- * and the stylesheet its field background. A face of the form is not the
26
- * document's to load: pdf.js makes it in the host page's realm, from the
27
- * file's bytes, a `local()` name or a face the package ships.
24
+ * image from `blob:`, which is how pdf.js hands it a form's images, or from
25
+ * `data:`. A face of the form is not the document's to load: pdf.js makes it
26
+ * in the host page's realm, from the file's bytes, a `local()` name or a face
27
+ * the package ships.
28
28
  */
29
29
  export class XfaStage {
30
30
  #frame = document.createElement('iframe');
@@ -61,6 +61,10 @@ export class XfaStage {
61
61
  const view = frameDocument.defaultView;
62
62
  const sheet = new view.CSSStyleSheet();
63
63
  sheet.replaceSync(css);
64
+ // pdf.js's sheet gives a field out of focus a background from a data: URL,
65
+ // which nothing here reads and a host's policy may refuse, a logged error
66
+ // for each field.
67
+ sheet.insertRule(':root { --xfa-unfocused-field-background: none; }', sheet.cssRules.length);
64
68
  frameDocument.adoptedStyleSheets = [sheet];
65
69
  // A sheet that did not apply leaves every field at its content size,
66
70
  // and the rects read off it are wrong with no error.
@@ -68,6 +68,9 @@ export function harvestWidgets(page, pageNumber) {
68
68
  const y = +(rect.top - origin.top).toFixed(2);
69
69
  const w = +rect.width.toFixed(2);
70
70
  const h = +rect.height.toFixed(2);
71
+ // pdf.js gives a control the place its layout comes to, on the page or not.
72
+ if (x >= page.width || y >= page.height || x + w <= 0 || y + h <= 0)
73
+ throw new Refused({ code: 'layout', detail: 'a field is off its page' });
71
74
  const tooltip = tooltipOf(el);
72
75
  const options = tag === 'select'
73
76
  ? [...el.options]