Files
lidar_rendu/lidar_pipeline/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

696 lines
28 KiB
Python
Raw Permalink 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.

"""PDF export of an area: printable field sheet of the oriented relief.
Composed directly in Lambert 93 from the pyramid sources (tiles.py) — exact
scale, grid aligned on the tiles — then drawn with reportlab (vector text,
grid and legend). Runs in the lightweight image: Pillow + pyproj +
reportlab, without numpy.
"""
import logging
import math
from dataclasses import dataclass
logger = logging.getLogger("lidar")
LAYER = "relief_oriente"
PAPERS_MM = {"A4": (210.0, 297.0), "A3": (297.0, 420.0)}
ORIENTS = ("portrait", "paysage") # API values ("paysage" = landscape)
_ORIENT_LABELS = {"portrait": "portrait", "paysage": "landscape"}
SCALES = (1000, 2000, 5000, 10000)
DPI = {"A4": 300, "A3": 250} # bounds the Pi's memory (~36 MB in A3)
NATIVE_RES_M = 0.2
MARGIN_MM = 10.0 # non-printable edge
ANNOT_MM = 7.0 # coordinate band around the map
PANEL_SIDE_MM = 64.0 # side panel on the right (landscape)
PANEL_BOTTOM_MM = 72.0 # bottom panel (portrait)
_GRID_STEPS = {1000: 100, 2000: 100, 5000: 500, 10000: 1000}
@dataclass(frozen=True)
class Layout:
"""Sheet geometry, in mm, origin at the bottom left (reportlab)."""
paper: str
orient: str
scale: int
dpi: int
page_w: float
page_h: float
map_x: float
map_y: float
map_w: float
map_h: float
panel_x: float
panel_y: float
panel_w: float
panel_h: float
def layout(paper, orient, scale):
"""Geometry of a sheet; ValueError if a setting is invalid."""
if paper not in PAPERS_MM:
raise ValueError(f"unknown paper size: {paper} (A4 or A3)")
if orient not in ORIENTS:
raise ValueError(f"unknown orientation: {orient} (portrait or paysage)")
try:
scale = int(scale)
except (TypeError, ValueError):
raise ValueError(f"invalid scale: {scale}") from None
if scale not in SCALES:
raise ValueError("scale not offered: 1:" + str(scale)
+ " (1:1000, 1:2000, 1:5000 or 1:10000)")
w, h = PAPERS_MM[paper]
if orient == "paysage":
w, h = h, w
inner = MARGIN_MM + ANNOT_MM
if orient == "paysage":
map_w = w - 2 * inner - PANEL_SIDE_MM
map_h = h - 2 * inner
return Layout(paper, orient, scale, DPI[paper], w, h, inner, inner, map_w, map_h,
w - MARGIN_MM - PANEL_SIDE_MM, MARGIN_MM, PANEL_SIDE_MM, h - 2 * MARGIN_MM)
map_w = w - 2 * inner
map_h = h - 2 * inner - PANEL_BOTTOM_MM
return Layout(paper, orient, scale, DPI[paper], w, h, inner, MARGIN_MM + PANEL_BOTTOM_MM + ANNOT_MM,
map_w, map_h, MARGIN_MM, MARGIN_MM, w - 2 * MARGIN_MM, PANEL_BOTTOM_MM)
def center_l93(lat, lon):
"""WGS84 center → Lambert 93; ValueError if not finite."""
from .tiles import wgs84_to_l93
lat, lon = float(lat), float(lon)
if not (math.isfinite(lat) and math.isfinite(lon)):
raise ValueError("invalid center coordinates")
cx, cy = wgs84_to_l93(lon, lat)
if not (math.isfinite(cx) and math.isfinite(cy)):
raise ValueError("center outside the Lambert 93 domain")
return cx, cy
def map_bbox(cx, cy, lay):
"""L93 ground footprint of the map area (paper × scale)."""
half_w = lay.map_w / 1000.0 * lay.scale / 2
half_h = lay.map_h / 1000.0 * lay.scale / 2
return (cx - half_w, cy - half_h, cx + half_w, cy + half_h)
def pixel_size(lay):
"""Ground size of a printed pixel (m), never finer than the native resolution."""
return max(NATIVE_RES_M, lay.scale * 0.0254 / lay.dpi)
def grid_step(scale):
"""L93 grid spacing (m) for the scale."""
return _GRID_STEPS[int(scale)]
def _to_wgs84(x, y):
from .tiles import _transformer
lon, lat = _transformer("EPSG:2154", "EPSG:4326").transform(x, y)
return lat, lon
def frame(lat, lon, paper, orient, scale):
"""Printable frame for the map: L93 footprint and WGS84 corners (NW, NE, SE, SW)."""
lay = layout(paper, orient, scale)
cx, cy = center_l93(lat, lon)
b = map_bbox(cx, cy, lay)
corners = [_to_wgs84(b[0], b[3]), _to_wgs84(b[2], b[3]),
_to_wgs84(b[2], b[1]), _to_wgs84(b[0], b[1])]
return {"cx": cx, "cy": cy, "bbox_l93": list(b),
"corners": [[round(a, 7), round(o, 7)] for a, o in corners],
"width_m": round(b[2] - b[0]), "height_m": round(b[3] - b[1])}
# Color of the oriented relief's no-data pixels (copy of
# visualizations.RELIEF_NODATA_RGB: that module imports numpy, absent from
# the lightweight image; equality checked by the tests).
NODATA_RGB = (38, 38, 41)
_NODATA_TOLERANCE = 3 # tolerated per-channel difference (resampling)
_HATCH_STEP_PX = 14
def _paste_l93(canvas, mask, src, bbox, px):
"""Crop and resample an L93 source into the sheet image."""
from PIL import Image, ImageChops
from . import tiles
img = tiles.load_source(src)
if img is None:
return False
w, h = img.size
sx0, sy0, sx1, sy1 = src.bounds
ix0, ix1 = max(bbox[0], sx0), min(bbox[2], sx1)
iy0, iy1 = max(bbox[1], sy0), min(bbox[3], sy1)
if ix1 <= ix0 or iy1 <= iy0:
return False
dx0 = int(round((ix0 - bbox[0]) / px)); dx1 = int(round((ix1 - bbox[0]) / px))
dy0 = int(round((bbox[3] - iy1) / px)); dy1 = int(round((bbox[3] - iy0) / px))
if dx1 <= dx0 or dy1 <= dy0:
return False
rx, ry = (sx1 - sx0) / w, (sy1 - sy0) / h
gx0, gx1 = bbox[0] + dx0 * px, bbox[0] + dx1 * px
gy1, gy0 = bbox[3] - dy0 * px, bbox[3] - dy1 * px
box = (max(0.0, (gx0 - sx0) / rx), max(0.0, (sy1 - gy1) / ry),
min(float(w), (gx1 - sx0) / rx), min(float(h), (sy1 - gy0) / ry))
part = img.resize((dx1 - dx0, dy1 - dy0), Image.LANCZOS, box=box)
rgb = part.convert("RGB")
diff = ImageChops.difference(rgb, Image.new("RGB", rgb.size, NODATA_RGB))
r, g, b = diff.split()
valid = ImageChops.lighter(ImageChops.lighter(r, g), b).point(
lambda v: 255 if v > _NODATA_TOLERANCE else 0)
if part.mode == "RGBA":
valid = ImageChops.multiply(valid, part.getchannel("A").point(
lambda v: 255 if v >= 128 else 0))
canvas.paste(rgb, (dx0, dy0), valid)
mask.paste(255, (dx0, dy0, dx1, dy1), valid)
return True
def _hatch(size):
"""White pattern with gray hatching (areas without data)."""
from PIL import Image, ImageDraw
w, h = size
pat = Image.new("RGB", size, (255, 255, 255))
draw = ImageDraw.Draw(pat)
for k in range(-h, w, _HATCH_STEP_PX):
draw.line([(k, h), (k + h, 0)], fill=(200, 200, 200), width=2)
return pat
def compose_l93(output_dir, bbox, px_size, layer=LAYER):
"""RGB image of the L93 footprint at px_size m/px, mask of the painted
pixels and contributing tiles. Outside the data: hatched white."""
from PIL import Image, ImageOps
from . import tiles
width = max(1, int(round((bbox[2] - bbox[0]) / px_size)))
height = max(1, int(round((bbox[3] - bbox[1]) / px_size)))
canvas = Image.new("RGB", (width, height), (255, 255, 255))
mask = Image.new("L", (width, height), 0)
cells = set()
for cell, src in tiles.sources_in_bbox(output_dir, layer, bbox, px_size):
if _paste_l93(canvas, mask, src, bbox, px_size):
cells.add(cell)
if mask.getextrema() != (255, 255):
canvas.paste(_hatch(canvas.size), (0, 0), ImageOps.invert(mask))
return canvas, mask, sorted(cells)
ROSE_L = 64.0
ROSE_CHROMA = 60.0 # = visualizations.RELIEF_CHROMA
# Ground point density classes (pts/m²): red = weak data.
DENSITY_CLASSES = [
(0.0, "under 1", "#d7301f"),
(1.0, "1 to 3", "#fc8d59"),
(3.0, "3 to 6", "#fdcc8a"),
(6.0, "6 to 10", "#a1d99b"),
(10.0, "10 and over", "#31a354"),
]
def lab_to_rgb(L, a, b):
"""CIELAB (D65) → 8-bit sRGB, same formula as the map (labToRgb)."""
fy = (L + 16) / 116
fx, fz = fy + a / 500, fy - b / 200
def finv(t):
return t ** 3 if t > 6 / 29 else 3 * (6 / 29) ** 2 * (t - 4 / 29)
X, Y, Z = 0.95047 * finv(fx), finv(fy), 1.08883 * finv(fz)
lin = (3.2406 * X - 1.5372 * Y - 0.4986 * Z,
-0.9689 * X + 1.8758 * Y + 0.0415 * Z,
0.0557 * X - 0.2040 * Y + 1.0570 * Z)
out = []
for c in lin:
v = 12.92 * c if c <= 0.0031308 else 1.055 * max(c, 0.0) ** (1 / 2.4) - 0.055
out.append(int(round(min(1.0, max(0.0, v)) * 255)))
return tuple(out)
def rose_color(compass_deg):
"""Color of a slope orientation (0 = N, clockwise), like the map's rose."""
chroma = ROSE_CHROMA * min(1.0, ROSE_L * (100 - ROSE_L) / 2500)
h = math.radians((compass_deg + 90) % 360)
return lab_to_rgb(ROSE_L, chroma * math.cos(h), chroma * math.sin(h))
def density_color(v):
"""Class color of a ground density (pts/m²)."""
color = DENSITY_CLASSES[0][2]
for low, _label, c in DENSITY_CLASSES:
if v >= low:
color = c
return color
def _pdf_text(s):
"""Text encodable by the standard fonts (WinAnsi/cp1252)."""
return "".join(ch if ch.encode("cp1252", "ignore") else "?" for ch in str(s))
def _cells_of_bbox(bbox):
"""1 km LHD tiles intersecting an L93 footprint."""
c0, c1 = int(math.floor(bbox[0] / 1000)), int(math.ceil(bbox[2] / 1000))
r0, r1 = int(math.floor(bbox[1] / 1000)) + 1, int(math.ceil(bbox[3] / 1000))
return [(c, r) for c in range(c0, c1) for r in range(r1, r0 - 1, -1)]
def zone_quality(bbox, table, cells_with_relief):
"""Aggregate the tile quality over a footprint (area-weighted)."""
from .index import parse_basename_coords
from .quality import DENSITY_CELL_M
by_cell = {}
for base, data in table.items():
coords = parse_basename_coords(base)
if coords is not None:
by_cell[tuple(coords)] = data
cells = _cells_of_bbox(bbox)
relief = set(map(tuple, cells_with_relief))
total_w = dens_w = empty_w = 0.0
dmin = None
starts, ends, sources = [], [], set()
grid_cells, missing_q = [], []
for col, row in cells:
q = by_cell.get((col, row))
if q is None:
missing_q.append((col, row))
continue
x0, y1 = col * 1000.0, row * 1000.0
grid = q.get("density_grid") or []
for j, line in enumerate(grid):
for i, v in enumerate(line):
gx0, gx1 = x0 + i * DENSITY_CELL_M, x0 + (i + 1) * DENSITY_CELL_M
gy1, gy0 = y1 - j * DENSITY_CELL_M, y1 - (j + 1) * DENSITY_CELL_M
ox = min(gx1, bbox[2]) - max(gx0, bbox[0])
oy = min(gy1, bbox[3]) - max(gy0, bbox[1])
if ox <= 0 or oy <= 0:
continue
area = ox * oy
total_w += area
dens_w += v * area
empty_w += float(q.get("empty_fraction") or 0.0) * area
dmin = v if dmin is None else min(dmin, v)
grid_cells.append((gx0, gy0, gx1, gy1, v))
if q.get("acq_start"):
starts.append(q["acq_start"]); ends.append(q["acq_end"] or q["acq_start"])
sources.add(q.get("acq_source"))
return {
"density_mean": dens_w / total_w if total_w else None,
"density_min": dmin,
"empty_fraction": empty_w / total_w if total_w else None,
"acq_start": min(starts) if starts else None,
"acq_end": max(ends) if ends else None,
"acq_sources": sources,
"cells": cells,
"missing_relief": [c for c in cells if c not in relief],
"missing_quality": missing_q,
"grid_cells": grid_cells,
}
class NoDataError(Exception):
"""No oriented-relief tile in the requested footprint."""
_JPEG_QUALITY = 90
_SCALEBAR_STEPS = (10, 20, 25, 50, 100, 200, 250, 500, 1000, 2000)
def _fmt_int(n):
return f"{int(n):,}"
def _hex(rgb):
return "#%02x%02x%02x" % tuple(rgb)
def _convergence_deg(cx, cy):
"""Meridian convergence: azimuth (°) of L93 grid north measured from true
north (clockwise), positive east of the central meridian (3°E)."""
lat0, lon0 = _to_wgs84(cx, cy)
lat1, lon1 = _to_wgs84(cx, cy + 100.0)
return math.degrees(math.atan2((lon1 - lon0) * math.cos(math.radians(lat0)), lat1 - lat0))
def _north_arrow_angle(cx, cy):
"""reportlab rotation angle (counter-clockwise, `Canvas.rotate`) so that
the north arrow — drawn pointing up, i.e. towards grid north — points to
true north."""
return _convergence_deg(cx, cy)
def _wrap_text(c, text, font, size, max_width, sep=" "):
"""Split `text` on `sep` into lines that each fit in `max_width` (pt)."""
parts = text.split(sep)
lines, cur = [], parts[0]
for part in parts[1:]:
candidate = cur + sep + part
if c.stringWidth(candidate, font, size) <= max_width:
cur = candidate
else:
lines.append(cur)
cur = part
lines.append(cur)
return lines
def _fit_title(c, text, font, size, max_width, min_size=7.0):
"""Shrink the font size down to `min_size`, then truncate with an
ellipsis so that `text` fits in `max_width` (pt)."""
while size > min_size and c.stringWidth(text, font, size) > max_width:
size -= 0.5
if c.stringWidth(text, font, size) > max_width:
while text and c.stringWidth(text + "...", font, size) > max_width:
text = text[:-1]
text = text.rstrip() + "..."
return text, size
_ARROW_COLUMN_MM = 16.0 # width (mm) reserved for the north arrow + its "N", top-right corner
def _cartouche_text_max_width(w, mm):
"""Width (pt) available for the title block's title/subtitle, excluding
the column reserved for the north arrow (top-right corner of the block,
same line)."""
return max(0.0, w - _ARROW_COLUMN_MM) * mm
def _draw_hatch(c, x, y, w, h, mm, step=3.0):
"""Gray hatching on a white background (cell without data), as on the map."""
from reportlab.lib.colors import Color, white
if w <= 0 or h <= 0:
return
c.saveState()
p = c.beginPath(); p.rect(x * mm, y * mm, w * mm, h * mm)
c.clipPath(p, stroke=0, fill=0)
c.setFillColor(white)
c.rect(x * mm, y * mm, w * mm, h * mm, stroke=0, fill=1)
c.setStrokeColor(Color(0.75, 0.75, 0.75)); c.setLineWidth(0.3)
n = int((w + h) / step) + 2
for k in range(-n, n):
x0 = x + k * step
c.line(x0 * mm, y * mm, (x0 + h) * mm, (y + h) * mm)
c.restoreState()
def _panel_boxes(lay):
"""Panel rectangles (x, y, w, h) in mm: legend, quality, title block."""
gap = 4.0
if lay.orient == "paysage":
h = (lay.panel_h - 2 * gap) / 3
x, w = lay.panel_x, lay.panel_w
top = lay.panel_y + lay.panel_h
return [(x, top - h, w, h), (x, top - 2 * h - gap, w, h), (x, lay.panel_y, w, h)]
w = (lay.panel_w - 2 * gap) / 3
y, h = lay.panel_y, lay.panel_h
return [(lay.panel_x, y, w, h), (lay.panel_x + w + gap, y, w, h),
(lay.panel_x + 2 * (w + gap), y, w, h)]
def _draw_grid(c, lay, bbox, mm):
"""L93 grid + values in the margin + WGS84 corners."""
from reportlab.lib.colors import black
step = grid_step(lay.scale)
sx = lay.map_w / (bbox[2] - bbox[0])
sy = lay.map_h / (bbox[3] - bbox[1])
c.saveState()
c.setStrokeColor(black); c.setStrokeAlpha(0.55); c.setLineWidth(0.3)
c.setFont("Helvetica", 5.5); c.setFillColor(black)
x = math.ceil(bbox[0] / step) * step
while x <= bbox[2]:
px = (lay.map_x + (x - bbox[0]) * sx) * mm
c.line(px, lay.map_y * mm, px, (lay.map_y + lay.map_h) * mm)
label = f"{x / 1000:.3f}"
c.drawCentredString(px, (lay.map_y - 3.2) * mm, label)
c.drawCentredString(px, (lay.map_y + lay.map_h + 1.4) * mm, label)
x += step
y = math.ceil(bbox[1] / step) * step
while y <= bbox[3]:
py = (lay.map_y + (y - bbox[1]) * sy) * mm
c.line(lay.map_x * mm, py, (lay.map_x + lay.map_w) * mm, py)
label = f"{y / 1000:.3f}"
c.saveState(); c.translate((lay.map_x - 1.4) * mm, py); c.rotate(90)
c.drawCentredString(0, 0, label); c.restoreState()
c.saveState(); c.translate((lay.map_x + lay.map_w + 3.2) * mm, py); c.rotate(90)
c.drawCentredString(0, 0, label); c.restoreState()
y += step
c.restoreState()
c.setFont("Helvetica", 5.5)
for (gx, gy), (px, py, align) in (
((bbox[0], bbox[3]), (lay.map_x, lay.map_y + lay.map_h + 4.2, "l")),
((bbox[2], bbox[3]), (lay.map_x + lay.map_w, lay.map_y + lay.map_h + 4.2, "r")),
((bbox[0], bbox[1]), (lay.map_x, lay.map_y - 6.2, "l")),
((bbox[2], bbox[1]), (lay.map_x + lay.map_w, lay.map_y - 6.2, "r"))):
lat, lon = _to_wgs84(gx, gy)
hemi = "E" if lon >= 0 else "W"
txt = f"{lat:.5f} N {abs(lon):.5f} {hemi}"
(c.drawString if align == "l" else c.drawRightString)(px * mm, py * mm, txt)
c.setFont("Helvetica", 5.5)
c.drawString(lay.map_x * mm, (lay.map_y + lay.map_h + 6.2) * mm,
_pdf_text(f"Lambert 93 grid (km), {_fmt_int(step)} m spacing - corners in WGS84"))
def _draw_legend(c, box, mm):
"""Lightness bar + orientation rose + text from VIZ_LEGENDS."""
from reportlab.lib.colors import HexColor, black, white
from .index import VIZ_LEGENDS
x, y, w, h = box
c.setFillColor(black); c.setFont("Helvetica-Bold", 8)
c.drawString(x * mm, (y + h - 4) * mm, _pdf_text("Legend - oriented relief"))
# Lightness bar (L* 20 → 90, neutral gray)
bx, by, bw, bh = x, y + h - 13, min(w, 55.0), 4.0
n = 40
for k in range(n):
L = 20 + 70 * k / (n - 1)
c.setFillColor(HexColor(_hex(lab_to_rgb(L, 0, 0))))
c.rect((bx + bw * k / n) * mm, by * mm, (bw / n + 0.05) * mm, bh * mm, stroke=0, fill=1)
c.setFillColor(black); c.setFont("Helvetica", 6)
c.drawString(bx * mm, (by - 2.8) * mm, _pdf_text("hollow, ditch"))
c.drawRightString((bx + bw) * mm, (by - 2.8) * mm, _pdf_text("bump, ridge"))
c.drawString(bx * mm, (by + bh + 0.8) * mm, _pdf_text("Lightness = micro-relief"))
# Orientation rose (color = slope orientation)
r_out, r_in = 8.5, 3.9 # leaves room for the reading sentences
rcx, rcy = x + r_out + 2, by - 5 - r_out - 2
for deg in range(0, 360, 5):
c.setFillColor(HexColor(_hex(rose_color(deg))))
start = 90 - deg - 2.5
c.wedge((rcx - r_out) * mm, (rcy - r_out) * mm, (rcx + r_out) * mm, (rcy + r_out) * mm,
start, 5.2, stroke=0, fill=1)
c.setFillColor(white)
c.circle(rcx * mm, rcy * mm, r_in * mm, stroke=0, fill=1)
c.setFillColor(black); c.setFont("Helvetica-Bold", 5.0)
for label, deg in (("N", 0), ("NE", 45), ("E", 90), ("SE", 135), ("S", 180),
("SW", 225), ("W", 270), ("NW", 315)):
a = math.radians(deg)
c.drawCentredString((rcx + (r_out + 2.4) * math.sin(a)) * mm,
(rcy + (r_out + 2.4) * math.cos(a) - 0.8) * mm, label)
c.setFont("Helvetica", 6)
c.drawString((rcx + r_out + 5) * mm, (rcy + 2) * mm, _pdf_text("Hue = orientation"))
c.drawString((rcx + r_out + 5) * mm, (rcy - 1) * mm, _pdf_text("of the slope"))
# Legend text ("How to read": sentences wrapped to the box width)
from reportlab.pdfbase.pdfmetrics import stringWidth
ty = rcy - r_out - 6
c.setFont("Helvetica", 5.2) # the reading sentences fit in the box, A4 included
for sentence in VIZ_LEGENDS[LAYER].get("reading") or VIZ_LEGENDS[LAYER]["legend"].split("\n"):
# On the sheet, pixels without a ground point are hatched white
# (not black as on the map): same sentence, adapted cue.
if sentence.startswith("Black gaps"):
sentence = "White hatched areas" + sentence[len("Black gaps"):]
# The hue is already explained next to the rose: redundant sentence.
if sentence.startswith("Hue = slope orientation"):
continue
line = ""
for word in _pdf_text(sentence).split(" "):
test = (line + " " + word).strip()
if stringWidth(test, "Helvetica", 5.2) * 0.3528 > w and line: # pt → mm
if ty < y + 1:
return
c.drawString(x * mm, ty * mm, line)
ty -= 2.5
line = word
else:
line = test
if line:
if ty < y + 1:
return
c.drawString(x * mm, ty * mm, line)
ty -= 3.1 # slightly larger spacing between two sentences
def _draw_quality(c, box, bbox, zq, mm):
"""Quality inset: ground density thumbnail + key figures."""
from reportlab.lib.colors import HexColor, black, white
x, y, w, h = box
c.setFillColor(black); c.setFont("Helvetica-Bold", 8)
c.drawString(x * mm, (y + h - 4) * mm, _pdf_text("Data quality"))
# Thumbnail: footprint of the area, 50 m cells colored by class
avail_w, avail_h = w * 0.45, h - 10
k = min(avail_w / (bbox[2] - bbox[0]), avail_h / (bbox[3] - bbox[1]))
mw, mh = (bbox[2] - bbox[0]) * k, (bbox[3] - bbox[1]) * k
mx, my = x, y + h - 7 - mh
_draw_hatch(c, mx, my, mw, mh, mm) # hatching = not available
for gx0, gy0, gx1, gy1, v in zq["grid_cells"]:
x0, x1 = max(gx0, bbox[0]), min(gx1, bbox[2])
y0, y1 = max(gy0, bbox[1]), min(gy1, bbox[3])
c.setFillColor(HexColor(density_color(v)))
c.rect((mx + (x0 - bbox[0]) * k) * mm, (my + (y0 - bbox[1]) * k) * mm,
((x1 - x0) * k + 0.02) * mm, ((y1 - y0) * k + 0.02) * mm, stroke=0, fill=1)
c.setStrokeColor(black); c.setLineWidth(0.4)
c.rect(mx * mm, my * mm, mw * mm, mh * mm, stroke=1, fill=0)
# Classes
lx, ly = x + mw + 3, y + h - 8
c.setFillColor(black); c.setFont("Helvetica", 5.8)
c.drawString(lx * mm, ly * mm, _pdf_text("Ground points / m²"))
for low, label, color in DENSITY_CLASSES:
ly -= 3.2
c.setFillColor(HexColor(color)); c.rect(lx * mm, ly * mm, 3 * mm, 2.2 * mm, stroke=0, fill=1)
c.setFillColor(black); c.drawString((lx + 4) * mm, (ly + 0.4) * mm, _pdf_text(label))
ly -= 3.2
c.setFillColor(white); c.rect(lx * mm, ly * mm, 3 * mm, 2.2 * mm, stroke=0, fill=1)
_draw_hatch(c, lx, ly, 3.0, 2.2, mm, step=1.4)
c.setStrokeColor(black); c.setLineWidth(0.3)
c.rect(lx * mm, ly * mm, 3 * mm, 2.2 * mm, stroke=1, fill=0)
c.setFillColor(black); c.drawString((lx + 4) * mm, (ly + 0.4) * mm, _pdf_text("not available"))
# Key figures
lines = []
if zq["density_mean"] is None:
lines.append("Ground density: not available")
else:
lines.append(f"Mean ground density: {zq['density_mean']:.1f} pts/m²")
lines.append(f"Weakest cell (50 m): {zq['density_min']:.1f} pts/m²")
lines.append(f"Area without ground points (interpolated): {zq['empty_fraction'] * 100:.0f}%")
if zq["acq_start"] is None:
lines.append("Acquisition: not available")
else:
period = zq["acq_start"] if zq["acq_start"] == zq["acq_end"] else \
f"{zq['acq_start']} to {zq['acq_end']}"
label = "Acquisition" if zq["acq_sources"] == {"gps"} else "File production date"
lines.append(f"{label}: {period}")
if zq["missing_relief"]:
lines.append("Missing data (no relief): " + ", ".join(
f"{c_}_{r_}" for c_, r_ in zq["missing_relief"]))
if zq["missing_quality"]:
lines.append("Quality not available: " + ", ".join(
f"{c_}_{r_}" for c_, r_ in zq["missing_quality"]))
ty = min(my, ly) - 3.5
font_q, size_q = "Helvetica", 5.8
c.setFillColor(black); c.setFont(font_q, size_q)
max_w = w * mm
for line in lines:
sep = ", " if ", " in line else " "
for part in _wrap_text(c, line, font_q, size_q, max_w, sep=sep):
if ty < y + 1:
return
c.drawString(x * mm, ty * mm, _pdf_text(part))
ty -= 2.9
def _draw_cartouche(c, box, lay, bbox, cx, cy, title, now, mm):
"""Title, graphic and numeric scale, north, date, source."""
from reportlab.lib.colors import black, white
x, y, w, h = box
# Title and subtitle share the top line with the north arrow (top-right
# corner): bounded width so they never overlap it.
max_w_head = _cartouche_text_max_width(w, mm)
txt, title_size = _fit_title(c, _pdf_text(title), "Helvetica-Bold", 10, max_w_head)
c.setFillColor(black); c.setFont("Helvetica-Bold", title_size)
c.drawString(x * mm, (y + h - 5) * mm, txt)
subtitle, subtitle_size = _fit_title(
c, _pdf_text(f"Scale 1:{_fmt_int(lay.scale)} - {lay.paper} "
f"{_ORIENT_LABELS.get(lay.orient, lay.orient)} - {lay.dpi} dpi"),
"Helvetica", 7, max_w_head, min_size=6.0)
c.setFont("Helvetica", subtitle_size)
c.drawString(x * mm, (y + h - 9) * mm, subtitle)
# Graphic scale: round length ≤ 40% of the block width
max_m = w * 0.4 / 1000 * lay.scale
length = max((s for s in _SCALEBAR_STEPS if s <= max_m), default=_SCALEBAR_STEPS[0])
bar_mm = length / lay.scale * 1000
bx, by = x, y + h - 15
for k in range(4):
c.setFillColor(black if k % 2 == 0 else white)
c.rect((bx + bar_mm * k / 4) * mm, by * mm, bar_mm / 4 * mm, 1.6 * mm, stroke=1, fill=1)
c.setFillColor(black); c.setFont("Helvetica", 6)
c.drawString(bx * mm, (by - 2.8) * mm, "0")
c.drawRightString((bx + bar_mm) * mm, (by - 2.8) * mm, f"{_fmt_int(length)} m")
# True north arrow (the map is oriented to grid north)
gamma = _north_arrow_angle(cx, cy)
ax, ay = x + w - 8, y + h - 12
c.saveState(); c.translate(ax * mm, ay * mm); c.rotate(gamma)
p = c.beginPath(); p.moveTo(0, 5 * mm); p.lineTo(-1.8 * mm, -3 * mm); p.lineTo(0, -1.5 * mm)
p.lineTo(1.8 * mm, -3 * mm); p.close()
c.drawPath(p, stroke=0, fill=1)
c.setFont("Helvetica-Bold", 6); c.drawCentredString(0, 6 * mm, "N")
c.restoreState()
# Orientation note: a line of its own (never at the height of the
# numeric scale — avoids overlapping the right- and left-aligned texts).
side = "west" if gamma >= 0 else "east"
north_label = f"True north {abs(gamma):.2f}° {side} of grid north"
ty = by - 7
font_c, size_c = "Helvetica", 6
c.setFillColor(black); c.setFont(font_c, size_c)
max_w_c = w * mm
for line in (north_label,
f"L93 center: X {_fmt_int(round(cx))} m Y {_fmt_int(round(cy))} m",
f"Area: {_fmt_int(round(bbox[2] - bbox[0]))} x {_fmt_int(round(bbox[3] - bbox[1]))} m",
f"Exported on {now:%Y-%m-%d %H:%M}",
"Source: LiDAR HD © IGN - rendered by lidar_rendu"):
for part in _wrap_text(c, line, font_c, size_c, max_w_c):
if ty < y + 1:
return
c.drawString(x * mm, ty * mm, _pdf_text(part))
ty -= 3.0
def build_pdf(output_dir, lat, lon, paper="A4", orient="paysage", scale=2000,
title=None, now=None, compress=True):
"""PDF sheet of an area. Returns (PDF bytes, file name).
Raises:
ValueError: invalid setting.
NoDataError: no relief tile in the footprint.
"""
import os
import tempfile
from datetime import datetime
from reportlab.lib.units import mm
from reportlab.pdfgen import canvas as rl_canvas
from io import BytesIO
from .quality import load_quality_table
lay = layout(paper, orient, scale)
cx, cy = center_l93(lat, lon)
bbox = map_bbox(cx, cy, lay)
img, _mask, cells = compose_l93(output_dir, bbox, pixel_size(lay))
if not cells:
raise NoDataError("no oriented-relief tile in this area")
now = now or datetime.now()
zq = zone_quality(bbox, load_quality_table(output_dir), cells)
title = (title or "").strip()[:120] or \
"Oriented relief - " + ", ".join(f"{c_}_{r_}" for c_, r_ in cells[:4]) + \
(" ..." if len(cells) > 4 else "")
buf = BytesIO()
c = rl_canvas.Canvas(buf, pagesize=(lay.page_w * mm, lay.page_h * mm),
pageCompression=1 if compress else 0)
c.setTitle(_pdf_text(title)); c.setAuthor("lidar_rendu")
# Map image as JPEG (embedded as is: lightweight PDF)
fd, jpg = tempfile.mkstemp(suffix=".jpg")
os.close(fd)
try:
img.save(jpg, format="JPEG", quality=_JPEG_QUALITY, subsampling=0)
del img
c.drawImage(jpg, lay.map_x * mm, lay.map_y * mm, lay.map_w * mm, lay.map_h * mm)
finally:
os.unlink(jpg)
c.setLineWidth(0.6)
c.rect(lay.map_x * mm, lay.map_y * mm, lay.map_w * mm, lay.map_h * mm, stroke=1, fill=0)
_draw_grid(c, lay, bbox, mm)
legend_box, quality_box, cart_box = _panel_boxes(lay)
_draw_legend(c, legend_box, mm)
_draw_quality(c, quality_box, bbox, zq, mm)
_draw_cartouche(c, cart_box, lay, bbox, cx, cy, title, now, mm)
c.showPage(); c.save()
name = f"relief_{cx / 1000:.3f}_{cy / 1000:.3f}_1-{lay.scale}.pdf"
return buf.getvalue(), name