Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for fontc and crater #1047

Merged
merged 16 commits into from
Nov 5, 2024
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 33 additions & 6 deletions Lib/gftools/builder/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,19 @@
from os import chdir
from pathlib import Path
from tempfile import NamedTemporaryFile, gettempdir
from typing import Any, Dict, List, Union
from typing import Any, Dict, List, Tuple, 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 +38,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,7 +60,9 @@ def __init__(self, config: Union[dict, str]):
else:
self._orig_config = yaml.dump(config)
self.config = config
fontc_args.modify_config(self.config)

self.known_operations = OperationRegistry(use_fontc=fontc_args.use_fontc)
self.writer = Writer(open("build.ninja", "w"))
self.named_files = {}
self.used_operations = set([])
Expand Down Expand Up @@ -156,9 +164,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 @@ -381,8 +389,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 +431,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 Down
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 (
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may be too simple since I have a seen a family that has 4 masters and 4 instances which are not MM compatible so it could only export 4 static fonts.

Something like self.is_glyphs and len(self.gsfont.masters) < len(self.gsfont.instances) but it still isn't 100% reliable.

Copy link
Member

@anthrotype anthrotype Nov 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

perhaps maybe_variable is a better name for the method. But I think the gftools config provides an even stronger hint as to whether a font is supposed to be variable: if the font dev asks to build a variable font, then it must be variable; if they disables building the VF (is it buildVariable: false?) then one should treat it as not variable.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But I think the gftools config provides an even stronger hint as to whether a font is supposed to be variable: if the font dev asks to build a variable font, then it must be variable; if they disables building the VF (is it buildVariable: false?) then one should treat it as not variable.

I'm not sure I understand this. buildStatic, buildVariable, buildOTF, buildWebfont etc. are parameters about targets, not about sources. "I don't want you to build a variable font today" does not mean "this is not a variable font".

self.is_designspace and len(self.designspace.sources) > 1
)

@cached_property
def gsfont(self):
if self.is_glyphs:
Expand Down
81 changes: 81 additions & 0 deletions Lib/gftools/builder/fontc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""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.
"""
cmyr marked this conversation as resolved.
Show resolved Hide resolved

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
# 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
56 changes: 43 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,46 @@ 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

cmyr marked this conversation as resolved.
Show resolved Hide resolved
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]:
cmyr marked this conversation as resolved.
Show resolved Hide resolved
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
56 changes: 56 additions & 0 deletions Lib/gftools/builder/operations/fontc/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
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
Copy link
Collaborator

@m4rc1e m4rc1e Nov 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit. I'm probably being dense here but couldn't we skip the reverse part and just keep popping the first element by using args.pop(0).

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_}"
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 FontcBuildTTF(FontcOperationBase):
cmyr marked this conversation as resolved.
Show resolved Hide resolved
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 @@ -327,7 +323,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