aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorElliott Hughes <enh@google.com>2024-04-01 15:28:06 +0000
committerElliott Hughes <enh@google.com>2024-04-01 15:28:06 +0000
commit8e084f2ba2ed3227158ec488d086798ef80d0b27 (patch)
tree8f75003d350867df75829811ecab46ead38e6cda
parente753d9084138bc0420c72e7ea7394cac6fc0063e (diff)
downloadfonttools-master.tar.gz
Upgrade fonttools to 4.49.0HEADmastermain
This project was upgraded with external_updater. Usage: tools/external_updater/updater.sh update external/fonttools For more info, check https://cs.android.com/android/platform/superproject/+/main:tools/external_updater/README.md Test: TreeHugger Change-Id: I5054ab1711063135fa20c2ca2eca8301c07d0267
-rw-r--r--.github/workflows/test.yml18
-rw-r--r--.github/workflows/wheels.yml28
-rw-r--r--Doc/docs-requirements.txt4
-rw-r--r--Doc/source/designspaceLib/index.rst35
-rw-r--r--Doc/source/designspaceLib/v5_class_diagram.pngbin273627 -> 290002 bytes
-rw-r--r--Doc/source/designspaceLib/v5_class_diagram.puml2
-rw-r--r--Doc/source/designspaceLib/xml.rst104
-rw-r--r--Lib/fontTools/__init__.py2
-rw-r--r--Lib/fontTools/afmLib.py6
-rw-r--r--Lib/fontTools/cffLib/__init__.py1
-rw-r--r--Lib/fontTools/colorLib/builder.py15
-rw-r--r--Lib/fontTools/config/__init__.py1
-rw-r--r--Lib/fontTools/designspaceLib/__init__.py100
-rw-r--r--Lib/fontTools/designspaceLib/__main__.py6
-rw-r--r--Lib/fontTools/designspaceLib/statNames.py1
-rw-r--r--Lib/fontTools/feaLib/builder.py35
-rw-r--r--Lib/fontTools/feaLib/lexer.py4
-rw-r--r--Lib/fontTools/feaLib/parser.py8
-rw-r--r--Lib/fontTools/merge/__init__.py54
-rw-r--r--Lib/fontTools/merge/layout.py16
-rw-r--r--Lib/fontTools/merge/options.py3
-rw-r--r--Lib/fontTools/misc/bezierTools.py16
-rw-r--r--Lib/fontTools/misc/classifyTools.py1
-rw-r--r--Lib/fontTools/misc/cliTools.py1
-rw-r--r--Lib/fontTools/misc/configTools.py1
-rw-r--r--Lib/fontTools/misc/dictTools.py1
-rw-r--r--Lib/fontTools/misc/etree.py1
-rw-r--r--Lib/fontTools/misc/filenames.py1
-rw-r--r--Lib/fontTools/misc/textTools.py1
-rw-r--r--Lib/fontTools/misc/transform.py1
-rw-r--r--Lib/fontTools/misc/vector.py1
-rw-r--r--Lib/fontTools/otlLib/builder.py372
-rw-r--r--Lib/fontTools/pens/basePen.py3
-rw-r--r--Lib/fontTools/pens/boundsPen.py2
-rw-r--r--Lib/fontTools/pens/filterPen.py1
-rw-r--r--Lib/fontTools/pens/hashPointPen.py14
-rw-r--r--Lib/fontTools/pens/momentsPen.py3
-rw-r--r--Lib/fontTools/pens/pointInsidePen.py1
-rw-r--r--Lib/fontTools/pens/quartzPen.py1
-rw-r--r--Lib/fontTools/pens/recordingPen.py34
-rw-r--r--Lib/fontTools/pens/reportLabPen.py1
-rw-r--r--Lib/fontTools/pens/roundingPen.py46
-rw-r--r--Lib/fontTools/pens/statisticsPen.py194
-rw-r--r--Lib/fontTools/pens/svgPathPen.py22
-rw-r--r--Lib/fontTools/pens/teePen.py1
-rw-r--r--Lib/fontTools/pens/transformPen.py1
-rw-r--r--Lib/fontTools/subset/__init__.py19
-rw-r--r--Lib/fontTools/subset/cff.py4
-rw-r--r--Lib/fontTools/svgLib/path/arc.py1
-rw-r--r--Lib/fontTools/t1Lib/__init__.py2
-rw-r--r--Lib/fontTools/ttLib/macUtils.py2
-rw-r--r--Lib/fontTools/ttLib/removeOverlaps.py8
-rw-r--r--Lib/fontTools/ttLib/scaleUpem.py1
-rw-r--r--Lib/fontTools/ttLib/tables/C_O_L_R_.py1
-rw-r--r--Lib/fontTools/ttLib/tables/O_S_2f_2.py130
-rw-r--r--Lib/fontTools/ttLib/tables/T_S_I__0.py1
-rw-r--r--Lib/fontTools/ttLib/tables/T_S_I__1.py1
-rw-r--r--Lib/fontTools/ttLib/tables/T_S_I__2.py1
-rw-r--r--Lib/fontTools/ttLib/tables/T_S_I__3.py1
-rw-r--r--Lib/fontTools/ttLib/tables/T_S_I__5.py1
-rw-r--r--Lib/fontTools/ttLib/tables/TupleVariation.py20
-rw-r--r--Lib/fontTools/ttLib/tables/V_O_R_G_.py1
-rw-r--r--Lib/fontTools/ttLib/tables/_g_l_y_f.py3
-rw-r--r--Lib/fontTools/ttLib/tables/_k_e_r_n.py6
-rw-r--r--Lib/fontTools/ttLib/tables/otBase.py6
-rw-r--r--Lib/fontTools/ttLib/tables/otConverters.py1
-rw-r--r--Lib/fontTools/ttLib/tables/otTables.py35
-rw-r--r--Lib/fontTools/ttLib/tables/otTraverse.py1
-rw-r--r--Lib/fontTools/ttLib/tables/sbixGlyph.py14
-rw-r--r--Lib/fontTools/ttLib/tables/ttProgram.py1
-rw-r--r--Lib/fontTools/ttLib/ttCollection.py1
-rw-r--r--Lib/fontTools/ttLib/ttFont.py8
-rw-r--r--Lib/fontTools/ttLib/ttGlyphSet.py77
-rw-r--r--Lib/fontTools/ttx.py1
-rwxr-xr-xLib/fontTools/ufoLib/__init__.py2
-rw-r--r--Lib/fontTools/ufoLib/converters.py1
-rw-r--r--Lib/fontTools/ufoLib/etree.py1
-rwxr-xr-xLib/fontTools/ufoLib/glifLib.py9
-rw-r--r--Lib/fontTools/ufoLib/plistlib.py1
-rw-r--r--Lib/fontTools/ufoLib/pointPen.py1
-rw-r--r--Lib/fontTools/ufoLib/utils.py1
-rw-r--r--Lib/fontTools/unicodedata/__init__.py6
-rw-r--r--Lib/fontTools/varLib/__init__.py83
-rw-r--r--Lib/fontTools/varLib/featureVars.py144
-rw-r--r--Lib/fontTools/varLib/instancer/__init__.py55
-rw-r--r--Lib/fontTools/varLib/instancer/solver.py4
-rw-r--r--Lib/fontTools/varLib/interpolatable.py1292
-rw-r--r--Lib/fontTools/varLib/interpolatableHelpers.py380
-rw-r--r--Lib/fontTools/varLib/interpolatablePlot.py1269
-rw-r--r--Lib/fontTools/varLib/interpolatableTestContourOrder.py82
-rw-r--r--Lib/fontTools/varLib/interpolatableTestStartingPoint.py105
-rw-r--r--Lib/fontTools/varLib/interpolate_layout.py1
-rw-r--r--Lib/fontTools/varLib/merger.py25
-rw-r--r--Lib/fontTools/varLib/models.py59
-rw-r--r--Lib/fontTools/varLib/mutator.py15
-rw-r--r--METADATA23
-rw-r--r--NEWS.rst112
-rw-r--r--README.rst9
-rw-r--r--Tests/designspaceLib/data/test_avar2.designspace15
-rw-r--r--Tests/designspaceLib/designspace_test.py8
-rw-r--r--Tests/designspaceLib/split_test.py4
-rw-r--r--Tests/feaLib/builder_test.py42
-rw-r--r--Tests/feaLib/data/GSUB_5_formats.ttx247
-rw-r--r--Tests/feaLib/data/GSUB_6_formats.ttx387
-rw-r--r--Tests/feaLib/data/spec4h1.ttx2
-rw-r--r--Tests/feaLib/data/spec5d1.fea10
-rw-r--r--Tests/feaLib/data/spec5d1.ttx16
-rw-r--r--Tests/feaLib/data/spec5f_ii_3.ttx214
-rw-r--r--Tests/feaLib/data/spec8a.ttx6
-rw-r--r--Tests/feaLib/data/variable_mark_anchor.fea10
-rw-r--r--Tests/feaLib/data/variable_mark_anchor.ttx128
-rw-r--r--Tests/feaLib/lexer_test.py4
-rw-r--r--Tests/feaLib/parser_test.py11
-rw-r--r--Tests/misc/bezierTools_test.py8
-rw-r--r--Tests/misc/symfont_test.py44
-rw-r--r--Tests/mtiLib/data/mti/gsubligature.ttx.GSUB16
-rw-r--r--Tests/otlLib/builder_test.py310
-rw-r--r--Tests/pens/roundingPen_test.py69
-rw-r--r--Tests/subset/data/NotoSansCJKjp-Regular.subset.ttx2
-rw-r--r--Tests/subset/data/TestContextSubstFormat3.ttx4
-rw-r--r--Tests/svgLib/path/path_test.py6
-rw-r--r--Tests/ttLib/tables/O_S_2f_2_test.py85
-rw-r--r--Tests/ttLib/tables/_g_l_y_f_test.py17
-rw-r--r--Tests/ttLib/ttGlyphSet_test.py48
-rw-r--r--Tests/varLib/data/FeatureVarsCustomTag.designspace2
-rw-r--r--Tests/varLib/data/test_results/FeatureVarsCustomTag.ttx48
-rw-r--r--Tests/varLib/featureVars_test.py166
-rw-r--r--Tests/varLib/instancer/data/PartialInstancerTest-VF.ttx9
-rw-r--r--Tests/varLib/instancer/instancer_test.py62
-rw-r--r--Tests/varLib/instancer/solver_test.py9
-rw-r--r--Tests/varLib/interpolatable_test.py42
-rw-r--r--Tests/varLib/models_test.py101
-rw-r--r--Tests/varLib/varLib_test.py34
-rw-r--r--dev-requirements.txt2
-rw-r--r--requirements.txt13
-rw-r--r--setup.cfg2
-rwxr-xr-xsetup.py13
137 files changed, 6181 insertions, 1227 deletions
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index d97c77f2..2cf71c27 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -19,9 +19,9 @@ jobs:
# https://github.community/t/github-actions-does-not-respect-skip-ci/17325/8
if: "! contains(toJSON(github.event.commits.*.message), '[skip ci]')"
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Set up Python 3.x
- uses: actions/setup-python@v4
+ uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Install packages
@@ -43,9 +43,9 @@ jobs:
- platform: windows-latest
python-version: 3.11
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v4
+ uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
allow-prereleases: true
@@ -60,7 +60,7 @@ jobs:
coverage combine
coverage xml
- name: Upload coverage to Codecov
- uses: codecov/codecov-action@v3
+ uses: codecov/codecov-action@v4
with:
file: coverage.xml
flags: unittests
@@ -74,9 +74,9 @@ jobs:
runs-on: ubuntu-latest
if: "! contains(toJSON(github.event.commits.*.message), '[skip ci]')"
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Set up Python 3.x
- uses: actions/setup-python@v4
+ uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install packages
@@ -88,9 +88,9 @@ jobs:
runs-on: ubuntu-latest
if: "! contains(toJSON(github.event.commits.*.message), '[skip ci]')"
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Set up Python pypy3
- uses: actions/setup-python@v4
+ uses: actions/setup-python@v5
with:
python-version: "pypy-3.9"
- name: Install packages
diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml
index 59ba1b0b..68dbf4cd 100644
--- a/.github/workflows/wheels.yml
+++ b/.github/workflows/wheels.yml
@@ -21,9 +21,9 @@ jobs:
build_pure:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Set up Python
- uses: actions/setup-python@v4
+ uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Install dependencies
@@ -32,8 +32,9 @@ jobs:
- name: Build source distribution and pure-python wheel
run: |
python setup.py sdist bdist_wheel
- - uses: actions/upload-artifact@v3
+ - uses: actions/upload-artifact@v4
with:
+ name: pure
path: |
dist/*.whl
dist/*.tar.gz
@@ -55,11 +56,11 @@ jobs:
- os: windows-latest
arch: auto32
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
with:
submodules: recursive
- name: Set up Python
- uses: actions/setup-python@v4
+ uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Install dependencies
@@ -70,8 +71,9 @@ jobs:
env:
CIBW_MANYLINUX_X86_64_IMAGE: manylinux2014
CIBW_ARCHS: ${{ matrix.arch }}
- - uses: actions/upload-artifact@v3
+ - uses: actions/upload-artifact@v4
with:
+ name: wheels-${{ matrix.os }}-${{ matrix.arch }}
path: wheelhouse/*.whl
build_arch_wheels:
@@ -83,10 +85,10 @@ jobs:
python: [38, 39, 310, 311, 312]
arch: [aarch64]
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
with:
submodules: true
- - uses: docker/setup-qemu-action@v2.2.0
+ - uses: docker/setup-qemu-action@v3.0.0
with:
platforms: all
- name: Install dependencies
@@ -96,8 +98,9 @@ jobs:
env:
CIBW_BUILD: cp${{ matrix.python }}-*
CIBW_ARCHS: ${{ matrix.arch }}
- - uses: actions/upload-artifact@v3
+ - uses: actions/upload-artifact@v4
with:
+ name: wheels-py${{ matrix.python }}-linux-${{ matrix.arch }}
path: wheelhouse/*.whl
deploy:
@@ -110,11 +113,12 @@ jobs:
# only run if the commit is tagged...
if: startsWith(github.ref, 'refs/tags/')
steps:
- - uses: actions/download-artifact@v3
+ - uses: actions/download-artifact@v4
with:
- name: artifact
+ # so that all artifacts are downloaded in the same directory specified by 'path'
+ merge-multiple: true
path: dist
- - uses: pypa/gh-action-pypi-publish@v1.8.8
+ - uses: pypa/gh-action-pypi-publish@v1.8.11
with:
user: __token__
password: ${{ secrets.PYPI_PASSWORD }}
diff --git a/Doc/docs-requirements.txt b/Doc/docs-requirements.txt
index f8f93c15..65c2982f 100644
--- a/Doc/docs-requirements.txt
+++ b/Doc/docs-requirements.txt
@@ -1,4 +1,4 @@
sphinx==7.2.6
-sphinx_rtd_theme==1.3.0
-reportlab==4.0.6
+sphinx_rtd_theme==2.0.0
+reportlab==4.1.0
freetype-py==2.4.0
diff --git a/Doc/source/designspaceLib/index.rst b/Doc/source/designspaceLib/index.rst
index 7b8b4878..6bf29754 100644
--- a/Doc/source/designspaceLib/index.rst
+++ b/Doc/source/designspaceLib/index.rst
@@ -120,6 +120,41 @@ any of the UFOs. If the lib key is empty or not present in the Designspace, all
glyphs should be exported, regardless of what the same lib key in any of the
UFOs says.
+public.fontInfo
+-----------------------
+
+This lib key, when included in the ``<lib>`` element inside an ``<instance>``
+or ``<variable-font>`` tag, or the ``<lib>`` element at the root of a
+designspace document, allows for direct manipulation of font info data in
+instances and variable fonts. The lib value must follow the
+`UFO3 fontinfo.plist specification <https://unifiedfontobject.org/versions/ufo3/fontinfo.plist/>`_,
+and should functionally appear to be a property list dictionary with the same
+structure as the ``fontinfo.plist`` file in a UFO.
+
+All font info items in the UFO fontinfo.plist specification should be able to
+be defined in the ``public.fontInfo`` lib. Checking validity of the data using
+``fontTools.ufoLib.validators`` is recommended but not required.
+
+All font info items for a variable font or an instance must be inherited using
+the following order, in order of descending priority:
+
+#. The ``public.fontInfo`` key in the ``<lib>`` element of the ``<variable-font>``
+ or ``<instance>`` elements.
+#. XML attributes for names (i.e. ``familyname``, ``stylename``, etc.), if the
+ target is an ``<instance>`` element.
+#. The ``public.fontInfo`` key found in the ``<lib>`` element of the designspace
+ document's root.
+#. The ``fontinfo.plist`` in the UFO source at the origin of the interpolation
+ space.
+
+Absence of a font info key from the value of a ``public.fontInfo`` lib does
+**not** mean a that piece of font info should be interpreted as being undefined.
+A tool generating the variable font or instance should recursively continue on
+to the next level of the inheritence order and apply the value found there, if
+any. If the tool makes it to the end of the inheritence order without finding a
+valid value for a given font info key, it should then be considered undefined.
+In the case of any conflicting values for a font info key, the value highest in
+the inheritance order must be chosen over the others.
Implementation and differences
==============================
diff --git a/Doc/source/designspaceLib/v5_class_diagram.png b/Doc/source/designspaceLib/v5_class_diagram.png
index 7c75bcb9..38bcf376 100644
--- a/Doc/source/designspaceLib/v5_class_diagram.png
+++ b/Doc/source/designspaceLib/v5_class_diagram.png
Binary files differ
diff --git a/Doc/source/designspaceLib/v5_class_diagram.puml b/Doc/source/designspaceLib/v5_class_diagram.puml
index 31f9e9c3..1cc00822 100644
--- a/Doc/source/designspaceLib/v5_class_diagram.puml
+++ b/Doc/source/designspaceLib/v5_class_diagram.puml
@@ -112,7 +112,7 @@ class Source {
+ path: str
+ layerName: Optional[str]
+ <color:brown><s><<Deprecated>> location: Location
-+ <color:green><b><<New>> designLocation: AnisotropicLocation
++ <color:green><b><<New>> designLocation: SimpleLocation
....
+ font: Optional[Font]
....
diff --git a/Doc/source/designspaceLib/xml.rst b/Doc/source/designspaceLib/xml.rst
index 4e3492ef..7b59dbb1 100644
--- a/Doc/source/designspaceLib/xml.rst
+++ b/Doc/source/designspaceLib/xml.rst
@@ -263,11 +263,14 @@ Example of all axis elements together
``<mappings>`` element
======================
-- Define axis mappings.
+- Define an axis mappings group.
- Child element of ``axes``
+.. rubric:: Attributes
- .. versionadded:: 5.1
+- ``description``: optional, string. the description of this mappings group
+
+ .. versionadded:: 5.2
``<mapping>`` element
@@ -276,8 +279,11 @@ Example of all axis elements together
- Defines an axis mapping.
- Child element of ``<mappings>``
+.. rubric:: Attributes
+
+- ``description``: optional, string. the description of this mapping
- .. versionadded:: 5.1
+ .. versionadded:: 5.2
``<input>`` element
@@ -438,8 +444,8 @@ glyphname pairs: the glyphs that need to be substituted. For a rule to be trigge
See the following issues for more information:
`fontTools#1371 <https://github.com/fonttools/fonttools/issues/1371#issuecomment-590214572>`__
`fontTools#2050 <https://github.com/fonttools/fonttools/issues/2050#issuecomment-678691020>`__
- - If you want to use a different feature altogether, e.g. ``calt``,
- use the lib key ``com.github.fonttools.varLib.featureVarsFeatureTag``
+ - If you want to use a different feature(s) altogether, e.g. ``calt``,
+ use the lib key ``com.github.fonttools.varLib.featureVarsFeatureTag``.
.. code:: xml
@@ -450,6 +456,9 @@ glyphname pairs: the glyphs that need to be substituted. For a rule to be trigge
</dict>
</lib>
+ This can also take a comma-separated list of feature tags, e.g. ``salt,ss01``,
+ if you wish the same rules to be applied with several features.
+
``<rule>`` element
@@ -790,7 +799,7 @@ The ``<variable-fonts>`` element contains one or more ``<variable-font>`` elemen
but the design space is sliced at the given location. *Note:* While valid to have a
specific value that doesn’t have a matching ``<source>`` at that value, currently there
isn’t an implentation that supports this. See `this fontmake issue
- <https://github.com/googlefonts/fontmake/issues/920>`.
+ <https://github.com/googlefonts/fontmake/issues/920>`_.
.. code:: xml
@@ -842,6 +851,38 @@ Arbitrary data about this variable font.
.. seealso:: :ref:`lib`
+Here is an example of using the ``public.fontInfo`` lib key to gain more granular
+control over the font info of a variable font, in this case setting some names to
+reflect the fact that this is a Narrow variable font subset from the larger designspace.
+This lib key allows font info in variable fonts to be more specific than the font
+info of the sources.
+
+.. rubric:: Example
+
+.. code:: xml
+
+ <variable-font name="MyFontNarrVF">
+ <axis-subsets>
+ <axis-subset name="Weight"/>
+ <axis-subset name="Width" uservalue="75"/>
+ </axis-subsets>
+ <lib>
+ <dict>
+ <key>public.fontInfo</key>
+ <dict>
+ <key>familyName</key>
+ <string>My Font Narrow VF</string>
+ <key>styleName</key>
+ <string>Regular</string>
+ <key>postscriptFontName</key>
+ <string>MyFontNarrVF-Regular</string>
+ <key>trademark</key>
+ <string>My Font Narrow VF is a registered trademark...</string>
+ </dict>
+ </dict>
+ </lib>
+ </variable-font>
+
Instances included in the variable font
---------------------------------------
@@ -989,6 +1030,57 @@ instance directly.
</instances>
</designspace>
+Here is an example of using the ``public.fontInfo`` lib key to gain more granular
+control over the font info of the instances.
+
+``openTypeNameWWSFamilyName`` and ``openTypeNameWWSSubfamilyName`` are not able to
+be set by attributes on the ``<instance>`` element. The ``openTypeOS2WeightClass``
+key is superseding the value that would have been set by the ``weight`` axis value.
+The ``trademark`` key is superseding the value that would have been set by UFO source
+at the origin. If the designer wishes to set name records for other encodings,
+platforms or laguages, they should do so using the ``openTypeNameRecords`` key, like
+they would in a UFO source.
+
+See `UFO3 fontinfo.plist specification <https://unifiedfontobject.org/versions/ufo3/fontinfo.plist/>`_.
+
+.. code:: xml
+
+ <instance familyname="My Font" stylename="Text Light" filename="instances/MyFont-TextLight.ufo" postscriptfontname="MyFont-TextLight" stylemapfamilyname="My Font Text Light" stylemapstylename="regular">
+ <location>
+ <dimension name="optical" xvalue="6"/>
+ <dimension name="weight" xvalue="325"/>
+ </location>
+ <lib>
+ <dict>
+ <key>public.fontInfo</key>
+ <dict>
+ <key>openTypeNameWWSFamilyName</key>
+ <string>My Font Text</string>
+ <key>openTypeNameWWSSubfamilyName</key>
+ <string>Light</string>
+ <key>openTypeOS2WeightClass</key>
+ <integer>300</integer>
+ <key>trademark</key>
+ <string>My Font Text Light is a registered trademark...</string>
+ <key>openTypeNameRecords</key>
+ <array>
+ <dict>
+ <key>encodingID</key>
+ <integer>1</integer>
+ <key>languageID</key>
+ <integer>1031</integer>
+ <key>nameID</key>
+ <integer>7</integer>
+ <key>platformID</key>
+ <integer>3</integer>
+ <key>string</key>
+ <string>Meine Schrift Text Leicht ist eine registrierte Marke...</string>
+ </dict>
+ </array>
+ </dict>
+ </dict>
+ </lib>
+ </instance>
``<glyphs>`` element (instance)
-------------------------------
diff --git a/Lib/fontTools/__init__.py b/Lib/fontTools/__init__.py
index 9a59504e..e6a745bd 100644
--- a/Lib/fontTools/__init__.py
+++ b/Lib/fontTools/__init__.py
@@ -3,6 +3,6 @@ from fontTools.misc.loggingTools import configLogger
log = logging.getLogger(__name__)
-version = __version__ = "4.44.0"
+version = __version__ = "4.49.0"
__all__ = ["version", "log", "configLogger"]
diff --git a/Lib/fontTools/afmLib.py b/Lib/fontTools/afmLib.py
index 935a1e8e..0aabf7f6 100644
--- a/Lib/fontTools/afmLib.py
+++ b/Lib/fontTools/afmLib.py
@@ -45,7 +45,6 @@ Here is an example of using `afmLib` to read, modify and write an AFM file:
"""
-
import re
# every single line starts with a "word"
@@ -82,7 +81,10 @@ kernRE = re.compile(
# regular expressions to parse composite info lines of the form:
# Aacute 2 ; PCC A 0 0 ; PCC acute 182 211 ;
compositeRE = re.compile(
- r"([.A-Za-z0-9_]+)" r"\s+" r"(\d+)" r"\s*;\s*" # char name # number of parts
+ r"([.A-Za-z0-9_]+)" # char name
+ r"\s+"
+ r"(\d+)" # number of parts
+ r"\s*;\s*"
)
componentRE = re.compile(
r"PCC\s+" # PPC
diff --git a/Lib/fontTools/cffLib/__init__.py b/Lib/fontTools/cffLib/__init__.py
index 644508c1..0ad41c56 100644
--- a/Lib/fontTools/cffLib/__init__.py
+++ b/Lib/fontTools/cffLib/__init__.py
@@ -2880,7 +2880,6 @@ class PrivateDict(BaseDict):
class IndexedStrings(object):
-
"""SID -> string mapping."""
def __init__(self, file=None):
diff --git a/Lib/fontTools/colorLib/builder.py b/Lib/fontTools/colorLib/builder.py
index 442bc20e..6e45e7a8 100644
--- a/Lib/fontTools/colorLib/builder.py
+++ b/Lib/fontTools/colorLib/builder.py
@@ -2,6 +2,7 @@
colorLib.builder: Build COLR/CPAL tables from scratch
"""
+
import collections
import copy
import enum
@@ -298,11 +299,15 @@ def buildPaletteLabels(
labels: Iterable[_OptionalLocalizedString], nameTable: _n_a_m_e.table__n_a_m_e
) -> List[Optional[int]]:
return [
- nameTable.addMultilingualName(l, mac=False)
- if isinstance(l, dict)
- else C_P_A_L_.table_C_P_A_L_.NO_NAME_ID
- if l is None
- else nameTable.addMultilingualName({"en": l}, mac=False)
+ (
+ nameTable.addMultilingualName(l, mac=False)
+ if isinstance(l, dict)
+ else (
+ C_P_A_L_.table_C_P_A_L_.NO_NAME_ID
+ if l is None
+ else nameTable.addMultilingualName({"en": l}, mac=False)
+ )
+ )
for l in labels
]
diff --git a/Lib/fontTools/config/__init__.py b/Lib/fontTools/config/__init__.py
index c106fe51..41ab8f75 100644
--- a/Lib/fontTools/config/__init__.py
+++ b/Lib/fontTools/config/__init__.py
@@ -6,6 +6,7 @@ etc. If this file gets too big, split it into smaller files per-module.
An instance of the Config class can be attached to a TTFont object, so that
the various modules can access their configuration options from it.
"""
+
from textwrap import dedent
from fontTools.misc.configTools import *
diff --git a/Lib/fontTools/designspaceLib/__init__.py b/Lib/fontTools/designspaceLib/__init__.py
index 1c71fd00..342f1dec 100644
--- a/Lib/fontTools/designspaceLib/__init__.py
+++ b/Lib/fontTools/designspaceLib/__init__.py
@@ -312,7 +312,7 @@ class SourceDescriptor(SimpleDescriptor):
return self.designLocation
@location.setter
- def location(self, location: Optional[AnisotropicLocationDict]):
+ def location(self, location: Optional[SimpleLocationDict]):
self.designLocation = location or {}
def setFamilyName(self, familyName, languageCode="en"):
@@ -329,15 +329,13 @@ class SourceDescriptor(SimpleDescriptor):
"""
return self.localisedFamilyName.get(languageCode)
- def getFullDesignLocation(
- self, doc: "DesignSpaceDocument"
- ) -> AnisotropicLocationDict:
+ def getFullDesignLocation(self, doc: "DesignSpaceDocument") -> SimpleLocationDict:
"""Get the complete design location of this source, from its
:attr:`designLocation` and the document's axis defaults.
.. versionadded:: 5.0
"""
- result: AnisotropicLocationDict = {}
+ result: SimpleLocationDict = {}
for axis in doc.axes:
if axis.name in self.designLocation:
result[axis.name] = self.designLocation[axis.name]
@@ -478,7 +476,14 @@ class AxisMappingDescriptor(SimpleDescriptor):
_attrs = ["inputLocation", "outputLocation"]
- def __init__(self, *, inputLocation=None, outputLocation=None):
+ def __init__(
+ self,
+ *,
+ inputLocation=None,
+ outputLocation=None,
+ description=None,
+ groupDescription=None,
+ ):
self.inputLocation: SimpleLocationDict = inputLocation or {}
"""dict. Axis values for the input of the mapping, in design space coordinates.
@@ -493,6 +498,20 @@ class AxisMappingDescriptor(SimpleDescriptor):
.. versionadded:: 5.1
"""
+ self.description = description
+ """string. A description of the mapping.
+
+ varLib.
+
+ .. versionadded:: 5.2
+ """
+ self.groupDescription = groupDescription
+ """string. A description of the group of mappings.
+
+ varLib.
+
+ .. versionadded:: 5.2
+ """
class InstanceDescriptor(SimpleDescriptor):
@@ -1415,18 +1434,27 @@ class BaseDocWriter(object):
):
axesElement = ET.Element("axes")
if self.documentObject.elidedFallbackName is not None:
- axesElement.attrib[
- "elidedfallbackname"
- ] = self.documentObject.elidedFallbackName
+ axesElement.attrib["elidedfallbackname"] = (
+ self.documentObject.elidedFallbackName
+ )
self.root.append(axesElement)
for axisObject in self.documentObject.axes:
self._addAxis(axisObject)
if self.documentObject.axisMappings:
- mappingsElement = ET.Element("mappings")
- self.root.findall(".axes")[0].append(mappingsElement)
+ mappingsElement = None
+ lastGroup = object()
for mappingObject in self.documentObject.axisMappings:
+ if getattr(mappingObject, "groupDescription", None) != lastGroup:
+ if mappingsElement is not None:
+ self.root.findall(".axes")[0].append(mappingsElement)
+ lastGroup = getattr(mappingObject, "groupDescription", None)
+ mappingsElement = ET.Element("mappings")
+ if lastGroup is not None:
+ mappingsElement.attrib["description"] = lastGroup
self._addAxisMapping(mappingsElement, mappingObject)
+ if mappingsElement is not None:
+ self.root.findall(".axes")[0].append(mappingsElement)
if self.documentObject.locationLabels:
labelsElement = ET.Element("labels")
@@ -1588,6 +1616,8 @@ class BaseDocWriter(object):
def _addAxisMapping(self, mappingsElement, mappingObject):
mappingElement = ET.Element("mapping")
+ if getattr(mappingObject, "description", None) is not None:
+ mappingElement.attrib["description"] = mappingObject.description
for what in ("inputLocation", "outputLocation"):
whatObject = getattr(mappingObject, what, None)
if whatObject is None:
@@ -1746,17 +1776,17 @@ class BaseDocWriter(object):
if instanceObject.filename is not None:
instanceElement.attrib["filename"] = instanceObject.filename
if instanceObject.postScriptFontName is not None:
- instanceElement.attrib[
- "postscriptfontname"
- ] = instanceObject.postScriptFontName
+ instanceElement.attrib["postscriptfontname"] = (
+ instanceObject.postScriptFontName
+ )
if instanceObject.styleMapFamilyName is not None:
- instanceElement.attrib[
- "stylemapfamilyname"
- ] = instanceObject.styleMapFamilyName
+ instanceElement.attrib["stylemapfamilyname"] = (
+ instanceObject.styleMapFamilyName
+ )
if instanceObject.styleMapStyleName is not None:
- instanceElement.attrib[
- "stylemapstylename"
- ] = instanceObject.styleMapStyleName
+ instanceElement.attrib["stylemapstylename"] = (
+ instanceObject.styleMapStyleName
+ )
if self.effectiveFormatTuple < (5, 0):
# Deprecated members as of version 5.0
if instanceObject.glyphs:
@@ -2083,10 +2113,11 @@ class BaseDocReader(LogMixin):
self.documentObject.axes.append(axisObject)
self.axisDefaults[axisObject.name] = axisObject.default
- mappingsElement = self.root.find(".axes/mappings")
self.documentObject.axisMappings = []
- if mappingsElement is not None:
+ for mappingsElement in self.root.findall(".axes/mappings"):
+ groupDescription = mappingsElement.attrib.get("description")
for mappingElement in mappingsElement.findall("mapping"):
+ description = mappingElement.attrib.get("description")
inputElement = mappingElement.find("input")
outputElement = mappingElement.find("output")
inputLoc = {}
@@ -2100,7 +2131,10 @@ class BaseDocReader(LogMixin):
value = float(dimElement.attrib["xvalue"])
outputLoc[name] = value
axisMappingObject = self.axisMappingDescriptorClass(
- inputLocation=inputLoc, outputLocation=outputLoc
+ inputLocation=inputLoc,
+ outputLocation=outputLoc,
+ description=description,
+ groupDescription=groupDescription,
)
self.documentObject.axisMappings.append(axisMappingObject)
@@ -3281,3 +3315,23 @@ class DesignSpaceDocument(LogMixin, AsDictMixin):
finally:
for source, font in zip(self.sources, fonts):
source.font = font
+
+
+def main(args=None):
+ """Roundtrip .designspace file through the DesignSpaceDocument class"""
+
+ if args is None:
+ import sys
+
+ args = sys.argv[1:]
+
+ from argparse import ArgumentParser
+
+ parser = ArgumentParser(prog="designspaceLib", description=main.__doc__)
+ parser.add_argument("input")
+ parser.add_argument("output")
+
+ options = parser.parse_args(args)
+
+ ds = DesignSpaceDocument.fromfile(options.input)
+ ds.write(options.output)
diff --git a/Lib/fontTools/designspaceLib/__main__.py b/Lib/fontTools/designspaceLib/__main__.py
new file mode 100644
index 00000000..8f5e44ea
--- /dev/null
+++ b/Lib/fontTools/designspaceLib/__main__.py
@@ -0,0 +1,6 @@
+import sys
+from fontTools.designspaceLib import main
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/Lib/fontTools/designspaceLib/statNames.py b/Lib/fontTools/designspaceLib/statNames.py
index a164169d..1474e5fc 100644
--- a/Lib/fontTools/designspaceLib/statNames.py
+++ b/Lib/fontTools/designspaceLib/statNames.py
@@ -8,6 +8,7 @@ instance:
names = getStatNames(doc, instance.getFullUserLocation(doc))
print(names.styleNames)
"""
+
from __future__ import annotations
from dataclasses import dataclass
diff --git a/Lib/fontTools/feaLib/builder.py b/Lib/fontTools/feaLib/builder.py
index cfaf54d4..7921a3f1 100644
--- a/Lib/fontTools/feaLib/builder.py
+++ b/Lib/fontTools/feaLib/builder.py
@@ -36,6 +36,7 @@ from fontTools.varLib.builder import buildVarDevTable
from fontTools.varLib.featureVars import addFeatureVariationsRaw
from fontTools.varLib.models import normalizeValue, piecewiseLinearMap
from collections import defaultdict
+import copy
import itertools
from io import StringIO
import logging
@@ -284,7 +285,11 @@ class Builder(object):
def build_feature_aalt_(self):
if not self.aalt_features_ and not self.aalt_alternates_:
return
- alternates = {g: set(a) for g, a in self.aalt_alternates_.items()}
+ # > alternate glyphs will be sorted in the order that the source features
+ # > are named in the aalt definition, not the order of the feature definitions
+ # > in the file. Alternates defined explicitly ... will precede all others.
+ # https://github.com/fonttools/fonttools/issues/836
+ alternates = {g: list(a) for g, a in self.aalt_alternates_.items()}
for location, name in self.aalt_features_ + [(None, "aalt")]:
feature = [
(script, lang, feature, lookups)
@@ -301,17 +306,14 @@ class Builder(object):
lookuplist = [lookuplist]
for lookup in lookuplist:
for glyph, alts in lookup.getAlternateGlyphs().items():
- alternates.setdefault(glyph, set()).update(alts)
+ alts_for_glyph = alternates.setdefault(glyph, [])
+ alts_for_glyph.extend(
+ g for g in alts if g not in alts_for_glyph
+ )
single = {
- glyph: list(repl)[0] for glyph, repl in alternates.items() if len(repl) == 1
- }
- # TODO: Figure out the glyph alternate ordering used by makeotf.
- # https://github.com/fonttools/fonttools/issues/836
- multi = {
- glyph: sorted(repl, key=self.font.getGlyphID)
- for glyph, repl in alternates.items()
- if len(repl) > 1
+ glyph: repl[0] for glyph, repl in alternates.items() if len(repl) == 1
}
+ multi = {glyph: repl for glyph, repl in alternates.items() if len(repl) > 1}
if not single and not multi:
return
self.features_ = {
@@ -1248,8 +1250,9 @@ class Builder(object):
def add_single_subst(self, location, prefix, suffix, mapping, forceChain):
if self.cur_feature_name_ == "aalt":
for from_glyph, to_glyph in mapping.items():
- alts = self.aalt_alternates_.setdefault(from_glyph, set())
- alts.add(to_glyph)
+ alts = self.aalt_alternates_.setdefault(from_glyph, [])
+ if to_glyph not in alts:
+ alts.append(to_glyph)
return
if prefix or suffix or forceChain:
self.add_single_subst_chained_(location, prefix, suffix, mapping)
@@ -1302,8 +1305,8 @@ class Builder(object):
# GSUB 3
def add_alternate_subst(self, location, prefix, glyph, suffix, replacement):
if self.cur_feature_name_ == "aalt":
- alts = self.aalt_alternates_.setdefault(glyph, set())
- alts.update(replacement)
+ alts = self.aalt_alternates_.setdefault(glyph, [])
+ alts.extend(g for g in replacement if g not in alts)
return
if prefix or suffix:
chain = self.get_lookup_(location, ChainContextSubstBuilder)
@@ -1337,7 +1340,7 @@ class Builder(object):
# substitutions to be specified on target sequences that contain
# glyph classes, the implementation software will enumerate
# all specific glyph sequences if glyph classes are detected"
- for g in sorted(itertools.product(*glyphs)):
+ for g in itertools.product(*glyphs):
lookup.ligatures[g] = replacement
# GSUB 5/6
@@ -1516,7 +1519,7 @@ class Builder(object):
for mark in markClassDef.glyphs.glyphSet():
if mark not in lookupBuilder.marks:
otMarkAnchor = self.makeOpenTypeAnchor(
- location, markClassDef.anchor
+ location, copy.deepcopy(markClassDef.anchor)
)
lookupBuilder.marks[mark] = (markClass.name, otMarkAnchor)
else:
diff --git a/Lib/fontTools/feaLib/lexer.py b/Lib/fontTools/feaLib/lexer.py
index e0ae0aef..5867f70b 100644
--- a/Lib/fontTools/feaLib/lexer.py
+++ b/Lib/fontTools/feaLib/lexer.py
@@ -111,10 +111,6 @@ class Lexer(object):
glyphclass = text[start + 1 : self.pos_]
if len(glyphclass) < 1:
raise FeatureLibError("Expected glyph class name", location)
- if len(glyphclass) > 63:
- raise FeatureLibError(
- "Glyph class names must not be longer than 63 characters", location
- )
if not Lexer.RE_GLYPHCLASS.match(glyphclass):
raise FeatureLibError(
"Glyph class names must consist of letters, digits, "
diff --git a/Lib/fontTools/feaLib/parser.py b/Lib/fontTools/feaLib/parser.py
index 8ffdf644..8cbe7959 100644
--- a/Lib/fontTools/feaLib/parser.py
+++ b/Lib/fontTools/feaLib/parser.py
@@ -2071,13 +2071,7 @@ class Parser(object):
def expect_glyph_(self):
self.advance_lexer_()
if self.cur_token_type_ is Lexer.NAME:
- self.cur_token_ = self.cur_token_.lstrip("\\")
- if len(self.cur_token_) > 63:
- raise FeatureLibError(
- "Glyph names must not be longer than 63 characters",
- self.cur_token_location_,
- )
- return self.cur_token_
+ return self.cur_token_.lstrip("\\")
elif self.cur_token_type_ is Lexer.CID:
return "cid%05d" % self.cur_token_
raise FeatureLibError("Expected a glyph name or CID", self.cur_token_location_)
diff --git a/Lib/fontTools/merge/__init__.py b/Lib/fontTools/merge/__init__.py
index 8d8a5213..7653e4a0 100644
--- a/Lib/fontTools/merge/__init__.py
+++ b/Lib/fontTools/merge/__init__.py
@@ -139,6 +139,7 @@ class Merger(object):
*(vars(table).keys() for table in tables if table is not NotImplemented),
)
for key in allKeys:
+ log.info(" %s", key)
try:
mergeLogic = logic[key]
except KeyError:
@@ -181,17 +182,50 @@ def main(args=None):
args = sys.argv[1:]
options = Options()
- args = options.parse_opts(args, ignore_unknown=["output-file"])
- outfile = "merged.ttf"
+ args = options.parse_opts(args)
fontfiles = []
+ if options.input_file:
+ with open(options.input_file) as inputfile:
+ fontfiles = [
+ line.strip()
+ for line in inputfile.readlines()
+ if not line.lstrip().startswith("#")
+ ]
for g in args:
- if g.startswith("--output-file="):
- outfile = g[14:]
- continue
fontfiles.append(g)
- if len(args) < 1:
- print("usage: pyftmerge font...", file=sys.stderr)
+ if len(fontfiles) < 1:
+ print(
+ "usage: pyftmerge [font1 ... fontN] [--input-file=filelist.txt] [--output-file=merged.ttf] [--import-file=tables.ttx]",
+ file=sys.stderr,
+ )
+ print(
+ " [--drop-tables=tags] [--verbose] [--timing]",
+ file=sys.stderr,
+ )
+ print("", file=sys.stderr)
+ print(" font1 ... fontN Files to merge.", file=sys.stderr)
+ print(
+ " --input-file=<filename> Read files to merge from a text file, each path new line. # Comment lines allowed.",
+ file=sys.stderr,
+ )
+ print(
+ " --output-file=<filename> Specify output file name (default: merged.ttf).",
+ file=sys.stderr,
+ )
+ print(
+ " --import-file=<filename> TTX file to import after merging. This can be used to set metadata.",
+ file=sys.stderr,
+ )
+ print(
+ " --drop-tables=<table tags> Comma separated list of table tags to skip, case sensitive.",
+ file=sys.stderr,
+ )
+ print(
+ " --verbose Output progress information.",
+ file=sys.stderr,
+ )
+ print(" --timing Output progress timing.", file=sys.stderr)
return 1
configLogger(level=logging.INFO if options.verbose else logging.WARNING)
@@ -202,8 +236,12 @@ def main(args=None):
merger = Merger(options=options)
font = merger.merge(fontfiles)
+
+ if options.import_file:
+ font.importXML(options.import_file)
+
with timer("compile and save font"):
- font.save(outfile)
+ font.save(options.output_file)
if __name__ == "__main__":
diff --git a/Lib/fontTools/merge/layout.py b/Lib/fontTools/merge/layout.py
index 6b85cd50..e1b504e6 100644
--- a/Lib/fontTools/merge/layout.py
+++ b/Lib/fontTools/merge/layout.py
@@ -169,20 +169,16 @@ otTables.BaseTagList.mergeMap = {
"BaselineTag": sumLists,
}
-otTables.GDEF.mergeMap = (
- otTables.GSUB.mergeMap
-) = (
- otTables.GPOS.mergeMap
-) = otTables.BASE.mergeMap = otTables.JSTF.mergeMap = otTables.MATH.mergeMap = {
+otTables.GDEF.mergeMap = otTables.GSUB.mergeMap = otTables.GPOS.mergeMap = (
+ otTables.BASE.mergeMap
+) = otTables.JSTF.mergeMap = otTables.MATH.mergeMap = {
"*": mergeObjects,
"Version": max,
}
-ttLib.getTableClass("GDEF").mergeMap = ttLib.getTableClass(
- "GSUB"
-).mergeMap = ttLib.getTableClass("GPOS").mergeMap = ttLib.getTableClass(
- "BASE"
-).mergeMap = ttLib.getTableClass(
+ttLib.getTableClass("GDEF").mergeMap = ttLib.getTableClass("GSUB").mergeMap = (
+ ttLib.getTableClass("GPOS").mergeMap
+) = ttLib.getTableClass("BASE").mergeMap = ttLib.getTableClass(
"JSTF"
).mergeMap = ttLib.getTableClass(
"MATH"
diff --git a/Lib/fontTools/merge/options.py b/Lib/fontTools/merge/options.py
index f1340093..8bc89471 100644
--- a/Lib/fontTools/merge/options.py
+++ b/Lib/fontTools/merge/options.py
@@ -11,6 +11,9 @@ class Options(object):
self.verbose = False
self.timing = False
self.drop_tables = []
+ self.input_file = None
+ self.output_file = "merged.ttf"
+ self.import_file = None
self.set(**kwargs)
diff --git a/Lib/fontTools/misc/bezierTools.py b/Lib/fontTools/misc/bezierTools.py
index 21ab0a5d..a1a707b0 100644
--- a/Lib/fontTools/misc/bezierTools.py
+++ b/Lib/fontTools/misc/bezierTools.py
@@ -1370,6 +1370,11 @@ def _curve_curve_intersections_t(
return unique_values
+def _is_linelike(segment):
+ maybeline = _alignment_transformation(segment).transformPoints(segment)
+ return all(math.isclose(p[1], 0.0) for p in maybeline)
+
+
def curveCurveIntersections(curve1, curve2):
"""Finds intersections between a curve and a curve.
@@ -1391,6 +1396,17 @@ def curveCurveIntersections(curve1, curve2):
>>> intersections[0].pt
(81.7831487395506, 109.88904552375288)
"""
+ if _is_linelike(curve1):
+ line1 = curve1[0], curve1[-1]
+ if _is_linelike(curve2):
+ line2 = curve2[0], curve2[-1]
+ return lineLineIntersections(*line1, *line2)
+ else:
+ return curveLineIntersections(curve2, line1)
+ elif _is_linelike(curve2):
+ line2 = curve2[0], curve2[-1]
+ return curveLineIntersections(curve1, line2)
+
intersection_ts = _curve_curve_intersections_t(curve1, curve2)
return [
Intersection(pt=segmentPointAtT(curve1, ts[0]), t1=ts[0], t2=ts[1])
diff --git a/Lib/fontTools/misc/classifyTools.py b/Lib/fontTools/misc/classifyTools.py
index 2235bbd7..aed7ca68 100644
--- a/Lib/fontTools/misc/classifyTools.py
+++ b/Lib/fontTools/misc/classifyTools.py
@@ -3,7 +3,6 @@
class Classifier(object):
-
"""
Main Classifier object, used to classify things into similar sets.
"""
diff --git a/Lib/fontTools/misc/cliTools.py b/Lib/fontTools/misc/cliTools.py
index 8322ea9e..8a64235b 100644
--- a/Lib/fontTools/misc/cliTools.py
+++ b/Lib/fontTools/misc/cliTools.py
@@ -1,4 +1,5 @@
"""Collection of utilities for command-line interfaces and console scripts."""
+
import os
import re
diff --git a/Lib/fontTools/misc/configTools.py b/Lib/fontTools/misc/configTools.py
index 38bbada2..7eb1854f 100644
--- a/Lib/fontTools/misc/configTools.py
+++ b/Lib/fontTools/misc/configTools.py
@@ -8,6 +8,7 @@ To create your own config system, you need to create an instance of
``options`` class variable set to your instance of Options.
"""
+
from __future__ import annotations
import logging
diff --git a/Lib/fontTools/misc/dictTools.py b/Lib/fontTools/misc/dictTools.py
index e3c0df73..cd3d394c 100644
--- a/Lib/fontTools/misc/dictTools.py
+++ b/Lib/fontTools/misc/dictTools.py
@@ -1,6 +1,5 @@
"""Misc dict tools."""
-
__all__ = ["hashdict"]
diff --git a/Lib/fontTools/misc/etree.py b/Lib/fontTools/misc/etree.py
index 9d4a65c3..d0967b5f 100644
--- a/Lib/fontTools/misc/etree.py
+++ b/Lib/fontTools/misc/etree.py
@@ -11,6 +11,7 @@ or subclasses built-in ElementTree classes to add features that are
only availble in lxml, like OrderedDict for attributes, pretty_print and
iterwalk.
"""
+
from fontTools.misc.textTools import tostr
diff --git a/Lib/fontTools/misc/filenames.py b/Lib/fontTools/misc/filenames.py
index d279f89c..ddedc521 100644
--- a/Lib/fontTools/misc/filenames.py
+++ b/Lib/fontTools/misc/filenames.py
@@ -17,7 +17,6 @@ by Tal Leming and is copyright (c) 2005-2016, The RoboFab Developers:
- Just van Rossum
"""
-
illegalCharacters = r"\" * + / : < > ? [ \ ] | \0".split(" ")
illegalCharacters += [chr(i) for i in range(1, 32)]
illegalCharacters += [chr(0x7F)]
diff --git a/Lib/fontTools/misc/textTools.py b/Lib/fontTools/misc/textTools.py
index f7ca1acc..f5484a83 100644
--- a/Lib/fontTools/misc/textTools.py
+++ b/Lib/fontTools/misc/textTools.py
@@ -1,6 +1,5 @@
"""fontTools.misc.textTools.py -- miscellaneous routines."""
-
import ast
import string
diff --git a/Lib/fontTools/misc/transform.py b/Lib/fontTools/misc/transform.py
index f85b54b7..0f9f3a5d 100644
--- a/Lib/fontTools/misc/transform.py
+++ b/Lib/fontTools/misc/transform.py
@@ -76,7 +76,6 @@ def _normSinCos(v):
class Transform(NamedTuple):
-
"""2x2 transformation matrix plus offset, a.k.a. Affine transform.
Transform instances are immutable: all transforming methods, eg.
rotate(), return a new Transform instance.
diff --git a/Lib/fontTools/misc/vector.py b/Lib/fontTools/misc/vector.py
index 666ff15c..02c62e65 100644
--- a/Lib/fontTools/misc/vector.py
+++ b/Lib/fontTools/misc/vector.py
@@ -8,7 +8,6 @@ __all__ = ["Vector"]
class Vector(tuple):
-
"""A math-like vector.
Represents an n-dimensional numeric vector. ``Vector`` objects support
diff --git a/Lib/fontTools/otlLib/builder.py b/Lib/fontTools/otlLib/builder.py
index 3508a7e2..70fd87ab 100644
--- a/Lib/fontTools/otlLib/builder.py
+++ b/Lib/fontTools/otlLib/builder.py
@@ -1,11 +1,13 @@
from collections import namedtuple, OrderedDict
import os
from fontTools.misc.fixedTools import fixedToFloat
+from fontTools.misc.roundTools import otRound
from fontTools import ttLib
from fontTools.ttLib.tables import otTables as ot
from fontTools.ttLib.tables.otBase import (
ValueRecord,
valueRecordFormatDict,
+ OTLOffsetOverflowError,
OTTableWriter,
CountReference,
)
@@ -350,16 +352,14 @@ class ChainContextualBuilder(LookupBuilder):
return [x for x in ruleset if len(x.rules) > 0]
def getCompiledSize_(self, subtables):
- size = 0
- for st in subtables:
- w = OTTableWriter()
- w["LookupType"] = CountReference(
- {"LookupType": st.LookupType}, "LookupType"
- )
- # We need to make a copy here because compiling
- # modifies the subtable (finalizing formats etc.)
- copy.deepcopy(st).compile(w, self.font)
- size += len(w.getAllData())
+ if not subtables:
+ return 0
+ # We need to make a copy here because compiling
+ # modifies the subtable (finalizing formats etc.)
+ table = self.buildLookup_(copy.deepcopy(subtables))
+ w = OTTableWriter()
+ table.compile(w, self.font)
+ size = len(w.getAllData())
return size
def build(self):
@@ -410,22 +410,23 @@ class ChainContextualBuilder(LookupBuilder):
if not ruleset.hasAnyGlyphClasses:
candidates[1] = [self.buildFormat1Subtable(ruleset, chaining)]
+ candidates_by_size = []
for i in [1, 2, 3]:
if candidates[i]:
try:
- self.getCompiledSize_(candidates[i])
- except Exception as e:
+ size = self.getCompiledSize_(candidates[i])
+ except OTLOffsetOverflowError as e:
log.warning(
"Contextual format %i at %s overflowed (%s)"
% (i, str(self.location), e)
)
- candidates[i] = None
+ else:
+ candidates_by_size.append((size, candidates[i]))
- candidates = [x for x in candidates if x is not None]
- if not candidates:
+ if not candidates_by_size:
raise OpenTypeLibError("All candidates overflowed", self.location)
- winner = min(candidates, key=self.getCompiledSize_)
+ _min_size, winner = min(candidates_by_size, key=lambda x: x[0])
subtables.extend(winner)
# If we are not chaining, lookup type will be automatically fixed by
@@ -774,7 +775,10 @@ class ChainContextSubstBuilder(ChainContextualBuilder):
if lookup is not None:
alts = lookup.getAlternateGlyphs()
for glyph, replacements in alts.items():
- result.setdefault(glyph, set()).update(replacements)
+ alts_for_glyph = result.setdefault(glyph, [])
+ alts_for_glyph.extend(
+ g for g in replacements if g not in alts_for_glyph
+ )
return result
def find_chainable_single_subst(self, mapping):
@@ -1238,7 +1242,7 @@ class SingleSubstBuilder(LookupBuilder):
return self.buildLookup_(subtables)
def getAlternateGlyphs(self):
- return {glyph: set([repl]) for glyph, repl in self.mapping.items()}
+ return {glyph: [repl] for glyph, repl in self.mapping.items()}
def add_subtable_break(self, location):
self.mapping[(self.SUBTABLE_BREAK_, location)] = self.SUBTABLE_BREAK_
@@ -1567,19 +1571,6 @@ def buildAlternateSubstSubtable(mapping):
return self
-def _getLigatureKey(components):
- # Computes a key for ordering ligatures in a GSUB Type-4 lookup.
-
- # When building the OpenType lookup, we need to make sure that
- # the longest sequence of components is listed first, so we
- # use the negative length as the primary key for sorting.
- # To make buildLigatureSubstSubtable() deterministic, we use the
- # component sequence as the secondary key.
-
- # For example, this will sort (f,f,f) < (f,f,i) < (f,f) < (f,i) < (f,l).
- return (-len(components), components)
-
-
def buildLigatureSubstSubtable(mapping):
"""Builds a ligature substitution (GSUB4) subtable.
@@ -1613,7 +1604,7 @@ def buildLigatureSubstSubtable(mapping):
# with fontTools >= 3.1:
# self.ligatures = dict(mapping)
self.ligatures = {}
- for components in sorted(mapping.keys(), key=_getLigatureKey):
+ for components in sorted(mapping.keys(), key=self._getLigatureSortKey):
ligature = ot.Ligature()
ligature.Component = components[1:]
ligature.CompCount = len(ligature.Component) + 1
@@ -2781,14 +2772,13 @@ def buildStatTable(
"""
ttFont["STAT"] = ttLib.newTable("STAT")
statTable = ttFont["STAT"].table = ot.STAT()
- nameTable = ttFont["name"]
statTable.ElidedFallbackNameID = _addName(
- nameTable, elidedFallbackName, windows=windowsNames, mac=macNames
+ ttFont, elidedFallbackName, windows=windowsNames, mac=macNames
)
# 'locations' contains data for AxisValue Format 4
axisRecords, axisValues = _buildAxisRecords(
- axes, nameTable, windowsNames=windowsNames, macNames=macNames
+ axes, ttFont, windowsNames=windowsNames, macNames=macNames
)
if not locations:
statTable.Version = 0x00010001
@@ -2797,10 +2787,10 @@ def buildStatTable(
# requires a higher table version
statTable.Version = 0x00010002
multiAxisValues = _buildAxisValuesFormat4(
- locations, axes, nameTable, windowsNames=windowsNames, macNames=macNames
+ locations, axes, ttFont, windowsNames=windowsNames, macNames=macNames
)
axisValues = multiAxisValues + axisValues
- nameTable.names.sort()
+ ttFont["name"].names.sort()
# Store AxisRecords
axisRecordArray = ot.AxisRecordArray()
@@ -2820,14 +2810,14 @@ def buildStatTable(
statTable.AxisValueCount = len(axisValues)
-def _buildAxisRecords(axes, nameTable, windowsNames=True, macNames=True):
+def _buildAxisRecords(axes, ttFont, windowsNames=True, macNames=True):
axisRecords = []
axisValues = []
for axisRecordIndex, axisDict in enumerate(axes):
axis = ot.AxisRecord()
axis.AxisTag = axisDict["tag"]
axis.AxisNameID = _addName(
- nameTable, axisDict["name"], 256, windows=windowsNames, mac=macNames
+ ttFont, axisDict["name"], 256, windows=windowsNames, mac=macNames
)
axis.AxisOrdering = axisDict.get("ordering", axisRecordIndex)
axisRecords.append(axis)
@@ -2837,7 +2827,7 @@ def _buildAxisRecords(axes, nameTable, windowsNames=True, macNames=True):
axisValRec.AxisIndex = axisRecordIndex
axisValRec.Flags = axisVal.get("flags", 0)
axisValRec.ValueNameID = _addName(
- nameTable, axisVal["name"], windows=windowsNames, mac=macNames
+ ttFont, axisVal["name"], windows=windowsNames, mac=macNames
)
if "value" in axisVal:
@@ -2863,9 +2853,7 @@ def _buildAxisRecords(axes, nameTable, windowsNames=True, macNames=True):
return axisRecords, axisValues
-def _buildAxisValuesFormat4(
- locations, axes, nameTable, windowsNames=True, macNames=True
-):
+def _buildAxisValuesFormat4(locations, axes, ttFont, windowsNames=True, macNames=True):
axisTagToIndex = {}
for axisRecordIndex, axisDict in enumerate(axes):
axisTagToIndex[axisDict["tag"]] = axisRecordIndex
@@ -2875,7 +2863,7 @@ def _buildAxisValuesFormat4(
axisValRec = ot.AxisValue()
axisValRec.Format = 4
axisValRec.ValueNameID = _addName(
- nameTable, axisLocationDict["name"], windows=windowsNames, mac=macNames
+ ttFont, axisLocationDict["name"], windows=windowsNames, mac=macNames
)
axisValRec.Flags = axisLocationDict.get("flags", 0)
axisValueRecords = []
@@ -2891,7 +2879,8 @@ def _buildAxisValuesFormat4(
return axisValues
-def _addName(nameTable, value, minNameID=0, windows=True, mac=True):
+def _addName(ttFont, value, minNameID=0, windows=True, mac=True):
+ nameTable = ttFont["name"]
if isinstance(value, int):
# Already a nameID
return value
@@ -2916,5 +2905,296 @@ def _addName(nameTable, value, minNameID=0, windows=True, mac=True):
else:
raise TypeError("value must be int, str, dict or list")
return nameTable.addMultilingualName(
- names, windows=windows, mac=mac, minNameID=minNameID
+ names, ttFont=ttFont, windows=windows, mac=mac, minNameID=minNameID
)
+
+
+def buildMathTable(
+ ttFont,
+ constants=None,
+ italicsCorrections=None,
+ topAccentAttachments=None,
+ extendedShapes=None,
+ mathKerns=None,
+ minConnectorOverlap=0,
+ vertGlyphVariants=None,
+ horizGlyphVariants=None,
+ vertGlyphAssembly=None,
+ horizGlyphAssembly=None,
+):
+ """
+ Add a 'MATH' table to 'ttFont'.
+
+ 'constants' is a dictionary of math constants. The keys are the constant
+ names from the MATH table specification (with capital first letter), and the
+ values are the constant values as numbers.
+
+ 'italicsCorrections' is a dictionary of italic corrections. The keys are the
+ glyph names, and the values are the italic corrections as numbers.
+
+ 'topAccentAttachments' is a dictionary of top accent attachments. The keys
+ are the glyph names, and the values are the top accent horizontal positions
+ as numbers.
+
+ 'extendedShapes' is a set of extended shape glyphs.
+
+ 'mathKerns' is a dictionary of math kerns. The keys are the glyph names, and
+ the values are dictionaries. The keys of these dictionaries are the side
+ names ('TopRight', 'TopLeft', 'BottomRight', 'BottomLeft'), and the values
+ are tuples of two lists. The first list contains the correction heights as
+ numbers, and the second list contains the kern values as numbers.
+
+ 'minConnectorOverlap' is the minimum connector overlap as a number.
+
+ 'vertGlyphVariants' is a dictionary of vertical glyph variants. The keys are
+ the glyph names, and the values are tuples of glyph name and full advance height.
+
+ 'horizGlyphVariants' is a dictionary of horizontal glyph variants. The keys
+ are the glyph names, and the values are tuples of glyph name and full
+ advance width.
+
+ 'vertGlyphAssembly' is a dictionary of vertical glyph assemblies. The keys
+ are the glyph names, and the values are tuples of assembly parts and italics
+ correction. The assembly parts are tuples of glyph name, flags, start
+ connector length, end connector length, and full advance height.
+
+ 'horizGlyphAssembly' is a dictionary of horizontal glyph assemblies. The
+ keys are the glyph names, and the values are tuples of assembly parts
+ and italics correction. The assembly parts are tuples of glyph name, flags,
+ start connector length, end connector length, and full advance width.
+
+ Where a number is expected, an integer or a float can be used. The floats
+ will be rounded.
+
+ Example::
+
+ constants = {
+ "ScriptPercentScaleDown": 70,
+ "ScriptScriptPercentScaleDown": 50,
+ "DelimitedSubFormulaMinHeight": 24,
+ "DisplayOperatorMinHeight": 60,
+ ...
+ }
+ italicsCorrections = {
+ "fitalic-math": 100,
+ "fbolditalic-math": 120,
+ ...
+ }
+ topAccentAttachments = {
+ "circumflexcomb": 500,
+ "acutecomb": 400,
+ "A": 300,
+ "B": 340,
+ ...
+ }
+ extendedShapes = {"parenleft", "parenright", ...}
+ mathKerns = {
+ "A": {
+ "TopRight": ([-50, -100], [10, 20, 30]),
+ "TopLeft": ([50, 100], [10, 20, 30]),
+ ...
+ },
+ ...
+ }
+ vertGlyphVariants = {
+ "parenleft": [("parenleft", 700), ("parenleft.size1", 1000), ...],
+ "parenright": [("parenright", 700), ("parenright.size1", 1000), ...],
+ ...
+ }
+ vertGlyphAssembly = {
+ "braceleft": [
+ (
+ ("braceleft.bottom", 0, 0, 200, 500),
+ ("braceleft.extender", 1, 200, 200, 200)),
+ ("braceleft.middle", 0, 100, 100, 700),
+ ("braceleft.extender", 1, 200, 200, 200),
+ ("braceleft.top", 0, 200, 0, 500),
+ ),
+ 100,
+ ],
+ ...
+ }
+ """
+ glyphMap = ttFont.getReverseGlyphMap()
+
+ ttFont["MATH"] = math = ttLib.newTable("MATH")
+ math.table = table = ot.MATH()
+ table.Version = 0x00010000
+ table.populateDefaults()
+
+ table.MathConstants = _buildMathConstants(constants)
+ table.MathGlyphInfo = _buildMathGlyphInfo(
+ glyphMap,
+ italicsCorrections,
+ topAccentAttachments,
+ extendedShapes,
+ mathKerns,
+ )
+ table.MathVariants = _buildMathVariants(
+ glyphMap,
+ minConnectorOverlap,
+ vertGlyphVariants,
+ horizGlyphVariants,
+ vertGlyphAssembly,
+ horizGlyphAssembly,
+ )
+
+
+def _buildMathConstants(constants):
+ if not constants:
+ return None
+
+ mathConstants = ot.MathConstants()
+ for conv in mathConstants.getConverters():
+ value = otRound(constants.get(conv.name, 0))
+ if conv.tableClass:
+ assert issubclass(conv.tableClass, ot.MathValueRecord)
+ value = _mathValueRecord(value)
+ setattr(mathConstants, conv.name, value)
+ return mathConstants
+
+
+def _buildMathGlyphInfo(
+ glyphMap,
+ italicsCorrections,
+ topAccentAttachments,
+ extendedShapes,
+ mathKerns,
+):
+ if not any([extendedShapes, italicsCorrections, topAccentAttachments, mathKerns]):
+ return None
+
+ info = ot.MathGlyphInfo()
+ info.populateDefaults()
+
+ if italicsCorrections:
+ coverage = buildCoverage(italicsCorrections.keys(), glyphMap)
+ info.MathItalicsCorrectionInfo = ot.MathItalicsCorrectionInfo()
+ info.MathItalicsCorrectionInfo.Coverage = coverage
+ info.MathItalicsCorrectionInfo.ItalicsCorrectionCount = len(coverage.glyphs)
+ info.MathItalicsCorrectionInfo.ItalicsCorrection = [
+ _mathValueRecord(italicsCorrections[n]) for n in coverage.glyphs
+ ]
+
+ if topAccentAttachments:
+ coverage = buildCoverage(topAccentAttachments.keys(), glyphMap)
+ info.MathTopAccentAttachment = ot.MathTopAccentAttachment()
+ info.MathTopAccentAttachment.TopAccentCoverage = coverage
+ info.MathTopAccentAttachment.TopAccentAttachmentCount = len(coverage.glyphs)
+ info.MathTopAccentAttachment.TopAccentAttachment = [
+ _mathValueRecord(topAccentAttachments[n]) for n in coverage.glyphs
+ ]
+
+ if extendedShapes:
+ info.ExtendedShapeCoverage = buildCoverage(extendedShapes, glyphMap)
+
+ if mathKerns:
+ coverage = buildCoverage(mathKerns.keys(), glyphMap)
+ info.MathKernInfo = ot.MathKernInfo()
+ info.MathKernInfo.MathKernCoverage = coverage
+ info.MathKernInfo.MathKernCount = len(coverage.glyphs)
+ info.MathKernInfo.MathKernInfoRecords = []
+ for glyph in coverage.glyphs:
+ record = ot.MathKernInfoRecord()
+ for side in {"TopRight", "TopLeft", "BottomRight", "BottomLeft"}:
+ if side in mathKerns[glyph]:
+ correctionHeights, kernValues = mathKerns[glyph][side]
+ assert len(correctionHeights) == len(kernValues) - 1
+ kern = ot.MathKern()
+ kern.HeightCount = len(correctionHeights)
+ kern.CorrectionHeight = [
+ _mathValueRecord(h) for h in correctionHeights
+ ]
+ kern.KernValue = [_mathValueRecord(v) for v in kernValues]
+ setattr(record, f"{side}MathKern", kern)
+ info.MathKernInfo.MathKernInfoRecords.append(record)
+
+ return info
+
+
+def _buildMathVariants(
+ glyphMap,
+ minConnectorOverlap,
+ vertGlyphVariants,
+ horizGlyphVariants,
+ vertGlyphAssembly,
+ horizGlyphAssembly,
+):
+ if not any(
+ [vertGlyphVariants, horizGlyphVariants, vertGlyphAssembly, horizGlyphAssembly]
+ ):
+ return None
+
+ variants = ot.MathVariants()
+ variants.populateDefaults()
+
+ variants.MinConnectorOverlap = minConnectorOverlap
+
+ if vertGlyphVariants or vertGlyphAssembly:
+ variants.VertGlyphCoverage, variants.VertGlyphConstruction = (
+ _buildMathGlyphConstruction(
+ glyphMap,
+ vertGlyphVariants,
+ vertGlyphAssembly,
+ )
+ )
+
+ if horizGlyphVariants or horizGlyphAssembly:
+ variants.HorizGlyphCoverage, variants.HorizGlyphConstruction = (
+ _buildMathGlyphConstruction(
+ glyphMap,
+ horizGlyphVariants,
+ horizGlyphAssembly,
+ )
+ )
+
+ return variants
+
+
+def _buildMathGlyphConstruction(glyphMap, variants, assemblies):
+ glyphs = set()
+ if variants:
+ glyphs.update(variants.keys())
+ if assemblies:
+ glyphs.update(assemblies.keys())
+ coverage = buildCoverage(glyphs, glyphMap)
+ constructions = []
+
+ for glyphName in coverage.glyphs:
+ construction = ot.MathGlyphConstruction()
+ construction.populateDefaults()
+
+ if variants and glyphName in variants:
+ construction.VariantCount = len(variants[glyphName])
+ construction.MathGlyphVariantRecord = []
+ for variantName, advance in variants[glyphName]:
+ record = ot.MathGlyphVariantRecord()
+ record.VariantGlyph = variantName
+ record.AdvanceMeasurement = otRound(advance)
+ construction.MathGlyphVariantRecord.append(record)
+
+ if assemblies and glyphName in assemblies:
+ parts, ic = assemblies[glyphName]
+ construction.GlyphAssembly = ot.GlyphAssembly()
+ construction.GlyphAssembly.ItalicsCorrection = _mathValueRecord(ic)
+ construction.GlyphAssembly.PartCount = len(parts)
+ construction.GlyphAssembly.PartRecords = []
+ for part in parts:
+ part_name, flags, start, end, advance = part
+ record = ot.GlyphPartRecord()
+ record.glyph = part_name
+ record.PartFlags = int(flags)
+ record.StartConnectorLength = otRound(start)
+ record.EndConnectorLength = otRound(end)
+ record.FullAdvance = otRound(advance)
+ construction.GlyphAssembly.PartRecords.append(record)
+
+ constructions.append(construction)
+
+ return coverage, constructions
+
+
+def _mathValueRecord(value):
+ value_record = ot.MathValueRecord()
+ value_record.Value = otRound(value)
+ return value_record
diff --git a/Lib/fontTools/pens/basePen.py b/Lib/fontTools/pens/basePen.py
index ac8abd40..5d2cf503 100644
--- a/Lib/fontTools/pens/basePen.py
+++ b/Lib/fontTools/pens/basePen.py
@@ -148,7 +148,6 @@ class AbstractPen:
class NullPen(AbstractPen):
-
"""A pen that does nothing."""
def moveTo(self, pt):
@@ -187,7 +186,6 @@ class MissingComponentError(KeyError):
class DecomposingPen(LoggingPen):
-
"""Implements a 'addComponent' method that decomposes components
(i.e. draws them onto self as simple contours).
It can also be used as a mixin class (e.g. see ContourRecordingPen).
@@ -229,7 +227,6 @@ class DecomposingPen(LoggingPen):
class BasePen(DecomposingPen):
-
"""Base class for drawing pens. You must override _moveTo, _lineTo and
_curveToOne. You may additionally override _closePath, _endPath,
addComponent, addVarComponent, and/or _qCurveToOne. You should not
diff --git a/Lib/fontTools/pens/boundsPen.py b/Lib/fontTools/pens/boundsPen.py
index d833cc89..c9218441 100644
--- a/Lib/fontTools/pens/boundsPen.py
+++ b/Lib/fontTools/pens/boundsPen.py
@@ -7,7 +7,6 @@ __all__ = ["BoundsPen", "ControlBoundsPen"]
class ControlBoundsPen(BasePen):
-
"""Pen to calculate the "control bounds" of a shape. This is the
bounding box of all control points, so may be larger than the
actual bounding box if there are curves that don't have points
@@ -67,7 +66,6 @@ class ControlBoundsPen(BasePen):
class BoundsPen(ControlBoundsPen):
-
"""Pen to calculate the bounds of a shape. It calculates the
correct bounds even when the shape contains curves that don't
have points on their extremes. This is somewhat slower to compute
diff --git a/Lib/fontTools/pens/filterPen.py b/Lib/fontTools/pens/filterPen.py
index 81423109..6c8712c2 100644
--- a/Lib/fontTools/pens/filterPen.py
+++ b/Lib/fontTools/pens/filterPen.py
@@ -9,7 +9,6 @@ class _PassThruComponentsMixin(object):
class FilterPen(_PassThruComponentsMixin, AbstractPen):
-
"""Base class for pens that apply some transformation to the coordinates
they receive and pass them to another pen.
diff --git a/Lib/fontTools/pens/hashPointPen.py b/Lib/fontTools/pens/hashPointPen.py
index b82468ec..f15dcabb 100644
--- a/Lib/fontTools/pens/hashPointPen.py
+++ b/Lib/fontTools/pens/hashPointPen.py
@@ -31,6 +31,20 @@ class HashPointPen(AbstractPointPen):
> # The hash values are identical, the outline has not changed.
> # Compile the hinting code ...
> pass
+
+ If you want to compare a glyph from a source format which supports floating point
+ coordinates and transformations against a glyph from a format which has restrictions
+ on the precision of floats, e.g. UFO vs. TTF, you must use an appropriate rounding
+ function to make the values comparable. For TTF fonts with composites, this
+ construct can be used to make the transform values conform to F2Dot14:
+
+ > ttf_hash_pen = HashPointPen(ttf_glyph_width, ttFont.getGlyphSet())
+ > ttf_round_pen = RoundingPointPen(ttf_hash_pen, transformRoundFunc=partial(floatToFixedToFloat, precisionBits=14))
+ > ufo_hash_pen = HashPointPen(ufo_glyph.width, ufo)
+ > ttf_glyph.drawPoints(ttf_round_pen, ttFont["glyf"])
+ > ufo_round_pen = RoundingPointPen(ufo_hash_pen, transformRoundFunc=partial(floatToFixedToFloat, precisionBits=14))
+ > ufo_glyph.drawPoints(ufo_round_pen)
+ > assert ttf_hash_pen.hash == ufo_hash_pen.hash
"""
def __init__(self, glyphWidth=0, glyphSet=None):
diff --git a/Lib/fontTools/pens/momentsPen.py b/Lib/fontTools/pens/momentsPen.py
index dab0d10e..4c7ddfe3 100644
--- a/Lib/fontTools/pens/momentsPen.py
+++ b/Lib/fontTools/pens/momentsPen.py
@@ -36,8 +36,7 @@ class MomentsPen(BasePen):
def _endPath(self):
p0 = self._getCurrentPoint()
if p0 != self.__startPoint:
- # Green theorem is not defined on open contours.
- raise OpenContourError("Green theorem is not defined on open contours.")
+ raise OpenContourError("Glyph statistics not defined on open contours.")
@cython.locals(r0=cython.double)
@cython.locals(r1=cython.double)
diff --git a/Lib/fontTools/pens/pointInsidePen.py b/Lib/fontTools/pens/pointInsidePen.py
index 8a579ae4..e1fbbbcb 100644
--- a/Lib/fontTools/pens/pointInsidePen.py
+++ b/Lib/fontTools/pens/pointInsidePen.py
@@ -10,7 +10,6 @@ __all__ = ["PointInsidePen"]
class PointInsidePen(BasePen):
-
"""This pen implements "point inside" testing: to test whether
a given point lies inside the shape (black) or outside (white).
Instances of this class can be recycled, as long as the
diff --git a/Lib/fontTools/pens/quartzPen.py b/Lib/fontTools/pens/quartzPen.py
index 6e1228d6..2b8a927d 100644
--- a/Lib/fontTools/pens/quartzPen.py
+++ b/Lib/fontTools/pens/quartzPen.py
@@ -9,7 +9,6 @@ __all__ = ["QuartzPen"]
class QuartzPen(BasePen):
-
"""A pen that creates a CGPath
Parameters
diff --git a/Lib/fontTools/pens/recordingPen.py b/Lib/fontTools/pens/recordingPen.py
index 6c3b6613..4f44a4d5 100644
--- a/Lib/fontTools/pens/recordingPen.py
+++ b/Lib/fontTools/pens/recordingPen.py
@@ -1,4 +1,5 @@
"""Pen recording operations that can be accessed or replayed."""
+
from fontTools.pens.basePen import AbstractPen, DecomposingPen
from fontTools.pens.pointPen import AbstractPointPen
@@ -8,6 +9,7 @@ __all__ = [
"RecordingPen",
"DecomposingRecordingPen",
"RecordingPointPen",
+ "lerpRecordings",
]
@@ -76,6 +78,8 @@ class RecordingPen(AbstractPen):
def replay(self, pen):
replayRecording(self.value, pen)
+ draw = replay
+
class DecomposingRecordingPen(DecomposingPen, RecordingPen):
"""Same as RecordingPen, except that it doesn't keep components
@@ -167,6 +171,36 @@ class RecordingPointPen(AbstractPointPen):
for operator, args, kwargs in self.value:
getattr(pointPen, operator)(*args, **kwargs)
+ drawPoints = replay
+
+
+def lerpRecordings(recording1, recording2, factor=0.5):
+ """Linearly interpolate between two recordings. The recordings
+ must be decomposed, i.e. they must not contain any components.
+
+ Factor is typically between 0 and 1. 0 means the first recording,
+ 1 means the second recording, and 0.5 means the average of the
+ two recordings. Other values are possible, and can be useful to
+ extrapolate. Defaults to 0.5.
+
+ Returns a generator with the new recording.
+ """
+ if len(recording1) != len(recording2):
+ raise ValueError(
+ "Mismatched lengths: %d and %d" % (len(recording1), len(recording2))
+ )
+ for (op1, args1), (op2, args2) in zip(recording1, recording2):
+ if op1 != op2:
+ raise ValueError("Mismatched operations: %s, %s" % (op1, op2))
+ if op1 == "addComponent":
+ raise ValueError("Cannot interpolate components")
+ else:
+ mid_args = [
+ (x1 + (x2 - x1) * factor, y1 + (y2 - y1) * factor)
+ for (x1, y1), (x2, y2) in zip(args1, args2)
+ ]
+ yield (op1, mid_args)
+
if __name__ == "__main__":
pen = RecordingPen()
diff --git a/Lib/fontTools/pens/reportLabPen.py b/Lib/fontTools/pens/reportLabPen.py
index 2cb89c8b..20c9065c 100644
--- a/Lib/fontTools/pens/reportLabPen.py
+++ b/Lib/fontTools/pens/reportLabPen.py
@@ -6,7 +6,6 @@ __all__ = ["ReportLabPen"]
class ReportLabPen(BasePen):
-
"""A pen for drawing onto a ``reportlab.graphics.shapes.Path`` object."""
def __init__(self, glyphSet, path=None):
diff --git a/Lib/fontTools/pens/roundingPen.py b/Lib/fontTools/pens/roundingPen.py
index 2a7c476c..176bcc7a 100644
--- a/Lib/fontTools/pens/roundingPen.py
+++ b/Lib/fontTools/pens/roundingPen.py
@@ -1,4 +1,4 @@
-from fontTools.misc.roundTools import otRound
+from fontTools.misc.roundTools import noRound, otRound
from fontTools.misc.transform import Transform
from fontTools.pens.filterPen import FilterPen, FilterPointPen
@@ -8,7 +8,9 @@ __all__ = ["RoundingPen", "RoundingPointPen"]
class RoundingPen(FilterPen):
"""
- Filter pen that rounds point coordinates and component XY offsets to integer.
+ Filter pen that rounds point coordinates and component XY offsets to integer. For
+ rounding the component transform values, a separate round function can be passed to
+ the pen.
>>> from fontTools.pens.recordingPen import RecordingPen
>>> recpen = RecordingPen()
@@ -28,9 +30,10 @@ class RoundingPen(FilterPen):
True
"""
- def __init__(self, outPen, roundFunc=otRound):
+ def __init__(self, outPen, roundFunc=otRound, transformRoundFunc=noRound):
super().__init__(outPen)
self.roundFunc = roundFunc
+ self.transformRoundFunc = transformRoundFunc
def moveTo(self, pt):
self._outPen.moveTo((self.roundFunc(pt[0]), self.roundFunc(pt[1])))
@@ -49,12 +52,16 @@ class RoundingPen(FilterPen):
)
def addComponent(self, glyphName, transformation):
+ xx, xy, yx, yy, dx, dy = transformation
self._outPen.addComponent(
glyphName,
Transform(
- *transformation[:4],
- self.roundFunc(transformation[4]),
- self.roundFunc(transformation[5]),
+ self.transformRoundFunc(xx),
+ self.transformRoundFunc(xy),
+ self.transformRoundFunc(yx),
+ self.transformRoundFunc(yy),
+ self.roundFunc(dx),
+ self.roundFunc(dy),
),
)
@@ -62,6 +69,8 @@ class RoundingPen(FilterPen):
class RoundingPointPen(FilterPointPen):
"""
Filter point pen that rounds point coordinates and component XY offsets to integer.
+ For rounding the component scale values, a separate round function can be passed to
+ the pen.
>>> from fontTools.pens.recordingPen import RecordingPointPen
>>> recpen = RecordingPointPen()
@@ -87,26 +96,35 @@ class RoundingPointPen(FilterPointPen):
True
"""
- def __init__(self, outPen, roundFunc=otRound):
+ def __init__(self, outPen, roundFunc=otRound, transformRoundFunc=noRound):
super().__init__(outPen)
self.roundFunc = roundFunc
+ self.transformRoundFunc = transformRoundFunc
- def addPoint(self, pt, segmentType=None, smooth=False, name=None, **kwargs):
+ def addPoint(
+ self, pt, segmentType=None, smooth=False, name=None, identifier=None, **kwargs
+ ):
self._outPen.addPoint(
(self.roundFunc(pt[0]), self.roundFunc(pt[1])),
segmentType=segmentType,
smooth=smooth,
name=name,
+ identifier=identifier,
**kwargs,
)
- def addComponent(self, baseGlyphName, transformation, **kwargs):
+ def addComponent(self, baseGlyphName, transformation, identifier=None, **kwargs):
+ xx, xy, yx, yy, dx, dy = transformation
self._outPen.addComponent(
- baseGlyphName,
- Transform(
- *transformation[:4],
- self.roundFunc(transformation[4]),
- self.roundFunc(transformation[5]),
+ baseGlyphName=baseGlyphName,
+ transformation=Transform(
+ self.transformRoundFunc(xx),
+ self.transformRoundFunc(xy),
+ self.transformRoundFunc(yx),
+ self.transformRoundFunc(yy),
+ self.roundFunc(dx),
+ self.roundFunc(dy),
),
+ identifier=identifier,
**kwargs,
)
diff --git a/Lib/fontTools/pens/statisticsPen.py b/Lib/fontTools/pens/statisticsPen.py
index 39f319e0..699b14ca 100644
--- a/Lib/fontTools/pens/statisticsPen.py
+++ b/Lib/fontTools/pens/statisticsPen.py
@@ -1,46 +1,78 @@
"""Pen calculating area, center of mass, variance and standard-deviation,
covariance and correlation, and slant, of glyph shapes."""
-import math
+
+from math import sqrt, degrees, atan
+from fontTools.pens.basePen import BasePen, OpenContourError
from fontTools.pens.momentsPen import MomentsPen
-__all__ = ["StatisticsPen"]
+__all__ = ["StatisticsPen", "StatisticsControlPen"]
+
+
+class StatisticsBase:
+ def __init__(self):
+ self._zero()
+
+ def _zero(self):
+ self.area = 0
+ self.meanX = 0
+ self.meanY = 0
+ self.varianceX = 0
+ self.varianceY = 0
+ self.stddevX = 0
+ self.stddevY = 0
+ self.covariance = 0
+ self.correlation = 0
+ self.slant = 0
+ def _update(self):
+ # XXX The variance formulas should never produce a negative value,
+ # but due to reasons I don't understand, both of our pens do.
+ # So we take the absolute value here.
+ self.varianceX = abs(self.varianceX)
+ self.varianceY = abs(self.varianceY)
-class StatisticsPen(MomentsPen):
+ self.stddevX = stddevX = sqrt(self.varianceX)
+ self.stddevY = stddevY = sqrt(self.varianceY)
+ # Correlation(X,Y) = Covariance(X,Y) / ( stddev(X) * stddev(Y) )
+ # https://en.wikipedia.org/wiki/Pearson_product-moment_correlation_coefficient
+ if stddevX * stddevY == 0:
+ correlation = float("NaN")
+ else:
+ # XXX The above formula should never produce a value outside
+ # the range [-1, 1], but due to reasons I don't understand,
+ # (probably the same issue as above), it does. So we clamp.
+ correlation = self.covariance / (stddevX * stddevY)
+ correlation = max(-1, min(1, correlation))
+ self.correlation = correlation if abs(correlation) > 1e-3 else 0
+
+ slant = (
+ self.covariance / self.varianceY if self.varianceY != 0 else float("NaN")
+ )
+ self.slant = slant if abs(slant) > 1e-3 else 0
+
+
+class StatisticsPen(StatisticsBase, MomentsPen):
"""Pen calculating area, center of mass, variance and
standard-deviation, covariance and correlation, and slant,
of glyph shapes.
- Note that all the calculated values are 'signed'. Ie. if the
- glyph shape is self-intersecting, the values are not correct
- (but well-defined). As such, area will be negative if contour
- directions are clockwise. Moreover, variance might be negative
- if the shapes are self-intersecting in certain ways."""
+ Note that if the glyph shape is self-intersecting, the values
+ are not correct (but well-defined). Moreover, area will be
+ negative if contour directions are clockwise."""
def __init__(self, glyphset=None):
MomentsPen.__init__(self, glyphset=glyphset)
- self.__zero()
+ StatisticsBase.__init__(self)
def _closePath(self):
MomentsPen._closePath(self)
- self.__update()
-
- def __zero(self):
- self.meanX = 0
- self.meanY = 0
- self.varianceX = 0
- self.varianceY = 0
- self.stddevX = 0
- self.stddevY = 0
- self.covariance = 0
- self.correlation = 0
- self.slant = 0
+ self._update()
- def __update(self):
+ def _update(self):
area = self.area
if not area:
- self.__zero()
+ self._zero()
return
# Center of mass
@@ -48,29 +80,97 @@ class StatisticsPen(MomentsPen):
self.meanX = meanX = self.momentX / area
self.meanY = meanY = self.momentY / area
- # Var(X) = E[X^2] - E[X]^2
- self.varianceX = varianceX = self.momentXX / area - meanX**2
- self.varianceY = varianceY = self.momentYY / area - meanY**2
+ # Var(X) = E[X^2] - E[X]^2
+ self.varianceX = self.momentXX / area - meanX * meanX
+ self.varianceY = self.momentYY / area - meanY * meanY
- self.stddevX = stddevX = math.copysign(abs(varianceX) ** 0.5, varianceX)
- self.stddevY = stddevY = math.copysign(abs(varianceY) ** 0.5, varianceY)
+ # Covariance(X,Y) = (E[X.Y] - E[X]E[Y])
+ self.covariance = self.momentXY / area - meanX * meanY
- # Covariance(X,Y) = ( E[X.Y] - E[X]E[Y] )
- self.covariance = covariance = self.momentXY / area - meanX * meanY
+ StatisticsBase._update(self)
- # Correlation(X,Y) = Covariance(X,Y) / ( stddev(X) * stddev(Y) )
- # https://en.wikipedia.org/wiki/Pearson_product-moment_correlation_coefficient
- if stddevX * stddevY == 0:
- correlation = float("NaN")
+
+class StatisticsControlPen(StatisticsBase, BasePen):
+ """Pen calculating area, center of mass, variance and
+ standard-deviation, covariance and correlation, and slant,
+ of glyph shapes, using the control polygon only.
+
+ Note that if the glyph shape is self-intersecting, the values
+ are not correct (but well-defined). Moreover, area will be
+ negative if contour directions are clockwise."""
+
+ def __init__(self, glyphset=None):
+ BasePen.__init__(self, glyphset)
+ StatisticsBase.__init__(self)
+ self._nodes = []
+
+ def _moveTo(self, pt):
+ self._nodes.append(complex(*pt))
+
+ def _lineTo(self, pt):
+ self._nodes.append(complex(*pt))
+
+ def _qCurveToOne(self, pt1, pt2):
+ for pt in (pt1, pt2):
+ self._nodes.append(complex(*pt))
+
+ def _curveToOne(self, pt1, pt2, pt3):
+ for pt in (pt1, pt2, pt3):
+ self._nodes.append(complex(*pt))
+
+ def _closePath(self):
+ self._update()
+
+ def _endPath(self):
+ p0 = self._getCurrentPoint()
+ if p0 != self.__startPoint:
+ raise OpenContourError("Glyph statistics not defined on open contours.")
+
+ def _update(self):
+ nodes = self._nodes
+ n = len(nodes)
+
+ # Triangle formula
+ self.area = (
+ sum(
+ (p0.real * p1.imag - p1.real * p0.imag)
+ for p0, p1 in zip(nodes, nodes[1:] + nodes[:1])
+ )
+ / 2
+ )
+
+ # Center of mass
+ # https://en.wikipedia.org/wiki/Center_of_mass#A_system_of_particles
+ sumNodes = sum(nodes)
+ self.meanX = meanX = sumNodes.real / n
+ self.meanY = meanY = sumNodes.imag / n
+
+ if n > 1:
+ # Var(X) = (sum[X^2] - sum[X]^2 / n) / (n - 1)
+ # https://www.statisticshowto.com/probability-and-statistics/descriptive-statistics/sample-variance/
+ self.varianceX = varianceX = (
+ sum(p.real * p.real for p in nodes)
+ - (sumNodes.real * sumNodes.real) / n
+ ) / (n - 1)
+ self.varianceY = varianceY = (
+ sum(p.imag * p.imag for p in nodes)
+ - (sumNodes.imag * sumNodes.imag) / n
+ ) / (n - 1)
+
+ # Covariance(X,Y) = (sum[X.Y] - sum[X].sum[Y] / n) / (n - 1)
+ self.covariance = covariance = (
+ sum(p.real * p.imag for p in nodes)
+ - (sumNodes.real * sumNodes.imag) / n
+ ) / (n - 1)
else:
- correlation = covariance / (stddevX * stddevY)
- self.correlation = correlation if abs(correlation) > 1e-3 else 0
+ self.varianceX = varianceX = 0
+ self.varianceY = varianceY = 0
+ self.covariance = covariance = 0
- slant = covariance / varianceY if varianceY != 0 else float("NaN")
- self.slant = slant if abs(slant) > 1e-3 else 0
+ StatisticsBase._update(self)
-def _test(glyphset, upem, glyphs, quiet=False):
+def _test(glyphset, upem, glyphs, quiet=False, *, control=False):
from fontTools.pens.transformPen import TransformPen
from fontTools.misc.transform import Scale
@@ -81,7 +181,10 @@ def _test(glyphset, upem, glyphs, quiet=False):
slnt_sum_perceptual = 0
for glyph_name in glyphs:
glyph = glyphset[glyph_name]
- pen = StatisticsPen(glyphset=glyphset)
+ if control:
+ pen = StatisticsControlPen(glyphset=glyphset)
+ else:
+ pen = StatisticsPen(glyphset=glyphset)
transformer = TransformPen(pen, Scale(1.0 / upem))
glyph.draw(transformer)
@@ -127,10 +230,10 @@ def _test(glyphset, upem, glyphs, quiet=False):
print("width: %g" % (wdth_sum / upem / len(glyphs)))
slant = slnt_sum / len(glyphs)
print("slant: %g" % slant)
- print("slant angle: %g" % -math.degrees(math.atan(slant)))
+ print("slant angle: %g" % -degrees(atan(slant)))
slant_perceptual = slnt_sum_perceptual / wdth_sum
print("slant (perceptual): %g" % slant_perceptual)
- print("slant (perceptual) angle: %g" % -math.degrees(math.atan(slant_perceptual)))
+ print("slant (perceptual) angle: %g" % -degrees(atan(slant_perceptual)))
def main(args):
@@ -155,6 +258,12 @@ def main(args):
help="Face index into a collection to open. Zero based.",
)
parser.add_argument(
+ "-c",
+ "--control",
+ action="store_true",
+ help="Use the control-box pen instead of the Green therem.",
+ )
+ parser.add_argument(
"-q", "--quiet", action="store_true", help="Only report font-wide statistics."
)
parser.add_argument(
@@ -188,6 +297,7 @@ def main(args):
font["head"].unitsPerEm,
glyphs,
quiet=options.quiet,
+ control=options.control,
)
diff --git a/Lib/fontTools/pens/svgPathPen.py b/Lib/fontTools/pens/svgPathPen.py
index ae6ebfbd..29d41a80 100644
--- a/Lib/fontTools/pens/svgPathPen.py
+++ b/Lib/fontTools/pens/svgPathPen.py
@@ -220,13 +220,19 @@ def main(args=None):
"fonttools pens.svgPathPen", description="Generate SVG from text"
)
parser.add_argument("font", metavar="font.ttf", help="Font file.")
- parser.add_argument("text", metavar="text", help="Text string.")
+ parser.add_argument("text", metavar="text", nargs="?", help="Text string.")
parser.add_argument(
"-y",
metavar="<number>",
help="Face index into a collection to open. Zero based.",
)
parser.add_argument(
+ "--glyphs",
+ metavar="whitespace-separated list of glyph names",
+ type=str,
+ help="Glyphs to show. Exclusive with text option",
+ )
+ parser.add_argument(
"--variations",
metavar="AXIS=LOC",
default="",
@@ -241,12 +247,13 @@ def main(args=None):
font = TTFont(options.font, fontNumber=fontNumber)
text = options.text
+ glyphs = options.glyphs
location = {}
for tag_v in options.variations.split():
fields = tag_v.split("=")
tag = fields[0].strip()
- v = int(fields[1])
+ v = float(fields[1])
location[tag] = v
hhea = font["hhea"]
@@ -255,10 +262,17 @@ def main(args=None):
glyphset = font.getGlyphSet(location=location)
cmap = font["cmap"].getBestCmap()
+ if glyphs is not None and text is not None:
+ raise ValueError("Options --glyphs and --text are exclusive")
+
+ if glyphs is None:
+ glyphs = " ".join(cmap[ord(u)] for u in text)
+
+ glyphs = glyphs.split()
+
s = ""
width = 0
- for u in text:
- g = cmap[ord(u)]
+ for g in glyphs:
glyph = glyphset[g]
pen = SVGPathPen(glyphset)
diff --git a/Lib/fontTools/pens/teePen.py b/Lib/fontTools/pens/teePen.py
index 2828175a..939f049b 100644
--- a/Lib/fontTools/pens/teePen.py
+++ b/Lib/fontTools/pens/teePen.py
@@ -1,4 +1,5 @@
"""Pen multiplexing drawing to one or more pens."""
+
from fontTools.pens.basePen import AbstractPen
diff --git a/Lib/fontTools/pens/transformPen.py b/Lib/fontTools/pens/transformPen.py
index 2e572f61..ff98dbdd 100644
--- a/Lib/fontTools/pens/transformPen.py
+++ b/Lib/fontTools/pens/transformPen.py
@@ -5,7 +5,6 @@ __all__ = ["TransformPen", "TransformPointPen"]
class TransformPen(FilterPen):
-
"""Pen that transforms all coordinates using a Affine transformation,
and passes them to another pen.
"""
diff --git a/Lib/fontTools/subset/__init__.py b/Lib/fontTools/subset/__init__.py
index bd826ed2..250a07ef 100644
--- a/Lib/fontTools/subset/__init__.py
+++ b/Lib/fontTools/subset/__init__.py
@@ -407,6 +407,10 @@ Other font-specific options
*not* be switched on if an intersection is found. [default]
--no-prune-unicode-ranges
Don't change the 'OS/2 ulUnicodeRange*' bits.
+--prune-codepage-ranges
+ Update the 'OS/2 ulCodePageRange*' bits after subsetting. [default]
+--no-prune-codepage-ranges
+ Don't change the 'OS/2 ulCodePageRange*' bits.
--recalc-average-width
Update the 'OS/2 xAvgCharWidth' field after subsetting.
--no-recalc-average-width
@@ -3086,6 +3090,7 @@ class Options(object):
self.recalc_bounds = False # Recalculate font bounding boxes
self.recalc_timestamp = False # Recalculate font modified timestamp
self.prune_unicode_ranges = True # Clear unused 'ulUnicodeRange' bits
+ self.prune_codepage_ranges = True # Clear unused 'ulCodePageRange' bits
self.recalc_average_width = False # update 'xAvgCharWidth'
self.recalc_max_context = False # update 'usMaxContext'
self.canonical_order = None # Order tables as recommended
@@ -3450,6 +3455,17 @@ class Subsetter(object):
log.info(
"%s Unicode ranges pruned: %s", tag, sorted(new_uniranges)
)
+ if self.options.prune_codepage_ranges and font[tag].version >= 1:
+ # codepage range fields were added with OS/2 format 1
+ # https://learn.microsoft.com/en-us/typography/opentype/spec/os2#version-1
+ old_codepages = font[tag].getCodePageRanges()
+ new_codepages = font[tag].recalcCodePageRanges(font, pruneOnly=True)
+ if old_codepages != new_codepages:
+ log.info(
+ "%s CodePage ranges pruned: %s",
+ tag,
+ sorted(new_codepages),
+ )
if self.options.recalc_average_width:
old_avg_width = font[tag].xAvgCharWidth
new_avg_width = font[tag].recalcAvgCharWidth(font)
@@ -3717,6 +3733,3 @@ __all__ = [
"parse_unicodes",
"main",
]
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/Lib/fontTools/subset/cff.py b/Lib/fontTools/subset/cff.py
index dd79f6db..03fc565b 100644
--- a/Lib/fontTools/subset/cff.py
+++ b/Lib/fontTools/subset/cff.py
@@ -502,7 +502,7 @@ def remove_unused_subroutines(self):
# Renumber glyph charstrings
for g in font.charset:
c, _ = cs.getItemAndSelector(g)
- subrs = getattr(c.private, "Subrs", [])
+ subrs = getattr(c.private, "Subrs", None)
c.subset_subroutines(subrs, font.GlobalSubrs)
# Renumber subroutines themselves
@@ -511,7 +511,7 @@ def remove_unused_subroutines(self):
if not hasattr(font, "FDArray") and hasattr(font.Private, "Subrs"):
local_subrs = font.Private.Subrs
else:
- local_subrs = []
+ local_subrs = None
else:
local_subrs = subrs
diff --git a/Lib/fontTools/svgLib/path/arc.py b/Lib/fontTools/svgLib/path/arc.py
index 3e0a211e..4b2aa5c9 100644
--- a/Lib/fontTools/svgLib/path/arc.py
+++ b/Lib/fontTools/svgLib/path/arc.py
@@ -4,6 +4,7 @@ The code is mostly adapted from Blink's SVGPathNormalizer::DecomposeArcToCubic
https://github.com/chromium/chromium/blob/93831f2/third_party/
blink/renderer/core/svg/svg_path_parser.cc#L169-L278
"""
+
from fontTools.misc.transform import Identity, Scale
from math import atan2, ceil, cos, fabs, isfinite, pi, radians, sin, sqrt, tan
diff --git a/Lib/fontTools/t1Lib/__init__.py b/Lib/fontTools/t1Lib/__init__.py
index a64f7809..0475881e 100644
--- a/Lib/fontTools/t1Lib/__init__.py
+++ b/Lib/fontTools/t1Lib/__init__.py
@@ -15,6 +15,7 @@ write(path, data, kind='OTHER', dohex=False)
part should be written as hexadecimal or binary, but only if kind
is 'OTHER'.
"""
+
import fontTools
from fontTools.misc import eexec
from fontTools.misc.macCreatorType import getMacCreatorAndType
@@ -49,7 +50,6 @@ class T1Error(Exception):
class T1Font(object):
-
"""Type 1 font class.
Uses a minimal interpeter that supports just about enough PS to parse
diff --git a/Lib/fontTools/ttLib/macUtils.py b/Lib/fontTools/ttLib/macUtils.py
index 468a75ad..0959a6fc 100644
--- a/Lib/fontTools/ttLib/macUtils.py
+++ b/Lib/fontTools/ttLib/macUtils.py
@@ -1,4 +1,5 @@
"""ttLib.macUtils.py -- Various Mac-specific stuff."""
+
from io import BytesIO
from fontTools.misc.macRes import ResourceReader, ResourceError
@@ -35,7 +36,6 @@ def openTTFonts(path):
class SFNTResourceReader(BytesIO):
-
"""Simple read-only file wrapper for 'sfnt' resources."""
def __init__(self, path, res_name_or_index):
diff --git a/Lib/fontTools/ttLib/removeOverlaps.py b/Lib/fontTools/ttLib/removeOverlaps.py
index 624cd47b..47953206 100644
--- a/Lib/fontTools/ttLib/removeOverlaps.py
+++ b/Lib/fontTools/ttLib/removeOverlaps.py
@@ -202,9 +202,11 @@ def removeOverlaps(
glyphNames = sorted(
glyphNames,
key=lambda name: (
- glyfTable[name].getCompositeMaxpValues(glyfTable).maxComponentDepth
- if glyfTable[name].isComposite()
- else 0,
+ (
+ glyfTable[name].getCompositeMaxpValues(glyfTable).maxComponentDepth
+ if glyfTable[name].isComposite()
+ else 0
+ ),
name,
),
)
diff --git a/Lib/fontTools/ttLib/scaleUpem.py b/Lib/fontTools/ttLib/scaleUpem.py
index 3f9b22af..2909bfcb 100644
--- a/Lib/fontTools/ttLib/scaleUpem.py
+++ b/Lib/fontTools/ttLib/scaleUpem.py
@@ -3,7 +3,6 @@
AAT and Graphite tables are not supported. CFF/CFF2 fonts
are de-subroutinized."""
-
from fontTools.ttLib.ttVisitor import TTVisitor
import fontTools.ttLib as ttLib
import fontTools.ttLib.tables.otBase as otBase
diff --git a/Lib/fontTools/ttLib/tables/C_O_L_R_.py b/Lib/fontTools/ttLib/tables/C_O_L_R_.py
index 2f03ec05..df857842 100644
--- a/Lib/fontTools/ttLib/tables/C_O_L_R_.py
+++ b/Lib/fontTools/ttLib/tables/C_O_L_R_.py
@@ -7,7 +7,6 @@ from . import DefaultTable
class table_C_O_L_R_(DefaultTable.DefaultTable):
-
"""This table is structured so that you can treat it like a dictionary keyed by glyph name.
``ttFont['COLR'][<glyphName>]`` will return the color layers for any glyph.
diff --git a/Lib/fontTools/ttLib/tables/O_S_2f_2.py b/Lib/fontTools/ttLib/tables/O_S_2f_2.py
index 7b403026..0c739bcc 100644
--- a/Lib/fontTools/ttLib/tables/O_S_2f_2.py
+++ b/Lib/fontTools/ttLib/tables/O_S_2f_2.py
@@ -113,7 +113,6 @@ OS2_format_5_addition = bigendian + OS2_format_5_addition
class table_O_S_2f_2(DefaultTable.DefaultTable):
-
"""the OS/2 table"""
dependencies = ["head"]
@@ -340,6 +339,49 @@ class table_O_S_2f_2(DefaultTable.DefaultTable):
self.setUnicodeRanges(bits)
return bits
+ def getCodePageRanges(self):
+ """Return the set of 'ulCodePageRange*' bits currently enabled."""
+ bits = set()
+ if self.version < 1:
+ return bits
+ ul1, ul2 = self.ulCodePageRange1, self.ulCodePageRange2
+ for i in range(32):
+ if ul1 & (1 << i):
+ bits.add(i)
+ if ul2 & (1 << i):
+ bits.add(i + 32)
+ return bits
+
+ def setCodePageRanges(self, bits):
+ """Set the 'ulCodePageRange*' fields to the specified 'bits'."""
+ ul1, ul2 = 0, 0
+ for bit in bits:
+ if 0 <= bit < 32:
+ ul1 |= 1 << bit
+ elif 32 <= bit < 64:
+ ul2 |= 1 << (bit - 32)
+ else:
+ raise ValueError(f"expected 0 <= int <= 63, found: {bit:r}")
+ if self.version < 1:
+ self.version = 1
+ self.ulCodePageRange1, self.ulCodePageRange2 = ul1, ul2
+
+ def recalcCodePageRanges(self, ttFont, pruneOnly=False):
+ unicodes = set()
+ for table in ttFont["cmap"].tables:
+ if table.isUnicode():
+ unicodes.update(table.cmap.keys())
+ bits = calcCodePageRanges(unicodes)
+ if pruneOnly:
+ bits &= self.getCodePageRanges()
+ # when no codepage ranges can be enabled, fall back to enabling bit 0
+ # (Latin 1) so that the font works in MS Word:
+ # https://github.com/googlei18n/fontmake/issues/468
+ if not bits:
+ bits = {0}
+ self.setCodePageRanges(bits)
+ return bits
+
def recalcAvgCharWidth(self, ttFont):
"""Recalculate xAvgCharWidth using metrics from ttFont's 'hmtx' table.
@@ -611,6 +653,92 @@ def intersectUnicodeRanges(unicodes, inverse=False):
return set(range(len(OS2_UNICODE_RANGES))) - bits if inverse else bits
+def calcCodePageRanges(unicodes):
+ """Given a set of Unicode codepoints (integers), calculate the
+ corresponding OS/2 CodePage range bits.
+ This is a direct translation of FontForge implementation:
+ https://github.com/fontforge/fontforge/blob/7b2c074/fontforge/tottf.c#L3158
+ """
+ bits = set()
+ hasAscii = set(range(0x20, 0x7E)).issubset(unicodes)
+ hasLineart = ord("┤") in unicodes
+
+ for uni in unicodes:
+ if uni == ord("Þ") and hasAscii:
+ bits.add(0) # Latin 1
+ elif uni == ord("Ľ") and hasAscii:
+ bits.add(1) # Latin 2: Eastern Europe
+ if hasLineart:
+ bits.add(58) # Latin 2
+ elif uni == ord("Б"):
+ bits.add(2) # Cyrillic
+ if ord("Ѕ") in unicodes and hasLineart:
+ bits.add(57) # IBM Cyrillic
+ if ord("╜") in unicodes and hasLineart:
+ bits.add(49) # MS-DOS Russian
+ elif uni == ord("Ά"):
+ bits.add(3) # Greek
+ if hasLineart and ord("½") in unicodes:
+ bits.add(48) # IBM Greek
+ if hasLineart and ord("√") in unicodes:
+ bits.add(60) # Greek, former 437 G
+ elif uni == ord("İ") and hasAscii:
+ bits.add(4) # Turkish
+ if hasLineart:
+ bits.add(56) # IBM turkish
+ elif uni == ord("א"):
+ bits.add(5) # Hebrew
+ if hasLineart and ord("√") in unicodes:
+ bits.add(53) # Hebrew
+ elif uni == ord("ر"):
+ bits.add(6) # Arabic
+ if ord("√") in unicodes:
+ bits.add(51) # Arabic
+ if hasLineart:
+ bits.add(61) # Arabic; ASMO 708
+ elif uni == ord("ŗ") and hasAscii:
+ bits.add(7) # Windows Baltic
+ if hasLineart:
+ bits.add(59) # MS-DOS Baltic
+ elif uni == ord("₫") and hasAscii:
+ bits.add(8) # Vietnamese
+ elif uni == ord("ๅ"):
+ bits.add(16) # Thai
+ elif uni == ord("エ"):
+ bits.add(17) # JIS/Japan
+ elif uni == ord("ㄅ"):
+ bits.add(18) # Chinese: Simplified
+ elif uni == ord("ㄱ"):
+ bits.add(19) # Korean wansung
+ elif uni == ord("央"):
+ bits.add(20) # Chinese: Traditional
+ elif uni == ord("곴"):
+ bits.add(21) # Korean Johab
+ elif uni == ord("♥") and hasAscii:
+ bits.add(30) # OEM Character Set
+ # TODO: Symbol bit has a special meaning (check the spec), we need
+ # to confirm if this is wanted by default.
+ # elif chr(0xF000) <= char <= chr(0xF0FF):
+ # codepageRanges.add(31) # Symbol Character Set
+ elif uni == ord("þ") and hasAscii and hasLineart:
+ bits.add(54) # MS-DOS Icelandic
+ elif uni == ord("╚") and hasAscii:
+ bits.add(62) # WE/Latin 1
+ bits.add(63) # US
+ elif hasAscii and hasLineart and ord("√") in unicodes:
+ if uni == ord("Å"):
+ bits.add(50) # MS-DOS Nordic
+ elif uni == ord("é"):
+ bits.add(52) # MS-DOS Canadian French
+ elif uni == ord("õ"):
+ bits.add(55) # MS-DOS Portuguese
+
+ if hasAscii and ord("‰") in unicodes and ord("∑") in unicodes:
+ bits.add(29) # Macintosh Character Set (US Roman)
+
+ return bits
+
+
if __name__ == "__main__":
import doctest, sys
diff --git a/Lib/fontTools/ttLib/tables/T_S_I__0.py b/Lib/fontTools/ttLib/tables/T_S_I__0.py
index f15fc67b..77905822 100644
--- a/Lib/fontTools/ttLib/tables/T_S_I__0.py
+++ b/Lib/fontTools/ttLib/tables/T_S_I__0.py
@@ -5,6 +5,7 @@ TSI0 is the index table containing the lengths and offsets for the glyph
programs and 'extra' programs ('fpgm', 'prep', and 'cvt') that are contained
in the TSI1 table.
"""
+
from . import DefaultTable
import struct
diff --git a/Lib/fontTools/ttLib/tables/T_S_I__1.py b/Lib/fontTools/ttLib/tables/T_S_I__1.py
index 55aca339..a9d04a09 100644
--- a/Lib/fontTools/ttLib/tables/T_S_I__1.py
+++ b/Lib/fontTools/ttLib/tables/T_S_I__1.py
@@ -4,6 +4,7 @@ tool to store its hinting source data.
TSI1 contains the text of the glyph programs in the form of low-level assembly
code, as well as the 'extra' programs 'fpgm', 'ppgm' (i.e. 'prep'), and 'cvt'.
"""
+
from . import DefaultTable
from fontTools.misc.loggingTools import LogMixin
from fontTools.misc.textTools import strjoin, tobytes, tostr
diff --git a/Lib/fontTools/ttLib/tables/T_S_I__2.py b/Lib/fontTools/ttLib/tables/T_S_I__2.py
index 4278be15..163ef452 100644
--- a/Lib/fontTools/ttLib/tables/T_S_I__2.py
+++ b/Lib/fontTools/ttLib/tables/T_S_I__2.py
@@ -5,6 +5,7 @@ TSI2 is the index table containing the lengths and offsets for the glyph
programs that are contained in the TSI3 table. It uses the same format as
the TSI0 table.
"""
+
from fontTools import ttLib
superclass = ttLib.getTableClass("TSI0")
diff --git a/Lib/fontTools/ttLib/tables/T_S_I__3.py b/Lib/fontTools/ttLib/tables/T_S_I__3.py
index 785ca231..604a7f0b 100644
--- a/Lib/fontTools/ttLib/tables/T_S_I__3.py
+++ b/Lib/fontTools/ttLib/tables/T_S_I__3.py
@@ -3,6 +3,7 @@ tool to store its hinting source data.
TSI3 contains the text of the glyph programs in the form of 'VTTTalk' code.
"""
+
from fontTools import ttLib
superclass = ttLib.getTableClass("TSI1")
diff --git a/Lib/fontTools/ttLib/tables/T_S_I__5.py b/Lib/fontTools/ttLib/tables/T_S_I__5.py
index 5edc86a9..d8679869 100644
--- a/Lib/fontTools/ttLib/tables/T_S_I__5.py
+++ b/Lib/fontTools/ttLib/tables/T_S_I__5.py
@@ -3,6 +3,7 @@ tool to store its hinting source data.
TSI5 contains the VTT character groups.
"""
+
from fontTools.misc.textTools import safeEval
from . import DefaultTable
import sys
diff --git a/Lib/fontTools/ttLib/tables/TupleVariation.py b/Lib/fontTools/ttLib/tables/TupleVariation.py
index 30d00990..027ac153 100644
--- a/Lib/fontTools/ttLib/tables/TupleVariation.py
+++ b/Lib/fontTools/ttLib/tables/TupleVariation.py
@@ -517,22 +517,22 @@ class TupleVariation(object):
return # no change
coordWidth = self.getCoordWidth()
self.coordinates = [
- None
- if d is None
- else d * scalar
- if coordWidth == 1
- else (d[0] * scalar, d[1] * scalar)
+ (
+ None
+ if d is None
+ else d * scalar if coordWidth == 1 else (d[0] * scalar, d[1] * scalar)
+ )
for d in self.coordinates
]
def roundDeltas(self):
coordWidth = self.getCoordWidth()
self.coordinates = [
- None
- if d is None
- else otRound(d)
- if coordWidth == 1
- else (otRound(d[0]), otRound(d[1]))
+ (
+ None
+ if d is None
+ else otRound(d) if coordWidth == 1 else (otRound(d[0]), otRound(d[1]))
+ )
for d in self.coordinates
]
diff --git a/Lib/fontTools/ttLib/tables/V_O_R_G_.py b/Lib/fontTools/ttLib/tables/V_O_R_G_.py
index 4508c137..b08737b2 100644
--- a/Lib/fontTools/ttLib/tables/V_O_R_G_.py
+++ b/Lib/fontTools/ttLib/tables/V_O_R_G_.py
@@ -4,7 +4,6 @@ import struct
class table_V_O_R_G_(DefaultTable.DefaultTable):
-
"""This table is structured so that you can treat it like a dictionary keyed by glyph name.
``ttFont['VORG'][<glyphName>]`` will return the vertical origin for any glyph.
diff --git a/Lib/fontTools/ttLib/tables/_g_l_y_f.py b/Lib/fontTools/ttLib/tables/_g_l_y_f.py
index bff0d92c..683912be 100644
--- a/Lib/fontTools/ttLib/tables/_g_l_y_f.py
+++ b/Lib/fontTools/ttLib/tables/_g_l_y_f.py
@@ -1211,6 +1211,9 @@ class Glyph(object):
g.recalcBounds(glyfTable, boundsDone=boundsDone)
if boundsDone is not None:
boundsDone.add(glyphName)
+ # empty components shouldn't update the bounds of the parent glyph
+ if g.numberOfContours == 0:
+ continue
x, y = compo.x, compo.y
bounds = updateBounds(bounds, (g.xMin + x, g.yMin + y))
diff --git a/Lib/fontTools/ttLib/tables/_k_e_r_n.py b/Lib/fontTools/ttLib/tables/_k_e_r_n.py
index 8f55a311..270b3b7e 100644
--- a/Lib/fontTools/ttLib/tables/_k_e_r_n.py
+++ b/Lib/fontTools/ttLib/tables/_k_e_r_n.py
@@ -147,9 +147,9 @@ class KernTable_format_0(object):
except IndexError:
# Slower, but will not throw an IndexError on an invalid
# glyph id.
- kernTable[
- (ttFont.getGlyphName(left), ttFont.getGlyphName(right))
- ] = value
+ kernTable[(ttFont.getGlyphName(left), ttFont.getGlyphName(right))] = (
+ value
+ )
if len(data) > 6 * nPairs + 4: # Ignore up to 4 bytes excess
log.warning(
"excess data in 'kern' subtable: %d bytes", len(data) - 6 * nPairs
diff --git a/Lib/fontTools/ttLib/tables/otBase.py b/Lib/fontTools/ttLib/tables/otBase.py
index d565603b..53abd13b 100644
--- a/Lib/fontTools/ttLib/tables/otBase.py
+++ b/Lib/fontTools/ttLib/tables/otBase.py
@@ -79,7 +79,6 @@ class RepackerState(IntEnum):
class BaseTTXConverter(DefaultTable):
-
"""Generic base class for TTX table converters. It functions as an
adapter between the TTX (ttLib actually) table model and the model
we use for OpenType tables, which is necessarily subtly different.
@@ -260,7 +259,6 @@ assert array.array("i").itemsize == 4, "Oops, file a bug against fonttools."
class OTTableReader(object):
-
"""Helper class to retrieve data from an OpenType table."""
__slots__ = ("data", "offset", "pos", "localState", "tableTag")
@@ -392,7 +390,6 @@ class OffsetToWriter(object):
class OTTableWriter(object):
-
"""Helper class to gather and assemble data for OpenType tables."""
def __init__(self, localState=None, tableTag=None):
@@ -882,7 +879,6 @@ def packUInt24(value):
class BaseTable(object):
-
"""Generic base class for all OpenType (sub)tables."""
def __getattr__(self, attr):
@@ -1210,7 +1206,6 @@ class BaseTable(object):
class FormatSwitchingBaseTable(BaseTable):
-
"""Minor specialization of BaseTable, for tables that have multiple
formats, eg. CoverageFormat1 vs. CoverageFormat2."""
@@ -1335,7 +1330,6 @@ valueRecordFormatDict = _buildDict()
class ValueRecordFactory(object):
-
"""Given a format code, this object convert ValueRecords."""
def __init__(self, valueFormat):
diff --git a/Lib/fontTools/ttLib/tables/otConverters.py b/Lib/fontTools/ttLib/tables/otConverters.py
index 390f1660..afe4e538 100644
--- a/Lib/fontTools/ttLib/tables/otConverters.py
+++ b/Lib/fontTools/ttLib/tables/otConverters.py
@@ -146,7 +146,6 @@ class _LazyList(UserList):
class BaseConverter(object):
-
"""Base class for converter objects. Apart from the constructor, this
is an abstract class."""
diff --git a/Lib/fontTools/ttLib/tables/otTables.py b/Lib/fontTools/ttLib/tables/otTables.py
index 262f8d41..3505f423 100644
--- a/Lib/fontTools/ttLib/tables/otTables.py
+++ b/Lib/fontTools/ttLib/tables/otTables.py
@@ -1123,6 +1123,35 @@ class LigatureSubst(FormatSwitchingBaseTable):
self.ligatures = ligatures
del self.Format # Don't need this anymore
+ @staticmethod
+ def _getLigatureSortKey(components):
+ # Computes a key for ordering ligatures in a GSUB Type-4 lookup.
+
+ # When building the OpenType lookup, we need to make sure that
+ # the longest sequence of components is listed first, so we
+ # use the negative length as the key for sorting.
+ # Note, we no longer need to worry about deterministic order because the
+ # ligature mapping `dict` remembers the insertion order, and this in
+ # turn depends on the order in which the ligatures are written in the FEA.
+ # Since python sort algorithm is stable, the ligatures of equal length
+ # will keep the relative order in which they appear in the feature file.
+ # For example, given the following ligatures (all starting with 'f' and
+ # thus belonging to the same LigatureSet):
+ #
+ # feature liga {
+ # sub f i by f_i;
+ # sub f f f by f_f_f;
+ # sub f f by f_f;
+ # sub f f i by f_f_i;
+ # } liga;
+ #
+ # this should sort to: f_f_f, f_f_i, f_i, f_f
+ # This is also what fea-rs does, see:
+ # https://github.com/adobe-type-tools/afdko/issues/1727
+ # https://github.com/fonttools/fonttools/issues/3428
+ # https://github.com/googlefonts/fontc/pull/680
+ return -len(components)
+
def preWrite(self, font):
self.Format = 1
ligatures = getattr(self, "ligatures", None)
@@ -1135,13 +1164,11 @@ class LigatureSubst(FormatSwitchingBaseTable):
# ligatures is map from components-sequence to lig-glyph
newLigatures = dict()
- for comps, lig in sorted(
- ligatures.items(), key=lambda item: (-len(item[0]), item[0])
- ):
+ for comps in sorted(ligatures.keys(), key=self._getLigatureSortKey):
ligature = Ligature()
ligature.Component = comps[1:]
ligature.CompCount = len(comps)
- ligature.LigGlyph = lig
+ ligature.LigGlyph = ligatures[comps]
newLigatures.setdefault(comps[0], []).append(ligature)
ligatures = newLigatures
diff --git a/Lib/fontTools/ttLib/tables/otTraverse.py b/Lib/fontTools/ttLib/tables/otTraverse.py
index bf22dcfd..ac942187 100644
--- a/Lib/fontTools/ttLib/tables/otTraverse.py
+++ b/Lib/fontTools/ttLib/tables/otTraverse.py
@@ -1,4 +1,5 @@
"""Methods for traversing trees of otData-driven OpenType tables."""
+
from collections import deque
from typing import Callable, Deque, Iterable, List, Optional, Tuple
from .otBase import BaseTable
diff --git a/Lib/fontTools/ttLib/tables/sbixGlyph.py b/Lib/fontTools/ttLib/tables/sbixGlyph.py
index fd687a18..b744a2a3 100644
--- a/Lib/fontTools/ttLib/tables/sbixGlyph.py
+++ b/Lib/fontTools/ttLib/tables/sbixGlyph.py
@@ -54,6 +54,10 @@ class Glyph(object):
# pad with spaces
self.graphicType += " "[: (4 - len(self.graphicType))]
+ def is_reference_type(self):
+ """Returns True if this glyph is a reference to another glyph's image data."""
+ return self.graphicType == "dupe" or self.graphicType == "flip"
+
def decompile(self, ttFont):
self.glyphName = ttFont.getGlyphName(self.gid)
if self.rawdata is None:
@@ -71,7 +75,7 @@ class Glyph(object):
sbixGlyphHeaderFormat, self.rawdata[:sbixGlyphHeaderFormatSize], self
)
- if self.graphicType == "dupe":
+ if self.is_reference_type():
# this glyph is a reference to another glyph's image data
(gid,) = struct.unpack(">H", self.rawdata[sbixGlyphHeaderFormatSize:])
self.referenceGlyphName = ttFont.getGlyphName(gid)
@@ -94,7 +98,7 @@ class Glyph(object):
rawdata = b""
else:
rawdata = sstruct.pack(sbixGlyphHeaderFormat, self)
- if self.graphicType == "dupe":
+ if self.is_reference_type():
rawdata += struct.pack(">H", ttFont.getGlyphID(self.referenceGlyphName))
else:
assert self.imageData is not None
@@ -117,8 +121,8 @@ class Glyph(object):
originOffsetY=self.originOffsetY,
)
xmlWriter.newline()
- if self.graphicType == "dupe":
- # graphicType == "dupe" is a reference to another glyph id.
+ if self.is_reference_type():
+ # this glyph is a reference to another glyph id.
xmlWriter.simpletag("ref", glyphname=self.referenceGlyphName)
else:
xmlWriter.begintag("hexdata")
@@ -131,7 +135,7 @@ class Glyph(object):
def fromXML(self, name, attrs, content, ttFont):
if name == "ref":
- # glyph is a "dupe", i.e. a reference to another glyph's image data.
+ # this glyph i.e. a reference to another glyph's image data.
# in this case imageData contains the glyph id of the reference glyph
# get glyph id from glyphname
glyphname = safeEval("'''" + attrs["glyphname"] + "'''")
diff --git a/Lib/fontTools/ttLib/tables/ttProgram.py b/Lib/fontTools/ttLib/tables/ttProgram.py
index 84aa63f3..32a4ec8b 100644
--- a/Lib/fontTools/ttLib/tables/ttProgram.py
+++ b/Lib/fontTools/ttLib/tables/ttProgram.py
@@ -1,4 +1,5 @@
"""ttLib.tables.ttProgram.py -- Assembler/disassembler for TrueType bytecode programs."""
+
from __future__ import annotations
from fontTools.misc.textTools import num2binary, binary2num, readHex, strjoin
diff --git a/Lib/fontTools/ttLib/ttCollection.py b/Lib/fontTools/ttLib/ttCollection.py
index 70ed4b7a..f01bc42b 100644
--- a/Lib/fontTools/ttLib/ttCollection.py
+++ b/Lib/fontTools/ttLib/ttCollection.py
@@ -8,7 +8,6 @@ log = logging.getLogger(__name__)
class TTCollection(object):
-
"""Object representing a TrueType Collection / OpenType Collection.
The main API is self.fonts being a list of TTFont instances.
diff --git a/Lib/fontTools/ttLib/ttFont.py b/Lib/fontTools/ttLib/ttFont.py
index 6a9ca098..ad62a187 100644
--- a/Lib/fontTools/ttLib/ttFont.py
+++ b/Lib/fontTools/ttLib/ttFont.py
@@ -15,7 +15,6 @@ log = logging.getLogger(__name__)
class TTFont(object):
-
"""Represents a TrueType font.
The object manages file input and output, and offers a convenient way of
@@ -735,7 +734,9 @@ class TTFont(object):
else:
raise KeyError(tag)
- def getGlyphSet(self, preferCFF=True, location=None, normalized=False):
+ def getGlyphSet(
+ self, preferCFF=True, location=None, normalized=False, recalcBounds=True
+ ):
"""Return a generic GlyphSet, which is a dict-like object
mapping glyph names to glyph objects. The returned glyph objects
have a ``.draw()`` method that supports the Pen protocol, and will
@@ -766,7 +767,7 @@ class TTFont(object):
if ("CFF " in self or "CFF2" in self) and (preferCFF or "glyf" not in self):
return _TTGlyphSetCFF(self, location)
elif "glyf" in self:
- return _TTGlyphSetGlyf(self, location)
+ return _TTGlyphSetGlyf(self, location, recalcBounds=recalcBounds)
else:
raise TTLibError("Font contains no outlines")
@@ -841,7 +842,6 @@ class TTFont(object):
class GlyphOrder(object):
-
"""A pseudo table. The glyph order isn't in the font as a separate
table, but it's nice to present it as such in the TTX format.
"""
diff --git a/Lib/fontTools/ttLib/ttGlyphSet.py b/Lib/fontTools/ttLib/ttGlyphSet.py
index d4384c89..b4beb3e7 100644
--- a/Lib/fontTools/ttLib/ttGlyphSet.py
+++ b/Lib/fontTools/ttLib/ttGlyphSet.py
@@ -9,15 +9,20 @@ from fontTools.misc.fixedTools import otRound
from fontTools.misc.loggingTools import deprecateFunction
from fontTools.misc.transform import Transform
from fontTools.pens.transformPen import TransformPen, TransformPointPen
+from fontTools.pens.recordingPen import (
+ DecomposingRecordingPen,
+ lerpRecordings,
+ replayRecording,
+)
class _TTGlyphSet(Mapping):
-
"""Generic dict-like GlyphSet class that pulls metrics from hmtx and
glyph shape from TrueType or CFF.
"""
- def __init__(self, font, location, glyphsMapping):
+ def __init__(self, font, location, glyphsMapping, *, recalcBounds=True):
+ self.recalcBounds = recalcBounds
self.font = font
self.defaultLocationNormalized = (
{axis.axisTag: 0 for axis in self.font["fvar"].axes}
@@ -89,13 +94,13 @@ class _TTGlyphSet(Mapping):
class _TTGlyphSetGlyf(_TTGlyphSet):
- def __init__(self, font, location):
+ def __init__(self, font, location, recalcBounds=True):
self.glyfTable = font["glyf"]
- super().__init__(font, location, self.glyfTable)
+ super().__init__(font, location, self.glyfTable, recalcBounds=recalcBounds)
self.gvarTable = font.get("gvar")
def __getitem__(self, glyphName):
- return _TTGlyphGlyf(self, glyphName)
+ return _TTGlyphGlyf(self, glyphName, recalcBounds=self.recalcBounds)
class _TTGlyphSetCFF(_TTGlyphSet):
@@ -119,7 +124,6 @@ class _TTGlyphSetCFF(_TTGlyphSet):
class _TTGlyph(ABC):
-
"""Glyph object that supports the Pen protocol, meaning that it has
.draw() and .drawPoints() methods that take a pen object as their only
argument. Additionally there are 'width' and 'lsb' attributes, read from
@@ -129,9 +133,10 @@ class _TTGlyph(ABC):
attributes.
"""
- def __init__(self, glyphSet, glyphName):
+ def __init__(self, glyphSet, glyphName, *, recalcBounds=True):
self.glyphSet = glyphSet
self.name = glyphName
+ self.recalcBounds = recalcBounds
self.width, self.lsb = glyphSet.hMetrics[glyphName]
if glyphSet.vMetrics is not None:
self.height, self.tsb = glyphSet.vMetrics[glyphName]
@@ -258,7 +263,9 @@ class _TTGlyphGlyf(_TTGlyph):
coordinates += GlyphCoordinates(delta) * scalar
glyph = copy(glyfTable[self.name]) # Shallow copy
- width, lsb, height, tsb = _setCoordinates(glyph, coordinates, glyfTable)
+ width, lsb, height, tsb = _setCoordinates(
+ glyph, coordinates, glyfTable, recalcBounds=self.recalcBounds
+ )
self.lsb = lsb
self.tsb = tsb
if glyphSet.hvarTable is None:
@@ -276,7 +283,7 @@ class _TTGlyphCFF(_TTGlyph):
self.glyphSet.charStrings[self.name].draw(pen, self.glyphSet.blender)
-def _setCoordinates(glyph, coord, glyfTable):
+def _setCoordinates(glyph, coord, glyfTable, *, recalcBounds=True):
# Handle phantom points for (left, right, top, bottom) positions.
assert len(coord) >= 4
leftSideX = coord[-4][0]
@@ -304,7 +311,8 @@ def _setCoordinates(glyph, coord, glyfTable):
assert len(coord) == len(glyph.coordinates)
glyph.coordinates = coord
- glyph.recalcBounds(glyfTable)
+ if recalcBounds:
+ glyph.recalcBounds(glyfTable)
horizontalAdvanceWidth = otRound(rightSideX - leftSideX)
verticalAdvanceWidth = otRound(topSideY - bottomSideY)
@@ -316,3 +324,52 @@ def _setCoordinates(glyph, coord, glyfTable):
verticalAdvanceWidth,
topSideBearing,
)
+
+
+class LerpGlyphSet(Mapping):
+ """A glyphset that interpolates between two other glyphsets.
+
+ Factor is typically between 0 and 1. 0 means the first glyphset,
+ 1 means the second glyphset, and 0.5 means the average of the
+ two glyphsets. Other values are possible, and can be useful to
+ extrapolate. Defaults to 0.5.
+ """
+
+ def __init__(self, glyphset1, glyphset2, factor=0.5):
+ self.glyphset1 = glyphset1
+ self.glyphset2 = glyphset2
+ self.factor = factor
+
+ def __getitem__(self, glyphname):
+ if glyphname in self.glyphset1 and glyphname in self.glyphset2:
+ return LerpGlyph(glyphname, self)
+ raise KeyError(glyphname)
+
+ def __contains__(self, glyphname):
+ return glyphname in self.glyphset1 and glyphname in self.glyphset2
+
+ def __iter__(self):
+ set1 = set(self.glyphset1)
+ set2 = set(self.glyphset2)
+ return iter(set1.intersection(set2))
+
+ def __len__(self):
+ set1 = set(self.glyphset1)
+ set2 = set(self.glyphset2)
+ return len(set1.intersection(set2))
+
+
+class LerpGlyph:
+ def __init__(self, glyphname, glyphset):
+ self.glyphset = glyphset
+ self.glyphname = glyphname
+
+ def draw(self, pen):
+ recording1 = DecomposingRecordingPen(self.glyphset.glyphset1)
+ self.glyphset.glyphset1[self.glyphname].draw(recording1)
+ recording2 = DecomposingRecordingPen(self.glyphset.glyphset2)
+ self.glyphset.glyphset2[self.glyphname].draw(recording2)
+
+ factor = self.glyphset.factor
+
+ replayRecording(lerpRecordings(recording1.value, recording2.value, factor), pen)
diff --git a/Lib/fontTools/ttx.py b/Lib/fontTools/ttx.py
index d8c2a3a7..e7a06874 100644
--- a/Lib/fontTools/ttx.py
+++ b/Lib/fontTools/ttx.py
@@ -103,7 +103,6 @@ Compile options
extension is available at https://pypi.python.org/pypi/zopfli
"""
-
from fontTools.ttLib import TTFont, TTLibError
from fontTools.misc.macCreatorType import getMacCreatorAndType
from fontTools.unicode import setUnicodeData
diff --git a/Lib/fontTools/ufoLib/__init__.py b/Lib/fontTools/ufoLib/__init__.py
index 1a456a20..c2d2b0b2 100755
--- a/Lib/fontTools/ufoLib/__init__.py
+++ b/Lib/fontTools/ufoLib/__init__.py
@@ -197,7 +197,6 @@ class _UFOBaseIO:
class UFOReader(_UFOBaseIO):
-
"""
Read the various components of the .ufo.
@@ -881,7 +880,6 @@ class UFOReader(_UFOBaseIO):
class UFOWriter(UFOReader):
-
"""
Write the various components of the .ufo.
diff --git a/Lib/fontTools/ufoLib/converters.py b/Lib/fontTools/ufoLib/converters.py
index daccf782..88a26c61 100644
--- a/Lib/fontTools/ufoLib/converters.py
+++ b/Lib/fontTools/ufoLib/converters.py
@@ -2,7 +2,6 @@
Conversion functions.
"""
-
# adapted from the UFO spec
diff --git a/Lib/fontTools/ufoLib/etree.py b/Lib/fontTools/ufoLib/etree.py
index 5054f816..77e3c16e 100644
--- a/Lib/fontTools/ufoLib/etree.py
+++ b/Lib/fontTools/ufoLib/etree.py
@@ -2,4 +2,5 @@
for the old ufoLib.etree module, which was moved to fontTools.misc.etree.
Please use the latter instead.
"""
+
from fontTools.misc.etree import *
diff --git a/Lib/fontTools/ufoLib/glifLib.py b/Lib/fontTools/ufoLib/glifLib.py
index 6dee9db3..62e87db0 100755
--- a/Lib/fontTools/ufoLib/glifLib.py
+++ b/Lib/fontTools/ufoLib/glifLib.py
@@ -91,7 +91,6 @@ GLIFFormatVersion.__str__ = _VersionTupleEnumMixin.__str__
class Glyph:
-
"""
Minimal glyph object. It has no glyph attributes until either
the draw() or the drawPoints() method has been called.
@@ -123,7 +122,6 @@ class Glyph:
class GlyphSet(_UFOBaseIO):
-
"""
GlyphSet manages a set of .glif files inside one directory.
@@ -1228,9 +1226,9 @@ def _readGlyphFromTreeFormat2(
unicodes = []
guidelines = []
anchors = []
- haveSeenAdvance = (
- haveSeenImage
- ) = haveSeenOutline = haveSeenLib = haveSeenNote = False
+ haveSeenAdvance = haveSeenImage = haveSeenOutline = haveSeenLib = haveSeenNote = (
+ False
+ )
identifiers = set()
for element in tree:
if element.tag == "outline":
@@ -1883,7 +1881,6 @@ _transformationInfo = [
class GLIFPointPen(AbstractPointPen):
-
"""
Helper class using the PointPen protocol to write the <outline>
part of .glif files.
diff --git a/Lib/fontTools/ufoLib/plistlib.py b/Lib/fontTools/ufoLib/plistlib.py
index 1f52f20a..38bb266b 100644
--- a/Lib/fontTools/ufoLib/plistlib.py
+++ b/Lib/fontTools/ufoLib/plistlib.py
@@ -2,6 +2,7 @@
for the old ufoLib.plistlib module, which was moved to fontTools.misc.plistlib.
Please use the latter instead.
"""
+
from fontTools.misc.plistlib import dump, dumps, load, loads
from fontTools.misc.textTools import tobytes
diff --git a/Lib/fontTools/ufoLib/pointPen.py b/Lib/fontTools/ufoLib/pointPen.py
index 3433fdbc..baef9a58 100644
--- a/Lib/fontTools/ufoLib/pointPen.py
+++ b/Lib/fontTools/ufoLib/pointPen.py
@@ -2,4 +2,5 @@
for the old ufoLib.pointPen module, which was moved to fontTools.pens.pointPen.
Please use the latter instead.
"""
+
from fontTools.pens.pointPen import *
diff --git a/Lib/fontTools/ufoLib/utils.py b/Lib/fontTools/ufoLib/utils.py
index 85878b47..45ec1c56 100644
--- a/Lib/fontTools/ufoLib/utils.py
+++ b/Lib/fontTools/ufoLib/utils.py
@@ -1,6 +1,7 @@
"""The module contains miscellaneous helpers.
It's not considered part of the public ufoLib API.
"""
+
import warnings
import functools
diff --git a/Lib/fontTools/unicodedata/__init__.py b/Lib/fontTools/unicodedata/__init__.py
index 808c9c72..06eb4619 100644
--- a/Lib/fontTools/unicodedata/__init__.py
+++ b/Lib/fontTools/unicodedata/__init__.py
@@ -201,15 +201,13 @@ T = TypeVar("T")
@overload
-def script_horizontal_direction(script_code: str, default: T) -> HorizDirection | T:
- ...
+def script_horizontal_direction(script_code: str, default: T) -> HorizDirection | T: ...
@overload
def script_horizontal_direction(
script_code: str, default: type[KeyError] = KeyError
-) -> HorizDirection:
- ...
+) -> HorizDirection: ...
def script_horizontal_direction(
diff --git a/Lib/fontTools/varLib/__init__.py b/Lib/fontTools/varLib/__init__.py
index b130d5b2..1e0f2ec2 100644
--- a/Lib/fontTools/varLib/__init__.py
+++ b/Lib/fontTools/varLib/__init__.py
@@ -18,6 +18,7 @@ Then you can make a variable-font this way:
API *will* change in near future.
"""
+
from typing import List
from fontTools.misc.vector import Vector
from fontTools.misc.roundTools import noRound, otRound
@@ -52,7 +53,8 @@ from .errors import VarLibError, VarLibValidationError
log = logging.getLogger("fontTools.varLib")
# This is a lib key for the designspace document. The value should be
-# an OpenType feature tag, to be used as the FeatureVariations feature.
+# a comma-separated list of OpenType feature tag(s), to be used as the
+# FeatureVariations feature.
# If present, the DesignSpace <rules processing="..."> flag is ignored.
FEAVAR_FEATURETAG_LIB_KEY = "com.github.fonttools.varLib.featureVarsFeatureTag"
@@ -215,8 +217,6 @@ def _add_avar(font, axes, mappings, axisTags):
if mappings:
interesting = True
- hiddenAxes = [axis for axis in axes.values() if axis.hidden]
-
inputLocations = [
{
axes[name].tag: models.normalizeValue(v, vals_triples[axes[name].tag])
@@ -570,9 +570,11 @@ def _get_advance_metrics(
sparse_advance = 0xFFFF
for glyph in glyphOrder:
vhAdvances = [
- metrics[glyph][0]
- if glyph in metrics and metrics[glyph][0] != sparse_advance
- else None
+ (
+ metrics[glyph][0]
+ if glyph in metrics and metrics[glyph][0] != sparse_advance
+ else None
+ )
for metrics in advMetricses
]
vhAdvanceDeltasAndSupports[glyph] = masterModel.getDeltasAndSupports(
@@ -751,10 +753,14 @@ def _add_BASE(font, masterModel, master_ttfs, axisTags):
def _merge_OTL(font, model, master_fonts, axisTags):
+ otl_tags = ["GSUB", "GDEF", "GPOS"]
+ if not any(tag in font for tag in otl_tags):
+ return
+
log.info("Merging OpenType Layout tables")
merger = VariationMerger(model, axisTags, font)
- merger.mergeTables(font, master_fonts, ["GSUB", "GDEF", "GPOS"])
+ merger.mergeTables(font, master_fonts, otl_tags)
store = merger.store_builder.finish()
if not store:
return
@@ -781,7 +787,9 @@ def _merge_OTL(font, model, master_fonts, axisTags):
font["GPOS"].table.remap_device_varidxes(varidx_map)
-def _add_GSUB_feature_variations(font, axes, internal_axis_supports, rules, featureTag):
+def _add_GSUB_feature_variations(
+ font, axes, internal_axis_supports, rules, featureTags
+):
def normalize(name, value):
return models.normalizeLocation({name: value}, internal_axis_supports)[name]
@@ -812,7 +820,7 @@ def _add_GSUB_feature_variations(font, axes, internal_axis_supports, rules, feat
conditional_subs.append((region, subs))
- addFeatureVariations(font, conditional_subs, featureTag)
+ addFeatureVariations(font, conditional_subs, featureTags)
_DesignSpaceData = namedtuple(
@@ -860,7 +868,7 @@ def _add_COLR(font, model, master_fonts, axisTags, colr_layer_reuse=True):
colr.VarIndexMap = builder.buildDeltaSetIndexMap(varIdxes)
-def load_designspace(designspace):
+def load_designspace(designspace, log_enabled=True):
# TODO: remove this and always assume 'designspace' is a DesignSpaceDocument,
# never a file path, as that's already handled by caller
if hasattr(designspace, "sources"): # Assume a DesignspaceDocument
@@ -908,10 +916,11 @@ def load_designspace(designspace):
axis.labelNames["en"] = tostr(axis_name)
axes[axis_name] = axis
- log.info("Axes:\n%s", pformat([axis.asdict() for axis in axes.values()]))
+ if log_enabled:
+ log.info("Axes:\n%s", pformat([axis.asdict() for axis in axes.values()]))
axisMappings = ds.axisMappings
- if axisMappings:
+ if axisMappings and log_enabled:
log.info("Mappings:\n%s", pformat(axisMappings))
# Check all master and instance locations are valid and fill in defaults
@@ -941,20 +950,23 @@ def load_designspace(designspace):
# Normalize master locations
internal_master_locs = [o.getFullDesignLocation(ds) for o in masters]
- log.info("Internal master locations:\n%s", pformat(internal_master_locs))
+ if log_enabled:
+ log.info("Internal master locations:\n%s", pformat(internal_master_locs))
# TODO This mapping should ideally be moved closer to logic in _add_fvar/avar
internal_axis_supports = {}
for axis in axes.values():
triple = (axis.minimum, axis.default, axis.maximum)
internal_axis_supports[axis.name] = [axis.map_forward(v) for v in triple]
- log.info("Internal axis supports:\n%s", pformat(internal_axis_supports))
+ if log_enabled:
+ log.info("Internal axis supports:\n%s", pformat(internal_axis_supports))
normalized_master_locs = [
models.normalizeLocation(m, internal_axis_supports)
for m in internal_master_locs
]
- log.info("Normalized master locations:\n%s", pformat(normalized_master_locs))
+ if log_enabled:
+ log.info("Normalized master locations:\n%s", pformat(normalized_master_locs))
# Find base master
base_idx = None
@@ -969,7 +981,8 @@ def load_designspace(designspace):
raise VarLibValidationError(
"Base master not found; no master at default location?"
)
- log.info("Index of base master: %s", base_idx)
+ if log_enabled:
+ log.info("Index of base master: %s", base_idx)
return _DesignSpaceData(
axes,
@@ -1204,11 +1217,9 @@ def build(
if "cvar" not in exclude and "glyf" in vf:
_merge_TTHinting(vf, model, master_fonts)
if "GSUB" not in exclude and ds.rules:
- featureTag = ds.lib.get(
- FEAVAR_FEATURETAG_LIB_KEY, "rclt" if ds.rulesProcessingLast else "rvrn"
- )
+ featureTags = _feature_variations_tags(ds)
_add_GSUB_feature_variations(
- vf, ds.axes, ds.internal_axis_supports, ds.rules, featureTag
+ vf, ds.axes, ds.internal_axis_supports, ds.rules, featureTags
)
if "CFF2" not in exclude and ("CFF " in vf or "CFF2" in vf):
_add_CFF2(vf, model, master_fonts)
@@ -1299,6 +1310,38 @@ class MasterFinder(object):
return os.path.normpath(path)
+def _feature_variations_tags(ds):
+ raw_tags = ds.lib.get(
+ FEAVAR_FEATURETAG_LIB_KEY,
+ "rclt" if ds.rulesProcessingLast else "rvrn",
+ )
+ return sorted({t.strip() for t in raw_tags.split(",")})
+
+
+def addGSUBFeatureVariations(vf, designspace, featureTags=(), *, log_enabled=False):
+ """Add GSUB FeatureVariations table to variable font, based on DesignSpace rules.
+
+ Args:
+ vf: A TTFont object representing the variable font.
+ designspace: A DesignSpaceDocument object.
+ featureTags: Optional feature tag(s) to use for the FeatureVariations records.
+ If unset, the key 'com.github.fonttools.varLib.featureVarsFeatureTag' is
+ looked up in the DS <lib> and used; otherwise the default is 'rclt' if
+ the <rules processing="last"> attribute is set, else 'rvrn'.
+ See <https://fonttools.readthedocs.io/en/latest/designspaceLib/xml.html#rules-element>
+ log_enabled: If True, log info about DS axes and sources. Default is False, as
+ the same info may have already been logged as part of varLib.build.
+ """
+ ds = load_designspace(designspace, log_enabled=log_enabled)
+ if not ds.rules:
+ return
+ if not featureTags:
+ featureTags = _feature_variations_tags(ds)
+ _add_GSUB_feature_variations(
+ vf, ds.axes, ds.internal_axis_supports, ds.rules, featureTags
+ )
+
+
def main(args=None):
"""Build variable fonts from a designspace file and masters"""
from argparse import ArgumentParser
diff --git a/Lib/fontTools/varLib/featureVars.py b/Lib/fontTools/varLib/featureVars.py
index f0403d76..2e957f55 100644
--- a/Lib/fontTools/varLib/featureVars.py
+++ b/Lib/fontTools/varLib/featureVars.py
@@ -3,6 +3,7 @@ https://docs.microsoft.com/en-us/typography/opentype/spec/chapter2#featurevariat
NOTE: The API is experimental and subject to change.
"""
+
from fontTools.misc.dictTools import hashdict
from fontTools.misc.intTools import bit_count
from fontTools.ttLib import newTable
@@ -43,9 +44,18 @@ def addFeatureVariations(font, conditionalSubstitutions, featureTag="rvrn"):
# ... ]
# >>> addFeatureVariations(f, condSubst)
# >>> f.save(dstPath)
+
+ The `featureTag` parameter takes either a str or a iterable of str (the single str
+ is kept for backwards compatibility), and defines which feature(s) will be
+ associated with the feature variations.
+ Note, if this is "rvrn", then the substitution lookup will be inserted at the
+ beginning of the lookup list so that it is processed before others, otherwise
+ for any other feature tags it will be appended last.
"""
- processLast = featureTag != "rvrn"
+ # process first when "rvrn" is the only listed tag
+ featureTags = [featureTag] if isinstance(featureTag, str) else sorted(featureTag)
+ processLast = "rvrn" not in featureTags or len(featureTags) > 1
_checkSubstitutionGlyphsExist(
glyphNames=set(font.getGlyphOrder()),
@@ -60,6 +70,14 @@ def addFeatureVariations(font, conditionalSubstitutions, featureTag="rvrn"):
)
if "GSUB" not in font:
font["GSUB"] = buildGSUB()
+ else:
+ existingTags = _existingVariableFeatures(font["GSUB"].table).intersection(
+ featureTags
+ )
+ if existingTags:
+ raise VarLibError(
+ f"FeatureVariations already exist for feature tag(s): {existingTags}"
+ )
# setup lookups
lookupMap = buildSubstitutionLookups(
@@ -75,7 +93,17 @@ def addFeatureVariations(font, conditionalSubstitutions, featureTag="rvrn"):
(conditionSet, [lookupMap[s] for s in substitutions])
)
- addFeatureVariationsRaw(font, font["GSUB"].table, conditionsAndLookups, featureTag)
+ addFeatureVariationsRaw(font, font["GSUB"].table, conditionsAndLookups, featureTags)
+
+
+def _existingVariableFeatures(table):
+ existingFeatureVarsTags = set()
+ if hasattr(table, "FeatureVariations") and table.FeatureVariations is not None:
+ features = table.FeatureList.FeatureRecord
+ for fvr in table.FeatureVariations.FeatureVariationRecord:
+ for ftsr in fvr.FeatureTableSubstitution.SubstitutionRecord:
+ existingFeatureVarsTags.add(features[ftsr.FeatureIndex].FeatureTag)
+ return existingFeatureVarsTags
def _checkSubstitutionGlyphsExist(glyphNames, substitutions):
@@ -324,51 +352,73 @@ def addFeatureVariationsRaw(font, table, conditionalSubstitutions, featureTag="r
"""Low level implementation of addFeatureVariations that directly
models the possibilities of the FeatureVariations table."""
- processLast = featureTag != "rvrn"
+ featureTags = [featureTag] if isinstance(featureTag, str) else sorted(featureTag)
+ processLast = "rvrn" not in featureTags or len(featureTags) > 1
#
- # if there is no <featureTag> feature:
+ # if a <featureTag> feature is not present:
# make empty <featureTag> feature
# sort features, get <featureTag> feature index
# add <featureTag> feature to all scripts
+ # if a <featureTag> feature is present:
+ # reuse <featureTag> feature index
# make lookups
# add feature variations
#
if table.Version < 0x00010001:
table.Version = 0x00010001 # allow table.FeatureVariations
- table.FeatureVariations = None # delete any existing FeatureVariations
+ varFeatureIndices = set()
- varFeatureIndices = []
- for index, feature in enumerate(table.FeatureList.FeatureRecord):
- if feature.FeatureTag == featureTag:
- varFeatureIndices.append(index)
+ existingTags = {
+ feature.FeatureTag
+ for feature in table.FeatureList.FeatureRecord
+ if feature.FeatureTag in featureTags
+ }
- if not varFeatureIndices:
- varFeature = buildFeatureRecord(featureTag, [])
- table.FeatureList.FeatureRecord.append(varFeature)
+ newTags = set(featureTags) - existingTags
+ if newTags:
+ varFeatures = []
+ for featureTag in sorted(newTags):
+ varFeature = buildFeatureRecord(featureTag, [])
+ table.FeatureList.FeatureRecord.append(varFeature)
+ varFeatures.append(varFeature)
table.FeatureList.FeatureCount = len(table.FeatureList.FeatureRecord)
sortFeatureList(table)
- varFeatureIndex = table.FeatureList.FeatureRecord.index(varFeature)
- for scriptRecord in table.ScriptList.ScriptRecord:
- if scriptRecord.Script.DefaultLangSys is None:
- raise VarLibError(
- "Feature variations require that the script "
- f"'{scriptRecord.ScriptTag}' defines a default language system."
- )
- langSystems = [lsr.LangSys for lsr in scriptRecord.Script.LangSysRecord]
- for langSys in [scriptRecord.Script.DefaultLangSys] + langSystems:
- langSys.FeatureIndex.append(varFeatureIndex)
- langSys.FeatureCount = len(langSys.FeatureIndex)
-
- varFeatureIndices = [varFeatureIndex]
+ for varFeature in varFeatures:
+ varFeatureIndex = table.FeatureList.FeatureRecord.index(varFeature)
+
+ for scriptRecord in table.ScriptList.ScriptRecord:
+ if scriptRecord.Script.DefaultLangSys is None:
+ raise VarLibError(
+ "Feature variations require that the script "
+ f"'{scriptRecord.ScriptTag}' defines a default language system."
+ )
+ langSystems = [lsr.LangSys for lsr in scriptRecord.Script.LangSysRecord]
+ for langSys in [scriptRecord.Script.DefaultLangSys] + langSystems:
+ langSys.FeatureIndex.append(varFeatureIndex)
+ langSys.FeatureCount = len(langSys.FeatureIndex)
+ varFeatureIndices.add(varFeatureIndex)
+
+ if existingTags:
+ # indices may have changed if we inserted new features and sorted feature list
+ # so we must do this after the above
+ varFeatureIndices.update(
+ index
+ for index, feature in enumerate(table.FeatureList.FeatureRecord)
+ if feature.FeatureTag in existingTags
+ )
axisIndices = {
axis.axisTag: axisIndex for axisIndex, axis in enumerate(font["fvar"].axes)
}
+ hasFeatureVariations = (
+ hasattr(table, "FeatureVariations") and table.FeatureVariations is not None
+ )
+
featureVariationRecords = []
for conditionSet, lookupIndices in conditionalSubstitutions:
conditionTable = []
@@ -380,7 +430,7 @@ def addFeatureVariationsRaw(font, table, conditionalSubstitutions, featureTag="r
ct = buildConditionTable(axisIndices[axisTag], minValue, maxValue)
conditionTable.append(ct)
records = []
- for varFeatureIndex in varFeatureIndices:
+ for varFeatureIndex in sorted(varFeatureIndices):
existingLookupIndices = table.FeatureList.FeatureRecord[
varFeatureIndex
].Feature.LookupListIndex
@@ -395,11 +445,30 @@ def addFeatureVariationsRaw(font, table, conditionalSubstitutions, featureTag="r
varFeatureIndex, combinedLookupIndices
)
)
- featureVariationRecords.append(
- buildFeatureVariationRecord(conditionTable, records)
- )
+ if hasFeatureVariations and (
+ fvr := findFeatureVariationRecord(table.FeatureVariations, conditionTable)
+ ):
+ fvr.FeatureTableSubstitution.SubstitutionRecord.extend(records)
+ fvr.FeatureTableSubstitution.SubstitutionCount = len(
+ fvr.FeatureTableSubstitution.SubstitutionRecord
+ )
+ else:
+ featureVariationRecords.append(
+ buildFeatureVariationRecord(conditionTable, records)
+ )
- table.FeatureVariations = buildFeatureVariations(featureVariationRecords)
+ if hasFeatureVariations:
+ if table.FeatureVariations.Version != 0x00010000:
+ raise VarLibError(
+ "Unsupported FeatureVariations table version: "
+ f"0x{table.FeatureVariations.Version:08x} (expected 0x00010000)."
+ )
+ table.FeatureVariations.FeatureVariationRecord.extend(featureVariationRecords)
+ table.FeatureVariations.FeatureVariationCount = len(
+ table.FeatureVariations.FeatureVariationRecord
+ )
+ else:
+ table.FeatureVariations = buildFeatureVariations(featureVariationRecords)
#
@@ -558,6 +627,21 @@ def buildConditionTable(axisIndex, filterRangeMinValue, filterRangeMaxValue):
return ct
+def findFeatureVariationRecord(featureVariations, conditionTable):
+ """Find a FeatureVariationRecord that has the same conditionTable."""
+ if featureVariations.Version != 0x00010000:
+ raise VarLibError(
+ "Unsupported FeatureVariations table version: "
+ f"0x{featureVariations.Version:08x} (expected 0x00010000)."
+ )
+
+ for fvr in featureVariations.FeatureVariationRecord:
+ if conditionTable == fvr.ConditionSet.ConditionTable:
+ return fvr
+
+ return None
+
+
def sortFeatureList(table):
"""Sort the feature list by feature tag, and remap the feature indices
elsewhere. This is needed after the feature list has been modified.
diff --git a/Lib/fontTools/varLib/instancer/__init__.py b/Lib/fontTools/varLib/instancer/__init__.py
index cde1d39f..89427dc5 100644
--- a/Lib/fontTools/varLib/instancer/__init__.py
+++ b/Lib/fontTools/varLib/instancer/__init__.py
@@ -82,6 +82,7 @@ are supported, but support for CFF2 variable fonts will be added soon.
The discussion and implementation of these features are tracked at
https://github.com/fonttools/fonttools/issues/1537
"""
+
from fontTools.misc.fixedTools import (
floatToFixedToFloat,
strToFixedToFloat,
@@ -105,6 +106,7 @@ from fontTools.misc.cliTools import makeOutputFileName
from fontTools.varLib.instancer import solver
import collections
import dataclasses
+from contextlib import contextmanager
from copy import deepcopy
from enum import IntEnum
import logging
@@ -613,7 +615,7 @@ def _instantiateGvarGlyph(
if optimize:
isComposite = glyf[glyphname].isComposite()
for var in tupleVarStore:
- var.optimize(coordinates, endPts, isComposite)
+ var.optimize(coordinates, endPts, isComposite=isComposite)
def instantiateGvarGlyph(varfont, glyphname, axisLimits, optimize=True):
@@ -642,9 +644,11 @@ def instantiateGvar(varfont, axisLimits, optimize=True):
glyphnames = sorted(
glyf.glyphOrder,
key=lambda name: (
- glyf[name].getCompositeMaxpValues(glyf).maxComponentDepth
- if glyf[name].isComposite() or glyf[name].isVarComposite()
- else 0,
+ (
+ glyf[name].getCompositeMaxpValues(glyf).maxComponentDepth
+ if glyf[name].isComposite() or glyf[name].isVarComposite()
+ else 0
+ ),
name,
),
)
@@ -694,6 +698,43 @@ def setMvarDeltas(varfont, deltas):
)
+@contextmanager
+def verticalMetricsKeptInSync(varfont):
+ """Ensure hhea vertical metrics stay in sync with OS/2 ones after instancing.
+
+ When applying MVAR deltas to the OS/2 table, if the ascender, descender and
+ line gap change but they were the same as the respective hhea metrics in the
+ original font, this context manager ensures that hhea metrcs also get updated
+ accordingly.
+ The MVAR spec only has tags for the OS/2 metrics, but it is common in fonts
+ to have the hhea metrics be equal to those for compat reasons.
+
+ https://learn.microsoft.com/en-us/typography/opentype/spec/mvar
+ https://googlefonts.github.io/gf-guide/metrics.html#7-hhea-and-typo-metrics-should-be-equal
+ https://github.com/fonttools/fonttools/issues/3297
+ """
+ current_os2_vmetrics = [
+ getattr(varfont["OS/2"], attr)
+ for attr in ("sTypoAscender", "sTypoDescender", "sTypoLineGap")
+ ]
+ metrics_are_synced = current_os2_vmetrics == [
+ getattr(varfont["hhea"], attr) for attr in ("ascender", "descender", "lineGap")
+ ]
+
+ yield metrics_are_synced
+
+ if metrics_are_synced:
+ new_os2_vmetrics = [
+ getattr(varfont["OS/2"], attr)
+ for attr in ("sTypoAscender", "sTypoDescender", "sTypoLineGap")
+ ]
+ if current_os2_vmetrics != new_os2_vmetrics:
+ for attr, value in zip(
+ ("ascender", "descender", "lineGap"), new_os2_vmetrics
+ ):
+ setattr(varfont["hhea"], attr, value)
+
+
def instantiateMVAR(varfont, axisLimits):
log.info("Instantiating MVAR table")
@@ -701,7 +742,9 @@ def instantiateMVAR(varfont, axisLimits):
fvarAxes = varfont["fvar"].axes
varStore = mvar.VarStore
defaultDeltas = instantiateItemVariationStore(varStore, fvarAxes, axisLimits)
- setMvarDeltas(varfont, defaultDeltas)
+
+ with verticalMetricsKeptInSync(varfont):
+ setMvarDeltas(varfont, defaultDeltas)
if varStore.VarRegionList.Region:
varIndexMapping = varStore.optimize()
@@ -1393,7 +1436,7 @@ def parseArgs(args):
nargs="*",
help="List of space separated locations. A location consists of "
"the tag of a variation axis, followed by '=' and the literal, "
- "string 'drop', or comma-separate list of one to three values, "
+ "string 'drop', or colon-separated list of one to three values, "
"each of which is the empty string, or a number. "
"E.g.: wdth=100 or wght=75.0:125.0 or wght=100:400:700 or wght=:500: "
"or wght=drop",
diff --git a/Lib/fontTools/varLib/instancer/solver.py b/Lib/fontTools/varLib/instancer/solver.py
index 9c568fe9..ba5231b7 100644
--- a/Lib/fontTools/varLib/instancer/solver.py
+++ b/Lib/fontTools/varLib/instancer/solver.py
@@ -178,7 +178,9 @@ def _solve(tent, axisLimit, negative=False):
#
newUpper = peak + (1 - gain) * (upper - peak)
assert axisMax <= newUpper # Because outGain > gain
- if newUpper <= axisDef + (axisMax - axisDef) * 2:
+ # Disabled because ots doesn't like us:
+ # https://github.com/fonttools/fonttools/issues/3350
+ if False and newUpper <= axisDef + (axisMax - axisDef) * 2:
upper = newUpper
if not negative and axisDef + (axisMax - axisDef) * MAX_F2DOT14 < upper:
# we clamp +2.0 to the max F2Dot14 (~1.99994) for convenience
diff --git a/Lib/fontTools/varLib/interpolatable.py b/Lib/fontTools/varLib/interpolatable.py
index c3f01f46..5fc12e04 100644
--- a/Lib/fontTools/varLib/interpolatable.py
+++ b/Lib/fontTools/varLib/interpolatable.py
@@ -6,273 +6,234 @@ Call as:
$ fonttools varLib.interpolatable font1 font2 ...
"""
-from fontTools.pens.basePen import AbstractPen, BasePen
-from fontTools.pens.pointPen import AbstractPointPen, SegmentToPointPen
-from fontTools.pens.recordingPen import RecordingPen
-from fontTools.pens.statisticsPen import StatisticsPen
+from .interpolatableHelpers import *
+from .interpolatableTestContourOrder import test_contour_order
+from .interpolatableTestStartingPoint import test_starting_point
+from fontTools.pens.recordingPen import (
+ RecordingPen,
+ DecomposingRecordingPen,
+ lerpRecordings,
+)
+from fontTools.pens.transformPen import TransformPen
+from fontTools.pens.statisticsPen import StatisticsPen, StatisticsControlPen
from fontTools.pens.momentsPen import OpenContourError
+from fontTools.varLib.models import piecewiseLinearMap, normalizeLocation
+from fontTools.misc.fixedTools import floatToFixedToStr
+from fontTools.misc.transform import Transform
from collections import defaultdict
-import math
-import itertools
-import sys
-
-
-def _rot_list(l, k):
- """Rotate list by k items forward. Ie. item at position 0 will be
- at position k in returned list. Negative k is allowed."""
- return l[-k:] + l[:-k]
-
-
-class PerContourPen(BasePen):
- def __init__(self, Pen, glyphset=None):
- BasePen.__init__(self, glyphset)
- self._glyphset = glyphset
- self._Pen = Pen
- self._pen = None
- self.value = []
-
- def _moveTo(self, p0):
- self._newItem()
- self._pen.moveTo(p0)
-
- def _lineTo(self, p1):
- self._pen.lineTo(p1)
-
- def _qCurveToOne(self, p1, p2):
- self._pen.qCurveTo(p1, p2)
-
- def _curveToOne(self, p1, p2, p3):
- self._pen.curveTo(p1, p2, p3)
-
- def _closePath(self):
- self._pen.closePath()
- self._pen = None
-
- def _endPath(self):
- self._pen.endPath()
- self._pen = None
-
- def _newItem(self):
- self._pen = pen = self._Pen()
- self.value.append(pen)
-
-
-class PerContourOrComponentPen(PerContourPen):
- def addComponent(self, glyphName, transformation):
- self._newItem()
- self.value[-1].addComponent(glyphName, transformation)
-
-
-class RecordingPointPen(AbstractPointPen):
- def __init__(self):
- self.value = []
-
- def beginPath(self, identifier=None, **kwargs):
- pass
-
- def endPath(self) -> None:
- pass
-
- def addPoint(self, pt, segmentType=None):
- self.value.append((pt, False if segmentType is None else True))
-
-
-def _vdiff_hypot2(v0, v1):
- s = 0
- for x0, x1 in zip(v0, v1):
- d = x1 - x0
- s += d * d
- return s
-
-
-def _vdiff_hypot2_complex(v0, v1):
- s = 0
- for x0, x1 in zip(v0, v1):
- d = x1 - x0
- s += d.real * d.real + d.imag * d.imag
- return s
-
-
-def _matching_cost(G, matching):
- return sum(G[i][j] for i, j in enumerate(matching))
-
-
-def min_cost_perfect_bipartite_matching_scipy(G):
- n = len(G)
- rows, cols = linear_sum_assignment(G)
- assert (rows == list(range(n))).all()
- return list(cols), _matching_cost(G, cols)
-
-
-def min_cost_perfect_bipartite_matching_munkres(G):
- n = len(G)
- cols = [None] * n
- for row, col in Munkres().compute(G):
- cols[row] = col
- return cols, _matching_cost(G, cols)
-
-
-def min_cost_perfect_bipartite_matching_bruteforce(G):
- n = len(G)
-
- if n > 6:
- raise Exception("Install Python module 'munkres' or 'scipy >= 0.17.0'")
-
- # Otherwise just brute-force
- permutations = itertools.permutations(range(n))
- best = list(next(permutations))
- best_cost = _matching_cost(G, best)
- for p in permutations:
- cost = _matching_cost(G, p)
- if cost < best_cost:
- best, best_cost = list(p), cost
- return best, best_cost
+from types import SimpleNamespace
+from functools import wraps
+from pprint import pformat
+from math import sqrt, atan2, pi
+import logging
+import os
+
+log = logging.getLogger("fontTools.varLib.interpolatable")
+
+DEFAULT_TOLERANCE = 0.95
+DEFAULT_KINKINESS = 0.5
+DEFAULT_KINKINESS_LENGTH = 0.002 # ratio of UPEM
+DEFAULT_UPEM = 1000
+
+
+class Glyph:
+ ITEMS = (
+ "recordings",
+ "greenStats",
+ "controlStats",
+ "greenVectors",
+ "controlVectors",
+ "nodeTypes",
+ "isomorphisms",
+ "points",
+ "openContours",
+ )
+ def __init__(self, glyphname, glyphset):
+ self.name = glyphname
+ for item in self.ITEMS:
+ setattr(self, item, [])
+ self._populate(glyphset)
-try:
- from scipy.optimize import linear_sum_assignment
+ def _fill_in(self, ix):
+ for item in self.ITEMS:
+ if len(getattr(self, item)) == ix:
+ getattr(self, item).append(None)
- min_cost_perfect_bipartite_matching = min_cost_perfect_bipartite_matching_scipy
-except ImportError:
- try:
- from munkres import Munkres
+ def _populate(self, glyphset):
+ glyph = glyphset[self.name]
+ self.doesnt_exist = glyph is None
+ if self.doesnt_exist:
+ return
- min_cost_perfect_bipartite_matching = (
- min_cost_perfect_bipartite_matching_munkres
- )
- except ImportError:
- min_cost_perfect_bipartite_matching = (
- min_cost_perfect_bipartite_matching_bruteforce
- )
+ perContourPen = PerContourOrComponentPen(RecordingPen, glyphset=glyphset)
+ try:
+ glyph.draw(perContourPen, outputImpliedClosingLine=True)
+ except TypeError:
+ glyph.draw(perContourPen)
+ self.recordings = perContourPen.value
+ del perContourPen
+
+ for ix, contour in enumerate(self.recordings):
+ nodeTypes = [op for op, arg in contour.value]
+ self.nodeTypes.append(nodeTypes)
+
+ greenStats = StatisticsPen(glyphset=glyphset)
+ controlStats = StatisticsControlPen(glyphset=glyphset)
+ try:
+ contour.replay(greenStats)
+ contour.replay(controlStats)
+ self.openContours.append(False)
+ except OpenContourError as e:
+ self.openContours.append(True)
+ self._fill_in(ix)
+ continue
+ self.greenStats.append(greenStats)
+ self.controlStats.append(controlStats)
+ self.greenVectors.append(contour_vector_from_stats(greenStats))
+ self.controlVectors.append(contour_vector_from_stats(controlStats))
+
+ # Check starting point
+ if nodeTypes[0] == "addComponent":
+ self._fill_in(ix)
+ continue
+ assert nodeTypes[0] == "moveTo"
+ assert nodeTypes[-1] in ("closePath", "endPath")
+ points = SimpleRecordingPointPen()
+ converter = SegmentToPointPen(points, False)
+ contour.replay(converter)
+ # points.value is a list of pt,bool where bool is true if on-curve and false if off-curve;
+ # now check all rotations and mirror-rotations of the contour and build list of isomorphic
+ # possible starting points.
+ self.points.append(points.value)
+
+ isomorphisms = []
+ self.isomorphisms.append(isomorphisms)
+
+ # Add rotations
+ add_isomorphisms(points.value, isomorphisms, False)
+ # Add mirrored rotations
+ add_isomorphisms(points.value, isomorphisms, True)
+
+ def draw(self, pen, countor_idx=None):
+ if countor_idx is None:
+ for contour in self.recordings:
+ contour.draw(pen)
+ else:
+ self.recordings[countor_idx].draw(pen)
+
+
+def test_gen(
+ glyphsets,
+ glyphs=None,
+ names=None,
+ ignore_missing=False,
+ *,
+ locations=None,
+ tolerance=DEFAULT_TOLERANCE,
+ kinkiness=DEFAULT_KINKINESS,
+ upem=DEFAULT_UPEM,
+ show_all=False,
+):
+ if tolerance >= 10:
+ tolerance *= 0.01
+ assert 0 <= tolerance <= 1
+ if kinkiness >= 10:
+ kinkiness *= 0.01
+ assert 0 <= kinkiness
+
+ names = names or [repr(g) for g in glyphsets]
-def test_gen(glyphsets, glyphs=None, names=None, ignore_missing=False):
- if names is None:
- names = glyphsets
if glyphs is None:
# `glyphs = glyphsets[0].keys()` is faster, certainly, but doesn't allow for sparse TTFs/OTFs given out of order
# ... risks the sparse master being the first one, and only processing a subset of the glyphs
glyphs = {g for glyphset in glyphsets for g in glyphset.keys()}
- hist = []
+ parents, order = find_parents_and_order(glyphsets, locations)
- for glyph_name in glyphs:
- try:
- m0idx = 0
- allVectors = []
- allNodeTypes = []
- allContourIsomorphisms = []
- allGlyphs = [glyphset[glyph_name] for glyphset in glyphsets]
- if len([1 for glyph in allGlyphs if glyph is not None]) <= 1:
- continue
- for glyph, glyphset, name in zip(allGlyphs, glyphsets, names):
- if glyph is None:
- if not ignore_missing:
- yield (glyph_name, {"type": "missing", "master": name})
- allNodeTypes.append(None)
- allVectors.append(None)
- allContourIsomorphisms.append(None)
- continue
+ def grand_parent(i, glyphname):
+ if i is None:
+ return None
+ i = parents[i]
+ if i is None:
+ return None
+ while parents[i] is not None and glyphsets[i][glyphname] is None:
+ i = parents[i]
+ return i
- perContourPen = PerContourOrComponentPen(
- RecordingPen, glyphset=glyphset
- )
- try:
- glyph.draw(perContourPen, outputImpliedClosingLine=True)
- except TypeError:
- glyph.draw(perContourPen)
- contourPens = perContourPen.value
- del perContourPen
-
- contourVectors = []
- contourIsomorphisms = []
- nodeTypes = []
- allNodeTypes.append(nodeTypes)
- allVectors.append(contourVectors)
- allContourIsomorphisms.append(contourIsomorphisms)
- for ix, contour in enumerate(contourPens):
- nodeVecs = tuple(instruction[0] for instruction in contour.value)
- nodeTypes.append(nodeVecs)
-
- stats = StatisticsPen(glyphset=glyphset)
- try:
- contour.replay(stats)
- except OpenContourError as e:
- yield (
- glyph_name,
- {"master": name, "contour": ix, "type": "open_path"},
- )
- continue
- size = math.sqrt(abs(stats.area)) * 0.5
- vector = (
- int(size),
- int(stats.meanX),
- int(stats.meanY),
- int(stats.stddevX * 2),
- int(stats.stddevY * 2),
- int(stats.correlation * size),
- )
- contourVectors.append(vector)
- # print(vector)
-
- # Check starting point
- if nodeVecs[0] == "addComponent":
- continue
- assert nodeVecs[0] == "moveTo"
- assert nodeVecs[-1] in ("closePath", "endPath")
- points = RecordingPointPen()
- converter = SegmentToPointPen(points, False)
- contour.replay(converter)
- # points.value is a list of pt,bool where bool is true if on-curve and false if off-curve;
- # now check all rotations and mirror-rotations of the contour and build list of isomorphic
- # possible starting points.
- bits = 0
- for pt, b in points.value:
- bits = (bits << 1) | b
- n = len(points.value)
- mask = (1 << n) - 1
- isomorphisms = []
- contourIsomorphisms.append(isomorphisms)
- complexPoints = [complex(*pt) for pt, bl in points.value]
- for i in range(n):
- b = ((bits << i) & mask) | ((bits >> (n - i)))
- if b == bits:
- isomorphisms.append(_rot_list(complexPoints, i))
- # Add mirrored rotations
- mirrored = list(reversed(points.value))
- reversed_bits = 0
- for pt, b in mirrored:
- reversed_bits = (reversed_bits << 1) | b
- complexPoints = list(reversed(complexPoints))
- for i in range(n):
- b = ((reversed_bits << i) & mask) | ((reversed_bits >> (n - i)))
- if b == bits:
- isomorphisms.append(_rot_list(complexPoints, i))
-
- # m0idx should be the index of the first non-None item in allNodeTypes,
- # else give it the last item.
- m0idx = next(
- (i for i, x in enumerate(allNodeTypes) if x is not None),
- len(allNodeTypes) - 1,
- )
- # m0 is the first non-None item in allNodeTypes, or last one if all None
- m0 = allNodeTypes[m0idx]
- for i, m1 in enumerate(allNodeTypes[m0idx + 1 :]):
- if m1 is None:
- continue
- if len(m0) != len(m1):
+ for glyph_name in glyphs:
+ log.info("Testing glyph %s", glyph_name)
+ allGlyphs = [Glyph(glyph_name, glyphset) for glyphset in glyphsets]
+ if len([1 for glyph in allGlyphs if glyph is not None]) <= 1:
+ continue
+ for master_idx, (glyph, glyphset, name) in enumerate(
+ zip(allGlyphs, glyphsets, names)
+ ):
+ if glyph.doesnt_exist:
+ if not ignore_missing:
yield (
glyph_name,
{
- "type": "path_count",
- "master_1": names[m0idx],
- "master_2": names[m0idx + i + 1],
- "value_1": len(m0),
- "value_2": len(m1),
+ "type": InterpolatableProblem.MISSING,
+ "master": name,
+ "master_idx": master_idx,
},
)
- if m0 == m1:
+ continue
+
+ has_open = False
+ for ix, open in enumerate(glyph.openContours):
+ if not open:
continue
+ has_open = True
+ yield (
+ glyph_name,
+ {
+ "type": InterpolatableProblem.OPEN_PATH,
+ "master": name,
+ "master_idx": master_idx,
+ "contour": ix,
+ },
+ )
+ if has_open:
+ continue
+
+ matchings = [None] * len(glyphsets)
+
+ for m1idx in order:
+ glyph1 = allGlyphs[m1idx]
+ if glyph1 is None or not glyph1.nodeTypes:
+ continue
+ m0idx = grand_parent(m1idx, glyph_name)
+ if m0idx is None:
+ continue
+ glyph0 = allGlyphs[m0idx]
+ if glyph0 is None or not glyph0.nodeTypes:
+ continue
+
+ #
+ # Basic compatibility checks
+ #
+
+ m1 = glyph0.nodeTypes
+ m0 = glyph1.nodeTypes
+ if len(m0) != len(m1):
+ yield (
+ glyph_name,
+ {
+ "type": InterpolatableProblem.PATH_COUNT,
+ "master_1": names[m0idx],
+ "master_2": names[m1idx],
+ "master_1_idx": m0idx,
+ "master_2_idx": m1idx,
+ "value_1": len(m0),
+ "value_2": len(m1),
+ },
+ )
+ continue
+
+ if m0 != m1:
for pathIx, (nodes1, nodes2) in enumerate(zip(m0, m1)):
if nodes1 == nodes2:
continue
@@ -280,10 +241,12 @@ def test_gen(glyphsets, glyphs=None, names=None, ignore_missing=False):
yield (
glyph_name,
{
- "type": "node_count",
+ "type": InterpolatableProblem.NODE_COUNT,
"path": pathIx,
"master_1": names[m0idx],
- "master_2": names[m0idx + i + 1],
+ "master_2": names[m1idx],
+ "master_1_idx": m0idx,
+ "master_2_idx": m1idx,
"value_1": len(nodes1),
"value_2": len(nodes2),
},
@@ -294,93 +257,332 @@ def test_gen(glyphsets, glyphs=None, names=None, ignore_missing=False):
yield (
glyph_name,
{
- "type": "node_incompatibility",
+ "type": InterpolatableProblem.NODE_INCOMPATIBILITY,
"path": pathIx,
"node": nodeIx,
"master_1": names[m0idx],
- "master_2": names[m0idx + i + 1],
+ "master_2": names[m1idx],
+ "master_1_idx": m0idx,
+ "master_2_idx": m1idx,
"value_1": n1,
"value_2": n2,
},
)
continue
- # m0idx should be the index of the first non-None item in allVectors,
- # else give it the last item.
- m0idx = next(
- (i for i, x in enumerate(allVectors) if x is not None),
- len(allVectors) - 1,
- )
- # m0 is the first non-None item in allVectors, or last one if all None
- m0 = allVectors[m0idx]
- if m0 is not None and len(m0) > 1:
- for i, m1 in enumerate(allVectors[m0idx + 1 :]):
- if m1 is None:
- continue
- if len(m0) != len(m1):
- # We already reported this
- continue
- costs = [[_vdiff_hypot2(v0, v1) for v1 in m1] for v0 in m0]
- matching, matching_cost = min_cost_perfect_bipartite_matching(costs)
- identity_matching = list(range(len(m0)))
- identity_cost = sum(costs[i][i] for i in range(len(m0)))
- if (
- matching != identity_matching
- and matching_cost < identity_cost * 0.95
+ #
+ # InterpolatableProblem.CONTOUR_ORDER check
+ #
+
+ this_tolerance, matching = test_contour_order(glyph0, glyph1)
+ if this_tolerance < tolerance:
+ yield (
+ glyph_name,
+ {
+ "type": InterpolatableProblem.CONTOUR_ORDER,
+ "master_1": names[m0idx],
+ "master_2": names[m1idx],
+ "master_1_idx": m0idx,
+ "master_2_idx": m1idx,
+ "value_1": list(range(len(matching))),
+ "value_2": matching,
+ "tolerance": this_tolerance,
+ },
+ )
+ matchings[m1idx] = matching
+
+ #
+ # wrong-start-point / weight check
+ #
+
+ m0Isomorphisms = glyph0.isomorphisms
+ m1Isomorphisms = glyph1.isomorphisms
+ m0Vectors = glyph0.greenVectors
+ m1Vectors = glyph1.greenVectors
+ recording0 = glyph0.recordings
+ recording1 = glyph1.recordings
+
+ # If contour-order is wrong, adjust it
+ matching = matchings[m1idx]
+ if (
+ matching is not None and m1Isomorphisms
+ ): # m1 is empty for composite glyphs
+ m1Isomorphisms = [m1Isomorphisms[i] for i in matching]
+ m1Vectors = [m1Vectors[i] for i in matching]
+ recording1 = [recording1[i] for i in matching]
+
+ midRecording = []
+ for c0, c1 in zip(recording0, recording1):
+ try:
+ r = RecordingPen()
+ r.value = list(lerpRecordings(c0.value, c1.value))
+ midRecording.append(r)
+ except ValueError:
+ # Mismatch because of the reordering above
+ midRecording.append(None)
+
+ for ix, (contour0, contour1) in enumerate(
+ zip(m0Isomorphisms, m1Isomorphisms)
+ ):
+ if (
+ contour0 is None
+ or contour1 is None
+ or len(contour0) == 0
+ or len(contour0) != len(contour1)
+ ):
+ # We already reported this; or nothing to do; or not compatible
+ # after reordering above.
+ continue
+
+ this_tolerance, proposed_point, reverse = test_starting_point(
+ glyph0, glyph1, ix, tolerance, matching
+ )
+
+ if this_tolerance < tolerance:
+ yield (
+ glyph_name,
+ {
+ "type": InterpolatableProblem.WRONG_START_POINT,
+ "contour": ix,
+ "master_1": names[m0idx],
+ "master_2": names[m1idx],
+ "master_1_idx": m0idx,
+ "master_2_idx": m1idx,
+ "value_1": 0,
+ "value_2": proposed_point,
+ "reversed": reverse,
+ "tolerance": this_tolerance,
+ },
+ )
+
+ # Weight check.
+ #
+ # If contour could be mid-interpolated, and the two
+ # contours have the same area sign, proceeed.
+ #
+ # The sign difference can happen if it's a weirdo
+ # self-intersecting contour; ignore it.
+ contour = midRecording[ix]
+
+ if contour and (m0Vectors[ix][0] < 0) == (m1Vectors[ix][0] < 0):
+ midStats = StatisticsPen(glyphset=None)
+ contour.replay(midStats)
+
+ midVector = contour_vector_from_stats(midStats)
+
+ m0Vec = m0Vectors[ix]
+ m1Vec = m1Vectors[ix]
+ size0 = m0Vec[0] * m0Vec[0]
+ size1 = m1Vec[0] * m1Vec[0]
+ midSize = midVector[0] * midVector[0]
+
+ for overweight, problem_type in enumerate(
+ (
+ InterpolatableProblem.UNDERWEIGHT,
+ InterpolatableProblem.OVERWEIGHT,
+ )
):
- yield (
- glyph_name,
- {
- "type": "contour_order",
- "master_1": names[m0idx],
- "master_2": names[m0idx + i + 1],
- "value_1": list(range(len(m0))),
- "value_2": matching,
- },
+ if overweight:
+ expectedSize = max(size0, size1)
+ continue
+ else:
+ expectedSize = sqrt(size0 * size1)
+
+ log.debug(
+ "%s: actual size %g; threshold size %g, master sizes: %g, %g",
+ problem_type,
+ midSize,
+ expectedSize,
+ size0,
+ size1,
)
- break
- # m0idx should be the index of the first non-None item in allContourIsomorphisms,
- # else give it the last item.
- m0idx = next(
- (i for i, x in enumerate(allContourIsomorphisms) if x is not None),
- len(allVectors) - 1,
- )
- # m0 is the first non-None item in allContourIsomorphisms, or last one if all None
- m0 = allContourIsomorphisms[m0idx]
- if m0:
- for i, m1 in enumerate(allContourIsomorphisms[m0idx + 1 :]):
- if m1 is None:
- continue
- if len(m0) != len(m1):
- # We already reported this
- continue
- for ix, (contour0, contour1) in enumerate(zip(m0, m1)):
- c0 = contour0[0]
- costs = [_vdiff_hypot2_complex(c0, c1) for c1 in contour1]
- min_cost = min(costs)
- first_cost = costs[0]
- if min_cost < first_cost * 0.95:
+ if (
+ not overweight and expectedSize * tolerance > midSize + 1e-5
+ ) or (overweight and 1e-5 + expectedSize / tolerance < midSize):
+ try:
+ if overweight:
+ this_tolerance = expectedSize / midSize
+ else:
+ this_tolerance = midSize / expectedSize
+ except ZeroDivisionError:
+ this_tolerance = 0
+ log.debug("tolerance %g", this_tolerance)
yield (
glyph_name,
{
- "type": "wrong_start_point",
+ "type": problem_type,
"contour": ix,
"master_1": names[m0idx],
- "master_2": names[m0idx + i + 1],
+ "master_2": names[m1idx],
+ "master_1_idx": m0idx,
+ "master_2_idx": m1idx,
+ "tolerance": this_tolerance,
},
)
- except ValueError as e:
- yield (
- glyph_name,
- {"type": "math_error", "master": name, "error": e},
+ #
+ # "kink" detector
+ #
+ m0 = glyph0.points
+ m1 = glyph1.points
+
+ # If contour-order is wrong, adjust it
+ if matchings[m1idx] is not None and m1: # m1 is empty for composite glyphs
+ m1 = [m1[i] for i in matchings[m1idx]]
+
+ t = 0.1 # ~sin(radian(6)) for tolerance 0.95
+ deviation_threshold = (
+ upem * DEFAULT_KINKINESS_LENGTH * DEFAULT_KINKINESS / kinkiness
)
+ for ix, (contour0, contour1) in enumerate(zip(m0, m1)):
+ if (
+ contour0 is None
+ or contour1 is None
+ or len(contour0) == 0
+ or len(contour0) != len(contour1)
+ ):
+ # We already reported this; or nothing to do; or not compatible
+ # after reordering above.
+ continue
-def test(glyphsets, glyphs=None, names=None, ignore_missing=False):
+ # Walk the contour, keeping track of three consecutive points, with
+ # middle one being an on-curve. If the three are co-linear then
+ # check for kinky-ness.
+ for i in range(len(contour0)):
+ pt0 = contour0[i]
+ pt1 = contour1[i]
+ if not pt0[1] or not pt1[1]:
+ # Skip off-curves
+ continue
+ pt0_prev = contour0[i - 1]
+ pt1_prev = contour1[i - 1]
+ pt0_next = contour0[(i + 1) % len(contour0)]
+ pt1_next = contour1[(i + 1) % len(contour1)]
+
+ if pt0_prev[1] and pt1_prev[1]:
+ # At least one off-curve is required
+ continue
+ if pt0_prev[1] and pt1_prev[1]:
+ # At least one off-curve is required
+ continue
+
+ pt0 = complex(*pt0[0])
+ pt1 = complex(*pt1[0])
+ pt0_prev = complex(*pt0_prev[0])
+ pt1_prev = complex(*pt1_prev[0])
+ pt0_next = complex(*pt0_next[0])
+ pt1_next = complex(*pt1_next[0])
+
+ # We have three consecutive points. Check whether
+ # they are colinear.
+ d0_prev = pt0 - pt0_prev
+ d0_next = pt0_next - pt0
+ d1_prev = pt1 - pt1_prev
+ d1_next = pt1_next - pt1
+
+ sin0 = d0_prev.real * d0_next.imag - d0_prev.imag * d0_next.real
+ sin1 = d1_prev.real * d1_next.imag - d1_prev.imag * d1_next.real
+ try:
+ sin0 /= abs(d0_prev) * abs(d0_next)
+ sin1 /= abs(d1_prev) * abs(d1_next)
+ except ZeroDivisionError:
+ continue
+
+ if abs(sin0) > t or abs(sin1) > t:
+ # Not colinear / not smooth.
+ continue
+
+ # Check the mid-point is actually, well, in the middle.
+ dot0 = d0_prev.real * d0_next.real + d0_prev.imag * d0_next.imag
+ dot1 = d1_prev.real * d1_next.real + d1_prev.imag * d1_next.imag
+ if dot0 < 0 or dot1 < 0:
+ # Sharp corner.
+ continue
+
+ # Fine, if handle ratios are similar...
+ r0 = abs(d0_prev) / (abs(d0_prev) + abs(d0_next))
+ r1 = abs(d1_prev) / (abs(d1_prev) + abs(d1_next))
+ r_diff = abs(r0 - r1)
+ if abs(r_diff) < t:
+ # Smooth enough.
+ continue
+
+ mid = (pt0 + pt1) / 2
+ mid_prev = (pt0_prev + pt1_prev) / 2
+ mid_next = (pt0_next + pt1_next) / 2
+
+ mid_d0 = mid - mid_prev
+ mid_d1 = mid_next - mid
+
+ sin_mid = mid_d0.real * mid_d1.imag - mid_d0.imag * mid_d1.real
+ try:
+ sin_mid /= abs(mid_d0) * abs(mid_d1)
+ except ZeroDivisionError:
+ continue
+
+ # ...or if the angles are similar.
+ if abs(sin_mid) * (tolerance * kinkiness) <= t:
+ # Smooth enough.
+ continue
+
+ # How visible is the kink?
+
+ cross = sin_mid * abs(mid_d0) * abs(mid_d1)
+ arc_len = abs(mid_d0 + mid_d1)
+ deviation = abs(cross / arc_len)
+ if deviation < deviation_threshold:
+ continue
+ deviation_ratio = deviation / arc_len
+ if deviation_ratio > t:
+ continue
+
+ this_tolerance = t / (abs(sin_mid) * kinkiness)
+
+ log.debug(
+ "kink: deviation %g; deviation_ratio %g; sin_mid %g; r_diff %g",
+ deviation,
+ deviation_ratio,
+ sin_mid,
+ r_diff,
+ )
+ log.debug("tolerance %g", this_tolerance)
+ yield (
+ glyph_name,
+ {
+ "type": InterpolatableProblem.KINK,
+ "contour": ix,
+ "master_1": names[m0idx],
+ "master_2": names[m1idx],
+ "master_1_idx": m0idx,
+ "master_2_idx": m1idx,
+ "value": i,
+ "tolerance": this_tolerance,
+ },
+ )
+
+ #
+ # --show-all
+ #
+
+ if show_all:
+ yield (
+ glyph_name,
+ {
+ "type": InterpolatableProblem.NOTHING,
+ "master_1": names[m0idx],
+ "master_2": names[m1idx],
+ "master_1_idx": m0idx,
+ "master_2_idx": m1idx,
+ },
+ )
+
+
+@wraps(test_gen)
+def test(*args, **kwargs):
problems = defaultdict(list)
- for glyphname, problem in test_gen(glyphsets, glyphs, names, ignore_missing):
+ for glyphname, problem in test_gen(*args, **kwargs):
problems[glyphname].append(problem)
return problems
@@ -394,9 +596,17 @@ def recursivelyAddGlyph(glyphname, glyphset, ttGlyphSet, glyf):
recursivelyAddGlyph(component.glyphName, glyphset, ttGlyphSet, glyf)
+def ensure_parent_dir(path):
+ dirname = os.path.dirname(path)
+ if dirname:
+ os.makedirs(dirname, exist_ok=True)
+ return path
+
+
def main(args=None):
"""Test for interpolatability issues between fonts"""
import argparse
+ import sys
parser = argparse.ArgumentParser(
"fonttools varLib.interpolatable",
@@ -408,16 +618,53 @@ def main(args=None):
help="Space-separate name of glyphs to check",
)
parser.add_argument(
+ "--show-all",
+ action="store_true",
+ help="Show all glyph pairs, even if no problems are found",
+ )
+ parser.add_argument(
+ "--tolerance",
+ action="store",
+ type=float,
+ help="Error tolerance. Between 0 and 1. Default %s" % DEFAULT_TOLERANCE,
+ )
+ parser.add_argument(
+ "--kinkiness",
+ action="store",
+ type=float,
+ help="How aggressively report kinks. Default %s" % DEFAULT_KINKINESS,
+ )
+ parser.add_argument(
"--json",
action="store_true",
help="Output report in JSON format",
)
parser.add_argument(
+ "--pdf",
+ action="store",
+ help="Output report in PDF format",
+ )
+ parser.add_argument(
+ "--ps",
+ action="store",
+ help="Output report in PostScript format",
+ )
+ parser.add_argument(
+ "--html",
+ action="store",
+ help="Output report in HTML format",
+ )
+ parser.add_argument(
"--quiet",
action="store_true",
help="Only exit with code 1 or 0, no output",
)
parser.add_argument(
+ "--output",
+ action="store",
+ help="Output file for the problem report; Default: stdout",
+ )
+ parser.add_argument(
"--ignore-missing",
action="store_true",
help="Will not report glyphs missing from sparse masters as errors",
@@ -429,37 +676,96 @@ def main(args=None):
nargs="+",
help="Input a single variable font / DesignSpace / Glyphs file, or multiple TTF/UFO files",
)
+ parser.add_argument(
+ "--name",
+ metavar="NAME",
+ type=str,
+ action="append",
+ help="Name of the master to use in the report. If not provided, all are used.",
+ )
+ parser.add_argument("-v", "--verbose", action="store_true", help="Run verbosely.")
+ parser.add_argument("--debug", action="store_true", help="Run with debug output.")
args = parser.parse_args(args)
+ from fontTools import configLogger
+
+ configLogger(level=("INFO" if args.verbose else "ERROR"))
+ if args.debug:
+ configLogger(level="DEBUG")
+
glyphs = args.glyphs.split() if args.glyphs else None
from os.path import basename
fonts = []
names = []
+ locations = []
+ upem = DEFAULT_UPEM
+
+ original_args_inputs = tuple(args.inputs)
if len(args.inputs) == 1:
+ designspace = None
if args.inputs[0].endswith(".designspace"):
from fontTools.designspaceLib import DesignSpaceDocument
designspace = DesignSpaceDocument.fromfile(args.inputs[0])
args.inputs = [master.path for master in designspace.sources]
-
- elif args.inputs[0].endswith(".glyphs"):
- from glyphsLib import GSFont, to_ufos
+ locations = [master.location for master in designspace.sources]
+ axis_triples = {
+ a.name: (a.minimum, a.default, a.maximum) for a in designspace.axes
+ }
+ axis_mappings = {a.name: a.map for a in designspace.axes}
+ axis_triples = {
+ k: tuple(piecewiseLinearMap(v, dict(axis_mappings[k])) for v in vv)
+ for k, vv in axis_triples.items()
+ }
+
+ elif args.inputs[0].endswith((".glyphs", ".glyphspackage")):
+ from glyphsLib import GSFont, to_designspace
gsfont = GSFont(args.inputs[0])
- fonts.extend(to_ufos(gsfont))
+ upem = gsfont.upm
+ designspace = to_designspace(gsfont)
+ fonts = [source.font for source in designspace.sources]
names = ["%s-%s" % (f.info.familyName, f.info.styleName) for f in fonts]
args.inputs = []
+ locations = [master.location for master in designspace.sources]
+ axis_triples = {
+ a.name: (a.minimum, a.default, a.maximum) for a in designspace.axes
+ }
+ axis_mappings = {a.name: a.map for a in designspace.axes}
+ axis_triples = {
+ k: tuple(piecewiseLinearMap(v, dict(axis_mappings[k])) for v in vv)
+ for k, vv in axis_triples.items()
+ }
elif args.inputs[0].endswith(".ttf"):
from fontTools.ttLib import TTFont
font = TTFont(args.inputs[0])
+ upem = font["head"].unitsPerEm
if "gvar" in font:
# Is variable font
+
+ axisMapping = {}
+ fvar = font["fvar"]
+ for axis in fvar.axes:
+ axisMapping[axis.axisTag] = {
+ -1: axis.minValue,
+ 0: axis.defaultValue,
+ 1: axis.maxValue,
+ }
+ if "avar" in font:
+ avar = font["avar"]
+ for axisTag, segments in avar.segments.items():
+ fvarMapping = axisMapping[axisTag].copy()
+ for location, value in segments.items():
+ axisMapping[axisTag][value] = piecewiseLinearMap(
+ location, fvarMapping
+ )
+
gvar = font["gvar"]
glyf = font["glyf"]
# Gather all glyphs at their "master" locations
@@ -479,30 +785,56 @@ def main(args=None):
locTuple = tuple(loc)
if locTuple not in ttGlyphSets:
ttGlyphSets[locTuple] = font.getGlyphSet(
- location=locDict, normalized=True
+ location=locDict, normalized=True, recalcBounds=False
)
recursivelyAddGlyph(
glyphname, glyphsets[locTuple], ttGlyphSets[locTuple], glyf
)
- names = ["()"]
+ names = ["''"]
fonts = [font.getGlyphSet()]
+ locations = [{}]
+ axis_triples = {a: (-1, 0, +1) for a in sorted(axisMapping.keys())}
for locTuple in sorted(glyphsets.keys(), key=lambda v: (len(v), v)):
- names.append(str(locTuple))
+ name = (
+ "'"
+ + " ".join(
+ "%s=%s"
+ % (
+ k,
+ floatToFixedToStr(
+ piecewiseLinearMap(v, axisMapping[k]), 14
+ ),
+ )
+ for k, v in locTuple
+ )
+ + "'"
+ )
+ names.append(name)
fonts.append(glyphsets[locTuple])
+ locations.append(dict(locTuple))
args.ignore_missing = True
args.inputs = []
+ if not locations:
+ locations = [{} for _ in fonts]
+
for filename in args.inputs:
if filename.endswith(".ufo"):
from fontTools.ufoLib import UFOReader
- fonts.append(UFOReader(filename))
+ font = UFOReader(filename)
+ info = SimpleNamespace()
+ font.readInfo(info)
+ upem = info.unitsPerEm
+ fonts.append(font)
else:
from fontTools.ttLib import TTFont
- fonts.append(TTFont(filename))
+ font = TTFont(filename)
+ upem = font["head"].unitsPerEm
+ fonts.append(font)
names.append(basename(filename).rsplit(".", 1)[0])
@@ -514,6 +846,20 @@ def main(args=None):
glyphset = font
glyphsets.append({k: glyphset[k] for k in glyphset.keys()})
+ if args.name:
+ accepted_names = set(args.name)
+ glyphsets = [
+ glyphset
+ for name, glyphset in zip(names, glyphsets)
+ if name in accepted_names
+ ]
+ locations = [
+ location
+ for name, location in zip(names, locations)
+ if name in accepted_names
+ ]
+ names = [name for name in names if name in accepted_names]
+
if not glyphs:
glyphs = sorted(set([gn for glyphset in glyphsets for gn in glyphset.keys()]))
@@ -525,90 +871,248 @@ def main(args=None):
for gn in diff:
glyphset[gn] = None
- problems_gen = test_gen(
- glyphsets, glyphs=glyphs, names=names, ignore_missing=args.ignore_missing
- )
- problems = defaultdict(list)
+ # Normalize locations
+ locations = [normalizeLocation(loc, axis_triples) for loc in locations]
+ tolerance = args.tolerance or DEFAULT_TOLERANCE
+ kinkiness = args.kinkiness if args.kinkiness is not None else DEFAULT_KINKINESS
- if not args.quiet:
- if args.json:
- import json
+ try:
+ log.info("Running on %d glyphsets", len(glyphsets))
+ log.info("Locations: %s", pformat(locations))
+ problems_gen = test_gen(
+ glyphsets,
+ glyphs=glyphs,
+ names=names,
+ locations=locations,
+ upem=upem,
+ ignore_missing=args.ignore_missing,
+ tolerance=tolerance,
+ kinkiness=kinkiness,
+ show_all=args.show_all,
+ )
+ problems = defaultdict(list)
- for glyphname, problem in problems_gen:
- problems[glyphname].append(problem)
+ f = (
+ sys.stdout
+ if args.output is None
+ else open(ensure_parent_dir(args.output), "w")
+ )
- print(json.dumps(problems))
- else:
- last_glyphname = None
- for glyphname, p in problems_gen:
- problems[glyphname].append(p)
-
- if glyphname != last_glyphname:
- print(f"Glyph {glyphname} was not compatible: ")
- last_glyphname = glyphname
-
- if p["type"] == "missing":
- print(" Glyph was missing in master %s" % p["master"])
- if p["type"] == "open_path":
- print(" Glyph has an open path in master %s" % p["master"])
- if p["type"] == "path_count":
- print(
- " Path count differs: %i in %s, %i in %s"
- % (p["value_1"], p["master_1"], p["value_2"], p["master_2"])
+ if not args.quiet:
+ if args.json:
+ import json
+
+ for glyphname, problem in problems_gen:
+ problems[glyphname].append(problem)
+
+ print(json.dumps(problems), file=f)
+ else:
+ last_glyphname = None
+ for glyphname, p in problems_gen:
+ problems[glyphname].append(p)
+
+ if glyphname != last_glyphname:
+ print(f"Glyph {glyphname} was not compatible:", file=f)
+ last_glyphname = glyphname
+ last_master_idxs = None
+
+ master_idxs = (
+ (p["master_idx"])
+ if "master_idx" in p
+ else (p["master_1_idx"], p["master_2_idx"])
)
- if p["type"] == "node_count":
- print(
- " Node count differs in path %i: %i in %s, %i in %s"
- % (
- p["path"],
- p["value_1"],
- p["master_1"],
- p["value_2"],
- p["master_2"],
+ if master_idxs != last_master_idxs:
+ master_names = (
+ (p["master"])
+ if "master" in p
+ else (p["master_1"], p["master_2"])
)
- )
- if p["type"] == "node_incompatibility":
- print(
- " Node %o incompatible in path %i: %s in %s, %s in %s"
- % (
- p["node"],
- p["path"],
- p["value_1"],
- p["master_1"],
- p["value_2"],
- p["master_2"],
+ print(f" Masters: %s:" % ", ".join(master_names), file=f)
+ last_master_idxs = master_idxs
+
+ if p["type"] == InterpolatableProblem.MISSING:
+ print(
+ " Glyph was missing in master %s" % p["master"], file=f
)
- )
- if p["type"] == "contour_order":
- print(
- " Contour order differs: %s in %s, %s in %s"
- % (
- p["value_1"],
- p["master_1"],
- p["value_2"],
- p["master_2"],
+ elif p["type"] == InterpolatableProblem.OPEN_PATH:
+ print(
+ " Glyph has an open path in master %s" % p["master"],
+ file=f,
)
- )
- if p["type"] == "wrong_start_point":
- print(
- " Contour %d start point differs: %s, %s"
- % (
- p["contour"],
- p["master_1"],
- p["master_2"],
+ elif p["type"] == InterpolatableProblem.PATH_COUNT:
+ print(
+ " Path count differs: %i in %s, %i in %s"
+ % (
+ p["value_1"],
+ p["master_1"],
+ p["value_2"],
+ p["master_2"],
+ ),
+ file=f,
)
- )
- if p["type"] == "math_error":
- print(
- " Miscellaneous error in %s: %s"
- % (
- p["master"],
- p["error"],
+ elif p["type"] == InterpolatableProblem.NODE_COUNT:
+ print(
+ " Node count differs in path %i: %i in %s, %i in %s"
+ % (
+ p["path"],
+ p["value_1"],
+ p["master_1"],
+ p["value_2"],
+ p["master_2"],
+ ),
+ file=f,
+ )
+ elif p["type"] == InterpolatableProblem.NODE_INCOMPATIBILITY:
+ print(
+ " Node %o incompatible in path %i: %s in %s, %s in %s"
+ % (
+ p["node"],
+ p["path"],
+ p["value_1"],
+ p["master_1"],
+ p["value_2"],
+ p["master_2"],
+ ),
+ file=f,
+ )
+ elif p["type"] == InterpolatableProblem.CONTOUR_ORDER:
+ print(
+ " Contour order differs: %s in %s, %s in %s"
+ % (
+ p["value_1"],
+ p["master_1"],
+ p["value_2"],
+ p["master_2"],
+ ),
+ file=f,
+ )
+ elif p["type"] == InterpolatableProblem.WRONG_START_POINT:
+ print(
+ " Contour %d start point differs: %s in %s, %s in %s; reversed: %s"
+ % (
+ p["contour"],
+ p["value_1"],
+ p["master_1"],
+ p["value_2"],
+ p["master_2"],
+ p["reversed"],
+ ),
+ file=f,
+ )
+ elif p["type"] == InterpolatableProblem.UNDERWEIGHT:
+ print(
+ " Contour %d interpolation is underweight: %s, %s"
+ % (
+ p["contour"],
+ p["master_1"],
+ p["master_2"],
+ ),
+ file=f,
+ )
+ elif p["type"] == InterpolatableProblem.OVERWEIGHT:
+ print(
+ " Contour %d interpolation is overweight: %s, %s"
+ % (
+ p["contour"],
+ p["master_1"],
+ p["master_2"],
+ ),
+ file=f,
+ )
+ elif p["type"] == InterpolatableProblem.KINK:
+ print(
+ " Contour %d has a kink at %s: %s, %s"
+ % (
+ p["contour"],
+ p["value"],
+ p["master_1"],
+ p["master_2"],
+ ),
+ file=f,
+ )
+ elif p["type"] == InterpolatableProblem.NOTHING:
+ print(
+ " Showing %s and %s"
+ % (
+ p["master_1"],
+ p["master_2"],
+ ),
+ file=f,
)
+ else:
+ for glyphname, problem in problems_gen:
+ problems[glyphname].append(problem)
+
+ problems = sort_problems(problems)
+
+ for p in "ps", "pdf":
+ arg = getattr(args, p)
+ if arg is None:
+ continue
+ log.info("Writing %s to %s", p.upper(), arg)
+ from .interpolatablePlot import InterpolatablePS, InterpolatablePDF
+
+ PlotterClass = InterpolatablePS if p == "ps" else InterpolatablePDF
+
+ with PlotterClass(
+ ensure_parent_dir(arg), glyphsets=glyphsets, names=names
+ ) as doc:
+ doc.add_title_page(
+ original_args_inputs, tolerance=tolerance, kinkiness=kinkiness
+ )
+ if problems:
+ doc.add_summary(problems)
+ doc.add_problems(problems)
+ if not problems and not args.quiet:
+ doc.draw_cupcake()
+ if problems:
+ doc.add_index()
+ doc.add_table_of_contents()
+
+ if args.html:
+ log.info("Writing HTML to %s", args.html)
+ from .interpolatablePlot import InterpolatableSVG
+
+ svgs = []
+ glyph_starts = {}
+ with InterpolatableSVG(svgs, glyphsets=glyphsets, names=names) as svg:
+ svg.add_title_page(
+ original_args_inputs,
+ show_tolerance=False,
+ tolerance=tolerance,
+ kinkiness=kinkiness,
+ )
+ for glyph, glyph_problems in problems.items():
+ glyph_starts[len(svgs)] = glyph
+ svg.add_problems(
+ {glyph: glyph_problems},
+ show_tolerance=False,
+ show_page_number=False,
)
- else:
- for glyphname, problem in problems_gen:
- problems[glyphname].append(problem)
+ if not problems and not args.quiet:
+ svg.draw_cupcake()
+
+ import base64
+
+ with open(ensure_parent_dir(args.html), "wb") as f:
+ f.write(b"<!DOCTYPE html>\n")
+ f.write(
+ b'<html><body align="center" style="font-family: sans-serif; text-color: #222">\n'
+ )
+ f.write(b"<title>fonttools varLib.interpolatable report</title>\n")
+ for i, svg in enumerate(svgs):
+ if i in glyph_starts:
+ f.write(f"<h1>Glyph {glyph_starts[i]}</h1>\n".encode("utf-8"))
+ f.write("<img src='data:image/svg+xml;base64,".encode("utf-8"))
+ f.write(base64.b64encode(svg))
+ f.write(b"' />\n")
+ f.write(b"<hr>\n")
+ f.write(b"</body></html>\n")
+
+ except Exception as e:
+ e.args += original_args_inputs
+ log.error(e)
+ raise
if problems:
return problems
diff --git a/Lib/fontTools/varLib/interpolatableHelpers.py b/Lib/fontTools/varLib/interpolatableHelpers.py
new file mode 100644
index 00000000..2a3540ff
--- /dev/null
+++ b/Lib/fontTools/varLib/interpolatableHelpers.py
@@ -0,0 +1,380 @@
+from fontTools.ttLib.ttGlyphSet import LerpGlyphSet
+from fontTools.pens.basePen import AbstractPen, BasePen, DecomposingPen
+from fontTools.pens.pointPen import AbstractPointPen, SegmentToPointPen
+from fontTools.pens.recordingPen import RecordingPen, DecomposingRecordingPen
+from fontTools.misc.transform import Transform
+from collections import defaultdict, deque
+from math import sqrt, copysign, atan2, pi
+from enum import Enum
+import itertools
+
+import logging
+
+log = logging.getLogger("fontTools.varLib.interpolatable")
+
+
+class InterpolatableProblem:
+ NOTHING = "nothing"
+ MISSING = "missing"
+ OPEN_PATH = "open_path"
+ PATH_COUNT = "path_count"
+ NODE_COUNT = "node_count"
+ NODE_INCOMPATIBILITY = "node_incompatibility"
+ CONTOUR_ORDER = "contour_order"
+ WRONG_START_POINT = "wrong_start_point"
+ KINK = "kink"
+ UNDERWEIGHT = "underweight"
+ OVERWEIGHT = "overweight"
+
+ severity = {
+ MISSING: 1,
+ OPEN_PATH: 2,
+ PATH_COUNT: 3,
+ NODE_COUNT: 4,
+ NODE_INCOMPATIBILITY: 5,
+ CONTOUR_ORDER: 6,
+ WRONG_START_POINT: 7,
+ KINK: 8,
+ UNDERWEIGHT: 9,
+ OVERWEIGHT: 10,
+ NOTHING: 11,
+ }
+
+
+def sort_problems(problems):
+ """Sort problems by severity, then by glyph name, then by problem message."""
+ return dict(
+ sorted(
+ problems.items(),
+ key=lambda _: -min(
+ (
+ (InterpolatableProblem.severity[p["type"]] + p.get("tolerance", 0))
+ for p in _[1]
+ ),
+ ),
+ reverse=True,
+ )
+ )
+
+
+def rot_list(l, k):
+ """Rotate list by k items forward. Ie. item at position 0 will be
+ at position k in returned list. Negative k is allowed."""
+ return l[-k:] + l[:-k]
+
+
+class PerContourPen(BasePen):
+ def __init__(self, Pen, glyphset=None):
+ BasePen.__init__(self, glyphset)
+ self._glyphset = glyphset
+ self._Pen = Pen
+ self._pen = None
+ self.value = []
+
+ def _moveTo(self, p0):
+ self._newItem()
+ self._pen.moveTo(p0)
+
+ def _lineTo(self, p1):
+ self._pen.lineTo(p1)
+
+ def _qCurveToOne(self, p1, p2):
+ self._pen.qCurveTo(p1, p2)
+
+ def _curveToOne(self, p1, p2, p3):
+ self._pen.curveTo(p1, p2, p3)
+
+ def _closePath(self):
+ self._pen.closePath()
+ self._pen = None
+
+ def _endPath(self):
+ self._pen.endPath()
+ self._pen = None
+
+ def _newItem(self):
+ self._pen = pen = self._Pen()
+ self.value.append(pen)
+
+
+class PerContourOrComponentPen(PerContourPen):
+ def addComponent(self, glyphName, transformation):
+ self._newItem()
+ self.value[-1].addComponent(glyphName, transformation)
+
+
+class SimpleRecordingPointPen(AbstractPointPen):
+ def __init__(self):
+ self.value = []
+
+ def beginPath(self, identifier=None, **kwargs):
+ pass
+
+ def endPath(self) -> None:
+ pass
+
+ def addPoint(self, pt, segmentType=None):
+ self.value.append((pt, False if segmentType is None else True))
+
+
+def vdiff_hypot2(v0, v1):
+ s = 0
+ for x0, x1 in zip(v0, v1):
+ d = x1 - x0
+ s += d * d
+ return s
+
+
+def vdiff_hypot2_complex(v0, v1):
+ s = 0
+ for x0, x1 in zip(v0, v1):
+ d = x1 - x0
+ s += d.real * d.real + d.imag * d.imag
+ # This does the same but seems to be slower:
+ # s += (d * d.conjugate()).real
+ return s
+
+
+def matching_cost(G, matching):
+ return sum(G[i][j] for i, j in enumerate(matching))
+
+
+def min_cost_perfect_bipartite_matching_scipy(G):
+ n = len(G)
+ rows, cols = linear_sum_assignment(G)
+ assert (rows == list(range(n))).all()
+ return list(cols), matching_cost(G, cols)
+
+
+def min_cost_perfect_bipartite_matching_munkres(G):
+ n = len(G)
+ cols = [None] * n
+ for row, col in Munkres().compute(G):
+ cols[row] = col
+ return cols, matching_cost(G, cols)
+
+
+def min_cost_perfect_bipartite_matching_bruteforce(G):
+ n = len(G)
+
+ if n > 6:
+ raise Exception("Install Python module 'munkres' or 'scipy >= 0.17.0'")
+
+ # Otherwise just brute-force
+ permutations = itertools.permutations(range(n))
+ best = list(next(permutations))
+ best_cost = matching_cost(G, best)
+ for p in permutations:
+ cost = matching_cost(G, p)
+ if cost < best_cost:
+ best, best_cost = list(p), cost
+ return best, best_cost
+
+
+try:
+ from scipy.optimize import linear_sum_assignment
+
+ min_cost_perfect_bipartite_matching = min_cost_perfect_bipartite_matching_scipy
+except ImportError:
+ try:
+ from munkres import Munkres
+
+ min_cost_perfect_bipartite_matching = (
+ min_cost_perfect_bipartite_matching_munkres
+ )
+ except ImportError:
+ min_cost_perfect_bipartite_matching = (
+ min_cost_perfect_bipartite_matching_bruteforce
+ )
+
+
+def contour_vector_from_stats(stats):
+ # Don't change the order of items here.
+ # It's okay to add to the end, but otherwise, other
+ # code depends on it. Search for "covariance".
+ size = sqrt(abs(stats.area))
+ return (
+ copysign((size), stats.area),
+ stats.meanX,
+ stats.meanY,
+ stats.stddevX * 2,
+ stats.stddevY * 2,
+ stats.correlation * size,
+ )
+
+
+def matching_for_vectors(m0, m1):
+ n = len(m0)
+
+ identity_matching = list(range(n))
+
+ costs = [[vdiff_hypot2(v0, v1) for v1 in m1] for v0 in m0]
+ (
+ matching,
+ matching_cost,
+ ) = min_cost_perfect_bipartite_matching(costs)
+ identity_cost = sum(costs[i][i] for i in range(n))
+ return matching, matching_cost, identity_cost
+
+
+def points_characteristic_bits(points):
+ bits = 0
+ for pt, b in reversed(points):
+ bits = (bits << 1) | b
+ return bits
+
+
+_NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR = 4
+
+
+def points_complex_vector(points):
+ vector = []
+ if not points:
+ return vector
+ points = [complex(*pt) for pt, _ in points]
+ n = len(points)
+ assert _NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR == 4
+ points.extend(points[: _NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR - 1])
+ while len(points) < _NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR:
+ points.extend(points[: _NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR - 1])
+ for i in range(n):
+ # The weights are magic numbers.
+
+ # The point itself
+ p0 = points[i]
+ vector.append(p0)
+
+ # The vector to the next point
+ p1 = points[i + 1]
+ d0 = p1 - p0
+ vector.append(d0 * 3)
+
+ # The turn vector
+ p2 = points[i + 2]
+ d1 = p2 - p1
+ vector.append(d1 - d0)
+
+ # The angle to the next point, as a cross product;
+ # Square root of, to match dimentionality of distance.
+ cross = d0.real * d1.imag - d0.imag * d1.real
+ cross = copysign(sqrt(abs(cross)), cross)
+ vector.append(cross * 4)
+
+ return vector
+
+
+def add_isomorphisms(points, isomorphisms, reverse):
+ reference_bits = points_characteristic_bits(points)
+ n = len(points)
+
+ # if points[0][0] == points[-1][0]:
+ # abort
+
+ if reverse:
+ points = points[::-1]
+ bits = points_characteristic_bits(points)
+ else:
+ bits = reference_bits
+
+ vector = points_complex_vector(points)
+
+ assert len(vector) % n == 0
+ mult = len(vector) // n
+ mask = (1 << n) - 1
+
+ for i in range(n):
+ b = ((bits << (n - i)) & mask) | (bits >> i)
+ if b == reference_bits:
+ isomorphisms.append(
+ (rot_list(vector, -i * mult), n - 1 - i if reverse else i, reverse)
+ )
+
+
+def find_parents_and_order(glyphsets, locations):
+ parents = [None] + list(range(len(glyphsets) - 1))
+ order = list(range(len(glyphsets)))
+ if locations:
+ # Order base master first
+ bases = (i for i, l in enumerate(locations) if all(v == 0 for v in l.values()))
+ if bases:
+ base = next(bases)
+ logging.info("Base master index %s, location %s", base, locations[base])
+ else:
+ base = 0
+ logging.warning("No base master location found")
+
+ # Form a minimum spanning tree of the locations
+ try:
+ from scipy.sparse.csgraph import minimum_spanning_tree
+
+ graph = [[0] * len(locations) for _ in range(len(locations))]
+ axes = set()
+ for l in locations:
+ axes.update(l.keys())
+ axes = sorted(axes)
+ vectors = [tuple(l.get(k, 0) for k in axes) for l in locations]
+ for i, j in itertools.combinations(range(len(locations)), 2):
+ graph[i][j] = vdiff_hypot2(vectors[i], vectors[j])
+
+ tree = minimum_spanning_tree(graph)
+ rows, cols = tree.nonzero()
+ graph = defaultdict(set)
+ for row, col in zip(rows, cols):
+ graph[row].add(col)
+ graph[col].add(row)
+
+ # Traverse graph from the base and assign parents
+ parents = [None] * len(locations)
+ order = []
+ visited = set()
+ queue = deque([base])
+ while queue:
+ i = queue.popleft()
+ visited.add(i)
+ order.append(i)
+ for j in sorted(graph[i]):
+ if j not in visited:
+ parents[j] = i
+ queue.append(j)
+
+ except ImportError:
+ pass
+
+ log.info("Parents: %s", parents)
+ log.info("Order: %s", order)
+ return parents, order
+
+
+def transform_from_stats(stats, inverse=False):
+ # https://cookierobotics.com/007/
+ a = stats.varianceX
+ b = stats.covariance
+ c = stats.varianceY
+
+ delta = (((a - c) * 0.5) ** 2 + b * b) ** 0.5
+ lambda1 = (a + c) * 0.5 + delta # Major eigenvalue
+ lambda2 = (a + c) * 0.5 - delta # Minor eigenvalue
+ theta = atan2(lambda1 - a, b) if b != 0 else (pi * 0.5 if a < c else 0)
+ trans = Transform()
+
+ if lambda2 < 0:
+ # XXX This is a hack.
+ # The problem is that the covariance matrix is singular.
+ # This happens when the contour is a line, or a circle.
+ # In that case, the covariance matrix is not a good
+ # representation of the contour.
+ # We should probably detect this earlier and avoid
+ # computing the covariance matrix in the first place.
+ # But for now, we just avoid the division by zero.
+ lambda2 = 0
+
+ if inverse:
+ trans = trans.translate(-stats.meanX, -stats.meanY)
+ trans = trans.rotate(-theta)
+ trans = trans.scale(1 / sqrt(lambda1), 1 / sqrt(lambda2))
+ else:
+ trans = trans.scale(sqrt(lambda1), sqrt(lambda2))
+ trans = trans.rotate(theta)
+ trans = trans.translate(stats.meanX, stats.meanY)
+
+ return trans
diff --git a/Lib/fontTools/varLib/interpolatablePlot.py b/Lib/fontTools/varLib/interpolatablePlot.py
new file mode 100644
index 00000000..3c206c6e
--- /dev/null
+++ b/Lib/fontTools/varLib/interpolatablePlot.py
@@ -0,0 +1,1269 @@
+from .interpolatableHelpers import *
+from fontTools.ttLib import TTFont
+from fontTools.ttLib.ttGlyphSet import LerpGlyphSet
+from fontTools.pens.recordingPen import (
+ RecordingPen,
+ DecomposingRecordingPen,
+ RecordingPointPen,
+)
+from fontTools.pens.boundsPen import ControlBoundsPen
+from fontTools.pens.cairoPen import CairoPen
+from fontTools.pens.pointPen import (
+ SegmentToPointPen,
+ PointToSegmentPen,
+ ReverseContourPointPen,
+)
+from fontTools.varLib.interpolatableHelpers import (
+ PerContourOrComponentPen,
+ SimpleRecordingPointPen,
+)
+from itertools import cycle
+from functools import wraps
+from io import BytesIO
+import cairo
+import math
+import os
+import logging
+
+log = logging.getLogger("fontTools.varLib.interpolatable")
+
+
+class OverridingDict(dict):
+ def __init__(self, parent_dict):
+ self.parent_dict = parent_dict
+
+ def __missing__(self, key):
+ return self.parent_dict[key]
+
+
+class InterpolatablePlot:
+ width = 8.5 * 72
+ height = 11 * 72
+ pad = 0.1 * 72
+ title_font_size = 24
+ font_size = 16
+ page_number = 1
+ head_color = (0.3, 0.3, 0.3)
+ label_color = (0.2, 0.2, 0.2)
+ border_color = (0.9, 0.9, 0.9)
+ border_width = 0.5
+ fill_color = (0.8, 0.8, 0.8)
+ stroke_color = (0.1, 0.1, 0.1)
+ stroke_width = 1
+ oncurve_node_color = (0, 0.8, 0, 0.7)
+ oncurve_node_diameter = 6
+ offcurve_node_color = (0, 0.5, 0, 0.7)
+ offcurve_node_diameter = 4
+ handle_color = (0, 0.5, 0, 0.7)
+ handle_width = 0.5
+ corrected_start_point_color = (0, 0.9, 0, 0.7)
+ corrected_start_point_size = 7
+ wrong_start_point_color = (1, 0, 0, 0.7)
+ start_point_color = (0, 0, 1, 0.7)
+ start_arrow_length = 9
+ kink_point_size = 7
+ kink_point_color = (1, 0, 1, 0.7)
+ kink_circle_size = 15
+ kink_circle_stroke_width = 1
+ kink_circle_color = (1, 0, 1, 0.7)
+ contour_colors = ((1, 0, 0), (0, 0, 1), (0, 1, 0), (1, 1, 0), (1, 0, 1), (0, 1, 1))
+ contour_alpha = 0.5
+ weight_issue_contour_color = (0, 0, 0, 0.4)
+ no_issues_label = "Your font's good! Have a cupcake..."
+ no_issues_label_color = (0, 0.5, 0)
+ cupcake_color = (0.3, 0, 0.3)
+ cupcake = r"""
+ ,@.
+ ,@.@@,.
+ ,@@,.@@@. @.@@@,.
+ ,@@. @@@. @@. @@,.
+ ,@@@.@,.@. @. @@@@,.@.@@,.
+ ,@@.@. @@.@@. @,. .@' @' @@,
+ ,@@. @. .@@.@@@. @@' @,
+,@. @@. @,
+@. @,@@,. , .@@,
+@,. .@,@@,. .@@,. , .@@, @, @,
+@. .@. @ @@,. , @
+ @,.@@. @,. @@,. @. @,. @'
+ @@||@,. @'@,. @@,. @@ @,. @'@@, @'
+ \\@@@@' @,. @'@@@@' @@,. @@@' //@@@'
+ |||||||| @@,. @@' ||||||| |@@@|@|| ||
+ \\\\\\\ ||@@@|| ||||||| ||||||| //
+ ||||||| |||||| |||||| |||||| ||
+ \\\\\\ |||||| |||||| |||||| //
+ |||||| ||||| ||||| ||||| ||
+ \\\\\ ||||| ||||| ||||| //
+ ||||| |||| ||||| |||| ||
+ \\\\ |||| |||| |||| //
+ ||||||||||||||||||||||||
+"""
+ emoticon_color = (0, 0.3, 0.3)
+ shrug = r"""\_(")_/"""
+ underweight = r"""
+ o
+/|\
+/ \
+"""
+ overweight = r"""
+ o
+/O\
+/ \
+"""
+ yay = r""" \o/ """
+
+ def __init__(self, out, glyphsets, names=None, **kwargs):
+ self.out = out
+ self.glyphsets = glyphsets
+ self.names = names or [repr(g) for g in glyphsets]
+ self.toc = {}
+
+ for k, v in kwargs.items():
+ if not hasattr(self, k):
+ raise TypeError("Unknown keyword argument: %s" % k)
+ setattr(self, k, v)
+
+ self.panel_width = self.width / 2 - self.pad * 3
+ self.panel_height = (
+ self.height / 2 - self.pad * 6 - self.font_size * 2 - self.title_font_size
+ )
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, type, value, traceback):
+ pass
+
+ def show_page(self):
+ self.page_number += 1
+
+ def add_title_page(
+ self, files, *, show_tolerance=True, tolerance=None, kinkiness=None
+ ):
+ pad = self.pad
+ width = self.width - 3 * self.pad
+ height = self.height - 2 * self.pad
+ x = y = pad
+
+ self.draw_label(
+ "Problem report for:",
+ x=x,
+ y=y,
+ bold=True,
+ width=width,
+ font_size=self.title_font_size,
+ )
+ y += self.title_font_size
+
+ import hashlib
+
+ for file in files:
+ base_file = os.path.basename(file)
+ y += self.font_size + self.pad
+ self.draw_label(base_file, x=x, y=y, bold=True, width=width)
+ y += self.font_size + self.pad
+
+ try:
+ h = hashlib.sha1(open(file, "rb").read()).hexdigest()
+ self.draw_label("sha1: %s" % h, x=x + pad, y=y, width=width)
+ y += self.font_size
+ except IsADirectoryError:
+ pass
+
+ if file.endswith(".ttf"):
+ ttFont = TTFont(file)
+ name = ttFont["name"] if "name" in ttFont else None
+ if name:
+ for what, nameIDs in (
+ ("Family name", (21, 16, 1)),
+ ("Version", (5,)),
+ ):
+ n = name.getFirstDebugName(nameIDs)
+ if n is None:
+ continue
+ self.draw_label(
+ "%s: %s" % (what, n), x=x + pad, y=y, width=width
+ )
+ y += self.font_size + self.pad
+ elif file.endswith((".glyphs", ".glyphspackage")):
+ from glyphsLib import GSFont
+
+ f = GSFont(file)
+ for what, field in (
+ ("Family name", "familyName"),
+ ("VersionMajor", "versionMajor"),
+ ("VersionMinor", "_versionMinor"),
+ ):
+ self.draw_label(
+ "%s: %s" % (what, getattr(f, field)),
+ x=x + pad,
+ y=y,
+ width=width,
+ )
+ y += self.font_size + self.pad
+
+ self.draw_legend(
+ show_tolerance=show_tolerance, tolerance=tolerance, kinkiness=kinkiness
+ )
+ self.show_page()
+
+ def draw_legend(self, *, show_tolerance=True, tolerance=None, kinkiness=None):
+ cr = cairo.Context(self.surface)
+
+ x = self.pad
+ y = self.height - self.pad - self.font_size * 2
+ width = self.width - 2 * self.pad
+
+ xx = x + self.pad * 2
+ xxx = x + self.pad * 4
+
+ if show_tolerance:
+ self.draw_label(
+ "Tolerance: badness; closer to zero the worse", x=xxx, y=y, width=width
+ )
+ y -= self.pad + self.font_size
+
+ self.draw_label("Underweight contours", x=xxx, y=y, width=width)
+ cr.rectangle(xx - self.pad * 0.7, y, 1.5 * self.pad, self.font_size)
+ cr.set_source_rgb(*self.fill_color)
+ cr.fill_preserve()
+ if self.stroke_color:
+ cr.set_source_rgb(*self.stroke_color)
+ cr.set_line_width(self.stroke_width)
+ cr.stroke_preserve()
+ cr.set_source_rgba(*self.weight_issue_contour_color)
+ cr.fill()
+ y -= self.pad + self.font_size
+
+ self.draw_label(
+ "Colored contours: contours with the wrong order", x=xxx, y=y, width=width
+ )
+ cr.rectangle(xx - self.pad * 0.7, y, 1.5 * self.pad, self.font_size)
+ if self.fill_color:
+ cr.set_source_rgb(*self.fill_color)
+ cr.fill_preserve()
+ if self.stroke_color:
+ cr.set_source_rgb(*self.stroke_color)
+ cr.set_line_width(self.stroke_width)
+ cr.stroke_preserve()
+ cr.set_source_rgba(*self.contour_colors[0], self.contour_alpha)
+ cr.fill()
+ y -= self.pad + self.font_size
+
+ self.draw_label("Kink artifact", x=xxx, y=y, width=width)
+ self.draw_circle(
+ cr,
+ x=xx,
+ y=y + self.font_size * 0.5,
+ diameter=self.kink_circle_size,
+ stroke_width=self.kink_circle_stroke_width,
+ color=self.kink_circle_color,
+ )
+ y -= self.pad + self.font_size
+
+ self.draw_label("Point causing kink in the contour", x=xxx, y=y, width=width)
+ self.draw_dot(
+ cr,
+ x=xx,
+ y=y + self.font_size * 0.5,
+ diameter=self.kink_point_size,
+ color=self.kink_point_color,
+ )
+ y -= self.pad + self.font_size
+
+ self.draw_label("Suggested new contour start point", x=xxx, y=y, width=width)
+ self.draw_dot(
+ cr,
+ x=xx,
+ y=y + self.font_size * 0.5,
+ diameter=self.corrected_start_point_size,
+ color=self.corrected_start_point_color,
+ )
+ y -= self.pad + self.font_size
+
+ self.draw_label(
+ "Contour start point in contours with wrong direction",
+ x=xxx,
+ y=y,
+ width=width,
+ )
+ self.draw_arrow(
+ cr,
+ x=xx - self.start_arrow_length * 0.3,
+ y=y + self.font_size * 0.5,
+ color=self.wrong_start_point_color,
+ )
+ y -= self.pad + self.font_size
+
+ self.draw_label(
+ "Contour start point when the first two points overlap",
+ x=xxx,
+ y=y,
+ width=width,
+ )
+ self.draw_dot(
+ cr,
+ x=xx,
+ y=y + self.font_size * 0.5,
+ diameter=self.corrected_start_point_size,
+ color=self.start_point_color,
+ )
+ y -= self.pad + self.font_size
+
+ self.draw_label("Contour start point and direction", x=xxx, y=y, width=width)
+ self.draw_arrow(
+ cr,
+ x=xx - self.start_arrow_length * 0.3,
+ y=y + self.font_size * 0.5,
+ color=self.start_point_color,
+ )
+ y -= self.pad + self.font_size
+
+ self.draw_label("Legend:", x=x, y=y, width=width, bold=True)
+ y -= self.pad + self.font_size
+
+ if kinkiness is not None:
+ self.draw_label(
+ "Kink-reporting aggressiveness: %g" % kinkiness,
+ x=xxx,
+ y=y,
+ width=width,
+ )
+ y -= self.pad + self.font_size
+
+ if tolerance is not None:
+ self.draw_label(
+ "Error tolerance: %g" % tolerance,
+ x=xxx,
+ y=y,
+ width=width,
+ )
+ y -= self.pad + self.font_size
+
+ self.draw_label("Parameters:", x=x, y=y, width=width, bold=True)
+ y -= self.pad + self.font_size
+
+ def add_summary(self, problems):
+ pad = self.pad
+ width = self.width - 3 * self.pad
+ height = self.height - 2 * self.pad
+ x = y = pad
+
+ self.draw_label(
+ "Summary of problems",
+ x=x,
+ y=y,
+ bold=True,
+ width=width,
+ font_size=self.title_font_size,
+ )
+ y += self.title_font_size
+
+ glyphs_per_problem = defaultdict(set)
+ for glyphname, problems in sorted(problems.items()):
+ for problem in problems:
+ glyphs_per_problem[problem["type"]].add(glyphname)
+
+ if "nothing" in glyphs_per_problem:
+ del glyphs_per_problem["nothing"]
+
+ for problem_type in sorted(
+ glyphs_per_problem, key=lambda x: InterpolatableProblem.severity[x]
+ ):
+ y += self.font_size
+ self.draw_label(
+ "%s: %d" % (problem_type, len(glyphs_per_problem[problem_type])),
+ x=x,
+ y=y,
+ width=width,
+ bold=True,
+ )
+ y += self.font_size
+
+ for glyphname in sorted(glyphs_per_problem[problem_type]):
+ if y + self.font_size > height:
+ self.show_page()
+ y = self.font_size + pad
+ self.draw_label(glyphname, x=x + 2 * pad, y=y, width=width - 2 * pad)
+ y += self.font_size
+
+ self.show_page()
+
+ def _add_listing(self, title, items):
+ pad = self.pad
+ width = self.width - 2 * self.pad
+ height = self.height - 2 * self.pad
+ x = y = pad
+
+ self.draw_label(
+ title, x=x, y=y, bold=True, width=width, font_size=self.title_font_size
+ )
+ y += self.title_font_size + self.pad
+
+ last_glyphname = None
+ for page_no, (glyphname, problems) in items:
+ if glyphname == last_glyphname:
+ continue
+ last_glyphname = glyphname
+ if y + self.font_size > height:
+ self.show_page()
+ y = self.font_size + pad
+ self.draw_label(glyphname, x=x + 5 * pad, y=y, width=width - 2 * pad)
+ self.draw_label(str(page_no), x=x, y=y, width=4 * pad, align=1)
+ y += self.font_size
+
+ self.show_page()
+
+ def add_table_of_contents(self):
+ self._add_listing("Table of contents", sorted(self.toc.items()))
+
+ def add_index(self):
+ self._add_listing("Index", sorted(self.toc.items(), key=lambda x: x[1][0]))
+
+ def add_problems(self, problems, *, show_tolerance=True, show_page_number=True):
+ for glyph, glyph_problems in problems.items():
+ last_masters = None
+ current_glyph_problems = []
+ for p in glyph_problems:
+ masters = (
+ p["master_idx"]
+ if "master_idx" in p
+ else (p["master_1_idx"], p["master_2_idx"])
+ )
+ if masters == last_masters:
+ current_glyph_problems.append(p)
+ continue
+ # Flush
+ if current_glyph_problems:
+ self.add_problem(
+ glyph,
+ current_glyph_problems,
+ show_tolerance=show_tolerance,
+ show_page_number=show_page_number,
+ )
+ self.show_page()
+ current_glyph_problems = []
+ last_masters = masters
+ current_glyph_problems.append(p)
+ if current_glyph_problems:
+ self.add_problem(
+ glyph,
+ current_glyph_problems,
+ show_tolerance=show_tolerance,
+ show_page_number=show_page_number,
+ )
+ self.show_page()
+
+ def add_problem(
+ self, glyphname, problems, *, show_tolerance=True, show_page_number=True
+ ):
+ if type(problems) not in (list, tuple):
+ problems = [problems]
+
+ self.toc[self.page_number] = (glyphname, problems)
+
+ problem_type = problems[0]["type"]
+ problem_types = set(problem["type"] for problem in problems)
+ if not all(pt == problem_type for pt in problem_types):
+ problem_type = ", ".join(sorted({problem["type"] for problem in problems}))
+
+ log.info("Drawing %s: %s", glyphname, problem_type)
+
+ master_keys = (
+ ("master_idx",)
+ if "master_idx" in problems[0]
+ else ("master_1_idx", "master_2_idx")
+ )
+ master_indices = [problems[0][k] for k in master_keys]
+
+ if problem_type == InterpolatableProblem.MISSING:
+ sample_glyph = next(
+ i for i, m in enumerate(self.glyphsets) if m[glyphname] is not None
+ )
+ master_indices.insert(0, sample_glyph)
+
+ x = self.pad
+ y = self.pad
+
+ self.draw_label(
+ "Glyph name: " + glyphname,
+ x=x,
+ y=y,
+ color=self.head_color,
+ align=0,
+ bold=True,
+ font_size=self.title_font_size,
+ )
+ tolerance = min(p.get("tolerance", 1) for p in problems)
+ if tolerance < 1 and show_tolerance:
+ self.draw_label(
+ "tolerance: %.2f" % tolerance,
+ x=x,
+ y=y,
+ width=self.width - 2 * self.pad,
+ align=1,
+ bold=True,
+ )
+ y += self.title_font_size + self.pad
+ self.draw_label(
+ "Problems: " + problem_type,
+ x=x,
+ y=y,
+ width=self.width - 2 * self.pad,
+ color=self.head_color,
+ bold=True,
+ )
+ y += self.font_size + self.pad * 2
+
+ scales = []
+ for which, master_idx in enumerate(master_indices):
+ glyphset = self.glyphsets[master_idx]
+ name = self.names[master_idx]
+
+ self.draw_label(
+ name,
+ x=x,
+ y=y,
+ color=self.label_color,
+ width=self.panel_width,
+ align=0.5,
+ )
+ y += self.font_size + self.pad
+
+ if glyphset[glyphname] is not None:
+ scales.append(
+ self.draw_glyph(glyphset, glyphname, problems, which, x=x, y=y)
+ )
+ else:
+ self.draw_emoticon(self.shrug, x=x, y=y)
+ y += self.panel_height + self.font_size + self.pad
+
+ if any(
+ pt
+ in (
+ InterpolatableProblem.NOTHING,
+ InterpolatableProblem.WRONG_START_POINT,
+ InterpolatableProblem.CONTOUR_ORDER,
+ InterpolatableProblem.KINK,
+ InterpolatableProblem.UNDERWEIGHT,
+ InterpolatableProblem.OVERWEIGHT,
+ )
+ for pt in problem_types
+ ):
+ x = self.pad + self.panel_width + self.pad
+ y = self.pad
+ y += self.title_font_size + self.pad * 2
+ y += self.font_size + self.pad
+
+ glyphset1 = self.glyphsets[master_indices[0]]
+ glyphset2 = self.glyphsets[master_indices[1]]
+
+ # Draw the mid-way of the two masters
+
+ self.draw_label(
+ "midway interpolation",
+ x=x,
+ y=y,
+ color=self.head_color,
+ width=self.panel_width,
+ align=0.5,
+ )
+ y += self.font_size + self.pad
+
+ midway_glyphset = LerpGlyphSet(glyphset1, glyphset2)
+ self.draw_glyph(
+ midway_glyphset,
+ glyphname,
+ [{"type": "midway"}]
+ + [
+ p
+ for p in problems
+ if p["type"]
+ in (
+ InterpolatableProblem.KINK,
+ InterpolatableProblem.UNDERWEIGHT,
+ InterpolatableProblem.OVERWEIGHT,
+ )
+ ],
+ None,
+ x=x,
+ y=y,
+ scale=min(scales),
+ )
+
+ y += self.panel_height + self.font_size + self.pad
+
+ if any(
+ pt
+ in (
+ InterpolatableProblem.WRONG_START_POINT,
+ InterpolatableProblem.CONTOUR_ORDER,
+ InterpolatableProblem.KINK,
+ )
+ for pt in problem_types
+ ):
+ # Draw the proposed fix
+
+ self.draw_label(
+ "proposed fix",
+ x=x,
+ y=y,
+ color=self.head_color,
+ width=self.panel_width,
+ align=0.5,
+ )
+ y += self.font_size + self.pad
+
+ overriding1 = OverridingDict(glyphset1)
+ overriding2 = OverridingDict(glyphset2)
+ perContourPen1 = PerContourOrComponentPen(
+ RecordingPen, glyphset=overriding1
+ )
+ perContourPen2 = PerContourOrComponentPen(
+ RecordingPen, glyphset=overriding2
+ )
+ glyphset1[glyphname].draw(perContourPen1)
+ glyphset2[glyphname].draw(perContourPen2)
+
+ for problem in problems:
+ if problem["type"] == InterpolatableProblem.CONTOUR_ORDER:
+ fixed_contours = [
+ perContourPen2.value[i] for i in problems[0]["value_2"]
+ ]
+ perContourPen2.value = fixed_contours
+
+ for problem in problems:
+ if problem["type"] == InterpolatableProblem.WRONG_START_POINT:
+ # Save the wrong contours
+ wrongContour1 = perContourPen1.value[problem["contour"]]
+ wrongContour2 = perContourPen2.value[problem["contour"]]
+
+ # Convert the wrong contours to point pens
+ points1 = RecordingPointPen()
+ converter = SegmentToPointPen(points1, False)
+ wrongContour1.replay(converter)
+ points2 = RecordingPointPen()
+ converter = SegmentToPointPen(points2, False)
+ wrongContour2.replay(converter)
+
+ proposed_start = problem["value_2"]
+
+ # See if we need reversing; fragile but worth a try
+ if problem["reversed"]:
+ new_points2 = RecordingPointPen()
+ reversedPen = ReverseContourPointPen(new_points2)
+ points2.replay(reversedPen)
+ points2 = new_points2
+ proposed_start = len(points2.value) - 2 - proposed_start
+
+ # Rotate points2 so that the first point is the same as in points1
+ beginPath = points2.value[:1]
+ endPath = points2.value[-1:]
+ pts = points2.value[1:-1]
+ pts = pts[proposed_start:] + pts[:proposed_start]
+ points2.value = beginPath + pts + endPath
+
+ # Convert the point pens back to segment pens
+ segment1 = RecordingPen()
+ converter = PointToSegmentPen(segment1, True)
+ points1.replay(converter)
+ segment2 = RecordingPen()
+ converter = PointToSegmentPen(segment2, True)
+ points2.replay(converter)
+
+ # Replace the wrong contours
+ wrongContour1.value = segment1.value
+ wrongContour2.value = segment2.value
+ perContourPen1.value[problem["contour"]] = wrongContour1
+ perContourPen2.value[problem["contour"]] = wrongContour2
+
+ for problem in problems:
+ # If we have a kink, try to fix it.
+ if problem["type"] == InterpolatableProblem.KINK:
+ # Save the wrong contours
+ wrongContour1 = perContourPen1.value[problem["contour"]]
+ wrongContour2 = perContourPen2.value[problem["contour"]]
+
+ # Convert the wrong contours to point pens
+ points1 = RecordingPointPen()
+ converter = SegmentToPointPen(points1, False)
+ wrongContour1.replay(converter)
+ points2 = RecordingPointPen()
+ converter = SegmentToPointPen(points2, False)
+ wrongContour2.replay(converter)
+
+ i = problem["value"]
+
+ # Position points to be around the same ratio
+ # beginPath / endPath dance
+ j = i + 1
+ pt0 = points1.value[j][1][0]
+ pt1 = points2.value[j][1][0]
+ j_prev = (i - 1) % (len(points1.value) - 2) + 1
+ pt0_prev = points1.value[j_prev][1][0]
+ pt1_prev = points2.value[j_prev][1][0]
+ j_next = (i + 1) % (len(points1.value) - 2) + 1
+ pt0_next = points1.value[j_next][1][0]
+ pt1_next = points2.value[j_next][1][0]
+
+ pt0 = complex(*pt0)
+ pt1 = complex(*pt1)
+ pt0_prev = complex(*pt0_prev)
+ pt1_prev = complex(*pt1_prev)
+ pt0_next = complex(*pt0_next)
+ pt1_next = complex(*pt1_next)
+
+ # Find the ratio of the distance between the points
+ r0 = abs(pt0 - pt0_prev) / abs(pt0_next - pt0_prev)
+ r1 = abs(pt1 - pt1_prev) / abs(pt1_next - pt1_prev)
+ r_mid = (r0 + r1) / 2
+
+ pt0 = pt0_prev + r_mid * (pt0_next - pt0_prev)
+ pt1 = pt1_prev + r_mid * (pt1_next - pt1_prev)
+
+ points1.value[j] = (
+ points1.value[j][0],
+ (((pt0.real, pt0.imag),) + points1.value[j][1][1:]),
+ points1.value[j][2],
+ )
+ points2.value[j] = (
+ points2.value[j][0],
+ (((pt1.real, pt1.imag),) + points2.value[j][1][1:]),
+ points2.value[j][2],
+ )
+
+ # Convert the point pens back to segment pens
+ segment1 = RecordingPen()
+ converter = PointToSegmentPen(segment1, True)
+ points1.replay(converter)
+ segment2 = RecordingPen()
+ converter = PointToSegmentPen(segment2, True)
+ points2.replay(converter)
+
+ # Replace the wrong contours
+ wrongContour1.value = segment1.value
+ wrongContour2.value = segment2.value
+
+ # Assemble
+ fixed1 = RecordingPen()
+ fixed2 = RecordingPen()
+ for contour in perContourPen1.value:
+ fixed1.value.extend(contour.value)
+ for contour in perContourPen2.value:
+ fixed2.value.extend(contour.value)
+ fixed1.draw = fixed1.replay
+ fixed2.draw = fixed2.replay
+
+ overriding1[glyphname] = fixed1
+ overriding2[glyphname] = fixed2
+
+ try:
+ midway_glyphset = LerpGlyphSet(overriding1, overriding2)
+ self.draw_glyph(
+ midway_glyphset,
+ glyphname,
+ {"type": "fixed"},
+ None,
+ x=x,
+ y=y,
+ scale=min(scales),
+ )
+ except ValueError:
+ self.draw_emoticon(self.shrug, x=x, y=y)
+ y += self.panel_height + self.pad
+
+ else:
+ emoticon = self.shrug
+ if InterpolatableProblem.UNDERWEIGHT in problem_types:
+ emoticon = self.underweight
+ elif InterpolatableProblem.OVERWEIGHT in problem_types:
+ emoticon = self.overweight
+ elif InterpolatableProblem.NOTHING in problem_types:
+ emoticon = self.yay
+ self.draw_emoticon(emoticon, x=x, y=y)
+
+ if show_page_number:
+ self.draw_label(
+ str(self.page_number),
+ x=0,
+ y=self.height - self.font_size - self.pad,
+ width=self.width,
+ color=self.head_color,
+ align=0.5,
+ )
+
+ def draw_label(
+ self,
+ label,
+ *,
+ x=0,
+ y=0,
+ color=(0, 0, 0),
+ align=0,
+ bold=False,
+ width=None,
+ height=None,
+ font_size=None,
+ ):
+ if width is None:
+ width = self.width
+ if height is None:
+ height = self.height
+ if font_size is None:
+ font_size = self.font_size
+ cr = cairo.Context(self.surface)
+ cr.select_font_face(
+ "@cairo:",
+ cairo.FONT_SLANT_NORMAL,
+ cairo.FONT_WEIGHT_BOLD if bold else cairo.FONT_WEIGHT_NORMAL,
+ )
+ cr.set_font_size(font_size)
+ font_extents = cr.font_extents()
+ font_size = font_size * font_size / font_extents[2]
+ cr.set_font_size(font_size)
+ font_extents = cr.font_extents()
+
+ cr.set_source_rgb(*color)
+
+ extents = cr.text_extents(label)
+ if extents.width > width:
+ # Shrink
+ font_size *= width / extents.width
+ cr.set_font_size(font_size)
+ font_extents = cr.font_extents()
+ extents = cr.text_extents(label)
+
+ # Center
+ label_x = x + (width - extents.width) * align
+ label_y = y + font_extents[0]
+ cr.move_to(label_x, label_y)
+ cr.show_text(label)
+
+ def draw_glyph(self, glyphset, glyphname, problems, which, *, x=0, y=0, scale=None):
+ if type(problems) not in (list, tuple):
+ problems = [problems]
+
+ midway = any(problem["type"] == "midway" for problem in problems)
+ problem_type = problems[0]["type"]
+ problem_types = set(problem["type"] for problem in problems)
+ if not all(pt == problem_type for pt in problem_types):
+ problem_type = "mixed"
+ glyph = glyphset[glyphname]
+
+ recording = RecordingPen()
+ glyph.draw(recording)
+ decomposedRecording = DecomposingRecordingPen(glyphset)
+ glyph.draw(decomposedRecording)
+
+ boundsPen = ControlBoundsPen(glyphset)
+ decomposedRecording.replay(boundsPen)
+ bounds = boundsPen.bounds
+ if bounds is None:
+ bounds = (0, 0, 0, 0)
+
+ glyph_width = bounds[2] - bounds[0]
+ glyph_height = bounds[3] - bounds[1]
+
+ if glyph_width:
+ if scale is None:
+ scale = self.panel_width / glyph_width
+ else:
+ scale = min(scale, self.panel_height / glyph_height)
+ if glyph_height:
+ if scale is None:
+ scale = self.panel_height / glyph_height
+ else:
+ scale = min(scale, self.panel_height / glyph_height)
+ if scale is None:
+ scale = 1
+
+ cr = cairo.Context(self.surface)
+ cr.translate(x, y)
+ # Center
+ cr.translate(
+ (self.panel_width - glyph_width * scale) / 2,
+ (self.panel_height - glyph_height * scale) / 2,
+ )
+ cr.scale(scale, -scale)
+ cr.translate(-bounds[0], -bounds[3])
+
+ if self.border_color:
+ cr.set_source_rgb(*self.border_color)
+ cr.rectangle(bounds[0], bounds[1], glyph_width, glyph_height)
+ cr.set_line_width(self.border_width / scale)
+ cr.stroke()
+
+ if self.fill_color or self.stroke_color:
+ pen = CairoPen(glyphset, cr)
+ decomposedRecording.replay(pen)
+
+ if self.fill_color and problem_type != InterpolatableProblem.OPEN_PATH:
+ cr.set_source_rgb(*self.fill_color)
+ cr.fill_preserve()
+
+ if self.stroke_color:
+ cr.set_source_rgb(*self.stroke_color)
+ cr.set_line_width(self.stroke_width / scale)
+ cr.stroke_preserve()
+
+ cr.new_path()
+
+ if (
+ InterpolatableProblem.UNDERWEIGHT in problem_types
+ or InterpolatableProblem.OVERWEIGHT in problem_types
+ ):
+ perContourPen = PerContourOrComponentPen(RecordingPen, glyphset=glyphset)
+ recording.replay(perContourPen)
+ for problem in problems:
+ if problem["type"] in (
+ InterpolatableProblem.UNDERWEIGHT,
+ InterpolatableProblem.OVERWEIGHT,
+ ):
+ contour = perContourPen.value[problem["contour"]]
+ contour.replay(CairoPen(glyphset, cr))
+ cr.set_source_rgba(*self.weight_issue_contour_color)
+ cr.fill()
+
+ if any(
+ t in problem_types
+ for t in {
+ InterpolatableProblem.NOTHING,
+ InterpolatableProblem.NODE_COUNT,
+ InterpolatableProblem.NODE_INCOMPATIBILITY,
+ }
+ ):
+ cr.set_line_cap(cairo.LINE_CAP_ROUND)
+
+ # Oncurve nodes
+ for segment, args in decomposedRecording.value:
+ if not args:
+ continue
+ x, y = args[-1]
+ cr.move_to(x, y)
+ cr.line_to(x, y)
+ cr.set_source_rgba(*self.oncurve_node_color)
+ cr.set_line_width(self.oncurve_node_diameter / scale)
+ cr.stroke()
+
+ # Offcurve nodes
+ for segment, args in decomposedRecording.value:
+ if not args:
+ continue
+ for x, y in args[:-1]:
+ cr.move_to(x, y)
+ cr.line_to(x, y)
+ cr.set_source_rgba(*self.offcurve_node_color)
+ cr.set_line_width(self.offcurve_node_diameter / scale)
+ cr.stroke()
+
+ # Handles
+ for segment, args in decomposedRecording.value:
+ if not args:
+ pass
+ elif segment in ("moveTo", "lineTo"):
+ cr.move_to(*args[0])
+ elif segment == "qCurveTo":
+ for x, y in args:
+ cr.line_to(x, y)
+ cr.new_sub_path()
+ cr.move_to(*args[-1])
+ elif segment == "curveTo":
+ cr.line_to(*args[0])
+ cr.new_sub_path()
+ cr.move_to(*args[1])
+ cr.line_to(*args[2])
+ cr.new_sub_path()
+ cr.move_to(*args[-1])
+ else:
+ continue
+
+ cr.set_source_rgba(*self.handle_color)
+ cr.set_line_width(self.handle_width / scale)
+ cr.stroke()
+
+ matching = None
+ for problem in problems:
+ if problem["type"] == InterpolatableProblem.CONTOUR_ORDER:
+ matching = problem["value_2"]
+ colors = cycle(self.contour_colors)
+ perContourPen = PerContourOrComponentPen(
+ RecordingPen, glyphset=glyphset
+ )
+ recording.replay(perContourPen)
+ for i, contour in enumerate(perContourPen.value):
+ if matching[i] == i:
+ continue
+ color = next(colors)
+ contour.replay(CairoPen(glyphset, cr))
+ cr.set_source_rgba(*color, self.contour_alpha)
+ cr.fill()
+
+ for problem in problems:
+ if problem["type"] in (
+ InterpolatableProblem.NOTHING,
+ InterpolatableProblem.WRONG_START_POINT,
+ ):
+ idx = problem.get("contour")
+
+ # Draw suggested point
+ if idx is not None and which == 1 and "value_2" in problem:
+ perContourPen = PerContourOrComponentPen(
+ RecordingPen, glyphset=glyphset
+ )
+ decomposedRecording.replay(perContourPen)
+ points = SimpleRecordingPointPen()
+ converter = SegmentToPointPen(points, False)
+ perContourPen.value[
+ idx if matching is None else matching[idx]
+ ].replay(converter)
+ targetPoint = points.value[problem["value_2"]][0]
+ cr.save()
+ cr.translate(*targetPoint)
+ cr.scale(1 / scale, 1 / scale)
+ self.draw_dot(
+ cr,
+ diameter=self.corrected_start_point_size,
+ color=self.corrected_start_point_color,
+ )
+ cr.restore()
+
+ # Draw start-point arrow
+ if which == 0 or not problem.get("reversed"):
+ color = self.start_point_color
+ else:
+ color = self.wrong_start_point_color
+ first_pt = None
+ i = 0
+ cr.save()
+ for segment, args in decomposedRecording.value:
+ if segment == "moveTo":
+ first_pt = args[0]
+ continue
+ if first_pt is None:
+ continue
+ if segment == "closePath":
+ second_pt = first_pt
+ else:
+ second_pt = args[0]
+
+ if idx is None or i == idx:
+ cr.save()
+ first_pt = complex(*first_pt)
+ second_pt = complex(*second_pt)
+ length = abs(second_pt - first_pt)
+ cr.translate(first_pt.real, first_pt.imag)
+ if length:
+ # Draw arrowhead
+ cr.rotate(
+ math.atan2(
+ second_pt.imag - first_pt.imag,
+ second_pt.real - first_pt.real,
+ )
+ )
+ cr.scale(1 / scale, 1 / scale)
+ self.draw_arrow(cr, color=color)
+ else:
+ # Draw circle
+ cr.scale(1 / scale, 1 / scale)
+ self.draw_dot(
+ cr,
+ diameter=self.corrected_start_point_size,
+ color=color,
+ )
+ cr.restore()
+
+ if idx is not None:
+ break
+
+ first_pt = None
+ i += 1
+
+ cr.restore()
+
+ if problem["type"] == InterpolatableProblem.KINK:
+ idx = problem.get("contour")
+ perContourPen = PerContourOrComponentPen(
+ RecordingPen, glyphset=glyphset
+ )
+ decomposedRecording.replay(perContourPen)
+ points = SimpleRecordingPointPen()
+ converter = SegmentToPointPen(points, False)
+ perContourPen.value[idx if matching is None else matching[idx]].replay(
+ converter
+ )
+
+ targetPoint = points.value[problem["value"]][0]
+ cr.save()
+ cr.translate(*targetPoint)
+ cr.scale(1 / scale, 1 / scale)
+ if midway:
+ self.draw_circle(
+ cr,
+ diameter=self.kink_circle_size,
+ stroke_width=self.kink_circle_stroke_width,
+ color=self.kink_circle_color,
+ )
+ else:
+ self.draw_dot(
+ cr,
+ diameter=self.kink_point_size,
+ color=self.kink_point_color,
+ )
+ cr.restore()
+
+ return scale
+
+ def draw_dot(self, cr, *, x=0, y=0, color=(0, 0, 0), diameter=10):
+ cr.save()
+ cr.set_line_width(diameter)
+ cr.set_line_cap(cairo.LINE_CAP_ROUND)
+ cr.move_to(x, y)
+ cr.line_to(x, y)
+ if len(color) == 3:
+ color = color + (1,)
+ cr.set_source_rgba(*color)
+ cr.stroke()
+ cr.restore()
+
+ def draw_circle(
+ self, cr, *, x=0, y=0, color=(0, 0, 0), diameter=10, stroke_width=1
+ ):
+ cr.save()
+ cr.set_line_width(stroke_width)
+ cr.set_line_cap(cairo.LINE_CAP_SQUARE)
+ cr.arc(x, y, diameter / 2, 0, 2 * math.pi)
+ if len(color) == 3:
+ color = color + (1,)
+ cr.set_source_rgba(*color)
+ cr.stroke()
+ cr.restore()
+
+ def draw_arrow(self, cr, *, x=0, y=0, color=(0, 0, 0)):
+ cr.save()
+ if len(color) == 3:
+ color = color + (1,)
+ cr.set_source_rgba(*color)
+ cr.translate(self.start_arrow_length + x, y)
+ cr.move_to(0, 0)
+ cr.line_to(
+ -self.start_arrow_length,
+ -self.start_arrow_length * 0.4,
+ )
+ cr.line_to(
+ -self.start_arrow_length,
+ self.start_arrow_length * 0.4,
+ )
+ cr.close_path()
+ cr.fill()
+ cr.restore()
+
+ def draw_text(self, text, *, x=0, y=0, color=(0, 0, 0), width=None, height=None):
+ if width is None:
+ width = self.width
+ if height is None:
+ height = self.height
+
+ text = text.splitlines()
+ cr = cairo.Context(self.surface)
+ cr.set_source_rgb(*color)
+ cr.set_font_size(self.font_size)
+ cr.select_font_face(
+ "@cairo:monospace", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL
+ )
+ text_width = 0
+ text_height = 0
+ font_extents = cr.font_extents()
+ font_font_size = font_extents[2]
+ font_ascent = font_extents[0]
+ for line in text:
+ extents = cr.text_extents(line)
+ text_width = max(text_width, extents.x_advance)
+ text_height += font_font_size
+ if not text_width:
+ return
+ cr.translate(x, y)
+ scale = min(width / text_width, height / text_height)
+ # center
+ cr.translate(
+ (width - text_width * scale) / 2, (height - text_height * scale) / 2
+ )
+ cr.scale(scale, scale)
+
+ cr.translate(0, font_ascent)
+ for line in text:
+ cr.move_to(0, 0)
+ cr.show_text(line)
+ cr.translate(0, font_font_size)
+
+ def draw_cupcake(self):
+ self.draw_label(
+ self.no_issues_label,
+ x=self.pad,
+ y=self.pad,
+ color=self.no_issues_label_color,
+ width=self.width - 2 * self.pad,
+ align=0.5,
+ bold=True,
+ font_size=self.title_font_size,
+ )
+
+ self.draw_text(
+ self.cupcake,
+ x=self.pad,
+ y=self.pad + self.font_size,
+ width=self.width - 2 * self.pad,
+ height=self.height - 2 * self.pad - self.font_size,
+ color=self.cupcake_color,
+ )
+
+ def draw_emoticon(self, emoticon, x=0, y=0):
+ self.draw_text(
+ emoticon,
+ x=x,
+ y=y,
+ color=self.emoticon_color,
+ width=self.panel_width,
+ height=self.panel_height,
+ )
+
+
+class InterpolatablePostscriptLike(InterpolatablePlot):
+ def __exit__(self, type, value, traceback):
+ self.surface.finish()
+
+ def show_page(self):
+ super().show_page()
+ self.surface.show_page()
+
+
+class InterpolatablePS(InterpolatablePostscriptLike):
+ def __enter__(self):
+ self.surface = cairo.PSSurface(self.out, self.width, self.height)
+ return self
+
+
+class InterpolatablePDF(InterpolatablePostscriptLike):
+ def __enter__(self):
+ self.surface = cairo.PDFSurface(self.out, self.width, self.height)
+ self.surface.set_metadata(
+ cairo.PDF_METADATA_CREATOR, "fonttools varLib.interpolatable"
+ )
+ self.surface.set_metadata(cairo.PDF_METADATA_CREATE_DATE, "")
+ return self
+
+
+class InterpolatableSVG(InterpolatablePlot):
+ def __enter__(self):
+ self.sink = BytesIO()
+ self.surface = cairo.SVGSurface(self.sink, self.width, self.height)
+ return self
+
+ def __exit__(self, type, value, traceback):
+ if self.surface is not None:
+ self.show_page()
+
+ def show_page(self):
+ super().show_page()
+ self.surface.finish()
+ self.out.append(self.sink.getvalue())
+ self.sink = BytesIO()
+ self.surface = cairo.SVGSurface(self.sink, self.width, self.height)
diff --git a/Lib/fontTools/varLib/interpolatableTestContourOrder.py b/Lib/fontTools/varLib/interpolatableTestContourOrder.py
new file mode 100644
index 00000000..9edb1afc
--- /dev/null
+++ b/Lib/fontTools/varLib/interpolatableTestContourOrder.py
@@ -0,0 +1,82 @@
+from .interpolatableHelpers import *
+import logging
+
+log = logging.getLogger("fontTools.varLib.interpolatable")
+
+
+def test_contour_order(glyph0, glyph1):
+ # We try matching both the StatisticsControlPen vector
+ # and the StatisticsPen vector.
+ #
+ # If either method found a identity matching, accept it.
+ # This is crucial for fonts like Kablammo[MORF].ttf and
+ # Nabla[EDPT,EHLT].ttf, since they really confuse the
+ # StatisticsPen vector because of their area=0 contours.
+
+ n = len(glyph0.controlVectors)
+ matching = None
+ matching_cost = 0
+ identity_cost = 0
+ done = n <= 1
+ if not done:
+ m0Control = glyph0.controlVectors
+ m1Control = glyph1.controlVectors
+ (
+ matching_control,
+ matching_cost_control,
+ identity_cost_control,
+ ) = matching_for_vectors(m0Control, m1Control)
+ done = matching_cost_control == identity_cost_control
+ if not done:
+ m0Green = glyph0.greenVectors
+ m1Green = glyph1.greenVectors
+ (
+ matching_green,
+ matching_cost_green,
+ identity_cost_green,
+ ) = matching_for_vectors(m0Green, m1Green)
+ done = matching_cost_green == identity_cost_green
+
+ if not done:
+ # See if reversing contours in one master helps.
+ # That's a common problem. Then the wrong_start_point
+ # test will fix them.
+ #
+ # Reverse the sign of the area (0); the rest stay the same.
+ if not done:
+ m1ControlReversed = [(-m[0],) + m[1:] for m in m1Control]
+ (
+ matching_control_reversed,
+ matching_cost_control_reversed,
+ identity_cost_control_reversed,
+ ) = matching_for_vectors(m0Control, m1ControlReversed)
+ done = matching_cost_control_reversed == identity_cost_control_reversed
+ if not done:
+ m1GreenReversed = [(-m[0],) + m[1:] for m in m1Green]
+ (
+ matching_control_reversed,
+ matching_cost_control_reversed,
+ identity_cost_control_reversed,
+ ) = matching_for_vectors(m0Control, m1ControlReversed)
+ done = matching_cost_control_reversed == identity_cost_control_reversed
+
+ if not done:
+ # Otherwise, use the worst of the two matchings.
+ if (
+ matching_cost_control / identity_cost_control
+ < matching_cost_green / identity_cost_green
+ ):
+ matching = matching_control
+ matching_cost = matching_cost_control
+ identity_cost = identity_cost_control
+ else:
+ matching = matching_green
+ matching_cost = matching_cost_green
+ identity_cost = identity_cost_green
+
+ this_tolerance = matching_cost / identity_cost if identity_cost else 1
+ log.debug(
+ "test-contour-order: tolerance %g",
+ this_tolerance,
+ )
+ return this_tolerance, matching
diff --git a/Lib/fontTools/varLib/interpolatableTestStartingPoint.py b/Lib/fontTools/varLib/interpolatableTestStartingPoint.py
new file mode 100644
index 00000000..e7600066
--- /dev/null
+++ b/Lib/fontTools/varLib/interpolatableTestStartingPoint.py
@@ -0,0 +1,105 @@
+from .interpolatableHelpers import *
+
+
+def test_starting_point(glyph0, glyph1, ix, tolerance, matching):
+ if matching is None:
+ matching = list(range(len(glyph0.isomorphisms)))
+ contour0 = glyph0.isomorphisms[ix]
+ contour1 = glyph1.isomorphisms[matching[ix]]
+ m0Vectors = glyph0.greenVectors
+ m1Vectors = [glyph1.greenVectors[i] for i in matching]
+
+ c0 = contour0[0]
+ # Next few lines duplicated below.
+ costs = [vdiff_hypot2_complex(c0[0], c1[0]) for c1 in contour1]
+ min_cost_idx, min_cost = min(enumerate(costs), key=lambda x: x[1])
+ first_cost = costs[0]
+ proposed_point = contour1[min_cost_idx][1]
+ reverse = contour1[min_cost_idx][2]
+
+ if min_cost < first_cost * tolerance:
+ # c0 is the first isomorphism of the m0 master
+ # contour1 is list of all isomorphisms of the m1 master
+ #
+ # If the two shapes are both circle-ish and slightly
+ # rotated, we detect wrong start point. This is for
+ # example the case hundreds of times in
+ # RobotoSerif-Italic[GRAD,opsz,wdth,wght].ttf
+ #
+ # If the proposed point is only one off from the first
+ # point (and not reversed), try harder:
+ #
+ # Find the major eigenvector of the covariance matrix,
+ # and rotate the contours by that angle. Then find the
+ # closest point again. If it matches this time, let it
+ # pass.
+
+ num_points = len(glyph1.points[ix])
+ leeway = 3
+ if not reverse and (
+ proposed_point <= leeway or proposed_point >= num_points - leeway
+ ):
+ # Try harder
+
+ # Recover the covariance matrix from the GreenVectors.
+ # This is a 2x2 matrix.
+ transforms = []
+ for vector in (m0Vectors[ix], m1Vectors[ix]):
+ meanX = vector[1]
+ meanY = vector[2]
+ stddevX = vector[3] * 0.5
+ stddevY = vector[4] * 0.5
+ correlation = vector[5] / abs(vector[0])
+
+ # https://cookierobotics.com/007/
+ a = stddevX * stddevX # VarianceX
+ c = stddevY * stddevY # VarianceY
+ b = correlation * stddevX * stddevY # Covariance
+
+ delta = (((a - c) * 0.5) ** 2 + b * b) ** 0.5
+ lambda1 = (a + c) * 0.5 + delta # Major eigenvalue
+ lambda2 = (a + c) * 0.5 - delta # Minor eigenvalue
+ theta = atan2(lambda1 - a, b) if b != 0 else (pi * 0.5 if a < c else 0)
+ trans = Transform()
+ # Don't translate here. We are working on the complex-vector
+ # that includes more than just the points. It's horrible what
+ # we are doing anyway...
+ # trans = trans.translate(meanX, meanY)
+ trans = trans.rotate(theta)
+ trans = trans.scale(sqrt(lambda1), sqrt(lambda2))
+ transforms.append(trans)
+
+ trans = transforms[0]
+ new_c0 = (
+ [complex(*trans.transformPoint((pt.real, pt.imag))) for pt in c0[0]],
+ ) + c0[1:]
+ trans = transforms[1]
+ new_contour1 = []
+ for c1 in contour1:
+ new_c1 = (
+ [
+ complex(*trans.transformPoint((pt.real, pt.imag)))
+ for pt in c1[0]
+ ],
+ ) + c1[1:]
+ new_contour1.append(new_c1)
+
+ # Next few lines duplicate from above.
+ costs = [
+ vdiff_hypot2_complex(new_c0[0], new_c1[0]) for new_c1 in new_contour1
+ ]
+ min_cost_idx, min_cost = min(enumerate(costs), key=lambda x: x[1])
+ first_cost = costs[0]
+ if min_cost < first_cost * tolerance:
+ # Don't report this
+ # min_cost = first_cost
+ # reverse = False
+ # proposed_point = 0 # new_contour1[min_cost_idx][1]
+ pass
+
+ this_tolerance = min_cost / first_cost if first_cost else 1
+ log.debug(
+ "test-starting-point: tolerance %g",
+ this_tolerance,
+ )
+ return this_tolerance, proposed_point, reverse
diff --git a/Lib/fontTools/varLib/interpolate_layout.py b/Lib/fontTools/varLib/interpolate_layout.py
index aa3f49c6..798b2959 100644
--- a/Lib/fontTools/varLib/interpolate_layout.py
+++ b/Lib/fontTools/varLib/interpolate_layout.py
@@ -1,6 +1,7 @@
"""
Interpolate OpenType Layout tables (GDEF / GPOS / GSUB).
"""
+
from fontTools.ttLib import TTFont
from fontTools.varLib import models, VarLibError, load_designspace, load_masters
from fontTools.varLib.merger import InstancerMerger
diff --git a/Lib/fontTools/varLib/merger.py b/Lib/fontTools/varLib/merger.py
index b2c34016..61122f4c 100644
--- a/Lib/fontTools/varLib/merger.py
+++ b/Lib/fontTools/varLib/merger.py
@@ -1,6 +1,7 @@
"""
Merge OpenType Layout tables (GDEF / GPOS / GSUB).
"""
+
import os
import copy
import enum
@@ -1059,7 +1060,7 @@ class InstancerMerger(AligningMerger):
Merger.__init__(self, font)
self.model = model
self.location = location
- self.scalars = model.getScalars(location)
+ self.masterScalars = model.getMasterScalars(location)
@InstancerMerger.merger(ot.CaretValue)
@@ -1067,8 +1068,10 @@ def merge(merger, self, lst):
assert self.Format == 1
Coords = [a.Coordinate for a in lst]
model = merger.model
- scalars = merger.scalars
- self.Coordinate = otRound(model.interpolateFromMastersAndScalars(Coords, scalars))
+ masterScalars = merger.masterScalars
+ self.Coordinate = otRound(
+ model.interpolateFromValuesAndScalars(Coords, masterScalars)
+ )
@InstancerMerger.merger(ot.Anchor)
@@ -1077,15 +1080,19 @@ def merge(merger, self, lst):
XCoords = [a.XCoordinate for a in lst]
YCoords = [a.YCoordinate for a in lst]
model = merger.model
- scalars = merger.scalars
- self.XCoordinate = otRound(model.interpolateFromMastersAndScalars(XCoords, scalars))
- self.YCoordinate = otRound(model.interpolateFromMastersAndScalars(YCoords, scalars))
+ masterScalars = merger.masterScalars
+ self.XCoordinate = otRound(
+ model.interpolateFromValuesAndScalars(XCoords, masterScalars)
+ )
+ self.YCoordinate = otRound(
+ model.interpolateFromValuesAndScalars(YCoords, masterScalars)
+ )
@InstancerMerger.merger(otBase.ValueRecord)
def merge(merger, self, lst):
model = merger.model
- scalars = merger.scalars
+ masterScalars = merger.masterScalars
# TODO Handle differing valueformats
for name, tableName in [
("XAdvance", "XAdvDevice"),
@@ -1097,7 +1104,9 @@ def merge(merger, self, lst):
if hasattr(self, name):
values = [getattr(a, name, 0) for a in lst]
- value = otRound(model.interpolateFromMastersAndScalars(values, scalars))
+ value = otRound(
+ model.interpolateFromValuesAndScalars(values, masterScalars)
+ )
setattr(self, name, value)
diff --git a/Lib/fontTools/varLib/models.py b/Lib/fontTools/varLib/models.py
index 5bd66dba..59815316 100644
--- a/Lib/fontTools/varLib/models.py
+++ b/Lib/fontTools/varLib/models.py
@@ -4,6 +4,7 @@ __all__ = [
"normalizeValue",
"normalizeLocation",
"supportScalar",
+ "piecewiseLinearMap",
"VariationModel",
]
@@ -270,6 +271,12 @@ class VariationModel(object):
self._subModels = {}
def getSubModel(self, items):
+ """Return a sub-model and the items that are not None.
+
+ The sub-model is necessary for working with the subset
+ of items when some are None.
+
+ The sub-model is cached."""
if None not in items:
return self, items
key = tuple(v is not None for v in items)
@@ -464,6 +471,10 @@ class VariationModel(object):
return model.getDeltas(items, round=round), model.supports
def getScalars(self, loc):
+ """Return scalars for each delta, for the given location.
+ If interpolating many master-values at the same location,
+ this function allows speed up by fetching the scalars once
+ and using them with interpolateFromMastersAndScalars()."""
return [
supportScalar(
loc, support, extrapolate=self.extrapolate, axisRanges=self.axisRanges
@@ -471,29 +482,65 @@ class VariationModel(object):
for support in self.supports
]
+ def getMasterScalars(self, targetLocation):
+ """Return multipliers for each master, for the given location.
+ If interpolating many master-values at the same location,
+ this function allows speed up by fetching the scalars once
+ and using them with interpolateFromValuesAndScalars().
+
+ Note that the scalars used in interpolateFromMastersAndScalars(),
+ are *not* the same as the ones returned here. They are the result
+ of getScalars()."""
+ out = self.getScalars(targetLocation)
+ for i, weights in reversed(list(enumerate(self.deltaWeights))):
+ for j, weight in weights.items():
+ out[j] -= out[i] * weight
+
+ out = [out[self.mapping[i]] for i in range(len(out))]
+ return out
+
@staticmethod
- def interpolateFromDeltasAndScalars(deltas, scalars):
+ def interpolateFromValuesAndScalars(values, scalars):
+ """Interpolate from values and scalars coefficients.
+
+ If the values are master-values, then the scalars should be
+ fetched from getMasterScalars().
+
+ If the values are deltas, then the scalars should be fetched
+ from getScalars(); in which case this is the same as
+ interpolateFromDeltasAndScalars().
+ """
v = None
- assert len(deltas) == len(scalars)
- for delta, scalar in zip(deltas, scalars):
+ assert len(values) == len(scalars)
+ for value, scalar in zip(values, scalars):
if not scalar:
continue
- contribution = delta * scalar
+ contribution = value * scalar
if v is None:
v = contribution
else:
v += contribution
return v
+ @staticmethod
+ def interpolateFromDeltasAndScalars(deltas, scalars):
+ """Interpolate from deltas and scalars fetched from getScalars()."""
+ return VariationModel.interpolateFromValuesAndScalars(deltas, scalars)
+
def interpolateFromDeltas(self, loc, deltas):
+ """Interpolate from deltas, at location loc."""
scalars = self.getScalars(loc)
return self.interpolateFromDeltasAndScalars(deltas, scalars)
def interpolateFromMasters(self, loc, masterValues, *, round=noRound):
- deltas = self.getDeltas(masterValues, round=round)
- return self.interpolateFromDeltas(loc, deltas)
+ """Interpolate from master-values, at location loc."""
+ scalars = self.getMasterScalars(loc)
+ return self.interpolateFromValuesAndScalars(masterValues, scalars)
def interpolateFromMastersAndScalars(self, masterValues, scalars, *, round=noRound):
+ """Interpolate from master-values, and scalars fetched from
+ getScalars(), which is useful when you want to interpolate
+ multiple master-values with the same location."""
deltas = self.getDeltas(masterValues, round=round)
return self.interpolateFromDeltasAndScalars(deltas, scalars)
diff --git a/Lib/fontTools/varLib/mutator.py b/Lib/fontTools/varLib/mutator.py
index d1d123ab..c7c37dab 100644
--- a/Lib/fontTools/varLib/mutator.py
+++ b/Lib/fontTools/varLib/mutator.py
@@ -3,6 +3,7 @@ Instantiate a variation font. Run, eg:
$ fonttools varLib.mutator ./NotoSansArabic-VF.ttf wght=140 wdth=85
"""
+
from fontTools.misc.fixedTools import floatToFixedToFloat, floatToFixed
from fontTools.misc.roundTools import otRound
from fontTools.pens.boundsPen import BoundsPen
@@ -198,9 +199,11 @@ def instantiateVariableFont(varfont, location, inplace=False, overlap=True):
glyphnames = sorted(
gvar.variations.keys(),
key=lambda name: (
- glyf[name].getCompositeMaxpValues(glyf).maxComponentDepth
- if glyf[name].isComposite() or glyf[name].isVarComposite()
- else 0,
+ (
+ glyf[name].getCompositeMaxpValues(glyf).maxComponentDepth
+ if glyf[name].isComposite() or glyf[name].isVarComposite()
+ else 0
+ ),
name,
),
)
@@ -304,9 +307,9 @@ def instantiateVariableFont(varfont, location, inplace=False, overlap=True):
if applies:
assert record.FeatureTableSubstitution.Version == 0x00010000
for rec in record.FeatureTableSubstitution.SubstitutionRecord:
- table.FeatureList.FeatureRecord[
- rec.FeatureIndex
- ].Feature = rec.Feature
+ table.FeatureList.FeatureRecord[rec.FeatureIndex].Feature = (
+ rec.Feature
+ )
break
del table.FeatureVariations
diff --git a/METADATA b/METADATA
index 50e856e1..3905eb81 100644
--- a/METADATA
+++ b/METADATA
@@ -1,23 +1,20 @@
# This project was upgraded with external_updater.
-# Usage: tools/external_updater/updater.sh update fonttools
+# Usage: tools/external_updater/updater.sh update external/fonttools
# For more info, check https://cs.android.com/android/platform/superproject/+/main:tools/external_updater/README.md
name: "fonttools"
description: "fontTools is a library for manipulating fonts, written in Python."
third_party {
- url {
- type: HOMEPAGE
- value: "https://github.com/fonttools/fonttools"
- }
- url {
- type: ARCHIVE
- value: "https://github.com/fonttools/fonttools/archive/4.44.0.zip"
- }
- version: "4.44.0"
license_type: NOTICE
last_upgrade_date {
- year: 2023
- month: 11
- day: 10
+ year: 2024
+ month: 4
+ day: 1
+ }
+ homepage: "https://github.com/fonttools/fonttools"
+ identifier {
+ type: "Archive"
+ value: "https://github.com/fonttools/fonttools/archive/4.49.0.zip"
+ version: "4.49.0"
}
}
diff --git a/NEWS.rst b/NEWS.rst
index cddd851f..0a298ac2 100644
--- a/NEWS.rst
+++ b/NEWS.rst
@@ -1,3 +1,109 @@
+4.49.0 (released 2024-02-15)
+----------------------------
+
+- [otlLib] Add API for building ``MATH`` table (#3446)
+
+4.48.1 (released 2024-02-06)
+----------------------------
+
+- Fixed uploading wheels to PyPI, no code changes since v4.48.0.
+
+4.48.0 (released 2024-02-06)
+----------------------------
+
+- [varLib] Do not log when there are no OTL tables to be merged.
+- [setup.py] Do not restrict lxml<5 any more, tests pass just fine with lxml>=5.
+- [feaLib] Remove glyph and class names length restrictions in FEA (#3424).
+- [roundingPens] Added ``transformRoundFunc`` parameter to the rounding pens to allow
+ for custom rounding of the components' transforms (#3426).
+- [feaLib] Keep declaration order of ligature components within a ligature set, instead
+ of sorting by glyph name (#3429).
+- [feaLib] Fixed ordering of alternates in ``aalt`` lookups, following the declaration
+ order of feature references within the ``aalt`` feature block (#3430).
+- [varLib.instancer] Fixed a bug in the instancer's IUP optimization (#3432).
+- [sbix] Support sbix glyphs with new graphicType "flip" (#3433).
+- [svgPathPen] Added ``--glyphs`` option to dump the SVG paths for the named glyphs
+ in the font (0572f78).
+- [designspaceLib] Added "description" attribute to ``<mappings>`` and ``<mapping>``
+ elements, and allow multiple ``<mappings>`` elements to group ``<mapping>`` elements
+ that are logically related (#3435, #3437).
+- [otlLib] Correctly choose the most compact GSUB contextual lookup format (#3439).
+
+4.47.2 (released 2024-01-11)
+----------------------------
+
+Minor release to fix uploading wheels to PyPI.
+
+4.47.1 (released 2024-01-11)
+----------------------------
+
+- [merge] Improve help message and add standard command line options (#3408)
+- [otlLib] Pass ``ttFont`` to ``name.addName`` in ``buildStatTable`` (#3406)
+- [featureVars] Re-use ``FeatureVariationRecord``'s when possible (#3413)
+
+4.47.0 (released 2023-12-18)
+----------------------------
+
+- [varLib.models] New API for VariationModel: ``getMasterScalars`` and
+ ``interpolateFromValuesAndScalars``.
+- [varLib.interpolatable] Various bugfixes and rendering improvements. In particular,
+ add a Summary page in the front, and an Index and Table-of-Contents in the back.
+ Change the page size to Letter.
+- [Docs/designspaceLib] Defined a new ``public.fontInfo`` lib key, not used anywhere yet (#3358).
+
+4.46.0 (released 2023-12-02)
+----------------------------
+
+- [featureVars] Allow to register the same set of substitution rules to multiple features.
+ The ``addFeatureVariations`` function can now take a list of featureTags; similarly, the
+ lib key 'com.github.fonttools.varLib.featureVarsFeatureTag' can now take a
+ comma-separateed string of feature tags (e.g. "salt,ss01") instead of a single tag (#3360).
+- [featureVars] Don't overwrite GSUB FeatureVariations, but append new records to it
+ for features which are not already there. But raise ``VarLibError`` if the feature tag
+ already has feature variations associated with it (#3363).
+- [varLib] Added ``addGSUBFeatureVariations`` function to add GSUB Feature Variations
+ to an existing variable font from rules defined in a DesignSpace document (#3362).
+- [varLib.interpolatable] Various bugfixes and rendering improvements. In particular,
+ a new test for "underweight" glyphs. The new test reports quite a few false-positives
+ though. Please send feedback.
+
+4.45.1 (released 2023-11-23)
+----------------------------
+
+- [varLib.interpolatable] Various bugfixes and improvements, better reporting, reduced
+ false positives.
+- [ttGlyphSet] Added option to not recalculate glyf bounds (#3348).
+
+4.45.0 (released 2023-11-20)
+----------------------------
+
+- [varLib.interpolatable] Vastly improved algorithms. Also available now is ``--pdf``
+ and ``--html`` options to generate a PDF or HTML report of the interpolation issues.
+ The PDF/HTML report showcases the problematic masters, the interpolated broken
+ glyph, as well as the proposed fixed version.
+
+4.44.3 (released 2023-11-15)
+----------------------------
+
+- [subset] Only prune codepage ranges for OS/2.version >= 1, ignore otherwise (#3334).
+- [instancer] Ensure hhea vertical metrics stay in sync with OS/2 ones after instancing
+ MVAR table containing 'hasc', 'hdsc' or 'hlgp' tags (#3297).
+
+4.44.2 (released 2023-11-14)
+----------------------------
+
+- [glyf] Have ``Glyph.recalcBounds`` skip empty components (base glyph with no contours)
+ when computing the bounding box of composite glyphs. This simply restores the existing
+ behavior before some changes were introduced in fonttools 4.44.0 (#3333).
+
+4.44.1 (released 2023-11-14)
+----------------------------
+
+- [feaLib] Ensure variable mark anchors are deep-copied while building since they
+ get modified in-place and later reused (#3330).
+- [OS/2|subset] Added method to ``recalcCodePageRanges`` to OS/2 table class; added
+ ``--prune-codepage-ranges`` to `fonttools subset` command (#3328, #2607).
+
4.44.0 (released 2023-11-03)
----------------------------
@@ -245,10 +351,10 @@
----------------------------
- [varLib.instancer] Added support for L4 instancing, i.e. moving the default value of
- an axis while keeping it variable. Thanks Behdad! (#2728, #2861).
+ an axis while keeping it variable. Thanks Behdad! (#2728, #2861).
It's now also possible to restrict an axis min/max values beyond the current default
value, e.g. a font wght has min=100, def=400, max=900 and you want a partial VF that
- only varies between 500 and 700, you can now do that.
+ only varies between 500 and 700, you can now do that.
You can either specify two min/max values (wght=500:700), and the new default will be
set to either the minimum or maximum, depending on which one is closer to the current
default (e.g. 500 in this case). Or you can specify three values (e.g. wght=500:600:700)
@@ -256,7 +362,7 @@
- [otlLib/featureVars] Set a few Count values so one doesn't need to compile the font
to update them (#2860).
- [varLib.models] Make extrapolation work for 2-master models as well where one master
- is at the default location (#2843, #2846).
+ is at the default location (#2843, #2846).
Add optional extrapolate=False to normalizeLocation() (#2847, #2849).
- [varLib.cff] Fixed sub-optimal packing of CFF2 deltas by no longer rounding them to
integer (#2838).
diff --git a/README.rst b/README.rst
index bcb7f0d4..2274fbdc 100644
--- a/README.rst
+++ b/README.rst
@@ -44,7 +44,7 @@ Python 3 `venv <https://docs.python.org/3/library/venv.html>`__ module.
# create new virtual environment called e.g. 'fonttools-venv', or anything you like
python -m virtualenv fonttools-venv
- # source the `activate` shell script to enter the environment (Un*x); to exit, just type `deactivate`
+ # source the `activate` shell script to enter the environment (Unix-like); to exit, just type `deactivate`
. fonttools-venv/bin/activate
# to activate the virtual environment in Windows `cmd.exe`, do
@@ -138,6 +138,13 @@ are required to unlock the extra features named "ufo", etc.
* `munkres <https://pypi.python.org/pypi/munkres>`__: a pure-Python
module that implements the Hungarian or Kuhn-Munkres algorithm.
+ To plot the results to a PDF or HTML format, you also need to install:
+
+ * `pycairo <https://pypi.org/project/pycairo/>`__: Python bindings for the
+ Cairo graphics library. Note that wheels are currently only available for
+ Windows, for other platforms see pycairo's `installation instructions
+ <https://pycairo.readthedocs.io/en/latest/getting_started.html>`__.
+
*Extra:* ``interpolatable``
- ``Lib/fontTools/varLib/plot.py``
diff --git a/Tests/designspaceLib/data/test_avar2.designspace b/Tests/designspaceLib/data/test_avar2.designspace
index d54588a6..4c286514 100644
--- a/Tests/designspaceLib/data/test_avar2.designspace
+++ b/Tests/designspaceLib/data/test_avar2.designspace
@@ -19,8 +19,8 @@
<map input="87.5" output="89"/>
<map input="100" output="100"/>
</axis>
- <mappings>
- <mapping>
+ <mappings description="mappings 1">
+ <mapping description="justify low">
<input>
<dimension name="Justify" xvalue="-100"/>
<dimension name="Width" xvalue="100"/>
@@ -30,6 +30,17 @@
</output>
</mapping>
</mappings>
+ <mappings description="mappings 2">
+ <mapping description="test mapping">
+ <input>
+ <dimension name="Justify" xvalue="100"/>
+ <dimension name="Width" xvalue="70"/>
+ </input>
+ <output>
+ <dimension name="Width" xvalue="100"/>
+ </output>
+ </mapping>
+ </mappings>
</axes>
<variable-fonts>
<variable-font name="NotoSansArabic_Justify_Width">
diff --git a/Tests/designspaceLib/designspace_test.py b/Tests/designspaceLib/designspace_test.py
index ceddfd10..0b4df139 100644
--- a/Tests/designspaceLib/designspace_test.py
+++ b/Tests/designspaceLib/designspace_test.py
@@ -701,7 +701,7 @@ def test_axisMappingsRoundtrip(tmpdir):
doc = DesignSpaceDocument()
doc.read(testDocPath)
assert doc.axisMappings
- assert len(doc.axisMappings) == 1
+ assert len(doc.axisMappings) == 2
assert doc.axisMappings[0].inputLocation == {"Justify": -100.0, "Width": 100.0}
# This is a bit of a hack, but it's the only way to make sure
@@ -720,6 +720,12 @@ def test_axisMappingsRoundtrip(tmpdir):
assert [mapping.outputLocation for mapping in doc.axisMappings] == [
mapping.outputLocation for mapping in doc2.axisMappings
]
+ assert [mapping.description for mapping in doc.axisMappings] == [
+ mapping.description for mapping in doc2.axisMappings
+ ]
+ assert [mapping.groupDescription for mapping in doc.axisMappings] == [
+ mapping.groupDescription for mapping in doc2.axisMappings
+ ]
def test_rulesConditions(tmpdir):
diff --git a/Tests/designspaceLib/split_test.py b/Tests/designspaceLib/split_test.py
index 3364133f..3c888a45 100644
--- a/Tests/designspaceLib/split_test.py
+++ b/Tests/designspaceLib/split_test.py
@@ -217,13 +217,13 @@ def test_avar2(datadir):
ds = DesignSpaceDocument()
ds.read(datadir / "test_avar2.designspace")
_, subDoc = next(splitInterpolable(ds))
- assert len(subDoc.axisMappings) == 1
+ assert len(subDoc.axisMappings) == 2
subDocs = list(splitVariableFonts(ds))
assert len(subDocs) == 5
for i, (_, subDoc) in enumerate(subDocs):
# Only the first one should have a mapping, according to the document
if i == 0:
- assert len(subDoc.axisMappings) == 1
+ assert len(subDoc.axisMappings) == 2
else:
assert len(subDoc.axisMappings) == 0
diff --git a/Tests/feaLib/builder_test.py b/Tests/feaLib/builder_test.py
index adcb058f..875223af 100644
--- a/Tests/feaLib/builder_test.py
+++ b/Tests/feaLib/builder_test.py
@@ -81,6 +81,7 @@ class BuilderTest(unittest.TestCase):
MultipleLookupsPerGlyph MultipleLookupsPerGlyph2 GSUB_6_formats
GSUB_5_formats delete_glyph STAT_test STAT_test_elidedFallbackNameID
variable_scalar_valuerecord variable_scalar_anchor variable_conditionset
+ variable_mark_anchor
""".split()
VARFONT_AXES = [
@@ -989,6 +990,47 @@ class BuilderTest(unittest.TestCase):
f'{name}.fea:{line}:12: Ambiguous "ignore {sub}", there should be least one marked glyph'
)
+ def test_conditionset_multiple_features(self):
+ """Test that using the same `conditionset` for multiple features reuses the
+ `FeatureVariationRecord`."""
+
+ features = """
+ languagesystem DFLT dflt;
+
+ conditionset test {
+ wght 600 1000;
+ wdth 150 200;
+ } test;
+
+ variation ccmp test {
+ sub e by a;
+ } ccmp;
+
+ variation rlig test {
+ sub b by c;
+ } rlig;
+ """
+
+ def make_mock_vf():
+ font = makeTTFont()
+ font["name"] = newTable("name")
+ addFvar(
+ font,
+ [("wght", 0, 0, 1000, "Weight"), ("wdth", 100, 100, 200, "Width")],
+ [],
+ )
+ del font["name"]
+ return font
+
+ font = make_mock_vf()
+ addOpenTypeFeaturesFromString(font, features)
+
+ table = font["GSUB"].table
+ assert table.FeatureVariations.FeatureVariationCount == 1
+
+ fvr = table.FeatureVariations.FeatureVariationRecord[0]
+ assert fvr.FeatureTableSubstitution.SubstitutionCount == 2
+
def test_condition_set_avar(self):
"""Test that the `avar` table is consulted when normalizing user-space
values."""
diff --git a/Tests/feaLib/data/GSUB_5_formats.ttx b/Tests/feaLib/data/GSUB_5_formats.ttx
index 80196aaa..29d57d5e 100644
--- a/Tests/feaLib/data/GSUB_5_formats.ttx
+++ b/Tests/feaLib/data/GSUB_5_formats.ttx
@@ -62,9 +62,11 @@
<Lookup index="1">
<LookupType value="5"/>
<LookupFlag value="0"/>
- <!-- SubTableCount=1 -->
- <ContextSubst index="0" Format="2">
- <Coverage>
+ <!-- SubTableCount=3 -->
+ <ContextSubst index="0" Format="3">
+ <!-- GlyphCount=3 -->
+ <!-- SubstCount=0 -->
+ <Coverage index="0">
<Glyph value="a"/>
<Glyph value="b"/>
<Glyph value="c"/>
@@ -92,91 +94,160 @@
<Glyph value="y"/>
<Glyph value="z"/>
</Coverage>
- <ClassDef>
- <ClassDef glyph="A" class="3"/>
- <ClassDef glyph="B" class="3"/>
- <ClassDef glyph="C" class="3"/>
- <ClassDef glyph="D" class="3"/>
- <ClassDef glyph="E" class="3"/>
- <ClassDef glyph="F" class="3"/>
- <ClassDef glyph="G" class="3"/>
- <ClassDef glyph="H" class="3"/>
- <ClassDef glyph="I" class="2"/>
- <ClassDef glyph="J" class="2"/>
- <ClassDef glyph="K" class="2"/>
- <ClassDef glyph="L" class="2"/>
- <ClassDef glyph="M" class="2"/>
- <ClassDef glyph="N" class="2"/>
- <ClassDef glyph="O" class="2"/>
- <ClassDef glyph="P" class="2"/>
- <ClassDef glyph="Q" class="2"/>
- <ClassDef glyph="R" class="2"/>
- <ClassDef glyph="S" class="2"/>
- <ClassDef glyph="T" class="2"/>
- <ClassDef glyph="U" class="2"/>
- <ClassDef glyph="V" class="2"/>
- <ClassDef glyph="W" class="2"/>
- <ClassDef glyph="X" class="2"/>
- <ClassDef glyph="Y" class="2"/>
- <ClassDef glyph="Z" class="2"/>
- <ClassDef glyph="a" class="1"/>
- <ClassDef glyph="b" class="1"/>
- <ClassDef glyph="c" class="1"/>
- <ClassDef glyph="d" class="1"/>
- <ClassDef glyph="e" class="1"/>
- <ClassDef glyph="f" class="1"/>
- <ClassDef glyph="g" class="1"/>
- <ClassDef glyph="h" class="1"/>
- <ClassDef glyph="i" class="1"/>
- <ClassDef glyph="j" class="1"/>
- <ClassDef glyph="k" class="1"/>
- <ClassDef glyph="l" class="1"/>
- <ClassDef glyph="m" class="1"/>
- <ClassDef glyph="n" class="1"/>
- <ClassDef glyph="o" class="1"/>
- <ClassDef glyph="p" class="1"/>
- <ClassDef glyph="q" class="1"/>
- <ClassDef glyph="r" class="1"/>
- <ClassDef glyph="s" class="1"/>
- <ClassDef glyph="t" class="1"/>
- <ClassDef glyph="u" class="1"/>
- <ClassDef glyph="v" class="1"/>
- <ClassDef glyph="w" class="1"/>
- <ClassDef glyph="x" class="1"/>
- <ClassDef glyph="y" class="1"/>
- <ClassDef glyph="z" class="1"/>
- </ClassDef>
- <!-- SubClassSetCount=4 -->
- <SubClassSet index="0">
- <!-- SubClassRuleCount=0 -->
- </SubClassSet>
- <SubClassSet index="1">
- <!-- SubClassRuleCount=3 -->
- <SubClassRule index="0">
- <!-- GlyphCount=3 -->
- <!-- SubstCount=0 -->
- <Class index="0" value="3"/>
- <Class index="1" value="2"/>
- </SubClassRule>
- <SubClassRule index="1">
- <!-- GlyphCount=3 -->
- <!-- SubstCount=0 -->
- <Class index="0" value="3"/>
- <Class index="1" value="2"/>
- </SubClassRule>
- <SubClassRule index="2">
- <!-- GlyphCount=3 -->
- <!-- SubstCount=0 -->
- <Class index="0" value="2"/>
- <Class index="1" value="3"/>
- </SubClassRule>
- </SubClassSet>
- <SubClassSet index="2">
- <!-- SubClassRuleCount=0 -->
- </SubClassSet>
- <SubClassSet index="3">
- <!-- SubClassRuleCount=0 -->
- </SubClassSet>
+ <Coverage index="1">
+ <Glyph value="A"/>
+ <Glyph value="B"/>
+ <Glyph value="C"/>
+ <Glyph value="D"/>
+ <Glyph value="E"/>
+ <Glyph value="F"/>
+ <Glyph value="G"/>
+ <Glyph value="H"/>
+ </Coverage>
+ <Coverage index="2">
+ <Glyph value="I"/>
+ <Glyph value="J"/>
+ <Glyph value="K"/>
+ <Glyph value="L"/>
+ <Glyph value="M"/>
+ <Glyph value="N"/>
+ <Glyph value="O"/>
+ <Glyph value="P"/>
+ <Glyph value="Q"/>
+ <Glyph value="R"/>
+ <Glyph value="S"/>
+ <Glyph value="T"/>
+ <Glyph value="U"/>
+ <Glyph value="V"/>
+ <Glyph value="W"/>
+ <Glyph value="X"/>
+ <Glyph value="Y"/>
+ <Glyph value="Z"/>
+ </Coverage>
+ </ContextSubst>
+ <ContextSubst index="1" Format="3">
+ <!-- GlyphCount=3 -->
+ <!-- SubstCount=0 -->
+ <Coverage index="0">
+ <Glyph value="a"/>
+ <Glyph value="b"/>
+ <Glyph value="c"/>
+ <Glyph value="d"/>
+ <Glyph value="e"/>
+ <Glyph value="f"/>
+ <Glyph value="g"/>
+ <Glyph value="h"/>
+ <Glyph value="i"/>
+ <Glyph value="j"/>
+ <Glyph value="k"/>
+ <Glyph value="l"/>
+ <Glyph value="m"/>
+ <Glyph value="n"/>
+ <Glyph value="o"/>
+ <Glyph value="p"/>
+ <Glyph value="q"/>
+ <Glyph value="r"/>
+ <Glyph value="s"/>
+ <Glyph value="t"/>
+ <Glyph value="u"/>
+ <Glyph value="v"/>
+ <Glyph value="w"/>
+ <Glyph value="x"/>
+ <Glyph value="y"/>
+ <Glyph value="z"/>
+ </Coverage>
+ <Coverage index="1">
+ <Glyph value="A"/>
+ <Glyph value="B"/>
+ <Glyph value="C"/>
+ <Glyph value="D"/>
+ <Glyph value="E"/>
+ <Glyph value="F"/>
+ <Glyph value="G"/>
+ <Glyph value="H"/>
+ </Coverage>
+ <Coverage index="2">
+ <Glyph value="I"/>
+ <Glyph value="J"/>
+ <Glyph value="K"/>
+ <Glyph value="L"/>
+ <Glyph value="M"/>
+ <Glyph value="N"/>
+ <Glyph value="O"/>
+ <Glyph value="P"/>
+ <Glyph value="Q"/>
+ <Glyph value="R"/>
+ <Glyph value="S"/>
+ <Glyph value="T"/>
+ <Glyph value="U"/>
+ <Glyph value="V"/>
+ <Glyph value="W"/>
+ <Glyph value="X"/>
+ <Glyph value="Y"/>
+ <Glyph value="Z"/>
+ </Coverage>
+ </ContextSubst>
+ <ContextSubst index="2" Format="3">
+ <!-- GlyphCount=3 -->
+ <!-- SubstCount=0 -->
+ <Coverage index="0">
+ <Glyph value="a"/>
+ <Glyph value="b"/>
+ <Glyph value="c"/>
+ <Glyph value="d"/>
+ <Glyph value="e"/>
+ <Glyph value="f"/>
+ <Glyph value="g"/>
+ <Glyph value="h"/>
+ <Glyph value="i"/>
+ <Glyph value="j"/>
+ <Glyph value="k"/>
+ <Glyph value="l"/>
+ <Glyph value="m"/>
+ <Glyph value="n"/>
+ <Glyph value="o"/>
+ <Glyph value="p"/>
+ <Glyph value="q"/>
+ <Glyph value="r"/>
+ <Glyph value="s"/>
+ <Glyph value="t"/>
+ <Glyph value="u"/>
+ <Glyph value="v"/>
+ <Glyph value="w"/>
+ <Glyph value="x"/>
+ <Glyph value="y"/>
+ <Glyph value="z"/>
+ </Coverage>
+ <Coverage index="1">
+ <Glyph value="I"/>
+ <Glyph value="J"/>
+ <Glyph value="K"/>
+ <Glyph value="L"/>
+ <Glyph value="M"/>
+ <Glyph value="N"/>
+ <Glyph value="O"/>
+ <Glyph value="P"/>
+ <Glyph value="Q"/>
+ <Glyph value="R"/>
+ <Glyph value="S"/>
+ <Glyph value="T"/>
+ <Glyph value="U"/>
+ <Glyph value="V"/>
+ <Glyph value="W"/>
+ <Glyph value="X"/>
+ <Glyph value="Y"/>
+ <Glyph value="Z"/>
+ </Coverage>
+ <Coverage index="2">
+ <Glyph value="A"/>
+ <Glyph value="B"/>
+ <Glyph value="C"/>
+ <Glyph value="D"/>
+ <Glyph value="E"/>
+ <Glyph value="F"/>
+ <Glyph value="G"/>
+ <Glyph value="H"/>
+ </Coverage>
</ContextSubst>
</Lookup>
<Lookup index="2">
diff --git a/Tests/feaLib/data/GSUB_6_formats.ttx b/Tests/feaLib/data/GSUB_6_formats.ttx
index ad2a1c5e..45d5f3a0 100644
--- a/Tests/feaLib/data/GSUB_6_formats.ttx
+++ b/Tests/feaLib/data/GSUB_6_formats.ttx
@@ -72,9 +72,41 @@
<Lookup index="1">
<LookupType value="6"/>
<LookupFlag value="0"/>
- <!-- SubTableCount=1 -->
- <ChainContextSubst index="0" Format="2">
- <Coverage>
+ <!-- SubTableCount=3 -->
+ <ChainContextSubst index="0" Format="3">
+ <!-- BacktrackGlyphCount=2 -->
+ <BacktrackCoverage index="0">
+ <Glyph value="I"/>
+ <Glyph value="J"/>
+ <Glyph value="K"/>
+ <Glyph value="L"/>
+ <Glyph value="M"/>
+ <Glyph value="N"/>
+ <Glyph value="O"/>
+ <Glyph value="P"/>
+ <Glyph value="Q"/>
+ <Glyph value="R"/>
+ <Glyph value="S"/>
+ <Glyph value="T"/>
+ <Glyph value="U"/>
+ <Glyph value="V"/>
+ <Glyph value="W"/>
+ <Glyph value="X"/>
+ <Glyph value="Y"/>
+ <Glyph value="Z"/>
+ </BacktrackCoverage>
+ <BacktrackCoverage index="1">
+ <Glyph value="A"/>
+ <Glyph value="B"/>
+ <Glyph value="C"/>
+ <Glyph value="D"/>
+ <Glyph value="E"/>
+ <Glyph value="F"/>
+ <Glyph value="G"/>
+ <Glyph value="H"/>
+ </BacktrackCoverage>
+ <!-- InputGlyphCount=3 -->
+ <InputCoverage index="0">
<Glyph value="a"/>
<Glyph value="b"/>
<Glyph value="c"/>
@@ -101,134 +133,227 @@
<Glyph value="x"/>
<Glyph value="y"/>
<Glyph value="z"/>
- </Coverage>
- <BacktrackClassDef>
- <ClassDef glyph="A" class="2"/>
- <ClassDef glyph="B" class="2"/>
- <ClassDef glyph="C" class="2"/>
- <ClassDef glyph="D" class="2"/>
- <ClassDef glyph="E" class="2"/>
- <ClassDef glyph="F" class="2"/>
- <ClassDef glyph="G" class="2"/>
- <ClassDef glyph="H" class="2"/>
- <ClassDef glyph="I" class="1"/>
- <ClassDef glyph="J" class="1"/>
- <ClassDef glyph="K" class="1"/>
- <ClassDef glyph="L" class="1"/>
- <ClassDef glyph="M" class="1"/>
- <ClassDef glyph="N" class="1"/>
- <ClassDef glyph="O" class="1"/>
- <ClassDef glyph="P" class="1"/>
- <ClassDef glyph="Q" class="1"/>
- <ClassDef glyph="R" class="1"/>
- <ClassDef glyph="S" class="1"/>
- <ClassDef glyph="T" class="1"/>
- <ClassDef glyph="U" class="1"/>
- <ClassDef glyph="V" class="1"/>
- <ClassDef glyph="W" class="1"/>
- <ClassDef glyph="X" class="1"/>
- <ClassDef glyph="Y" class="1"/>
- <ClassDef glyph="Z" class="1"/>
- </BacktrackClassDef>
- <InputClassDef>
- <ClassDef glyph="A" class="3"/>
- <ClassDef glyph="B" class="3"/>
- <ClassDef glyph="C" class="3"/>
- <ClassDef glyph="D" class="3"/>
- <ClassDef glyph="E" class="3"/>
- <ClassDef glyph="F" class="3"/>
- <ClassDef glyph="G" class="3"/>
- <ClassDef glyph="H" class="3"/>
- <ClassDef glyph="I" class="2"/>
- <ClassDef glyph="J" class="2"/>
- <ClassDef glyph="K" class="2"/>
- <ClassDef glyph="L" class="2"/>
- <ClassDef glyph="M" class="2"/>
- <ClassDef glyph="N" class="2"/>
- <ClassDef glyph="O" class="2"/>
- <ClassDef glyph="P" class="2"/>
- <ClassDef glyph="Q" class="2"/>
- <ClassDef glyph="R" class="2"/>
- <ClassDef glyph="S" class="2"/>
- <ClassDef glyph="T" class="2"/>
- <ClassDef glyph="U" class="2"/>
- <ClassDef glyph="V" class="2"/>
- <ClassDef glyph="W" class="2"/>
- <ClassDef glyph="X" class="2"/>
- <ClassDef glyph="Y" class="2"/>
- <ClassDef glyph="Z" class="2"/>
- <ClassDef glyph="a" class="1"/>
- <ClassDef glyph="b" class="1"/>
- <ClassDef glyph="c" class="1"/>
- <ClassDef glyph="d" class="1"/>
- <ClassDef glyph="e" class="1"/>
- <ClassDef glyph="f" class="1"/>
- <ClassDef glyph="g" class="1"/>
- <ClassDef glyph="h" class="1"/>
- <ClassDef glyph="i" class="1"/>
- <ClassDef glyph="j" class="1"/>
- <ClassDef glyph="k" class="1"/>
- <ClassDef glyph="l" class="1"/>
- <ClassDef glyph="m" class="1"/>
- <ClassDef glyph="n" class="1"/>
- <ClassDef glyph="o" class="1"/>
- <ClassDef glyph="p" class="1"/>
- <ClassDef glyph="q" class="1"/>
- <ClassDef glyph="r" class="1"/>
- <ClassDef glyph="s" class="1"/>
- <ClassDef glyph="t" class="1"/>
- <ClassDef glyph="u" class="1"/>
- <ClassDef glyph="v" class="1"/>
- <ClassDef glyph="w" class="1"/>
- <ClassDef glyph="x" class="1"/>
- <ClassDef glyph="y" class="1"/>
- <ClassDef glyph="z" class="1"/>
- </InputClassDef>
- <LookAheadClassDef>
- </LookAheadClassDef>
- <!-- ChainSubClassSetCount=4 -->
- <ChainSubClassSet index="0">
- <!-- ChainSubClassRuleCount=0 -->
- </ChainSubClassSet>
- <ChainSubClassSet index="1">
- <!-- ChainSubClassRuleCount=3 -->
- <ChainSubClassRule index="0">
- <!-- BacktrackGlyphCount=2 -->
- <Backtrack index="0" value="1"/>
- <Backtrack index="1" value="2"/>
- <!-- InputGlyphCount=3 -->
- <Input index="0" value="3"/>
- <Input index="1" value="2"/>
- <!-- LookAheadGlyphCount=0 -->
- <!-- SubstCount=0 -->
- </ChainSubClassRule>
- <ChainSubClassRule index="1">
- <!-- BacktrackGlyphCount=2 -->
- <Backtrack index="0" value="2"/>
- <Backtrack index="1" value="1"/>
- <!-- InputGlyphCount=3 -->
- <Input index="0" value="3"/>
- <Input index="1" value="2"/>
- <!-- LookAheadGlyphCount=0 -->
- <!-- SubstCount=0 -->
- </ChainSubClassRule>
- <ChainSubClassRule index="2">
- <!-- BacktrackGlyphCount=2 -->
- <Backtrack index="0" value="1"/>
- <Backtrack index="1" value="2"/>
- <!-- InputGlyphCount=3 -->
- <Input index="0" value="2"/>
- <Input index="1" value="3"/>
- <!-- LookAheadGlyphCount=0 -->
- <!-- SubstCount=0 -->
- </ChainSubClassRule>
- </ChainSubClassSet>
- <ChainSubClassSet index="2">
- <!-- ChainSubClassRuleCount=0 -->
- </ChainSubClassSet>
- <ChainSubClassSet index="3">
- <!-- ChainSubClassRuleCount=0 -->
- </ChainSubClassSet>
+ </InputCoverage>
+ <InputCoverage index="1">
+ <Glyph value="A"/>
+ <Glyph value="B"/>
+ <Glyph value="C"/>
+ <Glyph value="D"/>
+ <Glyph value="E"/>
+ <Glyph value="F"/>
+ <Glyph value="G"/>
+ <Glyph value="H"/>
+ </InputCoverage>
+ <InputCoverage index="2">
+ <Glyph value="I"/>
+ <Glyph value="J"/>
+ <Glyph value="K"/>
+ <Glyph value="L"/>
+ <Glyph value="M"/>
+ <Glyph value="N"/>
+ <Glyph value="O"/>
+ <Glyph value="P"/>
+ <Glyph value="Q"/>
+ <Glyph value="R"/>
+ <Glyph value="S"/>
+ <Glyph value="T"/>
+ <Glyph value="U"/>
+ <Glyph value="V"/>
+ <Glyph value="W"/>
+ <Glyph value="X"/>
+ <Glyph value="Y"/>
+ <Glyph value="Z"/>
+ </InputCoverage>
+ <!-- LookAheadGlyphCount=0 -->
+ <!-- SubstCount=0 -->
+ </ChainContextSubst>
+ <ChainContextSubst index="1" Format="3">
+ <!-- BacktrackGlyphCount=2 -->
+ <BacktrackCoverage index="0">
+ <Glyph value="A"/>
+ <Glyph value="B"/>
+ <Glyph value="C"/>
+ <Glyph value="D"/>
+ <Glyph value="E"/>
+ <Glyph value="F"/>
+ <Glyph value="G"/>
+ <Glyph value="H"/>
+ </BacktrackCoverage>
+ <BacktrackCoverage index="1">
+ <Glyph value="I"/>
+ <Glyph value="J"/>
+ <Glyph value="K"/>
+ <Glyph value="L"/>
+ <Glyph value="M"/>
+ <Glyph value="N"/>
+ <Glyph value="O"/>
+ <Glyph value="P"/>
+ <Glyph value="Q"/>
+ <Glyph value="R"/>
+ <Glyph value="S"/>
+ <Glyph value="T"/>
+ <Glyph value="U"/>
+ <Glyph value="V"/>
+ <Glyph value="W"/>
+ <Glyph value="X"/>
+ <Glyph value="Y"/>
+ <Glyph value="Z"/>
+ </BacktrackCoverage>
+ <!-- InputGlyphCount=3 -->
+ <InputCoverage index="0">
+ <Glyph value="a"/>
+ <Glyph value="b"/>
+ <Glyph value="c"/>
+ <Glyph value="d"/>
+ <Glyph value="e"/>
+ <Glyph value="f"/>
+ <Glyph value="g"/>
+ <Glyph value="h"/>
+ <Glyph value="i"/>
+ <Glyph value="j"/>
+ <Glyph value="k"/>
+ <Glyph value="l"/>
+ <Glyph value="m"/>
+ <Glyph value="n"/>
+ <Glyph value="o"/>
+ <Glyph value="p"/>
+ <Glyph value="q"/>
+ <Glyph value="r"/>
+ <Glyph value="s"/>
+ <Glyph value="t"/>
+ <Glyph value="u"/>
+ <Glyph value="v"/>
+ <Glyph value="w"/>
+ <Glyph value="x"/>
+ <Glyph value="y"/>
+ <Glyph value="z"/>
+ </InputCoverage>
+ <InputCoverage index="1">
+ <Glyph value="A"/>
+ <Glyph value="B"/>
+ <Glyph value="C"/>
+ <Glyph value="D"/>
+ <Glyph value="E"/>
+ <Glyph value="F"/>
+ <Glyph value="G"/>
+ <Glyph value="H"/>
+ </InputCoverage>
+ <InputCoverage index="2">
+ <Glyph value="I"/>
+ <Glyph value="J"/>
+ <Glyph value="K"/>
+ <Glyph value="L"/>
+ <Glyph value="M"/>
+ <Glyph value="N"/>
+ <Glyph value="O"/>
+ <Glyph value="P"/>
+ <Glyph value="Q"/>
+ <Glyph value="R"/>
+ <Glyph value="S"/>
+ <Glyph value="T"/>
+ <Glyph value="U"/>
+ <Glyph value="V"/>
+ <Glyph value="W"/>
+ <Glyph value="X"/>
+ <Glyph value="Y"/>
+ <Glyph value="Z"/>
+ </InputCoverage>
+ <!-- LookAheadGlyphCount=0 -->
+ <!-- SubstCount=0 -->
+ </ChainContextSubst>
+ <ChainContextSubst index="2" Format="3">
+ <!-- BacktrackGlyphCount=2 -->
+ <BacktrackCoverage index="0">
+ <Glyph value="I"/>
+ <Glyph value="J"/>
+ <Glyph value="K"/>
+ <Glyph value="L"/>
+ <Glyph value="M"/>
+ <Glyph value="N"/>
+ <Glyph value="O"/>
+ <Glyph value="P"/>
+ <Glyph value="Q"/>
+ <Glyph value="R"/>
+ <Glyph value="S"/>
+ <Glyph value="T"/>
+ <Glyph value="U"/>
+ <Glyph value="V"/>
+ <Glyph value="W"/>
+ <Glyph value="X"/>
+ <Glyph value="Y"/>
+ <Glyph value="Z"/>
+ </BacktrackCoverage>
+ <BacktrackCoverage index="1">
+ <Glyph value="A"/>
+ <Glyph value="B"/>
+ <Glyph value="C"/>
+ <Glyph value="D"/>
+ <Glyph value="E"/>
+ <Glyph value="F"/>
+ <Glyph value="G"/>
+ <Glyph value="H"/>
+ </BacktrackCoverage>
+ <!-- InputGlyphCount=3 -->
+ <InputCoverage index="0">
+ <Glyph value="a"/>
+ <Glyph value="b"/>
+ <Glyph value="c"/>
+ <Glyph value="d"/>
+ <Glyph value="e"/>
+ <Glyph value="f"/>
+ <Glyph value="g"/>
+ <Glyph value="h"/>
+ <Glyph value="i"/>
+ <Glyph value="j"/>
+ <Glyph value="k"/>
+ <Glyph value="l"/>
+ <Glyph value="m"/>
+ <Glyph value="n"/>
+ <Glyph value="o"/>
+ <Glyph value="p"/>
+ <Glyph value="q"/>
+ <Glyph value="r"/>
+ <Glyph value="s"/>
+ <Glyph value="t"/>
+ <Glyph value="u"/>
+ <Glyph value="v"/>
+ <Glyph value="w"/>
+ <Glyph value="x"/>
+ <Glyph value="y"/>
+ <Glyph value="z"/>
+ </InputCoverage>
+ <InputCoverage index="1">
+ <Glyph value="I"/>
+ <Glyph value="J"/>
+ <Glyph value="K"/>
+ <Glyph value="L"/>
+ <Glyph value="M"/>
+ <Glyph value="N"/>
+ <Glyph value="O"/>
+ <Glyph value="P"/>
+ <Glyph value="Q"/>
+ <Glyph value="R"/>
+ <Glyph value="S"/>
+ <Glyph value="T"/>
+ <Glyph value="U"/>
+ <Glyph value="V"/>
+ <Glyph value="W"/>
+ <Glyph value="X"/>
+ <Glyph value="Y"/>
+ <Glyph value="Z"/>
+ </InputCoverage>
+ <InputCoverage index="2">
+ <Glyph value="A"/>
+ <Glyph value="B"/>
+ <Glyph value="C"/>
+ <Glyph value="D"/>
+ <Glyph value="E"/>
+ <Glyph value="F"/>
+ <Glyph value="G"/>
+ <Glyph value="H"/>
+ </InputCoverage>
+ <!-- LookAheadGlyphCount=0 -->
+ <!-- SubstCount=0 -->
</ChainContextSubst>
</Lookup>
<Lookup index="2">
diff --git a/Tests/feaLib/data/spec4h1.ttx b/Tests/feaLib/data/spec4h1.ttx
index 0e42fc56..a399ab2b 100644
--- a/Tests/feaLib/data/spec4h1.ttx
+++ b/Tests/feaLib/data/spec4h1.ttx
@@ -147,8 +147,8 @@
<!-- SubTableCount=1 -->
<LigatureSubst index="0">
<LigatureSet glyph="c">
- <Ligature components="s" glyph="c_s"/>
<Ligature components="t" glyph="c_t"/>
+ <Ligature components="s" glyph="c_s"/>
</LigatureSet>
</LigatureSubst>
</Lookup>
diff --git a/Tests/feaLib/data/spec5d1.fea b/Tests/feaLib/data/spec5d1.fea
index cf7d76f7..0b5acdd3 100644
--- a/Tests/feaLib/data/spec5d1.fea
+++ b/Tests/feaLib/data/spec5d1.fea
@@ -11,6 +11,16 @@ feature F1 {
# if glyph classes are detected in <glyph sequence>. Thus, the above
# example produces an identical representation in the font as if all
# the sequences were manually enumerated by the font editor:
+#
+# NOTE(anthrotype): The previous sentence is no longer entirely true, since we
+# now preserve the order in which the ligatures (with same length and first glyph)
+# were specified in the feature file and do not sort them alphabetically
+# by the ligature component names. Therefore, the way this particular example from
+# the FEA spec is written will produce two slightly different representations
+# in the font in which the ligatures are enumerated differently, however the two
+# lookups are functionally equivalent.
+# See: https://github.com/fonttools/fonttools/issues/3428
+# https://github.com/adobe-type-tools/afdko/issues/1727
feature F2 {
sub one slash two by onehalf;
sub one.oldstyle slash two by onehalf;
diff --git a/Tests/feaLib/data/spec5d1.ttx b/Tests/feaLib/data/spec5d1.ttx
index 77dfc93b..8763c931 100644
--- a/Tests/feaLib/data/spec5d1.ttx
+++ b/Tests/feaLib/data/spec5d1.ttx
@@ -43,16 +43,16 @@
<!-- SubTableCount=1 -->
<LigatureSubst index="0">
<LigatureSet glyph="one">
- <Ligature components="fraction,two" glyph="onehalf"/>
- <Ligature components="fraction,two.oldstyle" glyph="onehalf"/>
<Ligature components="slash,two" glyph="onehalf"/>
<Ligature components="slash,two.oldstyle" glyph="onehalf"/>
- </LigatureSet>
- <LigatureSet glyph="one.oldstyle">
<Ligature components="fraction,two" glyph="onehalf"/>
<Ligature components="fraction,two.oldstyle" glyph="onehalf"/>
+ </LigatureSet>
+ <LigatureSet glyph="one.oldstyle">
<Ligature components="slash,two" glyph="onehalf"/>
<Ligature components="slash,two.oldstyle" glyph="onehalf"/>
+ <Ligature components="fraction,two" glyph="onehalf"/>
+ <Ligature components="fraction,two.oldstyle" glyph="onehalf"/>
</LigatureSet>
</LigatureSubst>
</Lookup>
@@ -62,16 +62,16 @@
<!-- SubTableCount=1 -->
<LigatureSubst index="0">
<LigatureSet glyph="one">
- <Ligature components="fraction,two" glyph="onehalf"/>
- <Ligature components="fraction,two.oldstyle" glyph="onehalf"/>
<Ligature components="slash,two" glyph="onehalf"/>
+ <Ligature components="fraction,two" glyph="onehalf"/>
<Ligature components="slash,two.oldstyle" glyph="onehalf"/>
+ <Ligature components="fraction,two.oldstyle" glyph="onehalf"/>
</LigatureSet>
<LigatureSet glyph="one.oldstyle">
- <Ligature components="fraction,two" glyph="onehalf"/>
- <Ligature components="fraction,two.oldstyle" glyph="onehalf"/>
<Ligature components="slash,two" glyph="onehalf"/>
+ <Ligature components="fraction,two" glyph="onehalf"/>
<Ligature components="slash,two.oldstyle" glyph="onehalf"/>
+ <Ligature components="fraction,two.oldstyle" glyph="onehalf"/>
</LigatureSet>
</LigatureSubst>
</Lookup>
diff --git a/Tests/feaLib/data/spec5f_ii_3.ttx b/Tests/feaLib/data/spec5f_ii_3.ttx
index c03a81fb..6ef871b8 100644
--- a/Tests/feaLib/data/spec5f_ii_3.ttx
+++ b/Tests/feaLib/data/spec5f_ii_3.ttx
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
-<ttFont sfntVersion="true" ttLibVersion="3.0">
+<ttFont>
<GSUB>
<Version value="0x00010000"/>
@@ -32,115 +32,111 @@
<Lookup index="0">
<LookupType value="6"/>
<LookupFlag value="0"/>
- <!-- SubTableCount=1 -->
- <ChainContextSubst index="0" Format="2">
- <Coverage>
+ <!-- SubTableCount=3 -->
+ <ChainContextSubst index="0" Format="3">
+ <!-- BacktrackGlyphCount=1 -->
+ <BacktrackCoverage index="0">
+ <Glyph value="a"/>
+ <Glyph value="b"/>
+ <Glyph value="c"/>
+ <Glyph value="d"/>
+ <Glyph value="e"/>
+ <Glyph value="f"/>
+ <Glyph value="g"/>
+ <Glyph value="h"/>
+ <Glyph value="i"/>
+ <Glyph value="j"/>
+ <Glyph value="k"/>
+ <Glyph value="l"/>
+ <Glyph value="m"/>
+ <Glyph value="n"/>
+ <Glyph value="o"/>
+ <Glyph value="p"/>
+ <Glyph value="q"/>
+ <Glyph value="r"/>
+ <Glyph value="s"/>
+ <Glyph value="t"/>
+ <Glyph value="u"/>
+ <Glyph value="v"/>
+ <Glyph value="w"/>
+ <Glyph value="x"/>
+ <Glyph value="y"/>
+ <Glyph value="z"/>
+ </BacktrackCoverage>
+ <!-- InputGlyphCount=3 -->
+ <InputCoverage index="0">
+ <Glyph value="a"/>
+ </InputCoverage>
+ <InputCoverage index="1">
+ <Glyph value="n"/>
+ </InputCoverage>
+ <InputCoverage index="2">
+ <Glyph value="d"/>
+ </InputCoverage>
+ <!-- LookAheadGlyphCount=0 -->
+ <!-- SubstCount=0 -->
+ </ChainContextSubst>
+ <ChainContextSubst index="1" Format="3">
+ <!-- BacktrackGlyphCount=0 -->
+ <!-- InputGlyphCount=3 -->
+ <InputCoverage index="0">
+ <Glyph value="a"/>
+ </InputCoverage>
+ <InputCoverage index="1">
+ <Glyph value="n"/>
+ </InputCoverage>
+ <InputCoverage index="2">
+ <Glyph value="d"/>
+ </InputCoverage>
+ <!-- LookAheadGlyphCount=1 -->
+ <LookAheadCoverage index="0">
+ <Glyph value="a"/>
+ <Glyph value="b"/>
+ <Glyph value="c"/>
+ <Glyph value="d"/>
+ <Glyph value="e"/>
+ <Glyph value="f"/>
+ <Glyph value="g"/>
+ <Glyph value="h"/>
+ <Glyph value="i"/>
+ <Glyph value="j"/>
+ <Glyph value="k"/>
+ <Glyph value="l"/>
+ <Glyph value="m"/>
+ <Glyph value="n"/>
+ <Glyph value="o"/>
+ <Glyph value="p"/>
+ <Glyph value="q"/>
+ <Glyph value="r"/>
+ <Glyph value="s"/>
+ <Glyph value="t"/>
+ <Glyph value="u"/>
+ <Glyph value="v"/>
+ <Glyph value="w"/>
+ <Glyph value="x"/>
+ <Glyph value="y"/>
+ <Glyph value="z"/>
+ </LookAheadCoverage>
+ <!-- SubstCount=0 -->
+ </ChainContextSubst>
+ <ChainContextSubst index="2" Format="3">
+ <!-- BacktrackGlyphCount=0 -->
+ <!-- InputGlyphCount=3 -->
+ <InputCoverage index="0">
<Glyph value="a"/>
- </Coverage>
- <BacktrackClassDef>
- <ClassDef glyph="a" class="1"/>
- <ClassDef glyph="b" class="1"/>
- <ClassDef glyph="c" class="1"/>
- <ClassDef glyph="d" class="1"/>
- <ClassDef glyph="e" class="1"/>
- <ClassDef glyph="f" class="1"/>
- <ClassDef glyph="g" class="1"/>
- <ClassDef glyph="h" class="1"/>
- <ClassDef glyph="i" class="1"/>
- <ClassDef glyph="j" class="1"/>
- <ClassDef glyph="k" class="1"/>
- <ClassDef glyph="l" class="1"/>
- <ClassDef glyph="m" class="1"/>
- <ClassDef glyph="n" class="1"/>
- <ClassDef glyph="o" class="1"/>
- <ClassDef glyph="p" class="1"/>
- <ClassDef glyph="q" class="1"/>
- <ClassDef glyph="r" class="1"/>
- <ClassDef glyph="s" class="1"/>
- <ClassDef glyph="t" class="1"/>
- <ClassDef glyph="u" class="1"/>
- <ClassDef glyph="v" class="1"/>
- <ClassDef glyph="w" class="1"/>
- <ClassDef glyph="x" class="1"/>
- <ClassDef glyph="y" class="1"/>
- <ClassDef glyph="z" class="1"/>
- </BacktrackClassDef>
- <InputClassDef>
- <ClassDef glyph="a" class="1"/>
- <ClassDef glyph="d" class="2"/>
- <ClassDef glyph="n" class="3"/>
- </InputClassDef>
- <LookAheadClassDef>
- <ClassDef glyph="a" class="1"/>
- <ClassDef glyph="b" class="1"/>
- <ClassDef glyph="c" class="1"/>
- <ClassDef glyph="d" class="1"/>
- <ClassDef glyph="e" class="1"/>
- <ClassDef glyph="f" class="1"/>
- <ClassDef glyph="g" class="1"/>
- <ClassDef glyph="h" class="1"/>
- <ClassDef glyph="i" class="1"/>
- <ClassDef glyph="j" class="1"/>
- <ClassDef glyph="k" class="1"/>
- <ClassDef glyph="l" class="1"/>
- <ClassDef glyph="m" class="1"/>
- <ClassDef glyph="n" class="1"/>
- <ClassDef glyph="o" class="1"/>
- <ClassDef glyph="p" class="1"/>
- <ClassDef glyph="q" class="1"/>
- <ClassDef glyph="r" class="1"/>
- <ClassDef glyph="s" class="1"/>
- <ClassDef glyph="t" class="1"/>
- <ClassDef glyph="u" class="1"/>
- <ClassDef glyph="v" class="1"/>
- <ClassDef glyph="w" class="1"/>
- <ClassDef glyph="x" class="1"/>
- <ClassDef glyph="y" class="1"/>
- <ClassDef glyph="z" class="1"/>
- </LookAheadClassDef>
- <!-- ChainSubClassSetCount=4 -->
- <ChainSubClassSet index="0">
- <!-- ChainSubClassRuleCount=0 -->
- </ChainSubClassSet>
- <ChainSubClassSet index="1">
- <!-- ChainSubClassRuleCount=3 -->
- <ChainSubClassRule index="0">
- <!-- BacktrackGlyphCount=1 -->
- <Backtrack index="0" value="1"/>
- <!-- InputGlyphCount=3 -->
- <Input index="0" value="3"/>
- <Input index="1" value="2"/>
- <!-- LookAheadGlyphCount=0 -->
- <!-- SubstCount=0 -->
- </ChainSubClassRule>
- <ChainSubClassRule index="1">
- <!-- BacktrackGlyphCount=0 -->
- <!-- InputGlyphCount=3 -->
- <Input index="0" value="3"/>
- <Input index="1" value="2"/>
- <!-- LookAheadGlyphCount=1 -->
- <LookAhead index="0" value="1"/>
- <!-- SubstCount=0 -->
- </ChainSubClassRule>
- <ChainSubClassRule index="2">
- <!-- BacktrackGlyphCount=0 -->
- <!-- InputGlyphCount=3 -->
- <Input index="0" value="3"/>
- <Input index="1" value="2"/>
- <!-- LookAheadGlyphCount=0 -->
- <!-- SubstCount=1 -->
- <SubstLookupRecord index="0">
- <SequenceIndex value="0"/>
- <LookupListIndex value="1"/>
- </SubstLookupRecord>
- </ChainSubClassRule>
- </ChainSubClassSet>
- <ChainSubClassSet index="2">
- <!-- ChainSubClassRuleCount=0 -->
- </ChainSubClassSet>
- <ChainSubClassSet index="3">
- <!-- ChainSubClassRuleCount=0 -->
- </ChainSubClassSet>
+ </InputCoverage>
+ <InputCoverage index="1">
+ <Glyph value="n"/>
+ </InputCoverage>
+ <InputCoverage index="2">
+ <Glyph value="d"/>
+ </InputCoverage>
+ <!-- LookAheadGlyphCount=0 -->
+ <!-- SubstCount=1 -->
+ <SubstLookupRecord index="0">
+ <SequenceIndex value="0"/>
+ <LookupListIndex value="1"/>
+ </SubstLookupRecord>
</ChainContextSubst>
</Lookup>
<Lookup index="1">
diff --git a/Tests/feaLib/data/spec8a.ttx b/Tests/feaLib/data/spec8a.ttx
index 787ecfa1..9c8c758e 100644
--- a/Tests/feaLib/data/spec8a.ttx
+++ b/Tests/feaLib/data/spec8a.ttx
@@ -99,18 +99,18 @@
<!-- SubTableCount=1 -->
<AlternateSubst index="0">
<AlternateSet glyph="a">
- <Alternate glyph="A.sc"/>
<Alternate glyph="a.alt1"/>
<Alternate glyph="a.alt2"/>
<Alternate glyph="a.alt3"/>
+ <Alternate glyph="A.sc"/>
</AlternateSet>
<AlternateSet glyph="b">
- <Alternate glyph="B.sc"/>
<Alternate glyph="b.alt"/>
+ <Alternate glyph="B.sc"/>
</AlternateSet>
<AlternateSet glyph="c">
- <Alternate glyph="C.sc"/>
<Alternate glyph="c.mid"/>
+ <Alternate glyph="C.sc"/>
</AlternateSet>
<AlternateSet glyph="d">
<Alternate glyph="d.alt"/>
diff --git a/Tests/feaLib/data/variable_mark_anchor.fea b/Tests/feaLib/data/variable_mark_anchor.fea
new file mode 100644
index 00000000..39ead93c
--- /dev/null
+++ b/Tests/feaLib/data/variable_mark_anchor.fea
@@ -0,0 +1,10 @@
+markClass macron <anchor 0 (wght=200:150 wght=900:152)> @MC_top;
+
+lookup one {
+ pos base a
+ <anchor 0 0> mark @MC_top;
+} one;
+lookup two {
+ pos base a
+ <anchor 0 0> mark @MC_top;
+} two;
diff --git a/Tests/feaLib/data/variable_mark_anchor.ttx b/Tests/feaLib/data/variable_mark_anchor.ttx
new file mode 100644
index 00000000..962cff74
--- /dev/null
+++ b/Tests/feaLib/data/variable_mark_anchor.ttx
@@ -0,0 +1,128 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ttFont>
+
+ <GDEF>
+ <Version value="0x00010003"/>
+ <GlyphClassDef>
+ <ClassDef glyph="a" class="1"/>
+ <ClassDef glyph="macron" class="3"/>
+ </GlyphClassDef>
+ <VarStore Format="1">
+ <Format value="1"/>
+ <VarRegionList>
+ <!-- RegionAxisCount=2 -->
+ <!-- RegionCount=1 -->
+ <Region index="0">
+ <VarRegionAxis index="0">
+ <StartCoord value="0.0"/>
+ <PeakCoord value="0.875"/>
+ <EndCoord value="0.875"/>
+ </VarRegionAxis>
+ <VarRegionAxis index="1">
+ <StartCoord value="0.0"/>
+ <PeakCoord value="0.0"/>
+ <EndCoord value="0.0"/>
+ </VarRegionAxis>
+ </Region>
+ </VarRegionList>
+ <!-- VarDataCount=1 -->
+ <VarData index="0">
+ <!-- ItemCount=1 -->
+ <NumShorts value="0"/>
+ <!-- VarRegionCount=1 -->
+ <VarRegionIndex index="0" value="0"/>
+ <Item index="0" value="[2]"/>
+ </VarData>
+ </VarStore>
+ </GDEF>
+
+ <GPOS>
+ <Version value="0x00010000"/>
+ <ScriptList>
+ <!-- ScriptCount=0 -->
+ </ScriptList>
+ <FeatureList>
+ <!-- FeatureCount=0 -->
+ </FeatureList>
+ <LookupList>
+ <!-- LookupCount=2 -->
+ <Lookup index="0">
+ <LookupType value="4"/>
+ <LookupFlag value="0"/>
+ <!-- SubTableCount=1 -->
+ <MarkBasePos index="0" Format="1">
+ <MarkCoverage>
+ <Glyph value="macron"/>
+ </MarkCoverage>
+ <BaseCoverage>
+ <Glyph value="a"/>
+ </BaseCoverage>
+ <!-- ClassCount=1 -->
+ <MarkArray>
+ <!-- MarkCount=1 -->
+ <MarkRecord index="0">
+ <Class value="0"/>
+ <MarkAnchor Format="3">
+ <XCoordinate value="0"/>
+ <YCoordinate value="150"/>
+ <YDeviceTable>
+ <StartSize value="0"/>
+ <EndSize value="0"/>
+ <DeltaFormat value="32768"/>
+ </YDeviceTable>
+ </MarkAnchor>
+ </MarkRecord>
+ </MarkArray>
+ <BaseArray>
+ <!-- BaseCount=1 -->
+ <BaseRecord index="0">
+ <BaseAnchor index="0" Format="1">
+ <XCoordinate value="0"/>
+ <YCoordinate value="0"/>
+ </BaseAnchor>
+ </BaseRecord>
+ </BaseArray>
+ </MarkBasePos>
+ </Lookup>
+ <Lookup index="1">
+ <LookupType value="4"/>
+ <LookupFlag value="0"/>
+ <!-- SubTableCount=1 -->
+ <MarkBasePos index="0" Format="1">
+ <MarkCoverage>
+ <Glyph value="macron"/>
+ </MarkCoverage>
+ <BaseCoverage>
+ <Glyph value="a"/>
+ </BaseCoverage>
+ <!-- ClassCount=1 -->
+ <MarkArray>
+ <!-- MarkCount=1 -->
+ <MarkRecord index="0">
+ <Class value="0"/>
+ <MarkAnchor Format="3">
+ <XCoordinate value="0"/>
+ <YCoordinate value="150"/>
+ <YDeviceTable>
+ <StartSize value="0"/>
+ <EndSize value="0"/>
+ <DeltaFormat value="32768"/>
+ </YDeviceTable>
+ </MarkAnchor>
+ </MarkRecord>
+ </MarkArray>
+ <BaseArray>
+ <!-- BaseCount=1 -->
+ <BaseRecord index="0">
+ <BaseAnchor index="0" Format="1">
+ <XCoordinate value="0"/>
+ <YCoordinate value="0"/>
+ </BaseAnchor>
+ </BaseRecord>
+ </BaseArray>
+ </MarkBasePos>
+ </Lookup>
+ </LookupList>
+ </GPOS>
+
+</ttFont>
diff --git a/Tests/feaLib/lexer_test.py b/Tests/feaLib/lexer_test.py
index 317a9a8c..21db4e59 100644
--- a/Tests/feaLib/lexer_test.py
+++ b/Tests/feaLib/lexer_test.py
@@ -41,9 +41,7 @@ class LexerTest(unittest.TestCase):
self.assertEqual(lex("@Vowel-sc"), [(Lexer.GLYPHCLASS, "Vowel-sc")])
self.assertRaisesRegex(FeatureLibError, "Expected glyph class", lex, "@(a)")
self.assertRaisesRegex(FeatureLibError, "Expected glyph class", lex, "@ A")
- self.assertRaisesRegex(
- FeatureLibError, "not be longer than 63 characters", lex, "@" + ("A" * 64)
- )
+ self.assertEqual(lex("@" + ("A" * 600)), [(Lexer.GLYPHCLASS, "A" * 600)])
self.assertRaisesRegex(
FeatureLibError, "Glyph class names must consist of", lex, "@Ab:c"
)
diff --git a/Tests/feaLib/parser_test.py b/Tests/feaLib/parser_test.py
index c140629a..bee00d9d 100644
--- a/Tests/feaLib/parser_test.py
+++ b/Tests/feaLib/parser_test.py
@@ -54,6 +54,7 @@ GLYPHNAMES = (
"""
).split()
+ ["foo.%d" % i for i in range(1, 200)]
+ + ["G" * 600]
)
@@ -327,12 +328,10 @@ class ParserTest(unittest.TestCase):
self.assertEqual(gc.glyphSet(), ("endash", "emdash", "figuredash"))
def test_glyphclass_glyphNameTooLong(self):
- self.assertRaisesRegex(
- FeatureLibError,
- "must not be longer than 63 characters",
- self.parse,
- "@GlyphClass = [%s];" % ("G" * 64),
- )
+ gname = "G" * 600
+ [gc] = self.parse(f"@GlyphClass = [{gname}];").statements
+ self.assertEqual(gc.name, "GlyphClass")
+ self.assertEqual(gc.glyphSet(), (gname,))
def test_glyphclass_bad(self):
self.assertRaisesRegex(
diff --git a/Tests/misc/bezierTools_test.py b/Tests/misc/bezierTools_test.py
index 8a3e2ecd..ce8a9e17 100644
--- a/Tests/misc/bezierTools_test.py
+++ b/Tests/misc/bezierTools_test.py
@@ -4,6 +4,7 @@ from fontTools.misc.bezierTools import (
calcQuadraticArcLength,
calcCubicBounds,
curveLineIntersections,
+ curveCurveIntersections,
segmentPointAtT,
splitLine,
splitQuadratic,
@@ -189,3 +190,10 @@ def test_calcQuadraticArcLength():
assert calcQuadraticArcLength(
(210, 333), (289, 333), (326.5, 290.5)
) == pytest.approx(127.9225)
+
+
+def test_intersections_linelike():
+ seg1 = [(0.0, 0.0), (0.0, 0.25), (0.0, 0.75), (0.0, 1.0)]
+ seg2 = [(0.0, 0.5), (0.25, 0.5), (0.75, 0.5), (1.0, 0.5)]
+ pt = curveCurveIntersections(seg1, seg2)[0][0]
+ assert pt == (0.0, 0.5)
diff --git a/Tests/misc/symfont_test.py b/Tests/misc/symfont_test.py
new file mode 100644
index 00000000..3e2feef7
--- /dev/null
+++ b/Tests/misc/symfont_test.py
@@ -0,0 +1,44 @@
+try:
+ from fontTools.misc.symfont import AreaPen
+except ImportError:
+ AreaPen = None
+import unittest
+import pytest
+
+precision = 6
+
+
+def draw1_(pen):
+ pen.moveTo((254, 360))
+ pen.lineTo((771, 367))
+ pen.curveTo((800, 393), (808, 399), (819, 412))
+ pen.curveTo((818, 388), (774, 138), (489, 145))
+ pen.curveTo((188, 145), (200, 398), (200, 421))
+ pen.curveTo((209, 409), (220, 394), (254, 360))
+ pen.closePath()
+
+
+class AreaPenTest(unittest.TestCase):
+ @pytest.mark.skipif(AreaPen is None, reason="sympy not installed")
+ def test_PScontour_clockwise_line_first(self):
+ pen = AreaPen(glyphset=None)
+ draw1_(pen)
+ self.assertEqual(-104561.35, round(pen.value, precision))
+
+ @pytest.mark.skipif(AreaPen is None, reason="sympy not installed")
+ def test_openPaths(self):
+ pen = AreaPen()
+ pen.moveTo((0, 0))
+ pen.endPath()
+ self.assertEqual(0, pen.value)
+
+ pen.moveTo((0, 0))
+ pen.lineTo((1, 0))
+ with self.assertRaises(NotImplementedError):
+ pen.endPath()
+
+
+if __name__ == "__main__":
+ import sys
+
+ sys.exit(unittest.main())
diff --git a/Tests/mtiLib/data/mti/gsubligature.ttx.GSUB b/Tests/mtiLib/data/mti/gsubligature.ttx.GSUB
index 5ad20184..d7575117 100644
--- a/Tests/mtiLib/data/mti/gsubligature.ttx.GSUB
+++ b/Tests/mtiLib/data/mti/gsubligature.ttx.GSUB
@@ -16,20 +16,20 @@
<Ligature components="Jsmall" glyph="IJsmall"/>
</LigatureSet>
<LigatureSet glyph="f">
- <Ligature components="f,b" glyph="ffb"/>
- <Ligature components="f,h" glyph="ffh"/>
<Ligature components="f,i" glyph="ffi"/>
- <Ligature components="f,k" glyph="ffk"/>
<Ligature components="f,l" glyph="ffl"/>
<Ligature components="f,t" glyph="fft"/>
- <Ligature components="b" glyph="fb"/>
- <Ligature components="f" glyph="ff"/>
- <Ligature components="h" glyph="fh"/>
+ <Ligature components="f,b" glyph="ffb"/>
+ <Ligature components="f,h" glyph="ffh"/>
+ <Ligature components="f,k" glyph="ffk"/>
<Ligature components="i" glyph="fi"/>
- <Ligature components="j" glyph="fj"/>
- <Ligature components="k" glyph="fk"/>
<Ligature components="l" glyph="fl"/>
+ <Ligature components="f" glyph="ff"/>
<Ligature components="t" glyph="ft"/>
+ <Ligature components="b" glyph="fb"/>
+ <Ligature components="h" glyph="fh"/>
+ <Ligature components="k" glyph="fk"/>
+ <Ligature components="j" glyph="fj"/>
</LigatureSet>
<LigatureSet glyph="i">
<Ligature components="j" glyph="ij"/>
diff --git a/Tests/otlLib/builder_test.py b/Tests/otlLib/builder_test.py
index b7a6caa2..0d0b213f 100644
--- a/Tests/otlLib/builder_test.py
+++ b/Tests/otlLib/builder_test.py
@@ -1051,11 +1051,11 @@ class BuilderTest(object):
func = lambda writer, font: value.toXML(writer, font, valueName="Val")
assert getXML(func) == ['<Val XPlacement="7" YPlacement="23"/>']
- def test_getLigatureKey(self):
+ def test_getLigatureSortKey(self):
components = lambda s: [tuple(word) for word in s.split()]
c = components("fi fl ff ffi fff")
- c.sort(key=builder._getLigatureKey)
- assert c == components("fff ffi ff fi fl")
+ c.sort(key=otTables.LigatureSubst._getLigatureSortKey)
+ assert c == components("ffi fff fi fl ff")
def test_getSinglePosValueKey(self):
device = builder.buildDevice({10: 1, 11: 3})
@@ -1549,6 +1549,310 @@ def test_stat_infinities():
assert struct.pack(">l", posInf) == b"\x7f\xff\xff\xff"
+def test_buildMathTable_empty():
+ ttFont = ttLib.TTFont()
+ ttFont.setGlyphOrder([])
+ builder.buildMathTable(ttFont)
+
+ assert "MATH" in ttFont
+ mathTable = ttFont["MATH"].table
+ assert mathTable.Version == 0x00010000
+
+ assert mathTable.MathConstants is None
+ assert mathTable.MathGlyphInfo is None
+ assert mathTable.MathVariants is None
+
+
+def test_buildMathTable_constants():
+ ttFont = ttLib.TTFont()
+ ttFont.setGlyphOrder([])
+ constants = {
+ "AccentBaseHeight": 516,
+ "AxisHeight": 262,
+ "DelimitedSubFormulaMinHeight": 1500,
+ "DisplayOperatorMinHeight": 2339,
+ "FlattenedAccentBaseHeight": 698,
+ "FractionDenomDisplayStyleGapMin": 198,
+ "FractionDenominatorDisplayStyleShiftDown": 698,
+ "FractionDenominatorGapMin": 66,
+ "FractionDenominatorShiftDown": 465,
+ "FractionNumDisplayStyleGapMin": 198,
+ "FractionNumeratorDisplayStyleShiftUp": 774,
+ "FractionNumeratorGapMin": 66,
+ "FractionNumeratorShiftUp": 516,
+ "FractionRuleThickness": 66,
+ "LowerLimitBaselineDropMin": 585,
+ "LowerLimitGapMin": 132,
+ "MathLeading": 300,
+ "OverbarExtraAscender": 66,
+ "OverbarRuleThickness": 66,
+ "OverbarVerticalGap": 198,
+ "RadicalDegreeBottomRaisePercent": 75,
+ "RadicalDisplayStyleVerticalGap": 195,
+ "RadicalExtraAscender": 66,
+ "RadicalKernAfterDegree": -556,
+ "RadicalKernBeforeDegree": 278,
+ "RadicalRuleThickness": 66,
+ "RadicalVerticalGap": 82,
+ "ScriptPercentScaleDown": 70,
+ "ScriptScriptPercentScaleDown": 55,
+ "SkewedFractionHorizontalGap": 66,
+ "SkewedFractionVerticalGap": 77,
+ "SpaceAfterScript": 42,
+ "StackBottomDisplayStyleShiftDown": 698,
+ "StackBottomShiftDown": 465,
+ "StackDisplayStyleGapMin": 462,
+ "StackGapMin": 198,
+ "StackTopDisplayStyleShiftUp": 774,
+ "StackTopShiftUp": 516,
+ "StretchStackBottomShiftDown": 585,
+ "StretchStackGapAboveMin": 132,
+ "StretchStackGapBelowMin": 132,
+ "StretchStackTopShiftUp": 165,
+ "SubSuperscriptGapMin": 264,
+ "SubscriptBaselineDropMin": 105,
+ "SubscriptShiftDown": 140,
+ "SubscriptTopMax": 413,
+ "SuperscriptBaselineDropMax": 221,
+ "SuperscriptBottomMaxWithSubscript": 413,
+ "SuperscriptBottomMin": 129,
+ "SuperscriptShiftUp": 477,
+ "SuperscriptShiftUpCramped": 358,
+ "UnderbarExtraDescender": 66,
+ "UnderbarRuleThickness": 66,
+ "UnderbarVerticalGap": 198,
+ "UpperLimitBaselineRiseMin": 165,
+ "UpperLimitGapMin": 132,
+ }
+ builder.buildMathTable(ttFont, constants=constants)
+ mathTable = ttFont["MATH"].table
+ assert mathTable.MathConstants
+ assert mathTable.MathGlyphInfo is None
+ assert mathTable.MathVariants is None
+ for k, v in constants.items():
+ r = getattr(mathTable.MathConstants, k)
+ try:
+ r = r.Value
+ except AttributeError:
+ pass
+ assert r == v
+
+
+def test_buildMathTable_italicsCorrection():
+ ttFont = ttLib.TTFont()
+ ttFont.setGlyphOrder(["A", "B", "C", "D", "E", "F", "G", "H", "I", "J"])
+ italicsCorrections = {"A": 100, "C": 300, "D": 400, "E": 500}
+ builder.buildMathTable(ttFont, italicsCorrections=italicsCorrections)
+ mathTable = ttFont["MATH"].table
+ assert mathTable.MathConstants is None
+ assert mathTable.MathGlyphInfo
+ assert mathTable.MathVariants is None
+ assert set(
+ mathTable.MathGlyphInfo.MathItalicsCorrectionInfo.Coverage.glyphs
+ ) == set(italicsCorrections.keys())
+ for glyph, correction in zip(
+ mathTable.MathGlyphInfo.MathItalicsCorrectionInfo.Coverage.glyphs,
+ mathTable.MathGlyphInfo.MathItalicsCorrectionInfo.ItalicsCorrection,
+ ):
+ assert correction.Value == italicsCorrections[glyph]
+
+
+def test_buildMathTable_topAccentAttachment():
+ ttFont = ttLib.TTFont()
+ ttFont.setGlyphOrder(["A", "B", "C", "D", "E", "F", "G", "H", "I", "J"])
+ topAccentAttachments = {"A": 10, "B": 20, "C": 30, "E": 50}
+ builder.buildMathTable(ttFont, topAccentAttachments=topAccentAttachments)
+ mathTable = ttFont["MATH"].table
+ assert mathTable.MathConstants is None
+ assert mathTable.MathGlyphInfo
+ assert mathTable.MathVariants is None
+ assert set(
+ mathTable.MathGlyphInfo.MathTopAccentAttachment.TopAccentCoverage.glyphs
+ ) == set(topAccentAttachments.keys())
+ for glyph, attachment in zip(
+ mathTable.MathGlyphInfo.MathTopAccentAttachment.TopAccentCoverage.glyphs,
+ mathTable.MathGlyphInfo.MathTopAccentAttachment.TopAccentAttachment,
+ ):
+ assert attachment.Value == topAccentAttachments[glyph]
+
+
+def test_buildMathTable_extendedShape():
+ ttFont = ttLib.TTFont()
+ ttFont.setGlyphOrder(["A", "B", "C", "D", "E", "F", "G", "H", "I", "J"])
+ extendedShapes = {"A", "C", "E", "F"}
+ builder.buildMathTable(ttFont, extendedShapes=extendedShapes)
+ mathTable = ttFont["MATH"].table
+ assert mathTable.MathConstants is None
+ assert mathTable.MathGlyphInfo
+ assert mathTable.MathVariants is None
+ assert set(mathTable.MathGlyphInfo.ExtendedShapeCoverage.glyphs) == extendedShapes
+
+
+def test_buildMathTable_mathKern():
+ ttFont = ttLib.TTFont()
+ ttFont.setGlyphOrder(["A", "B"])
+ mathKerns = {
+ "A": {
+ "TopRight": ([10, 20], [10, 20, 30]),
+ "BottomRight": ([], [10]),
+ "TopLeft": ([10], [0, 20]),
+ "BottomLeft": ([-10, 0], [0, 10, 20]),
+ },
+ }
+ builder.buildMathTable(ttFont, mathKerns=mathKerns)
+ mathTable = ttFont["MATH"].table
+ assert mathTable.MathConstants is None
+ assert mathTable.MathGlyphInfo
+ assert mathTable.MathVariants is None
+ assert set(mathTable.MathGlyphInfo.MathKernInfo.MathKernCoverage.glyphs) == set(
+ mathKerns.keys()
+ )
+ for glyph, record in zip(
+ mathTable.MathGlyphInfo.MathKernInfo.MathKernCoverage.glyphs,
+ mathTable.MathGlyphInfo.MathKernInfo.MathKernInfoRecords,
+ ):
+ h, k = mathKerns[glyph]["TopRight"]
+ assert [v.Value for v in record.TopRightMathKern.CorrectionHeight] == h
+ assert [v.Value for v in record.TopRightMathKern.KernValue] == k
+ h, k = mathKerns[glyph]["BottomRight"]
+ assert [v.Value for v in record.BottomRightMathKern.CorrectionHeight] == h
+ assert [v.Value for v in record.BottomRightMathKern.KernValue] == k
+ h, k = mathKerns[glyph]["TopLeft"]
+ assert [v.Value for v in record.TopLeftMathKern.CorrectionHeight] == h
+ assert [v.Value for v in record.TopLeftMathKern.KernValue] == k
+ h, k = mathKerns[glyph]["BottomLeft"]
+ assert [v.Value for v in record.BottomLeftMathKern.CorrectionHeight] == h
+ assert [v.Value for v in record.BottomLeftMathKern.KernValue] == k
+
+
+def test_buildMathTable_vertVariants():
+ ttFont = ttLib.TTFont()
+ ttFont.setGlyphOrder(["A", "A.size1", "A.size2"])
+ vertGlyphVariants = {"A": [("A.size1", 100), ("A.size2", 200)]}
+ builder.buildMathTable(ttFont, vertGlyphVariants=vertGlyphVariants)
+ mathTable = ttFont["MATH"].table
+ assert mathTable.MathConstants is None
+ assert mathTable.MathGlyphInfo is None
+ assert mathTable.MathVariants
+ assert set(mathTable.MathVariants.VertGlyphCoverage.glyphs) == set(
+ vertGlyphVariants.keys()
+ )
+ for glyph, construction in zip(
+ mathTable.MathVariants.VertGlyphCoverage.glyphs,
+ mathTable.MathVariants.VertGlyphConstruction,
+ ):
+ assert [
+ (r.VariantGlyph, r.AdvanceMeasurement)
+ for r in construction.MathGlyphVariantRecord
+ ] == vertGlyphVariants[glyph]
+
+
+def test_buildMathTable_horizVariants():
+ ttFont = ttLib.TTFont()
+ ttFont.setGlyphOrder(["A", "A.size1", "A.size2"])
+ horizGlyphVariants = {"A": [("A.size1", 100), ("A.size2", 200)]}
+ builder.buildMathTable(ttFont, horizGlyphVariants=horizGlyphVariants)
+ mathTable = ttFont["MATH"].table
+ assert mathTable.MathConstants is None
+ assert mathTable.MathGlyphInfo is None
+ assert mathTable.MathVariants
+ assert set(mathTable.MathVariants.HorizGlyphCoverage.glyphs) == set(
+ horizGlyphVariants.keys()
+ )
+ for glyph, construction in zip(
+ mathTable.MathVariants.HorizGlyphCoverage.glyphs,
+ mathTable.MathVariants.HorizGlyphConstruction,
+ ):
+ assert [
+ (r.VariantGlyph, r.AdvanceMeasurement)
+ for r in construction.MathGlyphVariantRecord
+ ] == horizGlyphVariants[glyph]
+
+
+def test_buildMathTable_vertAssembly():
+ ttFont = ttLib.TTFont()
+ ttFont.setGlyphOrder(["A", "A.top", "A.middle", "A.bottom", "A.extender"])
+ vertGlyphAssembly = {
+ "A": [
+ [
+ ("A.bottom", 0, 0, 100, 200),
+ ("A.extender", 1, 50, 50, 100),
+ ("A.middle", 0, 100, 100, 200),
+ ("A.extender", 1, 50, 50, 100),
+ ("A.top", 0, 100, 0, 200),
+ ],
+ 10,
+ ],
+ }
+ builder.buildMathTable(ttFont, vertGlyphAssembly=vertGlyphAssembly)
+ mathTable = ttFont["MATH"].table
+ assert mathTable.MathConstants is None
+ assert mathTable.MathGlyphInfo is None
+ assert mathTable.MathVariants
+ assert set(mathTable.MathVariants.VertGlyphCoverage.glyphs) == set(
+ vertGlyphAssembly.keys()
+ )
+ for glyph, construction in zip(
+ mathTable.MathVariants.VertGlyphCoverage.glyphs,
+ mathTable.MathVariants.VertGlyphConstruction,
+ ):
+ assert [
+ [
+ (
+ r.glyph,
+ r.PartFlags,
+ r.StartConnectorLength,
+ r.EndConnectorLength,
+ r.FullAdvance,
+ )
+ for r in construction.GlyphAssembly.PartRecords
+ ],
+ construction.GlyphAssembly.ItalicsCorrection.Value,
+ ] == vertGlyphAssembly[glyph]
+
+
+def test_buildMathTable_horizAssembly():
+ ttFont = ttLib.TTFont()
+ ttFont.setGlyphOrder(["A", "A.top", "A.middle", "A.bottom", "A.extender"])
+ horizGlyphAssembly = {
+ "A": [
+ [
+ ("A.bottom", 0, 0, 100, 200),
+ ("A.extender", 1, 50, 50, 100),
+ ("A.middle", 0, 100, 100, 200),
+ ("A.extender", 1, 50, 50, 100),
+ ("A.top", 0, 100, 0, 200),
+ ],
+ 10,
+ ],
+ }
+ builder.buildMathTable(ttFont, horizGlyphAssembly=horizGlyphAssembly)
+ mathTable = ttFont["MATH"].table
+ assert mathTable.MathConstants is None
+ assert mathTable.MathGlyphInfo is None
+ assert mathTable.MathVariants
+ assert set(mathTable.MathVariants.HorizGlyphCoverage.glyphs) == set(
+ horizGlyphAssembly.keys()
+ )
+ for glyph, construction in zip(
+ mathTable.MathVariants.HorizGlyphCoverage.glyphs,
+ mathTable.MathVariants.HorizGlyphConstruction,
+ ):
+ assert [
+ [
+ (
+ r.glyph,
+ r.PartFlags,
+ r.StartConnectorLength,
+ r.EndConnectorLength,
+ r.FullAdvance,
+ )
+ for r in construction.GlyphAssembly.PartRecords
+ ],
+ construction.GlyphAssembly.ItalicsCorrection.Value,
+ ] == horizGlyphAssembly[glyph]
+
+
class ChainContextualRulesetTest(object):
def test_makeRulesets(self):
font = ttLib.TTFont()
diff --git a/Tests/pens/roundingPen_test.py b/Tests/pens/roundingPen_test.py
new file mode 100644
index 00000000..3c1f00e4
--- /dev/null
+++ b/Tests/pens/roundingPen_test.py
@@ -0,0 +1,69 @@
+from fontTools.misc.fixedTools import floatToFixedToFloat
+from fontTools.pens.recordingPen import RecordingPen, RecordingPointPen
+from fontTools.pens.roundingPen import RoundingPen, RoundingPointPen
+from functools import partial
+
+
+tt_scale_round = partial(floatToFixedToFloat, precisionBits=14)
+
+
+class RoundingPenTest(object):
+ def test_general(self):
+ recpen = RecordingPen()
+ roundpen = RoundingPen(recpen)
+ roundpen.moveTo((0.4, 0.6))
+ roundpen.lineTo((1.6, 2.5))
+ roundpen.qCurveTo((2.4, 4.6), (3.3, 5.7), (4.9, 6.1))
+ roundpen.curveTo((6.4, 8.6), (7.3, 9.7), (8.9, 10.1))
+ roundpen.addComponent("a", (1.5, 0, 0, 1.5, 10.5, -10.5))
+ assert recpen.value == [
+ ("moveTo", ((0, 1),)),
+ ("lineTo", ((2, 3),)),
+ ("qCurveTo", ((2, 5), (3, 6), (5, 6))),
+ ("curveTo", ((6, 9), (7, 10), (9, 10))),
+ ("addComponent", ("a", (1.5, 0, 0, 1.5, 11, -10))),
+ ]
+
+ def test_transform_round(self):
+ recpen = RecordingPen()
+ roundpen = RoundingPen(recpen, transformRoundFunc=tt_scale_round)
+ # The 0.913 is equal to 91.3% scale in a source editor
+ roundpen.addComponent("a", (0.9130000305, 0, 0, -1, 10.5, -10.5))
+ # The value should compare equal to its F2Dot14 representation
+ assert recpen.value == [
+ ("addComponent", ("a", (0.91302490234375, 0, 0, -1, 11, -10))),
+ ]
+
+
+class RoundingPointPenTest(object):
+ def test_general(self):
+ recpen = RecordingPointPen()
+ roundpen = RoundingPointPen(recpen)
+ roundpen.beginPath()
+ roundpen.addPoint((0.4, 0.6), "line")
+ roundpen.addPoint((1.6, 2.5), "line")
+ roundpen.addPoint((2.4, 4.6))
+ roundpen.addPoint((3.3, 5.7))
+ roundpen.addPoint((4.9, 6.1), "qcurve")
+ roundpen.endPath()
+ roundpen.addComponent("a", (1.5, 0, 0, 1.5, 10.5, -10.5))
+ assert recpen.value == [
+ ("beginPath", (), {}),
+ ("addPoint", ((0, 1), "line", False, None), {}),
+ ("addPoint", ((2, 3), "line", False, None), {}),
+ ("addPoint", ((2, 5), None, False, None), {}),
+ ("addPoint", ((3, 6), None, False, None), {}),
+ ("addPoint", ((5, 6), "qcurve", False, None), {}),
+ ("endPath", (), {}),
+ ("addComponent", ("a", (1.5, 0, 0, 1.5, 11, -10)), {}),
+ ]
+
+ def test_transform_round(self):
+ recpen = RecordingPointPen()
+ roundpen = RoundingPointPen(recpen, transformRoundFunc=tt_scale_round)
+ # The 0.913 is equal to 91.3% scale in a source editor
+ roundpen.addComponent("a", (0.913, 0, 0, -1, 10.5, -10.5))
+ # The value should compare equal to its F2Dot14 representation
+ assert recpen.value == [
+ ("addComponent", ("a", (0.91302490234375, 0, 0, -1, 11, -10)), {}),
+ ]
diff --git a/Tests/subset/data/NotoSansCJKjp-Regular.subset.ttx b/Tests/subset/data/NotoSansCJKjp-Regular.subset.ttx
index 4dfc0b23..2f9501d6 100644
--- a/Tests/subset/data/NotoSansCJKjp-Regular.subset.ttx
+++ b/Tests/subset/data/NotoSansCJKjp-Regular.subset.ttx
@@ -98,7 +98,7 @@
<sTypoLineGap value="0"/>
<usWinAscent value="1160"/>
<usWinDescent value="288"/>
- <ulCodePageRange1 value="01100000 00101110 00000001 00000111"/>
+ <ulCodePageRange1 value="00000000 00000000 00000000 00000001"/>
<ulCodePageRange2 value="00000000 00000000 00000000 00000000"/>
<sxHeight value="543"/>
<sCapHeight value="733"/>
diff --git a/Tests/subset/data/TestContextSubstFormat3.ttx b/Tests/subset/data/TestContextSubstFormat3.ttx
index 0ed43ee2..c24ac164 100644
--- a/Tests/subset/data/TestContextSubstFormat3.ttx
+++ b/Tests/subset/data/TestContextSubstFormat3.ttx
@@ -117,8 +117,8 @@
<sTypoLineGap value="0"/>
<usWinAscent value="977"/>
<usWinDescent value="272"/>
- <ulCodePageRange1 value="00100000 00000000 00000001 00011111"/>
- <ulCodePageRange2 value="11000100 00000000 00000000 00000000"/>
+ <ulCodePageRange1 value="00000000 00000000 00000000 00000001"/>
+ <ulCodePageRange2 value="00000000 00000000 00000000 00000000"/>
<sxHeight value="530"/>
<sCapHeight value="735"/>
<usDefaultChar value="0"/>
diff --git a/Tests/svgLib/path/path_test.py b/Tests/svgLib/path/path_test.py
index 0b82193d..c92ca680 100644
--- a/Tests/svgLib/path/path_test.py
+++ b/Tests/svgLib/path/path_test.py
@@ -7,7 +7,7 @@ from tempfile import NamedTemporaryFile
SVG_DATA = """\
-<?xml version="1.0" standalone="no"?>
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
@@ -15,7 +15,9 @@ SVG_DATA = """\
<path d="M 100 100 L 300 100 L 200 300 z"/>
<path d="M100,200 C100,100 250,100 250,200 S400,300 400,200"/>
</svg>
-"""
+""".encode(
+ "utf-8"
+)
EXPECTED_PEN_COMMANDS = [
("moveTo", ((100.0, 100.0),)),
diff --git a/Tests/ttLib/tables/O_S_2f_2_test.py b/Tests/ttLib/tables/O_S_2f_2_test.py
index 9567b9ec..71388560 100644
--- a/Tests/ttLib/tables/O_S_2f_2_test.py
+++ b/Tests/ttLib/tables/O_S_2f_2_test.py
@@ -4,6 +4,19 @@ import unittest
class OS2TableTest(unittest.TestCase):
+ @staticmethod
+ def makeOS2_cmap(mapping):
+ font = TTFont()
+ font["OS/2"] = os2 = newTable("OS/2")
+ os2.version = 4
+ font["cmap"] = cmap = newTable("cmap")
+ st = getTableModule("cmap").CmapSubtable.newSubtable(4)
+ st.platformID, st.platEncID, st.language = 3, 1, 0
+ st.cmap = mapping
+ cmap.tables = []
+ cmap.tables.append(st)
+ return font, os2, cmap
+
def test_getUnicodeRanges(self):
table = table_O_S_2f_2()
table.ulUnicodeRange1 = 0xFFFFFFFF
@@ -27,14 +40,9 @@ class OS2TableTest(unittest.TestCase):
table.setUnicodeRanges([-1, 127, 255])
def test_recalcUnicodeRanges(self):
- font = TTFont()
- font["OS/2"] = os2 = newTable("OS/2")
- font["cmap"] = cmap = newTable("cmap")
- st = getTableModule("cmap").CmapSubtable.newSubtable(4)
- st.platformID, st.platEncID, st.language = 3, 1, 0
- st.cmap = {0x0041: "A", 0x03B1: "alpha", 0x0410: "Acyr"}
- cmap.tables = []
- cmap.tables.append(st)
+ font, os2, cmap = self.makeOS2_cmap(
+ {0x0041: "A", 0x03B1: "alpha", 0x0410: "Acyr"}
+ )
os2.setUnicodeRanges({0, 1, 9})
# 'pruneOnly' will clear any bits for which there's no intersection:
# bit 1 ('Latin 1 Supplement'), in this case. However, it won't set
@@ -43,7 +51,7 @@ class OS2TableTest(unittest.TestCase):
# try again with pruneOnly=False: bit 7 is now set.
self.assertEqual(os2.recalcUnicodeRanges(font), {0, 7, 9})
# add a non-BMP char from 'Mahjong Tiles' block (bit 122)
- st.cmap[0x1F000] = "eastwindtile"
+ cmap.tables[0].cmap[0x1F000] = "eastwindtile"
# the bit 122 and the special bit 57 ('Non Plane 0') are also enabled
self.assertEqual(os2.recalcUnicodeRanges(font), {0, 7, 9, 57, 122})
@@ -55,6 +63,65 @@ class OS2TableTest(unittest.TestCase):
(set(range(123)) - {9, 57, 122}),
)
+ def test_getCodePageRanges(self):
+ table = table_O_S_2f_2()
+ # version 0 doesn't define these fields so by definition defines no cp ranges
+ table.version = 0
+ self.assertEqual(table.getCodePageRanges(), set())
+ # version 1 and above do contain ulCodePageRange1 and 2 fields
+ table.version = 1
+ table.ulCodePageRange1 = 0xFFFFFFFF
+ table.ulCodePageRange2 = 0xFFFFFFFF
+ bits = table.getCodePageRanges()
+ for i in range(63):
+ self.assertIn(i, bits)
+
+ def test_setCodePageRanges(self):
+ table = table_O_S_2f_2()
+ table.version = 4
+ table.ulCodePageRange1 = 0
+ table.ulCodePageRange2 = 0
+ bits = set(range(64))
+ table.setCodePageRanges(bits)
+ self.assertEqual(table.getCodePageRanges(), bits)
+ with self.assertRaises(ValueError):
+ table.setCodePageRanges([-1])
+ with self.assertRaises(ValueError):
+ table.setCodePageRanges([64])
+ with self.assertRaises(ValueError):
+ table.setCodePageRanges([255])
+
+ def test_setCodePageRanges_bump_version(self):
+ # Setting codepage ranges on a OS/2 table version 0 automatically makes it
+ # a version 1 table
+ table = table_O_S_2f_2()
+ table.version = 0
+ self.assertEqual(table.getCodePageRanges(), set())
+ table.setCodePageRanges({0, 1, 2})
+ self.assertEqual(table.getCodePageRanges(), {0, 1, 2})
+ self.assertEqual(table.version, 1)
+
+ def test_recalcCodePageRanges(self):
+ font, os2, cmap = self.makeOS2_cmap(
+ {ord("A"): "A", ord("Ά"): "Alphatonos", ord("Б"): "Be"}
+ )
+ os2.setCodePageRanges({0, 2, 9})
+
+ # With pruneOnly=True, should clear any CodePage for which there are no
+ # characters in the cmap.
+ self.assertEqual(os2.recalcCodePageRanges(font, pruneOnly=True), {2})
+
+ # With pruneOnly=False, should also set CodePages not initially set.
+ self.assertEqual(os2.recalcCodePageRanges(font), {2, 3})
+
+ # Add a Korean character, should set CodePage 21 (Korean Johab)
+ cmap.tables[0].cmap[ord("곴")] = "goss"
+ self.assertEqual(os2.recalcCodePageRanges(font), {2, 3, 21})
+
+ # Remove all characters from cmap, should still set CodePage 0 (Latin 1)
+ cmap.tables[0].cmap = {}
+ self.assertEqual(os2.recalcCodePageRanges(font), {0})
+
if __name__ == "__main__":
import sys
diff --git a/Tests/ttLib/tables/_g_l_y_f_test.py b/Tests/ttLib/tables/_g_l_y_f_test.py
index ce2e0e57..39f48b26 100644
--- a/Tests/ttLib/tables/_g_l_y_f_test.py
+++ b/Tests/ttLib/tables/_g_l_y_f_test.py
@@ -562,6 +562,23 @@ class GlyphTest:
assert glyphSet["percent"].getCompositeMaxpValues(glyphSet)[2] == 2
assert glyphSet["perthousand"].getCompositeMaxpValues(glyphSet)[2] == 2
+ def test_recalcBounds_empty_components(self):
+ glyphSet = {}
+ pen = TTGlyphPen(glyphSet)
+ # empty simple glyph
+ foo = glyphSet["foo"] = pen.glyph()
+ # use the empty 'foo' glyph as a component in 'bar' with some x/y offsets
+ pen.addComponent("foo", (1, 0, 0, 1, -80, 50))
+ bar = glyphSet["bar"] = pen.glyph()
+
+ foo.recalcBounds(glyphSet)
+ bar.recalcBounds(glyphSet)
+
+ # we expect both the empty simple glyph and the composite referencing it
+ # to have empty bounding boxes (0, 0, 0, 0) no matter the component's shift
+ assert (foo.xMin, foo.yMin, foo.xMax, foo.yMax) == (0, 0, 0, 0)
+ assert (bar.xMin, bar.yMin, bar.xMax, bar.yMax) == (0, 0, 0, 0)
+
class GlyphComponentTest:
def test_toXML_no_transform(self):
diff --git a/Tests/ttLib/ttGlyphSet_test.py b/Tests/ttLib/ttGlyphSet_test.py
index 56514464..177b8a4e 100644
--- a/Tests/ttLib/ttGlyphSet_test.py
+++ b/Tests/ttLib/ttGlyphSet_test.py
@@ -1,5 +1,6 @@
from fontTools.ttLib import TTFont
from fontTools.ttLib import ttGlyphSet
+from fontTools.ttLib.ttGlyphSet import LerpGlyphSet
from fontTools.pens.recordingPen import (
RecordingPen,
RecordingPointPen,
@@ -164,6 +165,53 @@ class TTGlyphSetTest(object):
assert actual == expected, (location, actual, expected)
+ @pytest.mark.parametrize(
+ "fontfile, locations, factor, expected",
+ [
+ (
+ "I.ttf",
+ ({"wght": 400}, {"wght": 1000}),
+ 0.5,
+ [
+ ("moveTo", ((151.5, 0.0),)),
+ ("lineTo", ((458.5, 0.0),)),
+ ("lineTo", ((458.5, 1456.0),)),
+ ("lineTo", ((151.5, 1456.0),)),
+ ("closePath", ()),
+ ],
+ ),
+ (
+ "I.ttf",
+ ({"wght": 400}, {"wght": 1000}),
+ 0.25,
+ [
+ ("moveTo", ((163.25, 0.0),)),
+ ("lineTo", ((412.75, 0.0),)),
+ ("lineTo", ((412.75, 1456.0),)),
+ ("lineTo", ((163.25, 1456.0),)),
+ ("closePath", ()),
+ ],
+ ),
+ ],
+ )
+ def test_lerp_glyphset(self, fontfile, locations, factor, expected):
+ font = TTFont(self.getpath(fontfile))
+ glyphset1 = font.getGlyphSet(location=locations[0])
+ glyphset2 = font.getGlyphSet(location=locations[1])
+ glyphset = LerpGlyphSet(glyphset1, glyphset2, factor)
+
+ assert "I" in glyphset
+
+ pen = RecordingPen()
+ glyph = glyphset["I"]
+
+ assert glyphset.get("foobar") is None
+
+ glyph.draw(pen)
+ actual = pen.value
+
+ assert actual == expected, (locations, actual, expected)
+
def test_glyphset_varComposite_components(self):
font = TTFont(self.getpath("varc-ac00-ac01.ttf"))
glyphset = font.getGlyphSet()
diff --git a/Tests/varLib/data/FeatureVarsCustomTag.designspace b/Tests/varLib/data/FeatureVarsCustomTag.designspace
index 45b06f30..ef24ccfd 100644
--- a/Tests/varLib/data/FeatureVarsCustomTag.designspace
+++ b/Tests/varLib/data/FeatureVarsCustomTag.designspace
@@ -71,7 +71,7 @@
<lib>
<dict>
<key>com.github.fonttools.varLib.featureVarsFeatureTag</key>
- <string>calt</string>
+ <string>rclt,calt</string>
</dict>
</lib>
</designspace>
diff --git a/Tests/varLib/data/test_results/FeatureVarsCustomTag.ttx b/Tests/varLib/data/test_results/FeatureVarsCustomTag.ttx
index 3f9e1e08..5ad62a98 100644
--- a/Tests/varLib/data/test_results/FeatureVarsCustomTag.ttx
+++ b/Tests/varLib/data/test_results/FeatureVarsCustomTag.ttx
@@ -33,21 +33,28 @@
<Script>
<DefaultLangSys>
<ReqFeatureIndex value="65535"/>
- <!-- FeatureCount=1 -->
+ <!-- FeatureCount=2 -->
<FeatureIndex index="0" value="0"/>
+ <FeatureIndex index="1" value="1"/>
</DefaultLangSys>
<!-- LangSysCount=0 -->
</Script>
</ScriptRecord>
</ScriptList>
<FeatureList>
- <!-- FeatureCount=1 -->
+ <!-- FeatureCount=2 -->
<FeatureRecord index="0">
<FeatureTag value="calt"/>
<Feature>
<!-- LookupCount=0 -->
</Feature>
</FeatureRecord>
+ <FeatureRecord index="1">
+ <FeatureTag value="rclt"/>
+ <Feature>
+ <!-- LookupCount=0 -->
+ </Feature>
+ </FeatureRecord>
</FeatureList>
<LookupList>
<!-- LookupCount=3 -->
@@ -95,7 +102,7 @@
</ConditionSet>
<FeatureTableSubstitution>
<Version value="0x00010000"/>
- <!-- SubstitutionCount=1 -->
+ <!-- SubstitutionCount=2 -->
<SubstitutionRecord index="0">
<FeatureIndex value="0"/>
<Feature>
@@ -104,6 +111,14 @@
<LookupListIndex index="1" value="1"/>
</Feature>
</SubstitutionRecord>
+ <SubstitutionRecord index="1">
+ <FeatureIndex value="1"/>
+ <Feature>
+ <!-- LookupCount=2 -->
+ <LookupListIndex index="0" value="0"/>
+ <LookupListIndex index="1" value="1"/>
+ </Feature>
+ </SubstitutionRecord>
</FeatureTableSubstitution>
</FeatureVariationRecord>
<FeatureVariationRecord index="1">
@@ -122,7 +137,7 @@
</ConditionSet>
<FeatureTableSubstitution>
<Version value="0x00010000"/>
- <!-- SubstitutionCount=1 -->
+ <!-- SubstitutionCount=2 -->
<SubstitutionRecord index="0">
<FeatureIndex value="0"/>
<Feature>
@@ -130,6 +145,13 @@
<LookupListIndex index="0" value="2"/>
</Feature>
</SubstitutionRecord>
+ <SubstitutionRecord index="1">
+ <FeatureIndex value="1"/>
+ <Feature>
+ <!-- LookupCount=1 -->
+ <LookupListIndex index="0" value="2"/>
+ </Feature>
+ </SubstitutionRecord>
</FeatureTableSubstitution>
</FeatureVariationRecord>
<FeatureVariationRecord index="2">
@@ -143,7 +165,7 @@
</ConditionSet>
<FeatureTableSubstitution>
<Version value="0x00010000"/>
- <!-- SubstitutionCount=1 -->
+ <!-- SubstitutionCount=2 -->
<SubstitutionRecord index="0">
<FeatureIndex value="0"/>
<Feature>
@@ -151,6 +173,13 @@
<LookupListIndex index="0" value="1"/>
</Feature>
</SubstitutionRecord>
+ <SubstitutionRecord index="1">
+ <FeatureIndex value="1"/>
+ <Feature>
+ <!-- LookupCount=1 -->
+ <LookupListIndex index="0" value="1"/>
+ </Feature>
+ </SubstitutionRecord>
</FeatureTableSubstitution>
</FeatureVariationRecord>
<FeatureVariationRecord index="3">
@@ -164,7 +193,7 @@
</ConditionSet>
<FeatureTableSubstitution>
<Version value="0x00010000"/>
- <!-- SubstitutionCount=1 -->
+ <!-- SubstitutionCount=2 -->
<SubstitutionRecord index="0">
<FeatureIndex value="0"/>
<Feature>
@@ -172,6 +201,13 @@
<LookupListIndex index="0" value="0"/>
</Feature>
</SubstitutionRecord>
+ <SubstitutionRecord index="1">
+ <FeatureIndex value="1"/>
+ <Feature>
+ <!-- LookupCount=1 -->
+ <LookupListIndex index="0" value="0"/>
+ </Feature>
+ </SubstitutionRecord>
</FeatureTableSubstitution>
</FeatureVariationRecord>
</FeatureVariations>
diff --git a/Tests/varLib/featureVars_test.py b/Tests/varLib/featureVars_test.py
index 7a3a6650..ef32ee03 100644
--- a/Tests/varLib/featureVars_test.py
+++ b/Tests/varLib/featureVars_test.py
@@ -1,4 +1,168 @@
-from fontTools.varLib.featureVars import overlayFeatureVariations, overlayBox
+from collections import OrderedDict
+from fontTools.designspaceLib import AxisDescriptor
+from fontTools.ttLib import TTFont, newTable
+from fontTools import varLib
+from fontTools.varLib.featureVars import (
+ addFeatureVariations,
+ overlayFeatureVariations,
+ overlayBox,
+)
+import pytest
+
+
+def makeVariableFont(glyphOrder, axes):
+ font = TTFont()
+ font.setGlyphOrder(glyphOrder)
+ font["name"] = newTable("name")
+ ds_axes = OrderedDict()
+ for axisTag, (minimum, default, maximum) in axes.items():
+ axis = AxisDescriptor()
+ axis.name = axis.tag = axis.labelNames["en"] = axisTag
+ axis.minimum, axis.default, axis.maximum = minimum, default, maximum
+ ds_axes[axisTag] = axis
+ varLib._add_fvar(font, ds_axes, instances=())
+ return font
+
+
+@pytest.fixture
+def varfont():
+ return makeVariableFont(
+ [".notdef", "space", "A", "B", "A.alt", "B.alt"],
+ {"wght": (100, 400, 900)},
+ )
+
+
+def test_addFeatureVariations(varfont):
+ assert "GSUB" not in varfont
+
+ addFeatureVariations(varfont, [([{"wght": (0.5, 1.0)}], {"A": "A.alt"})])
+
+ assert "GSUB" in varfont
+ gsub = varfont["GSUB"].table
+
+ assert len(gsub.ScriptList.ScriptRecord) == 1
+ assert gsub.ScriptList.ScriptRecord[0].ScriptTag == "DFLT"
+
+ assert len(gsub.FeatureList.FeatureRecord) == 1
+ assert gsub.FeatureList.FeatureRecord[0].FeatureTag == "rvrn"
+
+ assert len(gsub.LookupList.Lookup) == 1
+ assert gsub.LookupList.Lookup[0].LookupType == 1
+ assert len(gsub.LookupList.Lookup[0].SubTable) == 1
+ assert gsub.LookupList.Lookup[0].SubTable[0].mapping == {"A": "A.alt"}
+
+ assert gsub.FeatureVariations is not None
+ assert len(gsub.FeatureVariations.FeatureVariationRecord) == 1
+ fvr = gsub.FeatureVariations.FeatureVariationRecord[0]
+ assert len(fvr.ConditionSet.ConditionTable) == 1
+ cst = fvr.ConditionSet.ConditionTable[0]
+ assert cst.AxisIndex == 0
+ assert cst.FilterRangeMinValue == 0.5
+ assert cst.FilterRangeMaxValue == 1.0
+ assert len(fvr.FeatureTableSubstitution.SubstitutionRecord) == 1
+ ftsr = fvr.FeatureTableSubstitution.SubstitutionRecord[0]
+ assert ftsr.FeatureIndex == 0
+ assert ftsr.Feature.LookupListIndex == [0]
+
+
+def _substitution_features(gsub, rec_index):
+ fea_tags = [feature.FeatureTag for feature in gsub.FeatureList.FeatureRecord]
+ fea_indices = [
+ gsub.FeatureVariations.FeatureVariationRecord[rec_index]
+ .FeatureTableSubstitution.SubstitutionRecord[i]
+ .FeatureIndex
+ for i in range(
+ len(
+ gsub.FeatureVariations.FeatureVariationRecord[
+ rec_index
+ ].FeatureTableSubstitution.SubstitutionRecord
+ )
+ )
+ ]
+ return [(i, fea_tags[i]) for i in fea_indices]
+
+
+def test_addFeatureVariations_existing_variable_feature(varfont):
+ assert "GSUB" not in varfont
+
+ addFeatureVariations(varfont, [([{"wght": (0.5, 1.0)}], {"A": "A.alt"})])
+
+ gsub = varfont["GSUB"].table
+ assert len(gsub.FeatureList.FeatureRecord) == 1
+ assert gsub.FeatureList.FeatureRecord[0].FeatureTag == "rvrn"
+ assert len(gsub.FeatureVariations.FeatureVariationRecord) == 1
+ assert _substitution_features(gsub, rec_index=0) == [(0, "rvrn")]
+
+ # can't add feature variations for an existing feature tag that already has some,
+ # in this case the default 'rvrn'
+ with pytest.raises(
+ varLib.VarLibError,
+ match=r"FeatureVariations already exist for feature tag\(s\): {'rvrn'}",
+ ):
+ addFeatureVariations(varfont, [([{"wght": (0.5, 1.0)}], {"A": "A.alt"})])
+
+
+def test_addFeatureVariations_new_feature(varfont):
+ assert "GSUB" not in varfont
+
+ addFeatureVariations(varfont, [([{"wght": (0.5, 1.0)}], {"A": "A.alt"})])
+
+ gsub = varfont["GSUB"].table
+ assert len(gsub.FeatureList.FeatureRecord) == 1
+ assert gsub.FeatureList.FeatureRecord[0].FeatureTag == "rvrn"
+ assert len(gsub.LookupList.Lookup) == 1
+ assert len(gsub.FeatureVariations.FeatureVariationRecord) == 1
+ assert _substitution_features(gsub, rec_index=0) == [(0, "rvrn")]
+
+ # we can add feature variations for a feature tag that does not have
+ # any feature variations yet
+ addFeatureVariations(
+ varfont, [([{"wght": (-1.0, 0.0)}], {"B": "B.alt"})], featureTag="rclt"
+ )
+
+ assert len(gsub.FeatureList.FeatureRecord) == 2
+ # Note 'rclt' is now first (index=0) in the feature list sorted by tag, and
+ # 'rvrn' is second (index=1)
+ assert gsub.FeatureList.FeatureRecord[0].FeatureTag == "rclt"
+ assert gsub.FeatureList.FeatureRecord[1].FeatureTag == "rvrn"
+ assert len(gsub.LookupList.Lookup) == 2
+ assert len(gsub.FeatureVariations.FeatureVariationRecord) == 2
+ # The new 'rclt' feature variation record is appended to the end;
+ # the feature index for 'rvrn' feature table substitution record is now 1
+ assert _substitution_features(gsub, rec_index=0) == [(1, "rvrn")]
+ assert _substitution_features(gsub, rec_index=1) == [(0, "rclt")]
+
+
+def test_addFeatureVariations_existing_condition(varfont):
+ assert "GSUB" not in varfont
+
+ # Add a feature variation for 'ccmp' feature tag with a condition
+ addFeatureVariations(
+ varfont, [([{"wght": (0.5, 1.0)}], {"A": "A.alt"})], featureTag="ccmp"
+ )
+
+ gsub = varfont["GSUB"].table
+
+ # Should now have one feature record, one lookup, and one feature variation record
+ assert len(gsub.FeatureList.FeatureRecord) == 1
+ assert gsub.FeatureList.FeatureRecord[0].FeatureTag == "ccmp"
+ assert len(gsub.LookupList.Lookup) == 1
+ assert len(gsub.FeatureVariations.FeatureVariationRecord) == 1
+ assert _substitution_features(gsub, rec_index=0) == [(0, "ccmp")]
+
+ # Add a feature variation for 'rlig' feature tag with the same condition
+ addFeatureVariations(
+ varfont, [([{"wght": (0.5, 1.0)}], {"B": "B.alt"})], featureTag="rlig"
+ )
+
+ # Should now have two feature records, two lookups, and one feature variation
+ # record, since the condition is the same for both feature variations
+ assert len(gsub.FeatureList.FeatureRecord) == 2
+ assert gsub.FeatureList.FeatureRecord[0].FeatureTag == "ccmp"
+ assert gsub.FeatureList.FeatureRecord[1].FeatureTag == "rlig"
+ assert len(gsub.LookupList.Lookup) == 2
+ assert len(gsub.FeatureVariations.FeatureVariationRecord) == 1
+ assert _substitution_features(gsub, rec_index=0) == [(0, "ccmp"), (1, "rlig")]
def _test_linear(n):
diff --git a/Tests/varLib/instancer/data/PartialInstancerTest-VF.ttx b/Tests/varLib/instancer/data/PartialInstancerTest-VF.ttx
index 2f1754b0..cee18846 100644
--- a/Tests/varLib/instancer/data/PartialInstancerTest-VF.ttx
+++ b/Tests/varLib/instancer/data/PartialInstancerTest-VF.ttx
@@ -108,9 +108,9 @@
<fsSelection value="00000000 01000000"/>
<usFirstCharIndex value="32"/>
<usLastCharIndex value="8722"/>
- <sTypoAscender value="800"/>
+ <sTypoAscender value="1000"/>
<sTypoDescender value="-200"/>
- <sTypoLineGap value="200"/>
+ <sTypoLineGap value="0"/>
<usWinAscent value="1000"/>
<usWinDescent value="200"/>
<ulCodePageRange1 value="00000000 00000000 00000000 00000001"/>
@@ -687,6 +687,7 @@
<!-- VarRegionCount=1 -->
<VarRegionIndex index="0" value="0"/>
<Item index="0" value="[30]"/>
+ <Item index="1" value="[100]"/>
</VarData>
</VarStore>
<ValueRecord index="0">
@@ -705,6 +706,10 @@
<ValueTag value="xhgt"/>
<VarIdx value="65536"/>
</ValueRecord>
+ <ValueRecord index="3">
+ <ValueTag value="hasc"/>
+ <VarIdx value="65537"/>
+ </ValueRecord>
</MVAR>
<STAT>
diff --git a/Tests/varLib/instancer/instancer_test.py b/Tests/varLib/instancer/instancer_test.py
index 20d9194f..0ace29f7 100644
--- a/Tests/varLib/instancer/instancer_test.py
+++ b/Tests/varLib/instancer/instancer_test.py
@@ -304,39 +304,69 @@ class InstantiateMVARTest(object):
assert len(mvar.VarStore.VarData) == 1
@pytest.mark.parametrize(
- "location, expected",
+ "location, expected, sync_vmetrics",
[
pytest.param(
{"wght": 1.0, "wdth": 0.0},
- {"strs": 100, "undo": -200, "unds": 150},
+ {"strs": 100, "undo": -200, "unds": 150, "hasc": 1100},
+ True,
id="wght=1.0,wdth=0.0",
),
pytest.param(
{"wght": 0.0, "wdth": -1.0},
- {"strs": 20, "undo": -100, "unds": 50},
+ {"strs": 20, "undo": -100, "unds": 50, "hasc": 1000},
+ True,
id="wght=0.0,wdth=-1.0",
),
pytest.param(
{"wght": 0.5, "wdth": -0.5},
- {"strs": 55, "undo": -145, "unds": 95},
+ {"strs": 55, "undo": -145, "unds": 95, "hasc": 1050},
+ True,
id="wght=0.5,wdth=-0.5",
),
pytest.param(
{"wght": 1.0, "wdth": -1.0},
- {"strs": 50, "undo": -180, "unds": 130},
+ {"strs": 50, "undo": -180, "unds": 130, "hasc": 1100},
+ True,
id="wght=0.5,wdth=-0.5",
),
+ pytest.param(
+ {"wght": 1.0, "wdth": 0.0},
+ {"strs": 100, "undo": -200, "unds": 150, "hasc": 1100},
+ False,
+ id="wght=1.0,wdth=0.0,no_sync_vmetrics",
+ ),
],
)
- def test_full_instance(self, varfont, location, expected):
+ def test_full_instance(self, varfont, location, sync_vmetrics, expected):
location = instancer.NormalizedAxisLimits(location)
+ # check vertical metrics are in sync before...
+ if sync_vmetrics:
+ assert varfont["OS/2"].sTypoAscender == varfont["hhea"].ascender
+ assert varfont["OS/2"].sTypoDescender == varfont["hhea"].descender
+ assert varfont["OS/2"].sTypoLineGap == varfont["hhea"].lineGap
+ else:
+ # force them not to be in sync
+ varfont["OS/2"].sTypoDescender -= 100
+ varfont["OS/2"].sTypoLineGap += 200
+
instancer.instantiateMVAR(varfont, location)
for mvar_tag, expected_value in expected.items():
table_tag, item_name = MVAR_ENTRIES[mvar_tag]
assert getattr(varfont[table_tag], item_name) == expected_value
+ # ... as well as after instancing, but only if they were already
+ # https://github.com/fonttools/fonttools/issues/3297
+ if sync_vmetrics:
+ assert varfont["OS/2"].sTypoAscender == varfont["hhea"].ascender
+ assert varfont["OS/2"].sTypoDescender == varfont["hhea"].descender
+ assert varfont["OS/2"].sTypoLineGap == varfont["hhea"].lineGap
+ else:
+ assert varfont["OS/2"].sTypoDescender != varfont["hhea"].descender
+ assert varfont["OS/2"].sTypoLineGap != varfont["hhea"].lineGap
+
assert "MVAR" not in varfont
@@ -1956,7 +1986,10 @@ class LimitTupleVariationAxisRangesTest:
TupleVariation({"wght": (0.0, 0.5, 1.0)}, [100, 100]),
"wght",
0.6,
- [TupleVariation({"wght": (0.0, 0.833334, 1.666667)}, [100, 100])],
+ [
+ TupleVariation({"wght": (0.0, 0.833334, 1.0)}, [100, 100]),
+ TupleVariation({"wght": (0.833334, 1.0, 1.0)}, [80, 80]),
+ ],
),
(
TupleVariation({"wght": (0.0, 0.2, 1.0)}, [100, 100]),
@@ -1971,7 +2004,10 @@ class LimitTupleVariationAxisRangesTest:
TupleVariation({"wght": (0.0, 0.2, 1.0)}, [100, 100]),
"wght",
0.5,
- [TupleVariation({"wght": (0.0, 0.4, 1.99994)}, [100, 100])],
+ [
+ TupleVariation({"wght": (0.0, 0.4, 1)}, [100, 100]),
+ TupleVariation({"wght": (0.4, 1, 1)}, [62.5, 62.5]),
+ ],
),
(
TupleVariation({"wght": (0.5, 0.5, 1.0)}, [100, 100]),
@@ -2035,7 +2071,10 @@ class LimitTupleVariationAxisRangesTest:
TupleVariation({"wght": (-1.0, -0.5, 0.0)}, [100, 100]),
"wght",
-0.6,
- [TupleVariation({"wght": (-1.666667, -0.833334, 0.0)}, [100, 100])],
+ [
+ TupleVariation({"wght": (-1.0, -0.833334, 0.0)}, [100, 100]),
+ TupleVariation({"wght": (-1.0, -1.0, -0.833334)}, [80, 80]),
+ ],
),
(
TupleVariation({"wght": (-1.0, -0.2, 0.0)}, [100, 100]),
@@ -2050,7 +2089,10 @@ class LimitTupleVariationAxisRangesTest:
TupleVariation({"wght": (-1.0, -0.2, 0.0)}, [100, 100]),
"wght",
-0.5,
- [TupleVariation({"wght": (-2.0, -0.4, 0.0)}, [100, 100])],
+ [
+ TupleVariation({"wght": (-1.0, -0.4, 0.0)}, [100, 100]),
+ TupleVariation({"wght": (-1.0, -1.0, -0.4)}, [62.5, 62.5]),
+ ],
),
(
TupleVariation({"wght": (-1.0, -0.5, -0.5)}, [100, 100]),
diff --git a/Tests/varLib/instancer/solver_test.py b/Tests/varLib/instancer/solver_test.py
index b9acf82f..7bcab637 100644
--- a/Tests/varLib/instancer/solver_test.py
+++ b/Tests/varLib/instancer/solver_test.py
@@ -43,7 +43,8 @@ class RebaseTentTest(object):
(0, 0.2, 1),
(-1, 0, 0.8),
[
- (1, (0, 0.25, 1.25)),
+ (1, (0, 0.25, 1)),
+ (0.25, (0.25, 1, 1)),
],
),
# Case 3 boundary
@@ -51,7 +52,8 @@ class RebaseTentTest(object):
(0, 0.4, 1),
(-1, 0, 0.5),
[
- (1, (0, 0.8, 1.99994)),
+ (1, (0, 0.8, 1)),
+ (2.5 / 3, (0.8, 1, 1)),
],
),
# Case 4
@@ -234,7 +236,8 @@ class RebaseTentTest(object):
(0, 0.2, 1),
(0, 0, 0.5),
[
- (1, (0, 0.4, 1.99994)),
+ (1, (0, 0.4, 1)),
+ (0.625, (0.4, 1, 1)),
],
),
# https://github.com/fonttools/fonttools/issues/3139
diff --git a/Tests/varLib/interpolatable_test.py b/Tests/varLib/interpolatable_test.py
index 10b9cc30..c97edd34 100644
--- a/Tests/varLib/interpolatable_test.py
+++ b/Tests/varLib/interpolatable_test.py
@@ -47,7 +47,7 @@ class InterpolatableTest(unittest.TestCase):
for p in all_files:
if p.startswith(prefix) and p.endswith(suffix):
file_list.append(os.path.abspath(os.path.join(folder, p)))
- return file_list
+ return sorted(file_list)
def temp_path(self, suffix):
self.temp_dir()
@@ -136,18 +136,20 @@ class InterpolatableTest(unittest.TestCase):
# without --ignore-missing
problems = interpolatable_main(["--quiet"] + ttf_paths)
self.assertEqual(
- problems["a"], [{"type": "missing", "master": "SparseMasters-Medium"}]
+ problems["a"],
+ [{"type": "missing", "master": "SparseMasters-Medium", "master_idx": 1}],
)
self.assertEqual(
- problems["s"], [{"type": "missing", "master": "SparseMasters-Medium"}]
+ problems["s"],
+ [{"type": "missing", "master": "SparseMasters-Medium", "master_idx": 1}],
)
self.assertEqual(
problems["edotabove"],
- [{"type": "missing", "master": "SparseMasters-Medium"}],
+ [{"type": "missing", "master": "SparseMasters-Medium", "master_idx": 1}],
)
self.assertEqual(
problems["dotabovecomb"],
- [{"type": "missing", "master": "SparseMasters-Medium"}],
+ [{"type": "missing", "master": "SparseMasters-Medium", "master_idx": 1}],
)
# normal order, with --ignore-missing
@@ -172,18 +174,20 @@ class InterpolatableTest(unittest.TestCase):
# without --ignore-missing
problems = interpolatable_main(["--quiet"] + ufo_paths)
self.assertEqual(
- problems["a"], [{"type": "missing", "master": "SparseMasters-Medium"}]
+ problems["a"],
+ [{"type": "missing", "master": "SparseMasters-Medium", "master_idx": 1}],
)
self.assertEqual(
- problems["s"], [{"type": "missing", "master": "SparseMasters-Medium"}]
+ problems["s"],
+ [{"type": "missing", "master": "SparseMasters-Medium", "master_idx": 1}],
)
self.assertEqual(
problems["edotabove"],
- [{"type": "missing", "master": "SparseMasters-Medium"}],
+ [{"type": "missing", "master": "SparseMasters-Medium", "master_idx": 1}],
)
self.assertEqual(
problems["dotabovecomb"],
- [{"type": "missing", "master": "SparseMasters-Medium"}],
+ [{"type": "missing", "master": "SparseMasters-Medium", "master_idx": 1}],
)
# normal order, with --ignore-missing
@@ -206,18 +210,20 @@ class InterpolatableTest(unittest.TestCase):
problems = interpolatable_main(["--quiet", designspace_path])
self.assertEqual(
- problems["a"], [{"type": "missing", "master": "SparseMasters-Medium"}]
+ problems["a"],
+ [{"type": "missing", "master": "SparseMasters-Medium", "master_idx": 1}],
)
self.assertEqual(
- problems["s"], [{"type": "missing", "master": "SparseMasters-Medium"}]
+ problems["s"],
+ [{"type": "missing", "master": "SparseMasters-Medium", "master_idx": 1}],
)
self.assertEqual(
problems["edotabove"],
- [{"type": "missing", "master": "SparseMasters-Medium"}],
+ [{"type": "missing", "master": "SparseMasters-Medium", "master_idx": 1}],
)
self.assertEqual(
problems["dotabovecomb"],
- [{"type": "missing", "master": "SparseMasters-Medium"}],
+ [{"type": "missing", "master": "SparseMasters-Medium", "master_idx": 1}],
)
# normal order, with --ignore-missing
@@ -229,18 +235,20 @@ class InterpolatableTest(unittest.TestCase):
problems = interpolatable_main(["--quiet", glyphsapp_path])
self.assertEqual(
- problems["a"], [{"type": "missing", "master": "Sparse Masters-Medium"}]
+ problems["a"],
+ [{"type": "missing", "master": "Sparse Masters-Medium", "master_idx": 1}],
)
self.assertEqual(
- problems["s"], [{"type": "missing", "master": "Sparse Masters-Medium"}]
+ problems["s"],
+ [{"type": "missing", "master": "Sparse Masters-Medium", "master_idx": 1}],
)
self.assertEqual(
problems["edotabove"],
- [{"type": "missing", "master": "Sparse Masters-Medium"}],
+ [{"type": "missing", "master": "Sparse Masters-Medium", "master_idx": 1}],
)
self.assertEqual(
problems["dotabovecomb"],
- [{"type": "missing", "master": "Sparse Masters-Medium"}],
+ [{"type": "missing", "master": "Sparse Masters-Medium", "master_idx": 1}],
)
# normal order, with --ignore-missing
diff --git a/Tests/varLib/models_test.py b/Tests/varLib/models_test.py
index 11ec1a1e..7a05c526 100644
--- a/Tests/varLib/models_test.py
+++ b/Tests/varLib/models_test.py
@@ -192,6 +192,17 @@ def test_modeling_error(numLocations, numSamples):
# print("{:d} {:.2} {:.2}".format(i, err, err_bad))
+locationsA = [{}, {"wght": 1}, {"wdth": 1}]
+locationsB = [{}, {"wght": 1}, {"wdth": 1}, {"wght": 1, "wdth": 1}]
+locationsC = [
+ {},
+ {"wght": 0.5},
+ {"wght": 1},
+ {"wdth": 1},
+ {"wght": 1, "wdth": 1},
+]
+
+
class VariationModelTest(object):
@pytest.mark.parametrize(
"locations, axisOrder, sortedLocs, supports, deltaWeights",
@@ -397,7 +408,7 @@ class VariationModelTest(object):
)
@pytest.mark.parametrize(
- "locations, axisOrder, masterValues, instanceLocation, expectedValue",
+ "locations, axisOrder, masterValues, instanceLocation, expectedValue, masterScalars",
[
(
[
@@ -422,6 +433,7 @@ class VariationModelTest(object):
"axis_B": 0.5,
},
37.5,
+ [0.25, 0.0, 0.0, -0.25, 0.5, 0.5],
),
],
)
@@ -432,8 +444,93 @@ class VariationModelTest(object):
masterValues,
instanceLocation,
expectedValue,
+ masterScalars,
):
model = VariationModel(locations, axisOrder=axisOrder)
- interpolatedValue = model.interpolateFromMasters(instanceLocation, masterValues)
+ interpolatedValue = model.interpolateFromMasters(instanceLocation, masterValues)
assert interpolatedValue == expectedValue
+
+ assert masterScalars == model.getMasterScalars(instanceLocation)
+
+ assert model.interpolateFromValuesAndScalars(
+ masterValues, masterScalars
+ ) == pytest.approx(interpolatedValue)
+
+ @pytest.mark.parametrize(
+ "masterLocations, location, expected",
+ [
+ (locationsA, {"wght": 0, "wdth": 0}, [1, 0, 0]),
+ (
+ locationsA,
+ {"wght": 0.5, "wdth": 0},
+ [0.5, 0.5, 0],
+ ),
+ (locationsA, {"wght": 1, "wdth": 0}, [0, 1, 0]),
+ (
+ locationsA,
+ {"wght": 0, "wdth": 0.5},
+ [0.5, 0, 0.5],
+ ),
+ (locationsA, {"wght": 0, "wdth": 1}, [0, 0, 1]),
+ (locationsA, {"wght": 1, "wdth": 1}, [-1, 1, 1]),
+ (
+ locationsA,
+ {"wght": 0.5, "wdth": 0.5},
+ [0, 0.5, 0.5],
+ ),
+ (
+ locationsA,
+ {"wght": 0.75, "wdth": 0.75},
+ [-0.5, 0.75, 0.75],
+ ),
+ (
+ locationsB,
+ {"wght": 1, "wdth": 1},
+ [0, 0, 0, 1],
+ ),
+ (
+ locationsB,
+ {"wght": 0.5, "wdth": 0},
+ [0.5, 0.5, 0, 0],
+ ),
+ (
+ locationsB,
+ {"wght": 1, "wdth": 0.5},
+ [0, 0.5, 0, 0.5],
+ ),
+ (
+ locationsB,
+ {"wght": 0.5, "wdth": 0.5},
+ [0.25, 0.25, 0.25, 0.25],
+ ),
+ (
+ locationsC,
+ {"wght": 0.5, "wdth": 0},
+ [0, 1, 0, 0, 0],
+ ),
+ (
+ locationsC,
+ {"wght": 0.25, "wdth": 0},
+ [0.5, 0.5, 0, 0, 0],
+ ),
+ (
+ locationsC,
+ {"wght": 0.75, "wdth": 0},
+ [0, 0.5, 0.5, 0, 0],
+ ),
+ (
+ locationsC,
+ {"wght": 0.5, "wdth": 1},
+ [-0.5, 1, -0.5, 0.5, 0.5],
+ ),
+ (
+ locationsC,
+ {"wght": 0.75, "wdth": 1},
+ [-0.25, 0.5, -0.25, 0.25, 0.75],
+ ),
+ ],
+ )
+ def test_getMasterScalars(self, masterLocations, location, expected):
+ model = VariationModel(masterLocations)
+ assert model.getMasterScalars(location) == expected
diff --git a/Tests/varLib/varLib_test.py b/Tests/varLib/varLib_test.py
index 87616ae2..53acc165 100644
--- a/Tests/varLib/varLib_test.py
+++ b/Tests/varLib/varLib_test.py
@@ -1,7 +1,13 @@
from fontTools.colorLib.builder import buildCOLR
from fontTools.ttLib import TTFont, newTable
from fontTools.ttLib.tables import otTables as ot
-from fontTools.varLib import build, build_many, load_designspace, _add_COLR
+from fontTools.varLib import (
+ build,
+ build_many,
+ load_designspace,
+ _add_COLR,
+ addGSUBFeatureVariations,
+)
from fontTools.varLib.errors import VarLibValidationError
import fontTools.varLib.errors as varLibErrors
from fontTools.varLib.models import VariationModel
@@ -1009,6 +1015,32 @@ Expected to see .ScriptCount==1, instead saw 0""",
save_before_dump=True,
)
+ def test_varlib_addGSUBFeatureVariations(self):
+ ttx_dir = self.get_test_input("master_ttx_interpolatable_ttf")
+
+ ds = DesignSpaceDocument.fromfile(
+ self.get_test_input("FeatureVars.designspace")
+ )
+ for source in ds.sources:
+ ttx_dump = TTFont()
+ ttx_dump.importXML(
+ os.path.join(
+ ttx_dir, os.path.basename(source.filename).replace(".ufo", ".ttx")
+ )
+ )
+ source.font = ttx_dump
+
+ varfont, _, _ = build(ds, exclude=["GSUB"])
+ assert "GSUB" not in varfont
+
+ addGSUBFeatureVariations(varfont, ds)
+ assert "GSUB" in varfont
+
+ tables = ["fvar", "GSUB"]
+ expected_ttx_path = self.get_test_output("FeatureVars.ttx")
+ self.expect_ttx(varfont, expected_ttx_path, tables)
+ self.check_ttx_dump(varfont, expected_ttx_path, tables, ".ttf")
+
def test_load_masters_layerName_without_required_font():
ds = DesignSpaceDocument()
diff --git a/dev-requirements.txt b/dev-requirements.txt
index 69601f35..f104af9a 100644
--- a/dev-requirements.txt
+++ b/dev-requirements.txt
@@ -5,4 +5,4 @@ sphinx>=1.5.5
mypy>=0.782
# Pin black as each version could change formatting, breaking CI randomly.
-black==23.10.0
+black==24.1.1
diff --git a/requirements.txt b/requirements.txt
index 8a764102..18025212 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -4,16 +4,17 @@ brotli==1.1.0; platform_python_implementation != "PyPy"
brotlicffi==1.1.0.0; platform_python_implementation == "PyPy"
unicodedata2==15.1.0; python_version <= '3.11'
scipy==1.10.0; platform_python_implementation != "PyPy" and python_version <= '3.8' # pyup: ignore
-scipy==1.11.3; platform_python_implementation != "PyPy" and python_version >= '3.9'
+scipy==1.12.0; platform_python_implementation != "PyPy" and python_version >= '3.9'
munkres==1.1.4; platform_python_implementation == "PyPy"
zopfli==0.2.3
fs==2.4.16
skia-pathops==0.8.0.post1; platform_python_implementation != "PyPy"
# this is only required to run Tests/cu2qu/{ufo,cli}_test.py
ufoLib2==0.16.0
-ufo2ft==2.33.4
-pyobjc==10.0; sys_platform == "darwin"
+ufo2ft==3.0.1
+pyobjc==10.1; sys_platform == "darwin"
freetype-py==2.4.0
-uharfbuzz==0.37.3
-glyphsLib==6.4.1 # this is only required to run Tests/varLib/interpolatable_test.py
-lxml==4.9.3
+uharfbuzz==0.39.0
+glyphsLib==6.6.3 # this is only required to run Tests/varLib/interpolatable_test.py
+lxml==5.1.0
+sympy==1.12
diff --git a/setup.cfg b/setup.cfg
index 3c41de2f..aa7f46c4 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,5 +1,5 @@
[bumpversion]
-current_version = 4.44.0
+current_version = 4.49.0
commit = True
tag = False
tag_name = {new_version}
diff --git a/setup.py b/setup.py
index da616903..2d241056 100755
--- a/setup.py
+++ b/setup.py
@@ -46,9 +46,7 @@ env_with_cython = os.environ.get("FONTTOOLS_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
+ else False if env_with_cython in {"0", "false", "no"} else None
)
# --with-cython/--without-cython options override environment variables
opt_with_cython = {"--with-cython"}.intersection(sys.argv)
@@ -97,7 +95,7 @@ extras_require = {
# for fontTools.misc.etree and fontTools.misc.plistlib: use lxml to
# read/write XML files (faster/safer than built-in ElementTree)
"lxml": [
- "lxml >= 4.0, < 5",
+ "lxml >= 4.0",
],
# for fontTools.sfnt and fontTools.woff2: to compress/uncompress
# WOFF 1.0 and WOFF 2.0 webfonts.
@@ -120,6 +118,9 @@ extras_require = {
# use pure-python alternative on pypy
"scipy; platform_python_implementation != 'PyPy'",
"munkres; platform_python_implementation == 'PyPy'",
+ # to output PDF or HTML reports. NOTE: wheels are only available for
+ # windows currently, other platforms will need to build from source.
+ "pycairo",
],
# for fontTools.varLib.plot, to visualize DesignSpaceDocument and resulting
# VariationModel
@@ -241,7 +242,7 @@ class release(Command):
]
changelog_name = "NEWS.rst"
- version_RE = re.compile("^[0-9]+\.[0-9]+")
+ version_RE = re.compile(r"^[0-9]+\.[0-9]+")
date_fmt = "%Y-%m-%d"
header_fmt = "%s (released %s)"
commit_message = "Release {new_version}"
@@ -467,7 +468,7 @@ if ext_modules:
setup_params = dict(
name="fonttools",
- version="4.44.0",
+ version="4.49.0",
description="Tools to manipulate font files",
author="Just van Rossum",
author_email="just@letterror.com",