feat: PDF-Export zusätzlich zu DOCX

Mac-Worker:
- pdf_export.py mit reportlab (pure Python, kein externes Tool)
- Nutzt dieselbe profile.docx-Konfig wie der DOCX-Renderer →
  strukturell identisch, nur andere Optik
- POST /api/export/pdf
- requirements: reportlab==4.2.5

LXC:
- Job.pdf_path neu (Migration: ALTER TABLE job ADD COLUMN)
- Pipeline-Step nach DOCX: PDF wird ebenfalls erzeugt
  (Progress: 90 DOCX → 95 PDF → 100 done)
- /api/jobs/{id}/download/pdf
- /api/jobs/{id}/retry löscht PDF mit, damit es neu erzeugt wird
- Frontend: zusätzlicher Download-Button "PDF" (vorne im Array)
- Tabelle: Download-Spalte 320px, 3-spaltiges Grid (5 Buttons → 3+2)
- Cards (Mobile) bleiben 2-spaltig (5 Buttons → 3 Zeilen)
This commit is contained in:
root
2026-05-14 04:48:47 +00:00
parent 7d4961933e
commit 354e4c57eb
10 changed files with 222 additions and 9 deletions
+165
View File
@@ -0,0 +1,165 @@
"""Profil-getriebenes PDF-Rendering mit reportlab.
Nutzt dieselbe `docx`-Konfig im Profil wie der DOCX-Renderer (meta + sections).
Damit ist das PDF strukturell konsistent zum DOCX, nur in anderer Optik.
"""
from __future__ import annotations
from io import BytesIO
from typing import Any
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import cm
from reportlab.platypus import (
ListFlowable,
ListItem,
Paragraph,
SimpleDocTemplate,
Spacer,
)
from . import profiles
def _build_styles():
s = getSampleStyleSheet()
s.add(ParagraphStyle(
name="DocTitle", parent=s["Title"],
fontSize=20, spaceAfter=10, textColor=colors.HexColor("#1a1a1a"),
))
s.add(ParagraphStyle(
name="MetaLine", parent=s["Normal"],
fontSize=10, leading=13, textColor=colors.HexColor("#444444"),
))
s.add(ParagraphStyle(
name="SectionHeading", parent=s["Heading2"],
fontSize=13, spaceBefore=14, spaceAfter=6,
textColor=colors.HexColor("#1a1a1a"),
))
s.add(ParagraphStyle(
name="Body2", parent=s["BodyText"],
fontSize=10.5, leading=14,
))
s.add(ParagraphStyle(
name="BulletItem", parent=s["BodyText"],
fontSize=10.5, leading=14, leftIndent=0,
))
return s
_STYLES = _build_styles()
def _esc(value: Any) -> str:
"""XML-Escape für reportlab Paragraph-Markup."""
if value is None:
return ""
return (
str(value)
.replace("&", "&")
.replace("<", "&lt;")
.replace(">", "&gt;")
)
def _para(text: str, style_name: str = "Body2") -> Paragraph:
"""Paragraph mit Newline → <br/>."""
return Paragraph(_esc(text).replace("\n", "<br/>"), _STYLES[style_name])
def build_pdf(data: dict[str, Any], profile_name: str | None = None) -> bytes:
profile = profiles.get(profile_name)
cfg = profile.docx or {}
title_field = cfg.get("title_field", "title")
title = (data.get(title_field) or cfg.get("default_title") or profile.display_name).strip()
buf = BytesIO()
doc = SimpleDocTemplate(
buf,
pagesize=A4,
leftMargin=2 * cm,
rightMargin=2 * cm,
topMargin=2 * cm,
bottomMargin=2 * cm,
title=title,
author="Voice-Agent",
)
story: list = [Paragraph(_esc(title), _STYLES["DocTitle"])]
# Meta-Block
meta_paras = []
for meta in cfg.get("meta", []) or []:
val = data.get(meta["key"])
if val:
label = meta.get("label", meta["key"])
meta_paras.append(Paragraph(
f"<b>{_esc(label)}:</b> {_esc(val)}",
_STYLES["MetaLine"],
))
story.extend(meta_paras)
if meta_paras:
story.append(Spacer(1, 8))
# Sektionen
for section in cfg.get("sections", []) or []:
_render_section(story, data, section)
# Parse-Error-Fallback
if data.get("_parse_error") and data.get("_raw"):
story.append(Paragraph("Roh-Antwort des Modells", _STYLES["SectionHeading"]))
story.append(_para(str(data["_raw"])))
doc.build(story)
return buf.getvalue()
def _render_section(story: list, data: dict, section: dict) -> None:
val = data.get(section["key"])
if val in (None, "", [], {}):
return
label = section.get("label", section["key"])
sec_type = section.get("type", "text")
story.append(Paragraph(_esc(label), _STYLES["SectionHeading"]))
if sec_type == "text":
story.append(_para(str(val)))
elif sec_type == "list":
items = val if isinstance(val, list) else [val]
bullets = [ListItem(_para(str(i), "BulletItem"), leftIndent=12) for i in items]
story.append(ListFlowable(bullets, bulletType="bullet", leftIndent=15))
elif sec_type == "tasks":
items = val if isinstance(val, list) else [val]
bullets = []
for t in items:
if isinstance(t, dict):
owner = t.get("owner") or "n/a"
task = t.get("task") or t.get("description") or ""
bullets.append(ListItem(
Paragraph(f"<b>{_esc(owner)}:</b> {_esc(task)}", _STYLES["BulletItem"]),
leftIndent=12,
))
else:
bullets.append(ListItem(_para(str(t), "BulletItem"), leftIndent=12))
story.append(ListFlowable(bullets, bulletType="bullet", leftIndent=15))
elif sec_type == "transcript":
items = val if isinstance(val, list) else [val]
for item in items:
if isinstance(item, dict):
t = item.get("time", "")
spk = item.get("speaker", "")
txt = item.get("text", "")
story.append(Paragraph(
f"<b>[{_esc(t)}] {_esc(spk)}:</b> {_esc(txt)}",
_STYLES["Body2"],
))
else:
story.append(_para(str(item)))
else:
story.append(_para(str(val)))
story.append(Spacer(1, 4))