feat(#180): PNG-Scorecards via WeasyPrint→PyMuPDF

PyMuPDF (fitz) ist bereits in requirements.txt — also kein extra
Dependency noetig. Render-Pipeline: HTML-Template → WeasyPrint-PDF →
fitz Pixmap → PNG-Bytes.

Endpoint: GET /api/assessment/scorecard.png?drucksache=&bundesland=
&format=og|square&scale=2.0
- scale=2.0 (Default) liefert Retina-Aufloesung.
- scale=1.0..4.0 erlaubt.

Refactor: gemeinsamer Helper _render_scorecard_pdf() fuer .pdf und .png
— vorher Code-Duplikat zwischen den Endpoints.

Refs: #179, #180

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dotty Dotter 2026-05-07 08:03:38 +02:00
parent eeedf85d7e
commit 52ff36a136

View File

@ -3413,30 +3413,25 @@ async def scorecard_template(
})
@app.get("/api/assessment/scorecard.pdf")
async def api_scorecard_pdf(
drucksache: str, bundesland: str = "NRW", format: str = "og",
):
"""Liefert die Scorecard als PDF via WeasyPrint (#179).
async def _render_scorecard_pdf(
drucksache: str, bundesland: str, format: str,
) -> tuple[bytes, int, int, str]:
"""Render Scorecard-HTML zu PDF (gemeinsamer Helper für .pdf und .png).
`format=og` 1200×630pt, `format=square` 1080×1080pt.
PNG-Render via Playwright wird in einem Folge-Issue ergänzt
(Container hat heute kein Chromium installiert).
Returns (pdf_bytes, width, height, safe_filename).
"""
from fastapi.responses import Response as _Response
from weasyprint import HTML, CSS
from .models import Assessment
if format not in ("og", "square"):
raise HTTPException(status_code=400, detail="format muss 'og' oder 'square' sein")
width, height = ((1200, 630) if format == "og" else (1080, 1080))
drucksache = validate_drucksache(drucksache)
row = await get_assessment(drucksache)
if not row:
raise HTTPException(status_code=404, detail="Antrag nicht gefunden")
if format not in ("og", "square"):
raise HTTPException(status_code=400, detail="format muss 'og' oder 'square' sein")
width, height = ((1200, 630) if format == "og" else (1080, 1080))
# HTML aus dem internen Template ziehen (gleiche Logik wie /v2/scorecard).
from .models import Assessment
assessment = Assessment.model_validate(row)
matrix_lookup = {e.field: {"rating": e.rating} for e in assessment.gwoe_matrix}
fraktionen = list(row.get("fraktionen", []) or [])
@ -3461,20 +3456,63 @@ async def api_scorecard_pdf(
fraktionen=fraktionen[:4],
datum=(row.get("datum") or "")[:10],
score_color=score_color,
width=width,
height=height,
width=width, height=height,
)
# PDF mit exakt einer Seite in der Zielgrösse
page_size_css = f"@page {{ size: {width}px {height}px; margin: 0; }}"
pdf = HTML(string=html_content).write_pdf(stylesheets=[CSS(string=page_size_css)])
safe_name = drucksache.replace("/", "-")
safe = drucksache.replace("/", "-")
return pdf, width, height, f"scorecard-{safe}-{format}"
@app.get("/api/assessment/scorecard.png")
async def api_scorecard_png(
drucksache: str, bundesland: str = "NRW", format: str = "og", scale: float = 2.0,
):
"""Liefert die Scorecard als PNG via WeasyPrint→PyMuPDF.
`scale=2.0` rendert in doppelter Auflösung (für Retina-Displays).
"""
from fastapi.responses import Response as _Response
pdf_bytes, width, height, safe_name = await _render_scorecard_pdf(
drucksache, bundesland, format,
)
try:
import fitz # pymupdf
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
page = doc[0]
# zoom-Matrix für höhere Auflösung
zoom = max(0.5, min(4.0, float(scale)))
mat = fitz.Matrix(zoom, zoom)
pix = page.get_pixmap(matrix=mat, alpha=False)
png = pix.tobytes("png")
doc.close()
except Exception as e:
logger.exception("PNG-Render fehlgeschlagen")
raise HTTPException(status_code=500, detail=f"PNG-Render-Fehler: {e}")
return _Response(
content=png, media_type="image/png",
headers={
"Content-Disposition": f'inline; filename="{safe_name}.png"',
"Cache-Control": "public, max-age=600",
},
)
@app.get("/api/assessment/scorecard.pdf")
async def api_scorecard_pdf(
drucksache: str, bundesland: str = "NRW", format: str = "og",
):
"""Liefert die Scorecard als PDF via WeasyPrint (#179).
`format=og` 1200×630, `format=square` 1080×1080.
"""
from fastapi.responses import Response as _Response
pdf, _w, _h, safe_name = await _render_scorecard_pdf(drucksache, bundesland, format)
return _Response(
content=pdf, media_type="application/pdf",
headers={
"Content-Disposition": (
f'inline; filename="scorecard-{safe_name}-{format}.pdf"'
),
"Content-Disposition": f'inline; filename="{safe_name}.pdf"',
"Cache-Control": "public, max-age=600",
},
)