Skip to content

Commit 2b5173e

Browse files
authored
Merge pull request #12 from atlasia-ma/fix/coverage-presentation-forms
fix(fonts): check coverage against the glyphs that are actually drawn
2 parents a6bb4c5 + 767c7b1 commit 2b5173e

7 files changed

Lines changed: 165 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,30 @@ All notable changes to this project are documented here. The format follows
44
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres to
55
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [1.0.2] - 2026-08-13
8+
9+
### Fixed
10+
11+
- **Font coverage was checked against the wrong string, so Arabic could render as tofu.**
12+
Coverage was judged on the *logical* text, but without Raqm the renderer draws Arabic
13+
*presentation forms* (U+FE70-FEFF). A modern OpenType face such as Fustat or Mada covers
14+
the base Arabic block while carrying no presentation-form glyphs at all — it joins
15+
letters via GSUB instead — so the probe reported 100% coverage and every glyph then
16+
rendered as an empty box, with the label still claiming the text. `FontPool` now shapes
17+
before probing, using the same shaper the renderer will use. Raqm builds are unaffected
18+
and correctly keep judging the logical form.
19+
- **The coverage probe missed table cells and list items.** A table block's own text is
20+
empty; its content lives in the cells. An invoice whose prose was four words therefore
21+
chose a font on the strength of those four words and drew its entire table as tofu.
22+
Added `DocumentContent.all_text`, which includes cells and list items, and the pipeline
23+
now probes against it.
24+
- **The font fallback silently discarded the guarantee.** When no face covered a document,
25+
the pipeline fell back to the *entire* pool and could hand the document a face that
26+
cannot draw its script at all. It now falls back to the single best-covering face.
27+
28+
Found by rendering sample documents and looking at them — every automated check passed
29+
while the images were visibly broken.
30+
731
## [1.0.1] - 2026-08-13
832

933
### Fixed
@@ -157,5 +181,6 @@ page before it reaches the dataset.
157181
were unset, which made every sample fail for configs that omit them.
158182
- Whitespace-only input no longer produces a zero-sized canvas.
159183

184+
[1.0.2]: https://github.com/atlasia-ma/OCRSmith/releases/tag/v1.0.2
160185
[1.0.1]: https://github.com/atlasia-ma/OCRSmith/releases/tag/v1.0.1
161186
[1.0.0]: https://github.com/atlasia-ma/OCRSmith/releases/tag/v1.0.0

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "ocrsmith"
7-
version = "1.0.1"
7+
version = "1.0.2"
88
description = "Synthetic document and OCR dataset forge for Arabic, Darija and Latin scripts."
99
readme = "README.md"
1010
authors = [

src/ocrsmith/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,4 @@
2929
"run_generation",
3030
]
3131

32-
__version__ = "1.0.1"
32+
__version__ = "1.0.2"

src/ocrsmith/core/documents/content.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,24 @@ class DocumentContent:
5353
def text(self) -> str:
5454
return "\n".join(block.text for block in self.blocks if block.text)
5555

56+
@property
57+
def all_text(self) -> str:
58+
"""Every character the document will draw, including table cells and list items.
59+
60+
`text` reads block text only, and a table block's text is empty — the content
61+
lives in its cells. Font coverage must be judged against *this*, or an invoice
62+
whose prose is four words picks a face on the strength of those four words and
63+
then renders its entire table as tofu.
64+
"""
65+
parts: list[str] = []
66+
for block in self.blocks:
67+
if block.text:
68+
parts.append(block.text)
69+
parts.extend(block.items)
70+
if block.table is not None:
71+
parts.extend(cell.text for cell in block.table.cells if cell.text)
72+
return "\n".join(parts)
73+
5674
def __len__(self) -> int:
5775
return len(self.blocks)
5876

src/ocrsmith/core/fonts.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from PIL.ImageFont import FreeTypeFont
2020

2121
from ..text.coverage import fonts_supporting, supports_text
22+
from ..text.shaping import TextShaper, resolve_shaper
2223

2324
__all__ = ["FontPool", "clear_font_cache", "discover_fonts", "load_font"]
2425

@@ -86,11 +87,16 @@ def __init__(
8687
include: Sequence[str] = (),
8788
exclude: Sequence[str] = (),
8889
require_full_coverage: bool = True,
90+
shaper: TextShaper | None = None,
8991
):
9092
self.faces = discover_fonts(paths, include=include, exclude=exclude)
9193
if not self.faces:
9294
raise ValueError(f"No font files found under {list(paths)!r}")
9395
self.require_full_coverage = require_full_coverage
96+
# Coverage must be judged against the characters that will actually be drawn,
97+
# which is the shaper's business — so the pool needs the same shaper the renderer
98+
# will use.
99+
self.shaper = shaper or resolve_shaper()
94100
self._coverage_cache: dict[tuple[str, str], bool] = {}
95101

96102
def __len__(self) -> int:
@@ -124,11 +130,17 @@ def covers(self, path: str | Path, text: str) -> bool:
124130
self._coverage_cache[key] = cached
125131
return cached
126132

127-
@staticmethod
128-
def _probe(text: str) -> str:
129-
"""The distinct characters of `text`, which is all coverage depends on.
133+
def _probe(self, text: str) -> str:
134+
"""The distinct characters that will actually be *drawn* for `text`.
130135
131-
Collapsing to a character set turns a per-document question into a per-alphabet
132-
one, so the coverage cache actually hits.
136+
Shaping happens first, and this is the whole point. Without Raqm the renderer
137+
draws Arabic presentation forms (U+FE70-FEFF), not the base letters — and a modern
138+
OpenType face such as Fustat or Mada covers the base block while carrying no
139+
presentation-form glyphs at all, because it joins letters via GSUB instead. Probing
140+
the logical string reports 100% coverage for such a font and every glyph then
141+
renders as tofu, with the label still claiming the text.
142+
143+
Collapsing to a character set afterwards turns a per-document question into a
144+
per-alphabet one, so the coverage cache actually hits.
133145
"""
134-
return "".join(sorted(set(text)))
146+
return "".join(sorted(set(self.shaper.shape(text).visual)))

src/ocrsmith/pipeline/factory.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,16 @@ def _page_spec(self, page: PageConfig, direction: Direction | None, rng: random.
159159
)
160160

161161
def _typography(self, content, rng: random.Random):
162-
faces = self.fonts.supporting(content.text[:2000]) or self.fonts.faces
162+
# Probe against everything the page will draw - table cells and list items
163+
# included - and against the *shaped* form, which is what actually reaches the
164+
# rasteriser.
165+
probe = content.all_text[:4000]
166+
faces = self.fonts.supporting(probe)
167+
if not faces:
168+
# Falling back to the whole pool would hand the document a face that cannot
169+
# draw its script at all, which is the exact failure coverage exists to
170+
# prevent. Fall back to the best face instead, and only that one.
171+
faces = (self.fonts.choose(probe, rng),)
163172
sampler = TypographySampler(faces, body_size_range=tuple(self.config.fonts.size_range))
164173
return sampler.sample(rng)
165174

tests/test_font_pool.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
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

Comments
 (0)