Skip to content

Commit 530caa3

Browse files
Merge pull request #88 from anthrotype/variable-colr
Add support for variable COLR tables using VarIndexBase and DeltaSetIndexMap
2 parents c57408d + 785fe43 commit 530caa3

25 files changed

+112
-29
lines changed

Lib/blackrenderer/font.py

+77-26
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,16 @@
66
from fontTools.misc.transform import Transform, Identity
77
from fontTools.misc.arrayTools import unionRect
88
from fontTools.ttLib import TTFont
9-
from fontTools.ttLib.tables.otTables import CompositeMode, PaintFormat
9+
from fontTools.ttLib.tables.otTables import (
10+
BaseTable,
11+
ClipBoxFormat,
12+
CompositeMode,
13+
Paint,
14+
PaintFormat,
15+
VarAffine2x3,
16+
VarColorStop,
17+
VarColorLine,
18+
)
1019
from fontTools.varLib.varStore import VarStoreInstancer
1120
import uharfbuzz as hb
1221

@@ -34,6 +43,7 @@ def __init__(self, path, *, fontNumber=0, lazy=True):
3443
self.colrV0Glyphs = {}
3544
self.colrV1Glyphs = {}
3645
self.instancer = None
46+
self.varIndexMap = None
3747

3848
if "COLR" in self.ttFont:
3949
colrTable = self.ttFont["COLR"]
@@ -53,6 +63,8 @@ def __init__(self, path, *, fontNumber=0, lazy=True):
5363
self.clipBoxes = colrTable.ClipList.clips
5464
self.colrLayersV1 = colrTable.LayerList
5565
if colrTable.VarStore is not None:
66+
if colrTable.VarIndexMap:
67+
self.varIndexMap = colrTable.VarIndexMap.mapping
5668
self.instancer = VarStoreInstancer(
5769
colrTable.VarStore, self.ttFont["fvar"].axes
5870
)
@@ -106,6 +118,11 @@ def getGlyphBounds(self, glyphName):
106118
if self.clipBoxes is not None:
107119
box = self.clipBoxes.get(glyphName)
108120
if box is not None:
121+
if (
122+
box.Format == ClipBoxFormat.Variable
123+
and self.instancer is not None
124+
):
125+
box = VarTableWrapper(box, self.instancer, self.varIndexMap)
109126
bounds = box.xMin, box.yMin, box.xMax, box.yMax
110127
elif glyphName in self.colrV0Glyphs:
111128
# For COLRv0, we take the union of all layer bounds
@@ -174,7 +191,7 @@ def _drawPaint(self, paint, canvas):
174191
# PaintVar -- we map to its non-var counterpart and use a wrapper
175192
# that takes care of instantiating values
176193
paintName = PAINT_NAMES[nonVarFormat]
177-
paint = PaintVarWrapper(paint, self.instancer)
194+
paint = VarTableWrapper(paint, self.instancer, self.varIndexMap)
178195
drawHandler = getattr(self, "_draw" + paintName)
179196
drawHandler(paint, canvas)
180197

@@ -487,34 +504,68 @@ def axisValuesToLocation(normalizedAxisValues, axisTags):
487504
}
488505

489506

490-
# _conversionFactors = {
491-
# VarF2Dot14: 1 / (1 << 14),
492-
# VarFixed: 1 / (1 << 16),
493-
# }
507+
class VarTableWrapper:
494508

495-
496-
class PaintVarWrapper:
497-
def __init__(self, wrappedPaint, instancer):
498-
assert not isinstance(wrappedPaint, PaintVarWrapper)
499-
self._wrappedPaint = wrappedPaint
509+
def __init__(self, wrapped, instancer, varIndexMap=None):
510+
assert not isinstance(wrapped, VarTableWrapper)
511+
self._wrapped = wrapped
500512
self._instancer = instancer
513+
self._varIndexMap = varIndexMap
514+
# Map {attrName: varIndexOffset}, where the offset is a number to add to
515+
# VarIndexBase to get to the VarIndex associated with each attribute.
516+
# getVariableAttrs method returns a sequence of variable attributes in the
517+
# order in which they appear in a table.
518+
# E.g. in ColorStop table, the first variable attribute is "StopOffset",
519+
# and the second is "Alpha": hence the StopOffset's VarIndex is computed as
520+
# VarIndexBase + 0, Alpha's is VarIndexBase + 1, etc.
521+
self._varAttrs = {a: i for i, a in enumerate(wrapped.getVariableAttrs())}
501522

