Spaces:
Running
Running
| """ | |
| معالج ملفات PDF | |
| ================== | |
| يقوم باستخراج النصوص والصور والجداول من ملفات PDF | |
| باستخدام PyMuPDF (fitz) مع pdfplumber كاحتياطي للتخطيطات المعقدة. | |
| القدرات: | |
| - استخراج النص من صفحات محددة أو كل الصفحات | |
| - تحويل الصفحات إلى صور PIL | |
| - دعم الملفات المحمية بكلمة مرور | |
| - تتبع التقدم عبر دوال الاستدعاء (callbacks) | |
| - دعم المسارات الملفية والبايتات (bytes) | |
| """ | |
| import logging | |
| from typing import Optional, Union, Callable, Any | |
| from pathlib import Path | |
| import numpy as np | |
| logger = logging.getLogger(__name__) | |
| class PDFProcessor: | |
| """ | |
| معالج ملفات PDF - يستخدم PyMuPDF للسرعة و pdfplumber للتخطيطات المعقدة. | |
| مثال الاستخدام: | |
| >>> processor = PDFProcessor(dpi=300) | |
| >>> results = processor.process_pdf("document.pdf", pages=[0, 1, 2]) | |
| >>> count = processor.get_page_count("document.pdf") | |
| >>> img = processor.extract_page("document.pdf", page_num=0) | |
| """ | |
| def __init__( | |
| self, | |
| dpi: int = 300, | |
| use_pdfplumber_fallback: bool = True, | |
| password: Optional[str] = None, | |
| ) -> None: | |
| """ | |
| تهيئة معالج PDF. | |
| Args: | |
| dpi: دقة التحول إلى صور (الافتراضي 300) | |
| use_pdfplumber_fallback: استخدام pdfplumber كاحتياطي (الافتراضي True) | |
| password: كلمة مرور افتراضية للملفات المحمية | |
| """ | |
| self.dpi = dpi | |
| self.use_pdfplumber_fallback = use_pdfplumber_fallback | |
| self.default_password = password | |
| # التحقق من توفر المكتبات | |
| self._has_fitz = self._check_library("fitz", "PyMuPDF") | |
| self._has_pdfplumber = self._check_library("pdfplumber", "pdfplumber") | |
| self._has_pil = self._check_library("PIL", "Pillow") | |
| if not self._has_fitz: | |
| logger.warning( | |
| "PyMuPDF (fitz) غير مثبت. لن يتمكن المعالج من العمل بشكل كامل. " | |
| "قم بالتثبيت: pip install PyMuPDF" | |
| ) | |
| def _check_library(import_name: str, package_name: str) -> bool: | |
| """التحقق من توفر مكتبة معينة.""" | |
| try: | |
| __import__(import_name) | |
| return True | |
| except ImportError: | |
| return False | |
| # ------------------------------------------------------------------ | |
| # الأساليب العامة (Public API) | |
| # ------------------------------------------------------------------ | |
| def get_page_count(self, pdf_source: Union[str, bytes]) -> int: | |
| """ | |
| الحصول على عدد صفحات ملف PDF. | |
| Args: | |
| pdf_source: مسار الملف أو البايتات الخام | |
| Returns: | |
| عدد الصفحات | |
| Raises: | |
| ValueError: إذا لم يكن الملف صالحاً أو لم تتوفر المكتبة | |
| """ | |
| if not self._has_fitz: | |
| raise RuntimeError("PyMuPDF (fitz) غير مثبت - لا يمكن معالجة PDF") | |
| try: | |
| doc = self._open_document(pdf_source) | |
| count = len(doc) | |
| doc.close() | |
| logger.debug("عدد صفحات PDF: %d", count) | |
| return count | |
| except Exception as e: | |
| logger.error("فشل في قراءة عدد صفحات PDF: %s", e) | |
| raise ValueError(f"فشل في قراءة ملف PDF: {e}") from e | |
| def extract_page( | |
| self, | |
| pdf_source: Union[str, bytes], | |
| page_num: int, | |
| ) -> "PIL.Image.Image": | |
| """ | |
| تحويل صفحة PDF إلى صورة PIL. | |
| Args: | |
| pdf_source: مسار الملف أو البايتات الخام | |
| page_num: رقم الصفحة (يبدأ من 0) | |
| Returns: | |
| صورة PIL للصفحة المطلوبة | |
| Raises: | |
| IndexError: إذا كان رقم الصفحة خارج النطاق | |
| RuntimeError: إذا لم تتوفر المكتبات المطلوبة | |
| """ | |
| if not self._has_fitz or not self._has_pil: | |
| raise RuntimeError("PyMuPDF و Pillow مطلوبان لتحويل PDF إلى صورة") | |
| try: | |
| doc = self._open_document(pdf_source) | |
| if page_num < 0 or page_num >= len(doc): | |
| doc.close() | |
| raise IndexError( | |
| f"رقم الصفحة {page_num} خارج النطاق (0-{len(doc)-1})" | |
| ) | |
| page = doc[page_num] | |
| # تحويل الصفحة إلى مصفوفة بكسلات | |
| mat = fitz.Matrix(self.dpi / 72, self.dpi / 72) | |
| pix = page.get_pixmap(matrix=mat) | |
| img_array = np.frombuffer(pix.samples, dtype=np.uint8).reshape( | |
| pix.h, pix.w, pix.n | |
| ) | |
| from PIL import Image | |
| # التأكد من أن الصورة بصيغة RGB | |
| if pix.n == 4: | |
| image = Image.fromarray(img_array[:, :, :3], mode="RGB") | |
| elif pix.n == 1: | |
| image = Image.fromarray(img_array[:, :, 0], mode="L").convert("RGB") | |
| else: | |
| image = Image.fromarray(img_array, mode="RGB") | |
| doc.close() | |
| logger.debug("تم تحويل الصفحة %d إلى صورة بنجاح", page_num) | |
| return image | |
| except IndexError: | |
| raise | |
| except Exception as e: | |
| logger.error("فشل في تحويل الصفحة %d إلى صورة: %s", page_num, e) | |
| raise RuntimeError(f"فشل في تحويل الصفحة إلى صورة: {e}") from e | |
| def process_pdf( | |
| self, | |
| pdf_source: Union[str, bytes], | |
| pages: Optional[list[int]] = None, | |
| password: Optional[str] = None, | |
| progress_callback: Optional[Callable[[int, int, str], Any]] = None, | |
| ) -> list[dict[str, Any]]: | |
| """ | |
| معالجة ملف PDF بالكامل واستخراج المحتوى. | |
| Args: | |
| pdf_source: مسار الملف أو البايتات الخام | |
| pages: قائمة أرقام الصفحات (يبدأ من 0). None = كل الصفحات | |
| password: كلمة مرور للملفات المحمية (تجاوز الافتراضي) | |
| progress_callback: دالة استدعاء لمراقبة التقدم (current, total, status) | |
| Returns: | |
| قائمة قواميس، كل قاموس يحتوي: | |
| - page_num: رقم الصفحة (يبدأ من 0) | |
| - text: النص المستخرج | |
| - images: قائمة الصور المستخرجة (PIL Images) | |
| - tables: قائمة الجداول المستخرجة (قوائم القوائم) | |
| """ | |
| if not self._has_fitz: | |
| raise RuntimeError("PyMuPDF (fitz) غير مثبت - لا يمكن معالجة PDF") | |
| pwd = password or self.default_password | |
| try: | |
| doc = self._open_document(pdf_source, password=pwd) | |
| total_pages = len(doc) | |
| # تحديد الصفحات المطلوبة | |
| if pages is None: | |
| target_pages = list(range(total_pages)) | |
| else: | |
| target_pages = [p for p in pages if 0 <= p < total_pages] | |
| logger.info( | |
| "بدء معالجة PDF: %d صفحة مطلوبة من أصل %d", | |
| len(target_pages), total_pages, | |
| ) | |
| results: list[dict[str, Any]] = [] | |
| for idx, page_num in enumerate(target_pages): | |
| try: | |
| if progress_callback: | |
| progress_callback(idx + 1, len(target_pages), f"معالجة صفحة {page_num + 1}") | |
| page_result = self._process_single_page( | |
| doc, page_num, use_pdfplumber=False | |
| ) | |
| # استخدام pdfplumber كاحتياطي إذا لم يُستخرج نص | |
| if ( | |
| self.use_pdfplumber_fallback | |
| and self._has_pdfplumber | |
| and not page_result["text"].strip() | |
| ): | |
| logger.debug( | |
| "لا يوجد نص في صفحة %d باستخدام PyMuPDF، " | |
| "جرب pdfplumber...", | |
| page_num, | |
| ) | |
| page_result_fallback = self._process_single_page_pdfplumber( | |
| pdf_source, page_num, password=pwd | |
| ) | |
| # دمج النتائج | |
| if page_result_fallback["text"].strip(): | |
| page_result["text"] = page_result_fallback["text"] | |
| page_result["tables"] = page_result_fallback["tables"] | |
| results.append(page_result) | |
| logger.debug( | |
| "تمت معالجة الصفحة %d: %d حرف، %d صورة", | |
| page_num, | |
| len(page_result["text"]), | |
| len(page_result["images"]), | |
| ) | |
| except Exception as e: | |
| logger.error("خطأ في معالجة الصفحة %d: %s", page_num, e) | |
| results.append({ | |
| "page_num": page_num, | |
| "text": "", | |
| "images": [], | |
| "tables": [], | |
| "error": str(e), | |
| }) | |
| doc.close() | |
| if progress_callback: | |
| progress_callback(len(target_pages), len(target_pages), "اكتملت المعالجة") | |
| logger.info("تمت معالجة %d صفحة بنجاح", len(results)) | |
| return results | |
| except Exception as e: | |
| logger.error("فشل في معالجة ملف PDF: %s", e) | |
| raise RuntimeError(f"فشل في معالجة ملف PDF: {e}") from e | |
| # ------------------------------------------------------------------ | |
| # الأساليب الداخلية (Private) | |
| # ------------------------------------------------------------------ | |
| def _open_document( | |
| self, | |
| pdf_source: Union[str, bytes], | |
| password: Optional[str] = None, | |
| ) -> "fitz.Document": | |
| """ | |
| فتح مستند PDF من مسار ملف أو بايتات. | |
| Args: | |
| pdf_source: مسار الملف أو البايتات الخام | |
| password: كلمة مرور للملفات المحمية | |
| Returns: | |
| مستند PyMuPDF مفتوح | |
| """ | |
| import fitz | |
| try: | |
| if isinstance(pdf_source, (str, Path)): | |
| path_str = str(pdf_source) | |
| if not Path(path_str).exists(): | |
| raise FileNotFoundError(f"الملف غير موجود: {path_str}") | |
| doc = fitz.open(path_str) | |
| elif isinstance(pdf_source, bytes): | |
| doc = fitz.open(stream=pdf_source, filetype="pdf") | |
| else: | |
| raise TypeError( | |
| f"نوع غير مدعوم لـ pdf_source: {type(pdf_source)}" | |
| ) | |
| # فك تشفير الملف إذا كان محمياً | |
| if doc.is_encrypted: | |
| if password: | |
| if not doc.authenticate(password): | |
| doc.close() | |
| raise PermissionError("كلمة المرور غير صحيحة") | |
| elif self.default_password: | |
| if not doc.authenticate(self.default_password): | |
| doc.close() | |
| raise PermissionError("كلمة المرور الافتراضية غير صحيحة") | |
| else: | |
| doc.close() | |
| raise PermissionError( | |
| "ملف PDF محمي بكلمة مرور ولم يتم توفير واحدة" | |
| ) | |
| return doc | |
| except Exception as e: | |
| raise RuntimeError(f"فشل في فتح مستند PDF: {e}") from e | |
| def _process_single_page( | |
| self, | |
| doc: "fitz.Document", | |
| page_num: int, | |
| use_pdfplumber: bool = False, | |
| ) -> dict[str, Any]: | |
| """ | |
| معالجة صفحة واحدة باستخدام PyMuPDF. | |
| Args: | |
| doc: مستند PyMuPDF مفتوح | |
| page_num: رقم الصفحة | |
| use_pdfplumber: تجاهل - يُستخدم داخلياً | |
| Returns: | |
| قاموس بالنتائج | |
| """ | |
| import fitz | |
| page = doc[page_num] | |
| # استخراج النص | |
| text = page.get_text("text").strip() | |
| # استخراج الصور | |
| images: list[Any] = [] | |
| image_list = page.get_images(full=True) | |
| for img_idx, img_info in enumerate(image_list): | |
| try: | |
| xref = img_info[0] | |
| base_image = doc.extract_image(xref) | |
| if base_image: | |
| from PIL import Image | |
| import io | |
| img_bytes = base_image["image"] | |
| img_pil = Image.open(io.BytesIO(img_bytes)) | |
| images.append(img_pil.convert("RGB")) | |
| except Exception as e: | |
| logger.warning( | |
| "فشل في استخراج صورة %d من صفحة %d: %s", | |
| img_idx, page_num, e, | |
| ) | |
| # تحويل الصفحة إلى صورة كاملة | |
| page_image = None | |
| try: | |
| mat = fitz.Matrix(self.dpi / 72, self.dpi / 72) | |
| pix = page.get_pixmap(matrix=mat) | |
| img_array = np.frombuffer(pix.samples, dtype=np.uint8).reshape( | |
| pix.h, pix.w, pix.n | |
| ) | |
| if self._has_pil: | |
| from PIL import Image | |
| if pix.n == 4: | |
| page_image = Image.fromarray(img_array[:, :, :3], mode="RGB") | |
| elif pix.n == 1: | |
| page_image = Image.fromarray(img_array[:, :, 0], mode="L").convert("RGB") | |
| else: | |
| page_image = Image.fromarray(img_array, mode="RGB") | |
| except Exception as e: | |
| logger.warning("فشل في تحويل صفحة %d إلى صورة: %s", page_num, e) | |
| return { | |
| "page_num": page_num, | |
| "text": text, | |
| "images": images, | |
| "tables": [], | |
| "page_image": page_image, | |
| } | |
| def _process_single_page_pdfplumber( | |
| self, | |
| pdf_source: Union[str, bytes], | |
| page_num: int, | |
| password: Optional[str] = None, | |
| ) -> dict[str, Any]: | |
| """ | |
| معالجة صفحة واحدة باستخدام pdfplumber كاحتياطي. | |
| مفيد للتخطيطات المعقدة والجداول. | |
| Args: | |
| pdf_source: مسار الملف أو البايتات الخام | |
| page_num: رقم الصفحة | |
| password: كلمة مرور (يدعم pdfplumber المحمية فقط في بعض الحالات) | |
| Returns: | |
| قاميس بالنتائج | |
| """ | |
| import pdfplumber | |
| tables: list[Any] = [] | |
| text = "" | |
| try: | |
| if isinstance(pdf_source, (str, Path)): | |
| pdf = pdfplumber.open(str(pdf_source), password=password) | |
| elif isinstance(pdf_source, bytes): | |
| pdf = pdfplumber.open(io.BytesIO(pdf_source)) | |
| else: | |
| raise TypeError(f"نوع غير مدعوم: {type(pdf_source)}") | |
| if page_num < 0 or page_num >= len(pdf.pages): | |
| pdf.close() | |
| return {"page_num": page_num, "text": "", "images": [], "tables": []} | |
| page = pdf.pages[page_num] | |
| text = (page.extract_text() or "").strip() | |
| tables = [table for table in (page.extract_tables() or []) if table] | |
| pdf.close() | |
| except Exception as e: | |
| logger.warning("pdfplumber فشل في معالجة صفحة %d: %s", page_num, e) | |
| return { | |
| "page_num": page_num, | |
| "text": text, | |
| "images": [], | |
| "tables": tables, | |
| "page_image": None, | |
| } | |
| def get_metadata( | |
| self, pdf_source: Union[str, bytes] | |
| ) -> dict[str, Any]: | |
| """ | |
| استخراج البيانات الوصفية (metadata) من ملف PDF. | |
| Args: | |
| pdf_source: مسار الملف أو البايتات الخام | |
| Returns: | |
| قاموس يحتوي البيانات الوصفية | |
| """ | |
| if not self._has_fitz: | |
| raise RuntimeError("PyMuPDF (fitz) غير مثبت") | |
| try: | |
| doc = self._open_document(pdf_source) | |
| metadata = doc.metadata or {} | |
| doc.close() | |
| result = { | |
| "title": metadata.get("title", ""), | |
| "author": metadata.get("author", ""), | |
| "subject": metadata.get("subject", ""), | |
| "keywords": metadata.get("keywords", ""), | |
| "creator": metadata.get("creator", ""), | |
| "producer": metadata.get("producer", ""), | |
| "creation_date": metadata.get("creationDate", ""), | |
| "modification_date": metadata.get("modDate", ""), | |
| "page_count": self.get_page_count(pdf_source), | |
| "is_encrypted": False, | |
| } | |
| # التحقق من التشفير | |
| try: | |
| doc = self._open_document(pdf_source) | |
| result["is_encrypted"] = doc.is_encrypted | |
| doc.close() | |
| except Exception: | |
| result["is_encrypted"] = True | |
| return result | |
| except Exception as e: | |
| logger.error("فشل في استخراج البيانات الوصفية: %s", e) | |
| raise RuntimeError(f"فشل في استخراج البيانات الوصفية: {e}") from e | |