@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.
- package/CHANGELOG.md +13 -0
- package/README.md +50 -16
- package/agent-map.d.ts +32 -0
- package/agent-map.js +5 -3
- package/ascii-export.d.ts +10 -0
- package/ascii-export.js +21 -6
- package/capture-frames.js +5 -5
- package/color-tint.js +4 -0
- package/context-export.d.ts +23 -0
- package/context-export.js +4 -1
- package/filter.js +1 -1
- package/gif-export.d.ts +6 -0
- package/gif-export.js +3 -3
- package/html-export.d.ts +8 -0
- package/html-export.js +6 -4
- package/index.d.ts +2 -1
- package/index.js +2 -1
- package/package.json +16 -5
- package/pdf/LICENSE +21 -0
- package/pdf/README.md +22 -0
- package/pdf/REFERENCE.md +520 -0
- package/pdf/src/frontmatter.js +200 -0
- package/pdf/src/furniture.js +398 -0
- package/pdf/src/index.js +2415 -0
- package/pdf/src/mega/README.md +47 -0
- package/pdf/src/mega/diag.js +148 -0
- package/pdf/src/mega/flow.js +56 -0
- package/pdf/src/mega/fonts.js +287 -0
- package/pdf/src/mega/forms.js +479 -0
- package/pdf/src/mega/labels.js +43 -0
- package/pdf/src/mega/meta.js +145 -0
- package/pdf/src/nav.js +357 -0
- package/pdf/src/paginate.js +583 -0
- package/pdf/src/tagged.js +355 -0
- package/pdf/src/text-layer.js +1771 -0
- package/pdf/src/writer/encrypt.js +218 -0
- package/pdf/src/writer/font.js +507 -0
- package/pdf/src/writer/index.js +1135 -0
- package/pdf/src/writer/subset.js +387 -0
- package/pdf.d.ts +294 -0
- package/pdf.js +1 -0
- package/redact-inputs.js +6 -6
- package/vector/CONTRACT.md +906 -0
- package/vector/LICENSE +21 -0
- package/vector/README.md +35 -0
- package/vector/src/adapters/snapdom.js +795 -0
- package/vector/src/collect/box.js +1676 -0
- package/vector/src/collect/font.js +487 -0
- package/vector/src/collect/image.js +1458 -0
- package/vector/src/collect/mathml.js +272 -0
- package/vector/src/collect/text.js +3027 -0
- package/vector/src/css/color.js +823 -0
- package/vector/src/css/gradient.js +1126 -0
- package/vector/src/css/matrix.js +398 -0
- package/vector/src/css/radius.js +124 -0
- package/vector/src/css/shadow.js +201 -0
- package/vector/src/emit/figma-json.js +1995 -0
- package/vector/src/emit/h2d-vector.js +215 -0
- package/vector/src/emit/h2d.js +1147 -0
- package/vector/src/emit/pdf.js +2571 -0
- package/vector/src/emit/svg-flat.js +4842 -0
- package/vector/src/exclusion.js +13 -0
- package/vector/src/index.js +2910 -0
- package/vector/src/paint.js +2908 -0
- package/vector/src/public.js +57 -0
- package/vector/src/writer/encrypt.js +227 -0
- package/vector/src/writer/font.js +507 -0
- package/vector/src/writer/index.js +1148 -0
- package/vector/src/writer/subset.js +387 -0
- package/vector/svd/index.js +72 -0
- package/vector/svd/schema.js +985 -0
- package/vector.d.ts +28 -0
- package/vector.js +1 -0
- package/video-export.d.ts +6 -0
- package/video-export.js +7 -7
- package/pdf-image.d.ts +0 -18
- 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
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
|
+
}
|