@@ -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+
6882class 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 )
120133def _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 )
0 commit comments