Merge remote-tracking branch 'cu2qu-origin/fonttools-merge' into cu2qu
This commit is contained in:
commit
b8f1f7bf5e
6
fonttools-merge/__main__.py
Normal file
6
fonttools-merge/__main__.py
Normal file
@ -0,0 +1,6 @@
|
||||
import sys
|
||||
from cu2qu.cli import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
161
fonttools-merge/cli.py
Normal file
161
fonttools-merge/cli.py
Normal file
@ -0,0 +1,161 @@
|
||||
from __future__ import print_function, division, absolute_import
|
||||
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)
|
||||
parser.add_argument(
|
||||
"-e",
|
||||
"--conversion-error",
|
||||
type=float,
|
||||
metavar="ERROR",
|
||||
default=None,
|
||||
help="maxiumum approximation error measured in EM (default: 0.001)")
|
||||
parser.add_argument(
|
||||
"--keep-direction",
|
||||
dest="reverse_direction",
|
||||
action="store_false",
|
||||
help="do not reverse the contour direction")
|
||||
|
||||
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_dir = options.output_dir
|
||||
if not os.path.exists(output_dir):
|
||||
os.mkdir(output_dir)
|
||||
elif not os.path.isdir(output_dir):
|
||||
parser.error("'%s' is not a directory" % output_dir)
|
||||
output_paths = [
|
||||
os.path.join(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)
|
||||
|
||||
kwargs = dict(dump_stats=options.verbose > 0,
|
||||
max_err_em=options.conversion_error,
|
||||
reverse_direction=options.reverse_direction)
|
||||
|
||||
if options.interpolatable:
|
||||
logger.info('Converting curves compatibly')
|
||||
ufos = [defcon.Font(infile) for infile in options.infiles]
|
||||
if fonts_to_quadratic(ufos, **kwargs):
|
||||
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, **kwargs)
|
||||
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, **kwargs)
|
85
fonttools-merge/cli_test.py
Normal file
85
fonttools-merge/cli_test.py
Normal file
@ -0,0 +1,85 @@
|
||||
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"]))
|
||||
|
||||
def test_keep_direction(self, test_paths):
|
||||
self.run_main('--keep-direction', *test_paths)
|
||||
|
||||
def test_conversion_error(self, test_paths):
|
||||
self.run_main('--conversion-error', 0.002, *test_paths)
|
||||
|
||||
def test_conversion_error_short(self, test_paths):
|
||||
self.run_main('-e', 0.003, test_paths[0])
|
324
fonttools-merge/ufo.py
Normal file
324
fonttools-merge/ufo.py
Normal file
@ -0,0 +1,324 @@
|
||||
# Copyright 2015 Google Inc. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
|
||||
"""Converts cubic bezier curves to quadratic splines.
|
||||
|
||||
Conversion is performed such that the quadratic splines keep the same end-curve
|
||||
tangents as the original cubics. The approach is iterative, increasing the
|
||||
number of segments for a spline until the error gets below a bound.
|
||||
|
||||
Respective curves from multiple fonts will be converted at once to ensure that
|
||||
the resulting splines are interpolation-compatible.
|
||||
"""
|
||||
|
||||
|
||||
from __future__ import print_function, division, absolute_import
|
||||
|
||||
import logging
|
||||
from fontTools.pens.basePen import AbstractPen
|
||||
from fontTools.pens.pointPen import PointToSegmentPen
|
||||
from fontTools.pens.reverseContourPen import ReverseContourPen
|
||||
|
||||
from cu2qu import curves_to_quadratic
|
||||
from cu2qu.errors import (
|
||||
UnequalZipLengthsError, IncompatibleSegmentNumberError,
|
||||
IncompatibleSegmentTypesError, IncompatibleGlyphsError,
|
||||
IncompatibleFontsError)
|
||||
|
||||
|
||||
__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__)
|
||||
|
||||
|
||||
_zip = zip
|
||||
def zip(*args):
|
||||
"""Ensure each argument to zip has the same length. Also make sure a list is
|
||||
returned for python 2/3 compatibility.
|
||||
"""
|
||||
|
||||
if len(set(len(a) for a in args)) != 1:
|
||||
raise UnequalZipLengthsError(*args)
|
||||
return list(_zip(*args))
|
||||
|
||||
|
||||
class GetSegmentsPen(AbstractPen):
|
||||
"""Pen to collect segments into lists of points for conversion.
|
||||
|
||||
Curves always include their initial on-curve point, so some points are
|
||||
duplicated between segments.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._last_pt = None
|
||||
self.segments = []
|
||||
|
||||
def _add_segment(self, tag, *args):
|
||||
if tag in ['move', 'line', 'qcurve', 'curve']:
|
||||
self._last_pt = args[-1]
|
||||
self.segments.append((tag, args))
|
||||
|
||||
def moveTo(self, pt):
|
||||
self._add_segment('move', pt)
|
||||
|
||||
def lineTo(self, pt):
|
||||
self._add_segment('line', pt)
|
||||
|
||||
def qCurveTo(self, *points):
|
||||
self._add_segment('qcurve', self._last_pt, *points)
|
||||
|
||||
def curveTo(self, *points):
|
||||
self._add_segment('curve', self._last_pt, *points)
|
||||
|
||||
def closePath(self):
|
||||
self._add_segment('close')
|
||||
|
||||
def endPath(self):
|
||||
self._add_segment('end')
|
||||
|
||||
def addComponent(self, glyphName, transformation):
|
||||
pass
|
||||
|
||||
|
||||
def _get_segments(glyph):
|
||||
"""Get a glyph's segments as extracted by GetSegmentsPen."""
|
||||
|
||||
pen = GetSegmentsPen()
|
||||
# glyph.draw(pen)
|
||||
# We can't simply draw the glyph with the pen, but we must initialize the
|
||||
# PointToSegmentPen explicitly with outputImpliedClosingLine=True.
|
||||
# By default PointToSegmentPen does not outputImpliedClosingLine -- unless
|
||||
# last and first point on closed contour are duplicated. Because we are
|
||||
# converting multiple glyphs at the same time, we want to make sure
|
||||
# this function returns the same number of segments, whether or not
|
||||
# the last and first point overlap.
|
||||
# https://github.com/googlefonts/fontmake/issues/572
|
||||
# https://github.com/fonttools/fonttools/pull/1720
|
||||
pointPen = PointToSegmentPen(pen, outputImpliedClosingLine=True)
|
||||
glyph.drawPoints(pointPen)
|
||||
return pen.segments
|
||||
|
||||
|
||||
def _set_segments(glyph, segments, reverse_direction):
|
||||
"""Draw segments as extracted by GetSegmentsPen back to a glyph."""
|
||||
|
||||
glyph.clearContours()
|
||||
pen = glyph.getPen()
|
||||
if reverse_direction:
|
||||
pen = ReverseContourPen(pen)
|
||||
for tag, args in segments:
|
||||
if tag == 'move':
|
||||
pen.moveTo(*args)
|
||||
elif tag == 'line':
|
||||
pen.lineTo(*args)
|
||||
elif tag == 'curve':
|
||||
pen.curveTo(*args[1:])
|
||||
elif tag == 'qcurve':
|
||||
pen.qCurveTo(*args[1:])
|
||||
elif tag == 'close':
|
||||
pen.closePath()
|
||||
elif tag == 'end':
|
||||
pen.endPath()
|
||||
else:
|
||||
raise AssertionError('Unhandled segment type "%s"' % tag)
|
||||
|
||||
|
||||
def _segments_to_quadratic(segments, max_err, stats):
|
||||
"""Return quadratic approximations of cubic segments."""
|
||||
|
||||
assert all(s[0] == 'curve' for s in segments), 'Non-cubic given to convert'
|
||||
|
||||
new_points = curves_to_quadratic([s[1] for s in segments], max_err)
|
||||
n = len(new_points[0])
|
||||
assert all(len(s) == n for s in new_points[1:]), 'Converted incompatibly'
|
||||
|
||||
spline_length = str(n - 2)
|
||||
stats[spline_length] = stats.get(spline_length, 0) + 1
|
||||
|
||||
return [('qcurve', p) for p in new_points]
|
||||
|
||||
|
||||
def _glyphs_to_quadratic(glyphs, max_err, reverse_direction, stats):
|
||||
"""Do the actual conversion of a set of compatible glyphs, after arguments
|
||||
have been set up.
|
||||
|
||||
Return True if the glyphs were modified, else return False.
|
||||
"""
|
||||
|
||||
try:
|
||||
segments_by_location = zip(*[_get_segments(g) for g in glyphs])
|
||||
except UnequalZipLengthsError:
|
||||
raise IncompatibleSegmentNumberError(glyphs)
|
||||
if not any(segments_by_location):
|
||||
return False
|
||||
|
||||
# always modify input glyphs if reverse_direction is True
|
||||
glyphs_modified = reverse_direction
|
||||
|
||||
new_segments_by_location = []
|
||||
incompatible = {}
|
||||
for i, segments in enumerate(segments_by_location):
|
||||
tag = segments[0][0]
|
||||
if not all(s[0] == tag for s in segments[1:]):
|
||||
incompatible[i] = [s[0] for s in segments]
|
||||
elif tag == 'curve':
|
||||
segments = _segments_to_quadratic(segments, max_err, stats)
|
||||
glyphs_modified = True
|
||||
new_segments_by_location.append(segments)
|
||||
|
||||
if glyphs_modified:
|
||||
new_segments_by_glyph = zip(*new_segments_by_location)
|
||||
for glyph, new_segments in zip(glyphs, new_segments_by_glyph):
|
||||
_set_segments(glyph, new_segments, reverse_direction)
|
||||
|
||||
if incompatible:
|
||||
raise IncompatibleSegmentTypesError(glyphs, segments=incompatible)
|
||||
return glyphs_modified
|
||||
|
||||
|
||||
def glyphs_to_quadratic(
|
||||
glyphs, max_err=None, reverse_direction=False, stats=None):
|
||||
"""Convert the curves of a set of compatible of glyphs to quadratic.
|
||||
|
||||
All curves will be converted to quadratic at once, ensuring interpolation
|
||||
compatibility. If this is not required, calling glyphs_to_quadratic with one
|
||||
glyph at a time may yield slightly more optimized results.
|
||||
|
||||
Return True if glyphs were modified, else return False.
|
||||
|
||||
Raises IncompatibleGlyphsError if glyphs have non-interpolatable outlines.
|
||||
"""
|
||||
if stats is None:
|
||||
stats = {}
|
||||
|
||||
if not max_err:
|
||||
# assume 1000 is the default UPEM
|
||||
max_err = DEFAULT_MAX_ERR * 1000
|
||||
|
||||
if isinstance(max_err, (list, tuple)):
|
||||
max_errors = max_err
|
||||
else:
|
||||
max_errors = [max_err] * len(glyphs)
|
||||
assert len(max_errors) == len(glyphs)
|
||||
|
||||
return _glyphs_to_quadratic(glyphs, max_errors, reverse_direction, stats)
|
||||
|
||||
|
||||
def fonts_to_quadratic(
|
||||
fonts, max_err_em=None, max_err=None, reverse_direction=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
|
||||
compatibility. If this is not required, calling fonts_to_quadratic with one
|
||||
font at a time may yield slightly more optimized results.
|
||||
|
||||
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 = {}
|
||||
|
||||
if max_err_em and max_err:
|
||||
raise TypeError('Only one of max_err and max_err_em can be specified.')
|
||||
if not (max_err_em or max_err):
|
||||
max_err_em = DEFAULT_MAX_ERR
|
||||
|
||||
if isinstance(max_err, (list, tuple)):
|
||||
assert len(max_err) == len(fonts)
|
||||
max_errors = max_err
|
||||
elif max_err:
|
||||
max_errors = [max_err] * len(fonts)
|
||||
|
||||
if isinstance(max_err_em, (list, tuple)):
|
||||
assert len(fonts) == len(max_err_em)
|
||||
max_errors = [f.info.unitsPerEm * e
|
||||
for f, e in zip(fonts, max_err_em)]
|
||||
elif max_err_em:
|
||||
max_errors = [f.info.unitsPerEm * max_err_em for f in fonts]
|
||||
|
||||
modified = False
|
||||
glyph_errors = {}
|
||||
for name in set().union(*(f.keys() for f in fonts)):
|
||||
glyphs = []
|
||||
cur_max_errors = []
|
||||
for font, error in zip(fonts, max_errors):
|
||||
if name in font:
|
||||
glyphs.append(font[name])
|
||||
cur_max_errors.append(error)
|
||||
try:
|
||||
modified |= _glyphs_to_quadratic(
|
||||
glyphs, cur_max_errors, reverse_direction, stats)
|
||||
except IncompatibleGlyphsError as exc:
|
||||
logger.error(exc)
|
||||
glyph_errors[name] = exc
|
||||
|
||||
if glyph_errors:
|
||||
raise IncompatibleFontsError(glyph_errors)
|
||||
|
||||
if modified and dump_stats:
|
||||
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
|
||||
|
||||
|
||||
def glyph_to_quadratic(glyph, **kwargs):
|
||||
"""Convenience wrapper around glyphs_to_quadratic, for just one glyph.
|
||||
Return True if the glyph was modified, else return False.
|
||||
"""
|
||||
|
||||
return glyphs_to_quadratic([glyph], **kwargs)
|
||||
|
||||
|
||||
def font_to_quadratic(font, **kwargs):
|
||||
"""Convenience wrapper around fonts_to_quadratic, for just one font.
|
||||
Return True if the font was modified, else return False.
|
||||
"""
|
||||
|
||||
return fonts_to_quadratic([font], **kwargs)
|
285
fonttools-merge/ufo_test.py
Normal file
285
fonttools-merge/ufo_test.py
Normal file
@ -0,0 +1,285 @@
|
||||
from __future__ import print_function, division, absolute_import
|
||||
import os
|
||||
|
||||
from fontTools.misc.loggingTools import CapturingLogHandler
|
||||
from defcon import Font, Glyph
|
||||
from cu2qu.ufo import (
|
||||
fonts_to_quadratic,
|
||||
font_to_quadratic,
|
||||
glyphs_to_quadratic,
|
||||
glyph_to_quadratic,
|
||||
logger,
|
||||
CURVE_TYPE_LIB_KEY,
|
||||
)
|
||||
from cu2qu.errors import (
|
||||
IncompatibleSegmentNumberError,
|
||||
IncompatibleSegmentTypesError,
|
||||
IncompatibleFontsError,
|
||||
)
|
||||
|
||||
from . import DATADIR
|
||||
import pytest
|
||||
|
||||
|
||||
TEST_UFOS = [
|
||||
os.path.join(DATADIR, "RobotoSubset-Regular.ufo"),
|
||||
os.path.join(DATADIR, "RobotoSubset-Bold.ufo"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fonts():
|
||||
return [Font(ufo) for ufo in TEST_UFOS]
|
||||
|
||||
|
||||
class FontsToQuadraticTest(object):
|
||||
|
||||
def test_modified(self, fonts):
|
||||
modified = fonts_to_quadratic(fonts)
|
||||
assert modified
|
||||
|
||||
def test_stats(self, fonts):
|
||||
stats = {}
|
||||
fonts_to_quadratic(fonts, stats=stats)
|
||||
assert stats == {'1': 1, '2': 79, '3': 130, '4': 2}
|
||||
|
||||
def test_dump_stats(self, fonts):
|
||||
with CapturingLogHandler(logger, "INFO") as captor:
|
||||
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]
|
||||
assert 'a' in fonts[1]
|
||||
assert fonts_to_quadratic(fonts)
|
||||
|
||||
def test_max_err_em_float(self, fonts):
|
||||
stats = {}
|
||||
fonts_to_quadratic(fonts, max_err_em=0.002, stats=stats)
|
||||
assert stats == {'1': 5, '2': 193, '3': 14}
|
||||
|
||||
def test_max_err_em_list(self, fonts):
|
||||
stats = {}
|
||||
fonts_to_quadratic(fonts, max_err_em=[0.002, 0.002], stats=stats)
|
||||
assert stats == {'1': 5, '2': 193, '3': 14}
|
||||
|
||||
def test_max_err_float(self, fonts):
|
||||
stats = {}
|
||||
fonts_to_quadratic(fonts, max_err=4.096, stats=stats)
|
||||
assert stats == {'1': 5, '2': 193, '3': 14}
|
||||
|
||||
def test_max_err_list(self, fonts):
|
||||
stats = {}
|
||||
fonts_to_quadratic(fonts, max_err=[4.096, 4.096], stats=stats)
|
||||
assert stats == {'1': 5, '2': 193, '3': 14}
|
||||
|
||||
def test_both_max_err_and_max_err_em(self, fonts):
|
||||
with pytest.raises(TypeError, match="Only one .* can be specified"):
|
||||
fonts_to_quadratic(fonts, max_err=1.000, max_err_em=0.001)
|
||||
|
||||
def test_single_font(self, fonts):
|
||||
assert font_to_quadratic(fonts[0], max_err_em=0.002,
|
||||
reverse_direction=True)
|
||||
|
||||
|
||||
class GlyphsToQuadraticTest(object):
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
["glyph", "expected"],
|
||||
[('A', False), # contains no curves, it is not modified
|
||||
('a', True)],
|
||||
ids=['lines-only', 'has-curves']
|
||||
)
|
||||
def test_modified(self, fonts, glyph, expected):
|
||||
glyphs = [f[glyph] for f in fonts]
|
||||
assert glyphs_to_quadratic(glyphs) == expected
|
||||
|
||||
def test_stats(self, fonts):
|
||||
stats = {}
|
||||
glyphs_to_quadratic([f['a'] for f in fonts], stats=stats)
|
||||
assert stats == {'2': 1, '3': 7, '4': 3, '5': 1}
|
||||
|
||||
def test_max_err_float(self, fonts):
|
||||
glyphs = [f['a'] for f in fonts]
|
||||
stats = {}
|
||||
glyphs_to_quadratic(glyphs, max_err=4.096, stats=stats)
|
||||
assert stats == {'2': 11, '3': 1}
|
||||
|
||||
def test_max_err_list(self, fonts):
|
||||
glyphs = [f['a'] for f in fonts]
|
||||
stats = {}
|
||||
glyphs_to_quadratic(glyphs, max_err=[4.096, 4.096], stats=stats)
|
||||
assert stats == {'2': 11, '3': 1}
|
||||
|
||||
def test_reverse_direction(self, fonts):
|
||||
glyphs = [f['A'] for f in fonts]
|
||||
assert glyphs_to_quadratic(glyphs, reverse_direction=True)
|
||||
|
||||
def test_single_glyph(self, fonts):
|
||||
assert glyph_to_quadratic(fonts[0]['a'], max_err=4.096,
|
||||
reverse_direction=True)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
["outlines", "exception", "message"],
|
||||
[
|
||||
[
|
||||
[
|
||||
[
|
||||
('moveTo', ((0, 0),)),
|
||||
('curveTo', ((1, 1), (2, 2), (3, 3))),
|
||||
('curveTo', ((4, 4), (5, 5), (6, 6))),
|
||||
('closePath', ()),
|
||||
],
|
||||
[
|
||||
('moveTo', ((7, 7),)),
|
||||
('curveTo', ((8, 8), (9, 9), (10, 10))),
|
||||
('closePath', ()),
|
||||
]
|
||||
],
|
||||
IncompatibleSegmentNumberError,
|
||||
"have different number of segments",
|
||||
],
|
||||
[
|
||||
[
|
||||
|
||||
[
|
||||
('moveTo', ((0, 0),)),
|
||||
('curveTo', ((1, 1), (2, 2), (3, 3))),
|
||||
('closePath', ()),
|
||||
],
|
||||
[
|
||||
('moveTo', ((4, 4),)),
|
||||
('lineTo', ((5, 5),)),
|
||||
('closePath', ()),
|
||||
],
|
||||
],
|
||||
IncompatibleSegmentTypesError,
|
||||
"have incompatible segment types",
|
||||
],
|
||||
],
|
||||
ids=[
|
||||
"unequal-length",
|
||||
"different-segment-types",
|
||||
]
|
||||
)
|
||||
def test_incompatible_glyphs(self, outlines, exception, message):
|
||||
glyphs = []
|
||||
for i, outline in enumerate(outlines):
|
||||
glyph = Glyph()
|
||||
glyph.name = "glyph%d" % i
|
||||
pen = glyph.getPen()
|
||||
for operator, args in outline:
|
||||
getattr(pen, operator)(*args)
|
||||
glyphs.append(glyph)
|
||||
with pytest.raises(exception) as excinfo:
|
||||
glyphs_to_quadratic(glyphs)
|
||||
assert excinfo.match(message)
|
||||
|
||||
def test_incompatible_fonts(self):
|
||||
font1 = Font()
|
||||
font1.info.unitsPerEm = 1000
|
||||
glyph1 = font1.newGlyph("a")
|
||||
pen1 = glyph1.getPen()
|
||||
for operator, args in [("moveTo", ((0, 0),)),
|
||||
("lineTo", ((1, 1),)),
|
||||
("endPath", ())]:
|
||||
getattr(pen1, operator)(*args)
|
||||
|
||||
font2 = Font()
|
||||
font2.info.unitsPerEm = 1000
|
||||
glyph2 = font2.newGlyph("a")
|
||||
pen2 = glyph2.getPen()
|
||||
for operator, args in [("moveTo", ((0, 0),)),
|
||||
("curveTo", ((1, 1), (2, 2), (3, 3))),
|
||||
("endPath", ())]:
|
||||
getattr(pen2, operator)(*args)
|
||||
|
||||
with pytest.raises(IncompatibleFontsError) as excinfo:
|
||||
fonts_to_quadratic([font1, font2])
|
||||
assert excinfo.match("fonts contains incompatible glyphs: 'a'")
|
||||
|
||||
assert hasattr(excinfo.value, "glyph_errors")
|
||||
error = excinfo.value.glyph_errors['a']
|
||||
assert isinstance(error, IncompatibleSegmentTypesError)
|
||||
assert error.segments == {1: ["line", "curve"]}
|
||||
|
||||
def test_already_quadratic(self):
|
||||
glyph = Glyph()
|
||||
pen = glyph.getPen()
|
||||
pen.moveTo((0, 0))
|
||||
pen.qCurveTo((1, 1), (2, 2))
|
||||
pen.closePath()
|
||||
assert not glyph_to_quadratic(glyph)
|
||||
|
||||
def test_open_paths(self):
|
||||
glyph = Glyph()
|
||||
pen = glyph.getPen()
|
||||
pen.moveTo((0, 0))
|
||||
pen.lineTo((1, 1))
|
||||
pen.curveTo((2, 2), (3, 3), (4, 4))
|
||||
pen.endPath()
|
||||
assert glyph_to_quadratic(glyph)
|
||||
# open contour is still open
|
||||
assert glyph[-1][0].segmentType == "move"
|
||||
|
||||
def test_ignore_components(self):
|
||||
glyph = Glyph()
|
||||
pen = glyph.getPen()
|
||||
pen.addComponent('a', (1, 0, 0, 1, 0, 0))
|
||||
pen.moveTo((0, 0))
|
||||
pen.curveTo((1, 1), (2, 2), (3, 3))
|
||||
pen.closePath()
|
||||
assert glyph_to_quadratic(glyph)
|
||||
assert len(glyph.components) == 1
|
||||
|
||||
def test_overlapping_start_end_points(self):
|
||||
# https://github.com/googlefonts/fontmake/issues/572
|
||||
glyph1 = Glyph()
|
||||
pen = glyph1.getPointPen()
|
||||
pen.beginPath()
|
||||
pen.addPoint((0, 651), segmentType="line")
|
||||
pen.addPoint((0, 101), segmentType="line")
|
||||
pen.addPoint((0, 101), segmentType="line")
|
||||
pen.addPoint((0, 651), segmentType="line")
|
||||
pen.endPath()
|
||||
|
||||
glyph2 = Glyph()
|
||||
pen = glyph2.getPointPen()
|
||||
pen.beginPath()
|
||||
pen.addPoint((1, 651), segmentType="line")
|
||||
pen.addPoint((2, 101), segmentType="line")
|
||||
pen.addPoint((3, 101), segmentType="line")
|
||||
pen.addPoint((4, 651), segmentType="line")
|
||||
pen.endPath()
|
||||
|
||||
glyphs = [glyph1, glyph2]
|
||||
|
||||
assert glyphs_to_quadratic(glyphs, reverse_direction=True)
|
||||
|
||||
assert [[(p.x, p.y) for p in glyph[0]] for glyph in glyphs] == [
|
||||
[
|
||||
(0, 651),
|
||||
(0, 651),
|
||||
(0, 101),
|
||||
(0, 101),
|
||||
],
|
||||
[
|
||||
(1, 651),
|
||||
(4, 651),
|
||||
(3, 101),
|
||||
(2, 101)
|
||||
],
|
||||
]
|
Loading…
x
Reference in New Issue
Block a user