@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/xfa.js CHANGED
@@ -44,8 +44,9 @@ export async function xfaToAcroForm(bytes, signal) {
44
44
  for (const span of laid.layer.querySelectorAll('.xfaPageCount'))
45
45
  span.textContent = String(pdf.numPages);
46
46
  const list = displayList(laid, number);
47
- for (const { image } of list.pictures)
48
- pixels += image.naturalWidth * image.naturalHeight;
47
+ for (const item of list.drawn)
48
+ if ('image' in item)
49
+ pixels += item.image.naturalWidth * item.image.naturalHeight;
49
50
  if (pixels > MAX_PICTURE_PIXELS)
50
51
  throw new Refused({
51
52
  code: 'unpaintable',
@@ -73,7 +74,36 @@ export async function xfaToAcroForm(bytes, signal) {
73
74
  /** PDF's origin is bottom-left; every primitive arrives top-left. */
74
75
  async function paint(page, pageNumber, list, font) {
75
76
  const H = list.height;
76
- for (let box of list.boxes) {
77
+ for (const item of list.drawn) {
78
+ if ('text' in item) {
79
+ await drawRun(page, pageNumber, item, H, font);
80
+ continue;
81
+ }
82
+ if ('image' in item) {
83
+ const { x, y, w, h, image } = item;
84
+ // The pixels are read off a canvas of the host page's. The frame's policy
85
+ // governs what its document loads, and the picture is of the page's origin.
86
+ const canvas = document.createElement('canvas');
87
+ canvas.width = image.naturalWidth;
88
+ canvas.height = image.naturalHeight;
89
+ canvas.getContext('2d').drawImage(image, 0, 0);
90
+ const png = canvas.toDataURL('image/png');
91
+ // What a canvas with a side the browser will not make gives.
92
+ if (png === 'data:,')
93
+ throw new Refused({ code: 'unpaintable', page: pageNumber - 1, construct: 'image' });
94
+ page.drawImage(await page.doc.embedPng(png), { x, y: H - y - h, width: w, height: h });
95
+ continue;
96
+ }
97
+ if ('x1' in item) {
98
+ page.drawLine({
99
+ start: { x: item.x1, y: H - item.y1 },
100
+ end: { x: item.x2, y: H - item.y2 },
101
+ thickness: item.width,
102
+ color: rgb(...item.colour)
103
+ });
104
+ continue;
105
+ }
106
+ let box = item;
77
107
  if (box.radius) {
78
108
  // An SVG rect's outline runs along the middle of its stroke, and each
79
109
  // radius stops at half the side it lies along.
@@ -125,27 +155,6 @@ async function paint(page, pageNumber, list, font) {
125
155
  if (right)
126
156
  seg(box.x + box.w - right.width / 2, box.y, box.x + box.w - right.width / 2, box.y + box.h, right);
127
157
  }
128
- for (const { x, y, w, h, image } of list.pictures) {
129
- // The pixels are read off a canvas of the host page's. The frame's policy
130
- // governs what its document loads, and the picture is of the page's origin.
131
- const canvas = document.createElement('canvas');
132
- canvas.width = image.naturalWidth;
133
- canvas.height = image.naturalHeight;
134
- canvas.getContext('2d').drawImage(image, 0, 0);
135
- const png = canvas.toDataURL('image/png');
136
- // What a canvas with a side the browser will not make gives.
137
- if (png === 'data:,')
138
- throw new Refused({ code: 'unpaintable', page: pageNumber - 1, construct: 'image' });
139
- page.drawImage(await page.doc.embedPng(png), { x, y: H - y - h, width: w, height: h });
140
- }
141
- for (const line of list.lines)
142
- page.drawLine({
143
- start: { x: line.x1, y: H - line.y1 },
144
- end: { x: line.x2, y: H - line.y2 },
145
- thickness: line.width,
146
- color: rgb(...line.colour)
147
- });
148
- return drawRuns(page, pageNumber, list.runs, H, font);
149
158
  }
150
159
  /**
151
160
  * The dash and the gap the browser paints along a dashed edge, or nothing for
@@ -168,53 +177,71 @@ function dashes(width, length) {
168
177
  const [wide, narrow] = [fit(count), fit(count + 1)];
169
178
  return [dash, narrow <= 0 || Math.abs(wide - gap) < Math.abs(narrow - gap) ? wide : narrow];
170
179
  }
171
- async function drawRuns(page, pageNumber, runs, H, get) {
172
- for (const run of runs) {
173
- const font = await get(standard14(run));
174
- // The standard-14 fonts encode WinAnsi alone. Dropping the run would lose a
175
- // line of the form without anything reporting it.
176
- const encodable = font.getCharacterSet();
177
- if ([...run.text].some((char) => !encodable.includes(char.codePointAt(0))))
180
+ /** The standard fonts that hold what the text fonts lack, in the order a character is looked for. */
181
+ const SYMBOLS = [StandardFonts.Symbol, StandardFonts.ZapfDingbats];
182
+ async function drawRun(page, pageNumber, run, H, get) {
183
+ const font = await get(standard14(run));
184
+ // The standard-14 text fonts encode WinAnsi alone. U+2010 and U+2011 are
185
+ // drawn as its hyphen, and any other character outside it in the first of
186
+ // 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. The
188
+ // one it breaks a line at is a hyphen in the run already.
189
+ // Dropping the run would lose a line of the form without anything
190
+ // reporting it.
191
+ // pdf-lib kerns the width it measures and writes no kerning, so the width
192
+ // it draws a run at is the sum of the widths of its characters.
193
+ const parts = [];
194
+ let natural = 0;
195
+ for (const char of run.text.replaceAll('\u00ad', '').replace(/[\u2010\u2011]/g, '-')) {
196
+ const code = char.codePointAt(0);
197
+ let face = font;
198
+ for (const name of SYMBOLS)
199
+ if (!face.getCharacterSet().includes(code))
200
+ face = await get(name);
201
+ if (!face.getCharacterSet().includes(code))
178
202
  throw new Refused({
179
203
  code: 'unpaintable',
180
204
  page: pageNumber - 1,
181
205
  construct: 'glyph',
182
206
  detail: run.text.slice(0, 40)
183
207
  });
184
- // The standard-14 substitute's advances differ from the embedded face's.
185
- // Letter spacing goes in first, since it is a real property of the run;
186
- // horizontal scaling then pins the whole run to the width the browser
187
- // measured, so the error stays inside the run instead of drifting across
188
- // the line.
189
- // pdf-lib kerns the width it measures and writes no kerning, so the width
190
- // it draws a run at is the sum of the widths of its characters.
191
- let natural = 0;
192
- for (const char of run.text)
193
- natural += font.widthOfTextAtSize(char, run.size);
194
- const spaced = natural + run.letterSpacing * Math.max(0, run.text.length - 1);
195
- const scale = spaced > 0 && run.width > 0 ? Math.max(50, Math.min(200, (100 * run.width) / spaced)) : 100;
196
- const scaled = Math.abs(scale - 100) > 3;
197
- // Both are text-state parameters, so they persist across drawText's own
198
- // BT/ET and have to be put back.
199
- if (run.letterSpacing)
200
- page.pushOperators(PDFOperator.of(PDFOperatorNames.SetCharacterSpacing, [
201
- String(+run.letterSpacing.toFixed(4))
202
- ]));
203
- if (scaled)
204
- page.pushOperators(PDFOperator.of(PDFOperatorNames.SetTextHorizontalScaling, [String(+scale.toFixed(3))]));
205
- page.drawText(run.text, {
206
- x: run.x,
207
- y: H - run.baseline,
208
+ const last = parts.at(-1);
209
+ if (last?.font === face)
210
+ last.text += char;
211
+ else
212
+ parts.push({ font: face, text: char, at: natural });
213
+ natural += face.widthOfTextAtSize(char, run.size) + run.letterSpacing;
214
+ }
215
+ // The standard-14 substitute's advances differ from the embedded face's.
216
+ // Letter spacing goes in first, since it is a real property of the run;
217
+ // horizontal scaling then pins the whole run to the width the browser
218
+ // measured, so the error stays inside the run instead of drifting across
219
+ // the line.
220
+ const spaced = natural - run.letterSpacing;
221
+ const scale = spaced > 0 && run.width > 0 ? Math.max(50, Math.min(200, (100 * run.width) / spaced)) : 100;
222
+ const scaled = Math.abs(scale - 100) > 3;
223
+ // Both are text-state parameters, so they persist across drawText's own
224
+ // BT/ET and have to be put back.
225
+ if (run.letterSpacing)
226
+ page.pushOperators(PDFOperator.of(PDFOperatorNames.SetCharacterSpacing, [String(+run.letterSpacing.toFixed(4))]));
227
+ if (scaled)
228
+ page.pushOperators(PDFOperator.of(PDFOperatorNames.SetTextHorizontalScaling, [String(+scale.toFixed(3))]));
229
+ const angle = (Math.PI / 2) * (run.turn ?? 0);
230
+ for (const part of parts) {
231
+ const along = scaled ? (part.at * scale) / 100 : part.at;
232
+ page.drawText(part.text, {
233
+ x: run.x + along * Math.cos(angle),
234
+ y: H - run.baseline + along * Math.sin(angle),
208
235
  size: run.size,
209
- font,
236
+ font: part.font,
210
237
  color: rgb(...run.colour),
211
238
  rotate: degrees(90 * (run.turn ?? 0))
212
239
  });
213
- if (scaled)
214
- page.pushOperators(PDFOperator.of(PDFOperatorNames.SetTextHorizontalScaling, ['100']));
215
- if (run.letterSpacing)
216
- page.pushOperators(PDFOperator.of(PDFOperatorNames.SetCharacterSpacing, ['0']));
217
240
  }
241
+ if (scaled)
242
+ page.pushOperators(PDFOperator.of(PDFOperatorNames.SetTextHorizontalScaling, ['100']));
243
+ if (run.letterSpacing)
244
+ page.pushOperators(PDFOperator.of(PDFOperatorNames.SetCharacterSpacing, ['0']));
218
245
  }
219
246
  function standard14(run) {
220
247
  const family = run.family.toLowerCase();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quillmark/quillify",
3
- "version": "0.3.0",
3
+ "version": "0.4.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,9 +48,10 @@
48
48
  },
49
49
  "devDependencies": {
50
50
  "@neslinesli93/qpdf-wasm": "0.3.0",
51
- "@quillmark/wasm": "0.124.0",
51
+ "@quillmark/wasm": "0.125.0",
52
52
  "@types/node": "24.19.0",
53
53
  "@vitest/browser-playwright": "5.0.2",
54
+ "fast-check": "4.10.2",
54
55
  "playwright": "1.63.0",
55
56
  "prettier": "3.9.9",
56
57
  "typescript": "6.0.3",