|
| 1 | +"""Contract for coverage-aware font selection. |
| 2 | +
|
| 3 | +The bug these pin: coverage was checked against the *logical* string while the renderer |
| 4 | +drew *presentation forms*. Without Raqm, arabic-reshaper turns `أمينة` into U+FE94 U+FEE8 |
| 5 | +… and a modern OpenType face like Fustat or Mada covers the base Arabic block while |
| 6 | +carrying no presentation-form glyphs at all — it joins via GSUB instead. Probing the |
| 7 | +logical string reported 100% coverage for such a face, and every glyph then rendered as an |
| 8 | +empty box while the label still claimed the text. |
| 9 | +""" |
| 10 | + |
| 11 | +from pathlib import Path |
| 12 | + |
| 13 | +import pytest |
| 14 | + |
| 15 | +from ocrsmith.core.fonts import FontPool |
| 16 | +from ocrsmith.text.shaping import ReshaperBidiShaper, TransparentShaper |
| 17 | + |
| 18 | +FONT_DIR = Path(__file__).resolve().parents[1] / "assets" / "fonts" |
| 19 | + |
| 20 | +# Covers base Arabic *and* presentation forms. |
| 21 | +FULL_FORMS = "NotoSansArabic-Regular.ttf" |
| 22 | +# Covers base Arabic but has no presentation forms. |
| 23 | +GSUB_ONLY = "Fustat-Medium.ttf" |
| 24 | + |
| 25 | +ARABIC = "أمينة كتب الدرس" |
| 26 | + |
| 27 | +pytestmark = pytest.mark.skipif(not (FONT_DIR / GSUB_ONLY).exists(), reason="bundled fonts unavailable") |
| 28 | + |
| 29 | + |
| 30 | +@pytest.fixture |
| 31 | +def pool(): |
| 32 | + return FontPool( |
| 33 | + [FONT_DIR / FULL_FORMS, FONT_DIR / GSUB_ONLY], |
| 34 | + shaper=ReshaperBidiShaper(), |
| 35 | + ) |
| 36 | + |
| 37 | + |
| 38 | +class TestPresentationFormCoverage: |
| 39 | + def test_a_font_without_presentation_forms_is_rejected(self, pool): |
| 40 | + eligible = {path.name for path in pool.supporting(ARABIC)} |
| 41 | + |
| 42 | + assert FULL_FORMS in eligible |
| 43 | + assert GSUB_ONLY not in eligible, "font would render tofu under the reshaper backend" |
| 44 | + |
| 45 | + def test_choose_never_returns_the_unusable_font(self, pool): |
| 46 | + import random |
| 47 | + |
| 48 | + for seed in range(20): |
| 49 | + assert pool.choose(ARABIC, random.Random(seed)).name == FULL_FORMS |
| 50 | + |
| 51 | + def test_latin_is_unaffected(self, pool): |
| 52 | + eligible = {path.name for path in pool.supporting("hello world")} |
| 53 | + |
| 54 | + assert eligible == {FULL_FORMS, GSUB_ONLY} |
| 55 | + |
| 56 | + def test_a_raqm_backend_judges_the_logical_form_instead(self): |
| 57 | + # With Raqm, HarfBuzz applies GSUB and never emits presentation forms, so a |
| 58 | + # GSUB-only face is perfectly usable and must not be excluded. |
| 59 | + pool = FontPool( |
| 60 | + [FONT_DIR / FULL_FORMS, FONT_DIR / GSUB_ONLY], |
| 61 | + shaper=TransparentShaper(), |
| 62 | + ) |
| 63 | + |
| 64 | + eligible = {path.name for path in pool.supporting(ARABIC)} |
| 65 | + |
| 66 | + assert eligible == {FULL_FORMS, GSUB_ONLY} |
| 67 | + |
| 68 | + def test_disabling_the_requirement_still_returns_everything(self): |
| 69 | + pool = FontPool( |
| 70 | + [FONT_DIR / FULL_FORMS, FONT_DIR / GSUB_ONLY], |
| 71 | + require_full_coverage=False, |
| 72 | + shaper=ReshaperBidiShaper(), |
| 73 | + ) |
| 74 | + |
| 75 | + assert len(pool.supporting(ARABIC)) == 2 |
| 76 | + |
| 77 | + |
| 78 | +class TestProbeCompleteness: |
| 79 | + def test_table_and_list_text_reach_the_coverage_probe(self): |
| 80 | + from ocrsmith.core.documents import DocumentBuilder |
| 81 | + |
| 82 | + content = ( |
| 83 | + DocumentBuilder().paragraph("short").list(["ONE", "TWO"]).table([["HEAD"], ["CELL"]]).build() |
| 84 | + ) |
| 85 | + |
| 86 | + probe = content.all_text |
| 87 | + |
| 88 | + # `text` alone misses both, which is how an invoice picked a font on the strength |
| 89 | + # of four words of prose and then drew its whole table as tofu. |
| 90 | + assert "CELL" in probe and "HEAD" in probe |
| 91 | + assert "ONE" in probe and "TWO" in probe |
| 92 | + assert "CELL" not in content.text |
0 commit comments