diff --git a/Lib/gftools/builder/__init__.py b/Lib/gftools/builder/__init__.py index afdb9f20..5c12c87c 100644 --- a/Lib/gftools/builder/__init__.py +++ b/Lib/gftools/builder/__init__.py @@ -7,17 +7,20 @@ from os import chdir from pathlib import Path from tempfile import NamedTemporaryFile, gettempdir +import time from typing import Any, Dict, List, Union +from gftools.builder.fontc import FontcArgs import networkx as nx import strictyaml import yaml from fontmake.font_project import FontProject from ninja import _program from ninja.ninja_syntax import Writer, escape_path +from typing import Union from gftools.builder.file import File -from gftools.builder.operations import OperationBase, known_operations +from gftools.builder.operations import OperationBase, OperationRegistry from gftools.builder.operations.copy import Copy from gftools.builder.recipeproviders import get_provider from gftools.builder.schema import BASE_SCHEMA @@ -36,7 +39,11 @@ class GFBuilder: config: dict recipe: Recipe - def __init__(self, config: Union[dict, str]): + def __init__( + self, + config: Union[dict, str], + fontc_args=FontcArgs(None), + ): if isinstance(config, str): parentpath = Path(config).resolve().parent with open(config, "r") as file: @@ -54,8 +61,11 @@ def __init__(self, config: Union[dict, str]): else: self._orig_config = yaml.dump(config) self.config = config + fontc_args.modify_config(self.config) - self.writer = Writer(open("build.ninja", "w")) + self.known_operations = OperationRegistry(use_fontc=fontc_args.use_fontc) + self.ninja_file_name = f"build-{time.time_ns()}.ninja" + self.writer = Writer(open(self.ninja_file_name, "w")) self.named_files = {} self.used_operations = set([]) self.graph = nx.DiGraph() @@ -156,9 +166,9 @@ def glyphs_to_ufo(self, source): def operation_step_to_object(self, step): operation = step.get("operation") or step.get("postprocess") - if operation not in known_operations: + cls = self.known_operations.get(operation) + if cls is None: raise ValueError(f"Unknown operation {operation}") - cls = known_operations[operation] if operation not in self.used_operations: self.used_operations.add(operation) cls.write_rules(self.writer) @@ -328,7 +338,9 @@ def walk_graph(self): def draw_graph(self): import pydot - dot = subprocess.run(["ninja", "-t", "graph"], capture_output=True) + dot = subprocess.run( + ["ninja", "-t", "graph", "-f", self.ninja_file_name], capture_output=True + ) graphs = pydot.graph_from_dot_data(dot.stdout.decode("utf-8")) targets = self.recipe.keys() if graphs and graphs[0]: @@ -354,7 +366,7 @@ def clean(self): if cleanUp == True: print("Cleaning up temporary files...") - for file in ["./build.ninja", "./.ninja_log"]: + for file in [self.ninja_file_name, "./.ninja_log"]: if os.path.exists(file): os.remove(file) @@ -381,8 +393,27 @@ def main(args=None): help="Just generate and output recipe from recipe builder", action="store_true", ) + parser.add_argument( + "--experimental-fontc", + help=f"Use fontc instead of fontmake. Argument is path to the fontc executable", + type=Path, + ) + + parser.add_argument( + "--experimental-simple-output", + help="generate a reduced set of targets, and copy them to the provided directory", + type=Path, + ) + + parser.add_argument( + "--experimental-single-source", + help="only compile the single named source file", + type=str, + ) + parser.add_argument("config", help="Path to config file or source file", nargs="+") args = parser.parse_args(args) + fontc_args = FontcArgs(args) yaml_files = [] source_files = [] for config in args.config: @@ -404,7 +435,7 @@ def main(args=None): raise ValueError("Only one config file can be given for now") config = args.config[0] - pd = GFBuilder(config) + pd = GFBuilder(config, fontc_args=fontc_args) if args.generate: config = pd.config config["recipe"] = pd.recipe @@ -417,4 +448,4 @@ def main(args=None): pd.draw_graph() if not args.no_ninja: atexit.register(pd.clean) - raise SystemExit(_program("ninja", [])) + raise SystemExit(_program("ninja", ["-f", pd.ninja_file_name])) diff --git a/Lib/gftools/builder/file.py b/Lib/gftools/builder/file.py index 7191821a..1d0b2d0b 100644 --- a/Lib/gftools/builder/file.py +++ b/Lib/gftools/builder/file.py @@ -47,6 +47,12 @@ def is_designspace(self): def is_font_source(self): return self.is_glyphs or self.is_ufo or self.is_designspace + @cached_property + def is_variable(self) -> bool: + return (self.is_glyphs and len(self.gsfont.masters) > 1) or ( + self.is_designspace and len(self.designspace.sources) > 1 + ) + @cached_property def gsfont(self): if self.is_glyphs: diff --git a/Lib/gftools/builder/fontc.py b/Lib/gftools/builder/fontc.py new file mode 100644 index 00000000..5033248f --- /dev/null +++ b/Lib/gftools/builder/fontc.py @@ -0,0 +1,85 @@ +"""functionality for running fontc via gftools + +gftools has a few special flags that allow it to use fontc, an alternative +font compiler (https://github.com/googlefonts/fontc). + +This module exists to keep the logic related to fontc in one place, and not +dirty up everything else. +""" + +from argparse import Namespace +from pathlib import Path +from typing import Union + +from gftools.builder.file import File +from gftools.builder.operations.fontc import set_global_fontc_path + + +class FontcArgs: + # init with 'None' returns a default obj where everything is None + def __init__(self, args: Union[Namespace, None]) -> None: + if not args: + self.simple_output_path = None + self.fontc_bin_path = None + self.single_source = None + return + self.simple_output_path = abspath(args.experimental_simple_output) + self.fontc_bin_path = abspath(args.experimental_fontc) + self.single_source = args.experimental_single_source + if self.fontc_bin_path: + if not self.fontc_bin_path.is_file(): + raise ValueError(f"fontc does not exist at {self.fontc_bin_path}") + set_global_fontc_path(self.fontc_bin_path) + + @property + def use_fontc(self) -> bool: + return self.fontc_bin_path is not None + + # update the config dictionary based on our special needs + def modify_config(self, config: dict): + if self.single_source: + filtered_sources = [s for s in config["sources"] if self.single_source in s] + n_sources = len(filtered_sources) + if n_sources != 1: + raise ValueError( + f"--exerimental-single-source {self.single_source} must match exactly one of {config['sources']} (matched {n_sources}) " + ) + config["sources"] = filtered_sources + + if self.fontc_bin_path or self.simple_output_path: + # we stash this flag here to pass it down to the recipe provider + config["use_fontc"] = self.fontc_bin_path + config["buildWebfont"] = False + config["buildSmallCap"] = False + config["splitItalic"] = False + # set --no-production-names, because it's easier to debug + extra_args = config.get("extraFontmakeArgs") or "" + extra_args += " --no-production-names --drop-implied-oncurves" + config["extraFontmakeArgs"] = extra_args + # override config to turn not build instances if we're variable + if self.will_build_variable_font(config): + config["buildStatic"] = False + # if the font doesn't explicitly request CFF, just build TT outlines + # if the font _only_ wants CFF outlines, we will try to build them + # ( but fail on fontc for now) (but is this even a thing?) + elif config.get("buildTTF", True): + config["buildOTF"] = False + if self.simple_output_path: + output_dir = str(self.simple_output_path) + # we dump everything into one dir in this case + config["outputDir"] = str(output_dir) + config["ttDir"] = str(output_dir) + config["otDir"] = str(output_dir) + config["vfDir"] = str(output_dir) + + def will_build_variable_font(self, config: dict) -> bool: + # if config explicitly says dont build variable, believe it + if not config.get("buildVariable", True): + return False + + source = File(config["sources"][0]) + return source.is_variable + + +def abspath(path: Union[Path, None]) -> Union[Path, None]: + return path.resolve() if path else None diff --git a/Lib/gftools/builder/operations/__init__.py b/Lib/gftools/builder/operations/__init__.py index 0b033073..ca4d3cd0 100644 --- a/Lib/gftools/builder/operations/__init__.py +++ b/Lib/gftools/builder/operations/__init__.py @@ -8,6 +8,7 @@ import sys from os.path import dirname from tempfile import NamedTemporaryFile +from typing import Dict from gftools.builder.file import File from gftools.utils import shell_quote @@ -150,17 +151,48 @@ def variables(self): return vars -known_operations = {} +class OperationRegistry: + def __init__(self, use_fontc: bool): + self.known_operations = get_known_operations() + self.use_fontc = use_fontc -for mod in pkgutil.iter_modules([dirname(__file__)]): - imp = importlib.import_module("gftools.builder.operations." + mod.name) - classes = [ - (name, cls) - for name, cls in inspect.getmembers(sys.modules[imp.__name__], inspect.isclass) - if "OperationBase" not in name and issubclass(cls, OperationBase) - ] - if len(classes) > 1: - raise ValueError( - f"Too many classes in module gftools.builder.operations.{mod.name}" - ) - known_operations[mod.name] = classes[0][1] + def get(self, operation_name: str): + if self.use_fontc: + if operation_name == "buildVariable": + # if we import this at the top level it's a circular import error + from .fontc.fontcBuildVariable import FontcBuildVariable + + return FontcBuildVariable + if operation_name == "buildTTF": + from .fontc.fontcBuildTTF import FontcBuildTTF + + return FontcBuildTTF + + if operation_name == "buildOTF": + from .fontc.fontcBuildOTF import FontcBuildOTF + + return FontcBuildOTF + + return self.known_operations.get(operation_name) + + +def get_known_operations() -> Dict[str, OperationBase]: + known_operations = {} + + for mod in pkgutil.iter_modules([dirname(__file__)]): + if "fontc" in mod.name: + continue + imp = importlib.import_module("gftools.builder.operations." + mod.name) + classes = [ + (name, cls) + for name, cls in inspect.getmembers( + sys.modules[imp.__name__], inspect.isclass + ) + if "OperationBase" not in name and issubclass(cls, OperationBase) + ] + if len(classes) > 1: + raise ValueError( + f"Too many classes in module gftools.builder.operations.{mod.name}" + ) + known_operations[mod.name] = classes[0][1] + return known_operations diff --git a/Lib/gftools/builder/operations/fontc/__init__.py b/Lib/gftools/builder/operations/fontc/__init__.py new file mode 100644 index 00000000..e3f0fb6d --- /dev/null +++ b/Lib/gftools/builder/operations/fontc/__init__.py @@ -0,0 +1,61 @@ +from pathlib import Path +from typing import List +from gftools.builder.operations import OperationBase + +_FONTC_PATH = None + + +# should only be called once, from main, before doing anything else. This is a +# relatively non-invasive way to smuggle this value into FontcOperationBase +def set_global_fontc_path(path: Path): + global _FONTC_PATH + assert _FONTC_PATH is None, "set_global_fontc_path should only be called once" + _FONTC_PATH = path + + +class FontcOperationBase(OperationBase): + @property + def variables(self): + vars = super().variables + vars["fontc_path"] = _FONTC_PATH + args = vars.get("args") + if args: + vars["args"] = rewrite_fontmake_args_for_fontc(args) + + return vars + + +def rewrite_fontmake_args_for_fontc(args: str) -> str: + out_args = [] + arg_list = args.split() + # reverse so we can pop in order + arg_list.reverse() + while arg_list: + out_args.append(rewrite_one_arg(arg_list)) + return " ".join(out_args) + + +# remove next arg from the front of the list and return its fontc equivalent +def rewrite_one_arg(args: List[str]) -> str: + next_ = args.pop() + if next_ == "--filter": + filter_ = args.pop() + # this means 'retain filters defined in UFO', which... do we even support + # that in fontc? + if filter_ == "...": + pass + elif filter_ == "FlattenComponentsFilter": + return "--flatten-components" + elif filter_ == "DecomposeTransformedComponentsFilter": + return "--decompose-transformed-components" + else: + # glue the filter back together for better reporting below + next_ = f"{next_} {filter_}" + elif next_ == "--no-production-names": + return next_ + elif next_ == "--drop-implied-oncurves": + # this is our default behaviour so no worries + return "" + else: + raise ValueError(f"unknown fontmake arg '{next_}'") + return "" diff --git a/Lib/gftools/builder/operations/fontc/fontcBuildOTF.py b/Lib/gftools/builder/operations/fontc/fontcBuildOTF.py new file mode 100644 index 00000000..8ca3c852 --- /dev/null +++ b/Lib/gftools/builder/operations/fontc/fontcBuildOTF.py @@ -0,0 +1,8 @@ +from gftools.builder.operations.fontc import FontcOperationBase + + +class FontcBuildOTF(FontcOperationBase): + description = "Build an OTF from a source file (with fontc)" + # the '--cff-outlines' flag does not exit in fontc, so this will + # error, which we want + rule = "$fontc_path -o $out $in $args --cff-outlines" diff --git a/Lib/gftools/builder/operations/fontc/fontcBuildTTF.py b/Lib/gftools/builder/operations/fontc/fontcBuildTTF.py new file mode 100644 index 00000000..a28c450b --- /dev/null +++ b/Lib/gftools/builder/operations/fontc/fontcBuildTTF.py @@ -0,0 +1,6 @@ +from gftools.builder.operations.fontc import FontcOperationBase + + +class FontcBuildTTF(FontcOperationBase): + description = "Build a TTF from a source file (with fontc)" + rule = "$fontc_path -o $out $in $args" diff --git a/Lib/gftools/builder/operations/fontc/fontcBuildVariable.py b/Lib/gftools/builder/operations/fontc/fontcBuildVariable.py new file mode 100644 index 00000000..abe72504 --- /dev/null +++ b/Lib/gftools/builder/operations/fontc/fontcBuildVariable.py @@ -0,0 +1,6 @@ +from gftools.builder.operations.fontc import FontcOperationBase + + +class FontcBuildVariable(FontcOperationBase): + description = "Build a variable font from a source file (with fontc)" + rule = f"$fontc_path -o $out $in $args" diff --git a/Lib/gftools/builder/recipeproviders/googlefonts.py b/Lib/gftools/builder/recipeproviders/googlefonts.py index b18781eb..350328c9 100644 --- a/Lib/gftools/builder/recipeproviders/googlefonts.py +++ b/Lib/gftools/builder/recipeproviders/googlefonts.py @@ -227,11 +227,7 @@ def build_all_variables(self): if not self.config.get("buildVariable", True): return for source in self.sources: - if ( - (source.is_glyphs and len(source.gsfont.masters) < 2) - or source.is_ufo - or (source.is_designspace and len(source.designspace.sources) < 2) - ): + if not source.is_variable: continue italic_ds = None if self.config["splitItalic"]: @@ -337,7 +333,8 @@ def build_a_static(self, source: File, instance: InstanceDescriptor, output): steps = [ {"source": source.path}, ] - if not source.is_ufo: + # if we're running fontc we skip conversion to UFO + if not source.is_ufo and not self.config.get("use_fontc", False): instancename = instance.name if instancename is None: if not instance.familyName or not instance.styleName: