diff --git a/Lib/fontbakery/checks/case_mapping.py b/Lib/fontbakery/checks/case_mapping.py new file mode 100644 index 0000000000..c9fe46bddd --- /dev/null +++ b/Lib/fontbakery/checks/case_mapping.py @@ -0,0 +1,80 @@ +from fontbakery.prelude import check, Message, FAIL + + +@check( + id="case_mapping", + rationale=""" + Ensure that no glyph lacks its corresponding upper or lower counterpart + (but only when unicode supports case-mapping). + """, + proposal="https://github.com/googlefonts/fontbakery/issues/3230", + severity=10, # if a font shows tofu in caps but not in lowercase + # then it can be considered broken. +) +def check_case_mapping(ttFont): + """Ensure the font supports case swapping for all its glyphs.""" + import unicodedata + from fontbakery.utils import markdown_table + + # These are a selection of codepoints for which the corresponding case-swap + # glyphs are missing way too often on the Google Fonts library, + # so we'll ignore for now: + EXCEPTIONS = [ + 0x0192, # ƒ - Latin Small Letter F with Hook + 0x00B5, # µ - Micro Sign + 0x03C0, # π - Greek Small Letter Pi + 0x2126, # Ω - Ohm Sign + 0x03BC, # μ - Greek Small Letter Mu + 0x03A9, # Ω - Greek Capital Letter Omega + 0x0394, # Δ - Greek Capital Letter Delta + 0x0251, # ɑ - Latin Small Letter Alpha + 0x0261, # ɡ - Latin Small Letter Script G + 0x00FF, # ÿ - Latin Small Letter Y with Diaeresis + 0x0250, # ɐ - Latin Small Letter Turned A + 0x025C, # ɜ - Latin Small Letter Reversed Open E + 0x0252, # ɒ - Latin Small Letter Turned Alpha + 0x0271, # ɱ - Latin Small Letter M with Hook + 0x0282, # ʂ - Latin Small Letter S with Hook + 0x029E, # ʞ - Latin Small Letter Turned K + 0x0287, # ʇ - Latin Small Letter Turned T + 0x0127, # ħ - Latin Small Letter H with Stroke + 0x0140, # ŀ - Latin Small Letter L with Middle Dot + 0x023F, # ȿ - Latin Small Letter S with Swash Tail + 0x0240, # ɀ - Latin Small Letter Z with Swash Tail + 0x026B, # ɫ - Latin Small Letter L with Middle Tilde + ] + + missing_counterparts_table = [] + cmap = ttFont["cmap"].getBestCmap() + for codepoint in cmap: + if codepoint in EXCEPTIONS: + continue + + # Only check letters + if unicodedata.category(chr(codepoint))[0] != "L": + continue + + the_char = chr(codepoint) + swapped = the_char.swapcase() + + # skip cases like 'ß' => 'SS' + if len(swapped) > 1: + continue + + if the_char != swapped and ord(swapped) not in cmap: + name = unicodedata.name(the_char) + swapped_name = unicodedata.name(swapped) + row = { + "Glyph present in the font": f"U+{codepoint:04X}: {name}", + "Missing case-swapping counterpart": ( + f"U+{ord(swapped):04X}: {swapped_name}" + ), + } + missing_counterparts_table.append(row) + + if missing_counterparts_table: + yield FAIL, Message( + "missing-case-counterparts", + f"The following glyphs lack their case-swapping counterparts:\n\n" + f"{markdown_table(missing_counterparts_table)}\n\n", + ) diff --git a/Lib/fontbakery/checks/control_chars.py b/Lib/fontbakery/checks/control_chars.py new file mode 100644 index 0000000000..7ee8c83e74 --- /dev/null +++ b/Lib/fontbakery/checks/control_chars.py @@ -0,0 +1,32 @@ +from fontbakery.prelude import check, Message, FAIL + + +@check( + id="control_chars", + conditions=["are_ttf"], + rationale=""" + Use of some unacceptable control characters in the U+0000 - U+001F range can + lead to rendering issues on some platforms. + + Acceptable control characters are defined as .null (U+0000) and + CR (U+000D) for this check. + """, + proposal="https://github.com/fonttools/fontbakery/pull/2430", +) +def check_family_control_chars(ttFont): + """Does font file include unacceptable control character glyphs?""" + # list of unacceptable control character glyph names + # definition includes the entire control character Unicode block except: + # - .null (U+0000) + # - CR (U+000D) + UNACCEPTABLE_CC = {f"uni{n:04X}" for n in range(32) if n not in [0x00, 0x0D]} + + glyphset = set(ttFont["glyf"].glyphs.keys()) + bad_glyphs = glyphset.intersection(UNACCEPTABLE_CC) + + if bad_glyphs: + bad = ", ".join(bad_glyphs) + yield FAIL, Message( + "unacceptable", + f"The following unacceptable control characters were identified:\n{bad}", + ) diff --git a/Lib/fontbakery/checks/glyphset.py b/Lib/fontbakery/checks/glyphset.py deleted file mode 100644 index 3bd2cd0ec5..0000000000 --- a/Lib/fontbakery/checks/glyphset.py +++ /dev/null @@ -1,518 +0,0 @@ -from copy import deepcopy - -from fontbakery.constants import ( - NameID, - PlatformID, - WindowsEncodingID, - WindowsLanguageID, -) -from fontbakery.prelude import check, Message, FAIL, WARN, PASS -from fontbakery.utils import bullet_list, glyph_has_ink - - -@check( - id="case_mapping", - rationale=""" - Ensure that no glyph lacks its corresponding upper or lower counterpart - (but only when unicode supports case-mapping). - """, - proposal="https://github.com/googlefonts/fontbakery/issues/3230", - severity=10, # if a font shows tofu in caps but not in lowercase - # then it can be considered broken. -) -def check_case_mapping(ttFont): - """Ensure the font supports case swapping for all its glyphs.""" - import unicodedata - from fontbakery.utils import markdown_table - - # These are a selection of codepoints for which the corresponding case-swap - # glyphs are missing way too often on the Google Fonts library, - # so we'll ignore for now: - EXCEPTIONS = [ - 0x0192, # ƒ - Latin Small Letter F with Hook - 0x00B5, # µ - Micro Sign - 0x03C0, # π - Greek Small Letter Pi - 0x2126, # Ω - Ohm Sign - 0x03BC, # μ - Greek Small Letter Mu - 0x03A9, # Ω - Greek Capital Letter Omega - 0x0394, # Δ - Greek Capital Letter Delta - 0x0251, # ɑ - Latin Small Letter Alpha - 0x0261, # ɡ - Latin Small Letter Script G - 0x00FF, # ÿ - Latin Small Letter Y with Diaeresis - 0x0250, # ɐ - Latin Small Letter Turned A - 0x025C, # ɜ - Latin Small Letter Reversed Open E - 0x0252, # ɒ - Latin Small Letter Turned Alpha - 0x0271, # ɱ - Latin Small Letter M with Hook - 0x0282, # ʂ - Latin Small Letter S with Hook - 0x029E, # ʞ - Latin Small Letter Turned K - 0x0287, # ʇ - Latin Small Letter Turned T - 0x0127, # ħ - Latin Small Letter H with Stroke - 0x0140, # ŀ - Latin Small Letter L with Middle Dot - 0x023F, # ȿ - Latin Small Letter S with Swash Tail - 0x0240, # ɀ - Latin Small Letter Z with Swash Tail - 0x026B, # ɫ - Latin Small Letter L with Middle Tilde - ] - - missing_counterparts_table = [] - cmap = ttFont["cmap"].getBestCmap() - for codepoint in cmap: - if codepoint in EXCEPTIONS: - continue - - # Only check letters - if unicodedata.category(chr(codepoint))[0] != "L": - continue - - the_char = chr(codepoint) - swapped = the_char.swapcase() - - # skip cases like 'ß' => 'SS' - if len(swapped) > 1: - continue - - if the_char != swapped and ord(swapped) not in cmap: - name = unicodedata.name(the_char) - swapped_name = unicodedata.name(swapped) - row = { - "Glyph present in the font": f"U+{codepoint:04X}: {name}", - "Missing case-swapping counterpart": ( - f"U+{ord(swapped):04X}: {swapped_name}" - ), - } - missing_counterparts_table.append(row) - - if missing_counterparts_table: - yield FAIL, Message( - "missing-case-counterparts", - f"The following glyphs lack their case-swapping counterparts:\n\n" - f"{markdown_table(missing_counterparts_table)}\n\n", - ) - - -@check( - id="control_chars", - conditions=["are_ttf"], - rationale=""" - Use of some unacceptable control characters in the U+0000 - U+001F range can - lead to rendering issues on some platforms. - - Acceptable control characters are defined as .null (U+0000) and - CR (U+000D) for this check. - """, - proposal="https://github.com/fonttools/fontbakery/pull/2430", -) -def check_family_control_chars(ttFont): - """Does font file include unacceptable control character glyphs?""" - # list of unacceptable control character glyph names - # definition includes the entire control character Unicode block except: - # - .null (U+0000) - # - CR (U+000D) - UNACCEPTABLE_CC = {f"uni{n:04X}" for n in range(32) if n not in [0x00, 0x0D]} - - glyphset = set(ttFont["glyf"].glyphs.keys()) - bad_glyphs = glyphset.intersection(UNACCEPTABLE_CC) - - if bad_glyphs: - bad = ", ".join(bad_glyphs) - yield FAIL, Message( - "unacceptable", - f"The following unacceptable control characters were identified:\n{bad}", - ) - - -@check( - id="mandatory_glyphs", - rationale=""" - The OpenType specification v1.8.2 recommends that the first glyph is the - '.notdef' glyph without a codepoint assigned and with a drawing: - - The .notdef glyph is very important for providing the user feedback - that a glyph is not found in the font. This glyph should not be left - without an outline as the user will only see what looks like a space - if a glyph is missing and not be aware of the active font’s limitation. - - https://docs.microsoft.com/en-us/typography/opentype/spec/recom#glyph-0-the-notdef-glyph - - Pre-v1.8, it was recommended that fonts should also contain 'space', 'CR' - and '.null' glyphs. This might have been relevant for MacOS 9 applications. - """, - proposal="https://github.com/fonttools/fontbakery/issues/4829", # legacy check -) -def check_mandatory_glyphs(ttFont): - """Font contains '.notdef' as its first glyph?""" - NOTDEF = ".notdef" - glyph_order = ttFont.getGlyphOrder() - - if NOTDEF not in glyph_order or len(glyph_order) == 0: - yield WARN, Message( - "notdef-not-found", f"Font should contain the {NOTDEF!r} glyph." - ) - # The font doesn't even have the notdef. There's no point in testing further. - return - - if glyph_order[0] != NOTDEF: - yield WARN, Message( - "notdef-not-first", f"The {NOTDEF!r} should be the font's first glyph." - ) - - cmap = ttFont.getBestCmap() # e.g. {65: 'A', 66: 'B', 67: 'C'} or None - if cmap and NOTDEF in cmap.values(): - rev_cmap = {name: val for val, name in reversed(sorted(cmap.items()))} - yield WARN, Message( - "notdef-has-codepoint", - f"The {NOTDEF!r} glyph should not have a Unicode codepoint value assigned," - f" but has 0x{rev_cmap[NOTDEF]:04X}.", - ) - - if not glyph_has_ink(ttFont, NOTDEF): - yield FAIL, Message( - "notdef-is-blank", - f"The {NOTDEF!r} glyph should contain a drawing, but it is blank.", - ) - - -@check( - id="missing_small_caps_glyphs", - rationale=""" - Ensure small caps glyphs are available if - a font declares smcp or c2sc OT features. - """, - proposal="https://github.com/fonttools/fontbakery/issues/3154", -) -def check_missing_small_caps_glyphs(ttFont): - """Ensure small caps glyphs are available.""" - - if "GSUB" in ttFont and ttFont["GSUB"].table.FeatureList is not None: - llist = ttFont["GSUB"].table.LookupList - for record in range(ttFont["GSUB"].table.FeatureList.FeatureCount): - feature = ttFont["GSUB"].table.FeatureList.FeatureRecord[record] - tag = feature.FeatureTag - if tag in ["smcp", "c2sc"]: - for index in feature.Feature.LookupListIndex: - subtable = llist.Lookup[index].SubTable[0] - if subtable.LookupType == 7: - # This is an Extension lookup - # used for reaching 32-bit offsets - # within the GSUB table. - subtable = subtable.ExtSubTable - if not hasattr(subtable, "mapping"): - continue - smcp_glyphs = set() - for value in subtable.mapping.values(): - if isinstance(value, list): - for v in value: - smcp_glyphs.add(v) - else: - smcp_glyphs.add(value) - missing = smcp_glyphs - set(ttFont.getGlyphNames()) - if missing: - missing = "\n\t - " + "\n\t - ".join(missing) - yield FAIL, Message( - "missing-glyphs", - f"These '{tag}' glyphs are missing:\n\n{missing}", - ) - break - - -def can_shape(ttFont, text, parameters=None): - """ - Returns true if the font can render a text string without any - .notdef characters. - """ - from vharfbuzz import Vharfbuzz - - filename = ttFont.reader.file.name - vharfbuzz = Vharfbuzz(filename) - buf = vharfbuzz.shape(text, parameters) - return all(g.codepoint != 0 for g in buf.glyph_infos) - - -@check( - id="render_own_name", - rationale=""" - A base expectation is that a font family's regular/default (400 roman) style - can render its 'menu name' (nameID 1) in itself. - """, - proposal="https://github.com/fonttools/fontbakery/issues/3159", -) -def check_render_own_name(ttFont): - """Ensure font can render its own name.""" - menu_name = ( - ttFont["name"] - .getName( - NameID.FONT_FAMILY_NAME, - PlatformID.WINDOWS, - WindowsEncodingID.UNICODE_BMP, - WindowsLanguageID.ENGLISH_USA, - ) - .toUnicode() - ) - if not can_shape(ttFont, menu_name): - yield FAIL, Message( - "render-own-name", - f".notdef glyphs were found when attempting to render {menu_name}", - ) - - -@check( - id="rupee", - rationale=""" - Per Bureau of Indian Standards every font supporting one of the - official Indian languages needs to include Unicode Character - “₹” (U+20B9) Indian Rupee Sign. - """, - conditions=["is_indic_font"], - proposal="https://github.com/fonttools/fontbakery/issues/2967", -) -def check_rupee(ttFont): - """Ensure indic fonts have the Indian Rupee Sign glyph.""" - if 0x20B9 not in ttFont["cmap"].getBestCmap().keys(): - yield FAIL, Message( - "missing-rupee", - "Please add a glyph for Indian Rupee Sign (₹) at codepoint U+20B9.", - ) - else: - yield PASS, "Looks good!" - - -@check( - id="soft_hyphen", - rationale=""" - The 'Soft Hyphen' character (codepoint 0x00AD) is used to mark - a hyphenation possibility within a word in the absence of or - overriding dictionary hyphenation. - - It is sometimes designed empty with no width (such as a control character), - sometimes the same as the traditional hyphen, sometimes double encoded with - the hyphen. - - That being said, it is recommended to not include it in the font at all, - because discretionary hyphenation should be handled at the level of the - shaping engine, not the font. Also, even if present, the software would - not display that character. - - More discussion at: - https://typedrawers.com/discussion/2046/special-dash-things-softhyphen-horizontalbar - """, - proposal=[ - "https://github.com/fonttools/fontbakery/issues/4046", - "https://github.com/fonttools/fontbakery/issues/3486", - ], -) -def check_soft_hyphen(ttFont): - """Does the font contain a soft hyphen?""" - if 0x00AD in ttFont["cmap"].getBestCmap().keys(): - yield WARN, Message("softhyphen", "This font has a 'Soft Hyphen' character.") - else: - yield PASS, "Looks good!" - - -@check( - id="unreachable_glyphs", - rationale=""" - Glyphs are either accessible directly through Unicode codepoints or through - substitution rules. - - In Color Fonts, glyphs are also referenced by the COLR table. And mathematical - fonts also reference glyphs via the MATH table. - - Any glyphs not accessible by these means are redundant and serve only - to increase the font's file size. - """, - proposal="https://github.com/fonttools/fontbakery/issues/3160", -) -def unreachable_glyphs(ttFont, config): - """Check font contains no unreachable glyphs""" - - # remove_lookup_outputs() mutates the TTF; deep copy to avoid this, and so - # avoid issues with concurrent tests that also use ttFont. - # See https://github.com/fonttools/fontbakery/issues/4834 - ttFont = deepcopy(ttFont) - - def remove_lookup_outputs(all_glyphs, lookup): - if lookup.LookupType == 1: # Single: - # Replace one glyph with one glyph - for sub in lookup.SubTable: - all_glyphs -= set(sub.mapping.values()) - - if lookup.LookupType == 2: # Multiple: - # Replace one glyph with more than one glyph - for sub in lookup.SubTable: - for slot in sub.mapping.values(): - all_glyphs -= set(slot) - - if lookup.LookupType == 3: # Alternate: - # Replace one glyph with one of many glyphs - for sub in lookup.SubTable: - for slot in sub.alternates.values(): - all_glyphs -= set(slot) - - if lookup.LookupType == 4: # Ligature: - # Replace multiple glyphs with one glyph - for sub in lookup.SubTable: - for ligatures in sub.ligatures.values(): - all_glyphs -= set(lig.LigGlyph for lig in ligatures) - - if lookup.LookupType in [5, 6]: - # We do nothing here, because these contextual lookup types don't - # generate glyphs directly; they only dispatch to other lookups - # stored elsewhere in the lookup list. As we are examining all - # lookups in the lookup list, other calls to this function will - # deal with the lookups that a contextual lookup references. - pass - - if lookup.LookupType == 7: # Extension Substitution: - # Extension mechanism for other substitutions - for xt in lookup.SubTable: - xt.SubTable = [xt.ExtSubTable] - xt.LookupType = xt.ExtSubTable.LookupType - remove_lookup_outputs(all_glyphs, xt) - - if lookup.LookupType == 8: # Reverse chaining context single: - # Applied in reverse order, - # replace single glyph in chaining context - for sub in lookup.SubTable: - all_glyphs -= set(sub.Substitute) - - all_glyphs = set(ttFont.getGlyphOrder()) - - # Exclude cmapped glyphs - all_glyphs -= set(ttFont.getBestCmap().values()) - - # Exclude glyphs referenced by cmap format 14 variation sequences - # (as discussed at https://github.com/fonttools/fontbakery/issues/3915): - for table in ttFont["cmap"].tables: - if table.format == 14: - for values in table.uvsDict.values(): - for v in list(values): - if v[1] is not None: - all_glyphs.discard(v[1]) - - # and ignore these: - all_glyphs.discard(".null") - all_glyphs.discard(".notdef") - - # Glyphs identified in the Extender Glyph Table within JSTF table, - # such as kashidas, are not included in the check output: - # https://github.com/fonttools/fontbakery/issues/4773 - if "JSTF" in ttFont: - for subtable in ttFont["JSTF"].table.iterSubTables(): - for extender_glyph in subtable.value.JstfScript.ExtenderGlyph.ExtenderGlyph: - all_glyphs.discard(extender_glyph) - - if "MATH" in ttFont: - glyphinfo = ttFont["MATH"].table.MathGlyphInfo - mathvariants = ttFont["MATH"].table.MathVariants - - for glyphname in glyphinfo.MathTopAccentAttachment.TopAccentCoverage.glyphs: - all_glyphs.discard(glyphname) - - for glyphname in glyphinfo.ExtendedShapeCoverage.glyphs: - all_glyphs.discard(glyphname) - - for glyphname in mathvariants.VertGlyphCoverage.glyphs: - all_glyphs.discard(glyphname) - - for glyphname in mathvariants.HorizGlyphCoverage.glyphs: - all_glyphs.discard(glyphname) - - for vgc in mathvariants.VertGlyphConstruction: - if vgc.GlyphAssembly: - for part in vgc.GlyphAssembly.PartRecords: - all_glyphs.discard(part.glyph) - - for rec in vgc.MathGlyphVariantRecord: - all_glyphs.discard(rec.VariantGlyph) - - for hgc in mathvariants.HorizGlyphConstruction: - if hgc.GlyphAssembly: - for part in hgc.GlyphAssembly.PartRecords: - all_glyphs.discard(part.glyph) - - for rec in hgc.MathGlyphVariantRecord: - all_glyphs.discard(rec.VariantGlyph) - - if "COLR" in ttFont: - if ttFont["COLR"].version == 0: - for glyphname, colorlayers in ttFont["COLR"].ColorLayers.items(): - for layer in colorlayers: - all_glyphs.discard(layer.name) - - elif ttFont["COLR"].version == 1: - if ( - hasattr(ttFont["COLR"].table, "BaseGlyphRecordArray") - and ttFont["COLR"].table.BaseGlyphRecordArray is not None - ): - for baseglyph_record in ttFont[ - "COLR" - ].table.BaseGlyphRecordArray.BaseGlyphRecord: - all_glyphs.discard(baseglyph_record.BaseGlyph) - - if ( - hasattr(ttFont["COLR"].table, "LayerRecordArray") - and ttFont["COLR"].table.LayerRecordArray is not None - ): - for layer_record in ttFont["COLR"].table.LayerRecordArray.LayerRecord: - all_glyphs.discard(layer_record.LayerGlyph) - - for paint_record in ttFont["COLR"].table.BaseGlyphList.BaseGlyphPaintRecord: - if hasattr(paint_record.Paint, "Glyph"): - all_glyphs.discard(paint_record.Paint.Glyph) - - if ttFont["COLR"].table.LayerList: - for paint in ttFont["COLR"].table.LayerList.Paint: - if hasattr(paint, "Glyph"): - all_glyphs.discard(paint.Glyph) - - if "GSUB" in ttFont and ttFont["GSUB"].table.LookupList: - lookups = ttFont["GSUB"].table.LookupList.Lookup - - for lookup in lookups: - remove_lookup_outputs(all_glyphs, lookup) - - # Remove components used in TrueType table - if "glyf" in ttFont: - for glyph_name in ttFont["glyf"].keys(): - base_glyph = ttFont["glyf"][glyph_name] - if base_glyph.isComposite() and glyph_name not in all_glyphs: - all_glyphs -= set(base_glyph.getComponentNames(ttFont["glyf"])) - - if all_glyphs: - yield WARN, Message( - "unreachable-glyphs", - "The following glyphs could not be reached" - " by codepoint or substitution rules:\n\n" - f"{bullet_list(config, sorted(all_glyphs))}\n", - ) - else: - yield PASS, "Font did not contain any unreachable glyphs" - - -@check( - id="whitespace_glyphs", - rationale=""" - The OpenType specification recommends that fonts should contain - glyphs for the following whitespace characters: - - - U+0020 SPACE - - U+00A0 NO-BREAK SPACE - - The space character is required for text processing, and the no-break - space is useful to prevent line breaks at its position. It is also - recommended to have a glyph for the tab character (U+0009) and the - soft hyphen (U+00AD), but these are not mandatory. - """, - proposal="https://github.com/fonttools/fontbakery/issues/4829", # legacy check -) -def check_whitespace_glyphs(ttFont, missing_whitespace_chars): - """Font contains glyphs for whitespace characters?""" - failed = False - for wsc in missing_whitespace_chars: - failed = True - yield FAIL, Message( - f"missing-whitespace-glyph-{wsc}", - f"Whitespace glyph missing for codepoint {wsc}.", - ) - - if not failed: - yield PASS, "Font contains glyphs for whitespace characters." diff --git a/Lib/fontbakery/checks/mandatory_glyphs.py b/Lib/fontbakery/checks/mandatory_glyphs.py new file mode 100644 index 0000000000..6b73632449 --- /dev/null +++ b/Lib/fontbakery/checks/mandatory_glyphs.py @@ -0,0 +1,53 @@ +from fontbakery.prelude import check, Message, FAIL, WARN +from fontbakery.utils import glyph_has_ink + + +@check( + id="mandatory_glyphs", + rationale=""" + The OpenType specification v1.8.2 recommends that the first glyph is the + '.notdef' glyph without a codepoint assigned and with a drawing: + + The .notdef glyph is very important for providing the user feedback + that a glyph is not found in the font. This glyph should not be left + without an outline as the user will only see what looks like a space + if a glyph is missing and not be aware of the active font’s limitation. + + https://docs.microsoft.com/en-us/typography/opentype/spec/recom#glyph-0-the-notdef-glyph + + Pre-v1.8, it was recommended that fonts should also contain 'space', 'CR' + and '.null' glyphs. This might have been relevant for MacOS 9 applications. + """, + proposal="https://github.com/fonttools/fontbakery/issues/4829", # legacy check +) +def check_mandatory_glyphs(ttFont): + """Font contains '.notdef' as its first glyph?""" + NOTDEF = ".notdef" + glyph_order = ttFont.getGlyphOrder() + + if NOTDEF not in glyph_order or len(glyph_order) == 0: + yield WARN, Message( + "notdef-not-found", f"Font should contain the {NOTDEF!r} glyph." + ) + # The font doesn't even have the notdef. There's no point in testing further. + return + + if glyph_order[0] != NOTDEF: + yield WARN, Message( + "notdef-not-first", f"The {NOTDEF!r} should be the font's first glyph." + ) + + cmap = ttFont.getBestCmap() # e.g. {65: 'A', 66: 'B', 67: 'C'} or None + if cmap and NOTDEF in cmap.values(): + rev_cmap = {name: val for val, name in reversed(sorted(cmap.items()))} + yield WARN, Message( + "notdef-has-codepoint", + f"The {NOTDEF!r} glyph should not have a Unicode codepoint value assigned," + f" but has 0x{rev_cmap[NOTDEF]:04X}.", + ) + + if not glyph_has_ink(ttFont, NOTDEF): + yield FAIL, Message( + "notdef-is-blank", + f"The {NOTDEF!r} glyph should contain a drawing, but it is blank.", + ) diff --git a/Lib/fontbakery/checks/missing_small_caps_glyphs.py b/Lib/fontbakery/checks/missing_small_caps_glyphs.py new file mode 100644 index 0000000000..42c9cc41fb --- /dev/null +++ b/Lib/fontbakery/checks/missing_small_caps_glyphs.py @@ -0,0 +1,44 @@ +from fontbakery.prelude import check, Message, FAIL + + +@check( + id="missing_small_caps_glyphs", + rationale=""" + Ensure small caps glyphs are available if + a font declares smcp or c2sc OT features. + """, + proposal="https://github.com/fonttools/fontbakery/issues/3154", +) +def check_missing_small_caps_glyphs(ttFont): + """Ensure small caps glyphs are available.""" + + if "GSUB" in ttFont and ttFont["GSUB"].table.FeatureList is not None: + llist = ttFont["GSUB"].table.LookupList + for record in range(ttFont["GSUB"].table.FeatureList.FeatureCount): + feature = ttFont["GSUB"].table.FeatureList.FeatureRecord[record] + tag = feature.FeatureTag + if tag in ["smcp", "c2sc"]: + for index in feature.Feature.LookupListIndex: + subtable = llist.Lookup[index].SubTable[0] + if subtable.LookupType == 7: + # This is an Extension lookup + # used for reaching 32-bit offsets + # within the GSUB table. + subtable = subtable.ExtSubTable + if not hasattr(subtable, "mapping"): + continue + smcp_glyphs = set() + for value in subtable.mapping.values(): + if isinstance(value, list): + for v in value: + smcp_glyphs.add(v) + else: + smcp_glyphs.add(value) + missing = smcp_glyphs - set(ttFont.getGlyphNames()) + if missing: + missing = "\n\t - " + "\n\t - ".join(missing) + yield FAIL, Message( + "missing-glyphs", + f"These '{tag}' glyphs are missing:\n\n{missing}", + ) + break diff --git a/Lib/fontbakery/checks/render_own_name.py b/Lib/fontbakery/checks/render_own_name.py new file mode 100644 index 0000000000..9c78088d55 --- /dev/null +++ b/Lib/fontbakery/checks/render_own_name.py @@ -0,0 +1,35 @@ +from fontbakery.constants import ( + NameID, + PlatformID, + WindowsEncodingID, + WindowsLanguageID, +) +from fontbakery.prelude import check, Message, FAIL +from fontbakery.utils import can_shape + + +@check( + id="render_own_name", + rationale=""" + A base expectation is that a font family's regular/default (400 roman) style + can render its 'menu name' (nameID 1) in itself. + """, + proposal="https://github.com/fonttools/fontbakery/issues/3159", +) +def check_render_own_name(ttFont): + """Ensure font can render its own name.""" + menu_name = ( + ttFont["name"] + .getName( + NameID.FONT_FAMILY_NAME, + PlatformID.WINDOWS, + WindowsEncodingID.UNICODE_BMP, + WindowsLanguageID.ENGLISH_USA, + ) + .toUnicode() + ) + if not can_shape(ttFont, menu_name): + yield FAIL, Message( + "render-own-name", + f".notdef glyphs were found when attempting to render {menu_name}", + ) diff --git a/Lib/fontbakery/checks/rupee.py b/Lib/fontbakery/checks/rupee.py new file mode 100644 index 0000000000..e0984398a0 --- /dev/null +++ b/Lib/fontbakery/checks/rupee.py @@ -0,0 +1,22 @@ +from fontbakery.prelude import check, Message, FAIL, PASS + + +@check( + id="rupee", + rationale=""" + Per Bureau of Indian Standards every font supporting one of the + official Indian languages needs to include Unicode Character + “₹” (U+20B9) Indian Rupee Sign. + """, + conditions=["is_indic_font"], + proposal="https://github.com/fonttools/fontbakery/issues/2967", +) +def check_rupee(ttFont): + """Ensure indic fonts have the Indian Rupee Sign glyph.""" + if 0x20B9 not in ttFont["cmap"].getBestCmap().keys(): + yield FAIL, Message( + "missing-rupee", + "Please add a glyph for Indian Rupee Sign (₹) at codepoint U+20B9.", + ) + else: + yield PASS, "Looks good!" diff --git a/Lib/fontbakery/checks/soft_hyphen.py b/Lib/fontbakery/checks/soft_hyphen.py new file mode 100644 index 0000000000..e8bd141915 --- /dev/null +++ b/Lib/fontbakery/checks/soft_hyphen.py @@ -0,0 +1,33 @@ +from fontbakery.prelude import check, Message, WARN, PASS + + +@check( + id="soft_hyphen", + rationale=""" + The 'Soft Hyphen' character (codepoint 0x00AD) is used to mark + a hyphenation possibility within a word in the absence of or + overriding dictionary hyphenation. + + It is sometimes designed empty with no width (such as a control character), + sometimes the same as the traditional hyphen, sometimes double encoded with + the hyphen. + + That being said, it is recommended to not include it in the font at all, + because discretionary hyphenation should be handled at the level of the + shaping engine, not the font. Also, even if present, the software would + not display that character. + + More discussion at: + https://typedrawers.com/discussion/2046/special-dash-things-softhyphen-horizontalbar + """, + proposal=[ + "https://github.com/fonttools/fontbakery/issues/4046", + "https://github.com/fonttools/fontbakery/issues/3486", + ], +) +def check_soft_hyphen(ttFont): + """Does the font contain a soft hyphen?""" + if 0x00AD in ttFont["cmap"].getBestCmap().keys(): + yield WARN, Message("softhyphen", "This font has a 'Soft Hyphen' character.") + else: + yield PASS, "Looks good!" diff --git a/Lib/fontbakery/checks/unreachable_glyphs.py b/Lib/fontbakery/checks/unreachable_glyphs.py new file mode 100644 index 0000000000..7c55e6552e --- /dev/null +++ b/Lib/fontbakery/checks/unreachable_glyphs.py @@ -0,0 +1,185 @@ +from copy import deepcopy + +from fontbakery.prelude import check, Message, WARN, PASS +from fontbakery.utils import bullet_list + + +@check( + id="unreachable_glyphs", + rationale=""" + Glyphs are either accessible directly through Unicode codepoints or through + substitution rules. + + In Color Fonts, glyphs are also referenced by the COLR table. And mathematical + fonts also reference glyphs via the MATH table. + + Any glyphs not accessible by these means are redundant and serve only + to increase the font's file size. + """, + proposal="https://github.com/fonttools/fontbakery/issues/3160", +) +def unreachable_glyphs(ttFont, config): + """Check font contains no unreachable glyphs""" + + # remove_lookup_outputs() mutates the TTF; deep copy to avoid this, and so + # avoid issues with concurrent tests that also use ttFont. + # See https://github.com/fonttools/fontbakery/issues/4834 + ttFont = deepcopy(ttFont) + + def remove_lookup_outputs(all_glyphs, lookup): + if lookup.LookupType == 1: # Single: + # Replace one glyph with one glyph + for sub in lookup.SubTable: + all_glyphs -= set(sub.mapping.values()) + + if lookup.LookupType == 2: # Multiple: + # Replace one glyph with more than one glyph + for sub in lookup.SubTable: + for slot in sub.mapping.values(): + all_glyphs -= set(slot) + + if lookup.LookupType == 3: # Alternate: + # Replace one glyph with one of many glyphs + for sub in lookup.SubTable: + for slot in sub.alternates.values(): + all_glyphs -= set(slot) + + if lookup.LookupType == 4: # Ligature: + # Replace multiple glyphs with one glyph + for sub in lookup.SubTable: + for ligatures in sub.ligatures.values(): + all_glyphs -= set(lig.LigGlyph for lig in ligatures) + + if lookup.LookupType in [5, 6]: + # We do nothing here, because these contextual lookup types don't + # generate glyphs directly; they only dispatch to other lookups + # stored elsewhere in the lookup list. As we are examining all + # lookups in the lookup list, other calls to this function will + # deal with the lookups that a contextual lookup references. + pass + + if lookup.LookupType == 7: # Extension Substitution: + # Extension mechanism for other substitutions + for xt in lookup.SubTable: + xt.SubTable = [xt.ExtSubTable] + xt.LookupType = xt.ExtSubTable.LookupType + remove_lookup_outputs(all_glyphs, xt) + + if lookup.LookupType == 8: # Reverse chaining context single: + # Applied in reverse order, + # replace single glyph in chaining context + for sub in lookup.SubTable: + all_glyphs -= set(sub.Substitute) + + all_glyphs = set(ttFont.getGlyphOrder()) + + # Exclude cmapped glyphs + all_glyphs -= set(ttFont.getBestCmap().values()) + + # Exclude glyphs referenced by cmap format 14 variation sequences + # (as discussed at https://github.com/fonttools/fontbakery/issues/3915): + for table in ttFont["cmap"].tables: + if table.format == 14: + for values in table.uvsDict.values(): + for v in list(values): + if v[1] is not None: + all_glyphs.discard(v[1]) + + # and ignore these: + all_glyphs.discard(".null") + all_glyphs.discard(".notdef") + + # Glyphs identified in the Extender Glyph Table within JSTF table, + # such as kashidas, are not included in the check output: + # https://github.com/fonttools/fontbakery/issues/4773 + if "JSTF" in ttFont: + for subtable in ttFont["JSTF"].table.iterSubTables(): + for extender_glyph in subtable.value.JstfScript.ExtenderGlyph.ExtenderGlyph: + all_glyphs.discard(extender_glyph) + + if "MATH" in ttFont: + glyphinfo = ttFont["MATH"].table.MathGlyphInfo + mathvariants = ttFont["MATH"].table.MathVariants + + for glyphname in glyphinfo.MathTopAccentAttachment.TopAccentCoverage.glyphs: + all_glyphs.discard(glyphname) + + for glyphname in glyphinfo.ExtendedShapeCoverage.glyphs: + all_glyphs.discard(glyphname) + + for glyphname in mathvariants.VertGlyphCoverage.glyphs: + all_glyphs.discard(glyphname) + + for glyphname in mathvariants.HorizGlyphCoverage.glyphs: + all_glyphs.discard(glyphname) + + for vgc in mathvariants.VertGlyphConstruction: + if vgc.GlyphAssembly: + for part in vgc.GlyphAssembly.PartRecords: + all_glyphs.discard(part.glyph) + + for rec in vgc.MathGlyphVariantRecord: + all_glyphs.discard(rec.VariantGlyph) + + for hgc in mathvariants.HorizGlyphConstruction: + if hgc.GlyphAssembly: + for part in hgc.GlyphAssembly.PartRecords: + all_glyphs.discard(part.glyph) + + for rec in hgc.MathGlyphVariantRecord: + all_glyphs.discard(rec.VariantGlyph) + + if "COLR" in ttFont: + if ttFont["COLR"].version == 0: + for glyphname, colorlayers in ttFont["COLR"].ColorLayers.items(): + for layer in colorlayers: + all_glyphs.discard(layer.name) + + elif ttFont["COLR"].version == 1: + if ( + hasattr(ttFont["COLR"].table, "BaseGlyphRecordArray") + and ttFont["COLR"].table.BaseGlyphRecordArray is not None + ): + for baseglyph_record in ttFont[ + "COLR" + ].table.BaseGlyphRecordArray.BaseGlyphRecord: + all_glyphs.discard(baseglyph_record.BaseGlyph) + + if ( + hasattr(ttFont["COLR"].table, "LayerRecordArray") + and ttFont["COLR"].table.LayerRecordArray is not None + ): + for layer_record in ttFont["COLR"].table.LayerRecordArray.LayerRecord: + all_glyphs.discard(layer_record.LayerGlyph) + + for paint_record in ttFont["COLR"].table.BaseGlyphList.BaseGlyphPaintRecord: + if hasattr(paint_record.Paint, "Glyph"): + all_glyphs.discard(paint_record.Paint.Glyph) + + if ttFont["COLR"].table.LayerList: + for paint in ttFont["COLR"].table.LayerList.Paint: + if hasattr(paint, "Glyph"): + all_glyphs.discard(paint.Glyph) + + if "GSUB" in ttFont and ttFont["GSUB"].table.LookupList: + lookups = ttFont["GSUB"].table.LookupList.Lookup + + for lookup in lookups: + remove_lookup_outputs(all_glyphs, lookup) + + # Remove components used in TrueType table + if "glyf" in ttFont: + for glyph_name in ttFont["glyf"].keys(): + base_glyph = ttFont["glyf"][glyph_name] + if base_glyph.isComposite() and glyph_name not in all_glyphs: + all_glyphs -= set(base_glyph.getComponentNames(ttFont["glyf"])) + + if all_glyphs: + yield WARN, Message( + "unreachable-glyphs", + "The following glyphs could not be reached" + " by codepoint or substitution rules:\n\n" + f"{bullet_list(config, sorted(all_glyphs))}\n", + ) + else: + yield PASS, "Font did not contain any unreachable glyphs" diff --git a/Lib/fontbakery/checks/vendorspecific/googlefonts/metadata.py b/Lib/fontbakery/checks/vendorspecific/googlefonts/metadata.py index d90b21a9bf..d8affc0b1a 100644 --- a/Lib/fontbakery/checks/vendorspecific/googlefonts/metadata.py +++ b/Lib/fontbakery/checks/vendorspecific/googlefonts/metadata.py @@ -1,10 +1,13 @@ from collections import defaultdict import os -from fontbakery.checks.glyphset import can_shape from fontbakery.constants import NameID, RIBBI_STYLE_NAMES from fontbakery.prelude import check, Message, INFO, PASS, FAIL, WARN, SKIP, FATAL -from fontbakery.utils import exit_with_install_instructions, show_inconsistencies +from fontbakery.utils import ( + can_shape, + exit_with_install_instructions, + show_inconsistencies, +) @check( diff --git a/Lib/fontbakery/checks/whitespace_glyphs.py b/Lib/fontbakery/checks/whitespace_glyphs.py new file mode 100644 index 0000000000..c5f391774c --- /dev/null +++ b/Lib/fontbakery/checks/whitespace_glyphs.py @@ -0,0 +1,31 @@ +from fontbakery.prelude import check, Message, FAIL, PASS + + +@check( + id="whitespace_glyphs", + rationale=""" + The OpenType specification recommends that fonts should contain + glyphs for the following whitespace characters: + + - U+0020 SPACE + - U+00A0 NO-BREAK SPACE + + The space character is required for text processing, and the no-break + space is useful to prevent line breaks at its position. It is also + recommended to have a glyph for the tab character (U+0009) and the + soft hyphen (U+00AD), but these are not mandatory. + """, + proposal="https://github.com/fonttools/fontbakery/issues/4829", # legacy check +) +def check_whitespace_glyphs(ttFont, missing_whitespace_chars): + """Font contains glyphs for whitespace characters?""" + failed = False + for wsc in missing_whitespace_chars: + failed = True + yield FAIL, Message( + f"missing-whitespace-glyph-{wsc}", + f"Whitespace glyph missing for codepoint {wsc}.", + ) + + if not failed: + yield PASS, "Font contains glyphs for whitespace characters." diff --git a/Lib/fontbakery/utils.py b/Lib/fontbakery/utils.py index afe2dc586b..992c12479d 100644 --- a/Lib/fontbakery/utils.py +++ b/Lib/fontbakery/utils.py @@ -732,3 +732,16 @@ def image_dimensions(filename): else: return None # some other file format + + +def can_shape(ttFont, text, parameters=None): + """ + Returns true if the font can render a text string without any + .notdef characters. + """ + from vharfbuzz import Vharfbuzz + + filename = ttFont.reader.file.name + vharfbuzz = Vharfbuzz(filename) + buf = vharfbuzz.shape(text, parameters) + return all(g.codepoint != 0 for g in buf.glyph_infos) diff --git a/tests/test_checks_glyphset.py b/tests/test_checks_glyphset.py index 92b99465dc..d37609e604 100644 --- a/tests/test_checks_glyphset.py +++ b/tests/test_checks_glyphset.py @@ -1,7 +1,6 @@ from fontTools.ttLib import TTFont from conftest import check_id -from fontbakery.checks.glyphset import can_shape from fontbakery.codetesting import ( assert_PASS, assert_results_contain, @@ -9,7 +8,7 @@ TEST_FILE, ) from fontbakery.status import FAIL -from fontbakery.utils import remove_cmap_entry +from fontbakery.utils import can_shape, remove_cmap_entry def test_can_shape():