release 1.2
This commit is contained in:
Vendored
BIN
Binary file not shown.
+1
-1
@@ -196,7 +196,7 @@ async def getTrackingIDSigning(trackingID: str, singingId: int):
|
||||
if not content:
|
||||
continue
|
||||
|
||||
ATTACHMENTS_DIR = Path("src/attachments/import")
|
||||
ATTACHMENTS_DIR = Path("attachments/import").resolve()
|
||||
result = detect_and_save_attachment(
|
||||
b64_content=content,
|
||||
output_dir=ATTACHMENTS_DIR / str(singingId),
|
||||
|
||||
@@ -524,8 +524,8 @@ async def search_recipients_post(
|
||||
|
||||
settings = await Settings.getSettings(False)
|
||||
if not settings.success:
|
||||
logger.error(settings.error)
|
||||
return {"status": "error", "message": settings.error}
|
||||
logger.error(settings.message)
|
||||
return {"status": "error", "message": settings.message}
|
||||
settings = settings.data
|
||||
try:
|
||||
patientsDB = await findPatientByPhone(
|
||||
|
||||
@@ -17,7 +17,6 @@ async def settingsPage(request: Request):
|
||||
@router.get("/get", name="getSettings", summary="Получить настройки")
|
||||
async def getSettings():
|
||||
settings = await Settings.getSettings()
|
||||
# logger.info(settings.data)
|
||||
return settings.data
|
||||
|
||||
|
||||
|
||||
@@ -364,7 +364,12 @@ function updateStaffTable() {
|
||||
// Определяем тип подписи
|
||||
let signatureTypeHtml;
|
||||
if (practitioner.esiaAuth) {
|
||||
signatureTypeHtml = '<span class="badge bg-success">УКЭП</span>';
|
||||
const snils = (practitioner.snils || '').replace(/"/g, '"').replace(/'/g, ''');
|
||||
const snilsIcon = `<button type="button" class="btn btn-sm btn-link p-0 ms-1"
|
||||
onclick="showAttorneyPopup('${snils}', this)">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>`;
|
||||
signatureTypeHtml = `<span class="badge bg-success me-4">УКЭП</span><span class="badge bg-info">СНИЛС</span>${snilsIcon}`;
|
||||
} else {
|
||||
// Экранируем специальные символы в attorney
|
||||
const attorney = (practitioner.attorney || '').replace(/"/g, '"').replace(/'/g, ''');
|
||||
@@ -914,7 +919,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
const userIdLpu = document.getElementById('ukepPractitionerId').value;
|
||||
const ukepFile = document.getElementById('ukepFile').files[0];
|
||||
const expiryDate = document.getElementById('ukepExpiryDate').value;
|
||||
const snils = document.getElementById('mchdSnilsNumber').value.replace(/\D/g, '');
|
||||
const snils = document.getElementById('ukepSnilsNumber').value.replace(/\D/g, '');
|
||||
|
||||
if (!expiryDate) {
|
||||
showAlert('Заполните дату окончания действия', 'warning');
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
<!-- Заголовок страницы -->
|
||||
{% block title %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
<!-- Блок шапки -->
|
||||
{% block head %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
<!-- Блок тела -->
|
||||
{% block body %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
<!-- Блок скриптов -->
|
||||
{% block scripts %}
|
||||
|
||||
{% endblock %}
|
||||
@@ -236,7 +236,7 @@
|
||||
<div id="attorneyPopup" class="position-fixed bg-white border rounded shadow p-3"
|
||||
style="display: none; z-index: 1060; max-width: 400px;">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<h6 class="mb-0">Номер МЧД</h6>
|
||||
<h6 class="mb-0">Номер:</h6>
|
||||
<button type="button" class="btn-close btn-sm" id="closeAttorneyPopupBtn"></button>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -1,87 +0,0 @@
|
||||
{
|
||||
"201": {
|
||||
"ru": "Создан",
|
||||
"en": "Created",
|
||||
"description": "Документ создан через API, готов к обработке"
|
||||
},
|
||||
"202": {
|
||||
"ru": "Отправлен на подписание",
|
||||
"en": "Sent for Signing",
|
||||
"description": "Документ отправлен получателю"
|
||||
},
|
||||
"203": {
|
||||
"ru": "Просмотрел",
|
||||
"en": "Viewed",
|
||||
"description": "Документ просмотрен получателем"
|
||||
},
|
||||
"204": {
|
||||
"ru": "Подписал",
|
||||
"en": "Signed",
|
||||
"description": "Документ подписан получателем"
|
||||
},
|
||||
"205": {
|
||||
"ru": "Отказался",
|
||||
"en": "Rejected",
|
||||
"description": "Получатель отказался от подписания"
|
||||
},
|
||||
"206": {
|
||||
"ru": "Срок для подписания истек",
|
||||
"en": "Signing Period Expired",
|
||||
"description": "Срок действия ссылки истек до момента проставления подписи или отказа"
|
||||
},
|
||||
"207": {
|
||||
"ru": "Ожидание действий всех получателей",
|
||||
"en": "Awaiting Actions from All Recipients",
|
||||
"description": "Ожидание действий от всех получателей"
|
||||
},
|
||||
"208": {
|
||||
"ru": "Успешно. Подписан всеми — обработка",
|
||||
"en": "Success. Fully Signed — Processing",
|
||||
"description": "Все получатели подписали, документ обрабатывается"
|
||||
},
|
||||
"209": {
|
||||
"ru": "Успешно. Подписан не всеми — обработка",
|
||||
"en": "Success. Partially Signed — Processing",
|
||||
"description": "Не все получатели подписали, документ обрабатывается"
|
||||
},
|
||||
"210": {
|
||||
"ru": "Завершено. Подписано всеми",
|
||||
"en": "Completed. Fully Signed",
|
||||
"description": "Все получатели подписали, документ сохранен"
|
||||
},
|
||||
"211": {
|
||||
"ru": "Завершено. Подписано не всеми",
|
||||
"en": "Completed. Partially Signed",
|
||||
"description": "Не все получатели подписали, документ сохранен"
|
||||
},
|
||||
"212": {
|
||||
"ru": "Отменен для получателя",
|
||||
"en": "Cancelled for Recipient",
|
||||
"description": "Подписание отменено для конкретного получателя"
|
||||
},
|
||||
"213": {
|
||||
"ru": "Завершено. Отменено для всех",
|
||||
"en": "Completed. Cancelled for All",
|
||||
"description": "Подписание отменено для всех получателей"
|
||||
},
|
||||
"498": {
|
||||
"ru": "Завершено. Отклонено всеми",
|
||||
"en": "Completed. Rejected by All",
|
||||
"description": "Все получатели отказались от подписания"
|
||||
},
|
||||
"499": {
|
||||
"ru": "Завершено. Истекло для всех",
|
||||
"en": "Completed. Expired for All",
|
||||
"description": "Срок ссылки истек для всех получателей"
|
||||
},
|
||||
"500": {
|
||||
"ru": "Ошибка обработки",
|
||||
"en": "Processing Error",
|
||||
"description": "Ошибка во время обработки документа"
|
||||
},
|
||||
"501": {
|
||||
"ru": "Ошибка доставки получателю",
|
||||
"en": "Delivery Error",
|
||||
"description": "Произошла ошибка при доставке конкретному получателю"
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ class DatabaseInitializer:
|
||||
if not tables_exist:
|
||||
logger.warning("Не все таблицы существуют. Создаем недостающие...")
|
||||
await self._create_tables_directly()
|
||||
await self._initialize_data()
|
||||
|
||||
# 🔥 СИНХРОНИЗАЦИЯ СХЕМЫ
|
||||
logger.info("Синхронизация схемы БД...")
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2
-21
@@ -5,27 +5,14 @@ from contextlib import asynccontextmanager
|
||||
from app.routers import router
|
||||
|
||||
import config
|
||||
from utils import logger
|
||||
|
||||
from utils import logger
|
||||
from utils.background import init_scheduler, scheduler_lifespan
|
||||
|
||||
# Инициализация задач планировщика (без запуска)
|
||||
init_scheduler()
|
||||
|
||||
|
||||
async def initDB():
|
||||
from db import DATABASE_URL
|
||||
from db.initialize import DatabaseInitializer
|
||||
|
||||
try:
|
||||
force = False
|
||||
reNewDB = False
|
||||
await DatabaseInitializer(DATABASE_URL).initialize(force, reNewDB)
|
||||
logger.info("База данных инициализирована")
|
||||
except Exception as e:
|
||||
logger.error(f"Инициализация базы завершилась ошибкой: {str(e)}", exc_info=True)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Запускаем lifespan планировщика
|
||||
@@ -89,13 +76,7 @@ def startServer():
|
||||
)
|
||||
|
||||
|
||||
async def main():
|
||||
startServer()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import asyncio
|
||||
|
||||
asyncio.run(initDB())
|
||||
|
||||
asyncio.run(main())
|
||||
asyncio.run(startServer())
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
async def initDB():
|
||||
from utils import logger
|
||||
from db import DATABASE_URL
|
||||
from db.initialize import DatabaseInitializer
|
||||
|
||||
try:
|
||||
force = False
|
||||
reNewDB = False
|
||||
await DatabaseInitializer(DATABASE_URL).initialize(force, reNewDB)
|
||||
logger.info("База данных инициализирована")
|
||||
except Exception as e:
|
||||
logger.error(f"Инициализация базы завершилась ошибкой: {str(e)}", exc_info=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import asyncio
|
||||
|
||||
asyncio.run(initDB())
|
||||
@@ -92,7 +92,7 @@ def p7s_save(signature_data, employee_id):
|
||||
|
||||
try:
|
||||
# Базовый путь к директории
|
||||
base_path = Path("src/attachments/secure")
|
||||
base_path = Path("attachments/secure").resolve()
|
||||
|
||||
# Путь к папке сотрудника
|
||||
employee_path = base_path / str(employee_id)
|
||||
@@ -141,7 +141,7 @@ def p7s_delete(employee_id):
|
||||
from utils import answer
|
||||
|
||||
try:
|
||||
signature_dir = Path(f"src/attachments/secure/{employee_id}")
|
||||
signature_dir = Path(f"attachments/secure/{employee_id}").resolve()
|
||||
|
||||
if signature_dir.exists():
|
||||
for file in signature_dir.iterdir():
|
||||
|
||||
+83
-352
@@ -1,70 +1,47 @@
|
||||
"""
|
||||
Модуль для конвертации HTML в PDF с использованием системного WeasyPrint (Homebrew)
|
||||
Фильтрация пустых строк таблиц, центрирование по text-align: center.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
from typing import List, Dict, Tuple
|
||||
from pathlib import Path
|
||||
from weasyprint import HTML
|
||||
from utils import logger
|
||||
|
||||
|
||||
class WeasyPrintConverter:
|
||||
def __init__(self, output_dir: list[str]):
|
||||
def __init__(self, output_dir: str):
|
||||
self.output_dir = Path(output_dir).resolve()
|
||||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.max_files = 5
|
||||
|
||||
self.weasyprint_path = self._find_weasyprint()
|
||||
|
||||
def _find_weasyprint(self):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["which", "weasyprint"], capture_output=True, text=True, check=True
|
||||
)
|
||||
weasyprint_path = result.stdout.strip()
|
||||
logger.info(f"✅ WeasyPrint найден: {weasyprint_path}")
|
||||
return weasyprint_path
|
||||
except subprocess.CalledProcessError:
|
||||
possible_paths = [
|
||||
"/opt/homebrew/bin/weasyprint",
|
||||
"/usr/local/bin/weasyprint",
|
||||
"/usr/bin/weasyprint",
|
||||
]
|
||||
|
||||
for path in possible_paths:
|
||||
if os.path.exists(path):
|
||||
logger.info(f"✅ WeasyPrint найден: {path}")
|
||||
return path
|
||||
|
||||
logger.error("❌ WeasyPrint не найден. Установите: brew install weasyprint")
|
||||
return None
|
||||
# ---------------------------------------------------------
|
||||
# SERVICE METHODS
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def _clean_filename(self, filename: str) -> str:
|
||||
"""Очищает имя файла от недопустимых символов"""
|
||||
# Только заменяем запрещённые символы на подчёркивание
|
||||
clean_name = re.sub(r'[<>:"/\\|?*]', "_", filename)
|
||||
|
||||
# Убираем пробелы по краям
|
||||
clean_name = clean_name.strip()
|
||||
|
||||
# Ограничиваем длину
|
||||
if len(clean_name) > 200:
|
||||
clean_name = clean_name[:200]
|
||||
|
||||
# Защита от пустого имени
|
||||
return clean_name if clean_name else "document"
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# TABLE CLEANING
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def _is_empty_table_row(self, row_html: str) -> bool:
|
||||
text_only = re.sub(r"<[^>]+>", "", row_html)
|
||||
clean_text = re.sub(r"[\s\xa0\u200b\u200c\u200d\u2060\ufeff]+", "", text_only)
|
||||
clean_text = re.sub(r"&[^;]{2,6};", "", clean_text)
|
||||
|
||||
has_colspan = "colspan" in row_html.lower()
|
||||
has_rowspan = "rowspan" in row_html.lower()
|
||||
has_nbsp = " " in row_html or " " in row_html
|
||||
|
||||
is_not_empty = bool(clean_text) or has_colspan or has_rowspan or has_nbsp
|
||||
return not is_not_empty
|
||||
|
||||
def _remove_empty_table_rows_comprehensive(self, html_content: str) -> str:
|
||||
"""
|
||||
Комплексное удаление пустых строк таблиц ВНУТРИ таблиц
|
||||
Убирает строки без текстового содержимого внутри <table>...</table>
|
||||
"""
|
||||
try:
|
||||
# Находим все таблицы
|
||||
tables = list(
|
||||
re.finditer(
|
||||
r"<table[^>]*>.*?</table>",
|
||||
@@ -76,60 +53,35 @@ class WeasyPrintConverter:
|
||||
if not tables:
|
||||
return html_content
|
||||
|
||||
# Обрабатываем с конца, чтобы не сбивать индексы
|
||||
for table_match in reversed(tables):
|
||||
table_html = table_match.group(0)
|
||||
table_start = table_match.start()
|
||||
table_end = table_match.end()
|
||||
|
||||
# Разбираем таблицу на строки
|
||||
rows = []
|
||||
pos = 0
|
||||
table_length = len(table_html)
|
||||
|
||||
while pos < table_length:
|
||||
# Ищем начало строки
|
||||
tr_start = table_html.lower().find("<tr", pos)
|
||||
if tr_start == -1:
|
||||
break
|
||||
|
||||
# Ищем конец строки
|
||||
tr_end = table_html.lower().find("</tr>", tr_start)
|
||||
if tr_end == -1:
|
||||
# Нет закрывающего тега - ищем конец таблицы
|
||||
tr_end = table_html.lower().find("</table>", tr_start)
|
||||
if tr_end == -1:
|
||||
tr_end = table_length
|
||||
tr_end = table_length
|
||||
tr_end += 5
|
||||
|
||||
tr_end += 5 # Длина '</tr>'
|
||||
row_html = table_html[tr_start:tr_end]
|
||||
|
||||
# Проверяем строку на пустоту
|
||||
is_empty_row = self._is_empty_table_row(row_html)
|
||||
|
||||
if not is_empty_row:
|
||||
# Сохраняем непустую строку
|
||||
if not self._is_empty_table_row(row_html):
|
||||
rows.append(row_html)
|
||||
else:
|
||||
logger.debug(f"Удалена пустая строка таблицы")
|
||||
|
||||
pos = tr_end
|
||||
|
||||
if len(rows) == 0:
|
||||
# Если все строки пустые, удаляем всю таблицу
|
||||
logger.info(f"🗑️ Удалена полностью пустая таблица")
|
||||
html_content = html_content[:table_start] + html_content[table_end:]
|
||||
else:
|
||||
# Собираем таблицу обратно
|
||||
new_table_html = table_html
|
||||
for row in rows:
|
||||
# Убедимся, что строка уже в таблице
|
||||
if row not in new_table_html:
|
||||
# Это не должно происходить, но на всякий случай
|
||||
pass
|
||||
|
||||
# Заменяем оригинальную таблицу (она осталась неизменной если строки не удалялись)
|
||||
# Но мы все равно пересобираем для чистоты
|
||||
table_header_end = (
|
||||
table_html.lower().find(">", table_html.lower().find("<table"))
|
||||
+ 1
|
||||
@@ -141,6 +93,7 @@ class WeasyPrintConverter:
|
||||
+ "".join(rows)
|
||||
+ table_html[table_footer_start:]
|
||||
)
|
||||
|
||||
html_content = (
|
||||
html_content[:table_start]
|
||||
+ new_table
|
||||
@@ -153,52 +106,24 @@ class WeasyPrintConverter:
|
||||
logger.warning(f"⚠ Ошибка удаления пустых строк таблиц: {e}")
|
||||
return html_content
|
||||
|
||||
def _is_empty_table_row(self, row_html: str) -> bool:
|
||||
"""
|
||||
Проверяет, является ли строка таблицы пустой
|
||||
"""
|
||||
# Убираем все HTML теги
|
||||
text_only = re.sub(r"<[^>]+>", "", row_html)
|
||||
|
||||
# Убираем все пробелы, невидимые символы и спецсимволы
|
||||
clean_text = re.sub(r"[\s\xa0\u200b\u200c\u200d\u2060\ufeff]+", "", text_only)
|
||||
|
||||
# Убираем HTML entities (но оставляем как признак непустоты)
|
||||
clean_text = re.sub(r"&[^;]{2,6};", "", clean_text)
|
||||
|
||||
# Проверяем специальные случаи
|
||||
has_colspan = "colspan" in row_html.lower()
|
||||
has_rowspan = "rowspan" in row_html.lower()
|
||||
has_nbsp = " " in row_html or " " in row_html
|
||||
|
||||
# Строка считается НЕ пустой если:
|
||||
# 1. Есть текст после очистки
|
||||
# 2. Есть объединенные ячейки
|
||||
# 3. Есть неразрывные пробелы
|
||||
is_not_empty = bool(clean_text) or has_colspan or has_rowspan or has_nbsp
|
||||
|
||||
return not is_not_empty
|
||||
# ---------------------------------------------------------
|
||||
# ALIGNMENT FIX
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def _center_elements_with_center_alignment(self, html_content: str) -> str:
|
||||
"""
|
||||
Находит элементы с text-align: center и гарантирует их центрирование
|
||||
"""
|
||||
try:
|
||||
# Ищем элементы со стилем text-align: center
|
||||
|
||||
def add_center_alignment(match):
|
||||
full_match = match.group(0)
|
||||
element_with_style = match.group(1)
|
||||
element_name = match.group(2)
|
||||
style_content = match.group(3)
|
||||
|
||||
# Если уже есть align="center", оставляем как есть
|
||||
if (
|
||||
'align="center"' in element_with_style
|
||||
or "align='center'" in element_with_style
|
||||
):
|
||||
return full_match
|
||||
|
||||
# Добавляем align="center" к элементу
|
||||
if element_name in [
|
||||
"p",
|
||||
"div",
|
||||
@@ -211,289 +136,103 @@ class WeasyPrintConverter:
|
||||
"td",
|
||||
"th",
|
||||
]:
|
||||
new_element = element_with_style.replace(
|
||||
return element_with_style.replace(
|
||||
f"<{element_name}", f'<{element_name} align="center"'
|
||||
)
|
||||
return new_element + match.group(4)
|
||||
) + match.group(4)
|
||||
|
||||
return full_match
|
||||
|
||||
# Паттерн для поиска элементов с text-align: center
|
||||
pattern = r'(<(\w+)[^>]*style\s*=\s*["\'][^"\']*text-align\s*:\s*center[^"\']*["\'][^>]*>)(.*?)'
|
||||
|
||||
html_content = re.sub(
|
||||
return re.sub(
|
||||
pattern,
|
||||
add_center_alignment,
|
||||
html_content,
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
|
||||
except Exception:
|
||||
return html_content
|
||||
|
||||
except Exception as e:
|
||||
# logger.warning(f"⚠ Ошибка центрирования элементов: {e}")
|
||||
return html_content
|
||||
# ---------------------------------------------------------
|
||||
# HTML PREP
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def _prepare_html(self, html_content: str) -> str:
|
||||
"""Подготавливает HTML для конвертации"""
|
||||
try:
|
||||
# 1. Удаляем изображения
|
||||
html_content = re.sub(r"<img[^>]*>", "", html_content, flags=re.IGNORECASE)
|
||||
|
||||
# 2. Убираем ВСЕ пустые строки таблиц (основная задача)
|
||||
html_content = self._remove_empty_table_rows_comprehensive(html_content)
|
||||
|
||||
# 3. Центрируем элементы с text-align: center
|
||||
html_content = self._center_elements_with_center_alignment(html_content)
|
||||
|
||||
# 4. Добавляем базовую структуру если её нет
|
||||
if not html_content.strip().startswith("<!DOCTYPE"):
|
||||
html_content = f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
/* МИНИМАЛЬНЫЕ СТИЛИ - почти не вмешиваемся в оригинальный формат */
|
||||
|
||||
@page {{
|
||||
size: A4;
|
||||
margin: 0.5cm !important;
|
||||
}}
|
||||
|
||||
/* Основные настройки */
|
||||
body {{
|
||||
font-family: "DejaVu Sans", Arial, sans-serif;
|
||||
font-size: 9pt !important;
|
||||
line-height: 1.0 !important;
|
||||
color: #000000;
|
||||
}}
|
||||
|
||||
/* ТАБЛИЦЫ - сохраняем оригинальный формат! */
|
||||
table {{
|
||||
border-collapse: collapse !important;
|
||||
margin: 0 !important; /* Убрали все отступы вокруг таблиц */
|
||||
font-size: 8.5pt !important;
|
||||
page-break-inside: auto !important;
|
||||
}}
|
||||
|
||||
/* ЯЧЕЙКИ ТАБЛИЦ - только вертикальное центрирование и небольшие отступы */
|
||||
th, td {{
|
||||
padding: 3px 5px !important; /* Минимальные отступы */
|
||||
vertical-align: middle !important; /* Вертикальное центрирование */
|
||||
}}
|
||||
|
||||
/* НЕ добавляем границы автоматически! */
|
||||
/* Границы будут ТОЛЬКО там, где они были в оригинале */
|
||||
|
||||
/* Горизонтальное центрирование заголовков таблиц */
|
||||
th {{
|
||||
text-align: center !important;
|
||||
}}
|
||||
|
||||
/* Элементы с align="center" */
|
||||
[align="center"] {{
|
||||
text-align: center !important;
|
||||
}}
|
||||
|
||||
/* Элементы со style*="text-align: center" */
|
||||
[style*="text-align: center"] {{
|
||||
text-align: center !important;
|
||||
}}
|
||||
|
||||
/* Заголовки документа */
|
||||
h1, h2, h3, h4, h5, h6 {{
|
||||
font-weight: bold !important;
|
||||
margin-top: 8px !important;
|
||||
margin-bottom: 4px !important;
|
||||
}}
|
||||
|
||||
h1 {{ font-size: 11pt !important; page-break-before: always !important; }}
|
||||
h2 {{ font-size: 10pt !important; }}
|
||||
h3 {{ font-size: 9.5pt !important; }}
|
||||
|
||||
/* Параграфы - минимальные отступы */
|
||||
p {{
|
||||
margin: 0 0 3px 0 !important;
|
||||
}}
|
||||
|
||||
/* Форматирование текста */
|
||||
strong, b {{
|
||||
font-weight: bold !important;
|
||||
}}
|
||||
|
||||
em, i {{
|
||||
font-style: italic !important;
|
||||
}}
|
||||
|
||||
/* Списки */
|
||||
ul, ol {{
|
||||
margin: 3px 0 3px 15px !important;
|
||||
padding: 0 !important;
|
||||
}}
|
||||
|
||||
li {{
|
||||
margin-bottom: 1px !important;
|
||||
}}
|
||||
|
||||
/* Убираем лишние отступы */
|
||||
div, p, span {{
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}}
|
||||
|
||||
br {{
|
||||
line-height: 0.5 !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}}
|
||||
|
||||
/* Убираем отступы перед таблицами на уровне CSS */
|
||||
:before(table) {{
|
||||
margin-bottom: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
}}
|
||||
</style>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
@page {{ size: A4; margin: 0.5cm; }}
|
||||
body {{
|
||||
font-family: "DejaVu Sans", Arial, sans-serif;
|
||||
font-size: 9pt;
|
||||
line-height: 1.0;
|
||||
}}
|
||||
table {{
|
||||
border-collapse: collapse;
|
||||
margin: 0;
|
||||
font-size: 8.5pt;
|
||||
}}
|
||||
th, td {{
|
||||
padding: 3px 5px;
|
||||
vertical-align: middle;
|
||||
}}
|
||||
th {{
|
||||
text-align: center;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{html_content}
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
# 5. Убеждаемся в наличии charset
|
||||
if "<meta charset=" not in html_content.lower():
|
||||
html_content = html_content.replace(
|
||||
"<head>", '<head><meta charset="UTF-8">', 1
|
||||
)
|
||||
|
||||
# 6. ОБРАБОТКА ТАБЛИЦ:
|
||||
# Только добавляем вертикальное центрирование и отступы, НЕ меняем границы!
|
||||
|
||||
# Добавляем vertical-align: middle ко всем ячейкам если его нет
|
||||
def add_vertical_align(match):
|
||||
cell_tag = match.group(0)
|
||||
if (
|
||||
"valign=" not in cell_tag.lower()
|
||||
and "vertical-align:" not in cell_tag.lower()
|
||||
):
|
||||
return cell_tag.replace("<td", '<td valign="middle"').replace(
|
||||
"<th", '<th valign="middle"'
|
||||
)
|
||||
return cell_tag
|
||||
|
||||
html_content = re.sub(
|
||||
r"<(td|th)(?![^>]*(?:valign|vertical-align))[^>]*>",
|
||||
add_vertical_align,
|
||||
html_content,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Добавляем text-align: center к заголовкам таблиц если нет своего выравнивания
|
||||
def add_th_center(match):
|
||||
th_tag = match.group(0)
|
||||
if (
|
||||
"align=" not in th_tag.lower()
|
||||
and "text-align:" not in th_tag.lower()
|
||||
):
|
||||
return th_tag.replace("<th", '<th align="center"')
|
||||
return th_tag
|
||||
|
||||
html_content = re.sub(
|
||||
r"<th[^>]*>", add_th_center, html_content, flags=re.IGNORECASE
|
||||
)
|
||||
|
||||
# 7. ФИНАЛЬНАЯ ПРОВЕРКА: еще раз удаляем пустые строки таблиц после всех модификаций
|
||||
html_content = self._remove_empty_table_rows_comprehensive(html_content)
|
||||
|
||||
logger.debug(f"HTML подготовлен, размер: {len(html_content)} символов")
|
||||
|
||||
return html_content
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка подготовки HTML: {e}")
|
||||
# Минимальная обработка в случае ошибки
|
||||
return f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
@page {{ margin: 0.5cm; }}
|
||||
body {{ font-size: 9pt; line-height: 1.0; }}
|
||||
table {{ border-collapse: collapse; margin: 0; }}
|
||||
th, td {{ padding: 3px 5px; vertical-align: middle; }}
|
||||
th {{ text-align: center; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>{html_content}</body>
|
||||
</html>"""
|
||||
return html_content
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# MAIN CONVERSION
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def convert_html_to_pdf(
|
||||
self, html_content: str, output_path: Path
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
Конвертирует HTML в PDF и сохраняет во все указанные папки
|
||||
Оптимизированная версия: конвертирует один раз, копирует результат
|
||||
"""
|
||||
if not self.weasyprint_path:
|
||||
return False, "WeasyPrint не найден. Установите: brew install weasyprint"
|
||||
|
||||
try:
|
||||
prepared_html = self._prepare_html(html_content)
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".html", delete=False, encoding="utf-8"
|
||||
) as f:
|
||||
f.write(prepared_html)
|
||||
temp_html = f.name
|
||||
HTML(string=prepared_html, base_url=str(self.output_dir)).write_pdf(
|
||||
target=str(output_path), presentational_hints=True
|
||||
)
|
||||
|
||||
try:
|
||||
cmd = [
|
||||
self.weasyprint_path,
|
||||
temp_html,
|
||||
output_path,
|
||||
"--encoding",
|
||||
"utf-8",
|
||||
"--presentational-hints",
|
||||
]
|
||||
if not output_path.exists() or output_path.stat().st_size < 1024:
|
||||
return False, "Созданный PDF слишком мал или отсутствует"
|
||||
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=180
|
||||
)
|
||||
return True, f"✅ PDF создан: {output_path}"
|
||||
|
||||
if result.returncode != 0:
|
||||
error_msg = (
|
||||
result.stderr[:300] if result.stderr else "Неизвестная ошибка"
|
||||
)
|
||||
return False, f"Ошибка конвертации: {error_msg}"
|
||||
|
||||
if not output_path.exists() or output_path.stat().st_size < 1024:
|
||||
return False, "Созданный PDF слишком мал или отсутствует"
|
||||
|
||||
# Формируем результат
|
||||
return (
|
||||
True,
|
||||
f"✅ PDF создан и сохранен: {output_path}",
|
||||
)
|
||||
|
||||
finally:
|
||||
if os.path.exists(temp_html):
|
||||
os.unlink(temp_html)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "Таймаут при конвертации (180 секунд)"
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Неожиданная ошибка: {str(e)}")
|
||||
return False, f"Ошибка конвертации: {str(e)}"
|
||||
logger.error(f"❌ Ошибка конвертации: {e}")
|
||||
return False, str(e)
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# BATCH PROCESSING
|
||||
# ---------------------------------------------------------
|
||||
|
||||
def convert_documents(self, docs: List[Dict]) -> Dict[str, Dict]:
|
||||
results = {
|
||||
"status": "SUCCESS",
|
||||
"files": [],
|
||||
}
|
||||
results = {"status": "SUCCESS", "files": []}
|
||||
|
||||
if len(docs) > self.max_files:
|
||||
logger.warning(
|
||||
f"⚠ Получено {len(docs)} документов, обработано {self.max_files}"
|
||||
)
|
||||
docs = docs[: self.max_files]
|
||||
|
||||
for i, doc in enumerate(docs, 1):
|
||||
@@ -513,34 +252,26 @@ class WeasyPrintConverter:
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"📄 Документ {i}/{len(docs)}: {filename}")
|
||||
|
||||
if not html_content or len(html_content.strip()) < 100:
|
||||
results["status"] = "ERROR"
|
||||
logger.warning(f" ⚠ Пропущен: недостаточно контента")
|
||||
continue
|
||||
|
||||
success, message = self.convert_html_to_pdf(html_content, output_path)
|
||||
|
||||
if success:
|
||||
logger.info(f" ✅ Успешно")
|
||||
else:
|
||||
logger.error(f" ❌ Ошибка: {message}")
|
||||
if not success:
|
||||
results["status"] = "ERROR"
|
||||
logger.error(message)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def convert_docs_to_pdfs(docs: List[Dict], idPatientMis: str) -> Dict:
|
||||
try:
|
||||
baseDir = "src/attachments/export/"
|
||||
outputDir = f"{baseDir}{idPatientMis}"
|
||||
converter = WeasyPrintConverter(outputDir)
|
||||
base_dir = "attachments/export/"
|
||||
output_dir = f"{base_dir}{idPatientMis}"
|
||||
converter = WeasyPrintConverter(output_dir)
|
||||
return converter.convert_documents(docs)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Критическая ошибка в convert_docs_to_pdfs: {str(e)}")
|
||||
|
||||
return {
|
||||
"status": "ERROR",
|
||||
"message": f"Критическая ошибка в convert_docs_to_pdfs: {str(e)}",
|
||||
}
|
||||
logger.error(f"❌ Критическая ошибка: {e}")
|
||||
return {"status": "ERROR", "message": str(e)}
|
||||
|
||||
@@ -22,7 +22,6 @@ async def render(
|
||||
context = {
|
||||
"request": request,
|
||||
"content": {},
|
||||
# "content": {"app_secret": config.APP_SECRET},
|
||||
}
|
||||
|
||||
fileName = f"{request.scope['path']}/index.html"
|
||||
|
||||
Reference in New Issue
Block a user