Skip to content

Commit 5cae3c4

Browse files
hBouananeclaude
andcommitted
feat(text): add script detection, normalisation, bidi shaping and glyph coverage
Arabic ground truth has two representations that must never be confused: the logical string the model predicts and the visual string the rasteriser draws. ocrsmith.text makes that split explicit. - script.py: script classification and first-strong-character base direction. - normalization.py: NormalizationPolicy with opt-in, idempotent transforms (diacritics, tatweel, alef/ya/ta-marbuta unification, numeral systems, whitespace). Every transform changes the label, so none is applied silently. - shaping.py: two interchangeable backends behind one protocol. Pillow builds with Raqm shape and reorder themselves (TransparentShaper); builds without it get presentation forms and visual reordering from arabic-reshaper + python-bidi (ReshaperBidiShaper). Both keep the logical label identical, so the dataset does not depend on how Pillow was compiled. - coverage.py: exact cmap coverage via fontTools, cached per font file. A font that cannot draw a character is now rejectable instead of silently rendering tofu that contradicts the label. Adds fonttools as a dependency and drops two empty placeholder modules. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent d0b6b72 commit 5cae3c4

12 files changed

Lines changed: 903 additions & 0 deletions

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,18 @@ All notable changes to this project are documented here. The format follows
88

99
### Added
1010

