File size: 10,143 Bytes
86c402d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
"""
Модуль с парсером для 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