muryshev's picture
update
86c402d
"""
Модуль с парсером для PDF документов.
"""
import io
import logging
import os
from typing import BinaryIO
import fitz # PyMuPDF
from ...data_classes import ParsedDocument, ParsedMeta
from ..abstract_parser import AbstractParser
from ..file_types import FileType
from .pdf.formula_parser import PDFFormulaParser
from .pdf.image_parser import PDFImageParser
from .pdf.meta_parser import PDFMetaParser
from .pdf.paragraph_parser import PDFParagraphParser
from .pdf.table_parser import PDFTableParser
logger = logging.getLogger(__name__)
class PDFParser(AbstractParser):
"""
Парсер для PDF документов.
Использует PyMuPDF (fitz) для извлечения текста, изображений, таблиц,
формул и метаданных из документа.
"""
def __init__(self):
"""
Инициализирует PDF парсер и его компоненты.
"""
super().__init__(FileType.PDF)
self.meta_parser = PDFMetaParser()
self.paragraph_parser = PDFParagraphParser()
self.table_parser = PDFTableParser()
self.image_parser = PDFImageParser()
self.formula_parser = PDFFormulaParser()
def parse_by_path(self, file_path: str) -> ParsedDocument:
"""
Парсит PDF документ по пути к файлу и возвращает его структурное представление.
Args:
file_path (str): Путь к PDF файлу для парсинга.
Returns:
ParsedDocument: Структурное представление документа.
Raises:
ValueError: Если файл не существует или не может быть прочитан.
"""
logger.debug(f"Parsing PDF file: {file_path}")
if not os.path.exists(file_path):
raise ValueError(f"File not found: {file_path}")
try:
# Открываем PDF с помощью PyMuPDF
pdf_doc = fitz.open(file_path)
filename = os.path.basename(file_path)
return self._parse_document(pdf_doc, filename, file_path)
except Exception as e:
logger.error(f"Failed to open PDF file: {e}")
raise ValueError(f"Cannot open PDF file: {str(e)}")
def parse(self, file: BinaryIO, file_type: FileType | str | None = None) -> ParsedDocument:
"""
Парсит PDF документ из объекта файла и возвращает его структурное представление.
Args:
file (BinaryIO): Объект файла для парсинга.
file_type: Тип файла, если известен.
Может быть объектом FileType или строкой с расширением (".pdf").
Returns:
ParsedDocument: Структурное представление документа.
Raises:
ValueError: Если файл не может быть прочитан или распарсен.
"""
logger.debug("Parsing PDF from file object")
# Проверяем соответствие типа файла
if file_type and isinstance(file_type, FileType) and file_type != FileType.PDF:
logger.warning(
f"Provided file_type {file_type} doesn't match parser type {FileType.PDF}"
)
try:
# Читаем содержимое файла в память
content = file.read()
# Открываем PDF из потока с помощью PyMuPDF
pdf_stream = io.BytesIO(content)
pdf_doc = fitz.open(stream=pdf_stream, filetype="pdf")
return self._parse_document(pdf_doc, "unknown.pdf", None)
except Exception as e:
logger.error(f"Failed to parse PDF from stream: {e}")
raise ValueError(f"Cannot parse PDF content: {str(e)}")
def _parse_document(
self,
pdf_doc: fitz.Document,
filename: str,
filepath: str | None,
) -> ParsedDocument:
"""
Внутренний метод для парсинга открытого PDF документа.
Args:
pdf_doc (fitz.Document): Открытый PDF документ.
filename (str): Имя файла для документа.
filepath (str | None): Путь к файлу (или None, если из объекта).
Returns:
ParsedDocument: Структурное представление документа.
Raises:
ValueError: Если содержимое не может быть распарсено.
"""
# Создание базового документа
doc = ParsedDocument(name=filename, type=FileType.PDF)
try:
# Извлечение метаданных
meta_dict = self.meta_parser.parse(pdf_doc, filepath)
# Преобразуем словарь метаданных в объект ParsedMeta
meta = ParsedMeta()
if 'author' in meta_dict:
meta.owner = meta_dict['author']
if 'creation_date' in meta_dict:
meta.date = meta_dict['creation_date']
if filepath:
meta.source = filepath
# Сохраняем остальные метаданные в поле note
meta.note = meta_dict
doc.meta = meta
logger.debug("Parsed metadata")
# Последовательный вызов парсеров
try:
# Парсим таблицы
doc.tables.extend(self.table_parser.parse(pdf_doc))
logger.debug(f"Parsed {len(doc.tables)} tables")
# Парсим изображения
doc.images.extend(self.image_parser.parse(pdf_doc))
logger.debug(f"Parsed {len(doc.images)} images")
# Парсим формулы
doc.formulas.extend(self.formula_parser.parse(pdf_doc))
logger.debug(f"Parsed {len(doc.formulas)} formulas")
# Парсим текст
doc.paragraphs.extend(self.paragraph_parser.parse(pdf_doc))
logger.debug(f"Parsed {len(doc.paragraphs)} paragraphs")
# Связываем элементы с их заголовками
self._link_elements_with_captions(doc)
logger.debug("Linked elements with captions")
except Exception as e:
logger.error(f"Error during parsing components: {e}")
logger.exception(e)
raise ValueError(f"Error parsing document components: {str(e)}")
return doc
finally:
# Закрываем документ после использования
pdf_doc.close()
def _link_elements_with_captions(self, doc: ParsedDocument) -> None:
"""
Связывает таблицы, изображения и формулы с их заголовками на основе анализа текста.
Args:
doc (ParsedDocument): Документ для обработки.
"""
# Находим параграфы, которые могут быть заголовками
caption_paragraphs = {}
for i, para in enumerate(doc.paragraphs):
text = para.text.lower()
if any(keyword in text for keyword in ["таблица", "рисунок", "формула", "рис.", "табл."]):
caption_paragraphs[i] = {
"text": text,
"page": para.page_number
}
# Для таблиц ищем соответствующие заголовки
for table in doc.tables:
table_page = table.page_number
# Ищем заголовки на той же странице или на предыдущей
for para_idx, caption_info in caption_paragraphs.items():
if ("таблица" in caption_info["text"] or "табл." in caption_info["text"]) and \
(caption_info["page"] == table_page or caption_info["page"] == table_page - 1):
table.title_index_in_paragraphs = para_idx
break
# Для изображений ищем соответствующие заголовки
for image in doc.images:
image_page = image.page_number
# Ищем заголовки на той же странице
for para_idx, caption_info in caption_paragraphs.items():
if ("рисунок" in caption_info["text"] or "рис." in caption_info["text"]) and \
caption_info["page"] == image_page:
image.referenced_element_index = para_idx
break
# Для формул ищем соответствующие заголовки
for formula in doc.formulas:
formula_page = formula.page_number
# Ищем заголовки на той же странице
for para_idx, caption_info in caption_paragraphs.items():
if "формула" in caption_info["text"] and caption_info["page"] == formula_page:
formula.referenced_element_index = para_idx
break