feat: поддержка .doc через LibreOffice headless
Deploy drhider / validate (push) Waiting to run

- extractor.py: добавлена doc_to_markdown() — .doc → .docx (soffice) → docx_to_markdown()
- Dockerfile: apt install libreoffice-writer
- ARCHITECTURE.md: обновлён статус .doc
- bump: 0.0.16 → 0.0.17
This commit is contained in:
2026-07-14 07:21:39 +04:00
parent 444ef4b732
commit 00d54dbfb6
4 changed files with 56 additions and 5 deletions
+3
View File
@@ -1,5 +1,8 @@
FROM python:3.12-slim FROM python:3.12-slim
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libreoffice-writer \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY site /app/site COPY site /app/site
+1 -1
View File
@@ -45,7 +45,7 @@ site/app.py ──→ Flask (create_app)
| **`.pdf`** | → Markdown | Текст + таблицы (`pdfplumber`), без форматирования | | **`.pdf`** | → Markdown | Текст + таблицы (`pdfplumber`), без форматирования |
| **`.txt`** и прочие текстовые | → как есть | Декодируется UTF-8, ошибки заменяются `` | | **`.txt`** и прочие текстовые | → как есть | Декодируется UTF-8, ошибки заменяются `` |
| **`.zip`** | → распаковка | Файлы внутри обрабатываются рекурсивно. Защита от ZIP-бомб: ≤500 файлов, ratio ≤100:1, ≤500 MB | | **`.zip`** | → распаковка | Файлы внутри обрабатываются рекурсивно. Защита от ZIP-бомб: ≤500 файлов, ratio ≤100:1, ≤500 MB |
| **`.doc`** (бинарный) | ❌ не поддерживается | Возвращает заглушку `[DOC binary — not supported. Convert to .docx first.]` | | **`.doc`** (бинарный) | → Docx → Markdown | LibreOffice headless: .doc → .docx → штатный `docx_to_markdown()` (таблицы, форматирование). Требует `libreoffice-writer` в контейнере |
Ограничение на размер загрузки: **200 MB** (`MAX_CONTENT_LENGTH`). Ограничение на размер загрузки: **200 MB** (`MAX_CONTENT_LENGTH`).
+51 -3
View File
@@ -4,7 +4,7 @@
Поддерживает: Поддерживает:
- .docx → MD (python-docx: стили, жирный/курсив, таблицы) - .docx → MD (python-docx: стили, жирный/курсив, таблицы)
- .pdf → MD (pdfplumber: текст + таблицы, без форматирования) - .pdf → MD (pdfplumber: текст + таблицы, без форматирования)
- .doc (бинарный — не парсится, возвращает заглушку) - .doc → MD (LibreOffice headless: .doc → .docx → штатный docx_to_markdown)
- .txt и прочие → как есть - .txt и прочие → как есть
Также содержит: Также содержит:
@@ -14,6 +14,9 @@
import io import io
import os import os
import zipfile import zipfile
import tempfile
import shutil
import subprocess
import logging import logging
from typing import List, Tuple from typing import List, Tuple
@@ -145,6 +148,51 @@ def pdf_to_markdown(content: bytes) -> str:
return "\n".join(lines) return "\n".join(lines)
# ═══════════════════════════════════════════════════════════════════════════
# DOC → Markdown (через LibreOffice)
# ═══════════════════════════════════════════════════════════════════════════
def doc_to_markdown(content: bytes) -> str:
"""Конвертировать бинарный .doc → .docx (LibreOffice) → Markdown.
Использует soffice --headless --convert-to docx, затем прогоняет
через штатный docx_to_markdown() с полным форматированием и таблицами.
Args:
content: Бинарное содержимое .doc файла
Returns:
Строка в формате Markdown (или сообщение об ошибке)
"""
tmpdir = tempfile.mkdtemp()
doc_path = os.path.join(tmpdir, "input.doc")
try:
with open(doc_path, 'wb') as f:
f.write(content)
result = subprocess.run(
["soffice", "--headless", "--convert-to", "docx",
"--outdir", tmpdir, doc_path],
timeout=60, capture_output=True
)
docx_path = os.path.join(tmpdir, "input.docx")
if os.path.exists(docx_path):
with open(docx_path, 'rb') as f:
return docx_to_markdown(f.read())
stderr = result.stderr.decode('utf-8', errors='replace').strip()
log.warning("LibreOffice conversion failed: %s", stderr)
return f"[DOC — LibreOffice conversion failed: {stderr}]"
except FileNotFoundError:
return "[DOC — LibreOffice not installed. Install: apt install libreoffice-writer]"
except subprocess.TimeoutExpired:
return "[DOC — conversion timed out (>60s)]"
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
# ═══════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════
# Единая точка входа # Единая точка входа
# ═══════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════
@@ -176,9 +224,9 @@ def extract_text(fname: str, content: bytes, ctype: str = "") -> str:
except ImportError: except ImportError:
return content.decode('utf-8', errors='replace') return content.decode('utf-8', errors='replace')
# ── .doc: бинарный — пока не поддерживается ── # ── .doc: бинарный → LibreOffice → DOCX → Markdown ──
elif ext == '.doc': elif ext == '.doc':
return "[DOC binary — not supported. Convert to .docx first.]" return doc_to_markdown(content)
# ── .txt и прочие ── # ── .txt и прочие ──
else: else:
+1 -1
View File
@@ -20,7 +20,7 @@ if _sys_path_root not in sys.path:
sys.path.insert(0, _sys_path_root) sys.path.insert(0, _sys_path_root)
# Версия приложения (меняется при изменениях) # Версия приложения (меняется при изменениях)
VERSION = "0.0.16" VERSION = "0.0.17"
def create_app(): def create_app():