Skip to content

Commit

Permalink
Merge pull request #1047 from googlefonts/fontc-flag-sketch
Browse files Browse the repository at this point in the history
Support for fontc and crater
  • Loading branch information
m4rc1e authored Nov 5, 2024
2 parents 9e060b9 + 31d7c78 commit ad287cc
Show file tree
Hide file tree
Showing 9 changed files with 260 additions and 28 deletions.
49 changes: 40 additions & 9 deletions Lib/gftools/builder/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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]:
Expand All @@ -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)

Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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]))
6 changes: 6 additions & 0 deletions Lib/gftools/builder/file.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
85 changes: 85 additions & 0 deletions Lib/gftools/builder/fontc.py
Original file line number Diff line number Diff line change
@@ -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
58 changes: 45 additions & 13 deletions Lib/gftools/builder/operations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
61 changes: 61 additions & 0 deletions Lib/gftools/builder/operations/fontc/__init__.py
Original file line number Diff line number Diff line change
@@ -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 ""
8 changes: 8 additions & 0 deletions Lib/gftools/builder/operations/fontc/fontcBuildOTF.py
Original file line number Diff line number Diff line change
@@ -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"
6 changes: 6 additions & 0 deletions Lib/gftools/builder/operations/fontc/fontcBuildTTF.py
Original file line number Diff line number Diff line change
@@ -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"
6 changes: 6 additions & 0 deletions Lib/gftools/builder/operations/fontc/fontcBuildVariable.py
Original file line number Diff line number Diff line change
@@ -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"
9 changes: 3 additions & 6 deletions Lib/gftools/builder/recipeproviders/googlefonts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]:
Expand Down Expand Up @@ -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:
Expand Down

0 comments on commit ad287cc

Please sign in to comment.