1715 lines
68 KiB
Python
1715 lines
68 KiB
Python
from fontTools.misc import sstruct
|
|
from fontTools.misc.textTools import Tag, tostr, binary2num, safeEval
|
|
from fontTools.feaLib.error import FeatureLibError
|
|
from fontTools.feaLib.lookupDebugInfo import (
|
|
LookupDebugInfo,
|
|
LOOKUP_DEBUG_INFO_KEY,
|
|
LOOKUP_DEBUG_ENV_VAR,
|
|
)
|
|
from fontTools.feaLib.parser import Parser
|
|
from fontTools.feaLib.ast import FeatureFile
|
|
from fontTools.feaLib.variableScalar import VariableScalar
|
|
from fontTools.otlLib import builder as otl
|
|
from fontTools.otlLib.maxContextCalc import maxCtxFont
|
|
from fontTools.ttLib import newTable, getTableModule
|
|
from fontTools.ttLib.tables import otBase, otTables
|
|
from fontTools.otlLib.builder import (
|
|
AlternateSubstBuilder,
|
|
ChainContextPosBuilder,
|
|
ChainContextSubstBuilder,
|
|
LigatureSubstBuilder,
|
|
MultipleSubstBuilder,
|
|
CursivePosBuilder,
|
|
MarkBasePosBuilder,
|
|
MarkLigPosBuilder,
|
|
MarkMarkPosBuilder,
|
|
ReverseChainSingleSubstBuilder,
|
|
SingleSubstBuilder,
|
|
ClassPairPosSubtableBuilder,
|
|
PairPosBuilder,
|
|
SinglePosBuilder,
|
|
ChainContextualRule,
|
|
)
|
|
from fontTools.otlLib.error import OpenTypeLibError
|
|
from fontTools.varLib.varStore import OnlineVarStoreBuilder
|
|
from fontTools.varLib.builder import buildVarDevTable
|
|
from fontTools.varLib.featureVars import addFeatureVariationsRaw
|
|
from fontTools.varLib.models import normalizeValue, piecewiseLinearMap
|
|
from collections import defaultdict
|
|
import copy
|
|
import itertools
|
|
from io import StringIO
|
|
import logging
|
|
import warnings
|
|
import os
|
|
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
def addOpenTypeFeatures(font, featurefile, tables=None, debug=False):
|
|
"""Add features from a file to a font. Note that this replaces any features
|
|
currently present.
|
|
|
|
Args:
|
|
font (feaLib.ttLib.TTFont): The font object.
|
|
featurefile: Either a path or file object (in which case we
|
|
parse it into an AST), or a pre-parsed AST instance.
|
|
tables: If passed, restrict the set of affected tables to those in the
|
|
list.
|
|
debug: Whether to add source debugging information to the font in the
|
|
``Debg`` table
|
|
|
|
"""
|
|
builder = Builder(font, featurefile)
|
|
builder.build(tables=tables, debug=debug)
|
|
|
|
|
|
def addOpenTypeFeaturesFromString(
|
|
font, features, filename=None, tables=None, debug=False
|
|
):
|
|
"""Add features from a string to a font. Note that this replaces any
|
|
features currently present.
|
|
|
|
Args:
|
|
font (feaLib.ttLib.TTFont): The font object.
|
|
features: A string containing feature code.
|
|
filename: The directory containing ``filename`` is used as the root of
|
|
relative ``include()`` paths; if ``None`` is provided, the current
|
|
directory is assumed.
|
|
tables: If passed, restrict the set of affected tables to those in the
|
|
list.
|
|
debug: Whether to add source debugging information to the font in the
|
|
``Debg`` table
|
|
|
|
"""
|
|
|
|
featurefile = StringIO(tostr(features))
|
|
if filename:
|
|
featurefile.name = filename
|
|
addOpenTypeFeatures(font, featurefile, tables=tables, debug=debug)
|
|
|
|
|
|
class Builder(object):
|
|
supportedTables = frozenset(
|
|
Tag(tag)
|
|
for tag in [
|
|
"BASE",
|
|
"GDEF",
|
|
"GPOS",
|
|
"GSUB",
|
|
"OS/2",
|
|
"head",
|
|
"hhea",
|
|
"name",
|
|
"vhea",
|
|
"STAT",
|
|
]
|
|
)
|
|
|
|
def __init__(self, font, featurefile):
|
|
self.font = font
|
|
# 'featurefile' can be either a path or file object (in which case we
|
|
# parse it into an AST), or a pre-parsed AST instance
|
|
if isinstance(featurefile, FeatureFile):
|
|
self.parseTree, self.file = featurefile, None
|
|
else:
|
|
self.parseTree, self.file = None, featurefile
|
|
self.glyphMap = font.getReverseGlyphMap()
|
|
self.varstorebuilder = None
|
|
if "fvar" in font:
|
|
self.axes = font["fvar"].axes
|
|
self.varstorebuilder = OnlineVarStoreBuilder(
|
|
[ax.axisTag for ax in self.axes]
|
|
)
|
|
self.default_language_systems_ = set()
|
|
self.script_ = None
|
|
self.lookupflag_ = 0
|
|
self.lookupflag_markFilterSet_ = None
|
|
self.language_systems = set()
|
|
self.seen_non_DFLT_script_ = False
|
|
self.named_lookups_ = {}
|
|
self.cur_lookup_ = None
|
|
self.cur_lookup_name_ = None
|
|
self.cur_feature_name_ = None
|
|
self.lookups_ = []
|
|
self.lookup_locations = {"GSUB": {}, "GPOS": {}}
|
|
self.features_ = {} # ('latn', 'DEU ', 'smcp') --> [LookupBuilder*]
|
|
self.required_features_ = {} # ('latn', 'DEU ') --> 'scmp'
|
|
self.feature_variations_ = {}
|
|
# for feature 'aalt'
|
|
self.aalt_features_ = [] # [(location, featureName)*], for 'aalt'
|
|
self.aalt_location_ = None
|
|
self.aalt_alternates_ = {}
|
|
# for 'featureNames'
|
|
self.featureNames_ = set()
|
|
self.featureNames_ids_ = {}
|
|
# for 'cvParameters'
|
|
self.cv_parameters_ = set()
|
|
self.cv_parameters_ids_ = {}
|
|
self.cv_num_named_params_ = {}
|
|
self.cv_characters_ = defaultdict(list)
|
|
# for feature 'size'
|
|
self.size_parameters_ = None
|
|
# for table 'head'
|
|
self.fontRevision_ = None # 2.71
|
|
# for table 'name'
|
|
self.names_ = []
|
|
# for table 'BASE'
|
|
self.base_horiz_axis_ = None
|
|
self.base_vert_axis_ = None
|
|
# for table 'GDEF'
|
|
self.attachPoints_ = {} # "a" --> {3, 7}
|
|
self.ligCaretCoords_ = {} # "f_f_i" --> {300, 600}
|
|
self.ligCaretPoints_ = {} # "f_f_i" --> {3, 7}
|
|
self.glyphClassDefs_ = {} # "fi" --> (2, (file, line, column))
|
|
self.markAttach_ = {} # "acute" --> (4, (file, line, column))
|
|
self.markAttachClassID_ = {} # frozenset({"acute", "grave"}) --> 4
|
|
self.markFilterSets_ = {} # frozenset({"acute", "grave"}) --> 4
|
|
# for table 'OS/2'
|
|
self.os2_ = {}
|
|
# for table 'hhea'
|
|
self.hhea_ = {}
|
|
# for table 'vhea'
|
|
self.vhea_ = {}
|
|
# for table 'STAT'
|
|
self.stat_ = {}
|
|
# for conditionsets
|
|
self.conditionsets_ = {}
|
|
# We will often use exactly the same locations (i.e. the font's masters)
|
|
# for a large number of variable scalars. Instead of creating a model
|
|
# for each, let's share the models.
|
|
self.model_cache = {}
|
|
|
|
def build(self, tables=None, debug=False):
|
|
if self.parseTree is None:
|
|
self.parseTree = Parser(self.file, self.glyphMap).parse()
|
|
self.parseTree.build(self)
|
|
# by default, build all the supported tables
|
|
if tables is None:
|
|
tables = self.supportedTables
|
|
else:
|
|
tables = frozenset(tables)
|
|
unsupported = tables - self.supportedTables
|
|
if unsupported:
|
|
unsupported_string = ", ".join(sorted(unsupported))
|
|
raise NotImplementedError(
|
|
"The following tables were requested but are unsupported: "
|
|
f"{unsupported_string}."
|
|
)
|
|
if "GSUB" in tables:
|
|
self.build_feature_aalt_()
|
|
if "head" in tables:
|
|
self.build_head()
|
|
if "hhea" in tables:
|
|
self.build_hhea()
|
|
if "vhea" in tables:
|
|
self.build_vhea()
|
|
if "name" in tables:
|
|
self.build_name()
|
|
if "OS/2" in tables:
|
|
self.build_OS_2()
|
|
if "STAT" in tables:
|
|
self.build_STAT()
|
|
for tag in ("GPOS", "GSUB"):
|
|
if tag not in tables:
|
|
continue
|
|
table = self.makeTable(tag)
|
|
if self.feature_variations_:
|
|
self.makeFeatureVariations(table, tag)
|
|
if (
|
|
table.ScriptList.ScriptCount > 0
|
|
or table.FeatureList.FeatureCount > 0
|
|
or table.LookupList.LookupCount > 0
|
|
):
|
|
fontTable = self.font[tag] = newTable(tag)
|
|
fontTable.table = table
|
|
elif tag in self.font:
|
|
del self.font[tag]
|
|
if any(tag in self.font for tag in ("GPOS", "GSUB")) and "OS/2" in self.font:
|
|
self.font["OS/2"].usMaxContext = maxCtxFont(self.font)
|
|
if "GDEF" in tables:
|
|
gdef = self.buildGDEF()
|
|
if gdef:
|
|
self.font["GDEF"] = gdef
|
|
elif "GDEF" in self.font:
|
|
del self.font["GDEF"]
|
|
if "BASE" in tables:
|
|
base = self.buildBASE()
|
|
if base:
|
|
self.font["BASE"] = base
|
|
elif "BASE" in self.font:
|
|
del self.font["BASE"]
|
|
if debug or os.environ.get(LOOKUP_DEBUG_ENV_VAR):
|
|
self.buildDebg()
|
|
|
|
def get_chained_lookup_(self, location, builder_class):
|
|
result = builder_class(self.font, location)
|
|
result.lookupflag = self.lookupflag_
|
|
result.markFilterSet = self.lookupflag_markFilterSet_
|
|
self.lookups_.append(result)
|
|
return result
|
|
|
|
def add_lookup_to_feature_(self, lookup, feature_name):
|
|
for script, lang in self.language_systems:
|
|
key = (script, lang, feature_name)
|
|
self.features_.setdefault(key, []).append(lookup)
|
|
|
|
def get_lookup_(self, location, builder_class):
|
|
if (
|
|
self.cur_lookup_
|
|
and type(self.cur_lookup_) == builder_class
|
|
and self.cur_lookup_.lookupflag == self.lookupflag_
|
|
and self.cur_lookup_.markFilterSet == self.lookupflag_markFilterSet_
|
|
):
|
|
return self.cur_lookup_
|
|
if self.cur_lookup_name_ and self.cur_lookup_:
|
|
raise FeatureLibError(
|
|
"Within a named lookup block, all rules must be of "
|
|
"the same lookup type and flag",
|
|
location,
|
|
)
|
|
self.cur_lookup_ = builder_class(self.font, location)
|
|
self.cur_lookup_.lookupflag = self.lookupflag_
|
|
self.cur_lookup_.markFilterSet = self.lookupflag_markFilterSet_
|
|
self.lookups_.append(self.cur_lookup_)
|
|
if self.cur_lookup_name_:
|
|
# We are starting a lookup rule inside a named lookup block.
|
|
self.named_lookups_[self.cur_lookup_name_] = self.cur_lookup_
|
|
if self.cur_feature_name_:
|
|
# We are starting a lookup rule inside a feature. This includes
|
|
# lookup rules inside named lookups inside features.
|
|
self.add_lookup_to_feature_(self.cur_lookup_, self.cur_feature_name_)
|
|
return self.cur_lookup_
|
|
|
|
def build_feature_aalt_(self):
|
|
if not self.aalt_features_ and not self.aalt_alternates_:
|
|
return
|
|
# > alternate glyphs will be sorted in the order that the source features
|
|
# > are named in the aalt definition, not the order of the feature definitions
|
|
# > in the file. Alternates defined explicitly ... will precede all others.
|
|
# https://github.com/fonttools/fonttools/issues/836
|
|
alternates = {g: list(a) for g, a in self.aalt_alternates_.items()}
|
|
for location, name in self.aalt_features_ + [(None, "aalt")]:
|
|
feature = [
|
|
(script, lang, feature, lookups)
|
|
for (script, lang, feature), lookups in self.features_.items()
|
|
if feature == name
|
|
]
|
|
# "aalt" does not have to specify its own lookups, but it might.
|
|
if not feature and name != "aalt":
|
|
warnings.warn("%s: Feature %s has not been defined" % (location, name))
|
|
continue
|
|
for script, lang, feature, lookups in feature:
|
|
for lookuplist in lookups:
|
|
if not isinstance(lookuplist, list):
|
|
lookuplist = [lookuplist]
|
|
for lookup in lookuplist:
|
|
for glyph, alts in lookup.getAlternateGlyphs().items():
|
|
alts_for_glyph = alternates.setdefault(glyph, [])
|
|
alts_for_glyph.extend(
|
|
g for g in alts if g not in alts_for_glyph
|
|
)
|
|
single = {
|
|
glyph: repl[0] for glyph, repl in alternates.items() if len(repl) == 1
|
|
}
|
|
multi = {glyph: repl for glyph, repl in alternates.items() if len(repl) > 1}
|
|
if not single and not multi:
|
|
return
|
|
self.features_ = {
|
|
(script, lang, feature): lookups
|
|
for (script, lang, feature), lookups in self.features_.items()
|
|
if feature != "aalt"
|
|
}
|
|
old_lookups = self.lookups_
|
|
self.lookups_ = []
|
|
self.start_feature(self.aalt_location_, "aalt")
|
|
if single:
|
|
single_lookup = self.get_lookup_(location, SingleSubstBuilder)
|
|
single_lookup.mapping = single
|
|
if multi:
|
|
multi_lookup = self.get_lookup_(location, AlternateSubstBuilder)
|
|
multi_lookup.alternates = multi
|
|
self.end_feature()
|
|
self.lookups_.extend(old_lookups)
|
|
|
|
def build_head(self):
|
|
if not self.fontRevision_:
|
|
return
|
|
table = self.font.get("head")
|
|
if not table: # this only happens for unit tests
|
|
table = self.font["head"] = newTable("head")
|
|
table.decompile(b"\0" * 54, self.font)
|
|
table.tableVersion = 1.0
|
|
table.created = table.modified = 3406620153 # 2011-12-13 11:22:33
|
|
table.fontRevision = self.fontRevision_
|
|
|
|
def build_hhea(self):
|
|
if not self.hhea_:
|
|
return
|
|
table = self.font.get("hhea")
|
|
if not table: # this only happens for unit tests
|
|
table = self.font["hhea"] = newTable("hhea")
|
|
table.decompile(b"\0" * 36, self.font)
|
|
table.tableVersion = 0x00010000
|
|
if "caretoffset" in self.hhea_:
|
|
table.caretOffset = self.hhea_["caretoffset"]
|
|
if "ascender" in self.hhea_:
|
|
table.ascent = self.hhea_["ascender"]
|
|
if "descender" in self.hhea_:
|
|
table.descent = self.hhea_["descender"]
|
|
if "linegap" in self.hhea_:
|
|
table.lineGap = self.hhea_["linegap"]
|
|
|
|
def build_vhea(self):
|
|
if not self.vhea_:
|
|
return
|
|
table = self.font.get("vhea")
|
|
if not table: # this only happens for unit tests
|
|
table = self.font["vhea"] = newTable("vhea")
|
|
table.decompile(b"\0" * 36, self.font)
|
|
table.tableVersion = 0x00011000
|
|
if "verttypoascender" in self.vhea_:
|
|
table.ascent = self.vhea_["verttypoascender"]
|
|
if "verttypodescender" in self.vhea_:
|
|
table.descent = self.vhea_["verttypodescender"]
|
|
if "verttypolinegap" in self.vhea_:
|
|
table.lineGap = self.vhea_["verttypolinegap"]
|
|
|
|
def get_user_name_id(self, table):
|
|
# Try to find first unused font-specific name id
|
|
nameIDs = [name.nameID for name in table.names]
|
|
for user_name_id in range(256, 32767):
|
|
if user_name_id not in nameIDs:
|
|
return user_name_id
|
|
|
|
def buildFeatureParams(self, tag):
|
|
params = None
|
|
if tag == "size":
|
|
params = otTables.FeatureParamsSize()
|
|
(
|
|
params.DesignSize,
|
|
params.SubfamilyID,
|
|
params.RangeStart,
|
|
params.RangeEnd,
|
|
) = self.size_parameters_
|
|
if tag in self.featureNames_ids_:
|
|
params.SubfamilyNameID = self.featureNames_ids_[tag]
|
|
else:
|
|
params.SubfamilyNameID = 0
|
|
elif tag in self.featureNames_:
|
|
if not self.featureNames_ids_:
|
|
# name table wasn't selected among the tables to build; skip
|
|
pass
|
|
else:
|
|
assert tag in self.featureNames_ids_
|
|
params = otTables.FeatureParamsStylisticSet()
|
|
params.Version = 0
|
|
params.UINameID = self.featureNames_ids_[tag]
|
|
elif tag in self.cv_parameters_:
|
|
params = otTables.FeatureParamsCharacterVariants()
|
|
params.Format = 0
|
|
params.FeatUILabelNameID = self.cv_parameters_ids_.get(
|
|
(tag, "FeatUILabelNameID"), 0
|
|
)
|
|
params.FeatUITooltipTextNameID = self.cv_parameters_ids_.get(
|
|
(tag, "FeatUITooltipTextNameID"), 0
|
|
)
|
|
params.SampleTextNameID = self.cv_parameters_ids_.get(
|
|
(tag, "SampleTextNameID"), 0
|
|
)
|
|
params.NumNamedParameters = self.cv_num_named_params_.get(tag, 0)
|
|
params.FirstParamUILabelNameID = self.cv_parameters_ids_.get(
|
|
(tag, "ParamUILabelNameID_0"), 0
|
|
)
|
|
params.CharCount = len(self.cv_characters_[tag])
|
|
params.Character = self.cv_characters_[tag]
|
|
return params
|
|
|
|
def build_name(self):
|
|
if not self.names_:
|
|
return
|
|
table = self.font.get("name")
|
|
if not table: # this only happens for unit tests
|
|
table = self.font["name"] = newTable("name")
|
|
table.names = []
|
|
for name in self.names_:
|
|
nameID, platformID, platEncID, langID, string = name
|
|
# For featureNames block, nameID is 'feature tag'
|
|
# For cvParameters blocks, nameID is ('feature tag', 'block name')
|
|
if not isinstance(nameID, int):
|
|
tag = nameID
|
|
if tag in self.featureNames_:
|
|
if tag not in self.featureNames_ids_:
|
|
self.featureNames_ids_[tag] = self.get_user_name_id(table)
|
|
assert self.featureNames_ids_[tag] is not None
|
|
nameID = self.featureNames_ids_[tag]
|
|
elif tag[0] in self.cv_parameters_:
|
|
if tag not in self.cv_parameters_ids_:
|
|
self.cv_parameters_ids_[tag] = self.get_user_name_id(table)
|
|
assert self.cv_parameters_ids_[tag] is not None
|
|
nameID = self.cv_parameters_ids_[tag]
|
|
table.setName(string, nameID, platformID, platEncID, langID)
|
|
table.names.sort()
|
|
|
|
def build_OS_2(self):
|
|
if not self.os2_:
|
|
return
|
|
table = self.font.get("OS/2")
|
|
if not table: # this only happens for unit tests
|
|
table = self.font["OS/2"] = newTable("OS/2")
|
|
data = b"\0" * sstruct.calcsize(getTableModule("OS/2").OS2_format_0)
|
|
table.decompile(data, self.font)
|
|
version = 0
|
|
if "fstype" in self.os2_:
|
|
table.fsType = self.os2_["fstype"]
|
|
if "panose" in self.os2_:
|
|
panose = getTableModule("OS/2").Panose()
|
|
(
|
|
panose.bFamilyType,
|
|
panose.bSerifStyle,
|
|
panose.bWeight,
|
|
panose.bProportion,
|
|
panose.bContrast,
|
|
panose.bStrokeVariation,
|
|
panose.bArmStyle,
|
|
panose.bLetterForm,
|
|
panose.bMidline,
|
|
panose.bXHeight,
|
|
) = self.os2_["panose"]
|
|
table.panose = panose
|
|
if "typoascender" in self.os2_:
|
|
table.sTypoAscender = self.os2_["typoascender"]
|
|
if "typodescender" in self.os2_:
|
|
table.sTypoDescender = self.os2_["typodescender"]
|
|
if "typolinegap" in self.os2_:
|
|
table.sTypoLineGap = self.os2_["typolinegap"]
|
|
if "winascent" in self.os2_:
|
|
table.usWinAscent = self.os2_["winascent"]
|
|
if "windescent" in self.os2_:
|
|
table.usWinDescent = self.os2_["windescent"]
|
|
if "vendor" in self.os2_:
|
|
table.achVendID = safeEval("'''" + self.os2_["vendor"] + "'''")
|
|
if "weightclass" in self.os2_:
|
|
table.usWeightClass = self.os2_["weightclass"]
|
|
if "widthclass" in self.os2_:
|
|
table.usWidthClass = self.os2_["widthclass"]
|
|
if "unicoderange" in self.os2_:
|
|
table.setUnicodeRanges(self.os2_["unicoderange"])
|
|
if "codepagerange" in self.os2_:
|
|
pages = self.build_codepages_(self.os2_["codepagerange"])
|
|
table.ulCodePageRange1, table.ulCodePageRange2 = pages
|
|
version = 1
|
|
if "xheight" in self.os2_:
|
|
table.sxHeight = self.os2_["xheight"]
|
|
version = 2
|
|
if "capheight" in self.os2_:
|
|
table.sCapHeight = self.os2_["capheight"]
|
|
version = 2
|
|
if "loweropsize" in self.os2_:
|
|
table.usLowerOpticalPointSize = self.os2_["loweropsize"]
|
|
version = 5
|
|
if "upperopsize" in self.os2_:
|
|
table.usUpperOpticalPointSize = self.os2_["upperopsize"]
|
|
version = 5
|
|
|
|
def checkattr(table, attrs):
|
|
for attr in attrs:
|
|
if not hasattr(table, attr):
|
|
setattr(table, attr, 0)
|
|
|
|
table.version = max(version, table.version)
|
|
# this only happens for unit tests
|
|
if version >= 1:
|
|
checkattr(table, ("ulCodePageRange1", "ulCodePageRange2"))
|
|
if version >= 2:
|
|
checkattr(
|
|
table,
|
|
(
|
|
"sxHeight",
|
|
"sCapHeight",
|
|
"usDefaultChar",
|
|
"usBreakChar",
|
|
"usMaxContext",
|
|
),
|
|
)
|
|
if version >= 5:
|
|
checkattr(table, ("usLowerOpticalPointSize", "usUpperOpticalPointSize"))
|
|
|
|
def setElidedFallbackName(self, value, location):
|
|
# ElidedFallbackName is a convenience method for setting
|
|
# ElidedFallbackNameID so only one can be allowed
|
|
for token in ("ElidedFallbackName", "ElidedFallbackNameID"):
|
|
if token in self.stat_:
|
|
raise FeatureLibError(
|
|
f"{token} is already set.",
|
|
location,
|
|
)
|
|
if isinstance(value, int):
|
|
self.stat_["ElidedFallbackNameID"] = value
|
|
elif isinstance(value, list):
|
|
self.stat_["ElidedFallbackName"] = value
|
|
else:
|
|
raise AssertionError(value)
|
|
|
|
def addDesignAxis(self, designAxis, location):
|
|
if "DesignAxes" not in self.stat_:
|
|
self.stat_["DesignAxes"] = []
|
|
if designAxis.tag in (r.tag for r in self.stat_["DesignAxes"]):
|
|
raise FeatureLibError(
|
|
f'DesignAxis already defined for tag "{designAxis.tag}".',
|
|
location,
|
|
)
|
|
if designAxis.axisOrder in (r.axisOrder for r in self.stat_["DesignAxes"]):
|
|
raise FeatureLibError(
|
|
f"DesignAxis already defined for axis number {designAxis.axisOrder}.",
|
|
location,
|
|
)
|
|
self.stat_["DesignAxes"].append(designAxis)
|
|
|
|
def addAxisValueRecord(self, axisValueRecord, location):
|
|
if "AxisValueRecords" not in self.stat_:
|
|
self.stat_["AxisValueRecords"] = []
|
|
# Check for duplicate AxisValueRecords
|
|
for record_ in self.stat_["AxisValueRecords"]:
|
|
if (
|
|
{n.asFea() for n in record_.names}
|
|
== {n.asFea() for n in axisValueRecord.names}
|
|
and {n.asFea() for n in record_.locations}
|
|
== {n.asFea() for n in axisValueRecord.locations}
|
|
and record_.flags == axisValueRecord.flags
|
|
):
|
|
raise FeatureLibError(
|
|
"An AxisValueRecord with these values is already defined.",
|
|
location,
|
|
)
|
|
self.stat_["AxisValueRecords"].append(axisValueRecord)
|
|
|
|
def build_STAT(self):
|
|
if not self.stat_:
|
|
return
|
|
|
|
axes = self.stat_.get("DesignAxes")
|
|
if not axes:
|
|
raise FeatureLibError("DesignAxes not defined", None)
|
|
axisValueRecords = self.stat_.get("AxisValueRecords")
|
|
axisValues = {}
|
|
format4_locations = []
|
|
for tag in axes:
|
|
axisValues[tag.tag] = []
|
|
if axisValueRecords is not None:
|
|
for avr in axisValueRecords:
|
|
valuesDict = {}
|
|
if avr.flags > 0:
|
|
valuesDict["flags"] = avr.flags
|
|
if len(avr.locations) == 1:
|
|
location = avr.locations[0]
|
|
values = location.values
|
|
if len(values) == 1: # format1
|
|
valuesDict.update({"value": values[0], "name": avr.names})
|
|
if len(values) == 2: # format3
|
|
valuesDict.update(
|
|
{
|
|
"value": values[0],
|
|
"linkedValue": values[1],
|
|
"name": avr.names,
|
|
}
|
|
)
|
|
if len(values) == 3: # format2
|
|
nominal, minVal, maxVal = values
|
|
valuesDict.update(
|
|
{
|
|
"nominalValue": nominal,
|
|
"rangeMinValue": minVal,
|
|
"rangeMaxValue": maxVal,
|
|
"name": avr.names,
|
|
}
|
|
)
|
|
axisValues[location.tag].append(valuesDict)
|
|
else:
|
|
valuesDict.update(
|
|
{
|
|
"location": {i.tag: i.values[0] for i in avr.locations},
|
|
"name": avr.names,
|
|
}
|
|
)
|
|
format4_locations.append(valuesDict)
|
|
|
|
designAxes = [
|
|
{
|
|
"ordering": a.axisOrder,
|
|
"tag": a.tag,
|
|
"name": a.names,
|
|
"values": axisValues[a.tag],
|
|
}
|
|
for a in axes
|
|
]
|
|
|
|
nameTable = self.font.get("name")
|
|
if not nameTable: # this only happens for unit tests
|
|
nameTable = self.font["name"] = newTable("name")
|
|
nameTable.names = []
|
|
|
|
if "ElidedFallbackNameID" in self.stat_:
|
|
nameID = self.stat_["ElidedFallbackNameID"]
|
|
name = nameTable.getDebugName(nameID)
|
|
if not name:
|
|
raise FeatureLibError(
|
|
f"ElidedFallbackNameID {nameID} points "
|
|
"to a nameID that does not exist in the "
|
|
'"name" table',
|
|
None,
|
|
)
|
|
elif "ElidedFallbackName" in self.stat_:
|
|
nameID = self.stat_["ElidedFallbackName"]
|
|
|
|
otl.buildStatTable(
|
|
self.font,
|
|
designAxes,
|
|
locations=format4_locations,
|
|
elidedFallbackName=nameID,
|
|
)
|
|
|
|
def build_codepages_(self, pages):
|
|
pages2bits = {
|
|
1252: 0,
|
|
1250: 1,
|
|
1251: 2,
|
|
1253: 3,
|
|
1254: 4,
|
|
1255: 5,
|
|
1256: 6,
|
|
1257: 7,
|
|
1258: 8,
|
|
874: 16,
|
|
932: 17,
|
|
936: 18,
|
|
949: 19,
|
|
950: 20,
|
|
1361: 21,
|
|
869: 48,
|
|
866: 49,
|
|
865: 50,
|
|
864: 51,
|
|
863: 52,
|
|
862: 53,
|
|
861: 54,
|
|
860: 55,
|
|
857: 56,
|
|
855: 57,
|
|
852: 58,
|
|
775: 59,
|
|
737: 60,
|
|
708: 61,
|
|
850: 62,
|
|
437: 63,
|
|
}
|
|
bits = [pages2bits[p] for p in pages if p in pages2bits]
|
|
pages = []
|
|
for i in range(2):
|
|
pages.append("")
|
|
for j in range(i * 32, (i + 1) * 32):
|
|
if j in bits:
|
|
pages[i] += "1"
|
|
else:
|
|
pages[i] += "0"
|
|
return [binary2num(p[::-1]) for p in pages]
|
|
|
|
def buildBASE(self):
|
|
if not self.base_horiz_axis_ and not self.base_vert_axis_:
|
|
return None
|
|
base = otTables.BASE()
|
|
base.Version = 0x00010000
|
|
base.HorizAxis = self.buildBASEAxis(self.base_horiz_axis_)
|
|
base.VertAxis = self.buildBASEAxis(self.base_vert_axis_)
|
|
|
|
result = newTable("BASE")
|
|
result.table = base
|
|
return result
|
|
|
|
def buildBASEAxis(self, axis):
|
|
if not axis:
|
|
return
|
|
bases, scripts = axis
|
|
axis = otTables.Axis()
|
|
axis.BaseTagList = otTables.BaseTagList()
|
|
axis.BaseTagList.BaselineTag = bases
|
|
axis.BaseTagList.BaseTagCount = len(bases)
|
|
axis.BaseScriptList = otTables.BaseScriptList()
|
|
axis.BaseScriptList.BaseScriptRecord = []
|
|
axis.BaseScriptList.BaseScriptCount = len(scripts)
|
|
for script in sorted(scripts):
|
|
record = otTables.BaseScriptRecord()
|
|
record.BaseScriptTag = script[0]
|
|
record.BaseScript = otTables.BaseScript()
|
|
record.BaseScript.BaseLangSysCount = 0
|
|
record.BaseScript.BaseValues = otTables.BaseValues()
|
|
record.BaseScript.BaseValues.DefaultIndex = bases.index(script[1])
|
|
record.BaseScript.BaseValues.BaseCoord = []
|
|
record.BaseScript.BaseValues.BaseCoordCount = len(script[2])
|
|
for c in script[2]:
|
|
coord = otTables.BaseCoord()
|
|
coord.Format = 1
|
|
coord.Coordinate = c
|
|
record.BaseScript.BaseValues.BaseCoord.append(coord)
|
|
axis.BaseScriptList.BaseScriptRecord.append(record)
|
|
return axis
|
|
|
|
def buildGDEF(self):
|
|
gdef = otTables.GDEF()
|
|
gdef.GlyphClassDef = self.buildGDEFGlyphClassDef_()
|
|
gdef.AttachList = otl.buildAttachList(self.attachPoints_, self.glyphMap)
|
|
gdef.LigCaretList = otl.buildLigCaretList(
|
|
self.ligCaretCoords_, self.ligCaretPoints_, self.glyphMap
|
|
)
|
|
gdef.MarkAttachClassDef = self.buildGDEFMarkAttachClassDef_()
|
|
gdef.MarkGlyphSetsDef = self.buildGDEFMarkGlyphSetsDef_()
|
|
gdef.Version = 0x00010002 if gdef.MarkGlyphSetsDef else 0x00010000
|
|
if self.varstorebuilder:
|
|
store = self.varstorebuilder.finish()
|
|
if store:
|
|
gdef.Version = 0x00010003
|
|
gdef.VarStore = store
|
|
varidx_map = store.optimize()
|
|
|
|
gdef.remap_device_varidxes(varidx_map)
|
|
if "GPOS" in self.font:
|
|
self.font["GPOS"].table.remap_device_varidxes(varidx_map)
|
|
self.model_cache.clear()
|
|
if any(
|
|
(
|
|
gdef.GlyphClassDef,
|
|
gdef.AttachList,
|
|
gdef.LigCaretList,
|
|
gdef.MarkAttachClassDef,
|
|
gdef.MarkGlyphSetsDef,
|
|
)
|
|
) or hasattr(gdef, "VarStore"):
|
|
result = newTable("GDEF")
|
|
result.table = gdef
|
|
return result
|
|
else:
|
|
return None
|
|
|
|
def buildGDEFGlyphClassDef_(self):
|
|
if self.glyphClassDefs_:
|
|
classes = {g: c for (g, (c, _)) in self.glyphClassDefs_.items()}
|
|
else:
|
|
classes = {}
|
|
for lookup in self.lookups_:
|
|
classes.update(lookup.inferGlyphClasses())
|
|
for markClass in self.parseTree.markClasses.values():
|
|
for markClassDef in markClass.definitions:
|
|
for glyph in markClassDef.glyphSet():
|
|
classes[glyph] = 3
|
|
if classes:
|
|
result = otTables.GlyphClassDef()
|
|
result.classDefs = classes
|
|
return result
|
|
else:
|
|
return None
|
|
|
|
def buildGDEFMarkAttachClassDef_(self):
|
|
classDefs = {g: c for g, (c, _) in self.markAttach_.items()}
|
|
if not classDefs:
|
|
return None
|
|
result = otTables.MarkAttachClassDef()
|
|
result.classDefs = classDefs
|
|
return result
|
|
|
|
def buildGDEFMarkGlyphSetsDef_(self):
|
|
sets = []
|
|
for glyphs, id_ in sorted(
|
|
self.markFilterSets_.items(), key=lambda item: item[1]
|
|
):
|
|
sets.append(glyphs)
|
|
return otl.buildMarkGlyphSetsDef(sets, self.glyphMap)
|
|
|
|
def buildDebg(self):
|
|
if "Debg" not in self.font:
|
|
self.font["Debg"] = newTable("Debg")
|
|
self.font["Debg"].data = {}
|
|
self.font["Debg"].data[LOOKUP_DEBUG_INFO_KEY] = self.lookup_locations
|
|
|
|
def buildLookups_(self, tag):
|
|
assert tag in ("GPOS", "GSUB"), tag
|
|
for lookup in self.lookups_:
|
|
lookup.lookup_index = None
|
|
lookups = []
|
|
for lookup in self.lookups_:
|
|
if lookup.table != tag:
|
|
continue
|
|
lookup.lookup_index = len(lookups)
|
|
self.lookup_locations[tag][str(lookup.lookup_index)] = LookupDebugInfo(
|
|
location=str(lookup.location),
|
|
name=self.get_lookup_name_(lookup),
|
|
feature=None,
|
|
)
|
|
lookups.append(lookup)
|
|
otLookups = []
|
|
for l in lookups:
|
|
try:
|
|
otLookups.append(l.build())
|
|
except OpenTypeLibError as e:
|
|
raise FeatureLibError(str(e), e.location) from e
|
|
except Exception as e:
|
|
location = self.lookup_locations[tag][str(l.lookup_index)].location
|
|
raise FeatureLibError(str(e), location) from e
|
|
return otLookups
|
|
|
|
def makeTable(self, tag):
|
|
table = getattr(otTables, tag, None)()
|
|
table.Version = 0x00010000
|
|
table.ScriptList = otTables.ScriptList()
|
|
table.ScriptList.ScriptRecord = []
|
|
table.FeatureList = otTables.FeatureList()
|
|
table.FeatureList.FeatureRecord = []
|
|
table.LookupList = otTables.LookupList()
|
|
table.LookupList.Lookup = self.buildLookups_(tag)
|
|
|
|
# Build a table for mapping (tag, lookup_indices) to feature_index.
|
|
# For example, ('liga', (2,3,7)) --> 23.
|
|
feature_indices = {}
|
|
required_feature_indices = {} # ('latn', 'DEU') --> 23
|
|
scripts = {} # 'latn' --> {'DEU': [23, 24]} for feature #23,24
|
|
# Sort the feature table by feature tag:
|
|
# https://github.com/fonttools/fonttools/issues/568
|
|
sortFeatureTag = lambda f: (f[0][2], f[0][1], f[0][0], f[1])
|
|
for key, lookups in sorted(self.features_.items(), key=sortFeatureTag):
|
|
script, lang, feature_tag = key
|
|
# l.lookup_index will be None when a lookup is not needed
|
|
# for the table under construction. For example, substitution
|
|
# rules will have no lookup_index while building GPOS tables.
|
|
lookup_indices = tuple(
|
|
[l.lookup_index for l in lookups if l.lookup_index is not None]
|
|
)
|
|
|
|
size_feature = tag == "GPOS" and feature_tag == "size"
|
|
force_feature = self.any_feature_variations(feature_tag, tag)
|
|
if len(lookup_indices) == 0 and not size_feature and not force_feature:
|
|
continue
|
|
|
|
for ix in lookup_indices:
|
|
try:
|
|
self.lookup_locations[tag][str(ix)] = self.lookup_locations[tag][
|
|
str(ix)
|
|
]._replace(feature=key)
|
|
except KeyError:
|
|
warnings.warn(
|
|
"feaLib.Builder subclass needs upgrading to "
|
|
"stash debug information. See fonttools#2065."
|
|
)
|
|
|
|
feature_key = (feature_tag, lookup_indices)
|
|
feature_index = feature_indices.get(feature_key)
|
|
if feature_index is None:
|
|
feature_index = len(table.FeatureList.FeatureRecord)
|
|
frec = otTables.FeatureRecord()
|
|
frec.FeatureTag = feature_tag
|
|
frec.Feature = otTables.Feature()
|
|
frec.Feature.FeatureParams = self.buildFeatureParams(feature_tag)
|
|
frec.Feature.LookupListIndex = list(lookup_indices)
|
|
frec.Feature.LookupCount = len(lookup_indices)
|
|
table.FeatureList.FeatureRecord.append(frec)
|
|
feature_indices[feature_key] = feature_index
|
|
scripts.setdefault(script, {}).setdefault(lang, []).append(feature_index)
|
|
if self.required_features_.get((script, lang)) == feature_tag:
|
|
required_feature_indices[(script, lang)] = feature_index
|
|
|
|
# Build ScriptList.
|
|
for script, lang_features in sorted(scripts.items()):
|
|
srec = otTables.ScriptRecord()
|
|
srec.ScriptTag = script
|
|
srec.Script = otTables.Script()
|
|
srec.Script.DefaultLangSys = None
|
|
srec.Script.LangSysRecord = []
|
|
for lang, feature_indices in sorted(lang_features.items()):
|
|
langrec = otTables.LangSysRecord()
|
|
langrec.LangSys = otTables.LangSys()
|
|
langrec.LangSys.LookupOrder = None
|
|
|
|
req_feature_index = required_feature_indices.get((script, lang))
|
|
if req_feature_index is None:
|
|
langrec.LangSys.ReqFeatureIndex = 0xFFFF
|
|
else:
|
|
langrec.LangSys.ReqFeatureIndex = req_feature_index
|
|
|
|
langrec.LangSys.FeatureIndex = [
|
|
i for i in feature_indices if i != req_feature_index
|
|
]
|
|
langrec.LangSys.FeatureCount = len(langrec.LangSys.FeatureIndex)
|
|
|
|
if lang == "dflt":
|
|
srec.Script.DefaultLangSys = langrec.LangSys
|
|
else:
|
|
langrec.LangSysTag = lang
|
|
srec.Script.LangSysRecord.append(langrec)
|
|
srec.Script.LangSysCount = len(srec.Script.LangSysRecord)
|
|
table.ScriptList.ScriptRecord.append(srec)
|
|
|
|
table.ScriptList.ScriptCount = len(table.ScriptList.ScriptRecord)
|
|
table.FeatureList.FeatureCount = len(table.FeatureList.FeatureRecord)
|
|
table.LookupList.LookupCount = len(table.LookupList.Lookup)
|
|
return table
|
|
|
|
def makeFeatureVariations(self, table, table_tag):
|
|
feature_vars = {}
|
|
has_any_variations = False
|
|
# Sort out which lookups to build, gather their indices
|
|
for (_, _, feature_tag), variations in self.feature_variations_.items():
|
|
feature_vars[feature_tag] = []
|
|
for conditionset, builders in variations.items():
|
|
raw_conditionset = self.conditionsets_[conditionset]
|
|
indices = []
|
|
for b in builders:
|
|
if b.table != table_tag:
|
|
continue
|
|
assert b.lookup_index is not None
|
|
indices.append(b.lookup_index)
|
|
has_any_variations = True
|
|
feature_vars[feature_tag].append((raw_conditionset, indices))
|
|
|
|
if has_any_variations:
|
|
for feature_tag, conditions_and_lookups in feature_vars.items():
|
|
addFeatureVariationsRaw(
|
|
self.font, table, conditions_and_lookups, feature_tag
|
|
)
|
|
|
|
def any_feature_variations(self, feature_tag, table_tag):
|
|
for (_, _, feature), variations in self.feature_variations_.items():
|
|
if feature != feature_tag:
|
|
continue
|
|
for conditionset, builders in variations.items():
|
|
if any(b.table == table_tag for b in builders):
|
|
return True
|
|
return False
|
|
|
|
def get_lookup_name_(self, lookup):
|
|
rev = {v: k for k, v in self.named_lookups_.items()}
|
|
if lookup in rev:
|
|
return rev[lookup]
|
|
return None
|
|
|
|
def add_language_system(self, location, script, language):
|
|
# OpenType Feature File Specification, section 4.b.i
|
|
if script == "DFLT" and language == "dflt" and self.default_language_systems_:
|
|
raise FeatureLibError(
|
|
'If "languagesystem DFLT dflt" is present, it must be '
|
|
"the first of the languagesystem statements",
|
|
location,
|
|
)
|
|
if script == "DFLT":
|
|
if self.seen_non_DFLT_script_:
|
|
raise FeatureLibError(
|
|
'languagesystems using the "DFLT" script tag must '
|
|
"precede all other languagesystems",
|
|
location,
|
|
)
|
|
else:
|
|
self.seen_non_DFLT_script_ = True
|
|
if (script, language) in self.default_language_systems_:
|
|
raise FeatureLibError(
|
|
'"languagesystem %s %s" has already been specified'
|
|
% (script.strip(), language.strip()),
|
|
location,
|
|
)
|
|
self.default_language_systems_.add((script, language))
|
|
|
|
def get_default_language_systems_(self):
|
|
# OpenType Feature File specification, 4.b.i. languagesystem:
|
|
# If no "languagesystem" statement is present, then the
|
|
# implementation must behave exactly as though the following
|
|
# statement were present at the beginning of the feature file:
|
|
# languagesystem DFLT dflt;
|
|
if self.default_language_systems_:
|
|
return frozenset(self.default_language_systems_)
|
|
else:
|
|
return frozenset({("DFLT", "dflt")})
|
|
|
|
def start_feature(self, location, name):
|
|
self.language_systems = self.get_default_language_systems_()
|
|
self.script_ = "DFLT"
|
|
self.cur_lookup_ = None
|
|
self.cur_feature_name_ = name
|
|
self.lookupflag_ = 0
|
|
self.lookupflag_markFilterSet_ = None
|
|
if name == "aalt":
|
|
self.aalt_location_ = location
|
|
|
|
def end_feature(self):
|
|
assert self.cur_feature_name_ is not None
|
|
self.cur_feature_name_ = None
|
|
self.language_systems = None
|
|
self.cur_lookup_ = None
|
|
self.lookupflag_ = 0
|
|
self.lookupflag_markFilterSet_ = None
|
|
|
|
def start_lookup_block(self, location, name):
|
|
if name in self.named_lookups_:
|
|
raise FeatureLibError(
|
|
'Lookup "%s" has already been defined' % name, location
|
|
)
|
|
if self.cur_feature_name_ == "aalt":
|
|
raise FeatureLibError(
|
|
"Lookup blocks cannot be placed inside 'aalt' features; "
|
|
"move it out, and then refer to it with a lookup statement",
|
|
location,
|
|
)
|
|
self.cur_lookup_name_ = name
|
|
self.named_lookups_[name] = None
|
|
self.cur_lookup_ = None
|
|
if self.cur_feature_name_ is None:
|
|
self.lookupflag_ = 0
|
|
self.lookupflag_markFilterSet_ = None
|
|
|
|
def end_lookup_block(self):
|
|
assert self.cur_lookup_name_ is not None
|
|
self.cur_lookup_name_ = None
|
|
self.cur_lookup_ = None
|
|
if self.cur_feature_name_ is None:
|
|
self.lookupflag_ = 0
|
|
self.lookupflag_markFilterSet_ = None
|
|
|
|
def add_lookup_call(self, lookup_name):
|
|
assert lookup_name in self.named_lookups_, lookup_name
|
|
self.cur_lookup_ = None
|
|
lookup = self.named_lookups_[lookup_name]
|
|
if lookup is not None: # skip empty named lookup
|
|
self.add_lookup_to_feature_(lookup, self.cur_feature_name_)
|
|
|
|
def set_font_revision(self, location, revision):
|
|
self.fontRevision_ = revision
|
|
|
|
def set_language(self, location, language, include_default, required):
|
|
assert len(language) == 4
|
|
if self.cur_feature_name_ in ("aalt", "size"):
|
|
raise FeatureLibError(
|
|
"Language statements are not allowed "
|
|
'within "feature %s"' % self.cur_feature_name_,
|
|
location,
|
|
)
|
|
if self.cur_feature_name_ is None:
|
|
raise FeatureLibError(
|
|
"Language statements are not allowed "
|
|
"within standalone lookup blocks",
|
|
location,
|
|
)
|
|
self.cur_lookup_ = None
|
|
|
|
key = (self.script_, language, self.cur_feature_name_)
|
|
lookups = self.features_.get((key[0], "dflt", key[2]))
|
|
if (language == "dflt" or include_default) and lookups:
|
|
self.features_[key] = lookups[:]
|
|
else:
|
|
self.features_[key] = []
|
|
self.language_systems = frozenset([(self.script_, language)])
|
|
|
|
if required:
|
|
key = (self.script_, language)
|
|
if key in self.required_features_:
|
|
raise FeatureLibError(
|
|
"Language %s (script %s) has already "
|
|
"specified feature %s as its required feature"
|
|
% (
|
|
language.strip(),
|
|
self.script_.strip(),
|
|
self.required_features_[key].strip(),
|
|
),
|
|
location,
|
|
)
|
|
self.required_features_[key] = self.cur_feature_name_
|
|
|
|
def getMarkAttachClass_(self, location, glyphs):
|
|
glyphs = frozenset(glyphs)
|
|
id_ = self.markAttachClassID_.get(glyphs)
|
|
if id_ is not None:
|
|
return id_
|
|
id_ = len(self.markAttachClassID_) + 1
|
|
self.markAttachClassID_[glyphs] = id_
|
|
for glyph in glyphs:
|
|
if glyph in self.markAttach_:
|
|
_, loc = self.markAttach_[glyph]
|
|
raise FeatureLibError(
|
|
"Glyph %s already has been assigned "
|
|
"a MarkAttachmentType at %s" % (glyph, loc),
|
|
location,
|
|
)
|
|
self.markAttach_[glyph] = (id_, location)
|
|
return id_
|
|
|
|
def getMarkFilterSet_(self, location, glyphs):
|
|
glyphs = frozenset(glyphs)
|
|
id_ = self.markFilterSets_.get(glyphs)
|
|
if id_ is not None:
|
|
return id_
|
|
id_ = len(self.markFilterSets_)
|
|
self.markFilterSets_[glyphs] = id_
|
|
return id_
|
|
|
|
def set_lookup_flag(self, location, value, markAttach, markFilter):
|
|
value = value & 0xFF
|
|
if markAttach:
|
|
markAttachClass = self.getMarkAttachClass_(location, markAttach)
|
|
value = value | (markAttachClass << 8)
|
|
if markFilter:
|
|
markFilterSet = self.getMarkFilterSet_(location, markFilter)
|
|
value = value | 0x10
|
|
self.lookupflag_markFilterSet_ = markFilterSet
|
|
else:
|
|
self.lookupflag_markFilterSet_ = None
|
|
self.lookupflag_ = value
|
|
|
|
def set_script(self, location, script):
|
|
if self.cur_feature_name_ in ("aalt", "size"):
|
|
raise FeatureLibError(
|
|
"Script statements are not allowed "
|
|
'within "feature %s"' % self.cur_feature_name_,
|
|
location,
|
|
)
|
|
if self.cur_feature_name_ is None:
|
|
raise FeatureLibError(
|
|
"Script statements are not allowed " "within standalone lookup blocks",
|
|
location,
|
|
)
|
|
if self.language_systems == {(script, "dflt")}:
|
|
# Nothing to do.
|
|
return
|
|
self.cur_lookup_ = None
|
|
self.script_ = script
|
|
self.lookupflag_ = 0
|
|
self.lookupflag_markFilterSet_ = None
|
|
self.set_language(location, "dflt", include_default=True, required=False)
|
|
|
|
def find_lookup_builders_(self, lookups):
|
|
"""Helper for building chain contextual substitutions
|
|
|
|
Given a list of lookup names, finds the LookupBuilder for each name.
|
|
If an input name is None, it gets mapped to a None LookupBuilder.
|
|
"""
|
|
lookup_builders = []
|
|
for lookuplist in lookups:
|
|
if lookuplist is not None:
|
|
lookup_builders.append(
|
|
[self.named_lookups_.get(l.name) for l in lookuplist]
|
|
)
|
|
else:
|
|
lookup_builders.append(None)
|
|
return lookup_builders
|
|
|
|
def add_attach_points(self, location, glyphs, contourPoints):
|
|
for glyph in glyphs:
|
|
self.attachPoints_.setdefault(glyph, set()).update(contourPoints)
|
|
|
|
def add_feature_reference(self, location, featureName):
|
|
if self.cur_feature_name_ != "aalt":
|
|
raise FeatureLibError(
|
|
'Feature references are only allowed inside "feature aalt"', location
|
|
)
|
|
self.aalt_features_.append((location, featureName))
|
|
|
|
def add_featureName(self, tag):
|
|
self.featureNames_.add(tag)
|
|
|
|
def add_cv_parameter(self, tag):
|
|
self.cv_parameters_.add(tag)
|
|
|
|
def add_to_cv_num_named_params(self, tag):
|
|
"""Adds new items to ``self.cv_num_named_params_``
|
|
or increments the count of existing items."""
|
|
if tag in self.cv_num_named_params_:
|
|
self.cv_num_named_params_[tag] += 1
|
|
else:
|
|
self.cv_num_named_params_[tag] = 1
|
|
|
|
def add_cv_character(self, character, tag):
|
|
self.cv_characters_[tag].append(character)
|
|
|
|
def set_base_axis(self, bases, scripts, vertical):
|
|
if vertical:
|
|
self.base_vert_axis_ = (bases, scripts)
|
|
else:
|
|
self.base_horiz_axis_ = (bases, scripts)
|
|
|
|
def set_size_parameters(
|
|
self, location, DesignSize, SubfamilyID, RangeStart, RangeEnd
|
|
):
|
|
if self.cur_feature_name_ != "size":
|
|
raise FeatureLibError(
|
|
"Parameters statements are not allowed "
|
|
'within "feature %s"' % self.cur_feature_name_,
|
|
location,
|
|
)
|
|
self.size_parameters_ = [DesignSize, SubfamilyID, RangeStart, RangeEnd]
|
|
for script, lang in self.language_systems:
|
|
key = (script, lang, self.cur_feature_name_)
|
|
self.features_.setdefault(key, [])
|
|
|
|
# GSUB rules
|
|
|
|
# GSUB 1
|
|
def add_single_subst(self, location, prefix, suffix, mapping, forceChain):
|
|
if self.cur_feature_name_ == "aalt":
|
|
for from_glyph, to_glyph in mapping.items():
|
|
alts = self.aalt_alternates_.setdefault(from_glyph, [])
|
|
if to_glyph not in alts:
|
|
alts.append(to_glyph)
|
|
return
|
|
if prefix or suffix or forceChain:
|
|
self.add_single_subst_chained_(location, prefix, suffix, mapping)
|
|
return
|
|
lookup = self.get_lookup_(location, SingleSubstBuilder)
|
|
for from_glyph, to_glyph in mapping.items():
|
|
if from_glyph in lookup.mapping:
|
|
if to_glyph == lookup.mapping[from_glyph]:
|
|
log.info(
|
|
"Removing duplicate single substitution from glyph"
|
|
' "%s" to "%s" at %s',
|
|
from_glyph,
|
|
to_glyph,
|
|
location,
|
|
)
|
|
else:
|
|
raise FeatureLibError(
|
|
'Already defined rule for replacing glyph "%s" by "%s"'
|
|
% (from_glyph, lookup.mapping[from_glyph]),
|
|
location,
|
|
)
|
|
lookup.mapping[from_glyph] = to_glyph
|
|
|
|
# GSUB 2
|
|
def add_multiple_subst(
|
|
self, location, prefix, glyph, suffix, replacements, forceChain=False
|
|
):
|
|
if prefix or suffix or forceChain:
|
|
chain = self.get_lookup_(location, ChainContextSubstBuilder)
|
|
sub = self.get_chained_lookup_(location, MultipleSubstBuilder)
|
|
sub.mapping[glyph] = replacements
|
|
chain.rules.append(ChainContextualRule(prefix, [{glyph}], suffix, [sub]))
|
|
return
|
|
lookup = self.get_lookup_(location, MultipleSubstBuilder)
|
|
if glyph in lookup.mapping:
|
|
if replacements == lookup.mapping[glyph]:
|
|
log.info(
|
|
"Removing duplicate multiple substitution from glyph"
|
|
' "%s" to %s%s',
|
|
glyph,
|
|
replacements,
|
|
f" at {location}" if location else "",
|
|
)
|
|
else:
|
|
raise FeatureLibError(
|
|
'Already defined substitution for glyph "%s"' % glyph, location
|
|
)
|
|
lookup.mapping[glyph] = replacements
|
|
|
|
# GSUB 3
|
|
def add_alternate_subst(self, location, prefix, glyph, suffix, replacement):
|
|
if self.cur_feature_name_ == "aalt":
|
|
alts = self.aalt_alternates_.setdefault(glyph, [])
|
|
alts.extend(g for g in replacement if g not in alts)
|
|
return
|
|
if prefix or suffix:
|
|
chain = self.get_lookup_(location, ChainContextSubstBuilder)
|
|
lookup = self.get_chained_lookup_(location, AlternateSubstBuilder)
|
|
chain.rules.append(ChainContextualRule(prefix, [{glyph}], suffix, [lookup]))
|
|
else:
|
|
lookup = self.get_lookup_(location, AlternateSubstBuilder)
|
|
if glyph in lookup.alternates:
|
|
raise FeatureLibError(
|
|
'Already defined alternates for glyph "%s"' % glyph, location
|
|
)
|
|
# We allow empty replacement glyphs here.
|
|
lookup.alternates[glyph] = replacement
|
|
|
|
# GSUB 4
|
|
def add_ligature_subst(
|
|
self, location, prefix, glyphs, suffix, replacement, forceChain
|
|
):
|
|
if prefix or suffix or forceChain:
|
|
chain = self.get_lookup_(location, ChainContextSubstBuilder)
|
|
lookup = self.get_chained_lookup_(location, LigatureSubstBuilder)
|
|
chain.rules.append(ChainContextualRule(prefix, glyphs, suffix, [lookup]))
|
|
else:
|
|
lookup = self.get_lookup_(location, LigatureSubstBuilder)
|
|
|
|
if not all(glyphs):
|
|
raise FeatureLibError("Empty glyph class in substitution", location)
|
|
|
|
# OpenType feature file syntax, section 5.d, "Ligature substitution":
|
|
# "Since the OpenType specification does not allow ligature
|
|
# substitutions to be specified on target sequences that contain
|
|
# glyph classes, the implementation software will enumerate
|
|
# all specific glyph sequences if glyph classes are detected"
|
|
for g in itertools.product(*glyphs):
|
|
lookup.ligatures[g] = replacement
|
|
|
|
# GSUB 5/6
|
|
def add_chain_context_subst(self, location, prefix, glyphs, suffix, lookups):
|
|
if not all(glyphs) or not all(prefix) or not all(suffix):
|
|
raise FeatureLibError(
|
|
"Empty glyph class in contextual substitution", location
|
|
)
|
|
lookup = self.get_lookup_(location, ChainContextSubstBuilder)
|
|
lookup.rules.append(
|
|
ChainContextualRule(
|
|
prefix, glyphs, suffix, self.find_lookup_builders_(lookups)
|
|
)
|
|
)
|
|
|
|
def add_single_subst_chained_(self, location, prefix, suffix, mapping):
|
|
if not mapping or not all(prefix) or not all(suffix):
|
|
raise FeatureLibError(
|
|
"Empty glyph class in contextual substitution", location
|
|
)
|
|
# https://github.com/fonttools/fonttools/issues/512
|
|
# https://github.com/fonttools/fonttools/issues/2150
|
|
chain = self.get_lookup_(location, ChainContextSubstBuilder)
|
|
sub = chain.find_chainable_single_subst(mapping)
|
|
if sub is None:
|
|
sub = self.get_chained_lookup_(location, SingleSubstBuilder)
|
|
sub.mapping.update(mapping)
|
|
chain.rules.append(
|
|
ChainContextualRule(prefix, [list(mapping.keys())], suffix, [sub])
|
|
)
|
|
|
|
# GSUB 8
|
|
def add_reverse_chain_single_subst(self, location, old_prefix, old_suffix, mapping):
|
|
if not mapping:
|
|
raise FeatureLibError("Empty glyph class in substitution", location)
|
|
lookup = self.get_lookup_(location, ReverseChainSingleSubstBuilder)
|
|
lookup.rules.append((old_prefix, old_suffix, mapping))
|
|
|
|
# GPOS rules
|
|
|
|
# GPOS 1
|
|
def add_single_pos(self, location, prefix, suffix, pos, forceChain):
|
|
if prefix or suffix or forceChain:
|
|
self.add_single_pos_chained_(location, prefix, suffix, pos)
|
|
else:
|
|
lookup = self.get_lookup_(location, SinglePosBuilder)
|
|
for glyphs, value in pos:
|
|
if not glyphs:
|
|
raise FeatureLibError(
|
|
"Empty glyph class in positioning rule", location
|
|
)
|
|
otValueRecord = self.makeOpenTypeValueRecord(
|
|
location, value, pairPosContext=False
|
|
)
|
|
for glyph in glyphs:
|
|
try:
|
|
lookup.add_pos(location, glyph, otValueRecord)
|
|
except OpenTypeLibError as e:
|
|
raise FeatureLibError(str(e), e.location) from e
|
|
|
|
# GPOS 2
|
|
def add_class_pair_pos(self, location, glyphclass1, value1, glyphclass2, value2):
|
|
if not glyphclass1 or not glyphclass2:
|
|
raise FeatureLibError("Empty glyph class in positioning rule", location)
|
|
lookup = self.get_lookup_(location, PairPosBuilder)
|
|
v1 = self.makeOpenTypeValueRecord(location, value1, pairPosContext=True)
|
|
v2 = self.makeOpenTypeValueRecord(location, value2, pairPosContext=True)
|
|
lookup.addClassPair(location, glyphclass1, v1, glyphclass2, v2)
|
|
|
|
def add_specific_pair_pos(self, location, glyph1, value1, glyph2, value2):
|
|
if not glyph1 or not glyph2:
|
|
raise FeatureLibError("Empty glyph class in positioning rule", location)
|
|
lookup = self.get_lookup_(location, PairPosBuilder)
|
|
v1 = self.makeOpenTypeValueRecord(location, value1, pairPosContext=True)
|
|
v2 = self.makeOpenTypeValueRecord(location, value2, pairPosContext=True)
|
|
lookup.addGlyphPair(location, glyph1, v1, glyph2, v2)
|
|
|
|
# GPOS 3
|
|
def add_cursive_pos(self, location, glyphclass, entryAnchor, exitAnchor):
|
|
if not glyphclass:
|
|
raise FeatureLibError("Empty glyph class in positioning rule", location)
|
|
lookup = self.get_lookup_(location, CursivePosBuilder)
|
|
lookup.add_attachment(
|
|
location,
|
|
glyphclass,
|
|
self.makeOpenTypeAnchor(location, entryAnchor),
|
|
self.makeOpenTypeAnchor(location, exitAnchor),
|
|
)
|
|
|
|
# GPOS 4
|
|
def add_mark_base_pos(self, location, bases, marks):
|
|
builder = self.get_lookup_(location, MarkBasePosBuilder)
|
|
self.add_marks_(location, builder, marks)
|
|
if not bases:
|
|
raise FeatureLibError("Empty glyph class in positioning rule", location)
|
|
for baseAnchor, markClass in marks:
|
|
otBaseAnchor = self.makeOpenTypeAnchor(location, baseAnchor)
|
|
for base in bases:
|
|
builder.bases.setdefault(base, {})[markClass.name] = otBaseAnchor
|
|
|
|
# GPOS 5
|
|
def add_mark_lig_pos(self, location, ligatures, components):
|
|
builder = self.get_lookup_(location, MarkLigPosBuilder)
|
|
componentAnchors = []
|
|
if not ligatures:
|
|
raise FeatureLibError("Empty glyph class in positioning rule", location)
|
|
for marks in components:
|
|
anchors = {}
|
|
self.add_marks_(location, builder, marks)
|
|
for ligAnchor, markClass in marks:
|
|
anchors[markClass.name] = self.makeOpenTypeAnchor(location, ligAnchor)
|
|
componentAnchors.append(anchors)
|
|
for glyph in ligatures:
|
|
builder.ligatures[glyph] = componentAnchors
|
|
|
|
# GPOS 6
|
|
def add_mark_mark_pos(self, location, baseMarks, marks):
|
|
builder = self.get_lookup_(location, MarkMarkPosBuilder)
|
|
self.add_marks_(location, builder, marks)
|
|
if not baseMarks:
|
|
raise FeatureLibError("Empty glyph class in positioning rule", location)
|
|
for baseAnchor, markClass in marks:
|
|
otBaseAnchor = self.makeOpenTypeAnchor(location, baseAnchor)
|
|
for baseMark in baseMarks:
|
|
builder.baseMarks.setdefault(baseMark, {})[
|
|
markClass.name
|
|
] = otBaseAnchor
|
|
|
|
# GPOS 7/8
|
|
def add_chain_context_pos(self, location, prefix, glyphs, suffix, lookups):
|
|
if not all(glyphs) or not all(prefix) or not all(suffix):
|
|
raise FeatureLibError(
|
|
"Empty glyph class in contextual positioning rule", location
|
|
)
|
|
lookup = self.get_lookup_(location, ChainContextPosBuilder)
|
|
lookup.rules.append(
|
|
ChainContextualRule(
|
|
prefix, glyphs, suffix, self.find_lookup_builders_(lookups)
|
|
)
|
|
)
|
|
|
|
def add_single_pos_chained_(self, location, prefix, suffix, pos):
|
|
if not pos or not all(prefix) or not all(suffix):
|
|
raise FeatureLibError(
|
|
"Empty glyph class in contextual positioning rule", location
|
|
)
|
|
# https://github.com/fonttools/fonttools/issues/514
|
|
chain = self.get_lookup_(location, ChainContextPosBuilder)
|
|
targets = []
|
|
for _, _, _, lookups in chain.rules:
|
|
targets.extend(lookups)
|
|
subs = []
|
|
for glyphs, value in pos:
|
|
if value is None:
|
|
subs.append(None)
|
|
continue
|
|
otValue = self.makeOpenTypeValueRecord(
|
|
location, value, pairPosContext=False
|
|
)
|
|
sub = chain.find_chainable_single_pos(targets, glyphs, otValue)
|
|
if sub is None:
|
|
sub = self.get_chained_lookup_(location, SinglePosBuilder)
|
|
targets.append(sub)
|
|
for glyph in glyphs:
|
|
sub.add_pos(location, glyph, otValue)
|
|
subs.append(sub)
|
|
assert len(pos) == len(subs), (pos, subs)
|
|
chain.rules.append(
|
|
ChainContextualRule(prefix, [g for g, v in pos], suffix, subs)
|
|
)
|
|
|
|
def add_marks_(self, location, lookupBuilder, marks):
|
|
"""Helper for add_mark_{base,liga,mark}_pos."""
|
|
for _, markClass in marks:
|
|
for markClassDef in markClass.definitions:
|
|
for mark in markClassDef.glyphs.glyphSet():
|
|
if mark not in lookupBuilder.marks:
|
|
otMarkAnchor = self.makeOpenTypeAnchor(
|
|
location, copy.deepcopy(markClassDef.anchor)
|
|
)
|
|
lookupBuilder.marks[mark] = (markClass.name, otMarkAnchor)
|
|
else:
|
|
existingMarkClass = lookupBuilder.marks[mark][0]
|
|
if markClass.name != existingMarkClass:
|
|
raise FeatureLibError(
|
|
"Glyph %s cannot be in both @%s and @%s"
|
|
% (mark, existingMarkClass, markClass.name),
|
|
location,
|
|
)
|
|
|
|
def add_subtable_break(self, location):
|
|
self.cur_lookup_.add_subtable_break(location)
|
|
|
|
def setGlyphClass_(self, location, glyph, glyphClass):
|
|
oldClass, oldLocation = self.glyphClassDefs_.get(glyph, (None, None))
|
|
if oldClass and oldClass != glyphClass:
|
|
raise FeatureLibError(
|
|
"Glyph %s was assigned to a different class at %s"
|
|
% (glyph, oldLocation),
|
|
location,
|
|
)
|
|
self.glyphClassDefs_[glyph] = (glyphClass, location)
|
|
|
|
def add_glyphClassDef(
|
|
self, location, baseGlyphs, ligatureGlyphs, markGlyphs, componentGlyphs
|
|
):
|
|
for glyph in baseGlyphs:
|
|
self.setGlyphClass_(location, glyph, 1)
|
|
for glyph in ligatureGlyphs:
|
|
self.setGlyphClass_(location, glyph, 2)
|
|
for glyph in markGlyphs:
|
|
self.setGlyphClass_(location, glyph, 3)
|
|
for glyph in componentGlyphs:
|
|
self.setGlyphClass_(location, glyph, 4)
|
|
|
|
def add_ligatureCaretByIndex_(self, location, glyphs, carets):
|
|
for glyph in glyphs:
|
|
if glyph not in self.ligCaretPoints_:
|
|
self.ligCaretPoints_[glyph] = carets
|
|
|
|
def makeLigCaret(self, location, caret):
|
|
if not isinstance(caret, VariableScalar):
|
|
return caret
|
|
default, device = self.makeVariablePos(location, caret)
|
|
if device is not None:
|
|
return (default, device)
|
|
return default
|
|
|
|
def add_ligatureCaretByPos_(self, location, glyphs, carets):
|
|
carets = [self.makeLigCaret(location, caret) for caret in carets]
|
|
for glyph in glyphs:
|
|
if glyph not in self.ligCaretCoords_:
|
|
self.ligCaretCoords_[glyph] = carets
|
|
|
|
def add_name_record(self, location, nameID, platformID, platEncID, langID, string):
|
|
self.names_.append([nameID, platformID, platEncID, langID, string])
|
|
|
|
def add_os2_field(self, key, value):
|
|
self.os2_[key] = value
|
|
|
|
def add_hhea_field(self, key, value):
|
|
self.hhea_[key] = value
|
|
|
|
def add_vhea_field(self, key, value):
|
|
self.vhea_[key] = value
|
|
|
|
def add_conditionset(self, location, key, value):
|
|
if "fvar" not in self.font:
|
|
raise FeatureLibError(
|
|
"Cannot add feature variations to a font without an 'fvar' table",
|
|
location,
|
|
)
|
|
|
|
# Normalize
|
|
axisMap = {
|
|
axis.axisTag: (axis.minValue, axis.defaultValue, axis.maxValue)
|
|
for axis in self.axes
|
|
}
|
|
|
|
value = {
|
|
tag: (
|
|
normalizeValue(bottom, axisMap[tag]),
|
|
normalizeValue(top, axisMap[tag]),
|
|
)
|
|
for tag, (bottom, top) in value.items()
|
|
}
|
|
|
|
# NOTE: This might result in rounding errors (off-by-ones) compared to
|
|
# rules in Designspace files, since we're working with what's in the
|
|
# `avar` table rather than the original values.
|
|
if "avar" in self.font:
|
|
mapping = self.font["avar"].segments
|
|
value = {
|
|
axis: tuple(
|
|
piecewiseLinearMap(v, mapping[axis]) if axis in mapping else v
|
|
for v in condition_range
|
|
)
|
|
for axis, condition_range in value.items()
|
|
}
|
|
|
|
self.conditionsets_[key] = value
|
|
|
|
def makeVariablePos(self, location, varscalar):
|
|
if not self.varstorebuilder:
|
|
raise FeatureLibError(
|
|
"Can't define a variable scalar in a non-variable font", location
|
|
)
|
|
|
|
varscalar.axes = self.axes
|
|
if not varscalar.does_vary:
|
|
return varscalar.default, None
|
|
|
|
default, index = varscalar.add_to_variation_store(
|
|
self.varstorebuilder, self.model_cache, self.font.get("avar")
|
|
)
|
|
|
|
device = None
|
|
if index is not None and index != 0xFFFFFFFF:
|
|
device = buildVarDevTable(index)
|
|
|
|
return default, device
|
|
|
|
def makeOpenTypeAnchor(self, location, anchor):
|
|
"""ast.Anchor --> otTables.Anchor"""
|
|
if anchor is None:
|
|
return None
|
|
variable = False
|
|
deviceX, deviceY = None, None
|
|
if anchor.xDeviceTable is not None:
|
|
deviceX = otl.buildDevice(dict(anchor.xDeviceTable))
|
|
if anchor.yDeviceTable is not None:
|
|
deviceY = otl.buildDevice(dict(anchor.yDeviceTable))
|
|
for dim in ("x", "y"):
|
|
varscalar = getattr(anchor, dim)
|
|
if not isinstance(varscalar, VariableScalar):
|
|
continue
|
|
if getattr(anchor, dim + "DeviceTable") is not None:
|
|
raise FeatureLibError(
|
|
"Can't define a device coordinate and variable scalar", location
|
|
)
|
|
default, device = self.makeVariablePos(location, varscalar)
|
|
setattr(anchor, dim, default)
|
|
if device is not None:
|
|
if dim == "x":
|
|
deviceX = device
|
|
else:
|
|
deviceY = device
|
|
variable = True
|
|
|
|
otlanchor = otl.buildAnchor(
|
|
anchor.x, anchor.y, anchor.contourpoint, deviceX, deviceY
|
|
)
|
|
if variable:
|
|
otlanchor.Format = 3
|
|
return otlanchor
|
|
|
|
_VALUEREC_ATTRS = {
|
|
name[0].lower() + name[1:]: (name, isDevice)
|
|
for _, name, isDevice, _ in otBase.valueRecordFormat
|
|
if not name.startswith("Reserved")
|
|
}
|
|
|
|
def makeOpenTypeValueRecord(self, location, v, pairPosContext):
|
|
"""ast.ValueRecord --> otBase.ValueRecord"""
|
|
if not v:
|
|
return None
|
|
|
|
vr = {}
|
|
for astName, (otName, isDevice) in self._VALUEREC_ATTRS.items():
|
|
val = getattr(v, astName, None)
|
|
if not val:
|
|
continue
|
|
if isDevice:
|
|
vr[otName] = otl.buildDevice(dict(val))
|
|
elif isinstance(val, VariableScalar):
|
|
otDeviceName = otName[0:4] + "Device"
|
|
feaDeviceName = otDeviceName[0].lower() + otDeviceName[1:]
|
|
if getattr(v, feaDeviceName):
|
|
raise FeatureLibError(
|
|
"Can't define a device coordinate and variable scalar", location
|
|
)
|
|
vr[otName], device = self.makeVariablePos(location, val)
|
|
if device is not None:
|
|
vr[otDeviceName] = device
|
|
else:
|
|
vr[otName] = val
|
|
|
|
if pairPosContext and not vr:
|
|
vr = {"YAdvance": 0} if v.vertical else {"XAdvance": 0}
|
|
valRec = otl.buildValue(vr)
|
|
return valRec
|