"""Структурный парсер PDF/DOCX с порядком чтения и контекстом таблиц. Формат elements_json остаётся совместимым с текущей загрузкой: верхнеуровневый status по-прежнему только `parsed` или `error`. Новые метаданные добавляются в элементы, чтобы выбор файлов и отображение результата загрузки не менялись. """ import io def parse_file(filename, data): """Parse file bytes → {status, elements, element_count}.""" ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else "" try: if ext == "pdf": return _parse_pdf(data) elif ext == "docx": return _parse_docx(data) elif ext == "doc": return _parse_docx(data) # python-docx handles most .doc else: return _parse_text(data) except Exception as e: return {"status": "error", "error": str(e), "element_count": 0} def _parse_pdf(data): """Извлечь PDF в порядке страницы, привязав предшествующий текст к таблицам. Координаты pdfplumber позволяют отделить заголовки таблиц от текста в её ячейках и расположить абзацы/таблицы в порядке чтения. Для сканов без текстового слоя возвращается parsed с отдельным флагом, чтобы существующий upload-контракт не трактовал нечитаемый файл как ошибку передачи. """ import pdfplumber elements = [] table_index = 0 with pdfplumber.open(io.BytesIO(data)) as pdf: for page in pdf.pages: table_events = [] table_regions = [] for table in page.find_tables(): rows = _clean_table_rows(table.extract()) if not rows: continue table_index += 1 x0, top, x1, bottom = table.bbox table_regions.append((x0, top, x1, bottom)) table_events.append((float(top), { "type": "table", "table_index": table_index, "rows": rows, })) word_events = _pdf_paragraph_events(page, table_regions) events = [(top, 0, element) for top, element in word_events] events.extend((top, 1, element) for top, element in table_events) events.sort(key=lambda event: (event[0], event[1])) preceding_paragraphs = [] for _top, _kind, element in events: if element["type"] == "paragraph": elements.append(element) preceding_paragraphs.append(element["text"]) preceding_paragraphs = preceding_paragraphs[-4:] else: element["preceding_paragraphs"] = list(preceding_paragraphs) elements.append(element) preceding_paragraphs = [] result = {"status": "parsed", "element_count": len(elements), "elements": elements} if not elements: result["is_unreadable_scan"] = True return result def _clean_table_rows(table): """Нормализовать ячейки таблицы, удаляя только полностью пустые строки.""" rows = [] for row in table or []: if not row: continue cells = [str(cell or "").strip() for cell in row] if any(cells): rows.append(cells) return rows def _pdf_paragraph_events(page, table_regions): """Сгруппировать слова PDF в строки вне bbox таблиц. Каждая строка получает верхнюю координату для общей сортировки с таблицами. Если PDF-библиотека смогла получить текст страницы, но не вернула слова, весь текст сохраняется одним абзацем вместо ошибочной пометки «скан». """ words = page.extract_words() or [] visible_words = [] for word in words: x0 = float(word.get("x0", 0)) x1 = float(word.get("x1", x0)) top = float(word.get("top", 0)) bottom = float(word.get("bottom", top)) center_x = (x0 + x1) / 2 center_y = (top + bottom) / 2 inside_table = any( left <= center_x <= right and upper <= center_y <= lower for left, upper, right, lower in table_regions ) text = str(word.get("text", "")).strip() if text and not inside_table: visible_words.append((top, x0, text)) visible_words.sort(key=lambda item: (item[0], item[1])) lines = [] for top, x0, text in visible_words: if not lines or abs(top - lines[-1]["top"]) > 3: lines.append({"top": top, "words": []}) lines[-1]["words"].append((x0, text)) events = [] for line in lines: line_text = " ".join(text for _x0, text in sorted(line["words"])) if line_text: events.append((line["top"], { "type": "paragraph", "text": line_text, "style": "", })) if events: return events if words and table_regions: return [] text = page.extract_text() if not text: return [] return [ (0.0, {"type": "paragraph", "text": line.strip(), "style": ""}) for line in text.split("\n") if line.strip() ] def _parse_docx(data): """Извлечь DOCX, сохранив порядок блоков и абзацы перед каждой таблицей.""" import docx doc = docx.Document(io.BytesIO(data)) elements = [] preceding_paragraphs = [] table_index = 0 for block in doc.element.body: tag = block.tag.split("}")[-1] if "}" in block.tag else block.tag if tag == "p": text = _extract_paragraph_text(block) if text: elements.append({"type": "paragraph", "text": text, "style": ""}) preceding_paragraphs.append(text) preceding_paragraphs = preceding_paragraphs[-4:] elif tag == "tbl": rows = [] for tr in block.findall(".//{http://schemas.openxmlformats.org/wordprocessingml/2006/main}tr"): cells = [] for tc in tr.findall(".//{http://schemas.openxmlformats.org/wordprocessingml/2006/main}tc"): cell_text = "".join(t.text or "" for t in tc.findall(".//{http://schemas.openxmlformats.org/wordprocessingml/2006/main}t")) cells.append(cell_text.strip()) if cells: rows.append(cells) if rows: table_index += 1 elements.append({ "type": "table", "table_index": table_index, "preceding_paragraphs": list(preceding_paragraphs), "rows": rows, }) preceding_paragraphs = [] return {"status": "parsed", "element_count": len(elements), "elements": elements} def _extract_paragraph_text(p_elem): texts = [] for t in p_elem.findall(".//{http://schemas.openxmlformats.org/wordprocessingml/2006/main}t"): if t.text: texts.append(t.text) return "".join(texts).strip() def _parse_text(data): text = data.decode("utf-8", errors="ignore")[:5000] lines = [l.strip() for l in text.split("\n") if l.strip()] return {"status": "parsed", "element_count": len(lines), "elements": [{"type": "paragraph", "text": l, "style": ""} for l in lines]} def format_elements_with_structure(elements): """Отформатировать элементы документа для stateless-промпта. Табличный контекст продублирован в заголовке таблицы намеренно: extractor получает локальную связь периода/итога с таблицей, даже если его окно внимания не удерживает длинный юридический текст перед ней. """ lines = [] paragraph_index = 0 table_index = 0 for element in elements or []: if not isinstance(element, dict): continue if element.get("type") == "paragraph": text = str(element.get("text") or "").strip() if text: paragraph_index += 1 lines.append(f"[ПАРАГРАФ p{paragraph_index}] {text}") elif element.get("type") == "table": table_index += 1 table_ref = element.get("table_index") or table_index context = " | ".join( str(value).strip() for value in element.get("preceding_paragraphs", []) if str(value).strip() ) lines.append(f"[ТАБЛИЦА #{table_ref} | Контекст над таблицей: {context!r}]") for row_index, row in enumerate(element.get("rows", []), start=1): cells = [str(cell or "").strip() for cell in (row or [])] lines.append(f" [Строка {table_ref}.{row_index}] " + " | ".join(cells)) return "\n".join(lines)