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