From 7f590ddecfd491877bfb12a40de089853443f56a Mon Sep 17 00:00:00 2001 From: Antoine Jacquin Date: Sun, 27 Sep 2026 16:11:57 +0200 Subject: [PATCH] Exposer le cadre imprimable et l'export PDF dans mapserve Co-Authored-By: Claude Opus 5.5 --- lidar_pipeline/mapserve.py | 42 ++++++++++++++++ lidar_pipeline/tests/test_mapserve.py | 70 +++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) diff --git a/lidar_pipeline/mapserve.py b/lidar_pipeline/mapserve.py index 5672d3e..70ef4c6 100644 --- a/lidar_pipeline/mapserve.py +++ b/lidar_pipeline/mapserve.py @@ -1764,6 +1764,48 @@ def tiles_inventory(stamp: Optional[float] = None): return data +# --- export PDF (planche d'impression terrain) --- +# Toujours calculé localement (jamais délégué à LIDAR_GENERATION_URL) : la +# carte légère seule doit savoir exporter. Un export à la fois : l'image de +# la planche occupe jusqu'à ~40 Mo (Pi, mem_limit 1g). +_export_lock = threading.Lock() + + +@app.get("/api/export/frame") +def export_frame(lat: float, lon: float, paper: str = "A4", orient: str = "paysage", + scale: int = 2000): + """Cadre imprimable (coins WGS84) centré sur (lat, lon) pour ces réglages.""" + from . import export_pdf + try: + return export_pdf.frame(lat, lon, paper, orient, scale) + except ValueError as e: + raise HTTPException(400, str(e)) + + +@app.get("/api/export/pdf") +def export_pdf_file(lat: float, lon: float, paper: str = "A4", orient: str = "paysage", + scale: int = 2000, title: Optional[str] = None): + """Planche PDF du relief orienté centrée sur (lat, lon).""" + from . import export_pdf + if not _export_lock.acquire(blocking=False): + raise HTTPException(429, "un export PDF est déjà en cours, réessayez dans quelques secondes") + try: + data, name = export_pdf.build_pdf(OUTPUT_DIR, lat, lon, paper, orient, scale, + title=title) + except ValueError as e: + raise HTTPException(400, str(e)) + except export_pdf.NoDataError as e: + raise HTTPException(404, str(e)) + except Exception: # noqa: BLE001 — message propre côté carte + logger.exception("Échec de l'export PDF") + raise HTTPException(500, "échec de l'export PDF (voir les journaux du serveur)") + finally: + _export_lock.release() + return Response(data, media_type="application/pdf", + headers={"Content-Disposition": f'attachment; filename="{name}"', + "Cache-Control": "no-store"}) + + @app.post("/api/preview", dependencies=[Depends(_require_token), Depends(_require_lan_for_generation)]) def preview(req: PreviewRequest): diff --git a/lidar_pipeline/tests/test_mapserve.py b/lidar_pipeline/tests/test_mapserve.py index 841a0e1..dddb22d 100644 --- a/lidar_pipeline/tests/test_mapserve.py +++ b/lidar_pipeline/tests/test_mapserve.py @@ -1251,3 +1251,73 @@ def test_background_scan_retries_cells_beyond_queue_max(tmp_path, monkeypatch): assert seen == expected # Pyramide passée en entier : les scans suivants se taisent assert mapserve.background_scan(tmp_path) == 0 + + +def _center(col=1054, row=6882): + from lidar_pipeline.tiles import _transformer + lon, lat = _transformer("EPSG:2154", "EPSG:4326").transform(col * 1000 + 500, (row - 1) * 1000 + 500) + return lat, lon + + +def test_export_frame_returns_corners(tmp_path, monkeypatch): + mapserve = _setup(tmp_path, monkeypatch, layers=("relief_oriente",)) + lat, lon = _center() + d = mapserve.export_frame(lat, lon, "A4", "paysage", 2000) + assert len(d["corners"]) == 4 and d["width_m"] > 0 + + +def test_export_pdf_ok(tmp_path, monkeypatch): + mapserve = _setup(tmp_path, monkeypatch, layers=("relief_oriente",)) + lat, lon = _center() + resp = mapserve.export_pdf_file(lat, lon, "A4", "paysage", 2000, None) + assert resp.media_type == "application/pdf" and resp.body.startswith(b"%PDF") + assert 'attachment; filename="relief_' in resp.headers["Content-Disposition"] + + +def test_export_pdf_errors(tmp_path, monkeypatch): + import pytest + from fastapi import HTTPException + mapserve = _setup(tmp_path, monkeypatch, layers=("relief_oriente",)) + lat, lon = _center() + with pytest.raises(HTTPException) as e: + mapserve.export_pdf_file(lat, lon, "A4", "paysage", 1234, None) + assert e.value.status_code == 400 + with pytest.raises(HTTPException) as e: + mapserve.export_pdf_file(46.5, 2.5, "A4", "paysage", 2000, None) + assert e.value.status_code == 404 + + +def test_export_pdf_rejects_non_finite(tmp_path, monkeypatch): + import pytest + from fastapi import HTTPException + mapserve = _setup(tmp_path, monkeypatch, layers=("relief_oriente",)) + for lat, lon in ((float("nan"), 2.0), (48.0, float("inf"))): + with pytest.raises(HTTPException) as e: + mapserve.export_pdf_file(lat, lon, "A4", "paysage", 2000, None) + assert e.value.status_code == 400 + with pytest.raises(HTTPException) as e: + mapserve.export_frame(lat, lon, "A4", "paysage", 2000) + assert e.value.status_code == 400 + + +def test_export_pdf_concurrent_429(tmp_path, monkeypatch): + import pytest + from fastapi import HTTPException + mapserve = _setup(tmp_path, monkeypatch, layers=("relief_oriente",)) + lat, lon = _center() + assert mapserve._export_lock.acquire(blocking=False) + try: + with pytest.raises(HTTPException) as e: + mapserve.export_pdf_file(lat, lon, "A4", "paysage", 2000, None) + assert e.value.status_code == 429 + finally: + mapserve._export_lock.release() + assert mapserve.export_pdf_file(lat, lon, "A4", "paysage", 2000, None).body.startswith(b"%PDF") + + +def test_export_pdf_never_delegated(tmp_path, monkeypatch): + """Carte légère avec générateur distant : l'export reste local.""" + mapserve = _setup(tmp_path, monkeypatch, layers=("relief_oriente",)) + monkeypatch.setattr(mapserve, "GENERATION_URL", "http://injoignable.invalid") + lat, lon = _center() + assert mapserve.export_pdf_file(lat, lon, "A4", "paysage", 2000, None).body.startswith(b"%PDF")