217 lines
7.3 KiB
Python
217 lines
7.3 KiB
Python
"""
|
|
Парсер документов — извлекает ВСЕ элементы без фильтрации и потерь.
|
|
|
|
Поддерживает: 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:
|
|
result = {"elements": [], "errors": []}
|
|
|
|
# Попробовать camelot (pdfium, без системных зависимостей)
|
|
tables_from_camelot = False
|
|
try:
|
|
import camelot
|
|
for flavor in ("lattice", "stream"):
|
|
try:
|
|
ctables = camelot.read_pdf(io.BytesIO(file_bytes), pages="all", flavor=flavor)
|
|
for ct in ctables:
|
|
rows = [list(ct.df.columns)] + ct.df.values.tolist()
|
|
result["elements"].append({
|
|
"type": "table",
|
|
"rows": rows,
|
|
"page": ct.page,
|
|
})
|
|
tables_from_camelot = True
|
|
except Exception:
|
|
pass
|
|
except ImportError:
|
|
result["errors"].append("camelot not installed, using pdfplumber")
|
|
except Exception as e:
|
|
result["errors"].append(f"camelot error: {e}")
|
|
|
|
# pdfplumber: текст + таблицы (если tabula не сработал)
|
|
try:
|
|
import pdfplumber
|
|
with pdfplumber.open(io.BytesIO(file_bytes)) as pdf:
|
|
for page in pdf.pages:
|
|
pn = page.page_number
|
|
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,
|
|
})
|
|
if not tables_from_camelot:
|
|
tables = page.extract_tables()
|
|
for tbl in tables:
|
|
if tbl:
|
|
result["elements"].append({
|
|
"type": "table",
|
|
"rows": tbl,
|
|
"page": pn,
|
|
})
|
|
except Exception as e:
|
|
result["errors"].append(f"pdfplumber: {e}")
|
|
|
|
return result
|
|
|
|
|
|
# ── 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
|