- Загрузка/сканирование видео, извлечение кадров, анализ через SDK ФПГ - Понятный диагноз с сохранением в SQLite и share-ссылками - Экспорт PDF/DOCX, скачивание записанного видео - Камерное сканирование на мобильных (canvas + MediaRecorder) - Секреты вынесены в переменные окружения
268 lines
9.5 KiB
Python
268 lines
9.5 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
Генерация PDF и DOCX отчётов из сохранённого результата диагностики.
|
||
"""
|
||
import io
|
||
import json
|
||
|
||
from reportlab.lib.pagesizes import A4
|
||
from reportlab.lib.units import mm
|
||
from reportlab.lib import colors
|
||
from reportlab.pdfbase import pdfmetrics
|
||
from reportlab.pdfbase.ttfonts import TTFont
|
||
from reportlab.platypus import (
|
||
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, HRFlowable
|
||
)
|
||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||
from reportlab.lib.enums import TA_CENTER
|
||
|
||
from docx import Document
|
||
from docx.shared import Pt, Cm, RGBColor
|
||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||
|
||
# --- Шрифты для кириллицы в PDF ---
|
||
FONT_PATHS = {
|
||
"DejaVuSans": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||
"DejaVuSans-Bold": "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||
"DejaVuSans-Oblique": "/usr/share/fonts/truetype/dejavu/DejaVuSans-Oblique.ttf",
|
||
}
|
||
|
||
for name, path in FONT_PATHS.items():
|
||
try:
|
||
pdfmetrics.registerFont(TTFont(name, path))
|
||
except Exception:
|
||
pass
|
||
|
||
STATUS_TEXT = {
|
||
"ok": "В норме",
|
||
"warning": "Обратить внимание",
|
||
"danger": "Отклонение",
|
||
"info": "Информация",
|
||
}
|
||
|
||
STATUS_COLORS = {
|
||
"ok": colors.HexColor("#155724"),
|
||
"warning": colors.HexColor("#856404"),
|
||
"danger": colors.HexColor("#721c24"),
|
||
"info": colors.HexColor("#0c5460"),
|
||
}
|
||
|
||
STATUS_BG = {
|
||
"ok": colors.HexColor("#d4edda"),
|
||
"warning": colors.HexColor("#fff3cd"),
|
||
"danger": colors.HexColor("#f8d7da"),
|
||
"info": colors.HexColor("#d1ecf1"),
|
||
}
|
||
|
||
|
||
def _load_result(share_id):
|
||
"""Загрузить результат из БД (импорт на месте, чтобы не было циклических зависимостей)."""
|
||
import storage
|
||
return storage.get_result(share_id)
|
||
|
||
|
||
def _parse_json_list(raw):
|
||
"""Вернуть список из БД (get_result уже возвращает распарсенные списки)."""
|
||
if isinstance(raw, list):
|
||
return raw
|
||
try:
|
||
return json.loads(raw) if raw else []
|
||
except Exception:
|
||
return []
|
||
|
||
|
||
# ============================ PDF ============================
|
||
|
||
def generate_pdf(share_id) -> bytes:
|
||
"""Сгенерировать PDF-отчёт. Возвращает байты."""
|
||
result = _load_result(share_id)
|
||
if result is None:
|
||
raise ValueError("Результат не найден")
|
||
|
||
buf = io.BytesIO()
|
||
doc = SimpleDocTemplate(
|
||
buf, pagesize=A4,
|
||
leftMargin=20 * mm, rightMargin=20 * mm,
|
||
topMargin=18 * mm, bottomMargin=18 * mm,
|
||
title=f"Результат диагностики #{share_id}",
|
||
author="med.my-dpr.ru",
|
||
)
|
||
|
||
styles = getSampleStyleSheet()
|
||
styles.add(ParagraphStyle(
|
||
name="TitleRu", fontName="DejaVuSans-Bold", fontSize=18,
|
||
leading=24, alignment=TA_CENTER, spaceAfter=4,
|
||
))
|
||
styles.add(ParagraphStyle(
|
||
name="SubtitleRu", fontName="DejaVuSans", fontSize=10,
|
||
leading=14, alignment=TA_CENTER, textColor=colors.grey, spaceAfter=14,
|
||
))
|
||
styles.add(ParagraphStyle(
|
||
name="H2Ru", fontName="DejaVuSans-Bold", fontSize=13,
|
||
leading=18, spaceBefore=12, spaceAfter=6,
|
||
))
|
||
styles.add(ParagraphStyle(
|
||
name="BodyRu", fontName="DejaVuSans", fontSize=10.5,
|
||
leading=16, spaceAfter=6,
|
||
))
|
||
styles.add(ParagraphStyle(
|
||
name="SmallRu", fontName="DejaVuSans", fontSize=8.5,
|
||
leading=12, textColor=colors.grey,
|
||
))
|
||
|
||
story = []
|
||
|
||
# Заголовок
|
||
story.append(Paragraph("🩺 Диагностика по видео", styles["TitleRu"]))
|
||
story.append(Paragraph(
|
||
f"Результат #{result['share_id']} • {result['created_at'][:10]} {result['created_at'][11:16]}",
|
||
styles["SubtitleRu"],
|
||
))
|
||
story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor("#667eea"), spaceAfter=12))
|
||
|
||
# Сводка
|
||
story.append(Paragraph("Сводка", styles["H2Ru"]))
|
||
story.append(Paragraph(result["summary"], styles["BodyRu"]))
|
||
|
||
# Предупреждения
|
||
warnings = _parse_json_list(result["warnings"])
|
||
if warnings:
|
||
story.append(Paragraph("⚠️ Требует внимания", styles["H2Ru"]))
|
||
for w in warnings:
|
||
story.append(Paragraph(f"• {w}", styles["BodyRu"]))
|
||
|
||
# Показатели
|
||
metrics = _parse_json_list(result["metrics"])
|
||
if metrics:
|
||
story.append(Paragraph("Показатели", styles["H2Ru"]))
|
||
data = [["Показатель", "Значение", "Оценка"]]
|
||
for m in metrics:
|
||
value = m.get("value", "")
|
||
comment = m.get("comment", "")
|
||
if comment:
|
||
value = f"{value} ({comment})"
|
||
status = m.get("status", "info")
|
||
data.append([
|
||
m.get("name", ""),
|
||
value,
|
||
STATUS_TEXT.get(status, status),
|
||
])
|
||
table = Table(data, colWidths=[55 * mm, 75 * mm, 50 * mm])
|
||
table.setStyle(TableStyle([
|
||
("FONTNAME", (0, 0), (-1, 0), "DejaVuSans-Bold"),
|
||
("FONTNAME", (0, 1), (-1, -1), "DejaVuSans"),
|
||
("FONTSIZE", (0, 0), (-1, -1), 9.5),
|
||
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#667eea")),
|
||
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
|
||
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f5f6ff")]),
|
||
("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#cccccc")),
|
||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||
("LEFTPADDING", (0, 0), (-1, -1), 6),
|
||
("RIGHTPADDING", (0, 0), (-1, -1), 6),
|
||
("TOPPADDING", (0, 0), (-1, -1), 5),
|
||
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
|
||
]))
|
||
story.append(table)
|
||
|
||
# Рекомендации
|
||
advice = _parse_json_list(result["advice"])
|
||
if advice:
|
||
story.append(Paragraph("💡 Рекомендации", styles["H2Ru"]))
|
||
for a in advice:
|
||
story.append(Paragraph(f"• {a}", styles["BodyRu"]))
|
||
|
||
story.append(Spacer(1, 10))
|
||
story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#dddddd")))
|
||
story.append(Spacer(1, 6))
|
||
story.append(Paragraph(
|
||
"⚠️ Результат получен автоматически по анализу видео и не является медицинским диагнозом. "
|
||
"Для точной оценки обратитесь к врачу.",
|
||
styles["SmallRu"],
|
||
))
|
||
|
||
doc.build(story)
|
||
return buf.getvalue()
|
||
|
||
|
||
# ============================ DOCX ============================
|
||
|
||
def generate_docx(share_id) -> bytes:
|
||
"""Сгенерировать DOCX-отчёт. Возвращает байты."""
|
||
result = _load_result(share_id)
|
||
if result is None:
|
||
raise ValueError("Результат не найден")
|
||
|
||
doc = Document()
|
||
|
||
# Стили
|
||
style = doc.styles["Normal"]
|
||
style.font.name = "Calibri"
|
||
style.font.size = Pt(11)
|
||
|
||
# Заголовок
|
||
title = doc.add_heading("🩺 Диагностика по видео", level=0)
|
||
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||
for run in title.runs:
|
||
run.font.color.rgb = RGBColor(0x66, 0x7E, 0xEA)
|
||
|
||
sub = doc.add_paragraph()
|
||
sub.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||
run = sub.add_run(
|
||
f"Результат #{result['share_id']} • {result['created_at'][:10]} {result['created_at'][11:16]}"
|
||
)
|
||
run.font.size = Pt(10)
|
||
run.font.color.rgb = RGBColor(0x88, 0x88, 0x88)
|
||
|
||
# Сводка
|
||
doc.add_heading("Сводка", level=1)
|
||
doc.add_paragraph(result["summary"])
|
||
|
||
# Предупреждения
|
||
warnings = _parse_json_list(result["warnings"])
|
||
if warnings:
|
||
doc.add_heading("⚠️ Требует внимания", level=1)
|
||
for w in warnings:
|
||
doc.add_paragraph(w, style="List Bullet")
|
||
|
||
# Показатели
|
||
metrics = _parse_json_list(result["metrics"])
|
||
if metrics:
|
||
doc.add_heading("Показатели", level=1)
|
||
table = doc.add_table(rows=1, cols=3)
|
||
table.style = "Light Grid Accent 1"
|
||
hdr = table.rows[0].cells
|
||
for i, h in enumerate(["Показатель", "Значение", "Оценка"]):
|
||
hdr[i].text = h
|
||
for p in hdr[i].paragraphs:
|
||
for r in p.runs:
|
||
r.font.bold = True
|
||
for m in metrics:
|
||
row = table.add_row().cells
|
||
value = m.get("value", "")
|
||
comment = m.get("comment", "")
|
||
if comment:
|
||
value = f"{value} ({comment})"
|
||
row[0].text = m.get("name", "")
|
||
row[1].text = value
|
||
row[2].text = STATUS_TEXT.get(m.get("status", "info"), m.get("status", ""))
|
||
|
||
# Рекомендации
|
||
advice = _parse_json_list(result["advice"])
|
||
if advice:
|
||
doc.add_heading("💡 Рекомендации", level=1)
|
||
for a in advice:
|
||
doc.add_paragraph(a, style="List Bullet")
|
||
|
||
# Дисклеймер
|
||
doc.add_paragraph()
|
||
disc = doc.add_paragraph()
|
||
run = disc.add_run(
|
||
"⚠️ Результат получен автоматически по анализу видео и не является медицинским диагнозом. "
|
||
"Для точной оценки обратитесь к врачу."
|
||
)
|
||
run.font.size = Pt(9)
|
||
run.font.color.rgb = RGBColor(0x88, 0x88, 0x88)
|
||
|
||
buf = io.BytesIO()
|
||
doc.save(buf)
|
||
return buf.getvalue() |