[colrv1] otData: define ClipList, ClipBox, etc.
This commit is contained in:
parent
c552a77fea
commit
bee2c85f61
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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 -->",
|
||||
|
Loading…
x
Reference in New Issue
Block a user