xref: /aosp_15_r20/external/fonttools/Lib/fontTools/voltLib/voltToFea.py (revision e1fe3e4ad2793916b15cccdc4a7da52a7e1dd0e9)
1"""\
2MS VOLT ``.vtp`` to AFDKO ``.fea`` OpenType Layout converter.
3
4Usage
5-----
6
7To convert a VTP project file:
8
9
10    $ fonttools voltLib.voltToFea input.vtp output.fea
11
12It is also possible convert font files with `TSIV` table (as saved from Volt),
13in this case the glyph names used in the Volt project will be mapped to the
14actual glyph names in the font files when written to the feature file:
15
16    $ fonttools voltLib.voltToFea input.ttf output.fea
17
18The ``--quiet`` option can be used to suppress warnings.
19
20The ``--traceback`` can be used to get Python traceback in case of exceptions,
21instead of suppressing the traceback.
22
23
24Limitations
25-----------
26
27* Not all VOLT features are supported, the script will error if it it
28  encounters something it does not understand. Please report an issue if this
29  happens.
30* AFDKO feature file syntax for mark positioning is awkward and does not allow
31  setting the mark coverage. It also defines mark anchors globally, as a result
32  some mark positioning lookups might cover many marks than what was in the VOLT
33  file. This should not be an issue in practice, but if it is then the only way
34  is to modify the VOLT file or the generated feature file manually to use unique
35  mark anchors for each lookup.
36* VOLT allows subtable breaks in any lookup type, but AFDKO feature file
37  implementations vary in their support; currently AFDKO’s makeOTF supports
38  subtable breaks in pair positioning lookups only, while FontTools’ feaLib
39  support it for most substitution lookups and only some positioning lookups.
40"""
41
42import logging
43import re
44from io import StringIO
45
46from fontTools.feaLib import ast
47from fontTools.ttLib import TTFont, TTLibError
48from fontTools.voltLib import ast as VAst
49from fontTools.voltLib.parser import Parser as VoltParser
50
51log = logging.getLogger("fontTools.voltLib.voltToFea")
52
53TABLES = ["GDEF", "GSUB", "GPOS"]
54
55
56class MarkClassDefinition(ast.MarkClassDefinition):
57    def asFea(self, indent=""):
58        res = ""
59        if not getattr(self, "used", False):
60            res += "#"
61        res += ast.MarkClassDefinition.asFea(self, indent)
62        return res
63
64
65# For sorting voltLib.ast.GlyphDefinition, see its use below.
66class Group:
67    def __init__(self, group):
68        self.name = group.name.lower()
69        self.groups = [
70            x.group.lower() for x in group.enum.enum if isinstance(x, VAst.GroupName)
71        ]
72
73    def __lt__(self, other):
74        if self.name in other.groups:
75            return True
76        if other.name in self.groups:
77            return False
78        if self.groups and not other.groups:
79            return False
80        if not self.groups and other.groups:
81            return True
82
83
84class VoltToFea:
85    _NOT_LOOKUP_NAME_RE = re.compile(r"[^A-Za-z_0-9.]")
86    _NOT_CLASS_NAME_RE = re.compile(r"[^A-Za-z_0-9.\-]")
87
88    def __init__(self, file_or_path, font=None):
89        self._file_or_path = file_or_path
90        self._font = font
91
92        self._glyph_map = {}
93        self._glyph_order = None
94
95        self._gdef = {}
96        self._glyphclasses = {}
97        self._features = {}
98        self._lookups = {}
99
100        self._marks = set()
101        self._ligatures = {}
102
103        self._markclasses = {}
104        self._anchors = {}
105
106        self._settings = {}
107
108        self._lookup_names = {}
109        self._class_names = {}
110
111    def _lookupName(self, name):
112        if name not in self._lookup_names:
113            res = self._NOT_LOOKUP_NAME_RE.sub("_", name)
114            while res in self._lookup_names.values():
115                res += "_"
116            self._lookup_names[name] = res
117        return self._lookup_names[name]
118
119    def _className(self, name):
120        if name not in self._class_names:
121            res = self._NOT_CLASS_NAME_RE.sub("_", name)
122            while res in self._class_names.values():
123                res += "_"
124            self._class_names[name] = res
125        return self._class_names[name]
126
127    def _collectStatements(self, doc, tables):
128        # Collect and sort group definitions first, to make sure a group
129        # definition that references other groups comes after them since VOLT
130        # does not enforce such ordering, and feature file require it.
131        groups = [s for s in doc.statements if isinstance(s, VAst.GroupDefinition)]
132        for statement in sorted(groups, key=lambda x: Group(x)):
133            self._groupDefinition(statement)
134
135        for statement in doc.statements:
136            if isinstance(statement, VAst.GlyphDefinition):
137                self._glyphDefinition(statement)
138            elif isinstance(statement, VAst.AnchorDefinition):
139                if "GPOS" in tables:
140                    self._anchorDefinition(statement)
141            elif isinstance(statement, VAst.SettingDefinition):
142                self._settingDefinition(statement)
143            elif isinstance(statement, VAst.GroupDefinition):
144                pass  # Handled above
145            elif isinstance(statement, VAst.ScriptDefinition):
146                self._scriptDefinition(statement)
147            elif not isinstance(statement, VAst.LookupDefinition):
148                raise NotImplementedError(statement)
149
150        # Lookup definitions need to be handled last as they reference glyph
151        # and mark classes that might be defined after them.
152        for statement in doc.statements:
153            if isinstance(statement, VAst.LookupDefinition):
154                if statement.pos and "GPOS" not in tables:
155                    continue
156                if statement.sub and "GSUB" not in tables:
157                    continue
158                self._lookupDefinition(statement)
159
160    def _buildFeatureFile(self, tables):
161        doc = ast.FeatureFile()
162        statements = doc.statements
163
164        if self._glyphclasses:
165            statements.append(ast.Comment("# Glyph classes"))
166            statements.extend(self._glyphclasses.values())
167
168        if self._markclasses:
169            statements.append(ast.Comment("\n# Mark classes"))
170            statements.extend(c[1] for c in sorted(self._markclasses.items()))
171
172        if self._lookups:
173            statements.append(ast.Comment("\n# Lookups"))
174            for lookup in self._lookups.values():
175                statements.extend(getattr(lookup, "targets", []))
176                statements.append(lookup)
177
178        # Prune features
179        features = self._features.copy()
180        for ftag in features:
181            scripts = features[ftag]
182            for stag in scripts:
183                langs = scripts[stag]
184                for ltag in langs:
185                    langs[ltag] = [l for l in langs[ltag] if l.lower() in self._lookups]
186                scripts[stag] = {t: l for t, l in langs.items() if l}
187            features[ftag] = {t: s for t, s in scripts.items() if s}
188        features = {t: f for t, f in features.items() if f}
189
190        if features:
191            statements.append(ast.Comment("# Features"))
192            for ftag, scripts in features.items():
193                feature = ast.FeatureBlock(ftag)
194                stags = sorted(scripts, key=lambda k: 0 if k == "DFLT" else 1)
195                for stag in stags:
196                    feature.statements.append(ast.ScriptStatement(stag))
197                    ltags = sorted(scripts[stag], key=lambda k: 0 if k == "dflt" else 1)
198                    for ltag in ltags:
199                        include_default = True if ltag == "dflt" else False
200                        feature.statements.append(
201                            ast.LanguageStatement(ltag, include_default=include_default)
202                        )
203                        for name in scripts[stag][ltag]:
204                            lookup = self._lookups[name.lower()]
205                            lookupref = ast.LookupReferenceStatement(lookup)
206                            feature.statements.append(lookupref)
207                statements.append(feature)
208
209        if self._gdef and "GDEF" in tables:
210            classes = []
211            for name in ("BASE", "MARK", "LIGATURE", "COMPONENT"):
212                if name in self._gdef:
213                    classname = "GDEF_" + name.lower()
214                    glyphclass = ast.GlyphClassDefinition(classname, self._gdef[name])
215                    statements.append(glyphclass)
216                    classes.append(ast.GlyphClassName(glyphclass))
217                else:
218                    classes.append(None)
219
220            gdef = ast.TableBlock("GDEF")
221            gdef.statements.append(ast.GlyphClassDefStatement(*classes))
222            statements.append(gdef)
223
224        return doc
225
226    def convert(self, tables=None):
227        doc = VoltParser(self._file_or_path).parse()
228
229        if tables is None:
230            tables = TABLES
231        if self._font is not None:
232            self._glyph_order = self._font.getGlyphOrder()
233
234        self._collectStatements(doc, tables)
235        fea = self._buildFeatureFile(tables)
236        return fea.asFea()
237
238    def _glyphName(self, glyph):
239        try:
240            name = glyph.glyph
241        except AttributeError:
242            name = glyph
243        return ast.GlyphName(self._glyph_map.get(name, name))
244
245    def _groupName(self, group):
246        try:
247            name = group.group
248        except AttributeError:
249            name = group
250        return ast.GlyphClassName(self._glyphclasses[name.lower()])
251
252    def _coverage(self, coverage):
253        items = []
254        for item in coverage:
255            if isinstance(item, VAst.GlyphName):
256                items.append(self._glyphName(item))
257            elif isinstance(item, VAst.GroupName):
258                items.append(self._groupName(item))
259            elif isinstance(item, VAst.Enum):
260                items.append(self._enum(item))
261            elif isinstance(item, VAst.Range):
262                items.append((item.start, item.end))
263            else:
264                raise NotImplementedError(item)
265        return items
266
267    def _enum(self, enum):
268        return ast.GlyphClass(self._coverage(enum.enum))
269
270    def _context(self, context):
271        out = []
272        for item in context:
273            coverage = self._coverage(item)
274            if not isinstance(coverage, (tuple, list)):
275                coverage = [coverage]
276            out.extend(coverage)
277        return out
278
279    def _groupDefinition(self, group):
280        name = self._className(group.name)
281        glyphs = self._enum(group.enum)
282        glyphclass = ast.GlyphClassDefinition(name, glyphs)
283
284        self._glyphclasses[group.name.lower()] = glyphclass
285
286    def _glyphDefinition(self, glyph):
287        try:
288            self._glyph_map[glyph.name] = self._glyph_order[glyph.id]
289        except TypeError:
290            pass
291
292        if glyph.type in ("BASE", "MARK", "LIGATURE", "COMPONENT"):
293            if glyph.type not in self._gdef:
294                self._gdef[glyph.type] = ast.GlyphClass()
295            self._gdef[glyph.type].glyphs.append(self._glyphName(glyph.name))
296
297        if glyph.type == "MARK":
298            self._marks.add(glyph.name)
299        elif glyph.type == "LIGATURE":
300            self._ligatures[glyph.name] = glyph.components
301
302    def _scriptDefinition(self, script):
303        stag = script.tag
304        for lang in script.langs:
305            ltag = lang.tag
306            for feature in lang.features:
307                lookups = {l.split("\\")[0]: True for l in feature.lookups}
308                ftag = feature.tag
309                if ftag not in self._features:
310                    self._features[ftag] = {}
311                if stag not in self._features[ftag]:
312                    self._features[ftag][stag] = {}
313                assert ltag not in self._features[ftag][stag]
314                self._features[ftag][stag][ltag] = lookups.keys()
315
316    def _settingDefinition(self, setting):
317        if setting.name.startswith("COMPILER_"):
318            self._settings[setting.name] = setting.value
319        else:
320            log.warning(f"Unsupported setting ignored: {setting.name}")
321
322    def _adjustment(self, adjustment):
323        adv, dx, dy, adv_adjust_by, dx_adjust_by, dy_adjust_by = adjustment
324
325        adv_device = adv_adjust_by and adv_adjust_by.items() or None
326        dx_device = dx_adjust_by and dx_adjust_by.items() or None
327        dy_device = dy_adjust_by and dy_adjust_by.items() or None
328
329        return ast.ValueRecord(
330            xPlacement=dx,
331            yPlacement=dy,
332            xAdvance=adv,
333            xPlaDevice=dx_device,
334            yPlaDevice=dy_device,
335            xAdvDevice=adv_device,
336        )
337
338    def _anchor(self, adjustment):
339        adv, dx, dy, adv_adjust_by, dx_adjust_by, dy_adjust_by = adjustment
340
341        assert not adv_adjust_by
342        dx_device = dx_adjust_by and dx_adjust_by.items() or None
343        dy_device = dy_adjust_by and dy_adjust_by.items() or None
344
345        return ast.Anchor(
346            dx or 0,
347            dy or 0,
348            xDeviceTable=dx_device or None,
349            yDeviceTable=dy_device or None,
350        )
351
352    def _anchorDefinition(self, anchordef):
353        anchorname = anchordef.name
354        glyphname = anchordef.glyph_name
355        anchor = self._anchor(anchordef.pos)
356
357        if anchorname.startswith("MARK_"):
358            name = "_".join(anchorname.split("_")[1:])
359            markclass = ast.MarkClass(self._className(name))
360            glyph = self._glyphName(glyphname)
361            markdef = MarkClassDefinition(markclass, anchor, glyph)
362            self._markclasses[(glyphname, anchorname)] = markdef
363        else:
364            if glyphname not in self._anchors:
365                self._anchors[glyphname] = {}
366            if anchorname not in self._anchors[glyphname]:
367                self._anchors[glyphname][anchorname] = {}
368            self._anchors[glyphname][anchorname][anchordef.component] = anchor
369
370    def _gposLookup(self, lookup, fealookup):
371        statements = fealookup.statements
372
373        pos = lookup.pos
374        if isinstance(pos, VAst.PositionAdjustPairDefinition):
375            for (idx1, idx2), (pos1, pos2) in pos.adjust_pair.items():
376                coverage_1 = pos.coverages_1[idx1 - 1]
377                coverage_2 = pos.coverages_2[idx2 - 1]
378
379                # If not both are groups, use “enum pos” otherwise makeotf will
380                # fail.
381                enumerated = False
382                for item in coverage_1 + coverage_2:
383                    if not isinstance(item, VAst.GroupName):
384                        enumerated = True
385
386                glyphs1 = self._coverage(coverage_1)
387                glyphs2 = self._coverage(coverage_2)
388                record1 = self._adjustment(pos1)
389                record2 = self._adjustment(pos2)
390                assert len(glyphs1) == 1
391                assert len(glyphs2) == 1
392                statements.append(
393                    ast.PairPosStatement(
394                        glyphs1[0], record1, glyphs2[0], record2, enumerated=enumerated
395                    )
396                )
397        elif isinstance(pos, VAst.PositionAdjustSingleDefinition):
398            for a, b in pos.adjust_single:
399                glyphs = self._coverage(a)
400                record = self._adjustment(b)
401                assert len(glyphs) == 1
402                statements.append(
403                    ast.SinglePosStatement([(glyphs[0], record)], [], [], False)
404                )
405        elif isinstance(pos, VAst.PositionAttachDefinition):
406            anchors = {}
407            for marks, classname in pos.coverage_to:
408                for mark in marks:
409                    # Set actually used mark classes. Basically a hack to get
410                    # around the feature file syntax limitation of making mark
411                    # classes global and not allowing mark positioning to
412                    # specify mark coverage.
413                    for name in mark.glyphSet():
414                        key = (name, "MARK_" + classname)
415                        self._markclasses[key].used = True
416                markclass = ast.MarkClass(self._className(classname))
417                for base in pos.coverage:
418                    for name in base.glyphSet():
419                        if name not in anchors:
420                            anchors[name] = []
421                        if classname not in anchors[name]:
422                            anchors[name].append(classname)
423
424            for name in anchors:
425                components = 1
426                if name in self._ligatures:
427                    components = self._ligatures[name]
428
429                marks = []
430                for mark in anchors[name]:
431                    markclass = ast.MarkClass(self._className(mark))
432                    for component in range(1, components + 1):
433                        if len(marks) < component:
434                            marks.append([])
435                        anchor = None
436                        if component in self._anchors[name][mark]:
437                            anchor = self._anchors[name][mark][component]
438                        marks[component - 1].append((anchor, markclass))
439
440                base = self._glyphName(name)
441                if name in self._marks:
442                    mark = ast.MarkMarkPosStatement(base, marks[0])
443                elif name in self._ligatures:
444                    mark = ast.MarkLigPosStatement(base, marks)
445                else:
446                    mark = ast.MarkBasePosStatement(base, marks[0])
447                statements.append(mark)
448        elif isinstance(pos, VAst.PositionAttachCursiveDefinition):
449            # Collect enter and exit glyphs
450            enter_coverage = []
451            for coverage in pos.coverages_enter:
452                for base in coverage:
453                    for name in base.glyphSet():
454                        enter_coverage.append(name)
455            exit_coverage = []
456            for coverage in pos.coverages_exit:
457                for base in coverage:
458                    for name in base.glyphSet():
459                        exit_coverage.append(name)
460
461            # Write enter anchors, also check if the glyph has exit anchor and
462            # write it, too.
463            for name in enter_coverage:
464                glyph = self._glyphName(name)
465                entry = self._anchors[name]["entry"][1]
466                exit = None
467                if name in exit_coverage:
468                    exit = self._anchors[name]["exit"][1]
469                    exit_coverage.pop(exit_coverage.index(name))
470                statements.append(ast.CursivePosStatement(glyph, entry, exit))
471
472            # Write any remaining exit anchors.
473            for name in exit_coverage:
474                glyph = self._glyphName(name)
475                exit = self._anchors[name]["exit"][1]
476                statements.append(ast.CursivePosStatement(glyph, None, exit))
477        else:
478            raise NotImplementedError(pos)
479
480    def _gposContextLookup(
481        self, lookup, prefix, suffix, ignore, fealookup, targetlookup
482    ):
483        statements = fealookup.statements
484
485        assert not lookup.reversal
486
487        pos = lookup.pos
488        if isinstance(pos, VAst.PositionAdjustPairDefinition):
489            for (idx1, idx2), (pos1, pos2) in pos.adjust_pair.items():
490                glyphs1 = self._coverage(pos.coverages_1[idx1 - 1])
491                glyphs2 = self._coverage(pos.coverages_2[idx2 - 1])
492                assert len(glyphs1) == 1
493                assert len(glyphs2) == 1
494                glyphs = (glyphs1[0], glyphs2[0])
495
496                if ignore:
497                    statement = ast.IgnorePosStatement([(prefix, glyphs, suffix)])
498                else:
499                    lookups = (targetlookup, targetlookup)
500                    statement = ast.ChainContextPosStatement(
501                        prefix, glyphs, suffix, lookups
502                    )
503                statements.append(statement)
504        elif isinstance(pos, VAst.PositionAdjustSingleDefinition):
505            glyphs = [ast.GlyphClass()]
506            for a, b in pos.adjust_single:
507                glyph = self._coverage(a)
508                glyphs[0].extend(glyph)
509
510            if ignore:
511                statement = ast.IgnorePosStatement([(prefix, glyphs, suffix)])
512            else:
513                statement = ast.ChainContextPosStatement(
514                    prefix, glyphs, suffix, [targetlookup]
515                )
516            statements.append(statement)
517        elif isinstance(pos, VAst.PositionAttachDefinition):
518            glyphs = [ast.GlyphClass()]
519            for coverage, _ in pos.coverage_to:
520                glyphs[0].extend(self._coverage(coverage))
521
522            if ignore:
523                statement = ast.IgnorePosStatement([(prefix, glyphs, suffix)])
524            else:
525                statement = ast.ChainContextPosStatement(
526                    prefix, glyphs, suffix, [targetlookup]
527                )
528            statements.append(statement)
529        else:
530            raise NotImplementedError(pos)
531
532    def _gsubLookup(self, lookup, prefix, suffix, ignore, chain, fealookup):
533        statements = fealookup.statements
534
535        sub = lookup.sub
536        for key, val in sub.mapping.items():
537            if not key or not val:
538                path, line, column = sub.location
539                log.warning(f"{path}:{line}:{column}: Ignoring empty substitution")
540                continue
541            statement = None
542            glyphs = self._coverage(key)
543            replacements = self._coverage(val)
544            if ignore:
545                chain_context = (prefix, glyphs, suffix)
546                statement = ast.IgnoreSubstStatement([chain_context])
547            elif isinstance(sub, VAst.SubstitutionSingleDefinition):
548                assert len(glyphs) == 1
549                assert len(replacements) == 1
550                statement = ast.SingleSubstStatement(
551                    glyphs, replacements, prefix, suffix, chain
552                )
553            elif isinstance(sub, VAst.SubstitutionReverseChainingSingleDefinition):
554                assert len(glyphs) == 1
555                assert len(replacements) == 1
556                statement = ast.ReverseChainSingleSubstStatement(
557                    prefix, suffix, glyphs, replacements
558                )
559            elif isinstance(sub, VAst.SubstitutionMultipleDefinition):
560                assert len(glyphs) == 1
561                statement = ast.MultipleSubstStatement(
562                    prefix, glyphs[0], suffix, replacements, chain
563                )
564            elif isinstance(sub, VAst.SubstitutionLigatureDefinition):
565                assert len(replacements) == 1
566                statement = ast.LigatureSubstStatement(
567                    prefix, glyphs, suffix, replacements[0], chain
568                )
569            else:
570                raise NotImplementedError(sub)
571            statements.append(statement)
572
573    def _lookupDefinition(self, lookup):
574        mark_attachement = None
575        mark_filtering = None
576
577        flags = 0
578        if lookup.direction == "RTL":
579            flags |= 1
580        if not lookup.process_base:
581            flags |= 2
582        # FIXME: Does VOLT support this?
583        # if not lookup.process_ligatures:
584        #     flags |= 4
585        if not lookup.process_marks:
586            flags |= 8
587        elif isinstance(lookup.process_marks, str):
588            mark_attachement = self._groupName(lookup.process_marks)
589        elif lookup.mark_glyph_set is not None:
590            mark_filtering = self._groupName(lookup.mark_glyph_set)
591
592        lookupflags = None
593        if flags or mark_attachement is not None or mark_filtering is not None:
594            lookupflags = ast.LookupFlagStatement(
595                flags, mark_attachement, mark_filtering
596            )
597        if "\\" in lookup.name:
598            # Merge sub lookups as subtables (lookups named “base\sub”),
599            # makeotf/feaLib will issue a warning and ignore the subtable
600            # statement if it is not a pairpos lookup, though.
601            name = lookup.name.split("\\")[0]
602            if name.lower() not in self._lookups:
603                fealookup = ast.LookupBlock(self._lookupName(name))
604                if lookupflags is not None:
605                    fealookup.statements.append(lookupflags)
606                fealookup.statements.append(ast.Comment("# " + lookup.name))
607            else:
608                fealookup = self._lookups[name.lower()]
609                fealookup.statements.append(ast.SubtableStatement())
610                fealookup.statements.append(ast.Comment("# " + lookup.name))
611            self._lookups[name.lower()] = fealookup
612        else:
613            fealookup = ast.LookupBlock(self._lookupName(lookup.name))
614            if lookupflags is not None:
615                fealookup.statements.append(lookupflags)
616            self._lookups[lookup.name.lower()] = fealookup
617
618        if lookup.comments is not None:
619            fealookup.statements.append(ast.Comment("# " + lookup.comments))
620
621        contexts = []
622        if lookup.context:
623            for context in lookup.context:
624                prefix = self._context(context.left)
625                suffix = self._context(context.right)
626                ignore = context.ex_or_in == "EXCEPT_CONTEXT"
627                contexts.append([prefix, suffix, ignore, False])
628                # It seems that VOLT will create contextual substitution using
629                # only the input if there is no other contexts in this lookup.
630                if ignore and len(lookup.context) == 1:
631                    contexts.append([[], [], False, True])
632        else:
633            contexts.append([[], [], False, False])
634
635        targetlookup = None
636        for prefix, suffix, ignore, chain in contexts:
637            if lookup.sub is not None:
638                self._gsubLookup(lookup, prefix, suffix, ignore, chain, fealookup)
639
640            if lookup.pos is not None:
641                if self._settings.get("COMPILER_USEEXTENSIONLOOKUPS"):
642                    fealookup.use_extension = True
643                if prefix or suffix or chain or ignore:
644                    if not ignore and targetlookup is None:
645                        targetname = self._lookupName(lookup.name + " target")
646                        targetlookup = ast.LookupBlock(targetname)
647                        fealookup.targets = getattr(fealookup, "targets", [])
648                        fealookup.targets.append(targetlookup)
649                        self._gposLookup(lookup, targetlookup)
650                    self._gposContextLookup(
651                        lookup, prefix, suffix, ignore, fealookup, targetlookup
652                    )
653                else:
654                    self._gposLookup(lookup, fealookup)
655
656
657def main(args=None):
658    """Convert MS VOLT to AFDKO feature files."""
659
660    import argparse
661    from pathlib import Path
662
663    from fontTools import configLogger
664
665    parser = argparse.ArgumentParser(
666        "fonttools voltLib.voltToFea", description=main.__doc__
667    )
668    parser.add_argument(
669        "input", metavar="INPUT", type=Path, help="input font/VTP file to process"
670    )
671    parser.add_argument(
672        "featurefile", metavar="OUTPUT", type=Path, help="output feature file"
673    )
674    parser.add_argument(
675        "-t",
676        "--table",
677        action="append",
678        choices=TABLES,
679        dest="tables",
680        help="List of tables to write, by default all tables are written",
681    )
682    parser.add_argument(
683        "-q", "--quiet", action="store_true", help="Suppress non-error messages"
684    )
685    parser.add_argument(
686        "--traceback", action="store_true", help="Don’t catch exceptions"
687    )
688
689    options = parser.parse_args(args)
690
691    configLogger(level=("ERROR" if options.quiet else "INFO"))
692
693    file_or_path = options.input
694    font = None
695    try:
696        font = TTFont(file_or_path)
697        if "TSIV" in font:
698            file_or_path = StringIO(font["TSIV"].data.decode("utf-8"))
699        else:
700            log.error('"TSIV" table is missing, font was not saved from VOLT?')
701            return 1
702    except TTLibError:
703        pass
704
705    converter = VoltToFea(file_or_path, font)
706    try:
707        fea = converter.convert(options.tables)
708    except NotImplementedError as e:
709        if options.traceback:
710            raise
711        location = getattr(e.args[0], "location", None)
712        message = f'"{e}" is not supported'
713        if location:
714            path, line, column = location
715            log.error(f"{path}:{line}:{column}: {message}")
716        else:
717            log.error(message)
718        return 1
719    with open(options.featurefile, "w") as feafile:
720        feafile.write(fea)
721
722
723if __name__ == "__main__":
724    import sys
725
726    sys.exit(main())
727