├── requirements.txt ├── Lib └── fontPens │ ├── __init__.py │ ├── transformPointPen.py │ ├── recordingPointPen.py │ ├── printPointPen.py │ ├── printPen.py │ ├── thresholdPen.py │ ├── digestPointPen.py │ ├── guessSmoothPointPen.py │ ├── thresholdPointPen.py │ ├── angledMarginPen.py │ ├── spikePen.py │ ├── marginPen.py │ ├── penTools.py │ └── flattenPen.py ├── MANIFEST.in ├── dev-requirements.txt ├── pyproject.toml ├── .pyup.yml ├── .travis ├── run.sh ├── before_install.sh ├── after_success.sh ├── before_deploy.sh └── install.sh ├── .codecov.yml ├── .gitignore ├── README.rst ├── .coveragerc ├── setup.cfg ├── LICENSE.txt ├── tox.ini ├── .travis.yml └── setup.py /requirements.txt: -------------------------------------------------------------------------------- 1 | FontTools>=3.32.0 2 | -------------------------------------------------------------------------------- /Lib/fontPens/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.2.5.dev0" 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include LICENSE.txt 3 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | pytest>=3.0 2 | tox>=2.5 3 | fontParts>=0.8.4 4 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | -------------------------------------------------------------------------------- /.pyup.yml: -------------------------------------------------------------------------------- 1 | # autogenerated pyup.io config file 2 | # see https://pyup.io/docs/configuration/ for all available options 3 | 4 | schedule: every week 5 | update: false 6 | -------------------------------------------------------------------------------- /.travis/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | set -x 5 | 6 | if [ "$TRAVIS_OS_NAME" == "osx" ]; then 7 | source .venv/bin/activate 8 | fi 9 | 10 | tox 11 | -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | comment: 2 | layout: "diff, files" 3 | behavior: default 4 | require_changes: true 5 | coverage: 6 | status: 7 | project: off 8 | patch: off 9 | -------------------------------------------------------------------------------- /.travis/before_install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [[ -n "$PYENV_VERSION" ]]; then 4 | wget https://github.com/praekeltfoundation/travis-pyenv/releases/download/${TRAVIS_PYENV_VERSION}/setup-pyenv.sh 5 | source setup-pyenv.sh 6 | fi 7 | -------------------------------------------------------------------------------- /.travis/after_success.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | set -x 5 | 6 | if [ "$TRAVIS_OS_NAME" == "osx" ]; then 7 | source .venv/bin/activate 8 | fi 9 | 10 | # upload coverage data to Codecov.io 11 | [[ ${TOXENV} == *"-cov"* ]] && tox -e codecov 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized files 2 | __pycache__/ 3 | *.py[co] 4 | *$py.class 5 | 6 | .cache 7 | .eggs 8 | .tox 9 | .coverage 10 | .coverage.* 11 | .pytest_cache 12 | htmlcov 13 | 14 | # OSX Finder 15 | .DS_Store 16 | 17 | # directories created during build/install process 18 | build/ 19 | dist/ 20 | Lib/fontPens.egg-info/ 21 | 22 | # PyCharm's directory 23 | .idea/ 24 | -------------------------------------------------------------------------------- /.travis/before_deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | set -x 5 | 6 | # build sdist and wheel distribution packages in ./dist folder. 7 | # Travis runs the `before_deploy` stage before each deployment, but 8 | # we only want to build them once, as we want to use the same 9 | # files for both Github and PyPI 10 | if $(ls ./dist/fontPens*.zip > /dev/null 2>&1) && \ 11 | $(ls ./dist/fontPens*.whl > /dev/null 2>&1); then 12 | echo "Distribution packages already exists; skipping" 13 | else 14 | tox -e bdist 15 | fi 16 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | |Build Status| |Coverage| |PyPI| |Versions| 2 | 3 | fontPens 4 | -------- 5 | 6 | A collection of classes implementing the pen protocol for manipulating glyphs. 7 | 8 | 9 | .. |Build Status| image:: https://travis-ci.org/robotools/fontPens.svg?branch=master 10 | :target: https://travis-ci.org/robotools/fontPens 11 | .. |PyPI| image:: https://img.shields.io/pypi/v/fontPens.svg 12 | :target: https://pypi.org/project/fontPens 13 | .. |Versions| image:: https://img.shields.io/badge/python-2.7%2C%203.7-blue.svg 14 | :alt: Python Versions 15 | .. |Coverage| image:: https://codecov.io/gh/robotools/fontPens/branch/master/graph/badge.svg 16 | :target: https://codecov.io/gh/robotools/fontPens 17 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | # measure 'branch' coverage in addition to 'statement' coverage 3 | # See: http://coverage.readthedocs.io/en/coverage-4.5.1/branch.html 4 | branch = True 5 | 6 | # list of directories or packages to measure 7 | source = fontPens 8 | 9 | [paths] 10 | source = 11 | Lib/fontPens 12 | .tox/*/lib/python*/site-packages/fontPens 13 | .tox/pypy*/site-packages/fontPens 14 | 15 | [report] 16 | # Regexes for lines to exclude from consideration 17 | exclude_lines = 18 | # keywords to use in inline comments to skip coverage 19 | pragma: no cover 20 | 21 | # don't complain if tests don't hit defensive assertion code 22 | (raise|except)(\s)?NotImplementedError 23 | 24 | # don't complain if non-runnable code isn't run 25 | if __name__ == .__main__.: 26 | 27 | # ignore source code that can’t be found 28 | ignore_errors = True 29 | 30 | # when running a summary report, show missing lines 31 | show_missing = True 32 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.2.5.dev0 3 | commit = True 4 | tag = False 5 | tag_name = v{new_version} 6 | parse = (?P\d+)\.(?P\d+)\.(?P\d+)(\.(?P[a-z]+)(?P\d+))? 7 | serialize = 8 | {major}.{minor}.{patch}.{release}{dev} 9 | {major}.{minor}.{patch} 10 | 11 | [bumpversion:part:release] 12 | optional_value = final 13 | values = 14 | dev 15 | final 16 | 17 | [bumpversion:part:dev] 18 | 19 | [bumpversion:file:Lib/fontPens/__init__.py] 20 | search = __version__ = "{current_version}" 21 | replace = __version__ = "{new_version}" 22 | 23 | [bumpversion:file:setup.py] 24 | search = version="{current_version}" 25 | replace = version="{new_version}" 26 | 27 | [wheel] 28 | universal = 1 29 | 30 | [aliases] 31 | test = pytest 32 | 33 | [metadata] 34 | license_file = LICENSE.txt 35 | 36 | [tool:pytest] 37 | minversion = 3.0.2 38 | testpaths = 39 | Lib/fontPens 40 | addopts = 41 | -v 42 | -r a 43 | --doctest-modules 44 | 45 | -------------------------------------------------------------------------------- /.travis/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | set -x 5 | 6 | ci_requirements="pip setuptools tox" 7 | 8 | if [ "$TRAVIS_OS_NAME" == "osx" ]; then 9 | if [[ ${TOXENV} == *"py27"* ]]; then 10 | # install pip on the system python 11 | curl -O https://bootstrap.pypa.io/get-pip.py 12 | python get-pip.py --user 13 | # install virtualenv and create virtual environment 14 | python -m pip install --user virtualenv 15 | python -m virtualenv .venv/ 16 | elif [[ ${TOXENV} == *"py3"* ]]; then 17 | # install current python3 with homebrew 18 | # NOTE: the formula is now named just "python" 19 | brew install python 20 | command -v python3 21 | python3 --version 22 | python3 -m pip install virtualenv 23 | python3 -m virtualenv .venv/ 24 | else 25 | echo "unsupported $TOXENV: "${TOXENV} 26 | exit 1 27 | fi 28 | # activate virtual environment 29 | source .venv/bin/activate 30 | fi 31 | 32 | python -m pip install $ci_requirements 33 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | fontPens License Agreement 2 | 3 | Copyright (c) 2005-2017, The RoboFab Developers: 4 | Erik van Blokland 5 | Tal Leming 6 | Just van Rossum 7 | 8 | All rights reserved. 9 | 10 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 11 | 12 | Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 13 | Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 14 | Neither the name of the The RoboFab Developers nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 16 | 17 | Up to date info on fontPens: 18 | https://github.com/unified-font-object/fontPens 19 | 20 | This is the BSD license: 21 | http://www.opensource.org/licenses/BSD-3-Clause 22 | -------------------------------------------------------------------------------- /Lib/fontPens/transformPointPen.py: -------------------------------------------------------------------------------- 1 | from fontTools.pens.pointPen import AbstractPointPen 2 | 3 | 4 | class TransformPointPen(AbstractPointPen): 5 | """ 6 | PointPen that transforms all coordinates, and passes them to another 7 | PointPen. It also transforms the transformation given to addComponent(). 8 | """ 9 | 10 | def __init__(self, outPen, transformation): 11 | if not hasattr(transformation, "transformPoint"): 12 | from fontTools.misc.transform import Transform 13 | transformation = Transform(*transformation) 14 | self._transformation = transformation 15 | self._transformPoint = transformation.transformPoint 16 | self._outPen = outPen 17 | self._stack = [] 18 | 19 | def beginPath(self, identifier=None): 20 | self._outPen.beginPath(identifier=identifier) 21 | 22 | def endPath(self): 23 | self._outPen.endPath() 24 | 25 | def addPoint(self, pt, segmentType=None, smooth=False, name=None, **kwargs): 26 | pt = self._transformPoint(pt) 27 | self._outPen.addPoint(pt, segmentType, smooth, name, **kwargs) 28 | 29 | def addComponent(self, glyphName, transformation, identifier=None): 30 | transformation = self._transformation.transform(transformation) 31 | self._outPen.addComponent(glyphName, transformation, identifier) 32 | 33 | 34 | def _testTransformPointPen(): 35 | """ 36 | >>> from fontPens.printPointPen import PrintPointPen 37 | 38 | >>> pen = TransformPointPen(PrintPointPen(), (1, 0, 0, 1, 20, 20)) 39 | >>> pen.beginPath() 40 | pen.beginPath() 41 | >>> pen.addPoint((0, 0), "move", name="hello") 42 | pen.addPoint((20, 20), segmentType='move', name='hello') 43 | >>> pen.endPath() 44 | pen.endPath() 45 | """ 46 | 47 | 48 | if __name__ == "__main__": 49 | import doctest 50 | doctest.testmod() 51 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox (http://tox.testrun.org/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | [tox] 7 | envlist = py{27,36,37}-cov, py{py}-nocov, htmlcov 8 | 9 | [testenv] 10 | deps = 11 | cov: coverage>=4.3 12 | pytest 13 | fontparts>=0.8.1 14 | FontTools[ufo,lxml,unicode]>=3.32.0 15 | install_command = 16 | pip install -v {opts} {packages} 17 | commands = 18 | cov: coverage run --parallel-mode -m pytest {posargs} 19 | nocov: pytest {posargs} 20 | 21 | [testenv:htmlcov] 22 | basepython = {env:TOXPYTHON:python3.6} 23 | deps = 24 | coverage>=4.3 25 | skip_install = true 26 | commands = 27 | coverage combine 28 | coverage html 29 | 30 | [testenv:codecov] 31 | passenv = * 32 | basepython = {env:TOXPYTHON:python} 33 | deps = 34 | coverage>=4.3 35 | codecov 36 | skip_install = true 37 | ignore_outcome = true 38 | commands = 39 | coverage combine 40 | codecov --env TOXENV 41 | 42 | [testenv:bdist] 43 | basepython = {env:TOXPYTHON:python3.6} 44 | deps = 45 | pygments 46 | docutils 47 | setuptools 48 | wheel 49 | skip_install = true 50 | install_command = 51 | # make sure we use the latest setuptools and wheel 52 | pip install --upgrade {opts} {packages} 53 | whitelist_externals = 54 | rm 55 | commands = 56 | # check metadata and rst long_description 57 | python setup.py check --restructuredtext --strict 58 | # clean up build/ and dist/ folders 59 | rm -rf {toxinidir}/dist 60 | python setup.py clean --all 61 | # build sdist 62 | python setup.py sdist --dist-dir {toxinidir}/dist 63 | # build wheel from sdist 64 | pip wheel -v --no-deps --no-index --wheel-dir {toxinidir}/dist --find-links {toxinidir}/dist fontPens 65 | -------------------------------------------------------------------------------- /Lib/fontPens/recordingPointPen.py: -------------------------------------------------------------------------------- 1 | from fontTools.pens.pointPen import AbstractPointPen 2 | 3 | 4 | def replayRecording(recording, pen): 5 | """Replay a recording, as produced by RecordingPointPen, to a pointpen. 6 | 7 | Note that recording does not have to be produced by those pens. 8 | It can be any iterable of tuples of method name and tuple-of-arguments. 9 | Likewise, pen can be any objects receiving those method calls. 10 | """ 11 | for operator, operands, kwargs in recording: 12 | getattr(pen, operator)(*operands, **kwargs) 13 | 14 | 15 | class RecordingPointPen(AbstractPointPen): 16 | 17 | def __init__(self): 18 | self.value = [] 19 | 20 | def beginPath(self, **kwargs): 21 | self.value.append(("beginPath", (), kwargs)) 22 | 23 | def endPath(self): 24 | self.value.append(("endPath", (), {})) 25 | 26 | def addPoint(self, pt, segmentType=None, smooth=False, name=None, **kwargs): 27 | self.value.append(("addPoint", (pt, segmentType, smooth, name), kwargs)) 28 | 29 | def addComponent(self, baseGlyphName, transformation, **kwargs): 30 | self.value.append(("addComponent", (baseGlyphName, transformation), kwargs)) 31 | 32 | def replay(self, pen): 33 | replayRecording(self.value, pen) 34 | 35 | 36 | def _test(): 37 | """ 38 | >>> from fontPens.printPointPen import PrintPointPen 39 | >>> pen = RecordingPointPen() 40 | >>> pen.beginPath() 41 | >>> pen.addPoint((100, 200), smooth=False, segmentType="line") 42 | >>> pen.endPath() 43 | >>> pen.beginPath(identifier="my_path_id") 44 | >>> pen.addPoint((200, 300), segmentType="line") 45 | >>> pen.addPoint((200, 400), segmentType="line", identifier="my_point_id") 46 | >>> pen.endPath() 47 | >>> pen2 = RecordingPointPen() 48 | >>> pen.replay(pen2) 49 | >>> assert pen.value == pen2.value 50 | >>> ppp = PrintPointPen() 51 | >>> pen2.replay(ppp) 52 | pen.beginPath() 53 | pen.addPoint((100, 200), segmentType='line') 54 | pen.endPath() 55 | pen.beginPath(identifier='my_path_id') 56 | pen.addPoint((200, 300), segmentType='line') 57 | pen.addPoint((200, 400), segmentType='line', identifier='my_point_id') 58 | pen.endPath() 59 | """ 60 | 61 | 62 | if __name__ == "__main__": 63 | import doctest 64 | doctest.testmod() 65 | -------------------------------------------------------------------------------- /Lib/fontPens/printPointPen.py: -------------------------------------------------------------------------------- 1 | from fontTools.pens.pointPen import AbstractPointPen 2 | 3 | 4 | class PrintPointPen(AbstractPointPen): 5 | """ 6 | A PointPen that prints every step. 7 | """ 8 | 9 | def __init__(self): 10 | self.havePath = False 11 | 12 | def beginPath(self, identifier=None): 13 | self.havePath = True 14 | if identifier is not None: 15 | print("pen.beginPath(identifier=%r)" % identifier) 16 | else: 17 | print("pen.beginPath()") 18 | 19 | def endPath(self): 20 | self.havePath = False 21 | print("pen.endPath()") 22 | 23 | def addPoint(self, pt, segmentType=None, smooth=False, name=None, identifier=None, **kwargs): 24 | assert self.havePath 25 | args = ["(%s, %s)" % (pt[0], pt[1])] 26 | if segmentType is not None: 27 | args.append("segmentType='%s'" % segmentType) 28 | if smooth: 29 | args.append("smooth=True") 30 | if name is not None: 31 | args.append("name='%s'" % name) 32 | if identifier is not None: 33 | args.append("identifier='%s'" % identifier) 34 | if kwargs: 35 | args.append("**%s" % kwargs) 36 | print("pen.addPoint(%s)" % ", ".join(args)) 37 | 38 | def addComponent(self, baseGlyphName, transformation, identifier=None): 39 | assert not self.havePath 40 | args = "'%s', %r" % (baseGlyphName, transformation) 41 | if identifier is not None: 42 | args += ", identifier='%s'" % identifier 43 | print("pen.addComponent(%s)" % args) 44 | 45 | 46 | def _testPrintPointPen(): 47 | """ 48 | >>> pen = PrintPointPen() 49 | >>> pen.beginPath() 50 | pen.beginPath() 51 | >>> pen.beginPath("abc123") 52 | pen.beginPath(identifier='abc123') 53 | >>> pen.addPoint((10, 10), "curve", True, identifier="abc123") 54 | pen.addPoint((10, 10), segmentType='curve', smooth=True, identifier='abc123') 55 | >>> pen.addPoint((10, 10), "curve", True) 56 | pen.addPoint((10, 10), segmentType='curve', smooth=True) 57 | >>> pen.endPath() 58 | pen.endPath() 59 | >>> pen.addComponent("a", (1, 0, 0, 1, 10, 10), "xyz987") 60 | pen.addComponent('a', (1, 0, 0, 1, 10, 10), identifier='xyz987') 61 | >>> pen.addComponent("a", (1, 0, 0, 1, 10, 10), identifier="xyz9876") 62 | pen.addComponent('a', (1, 0, 0, 1, 10, 10), identifier='xyz9876') 63 | >>> pen.addComponent("a", (1, 0, 0, 1, 10, 10)) 64 | pen.addComponent('a', (1, 0, 0, 1, 10, 10)) 65 | """ 66 | 67 | 68 | if __name__ == "__main__": 69 | import doctest 70 | doctest.testmod() 71 | -------------------------------------------------------------------------------- /Lib/fontPens/printPen.py: -------------------------------------------------------------------------------- 1 | from fontTools.pens.basePen import AbstractPen 2 | 3 | 4 | class PrintPen(AbstractPen): 5 | """ 6 | A SegmentPen that prints every step. 7 | """ 8 | 9 | def moveTo(self, pt): 10 | print("pen.moveTo(%s)" % (tuple(pt),)) 11 | 12 | def lineTo(self, pt): 13 | print("pen.lineTo(%s)" % (tuple(pt),)) 14 | 15 | def curveTo(self, *pts): 16 | args = self._pointArgsRepr(pts) 17 | print("pen.curveTo(%s)" % args) 18 | 19 | def qCurveTo(self, *pts): 20 | args = self._pointArgsRepr(pts) 21 | print("pen.qCurveTo(%s)" % args) 22 | 23 | def closePath(self): 24 | print("pen.closePath()") 25 | 26 | def endPath(self): 27 | print("pen.endPath()") 28 | 29 | def addComponent(self, baseGlyphName, transformation): 30 | print("pen.addComponent('%s', %s)" % (baseGlyphName, tuple(transformation))) 31 | 32 | @staticmethod 33 | def _pointArgsRepr(pts): 34 | return ", ".join("None" if pt is None else str(tuple(pt)) for pt in pts) 35 | 36 | 37 | def _testPrintPen(): 38 | """ 39 | >>> pen = PrintPen() 40 | >>> pen.moveTo((10, 10)) 41 | pen.moveTo((10, 10)) 42 | >>> pen.lineTo((20, 20)) 43 | pen.lineTo((20, 20)) 44 | >>> pen.curveTo((1, 1), (2, 2), (3, 3)) 45 | pen.curveTo((1, 1), (2, 2), (3, 3)) 46 | >>> pen.qCurveTo((4, 4), (5, 5)) 47 | pen.qCurveTo((4, 4), (5, 5)) 48 | >>> pen.qCurveTo((6, 6)) 49 | pen.qCurveTo((6, 6)) 50 | >>> pen.closePath() 51 | pen.closePath() 52 | >>> pen.endPath() 53 | pen.endPath() 54 | >>> pen.addComponent("a", (1, 0, 0, 1, 10, 10)) 55 | pen.addComponent('a', (1, 0, 0, 1, 10, 10)) 56 | >>> pen.curveTo((1, 1), (2, 2), (3, 3), None) 57 | pen.curveTo((1, 1), (2, 2), (3, 3), None) 58 | >>> pen.qCurveTo((1, 1), (2, 2), (3, 3), None) 59 | pen.qCurveTo((1, 1), (2, 2), (3, 3), None) 60 | """ 61 | 62 | 63 | def _testPrintPen_nonTuplePoints(): 64 | """ 65 | >>> pen = PrintPen() 66 | >>> pen.moveTo([10, 10]) 67 | pen.moveTo((10, 10)) 68 | >>> pen.lineTo([20, 20]) 69 | pen.lineTo((20, 20)) 70 | >>> pen.curveTo([1, 1], [2, 2], [3, 3]) 71 | pen.curveTo((1, 1), (2, 2), (3, 3)) 72 | >>> pen.qCurveTo([4, 4], [5, 5]) 73 | pen.qCurveTo((4, 4), (5, 5)) 74 | >>> pen.qCurveTo([6, 6]) 75 | pen.qCurveTo((6, 6)) 76 | >>> pen.closePath() 77 | pen.closePath() 78 | >>> pen.endPath() 79 | pen.endPath() 80 | >>> pen.addComponent("a", [1, 0, 0, 1, 10, 10]) 81 | pen.addComponent('a', (1, 0, 0, 1, 10, 10)) 82 | >>> pen.curveTo([1, 1], [2, 2], [3, 3], None) 83 | pen.curveTo((1, 1), (2, 2), (3, 3), None) 84 | >>> pen.qCurveTo([1, 1], [2, 2], [3, 3], None) 85 | pen.qCurveTo((1, 1), (2, 2), (3, 3), None) 86 | """ 87 | 88 | 89 | if __name__ == "__main__": 90 | import doctest 91 | doctest.testmod() 92 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | 3 | language: python 4 | python: 3.6 5 | 6 | # empty "env:" is needed for 'allow_failures' 7 | # https://docs.travis-ci.com/user/customizing-the-build/#Rows-that-are-Allowed-to-Fail 8 | env: 9 | 10 | matrix: 11 | fast_finish: true 12 | exclude: 13 | - python: 3.6 14 | include: 15 | - python: 3.6 16 | env: 17 | - TOXENV=py36-cov 18 | - BUILD_DIST=true 19 | - python: 3.7 20 | env: TOXENV=py37-cov 21 | - python: 3.8 22 | env: TOXENV=py38-cov 23 | - python: pypy3 24 | env: TOXENV=pypy3 25 | - language: generic 26 | os: osx 27 | env: 28 | - TOXENV=py3-cov 29 | - HOMEBREW_NO_AUTO_UPDATE=1 30 | allow_failures: 31 | # We use fast_finish + allow_failures because OSX builds take forever 32 | # https://blog.travis-ci.com/2013-11-27-fast-finishing-builds 33 | - language: generic 34 | os: osx 35 | env: 36 | - TOXENV=py3-cov 37 | - HOMEBREW_NO_AUTO_UPDATE=1 38 | 39 | cache: 40 | - pip 41 | - directories: 42 | - $HOME/.pyenv_cache 43 | 44 | before_install: 45 | - source ./.travis/before_install.sh 46 | 47 | install: 48 | - ./.travis/install.sh 49 | 50 | script: 51 | - ./.travis/run.sh 52 | 53 | after_success: 54 | - ./.travis/after_success.sh 55 | 56 | before_deploy: 57 | - ./.travis/before_deploy.sh 58 | 59 | deploy: 60 | # deploy to Github Releases on tags 61 | - provider: releases 62 | api_key: 63 | secure: Kdf6HnU4mm0UK+yd+ywpN87IcUbhQ2ShjEMwenIBnrvBd2tD1hzHWrfKJVpSnVBy8QEtOcEqdRsSL4NGq/3qgp5cAxkf8hLtNChaVACeQsmQA8SLauipFPukCtkrpNdOlSDZLNHoGcIcVVPyTEkLVUpt68BxBpX8M859pA9KZ7KqFnAQFiCH06Qi5NZNHYzFBFeEcpFZyeo+3BAMEMWNfHRfkebC7BIim4de79tCui7F3C54vcGug/CuGKGU8Pbd7nfYXwEtibCDV9uOKF+kW5m7923fAvhJILrzDe3/C9KjaqtH/HQiiTNMlJ1TK1R9rodP3UUu2fN0oPpe/bMQvJUzllHMeHRB4luuaYMriRDtcCmZwCCOFeeHo3QxK8zfjv/v1lcVivPuhJiFXvx+1mU9ktSnQ5iD9QU0Si97ctJPypLaUeZq1WoluaKDv8Yw0Lmq7mZPo+rByikcGwTX7nx/LJYGlw9gtIdXSLVpsglPilM+2jVDOo7h+8oslD/AKs1S3ujDAFOeogL3SylSyswDHr4GLQMZBf/NrPBQHJge3uMYaldwKcT8xzu3LpkyCazYvt4f+Mh3RYfmE06rk86+R3luk5NNHjIJvFm0I4j+I7PtjpQYCnx6vtkH4rR0FBuE2/T0V8BBxp2UbIZF/wztBtV89oiO6yBGOauwkTM= 64 | skip_cleanup: true 65 | file_glob: true 66 | file: "dist/*" 67 | on: 68 | tags: true 69 | repo: robofab-developers/fontPens 70 | all_branches: true 71 | condition: "$BUILD_DIST == true" 72 | # deploy to PyPI on tags 73 | - provider: pypi 74 | user: benkiel 75 | password: 76 | secure: "dQmUNMwDeq7162p3IE3M9d9BW3lfk/chaW8SY605YikW7J56bfT1CQFaOiYumdvMF/eWBrVxbQbdwdupL2sDm1dSgYKWu0ite1jePaoTkMhEKFr+XW7GogcX+LOiFa7FITscnWgV02XwijStbklQmZ2beBe/tjB5Ug/Swx6CVCsTN/j1n0+r3UjtHcsnVgN+XAhpC0+ewoyKkoKP/aalb2gGFwsRDh2SzJZ/sOICHmmjJUGDB6/vS8tGgnI1arDSpSH7KDNB1dVAfDvjK9yXFEDPkYO5vEU//vZWP9yKTqXPmDiv+SMg959UcdgCUnNPSv44/VDtqv9kNhG4t3Ye4bjV/WBnyZ3SiWF8XHI+r3nk6x6Swjhq8ZRPk861JhDPK67kCJHdmMjOfaKy+MoTWscDqLlzxxAABSv+HkgQ3LiHKqBbvJVBbuhbmFdo8qtmZXKl4z8LlUcTHskMAioEMbueKRW/+jEDN5xm0h7c4W2mfHyrnn3Td5hWpmXZKe7ZKqbU8koBruGJpnC6miE689nO6HLpQbW8AjYy6ZOkz4HbkZCAYh0NqYe7qwgFKx8+iYy5smWyiqAS7A4D1kyFzaXV5jEe87jddDc/wMofgcKSUBOGe9eM/w4FKVRCW8jAMkjMiZKVMzHE2f9nk9q4A8bU++Da/AgIw3JKOVnWAPg=" 77 | skip_cleanup: true 78 | distributions: "sdist bdist_wheel" 79 | on: 80 | tags: true 81 | repo: robofab-developers/fontPens 82 | all_branches: true 83 | condition: "$BUILD_DIST == true" 84 | -------------------------------------------------------------------------------- /Lib/fontPens/thresholdPen.py: -------------------------------------------------------------------------------- 1 | from fontTools.pens.basePen import AbstractPen 2 | 3 | from fontPens.penTools import distance 4 | 5 | 6 | class ThresholdPen(AbstractPen): 7 | """ 8 | This pen only draws segments longer in length than the threshold value. 9 | 10 | - otherPen: a different segment pen object this filter should draw the results with. 11 | - threshold: the minimum length of a segment 12 | """ 13 | 14 | def __init__(self, otherPen, threshold=10): 15 | self.threshold = threshold 16 | self._lastPt = None 17 | self.otherPen = otherPen 18 | 19 | def moveTo(self, pt): 20 | self._lastPt = pt 21 | self.otherPen.moveTo(pt) 22 | 23 | def lineTo(self, pt, smooth=False): 24 | if self.threshold <= distance(pt, self._lastPt): 25 | self.otherPen.lineTo(pt) 26 | self._lastPt = pt 27 | 28 | def curveTo(self, pt1, pt2, pt3): 29 | if self.threshold <= distance(pt3, self._lastPt): 30 | self.otherPen.curveTo(pt1, pt2, pt3) 31 | self._lastPt = pt3 32 | 33 | def qCurveTo(self, *points): 34 | if self.threshold <= distance(points[-1], self._lastPt): 35 | self.otherPen.qCurveTo(*points) 36 | self._lastPt = points[-1] 37 | 38 | def closePath(self): 39 | self.otherPen.closePath() 40 | 41 | def endPath(self): 42 | self.otherPen.endPath() 43 | 44 | def addComponent(self, glyphName, transformation): 45 | self.otherPen.addComponent(glyphName, transformation) 46 | 47 | 48 | def thresholdGlyph(aGlyph, threshold=10): 49 | """ 50 | Convenience function that applies the **ThresholdPen** to a glyph in place. 51 | """ 52 | from fontTools.pens.recordingPen import RecordingPen 53 | recorder = RecordingPen() 54 | filterpen = ThresholdPen(recorder, threshold) 55 | aGlyph.draw(filterpen) 56 | aGlyph.clear() 57 | recorder.replay(aGlyph.getPen()) 58 | return aGlyph 59 | 60 | 61 | # ========= 62 | # = tests = 63 | # ========= 64 | 65 | def _makeTestGlyph(): 66 | # make a simple glyph that we can test the pens with. 67 | from fontParts.fontshell import RGlyph 68 | testGlyph = RGlyph() 69 | testGlyph.name = "testGlyph" 70 | testGlyph.width = 1000 71 | pen = testGlyph.getPen() 72 | pen.moveTo((100, 100)) 73 | pen.lineTo((900, 100)) 74 | pen.lineTo((900, 109)) 75 | pen.lineTo((900, 800)) 76 | pen.lineTo((100, 800)) 77 | pen.closePath() 78 | pen.addComponent("a", (1, 0, 0, 1, 0, 0)) 79 | return testGlyph 80 | 81 | 82 | def _testThresholdPen(): 83 | """ 84 | >>> from fontPens.printPen import PrintPen 85 | >>> glyph = _makeTestGlyph() 86 | >>> pen = ThresholdPen(PrintPen()) 87 | >>> glyph.draw(pen) 88 | pen.moveTo((100, 100)) 89 | pen.lineTo((900, 100)) 90 | pen.lineTo((900, 800)) 91 | pen.lineTo((100, 800)) 92 | pen.closePath() 93 | pen.addComponent('a', (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)) 94 | """ 95 | 96 | 97 | def _testThresholdGlyph(): 98 | """ 99 | >>> from fontPens.printPen import PrintPen 100 | >>> glyph = _makeTestGlyph() 101 | >>> thresholdGlyph(glyph) #doctest: +ELLIPSIS 102 | >> glyph.draw(PrintPen()) 104 | pen.moveTo((100, 100)) 105 | pen.lineTo((900, 100)) 106 | pen.lineTo((900, 800)) 107 | pen.lineTo((100, 800)) 108 | pen.closePath() 109 | pen.addComponent('a', (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)) 110 | """ 111 | 112 | 113 | if __name__ == "__main__": 114 | import doctest 115 | doctest.testmod() 116 | -------------------------------------------------------------------------------- /Lib/fontPens/digestPointPen.py: -------------------------------------------------------------------------------- 1 | from fontTools.pens.pointPen import AbstractPointPen 2 | 3 | 4 | class DigestPointPen(AbstractPointPen): 5 | """ 6 | This calculates a tuple representing the structure and values in a glyph: 7 | 8 | - including coordinates 9 | - including components 10 | """ 11 | 12 | def __init__(self, ignoreSmoothAndName=False): 13 | self._data = [] 14 | self.ignoreSmoothAndName = ignoreSmoothAndName 15 | 16 | def beginPath(self, identifier=None): 17 | self._data.append('beginPath') 18 | 19 | def endPath(self): 20 | self._data.append('endPath') 21 | 22 | def addPoint(self, pt, segmentType=None, smooth=False, name=None, **kwargs): 23 | if self.ignoreSmoothAndName: 24 | self._data.append((pt, segmentType)) 25 | else: 26 | self._data.append((pt, segmentType, smooth, name)) 27 | 28 | def addComponent(self, baseGlyphName, transformation, identifier=None): 29 | t = [] 30 | for v in transformation: 31 | if int(v) == v: 32 | t.append(int(v)) 33 | else: 34 | t.append(v) 35 | self._data.append((baseGlyphName, tuple(t), identifier)) 36 | 37 | def getDigest(self): 38 | """ 39 | Return the digest as a tuple with all coordinates of all points. 40 | """ 41 | return tuple(self._data) 42 | 43 | def getDigestPointsOnly(self, needSort=True): 44 | """ 45 | Return the digest as a tuple with all coordinates of all points, 46 | - but without smooth info or drawing instructions. 47 | - For instance if you want to compare 2 glyphs in shape, 48 | but not interpolatability. 49 | """ 50 | points = [] 51 | for item in self._data: 52 | if isinstance(item, tuple) and isinstance(item[0], tuple): 53 | points.append(item[0]) 54 | if needSort: 55 | points.sort() 56 | return tuple(points) 57 | 58 | 59 | class DigestPointStructurePen(DigestPointPen): 60 | 61 | """ 62 | This calculates a tuple representing the structure and values in a glyph: 63 | 64 | - excluding coordinates 65 | - excluding components 66 | """ 67 | 68 | def addPoint(self, pt, segmentType=None, smooth=False, name=None, **kwargs): 69 | self._data.append(segmentType) 70 | 71 | def addComponent(self, baseGlyphName, transformation, identifier=None): 72 | self._data.append(baseGlyphName) 73 | 74 | 75 | def _testDigestPointPen(): 76 | """ 77 | >>> pen = DigestPointPen() 78 | >>> pen.beginPath("abc123") 79 | >>> pen.getDigest() 80 | ('beginPath',) 81 | >>> pen.addPoint((10, 10), "move", True) 82 | >>> pen.addPoint((-10, 100), "line", False) 83 | >>> pen.endPath() 84 | >>> pen.getDigest() 85 | ('beginPath', ((10, 10), 'move', True, None), ((-10, 100), 'line', False, None), 'endPath') 86 | >>> pen.getDigestPointsOnly() # https://github.com/robofab-developers/fontPens/issues/8 87 | ((-10, 100), (10, 10)) 88 | >>> pen.beginPath() 89 | >>> pen.addPoint((100, 100), 'line') 90 | >>> pen.addPoint((100, 10), 'line') 91 | >>> pen.addPoint((10, 10), 'line') 92 | >>> pen.endPath() 93 | >>> pen.getDigest() 94 | ('beginPath', ((10, 10), 'move', True, None), ((-10, 100), 'line', False, None), 'endPath', 'beginPath', ((100, 100), 'line', False, None), ((100, 10), 'line', False, None), ((10, 10), 'line', False, None), 'endPath') 95 | >>> pen.getDigestPointsOnly() 96 | ((-10, 100), (10, 10), (10, 10), (100, 10), (100, 100)) 97 | """ 98 | 99 | 100 | if __name__ == "__main__": 101 | import doctest 102 | doctest.testmod() 103 | -------------------------------------------------------------------------------- /Lib/fontPens/guessSmoothPointPen.py: -------------------------------------------------------------------------------- 1 | import math 2 | from fontTools.pens.pointPen import AbstractPointPen 3 | 4 | 5 | class GuessSmoothPointPen(AbstractPointPen): 6 | """ 7 | Filtering PointPen that tries to determine whether an on-curve point 8 | should be "smooth", ie. that it's a "tangent" point or a "curve" point. 9 | """ 10 | 11 | def __init__(self, outPen, error=0.05): 12 | self._outPen = outPen 13 | self._error = error 14 | self._points = None 15 | 16 | def _flushContour(self): 17 | points = self._points 18 | nPoints = len(points) 19 | if not nPoints: 20 | return 21 | if points[0][1] == "move": 22 | # Open path. 23 | indices = range(1, nPoints - 1) 24 | elif nPoints > 1: 25 | # Closed path. To avoid having to mod the contour index, we 26 | # simply abuse Python's negative index feature, and start at -1 27 | indices = range(-1, nPoints - 1) 28 | else: 29 | # closed path containing 1 point (!), ignore. 30 | indices = [] 31 | for i in indices: 32 | pt, segmentType, dummy, name, kwargs = points[i] 33 | if segmentType is None: 34 | continue 35 | prev = i - 1 36 | next = i + 1 37 | if points[prev][1] is not None and points[next][1] is not None: 38 | continue 39 | # At least one of our neighbors is an off-curve point 40 | pt = points[i][0] 41 | prevPt = points[prev][0] 42 | nextPt = points[next][0] 43 | if pt != prevPt and pt != nextPt: 44 | dx1, dy1 = pt[0] - prevPt[0], pt[1] - prevPt[1] 45 | dx2, dy2 = nextPt[0] - pt[0], nextPt[1] - pt[1] 46 | a1 = math.atan2(dx1, dy1) 47 | a2 = math.atan2(dx2, dy2) 48 | if abs(a1 - a2) < self._error: 49 | points[i] = pt, segmentType, True, name, kwargs 50 | 51 | for pt, segmentType, smooth, name, kwargs in points: 52 | self._outPen.addPoint(pt, segmentType, smooth, name, **kwargs) 53 | 54 | def beginPath(self, identifier=None): 55 | assert self._points is None 56 | self._points = [] 57 | self._outPen.beginPath(identifier) 58 | 59 | def endPath(self): 60 | self._flushContour() 61 | self._outPen.endPath() 62 | self._points = None 63 | 64 | def addPoint(self, pt, segmentType=None, smooth=False, name=None, **kwargs): 65 | self._points.append((pt, segmentType, False, name, kwargs)) 66 | 67 | def addComponent(self, glyphName, transformation, identifier=None): 68 | assert self._points is None 69 | self._outPen.addComponent(glyphName, transformation, identifier) 70 | 71 | 72 | def _testGuessSmoothPointPen(): 73 | """ 74 | >>> from fontPens.printPointPen import PrintPointPen 75 | >>> pen = GuessSmoothPointPen(PrintPointPen()) 76 | 77 | >>> pen.beginPath(identifier="abc123") 78 | pen.beginPath(identifier='abc123') 79 | >>> pen.addPoint((10, 100), "move") 80 | >>> pen.addPoint((10, 200)) 81 | >>> pen.addPoint((10, 300)) 82 | >>> pen.addPoint((10, 400), "curve") 83 | >>> pen.addPoint((10, 500)) 84 | >>> pen.endPath() 85 | pen.addPoint((10, 100), segmentType='move') 86 | pen.addPoint((10, 200)) 87 | pen.addPoint((10, 300)) 88 | pen.addPoint((10, 400), segmentType='curve', smooth=True) 89 | pen.addPoint((10, 500)) 90 | pen.endPath() 91 | 92 | >>> pen.beginPath(identifier="abc123") 93 | pen.beginPath(identifier='abc123') 94 | >>> pen.addPoint((10, 100), "move") 95 | >>> pen.addPoint((10, 200)) 96 | >>> pen.addPoint((8, 300)) 97 | >>> pen.addPoint((10, 400), "curve", smooth=False) 98 | >>> pen.addPoint((10, 500)) 99 | >>> pen.endPath() 100 | pen.addPoint((10, 100), segmentType='move') 101 | pen.addPoint((10, 200)) 102 | pen.addPoint((8, 300)) 103 | pen.addPoint((10, 400), segmentType='curve', smooth=True) 104 | pen.addPoint((10, 500)) 105 | pen.endPath() 106 | 107 | >>> pen.addComponent("a", (1, 0, 0, 1, 10, 10), "xyz987") 108 | pen.addComponent('a', (1, 0, 0, 1, 10, 10), identifier='xyz987') 109 | """ 110 | 111 | 112 | if __name__ == "__main__": 113 | import doctest 114 | doctest.testmod() 115 | -------------------------------------------------------------------------------- /Lib/fontPens/thresholdPointPen.py: -------------------------------------------------------------------------------- 1 | from fontTools.pens.pointPen import AbstractPointPen 2 | 3 | from fontPens.penTools import distance 4 | 5 | 6 | class ThresholdPointPen(AbstractPointPen): 7 | """ 8 | Rewrite of the ThresholdPen as a PointPen 9 | so that we can preserve named points and other arguments. 10 | This pen will add components from the original glyph, but 11 | but it won't filter those components. 12 | 13 | "move", "line", "curve" or "qcurve" 14 | """ 15 | 16 | def __init__(self, otherPointPen, threshold=10): 17 | self.threshold = threshold 18 | self._lastPt = None 19 | self._offCurveBuffer = [] 20 | self.otherPointPen = otherPointPen 21 | 22 | def beginPath(self, identifier=None): 23 | """Start a new sub path.""" 24 | self.otherPointPen.beginPath(identifier) 25 | self._lastPt = None 26 | 27 | def endPath(self): 28 | """End the current sub path.""" 29 | self.otherPointPen.endPath() 30 | 31 | def addPoint(self, pt, segmentType=None, smooth=False, name=None, **kwargs): 32 | """Add a point to the current sub path.""" 33 | if segmentType in ['curve', 'qcurve']: 34 | # it's an offcurve, let's buffer them until we get another oncurve 35 | # and we know what to do with them 36 | self._offCurveBuffer.append((pt, segmentType, smooth, name, kwargs)) 37 | return 38 | 39 | elif segmentType == "move": 40 | # start of an open contour 41 | self.otherPointPen.addPoint(pt, segmentType, smooth, name) # how to add kwargs? 42 | self._lastPt = pt 43 | self._offCurveBuffer = [] 44 | 45 | elif segmentType == "line": 46 | if self._lastPt is None: 47 | self.otherPointPen.addPoint(pt, segmentType, smooth, name) # how to add kwargs? 48 | self._lastPt = pt 49 | elif distance(pt, self._lastPt) >= self.threshold: 50 | # we're oncurve and far enough from the last oncurve 51 | if self._offCurveBuffer: 52 | # empty any buffered offcurves 53 | for buf_pt, buf_segmentType, buf_smooth, buf_name, buf_kwargs in self._offCurveBuffer: 54 | self.otherPointPen.addPoint(buf_pt, buf_segmentType, buf_smooth, buf_name) # how to add kwargs? 55 | self._offCurveBuffer = [] 56 | # finally add the oncurve. 57 | self.otherPointPen.addPoint(pt, segmentType, smooth, name) # how to add kwargs? 58 | self._lastPt = pt 59 | else: 60 | # we're too short, so we're not going to make it. 61 | # we need to clear out the offcurve buffer. 62 | self._offCurveBuffer = [] 63 | 64 | def addComponent(self, baseGlyphName, transformation, identifier=None): 65 | """Add a sub glyph. Note: this way components are not filtered.""" 66 | self.otherPointPen.addComponent(baseGlyphName, transformation, identifier) 67 | 68 | 69 | # ========= 70 | # = tests = 71 | # ========= 72 | 73 | def _makeTestGlyph(): 74 | # make a simple glyph that we can test the pens with. 75 | from fontParts.fontshell import RGlyph 76 | testGlyph = RGlyph() 77 | testGlyph.name = "testGlyph" 78 | testGlyph.width = 1000 79 | pen = testGlyph.getPen() 80 | pen.moveTo((100, 100)) 81 | pen.lineTo((900, 100)) 82 | pen.lineTo((900, 109)) 83 | pen.lineTo((900, 800)) 84 | pen.lineTo((100, 800)) 85 | pen.closePath() 86 | pen.addComponent("a", (1, 0, 0, 1, 0, 0)) 87 | return testGlyph 88 | 89 | 90 | def _testThresholdPen(): 91 | """ 92 | >>> from fontPens.printPointPen import PrintPointPen 93 | >>> from random import seed 94 | >>> seed(100) 95 | >>> glyph = _makeTestGlyph() 96 | >>> pen = ThresholdPointPen(PrintPointPen()) 97 | >>> glyph.drawPoints(pen) 98 | pen.beginPath() 99 | pen.addPoint((100, 100), segmentType='line') 100 | pen.addPoint((900, 100), segmentType='line') 101 | pen.addPoint((900, 800), segmentType='line') 102 | pen.addPoint((100, 800), segmentType='line') 103 | pen.endPath() 104 | pen.addComponent('a', (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)) 105 | """ 106 | 107 | 108 | if __name__ == "__main__": 109 | import doctest 110 | doctest.testmod() 111 | -------------------------------------------------------------------------------- /Lib/fontPens/angledMarginPen.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | from fontTools.pens.basePen import BasePen 4 | 5 | from fontPens.penTools import getCubicPoint 6 | 7 | 8 | class AngledMarginPen(BasePen): 9 | """ 10 | Pen to calculate the margins according to a slanted coordinate system. Slant angle comes from font.info.italicAngle. 11 | 12 | - this pen works on the on-curve points, and approximates the distance to curves. 13 | - results will be float. 14 | """ 15 | 16 | def __init__(self, glyphSet, width, italicAngle): 17 | BasePen.__init__(self, glyphSet) 18 | self.width = width 19 | self._angle = math.radians(90 + italicAngle) 20 | self.maxSteps = 100 21 | self.margin = None 22 | self._left = None 23 | self._right = None 24 | self._start = None 25 | self.currentPoint = None 26 | 27 | def _getAngled(self, pt): 28 | right = (self.width + (pt[1] / math.tan(self._angle))) - pt[0] 29 | left = pt[0] - ((pt[1] / math.tan(self._angle))) 30 | if self._right is None: 31 | self._right = right 32 | else: 33 | self._right = min(self._right, right) 34 | if self._left is None: 35 | self._left = left 36 | else: 37 | self._left = min(self._left, left) 38 | self.margin = self._left, self._right 39 | 40 | def _moveTo(self, pt): 41 | self._start = self.currentPoint = pt 42 | 43 | def _lineTo(self, pt): 44 | self._getAngled(pt) 45 | self.currentPoint = pt 46 | 47 | def _curveToOne(self, pt1, pt2, pt3): 48 | step = 1.0 / self.maxSteps 49 | factors = range(0, self.maxSteps + 1) 50 | for i in factors: 51 | pt = getCubicPoint(i * step, self.currentPoint, pt1, pt2, pt3) 52 | self._getAngled(pt) 53 | self.currentPoint = pt3 54 | 55 | 56 | def getAngledMargins(glyph, font): 57 | """ 58 | Convenience function, returns the angled margins for this glyph. Adjusted for font.info.italicAngle. 59 | """ 60 | pen = AngledMarginPen(font, glyph.width, font.info.italicAngle) 61 | glyph.draw(pen) 62 | return pen.margin 63 | 64 | 65 | def setAngledLeftMargin(glyph, font, value): 66 | """ 67 | Convenience function, sets the left angled margin to value. Adjusted for font.info.italicAngle. 68 | """ 69 | pen = AngledMarginPen(font, glyph.width, font.info.italicAngle) 70 | glyph.draw(pen) 71 | isLeft, isRight = pen.margin 72 | glyph.leftMargin += value - isLeft 73 | 74 | 75 | def setAngledRightMargin(glyph, font, value): 76 | """ 77 | Convenience function, sets the right angled margin to value. Adjusted for font.info.italicAngle. 78 | """ 79 | pen = AngledMarginPen(font, glyph.width, font.info.italicAngle) 80 | glyph.draw(pen) 81 | isLeft, isRight = pen.margin 82 | glyph.rightMargin += value - isRight 83 | 84 | 85 | def centerAngledMargins(glyph, font): 86 | """ 87 | Convenience function, centers the glyph on angled margins. 88 | """ 89 | pen = AngledMarginPen(font, glyph.width, font.info.italicAngle) 90 | glyph.draw(pen) 91 | isLeft, isRight = pen.margin 92 | setAngledLeftMargin(glyph, font, (isLeft + isRight) * .5) 93 | setAngledRightMargin(glyph, font, (isLeft + isRight) * .5) 94 | 95 | 96 | def guessItalicOffset(glyph, font): 97 | """ 98 | Guess the italic offset based on the margins of a symetric glyph. 99 | For instance H or I. 100 | """ 101 | l, r = getAngledMargins(glyph, font) 102 | return l - (l + r) * .5 103 | 104 | 105 | # ========= 106 | # = tests = 107 | # ========= 108 | 109 | def _makeTestGlyph(): 110 | # make a simple glyph that we can test the pens with. 111 | from fontParts.fontshell import RGlyph 112 | testGlyph = RGlyph() 113 | testGlyph.name = "testGlyph" 114 | testGlyph.width = 1000 115 | pen = testGlyph.getPen() 116 | pen.moveTo((100, 100)) 117 | pen.lineTo((900, 100)) 118 | pen.lineTo((900, 800)) 119 | pen.lineTo((100, 800)) 120 | pen.curveTo((120, 700), (120, 300), (100, 100)) 121 | pen.closePath() 122 | return testGlyph 123 | 124 | 125 | def _testAngledMarginPen(): 126 | """ 127 | >>> glyph = _makeTestGlyph() 128 | >>> pen = AngledMarginPen(dict(), width=0, italicAngle=10) 129 | >>> glyph.draw(pen) 130 | >>> pen.margin 131 | (117.63269807084649, -1041.061584566772) 132 | """ 133 | 134 | 135 | if __name__ == "__main__": 136 | import doctest 137 | doctest.testmod() 138 | -------------------------------------------------------------------------------- /Lib/fontPens/spikePen.py: -------------------------------------------------------------------------------- 1 | from fontPens.flattenPen import FlattenPen 2 | from fontTools.pens.basePen import BasePen 3 | from math import atan2, sin, cos, pi 4 | 5 | 6 | class SpikePen(BasePen): 7 | 8 | """ 9 | Add narly spikes or dents to the glyph. 10 | patternFunc is an optional function which recalculates the offset. 11 | """ 12 | 13 | def __init__(self, otherPen, segmentLength=20, spikeLength=40, patternFunc=None): 14 | self.otherPen = otherPen 15 | self.segmentLength = segmentLength 16 | self.spikeLength = spikeLength 17 | self.patternFunc = patternFunc 18 | 19 | def _moveTo(self, pt): 20 | self._points = [pt] 21 | 22 | def _lineTo(self, pt): 23 | self._points.append(pt) 24 | 25 | def _processPoints(self): 26 | pointCount = len(self._points) 27 | for i in range(0, pointCount): 28 | x, y = self._points[i] 29 | 30 | if not i % 2: 31 | previousPoint = self._points[i - 1] 32 | nextPoint = self._points[(i + 1) % pointCount] 33 | angle = atan2(previousPoint[0] - nextPoint[0], previousPoint[1] - nextPoint[1]) 34 | if self.patternFunc is not None: 35 | thisSpikeLength = self.patternFunc(self.spikeLength) 36 | else: 37 | thisSpikeLength = self.spikeLength 38 | x -= sin(angle + .5 * pi) * thisSpikeLength 39 | y -= cos(angle + .5 * pi) * thisSpikeLength 40 | 41 | if i == 0: 42 | self.otherPen.moveTo((x, y)) 43 | else: 44 | self.otherPen.lineTo((x, y)) 45 | 46 | def closePath(self): 47 | # pop last point 48 | self._points.pop() 49 | self._processPoints() 50 | self.otherPen.closePath() 51 | 52 | def endPath(self): 53 | self._processPoints() 54 | self.otherPen.endPath() 55 | 56 | 57 | def spikeGlyph(aGlyph, segmentLength=20, spikeLength=40, patternFunc=None): 58 | from fontTools.pens.recordingPen import RecordingPen 59 | recorder = RecordingPen() 60 | spikePen = SpikePen(recorder, spikeLength=spikeLength, patternFunc=patternFunc) 61 | filterPen = FlattenPen(spikePen, approximateSegmentLength=segmentLength, segmentLines=True) 62 | aGlyph.draw(filterPen) 63 | aGlyph.clear() 64 | recorder.replay(aGlyph.getPen()) 65 | return aGlyph 66 | 67 | 68 | # ========= 69 | # = tests = 70 | # ========= 71 | 72 | def _makeTestGlyphRect(): 73 | # make a simple glyph that we can test the pens with. 74 | from fontParts.fontshell import RGlyph 75 | testGlyph = RGlyph() 76 | testGlyph.name = "testGlyph" 77 | testGlyph.width = 500 78 | pen = testGlyph.getPen() 79 | pen.moveTo((100, 100)) 80 | pen.lineTo((100, 300)) 81 | pen.lineTo((300, 300)) 82 | pen.lineTo((300, 100)) 83 | pen.closePath() 84 | return testGlyph 85 | 86 | 87 | def _makeTestGlyphLine(): 88 | from fontParts.fontshell import RGlyph 89 | testGlyph = RGlyph() 90 | testGlyph.name = "testGlyph" 91 | testGlyph.width = 500 92 | testPen = testGlyph.getPen() 93 | testPen.moveTo((10, 0)) 94 | testPen.lineTo((30, 0)) 95 | testPen.lineTo((50, 0)) 96 | testPen.lineTo((70, 0)) 97 | testPen.lineTo((90, 0)) 98 | testPen.endPath() 99 | return testGlyph 100 | 101 | 102 | def _testSpikePen(): 103 | """ 104 | >>> from fontPens.printPen import PrintPen 105 | >>> glyph = _makeTestGlyphLine() 106 | >>> pen = SpikePen(PrintPen()) 107 | >>> glyph.draw(pen) 108 | pen.moveTo((9.999999999999995, 40.0)) 109 | pen.lineTo((30, 0)) 110 | pen.lineTo((50.0, -40.0)) 111 | pen.lineTo((70, 0)) 112 | pen.lineTo((90.0, 40.0)) 113 | pen.endPath() 114 | """ 115 | 116 | 117 | def _testSpikeGlyph(): 118 | """ 119 | >>> from fontPens.printPen import PrintPen 120 | >>> glyph = _makeTestGlyphRect() 121 | >>> spikeGlyph(glyph) #doctest: +ELLIPSIS 122 | >> glyph.draw(PrintPen()) 124 | pen.moveTo((128.2842712474619, 128.2842712474619)) 125 | pen.lineTo((100.0, 120.0)) 126 | pen.lineTo((140.0, 140.0)) 127 | pen.lineTo((100.0, 160.0)) 128 | pen.lineTo((140.0, 180.0)) 129 | pen.lineTo((100.0, 200.0)) 130 | pen.lineTo((140.0, 220.0)) 131 | pen.lineTo((100.0, 240.0)) 132 | pen.lineTo((140.0, 260.0)) 133 | pen.lineTo((100.0, 280.0)) 134 | pen.lineTo((128.2842712474619, 271.7157287525381)) 135 | pen.lineTo((120.0, 300.0)) 136 | pen.lineTo((140.0, 260.0)) 137 | pen.lineTo((160.0, 300.0)) 138 | pen.lineTo((180.0, 260.0)) 139 | pen.lineTo((200.0, 300.0)) 140 | pen.lineTo((220.0, 260.0)) 141 | pen.lineTo((240.0, 300.0)) 142 | pen.lineTo((260.0, 260.0)) 143 | pen.lineTo((280.0, 300.0)) 144 | pen.lineTo((271.7157287525381, 271.7157287525381)) 145 | pen.lineTo((300.0, 280.0)) 146 | pen.lineTo((260.0, 260.0)) 147 | pen.lineTo((300.0, 240.0)) 148 | pen.lineTo((260.0, 220.0)) 149 | pen.lineTo((300.0, 200.0)) 150 | pen.lineTo((260.0, 180.0)) 151 | pen.lineTo((300.0, 160.0)) 152 | pen.lineTo((260.0, 140.0)) 153 | pen.lineTo((300.0, 120.0)) 154 | pen.lineTo((271.7157287525381, 128.2842712474619)) 155 | pen.lineTo((280.0, 100.0)) 156 | pen.lineTo((260.0, 140.0)) 157 | pen.lineTo((240.0, 100.0)) 158 | pen.lineTo((220.0, 140.0)) 159 | pen.lineTo((200.0, 100.0)) 160 | pen.lineTo((180.0, 140.0)) 161 | pen.lineTo((160.0, 100.0)) 162 | pen.lineTo((140.0, 140.0)) 163 | pen.lineTo((120.0, 100.0)) 164 | pen.closePath() 165 | """ 166 | 167 | 168 | if __name__ == "__main__": 169 | import doctest 170 | doctest.testmod() -------------------------------------------------------------------------------- /Lib/fontPens/marginPen.py: -------------------------------------------------------------------------------- 1 | from fontTools.pens.basePen import BasePen 2 | from fontTools.misc.bezierTools import splitLine, splitCubic 3 | 4 | 5 | class MarginPen(BasePen): 6 | """ 7 | Pen to calculate the margins at a given height or width. 8 | 9 | - isHorizontal = True: slice the glyph at y=value. 10 | - isHorizontal = False: slice the glyph at x=value. 11 | 12 | Possible optimisation: 13 | Initialise the pen object with a list of points we want to measure, 14 | then draw the glyph once, but do the splitLine() math for all measure points. 15 | """ 16 | 17 | def __init__(self, glyphSet, value, isHorizontal=True): 18 | BasePen.__init__(self, glyphSet) 19 | self.value = value 20 | self.hits = {} 21 | self.filterDoubles = True 22 | self.contourIndex = None 23 | self.startPt = None 24 | self.currentPt = None 25 | self.isHorizontal = isHorizontal 26 | 27 | def _moveTo(self, pt): 28 | self.currentPt = pt 29 | self.startPt = pt 30 | if self.contourIndex is None: 31 | self.contourIndex = 0 32 | else: 33 | self.contourIndex += 1 34 | 35 | def _lineTo(self, pt): 36 | if self.filterDoubles: 37 | if pt == self.currentPt: 38 | return 39 | hits = splitLine(self.currentPt, pt, self.value, self.isHorizontal) 40 | if len(hits) > 1: 41 | # result will be 2 tuples of 2 coordinates 42 | # first two points: start to intersect 43 | # second two points: intersect to end 44 | # so, second point in first tuple is the intersect 45 | # then, the first coordinate of that point is the x. 46 | if self.contourIndex not in self.hits: 47 | self.hits[self.contourIndex] = [] 48 | if self.isHorizontal: 49 | self.hits[self.contourIndex].append(round(hits[0][-1][0], 4)) 50 | else: 51 | self.hits[self.contourIndex].append(round(hits[0][-1][1], 4)) 52 | if self.isHorizontal and pt[1] == self.value: 53 | # it could happen 54 | if self.contourIndex not in self.hits: 55 | self.hits[self.contourIndex] = [] 56 | self.hits[self.contourIndex].append(pt[0]) 57 | elif (not self.isHorizontal) and (pt[0] == self.value): 58 | # it could happen 59 | if self.contourIndex not in self.hits: 60 | self.hits[self.contourIndex] = [] 61 | self.hits[self.contourIndex].append(pt[1]) 62 | self.currentPt = pt 63 | 64 | def _curveToOne(self, pt1, pt2, pt3): 65 | hits = splitCubic(self.currentPt, pt1, pt2, pt3, self.value, self.isHorizontal) 66 | for i in range(len(hits) - 1): 67 | # a number of intersections is possible. Just take the 68 | # last point of each segment. 69 | if self.contourIndex not in self.hits: 70 | self.hits[self.contourIndex] = [] 71 | if self.isHorizontal: 72 | self.hits[self.contourIndex].append(round(hits[i][-1][0], 4)) 73 | else: 74 | self.hits[self.contourIndex].append(round(hits[i][-1][1], 4)) 75 | if self.isHorizontal and pt3[1] == self.value: 76 | # it could happen 77 | if self.contourIndex not in self.hits: 78 | self.hits[self.contourIndex] = [] 79 | self.hits[self.contourIndex].append(pt3[0]) 80 | if (not self.isHorizontal) and (pt3[0] == self.value): 81 | # it could happen 82 | if self.contourIndex not in self.hits: 83 | self.hits[self.contourIndex] = [] 84 | self.hits[self.contourIndex].append(pt3[1]) 85 | self.currentPt = pt3 86 | 87 | def _closePath(self): 88 | if self.currentPt != self.startPt: 89 | self._lineTo(self.startPt) 90 | self.currentPt = self.startPt = None 91 | 92 | def _endPath(self): 93 | self.currentPt = None 94 | 95 | def getMargins(self): 96 | """ 97 | Return the extremes of the slice for all contours combined, i.e. the whole glyph. 98 | """ 99 | allHits = [] 100 | for index, pts in self.hits.items(): 101 | allHits.extend(pts) 102 | if allHits: 103 | return min(allHits), max(allHits) 104 | return None 105 | 106 | def getContourMargins(self): 107 | """ 108 | Return the extremes of the slice for each contour. 109 | """ 110 | allHits = {} 111 | for index, pts in self.hits.items(): 112 | unique = list(set(pts)) 113 | unique.sort() 114 | allHits[index] = unique 115 | return allHits 116 | 117 | def getAll(self): 118 | """ 119 | Return all the slices. 120 | """ 121 | allHits = [] 122 | for index, pts in self.hits.items(): 123 | allHits.extend(pts) 124 | unique = list(set(allHits)) 125 | unique.sort() 126 | return unique 127 | 128 | 129 | # ========= 130 | # = tests = 131 | # ========= 132 | 133 | def _makeTestGlyph(): 134 | # make a simple glyph that we can test the pens with. 135 | from fontParts.fontshell import RGlyph 136 | testGlyph = RGlyph() 137 | testGlyph.name = "testGlyph" 138 | testGlyph.width = 1000 139 | pen = testGlyph.getPen() 140 | pen.moveTo((100, 100)) 141 | pen.lineTo((900, 100)) 142 | pen.lineTo((900, 800)) 143 | pen.lineTo((100, 800)) 144 | # a curve 145 | pen.curveTo((120, 700), (120, 300), (100, 100)) 146 | pen.closePath() 147 | return testGlyph 148 | 149 | 150 | def _testMarginPen(): 151 | """ 152 | >>> glyph = _makeTestGlyph() 153 | >>> pen = MarginPen(dict(), 200, isHorizontal=True) 154 | >>> glyph.draw(pen) 155 | >>> pen.getAll() 156 | [107.5475, 900.0] 157 | """ 158 | 159 | 160 | def _makeTestFont(): 161 | # make a simple glyph that we can test the pens with. 162 | from fontParts.fontshell import RFont 163 | testFont = RFont() 164 | baseGlyph = testFont.newGlyph("baseGlyph") 165 | pen = baseGlyph.getPen() 166 | pen.moveTo((100, 100)) 167 | pen.lineTo((600, 100)) 168 | pen.lineTo((600, 600)) 169 | pen.lineTo((100, 600)) 170 | pen.closePath() 171 | 172 | testGlyph = testFont.newGlyph("testGlyph") 173 | testGlyph.appendComponent("baseGlyph", scale=.5) 174 | return testGlyph 175 | 176 | 177 | def _testMarginPenComponent(): 178 | """ 179 | >>> glyph = _makeTestFont() 180 | >>> pen = MarginPen(glyph.layer, 200, isHorizontal=True) 181 | >>> glyph.draw(pen) 182 | >>> pen.getAll() 183 | [50.0, 300.0] 184 | """ 185 | 186 | 187 | if __name__ == "__main__": 188 | import doctest 189 | doctest.testmod() 190 | -------------------------------------------------------------------------------- /Lib/fontPens/penTools.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | from fontTools.misc.bezierTools import calcQuadraticArcLengthC 4 | 5 | 6 | def distance(pt1, pt2): 7 | """ 8 | The distance between two points 9 | 10 | >>> distance((0, 0), (10, 0)) 11 | 10.0 12 | >>> distance((0, 0), (-10, 0)) 13 | 10.0 14 | >>> distance((0, 0), (0, 10)) 15 | 10.0 16 | >>> distance((0, 0), (0, -10)) 17 | 10.0 18 | >>> distance((10, 10), (13, 14)) 19 | 5.0 20 | """ 21 | return math.sqrt((pt1[0] - pt2[0])**2 + (pt1[1] - pt2[1])**2) 22 | 23 | 24 | def middlePoint(pt1, pt2): 25 | """ 26 | Return the point that lies in between the two input points. 27 | """ 28 | (x0, y0), (x1, y1) = pt1, pt2 29 | return 0.5 * (x0 + x1), 0.5 * (y0 + y1) 30 | 31 | 32 | def getCubicPoint(t, pt0, pt1, pt2, pt3): 33 | """ 34 | Return the point for t on the cubic curve defined by pt0, pt1, pt2, pt3. 35 | 36 | >>> getCubicPoint(0.00, (0, 0), (50, -10), (80, 50), (120, 40)) 37 | (0, 0) 38 | >>> getCubicPoint(0.20, (0, 0), (50, -10), (80, 50), (120, 40)) 39 | (27.84, 1.280000000000002) 40 | >>> getCubicPoint(0.50, (0, 0), (50, -10), (80, 50), (120, 40)) 41 | (63.75, 20.0) 42 | >>> getCubicPoint(0.85, (0, 0), (50, -10), (80, 50), (120, 40)) 43 | (102.57375, 40.2475) 44 | >>> getCubicPoint(1.00, (0, 0), (50, -10), (80, 50), (120, 40)) 45 | (120, 40) 46 | """ 47 | if t == 0: 48 | return pt0 49 | if t == 1: 50 | return pt3 51 | if t == 0.5: 52 | a = middlePoint(pt0, pt1) 53 | b = middlePoint(pt1, pt2) 54 | c = middlePoint(pt2, pt3) 55 | d = middlePoint(a, b) 56 | e = middlePoint(b, c) 57 | return middlePoint(d, e) 58 | else: 59 | cx = (pt1[0] - pt0[0]) * 3 60 | cy = (pt1[1] - pt0[1]) * 3 61 | bx = (pt2[0] - pt1[0]) * 3 - cx 62 | by = (pt2[1] - pt1[1]) * 3 - cy 63 | ax = pt3[0] - pt0[0] - cx - bx 64 | ay = pt3[1] - pt0[1] - cy - by 65 | t3 = t ** 3 66 | t2 = t * t 67 | x = ax * t3 + bx * t2 + cx * t + pt0[0] 68 | y = ay * t3 + by * t2 + cy * t + pt0[1] 69 | return x, y 70 | 71 | 72 | def getQuadraticPoint(t, pt0, pt1, pt2): 73 | """ 74 | Return the point for t on the quadratic curve defined by pt0, pt1, pt2, pt3. 75 | 76 | >>> getQuadraticPoint(0.00, (0, 0), (50, -10), (80, 50)) 77 | (0, 0) 78 | >>> getQuadraticPoint(0.21, (0, 0), (50, -10), (80, 50)) 79 | (20.118, -1.113) 80 | >>> getQuadraticPoint(0.50, (0, 0), (50, -10), (80, 50)) 81 | (45.0, 7.5) 82 | >>> getQuadraticPoint(0.87, (0, 0), (50, -10), (80, 50)) 83 | (71.862, 35.583) 84 | >>> getQuadraticPoint(1.00, (0, 0), (50, -10), (80, 50)) 85 | (80, 50) 86 | """ 87 | if t == 0: 88 | return pt0 89 | if t == 1: 90 | return pt2 91 | a = (1 - t) ** 2 92 | b = 2 * (1 - t) * t 93 | c = t ** 2 94 | 95 | x = a * pt0[0] + b * pt1[0] + c * pt2[0]; 96 | y = a * pt0[1] + b * pt1[1] + c * pt2[1]; 97 | return x, y 98 | 99 | 100 | def getCubicPoints(ts, pt0, pt1, pt2, pt3): 101 | """ 102 | Return a list of points for increments of t on the cubic curve defined by pt0, pt1, pt2, pt3. 103 | 104 | >>> getCubicPoints([i/10 for i in range(11)], (0, 0), (50, -10), (80, 50), (120, 40)) 105 | [(0.0, 0.0), (14.43, -1.0399999999999996), (27.84, 1.280000000000002), (40.41, 6.119999999999999), (52.32, 12.640000000000008), (63.75, 20.0), (74.88, 27.36), (85.89, 33.88), (96.96, 38.72000000000001), (108.27000000000001, 41.040000000000006), (120.0, 40.0)] 106 | """ 107 | (x0, y0), (x1, y1) = pt0, pt1 108 | cx = (x1 - x0) * 3 109 | cy = (y1 - y0) * 3 110 | bx = (pt2[0] - x1) * 3 - cx 111 | by = (pt2[1] - y1) * 3 - cy 112 | ax = pt3[0] - x0 - cx - bx 113 | ay = pt3[1] - y0 - cy - by 114 | path = [] 115 | for t in ts: 116 | t3 = t ** 3 117 | t2 = t * t 118 | x = ax * t3 + bx * t2 + cx * t + x0 119 | y = ay * t3 + by * t2 + cy * t + y0 120 | path.append((x, y)) 121 | return path 122 | 123 | 124 | def estimateCubicCurveLength(pt0, pt1, pt2, pt3, precision=10): 125 | """ 126 | Estimate the length of this curve by iterating 127 | through it and averaging the length of the flat bits. 128 | 129 | >>> estimateCubicCurveLength((0, 0), (0, 0), (0, 0), (0, 0), 1000) 130 | 0.0 131 | >>> estimateCubicCurveLength((0, 0), (0, 0), (120, 0), (120, 0), 1000) 132 | 120.0 133 | >>> estimateCubicCurveLength((0, 0), (50, 0), (80, 0), (120, 0), 1000) 134 | 120.0 135 | >>> estimateCubicCurveLength((0, 0), (50, -10), (80, 50), (120, 40), 1) 136 | 126.49110640673517 137 | >>> estimateCubicCurveLength((0, 0), (50, -10), (80, 50), (120, 40), 1000) 138 | 130.4488917899906 139 | """ 140 | length = 0 141 | step = 1.0 / precision 142 | points = getCubicPoints([f * step for f in range(precision + 1)], pt0, pt1, pt2, pt3) 143 | for i in range(len(points) - 1): 144 | pta = points[i] 145 | ptb = points[i + 1] 146 | length += distance(pta, ptb) 147 | return length 148 | 149 | 150 | def estimateQuadraticCurveLength(pt0, pt1, pt2, precision=10): 151 | """ 152 | Estimate the length of this curve by iterating 153 | through it and averaging the length of the flat bits. 154 | 155 | >>> estimateQuadraticCurveLength((0, 0), (0, 0), (0, 0)) # empty segment 156 | 0.0 157 | >>> estimateQuadraticCurveLength((0, 0), (50, 0), (80, 0)) # collinear points 158 | 80.0 159 | >>> estimateQuadraticCurveLength((0, 0), (50, 20), (100, 40)) # collinear points 160 | 107.70329614269008 161 | >>> estimateQuadraticCurveLength((0, 0), (0, 100), (100, 0)) 162 | 153.6861437729263 163 | >>> estimateQuadraticCurveLength((0, 0), (50, -10), (80, 50)) 164 | 102.4601733446439 165 | >>> estimateQuadraticCurveLength((0, 0), (40, 0), (-40, 0)) # collinear points, control point outside 166 | 66.39999999999999 167 | >>> estimateQuadraticCurveLength((0, 0), (40, 0), (0, 0)) # collinear points, looping back 168 | 40.0 169 | """ 170 | points = [] 171 | length = 0 172 | step = 1.0 / precision 173 | factors = range(0, precision + 1) 174 | for i in factors: 175 | points.append(getQuadraticPoint(i * step, pt0, pt1, pt2)) 176 | for i in range(len(points) - 1): 177 | pta = points[i] 178 | ptb = points[i + 1] 179 | length += distance(pta, ptb) 180 | return length 181 | 182 | 183 | def interpolatePoint(pt1, pt2, v): 184 | """ 185 | interpolate point by factor v 186 | 187 | >>> interpolatePoint((0, 0), (10, 10), 0.5) 188 | (5.0, 5.0) 189 | >>> interpolatePoint((0, 0), (10, 10), 0.6) 190 | (6.0, 6.0) 191 | """ 192 | (xa, ya), (xb, yb) = pt1, pt2 193 | if not isinstance(v, tuple): 194 | xv = v 195 | yv = v 196 | else: 197 | xv, yv = v 198 | return xa + (xb - xa) * xv, ya + (yb - ya) * yv 199 | 200 | 201 | if __name__ == "__main__": 202 | import doctest 203 | doctest.testmod() 204 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | import sys 3 | from setuptools import setup, find_packages, Command 4 | from distutils import log 5 | 6 | 7 | class bump_version(Command): 8 | 9 | description = "increment the package version and commit the changes" 10 | 11 | user_options = [ 12 | ("major", None, "bump the first digit, for incompatible API changes"), 13 | ("minor", None, "bump the second digit, for new backward-compatible features"), 14 | ("patch", None, "bump the third digit, for bug fixes (default)"), 15 | ] 16 | 17 | def initialize_options(self): 18 | self.minor = False 19 | self.major = False 20 | self.patch = False 21 | 22 | def finalize_options(self): 23 | part = None 24 | for attr in ("major", "minor", "patch"): 25 | if getattr(self, attr, False): 26 | if part is None: 27 | part = attr 28 | else: 29 | from distutils.errors import DistutilsOptionError 30 | raise DistutilsOptionError( 31 | "version part options are mutually exclusive") 32 | self.part = part or "patch" 33 | 34 | def bumpversion(self, part, **kwargs): 35 | """ Run bump2version.main() with the specified arguments. 36 | """ 37 | import bumpversion 38 | 39 | args = ['--verbose'] if self.verbose > 1 else [] 40 | for k, v in kwargs.items(): 41 | k = "--{}".format(k.replace("_", "-")) 42 | is_bool = isinstance(v, bool) and v is True 43 | args.extend([k] if is_bool else [k, str(v)]) 44 | args.append(part) 45 | 46 | log.debug( 47 | "$ bumpversion %s" % " ".join(a.replace(" ", "\\ ") for a in args)) 48 | 49 | bumpversion.main(args) 50 | 51 | def run(self): 52 | log.info("bumping '%s' version" % self.part) 53 | self.bumpversion(self.part) 54 | 55 | 56 | class release(bump_version): 57 | """Drop the developmental release '.devN' suffix from the package version, 58 | open the default text $EDITOR to write release notes, commit the changes 59 | and generate a git tag. 60 | 61 | Release notes can also be set with the -m/--message option, or by reading 62 | from standard input. 63 | """ 64 | 65 | description = "tag a new release" 66 | 67 | user_options = [ 68 | ("message=", 'm', "message containing the release notes"), 69 | ] 70 | 71 | def initialize_options(self): 72 | self.message = None 73 | 74 | def finalize_options(self): 75 | import re 76 | 77 | current_version = self.distribution.metadata.get_version() 78 | if not re.search(r"\.dev[0-9]+", current_version): 79 | from distutils.errors import DistutilsSetupError 80 | raise DistutilsSetupError( 81 | "current version (%s) has no '.devN' suffix.\n " 82 | "Run 'setup.py bump_version' with any of " 83 | "--major, --minor, --patch options" % current_version) 84 | 85 | message = self.message 86 | if message is None: 87 | if sys.stdin.isatty(): 88 | # stdin is interactive, use editor to write release notes 89 | message = self.edit_release_notes() 90 | else: 91 | # read release notes from stdin pipe 92 | message = sys.stdin.read() 93 | 94 | if not message.strip(): 95 | from distutils.errors import DistutilsSetupError 96 | raise DistutilsSetupError("release notes message is empty") 97 | 98 | self.message = "v{new_version}\n\n%s" % message 99 | 100 | @staticmethod 101 | def edit_release_notes(): 102 | """Use the default text $EDITOR to write release notes. 103 | If $EDITOR is not set, use 'nano'.""" 104 | from tempfile import mkstemp 105 | import os 106 | import shlex 107 | import subprocess 108 | 109 | text_editor = shlex.split(os.environ.get('EDITOR', 'nano')) 110 | 111 | fd, tmp = mkstemp(prefix='bumpversion-') 112 | try: 113 | os.close(fd) 114 | with open(tmp, 'w') as f: 115 | f.write("\n\n# Write release notes.\n" 116 | "# Lines starting with '#' will be ignored.") 117 | subprocess.check_call(text_editor + [tmp]) 118 | with open(tmp, 'r') as f: 119 | changes = "".join( 120 | l for l in f.readlines() if not l.startswith('#')) 121 | finally: 122 | os.remove(tmp) 123 | return changes 124 | 125 | def run(self): 126 | log.info("stripping developmental release suffix") 127 | # drop '.dev0' suffix, commit with given message and create git tag 128 | self.bumpversion("release", 129 | tag=True, 130 | message="Release {new_version}", 131 | tag_message=self.message) 132 | 133 | 134 | needs_pytest = {'pytest', 'test'}.intersection(sys.argv) 135 | pytest_runner = ['pytest_runner'] if needs_pytest else [] 136 | needs_wheel = {'bdist_wheel'}.intersection(sys.argv) 137 | wheel = ['wheel'] if needs_wheel else [] 138 | needs_bump2version = {'release', 'bump_version'}.intersection(sys.argv) 139 | bump2version = ['bump2version'] if needs_bump2version else [] 140 | 141 | with open('README.rst', 'r') as f: 142 | long_description = f.read() 143 | 144 | setup_params = dict( 145 | name="fontPens", 146 | version="0.2.5.dev0", 147 | description=("A collection of classes implementing the pen " 148 | "protocol for manipulating glyphs."), 149 | author="Just van Rossum, Tal Leming, Erik van Blokland, Ben Kiel, others", 150 | author_email="info@robofab.com", 151 | maintainer="Just van Rossum, Tal Leming, Erik van Blokland, Ben Kiel", 152 | maintainer_email="info@robofab.com", 153 | url="https://github.com/robotools/fontPens", 154 | license="OpenSource, BSD-style", 155 | platforms=["Any"], 156 | python_requires=">=3.6", 157 | long_description=long_description, 158 | package_dir={'': 'Lib'}, 159 | packages=find_packages('Lib'), 160 | include_package_data=True, 161 | setup_requires=pytest_runner + wheel + bump2version, 162 | tests_require=[ 163 | 'pytest', 164 | 'fontParts>=0.8.1' 165 | ], 166 | install_requires=[ 167 | "FontTools>=3.32.0", 168 | ], 169 | cmdclass={ 170 | "release": release, 171 | "bump_version": bump_version, 172 | }, 173 | classifiers=[ 174 | "Development Status :: 4 - Beta", 175 | "Environment :: Console", 176 | "Environment :: Other Environment", 177 | "Intended Audience :: Developers", 178 | "Intended Audience :: End Users/Desktop", 179 | "License :: OSI Approved :: BSD License", 180 | "Natural Language :: English", 181 | "Operating System :: OS Independent", 182 | "Programming Language :: Python", 183 | "Programming Language :: Python :: 3", 184 | "Topic :: Multimedia :: Graphics", 185 | "Topic :: Multimedia :: Graphics :: Graphics Conversion", 186 | ], 187 | ) 188 | 189 | 190 | if __name__ == "__main__": 191 | setup(**setup_params) 192 | -------------------------------------------------------------------------------- /Lib/fontPens/flattenPen.py: -------------------------------------------------------------------------------- 1 | from fontTools.misc.bezierTools import calcQuadraticArcLength 2 | from fontTools.pens.basePen import BasePen 3 | 4 | from fontPens.penTools import estimateCubicCurveLength, distance, interpolatePoint, getCubicPoint, getQuadraticPoint 5 | 6 | 7 | class FlattenPen(BasePen): 8 | """ 9 | This filter pen processes the contours into a series of straight lines by flattening the curves. 10 | 11 | - otherPen: a different segment pen object this filter should draw the results with. 12 | - approximateSegmentLength: the length you want the flattened segments to be (roughly). 13 | - segmentLines: whether to cut straight lines into segments as well. 14 | - filterDoubles: don't draw if a segment goes to the same coordinate. 15 | """ 16 | 17 | def __init__(self, otherPen, approximateSegmentLength=5, segmentLines=False, filterDoubles=True): 18 | self.approximateSegmentLength = approximateSegmentLength 19 | BasePen.__init__(self, {}) 20 | self.otherPen = otherPen 21 | self.currentPt = None 22 | self.firstPt = None 23 | self.segmentLines = segmentLines 24 | self.filterDoubles = filterDoubles 25 | 26 | def _moveTo(self, pt): 27 | self.otherPen.moveTo(pt) 28 | self.currentPt = pt 29 | self.firstPt = pt 30 | 31 | def _lineTo(self, pt): 32 | if self.filterDoubles: 33 | if pt == self.currentPt: 34 | return 35 | if not self.segmentLines: 36 | self.otherPen.lineTo(pt) 37 | self.currentPt = pt 38 | return 39 | d = distance(self.currentPt, pt) 40 | maxSteps = int(round(d / self.approximateSegmentLength)) 41 | if maxSteps < 1: 42 | self.otherPen.lineTo(pt) 43 | self.currentPt = pt 44 | return 45 | step = 1.0 / maxSteps 46 | for factor in range(1, maxSteps + 1): 47 | self.otherPen.lineTo(interpolatePoint(self.currentPt, pt, factor * step)) 48 | self.currentPt = pt 49 | 50 | def _curveToOne(self, pt1, pt2, pt3): 51 | falseCurve = (pt1 == self.currentPt) and (pt2 == pt3) 52 | if falseCurve: 53 | self._lineTo(pt3) 54 | return 55 | est = estimateCubicCurveLength(self.currentPt, pt1, pt2, pt3) / self.approximateSegmentLength 56 | maxSteps = int(round(est)) 57 | if maxSteps < 1: 58 | self.otherPen.lineTo(pt3) 59 | self.currentPt = pt3 60 | return 61 | step = 1.0 / maxSteps 62 | for factor in range(1, maxSteps + 1): 63 | pt = getCubicPoint(factor * step, self.currentPt, pt1, pt2, pt3) 64 | self.otherPen.lineTo(pt) 65 | self.currentPt = pt3 66 | 67 | def _qCurveToOne(self, pt1, pt2): 68 | falseCurve = (pt1 == self.currentPt) or (pt1 == pt2) 69 | if falseCurve: 70 | self._lineTo(pt2) 71 | return 72 | est = calcQuadraticArcLength(self.currentPt, pt1, pt2) / self.approximateSegmentLength 73 | maxSteps = int(round(est)) 74 | if maxSteps < 1: 75 | self.otherPen.lineTo(pt2) 76 | self.currentPt = pt2 77 | return 78 | step = 1.0 / maxSteps 79 | for factor in range(1, maxSteps + 1): 80 | pt = getQuadraticPoint(factor * step, self.currentPt, pt1, pt2) 81 | self.otherPen.lineTo(pt) 82 | self.currentPt = pt2 83 | 84 | def _closePath(self): 85 | self.lineTo(self.firstPt) 86 | self.otherPen.closePath() 87 | self.currentPt = None 88 | 89 | def _endPath(self): 90 | self.otherPen.endPath() 91 | self.currentPt = None 92 | 93 | def addComponent(self, glyphName, transformation): 94 | self.otherPen.addComponent(glyphName, transformation) 95 | 96 | 97 | def flattenGlyph(aGlyph, threshold=10, segmentLines=True): 98 | """ 99 | Convenience function that applies the **FlattenPen** pen to a glyph in place. 100 | """ 101 | if len(aGlyph) == 0: 102 | return aGlyph 103 | from fontTools.pens.recordingPen import RecordingPen 104 | recorder = RecordingPen() 105 | filterpen = FlattenPen(recorder, approximateSegmentLength=threshold, segmentLines=segmentLines) 106 | aGlyph.draw(filterpen) 107 | aGlyph.clear() 108 | recorder.replay(aGlyph.getPen()) 109 | return aGlyph 110 | 111 | 112 | 113 | class SamplingPen(BasePen): 114 | """ 115 | This filter pen processes the contours into a series of straight lines by flattening the curves. 116 | Unlike FlattenPen, SamplingPen draws each curve with the given number of steps. 117 | 118 | - otherPen: a different segment pen object this filter should draw the results with. 119 | - steps: the number of steps for each curve segment. 120 | - filterDoubles: don't draw if a segment goes to the same coordinate. 121 | """ 122 | 123 | def __init__(self, otherPen, steps=10, filterDoubles=True): 124 | BasePen.__init__(self, {}) 125 | self.otherPen = otherPen 126 | self.currentPt = None 127 | self.firstPt = None 128 | self.steps = steps 129 | self.filterDoubles = filterDoubles 130 | 131 | def _moveTo(self, pt): 132 | self.otherPen.moveTo(pt) 133 | self.currentPt = pt 134 | self.firstPt = pt 135 | 136 | def _lineTo(self, pt): 137 | if self.filterDoubles: 138 | if pt == self.currentPt: 139 | return 140 | self.otherPen.lineTo(pt) 141 | self.currentPt = pt 142 | return 143 | 144 | def _curveToOne(self, pt1, pt2, pt3): 145 | falseCurve = (pt1 == self.currentPt) and (pt2 == pt3) 146 | if falseCurve: 147 | self._lineTo(pt3) 148 | return 149 | step = 1.0 / self.steps 150 | for factor in range(1, self.steps + 1): 151 | pt = getCubicPoint(factor * step, self.currentPt, pt1, pt2, pt3) 152 | self.otherPen.lineTo(pt) 153 | self.currentPt = pt3 154 | 155 | def _qCurveToOne(self, pt1, pt2): 156 | falseCurve = (pt1 == self.currentPt) or (pt1 == pt2) 157 | if falseCurve: 158 | self._lineTo(pt2) 159 | return 160 | step = 1.0 / self.steps 161 | for factor in range(1, self.steps + 1): 162 | pt = getQuadraticPoint(factor * step, self.currentPt, pt1, pt2) 163 | self.otherPen.lineTo(pt) 164 | self.currentPt = pt2 165 | 166 | def _closePath(self): 167 | self.lineTo(self.firstPt) 168 | self.otherPen.closePath() 169 | self.currentPt = None 170 | 171 | def _endPath(self): 172 | self.otherPen.endPath() 173 | self.currentPt = None 174 | 175 | def addComponent(self, glyphName, transformation): 176 | self.otherPen.addComponent(glyphName, transformation) 177 | 178 | 179 | def samplingGlyph(aGlyph, steps=10): 180 | """ 181 | Convenience function that applies the **SamplingPen** pen to a glyph in place. 182 | """ 183 | if len(aGlyph) == 0: 184 | return aGlyph 185 | from fontTools.pens.recordingPen import RecordingPen 186 | recorder = RecordingPen() 187 | filterpen = SamplingPen(recorder, steps=steps) 188 | aGlyph.draw(filterpen) 189 | aGlyph.clear() 190 | recorder.replay(aGlyph.getPen()) 191 | return aGlyph 192 | 193 | # ========= 194 | # = tests = 195 | # ========= 196 | 197 | def _makeTestGlyph(): 198 | # make a simple glyph that we can test the pens with. 199 | from fontParts.fontshell import RGlyph 200 | testGlyph = RGlyph() 201 | testGlyph.name = "testGlyph" 202 | testGlyph.width = 500 203 | pen = testGlyph.getPen() 204 | pen.moveTo((10, 10)) 205 | pen.lineTo((10, 30)) 206 | pen.lineTo((30, 30)) 207 | pen.lineTo((30, 10)) 208 | pen.closePath() 209 | return testGlyph 210 | 211 | 212 | def _testFlattenPen(): 213 | """ 214 | >>> from fontPens.printPen import PrintPen 215 | >>> glyph = _makeTestGlyph() 216 | >>> pen = FlattenPen(PrintPen(), approximateSegmentLength=10, segmentLines=True) 217 | >>> glyph.draw(pen) 218 | pen.moveTo((10, 10)) 219 | pen.lineTo((10.0, 20.0)) 220 | pen.lineTo((10.0, 30.0)) 221 | pen.lineTo((20.0, 30.0)) 222 | pen.lineTo((30.0, 30.0)) 223 | pen.lineTo((30.0, 20.0)) 224 | pen.lineTo((30.0, 10.0)) 225 | pen.lineTo((20.0, 10.0)) 226 | pen.lineTo((10.0, 10.0)) 227 | pen.closePath() 228 | """ 229 | 230 | 231 | def _testFlattenGlyph(): 232 | """ 233 | >>> from fontPens.printPen import PrintPen 234 | >>> glyph = _makeTestGlyph() 235 | >>> flattenGlyph(glyph) #doctest: +ELLIPSIS 236 | >> glyph.draw(PrintPen()) 238 | pen.moveTo((10.0, 10.0)) 239 | pen.lineTo((10.0, 20.0)) 240 | pen.lineTo((10.0, 30.0)) 241 | pen.lineTo((20.0, 30.0)) 242 | pen.lineTo((30.0, 30.0)) 243 | pen.lineTo((30.0, 20.0)) 244 | pen.lineTo((30.0, 10.0)) 245 | pen.lineTo((20.0, 10.0)) 246 | pen.closePath() 247 | """ 248 | 249 | 250 | def _makeTestGlyphWithCurve(): 251 | # make a simple glyph that we can test the pens with. 252 | from fontParts.fontshell import RGlyph 253 | testGlyph = RGlyph() 254 | testGlyph.name = "testGlyph" 255 | testGlyph.width = 500 256 | pen = testGlyph.getPen() 257 | pen.moveTo((84, 37)) 258 | pen.lineTo((348, 37)) 259 | pen.lineTo((348, 300)) 260 | pen.curveTo((265, 350.0), (177, 350.0), (84, 300)) 261 | pen.closePath() 262 | return testGlyph 263 | 264 | 265 | def _testFlattenPen(): 266 | """ 267 | >>> from fontPens.printPen import PrintPen 268 | >>> glyph = _makeTestGlyphWithCurve() 269 | >>> pen = SamplingPen(PrintPen(), steps=2) 270 | >>> glyph.draw(pen) 271 | pen.moveTo((84, 37)) 272 | pen.lineTo((348, 37)) 273 | pen.lineTo((348, 300)) 274 | pen.lineTo((219.75, 337.5)) 275 | pen.lineTo((84, 300)) 276 | pen.lineTo((84, 37)) 277 | pen.closePath() 278 | 279 | """ 280 | 281 | 282 | def _testFlattenGlyph(): 283 | """ 284 | >>> from fontPens.printPen import PrintPen 285 | >>> glyph = _makeTestGlyphWithCurve() 286 | >>> samplingGlyph(glyph) #doctest: +ELLIPSIS 287 | >> glyph.draw(PrintPen()) 289 | pen.moveTo((84, 37)) 290 | pen.lineTo((348, 37)) 291 | pen.lineTo((348, 300)) 292 | pen.lineTo((322.95, 313.5)) 293 | pen.lineTo((297.6, 324.0)) 294 | pen.lineTo((271.95, 331.5)) 295 | pen.lineTo((246.0, 336.0)) 296 | pen.lineTo((219.75, 337.5)) 297 | pen.lineTo((193.19999999999996, 336.0)) 298 | pen.lineTo((166.35, 331.5)) 299 | pen.lineTo((139.2, 324.0)) 300 | pen.lineTo((111.75, 313.5)) 301 | pen.lineTo((84, 300)) 302 | pen.closePath() 303 | """ 304 | 305 | if __name__ == "__main__": 306 | import doctest 307 | doctest.testmod() 308 | --------------------------------------------------------------------------------