diff --git a/Lib/fontTools/feaLib/builder.py b/Lib/fontTools/feaLib/builder.py index 456ae3cc7..8880acf1c 100644 --- a/Lib/fontTools/feaLib/builder.py +++ b/Lib/fontTools/feaLib/builder.py @@ -7,6 +7,7 @@ from fontTools.feaLib.error import FeatureLibError from fontTools.feaLib.parser import Parser from fontTools.feaLib.ast import FeatureFile from fontTools.otlLib import builder as otl +from fontTools.otlLib.maxContextCalc import maxCtxFont from fontTools.ttLib import newTable, getTableModule from fontTools.ttLib.tables import otBase, otTables from collections import defaultdict, OrderedDict @@ -137,6 +138,9 @@ class Builder(object): fontTable.table = table elif tag in self.font: del self.font[tag] + if (any(tag in self.font for tag in ("GPOS", "GSUB")) and + "OS/2" in self.font): + self.font["OS/2"].usMaxContext = maxCtxFont(self.font) if "GDEF" in tables: gdef = self.buildGDEF() if gdef: diff --git a/Lib/fontTools/fontBuilder.py b/Lib/fontTools/fontBuilder.py index 18f6d4371..ef14b045f 100644 --- a/Lib/fontTools/fontBuilder.py +++ b/Lib/fontTools/fontBuilder.py @@ -21,6 +21,7 @@ that works: fb.setupHorizontalHeader() fb.setupNameTable(...) fb.setupOS2() + fb.addOpenTypeFeatures(...) fb.setupPost() fb.save(...) @@ -299,7 +300,7 @@ _OS2Defaults = dict( sCapHeight = 0, usDefaultChar = 0, # .notdef usBreakChar = 32, # space - usMaxContext = 2, # just kerning + usMaxContext = 0, usLowerOpticalPointSize = 0, usUpperOpticalPointSize = 0, ) diff --git a/Lib/fontTools/otlLib/maxContextCalc.py b/Lib/fontTools/otlLib/maxContextCalc.py new file mode 100644 index 000000000..5659310f2 --- /dev/null +++ b/Lib/fontTools/otlLib/maxContextCalc.py @@ -0,0 +1,101 @@ +from __future__ import print_function, division, absolute_import, unicode_literals + +__all__ = ['maxCtxFont'] + + +def maxCtxFont(font): + """Calculate the usMaxContext value for an entire font.""" + + maxCtx = 0 + for tag in ('GSUB', 'GPOS'): + if tag not in font: + continue + table = font[tag].table + if not table.LookupList: + continue + for lookup in table.LookupList.Lookup: + for st in lookup.SubTable: + maxCtx = maxCtxSubtable(maxCtx, tag, lookup.LookupType, st) + return maxCtx + + +def maxCtxSubtable(maxCtx, tag, lookupType, st): + """Calculate usMaxContext based on a single lookup table (and an existing + max value). + """ + + # single positioning, single / multiple substitution + if (tag == 'GPOS' and lookupType == 1) or ( + tag == 'GSUB' and lookupType in (1, 2, 3)): + maxCtx = max(maxCtx, 1) + + # pair positioning + elif tag == 'GPOS' and lookupType == 2: + maxCtx = max(maxCtx, 2) + + # ligatures + elif tag == 'GSUB' and lookupType == 4: + for ligatures in st.ligatures.values(): + for ligature in ligatures: + maxCtx = max(maxCtx, ligature.CompCount) + + # context + elif (tag == 'GPOS' and lookupType == 7) or ( + tag == 'GSUB' and lookupType == 5): + maxCtx = maxCtxContextualSubtable( + maxCtx, st, 'Pos' if tag == 'GPOS' else 'Sub') + + # chained context + elif (tag == 'GPOS' and lookupType == 8) or ( + tag == 'GSUB' and lookupType == 6): + maxCtx = maxCtxContextualSubtable( + maxCtx, st, 'Pos' if tag == 'GPOS' else 'Sub', 'Chain') + + # extensions + elif (tag == 'GPOS' and lookupType == 9) or ( + tag == 'GSUB' and lookupType == 7): + maxCtx = maxCtxSubtable( + maxCtx, tag, st.ExtensionLookupType, st.ExtSubTable) + + # reverse-chained context + elif tag == 'GSUB' and lookupType == 8: + maxCtx = maxCtxContextualRule(maxCtx, st, 'Reverse') + + return maxCtx + + +def maxCtxContextualSubtable(maxCtx, st, ruleType, chain=''): + """Calculate usMaxContext based on a contextual feature subtable.""" + + if st.Format == 1: + for ruleset in getattr(st, '%s%sRuleSet' % (chain, ruleType)): + if ruleset is None: + continue + for rule in getattr(ruleset, '%s%sRule' % (chain, ruleType)): + if rule is None: + continue + maxCtx = maxCtxContextualRule(maxCtx, rule, chain) + + elif st.Format == 2: + for ruleset in getattr(st, '%s%sClassSet' % (chain, ruleType)): + if ruleset is None: + continue + for rule in getattr(ruleset, '%s%sClassRule' % (chain, ruleType)): + if rule is None: + continue + maxCtx = maxCtxContextualRule(maxCtx, rule, chain) + + elif st.Format == 3: + maxCtx = maxCtxContextualRule(maxCtx, st, chain) + + return maxCtx + + +def maxCtxContextualRule(maxCtx, st, chain): + """Calculate usMaxContext based on a contextual feature rule.""" + + if not chain: + return max(maxCtx, st.GlyphCount) + elif chain == 'Reverse': + return max(maxCtx, st.GlyphCount + st.LookAheadGlyphCount) + return max(maxCtx, st.InputGlyphCount + st.LookAheadGlyphCount) diff --git a/Lib/fontTools/subset/__init__.py b/Lib/fontTools/subset/__init__.py index 143a0277c..5fddef907 100644 --- a/Lib/fontTools/subset/__init__.py +++ b/Lib/fontTools/subset/__init__.py @@ -7,6 +7,7 @@ from fontTools.misc.py23 import * from fontTools.misc.fixedTools import otRound from fontTools import ttLib from fontTools.ttLib.tables import otTables +from fontTools.otlLib.maxContextCalc import maxCtxFont from fontTools.pens.basePen import NullPen from fontTools.misc.loggingTools import Timer from fontTools.subset.cff import * @@ -322,6 +323,10 @@ Other font-specific options: Update the 'OS/2 xAvgCharWidth' field after subsetting. --no-recalc-average-width Don't change the 'OS/2 xAvgCharWidth' field. [default] + --recalc-max-context + Update the 'OS/2 usMaxContext' field after subsetting. + --no-recalc-max-context + Don't change the 'OS/2 usMaxContext' field. [default] --font-number= Select font number for TrueType Collection (.ttc/.otc), starting from 0. @@ -2305,6 +2310,7 @@ class Options(object): self.recalc_timestamp = False # Recalculate font modified timestamp self.prune_unicode_ranges = True # Clear unused 'ulUnicodeRange' bits self.recalc_average_width = False # update 'xAvgCharWidth' + self.recalc_max_context = False # update 'usMaxContext' self.canonical_order = None # Order tables as recommended self.flavor = None # May be 'woff' or 'woff2' self.with_zopfli = False # use zopfli instead of zlib for WOFF 1.0 @@ -2614,6 +2620,11 @@ class Subsetter(object): if avg_width != font[tag].xAvgCharWidth: font[tag].xAvgCharWidth = avg_width log.info("%s xAvgCharWidth updated: %d", tag, avg_width) + if self.options.recalc_max_context: + max_context = maxCtxFont(font) + if max_context != font[tag].usMaxContext: + font[tag].usMaxContext = max_context + log.info("%s usMaxContext updated: %d", tag, max_context) clazz = ttLib.getTableClass(tag) if hasattr(clazz, 'prune_post_subset'): with timer("prune '%s'" % tag): diff --git a/Tests/fontBuilder/data/test.otf.ttx b/Tests/fontBuilder/data/test.otf.ttx index 4c9a2a754..76ebb1777 100644 --- a/Tests/fontBuilder/data/test.otf.ttx +++ b/Tests/fontBuilder/data/test.otf.ttx @@ -233,6 +233,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/fontBuilder/data/test.ttf.ttx b/Tests/fontBuilder/data/test.ttf.ttx index b2804ccdd..28e179e02 100644 --- a/Tests/fontBuilder/data/test.ttf.ttx +++ b/Tests/fontBuilder/data/test.ttf.ttx @@ -119,7 +119,7 @@ - + @@ -257,6 +257,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/fontBuilder/data/test_var.otf.ttx b/Tests/fontBuilder/data/test_var.otf.ttx index 960294114..e00d33c59 100644 --- a/Tests/fontBuilder/data/test_var.otf.ttx +++ b/Tests/fontBuilder/data/test_var.otf.ttx @@ -105,7 +105,7 @@ - + diff --git a/Tests/fontBuilder/data/test_var.ttf.ttx b/Tests/fontBuilder/data/test_var.ttf.ttx index 760e65a56..54ddfb06a 100644 --- a/Tests/fontBuilder/data/test_var.ttf.ttx +++ b/Tests/fontBuilder/data/test_var.ttf.ttx @@ -119,7 +119,7 @@ - + diff --git a/Tests/fontBuilder/fontBuilder_test.py b/Tests/fontBuilder/fontBuilder_test.py index 7d73b87ee..309528468 100644 --- a/Tests/fontBuilder/fontBuilder_test.py +++ b/Tests/fontBuilder/fontBuilder_test.py @@ -117,6 +117,7 @@ def test_build_ttf(tmpdir): fb.setupHorizontalHeader(ascent=824, descent=200) fb.setupNameTable(nameStrings) fb.setupOS2() + fb.addOpenTypeFeatures("feature salt { sub A by a; } salt;") fb.setupPost() fb.setupDummyDSIG() @@ -145,6 +146,7 @@ def test_build_otf(tmpdir): fb.setupHorizontalHeader(ascent=824, descent=200) fb.setupNameTable(nameStrings) fb.setupOS2() + fb.addOpenTypeFeatures("feature kern { pos A a -50; } kern;") fb.setupPost() fb.setupDummyDSIG() diff --git a/Tests/otlLib/data/gpos_91.ttx b/Tests/otlLib/data/gpos_91.ttx new file mode 100644 index 000000000..ee7bf7c24 --- /dev/null +++ b/Tests/otlLib/data/gpos_91.ttx @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/otlLib/data/gsub_51.ttx b/Tests/otlLib/data/gsub_51.ttx new file mode 100644 index 000000000..280582c9a --- /dev/null +++ b/Tests/otlLib/data/gsub_51.ttx @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/otlLib/data/gsub_52.ttx b/Tests/otlLib/data/gsub_52.ttx new file mode 100644 index 000000000..189178c38 --- /dev/null +++ b/Tests/otlLib/data/gsub_52.ttx @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/otlLib/data/gsub_71.ttx b/Tests/otlLib/data/gsub_71.ttx new file mode 100644 index 000000000..201de4cbe --- /dev/null +++ b/Tests/otlLib/data/gsub_71.ttx @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/otlLib/maxContextCalc_test.py b/Tests/otlLib/maxContextCalc_test.py new file mode 100644 index 000000000..759b30dd8 --- /dev/null +++ b/Tests/otlLib/maxContextCalc_test.py @@ -0,0 +1,76 @@ +from __future__ import print_function, division, absolute_import +from __future__ import unicode_literals + +import os +import pytest +from fontTools.ttLib import TTFont +from fontTools.otlLib.maxContextCalc import maxCtxFont +from fontTools.feaLib.builder import addOpenTypeFeaturesFromString + + +def test_max_ctx_calc_no_features(): + font = TTFont() + assert maxCtxFont(font) == 0 + font.setGlyphOrder(['.notdef']) + addOpenTypeFeaturesFromString(font, '') + assert maxCtxFont(font) == 0 + + +def test_max_ctx_calc_features(): + glyphs = '.notdef space A B C a b c'.split() + features = """ + lookup GSUB_EXT useExtension { + sub a by b; + } GSUB_EXT; + + lookup GPOS_EXT useExtension { + pos a b -10; + } GPOS_EXT; + + feature sub1 { + sub A by a; + sub A B by b; + 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; + sub a from [A B C]; + rsub a by b; + rsub a' by b; + rsub a b' by c; + rsub a b' c by A; + rsub [a b] c' by A; + rsub [a b] c' [a b] by B; + lookup GSUB_EXT; + } sub1; + + feature pos1 { + pos A 20; + pos A B -50; + pos A B' 10 C; + lookup GPOS_EXT; + } pos1; + """ + font = TTFont() + font.setGlyphOrder(glyphs) + addOpenTypeFeaturesFromString(font, features) + + assert maxCtxFont(font) == 3 + + +@pytest.mark.parametrize('file_name, max_context', [ + ('gsub_51', 2), + ('gsub_52', 2), + ('gsub_71', 1), + ('gpos_91', 1), +]) +def test_max_ctx_calc_features_ttx(file_name, max_context): + ttx_path = os.path.join(os.path.dirname(__file__), + 'data', '{}.ttx'.format(file_name)) + font = TTFont() + font.importXML(ttx_path) + + assert maxCtxFont(font) == max_context diff --git a/Tests/subset/subset_test.py b/Tests/subset/subset_test.py index 2fc3b8afd..dde3a2d29 100644 --- a/Tests/subset/subset_test.py +++ b/Tests/subset/subset_test.py @@ -485,6 +485,24 @@ class SubsetTest(unittest.TestCase): subset.main([fontpath, "--recalc-timestamp", "--output-file=%s" % subsetpath, "*"]) self.assertLess(modified, TTFont(subsetpath)['head'].modified) + def test_recalc_max_context(self): + ttxpath = self.getpath("Lobster.subset.ttx") + font = TTFont() + font.importXML(ttxpath) + max_context = font['OS/2'].usMaxContext + _, fontpath = self.compile_font(ttxpath, ".otf") + subsetpath = self.temp_path(".otf") + + # by default, the subsetter does not recalculate the usMaxContext + subset.main([fontpath, "--drop-tables+=GSUB,GPOS", + "--output-file=%s" % subsetpath]) + self.assertEqual(max_context, TTFont(subsetpath)['OS/2'].usMaxContext) + + subset.main([fontpath, "--recalc-max-context", + "--drop-tables+=GSUB,GPOS", + "--output-file=%s" % subsetpath]) + self.assertEqual(0, TTFont(subsetpath)['OS/2'].usMaxContext) + def test_retain_gids_ttf(self): _, fontpath = self.compile_font(self.getpath("TestTTF-Regular.ttx"), ".ttf") font = TTFont(fontpath)