From 898a0ff2b7444ad28a69ad0c8908f504fddbddbf Mon Sep 17 00:00:00 2001 From: Khaled Hosny Date: Sat, 10 May 2025 20:17:21 +0300 Subject: [PATCH 01/36] =?UTF-8?q?Bump=20version:=204.58.0=20=E2=86=92=204.?= =?UTF-8?q?58.1.dev0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Lib/fontTools/__init__.py | 2 +- setup.cfg | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/__init__.py b/Lib/fontTools/__init__.py index 6e41eb43a8..ed0fd36c35 100644 --- a/Lib/fontTools/__init__.py +++ b/Lib/fontTools/__init__.py @@ -3,6 +3,6 @@ log = logging.getLogger(__name__) -version = __version__ = "4.58.0" +version = __version__ = "4.58.1.dev0" __all__ = ["version", "log", "configLogger"] diff --git a/setup.cfg b/setup.cfg index c280c349f5..8aef1d31a6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 4.58.0 +current_version = 4.58.1.dev0 commit = True tag = False tag_name = {new_version} diff --git a/setup.py b/setup.py index 1d9f1ac9f9..97b22728b4 100755 --- a/setup.py +++ b/setup.py @@ -493,7 +493,7 @@ def build_extensions(self): setup_params = dict( name="fonttools", - version="4.58.0", + version="4.58.1.dev0", description="Tools to manipulate font files", author="Just van Rossum", author_email="just@letterror.com", From 3f8e94ec6bd9916f245eff563b4a0dfd708a50e6 Mon Sep 17 00:00:00 2001 From: NFSL2001 <33471049+NightFurySL2001@users.noreply.github.com> Date: Mon, 12 May 2025 18:40:25 +0800 Subject: [PATCH 02/36] Update text file read to use UTF-8 with BOM --- Lib/fontTools/mtiLib/__init__.py | 2 +- Lib/fontTools/subset/__init__.py | 2 +- Lib/fontTools/ufoLib/__init__.py | 2 +- MetaTools/buildUCD.py | 2 +- Snippets/svg2glif.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Lib/fontTools/mtiLib/__init__.py b/Lib/fontTools/mtiLib/__init__.py index e797be375b..32d3a244e1 100644 --- a/Lib/fontTools/mtiLib/__init__.py +++ b/Lib/fontTools/mtiLib/__init__.py @@ -1375,7 +1375,7 @@ def main(args=None, font=None): for f in args.inputs: log.debug("Processing %s", f) - with open(f, "rt", encoding="utf-8") as f: + with open(f, "rt", encoding="utf-8-sig") as f: table = build(f, font, tableTag=args.tableTag) blob = table.compile(font) # Make sure it compiles decompiled = table.__class__() diff --git a/Lib/fontTools/subset/__init__.py b/Lib/fontTools/subset/__init__.py index 056ad81bab..71d5f5d80d 100644 --- a/Lib/fontTools/subset/__init__.py +++ b/Lib/fontTools/subset/__init__.py @@ -3757,7 +3757,7 @@ def main(args=None): text += g[7:] continue if g.startswith("--text-file="): - with open(g[12:], encoding="utf-8") as f: + with open(g[12:], encoding="utf-8-sig") as f: text += f.read().replace("\n", "") continue if g.startswith("--unicodes="): diff --git a/Lib/fontTools/ufoLib/__init__.py b/Lib/fontTools/ufoLib/__init__.py index f76938a8f1..2c5c51d61b 100755 --- a/Lib/fontTools/ufoLib/__init__.py +++ b/Lib/fontTools/ufoLib/__init__.py @@ -654,7 +654,7 @@ def readFeatures(self): The returned string is empty if the file is missing. """ try: - with self.fs.open(FEATURES_FILENAME, "r", encoding="utf-8") as f: + with self.fs.open(FEATURES_FILENAME, "r", encoding="utf-8-sig") as f: return f.read() except fs.errors.ResourceNotFound: return "" diff --git a/MetaTools/buildUCD.py b/MetaTools/buildUCD.py index 898e3759c6..aec3e9b984 100755 --- a/MetaTools/buildUCD.py +++ b/MetaTools/buildUCD.py @@ -35,7 +35,7 @@ def read_unidata_file(filename, local_ucd_path=None) -> List[str]: Return the list of lines. """ if local_ucd_path is not None: - with open(pjoin(local_ucd_path, filename), "r", encoding="utf-8") as f: + with open(pjoin(local_ucd_path, filename), "r", encoding="utf-8-sig") as f: return f.readlines() else: url = UNIDATA_URL + filename diff --git a/Snippets/svg2glif.py b/Snippets/svg2glif.py index c0aa822bf4..898bbf8354 100755 --- a/Snippets/svg2glif.py +++ b/Snippets/svg2glif.py @@ -131,7 +131,7 @@ def main(args=None): name = os.path.splitext(os.path.basename(svg_file))[0] - with open(svg_file, "r", encoding="utf-8") as f: + with open(svg_file, "r", encoding="utf-8-sig") as f: svg = f.read() glif = svg2glif( From 5fb540126dff0e3ce8ecdd1f2c2f87402cf544da Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Wed, 14 May 2025 14:38:04 -0600 Subject: [PATCH 03/36] Typo --- Lib/fontTools/varLib/hvar.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/fontTools/varLib/hvar.py b/Lib/fontTools/varLib/hvar.py index d243f9aa8c..0bdb16c62b 100644 --- a/Lib/fontTools/varLib/hvar.py +++ b/Lib/fontTools/varLib/hvar.py @@ -18,7 +18,7 @@ def _get_advance_metrics(font, axisTags, tableFields): # advance width at that peak. Then pass these all to a VariationModel # builder to compute back the deltas. # 2. For each master peak, pull out the deltas of the advance width directly, - # and feed these to the VarStoreBuilder, forgoing the remoding step. + # and feed these to the VarStoreBuilder, forgoing the remodeling step. # We'll go with the second option, as it's simpler, faster, and more direct. gvar = font["gvar"] vhAdvanceDeltasAndSupports = {} From f9c3b63070780b0930ab074537fcda2904d67c02 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Thu, 15 May 2025 01:51:31 -0700 Subject: [PATCH 04/36] [instantiate/cff2] Fix vsindex of Private dicts when instantiating (#3828) Also add a TODO for a bug in that code. Fixes https://github.com/fonttools/fonttools/issues/3827 --- Lib/fontTools/varLib/instancer/__init__.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/varLib/instancer/__init__.py b/Lib/fontTools/varLib/instancer/__init__.py index f6df65e96d..4011ad7883 100644 --- a/Lib/fontTools/varLib/instancer/__init__.py +++ b/Lib/fontTools/varLib/instancer/__init__.py @@ -743,7 +743,7 @@ def fetchBlendsFromVarStore(arg): # Add charstring blend lists to VarStore so we can instantiate them for commands in allCommands: - vsindex = 0 + vsindex = 0 # Ouch. This ought to be what set by private dict for command in commands: if command[0] == "vsindex": vsindex = command[1][0] @@ -752,7 +752,6 @@ def fetchBlendsFromVarStore(arg): storeBlendsToVarStore(arg) # Add private blend lists to VarStore so we can instantiate values - vsindex = 0 for opcode, name, arg_type, default, converter in privateDictOperators2: if arg_type not in ("number", "delta", "array"): continue @@ -784,7 +783,7 @@ def fetchBlendsFromVarStore(arg): # Read back new charstring blends from the instantiated VarStore varDataCursor = [0] * len(varStore.VarData) for commands in allCommands: - vsindex = 0 + vsindex = 0 # Ouch. This ought to be what set by private dict for command in commands: if command[0] == "vsindex": vsindex = command[1][0] @@ -799,9 +798,15 @@ def fetchBlendsFromVarStore(arg): if arg_type not in ("number", "delta", "array"): continue + vsindex = 0 for private in privateDicts: if not hasattr(private, name): continue + + if name == "vsindex": + vsindex = values[0] + continue + values = getattr(private, name) if arg_type == "number": values = [values] From 67c34939e405d953b4327f01e977b30d356e2deb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hu=C3=A1ng=20J=C3=B9nli=C3=A0ng?= Date: Thu, 15 May 2025 08:28:21 -0400 Subject: [PATCH 05/36] [merge] handle cmap format=14 subtable --- Lib/fontTools/merge/cmap.py | 32 ++++++++++++- Lib/fontTools/merge/tables.py | 12 ++++- Tests/merge/merge_test.py | 85 +++++++++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 2 deletions(-) diff --git a/Lib/fontTools/merge/cmap.py b/Lib/fontTools/merge/cmap.py index 3209a5d7b8..3b83ad2dfc 100644 --- a/Lib/fontTools/merge/cmap.py +++ b/Lib/fontTools/merge/cmap.py @@ -53,6 +53,26 @@ def _glyphsAreSame( return False return True +def computeMegaUvs(merger, uvsTables): + """Returns merged UVS subtable (cmap format=14).""" + uvsDict = {} + cmap = merger.cmap + for table in uvsTables: + for variationSelector, uvsMapping in table.uvsDict.items(): + if variationSelector not in uvsDict: + uvsDict[variationSelector] = {} + for (unicodeValue, glyphName) in uvsMapping: + if cmap.get(unicodeValue) == glyphName: + # this is a default variation + glyphName = None + # prefer previous glyph id if both fonts defined UVS + if unicodeValue not in uvsDict[variationSelector]: + uvsDict[variationSelector][unicodeValue] = glyphName + + for variationSelector in uvsDict: + uvsDict[variationSelector] = [*uvsDict[variationSelector].items()] + + return uvsDict # Valid (format, platformID, platEncID) triplets for cmap subtables containing # Unicode BMP-only and Unicode Full Repertoire semantics. @@ -61,24 +81,29 @@ def _glyphsAreSame( class _CmapUnicodePlatEncodings: BMP = {(4, 3, 1), (4, 0, 3), (4, 0, 4), (4, 0, 6)} FullRepertoire = {(12, 3, 10), (12, 0, 4), (12, 0, 6)} + UVS = {(14, 0, 5)} def computeMegaCmap(merger, cmapTables): - """Sets merger.cmap and merger.glyphOrder.""" + """Sets merger.cmap and merger.uvsDict.""" # TODO Handle format=14. # Only merge format 4 and 12 Unicode subtables, ignores all other subtables # If there is a format 12 table for a font, ignore the format 4 table of it chosenCmapTables = [] + chosenUvsTables = [] for fontIdx, table in enumerate(cmapTables): format4 = None format12 = None + format14 = None for subtable in table.tables: properties = (subtable.format, subtable.platformID, subtable.platEncID) if properties in _CmapUnicodePlatEncodings.BMP: format4 = subtable elif properties in _CmapUnicodePlatEncodings.FullRepertoire: format12 = subtable + elif properties in _CmapUnicodePlatEncodings.UVS: + format14 = subtable else: log.warning( "Dropped cmap subtable from font '%s':\t" @@ -93,6 +118,9 @@ def computeMegaCmap(merger, cmapTables): elif format4 is not None: chosenCmapTables.append((format4, fontIdx)) + if format14 is not None: + chosenUvsTables.append(format14) + # Build the unicode mapping merger.cmap = cmap = {} fontIndexForGlyph = {} @@ -126,6 +154,8 @@ def computeMegaCmap(merger, cmapTables): log.warning( "Dropped mapping from codepoint %#06X to glyphId '%s'", uni, gid ) + + merger.uvsDict = computeMegaUvs(merger, chosenUvsTables) def renameCFFCharStrings(merger, glyphOrder, cffTable): diff --git a/Lib/fontTools/merge/tables.py b/Lib/fontTools/merge/tables.py index 208a5099ff..9f476c9378 100644 --- a/Lib/fontTools/merge/tables.py +++ b/Lib/fontTools/merge/tables.py @@ -312,7 +312,6 @@ def merge(self, m, tables): @add_method(ttLib.getTableClass("cmap")) def merge(self, m, tables): - # TODO Handle format=14. if not hasattr(m, "cmap"): computeMegaCmap(m, tables) cmap = m.cmap @@ -336,6 +335,17 @@ def merge(self, m, tables): cmapTable.cmap = cmapBmpOnly # ordered by platform then encoding self.tables.insert(0, cmapTable) + + uvsDict = m.uvsDict + if uvsDict: + # format-14 + uvsTable = module.cmap_classes[14](14) + uvsTable.platformID = 0 + uvsTable.platEncID = 5 + uvsTable.language = 0 + uvsTable.uvsDict = uvsDict + # ordered by platform then encoding + self.tables.insert(0, uvsTable) self.tableVersion = 0 self.numSubTables = len(self.tables) return self diff --git a/Tests/merge/merge_test.py b/Tests/merge/merge_test.py index 5558a2e381..90f369c12c 100644 --- a/Tests/merge/merge_test.py +++ b/Tests/merge/merge_test.py @@ -139,6 +139,17 @@ def makeSubtable(self, format, platformID, platEncID, cmap): ) return subtable + def makeUvsSubtable(self, format, platformID, platEncID, uvsDict): + module = ttLib.getTableModule("cmap") + subtable = module.cmap_classes[format](format) + (subtable.platformID, subtable.platEncID, subtable.language, subtable.uvsDict) = ( + platformID, + platEncID, + 0, + uvsDict, + ) + return subtable + # 4-3-1 table merged with 12-3-10 table with no dupes with codepoints outside BMP def test_cmap_merge_no_dupes(self): table1 = self.table1 @@ -199,7 +210,81 @@ def test_cmap_merge_three_dupes(self): self.assertEqual( self.merger.duplicateGlyphsPerFont, [{}, {"space#0": "space#1"}] ) + + def test_cmap_merge_format_14(self): + table1 = self.table1 + table2 = self.table2 + mergedTable = self.mergedTable + + cmap1 = { 0x3404: 'uni3404' } + uvsDict1 = { 0xFE00: [(0x0030, 'uni0030.FE00')], 0xE0101: [(0x3404, 'uni3404')] } + table1.tables = [self.makeUvsSubtable(14, 0, 5, uvsDict1), self.makeSubtable(4, 3, 1, cmap1)] + uvsDict2 = { 0xFE00: [(0x20122, 'u2F803')], 0xE0102: [(0x34FF, 'uni34FF.var2')] } + table2.tables = [self.makeUvsSubtable(14, 0, 5, uvsDict2)] + + self.merger.duplicateGlyphsPerFont = [{}, {}] + mergedTable.merge(self.merger, [table1, table2]) + + self.assertEqual(mergedTable.numSubTables, 2) + (uvsTable, cmapTable) = mergedTable.tables + + self.assertEqual( + (uvsTable.format, uvsTable.platformID, uvsTable.platEncID, uvsTable.language), + (14, 0, 5, 0), + ) + self.assertEqual( + (cmapTable.format, cmapTable.platformID, cmapTable.platEncID, cmapTable.language), + (4, 3, 1, 0), + ) + expectedUvsDict = { + 0xFE00: [(0x0030, 'uni0030.FE00'), (0x20122, 'u2F803')], + 0xE0101: [(0x3404, None)], + 0xE0102: [(0x34FF, 'uni34FF.var2')] + } + self.assertEqual(uvsTable.uvsDict, expectedUvsDict) + + + def test_cmap_merge_format_14_dupes(self): + table1 = self.table1 + table2 = self.table2 + mergedTable = self.mergedTable + + cmap1 = { 0x2F803: 'u2F803#0' } + uvsDict1 = { 0xFE00: [(0x20122, 'u2F803#0')], 0xE0101: [(0x3404, 'uni3404')] } + table1.tables = [self.makeUvsSubtable(14, 0, 5, uvsDict1), self.makeSubtable(12, 3, 10, cmap1)] + cmap2 = { 0x2F803: 'u2F803#1' } + uvsDict2 = { 0xFE00: [(0x20122, 'u2F803#1')], 0xE0102: [(0x34FF, 'uni34FF.var2')] } + table2.tables = [self.makeUvsSubtable(14, 0, 5, uvsDict2), self.makeSubtable(12, 3, 10, cmap2)] + + self.merger.duplicateGlyphsPerFont = [{}, {}] + mergedTable.merge(self.merger, [table1, table2]) + + self.assertEqual(mergedTable.numSubTables, 3) + (uvsTable, cmap_4_3_1_Table, cmap_12_3_10_Table) = mergedTable.tables + + self.assertEqual( + (uvsTable.format, uvsTable.platformID, uvsTable.platEncID, uvsTable.language), + (14, 0, 5, 0), + ) + self.assertEqual( + (cmap_4_3_1_Table.format, cmap_4_3_1_Table.platformID, cmap_4_3_1_Table.platEncID, cmap_4_3_1_Table.language), + (4, 3, 1, 0), + ) + self.assertEqual( + (cmap_12_3_10_Table.format, cmap_12_3_10_Table.platformID, cmap_12_3_10_Table.platEncID, cmap_12_3_10_Table.language), + (12, 3, 10, 0), + ) + + expectedUvsDict = { + 0xFE00: [(0x20122, 'u2F803#0')], + 0xE0101: [(0x3404, 'uni3404')], + 0xE0102: [(0x34FF, 'uni34FF.var2')] + } + self.assertEqual(uvsTable.uvsDict, expectedUvsDict) + self.assertEqual( + self.merger.duplicateGlyphsPerFont, [{}, {"u2F803#0": "u2F803#1"}] + ) def _compile(ttFont): buf = io.BytesIO() From 71de136b082ec590cb2e2ac1f105453996c96602 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hu=C3=A1ng=20J=C3=B9nli=C3=A0ng?= Date: Thu, 15 May 2025 09:26:38 -0400 Subject: [PATCH 06/36] introduce isUVS subtable helper --- Lib/fontTools/ttLib/tables/O_S_2f_2.py | 2 +- Lib/fontTools/ttLib/tables/_c_m_a_p.py | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/ttLib/tables/O_S_2f_2.py b/Lib/fontTools/ttLib/tables/O_S_2f_2.py index 9a7e5f70bb..e1986ad052 100644 --- a/Lib/fontTools/ttLib/tables/O_S_2f_2.py +++ b/Lib/fontTools/ttLib/tables/O_S_2f_2.py @@ -259,7 +259,7 @@ def updateFirstAndLastCharIndex(self, ttFont): return codes = set() for table in getattr(ttFont["cmap"], "tables", []): - if table.isUnicode(): + if table.isUnicode() and not table.isUVS(): codes.update(table.cmap.keys()) if codes: minCode = min(codes) diff --git a/Lib/fontTools/ttLib/tables/_c_m_a_p.py b/Lib/fontTools/ttLib/tables/_c_m_a_p.py index 7fad1a2d85..9eee432a4b 100644 --- a/Lib/fontTools/ttLib/tables/_c_m_a_p.py +++ b/Lib/fontTools/ttLib/tables/_c_m_a_p.py @@ -215,12 +215,13 @@ def compile(self, ttFont): {} ) # Some tables are different objects, but compile to the same data chunk for table in self.tables: - offset = seen.get(id(table.cmap)) + mapId = id(table.uvsDict if table.isUVS() else table.cmap) + offset = seen.get(mapId) if offset is None: chunk = table.compile(ttFont) offset = done.get(chunk) if offset is None: - offset = seen[id(table.cmap)] = done[chunk] = totalOffset + len( + offset = seen[mapId] = done[chunk] = totalOffset + len( tableData ) tableData = tableData + chunk @@ -350,6 +351,10 @@ def isUnicode(self): self.platformID == 3 and self.platEncID in [0, 1, 10] ) + def isUVS(self): + """Returns true if it is a UVS subtable""" + return self.platformID == 0 and self.platEncID == 5 + def isSymbol(self): """Returns true if the subtable is for the Symbol encoding (3,0)""" return self.platformID == 3 and self.platEncID == 0 From ac449a57ebe7dc15f5a5fca79c7d519c7c20ed80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hu=C3=A1ng=20J=C3=B9nli=C3=A0ng?= Date: Thu, 15 May 2025 13:30:26 -0400 Subject: [PATCH 07/36] black format --- Lib/fontTools/merge/cmap.py | 10 +-- Lib/fontTools/ttLib/tables/_c_m_a_p.py | 4 +- Tests/merge/merge_test.py | 88 +++++++++++++++++++------- 3 files changed, 72 insertions(+), 30 deletions(-) diff --git a/Lib/fontTools/merge/cmap.py b/Lib/fontTools/merge/cmap.py index 3b83ad2dfc..7cc4a4ead1 100644 --- a/Lib/fontTools/merge/cmap.py +++ b/Lib/fontTools/merge/cmap.py @@ -53,6 +53,7 @@ def _glyphsAreSame( return False return True + def computeMegaUvs(merger, uvsTables): """Returns merged UVS subtable (cmap format=14).""" uvsDict = {} @@ -61,19 +62,20 @@ def computeMegaUvs(merger, uvsTables): for variationSelector, uvsMapping in table.uvsDict.items(): if variationSelector not in uvsDict: uvsDict[variationSelector] = {} - for (unicodeValue, glyphName) in uvsMapping: + for unicodeValue, glyphName in uvsMapping: if cmap.get(unicodeValue) == glyphName: # this is a default variation glyphName = None # prefer previous glyph id if both fonts defined UVS if unicodeValue not in uvsDict[variationSelector]: uvsDict[variationSelector][unicodeValue] = glyphName - + for variationSelector in uvsDict: uvsDict[variationSelector] = [*uvsDict[variationSelector].items()] - + return uvsDict + # Valid (format, platformID, platEncID) triplets for cmap subtables containing # Unicode BMP-only and Unicode Full Repertoire semantics. # Cf. OpenType spec for "Platform specific encodings": @@ -154,7 +156,7 @@ def computeMegaCmap(merger, cmapTables): log.warning( "Dropped mapping from codepoint %#06X to glyphId '%s'", uni, gid ) - + merger.uvsDict = computeMegaUvs(merger, chosenUvsTables) diff --git a/Lib/fontTools/ttLib/tables/_c_m_a_p.py b/Lib/fontTools/ttLib/tables/_c_m_a_p.py index 9eee432a4b..85e904efac 100644 --- a/Lib/fontTools/ttLib/tables/_c_m_a_p.py +++ b/Lib/fontTools/ttLib/tables/_c_m_a_p.py @@ -221,9 +221,7 @@ def compile(self, ttFont): chunk = table.compile(ttFont) offset = done.get(chunk) if offset is None: - offset = seen[mapId] = done[chunk] = totalOffset + len( - tableData - ) + offset = seen[mapId] = done[chunk] = totalOffset + len(tableData) tableData = tableData + chunk data = data + struct.pack(">HHl", table.platformID, table.platEncID, offset) return data + tableData diff --git a/Tests/merge/merge_test.py b/Tests/merge/merge_test.py index 90f369c12c..9b0883549e 100644 --- a/Tests/merge/merge_test.py +++ b/Tests/merge/merge_test.py @@ -142,7 +142,12 @@ def makeSubtable(self, format, platformID, platEncID, cmap): def makeUvsSubtable(self, format, platformID, platEncID, uvsDict): module = ttLib.getTableModule("cmap") subtable = module.cmap_classes[format](format) - (subtable.platformID, subtable.platEncID, subtable.language, subtable.uvsDict) = ( + ( + subtable.platformID, + subtable.platEncID, + subtable.language, + subtable.uvsDict, + ) = ( platformID, platEncID, 0, @@ -210,16 +215,19 @@ def test_cmap_merge_three_dupes(self): self.assertEqual( self.merger.duplicateGlyphsPerFont, [{}, {"space#0": "space#1"}] ) - + def test_cmap_merge_format_14(self): table1 = self.table1 table2 = self.table2 mergedTable = self.mergedTable - cmap1 = { 0x3404: 'uni3404' } - uvsDict1 = { 0xFE00: [(0x0030, 'uni0030.FE00')], 0xE0101: [(0x3404, 'uni3404')] } - table1.tables = [self.makeUvsSubtable(14, 0, 5, uvsDict1), self.makeSubtable(4, 3, 1, cmap1)] - uvsDict2 = { 0xFE00: [(0x20122, 'u2F803')], 0xE0102: [(0x34FF, 'uni34FF.var2')] } + cmap1 = {0x3404: "uni3404"} + uvsDict1 = {0xFE00: [(0x0030, "uni0030.FE00")], 0xE0101: [(0x3404, "uni3404")]} + table1.tables = [ + self.makeUvsSubtable(14, 0, 5, uvsDict1), + self.makeSubtable(4, 3, 1, cmap1), + ] + uvsDict2 = {0xFE00: [(0x20122, "u2F803")], 0xE0102: [(0x34FF, "uni34FF.var2")]} table2.tables = [self.makeUvsSubtable(14, 0, 5, uvsDict2)] self.merger.duplicateGlyphsPerFont = [{}, {}] @@ -229,33 +237,51 @@ def test_cmap_merge_format_14(self): (uvsTable, cmapTable) = mergedTable.tables self.assertEqual( - (uvsTable.format, uvsTable.platformID, uvsTable.platEncID, uvsTable.language), + ( + uvsTable.format, + uvsTable.platformID, + uvsTable.platEncID, + uvsTable.language, + ), (14, 0, 5, 0), ) self.assertEqual( - (cmapTable.format, cmapTable.platformID, cmapTable.platEncID, cmapTable.language), + ( + cmapTable.format, + cmapTable.platformID, + cmapTable.platEncID, + cmapTable.language, + ), (4, 3, 1, 0), ) expectedUvsDict = { - 0xFE00: [(0x0030, 'uni0030.FE00'), (0x20122, 'u2F803')], + 0xFE00: [(0x0030, "uni0030.FE00"), (0x20122, "u2F803")], 0xE0101: [(0x3404, None)], - 0xE0102: [(0x34FF, 'uni34FF.var2')] + 0xE0102: [(0x34FF, "uni34FF.var2")], } self.assertEqual(uvsTable.uvsDict, expectedUvsDict) - def test_cmap_merge_format_14_dupes(self): table1 = self.table1 table2 = self.table2 mergedTable = self.mergedTable - cmap1 = { 0x2F803: 'u2F803#0' } - uvsDict1 = { 0xFE00: [(0x20122, 'u2F803#0')], 0xE0101: [(0x3404, 'uni3404')] } - table1.tables = [self.makeUvsSubtable(14, 0, 5, uvsDict1), self.makeSubtable(12, 3, 10, cmap1)] - cmap2 = { 0x2F803: 'u2F803#1' } - uvsDict2 = { 0xFE00: [(0x20122, 'u2F803#1')], 0xE0102: [(0x34FF, 'uni34FF.var2')] } - table2.tables = [self.makeUvsSubtable(14, 0, 5, uvsDict2), self.makeSubtable(12, 3, 10, cmap2)] + cmap1 = {0x2F803: "u2F803#0"} + uvsDict1 = {0xFE00: [(0x20122, "u2F803#0")], 0xE0101: [(0x3404, "uni3404")]} + table1.tables = [ + self.makeUvsSubtable(14, 0, 5, uvsDict1), + self.makeSubtable(12, 3, 10, cmap1), + ] + cmap2 = {0x2F803: "u2F803#1"} + uvsDict2 = { + 0xFE00: [(0x20122, "u2F803#1")], + 0xE0102: [(0x34FF, "uni34FF.var2")], + } + table2.tables = [ + self.makeUvsSubtable(14, 0, 5, uvsDict2), + self.makeSubtable(12, 3, 10, cmap2), + ] self.merger.duplicateGlyphsPerFont = [{}, {}] mergedTable.merge(self.merger, [table1, table2]) @@ -264,28 +290,44 @@ def test_cmap_merge_format_14_dupes(self): (uvsTable, cmap_4_3_1_Table, cmap_12_3_10_Table) = mergedTable.tables self.assertEqual( - (uvsTable.format, uvsTable.platformID, uvsTable.platEncID, uvsTable.language), + ( + uvsTable.format, + uvsTable.platformID, + uvsTable.platEncID, + uvsTable.language, + ), (14, 0, 5, 0), ) self.assertEqual( - (cmap_4_3_1_Table.format, cmap_4_3_1_Table.platformID, cmap_4_3_1_Table.platEncID, cmap_4_3_1_Table.language), + ( + cmap_4_3_1_Table.format, + cmap_4_3_1_Table.platformID, + cmap_4_3_1_Table.platEncID, + cmap_4_3_1_Table.language, + ), (4, 3, 1, 0), ) self.assertEqual( - (cmap_12_3_10_Table.format, cmap_12_3_10_Table.platformID, cmap_12_3_10_Table.platEncID, cmap_12_3_10_Table.language), + ( + cmap_12_3_10_Table.format, + cmap_12_3_10_Table.platformID, + cmap_12_3_10_Table.platEncID, + cmap_12_3_10_Table.language, + ), (12, 3, 10, 0), ) expectedUvsDict = { - 0xFE00: [(0x20122, 'u2F803#0')], - 0xE0101: [(0x3404, 'uni3404')], - 0xE0102: [(0x34FF, 'uni34FF.var2')] + 0xFE00: [(0x20122, "u2F803#0")], + 0xE0101: [(0x3404, "uni3404")], + 0xE0102: [(0x34FF, "uni34FF.var2")], } self.assertEqual(uvsTable.uvsDict, expectedUvsDict) self.assertEqual( self.merger.duplicateGlyphsPerFont, [{}, {"u2F803#0": "u2F803#1"}] ) + def _compile(ttFont): buf = io.BytesIO() ttFont.save(buf) From 8901a7bb7952d138bb3407c73b9a3fc058935c34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hu=C3=A1ng=20J=C3=B9nli=C3=A0ng?= Date: Mon, 19 May 2025 09:00:09 -0400 Subject: [PATCH 08/36] address review comments --- Lib/fontTools/ttLib/tables/_c_m_a_p.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/fontTools/ttLib/tables/_c_m_a_p.py b/Lib/fontTools/ttLib/tables/_c_m_a_p.py index 85e904efac..debf83d827 100644 --- a/Lib/fontTools/ttLib/tables/_c_m_a_p.py +++ b/Lib/fontTools/ttLib/tables/_c_m_a_p.py @@ -351,7 +351,7 @@ def isUnicode(self): def isUVS(self): """Returns true if it is a UVS subtable""" - return self.platformID == 0 and self.platEncID == 5 + return self.format == 14 and self.platformID == 0 and self.platEncID == 5 def isSymbol(self): """Returns true if the subtable is for the Symbol encoding (3,0)""" From df22965f8427fb2be6924409fd7c085918051a91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hu=C3=A1ng=20J=C3=B9nli=C3=A0ng?= Date: Mon, 19 May 2025 09:10:34 -0400 Subject: [PATCH 09/36] Revert "introduce isUVS subtable helper" This reverts commit 71de136b082ec590cb2e2ac1f105453996c96602. # Conflicts: # Lib/fontTools/ttLib/tables/_c_m_a_p.py --- Lib/fontTools/ttLib/tables/O_S_2f_2.py | 2 +- Lib/fontTools/ttLib/tables/_c_m_a_p.py | 11 ++++------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/Lib/fontTools/ttLib/tables/O_S_2f_2.py b/Lib/fontTools/ttLib/tables/O_S_2f_2.py index e1986ad052..9a7e5f70bb 100644 --- a/Lib/fontTools/ttLib/tables/O_S_2f_2.py +++ b/Lib/fontTools/ttLib/tables/O_S_2f_2.py @@ -259,7 +259,7 @@ def updateFirstAndLastCharIndex(self, ttFont): return codes = set() for table in getattr(ttFont["cmap"], "tables", []): - if table.isUnicode() and not table.isUVS(): + if table.isUnicode(): codes.update(table.cmap.keys()) if codes: minCode = min(codes) diff --git a/Lib/fontTools/ttLib/tables/_c_m_a_p.py b/Lib/fontTools/ttLib/tables/_c_m_a_p.py index debf83d827..7fad1a2d85 100644 --- a/Lib/fontTools/ttLib/tables/_c_m_a_p.py +++ b/Lib/fontTools/ttLib/tables/_c_m_a_p.py @@ -215,13 +215,14 @@ def compile(self, ttFont): {} ) # Some tables are different objects, but compile to the same data chunk for table in self.tables: - mapId = id(table.uvsDict if table.isUVS() else table.cmap) - offset = seen.get(mapId) + offset = seen.get(id(table.cmap)) if offset is None: chunk = table.compile(ttFont) offset = done.get(chunk) if offset is None: - offset = seen[mapId] = done[chunk] = totalOffset + len(tableData) + offset = seen[id(table.cmap)] = done[chunk] = totalOffset + len( + tableData + ) tableData = tableData + chunk data = data + struct.pack(">HHl", table.platformID, table.platEncID, offset) return data + tableData @@ -349,10 +350,6 @@ def isUnicode(self): self.platformID == 3 and self.platEncID in [0, 1, 10] ) - def isUVS(self): - """Returns true if it is a UVS subtable""" - return self.format == 14 and self.platformID == 0 and self.platEncID == 5 - def isSymbol(self): """Returns true if the subtable is for the Symbol encoding (3,0)""" return self.platformID == 3 and self.platEncID == 0 From 79da61e35d47bb3829b6ad308e30b182fcfe22e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hu=C3=A1ng=20J=C3=B9nli=C3=A0ng?= Date: Mon, 19 May 2025 09:13:35 -0400 Subject: [PATCH 10/36] set dummy cmap property for merged format=14 table --- Lib/fontTools/merge/tables.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Lib/fontTools/merge/tables.py b/Lib/fontTools/merge/tables.py index 9f476c9378..a46977f0b4 100644 --- a/Lib/fontTools/merge/tables.py +++ b/Lib/fontTools/merge/tables.py @@ -343,6 +343,7 @@ def merge(self, m, tables): uvsTable.platformID = 0 uvsTable.platEncID = 5 uvsTable.language = 0 + uvsTable.cmap = {} uvsTable.uvsDict = uvsDict # ordered by platform then encoding self.tables.insert(0, uvsTable) From 83d8893ac5320222002c8c41ea35ed698ad437c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hu=C3=A1ng=20J=C3=B9nli=C3=A0ng?= Date: Mon, 19 May 2025 09:31:00 -0400 Subject: [PATCH 11/36] fix: set dummy cmap property in merge_test --- Tests/merge/merge_test.py | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/Tests/merge/merge_test.py b/Tests/merge/merge_test.py index 9b0883549e..6bb93bbf62 100644 --- a/Tests/merge/merge_test.py +++ b/Tests/merge/merge_test.py @@ -140,19 +140,8 @@ def makeSubtable(self, format, platformID, platEncID, cmap): return subtable def makeUvsSubtable(self, format, platformID, platEncID, uvsDict): - module = ttLib.getTableModule("cmap") - subtable = module.cmap_classes[format](format) - ( - subtable.platformID, - subtable.platEncID, - subtable.language, - subtable.uvsDict, - ) = ( - platformID, - platEncID, - 0, - uvsDict, - ) + subtable = self.makeSubtable(format, platformID, platEncID, {}) + subtable.uvsDict = uvsDict return subtable # 4-3-1 table merged with 12-3-10 table with no dupes with codepoints outside BMP From aaa3e274286084575f8a4f4148407738502a5308 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Tue, 20 May 2025 13:26:03 -0600 Subject: [PATCH 12/36] [instancer/CFF2] Inherit vsindex from private dict The CharString vsindex defaults to that of its private dict, not 0. --- Lib/fontTools/varLib/instancer/__init__.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Lib/fontTools/varLib/instancer/__init__.py b/Lib/fontTools/varLib/instancer/__init__.py index 4011ad7883..a0b832a3c1 100644 --- a/Lib/fontTools/varLib/instancer/__init__.py +++ b/Lib/fontTools/varLib/instancer/__init__.py @@ -675,6 +675,7 @@ def getNumRegions(vsindex): privateDicts.append(fd.Private) allCommands = [] + allCommandPrivates = [] for cs in charStrings: assert cs.private.vstore.otVarStore is varStore # Or in many places!! commands = programToCommands(cs.program, getNumRegions=getNumRegions) @@ -683,6 +684,7 @@ def getNumRegions(vsindex): if specialize: commands = specializeCommands(commands, generalizeFirst=not generalize) allCommands.append(commands) + allCommandPrivates.append(cs.private) def storeBlendsToVarStore(arg): if not isinstance(arg, list): @@ -742,8 +744,8 @@ def fetchBlendsFromVarStore(arg): assert varData.ItemCount == 0 # Add charstring blend lists to VarStore so we can instantiate them - for commands in allCommands: - vsindex = 0 # Ouch. This ought to be what set by private dict + for commands, private in zip(allCommands, allCommandPrivates): + vsindex = getattr(private, "vsindex", 0) for command in commands: if command[0] == "vsindex": vsindex = command[1][0] @@ -762,6 +764,7 @@ def fetchBlendsFromVarStore(arg): continue values = getattr(private, name) + # This is safe here since "vsindex" is the first in the privateDictOperators2 if name == "vsindex": vsindex = values[0] continue @@ -782,8 +785,8 @@ def fetchBlendsFromVarStore(arg): # Read back new charstring blends from the instantiated VarStore varDataCursor = [0] * len(varStore.VarData) - for commands in allCommands: - vsindex = 0 # Ouch. This ought to be what set by private dict + for commands, private in zip(allCommands, allCommandPrivates): + vsindex = getattr(private, "vsindex", 0) for command in commands: if command[0] == "vsindex": vsindex = command[1][0] @@ -803,6 +806,7 @@ def fetchBlendsFromVarStore(arg): if not hasattr(private, name): continue + # This is safe here since "vsindex" is the first in the privateDictOperators2 if name == "vsindex": vsindex = values[0] continue From fe5ea976d8ae0e0b080cf12294ca1384bfe1699e Mon Sep 17 00:00:00 2001 From: Colin Rofls Date: Wed, 21 May 2025 12:47:30 -0400 Subject: [PATCH 13/36] [feaLib] Error on conflicting ligature rules This matches AFDKO, and will fontc will match this. --- Lib/fontTools/feaLib/builder.py | 14 ++++++++++++++ Tests/feaLib/builder_test.py | 8 ++++++++ 2 files changed, 22 insertions(+) diff --git a/Lib/fontTools/feaLib/builder.py b/Lib/fontTools/feaLib/builder.py index 1583f06d9e..8747b1d488 100644 --- a/Lib/fontTools/feaLib/builder.py +++ b/Lib/fontTools/feaLib/builder.py @@ -1387,6 +1387,13 @@ def add_ligature_subst( # glyph classes, the implementation software will enumerate # all specific glyph sequences if glyph classes are detected" for g in itertools.product(*glyphs): + existing = lookup.ligatures.get(g, replacement) + if existing != replacement: + raise FeatureLibError( + f"Conflicting ligature sub rules: '{g}' maps to '{existing}' and '{replacement}'", + location, + ) + lookup.ligatures[g] = replacement # GSUB 5/6 @@ -1445,6 +1452,13 @@ def add_ligature_subst_chained_( sub = self.get_chained_lookup_(location, LigatureSubstBuilder) for g in itertools.product(*glyphs): + existing = sub.ligatures.get(g, replacement) + if existing != replacement: + raise FeatureLibError( + f"Conflicting ligature sub rules: '{g}' maps to '{existing}' and '{replacement}'", + location, + ) + sub.ligatures[g] = replacement chain.rules.append(ChainContextualRule(prefix, glyphs, suffix, [sub])) diff --git a/Tests/feaLib/builder_test.py b/Tests/feaLib/builder_test.py index aa869806fd..bac97b4379 100644 --- a/Tests/feaLib/builder_test.py +++ b/Tests/feaLib/builder_test.py @@ -1009,6 +1009,14 @@ def test_pairPos_enumRuleOverridenBySinglePair_DEBUG(self): ) captor.assertRegex("Already defined position for pair A V at") + def test_ligatureSubst_conflicting_rules(self): + self.assertRaisesRegex( + FeatureLibError, + "Conflicting ligature sub", + self.build, + "feature test {" " sub a b by one;" " sub a b by two;" "} test;", + ) + def test_ignore_empty_lookup_block(self): # https://github.com/fonttools/fonttools/pull/2277 font = self.build( From 4ee00dfe1e71910b66a752cc2a65f51a408b8e0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sam=20Minn=C3=A9e?= Date: Thu, 22 May 2025 14:59:35 +1200 Subject: [PATCH 14/36] fix: avoid conflict with freezegun The original implementation meant that self is passed to default_timer. When freezegun overrides this with a function that doesn't take an argument, it casues a runtime error. By setting the _time value in the init method, self isn't passed automatically as the first argument --- Lib/fontTools/misc/loggingTools.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Lib/fontTools/misc/loggingTools.py b/Lib/fontTools/misc/loggingTools.py index 78704f5a9a..99fbddfae0 100644 --- a/Lib/fontTools/misc/loggingTools.py +++ b/Lib/fontTools/misc/loggingTools.py @@ -284,11 +284,12 @@ class Timer(object): """ # timeit.default_timer choses the most accurate clock for each platform - _time = timeit.default_timer + _time: Callable[[], float] default_msg = "elapsed time: %(time).3fs" default_format = "Took %(time).3fs to %(msg)s" def __init__(self, logger=None, msg=None, level=None, start=None): + self._time = timeit.default_timer self.reset(start) if logger is None: for arg in ("msg", "level"): From 50e337b1b2fb3441475e49f2d0a3ffffbe614269 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Fri, 23 May 2025 15:07:36 -0600 Subject: [PATCH 15/36] [cffLib.specializer] Fix rmoveto merging when blends used Fixes https://github.com/fonttools/fonttools/issues/3839 --- Lib/fontTools/cffLib/specializer.py | 5 ++++- Tests/cffLib/specializer_test.py | 8 +++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/Lib/fontTools/cffLib/specializer.py b/Lib/fontTools/cffLib/specializer.py index 5fddcb67dd..974060c40e 100644 --- a/Lib/fontTools/cffLib/specializer.py +++ b/Lib/fontTools/cffLib/specializer.py @@ -580,7 +580,10 @@ def specializeCommands( for i in range(len(commands) - 1, 0, -1): if "rmoveto" == commands[i][0] == commands[i - 1][0]: v1, v2 = commands[i - 1][1], commands[i][1] - commands[i - 1] = ("rmoveto", [v1[0] + v2[0], v1[1] + v2[1]]) + commands[i - 1] = ( + "rmoveto", + [_addArgs(v1[0], v2[0]), _addArgs(v1[1], v2[1])], + ) del commands[i] # 2. Specialize rmoveto/rlineto/rrcurveto operators into horizontal/vertical variants. diff --git a/Tests/cffLib/specializer_test.py b/Tests/cffLib/specializer_test.py index ebe702fee4..ba6c93623c 100644 --- a/Tests/cffLib/specializer_test.py +++ b/Tests/cffLib/specializer_test.py @@ -361,6 +361,10 @@ def test_raises(self, charstr): ("100 0 0 rmoveto", "100 0 hmoveto"), # test_rmoveto_zero_width (".55 -.8 rmoveto", "0.55 -0.8 rmoveto"), # test_rmoveto ("55 -8 rmoveto " * 3, "165 -24 rmoveto"), # test_rmoveto_mult + ( + "0.55 -0.8 rmoveto 10 20 1 blend 30 rmoveto", + "10.55 20 1 blend 29.2 rmoveto", + ), # test_rmoveto_blend ("100.5 50 -5.8 rmoveto", None), # test_rmoveto_width # rlineto ("0 0 rlineto", ""), # test_rlineto_zero @@ -568,8 +572,10 @@ def test_raises(self, charstr): def test_specialize(self, charstr, expected): if expected is None: expected = charstr + numRegions = 2 + getNumRegions = lambda iv: numRegions expected = expected.strip() - specialized = charstr_specialize(charstr) + specialized = charstr_specialize(charstr, getNumRegions=getNumRegions) assert specialized == expected, (specialized, expected) # maxstack CFF=48, specializer uses up to 47 From 13f2da1482c60fc2cd5e9513a6968e0a2d2aeec7 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Mon, 26 May 2025 13:31:07 -0600 Subject: [PATCH 16/36] [symfont] Add a main --- Lib/fontTools/misc/symfont.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/Lib/fontTools/misc/symfont.py b/Lib/fontTools/misc/symfont.py index 4dea418408..fc84d53f58 100644 --- a/Lib/fontTools/misc/symfont.py +++ b/Lib/fontTools/misc/symfont.py @@ -234,11 +234,8 @@ def _curveToOne(self, p1, p2, p3): if __name__ == "__main__": - pen = AreaPen() - pen.moveTo((100, 100)) - pen.lineTo((100, 200)) - pen.lineTo((200, 200)) - pen.curveTo((200, 250), (300, 300), (250, 350)) - pen.lineTo((200, 100)) - pen.closePath() - print(pen.value) + import sys + if sys.argv[1:]: + penName = sys.argv[1] + funcs = [(name, eval(f)) for name, f in zip(sys.argv[2::2], sys.argv[3::2])] + printGreenPen(penName, funcs, file=sys.stdout) From 70647787d9f74554b6bd082638bc9ec0f541a8f1 Mon Sep 17 00:00:00 2001 From: Nikolaus Waxweiler Date: Fri, 23 May 2025 18:21:08 +0100 Subject: [PATCH 17/36] Add test --- Tests/varLib/varLib_test.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/Tests/varLib/varLib_test.py b/Tests/varLib/varLib_test.py index fa94e93546..4b636cb0e4 100644 --- a/Tests/varLib/varLib_test.py +++ b/Tests/varLib/varLib_test.py @@ -337,6 +337,35 @@ def add_rclt(font, savepath): post_process_master=add_rclt, ) + def test_varlib_build_feature_variations_without_latn_dflt_feature(self): + """Test that a script gets a dflt language so that when we later add + variations, we can attach to something.""" + + def add_features(font, savepath): + features = """ + languagesystem DFLT dflt; + languagesystem latn dflt; + languagesystem latn CAT; + + feature locl { + script latn; + # Intentionally skip defining anything for `language dflt;`. + language CAT; + sub uni0061 by uni0061; + } locl; + """ + addOpenTypeFeaturesFromString(font, features) + font.save(savepath) + + self._run_varlib_build_test( + designspace_name="FeatureVars", + font_name="TestFamily", + tables=["GSUB"], + expected_ttx_name="FeatureVars_latn_dflt_var", + save_before_dump=True, + post_process_master=add_features, + ) + def test_varlib_gvar_explicit_delta(self): """The variable font contains a composite glyph odieresis which does not need a gvar entry. From c5f279aaac1ab419327136fc42f5126483148f7a Mon Sep 17 00:00:00 2001 From: Nikolaus Waxweiler Date: Tue, 27 May 2025 12:12:28 +0100 Subject: [PATCH 18/36] Create default LangSys on demand --- Lib/fontTools/varLib/featureVars.py | 11 +- .../FeatureVars_latn_dflt_var.ttx | 181 ++++++++++++++++++ Tests/varLib/varLib_test.py | 4 +- 3 files changed, 190 insertions(+), 6 deletions(-) create mode 100644 Tests/varLib/data/test_results/FeatureVars_latn_dflt_var.ttx diff --git a/Lib/fontTools/varLib/featureVars.py b/Lib/fontTools/varLib/featureVars.py index 2e957f5585..d1a3311d29 100644 --- a/Lib/fontTools/varLib/featureVars.py +++ b/Lib/fontTools/varLib/featureVars.py @@ -392,10 +392,13 @@ def addFeatureVariationsRaw(font, table, conditionalSubstitutions, featureTag="r for scriptRecord in table.ScriptList.ScriptRecord: if scriptRecord.Script.DefaultLangSys is None: - raise VarLibError( - "Feature variations require that the script " - f"'{scriptRecord.ScriptTag}' defines a default language system." - ) + # We need to have a default LangSys to attach variations to. + langSys = ot.LangSys() + langSys.LookupOrder = None + langSys.ReqFeatureIndex = 0xFFFF + langSys.FeatureIndex = [] + langSys.FeatureCount = 0 + scriptRecord.Script.DefaultLangSys = langSys langSystems = [lsr.LangSys for lsr in scriptRecord.Script.LangSysRecord] for langSys in [scriptRecord.Script.DefaultLangSys] + langSystems: langSys.FeatureIndex.append(varFeatureIndex) diff --git a/Tests/varLib/data/test_results/FeatureVars_latn_dflt_var.ttx b/Tests/varLib/data/test_results/FeatureVars_latn_dflt_var.ttx new file mode 100644 index 0000000000..af144a8647 --- /dev/null +++ b/Tests/varLib/data/test_results/FeatureVars_latn_dflt_var.ttx @@ -0,0 +1,181 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/varLib/varLib_test.py b/Tests/varLib/varLib_test.py index 4b636cb0e4..2ce3858382 100644 --- a/Tests/varLib/varLib_test.py +++ b/Tests/varLib/varLib_test.py @@ -338,8 +338,8 @@ def add_rclt(font, savepath): ) def test_varlib_build_feature_variations_without_latn_dflt_feature(self): - """Test that a script gets a dflt language so that when we later add - variations, we can attach to something.""" + """Test that when a script does not have a dflt language, it gets one + when we later add variations, we can attach them to it.""" def add_features(font, savepath): features = """ From a235494ab472948a61f29669f4e33f39d14896e1 Mon Sep 17 00:00:00 2001 From: Nikolaus Waxweiler Date: Tue, 27 May 2025 12:45:45 +0100 Subject: [PATCH 19/36] Format --- Lib/fontTools/misc/symfont.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Lib/fontTools/misc/symfont.py b/Lib/fontTools/misc/symfont.py index fc84d53f58..9bba2d2d56 100644 --- a/Lib/fontTools/misc/symfont.py +++ b/Lib/fontTools/misc/symfont.py @@ -235,6 +235,7 @@ def _curveToOne(self, p1, p2, p3): if __name__ == "__main__": import sys + if sys.argv[1:]: penName = sys.argv[1] funcs = [(name, eval(f)) for name, f in zip(sys.argv[2::2], sys.argv[3::2])] From 26b28b5f72a88c776c1afbe613362c3468e8a6e0 Mon Sep 17 00:00:00 2001 From: Nikolaus Waxweiler Date: Tue, 27 May 2025 13:19:23 +0100 Subject: [PATCH 20/36] Add NEWS entry [skip ci] --- NEWS.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/NEWS.rst b/NEWS.rst index a6af57ee70..5e11444cf4 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -1,3 +1,5 @@ +- [varLib] Create a dflt LangSys in a ScriptRecord when adding variations later, to fix an avoidable crash in an edge case (#3838). + 4.58.0 (released 2025-05-10) ---------------------------- From 9a44189d2bc7cb8d5313a7616a05dbbb7ecb05bd Mon Sep 17 00:00:00 2001 From: Nikolaus Waxweiler Date: Fri, 16 May 2025 19:12:28 +0100 Subject: [PATCH 21/36] Instances should only reuse name ID 2 or 17 if they are at the default location across all axes Closes #3825 --- Lib/fontTools/varLib/__init__.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/Lib/fontTools/varLib/__init__.py b/Lib/fontTools/varLib/__init__.py index e3c00d73fa..86ab6ecc96 100644 --- a/Lib/fontTools/varLib/__init__.py +++ b/Lib/fontTools/varLib/__init__.py @@ -109,6 +109,8 @@ def _add_fvar(font, axes, instances: List[InstanceDescriptor]): axis.flags = int(a.hidden) fvar.axes.append(axis) + default_coordinates = {axis.axisTag: axis.defaultValue for axis in fvar.axes} + for instance in instances: # Filter out discrete axis locations coordinates = { @@ -130,16 +132,26 @@ def _add_fvar(font, axes, instances: List[InstanceDescriptor]): psname = instance.postScriptFontName inst = NamedInstance() + inst.coordinates = { + axes[k].tag: axes[k].map_backward(v) for k, v in coordinates.items() + } + + # Instances should only reuse name ID 2 or 17 if they are at the default + # location across all axes. See + # https://github.com/fonttools/fonttools/issues/3825. + styleNameExists = nameTable.findMultilingualName( + localisedStyleName, windows=True, mac=macNames, minNameID=0 + ) + if styleNameExists in {2, 17} and inst.coordinates != default_coordinates: + minNameId = 256 + else: + minNameId = 0 inst.subfamilyNameID = nameTable.addMultilingualName( - localisedStyleName, mac=macNames + localisedStyleName, mac=macNames, minNameID=minNameId ) if psname is not None: psname = tostr(psname) inst.postscriptNameID = nameTable.addName(psname, platforms=platforms) - inst.coordinates = { - axes[k].tag: axes[k].map_backward(v) for k, v in coordinates.items() - } - # inst.coordinates = {axes[k].tag:v for k,v in coordinates.items()} fvar.instances.append(inst) assert "fvar" not in font From aaee3ab4648bc9289511cff0c93cc3b19ef75ad5 Mon Sep 17 00:00:00 2001 From: Nikolaus Waxweiler Date: Mon, 19 May 2025 19:20:48 +0100 Subject: [PATCH 22/36] Update expectations --- Tests/varLib/data/test_results/Build.ttx | 14 ++++---- Tests/varLib/data/test_results/BuildMain.ttx | 37 +++++++++++--------- 2 files changed, 27 insertions(+), 24 deletions(-) diff --git a/Tests/varLib/data/test_results/Build.ttx b/Tests/varLib/data/test_results/Build.ttx index c4f70e8aba..1352f6895b 100644 --- a/Tests/varLib/data/test_results/Build.ttx +++ b/Tests/varLib/data/test_results/Build.ttx @@ -1,5 +1,5 @@ - + @@ -154,42 +154,42 @@ - + - + - + - + - + - + diff --git a/Tests/varLib/data/test_results/BuildMain.ttx b/Tests/varLib/data/test_results/BuildMain.ttx index c82323e425..ff82dcc394 100644 --- a/Tests/varLib/data/test_results/BuildMain.ttx +++ b/Tests/varLib/data/test_results/BuildMain.ttx @@ -1,5 +1,5 @@ - + @@ -489,36 +489,39 @@ TestFamily-Light - TestFamily-Regular + Regular - Semibold + TestFamily-Regular - TestFamily-Semibold + Semibold - Bold + TestFamily-Semibold - TestFamily-Bold + Bold - Black + TestFamily-Bold - TestFamily-Black + Black - Black Medium Contrast + TestFamily-Black - TestFamily-BlackMediumContrast + Black Medium Contrast - Black High Contrast + TestFamily-BlackMediumContrast + Black High Contrast + + TestFamily-BlackHighContrast @@ -740,42 +743,42 @@ - + - + - + - + - + - + From 8c8305ec5861ff7cc98cc80c5b538da9bfe0a00f Mon Sep 17 00:00:00 2001 From: Nikolaus Waxweiler Date: Tue, 20 May 2025 10:32:04 +0100 Subject: [PATCH 23/36] Blacken --- Lib/fontTools/varLib/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/fontTools/varLib/__init__.py b/Lib/fontTools/varLib/__init__.py index 86ab6ecc96..12eff3bd8d 100644 --- a/Lib/fontTools/varLib/__init__.py +++ b/Lib/fontTools/varLib/__init__.py @@ -136,7 +136,7 @@ def _add_fvar(font, axes, instances: List[InstanceDescriptor]): axes[k].tag: axes[k].map_backward(v) for k, v in coordinates.items() } - # Instances should only reuse name ID 2 or 17 if they are at the default + # Instances should only reuse name ID 2 or 17 if they are at the default # location across all axes. See # https://github.com/fonttools/fonttools/issues/3825. styleNameExists = nameTable.findMultilingualName( From 123932a8bc3debeab6a1bc023af883e2b98d7d3c Mon Sep 17 00:00:00 2001 From: Nikolaus Waxweiler Date: Thu, 22 May 2025 18:02:54 +0100 Subject: [PATCH 24/36] Only search for new name ID when needed --- Lib/fontTools/varLib/__init__.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/Lib/fontTools/varLib/__init__.py b/Lib/fontTools/varLib/__init__.py index 12eff3bd8d..93272bf258 100644 --- a/Lib/fontTools/varLib/__init__.py +++ b/Lib/fontTools/varLib/__init__.py @@ -136,19 +136,20 @@ def _add_fvar(font, axes, instances: List[InstanceDescriptor]): axes[k].tag: axes[k].map_backward(v) for k, v in coordinates.items() } - # Instances should only reuse name ID 2 or 17 if they are at the default - # location across all axes. See - # https://github.com/fonttools/fonttools/issues/3825. - styleNameExists = nameTable.findMultilingualName( + subfamilyNameID = nameTable.findMultilingualName( localisedStyleName, windows=True, mac=macNames, minNameID=0 ) - if styleNameExists in {2, 17} and inst.coordinates != default_coordinates: - minNameId = 256 + if subfamilyNameID in {2, 17} and inst.coordinates != default_coordinates: + # Instances should only reuse name ID 2 or 17 if they are at the + # default location across all axes, so try again and only look at + # IDs >= 256. See + # https://github.com/fonttools/fonttools/issues/3825. + inst.subfamilyNameID = nameTable.addMultilingualName( + localisedStyleName, windows=True, mac=macNames, minNameID=256 + ) else: - minNameId = 0 - inst.subfamilyNameID = nameTable.addMultilingualName( - localisedStyleName, mac=macNames, minNameID=minNameId - ) + inst.subfamilyNameID = subfamilyNameID + if psname is not None: psname = tostr(psname) inst.postscriptNameID = nameTable.addName(psname, platforms=platforms) From c788e61f231cabb3474a674fec72a234ffa2a525 Mon Sep 17 00:00:00 2001 From: Nikolaus Waxweiler Date: Thu, 22 May 2025 18:21:49 +0100 Subject: [PATCH 25/36] Add test for reusing name ID 2 --- .../varLib/data/BuildReuseNameId2.designspace | 58 +++++++++++++++++++ .../data/test_results/BuildReuseNameId2.ttx | 33 +++++++++++ Tests/varLib/varLib_test.py | 9 +++ 3 files changed, 100 insertions(+) create mode 100644 Tests/varLib/data/BuildReuseNameId2.designspace create mode 100644 Tests/varLib/data/test_results/BuildReuseNameId2.ttx diff --git a/Tests/varLib/data/BuildReuseNameId2.designspace b/Tests/varLib/data/BuildReuseNameId2.designspace new file mode 100644 index 0000000000..98a0483b85 --- /dev/null +++ b/Tests/varLib/data/BuildReuseNameId2.designspace @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/varLib/data/test_results/BuildReuseNameId2.ttx b/Tests/varLib/data/test_results/BuildReuseNameId2.ttx new file mode 100644 index 0000000000..ce3dd51143 --- /dev/null +++ b/Tests/varLib/data/test_results/BuildReuseNameId2.ttx @@ -0,0 +1,33 @@ + + + + + + + + wght + 0x0 + 0.0 + 400.0 + 900.0 + 256 + + + + + cntr + 0x0 + 0.0 + 0.0 + 100.0 + 257 + + + + + + + + + + diff --git a/Tests/varLib/varLib_test.py b/Tests/varLib/varLib_test.py index 2ce3858382..f33eafb1b9 100644 --- a/Tests/varLib/varLib_test.py +++ b/Tests/varLib/varLib_test.py @@ -175,6 +175,15 @@ def test_varlib_build_ttf(self): expected_ttx_name="Build", ) + def test_varlib_build_ttf_reuse_nameid_2(self): + """Instances at the default location can reuse name ID 2 or 17.""" + self._run_varlib_build_test( + designspace_name="BuildReuseNameId2", + font_name="TestFamily", + tables=["fvar"], + expected_ttx_name="BuildReuseNameId2", + ) + def test_varlib_build_no_axes_ttf(self): """Designspace file does not contain an element.""" ds_path = self.get_test_input("InterpolateLayout3.designspace") From 0b81df1008d6ada3b34acebe24e31e8e1d15595c Mon Sep 17 00:00:00 2001 From: Nikolaus Waxweiler Date: Fri, 23 May 2025 12:45:48 +0100 Subject: [PATCH 26/36] Fix typo --- Lib/fontTools/varLib/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/fontTools/varLib/__init__.py b/Lib/fontTools/varLib/__init__.py index 93272bf258..c3954aceb5 100644 --- a/Lib/fontTools/varLib/__init__.py +++ b/Lib/fontTools/varLib/__init__.py @@ -136,7 +136,7 @@ def _add_fvar(font, axes, instances: List[InstanceDescriptor]): axes[k].tag: axes[k].map_backward(v) for k, v in coordinates.items() } - subfamilyNameID = nameTable.findMultilingualName( + subfamilyNameID = nameTable.addMultilingualName( localisedStyleName, windows=True, mac=macNames, minNameID=0 ) if subfamilyNameID in {2, 17} and inst.coordinates != default_coordinates: From aed57ca445b94048732a807b24836a9d2d63d9de Mon Sep 17 00:00:00 2001 From: Nikolaus Waxweiler Date: Tue, 27 May 2025 13:23:47 +0100 Subject: [PATCH 27/36] Add NEWS entry --- NEWS.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/NEWS.rst b/NEWS.rst index 5e11444cf4..6d74ced804 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -1,3 +1,4 @@ +- [varLib] Ensure that instances only reuse name ID 2 or 17 if they are at the default location across all axes (#3831). - [varLib] Create a dflt LangSys in a ScriptRecord when adding variations later, to fix an avoidable crash in an edge case (#3838). 4.58.0 (released 2025-05-10) From ee1b5d64e1fc921d14df621c8edeba9321e4cd12 Mon Sep 17 00:00:00 2001 From: Khaled Hosny Date: Tue, 27 May 2025 22:15:19 +0300 Subject: [PATCH 28/36] [feaLib] Add promoted single substitutions to aalt feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If a single substitution was promoted to a multiple or ligature substitutions, we would no longer add it to aalt feature (when requested) since only single and alternate substations are added there. This can be considered a regression from lookup promotion. Since a single-looking substitution wouldn’t occurs in ligature or multiple substitution unless we promoted it, it should be safe to add these to allt feature. Fixes https://github.com/fonttools/fonttools/issues/3845 --- Lib/fontTools/otlLib/builder.py | 16 ++++++++++ Tests/feaLib/builder_test.py | 55 +++++++++++++++++++++++++++++++++ Tests/feaLib/data/spec8a.ttx | 11 +++++-- Tests/feaLib/data/spec8a_2.ttx | 11 +++++-- 4 files changed, 89 insertions(+), 4 deletions(-) diff --git a/Lib/fontTools/otlLib/builder.py b/Lib/fontTools/otlLib/builder.py index 064b2fce31..dd95fb95e4 100644 --- a/Lib/fontTools/otlLib/builder.py +++ b/Lib/fontTools/otlLib/builder.py @@ -883,6 +883,14 @@ def build(self): ) return self.buildLookup_(subtables) + def getAlternateGlyphs(self): + # https://github.com/fonttools/fonttools/issues/3845 + return { + components[0]: [ligature] + for components, ligature in self.ligatures.items() + if len(components) == 1 + } + def add_subtable_break(self, location): self.ligatures[(self.SUBTABLE_BREAK_, location)] = self.SUBTABLE_BREAK_ @@ -921,6 +929,14 @@ def build(self): subtables = self.build_subst_subtables(self.mapping, buildMultipleSubstSubtable) return self.buildLookup_(subtables) + def getAlternateGlyphs(self): + # https://github.com/fonttools/fonttools/issues/3845 + return { + glyph: replacements + for glyph, replacements in self.mapping.items() + if len(replacements) == 1 + } + def add_subtable_break(self, location): self.mapping[(self.SUBTABLE_BREAK_, location)] = self.SUBTABLE_BREAK_ diff --git a/Tests/feaLib/builder_test.py b/Tests/feaLib/builder_test.py index aa869806fd..d3dd28b74f 100644 --- a/Tests/feaLib/builder_test.py +++ b/Tests/feaLib/builder_test.py @@ -333,6 +333,39 @@ def test_mixed_singleSubst_multipleSubst(self): }, ) + def test_mixed_singleSubst_multipleSubst_aalt(self): + font = self.build( + dedent( + """ + feature aalt { + feature ccmp; + } aalt; + + feature ccmp { + sub f_f by f f; + sub f by f; + sub f_f_i by f f i; + sub [A A.sc] by A; + sub [B B.sc] by [B B.sc]; + } ccmp; + """ + ) + ) + + assert "GSUB" in font + st = font["GSUB"].table.LookupList.Lookup[0].SubTable[0] + self.assertEqual(st.LookupType, 1) + self.assertEqual( + st.mapping, + { + "A": "A", + "A.sc": "A", + "B": "B", + "B.sc": "B.sc", + "f": "f", + }, + ) + def test_mixed_singleSubst_ligatureSubst(self): font = self.build( "lookup test {" @@ -358,6 +391,28 @@ def test_mixed_singleSubst_ligatureSubst(self): self.assertEqual(st.ligatures["A"][0].LigGlyph, "A.sc") self.assertEqual(len(st.ligatures["A"][0].Component), 0) + def test_mixed_singleSubst_ligatureSubst_aalt(self): + font = self.build( + dedent( + """ + feature aalt { + feature liga; + } aalt; + + feature liga { + sub f f by f_f; + sub f f i by f_f_i; + sub A by A.sc; + } liga; + """ + ) + ) + + assert "GSUB" in font + st = font["GSUB"].table.LookupList.Lookup[0].SubTable[0] + self.assertEqual(st.LookupType, 1) + self.assertEqual(st.mapping, {"A": "A.sc"}) + def test_mixed_singleSubst_multipleSubst_ligatureSubst(self): self.assertRaisesRegex( FeatureLibError, diff --git a/Tests/feaLib/data/spec8a.ttx b/Tests/feaLib/data/spec8a.ttx index 569219eba6..076e277591 100644 --- a/Tests/feaLib/data/spec8a.ttx +++ b/Tests/feaLib/data/spec8a.ttx @@ -89,8 +89,6 @@ - - @@ -103,6 +101,15 @@ + + + + + + + + + diff --git a/Tests/feaLib/data/spec8a_2.ttx b/Tests/feaLib/data/spec8a_2.ttx index 5cb4a4a410..549b083ab5 100644 --- a/Tests/feaLib/data/spec8a_2.ttx +++ b/Tests/feaLib/data/spec8a_2.ttx @@ -91,8 +91,6 @@ - - @@ -108,6 +106,15 @@ + + + + + + + + + From 4bfda3947a1b17a1e9dc500787c378a095c6cf24 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 28 May 2025 11:20:54 +0100 Subject: [PATCH 29/36] [featureVars] set NULL offset for empty ConditionSet Fixes #3844 --- Lib/fontTools/varLib/featureVars.py | 9 ++++++--- Tests/varLib/data/test_results/FeatureVarsWholeRange.ttx | 3 --- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Lib/fontTools/varLib/featureVars.py b/Lib/fontTools/varLib/featureVars.py index d1a3311d29..856f00bcb9 100644 --- a/Lib/fontTools/varLib/featureVars.py +++ b/Lib/fontTools/varLib/featureVars.py @@ -600,9 +600,12 @@ def buildFeatureRecord(featureTag, lookupListIndices): def buildFeatureVariationRecord(conditionTable, substitutionRecords): """Build a FeatureVariationRecord.""" fvr = ot.FeatureVariationRecord() - fvr.ConditionSet = ot.ConditionSet() - fvr.ConditionSet.ConditionTable = conditionTable - fvr.ConditionSet.ConditionCount = len(conditionTable) + if len(conditionTable) != 0: + fvr.ConditionSet = ot.ConditionSet() + fvr.ConditionSet.ConditionTable = conditionTable + fvr.ConditionSet.ConditionCount = len(conditionTable) + else: + fvr.ConditionSet = None fvr.FeatureTableSubstitution = ot.FeatureTableSubstitution() fvr.FeatureTableSubstitution.Version = 0x00010000 fvr.FeatureTableSubstitution.SubstitutionRecord = substitutionRecords diff --git a/Tests/varLib/data/test_results/FeatureVarsWholeRange.ttx b/Tests/varLib/data/test_results/FeatureVarsWholeRange.ttx index 8ae64da4f6..a1a14cdadd 100644 --- a/Tests/varLib/data/test_results/FeatureVarsWholeRange.ttx +++ b/Tests/varLib/data/test_results/FeatureVarsWholeRange.ttx @@ -54,9 +54,6 @@ - - - From c3f8e183d270396e2f2f9c989d70ff6830e1c003 Mon Sep 17 00:00:00 2001 From: Jens Kutilek Date: Wed, 28 May 2025 12:31:12 +0200 Subject: [PATCH 30/36] Add typing annotations to T2CharStringPen (#3837) * Sort imports * Add typing annotations --- Lib/fontTools/pens/t2CharStringPen.py | 42 ++++++++++++++++++++------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/Lib/fontTools/pens/t2CharStringPen.py b/Lib/fontTools/pens/t2CharStringPen.py index 41ab0f92f2..6ee25ce66c 100644 --- a/Lib/fontTools/pens/t2CharStringPen.py +++ b/Lib/fontTools/pens/t2CharStringPen.py @@ -1,10 +1,14 @@ # Copyright (c) 2009 Type Supply LLC # Author: Tal Leming -from fontTools.misc.roundTools import otRound, roundFunc +from __future__ import annotations + +from typing import Any, List, Tuple + +from fontTools.cffLib.specializer import commandsToProgram, specializeCommands from fontTools.misc.psCharStrings import T2CharString +from fontTools.misc.roundTools import otRound, roundFunc from fontTools.pens.basePen import BasePen -from fontTools.cffLib.specializer import specializeCommands, commandsToProgram class T2CharStringPen(BasePen): @@ -18,36 +22,52 @@ class T2CharStringPen(BasePen): which are close to their integral part within the tolerated range. """ - def __init__(self, width, glyphSet, roundTolerance=0.5, CFF2=False): + def __init__( + self, + width: float, + glyphSet: dict[str, Any] | None, + roundTolerance: float = 0.5, + CFF2: bool = False, + ) -> None: super(T2CharStringPen, self).__init__(glyphSet) self.round = roundFunc(roundTolerance) self._CFF2 = CFF2 self._width = width - self._commands = [] + self._commands: List[Tuple[str | bytes, List[float]]] = [] self._p0 = (0, 0) - def _p(self, pt): + def _p(self, pt: Tuple[float, float]) -> List[float]: p0 = self._p0 pt = self._p0 = (self.round(pt[0]), self.round(pt[1])) return [pt[0] - p0[0], pt[1] - p0[1]] - def _moveTo(self, pt): + def _moveTo(self, pt: Tuple[float, float]) -> None: self._commands.append(("rmoveto", self._p(pt))) - def _lineTo(self, pt): + def _lineTo(self, pt: Tuple[float, float]) -> None: self._commands.append(("rlineto", self._p(pt))) - def _curveToOne(self, pt1, pt2, pt3): + def _curveToOne( + self, + pt1: Tuple[float, float], + pt2: Tuple[float, float], + pt3: Tuple[float, float], + ) -> None: _p = self._p self._commands.append(("rrcurveto", _p(pt1) + _p(pt2) + _p(pt3))) - def _closePath(self): + def _closePath(self) -> None: pass - def _endPath(self): + def _endPath(self) -> None: pass - def getCharString(self, private=None, globalSubrs=None, optimize=True): + def getCharString( + self, + private: dict | None = None, + globalSubrs: List | None = None, + optimize: bool = True, + ) -> T2CharString: commands = self._commands if optimize: maxstack = 48 if not self._CFF2 else 513 From 4fc7dcfa3ac5300753a3b8531aa3b3e9cedf977b Mon Sep 17 00:00:00 2001 From: Jens Kutilek Date: Wed, 28 May 2025 12:40:42 +0200 Subject: [PATCH 31/36] Small fixup of typing annotations --- Lib/fontTools/pens/t2CharStringPen.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Lib/fontTools/pens/t2CharStringPen.py b/Lib/fontTools/pens/t2CharStringPen.py index 6ee25ce66c..ddff2c93f0 100644 --- a/Lib/fontTools/pens/t2CharStringPen.py +++ b/Lib/fontTools/pens/t2CharStringPen.py @@ -3,7 +3,7 @@ from __future__ import annotations -from typing import Any, List, Tuple +from typing import Any, Dict, List, Tuple from fontTools.cffLib.specializer import commandsToProgram, specializeCommands from fontTools.misc.psCharStrings import T2CharString @@ -24,8 +24,8 @@ class T2CharStringPen(BasePen): def __init__( self, - width: float, - glyphSet: dict[str, Any] | None, + width: float | None, + glyphSet: Dict[str, Any] | None, roundTolerance: float = 0.5, CFF2: bool = False, ) -> None: @@ -64,7 +64,7 @@ def _endPath(self) -> None: def getCharString( self, - private: dict | None = None, + private: Dict | None = None, globalSubrs: List | None = None, optimize: bool = True, ) -> T2CharString: From 509ca629a187d44d6aa5c484779bd5b29f4dfc7f Mon Sep 17 00:00:00 2001 From: Khaled Hosny Date: Wed, 28 May 2025 02:03:32 +0300 Subject: [PATCH 32/36] [feaLib] Improve single substitution promotion Move the logic down into the builder so that it is applied after lookups are split due to e.g. different lookup flags or interspersed contextual substitution rules. Arguably the builder was the right place for this all along, but I took the either route (twice!) before. Fixes https://github.com/fonttools/fonttools/issues/3846 --- Lib/fontTools/feaLib/ast.py | 73 +------------- Lib/fontTools/feaLib/builder.py | 100 +++++++++---------- Lib/fontTools/otlLib/builder.py | 148 ++++++++++++++++++++++++++++ Tests/feaLib/builder_test.py | 45 +++++++-- Tests/feaLib/data/bug3846_1.fea | 5 + Tests/feaLib/data/bug3846_1.ttx | 54 ++++++++++ Tests/feaLib/data/bug3846_2.fea | 9 ++ Tests/feaLib/data/bug3846_2.ttx | 87 ++++++++++++++++ Tests/otlLib/maxContextCalc_test.py | 1 - 9 files changed, 387 insertions(+), 135 deletions(-) create mode 100644 Tests/feaLib/data/bug3846_1.fea create mode 100644 Tests/feaLib/data/bug3846_1.ttx create mode 100644 Tests/feaLib/data/bug3846_2.fea create mode 100644 Tests/feaLib/data/bug3846_2.ttx diff --git a/Lib/fontTools/feaLib/ast.py b/Lib/fontTools/feaLib/ast.py index 8479d7300d..efcce8c680 100644 --- a/Lib/fontTools/feaLib/ast.py +++ b/Lib/fontTools/feaLib/ast.py @@ -337,76 +337,6 @@ def asFea(self, indent=""): return res -def _upgrade_mixed_subst_statements(statements): - # https://github.com/fonttools/fonttools/issues/612 - # A multiple substitution may have a single destination, in which case - # it will look just like a single substitution. So if there are both - # multiple and single substitutions, upgrade all the single ones to - # multiple substitutions. Similarly, a ligature substitution may have a - # single source glyph, so if there are both ligature and single - # substitutions, upgrade all the single ones to ligature substitutions. - - has_single = False - has_multiple = False - has_ligature = False - for s in statements: - if isinstance(s, SingleSubstStatement): - has_single = not any([s.prefix, s.suffix, s.forceChain]) - elif isinstance(s, MultipleSubstStatement): - has_multiple = not any([s.prefix, s.suffix, s.forceChain]) - elif isinstance(s, LigatureSubstStatement): - has_ligature = not any([s.prefix, s.suffix, s.forceChain]) - - to_multiple = False - to_ligature = False - - # If we have mixed single and multiple substitutions, - # upgrade all single substitutions to multiple substitutions. - if has_single and has_multiple and not has_ligature: - to_multiple = True - - # If we have mixed single and ligature substitutions, - # upgrade all single substitutions to ligature substitutions. - elif has_single and has_ligature and not has_multiple: - to_ligature = True - - if to_multiple or to_ligature: - ret = [] - for s in statements: - if isinstance(s, SingleSubstStatement): - glyphs = s.glyphs[0].glyphSet() - replacements = s.replacements[0].glyphSet() - if len(replacements) == 1: - replacements *= len(glyphs) - for glyph, replacement in zip(glyphs, replacements): - if to_multiple: - ret.append( - MultipleSubstStatement( - s.prefix, - glyph, - s.suffix, - [replacement], - s.forceChain, - location=s.location, - ) - ) - elif to_ligature: - ret.append( - LigatureSubstStatement( - s.prefix, - [GlyphName(glyph)], - s.suffix, - replacement, - s.forceChain, - location=s.location, - ) - ) - else: - ret.append(s) - return ret - return statements - - class Block(Statement): """A block of statements: feature, lookup, etc.""" @@ -418,8 +348,7 @@ def build(self, builder): """When handed a 'builder' object of comparable interface to :class:`fontTools.feaLib.builder`, walks the statements in this block, calling the builder callbacks.""" - statements = _upgrade_mixed_subst_statements(self.statements) - for s in statements: + for s in self.statements: s.build(builder) def asFea(self, indent=""): diff --git a/Lib/fontTools/feaLib/builder.py b/Lib/fontTools/feaLib/builder.py index 8747b1d488..3563db6e37 100644 --- a/Lib/fontTools/feaLib/builder.py +++ b/Lib/fontTools/feaLib/builder.py @@ -29,6 +29,7 @@ PairPosBuilder, SinglePosBuilder, ChainContextualRule, + AnySubstBuilder, ) from fontTools.otlLib.error import OpenTypeLibError from fontTools.varLib.varStore import OnlineVarStoreBuilder @@ -866,13 +867,22 @@ def buildLookups_(self, tag): for lookup in self.lookups_: if lookup.table != tag: continue - lookup.lookup_index = len(lookups) - self.lookup_locations[tag][str(lookup.lookup_index)] = LookupDebugInfo( - location=str(lookup.location), - name=self.get_lookup_name_(lookup), - feature=None, - ) - lookups.append(lookup) + name = self.get_lookup_name_(lookup) + resolved = lookup.promote_lookup_type(is_named_lookup=name is not None) + if resolved is None: + raise FeatureLibError( + "Within a named lookup block, all rules must be of " + "the same lookup type and flag", + lookup.location, + ) + for l in resolved: + lookup.lookup_index = len(lookups) + self.lookup_locations[tag][str(lookup.lookup_index)] = LookupDebugInfo( + location=str(lookup.location), + name=name, + feature=None, + ) + lookups.append(l) otLookups = [] for l in lookups: try: @@ -1294,6 +1304,24 @@ def set_size_parameters( # GSUB rules + def add_any_subst_(self, location, mapping): + lookup = self.get_lookup_(location, AnySubstBuilder) + for key, value in mapping.items(): + if key in lookup.mapping: + if value == lookup.mapping[key]: + log.info( + 'Removing duplicate substitution from "%s" to "%s" at %s', + ", ".join(key), + ", ".join(value), + location, + ) + else: + raise FeatureLibError( + 'Already defined substitution for "%s"' % ", ".join(key), + location, + ) + lookup.mapping[key] = value + # GSUB 1 def add_single_subst(self, location, prefix, suffix, mapping, forceChain): if self.cur_feature_name_ == "aalt": @@ -1305,24 +1333,11 @@ def add_single_subst(self, location, prefix, suffix, mapping, forceChain): if prefix or suffix or forceChain: self.add_single_subst_chained_(location, prefix, suffix, mapping) return - lookup = self.get_lookup_(location, SingleSubstBuilder) - for from_glyph, to_glyph in mapping.items(): - if from_glyph in lookup.mapping: - if to_glyph == lookup.mapping[from_glyph]: - log.info( - "Removing duplicate single substitution from glyph" - ' "%s" to "%s" at %s', - from_glyph, - to_glyph, - location, - ) - else: - raise FeatureLibError( - 'Already defined rule for replacing glyph "%s" by "%s"' - % (from_glyph, lookup.mapping[from_glyph]), - location, - ) - lookup.mapping[from_glyph] = to_glyph + + self.add_any_subst_( + location, + {(key,): (value,) for key, value in mapping.items()}, + ) # GSUB 2 def add_multiple_subst( @@ -1331,21 +1346,10 @@ def add_multiple_subst( if prefix or suffix or forceChain: self.add_multi_subst_chained_(location, prefix, glyph, suffix, replacements) return - lookup = self.get_lookup_(location, MultipleSubstBuilder) - if glyph in lookup.mapping: - if replacements == lookup.mapping[glyph]: - log.info( - "Removing duplicate multiple substitution from glyph" - ' "%s" to %s%s', - glyph, - replacements, - f" at {location}" if location else "", - ) - else: - raise FeatureLibError( - 'Already defined substitution for glyph "%s"' % glyph, location - ) - lookup.mapping[glyph] = replacements + self.add_any_subst_( + location, + {(glyph,): tuple(replacements)}, + ) # GSUB 3 def add_alternate_subst(self, location, prefix, glyph, suffix, replacement): @@ -1375,9 +1379,6 @@ def add_ligature_subst( location, prefix, glyphs, suffix, replacement ) return - else: - lookup = self.get_lookup_(location, LigatureSubstBuilder) - if not all(glyphs): raise FeatureLibError("Empty glyph class in substitution", location) @@ -1386,15 +1387,10 @@ def add_ligature_subst( # substitutions to be specified on target sequences that contain # glyph classes, the implementation software will enumerate # all specific glyph sequences if glyph classes are detected" - for g in itertools.product(*glyphs): - existing = lookup.ligatures.get(g, replacement) - if existing != replacement: - raise FeatureLibError( - f"Conflicting ligature sub rules: '{g}' maps to '{existing}' and '{replacement}'", - location, - ) - - lookup.ligatures[g] = replacement + self.add_any_subst_( + location, + {g: (replacement,) for g in itertools.product(*glyphs)}, + ) # GSUB 5/6 def add_chain_context_subst(self, location, prefix, glyphs, suffix, lookups): diff --git a/Lib/fontTools/otlLib/builder.py b/Lib/fontTools/otlLib/builder.py index dd95fb95e4..da00e9c5eb 100644 --- a/Lib/fontTools/otlLib/builder.py +++ b/Lib/fontTools/otlLib/builder.py @@ -170,6 +170,9 @@ def equals(self, other): and self.extension == other.extension ) + def promote_lookup_type(self, is_named_lookup): + return [self] + def inferGlyphClasses(self): """Infers glyph glasses for the GDEF table, such as {"cedilla":3}.""" return {} @@ -1324,6 +1327,151 @@ def add_subtable_break(self, location): pass +class AnySubstBuilder(LookupBuilder): + """A temporary builder for Single, Multiple, or Ligature substitution lookup. + + Users are expected to manually add substitutions to the ``mapping`` + attribute after the object has been initialized, e.g.:: + + # sub x by y; + builder.mapping[("x",)] = ("y",) + # sub a by b c; + builder.mapping[("a",)] = ("b", "c") + # sub f i by f_i; + builder.mapping[("f", "i")] = ("f_i",) + + Then call `promote_lookup_type()` to convert this builder into the + appropriate type of substitution lookup builder. This would promote single + substitutions to either multiple or ligature substitutions, depending on the + rest of the rules in the mapping. + + Attributes: + font (``fontTools.TTLib.TTFont``): A font object. + location: A string or tuple representing the location in the original + source which produced this lookup. + mapping: An ordered dictionary mapping a tuple of glyph names to another + tuple of glyph names. + lookupflag (int): The lookup's flag + markFilterSet: Either ``None`` if no mark filtering set is used, or + an integer representing the filtering set to be used for this + lookup. If a mark filtering set is provided, + `LOOKUP_FLAG_USE_MARK_FILTERING_SET` will be set on the lookup's + flags. + """ + + def __init__(self, font, location): + LookupBuilder.__init__(self, font, location, "GSUB", 0) + self.mapping = OrderedDict() + + def _add_to_single_subst(self, builder, key, value): + if key[0] != self.SUBTABLE_BREAK_: + key = key[0] + builder.mapping[key] = value[0] + + def _add_to_multiple_subst(self, builder, key, value): + if key[0] != self.SUBTABLE_BREAK_: + key = key[0] + builder.mapping[key] = value + + def _add_to_ligature_subst(self, builder, key, value): + builder.ligatures[key] = value[0] + + def promote_lookup_type(self, is_named_lookup): + # https://github.com/fonttools/fonttools/issues/612 + # A multiple substitution may have a single destination, in which case + # it will look just like a single substitution. So if there are both + # multiple and single substitutions, upgrade all the single ones to + # multiple substitutions. Similarly, a ligature substitution may have a + # single source glyph, so if there are both ligature and single + # substitutions, upgrade all the single ones to ligature substitutions. + builder_classes = [] + for key, value in self.mapping.items(): + if key[0] == self.SUBTABLE_BREAK_: + builder_classes.append(None) + elif len(key) == 1 and len(value) == 1: + builder_classes.append(SingleSubstBuilder) + elif len(key) == 1 and len(value) != 1: + builder_classes.append(MultipleSubstBuilder) + elif len(key) > 1 and len(value) == 1: + builder_classes.append(LigatureSubstBuilder) + else: + assert False, "Should not happen" + + has_multiple = any(b is MultipleSubstBuilder for b in builder_classes) + has_ligature = any(b is LigatureSubstBuilder for b in builder_classes) + + # If we have mixed single and multiple substitutions, + # upgrade all single substitutions to multiple substitutions. + to_multiple = has_multiple and not has_ligature + + # If we have mixed single and ligature substitutions, + # upgrade all single substitutions to ligature substitutions. + to_ligature = has_ligature and not has_multiple + + # If we have only single substitutions, we can keep them as is. + to_single = not has_ligature and not has_multiple + + ret = [] + if to_single: + builder = SingleSubstBuilder(self.font, self.location) + for key, value in self.mapping.items(): + self._add_to_single_subst(builder, key, value) + ret = [builder] + elif to_multiple: + builder = MultipleSubstBuilder(self.font, self.location) + for key, value in self.mapping.items(): + self._add_to_multiple_subst(builder, key, value) + ret = [builder] + elif to_ligature: + builder = LigatureSubstBuilder(self.font, self.location) + for key, value in self.mapping.items(): + self._add_to_ligature_subst(builder, key, value) + ret = [builder] + elif is_named_lookup: + # This is a named lookup with mixed substitutions that can’t be promoted, + # since we can’t split it into multiple lookups, we return None here to + # signal that to the caller + return None + else: + curr_builder = None + for builder_class, (key, value) in zip( + builder_classes, self.mapping.items() + ): + if curr_builder is None or type(curr_builder) is not builder_class: + curr_builder = builder_class(self.font, self.location) + ret.append(curr_builder) + if builder_class is SingleSubstBuilder: + self._add_to_single_subst(curr_builder, key, value) + elif builder_class is MultipleSubstBuilder: + self._add_to_multiple_subst(curr_builder, key, value) + elif builder_class is LigatureSubstBuilder: + self._add_to_ligature_subst(curr_builder, key, value) + else: + assert False, "Should not happen" + + for builder in ret: + builder.extension = self.extension + builder.lookupflag = self.lookupflag + builder.markFilterSet = self.markFilterSet + return ret + + def equals(self, other): + return LookupBuilder.equals(self, other) and self.mapping == other.mapping + + def build(self): + assert False + + def getAlternateGlyphs(self): + return { + key[0]: value + for key, value in self.mapping.items() + if len(key) == 1 and len(value) == 1 + } + + def add_subtable_break(self, location): + self.mapping[(self.SUBTABLE_BREAK_, location)] = self.SUBTABLE_BREAK_ + + class SingleSubstBuilder(LookupBuilder): """Builds a Single Substitution (GSUB1) lookup. diff --git a/Tests/feaLib/builder_test.py b/Tests/feaLib/builder_test.py index d0436881b7..e2640d2806 100644 --- a/Tests/feaLib/builder_test.py +++ b/Tests/feaLib/builder_test.py @@ -94,6 +94,7 @@ class BuilderTest(unittest.TestCase): single_pos_NULL class_pair_pos_duplicates useExtension + bug3846_1 bug3846_2 """.split() VARFONT_AXES = [ @@ -276,14 +277,12 @@ def test_singleSubst_multipleIdenticalSubstitutionsForSameGlyph_info(self): " sub A by A.sc;" "} test;" ) - captor.assertRegex( - 'Removing duplicate single substitution from glyph "A" to "A.sc"' - ) + captor.assertRegex('Removing duplicate substitution from "A" to "A.sc"') def test_multipleSubst_multipleSubstitutionsForSameGlyph(self): self.assertRaisesRegex( FeatureLibError, - 'Already defined substitution for glyph "f_f_i"', + 'Already defined substitution for "f_f_i"', self.build, "feature test {" " sub f_f_i by f f i;" @@ -302,9 +301,7 @@ def test_multipleSubst_multipleIdenticalSubstitutionsForSameGlyph_info(self): " sub f_f_i by f f i;" "} test;" ) - captor.assertRegex( - r"Removing duplicate multiple substitution from glyph \"f_f_i\" to \('f', 'f', 'i'\)" - ) + captor.assertRegex('Removing duplicate substitution from "f_f_i" to "f, f, i"') def test_mixed_singleSubst_multipleSubst(self): font = self.build( @@ -413,7 +410,7 @@ def test_mixed_singleSubst_ligatureSubst_aalt(self): self.assertEqual(st.LookupType, 1) self.assertEqual(st.mapping, {"A": "A.sc"}) - def test_mixed_singleSubst_multipleSubst_ligatureSubst(self): + def test_mixed_singleSubst_multipleSubst_ligatureSubst_named_lookup(self): self.assertRaisesRegex( FeatureLibError, "Within a named lookup block, all rules must be of the " @@ -426,6 +423,34 @@ def test_mixed_singleSubst_multipleSubst_ligatureSubst(self): "} test;", ) + def test_mixed_singleSubst_multipleSubst_ligatureSubst_feature(self): + font = self.build( + dedent( + """ + feature test { + sub A by A.sc; + sub f_f by f f; + sub f f i by f_f_i; + } test; + """ + ) + ) + + assert "GSUB" in font + lookups = font["GSUB"].table.LookupList.Lookup + self.assertEqual(len(lookups), 3) + st = lookups[0].SubTable[0] + self.assertEqual(st.LookupType, 1) + self.assertEqual(st.mapping, {"A": "A.sc"}) + st = lookups[1].SubTable[0] + self.assertEqual(st.LookupType, 2) + self.assertEqual(st.mapping, {"f_f": ("f", "f")}) + st = lookups[2].SubTable[0] + self.assertEqual(st.LookupType, 4) + self.assertEqual(len(st.ligatures), 1) + self.assertEqual(len(st.ligatures["f"]), 1) + self.assertEqual(st.ligatures["f"][0].LigGlyph, "f_f_i") + def test_pairPos_redefinition_warning(self): # https://github.com/fonttools/fonttools/issues/1147 logger = logging.getLogger("fontTools.otlLib.builder") @@ -454,7 +479,7 @@ def test_pairPos_redefinition_warning(self): def test_singleSubst_multipleSubstitutionsForSameGlyph(self): self.assertRaisesRegex( FeatureLibError, - 'Already defined rule for replacing glyph "e" by "E.sc"', + 'Already defined substitution for "e"', self.build, "feature test {" " sub [a-z] by [A.sc-Z.sc];" @@ -1067,7 +1092,7 @@ def test_pairPos_enumRuleOverridenBySinglePair_DEBUG(self): def test_ligatureSubst_conflicting_rules(self): self.assertRaisesRegex( FeatureLibError, - "Conflicting ligature sub", + 'Already defined substitution for "a, b"', self.build, "feature test {" " sub a b by one;" " sub a b by two;" "} test;", ) diff --git a/Tests/feaLib/data/bug3846_1.fea b/Tests/feaLib/data/bug3846_1.fea new file mode 100644 index 0000000000..14071aae7b --- /dev/null +++ b/Tests/feaLib/data/bug3846_1.fea @@ -0,0 +1,5 @@ +feature derp { + sub x by y; + lookupflag IgnoreLigatures; + sub f i by one; +} derp; diff --git a/Tests/feaLib/data/bug3846_1.ttx b/Tests/feaLib/data/bug3846_1.ttx new file mode 100644 index 0000000000..3ac80e07ad --- /dev/null +++ b/Tests/feaLib/data/bug3846_1.ttx @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/feaLib/data/bug3846_2.fea b/Tests/feaLib/data/bug3846_2.fea new file mode 100644 index 0000000000..c7f6647da5 --- /dev/null +++ b/Tests/feaLib/data/bug3846_2.fea @@ -0,0 +1,9 @@ +lookup ther { +sub l by j; +} ther; + +feature clig { + sub a by b; + sub e l l' lookup ther; + sub e l f by z; +} clig; diff --git a/Tests/feaLib/data/bug3846_2.ttx b/Tests/feaLib/data/bug3846_2.ttx new file mode 100644 index 0000000000..068fb509f5 --- /dev/null +++ b/Tests/feaLib/data/bug3846_2.ttx @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/otlLib/maxContextCalc_test.py b/Tests/otlLib/maxContextCalc_test.py index a94ba246e5..6d2a736556 100644 --- a/Tests/otlLib/maxContextCalc_test.py +++ b/Tests/otlLib/maxContextCalc_test.py @@ -30,7 +30,6 @@ def test_max_ctx_calc_features(): sub A B C by c; sub [A B] C by c; sub [A B] C [A B] by c; - sub A by A B; sub A' C by A B; sub a' by b; sub a' b by c; From 403c7cab9ef38d0bbf7eef0f37aebf1e9fac2ce7 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 28 May 2025 12:50:48 +0100 Subject: [PATCH 33/36] don't call addMultilingualName twice; try to find else add --- Lib/fontTools/varLib/__init__.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/Lib/fontTools/varLib/__init__.py b/Lib/fontTools/varLib/__init__.py index c3954aceb5..7ccecb75ff 100644 --- a/Lib/fontTools/varLib/__init__.py +++ b/Lib/fontTools/varLib/__init__.py @@ -136,19 +136,18 @@ def _add_fvar(font, axes, instances: List[InstanceDescriptor]): axes[k].tag: axes[k].map_backward(v) for k, v in coordinates.items() } - subfamilyNameID = nameTable.addMultilingualName( - localisedStyleName, windows=True, mac=macNames, minNameID=0 + subfamilyNameID = nameTable.findMultilingualName( + localisedStyleName, windows=True, mac=macNames ) - if subfamilyNameID in {2, 17} and inst.coordinates != default_coordinates: - # Instances should only reuse name ID 2 or 17 if they are at the - # default location across all axes, so try again and only look at - # IDs >= 256. See + if subfamilyNameID in {2, 17} and inst.coordinates == default_coordinates: + # Instances can only reuse an existing name ID 2 or 17 if they are at the + # default location across all axes, see: # https://github.com/fonttools/fonttools/issues/3825. + inst.subfamilyNameID = subfamilyNameID + else: inst.subfamilyNameID = nameTable.addMultilingualName( localisedStyleName, windows=True, mac=macNames, minNameID=256 ) - else: - inst.subfamilyNameID = subfamilyNameID if psname is not None: psname = tostr(psname) From a2284540c98ceee192ecb9eec19508700eb8abe1 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 28 May 2025 16:00:19 +0100 Subject: [PATCH 34/36] [loggingTools] make Timer._time a static method that doesn't take self but it is still defined at the class level --- Lib/fontTools/misc/loggingTools.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Lib/fontTools/misc/loggingTools.py b/Lib/fontTools/misc/loggingTools.py index 99fbddfae0..be6c2d369b 100644 --- a/Lib/fontTools/misc/loggingTools.py +++ b/Lib/fontTools/misc/loggingTools.py @@ -284,12 +284,11 @@ class Timer(object): """ # timeit.default_timer choses the most accurate clock for each platform - _time: Callable[[], float] + _time: Callable[[], float] = staticmethod(timeit.default_timer) default_msg = "elapsed time: %(time).3fs" default_format = "Took %(time).3fs to %(msg)s" def __init__(self, logger=None, msg=None, level=None, start=None): - self._time = timeit.default_timer self.reset(start) if logger is None: for arg in ("msg", "level"): From a48f5d9298e34fcfe00b09604e3bb184b5959791 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 28 May 2025 16:07:05 +0100 Subject: [PATCH 35/36] Update NEWS.rst --- NEWS.rst | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/NEWS.rst b/NEWS.rst index 6d74ced804..5fc65d8d91 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -1,3 +1,16 @@ +- [varLib] Make sure that fvar named instances only reuse name ID 2 or 17 if they are at the default location across all axes, to match OT spec requirement (#3831). +- [feaLib] Improve single substitution promotion to multiple/ligature substitutions, fixing a few bugs as well (#3849). +- [loggingTools] Make ``Timer._time`` a static method that doesn't take self, makes it easier to override (#3836). +- [featureVars] Use ``None`` for empty ConditionSet, which translates to a null offset in the compiled table (#3850). +- [feaLib] Raise an error on conflicting ligature substitution rules instead of silently taking the last one (#3835). +- Add typing annotations to T2CharStringPen (#3837). +- [feaLib] Add single substitutions that were promoted to multiple or ligature substitutions to ``aalt`` feature (#3847). +- [featureVars] Create a default ``LangSys`` in a ``ScriptRecord`` if missing when adding feature variations to existing GSUB later in the build (#3838). +- [symfont] Added a ``main()``. +- [cffLib.specializer] Fix rmoveto merging when blends used (#3839, #3840). +- [pyftmerge] Add support for cmap format 14 in the merge tool (#3830). +- [varLib.instancer/cff2] Fix vsindex of Private dicts when instantiating (#3828, #3232). +- Update text file read to use UTF-8 with optional BOM so it works with e.g. Windows Notepad.exe (#3824). - [varLib] Ensure that instances only reuse name ID 2 or 17 if they are at the default location across all axes (#3831). - [varLib] Create a dflt LangSys in a ScriptRecord when adding variations later, to fix an avoidable crash in an edge case (#3838). From ad9e68badc12def1fd17a912d9ca88ad8132edff Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 28 May 2025 16:08:53 +0100 Subject: [PATCH 36/36] Release 4.58.1 --- Lib/fontTools/__init__.py | 2 +- NEWS.rst | 3 +++ setup.cfg | 2 +- setup.py | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/__init__.py b/Lib/fontTools/__init__.py index ed0fd36c35..ee6b53b553 100644 --- a/Lib/fontTools/__init__.py +++ b/Lib/fontTools/__init__.py @@ -3,6 +3,6 @@ log = logging.getLogger(__name__) -version = __version__ = "4.58.1.dev0" +version = __version__ = "4.58.1" __all__ = ["version", "log", "configLogger"] diff --git a/NEWS.rst b/NEWS.rst index 5fc65d8d91..868982793f 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -1,3 +1,6 @@ +4.58.1 (released 2025-05-28) +---------------------------- + - [varLib] Make sure that fvar named instances only reuse name ID 2 or 17 if they are at the default location across all axes, to match OT spec requirement (#3831). - [feaLib] Improve single substitution promotion to multiple/ligature substitutions, fixing a few bugs as well (#3849). - [loggingTools] Make ``Timer._time`` a static method that doesn't take self, makes it easier to override (#3836). diff --git a/setup.cfg b/setup.cfg index 8aef1d31a6..6d90290877 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 4.58.1.dev0 +current_version = 4.58.1 commit = True tag = False tag_name = {new_version} diff --git a/setup.py b/setup.py index 97b22728b4..4f47ced104 100755 --- a/setup.py +++ b/setup.py @@ -493,7 +493,7 @@ def build_extensions(self): setup_params = dict( name="fonttools", - version="4.58.1.dev0", + version="4.58.1", description="Tools to manipulate font files", author="Just van Rossum", author_email="just@letterror.com",