From 1b49efd039bc01ff399d9d9c6a56994fc1a344af Mon Sep 17 00:00:00 2001 From: Nikolaus Waxweiler Date: Thu, 5 Sep 2024 09:37:17 +0100 Subject: [PATCH 1/3] Silence unactionable warnings --- setup.cfg | 2 ++ 1 file changed, 2 insertions(+) diff --git a/setup.cfg b/setup.cfg index 8dd8cafc5..9b2aed290 100644 --- a/setup.cfg +++ b/setup.cfg @@ -24,3 +24,5 @@ filterwarnings: ignore:tostring:DeprecationWarning ignore:fromstring:DeprecationWarning ignore:.*bytes:DeprecationWarning:fs.base + ignore::DeprecationWarning:fs + ignore::DeprecationWarning:pkg_resources \ No newline at end of file From 5dc1da09ce710180c7371d5d41f7474f72b14ac8 Mon Sep 17 00:00:00 2001 From: Nikolaus Waxweiler Date: Thu, 5 Sep 2024 09:37:51 +0100 Subject: [PATCH 2/3] Change testing infra for alternate kern writer --- dev-requirements.txt | 1 + .../kernFeatureWriter2_test.ambr | 707 ++++++ .../featureWriters/kernFeatureWriter2_test.py | 2170 ++++++++--------- 3 files changed, 1768 insertions(+), 1110 deletions(-) create mode 100644 tests/featureWriters/__snapshots__/kernFeatureWriter2_test.ambr diff --git a/dev-requirements.txt b/dev-requirements.txt index 5fdb59880..94da14838 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -3,3 +3,4 @@ pytest black isort flake8-bugbear +syrupy \ No newline at end of file diff --git a/tests/featureWriters/__snapshots__/kernFeatureWriter2_test.ambr b/tests/featureWriters/__snapshots__/kernFeatureWriter2_test.ambr new file mode 100644 index 000000000..73fc632f7 --- /dev/null +++ b/tests/featureWriters/__snapshots__/kernFeatureWriter2_test.ambr @@ -0,0 +1,707 @@ +# serializer version: 1 +# name: test_arabic_numerals + ''' + lookup kern_rtl { + lookupflag IgnoreMarks; + pos four-ar seven-ar -30; + } kern_rtl; + + feature kern { + lookup kern_rtl; + } kern; + + ''' +# --- +# name: test_arabic_numerals.1 + ''' + lookup kern_rtl { + lookupflag IgnoreMarks; + pos four-ar seven-ar -30; + } kern_rtl; + + feature kern { + lookup kern_rtl; + } kern; + + ''' +# --- +# name: test_arabic_numerals.2 + ''' + lookup kern_rtl { + lookupflag IgnoreMarks; + pos four-ar seven-ar -30; + } kern_rtl; + + feature kern { + lookup kern_rtl; + } kern; + + ''' +# --- +# name: test_arabic_numerals.3 + ''' + lookup kern_rtl { + lookupflag IgnoreMarks; + pos four-ar seven-ar -30; + } kern_rtl; + + feature kern { + lookup kern_rtl; + } kern; + + ''' +# --- +# name: test_defining_classdefs + ''' + @kern1.shatelugu.below = [sha-telugu.below]; + @kern1.ssatelugu.alt = [ssa-telugu.alt ss-telugu.alt]; + @kern2.katelugu.below = [ka-telugu.below]; + @kern2.rVocalicMatratelugu = [rVocalicMatra-telugu]; + + lookup kern_ltr { + lookupflag IgnoreMarks; + enum pos @kern1.ssatelugu.alt sha-telugu.below 150; + pos @kern1.shatelugu.below @kern2.katelugu.below 20; + pos @kern1.ssatelugu.alt @kern2.katelugu.below 60; + } kern_ltr; + + lookup kern_ltr_marks { + pos @kern1.ssatelugu.alt @kern2.rVocalicMatratelugu 180; + } kern_ltr_marks; + + feature kern { + lookup kern_ltr; + lookup kern_ltr_marks; + } kern; + + ''' +# --- +# name: test_dflt_language + ''' + lookup kern_ltr { + lookupflag IgnoreMarks; + pos a a 1; + pos comma comma 2; + } kern_ltr; + + feature kern { + lookup kern_ltr; + } kern; + + ''' +# --- +# name: test_dist_LTR + ''' + @kern1.KND_aaMatra_R = [aaMatra_kannada]; + @kern2.KND_ailength_L = [aaMatra_kannada]; + + lookup kern_ltr { + lookupflag IgnoreMarks; + pos @kern1.KND_aaMatra_R @kern2.KND_ailength_L 34; + } kern_ltr; + + feature kern { + script DFLT; + language dflt; + lookup kern_ltr; + script latn; + language dflt; + lookup kern_ltr; + } kern; + + feature dist { + script knda; + language dflt; + lookup kern_ltr; + script knd2; + language dflt; + lookup kern_ltr; + } dist; + + ''' +# --- +# name: test_dist_LTR_and_RTL + ''' + @kern1.KND_aaMatra_R = [aaMatra_kannada]; + @kern2.KND_ailength_L = [aaMatra_kannada]; + + lookup kern_ltr { + lookupflag IgnoreMarks; + pos @kern1.KND_aaMatra_R @kern2.KND_ailength_L 34; + } kern_ltr; + + lookup kern_rtl { + lookupflag IgnoreMarks; + pos u10A1E u10A06 <117 0 117 0>; + } kern_rtl; + + feature dist { + script knda; + language dflt; + lookup kern_ltr; + script knd2; + language dflt; + lookup kern_ltr; + script khar; + language dflt; + lookup kern_rtl; + } dist; + + ''' +# --- +# name: test_dist_RTL + ''' + lookup kern_rtl { + lookupflag IgnoreMarks; + pos u10A1E u10A06 <117 0 117 0>; + } kern_rtl; + + feature kern { + script DFLT; + language dflt; + lookup kern_rtl; + script arab; + language dflt; + lookup kern_rtl; + } kern; + + feature dist { + script khar; + language dflt; + lookup kern_rtl; + } dist; + + ''' +# --- +# name: test_hyphenated_duplicates + ''' + @kern1.hyphen = [comma]; + @kern1.hyphen_1 = [period]; + + lookup kern_ltr { + lookupflag IgnoreMarks; + enum pos @kern1.hyphen comma 1; + enum pos @kern1.hyphen_1 period 2; + } kern_ltr; + + feature kern { + lookup kern_ltr; + } kern; + + ''' +# --- +# name: test_ignoreMarks + ''' + lookup kern_ltr { + lookupflag IgnoreMarks; + pos four six -55; + pos one six -30; + } kern_ltr; + + feature kern { + lookup kern_ltr; + } kern; + + ''' +# --- +# name: test_ignoreMarks.1 + ''' + lookup kern_ltr { + pos four six -55; + pos one six -30; + } kern_ltr; + + feature kern { + lookup kern_ltr; + } kern; + + ''' +# --- +# name: test_insert_comment_after + ''' + feature kern { + pos one four' -50 six; + # + # + } kern; + + lookup kern_ltr { + lookupflag IgnoreMarks; + pos seven six 25; + } kern_ltr; + + feature kern { + lookup kern_ltr; + } kern; + + ''' +# --- +# name: test_insert_comment_after.1 + ''' + lookup kern_ltr { + lookupflag IgnoreMarks; + pos seven six 25; + } kern_ltr; + + feature kern { + lookup kern_ltr; + } kern; + + ''' +# --- +# name: test_insert_comment_before + ''' + lookup kern_ltr { + lookupflag IgnoreMarks; + pos seven six 25; + } kern_ltr; + + feature kern { + lookup kern_ltr; + } kern; + + feature kern { + # + # + pos one four' -50 six; + } kern; + + ''' +# --- +# name: test_insert_comment_before.1 + ''' + lookup kern_ltr { + lookupflag IgnoreMarks; + pos seven six 25; + } kern_ltr; + + feature kern { + lookup kern_ltr; + } kern; + + ''' +# --- +# name: test_insert_comment_before_extended + ''' + lookup kern_ltr { + lookupflag IgnoreMarks; + pos seven six 25; + } kern_ltr; + + feature kern { + lookup kern_ltr; + } kern; + + feature kern { + # + # + pos one four' -50 six; + } kern; + + ''' +# --- +# name: test_insert_comment_middle + ''' + lookup kern_ltr { + lookupflag IgnoreMarks; + pos seven six 25; + } kern_ltr; + + feature kern { + lookup kern_ltr; + } kern; + + ''' +# --- +# name: test_kern_LTR_and_RTL + ''' + @kern1.A = [A Aacute]; + @kern1.reh = [reh-ar zain-ar reh-ar.fina]; + @kern2.alef = [alef-ar alef-ar.isol]; + + lookup kern_dflt { + pos seven four -25; + } kern_dflt; + + lookup kern_ltr { + enum pos @kern1.A V -40; + } kern_ltr; + + lookup kern_rtl { + pos four-ar seven-ar -30; + pos reh-ar.fina lam-ar.init <-80 0 -80 0>; + pos @kern1.reh @kern2.alef <-100 0 -100 0>; + } kern_rtl; + + feature kern { + lookup kern_dflt; + script latn; + language dflt; + lookup kern_ltr; + language TRK; + script arab; + language dflt; + lookup kern_rtl; + language URD; + } kern; + + ''' +# --- +# name: test_kern_LTR_and_RTL_with_marks + ''' + @kern1.A = [A Aacute]; + @kern1.reh = [reh-ar zain-ar reh-ar.fina]; + @kern2.alef = [alef-ar alef-ar.isol]; + + lookup kern_dflt { + lookupflag IgnoreMarks; + pos seven four -25; + } kern_dflt; + + lookup kern_ltr { + lookupflag IgnoreMarks; + enum pos @kern1.A V -40; + } kern_ltr; + + lookup kern_ltr_marks { + pos V acutecomb 70; + } kern_ltr_marks; + + lookup kern_rtl { + lookupflag IgnoreMarks; + pos four-ar seven-ar -30; + pos reh-ar.fina lam-ar.init <-80 0 -80 0>; + pos @kern1.reh @kern2.alef <-100 0 -100 0>; + } kern_rtl; + + lookup kern_rtl_marks { + pos reh-ar fatha-ar <80 0 80 0>; + } kern_rtl_marks; + + feature kern { + lookup kern_dflt; + script latn; + language dflt; + lookup kern_ltr; + lookup kern_ltr_marks; + language TRK; + script arab; + language dflt; + lookup kern_rtl; + lookup kern_rtl_marks; + language URD; + } kern; + + ''' +# --- +# name: test_kern_RTL_and_DFLT_numbers + ''' + lookup kern_dflt { + lookupflag IgnoreMarks; + pos seven four -25; + } kern_dflt; + + lookup kern_rtl { + lookupflag IgnoreMarks; + pos yod-hb bet-hb <-100 0 -100 0>; + } kern_rtl; + + feature kern { + lookup kern_dflt; + lookup kern_rtl; + } kern; + + ''' +# --- +# name: test_kern_RTL_with_marks + ''' + @kern1.reh = [reh-ar zain-ar reh-ar.fina]; + @kern2.alef = [alef-ar alef-ar.isol]; + + lookup kern_rtl { + lookupflag IgnoreMarks; + pos reh-ar.fina lam-ar.init <-80 0 -80 0>; + pos @kern1.reh @kern2.alef <-100 0 -100 0>; + } kern_rtl; + + lookup kern_rtl_marks { + pos reh-ar fatha-ar <80 0 80 0>; + } kern_rtl_marks; + + feature kern { + lookup kern_rtl; + lookup kern_rtl_marks; + } kern; + + ''' +# --- +# name: test_kern_hira_kana_hrkt + ''' + lookup kern_ltr { + lookupflag IgnoreMarks; + pos a-hira a-hira 1; + pos a-hira a-kana 2; + pos a-hira period 6; + pos a-kana a-hira 3; + pos a-kana a-kana 4; + pos a-kana period 8; + pos period a-hira 7; + pos period a-kana 9; + pos period period 5; + } kern_ltr; + + feature kern { + lookup kern_ltr; + } kern; + + ''' +# --- +# name: test_kern_split_multi_glyph_class[same] + ''' + @kern1.foo = [a period]; + @kern2.foo = [b period]; + + lookup kern_ltr { + lookupflag IgnoreMarks; + pos a a 1; + pos a b 2; + pos a period 3; + pos b a 4; + pos b b 5; + pos b period 6; + pos period a 7; + pos period b 8; + pos period period 9; + enum pos a @kern2.foo 12; + enum pos period @kern2.foo 13; + enum pos @kern1.foo b 10; + enum pos @kern1.foo period 11; + pos @kern1.foo @kern2.foo 14; + } kern_ltr; + + feature kern { + lookup kern_ltr; + } kern; + + ''' +# --- +# name: test_kern_uniqueness + ''' + @kern1.questiondown = [questiondown]; + @kern2.y = [y]; + + lookup kern_ltr { + lookupflag IgnoreMarks; + pos questiondown y 35; + enum pos questiondown @kern2.y -35; + enum pos @kern1.questiondown y 35; + pos @kern1.questiondown @kern2.y 15; + } kern_ltr; + + feature kern { + lookup kern_ltr; + } kern; + + ''' +# --- +# name: test_kern_zyyy_zinh + ''' + lookup kern_ltr { + lookupflag IgnoreMarks; + pos uni0640 uni0640 0; + pos uni0650 uni0650 1; + pos uni0670 uni0670 2; + pos uni10100 uni10100 30; + pos uni10110 uni10110 31; + pos uni10120 uni10120 32; + pos uni10130 uni10130 33; + pos uni102E0 uni102E0 34; + pos uni102F0 uni102F0 35; + pos uni1BCA0 uni1BCA0 36; + pos uni1CD0 uni1CD0 3; + pos uni1CE0 uni1CE0 4; + pos uni1CF0 uni1CF0 5; + pos uni1D360 uni1D360 37; + pos uni1D370 uni1D370 38; + pos uni1DC0 uni1DC0 6; + pos uni1F250 uni1F250 39; + pos uni20F0 uni20F0 7; + pos uni3010 uni3010 8; + pos uni3030 uni3030 9; + pos uni30A0 uni30A0 10; + pos uni3190 uni3190 11; + pos uni31C0 uni31C0 12; + pos uni31D0 uni31D0 13; + pos uni31E0 uni31E0 14; + pos uni3220 uni3220 15; + pos uni3230 uni3230 16; + pos uni3240 uni3240 17; + pos uni3280 uni3280 18; + pos uni3290 uni3290 19; + pos uni32A0 uni32A0 20; + pos uni32B0 uni32B0 21; + pos uni32C0 uni32C0 22; + pos uni3360 uni3360 23; + pos uni3370 uni3370 24; + pos uni33E0 uni33E0 25; + pos uni33F0 uni33F0 26; + pos uniA700 uniA700 27; + pos uniA830 uniA830 28; + pos uniFF70 uniFF70 29; + } kern_ltr; + + feature kern { + lookup kern_ltr; + } kern; + + ''' +# --- +# name: test_mark_base_kerning + ''' + @kern1.etamil = [aulengthmark-tamil va-tamil]; + @kern2.etamil = [aulengthmark-tamil va-tamil]; + + lookup kern_ltr { + lookupflag IgnoreMarks; + pos aa-tamil va-tamil -20; + pos va-tamil aa-tamil -20; + } kern_ltr; + + lookup kern_ltr_marks { + pos aulengthmark-tamil aulengthmark-tamil -200; + enum pos aa-tamil @kern2.etamil -35; + enum pos @kern1.etamil aa-tamil -35; + pos @kern1.etamil @kern2.etamil -100; + } kern_ltr_marks; + + feature kern { + lookup kern_ltr; + lookup kern_ltr_marks; + } kern; + + ''' +# --- +# name: test_mark_to_base_kern + ''' + lookup kern_ltr { + lookupflag IgnoreMarks; + pos B C -30; + } kern_ltr; + + lookup kern_ltr_marks { + pos A acutecomb -55; + } kern_ltr_marks; + + feature kern { + lookup kern_ltr; + lookup kern_ltr_marks; + } kern; + + ''' +# --- +# name: test_mark_to_base_kern.1 + ''' + lookup kern_ltr { + pos A acutecomb -55; + pos B C -30; + } kern_ltr; + + feature kern { + lookup kern_ltr; + } kern; + + ''' +# --- +# name: test_mark_to_base_only + ''' + lookup kern_ltr_marks { + pos A acutecomb -55; + } kern_ltr_marks; + + feature kern { + lookup kern_ltr_marks; + } kern; + + ''' +# --- +# name: test_mode.1 + ''' + feature kern { + pos one four' -50 six; + } kern; + + lookup kern_ltr { + lookupflag IgnoreMarks; + pos seven six 25; + } kern_ltr; + + feature kern { + lookup kern_ltr; + } kern; + + ''' +# --- +# name: test_mode[existing] + ''' + feature kern { + pos one four' -50 six; + } kern; + + ''' +# --- +# name: test_quantize + ''' + lookup kern_ltr { + lookupflag IgnoreMarks; + pos four six -55; + pos one six -25; + } kern_ltr; + + feature kern { + lookup kern_ltr; + } kern; + + ''' +# --- +# name: test_skip_spacing_marks + ''' + lookup kern_ltr { + lookupflag IgnoreMarks; + pos ka-deva ra-deva -250; + pos ra-deva ka-deva -250; + } kern_ltr; + + lookup kern_ltr_marks { + pos highspacingdot-deva ka-deva -200; + pos ka-deva highspacingdot-deva -150; + } kern_ltr_marks; + + feature kern { + lookup kern_ltr; + lookup kern_ltr_marks; + } kern; + + ''' +# --- +# name: test_skip_zero_class_kerns + ''' + @kern1.baz = [E F]; + @kern1.foo = [A B]; + @kern2.bar = [C D]; + @kern2.nul = [G H]; + + lookup kern_ltr { + lookupflag IgnoreMarks; + pos G H -5; + enum pos A @kern2.bar 5; + enum pos @kern1.foo D 15; + pos @kern1.baz @kern2.bar -10; + pos @kern1.foo @kern2.bar 10; + } kern_ltr; + + feature kern { + lookup kern_ltr; + } kern; + + ''' +# --- diff --git a/tests/featureWriters/kernFeatureWriter2_test.py b/tests/featureWriters/kernFeatureWriter2_test.py index 6230126cb..61c3a52b9 100644 --- a/tests/featureWriters/kernFeatureWriter2_test.py +++ b/tests/featureWriters/kernFeatureWriter2_test.py @@ -1,21 +1,57 @@ import logging -from textwrap import dedent +import fontTools.feaLib.ast as fea_ast import pytest +from fontTools import unicodedata +from syrupy.extensions.amber import AmberSnapshotExtension +from syrupy.location import PyTestLocation +from syrupy.types import SnapshotIndex +from ufo2ft.constants import UNICODE_SCRIPT_ALIASES from ufo2ft.errors import InvalidFeaturesData -from ufo2ft.featureCompiler import parseLayoutFeatures -from ufo2ft.featureWriters import ast +from ufo2ft.featureCompiler import FeatureCompiler, parseLayoutFeatures from ufo2ft.featureWriters.kernFeatureWriter2 import KernFeatureWriter +from ufo2ft.util import DFLT_SCRIPTS, unicodeScriptExtensions from . import FeatureWriterTest +class KernFeatureWriterTest(FeatureWriterTest): + FeatureWriter = KernFeatureWriter + + +class SameUfoLibResultsExtension(AmberSnapshotExtension): + """Make tests use the same snapshots when parameterized. + + Instead of having the snapshots of "test_something[defcon]" and + "test_something[ufoLib2]" be duplicates, use the same snapshots for both, + because the UFO library shouldn't make a difference. + """ + + @classmethod + def get_snapshot_name( + cls, *, test_location: "PyTestLocation", index: "SnapshotIndex" + ) -> str: + index_suffix = "" + if isinstance(index, (str,)): + index_suffix = f"[{index}]" + elif index: + index_suffix = f".{index}" + return f"{test_location.methodname}{index_suffix}" + + +@pytest.fixture +def snapshot(snapshot): + return snapshot.use_extension(SameUfoLibResultsExtension) + + def makeUFO(cls, glyphMap, groups=None, kerning=None, features=None): ufo = cls() for name, uni in glyphMap.items(): glyph = ufo.newGlyph(name) - if uni is not None: + if isinstance(uni, (list, tuple)): + glyph.unicodes = uni + elif uni is not None: glyph.unicode = uni if groups is not None: ufo.groups.update(groups) @@ -27,7 +63,9 @@ def makeUFO(cls, glyphMap, groups=None, kerning=None, features=None): def getClassDefs(feaFile): - return [s for s in feaFile.statements if isinstance(s, ast.GlyphClassDefinition)] + return [ + s for s in feaFile.statements if isinstance(s, fea_ast.GlyphClassDefinition) + ] def getGlyphs(classDef): @@ -35,1165 +73,1077 @@ def getGlyphs(classDef): def getLookups(feaFile): - return [s for s in feaFile.statements if isinstance(s, ast.LookupBlock)] + return [s for s in feaFile.statements if isinstance(s, fea_ast.LookupBlock)] def getPairPosRules(lookup): - return [s for s in lookup.statements if isinstance(s, ast.PairPosStatement)] - - -class KernFeatureWriterTest(FeatureWriterTest): - FeatureWriter = KernFeatureWriter - - def test_cleanup_missing_glyphs(self, FontClass): - groups = { - "public.kern1.A": ["A", "Aacute", "Abreve", "Acircumflex"], - "public.kern2.B": ["B", "D", "E", "F"], - "public.kern1.C": ["foobar"], - } - kerning = { - ("public.kern1.A", "public.kern2.B"): 10, - ("public.kern1.A", "baz"): -25, - ("baz", "public.kern2.B"): -20, - ("public.kern1.C", "public.kern2.B"): 20, - } - ufo = FontClass() - exclude = {"Abreve", "D", "foobar"} - for glyphs in groups.values(): - for glyph in glyphs: - if glyph in exclude: - continue - ufo.newGlyph(glyph) - ufo.groups.update(groups) - ufo.kerning.update(kerning) - - writer = KernFeatureWriter() - feaFile = parseLayoutFeatures(ufo) - writer.write(ufo, feaFile) - - classDefs = getClassDefs(feaFile) - assert len(classDefs) == 2 - assert classDefs[0].name == "kern1.A" - assert classDefs[1].name == "kern2.B" - assert getGlyphs(classDefs[0]) == ["A", "Aacute", "Acircumflex"] - assert getGlyphs(classDefs[1]) == ["B", "E", "F"] - - lookups = getLookups(feaFile) - assert len(lookups) == 1 - kern_ltr = lookups[0] - assert kern_ltr.name == "kern_ltr" - rules = getPairPosRules(kern_ltr) - assert len(rules) == 1 - assert str(rules[0]) == "pos @kern1.A @kern2.B 10;" - - def test_ignoreMarks(self, FontClass): - font = FontClass() - for name in ("one", "four", "six"): - font.newGlyph(name) - font.kerning.update({("four", "six"): -55.0, ("one", "six"): -30.0}) - # default is ignoreMarks=True - writer = KernFeatureWriter() - feaFile = ast.FeatureFile() - assert writer.write(font, feaFile) - - assert str(feaFile) == dedent( - """\ - lookup kern_ltr { - lookupflag IgnoreMarks; - pos four six -55; - pos one six -30; - } kern_ltr; - - feature kern { - lookup kern_ltr; - } kern; - """ - ) + return [s for s in lookup.statements if isinstance(s, fea_ast.PairPosStatement)] + + +def test_cleanup_missing_glyphs(FontClass): + groups = { + "public.kern1.A": ["A", "Aacute", "Abreve", "Acircumflex"], + "public.kern2.B": ["B", "D", "E", "F"], + "public.kern1.C": ["foobar"], + } + kerning = { + ("public.kern1.A", "public.kern2.B"): 10, + ("public.kern1.A", "baz"): -25, + ("baz", "public.kern2.B"): -20, + ("public.kern1.C", "public.kern2.B"): 20, + } + ufo = FontClass() + exclude = {"Abreve", "D", "foobar"} + for glyphs in groups.values(): + for glyph in glyphs: + if glyph in exclude: + continue + ufo.newGlyph(glyph) + ufo.groups.update(groups) + ufo.kerning.update(kerning) + + writer = KernFeatureWriter() + feaFile = parseLayoutFeatures(ufo) + writer.write(ufo, feaFile) + + classDefs = getClassDefs(feaFile) + assert len(classDefs) == 2 + assert classDefs[0].name == "kern1.dflt.A" + assert classDefs[1].name == "kern2.dflt.B" + assert getGlyphs(classDefs[0]) == ["A", "Aacute", "Acircumflex"] + assert getGlyphs(classDefs[1]) == ["B", "E", "F"] + + lookups = getLookups(feaFile) + assert len(lookups) == 1 + kern_lookup = lookups[0] + # We have no codepoints defined for these, so they're considered common + assert kern_lookup.name == "kern_dflt" + rules = getPairPosRules(kern_lookup) + assert len(rules) == 1 + assert str(rules[0]) == "pos @kern1.dflt.A @kern2.dflt.B 10;" + + +def test_ignoreMarks(snapshot, FontClass): + font = FontClass() + for name in ("one", "four", "six"): + font.newGlyph(name) + font.kerning.update({("four", "six"): -55.0, ("one", "six"): -30.0}) + # default is ignoreMarks=True + writer = KernFeatureWriter() + feaFile = fea_ast.FeatureFile() + assert writer.write(font, feaFile) + + assert feaFile.asFea() == snapshot + + writer = KernFeatureWriter(ignoreMarks=False) + feaFile = fea_ast.FeatureFile() + assert writer.write(font, feaFile) + + assert feaFile.asFea() == snapshot + + +def test_mark_to_base_kern(snapshot, FontClass): + font = FontClass() + for name in ("A", "B", "C"): + font.newGlyph(name).unicode = ord(name) + font.newGlyph("acutecomb").unicode = 0x0301 + font.kerning.update({("A", "acutecomb"): -55.0, ("B", "C"): -30.0}) + + font.features.text = """\ + @Bases = [A B C]; + @Marks = [acutecomb]; + table GDEF { + GlyphClassDef @Bases, [], @Marks, ; + } GDEF; + """ - writer = KernFeatureWriter(ignoreMarks=False) - feaFile = ast.FeatureFile() - assert writer.write(font, feaFile) + # default is ignoreMarks=True + feaFile = KernFeatureWriterTest.writeFeatures(font) + assert feaFile.asFea() == snapshot - assert str(feaFile) == dedent( - """\ - lookup kern_ltr { - pos four six -55; - pos one six -30; - } kern_ltr; + feaFile = KernFeatureWriterTest.writeFeatures(font, ignoreMarks=False) + assert feaFile.asFea() == snapshot - feature kern { - lookup kern_ltr; - } kern; - """ - ) - - def test_mark_to_base_kern(self, FontClass): - font = FontClass() - for name in ("A", "B", "C"): - font.newGlyph(name) - font.newGlyph("acutecomb").unicode = 0x0301 - font.kerning.update({("A", "acutecomb"): -55.0, ("B", "C"): -30.0}) - - font.features.text = dedent( - """\ - @Bases = [A B C]; - @Marks = [acutecomb]; - table GDEF { - GlyphClassDef @Bases, [], @Marks, ; - } GDEF; - """ - ) - # default is ignoreMarks=True - feaFile = self.writeFeatures(font) - assert str(feaFile) == dedent( - """\ - lookup kern_ltr { - lookupflag IgnoreMarks; - pos B C -30; - } kern_ltr; - - lookup kern_ltr_marks { - pos A acutecomb -55; - } kern_ltr_marks; - - feature kern { - lookup kern_ltr; - lookup kern_ltr_marks; - } kern; - """ - ) +def test_mark_to_base_only(snapshot, FontClass): + font = FontClass() + for name in ("A", "B", "C"): + font.newGlyph(name) + font.newGlyph("acutecomb").unicode = 0x0301 + font.kerning.update({("A", "acutecomb"): -55.0}) - feaFile = self.writeFeatures(font, ignoreMarks=False) - assert str(feaFile) == dedent( - """\ - lookup kern_ltr { - pos A acutecomb -55; - pos B C -30; - } kern_ltr; - - feature kern { - lookup kern_ltr; - } kern; - """ - ) + font.features.text = """\ + @Bases = [A B C]; + @Marks = [acutecomb]; + table GDEF { + GlyphClassDef @Bases, [], @Marks, ; + } GDEF; + """ - def test_mark_to_base_only(self, FontClass): - font = FontClass() - for name in ("A", "B", "C"): - font.newGlyph(name) - font.newGlyph("acutecomb").unicode = 0x0301 - font.kerning.update({("A", "acutecomb"): -55.0}) - - font.features.text = dedent( - """\ - @Bases = [A B C]; - @Marks = [acutecomb]; - table GDEF { - GlyphClassDef @Bases, [], @Marks, ; - } GDEF; - """ - ) + # default is ignoreMarks=True + feaFile = KernFeatureWriterTest.writeFeatures(font) + assert feaFile.asFea() == snapshot - # default is ignoreMarks=True - feaFile = self.writeFeatures(font) - assert str(feaFile) == dedent( - """\ - lookup kern_ltr_marks { - pos A acutecomb -55; - } kern_ltr_marks; - - feature kern { - lookup kern_ltr_marks; - } kern; - """ - ) - def test_mode(self, FontClass): - ufo = FontClass() - for name in ("one", "four", "six", "seven"): - ufo.newGlyph(name) - existing = dedent( - """\ - feature kern { - pos one four' -50 six; - } kern; - """ - ) - ufo.features.text = existing - ufo.kerning.update({("seven", "six"): 25.0}) - - writer = KernFeatureWriter() # default mode="skip" - feaFile = parseLayoutFeatures(ufo) - assert not writer.write(ufo, feaFile) +def test_mode(snapshot, FontClass): + ufo = FontClass() + for name in ("one", "four", "six", "seven"): + ufo.newGlyph(name) + existing = """\ + feature kern { + pos one four' -50 six; + } kern; + """ + ufo.features.text = existing + ufo.kerning.update({("seven", "six"): 25.0}) - assert str(feaFile) == existing + writer = KernFeatureWriter() # default mode="skip" + feaFile = parseLayoutFeatures(ufo) + assert not writer.write(ufo, feaFile) - # pass optional "append" mode - writer = KernFeatureWriter(mode="append") - feaFile = parseLayoutFeatures(ufo) - assert writer.write(ufo, feaFile) + assert str(feaFile) == snapshot(name="existing") - expected = existing + dedent( - """ - lookup kern_ltr { - lookupflag IgnoreMarks; - pos seven six 25; - } kern_ltr; - - feature kern { - lookup kern_ltr; - } kern; - """ - ) - assert str(feaFile) == expected - - # pass "skip" mode explicitly - writer = KernFeatureWriter(mode="skip") - feaFile = parseLayoutFeatures(ufo) - assert not writer.write(ufo, feaFile) - - assert str(feaFile) == existing - - def test_insert_comment_before(self, FontClass): - ufo = FontClass() - for name in ("one", "four", "six", "seven"): - ufo.newGlyph(name) - existing = dedent( - """\ - feature kern { - # - # Automatic Code - # - pos one four' -50 six; - } kern; - """ - ) - ufo.features.text = existing - ufo.kerning.update({("seven", "six"): 25.0}) - - writer = KernFeatureWriter() - feaFile = parseLayoutFeatures(ufo) - assert writer.write(ufo, feaFile) - - expected = dedent( - """\ - lookup kern_ltr { - lookupflag IgnoreMarks; - pos seven six 25; - } kern_ltr; - - feature kern { - lookup kern_ltr; - } kern; - - feature kern { - # - # - pos one four' -50 six; - } kern; - """ - ) + # pass optional "append" mode + writer = KernFeatureWriter(mode="append") + feaFile = parseLayoutFeatures(ufo) + assert writer.write(ufo, feaFile) - assert str(feaFile).strip() == expected.strip() + assert feaFile.asFea() == snapshot - # test append mode ignores insert marker - generated = self.writeFeatures(ufo, mode="append") - assert str(generated) == dedent( - """\ - lookup kern_ltr { - lookupflag IgnoreMarks; - pos seven six 25; - } kern_ltr; + # pass "skip" mode explicitly + writer = KernFeatureWriter(mode="skip") + feaFile = parseLayoutFeatures(ufo) + assert not writer.write(ufo, feaFile) - feature kern { - lookup kern_ltr; - } kern; - """ - ) + assert feaFile.asFea() == snapshot(name="existing") - def test_insert_comment_before_extended(self, FontClass): - ufo = FontClass() - for name in ("one", "four", "six", "seven"): - ufo.newGlyph(name) - existing = dedent( - """\ - feature kern { - # - # Automatic Code End - # - pos one four' -50 six; - } kern; - """ - ) - ufo.features.text = existing - ufo.kerning.update({("seven", "six"): 25.0}) - - writer = KernFeatureWriter() - feaFile = parseLayoutFeatures(ufo) - assert writer.write(ufo, feaFile) - - expected = dedent( - """\ - lookup kern_ltr { - lookupflag IgnoreMarks; - pos seven six 25; - } kern_ltr; - - feature kern { - lookup kern_ltr; - } kern; - - feature kern { - # - # - pos one four' -50 six; - } kern; - """ - ) - assert str(feaFile).strip() == expected.strip() - - def test_insert_comment_after(self, FontClass): - ufo = FontClass() - for name in ("one", "four", "six", "seven"): - ufo.newGlyph(name) - existing = dedent( - """\ - feature kern { - pos one four' -50 six; - # - # Automatic Code - # - } kern; - """ - ) - ufo.features.text = existing - ufo.kerning.update({("seven", "six"): 25.0}) - - writer = KernFeatureWriter() - feaFile = parseLayoutFeatures(ufo) - assert writer.write(ufo, feaFile) - - expected = dedent( - """\ - feature kern { - pos one four' -50 six; - # - # - } kern; - - lookup kern_ltr { - lookupflag IgnoreMarks; - pos seven six 25; - } kern_ltr; - - feature kern { - lookup kern_ltr; - } kern; - """ - ) +def test_insert_comment_before(snapshot, FontClass): + ufo = FontClass() + for name in ("one", "four", "six", "seven"): + ufo.newGlyph(name) + existing = """\ + feature kern { + # + # Automatic Code + # + pos one four' -50 six; + } kern; + """ + ufo.features.text = existing + ufo.kerning.update({("seven", "six"): 25.0}) - assert str(feaFile) == expected + writer = KernFeatureWriter() + feaFile = parseLayoutFeatures(ufo) + assert writer.write(ufo, feaFile) - # test append mode ignores insert marker - generated = self.writeFeatures(ufo, mode="append") - assert str(generated) == dedent( - """\ - lookup kern_ltr { - lookupflag IgnoreMarks; - pos seven six 25; - } kern_ltr; + assert feaFile.asFea() == snapshot - feature kern { - lookup kern_ltr; - } kern; - """ - ) + # test append mode ignores insert marker + generated = KernFeatureWriterTest.writeFeatures(ufo, mode="append") + assert generated.asFea() == snapshot - def test_insert_comment_middle(self, FontClass): - ufo = FontClass() - for name in ("one", "four", "six", "seven"): - ufo.newGlyph(name) - existing = dedent( - """\ - feature kern { - pos one four' -50 six; - # - # Automatic Code - # - pos one six' -50 six; - } kern; - """ - ) - ufo.features.text = existing - ufo.kerning.update({("seven", "six"): 25.0}) - - writer = KernFeatureWriter() - feaFile = parseLayoutFeatures(ufo) - - with pytest.raises( - InvalidFeaturesData, - match="Insert marker has rules before and after, feature kern " - "cannot be inserted.", - ): - writer.write(ufo, feaFile) - - # test append mode ignores insert marker - generated = self.writeFeatures(ufo, mode="append") - assert str(generated) == dedent( - """\ - lookup kern_ltr { - lookupflag IgnoreMarks; - pos seven six 25; - } kern_ltr; - - feature kern { - lookup kern_ltr; - } kern; - """ - ) - def test_arabic_numerals(self, FontClass): - """Test that arabic numerals (with bidi type AN) are kerned LTR. - https://github.com/googlei18n/ufo2ft/issues/198 - https://github.com/googlei18n/ufo2ft/pull/200 +def test_comment_wrong_case_or_missing(snapshot, FontClass, caplog): + ufo = FontClass() + for name in ("a", "b"): + ufo.newGlyph(name) + ufo.kerning.update({("a", "b"): 25.0}) + ufo.features.text = ( """ - ufo = FontClass() - for name, code in [("four-ar", 0x664), ("seven-ar", 0x667)]: - glyph = ufo.newGlyph(name) - glyph.unicode = code - ufo.kerning.update({("four-ar", "seven-ar"): -30}) - ufo.features.text = dedent( - """ - languagesystem DFLT dflt; - languagesystem arab dflt; - """ + feature kern { + # Automatic code + } kern; + """ + ).strip() + + with caplog.at_level(logging.WARNING): + compiler = FeatureCompiler(ufo, featureWriters=[KernFeatureWriter]) + font = compiler.compile() + + # We mis-cased the insertion marker above, so it's ignored and we end up + # with an empty `kern` block overriding the other kerning in the font + # source and therefore no `GPOS` table. + assert "miscased" in caplog.text + assert "Dropping the former" in caplog.text + assert "GPOS" not in font + + # Append mode ignores insertion markers and so should not log warnings + # and have kerning in the final font. + caplog.clear() + with caplog.at_level(logging.WARNING): + compiler = FeatureCompiler( + ufo, featureWriters=[KernFeatureWriter(mode="append")] ) + font = compiler.compile() + + assert not caplog.text + assert "GPOS" in font + + +def test_insert_comment_before_extended(snapshot, FontClass): + ufo = FontClass() + for name in ("one", "four", "six", "seven"): + ufo.newGlyph(name) + existing = """\ + feature kern { + # + # Automatic Code End + # + pos one four' -50 six; + } kern; + """ + ufo.features.text = existing + ufo.kerning.update({("seven", "six"): 25.0}) + + writer = KernFeatureWriter() + feaFile = parseLayoutFeatures(ufo) + assert writer.write(ufo, feaFile) + + assert feaFile.asFea() == snapshot + + +def test_insert_comment_after(snapshot, FontClass): + ufo = FontClass() + for name in ("one", "four", "six", "seven"): + ufo.newGlyph(name) + existing = """\ + feature kern { + pos one four' -50 six; + # + # Automatic Code + # + } kern; + """ + ufo.features.text = existing + ufo.kerning.update({("seven", "six"): 25.0}) + + writer = KernFeatureWriter() + feaFile = parseLayoutFeatures(ufo) + assert writer.write(ufo, feaFile) + + assert feaFile.asFea() == snapshot + + # test append mode ignores insert marker + generated = KernFeatureWriterTest.writeFeatures(ufo, mode="append") + assert generated.asFea() == snapshot + + +def test_insert_comment_middle(snapshot, FontClass): + ufo = FontClass() + for name in ("one", "four", "six", "seven"): + ufo.newGlyph(name) + existing = """\ + feature kern { + pos one four' -50 six; + # + # Automatic Code + # + pos one six' -50 six; + } kern; + """ + ufo.features.text = existing + ufo.kerning.update({("seven", "six"): 25.0}) - generated = self.writeFeatures(ufo) + writer = KernFeatureWriter() + feaFile = parseLayoutFeatures(ufo) - assert str(generated) == dedent( - """\ - lookup kern_rtl { - lookupflag IgnoreMarks; - pos four-ar seven-ar -30; - } kern_rtl; + with pytest.raises( + InvalidFeaturesData, + match="Insert marker has rules before and after, feature kern " + "cannot be inserted.", + ): + writer.write(ufo, feaFile) - feature kern { - lookup kern_rtl; - } kern; - """ - ) + # test append mode ignores insert marker + generated = KernFeatureWriterTest.writeFeatures(ufo, mode="append") + assert generated.asFea() == snapshot - def test__groupScriptsByTagAndDirection(self, FontClass): - font = FontClass() - font.features.text = dedent( - """ - languagesystem DFLT dflt; - languagesystem latn dflt; - languagesystem latn TRK; - languagesystem arab dflt; - languagesystem arab URD; - languagesystem deva dflt; - languagesystem dev2 dflt; - languagesystem math dflt; - """ - ) - feaFile = parseLayoutFeatures(font) - scripts = ast.getScriptLanguageSystems(feaFile) - scriptGroups = KernFeatureWriter._groupScriptsByTagAndDirection(scripts) - - assert "kern" in scriptGroups - assert list(scriptGroups["kern"]["LTR"]) == [ - ("latn", ["dflt", "TRK "]), - ("math", ["dflt"]), - ] - assert list(scriptGroups["kern"]["RTL"]) == [("arab", ["dflt", "URD "])] - - assert "dist" in scriptGroups - assert list(scriptGroups["dist"]["LTR"]) == [ - ("deva", ["dflt"]), - ("dev2", ["dflt"]), - ] - - def test_getKerningClasses(self, FontClass): - font = FontClass() - for i in range(65, 65 + 6): # A..F - font.newGlyph(chr(i)) - font.groups.update({"public.kern1.A": ["A", "B"], "public.kern2.C": ["C", "D"]}) - # simulate a name clash between pre-existing class definitions in - # feature file, and those generated by the feature writer - font.features.text = "@kern1.A = [E F];" - - feaFile = parseLayoutFeatures(font) - side1Classes, side2Classes = KernFeatureWriter.getKerningClasses(font, feaFile) - - assert "public.kern1.A" in side1Classes - # the new class gets a unique name - assert side1Classes["public.kern1.A"].name == "kern1.A_1" - assert getGlyphs(side1Classes["public.kern1.A"]) == ["A", "B"] - - assert "public.kern2.C" in side2Classes - assert side2Classes["public.kern2.C"].name == "kern2.C" - assert getGlyphs(side2Classes["public.kern2.C"]) == ["C", "D"] - - def test_correct_invalid_class_names(self, FontClass): - font = FontClass() - for i in range(65, 65 + 12): # A..L - font.newGlyph(chr(i)) - font.groups.update( - { - "public.kern1.foo$": ["A", "B", "C"], - "public.kern1.foo@": ["D", "E", "F"], - "@public.kern2.bar": ["G", "H", "I"], - "public.kern2.bar&": ["J", "K", "L"], - } - ) - font.kerning.update( - { - ("public.kern1.foo$", "@public.kern2.bar"): 10, - ("public.kern1.foo@", "public.kern2.bar&"): -10, - } - ) +def test_arabic_numerals(snapshot, FontClass): + """Test that arabic numerals (with bidi type AN) are kerned LTR. - side1Classes, side2Classes = KernFeatureWriter.getKerningClasses(font) - - assert side1Classes["public.kern1.foo$"].name == "kern1.foo" - assert side1Classes["public.kern1.foo@"].name == "kern1.foo_1" - # no valid 'public.kern{1,2}.' prefix, skipped - assert "@public.kern2.bar" not in side2Classes - assert side2Classes["public.kern2.bar&"].name == "kern2.bar" - - def test_getKerningPairs(self, FontClass): - font = FontClass() - for i in range(65, 65 + 8): # A..H - font.newGlyph(chr(i)) - font.groups.update( - { - "public.kern1.foo": ["A", "B"], - "public.kern2.bar": ["C", "D"], - "public.kern1.baz": ["E", "F"], - "public.kern2.nul": ["G", "H"], - } - ) - font.kerning.update( - { - ("public.kern1.foo", "public.kern2.bar"): 10, - ("public.kern1.baz", "public.kern2.bar"): -10, - ("public.kern1.foo", "D"): 15, - ("A", "public.kern2.bar"): 5, - ("G", "H"): -5, - # class-class zero-value pairs are skipped - ("public.kern1.foo", "public.kern2.nul"): 0, - } - ) + See: - s1c, s2c = KernFeatureWriter.getKerningClasses(font) - pairs = KernFeatureWriter.getKerningPairs(font, s1c, s2c) - assert len(pairs) == 5 - - assert "G H -5" in repr(pairs[0]) - assert (pairs[0].firstIsClass, pairs[0].secondIsClass) == (False, False) - assert pairs[0].glyphs == {"G", "H"} - - assert "A @kern2.bar 5" in repr(pairs[1]) - assert (pairs[1].firstIsClass, pairs[1].secondIsClass) == (False, True) - assert pairs[1].glyphs == {"A", "C", "D"} - - assert "@kern1.foo D 15" in repr(pairs[2]) - assert (pairs[2].firstIsClass, pairs[2].secondIsClass) == (True, False) - assert pairs[2].glyphs == {"A", "B", "D"} - - assert "@kern1.baz @kern2.bar -10" in repr(pairs[3]) - assert (pairs[3].firstIsClass, pairs[3].secondIsClass) == (True, True) - assert pairs[3].glyphs == {"C", "D", "E", "F"} - - assert "@kern1.foo @kern2.bar 10" in repr(pairs[4]) - assert (pairs[4].firstIsClass, pairs[4].secondIsClass) == (True, True) - assert pairs[4].glyphs == {"A", "B", "C", "D"} - - def test_kern_LTR_and_RTL(self, FontClass): - glyphs = { - ".notdef": None, - "four": 0x34, - "seven": 0x37, - "A": 0x41, - "V": 0x56, - "Aacute": 0xC1, - "alef-ar": 0x627, - "reh-ar": 0x631, - "zain-ar": 0x632, - "lam-ar": 0x644, - "four-ar": 0x664, - "seven-ar": 0x667, - # # we also add glyphs without unicode codepoint, but linked to - # # an encoded 'character' glyph by some GSUB rule - "alef-ar.isol": None, - "lam-ar.init": None, - "reh-ar.fina": None, - } - groups = { - "public.kern1.A": ["A", "Aacute"], - "public.kern1.reh": ["reh-ar", "zain-ar", "reh-ar.fina"], - "public.kern2.alef": ["alef-ar", "alef-ar.isol"], - } - kerning = { - ("public.kern1.A", "V"): -40, - ("seven", "four"): -25, - ("reh-ar.fina", "lam-ar.init"): -80, - ("public.kern1.reh", "public.kern2.alef"): -100, - ("four-ar", "seven-ar"): -30, - } - features = dedent( - """\ - languagesystem DFLT dflt; - languagesystem latn dflt; - languagesystem latn TRK; - languagesystem arab dflt; - languagesystem arab URD; - - feature init { - script arab; - sub lam-ar by lam-ar.init; - language URD; - } init; - - feature fina { - script arab; - sub reh-ar by reh-ar.fina; - language URD; - } fina; - """ - ) + * https://github.com/googlei18n/ufo2ft/issues/198 + * https://github.com/googlei18n/ufo2ft/pull/200 - ufo = makeUFO(FontClass, glyphs, groups, kerning, features) - - newFeatures = self.writeFeatures(ufo, ignoreMarks=False) - - assert str(newFeatures) == dedent( - """\ - @kern1.A = [A Aacute]; - @kern1.reh = [reh-ar zain-ar reh-ar.fina]; - @kern2.alef = [alef-ar alef-ar.isol]; - - lookup kern_dflt { - pos seven four -25; - } kern_dflt; - - lookup kern_ltr { - enum pos @kern1.A V -40; - } kern_ltr; - - lookup kern_rtl { - pos four-ar seven-ar -30; - pos reh-ar.fina lam-ar.init <-80 0 -80 0>; - pos @kern1.reh @kern2.alef <-100 0 -100 0>; - } kern_rtl; - - feature kern { - lookup kern_dflt; - script latn; - language dflt; - lookup kern_ltr; - language TRK; - script arab; - language dflt; - lookup kern_rtl; - language URD; - } kern; - """ - ) + Additionally, some Arabic numerals are used in more than one script. One + approach is to look at other glyphs with distinct script associations + and consider the font to be supporting those. + """ + ufo = FontClass() + for name, code in [("four-ar", 0x664), ("seven-ar", 0x667)]: + glyph = ufo.newGlyph(name) + glyph.unicode = code + ufo.kerning.update({("four-ar", "seven-ar"): -30}) + + generated = KernFeatureWriterTest.writeFeatures(ufo) + + assert generated.asFea() == snapshot + + ufo.newGlyph("alef-ar").unicode = 0x627 + generated = KernFeatureWriterTest.writeFeatures(ufo) + + assert generated.asFea() == snapshot + + ufo.features.text = """ + languagesystem DFLT dflt; + languagesystem Thaa dflt; + """ + generated = KernFeatureWriterTest.writeFeatures(ufo) + + assert generated.asFea() == snapshot + + del ufo["alef-ar"] + generated = KernFeatureWriterTest.writeFeatures(ufo) + + assert generated.asFea() == snapshot + + +def test_skip_zero_class_kerns(snapshot, FontClass): + glyphs = { + "A": ord("A"), + "B": ord("B"), + "C": ord("C"), + "D": ord("D"), + "E": ord("E"), + "F": ord("F"), + "G": ord("G"), + "H": ord("H"), + } + groups = { + "public.kern1.foo": ["A", "B"], + "public.kern2.bar": ["C", "D"], + "public.kern1.baz": ["E", "F"], + "public.kern2.nul": ["G", "H"], + } + kerning = { + ("public.kern1.foo", "public.kern2.bar"): 10, + ("public.kern1.baz", "public.kern2.bar"): -10, + ("public.kern1.foo", "D"): 15, + ("A", "public.kern2.bar"): 5, + ("G", "H"): -5, + # class-class zero-value pairs are skipped + ("public.kern1.foo", "public.kern2.nul"): 0, + } + + ufo = makeUFO(FontClass, glyphs, groups, kerning) + newFeatures = KernFeatureWriterTest.writeFeatures(ufo) + + assert newFeatures.asFea() == snapshot + + +def test_kern_uniqueness(snapshot, FontClass): + glyphs = { + ".notdef": None, + "questiondown": 0xBF, + "y": 0x79, + } + groups = { + "public.kern1.questiondown": ["questiondown"], + "public.kern2.y": ["y"], + } + kerning = { + ("public.kern1.questiondown", "public.kern2.y"): 15, + ("public.kern1.questiondown", "y"): 35, + ("questiondown", "public.kern2.y"): -35, + ("questiondown", "y"): 35, + } + ufo = makeUFO(FontClass, glyphs, groups, kerning) + + newFeatures = KernFeatureWriterTest.writeFeatures(ufo) + + # The final kerning value for questiondown, y is 35 and all variants + # must be present. Ensures the uniqueness filter doesn't filter things + # out. + assert newFeatures.asFea() == snapshot + + +def test_kern_LTR_and_RTL(snapshot, FontClass): + glyphs = { + ".notdef": None, + "four": 0x34, + "seven": 0x37, + "A": 0x41, + "V": 0x56, + "Aacute": 0xC1, + "alef-ar": 0x627, + "reh-ar": 0x631, + "zain-ar": 0x632, + "lam-ar": 0x644, + "four-ar": 0x664, + "seven-ar": 0x667, + # # we also add glyphs without unicode codepoint, but linked to + # # an encoded 'character' glyph by some GSUB rule + "alef-ar.isol": None, + "lam-ar.init": None, + "reh-ar.fina": None, + } + groups = { + "public.kern1.A": ["A", "Aacute"], + "public.kern1.reh": ["reh-ar", "zain-ar", "reh-ar.fina"], + "public.kern2.alef": ["alef-ar", "alef-ar.isol"], + } + kerning = { + ("public.kern1.A", "V"): -40, + ("seven", "four"): -25, + ("reh-ar.fina", "lam-ar.init"): -80, + ("public.kern1.reh", "public.kern2.alef"): -100, + ("four-ar", "seven-ar"): -30, + } + features = """\ + languagesystem DFLT dflt; + languagesystem latn dflt; + languagesystem latn TRK; + languagesystem arab dflt; + languagesystem arab URD; + + feature init { + script arab; + sub lam-ar by lam-ar.init; + language URD; + } init; + + feature fina { + script arab; + sub reh-ar by reh-ar.fina; + language URD; + } fina; + + feature isol { + script arab; + sub alef-ar by alef-ar.isol; + } isol; + """ - def test_kern_LTR_and_RTL_with_marks(self, FontClass): - glyphs = { - ".notdef": None, - "four": 0x34, - "seven": 0x37, - "A": 0x41, - "V": 0x56, - "Aacute": 0xC1, - "acutecomb": 0x301, - "alef-ar": 0x627, - "reh-ar": 0x631, - "zain-ar": 0x632, - "lam-ar": 0x644, - "four-ar": 0x664, - "seven-ar": 0x667, - "fatha-ar": 0x64E, - # # we also add glyphs without unicode codepoint, but linked to - # # an encoded 'character' glyph by some GSUB rule - "alef-ar.isol": None, - "lam-ar.init": None, - "reh-ar.fina": None, - } - groups = { - "public.kern1.A": ["A", "Aacute"], - "public.kern1.reh": ["reh-ar", "zain-ar", "reh-ar.fina"], - "public.kern2.alef": ["alef-ar", "alef-ar.isol"], - } - kerning = { - ("public.kern1.A", "V"): -40, - ("seven", "four"): -25, - ("reh-ar.fina", "lam-ar.init"): -80, - ("public.kern1.reh", "public.kern2.alef"): -100, - ("four-ar", "seven-ar"): -30, - ("V", "acutecomb"): 70, - ("reh-ar", "fatha-ar"): 80, - } - features = dedent( - """\ - languagesystem DFLT dflt; - languagesystem latn dflt; - languagesystem latn TRK; - languagesystem arab dflt; - languagesystem arab URD; - - feature init { - script arab; - sub lam-ar by lam-ar.init; - language URD; - } init; - - feature fina { - script arab; - sub reh-ar by reh-ar.fina; - language URD; - } fina; - - @Bases = [A V Aacute alef-ar reh-ar zain-ar lam-ar - alef-ar.isol lam-ar.init reh-ar.fina]; - @Marks = [acutecomb fatha-ar]; - table GDEF { - GlyphClassDef @Bases, [], @Marks, ; - } GDEF; - """ - ) + ufo = makeUFO(FontClass, glyphs, groups, kerning, features) + + newFeatures = KernFeatureWriterTest.writeFeatures(ufo, ignoreMarks=False) + + assert newFeatures.asFea() == snapshot + + +def test_kern_LTR_and_RTL_with_marks(snapshot, FontClass): + glyphs = { + ".notdef": None, + "four": 0x34, + "seven": 0x37, + "A": 0x41, + "V": 0x56, + "Aacute": 0xC1, + "acutecomb": 0x301, + "alef-ar": 0x627, + "reh-ar": 0x631, + "zain-ar": 0x632, + "lam-ar": 0x644, + "four-ar": 0x664, + "seven-ar": 0x667, + "fatha-ar": 0x64E, + # we also add glyphs without unicode codepoint, but linked to + # an encoded 'character' glyph by some GSUB rule + "alef-ar.isol": None, + "lam-ar.init": None, + "reh-ar.fina": None, + } + groups = { + "public.kern1.A": ["A", "Aacute"], + "public.kern1.reh": ["reh-ar", "zain-ar", "reh-ar.fina"], + "public.kern2.alef": ["alef-ar", "alef-ar.isol"], + } + kerning = { + ("public.kern1.A", "V"): -40, + ("seven", "four"): -25, + ("reh-ar.fina", "lam-ar.init"): -80, + ("public.kern1.reh", "public.kern2.alef"): -100, + ("four-ar", "seven-ar"): -30, + ("V", "acutecomb"): 70, + ("reh-ar", "fatha-ar"): 80, + } + features = """\ + languagesystem DFLT dflt; + languagesystem latn dflt; + languagesystem latn TRK; + languagesystem arab dflt; + languagesystem arab URD; + + feature init { + script arab; + sub lam-ar by lam-ar.init; + language URD; + } init; + + feature fina { + script arab; + sub reh-ar by reh-ar.fina; + language URD; + } fina; + + feature isol { + script arab; + sub alef-ar by alef-ar.isol; + } isol; + + @Bases = [A V Aacute alef-ar reh-ar zain-ar lam-ar + alef-ar.isol lam-ar.init reh-ar.fina]; + @Marks = [acutecomb fatha-ar]; + table GDEF { + GlyphClassDef @Bases, [], @Marks, ; + } GDEF; + """ - ufo = makeUFO(FontClass, glyphs, groups, kerning, features) - - newFeatures = self.writeFeatures(ufo) - - assert str(newFeatures) == dedent( - """\ - @kern1.A = [A Aacute]; - @kern1.reh = [reh-ar zain-ar reh-ar.fina]; - @kern2.alef = [alef-ar alef-ar.isol]; - - lookup kern_dflt { - lookupflag IgnoreMarks; - pos seven four -25; - } kern_dflt; - - lookup kern_ltr { - lookupflag IgnoreMarks; - enum pos @kern1.A V -40; - } kern_ltr; - - lookup kern_ltr_marks { - pos V acutecomb 70; - } kern_ltr_marks; - - lookup kern_rtl { - lookupflag IgnoreMarks; - pos four-ar seven-ar -30; - pos reh-ar.fina lam-ar.init <-80 0 -80 0>; - pos @kern1.reh @kern2.alef <-100 0 -100 0>; - } kern_rtl; - - lookup kern_rtl_marks { - pos reh-ar fatha-ar <80 0 80 0>; - } kern_rtl_marks; - - feature kern { - lookup kern_dflt; - script latn; - language dflt; - lookup kern_ltr; - lookup kern_ltr_marks; - language TRK; - script arab; - language dflt; - lookup kern_rtl; - lookup kern_rtl_marks; - language URD; - } kern; - """ - ) + ufo = makeUFO(FontClass, glyphs, groups, kerning, features) + + newFeatures = KernFeatureWriterTest.writeFeatures(ufo) + + assert newFeatures.asFea() == snapshot + + +def test_kern_RTL_with_marks(snapshot, FontClass): + glyphs = { + ".notdef": None, + "alef-ar": 0x627, + "reh-ar": 0x631, + "zain-ar": 0x632, + "lam-ar": 0x644, + "four-ar": 0x664, + "seven-ar": 0x667, + "fatha-ar": 0x64E, + # we also add glyphs without unicode codepoint, but linked to + # an encoded 'character' glyph by some GSUB rule + "alef-ar.isol": None, + "lam-ar.init": None, + "reh-ar.fina": None, + } + groups = { + "public.kern1.reh": ["reh-ar", "zain-ar", "reh-ar.fina"], + "public.kern2.alef": ["alef-ar", "alef-ar.isol"], + } + kerning = { + ("reh-ar.fina", "lam-ar.init"): -80, + ("public.kern1.reh", "public.kern2.alef"): -100, + ("reh-ar", "fatha-ar"): 80, + } + features = """\ + languagesystem arab dflt; + languagesystem arab ARA; + + feature init { + script arab; + sub lam-ar by lam-ar.init; + } init; + + feature fina { + script arab; + sub reh-ar by reh-ar.fina; + } fina; + + feature isol { + script arab; + sub alef-ar by alef-ar.isol; + } isol; + + @Bases = [alef-ar reh-ar zain-ar lam-ar alef-ar.isol lam-ar.init reh-ar.fina]; + @Marks = [fatha-ar]; + table GDEF { + GlyphClassDef @Bases, [], @Marks, ; + } GDEF; + """ - def test_kern_RTL_with_marks(self, FontClass): - glyphs = { - ".notdef": None, - "alef-ar": 0x627, - "reh-ar": 0x631, - "zain-ar": 0x632, - "lam-ar": 0x644, - "four-ar": 0x664, - "seven-ar": 0x667, - "fatha-ar": 0x64E, - # # we also add glyphs without unicode codepoint, but linked to - # # an encoded 'character' glyph by some GSUB rule - "alef-ar.isol": None, - "lam-ar.init": None, - "reh-ar.fina": None, - } - groups = { - "public.kern1.reh": ["reh-ar", "zain-ar", "reh-ar.fina"], - "public.kern2.alef": ["alef-ar", "alef-ar.isol"], - } - kerning = { - ("reh-ar.fina", "lam-ar.init"): -80, - ("public.kern1.reh", "public.kern2.alef"): -100, - ("reh-ar", "fatha-ar"): 80, - } - features = dedent( - """\ - languagesystem arab dflt; - languagesystem arab ARA; - - feature init { - script arab; - sub lam-ar by lam-ar.init; - } init; - - feature fina { - script arab; - sub reh-ar by reh-ar.fina; - } fina; - - @Bases = [alef-ar reh-ar zain-ar lam-ar alef-ar.isol lam-ar.init reh-ar.fina]; - @Marks = [fatha-ar]; - table GDEF { - GlyphClassDef @Bases, [], @Marks, ; - } GDEF; - """ - ) + ufo = makeUFO(FontClass, glyphs, groups, kerning, features) - ufo = makeUFO(FontClass, glyphs, groups, kerning, features) + newFeatures = KernFeatureWriterTest.writeFeatures(ufo) + assert newFeatures.asFea() == snapshot - newFeatures = self.writeFeatures(ufo) - assert str(newFeatures) == dedent( - """\ - @kern1.reh = [reh-ar zain-ar reh-ar.fina]; - @kern2.alef = [alef-ar alef-ar.isol]; +def test_kern_independent_of_languagesystem(snapshot, FontClass): + glyphs = {"A": 0x41, "V": 0x56, "reh-ar": 0x631, "alef-ar": 0x627} + kerning = {("A", "V"): -40, ("reh-ar", "alef-ar"): -100} + # No languagesystems declared. + ufo = makeUFO(FontClass, glyphs, kerning=kerning) + generated = KernFeatureWriterTest.writeFeatures(ufo) - lookup kern_rtl { - lookupflag IgnoreMarks; - pos reh-ar.fina lam-ar.init <-80 0 -80 0>; - pos @kern1.reh @kern2.alef <-100 0 -100 0>; - } kern_rtl; + assert generated.asFea() == snapshot(name="same") - lookup kern_rtl_marks { - pos reh-ar fatha-ar <80 0 80 0>; - } kern_rtl_marks; + features = "languagesystem arab dflt;" + ufo = makeUFO(FontClass, glyphs, kerning=kerning, features=features) + generated = KernFeatureWriterTest.writeFeatures(ufo) - feature kern { - lookup kern_rtl; - lookup kern_rtl_marks; - } kern; - """ - ) + assert generated.asFea() == snapshot(name="same") - def test_kern_LTR_and_RTL_one_uses_DFLT(self, FontClass): - glyphs = {"A": 0x41, "V": 0x56, "reh-ar": 0x631, "alef-ar": 0x627} - kerning = {("A", "V"): -40, ("reh-ar", "alef-ar"): -100} - features = "languagesystem latn dflt;" - ufo = makeUFO(FontClass, glyphs, kerning=kerning, features=features) - generated = self.writeFeatures(ufo) - - assert str(generated) == dedent( - """\ - lookup kern_ltr { - lookupflag IgnoreMarks; - pos A V -40; - } kern_ltr; - - lookup kern_rtl { - lookupflag IgnoreMarks; - pos reh-ar alef-ar <-100 0 -100 0>; - } kern_rtl; - - feature kern { - script DFLT; - language dflt; - lookup kern_rtl; - script latn; - language dflt; - lookup kern_ltr; - } kern; - """ - ) - features = dedent("languagesystem arab dflt;") - ufo = makeUFO(FontClass, glyphs, kerning=kerning, features=features) - generated = self.writeFeatures(ufo) - - assert str(generated) == dedent( - """\ - lookup kern_ltr { - lookupflag IgnoreMarks; - pos A V -40; - } kern_ltr; - - lookup kern_rtl { - lookupflag IgnoreMarks; - pos reh-ar alef-ar <-100 0 -100 0>; - } kern_rtl; - - feature kern { - script DFLT; - language dflt; - lookup kern_ltr; - script arab; - language dflt; - lookup kern_rtl; - } kern; - """ - ) +def test_dist_LTR(snapshot, FontClass): + glyphs = {"aaMatra_kannada": 0x0CBE, "ailength_kannada": 0xCD6} + groups = { + "public.kern1.KND_aaMatra_R": ["aaMatra_kannada"], + "public.kern2.KND_ailength_L": ["aaMatra_kannada"], + } + kerning = {("public.kern1.KND_aaMatra_R", "public.kern2.KND_ailength_L"): 34} + features = """\ + languagesystem DFLT dflt; + languagesystem latn dflt; + languagesystem knda dflt; + languagesystem knd2 dflt; + """ - def test_kern_LTR_and_RTL_cannot_use_DFLT(self, FontClass): - glyphs = {"A": 0x41, "V": 0x56, "reh-ar": 0x631, "alef-ar": 0x627} - kerning = {("A", "V"): -40, ("reh-ar", "alef-ar"): -100} - ufo = makeUFO(FontClass, glyphs, kerning=kerning) - with pytest.raises(ValueError, match="cannot use DFLT script"): - self.writeFeatures(ufo) - - def test_dist_LTR(self, FontClass): - glyphs = {"aaMatra_kannada": 0x0CBE, "ailength_kannada": 0xCD6} - groups = { - "public.kern1.KND_aaMatra_R": ["aaMatra_kannada"], - "public.kern2.KND_ailength_L": ["aaMatra_kannada"], - } - kerning = {("public.kern1.KND_aaMatra_R", "public.kern2.KND_ailength_L"): 34} - features = dedent( - """\ - languagesystem DFLT dflt; - languagesystem latn dflt; - languagesystem knda dflt; - languagesystem knd2 dflt; - """ - ) + ufo = makeUFO(FontClass, glyphs, groups, kerning, features) + generated = KernFeatureWriterTest.writeFeatures(ufo) - ufo = makeUFO(FontClass, glyphs, groups, kerning, features) - generated = self.writeFeatures(ufo) - - assert str(generated) == dedent( - """\ - @kern1.KND_aaMatra_R = [aaMatra_kannada]; - @kern2.KND_ailength_L = [aaMatra_kannada]; - - lookup kern_ltr { - lookupflag IgnoreMarks; - pos @kern1.KND_aaMatra_R @kern2.KND_ailength_L 34; - } kern_ltr; - - feature kern { - script DFLT; - language dflt; - lookup kern_ltr; - script latn; - language dflt; - lookup kern_ltr; - } kern; - - feature dist { - script knda; - language dflt; - lookup kern_ltr; - script knd2; - language dflt; - lookup kern_ltr; - } dist; - """ - ) + assert generated.asFea() == snapshot - def test_dist_RTL(self, FontClass): - glyphs = {"u10A06": 0x10A06, "u10A1E": 0x10A1E} - kerning = {("u10A1E", "u10A06"): 117} - features = dedent( - """\ - languagesystem DFLT dflt; - languagesystem arab dflt; - languagesystem khar dflt; - """ - ) - ufo = makeUFO(FontClass, glyphs, kerning=kerning, features=features) - generated = self.writeFeatures(ufo) - - assert str(generated) == dedent( - """\ - lookup kern_rtl { - lookupflag IgnoreMarks; - pos u10A1E u10A06 <117 0 117 0>; - } kern_rtl; - - feature kern { - script DFLT; - language dflt; - lookup kern_rtl; - script arab; - language dflt; - lookup kern_rtl; - } kern; - - feature dist { - script khar; - language dflt; - lookup kern_rtl; - } dist; - """ - ) - def test_dist_LTR_and_RTL(self, FontClass): - glyphs = { - "aaMatra_kannada": 0x0CBE, - "ailength_kannada": 0xCD6, - "u10A06": 0x10A06, - "u10A1E": 0x10A1E, - } - groups = { - "public.kern1.KND_aaMatra_R": ["aaMatra_kannada"], - "public.kern2.KND_ailength_L": ["aaMatra_kannada"], - } - kerning = { - ("public.kern1.KND_aaMatra_R", "public.kern2.KND_ailength_L"): 34, - ("u10A1E", "u10A06"): 117, - } - features = dedent( - """\ +def test_dist_RTL(snapshot, FontClass): + glyphs = {"u10A06": 0x10A06, "u10A1E": 0x10A1E} + kerning = {("u10A1E", "u10A06"): 117} + features = """\ + languagesystem DFLT dflt; + languagesystem arab dflt; + languagesystem khar dflt; + """ + + ufo = makeUFO(FontClass, glyphs, kerning=kerning, features=features) + generated = KernFeatureWriterTest.writeFeatures(ufo) + + assert generated.asFea() == snapshot + + +def test_dist_LTR_and_RTL(snapshot, FontClass): + glyphs = { + "aaMatra_kannada": 0x0CBE, + "ailength_kannada": 0xCD6, + "u10A06": 0x10A06, + "u10A1E": 0x10A1E, + } + groups = { + "public.kern1.KND_aaMatra_R": ["aaMatra_kannada"], + "public.kern2.KND_ailength_L": ["aaMatra_kannada"], + } + kerning = { + ("public.kern1.KND_aaMatra_R", "public.kern2.KND_ailength_L"): 34, + ("u10A1E", "u10A06"): 117, + } + features = """\ languagesystem DFLT dflt; languagesystem knda dflt; languagesystem knd2 dflt; languagesystem khar dflt; """ - ) - - ufo = makeUFO(FontClass, glyphs, groups, kerning, features) - generated = self.writeFeatures(ufo) - - assert str(generated) == dedent( - """\ - @kern1.KND_aaMatra_R = [aaMatra_kannada]; - @kern2.KND_ailength_L = [aaMatra_kannada]; - - lookup kern_ltr { - lookupflag IgnoreMarks; - pos @kern1.KND_aaMatra_R @kern2.KND_ailength_L 34; - } kern_ltr; - - lookup kern_rtl { - lookupflag IgnoreMarks; - pos u10A1E u10A06 <117 0 117 0>; - } kern_rtl; - - feature dist { - script knda; - language dflt; - lookup kern_ltr; - script knd2; - language dflt; - lookup kern_ltr; - script khar; - language dflt; - lookup kern_rtl; - } dist; - """ - ) - - def test_skip_ambiguous_direction_pair(self, FontClass, caplog): - caplog.set_level(logging.ERROR) - - ufo = FontClass() - ufo.newGlyph("A").unicode = 0x41 - ufo.newGlyph("one").unicode = 0x31 - ufo.newGlyph("yod-hb").unicode = 0x5D9 - ufo.newGlyph("reh-ar").unicode = 0x631 - ufo.newGlyph("one-ar").unicode = 0x661 - ufo.newGlyph("bar").unicodes = [0x73, 0x627] - ufo.kerning.update( - { - ("bar", "bar"): 1, - ("bar", "A"): 2, - ("reh-ar", "A"): 3, - ("reh-ar", "one-ar"): 4, - ("yod-hb", "one"): 5, - } - ) - ufo.features.text = dedent( - """\ - languagesystem DFLT dflt; - languagesystem latn dflt; - languagesystem arab dflt; - """ - ) - - logger = "ufo2ft.featureWriters.kernFeatureWriter2.KernFeatureWriter" - with caplog.at_level(logging.WARNING, logger=logger): - generated = self.writeFeatures(ufo) - - assert not generated - assert len(caplog.records) == 5 - assert "skipped kern pair with ambiguous direction" in caplog.text - - def test_kern_RTL_and_DFLT_numbers(self, FontClass): - glyphs = {"four": 0x34, "seven": 0x37, "bet-hb": 0x5D1, "yod-hb": 0x5D9} - kerning = {("seven", "four"): -25, ("yod-hb", "bet-hb"): -100} - features = dedent( - """\ - languagesystem DFLT dflt; - languagesystem hebr dflt; - """ - ) - - ufo = makeUFO(FontClass, glyphs, kerning=kerning, features=features) - generated = self.writeFeatures(ufo) - - assert str(generated) == dedent( - """\ - lookup kern_dflt { - lookupflag IgnoreMarks; - pos seven four -25; - } kern_dflt; - - lookup kern_rtl { - lookupflag IgnoreMarks; - pos yod-hb bet-hb <-100 0 -100 0>; - } kern_rtl; - - feature kern { - lookup kern_dflt; - lookup kern_rtl; - } kern; - """ - ) - - def test_quantize(self, FontClass): - font = FontClass() - for name in ("one", "four", "six"): - font.newGlyph(name) - font.kerning.update({("four", "six"): -57.0, ("one", "six"): -24.0}) - writer = KernFeatureWriter(quantization=5) - feaFile = ast.FeatureFile() - assert writer.write(font, feaFile) - - assert str(feaFile) == dedent( - """\ - lookup kern_ltr { - lookupflag IgnoreMarks; - pos four six -55; - pos one six -25; - } kern_ltr; - - feature kern { - lookup kern_ltr; - } kern; - """ - ) + ufo = makeUFO(FontClass, glyphs, groups, kerning, features) + generated = KernFeatureWriterTest.writeFeatures(ufo) + + assert generated.asFea() == snapshot + + +def test_ambiguous_direction_pair(snapshot, FontClass, caplog): + """Test that glyphs with ambiguous BiDi classes get split correctly.""" + + glyphs = { + "A": 0x0041, + "one": 0x0031, + "yod-hb": 0x05D9, + "reh-ar": 0x0631, + "one-ar": 0x0661, + "bar": [0x0073, 0x0627], + } + kerning = { + ("bar", "bar"): 1, + ("bar", "A"): 2, + ("reh-ar", "A"): 3, + ("reh-ar", "one-ar"): 4, + ("yod-hb", "one"): 5, + } + features = """\ + languagesystem DFLT dflt; + languagesystem latn dflt; + languagesystem arab dflt; + """ + ufo = makeUFO(FontClass, glyphs, kerning=kerning, features=features) + + with caplog.at_level(logging.INFO): + generated = KernFeatureWriterTest.writeFeatures(ufo) + + assert generated.asFea() == snapshot + assert caplog.messages == [ + "Skipping part of a kerning pair with mixed direction (LeftToRight, RightToLeft)", # noqa: B950 + "Skipping part of a kerning pair with mixed direction (RightToLeft, LeftToRight)", # noqa: B950 + "Skipping part of a kerning pair with conflicting BiDi classes", # noqa: B950 + "Skipping part of a kerning pair with mixed direction (RightToLeft, LeftToRight)", # noqa: B950 + "Skipping part of a kerning pair with mixed direction (RightToLeft, LeftToRight)", # noqa: B950 + "Skipping part of a kerning pair with conflicting BiDi classes", # noqa: B950 + "Skipping part of a kerning pair with conflicting BiDi classes", # noqa: B950 + ] + + +def test_kern_RTL_and_DFLT_numbers(snapshot, FontClass): + glyphs = {"four": 0x34, "seven": 0x37, "bet-hb": 0x5D1, "yod-hb": 0x5D9} + kerning = {("seven", "four"): -25, ("yod-hb", "bet-hb"): -100} + features = """\ + languagesystem DFLT dflt; + languagesystem hebr dflt; + """ -if __name__ == "__main__": - import sys + ufo = makeUFO(FontClass, glyphs, kerning=kerning, features=features) + generated = KernFeatureWriterTest.writeFeatures(ufo) + + assert generated.asFea() == snapshot + + +def test_quantize(snapshot, FontClass): + font = FontClass() + for name in ("one", "four", "six"): + font.newGlyph(name) + font.kerning.update({("four", "six"): -57.0, ("one", "six"): -24.0}) + writer = KernFeatureWriter(quantization=5) + feaFile = fea_ast.FeatureFile() + writer.write(font, feaFile) + + assert feaFile.asFea() == snapshot + + +def test_skip_spacing_marks(snapshot, data_dir, FontClass): + fontPath = data_dir / "SpacingCombiningTest-Regular.ufo" + testufo = FontClass(fontPath) + generated = KernFeatureWriterTest.writeFeatures(testufo) + + assert generated.asFea() == snapshot + + +def test_kern_split_multi_glyph_class(snapshot, FontClass): + """Test that kern pair types are correctly split across directions.""" + + glyphs = { + "a": ord("a"), + "b": ord("b"), + "period": ord("."), + } + groups = { + "public.kern1.foo": ["a", "period"], + "public.kern2.foo": ["b", "period"], + } + kerning = { + # Glyph-to-glyph + ("a", "a"): 1, + ("a", "b"): 2, + ("a", "period"): 3, + ("b", "a"): 4, + ("b", "b"): 5, + ("b", "period"): 6, + ("period", "a"): 7, + ("period", "b"): 8, + ("period", "period"): 9, + # Class-to-glyph + ("public.kern1.foo", "b"): 10, + ("public.kern1.foo", "period"): 11, + # Glyph-to-class + ("a", "public.kern2.foo"): 12, + ("period", "public.kern2.foo"): 13, + # Class-to-class + ("public.kern1.foo", "public.kern2.foo"): 14, + } + + ufo = makeUFO(FontClass, glyphs, groups, kerning) + newFeatures = KernFeatureWriterTest.writeFeatures(ufo) + + assert newFeatures.asFea() == snapshot(name="same") + + # Making a common glyph implicitly have an explicit script assigned (GSUB + # closure) will still keep it in the common section. + features = """ + feature ss01 { + sub a by period; # Make period be both Latn and Zyyy. + } ss01; + """ - sys.exit(pytest.main(sys.argv)) + ufo = makeUFO(FontClass, glyphs, groups, kerning, features) + newFeatures = KernFeatureWriterTest.writeFeatures(ufo) + + assert newFeatures.asFea() == snapshot(name="same") + + +def test_kern_split_and_drop(snapshot, FontClass, caplog): + """Test that mixed directions pairs are pruned and only the compatible parts + are kept.""" + + glyphs = { + "a": ord("a"), + "alpha": ord("α"), + "a-orya": 0x0B05, + "a-cy": 0x0430, + "alef-ar": 0x627, + "period": ord("."), + } + groups = { + "public.kern1.foo": ["a", "alpha", "a-orya"], + "public.kern2.foo": ["a", "alpha", "a-orya"], + "public.kern1.bar": ["a-cy", "alef-ar", "period"], + "public.kern2.bar": ["a-cy", "alef-ar", "period"], + } + kerning = { + ("public.kern1.foo", "public.kern2.bar"): 20, + ("public.kern1.bar", "public.kern2.foo"): 20, + } + + ufo = makeUFO(FontClass, glyphs, groups, kerning) + with caplog.at_level(logging.INFO): + newFeatures = KernFeatureWriterTest.writeFeatures(ufo) + + assert newFeatures.asFea() == snapshot + assert caplog.messages == [ + "Skipping part of a kerning pair <('a', 'a-orya', 'alpha') ('alef-ar',) 20> with mixed direction (LeftToRight, RightToLeft)", # noqa: B950 + "Skipping part of a kerning pair <('alef-ar',) ('a', 'a-orya', 'alpha') 20> with mixed direction (RightToLeft, LeftToRight)", # noqa: B950 + ] + + +def test_kern_split_and_drop_mixed(snapshot, caplog, FontClass): + """Test that mixed directions pairs are dropped. + + And that scripts with no remaining lookups don't crash. + """ + + glyphs = {"V": ord("V"), "W": ord("W"), "gba-nko": 0x07DC} + groups = {"public.kern1.foo": ["V", "W"], "public.kern2.foo": ["gba-nko", "W"]} + kerning = {("public.kern1.foo", "public.kern2.foo"): -20} + ufo = makeUFO(FontClass, glyphs, groups, kerning) + with caplog.at_level(logging.INFO): + newFeatures = KernFeatureWriterTest.writeFeatures(ufo) + + assert newFeatures.asFea() == snapshot + assert ( + "<('V', 'W') ('gba-nko',) -20> with mixed direction (LeftToRight, RightToLeft)" + in caplog.text + ) + + +def test_kern_mixed_bidis(snapshot, caplog, FontClass): + """Test that BiDi types for pairs are respected.""" + + # TODO: Add Adlam numbers (rtl) + glyphs = { + "a": ord("a"), + "comma": ord(","), + "alef-ar": 0x0627, + "comma-ar": 0x060C, + "one-ar": 0x0661, + "one-adlam": 0x1E951, + } + kerning = { + # Undetermined: LTR + ("comma", "comma"): -1, + # LTR + ("a", "a"): 1, + ("a", "comma"): 2, + ("comma", "a"): 3, + # RTL + ("alef-ar", "alef-ar"): 4, + ("alef-ar", "comma-ar"): 5, + ("comma-ar", "alef-ar"): 6, + ("one-adlam", "one-adlam"): 10, + ("one-adlam", "comma-ar"): 11, + ("comma-ar", "one-adlam"): 12, + # Mixed: should be dropped + ("alef-ar", "one-ar"): 7, + ("one-ar", "alef-ar"): 8, + ("one-ar", "one-adlam"): 13, + # LTR despite being an RTL script + ("one-ar", "one-ar"): 9, + } + ufo = makeUFO(FontClass, glyphs, None, kerning) + with caplog.at_level(logging.INFO): + newFeatures = KernFeatureWriterTest.writeFeatures(ufo) + + assert newFeatures.asFea() == snapshot + assert " with conflicting BiDi classes" in caplog.text + assert " with conflicting BiDi classes" in caplog.text + assert " with conflicting BiDi classes" in caplog.text + + +def unicodeScript(codepoint: int) -> str: + """Returns the Unicode script for a codepoint, combining some + scripts into the same bucket. + + This allows lookups to contain more than one script. The most prominent case + is being able to kern Hiragana and Katakana against each other, Unicode + defines "Hrkt" as an alias for both scripts. + + Note: Keep in sync with unicodeScriptExtensions! + """ + script = unicodedata.script(chr(codepoint)) + return UNICODE_SCRIPT_ALIASES.get(script, script) + + +def test_kern_zyyy_zinh(snapshot, FontClass): + """Test that a sampling of glyphs with a common or inherited script, but a + disjoint set of explicit script extensions end up in the correct lookups.""" + glyphs = {} + for i in range(0, 0x110000, 0x10): + script = unicodeScript(i) + script_extension = unicodeScriptExtensions(i) + if script not in script_extension: + assert script in DFLT_SCRIPTS + name = f"uni{i:04X}" + glyphs[name] = i + kerning = {(glyph, glyph): i for i, glyph in enumerate(glyphs)} + ufo = makeUFO(FontClass, glyphs, None, kerning) + newFeatures = KernFeatureWriterTest.writeFeatures(ufo) + + assert newFeatures.asFea() == snapshot + + +def test_kern_hira_kana_hrkt(snapshot, FontClass): + """Test that Hiragana and Katakana lands in the same lookup and can be + kerned against each other and common glyphs are kerned just once.""" + glyphs = {"a-hira": 0x3042, "a-kana": 0x30A2, "period": ord(".")} + kerning = { + ("a-hira", "a-hira"): 1, + ("a-hira", "a-kana"): 2, + ("a-kana", "a-hira"): 3, + ("a-kana", "a-kana"): 4, + ("period", "period"): 5, + ("a-hira", "period"): 6, + ("period", "a-hira"): 7, + ("a-kana", "period"): 8, + ("period", "a-kana"): 9, + } + ufo = makeUFO(FontClass, glyphs, None, kerning) + newFeatures = KernFeatureWriterTest.writeFeatures(ufo) + + assert newFeatures.asFea() == snapshot + + +# TODO: Keep? Then modify comments. +def test_defining_classdefs(snapshot, FontClass): + """Check that we aren't redefining class definitions with different + content.""" + + glyphs = { + "halant-telugu": 0xC4D, # Telu + "ka-telugu.below": None, # Telu by substitution + "ka-telugu": 0xC15, # Telu + "rVocalicMatra-telugu": 0xC43, # Telu + "sha-telugu.below": None, # Default + "ss-telugu.alt": None, # Default + "ssa-telugu.alt": None, # Telu by substitution + "ssa-telugu": 0xC37, # Telu + } + groups = { + "public.kern1.sha-telugu.below": ["sha-telugu.below"], + # The following group is a mix of Telu and Default through its gylphs. The + # kerning for bases below will create a Telu and Default split group. + # Important for the NOTE below. + "public.kern1.ssa-telugu.alt": ["ssa-telugu.alt", "ss-telugu.alt"], + "public.kern2.ka-telugu.below": ["ka-telugu.below"], + "public.kern2.rVocalicMatra-telugu": ["rVocalicMatra-telugu"], + } + kerning = { + # The follwoing three pairs are base-to-base pairs: + ("public.kern1.sha-telugu.below", "public.kern2.ka-telugu.below"): 20, + ("public.kern1.ssa-telugu.alt", "public.kern2.ka-telugu.below"): 60, + ("public.kern1.ssa-telugu.alt", "sha-telugu.below"): 150, + # NOTE: This last pair kerns bases against marks, triggering an extra + # pass to make a mark lookup that will create new classDefs. This extra + # pass will work on just this one pair, and kern splitting won't split + # off a Default group from `public.kern1.ssa-telugu.alt`, you get just a + # Telu pair. Unless the writer keeps track of which classDefs it already + # generated, this will overwrite the previous `@kern1.Telu.ssatelugu.alt + # = [ssa-telugu.alt]` with `@kern1.Telu.ssatelugu.alt = + # [ss-telugu.alt]`, losing kerning. + ("public.kern1.ssa-telugu.alt", "public.kern2.rVocalicMatra-telugu"): 180, + } + features = """ + feature blwf { + script tel2; + sub halant-telugu ka-telugu by ka-telugu.below; + } blwf; + + feature psts { + script tel2; + sub ssa-telugu' [rVocalicMatra-telugu sha-telugu.below ka-telugu.below] by ssa-telugu.alt; + } psts; + """ # noqa: B950 + ufo = makeUFO(FontClass, glyphs, groups, kerning, features) + ufo.lib["public.openTypeCategories"] = { + "halant-telugu": "mark", + "ka-telugu": "base", + "rVocalicMatra-telugu": "mark", + "ss-telugu.alt": "base", + "ssa-telugu.alt": "base", + "ssa-telugu": "base", + } + + newFeatures = KernFeatureWriterTest.writeFeatures(ufo) + + assert newFeatures.asFea() == snapshot + + +def test_mark_base_kerning(snapshot, FontClass): + """Check that kerning of bases against marks is correctly split into + base-only and mixed-mark-and-base lookups, to preserve the semantics of + kerning exceptions (pairs modifying the effect of other pairs).""" + + glyphs = { + "aa-tamil": 0x0B86, + "va-tamil": 0x0BB5, + "aulengthmark-tamil": 0x0BD7, + } + groups = { + # Each group is a mix of mark and base glyph. + "public.kern1.e-tamil": ["aulengthmark-tamil", "va-tamil"], + "public.kern2.e-tamil": ["aulengthmark-tamil", "va-tamil"], + } + kerning = { + ("aa-tamil", "va-tamil"): -20, + ("aa-tamil", "public.kern2.e-tamil"): -35, + ("va-tamil", "aa-tamil"): -20, + ("public.kern1.e-tamil", "aa-tamil"): -35, + ("aulengthmark-tamil", "aulengthmark-tamil"): -200, + ("public.kern1.e-tamil", "public.kern2.e-tamil"): -100, + } + ufo = makeUFO(FontClass, glyphs, groups, kerning) + ufo.lib["public.openTypeCategories"] = { + "aulengthmark-tamil": "mark", + } + + newFeatures = KernFeatureWriterTest.writeFeatures(ufo) + + assert newFeatures.asFea() == snapshot + + +def test_hyphenated_duplicates(snapshot, FontClass): + """Check that kerning group names are kept separate even if their sanitized + names are the same.""" + + glyphs = {"comma": ord(","), "period": ord(".")} + groups = { + "public.kern1.hy-phen": ["comma"], + "public.kern1.hyp-hen": ["period"], + } + kerning = { + ("public.kern1.hy-phen", "comma"): 1, + ("public.kern1.hyp-hen", "period"): 2, + } + ufo = makeUFO(FontClass, glyphs, groups, kerning) + + newFeatures = KernFeatureWriterTest.writeFeatures(ufo) + + assert newFeatures.asFea() == snapshot + + +def test_dflt_language(snapshot, FontClass): + """Check that languages defined for the special DFLT script are registered + as well.""" + + glyphs = {"a": ord("a"), "comma": ord(",")} + groups = {} + kerning = {("a", "a"): 1, ("comma", "comma"): 2} + features = """ + languagesystem DFLT dflt; + languagesystem DFLT ZND; + languagesystem latn dflt; + languagesystem latn ANG; + """ + ufo = makeUFO(FontClass, glyphs, groups, kerning, features) + + newFeatures = KernFeatureWriterTest.writeFeatures(ufo) + + assert newFeatures.asFea() == snapshot From fb86b1da1b112221bbb01c30ec7bcb846b94baf2 Mon Sep 17 00:00:00 2001 From: Nikolaus Waxweiler Date: Thu, 5 Sep 2024 09:39:50 +0100 Subject: [PATCH 3/3] Change kern writer See #858. --- .../featureWriters/kernFeatureWriter2.py | 1423 +++++++++++------ .../kernFeatureWriter2_test.ambr | 651 ++++++-- .../variableFeatureWriter_test.py | 14 +- 3 files changed, 1487 insertions(+), 601 deletions(-) diff --git a/Lib/ufo2ft/featureWriters/kernFeatureWriter2.py b/Lib/ufo2ft/featureWriters/kernFeatureWriter2.py index de4eb663b..6f7a54a29 100644 --- a/Lib/ufo2ft/featureWriters/kernFeatureWriter2.py +++ b/Lib/ufo2ft/featureWriters/kernFeatureWriter2.py @@ -1,116 +1,179 @@ -"""Old implementation of KernFeatureWriter as of ufo2ft v2.30.0 for backward compat.""" +"""Alternative implementation of KernFeatureWriter. + +This behaves like the primary kern feature writer, with the important difference +of grouping kerning data into lookups by kerning direction, not script, like the +feature writer in ufo2ft v2.30 and older did. + +The original idea for the primary splitter was to generate smaller, easier to +pack lookups for each script exclusively, as cross-script kerning dos not work +in browsers. However, other applications may allow it, e.g. Adobe's InDesign. +Subsequently, it was modified to clump together lookups that cross-reference +each other's scripts, negating the size advantages if you design fonts with +cross-script kerning for designer ease. + +As a special edge case, InDesign's default text shaper does not properly itemize +text runs, meaning it may group different scripts into the same run unless the +user specifically marks some text as being a specific script or language. To +make all kerning reachable in that case, it must be put into a single broad LTR, +RTL or neutral direction lookup instead of finer script clusters. That will make +it work in all cases, including when there is no cross-script kerning to fuse +different lookups together. + +Testing showed that size benefits are clawed back with the use of the HarfBuzz +repacker (during compilation) and GPOS compression (after compilation) at +acceptable speed. +""" from __future__ import annotations +import enum +import itertools +import logging +import sys +from collections import OrderedDict from types import SimpleNamespace -from typing import Mapping +from typing import Any, Iterator, Mapping, cast +import fontTools.feaLib.ast as fea_ast from fontTools import unicodedata from fontTools.designspaceLib import DesignSpaceDocument +from fontTools.feaLib.variableScalar import Location as VariableScalarLocation +from fontTools.feaLib.variableScalar import VariableScalar +from fontTools.ufoLib.kerning import lookupKerningValue +from fontTools.unicodedata import script_horizontal_direction -from ufo2ft.constants import INDIC_SCRIPTS, USE_SCRIPTS from ufo2ft.featureWriters import BaseFeatureWriter, ast -from ufo2ft.featureWriters.kernFeatureWriter import ( - KernFeatureWriter as NewKernFeatureWriter, +from ufo2ft.util import ( + DFLT_SCRIPTS, + classifyGlyphs, + collapse_varscalar, + describe_ufo, + get_userspace_location, + quantize, ) -from ufo2ft.util import classifyGlyphs, quantize, unicodeScriptDirection - -SIDE1_PREFIX = "public.kern1." -SIDE2_PREFIX = "public.kern2." - -# In HarfBuzz the 'dist' feature is automatically enabled for these shapers: -# src/hb-ot-shape-complex-myanmar.cc -# src/hb-ot-shape-complex-use.cc -# src/hb-ot-shape-complex-indic.cc -# src/hb-ot-shape-complex-khmer.cc -# We derived the list of scripts associated to each dist-enabled shaper from -# `hb_ot_shape_complex_categorize` in src/hb-ot-shape-complex-private.hh -DIST_ENABLED_SCRIPTS = set(INDIC_SCRIPTS) | set(["Khmr", "Mymr"]) | set(USE_SCRIPTS) - -RTL_BIDI_TYPES = {"R", "AL"} -LTR_BIDI_TYPES = {"L", "AN", "EN"} +from .kernFeatureWriter import ( + AMBIGUOUS_BIDIS, + DIST_ENABLED_SCRIPTS, + LTR_BIDI_TYPES, + RTL_BIDI_TYPES, + SIDE1_PREFIX, + SIDE2_PREFIX, + KerningPair, + addClassDefinition, + log_redefined_group, + log_regrouped_glyph, +) -def unicodeBidiType(uv): - """Return "R" for characters with RTL direction, or "L" for LTR (whether - 'strong' or 'weak'), or None for neutral direction. - """ - char = chr(uv) - bidiType = unicodedata.bidirectional(char) - if bidiType in RTL_BIDI_TYPES: - return "R" - elif bidiType in LTR_BIDI_TYPES: - return "L" - else: - return None +if sys.version_info < (3, 10): + from typing_extensions import TypeAlias +else: + from typing import TypeAlias +LOGGER = logging.getLogger(__name__) -class KerningPair: - __slots__ = ("side1", "side2", "value", "directions", "bidiTypes") +KerningGroup: TypeAlias = "Mapping[str, tuple[str, ...]]" - def __init__(self, side1, side2, value, directions=None, bidiTypes=None): - if isinstance(side1, str): - self.side1 = ast.GlyphName(side1) - elif isinstance(side1, ast.GlyphClassDefinition): - self.side1 = ast.GlyphClassName(side1) - else: - raise AssertionError(side1) - if isinstance(side2, str): - self.side2 = ast.GlyphName(side2) - elif isinstance(side2, ast.GlyphClassDefinition): - self.side2 = ast.GlyphClassName(side2) - else: - raise AssertionError(side2) +class Direction(enum.Enum): + Neutral = "dflt" + LeftToRight = "ltr" + RightToLeft = "rtl" - self.value = value - self.directions = directions or set() - self.bidiTypes = bidiTypes or set() + def __lt__(self, other: Direction) -> bool: + if not isinstance(other, Direction): + return NotImplemented - @property - def firstIsClass(self): - return isinstance(self.side1, ast.GlyphClassName) + return self.name < other.name - @property - def secondIsClass(self): - return isinstance(self.side2, ast.GlyphClassName) - @property - def glyphs(self): - if self.firstIsClass: - classDef1 = self.side1.glyphclass - glyphs1 = {g.asFea() for g in classDef1.glyphSet()} - else: - glyphs1 = {self.side1.asFea()} - if self.secondIsClass: - classDef2 = self.side2.glyphclass - glyphs2 = {g.asFea() for g in classDef2.glyphSet()} - else: - glyphs2 = {self.side2.asFea()} - return glyphs1 | glyphs2 - - def __repr__(self): - return "<{} {} {} {}{}{}>".format( - self.__class__.__name__, - self.side1, - self.side2, - self.value, - " %r" % self.directions if self.directions else "", - " %r" % self.bidiTypes if self.bidiTypes else "", - ) +class KernContext(SimpleNamespace): + bidiGlyphs: dict[Direction, set[str]] + compiler: Any + default_source: Any + existingFeatures: Any + feaFile: Any + feaLanguagesByTag: dict[str, list[str]] + font: Any + gdefClasses: Any + glyphBidi: dict[str, set[Direction]] + glyphDirection: dict[str, set[Direction]] + glyphSet: OrderedDict[str, Any] + insertComments: Any + isVariable: bool + kerning: Any + knownScripts: set[str] + side1Membership: dict[str, str] + side2Membership: dict[str, str] + todo: Any class KernFeatureWriter(BaseFeatureWriter): """Generates a kerning feature based on groups and rules contained in an UFO's kerning data. - There are currently two possible writing modes: - 2) "skip" (default) will not write anything if the features are already present; - 1) "append" will add additional lookups to an existing feature, if present, - or it will add a new one at the end of all features. - If the `quantization` argument is given in the filter options, the resulting anchors are rounded to the nearest multiple of the quantization value. + + ## Implementation Notes + + The algorithm works like this: + + * Parse GDEF GlyphClassDefinition from UFO features.fea to get the set of + "Mark" glyphs (this will be used later to decide whether to add + ignoreMarks flag to kern lookups containing pairs between base and mark + glyphs). + * Get the ordered glyphset for the font, for filtering kerning groups and + kernings that reference unknown glyphs. + * Determine which scripts the kerning affects (read: "the font most probably + supports"), to know which lookups to generate later: + * First, determine the unambiguous script associations for each + (Unicoded) glyph in the glyphset, as in, glyphs that have a single + entry for their Unicode script extensions property; + * then, parse the `languagesystem` statements in the provided feature + file to add on top. + * Compile a Unicode cmap from the UFO and a GSUB table from the features so + far, so we can determine the bidirectionality class, so we can later + filter out kerning pairs that would mix RTL and LTR glyphs, which will not + occur in applications, and put the pairs into their correct lookup. + Unicode BiDi classes R and AL are considered R. Common characters and + numbers are considered neutral even when their BiDi class says otherwise, + so they'll end up in the common lookup available to all scripts. + * Get the kerning groups from the UFO and filter out glyphs not in the + glyphset and empty groups. Remember which group a glyph is a member of, + for kern1 and kern2, so we can later reconstruct per-direction groups. + * Get the bare kerning pairs from the UFO, filtering out pairs with unknown + groups or glyphs not in the glyphset and (redundant) zero class-to-class + kernings and optionally quantizing kerning values. + * Optionally, split kerning pairs into base (only base against base) and + mark (mixed mark and base) pairs, according to the glyphs' GDEF category, + so that kerning against marks can be accounted for correctly later. + * Go through all kerning pairs and split them up by direction, to put them + in different lookups. In pairs with common glyphs, assume the direction of + the dominant script, in pairs of common glyphs, assume no direction. Pairs + with clashing script directions are dropped. + * Partition the first and second side of a pair by BiDi direction (as + above) and emit only those with the same direction or a strong + direction and neutral one. + * Discard pairs that mix RTL and LTR BiDi types, because they won't show + up in applications due to how Unicode text is split into runs. + * Glyphs will have only one direction assigned to them. * Preserve the + type of the kerning pair, so class-to-class kerning stays that way, + even when there's only one glyph on each side. + * Reconstruct kerning group names for the newly split classes. This is done + for debuggability; it makes no difference for the final font binary. + * This first looks at the neutral lookups and then all others, assigning + new group names are it goes. A class like `@kern1.something = [foo bar + baz]` may be split up into `@kern1.dflt.something = [foo]` and + `@kern1.ltr.something = [bar baz]`. Note: If there is no dedicated + dflt lookup, common glyph classes like `[foo]` might carry the name + `@kern1.ltr.foo` if the class was first encountered while going over + the ltr lookup. + * Make a `kern` (and potentially `dist`) feature block and register the + lookups for each script. Some scripts need to be registered in the `dist` + feature for some shapers to discover them, e.g. Yezi. + * Write the new glyph class definitions and then the lookups and feature + blocks to the feature file. """ tableTag = "GPOS" @@ -118,38 +181,105 @@ class KernFeatureWriter(BaseFeatureWriter): options = dict(ignoreMarks=True, quantization=1) def setContext(self, font, feaFile, compiler=None): - ctx = super().setContext(font, feaFile, compiler=compiler) + ctx: KernContext = cast( + KernContext, super().setContext(font, feaFile, compiler=compiler) + ) + + if hasattr(font, "findDefault"): + ctx.default_source = font.findDefault().font + else: + ctx.default_source = font + + # Unless we use the legacy append mode (which ignores insertion + # markers), if the font (Designspace: default source) contains kerning + # and the feaFile contains `kern` or `dist` feature blocks, but we have + # no insertion markers (or they were misspelt and ignored), warn the + # user that the kerning blocks in the feaFile take precedence and other + # kerning is dropped. + if ( + self.mode == "skip" + and ctx.default_source.kerning + and ctx.existingFeatures & self.features + and not ctx.insertComments + ): + LOGGER.warning( + "%s: font has kerning, but also manually written kerning features " + "without an insertion comment. Dropping the former.", + describe_ufo(ctx.default_source), + ) + + # Remember which languages are defined for which OT tag, as all + # generated kerning needs to be registered for the script's `dflt` + # language, but also all those the designer defined manually. Otherwise, + # setting any language for a script would deactivate kerning. + feaLanguagesByScript = ast.getScriptLanguageSystems(feaFile, excludeDflt=False) + ctx.feaLanguagesByTag = { + otTag: languages + for _, languageSystems in feaLanguagesByScript.items() + for otTag, languages in languageSystems + } + + ctx.glyphSet = self.getOrderedGlyphSet() ctx.gdefClasses = self.getGDEFGlyphClasses() - ctx.kerning = self.getKerningData( - font, self.options, feaFile, self.getOrderedGlyphSet() + ctx.knownScripts = self.guessFontScripts() + + # We need the direction of a glyph (with common characters considered + # neutral or "dflt") to know in which of the three lookups to put the + # pair. + cmap = self.makeUnicodeToGlyphNameMapping() + gsub = self.compileGSUB() + extras = self.extraSubstitutions() + dirGlyphs = classifyGlyphs(unicodeScriptDirection, cmap, gsub, extras) + neutral_glyphs = ( + ctx.glyphSet.keys() + - dirGlyphs.get(Direction.LeftToRight, set()) + - dirGlyphs.get(Direction.RightToLeft, set()) + ) + dirGlyphs[Direction.Neutral] = neutral_glyphs + glyphDirection = {} + for direction, glyphs in dirGlyphs.items(): + for name in glyphs: + glyphDirection.setdefault(name, set()).add(direction) + ctx.glyphDirection = glyphDirection + + # We need the BiDi class of a glyph to reject kerning of RTL glyphs + # against LTR glyphs. + ctx.bidiGlyphs = classifyGlyphs(unicodeBidiType, cmap, gsub, extras) + neutral_glyphs = ( + ctx.glyphSet.keys() + - ctx.bidiGlyphs.get(Direction.LeftToRight, set()) + - ctx.bidiGlyphs.get(Direction.RightToLeft, set()) ) + ctx.bidiGlyphs[Direction.Neutral] = neutral_glyphs + glyphBidi = {} + for direction, glyphs in ctx.bidiGlyphs.items(): + for name in glyphs: + glyphBidi.setdefault(name, set()).add(direction) + ctx.glyphBidi = glyphBidi - feaScripts = ast.getScriptLanguageSystems(feaFile) - ctx.scriptGroups = self._groupScriptsByTagAndDirection(feaScripts) + ctx.kerning = extract_kerning_data(ctx, cast(SimpleNamespace, self.options)) return ctx def shouldContinue(self): - if not self.context.kerning.pairs: + if ( + not self.context.kerning.base_pairs_by_direction + and not self.context.kerning.mark_pairs_by_direction + ): self.log.debug("No kerning data; skipped") return False - if "dist" in self.context.todo and "dist" not in self.context.scriptGroups: - self.log.debug( - "No dist-enabled scripts defined in languagesystem " - "statements; dist feature will not be generated" - ) - self.context.todo.remove("dist") - return super().shouldContinue() def _write(self): - lookups = self._makeKerningLookups() + self.context: KernContext + self.options: SimpleNamespace + lookups = make_kerning_lookups(self.context, self.options) if not lookups: self.log.debug("kerning lookups empty; skipped") return False - features = self._makeFeatureBlocks(lookups) + features = make_feature_blocks(self.context, lookups) if not features: self.log.debug("kerning features empty; skipped") return False @@ -158,15 +288,14 @@ def _write(self): feaFile = self.context.feaFile # first add the glyph class definitions - side1Classes = self.context.kerning.side1Classes - side2Classes = self.context.kerning.side2Classes - newClassDefs = [] - for classes in (side1Classes, side2Classes): - newClassDefs.extend([c for _, c in sorted(classes.items())]) + classDefs = self.context.kerning.classDefs + newClassDefs = [c for _, c in sorted(classDefs.items())] lookupGroups = [] - for _, lookupGroup in sorted(lookups.items()): - lookupGroups.extend(lookupGroup) + for _, lookupGroup in sorted(lookups.items(), key=lambda x: x[0].value): + lookupGroups.extend( + lkp for lkp in lookupGroup.values() if lkp not in lookupGroups + ) self._insert( feaFile=feaFile, @@ -176,410 +305,734 @@ def _write(self): ) return True - @classmethod - def getKerningData(cls, font, options, feaFile=None, glyphSet=None): - side1Classes, side2Classes = cls.getKerningClasses(font, feaFile, glyphSet) - pairs = cls.getKerningPairs(font, side1Classes, side2Classes, glyphSet, options) - return SimpleNamespace( - side1Classes=side1Classes, side2Classes=side2Classes, pairs=pairs - ) - @staticmethod - def getKerningGroups(font, glyphSet=None): - if glyphSet: - allGlyphs = set(glyphSet.keys()) - else: - allGlyphs = set(font.keys()) - side1Groups = {} - side2Groups = {} +def unicodeBidiType(uv: int) -> Direction | None: + """Return Direction.RightToLeft for characters with strong RTL + direction, or Direction.LeftToRight for strong LTR and European and Arabic + numbers, or None for neutral direction. + """ + bidiType = unicodedata.bidirectional(chr(uv)) + if bidiType in RTL_BIDI_TYPES: + return Direction.RightToLeft + elif bidiType in LTR_BIDI_TYPES: + return Direction.LeftToRight + return None + + +def unicodeScriptDirection(uv: int) -> Direction | None: + script = unicodedata.script(chr(uv)) + if script in DFLT_SCRIPTS: + return None + direction = unicodedata.script_horizontal_direction(script, "LTR") + if direction == "LTR": + return Direction.LeftToRight + elif direction == "RTL": + return Direction.RightToLeft + raise ValueError(f"Unknown direction {direction}") + + +def extract_kerning_data(context: KernContext, options: SimpleNamespace) -> Any: + side1Groups, side2Groups = get_kerning_groups(context) + if context.isVariable: + pairs = get_variable_kerning_pairs(context, options, side1Groups, side2Groups) + else: + pairs = get_kerning_pairs(context, options, side1Groups, side2Groups) + + if options.ignoreMarks: + marks = context.gdefClasses.mark + base_pairs, mark_pairs = split_base_and_mark_pairs(pairs, marks) + else: + base_pairs = pairs + mark_pairs = [] + + base_pairs_by_direction = split_kerning(context, base_pairs) + mark_pairs_by_direction = split_kerning(context, mark_pairs) - if isinstance(font, DesignSpaceDocument): - default_font = font.findDefault() - assert default_font is not None - font = default_font.font - assert font is not None + return SimpleNamespace( + base_pairs_by_direction=base_pairs_by_direction, + mark_pairs_by_direction=mark_pairs_by_direction, + side1Classes={}, + side2Classes={}, + classDefs={}, + ) + +def get_kerning_groups(context: KernContext) -> tuple[KerningGroup, KerningGroup]: + allGlyphs = context.glyphSet + + side1Groups: dict[str, tuple[str, ...]] = {} + side1Membership: dict[str, str] = {} + side2Groups: dict[str, tuple[str, ...]] = {} + side2Membership: dict[str, str] = {} + + if isinstance(context.font, DesignSpaceDocument): + fonts = [source.font for source in context.font.sources] + else: + fonts = [context.font] + + for font in fonts: + assert font is not None for name, members in font.groups.items(): # prune non-existent or skipped glyphs - members = [g for g in members if g in allGlyphs] + members = {g for g in members if g in allGlyphs} + # skip empty groups if not members: - # skip empty groups continue # skip groups without UFO3 public.kern{1,2} prefix if name.startswith(SIDE1_PREFIX): - side1Groups[name] = members + name_truncated = name[len(SIDE1_PREFIX) :] + known_members = members.intersection(side1Membership.keys()) + if known_members: + for glyph_name in known_members: + original_name_truncated = side1Membership[glyph_name] + if name_truncated != original_name_truncated: + log_regrouped_glyph( + "first", + name, + original_name_truncated, + font, + glyph_name, + ) + # Skip the whole group definition if there is any + # overlap problem. + continue + group = side1Groups.get(name) + if group is None: + side1Groups[name] = tuple(sorted(members)) + for member in members: + side1Membership[member] = name_truncated + elif set(group) != members: + log_redefined_group("left", name, group, font, members) elif name.startswith(SIDE2_PREFIX): - side2Groups[name] = members - return side1Groups, side2Groups - - @classmethod - def getKerningClasses(cls, font, feaFile=None, glyphSet=None): - side1Groups, side2Groups = cls.getKerningGroups(font, glyphSet) - side1Classes = ast.makeGlyphClassDefinitions( - side1Groups, feaFile, stripPrefix="public." - ) - side2Classes = ast.makeGlyphClassDefinitions( - side2Groups, feaFile, stripPrefix="public." + name_truncated = name[len(SIDE2_PREFIX) :] + known_members = members.intersection(side2Membership.keys()) + if known_members: + for glyph_name in known_members: + original_name_truncated = side2Membership[glyph_name] + if name_truncated != original_name_truncated: + log_regrouped_glyph( + "second", + name, + original_name_truncated, + font, + glyph_name, + ) + # Skip the whole group definition if there is any + # overlap problem. + continue + group = side2Groups.get(name) + if group is None: + side2Groups[name] = tuple(sorted(members)) + for member in members: + side2Membership[member] = name_truncated + elif set(group) != members: + log_redefined_group("right", name, group, font, members) + context.side1Membership = side1Membership + context.side2Membership = side2Membership + return side1Groups, side2Groups + + +def get_kerning_pairs( + context: KernContext, + options: SimpleNamespace, + side1Classes: KerningGroup, + side2Classes: KerningGroup, +) -> list[KerningPair]: + glyphSet = context.glyphSet + font = context.font + kerning: Mapping[tuple[str, str], float] = font.kerning + quantization = options.quantization + + result = [] + for (side1, side2), value in kerning.items(): + firstIsClass, secondIsClass = (side1 in side1Classes, side2 in side2Classes) + # Filter out pairs that reference missing groups or glyphs. + if not firstIsClass and side1 not in glyphSet: + continue + if not secondIsClass and side2 not in glyphSet: + continue + # Ignore zero-valued class kern pairs. They are the most general + # kerns, so they don't override anything else like glyph kerns would + # and zero is the default. + if firstIsClass and secondIsClass and value == 0: + continue + if firstIsClass: + side1 = side1Classes[side1] + if secondIsClass: + side2 = side2Classes[side2] + value = quantize(value, quantization) + result.append(KerningPair(side1, side2, value)) + + return result + + +def get_variable_kerning_pairs( + context: KernContext, + options: SimpleNamespace, + side1Classes: KerningGroup, + side2Classes: KerningGroup, +) -> list[KerningPair]: + designspace: DesignSpaceDocument = context.font + glyphSet = context.glyphSet + quantization = options.quantization + + # Gather utility variables for faster kerning lookups. + # TODO: Do we construct these in code elsewhere? + assert not (set(side1Classes) & set(side2Classes)) + unified_groups = {**side1Classes, **side2Classes} + + glyphToFirstGroup = { + glyph_name: group_name # TODO: Is this overwrite safe? User input is adversarial + for group_name, glyphs in side1Classes.items() + for glyph_name in glyphs + } + glyphToSecondGroup = { + glyph_name: group_name + for group_name, glyphs in side2Classes.items() + for glyph_name in glyphs + } + + # Collate every kerning pair in the designspace, as even UFOs that + # provide no entry for the pair must contribute a value at their + # source's location in the VariableScalar. + # NOTE: This is required as the DS+UFO kerning model and the OpenType + # variation model handle the absence of a kerning value at a + # given location differently: + # - DS+UFO: + # If the missing pair excepts another pair, take its value; + # Otherwise, take a value of 0. + # - OpenType: + # Always interpolate from other locations, ignoring more + # general pairs that this one excepts. + # See discussion: https://github.com/googlefonts/ufo2ft/pull/635 + all_pairs: set[tuple[str, str]] = set() + for source in designspace.sources: + if source.layerName is not None: + continue + assert source.font is not None + all_pairs |= set(source.font.kerning) + + kerning_pairs_in_progress: dict[ + tuple[str | tuple[str, ...], str | tuple[str, ...]], VariableScalar + ] = {} + for source in designspace.sources: + # Skip sparse sources, because they can have no kerning. + if source.layerName is not None: + continue + assert source.font is not None + + location = VariableScalarLocation( + get_userspace_location(designspace, source.location) ) - return side1Classes, side2Classes - - @staticmethod - def getKerningPairs(font, side1Classes, side2Classes, glyphSet=None, options=None): - if isinstance(font, DesignSpaceDocument): - # Reuse the newer kern writers variable kerning extractor. Repack - # some arguments and the return type for this. - side1ClassesRaw: Mapping[str, tuple[str, ...]] = { - group_name: tuple( - glyph - for glyphs in glyph_defs.glyphSet() - for glyph in glyphs.glyphSet() - ) - for group_name, glyph_defs in side1Classes.items() - } - side2ClassesRaw: Mapping[str, tuple[str, ...]] = { - group_name: tuple( - glyph - for glyphs in glyph_defs.glyphSet() - for glyph in glyphs.glyphSet() - ) - for group_name, glyph_defs in side2Classes.items() - } - pairs = NewKernFeatureWriter.getVariableKerningPairs( - font, - side1ClassesRaw, - side2ClassesRaw, - glyphSet or {}, - options or SimpleNamespace(**KernFeatureWriter.options), - ) - pairs.sort() - side1toClass: Mapping[tuple[str, ...], ast.GlyphClassDefinition] = { - tuple( - glyph - for glyphs in glyph_defs.glyphSet() - for glyph in glyphs.glyphSet() - ): glyph_defs - for glyph_defs in side1Classes.values() - } - side2toClass: Mapping[tuple[str, ...], ast.GlyphClassDefinition] = { - tuple( - glyph - for glyphs in glyph_defs.glyphSet() - for glyph in glyphs.glyphSet() - ): glyph_defs - for glyph_defs in side2Classes.values() - } - return [ - KerningPair( - ( - side1toClass[pair.side1] - if isinstance(pair.side1, tuple) - else pair.side1 - ), - ( - side2toClass[pair.side2] - if isinstance(pair.side2, tuple) - else pair.side2 - ), - pair.value, - ) - for pair in pairs - ] - if glyphSet: - allGlyphs = set(glyphSet.keys()) - else: - allGlyphs = set(font.keys()) - kerning = font.kerning + kerning: Mapping[tuple[str, str], float] = source.font.kerning + for pair in all_pairs: + side1, side2 = pair + firstIsClass = side1 in side1Classes + secondIsClass = side2 in side2Classes - pairsByFlags = {} - for side1, side2 in kerning: - # filter out pairs that reference missing groups or glyphs - if side1 not in side1Classes and side1 not in allGlyphs: + # Filter out pairs that reference missing groups or glyphs. + # TODO: Can we do this outside of the loop? We know the pairs already. + if not firstIsClass and side1 not in glyphSet: continue - if side2 not in side2Classes and side2 not in allGlyphs: + if not secondIsClass and side2 not in glyphSet: continue - flags = (side1 in side1Classes, side2 in side2Classes) - pairsByFlags.setdefault(flags, set()).add((side1, side2)) - - result = [] - for flags, pairs in sorted(pairsByFlags.items()): - for side1, side2 in sorted(pairs): - value = kerning[side1, side2] - if all(flags) and value == 0: - # ignore zero-valued class kern pairs - continue - firstIsClass, secondIsClass = flags - if firstIsClass: - side1 = side1Classes[side1] - if secondIsClass: - side2 = side2Classes[side2] - result.append(KerningPair(side1, side2, value)) - return result - - def _intersectPairs(self, attribute, glyphSets): - allKeys = set() - for pair in self.context.kerning.pairs: - for key, glyphs in glyphSets.items(): - if not pair.glyphs.isdisjoint(glyphs): - getattr(pair, attribute).add(key) - allKeys.add(key) - return allKeys - - @staticmethod - def _groupScriptsByTagAndDirection(feaScripts): - # Read scripts/languages defined in feaFile's 'languagesystem' - # statements and group them by the feature tag (kern or dist) - # they are associated with, and the global script's horizontal - # direction (DFLT is excluded) - scriptGroups = {} - for scriptCode, scriptLangSys in feaScripts.items(): - if scriptCode: - direction = unicodedata.script_horizontal_direction(scriptCode, "LTR") - else: - direction = "LTR" - if scriptCode in DIST_ENABLED_SCRIPTS: - tag = "dist" - else: - tag = "kern" - scriptGroups.setdefault(tag, {}).setdefault(direction, []).extend( - scriptLangSys + + # Get the kerning value for this source and quantize, following + # the DS+UFO semantics described above. + value = quantize( + lookupKerningValue( + pair, + kerning, + unified_groups, + glyphToFirstGroup=glyphToFirstGroup, + glyphToSecondGroup=glyphToSecondGroup, + ), + quantization, + ) + + if firstIsClass: + side1 = side1Classes[side1] + if secondIsClass: + side2 = side2Classes[side2] + + # TODO: Can we instantiate these outside of the loop? We know the pairs already. + var_scalar = kerning_pairs_in_progress.setdefault( + (side1, side2), VariableScalar() + ) + # NOTE: Avoid using .add_value because it instantiates a new + # VariableScalarLocation on each call. + var_scalar.values[location] = value + + # We may need to provide a default location value to the variation + # model, find out where that is. + default_source = context.font.findDefault() + default_location = VariableScalarLocation( + get_userspace_location(designspace, default_source.location) + ) + + result = [] + for (side1, side2), value in kerning_pairs_in_progress.items(): + # TODO: Should we interpolate a default value if it's not in the + # sources, rather than inserting a zero? What would varLib do? + if default_location not in value.values: + value.values[default_location] = 0 + value = collapse_varscalar(value) + pair = KerningPair(side1, side2, value) + # Ignore zero-valued class kern pairs. They are the most general + # kerns, so they don't override anything else like glyph kerns would + # and zero is the default. + if pair.firstIsClass and pair.secondIsClass and pair.value == 0: + continue + result.append(pair) + + return result + + +def split_base_and_mark_pairs( + pairs: list[KerningPair], marks: set[str] +) -> tuple[list[KerningPair], list[KerningPair]]: + if not marks: + return list(pairs), [] + + basePairs: list[KerningPair] = [] + markPairs: list[KerningPair] = [] + for pair in pairs: + # Disentangle kerning between bases and marks by splitting a pair + # into a list of base-to-base pairs (basePairs) and a list of + # base-to-mark, mark-to-base and mark-to-mark pairs (markPairs). + # This ensures that "kerning exceptions" (a kerning pair modifying + # the effect of another) work as intended because these related + # pairs end up in the same list together. + side1Bases: tuple[str, ...] | str | None = None + side1Marks: tuple[str, ...] | str | None = None + if pair.firstIsClass: + side1Bases = tuple(glyph for glyph in pair.side1 if glyph not in marks) + side1Marks = tuple(glyph for glyph in pair.side1 if glyph in marks) + elif pair.side1 in marks: + side1Marks = pair.side1 + else: + side1Bases = pair.side1 + + side2Bases: tuple[str, ...] | str | None = None + side2Marks: tuple[str, ...] | str | None = None + if pair.secondIsClass: + side2Bases = tuple(glyph for glyph in pair.side2 if glyph not in marks) + side2Marks = tuple(glyph for glyph in pair.side2 if glyph in marks) + elif pair.side2 in marks: + side2Marks = pair.side2 + else: + side2Bases = pair.side2 + + if side1Bases and side2Bases: # base-to-base + basePairs.append(KerningPair(side1Bases, side2Bases, value=pair.value)) + + if side1Bases and side2Marks: # base-to-mark + markPairs.append(KerningPair(side1Bases, side2Marks, value=pair.value)) + if side1Marks and side2Bases: # mark-to-base + markPairs.append(KerningPair(side1Marks, side2Bases, value=pair.value)) + if side1Marks and side2Marks: # mark-to-mark + markPairs.append(KerningPair(side1Marks, side2Marks, value=pair.value)) + + return basePairs, markPairs + + +def split_kerning( + context: KernContext, + pairs: list[KerningPair], +) -> dict[Direction, list[KerningPair]]: + # Split kerning into per-direction buckets, so we can drop them into their + # own lookups. + glyph_bidi = context.glyphBidi + glyph_direction = context.glyphDirection + kerning_per_direction: dict[Direction, list[KerningPair]] = {} + for pair in pairs: + for direction, split_pair in partition_by_direction( + pair, glyph_bidi, glyph_direction + ): + kerning_per_direction.setdefault(direction, []).append(split_pair) + + for pairs in kerning_per_direction.values(): + pairs.sort() + + return kerning_per_direction + + +def partition_by_direction( + pair: KerningPair, + glyph_bidi: Mapping[str, set[Direction]], + glyph_direction: Mapping[str, set[Direction]], +) -> Iterator[tuple[Direction, KerningPair]]: + """Split a potentially mixed-direction pair into pairs of the same + or compatible direction.""" + + side1Bidis: dict[Direction, set[str]] = {} + side2Bidis: dict[Direction, set[str]] = {} + side1Directions: dict[Direction, set[str]] = {} + side2Directions: dict[Direction, set[str]] = {} + for glyph in pair.firstGlyphs: + bidis = glyph_bidi[glyph] + directions = glyph_direction[glyph] + for bidi in bidis: + side1Bidis.setdefault(bidi, set()).add(glyph) + for direction in directions: + side1Directions.setdefault(direction, set()).add(glyph) + for glyph in pair.secondGlyphs: + bidis = glyph_bidi[glyph] + directions = glyph_direction[glyph] + for bidi in bidis: + side2Bidis.setdefault(bidi, set()).add(glyph) + for direction in directions: + side2Directions.setdefault(direction, set()).add(glyph) + + for side1Direction, side2Direction in itertools.product( + sorted(side1Directions), sorted(side2Directions) + ): + localSide1: str | tuple[str, ...] + localSide2: str | tuple[str, ...] + if pair.firstIsClass: + localSide1 = tuple(sorted(side1Directions[side1Direction])) + else: + assert len(side1Directions[side1Direction]) == 1 + (localSide1,) = side1Directions[side1Direction] + if pair.secondIsClass: + localSide2 = tuple(sorted(side2Directions[side2Direction])) + else: + assert len(side2Directions[side2Direction]) == 1 + (localSide2,) = side2Directions[side2Direction] + + # Skip pairs with clashing directions (e.g. "a" to "alef-ar"). + if side1Direction != side2Direction and not any( + side is Direction.Neutral for side in (side1Direction, side2Direction) + ): + LOGGER.info( + "Skipping part of a kerning pair <%s %s %s> with mixed direction (%s, %s)", + localSide1, + localSide2, + pair.value, + side1Direction.name, + side2Direction.name, + ) + continue + + # Skip pairs with clashing BiDi classes (e.g. "alef-ar" to "one-ar"). + localSide1Bidis = { + bidi + for glyph in side1Directions[side1Direction] + for bidi in glyph_bidi[glyph] + } + localSide2Bidis = { + bidi + for glyph in side2Directions[side2Direction] + for bidi in glyph_bidi[glyph] + } + if localSide1Bidis != localSide2Bidis and not any( + Direction.Neutral in side for side in (localSide1Bidis, localSide2Bidis) + ): + LOGGER.info( + "Skipping part of a kerning pair <%s %s %s> with conflicting BiDi classes", + localSide1, + localSide2, + pair.value, ) - return scriptGroups - - @staticmethod - def _makePairPosRule(pair, rtl=False, quantization=1): - enumerated = pair.firstIsClass ^ pair.secondIsClass - value = quantize(pair.value, quantization) - if rtl and "L" in pair.bidiTypes: - # numbers are always shaped LTR even in RTL scripts - rtl = False - valuerecord = ast.ValueRecord( - xPlacement=value if rtl else None, - yPlacement=0 if rtl else None, - xAdvance=value, - yAdvance=0 if rtl else None, + continue + + dominant_direction = ( + side1Direction if side2Direction is Direction.Neutral else side2Direction + ) + yield (dominant_direction, KerningPair(localSide1, localSide2, pair.value)) + + +def make_kerning_lookups( + context: KernContext, options: SimpleNamespace +) -> dict[Direction, dict[str, fea_ast.LookupBlock]]: + lookups: dict[Direction, dict[str, fea_ast.LookupBlock]] = {} + if context.kerning.base_pairs_by_direction: + make_split_kerning_lookups( + context, options, lookups, context.kerning.base_pairs_by_direction ) - return ast.PairPosStatement( - glyphs1=pair.side1, - valuerecord1=valuerecord, - glyphs2=pair.side2, - valuerecord2=None, - enumerated=enumerated, + if context.kerning.mark_pairs_by_direction: + make_split_kerning_lookups( + context, + options, + lookups, + context.kerning.mark_pairs_by_direction, + ignoreMarks=False, + suffix="_marks", ) + return lookups - def _makeKerningLookup( - self, name, pairs, exclude=None, rtl=False, ignoreMarks=True - ): - assert pairs - rules = [] + +def make_split_kerning_lookups( + context: KernContext, + options: SimpleNamespace, + lookups: dict[Direction, dict[str, fea_ast.LookupBlock]], + kerning_per_direction: dict[Direction, list[KerningPair]], + ignoreMarks: bool = True, + suffix: str = "", +) -> None: + bidiGlyphs = context.bidiGlyphs + side1Classes = context.kerning.side1Classes + side2Classes = context.kerning.side2Classes + + newClassDefs, newSide1Classes, newSide2Classes = make_all_glyph_class_definitions( + kerning_per_direction, context, context.feaFile + ) + # NOTE: Consider duplicate names a bug, even if the classes would carry + # the same glyphs. + assert not context.kerning.classDefs.keys() & newClassDefs.keys() + context.kerning.classDefs.update(newClassDefs) + assert not side1Classes.keys() & newSide1Classes.keys() + side1Classes.update(newSide1Classes) + assert not side2Classes.keys() & newSide2Classes.keys() + side2Classes.update(newSide2Classes) + + for direction, pairs in kerning_per_direction.items(): + lookupName = f"kern_{direction.value}{suffix}" + lookup = make_kerning_lookup( + context, options, lookupName, ignoreMarks=ignoreMarks + ) for pair in pairs: - if exclude is not None and exclude(pair): - self.log.debug("pair excluded from '%s' lookup: %r", name, pair) - continue - rules.append( - self._makePairPosRule( - pair, rtl=rtl, quantization=self.options.quantization - ) + bidiTypes = { + direction + for direction, glyphs in bidiGlyphs.items() + if not set(pair.glyphs).isdisjoint(glyphs) + } + if bidiTypes.issuperset(AMBIGUOUS_BIDIS): + assert None, "this should have been caught by the splitter" + # European and Arabic Numbers are always shaped LTR even in RTL scripts: + pairIsRtl = ( + direction == Direction.RightToLeft + and Direction.LeftToRight not in bidiTypes ) + rule = make_pairpos_rule(pair, side1Classes, side2Classes, pairIsRtl) + lookup.statements.append(rule) + lookups.setdefault(direction, {})[lookupName] = lookup - if rules: - lookup = ast.LookupBlock(name) - if ignoreMarks and self.options.ignoreMarks: - lookup.statements.append(ast.makeLookupFlag("IgnoreMarks")) - lookup.statements.extend(rules) - return lookup - def _makeKerningLookups(self): - cmap = self.makeUnicodeToGlyphNameMapping() - if any(unicodeScriptDirection(uv) == "RTL" for uv in cmap): - # If there are any characters from globally RTL scripts in the - # cmap, we compile a temporary GSUB table to resolve substitutions - # and group glyphs by script horizontal direction and bidirectional - # type. We then mark each kerning pair with these properties when - # any of the glyphs involved in a pair intersects these groups. - gsub = self.compileGSUB() - extras = self.extraSubstitutions() - dirGlyphs = classifyGlyphs(unicodeScriptDirection, cmap, gsub, extras) - directions = self._intersectPairs("directions", dirGlyphs) - shouldSplit = "RTL" in directions - if shouldSplit: - bidiGlyphs = classifyGlyphs(unicodeBidiType, cmap, gsub, extras) - self._intersectPairs("bidiTypes", bidiGlyphs) - else: - shouldSplit = False - - marks = self.context.gdefClasses.mark - lookups = {} - if shouldSplit: - # make one DFLT lookup with script-agnostic characters, and two - # LTR/RTL lookups excluding pairs from the opposite group. - # We drop kerning pairs with ambiguous direction: i.e. those containing - # glyphs from scripts with different overall horizontal direction, or - # glyphs with incompatible bidirectional type (e.g. arabic letters vs - # arabic numerals). - pairs = [] - for pair in self.context.kerning.pairs: - if ("RTL" in pair.directions and "LTR" in pair.directions) or ( - "R" in pair.bidiTypes and "L" in pair.bidiTypes - ): - self.log.warning( - "skipped kern pair with ambiguous direction: %r", pair - ) - continue - pairs.append(pair) - if not pairs: - return lookups - - if self.options.ignoreMarks: - # If there are pairs with a mix of mark/base then the IgnoreMarks - # flag is unnecessary and should not be set - basePairs, markPairs = self._splitBaseAndMarkPairs(pairs, marks) - if basePairs: - self._makeSplitDirectionKernLookups(lookups, basePairs) - if markPairs: - self._makeSplitDirectionKernLookups( - lookups, markPairs, ignoreMarks=False, suffix="_marks" - ) - else: - self._makeSplitDirectionKernLookups(lookups, pairs) - else: - # only make a single (implicitly LTR) lookup including all base/base pairs - # and a single lookup including all base/mark pairs (if any) - pairs = self.context.kerning.pairs - if self.options.ignoreMarks: - basePairs, markPairs = self._splitBaseAndMarkPairs(pairs, marks) - lookups["LTR"] = [] - if basePairs: - lookups["LTR"].append( - self._makeKerningLookup("kern_ltr", basePairs) - ) - if markPairs: - lookups["LTR"].append( - self._makeKerningLookup( - "kern_ltr_marks", markPairs, ignoreMarks=False - ) - ) - else: - lookups["LTR"] = [self._makeKerningLookup("kern_ltr", pairs)] - return lookups - - def _splitBaseAndMarkPairs(self, pairs, marks): - basePairs, markPairs = [], [] +def make_all_glyph_class_definitions( + kerning_per_direction: dict[Direction, list[KerningPair]], + context: KernContext, + feaFile: fea_ast.FeatureFile | None = None, +): + # Note: Refer to the context for existing classDefs and mappings of glyph + # class tuples to feaLib AST to avoid overwriting existing class names, + # because base and mark kerning pairs might be separate passes. + newClassDefs = {} + existingSide1Classes = context.kerning.side1Classes + existingSide2Classes = context.kerning.side2Classes + newSide1Classes = {} + newSide2Classes = {} + side1Membership = context.side1Membership + side2Membership = context.side2Membership + + if feaFile is not None: + classNames = {cdef.name for cdef in ast.iterClassDefinitions(feaFile)} + else: + classNames = set() + classNames.update(context.kerning.classDefs.keys()) + + # Generate common class names first so that common classes are correctly + # named in other lookups. + for direction in ( + Direction.Neutral, + Direction.LeftToRight, + Direction.RightToLeft, + ): + for pair in kerning_per_direction.get(direction, []): + if ( + pair.firstIsClass + and pair.side1 not in existingSide1Classes + and pair.side1 not in newSide1Classes + ): + addClassDefinition( + "kern1", + pair.side1, + newSide1Classes, + side1Membership, + newClassDefs, + classNames, + direction.value, + ) + if ( + pair.secondIsClass + and pair.side2 not in existingSide2Classes + and pair.side2 not in newSide2Classes + ): + addClassDefinition( + "kern2", + pair.side2, + newSide2Classes, + side2Membership, + newClassDefs, + classNames, + direction.value, + ) + + return newClassDefs, newSide1Classes, newSide2Classes + + +def make_kerning_lookup( + context: KernContext, options: SimpleNamespace, name: str, ignoreMarks: bool = True +) -> fea_ast.LookupBlock: + lookup = fea_ast.LookupBlock(name) + if ignoreMarks and options.ignoreMarks: + # We only want to filter the spacing marks + marks = set(context.gdefClasses.mark or []) & set(context.glyphSet.keys()) + + spacing = [] if marks: - for pair in pairs: - if any(glyph in marks for glyph in pair.glyphs): - markPairs.append(pair) - else: - basePairs.append(pair) + spacing = filter_spacing_marks(context, marks) + if not spacing: + # Simple case, there are no spacing ("Spacing Combining") marks, + # do what we've always done. + lookup.statements.append(ast.makeLookupFlag("IgnoreMarks")) else: - basePairs[:] = pairs - return basePairs, markPairs + # We want spacing marks to block kerns. + className = f"MFS_{name}" + filteringClass = ast.makeGlyphClassDefinitions( + {className: spacing}, feaFile=context.feaFile + )[className] + lookup.statements.append(filteringClass) + lookup.statements.append( + ast.makeLookupFlag(markFilteringSet=filteringClass) + ) + return lookup - def _makeSplitDirectionKernLookups( - self, lookups, pairs, ignoreMarks=True, suffix="" - ): - dfltKern = self._makeKerningLookup( - "kern_dflt" + suffix, - pairs, - exclude=(lambda pair: {"LTR", "RTL"}.intersection(pair.directions)), - rtl=False, - ignoreMarks=ignoreMarks, + +def filter_spacing_marks(context: KernContext, marks: set[str]) -> list[str]: + if context.isVariable: + spacing = [] + for mark in marks: + if all( + source.font[mark].width != 0 + for source in context.font.sources + if mark in source.font + ): + spacing.append(mark) + return spacing + + return [mark for mark in marks if context.font[mark].width != 0] + + +def make_pairpos_rule( + pair: KerningPair, side1Classes, side2Classes, rtl: bool = False +) -> fea_ast.PairPosStatement: + enumerated = pair.firstIsClass ^ pair.secondIsClass + valuerecord = fea_ast.ValueRecord( + xPlacement=pair.value if rtl else None, + yPlacement=0 if rtl else None, + xAdvance=pair.value, + yAdvance=0 if rtl else None, + ) + + if pair.firstIsClass: + glyphs1 = fea_ast.GlyphClassName(side1Classes[pair.side1]) + else: + glyphs1 = fea_ast.GlyphName(pair.side1) + if pair.secondIsClass: + glyphs2 = fea_ast.GlyphClassName(side2Classes[pair.side2]) + else: + glyphs2 = fea_ast.GlyphName(pair.side2) + + return fea_ast.PairPosStatement( + glyphs1=glyphs1, + valuerecord1=valuerecord, + glyphs2=glyphs2, + valuerecord2=None, + enumerated=enumerated, + ) + + +def make_feature_blocks( + context: KernContext, lookups: dict[Direction, dict[str, Any]] +) -> Any: + features = {} + if "kern" in context.todo: + kern = fea_ast.FeatureBlock("kern") + register_lookups(context, kern, lookups) + if kern.statements: + features["kern"] = kern + if "dist" in context.todo: + dist = fea_ast.FeatureBlock("dist") + register_lookups(context, dist, lookups) + if dist.statements: + features["dist"] = dist + return features + + +def register_lookups( + context: KernContext, + feature: fea_ast.FeatureBlock, + lookups: dict[Direction, dict[str, fea_ast.LookupBlock]], +) -> None: + # Ensure we have kerning for pure common script runs (e.g. ">1") + isKernBlock = feature.name == "kern" + lookupsNeutral: list[fea_ast.LookupBlock] = [] + if isKernBlock and Direction.Neutral in lookups: + lookupsNeutral.extend( + lkp + for lkp in lookups[Direction.Neutral].values() + if lkp not in lookupsNeutral ) - if dfltKern: - lookups.setdefault("DFLT", []).append(dfltKern) - - ltrKern = self._makeKerningLookup( - "kern_ltr" + suffix, - pairs, - exclude=(lambda pair: not pair.directions or "RTL" in pair.directions), - rtl=False, - ignoreMarks=ignoreMarks, + + # InDesign bugfix: register kerning lookups for all LTR scripts under DFLT + # so that the basic composer, without a language selected, will still kern. + # Register LTR lookups if any, otherwise RTL lookups. + if isKernBlock: + lookupsLTR: list[fea_ast.LookupBlock] = ( + list(lookups[Direction.LeftToRight].values()) + if Direction.LeftToRight in lookups + else [] ) - if ltrKern: - lookups.setdefault("LTR", []).append(ltrKern) - - rtlKern = self._makeKerningLookup( - "kern_rtl" + suffix, - pairs, - exclude=(lambda pair: not pair.directions or "LTR" in pair.directions), - rtl=True, - ignoreMarks=ignoreMarks, + lookupsRTL: list[fea_ast.LookupBlock] = ( + list(lookups[Direction.RightToLeft].values()) + if Direction.RightToLeft in lookups + else [] ) - if rtlKern: - lookups.setdefault("RTL", []).append(rtlKern) - - def _makeFeatureBlocks(self, lookups): - features = {} - if "kern" in self.context.todo: - kern = ast.FeatureBlock("kern") - self._registerKernLookups(kern, lookups) - if kern.statements: - features["kern"] = kern - if "dist" in self.context.todo: - dist = ast.FeatureBlock("dist") - self._registerDistLookups(dist, lookups) - if dist.statements: - features["dist"] = dist - return features - - def _registerKernLookups(self, feature, lookups): - if "DFLT" in lookups: - ast.addLookupReferences(feature, lookups["DFLT"]) - - scriptGroups = self.context.scriptGroups - if "dist" in self.context.todo: - distScripts = scriptGroups["dist"] - else: - distScripts = {} - kernScripts = scriptGroups.get("kern", {}) - ltrScripts = kernScripts.get("LTR", []) - rtlScripts = kernScripts.get("RTL", []) - - ltrLookups = lookups.get("LTR") - rtlLookups = lookups.get("RTL") - if ltrLookups and rtlLookups: - if ltrScripts and rtlScripts: - for script, langs in ltrScripts: - ast.addLookupReferences(feature, ltrLookups, script, langs) - for script, langs in rtlScripts: - ast.addLookupReferences(feature, rtlLookups, script, langs) - elif ltrScripts: - ast.addLookupReferences(feature, rtlLookups, script="DFLT") - for script, langs in ltrScripts: - ast.addLookupReferences(feature, ltrLookups, script, langs) - elif rtlScripts: - ast.addLookupReferences(feature, ltrLookups, script="DFLT") - for script, langs in rtlScripts: - ast.addLookupReferences(feature, rtlLookups, script, langs) - else: - if not (distScripts.get("LTR") and distScripts.get("RTL")): - raise ValueError( - "cannot use DFLT script for both LTR and RTL kern " - "lookups; add 'languagesystems' to features for at " - "least one LTR or RTL script using the kern feature" - ) - elif ltrLookups: - if not (rtlScripts or distScripts): - ast.addLookupReferences(feature, ltrLookups) - else: - ast.addLookupReferences(feature, ltrLookups, script="DFLT") - for script, langs in ltrScripts: - ast.addLookupReferences(feature, ltrLookups, script, langs) - elif rtlLookups: - if not (ltrScripts or distScripts): - ast.addLookupReferences(feature, rtlLookups) - else: - ast.addLookupReferences(feature, rtlLookups, script="DFLT") - for script, langs in rtlScripts: - ast.addLookupReferences(feature, rtlLookups, script, langs) - - def _registerDistLookups(self, feature, lookups): - scripts = self.context.scriptGroups["dist"] - ltrLookups = lookups.get("LTR") - if ltrLookups: - for script, langs in scripts.get("LTR", []): - ast.addLookupReferences(feature, ltrLookups, script, langs) - rtlLookups = lookups.get("RTL") - if rtlLookups: - for script, langs in scripts.get("RTL", []): - ast.addLookupReferences(feature, rtlLookups, script, langs) + lookupsNeutral.extend( + lkp for lkp in (lookupsLTR or lookupsRTL) if lkp not in lookupsNeutral + ) + + if lookupsNeutral: + languages = context.feaLanguagesByTag.get("DFLT", ["dflt"]) + ast.addLookupReferences(feature, lookupsNeutral, "DFLT", languages) + + # Feature blocks use script tags to distinguish what to run for a + # Unicode script. + # + # "Script tags generally correspond to a Unicode script. However, the + # associations between them may not always be one-to-one, and the + # OpenType script tags are not guaranteed to be the same as Unicode + # Script property-value aliases or ISO 15924 script IDs." + # + # E.g. {"latn": "Latn", "telu": "Telu", "tel2": "Telu"} + # + # Skip DFLT script because we always take care of it above for `kern`. + # It never occurs in `dist`. + if isKernBlock: + scriptsToReference: set[str] = context.knownScripts - DIST_ENABLED_SCRIPTS + else: + scriptsToReference = DIST_ENABLED_SCRIPTS.intersection(context.knownScripts) + scriptsToReference -= DFLT_SCRIPTS + for script in sorted(scriptsToReference): + script_direction = script_horizontal_direction(script, "LTR") + for tag in unicodedata.ot_tags_from_script(script): + lookupsForThisScript = {} + if Direction.Neutral in lookups: + lookupsForThisScript.update(lookups[Direction.Neutral]) + if script_direction == "LTR" and Direction.LeftToRight in lookups: + lookupsForThisScript.update(lookups[Direction.LeftToRight]) + if script_direction == "RTL" and Direction.RightToLeft in lookups: + lookupsForThisScript.update(lookups[Direction.RightToLeft]) + if not lookupsForThisScript: + continue + if feature.statements: + feature.statements.append(fea_ast.Comment("")) + # Register the lookups for all languages defined in the feature + # file for the script, otherwise kerning is not applied if any + # language is set at all. + languages = context.feaLanguagesByTag.get(tag, ["dflt"]) + ast.addLookupReferences( + feature, lookupsForThisScript.values(), tag, languages + ) diff --git a/tests/featureWriters/__snapshots__/kernFeatureWriter2_test.ambr b/tests/featureWriters/__snapshots__/kernFeatureWriter2_test.ambr index 73fc632f7..def5d3d7e 100644 --- a/tests/featureWriters/__snapshots__/kernFeatureWriter2_test.ambr +++ b/tests/featureWriters/__snapshots__/kernFeatureWriter2_test.ambr @@ -1,4 +1,36 @@ # serializer version: 1 +# name: test_ambiguous_direction_pair + ''' + lookup kern_ltr { + lookupflag IgnoreMarks; + pos bar bar 1; + } kern_ltr; + + lookup kern_rtl { + lookupflag IgnoreMarks; + pos bar bar 1; + } kern_rtl; + + feature kern { + script DFLT; + language dflt; + lookup kern_ltr; + + script arab; + language dflt; + lookup kern_rtl; + + script hebr; + language dflt; + lookup kern_rtl; + + script latn; + language dflt; + lookup kern_ltr; + } kern; + + ''' +# --- # name: test_arabic_numerals ''' lookup kern_rtl { @@ -7,6 +39,8 @@ } kern_rtl; feature kern { + script DFLT; + language dflt; lookup kern_rtl; } kern; @@ -20,6 +54,12 @@ } kern_rtl; feature kern { + script DFLT; + language dflt; + lookup kern_rtl; + + script arab; + language dflt; lookup kern_rtl; } kern; @@ -33,6 +73,16 @@ } kern_rtl; feature kern { + script DFLT; + language dflt; + lookup kern_rtl; + + script arab; + language dflt; + lookup kern_rtl; + + script thaa; + language dflt; lookup kern_rtl; } kern; @@ -46,6 +96,12 @@ } kern_rtl; feature kern { + script DFLT; + language dflt; + lookup kern_rtl; + + script thaa; + language dflt; lookup kern_rtl; } kern; @@ -53,67 +109,108 @@ # --- # name: test_defining_classdefs ''' - @kern1.shatelugu.below = [sha-telugu.below]; - @kern1.ssatelugu.alt = [ssa-telugu.alt ss-telugu.alt]; - @kern2.katelugu.below = [ka-telugu.below]; - @kern2.rVocalicMatratelugu = [rVocalicMatra-telugu]; + @kern1.dflt.ssatelugu.alt = [ss-telugu.alt]; + @kern1.ltr.shatelugu.below = [sha-telugu.below]; + @kern1.ltr.ssatelugu.alt = [ssa-telugu.alt]; + @kern2.ltr.katelugu.below = [ka-telugu.below]; + @kern2.ltr.rVocalicMatratelugu = [rVocalicMatra-telugu]; + + lookup kern_dflt { + lookupflag IgnoreMarks; + enum pos @kern1.dflt.ssatelugu.alt sha-telugu.below 150; + } kern_dflt; lookup kern_ltr { lookupflag IgnoreMarks; - enum pos @kern1.ssatelugu.alt sha-telugu.below 150; - pos @kern1.shatelugu.below @kern2.katelugu.below 20; - pos @kern1.ssatelugu.alt @kern2.katelugu.below 60; + enum pos @kern1.ltr.ssatelugu.alt sha-telugu.below 150; + pos @kern1.ltr.shatelugu.below @kern2.ltr.katelugu.below 20; + pos @kern1.dflt.ssatelugu.alt @kern2.ltr.katelugu.below 60; + pos @kern1.ltr.ssatelugu.alt @kern2.ltr.katelugu.below 60; } kern_ltr; lookup kern_ltr_marks { - pos @kern1.ssatelugu.alt @kern2.rVocalicMatratelugu 180; + pos @kern1.dflt.ssatelugu.alt @kern2.ltr.rVocalicMatratelugu 180; + pos @kern1.ltr.ssatelugu.alt @kern2.ltr.rVocalicMatratelugu 180; } kern_ltr_marks; feature kern { + script DFLT; + language dflt; + lookup kern_dflt; lookup kern_ltr; lookup kern_ltr_marks; } kern; + feature dist { + script tel2; + language dflt; + lookup kern_dflt; + lookup kern_ltr; + lookup kern_ltr_marks; + + script telu; + language dflt; + lookup kern_dflt; + lookup kern_ltr; + lookup kern_ltr_marks; + } dist; + ''' # --- # name: test_dflt_language ''' + lookup kern_dflt { + lookupflag IgnoreMarks; + pos comma comma 2; + } kern_dflt; + lookup kern_ltr { lookupflag IgnoreMarks; pos a a 1; - pos comma comma 2; } kern_ltr; feature kern { + script DFLT; + language dflt; + lookup kern_dflt; lookup kern_ltr; + language ZND; + + script latn; + language dflt; + lookup kern_dflt; + lookup kern_ltr; + language ANG; } kern; ''' # --- # name: test_dist_LTR ''' - @kern1.KND_aaMatra_R = [aaMatra_kannada]; - @kern2.KND_ailength_L = [aaMatra_kannada]; + @kern1.ltr.KND_aaMatra_R = [aaMatra_kannada]; + @kern2.ltr.KND_ailength_L = [aaMatra_kannada]; lookup kern_ltr { lookupflag IgnoreMarks; - pos @kern1.KND_aaMatra_R @kern2.KND_ailength_L 34; + pos @kern1.ltr.KND_aaMatra_R @kern2.ltr.KND_ailength_L 34; } kern_ltr; feature kern { script DFLT; language dflt; lookup kern_ltr; + script latn; language dflt; lookup kern_ltr; } kern; feature dist { - script knda; + script knd2; language dflt; lookup kern_ltr; - script knd2; + + script knda; language dflt; lookup kern_ltr; } dist; @@ -122,12 +219,12 @@ # --- # name: test_dist_LTR_and_RTL ''' - @kern1.KND_aaMatra_R = [aaMatra_kannada]; - @kern2.KND_ailength_L = [aaMatra_kannada]; + @kern1.ltr.KND_aaMatra_R = [aaMatra_kannada]; + @kern2.ltr.KND_ailength_L = [aaMatra_kannada]; lookup kern_ltr { lookupflag IgnoreMarks; - pos @kern1.KND_aaMatra_R @kern2.KND_ailength_L 34; + pos @kern1.ltr.KND_aaMatra_R @kern2.ltr.KND_ailength_L 34; } kern_ltr; lookup kern_rtl { @@ -135,16 +232,24 @@ pos u10A1E u10A06 <117 0 117 0>; } kern_rtl; - feature dist { - script knda; + feature kern { + script DFLT; language dflt; lookup kern_ltr; + } kern; + + feature dist { + script khar; + language dflt; + lookup kern_rtl; + script knd2; language dflt; lookup kern_ltr; - script khar; + + script knda; language dflt; - lookup kern_rtl; + lookup kern_ltr; } dist; ''' @@ -160,6 +265,7 @@ script DFLT; language dflt; lookup kern_rtl; + script arab; language dflt; lookup kern_rtl; @@ -175,44 +281,50 @@ # --- # name: test_hyphenated_duplicates ''' - @kern1.hyphen = [comma]; - @kern1.hyphen_1 = [period]; + @kern1.dflt.hyphen = [comma]; + @kern1.dflt.hyphen_1 = [period]; - lookup kern_ltr { + lookup kern_dflt { lookupflag IgnoreMarks; - enum pos @kern1.hyphen comma 1; - enum pos @kern1.hyphen_1 period 2; - } kern_ltr; + enum pos @kern1.dflt.hyphen comma 1; + enum pos @kern1.dflt.hyphen_1 period 2; + } kern_dflt; feature kern { - lookup kern_ltr; + script DFLT; + language dflt; + lookup kern_dflt; } kern; ''' # --- # name: test_ignoreMarks ''' - lookup kern_ltr { + lookup kern_dflt { lookupflag IgnoreMarks; pos four six -55; pos one six -30; - } kern_ltr; + } kern_dflt; feature kern { - lookup kern_ltr; + script DFLT; + language dflt; + lookup kern_dflt; } kern; ''' # --- # name: test_ignoreMarks.1 ''' - lookup kern_ltr { + lookup kern_dflt { pos four six -55; pos one six -30; - } kern_ltr; + } kern_dflt; feature kern { - lookup kern_ltr; + script DFLT; + language dflt; + lookup kern_dflt; } kern; ''' @@ -225,39 +337,45 @@ # } kern; - lookup kern_ltr { + lookup kern_dflt { lookupflag IgnoreMarks; pos seven six 25; - } kern_ltr; + } kern_dflt; feature kern { - lookup kern_ltr; + script DFLT; + language dflt; + lookup kern_dflt; } kern; ''' # --- # name: test_insert_comment_after.1 ''' - lookup kern_ltr { + lookup kern_dflt { lookupflag IgnoreMarks; pos seven six 25; - } kern_ltr; + } kern_dflt; feature kern { - lookup kern_ltr; + script DFLT; + language dflt; + lookup kern_dflt; } kern; ''' # --- # name: test_insert_comment_before ''' - lookup kern_ltr { + lookup kern_dflt { lookupflag IgnoreMarks; pos seven six 25; - } kern_ltr; + } kern_dflt; feature kern { - lookup kern_ltr; + script DFLT; + language dflt; + lookup kern_dflt; } kern; feature kern { @@ -270,26 +388,30 @@ # --- # name: test_insert_comment_before.1 ''' - lookup kern_ltr { + lookup kern_dflt { lookupflag IgnoreMarks; pos seven six 25; - } kern_ltr; + } kern_dflt; feature kern { - lookup kern_ltr; + script DFLT; + language dflt; + lookup kern_dflt; } kern; ''' # --- # name: test_insert_comment_before_extended ''' - lookup kern_ltr { + lookup kern_dflt { lookupflag IgnoreMarks; pos seven six 25; - } kern_ltr; + } kern_dflt; feature kern { - lookup kern_ltr; + script DFLT; + language dflt; + lookup kern_dflt; } kern; feature kern { @@ -302,56 +424,65 @@ # --- # name: test_insert_comment_middle ''' - lookup kern_ltr { + lookup kern_dflt { lookupflag IgnoreMarks; pos seven six 25; - } kern_ltr; + } kern_dflt; feature kern { - lookup kern_ltr; + script DFLT; + language dflt; + lookup kern_dflt; } kern; ''' # --- # name: test_kern_LTR_and_RTL ''' - @kern1.A = [A Aacute]; - @kern1.reh = [reh-ar zain-ar reh-ar.fina]; - @kern2.alef = [alef-ar alef-ar.isol]; + @kern1.ltr.A = [A Aacute]; + @kern1.rtl.reh = [reh-ar reh-ar.fina zain-ar]; + @kern2.rtl.alef = [alef-ar alef-ar.isol]; lookup kern_dflt { pos seven four -25; } kern_dflt; lookup kern_ltr { - enum pos @kern1.A V -40; + enum pos @kern1.ltr.A V -40; } kern_ltr; lookup kern_rtl { pos four-ar seven-ar -30; pos reh-ar.fina lam-ar.init <-80 0 -80 0>; - pos @kern1.reh @kern2.alef <-100 0 -100 0>; + pos @kern1.rtl.reh @kern2.rtl.alef <-100 0 -100 0>; } kern_rtl; feature kern { - lookup kern_dflt; - script latn; + script DFLT; language dflt; + lookup kern_dflt; lookup kern_ltr; - language TRK; + script arab; language dflt; + lookup kern_dflt; lookup kern_rtl; language URD; + + script latn; + language dflt; + lookup kern_dflt; + lookup kern_ltr; + language TRK; } kern; ''' # --- # name: test_kern_LTR_and_RTL_with_marks ''' - @kern1.A = [A Aacute]; - @kern1.reh = [reh-ar zain-ar reh-ar.fina]; - @kern2.alef = [alef-ar alef-ar.isol]; + @kern1.ltr.A = [A Aacute]; + @kern1.rtl.reh = [reh-ar reh-ar.fina zain-ar]; + @kern2.rtl.alef = [alef-ar alef-ar.isol]; lookup kern_dflt { lookupflag IgnoreMarks; @@ -360,7 +491,7 @@ lookup kern_ltr { lookupflag IgnoreMarks; - enum pos @kern1.A V -40; + enum pos @kern1.ltr.A V -40; } kern_ltr; lookup kern_ltr_marks { @@ -371,7 +502,7 @@ lookupflag IgnoreMarks; pos four-ar seven-ar -30; pos reh-ar.fina lam-ar.init <-80 0 -80 0>; - pos @kern1.reh @kern2.alef <-100 0 -100 0>; + pos @kern1.rtl.reh @kern2.rtl.alef <-100 0 -100 0>; } kern_rtl; lookup kern_rtl_marks { @@ -379,17 +510,25 @@ } kern_rtl_marks; feature kern { - lookup kern_dflt; - script latn; + script DFLT; language dflt; + lookup kern_dflt; lookup kern_ltr; lookup kern_ltr_marks; - language TRK; + script arab; language dflt; + lookup kern_dflt; lookup kern_rtl; lookup kern_rtl_marks; language URD; + + script latn; + language dflt; + lookup kern_dflt; + lookup kern_ltr; + lookup kern_ltr_marks; + language TRK; } kern; ''' @@ -407,6 +546,13 @@ } kern_rtl; feature kern { + script DFLT; + language dflt; + lookup kern_dflt; + lookup kern_rtl; + + script hebr; + language dflt; lookup kern_dflt; lookup kern_rtl; } kern; @@ -415,13 +561,13 @@ # --- # name: test_kern_RTL_with_marks ''' - @kern1.reh = [reh-ar zain-ar reh-ar.fina]; - @kern2.alef = [alef-ar alef-ar.isol]; + @kern1.rtl.reh = [reh-ar reh-ar.fina zain-ar]; + @kern2.rtl.alef = [alef-ar alef-ar.isol]; lookup kern_rtl { lookupflag IgnoreMarks; pos reh-ar.fina lam-ar.init <-80 0 -80 0>; - pos @kern1.reh @kern2.alef <-100 0 -100 0>; + pos @kern1.rtl.reh @kern2.rtl.alef <-100 0 -100 0>; } kern_rtl; lookup kern_rtl_marks { @@ -429,14 +575,27 @@ } kern_rtl_marks; feature kern { + script DFLT; + language dflt; + lookup kern_rtl; + lookup kern_rtl_marks; + + script arab; + language dflt; lookup kern_rtl; lookup kern_rtl_marks; + language ARA; } kern; ''' # --- # name: test_kern_hira_kana_hrkt ''' + lookup kern_dflt { + lookupflag IgnoreMarks; + pos period period 5; + } kern_dflt; + lookup kern_ltr { lookupflag IgnoreMarks; pos a-hira a-hira 1; @@ -447,10 +606,165 @@ pos a-kana period 8; pos period a-hira 7; pos period a-kana 9; - pos period period 5; } kern_ltr; feature kern { + script DFLT; + language dflt; + lookup kern_dflt; + lookup kern_ltr; + + script kana; + language dflt; + lookup kern_dflt; + lookup kern_ltr; + } kern; + + ''' +# --- +# name: test_kern_independent_of_languagesystem[same] + ''' + lookup kern_ltr { + lookupflag IgnoreMarks; + pos A V -40; + } kern_ltr; + + lookup kern_rtl { + lookupflag IgnoreMarks; + pos reh-ar alef-ar <-100 0 -100 0>; + } kern_rtl; + + feature kern { + script DFLT; + language dflt; + lookup kern_ltr; + + script arab; + language dflt; + lookup kern_rtl; + + script latn; + language dflt; + lookup kern_ltr; + } kern; + + ''' +# --- +# name: test_kern_mixed_bidis + ''' + lookup kern_dflt { + lookupflag IgnoreMarks; + pos comma comma -1; + } kern_dflt; + + lookup kern_ltr { + lookupflag IgnoreMarks; + pos a a 1; + pos a comma 2; + pos comma a 3; + } kern_ltr; + + lookup kern_rtl { + lookupflag IgnoreMarks; + pos alef-ar alef-ar <4 0 4 0>; + pos alef-ar comma-ar <5 0 5 0>; + pos comma-ar alef-ar <6 0 6 0>; + pos comma-ar one-adlam <12 0 12 0>; + pos one-adlam comma-ar <11 0 11 0>; + pos one-adlam one-adlam <10 0 10 0>; + pos one-ar one-ar 9; + } kern_rtl; + + feature kern { + script DFLT; + language dflt; + lookup kern_dflt; + lookup kern_ltr; + + script arab; + language dflt; + lookup kern_dflt; + lookup kern_rtl; + + script latn; + language dflt; + lookup kern_dflt; + lookup kern_ltr; + } kern; + + feature dist { + script adlm; + language dflt; + lookup kern_dflt; + lookup kern_rtl; + } dist; + + ''' +# --- +# name: test_kern_split_and_drop + ''' + @kern1.ltr.bar = [a-cy]; + @kern1.ltr.bar_1 = [period]; + @kern1.ltr.foo = [a a-orya alpha]; + @kern2.ltr.bar = [a-cy]; + @kern2.ltr.bar_1 = [period]; + @kern2.ltr.foo = [a a-orya alpha]; + + lookup kern_ltr { + lookupflag IgnoreMarks; + pos @kern1.ltr.foo @kern2.ltr.bar 20; + pos @kern1.ltr.foo @kern2.ltr.bar_1 20; + pos @kern1.ltr.bar @kern2.ltr.foo 20; + pos @kern1.ltr.bar_1 @kern2.ltr.foo 20; + } kern_ltr; + + feature kern { + script DFLT; + language dflt; + lookup kern_ltr; + + script cyrl; + language dflt; + lookup kern_ltr; + + script grek; + language dflt; + lookup kern_ltr; + + script latn; + language dflt; + lookup kern_ltr; + } kern; + + feature dist { + script ory2; + language dflt; + lookup kern_ltr; + + script orya; + language dflt; + lookup kern_ltr; + } dist; + + ''' +# --- +# name: test_kern_split_and_drop_mixed + ''' + @kern1.ltr.foo = [V W]; + @kern2.ltr.foo = [W]; + + lookup kern_ltr { + lookupflag IgnoreMarks; + pos @kern1.ltr.foo @kern2.ltr.foo -20; + } kern_ltr; + + feature kern { + script DFLT; + language dflt; + lookup kern_ltr; + + script latn; + language dflt; lookup kern_ltr; } kern; @@ -458,8 +772,18 @@ # --- # name: test_kern_split_multi_glyph_class[same] ''' - @kern1.foo = [a period]; - @kern2.foo = [b period]; + @kern1.dflt.foo = [period]; + @kern1.ltr.foo = [a]; + @kern2.dflt.foo = [period]; + @kern2.ltr.foo = [b]; + + lookup kern_dflt { + lookupflag IgnoreMarks; + pos period period 9; + enum pos period @kern2.dflt.foo 13; + enum pos @kern1.dflt.foo period 11; + pos @kern1.dflt.foo @kern2.dflt.foo 14; + } kern_dflt; lookup kern_ltr { lookupflag IgnoreMarks; @@ -471,15 +795,26 @@ pos b period 6; pos period a 7; pos period b 8; - pos period period 9; - enum pos a @kern2.foo 12; - enum pos period @kern2.foo 13; - enum pos @kern1.foo b 10; - enum pos @kern1.foo period 11; - pos @kern1.foo @kern2.foo 14; + enum pos a @kern2.ltr.foo 12; + enum pos a @kern2.dflt.foo 12; + enum pos period @kern2.ltr.foo 13; + enum pos @kern1.ltr.foo b 10; + enum pos @kern1.ltr.foo period 11; + enum pos @kern1.dflt.foo b 10; + pos @kern1.ltr.foo @kern2.ltr.foo 14; + pos @kern1.ltr.foo @kern2.dflt.foo 14; + pos @kern1.dflt.foo @kern2.ltr.foo 14; } kern_ltr; feature kern { + script DFLT; + language dflt; + lookup kern_dflt; + lookup kern_ltr; + + script latn; + language dflt; + lookup kern_dflt; lookup kern_ltr; } kern; @@ -487,18 +822,24 @@ # --- # name: test_kern_uniqueness ''' - @kern1.questiondown = [questiondown]; - @kern2.y = [y]; + @kern1.ltr.questiondown = [questiondown]; + @kern2.ltr.y = [y]; lookup kern_ltr { lookupflag IgnoreMarks; pos questiondown y 35; - enum pos questiondown @kern2.y -35; - enum pos @kern1.questiondown y 35; - pos @kern1.questiondown @kern2.y 15; + enum pos questiondown @kern2.ltr.y -35; + enum pos @kern1.ltr.questiondown y 35; + pos @kern1.ltr.questiondown @kern2.ltr.y 15; } kern_ltr; feature kern { + script DFLT; + language dflt; + lookup kern_ltr; + + script latn; + language dflt; lookup kern_ltr; } kern; @@ -506,7 +847,7 @@ # --- # name: test_kern_zyyy_zinh ''' - lookup kern_ltr { + lookup kern_dflt { lookupflag IgnoreMarks; pos uni0640 uni0640 0; pos uni0650 uni0650 1; @@ -548,37 +889,86 @@ pos uniA700 uniA700 27; pos uniA830 uniA830 28; pos uniFF70 uniFF70 29; - } kern_ltr; + } kern_dflt; feature kern { - lookup kern_ltr; + script DFLT; + language dflt; + lookup kern_dflt; + + script grek; + language dflt; + lookup kern_dflt; + + script hani; + language dflt; + lookup kern_dflt; + + script kana; + language dflt; + lookup kern_dflt; } kern; + feature dist { + script dev2; + language dflt; + lookup kern_dflt; + + script deva; + language dflt; + lookup kern_dflt; + + script dupl; + language dflt; + lookup kern_dflt; + } dist; + ''' # --- # name: test_mark_base_kerning ''' - @kern1.etamil = [aulengthmark-tamil va-tamil]; - @kern2.etamil = [aulengthmark-tamil va-tamil]; + @kern1.ltr.etamil = [va-tamil]; + @kern1.ltr.etamil_1 = [aulengthmark-tamil]; + @kern2.ltr.etamil = [va-tamil]; + @kern2.ltr.etamil_1 = [aulengthmark-tamil]; lookup kern_ltr { lookupflag IgnoreMarks; pos aa-tamil va-tamil -20; pos va-tamil aa-tamil -20; + enum pos aa-tamil @kern2.ltr.etamil -35; + enum pos @kern1.ltr.etamil aa-tamil -35; + pos @kern1.ltr.etamil @kern2.ltr.etamil -100; } kern_ltr; lookup kern_ltr_marks { pos aulengthmark-tamil aulengthmark-tamil -200; - enum pos aa-tamil @kern2.etamil -35; - enum pos @kern1.etamil aa-tamil -35; - pos @kern1.etamil @kern2.etamil -100; + enum pos aa-tamil @kern2.ltr.etamil_1 -35; + enum pos @kern1.ltr.etamil_1 aa-tamil -35; + pos @kern1.ltr.etamil_1 @kern2.ltr.etamil_1 -100; + pos @kern1.ltr.etamil_1 @kern2.ltr.etamil -100; + pos @kern1.ltr.etamil @kern2.ltr.etamil_1 -100; } kern_ltr_marks; feature kern { + script DFLT; + language dflt; lookup kern_ltr; lookup kern_ltr_marks; } kern; + feature dist { + script tml2; + language dflt; + lookup kern_ltr; + lookup kern_ltr_marks; + + script taml; + language dflt; + lookup kern_ltr; + lookup kern_ltr_marks; + } dist; + ''' # --- # name: test_mark_to_base_kern @@ -593,6 +983,13 @@ } kern_ltr_marks; feature kern { + script DFLT; + language dflt; + lookup kern_ltr; + lookup kern_ltr_marks; + + script latn; + language dflt; lookup kern_ltr; lookup kern_ltr_marks; } kern; @@ -607,6 +1004,12 @@ } kern_ltr; feature kern { + script DFLT; + language dflt; + lookup kern_ltr; + + script latn; + language dflt; lookup kern_ltr; } kern; @@ -614,12 +1017,14 @@ # --- # name: test_mark_to_base_only ''' - lookup kern_ltr_marks { + lookup kern_dflt_marks { pos A acutecomb -55; - } kern_ltr_marks; + } kern_dflt_marks; feature kern { - lookup kern_ltr_marks; + script DFLT; + language dflt; + lookup kern_dflt_marks; } kern; ''' @@ -630,13 +1035,15 @@ pos one four' -50 six; } kern; - lookup kern_ltr { + lookup kern_dflt { lookupflag IgnoreMarks; pos seven six 25; - } kern_ltr; + } kern_dflt; feature kern { - lookup kern_ltr; + script DFLT; + language dflt; + lookup kern_dflt; } kern; ''' @@ -651,14 +1058,16 @@ # --- # name: test_quantize ''' - lookup kern_ltr { + lookup kern_dflt { lookupflag IgnoreMarks; pos four six -55; pos one six -25; - } kern_ltr; + } kern_dflt; feature kern { - lookup kern_ltr; + script DFLT; + language dflt; + lookup kern_dflt; } kern; ''' @@ -666,7 +1075,8 @@ # name: test_skip_spacing_marks ''' lookup kern_ltr { - lookupflag IgnoreMarks; + @MFS_kern_ltr = [highspacingdot-deva]; + lookupflag UseMarkFilteringSet @MFS_kern_ltr; pos ka-deva ra-deva -250; pos ra-deva ka-deva -250; } kern_ltr; @@ -677,29 +1087,48 @@ } kern_ltr_marks; feature kern { + script DFLT; + language dflt; lookup kern_ltr; lookup kern_ltr_marks; } kern; + feature dist { + script dev2; + language dflt; + lookup kern_ltr; + lookup kern_ltr_marks; + + script deva; + language dflt; + lookup kern_ltr; + lookup kern_ltr_marks; + } dist; + ''' # --- # name: test_skip_zero_class_kerns ''' - @kern1.baz = [E F]; - @kern1.foo = [A B]; - @kern2.bar = [C D]; - @kern2.nul = [G H]; + @kern1.ltr.baz = [E F]; + @kern1.ltr.foo = [A B]; + @kern2.ltr.bar = [C D]; lookup kern_ltr { lookupflag IgnoreMarks; pos G H -5; - enum pos A @kern2.bar 5; - enum pos @kern1.foo D 15; - pos @kern1.baz @kern2.bar -10; - pos @kern1.foo @kern2.bar 10; + enum pos A @kern2.ltr.bar 5; + enum pos @kern1.ltr.foo D 15; + pos @kern1.ltr.foo @kern2.ltr.bar 10; + pos @kern1.ltr.baz @kern2.ltr.bar -10; } kern_ltr; feature kern { + script DFLT; + language dflt; + lookup kern_ltr; + + script latn; + language dflt; lookup kern_ltr; } kern; diff --git a/tests/featureWriters/variableFeatureWriter_test.py b/tests/featureWriters/variableFeatureWriter_test.py index e81a90e7b..1ea5ecabb 100644 --- a/tests/featureWriters/variableFeatureWriter_test.py +++ b/tests/featureWriters/variableFeatureWriter_test.py @@ -103,18 +103,22 @@ def test_variable_features_old_kern_writer(FontClass): markClass dotabove-ar @MC_top; markClass gravecmb @MC_top; - @kern1.a = [a]; - @kern1.alef = [alef-ar.fina]; - @kern2.a = [a]; - @kern2.alef = [alef-ar.fina]; + @kern1.rtl.alef = [alef-ar.fina]; + @kern2.rtl.alef = [alef-ar.fina]; lookup kern_rtl { lookupflag IgnoreMarks; pos alef-ar.fina alef-ar.fina <(wght=100:15 wght=1000:35) 0 (wght=100:15 wght=1000:35) 0>; - pos @kern1.alef @kern2.alef <(wght=100:0 wght=1000:1) 0 (wght=100:0 wght=1000:1) 0>; + pos @kern1.rtl.alef @kern2.rtl.alef <(wght=100:0 wght=1000:1) 0 (wght=100:0 wght=1000:1) 0>; } kern_rtl; feature kern { + script DFLT; + language dflt; + lookup kern_rtl; + + script arab; + language dflt; lookup kern_rtl; } kern;