"""Экспорт разбора занятия без обращения к внешним сервисам. CSV - плоский журнал с пометкой раздела. PDF - читаемый протокол с переносом длинных реплик и автоматическим разбиением на страницы. """ import csv import io import os from html import escape from pathlib import Path from app.domain.events import SessionReport def _cell(value: object) -> str: """Excel/LibreOffice не должны исполнять текст курсанта как формулу.""" if value is None: return "" result = str(value) probe = result.lstrip(" \t\r\n\ufeff\u200b") if probe.startswith(("=", "+", "-", "@")) or result.startswith(("\t", "\r", "\n")): return "'" + result return result def to_csv(report: SessionReport) -> bytes: """UTF-8 с BOM: кириллица открывается в Excel без выбора кодировки.""" output = io.StringIO(newline="") writer = csv.writer(output, lineterminator="\r\n") writer.writerow(["Раздел", "№", "Поле", "Значение", "Дополнительно"]) def row(section: str, index: object, field: str, value: object, detail: object = "") -> None: writer.writerow([_cell(part) for part in (section, index, field, value, detail)]) row("Занятие", "", "ID", report.session_id) row("Занятие", "", "Сценарий", report.scenario_id) row("Занятие", "", "Режим", report.mode.value) row("Занятие", "", "Попытка", report.attempt) row("Критерии", "", "Лимит решения ДДС, с", report.criteria.decision_time_limit_seconds) row("Критерии", "", "Допустимо ошибок", report.criteria.allowed_errors) row("Критерии", "", "Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет") row("Критерии", "", "Переопределения весов", report.criteria.score_weights or "стандартные") row("Критерии", "", "Нарушенных метрик", report.failed_metrics) row("Критерии", "", "Зачёт", "да" if report.passed else "нет") row("Оценка", "", "Автоматическая", report.score_auto) row("Оценка", "", "Итоговая", report.score_final) row("Оценка", "", "Изменена преподавателем", report.overridden_by) row("Оценка", "", "Причина изменения", report.override_comment) for number, item in enumerate(report.metrics, 1): row("Метрики", number, item.title, item.fact, f"Норматив: {item.norm}; результат: {'да' if item.passed else 'нет'}; вес: {item.weight:g}; источник: {item.ref or ''}") for number, item in enumerate(report.findings, 1): row("Ошибки", number, item.code.value, item.summary, f"Факт: {item.fact}; норматив: {item.norm or ''}; источник: {item.ref or ''}") for number, item in enumerate(report.competencies, 1): row("Компетенции", number, item.competency, item.value) for number, item in enumerate(report.transcript, 1): row("Транскрипт", number, item.speaker.value, item.text, item.at.isoformat()) for number, item in enumerate(report.reference_questions, 1): row("Эталонные вопросы", number, item.checklist_id, item.question) for number, item in enumerate(report.missed_checklist, 1): row("Пропущенные вопросы", number, "Пункт", item) for number, item in enumerate(report.hints_used, 1): row("Подсказки", number, item.checklist_id, item.question, item.at.isoformat()) if report.self_assessment: row("Самооценка", "", "Пропущено по мнению курсанта", ", ".join(report.self_assessment.missed)) row("Самооценка", "", "Комментарий", report.self_assessment.comment) if report.self_assessment_diff: for label, values in ( ("Заметил", report.self_assessment_diff.noticed), ("Не заметил", report.self_assessment_diff.unnoticed), ("Отметил ошибочно", report.self_assessment_diff.overcautious), ): row("Сверка самооценки", "", label, ", ".join(values)) for number, item in enumerate(report.notes, 1): row("Пометки преподавателя", number, item.transcript_ref, item.text, item.author) return ("\ufeff" + output.getvalue()).encode("utf-8") def _font_paths() -> tuple[Path, Path | None]: """Находить TTF с кириллицей явно, без молчаливой подмены Helvetica.""" custom = os.environ.get("LCT_PDF_FONT") if custom: path = Path(custom) if not path.is_file(): raise RuntimeError(f"LCT_PDF_FONT не найден: {path}") return path, None candidates = ( ("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"), ("/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf", "/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf"), ("/System/Library/Fonts/Supplemental/Arial.ttf", "/System/Library/Fonts/Supplemental/Arial Bold.ttf"), ) for regular, bold in candidates: if Path(regular).is_file(): return Path(regular), Path(bold) if Path(bold).is_file() else None raise RuntimeError("Не найден TTF-шрифт с кириллицей; установите DejaVu Sans или задайте LCT_PDF_FONT") def to_pdf(report: SessionReport) -> bytes: """Собрать многостраничный PDF с кириллицей и переносом длинных текстов.""" from reportlab.lib import colors from reportlab.lib.enums import TA_LEFT from reportlab.lib.pagesizes import A4 from reportlab.lib.styles import ParagraphStyle from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont from reportlab.platypus import HRFlowable, Paragraph, SimpleDocTemplate, Spacer regular_path, bold_path = _font_paths() regular_name = "LCTReportRegular" bold_name = "LCTReportBold" if regular_name not in pdfmetrics.getRegisteredFontNames(): regular = TTFont(regular_name, str(regular_path)) if not all(ord(char) in regular.face.charToGlyph for char in "Отчёт курсанта Ёж"): raise RuntimeError(f"Шрифт не поддерживает кириллицу: {regular_path}") pdfmetrics.registerFont(regular) pdfmetrics.registerFont(TTFont(bold_name, str(bold_path or regular_path))) pdfmetrics.registerFontFamily(regular_name, normal=regular_name, bold=bold_name) base = ParagraphStyle( "body", fontName=regular_name, fontSize=9, leading=13, textColor=colors.HexColor("#18202a"), spaceAfter=5, allowWidows=0, allowOrphans=0, splitLongWords=1, ) title_style = ParagraphStyle( "title", parent=base, fontName=bold_name, fontSize=17, leading=22, textColor=colors.HexColor("#193b56"), spaceAfter=10, ) heading = ParagraphStyle( "heading", parent=base, fontName=bold_name, fontSize=12, leading=17, textColor=colors.HexColor("#193b56"), spaceBefore=13, spaceAfter=5, keepWithNext=True, ) muted = ParagraphStyle("muted", parent=base, fontSize=8, textColor=colors.HexColor("#5d6873")) def p(value: object, style=base): # XML-экранирование необходимо: реплики и пометки вводит человек. safe = escape("" if value is None else str(value)).replace("\n", "
") return Paragraph(safe or "-", style) story = [p("Отчёт по учебному занятию", title_style)] for label, value in ( ("Идентификатор", report.session_id), ("Сценарий", report.scenario_id), ("Режим", report.mode.value), ("Попытка", report.attempt), ("Лимит решения ДДС", f"{report.criteria.decision_time_limit_seconds} с"), ("Допустимо ошибок", report.criteria.allowed_errors), ("Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет"), ("Переопределения весов", report.criteria.score_weights or "стандартные"), ("Нарушенных метрик", report.failed_metrics), ("Результат", "зачёт" if report.passed else "не зачтено"), ("Автоматическая оценка", report.score_auto), ("Итоговая оценка", report.score_final), ): story.append(Paragraph(f"{escape(label)}: {escape(str(value))}", base)) if report.overridden_by: story.append(Paragraph(f"Оценка изменена: {escape(report.overridden_by)}", base)) if report.override_comment: story.append(p(f"Причина изменения: {report.override_comment}")) story.extend([Spacer(1, 7), HRFlowable(width="100%", color=colors.HexColor("#aeb8c2"))]) def section(label: str): story.append(p(label, heading)) section("Метрики") if not report.metrics: story.append(p("Нет данных", muted)) for item in report.metrics: story.append(p(f"{item.title} - {'выполнено' if item.passed else 'нарушено'} (вес {item.weight:g})")) story.append(p(f"Факт: {item.fact}. Норматив: {item.norm}. {item.ref or ''}", muted)) section("Выявленные ошибки") if not report.findings: story.append(p("Ошибок не выявлено", muted)) for item in report.findings: story.append(p(f"{item.code.value} - {item.summary}")) story.append(p(f"Факт: {item.fact}. Норматив: {item.norm or 'не указан'}. Источник: {item.ref or 'не указан'}", muted)) section("Компетенции") for item in report.competencies: story.append(p(f"{item.competency}: {item.value:g}")) if not report.competencies: story.append(p("Нет данных", muted)) section("Транскрипт") if not report.transcript: story.append(p("Реплик нет", muted)) for item in report.transcript: story.append(p(f"{item.at.isoformat()} - {item.speaker.value}", muted)) story.append(p(item.text)) section("Эталонные вопросы") for item in report.reference_questions: story.append(p(f"{item.checklist_id}: {item.question}")) if not report.reference_questions: story.append(p("Нет данных", muted)) if report.missed_checklist: section("Пропущенные пункты") for item in report.missed_checklist: story.append(p(item)) if report.hints_used: section("Использованные подсказки") for item in report.hints_used: story.append(p(f"{item.at.isoformat()} - {item.checklist_id}: {item.question}")) if report.self_assessment: section("Самооценка") story.append(p("Пропущено по мнению курсанта: " + ", ".join(report.self_assessment.missed))) story.append(p("Комментарий: " + report.self_assessment.comment)) if report.self_assessment_diff: for label, items in ( ("Заметил", report.self_assessment_diff.noticed), ("Не заметил", report.self_assessment_diff.unnoticed), ("Отметил ошибочно", report.self_assessment_diff.overcautious), ): story.append(p(f"{label}: {', '.join(items) or '-'}")) if report.notes: section("Пометки преподавателя") for item in report.notes: story.append(p(f"{item.transcript_ref} - {item.author}: {item.text}")) output = io.BytesIO() document = SimpleDocTemplate( output, pagesize=A4, leftMargin=42, rightMargin=42, topMargin=45, bottomMargin=44, title="Отчёт по учебному занятию", author="Учебный симулятор 112", ) def footer(canvas, doc): canvas.saveState() canvas.setFont(regular_name, 8) canvas.setFillColor(colors.HexColor("#5d6873")) canvas.drawString(42, 25, "Учебный симулятор 112") canvas.drawRightString(A4[0] - 42, 25, f"Стр. {doc.page}") canvas.restoreState() document.build(story, onFirstPage=footer, onLaterPages=footer) return output.getvalue() def certificate_pdf( *, trainee_name: str, trainee_id: object, group_name: str | None, attempts: int, average_score: float, issued_at: object, ) -> bytes: """Одностраничный сертификат по фактически сохранённым результатам.""" from reportlab.lib import colors from reportlab.lib.enums import TA_CENTER from reportlab.lib.pagesizes import A4, landscape from reportlab.lib.styles import ParagraphStyle from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer regular_path, bold_path = _font_paths() regular_name = "LCTCertificateRegular" bold_name = "LCTCertificateBold" if regular_name not in pdfmetrics.getRegisteredFontNames(): regular = TTFont(regular_name, str(regular_path)) if not all(ord(char) in regular.face.charToGlyph for char in "Сертификат Ёж"): raise RuntimeError(f"Шрифт не поддерживает кириллицу: {regular_path}") pdfmetrics.registerFont(regular) pdfmetrics.registerFont(TTFont(bold_name, str(bold_path or regular_path))) title = ParagraphStyle( "certificate-title", fontName=bold_name, fontSize=28, leading=34, alignment=TA_CENTER, textColor=colors.HexColor("#193b56"), spaceAfter=20, ) body = ParagraphStyle( "certificate-body", fontName=regular_name, fontSize=14, leading=21, alignment=TA_CENTER, textColor=colors.HexColor("#18202a"), spaceAfter=10, ) name_style = ParagraphStyle( "certificate-name", parent=body, fontName=bold_name, fontSize=22, leading=28, textColor=colors.HexColor("#a24816"), spaceAfter=18, ) small = ParagraphStyle( "certificate-small", parent=body, fontSize=9, leading=13, textColor=colors.HexColor("#5d6873"), ) safe_name = escape(trainee_name) safe_group = escape(group_name or "без группы") safe_date = escape(str(issued_at)) story = [ Spacer(1, 34), Paragraph("СЕРТИФИКАТ", title), Paragraph("о прохождении учебных занятий в локальном тренажёре системы-112 и ДДС", body), Spacer(1, 12), Paragraph(safe_name, name_style), Paragraph( f"Выполнено занятий: {attempts} · средняя итоговая оценка: " f"{average_score:.1f} из 100", body, ), Paragraph(f"Учебная группа: {safe_group}", body), Spacer(1, 28), Paragraph(f"Дата формирования: {safe_date}", small), Paragraph(f"Идентификатор курсанта: {escape(str(trainee_id))}", small), Paragraph( "Документ сформирован локально по сохранённым результатам и не заменяет " "квалификационный документ установленного образца.", small, ), ] output = io.BytesIO() document = SimpleDocTemplate( output, pagesize=landscape(A4), leftMargin=60, rightMargin=60, topMargin=45, bottomMargin=45, title="Сертификат о прохождении обучения", author="Учебный симулятор 112", ) document.build(story) return output.getvalue()