docs(office): add section 19 to roadmap and changelog for two-phase handwritten ocr pipeline

This commit is contained in:
2026-09-25 16:35:27 +03:00
parent 90656944c5
commit 11691701e1
8 changed files with 346 additions and 86 deletions
+14
View File
@@ -2,6 +2,20 @@
Все важные изменения проекта документируются в этом файле.
## [Unreleased]
### ✨ Добавлено (Added)
- **Изолированный домен работы с документами (`services/office/`):**
- Создан специализированный пакет для постраничной обработки многостраничных PDF и сканов без ограничений контекста диалога.
- Автоматическая сборка распознанного текста в файл Microsoft Word (`.docx`) со стандартизированным оформлением.
- Интеграция с роутером чата и выдача контрастной карточки скачивания документа.
### 📝 Запланировано (Planned)
- **Двухфазный OCR-конвейер рукописных виз и резолюций (`services/office/`):**
- Рендеринг сканов в высоком разрешении (`250–300 DPI`) для точного захвата линий чернил.
- Фаза 2 (Refiner на `qwen2.5:14b`): автоматическое устранение артефактов OCR, склейка абзацев и преобразование списков в таблицы Word.
- Распознавание рукописных виз «Согласовано», подписей и дат руководства с выделением в блок `[Резолюция: ...]`.
## [3.4.0] — 2026-09-25
### ✨ Добавлено (Added)
+19 -1
View File
@@ -227,4 +227,22 @@
## 18. Исследовательский трек: Изолированная песочница кода `[ОТЛОЖЕНО]`
- [ ] **Docker/gVisor контур:**
- [ ] Изолированный контейнер без доступа к внешней сети (`network: none`) с жесткими cgroups-лимитами и монтированием данных в режиме Read-Only.
- [ ] Генерация и выполнение Python/Pandas скриптов для построения сложных графиков и нестандартной статистики на лету.
- [ ] Генерация и выполнение Python/Pandas скриптов для построения сложных графиков и нестандартной статистики на лету.
---
## 19. Доменный модуль канцелярии и документов (`services/office/`) `[В РАБОТЕ]`
- [x] **Изоляция офисного домена от кадрового ядра СКУД:**
- [x] Создание независимого пакета `services/office/` (`document_extractor.py`, `word_builder.py`, `service.py`).
- [x] Снятие ограничений контекста диалога: многостраничный постраничный обход PDF вместо обрезания первой страницы.
- [x] Генерация ГОСТ-документов Word (`.docx`) с выдачей контрастной карточки скачивания в интерфейс чата.
- [ ] **Двухфазный конвейер OCR и глубокая нормализация (Vision + LLM Refiner):**
- [ ] **Фаза 1 (Vision Extraction):** повышение разрешения рендеринга до `250–300 DPI` для чёткой отрисовки тонких линий шариковых ручек и штампов.
- [ ] **Фаза 2 (LLM Post-Processing Refiner):** вычитка текста через `qwen2.5:14b`:
- Устранение диалогового шума и случайных приветствий модели.
- Склейка разорванных строк внутри абзацев.
- Автоматическая сборка списков участников и табличных данных в полноценные таблицы Word (`Table Grid`).
- [ ] **Специализированное распознавание рукописных виз и резолюций канцелярии:**
- [ ] Распознавание наклонного и беглого почерка на полях и в шапке документов (визы «Согласовано», резолюции руководства, подписи, даты).
- [ ] Выделение рукописных пометок в стандартизированные структурные блоки: `[Резолюция: ...]` в начале страницы.
- [ ] **Two-Pass Context Injection:** перекрёстное сопоставление неразборчивых фамилий и инициалов с реестром участников совещания/штатом из тела самого документа.
+50 -56
View File
@@ -1,21 +1,22 @@
"""
===============================================================================
FILE: modules/web_api/routers/chat.py
ROLE: Полнофункциональный роутер чата с извлечением текста из PDF и сканов,
поддержкой Function Calling, Fast-Path и оптического распознавания OCR.
ROLE: Роутер чата с чистым разделением:
- Диалог и команды СКУД/1С (через agent.py).
- Парсинг и извлечение документов без обрезания (через file_parser.py).
===============================================================================
"""
import os
import shutil
import base64
import logging
from typing import Optional, List, Dict, Any
from typing import Optional
from fastapi import APIRouter, Header, HTTPException, UploadFile, File, Form
from pydantic import BaseModel
from llm.agent import process_chat_message
from llm.file_parser import parse_uploaded_file
from config import BASE_DIR
logger = logging.getLogger("CHAT_API")
@@ -39,11 +40,6 @@ def resolve_user_id(authorization: Optional[str] = None, explicit_user_id: Optio
token = authorization.replace("Bearer ", "").strip()
if token.isdigit():
return int(token)
elif token.startswith("dev_token_"):
try:
return int(token.replace("dev_token_", ""))
except ValueError:
pass
return 1
@@ -56,8 +52,6 @@ async def chat_endpoint(payload: ChatMessageRequest, authorization: Optional[str
if not user_msg:
raise HTTPException(status_code=400, detail="Пустое сообщение")
logger.info(f"Сообщение от user_id={user_id}, session_id={session_id}: {user_msg}")
reply_text, history, action_payload = process_chat_message(
user_id=user_id,
user_message=user_msg,
@@ -86,60 +80,60 @@ async def chat_upload_endpoint(
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
file_context = ""
image_b64 = None
user_msg = (message or "").strip()
msg_lower = user_msg.lower()
fn_lower = file.filename.lower()
# 1. Текстовые форматы
if fn_lower.endswith((".txt", ".csv", ".log", ".md")):
try:
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
file_context = f.read(6000)
except Exception as e:
logger.warning(f"Не удалось прочитать текст: {e}")
# ⭐️ 1. ПРЯМАЯ ИЗОЛИРОВАННАЯ ОБРАБОТКА PDF/СКАНОВ В WORD ЧЕРЕЗ OFFICE МОДУЛЬ
ocr_keywords = [
"распознай", "распознать", "в word", "в ворд", "для ворда", "для word",
"отформатируй", "текст для вставки", "извлеки текст", "сделай документ", "переведи в ворд"
]
if fn_lower.endswith(".pdf") and (any(k in msg_lower for k in ocr_keywords) or not user_msg):
logger.info(f"[Office] Прямой запуск распознавания PDF в Word: {file.filename}")
from services.office.service import convert_pdf_to_word_service
from modules.web_api.llm.db_tools import db_save_chat_message, db_get_chat_history
# 2. Изображения (прямой OCR)
elif fn_lower.endswith((".png", ".jpg", ".jpeg", ".webp")):
try:
with open(file_path, "rb") as f:
image_b64 = base64.b64encode(f.read()).decode("utf-8")
except Exception as e:
logger.warning(f"Ошибка кодирования картинки в base64: {e}")
# Сохраняем вопрос пользователя в историю
prompt_text = user_msg or f"Распознать документ {file.filename} для MS Word"
db_save_chat_message(session_id, "user", prompt_text, is_ephemeral=0)
# 3. PDF документы (текстовый слой + рендеринг скана при необходимости)
elif fn_lower.endswith(".pdf"):
# Попытка извлечь встроенный текстовый слой
try:
import pypdf
reader = pypdf.PdfReader(file_path)
extracted = []
for page in reader.pages:
t = page.extract_text()
if t:
extracted.append(t)
file_context = "\n".join(extracted).strip()
except Exception:
pass
# Вызываем офисный сервис постраничного OCR и сборки DOCX
office_res = convert_pdf_to_word_service(file_path, file.filename)
# Если текстового слоя мало (скан или фото документа), рендерим страницу в картинку для Vision OCR
if len(file_context) < 40:
try:
import fitz # PyMuPDF
doc = fitz.open(file_path)
if len(doc) > 0:
page = doc[0]
pix = page.get_pixmap(dpi=150)
img_bytes = pix.tobytes("png")
image_b64 = base64.b64encode(img_bytes).decode("utf-8")
file_context = ""
except Exception as e:
logger.warning(f"PyMuPDF не установлен или сбой рендеринга PDF: {e}")
reply_text = (
f"📄 **{office_res['message']}**\n\n"
f"**Фрагмент первой страницы документа:**\n"
f"```text\n{office_res['preview_text']}...\n```\n\n"
f"Файл готов к скачиванию и редактированию в MS Word."
)
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0)
user_msg = message.strip() or f"Распознай и проанализируй прикрепленный документ {file.filename}"
action_payload = {
"type": "FILE_DOWNLOAD_CARD",
"filename": office_res["filename"],
"download_url": office_res["download_url"],
"tasks_count": f"{office_res['total_pages']} стр."
}
return {
"status": "success",
"user_id": user_id,
"session_id": session_id,
"response": reply_text,
"action_payload": action_payload
}
# 2. Если это не задача OCR в Word — отправляем файл в обычный диалог агента
parsed = parse_uploaded_file(file_path, file.filename)
file_context = parsed.get("context_text", "")
image_b64 = parsed.get("image_b64")
prompt_for_agent = user_msg or f"Проанализируй документ {file.filename}"
reply_text, history, action_payload = process_chat_message(
user_id=user_id,
user_message=user_msg,
user_message=prompt_for_agent,
file_context=file_context,
image_b64=image_b64,
session_id=session_id
+43 -16
View File
@@ -11,16 +11,32 @@
* { overflow-anchor: none !important; }
#chat-messages-container, main { overflow-anchor: none !important; }
#chat-messages-container { padding-bottom: clamp(400px, 85vh, 900px) !important; }
/* ⭐️ Комфортный размер шрифта в стиле Gemini */
#chat-messages-container .message-content,
#chat-messages-container .text-sm,
#chat-messages-container .text-xs,
#chat-messages-container .text-sm {
font-size: 14.5px !important;
line-height: 1.6 !important;
#chat-messages-container p,
#chat-messages-container li {
font-size: 16px !important;
line-height: 1.65 !important;
color: #1e293b !important; /* slate-800 */
}
#chat-messages-container .user-chat-bubble .text-sm,
#chat-messages-container .user-chat-bubble p,
#chat-messages-container .user-chat-bubble div {
font-size: 16px !important;
line-height: 1.6 !important;
color: #ffffff !important;
}
#chat-messages-container pre,
#chat-messages-container code {
font-size: 13.5px !important;
font-size: 14.5px !important;
line-height: 1.5 !important;
}
.user-chat-bubble { scroll-margin-top: 24px !important; }
</style>
</head>
@@ -234,12 +250,12 @@
<!-- ЛЕНТА ЧАТА -->
<div id="chat-messages-container" class="flex-1 overflow-y-auto p-4 md:p-6 flex flex-col gap-4">
<div class="flex gap-3 max-w-4xl mx-auto w-full">
<div class="w-7 h-7 rounded-lg bg-indigo-600 text-white flex items-center justify-center shrink-0 shadow-sm mt-0.5">
<i class="fa-solid fa-robot text-xs"></i>
<div class="w-8 h-8 rounded-lg bg-indigo-600 text-white flex items-center justify-center shrink-0 shadow-sm mt-0.5">
<i class="fa-solid fa-robot text-sm"></i>
</div>
<div class="flex-1 bg-white border border-slate-200 rounded-2xl rounded-tl-none p-4 shadow-sm">
<div class="text-[10px] font-bold text-indigo-600 uppercase tracking-wider mb-1">ИИ-ассистент SCUD Orion AI</div>
<div class="text-xs text-slate-700 leading-relaxed">
<div class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1">ИИ-ассистент SCUD Orion AI</div>
<div class="text-sm text-slate-700 leading-relaxed">
Привет! Вы можете задавать вопросы ассистенту, управлять системным промптом, сверять кадровые нестыковки СКУД и 1С или формировать срезы и отчеты.
</div>
</div>
@@ -258,24 +274,35 @@
</div>
<!-- СТРОКА ВВОДА -->
<div class="px-4 py-2 bg-white border-t border-slate-200">
<div id="chat-input-box" class="max-w-4xl mx-auto w-full flex items-center gap-2 bg-slate-50 border border-slate-300 rounded-xl px-2.5 py-1 transition focus-within:border-indigo-500 focus-within:bg-white focus-within:ring-1 focus-within:ring-indigo-100">
<button type="button" onclick="document.getElementById('file-upload-input').click()" class="text-slate-400 hover:text-indigo-600 p-1 transition shrink-0" title="Прикрепить файл">
<i class="fa-solid fa-paperclip text-xs"></i>
<div class="px-4 py-3 bg-white border-t border-slate-200">
<div id="chat-input-box" class="max-w-4xl mx-auto w-full flex items-end gap-3 bg-slate-50 border border-slate-300 rounded-2xl px-4 py-2.5 transition focus-within:border-indigo-500 focus-within:bg-white focus-within:ring-2 focus-within:ring-indigo-100 shadow-xs">
<button type="button" onclick="document.getElementById('file-upload-input').click()" class="text-slate-400 hover:text-indigo-600 p-1.5 transition shrink-0 mb-0.5" title="Прикрепить файл">
<i class="fa-solid fa-paperclip text-base"></i>
</button>
<input type="file" id="file-upload-input" class="hidden" />
<textarea id="user-input" rows="1" placeholder="Команда, вопрос (Enter - отправить, Shift+Enter - перенос строки)..."
class="flex-1 bg-transparent border-0 focus:outline-none text-xs text-slate-800 resize-none py-0 leading-5" style="height: 24px; line-height: 24px;"></textarea>
<textarea id="user-input" rows="1" placeholder="Задайте вопрос, отправьте команду (Enter — отправить, Shift+Enter — перенос)..."
class="flex-1 bg-transparent border-0 focus:outline-none text-[15.5px] text-slate-800 placeholder:text-slate-400 resize-none py-1 leading-6 min-h-[32px] max-h-36" style="height: 32px;"></textarea>
<button type="button" onclick="window.sendMessage()" class="w-6 h-6 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white flex items-center justify-center shrink-0 shadow-sm transition">
<i class="fa-solid fa-paper-plane text-[10px]"></i>
<button type="button" onclick="window.sendMessage()" class="w-8 h-8 rounded-xl bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white flex items-center justify-center shrink-0 shadow-sm transition mb-0.5">
<i class="fa-solid fa-paper-plane text-xs"></i>
</button>
</div>
</div>
</main>
</div>
<!-- ПОЛНОЭКРАННЫЙ ОВЕРЛЕЙ DRAG & DROP -->
<div id="global-drag-overlay" class="fixed inset-0 bg-indigo-950/70 backdrop-blur-xs z-50 flex items-center justify-center p-8 hidden pointer-events-none transition-all">
<div class="border-3 border-dashed border-indigo-300 rounded-3xl w-full h-full flex flex-col items-center justify-center text-white gap-4 bg-indigo-900/40">
<div class="w-20 h-20 rounded-2xl bg-white/10 flex items-center justify-center shadow-lg backdrop-blur-md border border-white/20">
<i class="fa-solid fa-cloud-arrow-up text-4xl text-indigo-200"></i>
</div>
<div class="text-xl font-bold tracking-wide">Перетащите файл в окно для загрузки</div>
<div class="text-sm text-indigo-200">Поддерживаются PDF (документы и сканы), изображения, Excel, CSV, TXT</div>
</div>
</div>
<!-- КОНТЕЙНЕР ДИНАМИЧЕСКИХ МОДАЛЬНЫХ ОКОН -->
<div id="modals-container"></div>
+13 -13
View File
@@ -48,7 +48,7 @@ function updateInputHeightAndFade(textarea) {
if (!textarea) return;
if (!textarea.value || textarea.value.trim() === '') {
textarea.style.height = '24px';
textarea.style.height = '32px';
textarea.style.overflowY = 'hidden';
textarea.style.maskImage = 'none';
textarea.style.webkitMaskImage = 'none';
@@ -56,8 +56,8 @@ function updateInputHeightAndFade(textarea) {
}
textarea.style.height = 'auto';
const minHeight = 24;
const maxHeight = 120;
const minHeight = 32;
const maxHeight = 140;
const currentScrollHeight = textarea.scrollHeight;
if (currentScrollHeight <= minHeight + 2) {
@@ -143,23 +143,23 @@ function appendAssistantMessage(text, buttons = [], actionPayload = null) {
payloadHtml = renderInteractiveTaskCard(actionPayload.tasks);
} else if (actionPayload.type === 'FILE_DOWNLOAD_CARD') {
const dlUrl = actionPayload.download_url || '#';
const fName = actionPayload.filename || 'ROADMAP.md';
const fName = actionPayload.filename || 'document.docx';
const count = actionPayload.tasks_count || '';
payloadHtml = `
<div class="mt-3 p-3.5 bg-indigo-50/80 border border-indigo-200 rounded-xl flex items-center justify-between gap-3 shadow-sm">
<div class="mt-3 p-3.5 bg-slate-50 border border-slate-200 rounded-xl flex items-center justify-between gap-3 shadow-xs">
<div class="flex items-center gap-3 min-w-0">
<div class="w-9 h-9 rounded-lg bg-indigo-600 text-white flex items-center justify-center shrink-0 shadow-sm">
<i class="fa-solid fa-file-lines text-base"></i>
<div class="w-10 h-10 rounded-xl bg-blue-600 text-white flex items-center justify-center shrink-0 shadow-sm">
<i class="fa-solid fa-file-word text-lg"></i>
</div>
<div class="min-w-0">
<div class="text-sm font-bold text-slate-800 truncate">${escapeHtml(fName)}</div>
<div class="text-xs text-slate-500">Задач выгружено: ${count} шт. · Markdown</div>
<div class="text-xs text-slate-500">${count ? count + ' · ' : ''}Документ MS Word (.docx)</div>
</div>
</div>
<a href="${dlUrl}" download="${escapeHtml(fName)}" target="_blank"
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white rounded-lg text-xs font-bold shadow-sm transition flex items-center gap-1.5 shrink-0">
<i class="fa-solid fa-download text-xs"></i>
<span>Скачать файл</span>
class="px-4 py-2.5 bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white font-bold rounded-xl text-xs shadow-sm transition flex items-center gap-2 shrink-0">
<i class="fa-solid fa-arrow-down-to-line text-sm text-white"></i>
<span class="text-white tracking-wide">Скачать файл</span>
</a>
</div>
`;
@@ -363,7 +363,7 @@ window.sendMessage = async function() {
saveCommandToHistory(messageText);
input.value = "";
input.style.height = '24px';
input.style.height = '32px';
input.style.overflowY = 'hidden';
input.style.maskImage = 'none';
input.style.webkitMaskImage = 'none';
@@ -478,7 +478,7 @@ document.addEventListener("DOMContentLoaded", () => {
if (input) {
input.style.lineHeight = '24px';
input.style.height = '24px';
input.style.height = '32px';
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
+89
View File
@@ -0,0 +1,89 @@
"""
===============================================================================
FILE: services/office/document_extractor.py
PROJECT: SCUD Orion AI (Office Domain)
ROLE: Постраничное извлечение текста и OCR сканов документов через Vision LLM.
===============================================================================
"""
import os
import base64
import logging
from typing import List, Dict, Any, Optional
import fitz # PyMuPDF
from modules.web_api.llm.core.ollama_client import call_ollama_chat
logger = logging.getLogger("OFFICE_EXTRACTOR")
def ocr_image_b64(image_b64: str, page_num: int = 1) -> str:
"""
Распознает текст с одного растрового изображения через Qwen 2.5 VL.
"""
system_prompt = (
"Ты — высокоточный профессиональный модуль OCR для канцелярии и документооборота.\n"
"Твоя задача — точно переписать весь текст с предоставленного изображения документа.\n"
"ПРАВИЛА:\n"
"1. Переписывай текст дословно, сохраняя структуру, заголовки, списки, нумерацию и таблицы.\n"
"2. Запрещено добавлять вводные слова, приветствия, комментарии ('Спасибо за обращение', 'Вот текст' и т.д.).\n"
"3. Выводи СТРОГО чистый распознанный текст документа."
)
user_message = {
"role": "user",
"content": f"Распознай весь печатный и рукописный текст страницы №{page_num} без пропусков.",
"images": [image_b64]
}
try:
res = call_ollama_chat(
messages=[{"role": "system", "content": system_prompt}, user_message],
is_vision=True,
timeout=300 # 5 минут на тяжелые страницы
)
return (res.get("content") or "").strip()
except Exception as e:
logger.error(f"[Office OCR] Ошибка распознавания страницы {page_num}: {e}")
return f"[Ошибка распознавания страницы {page_num}: {e}]"
def process_pdf_full(file_path: str, max_pages: Optional[int] = None) -> List[Dict[str, Any]]:
"""
Обрабатывает PDF целиком:
- Если есть качественный цифровой текст — мгновенно извлекает его со всех страниц.
- Если страница является сканом/картинкой — рендерит ее в 200 DPI и прогоняет через Vision OCR.
"""
results = []
doc = fitz.open(file_path)
total_pages = len(doc)
limit = min(total_pages, max_pages) if max_pages else total_pages
logger.info(f"[Office] Начало обработки PDF: {os.path.basename(file_path)} (всего страниц: {total_pages})")
for idx in range(limit):
page_num = idx + 1
page = doc[idx]
extracted_text = (page.get_text("text") or "").strip()
# Если на странице есть хороший машинный текст (не скан)
if len(extracted_text) > 80:
logger.info(f"[Office] Страница {page_num}/{limit}: извлечен цифровой текст ({len(extracted_text)} симв.)")
results.append({
"page": page_num,
"method": "DIGITAL_TEXT",
"text": extracted_text
})
else:
# Чистый скан — рендерим страницу в PNG и передаем в Vision LLM
logger.info(f"[Office] Страница {page_num}/{limit}: распознавание скана через Ollama Vision...")
pix = page.get_pixmap(dpi=200)
img_b64 = base64.b64encode(pix.tobytes("png")).decode("utf-8")
ocr_text = ocr_image_b64(img_b64, page_num=page_num)
results.append({
"page": page_num,
"method": "VISION_OCR",
"text": ocr_text
})
doc.close()
return results
+55
View File
@@ -0,0 +1,55 @@
"""
===============================================================================
FILE: services/office/service.py
PROJECT: SCUD Orion AI (Office Domain)
ROLE: Единый фасад офисного модуля для вызова из API и фоновых задач.
===============================================================================
"""
import os
import uuid
import logging
from typing import Dict, Any
from config import BASE_DIR
from .document_extractor import process_pdf_full
from .word_builder import build_docx_from_ocr
logger = logging.getLogger("OFFICE_SERVICE")
OFFICE_OUTPUT_DIR = os.path.join(BASE_DIR, "output", "web", "office")
os.makedirs(OFFICE_OUTPUT_DIR, exist_ok=True)
def convert_pdf_to_word_service(file_path: str, original_filename: str) -> Dict[str, Any]:
"""
Полный цикл: Постраничный OCR скана PDF -> Сборка документа DOCX -> Ссылка на скачивание.
"""
session_id = str(uuid.uuid4())[:8]
base_name = os.path.splitext(os.path.basename(original_filename))[0]
out_docx_name = f"{base_name}_распознан.docx"
target_dir = os.path.join(OFFICE_OUTPUT_DIR, session_id)
os.makedirs(target_dir, exist_ok=True)
out_docx_path = os.path.join(target_dir, out_docx_name)
# 1. Постраничный парсинг всех страниц
pages = process_pdf_full(file_path)
# 2. Сборка Word-документа
build_docx_from_ocr(pages, out_docx_path, doc_title=base_name)
# 3. Краткое превью для окна чата
preview_sample = pages[0]["text"][:600] if pages else "Документ распознан."
download_url = f"/api/v1/files/download/office/{session_id}/{out_docx_name}"
return {
"status": "success",
"total_pages": len(pages),
"filename": out_docx_name,
"filepath": out_docx_path,
"download_url": download_url,
"preview_text": preview_sample,
"message": f"Документ успешно распознан целиком ({len(pages)} стр.) и собран в MS Word."
}
+63
View File
@@ -0,0 +1,63 @@
"""
===============================================================================
FILE: services/office/word_builder.py
PROJECT: SCUD Orion AI (Office Domain)
ROLE: Сборка форматированного документа MS Word (.docx) из распознанного текста.
===============================================================================
"""
import os
from typing import List, Dict, Any
from docx import Document
from docx.shared import Pt, Inches
from docx.enum.text import WD_ALIGN_PARAGRAPH
def build_docx_from_ocr(pages_data: List[Dict[str, Any]], output_filepath: str, doc_title: str = "Распознанный документ") -> str:
"""
Создает файл .docx по ГОСТ-стандартам делопроизводства:
- Шрифт Times New Roman 12-14pt.
- Межстрочный интервал 1.15.
- Разделители страниц и колонтитулы.
"""
doc = Document()
sections = doc.sections
for section in sections:
section.top_margin = Inches(0.79)
section.bottom_margin = Inches(0.79)
section.left_margin = Inches(0.79)
section.right_margin = Inches(0.59)
for p_idx, page in enumerate(pages_data):
page_num = page.get("page", p_idx + 1)
text_content = page.get("text", "")
if p_idx > 0:
doc.add_page_break()
lines = text_content.splitlines()
for line in lines:
line_str = line.strip()
if not line_str:
continue
p = doc.add_paragraph()
p.paragraph_format.space_after = Pt(3)
p.paragraph_format.line_spacing = 1.15
if any(h in line_str.upper() for h in ["ПРЕДСЕДАТЕЛЬСТВОВАЛ", "ПРОТОКОЛ", "ПОВЕСТКА", "РЕШИЛИ:", "ОТМЕТИЛИ:"]):
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = p.add_run(line_str)
run.font.name = "Times New Roman"
run.font.size = Pt(13)
run.font.bold = True
else:
p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
run = p.add_run(line_str)
run.font.name = "Times New Roman"
run.font.size = Pt(12)
os.makedirs(os.path.dirname(output_filepath), exist_ok=True)
doc.save(output_filepath)
return output_filepath