224 lines
9.5 KiB
Python
224 lines
9.5 KiB
Python
"""Структурный парсер 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)
|