@zumer/snapdom-plugins 3.0.0 → 4.0.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 (77) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +50 -16
  3. package/agent-map.d.ts +32 -0
  4. package/agent-map.js +5 -3
  5. package/ascii-export.d.ts +10 -0
  6. package/ascii-export.js +21 -6
  7. package/capture-frames.js +5 -5
  8. package/color-tint.js +4 -0
  9. package/context-export.d.ts +23 -0
  10. package/context-export.js +4 -1
  11. package/filter.js +1 -1
  12. package/gif-export.d.ts +6 -0
  13. package/gif-export.js +3 -3
  14. package/html-export.d.ts +8 -0
  15. package/html-export.js +6 -4
  16. package/index.d.ts +2 -1
  17. package/index.js +2 -1
  18. package/package.json +16 -5
  19. package/pdf/LICENSE +21 -0
  20. package/pdf/README.md +22 -0
  21. package/pdf/REFERENCE.md +520 -0
  22. package/pdf/src/frontmatter.js +200 -0
  23. package/pdf/src/furniture.js +398 -0
  24. package/pdf/src/index.js +2415 -0
  25. package/pdf/src/mega/README.md +47 -0
  26. package/pdf/src/mega/diag.js +148 -0
  27. package/pdf/src/mega/flow.js +56 -0
  28. package/pdf/src/mega/fonts.js +287 -0
  29. package/pdf/src/mega/forms.js +479 -0
  30. package/pdf/src/mega/labels.js +43 -0
  31. package/pdf/src/mega/meta.js +145 -0
  32. package/pdf/src/nav.js +357 -0
  33. package/pdf/src/paginate.js +583 -0
  34. package/pdf/src/tagged.js +355 -0
  35. package/pdf/src/text-layer.js +1771 -0
  36. package/pdf/src/writer/encrypt.js +218 -0
  37. package/pdf/src/writer/font.js +507 -0
  38. package/pdf/src/writer/index.js +1135 -0
  39. package/pdf/src/writer/subset.js +387 -0
  40. package/pdf.d.ts +294 -0
  41. package/pdf.js +1 -0
  42. package/redact-inputs.js +6 -6
  43. package/vector/CONTRACT.md +906 -0
  44. package/vector/LICENSE +21 -0
  45. package/vector/README.md +35 -0
  46. package/vector/src/adapters/snapdom.js +795 -0
  47. package/vector/src/collect/box.js +1676 -0
  48. package/vector/src/collect/font.js +487 -0
  49. package/vector/src/collect/image.js +1458 -0
  50. package/vector/src/collect/mathml.js +272 -0
  51. package/vector/src/collect/text.js +3027 -0
  52. package/vector/src/css/color.js +823 -0
  53. package/vector/src/css/gradient.js +1126 -0
  54. package/vector/src/css/matrix.js +398 -0
  55. package/vector/src/css/radius.js +124 -0
  56. package/vector/src/css/shadow.js +201 -0
  57. package/vector/src/emit/figma-json.js +1995 -0
  58. package/vector/src/emit/h2d-vector.js +215 -0
  59. package/vector/src/emit/h2d.js +1147 -0
  60. package/vector/src/emit/pdf.js +2571 -0
  61. package/vector/src/emit/svg-flat.js +4842 -0
  62. package/vector/src/exclusion.js +13 -0
  63. package/vector/src/index.js +2910 -0
  64. package/vector/src/paint.js +2908 -0
  65. package/vector/src/public.js +57 -0
  66. package/vector/src/writer/encrypt.js +227 -0
  67. package/vector/src/writer/font.js +507 -0
  68. package/vector/src/writer/index.js +1148 -0
  69. package/vector/src/writer/subset.js +387 -0
  70. package/vector/svd/index.js +72 -0
  71. package/vector/svd/schema.js +985 -0
  72. package/vector.d.ts +28 -0
  73. package/vector.js +1 -0
  74. package/video-export.d.ts +6 -0
  75. package/video-export.js +7 -7
  76. package/pdf-image.d.ts +0 -18
  77. package/pdf-image.js +0 -114
