175 lines
5.7 KiB
Python
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())
|