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))
+16
View File
@@ -9,6 +9,7 @@ from pydantic import BaseModel
from app.core import ollama_client, profiles
from app.core.config import settings
from app.core.docx_export import build_docx
from app.core.pdf_export import build_pdf
from app.core.whisper_engine import engine as whisper
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
@@ -35,6 +36,11 @@ class DocxIn(BaseModel):
profile: str | None = None
class PdfIn(BaseModel):
data: dict
profile: str | None = None
@app.get("/health")
async def health():
info = {
@@ -99,3 +105,13 @@ async def export_docx(payload: DocxIn):
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
headers={"Content-Disposition": 'attachment; filename="protocol.docx"'},
)
@app.post("/api/export/pdf")
async def export_pdf(payload: PdfIn):
data = build_pdf(payload.data, profile_name=payload.profile)
return Response(
content=data,
media_type="application/pdf",
headers={"Content-Disposition": 'attachment; filename="protocol.pdf"'},
)