feat(python): draw Arabic text in vis() with khatt from the rtl extra (#763)

* feat(python): draw Arabic text in vis() with khatt from the rtl extra

Recognized Arabic, Persian and Urdu text is returned in reading order,
but vis() draws it with ImageDraw.text, which only joins Arabic letters
and lays out right-to-left text when Pillow is built with Raqm. Without
it the visualization shows separate letters in reverse order, running
out of the box.

When khatt is installed (now part of the rtl extra on Python 3.10+),
right-to-left lines are shaped, start at the right edge of their box,
are centered vertically and shrink to fit the box. Other text, and RTL
text without khatt, is drawn by Pillow as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* style(python): ruff format the vis RTL changes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(python): let RTL text shrink below 10px to fit small boxes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Mohammad EssamandClaude Opus 5.5 authored and GitHub committed 2026-11-04 22:01:00 +08:00
1 parent 80b427d69b
commit a538d3a75e
4 files changed
+154 -7

No files matched your search

+5 -1
View File
@@ -44,7 +44,11 @@ Changelog = "https://github.com/RapidAI/RapidOCR/releases"
rapidocr = "rapidocr.main:main"
[project.optional-dependencies]
rtl = ["python-bidi>=0.4.2,<1.0"]
rtl = [
"python-bidi>=0.4.2,<1.0",
# Shapes Arabic text in vis(); needs Python 3.10+
"khatt>=0.4.0; python_version >= '3.10'",
]
[tool.setuptools]
include-package-data = true
+59 -4
View File
@@ -3,6 +3,7 @@
# @Contact: liekkaskono@163.com
import math
import random
import re
from functools import lru_cache
from pathlib import Path
from typing import List, Optional, Sequence, Tuple, Union
@@ -18,6 +19,13 @@ from .log import logger
from .model_resolver import normalize_lang
from .typings import LangRec
try:
# Part of the rtl extra: joins Arabic letters and lays out right-to-left
# text, which Pillow only does when it is built with Raqm
import khatt
except ImportError:
khatt = None
root_dir = Path(__file__).resolve().parent.parent
InputType = Union[str, np.ndarray, bytes, Path, Image.Image]
@@ -25,6 +33,11 @@ DEFAULT_FONT_DIR = root_dir / "models"
DEFAULT_FONT_PATH = DEFAULT_FONT_DIR / "FZYTK.TTF"
FONT_YAML_PATH = root_dir / "default_models.yaml"
# Hebrew, Arabic, Syriac, Thaana, N'Ko and the Arabic presentation forms
RTL_PATTERN = re.compile("[֐-ࣿיִ-﷿ﹰ-ﻼ]")
# Arabic glyphs for a font that has none, e.g. the default FZYTK.TTF
RTL_FALLBACK_FONTS = ["noto-naskh"]
@lru_cache()
def font_cfg():
@@ -173,8 +186,7 @@ class VisRes:
cur_y += self.get_char_size(font, c)
else:
font_size = max(int(box_height * 0.8), 10)
font = ImageFont.truetype(self.font_path, font_size, encoding="utf-8")
draw_right.text([box[0][0], box[0][1]], txt, fill=(0, 0, 0), font=font)
self.draw_line(img_right, draw_right, box, txt, font_size)
img_left = Image.blend(image, img_left, 0.5)
img_show = Image.new("RGB", (w * 2, h), 255)
@@ -224,8 +236,7 @@ class VisRes:
cur_y += self.get_char_size(font, c)
else:
font_size = max(int(box_height * 0.8), 10)
font = ImageFont.truetype(self.font_path, font_size, encoding="utf-8")
draw_right.text([box[0][0], box[0][1]], txt, fill=(0, 0, 0), font=font)
self.draw_line(img_right, draw_right, box, txt, font_size)
img_left = Image.blend(image, img_left, 0.5)
img_show = Image.new("RGB", (w * 2, h), (255, 255, 255))
@@ -233,6 +244,50 @@ class VisRes:
img_show.paste(img_right, (w, 0, w * 2, h))
return np.array(img_show)
def draw_line(
self,
img: Image.Image,
draw: ImageDraw.ImageDraw,
box: List[List[Union[float, int]]],
txt: str,
font_size: int,
) -> None:
"""Draw a horizontal line of text in its box.
With khatt installed (``pip install "rapidocr[rtl]"``), right-to-left text
is shaped and starts at the right edge of the box, where it was read. It
is centered vertically, and made smaller if it would not fit the box.
"""
if khatt is not None and RTL_PATTERN.search(txt):
options = {"font": self.font_path, "fallback_fonts": RTL_FALLBACK_FONTS}
left, top, right, bottom = khatt.text_bbox(
txt, size_px=font_size, **options
)
scale = min(
self.get_box_width(box) / max(right - left, 1),
self.get_box_height(box) / max(bottom - top, 1),
)
if scale < 1:
font_size = max(int(font_size * scale), 1)
left, top, right, bottom = khatt.text_bbox(
txt, size_px=font_size, **options
)
middle_y = (box[1][1] + box[2][1]) / 2
khatt.draw_text(
img,
(float(box[1][0]), float(middle_y - (bottom - top) / 2)),
txt,
size_px=font_size,
fill=(0, 0, 0),
anchor="rt",
**options,
)
return
font = ImageFont.truetype(self.font_path, font_size, encoding="utf-8")
draw.text([box[0][0], box[0][1]], txt, fill=(0, 0, 0), font=font)
@staticmethod
def get_random_color() -> Tuple[int, int, int]:
return (
+89 -1
View File
@@ -5,11 +5,13 @@ import sys
from pathlib import Path
import pytest
from PIL import Image, ImageDraw, ImageOps
root_dir = Path(__file__).resolve().parent.parent
sys.path.append(str(root_dir))
from rapidocr import RapidOCR
from rapidocr import LangRec, ModelType, OCRVersion, RapidOCR
from rapidocr.utils import vis_res
tests_dir = root_dir / "tests" / "test_files"
img_path = tests_dir / "ch_en_num.jpg"
@@ -47,3 +49,89 @@ def test_vis_det_cls_rec(engine):
result = engine(img_path, use_det=True, use_cls=True, use_rec=True)
vis_img = result.vis()
assert vis_img.shape[2] == 3
ARABIC_TEXT = "امروز هوا خوبه"
def ink_box(img):
return ImageOps.invert(img.convert("L")).getbbox()
def test_vis_draws_rtl_text_with_khatt(monkeypatch):
khatt = pytest.importorskip("khatt")
drawn = []
draw_text = khatt.draw_text
def spy(img, xy, txt, **kwargs):
drawn.append(txt)
return draw_text(img, xy, txt, **kwargs)
monkeypatch.setattr(vis_res.khatt, "draw_text", spy)
engine = RapidOCR(
params={
"Rec.lang_type": LangRec.ARABIC,
"Rec.model_type": ModelType.MOBILE,
"Rec.ocr_version": OCRVersion.PPOCRV5,
}
)
result = engine(
tests_dir / "arabic.png", use_det=False, use_cls=False, use_rec=True
)
vis_img = result.vis()
assert vis_img.shape[2] == 3
assert drawn == list(result.txts)
def test_rtl_line_starts_at_the_right_edge_and_fits_its_box():
pytest.importorskip("khatt")
vis = vis_res.VisRes(lang_type=LangRec.ARABIC)
img = Image.new("RGB", (300, 60), (255, 255, 255))
box = [[20, 10], [280, 10], [280, 50], [20, 50]]
vis.draw_line(img, ImageDraw.Draw(img), box, ARABIC_TEXT, 40)
left, top, right, bottom = ink_box(img)
assert right == 280
assert left >= 20 and top >= 10 and bottom <= 50
def test_rtl_line_shrinks_below_10px_to_fit_a_small_box():
pytest.importorskip("khatt")
vis = vis_res.VisRes(lang_type=LangRec.ARABIC)
img = Image.new("RGB", (100, 30), (255, 255, 255))
box = [[20, 10], [60, 10], [60, 18], [20, 18]]
vis.draw_line(img, ImageDraw.Draw(img), box, ARABIC_TEXT, 10)
left, top, right, bottom = ink_box(img)
assert left >= 20 and right <= 60 and top >= 10 and bottom <= 18
def test_ltr_line_is_drawn_by_pillow(monkeypatch):
pytest.importorskip("khatt")
monkeypatch.setattr(
vis_res.khatt, "draw_text", lambda *args, **kwargs: pytest.fail("khatt used")
)
vis = vis_res.VisRes()
img = Image.new("RGB", (300, 60), (255, 255, 255))
vis.draw_line(
img,
ImageDraw.Draw(img),
[[20, 10], [280, 10], [280, 50], [20, 50]],
"Hello",
32,
)
assert ink_box(img) is not None
def test_rtl_line_without_khatt_is_drawn_by_pillow(monkeypatch):
monkeypatch.setattr(vis_res, "khatt", None)
vis = vis_res.VisRes(lang_type=LangRec.ARABIC)
img = Image.new("RGB", (300, 60), (255, 255, 255))
vis.draw_line(
img,
ImageDraw.Draw(img),
[[20, 10], [280, 10], [280, 50], [20, 50]],
ARABIC_TEXT,
32,
)
assert ink_box(img)[0] >= 20