[colrv1] otData: define ClipList, ClipBox, etc.

This commit is contained in:
Cosimo Lupo 2021-07-26 15:47:02 +01:00
parent c552a77fea
commit bee2c85f61
5 changed files with 291 additions and 7 deletions

View File

@ -21,6 +21,7 @@ from typing import (
TypeVar,
Union,
)
from fontTools.misc.arrayTools import intRect
from fontTools.misc.fixedTools import fixedToFloat
from fontTools.ttLib.tables import C_O_L_R_
from fontTools.ttLib.tables import C_P_A_L_
@ -39,6 +40,11 @@ _PaintInput = Union[int, _Kwargs, ot.Paint, Tuple[str, "_PaintInput"]]
_PaintInputList = Sequence[_PaintInput]
_ColorGlyphsDict = Dict[str, Union[_PaintInputList, _PaintInput]]
_ColorGlyphsV0Dict = Dict[str, Sequence[Tuple[str, int]]]
_ClipBoxInput = Union[
Tuple[int, int, int, int, int], # format 1, variable
Tuple[int, int, int, int], # format 0, non-variable
ot.ClipBox,
]
MAX_PAINT_COLR_LAYER_COUNT = 255
@ -183,6 +189,7 @@ def buildCOLR(
glyphMap: Optional[Mapping[str, int]] = None,
varStore: Optional[ot.VarStore] = None,
varIndexMap: Optional[ot.DeltaSetIndexMap] = None,
clipBoxes: Optional[Dict[str, _ClipBoxInput]] = None,
) -> C_O_L_R_.table_C_O_L_R_:
"""Build COLR table from color layers mapping.
Args:
@ -197,6 +204,8 @@ def buildCOLR(
TTFont.getReverseGlyphMap(), to optionally sort base records by GID.
varStore: Optional ItemVarationStore for deltas associated with v1 layer.
varIndexMap: Optional DeltaSetIndexMap for deltas associated with v1 layer.
clipBoxes: Optional map of base glyph name to clip box 4- or 5-tuples:
(xMin, yMin, xMax, yMax) or (xMin, yMin, xMax, yMax, varIndexBase).
Return:
A new COLR table.
"""
@ -230,6 +239,10 @@ def buildCOLR(
if version == 0:
self.ColorLayers = self._decompileColorLayersV0(colr)
else:
clipBoxes = {
name: clipBoxes[name] for name in clipBoxes or {} if name in colorGlyphsV1
}
colr.ClipList = buildClipList(clipBoxes) if clipBoxes else None
colr.VarIndexMap = varIndexMap
colr.VarStore = varStore
self.table = colr
@ -237,6 +250,28 @@ def buildCOLR(
return self
def buildClipList(clipBoxes: Dict[str, _ClipBoxInput]) -> ot.ClipList:
clipList = ot.ClipList()
clipList.clips = {name: buildClipBox(box) for name, box in clipBoxes.items()}
return clipList
def buildClipBox(clipBox: _ClipBoxInput) -> ot.ClipBox:
if isinstance(clipBox, ot.ClipBox):
return clipBox
n = len(clipBox)
clip = ot.ClipBox()
if n < 4 or n > 5:
raise ValueError(f"Invalid ClipBox: expected 4 or 5 values, found {n}")
clip.xMin, clip.yMin, clip.xMax, clip.yMax = intRect(clipBox[:4])
if n == 5:
clip.Format = 1
clip.VarIndexBase = int(clipBox[4])
else:
clip.Format = 0
return clip
class ColorPaletteType(enum.IntFlag):
USABLE_WITH_LIGHT_BACKGROUND = 0x0001
USABLE_WITH_DARK_BACKGROUND = 0x0002

View File

@ -1562,6 +1562,7 @@ otData = [
('uint16', 'LayerRecordCount', None, None, 'Number of Layer Records.'),
('LOffset', 'BaseGlyphList', None, 'Version >= 1', 'Offset (from beginning of COLR table) to array of Version-1 Base Glyph records.'),
('LOffset', 'LayerList', None, 'Version >= 1', 'Offset (from beginning of COLR table) to LayerList.'),
('LOffset', 'ClipList', None, 'Version >= 1', 'Offset to ClipList table (may be NULL)'),
('LOffsetTo(DeltaSetIndexMap)', 'VarIndexMap', None, 'Version >= 1', 'Offset to DeltaSetIndexMap table (may be NULL)'),
('LOffset', 'VarStore', None, 'Version >= 1', 'Offset to variation store (may be NULL)'),
]),
@ -1600,6 +1601,34 @@ otData = [
('LOffset', 'Paint', 'LayerCount', 0, 'Array of offsets to Paint tables, from the start of the LayerList table.'),
]),
('ClipList', [
('uint32', 'ClipCount', None, None, 'Number of Clip records.'),
('struct', 'ClipRecord', 'ClipCount', 0, 'Array of Clip records sorted by glyph ID.'),
]),
('ClipRecord', [
('uint16', 'StartGlyphID', None, None, 'First glyph ID in the range.'),
('uint16', 'EndGlyphID', None, None, 'Last glyph ID in the range.'),
('Offset24', 'ClipBox', None, None, 'Offset to a ClipBox table.'),
]),
('ClipBoxFormat0', [
('uint8', 'Format', None, None, 'Format for ClipBox without variation: set to 0.'),
('int16', 'xMin', None, None, 'Minimum x of clip box.'),
('int16', 'yMin', None, None, 'Minimum y of clip box.'),
('int16', 'xMax', None, None, 'Maximum x of clip box.'),
('int16', 'yMax', None, None, 'Maximum y of clip box.'),
]),
('ClipBoxFormat1', [
('uint8', 'Format', None, None, 'Format for variable ClipBox: set to 1.'),
('int16', 'xMin', None, None, 'Minimum x of clip box.'),
('int16', 'yMin', None, None, 'Minimum y of clip box.'),
('int16', 'xMax', None, None, 'Maximum x of clip box.'),
('int16', 'yMax', None, None, 'Maximum y of clip box.'),
('VarIndex', 'VarIndexBase', None, None, 'Base index into DeltaSetIndexMap.'),
]),
# COLRv1 Affine2x3 uses the same column-major order to serialize a 2D
# Affine Transformation as the one used by fontTools.misc.transform.
# However, for historical reasons, the labels 'xy' and 'yx' are swapped.

