Revert "Remove all top-level cu2qu files that conflict with fonttools merge"

This reverts commit 09257ab57b03cef5c73557ece77ca048db2fc78d.
This commit is contained in:
Cosimo Lupo 2020-04-01 18:41:29 +01:00
parent 09257ab57b
commit 75c14ae7e9
No known key found for this signature in database
GPG Key ID: 179A8F0895A02F4F
23 changed files with 1859 additions and 0 deletions

5
.codecov.yml Normal file
View File

@ -0,0 +1,5 @@
comment: false
coverage:
status:
project: off
patch: off

35
.coveragerc Normal file
View File

@ -0,0 +1,35 @@
[run]
# measure 'branch' coverage in addition to 'statement' coverage
# See: http://coverage.readthedocs.org/en/coverage-4.0.3/branch.html#branch
branch = True
# list of directories or packages to measure
source = cu2qu
# this is simply vendored, no need to include in coverage report
omit =
*/cu2qu/cython.py
# these are treated as equivalent when combining data
[paths]
source =
Lib/cu2qu
.tox/*/lib/python*/site-packages/cu2qu
.tox/pypy*/site-packages/cu2qu
[report]
# Regexes for lines to exclude from consideration
exclude_lines =
# keywords to use in inline comments to skip coverage
pragma: no cover
# don't complain if tests don't hit defensive assertion code
raise AssertionError
raise NotImplementedError
# don't complain if non-runnable code isn't run
if 0:
if __name__ == .__main__.:
# ignore source code that cant be found
ignore_errors = True

29
.gitignore vendored Normal file
View File

