# -*- 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()