"""PDF-Export: projektbasierte To-Do-Liste zum Ausdrucken. Erzeugt mit fpdf2 (reines Python, keine System-Abhängigkeiten). Die DejaVu- Schrift ist im Repo mitgeliefert (app/projects/fonts/), damit das Ergebnis auf dem VPS unabhängig von dort installierten Fonts identisch aussieht und beliebiger Aufgabentext (Umlaute, Sonderzeichen) korrekt gerendert wird. Inhalt laut Festlegung: offene UND erledigte Aufgaben (erledigte abgehakt und durchgestrichen), Aufgaben ohne Projekt in einem eigenen Abschnitt am Ende. """ import os from datetime import date from fpdf import FPDF from fpdf.enums import XPos, YPos from .helpers import sort_tasks, today_label FONT_DIR = os.path.join(os.path.dirname(__file__), "fonts") # Farben (RGB) — bewusst auch in S/W-Druck lesbar (Info steht zusätzlich als Text). COL_TEXT = (25, 25, 25) COL_MUTED = (130, 130, 130) COL_OVERDUE = (200, 50, 60) COL_SOON = (190, 120, 20) COL_RULE = (210, 210, 210) def _hex_to_rgb(h: str) -> tuple: h = h.lstrip("#") return tuple(int(h[i : i + 2], 16) for i in (0, 2, 4)) def _due_text(due: date | None, done: bool): """Fälligkeit als reiner Text + Farbe (ohne Emoji, druckfreundlich).""" if due is None: return None, COL_MUTED label = f"{due.day:02d}.{due.month:02d}.{str(due.year)[2:]}" if done: return label, COL_MUTED diff = (due - date.today()).days if diff < 0: return f"{label} · {abs(diff)} Tag(e) überfällig", COL_OVERDUE if diff == 0: return f"{label} · heute fällig", COL_SOON if diff <= 7: return f"{label} · in {diff} Tag(en)", COL_SOON return label, COL_MUTED class TodoPDF(FPDF): def __init__(self): super().__init__(format="A4") self.set_auto_page_break(auto=True, margin=18) self.set_margins(15, 15, 15) self.add_font("DejaVu", "", os.path.join(FONT_DIR, "DejaVuSans.ttf")) self.add_font("DejaVu", "B", os.path.join(FONT_DIR, "DejaVuSans-Bold.ttf")) def header(self): self.set_font("DejaVu", "B", 15) self.set_text_color(*COL_TEXT) self.cell(0, 9, "Projekte & Aufgaben", new_x=XPos.LMARGIN, new_y=YPos.NEXT) self.set_font("DejaVu", "", 9) self.set_text_color(*COL_MUTED) self.cell(0, 5, today_label(), new_x=XPos.LMARGIN, new_y=YPos.NEXT) self.ln(3) self.set_draw_color(*COL_RULE) self.set_line_width(0.2) y = self.get_y() self.line(self.l_margin, y, self.w - self.r_margin, y) self.ln(4) def footer(self): self.set_y(-14) self.set_font("DejaVu", "", 8) self.set_text_color(*COL_MUTED) self.cell(0, 5, f"Seite {self.page_no()}", align="C") def _checkbox(pdf: TodoPDF, done: bool, size: float = 3.6) -> None: """Kästchen als Vektor zeichnen (scharf im Druck); bei done mit Haken.""" x, y = pdf.get_x(), pdf.get_y() top = y + 1.1 pdf.set_draw_color(*COL_TEXT) pdf.set_line_width(0.3) pdf.rect(x, top, size, size) if done: pdf.line(x + 0.7, top + size * 0.55, x + size * 0.42, top + size - 0.6) pdf.line(x + size * 0.42, top + size - 0.6, x + size - 0.4, top + 0.6) pdf.set_x(x + size + 2.5) def _task_line(pdf: TodoPDF, task) -> None: pdf.set_x(pdf.l_margin + 4) _checkbox(pdf, task.done) text_x = pdf.get_x() avail = pdf.w - pdf.r_margin - text_x pdf.set_font("DejaVu", "", 10.5) pdf.set_text_color(*(COL_MUTED if task.done else COL_TEXT)) y_before = pdf.get_y() pdf.multi_cell(avail, 5.2, task.text, new_x=XPos.LMARGIN, new_y=YPos.NEXT) if task.done: width = min(avail, pdf.get_string_width(task.text)) pdf.set_draw_color(*COL_MUTED) pdf.set_line_width(0.3) pdf.line(text_x, y_before + 2.6, text_x + width, y_before + 2.6) due_str, col = _due_text(task.due_date, task.done) if due_str: pdf.set_x(text_x) pdf.set_font("DejaVu", "", 8.5) pdf.set_text_color(*col) pdf.cell(0, 4.4, due_str, new_x=XPos.LMARGIN, new_y=YPos.NEXT) pdf.ln(1.4) def _section_header(pdf: TodoPDF, name: str, color_hex, open_count: int, total: int) -> None: pdf.ln(2.5) if color_hex: x, y = pdf.get_x(), pdf.get_y() pdf.set_fill_color(*_hex_to_rgb(color_hex)) pdf.ellipse(x, y + 1.6, 3, 3, style="F") pdf.set_x(x + 5) pdf.set_font("DejaVu", "B", 12) pdf.set_text_color(*COL_TEXT) pdf.cell(pdf.get_string_width(name) + 1, 6, name, new_x=XPos.RIGHT, new_y=YPos.TOP) pdf.set_font("DejaVu", "", 9) pdf.set_text_color(*COL_MUTED) pdf.cell(0, 6, f" ({open_count} offen · {total} gesamt)", new_x=XPos.LMARGIN, new_y=YPos.NEXT) pdf.ln(1) def build_todo_pdf(projects: list, tasks: list) -> bytes: """Baut die druckbare To-Do-PDF aus Projekten und Aufgaben des Users.""" by_pid = {} for t in tasks: by_pid.setdefault(t.project_id, []).append(t) pdf = TodoPDF() pdf.add_page() rendered = False for p in projects: ptasks = sort_tasks(by_pid.get(p.id, [])) if not ptasks: continue rendered = True open_count = sum(1 for t in ptasks if not t.done) _section_header(pdf, p.name, p.color, open_count, len(ptasks)) for t in ptasks: _task_line(pdf, t) no_project = sort_tasks(by_pid.get(None, [])) if no_project: rendered = True open_count = sum(1 for t in no_project if not t.done) _section_header(pdf, "Ohne Projekt", None, open_count, len(no_project)) for t in no_project: _task_line(pdf, t) if not rendered: pdf.set_font("DejaVu", "", 11) pdf.set_text_color(*COL_MUTED) pdf.cell(0, 8, "Keine Aufgaben vorhanden.", new_x=XPos.LMARGIN, new_y=YPos.NEXT) return bytes(pdf.output())