diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 000000000..5e7474d3c --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,5 @@ +comment: false +coverage: + status: + project: off + patch: off diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 000000000..6eed18d4e --- /dev/null +++ b/.coveragerc @@ -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 can’t be found +ignore_errors = True diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..37d4e1cff --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.pyup.yml b/.pyup.yml new file mode 100644 index 000000000..ed0ac860b --- /dev/null +++ b/.pyup.yml @@ -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 diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 000000000..e4e13f767 --- /dev/null +++ b/.travis.yml @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..200c99a3e --- /dev/null +++ b/CONTRIBUTING.md @@ -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). diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..261eeb9e9 --- /dev/null +++ b/LICENSE @@ -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. 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..5c1f44b41 --- /dev/null +++ b/Lib/cu2qu/cli.py @@ -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) diff --git a/Lib/cu2qu/ufo.py b/Lib/cu2qu/ufo.py new file mode 100644 index 000000000..0b5918738 --- /dev/null +++ b/Lib/cu2qu/ufo.py @@ -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) diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 000000000..b83310edc --- /dev/null +++ b/MANIFEST.in @@ -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 diff --git a/README.rst b/README.rst new file mode 100644 index 000000000..b3d3276c4 --- /dev/null +++ b/README.rst @@ -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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..a66397a43 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,8 @@ +[build-system] +requires = [ + "setuptools", + "wheel", + "setuptools_scm", + "cython", +] +build-backend = "setuptools.build_meta" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..19ae3dbc3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +fonttools[ufo]==3.32.0 +defcon==0.6.0 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 000000000..7ecc6d34d --- /dev/null +++ b/setup.cfg @@ -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 diff --git a/setup.py b/setup.py new file mode 100644 index 000000000..f7d684946 --- /dev/null +++ b/setup.py @@ -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}, +) diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 000000000..7093b61a3 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,2 @@ +coverage +pytest diff --git a/tests/cli_test.py b/tests/cli_test.py new file mode 100644 index 000000000..92620c51b --- /dev/null +++ b/tests/cli_test.py @@ -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]) diff --git a/tests/ufo_test.py b/tests/ufo_test.py new file mode 100644 index 000000000..ac69c7e3d --- /dev/null +++ b/tests/ufo_test.py @@ -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) + ], + ] diff --git a/tools/benchmark.py b/tools/benchmark.py new file mode 100644 index 000000000..bafb489e4 --- /dev/null +++ b/tools/benchmark.py @@ -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() diff --git a/tools/ufo_benchmark.py b/tools/ufo_benchmark.py new file mode 100644 index 000000000..a21a93c35 --- /dev/null +++ b/tools/ufo_benchmark.py @@ -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() diff --git a/tools/update_cython_shadow.py b/tools/update_cython_shadow.py new file mode 100644 index 000000000..61ecea0b4 --- /dev/null +++ b/tools/update_cython_shadow.py @@ -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) diff --git a/tox.ini b/tox.ini new file mode 100644 index 000000000..e58427d4e --- /dev/null +++ b/tox.ini @@ -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