diff --git a/tests/core/test_windows_fonts.py b/tests/core/test_windows_fonts.py new file mode 100644 index 00000000..1347e16d --- /dev/null +++ b/tests/core/test_windows_fonts.py @@ -0,0 +1,92 @@ +"""get_windows_fonts reads the fonts installed for all users and for the current user.""" + +from __future__ import annotations + +import sys +import types +from pathlib import Path, PureWindowsPath + +import pytest + +from unshackle.core import utilities + +HKLM, HKCU = "HKLM", "HKCU" + + +class Key(list): + def __enter__(self) -> Key: + return self + + def __exit__(self, *exc: object) -> None: + return None + + +@pytest.fixture +def registry(monkeypatch, tmp_path): + """Install a fake winreg whose hives are the returned dict, with the machine font folder in tmp_path.""" + hives: dict[str, list[tuple[str, object]]] = {} + + def open_key(hive: str, *_: object) -> Key: + if hive not in hives: + raise FileNotFoundError(hive) + return Key(hives[hive]) + + winreg = types.SimpleNamespace( + HKEY_LOCAL_MACHINE=HKLM, + HKEY_CURRENT_USER=HKCU, + KEY_READ=0, + OpenKey=open_key, + QueryInfoKey=lambda key: (0, len(key), 0), + EnumValue=lambda key, n: (*key[n], 1), + ) + monkeypatch.setitem(sys.modules, "winreg", winreg) + monkeypatch.setattr(sys, "platform", "win32") + (tmp_path / "machine").mkdir() + monkeypatch.setattr(utilities, "WINDOWS_FONTS_DIR", tmp_path / "machine") + monkeypatch.setattr(utilities, "extract_font_family", lambda path: None) + return hives + + +def font(path: Path) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"") + return path + + +def test_per_user_fonts_are_found(registry, tmp_path): + machine = font(tmp_path / "machine/arial.ttf") + user = font(tmp_path / "user/MyFont-Bold.ttf") + registry[HKLM] = [("Arial (TrueType)", "arial.ttf")] + registry[HKCU] = [("My Font Bold (TrueType)", str(user)), ("Arial (TrueType)", str(user))] + + fonts = utilities.get_windows_fonts() + assert fonts == {"Arial": machine, "My Font Bold": user} + assert utilities.find_font_with_fallbacks("My Font", fonts) == user + + +def test_a_font_is_found_by_the_family_name_in_its_file(registry, tmp_path, monkeypatch): + user = font(tmp_path / "user/My Font - Bold.ttf") + registry[HKCU] = [("MyFont-Bold (TrueType)", str(user))] + monkeypatch.setattr(utilities, "extract_font_family", lambda path: "My Font") + + fonts = utilities.get_windows_fonts() + assert fonts == {"MyFont-Bold": user, "My Font": user} + assert utilities.find_font_with_fallbacks("My Font", fonts) == user + + +def test_an_absolute_registry_value_replaces_the_font_folder(): + user = r"C:\Users\x\AppData\Local\Microsoft\Windows\Fonts\MyFont-Bold.ttf" + assert PureWindowsPath(r"C:\Windows\Fonts", user) == PureWindowsPath(user) + + +def test_missing_files_bitmap_fonts_and_odd_values_are_skipped(registry, tmp_path): + font(tmp_path / "machine/modern.fon") + collection = font(tmp_path / "machine/msgothic.ttc") + registry[HKLM] = [ + ("Modern (All res)", "modern.fon"), + ("MS Gothic & MS UI Gothic (TrueType)", "msgothic.ttc"), + ("Gone (TrueType)", "gone.ttf"), + ("Binary (TrueType)", b"x.ttf"), + ] + registry[HKCU] = [("Deleted (TrueType)", str(tmp_path / "user/Deleted.ttf"))] + assert utilities.get_windows_fonts() == {"MS Gothic & MS UI Gothic": collection} diff --git a/unshackle/core/utilities.py b/unshackle/core/utilities.py index 3ca01819..4180b0c5 100644 --- a/unshackle/core/utilities.py +++ b/unshackle/core/utilities.py @@ -838,9 +838,16 @@ def extract_font_family(font_path: Path) -> Optional[str]: return None +WINDOWS_FONTS_DIR = Path(r"C:\Windows\Fonts") + + def get_windows_fonts() -> dict[str, Path]: """ - Get fonts from Windows registry. + Get the fonts installed for all users and for the current user from the Windows registry. + + Only TrueType and OpenType files that exist are kept: a bitmap font cannot be attached, and a per-user entry + can outlive its file. Each font is also listed under the family name in its file, because a registry name + can differ from it (``GandhiSansPL-Bold`` for the family ``Gandhi Sans PL``). Returns: Dictionary mapping font family names to their file paths @@ -849,14 +856,24 @@ def get_windows_fonts() -> dict[str, Path]: raise OSError("Windows font registry is only available on Windows") import winreg - with winreg.ConnectRegistry(None, winreg.HKEY_LOCAL_MACHINE) as reg: - key = winreg.OpenKey(reg, r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts", 0, winreg.KEY_READ) - total_fonts = winreg.QueryInfoKey(key)[1] - return { - name.replace(" (TrueType)", ""): Path(r"C:\Windows\Fonts", filename) - for n in range(0, total_fonts) - for name, filename, _ in [winreg.EnumValue(key, n)] - } + fonts: dict[str, Path] = {} + for hive in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER): + try: + key = winreg.OpenKey(hive, r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts", 0, winreg.KEY_READ) + except OSError: + continue + with key: + for n in range(winreg.QueryInfoKey(key)[1]): + name, filename, _ = winreg.EnumValue(key, n) + if not isinstance(filename, str): + continue + path = Path(WINDOWS_FONTS_DIR, filename) + if path.suffix.lower() in {".ttf", ".otf", ".ttc"} and path.is_file(): + fonts.setdefault(name.replace(" (TrueType)", ""), path) + for path in dict.fromkeys(fonts.values()): + if family := extract_font_family(path): + fonts.setdefault(family, path) + return fonts def scan_font_directory(font_dir: Path, fonts: dict[str, Path], log: logging.Logger) -> None: