@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,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
+ }