fontmin-rs 0.1.0 → 0.2.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.
@@ -1,1831 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { createHash } from 'node:crypto'
4
- import {
5
- mkdir,
6
- lstat,
7
- readFile,
8
- realpath,
9
- rename,
10
- rm,
11
- stat,
12
- writeFile,
13
- } from 'node:fs/promises'
14
- import {
15
- basename,
16
- dirname,
17
- extname,
18
- isAbsolute,
19
- join,
20
- parse,
21
- relative,
22
- resolve,
23
- sep,
24
- } from 'node:path'
25
- import { pathToFileURL } from 'node:url'
26
- import {
27
- analyzeCoverage,
28
- eotToTtf,
29
- generateFontFaceCss,
30
- inspectFont,
31
- otfToTtf,
32
- subsetTtf,
33
- svgsToTtf,
34
- ttfToEot,
35
- ttfToSvg,
36
- ttfToWoff,
37
- ttfToWoff2,
38
- woff2ToTtf,
39
- woffToTtf,
40
- } from '@fontmin-rs/binding'
41
- import { parse as parseJsonc } from 'jsonc-parser'
42
- import { glob } from 'tinyglobby'
43
- import { withCacheLock } from './cache-lock.mjs'
3
+ import { runCli } from '../dist/cli.mjs'
44
4
 