502523
def __repr__(self):
503-
return f"PaintVarWrapper({self._wrappedPaint!r})"
524+
return f"VarTableWrapper({self._wrapped!r})"
525+
526+
def _getVarIndexForAttr(self, attrName):
527+
if attrName not in self._varAttrs:
528+
return None
529+
530+
baseIndex = self._wrapped.VarIndexBase
531+
if baseIndex == 0xFFFFFFFF:
532+
return baseIndex
533+
534+
offset = self._varAttrs[attrName]
535+
varIdx = baseIndex + offset
536+
if self._varIndexMap is not None:
537+
try:
538+
varIdx = self._varIndexMap[varIdx]
539+
except IndexError:
540+
pass
541+
542+
return varIdx
543+
544+
def _getDeltaForAttr(self, attrName, varIdx):
545+
delta = self._instancer[varIdx]
546+
# deltas for Fixed or F2Dot14 need to be converted from int to float
547+
conv = self._wrapped.getConverterByName(attrName)
548+
if hasattr(conv, "fromInt"):
549+
delta = conv.fromInt(delta)
550+
return delta
504551

505552
def __getattr__(self, attrName):
506-
value = getattr(self._wrappedPaint, attrName)
507-
raise NotImplementedError("This code is currently not working")
508-
# if isinstance(value, VariableValue):
509-
# if value.varIdx != 0xFFFFFFFF:
510-
# factor = _conversionFactors.get(
511-
# type(self._wrappedPaint.getConverterByName(attrName)), 1
512-
# )
513-
# value = value.value + self._instancer[value.varIdx] * factor
514-
# else:
515-
# value = value.value
516-
# elif type(value).__name__.startswith("Var"):
517-
# value = PaintVarWrapper(value, self._instancer)
518-
# elif isinstance(value, (list, UserList)):
519-
# value = [PaintVarWrapper(item, self._instancer) for item in value]
553+
value = getattr(self._wrapped, attrName)
554+
555+
varIdx = self._getVarIndexForAttr(attrName)
556+
if varIdx is not None:
557+
if varIdx < 0xFFFFFFFF:
558+
value += self._getDeltaForAttr(attrName, varIdx)
559+
elif isinstance(value, (VarAffine2x3, VarColorLine)):
560+
value = VarTableWrapper(value, self._instancer, self._varIndexMap)
561+
elif (
562+
isinstance(value, (list, UserList))
563+
and value
564+
and isinstance(value[0], VarColorStop)
565+
):
566+
value = [
567+
VarTableWrapper(item, self._instancer, self._varIndexMap)
568+
for item in value
569+
]
570+
520571
return value

Tests/data/TestVariableCOLR-VF.ttf

1.48 KB
Binary file not shown.
Loading
Loading
Loading
Loading
Loading
Loading
Loading
Loading
Loading
Loading
Loading
Loading
Loading
Loading
Loading
Loading

Tests/test_glyph_render.py

+11-1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"more_samples": dataDir / "more_samples-glyf_colr_1.ttf",
2929
"crash": dataDir / "crash.subset.otf",
3030
"nested_paintglyph": dataDir / "nested-paintglyph.ttf",
31+
"ftvartest": dataDir / "TestVariableCOLR-VF.ttf",
3132
}
3233

3334

@@ -93,6 +94,10 @@
9394
("more_samples", "skew_0_15_center_0_0", None),
9495
("more_samples", "upem_box_glyph", None),
9596
("nested_paintglyph", "A", None),
97+
("ftvartest", "A", {"wght": 400}),
98+
("ftvartest", "A", {"wght": 700}),
99+
("ftvartest", "B", {"wght": 400}),
100+
("ftvartest", "B", {"wght": 700}),
96101
]
97102

98103

@@ -113,14 +118,19 @@ def test_renderGlyph(backendName, surfaceClass, fontName, glyphName, location):
113118
canvas.scale(scaleFactor)
114119
font.drawGlyph(glyphName, canvas)
115120

116-
fileName = f"glyph_{fontName}_{glyphName}_{backendName}{ext}"
121+
locationString = "_" + _locationToString(location) if location else ""
122+
fileName = f"glyph_{fontName}_{glyphName}{locationString}_{backendName}{ext}"
117123
expectedPath = expectedOutputDir / fileName
118124
outputPath = tmpOutputDir / fileName
119125
surface.saveImage(outputPath)
120126
diff = compareImages(expectedPath, outputPath)
121127
assert diff < 0.00012, diff
122128

123129

130+
def _locationToString(location):
131+
return ",".join(f"{name}={value}" for name, value in sorted(location.items()))
132+
133+
124134
def test_pathCollector():
125135
font = BlackRendererFont(testFonts["noto"])
126136
canvas = PathCollectorCanvas()

requirements.txt

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
fonttools==4.33.3
1+
fonttools==4.34.0
22
uharfbuzz==0.27.0

setup.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
package_dir={"": "Lib"},
3737
packages=find_packages("Lib"),
3838
install_requires=[
39-
"fonttools >= 4.27.0",
39+
"fonttools >= 4.34.0",
4040
"uharfbuzz >= 0.16.0",
4141
],
4242
extras_require={

0 commit comments

Comments
 (0)