fontmin-rs 0.1.1 → 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,2007 +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.1'
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 cssUnicodeRanges = parseUnicodeRanges(
280
- readOptions(args, ['--css-unicode-range']),
281
- )
282
- const deliverySlices = parseDeliverySlices(
283
- readOptions(args, ['--delivery-slice']),
284
- )
285
- const outDir = readOption(args, ['-o', '--out-dir'])
286
- const formats = readOption(args, ['--formats'])
287
- const preset = readOption(args, ['--preset'])
288
- const basicText = readFlag(args, ['-b', '--basic-text'])
289
- const fontFamily = readOption(args, ['--font-family'])
290
- const fontPath = readOption(args, ['--font-path'])
291
- const variationCoordinates = parseVariations(
292
- readOptions(args, ['--variation']),
293
- )
294
- const subsetOptions = await subsetOptionsFromArgs(args)
295
- assertNoUnknownOptions(args)
296
-
297
- if (cache && noCache) {
298
- throw new Error('build accepts only one of --cache or --no-cache')
299
- }
300
-
301
- const cacheOverride = cacheOverrideFromFlags(cache, noCache)
302
-
303
- if (configPath !== undefined) {
304
- if (isIconfontPreset(preset)) {
305
- await buildIconfontConfigCommand(configPath, {
306
- fontFamily,
307
- fontPath,
308
- formats,
309
- inputs: [...args],
310
- cacheOverride,
311
- cssUnicodeRanges,
312
- deliverySlices,
313
- outDir,
314
- variationCoordinates,
315
- })
316
- return
317
- }
318
-
319
- await buildConfigCommand(configPath, {
320
- basicText,
321
- cacheOverride,
322
- cssGlyph,
323
- cssUnicodeRanges,
324
- deliverySlices,
325
- fontFamily,
326
- fontPath,
327
- formats,
328
- inputs: [...args],
329
- noOriginal,
330
- outDir,
331
- preset,
332
- subsetOptions,
333
- variationCoordinates,
334
- })
335
- return
336
- }
337
-
338
- if (args.length === 0) {
339
- const foundConfigPath = await findConfig()
340
-
341
- if (isIconfontPreset(preset)) {
342
- await buildIconfontConfigCommand(foundConfigPath, {
343
- fontFamily,
344
- fontPath,
345
- formats,
346
- cacheOverride,
347
- cssUnicodeRanges,
348
- deliverySlices,
349
- outDir,
350
- variationCoordinates,
351
- })
352
- return
353
- }
354
-
355
- await buildConfigCommand(foundConfigPath, {
356
- basicText,
357
- cacheOverride,
358
- cssGlyph,
359
- cssUnicodeRanges,
360
- deliverySlices,
361
- fontFamily,
362
- fontPath,
363
- formats,
364
- noOriginal,
365
- outDir,
366
- preset,
367
- subsetOptions,
368
- variationCoordinates,
369
- })
370
- return
371
- }
372
-
373
- const inputPatterns = [...args]
374
- const [input] = inputPatterns
375
-
376
- requireValue(input, 'build requires an input font')
377
- requireValue(outDir, 'build requires -o, --out-dir')
378
-
379
- if (isIconfontPreset(preset)) {
380
- if (formats !== undefined) {
381
- throw new Error('build accepts only one of --formats or --preset')
382
- }
383
-
384
- const inputs = await expandInputPaths(inputPatterns, process.cwd())
385
- const cacheOptions = normalizeCacheOptions(
386
- undefined,
387
- process.cwd(),
388
- cacheOverride,
389
- )
390
-
391
- await buildIconfontCommand(
392
- inputs,
393
- outDir,
394
- fontFamily,
395
- fontPath ?? './',
396
- [],
397
- {},
398
- cacheOptions,
399
- cssUnicodeRanges,
400
- )
401
- return
402
- }
403
-
404
- const inputs = await expandInputPaths(inputPatterns, process.cwd())
405
- const outputFormats = filterOriginalOutput(
406
- outputFormatsFromArgs(formats, preset),
407
- noOriginal,
408
- )
409
- const cacheOptions = normalizeCacheOptions(
410
- undefined,
411
- process.cwd(),
412
- cacheOverride,
413
- )
414
- if (outputFormats.length === 0) {
415
- throw new Error('build requires at least one non-original output format')
416
- }
417
-
418
- await mkdir(outDir, { recursive: true })
419
-
420
- for (const input of inputs) {
421
- await buildDirectInput(input, outDir, {
422
- basicText,
423
- cacheOptions,
424
- cssGlyph,
425
- cssUnicodeRanges,
426
- deliverySlices,
427
- fontFamily,
428
- fontPath,
429
- outputFormats,
430
- subsetOptions,
431
- variationCoordinates,
432
- })
433
- }
434
- }
435
-
436
- async function buildDirectInput(
437
- input,
438
- outDir,
439
- {
440
- basicText,
441
- cacheOptions,
442
- cssGlyph,
443
- cssUnicodeRanges,
444
- deliverySlices,
445
- fontFamily,
446
- fontPath,
447
- outputFormats,
448
- subsetOptions,
449
- variationCoordinates,
450
- },
451
- ) {
452
- const contents = await readFile(input)
453
- const ttfContents = convertFont(contents, 'ttf', { variationCoordinates })
454
- const baseName = basename(input, extname(input))
455
- const cacheKey = cacheOptions.enabled
456
- ? cacheKeyForBuildInput({
457
- contents,
458
- input,
459
- kind: 'direct',
460
- options: {
461
- basicText,
462
- cssGlyph,
463
- cssUnicodeRanges,
464
- deliverySlices,
465
- fontFamily,
466
- fontPath,
467
- outputFormats,
468
- subset: subsetOptions,
469
- variationCoordinates,
470
- },
471
- })
472
- : undefined
473
- const cachedOutputs =
474
- cacheKey === undefined
475
- ? undefined
476
- : await readCachedBuildOutputs(cacheOptions.dir, cacheKey)
477
-
478
- if (cachedOutputs !== undefined) {
479
- await writeBuildOutputs(outDir, cachedOutputs)
480
- return
481
- }
482
-
483
- const source =
484
- subsetOptions.text === undefined &&
485
- subsetOptions.unicodes.length === 0 &&
486
- !basicText
487
- ? ttfContents
488
- : subsetWithCoverage(ttfContents, {
489
- basicText,
490
- missingGlyphs: subsetOptions.missingGlyphs,
491
- text: subsetOptions.text,
492
- unicodes: subsetOptions.unicodes,
493
- })
494
- const cssGlyphs = cssGlyph ? cssGlyphsFromSubset(subsetOptions) : []
495
- const fontSources =
496
- deliverySlices.length === 0
497
- ? [{ baseName, contents: source }]
498
- : deliverySlices.map(slice => ({
499
- baseName: `${baseName}-${slice.name}`,
500
- contents: subsetTtf(source, {
501
- missingGlyphs: 'ignore',
502
- unicodeRanges: slice.unicodeRanges,
503
- }),
504
- unicodeRanges: slice.unicodeRanges,
505
- }))
506
- const cssSources = []
507
- const outputs = []
508
-
509
- for (const fontSource of fontSources) {
510
- for (const format of outputFormats.filter(format => format !== 'css')) {
511
- const fileName = `${fontSource.baseName}.${format}`
512
- const output = convertFont(fontSource.contents, format)
513
-
514
- outputs.push({ contents: output, fileName })
515
- cssSources.push({
516
- ...(cssGlyphs.length > 0 && { glyphs: cssGlyphs }),
517
- ...(fontSource.unicodeRanges !== undefined && {
518
- unicodeRanges: fontSource.unicodeRanges,
519
- }),
520
- fileName,
521
- format,
522
- })
523
- }
524
- }
525
-
526
- if (outputFormats.includes('css')) {
527
- if (cssSources.length === 0) {
528
- throw new Error('build CSS output requires at least one font format')
529
- }
530
-
531
- outputs.push({
532
- contents: Buffer.from(
533
- generateFontFaceCss(cssSources, {
534
- fontFamily: fontFamily ?? baseName,
535
- fontPath: fontPath ?? './',
536
- glyph: cssGlyph,
537
- unicodeRanges: cssUnicodeRanges,
538
- }),
539
- ),
540
- fileName: `${fontSources[0].baseName}.css`,
541
- })
542
- }
543
-
544
- await writeBuildOutputs(outDir, outputs)
545
-
546
- if (cacheKey !== undefined) {
547
- await writeCachedBuildOutputs(cacheOptions.dir, cacheKey, outputs)
548
- }
549
- }
550
-
551
- function reportShowTime(startedAt) {
552
- if (startedAt === undefined) {
553
- return
554
- }
555
-
556
- const elapsedMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000
557
-
558
- process.stdout.write(
559
- `fontmin-rs build completed in ${Math.round(elapsedMs)} ms\n`,
560
- )
561
- }
562
-
563
- async function buildIconfontCommand(
564
- inputs,
565
- outDir,
566
- fontFamily,
567
- fontPath,
568
- outputs = [],
569
- css = {},
570
- cacheOptions = normalizeCacheOptions(undefined, process.cwd()),
571
- cssUnicodeRanges = css.unicodeRanges ?? [],
572
- ) {
573
- if (inputs.length === 0) {
574
- throw new Error('build requires at least one input font')
575
- }
576
-
577
- const family = fontFamily ?? 'iconfont'
578
- const icons = await Promise.all(
579
- inputs.map(async input => ({
580
- contents: await readFile(input, 'utf8'),
581
- name: basename(input, extname(input)),
582
- })),
583
- )
584
- const cacheKey = cacheOptions.enabled
585
- ? cacheKeyForIconfontBuild({
586
- icons,
587
- inputs,
588
- options: {
589
- css,
590
- fontFamily,
591
- fontPath,
592
- outputs,
593
- },
594
- })
595
- : undefined
596
- const cachedOutputs =
597
- cacheKey === undefined
598
- ? undefined
599
- : await readCachedBuildOutputs(cacheOptions.dir, cacheKey)
600
-
601
- if (cachedOutputs !== undefined) {
602
- await writeBuildOutputs(outDir, cachedOutputs)
603
- return
604
- }
605
-
606
- const ttf = svgsToTtf(icons, {
607
- fontName: family,
608
- })
609
- const fileName = outputFileName(outputs, 'ttf', 'iconfont.ttf')
610
- const cssFileName = outputFileName(
611
- outputs,
612
- 'css',
613
- `${basename(fileName, extname(fileName))}.${css.target ?? 'css'}`,
614
- )
615
- const glyphs = icons.map((icon, index) => ({
616
- name: icon.name,
617
- unicode: 57_345 + index,
618
- }))
619
- const buildOutputs = [
620
- {
621
- contents: ttf,
622
- fileName,
623
- },
624
- {
625
- contents: Buffer.from(
626
- generateFontFaceCss(
627
- [
628
- {
629
- ...(css.base64 === true && { contents: ttf }),
630
- fileName,
631
- format: 'ttf',
632
- glyphs,
633
- },
634
- ],
635
- {
636
- asFileName: css.asFileName ?? true,
637
- base64: css.base64,
638
- fontDisplay: css.fontDisplay,
639
- fontFamily: family,
640
- fontPath,
641
- glyph: true,
642
- iconPrefix: css.iconPrefix,
643
- local: css.local,
644
- unicodeRanges: cssUnicodeRanges,
645
- },
646
- ),
647
- ),
648
- fileName: cssFileName,
649
- },
650
- ]
651
-
652
- await writeBuildOutputs(outDir, buildOutputs)
653
-
654
- if (cacheKey !== undefined) {
655
- await writeCachedBuildOutputs(cacheOptions.dir, cacheKey, buildOutputs)
656
- }
657
- }
658
-
659
- async function buildConfigCommand(
660
- configPath,
661
- {
662
- basicText,
663
- cacheOverride,
664
- cssGlyph,
665
- cssUnicodeRanges = [],
666
- deliverySlices = [],
667
- fontFamily,
668
- fontPath,
669
- formats,
670
- inputs: inputOverrides = [],
671
- noOriginal,
672
- outDir: outDirOverride,
673
- preset,
674
- subsetOptions,
675
- variationCoordinates = {},
676
- } = {},
677
- ) {
678
- const resolvedConfigPath = resolve(configPath)
679
- const config = await readConfig(resolvedConfigPath)
680
- const cwd =
681
- typeof config.cwd === 'string'
682
- ? resolve(config.cwd)
683
- : dirname(resolvedConfigPath)
684
- const inputs =
685
- inputOverrides.length > 0 ? inputOverrides : (config.input ?? [])
686
- const outDir = resolve(cwd, outDirOverride ?? config.outDir ?? 'build')
687
- const outputFormats = outputFormatsForConfig(config.outputs, {
688
- formats,
689
- noOriginal,
690
- preset,
691
- })
692
- const cacheOptions = normalizeCacheOptions(config.cache, cwd, cacheOverride)
693
- const resolvedVariationCoordinates = {
694
- ...config.otf?.variationCoordinates,
695
- ...variationCoordinates,
696
- }
697
-
698
- if (inputs.length === 0) {
699
- throw new Error('build config requires at least one input')
700
- }
701
-
702
- const inputPaths = await expandInputPaths(inputs, cwd)
703
-
704
- if (
705
- fontFamily !== undefined ||
706
- fontPath !== undefined ||
707
- cssGlyph === true ||
708
- cssUnicodeRanges.length > 0
709
- ) {
710
- config.css = {
711
- ...config.css,
712
- ...(cssGlyph === true && { glyph: true }),
713
- ...(cssUnicodeRanges.length > 0 && {
714
- unicodeRanges: cssUnicodeRanges,
715
- }),
716
- ...(fontFamily !== undefined && { fontFamily }),
717
- ...(fontPath !== undefined && { fontPath }),
718
- }
719
- }
720
- if (deliverySlices.length > 0) {
721
- config.delivery = { slices: deliverySlices }
722
- }
723
-
724
- applySubsetOverrides(config, { basicText, subsetOptions })
725
-
726
- if (config.clean === true) {
727
- await cleanOutputDirectory(cwd, outDir, [resolvedConfigPath, ...inputPaths])
728
- }
729
-
730
- await mkdir(outDir, { recursive: true })
731
-
732
- for (const inputPath of inputPaths) {
733
- await buildConfigInput(
734
- inputPath,
735
- outDir,
736
- outputFormats,
737
- config,
738
- cwd,
739
- cacheOptions,
740
- resolvedVariationCoordinates,
741
- )
742
- }
743
- }
744
-
745
- async function buildIconfontConfigCommand(
746
- configPath,
747
- {
748
- cacheOverride,
749
- cssUnicodeRanges = [],
750
- deliverySlices = [],
751
- fontFamily,
752
- fontPath,
753
- formats,
754
- inputs: inputOverrides = [],
755
- outDir: outDirOverride,
756
- },
757
- ) {
758
- if (formats !== undefined) {
759
- throw new Error('build accepts only one of --formats or --preset')
760
- }
761
-
762
- const resolvedConfigPath = resolve(configPath)
763
- const config = await readConfig(resolvedConfigPath)
764
- const cwd =
765
- typeof config.cwd === 'string'
766
- ? resolve(config.cwd)
767
- : dirname(resolvedConfigPath)
768
- const outDir = resolve(cwd, outDirOverride ?? config.outDir ?? 'build')
769
- const inputs = await expandConfigInputPaths(
770
- inputOverrides.length > 0 ? inputOverrides : (config.input ?? []),
771
- cwd,
772
- )
773
- const css = config.css ?? {}
774
- const cacheOptions = normalizeCacheOptions(config.cache, cwd, cacheOverride)
775
-
776
- if (cssUnicodeRanges.length > 0) {
777
- css.unicodeRanges = cssUnicodeRanges
778
- }
779
- if (deliverySlices.length > 0) {
780
- config.delivery = { slices: deliverySlices }
781
- }
782
-
783
- if (config.clean === true) {
784
- await cleanOutputDirectory(cwd, outDir, [resolvedConfigPath, ...inputs])
785
- }
786
-
787
- await buildIconfontCommand(
788
- inputs,
789
- outDir,
790
- fontFamily ?? css.fontFamily,
791
- fontPath ?? css.fontPath ?? './',
792
- config.outputs ?? [],
793
- css,
794
- cacheOptions,
795
- css.unicodeRanges,
796
- )
797
- }
798
-
799
- function expandConfigInputPaths(inputs, cwd) {
800
- return expandInputPaths(inputs, cwd)
801
- }
802
-
803
- async function expandInputPaths(inputs, cwd) {
804
- if (inputs.length === 0) {
805
- throw new Error('build config requires at least one input')
806
- }
807
-
808
- const paths = []
809
-
810
- for (const input of inputs) {
811
- if (typeof input !== 'string') {
812
- throw new TypeError('build config input entries must be file paths')
813
- }
814
-
815
- paths.push(...(await expandInputPath(input, cwd)))
816
- }
817
-
818
- return paths
819
- }
820
-
821
- function outputFileName(outputs, format, fallback) {
822
- const output = outputs.find(output => output?.format === format)
823
-
824
- if (typeof output?.fileName === 'string') {
825
- return output.fileName
826
- }
827
-
828
- if (typeof output?.ext === 'string') {
829
- return `${basename(fallback, extname(fallback))}.${normalizeExtension(output.ext)}`
830
- }
831
-
832
- return fallback
833
- }
834
-
835
- function applySubsetOverrides(config, { basicText, subsetOptions } = {}) {
836
- if (
837
- subsetOptions === undefined ||
838
- (subsetOptions.text === undefined &&
839
- subsetOptions.unicodes.length === 0 &&
840
- basicText !== true &&
841
- subsetOptions.missingGlyphs === undefined)
842
- ) {
843
- return
844
- }
845
-
846
- config.subset = {
847
- ...config.subset,
848
- ...(subsetOptions.text !== undefined && { text: subsetOptions.text }),
849
- ...(subsetOptions.unicodes.length > 0 && {
850
- unicodes: subsetOptions.unicodes,
851
- }),
852
- ...(basicText === true && { basicText }),
853
- ...(subsetOptions.missingGlyphs !== undefined && {
854
- missingGlyphs: subsetOptions.missingGlyphs,
855
- }),
856
- }
857
- }
858
-
859
- async function buildConfigInput(
860
- input,
861
- outDir,
862
- outputFormats,
863
- config,
864
- cwd,
865
- cacheOptions,
866
- variationCoordinates,
867
- ) {
868
- const contents = await readFile(input)
869
- const ttfContents = convertFont(contents, 'ttf', { variationCoordinates })
870
- const subset = await resolveSubsetTextFile(config.subset ?? {}, cwd)
871
- const unicodes = subset.unicodes ?? []
872
- const deliverySlices = normalizeDeliverySlices(config.delivery?.slices ?? [])
873
- const cacheKey = cacheOptions.enabled
874
- ? cacheKeyForBuildInput({
875
- contents,
876
- input,
877
- kind: 'config',
878
- options: {
879
- css: config.css,
880
- deliverySlices,
881
- outputFormats,
882
- outputs: config.outputs,
883
- subset,
884
- variationCoordinates,
885
- },
886
- })
887
- : undefined
888
- const cachedOutputs =
889
- cacheKey === undefined
890
- ? undefined
891
- : await readCachedBuildOutputs(cacheOptions.dir, cacheKey)
892
-
893
- if (cachedOutputs !== undefined) {
894
- await writeBuildOutputs(outDir, cachedOutputs)
895
- return
896
- }
897
-
898
- const source =
899
- subset.text === undefined &&
900
- unicodes.length === 0 &&
901
- subset.basicText !== true
902
- ? ttfContents
903
- : subsetWithCoverage(ttfContents, {
904
- basicText: subset.basicText,
905
- missingGlyphs: subset.missingGlyphs,
906
- text: subset.text,
907
- unicodes,
908
- })
909
- const baseName = basename(input, extname(input))
910
- const css = config.css ?? {}
911
- const cssGlyphs = css.glyph === true ? cssGlyphsFromSubset(subset) : []
912
- const fontSources =
913
- deliverySlices.length === 0
914
- ? [{ baseName, contents: source }]
915
- : deliverySlices.map(slice => ({
916
- baseName: `${baseName}-${slice.name}`,
917
- contents: subsetTtf(source, {
918
- missingGlyphs: 'ignore',
919
- unicodeRanges: slice.unicodeRanges,
920
- }),
921
- unicodeRanges: slice.unicodeRanges,
922
- }))
923
- const cssSources = []
924
- const outputs = config.outputs ?? []
925
- const buildOutputs = []
926
-
927
- for (const fontSource of fontSources) {
928
- for (const format of outputFormats.filter(format => format !== 'css')) {
929
- const fileName = outputFileName(
930
- outputs,
931
- format,
932
- `${fontSource.baseName}.${format}`,
933
- )
934
- const output = convertFont(fontSource.contents, format)
935
-
936
- buildOutputs.push({ contents: output, fileName })
937
- cssSources.push({
938
- ...(css.base64 === true && { contents: output }),
939
- ...(cssGlyphs.length > 0 && { glyphs: cssGlyphs }),
940
- ...(fontSource.unicodeRanges !== undefined && {
941
- unicodeRanges: fontSource.unicodeRanges,
942
- }),
943
- fileName,
944
- format,
945
- })
946
- }
947
- }
948
-
949
- if (outputFormats.includes('css')) {
950
- if (cssSources.length === 0) {
951
- throw new Error('build CSS output requires at least one font format')
952
- }
953
-
954
- const firstCssSource = cssSources[0]
955
- const cssFileName = outputFileName(
956
- outputs,
957
- 'css',
958
- `${basename(firstCssSource.fileName, extname(firstCssSource.fileName))}.${css.target ?? 'css'}`,
959
- )
960
-
961
- buildOutputs.push({
962
- contents: Buffer.from(
963
- generateFontFaceCss(cssSources, {
964
- asFileName: css.asFileName,
965
- base64: css.base64,
966
- fontDisplay: css.fontDisplay,
967
- fontFamily: css.fontFamily ?? baseName,
968
- fontPath: css.fontPath ?? './',
969
- glyph: css.glyph,
970
- iconPrefix: css.iconPrefix,
971
- local: css.local,
972
- unicodeRanges: css.unicodeRanges,
973
- }),
974
- ),
975
- fileName: cssFileName,
976
- })
977
- }
978
-
979
- await writeBuildOutputs(outDir, buildOutputs)
980
-
981
- if (cacheKey !== undefined) {
982
- await writeCachedBuildOutputs(cacheOptions.dir, cacheKey, buildOutputs)
983
- }
984
- }
985
-
986
- function cssGlyphsFromSubset(subset) {
987
- const seen = new Set()
988
- const glyphs = []
989
-
990
- for (const character of subset.text ?? '') {
991
- const unicode = character.codePointAt(0)
992
-
993
- if (unicode !== undefined && !seen.has(unicode)) {
994
- seen.add(unicode)
995
- glyphs.push({ unicode })
996
- }
997
- }
998
-
999
- for (const unicode of subset.unicodes ?? []) {
1000
- if (!seen.has(unicode)) {
1001
- seen.add(unicode)
1002
- glyphs.push({ unicode })
1003
- }
1004
- }
1005
-
1006
- return glyphs
1007
- }
1008
-
1009
- async function writeBuildOutputs(outDir, outputs) {
1010
- await mkdir(outDir, { recursive: true })
1011
-
1012
- for (const output of outputs) {
1013
- const outputPath = resolveContainedPath(
1014
- outDir,
1015
- output.fileName,
1016
- 'output file name',
1017
- )
1018
-
1019
- await mkdir(dirname(outputPath), { recursive: true })
1020
- await ensureRealPathContained(
1021
- outDir,
1022
- dirname(outputPath),
1023
- 'output file name',
1024
- )
1025
- await rejectSymbolicLink(outputPath)
1026
- await writeFile(outputPath, output.contents)
1027
- }
1028
- }
1029
-
1030
- async function readCachedBuildOutputs(cacheDir, key) {
1031
- let manifest = null
1032
-
1033
- try {
1034
- manifest = JSON.parse(
1035
- await readFile(cacheManifestPath(cacheDir, key), 'utf8'),
1036
- )
1037
- } catch {
1038
- return
1039
- }
1040
-
1041
- if (manifest.version !== CACHE_SCHEMA_VERSION || manifest.key !== key) {
1042
- return
1043
- }
1044
-
1045
- const entryDir = cacheEntryDir(cacheDir, key)
1046
- const outputs = []
1047
-
1048
- try {
1049
- for (const record of manifest.outputs) {
1050
- const cacheFile = resolveContainedPath(
1051
- entryDir,
1052
- record.cacheFileName,
1053
- 'cache file name',
1054
- )
1055
-
1056
- await ensureRealPathContained(entryDir, cacheFile, 'cache file name')
1057
- outputs.push({
1058
- contents: await readFile(cacheFile),
1059
- fileName: record.fileName,
1060
- })
1061
- }
1062
- } catch {
1063
- return
1064
- }
1065
-
1066
- return outputs
1067
- }
1068
-
1069
- async function writeCachedBuildOutputs(cacheDir, key, outputs) {
1070
- await withCacheLock(cacheRoot(cacheDir), async () => {
1071
- const entryDir = cacheEntryDir(cacheDir, key)
1072
- const records = []
1073
-
1074
- await mkdir(entryDir, { recursive: true })
1075
-
1076
- for (const [index, output] of outputs.entries()) {
1077
- const extension = extname(output.fileName).replace(/^\./u, '') || 'bin'
1078
- const cacheFileName = `${String(index).padStart(3, '0')}.${extension}`
1079
-
1080
- await atomicWriteFile(join(entryDir, cacheFileName), output.contents)
1081
- records.push({
1082
- cacheFileName,
1083
- fileName: output.fileName,
1084
- })
1085
- }
1086
-
1087
- await atomicWriteFile(
1088
- cacheManifestPath(cacheDir, key),
1089
- `${JSON.stringify(
1090
- {
1091
- key,
1092
- outputs: records,
1093
- version: CACHE_SCHEMA_VERSION,
1094
- },
1095
- undefined,
1096
- 2,
1097
- )}\n`,
1098
- )
1099
- await updateBuildCacheIndex(cacheDir, key, records)
1100
- })
1101
- }
1102
-
1103
- async function updateBuildCacheIndex(cacheDir, key, outputs) {
1104
- const indexPath = cacheIndexPath(cacheDir)
1105
- let index = {
1106
- entries: {},
1107
- version: CACHE_SCHEMA_VERSION,
1108
- }
1109
-
1110
- try {
1111
- index = JSON.parse(await readFile(indexPath, 'utf8'))
1112
- } catch {
1113
- // A missing or corrupted cache index can be rebuilt from the next writes.
1114
- }
1115
-
1116
- if (index.version !== CACHE_SCHEMA_VERSION) {
1117
- index = {
1118
- entries: {},
1119
- version: CACHE_SCHEMA_VERSION,
1120
- }
1121
- }
1122
-
1123
- index.entries[key] = {
1124
- outputs: outputs.map(output => output.fileName),
1125
- updatedAt: new Date().toISOString(),
1126
- }
1127
-
1128
- await mkdir(dirname(indexPath), { recursive: true })
1129
- await atomicWriteFile(indexPath, `${JSON.stringify(index, undefined, 2)}\n`)
1130
- }
1131
-
1132
- function normalizeCacheOptions(options, cwd, override) {
1133
- const configuredDir =
1134
- typeof options === 'object' &&
1135
- options !== null &&
1136
- typeof options.dir === 'string'
1137
- ? options.dir
1138
- : DEFAULT_CACHE_DIR
1139
-
1140
- if (override === true) {
1141
- return {
1142
- dir: resolve(cwd, configuredDir),
1143
- enabled: true,
1144
- }
1145
- }
1146
-
1147
- if (override === false || options === undefined || options === false) {
1148
- return {
1149
- dir: resolve(cwd, configuredDir),
1150
- enabled: false,
1151
- }
1152
- }
1153
-
1154
- if (options === true) {
1155
- return {
1156
- dir: resolve(cwd, DEFAULT_CACHE_DIR),
1157
- enabled: true,
1158
- }
1159
- }
1160
-
1161
- return {
1162
- dir: resolve(cwd, options.dir ?? DEFAULT_CACHE_DIR),
1163
- enabled: options.enabled ?? true,
1164
- }
1165
- }
1166
-
1167
- function cacheOverrideFromFlags(cache, noCache) {
1168
- if (cache) {
1169
- return true
1170
- }
1171
-
1172
- if (noCache) {
1173
- return false
1174
- }
1175
- }
1176
-
1177
- function cacheKeyForBuildInput({ contents, input, kind, options }) {
1178
- return sha256(
1179
- stableStringify({
1180
- fontminVersion: FONTMIN_VERSION,
1181
- input: {
1182
- hash: sha256(contents),
1183
- path: input,
1184
- },
1185
- kind,
1186
- options,
1187
- schema: CACHE_SCHEMA_VERSION,
1188
- }),
1189
- )
1190
- }
1191
-
1192
- function cacheKeyForIconfontBuild({ icons, inputs, options }) {
1193
- return sha256(
1194
- stableStringify({
1195
- fontminVersion: FONTMIN_VERSION,
1196
- icons: icons.map((icon, index) => ({
1197
- hash: sha256(icon.contents),
1198
- input: inputs[index],
1199
- name: icon.name,
1200
- })),
1201
- kind: 'iconfont',
1202
- options,
1203
- schema: CACHE_SCHEMA_VERSION,
1204
- }),
1205
- )
1206
- }
1207
-
1208
- function cacheRoot(cacheDir) {
1209
- return join(cacheDir, CACHE_SCHEMA_VERSION)
1210
- }
1211
-
1212
- function cacheIndexPath(cacheDir) {
1213
- return join(cacheRoot(cacheDir), 'index.json')
1214
- }
1215
-
1216
- function cacheEntryDir(cacheDir, key) {
1217
- return join(cacheRoot(cacheDir), key)
1218
- }
1219
-
1220
- function cacheManifestPath(cacheDir, key) {
1221
- return join(cacheEntryDir(cacheDir, key), 'index.json')
1222
- }
1223
-
1224
- function sha256(input) {
1225
- return createHash('sha256').update(input).digest('hex')
1226
- }
1227
-
1228
- function stableStringify(value) {
1229
- if (value === null || typeof value !== 'object') {
1230
- return JSON.stringify(value)
1231
- }
1232
-
1233
- if (Array.isArray(value)) {
1234
- return `[${value.map(item => stableStringify(item)).join(',')}]`
1235
- }
1236
-
1237
- const record = value
1238
- const entries = Object.keys(record)
1239
- .sort()
1240
- .map(key => {
1241
- return `${JSON.stringify(key)}:${stableStringify(record[key])}`
1242
- })
1243
-
1244
- return `{${entries.join(',')}}`
1245
- }
1246
-
1247
- async function inspectCommand(args) {
1248
- const json = readFlag(args, ['--json'])
1249
- assertNoUnexpectedArgs(args)
1250
- const [input] = args
1251
-
1252
- requireValue(input, 'inspect requires an input font')
1253
-
1254
- const contents = await readFile(input)
1255
- const info = inspectFont(contents)
1256
-
1257
- if (json) {
1258
- console.log(JSON.stringify(info, undefined, 2))
1259
- } else {
1260
- console.log(
1261
- `${input}: ${info.format}, ${info.size} bytes, ${info.metadata.glyphCount} glyphs`,
1262
- )
1263
- }
1264
- }
1265
-
1266
- async function initCommand() {
1267
- try {
1268
- await writeFile(INIT_CONFIG_FILE, DEFAULT_INIT_CONFIG, { flag: 'wx' })
1269
- } catch (error) {
1270
- if (hasErrorCode(error, 'EEXIST')) {
1271
- throw new Error(`${INIT_CONFIG_FILE} already exists`, { cause: error })
1272
- }
1273
-
1274
- throw error
1275
- }
1276
-
1277
- process.stdout.write(`created ${INIT_CONFIG_FILE}\n`)
1278
- }
1279
-
1280
- async function subsetOptionsFromArgs(args) {
1281
- const subset = await resolveSubsetTextFile(
1282
- {
1283
- text: readOption(args, ['-t', '--text']),
1284
- textFile: readOption(args, ['--text-file']),
1285
- unicodes: parseUnicodes(readOption(args, ['--unicodes'])),
1286
- },
1287
- process.cwd(),
1288
- )
1289
-
1290
- return {
1291
- ...subset,
1292
- missingGlyphs: parseMissingGlyphPolicy(
1293
- readOption(args, ['--missing-glyphs']),
1294
- ),
1295
- unicodes: subset.unicodes ?? [],
1296
- }
1297
- }
1298
-
1299
- function subsetWithCoverage(contents, options) {
1300
- const policy = options.missingGlyphs ?? 'warn'
1301
-
1302
- if (policy === 'warn') {
1303
- const report = analyzeCoverage(contents, options)
1304
- const warning = missingGlyphMessage(report)
1305
-
1306
- if (warning !== undefined && emitWarnings) {
1307
- process.stderr.write(`warning: ${warning}\n`)
1308
- }
1309
- }
1310
-
1311
- return subsetTtf(contents, {
1312
- ...options,
1313
- missingGlyphs: policy === 'warn' ? 'ignore' : policy,
1314
- })
1315
- }
1316
-
1317
- function missingGlyphMessage(report) {
1318
- if (report.missing.length === 0) {
1319
- return
1320
- }
1321
-
1322
- const visible = report.missing
1323
- .slice(0, 16)
1324
- .map(
1325
- codePoint => `U+${codePoint.toString(16).toUpperCase().padStart(4, '0')}`,
1326
- )
1327
- .join(', ')
1328
- const remaining = report.missing.length - 16
1329
-
1330
- return `missing glyphs for requested Unicode code points: ${visible}${remaining > 0 ? `, and ${remaining} more` : ''}`
1331
- }
1332
-
1333
- function parseMissingGlyphPolicy(value) {
1334
- if (value === undefined) {
1335
- return
1336
- }
1337
-
1338
- const policy = value.trim().toLowerCase()
1339
-
1340
- if (policy === 'ignore' || policy === 'warn' || policy === 'error') {
1341
- return policy
1342
- }
1343
-
1344
- throw new Error(
1345
- `missing glyph policy must be \`ignore\`, \`warn\`, or \`error\`: ${value}`,
1346
- )
1347
- }
1348
-
1349
- async function resolveSubsetTextFile(subset, cwd) {
1350
- if (subset.textFile === undefined) {
1351
- return subset
1352
- }
1353
-
1354
- const fileText = await readFile(resolve(cwd, subset.textFile), 'utf8')
1355
-
1356
- return {
1357
- ...subset,
1358
- text: subset.text === undefined ? fileText : `${subset.text}${fileText}`,
1359
- }
1360
- }
1361
-
1362
- async function readConfig(configPath) {
1363
- const contents = await readFile(configPath, 'utf8')
1364
- const extension = extname(configPath)
1365
-
1366
- if (extension === '.json') {
1367
- return JSON.parse(contents)
1368
- }
1369
-
1370
- if (extension === '.jsonc') {
1371
- return parseJsonc(contents)
1372
- }
1373
-
1374
- if (MODULE_CONFIG_EXTENSIONS.has(extension)) {
1375
- const configModule = await import(pathToFileURL(configPath).href)
1376
- const loadedConfig = configModule.default ?? configModule.config
1377
-
1378
- if (loadedConfig === undefined) {
1379
- throw new Error(`config file \`${configPath}\` does not export a config`)
1380
- }
1381
-
1382
- return typeof loadedConfig === 'function'
1383
- ? await loadedConfig()
1384
- : loadedConfig
1385
- }
1386
-
1387
- throw new Error(`unsupported config extension \`${extension}\``)
1388
- }
1389
-
1390
- async function findConfig(cwd = process.cwd()) {
1391
- for (const fileName of DEFAULT_CONFIG_FILES) {
1392
- const configPath = resolve(cwd, fileName)
1393
-
1394
- if (await isFile(configPath)) {
1395
- return configPath
1396
- }
1397
- }
1398
-
1399
- throw new Error(`could not find fontmin config in ${cwd}`)
1400
- }
1401
-
1402
- async function isFile(path) {
1403
- try {
1404
- const stats = await stat(path)
1405
- return stats.isFile()
1406
- } catch {
1407
- return false
1408
- }
1409
- }
1410
-
1411
- async function expandInputPath(input, cwd) {
1412
- if (!isGlobPattern(input)) {
1413
- return [resolve(cwd, input)]
1414
- }
1415
-
1416
- const matches = await glob(input, {
1417
- absolute: true,
1418
- cwd,
1419
- onlyFiles: true,
1420
- })
1421
-
1422
- if (matches.length === 0) {
1423
- throw new Error(`fontmin-rs input glob matched no files: ${input}`)
1424
- }
1425
-
1426
- return matches.sort((left, right) => left.localeCompare(right))
1427
- }
1428
-
1429
- function isGlobPattern(path) {
1430
- return /[*?[\]{}]/u.test(path)
1431
- }
1432
-
1433
- function hasErrorCode(error, code) {
1434
- return (
1435
- typeof error === 'object' &&
1436
- error !== null &&
1437
- 'code' in error &&
1438
- error.code === code
1439
- )
1440
- }
1441
-
1442
- async function cleanOutputDirectory(cwd, outDir, protectedPaths) {
1443
- const root = resolve(cwd)
1444
- const target = resolve(outDir)
1445
- const targetIsInsideRoot = pathContains(root, target) && target !== root
1446
- const targetContainsInput = protectedPaths.some(path =>
1447
- pathContains(target, resolve(path)),
1448
- )
1449
-
1450
- if (
1451
- target === parse(target).root ||
1452
- pathContains(target, root) ||
1453
- targetContainsInput
1454
- ) {
1455
- throw new Error(
1456
- `refusing to clean output directory ${target} because it is the project directory, an input ancestor, or a filesystem root`,
1457
- )
1458
- }
1459
-
1460
- try {
1461
- const [realRoot, realTarget, ...realProtectedPaths] = await Promise.all([
1462
- realpath(root),
1463
- realpath(target),
1464
- ...protectedPaths.map(path => realpath(resolve(path))),
1465
- ])
1466
-
1467
- if (
1468
- realTarget === parse(realTarget).root ||
1469
- pathContains(realTarget, realRoot) ||
1470
- realProtectedPaths.some(path => pathContains(realTarget, path)) ||
1471
- (targetIsInsideRoot && !pathContains(realRoot, realTarget))
1472
- ) {
1473
- throw new Error(
1474
- `refusing to clean output directory ${target} because its resolved location is unsafe for project ${root}`,
1475
- )
1476
- }
1477
- } catch (error) {
1478
- if (!hasErrorCode(error, 'ENOENT')) {
1479
- throw error
1480
- }
1481
- }
1482
-
1483
- await rm(target, { recursive: true, force: true })
1484
- }
1485
-
1486
- function pathContains(parent, candidate) {
1487
- const childPath = relative(parent, candidate)
1488
-
1489
- return (
1490
- childPath === '' ||
1491
- (childPath !== '..' &&
1492
- !childPath.startsWith(`..${sep}`) &&
1493
- !isAbsolute(childPath))
1494
- )
1495
- }
1496
-
1497
- function resolveContainedPath(root, path, label) {
1498
- if (typeof path !== 'string' || path.length === 0 || isAbsolute(path)) {
1499
- throw new Error(`${label} must be a non-empty relative path: ${path}`)
1500
- }
1501
-
1502
- const resolvedRoot = resolve(root)
1503
- const resolvedPath = resolve(resolvedRoot, path)
1504
- const relativePath = relative(resolvedRoot, resolvedPath)
1505
-
1506
- if (
1507
- relativePath === '' ||
1508
- relativePath === '..' ||
1509
- relativePath.startsWith(`..${sep}`) ||
1510
- isAbsolute(relativePath)
1511
- ) {
1512
- throw new Error(
1513
- `${label} must stay within its destination directory: ${path}`,
1514
- )
1515
- }
1516
-
1517
- return resolvedPath
1518
- }
1519
-
1520
- async function ensureRealPathContained(root, path, label) {
1521
- const [realRoot, realPath] = await Promise.all([
1522
- realpath(root),
1523
- realpath(path),
1524
- ])
1525
- const relativePath = relative(realRoot, realPath)
1526
-
1527
- if (
1528
- relativePath === '..' ||
1529
- relativePath.startsWith(`..${sep}`) ||
1530
- isAbsolute(relativePath)
1531
- ) {
1532
- throw new Error(`${label} resolves outside its destination directory`)
1533
- }
1534
- }
1535
-
1536
- async function rejectSymbolicLink(path) {
1537
- try {
1538
- const metadata = await lstat(path)
1539
-
1540
- if (metadata.isSymbolicLink()) {
1541
- throw new Error(`refusing to write output through symbolic link: ${path}`)
1542
- }
1543
- } catch (error) {
1544
- if (!hasErrorCode(error, 'ENOENT')) {
1545
- throw error
1546
- }
1547
- }
1548
- }
1549
-
1550
- function normalizeExtension(extension) {
1551
- const normalized = extension.replace(/^\.+/u, '')
1552
-
1553
- if (
1554
- normalized.length === 0 ||
1555
- normalized === '..' ||
1556
- normalized.includes('/') ||
1557
- normalized.includes('\\')
1558
- ) {
1559
- throw new Error(`output extension must be a file extension: ${extension}`)
1560
- }
1561
-
1562
- return normalized
1563
- }
1564
-
1565
- async function atomicWriteFile(path, contents) {
1566
- const temporaryPath = `${path}.${process.pid}.${temporaryFileCounter}.tmp`
1567
- temporaryFileCounter += 1
1568
-
1569
- try {
1570
- await writeFile(temporaryPath, contents)
1571
- await rename(temporaryPath, path)
1572
- } finally {
1573
- await rm(temporaryPath, { force: true })
1574
- }
1575
- }
1576
-
1577
- function outputFormatsFromConfig(outputs) {
1578
- if (outputs === undefined) {
1579
- return ['eot', 'woff', 'woff2', 'svg', 'css']
1580
- }
1581
-
1582
- const formats = outputs.map(output =>
1583
- typeof output === 'string' ? output : output.format,
1584
- )
1585
-
1586
- return parseFormats(formats.join(','))
1587
- }
1588
-
1589
- function outputFormatsForConfig(outputs, { formats, noOriginal, preset }) {
1590
- const outputFormats =
1591
- formats === undefined && preset === undefined
1592
- ? outputFormatsFromConfig(outputs)
1593
- : outputFormatsFromArgs(formats, preset)
1594
- const filtered = filterOriginalOutput(outputFormats, noOriginal)
1595
-
1596
- if (filtered.length === 0) {
1597
- throw new Error('build requires at least one non-original output format')
1598
- }
1599
-
1600
- return filtered
1601
- }
1602
-
1603
- function outputFormatsFromArgs(formats, preset) {
1604
- if (formats !== undefined && preset !== undefined) {
1605
- throw new Error('build accepts only one of --formats or --preset')
1606
- }
1607
-
1608
- if (formats !== undefined) {
1609
- return parseFormats(formats)
1610
- }
1611
-
1612
- if (preset !== undefined) {
1613
- return outputFormatsFromPreset(preset)
1614
- }
1615
-
1616
- throw new Error('build requires --formats or --preset')
1617
- }
1618
-
1619
- function outputFormatsFromPreset(value) {
1620
- const preset = value.trim().toLowerCase()
1621
-
1622
- if (preset === 'compat') {
1623
- return ['eot', 'svg', 'woff', 'woff2', 'css']
1624
- }
1625
-
1626
- if (preset === 'modern-web') {
1627
- return ['woff2', 'woff', 'css']
1628
- }
1629
-
1630
- if (preset === 'iconfont') {
1631
- return ['ttf', 'css']
1632
- }
1633
-
1634
- throw new Error(`unsupported preset \`${preset}\``)
1635
- }
1636
-
1637
- function isIconfontPreset(value) {
1638
- return value?.trim().toLowerCase() === 'iconfont'
1639
- }
1640
-
1641
- function filterOriginalOutput(formats, noOriginal) {
1642
- return noOriginal ? formats.filter(format => format !== 'ttf') : formats
1643
- }
1644
-
1645
- function parseUnicodeRanges(values) {
1646
- return values.map(value => parseUnicodeRange(value))
1647
- }
1648
-
1649
- function formatUnicodeEndpoint(codePoint) {
1650
- return codePoint.toString(16).toUpperCase().padStart(4, '0')
1651
- }
1652
-
1653
- function parseUnicodeRange(value) {
1654
- const match =
1655
- /^u\+(?<start>[0-9a-f]{1,6})(?:-(?<end>[0-9a-f]{1,6}))?$/iu.exec(value)
1656
-
1657
- if (match?.groups === undefined) {
1658
- throw new Error(`invalid Unicode range: ${value}`)
1659
- }
1660
-
1661
- const start = Number.parseInt(match.groups.start, 16)
1662
- const end = Number.parseInt(match.groups.end ?? match.groups.start, 16)
1663
-
1664
- if (start > end || end > 0x10_ff_ff || (start <= 0xdf_ff && end >= 0xd8_00)) {
1665
- throw new Error(`invalid Unicode range: ${value}`)
1666
- }
1667
-
1668
- return start === end
1669
- ? `U+${formatUnicodeEndpoint(start)}`
1670
- : `U+${formatUnicodeEndpoint(start)}-${formatUnicodeEndpoint(end)}`
1671
- }
1672
-
1673
- function parseDeliverySlices(values) {
1674
- const slices = []
1675
-
1676
- for (const value of values) {
1677
- const separator = value.indexOf(':')
1678
-
1679
- if (separator === -1) {
1680
- throw new Error(`delivery slice must use NAME:RANGE[,RANGE...]: ${value}`)
1681
- }
1682
-
1683
- const name = value.slice(0, separator)
1684
- const unicodeRanges = value.slice(separator + 1).split(',')
1685
- const existing = slices.find(slice => slice.name === name)
1686
-
1687
- if (existing === undefined) {
1688
- slices.push({ name, unicodeRanges })
1689
- } else {
1690
- existing.unicodeRanges.push(...unicodeRanges)
1691
- }
1692
- }
1693
-
1694
- return normalizeDeliverySlices(slices)
1695
- }
1696
-
1697
- function normalizeDeliverySlices(values) {
1698
- if (!Array.isArray(values)) {
1699
- throw new TypeError('delivery slices must be an array')
1700
- }
1701
-
1702
- const names = new Set()
1703
-
1704
- return values.map((value, index) => {
1705
- if (typeof value !== 'object' || value === null || Array.isArray(value)) {
1706
- throw new Error(`delivery slice ${index + 1} must be an object`)
1707
- }
1708
-
1709
- const { name, unicodeRanges } = value
1710
-
1711
- if (
1712
- typeof name !== 'string' ||
1713
- name.length === 0 ||
1714
- !/^[A-Za-z0-9_-]+$/u.test(name)
1715
- ) {
1716
- throw new Error(`invalid delivery slice name: ${name}`)
1717
- }
1718
- if (names.has(name)) {
1719
- throw new Error(`duplicate delivery slice name: ${name}`)
1720
- }
1721
- if (!Array.isArray(unicodeRanges) || unicodeRanges.length === 0) {
1722
- throw new Error(
1723
- `delivery slice \`${name}\` requires at least one Unicode range`,
1724
- )
1725
- }
1726
-
1727
- names.add(name)
1728
-
1729
- return {
1730
- name,
1731
- unicodeRanges: parseUnicodeRanges(unicodeRanges),
1732
- }
1733
- })
1734
- }
1735
-
1736
- function parseUnicodes(value) {
1737
- if (value === undefined) {
1738
- return []
1739
- }
1740
-
1741
- const unicodes = value.split(',').map(item => parseUnicodeCodePoint(item))
1742
-
1743
- if (unicodes.length === 0) {
1744
- throw new Error('expected at least one unicode code point')
1745
- }
1746
-
1747
- return unicodes
1748
- }
1749
-
1750
- function parseUnicodeCodePoint(value) {
1751
- const item = value.trim()
1752
-
1753
- if (item.length === 0) {
1754
- throw new Error('empty unicode code point in --unicodes')
1755
- }
1756
-
1757
- let digits = item
1758
- let radix = 10
1759
-
1760
- if (/^(?:0x|u\+)/iu.test(item)) {
1761
- digits = item.slice(2)
1762
- radix = 16
1763
- }
1764
-
1765
- const validDigits = radix === 16 ? /^[0-9a-f]+$/iu : /^[0-9]+$/u
1766
-
1767
- if (!validDigits.test(digits)) {
1768
- throw new Error(`invalid unicode code point \`${item}\``)
1769
- }
1770
-
1771
- const codePoint = Number.parseInt(digits, radix)
1772
-
1773
- if (!Number.isInteger(codePoint) || codePoint > 4_294_967_295) {
1774
- throw new Error(`invalid unicode code point \`${item}\``)
1775
- }
1776
-
1777
- return codePoint
1778
- }
1779
-
1780
- function parseFormats(value) {
1781
- const formats = value
1782
- .split(',')
1783
- .map(format => format.trim().toLowerCase())
1784
- .filter(format => format.length > 0)
1785
-
1786
- if (formats.length === 0) {
1787
- throw new Error('expected at least one output format')
1788
- }
1789
-
1790
- for (const format of formats) {
1791
- if (
1792
- format !== 'ttf' &&
1793
- format !== 'woff' &&
1794
- format !== 'woff2' &&
1795
- format !== 'eot' &&
1796
- format !== 'svg' &&
1797
- format !== 'css'
1798
- ) {
1799
- throw new Error(`unsupported output format \`${format}\``)
1800
- }
1801
- }
1802
-
1803
- return formats
1804
- }
1805
-
1806
- function convertFont(contents, format, options = {}) {
1807
- const normalized = format.toLowerCase()
1808
-
1809
- if (normalized === 'ttf') {
1810
- if (isTtf(contents)) {
1811
- return contents
1812
- }
1813
-
1814
- if (isWoff(contents)) {
1815
- return woffToTtf(contents)
1816
- }
1817
-
1818
- if (isWoff2(contents)) {
1819
- return woff2ToTtf(contents)
1820
- }
1821
-
1822
- if (isEot(contents)) {
1823
- return eotToTtf(contents)
1824
- }
1825
-
1826
- if (isOtf(contents)) {
1827
- return otfToTtf(contents, {
1828
- variationCoordinates: options.variationCoordinates ?? {},
1829
- })
1830
- }
1831
-
1832
- throw new Error('unsupported input format for TTF conversion')
1833
- }
1834
-
1835
- const ttf = convertFont(contents, 'ttf', options)
1836
-
1837
- if (normalized === 'woff') {
1838
- return ttfToWoff(ttf)
1839
- }
1840
-
1841
- if (normalized === 'woff2') {
1842
- return ttfToWoff2(ttf)
1843
- }
1844
-
1845
- if (normalized === 'eot') {
1846
- return ttfToEot(ttf)
1847
- }
1848
-
1849
- if (normalized === 'svg') {
1850
- return ttfToSvg(ttf)
1851
- }
1852
-
1853
- throw new Error(`unsupported output format \`${format}\``)
1854
- }
1855
-
1856
- function isTtf(contents) {
1857
- return (
1858
- (contents[0] === 0x00 &&
1859
- contents[1] === 0x01 &&
1860
- contents[2] === 0x00 &&
1861
- contents[3] === 0x00) ||
1862
- contents.subarray(0, 4).toString('ascii') === 'true'
1863
- )
1864
- }
1865
-
1866
- function isOtf(contents) {
1867
- return contents.subarray(0, 4).toString('ascii') === 'OTTO'
1868
- }
1869
-
1870
- function isWoff(contents) {
1871
- return contents.subarray(0, 4).toString('ascii') === 'wOFF'
1872
- }
1873
-
1874
- function isWoff2(contents) {
1875
- return contents.subarray(0, 4).toString('ascii') === 'wOF2'
1876
- }
1877
-
1878
- function isEot(contents) {
1879
- return (
1880
- contents.byteLength >= 12 &&
1881
- ((contents[8] === 0x01 &&
1882
- contents[9] === 0x00 &&
1883
- contents[10] === 0x02 &&
1884
- contents[11] === 0x00) ||
1885
- (contents[8] === 0x02 &&
1886
- contents[9] === 0x00 &&
1887
- contents[10] === 0x02 &&
1888
- contents[11] === 0x00))
1889
- )
1890
- }
1891
-
1892
- function readOption(args, names) {
1893
- const index = args.findIndex(arg => names.includes(arg))
1894
-
1895
- if (index === -1) {
1896
- return
1897
- }
1898
-
1899
- const [name] = args.splice(index, 1)
1900
- const [value] = args.splice(index, 1)
1901
-
1902
- if (value === undefined || value.startsWith('-')) {
1903
- throw new Error(`${name} requires a value`)
1904
- }
1905
-
1906
- return value
1907
- }
1908
-
1909
- function readOptions(args, names) {
1910
- const values = []
1911
-
1912
- while (args.some(arg => names.includes(arg))) {
1913
- values.push(readOption(args, names))
1914
- }
1915
-
1916
- return values
1917
- }
1918
-
1919
- function readFlag(args, names) {
1920
- const index = args.findIndex(arg => names.includes(arg))
1921
-
1922
- if (index === -1) {
1923
- return false
1924
- }
1925
-
1926
- args.splice(index, 1)
1927
- return true
1928
- }
1929
-
1930
- function assertNoUnknownOptions(args) {
1931
- const option = args.find(arg => arg.startsWith('-'))
1932
-
1933
- if (option !== undefined) {
1934
- throw new Error(`unknown option \`${option}\``)
1935
- }
1936
- }
1937
-
1938
- function assertNoUnexpectedArgs(args, maximumPositionals = 1) {
1939
- assertNoUnknownOptions(args)
1940
-
1941
- if (args.length > maximumPositionals) {
1942
- throw new Error(`unexpected argument \`${args[maximumPositionals]}\``)
1943
- }
1944
- }
1945
-
1946
- function parseVariations(values) {
1947
- const coordinates = {}
1948
-
1949
- for (const value of values) {
1950
- const separator = value.indexOf('=')
1951
-
1952
- if (separator === -1) {
1953
- throw new Error(`invalid variation \`${value}\`; expected TAG=VALUE`)
1954
- }
1955
-
1956
- const tag = value.slice(0, separator)
1957
- const rawNumber = value.slice(separator + 1)
1958
-
1959
- if (
1960
- tag.length !== 4 ||
1961
- [...tag].some(character => (character.codePointAt(0) ?? 128) > 127)
1962
- ) {
1963
- throw new Error(
1964
- `invalid variation axis \`${tag}\`; expected four ASCII characters`,
1965
- )
1966
- }
1967
- if (Object.hasOwn(coordinates, tag)) {
1968
- throw new Error(`duplicate variation axis \`${tag}\``)
1969
- }
1970
-
1971
- const number = Number(rawNumber)
1972
-
1973
- if (rawNumber.length === 0 || !Number.isFinite(number)) {
1974
- throw new Error(
1975
- `invalid variation value \`${rawNumber}\` for axis \`${tag}\``,
1976
- )
1977
- }
1978
-
1979
- coordinates[tag] = number
1980
- }
1981
-
1982
- return coordinates
1983
- }
1984
-
1985
- function requireValue(value, message) {
1986
- if (value === undefined || value.length === 0) {
1987
- throw new Error(message)
1988
- }
1989
- }
1990
-
1991
- async function writeOutput(output, contents) {
1992
- await mkdir(dirname(output), { recursive: true })
1993
- await writeFile(output, contents)
1994
- }
1995
-
1996
- function usage(stream) {
1997
- stream.write(`Usage:
1998
- fontmin-rs subset <INPUT> -o|--output <OUTPUT> (-t|--text <TEXT> | --text-file <FILE> | --unicodes <LIST> | -b|--basic-text) [--missing-glyphs <ignore|warn|error>]
1999
- fontmin-rs coverage <INPUT> (-t|--text <TEXT> | --text-file <FILE> | --unicodes <LIST> | -b|--basic-text) [--json]
2000
- fontmin-rs convert <INPUT> -f|--format <ttf|woff|woff2|eot|svg> -o|--output <OUTPUT> [--variation <TAG=VALUE>]...
2001
- fontmin-rs build <INPUT...> [-c|--config <CONFIG>] [-o|--out-dir <OUT_DIR>] [-t|--text <TEXT>] [--text-file <FILE>] [--unicodes <LIST>] [-b|--basic-text] [--missing-glyphs <ignore|warn|error>] [-d|--deflate-woff] [-T|--show-time] [--silent] [--cache] [--no-cache] [--css-glyph] [--css-unicode-range <RANGE>]... [--delivery-slice <NAME:RANGE[,RANGE...]>]... [--variation <TAG=VALUE>]... [--formats <FORMATS>] [--preset <compat|modern-web|iconfont>] [--no-original] [--font-family <FONT_FAMILY>] [--font-path <FONT_PATH>]
2002
- fontmin-rs bench <INPUT> [-t|--text <TEXT>] [--text-file <FILE>] [--unicodes <LIST>] [-b|--basic-text] [--json]
2003
- fontmin-rs inspect <INPUT> [--json]
2004
- fontmin-rs init
2005
- fontmin-rs doctor
2006
- `)
2007
- }
5
+ await runCli()