Skip to content

Commit

Permalink
Merge pull request #1017 from googlefonts/also-open-json
Browse files Browse the repository at this point in the history
Also open/save JSON UFOs
  • Loading branch information
anthrotype authored Jul 28, 2023
2 parents df71fa3 + c426c7a commit a4c83a0
Show file tree
Hide file tree
Showing 5 changed files with 195 additions and 18 deletions.
37 changes: 28 additions & 9 deletions Lib/fontmake/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,7 @@ def parse_mutually_exclusive_inputs(parser, args):
if designspace_path:
parser.error("Only one *.designspace source file is allowed")
designspace_path = filename
elif (
os.path.normpath(filename).endswith(".ufo") and os.path.isdir(filename)
) or (filename.endswith(".ufoz") and os.path.isfile(filename)):
elif filename.endswith((".ufo", ".ufoz", ".ufo.json")):
ufo_paths.append(filename)
else:
parser.error(f"Unknown input file extension: {filename!r}")
Expand All @@ -145,6 +143,10 @@ def parse_mutually_exclusive_inputs(parser, args):
elif count > 1:
parser.error(f"Expected 1, got {count} different types of inputs files")

for filename in [glyphs_path] + [designspace_path] + ufo_paths:
if filename is not None and not os.path.exists(filename):
parser.error(f"{filename} not found")

format_name = (
"Glyphs" if glyphs_path else "designspace" if designspace_path else "UFO"
) + " source"
Expand Down Expand Up @@ -398,14 +400,27 @@ def main(args=None):
action="store_false",
help="Do not auto-generate a GDEF table, but keep an existing one intact.",
)
outputGroup.add_argument(

ufoStructureGroup = outputGroup.add_mutually_exclusive_group(required=False)
# kept for backward compat
ufoStructureGroup.add_argument(
"--save-ufo-as-zip",
dest="ufo_structure",
action="store_const",
const="zip",
dest="save_ufo_as_zip",
action="store_true",
help="Deprecated. Use --ufo-structure=zip instead.",
)
ufoStructureGroup.add_argument(
"--ufo-structure",
default="package",
help="Save UFOs as .ufoz format. Only valid when generating UFO masters "
"from glyphs source or interpolating UFO instances.",
choices=("package", "zip", "json"),
help="Select UFO format structure. Choose between: %(choices)s "
"(default: %(default)s). NOTE: json export is unofficial/experimental.",
)
outputGroup.add_argument(
"--indent-json",
action="store_true",
help="Whether to format the JSON files created with --ufo-structure=json "
"as multiple lines with 2-space indentation. Default: single line, no indent.",
)

contourGroup = parser.add_argument_group(title="Handling of contours")
Expand Down Expand Up @@ -632,6 +647,9 @@ def main(args=None):
if specs is not None:
args["filters"] = _loadFilters(parser, specs)

if args.pop("save_ufo_as_zip"):
args["ufo_structure"] = "zip"

inputs = parse_mutually_exclusive_inputs(parser, args)

if INTERPOLATABLE_OUTPUTS.intersection(args["output"]):
Expand Down Expand Up @@ -703,6 +721,7 @@ def main(args=None):
inputs.format_name,
)
args.pop("ufo_structure", None) # unused for UFO output
args.pop("indent_json", None)
project.run_from_ufos(
inputs.ufo_paths, is_instance=args.pop("masters_as_instances"), **args
)
Expand Down
43 changes: 34 additions & 9 deletions Lib/fontmake/font_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
UFO_STRUCTURE_EXTENSIONS = {
"package": ".ufo",
"zip": ".ufoz",
"json": ".ufo.json",
}


Expand Down Expand Up @@ -128,6 +129,11 @@ def __init__(

def open_ufo(self, path):
try:
path = Path(path)
if path.suffix == ".json":
with open(path, "rb") as f:
# pylint: disable=no-member
return ufoLib2.Font.json_load(f) # type: ignore
return ufoLib2.Font.open(path, validate=self.validate_ufo)
except Exception as e:
raise FontmakeError("Reading UFO source failed", path) from e
Expand All @@ -139,14 +145,26 @@ def _fix_ufo_path(path, ufo_structure):
Path(path).with_suffix(UFO_STRUCTURE_EXTENSIONS[ufo_structure])
)

