Project_Manager/app/projects/pdf.py
2026-06-26 18:21:22 +02:00

175 lines
5.7 KiB
Python

"""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())