diff --git a/Lib/cu2qu/__main__.py b/Lib/cu2qu/__main__.py new file mode 100644 index 000000000..63203a497 --- /dev/null +++ b/Lib/cu2qu/__main__.py @@ -0,0 +1,6 @@ +import sys +from cu2qu.cli import main + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Lib/cu2qu/cli.py b/Lib/cu2qu/cli.py new file mode 100644 index 000000000..9924ff87b --- /dev/null +++ b/Lib/cu2qu/cli.py @@ -0,0 +1,139 @@ +import os +import argparse +import logging +import shutil +import multiprocessing as mp +from contextlib import closing +from functools import partial + +import cu2qu +from cu2qu.ufo import font_to_quadratic, fonts_to_quadratic + +import defcon + +logger = logging.getLogger("cu2qu") + + +def _cpu_count(): + try: + return mp.cpu_count() + except NotImplementedError: # pragma: no cover + return 1 + + +def _font_to_quadratic(zipped_paths, **kwargs): + input_path, output_path = zipped_paths + ufo = defcon.Font(input_path) + logger.info('Converting curves for %s', input_path) + if font_to_quadratic(ufo, **kwargs): + logger.info("Saving %s", output_path) + ufo.save(output_path) + else: + _copytree(input_path, output_path) + + +def _samepath(path1, path2): + # TODO on python3+, there's os.path.samefile + path1 = os.path.normcase(os.path.abspath(os.path.realpath(path1))) + path2 = os.path.normcase(os.path.abspath(os.path.realpath(path2))) + return path1 == path2 + + +def _copytree(input_path, output_path): + if _samepath(input_path, output_path): + logger.debug("input and output paths are the same file; skipped copy") + return + if os.path.exists(output_path): + shutil.rmtree(output_path) + shutil.copytree(input_path, output_path) + + +def main(args=None): + parser = argparse.ArgumentParser(prog="cu2qu") + parser.add_argument( + "--version", action="version", version=cu2qu.__version__) + parser.add_argument( + "infiles", + nargs="+", + metavar="INPUT", + help="one or more input UFO source file(s).") + parser.add_argument("-v", "--verbose", action="count", default=0) + + mode_parser = parser.add_mutually_exclusive_group() + mode_parser.add_argument( + "-i", + "--interpolatable", + action="store_true", + help="whether curve conversion should keep interpolation compatibility" + ) + mode_parser.add_argument( + "-j", + "--jobs", + type=int, + nargs="?", + default=1, + const=_cpu_count(), + metavar="N", + help="Convert using N multiple processes (default: %(default)s)") + + output_parser = parser.add_mutually_exclusive_group() + output_parser.add_argument( + "-o", + "--output-file", + default=None, + metavar="OUTPUT", + help=("output filename for the converted UFO. By default fonts are " + "modified in place. This only works with a single input.")) + output_parser.add_argument( + "-d", + "--output-dir", + default=None, + metavar="DIRECTORY", + help="output directory where to save converted UFOs") + + options = parser.parse_args(args) + + if not options.verbose: + level = "WARNING" + elif options.verbose == 1: + level = "INFO" + else: + level = "DEBUG" + logging.basicConfig(level=level) + + if len(options.infiles) > 1 and options.output_file: + parser.error("-o/--output-file can't be used with multile inputs") + + if options.output_dir: + output_paths = [ + os.path.join(options.output_dir, os.path.basename(p)) + for p in options.infiles + ] + elif options.output_file: + output_paths = [options.output_file] + else: + # save in-place + output_paths = list(options.infiles) + + if options.interpolatable: + logger.info('Converting curves compatibly') + ufos = [defcon.Font(infile) for infile in options.infiles] + if fonts_to_quadratic(ufos, dump_stats=True): + for ufo, output_path in zip(ufos, output_paths): + logger.info("Saving %s", output_path) + ufo.save(output_path) + else: + for input_path, output_path in zip(options.infiles, output_paths): + _copytree(input_path, output_path) + else: + jobs = min(len(options.infiles), + options.jobs) if options.jobs > 1 else 1 + if jobs > 1: + func = partial(_font_to_quadratic, dump_stats=False) + logger.info('Running %d parallel processes', jobs) + with closing(mp.Pool(jobs)) as pool: + # can't use Pool.starmap as it's 3.3+ only + pool.map(func, zip(options.infiles, output_paths)) + else: + for paths in zip(options.infiles, output_paths): + _font_to_quadratic(paths, dump_stats=True) diff --git a/Lib/cu2qu/ufo.py b/Lib/cu2qu/ufo.py index 3ef899dac..3c1bfaae1 100644 --- a/Lib/cu2qu/ufo.py +++ b/Lib/cu2qu/ufo.py @@ -40,6 +40,7 @@ from cu2qu.errors import ( __all__ = ['fonts_to_quadratic', 'font_to_quadratic'] DEFAULT_MAX_ERR = 0.001 +CURVE_TYPE_LIB_KEY = "com.github.googlei18n.cu2qu.curve_type" logger = logging.getLogger(__name__) @@ -208,7 +209,7 @@ def glyphs_to_quadratic( def fonts_to_quadratic( fonts, max_err_em=None, max_err=None, reverse_direction=False, - stats=None, dump_stats=False): + stats=None, dump_stats=False, remember_curve_type=True): """Convert the curves of a collection of fonts to quadratic. All curves will be converted to quadratic at once, ensuring interpolation @@ -217,10 +218,30 @@ def fonts_to_quadratic( Return True if fonts were modified, else return False. + By default, cu2qu stores the curve type in the fonts' lib, under a private + key "com.github.googlei18n.cu2qu.curve_type", and will not try to convert + them again if the curve type is already set to "quadratic". + Setting 'remember_curve_type' to False disables this optimization. + Raises IncompatibleFontsError if same-named glyphs from different fonts have non-interpolatable outlines. """ + if remember_curve_type: + curve_types = {f.lib.get(CURVE_TYPE_LIB_KEY, "cubic") for f in fonts} + if len(curve_types) == 1: + curve_type = next(iter(curve_types)) + if curve_type == "quadratic": + logger.info("Curves already converted to quadratic") + return False + elif curve_type == "cubic": + pass # keep converting + else: + raise NotImplementedError(curve_type) + elif len(curve_types) > 1: + # going to crash later if they do differ + logger.warning("fonts may contain different curve types") + if stats is None: stats = {} @@ -265,6 +286,13 @@ def fonts_to_quadratic( spline_lengths = sorted(stats.keys()) logger.info('New spline lengths: %s' % (', '.join( '%s: %d' % (l, stats[l]) for l in spline_lengths))) + + if remember_curve_type: + for font in fonts: + curve_type = font.lib.get(CURVE_TYPE_LIB_KEY, "cubic") + if curve_type != "quadratic": + font.lib[CURVE_TYPE_LIB_KEY] = "quadratic" + modified = True return modified diff --git a/setup.py b/setup.py index 55a0523fb..3a4b1426f 100644 --- a/setup.py +++ b/setup.py @@ -179,6 +179,8 @@ setup( "fonttools>=3.18.0", "ufoLib>=2.1.1", ], + extras_require={"cli": ["defcon>=0.4.0"]}, + entry_points={"console_scripts": ["cu2qu = cu2qu.cli:main [cli]"]}, cmdclass={ "release": release, "bump_version": bump_version, diff --git a/tests/cli_test.py b/tests/cli_test.py new file mode 100644 index 000000000..37d81e727 --- /dev/null +++ b/tests/cli_test.py @@ -0,0 +1,76 @@ +from __future__ import print_function, division, absolute_import + +import defcon + +from . import DATADIR +import pytest +import py + +from cu2qu.ufo import CURVE_TYPE_LIB_KEY +from cu2qu.cli import main + + +TEST_UFOS = [ + py.path.local(DATADIR).join("RobotoSubset-Regular.ufo"), + py.path.local(DATADIR).join("RobotoSubset-Bold.ufo"), +] + + +@pytest.fixture +def test_paths(tmpdir): + result = [] + for path in TEST_UFOS: + new_path = tmpdir / path.basename + path.copy(new_path) + result.append(new_path) + return result + + +class MainTest(object): + + @staticmethod + def run_main(*args): + main([str(p) for p in args if p]) + + def test_single_input_no_output(self, test_paths): + ufo_path = test_paths[0] + + self.run_main(ufo_path) + + font = defcon.Font(str(ufo_path)) + assert font.lib[CURVE_TYPE_LIB_KEY] == "quadratic" + + def test_single_input_output_file(self, tmpdir): + input_path = TEST_UFOS[0] + output_path = tmpdir / input_path.basename + self.run_main('-o', output_path, input_path) + + assert output_path.check(dir=1) + + def test_multiple_inputs_output_dir(self, tmpdir): + output_dir = tmpdir / "output_dir" + self.run_main('-d', output_dir, *TEST_UFOS) + + assert output_dir.check(dir=1) + outputs = set(p.basename for p in output_dir.listdir()) + assert "RobotoSubset-Regular.ufo" in outputs + assert "RobotoSubset-Bold.ufo" in outputs + + def test_interpolatable_inplace(self, test_paths): + self.run_main('-i', *test_paths) + self.run_main('-i', *test_paths) # idempotent + + @pytest.mark.parametrize( + "mode", ["", "-i"], ids=["normal", "interpolatable"]) + def test_copytree(self, mode, tmpdir): + output_dir = tmpdir / "output_dir" + self.run_main(mode, '-d', output_dir, *TEST_UFOS) + + output_dir_2 = tmpdir / "output_dir_2" + # no conversion when curves are already quadratic, just copy + self.run_main(mode, '-d', output_dir_2, *output_dir.listdir()) + # running again overwrites existing with the copy + self.run_main(mode, '-d', output_dir_2, *output_dir.listdir()) + + def test_multiprocessing(self, tmpdir, test_paths): + self.run_main(*(test_paths + ["-j"])) diff --git a/tests/ufo_test.py b/tests/ufo_test.py index ab8123654..d9ad2c6cc 100644 --- a/tests/ufo_test.py +++ b/tests/ufo_test.py @@ -9,6 +9,7 @@ from cu2qu.ufo import ( glyphs_to_quadratic, glyph_to_quadratic, logger, + CURVE_TYPE_LIB_KEY, ) from cu2qu.errors import ( IncompatibleSegmentNumberError, @@ -47,6 +48,18 @@ class FontsToQuadraticTest(object): fonts_to_quadratic(fonts, dump_stats=True) assert captor.assertRegex("New spline lengths:") + def test_remember_curve_type(self, fonts): + fonts_to_quadratic(fonts, remember_curve_type=True) + assert fonts[0].lib[CURVE_TYPE_LIB_KEY] == "quadratic" + with CapturingLogHandler(logger, "INFO") as captor: + fonts_to_quadratic(fonts, remember_curve_type=True) + assert captor.assertRegex("already converted") + + def test_no_remember_curve_type(self, fonts): + assert CURVE_TYPE_LIB_KEY not in fonts[0].lib + fonts_to_quadratic(fonts, remember_curve_type=False) + assert CURVE_TYPE_LIB_KEY not in fonts[0].lib + def test_different_glyphsets(self, fonts): del fonts[0]['a'] assert 'a' not in fonts[0]