""" مُعيد تجميع النصوص ===================== إعادة تجميع الكلمات المكتشفة من OCR إلى نصوص مترابطة مع دعم خاص للنصوص العربية (RTL). القدرات: - تجميع الكلمات بناءً على إحداثياتها (x, y) - تجميع الكلمات في سطور حسب القرب العمودي - دعم النص العربي RTL باستخدام arabic-reshaper و python-bidi - التعامل مع النصوص المختلطة (عربي + إنجليزي) """ import logging import re from typing import Optional logger = logging.getLogger(__name__) class TextReconstructor: """ مُعيد تجميع النصوص - يعيد بناء الجمل من نتائج OCR على مستوى الكلمات. مثال الاستخدام: >>> reconstructor = TextReconstructor(line_threshold=15) >>> words = [ ... {"text": "مرحبا", "x": 200, "y": 10, "w": 50, "h": 20}, ... {"text": "بالعالم", "x": 140, "y": 10, "w": 60, "h": 20}, ... {"text": "Hello", "x": 10, "y": 50, "w": 40, "h": 20}, ... ] >>> text = reconstructor.reconstruct(words, direction="rtl") """ def __init__( self, line_threshold: float = 15.0, word_gap_threshold: float = 50.0, default_direction: str = "auto", ) -> None: """ تهيئة مُعيد التجميع. Args: line_threshold: أقصى فرق عمودي (Y) لاعتبار كلمتين في نفس السطر word_gap_threshold: أقل مسافة أفقية لفصل الكلمات بمسافة default_direction: الاتجاه الافتراضي ("auto", "rtl", "ltr") """ self.line_threshold = line_threshold self.word_gap_threshold = word_gap_threshold self.default_direction = default_direction # التحقق من مكتبات إعادة تشكيل العربية self._has_reshaper = self._check_library( "arabic_reshaper", "arabic-reshaper" ) self._has_bidi = self._check_library( "bidi", "python-bidi" ) if not self._has_reshaper: logger.warning( "arabic-reshaper غير مثبت. النص العربي قد لا يظهر بشكل صحيح. " "قم بالتثبيت: pip install arabic-reshaper" ) if not self._has_bidi: logger.warning( "python-bidi غير مثبت. اتجاه النص قد لا يكون صحيحاً. " "قم بالتثبيت: pip install python-bidi" ) @staticmethod def _check_library(import_name: str, package_name: str) -> bool: """التحقق من توفر مكتبة.""" try: __import__(import_name) return True except ImportError: return False # ------------------------------------------------------------------ # الأساليب العامة (Public API) # ------------------------------------------------------------------ def reconstruct( self, words: list[dict], direction: str = "auto", ) -> str: """ إعادة تجميع قائمة كلمات إلى نص مترابط. Args: words: قائمة كلمات، كل كلمة قاموس يحتوي: - text: النص - x, y: موقع أعلى اليسار - w, h: العرض والارتفاع direction: اتجاه النص ("auto", "rtl", "ltr") Returns: النص المُعاد تجميعه """ if not words: return "" # تنظيف الكلمات الفارغة valid_words = [ w for w in words if w.get("text", "").strip() and all(k in w for k in ("x", "y", "w", "h")) ] if not valid_words: return "" # تحديد الاتجاه detected_direction = self._detect_direction(valid_words, direction) # تجميع الكلمات في سطور lines = self._group_into_lines(valid_words) # ترتيب الكلمات داخل كل سطر ordered_lines: list[str] = [] for line_words in lines: line_text = self._order_line(line_words, detected_direction) ordered_lines.append(line_text) # دمج الأسطر full_text = "\n".join(ordered_lines) return full_text.strip() def reconstruct_with_direction( self, words: list[dict], direction: str = "rtl", ) -> str: """ إعادة تجميع النصوص مع تحديد الاتجاه بشكل صريح. Args: words: قائمة كلمات OCR direction: "rtl" أو "ltr" Returns: النص المُعاد تجميعه مع معالجة الاتجاه """ if direction not in ("rtl", "ltr"): logger.warning( "اتجاه غير معروف '%s' - سيتم استخدام auto", direction ) return self.reconstruct(words, direction="auto") text = self.reconstruct(words, direction=direction) # إعادة تشكيل النص العربي إذا توفرت المكتبات if direction == "rtl" and self._has_reshaper and self._has_bidi: text = self._apply_arabic_reshaping(text) return text def get_statistics(self, words: list[dict]) -> dict: """ الحصول على إحصائيات حول نتائج OCR. Args: words: قائمة كلمات OCR Returns: قاموس يحتوي إحصائيات """ if not words: return {"total_words": 0} valid_words = [ w for w in words if w.get("text", "").strip() ] lines = self._group_into_lines(valid_words) # اكتشاف نسبة العربية arabic_count = sum( 1 for w in valid_words if self._is_arabic_text(w.get("text", "")) ) return { "total_words": len(valid_words), "total_lines": len(lines), "arabic_words": arabic_count, "english_words": len(valid_words) - arabic_count, "arabic_ratio": arabic_count / max(1, len(valid_words)), "direction": self._detect_direction(valid_words, "auto"), } # ------------------------------------------------------------------ # الأساليب الداخلية - التجميع والترتيب # ------------------------------------------------------------------ def _group_into_lines( self, words: list[dict] ) -> list[list[dict]]: """ تجميع الكلمات في أسطر بناءً على القرب العمودي. الخوارزمية: 1. ترتيب الكلمات حسب Y 2. تجميع الكلمات القريبة عمودياً في نفس السطر 3. استخدام المتوسط المتحرك لحدود الأسطر Args: words: قائمة كلمات صالحة Returns: قائمة أسطر، كل سطر قائمة كلمات """ # ترتيب حسب Y أولاً (الصفوف العلوية أولاً) sorted_words = sorted(words, key=lambda w: w["y"]) lines: list[list[dict]] = [] current_line: list[dict] = [sorted_words[0]] for word in sorted_words[1:]: # حساب متوسط Y للسطر الحالي avg_y = sum(w["y"] for w in current_line) / len(current_line) word_center_y = word["y"] + word["h"] / 2 current_center_y = avg_y + (current_line[0]["h"] / 2) # إذا كانت الكلمة قريبة عمودياً من السطر الحالي if abs(word_center_y - current_center_y) <= self.line_threshold: current_line.append(word) else: # سطر جديد lines.append(current_line) current_line = [word] # إضافة السطر الأخير if current_line: lines.append(current_line) # ترتيب كل سطر حسب Y المتوسط (للضمان) lines.sort(key=lambda line: sum(w["y"] for w in line) / len(line)) return lines def _order_line( self, line_words: list[dict], direction: str, ) -> str: """ ترتيب الكلمات داخل سطر وبناء النص. Args: line_words: كلمات في نفس السطر direction: اتجاه النص Returns: نص السطر """ if not line_words: return "" # ترتيب حسب X (اليسار لليمين أولاً) sorted_by_x = sorted(line_words, key=lambda w: w["x"]) if direction == "rtl": # للعربية: الكلمات على اليمين تأتي أولاً # لكننا نبقي ترتيب X لأن الكلمة اليمنى لها x أكبر # نحتاج لعكس الترتيب sorted_by_x = sorted(line_words, key=lambda w: -w["x"]) # بناء النص مع مراعاة المسافات result_parts: list[str] = [] for i, word in enumerate(sorted_by_x): text = word["text"].strip() if not text: continue if i == 0: result_parts.append(text) else: # حساب المسافة من الكلمة السابقة prev_word = sorted_by_x[i - 1] gap = self._calculate_gap(prev_word, word, direction) if gap > self.word_gap_threshold: # مسافة كبيرة = مسافة بين كلمات result_parts.append(" ") result_parts.append(text) else: # مسافة صغيرة = كلمات متصلة أو مسافة عادية result_parts.append(" ") result_parts.append(text) return "".join(result_parts).strip() @staticmethod def _calculate_gap( word1: dict, word2: dict, direction: str ) -> float: """ حساب المسافة الأفقية بين كلمتين. Args: word1: الكلمة الأولى word2: الكلمة الثانية direction: اتجاه النص Returns: المسافة بالبكسل """ if direction == "rtl": # للعربية: word1 على اليمين و word2 على اليسار # word1.x > word2.x (عادةً) # المسافة = word1.x - (word2.x + word2.w) right_word = word1 if word1["x"] > word2["x"] else word2 left_word = word2 if word1["x"] > word2["x"] else word1 return max(0, left_word["x"] - (right_word["x"] + right_word["w"])) else: # للإنجليزية: word1 على اليسار و word2 على اليمين left_word = word1 if word1["x"] < word2["x"] else word2 right_word = word2 if word1["x"] < word2["x"] else word1 return max(0, right_word["x"] - (left_word["x"] + left_word["w"])) # ------------------------------------------------------------------ # الأساليب الداخلية - كشف الاتجاه # ------------------------------------------------------------------ def _detect_direction( self, words: list[dict], hint: str ) -> str: """ اكتشاف اتجاه النص تلقائياً أو استخدام الإشارة المحددة. Args: words: قائمة الكلمات hint: الإشارة ("auto", "rtl", "ltr") Returns: "rtl" أو "ltr" """ if hint in ("rtl", "ltr"): return hint # كشف تلقائي if hint == "auto" or hint not in ("rtl", "ltr"): arabic_chars = 0 latin_chars = 0 arabic_ranges = [ (0x0600, 0x06FF), (0x0750, 0x077F), (0x08A0, 0x08FF), (0xFB50, 0xFDFF), (0xFE70, 0xFEFF), (0x0660, 0x0669), ] for word in words: text = word.get("text", "") if not text.strip(): continue for char in text: code = ord(char) if any(start <= code <= end for start, end in arabic_ranges): arabic_chars += 1 elif ("A" <= char <= "Z") or ("a" <= char <= "z"): latin_chars += 1 if arabic_chars == 0 and latin_chars == 0: return "ltr" if arabic_chars > latin_chars: logger.debug( "اتجاه RTL مكتشف (حروف عربية: %d، لاتينية: %d)", arabic_chars, latin_chars, ) return "rtl" logger.debug( "اتجاه LTR مكتشف (حروف عربية: %d، لاتينية: %d)", arabic_chars, latin_chars, ) return "ltr" return "ltr" @staticmethod def _is_arabic_text(text: str) -> bool: """ التحقق مما إذا كان النص يحتوي على حروف عربية. يتحقق من وجود حروف عربية (U+0600–U+06FF) أو أرقام هندية. Args: text: النص المراد فحصه Returns: True إذا كان النص يحتوي على عربية """ if not text: return False # نطاقات اليونيكود العربية arabic_ranges = [ (0x0600, 0x06FF), # الحروف العربية (0x0750, 0x077F), # امتدادات العربية (0x08A0, 0x08FF), # امتدادات إضافية (0xFB50, 0xFDFF), # أشكال العرض العربية (0xFE70, 0xFEFF), # أشكال العرض العربية - B (0x0660, 0x0669), # الأرقام الهندية ] for char in text: code = ord(char) for start, end in arabic_ranges: if start <= code <= end: return True return False @staticmethod def _is_latin_text(text: str) -> bool: """ التحقق مما إذا كان النص يحتوي على حروف لاتينية/إنجليزية. Args: text: النص المراد فحصه Returns: True إذا كان النص يحتوي على لاتينية """ if not text: return False for char in text: if ("A" <= char <= "Z") or ("a" <= char <= "z"): return True return False # ------------------------------------------------------------------ # أساليب إعادة تشكيل النص العربي # ------------------------------------------------------------------ def _apply_arabic_reshaping(self, text: str) -> str: """ إعادة تشكيل النص العربي ليظهر بشكل صحيح. تستخدم arabic-reshaper لتوصيل الحروف و python-bidi لعكس اتجاه العرض. Args: text: النص العربي الخام Returns: النص المعاد تشكيله """ if not text: return text try: import arabic_reshaper from bidi.algorithm import get_display # تقسيم النص إلى أسطر ومعالجة كل سطر lines = text.split("\n") reshaped_lines: list[str] = [] for line in lines: if not line.strip(): reshaped_lines.append(line) continue # التعامل مع النص المختلط (عربي + إنجليزي) segments = self._split_mixed_text(line) reshaped_segments: list[str] = [] for segment in segments: if segment["type"] == "arabic": reshaped = arabic_reshaper.reshape(segment["text"]) displayed = get_display(reshaped) reshaped_segments.append(displayed) else: reshaped_segments.append(segment["text"]) reshaped_lines.append("".join(reshaped_segments)) return "\n".join(reshaped_lines) except Exception as e: logger.warning("فشل في إعادة تشكيل النص العربي: %s", e) return text @staticmethod def _split_mixed_text(text: str) -> list[dict]: """ تقسيم النص المختلط إلى أجزاء عربية وغير عربية. Args: text: النص المختلط Returns: قائمة أجزاء: [{"text": "...", "type": "arabic|other"}] """ if not text: return [] segments: list[dict] = [] current_segment = "" current_type = None arabic_ranges = [ (0x0600, 0x06FF), (0x0750, 0x077F), (0x08A0, 0x08FF), (0xFB50, 0xFDFF), (0xFE70, 0xFEFF), ] for char in text: code = ord(char) is_arabic = any(start <= code <= end for start, end in arabic_ranges) is_space = char in (" ", "\t", "\n") char_type = "arabic" if is_arabic else "other" # المسافات تنضم للنوع الحالي if is_space: current_segment += char continue if current_type is None: current_type = char_type current_segment = char elif char_type == current_type: current_segment += char else: # تغيير النوع if current_segment.strip(): segments.append({ "text": current_segment, "type": current_type, }) current_type = char_type current_segment = char # إضافة الجزء الأخير if current_segment.strip(): segments.append({ "text": current_segment, "type": current_type, }) return segments def reconstruct_mixed_paragraph( self, words: list[dict], ) -> str: """ إعادة تجميع فقرة مختلطة (عربي + إنجليزي) مع معالجة ذكية. يحاول فصل الأجزاء العربية عن الإنجليزية ويعالج كل جزء حسب اتجاهه المناسب. Args: words: قائمة كلمات OCR Returns: النص المُعاد تجميعه """ if not words: return "" # تجميع في سطور valid_words = [ w for w in words if w.get("text", "").strip() and all(k in w for k in ("x", "y", "w", "h")) ] if not valid_words: return "" lines = self._group_into_lines(valid_words) result_lines: list[str] = [] for line_words in lines: # فصل كلمات العربي عن الإنجليزي arabic_words = [ w for w in line_words if self._is_arabic_text(w["text"]) ] english_words = [ w for w in line_words if self._is_latin_text(w["text"]) ] # ترتيب كل مجموعة arabic_sorted = sorted( arabic_words, key=lambda w: -w["x"] ) english_sorted = sorted( english_words, key=lambda w: w["x"] ) # دمج حسب الموقع line_text = self._merge_mixed_line( arabic_sorted, english_sorted, line_words ) result_lines.append(line_text) full_text = "\n".join(result_lines) # إعادة تشكيل العربي if self._has_reshaper and self._has_bidi: full_text = self._apply_arabic_reshaping(full_text) return full_text.strip() @staticmethod def _merge_mixed_line( arabic_words: list[dict], english_words: list[dict], all_words: list[dict], ) -> str: """ دمج كلمات عربية وإنجليزية في سطر واحد حسب الموقع. Args: arabic_words: الكلمات العربية (مرتبة RTL) english_words: الكلمات الإنجليزية (مرتبة LTR) all_words: كل الكلمات (مرتبة حسب الموقع الأصلي) Returns: نص السطر المدمج """ # إنشاء خريطة الموقع -> النص position_map: dict[tuple[int, int], str] = {} for w in all_words: center_x = w["x"] + w["w"] // 2 center_y = w["y"] + w["h"] // 2 position_map[(center_x, center_y)] = w["text"].strip() # ترتيب حسب الموقع الأصلي (X تنازلياً للعربية) sorted_positions = sorted( position_map.keys(), key=lambda pos: pos[0], ) # البناء - نعكس النص العربي فقط parts: list[str] = [] for pos in sorted_positions: text = position_map[pos] parts.append(text) return " ".join(parts)