Spaces:
Running
Running
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. | |
# We also deduplicate lookup indices, as they only get applied once | |
# within a given feature: | |
# https://github.com/fonttools/fonttools/issues/2946 | |
lookup_indices = tuple( | |
dict.fromkeys( | |
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 | |