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.
686 lines
27 KiB
Python
686 lines
27 KiB
Python
# SPDX-FileCopyrightText: 2025 James R. Barlow
|
|
# SPDX-License-Identifier: MPL-2.0
|
|
|
|
"""Unit tests for MultiFontManager and FontProvider."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from ocrmypdf.font import BuiltinFontProvider, FontManager, MultiFontManager
|
|
|
|
|
|
@pytest.fixture
|
|
def font_dir():
|
|
"""Return path to font directory."""
|
|
return Path(__file__).parent.parent / "src" / "ocrmypdf" / "data"
|
|
|
|
|
|
@pytest.fixture
|
|
def multi_font_manager(font_dir):
|
|
"""Create MultiFontManager instance for testing."""
|
|
return MultiFontManager(font_dir)
|
|
|
|
|
|
def has_cjk_font(manager: MultiFontManager) -> bool:
|
|
"""Check if CJK font is available (from system)."""
|
|
return 'NotoSansCJK-Regular' in manager.fonts
|
|
|
|
|
|
def has_arabic_font(manager: MultiFontManager) -> bool:
|
|
"""Check if Arabic font is available (from system)."""
|
|
return 'NotoSansArabic-Regular' in manager.fonts
|
|
|
|
|
|
def has_devanagari_font(manager: MultiFontManager) -> bool:
|
|
"""Check if Devanagari font is available (from system)."""
|
|
return 'NotoSansDevanagari-Regular' in manager.fonts
|
|
|
|
|
|
# Marker for tests that require CJK fonts
|
|
requires_cjk = pytest.mark.skipif(
|
|
"not has_cjk_font(MultiFontManager())",
|
|
reason="CJK font not available (not installed on system)",
|
|
)
|
|
|
|
|
|
# --- MultiFontManager Initialization Tests ---
|
|
|
|
|
|
def test_init_loads_builtin_fonts(multi_font_manager):
|
|
"""Test that initialization loads all expected builtin fonts."""
|
|
# Only NotoSans-Regular and Occulta are bundled
|
|
assert 'NotoSans-Regular' in multi_font_manager.fonts
|
|
assert 'Occulta' in multi_font_manager.fonts
|
|
|
|
# At least 2 builtin fonts should be loaded
|
|
assert len(multi_font_manager.fonts) >= 2
|
|
|
|
# Arabic, Devanagari, CJK are optional (system fonts)
|
|
|
|
|
|
def test_missing_font_directory():
|
|
"""Test that missing font directory raises error for fallback font."""
|
|
with pytest.raises(FileNotFoundError):
|
|
MultiFontManager(Path("/nonexistent/path"))
|
|
|
|
|
|
# --- Arabic Script Language Tests ---
|
|
# These tests require Arabic fonts to be installed on the system
|
|
|
|
|
|
def test_select_font_for_arabic_language(multi_font_manager):
|
|
"""Test font selection with Arabic language hint."""
|
|
if not has_arabic_font(multi_font_manager):
|
|
pytest.skip("Arabic font not available")
|
|
font_manager = multi_font_manager.select_font_for_word("مرحبا", "ara")
|
|
assert font_manager == multi_font_manager.fonts['NotoSansArabic-Regular']
|
|
|
|
|
|
def test_select_font_for_persian_language(multi_font_manager):
|
|
"""Test font selection with Persian language hint."""
|
|
if not has_arabic_font(multi_font_manager):
|
|
pytest.skip("Arabic font not available")
|
|
font_manager = multi_font_manager.select_font_for_word("سلام", "per")
|
|
assert font_manager == multi_font_manager.fonts['NotoSansArabic-Regular']
|
|
|
|
|
|
def test_select_font_for_urdu_language(multi_font_manager):
|
|
"""Test font selection with Urdu language hint."""
|
|
if not has_arabic_font(multi_font_manager):
|
|
pytest.skip("Arabic font not available")
|
|
font_manager = multi_font_manager.select_font_for_word("ہیلو", "urd")
|
|
assert font_manager == multi_font_manager.fonts['NotoSansArabic-Regular']
|
|
|
|
|
|
def test_farsi_language_code(multi_font_manager):
|
|
"""Test that 'fas' (Farsi alternative code) maps to Arabic font."""
|
|
if not has_arabic_font(multi_font_manager):
|
|
pytest.skip("Arabic font not available")
|
|
font_manager = multi_font_manager.select_font_for_word("سلام", "fas")
|
|
assert font_manager == multi_font_manager.fonts['NotoSansArabic-Regular']
|
|
|
|
|
|
# --- Devanagari Script Language Tests ---
|
|
# These tests require Devanagari fonts to be installed on the system
|
|
|
|
|
|
def test_select_font_for_hindi_language(multi_font_manager):
|
|
"""Test font selection with Hindi language hint."""
|
|
if not has_devanagari_font(multi_font_manager):
|
|
pytest.skip("Devanagari font not available")
|
|
font_manager = multi_font_manager.select_font_for_word("नमस्ते", "hin")
|
|
assert font_manager == multi_font_manager.fonts['NotoSansDevanagari-Regular']
|
|
|
|
|
|
def test_select_font_for_sanskrit_language(multi_font_manager):
|
|
"""Test font selection with Sanskrit language hint."""
|
|
if not has_devanagari_font(multi_font_manager):
|
|
pytest.skip("Devanagari font not available")
|
|
font_manager = multi_font_manager.select_font_for_word("संस्कृतम्", "san")
|
|
assert font_manager == multi_font_manager.fonts['NotoSansDevanagari-Regular']
|
|
|
|
|
|
def test_select_font_for_marathi_language(multi_font_manager):
|
|
"""Test font selection with Marathi language hint."""
|
|
if not has_devanagari_font(multi_font_manager):
|
|
pytest.skip("Devanagari font not available")
|
|
font_manager = multi_font_manager.select_font_for_word("मराठी", "mar")
|
|
assert font_manager == multi_font_manager.fonts['NotoSansDevanagari-Regular']
|
|
|
|
|
|
def test_select_font_for_nepali_language(multi_font_manager):
|
|
"""Test font selection with Nepali language hint."""
|
|
if not has_devanagari_font(multi_font_manager):
|
|
pytest.skip("Devanagari font not available")
|
|
font_manager = multi_font_manager.select_font_for_word("नेपाली", "nep")
|
|
assert font_manager == multi_font_manager.fonts['NotoSansDevanagari-Regular']
|
|
|
|
|
|
# --- CJK Language Tests ---
|
|
# These tests require CJK fonts to be installed on the system
|
|
|
|
|
|
def test_select_font_for_chinese_language(multi_font_manager):
|
|
"""Test font selection with Chinese language hint (ISO 639-3)."""
|
|
if not has_cjk_font(multi_font_manager):
|
|
pytest.skip("CJK font not available")
|
|
font_manager = multi_font_manager.select_font_for_word("你好", "zho")
|
|
# A real, glyph-covering CJK font is selected (which specific family
|
|
# depends on what is installed: pan-CJK super font or a per-language subset).
|
|
assert font_manager.font_path.name != 'Occulta.ttf'
|
|
|
|
|
|
def test_select_font_for_chinese_generic(multi_font_manager):
|
|
"""Test font selection with generic Chinese language code."""
|
|
if not has_cjk_font(multi_font_manager):
|
|
pytest.skip("CJK font not available")
|
|
font_manager = multi_font_manager.select_font_for_word("中文", "chi")
|
|
# A real, glyph-covering CJK font is selected (which specific family
|
|
# depends on what is installed: pan-CJK super font or a per-language subset).
|
|
assert font_manager.font_path.name != 'Occulta.ttf'
|
|
|
|
|
|
def test_select_font_for_chinese_simplified(multi_font_manager):
|
|
"""Test font selection with Tesseract's chi_sim language code."""
|
|
if not has_cjk_font(multi_font_manager):
|
|
pytest.skip("CJK font not available")
|
|
font_manager = multi_font_manager.select_font_for_word("简体字", "chi_sim")
|
|
# A real, glyph-covering CJK font is selected (which specific family
|
|
# depends on what is installed: pan-CJK super font or a per-language subset).
|
|
assert font_manager.font_path.name != 'Occulta.ttf'
|
|
|
|
|
|
def test_select_font_for_chinese_traditional(multi_font_manager):
|
|
"""Test font selection with Tesseract's chi_tra language code."""
|
|
if not has_cjk_font(multi_font_manager):
|
|
pytest.skip("CJK font not available")
|
|
font_manager = multi_font_manager.select_font_for_word("漢字", "chi_tra")
|
|
# A real, glyph-covering CJK font is selected (which specific family
|
|
# depends on what is installed: pan-CJK super font or a per-language subset).
|
|
assert font_manager.font_path.name != 'Occulta.ttf'
|
|
|
|
|
|
def test_select_font_for_japanese_language(multi_font_manager):
|
|
"""Test font selection with Japanese language hint."""
|
|
if not has_cjk_font(multi_font_manager):
|
|
pytest.skip("CJK font not available")
|
|
font_manager = multi_font_manager.select_font_for_word("こんにちは", "jpn")
|
|
# A real, glyph-covering CJK font is selected (which specific family
|
|
# depends on what is installed: pan-CJK super font or a per-language subset).
|
|
assert font_manager.font_path.name != 'Occulta.ttf'
|
|
|
|
|
|
def test_select_font_for_korean_language(multi_font_manager):
|
|
"""Test font selection with Korean language hint."""
|
|
if not has_cjk_font(multi_font_manager):
|
|
pytest.skip("CJK font not available")
|
|
font_manager = multi_font_manager.select_font_for_word("안녕하세요", "kor")
|
|
# A real, glyph-covering CJK font is selected (which specific family
|
|
# depends on what is installed: pan-CJK super font or a per-language subset).
|
|
assert font_manager.font_path.name != 'Occulta.ttf'
|
|
|
|
|
|
# --- Latin/English Tests ---
|
|
|
|
|
|
def test_select_font_for_english_text(multi_font_manager):
|
|
"""Test font selection for English text."""
|
|
font_manager = multi_font_manager.select_font_for_word("Hello World", "eng")
|
|
assert font_manager == multi_font_manager.fonts['NotoSans-Regular']
|
|
|
|
|
|
def test_select_font_without_language_hint(multi_font_manager):
|
|
"""Test font selection without language hint falls back to glyph checking."""
|
|
font_manager = multi_font_manager.select_font_for_word("Hello", None)
|
|
assert font_manager == multi_font_manager.fonts['NotoSans-Regular']
|
|
|
|
|
|
# --- Fallback Behavior Tests ---
|
|
|
|
|
|
def test_select_font_arabic_text_without_language_hint(multi_font_manager):
|
|
"""Test that Arabic text is handled via fallback without language hint."""
|
|
if not has_arabic_font(multi_font_manager):
|
|
pytest.skip("Arabic font not available")
|
|
font_manager = multi_font_manager.select_font_for_word("مرحبا", None)
|
|
# Should get NotoSansArabic-Regular via fallback chain glyph checking
|
|
assert font_manager == multi_font_manager.fonts['NotoSansArabic-Regular']
|
|
|
|
|
|
def test_devanagari_text_without_language_hint(multi_font_manager):
|
|
"""Test that Devanagari text is handled via fallback without language hint."""
|
|
# NotoSans-Regular includes Devanagari glyphs, so it's selected first in fallback
|
|
font_manager = multi_font_manager.select_font_for_word("नमस्ते", None)
|
|
# Could be NotoSans-Regular or NotoSansDevanagari-Regular depending on availability
|
|
assert font_manager is not None
|
|
|
|
|
|
def test_cjk_text_without_language_hint(multi_font_manager):
|
|
"""Test that CJK text is handled via fallback without language hint."""
|
|
if not has_cjk_font(multi_font_manager):
|
|
pytest.skip("CJK font not available")
|
|
font_manager = multi_font_manager.select_font_for_word("你好", None)
|
|
# A real, glyph-covering CJK font is selected (which specific family
|
|
# depends on what is installed: pan-CJK super font or a per-language subset).
|
|
assert font_manager.font_path.name != 'Occulta.ttf'
|
|
|
|
|
|
def test_fallback_to_occulta_font(multi_font_manager):
|
|
"""Test that unsupported characters fall back to Occulta.ttf."""
|
|
# Use a character unlikely to be in any standard font
|
|
font_manager = multi_font_manager.select_font_for_word("test", "xyz")
|
|
# Should return some valid font
|
|
assert font_manager in multi_font_manager.fonts.values()
|
|
|
|
|
|
def test_fallback_fonts_constant(multi_font_manager):
|
|
"""Test that FALLBACK_FONTS contains expected fonts."""
|
|
# Check that core fonts are in fallback list
|
|
assert 'NotoSans-Regular' in MultiFontManager.FALLBACK_FONTS
|
|
assert 'NotoSansArabic-Regular' in MultiFontManager.FALLBACK_FONTS
|
|
assert 'NotoSansDevanagari-Regular' in MultiFontManager.FALLBACK_FONTS
|
|
assert 'NotoSansCJK-Regular' in MultiFontManager.FALLBACK_FONTS
|
|
|
|
# Only NotoSans-Regular is bundled; other scripts are system fonts
|
|
assert 'NotoSans-Regular' in multi_font_manager.fonts
|
|
|
|
|
|
# --- Glyph Coverage Tests ---
|
|
|
|
|
|
def test_has_all_glyphs_for_english(multi_font_manager):
|
|
"""Test glyph coverage checking for English text."""
|
|
assert multi_font_manager.has_all_glyphs('NotoSans-Regular', "Hello World")
|
|
assert multi_font_manager.has_all_glyphs('NotoSans-Regular', "café")
|
|
|
|
|
|
def test_has_all_glyphs_for_arabic(multi_font_manager):
|
|
"""Test glyph coverage checking for Arabic text."""
|
|
if not has_arabic_font(multi_font_manager):
|
|
pytest.skip("Arabic font not available")
|
|
assert multi_font_manager.has_all_glyphs('NotoSansArabic-Regular', "مرحبا")
|
|
|
|
|
|
def test_has_all_glyphs_for_devanagari(multi_font_manager):
|
|
"""Test glyph coverage checking for Devanagari text."""
|
|
if not has_devanagari_font(multi_font_manager):
|
|
pytest.skip("Devanagari font not available")
|
|
assert multi_font_manager.has_all_glyphs('NotoSansDevanagari-Regular', "नमस्ते")
|
|
|
|
|
|
def test_has_all_glyphs_for_cjk(multi_font_manager):
|
|
"""Test glyph coverage checking for CJK text."""
|
|
if not has_cjk_font(multi_font_manager):
|
|
pytest.skip("CJK font not available")
|
|
assert multi_font_manager.has_all_glyphs('NotoSansCJK-Regular', "你好")
|
|
|
|
|
|
def test_empty_text_has_all_glyphs(multi_font_manager):
|
|
"""Test that empty text returns True for glyph coverage."""
|
|
assert multi_font_manager.has_all_glyphs('NotoSans-Regular', "")
|
|
|
|
|
|
def test_has_all_glyphs_missing_font(multi_font_manager):
|
|
"""Test that has_all_glyphs returns False for non-existent font."""
|
|
assert not multi_font_manager.has_all_glyphs('NonExistentFont', "test")
|
|
|
|
|
|
# --- Caching Tests ---
|
|
|
|
|
|
def test_font_selection_caching(multi_font_manager):
|
|
"""Test that font selection results are cached."""
|
|
font1 = multi_font_manager.select_font_for_word("Hello", "eng")
|
|
|
|
cache_key = ("Hello", "eng")
|
|
assert cache_key in multi_font_manager._selection_cache
|
|
|
|
font2 = multi_font_manager.select_font_for_word("Hello", "eng")
|
|
assert font1 == font2
|
|
|
|
|
|
# --- Language Font Map Tests ---
|
|
|
|
|
|
def test_language_font_map_coverage():
|
|
"""Test that LANGUAGE_FONT_MAP has valid structure."""
|
|
# Only NotoSans-Regular is bundled now
|
|
# This test just verifies the structure is valid
|
|
for font_name in MultiFontManager.LANGUAGE_FONT_MAP.values():
|
|
# All font names should be valid strings
|
|
assert isinstance(font_name, str)
|
|
assert font_name.startswith('NotoSans')
|
|
|
|
|
|
# --- get_all_fonts Tests ---
|
|
|
|
|
|
def test_get_all_fonts(multi_font_manager):
|
|
"""Test get_all_fonts returns all loaded fonts."""
|
|
all_fonts = multi_font_manager.get_all_fonts()
|
|
assert isinstance(all_fonts, dict)
|
|
# At least 2 builtin fonts should be loaded (NotoSans-Regular and Occulta)
|
|
assert len(all_fonts) >= 2
|
|
assert 'NotoSans-Regular' in all_fonts
|
|
assert 'Occulta' in all_fonts
|
|
# Arabic, Devanagari, CJK are optional (system fonts)
|
|
|
|
|
|
# --- FontProvider Tests ---
|
|
|
|
|
|
class MockFontProvider:
|
|
"""Mock FontProvider for testing missing fonts."""
|
|
|
|
def __init__(self, available_fonts: dict[str, FontManager], fallback: FontManager):
|
|
"""Initialize mock font provider with given fonts."""
|
|
self._fonts = available_fonts
|
|
self._fallback = fallback
|
|
|
|
def get_font(self, font_name: str) -> FontManager | None:
|
|
return self._fonts.get(font_name)
|
|
|
|
def get_available_fonts(self) -> list[str]:
|
|
return list(self._fonts.keys())
|
|
|
|
def get_fallback_font(self) -> FontManager:
|
|
return self._fallback
|
|
|
|
|
|
def test_custom_font_provider(font_dir):
|
|
"""Test that custom FontProvider can be injected."""
|
|
fonts = {
|
|
'NotoSans-Regular': FontManager(font_dir / 'NotoSans-Regular.ttf'),
|
|
'Occulta': FontManager(font_dir / 'Occulta.ttf'),
|
|
}
|
|
provider = MockFontProvider(fonts, fonts['Occulta'])
|
|
|
|
manager = MultiFontManager(font_provider=provider)
|
|
|
|
# Should only have the fonts we provided
|
|
assert len(manager.fonts) == 2
|
|
assert 'NotoSans-Regular' in manager.fonts
|
|
assert 'Occulta' in manager.fonts
|
|
|
|
|
|
def test_missing_font_uses_fallback(font_dir):
|
|
"""Test that missing fonts gracefully fall back."""
|
|
fonts = {
|
|
'NotoSans-Regular': FontManager(font_dir / 'NotoSans-Regular.ttf'),
|
|
'Occulta': FontManager(font_dir / 'Occulta.ttf'),
|
|
}
|
|
provider = MockFontProvider(fonts, fonts['Occulta'])
|
|
|
|
manager = MultiFontManager(font_provider=provider)
|
|
|
|
# Arabic text should fall back to Occulta since NotoSansArabic is missing
|
|
font = manager.select_font_for_word("مرحبا", "ara")
|
|
assert font == fonts['Occulta']
|
|
|
|
|
|
def test_builtin_font_provider_loads_expected_fonts(font_dir):
|
|
"""Test BuiltinFontProvider loads all expected builtin fonts."""
|
|
provider = BuiltinFontProvider(font_dir)
|
|
|
|
available = provider.get_available_fonts()
|
|
assert 'NotoSans-Regular' in available
|
|
assert 'Occulta' in available
|
|
# Only Latin (NotoSans) and glyphless fallback (Occulta) are bundled.
|
|
# All other scripts (Arabic, Devanagari, CJK, etc.) are discovered
|
|
# from system fonts by SystemFontProvider to reduce package size.
|
|
assert len(available) == 2
|
|
|
|
|
|
def test_builtin_font_provider_get_font(font_dir):
|
|
"""Test BuiltinFontProvider.get_font returns correct fonts."""
|
|
provider = BuiltinFontProvider(font_dir)
|
|
|
|
font = provider.get_font('NotoSans-Regular')
|
|
assert font is not None
|
|
assert isinstance(font, FontManager)
|
|
|
|
missing = provider.get_font('NonExistent')
|
|
assert missing is None
|
|
|
|
|
|
def test_builtin_font_provider_get_fallback(font_dir):
|
|
"""Test BuiltinFontProvider.get_fallback_font returns Occulta font."""
|
|
provider = BuiltinFontProvider(font_dir)
|
|
|
|
fallback = provider.get_fallback_font()
|
|
assert fallback is not None
|
|
assert fallback == provider.get_font('Occulta')
|
|
|
|
|
|
def test_builtin_font_provider_missing_font_logs_warning(tmp_path, font_dir, caplog):
|
|
"""Test that missing expected fonts log a warning."""
|
|
# Create minimal font directory with only Occulta.ttf
|
|
(tmp_path / 'Occulta.ttf').write_bytes((font_dir / 'Occulta.ttf').read_bytes())
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
provider = BuiltinFontProvider(tmp_path)
|
|
|
|
# Should have logged warnings for missing fonts
|
|
assert 'NotoSans-Regular' in caplog.text
|
|
assert 'not found' in caplog.text
|
|
|
|
# But Occulta should be loaded
|
|
assert provider.get_fallback_font() is not None
|
|
|
|
|
|
def test_builtin_font_provider_missing_occulta_raises(tmp_path):
|
|
"""Test that missing Occulta.ttf raises FileNotFoundError."""
|
|
with pytest.raises(FileNotFoundError, match="Required fallback font"):
|
|
BuiltinFontProvider(tmp_path)
|
|
|
|
|
|
class _StubHbFont:
|
|
"""Minimal uharfbuzz Font stand-in with controllable glyph coverage."""
|
|
|
|
def __init__(self, covered_codepoints: set[int]):
|
|
self._covered = covered_codepoints
|
|
|
|
def get_nominal_glyph(self, codepoint: int) -> int:
|
|
return 1 if codepoint in self._covered else 0
|
|
|
|
|
|
class _FakeFontManager:
|
|
"""FontManager stand-in whose glyph coverage is fixed per test."""
|
|
|
|
def __init__(self, name: str, covered_chars: str):
|
|
self.font_path = Path(name)
|
|
self._hb = _StubHbFont({ord(c) for c in covered_chars})
|
|
|
|
def get_hb_font(self) -> _StubHbFont:
|
|
return self._hb
|
|
|
|
|
|
class _FakeFontProvider:
|
|
"""FontProvider returning controlled fonts by logical name."""
|
|
|
|
def __init__(self, fonts: dict[str, _FakeFontManager]):
|
|
self._fonts = fonts
|
|
self._fallback = _FakeFontManager('Occulta.ttf', '')
|
|
|
|
def get_font(self, name: str) -> _FakeFontManager | None:
|
|
return self._fonts.get(name)
|
|
|
|
def get_available_fonts(self) -> list[str]:
|
|
return list(self._fonts)
|
|
|
|
def get_fallback_font(self) -> _FakeFontManager:
|
|
return self._fallback
|
|
|
|
|
|
def test_japanese_prefers_jp_family_over_other_cjk():
|
|
"""A Japanese language hint selects NotoSansJP, not another CJK family."""
|
|
fonts = {
|
|
'NotoSansSC-Regular': _FakeFontManager('NotoSansSC.ttf', '中'),
|
|
'NotoSansJP-Regular': _FakeFontManager('NotoSansJP.ttf', '中こ'),
|
|
}
|
|
manager = MultiFontManager(font_provider=_FakeFontProvider(fonts))
|
|
# 'こ' (kana) is only covered by JP; both cover the kanji '中'.
|
|
font = manager.select_font_for_word('中こ', 'jpn')
|
|
assert font.font_path.name == 'NotoSansJP.ttf'
|
|
|
|
|
|
def test_chinese_simplified_prefers_sc_family():
|
|
"""A Simplified Chinese hint selects NotoSansSC over the pan-CJK font."""
|
|
fonts = {
|
|
'NotoSansSC-Regular': _FakeFontManager('NotoSansSC.ttf', '简'),
|
|
'NotoSansCJK-Regular': _FakeFontManager('NotoSansCJK.ttc', '简'),
|
|
}
|
|
manager = MultiFontManager(font_provider=_FakeFontProvider(fonts))
|
|
font = manager.select_font_for_word('简', 'chi_sim')
|
|
assert font.font_path.name == 'NotoSansSC.ttf'
|
|
|
|
|
|
def test_cjk_fails_over_when_preferred_subset_lacks_glyph():
|
|
"""If the language's subset font lacks a glyph, another CJK family is used."""
|
|
fonts = {
|
|
# Simplified Chinese subset cannot render Japanese kana.
|
|
'NotoSansSC-Regular': _FakeFontManager('NotoSansSC.ttf', '中'),
|
|
'NotoSansJP-Regular': _FakeFontManager('NotoSansJP.ttf', '中こ'),
|
|
}
|
|
manager = MultiFontManager(font_provider=_FakeFontProvider(fonts))
|
|
# Tagged Simplified Chinese, but the text needs kana only JP covers.
|
|
font = manager.select_font_for_word('こ', 'chi_sim')
|
|
assert font.font_path.name == 'NotoSansJP.ttf'
|
|
|
|
|
|
def test_cjk_falls_back_to_pan_cjk_super_font():
|
|
"""When only the full-coverage pan-CJK font exists, it serves any CJK lang."""
|
|
fonts = {
|
|
'NotoSansCJK-Regular': _FakeFontManager('NotoSansCJK.ttc', '中こ안'),
|
|
}
|
|
manager = MultiFontManager(font_provider=_FakeFontProvider(fonts))
|
|
assert manager.select_font_for_word('こ', 'jpn').font_path.name == 'NotoSansCJK.ttc'
|
|
|
|
|
|
def test_missing_cjk_font_warning_names_language_font(font_dir, caplog):
|
|
"""The missing-font warning names the language-specific CJK family (#1652)."""
|
|
manager = MultiFontManager(font_provider=BuiltinFontProvider(font_dir))
|
|
with caplog.at_level(logging.WARNING):
|
|
manager.select_font_for_word("こんにちは", "jpn")
|
|
assert 'NotoSansJP' in caplog.text
|
|
|
|
|
|
def test_missing_font_warning_explains_consequences(font_dir, caplog):
|
|
"""The glyphless-fallback warning should be actionable, not cryptic (#1652).
|
|
|
|
With only builtin fonts available, Arabic text cannot be covered, so the
|
|
manager falls back to glyphless Occulta and must warn helpfully.
|
|
"""
|
|
# Builtin-only provider: NotoSansArabic is never available, forcing fallback.
|
|
manager = MultiFontManager(font_provider=BuiltinFontProvider(font_dir))
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
manager.select_font_for_word("سلام", "fas")
|
|
|
|
msg = caplog.text
|
|
# Identifies the affected language and the font family to install.
|
|
assert 'fas' in msg
|
|
assert 'NotoSansArabic' in msg
|
|
# Explains the user-visible consequence so the message is not cryptic:
|
|
# the text stays searchable but renders blank when highlighted.
|
|
assert 'searchable' in msg.lower()
|
|
assert 'highlight' in msg.lower() or 'select' in msg.lower()
|
|
|
|
|
|
# --- Coverage-driven last-resort font search (#1722) ---
|
|
|
|
|
|
class _FakeProviderWithSearch(_FakeFontProvider):
|
|
"""FontProvider that can also search unlisted fonts by glyph coverage."""
|
|
|
|
def __init__(self, fonts, searchable):
|
|
super().__init__(fonts)
|
|
self._searchable = searchable
|
|
self.search_calls: list[str] = []
|
|
|
|
def find_font_with_glyphs(self, text):
|
|
self.search_calls.append(text)
|
|
for name, font in self._searchable.items():
|
|
hb = font.get_hb_font()
|
|
if all(hb.get_nominal_glyph(ord(c)) for c in text):
|
|
self._fonts[name] = font # discovered fonts become resolvable
|
|
return name, font
|
|
return None
|
|
|
|
|
|
def test_unlisted_font_found_by_coverage_search():
|
|
"""A script outside FALLBACK_FONTS is rendered if the font is installed."""
|
|
searchable = {'NotoSansCherokee-Regular': _FakeFontManager('Cherokee.ttf', 'ᏣᎳᎩ')}
|
|
provider = _FakeProviderWithSearch({}, searchable)
|
|
manager = MultiFontManager(font_provider=provider)
|
|
|
|
font = manager.select_font_for_word('ᏣᎳᎩ', None)
|
|
|
|
assert font.font_path.name == 'Cherokee.ttf'
|
|
assert provider.search_calls == ['ᏣᎳᎩ']
|
|
|
|
|
|
def test_coverage_search_result_is_cached():
|
|
"""The expensive coverage search runs once per distinct word."""
|
|
searchable = {'NotoSansCherokee-Regular': _FakeFontManager('Cherokee.ttf', 'ᏣᎳᎩ')}
|
|
provider = _FakeProviderWithSearch({}, searchable)
|
|
manager = MultiFontManager(font_provider=provider)
|
|
|
|
manager.select_font_for_word('ᏣᎳᎩ', None)
|
|
manager.select_font_for_word('ᏣᎳᎩ', None)
|
|
|
|
assert len(provider.search_calls) == 1
|
|
|
|
|
|
def test_named_fonts_take_precedence_over_coverage_search():
|
|
"""The coverage search is a last resort, not a substitute for named fonts."""
|
|
fonts = {'NotoSans-Regular': _FakeFontManager('NotoSans.ttf', 'abc')}
|
|
searchable = {'NotoSansMono-Regular': _FakeFontManager('NotoSansMono.ttf', 'abc')}
|
|
provider = _FakeProviderWithSearch(fonts, searchable)
|
|
manager = MultiFontManager(font_provider=provider)
|
|
|
|
font = manager.select_font_for_word('abc', None)
|
|
|
|
assert font.font_path.name == 'NotoSans.ttf'
|
|
assert provider.search_calls == []
|
|
|
|
|
|
def test_provider_without_coverage_search_still_falls_back():
|
|
"""Providers predating find_font_with_glyphs() keep working (duck-typed)."""
|
|
manager = MultiFontManager(font_provider=_FakeFontProvider({}))
|
|
font = manager.select_font_for_word('ᏣᎳᎩ', None)
|
|
assert font.font_path.name == 'Occulta.ttf'
|
|
|
|
|
|
def test_missing_font_warning_names_the_missing_characters(font_dir, caplog):
|
|
"""The warning must identify what could not be rendered (#1722).
|
|
|
|
The user's real question is "which font package do I install?" — naming the
|
|
offending codepoints and their Unicode names answers it even for scripts
|
|
OCRmyPDF has no language mapping for.
|
|
"""
|
|
manager = MultiFontManager(font_provider=BuiltinFontProvider(font_dir))
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
manager.select_font_for_word('ᏣᎳᎩ', None)
|
|
|
|
msg = caplog.text
|
|
assert 'U+13E3' in msg # CHEROKEE LETTER TSA
|
|
assert 'CHEROKEE' in msg.upper()
|
|
|
|
|
|
def test_missing_character_warning_ignores_covered_characters(font_dir, caplog):
|
|
"""Only the uncoverable characters are reported, not the whole word."""
|
|
manager = MultiFontManager(font_provider=BuiltinFontProvider(font_dir))
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
manager.select_font_for_word('aᏣb', None)
|
|
|
|
msg = caplog.text
|
|
assert 'U+13E3' in msg
|
|
assert 'U+0061' not in msg # 'a' is covered by the builtin Latin font
|
|
|
|
|
|
def test_mixed_script_word_warning_does_not_advise_installing_fonts(caplog):
|
|
"""A word no single font covers is reported as such, not as a missing font.
|
|
|
|
Every character here has an installed font; telling the user to install
|
|
more would be wrong advice and is exactly the confusion behind #1722.
|
|
"""
|
|
fonts = {'NotoSans-Regular': _FakeFontManager('NotoSans.ttf', 'ab')}
|
|
searchable = {'NotoSansCherokee-Regular': _FakeFontManager('Cherokee.ttf', 'Ꮳ')}
|
|
manager = MultiFontManager(font_provider=_FakeProviderWithSearch(fonts, searchable))
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
font = manager.select_font_for_word('aᏣb', None)
|
|
|
|
assert font.font_path.name == 'Occulta.ttf'
|
|
msg = caplog.text
|
|
assert 'mixing scripts' in msg
|
|
assert 'will not help' in msg
|
|
assert 'U+' not in msg # no codepoints to blame; nothing to install
|