@ -0,0 +1,29 @@
# Byte-compiled and optimized files
__pycache__/
*.py[cod]
*$py.class
*.so
# cython generated C/HTML files
Lib/cu2qu/*.c
Lib/cu2qu/*.html
# Packaging
*.egg-info
*.eggs
build
dist
# Unit test and coverage files
.cache
.coverage
.coverage.*
.tox
htmlcov
.pytest_cache/
# OS X Finder
.DS_Store
# auto-generated version file
Lib/cu2qu/_version.py

5
.pyup.yml Normal file
View File

@ -0,0 +1,5 @@
# controls the frequency of updates (undocumented beta feature)
schedule: every week
# do not pin dependencies unless they have explicit version specifiers
pin: False

37
.travis.yml Normal file
View File

@ -0,0 +1,37 @@
sudo: false
language: python
env:
global:
- TWINE_USERNAME="anthrotype"
- secure: uIlWYz63F0Y/pDZawW2mS5DolWghdIodM8VtJtGbyIYB3fL3/edwTG5z+FWKaNeRtNfTAGDMs0y2dF45IJ7ZqTzw4yotgHz0uzLqjGjQ1/MOu99tppoXA6wQocZvmrdZpUcRbvBzJQzpAUcjldPLAP200mV9cG8+LfODn8Di2eJ329Ts3aA140pjUF8791jRLHBhUTpxK5RDPn1Q7OlugjryS7yNVIfT1/DaNDXAu4OZ8oNkygioRcyZ9QiFLjv5yBZ7uHB4UXWxw89RYPyz4NfHwyzDR38X/A6vfP19w2V0kecAK5BVBUE+WI7d26XjQzxDuH6Z0phB3x6MFuCXrX/pdrvNr7hs5kTAdQ7R1YA6MH4lPa+7oXha1/j353StzDMUKByXGVHyLAv7Ct2RSXOHUM6hAB4T+JbyJp/YkPWh968GKpl1lwvziKTi7K1qpngbXCdYIYKJ78IbmDcxzmQ+3j+fsXt9+gArZW7ICLgWrs+Lr1FiJsBOKLmqigOSmqrjHa+ef+wjieFSgzCVIfr9zvibzCEtYeqkuJuDQcSBIS0JG/heOfBGQ6FIxSXzwvICyWpldmfP67nBjVOVzPcyEAT8w+45LOM0HPCm6+Xjn7mKstc7x9TD7dVjWeyKJzZuKSmuAFA4UtGGKDQ93YQlAY2SCW4irYsusj80LHU=
matrix:
include:
- env: TOXENV=py27-nocy
python: 2.7
- env: TOXENV=py36-nocy
python: 3.6
- env: TOXENV=py27-cy
python: 2.7
- env:
- TOXENV=py36-cy
- BUILD_DIST=true
python: 3.6
branches:
only:
- master
- /^v\d+\.\d+.*$/
install: pip install tox
script: tox
after_success:
- if [ -z "$TRAVIS_TAG" ]; then tox -e codecov; fi
# deploy to PyPI on tags
- |
if [ -n "$TRAVIS_TAG" ] && [ "$TRAVIS_REPO_SLUG" == "googlefonts/cu2qu" ] && [ "$BUILD_DIST" == true ]; then
tox -e pypi
fi

28
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,28 @@
Want to contribute? Great! First, read this page (including the small print
at the end).
### Before you contribute
Before we can use your code, you must sign the
[Google Individual Contributor License
Agreement](https://cla.developers.google.com/about/google-individual)
(CLA), which you can do online. The CLA is necessary mainly because you own the
copyright to your changes, even after your contribution becomes part of our
codebase, so we need your permission to use and distribute your code. We also
need to be sure of various other things—for instance that you'll tell us if you
know that your code infringes on other people's patents. You don't have to sign
the CLA until after you've submitted your code for review and a member has
approved it, but you must do it before we can put your code into our codebase.
Before you start working on a larger contribution, you should get in touch with
us first through the issue tracker with your idea so that we can help out and
possibly guide you. Coordinating up front makes it much easier to avoid
frustration later on.
### Code reviews
All submissions, including submissions by project members, require review. We
use Github pull requests for this purpose.
### The small print
Contributions made by corporations are covered by a different agreement than
the one above, the
[Software Grant and Corporate Contributor License
Agreement](https://cla.developers.google.com/about/google-corporate).

201
LICENSE Normal file
View File

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
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.

6
Lib/cu2qu/__main__.py Normal file
View File

@ -0,0 +1,6 @@
import sys
from cu2qu.cli import main
if __name__ == "__main__":
sys.exit(main())

161
Lib/cu2qu/cli.py Normal file
View 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)

324
Lib/cu2qu/ufo.py Normal file
View 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)

12
MANIFEST.in Normal file
View File

@ -0,0 +1,12 @@
include LICENSE
include README.rst
include CONTRIBUTING.md
include requirements.txt
include test-requirements.txt
include tox.ini
include .coveragerc
recursive-include tests *.py
recursive-include tests/data *.json
recursive-include tests/data */*.glif
recursive-include tests/data */*.plist */*/*.glif */*/*.plist
recursive-include tools *.py

122
README.rst Normal file
View File

@ -0,0 +1,122 @@
|Build Status| |PyPI Version| |Coverage|
cu2qu
=====
This library provides functions which take in UFO objects (Defcon Fonts
or Robofab RFonts) and converts any cubic curves to quadratic. The most
useful function is probably ``fonts_to_quadratic``:
.. code:: python
from defcon import Font
from cu2qu.ufo import fonts_to_quadratic
thin_font = Font('MyFont-Thin.ufo')
bold_font = Font('MyFont-Bold.ufo')
fonts_to_quadratic([thin_font, bold_font])
Interpolation compatibility is guaranteed during conversion. If it's not
needed, converting one font at a time may yield more optimized results:
.. code:: python
for font in [thin_font, bold_font]:
fonts_to_quadratic([font])
Some fonts may need a different error threshold than the default (0.001
em). This can also be provided by the caller:
.. code:: python
fonts_to_quadratic([thin_font, bold_font], max_err_em=0.005)
.. code:: python
for font in [thin_font, bold_font]:
fonts_to_quadratic([font], max_err_em=0.001)
``fonts_to_quadratic`` can print a string reporting the number of curves
of each length. For example
``fonts_to_quadratic([font], dump_stats=True)`` may print something
like:
::
3: 1000
4: 2000
5: 100
meaning that the font now contains 1000 curves with three points, 2000
with four points, and 100 with five. Given multiple fonts, the function
will report the total counts across all fonts. You can also accumulate
statistics between calls by providing your own report dictionary:
.. code:: python
stats = {}
for font in [thin_font, bold_font]:
fonts_to_quadratic([font], stats=stats)
# "stats" will report combined statistics for both fonts
The library also provides a command-line script also named ``cu2qu``.
Check its ``--help`` to see all the options.
Installation
------------
You can install/upgrade cu2qu using pip, like any other Python package.
.. code:: sh
$ pip install --upgrade cu2qu
This will download the latest stable version available from the Python
Package Index (PyPI).
If you wish to modify the sources in-place, you can clone the git repository
from Github and install in ``--editable`` (or ``-e``) mode:
.. code:: sh
$ git clone https://github.com/googlefonts/cu2qu
$ cd cu2qu
$ pip install --editable .
Optionally, you can build an optimized version of cu2qu which uses Cython_
to compile Python to C. The extension module thus created is *more than
twice as fast* than its pure-Python equivalent.
When installing cu2qu from PyPI using pip, as long as you have a C compiler
available, the cu2qu setup script will automatically attempt to build a
C/Python extension module. If the compilation fails for any reasons, an error
is printed and cu2qu will be installed as pure-Python, without the optimized
extension.
If you have cloned the git repository, the C source files are not present and
need to be regenerated. To do that, you need to install the latest Cython
(as usual, ``pip install -U cython``), and then use the global option
``--with-cython`` when invoking the ``setup.py`` script. You can also export
a ``CU2QU_WITH_CYTHON=1`` environment variable if you prefer.
For example, to build the cu2qu extension module in-place (i.e. in the same
source directory):
.. code:: sh
$ python setup.py --with-cython build_ext --inplace
You can also pass ``--global-option`` when installing with pip from a local
source checkout, like so:
.. code:: sh
$ pip install --global-option="--with-cython" -e .
.. _Cython: https://github.com/cython/cython
.. |Build Status| image:: https://travis-ci.org/googlefonts/cu2qu.svg
:target: https://travis-ci.org/googlefonts/cu2qu
.. |PyPI Version| image:: https://img.shields.io/pypi/v/cu2qu.svg
:target: https://pypi.org/project/cu2qu/
.. |Coverage| image:: https://codecov.io/gh/googlefonts/cu2qu/branch/master/graph/badge.svg
:target: https://codecov.io/gh/googlefonts/cu2qu

8
pyproject.toml Normal file
View File

@ -0,0 +1,8 @@
[build-system]
requires = [
"setuptools",
"wheel",
"setuptools_scm",
"cython",
]
build-backend = "setuptools.build_meta"

2
requirements.txt Normal file
View File

@ -0,0 +1,2 @@
fonttools[ufo]==3.32.0
defcon==0.6.0

28
setup.cfg Normal file
View File

@ -0,0 +1,28 @@
[bdist_wheel]
universal = 1
[sdist]
formats = zip
[aliases]
test = pytest
[metadata]
license_file = LICENSE
[tool:pytest]
minversion = 3.0
testpaths =
tests
python_files =
*_test.py
python_classes =
*Test
addopts =
-s
-v
-r a
--doctest-modules
--doctest-ignore-import-errors
filterwarnings:
ignore:.*bytes:DeprecationWarning:fs.base

241
setup.py Normal file
View File

@ -0,0 +1,241 @@
# 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.
from setuptools import setup, find_packages, Extension
from setuptools.command.build_ext import build_ext as _build_ext
from setuptools.command.sdist import sdist as _sdist
import pkg_resources
from distutils import log
import sys
import os
import re
from io import open
needs_pytest = {'pytest', 'test'}.intersection(sys.argv)
pytest_runner = ['pytest_runner'] if needs_pytest else []
needs_wheel = {'bdist_wheel'}.intersection(sys.argv)
wheel = ['wheel'] if needs_wheel else []
# Check if minimum required Cython is available.
# For consistency, we require the same as our vendored Cython.Shadow module
cymod = "Lib/cu2qu/cython.py"
cython_version_re = re.compile('__version__ = ["\']([0-9][0-9\w\.]+)["\']')
with open(cymod, "r", encoding="utf-8") as fp:
for line in fp:
m = cython_version_re.match(line)
if m:
cython_min_version = m.group(1)
break
else:
sys.exit("error: failed to parse cython version in '%s'" % cymod)
required_cython = "cython >= %s" % cython_min_version
try:
pkg_resources.require(required_cython)
except pkg_resources.ResolutionError:
has_cython = False
else:
has_cython = True
# First, check if the CU2QU_WITH_CYTHON environment variable is set.
# Values "1", "true" or "yes" mean that Cython is required and will be used
# to regenerate the *.c sources from which the native extension is built;
# "0", "false" or "no" mean that Cython is not required and no extension
# module will be compiled (i.e. the wheel is pure-python and universal).
# If the variable is not set, then the pre-generated *.c sources that
# are included in the sdist package will be used to try build the extension.
# However, if any error occurs during compilation (e.g. the host
# machine doesn't have the required compiler toolchain installed), the
# installation proceeds without the compiled extensions, but will only have
# the pure-python module.
env_with_cython = os.environ.get("CU2QU_WITH_CYTHON")
with_cython = (
True if env_with_cython in {"1", "true", "yes"}
else False if env_with_cython in {"0", "false", "no"}
else None
)
# command line options --with-cython and --without-cython are also supported.
# They override the environment variable
opt_with_cython = {'--with-cython'}.intersection(sys.argv)
opt_without_cython = {'--without-cython'}.intersection(sys.argv)
if opt_with_cython and opt_without_cython:
sys.exit(
"error: the options '--with-cython' and '--without-cython' are "
"mutually exclusive"
)
elif opt_with_cython:
sys.argv.remove("--with-cython")
with_cython = True
elif opt_without_cython:
sys.argv.remove("--without-cython")
with_cython = False
class cython_build_ext(_build_ext):
"""Compile *.pyx source files to *.c using cythonize if Cython is
installed, else use the pre-generated *.c sources.
"""
def finalize_options(self):
if with_cython:
if not has_cython:
from distutils.errors import DistutilsSetupError
raise DistutilsSetupError(
"%s is required when using --with-cython" % required_cython
)
from Cython.Build import cythonize
# optionally enable line tracing for test coverage support
linetrace = os.environ.get("CYTHON_TRACE") == "1"
self.distribution.ext_modules[:] = cythonize(
self.distribution.ext_modules,
force=linetrace or self.force,
annotate=os.environ.get("CYTHON_ANNOTATE") == "1",
quiet=not self.verbose,
compiler_directives={
"linetrace": linetrace,
"language_level": 3,
"embedsignature": True,
},
)
else:
# replace *.py/.pyx sources with their pre-generated *.c versions
for ext in self.distribution.ext_modules:
ext.sources = [re.sub("\.pyx?$", ".c", n) for n in ext.sources]
_build_ext.finalize_options(self)
def build_extensions(self):
if not has_cython:
log.info(
"%s is not installed. Pre-generated *.c sources will be "
"will be used to build the extensions." % required_cython
)
try:
_build_ext.build_extensions(self)
except Exception as e:
if with_cython:
raise
from distutils.errors import DistutilsModuleError
# optional compilation failed: we delete 'ext_modules' and make sure
# the generated wheel is 'pure'
del self.distribution.ext_modules[:]
try:
bdist_wheel = self.get_finalized_command("bdist_wheel")
except DistutilsModuleError:
# 'bdist_wheel' command not available as wheel is not installed
pass
else:
bdist_wheel.root_is_pure = True
log.error('error: building extensions failed: %s' % e)
def get_source_files(self):
filenames = _build_ext.get_source_files(self)
# include pre-generated *.c sources inside sdist, but only if cython is
# installed (and hence they will be updated upon making the sdist)
if has_cython:
for ext in self.extensions:
filenames.extend(
[re.sub("\.pyx?$", ".c", n) for n in ext.sources]
)
return filenames
class cython_sdist(_sdist):
""" Run 'cythonize' on *.pyx sources to ensure the *.c files included
in the source distribution are up-to-date.
"""
def run(self):
if with_cython and not has_cython:
from distutils.errors import DistutilsSetupError
raise DistutilsSetupError(
"%s is required when creating sdist --with-cython"
% required_cython
)
if has_cython:
from Cython.Build import cythonize
cythonize(
self.distribution.ext_modules,
force=True, # always regenerate *.c sources
quiet=not self.verbose,
compiler_directives={
"language_level": 3,
"embedsignature": True
},
)
_sdist.run(self)
# don't build extensions if user explicitly requested --without-cython
if with_cython is False:
extensions = []
else:
extensions = [
Extension("cu2qu.cu2qu", ["Lib/cu2qu/cu2qu.py"]),
]
with open('README.rst', 'r') as f:
long_description = f.read()
setup(
name='cu2qu',
use_scm_version={"write_to": "Lib/cu2qu/_version.py"},
description='Cubic-to-quadratic bezier curve conversion',
author="James Godfrey-Kittle, Behdad Esfahbod",
author_email="jamesgk@google.com",
url="https://github.com/googlefonts",
license="Apache License, Version 2.0",
long_description=long_description,
packages=find_packages('Lib'),
package_dir={'': 'Lib'},
ext_modules=extensions,
include_package_data=True,
setup_requires=pytest_runner + wheel + ["setuptools_scm"],
tests_require=[
'pytest>=2.8',
],
install_requires=[
"fonttools[ufo] >= 3.32.0",
],
extras_require={"cli": ["defcon >= 0.6.0"]},
entry_points={"console_scripts": ["cu2qu = cu2qu.cli:main [cli]"]},
classifiers=[
'Development Status :: 4 - Beta',
'Intended Audience :: Developers',
'License :: OSI Approved :: Apache Software License',
'Operating System :: OS Independent',
'Programming Language :: Python',
'Programming Language :: Python :: 2',
'Programming Language :: Python :: 3',
'Topic :: Scientific/Engineering :: Mathematics',
'Topic :: Multimedia :: Graphics :: Graphics Conversion',
'Topic :: Multimedia :: Graphics :: Editors :: Vector-Based',
'Topic :: Software Development :: Libraries :: Python Modules',
],
cmdclass={"build_ext": cython_build_ext, "sdist": cython_sdist},
)

2
test-requirements.txt Normal file
View File

@ -0,0 +1,2 @@
coverage
pytest

85
tests/cli_test.py Normal file
View 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])

285
tests/ufo_test.py Normal file
View 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)
],
]

72
tools/benchmark.py Normal file
View File

@ -0,0 +1,72 @@
# 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.
from __future__ import print_function, division, absolute_import
import random
import timeit
MAX_ERR = 5
SETUP_CODE = '''
from %(module)s import %(function)s
from %(benchmark_module)s import %(setup_function)s
args = %(setup_function)s()
'''
def generate_curve():
return [
tuple(float(random.randint(0, 2048)) for coord in range(2))
for point in range(4)]
def setup_curve_to_quadratic():
return generate_curve(), MAX_ERR
def setup_curves_to_quadratic():
num_curves = 3
return (
[generate_curve() for curve in range(num_curves)],
[MAX_ERR] * num_curves)
def run_benchmark(
benchmark_module, module, function, setup_suffix='', repeat=1000):
setup_func = 'setup_' + function
if setup_suffix:
print('%s with %s:' % (function, setup_suffix), end='')
setup_func += '_' + setup_suffix
else:
print('%s:' % function, end='')
results = timeit.repeat(
'%s(*args)' % function,
setup=(SETUP_CODE % {
'benchmark_module': benchmark_module, 'setup_function': setup_func,
'module': module, 'function': function}),
repeat=repeat, number=1)
print('\tavg=%dus' % (sum(results) / len(results) * 1000000.),
'\tmin=%dus' % (min(results) * 1000000.))
def main():
run_benchmark('benchmark', 'cu2qu', 'curve_to_quadratic')
run_benchmark('benchmark', 'cu2qu', 'curves_to_quadratic')
if __name__ == '__main__':
random.seed(1)
main()

42
tools/ufo_benchmark.py Normal file
View File

@ -0,0 +1,42 @@
# Copyright 2016 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.
from __future__ import print_function, division, absolute_import
import os
import random
from benchmark import run_benchmark
MAX_ERR_EM = 0.002
DATADIR = os.path.join(
os.path.dirname(__file__), os.path.pardir, 'tests', 'data')
def setup_fonts_to_quadratic_defcon():
from defcon import Font
return [[Font(os.path.join(DATADIR, 'RobotoSubset-Regular.ufo'))],
MAX_ERR_EM]
def main():
run_benchmark(
'ufo_benchmark', 'cu2qu.ufo', 'fonts_to_quadratic',
setup_suffix='defcon', repeat=10)
if __name__ == '__main__':
random.seed(1)
main()

View File

@ -0,0 +1,32 @@
""" Update the embedded Lib/cu2qu/cython.py module with the contents of
the latest cython repository.
Usage:
$ python tools/update_cython_shadow.py 0.28.5
"""
import requests
import sys
header = b'''\
""" This module is copied verbatim from the "Cython.Shadow" module:
https://github.com/cython/cython/blob/master/Cython/Shadow.py
Cython is licensed under the Apache 2.0 Software License.
"""
'''
try:
version = sys.argv[1]
except IndexError:
version = "master"
CYTHON_SHADOW_URL = (
"https://raw.githubusercontent.com/cython/cython/%s/Cython/Shadow.py"
) % version
r = requests.get(CYTHON_SHADOW_URL, allow_redirects=True)
with open("Lib/cu2qu/cython.py", "wb") as f:
f.write(header)
f.write(r.content)

97
tox.ini Normal file
View File

@ -0,0 +1,97 @@
[tox]
envlist = py{27,37}-{cy,nocy}, htmlcov
package_name = cu2qu
; we skip tox's own sdist generation as we need to pass different environment
; variables for testing buiding with and without cython
skipsdist = true
[testenv]
setenv =
nocy: CU2QU_WITH_CYTHON=0
cy: CU2QU_WITH_CYTHON=1
cy: CYTHON_TRACE=1
cy: CYTHON_ANNOTATE=1
; download the latest pip, setuptools and wheel when creating the venv
download = true
deps =
-rtest-requirements.txt
-rrequirements.txt
cy: cython
changedir = {toxinidir}
commands =
# create source distribution in a temp dir
python setup.py --quiet sdist --dist-dir {envtmpdir}
# install from sdist
python -m pip install --no-build-isolation --ignore-installed --pre --no-deps --no-cache-dir --find-links {envtmpdir} {[tox]package_name}
# ensure we are running the requested cu2qu version (compiled vs interpreted)
nocy: python -c "import sys, cu2qu.cu2qu; cu2qu.cu2qu.COMPILED and sys.exit(1)"
cy: python -c "import sys, cu2qu.cu2qu; cu2qu.cu2qu.COMPILED or sys.exit(1)"
# run tests with code coverage enabled
coverage run --parallel-mode -m pytest {posargs}
[testenv:htmlcov]
deps =
coverage
changedir = {toxinidir}
commands =
coverage combine
coverage report
coverage html
[testenv:codecov]
passenv = *
deps =
coverage
codecov
ignore_outcome = true
changedir = {toxinidir}
commands =
coverage combine
codecov --env TRAVIS_PYTHON_VERSION
[testenv:update-cython]
deps = requests
changedir = {toxinidir}
commands =
python tools/update_cython_shadow.py {posargs}
[testenv:sdist]
deps =
setuptools
cython
changedir = {toxinidir}
commands =
python -c 'import shutil; shutil.rmtree("dist", ignore_errors=True)'
python setup.py --with-cython sdist --dist-dir dist
[testenv:pure-wheel]
deps =
{[testenv:sdist]deps}
pip
wheel
setenv = CU2QU_WITH_CYTHON=0
changedir = {toxinidir}
commands =
{[testenv:sdist]commands}
pip wheel --pre --no-deps --no-cache-dir --wheel-dir dist --find-links dist \
--no-binary {[tox]package_name} {[tox]package_name}
[testenv:native-wheel]
deps = {[testenv:pure-wheel]deps}
setenv = CU2QU_WITH_CYTHON=1
changedir = {toxinidir}
commands = {[testenv:pure-wheel]commands}
; we only upload the source distribution to PyPI (for now)
[testenv:pypi]
deps =
{[testenv:sdist]deps}
twine
passenv = TWINE_USERNAME TWINE_PASSWORD
changedir = {toxinidir}
commands =
{[testenv:sdist]commands}
twine upload dist/*.zip