aboutsummaryrefslogtreecommitdiff
path: root/Lib/fontTools/voltLib/voltToFea.py
diff options
context:
space:
mode:
Diffstat (limited to 'Lib/fontTools/voltLib/voltToFea.py')
-rw-r--r--Lib/fontTools/voltLib/voltToFea.py726
1 files changed, 726 insertions, 0 deletions
diff --git a/Lib/fontTools/voltLib/voltToFea.py b/Lib/fontTools/voltLib/voltToFea.py
new file mode 100644
index 00000000..2265d502
--- /dev/null
+++ b/Lib/fontTools/voltLib/voltToFea.py
@@ -0,0 +1,726 @@
+"""\
+MS VOLT ``.vtp`` to AFDKO ``.fea`` OpenType Layout converter.
+
+Usage
+-----
+
+To convert a VTP project file:
+
+
+ $ 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:
+
+ $ 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 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"]
+
+
+class MarkClassDefinition(ast.MarkClassDefinition):
+ def asFea(self, indent=""):
+ res = ""
+ if not getattr(self, "used", False):
+ res += "#"
+ res += ast.MarkClassDefinition.asFea(self, indent)
+ return res
+
+
+# For sorting voltLib.ast.GlyphDefinition, see its use below.
+class Group:
+ def __init__(self, group):
+ self.name = group.name.lower()
+ self.groups = [
+ x.group.lower() for x in group.enum.enum if isinstance(x, VAst.GroupName)
+ ]
+
+ def __lt__(self, other):
+ if self.name in other.groups:
+ return True
+ if other.name in self.groups:
+ return False
+ if self.groups and not other.groups:
+ return False
+ if not self.groups and other.groups:
+ return True
+
+
+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):
+ self._file_or_path = 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):
+ # 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 statement in sorted(groups, key=lambda x: Group(x)):
+ self._groupDefinition(statement)
+
+ for statement in doc.statements:
+ if isinstance(statement, VAst.GlyphDefinition):
+ self._glyphDefinition(statement)
+ elif isinstance(statement, VAst.AnchorDefinition):
+ if "GPOS" in tables:
+ self._anchorDefinition(statement)
+ elif isinstance(statement, VAst.SettingDefinition):
+ self._settingDefinition(statement)
+ elif isinstance(statement, 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(getattr(lookup, "targets", []))
+ statements.append(lookup)
+
+ # Prune features
+ features = self._features.copy()
+ for ftag in features:
+ scripts = features[ftag]
+ for stag in scripts:
+ langs = scripts[stag]
+ for ltag in langs:
+ langs[ltag] = [l for l in langs[ltag] if l.lower() in self._lookups]
+ scripts[stag] = {t: l for t, l in langs.items() if l}
+ features[ftag] = {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 ftag, scripts in features.items():
+ feature = ast.FeatureBlock(ftag)
+ stags = sorted(scripts, key=lambda k: 0 if k == "DFLT" else 1)
+ for stag in stags:
+ feature.statements.append(ast.ScriptStatement(stag))
+ ltags = sorted(scripts[stag], key=lambda k: 0 if k == "dflt" else 1)
+ for ltag in ltags:
+ include_default = True if ltag == "dflt" else False
+ feature.statements.append(
+ ast.LanguageStatement(ltag, include_default=include_default)
+ )
+ for name in scripts[stag][ltag]:
+ 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):
+ doc = VoltParser(self._file_or_path).parse()
+
+ if tables is None:
+ tables = TABLES
+ if self._font is not None:
+ self._glyph_order = self._font.getGlyphOrder()
+
+ self._collectStatements(doc, tables)
+ 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 _coverage(self, coverage):
+ 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):
+ items.append(self._enum(item))
+ elif isinstance(item, VAst.Range):
+ items.append((item.start, item.end))
+ else:
+ raise NotImplementedError(item)
+ return items
+
+ def _enum(self, enum):
+ return ast.GlyphClass(self._coverage(enum.enum))
+
+ def _context(self, context):
+ out = []
+ for item in context:
+ coverage = self._coverage(item)
+ if not isinstance(coverage, (tuple, list)):
+ coverage = [coverage]
+ out.extend(coverage)
+ return out
+
+ def _groupDefinition(self, group):
+ name = self._className(group.name)
+ glyphs = self._enum(group.enum)
+ glyphclass = ast.GlyphClassDefinition(name, glyphs)
+
+ self._glyphclasses[group.name.lower()] = glyphclass
+
+ 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):
+ if setting.name.startswith("COMPILER_"):
+ self._settings[setting.name] = setting.value
+ else:
+ 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 anchorname.startswith("MARK_"):
+ name = "_".join(anchorname.split("_")[1:])
+ markclass = ast.MarkClass(self._className(name))
+ glyph = self._glyphName(glyphname)
+ markdef = MarkClassDefinition(markclass, anchor, glyph)
+ self._markclasses[(glyphname, anchorname)] = markdef
+ else:
+ if glyphname not in self._anchors:
+ self._anchors[glyphname] = {}
+ 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 = {}
+ for marks, classname in pos.coverage_to:
+ for mark in marks:
+ # Set actually used mark classes. Basically a hack to get
+ # around the feature file syntax limitation of making mark
+ # classes global and not allowing mark positioning to
+ # specify mark coverage.
+ for name in mark.glyphSet():
+ key = (name, "MARK_" + classname)
+ self._markclasses[key].used = True
+ markclass = ast.MarkClass(self._className(classname))
+ for base in pos.coverage:
+ for name in base.glyphSet():
+ if name not in anchors:
+ anchors[name] = []
+ if classname not in anchors[name]:
+ anchors[name].append(classname)
+
+ for name in anchors:
+ components = 1
+ if name in self._ligatures:
+ components = self._ligatures[name]
+
+ marks = []
+ for mark in anchors[name]:
+ markclass = ast.MarkClass(self._className(mark))
+ for component in range(1, components + 1):
+ if len(marks) < component:
+ marks.append([])
+ anchor = None
+ if component in self._anchors[name][mark]:
+ anchor = self._anchors[name][mark][component]
+ marks[component - 1].append((anchor, markclass))
+
+ base = self._glyphName(name)
+ if name in self._marks:
+ mark = ast.MarkMarkPosStatement(base, marks[0])
+ elif name in self._ligatures:
+ 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, targetlookup
+ ):
+ statements = fealookup.statements
+
+ assert not lookup.reversal
+
+ 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:
+ lookups = (targetlookup, targetlookup)
+ statement = ast.ChainContextPosStatement(
+ prefix, glyphs, suffix, lookups
+ )
+ statements.append(statement)
+ elif isinstance(pos, VAst.PositionAdjustSingleDefinition):
+ glyphs = [ast.GlyphClass()]
+ for a, b in pos.adjust_single:
+ glyph = self._coverage(a)
+ glyphs[0].extend(glyph)
+
+ if ignore:
+ statement = ast.IgnorePosStatement([(prefix, glyphs, suffix)])
+ else:
+ statement = ast.ChainContextPosStatement(
+ prefix, glyphs, suffix, [targetlookup]
+ )
+ statements.append(statement)
+ elif isinstance(pos, VAst.PositionAttachDefinition):
+ glyphs = [ast.GlyphClass()]
+ for coverage, _ in pos.coverage_to:
+ glyphs[0].extend(self._coverage(coverage))
+
+ if ignore:
+ statement = ast.IgnorePosStatement([(prefix, glyphs, suffix)])
+ else:
+ statement = ast.ChainContextPosStatement(
+ prefix, glyphs, suffix, [targetlookup]
+ )
+ statements.append(statement)
+ else:
+ raise NotImplementedError(pos)
+
+ def _gsubLookup(self, lookup, prefix, suffix, ignore, chain, fealookup):
+ statements = fealookup.statements
+
+ sub = lookup.sub
+ 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
+ statement = None
+ glyphs = self._coverage(key)
+ replacements = self._coverage(val)
+ if ignore:
+ chain_context = (prefix, glyphs, suffix)
+ statement = ast.IgnoreSubstStatement([chain_context])
+ elif isinstance(sub, VAst.SubstitutionSingleDefinition):
+ assert len(glyphs) == 1
+ assert len(replacements) == 1
+ statement = ast.SingleSubstStatement(
+ glyphs, replacements, prefix, suffix, chain
+ )
+ elif isinstance(sub, VAst.SubstitutionReverseChainingSingleDefinition):
+ assert len(glyphs) == 1
+ assert len(replacements) == 1
+ statement = ast.ReverseChainSingleSubstStatement(
+ prefix, suffix, glyphs, replacements
+ )
+ elif isinstance(sub, VAst.SubstitutionMultipleDefinition):
+ assert len(glyphs) == 1
+ statement = ast.MultipleSubstStatement(
+ prefix, glyphs[0], suffix, replacements, chain
+ )
+ elif isinstance(sub, VAst.SubstitutionLigatureDefinition):
+ assert len(replacements) == 1
+ statement = ast.LigatureSubstStatement(
+ prefix, glyphs, suffix, replacements[0], chain
+ )
+ else:
+ raise NotImplementedError(sub)
+ statements.append(statement)
+
+ 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
+ )
+ 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 = ast.LookupBlock(self._lookupName(name))
+ 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 = ast.LookupBlock(self._lookupName(lookup.name))
+ 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 = []
+ if lookup.context:
+ 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, False])
+ # 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, True])
+ else:
+ contexts.append([[], [], False, False])
+
+ targetlookup = None
+ for prefix, suffix, ignore, chain in contexts:
+ if lookup.sub is not None:
+ self._gsubLookup(lookup, prefix, suffix, ignore, chain, fealookup)
+
+ if lookup.pos is not None:
+ if self._settings.get("COMPILER_USEEXTENSIONLOOKUPS"):
+ fealookup.use_extension = True
+ if prefix or suffix or chain or ignore:
+ if not ignore and targetlookup is None:
+ targetname = self._lookupName(lookup.name + " target")
+ targetlookup = ast.LookupBlock(targetname)
+ fealookup.targets = getattr(fealookup, "targets", [])
+ fealookup.targets.append(targetlookup)
+ self._gposLookup(lookup, targetlookup)
+ self._gposContextLookup(
+ lookup, prefix, suffix, ignore, fealookup, targetlookup
+ )
+ else:
+ 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())