blob: 0bfd8834c2b880dade9cf46fc6d694ea06d2f092 [file] [log] [blame]
import collections
from fontbakery.utils import get_name_entry_strings
from fontTools import ttLib
from lxml import etree
from pathlib import Path
import pytest
from typing import Tuple
_KNOWN_PATHLESS = {
"NotoSansSymbols-Regular-Subsetted.ttf",
"NotoColorEmoji.ttf",
"NotoColorEmojiFlags.ttf",
"NotoSansSymbols-Regular-Subsetted2.ttf",
}
_POSTSCRIPT_NAME = 6
def _repo_root() -> Path:
root = (Path(__file__).parent / "..").absolute()
if not (root / "LICENSE").is_file():
raise IOError(f"{root} does not contain LICENSE")
return root
def _noto_4_android_file() -> Path:
xml_file = _repo_root() / "android-connection" / "noto-fonts-4-android.xml"
if not xml_file.is_file():
raise IOError(f"No file {xml_file}")
return xml_file
def _font_file(font_el) -> str:
return ("".join(font_el.itertext())).strip()
def _font_path(font_el) -> Path:
name = _font_file(font_el)
path = font_el.attrib["path"]
return _repo_root() / path / name
def _is_collection(font_el) -> bool:
return _font_file(font_el).lower().endswith(".ttc")
def _open_font(font_el) -> ttLib.TTFont:
path = _font_path(font_el)
if not path.is_file():
raise IOError(f"No such file: {path}")
if _is_collection(font_el):
return ttLib.TTFont(str(path), fontNumber=int(font_el.attrib["index"]))
return ttLib.TTFont(str(path))
def _open_font_path(path, fontNumber) -> ttLib.TTFont:
if not path.is_file():
raise IOError(f"No such file: {path}")
if str(path).lower().endswith(".ttc"):
return ttLib.TTFont(str(path), fontNumber=int(fontNumber))
return ttLib.TTFont(str(path))
def _axis(font, tag):
if "fvar" not in font:
return None
axes = tuple(a for a in font["fvar"].axes if a.axisTag == tag)
if not axes:
return None
assert len(axes) < 2, f"only 0 or 1 fvar entries supported; {tag} has more"
return axes[0]
def _weight(font: ttLib.TTFont) -> Tuple[int, int, int]:
maybe_wght = _axis(font, "wght")
if maybe_wght:
return (maybe_wght.minValue, maybe_wght.defaultValue, maybe_wght.maxValue)
os2_weight = font["OS/2"].usWeightClass
return (os2_weight, os2_weight, os2_weight)
def _psname(font_el) -> str:
# Not every font element will have postScriptName tag. If it's not present then it is assumeed
# that it's the filename less extension
psn = font_el.attrib.get("postScriptName", None)
if psn:
return psn
path = _font_path(font_el)
assert path.is_file(), f"{path} missing"
return path.stem
def test_fonts_have_path():
root = etree.parse(str(_noto_4_android_file()))
bad = []
for font in root.iter("font"):
font_file = _font_file(font)
if font_file in _KNOWN_PATHLESS:
assert "path" not in font.attrib, f"{font_file} not expected to have path. Correct _KNOWN_PATHLESS if you just added path"
continue
if not font.attrib.get("path", ""):
bad.append(font_file)
assert not bad, "Missing path attribute: " + ", ".join(bad)
def test_ttcs_have_index():
root = etree.parse(str(_noto_4_android_file()))
bad = []
for font in root.iter("font"):
if not _is_collection(font):
continue
if "index" not in font.attrib:
bad.append(_font_file(font))
assert not bad, "Missing index attribute: " + ", ".join(bad)
def test_font_paths_are_valid():
root = etree.parse(str(_noto_4_android_file()))
bad = []
for font in root.xpath("//font[@path]"):
path = _font_path(font)
if not path.is_file():
bad.append(str(path))
assert not bad, "No such file: " + ", ".join(bad)
def test_font_weights():
root = etree.parse(str(_noto_4_android_file()))
errors = []
for font_el in root.xpath("//font[@path]"):
xml_weight = int(font_el.attrib["weight"])
path = _font_path(font_el)
font = _open_font(font_el)
min_wght, default_wght, max_weight = _weight(font)
if xml_weight < min_wght or xml_weight > max_weight:
error_str = f"{_font_file(font_el)} weight {xml_weight} outside font capability {min_wght}..{max_weight}"
errors.append(error_str)
assert not errors, ", ".join(errors)
def test_font_full_weight_coverage():
root = etree.parse(str(_noto_4_android_file()))
errors = []
for family in root.iter("family"):
font_to_xml_weights = collections.defaultdict(set)
for font in family.xpath("//font[@path]"):
font_to_xml_weights[(_font_path(font), font.attrib.get("index", -1))].add(int(font.attrib["weight"]))
# now you have a map of font path => set of weights in xml
for (font_path, font_number), xml_weights in font_to_xml_weights.items():
# open the font, compute the 100 weights between it's min/max weight
# if xml_weights != computed weights add this to the error list
font = _open_font_path(font_path, font_number)
min_wght, default_wght, max_weight = _weight(font)
if min(xml_weights) > min_wght or max(xml_weights) < max_weight:
errors.append(f"{font_path} weight range {min(xml_weights)}..{max(xml_weights)} could be expanded to {min_wght}..{max_weight}")
assert not errors, ", ".join(errors)
def test_font_psnames():
root = etree.parse(str(_noto_4_android_file()))
errors = []
font_to_xml_psnames = collections.defaultdict(set)
for font_el in root.xpath("//font[@path]"):
path = _font_path(font_el)
psname = _psname(font_el)
font_to_xml_psnames[(path, font_el.attrib.get("index", -1))].add(str(psname))
for (font_path, font_number), xml_psnames in font_to_xml_psnames.items():
font = _open_font_path(font_path, font_number)
postscript_names = set(get_name_entry_strings(font, _POSTSCRIPT_NAME))
if len(postscript_names) != 1:
errors.append(f"font file {font_path} should have a single postScriptName and not {postscript_names}")
continue
for xml_psname in xml_psnames:
if not (xml_psname in postscript_names):
errors.append(f"postScriptName=\"{postscript_names[0]}\" in font file {font_path} doesn't match the entry in XML: {xml_psname}")
assert not errors, ", ".join(errors)