import os
from typing import List
from collections import Counter
from fontbakery.callable import condition
# used to inform get_module_profile whether and how to create a profile
from fontbakery.fonts_profile import profile_factory # noqa:F401 pylint:disable=W0611
[docs]@condition
def ttFont(font):
from fontTools.ttLib import TTFont
return TTFont(font)
[docs]@condition
def is_ttf(ttFont):
return "glyf" in ttFont
[docs]@condition
def are_ttf(ttFonts):
for f in ttFonts:
if not is_ttf(f):
return False
# otherwise:
return True
[docs]@condition
def is_cff(ttFont):
return "CFF " in ttFont
[docs]@condition
def is_cff2(ttFont):
return "CFF2" in ttFont
[docs]@condition
def has_name_table(ttFont):
return "name" in ttFont.keys()
[docs]@condition
def has_os2_table(ttFont):
return "OS/2" in ttFont.keys()
[docs]@condition
def has_STAT_table(ttFont):
return "STAT" in ttFont
# -------------------------------------------------------------------
# FIXME! Redundant with @condition GF's canonical_stylename(font)?
[docs]@condition
def style(font):
"""Determine font style from canonical filename."""
from fontbakery.constants import STATIC_STYLE_NAMES
from fontTools.ttLib import TTFont
acceptable_stylenames = [name.replace(" ", "") for name in STATIC_STYLE_NAMES]
filename = os.path.basename(font)
# VF
ttFont = TTFont(font)
if is_variable_font(ttFont):
if default_wght_coord(ttFont) == 700.0:
if "Italic" in filename:
return "BoldItalic"
else:
return "Bold"
else:
if "Italic" in filename:
return "Italic"
else:
return "Regular"
# Static
elif "-" in filename:
stylename = os.path.splitext(filename)[0].split("-")[1]
if stylename in acceptable_stylenames:
return stylename
return None
[docs]@condition
def variable_font_filename(ttFont):
from fontbakery.utils import get_name_entry_strings
from fontbakery.constants import MacStyle, NameID
familynames = get_name_entry_strings(ttFont, NameID.FONT_FAMILY_NAME)
typo_familynames = get_name_entry_strings(ttFont, NameID.TYPOGRAPHIC_FAMILY_NAME)
if not familynames:
return None
familyname = typo_familynames[0] if typo_familynames else familynames[0]
familyname = "".join(familyname.split(" ")) # remove spaces
if bool(ttFont["head"].macStyle & MacStyle.ITALIC):
familyname += "-Italic"
tags = ttFont["fvar"].axes
tags = list(map(lambda t: t.axisTag, tags))
tags.sort()
tags = "[{}]".format(",".join(tags))
return f"{familyname}{tags}.ttf"
[docs]@condition
def family_directory(font):
"""Get the path of font project directory."""
if font:
dirname = os.path.dirname(font)
if dirname == "":
dirname = "."
return dirname
[docs]@condition
def sibling_directories(family_directory):
"""
Given a directory, this function tries to figure out where else in the filesystem
other related "sibling" families might be located.
This is guesswork and may not be able to find font files in other folders not yet
covered by this routine. We may improve this in the future by adding other
smarter filesystem lookup procedures or even by letting the user feed explicit
sibling family paths.
This function returs a list of paths to directories where related font files were
detected.
"""
SIBLING_SUFFIXES = ["sans", "sc", "narrow", "text", "display", "condensed"]
base_family_dir = family_directory
for suffix in SIBLING_SUFFIXES:
if family_directory.endswith(suffix):
candidate = family_directory[: -len(suffix)]
if os.path.isdir(candidate):
base_family_dir = candidate
break
directories = [base_family_dir]
for suffix in SIBLING_SUFFIXES:
candidate = base_family_dir + suffix
if os.path.isdir(candidate):
directories.append(candidate)
return directories
[docs]@condition
def superfamily(sibling_directories):
"""
Given a list of directories, this functions looks for font files
and returs a list of lists of the detected filepaths.
"""
result = []
for family_dir in sibling_directories:
filepaths = []
for entry in os.listdir(family_dir):
if entry[-4:] in [".otf", ".ttf"]:
filepaths.append(os.path.join(family_dir, entry))
result.append(filepaths)
return result
[docs]@condition
def superfamily_ttFonts(superfamily):
from fontTools.ttLib import TTFont
result = []
for family in superfamily:
result.append([TTFont(f) for f in family])
return result
[docs]@condition
def ligatures(ttFont):
from fontTools.ttLib.tables.otTables import LigatureSubst
all_ligatures = {}
try:
if "GSUB" in ttFont and ttFont["GSUB"].table.LookupList:
for record in ttFont["GSUB"].table.FeatureList.FeatureRecord:
if record.FeatureTag == "liga":
for index in record.Feature.LookupListIndex:
lookup = ttFont["GSUB"].table.LookupList.Lookup[index]
for subtable in lookup.SubTable:
if isinstance(subtable, LigatureSubst):
for firstGlyph in subtable.ligatures.keys():
all_ligatures[firstGlyph] = []
for lig in subtable.ligatures[firstGlyph]:
if (
lig.Component
not in all_ligatures[firstGlyph]
):
all_ligatures[firstGlyph].append(
lig.Component
)
return all_ligatures
except (AttributeError, IndexError):
return -1 # Indicate fontTools-related crash...
[docs]@condition
def ligature_glyphs(ttFont):
from fontTools.ttLib.tables.otTables import LigatureSubst
all_ligature_glyphs = []
try:
if "GSUB" in ttFont and ttFont["GSUB"].table.LookupList:
for record in ttFont["GSUB"].table.FeatureList.FeatureRecord:
if record.FeatureTag == "liga":
for index in record.Feature.LookupListIndex:
lookup = ttFont["GSUB"].table.LookupList.Lookup[index]
for subtable in lookup.SubTable:
if isinstance(subtable, LigatureSubst):
for firstGlyph in subtable.ligatures.keys():
for lig in subtable.ligatures[firstGlyph]:
if lig.LigGlyph not in all_ligature_glyphs:
all_ligature_glyphs.append(lig.LigGlyph)
return all_ligature_glyphs
except (AttributeError, IndexError):
return -1 # Indicate fontTools-related crash...
[docs]@condition
def glyph_metrics_stats(ttFont):
"""Returns a dict containing whether the font seems_monospaced,
what's the maximum glyph width and what's the most common width.
For a font to be considered monospaced, if at least 80% of ASCII
characters have glyphs, then at least 80% of those must have the same
width, otherwise all glyphs of printable characters must have one of
two widths or be zero-width.
"""
glyph_metrics = ttFont["hmtx"].metrics
# NOTE: `range(a, b)` includes `a` and does not include `b`.
# Here we don't include 0-31 as well as 127
# because these are control characters.
ascii_glyph_names = [
ttFont.getBestCmap()[c] for c in range(32, 127) if c in ttFont.getBestCmap()
]
if len(ascii_glyph_names) > 0.8 * (127 - 32):
ascii_widths = [
adv
for name, (adv, lsb) in glyph_metrics.items()
if name in ascii_glyph_names and adv != 0
]
ascii_width_count = Counter(ascii_widths)
ascii_most_common_width = ascii_width_count.most_common(1)[0][1]
seems_monospaced = ascii_most_common_width >= len(ascii_widths) * 0.8
else:
from fontTools import unicodedata
# Collect relevant glyphs.
relevant_glyph_names = set()
# Add character glyphs that are in one of these categories:
# Letter, Mark, Number, Punctuation, Symbol, Space_Separator.
# This excludes Line_Separator, Paragraph_Separator and Control.
for value, name in ttFont.getBestCmap().items():
if unicodedata.category(chr(value)).startswith(
("L", "M", "N", "P", "S", "Zs")
):
relevant_glyph_names.add(name)
# Remove character glyphs that are mark glyphs.
gdef = ttFont.get("GDEF")
if gdef and gdef.table.GlyphClassDef:
marks = {
name for name, c in gdef.table.GlyphClassDef.classDefs.items() if c == 3
}
relevant_glyph_names.difference_update(marks)
widths = sorted(
{
adv
for name, (adv, lsb) in glyph_metrics.items()
if name in relevant_glyph_names and adv != 0
}
)
seems_monospaced = len(widths) <= 2
width_max = max(adv for k, (adv, lsb) in glyph_metrics.items())
most_common_width = Counter(
[g for g in glyph_metrics.values() if g[0] != 0]
).most_common(1)[0][0][0]
return {
"seems_monospaced": seems_monospaced,
"width_max": width_max,
"most_common_width": most_common_width,
}
[docs]@condition
def missing_whitespace_chars(ttFont):
from fontbakery.utils import get_glyph_name
space = get_glyph_name(ttFont, 0x0020)
nbsp = get_glyph_name(ttFont, 0x00A0)
# tab = get_glyph_name(ttFont, 0x0009)
missing = []
if space is None:
missing.append("0x0020")
if nbsp is None:
missing.append("0x00A0")
# fonts probably don't need an actual tab char
# if tab is None: missing.append("0x0009")
return missing
[docs]@condition
def vmetrics(ttFonts):
from fontbakery.utils import get_bounding_box
v_metrics = {"ymin": 0, "ymax": 0}
for ttFont in ttFonts:
font_ymin, font_ymax = get_bounding_box(ttFont)
v_metrics["ymin"] = min(font_ymin, v_metrics["ymin"])
v_metrics["ymax"] = max(font_ymax, v_metrics["ymax"])
return v_metrics
[docs]@condition
def is_hinted(ttFont):
return "fpgm" in ttFont
[docs]@condition
def is_variable_font(ttFont):
return "fvar" in ttFont.keys()
[docs]@condition
def VFs(ttFonts):
"""Returns a list of font files which are recognized as variable fonts"""
return [ttFont for ttFont in ttFonts if is_variable_font(ttFont)]
[docs]@condition
def slnt_axis(ttFont):
if "fvar" in ttFont:
for axis in ttFont["fvar"].axes:
if axis.axisTag == "slnt":
return axis
[docs]@condition
def opsz_axis(ttFont):
if "fvar" in ttFont:
for axis in ttFont["fvar"].axes:
if axis.axisTag == "opsz":
return axis
[docs]@condition
def ital_axis(ttFont):
if "fvar" in ttFont:
for axis in ttFont["fvar"].axes:
if axis.axisTag == "ital":
return axis
[docs]@condition
def grad_axis(ttFont):
if "fvar" in ttFont:
for axis in ttFont["fvar"].axes:
if axis.axisTag == "GRAD":
return axis
[docs]@condition
def has_wght_axis(ttFont):
if is_variable_font(ttFont) and "wght" in get_axis_tags_set(ttFont):
return True
[docs]@condition
def has_wdth_axis(ttFont):
if is_variable_font(ttFont) and "wdth" in get_axis_tags_set(ttFont):
return True
[docs]@condition
def has_slnt_axis(ttFont):
if is_variable_font(ttFont) and "slnt" in get_axis_tags_set(ttFont):
return True
[docs]@condition
def has_ital_axis(ttFont):
if is_variable_font(ttFont) and "ital" in get_axis_tags_set(ttFont):
return True
[docs]@condition
def has_opsz_axis(ttFont):
if is_variable_font(ttFont) and "opsz" in get_axis_tags_set(ttFont):
return True
[docs]def get_instance_axis_value(ttFont, instance_name, axis_tag):
if not is_variable_font(ttFont):
return None
instance = None
for i in ttFont["fvar"].instances:
name = ttFont["name"].getDebugName(i.subfamilyNameID)
if name == instance_name:
instance = i
break
if instance:
for axis in ttFont["fvar"].axes:
if axis.axisTag == axis_tag:
return instance.coordinates[axis_tag]
[docs]@condition
def regular_wght_coord(ttFont):
upright = get_instance_axis_value(ttFont, "Regular", "wght")
italic = get_instance_axis_value(ttFont, "Italic", "wght")
# Note: you cannot simply do `return upright or italic` since `0 or None`
# will return None in Python.
return upright if upright is not None else italic
[docs]@condition
def default_wght_coord(ttFont):
for a in ttFont["fvar"].axes:
if a.axisTag == "wght":
return a.defaultValue
[docs]@condition
def bold_wght_coord(ttFont):
upright = get_instance_axis_value(ttFont, "Bold", "wght")
italic = get_instance_axis_value(ttFont, "Bold Italic", "wght")
# Note: you cannot simply do `return upright or italic` since `0 or None`
# will return None in Python.
return upright if upright is not None else italic
[docs]@condition
def regular_wdth_coord(ttFont):
upright = get_instance_axis_value(ttFont, "Regular", "wdth")
italic = get_instance_axis_value(ttFont, "Italic", "wdth")
# Note: you cannot simply do `return upright or italic` since `0 or None`
# will return None in Python.
return upright if upright is not None else italic
[docs]@condition
def regular_slnt_coord(ttFont):
return get_instance_axis_value(ttFont, "Regular", "slnt")
[docs]@condition
def regular_ital_coord(ttFont):
return get_instance_axis_value(ttFont, "Regular", "ital")
[docs]@condition
def regular_opsz_coord(ttFont):
upright = get_instance_axis_value(ttFont, "Regular", "opsz")
italic = get_instance_axis_value(ttFont, "Italic", "opsz")
# Note: you cannot simply do `return upright or italic` since `0 or None`
# will return None in Python.
return upright if upright is not None else italic
[docs]@condition
def vtt_talk_sources(ttFont) -> List[str]:
"""Return the tags of VTT source tables found in a font."""
VTT_SOURCE_TABLES = {"TSI0", "TSI1", "TSI2", "TSI3", "TSI5"}
tables_found = [tag for tag in ttFont.keys() if tag in VTT_SOURCE_TABLES]
return tables_found
[docs]@condition
def preferred_cmap(ttFont):
from fontbakery.utils import get_preferred_cmap
return get_preferred_cmap(ttFont)
[docs]@condition
def unicoderange(ttFont):
"""Get an integer bitmap representing the UnicodeRange fields in the os/2 table."""
os2 = ttFont["OS/2"]
return (
os2.ulUnicodeRange1
| os2.ulUnicodeRange2 << 32
| os2.ulUnicodeRange3 << 64
| os2.ulUnicodeRange4 << 96
)
[docs]@condition
def is_claiming_to_be_cjk_font(ttFont):
"""Test font object to confirm that it meets our definition of a CJK font file.
We do this in two ways: in some cases, we are testing the *metadata*,
i.e. what the font claims about itself, in which case the definition is
met if any of the following conditions are True:
1. The font has a CJK code page bit set in the OS/2 table
2. The font has a CJK Unicode range bit set in the OS/2 table
See below for another way of testing this.
"""
from fontbakery.constants import (
CJK_CODEPAGE_BITS,
CJK_UNICODE_RANGE_BITS,
)
if not has_os2_table(ttFont):
return
os2 = ttFont["OS/2"]
# OS/2 code page checks
for _, bit in CJK_CODEPAGE_BITS.items():
if os2.ulCodePageRange1 & (1 << bit):
return True
# OS/2 Unicode range checks
for _, bit in CJK_UNICODE_RANGE_BITS.items():
if bit in range(0, 32):
if os2.ulUnicodeRange1 & (1 << bit):
return True
elif bit in range(32, 64):
if os2.ulUnicodeRange2 & (1 << (bit - 32)):
return True
elif bit in range(64, 96):
if os2.ulUnicodeRange3 & (1 << (bit - 64)):
return True
# default, return False if the above checks did not identify a CJK font
return False
[docs]@condition
def is_cjk_font(ttFont):
"""
The `is_claiming_to_be_cjk_font` condition looks up the font's metadata to see if
it is claiming to be a CJK font. But the metadata may be wrong, and the correctness
of the metadata is something what we want to check!
We also want to know if the font really is a CJK font, i.e. it contains a
significant number of CJK characters. We say that *this* definition is met if the
font has more than 150 CJK Unicode code points defined in the cmap table.
"""
return len(get_cjk_glyphs(ttFont)) > 150
[docs]@condition
def get_cjk_glyphs(ttFont):
"""Return all glyphs which belong to a CJK unicode block"""
from fontbakery.constants import CJK_UNICODE_RANGES
results = []
cjk_unicodes = set()
for start, end in CJK_UNICODE_RANGES:
cjk_unicodes |= set(u for u in range(start, end + 1))
for uni, glyph_name in ttFont.getBestCmap().items():
if uni in cjk_unicodes:
results.append(glyph_name)
return results
[docs]@condition
def typo_metrics_enabled(ttFont):
return ttFont["OS/2"].fsSelection & 0b10000000 > 0
[docs]@condition
def is_indic_font(ttFont):
INDIC_FONT_DETECTION_CODEPOINTS = [
0x0988, # Bengali
0x0908, # Devanagari
0x0A88, # Gujarati
0x0A08, # Gurmukhi
0x0D08, # Kannada
0x0B08, # Malayalam
0xABC8, # Meetei Mayek
0x1C58, # OlChiki
0x0B08, # Oriya
0x0B88, # Tamil
0x0C08, # Telugu
]
font_codepoints = ttFont["cmap"].getBestCmap().keys()
for codepoint in INDIC_FONT_DETECTION_CODEPOINTS:
if codepoint in font_codepoints:
return True
# otherwise:
return False
[docs]def keyword_in_full_font_name(ttFont, keyword):
from fontbakery.constants import NameID
for entry in ttFont["name"].names:
if (
entry.nameID == NameID.FULL_FONT_NAME
and keyword in entry.string.decode(entry.getEncoding()).lower().split()
):
return True
return False
[docs]@condition
def is_italic(ttFont):
from fontbakery.constants import FsSelection, MacStyle
return (
("OS/2" in ttFont and ttFont["OS/2"].fsSelection & FsSelection.ITALIC)
or ("head" in ttFont and ttFont["head"].macStyle & MacStyle.ITALIC)
or keyword_in_full_font_name(ttFont, "italic")
or ("post" in ttFont and ttFont["post"].italicAngle)
)
[docs]@condition
def is_bold(ttFont):
from fontbakery.constants import FsSelection, MacStyle
return (
("OS/2" in ttFont and ttFont["OS/2"].fsSelection & FsSelection.BOLD)
or ("head" in ttFont and ttFont["head"].macStyle & MacStyle.BOLD)
or keyword_in_full_font_name(ttFont, "bold")
)