fonttools 4.55.4__cp313-cp313-musllinux_1_2_aarch64.whl → 4.61.1__cp313-cp313-musllinux_1_2_aarch64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. fontTools/__init__.py +1 -1
  2. fontTools/annotations.py +30 -0
  3. fontTools/cffLib/CFF2ToCFF.py +65 -10
  4. fontTools/cffLib/__init__.py +61 -26
  5. fontTools/cffLib/specializer.py +4 -1
  6. fontTools/cffLib/transforms.py +11 -6
  7. fontTools/config/__init__.py +15 -0
  8. fontTools/cu2qu/cu2qu.c +6567 -5579
  9. fontTools/cu2qu/cu2qu.cpython-313-aarch64-linux-musl.so +0 -0
  10. fontTools/cu2qu/cu2qu.py +36 -4
  11. fontTools/cu2qu/ufo.py +14 -0
  12. fontTools/designspaceLib/__init__.py +8 -3
  13. fontTools/designspaceLib/statNames.py +14 -7
  14. fontTools/feaLib/ast.py +24 -15
  15. fontTools/feaLib/builder.py +139 -66
  16. fontTools/feaLib/error.py +1 -1
  17. fontTools/feaLib/lexer.c +7038 -7995
  18. fontTools/feaLib/lexer.cpython-313-aarch64-linux-musl.so +0 -0
  19. fontTools/feaLib/parser.py +75 -40
  20. fontTools/feaLib/variableScalar.py +6 -1
  21. fontTools/fontBuilder.py +50 -44
  22. fontTools/merge/__init__.py +1 -1
  23. fontTools/merge/cmap.py +33 -1
  24. fontTools/merge/tables.py +12 -1
  25. fontTools/misc/bezierTools.c +14913 -17013
  26. fontTools/misc/bezierTools.cpython-313-aarch64-linux-musl.so +0 -0
  27. fontTools/misc/bezierTools.py +4 -1
  28. fontTools/misc/configTools.py +3 -1
  29. fontTools/misc/enumTools.py +23 -0
  30. fontTools/misc/etree.py +4 -27
  31. fontTools/misc/filesystem/__init__.py +68 -0
  32. fontTools/misc/filesystem/_base.py +134 -0
  33. fontTools/misc/filesystem/_copy.py +45 -0
  34. fontTools/misc/filesystem/_errors.py +54 -0
  35. fontTools/misc/filesystem/_info.py +75 -0
  36. fontTools/misc/filesystem/_osfs.py +164 -0
  37. fontTools/misc/filesystem/_path.py +67 -0
  38. fontTools/misc/filesystem/_subfs.py +92 -0
  39. fontTools/misc/filesystem/_tempfs.py +34 -0
  40. fontTools/misc/filesystem/_tools.py +34 -0
  41. fontTools/misc/filesystem/_walk.py +55 -0
  42. fontTools/misc/filesystem/_zipfs.py +204 -0
  43. fontTools/misc/fixedTools.py +1 -1
  44. fontTools/misc/loggingTools.py +1 -1
  45. fontTools/misc/psCharStrings.py +17 -2
  46. fontTools/misc/sstruct.py +2 -6
  47. fontTools/misc/symfont.py +6 -8
  48. fontTools/misc/testTools.py +5 -1
  49. fontTools/misc/textTools.py +4 -2
  50. fontTools/misc/visitor.py +32 -16
  51. fontTools/misc/xmlWriter.py +44 -8
  52. fontTools/mtiLib/__init__.py +1 -3
  53. fontTools/otlLib/builder.py +402 -155
  54. fontTools/otlLib/optimize/gpos.py +49 -63
  55. fontTools/pens/filterPen.py +218 -26
  56. fontTools/pens/momentsPen.c +5514 -5584
  57. fontTools/pens/momentsPen.cpython-313-aarch64-linux-musl.so +0 -0
  58. fontTools/pens/pointPen.py +61 -18
  59. fontTools/pens/roundingPen.py +2 -2
  60. fontTools/pens/t2CharStringPen.py +31 -11
  61. fontTools/qu2cu/qu2cu.c +6581 -6168
  62. fontTools/qu2cu/qu2cu.cpython-313-aarch64-linux-musl.so +0 -0
  63. fontTools/subset/__init__.py +283 -25
  64. fontTools/subset/svg.py +2 -3
  65. fontTools/ttLib/__init__.py +4 -0
  66. fontTools/ttLib/__main__.py +47 -8
  67. fontTools/ttLib/removeOverlaps.py +7 -5
  68. fontTools/ttLib/reorderGlyphs.py +8 -7
  69. fontTools/ttLib/sfnt.py +11 -9
  70. fontTools/ttLib/tables/D__e_b_g.py +20 -2
  71. fontTools/ttLib/tables/G_V_A_R_.py +5 -0
  72. fontTools/ttLib/tables/S__i_l_f.py +2 -2
  73. fontTools/ttLib/tables/T_S_I__0.py +14 -3
  74. fontTools/ttLib/tables/T_S_I__1.py +2 -5
  75. fontTools/ttLib/tables/T_S_I__5.py +18 -7
  76. fontTools/ttLib/tables/__init__.py +1 -0
  77. fontTools/ttLib/tables/_a_v_a_r.py +12 -3
  78. fontTools/ttLib/tables/_c_m_a_p.py +20 -7
  79. fontTools/ttLib/tables/_c_v_t.py +3 -2
  80. fontTools/ttLib/tables/_f_p_g_m.py +3 -1
  81. fontTools/ttLib/tables/_g_l_y_f.py +45 -21
  82. fontTools/ttLib/tables/_g_v_a_r.py +67 -19
  83. fontTools/ttLib/tables/_h_d_m_x.py +4 -4
  84. fontTools/ttLib/tables/_h_m_t_x.py +7 -3
  85. fontTools/ttLib/tables/_l_o_c_a.py +2 -2
  86. fontTools/ttLib/tables/_n_a_m_e.py +11 -6
  87. fontTools/ttLib/tables/_p_o_s_t.py +9 -7
  88. fontTools/ttLib/tables/otBase.py +5 -12
  89. fontTools/ttLib/tables/otConverters.py +5 -2
  90. fontTools/ttLib/tables/otData.py +1 -1
  91. fontTools/ttLib/tables/otTables.py +33 -30
  92. fontTools/ttLib/tables/otTraverse.py +2 -1
  93. fontTools/ttLib/tables/sbixStrike.py +3 -3
  94. fontTools/ttLib/ttFont.py +666 -120
  95. fontTools/ttLib/ttGlyphSet.py +0 -10
  96. fontTools/ttLib/woff2.py +10 -13
  97. fontTools/ttx.py +13 -1
  98. fontTools/ufoLib/__init__.py +300 -202
  99. fontTools/ufoLib/converters.py +103 -30
  100. fontTools/ufoLib/errors.py +8 -0
  101. fontTools/ufoLib/etree.py +1 -1
  102. fontTools/ufoLib/filenames.py +171 -106
  103. fontTools/ufoLib/glifLib.py +303 -205
  104. fontTools/ufoLib/kerning.py +98 -48
  105. fontTools/ufoLib/utils.py +46 -15
  106. fontTools/ufoLib/validators.py +121 -99
  107. fontTools/unicodedata/Blocks.py +35 -20
  108. fontTools/unicodedata/Mirrored.py +446 -0
  109. fontTools/unicodedata/ScriptExtensions.py +63 -37
  110. fontTools/unicodedata/Scripts.py +173 -152
  111. fontTools/unicodedata/__init__.py +10 -2
  112. fontTools/varLib/__init__.py +198 -109
  113. fontTools/varLib/avar/__init__.py +0 -0
  114. fontTools/varLib/avar/__main__.py +72 -0
  115. fontTools/varLib/avar/build.py +79 -0
  116. fontTools/varLib/avar/map.py +108 -0
  117. fontTools/varLib/avar/plan.py +1004 -0
  118. fontTools/varLib/{avar.py → avar/unbuild.py} +70 -59
  119. fontTools/varLib/avarPlanner.py +3 -999
  120. fontTools/varLib/featureVars.py +21 -7
  121. fontTools/varLib/hvar.py +113 -0
  122. fontTools/varLib/instancer/__init__.py +180 -65
  123. fontTools/varLib/interpolatableHelpers.py +3 -0
  124. fontTools/varLib/iup.c +7564 -6903
  125. fontTools/varLib/iup.cpython-313-aarch64-linux-musl.so +0 -0
  126. fontTools/varLib/models.py +17 -2
  127. fontTools/varLib/mutator.py +11 -0
  128. fontTools/varLib/varStore.py +10 -38
  129. fontTools/voltLib/__main__.py +206 -0
  130. fontTools/voltLib/ast.py +4 -0
  131. fontTools/voltLib/parser.py +16 -8
  132. fontTools/voltLib/voltToFea.py +347 -166
  133. {fonttools-4.55.4.dist-info → fonttools-4.61.1.dist-info}/METADATA +269 -1410
  134. {fonttools-4.55.4.dist-info → fonttools-4.61.1.dist-info}/RECORD +318 -294
  135. {fonttools-4.55.4.dist-info → fonttools-4.61.1.dist-info}/WHEEL +1 -1
  136. fonttools-4.61.1.dist-info/licenses/LICENSE.external +388 -0
  137. {fonttools-4.55.4.data → fonttools-4.61.1.data}/data/share/man/man1/ttx.1 +0 -0
  138. {fonttools-4.55.4.dist-info → fonttools-4.61.1.dist-info}/entry_points.txt +0 -0
  139. {fonttools-4.55.4.dist-info → fonttools-4.61.1.dist-info/licenses}/LICENSE +0 -0
  140. {fonttools-4.55.4.dist-info → fonttools-4.61.1.dist-info}/top_level.txt +0 -0
