From 52ff36a1360106fe97b7e300f4a5accb36274d9d Mon Sep 17 00:00:00 2001 From: Dotty Dotter Date: Thu, 7 May 2026 08:03:38 +0200 Subject: [PATCH] =?UTF-8?q?feat(#180):=20PNG-Scorecards=20via=20WeasyPrint?= =?UTF-8?q?=E2=86=92PyMuPDF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/main.py | 84 ++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 61 insertions(+), 23 deletions(-) diff --git a/app/main.py b/app/main.py index 7648a3d..4ea51d9 100644 --- a/app/main.py +++ b/app/main.py @@ -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", }, )