@zumer/snapdom-plugins 3.0.0-beta.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 +54 -20
  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 +17 -6
  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,57 @@
1
+ /**
2
+ * The public face of SnapDOM Vector: the plugin a customer registers, with two
3
+ * exports and one option.
4
+ *
5
+ * const shot = await snapdom(el, { plugins: [vector()] })
6
+ * const svg = await shot.toVector() // standalone SVG, universal target
7
+ * await shot.toFigma() // Figma's paste format, on the clipboard
8
+ *
9
+ * The engine in index.js keeps its whole surface (the SVD document, the SVG, Figma
10
+ * and PDF emitters, every tuning option) because the test suites and demos drive
11
+ * it. The customer bundle is built from THIS file, so none of that is part of the
12
+ * product: `exclude` is the only option that reaches the engine, and the engine's
13
+ * defaults decide everything else. `test/verify-plugin.mjs` pins both exports.
14
+ */
15
+ import { vector as engine } from './index.js'
16
+
17
+ /** Only `exclude` crosses into the engine; anything else a caller passes is dropped. */
18
+ const publicOptions = (options) => (options && options.exclude !== undefined ? { exclude: options.exclude } : {})
19
+
20
+ /**
21
+ * Create the plugin. A capture it runs on gains `toVector()` and `toFigma()`.
22
+ *
23
+ * @param {{exclude?: string|Function|Array<string|Function>}} [options] factory
24
+ * default for every export; the same option on a call wins for that call.
25
+ * @returns {object} snapdom plugin
26
+ */
27
+ export function vector (options = {}) {
28
+ const inner = engine(publicOptions(options))
29
+ return {
30
+ name: inner.name,
31
+ beforeRender: inner.beforeRender,
32
+ defineExports (captureCtx) {
33
+ const exports = inner.defineExports(captureCtx)
34
+ return {
35
+ vector: (ctx, opts) => exports.vectorSvg(ctx, publicOptions(opts)),
36
+ figma: (ctx, opts) => {
37
+ if (typeof navigator === 'undefined' || !navigator.clipboard?.write || typeof ClipboardItem === 'undefined') {
38
+ return Promise.reject(new Error('[snapdom-vector] toFigma() needs the async clipboard API: ' +
39
+ 'call it from a page served over https or localhost'))
40
+ }
41
+ // The write starts before the export finishes and is handed promises, so a
42
+ // browser that ties clipboard access to the user's click still sees one.
43
+ const envelope = exports.vectorFigmaClipboard(ctx, publicOptions(opts))
44
+ const part = (key, type) => envelope.then(result => new Blob([result[key]], { type }))
45
+ const written = navigator.clipboard.write([new ClipboardItem({
46
+ 'text/html': part('html', 'text/html'),
47
+ 'text/plain': part('plain', 'text/plain'),
48
+ })])
49
+ // The export's own error wins over the clipboard's generic one.
50
+ return Promise.all([envelope, written]).then(() => undefined)
51
+ },
52
+ }
53
+ },
54
+ }
55
+ }
56
+
57
+ export default vector
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Standard security handler, revision 6: AES-256, and nothing older.
3
+ *
4
+ * A PDF that leaves the browser as a download IS the delivery channel, so the
5
+ * only encryption worth writing here is the kind that protects the file at rest
6
+ * once it has been shared. That rules two things out, in opposite directions:
7
+ *
8
+ * - **RC4 and the /V 1–4 revisions are not offered.** A 40-bit key is brute
9
+ * forced in seconds and 128-bit RC4 is not far behind; both also need MD5,
10
+ * which the platform deliberately does not provide. Writing them by hand to
11
+ * ship a weaker product is not a trade this file makes.
12
+ * - **Permission flags are not protection and are not sold as such.** `/P` is
13
+ * a request a viewer may honour; any tool can ignore it. What actually keeps
14
+ * a document shut is the user password, because without it the bytes cannot
15
+ * be read at all. The flags are written because the format has them, and the
16
+ * caller is told what they are worth.
17
+ *
18
+ * Everything here comes from `crypto.subtle`: SHA-256/384/512 for Algorithm 2.B
19
+ * and AES-CBC for the rest. No dependency, no hand-rolled cipher, no MD5.
20
+ *
21
+ * **One thing WebCrypto will not do**, and the workaround, because it looks like
22
+ * a bug otherwise: 2.B and the key wrapping need AES-CBC with NO padding, and
23
+ * WebCrypto always appends a PKCS#7 block. The bytes it produces before that
24
+ * block ARE the unpadded CBC stream — CBC is sequential, so block n cannot
25
+ * depend on anything after it — so every no-padding call here encrypts and then
26
+ * truncates. `_encryptcheck.mjs` asserts that prefix property rather than
27
+ * trusting it.
28
+ *
29
+ * **Determinism survives.** The salts and the file encryption key are derived,
30
+ * not randomised: the key is HKDF-SHA256 over the password with the document's
31
+ * own plaintext digest as salt, and the salts are further derivations of that
32
+ * digest. The same document encrypted with the same password is byte-identical,
33
+ * which is the promise the rest of this writer makes and this file is not
34
+ * allowed to break. Nothing is weakened by it — a salt travels in the file in
35
+ * the clear anyway, it differs per document because the digest does, and the key
36
+ * is still unreachable without the password.
37
+ */
38
+
39
+ const utf8 = (s) => new TextEncoder().encode(s)
40
+
41
+ /** Concatenate byte runs. */
42
+ function cat(...runs) {
43
+ let n = 0
44
+ for (const r of runs) n += r.length
45
+ const out = new Uint8Array(n)
46
+ let at = 0
47
+ for (const r of runs) { out.set(r, at); at += r.length }
48
+ return out
49
+ }
50
+
51
+ const hex = (b) => [...b].map(x => x.toString(16).padStart(2, '0')).join('').toUpperCase()
52
+
53
+ async function sha(bits, data) {
54
+ return new Uint8Array(await crypto.subtle.digest(`SHA-${bits}`, data))
55
+ }
56
+
57
+ /**
58
+ * AES-CBC with no padding, by encrypting and dropping the block WebCrypto adds.
59
+ * `data.length` must be a multiple of 16, which every caller here guarantees.
60
+ */
61
+ async function aesNoPad(key, iv, data) {
62
+ const k = await crypto.subtle.importKey('raw', key, 'AES-CBC', false, ['encrypt'])
63
+ const full = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-CBC', iv }, k, data))
64
+ return full.subarray(0, data.length)
65
+ }
66
+
67
+ /** AES-CBC with the PKCS#7 padding the PDF stream/string format wants, IV first. */
68
+ async function aesCbc(key, iv, data) {
69
+ const k = await crypto.subtle.importKey('raw', key, 'AES-CBC', false, ['encrypt'])
70
+ return cat(iv, new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-CBC', iv }, k, data)))
71
+ }
72
+
73
+ /**
74
+ * ISO 32000-2, Algorithm 2.B — the hardened password hash revision 6 uses.
75
+ *
76
+ * The loop is the point of it: at least 64 rounds, each one AES over 64 copies
77
+ * of (password ‖ K ‖ udata) and then one of three SHA-2 variants chosen by the
78
+ * data itself, and it only stops once the last byte of a round's output falls
79
+ * below round − 32. That makes a guess cost work no table can amortise.
80
+ *
81
+ * @param {Uint8Array} password UTF-8, already truncated to 127 bytes
82
+ * @param {Uint8Array} salt 8 bytes
83
+ * @param {Uint8Array} udata the 48-byte /U for an owner hash; empty for a user one
84
+ */
85
+ async function hash2B(password, salt, udata) {
86
+ let k = await sha(256, cat(password, salt, udata))
87
+ for (let round = 0; ; round++) {
88
+ const block = cat(password, k, udata)
89
+ const k1 = new Uint8Array(block.length * 64)
90
+ for (let i = 0; i < 64; i++) k1.set(block, i * block.length)
91
+ const e = await aesNoPad(k.subarray(0, 16), k.subarray(16, 32), k1)
92
+ let mod = 0
93
+ for (let i = 0; i < 16; i++) mod += e[i]
94
+ k = await sha([256, 384, 512][mod % 3], e)
95
+ if (round >= 63 && e[e.length - 1] <= round - 31) break
96
+ }
97
+ return k.subarray(0, 32)
98
+ }
99
+
100
+ /** A PDF password: UTF-8, capped at 127 bytes, cut on a code point boundary. */
101
+ function passwordBytes(text) {
102
+ const raw = utf8(String(text ?? ''))
103
+ if (raw.length <= 127) return raw
104
+ let end = 127
105
+ while (end > 0 && (raw[end] & 0xc0) === 0x80) end--
106
+ return raw.subarray(0, end)
107
+ }
108
+
109
+ /**
110
+ * The permission bits of `/P`. Bits 1–2 and 7–8 are reserved and always 1; every
111
+ * bit this does not turn off stays on, so the default grants everything.
112
+ *
113
+ * Bit 10 — extraction for accessibility — is NOT exposed. It is deprecated in
114
+ * PDF 2.0 (a conforming reader must always allow it) and PDF/UA requires an
115
+ * encrypted file to keep permitting it, so an exporter whose entire selling
116
+ * point is an extractable text layer has no business offering to switch it off.
117
+ */
118
+ const PERMISSION_BITS = {
119
+ print: 3, modify: 4, copy: 5, annotate: 6,
120
+ fillForms: 9, assemble: 11, printHighRes: 12,
121
+ }
122
+
123
+ function permissionValue(permissions, warn) {
124
+ let p = -1 // every bit set
125
+ // Bits 1, 2, 7, 8 are reserved and must be 1; -1 already has them.
126
+ for (const [name, value] of Object.entries(permissions || {})) {
127
+ const bit = PERMISSION_BITS[name]
128
+ if (bit === undefined) {
129
+ warn(`encrypt.permissions.${name} is not a permission — ignored. Known: ` +
130
+ `${Object.keys(PERMISSION_BITS).join(', ')}.`)
131
+ continue
132
+ }
133
+ if (value === false) p &= ~(1 << (bit - 1))
134
+ }
135
+ return p | 0
136
+ }
137
+
138
+ /**
139
+ * Everything a document needs to be encrypted: the file key, and the /Encrypt
140
+ * dictionary that lets a reader recover it from a password.
141
+ *
142
+ * @param {object} args
143
+ * @param {string} args.userPassword opens the document; '' means "opens without one"
144
+ * @param {string} [args.ownerPassword] lifts the permission flags; defaults to the user one
145
+ * @param {object} [args.permissions] `{print:false, copy:false, …}`
146
+ * @param {Uint8Array} args.seed a digest of the document's PLAINTEXT bytes
147
+ * @param {(msg:string)=>void} args.warn
148
+ */
149
+ export async function buildEncryption({ userPassword, ownerPassword, permissions, seed, warn }) {
150
+ const user = passwordBytes(userPassword)
151
+ // An owner password equal to the user one is the common case and is fine; what
152
+ // is NOT fine is an owner password alone, because then anyone opens the file
153
+ // and only the advisory flags stand between them and everything.
154
+ const owner = passwordBytes(ownerPassword ?? userPassword)
155
+ const p = permissionValue(permissions, warn)
156
+
157
+ // Derived, never random — see the file comment. Distinct info strings keep the
158
+ // four derivations from ever colliding.
159
+ const derive = async (info, bytes) => {
160
+ const ikm = await crypto.subtle.importKey('raw', cat(user, owner), 'HKDF', false, ['deriveBits'])
161
+ const bits = await crypto.subtle.deriveBits(
162
+ { name: 'HKDF', hash: 'SHA-256', salt: seed, info: utf8(`snapdom/pdf-writer/${info}`) },
163
+ ikm, bytes * 8
164
+ )
165
+ return new Uint8Array(bits)
166
+ }
167
+ const fileKey = await derive('filekey/v1', 32)
168
+ const uValidation = await derive('u-validation/v1', 8)
169
+ const uKeySalt = await derive('u-keysalt/v1', 8)
170
+ const oValidation = await derive('o-validation/v1', 8)
171
+ const oKeySalt = await derive('o-keysalt/v1', 8)
172
+
173
+ // Algorithm 8: /U proves a user password, /UE hands over the file key.
174
+ const uHash = await hash2B(user, uValidation, new Uint8Array(0))
175
+ const U = cat(uHash, uValidation, uKeySalt)
176
+ const UE = await aesNoPad(await hash2B(user, uKeySalt, new Uint8Array(0)), new Uint8Array(16), fileKey)
177
+
178
+ // Algorithm 9: the owner hashes are bound to /U, so /O cannot be lifted onto
179
+ // another document.
180
+ const oHash = await hash2B(owner, oValidation, U)
181
+ const O = cat(oHash, oValidation, oKeySalt)
182
+ const OE = await aesNoPad(await hash2B(owner, oKeySalt, U), new Uint8Array(16), fileKey)
183
+
184
+ // Algorithm 10: /Perms is the permission set encrypted under the file key, so
185
+ // a reader that HAS the key can tell whether the flags were tampered with.
186
+ // Bytes 12–15 are unused by the spec; zeros keep the file deterministic.
187
+ const perms = new Uint8Array(16)
188
+ new DataView(perms.buffer).setInt32(0, p, true)
189
+ perms.set([0xff, 0xff, 0xff, 0xff], 4)
190
+ perms[8] = 0x54 // 'T': the metadata stream is encrypted too
191
+ perms.set([0x61, 0x64, 0x62], 9) // 'adb'
192
+ const Perms = await aesNoPad(fileKey, new Uint8Array(16), perms)
193
+
194
+ const dict =
195
+ '/Filter /Standard /V 5 /R 6 /Length 256 ' +
196
+ '/CF << /StdCF << /CFM /AESV3 /AuthEvent /DocOpen /Length 32 >> >> ' +
197
+ '/StmF /StdCF /StrF /StdCF ' +
198
+ `/O <${hex(O)}> /U <${hex(U)}> /OE <${hex(OE)}> /UE <${hex(UE)}> ` +
199
+ `/Perms <${hex(Perms)}> /P ${p} /EncryptMetadata true`
200
+
201
+ return {
202
+ dict,
203
+ p,
204
+ /**
205
+ * Encrypt one stream payload or one string. Revision 6 uses the file key
206
+ * directly — the per-object key derivation of revisions 2–4 is gone — so an
207
+ * object number is not needed and cannot be got wrong.
208
+ *
209
+ * The IV is derived from the file key and the object it protects rather than
210
+ * drawn at random, for the same determinism reason as everything else, and
211
+ * it is distinct per object and per occurrence, which is what CBC requires.
212
+ */
213
+ async encrypt(bytes, nonce) {
214
+ const iv = (await sha(256, cat(fileKey, utf8(`iv/${nonce}`)))).subarray(0, 16)
215
+ return aesCbc(fileKey, iv, bytes)
216
+ },
217
+ }
218
+ }
219
+
220
+ /**
221
+ * The catalog entry that tells an ISO 32000-1 reader this file uses a feature
222
+ * from the next edition. `%PDF-1.7` stays in the header — the file structure IS
223
+ * 1.7 — and AES-256 arrived as Adobe extension level 3 before ISO 32000-2
224
+ * standardised it, which is the shape every reader in the field learned.
225
+ */
226
+ export const AES256_EXTENSION =
227
+ '/Extensions << /ADBE << /BaseVersion /1.7 /ExtensionLevel 3 >> >>'