fontmin-rs 0.1.0-beta.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.
@@ -0,0 +1,1425 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createHash } from 'node:crypto'
4
+ import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
5
+ import { basename, dirname, extname, join, resolve } from 'node:path'
6
+ import { pathToFileURL } from 'node:url'
7
+ import {
8
+ eotToTtf,
9
+ generateFontFaceCss,
10
+ inspectFont,
11
+ subsetTtf,
12
+ svgsToTtf,
13
+ ttfToEot,
14
+ ttfToSvg,
15
+ ttfToWoff,
16
+ ttfToWoff2,
17
+ woff2ToTtf,
18
+ woffToTtf,
19
+ } from '@fontmin-rs/binding'
20
+ import { parse as parseJsonc } from 'jsonc-parser'
21
+ import { glob } from 'tinyglobby'
22
+
23
+ const DEFAULT_CONFIG_FILES = [
24
+ 'fontmin.config.ts',
25
+ 'fontmin.config.mts',
26
+ 'fontmin.config.mjs',
27
+ 'fontmin.config.cjs',
28
+ 'fontmin.config.json',
29
+ 'fontmin.config.jsonc',
30
+ ]
31
+ const MODULE_CONFIG_EXTENSIONS = new Set(['.ts', '.mts', '.mjs', '.cjs'])
32
+ const INIT_CONFIG_FILE = 'fontmin.config.jsonc'
33
+ const CACHE_SCHEMA_VERSION = 'v1'
34
+ const FONTMIN_VERSION = '0.1.0-beta.1'
35
+ const DEFAULT_CACHE_DIR = 'node_modules/.cache/fontmin-rs'
36
+ const DEFAULT_INIT_CONFIG = `{
37
+ // Generated by fontmin-rs init.
38
+ "input": ["fonts/*.ttf"],
39
+ "outDir": "build",
40
+ "subset": {
41
+ "text": "Hello",
42
+ "basicText": true
43
+ },
44
+ "outputs": [
45
+ { "format": "woff2" },
46
+ { "format": "woff" },
47
+ { "format": "css" }
48
+ ],
49
+ "css": {
50
+ "fontFamily": "MyFont",
51
+ "fontPath": "./",
52
+ "fontDisplay": "swap"
53
+ },
54
+ "cache": {
55
+ "enabled": true
56
+ }
57
+ }
58
+ `
59
+
60
+ const argv = process.argv.slice(2)
61
+ const command = argv[0]
62
+ const commandArgs = argv.slice(1)
63
+
64
+ try {
65
+ if (command === 'subset') {
66
+ await subsetCommand(commandArgs)
67
+ } else if (command === 'convert') {
68
+ await convertCommand(commandArgs)
69
+ } else if (command === 'build') {
70
+ await buildCommand(commandArgs)
71
+ } else if (command === 'inspect') {
72
+ await inspectCommand(commandArgs)
73
+ } else if (command === 'init') {
74
+ await initCommand()
75
+ } else if (command === 'bench') {
76
+ await benchCommand(commandArgs)
77
+ } else {
78
+ usage()
79
+ process.exitCode = 1
80
+ }
81
+ } catch (error) {
82
+ console.error(error instanceof Error ? error.message : String(error))
83
+ process.exitCode = 1
84
+ }
85
+
86
+ async function subsetCommand(args) {
87
+ const output = readOption(args, ['-o', '--output'])
88
+ const subsetOptions = await subsetOptionsFromArgs(args)
89
+ const basicText = readFlag(args, ['-b', '--basic-text'])
90
+ const [input] = args
91
+
92
+ requireValue(input, 'subset requires an input font')
93
+ requireValue(output, 'subset requires -o, --output')
94
+ if (
95
+ subsetOptions.text === undefined &&
96
+ subsetOptions.unicodes.length === 0 &&
97
+ !basicText
98
+ ) {
99
+ throw new Error(
100
+ 'subset requires --text, --text-file, --unicodes, or --basic-text',
101
+ )
102
+ }
103
+
104
+ const contents = await readFile(input)
105
+ const subset = subsetTtf(contents, {
106
+ basicText,
107
+ text: subsetOptions.text,
108
+ unicodes: subsetOptions.unicodes,
109
+ })
110
+
111
+ await writeOutput(output, subset)
112
+ }
113
+
114
+ async function convertCommand(args) {
115
+ const output = readOption(args, ['-o', '--output'])
116
+ const format = readOption(args, ['-f', '--format'])
117
+ const [input] = args
118
+
119
+ requireValue(input, 'convert requires an input font')
120
+ requireValue(output, 'convert requires -o, --output')
121
+ requireValue(format, 'convert requires -f, --format')
122
+
123
+ const contents = await readFile(input)
124
+ const converted = convertFont(contents, format)
125
+
126
+ await writeOutput(output, converted)
127
+ }
128
+
129
+ async function benchCommand(args) {
130
+ const json = readFlag(args, ['--json'])
131
+ const basicText = readFlag(args, ['-b', '--basic-text'])
132
+ const subsetOptions = await subsetOptionsFromArgs(args)
133
+ const [input] = args
134
+
135
+ requireValue(input, 'bench requires an input font')
136
+ if (
137
+ subsetOptions.text === undefined &&
138
+ subsetOptions.unicodes.length === 0 &&
139
+ !basicText
140
+ ) {
141
+ throw new Error(
142
+ 'bench requires --text, --text-file, --unicodes, or --basic-text',
143
+ )
144
+ }
145
+
146
+ const contents = await readFile(input)
147
+ const startedAt = process.hrtime.bigint()
148
+ const subset = subsetTtf(contents, {
149
+ basicText,
150
+ text: subsetOptions.text,
151
+ unicodes: subsetOptions.unicodes,
152
+ })
153
+ const elapsedMs = Math.round(
154
+ Number(process.hrtime.bigint() - startedAt) / 1_000_000,
155
+ )
156
+ const report = {
157
+ elapsedMs,
158
+ inputBytes: contents.byteLength,
159
+ operation: 'subset',
160
+ outputBytes: subset.byteLength,
161
+ }
162
+
163
+ if (json) {
164
+ process.stdout.write(`${JSON.stringify(report)}\n`)
165
+ } else {
166
+ process.stdout.write(
167
+ `fontmin-rs bench subset completed in ${elapsedMs} ms\ninput: ${report.inputBytes} bytes\noutput: ${report.outputBytes} bytes\n`,
168
+ )
169
+ }
170
+ }
171
+
172
+ async function buildCommand(args) {
173
+ const showTime = readFlag(args, ['-T', '--show-time'])
174
+ const silent = readFlag(args, ['--silent'])
175
+ const startedAt = showTime && !silent ? process.hrtime.bigint() : undefined
176
+
177
+ await runBuildCommand(args)
178
+ reportShowTime(startedAt)
179
+ }
180
+
181
+ async function runBuildCommand(args) {
182
+ const configPath = readOption(args, ['-c', '--config'])
183
+ const cache = readFlag(args, ['--cache'])
184
+ const noCache = readFlag(args, ['--no-cache'])
185
+ const noOriginal = readFlag(args, ['--no-original'])
186
+ readFlag(args, ['-d', '--deflate-woff'])
187
+ const cssGlyph = readFlag(args, ['--css-glyph'])
188
+ const outDir = readOption(args, ['-o', '--out-dir'])
189
+ const formats = readOption(args, ['--formats'])
190
+ const preset = readOption(args, ['--preset'])
191
+ const basicText = readFlag(args, ['-b', '--basic-text'])
192
+ const fontFamily = readOption(args, ['--font-family'])
193
+ const fontPath = readOption(args, ['--font-path'])
194
+ const subsetOptions = await subsetOptionsFromArgs(args)
195
+
196
+ if (cache && noCache) {
197
+ throw new Error('build accepts only one of --cache or --no-cache')
198
+ }
199
+
200
+ const cacheOverride = cacheOverrideFromFlags(cache, noCache)
201
+
202
+ if (configPath !== undefined) {
203
+ if (isIconfontPreset(preset)) {
204
+ await buildIconfontConfigCommand(configPath, {
205
+ fontFamily,
206
+ fontPath,
207
+ formats,
208
+ inputs: [...args],
209
+ cacheOverride,
210
+ outDir,
211
+ })
212
+ return
213
+ }
214
+
215
+ await buildConfigCommand(configPath, {
216
+ basicText,
217
+ cacheOverride,
218
+ cssGlyph,
219
+ fontFamily,
220
+ fontPath,
221
+ formats,
222
+ inputs: [...args],
223
+ noOriginal,
224
+ outDir,
225
+ preset,
226
+ subsetOptions,
227
+ })
228
+ return
229
+ }
230
+
231
+ if (args.length === 0) {
232
+ const foundConfigPath = await findConfig()
233
+
234
+ if (isIconfontPreset(preset)) {
235
+ await buildIconfontConfigCommand(foundConfigPath, {
236
+ fontFamily,
237
+ fontPath,
238
+ formats,
239
+ cacheOverride,
240
+ outDir,
241
+ })
242
+ return
243
+ }
244
+
245
+ await buildConfigCommand(foundConfigPath, {
246
+ basicText,
247
+ cacheOverride,
248
+ cssGlyph,
249
+ fontFamily,
250
+ fontPath,
251
+ formats,
252
+ noOriginal,
253
+ outDir,
254
+ preset,
255
+ subsetOptions,
256
+ })
257
+ return
258
+ }
259
+
260
+ const inputPatterns = [...args]
261
+ const [input] = inputPatterns
262
+
263
+ requireValue(input, 'build requires an input font')
264
+ requireValue(outDir, 'build requires -o, --out-dir')
265
+
266
+ if (isIconfontPreset(preset)) {
267
+ if (formats !== undefined) {
268
+ throw new Error('build accepts only one of --formats or --preset')
269
+ }
270
+
271
+ const inputs = await expandInputPaths(inputPatterns, process.cwd())
272
+ const cacheOptions = normalizeCacheOptions(
273
+ undefined,
274
+ process.cwd(),
275
+ cacheOverride,
276
+ )
277
+
278
+ await buildIconfontCommand(
279
+ inputs,
280
+ outDir,
281
+ fontFamily,
282
+ fontPath ?? './',
283
+ [],
284
+ {},
285
+ cacheOptions,
286
+ )
287
+ return
288
+ }
289
+
290
+ const inputs = await expandInputPaths(inputPatterns, process.cwd())
291
+ const outputFormats = filterOriginalOutput(
292
+ outputFormatsFromArgs(formats, preset),
293
+ noOriginal,
294
+ )
295
+ const cacheOptions = normalizeCacheOptions(
296
+ undefined,
297
+ process.cwd(),
298
+ cacheOverride,
299
+ )
300
+ if (outputFormats.length === 0) {
301
+ throw new Error('build requires at least one non-original output format')
302
+ }
303
+
304
+ await mkdir(outDir, { recursive: true })
305
+
306
+ for (const input of inputs) {
307
+ await buildDirectInput(input, outDir, {
308
+ basicText,
309
+ cacheOptions,
310
+ cssGlyph,
311
+ fontFamily,
312
+ fontPath,
313
+ outputFormats,
314
+ subsetOptions,
315
+ })
316
+ }
317
+ }
318
+
319
+ async function buildDirectInput(
320
+ input,
321
+ outDir,
322
+ {
323
+ basicText,
324
+ cacheOptions,
325
+ cssGlyph,
326
+ fontFamily,
327
+ fontPath,
328
+ outputFormats,
329
+ subsetOptions,
330
+ },
331
+ ) {
332
+ const contents = await readFile(input)
333
+ const baseName = basename(input, extname(input))
334
+ const cacheKey = cacheOptions.enabled
335
+ ? cacheKeyForBuildInput({
336
+ contents,
337
+ input,
338
+ kind: 'direct',
339
+ options: {
340
+ basicText,
341
+ cssGlyph,
342
+ fontFamily,
343
+ fontPath,
344
+ outputFormats,
345
+ subset: subsetOptions,
346
+ },
347
+ })
348
+ : undefined
349
+ const cachedOutputs =
350
+ cacheKey === undefined
351
+ ? undefined
352
+ : await readCachedBuildOutputs(cacheOptions.dir, cacheKey)
353
+
354
+ if (cachedOutputs !== undefined) {
355
+ await writeBuildOutputs(outDir, cachedOutputs)
356
+ return
357
+ }
358
+
359
+ const source =
360
+ subsetOptions.text === undefined &&
361
+ subsetOptions.unicodes.length === 0 &&
362
+ !basicText
363
+ ? contents
364
+ : subsetTtf(contents, {
365
+ basicText,
366
+ text: subsetOptions.text,
367
+ unicodes: subsetOptions.unicodes,
368
+ })
369
+ const cssGlyphs = cssGlyph ? cssGlyphsFromSubset(subsetOptions) : []
370
+ const cssSources = []
371
+ const outputs = []
372
+
373
+ for (const format of outputFormats.filter(format => format !== 'css')) {
374
+ const fileName = `${baseName}.${format}`
375
+ const output = convertFont(source, format)
376
+
377
+ outputs.push({ contents: output, fileName })
378
+ cssSources.push({
379
+ ...(cssGlyphs.length > 0 && { glyphs: cssGlyphs }),
380
+ fileName,
381
+ format,
382
+ })
383
+ }
384
+
385
+ if (outputFormats.includes('css')) {
386
+ if (cssSources.length === 0) {
387
+ throw new Error('build CSS output requires at least one font format')
388
+ }
389
+
390
+ outputs.push({
391
+ contents: Buffer.from(
392
+ generateFontFaceCss(cssSources, {
393
+ fontFamily: fontFamily ?? baseName,
394
+ fontPath: fontPath ?? './',
395
+ glyph: cssGlyph,
396
+ }),
397
+ ),
398
+ fileName: `${baseName}.css`,
399
+ })
400
+ }
401
+
402
+ await writeBuildOutputs(outDir, outputs)
403
+
404
+ if (cacheKey !== undefined) {
405
+ await writeCachedBuildOutputs(cacheOptions.dir, cacheKey, outputs)
406
+ }
407
+ }
408
+
409
+ function reportShowTime(startedAt) {
410
+ if (startedAt === undefined) {
411
+ return
412
+ }
413
+
414
+ const elapsedMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000
415
+
416
+ process.stdout.write(
417
+ `fontmin-rs build completed in ${Math.round(elapsedMs)} ms\n`,
418
+ )
419
+ }
420
+
421
+ async function buildIconfontCommand(
422
+ inputs,
423
+ outDir,
424
+ fontFamily,
425
+ fontPath,
426
+ outputs = [],
427
+ css = {},
428
+ cacheOptions = normalizeCacheOptions(undefined, process.cwd()),
429
+ ) {
430
+ if (inputs.length === 0) {
431
+ throw new Error('build requires at least one input font')
432
+ }
433
+
434
+ const family = fontFamily ?? 'iconfont'
435
+ const icons = await Promise.all(
436
+ inputs.map(async input => ({
437
+ contents: await readFile(input, 'utf8'),
438
+ name: basename(input, extname(input)),
439
+ })),
440
+ )
441
+ const cacheKey = cacheOptions.enabled
442
+ ? cacheKeyForIconfontBuild({
443
+ icons,
444
+ inputs,
445
+ options: {
446
+ css,
447
+ fontFamily,
448
+ fontPath,
449
+ outputs,
450
+ },
451
+ })
452
+ : undefined
453
+ const cachedOutputs =
454
+ cacheKey === undefined
455
+ ? undefined
456
+ : await readCachedBuildOutputs(cacheOptions.dir, cacheKey)
457
+
458
+ if (cachedOutputs !== undefined) {
459
+ await writeBuildOutputs(outDir, cachedOutputs)
460
+ return
461
+ }
462
+
463
+ const ttf = svgsToTtf(icons, {
464
+ fontName: family,
465
+ })
466
+ const fileName = outputFileName(outputs, 'ttf', 'iconfont.ttf')
467
+ const cssFileName = outputFileName(
468
+ outputs,
469
+ 'css',
470
+ `${basename(fileName, extname(fileName))}.${css.target ?? 'css'}`,
471
+ )
472
+ const glyphs = icons.map((icon, index) => ({
473
+ name: icon.name,
474
+ unicode: 57_345 + index,
475
+ }))
476
+ const buildOutputs = [
477
+ {
478
+ contents: ttf,
479
+ fileName,
480
+ },
481
+ {
482
+ contents: Buffer.from(
483
+ generateFontFaceCss(
484
+ [
485
+ {
486
+ ...(css.base64 === true && { contents: ttf }),
487
+ fileName,
488
+ format: 'ttf',
489
+ glyphs,
490
+ },
491
+ ],
492
+ {
493
+ asFileName: css.asFileName ?? true,
494
+ base64: css.base64,
495
+ fontDisplay: css.fontDisplay,
496
+ fontFamily: family,
497
+ fontPath,
498
+ glyph: true,
499
+ iconPrefix: css.iconPrefix,
500
+ local: css.local,
501
+ },
502
+ ),
503
+ ),
504
+ fileName: cssFileName,
505
+ },
506
+ ]
507
+
508
+ await writeBuildOutputs(outDir, buildOutputs)
509
+
510
+ if (cacheKey !== undefined) {
511
+ await writeCachedBuildOutputs(cacheOptions.dir, cacheKey, buildOutputs)
512
+ }
513
+ }
514
+
515
+ async function buildConfigCommand(
516
+ configPath,
517
+ {
518
+ basicText,
519
+ cacheOverride,
520
+ cssGlyph,
521
+ fontFamily,
522
+ fontPath,
523
+ formats,
524
+ inputs: inputOverrides = [],
525
+ noOriginal,
526
+ outDir: outDirOverride,
527
+ preset,
528
+ subsetOptions,
529
+ } = {},
530
+ ) {
531
+ const resolvedConfigPath = resolve(configPath)
532
+ const config = await readConfig(resolvedConfigPath)
533
+ const cwd =
534
+ typeof config.cwd === 'string'
535
+ ? resolve(config.cwd)
536
+ : dirname(resolvedConfigPath)
537
+ const inputs =
538
+ inputOverrides.length > 0 ? inputOverrides : (config.input ?? [])
539
+ const outDir = resolve(cwd, outDirOverride ?? config.outDir ?? 'build')
540
+ const outputFormats = outputFormatsForConfig(config.outputs, {
541
+ formats,
542
+ noOriginal,
543
+ preset,
544
+ })
545
+ const cacheOptions = normalizeCacheOptions(config.cache, cwd, cacheOverride)
546
+
547
+ if (inputs.length === 0) {
548
+ throw new Error('build config requires at least one input')
549
+ }
550
+
551
+ if (fontFamily !== undefined || fontPath !== undefined || cssGlyph === true) {
552
+ config.css = {
553
+ ...config.css,
554
+ ...(cssGlyph === true && { glyph: true }),
555
+ ...(fontFamily !== undefined && { fontFamily }),
556
+ ...(fontPath !== undefined && { fontPath }),
557
+ }
558
+ }
559
+
560
+ applySubsetOverrides(config, { basicText, subsetOptions })
561
+
562
+ if (config.clean === true) {
563
+ await rm(outDir, { recursive: true, force: true })
564
+ }
565
+
566
+ await mkdir(outDir, { recursive: true })
567
+
568
+ for (const input of inputs) {
569
+ if (typeof input !== 'string') {
570
+ throw new TypeError('build config input entries must be file paths')
571
+ }
572
+
573
+ for (const inputPath of await expandInputPath(input, cwd)) {
574
+ await buildConfigInput(
575
+ inputPath,
576
+ outDir,
577
+ outputFormats,
578
+ config,
579
+ cwd,
580
+ cacheOptions,
581
+ )
582
+ }
583
+ }
584
+ }
585
+
586
+ async function buildIconfontConfigCommand(
587
+ configPath,
588
+ {
589
+ cacheOverride,
590
+ fontFamily,
591
+ fontPath,
592
+ formats,
593
+ inputs: inputOverrides = [],
594
+ outDir: outDirOverride,
595
+ },
596
+ ) {
597
+ if (formats !== undefined) {
598
+ throw new Error('build accepts only one of --formats or --preset')
599
+ }
600
+
601
+ const resolvedConfigPath = resolve(configPath)
602
+ const config = await readConfig(resolvedConfigPath)
603
+ const cwd =
604
+ typeof config.cwd === 'string'
605
+ ? resolve(config.cwd)
606
+ : dirname(resolvedConfigPath)
607
+ const outDir = resolve(cwd, outDirOverride ?? config.outDir ?? 'build')
608
+ const inputs = await expandConfigInputPaths(
609
+ inputOverrides.length > 0 ? inputOverrides : (config.input ?? []),
610
+ cwd,
611
+ )
612
+ const css = config.css ?? {}
613
+ const cacheOptions = normalizeCacheOptions(config.cache, cwd, cacheOverride)
614
+
615
+ if (config.clean === true) {
616
+ await rm(outDir, { recursive: true, force: true })
617
+ }
618
+
619
+ await buildIconfontCommand(
620
+ inputs,
621
+ outDir,
622
+ fontFamily ?? css.fontFamily,
623
+ fontPath ?? css.fontPath ?? './',
624
+ config.outputs ?? [],
625
+ css,
626
+ cacheOptions,
627
+ )
628
+ }
629
+
630
+ function expandConfigInputPaths(inputs, cwd) {
631
+ return expandInputPaths(inputs, cwd)
632
+ }
633
+
634
+ async function expandInputPaths(inputs, cwd) {
635
+ if (inputs.length === 0) {
636
+ throw new Error('build config requires at least one input')
637
+ }
638
+
639
+ const paths = []
640
+
641
+ for (const input of inputs) {
642
+ if (typeof input !== 'string') {
643
+ throw new TypeError('build config input entries must be file paths')
644
+ }
645
+
646
+ paths.push(...(await expandInputPath(input, cwd)))
647
+ }
648
+
649
+ return paths
650
+ }
651
+
652
+ function outputFileName(outputs, format, fallback) {
653
+ const output = outputs.find(output => output?.format === format)
654
+
655
+ if (typeof output?.fileName === 'string') {
656
+ return output.fileName
657
+ }
658
+
659
+ if (typeof output?.ext === 'string') {
660
+ return `${basename(fallback, extname(fallback))}.${output.ext.replace(/^\./u, '')}`
661
+ }
662
+
663
+ return fallback
664
+ }
665
+
666
+ function applySubsetOverrides(config, { basicText, subsetOptions } = {}) {
667
+ if (
668
+ subsetOptions === undefined ||
669
+ (subsetOptions.text === undefined &&
670
+ subsetOptions.unicodes.length === 0 &&
671
+ basicText !== true)
672
+ ) {
673
+ return
674
+ }
675
+
676
+ config.subset = {
677
+ ...config.subset,
678
+ ...(subsetOptions.text !== undefined && { text: subsetOptions.text }),
679
+ ...(subsetOptions.unicodes.length > 0 && {
680
+ unicodes: subsetOptions.unicodes,
681
+ }),
682
+ ...(basicText === true && { basicText }),
683
+ }
684
+ }
685
+
686
+ async function buildConfigInput(
687
+ input,
688
+ outDir,
689
+ outputFormats,
690
+ config,
691
+ cwd,
692
+ cacheOptions,
693
+ ) {
694
+ const contents = await readFile(input)
695
+ const subset = await resolveSubsetTextFile(config.subset ?? {}, cwd)
696
+ const unicodes = subset.unicodes ?? []
697
+ const cacheKey = cacheOptions.enabled
698
+ ? cacheKeyForBuildInput({
699
+ contents,
700
+ input,
701
+ kind: 'config',
702
+ options: {
703
+ css: config.css,
704
+ outputFormats,
705
+ outputs: config.outputs,
706
+ subset,
707
+ },
708
+ })
709
+ : undefined
710
+ const cachedOutputs =
711
+ cacheKey === undefined
712
+ ? undefined
713
+ : await readCachedBuildOutputs(cacheOptions.dir, cacheKey)
714
+
715
+ if (cachedOutputs !== undefined) {
716
+ await writeBuildOutputs(outDir, cachedOutputs)
717
+ return
718
+ }
719
+
720
+ const source =
721
+ subset.text === undefined &&
722
+ unicodes.length === 0 &&
723
+ subset.basicText !== true
724
+ ? contents
725
+ : subsetTtf(contents, {
726
+ basicText: subset.basicText,
727
+ text: subset.text,
728
+ unicodes,
729
+ })
730
+ const baseName = basename(input, extname(input))
731
+ const css = config.css ?? {}
732
+ const cssGlyphs = css.glyph === true ? cssGlyphsFromSubset(subset) : []
733
+ const cssSources = []
734
+ const outputs = config.outputs ?? []
735
+ const buildOutputs = []
736
+
737
+ for (const format of outputFormats.filter(format => format !== 'css')) {
738
+ const fileName = outputFileName(outputs, format, `${baseName}.${format}`)
739
+ const output = convertFont(source, format)
740
+
741
+ buildOutputs.push({ contents: output, fileName })
742
+ cssSources.push({
743
+ ...(css.base64 === true && { contents: output }),
744
+ ...(cssGlyphs.length > 0 && { glyphs: cssGlyphs }),
745
+ fileName,
746
+ format,
747
+ })
748
+ }
749
+
750
+ if (outputFormats.includes('css')) {
751
+ if (cssSources.length === 0) {
752
+ throw new Error('build CSS output requires at least one font format')
753
+ }
754
+
755
+ const firstCssSource = cssSources[0]
756
+ const cssFileName = outputFileName(
757
+ outputs,
758
+ 'css',
759
+ `${basename(firstCssSource.fileName, extname(firstCssSource.fileName))}.${css.target ?? 'css'}`,
760
+ )
761
+
762
+ buildOutputs.push({
763
+ contents: Buffer.from(
764
+ generateFontFaceCss(cssSources, {
765
+ asFileName: css.asFileName,
766
+ base64: css.base64,
767
+ fontDisplay: css.fontDisplay,
768
+ fontFamily: css.fontFamily ?? baseName,
769
+ fontPath: css.fontPath ?? './',
770
+ glyph: css.glyph,
771
+ iconPrefix: css.iconPrefix,
772
+ local: css.local,
773
+ }),
774
+ ),
775
+ fileName: cssFileName,
776
+ })
777
+ }
778
+
779
+ await writeBuildOutputs(outDir, buildOutputs)
780
+
781
+ if (cacheKey !== undefined) {
782
+ await writeCachedBuildOutputs(cacheOptions.dir, cacheKey, buildOutputs)
783
+ }
784
+ }
785
+
786
+ function cssGlyphsFromSubset(subset) {
787
+ const seen = new Set()
788
+ const glyphs = []
789
+
790
+ for (const character of subset.text ?? '') {
791
+ const unicode = character.codePointAt(0)
792
+
793
+ if (unicode !== undefined && !seen.has(unicode)) {
794
+ seen.add(unicode)
795
+ glyphs.push({ unicode })
796
+ }
797
+ }
798
+
799
+ for (const unicode of subset.unicodes ?? []) {
800
+ if (!seen.has(unicode)) {
801
+ seen.add(unicode)
802
+ glyphs.push({ unicode })
803
+ }
804
+ }
805
+
806
+ return glyphs
807
+ }
808
+
809
+ async function writeBuildOutputs(outDir, outputs) {
810
+ await mkdir(outDir, { recursive: true })
811
+
812
+ for (const output of outputs) {
813
+ await writeFile(join(outDir, output.fileName), output.contents)
814
+ }
815
+ }
816
+
817
+ async function readCachedBuildOutputs(cacheDir, key) {
818
+ let manifest = null
819
+
820
+ try {
821
+ manifest = JSON.parse(
822
+ await readFile(cacheManifestPath(cacheDir, key), 'utf8'),
823
+ )
824
+ } catch {
825
+ return
826
+ }
827
+
828
+ if (manifest.version !== CACHE_SCHEMA_VERSION || manifest.key !== key) {
829
+ return
830
+ }
831
+
832
+ const entryDir = cacheEntryDir(cacheDir, key)
833
+ const outputs = []
834
+
835
+ try {
836
+ for (const record of manifest.outputs) {
837
+ outputs.push({
838
+ contents: await readFile(join(entryDir, record.cacheFileName)),
839
+ fileName: record.fileName,
840
+ })
841
+ }
842
+ } catch {
843
+ return
844
+ }
845
+
846
+ return outputs
847
+ }
848
+
849
+ async function writeCachedBuildOutputs(cacheDir, key, outputs) {
850
+ const entryDir = cacheEntryDir(cacheDir, key)
851
+ const records = []
852
+
853
+ await mkdir(entryDir, { recursive: true })
854
+
855
+ for (const [index, output] of outputs.entries()) {
856
+ const extension = extname(output.fileName).replace(/^\./u, '') || 'bin'
857
+ const cacheFileName = `${String(index).padStart(3, '0')}.${extension}`
858
+
859
+ await writeFile(join(entryDir, cacheFileName), output.contents)
860
+ records.push({
861
+ cacheFileName,
862
+ fileName: output.fileName,
863
+ })
864
+ }
865
+
866
+ await writeFile(
867
+ cacheManifestPath(cacheDir, key),
868
+ `${JSON.stringify(
869
+ {
870
+ key,
871
+ outputs: records,
872
+ version: CACHE_SCHEMA_VERSION,
873
+ },
874
+ undefined,
875
+ 2,
876
+ )}\n`,
877
+ )
878
+ await updateBuildCacheIndex(cacheDir, key, records)
879
+ }
880
+
881
+ async function updateBuildCacheIndex(cacheDir, key, outputs) {
882
+ const indexPath = cacheIndexPath(cacheDir)
883
+ let index = {
884
+ entries: {},
885
+ version: CACHE_SCHEMA_VERSION,
886
+ }
887
+
888
+ try {
889
+ index = JSON.parse(await readFile(indexPath, 'utf8'))
890
+ } catch {
891
+ // A missing or corrupted cache index can be rebuilt from the next writes.
892
+ }
893
+
894
+ if (index.version !== CACHE_SCHEMA_VERSION) {
895
+ index = {
896
+ entries: {},
897
+ version: CACHE_SCHEMA_VERSION,
898
+ }
899
+ }
900
+
901
+ index.entries[key] = {
902
+ outputs: outputs.map(output => output.fileName),
903
+ updatedAt: new Date().toISOString(),
904
+ }
905
+
906
+ await mkdir(dirname(indexPath), { recursive: true })
907
+ await writeFile(indexPath, `${JSON.stringify(index, undefined, 2)}\n`)
908
+ }
909
+
910
+ function normalizeCacheOptions(options, cwd, override) {
911
+ const configuredDir =
912
+ typeof options === 'object' &&
913
+ options !== null &&
914
+ typeof options.dir === 'string'
915
+ ? options.dir
916
+ : DEFAULT_CACHE_DIR
917
+
918
+ if (override === true) {
919
+ return {
920
+ dir: resolve(cwd, configuredDir),
921
+ enabled: true,
922
+ }
923
+ }
924
+
925
+ if (override === false || options === undefined || options === false) {
926
+ return {
927
+ dir: resolve(cwd, configuredDir),
928
+ enabled: false,
929
+ }
930
+ }
931
+
932
+ if (options === true) {
933
+ return {
934
+ dir: resolve(cwd, DEFAULT_CACHE_DIR),
935
+ enabled: true,
936
+ }
937
+ }
938
+
939
+ return {
940
+ dir: resolve(cwd, options.dir ?? DEFAULT_CACHE_DIR),
941
+ enabled: options.enabled ?? true,
942
+ }
943
+ }
944
+
945
+ function cacheOverrideFromFlags(cache, noCache) {
946
+ if (cache) {
947
+ return true
948
+ }
949
+
950
+ if (noCache) {
951
+ return false
952
+ }
953
+ }
954
+
955
+ function cacheKeyForBuildInput({ contents, input, kind, options }) {
956
+ return sha256(
957
+ stableStringify({
958
+ fontminVersion: FONTMIN_VERSION,
959
+ input: {
960
+ hash: sha256(contents),
961
+ path: input,
962
+ },
963
+ kind,
964
+ options,
965
+ schema: CACHE_SCHEMA_VERSION,
966
+ }),
967
+ )
968
+ }
969
+
970
+ function cacheKeyForIconfontBuild({ icons, inputs, options }) {
971
+ return sha256(
972
+ stableStringify({
973
+ fontminVersion: FONTMIN_VERSION,
974
+ icons: icons.map((icon, index) => ({
975
+ hash: sha256(icon.contents),
976
+ input: inputs[index],
977
+ name: icon.name,
978
+ })),
979
+ kind: 'iconfont',
980
+ options,
981
+ schema: CACHE_SCHEMA_VERSION,
982
+ }),
983
+ )
984
+ }
985
+
986
+ function cacheRoot(cacheDir) {
987
+ return join(cacheDir, CACHE_SCHEMA_VERSION)
988
+ }
989
+
990
+ function cacheIndexPath(cacheDir) {
991
+ return join(cacheRoot(cacheDir), 'index.json')
992
+ }
993
+
994
+ function cacheEntryDir(cacheDir, key) {
995
+ return join(cacheRoot(cacheDir), key)
996
+ }
997
+
998
+ function cacheManifestPath(cacheDir, key) {
999
+ return join(cacheEntryDir(cacheDir, key), 'index.json')
1000
+ }
1001
+
1002
+ function sha256(input) {
1003
+ return createHash('sha256').update(input).digest('hex')
1004
+ }
1005
+
1006
+ function stableStringify(value) {
1007
+ if (value === null || typeof value !== 'object') {
1008
+ return JSON.stringify(value)
1009
+ }
1010
+
1011
+ if (Array.isArray(value)) {
1012
+ return `[${value.map(item => stableStringify(item)).join(',')}]`
1013
+ }
1014
+
1015
+ const record = value
1016
+ const entries = Object.keys(record)
1017
+ .sort()
1018
+ .map(key => {
1019
+ return `${JSON.stringify(key)}:${stableStringify(record[key])}`
1020
+ })
1021
+
1022
+ return `{${entries.join(',')}}`
1023
+ }
1024
+
1025
+ async function inspectCommand(args) {
1026
+ const json = readFlag(args, ['--json'])
1027
+ const [input] = args
1028
+
1029
+ requireValue(input, 'inspect requires an input font')
1030
+
1031
+ const contents = await readFile(input)
1032
+ const info = inspectFont(contents)
1033
+
1034
+ if (json) {
1035
+ console.log(JSON.stringify(info, undefined, 2))
1036
+ } else {
1037
+ console.log(
1038
+ `${input}: ${info.format}, ${info.size} bytes, ${info.metadata.glyphCount} glyphs`,
1039
+ )
1040
+ }
1041
+ }
1042
+
1043
+ async function initCommand() {
1044
+ try {
1045
+ await writeFile(INIT_CONFIG_FILE, DEFAULT_INIT_CONFIG, { flag: 'wx' })
1046
+ } catch (error) {
1047
+ if (hasErrorCode(error, 'EEXIST')) {
1048
+ throw new Error(`${INIT_CONFIG_FILE} already exists`, { cause: error })
1049
+ }
1050
+
1051
+ throw error
1052
+ }
1053
+
1054
+ process.stdout.write(`created ${INIT_CONFIG_FILE}\n`)
1055
+ }
1056
+
1057
+ async function subsetOptionsFromArgs(args) {
1058
+ const subset = await resolveSubsetTextFile(
1059
+ {
1060
+ text: readOption(args, ['-t', '--text']),
1061
+ textFile: readOption(args, ['--text-file']),
1062
+ unicodes: parseUnicodes(readOption(args, ['--unicodes'])),
1063
+ },
1064
+ process.cwd(),
1065
+ )
1066
+
1067
+ return {
1068
+ ...subset,
1069
+ unicodes: subset.unicodes ?? [],
1070
+ }
1071
+ }
1072
+
1073
+ async function resolveSubsetTextFile(subset, cwd) {
1074
+ if (subset.textFile === undefined) {
1075
+ return subset
1076
+ }
1077
+
1078
+ const fileText = await readFile(resolve(cwd, subset.textFile), 'utf8')
1079
+
1080
+ return {
1081
+ ...subset,
1082
+ text: subset.text === undefined ? fileText : `${subset.text}${fileText}`,
1083
+ }
1084
+ }
1085
+
1086
+ async function readConfig(configPath) {
1087
+ const contents = await readFile(configPath, 'utf8')
1088
+ const extension = extname(configPath)
1089
+
1090
+ if (extension === '.json') {
1091
+ return JSON.parse(contents)
1092
+ }
1093
+
1094
+ if (extension === '.jsonc') {
1095
+ return parseJsonc(contents)
1096
+ }
1097
+
1098
+ if (MODULE_CONFIG_EXTENSIONS.has(extension)) {
1099
+ const configModule = await import(pathToFileURL(configPath).href)
1100
+ const loadedConfig = configModule.default ?? configModule.config
1101
+
1102
+ if (loadedConfig === undefined) {
1103
+ throw new Error(`config file \`${configPath}\` does not export a config`)
1104
+ }
1105
+
1106
+ return typeof loadedConfig === 'function'
1107
+ ? await loadedConfig()
1108
+ : loadedConfig
1109
+ }
1110
+
1111
+ throw new Error(`unsupported config extension \`${extension}\``)
1112
+ }
1113
+
1114
+ async function findConfig(cwd = process.cwd()) {
1115
+ for (const fileName of DEFAULT_CONFIG_FILES) {
1116
+ const configPath = resolve(cwd, fileName)
1117
+
1118
+ if (await isFile(configPath)) {
1119
+ return configPath
1120
+ }
1121
+ }
1122
+
1123
+ throw new Error(`could not find fontmin config in ${cwd}`)
1124
+ }
1125
+
1126
+ async function isFile(path) {
1127
+ try {
1128
+ return (await stat(path)).isFile()
1129
+ } catch {
1130
+ return false
1131
+ }
1132
+ }
1133
+
1134
+ async function expandInputPath(input, cwd) {
1135
+ if (!isGlobPattern(input)) {
1136
+ return [resolve(cwd, input)]
1137
+ }
1138
+
1139
+ const matches = await glob(input, {
1140
+ absolute: true,
1141
+ cwd,
1142
+ onlyFiles: true,
1143
+ })
1144
+
1145
+ if (matches.length === 0) {
1146
+ throw new Error(`fontmin-rs input glob matched no files: ${input}`)
1147
+ }
1148
+
1149
+ return matches.sort((left, right) => left.localeCompare(right))
1150
+ }
1151
+
1152
+ function isGlobPattern(path) {
1153
+ return /[*?[\]{}]/u.test(path)
1154
+ }
1155
+
1156
+ function hasErrorCode(error, code) {
1157
+ return (
1158
+ typeof error === 'object' &&
1159
+ error !== null &&
1160
+ 'code' in error &&
1161
+ error.code === code
1162
+ )
1163
+ }
1164
+
1165
+ function outputFormatsFromConfig(outputs) {
1166
+ if (outputs === undefined) {
1167
+ return ['eot', 'woff', 'woff2', 'svg', 'css']
1168
+ }
1169
+
1170
+ const formats = outputs.map(output =>
1171
+ typeof output === 'string' ? output : output.format,
1172
+ )
1173
+
1174
+ return parseFormats(formats.join(','))
1175
+ }
1176
+
1177
+ function outputFormatsForConfig(outputs, { formats, noOriginal, preset }) {
1178
+ const outputFormats =
1179
+ formats === undefined && preset === undefined
1180
+ ? outputFormatsFromConfig(outputs)
1181
+ : outputFormatsFromArgs(formats, preset)
1182
+ const filtered = filterOriginalOutput(outputFormats, noOriginal)
1183
+
1184
+ if (filtered.length === 0) {
1185
+ throw new Error('build requires at least one non-original output format')
1186
+ }
1187
+
1188
+ return filtered
1189
+ }
1190
+
1191
+ function outputFormatsFromArgs(formats, preset) {
1192
+ if (formats !== undefined && preset !== undefined) {
1193
+ throw new Error('build accepts only one of --formats or --preset')
1194
+ }
1195
+
1196
+ if (formats !== undefined) {
1197
+ return parseFormats(formats)
1198
+ }
1199
+
1200
+ if (preset !== undefined) {
1201
+ return outputFormatsFromPreset(preset)
1202
+ }
1203
+
1204
+ throw new Error('build requires --formats or --preset')
1205
+ }
1206
+
1207
+ function outputFormatsFromPreset(value) {
1208
+ const preset = value.trim().toLowerCase()
1209
+
1210
+ if (preset === 'compat') {
1211
+ return ['eot', 'svg', 'woff', 'woff2', 'css']
1212
+ }
1213
+
1214
+ if (preset === 'modern-web') {
1215
+ return ['woff2', 'woff', 'css']
1216
+ }
1217
+
1218
+ if (preset === 'iconfont') {
1219
+ return ['ttf', 'css']
1220
+ }
1221
+
1222
+ throw new Error(`unsupported preset \`${preset}\``)
1223
+ }
1224
+
1225
+ function isIconfontPreset(value) {
1226
+ return value?.trim().toLowerCase() === 'iconfont'
1227
+ }
1228
+
1229
+ function filterOriginalOutput(formats, noOriginal) {
1230
+ return noOriginal ? formats.filter(format => format !== 'ttf') : formats
1231
+ }
1232
+
1233
+ function parseUnicodes(value) {
1234
+ if (value === undefined) {
1235
+ return []
1236
+ }
1237
+
1238
+ const unicodes = value.split(',').map(item => parseUnicodeCodePoint(item))
1239
+
1240
+ if (unicodes.length === 0) {
1241
+ throw new Error('expected at least one unicode code point')
1242
+ }
1243
+
1244
+ return unicodes
1245
+ }
1246
+
1247
+ function parseUnicodeCodePoint(value) {
1248
+ const item = value.trim()
1249
+
1250
+ if (item.length === 0) {
1251
+ throw new Error('empty unicode code point in --unicodes')
1252
+ }
1253
+
1254
+ let digits = item
1255
+ let radix = 10
1256
+
1257
+ if (/^(?:0x|u\+)/iu.test(item)) {
1258
+ digits = item.slice(2)
1259
+ radix = 16
1260
+ }
1261
+
1262
+ const validDigits = radix === 16 ? /^[0-9a-f]+$/iu : /^[0-9]+$/u
1263
+
1264
+ if (!validDigits.test(digits)) {
1265
+ throw new Error(`invalid unicode code point \`${item}\``)
1266
+ }
1267
+
1268
+ const codePoint = Number.parseInt(digits, radix)
1269
+
1270
+ if (!Number.isInteger(codePoint) || codePoint > 4_294_967_295) {
1271
+ throw new Error(`invalid unicode code point \`${item}\``)
1272
+ }
1273
+
1274
+ return codePoint
1275
+ }
1276
+
1277
+ function parseFormats(value) {
1278
+ const formats = value
1279
+ .split(',')
1280
+ .map(format => format.trim().toLowerCase())
1281
+ .filter(format => format.length > 0)
1282
+
1283
+ if (formats.length === 0) {
1284
+ throw new Error('expected at least one output format')
1285
+ }
1286
+
1287
+ for (const format of formats) {
1288
+ if (
1289
+ format !== 'ttf' &&
1290
+ format !== 'woff' &&
1291
+ format !== 'woff2' &&
1292
+ format !== 'eot' &&
1293
+ format !== 'svg' &&
1294
+ format !== 'css'
1295
+ ) {
1296
+ throw new Error(`unsupported output format \`${format}\``)
1297
+ }
1298
+ }
1299
+
1300
+ return formats
1301
+ }
1302
+
1303
+ function convertFont(contents, format) {
1304
+ const normalized = format.toLowerCase()
1305
+
1306
+ if (normalized === 'ttf') {
1307
+ if (isTtf(contents)) {
1308
+ return contents
1309
+ }
1310
+
1311
+ if (isWoff(contents)) {
1312
+ return woffToTtf(contents)
1313
+ }
1314
+
1315
+ if (isWoff2(contents)) {
1316
+ return woff2ToTtf(contents)
1317
+ }
1318
+
1319
+ if (isEot(contents)) {
1320
+ return eotToTtf(contents)
1321
+ }
1322
+
1323
+ throw new Error('unsupported input format for TTF conversion')
1324
+ }
1325
+
1326
+ if (normalized === 'woff') {
1327
+ return ttfToWoff(contents)
1328
+ }
1329
+
1330
+ if (normalized === 'woff2') {
1331
+ return ttfToWoff2(contents)
1332
+ }
1333
+
1334
+ if (normalized === 'eot') {
1335
+ return ttfToEot(contents)
1336
+ }
1337
+
1338
+ if (normalized === 'svg') {
1339
+ return ttfToSvg(contents)
1340
+ }
1341
+
1342
+ throw new Error(`unsupported output format \`${format}\``)
1343
+ }
1344
+
1345
+ function isTtf(contents) {
1346
+ return (
1347
+ (contents[0] === 0x00 &&
1348
+ contents[1] === 0x01 &&
1349
+ contents[2] === 0x00 &&
1350
+ contents[3] === 0x00) ||
1351
+ contents.subarray(0, 4).toString('ascii') === 'true'
1352
+ )
1353
+ }
1354
+
1355
+ function isWoff(contents) {
1356
+ return contents.subarray(0, 4).toString('ascii') === 'wOFF'
1357
+ }
1358
+
1359
+ function isWoff2(contents) {
1360
+ return contents.subarray(0, 4).toString('ascii') === 'wOF2'
1361
+ }
1362
+
1363
+ function isEot(contents) {
1364
+ return (
1365
+ contents.byteLength >= 12 &&
1366
+ ((contents[8] === 0x01 &&
1367
+ contents[9] === 0x00 &&
1368
+ contents[10] === 0x02 &&
1369
+ contents[11] === 0x00) ||
1370
+ (contents[8] === 0x02 &&
1371
+ contents[9] === 0x00 &&
1372
+ contents[10] === 0x02 &&
1373
+ contents[11] === 0x00))
1374
+ )
1375
+ }
1376
+
1377
+ function readOption(args, names) {
1378
+ const index = args.findIndex(arg => names.includes(arg))
1379
+
1380
+ if (index === -1) {
1381
+ return
1382
+ }
1383
+
1384
+ const [name] = args.splice(index, 1)
1385
+ const [value] = args.splice(index, 1)
1386
+
1387
+ if (value === undefined || value.startsWith('-')) {
1388
+ throw new Error(`${name} requires a value`)
1389
+ }
1390
+
1391
+ return value
1392
+ }
1393
+
1394
+ function readFlag(args, names) {
1395
+ const index = args.findIndex(arg => names.includes(arg))
1396
+
1397
+ if (index === -1) {
1398
+ return false
1399
+ }
1400
+
1401
+ args.splice(index, 1)
1402
+ return true
1403
+ }
1404
+
1405
+ function requireValue(value, message) {
1406
+ if (value === undefined || value.length === 0) {
1407
+ throw new Error(message)
1408
+ }
1409
+ }
1410
+
1411
+ async function writeOutput(output, contents) {
1412
+ await mkdir(dirname(output), { recursive: true })
1413
+ await writeFile(output, contents)
1414
+ }
1415
+
1416
+ function usage() {
1417
+ console.error(`Usage:
1418
+ fontmin-rs subset <input.ttf> -o <output.ttf> (-t|--text <text> | --text-file <file> | --unicodes <list> | -b|--basic-text)
1419
+ fontmin-rs convert <input.ttf> -f <ttf|woff|woff2|eot|svg> -o <output>
1420
+ fontmin-rs build <input.ttf> -o <out-dir> --formats <ttf,woff,woff2,eot,svg,css> [-t|--text <text>] [--text-file <file>] [--unicodes <list>] [-b|--basic-text] [-d|--deflate-woff] [-T|--show-time] [--silent] [--no-original] [--cache|--no-cache]
1421
+ fontmin-rs build <input.ttf> -o <out-dir> --preset <compat|modern-web|iconfont> [-t|--text <text>] [--text-file <file>] [--unicodes <list>] [-b|--basic-text] [-d|--deflate-woff] [-T|--show-time] [--silent] [--no-original] [--cache|--no-cache]
1422
+ fontmin-rs bench <input.ttf> [-t|--text <text>] [--text-file <file>] [--unicodes <list>] [-b|--basic-text] [--json]
1423
+ fontmin-rs inspect <input.ttf> [--json]
1424
+ fontmin-rs init`)
1425
+ }