45
- const DEFAULT_CONFIG_FILES = [
46
- 'fontmin.config.ts',
47
- 'fontmin.config.mts',
48
- 'fontmin.config.mjs',
49
- 'fontmin.config.cjs',
50
- 'fontmin.config.json',
51
- 'fontmin.config.jsonc',
52
- ]
53
- const MODULE_CONFIG_EXTENSIONS = new Set(['.ts', '.mts', '.mjs', '.cjs'])
54
- const INIT_CONFIG_FILE = 'fontmin.config.jsonc'
55
- const CACHE_SCHEMA_VERSION = 'v1'
56
- const FONTMIN_VERSION = '0.1.0'
57
- const DEFAULT_CACHE_DIR = 'node_modules/.cache/fontmin-rs'
58
- let emitWarnings = true
59
- let temporaryFileCounter = 0
60
- const DEFAULT_INIT_CONFIG = `{
61
- // Generated by fontmin-rs init.
62
- "input": ["fonts/*.ttf"],
63
- "outDir": "build",
64
- "subset": {
65
- "text": "Hello",
66
- "basicText": true
67
- },
68
- "outputs": [
69
- { "format": "woff2" },
70
- { "format": "woff" },
71
- { "format": "css" }
72
- ],
73
- "css": {
74
- "fontFamily": "MyFont",
75
- "fontPath": "./",
76
- "fontDisplay": "swap"
77
- },
78
- "cache": {
79
- "enabled": true
80
- }
81
- }
82
- `
83
-
84
- const argv = process.argv.slice(2)
85
- const command = argv[0]
86
- const commandArgs = argv.slice(1)
87
-
88
- try {
89
- if (
90
- command === undefined ||
91
- command === '--help' ||
92
- command === '-h' ||
93
- commandArgs.includes('--help') ||
94
- commandArgs.includes('-h')
95
- ) {
96
- usage(process.stdout)
97
- } else if (command === '--version' || command === '-V') {
98
- process.stdout.write(`${FONTMIN_VERSION}\n`)
99
- } else if (command === 'doctor') {
100
- assertNoUnexpectedArgs(commandArgs, 0)
101
- process.stdout.write('fontmin-rs doctor ok\n')
102
- } else if (command === 'subset') {
103
- await subsetCommand(commandArgs)
104
- } else if (command === 'coverage') {
105
- await coverageCommand(commandArgs)
106
- } else if (command === 'convert') {
107
- await convertCommand(commandArgs)
108
- } else if (command === 'build') {
109
- await buildCommand(commandArgs)
110
- } else if (command === 'inspect') {
111
- await inspectCommand(commandArgs)
112
- } else if (command === 'init') {
113
- assertNoUnexpectedArgs(commandArgs, 0)
114
- await initCommand()
115
- } else if (command === 'bench') {
116
- await benchCommand(commandArgs)
117
- } else {
118
- usage(process.stderr)
119
- process.exitCode = 1
120
- }
121
- } catch (error) {
122
- console.error(error instanceof Error ? error.message : String(error))
123
- process.exitCode = 1
124
- }
125
-
126
- async function subsetCommand(args) {
127
- const output = readOption(args, ['-o', '--output'])
128
- const subsetOptions = await subsetOptionsFromArgs(args)
129
- const basicText = readFlag(args, ['-b', '--basic-text'])
130
- assertNoUnexpectedArgs(args)
131
- const [input] = args
132
-
133
- requireValue(input, 'subset requires an input font')
134
- requireValue(output, 'subset requires -o, --output')
135
- if (
136
- subsetOptions.text === undefined &&
137
- subsetOptions.unicodes.length === 0 &&
138
- !basicText
139
- ) {
140
- throw new Error(
141
- 'subset requires --text, --text-file, --unicodes, or --basic-text',
142
- )
143
- }
144
-
145
- const contents = await readFile(input)
146
- const subset = subsetWithCoverage(contents, {
147
- basicText,
148
- missingGlyphs: subsetOptions.missingGlyphs,
149
- text: subsetOptions.text,
150
- unicodes: subsetOptions.unicodes,
151
- })
152
-
153
- await writeOutput(output, subset)
154
- }
155
-
156
- async function coverageCommand(args) {
157
- const json = readFlag(args, ['--json'])
158
- const basicText = readFlag(args, ['-b', '--basic-text'])
159
- const options = await subsetOptionsFromArgs(args)
160
- assertNoUnexpectedArgs(args)
161
- const [input] = args
162
-
163
- requireValue(input, 'coverage requires an input font')
164
- if (
165
- options.text === undefined &&
166
- options.unicodes.length === 0 &&
167
- !basicText
168
- ) {
169
- throw new Error(
170
- 'coverage requires --text, --text-file, --unicodes, or --basic-text',
171
- )
172
- }
173
-
174
- const report = analyzeCoverage(await readFile(input), {
175
- basicText,
176
- text: options.text,
177
- unicodes: options.unicodes,
178
- })
179
-
180
- if (json) {
181
- process.stdout.write(`${JSON.stringify(report, undefined, 2)}\n`)
182
- return
183
- }
184
-
185
- process.stdout.write(
186
- `coverage: ${report.coveragePercent.toFixed(2)}% (${report.supported.length}/${report.requested.length})\nrequested: ${report.requested.length}\nsupported: ${report.supported.length}\nmissing: ${report.missing.length}\n`,
187
- )
188
- const warning = missingGlyphMessage(report)
189
- if (warning !== undefined) {
190
- process.stdout.write(`${warning}\n`)
191
- }
192
- }
193
-
194
- async function convertCommand(args) {
195
- const output = readOption(args, ['-o', '--output'])
196
- const format = readOption(args, ['-f', '--format'])
197
- const variationCoordinates = parseVariations(
198
- readOptions(args, ['--variation']),
199
- )
200
- assertNoUnexpectedArgs(args)
201
- const [input] = args
202
-
203
- requireValue(input, 'convert requires an input font')
204
- requireValue(output, 'convert requires -o, --output')
205
- requireValue(format, 'convert requires -f, --format')
206
-
207
- const contents = await readFile(input)
208
- const converted = convertFont(contents, format, { variationCoordinates })
209
-
210
- await writeOutput(output, converted)
211
- }
212
-
213
- async function benchCommand(args) {
214
- const json = readFlag(args, ['--json'])
215
- const basicText = readFlag(args, ['-b', '--basic-text'])
216
- const subsetOptions = await subsetOptionsFromArgs(args)
217
- assertNoUnexpectedArgs(args)
218
- const [input] = args
219
-
220
- requireValue(input, 'bench requires an input font')
221
- if (
222
- subsetOptions.text === undefined &&
223
- subsetOptions.unicodes.length === 0 &&
224
- !basicText
225
- ) {
226
- throw new Error(
227
- 'bench requires --text, --text-file, --unicodes, or --basic-text',
228
- )
229
- }
230
-
231
- const contents = await readFile(input)
232
- const startedAt = process.hrtime.bigint()
233
- const subset = subsetWithCoverage(contents, {
234
- basicText,
235
- missingGlyphs: subsetOptions.missingGlyphs,
236
- text: subsetOptions.text,
237
- unicodes: subsetOptions.unicodes,
238
- })
239
- const elapsedMs = Math.round(
240
- Number(process.hrtime.bigint() - startedAt) / 1_000_000,
241
- )
242
- const report = {
243
- elapsedMs,
244
- inputBytes: contents.byteLength,
245
- operation: 'subset',
246
- outputBytes: subset.byteLength,
247
- }
248
-
249
- if (json) {
250
- process.stdout.write(`${JSON.stringify(report)}\n`)
251
- } else {
252
- process.stdout.write(
253
- `fontmin-rs bench subset completed in ${elapsedMs} ms\ninput: ${report.inputBytes} bytes\noutput: ${report.outputBytes} bytes\n`,
254
- )
255
- }
256
- }
257
-
258
- async function buildCommand(args) {
259
- const showTime = readFlag(args, ['-T', '--show-time'])
260
- const silent = readFlag(args, ['--silent'])
261
- const startedAt = showTime && !silent ? process.hrtime.bigint() : undefined
262
-
263
- emitWarnings = !silent
264
- try {
265
- await runBuildCommand(args)
266
- reportShowTime(startedAt)
267
- } finally {
268
- emitWarnings = true
269
- }
270
- }
271
-
272
- async function runBuildCommand(args) {
273
- const configPath = readOption(args, ['-c', '--config'])
274
- const cache = readFlag(args, ['--cache'])
275
- const noCache = readFlag(args, ['--no-cache'])
276
- const noOriginal = readFlag(args, ['--no-original'])
277
- readFlag(args, ['-d', '--deflate-woff'])
278
- const cssGlyph = readFlag(args, ['--css-glyph'])
279
- const outDir = readOption(args, ['-o', '--out-dir'])
280
- const formats = readOption(args, ['--formats'])
281
- const preset = readOption(args, ['--preset'])
282
- const basicText = readFlag(args, ['-b', '--basic-text'])
283
- const fontFamily = readOption(args, ['--font-family'])
284
- const fontPath = readOption(args, ['--font-path'])
285
- const variationCoordinates = parseVariations(
286
- readOptions(args, ['--variation']),
287
- )
288
- const subsetOptions = await subsetOptionsFromArgs(args)
289
- assertNoUnknownOptions(args)
290
-
291
- if (cache && noCache) {
292
- throw new Error('build accepts only one of --cache or --no-cache')
293
- }
294
-
295
- const cacheOverride = cacheOverrideFromFlags(cache, noCache)
296
-
297
- if (configPath !== undefined) {
298
- if (isIconfontPreset(preset)) {
299
- await buildIconfontConfigCommand(configPath, {
300
- fontFamily,
301
- fontPath,
302
- formats,
303
- inputs: [...args],
304
- cacheOverride,
305
- outDir,
306
- variationCoordinates,
307
- })
308
- return
309
- }
310
-
311
- await buildConfigCommand(configPath, {
312
- basicText,
313
- cacheOverride,
314
- cssGlyph,
315
- fontFamily,
316
- fontPath,
317
- formats,
318
- inputs: [...args],
319
- noOriginal,
320
- outDir,
321
- preset,
322
- subsetOptions,
323
- variationCoordinates,
324
- })
325
- return
326
- }
327
-
328
- if (args.length === 0) {
329
- const foundConfigPath = await findConfig()
330
-
331
- if (isIconfontPreset(preset)) {
332
- await buildIconfontConfigCommand(foundConfigPath, {
333
- fontFamily,
334
- fontPath,
335
- formats,
336
- cacheOverride,
337
- outDir,
338
- variationCoordinates,
339
- })
340
- return
341
- }
342
-
343
- await buildConfigCommand(foundConfigPath, {
344
- basicText,
345
- cacheOverride,
346
- cssGlyph,
347
- fontFamily,
348
- fontPath,
349
- formats,
350
- noOriginal,
351
- outDir,
352
- preset,
353
- subsetOptions,
354
- variationCoordinates,
355
- })
356
- return
357
- }
358
-
359
- const inputPatterns = [...args]
360
- const [input] = inputPatterns
361
-
362
- requireValue(input, 'build requires an input font')
363
- requireValue(outDir, 'build requires -o, --out-dir')
364
-
365
- if (isIconfontPreset(preset)) {
366
- if (formats !== undefined) {
367
- throw new Error('build accepts only one of --formats or --preset')
368
- }
369
-
370
- const inputs = await expandInputPaths(inputPatterns, process.cwd())
371
- const cacheOptions = normalizeCacheOptions(
372
- undefined,
373
- process.cwd(),
374
- cacheOverride,
375
- )
376
-
377
- await buildIconfontCommand(
378
- inputs,
379
- outDir,
380
- fontFamily,
381
- fontPath ?? './',
382
- [],
383
- {},
384
- cacheOptions,
385
- )
386
- return
387
- }
388
-
389
- const inputs = await expandInputPaths(inputPatterns, process.cwd())
390
- const outputFormats = filterOriginalOutput(
391
- outputFormatsFromArgs(formats, preset),
392
- noOriginal,
393
- )
394
- const cacheOptions = normalizeCacheOptions(
395
- undefined,
396
- process.cwd(),
397
- cacheOverride,
398
- )
399
- if (outputFormats.length === 0) {
400
- throw new Error('build requires at least one non-original output format')
401
- }
402
-
403
- await mkdir(outDir, { recursive: true })
404
-
405
- for (const input of inputs) {
406
- await buildDirectInput(input, outDir, {
407
- basicText,
408
- cacheOptions,
409
- cssGlyph,
410
- fontFamily,
411
- fontPath,
412
- outputFormats,
413
- subsetOptions,
414
- variationCoordinates,
415
- })
416
- }
417
- }
418
-
419
- async function buildDirectInput(
420
- input,
421
- outDir,
422
- {
423
- basicText,
424
- cacheOptions,
425
- cssGlyph,
426
- fontFamily,
427
- fontPath,
428
- outputFormats,
429
- subsetOptions,
430
- variationCoordinates,
431
- },
432
- ) {
433
- const contents = await readFile(input)
434
- const ttfContents = convertFont(contents, 'ttf', { variationCoordinates })
435
- const baseName = basename(input, extname(input))
436
- const cacheKey = cacheOptions.enabled
437
- ? cacheKeyForBuildInput({
438
- contents,
439
- input,
440
- kind: 'direct',
441
- options: {
442
- basicText,
443
- cssGlyph,
444
- fontFamily,
445
- fontPath,
446
- outputFormats,
447
- subset: subsetOptions,
448
- variationCoordinates,
449
- },
450
- })
451
- : undefined
452
- const cachedOutputs =
453
- cacheKey === undefined
454
- ? undefined
455
- : await readCachedBuildOutputs(cacheOptions.dir, cacheKey)
456
-
457
- if (cachedOutputs !== undefined) {
458
- await writeBuildOutputs(outDir, cachedOutputs)
459
- return
460
- }
461
-
462
- const source =
463
- subsetOptions.text === undefined &&
464
- subsetOptions.unicodes.length === 0 &&
465
- !basicText
466
- ? ttfContents
467
- : subsetWithCoverage(ttfContents, {
468
- basicText,
469
- missingGlyphs: subsetOptions.missingGlyphs,
470
- text: subsetOptions.text,
471
- unicodes: subsetOptions.unicodes,
472
- })
473
- const cssGlyphs = cssGlyph ? cssGlyphsFromSubset(subsetOptions) : []
474
- const cssSources = []
475
- const outputs = []
476
-
477
- for (const format of outputFormats.filter(format => format !== 'css')) {
478
- const fileName = `${baseName}.${format}`
479
- const output = convertFont(source, format)
480
-
481
- outputs.push({ contents: output, fileName })
482
- cssSources.push({
483
- ...(cssGlyphs.length > 0 && { glyphs: cssGlyphs }),
484
- fileName,
485
- format,
486
- })
487
- }
488
-
489
- if (outputFormats.includes('css')) {
490
- if (cssSources.length === 0) {
491
- throw new Error('build CSS output requires at least one font format')
492
- }
493
-
494
- outputs.push({
495
- contents: Buffer.from(
496
- generateFontFaceCss(cssSources, {
497
- fontFamily: fontFamily ?? baseName,
498
- fontPath: fontPath ?? './',
499
- glyph: cssGlyph,
500
- }),
501
- ),
502
- fileName: `${baseName}.css`,
503
- })
504
- }
505
-
506
- await writeBuildOutputs(outDir, outputs)
507
-
508
- if (cacheKey !== undefined) {
509
- await writeCachedBuildOutputs(cacheOptions.dir, cacheKey, outputs)
510
- }
511
- }
512
-
513
- function reportShowTime(startedAt) {
514
- if (startedAt === undefined) {
515
- return
516
- }
517
-
518
- const elapsedMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000
519
-
520
- process.stdout.write(
521
- `fontmin-rs build completed in ${Math.round(elapsedMs)} ms\n`,
522
- )
523
- }
524
-
525
- async function buildIconfontCommand(
526
- inputs,
527
- outDir,
528
- fontFamily,
529
- fontPath,
530
- outputs = [],
531
- css = {},
532
- cacheOptions = normalizeCacheOptions(undefined, process.cwd()),
533
- ) {
534
- if (inputs.length === 0) {
535
- throw new Error('build requires at least one input font')
536
- }
537
-
538
- const family = fontFamily ?? 'iconfont'
539
- const icons = await Promise.all(
540
- inputs.map(async input => ({
541
- contents: await readFile(input, 'utf8'),
542
- name: basename(input, extname(input)),
543
- })),
544
- )
545
- const cacheKey = cacheOptions.enabled
546
- ? cacheKeyForIconfontBuild({
547
- icons,
548
- inputs,
549
- options: {
550
- css,
551
- fontFamily,
552
- fontPath,
553
- outputs,
554
- },
555
- })
556
- : undefined
557
- const cachedOutputs =
558
- cacheKey === undefined
559
- ? undefined
560
- : await readCachedBuildOutputs(cacheOptions.dir, cacheKey)
561
-
562
- if (cachedOutputs !== undefined) {
563
- await writeBuildOutputs(outDir, cachedOutputs)
564
- return
565
- }
566
-
567
- const ttf = svgsToTtf(icons, {
568
- fontName: family,
569
- })
570
- const fileName = outputFileName(outputs, 'ttf', 'iconfont.ttf')
571
- const cssFileName = outputFileName(
572
- outputs,
573
- 'css',
574
- `${basename(fileName, extname(fileName))}.${css.target ?? 'css'}`,
575
- )
576
- const glyphs = icons.map((icon, index) => ({
577
- name: icon.name,
578
- unicode: 57_345 + index,
579
- }))
580
- const buildOutputs = [
581
- {
582
- contents: ttf,
583
- fileName,
584
- },
585
- {
586
- contents: Buffer.from(
587
- generateFontFaceCss(
588
- [
589
- {
590
- ...(css.base64 === true && { contents: ttf }),
591
- fileName,
592
- format: 'ttf',
593
- glyphs,
594
- },
595
- ],
596
- {
597
- asFileName: css.asFileName ?? true,
598
- base64: css.base64,
599
- fontDisplay: css.fontDisplay,
600
- fontFamily: family,
601
- fontPath,
602
- glyph: true,
603
- iconPrefix: css.iconPrefix,
604
- local: css.local,
605
- },
606
- ),
607
- ),
608
- fileName: cssFileName,
609
- },
610
- ]
611
-
612
- await writeBuildOutputs(outDir, buildOutputs)
613
-
614
- if (cacheKey !== undefined) {
615
- await writeCachedBuildOutputs(cacheOptions.dir, cacheKey, buildOutputs)
616
- }
617
- }
618
-
619
- async function buildConfigCommand(
620
- configPath,
621
- {
622
- basicText,
623
- cacheOverride,
624
- cssGlyph,
625
- fontFamily,
626
- fontPath,
627
- formats,
628
- inputs: inputOverrides = [],
629
- noOriginal,
630
- outDir: outDirOverride,
631
- preset,
632
- subsetOptions,
633
- variationCoordinates = {},
634
- } = {},
635
- ) {
636
- const resolvedConfigPath = resolve(configPath)
637
- const config = await readConfig(resolvedConfigPath)
638
- const cwd =
639
- typeof config.cwd === 'string'
640
- ? resolve(config.cwd)
641
- : dirname(resolvedConfigPath)
642
- const inputs =
643
- inputOverrides.length > 0 ? inputOverrides : (config.input ?? [])
644
- const outDir = resolve(cwd, outDirOverride ?? config.outDir ?? 'build')
645
- const outputFormats = outputFormatsForConfig(config.outputs, {
646
- formats,
647
- noOriginal,
648
- preset,
649
- })
650
- const cacheOptions = normalizeCacheOptions(config.cache, cwd, cacheOverride)
651
- const resolvedVariationCoordinates = {
652
- ...config.otf?.variationCoordinates,
653
- ...variationCoordinates,
654
- }
655
-
656
- if (inputs.length === 0) {
657
- throw new Error('build config requires at least one input')
658
- }
659
-
660
- const inputPaths = await expandInputPaths(inputs, cwd)
661
-
662
- if (fontFamily !== undefined || fontPath !== undefined || cssGlyph === true) {
663
- config.css = {
664
- ...config.css,
665
- ...(cssGlyph === true && { glyph: true }),
666
- ...(fontFamily !== undefined && { fontFamily }),
667
- ...(fontPath !== undefined && { fontPath }),
668
- }
669
- }
670
-
671
- applySubsetOverrides(config, { basicText, subsetOptions })
672
-
673
- if (config.clean === true) {
674
- await cleanOutputDirectory(cwd, outDir, [resolvedConfigPath, ...inputPaths])
675
- }
676
-
677
- await mkdir(outDir, { recursive: true })
678
-
679
- for (const inputPath of inputPaths) {
680
- await buildConfigInput(
681
- inputPath,
682
- outDir,
683
- outputFormats,
684
- config,
685
- cwd,
686
- cacheOptions,
687
- resolvedVariationCoordinates,
688
- )
689
- }
690
- }
691
-
692
- async function buildIconfontConfigCommand(
693
- configPath,
694
- {
695
- cacheOverride,
696
- fontFamily,
697
- fontPath,
698
- formats,
699
- inputs: inputOverrides = [],
700
- outDir: outDirOverride,
701
- },
702
- ) {
703
- if (formats !== undefined) {
704
- throw new Error('build accepts only one of --formats or --preset')
705
- }
706
-
707
- const resolvedConfigPath = resolve(configPath)
708
- const config = await readConfig(resolvedConfigPath)
709
- const cwd =
710
- typeof config.cwd === 'string'
711
- ? resolve(config.cwd)
712
- : dirname(resolvedConfigPath)
713
- const outDir = resolve(cwd, outDirOverride ?? config.outDir ?? 'build')
714
- const inputs = await expandConfigInputPaths(
715
- inputOverrides.length > 0 ? inputOverrides : (config.input ?? []),
716
- cwd,
717
- )
718
- const css = config.css ?? {}
719
- const cacheOptions = normalizeCacheOptions(config.cache, cwd, cacheOverride)
720
-
721
- if (config.clean === true) {
722
- await cleanOutputDirectory(cwd, outDir, [resolvedConfigPath, ...inputs])
723
- }
724
-
725
- await buildIconfontCommand(
726
- inputs,
727
- outDir,
728
- fontFamily ?? css.fontFamily,
729
- fontPath ?? css.fontPath ?? './',
730
- config.outputs ?? [],
731
- css,
732
- cacheOptions,
733
- )
734
- }
735
-
736
- function expandConfigInputPaths(inputs, cwd) {
737
- return expandInputPaths(inputs, cwd)
738
- }
739
-
740
- async function expandInputPaths(inputs, cwd) {
741
- if (inputs.length === 0) {
742
- throw new Error('build config requires at least one input')
743
- }
744
-
745
- const paths = []
746
-
747
- for (const input of inputs) {
748
- if (typeof input !== 'string') {
749
- throw new TypeError('build config input entries must be file paths')
750
- }
751
-
752
- paths.push(...(await expandInputPath(input, cwd)))
753
- }
754
-
755
- return paths
756
- }
757
-
758
- function outputFileName(outputs, format, fallback) {
759
- const output = outputs.find(output => output?.format === format)
760
-
761
- if (typeof output?.fileName === 'string') {
762
- return output.fileName
763
- }
764
-
765
- if (typeof output?.ext === 'string') {
766
- return `${basename(fallback, extname(fallback))}.${normalizeExtension(output.ext)}`
767
- }
768
-
769
- return fallback
770
- }
771
-
772
- function applySubsetOverrides(config, { basicText, subsetOptions } = {}) {
773
- if (
774
- subsetOptions === undefined ||
775
- (subsetOptions.text === undefined &&
776
- subsetOptions.unicodes.length === 0 &&
777
- basicText !== true &&
778
- subsetOptions.missingGlyphs === undefined)
779
- ) {
780
- return
781
- }
782
-
783
- config.subset = {
784
- ...config.subset,
785
- ...(subsetOptions.text !== undefined && { text: subsetOptions.text }),
786
- ...(subsetOptions.unicodes.length > 0 && {
787
- unicodes: subsetOptions.unicodes,
788
- }),
789
- ...(basicText === true && { basicText }),
790
- ...(subsetOptions.missingGlyphs !== undefined && {
791
- missingGlyphs: subsetOptions.missingGlyphs,
792
- }),
793
- }
794
- }
795
-
796
- async function buildConfigInput(
797
- input,
798
- outDir,
799
- outputFormats,
800
- config,
801
- cwd,
802
- cacheOptions,
803
- variationCoordinates,
804
- ) {
805
- const contents = await readFile(input)
806
- const ttfContents = convertFont(contents, 'ttf', { variationCoordinates })
807
- const subset = await resolveSubsetTextFile(config.subset ?? {}, cwd)
808
- const unicodes = subset.unicodes ?? []
809
- const cacheKey = cacheOptions.enabled
810
- ? cacheKeyForBuildInput({
811
- contents,
812
- input,
813
- kind: 'config',
814
- options: {
815
- css: config.css,
816
- outputFormats,
817
- outputs: config.outputs,
818
- subset,
819
- variationCoordinates,
820
- },
821
- })
822
- : undefined
823
- const cachedOutputs =
824
- cacheKey === undefined
825
- ? undefined
826
- : await readCachedBuildOutputs(cacheOptions.dir, cacheKey)
827
-
828
- if (cachedOutputs !== undefined) {
829
- await writeBuildOutputs(outDir, cachedOutputs)
830
- return
831
- }
832
-
833
- const source =
834
- subset.text === undefined &&
835
- unicodes.length === 0 &&
836
- subset.basicText !== true
837
- ? ttfContents
838
- : subsetWithCoverage(ttfContents, {
839
- basicText: subset.basicText,
840
- missingGlyphs: subset.missingGlyphs,
841
- text: subset.text,
842
- unicodes,
843
- })
844
- const baseName = basename(input, extname(input))
845
- const css = config.css ?? {}
846
- const cssGlyphs = css.glyph === true ? cssGlyphsFromSubset(subset) : []
847
- const cssSources = []
848
- const outputs = config.outputs ?? []
849
- const buildOutputs = []
850
-
851
- for (const format of outputFormats.filter(format => format !== 'css')) {
852
- const fileName = outputFileName(outputs, format, `${baseName}.${format}`)
853
- const output = convertFont(source, format)
854
-
855
- buildOutputs.push({ contents: output, fileName })
856
- cssSources.push({
857
- ...(css.base64 === true && { contents: output }),
858
- ...(cssGlyphs.length > 0 && { glyphs: cssGlyphs }),
859
- fileName,
860
- format,
861
- })
862
- }
863
-
864
- if (outputFormats.includes('css')) {
865
- if (cssSources.length === 0) {
866
- throw new Error('build CSS output requires at least one font format')
867
- }
868
-
869
- const firstCssSource = cssSources[0]
870
- const cssFileName = outputFileName(
871
- outputs,
872
- 'css',
873
- `${basename(firstCssSource.fileName, extname(firstCssSource.fileName))}.${css.target ?? 'css'}`,
874
- )
875
-
876
- buildOutputs.push({
877
- contents: Buffer.from(
878
- generateFontFaceCss(cssSources, {
879
- asFileName: css.asFileName,
880
- base64: css.base64,
881
- fontDisplay: css.fontDisplay,
882
- fontFamily: css.fontFamily ?? baseName,
883
- fontPath: css.fontPath ?? './',
884
- glyph: css.glyph,
885
- iconPrefix: css.iconPrefix,
886
- local: css.local,
887
- }),
888
- ),
889
- fileName: cssFileName,
890
- })
891
- }
892
-
893
- await writeBuildOutputs(outDir, buildOutputs)
894
-
895
- if (cacheKey !== undefined) {
896
- await writeCachedBuildOutputs(cacheOptions.dir, cacheKey, buildOutputs)
897
- }
898
- }
899
-
900
- function cssGlyphsFromSubset(subset) {
901
- const seen = new Set()
902
- const glyphs = []
903
-
904
- for (const character of subset.text ?? '') {
905
- const unicode = character.codePointAt(0)
906
-
907
- if (unicode !== undefined && !seen.has(unicode)) {
908
- seen.add(unicode)
909
- glyphs.push({ unicode })
910
- }
911
- }
912
-
913
- for (const unicode of subset.unicodes ?? []) {
914
- if (!seen.has(unicode)) {
915
- seen.add(unicode)
916
- glyphs.push({ unicode })
917
- }
918
- }
919
-
920
- return glyphs
921
- }
922
-
923
- async function writeBuildOutputs(outDir, outputs) {
924
- await mkdir(outDir, { recursive: true })
925
-
926
- for (const output of outputs) {
927
- const outputPath = resolveContainedPath(
928
- outDir,
929
- output.fileName,
930
- 'output file name',
931
- )
932
-
933
- await mkdir(dirname(outputPath), { recursive: true })
934
- await ensureRealPathContained(
935
- outDir,
936
- dirname(outputPath),
937
- 'output file name',
938
- )
939
- await rejectSymbolicLink(outputPath)
940
- await writeFile(outputPath, output.contents)
941
- }
942
- }
943
-
944
- async function readCachedBuildOutputs(cacheDir, key) {
945
- let manifest = null
946
-
947
- try {
948
- manifest = JSON.parse(
949
- await readFile(cacheManifestPath(cacheDir, key), 'utf8'),
950
- )
951
- } catch {
952
- return
953
- }
954
-
955
- if (manifest.version !== CACHE_SCHEMA_VERSION || manifest.key !== key) {
956
- return
957
- }
958
-
959
- const entryDir = cacheEntryDir(cacheDir, key)
960
- const outputs = []
961
-
962
- try {
963
- for (const record of manifest.outputs) {
964
- const cacheFile = resolveContainedPath(
965
- entryDir,
966
- record.cacheFileName,
967
- 'cache file name',
968
- )
969
-
970
- await ensureRealPathContained(entryDir, cacheFile, 'cache file name')
971
- outputs.push({
972
- contents: await readFile(cacheFile),
973
- fileName: record.fileName,
974
- })
975
- }
976
- } catch {
977
- return
978
- }
979
-
980
- return outputs
981
- }
982
-
983
- async function writeCachedBuildOutputs(cacheDir, key, outputs) {
984
- await withCacheLock(cacheRoot(cacheDir), async () => {
985
- const entryDir = cacheEntryDir(cacheDir, key)
986
- const records = []
987
-
988
- await mkdir(entryDir, { recursive: true })
989
-
990
- for (const [index, output] of outputs.entries()) {
991
- const extension = extname(output.fileName).replace(/^\./u, '') || 'bin'
992
- const cacheFileName = `${String(index).padStart(3, '0')}.${extension}`
993
-
994
- await atomicWriteFile(join(entryDir, cacheFileName), output.contents)
995
- records.push({
996
- cacheFileName,
997
- fileName: output.fileName,
998
- })
999
- }
1000
-
1001
- await atomicWriteFile(
1002
- cacheManifestPath(cacheDir, key),
1003
- `${JSON.stringify(
1004
- {
1005
- key,
1006
- outputs: records,
1007
- version: CACHE_SCHEMA_VERSION,
1008
- },
1009
- undefined,
1010
- 2,
1011
- )}\n`,
1012
- )
1013
- await updateBuildCacheIndex(cacheDir, key, records)
1014
- })
1015
- }
1016
-
1017
- async function updateBuildCacheIndex(cacheDir, key, outputs) {
1018
- const indexPath = cacheIndexPath(cacheDir)
1019
- let index = {
1020
- entries: {},
1021
- version: CACHE_SCHEMA_VERSION,
1022
- }
1023
-
1024
- try {
1025
- index = JSON.parse(await readFile(indexPath, 'utf8'))
1026
- } catch {
1027
- // A missing or corrupted cache index can be rebuilt from the next writes.
1028
- }
1029
-
1030
- if (index.version !== CACHE_SCHEMA_VERSION) {
1031
- index = {
1032
- entries: {},
1033
- version: CACHE_SCHEMA_VERSION,
1034
- }
1035
- }
1036
-
1037
- index.entries[key] = {
1038
- outputs: outputs.map(output => output.fileName),
1039
- updatedAt: new Date().toISOString(),
1040
- }
1041
-
1042
- await mkdir(dirname(indexPath), { recursive: true })
1043
- await atomicWriteFile(indexPath, `${JSON.stringify(index, undefined, 2)}\n`)
1044
- }
1045
-
1046
- function normalizeCacheOptions(options, cwd, override) {
1047
- const configuredDir =
1048
- typeof options === 'object' &&
1049
- options !== null &&
1050
- typeof options.dir === 'string'
1051
- ? options.dir
1052
- : DEFAULT_CACHE_DIR
1053
-
1054
- if (override === true) {
1055
- return {
1056
- dir: resolve(cwd, configuredDir),
1057
- enabled: true,
1058
- }
1059
- }
1060
-
1061
- if (override === false || options === undefined || options === false) {
1062
- return {
1063
- dir: resolve(cwd, configuredDir),
1064
- enabled: false,
1065
- }
1066
- }
1067
-
1068
- if (options === true) {
1069
- return {
1070
- dir: resolve(cwd, DEFAULT_CACHE_DIR),
1071
- enabled: true,
1072
- }
1073
- }
1074
-
1075
- return {
1076
- dir: resolve(cwd, options.dir ?? DEFAULT_CACHE_DIR),
1077
- enabled: options.enabled ?? true,
1078
- }
1079
- }
1080
-
1081
- function cacheOverrideFromFlags(cache, noCache) {
1082
- if (cache) {
1083
- return true
1084
- }
1085
-
1086
- if (noCache) {
1087
- return false
1088
- }
1089
- }
1090
-
1091
- function cacheKeyForBuildInput({ contents, input, kind, options }) {
1092
- return sha256(
1093
- stableStringify({
1094
- fontminVersion: FONTMIN_VERSION,
1095
- input: {
1096
- hash: sha256(contents),
1097
- path: input,
1098
- },
1099
- kind,
1100
- options,
1101
- schema: CACHE_SCHEMA_VERSION,
1102
- }),
1103
- )
1104
- }
1105
-
1106
- function cacheKeyForIconfontBuild({ icons, inputs, options }) {
1107
- return sha256(
1108
- stableStringify({
1109
- fontminVersion: FONTMIN_VERSION,
1110
- icons: icons.map((icon, index) => ({
1111
- hash: sha256(icon.contents),
1112
- input: inputs[index],
1113
- name: icon.name,
1114
- })),
1115
- kind: 'iconfont',
1116
- options,
1117
- schema: CACHE_SCHEMA_VERSION,
1118
- }),
1119
- )
1120
- }
1121
-
1122
- function cacheRoot(cacheDir) {
1123
- return join(cacheDir, CACHE_SCHEMA_VERSION)
1124
- }
1125
-
1126
- function cacheIndexPath(cacheDir) {
1127
- return join(cacheRoot(cacheDir), 'index.json')
1128
- }
1129
-
1130
- function cacheEntryDir(cacheDir, key) {
1131
- return join(cacheRoot(cacheDir), key)
1132
- }
1133
-
1134
- function cacheManifestPath(cacheDir, key) {
1135
- return join(cacheEntryDir(cacheDir, key), 'index.json')
1136
- }
1137
-
1138
- function sha256(input) {
1139
- return createHash('sha256').update(input).digest('hex')
1140
- }
1141
-
1142
- function stableStringify(value) {
1143
- if (value === null || typeof value !== 'object') {
1144
- return JSON.stringify(value)
1145
- }
1146
-
1147
- if (Array.isArray(value)) {
1148
- return `[${value.map(item => stableStringify(item)).join(',')}]`
1149
- }
1150
-
1151
- const record = value
1152
- const entries = Object.keys(record)
1153
- .sort()
1154
- .map(key => {
1155
- return `${JSON.stringify(key)}:${stableStringify(record[key])}`
1156
- })
1157
-
1158
- return `{${entries.join(',')}}`
1159
- }
1160
-
1161
- async function inspectCommand(args) {
1162
- const json = readFlag(args, ['--json'])
1163
- assertNoUnexpectedArgs(args)
1164
- const [input] = args
1165
-
1166
- requireValue(input, 'inspect requires an input font')
1167
-
1168
- const contents = await readFile(input)
1169
- const info = inspectFont(contents)
1170
-
1171
- if (json) {
1172
- console.log(JSON.stringify(info, undefined, 2))
1173
- } else {
1174
- console.log(
1175
- `${input}: ${info.format}, ${info.size} bytes, ${info.metadata.glyphCount} glyphs`,
1176
- )
1177
- }
1178
- }
1179
-
1180
- async function initCommand() {
1181
- try {
1182
- await writeFile(INIT_CONFIG_FILE, DEFAULT_INIT_CONFIG, { flag: 'wx' })
1183
- } catch (error) {
1184
- if (hasErrorCode(error, 'EEXIST')) {
1185
- throw new Error(`${INIT_CONFIG_FILE} already exists`, { cause: error })
1186
- }
1187
-
1188
- throw error
1189
- }
1190
-
1191
- process.stdout.write(`created ${INIT_CONFIG_FILE}\n`)
1192
- }
1193
-
1194
- async function subsetOptionsFromArgs(args) {
1195
- const subset = await resolveSubsetTextFile(
1196
- {
1197
- text: readOption(args, ['-t', '--text']),
1198
- textFile: readOption(args, ['--text-file']),
1199
- unicodes: parseUnicodes(readOption(args, ['--unicodes'])),
1200
- },
1201
- process.cwd(),
1202
- )
1203
-
1204
- return {
1205
- ...subset,
1206
- missingGlyphs: parseMissingGlyphPolicy(
1207
- readOption(args, ['--missing-glyphs']),
1208
- ),
1209
- unicodes: subset.unicodes ?? [],
1210
- }
1211
- }
1212
-
1213
- function subsetWithCoverage(contents, options) {
1214
- const policy = options.missingGlyphs ?? 'warn'
1215
-
1216
- if (policy === 'warn') {
1217
- const report = analyzeCoverage(contents, options)
1218
- const warning = missingGlyphMessage(report)
1219
-
1220
- if (warning !== undefined && emitWarnings) {
1221
- process.stderr.write(`warning: ${warning}\n`)
1222
- }
1223
- }
1224
-
1225
- return subsetTtf(contents, {
1226
- ...options,
1227
- missingGlyphs: policy === 'warn' ? 'ignore' : policy,
1228
- })
1229
- }
1230
-
1231
- function missingGlyphMessage(report) {
1232
- if (report.missing.length === 0) {
1233
- return
1234
- }
1235
-
1236
- const visible = report.missing
1237
- .slice(0, 16)
1238
- .map(
1239
- codePoint => `U+${codePoint.toString(16).toUpperCase().padStart(4, '0')}`,
1240
- )
1241
- .join(', ')
1242
- const remaining = report.missing.length - 16
1243
-
1244
- return `missing glyphs for requested Unicode code points: ${visible}${remaining > 0 ? `, and ${remaining} more` : ''}`
1245
- }
1246
-
1247
- function parseMissingGlyphPolicy(value) {
1248
- if (value === undefined) {
1249
- return
1250
- }
1251
-
1252
- const policy = value.trim().toLowerCase()
1253
-
1254
- if (policy === 'ignore' || policy === 'warn' || policy === 'error') {
1255
- return policy
1256
- }
1257
-
1258
- throw new Error(
1259
- `missing glyph policy must be \`ignore\`, \`warn\`, or \`error\`: ${value}`,
1260
- )
1261
- }
1262
-
1263
- async function resolveSubsetTextFile(subset, cwd) {
1264
- if (subset.textFile === undefined) {
1265
- return subset
1266
- }
1267
-
1268
- const fileText = await readFile(resolve(cwd, subset.textFile), 'utf8')
1269
-
1270
- return {
1271
- ...subset,
1272
- text: subset.text === undefined ? fileText : `${subset.text}${fileText}`,
1273
- }
1274
- }
1275
-
1276
- async function readConfig(configPath) {
1277
- const contents = await readFile(configPath, 'utf8')
1278
- const extension = extname(configPath)
1279
-
1280
- if (extension === '.json') {
1281
- return JSON.parse(contents)
1282
- }
1283
-
1284
- if (extension === '.jsonc') {
1285
- return parseJsonc(contents)
1286
- }
1287
-
1288
- if (MODULE_CONFIG_EXTENSIONS.has(extension)) {
1289
- const configModule = await import(pathToFileURL(configPath).href)
1290
- const loadedConfig = configModule.default ?? configModule.config
1291
-
1292
- if (loadedConfig === undefined) {
1293
- throw new Error(`config file \`${configPath}\` does not export a config`)
1294
- }
1295
-
1296
- return typeof loadedConfig === 'function'
1297
- ? await loadedConfig()
1298
- : loadedConfig
1299
- }
1300
-
1301
- throw new Error(`unsupported config extension \`${extension}\``)
1302
- }
1303
-
1304
- async function findConfig(cwd = process.cwd()) {
1305
- for (const fileName of DEFAULT_CONFIG_FILES) {
1306
- const configPath = resolve(cwd, fileName)
1307
-
1308
- if (await isFile(configPath)) {
1309
- return configPath
1310
- }
1311
- }
1312
-
1313
- throw new Error(`could not find fontmin config in ${cwd}`)
1314
- }
1315
-
1316
- async function isFile(path) {
1317
- try {
1318
- const stats = await stat(path)
1319
- return stats.isFile()
1320
- } catch {
1321
- return false
1322
- }
1323
- }
1324
-
1325
- async function expandInputPath(input, cwd) {
1326
- if (!isGlobPattern(input)) {
1327
- return [resolve(cwd, input)]
1328
- }
1329
-
1330
- const matches = await glob(input, {
1331
- absolute: true,
1332
- cwd,
1333
- onlyFiles: true,
1334
- })
1335
-
1336
- if (matches.length === 0) {
1337
- throw new Error(`fontmin-rs input glob matched no files: ${input}`)
1338
- }
1339
-
1340
- return matches.sort((left, right) => left.localeCompare(right))
1341
- }
1342
-
1343
- function isGlobPattern(path) {
1344
- return /[*?[\]{}]/u.test(path)
1345
- }
1346
-
1347
- function hasErrorCode(error, code) {
1348
- return (
1349
- typeof error === 'object' &&
1350
- error !== null &&
1351
- 'code' in error &&
1352
- error.code === code
1353
- )
1354
- }
1355
-
1356
- async function cleanOutputDirectory(cwd, outDir, protectedPaths) {
1357
- const root = resolve(cwd)
1358
- const target = resolve(outDir)
1359
- const targetIsInsideRoot = pathContains(root, target) && target !== root
1360
- const targetContainsInput = protectedPaths.some(path =>
1361
- pathContains(target, resolve(path)),
1362
- )
1363
-
1364
- if (
1365
- target === parse(target).root ||
1366
- pathContains(target, root) ||
1367
- targetContainsInput
1368
- ) {
1369
- throw new Error(
1370
- `refusing to clean output directory ${target} because it is the project directory, an input ancestor, or a filesystem root`,
1371
- )
1372
- }
1373
-
1374
- try {
1375
- const [realRoot, realTarget, ...realProtectedPaths] = await Promise.all([
1376
- realpath(root),
1377
- realpath(target),
1378
- ...protectedPaths.map(path => realpath(resolve(path))),
1379
- ])
1380
-
1381
- if (
1382
- realTarget === parse(realTarget).root ||
1383
- pathContains(realTarget, realRoot) ||
1384
- realProtectedPaths.some(path => pathContains(realTarget, path)) ||
1385
- (targetIsInsideRoot && !pathContains(realRoot, realTarget))
1386
- ) {
1387
- throw new Error(
1388
- `refusing to clean output directory ${target} because its resolved location is unsafe for project ${root}`,
1389
- )
1390
- }
1391
- } catch (error) {
1392
- if (!hasErrorCode(error, 'ENOENT')) {
1393
- throw error
1394
- }
1395
- }
1396
-
1397
- await rm(target, { recursive: true, force: true })
1398
- }
1399
-
1400
- function pathContains(parent, candidate) {
1401
- const childPath = relative(parent, candidate)
1402
-
1403
- return (
1404
- childPath === '' ||
1405
- (childPath !== '..' &&
1406
- !childPath.startsWith(`..${sep}`) &&
1407
- !isAbsolute(childPath))
1408
- )
1409
- }
1410
-
1411
- function resolveContainedPath(root, path, label) {
1412
- if (typeof path !== 'string' || path.length === 0 || isAbsolute(path)) {
1413
- throw new Error(`${label} must be a non-empty relative path: ${path}`)
1414
- }
1415
-
1416
- const resolvedRoot = resolve(root)
1417
- const resolvedPath = resolve(resolvedRoot, path)
1418
- const relativePath = relative(resolvedRoot, resolvedPath)
1419
-
1420
- if (
1421
- relativePath === '' ||
1422
- relativePath === '..' ||
1423
- relativePath.startsWith(`..${sep}`) ||
1424
- isAbsolute(relativePath)
1425
- ) {
1426
- throw new Error(
1427
- `${label} must stay within its destination directory: ${path}`,
1428
- )
1429
- }
1430
-
1431
- return resolvedPath
1432
- }
1433
-
1434
- async function ensureRealPathContained(root, path, label) {
1435
- const [realRoot, realPath] = await Promise.all([
1436
- realpath(root),
1437
- realpath(path),
1438
- ])
1439
- const relativePath = relative(realRoot, realPath)
1440
-
1441
- if (
1442
- relativePath === '..' ||
1443
- relativePath.startsWith(`..${sep}`) ||
1444
- isAbsolute(relativePath)
1445
- ) {
1446
- throw new Error(`${label} resolves outside its destination directory`)
1447
- }
1448
- }
1449
-
1450
- async function rejectSymbolicLink(path) {
1451
- try {
1452
- const metadata = await lstat(path)
1453
-
1454
- if (metadata.isSymbolicLink()) {
1455
- throw new Error(`refusing to write output through symbolic link: ${path}`)
1456
- }
1457
- } catch (error) {
1458
- if (!hasErrorCode(error, 'ENOENT')) {
1459
- throw error
1460
- }
1461
- }
1462
- }
1463
-
1464
- function normalizeExtension(extension) {
1465
- const normalized = extension.replace(/^\.+/u, '')
1466
-
1467
- if (
1468
- normalized.length === 0 ||
1469
- normalized === '..' ||
1470
- normalized.includes('/') ||
1471
- normalized.includes('\\')
1472
- ) {
1473
- throw new Error(`output extension must be a file extension: ${extension}`)
1474
- }
1475
-
1476
- return normalized
1477
- }
1478
-
1479
- async function atomicWriteFile(path, contents) {
1480
- const temporaryPath = `${path}.${process.pid}.${temporaryFileCounter}.tmp`
1481
- temporaryFileCounter += 1
1482
-
1483
- try {
1484
- await writeFile(temporaryPath, contents)
1485
- await rename(temporaryPath, path)
1486
- } finally {
1487
- await rm(temporaryPath, { force: true })
1488
- }
1489
- }
1490
-
1491
- function outputFormatsFromConfig(outputs) {
1492
- if (outputs === undefined) {
1493
- return ['eot', 'woff', 'woff2', 'svg', 'css']
1494
- }
1495
-
1496
- const formats = outputs.map(output =>
1497
- typeof output === 'string' ? output : output.format,
1498
- )
1499
-
1500
- return parseFormats(formats.join(','))
1501
- }
1502
-
1503
- function outputFormatsForConfig(outputs, { formats, noOriginal, preset }) {
1504
- const outputFormats =
1505
- formats === undefined && preset === undefined
1506
- ? outputFormatsFromConfig(outputs)
1507
- : outputFormatsFromArgs(formats, preset)
1508
- const filtered = filterOriginalOutput(outputFormats, noOriginal)
1509
-
1510
- if (filtered.length === 0) {
1511
- throw new Error('build requires at least one non-original output format')
1512
- }
1513
-
1514
- return filtered
1515
- }
1516
-
1517
- function outputFormatsFromArgs(formats, preset) {
1518
- if (formats !== undefined && preset !== undefined) {
1519
- throw new Error('build accepts only one of --formats or --preset')
1520
- }
1521
-
1522
- if (formats !== undefined) {
1523
- return parseFormats(formats)
1524
- }
1525
-
1526
- if (preset !== undefined) {
1527
- return outputFormatsFromPreset(preset)
1528
- }
1529
-
1530
- throw new Error('build requires --formats or --preset')
1531
- }
1532
-
1533
- function outputFormatsFromPreset(value) {
1534
- const preset = value.trim().toLowerCase()
1535
-
1536
- if (preset === 'compat') {
1537
- return ['eot', 'svg', 'woff', 'woff2', 'css']
1538
- }
1539
-
1540
- if (preset === 'modern-web') {
1541
- return ['woff2', 'woff', 'css']
1542
- }
1543
-
1544
- if (preset === 'iconfont') {
1545
- return ['ttf', 'css']
1546
- }
1547
-
1548
- throw new Error(`unsupported preset \`${preset}\``)
1549
- }
1550
-
1551
- function isIconfontPreset(value) {
1552
- return value?.trim().toLowerCase() === 'iconfont'
1553
- }
1554
-
1555
- function filterOriginalOutput(formats, noOriginal) {
1556
- return noOriginal ? formats.filter(format => format !== 'ttf') : formats
1557
- }
1558
-
1559
- function parseUnicodes(value) {
1560
- if (value === undefined) {
1561
- return []
1562
- }
1563
-
1564
- const unicodes = value.split(',').map(item => parseUnicodeCodePoint(item))
1565
-
1566
- if (unicodes.length === 0) {
1567
- throw new Error('expected at least one unicode code point')
1568
- }
1569
-
1570
- return unicodes
1571
- }
1572
-
1573
- function parseUnicodeCodePoint(value) {
1574
- const item = value.trim()
1575
-
1576
- if (item.length === 0) {
1577
- throw new Error('empty unicode code point in --unicodes')
1578
- }
1579
-
1580
- let digits = item
1581
- let radix = 10
1582
-
1583
- if (/^(?:0x|u\+)/iu.test(item)) {
1584
- digits = item.slice(2)
1585
- radix = 16
1586
- }
1587
-
1588
- const validDigits = radix === 16 ? /^[0-9a-f]+$/iu : /^[0-9]+$/u
1589
-
1590
- if (!validDigits.test(digits)) {
1591
- throw new Error(`invalid unicode code point \`${item}\``)
1592
- }
1593
-
1594
- const codePoint = Number.parseInt(digits, radix)
1595
-
1596
- if (!Number.isInteger(codePoint) || codePoint > 4_294_967_295) {
1597
- throw new Error(`invalid unicode code point \`${item}\``)
1598
- }
1599
-
1600
- return codePoint
1601
- }
1602
-
1603
- function parseFormats(value) {
1604
- const formats = value
1605
- .split(',')
1606
- .map(format => format.trim().toLowerCase())
1607
- .filter(format => format.length > 0)
1608
-
1609
- if (formats.length === 0) {
1610
- throw new Error('expected at least one output format')
1611
- }
1612
-
1613
- for (const format of formats) {
1614
- if (
1615
- format !== 'ttf' &&
1616
- format !== 'woff' &&
1617
- format !== 'woff2' &&
1618
- format !== 'eot' &&
1619
- format !== 'svg' &&
1620
- format !== 'css'
1621
- ) {
1622
- throw new Error(`unsupported output format \`${format}\``)
1623
- }
1624
- }
1625
-
1626
- return formats
1627
- }
1628
-
1629
- function convertFont(contents, format, options = {}) {
1630
- const normalized = format.toLowerCase()
1631
-
1632
- if (normalized === 'ttf') {
1633
- if (isTtf(contents)) {
1634
- return contents
1635
- }
1636
-
1637
- if (isWoff(contents)) {
1638
- return woffToTtf(contents)
1639
- }
1640
-
1641
- if (isWoff2(contents)) {
1642
- return woff2ToTtf(contents)
1643
- }
1644
-
1645
- if (isEot(contents)) {
1646
- return eotToTtf(contents)
1647
- }
1648
-
1649
- if (isOtf(contents)) {
1650
- return otfToTtf(contents, {
1651
- variationCoordinates: options.variationCoordinates ?? {},
1652
- })
1653
- }
1654
-
1655
- throw new Error('unsupported input format for TTF conversion')
1656
- }
1657
-
1658
- const ttf = convertFont(contents, 'ttf', options)
1659
-
1660
- if (normalized === 'woff') {
1661
- return ttfToWoff(ttf)
1662
- }
1663
-
1664
- if (normalized === 'woff2') {
1665
- return ttfToWoff2(ttf)
1666
- }
1667
-
1668
- if (normalized === 'eot') {
1669
- return ttfToEot(ttf)
1670
- }
1671
-
1672
- if (normalized === 'svg') {
1673
- return ttfToSvg(ttf)
1674
- }
1675
-
1676
- throw new Error(`unsupported output format \`${format}\``)
1677
- }
1678
-
1679
- function isTtf(contents) {
1680
- return (
1681
- (contents[0] === 0x00 &&
1682
- contents[1] === 0x01 &&
1683
- contents[2] === 0x00 &&
1684
- contents[3] === 0x00) ||
1685
- contents.subarray(0, 4).toString('ascii') === 'true'
1686
- )
1687
- }
1688
-
1689
- function isOtf(contents) {
1690
- return contents.subarray(0, 4).toString('ascii') === 'OTTO'
1691
- }
1692
-
1693
- function isWoff(contents) {
1694
- return contents.subarray(0, 4).toString('ascii') === 'wOFF'
1695
- }
1696
-
1697
- function isWoff2(contents) {
1698
- return contents.subarray(0, 4).toString('ascii') === 'wOF2'
1699
- }
1700
-
1701
- function isEot(contents) {
1702
- return (
1703
- contents.byteLength >= 12 &&
1704
- ((contents[8] === 0x01 &&
1705
- contents[9] === 0x00 &&
1706
- contents[10] === 0x02 &&
1707
- contents[11] === 0x00) ||
1708
- (contents[8] === 0x02 &&
1709
- contents[9] === 0x00 &&
1710
- contents[10] === 0x02 &&
1711
- contents[11] === 0x00))
1712
- )
1713
- }
1714
-
1715
- function readOption(args, names) {
1716
- const index = args.findIndex(arg => names.includes(arg))
1717
-
1718
- if (index === -1) {
1719
- return
1720
- }
1721
-
1722
- const [name] = args.splice(index, 1)
1723
- const [value] = args.splice(index, 1)
1724
-
1725
- if (value === undefined || value.startsWith('-')) {
1726
- throw new Error(`${name} requires a value`)
1727
- }
1728
-
1729
- return value
1730
- }
1731
-
1732
- function readOptions(args, names) {
1733
- const values = []
1734
-
1735
- while (args.some(arg => names.includes(arg))) {
1736
- values.push(readOption(args, names))
1737
- }
1738
-
1739
- return values
1740
- }
1741
-
1742
- function readFlag(args, names) {
1743
- const index = args.findIndex(arg => names.includes(arg))
1744
-
1745
- if (index === -1) {
1746
- return false
1747
- }
1748
-
1749
- args.splice(index, 1)
1750
- return true
1751
- }
1752
-
1753
- function assertNoUnknownOptions(args) {
1754
- const option = args.find(arg => arg.startsWith('-'))
1755
-
1756
- if (option !== undefined) {
1757
- throw new Error(`unknown option \`${option}\``)
1758
- }
1759
- }
1760
-
1761
- function assertNoUnexpectedArgs(args, maximumPositionals = 1) {
1762
- assertNoUnknownOptions(args)
1763
-
1764
- if (args.length > maximumPositionals) {
1765
- throw new Error(`unexpected argument \`${args[maximumPositionals]}\``)
1766
- }
1767
- }
1768
-
1769
- function parseVariations(values) {
1770
- const coordinates = {}
1771
-
1772
- for (const value of values) {
1773
- const separator = value.indexOf('=')
1774
-
1775
- if (separator === -1) {
1776
- throw new Error(`invalid variation \`${value}\`; expected TAG=VALUE`)
1777
- }
1778
-
1779
- const tag = value.slice(0, separator)
1780
- const rawNumber = value.slice(separator + 1)
1781
-
1782
- if (
1783
- tag.length !== 4 ||
1784
- [...tag].some(character => (character.codePointAt(0) ?? 128) > 127)
1785
- ) {
1786
- throw new Error(
1787
- `invalid variation axis \`${tag}\`; expected four ASCII characters`,
1788
- )
1789
- }
1790
- if (Object.hasOwn(coordinates, tag)) {
1791
- throw new Error(`duplicate variation axis \`${tag}\``)
1792
- }
1793
-
1794
- const number = Number(rawNumber)
1795
-
1796
- if (rawNumber.length === 0 || !Number.isFinite(number)) {
1797
- throw new Error(
1798
- `invalid variation value \`${rawNumber}\` for axis \`${tag}\``,
1799
- )
1800
- }
1801
-
1802
- coordinates[tag] = number
1803
- }
1804
-
1805
- return coordinates
1806
- }
1807
-
1808
- function requireValue(value, message) {
1809
- if (value === undefined || value.length === 0) {
1810
- throw new Error(message)
1811
- }
1812
- }
1813
-
1814
- async function writeOutput(output, contents) {
1815
- await mkdir(dirname(output), { recursive: true })
1816
- await writeFile(output, contents)
1817
- }
1818
-
1819
- function usage(stream) {
1820
- stream.write(`Usage:
1821
- fontmin-rs subset <input.ttf> -o <output.ttf> (-t|--text <text> | --text-file <file> | --unicodes <list> | -b|--basic-text) [--missing-glyphs <ignore|warn|error>]
1822
- fontmin-rs coverage <input> (-t|--text <text> | --text-file <file> | --unicodes <list> | -b|--basic-text) [--json]
1823
- fontmin-rs convert <input> -f <ttf|woff|woff2|eot|svg> -o <output> [--variation <TAG=VALUE>]...
1824
- fontmin-rs build <input> -o <out-dir> --formats <ttf,woff,woff2,eot,svg,css> [-t|--text <text>] [--text-file <file>] [--unicodes <list>] [--variation <TAG=VALUE>]... [-b|--basic-text] [-d|--deflate-woff] [-T|--show-time] [--silent] [--no-original] [--cache|--no-cache]
1825
- fontmin-rs build <input> -o <out-dir> --preset <compat|modern-web|iconfont> [-t|--text <text>] [--text-file <file>] [--unicodes <list>] [--variation <TAG=VALUE>]... [-b|--basic-text] [-d|--deflate-woff] [-T|--show-time] [--silent] [--no-original] [--cache|--no-cache]
1826
- fontmin-rs bench <input.ttf> [-t|--text <text>] [--text-file <file>] [--unicodes <list>] [-b|--basic-text] [--json]
1827
- fontmin-rs inspect <input.ttf> [--json]
1828
- fontmin-rs init
1829
- fontmin-rs doctor
1830
- `)
1831
- }
5
+ await runCli()