@quillmark/quillify 0.3.1 → 0.5.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/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
@@ -1,35 +1,27 @@
1
1
  /** Every quill the library builds is a first version. */
2
2
  export const QUILL_VERSION = '0.1.0';
3
3
  /**
4
- * A string as a YAML double-quoted scalar. JSON writes it, from a well formed
5
- * string, and the characters the engine's reader refuses and JSON leaves raw
6
- * (U+007F to U+009F, U+FFFE and U+FFFF) are written as `\u` escapes.
4
+ * JSON as YAML the engine reads: the characters its reader refuses and JSON
5
+ * leaves raw (U+007F to U+009F, U+FFFE and U+FFFF) are written as `\u` escapes.
7
6
  */
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
- }
7
+ const escaped = (json) => json.replace(/[\u007f-\u009f\ufffe\uffff]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
8
+ /** A string as a YAML double-quoted scalar, which JSON writes from a well formed string. */
9
+ const q = (s) => escaped(JSON.stringify(s.toWellFormed()));
17
10
  /**
18
11
  * A `default:` says nobody need answer the field. A required field declares
19
12
  * none, a boolean apart: it defaults to false either way.
20
13
  */
21
14
  function fieldYaml(field) {
22
15
  const lines = [` ${q(field.name)}:`];
23
- const ui = [];
24
- if (field.group)
25
- ui.push(` group: ${q(groupId(field.group))}`);
16
+ if (field.title !== undefined)
17
+ lines.push(` title: ${q(field.title)}`);
26
18
  switch (field.type) {
27
19
  case 'string':
28
- lines.push(' type: string');
20
+ lines.push(' type: text');
29
21
  if (!field.required)
30
22
  lines.push(' default: ""');
31
23
  if (field.multiline)
32
- ui.push(' multiline: true');
24
+ lines.push(' multiline: true');
33
25
  break;
34
26
  case 'boolean':
35
27
  lines.push(' type: boolean', ' default: false');
@@ -40,7 +32,7 @@ function fieldYaml(field) {
40
32
  // It refuses ";", ">" and "|" in a value too, so a dropdown with one is a text box.
41
33
  const values = (field.options ?? []).filter((option) => option !== '');
42
34
  if (values.length === 0 || values.some((value) => /[;>|]/.test(value)))
43
- lines.push(' type: string');
35
+ lines.push(' type: text');
44
36
  else {
45
37
  lines.push(' type: enum', ' values:');
46
38
  for (const value of values)
@@ -51,14 +43,11 @@ function fieldYaml(field) {
51
43
  break;
52
44
  }
53
45
  case 'array':
54
- lines.push(' type: array', ' items:', ' type: string');
46
+ lines.push(' type: array', ' items:', ' type: text');
55
47
  if (!field.required)
56
48
  lines.push(' default: []');
57
- ui.push(' multiline: true');
58
49
  break;
59
50
  }
60
- if (ui.length > 0)
61
- lines.push(' ui:', ...ui);
62
51
  lines.push(` description: ${q(field.description)}`);
63
52
  return lines;
64
53
  }
@@ -74,9 +63,6 @@ export function quillYaml(form, fields) {
74
63
  ' body:',
75
64
  ' enabled: false'
76
65
  ];
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
66
  if (fields.length > 0) {
81
67
  lines.push(' fields:');
82
68
  for (const field of fields)
@@ -142,7 +128,7 @@ export function assembleQuill(form, inventory, formPdf) {
142
128
  const tree = new Map([
143
129
  ['Quill.yaml', encoder.encode(quillYaml(form, bound))],
144
130
  ['form.pdf', formPdf],
145
- ['form.json', encoder.encode(JSON.stringify(formJson, null, 2) + '\n')]
131
+ ['form.yaml', encoder.encode(escaped(JSON.stringify(formJson, null, 2)) + '\n')]
146
132
  ]);
147
133
  const example = exampleMarkdown(form, bound);
148
134
  if (example)
@@ -274,12 +274,13 @@ function svgOnly(tag) {
274
274
  * One run per visual line, with the box the browser measured it into. Splitting
275
275
  * where the rect top changes is what keeps a wrapped paragraph's second line at
276
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.
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.
278
279
  */
279
280
  function collectText(node, X, Y, drawn, turns) {
280
- const text = node.nodeValue;
281
+ let text = node.nodeValue ?? '';
281
282
  const parent = node.parentElement;
282
- if (!text || !text.trim() || !parent)
283
+ if (!text.trim() || !parent)
283
284
  return;
284
285
  // A <select> is de-chromed above; only the option the form shows is drawn.
285
286
  if (parent.tagName === 'OPTION' && !parent.selected)
@@ -347,10 +348,15 @@ function collectText(node, X, Y, drawn, turns) {
347
348
  }
348
349
  range.setStart(node, i);
349
350
  range.setEnd(node, i + 1);
350
- const rect = range.getClientRects()[0];
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];
351
355
  if (!rect)
352
356
  continue;
353
357
  const glyph = upright(rect);
358
+ if (text[i] === '\u00ad' && glyph.right > glyph.x)
359
+ text = `${text.slice(0, i)}-${text.slice(i + 1)}`;
354
360
  if (top === null || Math.abs(glyph.top - top) < 0.6) {
355
361
  top ??= glyph.top;
356
362
  box = box
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))
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/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,7 +81,7 @@ 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;
@@ -8,7 +8,7 @@ export interface XfaWidget {
8
8
  name: string;
9
9
  kind: XfaKind;
10
10
  page: number;
11
- /** Points, top-left origin, page-relative: the rect `form.json` wants. */
11
+ /** Points, top-left origin, page-relative: the rect `form.yaml` wants. */
12
12
  x: number;
13
13
  y: number;
14
14
  w: number;
package/dist/xfa.js CHANGED
@@ -54,9 +54,11 @@ export async function xfaToAcroForm(bytes, signal) {
54
54
  construct: 'image',
55
55
  detail: 'pixels'
56
56
  });
57
- for (const widget of harvestWidgets(laid, number))
57
+ const harvested = harvestWidgets(laid, number);
58
+ for (const widget of harvested)
58
59
  widgets.push(widget);
59
- await paint(out.addPage([list.width, list.height]), number, list, async (name) => {
60
+ const shown = whiten(list, harvested);
61
+ await paint(out.addPage([list.width, list.height]), number, shown, async (name) => {
60
62
  let font = fonts.get(name);
61
63
  if (!font)
62
64
  fonts.set(name, (font = await out.embedFont(name)));
@@ -71,6 +73,28 @@ export async function xfaToAcroForm(bytes, signal) {
71
73
  await opened?.close();
72
74
  }
73
75
  }
76
+ /**
77
+ * The engine fills every field in black, whatever colour the form gives its
78
+ * text. A field whose middle lies on a fill that black reads on at under 4.5:1,
79
+ * WCAG's floor for text, gets a white box drawn just over that fill.
80
+ */
81
+ function whiten(list, widgets) {
82
+ const drawn = [...list.drawn];
83
+ const filled = (item) => 'edges' in item && item.fill !== null;
84
+ for (const { x, y, w, h } of widgets) {
85
+ const [u, v] = [x + w / 2, y + h / 2];
86
+ const at = drawn.findLastIndex((item) => filled(item) && item.x <= u && u <= item.x + item.w && item.y <= v && v <= item.y + item.h);
87
+ const under = drawn[at];
88
+ if (at >= 0 && filled(under) && (luminance(under.fill) + 0.05) / 0.05 < 4.5)
89
+ drawn.splice(at + 1, 0, { x, y, w, h, fill: [1, 1, 1], edges: [null, null, null, null] });
90
+ }
91
+ return { ...list, drawn };
92
+ }
93
+ /** The relative luminance WCAG gives a colour in sRGB. */
94
+ function luminance(colour) {
95
+ const [r, g, b] = colour.map((c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
96
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
97
+ }
74
98
  /** PDF's origin is bottom-left; every primitive arrives top-left. */
75
99
  async function paint(page, pageNumber, list, font) {
76
100
  const H = list.height;
@@ -184,7 +208,8 @@ async function drawRun(page, pageNumber, run, H, get) {
184
208
  // The standard-14 text fonts encode WinAnsi alone. U+2010 and U+2011 are
185
209
  // drawn as its hyphen, and any other character outside it in the first of
186
210
  // SYMBOLS that holds it. A soft hyphen is left out: WinAnsi has it as a
187
- // hyphen, and the browser lays it out at no width and shows nothing.
211
+ // hyphen, and the browser lays it out at no width and shows nothing. The
212
+ // one it breaks a line at is a hyphen in the run already.
188
213
  // Dropping the run would lose a line of the form without anything
189
214
  // reporting it.
190
215
  // pdf-lib kerns the width it measures and writes no kerning, so the width
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quillmark/quillify",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "description": "Turn a published PDF form (AcroForm or XFA) into a Quillmark quill, in the browser.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -48,7 +48,7 @@
48
48
  },
49
49
  "devDependencies": {
50
50
  "@neslinesli93/qpdf-wasm": "0.3.0",
51
- "@quillmark/wasm": "0.124.0",
51
+ "@quillmark/wasm": "0.126.0",
52
52
  "@types/node": "24.19.0",
53
53
  "@vitest/browser-playwright": "5.0.2",
54
54
  "fast-check": "4.10.2",
@@ -63,7 +63,7 @@
63
63
  "pdfjs-dist": "6.4.299"
64
64
  },
65
65
  "peerDependencies": {
66
- "@quillmark/wasm": ">=0.124.0"
66
+ "@quillmark/wasm": ">=0.126.0"
67
67
  },
68
68
  "publishConfig": {
69
69
  "access": "public"