Files
OCRmyPDF/tests/test_system_font_provider.py
James R. Barlow 5d49f75c56 Use any installed Noto font when named families lack glyphs (closes #1722)
SystemFontProvider.NOTO_FONT_PATTERNS enumerates about two dozen Noto
families by name, and MultiFontManager only ever asked for those. Any
script outside that list fell through to the glyphless Occulta fallback
even when the correct font was installed, which is the common case on
macOS: it ships around a hundred script-specific Noto faces in
/System/Library/Fonts/Supplemental, almost none of which we knew how to
ask for. The reporter had the fonts and still got told to install them.

Add an optional GlyphSearchingFontProvider protocol (find_font_with_glyphs)
implemented by SystemFontProvider, BuiltinFontProvider and
ChainedFontProvider, and a new selection phase that uses it once the named
families fail. The system scan enumerates every Noto face, reusing the
existing variable-font/-Regular/-VF filename classification, and keeps
only the font it selects so a full scan does not retain every font file on
the system. Fonts found this way are remembered and retried by name for
later words. Capability detection is isinstance-based so third-party
providers keep working unchanged.

The warning itself was also unactionable: it named neither the characters
nor the script, which is why the reporter had to ask which package to
install. It now identifies what it could not render, e.g.
'Ꮳ' U+13E3 CHEROKEE LETTER TSA. A word mixing scripts that no single font
covers now gets a distinct message saying that installing fonts will not
help, rather than sending the user after fonts they already have.

Finally, the documented macOS install command was wrong: `brew install
font-noto` does not exist, since Homebrew has no single Noto package, only
a cask per family. The Fedora package name was also corrected, as
google-noto-fonts-common ships no actual fonts.
2026-07-26 23:25:51 -07:00

690 lines
27 KiB
Python

# SPDX-FileCopyrightText: 2025 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Unit tests for SystemFontProvider and ChainedFontProvider."""
from __future__ import annotations
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from ocrmypdf.font import (
BuiltinFontProvider,
ChainedFontProvider,
SystemFontProvider,
)
# --- SystemFontProvider Platform Detection Tests ---
class TestSystemFontProviderPlatform:
"""Test platform detection in SystemFontProvider."""
def test_get_platform_linux(self):
"""Test Linux platform detection."""
provider = SystemFontProvider()
with patch.object(sys, 'platform', 'linux'):
assert provider._get_platform() == 'linux'
def test_get_platform_darwin(self):
"""Test macOS platform detection."""
provider = SystemFontProvider()
with patch.object(sys, 'platform', 'darwin'):
assert provider._get_platform() == 'darwin'
def test_get_platform_windows(self):
"""Test Windows platform detection."""
provider = SystemFontProvider()
with patch.object(sys, 'platform', 'win32'):
assert provider._get_platform() == 'windows'
def test_get_platform_freebsd(self):
"""Test FreeBSD platform detection."""
provider = SystemFontProvider()
with patch.object(sys, 'platform', 'freebsd13'):
assert provider._get_platform() == 'freebsd'
class TestSystemFontProviderDirectories:
"""Test font directory resolution."""
def test_linux_font_dirs(self):
"""Test Linux font directories."""
provider = SystemFontProvider()
with patch.object(sys, 'platform', 'linux'):
provider._font_dirs = None # Reset cache
dirs = provider._get_font_dirs()
assert Path('/usr/share/fonts') in dirs
assert Path('/usr/local/share/fonts') in dirs
def test_darwin_font_dirs(self):
"""Test macOS font directories."""
provider = SystemFontProvider()
with patch.object(sys, 'platform', 'darwin'):
provider._font_dirs = None # Reset cache
dirs = provider._get_font_dirs()
assert Path('/Library/Fonts') in dirs
assert Path('/System/Library/Fonts') in dirs
def test_windows_font_dirs_with_windir(self):
"""Test Windows font directory from WINDIR env var."""
provider = SystemFontProvider()
with (
patch.object(sys, 'platform', 'win32'),
patch.dict('os.environ', {'WINDIR': r'D:\Windows'}),
):
provider._font_dirs = None # Reset cache
dirs = provider._get_font_dirs()
# Check that Fonts subdir of WINDIR is included
# Use str comparison to avoid Path normalization issues across platforms
dir_strs = [str(d) for d in dirs]
assert any('Fonts' in d for d in dir_strs)
def test_windows_font_dirs_default(self):
"""Test Windows font directory with default path."""
provider = SystemFontProvider()
with (
patch.object(sys, 'platform', 'win32'),
patch.dict('os.environ', {}, clear=True),
):
provider._font_dirs = None # Reset cache
dirs = provider._get_font_dirs()
# Check that Windows\Fonts is included (default fallback)
dir_strs = [str(d) for d in dirs]
assert any('Windows' in d and 'Fonts' in d for d in dir_strs)
def test_windows_font_dirs_with_localappdata(self):
"""Test Windows user fonts directory from LOCALAPPDATA env var."""
provider = SystemFontProvider()
with (
patch.object(sys, 'platform', 'win32'),
patch.dict(
'os.environ',
{
'WINDIR': r'C:\Windows',
'LOCALAPPDATA': r'C:\Users\Test\AppData\Local',
},
),
):
provider._font_dirs = None # Reset cache
dirs = provider._get_font_dirs()
dir_strs = [str(d) for d in dirs]
# Should have both system and user font directories
assert len(dirs) == 2
assert any('Windows' in d and 'Fonts' in d for d in dir_strs)
assert any(
'AppData' in d and 'Local' in d and 'Fonts' in d for d in dir_strs
)
def test_font_dirs_cached(self):
"""Test that font directories are cached."""
provider = SystemFontProvider()
dirs1 = provider._get_font_dirs()
dirs2 = provider._get_font_dirs()
assert dirs1 is dirs2 # Same object, not recomputed
class TestSystemFontProviderLazyLoading:
"""Test lazy loading behavior."""
def test_no_scanning_on_init(self):
"""Test that no directory scanning happens during initialization."""
provider = SystemFontProvider()
# Caches should be empty
assert len(provider._font_cache) == 0
assert len(provider._not_found) == 0
def test_get_font_unknown_name_returns_none(self):
"""Test that unknown font names return None."""
provider = SystemFontProvider()
result = provider.get_font('UnknownFont-Regular')
assert result is None
# Unknown fonts are added to not_found to cache the negative result
assert 'UnknownFont-Regular' in provider._not_found
def test_negative_cache(self):
"""Test that not-found results are cached."""
provider = SystemFontProvider()
# Mock _find_font_file to return None
with patch.object(provider, '_find_font_file', return_value=None):
result1 = provider.get_font('NotoSansCJK-Regular')
assert result1 is None
assert 'NotoSansCJK-Regular' in provider._not_found
# Second call should not call _find_font_file again
provider._find_font_file = MagicMock(return_value=None)
result2 = provider.get_font('NotoSansCJK-Regular')
assert result2 is None
provider._find_font_file.assert_not_called()
def test_positive_cache(self):
"""Test that found fonts are cached."""
provider = SystemFontProvider()
font_dir = Path(__file__).parent.parent / "src" / "ocrmypdf" / "data"
font_path = font_dir / "NotoSans-Regular.ttf"
if not font_path.exists():
pytest.skip("Test font not available")
with patch.object(provider, '_find_font_file', return_value=font_path):
result1 = provider.get_font('NotoSans-Regular')
assert result1 is not None
assert 'NotoSans-Regular' in provider._font_cache
# Second call should use cache
provider._find_font_file = MagicMock()
result2 = provider.get_font('NotoSans-Regular')
assert result2 is result1
provider._find_font_file.assert_not_called()
class TestSystemFontProviderAvailableFonts:
"""Test get_available_fonts method."""
def test_returns_all_patterns(self):
"""Test that get_available_fonts returns all known font patterns."""
provider = SystemFontProvider()
fonts = provider.get_available_fonts()
assert 'NotoSans-Regular' in fonts
assert 'NotoSansCJK-Regular' in fonts
assert 'NotoSansArabic-Regular' in fonts
assert 'NotoSansThai-Regular' in fonts
# Per-language CJK families (modern Google Fonts / Homebrew naming)
assert 'NotoSansSC-Regular' in fonts
assert 'NotoSansJP-Regular' in fonts
def test_fallback_font_raises(self):
"""Test that get_fallback_font raises NotImplementedError."""
provider = SystemFontProvider()
with pytest.raises(NotImplementedError):
provider.get_fallback_font()
class TestSystemFontProviderVariableFonts:
"""Test discovery of variable fonts and non-static filename variants.
Homebrew casks and Google Fonts ship Noto fonts as variable fonts with
bracketed axis filenames (e.g. ``NotoSansArabic[wdth,wght].ttf``) rather
than the static ``NotoSansArabic-Regular.ttf``. See issue #1652.
"""
@pytest.fixture
def real_font_bytes(self):
"""Bytes of a real, loadable font (content is irrelevant to the test)."""
font_path = (
Path(__file__).parent.parent
/ "src"
/ "ocrmypdf"
/ "data"
/ "NotoSans-Regular.ttf"
)
if not font_path.exists():
pytest.skip("Builtin font not available")
return font_path.read_bytes()
def _provider_for(self, tmp_path, filenames, real_font_bytes):
"""Build a provider whose only font dir is tmp_path with given files."""
for name in filenames:
(tmp_path / name).write_bytes(real_font_bytes)
provider = SystemFontProvider()
provider._font_dirs = [tmp_path]
return provider
def test_finds_variable_font_with_axes(self, tmp_path, real_font_bytes):
"""A bracketed variable font satisfies a request for the static name."""
provider = self._provider_for(
tmp_path, ['NotoSansArabic[wdth,wght].ttf'], real_font_bytes
)
font = provider.get_font('NotoSansArabic-Regular')
assert font is not None
assert font.font_path.name == 'NotoSansArabic[wdth,wght].ttf'
def test_finds_weight_only_variable_font(self, tmp_path, real_font_bytes):
"""A variable font with only a weight axis is also discovered."""
provider = self._provider_for(
tmp_path, ['NotoSansHebrew[wght].ttf'], real_font_bytes
)
assert provider.get_font('NotoSansHebrew-Regular') is not None
def test_variable_font_does_not_cross_match_other_script(
self, tmp_path, real_font_bytes
):
"""The generic NotoSans request must not match a script-specific font."""
provider = self._provider_for(
tmp_path, ['NotoSansArabic[wdth,wght].ttf'], real_font_bytes
)
# NotoSans (Latin) must NOT be satisfied by NotoSansArabic.
assert provider.get_font('NotoSans-Regular') is None
def test_does_not_match_ui_or_bold_variants(self, tmp_path, real_font_bytes):
"""Width/UI and weight variants must not satisfy the Regular request."""
provider = self._provider_for(
tmp_path,
['NotoSansArabicUI-Regular.ttf', 'NotoSansArabic-Bold.ttf'],
real_font_bytes,
)
assert provider.get_font('NotoSansArabic-Regular') is None
def test_prefers_static_regular_over_variable(self, tmp_path, real_font_bytes):
"""When both exist, the static Regular is preferred for predictability."""
provider = self._provider_for(
tmp_path,
['NotoSansArabic[wdth,wght].ttf', 'NotoSansArabic-Regular.ttf'],
real_font_bytes,
)
font = provider.get_font('NotoSansArabic-Regular')
assert font is not None
assert font.font_path.name == 'NotoSansArabic-Regular.ttf'
# --- Modern per-language CJK families (NotoSansSC/TC/HK/JP/KR) ---
# Homebrew casks (font-noto-sans-sc, ...) and Google Fonts ship CJK as
# variable fonts under these bases rather than the legacy NotoSansCJK*.
@pytest.mark.parametrize(
'filename',
[
'NotoSansSC[wght].ttf', # Simplified Chinese (Homebrew/Google)
'NotoSansTC[wght].ttf', # Traditional Chinese
'NotoSansHK[wght].ttf', # Hong Kong
'NotoSansJP[wght].ttf', # Japanese
'NotoSansKR[wght].ttf', # Korean
],
)
def test_finds_modern_cjk_variable_font(self, tmp_path, real_font_bytes, filename):
"""A modern per-language CJK variable font satisfies NotoSansCJK."""
provider = self._provider_for(tmp_path, [filename], real_font_bytes)
font = provider.get_font('NotoSansCJK-Regular')
assert font is not None
assert font.font_path.name == filename
def test_finds_static_cjk_language_variant(self, tmp_path, real_font_bytes):
"""A static per-language CJK Regular also satisfies NotoSansCJK."""
provider = self._provider_for(
tmp_path, ['NotoSansTC-Regular.otf'], real_font_bytes
)
assert provider.get_font('NotoSansCJK-Regular') is not None
def test_prefers_pan_cjk_over_language_variant(self, tmp_path, real_font_bytes):
"""The pan-CJK family is preferred over a single-language variant."""
provider = self._provider_for(
tmp_path,
['NotoSansSC[wght].ttf', 'NotoSansCJK[wght].ttf'],
real_font_bytes,
)
font = provider.get_font('NotoSansCJK-Regular')
assert font is not None
assert font.font_path.name == 'NotoSansCJK[wght].ttf'
def test_modern_cjk_does_not_cross_match_latin(self, tmp_path, real_font_bytes):
"""A CJK variable font must not satisfy the generic NotoSans request."""
provider = self._provider_for(
tmp_path, ['NotoSansSC[wght].ttf'], real_font_bytes
)
assert provider.get_font('NotoSans-Regular') is None
# --- Per-language CJK families reachable by their own logical name ---
# Needed so MultiFontManager can prefer the family matching the document
# language (NotoSansJP for Japanese, NotoSansSC for Simplified Chinese, ...).
@pytest.mark.parametrize(
'logical,filename',
[
('NotoSansSC-Regular', 'NotoSansSC[wght].ttf'),
('NotoSansTC-Regular', 'NotoSansTC[wght].ttf'),
('NotoSansHK-Regular', 'NotoSansHK[wght].ttf'),
('NotoSansJP-Regular', 'NotoSansJP[wght].ttf'),
('NotoSansKR-Regular', 'NotoSansKR[wght].ttf'),
],
)
def test_per_language_cjk_logical_name_resolves(
self, tmp_path, real_font_bytes, logical, filename
):
"""Each per-language CJK family is reachable by its own logical name."""
provider = self._provider_for(tmp_path, [filename], real_font_bytes)
font = provider.get_font(logical)
assert font is not None
assert font.font_path.name == filename
def test_per_language_cjk_static_resolves(self, tmp_path, real_font_bytes):
"""A static per-language Regular also resolves by logical name."""
provider = self._provider_for(
tmp_path, ['NotoSansJP-Regular.otf'], real_font_bytes
)
assert provider.get_font('NotoSansJP-Regular') is not None
def test_per_language_cjk_does_not_cross_match(self, tmp_path, real_font_bytes):
"""A JP font must not satisfy an SC request (distinct families)."""
provider = self._provider_for(
tmp_path, ['NotoSansJP[wght].ttf'], real_font_bytes
)
assert provider.get_font('NotoSansSC-Regular') is None
class TestSystemFontProviderUnlistedFamilies:
"""Test the coverage-driven search over Noto families we don't enumerate.
``NOTO_FONT_PATTERNS`` names only the couple dozen most common scripts, but
macOS ships ~100 script-specific Noto fonts and Homebrew/Linux distros offer
even more. Those fonts must still be usable when the enumerated families
cannot cover the text. See issue #1722.
"""
@pytest.fixture
def real_font_bytes(self):
"""Bytes of a real, loadable font covering ASCII."""
font_path = (
Path(__file__).parent.parent
/ "src"
/ "ocrmypdf"
/ "data"
/ "NotoSans-Regular.ttf"
)
if not font_path.exists():
pytest.skip("Builtin font not available")
return font_path.read_bytes()
def _provider_for(self, tmp_path, filenames, real_font_bytes):
"""Build a provider whose only font dir is tmp_path with given files."""
for name in filenames:
(tmp_path / name).write_bytes(real_font_bytes)
provider = SystemFontProvider()
provider._font_dirs = [tmp_path]
return provider
def test_finds_unlisted_family_by_coverage(self, tmp_path, real_font_bytes):
"""A Noto family absent from NOTO_FONT_PATTERNS is still usable."""
provider = self._provider_for(
tmp_path, ['NotoSansCherokee-Regular.ttf'], real_font_bytes
)
assert 'NotoSansCherokee-Regular' not in provider.NOTO_FONT_PATTERNS
found = provider.find_font_with_glyphs('A')
assert found is not None
name, font = found
assert name == 'NotoSansCherokee-Regular'
assert font.font_path.name == 'NotoSansCherokee-Regular.ttf'
def test_finds_unlisted_variable_family(self, tmp_path, real_font_bytes):
"""Bracketed variable filenames are eligible for the coverage search."""
provider = self._provider_for(
tmp_path, ['NotoSansVithkuqi[wght].ttf'], real_font_bytes
)
found = provider.find_font_with_glyphs('A')
assert found is not None
assert found[0] == 'NotoSansVithkuqi-Regular'
def test_discovered_font_resolves_by_logical_name(self, tmp_path, real_font_bytes):
"""A font found by coverage is afterwards reachable via get_font()."""
provider = self._provider_for(
tmp_path, ['NotoSansCherokee-Regular.ttf'], real_font_bytes
)
name, font = provider.find_font_with_glyphs('A')
assert provider.get_font(name) is font
def test_negative_cache_does_not_block_discovery(self, tmp_path, real_font_bytes):
"""A prior failed get_font() must not hide a later coverage match."""
provider = self._provider_for(
tmp_path, ['NotoSansCherokee-Regular.ttf'], real_font_bytes
)
assert provider.get_font('NotoSansCherokee-Regular') is None # not listed
name, font = provider.find_font_with_glyphs('A')
assert provider.get_font(name) is font
def test_skips_bold_and_italic_styles(self, tmp_path, real_font_bytes):
"""Only Regular/variable faces are candidates, never Bold or Italic."""
provider = self._provider_for(
tmp_path,
[
'NotoSansCherokee-Bold.ttf',
'NotoSansCherokee-Italic.ttf',
'NotoSans-Italic[wdth,wght].ttf',
],
real_font_bytes,
)
assert provider.find_font_with_glyphs('A') is None
def test_ignores_non_noto_fonts(self, tmp_path, real_font_bytes):
"""Non-Noto system fonts are not enlisted by the coverage search."""
provider = self._provider_for(
tmp_path, ['DejaVuSans.ttf', 'Arial.ttf'], real_font_bytes
)
assert provider.find_font_with_glyphs('A') is None
def test_returns_none_when_no_font_covers_text(self, tmp_path, real_font_bytes):
"""Text no installed font covers yields no match rather than a wrong one."""
provider = self._provider_for(
tmp_path, ['NotoSansCherokee-Regular.ttf'], real_font_bytes
)
# U+13A3 CHEROKEE LETTER O is absent from the Latin font's cmap.
assert provider.find_font_with_glyphs('Ꭳ') is None
def test_empty_text_does_not_match(self, tmp_path, real_font_bytes):
"""Empty text has nothing to cover, so no font is claimed for it."""
provider = self._provider_for(
tmp_path, ['NotoSansCherokee-Regular.ttf'], real_font_bytes
)
assert provider.find_font_with_glyphs('') is None
def test_unloadable_font_file_is_skipped(self, tmp_path, real_font_bytes):
"""A corrupt font file does not abort the search for a usable one."""
(tmp_path / 'NotoSansBroken-Regular.ttf').write_bytes(b'not a font')
provider = self._provider_for(
tmp_path, ['NotoSansCherokee-Regular.ttf'], real_font_bytes
)
found = provider.find_font_with_glyphs('A')
assert found is not None
assert found[0] == 'NotoSansCherokee-Regular'
@pytest.mark.parametrize(
'stem,expected',
[
('NotoSansCherokee-Regular', 'NotoSansCherokee'),
('NotoSansCherokee[wght]', 'NotoSansCherokee'),
('NotoSansArabic[wdth,wght]', 'NotoSansArabic'),
('NotoSansCJKsc-VF', 'NotoSansCJKsc'),
('NotoMusic', 'NotoMusic'),
('NotoSansCJK-Regular', 'NotoSansCJK'),
('NotoSans-Bold', None),
('NotoSans-Italic[wdth,wght]', None),
('NotoSans-SemiCondensedBlackItalic', None),
('DejaVuSans-Regular', None),
],
)
def test_family_base_parsing(self, stem, expected):
"""Filename stems map to family bases, rejecting non-Regular styles."""
assert SystemFontProvider._family_base(stem) == expected
# --- ChainedFontProvider Tests ---
class TestChainedFontProvider:
"""Test ChainedFontProvider."""
def test_requires_at_least_one_provider(self):
"""Test that empty provider list raises error."""
with pytest.raises(ValueError, match="At least one provider"):
ChainedFontProvider([])
def test_get_font_tries_providers_in_order(self):
"""Test that get_font tries providers in order."""
provider1 = MagicMock()
provider1.get_font.return_value = None
provider2 = MagicMock()
mock_font = MagicMock()
provider2.get_font.return_value = mock_font
chain = ChainedFontProvider([provider1, provider2])
result = chain.get_font('TestFont')
provider1.get_font.assert_called_once_with('TestFont')
provider2.get_font.assert_called_once_with('TestFont')
assert result is mock_font
def test_get_font_stops_on_first_match(self):
"""Test that get_font stops after first successful match."""
mock_font = MagicMock()
provider1 = MagicMock()
provider1.get_font.return_value = mock_font
provider2 = MagicMock()
chain = ChainedFontProvider([provider1, provider2])
result = chain.get_font('TestFont')
provider1.get_font.assert_called_once()
provider2.get_font.assert_not_called()
assert result is mock_font
def test_get_font_returns_none_if_all_fail(self):
"""Test that get_font returns None if all providers fail."""
provider1 = MagicMock()
provider1.get_font.return_value = None
provider2 = MagicMock()
provider2.get_font.return_value = None
chain = ChainedFontProvider([provider1, provider2])
result = chain.get_font('TestFont')
assert result is None
def test_get_available_fonts_combines_providers(self):
"""Test that get_available_fonts combines all providers."""
provider1 = MagicMock()
provider1.get_available_fonts.return_value = ['Font1', 'Font2']
provider2 = MagicMock()
provider2.get_available_fonts.return_value = ['Font2', 'Font3']
chain = ChainedFontProvider([provider1, provider2])
fonts = chain.get_available_fonts()
assert fonts == ['Font1', 'Font2', 'Font3'] # Deduplicated, order preserved
def test_get_fallback_font_from_first_provider(self):
"""Test that get_fallback_font uses first available fallback."""
mock_font = MagicMock()
provider1 = MagicMock()
provider1.get_fallback_font.return_value = mock_font
provider2 = MagicMock()
chain = ChainedFontProvider([provider1, provider2])
result = chain.get_fallback_font()
assert result is mock_font
provider2.get_fallback_font.assert_not_called()
def test_get_fallback_font_skips_not_implemented(self):
"""Test that get_fallback_font skips providers that raise."""
provider1 = MagicMock()
provider1.get_fallback_font.side_effect = NotImplementedError()
mock_font = MagicMock()
provider2 = MagicMock()
provider2.get_fallback_font.return_value = mock_font
chain = ChainedFontProvider([provider1, provider2])
result = chain.get_fallback_font()
assert result is mock_font
def test_get_fallback_font_raises_if_none_available(self):
"""Test that get_fallback_font raises if no provider has fallback."""
provider1 = MagicMock()
provider1.get_fallback_font.side_effect = NotImplementedError()
provider2 = MagicMock()
provider2.get_fallback_font.side_effect = KeyError()
chain = ChainedFontProvider([provider1, provider2])
with pytest.raises(RuntimeError, match="No fallback font available"):
chain.get_fallback_font()
class TestChainedFontProviderIntegration:
"""Integration tests with real providers."""
@pytest.fixture
def font_dir(self):
"""Return path to font directory."""
return Path(__file__).parent.parent / "src" / "ocrmypdf" / "data"
def test_builtin_then_system_chain(self, font_dir):
"""Test chaining BuiltinFontProvider with SystemFontProvider."""
builtin = BuiltinFontProvider(font_dir)
system = SystemFontProvider()
chain = ChainedFontProvider([builtin, system])
# Should find NotoSans from builtin
font = chain.get_font('NotoSans-Regular')
assert font is not None
# Should get fallback from builtin
fallback = chain.get_fallback_font()
assert fallback is not None
def test_system_fonts_extend_builtin(self, font_dir):
"""Test that system fonts add to builtin fonts."""
builtin = BuiltinFontProvider(font_dir)
system = SystemFontProvider()
chain = ChainedFontProvider([builtin, system])
builtin_fonts = set(builtin.get_available_fonts())
chain_fonts = set(chain.get_available_fonts())
# Chain should have at least as many fonts as builtin
assert chain_fonts >= builtin_fonts
class TestChainedFontProviderCoverageSearch:
"""Test that the chain delegates the coverage search to its members."""
class _Searchable:
"""Provider stub that reports one findable font."""
def __init__(self, result):
self.result = result
self.calls = 0
def get_font(self, name):
return None
def get_available_fonts(self):
return []
def get_fallback_font(self):
raise NotImplementedError
def find_font_with_glyphs(self, text):
self.calls += 1
return self.result
def test_delegates_to_first_provider_that_finds_a_font(self):
"""The first provider with a match wins; later ones are not consulted."""
first = self._Searchable(('NotoSansX-Regular', MagicMock()))
second = self._Searchable(('NotoSansY-Regular', MagicMock()))
chain = ChainedFontProvider([first, second])
assert chain.find_font_with_glyphs('x')[0] == 'NotoSansX-Regular'
assert second.calls == 0
def test_skips_providers_without_the_capability(self):
"""Providers lacking find_font_with_glyphs() are skipped, not fatal."""
legacy = MagicMock(spec=['get_font', 'get_available_fonts'])
searchable = self._Searchable(('NotoSansX-Regular', MagicMock()))
chain = ChainedFontProvider([legacy, searchable])
assert chain.find_font_with_glyphs('x')[0] == 'NotoSansX-Regular'
def test_returns_none_when_nothing_matches(self):
"""No provider matching yields None so the caller can use Occulta."""
chain = ChainedFontProvider([self._Searchable(None)])
assert chain.find_font_with_glyphs('x') is None