| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911 |
- """\
- 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())
|