""" Парсер документов — извлекает ВСЕ элементы без фильтрации и потерь. Поддерживает: docx, pdf, .doc (через libreoffice), zip. Возвращает полный слепок документа: абзацы (со стилями) + таблицы (все строки). """ import io import zipfile import subprocess import tempfile import os def parse(file_bytes: bytes, mime_type: str) -> dict: """ Принимает сырые байты файла и MIME-тип. Возвращает {"elements": [...], "errors": [...]}. elements — все абзацы и таблицы в порядке документа. """ result = {"elements": [], "errors": []} if mime_type == "application/zip": return _parse_zip(file_bytes) if mime_type in ("application/vnd.openxmlformats-officedocument.wordprocessingml.document",): return _parse_docx(file_bytes) if mime_type in ("application/msword",): return _parse_doc(file_bytes) if mime_type == "application/pdf": return _parse_pdf(file_bytes) result["errors"].append(f"unsupported mime_type: {mime_type}") return result # ── docx ── def _parse_docx(file_bytes: bytes) -> dict: import docx result = {"elements": [], "errors": []} try: doc = docx.Document(io.BytesIO(file_bytes)) # Собираем все параграфы и таблицы в порядке документа body = doc.element.body para_idx = 0 table_idx = 0 for child in body: tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag if tag == "p": # Параграф para = doc.paragraphs[para_idx] para_idx += 1 if para.text.strip(): result["elements"].append({ "type": "paragraph", "style": para.style.name if para.style else "", "text": para.text, }) elif tag == "tbl": # Таблица table = doc.tables[table_idx] table_idx += 1 rows = [] for row in table.rows: cells = [cell.text for cell in row.cells] rows.append(cells) result["elements"].append({ "type": "table", "rows": rows, }) except Exception as e: result["errors"].append(f"docx parse error: {e}") return result # ── .doc (через libreoffice) ── def _parse_doc(file_bytes: bytes) -> dict: result = {"elements": [], "errors": []} try: with tempfile.NamedTemporaryFile(suffix=".doc", delete=False) as tmp_doc: tmp_doc.write(file_bytes) tmp_doc_path = tmp_doc.name tmp_dir = tempfile.mkdtemp() subprocess.run( ["libreoffice", "--headless", "--convert-to", "docx", "--outdir", tmp_dir, tmp_doc_path], timeout=30, capture_output=True, ) # Находим получившийся docx docx_files = [f for f in os.listdir(tmp_dir) if f.endswith(".docx")] if docx_files: with open(os.path.join(tmp_dir, docx_files[0]), "rb") as f: result = _parse_docx(f.read()) else: result["errors"].append("libreoffice conversion produced no docx") os.unlink(tmp_doc_path) for f in os.listdir(tmp_dir): os.unlink(os.path.join(tmp_dir, f)) os.rmdir(tmp_dir) except FileNotFoundError: result["errors"].append("libreoffice not installed") except Exception as e: result["errors"].append(f"doc parse error: {e}") return result # ── PDF ── def _parse_pdf(file_bytes: bytes) -> dict: """ Извлечь текст и таблицы из PDF через pdfplumber. Таблицы: только с видимыми рамками (lines). Безрамковые (text) дают слишком много ложных срабатываний — не используем. Отбрасываем таблицы где >50% ячеек пусты или где все ячейки содержат фрагменты одной строки текста. """ result = {"elements": [], "errors": []} try: import pdfplumber with pdfplumber.open(io.BytesIO(file_bytes)) as pdf: for page in pdf.pages: pn = page.page_number # ── Текст: каждая непустая строка → paragraph ── text = page.extract_text() if text: for line in text.split("\n"): line = line.strip() if line: result["elements"].append({ "type": "paragraph", "style": "", "text": line, "page": pn, }) # ── Таблицы: с рамками + без рамок ── tables = page.extract_tables() found_any = False if tables: for tbl in tables: if tbl and _table_has_content(tbl): result["elements"].append({ "type": "table", "rows": tbl, "page": pn, }) found_any = True # Если рамок нет — пробуем text strategy с жёстким фильтром if not found_any: tbl_text = page.extract_tables({ "vertical_strategy": "text", "horizontal_strategy": "text", }) if tbl_text: for tbl in tbl_text: if tbl and _table_has_content(tbl, min_cols=3): result["elements"].append({ "type": "table", "rows": tbl, "page": pn, }) except Exception as e: result["errors"].append(f"pdfplumber: {e}") return result def _table_has_content(tbl: list, min_fill_ratio: float = 0.0, min_cols: int = 0) -> bool: """ Проверить что таблица содержит осмысленные ТАБЛИЧНЫЕ данные. Отбрасываем: - Полностью пустые - Менее 2 строк - Менее min_cols колонок (если задано) - Менее 4 заполненных ячеек - Менее 40% ячеек заполнены """ if not tbl or not tbl[0]: return False if len(tbl) <= 1: return False # Проверить минимальное число колонок if min_cols > 0 and len(tbl[0]) < min_cols: return False total = 0 filled = 0 for row in tbl: if not row: continue for cell in row: total += 1 cell_str = str(cell).strip() if cell else "" if cell_str: filled += 1 if filled < 4: return False if total > 0 and filled / total < 0.4: return False return True # ── ZIP ── def _parse_zip(file_bytes: bytes) -> dict: result = {"elements": [], "errors": [], "files": []} try: with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf: for name in zf.namelist(): if name.endswith("/"): continue file_data = zf.read(name) ext = name.lower().split(".")[-1] if "." in name else "" mime_map = { "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "doc": "application/msword", "pdf": "application/pdf", } mime = mime_map.get(ext, "") if mime: parsed = parse(file_data, mime) result["files"].append({ "filename": name, "elements": parsed["elements"], "errors": parsed["errors"], }) else: result["files"].append({ "filename": name, "elements": [], "errors": [f"unsupported extension: {ext}"], }) except Exception as e: result["errors"].append(f"zip parse error: {e}") return result