Files
lidar_rendu/lidar_pipeline/tests/test_export_pdf.py
Antoine fb892ea9f2 Translate the whole project to English and fix outdated comments and help
Comments, docstrings, logs, CLI help, map UI, legends, PDF sheet, scripts,
compose files and AGENTS.md are now English. Data keys stay unchanged
(relief_oriente, densite_sol, visualisations/, API JSON keys, link params).
Wrong comments and help defaults found along the way are corrected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:16:45 +02:00

339 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for the PDF export (printable field sheet)."""
import pytest
def test_layout_landscape_a4_dimensions():
from lidar_pipeline.export_pdf import layout
lay = layout("A4", "paysage", 2000)
assert (lay.page_w, lay.page_h) == (297.0, 210.0) and lay.dpi == 300
assert lay.map_w > 150 and lay.map_h > 150
assert lay.panel_x >= lay.map_x + lay.map_w # panel on the right
assert lay.map_x + lay.map_w <= lay.page_w and lay.map_y + lay.map_h <= lay.page_h
def test_layout_portrait_a3_panel_below():
from lidar_pipeline.export_pdf import layout
lay = layout("A3", "portrait", 5000)
assert (lay.page_w, lay.page_h) == (297.0, 420.0) and lay.dpi == 250
assert lay.panel_y + lay.panel_h <= lay.map_y # panel at the bottom
@pytest.mark.parametrize("args", [("A5", "paysage", 2000), ("A4", "biais", 2000),
("A4", "paysage", 1234)])
def test_layout_rejects_invalid(args):
from lidar_pipeline.export_pdf import layout
with pytest.raises(ValueError):
layout(*args)
def test_map_bbox_matches_paper_times_scale():
from lidar_pipeline.export_pdf import layout, map_bbox
lay = layout("A4", "paysage", 2000)
b = map_bbox(652500.0, 6861500.0, lay)
assert abs((b[2] - b[0]) - lay.map_w / 1000 * 2000) < 1e-6
assert abs((b[3] - b[1]) - lay.map_h / 1000 * 2000) < 1e-6
assert abs((b[0] + b[2]) / 2 - 652500.0) < 1e-6
def test_pixel_size_and_grid_step():
from lidar_pipeline.export_pdf import grid_step, layout, pixel_size
assert pixel_size(layout("A4", "paysage", 1000)) == 0.2 # capped at the native resolution
assert abs(pixel_size(layout("A3", "paysage", 10000)) - 10000 * 0.0254 / 250) < 1e-9
assert [grid_step(s) for s in (1000, 2000, 5000, 10000)] == [100, 100, 500, 1000]
def test_center_l93_rejects_non_finite():
from lidar_pipeline.export_pdf import center_l93
with pytest.raises(ValueError):
center_l93(float("nan"), 2.0)
def test_frame_roundtrip():
from lidar_pipeline.export_pdf import center_l93, frame
lat, lon = 48.85, 2.35
f = frame(lat, lon, "A4", "paysage", 2000)
cx, cy = center_l93(lat, lon)
assert abs(f["cx"] - cx) < 1e-6 and abs(f["cy"] - cy) < 1e-6
assert len(f["corners"]) == 4
nw, ne, se, sw = f["corners"]
assert nw[0] > sw[0] and ne[1] > nw[1] # [lat, lon]: north at the top, east on the right
assert f["width_m"] > f["height_m"] > 0
def test_export_modules_import_without_numpy():
import subprocess, sys
code = ("import sys; sys.modules['numpy'] = None; "
"import lidar_pipeline.export_pdf, lidar_pipeline.tiles; print('ok')")
r = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True)
assert r.returncode == 0, r.stderr
def _dalle(tmp_path, col, row, color=(200, 30, 30), px=100, layer="relief_oriente"):
from lidar_pipeline import index, tiles
from lidar_pipeline.tests.test_tiles import _make_dalle
_make_dalle(tmp_path, col, row, [layer], color=color, px=px)
tiles.source_index(tmp_path, force=True)
def test_nodata_color_matches_visualizations():
from lidar_pipeline.export_pdf import NODATA_RGB
from lidar_pipeline.visualizations import RELIEF_NODATA_RGB
assert NODATA_RGB == tuple(RELIEF_NODATA_RGB)
def test_compose_l93_full_cell(tmp_path, monkeypatch):
from lidar_pipeline import index
from lidar_pipeline.export_pdf import compose_l93
monkeypatch.setattr(index, "PANEL_VIZ", None)
_dalle(tmp_path, 1054, 6882)
img, mask, cells = compose_l93(tmp_path, (1054200.0, 6881200.0, 1054400.0, 6881300.0), 1.0)
assert img.size == (200, 100) and cells == [(1054, 6882)]
assert mask.getextrema() == (255, 255)
r, g, b = img.getpixel((100, 50))
assert r > 150 and g < 80 and b < 80
def test_compose_l93_half_outside_is_white_hatched(tmp_path, monkeypatch):
from lidar_pipeline import index
from lidar_pipeline.export_pdf import compose_l93
monkeypatch.setattr(index, "PANEL_VIZ", None)
_dalle(tmp_path, 1054, 6882)
# western half inside the tile, eastern half outside the data
img, mask, cells = compose_l93(tmp_path, (1054900.0, 6881400.0, 1055100.0, 6881500.0), 1.0)
assert mask.getpixel((50, 50)) == 255 and mask.getpixel((150, 50)) == 0
east = img.crop((110, 0, 200, 100)).convert("L").getextrema()
assert east[1] == 255 and east[0] < 255 # white + hatching
def test_compose_l93_nodata_pixels_masked(tmp_path, monkeypatch):
from lidar_pipeline import index
from lidar_pipeline.export_pdf import NODATA_RGB, compose_l93
monkeypatch.setattr(index, "PANEL_VIZ", None)
_dalle(tmp_path, 1054, 6882, color=NODATA_RGB)
img, mask, cells = compose_l93(tmp_path, (1054200.0, 6881200.0, 1054400.0, 6881300.0), 1.0)
assert mask.getextrema() == (0, 0)
def test_compose_l93_no_data_returns_empty_cells(tmp_path):
from lidar_pipeline.export_pdf import compose_l93
img, mask, cells = compose_l93(tmp_path, (0.0, 0.0, 100.0, 100.0), 1.0)
assert cells == [] and mask.getextrema() == (0, 0)
def test_lab_to_rgb_matches_numpy_reference():
import numpy as np
from lidar_pipeline.export_pdf import lab_to_rgb
from lidar_pipeline.visualizations import _lab_to_srgb
for L, a, b in [(64, 30, -20), (20, 0, 0), (90, -10, 40), (50, 60, 0)]:
ref = tuple(int(round(v * 255)) for v in _lab_to_srgb(
np.array(L, float), np.array(a, float), np.array(b, float)))
assert all(abs(p - q) <= 1 for p, q in zip(lab_to_rgb(L, a, b), ref))
def test_rose_color_distinct_orientations():
from lidar_pipeline.export_pdf import rose_color
colors = {rose_color(c) for c in (0, 90, 180, 270)}
assert len(colors) == 4
def test_density_color_classes():
from lidar_pipeline.export_pdf import DENSITY_CLASSES, density_color
assert density_color(0.0) == DENSITY_CLASSES[0][2]
assert density_color(50.0) == DENSITY_CLASSES[-1][2]
def test_pdf_text_replaces_unencodable():
from lidar_pipeline.export_pdf import _pdf_text
# é and — exist in cp1252; ≥ and the emoji do not
assert _pdf_text("Café — 1:2,000 ≥ 🚀") == "Café — 1:2,000 ? ?"
def test_pdf_legend_uses_reading_text(monkeypatch):
"""The PDF sheet legend shows the "How to read" text of VIZ_LEGENDS,
wrapped to the box width, rather than "legend"."""
from lidar_pipeline import export_pdf
from lidar_pipeline.index import VIZ_LEGENDS
drawn = []
class FakeCanvas:
def __getattr__(self, name):
def rec(*a, **k):
if name in ("drawString", "drawCentredString", "drawRightString"):
drawn.append(a[-1])
return rec
export_pdf._draw_legend(FakeCanvas(), (0, 0, 80, 200), 1.0)
first = export_pdf._pdf_text(VIZ_LEGENDS[export_pdf.LAYER]["reading"][0][:20])
assert any(first in s for s in drawn)
def _q(density, start="2023-03-15", end="2023-03-17", empty=0.1):
return {"version": 1, "ground_density": density,
"density_grid": [[density] * 20 for _ in range(20)],
"empty_fraction": empty, "acq_start": start, "acq_end": end,
"acq_source": "gps"}
def test_zone_quality_aggregates_two_cells():
from lidar_pipeline.export_pdf import zone_quality
table = {"LHD_FXX_1054_6882_PTS_LAMB93_IGN69": _q(4.0, "2023-03-15", "2023-03-15"),
"LHD_FXX_1055_6882_PTS_LAMB93_IGN69": _q(8.0, "2023-04-02", "2023-04-03", 0.3)}
bbox = (1054500.0, 6881500.0, 1055500.0, 6881600.0) # half of each tile
z = zone_quality(bbox, table, [(1054, 6882), (1055, 6882)])
assert abs(z["density_mean"] - 6.0) < 1e-6 and z["density_min"] == 4.0
assert abs(z["empty_fraction"] - 0.2) < 1e-6
assert (z["acq_start"], z["acq_end"]) == ("2023-03-15", "2023-04-03")
assert z["missing_relief"] == [] and z["missing_quality"] == []
assert len(z["grid_cells"]) == 2 * 10 * 2 # 10 cells in x × 2 in y per tile
def test_zone_quality_missing_cells():
from lidar_pipeline.export_pdf import zone_quality
bbox = (1054500.0, 6881500.0, 1055500.0, 6881600.0)
z = zone_quality(bbox, {}, [(1054, 6882)])
assert z["density_mean"] is None and z["acq_start"] is None
assert z["missing_quality"] == [(1054, 6882), (1055, 6882)]
assert z["missing_relief"] == [(1055, 6882)]
def _mediabox(pdf):
import re
m = re.search(rb"/MediaBox \[\s*0 0 ([\d.]+) ([\d.]+)\s*\]", pdf)
return float(m.group(1)), float(m.group(2))
def _cell_center_wgs84(col, row):
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_build_pdf_a4_landscape(tmp_path, monkeypatch):
from lidar_pipeline import index
from lidar_pipeline.export_pdf import build_pdf
from lidar_pipeline.quality import write_quality
monkeypatch.setattr(index, "PANEL_VIZ", None)
_dalle(tmp_path, 1054, 6882, px=200)
write_quality(tmp_path, "LHD_FXX_1054_6882_PTS_LAMB93_IGN69", _q(6.5))
lat, lon = _cell_center_wgs84(1054, 6882)
pdf, name = build_pdf(tmp_path, lat, lon, "A4", "paysage", 2000,
title="Survey 🚀 woods", compress=False)
assert pdf.startswith(b"%PDF")
w, h = _mediabox(pdf)
assert abs(w - 841.89) < 0.5 and abs(h - 595.28) < 0.5
assert name.startswith("relief_1054.") and name.endswith("_1-2000.pdf")
for s in (b"Survey ? woods", b"1:2,000", b"ground density", b"2023-03-15", b"LiDAR HD",
b"6.5 pts", b"landscape"):
assert s in pdf, s
def test_build_pdf_a3_portrait_size(tmp_path, monkeypatch):
from lidar_pipeline import index
from lidar_pipeline.export_pdf import build_pdf
monkeypatch.setattr(index, "PANEL_VIZ", None)
_dalle(tmp_path, 1054, 6882)
lat, lon = _cell_center_wgs84(1054, 6882)
pdf, _name = build_pdf(tmp_path, lat, lon, "A3", "portrait", 5000)
w, h = _mediabox(pdf)
assert abs(w - 841.89) < 0.5 and abs(h - 1190.55) < 0.5
def test_build_pdf_partial_zone_hatched(tmp_path, monkeypatch):
"""Area straddling the data edge: sheet produced, missing tile listed."""
from lidar_pipeline import index
from lidar_pipeline.export_pdf import build_pdf
from lidar_pipeline.tiles import _transformer
monkeypatch.setattr(index, "PANEL_VIZ", None)
_dalle(tmp_path, 1054, 6882)
lon, lat = _transformer("EPSG:2154", "EPSG:4326").transform(1055000.0, 6881500.0)
pdf, _ = build_pdf(tmp_path, lat, lon, "A4", "paysage", 2000, compress=False)
assert pdf.startswith(b"%PDF") and b"1055_6882" in pdf
assert b"Missing data" in pdf and b"no relief" in pdf # "(" is escaped in PDF strings
def test_build_pdf_no_data_raises(tmp_path):
import pytest
from lidar_pipeline.export_pdf import NoDataError, build_pdf
with pytest.raises(NoDataError):
build_pdf(tmp_path, 46.5, 2.5, "A4", "paysage", 2000)
def test_fmt_int():
from lidar_pipeline.export_pdf import _fmt_int
assert _fmt_int(2000) == "2,000" and _fmt_int(500) == "500"
def test_north_arrow_angle_matches_pyproj_reference():
"""The reportlab rotation angle must bring the arrow (drawn towards grid
north) onto true north, in the right direction, east and west of the
central meridian (3°E)."""
import math
from lidar_pipeline.export_pdf import _north_arrow_angle, _to_wgs84
from lidar_pipeline.tiles import _transformer, wgs84_to_l93
to_l93 = _transformer("EPSG:4326", "EPSG:2154")
def _reference_angle(cx, cy):
lat0, lon0 = _to_wgs84(cx, cy)
x0, y0 = to_l93.transform(lon0, lat0)
x1, y1 = to_l93.transform(lon0, lat0 + 0.001)
# azimuth of true north (clockwise from grid north)
psi = math.degrees(math.atan2(x1 - x0, y1 - y0))
return -psi # reportlab rotation (counter-clockwise) bringing "up" onto that north
east = wgs84_to_l93(6.0, 46.0) # east of the central meridian
west = wgs84_to_l93(0.0, 46.0) # west of the central meridian
for cx, cy in (east, west):
assert abs(_north_arrow_angle(cx, cy) - _reference_angle(cx, cy)) < 0.05
assert _north_arrow_angle(*east) > 0
assert _north_arrow_angle(*west) < 0
def test_wrap_text_lines_fit_width():
from reportlab.pdfgen import canvas as rl_canvas
from reportlab.lib.units import mm
from io import BytesIO
from lidar_pipeline.export_pdf import _wrap_text
c = rl_canvas.Canvas(BytesIO())
text = "Missing data (no relief): " + ", ".join(
f"{1050 + i}_6882" for i in range(12))
max_width = 60 * mm
lines = _wrap_text(c, text, "Helvetica", 5.8, max_width, sep=", ")
assert len(lines) > 1
for line in lines:
assert c.stringWidth(line, "Helvetica", 5.8) <= max_width + 1e-6
def test_fit_title_shrinks_then_elides():
from reportlab.pdfgen import canvas as rl_canvas
from reportlab.lib.units import mm
from io import BytesIO
from lidar_pipeline.export_pdf import _fit_title
c = rl_canvas.Canvas(BytesIO())
long_title = "Oriented relief - " + ", ".join(f"{1050 + i:04d}_6882" for i in range(6))
text, size = _fit_title(c, long_title, "Helvetica-Bold", 10, 60 * mm, min_size=7.0)
assert size >= 7.0
assert c.stringWidth(text, "Helvetica-Bold", size) <= 60 * mm + 1e-6
def test_cartouche_title_avoids_north_arrow_column():
"""The title block's title (and subtitle) must never encroach on the
column reserved for the north arrow, in landscape as in portrait."""
from reportlab.pdfgen import canvas as rl_canvas
from reportlab.lib.units import mm
from io import BytesIO
from lidar_pipeline.export_pdf import (
_cartouche_text_max_width, _fit_title, _panel_boxes, layout)
c = rl_canvas.Canvas(BytesIO())
long_title = "Oriented relief - " + ", ".join(f"{1050 + i:04d}_6882" for i in range(6))
for paper, orient, scale in (("A4", "paysage", 2000), ("A4", "portrait", 10000),
("A3", "portrait", 2000)):
lay = layout(paper, orient, scale)
_legend_box, _quality_box, cart_box = _panel_boxes(lay)
x, y, w, h = cart_box
max_w = _cartouche_text_max_width(w, mm)
txt, size = _fit_title(c, long_title, "Helvetica-Bold", 10, max_w)
title_right_mm = x + c.stringWidth(txt, "Helvetica-Bold", size) / mm
arrow_left_mm = (x + w - 8) - 1.8 # left edge of the arrow triangle
assert title_right_mm <= arrow_left_mm, (paper, orient, scale)