@@ -0,0 +1,479 @@
1
+ /**
2
+ * Fillable forms: a captured control becomes a real AcroForm field.
3
+ *
4
+ * The reasoning is the plugin's own, extended one step. A capture of a form is a
5
+ * PICTURE of a form; the invisible text layer already made the picture's values
6
+ * selectable and searchable. But a form's meaning is not its pixels — it is that
7
+ * someone can FILL it — and this exporter starts from the DOM, where the field
8
+ * boundaries, names, values, options and states are facts, not inferences. No
9
+ * competitor starting from a raster gets them at all; one starting from a layout
10
+ * engine gets them without the person's typed values. We get both for free.
11
+ *
12
+ * The appearance rule is dictated by the one viewer that will not be argued
13
+ * with. PDFKit — macOS Preview, Quick Look — draws a text field's /V ITSELF, on
14
+ * top of the appearance stream, whenever that stream contains an image XObject
15
+ * or shows no visible text (MEASURED against a probe matrix of appearance
16
+ * variants; `Tr 3` does not count as visible, and a painted-then-covered value
17
+ * does not either once any `Do` is present). So:
18
+ *
19
+ * - **a field holding a value** gets a REAL text appearance — background,
20
+ * border, and the value drawn from the text layer's own measured runs, so a
21
+ * textarea's wrap and an input's alignment are the layout's, not a guess;
22
+ * - **an empty field** gets the control's own crop of the page raster —
23
+ * border, corner radius and placeholder pixel-exact — because with no /V
24
+ * there is nothing PDFKit wants to redraw over it;
25
+ * - the PAGE image stops painting every fielded control (see index.js), so no
26
+ * viewer ever shows a stale value under a live field.
27
+ *
28
+ * What deliberately does NOT happen here:
29
+ *
30
+ * - **A password value never reaches the file.** The field exists, flagged as a
31
+ * password field, and starts empty. The text layer's bullet rule, restated.
32
+ * - **No JavaScript actions, no submit URLs.** A generated file that phones home
33
+ * is a liability, not a feature. Filling and saving is the scope.
34
+ * - **Buttons stay pixels.** A push button with no action is decoration, and
35
+ * inventing actions is exactly what this plugin never does.
36
+ *
37
+ * The capture half lives in the text layer's walk (`collectTextLayer`'s control
38
+ * records), because field geometry must come from the same measurement pass and
39
+ * the same visibility rules as everything else. This file plans fields from
40
+ * those records and writes the AcroForm bytes.
41
+ */
42
+ import { pdfTextString, pdfString } from '../writer/index.js'
43
+
44
+ /** Field flag bits, 1-based as the spec numbers them. */
45
+ const FF = {
46
+ readOnly: 1 << 0,
47
+ required: 1 << 1,
48
+ multiline: 1 << 12,
49
+ password: 1 << 13,
50
+ noToggleToOff: 1 << 14,
51
+ radio: 1 << 15,
52
+ combo: 1 << 17,
53
+ multiSelect: 1 << 21,
54
+ }
55
+
56
+ /** /Q — quadding. */
57
+ const ALIGN = { left: 0, center: 1, right: 2 }
58
+
59
+ const num = (n) => Number(n.toFixed(3))
60
+ const rgb = (c) => c.map(v => num(v)).join(' ')
61
+
62
+ /** What the control visibly SAYS — a select says its selected label, a password
63
+ * says nothing anywhere. */
64
+ const displayValue = (c) => {
65
+ if (c.kind === 'password') return ''
66
+ if (c.kind === 'select') {
67
+ return c.options.filter(o => o.selected).map(o => o.label || o.value).join(', ')
68
+ }
69
+ return String(c.value || '')
70
+ }
71
+
72
+ /**
73
+ * Whether this field's appearance should be the control's raster crop. Only an
74
+ * EMPTY text-ish field can afford one: PDFKit redraws /V over any appearance
75
+ * carrying an image, so a valued field must use a text appearance, and a
76
+ * password field must look empty rather than show the crop's bullets.
77
+ * Exported so index.js encodes a crop only when one will be referenced.
78
+ */
79
+ export function wantsCropAP(field, control) {
80
+ if (field.kind === 'checkbox' || field.kind === 'radio' || field.kind === 'password') return false
81
+ return displayValue(control) === ''
82
+ }
83
+
84
+ /**
85
+ * Group control records into fields, radio groups included, and settle names.
86
+ *
87
+ * PDF field names are a hierarchy keyed by /T, and two unrelated fields sharing
88
+ * one name become ONE field with one value — HTML's behaviour only for radios.
89
+ * So radios group by name on purpose and everything else is uniquified with a
90
+ * suffix, reported once.
91
+ *
92
+ * @param {Array<object>} controls records from the text layer's control walk
93
+ * @param {(msg: string) => void} report
94
+ * @returns {{fields: Array<object>, suppressed: Set<number>, needAppearances: boolean}}
95
+ * `suppressed` holds text-layer run indices whose words now live in a field's
96
+ * value — leaving them in the invisible layer would double every extraction.
97
+ */
98
+ export function planFields(controls, report) {
99
+ const fields = []
100
+ const suppressed = new Set()
101
+ const radios = new Map()
102
+ const used = new Map()
103
+ let renamed = 0
104
+ let skippedTransform = 0
105
+ let skippedClipped = 0
106
+ let passwords = 0
107
+ let needAppearances = false
108
+
109
+ const uniqueName = (wanted) => {
110
+ const base = wanted || 'field'
111
+ const n = used.get(base) || 0
112
+ used.set(base, n + 1)
113
+ if (n === 0) return base
114
+ renamed++
115
+ return `${base}.${n + 1}`
116
+ }
117
+
118
+ for (let i = 0; i < controls.length; i++) {
119
+ const c = controls[i]
120
+ if (c.skip === 'transformed') { skippedTransform++; continue }
121
+ if (c.skip === 'clipped') { skippedClipped++; continue }
122
+
123
+ if (c.kind === 'radio') {
124
+ // One field per group; the group key is the HTML name, which is the
125
+ // grouping HTML itself uses. A nameless radio is its own group.
126
+ const key = c.name || `radio.${i}`
127
+ if (!radios.has(key)) {
128
+ const field = {
129
+ kind: 'radio', name: null, wantedName: c.name || 'radio',
130
+ label: c.label, members: [], flags: FF.radio | FF.noToggleToOff,
131
+ }
132
+ radios.set(key, field)
133
+ fields.push(field)
134
+ }
135
+ const field = radios.get(key)
136
+ field.members.push({ i, control: c })
137
+ if (!field.label && c.label) field.label = c.label
138
+ if (c.readonly) field.flags |= FF.readOnly
139
+ if (c.required) field.flags |= FF.required
140
+ continue
141
+ }
142
+
143
+ let flags = 0
144
+ if (c.readonly) flags |= FF.readOnly
145
+ if (c.required) flags |= FF.required
146
+ if (c.kind === 'multiline') flags |= FF.multiline
147
+ if (c.kind === 'password') { flags |= FF.password; passwords++ }
148
+ if (c.kind === 'select' && !c.listbox) flags |= FF.combo
149
+ if (c.kind === 'select' && c.multiple) flags |= FF.multiSelect
150
+
151
+ // A value the base-14 fonts cannot draw still belongs in /V — pdfTextString
152
+ // carries any codepoint — but our synthesized appearance cannot paint it, so
153
+ // the viewer must be told to draw its own.
154
+ const value = c.kind === 'password' ? '' : (c.value || '')
155
+ if (value && pdfString(value) === null) needAppearances = true
156
+
157
+ fields.push({
158
+ kind: c.kind, wantedName: c.name || c.id || null, label: c.label,
159
+ flags, members: [{ i, control: c }],
160
+ })
161
+ for (const r of c.runs || []) suppressed.add(r)
162
+ }
163
+
164
+ // Radio value runs do not exist (no text), but their names settle with
165
+ // everything else's, in document order.
166
+ for (const field of fields) field.name = uniqueName(field.wantedName)
167
+
168
+ if (skippedTransform) {
169
+ report(`${skippedTransform} form control(s) did not become a fillable field — a rotated or ` +
170
+ 'skewed control cannot carry an axis-aligned widget. Their values stay in the text layer.')
171
+ }
172
+ if (skippedClipped) {
173
+ report(`${skippedClipped} form control(s) did not become a fillable field — an overflow clip ` +
174
+ 'hides part of them, and a widget must not reach outside what the capture shows.')
175
+ }
176
+ if (renamed) {
177
+ report(`${renamed} duplicate field name(s) uniquified with a suffix — PDF fields sharing a ` +
178
+ 'name share one value, which HTML controls do not.')
179
+ }
180
+ if (passwords) {
181
+ report(`${passwords} password field(s) written empty: a fillable field's value is plain text ` +
182
+ 'in the file, and a password must never be. The painted bullets leave the page with it.')
183
+ }
184
+ if (needAppearances) {
185
+ report('a field value is outside base-14 — /NeedAppearances is set so the viewer draws it ' +
186
+ 'with its own fonts.')
187
+ }
188
+ return { fields, suppressed, needAppearances }
189
+ }
190
+
191
+ /**
192
+ * Byte writer for the planned fields. One instance per export.
193
+ *
194
+ * Every widget id is RESERVED when its page is drawn — the page's /Annots needs
195
+ * it then — and FILLED at `finish()`, because a radio member needs its parent
196
+ * field's id and a tagged widget needs its /StructParent key, and neither exists
197
+ * until every page is done. The same deferral the page tree itself uses.
198
+ */
199
+ export function createFieldWriter(doc, { report }) {
200
+ let helv = null
201
+ const helvId = () => {
202
+ if (helv === null) {
203
+ helv = doc.add('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>')
204
+ }
205
+ return helv
206
+ }
207
+ const placed = new Map()
208
+ let straddled = 0
209
+
210
+ /** Ops painting the synthesized face: background always opaque — the widget
211
+ * owns its pixels, and a translucent face would show stale page ink under the
212
+ * new value the day the field is edited. */
213
+ const faceOps = (c, w, h, { withBorder = true } = {}) => {
214
+ const ops = []
215
+ const bg = c.style.bg || [1, 1, 1]
216
+ ops.push(`${rgb(bg)} rg 0 0 ${num(w)} ${num(h)} re f`)
217
+ if (withBorder && c.style.borderW > 0) {
218
+ const bw = Math.min(c.style.borderW, w / 2, h / 2)
219
+ ops.push(`${rgb(c.style.border || [0.6, 0.6, 0.6])} RG ${num(bw)} w ` +
220
+ `${num(bw / 2)} ${num(bw / 2)} ${num(w - bw)} ${num(h - bw)} re S`)
221
+ }
222
+ return ops
223
+ }
224
+
225
+ /** The control's own crop of the capture, filling the widget box. */
226
+ const cropOps = (w, h) => [`q ${num(w)} 0 0 ${num(h)} 0 0 cm /Ic Do Q`]
227
+
228
+ /** One appearance stream: a Form XObject over the widget's own box. */
229
+ const ap = (w, h, ops, resources = '') =>
230
+ doc.addStream(
231
+ `/Type /XObject /Subtype /Form /BBox [0 0 ${num(w)} ${num(h)}]` +
232
+ (resources ? ` /Resources << ${resources} >>` : ''),
233
+ ops.join('\n')
234
+ )
235
+
236
+ /** The check mark, drawn as a path so no dingbat font travels. */
237
+ const checkOps = (c, w, h) => {
238
+ const s = Math.min(w, h)
239
+ const x = (w - s) / 2
240
+ const y = (h - s) / 2
241
+ return [
242
+ `${rgb(c.style.color || [0, 0, 0])} RG ${num(Math.max(1, s * 0.12))} w 1 J 1 j`,
243
+ `${num(x + s * 0.22)} ${num(y + s * 0.52)} m ` +
244
+ `${num(x + s * 0.44)} ${num(y + s * 0.28)} l ` +
245
+ `${num(x + s * 0.78)} ${num(y + s * 0.72)} l S`,
246
+ ]
247
+ }
248
+
249
+ /** Four beziers approximating a circle around the box centre. */
250
+ const circlePath = (w, h, r) => {
251
+ const cx = w / 2
252
+ const cy = h / 2
253
+ const k = r * 0.5523
254
+ return `${num(cx + r)} ${num(cy)} m ` +
255
+ `${num(cx + r)} ${num(cy + k)} ${num(cx + k)} ${num(cy + r)} ${num(cx)} ${num(cy + r)} c ` +
256
+ `${num(cx - k)} ${num(cy + r)} ${num(cx - r)} ${num(cy + k)} ${num(cx - r)} ${num(cy)} c ` +
257
+ `${num(cx - r)} ${num(cy - k)} ${num(cx - k)} ${num(cy - r)} ${num(cx)} ${num(cy - r)} c ` +
258
+ `${num(cx + k)} ${num(cy - r)} ${num(cx + r)} ${num(cy - k)} ${num(cx + r)} ${num(cy)} c`
259
+ }
260
+
261
+ /** A filled circle — the radio dot. */
262
+ const dotOps = (c, w, h) => [
263
+ `${rgb(c.style.color || [0, 0, 0])} rg`,
264
+ `${circlePath(w, h, Math.min(w, h) * 0.22)} f`,
265
+ ]
266
+
267
+ /** A native control's own computed border is 0 — its chrome is themed paint —
268
+ * so a synthesized check/radio takes this stroke instead of vanishing into
269
+ * the page as a bare white shape. */
270
+ const CHROME = [0.45, 0.48, 0.53]
271
+
272
+ /** The radio's ring. Always a circle: a radio with a square border is a lie. */
273
+ const ringOps = (c, w, h) => {
274
+ const bw = c.style.borderW > 0 ? Math.min(c.style.borderW, 2) : 1
275
+ return [
276
+ `${rgb(c.style.border || CHROME)} RG ${num(bw)} w`,
277
+ `${circlePath(w, h, Math.min(w, h) / 2 - bw / 2)} S`,
278
+ ]
279
+ }
280
+
281
+ /** The checkbox's square border, with the same native-chrome fallback. */
282
+ const boxOps = (c, w, h) => {
283
+ const bw = c.style.borderW > 0 ? Math.min(c.style.borderW, 2) : 1
284
+ return [
285
+ `${rgb(c.style.border || CHROME)} RG ${num(bw)} w ` +
286
+ `${num(bw / 2)} ${num(bw / 2)} ${num(w - bw)} ${num(h - bw)} re S`,
287
+ ]
288
+ }
289
+
290
+ /**
291
+ * The value as visible text operators.
292
+ *
293
+ * The preferred source is `lines` — the control's own runs from the text
294
+ * layer, re-based into the widget's box — because they carry the REAL layout:
295
+ * a textarea's wrap, an input's alignment, a scrolled value's offset. The
296
+ * single centred line is the fallback for a control measured without them.
297
+ */
298
+ const valueOps = (c, w, h, sizePt, lines) => {
299
+ if (lines && lines.length) {
300
+ const ops = [`${rgb(c.style.color || [0, 0, 0])} rg`]
301
+ for (const line of lines) {
302
+ const str = pdfString(line.text)
303
+ if (!str) continue
304
+ ops.push(`BT /Fh ${num(line.size)} Tf ${num(line.x)} ${num(line.y)} Td ${str} Tj ET`)
305
+ }
306
+ if (ops.length > 1) return ops
307
+ }
308
+ const value = displayValue(c)
309
+ if (!value) return []
310
+ const line = c.kind === 'multiline' ? value.split('\n')[0] : value
311
+ const str = pdfString(line)
312
+ if (!str) return [] // NeedAppearances covers it; planFields already reported.
313
+ const pad = Math.max(2, sizePt * 0.35)
314
+ const y = c.kind === 'multiline' ? h - sizePt - pad : (h - sizePt) / 2 + sizePt * 0.18
315
+ return [
316
+ `${rgb(c.style.color || [0, 0, 0])} rg`,
317
+ `BT /Fh ${num(sizePt)} Tf ${num(pad)} ${num(y)} Td ${str} Tj ET`,
318
+ ]
319
+ }
320
+
321
+ /** The combo's disclosure arrow, so a synthesized select still reads as one. */
322
+ const arrowOps = (c, w, h) => {
323
+ const s = Math.min(h * 0.22, 5)
324
+ const cx = w - s * 2.2
325
+ const cy = h / 2
326
+ return [
327
+ '0.42 0.45 0.5 rg',
328
+ `${num(cx - s)} ${num(cy + s * 0.55)} m ${num(cx + s)} ${num(cy + s * 0.55)} l ` +
329
+ `${num(cx)} ${num(cy - s * 0.65)} l f`,
330
+ ]
331
+ }
332
+
333
+ return {
334
+ /** Count a widget clamped to one page of a control that crossed a break. */
335
+ straddle() { straddled++ },
336
+
337
+ /**
338
+ * Reserve one widget on a page. Returns its object id for /Annots — and for
339
+ * the structure registry, whose OBJR must point at the same object.
340
+ *
341
+ * @param {object} field a `planFields` field
342
+ * @param {object} member the field member drawn on this page
343
+ * @param {[number,number,number,number]} rect page points, [llx lly urx ury]
344
+ * @param {number} sizePt the value's type size on the page
345
+ * @param {number|null} cropId image XObject of the control's own raster crop
346
+ */
347
+ widget(field, member, rect, sizePt, cropId, lines = null) {
348
+ const id = doc.reserve()
349
+ placed.set(member, { field, member, rect, sizePt, cropId, lines, id })
350
+ return id
351
+ },
352
+
353
+ /**
354
+ * Write every field and widget; return the catalog's /AcroForm value.
355
+ * @param {(member: object) => number|null} structKey the widget's
356
+ * /StructParent key, or null when untagged
357
+ */
358
+ finish(structKey, needAppearances) {
359
+ if (!placed.size) return null
360
+ const fieldRefs = []
361
+ const byField = new Map()
362
+ for (const entry of placed.values()) {
363
+ if (!byField.has(entry.field)) byField.set(entry.field, [])
364
+ byField.get(entry.field).push(entry)
365
+ }
366
+ if (straddled) {
367
+ report(`${straddled} field annotation(s) straddle a page break and were clamped to one ` +
368
+ 'page — a widget cannot span two.')
369
+ }
370
+
371
+ for (const [field, entries] of byField) {
372
+ const c0 = entries[0].member.control
373
+ const common =
374
+ ` /T ${pdfTextString(field.name)}` +
375
+ (field.label ? ` /TU ${pdfTextString(field.label)}` : '') +
376
+ ` /Ff ${field.flags}`
377
+
378
+ if (field.kind === 'radio') {
379
+ const parentId = doc.reserve()
380
+ // With /Opt present the appearance states are option indices, which
381
+ // sidesteps every "is this value a legal /Name?" question.
382
+ const opts = field.members.map(m => pdfTextString(m.control.value || 'on'))
383
+ const checked = field.members.findIndex(m => m.control.checked)
384
+ for (const entry of entries) {
385
+ const idx = field.members.indexOf(entry.member)
386
+ const c = entry.member.control
387
+ const [llx, lly, urx, ury] = entry.rect
388
+ const w = urx - llx
389
+ const h = ury - lly
390
+ const on = ap(w, h, [...faceOps(c, w, h, { withBorder: false }),
391
+ ...ringOps(c, w, h), ...dotOps(c, w, h)])
392
+ const off = ap(w, h, [...faceOps(c, w, h, { withBorder: false }), ...ringOps(c, w, h)])
393
+ doc.fill(entry.id,
394
+ `<< /Type /Annot /Subtype /Widget /Parent ${parentId} 0 R /F 4` +
395
+ ` /Rect [${entry.rect.map(num).join(' ')}]` +
396
+ ` /AS /${c.checked ? idx : 'Off'}` +
397
+ ` /MK << /BG [${rgb(c.style.bg || [1, 1, 1])}] >>` +
398
+ structRef(structKey(entry.member)) +
399
+ ` /AP << /N << /${idx} ${on} 0 R /Off ${off} 0 R >> >> >>`)
400
+ }
401
+ doc.fill(parentId,
402
+ `<< /FT /Btn${common} /V /${checked >= 0 ? checked : 'Off'}` +
403
+ ` /Opt [${opts.join(' ')}]` +
404
+ ` /Kids [${entries.map(e => `${e.id} 0 R`).join(' ')}] >>`)
405
+ fieldRefs.push(`${parentId} 0 R`)
406
+ continue
407
+ }
408
+
409
+ // Every other kind: one field, one widget, merged into one dictionary.
410
+ const entry = entries[0]
411
+ const [llx, lly, urx, ury] = entry.rect
412
+ const w = urx - llx
413
+ const h = ury - lly
414
+ const c = c0
415
+ let typed = ''
416
+ let apRef = ''
417
+
418
+ if (field.kind === 'checkbox') {
419
+ const on = ap(w, h, [...faceOps(c, w, h, { withBorder: false }),
420
+ ...boxOps(c, w, h), ...checkOps(c, w, h)])
421
+ const off = ap(w, h, [...faceOps(c, w, h, { withBorder: false }), ...boxOps(c, w, h)])
422
+ typed = ` /FT /Btn /V /${c.checked ? 'Yes' : 'Off'} /AS /${c.checked ? 'Yes' : 'Off'}`
423
+ apRef = ` /AP << /N << /Yes ${on} 0 R /Off ${off} 0 R >> >>`
424
+ } else {
425
+ // An EMPTY field wears the control's own crop — pixel-exact border,
426
+ // radius and placeholder, and nothing for PDFKit to redraw. A VALUED
427
+ // field wears real text: PDFKit repaints /V over any appearance
428
+ // carrying an image XObject, so the crop and a value cannot coexist.
429
+ const crop = wantsCropAP(field, c) && entry.cropId !== null
430
+ const face = faceOps(c, w, h, { withBorder: !crop })
431
+ const body = crop ? [] : valueOps(c, w, h, entry.sizePt, entry.lines)
432
+ const over = crop ? cropOps(w, h)
433
+ : (field.kind === 'select' && !c.listbox ? arrowOps(c, w, h) : [])
434
+ const resources = [
435
+ crop ? `/XObject << /Ic ${entry.cropId} 0 R >>` : '',
436
+ body.length ? `/Font << /Fh ${helvId()} 0 R >>` : '',
437
+ ].filter(Boolean).join(' ')
438
+ const stream = ap(w, h, [...face, ...body, ...over], resources)
439
+ apRef = ` /AP << /N ${stream} 0 R >>`
440
+ const da = ` /DA (${rgb(c.style.color || [0, 0, 0])} rg /Helv ${num(entry.sizePt)} Tf)` +
441
+ ` /Q ${ALIGN[c.style.align] ?? 0}`
442
+
443
+ if (field.kind === 'select') {
444
+ const opts = c.options.map(o =>
445
+ `[${pdfTextString(o.value)} ${pdfTextString(o.label)}]`).join(' ')
446
+ const selected = c.options.filter(o => o.selected)
447
+ const v = c.multiple
448
+ ? ` /V [${selected.map(o => pdfTextString(o.value)).join(' ')}]`
449
+ : (selected[0] ? ` /V ${pdfTextString(selected[0].value)}` : '')
450
+ const indices = selected.map(o => c.options.indexOf(o))
451
+ typed = ` /FT /Ch /Opt [${opts}]${v}` +
452
+ (indices.length ? ` /I [${indices.join(' ')}]` : '') + da
453
+ } else {
454
+ const value = field.kind === 'password' ? '' : String(c.value || '')
455
+ typed = ` /FT /Tx${value ? ` /V ${pdfTextString(value)}` : ''}` +
456
+ (c.maxLength > 0 ? ` /MaxLen ${c.maxLength}` : '') + da
457
+ }
458
+ }
459
+
460
+ doc.fill(entry.id,
461
+ `<< /Type /Annot /Subtype /Widget /F 4` +
462
+ ` /Rect [${entry.rect.map(num).join(' ')}]` +
463
+ typed + common +
464
+ ` /MK << /BG [${rgb(c.style.bg || [1, 1, 1])}]` +
465
+ (c.style.borderW > 0 ? ` /BC [${rgb(c.style.border || [0.6, 0.6, 0.6])}]` : '') + ` >>` +
466
+ (c.style.borderW > 0 ? ` /BS << /W ${num(Math.max(0.5, c.style.borderW))} /S /S >>` : '') +
467
+ structRef(structKey(entry.member)) +
468
+ apRef + ' >>')
469
+ fieldRefs.push(`${entry.id} 0 R`)
470
+ }
471
+
472
+ return `<< /Fields [${fieldRefs.join(' ')}]` +
473
+ ` /DR << /Font << /Helv ${helvId()} 0 R >> >> /DA (0 g /Helv 0 Tf)` +
474
+ (needAppearances ? ' /NeedAppearances true' : '') + ' >>'
475
+ },
476
+ }
477
+ }
478
+
479
+ const structRef = (key) => (key === null || key === undefined) ? '' : ` /StructParent ${key}`
@@ -0,0 +1,43 @@
1
+ /**
2
+ * /PageLabels: the viewer's page field agreeing with the printed folio.
3
+ *
4
+ * The generated contents already numbers BODY pages from 1 and leaves front
5
+ * matter out, and the pageNumbers furniture stamps the same body count. Without
6
+ * /PageLabels a viewer numbers physical pages — cover is "1", the first body
7
+ * page is "3" — and the index appears off by the size of the front matter.
8
+ * The number tree below is that same folio convention said in PDF: front matter
9
+ * in lowercase roman, body decimal from 1, a closing page continuing the body
10
+ * count because a colophon is still a page of the document.
11
+ *
12
+ * This is deliberately NOT configurable. The one honest knob — "make the labels
13
+ * match a custom `pageNumbers.format`" — cannot exist, because a format function
14
+ * cannot be mirrored into a static number tree. The default convention is the
15
+ * one every viewer, print dialog and "page 3 of 12" conversation assumes.
16
+ */
17
+
18
+ /**
19
+ * @param {object} counts
20
+ * @param {number} counts.front cover + contents pages
21
+ * @param {number} counts.body body pages
22
+ * @param {number} counts.back closing pages
23
+ * @returns {string|null} the /PageLabels VALUE — a number tree dictionary — or
24
+ * null when every label a viewer would invent is already right
25
+ */
26
+ export function pageLabels({ front, body, back }) {
27
+ // One physical page with no front matter labels itself "1" unaided; front
28
+ // matter or a multi-page body is where viewers start lying about folios.
29
+ if (!front && front !== 0) return null
30
+ if (front === 0 && back === 0) return null
31
+ const nums = []
32
+ if (front > 0) {
33
+ nums.push('0 << /S /r >>')
34
+ nums.push(`${front} << /S /D >>`)
35
+ } else {
36
+ nums.push('0 << /S /D >>')
37
+ }
38
+ // Back pages continue the body's decimal run — no entry needed: a number tree
39
+ // range extends until the next key.
40
+ void body
41
+ void back
42
+ return `<< /Nums [${nums.join(' ')}] >>`
43
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Document identity: the /Info dictionary, the XMP metadata stream, and the
3
+ * viewer preference that makes a title actually show.
4
+ *
5
+ * Two facts shape this file.
6
+ *
7
+ * **Dates are opt-in.** The byte-determinism promise — same capture, same
8
+ * options, same bytes — is asserted by the release gate, and `new Date()` here
9
+ * would break it silently. So `created`/`modified` are written when the caller
10
+ * passes them and never invented. A caller who wants "now" writes `new Date()`
11
+ * themselves, visibly, at the call site.
12
+ *
13
+ * **The /ID is content, not chance.** The trailer /ID comes from a hash of the
14
+ * file's own bytes (see the writer), so "is this the same document?" is
15
+ * answerable by any tool without this plugin's help.
16
+ *
17
+ * Producer names the software and never the document, which is why it is not an
18
+ * option: a knob that exists only to misattribute the generator is not a feature.
19
+ */
20
+ import { pdfTextString } from '../writer/index.js'
21
+
22
+ const PRODUCER = 'snapdom-pdf'
23
+
24
+ const META_KEYS = ['title', 'author', 'subject', 'keywords', 'creator', 'created', 'modified']
25
+
26
+ /**
27
+ * Validate and normalise the `meta` export option.
28
+ *
29
+ * @param {object|null} meta `{ title, author, subject, keywords, creator,
30
+ * created, modified }` — all optional. `keywords` may be a string or an array
31
+ * of strings. Dates may be `Date` instances or ISO-8601 strings.
32
+ * @param {(msg: string) => void} report
33
+ * @returns {object|null} the normalised spec, or null when nothing usable was given
34
+ */
35
+ export function metaSpec(meta, report) {
36
+ if (meta == null) return null
37
+ if (typeof meta !== 'object' || Array.isArray(meta)) {
38
+ throw new TypeError('[snapdom-pdf] meta must be an object of document properties')
39
+ }
40
+ const out = {}
41
+ for (const key of Object.keys(meta)) {
42
+ if (!META_KEYS.includes(key)) {
43
+ report(`meta.${key} is not a document property — ` +
44
+ `the properties are ${META_KEYS.join(', ')}. Ignored.`)
45
+ }
46
+ }
47
+ for (const key of ['title', 'author', 'subject', 'creator']) {
48
+ if (meta[key] == null) continue
49
+ const value = String(meta[key]).trim()
50
+ if (value) out[key] = value
51
+ }
52
+ if (meta.keywords != null) {
53
+ const list = Array.isArray(meta.keywords) ? meta.keywords : [meta.keywords]
54
+ const joined = list.map(k => String(k).trim()).filter(Boolean).join(', ')
55
+ if (joined) out.keywords = joined
56
+ }
57
+ for (const key of ['created', 'modified']) {
58
+ if (meta[key] == null) continue
59
+ const date = meta[key] instanceof Date ? meta[key] : new Date(String(meta[key]))
60
+ if (Number.isNaN(date.getTime())) {
61
+ report(`meta.${key} is not a date this plugin can read — pass a Date or an ISO-8601 string. Ignored.`)
62
+ continue
63
+ }
64
+ out[key] = date
65
+ }
66
+ return Object.keys(out).length ? out : null
67
+ }
68
+
69
+ /** A `Date` as the spec's `D:YYYYMMDDHHmmSS` form, in UTC so the bytes do not
70
+ * depend on the timezone of whoever exported. */
71
+ function pdfDate(date) {
72
+ const p = (n, w = 2) => String(n).padStart(w, '0')
73
+ return `D:${date.getUTCFullYear()}${p(date.getUTCMonth() + 1)}${p(date.getUTCDate())}` +
74
+ `${p(date.getUTCHours())}${p(date.getUTCMinutes())}${p(date.getUTCSeconds())}Z`
75
+ }
76
+
77
+ /**
78
+ * The /Info dictionary body. Written even when the caller gave no `meta`,
79
+ * because /Producer is how a triaged support case tells this exporter's files
80
+ * from another tool's.
81
+ */
82
+ export function infoDict(spec) {
83
+ const parts = [`/Producer ${pdfTextString(PRODUCER)}`]
84
+ const names = {
85
+ title: 'Title', author: 'Author', subject: 'Subject',
86
+ keywords: 'Keywords', creator: 'Creator',
87
+ }
88
+ for (const [key, name] of Object.entries(names)) {
89
+ if (spec?.[key]) parts.push(`/${name} ${pdfTextString(spec[key])}`)
90
+ }
91
+ if (spec?.created) parts.push(`/CreationDate (${pdfDate(spec.created)})`)
92
+ if (spec?.modified) parts.push(`/ModDate (${pdfDate(spec.modified)})`)
93
+ return `<< ${parts.join(' ')} >>`
94
+ }
95
+
96
+ const xml = (text) => String(text)
97
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
98
+
99
+ /**
100
+ * The XMP packet mirroring /Info — what Bridge, Spotlight importers, DAM
101
+ * systems and every post-2001 Adobe tool actually read. Deliberately carries NO
102
+ * `pdfuaid:part`: this plugin's tagging is real but its fonts are not embedded,
103
+ * and a conformance identifier is a claim, not a decoration.
104
+ *
105
+ * Uncompressed by spec: an XMP packet must be scannable by tools that do not
106
+ * parse PDF, which is the whole reason it exists twice.
107
+ */
108
+ export function xmpPacket(spec, lang, { pdfua = false } = {}) {
109
+ const dcLang = lang || 'x-default'
110
+ const items = []
111
+ // Written ONLY when the export verified its own eligibility: every text
112
+ // operation on an embedded face, structure tagged, language set, title
113
+ // displayed. An identifier a validator can refute is worse than none.
114
+ if (pdfua) items.push(' <pdfuaid:part>1</pdfuaid:part>')
115
+ if (spec?.title) {
116
+ items.push(` <dc:title><rdf:Alt><rdf:li xml:lang="${xml(dcLang)}">${xml(spec.title)}</rdf:li></rdf:Alt></dc:title>`)
117
+ }
118
+ if (spec?.author) {
119
+ items.push(` <dc:creator><rdf:Seq><rdf:li>${xml(spec.author)}</rdf:li></rdf:Seq></dc:creator>`)
120
+ }
121
+ if (spec?.subject) {
122
+ items.push(` <dc:description><rdf:Alt><rdf:li xml:lang="${xml(dcLang)}">${xml(spec.subject)}</rdf:li></rdf:Alt></dc:description>`)
123
+ }
124
+ if (spec?.keywords) {
125
+ items.push(` <pdf:Keywords>${xml(spec.keywords)}</pdf:Keywords>`)
126
+ }
127
+ if (spec?.creator) items.push(` <xmp:CreatorTool>${xml(spec.creator)}</xmp:CreatorTool>`)
128
+ if (spec?.created) items.push(` <xmp:CreateDate>${spec.created.toISOString()}</xmp:CreateDate>`)
129
+ if (spec?.modified) items.push(` <xmp:ModifyDate>${spec.modified.toISOString()}</xmp:ModifyDate>`)
130
+ items.push(` <pdf:Producer>${xml(PRODUCER)}</pdf:Producer>`)
131
+
132
+ return `<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
133
+ <x:xmpmeta xmlns:x="adobe:ns:meta/">
134
+ <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
135
+ <rdf:Description rdf:about=""
136
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
137
+ xmlns:pdf="http://ns.adobe.com/pdf/1.3/"
138
+ xmlns:xmp="http://ns.adobe.com/xap/1.0/"${pdfua ? `
139
+ xmlns:pdfuaid="http://www.aiim.org/pdfua/ns/id/"` : ''}>
140
+ ${items.join('\n')}
141
+ </rdf:Description>
142
+ </rdf:RDF>
143
+ </x:xmpmeta>
144
+ <?xpacket end="w"?>`
145
+ }