@@ -46,6 +46,7 @@ Limitations
46
46
  import logging
47
47
  import re
48
48
  from io import StringIO
49
+ from graphlib import TopologicalSorter
49
50
 
50
51
  from fontTools.feaLib import ast
51
52
  from fontTools.ttLib import TTFont, TTLibError
@@ -57,32 +58,39 @@ log = logging.getLogger("fontTools.voltLib.voltToFea")
57
58
  TABLES = ["GDEF", "GSUB", "GPOS"]
58
59
 
59
60
 
60
- class MarkClassDefinition(ast.MarkClassDefinition):
61
- def asFea(self, indent=""):
62
- res = ""
63
- if not getattr(self, "used", False):
64
- res += "#"
65
- res += ast.MarkClassDefinition.asFea(self, indent)
66
- return res
67
-
68
-
69
- # For sorting voltLib.ast.GlyphDefinition, see its use below.
70
- class Group:
71
- def __init__(self, group):
72
- self.name = group.name.lower()
73
- self.groups = [
74
- x.group.lower() for x in group.enum.enum if isinstance(x, VAst.GroupName)
61
+ def _flatten_group(group):
62
+ ret = []
63
+ if isinstance(group, (tuple, list)):
64
+ for item in group:
65
+ ret.extend(_flatten_group(item))
66
+ elif hasattr(group, "enum"):
67
+ ret.extend(_flatten_group(group.enum))
68
+ else:
69
+ ret.append(group)
70
+ return ret
71
+
72
+
73
+ # Topologically sort of group definitions to ensure that all groups are defined
74
+ # before they are referenced. This is necessary because FEA requires it but
75
+ # VOLT does not, see below.
76
+ def sort_groups(groups):
77
+ group_map = {group.name.lower(): group for group in groups}
78
+ graph = {
79
+ group.name.lower(): [
80
+ x.group.lower()
81
+ for x in _flatten_group(group)
82
+ if isinstance(x, VAst.GroupName)
75
83
  ]
84
+ for group in groups
85
+ }
86
+ sorter = TopologicalSorter(graph)
87
+ return [group_map[name] for name in sorter.static_order()]
88
+
76
89
 
77
- def __lt__(self, other):
78
- if self.name in other.groups:
79
- return True
80
- if other.name in self.groups:
81
- return False
82
- if self.groups and not other.groups:
83
- return False
84
- if not self.groups and other.groups:
85
- return True
90
+ class Lookup(ast.LookupBlock):
91
+ def __init__(self, name, use_extension=False, location=None):
92
+ super().__init__(name, use_extension, location)
93
+ self.chained = []
86
94
 
87
95
 
88
96
  class VoltToFea:
@@ -90,7 +98,10 @@ class VoltToFea:
90
98
  _NOT_CLASS_NAME_RE = re.compile(r"[^A-Za-z_0-9.\-]")
91
99
 
92
100
  def __init__(self, file_or_path, font=None):
93
- self._file_or_path = file_or_path
101
+ if isinstance(file_or_path, VAst.VoltFile):
102
+ self._doc, self._file_or_path = file_or_path, None
103
+ else:
104
+ self._doc, self._file_or_path = None, file_or_path
94
105
  self._font = font
95
106
 
96
107
  self._glyph_map = {}
@@ -128,23 +139,26 @@ class VoltToFea:
128
139
  self._class_names[name] = res
129
140
  return self._class_names[name]
130
141
 
131
- def _collectStatements(self, doc, tables):
142
+ def _collectStatements(self, doc, tables, ignore_unsupported_settings=False):
143
+ # Collect glyph difinitions first, as we need them to map VOLT glyph names to font glyph name.
144
+ for statement in doc.statements:
145
+ if isinstance(statement, VAst.GlyphDefinition):
146
+ self._glyphDefinition(statement)
147
+
132
148
  # Collect and sort group definitions first, to make sure a group
133
149
  # definition that references other groups comes after them since VOLT
134
150
  # does not enforce such ordering, and feature file require it.
135
151
  groups = [s for s in doc.statements if isinstance(s, VAst.GroupDefinition)]
136
- for statement in sorted(groups, key=lambda x: Group(x)):
137
- self._groupDefinition(statement)
152
+ for group in sort_groups(groups):
153
+ self._groupDefinition(group)
138
154
 
139
155
  for statement in doc.statements:
140
- if isinstance(statement, VAst.GlyphDefinition):
141
- self._glyphDefinition(statement)
142
- elif isinstance(statement, VAst.AnchorDefinition):
156
+ if isinstance(statement, VAst.AnchorDefinition):
143
157
  if "GPOS" in tables:
144
158
  self._anchorDefinition(statement)
145
159
  elif isinstance(statement, VAst.SettingDefinition):
146
- self._settingDefinition(statement)
147
- elif isinstance(statement, VAst.GroupDefinition):
160
+ self._settingDefinition(statement, ignore_unsupported_settings)
161
+ elif isinstance(statement, (VAst.GlyphDefinition, VAst.GroupDefinition)):
148
162
  pass # Handled above
149
163
  elif isinstance(statement, VAst.ScriptDefinition):
150
164
  self._scriptDefinition(statement)
@@ -176,35 +190,57 @@ class VoltToFea:
176
190
  if self._lookups:
177
191
  statements.append(ast.Comment("\n# Lookups"))
178
192
  for lookup in self._lookups.values():
179
- statements.extend(getattr(lookup, "targets", []))
193
+ statements.extend(lookup.chained)
180
194
  statements.append(lookup)
181
195
 
182
196
  # Prune features
183
197
  features = self._features.copy()
184
- for ftag in features:
185
- scripts = features[ftag]
186
- for stag in scripts:
187
- langs = scripts[stag]
188
- for ltag in langs:
189
- langs[ltag] = [l for l in langs[ltag] if l.lower() in self._lookups]
190
- scripts[stag] = {t: l for t, l in langs.items() if l}
191
- features[ftag] = {t: s for t, s in scripts.items() if s}
198
+ for feature_tag in features:
199
+ scripts = features[feature_tag]
200
+ for script_tag in scripts:
201
+ langs = scripts[script_tag]
202
+ for language_tag in langs:
203
+ langs[language_tag] = [
204
+ l for l in langs[language_tag] if l.lower() in self._lookups
205
+ ]
206
+ scripts[script_tag] = {t: l for t, l in langs.items() if l}
207
+ features[feature_tag] = {t: s for t, s in scripts.items() if s}
192
208
  features = {t: f for t, f in features.items() if f}
193
209
 
194
210
  if features:
195
211
  statements.append(ast.Comment("# Features"))
196
- for ftag, scripts in features.items():
197
- feature = ast.FeatureBlock(ftag)
198
- stags = sorted(scripts, key=lambda k: 0 if k == "DFLT" else 1)
199
- for stag in stags:
200
- feature.statements.append(ast.ScriptStatement(stag))
201
- ltags = sorted(scripts[stag], key=lambda k: 0 if k == "dflt" else 1)
202
- for ltag in ltags:
203
- include_default = True if ltag == "dflt" else False
204
- feature.statements.append(
205
- ast.LanguageStatement(ltag, include_default=include_default)
212
+ for feature_tag, scripts in features.items():
213
+ feature = ast.FeatureBlock(feature_tag)
214
+ script_tags = sorted(scripts, key=lambda k: 0 if k == "DFLT" else 1)
215
+ if feature_tag == "aalt" and len(script_tags) > 1:
216
+ log.warning(
217
+ "FEA syntax does not allow script statements in 'aalt' feature, "
218
+ "so only lookups from the first script will be included."
219
+ )
220
+ script_tags = script_tags[:1]
221
+ for script_tag in script_tags:
222
+ if feature_tag != "aalt":
223
+ feature.statements.append(ast.ScriptStatement(script_tag))
224
+ language_tags = sorted(
225
+ scripts[script_tag],
226
+ key=lambda k: 0 if k == "dflt" else 1,
227
+ )
228
+ if feature_tag == "aalt" and len(language_tags) > 1:
229
+ log.warning(
230
+ "FEA syntax does not allow language statements in 'aalt' feature, "
231
+ "so only lookups from the first language will be included."
206
232
  )
207
- for name in scripts[stag][ltag]:
233
+ language_tags = language_tags[:1]
234
+ for language_tag in language_tags:
235
+ if feature_tag != "aalt":
236
+ include_default = True if language_tag == "dflt" else False
237
+ feature.statements.append(
238
+ ast.LanguageStatement(
239
+ language_tag.ljust(4),
240
+ include_default=include_default,
241
+ )
242
+ )
243
+ for name in scripts[script_tag][language_tag]:
208
244
  lookup = self._lookups[name.lower()]
209
245
  lookupref = ast.LookupReferenceStatement(lookup)
210
246
  feature.statements.append(lookupref)
@@ -227,15 +263,17 @@ class VoltToFea:
227
263
 
228
264
  return doc
229
265
 
230
- def convert(self, tables=None):
231
- doc = VoltParser(self._file_or_path).parse()
266
+ def convert(self, tables=None, ignore_unsupported_settings=False):
267
+ if self._doc is None:
268
+ self._doc = VoltParser(self._file_or_path).parse()
269
+ doc = self._doc
232
270
 
233
271
  if tables is None:
234
272
  tables = TABLES
235
273
  if self._font is not None:
236
274
  self._glyph_order = self._font.getGlyphOrder()
237
275
 
238
- self._collectStatements(doc, tables)
276
+ self._collectStatements(doc, tables, ignore_unsupported_settings)
239
277
  fea = self._buildFeatureFile(tables)
240
278
  return fea.asFea()
241
279
 
@@ -253,7 +291,13 @@ class VoltToFea:
253
291
  name = group
254
292
  return ast.GlyphClassName(self._glyphclasses[name.lower()])
255
293
 
256
- def _coverage(self, coverage):
294
+ def _glyphSet(self, item):
295
+ return [
296
+ (self._glyphName(x) if isinstance(x, (str, VAst.GlyphName)) else x)
297
+ for x in item.glyphSet()
298
+ ]
299
+
300
+ def _coverage(self, coverage, flatten=False):
257
301
  items = []
258
302
  for item in coverage:
259
303
  if isinstance(item, VAst.GlyphName):
@@ -261,31 +305,38 @@ class VoltToFea:
261
305
  elif isinstance(item, VAst.GroupName):
262
306
  items.append(self._groupName(item))
263
307
  elif isinstance(item, VAst.Enum):
264
- items.append(self._enum(item))
308
+ item = self._coverage(item.enum, flatten=True)
309
+ if flatten:
310
+ items.extend(item)
311
+ else:
312
+ items.append(ast.GlyphClass(item))
265
313
  elif isinstance(item, VAst.Range):
266
- items.append((item.start, item.end))
314
+ item = self._glyphSet(item)
315
+ if flatten:
316
+ items.extend(item)
317
+ else:
318
+ items.append(ast.GlyphClass(item))
267
319
  else:
268
320
  raise NotImplementedError(item)
269
321
  return items
270
322
 
271
- def _enum(self, enum):
272
- return ast.GlyphClass(self._coverage(enum.enum))
273
-
274
323
  def _context(self, context):
275
324
  out = []
276
325
  for item in context:
277
- coverage = self._coverage(item)
278
- if not isinstance(coverage, (tuple, list)):
279
- coverage = [coverage]
280
- out.extend(coverage)
326
+ coverage = self._coverage(item, flatten=True)
327
+ if len(coverage) > 1:
328
+ coverage = ast.GlyphClass(coverage)
329
+ else:
330
+ coverage = coverage[0]
331
+ out.append(coverage)
281
332
  return out
282
333
 
283
334
  def _groupDefinition(self, group):
284
335
  name = self._className(group.name)
285
- glyphs = self._enum(group.enum)
286
- glyphclass = ast.GlyphClassDefinition(name, glyphs)
287
-
288
- self._glyphclasses[group.name.lower()] = glyphclass
336
+ glyphs = self._coverage(group.enum.enum, flatten=True)
337
+ glyphclass = ast.GlyphClass(glyphs)
338
+ classdef = ast.GlyphClassDefinition(name, glyphclass)
339
+ self._glyphclasses[group.name.lower()] = classdef
289
340
 
290
341
  def _glyphDefinition(self, glyph):
291
342
  try:
@@ -317,10 +368,10 @@ class VoltToFea:
317
368
  assert ltag not in self._features[ftag][stag]
318
369
  self._features[ftag][stag][ltag] = lookups.keys()
319
370
 
320
- def _settingDefinition(self, setting):
371
+ def _settingDefinition(self, setting, ignore_unsupported=False):
321
372
  if setting.name.startswith("COMPILER_"):
322
373
  self._settings[setting.name] = setting.value
323
- else:
374
+ elif not ignore_unsupported:
324
375
  log.warning(f"Unsupported setting ignored: {setting.name}")
325
376
 
326
377
  def _adjustment(self, adjustment):
@@ -358,18 +409,15 @@ class VoltToFea:
358
409
  glyphname = anchordef.glyph_name
359
410
  anchor = self._anchor(anchordef.pos)
360
411
 
412
+ if glyphname not in self._anchors:
413
+ self._anchors[glyphname] = {}
361
414
  if anchorname.startswith("MARK_"):
362
- name = "_".join(anchorname.split("_")[1:])
363
- markclass = ast.MarkClass(self._className(name))
364
- glyph = self._glyphName(glyphname)
365
- markdef = MarkClassDefinition(markclass, anchor, glyph)
366
- self._markclasses[(glyphname, anchorname)] = markdef
415
+ anchorname = anchorname[:5] + anchorname[5:].lower()
367
416
  else:
368
- if glyphname not in self._anchors:
369
- self._anchors[glyphname] = {}
370
- if anchorname not in self._anchors[glyphname]:
371
- self._anchors[glyphname][anchorname] = {}
372
- self._anchors[glyphname][anchorname][anchordef.component] = anchor
417
+ anchorname = anchorname.lower()
418
+ if anchorname not in self._anchors[glyphname]:
419
+ self._anchors[glyphname][anchorname] = {}
420
+ self._anchors[glyphname][anchorname][anchordef.component] = anchor
373
421
 
374
422
  def _gposLookup(self, lookup, fealookup):
375
423
  statements = fealookup.statements
@@ -408,43 +456,66 @@ class VoltToFea:
408
456
  )
409
457
  elif isinstance(pos, VAst.PositionAttachDefinition):
410
458
  anchors = {}
411
- for marks, classname in pos.coverage_to:
412
- for mark in marks:
413
- # Set actually used mark classes. Basically a hack to get
414
- # around the feature file syntax limitation of making mark
415
- # classes global and not allowing mark positioning to
416
- # specify mark coverage.
417
- for name in mark.glyphSet():
418
- key = (name, "MARK_" + classname)
419
- self._markclasses[key].used = True
420
- markclass = ast.MarkClass(self._className(classname))
459
+ allmarks = set()
460
+ for coverage, anchorname in pos.coverage_to:
461
+ # In feature files mark classes are global, but in VOLT they
462
+ # are defined per-lookup. If we output mark class definitions
463
+ # for all marks that use a given anchor, we might end up with a
464
+ # mark used in two different classes in the same lookup, which
465
+ # is causes feature file compilation error.
466
+ # At the expense of uglier feature code, we make the mark class
467
+ # name by appending the current lookup name not the anchor
468
+ # name, and output mark class definitions only for marks used
469
+ # in this lookup.
470
+ classname = self._className(f"{anchorname}.{lookup.name}")
471
+ markclass = ast.MarkClass(classname)
472
+
473
+ # Anchor names are case-insensitive in VOLT
474
+ anchorname = anchorname.lower()
475
+
476
+ # We might still end in marks used in two different anchor
477
+ # classes, so we filter out already used marks.
478
+ marks = set()
479
+ for mark in coverage:
480
+ marks.update(mark.glyphSet())
481
+ if not marks.isdisjoint(allmarks):
482
+ marks.difference_update(allmarks)
483
+ if not marks:
484
+ continue
485
+ allmarks.update(marks)
486
+
487
+ for glyphname in marks:
488
+ glyph = self._glyphName(glyphname)
489
+ anchor = self._anchors[glyphname][f"MARK_{anchorname}"][1]
490
+ markdef = ast.MarkClassDefinition(markclass, anchor, glyph)
491
+ self._markclasses[(glyphname, classname)] = markdef
492
+
421
493
  for base in pos.coverage:
422
494
  for name in base.glyphSet():
423
495
  if name not in anchors:
424
496
  anchors[name] = []
425
- if classname not in anchors[name]:
426
- anchors[name].append(classname)
497
+ if (anchorname, classname) not in anchors[name]:
498
+ anchors[name].append((anchorname, classname))
427
499
 
500
+ is_ligature = all(n in self._ligatures for n in anchors)
501
+ is_mark = all(n in self._marks for n in anchors)
428
502
  for name in anchors:
429
503
  components = 1
430
- if name in self._ligatures:
504
+ if is_ligature:
431
505
  components = self._ligatures[name]
432
506
 
433
- marks = []
434
- for mark in anchors[name]:
435
- markclass = ast.MarkClass(self._className(mark))
507
+ marks = [[] for _ in range(components)]
508
+ for mark, classname in anchors[name]:
509
+ markclass = ast.MarkClass(classname)
436
510
  for component in range(1, components + 1):
437
- if len(marks) < component:
438
- marks.append([])
439
- anchor = None
440
511
  if component in self._anchors[name][mark]:
441
512
  anchor = self._anchors[name][mark][component]
442
- marks[component - 1].append((anchor, markclass))
513
+ marks[component - 1].append((anchor, markclass))
443
514
 
444
515
  base = self._glyphName(name)
445
- if name in self._marks:
516
+ if is_mark:
446
517
  mark = ast.MarkMarkPosStatement(base, marks[0])
447
- elif name in self._ligatures:
518
+ elif is_ligature:
448
519
  mark = ast.MarkLigPosStatement(base, marks)
449
520
  else:
450
521
  mark = ast.MarkBasePosStatement(base, marks[0])
@@ -481,13 +552,9 @@ class VoltToFea:
481
552
  else:
482
553
  raise NotImplementedError(pos)
483
554
 
484
- def _gposContextLookup(
485
- self, lookup, prefix, suffix, ignore, fealookup, targetlookup
486
- ):
555
+ def _gposContextLookup(self, lookup, prefix, suffix, ignore, fealookup, chained):
487
556
  statements = fealookup.statements
488
557
 
489
- assert not lookup.reversal
490
-
491
558
  pos = lookup.pos
492
559
  if isinstance(pos, VAst.PositionAdjustPairDefinition):
493
560
  for (idx1, idx2), (pos1, pos2) in pos.adjust_pair.items():
@@ -500,79 +567,181 @@ class VoltToFea:
500
567
  if ignore:
501
568
  statement = ast.IgnorePosStatement([(prefix, glyphs, suffix)])
502
569
  else:
503
- lookups = (targetlookup, targetlookup)
504
570
  statement = ast.ChainContextPosStatement(
505
- prefix, glyphs, suffix, lookups
571
+ prefix, glyphs, suffix, [chained, chained]
506
572
  )
507
573
  statements.append(statement)
508
574
  elif isinstance(pos, VAst.PositionAdjustSingleDefinition):
509
575
  glyphs = [ast.GlyphClass()]
510
- for a, b in pos.adjust_single:
511
- glyph = self._coverage(a)
512
- glyphs[0].extend(glyph)
576
+ for a, _ in pos.adjust_single:
577
+ glyphs[0].extend(self._coverage(a, flatten=True))
513
578
 
514
579
  if ignore:
515
580
  statement = ast.IgnorePosStatement([(prefix, glyphs, suffix)])
516
581
  else:
517
582
  statement = ast.ChainContextPosStatement(
518
- prefix, glyphs, suffix, [targetlookup]
583
+ prefix, glyphs, suffix, [chained]
519
584
  )
520
585
  statements.append(statement)
521
586
  elif isinstance(pos, VAst.PositionAttachDefinition):
522
587
  glyphs = [ast.GlyphClass()]
523
588
  for coverage, _ in pos.coverage_to:
524
- glyphs[0].extend(self._coverage(coverage))
589
+ glyphs[0].extend(self._coverage(coverage, flatten=True))
525
590
 
526
591
  if ignore:
527
592
  statement = ast.IgnorePosStatement([(prefix, glyphs, suffix)])
528
593
  else:
529
594
  statement = ast.ChainContextPosStatement(
530
- prefix, glyphs, suffix, [targetlookup]
595
+ prefix, glyphs, suffix, [chained]
531
596
  )
532
597
  statements.append(statement)
533
598
  else:
534
599
  raise NotImplementedError(pos)
535
600
 
536
- def _gsubLookup(self, lookup, prefix, suffix, ignore, chain, fealookup):
601
+ def _gsubLookup(self, lookup, fealookup):
537
602
  statements = fealookup.statements
538
603
 
539
604
  sub = lookup.sub
605
+
606
+ # Alternate substitutions are represented by adding multiple
607
+ # substitutions for the same glyph, so we need to collect them into one
608
+ # to many mapping.
609
+ if isinstance(sub, VAst.SubstitutionAlternateDefinition):
610
+ alternates = {}
611
+ for key, val in sub.mapping.items():
612
+ if not key or not val:
613
+ path, line, column = sub.location
614
+ log.warning(f"{path}:{line}:{column}: Ignoring empty substitution")
615
+ continue
616
+ glyphs = self._coverage(key)
617
+ replacements = self._coverage(val)
618
+ assert len(glyphs) == 1
619
+ for src_glyph, repl_glyph in zip(
620
+ glyphs[0].glyphSet(), replacements[0].glyphSet()
621
+ ):
622
+ alternates.setdefault(str(self._glyphName(src_glyph)), []).append(
623
+ str(self._glyphName(repl_glyph))
624
+ )
625
+
626
+ for glyph, replacements in alternates.items():
627
+ statement = ast.AlternateSubstStatement(
628
+ [], glyph, [], ast.GlyphClass(replacements)
629
+ )
630
+ statements.append(statement)
631
+ return
632
+
540
633
  for key, val in sub.mapping.items():
541
634
  if not key or not val:
542
635
  path, line, column = sub.location
543
636
  log.warning(f"{path}:{line}:{column}: Ignoring empty substitution")
544
637
  continue
545
- statement = None
546
638
  glyphs = self._coverage(key)
547
639
  replacements = self._coverage(val)
548
- if ignore:
549
- chain_context = (prefix, glyphs, suffix)
550
- statement = ast.IgnoreSubstStatement([chain_context])
551
- elif isinstance(sub, VAst.SubstitutionSingleDefinition):
640
+ if isinstance(sub, VAst.SubstitutionSingleDefinition):
552
641
  assert len(glyphs) == 1
553
642
  assert len(replacements) == 1
554
- statement = ast.SingleSubstStatement(
555
- glyphs, replacements, prefix, suffix, chain
643
+ statements.append(
644
+ ast.SingleSubstStatement(glyphs, replacements, [], [], False)
556
645
  )
557
646
  elif isinstance(sub, VAst.SubstitutionReverseChainingSingleDefinition):
558
- assert len(glyphs) == 1
559
- assert len(replacements) == 1
560
- statement = ast.ReverseChainSingleSubstStatement(
561
- prefix, suffix, glyphs, replacements
562
- )
647
+ # This is handled in gsubContextLookup()
648
+ pass
563
649
  elif isinstance(sub, VAst.SubstitutionMultipleDefinition):
564
650
  assert len(glyphs) == 1
565
- statement = ast.MultipleSubstStatement(
566
- prefix, glyphs[0], suffix, replacements, chain
651
+ statements.append(
652
+ ast.MultipleSubstStatement([], glyphs[0], [], replacements)
567
653
  )
568
654
  elif isinstance(sub, VAst.SubstitutionLigatureDefinition):
569
655
  assert len(replacements) == 1
570
656
  statement = ast.LigatureSubstStatement(
571
- prefix, glyphs, suffix, replacements[0], chain
657
+ [], glyphs, [], replacements[0], False
572
658
  )
659
+
660
+ # If any of the input glyphs is a group, we need to
661
+ # explode the substitution into multiple ligature substitutions
662
+ # since feature file syntax does not support classes in
663
+ # ligature substitutions.
664
+ n = max(len(x.glyphSet()) for x in glyphs)
665
+ if n > 1:
666
+ # All input should either be groups of the same length or single glyphs
667
+ assert all(len(x.glyphSet()) in (n, 1) for x in glyphs)
668
+ glyphs = [x.glyphSet() for x in glyphs]
669
+ glyphs = [([x[0]] * n if len(x) == 1 else x) for x in glyphs]
670
+
671
+ # In this case ligature replacements must be a group of the same length
672
+ # as the input groups, or a single glyph. VOLT
673
+ # allows the replacement glyphs to be longer and truncates them.
674
+ # So well allow that and zip() below will do the truncation
675
+ # for us.
676
+ replacement = replacements[0].glyphSet()
677
+ if len(replacement) == 1:
678
+ replacement = [replacement[0]] * n
679
+ assert len(replacement) >= n
680
+
681
+ # Add the unexploded statement commented out for reference.
682
+ statements.append(ast.Comment(f"# {statement}"))
683
+
684
+ for zipped in zip(*glyphs, replacement):
685
+ zipped = [self._glyphName(x) for x in zipped]
686
+ statements.append(
687
+ ast.LigatureSubstStatement(
688
+ [], zipped[:-1], [], zipped[-1], False
689
+ )
690
+ )
691
+ else:
692
+ statements.append(statement)
573
693
  else:
574
694
  raise NotImplementedError(sub)
575
- statements.append(statement)
695
+
696
+ def _gsubContextLookup(self, lookup, prefix, suffix, ignore, fealookup, chained):
697
+ statements = fealookup.statements
698
+
699
+ sub = lookup.sub
700
+
701
+ if isinstance(sub, VAst.SubstitutionReverseChainingSingleDefinition):
702
+ # Reverse substitutions is a special case, it can’t use chained lookups.
703
+ for key, val in sub.mapping.items():
704
+ if not key or not val:
705
+ path, line, column = sub.location
706
+ log.warning(f"{path}:{line}:{column}: Ignoring empty substitution")
707
+ continue
708
+ glyphs = self._coverage(key)
709
+ replacements = self._coverage(val)
710
+ statements.append(
711
+ ast.ReverseChainSingleSubstStatement(
712
+ prefix, suffix, glyphs, replacements
713
+ )
714
+ )
715
+ fealookup.chained = []
716
+ return
717
+
718
+ if not isinstance(
719
+ sub,
720
+ (
721
+ VAst.SubstitutionSingleDefinition,
722
+ VAst.SubstitutionMultipleDefinition,
723
+ VAst.SubstitutionLigatureDefinition,
724
+ VAst.SubstitutionAlternateDefinition,
725
+ ),
726
+ ):
727
+ raise NotImplementedError(type(sub))
728
+
729
+ glyphs = []
730
+ for key, val in sub.mapping.items():
731
+ if not key or not val:
732
+ path, line, column = sub.location
733
+ log.warning(f"{path}:{line}:{column}: Ignoring empty substitution")
734
+ continue
735
+ glyphs.extend(self._coverage(key, flatten=True))
736
+
737
+ if len(glyphs) > 1:
738
+ glyphs = [ast.GlyphClass(glyphs)]
739
+ if ignore:
740
+ statements.append(ast.IgnoreSubstStatement([(prefix, glyphs, suffix)]))
741
+ else:
742
+ statements.append(
743
+ ast.ChainContextSubstStatement(prefix, glyphs, suffix, [chained])
744
+ )
576
745
 
577
746
  def _lookupDefinition(self, lookup):
578
747
  mark_attachement = None
@@ -598,13 +767,21 @@ class VoltToFea:
598
767
  lookupflags = ast.LookupFlagStatement(
599
768
  flags, mark_attachement, mark_filtering
600
769
  )
770
+
771
+ use_extension = False
772
+ if self._settings.get("COMPILER_USEEXTENSIONLOOKUPS"):
773
+ use_extension = True
774
+
601
775
  if "\\" in lookup.name:
602
776
  # Merge sub lookups as subtables (lookups named “base\sub”),
603
777
  # makeotf/feaLib will issue a warning and ignore the subtable
604
778
  # statement if it is not a pairpos lookup, though.
605
779
  name = lookup.name.split("\\")[0]
606
780
  if name.lower() not in self._lookups:
607
- fealookup = ast.LookupBlock(self._lookupName(name))
781
+ fealookup = Lookup(
782
+ self._lookupName(name),
783
+ use_extension=use_extension,
784
+ )
608
785
  if lookupflags is not None:
609
786
  fealookup.statements.append(lookupflags)
610
787
  fealookup.statements.append(ast.Comment("# " + lookup.name))
@@ -614,7 +791,10 @@ class VoltToFea:
614
791
  fealookup.statements.append(ast.Comment("# " + lookup.name))
615
792
  self._lookups[name.lower()] = fealookup
616
793
  else:
617
- fealookup = ast.LookupBlock(self._lookupName(lookup.name))
794
+ fealookup = Lookup(
795
+ self._lookupName(lookup.name),
796
+ use_extension=use_extension,
797
+ )
618
798
  if lookupflags is not None:
619
799
  fealookup.statements.append(lookupflags)
620
800
  self._lookups[lookup.name.lower()] = fealookup
@@ -623,39 +803,40 @@ class VoltToFea:
623
803
  fealookup.statements.append(ast.Comment("# " + lookup.comments))
624
804
 
625
805
  contexts = []
626
- if lookup.context:
627
- for context in lookup.context:
628
- prefix = self._context(context.left)
629
- suffix = self._context(context.right)
630
- ignore = context.ex_or_in == "EXCEPT_CONTEXT"
631
- contexts.append([prefix, suffix, ignore, False])
632
- # It seems that VOLT will create contextual substitution using
633
- # only the input if there is no other contexts in this lookup.
634
- if ignore and len(lookup.context) == 1:
635
- contexts.append([[], [], False, True])
636
- else:
637
- contexts.append([[], [], False, False])
638
-
639
- targetlookup = None
640
- for prefix, suffix, ignore, chain in contexts:
806
+ for context in lookup.context:
807
+ prefix = self._context(context.left)
808
+ suffix = self._context(context.right)
809
+ ignore = context.ex_or_in == "EXCEPT_CONTEXT"
810
+ contexts.append([prefix, suffix, ignore])
811
+ # It seems that VOLT will create contextual substitution using
812
+ # only the input if there is no other contexts in this lookup.
813
+ if ignore and len(lookup.context) == 1:
814
+ contexts.append([[], [], False])
815
+
816
+ if contexts:
817
+ chained = ast.LookupBlock(
818
+ self._lookupName(lookup.name + " chained"),
819
+ use_extension=use_extension,
820
+ )
821
+ fealookup.chained.append(chained)
641
822
  if lookup.sub is not None:
642
- self._gsubLookup(lookup, prefix, suffix, ignore, chain, fealookup)
643
-
644
- if lookup.pos is not None:
645
- if self._settings.get("COMPILER_USEEXTENSIONLOOKUPS"):
646
- fealookup.use_extension = True
647
- if prefix or suffix or chain or ignore:
648
- if not ignore and targetlookup is None:
649
- targetname = self._lookupName(lookup.name + " target")
650
- targetlookup = ast.LookupBlock(targetname)
651
- fealookup.targets = getattr(fealookup, "targets", [])
652
- fealookup.targets.append(targetlookup)
653
- self._gposLookup(lookup, targetlookup)
823
+ self._gsubLookup(lookup, chained)
824
+ elif lookup.pos is not None:
825
+ self._gposLookup(lookup, chained)
826
+ for prefix, suffix, ignore in contexts:
827
+ if lookup.sub is not None:
828
+ self._gsubContextLookup(
829
+ lookup, prefix, suffix, ignore, fealookup, chained
830
+ )
831
+ elif lookup.pos is not None:
654
832
  self._gposContextLookup(
655
- lookup, prefix, suffix, ignore, fealookup, targetlookup
833
+ lookup, prefix, suffix, ignore, fealookup, chained
656
834
  )
657
- else:
658
- self._gposLookup(lookup, fealookup)
835
+ else:
836
+ if lookup.sub is not None:
837
+ self._gsubLookup(lookup, fealookup)
838
+ elif lookup.pos is not None:
839
+ self._gposLookup(lookup, fealookup)
659
840
 
660
841
 
661
842
  def main(args=None):