11+
- `ocrsmith.text`: a text subsystem that owns everything happening to a string before it
12+
becomes pixels.
13+
- Script and base-direction detection (`detect_script`, `detect_direction`).
14+
- `NormalizationPolicy` — opt-in, idempotent transforms for diacritics, tatweel,
15+
alef/ya/ta-marbuta unification, numeral systems and whitespace. Each one changes the
16+
label, so each one is explicit and recorded.
17+
- Bidi/shaping with two interchangeable backends: `TransparentShaper` when Pillow has
18+
Raqm, `ReshaperBidiShaper` (arabic-reshaper + python-bidi) otherwise. Both keep the
19+
logical string as the label so datasets are identical across machines.
20+
- Font glyph coverage via fontTools `cmap` (`supports_text`, `fonts_supporting`), so a
21+
font that cannot draw a character is rejected instead of silently emitting tofu.
22+
1123
- Development tooling: ruff lint/format configuration, pytest configuration with
1224
`pythonpath = ["src"]` (so a clean clone is testable without installing), coverage
1325
settings, and a GitHub Actions CI matrix over Linux/Windows and Python 3.10/3.12.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ dependencies = [
3737
"numpy>=1.24",
3838
"arabic-reshaper>=3.0",
3939
"python-bidi>=0.4.2",
40+
"fonttools>=4.40",
4041
"typer>=0.12",
4142
"rich>=13.0",
4243
"tqdm>=4.66",

src/ocrsmith/text/__init__.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""Text handling: script detection, normalisation, shaping and font coverage.
2+
3+
This package owns everything that happens to a string *before* it becomes pixels.
4+
"""
5+
6+
from .coverage import (
7+
CoverageReport,
8+
fonts_supporting,
9+
has_glyph,
10+
missing_glyphs,
11+
supports_text,
12+
)
13+
from .normalization import (
14+
NormalizationPolicy,
15+
NumeralSystem,
16+
normalize_text,
17+
strip_diacritics,
18+
strip_tatweel,
19+
to_numeral_system,
20+
)
21+
from .script import Direction, Script, detect_direction, detect_script, is_arabic_char
22+
from .shaping import (
23+
ReshaperBidiShaper,
24+
ShapedText,
25+
TextShaper,
26+
TransparentShaper,
27+
raqm_available,
28+
resolve_shaper,
29+
)
30+
31+
__all__ = [
32+
# script
33+
"Direction",
34+
"Script",
35+
"detect_direction",
36+
"detect_script",
37+
"is_arabic_char",
38+
# normalisation
39+
"NormalizationPolicy",
40+
"NumeralSystem",
41+
"normalize_text",
42+
"strip_diacritics",
43+
"strip_tatweel",
44+
"to_numeral_system",
45+
# shaping
46+
"ReshaperBidiShaper",
47+
"ShapedText",
48+
"TextShaper",
49+
"TransparentShaper",
50+
"raqm_available",
51+
"resolve_shaper",
52+
# coverage
53+
"CoverageReport",
54+
"fonts_supporting",
55+
"has_glyph",
56+
"missing_glyphs",
57+
"supports_text",
58+
]

src/ocrsmith/text/coverage.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"""Font glyph coverage.
2+
3+
A font that lacks a glyph does not fail — it draws a blank or a tofu box while the label
4+
still claims the character is there. At scale that quietly teaches a model to hallucinate.
5+
Coverage is therefore checked up front and unsupported (font, text) pairs are rejected
6+
before they can reach the dataset.
7+
8+
Coverage is read from the font's `cmap` via fontTools, which is exact and needs no
9+
rendering. Results are cached per font file because a generation run asks the same
10+
question millions of times.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
from collections.abc import Iterable, Sequence
16+
from dataclasses import dataclass
17+
from functools import lru_cache
18+
from pathlib import Path
19+
20+
__all__ = [
21+
"CoverageReport",
22+
"fonts_supporting",
23+
"has_glyph",
24+
"missing_glyphs",
25+
"supports_text",
26+
]
27+
28+
# Characters no font is expected to carry a glyph for and which never need one.
29+
_IGNORED = set(" \t\n\r​‌‍‎‏")
30+
31+
32+
@dataclass(frozen=True, slots=True)
33+
class CoverageReport:
34+
"""Which characters of a text a font can actually draw."""
35+
36+
font_path: str
37+
total: int
38+
missing: tuple[str, ...]
39+
40+
@property
41+
def covered(self) -> int:
42+
return self.total - len(self.missing)
43+
44+
@property
45+
def ratio(self) -> float:
46+
"""Fraction of checkable characters the font can draw; 1.0 for empty text."""
47+
return 1.0 if self.total == 0 else self.covered / self.total
48+
49+
@property
50+
def is_complete(self) -> bool:
51+
return not self.missing
52+
53+
def __bool__(self) -> bool:
54+
return self.is_complete
55+
56+
57+
@lru_cache(maxsize=512)
58+
def _codepoints(font_path: str) -> frozenset[int]:
59+
"""Every code point mapped by the font's character map."""
60+
from fontTools.ttLib import TTFont
61+
62+
covered: set[int] = set()
63+
with TTFont(font_path, fontNumber=0, lazy=True) as font:
64+
for table in font["cmap"].tables:
65+
covered.update(table.cmap.keys())
66+
return frozenset(covered)
67+
68+
69+
def has_glyph(font_path: str | Path, char: str) -> bool:
70+
"""Whether the font at `font_path` maps `char` to a glyph."""
71+
if not char:
72+
return True
73+
if char in _IGNORED:
74+
return True
75+
return ord(char[0]) in _codepoints(str(Path(font_path)))
76+
77+
78+
def missing_glyphs(font_path: str | Path, text: str) -> tuple[str, ...]:
79+
"""Unique characters of `text` the font cannot draw, in first-seen order."""
80+
covered = _codepoints(str(Path(font_path)))
81+
missing: list[str] = []
82+
seen: set[str] = set()
83+
for char in text:
84+
if char in _IGNORED or char in seen:
85+
continue
86+
seen.add(char)
87+
if ord(char) not in covered:
88+
missing.append(char)
89+
return tuple(missing)
90+
91+
92+
def supports_text(font_path: str | Path, text: str) -> CoverageReport:
93+
"""Full coverage report for rendering `text` with the font at `font_path`."""
94+
checkable = {char for char in text if char not in _IGNORED}
95+
return CoverageReport(
96+
font_path=str(font_path),
97+
total=len(checkable),
98+
missing=missing_glyphs(font_path, text),
99+
)
100+
101+
102+
def fonts_supporting(
103+
font_paths: Iterable[str | Path],
104+
text: str,
105+
*,
106+
min_ratio: float = 1.0,
107+
) -> Sequence[str]:
108+
"""Subset of `font_paths` that can draw `text` to at least `min_ratio` coverage.
109+
110+
Used to pick a font per sample instead of discovering mid-render that the chosen
111+
face has no Arabic in it.
112+
"""
113+
eligible: list[str] = []
114+
for path in font_paths:
115+
try:
116+
report = supports_text(path, text)
117+
except Exception:
118+
continue # unreadable or exotic font container: treat as ineligible
119+
if report.ratio >= min_ratio:
120+
eligible.append(str(path))
121+
return eligible

src/ocrsmith/text/normalization.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
"""Text normalisation policies.
2+
3+
Every transform here changes the ground-truth label, not just the pixels, so each one is
4+
opt-in and recorded on the sample. A dataset that silently strips diacritics teaches a
5+
model to drop them; that has to be a deliberate choice.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import re
11+
import unicodedata
12+
from dataclasses import dataclass
13+
from enum import Enum
14+
15+
__all__ = [
16+
"NormalizationPolicy",
17+
"NumeralSystem",
18+
"normalize_text",
19+
"strip_diacritics",
20+
"strip_tatweel",
21+
"to_numeral_system",
22+
]
23+
24+
TATWEEL = "ـ"
25+
26+
# Tashkeel, Quranic annotation marks and the superscript alef.
27+
_DIACRITICS = re.compile(
28+
"["
29+
"ؐ-ؚ"
30+
"ً-ٟ"
31+
"ٰ"
32+
"ۖ-ۜ"
33+
"۟-ۨ"
34+
"۪-ۭ"
35+
"࣓-ࣿ"
36+
"]"
37+
)
38+
39+
_ALEF_VARIANTS = str.maketrans({"أ": "ا", "إ": "ا", "آ": "ا", "ٱ": "ا"})
40+
_YA_VARIANTS = str.maketrans({"ى": "ي"})
41+
_TA_MARBUTA = str.maketrans({"ة": "ه"})
42+
43+
_WESTERN_DIGITS = "0123456789"
44+
_ARABIC_INDIC_DIGITS = "٠١٢٣٤٥٦٧٨٩"
45+
_EASTERN_DIGITS = "۰۱۲۳۴۵۶۷۸۹"
46+
47+
_HORIZONTAL_WS = re.compile(r"[^\S\n]+")
48+
_ALL_WS = re.compile(r"\s+")
49+
50+
51+
class NumeralSystem(str, Enum):
52+
"""Which digit shapes appear in the rendered text and in the label."""
53+
54+
KEEP = "keep"
55+
WESTERN = "western"
56+
ARABIC_INDIC = "arabic_indic"
57+
EASTERN_ARABIC_INDIC = "eastern_arabic_indic"
58+
59+
60+
_DIGIT_TABLES: dict[NumeralSystem, str] = {
61+
NumeralSystem.WESTERN: _WESTERN_DIGITS,
62+
NumeralSystem.ARABIC_INDIC: _ARABIC_INDIC_DIGITS,
63+
NumeralSystem.EASTERN_ARABIC_INDIC: _EASTERN_DIGITS,
64+
}
65+
66+
_ALL_DIGITS = _WESTERN_DIGITS + _ARABIC_INDIC_DIGITS + _EASTERN_DIGITS
67+
68+
69+
def strip_diacritics(text: str) -> str:
70+
"""Remove Arabic tashkeel and Quranic annotation marks, leaving the skeleton."""
71+
return _DIACRITICS.sub("", text)
72+
73+
74+
def strip_tatweel(text: str) -> str:
75+
"""Remove kashida (tatweel) elongation characters."""
76+
return text.replace(TATWEEL, "")
77+
78+
79+
def to_numeral_system(text: str, system: NumeralSystem) -> str:
80+
"""Rewrite every digit in `text` using `system`'s digit shapes."""
81+
if system is NumeralSystem.KEEP:
82+
return text
83+
target = _DIGIT_TABLES[system]
84+
table = {ord(digit): target[index % 10] for index, digit in enumerate(_ALL_DIGITS)}
85+
return text.translate(table)
86+
87+
88+
@dataclass(frozen=True, slots=True)
89+
class NormalizationPolicy:
90+
"""Declarative description of how raw source text becomes a label.
91+
92+
Defaults are deliberately conservative: only runs of whitespace are collapsed, which
93+
no OCR label format preserves anyway.
94+
"""
95+
96+
collapse_whitespace: bool = True
97+
preserve_line_breaks: bool = False
98+
strip_diacritics: bool = False
99+
strip_tatweel: bool = False
100+
unify_alef: bool = False
101+
unify_ya: bool = False
102+
unify_ta_marbuta: bool = False
103+
numerals: NumeralSystem = NumeralSystem.KEEP
104+
unicode_form: str | None = "NFC"
105+
106+
def apply(self, text: str) -> str:
107+
return normalize_text(text, self)
108+
109+
110+
def normalize_text(text: str, policy: NormalizationPolicy | None = None) -> str:
111+
"""Apply `policy` to `text`.
112+
113+
The order is fixed and the result is idempotent: composing characters are folded
114+
first so that later character-level rules see a canonical form.
115+
"""
116+
policy = policy or NormalizationPolicy()
117+
118+
if policy.unicode_form:
119+
text = unicodedata.normalize(policy.unicode_form, text)
120+
if policy.strip_diacritics:
121+
text = strip_diacritics(text)
122+
if policy.strip_tatweel:
123+
text = strip_tatweel(text)
124+
if policy.unify_alef:
125+
text = text.translate(_ALEF_VARIANTS)
126+
if policy.unify_ya:
127+
text = text.translate(_YA_VARIANTS)
128+
if policy.unify_ta_marbuta:
129+
text = text.translate(_TA_MARBUTA)
130+
text = to_numeral_system(text, policy.numerals)
131+
132+
if policy.collapse_whitespace:
133+
if policy.preserve_line_breaks:
134+
text = _HORIZONTAL_WS.sub(" ", text)
135+
# Keep one break between non-empty lines and no leading/trailing padding,
136+
# so a paragraph's line structure survives without ragged indentation.
137+
text = "\n".join(line.strip() for line in text.split("\n") if line.strip())
138+
else:
139+
text = _ALL_WS.sub(" ", text)
140+
text = text.strip()
141+
return text

0 commit comments

Comments
 (0)