DrAbdulmalek commited on
Commit
4c2e90c
·
verified ·
1 Parent(s): 3bce188

Upload modules/vision/text_reconstructor.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. modules/vision/text_reconstructor.py +649 -0
modules/vision/text_reconstructor.py ADDED
@@ -0,0 +1,649 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ مُعيد تجميع النصوص
3
+ =====================
4
+ إعادة تجميع الكلمات المكتشفة من OCR إلى نصوص مترابطة
5
+ مع دعم خاص للنصوص العربية (RTL).
6
+
7
+ القدرات:
8
+ - تجميع الكلمات بناءً على إحداثياتها (x, y)
9
+ - تجميع الكلمات في سطور حسب القرب العمودي
10
+ - دعم النص العربي RTL باستخدام arabic-reshaper و python-bidi
11
+ - التعامل مع النصوص المختلطة (عربي + إنجليزي)
12
+ """
13
+
14
+ import logging
15
+ import re
16
+ from typing import Optional
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class TextReconstructor:
22
+ """
23
+ مُعيد تجميع النصوص - يعيد بناء الجمل من نتائج OCR على مستوى الكلمات.
24
+
25
+ مثال الاستخدام:
26
+ >>> reconstructor = TextReconstructor(line_threshold=15)
27
+ >>> words = [
28
+ ... {"text": "مرحبا", "x": 200, "y": 10, "w": 50, "h": 20},
29
+ ... {"text": "بالعالم", "x": 140, "y": 10, "w": 60, "h": 20},
30
+ ... {"text": "Hello", "x": 10, "y": 50, "w": 40, "h": 20},
31
+ ... ]
32
+ >>> text = reconstructor.reconstruct(words, direction="rtl")
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ line_threshold: float = 15.0,
38
+ word_gap_threshold: float = 50.0,
39
+ default_direction: str = "auto",
40
+ ) -> None:
41
+ """
42
+ تهيئة مُعيد التجميع.
43
+
44
+ Args:
45
+ line_threshold: أقصى فرق عمودي (Y) لاعتبار كلمتين في نفس السطر
46
+ word_gap_threshold: أقل مسافة أفقية لفصل الكلمات بمسافة
47
+ default_direction: الاتجاه الافتراضي ("auto", "rtl", "ltr")
48
+ """
49
+ self.line_threshold = line_threshold
50
+ self.word_gap_threshold = word_gap_threshold
51
+ self.default_direction = default_direction
52
+
53
+ # التحقق من مكتبات إعادة تشكيل العربية
54
+ self._has_reshaper = self._check_library(
55
+ "arabic_reshaper", "arabic-reshaper"
56
+ )
57
+ self._has_bidi = self._check_library(
58
+ "bidi", "python-bidi"
59
+ )
60
+
61
+ if not self._has_reshaper:
62
+ logger.warning(
63
+ "arabic-reshaper غير مثبت. النص العربي قد لا يظهر بشكل صحيح. "
64
+ "قم بالتثبيت: pip install arabic-reshaper"
65
+ )
66
+ if not self._has_bidi:
67
+ logger.warning(
68
+ "python-bidi غير مثبت. اتجاه النص قد لا يكون صحيحاً. "
69
+ "قم بالتثبيت: pip install python-bidi"
70
+ )
71
+
72
+ @staticmethod
73
+ def _check_library(import_name: str, package_name: str) -> bool:
74
+ """التحقق من توفر مكتبة."""
75
+ try:
76
+ __import__(import_name)
77
+ return True
78
+ except ImportError:
79
+ return False
80
+
81
+ # ------------------------------------------------------------------
82
+ # الأساليب العامة (Public API)
83
+ # ------------------------------------------------------------------
84
+
85
+ def reconstruct(
86
+ self,
87
+ words: list[dict],
88
+ direction: str = "auto",
89
+ ) -> str:
90
+ """
91
+ إعادة تجميع قائمة كلمات إلى نص مترابط.
92
+
93
+ Args:
94
+ words: قائمة كلمات، كل كلمة قاموس يحتوي:
95
+ - text: النص
96
+ - x, y: موقع أعلى اليسار
97
+ - w, h: العرض والارتفاع
98
+ direction: اتجاه النص ("auto", "rtl", "ltr")
99
+
100
+ Returns:
101
+ النص المُعاد تجميعه
102
+ """
103
+ if not words:
104
+ return ""
105
+
106
+ # تنظيف الكلمات الفارغة
107
+ valid_words = [
108
+ w for w in words
109
+ if w.get("text", "").strip()
110
+ and all(k in w for k in ("x", "y", "w", "h"))
111
+ ]
112
+
113
+ if not valid_words:
114
+ return ""
115
+
116
+ # تحديد الاتجاه
117
+ detected_direction = self._detect_direction(valid_words, direction)
118
+
119
+ # تجميع الكلمات في سطور
120
+ lines = self._group_into_lines(valid_words)
121
+
122
+ # ترتيب الكلمات داخل كل سطر
123
+ ordered_lines: list[str] = []
124
+ for line_words in lines:
125
+ line_text = self._order_line(line_words, detected_direction)
126
+ ordered_lines.append(line_text)
127
+
128
+ # دمج الأسطر
129
+ full_text = "\n".join(ordered_lines)
130
+
131
+ return full_text.strip()
132
+
133
+ def reconstruct_with_direction(
134
+ self,
135
+ words: list[dict],
136
+ direction: str = "rtl",
137
+ ) -> str:
138
+ """
139
+ إعادة تجميع النصوص مع تحديد الاتجاه بشكل صريح.
140
+
141
+ Args:
142
+ words: قائمة كلمات OCR
143
+ direction: "rtl" أو "ltr"
144
+
145
+ Returns:
146
+ النص المُعاد تجميعه مع معالجة الاتجاه
147
+ """
148
+ if direction not in ("rtl", "ltr"):
149
+ logger.warning(
150
+ "اتجاه غير معروف '%s' - سيتم استخدام auto", direction
151
+ )
152
+ return self.reconstruct(words, direction="auto")
153
+
154
+ text = self.reconstruct(words, direction=direction)
155
+
156
+ # إعادة تشكيل النص العربي إذا توفرت المكتبات
157
+ if direction == "rtl" and self._has_reshaper and self._has_bidi:
158
+ text = self._apply_arabic_reshaping(text)
159
+
160
+ return text
161
+
162
+ def get_statistics(self, words: list[dict]) -> dict:
163
+ """
164
+ الحصول على إحصائيات حول نتائج OCR.
165
+
166
+ Args:
167
+ words: قائمة كلمات OCR
168
+
169
+ Returns:
170
+ قاموس يحتوي إحصائيات
171
+ """
172
+ if not words:
173
+ return {"total_words": 0}
174
+
175
+ valid_words = [
176
+ w for w in words
177
+ if w.get("text", "").strip()
178
+ ]
179
+
180
+ lines = self._group_into_lines(valid_words)
181
+
182
+ # اكتشاف نسبة العربية
183
+ arabic_count = sum(
184
+ 1 for w in valid_words
185
+ if self._is_arabic_text(w.get("text", ""))
186
+ )
187
+
188
+ return {
189
+ "total_words": len(valid_words),
190
+ "total_lines": len(lines),
191
+ "arabic_words": arabic_count,
192
+ "english_words": len(valid_words) - arabic_count,
193
+ "arabic_ratio": arabic_count / max(1, len(valid_words)),
194
+ "direction": self._detect_direction(valid_words, "auto"),
195
+ }
196
+
197
+ # ------------------------------------------------------------------
198
+ # الأساليب الداخلية - التجميع والترتيب
199
+ # ------------------------------------------------------------------
200
+
201
+ def _group_into_lines(
202
+ self, words: list[dict]
203
+ ) -> list[list[dict]]:
204
+ """
205
+ تجميع الكلمات في أسطر بناءً على القرب العمودي.
206
+
207
+ الخوارزمية:
208
+ 1. ترتيب الكلمات حسب Y
209
+ 2. تجميع الكلمات القريبة عمودياً في نفس السطر
210
+ 3. استخدام المتوسط المتحرك لحدود الأسطر
211
+
212
+ Args:
213
+ words: قائمة كلمات صالحة
214
+
215
+ Returns:
216
+ قائمة أسطر، كل سطر قائمة كلمات
217
+ """
218
+ # ترتيب حسب Y أولاً (الصفوف العلوية أولاً)
219
+ sorted_words = sorted(words, key=lambda w: w["y"])
220
+
221
+ lines: list[list[dict]] = []
222
+ current_line: list[dict] = [sorted_words[0]]
223
+
224
+ for word in sorted_words[1:]:
225
+ # حساب متوسط Y للسطر الحالي
226
+ avg_y = sum(w["y"] for w in current_line) / len(current_line)
227
+ word_center_y = word["y"] + word["h"] / 2
228
+ current_center_y = avg_y + (current_line[0]["h"] / 2)
229
+
230
+ # إذا كانت الكلمة قريبة عمودياً من السطر الحالي
231
+ if abs(word_center_y - current_center_y) <= self.line_threshold:
232
+ current_line.append(word)
233
+ else:
234
+ # سطر جديد
235
+ lines.append(current_line)
236
+ current_line = [word]
237
+
238
+ # إضافة السطر الأخير
239
+ if current_line:
240
+ lines.append(current_line)
241
+
242
+ # ترتيب كل سطر حسب Y المتوسط (للضمان)
243
+ lines.sort(key=lambda line: sum(w["y"] for w in line) / len(line))
244
+
245
+ return lines
246
+
247
+ def _order_line(
248
+ self,
249
+ line_words: list[dict],
250
+ direction: str,
251
+ ) -> str:
252
+ """
253
+ ترتيب الكلمات داخل سطر وبناء النص.
254
+
255
+ Args:
256
+ line_words: كلمات في نفس السطر
257
+ direction: اتجاه النص
258
+
259
+ Returns:
260
+ نص السطر
261
+ """
262
+ if not line_words:
263
+ return ""
264
+
265
+ # ترتيب حسب X (اليسار لليمين أولاً)
266
+ sorted_by_x = sorted(line_words, key=lambda w: w["x"])
267
+
268
+ if direction == "rtl":
269
+ # للعربية: الكلمات على اليمين تأتي أولاً
270
+ # لكننا نبقي ترتيب X لأن الكلمة اليمنى لها x أكبر
271
+ # نحتاج لعكس الترتيب
272
+ sorted_by_x = sorted(line_words, key=lambda w: -w["x"])
273
+
274
+ # بناء النص مع مراعاة المسافات
275
+ result_parts: list[str] = []
276
+
277
+ for i, word in enumerate(sorted_by_x):
278
+ text = word["text"].strip()
279
+ if not text:
280
+ continue
281
+
282
+ if i == 0:
283
+ result_parts.append(text)
284
+ else:
285
+ # حساب المسافة من الكلمة السابقة
286
+ prev_word = sorted_by_x[i - 1]
287
+ gap = self._calculate_gap(prev_word, word, direction)
288
+
289
+ if gap > self.word_gap_threshold:
290
+ # مسافة كبيرة = مسافة بين كلمات
291
+ result_parts.append(" ")
292
+ result_parts.append(text)
293
+ else:
294
+ # مسافة صغيرة = كلمات متصلة أو مسافة عادية
295
+ result_parts.append(" ")
296
+ result_parts.append(text)
297
+
298
+ return "".join(result_parts).strip()
299
+
300
+ @staticmethod
301
+ def _calculate_gap(
302
+ word1: dict, word2: dict, direction: str
303
+ ) -> float:
304
+ """
305
+ حساب المسافة الأفقية بين كلمتين.
306
+
307
+ Args:
308
+ word1: الكلمة الأولى
309
+ word2: الكلمة الثانية
310
+ direction: اتجاه النص
311
+
312
+ Returns:
313
+ المسافة بالبكسل
314
+ """
315
+ if direction == "rtl":
316
+ # للعربية: word1 على اليمين و word2 على اليسار
317
+ # word1.x > word2.x (عادةً)
318
+ # المسافة = word1.x - (word2.x + word2.w)
319
+ right_word = word1 if word1["x"] > word2["x"] else word2
320
+ left_word = word2 if word1["x"] > word2["x"] else word1
321
+ return max(0, left_word["x"] - (right_word["x"] + right_word["w"]))
322
+ else:
323
+ # للإنجليزية: word1 على اليسار و word2 على اليمين
324
+ left_word = word1 if word1["x"] < word2["x"] else word2
325
+ right_word = word2 if word1["x"] < word2["x"] else word1
326
+ return max(0, right_word["x"] - (left_word["x"] + left_word["w"]))
327
+
328
+ # ------------------------------------------------------------------
329
+ # الأساليب الداخلية - كشف الاتجاه
330
+ # ------------------------------------------------------------------
331
+
332
+ def _detect_direction(
333
+ self, words: list[dict], hint: str
334
+ ) -> str:
335
+ """
336
+ اكتشاف اتجاه النص تلقائياً أو استخدام الإشارة المحددة.
337
+
338
+ Args:
339
+ words: قائمة الكلمات
340
+ hint: الإشارة ("auto", "rtl", "ltr")
341
+
342
+ Returns:
343
+ "rtl" أو "ltr"
344
+ """
345
+ if hint in ("rtl", "ltr"):
346
+ return hint
347
+
348
+ # كشف تلقائي
349
+ if hint == "auto" or hint not in ("rtl", "ltr"):
350
+ arabic_count = 0
351
+ total_count = 0
352
+
353
+ for word in words:
354
+ text = word.get("text", "")
355
+ if text.strip():
356
+ total_count += 1
357
+ if self._is_arabic_text(text):
358
+ arabic_count += 1
359
+
360
+ if total_count == 0:
361
+ return "ltr"
362
+
363
+ arabic_ratio = arabic_count / total_count
364
+ if arabic_ratio > 0.3:
365
+ logger.debug(
366
+ "اتجاه RTL مكتشف (نسبة العربية: %.1f%%)",
367
+ arabic_ratio * 100,
368
+ )
369
+ return "rtl"
370
+ else:
371
+ logger.debug(
372
+ "اتجاه LTR مكتشف (نسبة الإنجليزية: %.1f%%)",
373
+ (1 - arabic_ratio) * 100,
374
+ )
375
+ return "ltr"
376
+
377
+ return "ltr"
378
+
379
+ @staticmethod
380
+ def _is_arabic_text(text: str) -> bool:
381
+ """
382
+ التحقق مما إذا كان النص يحتوي على حروف عربية.
383
+
384
+ يتحقق من وجود حروف عربية (U+0600–U+06FF) أو أرقام هندية.
385
+
386
+ Args:
387
+ text: النص المراد فحصه
388
+
389
+ Returns:
390
+ True إذا كان النص يحتوي على عربية
391
+ """
392
+ if not text:
393
+ return False
394
+
395
+ # نطاقات اليونيكود العربية
396
+ arabic_ranges = [
397
+ (0x0600, 0x06FF), # الحروف العربية
398
+ (0x0750, 0x077F), # امتدادات العربية
399
+ (0x08A0, 0x08FF), # امتدادات إضافية
400
+ (0xFB50, 0xFDFF), # أشكال العرض العربية
401
+ (0xFE70, 0xFEFF), # أشكال العرض العربية - B
402
+ (0x0660, 0x0669), # الأرقام الهندية
403
+ ]
404
+
405
+ for char in text:
406
+ code = ord(char)
407
+ for start, end in arabic_ranges:
408
+ if start <= code <= end:
409
+ return True
410
+
411
+ return False
412
+
413
+ @staticmethod
414
+ def _is_latin_text(text: str) -> bool:
415
+ """
416
+ التحقق مما إذا كان النص يحتوي على حروف لاتينية/إنجليزية.
417
+
418
+ Args:
419
+ text: النص المراد فحصه
420
+
421
+ Returns:
422
+ True إذا كان النص يحتوي على لاتينية
423
+ """
424
+ if not text:
425
+ return False
426
+
427
+ for char in text:
428
+ if ("A" <= char <= "Z") or ("a" <= char <= "z"):
429
+ return True
430
+
431
+ return False
432
+
433
+ # ------------------------------------------------------------------
434
+ # أساليب إعادة تشكيل النص العربي
435
+ # ------------------------------------------------------------------
436
+
437
+ def _apply_arabic_reshaping(self, text: str) -> str:
438
+ """
439
+ إعادة تشكيل النص العربي ليظهر بشكل صحيح.
440
+
441
+ تستخدم arabic-reshaper لتوصيل الحروف
442
+ و python-bidi لعكس اتجاه العرض.
443
+
444
+ Args:
445
+ text: النص العربي الخام
446
+
447
+ Returns:
448
+ النص المعاد تشكيله
449
+ """
450
+ if not text:
451
+ return text
452
+
453
+ try:
454
+ import arabic_reshaper
455
+ from bidi.algorithm import get_display
456
+
457
+ # تقسيم النص إلى أسطر ومعالجة كل سطر
458
+ lines = text.split("\n")
459
+ reshaped_lines: list[str] = []
460
+
461
+ for line in lines:
462
+ if not line.strip():
463
+ reshaped_lines.append(line)
464
+ continue
465
+
466
+ # التعامل مع النص المختلط (عربي + إنجليزي)
467
+ segments = self._split_mixed_text(line)
468
+
469
+ reshaped_segments: list[str] = []
470
+ for segment in segments:
471
+ if segment["type"] == "arabic":
472
+ reshaped = arabic_reshaper.reshape(segment["text"])
473
+ displayed = get_display(reshaped)
474
+ reshaped_segments.append(displayed)
475
+ else:
476
+ reshaped_segments.append(segment["text"])
477
+
478
+ reshaped_lines.append("".join(reshaped_segments))
479
+
480
+ return "\n".join(reshaped_lines)
481
+
482
+ except Exception as e:
483
+ logger.warning("فشل في إعادة تشكيل النص العربي: %s", e)
484
+ return text
485
+
486
+ @staticmethod
487
+ def _split_mixed_text(text: str) -> list[dict]:
488
+ """
489
+ تقسيم النص المختلط إلى أجزاء عربية وغير عربية.
490
+
491
+ Args:
492
+ text: النص المختلط
493
+
494
+ Returns:
495
+ قائمة أجزاء: [{"text": "...", "type": "arabic|other"}]
496
+ """
497
+ if not text:
498
+ return []
499
+
500
+ segments: list[dict] = []
501
+ current_segment = ""
502
+ current_type = None
503
+
504
+ arabic_ranges = [
505
+ (0x0600, 0x06FF),
506
+ (0x0750, 0x077F),
507
+ (0x08A0, 0x08FF),
508
+ (0xFB50, 0xFDFF),
509
+ (0xFE70, 0xFEFF),
510
+ ]
511
+
512
+ for char in text:
513
+ code = ord(char)
514
+ is_arabic = any(start <= code <= end for start, end in arabic_ranges)
515
+ is_space = char in (" ", "\t", "\n")
516
+
517
+ char_type = "arabic" if is_arabic else "other"
518
+
519
+ # المسافات تنضم للنوع الحالي
520
+ if is_space:
521
+ current_segment += char
522
+ continue
523
+
524
+ if current_type is None:
525
+ current_type = char_type
526
+ current_segment = char
527
+ elif char_type == current_type:
528
+ current_segment += char
529
+ else:
530
+ # تغيير النوع
531
+ if current_segment.strip():
532
+ segments.append({
533
+ "text": current_segment,
534
+ "type": current_type,
535
+ })
536
+ current_type = char_type
537
+ current_segment = char
538
+
539
+ # إضافة الجزء الأخير
540
+ if current_segment.strip():
541
+ segments.append({
542
+ "text": current_segment,
543
+ "type": current_type,
544
+ })
545
+
546
+ return segments
547
+
548
+ def reconstruct_mixed_paragraph(
549
+ self,
550
+ words: list[dict],
551
+ ) -> str:
552
+ """
553
+ إعادة تجميع فقرة مختلطة (عربي + إنجليزي) مع معالجة ذكية.
554
+
555
+ يحاول فصل الأجزاء العربية عن الإنجليزية ويعالج كل جزء
556
+ حسب اتجاهه المناسب.
557
+
558
+ Args:
559
+ words: قائمة كلمات OCR
560
+
561
+ Returns:
562
+ النص المُعاد تجميعه
563
+ """
564
+ if not words:
565
+ return ""
566
+
567
+ # تجميع في سطور
568
+ valid_words = [
569
+ w for w in words
570
+ if w.get("text", "").strip()
571
+ and all(k in w for k in ("x", "y", "w", "h"))
572
+ ]
573
+
574
+ if not valid_words:
575
+ return ""
576
+
577
+ lines = self._group_into_lines(valid_words)
578
+ result_lines: list[str] = []
579
+
580
+ for line_words in lines:
581
+ # فصل كلمات العربي عن الإنجليزي
582
+ arabic_words = [
583
+ w for w in line_words
584
+ if self._is_arabic_text(w["text"])
585
+ ]
586
+ english_words = [
587
+ w for w in line_words
588
+ if self._is_latin_text(w["text"])
589
+ ]
590
+
591
+ # ترتيب كل مجموعة
592
+ arabic_sorted = sorted(
593
+ arabic_words, key=lambda w: -w["x"]
594
+ )
595
+ english_sorted = sorted(
596
+ english_words, key=lambda w: w["x"]
597
+ )
598
+
599
+ # دمج حسب الموقع
600
+ line_text = self._merge_mixed_line(
601
+ arabic_sorted, english_sorted, line_words
602
+ )
603
+ result_lines.append(line_text)
604
+
605
+ full_text = "\n".join(result_lines)
606
+
607
+ # إعادة تشكيل العربي
608
+ if self._has_reshaper and self._has_bidi:
609
+ full_text = self._apply_arabic_reshaping(full_text)
610
+
611
+ return full_text.strip()
612
+
613
+ @staticmethod
614
+ def _merge_mixed_line(
615
+ arabic_words: list[dict],
616
+ english_words: list[dict],
617
+ all_words: list[dict],
618
+ ) -> str:
619
+ """
620
+ دمج كلمات عربية وإنجليزية في سطر واحد حسب الموقع.
621
+
622
+ Args:
623
+ arabic_words: الكلمات العربية (مرتبة RTL)
624
+ english_words: الكلمات الإنجليزية (مرتبة LTR)
625
+ all_words: كل الكلمات (مرتبة حسب الموقع الأصلي)
626
+
627
+ Returns:
628
+ نص السطر المدمج
629
+ """
630
+ # إنشاء خريطة الموقع -> النص
631
+ position_map: dict[tuple[int, int], str] = {}
632
+ for w in all_words:
633
+ center_x = w["x"] + w["w"] // 2
634
+ center_y = w["y"] + w["h"] // 2
635
+ position_map[(center_x, center_y)] = w["text"].strip()
636
+
637
+ # ترتيب حسب الموقع الأصلي (X تنازلياً للعربية)
638
+ sorted_positions = sorted(
639
+ position_map.keys(),
640
+ key=lambda pos: pos[0],
641
+ )
642
+
643
+ # البناء - نعكس النص العربي فقط
644
+ parts: list[str] = []
645
+ for pos in sorted_positions:
646
+ text = position_map[pos]
647
+ parts.append(text)
648
+
649
+ return " ".join(parts)