From 334918d0f70d974c51edb6ab53e65ac8971dd0a8 Mon Sep 17 00:00:00 2001 From: jbarlow Date: Thu, 18 Jun 2026 10:34:08 -0700 Subject: [PATCH] Discover variable and per-language Noto fonts for the OCR text layer (#1652) (#1707) * Bump uv lock, fix bump_version * Discover variable and per-language Noto fonts for the OCR text layer (#1652) SystemFontProvider only matched static "-Regular.ttf/.otf" filenames, so the variable fonts shipped by Homebrew casks and current Google Fonts (e.g. NotoSansArabic[wdth,wght].ttf) were never found. Users who had installed the font still got the glyphless Occulta fallback and a cryptic "No font found" warning. - Match variable fonts (Base[...]), -VF, and bare-family filenames via a boundary-aware flexible search, escaping the glob-special brackets. - Make CJK language-aware: the modern per-language Noto fonts (NotoSansSC /TC/HK/JP/KR) are region subsets, so map each CJK language to its own family and keep the full-coverage pan-CJK super font as a shared fallback. Glyph coverage, not shape, is what matters for the invisible text layer. - Reword the missing-font warning to explain the consequence (searchable but blank when highlighted) and name the language-specific font. --- bin/bump_version.py | 7 +- src/ocrmypdf/font/multi_font_manager.py | 43 ++++-- src/ocrmypdf/font/system_font_provider.py | 122 ++++++++++++++++ tests/test_multi_font_manager.py | 141 ++++++++++++++++++- tests/test_system_font_provider.py | 163 ++++++++++++++++++++++ uv.lock | 2 +- 6 files changed, 452 insertions(+), 26 deletions(-) diff --git a/bin/bump_version.py b/bin/bump_version.py index 65785b9b..d075782a 100644 --- a/bin/bump_version.py +++ b/bin/bump_version.py @@ -18,8 +18,9 @@ import cyclopts from packaging.version import InvalidVersion, Version try: - from github import Github, GithubException + from github import Auth, Github, GithubException except ImportError: + Auth = None # type: ignore Github = None # type: ignore GithubException = Exception # type: ignore @@ -68,7 +69,7 @@ def validate_release_notes(new_version: str) -> bool: def get_github_client(): """Get an authenticated GitHub client.""" - if Github is None: + if Github is None or Auth is None: print(f"{RED}error:{OFF} PyGithub is not installed") print(" Install with: pip install PyGithub") return None @@ -92,7 +93,7 @@ def get_github_client(): return None try: - return Github(token) + return Github(auth=Auth.Token(token)) except GithubException as e: print(f"{RED}error:{OFF} Failed to authenticate with GitHub: {e}") return None diff --git a/src/ocrmypdf/font/multi_font_manager.py b/src/ocrmypdf/font/multi_font_manager.py index 96283fde..34905e3c 100644 --- a/src/ocrmypdf/font/multi_font_manager.py +++ b/src/ocrmypdf/font/multi_font_manager.py @@ -54,13 +54,15 @@ class MultiFontManager: 'kok': 'NotoSansDevanagari-Regular', # Konkani 'bho': 'NotoSansDevanagari-Regular', # Bhojpuri 'mai': 'NotoSansDevanagari-Regular', # Maithili - # CJK - 'chi': 'NotoSansCJK-Regular', # Chinese (generic) - 'zho': 'NotoSansCJK-Regular', # Chinese (ISO 639-3) - 'chi_sim': 'NotoSansCJK-Regular', # Chinese Simplified (Tesseract) - 'chi_tra': 'NotoSansCJK-Regular', # Chinese Traditional (Tesseract) - 'jpn': 'NotoSansCJK-Regular', # Japanese - 'kor': 'NotoSansCJK-Regular', # Korean + # CJK — prefer the family matching the document language, because the + # modern per-language Noto fonts are region subsets (e.g. NotoSansSC + # lacks Japanese kana). The pan-CJK super font is a shared fallback. + 'chi': 'NotoSansSC-Regular', # Chinese (generic → Simplified) + 'zho': 'NotoSansSC-Regular', # Chinese (ISO 639-3) + 'chi_sim': 'NotoSansSC-Regular', # Chinese Simplified (Tesseract) + 'chi_tra': 'NotoSansTC-Regular', # Chinese Traditional (Tesseract) + 'jpn': 'NotoSansJP-Regular', # Japanese + 'kor': 'NotoSansKR-Regular', # Korean # Thai 'tha': 'NotoSansThai-Regular', # Thai # Hebrew @@ -113,7 +115,14 @@ class MultiFontManager: 'NotoSans-Regular', # Latin, Greek, Cyrillic 'NotoSansArabic-Regular', 'NotoSansDevanagari-Regular', + # Pan-CJK super font first (full coverage), then the per-language + # subsets so a glyph missing from one CJK family is found in another. 'NotoSansCJK-Regular', + 'NotoSansSC-Regular', + 'NotoSansTC-Regular', + 'NotoSansHK-Regular', + 'NotoSansJP-Regular', + 'NotoSansKR-Regular', 'NotoSansThai-Regular', 'NotoSansHebrew-Regular', 'NotoSansBengali-Regular', @@ -256,19 +265,23 @@ class MultiFontManager: self._warned_scripts.add(warn_key) if line_language and line_language in self.LANGUAGE_FONT_MAP: - font_name = self.LANGUAGE_FONT_MAP[line_language] + font_family = self.LANGUAGE_FONT_MAP[line_language].removesuffix('-Regular') log.warning( - "No font found with glyphs for '%s' text. " - "Install %s for better rendering. " - "See https://fonts.google.com/noto", + "No installed font has glyphs for the detected '%s' text, so " + "it was added as an invisible text layer: it stays searchable " + "and copyable, but appears blank when highlighted in a PDF " + "viewer. Install the %s font family (via your OS package " + "manager or https://fonts.google.com/noto) for full rendering.", line_language, - font_name, + font_family, ) else: log.warning( - "No font found with glyphs for some text. " - "Install Noto fonts for better rendering. " - "See https://fonts.google.com/noto" + "No installed font has glyphs for some of the detected text, " + "so it was added as an invisible text layer: it stays " + "searchable and copyable, but appears blank when highlighted " + "in a PDF viewer. Install the matching Noto fonts " + "(https://fonts.google.com/noto) for full rendering." ) def _has_all_glyphs(self, font: FontManager, text: str) -> bool: diff --git a/src/ocrmypdf/font/system_font_provider.py b/src/ocrmypdf/font/system_font_provider.py index d0d17bc3..72132361 100644 --- a/src/ocrmypdf/font/system_font_provider.py +++ b/src/ocrmypdf/font/system_font_provider.py @@ -9,6 +9,7 @@ Linux, macOS, and Windows platforms. from __future__ import annotations +import glob import logging import os import sys @@ -75,6 +76,35 @@ class SystemFontProvider: # Variable fonts 'NotoSansCJKsc-VF.otf', ], + # Per-language CJK families. Modern Google Fonts / Homebrew ship these + # as region subset variable fonts ('NotoSansJP[wght].ttf'), matched by + # the flexible base search; the legacy per-region super OTFs (full + # coverage) are listed here so they also satisfy the logical name. + 'NotoSansSC-Regular': [ + 'NotoSansSC-Regular.otf', + 'NotoSansSC-Regular.ttf', + 'NotoSansCJKsc-Regular.otf', + ], + 'NotoSansTC-Regular': [ + 'NotoSansTC-Regular.otf', + 'NotoSansTC-Regular.ttf', + 'NotoSansCJKtc-Regular.otf', + ], + 'NotoSansHK-Regular': [ + 'NotoSansHK-Regular.otf', + 'NotoSansHK-Regular.ttf', + 'NotoSansCJKhk-Regular.otf', + ], + 'NotoSansJP-Regular': [ + 'NotoSansJP-Regular.otf', + 'NotoSansJP-Regular.ttf', + 'NotoSansCJKjp-Regular.otf', + ], + 'NotoSansKR-Regular': [ + 'NotoSansKR-Regular.otf', + 'NotoSansKR-Regular.ttf', + 'NotoSansCJKkr-Regular.otf', + ], 'NotoSansThai-Regular': [ 'NotoSansThai-Regular.ttf', 'NotoSansThai-Regular.otf', @@ -149,6 +179,28 @@ class SystemFontProvider: ], } + # Font file extensions we know how to load. + _FONT_EXTENSIONS = ('.ttf', '.otf', '.ttc') + + # Acceptable filename variants for a font family, ranked best-first. + # Lower rank wins when multiple variants of the same family are present. + _VARIANT_RANK = {'regular': 0, 'variable': 1, 'vf': 2, 'plain': 3} + + # Extra family bases that can satisfy a logical font, tried after its own + # base (so the listed order is the preference). CJK is the case that needs + # this: the legacy Adobe-style 'NotoSansCJKsc-Regular.otf' is handled by + # NOTO_FONT_PATTERNS, but Homebrew casks and current Google Fonts ship the + # per-language families as variable fonts (e.g. 'NotoSansSC[wght].ttf'). + _ALTERNATE_BASES: dict[str, list[str]] = { + 'NotoSansCJK-Regular': [ + 'NotoSansSC', # Simplified Chinese + 'NotoSansTC', # Traditional Chinese + 'NotoSansHK', # Hong Kong + 'NotoSansJP', # Japanese + 'NotoSansKR', # Korean + ], + } + def __init__(self) -> None: """Initialize system font provider with empty caches.""" # Cache: font_name -> FontManager (successfully loaded fonts) @@ -230,6 +282,76 @@ class SystemFontProvider: # Skip directories we can't read continue + # No exact static '-Regular' file. Many distributors (Homebrew casks, + # current Google Fonts releases) ship Noto fonts as variable fonts with + # bracketed axis filenames such as 'NotoSansArabic[wdth,wght].ttf'. + # Fall back to a flexible search that also accepts those. See #1652. + return self._find_variant_font_file(font_name) + + @staticmethod + def _classify_variant(stem: str, base: str) -> str | None: + """Classify a font filename stem as a usable variant of ``base``. + + Args: + stem: Filename without extension (e.g. 'NotoSansArabic[wdth,wght]') + base: Family base name (e.g. 'NotoSansArabic') + + Returns: + The variant kind ('regular', 'variable', 'vf', 'plain') or None if + the stem is not an acceptable representative of the family. The + boundary after ``base`` is required so that 'NotoSans' does not + match 'NotoSansArabic', and 'NotoSansArabicUI'/'NotoSansArabic-Bold' + do not match a request for 'NotoSansArabic'. + """ + if stem == f'{base}-Regular': + return 'regular' + if stem.startswith(f'{base}['): # variable font, e.g. Base[wdth,wght] + return 'variable' + if stem == f'{base}-VF': # alternate variable-font naming + return 'vf' + if stem == base: # bare family name + return 'plain' + return None + + def _find_variant_font_file(self, font_name: str) -> Path | None: + """Search for a variable font or other acceptable filename variant. + + Tries the font's own family base first, then any alternate bases (used + for the modern per-language CJK families). Within that, a static Regular + is preferred over a variable font. See issue #1652. + + Args: + font_name: Logical font name (e.g. 'NotoSansArabic-Regular') + + Returns: + Path to the best-ranked matching font file, or None. + """ + bases = [font_name.removesuffix('-Regular')] + bases.extend(self._ALTERNATE_BASES.get(font_name, [])) + + # Selection key (base_index, variant_rank): earlier base wins, then the + # better variant. Path is carried along but not part of the comparison. + best: tuple[tuple[int, int], Path] | None = None + for base_index, base in enumerate(bases): + for font_dir in self._get_font_dirs(): + if not font_dir.exists(): + continue + try: + for path in font_dir.rglob(glob.escape(base) + '*'): + if path.suffix.lower() not in self._FONT_EXTENSIONS: + continue + kind = self._classify_variant(path.stem, base) + if kind is None: + continue + key = (base_index, self._VARIANT_RANK[kind]) + if best is None or key < best[0]: + best = (key, path) + except PermissionError: + # Skip directories we can't read + continue + if best is not None: + log.debug("Found system font %s at %s (variant match)", font_name, best[1]) + return best[1] return None def get_font(self, font_name: str) -> FontManager | None: diff --git a/tests/test_multi_font_manager.py b/tests/test_multi_font_manager.py index 0a69c0e9..6bf9abd2 100644 --- a/tests/test_multi_font_manager.py +++ b/tests/test_multi_font_manager.py @@ -149,7 +149,9 @@ def test_select_font_for_chinese_language(multi_font_manager): if not has_cjk_font(multi_font_manager): pytest.skip("CJK font not available") font_manager = multi_font_manager.select_font_for_word("你好", "zho") - assert font_manager == multi_font_manager.fonts['NotoSansCJK-Regular'] + # 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): @@ -157,7 +159,9 @@ def test_select_font_for_chinese_generic(multi_font_manager): if not has_cjk_font(multi_font_manager): pytest.skip("CJK font not available") font_manager = multi_font_manager.select_font_for_word("中文", "chi") - assert font_manager == multi_font_manager.fonts['NotoSansCJK-Regular'] + # 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): @@ -165,7 +169,9 @@ def test_select_font_for_chinese_simplified(multi_font_manager): 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") - assert font_manager == multi_font_manager.fonts['NotoSansCJK-Regular'] + # 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): @@ -173,7 +179,9 @@ def test_select_font_for_chinese_traditional(multi_font_manager): 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") - assert font_manager == multi_font_manager.fonts['NotoSansCJK-Regular'] + # 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): @@ -181,7 +189,9 @@ def test_select_font_for_japanese_language(multi_font_manager): if not has_cjk_font(multi_font_manager): pytest.skip("CJK font not available") font_manager = multi_font_manager.select_font_for_word("こんにちは", "jpn") - assert font_manager == multi_font_manager.fonts['NotoSansCJK-Regular'] + # 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): @@ -189,7 +199,9 @@ def test_select_font_for_korean_language(multi_font_manager): if not has_cjk_font(multi_font_manager): pytest.skip("CJK font not available") font_manager = multi_font_manager.select_font_for_word("안녕하세요", "kor") - assert font_manager == multi_font_manager.fonts['NotoSansCJK-Regular'] + # 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 --- @@ -232,7 +244,9 @@ def test_cjk_text_without_language_hint(multi_font_manager): if not has_cjk_font(multi_font_manager): pytest.skip("CJK font not available") font_manager = multi_font_manager.select_font_for_word("你好", None) - assert font_manager == multi_font_manager.fonts['NotoSansCJK-Regular'] + # 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): @@ -444,3 +458,116 @@ 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() diff --git a/tests/test_system_font_provider.py b/tests/test_system_font_provider.py index 31f35767..b4939a2d 100644 --- a/tests/test_system_font_provider.py +++ b/tests/test_system_font_provider.py @@ -190,6 +190,9 @@ class TestSystemFontProviderAvailableFonts: 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.""" @@ -198,6 +201,166 @@ class TestSystemFontProviderAvailableFonts: 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 + + # --- ChainedFontProvider Tests --- diff --git a/uv.lock b/uv.lock index ba396ce6..8b4b8878 100644 --- a/uv.lock +++ b/uv.lock @@ -1434,7 +1434,7 @@ wheels = [ [[package]] name = "ocrmypdf" -version = "17.6.0" +version = "17.7.0" source = { editable = "." } dependencies = [ { name = "deprecation" },