""" PDF Export Module for Abiturkorrektur System Generates: - Individual Gutachten PDFs for each student - Klausur overview PDFs with grade distribution - Niedersachsen-compliant formatting """ import io from datetime import datetime from typing import Dict, List, Optional, Any from reportlab.lib import colors from reportlab.lib.pagesizes import A4 from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.units import cm, mm from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_JUSTIFY, TA_RIGHT from reportlab.platypus import ( SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak, HRFlowable, Image, KeepTogether ) from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont # ============================================= # CONSTANTS # ============================================= GRADE_POINTS_TO_NOTE = { 15: "1+", 14: "1", 13: "1-", 12: "2+", 11: "2", 10: "2-", 9: "3+", 8: "3", 7: "3-", 6: "4+", 5: "4", 4: "4-", 3: "5+", 2: "5", 1: "5-", 0: "6" } CRITERIA_DISPLAY_NAMES = { "rechtschreibung": "Sprachliche Richtigkeit (Rechtschreibung)", "grammatik": "Sprachliche Richtigkeit (Grammatik)", "inhalt": "Inhaltliche Leistung", "struktur": "Aufbau und Struktur", "stil": "Ausdruck und Stil" } CRITERIA_WEIGHTS = { "rechtschreibung": 15, "grammatik": 15, "inhalt": 40, "struktur": 15, "stil": 15 } # ============================================= # STYLES # ============================================= def get_custom_styles(): """Create custom paragraph styles for Gutachten.""" styles = getSampleStyleSheet() # Title style styles.add(ParagraphStyle( name='GutachtenTitle', parent=styles['Heading1'], fontSize=16, spaceAfter=12, alignment=TA_CENTER, textColor=colors.HexColor('#1e3a5f') )) # Subtitle style styles.add(ParagraphStyle( name='GutachtenSubtitle', parent=styles['Heading2'], fontSize=12, spaceAfter=8, spaceBefore=16, textColor=colors.HexColor('#2c5282') )) # Section header styles.add(ParagraphStyle( name='SectionHeader', parent=styles['Heading3'], fontSize=11, spaceAfter=6, spaceBefore=12, textColor=colors.HexColor('#2d3748'), borderColor=colors.HexColor('#e2e8f0'), borderWidth=0, borderPadding=0 )) # Body text styles.add(ParagraphStyle( name='GutachtenBody', parent=styles['Normal'], fontSize=10, leading=14, alignment=TA_JUSTIFY, spaceAfter=6 )) # Small text for footer/meta styles.add(ParagraphStyle( name='MetaText', parent=styles['Normal'], fontSize=8, textColor=colors.grey, alignment=TA_LEFT )) # List item styles.add(ParagraphStyle( name='ListItem', parent=styles['Normal'], fontSize=10, leftIndent=20, bulletIndent=10, spaceAfter=4 )) return styles # ============================================= # PDF GENERATION FUNCTIONS # ============================================= def generate_gutachten_pdf( student_data: Dict[str, Any], klausur_data: Dict[str, Any], annotations: List[Dict[str, Any]] = None, workflow_data: Dict[str, Any] = None ) -> bytes: """ Generate a PDF Gutachten for a single student. Args: student_data: Student work data including criteria_scores, gutachten, grade_points klausur_data: Klausur metadata (title, subject, year, etc.) annotations: List of annotations for annotation summary workflow_data: Examiner workflow data (EK, ZK, DK info) Returns: PDF as bytes """ buffer = io.BytesIO() doc = SimpleDocTemplate( buffer, pagesize=A4, rightMargin=2*cm, leftMargin=2*cm, topMargin=2*cm, bottomMargin=2*cm ) styles = get_custom_styles() story = [] # Header story.append(Paragraph("Gutachten zur Abiturklausur", styles['GutachtenTitle'])) story.append(Paragraph(f"{klausur_data.get('subject', 'Deutsch')} - {klausur_data.get('title', '')}", styles['GutachtenSubtitle'])) story.append(Spacer(1, 0.5*cm)) # Meta information table meta_data = [ ["Pruefling:", student_data.get('student_name', 'Anonym')], ["Schuljahr:", f"{klausur_data.get('year', 2025)}"], ["Kurs:", klausur_data.get('semester', 'Abitur')], ["Datum:", datetime.now().strftime("%d.%m.%Y")] ] meta_table = Table(meta_data, colWidths=[4*cm, 10*cm]) meta_table.setStyle(TableStyle([ ('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, -1), 10), ('BOTTOMPADDING', (0, 0), (-1, -1), 4), ('TOPPADDING', (0, 0), (-1, -1), 4), ])) story.append(meta_table) story.append(Spacer(1, 0.5*cm)) story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor('#e2e8f0'))) story.append(Spacer(1, 0.5*cm)) # Gutachten content gutachten = student_data.get('gutachten', {}) if gutachten: # Einleitung if gutachten.get('einleitung'): story.append(Paragraph("Einleitung", styles['SectionHeader'])) story.append(Paragraph(gutachten['einleitung'], styles['GutachtenBody'])) story.append(Spacer(1, 0.3*cm)) # Hauptteil if gutachten.get('hauptteil'): story.append(Paragraph("Hauptteil", styles['SectionHeader'])) story.append(Paragraph(gutachten['hauptteil'], styles['GutachtenBody'])) story.append(Spacer(1, 0.3*cm)) # Fazit if gutachten.get('fazit'): story.append(Paragraph("Fazit", styles['SectionHeader'])) story.append(Paragraph(gutachten['fazit'], styles['GutachtenBody'])) story.append(Spacer(1, 0.3*cm)) # Staerken und Schwaechen if gutachten.get('staerken') or gutachten.get('schwaechen'): story.append(Spacer(1, 0.3*cm)) if gutachten.get('staerken'): story.append(Paragraph("Staerken:", styles['SectionHeader'])) for s in gutachten['staerken']: story.append(Paragraph(f"• {s}", styles['ListItem'])) if gutachten.get('schwaechen'): story.append(Paragraph("Verbesserungspotenzial:", styles['SectionHeader'])) for s in gutachten['schwaechen']: story.append(Paragraph(f"• {s}", styles['ListItem'])) else: story.append(Paragraph("Kein Gutachten-Text vorhanden.", styles['GutachtenBody'])) story.append(Spacer(1, 0.5*cm)) story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor('#e2e8f0'))) story.append(Spacer(1, 0.5*cm)) # Bewertungstabelle story.append(Paragraph("Bewertung nach Kriterien", styles['SectionHeader'])) story.append(Spacer(1, 0.2*cm)) criteria_scores = student_data.get('criteria_scores', {}) # Build criteria table data table_data = [["Kriterium", "Gewichtung", "Erreicht", "Punkte"]] total_weighted = 0 total_weight = 0 for key, display_name in CRITERIA_DISPLAY_NAMES.items(): weight = CRITERIA_WEIGHTS.get(key, 0) score_data = criteria_scores.get(key, {}) score = score_data.get('score', 0) if isinstance(score_data, dict) else score_data # Calculate weighted contribution weighted_score = (score / 100) * weight if score else 0 total_weighted += weighted_score total_weight += weight table_data.append([ display_name, f"{weight}%", f"{score}%", f"{weighted_score:.1f}" ]) # Add total row table_data.append([ "Gesamt", f"{total_weight}%", "", f"{total_weighted:.1f}" ]) criteria_table = Table(table_data, colWidths=[8*cm, 2.5*cm, 2.5*cm, 2.5*cm]) criteria_table.setStyle(TableStyle([ # Header row ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#2c5282')), ('TEXTCOLOR', (0, 0), (-1, 0), colors.white), ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, 0), 10), ('ALIGN', (1, 0), (-1, -1), 'CENTER'), # Body rows ('FONTSIZE', (0, 1), (-1, -1), 9), ('BOTTOMPADDING', (0, 0), (-1, -1), 6), ('TOPPADDING', (0, 0), (-1, -1), 6), # Grid ('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#e2e8f0')), # Total row ('BACKGROUND', (0, -1), (-1, -1), colors.HexColor('#f7fafc')), ('FONTNAME', (0, -1), (-1, -1), 'Helvetica-Bold'), # Alternating row colors ('ROWBACKGROUNDS', (0, 1), (-1, -2), [colors.white, colors.HexColor('#f7fafc')]), ])) story.append(criteria_table) story.append(Spacer(1, 0.5*cm)) # Final grade box grade_points = student_data.get('grade_points', 0) grade_note = GRADE_POINTS_TO_NOTE.get(grade_points, "?") raw_points = student_data.get('raw_points', 0) grade_data = [ ["Rohpunkte:", f"{raw_points} / 100"], ["Notenpunkte:", f"{grade_points} Punkte"], ["Note:", grade_note] ] grade_table = Table(grade_data, colWidths=[4*cm, 4*cm]) grade_table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, -1), colors.HexColor('#ebf8ff')), ('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'), ('FONTNAME', (1, -1), (1, -1), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, -1), 11), ('FONTSIZE', (1, -1), (1, -1), 14), ('TEXTCOLOR', (1, -1), (1, -1), colors.HexColor('#2c5282')), ('BOTTOMPADDING', (0, 0), (-1, -1), 8), ('TOPPADDING', (0, 0), (-1, -1), 8), ('LEFTPADDING', (0, 0), (-1, -1), 12), ('BOX', (0, 0), (-1, -1), 1, colors.HexColor('#2c5282')), ('ALIGN', (1, 0), (1, -1), 'RIGHT'), ])) story.append(KeepTogether([ Paragraph("Endergebnis", styles['SectionHeader']), Spacer(1, 0.2*cm), grade_table ])) # Examiner workflow information if workflow_data: story.append(Spacer(1, 0.5*cm)) story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor('#e2e8f0'))) story.append(Spacer(1, 0.3*cm)) story.append(Paragraph("Korrekturverlauf", styles['SectionHeader'])) workflow_rows = [] if workflow_data.get('erst_korrektor'): ek = workflow_data['erst_korrektor'] workflow_rows.append([ "Erstkorrektor:", ek.get('name', 'Unbekannt'), f"{ek.get('grade_points', '-')} Punkte" ]) if workflow_data.get('zweit_korrektor'): zk = workflow_data['zweit_korrektor'] workflow_rows.append([ "Zweitkorrektor:", zk.get('name', 'Unbekannt'), f"{zk.get('grade_points', '-')} Punkte" ]) if workflow_data.get('dritt_korrektor'): dk = workflow_data['dritt_korrektor'] workflow_rows.append([ "Drittkorrektor:", dk.get('name', 'Unbekannt'), f"{dk.get('grade_points', '-')} Punkte" ]) if workflow_data.get('final_grade_source'): workflow_rows.append([ "Endnote durch:", workflow_data['final_grade_source'], "" ]) if workflow_rows: workflow_table = Table(workflow_rows, colWidths=[4*cm, 6*cm, 4*cm]) workflow_table.setStyle(TableStyle([ ('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, -1), 9), ('BOTTOMPADDING', (0, 0), (-1, -1), 4), ('TOPPADDING', (0, 0), (-1, -1), 4), ])) story.append(workflow_table) # Annotation summary (if any) if annotations: story.append(Spacer(1, 0.5*cm)) story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor('#e2e8f0'))) story.append(Spacer(1, 0.3*cm)) story.append(Paragraph("Anmerkungen (Zusammenfassung)", styles['SectionHeader'])) # Group annotations by type by_type = {} for ann in annotations: ann_type = ann.get('type', 'comment') if ann_type not in by_type: by_type[ann_type] = [] by_type[ann_type].append(ann) for ann_type, anns in by_type.items(): type_name = CRITERIA_DISPLAY_NAMES.get(ann_type, ann_type.replace('_', ' ').title()) story.append(Paragraph(f"{type_name} ({len(anns)} Anmerkungen)", styles['ListItem'])) # Footer with generation info story.append(Spacer(1, 1*cm)) story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor('#cbd5e0'))) story.append(Spacer(1, 0.2*cm)) story.append(Paragraph( f"Erstellt am {datetime.now().strftime('%d.%m.%Y um %H:%M Uhr')} | BreakPilot Abiturkorrektur-System", styles['MetaText'] )) # Build PDF doc.build(story) buffer.seek(0) return buffer.getvalue() def generate_klausur_overview_pdf( klausur_data: Dict[str, Any], students: List[Dict[str, Any]], fairness_data: Optional[Dict[str, Any]] = None ) -> bytes: """ Generate an overview PDF for an entire Klausur with all student grades. Args: klausur_data: Klausur metadata students: List of all student work data fairness_data: Optional fairness analysis data Returns: PDF as bytes """ buffer = io.BytesIO() doc = SimpleDocTemplate( buffer, pagesize=A4, rightMargin=1.5*cm, leftMargin=1.5*cm, topMargin=2*cm, bottomMargin=2*cm ) styles = get_custom_styles() story = [] # Header story.append(Paragraph("Notenuebersicht", styles['GutachtenTitle'])) story.append(Paragraph(f"{klausur_data.get('subject', 'Deutsch')} - {klausur_data.get('title', '')}", styles['GutachtenSubtitle'])) story.append(Spacer(1, 0.5*cm)) # Meta information meta_data = [ ["Schuljahr:", f"{klausur_data.get('year', 2025)}"], ["Kurs:", klausur_data.get('semester', 'Abitur')], ["Anzahl Arbeiten:", str(len(students))], ["Stand:", datetime.now().strftime("%d.%m.%Y")] ] meta_table = Table(meta_data, colWidths=[4*cm, 10*cm]) meta_table.setStyle(TableStyle([ ('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, -1), 10), ('BOTTOMPADDING', (0, 0), (-1, -1), 4), ('TOPPADDING', (0, 0), (-1, -1), 4), ])) story.append(meta_table) story.append(Spacer(1, 0.5*cm)) # Statistics (if fairness data available) if fairness_data and fairness_data.get('statistics'): stats = fairness_data['statistics'] story.append(Paragraph("Statistik", styles['SectionHeader'])) stats_data = [ ["Durchschnitt:", f"{stats.get('average_grade', 0):.1f} Punkte"], ["Minimum:", f"{stats.get('min_grade', 0)} Punkte"], ["Maximum:", f"{stats.get('max_grade', 0)} Punkte"], ["Standardabweichung:", f"{stats.get('standard_deviation', 0):.2f}"], ] stats_table = Table(stats_data, colWidths=[4*cm, 4*cm]) stats_table.setStyle(TableStyle([ ('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, -1), 9), ('BOTTOMPADDING', (0, 0), (-1, -1), 4), ('BACKGROUND', (0, 0), (-1, -1), colors.HexColor('#f7fafc')), ('BOX', (0, 0), (-1, -1), 0.5, colors.HexColor('#e2e8f0')), ])) story.append(stats_table) story.append(Spacer(1, 0.5*cm)) story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor('#e2e8f0'))) story.append(Spacer(1, 0.5*cm)) # Student grades table story.append(Paragraph("Einzelergebnisse", styles['SectionHeader'])) story.append(Spacer(1, 0.2*cm)) # Sort students by grade (descending) sorted_students = sorted(students, key=lambda s: s.get('grade_points', 0), reverse=True) # Build table header table_data = [["#", "Name", "Rohpunkte", "Notenpunkte", "Note", "Status"]] for idx, student in enumerate(sorted_students, 1): grade_points = student.get('grade_points', 0) grade_note = GRADE_POINTS_TO_NOTE.get(grade_points, "-") raw_points = student.get('raw_points', 0) status = student.get('status', 'unknown') # Format status status_display = { 'completed': 'Abgeschlossen', 'first_examiner': 'In Korrektur', 'second_examiner': 'Zweitkorrektur', 'uploaded': 'Hochgeladen', 'ocr_complete': 'OCR fertig', 'analyzing': 'Wird analysiert' }.get(status, status) table_data.append([ str(idx), student.get('student_name', 'Anonym'), f"{raw_points}/100", str(grade_points), grade_note, status_display ]) # Create table student_table = Table(table_data, colWidths=[1*cm, 5*cm, 2.5*cm, 3*cm, 2*cm, 3*cm]) student_table.setStyle(TableStyle([ # Header ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#2c5282')), ('TEXTCOLOR', (0, 0), (-1, 0), colors.white), ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, 0), 9), ('ALIGN', (0, 0), (-1, 0), 'CENTER'), # Body ('FONTSIZE', (0, 1), (-1, -1), 9), ('ALIGN', (0, 1), (0, -1), 'CENTER'), ('ALIGN', (2, 1), (4, -1), 'CENTER'), ('BOTTOMPADDING', (0, 0), (-1, -1), 6), ('TOPPADDING', (0, 0), (-1, -1), 6), # Grid ('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#e2e8f0')), # Alternating rows ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor('#f7fafc')]), ])) story.append(student_table) # Grade distribution story.append(Spacer(1, 0.5*cm)) story.append(Paragraph("Notenverteilung", styles['SectionHeader'])) story.append(Spacer(1, 0.2*cm)) # Count grades grade_counts = {} for student in sorted_students: gp = student.get('grade_points', 0) grade_counts[gp] = grade_counts.get(gp, 0) + 1 # Build grade distribution table dist_data = [["Punkte", "Note", "Anzahl"]] for points in range(15, -1, -1): if points in grade_counts: note = GRADE_POINTS_TO_NOTE.get(points, "-") count = grade_counts[points] dist_data.append([str(points), note, str(count)]) if len(dist_data) > 1: dist_table = Table(dist_data, colWidths=[2.5*cm, 2.5*cm, 2.5*cm]) dist_table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#2c5282')), ('TEXTCOLOR', (0, 0), (-1, 0), colors.white), ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, -1), 9), ('ALIGN', (0, 0), (-1, -1), 'CENTER'), ('BOTTOMPADDING', (0, 0), (-1, -1), 4), ('TOPPADDING', (0, 0), (-1, -1), 4), ('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#e2e8f0')), ])) story.append(dist_table) # Footer story.append(Spacer(1, 1*cm)) story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor('#cbd5e0'))) story.append(Spacer(1, 0.2*cm)) story.append(Paragraph( f"Erstellt am {datetime.now().strftime('%d.%m.%Y um %H:%M Uhr')} | BreakPilot Abiturkorrektur-System", styles['MetaText'] )) # Build PDF doc.build(story) buffer.seek(0) return buffer.getvalue() def generate_annotations_pdf( student_data: Dict[str, Any], klausur_data: Dict[str, Any], annotations: List[Dict[str, Any]] ) -> bytes: """ Generate a PDF with all annotations for a student work. Args: student_data: Student work data klausur_data: Klausur metadata annotations: List of all annotations Returns: PDF as bytes """ buffer = io.BytesIO() doc = SimpleDocTemplate( buffer, pagesize=A4, rightMargin=2*cm, leftMargin=2*cm, topMargin=2*cm, bottomMargin=2*cm ) styles = get_custom_styles() story = [] # Header story.append(Paragraph("Anmerkungen zur Klausur", styles['GutachtenTitle'])) story.append(Paragraph(f"{student_data.get('student_name', 'Anonym')}", styles['GutachtenSubtitle'])) story.append(Spacer(1, 0.5*cm)) if not annotations: story.append(Paragraph("Keine Anmerkungen vorhanden.", styles['GutachtenBody'])) else: # Group by type by_type = {} for ann in annotations: ann_type = ann.get('type', 'comment') if ann_type not in by_type: by_type[ann_type] = [] by_type[ann_type].append(ann) for ann_type, anns in by_type.items(): type_name = CRITERIA_DISPLAY_NAMES.get(ann_type, ann_type.replace('_', ' ').title()) story.append(Paragraph(f"{type_name} ({len(anns)})", styles['SectionHeader'])) story.append(Spacer(1, 0.2*cm)) # Sort by page then position sorted_anns = sorted(anns, key=lambda a: (a.get('page', 0), a.get('position', {}).get('y', 0))) for idx, ann in enumerate(sorted_anns, 1): page = ann.get('page', 1) text = ann.get('text', '') suggestion = ann.get('suggestion', '') severity = ann.get('severity', 'minor') # Build annotation text ann_text = f"[S.{page}] {text}" if suggestion: ann_text += f" → {suggestion}" # Color code by severity if severity == 'critical': ann_text = f"{ann_text}" elif severity == 'major': ann_text = f"{ann_text}" story.append(Paragraph(f"{idx}. {ann_text}", styles['ListItem'])) story.append(Spacer(1, 0.3*cm)) # Footer story.append(Spacer(1, 1*cm)) story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor('#cbd5e0'))) story.append(Spacer(1, 0.2*cm)) story.append(Paragraph( f"Erstellt am {datetime.now().strftime('%d.%m.%Y um %H:%M Uhr')} | BreakPilot Abiturkorrektur-System", styles['MetaText'] )) # Build PDF doc.build(story) buffer.seek(0) return buffer.getvalue()