Skip to content

Commit a6bb4c5

Browse files
authored
Merge pull request #11 from atlasia-ma/perf/shaping-cache
perf(text): stop paying for arabic-reshaper's broken ligature cache
2 parents 871a9e1 + b2c4b19 commit a6bb4c5

10 files changed

Lines changed: 156 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,32 @@ 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.1] - 2026-08-13
8+
9+
### Fixed
10+
11+
- **Shaping was 68% of generation time.** arabic-reshaper 3.0.0 guards its ligature-regex
12+
cache with `hasattr(self, '__ligatures_re')`, but writes the cache to
13+
`self.__ligatures_re` — which Python mangles to `_ArabicReshaper__ligatures_re` inside
14+
the class body, while the string literal passed to `hasattr` is not mangled. The guard
15+
therefore checks a name that is never set, and every call rebuilt the regex, re-reading
16+
~290 configparser entries. Laying out one page called it ~1,800 times.
17+
18+
Two mitigations, both contained to `ocrsmith.text.shaping`: results are cached (shaping
19+
is a pure function of the string), and the reshaper's cache is warmed once so the
20+
library's own guard fires from the second call. 2,000 distinct strings: 26s -> 0.36s.
21+
22+
### Changed
23+
24+
- Wrapping now measures a line the way the renderer draws it — summing word advances plus
25+
space advances — instead of measuring the whole candidate line as one shaped run. This
26+
is a correctness improvement as well as a speed one: the two could previously disagree
27+
about where a line ends. It also keys the measurement cache on *words*, which repeat,
28+
rather than on line prefixes, which never do.
29+
30+
Test suite runtime: 95s -> 20s. Generation throughput on a mixed Arabic corpus improved
31+
~1.6x end to end; the remaining cost is genuine rasterisation and degradation work.
32+
733
## [1.0.0] - 2026-08-12
834

935
The first release of OCRSmith as a document forge rather than a line-image generator.
@@ -131,4 +157,5 @@ page before it reaches the dataset.
131157
were unset, which made every sample fail for configs that omit them.
132158
- Whitespace-only input no longer produces a zero-sized canvas.
133159