View File

@ -5,9 +5,10 @@ OpenType subtables.
Most are constructed upon import from data in otData.py, all are populated with
converter objects from otConverters.py.
"""
import copy
from enum import IntEnum
import itertools
from collections import namedtuple
from collections import defaultdict, namedtuple
from fontTools.misc.py23 import bytesjoin
from fontTools.misc.roundTools import otRound
from fontTools.misc.textTools import pad, safeEval
@ -1297,6 +1298,144 @@ class BaseGlyphList(BaseTable):
return self.__dict__.copy()
class ClipBox(getFormatSwitchingBaseTableClass("uint8")):
def as_tuple(self):
return tuple(getattr(self, conv.name) for conv in self.getConverters())
def __repr__(self):
return f"{self.__class__.__name__}{self.as_tuple()}"
class ClipList(BaseTable):
def populateDefaults(self, propagator=None):
if not hasattr(self, "clips"):
self.clips = {}
def postRead(self, rawTable, font):
clips = {}
glyphOrder = font.getGlyphOrder()
for i, rec in enumerate(rawTable["ClipRecord"]):
rangesOverlap = False
for glyphID in range(rec.StartGlyphID, rec.EndGlyphID + 1):
try:
glyph = glyphOrder[glyphID]
except IndexError:
continue
if glyph not in clips:
clips[glyph] = copy.copy(rec.ClipBox)
else:
rangesOverlap = True
if rangesOverlap:
log.warning(
"ClipRecord %i overlap previous records; "
"redefined clip boxes are skipped",
i,
)
self.clips = clips
def groups(self):
glyphsByClip = defaultdict(list)
uniqueClips = {}
for glyphName, clipBox in self.clips.items():
key = hash(clipBox.as_tuple())
glyphsByClip[key].append(glyphName)
if key not in uniqueClips:
uniqueClips[key] = clipBox
return {
frozenset(glyphs): uniqueClips[key]
for key, glyphs in glyphsByClip.items()
}
def preWrite(self, font):
if not hasattr(self, "clips"):
self.clips = {}
clipBoxRanges = {}
glyphMap = font.getReverseGlyphMap()
for glyphs, clipBox in self.groups().items():
glyphIDs = sorted(
glyphMap[glyphName] for glyphName in glyphs
if glyphName in glyphMap
)
if not glyphIDs:
continue
last = glyphIDs[0]
ranges = [[last]]
for glyphID in glyphIDs[1:]:
if glyphID != last + 1:
ranges[-1].append(last)
ranges.append([glyphID])
last = glyphID
ranges[-1].append(last)
for start, end in ranges:
assert (start, end) not in clipBoxRanges
clipBoxRanges[(start, end)] = clipBox
clipRecords = []
for (start, end), clipBox in sorted(clipBoxRanges.items()):
record = ClipRecord()
record.StartGlyphID = start
record.EndGlyphID = end
record.ClipBox = clipBox
clipRecords.append(record)
rawTable = {
"ClipCount": len(clipRecords),
"ClipRecord": clipRecords,
}
return rawTable
def toXML(self, xmlWriter, font, attrs=None, name=None):
tableName = name if name else self.__class__.__name__
if attrs is None:
attrs = []
xmlWriter.begintag(tableName, attrs)
xmlWriter.newline()
# sort clips alphabetically to ensure deterministic XML dump
for glyphs, clipBox in sorted(
self.groups().items(), key=lambda item: min(item[0])
):
xmlWriter.begintag("Clip")
xmlWriter.newline()
for glyphName in sorted(glyphs):
xmlWriter.simpletag("Glyph", value=glyphName)
xmlWriter.newline()
xmlWriter.begintag("ClipBox", [("Format", clipBox.Format)])
xmlWriter.newline()
clipBox.toXML2(xmlWriter, font)
xmlWriter.endtag("ClipBox")
xmlWriter.newline()
xmlWriter.endtag("Clip")
xmlWriter.newline()
xmlWriter.endtag(tableName)
xmlWriter.newline()
def fromXML(self, name, attrs, content, font):
clips = getattr(self, "clips", None)
if clips is None:
self.clips = clips = {}
assert name == "Clip"
glyphs = []
clipBox = None
for elem in content:
if not isinstance(elem, tuple):
continue
name, attrs, content = elem
if name == "Glyph":
glyphs.append(attrs["value"])
elif name == "ClipBox":
clipBox = ClipBox()
clipBox.Format = safeEval(attrs["Format"])
for elem in content:
if not isinstance(elem, tuple):
continue
name, attrs, content = elem
clipBox.fromXML(name, attrs, content, font)
if clipBox:
for glyphName in glyphs:
clips[glyphName] = clipBox
class ExtendMode(IntEnum):
PAD = 0
REPEAT = 1

View File

@ -1660,6 +1660,37 @@ class BuildCOLRTest(object):
assert paint.Format == ot.PaintFormat.PaintGlyph
assert paint.Paint.Format == ot.PaintFormat.PaintSolid
def test_build_clip_list(self):
colr = builder.buildCOLR(
{
"a": (
ot.PaintFormat.PaintGlyph,
(ot.PaintFormat.PaintSolid, 0),
"b",
),
"c": (
ot.PaintFormat.PaintGlyph,
(ot.PaintFormat.PaintSolid, 1),
"d",
),
},
clipBoxes={
"a": (0, 0, 1000, 1000, 0), # optional 5th: varIndexBase
"c": (-100.8, -200.4, 1100.1, 1200.5), # floats get rounded
"e": (0, 0, 10, 10), # missing base glyph 'e' is ignored
},
)
clipBoxes = colr.table.ClipList.clips
assert [
(baseGlyph, clipBox.as_tuple()) for baseGlyph, clipBox in clipBoxes.items()
] == [
("a", (0, 0, 1000, 1000, 0)),
("c", (-101, -201, 1101, 1201)),
]
assert clipBoxes["a"].Format == 1
assert clipBoxes["c"].Format == 0
class TrickyRadialGradientTest:
@staticmethod

View File

@ -104,13 +104,14 @@ COLR_V1_SAMPLE = (
(b"\x00\x01", "Version (1)"),
(b"\x00\x01", "BaseGlyphRecordCount (1)"),
(
b"\x00\x00\x00\x1e",
"Offset to BaseGlyphRecordArray from beginning of table (30)",
b"\x00\x00\x00\x22",
"Offset to BaseGlyphRecordArray from beginning of table (34)",
),
(b"\x00\x00\x00\x24", "Offset to LayerRecordArray from beginning of table (36)"),
(b"\x00\x00\x00\x28", "Offset to LayerRecordArray from beginning of table (40)"),
(b"\x00\x03", "LayerRecordCount (3)"),
(b"\x00\x00\x00\x30", "Offset to BaseGlyphList from beginning of table (48)"),
(b"\x00\x00\x00\x9b", "Offset to LayerList from beginning of table (155)"),
(b"\x00\x00\x00\x34", "Offset to BaseGlyphList from beginning of table (52)"),
(b"\x00\x00\x00\x9f", "Offset to LayerList from beginning of table (159)"),
(b"\x00\x00\x01\x62", "Offset to ClipList (354)"),
(b"\x00\x00\x00\x00", "Offset to DeltaSetIndexMap (NULL)"),
(b"\x00\x00\x00\x00", "Offset to VarStore (NULL)"),
(b"\x00\x06", "BaseGlyphRecord[0].BaseGlyph (6)"),
@ -295,6 +296,28 @@ COLR_V1_SAMPLE = (
(b"\x02", "LayerList.Paint[0].Paint.Paint.Paint.Paint.Format (2)"),
(b"\x00\x02", "Paint.PaletteIndex (2)"),
(b" \x00", "Paint.Alpha (0.5)"),
# ClipList
(b'\x00\x00\x00\x02', "ClipList.ClipCount (2)"),
(b'\x00\x0a', "ClipRecord[0].StartGlyphID (10)"),
(b'\x00\x0a', "ClipRecord[0].EndGlyphID (10)"),
(b'\x00\x00\x12', "Offset to ClipBox subtable from beginning of ClipList (18)"),
(b'\x00\x0e', "ClipRecord[1].StartGlyphID (14)"),
(b'\x00\x0f', "ClipRecord[1].EndGlyphID (15)"),
(b'\x00\x00\x1f', "Offset to ClipBox subtable from beginning of ClipList (31)"),
(b'\x01', "ClipBox.Format (1)"),
(b'\x00\x00', "ClipBox.xMin (0)"),
(b'\x00\x00', "ClipBox.yMin (0)"),
(b'\x01\xf4', "ClipBox.xMax (500)"),
(b'\x01\xf4', "ClipBox.yMax (500)"),
(b'\x00\x00\x00\t', "ClipBox.VarIndexBase (9)"),
(b'\x00', "ClipBox.Format (0)"),
(b'\x00\x00', "ClipBox.xMin (0)"),
(b'\x00\x00', "ClipBox.yMin (0)"),
(b'\x03\xe8', "ClipBox.xMax (1000)"),
(b'\x03\xe8', "ClipBox.yMax (1000)"),
)
COLR_V1_DATA = b"".join(t[0] for t in COLR_V1_SAMPLE)
@ -482,6 +505,28 @@ COLR_V1_XML = [
' <dy value="258"/>',
" </Paint>",
"</LayerList>",
"<ClipList>",
" <Clip>",
' <Glyph value="glyph00010"/>',
' <ClipBox Format="1">',
' <xMin value="0"/>',
' <yMin value="0"/>',
' <xMax value="500"/>',
' <yMax value="500"/>',
' <VarIndexBase value="9"/>',
" </ClipBox>",
" </Clip>",
" <Clip>",
' <Glyph value="glyph00014"/>',
' <Glyph value="glyph00015"/>',
' <ClipBox Format="0">',
' <xMin value="0"/>',
' <yMin value="0"/>',
' <xMax value="1000"/>',
' <yMax value="1000"/>',
" </ClipBox>",
" </Clip>",
"</ClipList>",
]
COLR_V1_VAR_XML = [
@ -495,6 +540,10 @@ COLR_V1_VAR_XML = [
' <Map index="6" outer="0" inner="2"/>',
' <Map index="7" outer="0" inner="0"/>',
' <Map index="8" outer="0" inner="1"/>',
' <Map index="9" outer="1" inner="0"/>',
' <Map index="10" outer="1" inner="0"/>',
' <Map index="11" outer="0" inner="3"/>',
' <Map index="12" outer="0" inner="3"/>',
"</VarIndexMap>",
'<VarStore Format="1">',
' <Format value="1"/>',
@ -511,13 +560,14 @@ COLR_V1_VAR_XML = [
" </VarRegionList>",
" <!-- VarDataCount=2 -->",
' <VarData index="0">',
" <!-- ItemCount=3 -->",
" <!-- ItemCount=4 -->",
' <NumShorts value="1"/>',
" <!-- VarRegionCount=1 -->",
' <VarRegionIndex index="0" value="0"/>',
' <Item index="0" value="[-3277]"/>',
' <Item index="1" value="[6553]"/>',
' <Item index="2" value="[8192]"/>',
' <Item index="3" value="[500]"/>',
" </VarData>",
' <VarData index="1">',
" <!-- ItemCount=2 -->",