def save_ufo_as(self, font, path, ufo_structure="package"):
def save_ufo_as(self, font, path, ufo_structure="package", indent_json=False):
try:
font.save(
_ensure_parent_dir(path),
overwrite=True,
validate=self.validate_ufo,
structure=ufo_structure,
)
path = _ensure_parent_dir(path)
if ufo_structure == "json":
with open(path, "wb") as f:
# pylint: disable=no-member
font.json_dump(
f,
# orjson only supports either 2 or none
indent=2 if indent_json else None,
# makes output deterministic
sort_keys=True,
) # type: ignore
else:
font.save(
path,
overwrite=True,
validate=self.validate_ufo,
structure=ufo_structure,
)
except Exception as e:
raise FontmakeError("Writing UFO source failed", path) from e

Expand All @@ -162,6 +180,7 @@ def build_master_ufos(
write_skipexportglyphs=True,
generate_GDEF=True,
ufo_structure="package",
indent_json=False,
glyph_data=None,
save_ufos=True,
):
Expand Down Expand Up @@ -239,7 +258,7 @@ def build_master_ufos(
if save_ufos:
for ufo_path, ufo in masters.items():
logger.info("Saving %s", ufo_path)
self.save_ufo_as(ufo, ufo_path, ufo_structure)
self.save_ufo_as(ufo, ufo_path, ufo_structure, indent_json)

return designspace

Expand Down Expand Up @@ -845,6 +864,7 @@ def run_from_glyphs(
write_skipexportglyphs=write_skipexportglyphs,
generate_GDEF=generate_GDEF,
ufo_structure=kwargs.get("ufo_structure"),
indent_json=kwargs.get("indent_json"),
glyph_data=glyph_data,
save_ufos=save_ufos,
)
Expand Down Expand Up @@ -900,6 +920,7 @@ def interpolate_instance_ufos(
expand_features_to_instances=False,
fea_include_dir=None,
ufo_structure="package",
indent_json=False,
save_ufos=True,
output_path=None,
output_dir=None,
Expand Down Expand Up @@ -987,7 +1008,9 @@ def interpolate_instance_ufos(
instance, designspace.path, output_dir, ufo_structure
)
logger.info("Saving %s", instance_path)
self.save_ufo_as(instance.font, instance_path, ufo_structure)
self.save_ufo_as(
instance.font, instance_path, ufo_structure, indent_json
)
elif instance.filename is not None:
# saving a UFO sets its path attribute; when saving the binary font
# compiled from this UFO, self._output_path() uses the ufo.path to
Expand Down Expand Up @@ -1158,6 +1181,7 @@ def _run_from_designspace_static(
expand_features_to_instances=False,
fea_include_dir=None,
ufo_structure="package",
indent_json=False,
output_path=None,
output_dir=None,
**kwargs,
Expand All @@ -1184,6 +1208,7 @@ def _run_from_designspace_static(
expand_features_to_instances=expand_features_to_instances,
fea_include_dir=fea_include_dir,
ufo_structure=ufo_structure,
indent_json=indent_json,
save_ufos=save_ufos,
output_path=ufo_output_path,
output_dir=ufo_output_dir,
Expand Down
10 changes: 10 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,13 @@ attrs==23.1.0
cffsubr==0.2.9.post1
compreffor==0.5.3
ttfautohint-py==0.5.1

# optional, for experimental reading/writing ufoLib2's UFO as json
cattrs==23.1.2
# orjson currently doesn't ship 32-bit wheels for Windows
# https://github.com/ijl/orjson/issues/409
# platform_machine environment marker returns 'AMD64' on Windows even if Python is 32-bit
# so there's no way to tell pip to install orjson only on 64-bit Win Python; hence
# don't install orjson on Windows (works fine without it using stdlib json module)
# https://stackoverflow.com/a/75411662
orjson==3.9.2; platform_python_implementation == 'CPython' and platform_system != 'Windows'
2 changes: 2 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
# to avoid fontmake installation failing if requested
"mutatormath": [],
"autohint": ["ttfautohint-py>=0.5.0"],
# For reading/writing ufoLib2's .ufo.json files (cattrs + orjson)
"json": ["ufoLib2[json]"],
}
# use a special 'all' key as shorthand to includes all the extra dependencies
extras_require["all"] = sum(extras_require.values(), [])
Expand Down
121 changes: 121 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1118,3 +1118,124 @@ def test_timing_logger(data_dir, tmp_path):
result.stderr.decode(),
flags=re.MULTILINE,
)


@pytest.fixture(params=["package", "zip", "json"])
def ufo_structure(request):
if request.param == "json":
# skip if ufoLib2's extra dep is not installed
pytest.importorskip("cattrs")
return request.param


@pytest.mark.parametrize("interpolate", [False, True])
def test_main_export_custom_ufo_structure(
data_dir, tmp_path, ufo_structure, interpolate
):
args = [
str(data_dir / "GlyphsUnitTestSans.glyphs"),
"-o",
"ufo",
"--output-dir",
str(tmp_path),
"--ufo-structure",
ufo_structure,
]
if interpolate:
args.append("-i")
else:
# strictly not needed, added just to make Windows happy about relative
# instance.filename when designspace is written to a different mount point
args.extend(["--instance-dir", str(tmp_path)])

fontmake.__main__.main(args)

ext = {"package": ".ufo", "zip": ".ufoz", "json": ".ufo.json"}[ufo_structure]

for style in ["Light", "Regular", "Bold"]:
assert (tmp_path / f"GlyphsUnitTestSans-{style}").with_suffix(ext).exists()

if interpolate:
for style in ["Thin", "ExtraLight", "Medium", "Black", "Web"]:
assert (tmp_path / f"GlyphsUnitTestSans-{style}").with_suffix(ext).exists()


@pytest.mark.parametrize("ufo_structure", ["zip", "json"])
def test_main_build_from_custom_ufo_structure(data_dir, tmp_path, ufo_structure):
pytest.importorskip("cattrs")

# export designspace pointing to {json,zip}-flavored source UFOs
fontmake.__main__.main(
[
str(data_dir / "GlyphsUnitTestSans.glyphs"),
"-o",
"ufo",
"--output-dir",
str(tmp_path / "master_ufos"),
"--ufo-structure",
ufo_structure,
# makes Windows happy about relative instance.filename across drives
"--instance-dir",
str(tmp_path / "instance_ufos"),
]
)

# interpolate one static TTF instance from this designspace
fontmake.__main__.main(
[
str(tmp_path / "master_ufos" / "GlyphsUnitTestSans.designspace"),
"-o",
"ttf",
"-i",
"Glyphs Unit Test Sans Extra Light",
"--output-path",
str(tmp_path / "instance_ttfs" / "GlyphsUnitTestSans-ExtraLight.ttf"),
]
)

assert len(list((tmp_path / "instance_ttfs").glob("*.ttf"))) == 1
assert (tmp_path / "instance_ttfs" / "GlyphsUnitTestSans-ExtraLight.ttf").exists()

# build one {json,zip} UFO => OTF
ext = {"json": ".ufo.json", "zip": ".ufoz"}[ufo_structure]
fontmake.__main__.main(
[
str(tmp_path / "master_ufos" / f"GlyphsUnitTestSans-Regular{ext}"),
"-o",
"otf",
"--output-path",
str(tmp_path / "master_otfs" / "GlyphsUnitTestSans-Regular.otf"),
]
)

assert len(list((tmp_path / "master_otfs").glob("*.otf"))) == 1
assert (tmp_path / "master_otfs" / "GlyphsUnitTestSans-Regular.otf").exists()


@pytest.mark.parametrize("indent_json", [False, True])
def test_main_export_ufo_json_with_indentation(data_dir, tmp_path, indent_json):
pytest.importorskip("cattrs")

fontmake.__main__.main(
[
str(data_dir / "GlyphsUnitTestSans.glyphs"),
"-o",
"ufo",
"--output-dir",
str(tmp_path / "master_ufos"),
"--ufo-structure",
"json",
# makes Windows happy about relative instance.filename across drives
"--instance-dir",
str(tmp_path / "instance_ufos"),
]
+ (["--indent-json"] if indent_json else [])
)

regular_ufo = tmp_path / "master_ufos" / "GlyphsUnitTestSans-Regular.ufo.json"
assert (regular_ufo).exists()

if indent_json:
assert regular_ufo.read_text().startswith('{\n "features"')
else:
assert regular_ufo.read_text().startswith('{"features"')

0 comments on commit a4c83a0

Please sign in to comment.