@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.
- package/CHANGELOG.md +13 -0
- package/README.md +54 -20
- 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 +17 -6
- 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,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 >> >>'
|