160+
[1.0.1]: https://github.com/atlasia-ma/OCRSmith/releases/tag/v1.0.1
134161
[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.0"
7+
version = "1.0.1"
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.0"
32+
__version__ = "1.0.1"

src/ocrsmith/core/documents/flow.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,7 @@ def _place_text(
284284
lines = [
285285
wrapped
286286
for source in text.split("\n")
287-
for wrapped in wrap_paragraph(source, metrics.advance, column.width)
287+
for wrapped in wrap_paragraph(source, metrics.line_advance, column.width)
288288
]
289289
if not lines:
290290
return (None, role.space_after, None)

src/ocrsmith/core/documents/table_renderer.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ def _column_widths(
121121
for cell in table.cells:
122122
if cell.col_span > 1:
123123
continue # spanning cells do not constrain a single column
124-
natural[cell.col] = max(natural[cell.col], metrics.advance(cell.text) + padding)
124+
natural[cell.col] = max(natural[cell.col], metrics.line_advance(cell.text) + padding)
125125

126126
total = sum(natural)
127127
if total <= max_width or total <= 0:
@@ -138,7 +138,7 @@ def _wrap_cells(
138138
for cell in table.cells:
139139
span_width = sum(col_widths[cell.col : cell.col + cell.col_span])
140140
available = max(1.0, span_width - inner_padding)
141-
wrapped[(cell.row, cell.col)] = wrap_paragraph(cell.text, metrics.advance, available)
141+
wrapped[(cell.row, cell.col)] = wrap_paragraph(cell.text, metrics.line_advance, available)
142142
return wrapped
143143

144144
def _row_heights(

src/ocrsmith/core/rendering/metrics.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,20 @@ def extent(self, text: str) -> TextExtent:
8181
def advance(self, text: str) -> float:
8282
return self._extent(text).advance
8383

84+
def line_advance(self, text: str) -> float:
85+
"""Width of `text` as the renderer will actually lay it out.
86+
87+
The renderer positions words individually and adds a space advance between them,
88+
so measuring the whole string as one shaped run could disagree with what gets
89+
drawn. Summing per word also means the measurement cache is keyed on *words*,
90+
which repeat, rather than on line prefixes, which never do — wrapping a paragraph
91+
asks for "a", "a b", "a b c" … and none of those would ever hit.
92+
"""
93+
words = text.split()
94+
if not words:
95+
return 0.0
96+
return sum(self.advance(word) for word in words) + self.space_advance * (len(words) - 1)
97+
8498
@property
8599
def space_advance(self) -> float:
86100
return self._extent(" ").advance

src/ocrsmith/core/rendering/text_renderer.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ def render(
126126
metrics = metrics_for(font, self.shaper)
127127
base_direction = direction or detect_direction(text)
128128

129-
line_texts = list(wrap_text(text.split("\n"), metrics.advance, max_width))
129+
line_texts = list(wrap_text(text.split("\n"), metrics.line_advance, max_width))
130130
line_height = metrics.line_height(style.line_spacing)
131131
line_texts, dropped = fit_lines(line_texts, line_height, max_height)
132132

src/ocrsmith/text/shaping.py

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,20 @@ def shape(self, text: str) -> ShapedText:
6565
return ShapedText(logical=text, visual=text, direction=detect_direction(text))
6666

6767

68+
#: Shaping is a pure function of the string, so results are cached. This is not a
69+
#: micro-optimisation: arabic-reshaper 3.0.0 guards its ligature-regex cache with
70+
#: `hasattr(self, '__ligatures_re')`, and because that string literal is not name-mangled
71+
#: the guard never fires — so every single call rebuilds the regex, re-reading ~290
72+
#: configparser entries. Laying out one page called it ~1_800 times, which measured as 68%
73+
#: of total generation time. Caching here sidesteps it without patching their library.
74+
_SHAPE_CACHE_SIZE = 200_000
75+
76+
77+
@lru_cache(maxsize=_SHAPE_CACHE_SIZE)
78+
def _visual_form(text: str) -> str:
79+
return _bidi_display(_reshape(text))
80+
81+
6882
class ReshaperBidiShaper:
6983
"""Substitute Arabic presentation forms and reorder runs to visual order."""
7084

@@ -74,8 +88,7 @@ def shape(self, text: str) -> ShapedText:
7488
direction = detect_direction(text)
7589
if not text:
7690
return ShapedText(logical=text, visual=text, direction=direction)
77-
visual = _bidi_display(_reshape(text))
78-
return ShapedText(logical=text, visual=visual, direction=direction)
91+
return ShapedText(logical=text, visual=_visual_form(text), direction=direction)
7992

8093

8194
_BACKENDS: dict[str, type] = {
@@ -118,9 +131,26 @@ def resolve_shaper(backend: str = "auto") -> TextShaper:
118131

119132
@lru_cache(maxsize=1)
120133
def _reshaper():
121-
import arabic_reshaper
134+
"""A reshaper whose ligature-regex cache actually works.
135+
136+
arabic-reshaper 3.0.0 guards that cache with `hasattr(self, '__ligatures_re')`, but
137+
writes it to `self.__ligatures_re` — which, inside the class body, Python mangles to
138+
`_ArabicReshaper__ligatures_re`. The string passed to `hasattr` is *not* mangled, so
139+
the guard checks a name that is never set and the regex is rebuilt on every call,
140+
re-reading around 290 configparser entries each time.
141+
142+
Warming the property once and then setting the unmangled name makes the guard fire
143+
from the second call onwards. Reaching into a third-party private attribute is not
144+
something to do lightly; it is contained to this adapter, and the alternative is
145+
paying that cost on every word of every page.
146+
"""
147+
from arabic_reshaper import ArabicReshaper
122148

123-
return arabic_reshaper.reshape
149+
reshaper = ArabicReshaper()
150+
reshaper._ligatures_re # noqa: B018 - builds and caches under the mangled name
151+
if not hasattr(reshaper, "__ligatures_re"):
152+
object.__setattr__(reshaper, "__ligatures_re", True)
153+
return reshaper.reshape
124154

125155

126156
@lru_cache(maxsize=1)

tests/test_rendering_text_block.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,3 +236,40 @@ def test_translated_annotation_moves_with_the_paste(self, renderer, font):
236236
original = rendered.lines[0].words[0].bbox
237237
assert moved[0].words[0].bbox.x0 == original.x0 + 100
238238
assert moved[0].words[0].bbox.y0 == original.y0 + 50
239+
240+
241+
class TestLineMeasurement:
242+
"""Wrapping must measure a line the way the renderer draws it.
243+
244+
The renderer places words individually and adds a space advance between them. If
245+
wrapping measured the line as one shaped run instead, the two could disagree about
246+
where a line ends — and the disagreement would show up as text overflowing its column.
247+
"""
248+
249+
def test_line_advance_is_the_sum_of_word_advances_and_gaps(self, font):
250+
from ocrsmith.core.rendering.metrics import metrics_for
251+
252+
metrics = metrics_for(font)
253+
words = ["mad", "rasa", "kbira"]
254+
255+
expected = sum(metrics.advance(w) for w in words) + metrics.space_advance * 2
256+
257+
assert metrics.line_advance(" ".join(words)) == pytest.approx(expected)
258+
259+
def test_a_single_word_measures_as_itself(self, font):
260+
from ocrsmith.core.rendering.metrics import metrics_for
261+
262+
metrics = metrics_for(font)
263+
264+
assert metrics.line_advance("مرحبا") == pytest.approx(metrics.advance("مرحبا"))
265+
266+
def test_empty_text_has_no_width(self, font):
267+
from ocrsmith.core.rendering.metrics import metrics_for
268+
269+
assert metrics_for(font).line_advance(" ") == 0.0
270+
271+
def test_wrapped_lines_fit_the_column_they_were_measured_against(self, renderer, font):
272+
rendered = renderer.render(ARABIC + " " + LATIN, font, max_width=240)
273+
274+
for line in rendered.lines:
275+
assert line.bbox.width <= 240 + 8 # canvas bleed

tests/test_text_shaping.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,41 @@ def test_unknown_backend_is_rejected(self):
133133
def test_any_shaper_keeps_the_label_intact(self):
134134
for backend in ("raqm", "reshaper"):
135135
assert resolve_shaper(backend).shape(HELLO_AR).logical == HELLO_AR
136+
137+
138+
class TestShapingCache:
139+
"""Shaping is cached because arabic-reshaper is pathologically slow per call.
140+
141+
Caching a pure function is only safe if it really is pure, so these pin the
142+
behaviour the cache must not change.
143+
"""
144+
145+
def test_repeated_shaping_is_stable(self):
146+
shaper = ReshaperBidiShaper()
147+
148+
first = shaper.shape(HELLO_AR)
149+
second = shaper.shape(HELLO_AR)
150+
151+
assert first == second
152+
153+
def test_the_cache_does_not_bleed_between_inputs(self):
154+
shaper = ReshaperBidiShaper()
155+
156+
one = shaper.shape("مرحبا").visual
157+
two = shaper.shape("بالعالم").visual
158+
159+
assert one != two
160+
assert shaper.shape("مرحبا").visual == one
161+
162+
def test_lam_alef_still_forms_a_ligature(self):
163+
# The reshaper's ligature table is what the cache-warming workaround touches, so
164+
# a lost ligature is the failure mode to watch for.
165+
shaped = ReshaperBidiShaper().shape("لا")
166+
167+
assert len(shaped.visual) == 1
168+
assert shaped.logical == "لا"
169+
170+
def test_a_shaped_word_keeps_its_character_count_otherwise(self):
171+
shaped = ReshaperBidiShaper().shape("بالعالم")
172+
173+
assert len(shaped.visual) == len("بالعالم")

0 commit comments

Comments
 (0)