OmniFile-Processor / modules /vision /pdf_processor.py
Dr. Abdulmalek
deploy: OmniFile AI Processor v4.3.0
900df0b
"""
معالج ملفات 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"
)
@staticmethod
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