import hashlib import importlib.util import json import platform import shutil import subprocess import sys import time from functools import partial from multiprocessing import Pool from os import listdir, makedirs, path, remove, walk, getenv from urllib.request import urlopen from zipfile import ZIP_DEFLATED, ZipFile from fontTools.ttLib import TTFont, newTable from fontTools.merge import Merger from fontTools.ttLib.tables import otTables # ========================================================================================= package_name = "foundryToolsCLI" package_installed = importlib.util.find_spec(package_name) is not None if not package_installed: print(f"{package_name} is not found. Please run `pip install foundrytools-cli`") exit(1) # ========================================================================================= release_mode = None use_normal = None use_cn_both = None use_hinted_font = None dir_prefix = None for arg in sys.argv: # whether to archieve fonts if arg == "--release": release_mode = True # whether to use normal preset elif arg == "--normal": use_normal = True # whether to build `Maple Mono CN` and `Maple Mono NF CN` elif arg == "--cn-both": use_cn_both = True # whether to use unhint font elif arg.startswith("--hinted="): use_hinted_font = arg.split("=")[1] == "1" # directory prefix elif arg.startswith("--prefix="): dir_prefix = arg.split("=")[1] # ========================================================================================= WIN_FONTFORGE_PATH = "C:/Program Files (x86)/FontForgeBuilds/bin/fontforge.exe" MAC_FONTFORGE_PATH = ( "/Applications/FontForge.app/Contents/Resources/opt/local/bin/fontforge" ) LINUX_FONTFORGE_PATH = "/usr/bin/fontforge" system_name = platform.uname()[0] font_forge_bin_default = LINUX_FONTFORGE_PATH if "Darwin" in system_name: font_forge_bin_default = MAC_FONTFORGE_PATH elif "Windows" in system_name: font_forge_bin_default = WIN_FONTFORGE_PATH # ========================================================================================= build_config = { # the number of parallel tasks # when run in codespace, this will be 1 "pool_size": 4, # font family name "family_name": "Maple Mono", # whether to use hinted ttf as base font "use_hinted": True, "feature_freeze": { "cv01": "ignore", "cv02": "ignore", "cv03": "ignore", "cv04": "ignore", "cv98": "ignore", "cv99": "ignore", "ss01": "ignore", "ss02": "ignore", "ss03": "ignore", "ss04": "ignore", "zero": "ignore", }, "feature_freeze_italic": { "cv01": "ignore", "cv02": "ignore", "cv03": "ignore", "cv04": "ignore", "cv98": "ignore", "cv99": "ignore", "ss01": "ignore", "ss02": "ignore", "ss03": "ignore", "ss04": "ignore", "zero": "ignore", }, # nerd font settings "nerd_font": { # whether to enable Nerd Font "enable": True, # target version of Nerd Font if font-patcher not exists "version": "3.2.1", # whether to make icon width fixed "mono": False, # prefer to use Font Patcher instead of using prebuild NerdFont base font # if you want to custom build nerd font using font-patcher, you need to set this to True "use_font_patcher": False, # symbol Fonts settings. # default args: ["--complete"] # if not, will use font-patcher to generate fonts # full args: https://github.com/ryanoasis/nerd-fonts?tab=readme-ov-file#font-patcher "glyphs": ["--complete"], # extra args for font-patcher # default args: ["-l", "--careful", "--outputdir", output_nf] # if "mono" is set to True, "--mono" will be added # full args: https://github.com/ryanoasis/nerd-fonts?tab=readme-ov-file#font-patcher "extra_args": [], }, # chinese font settings "cn": { # whether to build Chinese fonts # skip if Chinese base fonts are not founded "enable": True, # whether to patch Nerd Font "with_nerd_font": True, # fix design language and supported languages "fix_meta_table": True, # whether to clean instantiated base CN fonts "clean_cache": False, }, } try: with open("./source/preset-normal.json" if use_normal else "config.json", "r") as f: data = json.load(f) if "$schema" in data: del data["$schema"] build_config.update(data) if "font_forge_bin" not in build_config["nerd_font"]: build_config["nerd_font"]["font_forge_bin"] = font_forge_bin_default if use_hinted_font != None: build_config["use_hinted"] = use_hinted_font except: print("config.json is not found. Use default config.") family_name: str = build_config["family_name"] family_name_compact: str = family_name.replace(" ", "") # paths src_dir = "source" output_dir_default = "fonts" output_dir = ( path.join(output_dir_default, dir_prefix) if dir_prefix else output_dir_default ) output_otf = path.join(output_dir, "OTF") output_ttf = path.join(output_dir, "TTF") output_variable = path.join(output_dir_default, "Variable") output_woff2 = path.join(output_dir, "Woff2") output_nf = path.join(output_dir, "NF") cn_static_path = f"{src_dir}/cn/static" cn_base_font_dir = None suffix = None suffix_compact = None output_cn = None def load_cn_dir_and_suffix(with_nerd_font: bool): global cn_base_font_dir, suffix, suffix_compact, output_cn if with_nerd_font: cn_base_font_dir = output_nf suffix = "NF CN" suffix_compact = "NF-CN" else: cn_base_font_dir = path.join(output_dir, "TTF") suffix = suffix_compact = "CN" output_cn = path.join(output_dir, suffix_compact) load_cn_dir_and_suffix( build_config["cn"]["with_nerd_font"] and build_config["nerd_font"]["enable"] ) # In these subfamilies: # - NameID1 should be the family name # - NameID2 should be the subfamily name # - NameID16 and NameID17 should be removed # Other subfamilies: # - NameID1 should be the family name, append with subfamily name without "Italic" # - NameID2 should be the "Regular" or "Italic" # - NameID16 should be the family name # - NameID17 should be the subfamily name # https://github.com/subframe7536/maple-font/issues/182 # https://github.com/subframe7536/maple-font/issues/183 # # same as `ftcli assistant commit . --ls 400 700` # https://github.com/ftCLI/FoundryTools-CLI/issues/166#issuecomment-2095756721 skip_subfamily_list = ["Regular", "Bold", "Italic", "BoldItalic"] def pool_size(): return build_config["pool_size"] if not getenv("CODESPACE_NAME") else 1 # run command def run(cli: str | list[str], extra_args: list[str] = []) -> None: subprocess.run((cli.split(" ") if isinstance(cli, str) else cli) + extra_args) def set_font_name(font: TTFont, name: str, id: int): font["name"].setName(name, nameID=id, platformID=1, platEncID=0, langID=0x0) font["name"].setName(name, nameID=id, platformID=3, platEncID=1, langID=0x409) def get_font_name(font: TTFont, id: int) -> str: return ( font["name"] .getName(nameID=id, platformID=3, platEncID=1, langID=0x409) .__str__() ) def del_font_name(font: TTFont, id: int): font["name"].removeNames(nameID=id) # compress folder and return sha1 def compress_folder(source_file_or_dir_path: str, target_parent_dir_path: str) -> str: source_folder_name = path.basename(source_file_or_dir_path) zip_path = path.join( target_parent_dir_path, f"{family_name_compact}-{source_folder_name}.zip" ) with ZipFile(zip_path, "w", compression=ZIP_DEFLATED, compresslevel=5) as zip_file: for root, _, files in walk(source_file_or_dir_path): for file in files: file_path = path.join(root, file) zip_file.write( file_path, path.relpath(file_path, source_file_or_dir_path) ) zip_file.write("OFL.txt", "LICENSE.txt") if not source_file_or_dir_path.endswith("Variable"): zip_file.write(path.join(output_dir, "build-config.json"), "config.json") zip_file.close() sha1 = hashlib.sha1() with open(zip_path, "rb") as zip_file: while True: data = zip_file.read(1024) if not data: break sha1.update(data) return sha1.hexdigest() def check_font_patcher() -> bool: if path.exists("FontPatcher"): with open("FontPatcher/font-patcher", "r", encoding="utf-8") as f: if ( f"# Nerd Fonts Version: {build_config['nerd_font']['version']}" in f.read() ): return True else: print("FontPatcher version not match, delete it") shutil.rmtree("FontPatcher", ignore_errors=True) zip_path = "FontPatcher.zip" if not path.exists(zip_path): url = f"https://github.com/ryanoasis/nerd-fonts/releases/download/v{build_config['nerd_font']['version']}/FontPatcher.zip" try: print(f"NerdFont Patcher does not exist, download from {url}") with urlopen(url) as response, open(zip_path, "wb") as out_file: shutil.copyfileobj(response, out_file) except Exception as e: print( f"\nFail to fetch NerdFont Patcher. Please download it manually from {url}, then put downloaded 'FontPatcher.zip' into project's root and run this script again. \n\tError: {e}" ) print("use prebuilt Nerd Font instead") return False with ZipFile(zip_path, "r") as zip_ref: zip_ref.extractall("FontPatcher") remove(zip_path) return True def get_nerd_font_patcher_args(): # full args: https://github.com/ryanoasis/nerd-fonts?tab=readme-ov-file#font-patcher _nf_args = [ build_config["nerd_font"]["font_forge_bin"], "FontPatcher/font-patcher", "-l", "--careful", "--outputdir", output_nf, ] + build_config["nerd_font"]["glyphs"] if build_config["nerd_font"]["mono"]: _nf_args += ["--mono"] _nf_args += build_config["nerd_font"]["extra_args"] return _nf_args def parse_font_name(style_name_compact: str): is_italic = style_name_compact.endswith("Italic") _style_name = style_name_compact if is_italic and style_name_compact[0] != "I": _style_name = style_name_compact[:-6] + " Italic" if style_name_compact in skip_subfamily_list: return "", _style_name, _style_name, is_italic else: return ( " " + style_name_compact.replace("Italic", ""), "Italic" if is_italic else "Regular", _style_name, is_italic, ) def fix_cv98(font: TTFont): gsub_table = font["GSUB"].table feature_list = gsub_table.FeatureList for feature_record in feature_list.FeatureRecord: if feature_record.FeatureTag != "cv98": continue sub_table = gsub_table.LookupList.Lookup[ feature_record.Feature.LookupListIndex[0] ].SubTable[0] sub_table.mapping = { "emdash": "emdash.cv98", "ellipsis": "ellipsis.cv98", } break def remove_locl(font: TTFont): gsub = font["GSUB"] features_to_remove = [] for feature in gsub.table.FeatureList.FeatureRecord: feature_tag = feature.FeatureTag if feature_tag == "locl": features_to_remove.append(feature) for feature in features_to_remove: gsub.table.FeatureList.FeatureRecord.remove(feature) def freeze_feature(font: TTFont, is_italic: bool): # check feature list feature_record = font["GSUB"].table.FeatureList.FeatureRecord feature_dict = { feature.FeatureTag: feature.Feature for feature in feature_record if feature.FeatureTag != "calt" } calt_features = [ feature.Feature for feature in feature_record if feature.FeatureTag == "calt" ] # Process features for tag, status in build_config[ f"feature_freeze{'_italic' if is_italic else ''}" ].items(): target_feature = feature_dict.get(tag) if not target_feature or status == "ignore": continue if status == "disable": target_feature.LookupListIndex = [] continue if tag in ["ss03"]: # Enable by moving rules into "calt" for calt_feat in calt_features: calt_feat.LookupListIndex.extend(target_feature.LookupListIndex) else: # Enable by replacing data in glyf and hmtx tables glyph_dict = font["glyf"].glyphs hmtx_dict = font["hmtx"].metrics for index in target_feature.LookupListIndex: lookup = font["GSUB"].table.LookupList.Lookup[index] for old_key, new_key in lookup.SubTable[0].mapping.items(): if ( old_key in glyph_dict and old_key in hmtx_dict and new_key in glyph_dict and new_key in hmtx_dict ): glyph_dict[old_key] = glyph_dict[new_key] hmtx_dict[old_key] = hmtx_dict[new_key] else: print(f"{old_key} or {new_key} does not exist") return def build_mono(f: str): _path = path.join(output_ttf, f) font = TTFont(_path) style_compact = f.split("-")[-1].split(".")[0] style_with_prefix_space, style_in_2, style, is_italic = parse_font_name( style_compact ) set_font_name( font, family_name + style_with_prefix_space, 1, ) set_font_name(font, style_in_2, 2) set_font_name( font, f"{family_name} {style}", 4, ) set_font_name(font, f"{family_name_compact}-{style_compact}", 6) if style_compact not in skip_subfamily_list: set_font_name(font, family_name, 16) set_font_name(font, style, 17) # https://github.com/ftCLI/FoundryTools-CLI/issues/166#issuecomment-2095433585 if style_with_prefix_space == " Thin": font["OS/2"].usWeightClass = 250 elif style_with_prefix_space == " ExtraLight": font["OS/2"].usWeightClass = 275 freeze_feature(font, is_italic) font.save(_path) font.close() if build_config["use_hinted"]: run(f"ftcli ttf autohint {_path} -out {output_ttf}") run(f"ftcli converter ttf2otf {_path} -out {output_otf}") run(f"ftcli converter ft2wf {_path} -out {output_woff2} -f woff2") def build_nf(f: str, use_font_patcher: bool): print(f"generate NerdFont for {f}") nf_args = get_nerd_font_patcher_args() nf_file_name = "NerdFont" if build_config["nerd_font"]["mono"]: nf_file_name += "Mono" def build_using_prebuild_nerd_font(font_basename: str) -> TTFont: merger = Merger() return merger.merge( [ path.join(output_ttf, font_basename), f"{src_dir}/MapleMono-NF-Base{'-Mono' if build_config['nerd_font']['mono'] else ''}.ttf", ] ) def build_using_font_patcher(font_basename: str) -> TTFont: run(nf_args + [path.join(output_ttf, font_basename)]) _path = path.join(output_nf, font_basename.replace("-", f"{nf_file_name}-")) font = TTFont(_path) remove(_path) return font makedirs(output_nf, exist_ok=True) nf_font = ( build_using_font_patcher(f) if use_font_patcher else build_using_prebuild_nerd_font(f) ) # format font name style_compact_nf = f.split("-")[-1].split(".")[0] style_nf_with_prefix_space, style_nf_in_2, style_nf, _ = parse_font_name( style_compact_nf ) set_font_name( nf_font, f"{family_name} NF{style_nf_with_prefix_space}", 1, ) set_font_name(nf_font, style_nf_in_2, 2) set_font_name( nf_font, f"{family_name} NF {style_nf}", 4, ) postscript_name = f"{family_name_compact}-NF-{style_compact_nf}" set_font_name(nf_font, postscript_name, 6) set_font_name( nf_font, get_font_name(nf_font, 3).replace( f"MapleMono-{style_compact_nf}", postscript_name ), 3, ) if style_compact_nf not in skip_subfamily_list: set_font_name(nf_font, f"{family_name} NF", 16) set_font_name(nf_font, style_nf, 17) _path = path.join(output_nf, f"{family_name_compact}-NF-{style_compact_nf}.ttf") nf_font.save(_path) nf_font.close() def build_cn(f: str): style_compact_cn = f.split("-")[-1].split(".")[0] print(f"generate CN font for {f}") merger = Merger() font = merger.merge( [ path.join(cn_base_font_dir, f), path.join(cn_static_path, f"MapleMonoCN-{style_compact_cn}.ttf"), ] ) style_cn_with_prefix_space, style_cn_in_2, style_cn, is_italic = parse_font_name( style_compact_cn ) set_font_name( font, f"{family_name} {suffix}{style_cn_with_prefix_space}", 1, ) set_font_name(font, style_cn_in_2, 2) set_font_name( font, f"{family_name} {suffix} {style_cn}", 4, ) postscript_name = f"{family_name_compact}-{suffix_compact}-{style_compact_cn}" set_font_name(font, postscript_name, 6) set_font_name( font, get_font_name(font, 3).replace( f"MapleMono-{style_compact_cn}", postscript_name ), 3, ) if style_compact_cn not in skip_subfamily_list: set_font_name(font, f"{family_name} {suffix}", 16) set_font_name(font, style_cn, 17) font["OS/2"].xAvgCharWidth = 600 # https://github.com/subframe7536/maple-font/issues/188 fix_cv98(font) freeze_feature(font, is_italic) # https://github.com/subframe7536/maple-font/issues/239 remove_locl(font) if build_config["cn"]["fix_meta_table"]: # add code page, Latin / Japanese / Simplify Chinese / Traditional Chinese font["OS/2"].ulCodePageRange1 = 1 << 0 | 1 << 17 | 1 << 18 | 1 << 20 # fix meta table, https://learn.microsoft.com/en-us/typography/opentype/spec/meta meta = newTable("meta") meta.data = { "dlng": "Latn, Hans, Hant, Jpan", "slng": "Latn, Hans, Hant, Jpan", } font["meta"] = meta _path = path.join( output_cn, f"{family_name_compact}-{suffix_compact}-{style_compact_cn}.ttf", ) font.save(_path) font.close() def main(): print("=== [Clean Cache] ===") shutil.rmtree(output_dir, ignore_errors=True) shutil.rmtree(output_woff2, ignore_errors=True) makedirs(output_dir, exist_ok=True) makedirs(output_variable, exist_ok=True) start_time = time.time() print("=== [Build Start] ===") # ========================================================================================= # =================================== build basic ===================================== # ========================================================================================= input_files = [ f"{src_dir}/MapleMono-Italic[wght]-VF.ttf", f"{src_dir}/MapleMono[wght]-VF.ttf", ] for input_file in input_files: font = TTFont(input_file) set_font_name(font, family_name, 1) set_font_name( font, get_font_name(font, 3).replace("MapleMono", family_name_compact), 3 ) set_font_name( font, get_font_name(font, 4).replace("Maple Mono", family_name), 4 ) set_font_name( font, get_font_name(font, 6).replace("MapleMono", family_name_compact), 6 ) set_font_name(font, family_name_compact, 25) font.save( input_file.replace(src_dir, output_variable).replace( "MapleMono", family_name_compact ) ) run(f"ftcli fix italic-angle {output_variable}") run(f"ftcli fix monospace {output_variable}") run(f"ftcli converter vf2i {output_variable} -out {output_ttf}") run(f"ftcli fix italic-angle {output_ttf}") run(f"ftcli fix monospace {output_ttf}") run(f"ftcli fix strip-names {output_ttf}") run(f"ftcli ttf dehint {output_ttf}") run(f"ftcli ttf fix-contours {output_ttf}") run(f"ftcli ttf remove-overlaps {output_ttf}") with Pool(pool_size()) as p: p.map(build_mono, listdir(output_ttf)) # ========================================================================================= # ==================================== build NF ======================================= # ========================================================================================= if build_config["nerd_font"]["enable"]: use_font_patcher = ( len(build_config["nerd_font"]["extra_args"]) > 0 or build_config["nerd_font"]["use_font_patcher"] or build_config["nerd_font"]["glyphs"] != ["--complete"] ) and check_font_patcher() if use_font_patcher and not path.exists( build_config["nerd_font"]["font_forge_bin"] ): print( f"FontForge bin({build_config['nerd_font']['font_forge_bin']}) not found. Use prebuild Nerd Font instead." ) use_font_patcher = False with Pool(pool_size()) as p: _build_fn = partial(build_nf, use_font_patcher=use_font_patcher) _version = build_config["nerd_font"]["version"] _name = ( f"FontPatcher v{_version}" if use_font_patcher else "prebuild Nerd Font" ) print("========================================") print(f"patch Nerd Font using {_name}") print("========================================") p.map(_build_fn, listdir(output_ttf)) # ========================================================================================= # ==================================== build CN ======================================= # ========================================================================================= if build_config["cn"]["enable"] and path.exists(f"{src_dir}/cn"): if not path.exists(cn_static_path) or build_config["cn"]["clean_cache"]: print("=========================================") print("instantiating CN Base font, be patient...") print("=========================================") run(f"ftcli converter vf2i {src_dir}/cn -out {cn_static_path}") run(f"ftcli ttf fix-contours {cn_static_path}") run(f"ftcli ttf remove-overlaps {cn_static_path}") run(f"ftcli utils del-table -t kern -t GPOS {cn_static_path}") makedirs(output_cn, exist_ok=True) with Pool(pool_size()) as p: p.map(build_cn, listdir(cn_base_font_dir)) if use_cn_both and release_mode: load_cn_dir_and_suffix(not build_config["cn"]["with_nerd_font"]) makedirs(output_cn, exist_ok=True) with Pool(pool_size()) as p: p.map(build_cn, listdir(cn_base_font_dir)) run(f"ftcli name del-mac-names -r {output_dir}") # write config to output path with open( path.join(output_dir, "build-config.json"), "w", encoding="utf-8" ) as config_file: del build_config["pool_size"] del build_config["nerd_font"]["font_forge_bin"] config_file.write( json.dumps( build_config, indent=4, ) ) # ========================================================================================= # ==================================== release ======================================== # ========================================================================================= if release_mode: print("=== [Release Mode] ===") # archieve fonts release_dir = path.join(output_dir, "release") makedirs(release_dir, exist_ok=True) hash_map = {} # archieve fonts for f in listdir(output_dir): if f == "release" or f.endswith(".json"): continue hash_map[f] = compress_folder(path.join(output_dir, f), release_dir) print(f"archieve: {f}") # write sha1 with open( path.join(release_dir, "sha1.json"), "w", encoding="utf-8" ) as hash_file: hash_file.write(json.dumps(hash_map, indent=4)) print(f"=== [Build Success ({time.time() - start_time:.2f} s)] ===") if __name__ == "__main__": main()