-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbuild.py
More file actions
739 lines (665 loc) · 29.1 KB
/
Copy pathbuild.py
File metadata and controls
739 lines (665 loc) · 29.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
#!/usr/bin/env python3
"""
SarasaMonoTC-Emoji Font Builder
Merges NotoColorEmoji (CBDT/CBLC color bitmap) into Sarasa Mono TC,
producing a monospace font with embedded color emoji support.
Background:
No existing open-source project provides this combination.
thedemons/merge_color_emoji_font (the only public reference) uses
FontLab GUI. This is a Python/fonttools automated implementation.
Usage:
uv run python build.py
uv run python build.py --styles Regular
uv run python build.py --styles Regular,Bold --parallel 2
"""
import argparse
import json
import sys
import time
from concurrent.futures import ProcessPoolExecutor, as_completed
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict
import yaml
sys.path.insert(0, str(Path(__file__).parent))
from src.config import FontConfig
from src.emoji_merge import (
merge_emoji,
merge_emoji_lite,
merge_emoji_lite_nerd,
merge_emoji_colrv1,
detect_font_widths,
_strip_mac_name_records,
)
from src.utils import update_font_names, verify_glyph_width
def load_config(config_path: Path) -> Dict[str, Any]:
if not config_path.exists():
return {}
with open(config_path, "r", encoding="utf-8") as f:
return yaml.safe_load(f) or {}
def get_config_value(yaml_config: Dict[str, Any], *keys: str, default: Any = None) -> Any:
value = yaml_config
for key in keys:
if isinstance(value, dict):
value = value.get(key)
else:
return default
if value is None:
return default
return value
def get_config_int(
yaml_config: Dict[str, Any],
*keys: str,
default: int,
min_val: int | None = None,
max_val: int | None = None,
) -> int:
"""Like get_config_value but validates the result is an integer.
Raises SystemExit with a friendly message if the value is missing the
required type or is outside the allowed range — catches config.yaml typos
before they produce cryptic errors deep in the build pipeline.
"""
value = get_config_value(yaml_config, *keys, default=default)
key_path = ".".join(keys)
if not isinstance(value, int):
print(
f"Error: config.yaml '{key_path}' must be an integer, "
f"got {value!r} ({type(value).__name__})"
)
sys.exit(1)
if min_val is not None and value < min_val:
print(f"Error: config.yaml '{key_path}' must be >= {min_val}, got {value}")
sys.exit(1)
if max_val is not None and value > max_val:
print(f"Error: config.yaml '{key_path}' must be <= {max_val}, got {value}")
sys.exit(1)
return value
def get_config_int_ranges(
yaml_config: Dict[str, Any],
*keys: str,
) -> list[tuple[int, int]]:
"""Load a config list of [start, end] integer pairs."""
value = get_config_value(yaml_config, *keys, default=[])
key_path = ".".join(keys)
if not isinstance(value, list):
print(
f"Error: config.yaml '{key_path}' must be a list of [start, end] integer pairs, "
f"got {value!r} ({type(value).__name__})"
)
sys.exit(1)
ranges: list[tuple[int, int]] = []
for index, item in enumerate(value):
if not isinstance(item, (list, tuple)) or len(item) != 2:
print(
f"Error: config.yaml '{key_path}[{index}]' must be a [start, end] pair, "
f"got {item!r}"
)
sys.exit(1)
start, end = item
if not isinstance(start, int) or not isinstance(end, int):
print(
f"Error: config.yaml '{key_path}[{index}]' values must be integers, "
f"got {item!r}"
)
sys.exit(1)
if start > end:
print(
f"Error: config.yaml '{key_path}[{index}]' start must be <= end, "
f"got {item!r}"
)
sys.exit(1)
ranges.append((start, end))
return ranges
def _cleanup_partial_outputs(
output_dir: Path, family_name_compact: str, styles: list
) -> None:
"""Remove output files for all targeted styles.
Called after a failed parallel build to avoid leaving a mix of new and
old font files in the output directory (inconsistent state).
"""
removed = 0
for style in styles:
path = output_dir / f"{family_name_compact}-{style}.ttf"
if path.exists():
path.unlink()
print(f" Removed: {path.name}")
removed += 1
print(f" Cleaned up {removed} partial output file(s)")
def _parse_codepoint_sequence(value: str) -> tuple[int, ...]:
"""Parse a config sequence like 'U+1F469 U+200D U+1F4BB'."""
parts = [part for part in value.split() if part]
return tuple(int(part.replace("U+", "").replace("u+", ""), 16) for part in parts)
def _dedupe_codepoint_sequences(
sequences: list[tuple[int, ...]],
) -> list[tuple[int, ...]]:
"""Preserve config order while removing duplicate sequences."""
seen: set[tuple[int, ...]] = set()
ordered: list[tuple[int, ...]] = []
for sequence in sequences:
if sequence in seen:
continue
seen.add(sequence)
ordered.append(sequence)
return ordered
def find_font(fonts_dir: Path, filename: str) -> Path | None:
"""Locate a font file in fonts_dir.
Checks fonts_dir/<filename> first (explicit placement), then searches
subdirectories recursively so users can drop unzipped archives directly
into fonts/ without manually extracting individual TTF files.
Returns the first match, or None if not found.
"""
direct = fonts_dir / filename
if direct.exists():
return direct
matches = sorted(fonts_dir.glob(f"**/{filename}"))
return matches[0] if matches else None
def build_single_font(
style: str,
base_font_path: Path,
emoji_font_path: Path,
display_name: str,
output_dir: Path,
config: FontConfig,
metadata: dict,
lite: bool = False,
colrv1: bool = False,
max_new_glyphs: int | None = None,
priority_codepoints: set[int] | None = None,
priority_sequences: list[tuple[int, ...]] | None = None,
force_codepoints: set[int] | None = None,
nerd_lite: bool = False,
nerd_font_path: Path | None = None,
icon_ranges: list[tuple[int, int]] | None = None,
single_column_ranges: list[tuple[int, int]] | None = None,
) -> tuple[str, list[dict]]:
"""Build a single font variant with emoji merged in.
Args:
style: Font style key (e.g. Regular, Bold)
base_font_path: Path to SarasaMonoTC-{Style}.ttf
emoji_font_path: Path to emoji source font
display_name: Human-readable style name for name table
output_dir: Output directory
config: FontConfig object
metadata: Font metadata dict
lite: If True, use glyf-based monochrome merge (Lite variant)
colrv1: If True, use COLRv1 vector merge
max_new_glyphs: COLRv1 only — glyph budget for greedy selection
priority_codepoints: COLRv1 only — codepoints always included before greedy fill
priority_sequences: COLRv1 only — sequence codepoint tuples to include
before the remaining-budget greedy fill for sequences
force_codepoints: BMP codepoints forced to color even when skip_existing would
otherwise preserve Sarasa's monochrome glyph (COLRv1: via stub rename;
Color CBDT: via color bitmap rename)
Returns:
(output_path, selection_records) where selection_records contains
per-emoji metadata when COLRv1 greedy selection is active, else [].
"""
style_start = time.monotonic()
print(f"\nBuilding {config.family_name_compact}-{style}...")
# Merge emoji into base font
selection_records: list[dict] = []
if nerd_lite:
if nerd_font_path is None or icon_ranges is None:
raise ValueError("nerd_lite build requires nerd_font_path and icon_ranges")
merged_font = merge_emoji_lite_nerd(
base_font_path=str(base_font_path),
emoji_font_path=str(emoji_font_path),
nerd_font_path=str(nerd_font_path),
config=config,
icon_ranges=icon_ranges,
single_column_ranges=single_column_ranges or None,
force_codepoints=force_codepoints,
)
elif lite:
merged_font = merge_emoji_lite(
base_font_path=str(base_font_path),
emoji_font_path=str(emoji_font_path),
config=config,
force_codepoints=force_codepoints,
)
elif colrv1:
merged_font, selection_records = merge_emoji_colrv1(
base_font_path=str(base_font_path),
emoji_font_path=str(emoji_font_path),
config=config,
max_new_glyphs=max_new_glyphs,
priority_codepoints=priority_codepoints,
priority_sequences=priority_sequences,
force_codepoints=force_codepoints,
)
else:
merged_font = merge_emoji(
base_font_path=str(base_font_path),
emoji_font_path=str(emoji_font_path),
config=config,
force_codepoints=force_codepoints,
)
# Update font metadata
postscript_name = f"{config.family_name_compact}-{style}"
print(" Updating font metadata...")
update_font_names(
font=merged_font,
family_name=config.family_name,
style_name=display_name,
full_name=f"{config.family_name} {display_name}",
postscript_name=postscript_name,
version_str=f"Version {config.version}",
author=metadata.get("author", ""),
copyright_str=metadata.get("copyright", ""),
description=metadata.get("description", ""),
url=metadata.get("url", ""),
license_desc=metadata.get("license", ""),
license_url=metadata.get("license_url", ""),
)
# update_font_names() re-introduces Mac platform (platformID=1) name records
# via set_font_name(..., mac=True). Strip them again so the final font only
# contains Windows Unicode records (platformID=3), which all modern systems use.
_strip_mac_name_records(merged_font)
# Verify glyph widths (detect from font at runtime)
print(" Verifying glyph widths...")
from fontTools.ttLib import TTFont as _TTFont
_base = _TTFont(str(base_font_path))
half_w, full_w = detect_font_widths(_base)
_base.close()
emoji_w = half_w * config.emoji_width_multiplier
try:
verify_glyph_width(
font=merged_font,
expected_widths=[0, half_w, full_w, emoji_w],
file_name=postscript_name,
)
except ValueError as e:
print(f" Warning: {e}")
# Save
output_path = output_dir / f"{postscript_name}.ttf"
merged_font.save(str(output_path))
merged_font.close()
elapsed = time.monotonic() - style_start
print(f" Saved: {output_path} ({elapsed:.1f}s)")
return str(output_path), selection_records
def _write_emoji_list(
records: list[dict],
output_path: Path,
version: str,
max_new_glyphs: int,
) -> None:
"""Write the COLRv1 greedy-selected emoji list as JSON.
The file is committed to the repository so the selection can be reviewed
without rebuilding. All four styles share the same emoji font, so one
style's selection_records is sufficient.
Args:
records: List of per-emoji dicts from _select_colrv1_emoji_greedy.
output_path: Destination JSON file path (parent dir created if needed).
version: Font version string for provenance.
max_new_glyphs: Budget that was used during selection.
"""
output_path.parent.mkdir(parents=True, exist_ok=True)
total_cost = sum(r["new_glyph_cost"] for r in records)
data = {
"generated": datetime.now(timezone.utc).isoformat(),
"version": version,
"max_new_glyphs": max_new_glyphs,
"selected_count": len(records),
"total_glyph_cost": total_cost,
"emoji": records,
}
output_path.write_text(
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
)
print(f"\nEmoji list written: {output_path} ({len(records)} emoji, cost: {total_cost})")
def main():
default_config_path = Path(__file__).parent / "config.yaml"
parser = argparse.ArgumentParser(
description="Build SarasaMonoTC-Emoji font",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
uv run python build.py
uv run python build.py --styles Regular
uv run python build.py --styles Regular,Bold --parallel 2
Configuration priority: CLI args > config.yaml > defaults
""",
)
parser.add_argument("--config", type=Path, default=default_config_path)
parser.add_argument("--styles", type=str, default=None,
help="Comma-separated styles to build")
parser.add_argument("--fonts-dir", type=Path, default=None)
parser.add_argument("--output-dir", type=Path, default=None)
parser.add_argument("--parallel", type=int, default=None)
parser.add_argument("--lite", action="store_true",
help="Build Lite variant: monochrome glyf emoji (VHS-compatible, smaller files). "
"Requires NotoEmoji[wght].ttf in fonts/ directory.")
parser.add_argument("--colrv1", action="store_true",
help="Build COLRv1 variant: color vector emoji (Chrome 98+, smaller than CBDT). "
"Requires Noto-COLRv1.ttf in fonts/ directory.")
parser.add_argument("--nerd-lite", action="store_true",
help="Build Nerd Lite variant: Lite emoji + Nerd Fonts PUA icons. "
"Requires NotoEmoji[wght].ttf and SymbolsNerdFontMono-Regular.ttf in fonts/.")
args = parser.parse_args()
if sum([args.lite, args.colrv1, args.nerd_lite]) > 1:
print("Error: --lite, --colrv1, and --nerd-lite are mutually exclusive")
sys.exit(1)
yaml_config = load_config(args.config)
styles_config = get_config_value(yaml_config, "styles") or {}
if not styles_config:
print("Error: No styles defined in config.yaml")
sys.exit(1)
styles_str = (
args.styles
or get_config_value(yaml_config, "build", "styles")
or ",".join(styles_config.keys())
)
fonts_dir = args.fonts_dir or Path(get_config_value(yaml_config, "fonts_dir") or "fonts")
parallel = (
args.parallel
if args.parallel is not None
else get_config_int(yaml_config, "build", "parallel", default=1, min_val=1)
)
is_lite = args.lite
is_colrv1 = args.colrv1
is_nerd_lite = args.nerd_lite
# Determine family name, output dir, and emoji font override based on variant
if is_colrv1:
family_name = (
get_config_value(yaml_config, "colrv1", "family_name")
or (get_config_value(yaml_config, "font", "family_name") or "SarasaMonoTCEmoji") + "COLRv1"
)
variant_emoji_font = get_config_value(yaml_config, "colrv1", "emoji_font") or "Noto-COLRv1.ttf"
default_output_dir = get_config_value(yaml_config, "colrv1", "output_dir") or "output/fonts-colrv1"
colrv1_max_new_glyphs: int | None = get_config_int(
yaml_config, "colrv1", "max_new_glyphs", default=8136
)
colrv1_emoji_list_path = Path(
get_config_value(yaml_config, "colrv1", "emoji_list_path")
or "docs/colrv1-emoji-list.json"
)
# Parse priority codepoints from config (e.g. ["U+1F527", "U+1F680"])
_raw_priority = get_config_value(yaml_config, "colrv1", "priority_codepoints") or []
colrv1_priority_codepoints: set[int] | None = (
{int(s.replace("U+", "").replace("u+", ""), 16) for s in _raw_priority}
if _raw_priority else None
)
_raw_priority_sequences = get_config_value(yaml_config, "colrv1", "priority_sequences") or []
colrv1_priority_sequences: list[tuple[int, ...]] | None = (
_dedupe_codepoint_sequences(
[_parse_codepoint_sequence(s) for s in _raw_priority_sequences]
)
if _raw_priority_sequences else None
)
# Parse force codepoints — BMP symbols to colorize despite skip_existing
_raw_force = get_config_value(yaml_config, "colrv1", "force_colrv1_codepoints") or []
colrv1_force_codepoints: set[int] | None = (
{int(s.replace("U+", "").replace("u+", ""), 16) for s in _raw_force}
if _raw_force else None
)
color_force_codepoints = None
nerd_font_relative = None
nerd_icon_ranges = None
nerd_single_column_ranges = None
elif is_nerd_lite:
family_name = (
get_config_value(yaml_config, "nerd_lite", "family_name")
or (get_config_value(yaml_config, "font", "family_name") or "SarasaMonoTCEmoji") + "LiteNerd"
)
variant_emoji_font = (
get_config_value(yaml_config, "nerd_lite", "emoji_font")
or get_config_value(yaml_config, "lite", "emoji_font")
or "NotoEmoji[wght].ttf"
)
nerd_font_relative = get_config_value(yaml_config, "nerd_lite", "nerd_font")
if not nerd_font_relative:
print("Error: config.yaml 'nerd_lite.nerd_font' is required for --nerd-lite")
sys.exit(1)
nerd_icon_ranges = get_config_int_ranges(yaml_config, "nerd_lite", "icon_ranges")
if not nerd_icon_ranges:
print("Error: config.yaml 'nerd_lite.icon_ranges' must define at least one range")
sys.exit(1)
nerd_single_column_ranges = get_config_int_ranges(yaml_config, "nerd_lite", "single_column_ranges")
default_output_dir = (
get_config_value(yaml_config, "nerd_lite", "output_dir")
or "output/fonts-nerd-lite"
)
colrv1_max_new_glyphs = None
colrv1_emoji_list_path = None
colrv1_priority_codepoints = None
colrv1_priority_sequences = None
colrv1_force_codepoints = None
_raw_color_force = get_config_value(yaml_config, "emoji", "force_color_codepoints") or []
color_force_codepoints: set[int] | None = (
{int(s.replace("U+", "").replace("u+", ""), 16) for s in _raw_color_force}
if _raw_color_force else None
)
elif is_lite:
family_name = (
get_config_value(yaml_config, "lite", "family_name")
or (get_config_value(yaml_config, "font", "family_name") or "SarasaMonoTCEmoji") + "Lite"
)
variant_emoji_font = get_config_value(yaml_config, "lite", "emoji_font") or "NotoEmoji[wght].ttf"
default_output_dir = get_config_value(yaml_config, "lite", "output_dir") or "output/fonts-lite"
colrv1_max_new_glyphs = None
colrv1_emoji_list_path = None
colrv1_priority_codepoints = None
colrv1_priority_sequences = None
colrv1_force_codepoints = None
_raw_color_force = get_config_value(yaml_config, "emoji", "force_color_codepoints") or []
color_force_codepoints: set[int] | None = (
{int(s.replace("U+", "").replace("u+", ""), 16) for s in _raw_color_force}
if _raw_color_force else None
)
nerd_font_relative = None
nerd_icon_ranges = None
nerd_single_column_ranges = None
else:
family_name = get_config_value(yaml_config, "font", "family_name") or "SarasaMonoTCEmoji"
variant_emoji_font = None
default_output_dir = get_config_value(yaml_config, "build", "output_dir") or "output/fonts"
colrv1_max_new_glyphs = None
colrv1_emoji_list_path = None
colrv1_priority_codepoints = None
colrv1_priority_sequences = None
colrv1_force_codepoints = None
# Parse Color variant forced BMP codepoints
_raw_color_force = get_config_value(yaml_config, "emoji", "force_color_codepoints") or []
color_force_codepoints: set[int] | None = (
{int(s.replace("U+", "").replace("u+", ""), 16) for s in _raw_color_force}
if _raw_color_force else None
)
nerd_font_relative = None
nerd_icon_ranges = None
nerd_single_column_ranges = None
output_dir = args.output_dir or Path(default_output_dir)
version = get_config_value(yaml_config, "font", "version") or "1.0"
metadata = {
"author": get_config_value(yaml_config, "font", "author") or "",
"copyright": get_config_value(yaml_config, "font", "copyright") or "",
"description": (
get_config_value(yaml_config, "nerd_lite", "description")
if is_nerd_lite
else
get_config_value(yaml_config, "colrv1", "description")
if is_colrv1
else get_config_value(yaml_config, "lite", "description")
if is_lite
else get_config_value(yaml_config, "font", "description")
) or "",
"url": get_config_value(yaml_config, "font", "url") or "",
"license": get_config_value(yaml_config, "font", "license") or "",
"license_url": get_config_value(yaml_config, "font", "license_url") or "",
}
config = FontConfig(
family_name=family_name,
family_name_compact=family_name,
version=version,
emoji_width_multiplier=get_config_int(
yaml_config, "emoji", "emoji_width_multiplier", default=2, min_val=1, max_val=4
),
skip_existing=get_config_value(
yaml_config, "emoji", "skip_existing", default=True
),
)
styles = [s.strip() for s in styles_str.split(",")]
valid_styles = list(styles_config.keys())
for style in styles:
if style not in valid_styles:
print(f"Error: Invalid style '{style}'. Valid: {valid_styles}")
sys.exit(1)
# Validate font paths
font_paths: Dict[str, Dict[str, Any]] = {}
for style in styles:
style_cfg = styles_config[style]
base_font = style_cfg.get("base_font")
# Variant-specific emoji font override (COLRv1 or Lite replace per-style emoji font)
emoji_font = variant_emoji_font or style_cfg.get("emoji_font")
display_name = style_cfg.get("display_name", style)
if not base_font or not emoji_font:
print(f"Error: Style '{style}' must have both 'base_font' and 'emoji_font'")
sys.exit(1)
base_path = find_font(fonts_dir, base_font)
emoji_path = find_font(fonts_dir, emoji_font)
nerd_font_path = (
find_font(fonts_dir, Path(nerd_font_relative).name)
if is_nerd_lite and nerd_font_relative is not None
else None
)
if base_path is None:
print(f"Error: Base font '{base_font}' not found under {fonts_dir}/")
print(f" Download from: https://github.com/be5invis/Sarasa-Gothic/releases")
print(f" Extract the .7z and place the TTF files (or the unzipped folder) under {fonts_dir}/")
sys.exit(1)
if emoji_path is None:
print(f"Error: Emoji font '{emoji_font}' not found under {fonts_dir}/")
if is_colrv1:
print(f" Download Noto-COLRv1.ttf from:")
print(f" https://github.com/googlefonts/noto-emoji/blob/main/fonts/Noto-COLRv1.ttf")
elif is_lite:
print(f" Download NotoEmoji[wght].ttf from:")
print(f" https://github.com/google/fonts/raw/main/ofl/notoemoji/NotoEmoji%5Bwght%5D.ttf")
else:
print(f" Download from: https://github.com/googlefonts/noto-emoji/releases")
print(f" Place the file (or its containing folder) under {fonts_dir}/")
sys.exit(1)
if is_nerd_lite and nerd_font_relative is not None and nerd_font_path is None:
print(f"Error: Nerd font 'SymbolsNerdFontMono-Regular.ttf' not found under {fonts_dir}/")
print(f" Download NerdFontsSymbolsOnly.zip from:")
print(f" https://github.com/ryanoasis/nerd-fonts/releases")
print(f" Extract and place the zip (or SymbolsNerdFontMono-Regular.ttf) under {fonts_dir}/")
sys.exit(1)
font_paths[style] = {
"base_font_path": base_path,
"emoji_font_path": emoji_path,
"display_name": display_name,
"nerd_font_path": nerd_font_path,
}
output_dir.mkdir(parents=True, exist_ok=True)
variant_label = (
"COLRv1 (color vector)" if is_colrv1
else "Nerd Lite (monochrome glyf + Nerd PUA)" if is_nerd_lite
else "Lite (monochrome glyf)" if is_lite
else "Color (CBDT/CBLC)"
)
print(f"Building {config.family_name} v{config.version} [{variant_label}]")
print(f"Styles: {', '.join(styles)}")
print(f"Source: {fonts_dir}")
print(f"Output: {output_dir}")
print(f"Emoji width: {config.emoji_width_multiplier}x half-width")
if is_colrv1 and colrv1_max_new_glyphs is not None:
print(f"Glyph budget: {colrv1_max_new_glyphs} new slots (greedy selection)")
print("Font mapping:")
for style in styles:
p = font_paths[style]
base_rel = p['base_font_path'].relative_to(fonts_dir)
emoji_rel = p['emoji_font_path'].relative_to(fonts_dir)
mapping = f" {style}: {base_rel} + {emoji_rel}"
if is_nerd_lite and p["nerd_font_path"] is not None:
nerd_rel = p['nerd_font_path'].relative_to(fonts_dir)
mapping += f" + {nerd_rel}"
print(mapping)
build_start = time.monotonic()
colrv1_selection_records: list[dict] = []
# Determine effective force_codepoints for this variant
effective_force_codepoints: set[int] | None = (
colrv1_force_codepoints if is_colrv1
else color_force_codepoints
)
if parallel <= 1:
for style in styles:
p = font_paths[style]
_, records = build_single_font(
style, p["base_font_path"], p["emoji_font_path"],
p["display_name"], output_dir, config, metadata,
lite=is_lite, colrv1=is_colrv1,
max_new_glyphs=colrv1_max_new_glyphs,
priority_codepoints=colrv1_priority_codepoints,
priority_sequences=colrv1_priority_sequences,
force_codepoints=effective_force_codepoints,
nerd_lite=is_nerd_lite,
nerd_font_path=p["nerd_font_path"],
icon_ranges=nerd_icon_ranges,
single_column_ranges=nerd_single_column_ranges,
)
if records and not colrv1_selection_records:
colrv1_selection_records = records
else:
try:
with ProcessPoolExecutor(max_workers=parallel) as executor:
futures = {}
for style in styles:
p = font_paths[style]
future = executor.submit(
build_single_font,
style, p["base_font_path"], p["emoji_font_path"],
p["display_name"], output_dir, config, metadata,
is_lite, is_colrv1,
colrv1_max_new_glyphs,
colrv1_priority_codepoints,
colrv1_priority_sequences,
effective_force_codepoints,
is_nerd_lite,
p["nerd_font_path"],
nerd_icon_ranges,
nerd_single_column_ranges,
)
futures[future] = style
for future in as_completed(futures):
style = futures[future]
try:
_, records = future.result()
if records and not colrv1_selection_records:
colrv1_selection_records = records
except Exception as e:
print(f"\nError building {style}: {e}")
for f in futures:
f.cancel()
raise
except Exception:
print("\nParallel build failed — cleaning up partial outputs...")
_cleanup_partial_outputs(output_dir, config.family_name_compact, styles)
raise
build_elapsed = time.monotonic() - build_start
# Write COLRv1 emoji selection list (committed to repo for reference)
if is_colrv1 and colrv1_selection_records and colrv1_emoji_list_path is not None:
_write_emoji_list(
records=colrv1_selection_records,
output_path=colrv1_emoji_list_path,
version=version,
max_new_glyphs=colrv1_max_new_glyphs,
)
# Generate manifest for verify-emoji.html
manifest = {
"family_name": config.family_name,
"version": config.version,
"fonts": [],
}
for style in styles:
manifest["fonts"].append({
"style": style,
"display_name": font_paths[style]["display_name"],
"filename": f"{config.family_name_compact}-{style}.ttf",
})
manifest_path = output_dir / "fonts-manifest.json"
with open(manifest_path, "w", encoding="utf-8") as f:
json.dump(manifest, f, indent=2, ensure_ascii=False)
print(f"\nGenerated manifest: {manifest_path}")
print(f"Build complete! Fonts saved to: {output_dir} (total: {build_elapsed:.1f}s)")
if __name__ == "__main__":
main()