|
"""\ |
|
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 |
|
|
|
|
|
|
|
|
|
|
|
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): |
|
|
|
for statement in doc.statements: |
|
if isinstance(statement, VAst.GlyphDefinition): |
|
self._glyphDefinition(statement) |
|
|
|
|
|
|
|
|
|
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 |
|
elif isinstance(statement, VAst.ScriptDefinition): |
|
self._scriptDefinition(statement) |
|
elif not isinstance(statement, VAst.LookupDefinition): |
|
raise NotImplementedError(statement) |
|
|
|
|
|
|
|
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) |
|
|
|
|
|
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] |
|
|
|
|
|
|
|
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: |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
classname = self._className(f"{anchorname}.{lookup.name}") |
|
markclass = ast.MarkClass(classname) |
|
|
|
|
|
anchorname = anchorname.lower() |
|
|
|
|
|
|
|
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): |
|
|
|
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) |
|
|
|
|
|
|
|
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)) |
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
|
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): |
|
|
|
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 |
|
) |
|
|
|
|
|
|
|
|
|
|
|
n = max(len(x.glyphSet()) for x in glyphs) |
|
if n > 1: |
|
|
|
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] |
|
|
|
|
|
|
|
|
|
|
|
|
|
replacement = replacements[0].glyphSet() |
|
if len(replacement) == 1: |
|
replacement = [replacement[0]] * n |
|
assert len(replacement) >= n |
|
|
|
|
|
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): |
|
|
|
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 |
|
|
|
|
|
|
|
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: |
|
|
|
|
|
|
|
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]) |
|
|
|
|
|
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()) |
|
|