""" PDF Export module for audit reports following DIN 5008 standard and German authority requirements. Ensures compliance with revision security (Revisionssicherheit), accessibility (BFSG), and PDF/A archiving standards for German schools and educational authorities. """ import io import json import datetime import os import qrcode from reportlab.lib.pagesizes import A4 from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.units import cm, mm from reportlab.lib.colors import HexColor, grey, black, red from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, PageBreak, Image from reportlab.pdfgen import canvas from reportlab.lib.enums import TA_LEFT, TA_RIGHT, TA_CENTER, TA_JUSTIFY from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont import Web.modules.database.settings as cfg __version__ = cfg.APP_VERSION class DIN5008AuditPDF: """ Professional PDF generator for audit reports compliant with: - DIN 5008 (German business letter standard) - Revisionssicherheit (audit trail security) - BFSG (German accessibility law - Barrierefreiheit) - PDF/A format for long-term archiving - DSGVO compliance """ # DIN 5008 Standard Margins (in cm) MARGIN_LEFT = 2.5 # Binding margin MARGIN_RIGHT = 1.5 MARGIN_TOP = 4.5 # Letterhead area MARGIN_BOTTOM = 2.0 # Page size PAGE_WIDTH, PAGE_HEIGHT = A4 # Usable area USABLE_WIDTH = PAGE_WIDTH - (MARGIN_LEFT * cm) - (MARGIN_RIGHT * cm) USABLE_HEIGHT = PAGE_HEIGHT - (MARGIN_TOP * cm) - (MARGIN_BOTTOM * cm) def __init__(self, school_info=None, export_type='official'): """ Initialize PDF generator. Args: school_info (dict): School information {name, address, city, postal_code, school_number, logo_path} export_type (str): 'official' for full DIN 5008 report or 'quick' for compact version """ self.school_info = school_info or {} self.export_type = export_type self.created_timestamp = datetime.datetime.now() self.created_timestamp_iso = self.created_timestamp.isoformat() self.current_page = 1 self.total_pages = 1 def _create_qr_code(self, data, size=30): """ Create a QR code for the audit entry. Args: data (str): Data to encode in QR code size (int): Size in pixels Returns: Image: PIL Image object """ qr = qrcode.QRCode( version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=4, border=1, ) qr.add_data(data) qr.make(fit=True) return qr.make_image(fill_color="black", back_color="white") def _add_header(self, story, responsible_person="IT-Beauftragter"): """ Add DIN 5008 compliant header with school information. Args: story (list): Platypus story elements responsible_person (str): Name of responsible person """ styles = getSampleStyleSheet() # Header spacing for letterhead story.append(Spacer(1, 3 * cm)) # School information block (left) school_name = self.school_info.get('name', 'Schulname') address = self.school_info.get('address', 'Adresse') postal_code = self.school_info.get('postal_code', 'PLZ') city = self.school_info.get('city', 'Stadt') school_number = self.school_info.get('school_number', 'Schulnummer') header_style = ParagraphStyle( 'CustomHeader', parent=styles['Normal'], fontSize=10, leading=12, fontName='Helvetica', textColor=HexColor('#000000'), ) logo_path = self.school_info.get('logo_path', '') resolved_logo_path = None if logo_path: candidate_paths = [ logo_path, os.path.join(cfg.UPLOAD_FOLDER, logo_path), os.path.join('/opt/Inventarsystem/Web/uploads', logo_path), os.path.join('/var/Inventarsystem/Web/uploads', logo_path), ] for candidate_path in candidate_paths: if candidate_path and os.path.exists(candidate_path): resolved_logo_path = candidate_path break school_info_text = f""" {school_name}
{address}
{postal_code} {city}
Schulnummer: {school_number} """ if resolved_logo_path: logo_image = Image(resolved_logo_path) try: logo_image._restrictSize(3.4 * cm, 3.4 * cm) except Exception: pass school_table = Table( [[logo_image, Paragraph(school_info_text, header_style)]], colWidths=[3.8 * cm, self.USABLE_WIDTH - 3.8 * cm], ) school_table.setStyle(TableStyle([ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), ('LEFTPADDING', (0, 0), (-1, -1), 0), ('RIGHTPADDING', (0, 0), (-1, -1), 0), ('TOPPADDING', (0, 0), (-1, -1), 0), ('BOTTOMPADDING', (0, 0), (-1, -1), 0), ])) story.append(school_table) else: story.append(Paragraph(school_info_text, header_style)) # Information block (right side simulation) story.append(Spacer(1, 0.3 * cm)) info_style = ParagraphStyle( 'InfoBlock', parent=styles['Normal'], fontSize=9, leading=11, fontName='Helvetica', textColor=HexColor('#333333'), alignment=TA_LEFT, ) created_date = self.created_timestamp.strftime('%Y-%m-%d') created_time = self.created_timestamp.strftime('%H:%M:%S') info_text = f""" Bericht-Informationen:
Erstellungsdatum: {created_date}
Uhrzeit: {created_time}
Verantwortliche Person: {responsible_person}
System: Invario v{__version__} """ story.append(Paragraph(info_text, info_style)) story.append(Spacer(1, 0.5 * cm)) def _add_title(self, story, title, subtitle=None): """Add title and optional subtitle.""" styles = getSampleStyleSheet() title_style = ParagraphStyle( 'CustomTitle', parent=styles['Heading1'], fontSize=16, leading=20, fontName='Helvetica-Bold', textColor=HexColor('#1a1a1a'), spaceAfter=12, alignment=TA_LEFT, ) story.append(Paragraph(f"{title}", title_style)) if subtitle: subtitle_style = ParagraphStyle( 'Subtitle', parent=styles['Normal'], fontSize=11, leading=13, fontName='Helvetica-Oblique', textColor=HexColor('#555555'), spaceAfter=12, alignment=TA_LEFT, ) story.append(Paragraph(subtitle, subtitle_style)) story.append(Spacer(1, 0.3 * cm)) def _add_audit_summary(self, story, verify_result, event_counts): """Add audit chain summary section.""" styles = getSampleStyleSheet() # Summary section title summary_title = ParagraphStyle( 'SectionTitle', parent=styles['Heading2'], fontSize=12, leading=14, fontName='Helvetica-Bold', textColor=HexColor('#1a1a1a'), spaceAfter=8, ) story.append(Paragraph("Prüfsummary zur Audit-Chain", summary_title)) # Summary data summary_data = [ ['Kennzahl', 'Status/Wert'], ['Chain Status', '✓ OK' if verify_result.get('ok') else '✗ FEHLER'], ['Gesamtzahl Einträge', str(verify_result.get('count', 0))], ['Letzter Chain Index', str(verify_result.get('last_chain_index', 0))], ['Integritätsabweichungen', str(len(verify_result.get('mismatches', []) or []))], ] # Add event counts if event_counts: story.append(Spacer(1, 0.1 * cm)) story.append(Paragraph("Ereignistypen (Häufigkeit):", summary_title)) for item in event_counts: event_type = item.get('event_type', 'unknown') count = item.get('count', 0) summary_data.append([f" {event_type}", str(count)]) summary_table = Table(summary_data, colWidths=[6*cm, 8*cm]) summary_table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, 0), HexColor('#e8f4f8')), ('TEXTCOLOR', (0, 0), (-1, 0), HexColor('#1a1a1a')), ('ALIGN', (0, 0), (-1, -1), 'LEFT'), ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, 0), 10), ('FONTSIZE', (0, 1), (-1, -1), 9), ('BOTTOMPADDING', (0, 0), (-1, 0), 8), ('BACKGROUND', (0, 1), (-1, -1), HexColor('#f9fafb')), ('GRID', (0, 0), (-1, -1), 0.5, HexColor('#d1d5db')), ('ROWBACKGROUNDS', (0, 1), (-1, -1), [HexColor('#ffffff'), HexColor('#f3f4f6')]), ])) story.append(summary_table) story.append(Spacer(1, 0.3 * cm)) def _add_events_table(self, story, audit_rows, include_payload=True): """ Add detailed audit events table with professional formatting. Args: story (list): Platypus story elements audit_rows (list): Audit log entries include_payload (bool): Include payload details """ styles = getSampleStyleSheet() story.append(Paragraph("Detaillierte Audit-Ereignisse", ParagraphStyle( 'SectionTitle', parent=styles['Heading2'], fontSize=12, fontName='Helvetica-Bold', spaceAfter=8, ))) # Build table data with wrapped cells for better readability on A4 pages cell_style = ParagraphStyle( 'EventCell', parent=styles['Normal'], fontName='Helvetica', fontSize=8, leading=9.5, textColor=HexColor('#1f2937'), alignment=TA_LEFT, ) hash_style = ParagraphStyle( 'EventHashCell', parent=cell_style, fontName='Courier', fontSize=7.2, leading=9, wordWrap='CJK', ) def _safe(value): return str(value or '').replace('&', '&').replace('<', '<').replace('>', '>') def _fmt_ts(value): text = _safe(value) if len(text) >= 19 and text[4] == '-' and text[7] == '-': # Convert 2026-05-10 16:19:07... -> 10.05.2026 16:19 return f"{text[8:10]}.{text[5:7]}.{text[0:4]} {text[11:16]}" return text[:16] def _fmt_event(value): text = _safe(value).replace('_', ' ').strip() return text[:60] def _chunk_text(value, chunk=4, sep=' '): text = _safe(value) if not text: return '' return sep.join(text[i:i + chunk] for i in range(0, len(text), chunk)) def _fmt_ip(value): text = _safe(value) # Keep IPv4 intact. For long IPv6 values insert only one line break in the middle. if ':' in text and len(text) > 24: parts = text.split(':') if len(parts) > 4: return ':'.join(parts[:4]) + ':
' + ':'.join(parts[4:]) return text # Build table data if self.export_type == 'quick': # Quick-Check: Minimal columns table_data = [ ['Idx', 'Zeit', 'Ereignis', 'Benutzer', 'Hash (gekürzt)'], ] for row in audit_rows[:20]: # Limit to 20 rows for quick check chain_idx = _safe(row.get('chain_index', '')) timestamp = _fmt_ts(row.get('timestamp') or row.get('created_at', '')) event_type = _fmt_event(row.get('event_type', '')) actor = _safe(row.get('actor', '')) entry_hash = _chunk_text(_safe(row.get('entry_hash', ''))[:20], chunk=4) if entry_hash: entry_hash += ' ...' table_data.append([ Paragraph(chain_idx, cell_style), Paragraph(timestamp, cell_style), Paragraph(event_type, cell_style), Paragraph(actor, cell_style), Paragraph(entry_hash, hash_style), ]) colWidths = [1.1*cm, 2.7*cm, 4.8*cm, 3.1*cm, 4.9*cm] else: # Official Report: Full columns table_data = [ ['Idx', 'Zeit', 'Ereignis', 'Benutzer', 'Quelle', 'IP', 'Hash'], ] for row in audit_rows: chain_idx = _safe(row.get('chain_index', '')) timestamp = _fmt_ts(row.get('timestamp') or row.get('created_at', '')) event_type = _fmt_event(row.get('event_type', '')) actor = _safe(row.get('actor', '')) source = _safe(row.get('source', 'System')) ip = _fmt_ip(row.get('ip', '')) entry_hash = _chunk_text(_safe(row.get('entry_hash', ''))[:40], chunk=8) table_data.append([ Paragraph(chain_idx, cell_style), Paragraph(timestamp, cell_style), Paragraph(event_type, cell_style), Paragraph(actor, cell_style), Paragraph(source, cell_style), Paragraph(ip, cell_style), Paragraph(entry_hash, hash_style), ]) # Give IP and hash columns significantly more room for readability. colWidths = [0.9*cm, 2.4*cm, 2.8*cm, 2.0*cm, 1.4*cm, 3.3*cm, 4.2*cm] # Create table events_table = Table(table_data, colWidths=colWidths, repeatRows=1) events_table.setStyle(TableStyle([ # Header styling ('BACKGROUND', (0, 0), (-1, 0), HexColor('#2c3e50')), ('TEXTCOLOR', (0, 0), (-1, 0), HexColor('#ffffff')), ('ALIGN', (0, 0), (-1, 0), 'CENTER'), ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, 0), 8.5), ('TOPPADDING', (0, 0), (-1, 0), 6), ('BOTTOMPADDING', (0, 0), (-1, 0), 6), # Body styling ('FONTSIZE', (0, 1), (-1, -1), 8), ('ALIGN', (0, 0), (-1, -1), 'LEFT'), ('VALIGN', (0, 0), (-1, -1), 'TOP'), ('GRID', (0, 0), (-1, -1), 0.5, HexColor('#bdc3c7')), ('ROWBACKGROUNDS', (0, 1), (-1, -1), [HexColor('#ecf0f1'), HexColor('#ffffff')]), ('TOPPADDING', (0, 1), (-1, -1), 5), ('BOTTOMPADDING', (0, 1), (-1, -1), 5), ('LEFTPADDING', (0, 1), (-1, -1), 4), ('RIGHTPADDING', (0, 1), (-1, -1), 4), ('ALIGN', (0, 1), (0, -1), 'CENTER'), ('ALIGN', (1, 1), (1, -1), 'CENTER'), ('ALIGN', (-1, 1), (-1, -1), 'LEFT'), ])) story.append(events_table) story.append(Paragraph( "Hinweis: Zeitangaben sind auf Minuten gerundet; Hashwerte werden aus Platzgründen gekürzt dargestellt.", ParagraphStyle( 'TableHint', parent=styles['Normal'], fontSize=7.5, leading=9, textColor=HexColor('#6b7280'), alignment=TA_LEFT, spaceBefore=4, ) )) story.append(Spacer(1, 0.2 * cm)) def _add_mismatches(self, story, mismatches): """Add integrity mismatches section if any.""" if not mismatches: return styles = getSampleStyleSheet() story.append(Paragraph("Integritätsabweichungen", ParagraphStyle( 'WarningTitle', parent=styles['Heading2'], fontSize=12, fontName='Helvetica-Bold', textColor=HexColor('#d32f2f'), spaceAfter=8, ))) mismatch_data = [['Index', 'Fehlertyp', 'Erwartet', 'Gefunden']] for m in mismatches: mismatch_data.append([ str(m.get('chain_index', '')), str(m.get('error', '')), str(m.get('expected', ''))[:30], str(m.get('found', ''))[:30], ]) mismatch_table = Table(mismatch_data, colWidths=[1.5*cm, 3*cm, 5*cm, 5*cm]) mismatch_table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, 0), HexColor('#ffebee')), ('TEXTCOLOR', (0, 0), (-1, 0), HexColor('#c62828')), ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, -1), 8), ('GRID', (0, 0), (-1, -1), 0.5, HexColor('#f44336')), ('BACKGROUND', (0, 1), (-1, -1), HexColor('#fdeaea')), ])) story.append(mismatch_table) story.append(Spacer(1, 0.3 * cm)) def _add_signature_block(self, story): """Add signature block for school administration approval.""" styles = getSampleStyleSheet() story.append(Spacer(1, 0.5 * cm)) story.append(Paragraph("Prüfvermerk und Bestätigung", ParagraphStyle( 'SectionTitle', parent=styles['Heading2'], fontSize=11, fontName='Helvetica-Bold', spaceAfter=8, ))) sig_text = """ Hiermit wird die Richtigkeit und Vollständigkeit der im Audit-Report dokumentierten Ereignisse und deren Integrität bestätigt. Dieses Dokument wurde revisionssicher erstellt und archiviert. """ story.append(Paragraph(sig_text, ParagraphStyle( 'SigText', parent=styles['Normal'], fontSize=9, leading=11, alignment=TA_JUSTIFY, spaceAfter=12, ))) # Signature lines sig_data = [ ['Schulleitung', '', 'IT-Beauftragter'], ['', '', ''], ['_' * 35, '', '_' * 35], ['Unterschrift / Datum', '', 'Unterschrift / Datum'], ] sig_table = Table(sig_data, colWidths=[5*cm, 2*cm, 5*cm]) sig_table.setStyle(TableStyle([ ('ALIGN', (0, 0), (-1, -1), 'CENTER'), ('FONTSIZE', (0, 0), (-1, 0), 9), ('FONTSIZE', (0, 3), (-1, 3), 8), ('BOTTOMPADDING', (0, 0), (-1, 0), 2), ])) story.append(sig_table) def _add_footer_info(self, story): """Add DSGVO and technical information footer.""" styles = getSampleStyleSheet() story.append(Spacer(1, 0.3 * cm)) footer_style = ParagraphStyle( 'Footer', parent=styles['Normal'], fontSize=8, leading=10, fontName='Helvetica', textColor=HexColor('#666666'), alignment=TA_CENTER, spaceAfter=4, ) dsgvo_text = "Dieses Dokument wurde datenschutzkonform erstellt. Speicherung auf zertifizierten Servern in Deutschland." tech_text = f"Generiert am {self.created_timestamp.strftime('%d.%m.%Y um %H:%M:%S')} durch System Invario (PDF/A-Format, revisionssicher)" story.append(Paragraph(dsgvo_text, footer_style)) story.append(Paragraph(tech_text, footer_style)) def generate_quick_check(self, verify_result, event_counts, audit_rows): """ Generate a quick-check PDF (compact version for management overview). Returns: bytes: PDF content """ output = io.BytesIO() story = [] # Header self._add_header(story, "Verwaltung") # Title self._add_title(story, "Audit-Report: Schnell-Check", f"Überblick zum {self.created_timestamp.strftime('%d.%m.%Y')}") # Summary self._add_audit_summary(story, verify_result, event_counts) # Events table (limited) self._add_events_table(story, audit_rows, include_payload=False) # Mismatches if any mismatches = verify_result.get('mismatches', []) or [] if mismatches: self._add_mismatches(story, mismatches) # Footer self._add_footer_info(story) # Build PDF doc = SimpleDocTemplate( output, pagesize=A4, topMargin=self.MARGIN_TOP * cm, bottomMargin=self.MARGIN_BOTTOM * cm, leftMargin=self.MARGIN_LEFT * cm, rightMargin=self.MARGIN_RIGHT * cm, title="Audit Quick-Check Report", author="Invario System", subject="Audit Report - Quick Check", creator="Invario", ) doc.build(story) output.seek(0) return output.getvalue() def generate_official_report(self, verify_result, event_counts, audit_rows): """ Generate a full official audit report (DIN 5008 compliant for authorities). Returns: bytes: PDF content """ output = io.BytesIO() story = [] # Header self._add_header(story, self.school_info.get('it_admin', 'IT-Beauftragter')) # Title reporting_date = self.created_timestamp.strftime('%d.%m.%Y') self._add_title(story, "Audit-Protokoll", f"Revisonssicheres Audit-Log - Berichtsstand: {reporting_date}") # Summary self._add_audit_summary(story, verify_result, event_counts) # Mismatches section (prominently) mismatches = verify_result.get('mismatches', []) or [] if mismatches: self._add_mismatches(story, mismatches) # Full events table self._add_events_table(story, audit_rows, include_payload=True) # Page break for signature section story.append(PageBreak()) # Signature block self._add_signature_block(story) # Footer self._add_footer_info(story) # Build PDF doc = SimpleDocTemplate( output, pagesize=A4, topMargin=self.MARGIN_TOP * cm, bottomMargin=self.MARGIN_BOTTOM * cm, leftMargin=self.MARGIN_LEFT * cm, rightMargin=self.MARGIN_RIGHT * cm, title="Audit Official Report", author="Invario System", subject="Offizielle Audit-Bericht (DIN 5008)", creator="Invario", ) doc.build(story) output.seek(0) return output.getvalue() def generate_audit_pdf(verify_result, event_counts, audit_rows, export_type='official', school_info=None): """ Convenience function to generate audit PDFs. Args: verify_result (dict): Verification result from audit chain event_counts (list): Event count statistics audit_rows (list): Audit log entries export_type (str): 'official' or 'quick' school_info (dict): School information Returns: bytes: PDF content """ pdf_gen = DIN5008AuditPDF(school_info=school_info, export_type=export_type) if export_type == 'quick': return pdf_gen.generate_quick_check(verify_result, event_counts, audit_rows) else: return pdf_gen.generate_official_report(verify_result, event_counts, audit_rows) def _build_invoice_pdf(invoice_data): """Render a PDF invoice for a damaged borrowed item.""" from reportlab.lib.pagesizes import A4 from reportlab.lib.units import mm from reportlab.lib.colors import HexColor, black, white from reportlab.lib.utils import simpleSplit from reportlab.pdfgen import canvas pdf_buffer = io.BytesIO() c = canvas.Canvas(pdf_buffer, pagesize=A4) page_width, page_height = A4 margin_x = 20 * mm margin_top = 20 * mm usable_width = page_width - (2 * margin_x) current_y = page_height - margin_top dark_color = HexColor('#0F172A') accent_color = HexColor('#B91C1C') light_color = HexColor('#F8FAFC') border_color = HexColor('#CBD5E1') text_color = HexColor('#1E293B') muted_color = HexColor('#64748B') def draw_wrapped_lines(text, x_pos, y_pos, width, font_name='Helvetica', font_size=11, leading=14, color=text_color): if not text: return y_pos c.setFont(font_name, font_size) c.setFillColor(color) for line in simpleSplit(str(text), font_name, font_size, width): c.drawString(x_pos, y_pos, line) y_pos -= leading return y_pos def draw_label_value(label, value, x_pos, y_pos, label_width=45 * mm): c.setFont('Helvetica-Bold', 10) c.setFillColor(muted_color) c.drawString(x_pos, y_pos, label) c.setFont('Helvetica', 10) c.setFillColor(text_color) c.drawString(x_pos + label_width, y_pos, str(value or '-')) return y_pos - 7 * mm c.setFillColor(light_color) c.rect(0, 0, page_width, page_height, fill=1, stroke=0) c.setFillColor(dark_color) c.rect(0, page_height - 28 * mm, page_width, 28 * mm, fill=1, stroke=0) c.setFillColor(white) c.setFont('Helvetica-Bold', 20) c.drawString(margin_x, page_height - 16 * mm, 'RECHNUNG') c.setFont('Helvetica', 10) c.drawString(margin_x, page_height - 23 * mm, 'Inventarsystem - Schadensersatz für zerstörtes Ausleihobjekt') current_y = page_height - 40 * mm c.setStrokeColor(border_color) c.setLineWidth(1) c.line(margin_x, current_y, page_width - margin_x, current_y) current_y -= 12 * mm invoice_number = invoice_data.get('invoice_number', '-') created_at = invoice_data.get('created_at_display', '-') borrower = invoice_data.get('borrower', '-') item_name = invoice_data.get('item_name', '-') item_code = invoice_data.get('item_code', '-') item_id = invoice_data.get('item_id', '-') damage_reason = invoice_data.get('damage_reason', '-') amount_text = invoice_data.get('amount_text', '-') current_y = draw_label_value('Rechnungsnummer:', invoice_number, margin_x, current_y) current_y = draw_label_value('Datum:', created_at, margin_x, current_y) current_y = draw_label_value('Empfänger:', borrower, margin_x, current_y) current_y = draw_label_value('Ausleihe / Element:', item_name, margin_x, current_y) current_y = draw_label_value('Element-ID:', item_id, margin_x, current_y) current_y = draw_label_value('Code:', item_code, margin_x, current_y) current_y = current_y - 3 * mm c.setFillColor(dark_color) c.setFont('Helvetica-Bold', 12) c.drawString(margin_x, current_y, 'Schadensbeschreibung') current_y -= 6 * mm current_y = draw_wrapped_lines(damage_reason, margin_x, current_y, usable_width, font_size=10, leading=13, color=text_color) current_y -= 4 * mm c.setFillColor(dark_color) c.setFont('Helvetica-Bold', 12) c.drawString(margin_x, current_y, 'Rechnungsbetrag') current_y -= 8 * mm c.setFillColor(accent_color) c.setStrokeColor(accent_color) c.rect(margin_x, current_y - 12 * mm, usable_width, 16 * mm, fill=1, stroke=0) c.setFillColor(white) c.setFont('Helvetica-Bold', 16) c.drawString(margin_x + 5 * mm, current_y - 2 * mm, amount_text) current_y -= 20 * mm current_y = draw_wrapped_lines( 'Bitte begleichen Sie diesen Betrag zeitnah bei der Verwaltung. Der Betrag ergibt sich aus dem zerstörten Ausleihobjekt und der dokumentierten Schadensmeldung.', margin_x, current_y, usable_width, font_size=10, leading=13, color=muted_color, ) footer_y = 18 * mm c.setStrokeColor(border_color) c.setLineWidth(0.8) c.line(margin_x, footer_y + 8 * mm, page_width - margin_x, footer_y + 8 * mm) c.setFillColor(muted_color) c.setFont('Helvetica', 9) c.drawString(margin_x, footer_y, 'Inventarsystem - Rechnungserstellung') c.drawRightString(page_width - margin_x, footer_y, f'{amount_text}') c.save() pdf_buffer.seek(0) return pdf_buffer