Merge pull request #2789 from fonttools/varglyphset-refactor

[ttGlyphSet] Refactor/rebuild class hierarchy
This commit is contained in:
Just van Rossum 2022-08-30 12:49:32 +02:00 committed by GitHub
commit 55192edb0c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 267 additions and 244 deletions

View File

@ -4,12 +4,7 @@ from fontTools.misc.configTools import AbstractConfig
from fontTools.misc.textTools import Tag, byteord, tostr
from fontTools.misc.loggingTools import deprecateArgument
from fontTools.ttLib import TTLibError
from fontTools.ttLib.ttGlyphSet import (
_TTGlyphSet, _TTGlyph,
_TTGlyphCFF, _TTGlyphGlyf,
_TTVarGlyphSet,
_TTVarGlyphCFF, _TTVarGlyphGlyf,
)
from fontTools.ttLib.ttGlyphSet import _TTGlyph, _TTGlyphSetCFF, _TTGlyphSetGlyf
from fontTools.ttLib.sfnt import SFNTReader, SFNTWriter
from io import BytesIO, StringIO
import os
@ -684,44 +679,68 @@ class TTFont(object):
def getGlyphSet(self, preferCFF=True, location=None, normalized=False):
"""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
have a ``.draw()`` method that supports the Pen protocol, and will
have an attribute named 'width'.
If the font is CFF-based, the outlines will be taken from the 'CFF ' or
'CFF2' tables. Otherwise the outlines will be taken from the 'glyf' table.
If the font contains both a 'CFF '/'CFF2' and a 'glyf' table, you can use
the 'preferCFF' argument to specify which one should be taken. If the
font contains both a 'CFF ' and a 'CFF2' table, the latter is taken.
If the font is CFF-based, the outlines will be taken from the ``CFF ``
or ``CFF2`` tables. Otherwise the outlines will be taken from the
``glyf`` table.
If the 'location' parameter is set, it should be a dictionary mapping
If the font contains both a ``CFF ``/``CFF2`` and a ``glyf`` table, you
can use the ``preferCFF`` argument to specify which one should be taken.
If the font contains both a ``CFF `` and a ``CFF2`` table, the latter is
taken.
If the ``location`` parameter is set, it should be a dictionary mapping
four-letter variation tags to their float values, and the returned
glyph-set will represent an instance of a variable font at that location.
If the 'normalized' variable is set to True, that location is interpretted
as in the normalized (-1..+1) space, otherwise it is in the font's defined
axes space.
glyph-set will represent an instance of a variable font at that
location.
If the ``normalized`` variable is set to True, that location is
interpreted as in the normalized (-1..+1) space, otherwise it is in the
font's defined axes space.
"""
glyphs = None
if (preferCFF and any(tb in self for tb in ["CFF ", "CFF2"]) or
("glyf" not in self and any(tb in self for tb in ["CFF ", "CFF2"]))):
table_tag = "CFF2" if "CFF2" in self else "CFF "
glyphs = list(self[table_tag].cff.values())[0].CharStrings
if location and 'fvar' in self:
glyphs = _TTVarGlyphSet(self, glyphs, _TTVarGlyphCFF,
location, normalized)
else:
glyphs = _TTGlyphSet(self, glyphs, _TTGlyphCFF)
if glyphs is None and "glyf" in self:
if location and 'gvar' in self:
glyphs = _TTVarGlyphSet(self, self["glyf"], _TTVarGlyphGlyf,
location, normalized)
else:
glyphs = _TTGlyphSet(self, self["glyf"], _TTGlyphGlyf)
if glyphs is None:
if location and "fvar" not in self:
location = None
if location and not normalized:
location = self.normalizeLocation(location)
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)
else:
raise TTLibError("Font contains no outlines")
return glyphs
def normalizeLocation(self, location):
"""Normalize a ``location`` from the font's defined axes space (also
known as user space) into the normalized (-1..+1) space. It applies
``avar`` mapping if the font contains an ``avar`` table.
The ``location`` parameter should be a dictionary mapping four-letter
variation tags to their float values.
Raises ``TTLibError`` if the font is not a variable font.
"""
from fontTools.varLib.models import normalizeLocation, piecewiseLinearMap
if "fvar" not in self:
raise TTLibError("Not a variable font")
axes = {
a.axisTag: (a.minValue, a.defaultValue, a.maxValue) for a in self["fvar"].axes
}
location = normalizeLocation(location, axes)
if "avar" in self:
avar = self["avar"]
avarSegments = avar.segments
mappedLocation = {}
for axisTag, value in location.items():
avarMapping = avarSegments.get(axisTag, None)
if avarMapping is not None:
value = piecewiseLinearMap(value, avarMapping)
mappedLocation[axisTag] = value
location = mappedLocation
return location
def getBestCmap(self, cmapPreferences=((3, 10), (0, 6), (0, 4), (3, 1), (0, 3), (0, 2), (0, 1), (0, 0))):
"""Returns the 'best' Unicode cmap dictionary available in the font

View File

@ -1,158 +1,201 @@
"""GlyphSets returned by a TTFont."""
from fontTools.misc.fixedTools import otRound
from abc import ABC, abstractmethod
from collections.abc import Mapping
from copy import copy
from fontTools.misc.fixedTools import otRound
from fontTools.misc.loggingTools import deprecateFunction
class _TTGlyphSet(object):
class _TTGlyphSet(Mapping):
"""Generic dict-like GlyphSet class that pulls metrics from hmtx and
glyph shape from TrueType or CFF.
"""
def __init__(self, ttFont, glyphs, glyphType):
"""Construct a new glyphset.
def __init__(self, font, location, glyphsMapping):
self.font = font
self.location = location
self.glyphsMapping = glyphsMapping
self.hMetrics = font["hmtx"].metrics
self.vMetrics = getattr(font.get("vmtx"), "metrics", None)
if location:
from fontTools.varLib.varStore import VarStoreInstancer
Args:
font (TTFont): The font object (used to get metrics).
glyphs (dict): A dictionary mapping glyph names to ``_TTGlyph`` objects.
glyphType (class): Either ``_TTGlyphCFF`` or ``_TTGlyphGlyf``.
"""
self._glyphs = glyphs
self._hmtx = ttFont["hmtx"]
self._vmtx = ttFont["vmtx"] if "vmtx" in ttFont else None
self._glyphType = glyphType
self.hvarTable = getattr(font.get("HVAR"), "table", None)
if self.hvarTable is not None:
self.hvarInstancer = VarStoreInstancer(
self.hvarTable.VarStore, font["fvar"].axes, location
)
# TODO VVAR, VORG
def keys(self):
return list(self._glyphs.keys())
def __contains__(self, glyphName):
return glyphName in self.glyphsMapping
def has_key(self, glyphName):
return glyphName in self._glyphs
__contains__ = has_key
def __getitem__(self, glyphName):
horizontalMetrics = self._hmtx[glyphName]
verticalMetrics = self._vmtx[glyphName] if self._vmtx else None
return self._glyphType(
self, self._glyphs[glyphName], horizontalMetrics, verticalMetrics
)
def __iter__(self):
return iter(self.glyphsMapping.keys())
def __len__(self):
return len(self._glyphs)
return len(self.glyphsMapping)
def get(self, glyphName, default=None):
try:
return self[glyphName]
except KeyError:
return default
@deprecateFunction(
"use 'glyphName in glyphSet' instead", category=DeprecationWarning
)
def has_key(self, glyphName):
return glyphName in self.glyphsMapping
class _TTGlyph(object):
class _TTGlyphSetGlyf(_TTGlyphSet):
def __init__(self, font, location):
self.glyfTable = font["glyf"]
super().__init__(font, location, self.glyfTable)
if location:
self.gvarTable = font.get("gvar")
"""Wrapper for a TrueType glyph 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 the 'hmtx' table.
def __getitem__(self, glyphName):
return _TTGlyphGlyf(self, glyphName)
class _TTGlyphSetCFF(_TTGlyphSet):
def __init__(self, font, location):
tableTag = "CFF2" if "CFF2" in font else "CFF "
self.charStrings = list(font[tableTag].cff.values())[0].CharStrings
super().__init__(font, location, self.charStrings)
self.blender = None
if location:
from fontTools.varLib.varStore import VarStoreInstancer
varStore = getattr(self.charStrings, "varStore", None)
if varStore is not None:
instancer = VarStoreInstancer(
varStore.otVarStore, font["fvar"].axes, location
)
self.blender = instancer.interpolateFromDeltas
def __getitem__(self, glyphName):
return _TTGlyphCFF(self, glyphName)
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
the 'hmtx' table.
If the font contains a 'vmtx' table, there will also be 'height' and 'tsb'
attributes.
"""
def __init__(self, glyphset, glyph, horizontalMetrics=None, verticalMetrics=None):
"""Construct a new _TTGlyph.
Args:
glyphset (_TTGlyphSet): A glyphset object used to resolve components.
glyph (ttLib.tables._g_l_y_f.Glyph): The glyph object.
horizontalMetrics (int, int): The glyph's width and left sidebearing.
"""
self._glyphset = glyphset
self._glyph = glyph
if horizontalMetrics:
self.width, self.lsb = horizontalMetrics
else:
self.width, self.lsb = None, None
if verticalMetrics:
self.height, self.tsb = verticalMetrics
def __init__(self, glyphSet, glyphName):
self.glyphSet = glyphSet
self.name = glyphName
self.width, self.lsb = glyphSet.hMetrics[glyphName]
if glyphSet.vMetrics is not None:
self.height, self.tsb = glyphSet.vMetrics[glyphName]
else:
self.height, self.tsb = None, None
if glyphSet.location and glyphSet.hvarTable is not None:
varidx = (
glyphSet.font.getGlyphID(glyphName)
if glyphSet.hvarTable.AdvWidthMap is None
else glyphSet.hvarTable.AdvWidthMap.mapping[glyphName]
)
self.width += glyphSet.hvarInstancer[varidx]
# TODO: VVAR/VORG
@abstractmethod
def draw(self, pen):
"""Draw the glyph onto ``pen``. See fontTools.pens.basePen for details
how that works.
"""
self._glyph.draw(pen)
raise NotImplementedError
def drawPoints(self, pen):
"""Draw the glyph onto ``pen``. See fontTools.pens.pointPen for details
how that works.
"""
from fontTools.pens.pointPen import SegmentToPointPen
self.draw(SegmentToPointPen(pen))
class _TTGlyphCFF(_TTGlyph):
pass
class _TTGlyphGlyf(_TTGlyph):
def draw(self, pen):
"""Draw the glyph onto Pen. See fontTools.pens.basePen for details
"""Draw the glyph onto ``pen``. See fontTools.pens.basePen for details
how that works.
"""
glyfTable = self._glyphset._glyphs
glyph = self._glyph
offset = self.lsb - glyph.xMin if hasattr(glyph, "xMin") else 0
glyph.draw(pen, glyfTable, offset)
glyph, offset = self._getGlyphAndOffset()
glyph.draw(pen, offset)
def drawPoints(self, pen):
"""Draw the glyph onto PointPen. See fontTools.pens.pointPen
for details how that works.
"""Draw the glyph onto ``pen``. See fontTools.pens.pointPen for details
how that works.
"""
glyfTable = self._glyphset._glyphs
glyph = self._glyph
glyph, offset = self._getGlyphAndOffset()
glyph.drawPoints(pen, offset)
def _getGlyphAndOffset(self):
if self.glyphSet.location and self.glyphSet.gvarTable is not None:
glyph = self._getGlyphInstance()
else:
glyph = self.glyphSet.glyfTable[self.name]
offset = self.lsb - glyph.xMin if hasattr(glyph, "xMin") else 0
glyph.drawPoints(pen, glyfTable, offset)
return glyph, offset
def _getGlyphInstance(self):
from fontTools.varLib.iup import iup_delta
from fontTools.ttLib.tables._g_l_y_f import GlyphCoordinates
from fontTools.varLib.models import supportScalar
glyphSet = self.glyphSet
glyfTable = glyphSet.glyfTable
variations = glyphSet.gvarTable.variations[self.name]
hMetrics = glyphSet.hMetrics
vMetrics = glyphSet.vMetrics
coordinates, _ = glyfTable._getCoordinatesAndControls(
self.name, hMetrics, vMetrics
)
origCoords, endPts = None, None
for var in variations:
scalar = supportScalar(glyphSet.location, var.axes)
if not scalar:
continue
delta = var.coordinates
if None in delta:
if origCoords is None:
origCoords, control = glyfTable._getCoordinatesAndControls(
self.name, hMetrics, vMetrics
)
endPts = (
control[1] if control[0] >= 1 else list(range(len(control[1])))
)
delta = iup_delta(delta, origCoords, endPts)
coordinates += GlyphCoordinates(delta) * scalar
glyph = copy(glyfTable[self.name]) # Shallow copy
width, lsb, height, tsb = _setCoordinates(glyph, coordinates, glyfTable)
if glyphSet.hvarTable is None:
# no HVAR: let's set metrics from the phantom points
self.width = width
self.lsb = lsb
self.height = height
self.tsb = tsb
return glyph
class _TTVarGlyphSet(_TTGlyphSet):
def __init__(self, font, glyphs, glyphType, location, normalized):
self._ttFont = font
self._glyphs = glyphs
self._glyphType = glyphType
if not normalized:
from fontTools.varLib.models import normalizeLocation, piecewiseLinearMap
axes = {
a.axisTag: (a.minValue, a.defaultValue, a.maxValue)
for a in font["fvar"].axes
}
location = normalizeLocation(location, axes)
if "avar" in font:
avar = font["avar"]
avarSegments = avar.segments
new_location = {}
for axis_tag, value in location.items():
avarMapping = avarSegments.get(axis_tag, None)
if avarMapping is not None:
value = piecewiseLinearMap(value, avarMapping)
new_location[axis_tag] = value
location = new_location
del new_location
self.location = location
def __getitem__(self, glyphName):
if glyphName not in self._glyphs:
raise KeyError(glyphName)
return self._glyphType(self, glyphName, self.location)
class _TTGlyphCFF(_TTGlyph):
def draw(self, pen):
"""Draw the glyph onto ``pen``. See fontTools.pens.basePen for details
how that works.
"""
self.glyphSet.charStrings[self.name].draw(pen, self.glyphSet.blender)
def _setCoordinates(glyph, coord, glyfTable):
# Handle phantom points for (left, right, top, bottom) positions.
assert len(coord) >= 4
if not hasattr(glyph, "xMin"):
glyph.recalcBounds(glyfTable)
leftSideX = coord[-4][0]
rightSideX = coord[-3][0]
topSideY = coord[-2][1]
@ -163,7 +206,7 @@ def _setCoordinates(glyph, coord, glyfTable):
if glyph.isComposite():
assert len(coord) == len(glyph.components)
glyph.components = [copy(comp) for comp in glyph.components]
glyph.components = [copy(comp) for comp in glyph.components] # Shallow copy
for p, comp in zip(coord, glyph.components):
if hasattr(comp, "x"):
comp.x, comp.y = p
@ -185,94 +228,3 @@ def _setCoordinates(glyph, coord, glyfTable):
verticalAdvanceWidth,
topSideBearing,
)
class _TTVarGlyph(_TTGlyph):
def __init__(self, glyphSet, glyphName, location):
super().__init__(glyphSet._glyphs, glyphSet._glyphs[glyphName])
self._glyphSet = glyphSet
self._ttFont = glyphSet._ttFont
self._glyphs = glyphSet._glyphs
self._glyphName = glyphName
self._location = location
class _TTVarGlyphCFF(_TTVarGlyph):
def draw(self, pen):
varStore = self._glyphs.varStore
if varStore is None:
blender = None
else:
from fontTools.varLib.varStore import VarStoreInstancer
vsInstancer = getattr(self._glyphSet, "vsInstancer", None)
if vsInstancer is None:
self._glyphSet.vsInstancer = vsInstancer = VarStoreInstancer(
varStore.otVarStore, self._ttFont["fvar"].axes, self._location
)
blender = vsInstancer.interpolateFromDeltas
self._glyph.draw(pen, blender)
self.width = self._ttFont["hmtx"][self._glyphName][0]
if "HVAR" in self._ttFont:
hvar = self._ttFont["HVAR"].table
varidx = self._ttFont.getGlyphID(self._glyphName)
if hvar.AdvWidthMap is not None:
varidx = hvar.AdvWidthMap.mapping[self._glyphName]
vsInstancer = VarStoreInstancer(
hvar.VarStore, self._ttFont["fvar"].axes, self._location
)
delta = vsInstancer[varidx]
self.width += delta
class _TTVarGlyphGlyf(_TTVarGlyph):
def draw(self, pen):
self._drawWithPen(pen, isPointPen=False)
def drawPoints(self, pen):
self._drawWithPen(pen, isPointPen=True)
def _drawWithPen(self, pen, isPointPen):
from fontTools.varLib.iup import iup_delta
from fontTools.ttLib.tables._g_l_y_f import GlyphCoordinates
from fontTools.varLib.models import supportScalar
glyf = self._ttFont["glyf"]
hMetrics = self._ttFont["hmtx"].metrics
vMetrics = getattr(self._ttFont.get("vmtx"), "metrics", None)
variations = self._ttFont["gvar"].variations[self._glyphName]
coordinates, _ = glyf._getCoordinatesAndControls(
self._glyphName, hMetrics, vMetrics
)
origCoords, endPts = None, None
for var in variations:
scalar = supportScalar(self._location, var.axes)
if not scalar:
continue
delta = var.coordinates
if None in delta:
if origCoords is None:
origCoords, control = glyf._getCoordinatesAndControls(
self._glyphName, hMetrics, vMetrics
)
endPts = (
control[1] if control[0] >= 1 else list(range(len(control[1])))
)
delta = iup_delta(delta, origCoords, endPts)
coordinates += GlyphCoordinates(delta) * scalar
glyph = copy(glyf[self._glyphName]) # Shallow copy
width, lsb, height, tsb = _setCoordinates(glyph, coordinates, glyf)
self.width = width
self.lsb = lsb
self.height = height
self.tsb = tsb
offset = lsb - glyph.xMin if hasattr(glyph, "xMin") else 0
if isPointPen:
glyph.drawPoints(pen, glyf, offset)
else:
glyph.draw(pen, glyf, offset)

View File

@ -21,12 +21,11 @@ class TTGlyphPenTestBase:
glyphSet = font.getGlyphSet()
glyfTable = font["glyf"]
pen = self.penClass(font.getGlyphSet())
pen = self.penClass(glyphSet)
for name in font.getGlyphOrder():
oldGlyph = glyphSet[name]
getattr(oldGlyph, self.drawMethod)(pen)
oldGlyph = oldGlyph._glyph
getattr(glyphSet[name], self.drawMethod)(pen)
oldGlyph = glyfTable[name]
newGlyph = pen.glyph()
if hasattr(oldGlyph, "program"):

View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<ttFont sfntVersion="\x00\x01\x00\x00" ttLibVersion="4.36">
<avar>
<segment axis="wght">
<mapping from="-1.0" to="-1.0"/>
<mapping from="-0.5" to="-0.75"/>
<mapping from="0.0" to="0.0"/>
<mapping from="0.5" to="0.75"/>
<mapping from="1.0" to="1.0"/>
</segment>
</avar>
<fvar>
<Axis>
<AxisTag>wght</AxisTag>
<Flags>0x0</Flags>
<MinValue>100.0</MinValue>
<DefaultValue>400.0</DefaultValue>
<MaxValue>700.0</MaxValue>
<AxisNameID>256</AxisNameID>
</Axis>
</fvar>
</ttFont>

View File

@ -3,7 +3,7 @@ import os
import re
import random
from fontTools.feaLib.builder import addOpenTypeFeaturesFromString
from fontTools.ttLib import TTFont, newTable, registerCustomTableClass, unregisterCustomTableClass
from fontTools.ttLib import TTFont, TTLibError, newTable, registerCustomTableClass, unregisterCustomTableClass
from fontTools.ttLib.tables.DefaultTable import DefaultTable
from fontTools.ttLib.tables._c_m_a_p import CmapSubtable
import pytest
@ -212,3 +212,36 @@ def test_ensureDecompiled(lazy):
assert "Lookup" in font["GSUB"].table.LookupList.__dict__
assert "reader" not in font["GPOS"].table.LookupList.__dict__
assert "Lookup" in font["GPOS"].table.LookupList.__dict__
@pytest.fixture
def testFont_fvar_avar():
ttxpath = os.path.join(DATA_DIR, "TestTTF_normalizeLocation.ttx")
ttf = TTFont()
ttf.importXML(ttxpath)
return ttf
@pytest.mark.parametrize(
"userLocation, expectedNormalizedLocation",
[
({}, {"wght": 0.0}),
({"wght": 100}, {"wght": -1.0}),
({"wght": 250}, {"wght": -0.75}),
({"wght": 400}, {"wght": 0.0}),
({"wght": 550}, {"wght": 0.75}),
({"wght": 625}, {"wght": 0.875}),
({"wght": 700}, {"wght": 1.0}),
],
)
def test_font_normalizeLocation(
testFont_fvar_avar, userLocation, expectedNormalizedLocation
):
normalizedLocation = testFont_fvar_avar.normalizeLocation(userLocation)
assert expectedNormalizedLocation == normalizedLocation
def test_font_normalizeLocation_no_VF():
ttf = TTFont()
with pytest.raises(TTLibError, match="Not a variable font"):
ttf.normalizeLocation({})

View File

@ -119,8 +119,6 @@ class TTGlyphSetTest(object):
glyphset = font.getGlyphSet(location=location)
assert isinstance(glyphset, ttGlyphSet._TTGlyphSet)
if location:
assert isinstance(glyphset, ttGlyphSet._TTVarGlyphSet)
assert list(glyphset.keys()) == [".notdef", "I"]
@ -136,12 +134,7 @@ class TTGlyphSetTest(object):
assert isinstance(glyph, ttGlyphSet._TTGlyph)
is_glyf = fontfile.endswith(".ttf")
if location:
glyphType = (
ttGlyphSet._TTVarGlyphGlyf if is_glyf else ttGlyphSet._TTVarGlyphCFF
)
else:
glyphType = ttGlyphSet._TTGlyphGlyf if is_glyf else ttGlyphSet._TTGlyphCFF
glyphType = ttGlyphSet._TTGlyphGlyf if is_glyf else ttGlyphSet._TTGlyphCFF
assert isinstance(glyph, glyphType)
glyph.draw(pen)