2301 lines
81 KiB
Python
2301 lines
81 KiB
Python
# coding: utf-8
|
|
"""fontTools.ttLib.tables.otTables -- A collection of classes representing the various
|
|
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
|
|
from functools import reduce
|
|
from math import radians
|
|
import itertools
|
|
from collections import defaultdict, namedtuple
|
|
from fontTools.ttLib.tables.otTraverse import dfs_base_table
|
|
from fontTools.misc.arrayTools import quantizeRect
|
|
from fontTools.misc.roundTools import otRound
|
|
from fontTools.misc.transform import Transform, Identity
|
|
from fontTools.misc.textTools import bytesjoin, pad, safeEval
|
|
from fontTools.pens.boundsPen import ControlBoundsPen
|
|
from fontTools.pens.transformPen import TransformPen
|
|
from .otBase import (
|
|
BaseTable,
|
|
FormatSwitchingBaseTable,
|
|
ValueRecord,
|
|
CountReference,
|
|
getFormatSwitchingBaseTableClass,
|
|
)
|
|
from fontTools.feaLib.lookupDebugInfo import LookupDebugInfo, LOOKUP_DEBUG_INFO_KEY
|
|
import logging
|
|
import struct
|
|
from typing import TYPE_CHECKING, Iterator, List, Optional, Set
|
|
|
|
if TYPE_CHECKING:
|
|
from fontTools.ttLib.ttGlyphSet import _TTGlyphSet
|
|
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
class AATStateTable(object):
|
|
def __init__(self):
|
|
self.GlyphClasses = {} # GlyphID --> GlyphClass
|
|
self.States = [] # List of AATState, indexed by state number
|
|
self.PerGlyphLookups = [] # [{GlyphID:GlyphID}, ...]
|
|
|
|
|
|
class AATState(object):
|
|
def __init__(self):
|
|
self.Transitions = {} # GlyphClass --> AATAction
|
|
|
|
|
|
class AATAction(object):
|
|
_FLAGS = None
|
|
|
|
@staticmethod
|
|
def compileActions(font, states):
|
|
return (None, None)
|
|
|
|
def _writeFlagsToXML(self, xmlWriter):
|
|
flags = [f for f in self._FLAGS if self.__dict__[f]]
|
|
if flags:
|
|
xmlWriter.simpletag("Flags", value=",".join(flags))
|
|
xmlWriter.newline()
|
|
if self.ReservedFlags != 0:
|
|
xmlWriter.simpletag("ReservedFlags", value="0x%04X" % self.ReservedFlags)
|
|
xmlWriter.newline()
|
|
|
|
def _setFlag(self, flag):
|
|
assert flag in self._FLAGS, "unsupported flag %s" % flag
|
|
self.__dict__[flag] = True
|
|
|
|
|
|
class RearrangementMorphAction(AATAction):
|
|
staticSize = 4
|
|
actionHeaderSize = 0
|
|
_FLAGS = ["MarkFirst", "DontAdvance", "MarkLast"]
|
|
|
|
_VERBS = {
|
|
0: "no change",
|
|
1: "Ax ⇒ xA",
|
|
2: "xD ⇒ Dx",
|
|
3: "AxD ⇒ DxA",
|
|
4: "ABx ⇒ xAB",
|
|
5: "ABx ⇒ xBA",
|
|
6: "xCD ⇒ CDx",
|
|
7: "xCD ⇒ DCx",
|
|
8: "AxCD ⇒ CDxA",
|
|
9: "AxCD ⇒ DCxA",
|
|
10: "ABxD ⇒ DxAB",
|
|
11: "ABxD ⇒ DxBA",
|
|
12: "ABxCD ⇒ CDxAB",
|
|
13: "ABxCD ⇒ CDxBA",
|
|
14: "ABxCD ⇒ DCxAB",
|
|
15: "ABxCD ⇒ DCxBA",
|
|
}
|
|
|
|
def __init__(self):
|
|
self.NewState = 0
|
|
self.Verb = 0
|
|
self.MarkFirst = False
|
|
self.DontAdvance = False
|
|
self.MarkLast = False
|
|
self.ReservedFlags = 0
|
|
|
|
def compile(self, writer, font, actionIndex):
|
|
assert actionIndex is None
|
|
writer.writeUShort(self.NewState)
|
|
assert self.Verb >= 0 and self.Verb <= 15, self.Verb
|
|
flags = self.Verb | self.ReservedFlags
|
|
if self.MarkFirst:
|
|
flags |= 0x8000
|
|
if self.DontAdvance:
|
|
flags |= 0x4000
|
|
if self.MarkLast:
|
|
flags |= 0x2000
|
|
writer.writeUShort(flags)
|
|
|
|
def decompile(self, reader, font, actionReader):
|
|
assert actionReader is None
|
|
self.NewState = reader.readUShort()
|
|
flags = reader.readUShort()
|
|
self.Verb = flags & 0xF
|
|
self.MarkFirst = bool(flags & 0x8000)
|
|
self.DontAdvance = bool(flags & 0x4000)
|
|
self.MarkLast = bool(flags & 0x2000)
|
|
self.ReservedFlags = flags & 0x1FF0
|
|
|
|
def toXML(self, xmlWriter, font, attrs, name):
|
|
xmlWriter.begintag(name, **attrs)
|
|
xmlWriter.newline()
|
|
xmlWriter.simpletag("NewState", value=self.NewState)
|
|
xmlWriter.newline()
|
|
self._writeFlagsToXML(xmlWriter)
|
|
xmlWriter.simpletag("Verb", value=self.Verb)
|
|
verbComment = self._VERBS.get(self.Verb)
|
|
if verbComment is not None:
|
|
xmlWriter.comment(verbComment)
|
|
xmlWriter.newline()
|
|
xmlWriter.endtag(name)
|
|
xmlWriter.newline()
|
|
|
|
def fromXML(self, name, attrs, content, font):
|
|
self.NewState = self.Verb = self.ReservedFlags = 0
|
|
self.MarkFirst = self.DontAdvance = self.MarkLast = False
|
|
content = [t for t in content if isinstance(t, tuple)]
|
|
for eltName, eltAttrs, eltContent in content:
|
|
if eltName == "NewState":
|
|
self.NewState = safeEval(eltAttrs["value"])
|
|
elif eltName == "Verb":
|
|
self.Verb = safeEval(eltAttrs["value"])
|
|
elif eltName == "ReservedFlags":
|
|
self.ReservedFlags = safeEval(eltAttrs["value"])
|
|
elif eltName == "Flags":
|
|
for flag in eltAttrs["value"].split(","):
|
|
self._setFlag(flag.strip())
|
|
|
|
|
|
class ContextualMorphAction(AATAction):
|
|
staticSize = 8
|
|
actionHeaderSize = 0
|
|
_FLAGS = ["SetMark", "DontAdvance"]
|
|
|
|
def __init__(self):
|
|
self.NewState = 0
|
|
self.SetMark, self.DontAdvance = False, False
|
|
self.ReservedFlags = 0
|
|
self.MarkIndex, self.CurrentIndex = 0xFFFF, 0xFFFF
|
|
|
|
def compile(self, writer, font, actionIndex):
|
|
assert actionIndex is None
|
|
writer.writeUShort(self.NewState)
|
|
flags = self.ReservedFlags
|
|
if self.SetMark:
|
|
flags |= 0x8000
|
|
if self.DontAdvance:
|
|
flags |= 0x4000
|
|
writer.writeUShort(flags)
|
|
writer.writeUShort(self.MarkIndex)
|
|
writer.writeUShort(self.CurrentIndex)
|
|
|
|
def decompile(self, reader, font, actionReader):
|
|
assert actionReader is None
|
|
self.NewState = reader.readUShort()
|
|
flags = reader.readUShort()
|
|
self.SetMark = bool(flags & 0x8000)
|
|
self.DontAdvance = bool(flags & 0x4000)
|
|
self.ReservedFlags = flags & 0x3FFF
|
|
self.MarkIndex = reader.readUShort()
|
|
self.CurrentIndex = reader.readUShort()
|
|
|
|
def toXML(self, xmlWriter, font, attrs, name):
|
|
xmlWriter.begintag(name, **attrs)
|
|
xmlWriter.newline()
|
|
xmlWriter.simpletag("NewState", value=self.NewState)
|
|
xmlWriter.newline()
|
|
self._writeFlagsToXML(xmlWriter)
|
|
xmlWriter.simpletag("MarkIndex", value=self.MarkIndex)
|
|
xmlWriter.newline()
|
|
xmlWriter.simpletag("CurrentIndex", value=self.CurrentIndex)
|
|
xmlWriter.newline()
|
|
xmlWriter.endtag(name)
|
|
xmlWriter.newline()
|
|
|
|
def fromXML(self, name, attrs, content, font):
|
|
self.NewState = self.ReservedFlags = 0
|
|
self.SetMark = self.DontAdvance = False
|
|
self.MarkIndex, self.CurrentIndex = 0xFFFF, 0xFFFF
|
|
content = [t for t in content if isinstance(t, tuple)]
|
|
for eltName, eltAttrs, eltContent in content:
|
|
if eltName == "NewState":
|
|
self.NewState = safeEval(eltAttrs["value"])
|
|
elif eltName == "Flags":
|
|
for flag in eltAttrs["value"].split(","):
|
|
self._setFlag(flag.strip())
|
|
elif eltName == "ReservedFlags":
|
|
self.ReservedFlags = safeEval(eltAttrs["value"])
|
|
elif eltName == "MarkIndex":
|
|
self.MarkIndex = safeEval(eltAttrs["value"])
|
|
elif eltName == "CurrentIndex":
|
|
self.CurrentIndex = safeEval(eltAttrs["value"])
|
|
|
|
|
|
class LigAction(object):
|
|
def __init__(self):
|
|
self.Store = False
|
|
# GlyphIndexDelta is a (possibly negative) delta that gets
|
|
# added to the glyph ID at the top of the AAT runtime
|
|
# execution stack. It is *not* a byte offset into the
|
|
# morx table. The result of the addition, which is performed
|
|
# at run time by the shaping engine, is an index into
|
|
# the ligature components table. See 'morx' specification.
|
|
# In the AAT specification, this field is called Offset;
|
|
# but its meaning is quite different from other offsets
|
|
# in either AAT or OpenType, so we use a different name.
|
|
self.GlyphIndexDelta = 0
|
|
|
|
|
|
class LigatureMorphAction(AATAction):
|
|
staticSize = 6
|
|
|
|
# 4 bytes for each of {action,ligComponents,ligatures}Offset
|
|
actionHeaderSize = 12
|
|
|
|
_FLAGS = ["SetComponent", "DontAdvance"]
|
|
|
|
def __init__(self):
|
|
self.NewState = 0
|
|
self.SetComponent, self.DontAdvance = False, False
|
|
self.ReservedFlags = 0
|
|
self.Actions = []
|
|
|
|
def compile(self, writer, font, actionIndex):
|
|
assert actionIndex is not None
|
|
writer.writeUShort(self.NewState)
|
|
flags = self.ReservedFlags
|
|
if self.SetComponent:
|
|
flags |= 0x8000
|
|
if self.DontAdvance:
|
|
flags |= 0x4000
|
|
if len(self.Actions) > 0:
|
|
flags |= 0x2000
|
|
writer.writeUShort(flags)
|
|
if len(self.Actions) > 0:
|
|
actions = self.compileLigActions()
|
|
writer.writeUShort(actionIndex[actions])
|
|
else:
|
|
writer.writeUShort(0)
|
|
|
|
def decompile(self, reader, font, actionReader):
|
|
assert actionReader is not None
|
|
self.NewState = reader.readUShort()
|
|
flags = reader.readUShort()
|
|
self.SetComponent = bool(flags & 0x8000)
|
|
self.DontAdvance = bool(flags & 0x4000)
|
|
performAction = bool(flags & 0x2000)
|
|
# As of 2017-09-12, the 'morx' specification says that
|
|
# the reserved bitmask in ligature subtables is 0x3FFF.
|
|
# However, the specification also defines a flag 0x2000,
|
|
# so the reserved value should actually be 0x1FFF.
|
|
# TODO: Report this specification bug to Apple.
|
|
self.ReservedFlags = flags & 0x1FFF
|
|
actionIndex = reader.readUShort()
|
|
if performAction:
|
|
self.Actions = self._decompileLigActions(actionReader, actionIndex)
|
|
else:
|
|
self.Actions = []
|
|
|
|
@staticmethod
|
|
def compileActions(font, states):
|
|
result, actions, actionIndex = b"", set(), {}
|
|
for state in states:
|
|
for _glyphClass, trans in state.Transitions.items():
|
|
actions.add(trans.compileLigActions())
|
|
# Sort the compiled actions in decreasing order of
|
|
# length, so that the longer sequence come before the
|
|
# shorter ones. For each compiled action ABCD, its
|
|
# suffixes BCD, CD, and D do not be encoded separately
|
|
# (in case they occur); instead, we can just store an
|
|
# index that points into the middle of the longer
|
|
# sequence. Every compiled AAT ligature sequence is
|
|
# terminated with an end-of-sequence flag, which can
|
|
# only be set on the last element of the sequence.
|
|
# Therefore, it is sufficient to consider just the
|
|
# suffixes.
|
|
for a in sorted(actions, key=lambda x: (-len(x), x)):
|
|
if a not in actionIndex:
|
|
for i in range(0, len(a), 4):
|
|
suffix = a[i:]
|
|
suffixIndex = (len(result) + i) // 4
|
|
actionIndex.setdefault(suffix, suffixIndex)
|
|
result += a
|
|
result = pad(result, 4)
|
|
return (result, actionIndex)
|
|
|
|
def compileLigActions(self):
|
|
result = []
|
|
for i, action in enumerate(self.Actions):
|
|
last = i == len(self.Actions) - 1
|
|
value = action.GlyphIndexDelta & 0x3FFFFFFF
|
|
value |= 0x80000000 if last else 0
|
|
value |= 0x40000000 if action.Store else 0
|
|
result.append(struct.pack(">L", value))
|
|
return bytesjoin(result)
|
|
|
|
def _decompileLigActions(self, actionReader, actionIndex):
|
|
actions = []
|
|
last = False
|
|
reader = actionReader.getSubReader(actionReader.pos + actionIndex * 4)
|
|
while not last:
|
|
value = reader.readULong()
|
|
last = bool(value & 0x80000000)
|
|
action = LigAction()
|
|
actions.append(action)
|
|
action.Store = bool(value & 0x40000000)
|
|
delta = value & 0x3FFFFFFF
|
|
if delta >= 0x20000000: # sign-extend 30-bit value
|
|
delta = -0x40000000 + delta
|
|
action.GlyphIndexDelta = delta
|
|
return actions
|
|
|
|
def fromXML(self, name, attrs, content, font):
|
|
self.NewState = self.ReservedFlags = 0
|
|
self.SetComponent = self.DontAdvance = False
|
|
self.ReservedFlags = 0
|
|
self.Actions = []
|
|
content = [t for t in content if isinstance(t, tuple)]
|
|
for eltName, eltAttrs, eltContent in content:
|
|
if eltName == "NewState":
|
|
self.NewState = safeEval(eltAttrs["value"])
|
|
elif eltName == "Flags":
|
|
for flag in eltAttrs["value"].split(","):
|
|
self._setFlag(flag.strip())
|
|
elif eltName == "ReservedFlags":
|
|
self.ReservedFlags = safeEval(eltAttrs["value"])
|
|
elif eltName == "Action":
|
|
action = LigAction()
|
|
flags = eltAttrs.get("Flags", "").split(",")
|
|
flags = [f.strip() for f in flags]
|
|
action.Store = "Store" in flags
|
|
action.GlyphIndexDelta = safeEval(eltAttrs["GlyphIndexDelta"])
|
|
self.Actions.append(action)
|
|
|
|
def toXML(self, xmlWriter, font, attrs, name):
|
|
xmlWriter.begintag(name, **attrs)
|
|
xmlWriter.newline()
|
|
xmlWriter.simpletag("NewState", value=self.NewState)
|
|
xmlWriter.newline()
|
|
self._writeFlagsToXML(xmlWriter)
|
|
for action in self.Actions:
|
|
attribs = [("GlyphIndexDelta", action.GlyphIndexDelta)]
|
|
if action.Store:
|
|
attribs.append(("Flags", "Store"))
|
|
xmlWriter.simpletag("Action", attribs)
|
|
xmlWriter.newline()
|
|
xmlWriter.endtag(name)
|
|
xmlWriter.newline()
|
|
|
|
|
|
class InsertionMorphAction(AATAction):
|
|
staticSize = 8
|
|
actionHeaderSize = 4 # 4 bytes for actionOffset
|
|
_FLAGS = [
|
|
"SetMark",
|
|
"DontAdvance",
|
|
"CurrentIsKashidaLike",
|
|
"MarkedIsKashidaLike",
|
|
"CurrentInsertBefore",
|
|
"MarkedInsertBefore",
|
|
]
|
|
|
|
def __init__(self):
|
|
self.NewState = 0
|
|
for flag in self._FLAGS:
|
|
setattr(self, flag, False)
|
|
self.ReservedFlags = 0
|
|
self.CurrentInsertionAction, self.MarkedInsertionAction = [], []
|
|
|
|
def compile(self, writer, font, actionIndex):
|
|
assert actionIndex is not None
|
|
writer.writeUShort(self.NewState)
|
|
flags = self.ReservedFlags
|
|
if self.SetMark:
|
|
flags |= 0x8000
|
|
if self.DontAdvance:
|
|
flags |= 0x4000
|
|
if self.CurrentIsKashidaLike:
|
|
flags |= 0x2000
|
|
if self.MarkedIsKashidaLike:
|
|
flags |= 0x1000
|
|
if self.CurrentInsertBefore:
|
|
flags |= 0x0800
|
|
if self.MarkedInsertBefore:
|
|
flags |= 0x0400
|
|
flags |= len(self.CurrentInsertionAction) << 5
|
|
flags |= len(self.MarkedInsertionAction)
|
|
writer.writeUShort(flags)
|
|
if len(self.CurrentInsertionAction) > 0:
|
|
currentIndex = actionIndex[tuple(self.CurrentInsertionAction)]
|
|
else:
|
|
currentIndex = 0xFFFF
|
|
writer.writeUShort(currentIndex)
|
|
if len(self.MarkedInsertionAction) > 0:
|
|
markedIndex = actionIndex[tuple(self.MarkedInsertionAction)]
|
|
else:
|
|
markedIndex = 0xFFFF
|
|
writer.writeUShort(markedIndex)
|
|
|
|
def decompile(self, reader, font, actionReader):
|
|
assert actionReader is not None
|
|
self.NewState = reader.readUShort()
|
|
flags = reader.readUShort()
|
|
self.SetMark = bool(flags & 0x8000)
|
|
self.DontAdvance = bool(flags & 0x4000)
|
|
self.CurrentIsKashidaLike = bool(flags & 0x2000)
|
|
self.MarkedIsKashidaLike = bool(flags & 0x1000)
|
|
self.CurrentInsertBefore = bool(flags & 0x0800)
|
|
self.MarkedInsertBefore = bool(flags & 0x0400)
|
|
self.CurrentInsertionAction = self._decompileInsertionAction(
|
|
actionReader, font, index=reader.readUShort(), count=((flags & 0x03E0) >> 5)
|
|
)
|
|
self.MarkedInsertionAction = self._decompileInsertionAction(
|
|
actionReader, font, index=reader.readUShort(), count=(flags & 0x001F)
|
|
)
|
|
|
|
def _decompileInsertionAction(self, actionReader, font, index, count):
|
|
if index == 0xFFFF or count == 0:
|
|
return []
|
|
reader = actionReader.getSubReader(actionReader.pos + index * 2)
|
|
return font.getGlyphNameMany(reader.readUShortArray(count))
|
|
|
|
def toXML(self, xmlWriter, font, attrs, name):
|
|
xmlWriter.begintag(name, **attrs)
|
|
xmlWriter.newline()
|
|
xmlWriter.simpletag("NewState", value=self.NewState)
|
|
xmlWriter.newline()
|
|
self._writeFlagsToXML(xmlWriter)
|
|
for g in self.CurrentInsertionAction:
|
|
xmlWriter.simpletag("CurrentInsertionAction", glyph=g)
|
|
xmlWriter.newline()
|
|
for g in self.MarkedInsertionAction:
|
|
xmlWriter.simpletag("MarkedInsertionAction", glyph=g)
|
|
xmlWriter.newline()
|
|
xmlWriter.endtag(name)
|
|
xmlWriter.newline()
|
|
|
|
def fromXML(self, name, attrs, content, font):
|
|
self.__init__()
|
|
content = [t for t in content if isinstance(t, tuple)]
|
|
for eltName, eltAttrs, eltContent in content:
|
|
if eltName == "NewState":
|
|
self.NewState = safeEval(eltAttrs["value"])
|
|
elif eltName == "Flags":
|
|
for flag in eltAttrs["value"].split(","):
|
|
self._setFlag(flag.strip())
|
|
elif eltName == "CurrentInsertionAction":
|
|
self.CurrentInsertionAction.append(eltAttrs["glyph"])
|
|
elif eltName == "MarkedInsertionAction":
|
|
self.MarkedInsertionAction.append(eltAttrs["glyph"])
|
|
else:
|
|
assert False, eltName
|
|
|
|
@staticmethod
|
|
def compileActions(font, states):
|
|
actions, actionIndex, result = set(), {}, b""
|
|
for state in states:
|
|
for _glyphClass, trans in state.Transitions.items():
|
|
if trans.CurrentInsertionAction is not None:
|
|
actions.add(tuple(trans.CurrentInsertionAction))
|
|
if trans.MarkedInsertionAction is not None:
|
|
actions.add(tuple(trans.MarkedInsertionAction))
|
|
# Sort the compiled actions in decreasing order of
|
|
# length, so that the longer sequence come before the
|
|
# shorter ones.
|
|
for action in sorted(actions, key=lambda x: (-len(x), x)):
|
|
# We insert all sub-sequences of the action glyph sequence
|
|
# into actionIndex. For example, if one action triggers on
|
|
# glyph sequence [A, B, C, D, E] and another action triggers
|
|
# on [C, D], we return result=[A, B, C, D, E] (as list of
|
|
# encoded glyph IDs), and actionIndex={('A','B','C','D','E'): 0,
|
|
# ('C','D'): 2}.
|
|
if action in actionIndex:
|
|
continue
|
|
for start in range(0, len(action)):
|
|
startIndex = (len(result) // 2) + start
|
|
for limit in range(start, len(action)):
|
|
glyphs = action[start : limit + 1]
|
|
actionIndex.setdefault(glyphs, startIndex)
|
|
for glyph in action:
|
|
glyphID = font.getGlyphID(glyph)
|
|
result += struct.pack(">H", glyphID)
|
|
return result, actionIndex
|
|
|
|
|
|
class FeatureParams(BaseTable):
|
|
def compile(self, writer, font):
|
|
assert (
|
|
featureParamTypes.get(writer["FeatureTag"]) == self.__class__
|
|
), "Wrong FeatureParams type for feature '%s': %s" % (
|
|
writer["FeatureTag"],
|
|
self.__class__.__name__,
|
|
)
|
|
BaseTable.compile(self, writer, font)
|
|
|
|
def toXML(self, xmlWriter, font, attrs=None, name=None):
|
|
BaseTable.toXML(self, xmlWriter, font, attrs, name=self.__class__.__name__)
|
|
|
|
|
|
class FeatureParamsSize(FeatureParams):
|
|
pass
|
|
|
|
|
|
class FeatureParamsStylisticSet(FeatureParams):
|
|
pass
|
|
|
|
|
|
class FeatureParamsCharacterVariants(FeatureParams):
|
|
pass
|
|
|
|
|
|
class Coverage(FormatSwitchingBaseTable):
|
|
# manual implementation to get rid of glyphID dependencies
|
|
|
|
def populateDefaults(self, propagator=None):
|
|
if not hasattr(self, "glyphs"):
|
|
self.glyphs = []
|
|
|
|
def postRead(self, rawTable, font):
|
|
if self.Format == 1:
|
|
self.glyphs = rawTable["GlyphArray"]
|
|
elif self.Format == 2:
|
|
glyphs = self.glyphs = []
|
|
ranges = rawTable["RangeRecord"]
|
|
# Some SIL fonts have coverage entries that don't have sorted
|
|
# StartCoverageIndex. If it is so, fixup and warn. We undo
|
|
# this when writing font out.
|
|
sorted_ranges = sorted(ranges, key=lambda a: a.StartCoverageIndex)
|
|
if ranges != sorted_ranges:
|
|
log.warning("GSUB/GPOS Coverage is not sorted by glyph ids.")
|
|
ranges = sorted_ranges
|
|
del sorted_ranges
|
|
for r in ranges:
|
|
start = r.Start
|
|
end = r.End
|
|
startID = font.getGlyphID(start)
|
|
endID = font.getGlyphID(end) + 1
|
|
glyphs.extend(font.getGlyphNameMany(range(startID, endID)))
|
|
else:
|
|
self.glyphs = []
|
|
log.warning("Unknown Coverage format: %s", self.Format)
|
|
del self.Format # Don't need this anymore
|
|
|
|
def preWrite(self, font):
|
|
glyphs = getattr(self, "glyphs", None)
|
|
if glyphs is None:
|
|
glyphs = self.glyphs = []
|
|
format = 1
|
|
rawTable = {"GlyphArray": glyphs}
|
|
if glyphs:
|
|
# find out whether Format 2 is more compact or not
|
|
glyphIDs = font.getGlyphIDMany(glyphs)
|
|
brokenOrder = sorted(glyphIDs) != glyphIDs
|
|
|
|
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)
|
|
|
|
if brokenOrder or len(ranges) * 3 < len(glyphs): # 3 words vs. 1 word
|
|
# Format 2 is more compact
|
|
index = 0
|
|
for i in range(len(ranges)):
|
|
start, end = ranges[i]
|
|
r = RangeRecord()
|
|
r.StartID = start
|
|
r.Start = font.getGlyphName(start)
|
|
r.End = font.getGlyphName(end)
|
|
r.StartCoverageIndex = index
|
|
ranges[i] = r
|
|
index = index + end - start + 1
|
|
if brokenOrder:
|
|
log.warning("GSUB/GPOS Coverage is not sorted by glyph ids.")
|
|
ranges.sort(key=lambda a: a.StartID)
|
|
for r in ranges:
|
|
del r.StartID
|
|
format = 2
|
|
rawTable = {"RangeRecord": ranges}
|
|
# else:
|
|
# fallthrough; Format 1 is more compact
|
|
self.Format = format
|
|
return rawTable
|
|
|
|
def toXML2(self, xmlWriter, font):
|
|
for glyphName in getattr(self, "glyphs", []):
|
|
xmlWriter.simpletag("Glyph", value=glyphName)
|
|
xmlWriter.newline()
|
|
|
|
def fromXML(self, name, attrs, content, font):
|
|
glyphs = getattr(self, "glyphs", None)
|
|
if glyphs is None:
|
|
glyphs = []
|
|
self.glyphs = glyphs
|
|
glyphs.append(attrs["value"])
|
|
|
|
|
|
# The special 0xFFFFFFFF delta-set index is used to indicate that there
|
|
# is no variation data in the ItemVariationStore for a given variable field
|
|
NO_VARIATION_INDEX = 0xFFFFFFFF
|
|
|
|
|
|
class DeltaSetIndexMap(getFormatSwitchingBaseTableClass("uint8")):
|
|
def populateDefaults(self, propagator=None):
|
|
if not hasattr(self, "mapping"):
|
|
self.mapping = []
|
|
|
|
def postRead(self, rawTable, font):
|
|
assert (rawTable["EntryFormat"] & 0xFFC0) == 0
|
|
self.mapping = rawTable["mapping"]
|
|
|
|
@staticmethod
|
|
def getEntryFormat(mapping):
|
|
ored = 0
|
|
for idx in mapping:
|
|
ored |= idx
|
|
|
|
inner = ored & 0xFFFF
|
|
innerBits = 0
|
|
while inner:
|
|
innerBits += 1
|
|
inner >>= 1
|
|
innerBits = max(innerBits, 1)
|
|
assert innerBits <= 16
|
|
|
|
ored = (ored >> (16 - innerBits)) | (ored & ((1 << innerBits) - 1))
|
|
if ored <= 0x000000FF:
|
|
entrySize = 1
|
|
elif ored <= 0x0000FFFF:
|
|
entrySize = 2
|
|
elif ored <= 0x00FFFFFF:
|
|
entrySize = 3
|
|
else:
|
|
entrySize = 4
|
|
|
|
return ((entrySize - 1) << 4) | (innerBits - 1)
|
|
|
|
def preWrite(self, font):
|
|
mapping = getattr(self, "mapping", None)
|
|
if mapping is None:
|
|
mapping = self.mapping = []
|
|
self.Format = 1 if len(mapping) > 0xFFFF else 0
|
|
rawTable = self.__dict__.copy()
|
|
rawTable["MappingCount"] = len(mapping)
|
|
rawTable["EntryFormat"] = self.getEntryFormat(mapping)
|
|
return rawTable
|
|
|
|
def toXML2(self, xmlWriter, font):
|
|
# Make xml dump less verbose, by omitting no-op entries like:
|
|
# <Map index="..." outer="65535" inner="65535"/>
|
|
xmlWriter.comment("Omitted values default to 0xFFFF/0xFFFF (no variations)")
|
|
xmlWriter.newline()
|
|
for i, value in enumerate(getattr(self, "mapping", [])):
|
|
attrs = [("index", i)]
|
|
if value != NO_VARIATION_INDEX:
|
|
attrs.extend(
|
|
[
|
|
("outer", value >> 16),
|
|
("inner", value & 0xFFFF),
|
|
]
|
|
)
|
|
xmlWriter.simpletag("Map", attrs)
|
|
xmlWriter.newline()
|
|
|
|
def fromXML(self, name, attrs, content, font):
|
|
mapping = getattr(self, "mapping", None)
|
|
if mapping is None:
|
|
self.mapping = mapping = []
|
|
index = safeEval(attrs["index"])
|
|
outer = safeEval(attrs.get("outer", "0xFFFF"))
|
|
inner = safeEval(attrs.get("inner", "0xFFFF"))
|
|
assert inner <= 0xFFFF
|
|
mapping.insert(index, (outer << 16) | inner)
|
|
|
|
|
|
class VarIdxMap(BaseTable):
|
|
def populateDefaults(self, propagator=None):
|
|
if not hasattr(self, "mapping"):
|
|
self.mapping = {}
|
|
|
|
def postRead(self, rawTable, font):
|
|
assert (rawTable["EntryFormat"] & 0xFFC0) == 0
|
|
glyphOrder = font.getGlyphOrder()
|
|
mapList = rawTable["mapping"]
|
|
mapList.extend([mapList[-1]] * (len(glyphOrder) - len(mapList)))
|
|
self.mapping = dict(zip(glyphOrder, mapList))
|
|
|
|
def preWrite(self, font):
|
|
mapping = getattr(self, "mapping", None)
|
|
if mapping is None:
|
|
mapping = self.mapping = {}
|
|
|
|
glyphOrder = font.getGlyphOrder()
|
|
mapping = [mapping[g] for g in glyphOrder]
|
|
while len(mapping) > 1 and mapping[-2] == mapping[-1]:
|
|
del mapping[-1]
|
|
|
|
rawTable = {"mapping": mapping}
|
|
rawTable["MappingCount"] = len(mapping)
|
|
rawTable["EntryFormat"] = DeltaSetIndexMap.getEntryFormat(mapping)
|
|
return rawTable
|
|
|
|
def toXML2(self, xmlWriter, font):
|
|
for glyph, value in sorted(getattr(self, "mapping", {}).items()):
|
|
attrs = (
|
|
("glyph", glyph),
|
|
("outer", value >> 16),
|
|
("inner", value & 0xFFFF),
|
|
)
|
|
xmlWriter.simpletag("Map", attrs)
|
|
xmlWriter.newline()
|
|
|
|
def fromXML(self, name, attrs, content, font):
|
|
mapping = getattr(self, "mapping", None)
|
|
if mapping is None:
|
|
mapping = {}
|
|
self.mapping = mapping
|
|
try:
|
|
glyph = attrs["glyph"]
|
|
except: # https://github.com/fonttools/fonttools/commit/21cbab8ce9ded3356fef3745122da64dcaf314e9#commitcomment-27649836
|
|
glyph = font.getGlyphOrder()[attrs["index"]]
|
|
outer = safeEval(attrs["outer"])
|
|
inner = safeEval(attrs["inner"])
|
|
assert inner <= 0xFFFF
|
|
mapping[glyph] = (outer << 16) | inner
|
|
|
|
|
|
class VarRegionList(BaseTable):
|
|
def preWrite(self, font):
|
|
# The OT spec says VarStore.VarRegionList.RegionAxisCount should always
|
|
# be equal to the fvar.axisCount, and OTS < v8.0.0 enforces this rule
|
|
# even when the VarRegionList is empty. We can't treat RegionAxisCount
|
|
# like a normal propagated count (== len(Region[i].VarRegionAxis)),
|
|
# otherwise it would default to 0 if VarRegionList is empty.
|
|
# Thus, we force it to always be equal to fvar.axisCount.
|
|
# https://github.com/khaledhosny/ots/pull/192
|
|
fvarTable = font.get("fvar")
|
|
if fvarTable:
|
|
self.RegionAxisCount = len(fvarTable.axes)
|
|
return {
|
|
**self.__dict__,
|
|
"RegionAxisCount": CountReference(self.__dict__, "RegionAxisCount"),
|
|
}
|
|
|
|
|
|
class SingleSubst(FormatSwitchingBaseTable):
|
|
def populateDefaults(self, propagator=None):
|
|
if not hasattr(self, "mapping"):
|
|
self.mapping = {}
|
|
|
|
def postRead(self, rawTable, font):
|
|
mapping = {}
|
|
input = _getGlyphsFromCoverageTable(rawTable["Coverage"])
|
|
if self.Format == 1:
|
|
delta = rawTable["DeltaGlyphID"]
|
|
inputGIDS = font.getGlyphIDMany(input)
|
|
outGIDS = [(glyphID + delta) % 65536 for glyphID in inputGIDS]
|
|
outNames = font.getGlyphNameMany(outGIDS)
|
|
for inp, out in zip(input, outNames):
|
|
mapping[inp] = out
|
|
elif self.Format == 2:
|
|
assert (
|
|
len(input) == rawTable["GlyphCount"]
|
|
), "invalid SingleSubstFormat2 table"
|
|
subst = rawTable["Substitute"]
|
|
for inp, sub in zip(input, subst):
|
|
mapping[inp] = sub
|
|
else:
|
|
assert 0, "unknown format: %s" % self.Format
|
|
self.mapping = mapping
|
|
del self.Format # Don't need this anymore
|
|
|
|
def preWrite(self, font):
|
|
mapping = getattr(self, "mapping", None)
|
|
if mapping is None:
|
|
mapping = self.mapping = {}
|
|
items = list(mapping.items())
|
|
getGlyphID = font.getGlyphID
|
|
gidItems = [(getGlyphID(a), getGlyphID(b)) for a, b in items]
|
|
sortableItems = sorted(zip(gidItems, items))
|
|
|
|
# figure out format
|
|
format = 2
|
|
delta = None
|
|
for inID, outID in gidItems:
|
|
if delta is None:
|
|
delta = (outID - inID) % 65536
|
|
|
|
if (inID + delta) % 65536 != outID:
|
|
break
|
|
else:
|
|
if delta is None:
|
|
# the mapping is empty, better use format 2
|
|
format = 2
|
|
else:
|
|
format = 1
|
|
|
|
rawTable = {}
|
|
self.Format = format
|
|
cov = Coverage()
|
|
input = [item[1][0] for item in sortableItems]
|
|
subst = [item[1][1] for item in sortableItems]
|
|
cov.glyphs = input
|
|
rawTable["Coverage"] = cov
|
|
if format == 1:
|
|
assert delta is not None
|
|
rawTable["DeltaGlyphID"] = delta
|
|
else:
|
|
rawTable["Substitute"] = subst
|
|
return rawTable
|
|
|
|
def toXML2(self, xmlWriter, font):
|
|
items = sorted(self.mapping.items())
|
|
for inGlyph, outGlyph in items:
|
|
xmlWriter.simpletag("Substitution", [("in", inGlyph), ("out", outGlyph)])
|
|
xmlWriter.newline()
|
|
|
|
def fromXML(self, name, attrs, content, font):
|
|
mapping = getattr(self, "mapping", None)
|
|
if mapping is None:
|
|
mapping = {}
|
|
self.mapping = mapping
|
|
mapping[attrs["in"]] = attrs["out"]
|
|
|
|
|
|
class MultipleSubst(FormatSwitchingBaseTable):
|
|
def populateDefaults(self, propagator=None):
|
|
if not hasattr(self, "mapping"):
|
|
self.mapping = {}
|
|
|
|
def postRead(self, rawTable, font):
|
|
mapping = {}
|
|
if self.Format == 1:
|
|
glyphs = _getGlyphsFromCoverageTable(rawTable["Coverage"])
|
|
subst = [s.Substitute for s in rawTable["Sequence"]]
|
|
mapping = dict(zip(glyphs, subst))
|
|
else:
|
|
assert 0, "unknown format: %s" % self.Format
|
|
self.mapping = mapping
|
|
del self.Format # Don't need this anymore
|
|
|
|
def preWrite(self, font):
|
|
mapping = getattr(self, "mapping", None)
|
|
if mapping is None:
|
|
mapping = self.mapping = {}
|
|
cov = Coverage()
|
|
cov.glyphs = sorted(list(mapping.keys()), key=font.getGlyphID)
|
|
self.Format = 1
|
|
rawTable = {
|
|
"Coverage": cov,
|
|
"Sequence": [self.makeSequence_(mapping[glyph]) for glyph in cov.glyphs],
|
|
}
|
|
return rawTable
|
|
|
|
def toXML2(self, xmlWriter, font):
|
|
items = sorted(self.mapping.items())
|
|
for inGlyph, outGlyphs in items:
|
|
out = ",".join(outGlyphs)
|
|
xmlWriter.simpletag("Substitution", [("in", inGlyph), ("out", out)])
|
|
xmlWriter.newline()
|
|
|
|
def fromXML(self, name, attrs, content, font):
|
|
mapping = getattr(self, "mapping", None)
|
|
if mapping is None:
|
|
mapping = {}
|
|
self.mapping = mapping
|
|
|
|
# TTX v3.0 and earlier.
|
|
if name == "Coverage":
|
|
self.old_coverage_ = []
|
|
for element in content:
|
|
if not isinstance(element, tuple):
|
|
continue
|
|
element_name, element_attrs, _ = element
|
|
if element_name == "Glyph":
|
|
self.old_coverage_.append(element_attrs["value"])
|
|
return
|
|
if name == "Sequence":
|
|
index = int(attrs.get("index", len(mapping)))
|
|
glyph = self.old_coverage_[index]
|
|
glyph_mapping = mapping[glyph] = []
|
|
for element in content:
|
|
if not isinstance(element, tuple):
|
|
continue
|
|
element_name, element_attrs, _ = element
|
|
if element_name == "Substitute":
|
|
glyph_mapping.append(element_attrs["value"])
|
|
return
|
|
|
|
# TTX v3.1 and later.
|
|
outGlyphs = attrs["out"].split(",") if attrs["out"] else []
|
|
mapping[attrs["in"]] = [g.strip() for g in outGlyphs]
|
|
|
|
@staticmethod
|
|
def makeSequence_(g):
|
|
seq = Sequence()
|
|
seq.Substitute = g
|
|
return seq
|
|
|
|
|
|
class ClassDef(FormatSwitchingBaseTable):
|
|
def populateDefaults(self, propagator=None):
|
|
if not hasattr(self, "classDefs"):
|
|
self.classDefs = {}
|
|
|
|
def postRead(self, rawTable, font):
|
|
classDefs = {}
|
|
|
|
if self.Format == 1:
|
|
start = rawTable["StartGlyph"]
|
|
classList = rawTable["ClassValueArray"]
|
|
startID = font.getGlyphID(start)
|
|
endID = startID + len(classList)
|
|
glyphNames = font.getGlyphNameMany(range(startID, endID))
|
|
for glyphName, cls in zip(glyphNames, classList):
|
|
if cls:
|
|
classDefs[glyphName] = cls
|
|
|
|
elif self.Format == 2:
|
|
records = rawTable["ClassRangeRecord"]
|
|
for rec in records:
|
|
cls = rec.Class
|
|
if not cls:
|
|
continue
|
|
start = rec.Start
|
|
end = rec.End
|
|
startID = font.getGlyphID(start)
|
|
endID = font.getGlyphID(end) + 1
|
|
glyphNames = font.getGlyphNameMany(range(startID, endID))
|
|
for glyphName in glyphNames:
|
|
classDefs[glyphName] = cls
|
|
else:
|
|
log.warning("Unknown ClassDef format: %s", self.Format)
|
|
self.classDefs = classDefs
|
|
del self.Format # Don't need this anymore
|
|
|
|
def _getClassRanges(self, font):
|
|
classDefs = getattr(self, "classDefs", None)
|
|
if classDefs is None:
|
|
self.classDefs = {}
|
|
return
|
|
getGlyphID = font.getGlyphID
|
|
items = []
|
|
for glyphName, cls in classDefs.items():
|
|
if not cls:
|
|
continue
|
|
items.append((getGlyphID(glyphName), glyphName, cls))
|
|
if items:
|
|
items.sort()
|
|
last, lastName, lastCls = items[0]
|
|
ranges = [[lastCls, last, lastName]]
|
|
for glyphID, glyphName, cls in items[1:]:
|
|
if glyphID != last + 1 or cls != lastCls:
|
|
ranges[-1].extend([last, lastName])
|
|
ranges.append([cls, glyphID, glyphName])
|
|
last = glyphID
|
|
lastName = glyphName
|
|
lastCls = cls
|
|
ranges[-1].extend([last, lastName])
|
|
return ranges
|
|
|
|
def preWrite(self, font):
|
|
format = 2
|
|
rawTable = {"ClassRangeRecord": []}
|
|
ranges = self._getClassRanges(font)
|
|
if ranges:
|
|
startGlyph = ranges[0][1]
|
|
endGlyph = ranges[-1][3]
|
|
glyphCount = endGlyph - startGlyph + 1
|
|
if len(ranges) * 3 < glyphCount + 1:
|
|
# Format 2 is more compact
|
|
for i in range(len(ranges)):
|
|
cls, start, startName, end, endName = ranges[i]
|
|
rec = ClassRangeRecord()
|
|
rec.Start = startName
|
|
rec.End = endName
|
|
rec.Class = cls
|
|
ranges[i] = rec
|
|
format = 2
|
|
rawTable = {"ClassRangeRecord": ranges}
|
|
else:
|
|
# Format 1 is more compact
|
|
startGlyphName = ranges[0][2]
|
|
classes = [0] * glyphCount
|
|
for cls, start, startName, end, endName in ranges:
|
|
for g in range(start - startGlyph, end - startGlyph + 1):
|
|
classes[g] = cls
|
|
format = 1
|
|
rawTable = {"StartGlyph": startGlyphName, "ClassValueArray": classes}
|
|
self.Format = format
|
|
return rawTable
|
|
|
|
def toXML2(self, xmlWriter, font):
|
|
items = sorted(self.classDefs.items())
|
|
for glyphName, cls in items:
|
|
xmlWriter.simpletag("ClassDef", [("glyph", glyphName), ("class", cls)])
|
|
xmlWriter.newline()
|
|
|
|
def fromXML(self, name, attrs, content, font):
|
|
classDefs = getattr(self, "classDefs", None)
|
|
if classDefs is None:
|
|
classDefs = {}
|
|
self.classDefs = classDefs
|
|
classDefs[attrs["glyph"]] = int(attrs["class"])
|
|
|
|
|
|
class AlternateSubst(FormatSwitchingBaseTable):
|
|
def populateDefaults(self, propagator=None):
|
|
if not hasattr(self, "alternates"):
|
|
self.alternates = {}
|
|
|
|
def postRead(self, rawTable, font):
|
|
alternates = {}
|
|
if self.Format == 1:
|
|
input = _getGlyphsFromCoverageTable(rawTable["Coverage"])
|
|
alts = rawTable["AlternateSet"]
|
|
assert len(input) == len(alts)
|
|
for inp, alt in zip(input, alts):
|
|
alternates[inp] = alt.Alternate
|
|
else:
|
|
assert 0, "unknown format: %s" % self.Format
|
|
self.alternates = alternates
|
|
del self.Format # Don't need this anymore
|
|
|
|
def preWrite(self, font):
|
|
self.Format = 1
|
|
alternates = getattr(self, "alternates", None)
|
|
if alternates is None:
|
|
alternates = self.alternates = {}
|
|
items = list(alternates.items())
|
|
for i in range(len(items)):
|
|
glyphName, set = items[i]
|
|
items[i] = font.getGlyphID(glyphName), glyphName, set
|
|
items.sort()
|
|
cov = Coverage()
|
|
cov.glyphs = [item[1] for item in items]
|
|
alternates = []
|
|
setList = [item[-1] for item in items]
|
|
for set in setList:
|
|
alts = AlternateSet()
|
|
alts.Alternate = set
|
|
alternates.append(alts)
|
|
# a special case to deal with the fact that several hundred Adobe Japan1-5
|
|
# CJK fonts will overflow an offset if the coverage table isn't pushed to the end.
|
|
# Also useful in that when splitting a sub-table because of an offset overflow
|
|
# I don't need to calculate the change in the subtable offset due to the change in the coverage table size.
|
|
# Allows packing more rules in subtable.
|
|
self.sortCoverageLast = 1
|
|
return {"Coverage": cov, "AlternateSet": alternates}
|
|
|
|
def toXML2(self, xmlWriter, font):
|
|
items = sorted(self.alternates.items())
|
|
for glyphName, alternates in items:
|
|
xmlWriter.begintag("AlternateSet", glyph=glyphName)
|
|
xmlWriter.newline()
|
|
for alt in alternates:
|
|
xmlWriter.simpletag("Alternate", glyph=alt)
|
|
xmlWriter.newline()
|
|
xmlWriter.endtag("AlternateSet")
|
|
xmlWriter.newline()
|
|
|
|
def fromXML(self, name, attrs, content, font):
|
|
alternates = getattr(self, "alternates", None)
|
|
if alternates is None:
|
|
alternates = {}
|
|
self.alternates = alternates
|
|
glyphName = attrs["glyph"]
|
|
set = []
|
|
alternates[glyphName] = set
|
|
for element in content:
|
|
if not isinstance(element, tuple):
|
|
continue
|
|
name, attrs, content = element
|
|
set.append(attrs["glyph"])
|
|
|
|
|
|
class LigatureSubst(FormatSwitchingBaseTable):
|
|
def populateDefaults(self, propagator=None):
|
|
if not hasattr(self, "ligatures"):
|
|
self.ligatures = {}
|
|
|
|
def postRead(self, rawTable, font):
|
|
ligatures = {}
|
|
if self.Format == 1:
|
|
input = _getGlyphsFromCoverageTable(rawTable["Coverage"])
|
|
ligSets = rawTable["LigatureSet"]
|
|
assert len(input) == len(ligSets)
|
|
for i in range(len(input)):
|
|
ligatures[input[i]] = ligSets[i].Ligature
|
|
else:
|
|
assert 0, "unknown format: %s" % self.Format
|
|
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)
|
|
if ligatures is None:
|
|
ligatures = self.ligatures = {}
|
|
|
|
if ligatures and isinstance(next(iter(ligatures)), tuple):
|
|
# New high-level API in v3.1 and later. Note that we just support compiling this
|
|
# for now. We don't load to this API, and don't do XML with it.
|
|
|
|
# ligatures is map from components-sequence to lig-glyph
|
|
newLigatures = dict()
|
|
for comps in sorted(ligatures.keys(), key=self._getLigatureSortKey):
|
|
ligature = Ligature()
|
|
ligature.Component = comps[1:]
|
|
ligature.CompCount = len(comps)
|
|
ligature.LigGlyph = ligatures[comps]
|
|
newLigatures.setdefault(comps[0], []).append(ligature)
|
|
ligatures = newLigatures
|
|
|
|
items = list(ligatures.items())
|
|
for i in range(len(items)):
|
|
glyphName, set = items[i]
|
|
items[i] = font.getGlyphID(glyphName), glyphName, set
|
|
items.sort()
|
|
cov = Coverage()
|
|
cov.glyphs = [item[1] for item in items]
|
|
|
|
ligSets = []
|
|
setList = [item[-1] for item in items]
|
|
for set in setList:
|
|
ligSet = LigatureSet()
|
|
ligs = ligSet.Ligature = []
|
|
for lig in set:
|
|
ligs.append(lig)
|
|
ligSets.append(ligSet)
|
|
# Useful in that when splitting a sub-table because of an offset overflow
|
|
# I don't need to calculate the change in subtabl offset due to the coverage table size.
|
|
# Allows packing more rules in subtable.
|
|
self.sortCoverageLast = 1
|
|
return {"Coverage": cov, "LigatureSet": ligSets}
|
|
|
|
def toXML2(self, xmlWriter, font):
|
|
items = sorted(self.ligatures.items())
|
|
for glyphName, ligSets in items:
|
|
xmlWriter.begintag("LigatureSet", glyph=glyphName)
|
|
xmlWriter.newline()
|
|
for lig in ligSets:
|
|
xmlWriter.simpletag(
|
|
"Ligature", glyph=lig.LigGlyph, components=",".join(lig.Component)
|
|
)
|
|
xmlWriter.newline()
|
|
xmlWriter.endtag("LigatureSet")
|
|
xmlWriter.newline()
|
|
|
|
def fromXML(self, name, attrs, content, font):
|
|
ligatures = getattr(self, "ligatures", None)
|
|
if ligatures is None:
|
|
ligatures = {}
|
|
self.ligatures = ligatures
|
|
glyphName = attrs["glyph"]
|
|
ligs = []
|
|
ligatures[glyphName] = ligs
|
|
for element in content:
|
|
if not isinstance(element, tuple):
|
|
continue
|
|
name, attrs, content = element
|
|
lig = Ligature()
|
|
lig.LigGlyph = attrs["glyph"]
|
|
components = attrs["components"]
|
|
lig.Component = components.split(",") if components else []
|
|
lig.CompCount = len(lig.Component)
|
|
ligs.append(lig)
|
|
|
|
|
|
class COLR(BaseTable):
|
|
def decompile(self, reader, font):
|
|
# COLRv0 is exceptional in that LayerRecordCount appears *after* the
|
|
# LayerRecordArray it counts, but the parser logic expects Count fields
|
|
# to always precede the arrays. Here we work around this by parsing the
|
|
# LayerRecordCount before the rest of the table, and storing it in
|
|
# the reader's local state.
|
|
subReader = reader.getSubReader(offset=0)
|
|
for conv in self.getConverters():
|
|
if conv.name != "LayerRecordCount":
|
|
subReader.advance(conv.staticSize)
|
|
continue
|
|
reader[conv.name] = conv.read(subReader, font, tableDict={})
|
|
break
|
|
else:
|
|
raise AssertionError("LayerRecordCount converter not found")
|
|
return BaseTable.decompile(self, reader, font)
|
|
|
|
def preWrite(self, font):
|
|
# The writer similarly assumes Count values precede the things counted,
|
|
# thus here we pre-initialize a CountReference; the actual count value
|
|
# will be set to the lenght of the array by the time this is assembled.
|
|
self.LayerRecordCount = None
|
|
return {
|
|
**self.__dict__,
|
|
"LayerRecordCount": CountReference(self.__dict__, "LayerRecordCount"),
|
|
}
|
|
|
|
def computeClipBoxes(self, glyphSet: "_TTGlyphSet", quantization: int = 1):
|
|
if self.Version == 0:
|
|
return
|
|
|
|
clips = {}
|
|
for rec in self.BaseGlyphList.BaseGlyphPaintRecord:
|
|
try:
|
|
clipBox = rec.Paint.computeClipBox(self, glyphSet, quantization)
|
|
except Exception as e:
|
|
from fontTools.ttLib import TTLibError
|
|
|
|
raise TTLibError(
|
|
f"Failed to compute COLR ClipBox for {rec.BaseGlyph!r}"
|
|
) from e
|
|
|
|
if clipBox is not None:
|
|
clips[rec.BaseGlyph] = clipBox
|
|
|
|
hasClipList = hasattr(self, "ClipList") and self.ClipList is not None
|
|
if not clips:
|
|
if hasClipList:
|
|
self.ClipList = None
|
|
else:
|
|
if not hasClipList:
|
|
self.ClipList = ClipList()
|
|
self.ClipList.Format = 1
|
|
self.ClipList.clips = clips
|
|
|
|
|
|
class LookupList(BaseTable):
|
|
@property
|
|
def table(self):
|
|
for l in self.Lookup:
|
|
for st in l.SubTable:
|
|
if type(st).__name__.endswith("Subst"):
|
|
return "GSUB"
|
|
if type(st).__name__.endswith("Pos"):
|
|
return "GPOS"
|
|
raise ValueError
|
|
|
|
def toXML2(self, xmlWriter, font):
|
|
if (
|
|
not font
|
|
or "Debg" not in font
|
|
or LOOKUP_DEBUG_INFO_KEY not in font["Debg"].data
|
|
):
|
|
return super().toXML2(xmlWriter, font)
|
|
debugData = font["Debg"].data[LOOKUP_DEBUG_INFO_KEY][self.table]
|
|
for conv in self.getConverters():
|
|
if conv.repeat:
|
|
value = getattr(self, conv.name, [])
|
|
for lookupIndex, item in enumerate(value):
|
|
if str(lookupIndex) in debugData:
|
|
info = LookupDebugInfo(*debugData[str(lookupIndex)])
|
|
tag = info.location
|
|
if info.name:
|
|
tag = f"{info.name}: {tag}"
|
|
if info.feature:
|
|
script, language, feature = info.feature
|
|
tag = f"{tag} in {feature} ({script}/{language})"
|
|
xmlWriter.comment(tag)
|
|
xmlWriter.newline()
|
|
|
|
conv.xmlWrite(
|
|
xmlWriter, font, item, conv.name, [("index", lookupIndex)]
|
|
)
|
|
else:
|
|
if conv.aux and not eval(conv.aux, None, vars(self)):
|
|
continue
|
|
value = getattr(
|
|
self, conv.name, None
|
|
) # TODO Handle defaults instead of defaulting to None!
|
|
conv.xmlWrite(xmlWriter, font, value, conv.name, [])
|
|
|
|
|
|
class BaseGlyphRecordArray(BaseTable):
|
|
def preWrite(self, font):
|
|
self.BaseGlyphRecord = sorted(
|
|
self.BaseGlyphRecord, key=lambda rec: font.getGlyphID(rec.BaseGlyph)
|
|
)
|
|
return self.__dict__.copy()
|
|
|
|
|
|
class BaseGlyphList(BaseTable):
|
|
def preWrite(self, font):
|
|
self.BaseGlyphPaintRecord = sorted(
|
|
self.BaseGlyphPaintRecord, key=lambda rec: font.getGlyphID(rec.BaseGlyph)
|
|
)
|
|
return self.__dict__.copy()
|
|
|
|
|
|
class ClipBoxFormat(IntEnum):
|
|
Static = 1
|
|
Variable = 2
|
|
|
|
def is_variable(self):
|
|
return self is self.Variable
|
|
|
|
def as_variable(self):
|
|
return self.Variable
|
|
|
|
|
|
class ClipBox(getFormatSwitchingBaseTableClass("uint8")):
|
|
formatEnum = ClipBoxFormat
|
|
|
|
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(getFormatSwitchingBaseTableClass("uint8")):
|
|
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"]):
|
|
if rec.StartGlyphID > rec.EndGlyphID:
|
|
log.warning(
|
|
"invalid ClipRecord[%i].StartGlyphID (%i) > "
|
|
"EndGlyphID (%i); skipped",
|
|
i,
|
|
rec.StartGlyphID,
|
|
rec.EndGlyphID,
|
|
)
|
|
continue
|
|
redefinedGlyphs = []
|
|
missingGlyphs = []
|
|
for glyphID in range(rec.StartGlyphID, rec.EndGlyphID + 1):
|
|
try:
|
|
glyph = glyphOrder[glyphID]
|
|
except IndexError:
|
|
missingGlyphs.append(glyphID)
|
|
continue
|
|
if glyph not in clips:
|
|
clips[glyph] = copy.copy(rec.ClipBox)
|
|
else:
|
|
redefinedGlyphs.append(glyphID)
|
|
if redefinedGlyphs:
|
|
log.warning(
|
|
"ClipRecord[%i] overlaps previous records; "
|
|
"ignoring redefined clip boxes for the "
|
|
"following glyph ID range: [%i-%i]",
|
|
i,
|
|
min(redefinedGlyphs),
|
|
max(redefinedGlyphs),
|
|
)
|
|
if missingGlyphs:
|
|
log.warning(
|
|
"ClipRecord[%i] range references missing " "glyph IDs: [%i-%i]",
|
|
i,
|
|
min(missingGlyphs),
|
|
max(missingGlyphs),
|
|
)
|
|
self.clips = clips
|
|
|
|
def groups(self):
|
|
glyphsByClip = defaultdict(list)
|
|
uniqueClips = {}
|
|
for glyphName, clipBox in self.clips.items():
|
|
key = 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 = []
|
|
if hasattr(self, "Format"):
|
|
attrs.append(("Format", self.Format))
|
|
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
|
|
REFLECT = 2
|
|
|
|
|
|
# Porter-Duff modes for COLRv1 PaintComposite:
|
|
# https://github.com/googlefonts/colr-gradients-spec/tree/off_sub_1#compositemode-enumeration
|
|
class CompositeMode(IntEnum):
|
|
CLEAR = 0
|
|
SRC = 1
|
|
DEST = 2
|
|
SRC_OVER = 3
|
|
DEST_OVER = 4
|
|
SRC_IN = 5
|
|
DEST_IN = 6
|
|
SRC_OUT = 7
|
|
DEST_OUT = 8
|
|
SRC_ATOP = 9
|
|
DEST_ATOP = 10
|
|
XOR = 11
|
|
PLUS = 12
|
|
SCREEN = 13
|
|
OVERLAY = 14
|
|
DARKEN = 15
|
|
LIGHTEN = 16
|
|
COLOR_DODGE = 17
|
|
COLOR_BURN = 18
|
|
HARD_LIGHT = 19
|
|
SOFT_LIGHT = 20
|
|
DIFFERENCE = 21
|
|
EXCLUSION = 22
|
|
MULTIPLY = 23
|
|
HSL_HUE = 24
|
|
HSL_SATURATION = 25
|
|
HSL_COLOR = 26
|
|
HSL_LUMINOSITY = 27
|
|
|
|
|
|
class PaintFormat(IntEnum):
|
|
PaintColrLayers = 1
|
|
PaintSolid = 2
|
|
PaintVarSolid = 3
|
|
PaintLinearGradient = 4
|
|
PaintVarLinearGradient = 5
|
|
PaintRadialGradient = 6
|
|
PaintVarRadialGradient = 7
|
|
PaintSweepGradient = 8
|
|
PaintVarSweepGradient = 9
|
|
PaintGlyph = 10
|
|
PaintColrGlyph = 11
|
|
PaintTransform = 12
|
|
PaintVarTransform = 13
|
|
PaintTranslate = 14
|
|
PaintVarTranslate = 15
|
|
PaintScale = 16
|
|
PaintVarScale = 17
|
|
PaintScaleAroundCenter = 18
|
|
PaintVarScaleAroundCenter = 19
|
|
PaintScaleUniform = 20
|
|
PaintVarScaleUniform = 21
|
|
PaintScaleUniformAroundCenter = 22
|
|
PaintVarScaleUniformAroundCenter = 23
|
|
PaintRotate = 24
|
|
PaintVarRotate = 25
|
|
PaintRotateAroundCenter = 26
|
|
PaintVarRotateAroundCenter = 27
|
|
PaintSkew = 28
|
|
PaintVarSkew = 29
|
|
PaintSkewAroundCenter = 30
|
|
PaintVarSkewAroundCenter = 31
|
|
PaintComposite = 32
|
|
|
|
def is_variable(self):
|
|
return self.name.startswith("PaintVar")
|
|
|
|
def as_variable(self):
|
|
if self.is_variable():
|
|
return self
|
|
try:
|
|
return PaintFormat.__members__[f"PaintVar{self.name[5:]}"]
|
|
except KeyError:
|
|
return None
|
|
|
|
|
|
class Paint(getFormatSwitchingBaseTableClass("uint8")):
|
|
formatEnum = PaintFormat
|
|
|
|
def getFormatName(self):
|
|
try:
|
|
return self.formatEnum(self.Format).name
|
|
except ValueError:
|
|
raise NotImplementedError(f"Unknown Paint format: {self.Format}")
|
|
|
|
def toXML(self, xmlWriter, font, attrs=None, name=None):
|
|
tableName = name if name else self.__class__.__name__
|
|
if attrs is None:
|
|
attrs = []
|
|
attrs.append(("Format", self.Format))
|
|
xmlWriter.begintag(tableName, attrs)
|
|
xmlWriter.comment(self.getFormatName())
|
|
xmlWriter.newline()
|
|
self.toXML2(xmlWriter, font)
|
|
xmlWriter.endtag(tableName)
|
|
xmlWriter.newline()
|
|
|
|
def iterPaintSubTables(self, colr: COLR) -> Iterator[BaseTable.SubTableEntry]:
|
|
if self.Format == PaintFormat.PaintColrLayers:
|
|
# https://github.com/fonttools/fonttools/issues/2438: don't die when no LayerList exists
|
|
layers = []
|
|
if colr.LayerList is not None:
|
|
layers = colr.LayerList.Paint
|
|
yield from (
|
|
BaseTable.SubTableEntry(name="Layers", value=v, index=i)
|
|
for i, v in enumerate(
|
|
layers[self.FirstLayerIndex : self.FirstLayerIndex + self.NumLayers]
|
|
)
|
|
)
|
|
return
|
|
|
|
if self.Format == PaintFormat.PaintColrGlyph:
|
|
for record in colr.BaseGlyphList.BaseGlyphPaintRecord:
|
|
if record.BaseGlyph == self.Glyph:
|
|
yield BaseTable.SubTableEntry(name="BaseGlyph", value=record.Paint)
|
|
return
|
|
else:
|
|
raise KeyError(f"{self.Glyph!r} not in colr.BaseGlyphList")
|
|
|
|
for conv in self.getConverters():
|
|
if conv.tableClass is not None and issubclass(conv.tableClass, type(self)):
|
|
value = getattr(self, conv.name)
|
|
yield BaseTable.SubTableEntry(name=conv.name, value=value)
|
|
|
|
def getChildren(self, colr) -> List["Paint"]:
|
|
# this is kept for backward compatibility (e.g. it's used by the subsetter)
|
|
return [p.value for p in self.iterPaintSubTables(colr)]
|
|
|
|
def traverse(self, colr: COLR, callback):
|
|
"""Depth-first traversal of graph rooted at self, callback on each node."""
|
|
if not callable(callback):
|
|
raise TypeError("callback must be callable")
|
|
|
|
for path in dfs_base_table(
|
|
self, iter_subtables_fn=lambda paint: paint.iterPaintSubTables(colr)
|
|
):
|
|
paint = path[-1].value
|
|
callback(paint)
|
|
|
|
def getTransform(self) -> Transform:
|
|
if self.Format == PaintFormat.PaintTransform:
|
|
t = self.Transform
|
|
return Transform(t.xx, t.yx, t.xy, t.yy, t.dx, t.dy)
|
|
elif self.Format == PaintFormat.PaintTranslate:
|
|
return Identity.translate(self.dx, self.dy)
|
|
elif self.Format == PaintFormat.PaintScale:
|
|
return Identity.scale(self.scaleX, self.scaleY)
|
|
elif self.Format == PaintFormat.PaintScaleAroundCenter:
|
|
return (
|
|
Identity.translate(self.centerX, self.centerY)
|
|
.scale(self.scaleX, self.scaleY)
|
|
.translate(-self.centerX, -self.centerY)
|
|
)
|
|
elif self.Format == PaintFormat.PaintScaleUniform:
|
|
return Identity.scale(self.scale)
|
|
elif self.Format == PaintFormat.PaintScaleUniformAroundCenter:
|
|
return (
|
|
Identity.translate(self.centerX, self.centerY)
|
|
.scale(self.scale)
|
|
.translate(-self.centerX, -self.centerY)
|
|
)
|
|
elif self.Format == PaintFormat.PaintRotate:
|
|
return Identity.rotate(radians(self.angle))
|
|
elif self.Format == PaintFormat.PaintRotateAroundCenter:
|
|
return (
|
|
Identity.translate(self.centerX, self.centerY)
|
|
.rotate(radians(self.angle))
|
|
.translate(-self.centerX, -self.centerY)
|
|
)
|
|
elif self.Format == PaintFormat.PaintSkew:
|
|
return Identity.skew(radians(-self.xSkewAngle), radians(self.ySkewAngle))
|
|
elif self.Format == PaintFormat.PaintSkewAroundCenter:
|
|
return (
|
|
Identity.translate(self.centerX, self.centerY)
|
|
.skew(radians(-self.xSkewAngle), radians(self.ySkewAngle))
|
|
.translate(-self.centerX, -self.centerY)
|
|
)
|
|
if PaintFormat(self.Format).is_variable():
|
|
raise NotImplementedError(f"Variable Paints not supported: {self.Format}")
|
|
|
|
return Identity
|
|
|
|
def computeClipBox(
|
|
self, colr: COLR, glyphSet: "_TTGlyphSet", quantization: int = 1
|
|
) -> Optional[ClipBox]:
|
|
pen = ControlBoundsPen(glyphSet)
|
|
for path in dfs_base_table(
|
|
self, iter_subtables_fn=lambda paint: paint.iterPaintSubTables(colr)
|
|
):
|
|
paint = path[-1].value
|
|
if paint.Format == PaintFormat.PaintGlyph:
|
|
transformation = reduce(
|
|
Transform.transform,
|
|
(st.value.getTransform() for st in path),
|
|
Identity,
|
|
)
|
|
glyphSet[paint.Glyph].draw(TransformPen(pen, transformation))
|
|
|
|
if pen.bounds is None:
|
|
return None
|
|
|
|
cb = ClipBox()
|
|
cb.Format = int(ClipBoxFormat.Static)
|
|
cb.xMin, cb.yMin, cb.xMax, cb.yMax = quantizeRect(pen.bounds, quantization)
|
|
return cb
|
|
|
|
|
|
# For each subtable format there is a class. However, we don't really distinguish
|
|
# between "field name" and "format name": often these are the same. Yet there's
|
|
# a whole bunch of fields with different names. The following dict is a mapping
|
|
# from "format name" to "field name". _buildClasses() uses this to create a
|
|
# subclass for each alternate field name.
|
|
#
|
|
_equivalents = {
|
|
"MarkArray": ("Mark1Array",),
|
|
"LangSys": ("DefaultLangSys",),
|
|
"Coverage": (
|
|
"MarkCoverage",
|
|
"BaseCoverage",
|
|
"LigatureCoverage",
|
|
"Mark1Coverage",
|
|
"Mark2Coverage",
|
|
"BacktrackCoverage",
|
|
"InputCoverage",
|
|
"LookAheadCoverage",
|
|
"VertGlyphCoverage",
|
|
"HorizGlyphCoverage",
|
|
"TopAccentCoverage",
|
|
"ExtendedShapeCoverage",
|
|
"MathKernCoverage",
|
|
),
|
|
"ClassDef": (
|
|
"ClassDef1",
|
|
"ClassDef2",
|
|
"BacktrackClassDef",
|
|
"InputClassDef",
|
|
"LookAheadClassDef",
|
|
"GlyphClassDef",
|
|
"MarkAttachClassDef",
|
|
),
|
|
"Anchor": (
|
|
"EntryAnchor",
|
|
"ExitAnchor",
|
|
"BaseAnchor",
|
|
"LigatureAnchor",
|
|
"Mark2Anchor",
|
|
"MarkAnchor",
|
|
),
|
|
"Device": (
|
|
"XPlaDevice",
|
|
"YPlaDevice",
|
|
"XAdvDevice",
|
|
"YAdvDevice",
|
|
"XDeviceTable",
|
|
"YDeviceTable",
|
|
"DeviceTable",
|
|
),
|
|
"Axis": (
|
|
"HorizAxis",
|
|
"VertAxis",
|
|
),
|
|
"MinMax": ("DefaultMinMax",),
|
|
"BaseCoord": (
|
|
"MinCoord",
|
|
"MaxCoord",
|
|
),
|
|
"JstfLangSys": ("DefJstfLangSys",),
|
|
"JstfGSUBModList": (
|
|
"ShrinkageEnableGSUB",
|
|
"ShrinkageDisableGSUB",
|
|
"ExtensionEnableGSUB",
|
|
"ExtensionDisableGSUB",
|
|
),
|
|
"JstfGPOSModList": (
|
|
"ShrinkageEnableGPOS",
|
|
"ShrinkageDisableGPOS",
|
|
"ExtensionEnableGPOS",
|
|
"ExtensionDisableGPOS",
|
|
),
|
|
"JstfMax": (
|
|
"ShrinkageJstfMax",
|
|
"ExtensionJstfMax",
|
|
),
|
|
"MathKern": (
|
|
"TopRightMathKern",
|
|
"TopLeftMathKern",
|
|
"BottomRightMathKern",
|
|
"BottomLeftMathKern",
|
|
),
|
|
"MathGlyphConstruction": ("VertGlyphConstruction", "HorizGlyphConstruction"),
|
|
}
|
|
|
|
#
|
|
# OverFlow logic, to automatically create ExtensionLookups
|
|
# XXX This should probably move to otBase.py
|
|
#
|
|
|
|
|
|
def fixLookupOverFlows(ttf, overflowRecord):
|
|
"""Either the offset from the LookupList to a lookup overflowed, or
|
|
an offset from a lookup to a subtable overflowed.
|
|
The table layout is:
|
|
GPSO/GUSB
|
|
Script List
|
|
Feature List
|
|
LookUpList
|
|
Lookup[0] and contents
|
|
SubTable offset list
|
|
SubTable[0] and contents
|
|
...
|
|
SubTable[n] and contents
|
|
...
|
|
Lookup[n] and contents
|
|
SubTable offset list
|
|
SubTable[0] and contents
|
|
...
|
|
SubTable[n] and contents
|
|
If the offset to a lookup overflowed (SubTableIndex is None)
|
|
we must promote the *previous* lookup to an Extension type.
|
|
If the offset from a lookup to subtable overflowed, then we must promote it
|
|
to an Extension Lookup type.
|
|
"""
|
|
ok = 0
|
|
lookupIndex = overflowRecord.LookupListIndex
|
|
if overflowRecord.SubTableIndex is None:
|
|
lookupIndex = lookupIndex - 1
|
|
if lookupIndex < 0:
|
|
return ok
|
|
if overflowRecord.tableType == "GSUB":
|
|
extType = 7
|
|
elif overflowRecord.tableType == "GPOS":
|
|
extType = 9
|
|
|
|
lookups = ttf[overflowRecord.tableType].table.LookupList.Lookup
|
|
lookup = lookups[lookupIndex]
|
|
# If the previous lookup is an extType, look further back. Very unlikely, but possible.
|
|
while lookup.SubTable[0].__class__.LookupType == extType:
|
|
lookupIndex = lookupIndex - 1
|
|
if lookupIndex < 0:
|
|
return ok
|
|
lookup = lookups[lookupIndex]
|
|
|
|
for lookupIndex in range(lookupIndex, len(lookups)):
|
|
lookup = lookups[lookupIndex]
|
|
if lookup.LookupType != extType:
|
|
lookup.LookupType = extType
|
|
for si in range(len(lookup.SubTable)):
|
|
subTable = lookup.SubTable[si]
|
|
extSubTableClass = lookupTypes[overflowRecord.tableType][extType]
|
|
extSubTable = extSubTableClass()
|
|
extSubTable.Format = 1
|
|
extSubTable.ExtSubTable = subTable
|
|
lookup.SubTable[si] = extSubTable
|
|
ok = 1
|
|
return ok
|
|
|
|
|
|
def splitMultipleSubst(oldSubTable, newSubTable, overflowRecord):
|
|
ok = 1
|
|
oldMapping = sorted(oldSubTable.mapping.items())
|
|
oldLen = len(oldMapping)
|
|
|
|
if overflowRecord.itemName in ["Coverage", "RangeRecord"]:
|
|
# Coverage table is written last. Overflow is to or within the
|
|
# the coverage table. We will just cut the subtable in half.
|
|
newLen = oldLen // 2
|
|
|
|
elif overflowRecord.itemName == "Sequence":
|
|
# We just need to back up by two items from the overflowed
|
|
# Sequence index to make sure the offset to the Coverage table
|
|
# doesn't overflow.
|
|
newLen = overflowRecord.itemIndex - 1
|
|
|
|
newSubTable.mapping = {}
|
|
for i in range(newLen, oldLen):
|
|
item = oldMapping[i]
|
|
key = item[0]
|
|
newSubTable.mapping[key] = item[1]
|
|
del oldSubTable.mapping[key]
|
|
|
|
return ok
|
|
|
|
|
|
def splitAlternateSubst(oldSubTable, newSubTable, overflowRecord):
|
|
ok = 1
|
|
if hasattr(oldSubTable, "sortCoverageLast"):
|
|
newSubTable.sortCoverageLast = oldSubTable.sortCoverageLast
|
|
|
|
oldAlts = sorted(oldSubTable.alternates.items())
|
|
oldLen = len(oldAlts)
|
|
|
|
if overflowRecord.itemName in ["Coverage", "RangeRecord"]:
|
|
# Coverage table is written last. overflow is to or within the
|
|
# the coverage table. We will just cut the subtable in half.
|
|
newLen = oldLen // 2
|
|
|
|
elif overflowRecord.itemName == "AlternateSet":
|
|
# We just need to back up by two items
|
|
# from the overflowed AlternateSet index to make sure the offset
|
|
# to the Coverage table doesn't overflow.
|
|
newLen = overflowRecord.itemIndex - 1
|
|
|
|
newSubTable.alternates = {}
|
|
for i in range(newLen, oldLen):
|
|
item = oldAlts[i]
|
|
key = item[0]
|
|
newSubTable.alternates[key] = item[1]
|
|
del oldSubTable.alternates[key]
|
|
|
|
return ok
|
|
|
|
|
|
def splitLigatureSubst(oldSubTable, newSubTable, overflowRecord):
|
|
ok = 1
|
|
oldLigs = sorted(oldSubTable.ligatures.items())
|
|
oldLen = len(oldLigs)
|
|
|
|
if overflowRecord.itemName in ["Coverage", "RangeRecord"]:
|
|
# Coverage table is written last. overflow is to or within the
|
|
# the coverage table. We will just cut the subtable in half.
|
|
newLen = oldLen // 2
|
|
|
|
elif overflowRecord.itemName == "LigatureSet":
|
|
# We just need to back up by two items
|
|
# from the overflowed AlternateSet index to make sure the offset
|
|
# to the Coverage table doesn't overflow.
|
|
newLen = overflowRecord.itemIndex - 1
|
|
|
|
newSubTable.ligatures = {}
|
|
for i in range(newLen, oldLen):
|
|
item = oldLigs[i]
|
|
key = item[0]
|
|
newSubTable.ligatures[key] = item[1]
|
|
del oldSubTable.ligatures[key]
|
|
|
|
return ok
|
|
|
|
|
|
def splitPairPos(oldSubTable, newSubTable, overflowRecord):
|
|
st = oldSubTable
|
|
ok = False
|
|
newSubTable.Format = oldSubTable.Format
|
|
if oldSubTable.Format == 1 and len(oldSubTable.PairSet) > 1:
|
|
for name in "ValueFormat1", "ValueFormat2":
|
|
setattr(newSubTable, name, getattr(oldSubTable, name))
|
|
|
|
# Move top half of coverage to new subtable
|
|
|
|
newSubTable.Coverage = oldSubTable.Coverage.__class__()
|
|
|
|
coverage = oldSubTable.Coverage.glyphs
|
|
records = oldSubTable.PairSet
|
|
|
|
oldCount = len(oldSubTable.PairSet) // 2
|
|
|
|
oldSubTable.Coverage.glyphs = coverage[:oldCount]
|
|
oldSubTable.PairSet = records[:oldCount]
|
|
|
|
newSubTable.Coverage.glyphs = coverage[oldCount:]
|
|
newSubTable.PairSet = records[oldCount:]
|
|
|
|
oldSubTable.PairSetCount = len(oldSubTable.PairSet)
|
|
newSubTable.PairSetCount = len(newSubTable.PairSet)
|
|
|
|
ok = True
|
|
|
|
elif oldSubTable.Format == 2 and len(oldSubTable.Class1Record) > 1:
|
|
if not hasattr(oldSubTable, "Class2Count"):
|
|
oldSubTable.Class2Count = len(oldSubTable.Class1Record[0].Class2Record)
|
|
for name in "Class2Count", "ClassDef2", "ValueFormat1", "ValueFormat2":
|
|
setattr(newSubTable, name, getattr(oldSubTable, name))
|
|
|
|
# The two subtables will still have the same ClassDef2 and the table
|
|
# sharing will still cause the sharing to overflow. As such, disable
|
|
# sharing on the one that is serialized second (that's oldSubTable).
|
|
oldSubTable.DontShare = True
|
|
|
|
# Move top half of class numbers to new subtable
|
|
|
|
newSubTable.Coverage = oldSubTable.Coverage.__class__()
|
|
newSubTable.ClassDef1 = oldSubTable.ClassDef1.__class__()
|
|
|
|
coverage = oldSubTable.Coverage.glyphs
|
|
classDefs = oldSubTable.ClassDef1.classDefs
|
|
records = oldSubTable.Class1Record
|
|
|
|
oldCount = len(oldSubTable.Class1Record) // 2
|
|
newGlyphs = set(k for k, v in classDefs.items() if v >= oldCount)
|
|
|
|
oldSubTable.Coverage.glyphs = [g for g in coverage if g not in newGlyphs]
|
|
oldSubTable.ClassDef1.classDefs = {
|
|
k: v for k, v in classDefs.items() if v < oldCount
|
|
}
|
|
oldSubTable.Class1Record = records[:oldCount]
|
|
|
|
newSubTable.Coverage.glyphs = [g for g in coverage if g in newGlyphs]
|
|
newSubTable.ClassDef1.classDefs = {
|
|
k: (v - oldCount) for k, v in classDefs.items() if v > oldCount
|
|
}
|
|
newSubTable.Class1Record = records[oldCount:]
|
|
|
|
oldSubTable.Class1Count = len(oldSubTable.Class1Record)
|
|
newSubTable.Class1Count = len(newSubTable.Class1Record)
|
|
|
|
ok = True
|
|
|
|
return ok
|
|
|
|
|
|
def splitMarkBasePos(oldSubTable, newSubTable, overflowRecord):
|
|
# split half of the mark classes to the new subtable
|
|
classCount = oldSubTable.ClassCount
|
|
if classCount < 2:
|
|
# oh well, not much left to split...
|
|
return False
|
|
|
|
oldClassCount = classCount // 2
|
|
newClassCount = classCount - oldClassCount
|
|
|
|
oldMarkCoverage, oldMarkRecords = [], []
|
|
newMarkCoverage, newMarkRecords = [], []
|
|
for glyphName, markRecord in zip(
|
|
oldSubTable.MarkCoverage.glyphs, oldSubTable.MarkArray.MarkRecord
|
|
):
|
|
if markRecord.Class < oldClassCount:
|
|
oldMarkCoverage.append(glyphName)
|
|
oldMarkRecords.append(markRecord)
|
|
else:
|
|
markRecord.Class -= oldClassCount
|
|
newMarkCoverage.append(glyphName)
|
|
newMarkRecords.append(markRecord)
|
|
|
|
oldBaseRecords, newBaseRecords = [], []
|
|
for rec in oldSubTable.BaseArray.BaseRecord:
|
|
oldBaseRecord, newBaseRecord = rec.__class__(), rec.__class__()
|
|
oldBaseRecord.BaseAnchor = rec.BaseAnchor[:oldClassCount]
|
|
newBaseRecord.BaseAnchor = rec.BaseAnchor[oldClassCount:]
|
|
oldBaseRecords.append(oldBaseRecord)
|
|
newBaseRecords.append(newBaseRecord)
|
|
|
|
newSubTable.Format = oldSubTable.Format
|
|
|
|
oldSubTable.MarkCoverage.glyphs = oldMarkCoverage
|
|
newSubTable.MarkCoverage = oldSubTable.MarkCoverage.__class__()
|
|
newSubTable.MarkCoverage.glyphs = newMarkCoverage
|
|
|
|
# share the same BaseCoverage in both halves
|
|
newSubTable.BaseCoverage = oldSubTable.BaseCoverage
|
|
|
|
oldSubTable.ClassCount = oldClassCount
|
|
newSubTable.ClassCount = newClassCount
|
|
|
|
oldSubTable.MarkArray.MarkRecord = oldMarkRecords
|
|
newSubTable.MarkArray = oldSubTable.MarkArray.__class__()
|
|
newSubTable.MarkArray.MarkRecord = newMarkRecords
|
|
|
|
oldSubTable.MarkArray.MarkCount = len(oldMarkRecords)
|
|
newSubTable.MarkArray.MarkCount = len(newMarkRecords)
|
|
|
|
oldSubTable.BaseArray.BaseRecord = oldBaseRecords
|
|
newSubTable.BaseArray = oldSubTable.BaseArray.__class__()
|
|
newSubTable.BaseArray.BaseRecord = newBaseRecords
|
|
|
|
oldSubTable.BaseArray.BaseCount = len(oldBaseRecords)
|
|
newSubTable.BaseArray.BaseCount = len(newBaseRecords)
|
|
|
|
return True
|
|
|
|
|
|
splitTable = {
|
|
"GSUB": {
|
|
# 1: splitSingleSubst,
|
|
2: splitMultipleSubst,
|
|
3: splitAlternateSubst,
|
|
4: splitLigatureSubst,
|
|
# 5: splitContextSubst,
|
|
# 6: splitChainContextSubst,
|
|
# 7: splitExtensionSubst,
|
|
# 8: splitReverseChainSingleSubst,
|
|
},
|
|
"GPOS": {
|
|
# 1: splitSinglePos,
|
|
2: splitPairPos,
|
|
# 3: splitCursivePos,
|
|
4: splitMarkBasePos,
|
|
# 5: splitMarkLigPos,
|
|
# 6: splitMarkMarkPos,
|
|
# 7: splitContextPos,
|
|
# 8: splitChainContextPos,
|
|
# 9: splitExtensionPos,
|
|
},
|
|
}
|
|
|
|
|
|
def fixSubTableOverFlows(ttf, overflowRecord):
|
|
"""
|
|
An offset has overflowed within a sub-table. We need to divide this subtable into smaller parts.
|
|
"""
|
|
table = ttf[overflowRecord.tableType].table
|
|
lookup = table.LookupList.Lookup[overflowRecord.LookupListIndex]
|
|
subIndex = overflowRecord.SubTableIndex
|
|
subtable = lookup.SubTable[subIndex]
|
|
|
|
# First, try not sharing anything for this subtable...
|
|
if not hasattr(subtable, "DontShare"):
|
|
subtable.DontShare = True
|
|
return True
|
|
|
|
if hasattr(subtable, "ExtSubTable"):
|
|
# We split the subtable of the Extension table, and add a new Extension table
|
|
# to contain the new subtable.
|
|
|
|
subTableType = subtable.ExtSubTable.__class__.LookupType
|
|
extSubTable = subtable
|
|
subtable = extSubTable.ExtSubTable
|
|
newExtSubTableClass = lookupTypes[overflowRecord.tableType][
|
|
extSubTable.__class__.LookupType
|
|
]
|
|
newExtSubTable = newExtSubTableClass()
|
|
newExtSubTable.Format = extSubTable.Format
|
|
toInsert = newExtSubTable
|
|
|
|
newSubTableClass = lookupTypes[overflowRecord.tableType][subTableType]
|
|
newSubTable = newSubTableClass()
|
|
newExtSubTable.ExtSubTable = newSubTable
|
|
else:
|
|
subTableType = subtable.__class__.LookupType
|
|
newSubTableClass = lookupTypes[overflowRecord.tableType][subTableType]
|
|
newSubTable = newSubTableClass()
|
|
toInsert = newSubTable
|
|
|
|
if hasattr(lookup, "SubTableCount"): # may not be defined yet.
|
|
lookup.SubTableCount = lookup.SubTableCount + 1
|
|
|
|
try:
|
|
splitFunc = splitTable[overflowRecord.tableType][subTableType]
|
|
except KeyError:
|
|
log.error(
|
|
"Don't know how to split %s lookup type %s",
|
|
overflowRecord.tableType,
|
|
subTableType,
|
|
)
|
|
return False
|
|
|
|
ok = splitFunc(subtable, newSubTable, overflowRecord)
|
|
if ok:
|
|
lookup.SubTable.insert(subIndex + 1, toInsert)
|
|
return ok
|
|
|
|
|
|
# End of OverFlow logic
|
|
|
|
|
|
def _buildClasses():
|
|
import re
|
|
from .otData import otData
|
|
|
|
formatPat = re.compile(r"([A-Za-z0-9]+)Format(\d+)$")
|
|
namespace = globals()
|
|
|
|
# populate module with classes
|
|
for name, table in otData:
|
|
baseClass = BaseTable
|
|
m = formatPat.match(name)
|
|
if m:
|
|
# XxxFormatN subtable, we only add the "base" table
|
|
name = m.group(1)
|
|
# the first row of a format-switching otData table describes the Format;
|
|
# the first column defines the type of the Format field.
|
|
# Currently this can be either 'uint16' or 'uint8'.
|
|
formatType = table[0][0]
|
|
baseClass = getFormatSwitchingBaseTableClass(formatType)
|
|
if name not in namespace:
|
|
# the class doesn't exist yet, so the base implementation is used.
|
|
cls = type(name, (baseClass,), {})
|
|
if name in ("GSUB", "GPOS"):
|
|
cls.DontShare = True
|
|
namespace[name] = cls
|
|
|
|
# link Var{Table} <-> {Table} (e.g. ColorStop <-> VarColorStop, etc.)
|
|
for name, _ in otData:
|
|
if name.startswith("Var") and len(name) > 3 and name[3:] in namespace:
|
|
varType = namespace[name]
|
|
noVarType = namespace[name[3:]]
|
|
varType.NoVarType = noVarType
|
|
noVarType.VarType = varType
|
|
|
|
for base, alts in _equivalents.items():
|
|
base = namespace[base]
|
|
for alt in alts:
|
|
namespace[alt] = base
|
|
|
|
global lookupTypes
|
|
lookupTypes = {
|
|
"GSUB": {
|
|
1: SingleSubst,
|
|
2: MultipleSubst,
|
|
3: AlternateSubst,
|
|
4: LigatureSubst,
|
|
5: ContextSubst,
|
|
6: ChainContextSubst,
|
|
7: ExtensionSubst,
|
|
8: ReverseChainSingleSubst,
|
|
},
|
|
"GPOS": {
|
|
1: SinglePos,
|
|
2: PairPos,
|
|
3: CursivePos,
|
|
4: MarkBasePos,
|
|
5: MarkLigPos,
|
|
6: MarkMarkPos,
|
|
7: ContextPos,
|
|
8: ChainContextPos,
|
|
9: ExtensionPos,
|
|
},
|
|
"mort": {
|
|
4: NoncontextualMorph,
|
|
},
|
|
"morx": {
|
|
0: RearrangementMorph,
|
|
1: ContextualMorph,
|
|
2: LigatureMorph,
|
|
# 3: Reserved,
|
|
4: NoncontextualMorph,
|
|
5: InsertionMorph,
|
|
},
|
|
}
|
|
lookupTypes["JSTF"] = lookupTypes["GPOS"] # JSTF contains GPOS
|
|
for lookupEnum in lookupTypes.values():
|
|
for enum, cls in lookupEnum.items():
|
|
cls.LookupType = enum
|
|
|
|
global featureParamTypes
|
|
featureParamTypes = {
|
|
"size": FeatureParamsSize,
|
|
}
|
|
for i in range(1, 20 + 1):
|
|
featureParamTypes["ss%02d" % i] = FeatureParamsStylisticSet
|
|
for i in range(1, 99 + 1):
|
|
featureParamTypes["cv%02d" % i] = FeatureParamsCharacterVariants
|
|
|
|
# add converters to classes
|
|
from .otConverters import buildConverters
|
|
|
|
for name, table in otData:
|
|
m = formatPat.match(name)
|
|
if m:
|
|
# XxxFormatN subtable, add converter to "base" table
|
|
name, format = m.groups()
|
|
format = int(format)
|
|
cls = namespace[name]
|
|
if not hasattr(cls, "converters"):
|
|
cls.converters = {}
|
|
cls.convertersByName = {}
|
|
converters, convertersByName = buildConverters(table[1:], namespace)
|
|
cls.converters[format] = converters
|
|
cls.convertersByName[format] = convertersByName
|
|
# XXX Add staticSize?
|
|
else:
|
|
cls = namespace[name]
|
|
cls.converters, cls.convertersByName = buildConverters(table, namespace)
|
|
# XXX Add staticSize?
|
|
|
|
|
|
_buildClasses()
|
|
|
|
|
|
def _getGlyphsFromCoverageTable(coverage):
|
|
if coverage is None:
|
|
# empty coverage table
|
|
return []
|
|
else:
|
|
return coverage.glyphs
|