@zumer/snapdom-plugins 3.0.0 → 4.0.1
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 +21 -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 +22 -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,507 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embedded fonts: the WHOLE font program travels, and the text points into it.
|
|
3
|
+
*
|
|
4
|
+
* This module deliberately stops short of a subsetter. Subsetting rewrites
|
|
5
|
+
* glyf/loca/hmtx and renumbers every glyph — a font compiler, and a wrong one
|
|
6
|
+
* corrupts text silently. Embedding the file as it came from the wire needs
|
|
7
|
+
* none of that: the byte stream goes into /FontFile2 (or /FontFile3 for CFF)
|
|
8
|
+
* untouched, glyph ids keep the numbering the font was born with, and
|
|
9
|
+
* `CIDToGIDMap /Identity` is exact BY CONSTRUCTION — the CID written in the
|
|
10
|
+
* content stream IS the glyph id the font's own cmap names for that character.
|
|
11
|
+
* The cost is bytes (a face travels whole, typically 100–350 kB before Flate),
|
|
12
|
+
* and it is the caller's job to say so out loud.
|
|
13
|
+
*
|
|
14
|
+
* What is read out of the binary, and why nothing else:
|
|
15
|
+
*
|
|
16
|
+
* - `cmap` (formats 4 and 12): character → glyph id, the encoding itself.
|
|
17
|
+
* - `hmtx`/`hhea`: advances, for /W — a viewer places invisible text and
|
|
18
|
+
* selection boxes off these, so they must be the REAL ones.
|
|
19
|
+
* - `head`, `OS/2`, `post`, `maxp`: units-per-em, the descriptor's metrics,
|
|
20
|
+
* and the embedding-permission bits.
|
|
21
|
+
* - `name` (id 6): the PostScript name, so /BaseFont tells the truth.
|
|
22
|
+
*
|
|
23
|
+
* No shaping happens here. A CID per codepoint means no ligatures, no kerning,
|
|
24
|
+
* no contextual forms — correct for an invisible layer whose widths are pinned
|
|
25
|
+
* by Tz, and honest for short furniture strings. Scripts whose legibility
|
|
26
|
+
* REQUIRES shaping (Arabic joining) must not be painted through this path; the
|
|
27
|
+
* caller keeps them on the raster.
|
|
28
|
+
*
|
|
29
|
+
* `OS/2.fsType` is licensing, not advice. Restricted-license (bit 1) and
|
|
30
|
+
* bitmap-only (bit 9) faces are refused here, once, rather than quietly
|
|
31
|
+
* embedded into every customer's court exhibit.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { deflate, canDeflate } from './index.js'
|
|
35
|
+
import { subsetTrueType } from './subset.js'
|
|
36
|
+
|
|
37
|
+
const U16 = (b, at) => (b[at] << 8) | b[at + 1]
|
|
38
|
+
const I16 = (b, at) => { const v = U16(b, at); return v & 0x8000 ? v - 0x10000 : v }
|
|
39
|
+
const U32 = (b, at) => ((b[at] << 24) | (b[at + 1] << 16) | (b[at + 2] << 8) | b[at + 3]) >>> 0
|
|
40
|
+
const TAG = (b, at) => String.fromCharCode(b[at], b[at + 1], b[at + 2], b[at + 3])
|
|
41
|
+
|
|
42
|
+
/** zlib inflate (WOFF table compression) via the engine's own DecompressionStream. */
|
|
43
|
+
async function inflate(bytes) {
|
|
44
|
+
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('deflate'))
|
|
45
|
+
return new Uint8Array(await new Response(stream).arrayBuffer())
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Whatever arrived → a plain sfnt, or a refusal that names its reason.
|
|
50
|
+
*
|
|
51
|
+
* WOFF1 is a zip of the same tables and is rebuilt losslessly. WOFF2 is a
|
|
52
|
+
* different font (glyf transformed, brotli-compressed) and reconstructing it IS
|
|
53
|
+
* subsetter-grade surgery, so it is declined. A TrueType collection shares
|
|
54
|
+
* tables between faces behind absolute offsets; embedding the whole file would
|
|
55
|
+
* make every viewer guess which face was meant, so it is declined too.
|
|
56
|
+
*
|
|
57
|
+
* @returns {Promise<{sfnt: Uint8Array}|{error: string}>}
|
|
58
|
+
*/
|
|
59
|
+
export async function sfntFromBytes(bytes) {
|
|
60
|
+
if (!bytes || bytes.length < 12) return { error: 'not a font: shorter than an sfnt header' }
|
|
61
|
+
const tag = TAG(bytes, 0)
|
|
62
|
+
if (tag === 'wOF2') return { error: 'WOFF2 stores transformed tables behind Brotli; rebuilding the original font is a decompiler this exporter refuses to half-do' }
|
|
63
|
+
if (tag === 'ttcf') return { error: 'a TrueType collection shares tables between several faces; embedding it whole would leave the face choice to the viewer' }
|
|
64
|
+
if (tag === 'wOFF') {
|
|
65
|
+
if (typeof DecompressionStream !== 'function') {
|
|
66
|
+
return { error: 'WOFF needs DecompressionStream to unpack and this runtime has none' }
|
|
67
|
+
}
|
|
68
|
+
const flavor = U32(bytes, 4)
|
|
69
|
+
const numTables = U16(bytes, 12)
|
|
70
|
+
if (!numTables || numTables > 512) return { error: 'WOFF header declares an implausible table count' }
|
|
71
|
+
const entries = []
|
|
72
|
+
for (let i = 0; i < numTables; i++) {
|
|
73
|
+
const at = 44 + i * 20
|
|
74
|
+
if (at + 20 > bytes.length) return { error: 'WOFF table directory runs past the file' }
|
|
75
|
+
entries.push({
|
|
76
|
+
tag: U32(bytes, at), offset: U32(bytes, at + 4),
|
|
77
|
+
compLength: U32(bytes, at + 8), origLength: U32(bytes, at + 12),
|
|
78
|
+
checksum: U32(bytes, at + 16),
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
const tables = []
|
|
82
|
+
for (const e of entries) {
|
|
83
|
+
if (e.offset + e.compLength > bytes.length) return { error: 'WOFF table data runs past the file' }
|
|
84
|
+
const raw = bytes.subarray(e.offset, e.offset + e.compLength)
|
|
85
|
+
let data
|
|
86
|
+
if (e.compLength < e.origLength) {
|
|
87
|
+
try { data = await inflate(raw) } catch { return { error: 'a WOFF table failed to inflate' } }
|
|
88
|
+
if (data.length !== e.origLength) return { error: 'a WOFF table inflated to the wrong size' }
|
|
89
|
+
} else {
|
|
90
|
+
data = raw
|
|
91
|
+
}
|
|
92
|
+
tables.push({ tag: e.tag, checksum: e.checksum, data })
|
|
93
|
+
}
|
|
94
|
+
// sfnt directory: offsets 4-byte aligned, search fields per the spec's formula.
|
|
95
|
+
let entropy = 0
|
|
96
|
+
while ((1 << (entropy + 1)) <= numTables) entropy++
|
|
97
|
+
const searchRange = (1 << entropy) * 16
|
|
98
|
+
let offset = 12 + numTables * 16
|
|
99
|
+
const head = new Uint8Array(offset)
|
|
100
|
+
const w16 = (b, at, v) => { b[at] = (v >> 8) & 0xff; b[at + 1] = v & 0xff }
|
|
101
|
+
const w32 = (b, at, v) => { b[at] = (v >>> 24) & 0xff; b[at + 1] = (v >>> 16) & 0xff; b[at + 2] = (v >>> 8) & 0xff; b[at + 3] = v & 0xff }
|
|
102
|
+
w32(head, 0, flavor)
|
|
103
|
+
w16(head, 4, numTables)
|
|
104
|
+
w16(head, 6, searchRange)
|
|
105
|
+
w16(head, 8, entropy)
|
|
106
|
+
w16(head, 10, numTables * 16 - searchRange)
|
|
107
|
+
const chunks = [head]
|
|
108
|
+
tables.forEach((t, i) => {
|
|
109
|
+
const pad = (4 - (t.data.length % 4)) % 4
|
|
110
|
+
const at = 12 + i * 16
|
|
111
|
+
w32(head, at, t.tag)
|
|
112
|
+
w32(head, at + 4, t.checksum)
|
|
113
|
+
w32(head, at + 8, offset)
|
|
114
|
+
w32(head, at + 12, t.data.length)
|
|
115
|
+
chunks.push(t.data)
|
|
116
|
+
if (pad) chunks.push(new Uint8Array(pad))
|
|
117
|
+
offset += t.data.length + pad
|
|
118
|
+
})
|
|
119
|
+
const sfnt = new Uint8Array(offset)
|
|
120
|
+
let at = 0
|
|
121
|
+
for (const c of chunks) { sfnt.set(c, at); at += c.length }
|
|
122
|
+
return { sfnt }
|
|
123
|
+
}
|
|
124
|
+
const version = U32(bytes, 0)
|
|
125
|
+
if (version === 0x00010000 || tag === 'true' || tag === 'OTTO') return { sfnt: bytes }
|
|
126
|
+
return { error: 'unrecognised font container (not sfnt, WOFF or OTTO)' }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The best cmap subtable: format 12 sees astral planes, format 4 the BMP. */
|
|
130
|
+
function readCmap(b, at, length) {
|
|
131
|
+
const numTables = U16(b, at + 2)
|
|
132
|
+
let best = null
|
|
133
|
+
for (let i = 0; i < numTables; i++) {
|
|
134
|
+
const rec = at + 4 + i * 8
|
|
135
|
+
const platform = U16(b, rec)
|
|
136
|
+
const encoding = U16(b, rec + 2)
|
|
137
|
+
const offset = at + U32(b, rec + 4)
|
|
138
|
+
if (offset + 4 > b.length) continue
|
|
139
|
+
const format = U16(b, offset)
|
|
140
|
+
const unicode = platform === 0 || (platform === 3 && (encoding === 1 || encoding === 10))
|
|
141
|
+
if (!unicode) continue
|
|
142
|
+
const score = format === 12 ? 2 : format === 4 ? 1 : 0
|
|
143
|
+
if (!score) continue
|
|
144
|
+
if (!best || score > best.score) best = { format, offset, score }
|
|
145
|
+
}
|
|
146
|
+
if (!best) return null
|
|
147
|
+
const map = new Map()
|
|
148
|
+
const CAP = 200000 // a defensive ceiling, far above any real repertoire
|
|
149
|
+
if (best.format === 12) {
|
|
150
|
+
// The declared group count is a raw uint32 from bytes an attacker can
|
|
151
|
+
// serve (@font-face fetches). Trusting it spins for minutes on a header
|
|
152
|
+
// that declares billions of groups and maps nothing — the CAP guards
|
|
153
|
+
// map.size, not iterations — so the loop is bounded by the bytes that
|
|
154
|
+
// actually exist: 16 header bytes, 12 per group, inside table and file.
|
|
155
|
+
const declared = U32(b, best.offset + 12)
|
|
156
|
+
const room = Math.min(at + length, b.length) - best.offset - 16
|
|
157
|
+
const nGroups = Math.min(declared, Math.max(0, Math.floor(room / 12)))
|
|
158
|
+
for (let g = 0; g < nGroups && map.size < CAP; g++) {
|
|
159
|
+
const at12 = best.offset + 16 + g * 12
|
|
160
|
+
const start = U32(b, at12)
|
|
161
|
+
const end = U32(b, at12 + 4)
|
|
162
|
+
const gid = U32(b, at12 + 8)
|
|
163
|
+
for (let cp = start; cp <= end && map.size < CAP; cp++) {
|
|
164
|
+
const mapped = gid + (cp - start)
|
|
165
|
+
// Identity-H writes CIDs two bytes wide; a glyph id past 0xFFFF is
|
|
166
|
+
// unreachable there and would corrupt the hex string if written.
|
|
167
|
+
if (mapped !== 0 && mapped <= 0xffff) map.set(cp, mapped)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
const segX2 = U16(b, best.offset + 6)
|
|
172
|
+
const ends = best.offset + 14
|
|
173
|
+
const starts = ends + segX2 + 2
|
|
174
|
+
const deltas = starts + segX2
|
|
175
|
+
const rangeOffsets = deltas + segX2
|
|
176
|
+
for (let s = 0; s < segX2; s += 2) {
|
|
177
|
+
const end = U16(b, ends + s)
|
|
178
|
+
const start = U16(b, starts + s)
|
|
179
|
+
const delta = U16(b, deltas + s)
|
|
180
|
+
const ro = U16(b, rangeOffsets + s)
|
|
181
|
+
if (start === 0xffff) continue
|
|
182
|
+
for (let cp = start; cp <= end && map.size < CAP; cp++) {
|
|
183
|
+
let gid
|
|
184
|
+
if (ro === 0) {
|
|
185
|
+
gid = (cp + delta) & 0xffff
|
|
186
|
+
} else {
|
|
187
|
+
const gat = rangeOffsets + s + ro + (cp - start) * 2
|
|
188
|
+
if (gat + 2 > b.length) continue
|
|
189
|
+
gid = U16(b, gat)
|
|
190
|
+
if (gid !== 0) gid = (gid + delta) & 0xffff
|
|
191
|
+
}
|
|
192
|
+
if (gid !== 0) map.set(cp, gid)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return map.size ? map : null
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** name table, id 6: the PostScript name, decoded from either UTF-16BE or MacRoman-ish. */
|
|
200
|
+
function readPsName(b, at) {
|
|
201
|
+
const count = U16(b, at + 2)
|
|
202
|
+
const strings = at + U16(b, at + 4)
|
|
203
|
+
for (let i = 0; i < count; i++) {
|
|
204
|
+
const rec = at + 6 + i * 12
|
|
205
|
+
if (U16(b, rec + 6) !== 6) continue
|
|
206
|
+
const platform = U16(b, rec)
|
|
207
|
+
const len = U16(b, rec + 8)
|
|
208
|
+
const off = strings + U16(b, rec + 10)
|
|
209
|
+
if (off + len > b.length) continue
|
|
210
|
+
let out = ''
|
|
211
|
+
if (platform === 3 || platform === 0) {
|
|
212
|
+
for (let j = 0; j + 1 < len; j += 2) out += String.fromCharCode(U16(b, off + j))
|
|
213
|
+
} else {
|
|
214
|
+
for (let j = 0; j < len; j++) out += String.fromCharCode(b[off + j])
|
|
215
|
+
}
|
|
216
|
+
// A PDF name object survives only printable ASCII minus the delimiters.
|
|
217
|
+
out = out.replace(/[^!-~]|[()<>[\]{}/%#]/g, '')
|
|
218
|
+
if (out) return out.slice(0, 63)
|
|
219
|
+
}
|
|
220
|
+
return null
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The facts the writer needs, read off one sfnt. Returns `{error}` when the
|
|
225
|
+
* binary refuses them — malformed, or licensed against embedding.
|
|
226
|
+
*/
|
|
227
|
+
export function parseFont(sfnt) {
|
|
228
|
+
const b = sfnt
|
|
229
|
+
const numTables = U16(b, 4)
|
|
230
|
+
if (!numTables || 12 + numTables * 16 > b.length) return { error: 'sfnt table directory is malformed' }
|
|
231
|
+
const tables = new Map()
|
|
232
|
+
for (let i = 0; i < numTables; i++) {
|
|
233
|
+
const at = 12 + i * 16
|
|
234
|
+
const offset = U32(b, at + 8)
|
|
235
|
+
const length = U32(b, at + 12)
|
|
236
|
+
if (offset + length > b.length) return { error: `table ${TAG(b, at)} runs past the file` }
|
|
237
|
+
tables.set(TAG(b, at), { offset, length })
|
|
238
|
+
}
|
|
239
|
+
const need = (tag) => tables.get(tag) || null
|
|
240
|
+
const head = need('head')
|
|
241
|
+
const hhea = need('hhea')
|
|
242
|
+
const hmtx = need('hmtx')
|
|
243
|
+
const maxp = need('maxp')
|
|
244
|
+
const cmapT = need('cmap')
|
|
245
|
+
if (!head || !hhea || !hmtx || !maxp || !cmapT) {
|
|
246
|
+
return { error: 'missing one of head/hhea/hmtx/maxp/cmap' }
|
|
247
|
+
}
|
|
248
|
+
const cff = TAG(b, 0) === 'OTTO'
|
|
249
|
+
if (!cff && !tables.has('glyf')) return { error: 'TrueType flavor without a glyf table' }
|
|
250
|
+
|
|
251
|
+
const os2 = need('OS/2')
|
|
252
|
+
if (os2) {
|
|
253
|
+
const fsType = U16(b, os2.offset + 8)
|
|
254
|
+
if (fsType & 0x0002) return { error: 'OS/2 fsType forbids embedding (restricted license)' }
|
|
255
|
+
if (fsType & 0x0200) return { error: 'OS/2 fsType allows bitmap embedding only' }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const upm = U16(b, head.offset + 18) || 1000
|
|
259
|
+
const numGlyphs = U16(b, maxp.offset + 4)
|
|
260
|
+
const numH = U16(b, hhea.offset + 34)
|
|
261
|
+
if (!numGlyphs || !numH || numH > numGlyphs) return { error: 'hhea/maxp glyph counts disagree' }
|
|
262
|
+
if (hmtx.length < numH * 4) return { error: 'hmtx is shorter than hhea promises' }
|
|
263
|
+
const advances = new Uint16Array(numH)
|
|
264
|
+
for (let i = 0; i < numH; i++) advances[i] = U16(b, hmtx.offset + i * 4)
|
|
265
|
+
|
|
266
|
+
const cmap = readCmap(b, cmapT.offset, cmapT.length)
|
|
267
|
+
if (!cmap) return { error: 'no readable Unicode cmap (formats 4/12)' }
|
|
268
|
+
|
|
269
|
+
const scale = 1000 / upm
|
|
270
|
+
const os2v = os2 ? U16(b, os2.offset) : 0
|
|
271
|
+
const typoAsc = os2 ? I16(b, os2.offset + 68) : 0
|
|
272
|
+
const typoDesc = os2 ? I16(b, os2.offset + 70) : 0
|
|
273
|
+
const post = need('post')
|
|
274
|
+
return {
|
|
275
|
+
cff,
|
|
276
|
+
numGlyphs,
|
|
277
|
+
upm,
|
|
278
|
+
cmap,
|
|
279
|
+
psName: tables.has('name') ? readPsName(b, need('name').offset) : null,
|
|
280
|
+
weightClass: os2 ? U16(b, os2.offset + 4) : 400,
|
|
281
|
+
fixedPitch: post ? U32(b, post.offset + 12) !== 0 : false,
|
|
282
|
+
italicAngle: post ? I16(b, post.offset + 4) : 0, // integer degrees of the Fixed suffice
|
|
283
|
+
ascent: Math.round((typoAsc || I16(b, hhea.offset + 4)) * scale),
|
|
284
|
+
descent: Math.round((typoDesc || I16(b, hhea.offset + 6)) * scale),
|
|
285
|
+
capHeight: os2 && os2v >= 2 && os2.length >= 90
|
|
286
|
+
? Math.round(I16(b, os2.offset + 88) * scale)
|
|
287
|
+
: Math.round((typoAsc || I16(b, hhea.offset + 4)) * scale * 0.9),
|
|
288
|
+
bbox: [
|
|
289
|
+
Math.round(I16(b, head.offset + 36) * scale), Math.round(I16(b, head.offset + 38) * scale),
|
|
290
|
+
Math.round(I16(b, head.offset + 40) * scale), Math.round(I16(b, head.offset + 42) * scale),
|
|
291
|
+
],
|
|
292
|
+
/** Advance of one glyph in 1/1000 em — the /W unit. */
|
|
293
|
+
advanceOf(gid) {
|
|
294
|
+
return Math.round((gid < numH ? advances[gid] : advances[numH - 1]) * scale)
|
|
295
|
+
},
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* One embedded face in one document. Same lazy contract as `createUnicodeFont`:
|
|
301
|
+
* nothing is added to the document until the first `encode`, so a face that was
|
|
302
|
+
* fetched but never used costs no objects and no bytes.
|
|
303
|
+
*
|
|
304
|
+
* `encode` answers null when ANY character of the text has no glyph — the
|
|
305
|
+
* caller falls back to its unembedded path for the whole run, so a selection
|
|
306
|
+
* box never spans two fonts' disagreeing metrics.
|
|
307
|
+
*
|
|
308
|
+
* `finalize` is async (the font stream deflates) and must be awaited after the
|
|
309
|
+
* last encode and before `doc.build`.
|
|
310
|
+
*/
|
|
311
|
+
export function createEmbeddedFont(doc, parsed, sfnt, tag, { subset = true } = {}) {
|
|
312
|
+
// TrueType glyphs are RENUMBERED into a dense space as they are encoded, so the
|
|
313
|
+
// subset can carry only what the document used and the CID is already the
|
|
314
|
+
// subset's own glyph id. CFF is embedded whole and keeps its native ids, so its
|
|
315
|
+
// CID stays the original glyph id.
|
|
316
|
+
const renumber = !parsed.cff
|
|
317
|
+
const cidByOrig = new Map([[0, 0]]) // .notdef is always CID 0
|
|
318
|
+
const origByCid = new Map([[0, 0]])
|
|
319
|
+
const cpByCid = new Map() // CID → first codepoint that reached it (ToUnicode)
|
|
320
|
+
const codeToOrig = new Map() // codepoint → original gid (cmap rebuild)
|
|
321
|
+
let nextCid = 1
|
|
322
|
+
let id = null
|
|
323
|
+
let cidId, descId, fileId, toUniId
|
|
324
|
+
// "AAAAAA+" tags a subset font per PDF 9.6.4; six A's is a fixed, honest tag
|
|
325
|
+
// for a deterministic exporter, and keeps two subsets of one face distinct in
|
|
326
|
+
// a merged document by their different glyph sets rather than a random prefix.
|
|
327
|
+
const rawName = parsed.psName || `Embedded${tag}`
|
|
328
|
+
const baseName = renumber && subset ? `AAAAAA+${rawName}` : rawName
|
|
329
|
+
|
|
330
|
+
function ensure() {
|
|
331
|
+
if (id !== null) return
|
|
332
|
+
cidId = doc.reserve()
|
|
333
|
+
descId = doc.reserve()
|
|
334
|
+
fileId = doc.reserve()
|
|
335
|
+
toUniId = doc.reserve()
|
|
336
|
+
id = doc.add(
|
|
337
|
+
`<< /Type /Font /Subtype /Type0 /BaseFont /${baseName} /Encoding /Identity-H ` +
|
|
338
|
+
`/DescendantFonts [${cidId} 0 R] /ToUnicode ${toUniId} 0 R >>`
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const hex4 = (n) => n.toString(16).toUpperCase().padStart(4, '0')
|
|
343
|
+
|
|
344
|
+
/** Every codepoint maps to a glyph, or the whole text is not this font's to carry. */
|
|
345
|
+
function covers(text) {
|
|
346
|
+
for (const ch of text) {
|
|
347
|
+
if (!parsed.cmap.has(ch.codePointAt(0))) return false
|
|
348
|
+
}
|
|
349
|
+
return true
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function encode(text) {
|
|
353
|
+
if (!text) return null
|
|
354
|
+
for (const ch of text) {
|
|
355
|
+
if (!parsed.cmap.has(ch.codePointAt(0))) return null
|
|
356
|
+
}
|
|
357
|
+
ensure()
|
|
358
|
+
let out = '<'
|
|
359
|
+
for (const ch of text) {
|
|
360
|
+
const cp = ch.codePointAt(0)
|
|
361
|
+
const orig = parsed.cmap.get(cp)
|
|
362
|
+
let cid
|
|
363
|
+
if (renumber) {
|
|
364
|
+
cid = cidByOrig.get(orig)
|
|
365
|
+
if (cid === undefined) {
|
|
366
|
+
cid = nextCid++
|
|
367
|
+
cidByOrig.set(orig, cid)
|
|
368
|
+
origByCid.set(cid, orig)
|
|
369
|
+
}
|
|
370
|
+
} else {
|
|
371
|
+
cid = orig
|
|
372
|
+
origByCid.set(cid, orig)
|
|
373
|
+
}
|
|
374
|
+
if (!cpByCid.has(cid)) cpByCid.set(cid, cp)
|
|
375
|
+
codeToOrig.set(cp, orig)
|
|
376
|
+
out += hex4(cid)
|
|
377
|
+
}
|
|
378
|
+
return out + '>'
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** The REAL advance of the text at `size` — Tz denominators and furniture layout. */
|
|
382
|
+
function width(text, size) {
|
|
383
|
+
let units = 0
|
|
384
|
+
for (const ch of text) {
|
|
385
|
+
const gid = parsed.cmap.get(ch.codePointAt(0))
|
|
386
|
+
if (gid === undefined) return null
|
|
387
|
+
units += parsed.advanceOf(gid)
|
|
388
|
+
}
|
|
389
|
+
return units * size / 1000
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function finalize() {
|
|
393
|
+
if (id === null) return
|
|
394
|
+
|
|
395
|
+
// /W is keyed by CID, and every advance is the original glyph's — renumbering
|
|
396
|
+
// moved the id, never the metric.
|
|
397
|
+
const cids = [...cpByCid.keys()].sort((a, b) => a - b)
|
|
398
|
+
const wParts = []
|
|
399
|
+
for (let i = 0; i < cids.length;) {
|
|
400
|
+
let j = i
|
|
401
|
+
while (j + 1 < cids.length && cids[j + 1] === cids[j] + 1) j++
|
|
402
|
+
wParts.push(`${cids[i]} [${cids.slice(i, j + 1).map(c => parsed.advanceOf(origByCid.get(c))).join(' ')}]`)
|
|
403
|
+
i = j + 1
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// The font program, and how CID reaches glyph. Three outcomes:
|
|
407
|
+
// - CFF: embedded whole, CID = glyph id natively (no CIDToGIDMap).
|
|
408
|
+
// - TrueType subset: the renumber IS the mapping, so /Identity is exact and
|
|
409
|
+
// only the used outlines travel.
|
|
410
|
+
// - TrueType whole (subset off, or a subset that refused): the file travels
|
|
411
|
+
// whole and an explicit CIDToGIDMap stream carries CID → original glyph.
|
|
412
|
+
let program = sfnt
|
|
413
|
+
let cidToGid = parsed.cff ? '' : ' /CIDToGIDMap /Identity'
|
|
414
|
+
let subsetted = false
|
|
415
|
+
if (renumber) {
|
|
416
|
+
let done = false
|
|
417
|
+
if (subset) {
|
|
418
|
+
const r = subsetTrueType(sfnt, cidByOrig, codeToOrig)
|
|
419
|
+
if (!r.error) { program = r.sfnt; subsetted = true; done = true }
|
|
420
|
+
}
|
|
421
|
+
if (!done) {
|
|
422
|
+
// Whole file, dense CIDs: map each CID back to its original glyph id.
|
|
423
|
+
const maxCid = cids.length ? cids[cids.length - 1] : 0
|
|
424
|
+
const map = new Uint8Array((maxCid + 1) * 2)
|
|
425
|
+
for (const [cid, orig] of origByCid) {
|
|
426
|
+
map[cid * 2] = (orig >> 8) & 0xff
|
|
427
|
+
map[cid * 2 + 1] = orig & 0xff
|
|
428
|
+
}
|
|
429
|
+
const mapId = doc.reserve()
|
|
430
|
+
const mapBody = canDeflate ? await deflate(map) : map
|
|
431
|
+
doc.fillStream(mapId, canDeflate ? '/Filter /FlateDecode' : '', mapBody)
|
|
432
|
+
cidToGid = ` /CIDToGIDMap ${mapId} 0 R`
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const flags = 4 | (parsed.fixedPitch ? 1 : 0) | (parsed.italicAngle ? 64 : 0)
|
|
437
|
+
const stemV = Math.round(10 + (parsed.weightClass || 400) * 0.14)
|
|
438
|
+
doc.fill(descId,
|
|
439
|
+
`<< /Type /FontDescriptor /FontName /${baseName} /Flags ${flags} ` +
|
|
440
|
+
`/FontBBox [${parsed.bbox.join(' ')}] /ItalicAngle ${parsed.italicAngle} ` +
|
|
441
|
+
`/Ascent ${parsed.ascent} /Descent ${parsed.descent} /CapHeight ${parsed.capHeight} ` +
|
|
442
|
+
`/StemV ${stemV} /${parsed.cff ? 'FontFile3' : 'FontFile2'} ${fileId} 0 R >>`
|
|
443
|
+
)
|
|
444
|
+
doc.fill(cidId,
|
|
445
|
+
`<< /Type /Font /Subtype /${parsed.cff ? 'CIDFontType0' : 'CIDFontType2'} /BaseFont /${baseName} ` +
|
|
446
|
+
'/CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> ' +
|
|
447
|
+
`/FontDescriptor ${descId} 0 R /DW 1000` +
|
|
448
|
+
(wParts.length ? ` /W [${wParts.join(' ')}]` : '') +
|
|
449
|
+
cidToGid +
|
|
450
|
+
' >>'
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
// The program itself. /Length1 is the UNCOMPRESSED size and only FontFile2
|
|
454
|
+
// carries it; FontFile3 declares its flavor with /Subtype instead.
|
|
455
|
+
const body = canDeflate ? await deflate(program) : program
|
|
456
|
+
doc.fillStream(fileId,
|
|
457
|
+
(parsed.cff ? '/Subtype /OpenType ' : `/Length1 ${program.length} `) +
|
|
458
|
+
(canDeflate ? '/Filter /FlateDecode' : ''),
|
|
459
|
+
body)
|
|
460
|
+
kB = Math.round(program.length / 1024)
|
|
461
|
+
embeddedBytes = program.length
|
|
462
|
+
isSubset = subsetted
|
|
463
|
+
|
|
464
|
+
const utf16be = (cp) => {
|
|
465
|
+
if (cp <= 0xffff) return hex4(cp)
|
|
466
|
+
const v = cp - 0x10000
|
|
467
|
+
return hex4(0xd800 | (v >> 10)) + hex4(0xdc00 | (v & 0x3ff))
|
|
468
|
+
}
|
|
469
|
+
const lines = [
|
|
470
|
+
'/CIDInit /ProcSet findresource begin',
|
|
471
|
+
'12 dict begin',
|
|
472
|
+
'begincmap',
|
|
473
|
+
'/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def',
|
|
474
|
+
'/CMapName /Adobe-Identity-UCS def',
|
|
475
|
+
'/CMapType 2 def',
|
|
476
|
+
'1 begincodespacerange',
|
|
477
|
+
'<0000> <FFFF>',
|
|
478
|
+
'endcodespacerange',
|
|
479
|
+
]
|
|
480
|
+
const entries = cids.map(cid => `<${hex4(cid)}> <${utf16be(cpByCid.get(cid))}>`)
|
|
481
|
+
for (let i = 0; i < entries.length; i += 100) {
|
|
482
|
+
const chunk = entries.slice(i, i + 100)
|
|
483
|
+
lines.push(`${chunk.length} beginbfchar`, ...chunk, 'endbfchar')
|
|
484
|
+
}
|
|
485
|
+
lines.push('endcmap', 'CMapName currentdict /CMap defineresource pop', 'end', 'end')
|
|
486
|
+
doc.fillStream(toUniId, '', lines.join('\n'))
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Filled by finalize so the caller can report the embedded (post-subset) size.
|
|
490
|
+
let kB = Math.round(sfnt.length / 1024)
|
|
491
|
+
let embeddedBytes = sfnt.length
|
|
492
|
+
let isSubset = false
|
|
493
|
+
|
|
494
|
+
return {
|
|
495
|
+
name: `E${tag}`,
|
|
496
|
+
get id() { return id },
|
|
497
|
+
get active() { return id !== null },
|
|
498
|
+
get kB() { return kB },
|
|
499
|
+
get embeddedBytes() { return embeddedBytes },
|
|
500
|
+
get subset() { return isSubset },
|
|
501
|
+
baseName,
|
|
502
|
+
covers,
|
|
503
|
+
encode,
|
|
504
|
+
width,
|
|
505
|
+
finalize,
|
|
506
|
+
}
|
|
507
|
+
}
|