912 lines
36 KiB
Python
912 lines
36 KiB
Python
"""\
|
||
MS VOLT ``.vtp`` to AFDKO ``.fea`` OpenType Layout converter.
|
||
|
||
Usage
|
||
-----
|
||
|
||
To convert a VTP project file:
|
||
|
||
|
||
.. code-block:: sh
|
||
|
||
$ fonttools voltLib.voltToFea input.vtp output.fea
|
||
|
||
It is also possible convert font files with `TSIV` table (as saved from Volt),
|
||
in this case the glyph names used in the Volt project will be mapped to the
|
||
actual glyph names in the font files when written to the feature file:
|
||
|
||
.. code-block:: sh
|
||
|
||
$ fonttools voltLib.voltToFea input.ttf output.fea
|
||
|
||
The ``--quiet`` option can be used to suppress warnings.
|
||
|
||
The ``--traceback`` can be used to get Python traceback in case of exceptions,
|
||
instead of suppressing the traceback.
|
||
|
||
|
||
Limitations
|
||
-----------
|
||
|
||
* Not all VOLT features are supported, the script will error if it it
|
||
encounters something it does not understand. Please report an issue if this
|
||
happens.
|
||
* AFDKO feature file syntax for mark positioning is awkward and does not allow
|
||
setting the mark coverage. It also defines mark anchors globally, as a result
|
||
some mark positioning lookups might cover many marks than what was in the VOLT
|
||
file. This should not be an issue in practice, but if it is then the only way
|
||
is to modify the VOLT file or the generated feature file manually to use unique
|
||
mark anchors for each lookup.
|
||
* VOLT allows subtable breaks in any lookup type, but AFDKO feature file
|
||
implementations vary in their support; currently AFDKO’s makeOTF supports
|
||
subtable breaks in pair positioning lookups only, while FontTools’ feaLib
|
||
support it for most substitution lookups and only some positioning lookups.
|
||
"""
|
||
|
||
import logging
|
||
import re
|
||
from io import StringIO
|
||
from graphlib import TopologicalSorter
|
||
|
||
from fontTools.feaLib import ast
|
||
from fontTools.ttLib import TTFont, TTLibError
|
||
from fontTools.voltLib import ast as VAst
|
||
from fontTools.voltLib.parser import Parser as VoltParser
|
||
|
||
log = logging.getLogger("fontTools.voltLib.voltToFea")
|
||
|
||
TABLES = ["GDEF", "GSUB", "GPOS"]
|
||
|
||
|
||
def _flatten_group(group):
|
||
ret = []
|
||
if isinstance(group, (tuple, list)):
|
||
for item in group:
|
||
ret.extend(_flatten_group(item))
|
||
elif hasattr(group, "enum"):
|
||
ret.extend(_flatten_group(group.enum))
|
||
else:
|
||
ret.append(group)
|
||
return ret
|
||
|
||
|
||
# Topologically sort of group definitions to ensure that all groups are defined
|
||
# before they are referenced. This is necessary because FEA requires it but
|
||
# VOLT does not, see below.
|
||
def sort_groups(groups):
|
||
group_map = {group.name.lower(): group for group in groups}
|
||
graph = {
|
||
group.name.lower(): [
|
||
x.group.lower()
|
||
for x in _flatten_group(group)
|
||
if isinstance(x, VAst.GroupName)
|
||
]
|
||
for group in groups
|
||
}
|
||
sorter = TopologicalSorter(graph)
|
||
return [group_map[name] for name in sorter.static_order()]
|
||
|
||
|
||
class Lookup(ast.LookupBlock):
|
||
def __init__(self, name, use_extension=False, location=None):
|
||
super().__init__(name, use_extension, location)
|
||
self.chained = []
|
||
|
||
|
||
class VoltToFea:
|
||
_NOT_LOOKUP_NAME_RE = re.compile(r"[^A-Za-z_0-9.]")
|
||
_NOT_CLASS_NAME_RE = re.compile(r"[^A-Za-z_0-9.\-]")
|
||
|
||
def __init__(self, file_or_path, font=None):
|
||
if isinstance(file_or_path, VAst.VoltFile):
|
||
self._doc, self._file_or_path = file_or_path, None
|
||
else:
|
||
self._doc, self._file_or_path = None, file_or_path
|
||
self._font = font
|
||
|
||
self._glyph_map = {}
|
||
self._glyph_order = None
|
||
|
||
self._gdef = {}
|
||
self._glyphclasses = {}
|
||
self._features = {}
|
||
self._lookups = {}
|
||
|
||
self._marks = set()
|
||
self._ligatures = {}
|
||
|
||
self._markclasses = {}
|
||
self._anchors = {}
|
||
|
||
self._settings = {}
|
||
|
||
self._lookup_names = {}
|
||
self._class_names = {}
|
||
|
||
def _lookupName(self, name):
|
||
if name not in self._lookup_names:
|
||
res = self._NOT_LOOKUP_NAME_RE.sub("_", name)
|
||
while res in self._lookup_names.values():
|
||
res += "_"
|
||
self._lookup_names[name] = res
|
||
return self._lookup_names[name]
|
||
|
||
def _className(self, name):
|
||
if name not in self._class_names:
|
||
res = self._NOT_CLASS_NAME_RE.sub("_", name)
|
||
while res in self._class_names.values():
|
||
res += "_"
|
||
self._class_names[name] = res
|
||
return self._class_names[name]
|
||
|
||
def _collectStatements(self, doc, tables, ignore_unsupported_settings=False):
|
||
# Collect glyph difinitions first, as we need them to map VOLT glyph names to font glyph name.
|
||
for statement in doc.statements:
|
||
if isinstance(statement, VAst.GlyphDefinition):
|
||
self._glyphDefinition(statement)
|
||
|
||
# Collect and sort group definitions first, to make sure a group
|
||
# definition that references other groups comes after them since VOLT
|
||
# does not enforce such ordering, and feature file require it.
|
||
groups = [s for s in doc.statements if isinstance(s, VAst.GroupDefinition)]
|
||
for group in sort_groups(groups):
|
||
self._groupDefinition(group)
|
||
|
||
for statement in doc.statements:
|
||
if isinstance(statement, VAst.AnchorDefinition):
|
||
if "GPOS" in tables:
|
||
self._anchorDefinition(statement)
|
||
elif isinstance(statement, VAst.SettingDefinition):
|
||
self._settingDefinition(statement, ignore_unsupported_settings)
|
||
elif isinstance(statement, (VAst.GlyphDefinition, VAst.GroupDefinition)):
|
||
pass # Handled above
|
||
elif isinstance(statement, VAst.ScriptDefinition):
|
||
self._scriptDefinition(statement)
|
||
elif not isinstance(statement, VAst.LookupDefinition):
|
||
raise NotImplementedError(statement)
|
||
|
||
# Lookup definitions need to be handled last as they reference glyph
|
||
# and mark classes that might be defined after them.
|
||
for statement in doc.statements:
|
||
if isinstance(statement, VAst.LookupDefinition):
|
||
if statement.pos and "GPOS" not in tables:
|
||
continue
|
||
if statement.sub and "GSUB" not in tables:
|
||
continue
|
||
self._lookupDefinition(statement)
|
||
|
||
def _buildFeatureFile(self, tables):
|
||
doc = ast.FeatureFile()
|
||
statements = doc.statements
|
||
|
||
if self._glyphclasses:
|
||
statements.append(ast.Comment("# Glyph classes"))
|
||
statements.extend(self._glyphclasses.values())
|
||
|
||
if self._markclasses:
|
||
statements.append(ast.Comment("\n# Mark classes"))
|
||
statements.extend(c[1] for c in sorted(self._markclasses.items()))
|
||
|
||
if self._lookups:
|
||
statements.append(ast.Comment("\n# Lookups"))
|
||
for lookup in self._lookups.values():
|
||
statements.extend(lookup.chained)
|
||
statements.append(lookup)
|
||
|
||
# Prune features
|
||
features = self._features.copy()
|
||
for feature_tag in features:
|
||
scripts = features[feature_tag]
|
||
for script_tag in scripts:
|
||
langs = scripts[script_tag]
|
||
for language_tag in langs:
|
||
langs[language_tag] = [
|
||
l for l in langs[language_tag] if l.lower() in self._lookups
|
||
]
|
||
scripts[script_tag] = {t: l for t, l in langs.items() if l}
|
||
features[feature_tag] = {t: s for t, s in scripts.items() if s}
|
||
features = {t: f for t, f in features.items() if f}
|
||
|
||
if features:
|
||
statements.append(ast.Comment("# Features"))
|
||
for feature_tag, scripts in features.items():
|
||
feature = ast.FeatureBlock(feature_tag)
|
||
script_tags = sorted(scripts, key=lambda k: 0 if k == "DFLT" else 1)
|
||
if feature_tag == "aalt" and len(script_tags) > 1:
|
||
log.warning(
|
||
"FEA syntax does not allow script statements in 'aalt' feature, "
|
||
"so only lookups from the first script will be included."
|
||
)
|
||
script_tags = script_tags[:1]
|
||
for script_tag in script_tags:
|
||
if feature_tag != "aalt":
|
||
feature.statements.append(ast.ScriptStatement(script_tag))
|
||
language_tags = sorted(
|
||
scripts[script_tag],
|
||
key=lambda k: 0 if k == "dflt" else 1,
|
||
)
|
||
if feature_tag == "aalt" and len(language_tags) > 1:
|
||
log.warning(
|
||
"FEA syntax does not allow language statements in 'aalt' feature, "
|
||
"so only lookups from the first language will be included."
|
||
)
|
||
language_tags = language_tags[:1]
|
||
for language_tag in language_tags:
|
||
if feature_tag != "aalt":
|
||
include_default = True if language_tag == "dflt" else False
|
||
feature.statements.append(
|
||
ast.LanguageStatement(
|
||
language_tag.ljust(4),
|
||
include_default=include_default,
|
||
)
|
||
)
|
||
for name in scripts[script_tag][language_tag]:
|
||
lookup = self._lookups[name.lower()]
|
||
lookupref = ast.LookupReferenceStatement(lookup)
|
||
feature.statements.append(lookupref)
|
||
statements.append(feature)
|
||
|
||
if self._gdef and "GDEF" in tables:
|
||
classes = []
|
||
for name in ("BASE", "MARK", "LIGATURE", "COMPONENT"):
|
||
if name in self._gdef:
|
||
classname = "GDEF_" + name.lower()
|
||
glyphclass = ast.GlyphClassDefinition(classname, self._gdef[name])
|
||
statements.append(glyphclass)
|
||
classes.append(ast.GlyphClassName(glyphclass))
|
||
else:
|
||
classes.append(None)
|
||
|
||
gdef = ast.TableBlock("GDEF")
|
||
gdef.statements.append(ast.GlyphClassDefStatement(*classes))
|
||
statements.append(gdef)
|
||
|
||
return doc
|
||
|
||
def convert(self, tables=None, ignore_unsupported_settings=False):
|
||
if self._doc is None:
|
||
self._doc = VoltParser(self._file_or_path).parse()
|
||
doc = self._doc
|
||
|
||
if tables is None:
|
||
tables = TABLES
|
||
if self._font is not None:
|
||
self._glyph_order = self._font.getGlyphOrder()
|
||
|
||
self._collectStatements(doc, tables, ignore_unsupported_settings)
|
||
fea = self._buildFeatureFile(tables)
|
||
return fea.asFea()
|
||
|
||
def _glyphName(self, glyph):
|
||
try:
|
||
name = glyph.glyph
|
||
except AttributeError:
|
||
name = glyph
|
||
return ast.GlyphName(self._glyph_map.get(name, name))
|
||
|
||
def _groupName(self, group):
|
||
try:
|
||
name = group.group
|
||
except AttributeError:
|
||
name = group
|
||
return ast.GlyphClassName(self._glyphclasses[name.lower()])
|
||
|
||
def _glyphSet(self, item):
|
||
return [
|
||
(self._glyphName(x) if isinstance(x, (str, VAst.GlyphName)) else x)
|
||
for x in item.glyphSet()
|
||
]
|
||
|
||
def _coverage(self, coverage, flatten=False):
|
||
items = []
|
||
for item in coverage:
|
||
if isinstance(item, VAst.GlyphName):
|
||
items.append(self._glyphName(item))
|
||
elif isinstance(item, VAst.GroupName):
|
||
items.append(self._groupName(item))
|
||
elif isinstance(item, VAst.Enum):
|
||
item = self._coverage(item.enum, flatten=True)
|
||
if flatten:
|
||
items.extend(item)
|
||
else:
|
||
items.append(ast.GlyphClass(item))
|
||
elif isinstance(item, VAst.Range):
|
||
item = self._glyphSet(item)
|
||
if flatten:
|
||
items.extend(item)
|
||
else:
|
||
items.append(ast.GlyphClass(item))
|
||
else:
|
||
raise NotImplementedError(item)
|
||
return items
|
||
|
||
def _context(self, context):
|
||
out = []
|
||
for item in context:
|
||
coverage = self._coverage(item, flatten=True)
|
||
if len(coverage) > 1:
|
||
coverage = ast.GlyphClass(coverage)
|
||
else:
|
||
coverage = coverage[0]
|
||
out.append(coverage)
|
||
return out
|
||
|
||
def _groupDefinition(self, group):
|
||
name = self._className(group.name)
|
||
glyphs = self._coverage(group.enum.enum, flatten=True)
|
||
glyphclass = ast.GlyphClass(glyphs)
|
||
classdef = ast.GlyphClassDefinition(name, glyphclass)
|
||
self._glyphclasses[group.name.lower()] = classdef
|
||
|
||
def _glyphDefinition(self, glyph):
|
||
try:
|
||
self._glyph_map[glyph.name] = self._glyph_order[glyph.id]
|
||
except TypeError:
|
||
pass
|
||
|
||
if glyph.type in ("BASE", "MARK", "LIGATURE", "COMPONENT"):
|
||
if glyph.type not in self._gdef:
|
||
self._gdef[glyph.type] = ast.GlyphClass()
|
||
self._gdef[glyph.type].glyphs.append(self._glyphName(glyph.name))
|
||
|
||
if glyph.type == "MARK":
|
||
self._marks.add(glyph.name)
|
||
elif glyph.type == "LIGATURE":
|
||
self._ligatures[glyph.name] = glyph.components
|
||
|
||
def _scriptDefinition(self, script):
|
||
stag = script.tag
|
||
for lang in script.langs:
|
||
ltag = lang.tag
|
||
for feature in lang.features:
|
||
lookups = {l.split("\\")[0]: True for l in feature.lookups}
|
||
ftag = feature.tag
|
||
if ftag not in self._features:
|
||
self._features[ftag] = {}
|
||
if stag not in self._features[ftag]:
|
||
self._features[ftag][stag] = {}
|
||
assert ltag not in self._features[ftag][stag]
|
||
self._features[ftag][stag][ltag] = lookups.keys()
|
||
|
||
def _settingDefinition(self, setting, ignore_unsupported=False):
|
||
if setting.name.startswith("COMPILER_"):
|
||
self._settings[setting.name] = setting.value
|
||
elif not ignore_unsupported:
|
||
log.warning(f"Unsupported setting ignored: {setting.name}")
|
||
|
||
def _adjustment(self, adjustment):
|
||
adv, dx, dy, adv_adjust_by, dx_adjust_by, dy_adjust_by = adjustment
|
||
|
||
adv_device = adv_adjust_by and adv_adjust_by.items() or None
|
||
dx_device = dx_adjust_by and dx_adjust_by.items() or None
|
||
dy_device = dy_adjust_by and dy_adjust_by.items() or None
|
||
|
||
return ast.ValueRecord(
|
||
xPlacement=dx,
|
||
yPlacement=dy,
|
||
xAdvance=adv,
|
||
xPlaDevice=dx_device,
|
||
yPlaDevice=dy_device,
|
||
xAdvDevice=adv_device,
|
||
)
|
||
|
||
def _anchor(self, adjustment):
|
||
adv, dx, dy, adv_adjust_by, dx_adjust_by, dy_adjust_by = adjustment
|
||
|
||
assert not adv_adjust_by
|
||
dx_device = dx_adjust_by and dx_adjust_by.items() or None
|
||
dy_device = dy_adjust_by and dy_adjust_by.items() or None
|
||
|
||
return ast.Anchor(
|
||
dx or 0,
|
||
dy or 0,
|
||
xDeviceTable=dx_device or None,
|
||
yDeviceTable=dy_device or None,
|
||
)
|
||
|
||
def _anchorDefinition(self, anchordef):
|
||
anchorname = anchordef.name
|
||
glyphname = anchordef.glyph_name
|
||
anchor = self._anchor(anchordef.pos)
|
||
|
||
if glyphname not in self._anchors:
|
||
self._anchors[glyphname] = {}
|
||
if anchorname.startswith("MARK_"):
|
||
anchorname = anchorname[:5] + anchorname[5:].lower()
|
||
else:
|
||
anchorname = anchorname.lower()
|
||
if anchorname not in self._anchors[glyphname]:
|
||
self._anchors[glyphname][anchorname] = {}
|
||
self._anchors[glyphname][anchorname][anchordef.component] = anchor
|
||
|
||
def _gposLookup(self, lookup, fealookup):
|
||
statements = fealookup.statements
|
||
|
||
pos = lookup.pos
|
||
if isinstance(pos, VAst.PositionAdjustPairDefinition):
|
||
for (idx1, idx2), (pos1, pos2) in pos.adjust_pair.items():
|
||
coverage_1 = pos.coverages_1[idx1 - 1]
|
||
coverage_2 = pos.coverages_2[idx2 - 1]
|
||
|
||
# If not both are groups, use “enum pos” otherwise makeotf will
|
||
# fail.
|
||
enumerated = False
|
||
for item in coverage_1 + coverage_2:
|
||
if not isinstance(item, VAst.GroupName):
|
||
enumerated = True
|
||
|
||
glyphs1 = self._coverage(coverage_1)
|
||
glyphs2 = self._coverage(coverage_2)
|
||
record1 = self._adjustment(pos1)
|
||
record2 = self._adjustment(pos2)
|
||
assert len(glyphs1) == 1
|
||
assert len(glyphs2) == 1
|
||
statements.append(
|
||
ast.PairPosStatement(
|
||
glyphs1[0], record1, glyphs2[0], record2, enumerated=enumerated
|
||
)
|
||
)
|
||
elif isinstance(pos, VAst.PositionAdjustSingleDefinition):
|
||
for a, b in pos.adjust_single:
|
||
glyphs = self._coverage(a)
|
||
record = self._adjustment(b)
|
||
assert len(glyphs) == 1
|
||
statements.append(
|
||
ast.SinglePosStatement([(glyphs[0], record)], [], [], False)
|
||
)
|
||
elif isinstance(pos, VAst.PositionAttachDefinition):
|
||
anchors = {}
|
||
allmarks = set()
|
||
for coverage, anchorname in pos.coverage_to:
|
||
# In feature files mark classes are global, but in VOLT they
|
||
# are defined per-lookup. If we output mark class definitions
|
||
# for all marks that use a given anchor, we might end up with a
|
||
# mark used in two different classes in the same lookup, which
|
||
# is causes feature file compilation error.
|
||
# At the expense of uglier feature code, we make the mark class
|
||
# name by appending the current lookup name not the anchor
|
||
# name, and output mark class definitions only for marks used
|
||
# in this lookup.
|
||
classname = self._className(f"{anchorname}.{lookup.name}")
|
||
markclass = ast.MarkClass(classname)
|
||
|
||
# Anchor names are case-insensitive in VOLT
|
||
anchorname = anchorname.lower()
|
||
|
||
# We might still end in marks used in two different anchor
|
||
# classes, so we filter out already used marks.
|
||
marks = set()
|
||
for mark in coverage:
|
||
marks.update(mark.glyphSet())
|
||
if not marks.isdisjoint(allmarks):
|
||
marks.difference_update(allmarks)
|
||
if not marks:
|
||
continue
|
||
allmarks.update(marks)
|
||
|
||
for glyphname in marks:
|
||
glyph = self._glyphName(glyphname)
|
||
anchor = self._anchors[glyphname][f"MARK_{anchorname}"][1]
|
||
markdef = ast.MarkClassDefinition(markclass, anchor, glyph)
|
||
self._markclasses[(glyphname, classname)] = markdef
|
||
|
||
for base in pos.coverage:
|
||
for name in base.glyphSet():
|
||
if name not in anchors:
|
||
anchors[name] = []
|
||
if (anchorname, classname) not in anchors[name]:
|
||
anchors[name].append((anchorname, classname))
|
||
|
||
is_ligature = all(n in self._ligatures for n in anchors)
|
||
is_mark = all(n in self._marks for n in anchors)
|
||
for name in anchors:
|
||
components = 1
|
||
if is_ligature:
|
||
components = self._ligatures[name]
|
||
|
||
marks = [[] for _ in range(components)]
|
||
for mark, classname in anchors[name]:
|
||
markclass = ast.MarkClass(classname)
|
||
for component in range(1, components + 1):
|
||
if component in self._anchors[name][mark]:
|
||
anchor = self._anchors[name][mark][component]
|
||
marks[component - 1].append((anchor, markclass))
|
||
|
||
base = self._glyphName(name)
|
||
if is_mark:
|
||
mark = ast.MarkMarkPosStatement(base, marks[0])
|
||
elif is_ligature:
|
||
mark = ast.MarkLigPosStatement(base, marks)
|
||
else:
|
||
mark = ast.MarkBasePosStatement(base, marks[0])
|
||
statements.append(mark)
|
||
elif isinstance(pos, VAst.PositionAttachCursiveDefinition):
|
||
# Collect enter and exit glyphs
|
||
enter_coverage = []
|
||
for coverage in pos.coverages_enter:
|
||
for base in coverage:
|
||
for name in base.glyphSet():
|
||
enter_coverage.append(name)
|
||
exit_coverage = []
|
||
for coverage in pos.coverages_exit:
|
||
for base in coverage:
|
||
for name in base.glyphSet():
|
||
exit_coverage.append(name)
|
||
|
||
# Write enter anchors, also check if the glyph has exit anchor and
|
||
# write it, too.
|
||
for name in enter_coverage:
|
||
glyph = self._glyphName(name)
|
||
entry = self._anchors[name]["entry"][1]
|
||
exit = None
|
||
if name in exit_coverage:
|
||
exit = self._anchors[name]["exit"][1]
|
||
exit_coverage.pop(exit_coverage.index(name))
|
||
statements.append(ast.CursivePosStatement(glyph, entry, exit))
|
||
|
||
# Write any remaining exit anchors.
|
||
for name in exit_coverage:
|
||
glyph = self._glyphName(name)
|
||
exit = self._anchors[name]["exit"][1]
|
||
statements.append(ast.CursivePosStatement(glyph, None, exit))
|
||
else:
|
||
raise NotImplementedError(pos)
|
||
|
||
def _gposContextLookup(self, lookup, prefix, suffix, ignore, fealookup, chained):
|
||
statements = fealookup.statements
|
||
|
||
pos = lookup.pos
|
||
if isinstance(pos, VAst.PositionAdjustPairDefinition):
|
||
for (idx1, idx2), (pos1, pos2) in pos.adjust_pair.items():
|
||
glyphs1 = self._coverage(pos.coverages_1[idx1 - 1])
|
||
glyphs2 = self._coverage(pos.coverages_2[idx2 - 1])
|
||
assert len(glyphs1) == 1
|
||
assert len(glyphs2) == 1
|
||
glyphs = (glyphs1[0], glyphs2[0])
|
||
|
||
if ignore:
|
||
statement = ast.IgnorePosStatement([(prefix, glyphs, suffix)])
|
||
else:
|
||
statement = ast.ChainContextPosStatement(
|
||
prefix, glyphs, suffix, [chained, chained]
|
||
)
|
||
statements.append(statement)
|
||
elif isinstance(pos, VAst.PositionAdjustSingleDefinition):
|
||
glyphs = [ast.GlyphClass()]
|
||
for a, _ in pos.adjust_single:
|
||
glyphs[0].extend(self._coverage(a, flatten=True))
|
||
|
||
if ignore:
|
||
statement = ast.IgnorePosStatement([(prefix, glyphs, suffix)])
|
||
else:
|
||
statement = ast.ChainContextPosStatement(
|
||
prefix, glyphs, suffix, [chained]
|
||
)
|
||
statements.append(statement)
|
||
elif isinstance(pos, VAst.PositionAttachDefinition):
|
||
glyphs = [ast.GlyphClass()]
|
||
for coverage, _ in pos.coverage_to:
|
||
glyphs[0].extend(self._coverage(coverage, flatten=True))
|
||
|
||
if ignore:
|
||
statement = ast.IgnorePosStatement([(prefix, glyphs, suffix)])
|
||
else:
|
||
statement = ast.ChainContextPosStatement(
|
||
prefix, glyphs, suffix, [chained]
|
||
)
|
||
statements.append(statement)
|
||
else:
|
||
raise NotImplementedError(pos)
|
||
|
||
def _gsubLookup(self, lookup, fealookup):
|
||
statements = fealookup.statements
|
||
|
||
sub = lookup.sub
|
||
|
||
# Alternate substitutions are represented by adding multiple
|
||
# substitutions for the same glyph, so we need to collect them into one
|
||
# to many mapping.
|
||
if isinstance(sub, VAst.SubstitutionAlternateDefinition):
|
||
alternates = {}
|
||
for key, val in sub.mapping.items():
|
||
if not key or not val:
|
||
path, line, column = sub.location
|
||
log.warning(f"{path}:{line}:{column}: Ignoring empty substitution")
|
||
continue
|
||
glyphs = self._coverage(key)
|
||
replacements = self._coverage(val)
|
||
assert len(glyphs) == 1
|
||
for src_glyph, repl_glyph in zip(
|
||
glyphs[0].glyphSet(), replacements[0].glyphSet()
|
||
):
|
||
alternates.setdefault(str(self._glyphName(src_glyph)), []).append(
|
||
str(self._glyphName(repl_glyph))
|
||
)
|
||
|
||
for glyph, replacements in alternates.items():
|
||
statement = ast.AlternateSubstStatement(
|
||
[], glyph, [], ast.GlyphClass(replacements)
|
||
)
|
||
statements.append(statement)
|
||
return
|
||
|
||
for key, val in sub.mapping.items():
|
||
if not key or not val:
|
||
path, line, column = sub.location
|
||
log.warning(f"{path}:{line}:{column}: Ignoring empty substitution")
|
||
continue
|
||
glyphs = self._coverage(key)
|
||
replacements = self._coverage(val)
|
||
if isinstance(sub, VAst.SubstitutionSingleDefinition):
|
||
assert len(glyphs) == 1
|
||
assert len(replacements) == 1
|
||
statements.append(
|
||
ast.SingleSubstStatement(glyphs, replacements, [], [], False)
|
||
)
|
||
elif isinstance(sub, VAst.SubstitutionReverseChainingSingleDefinition):
|
||
# This is handled in gsubContextLookup()
|
||
pass
|
||
elif isinstance(sub, VAst.SubstitutionMultipleDefinition):
|
||
assert len(glyphs) == 1
|
||
statements.append(
|
||
ast.MultipleSubstStatement([], glyphs[0], [], replacements)
|
||
)
|
||
elif isinstance(sub, VAst.SubstitutionLigatureDefinition):
|
||
assert len(replacements) == 1
|
||
statement = ast.LigatureSubstStatement(
|
||
[], glyphs, [], replacements[0], False
|
||
)
|
||
|
||
# If any of the input glyphs is a group, we need to
|
||
# explode the substitution into multiple ligature substitutions
|
||
# since feature file syntax does not support classes in
|
||
# ligature substitutions.
|
||
n = max(len(x.glyphSet()) for x in glyphs)
|
||
if n > 1:
|
||
# All input should either be groups of the same length or single glyphs
|
||
assert all(len(x.glyphSet()) in (n, 1) for x in glyphs)
|
||
glyphs = [x.glyphSet() for x in glyphs]
|
||
glyphs = [([x[0]] * n if len(x) == 1 else x) for x in glyphs]
|
||
|
||
# In this case ligature replacements must be a group of the same length
|
||
# as the input groups, or a single glyph. VOLT
|
||
# allows the replacement glyphs to be longer and truncates them.
|
||
# So well allow that and zip() below will do the truncation
|
||
# for us.
|
||
replacement = replacements[0].glyphSet()
|
||
if len(replacement) == 1:
|
||
replacement = [replacement[0]] * n
|
||
assert len(replacement) >= n
|
||
|
||
# Add the unexploded statement commented out for reference.
|
||
statements.append(ast.Comment(f"# {statement}"))
|
||
|
||
for zipped in zip(*glyphs, replacement):
|
||
zipped = [self._glyphName(x) for x in zipped]
|
||
statements.append(
|
||
ast.LigatureSubstStatement(
|
||
[], zipped[:-1], [], zipped[-1], False
|
||
)
|
||
)
|
||
else:
|
||
statements.append(statement)
|
||
else:
|
||
raise NotImplementedError(sub)
|
||
|
||
def _gsubContextLookup(self, lookup, prefix, suffix, ignore, fealookup, chained):
|
||
statements = fealookup.statements
|
||
|
||
sub = lookup.sub
|
||
|
||
if isinstance(sub, VAst.SubstitutionReverseChainingSingleDefinition):
|
||
# Reverse substitutions is a special case, it can’t use chained lookups.
|
||
for key, val in sub.mapping.items():
|
||
if not key or not val:
|
||
path, line, column = sub.location
|
||
log.warning(f"{path}:{line}:{column}: Ignoring empty substitution")
|
||
continue
|
||
glyphs = self._coverage(key)
|
||
replacements = self._coverage(val)
|
||
statements.append(
|
||
ast.ReverseChainSingleSubstStatement(
|
||
prefix, suffix, glyphs, replacements
|
||
)
|
||
)
|
||
fealookup.chained = []
|
||
return
|
||
|
||
if not isinstance(
|
||
sub,
|
||
(
|
||
VAst.SubstitutionSingleDefinition,
|
||
VAst.SubstitutionMultipleDefinition,
|
||
VAst.SubstitutionLigatureDefinition,
|
||
VAst.SubstitutionAlternateDefinition,
|
||
),
|
||
):
|
||
raise NotImplementedError(type(sub))
|
||
|
||
glyphs = []
|
||
for key, val in sub.mapping.items():
|
||
if not key or not val:
|
||
path, line, column = sub.location
|
||
log.warning(f"{path}:{line}:{column}: Ignoring empty substitution")
|
||
continue
|
||
glyphs.extend(self._coverage(key, flatten=True))
|
||
|
||
if len(glyphs) > 1:
|
||
glyphs = [ast.GlyphClass(glyphs)]
|
||
if ignore:
|
||
statements.append(ast.IgnoreSubstStatement([(prefix, glyphs, suffix)]))
|
||
else:
|
||
statements.append(
|
||
ast.ChainContextSubstStatement(prefix, glyphs, suffix, [chained])
|
||
)
|
||
|
||
def _lookupDefinition(self, lookup):
|
||
mark_attachement = None
|
||
mark_filtering = None
|
||
|
||
flags = 0
|
||
if lookup.direction == "RTL":
|
||
flags |= 1
|
||
if not lookup.process_base:
|
||
flags |= 2
|
||
# FIXME: Does VOLT support this?
|
||
# if not lookup.process_ligatures:
|
||
# flags |= 4
|
||
if not lookup.process_marks:
|
||
flags |= 8
|
||
elif isinstance(lookup.process_marks, str):
|
||
mark_attachement = self._groupName(lookup.process_marks)
|
||
elif lookup.mark_glyph_set is not None:
|
||
mark_filtering = self._groupName(lookup.mark_glyph_set)
|
||
|
||
lookupflags = None
|
||
if flags or mark_attachement is not None or mark_filtering is not None:
|
||
lookupflags = ast.LookupFlagStatement(
|
||
flags, mark_attachement, mark_filtering
|
||
)
|
||
|
||
use_extension = False
|
||
if self._settings.get("COMPILER_USEEXTENSIONLOOKUPS"):
|
||
use_extension = True
|
||
|
||
if "\\" in lookup.name:
|
||
# Merge sub lookups as subtables (lookups named “base\sub”),
|
||
# makeotf/feaLib will issue a warning and ignore the subtable
|
||
# statement if it is not a pairpos lookup, though.
|
||
name = lookup.name.split("\\")[0]
|
||
if name.lower() not in self._lookups:
|
||
fealookup = Lookup(
|
||
self._lookupName(name),
|
||
use_extension=use_extension,
|
||
)
|
||
if lookupflags is not None:
|
||
fealookup.statements.append(lookupflags)
|
||
fealookup.statements.append(ast.Comment("# " + lookup.name))
|
||
else:
|
||
fealookup = self._lookups[name.lower()]
|
||
fealookup.statements.append(ast.SubtableStatement())
|
||
fealookup.statements.append(ast.Comment("# " + lookup.name))
|
||
self._lookups[name.lower()] = fealookup
|
||
else:
|
||
fealookup = Lookup(
|
||
self._lookupName(lookup.name),
|
||
use_extension=use_extension,
|
||
)
|
||
if lookupflags is not None:
|
||
fealookup.statements.append(lookupflags)
|
||
self._lookups[lookup.name.lower()] = fealookup
|
||
|
||
if lookup.comments is not None:
|
||
fealookup.statements.append(ast.Comment("# " + lookup.comments))
|
||
|
||
contexts = []
|
||
for context in lookup.context:
|
||
prefix = self._context(context.left)
|
||
suffix = self._context(context.right)
|
||
ignore = context.ex_or_in == "EXCEPT_CONTEXT"
|
||
contexts.append([prefix, suffix, ignore])
|
||
# It seems that VOLT will create contextual substitution using
|
||
# only the input if there is no other contexts in this lookup.
|
||
if ignore and len(lookup.context) == 1:
|
||
contexts.append([[], [], False])
|
||
|
||
if contexts:
|
||
chained = ast.LookupBlock(
|
||
self._lookupName(lookup.name + " chained"),
|
||
use_extension=use_extension,
|
||
)
|
||
fealookup.chained.append(chained)
|
||
if lookup.sub is not None:
|
||
self._gsubLookup(lookup, chained)
|
||
elif lookup.pos is not None:
|
||
self._gposLookup(lookup, chained)
|
||
for prefix, suffix, ignore in contexts:
|
||
if lookup.sub is not None:
|
||
self._gsubContextLookup(
|
||
lookup, prefix, suffix, ignore, fealookup, chained
|
||
)
|
||
elif lookup.pos is not None:
|
||
self._gposContextLookup(
|
||
lookup, prefix, suffix, ignore, fealookup, chained
|
||
)
|
||
else:
|
||
if lookup.sub is not None:
|
||
self._gsubLookup(lookup, fealookup)
|
||
elif lookup.pos is not None:
|
||
self._gposLookup(lookup, fealookup)
|
||
|
||
|
||
def main(args=None):
|
||
"""Convert MS VOLT to AFDKO feature files."""
|
||
|
||
import argparse
|
||
from pathlib import Path
|
||
|
||
from fontTools import configLogger
|
||
|
||
parser = argparse.ArgumentParser(
|
||
"fonttools voltLib.voltToFea", description=main.__doc__
|
||
)
|
||
parser.add_argument(
|
||
"input", metavar="INPUT", type=Path, help="input font/VTP file to process"
|
||
)
|
||
parser.add_argument(
|
||
"featurefile", metavar="OUTPUT", type=Path, help="output feature file"
|
||
)
|
||
parser.add_argument(
|
||
"-t",
|
||
"--table",
|
||
action="append",
|
||
choices=TABLES,
|
||
dest="tables",
|
||
help="List of tables to write, by default all tables are written",
|
||
)
|
||
parser.add_argument(
|
||
"-q", "--quiet", action="store_true", help="Suppress non-error messages"
|
||
)
|
||
parser.add_argument(
|
||
"--traceback", action="store_true", help="Don’t catch exceptions"
|
||
)
|
||
|
||
options = parser.parse_args(args)
|
||
|
||
configLogger(level=("ERROR" if options.quiet else "INFO"))
|
||
|
||
file_or_path = options.input
|
||
font = None
|
||
try:
|
||
font = TTFont(file_or_path)
|
||
if "TSIV" in font:
|
||
file_or_path = StringIO(font["TSIV"].data.decode("utf-8"))
|
||
else:
|
||
log.error('"TSIV" table is missing, font was not saved from VOLT?')
|
||
return 1
|
||
except TTLibError:
|
||
pass
|
||
|
||
converter = VoltToFea(file_or_path, font)
|
||
try:
|
||
fea = converter.convert(options.tables)
|
||
except NotImplementedError as e:
|
||
if options.traceback:
|
||
raise
|
||
location = getattr(e.args[0], "location", None)
|
||
message = f'"{e}" is not supported'
|
||
if location:
|
||
path, line, column = location
|
||
log.error(f"{path}:{line}:{column}: {message}")
|
||
else:
|
||
log.error(message)
|
||
return 1
|
||
with open(options.featurefile, "w") as feafile:
|
||
feafile.write(fea)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
import sys
|
||
|
||
sys.exit(main())
|