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>
This commit is contained in:
@ -1,9 +1,9 @@
|
||||
"""Export PDF d'une zone : planche d'impression terrain du relief orienté.
|
||||
"""PDF export of an area: printable field sheet of the oriented relief.
|
||||
|
||||
Composée directement en Lambert 93 depuis les sources de la pyramide
|
||||
(tiles.py) — échelle exacte, quadrillage aligné sur les dalles — puis
|
||||
dessinée avec reportlab (texte, grille et légende vectoriels). Tourne dans
|
||||
l'image légère : Pillow + pyproj + reportlab, sans numpy.
|
||||
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
|
||||
@ -14,20 +14,21 @@ logger = logging.getLogger("lidar")
|
||||
|
||||
LAYER = "relief_oriente"
|
||||
PAPERS_MM = {"A4": (210.0, 297.0), "A3": (297.0, 420.0)}
|
||||
ORIENTS = ("portrait", "paysage")
|
||||
ORIENTS = ("portrait", "paysage") # API values ("paysage" = landscape)
|
||||
_ORIENT_LABELS = {"portrait": "portrait", "paysage": "landscape"}
|
||||
SCALES = (1000, 2000, 5000, 10000)
|
||||
DPI = {"A4": 300, "A3": 250} # borne la mémoire du Pi (~36 Mo en A3)
|
||||
DPI = {"A4": 300, "A3": 250} # bounds the Pi's memory (~36 MB in A3)
|
||||
NATIVE_RES_M = 0.2
|
||||
MARGIN_MM = 10.0 # bord non imprimable
|
||||
ANNOT_MM = 7.0 # bande des coordonnées autour de la carte
|
||||
PANEL_SIDE_MM = 64.0 # bandeau à droite (paysage)
|
||||
PANEL_BOTTOM_MM = 72.0 # bandeau en bas (portrait)
|
||||
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:
|
||||
"""Géométrie de la planche, en mm, origine en bas à gauche (reportlab)."""
|
||||
"""Sheet geometry, in mm, origin at the bottom left (reportlab)."""
|
||||
paper: str
|
||||
orient: str
|
||||
scale: int
|
||||
@ -45,18 +46,18 @@ class Layout:
|
||||
|
||||
|
||||
def layout(paper, orient, scale):
|
||||
"""Géométrie d'une planche ; ValueError si un réglage est invalide."""
|
||||
"""Geometry of a sheet; ValueError if a setting is invalid."""
|
||||
if paper not in PAPERS_MM:
|
||||
raise ValueError(f"format inconnu : {paper} (A4 ou A3)")
|
||||
raise ValueError(f"unknown paper size: {paper} (A4 or A3)")
|
||||
if orient not in ORIENTS:
|
||||
raise ValueError(f"orientation inconnue : {orient} (portrait ou paysage)")
|
||||
raise ValueError(f"unknown orientation: {orient} (portrait or paysage)")
|
||||
try:
|
||||
scale = int(scale)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError(f"échelle invalide : {scale}") from None
|
||||
raise ValueError(f"invalid scale: {scale}") from None
|
||||
if scale not in SCALES:
|
||||
raise ValueError("échelle non proposée : 1:" + str(scale)
|
||||
+ " (1:1000, 1:2000, 1:5000 ou 1:10000)")
|
||||
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
|
||||
@ -73,31 +74,31 @@ def layout(paper, orient, scale):
|
||||
|
||||
|
||||
def center_l93(lat, lon):
|
||||
"""Centre WGS84 → Lambert 93 ; ValueError si non fini."""
|
||||
"""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("coordonnées du centre invalides")
|
||||
raise ValueError("invalid center coordinates")
|
||||
cx, cy = wgs84_to_l93(lon, lat)
|
||||
if not (math.isfinite(cx) and math.isfinite(cy)):
|
||||
raise ValueError("centre hors du domaine Lambert 93")
|
||||
raise ValueError("center outside the Lambert 93 domain")
|
||||
return cx, cy
|
||||
|
||||
|
||||
def map_bbox(cx, cy, lay):
|
||||
"""Emprise terrain L93 de la zone carte (papier × échelle)."""
|
||||
"""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):
|
||||
"""Taille terrain d'un pixel imprimé (m), jamais plus fine que le natif."""
|
||||
"""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):
|
||||
"""Pas du quadrillage L93 (m) selon l'échelle."""
|
||||
"""L93 grid spacing (m) for the scale."""
|
||||
return _GRID_STEPS[int(scale)]
|
||||
|
||||
|
||||
@ -108,7 +109,7 @@ def _to_wgs84(x, y):
|
||||
|
||||
|
||||
def frame(lat, lon, paper, orient, scale):
|
||||
"""Cadre imprimable pour la carte : emprise L93 et coins WGS84 (NO, NE, SE, SO)."""
|
||||
"""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)
|
||||
@ -119,16 +120,16 @@ def frame(lat, lon, paper, orient, scale):
|
||||
"width_m": round(b[2] - b[0]), "height_m": round(b[3] - b[1])}
|
||||
|
||||
|
||||
# Couleur des pixels sans donnée du relief orienté (recopie de
|
||||
# visualizations.RELIEF_NODATA_RGB : ce module importe numpy, absent de
|
||||
# l'image légère ; égalité vérifiée par les tests).
|
||||
# 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 # écart par canal toléré (rééchantillonnage)
|
||||
_NODATA_TOLERANCE = 3 # tolerated per-channel difference (resampling)
|
||||
_HATCH_STEP_PX = 14
|
||||
|
||||
|
||||
def _paste_l93(canvas, mask, src, bbox, px):
|
||||
"""Recadre et rééchantillonne une source L93 dans l'image de la planche."""
|
||||
"""Crop and resample an L93 source into the sheet image."""
|
||||
from PIL import Image, ImageChops
|
||||
from . import tiles
|
||||
|
||||
@ -165,7 +166,7 @@ def _paste_l93(canvas, mask, src, bbox, px):
|
||||
|
||||
|
||||
def _hatch(size):
|
||||
"""Motif blanc à hachures grises (zones sans donnée)."""
|
||||
"""White pattern with gray hatching (areas without data)."""
|
||||
from PIL import Image, ImageDraw
|
||||
w, h = size
|
||||
pat = Image.new("RGB", size, (255, 255, 255))
|
||||
@ -176,8 +177,8 @@ def _hatch(size):
|
||||
|
||||
|
||||
def compose_l93(output_dir, bbox, px_size, layer=LAYER):
|
||||
"""Image RGB de l'emprise L93 à px_size m/px, masque des pixels peints et
|
||||
dalles contributrices. Hors données : blanc hachuré."""
|
||||
"""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
|
||||
|
||||
@ -196,18 +197,18 @@ def compose_l93(output_dir, bbox, px_size, layer=LAYER):
|
||||
|
||||
ROSE_L = 64.0
|
||||
ROSE_CHROMA = 60.0 # = visualizations.RELIEF_CHROMA
|
||||
# Classes de densité de points sol (pts/m²) : rouge = donnée faible.
|
||||
# Ground point density classes (pts/m²): red = weak data.
|
||||
DENSITY_CLASSES = [
|
||||
(0.0, "moins de 1", "#d7301f"),
|
||||
(1.0, "1 à 3", "#fc8d59"),
|
||||
(3.0, "3 à 6", "#fdcc8a"),
|
||||
(6.0, "6 à 10", "#a1d99b"),
|
||||
(10.0, "10 et plus", "#31a354"),
|
||||
(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) → sRGB 8 bits, même formule que la carte (labToRgb)."""
|
||||
"""CIELAB (D65) → 8-bit sRGB, same formula as the map (labToRgb)."""
|
||||
fy = (L + 16) / 116
|
||||
fx, fz = fy + a / 500, fy - b / 200
|
||||
|
||||
@ -226,14 +227,14 @@ def lab_to_rgb(L, a, b):
|
||||
|
||||
|
||||
def rose_color(compass_deg):
|
||||
"""Couleur d'une orientation de pente (0 = N, sens horaire), comme la rose de la carte."""
|
||||
"""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):
|
||||
"""Couleur de classe d'une densité sol (pts/m²)."""
|
||||
"""Class color of a ground density (pts/m²)."""
|
||||
color = DENSITY_CLASSES[0][2]
|
||||
for low, _label, c in DENSITY_CLASSES:
|
||||
if v >= low:
|
||||
@ -242,19 +243,19 @@ def density_color(v):
|
||||
|
||||
|
||||
def _pdf_text(s):
|
||||
"""Texte encodable par les polices standard (WinAnsi/cp1252)."""
|
||||
"""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):
|
||||
"""Dalles LHD 1 km intersectant une emprise L93."""
|
||||
"""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):
|
||||
"""Agrège la qualité des dalles sur une emprise (pondérée par la surface)."""
|
||||
"""Aggregate the tile quality over a footprint (area-weighted)."""
|
||||
from .index import parse_basename_coords
|
||||
from .quality import DENSITY_CELL_M
|
||||
|
||||
@ -308,7 +309,7 @@ def zone_quality(bbox, table, cells_with_relief):
|
||||
|
||||
|
||||
class NoDataError(Exception):
|
||||
"""Aucune dalle du relief orienté dans l'emprise demandée."""
|
||||
"""No oriented-relief tile in the requested footprint."""
|
||||
|
||||
|
||||
_JPEG_QUALITY = 90
|
||||
@ -316,7 +317,7 @@ _SCALEBAR_STEPS = (10, 20, 25, 50, 100, 200, 250, 500, 1000, 2000)
|
||||
|
||||
|
||||
def _fmt_int(n):
|
||||
return f"{int(n):,}".replace(",", " ")
|
||||
return f"{int(n):,}"
|
||||
|
||||
|
||||
def _hex(rgb):
|
||||
@ -324,23 +325,22 @@ def _hex(rgb):
|
||||
|
||||
|
||||
def _convergence_deg(cx, cy):
|
||||
"""Convergence du méridien : azimut (°) du nord du quadrillage L93 mesuré
|
||||
depuis le nord géographique (sens horaire), positif à l'est du méridien
|
||||
central (3°E)."""
|
||||
"""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):
|
||||
"""Angle de rotation reportlab (sens antihoraire, `Canvas.rotate`) pour que
|
||||
la flèche du nord — dessinée vers le haut, c'est-à-dire vers le nord du
|
||||
quadrillage — pointe vers le nord géographique."""
|
||||
"""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=" "):
|
||||
"""Découpe `text` sur `sep` en lignes tenant chacune dans `max_width` (pt)."""
|
||||
"""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:]:
|
||||
@ -355,8 +355,8 @@ def _wrap_text(c, text, font, size, max_width, sep=" "):
|
||||
|
||||
|
||||
def _fit_title(c, text, font, size, max_width, min_size=7.0):
|
||||
"""Réduit la taille de police jusqu'à `min_size` puis tronque avec des
|
||||
points de suspension pour tenir `text` dans `max_width` (pt)."""
|
||||
"""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:
|
||||
@ -366,17 +366,18 @@ def _fit_title(c, text, font, size, max_width, min_size=7.0):
|
||||
return text, size
|
||||
|
||||
|
||||
_ARROW_COLUMN_MM = 16.0 # largeur (mm) réservée à la flèche du nord + son "N", coin haut droit
|
||||
_ARROW_COLUMN_MM = 16.0 # width (mm) reserved for the north arrow + its "N", top-right corner
|
||||
|
||||
|
||||
def _cartouche_text_max_width(w, mm):
|
||||
"""Largeur (pt) disponible pour le titre/sous-titre de la cartouche, hors
|
||||
colonne réservée à la flèche du nord (coin haut droit du bloc, même ligne)."""
|
||||
"""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):
|
||||
"""Hachures grises sur fond blanc (maille sans donnée), comme sur la carte."""
|
||||
"""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
|
||||
@ -394,7 +395,7 @@ def _draw_hatch(c, x, y, w, h, mm, step=3.0):
|
||||
|
||||
|
||||
def _panel_boxes(lay):
|
||||
"""Rectangles (x, y, w, h) mm du bandeau : légende, qualité, cartouche."""
|
||||
"""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
|
||||
@ -408,7 +409,7 @@ def _panel_boxes(lay):
|
||||
|
||||
|
||||
def _draw_grid(c, lay, bbox, mm):
|
||||
"""Quadrillage L93 + valeurs en marge + coins WGS84."""
|
||||
"""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])
|
||||
@ -420,7 +421,7 @@ def _draw_grid(c, lay, bbox, mm):
|
||||
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}".replace(".", ",")
|
||||
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
|
||||
@ -428,7 +429,7 @@ def _draw_grid(c, lay, bbox, mm):
|
||||
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}".replace(".", ",")
|
||||
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)
|
||||
@ -442,22 +443,22 @@ def _draw_grid(c, lay, bbox, mm):
|
||||
((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 "O"
|
||||
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"Quadrillage Lambert 93 (km), pas {_fmt_int(step)} m - coins en WGS84"))
|
||||
_pdf_text(f"Lambert 93 grid (km), {_fmt_int(step)} m spacing - corners in WGS84"))
|
||||
|
||||
|
||||
def _draw_legend(c, box, mm):
|
||||
"""Barre de clarté + rose des orientations + texte de VIZ_LEGENDS."""
|
||||
"""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("Légende - relief orienté"))
|
||||
# Barre de clarté (L* 20 → 90, gris neutre)
|
||||
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):
|
||||
@ -465,11 +466,11 @@ def _draw_legend(c, box, mm):
|
||||
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("creux, fossé"))
|
||||
c.drawRightString((bx + bw) * mm, (by - 2.8) * mm, _pdf_text("bosse, crête"))
|
||||
c.drawString(bx * mm, (by + bh + 0.8) * mm, _pdf_text("Clarté = micro-relief"))
|
||||
# Rose des orientations (couleur = orientation de la pente)
|
||||
r_out, r_in = 8.5, 3.9 # place pour les 4 phrases de lecture
|
||||
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))))
|
||||
@ -480,24 +481,24 @@ def _draw_legend(c, box, mm):
|
||||
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),
|
||||
("SO", 225), ("O", 270), ("NO", 315)):
|
||||
("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("Teinte = orientation"))
|
||||
c.drawString((rcx + r_out + 5) * mm, (rcy - 1) * mm, _pdf_text("de la pente"))
|
||||
# Texte de légende ("Comment lire" : phrases habillées à la largeur de la boîte)
|
||||
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) # 4 phrases dans la boîte, A4 compris
|
||||
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"):
|
||||
# Sur la planche, les pixels sans point sol sont hachurés en blanc
|
||||
# (pas noirs comme sur la carte) : même phrase, repère adapté.
|
||||
if sentence.startswith("Trous noirs"):
|
||||
sentence = "Hachures blanches" + sentence[len("Trous noirs"):]
|
||||
# La teinte est déjà légendée à côté de la rose : phrase redondante.
|
||||
if sentence.startswith("Teinte = orientation"):
|
||||
# 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(" "):
|
||||
@ -514,21 +515,21 @@ def _draw_legend(c, box, mm):
|
||||
if ty < y + 1:
|
||||
return
|
||||
c.drawString(x * mm, ty * mm, line)
|
||||
ty -= 3.1 # interligne un peu plus grand entre deux phrases
|
||||
ty -= 3.1 # slightly larger spacing between two sentences
|
||||
|
||||
|
||||
def _draw_quality(c, box, bbox, zq, mm):
|
||||
"""Encart qualité : miniature de densité sol + chiffres clés."""
|
||||
"""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("Qualité des données"))
|
||||
# Miniature : emprise de la zone, mailles 50 m colorées par classe
|
||||
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) # hachures = non renseigné
|
||||
_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])
|
||||
@ -540,7 +541,7 @@ def _draw_quality(c, box, bbox, zq, mm):
|
||||
# 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("Points sol / m²"))
|
||||
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)
|
||||
@ -550,27 +551,27 @@ def _draw_quality(c, box, bbox, zq, mm):
|
||||
_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("non renseigné"))
|
||||
# Chiffres clés
|
||||
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("Densité sol : non renseigné")
|
||||
lines.append("Ground density: not available")
|
||||
else:
|
||||
lines.append(f"Densité sol moyenne : {zq['density_mean']:.1f} pts/m²".replace(".", ","))
|
||||
lines.append(f"Maille la plus faible (50 m) : {zq['density_min']:.1f} pts/m²".replace(".", ","))
|
||||
lines.append(f"Surface sans point sol (interpolée) : {zq['empty_fraction'] * 100:.0f} %")
|
||||
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 : non renseigné")
|
||||
lines.append("Acquisition: not available")
|
||||
else:
|
||||
period = zq["acq_start"] if zq["acq_start"] == zq["acq_end"] else \
|
||||
f"{zq['acq_start']} au {zq['acq_end']}"
|
||||
label = "Acquisition" if zq["acq_sources"] == {"gps"} else "Date de production du fichier"
|
||||
lines.append(f"{label} : {period}")
|
||||
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("Donnée manquante (sans relief) : " + ", ".join(
|
||||
lines.append("Missing data (no relief): " + ", ".join(
|
||||
f"{c_}_{r_}" for c_, r_ in zq["missing_relief"]))
|
||||
if zq["missing_quality"]:
|
||||
lines.append("Qualité non renseignée : " + ", ".join(
|
||||
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
|
||||
@ -586,21 +587,22 @@ def _draw_quality(c, box, bbox, zq, mm):
|
||||
|
||||
|
||||
def _draw_cartouche(c, box, lay, bbox, cx, cy, title, now, mm):
|
||||
"""Titre, échelle graphique et numérique, nord, date, source."""
|
||||
"""Title, graphic and numeric scale, north, date, source."""
|
||||
from reportlab.lib.colors import black, white
|
||||
x, y, w, h = box
|
||||
# Titre et sous-titre partagent la ligne du haut avec la flèche du nord
|
||||
# (coin haut droit) : largeur bornée pour ne jamais la chevaucher.
|
||||
# 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"Échelle 1:{_fmt_int(lay.scale)} - {lay.paper} {lay.orient} - {lay.dpi} dpi"),
|
||||
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)
|
||||
# Échelle graphique : longueur ronde ≤ 40 % de la largeur du bloc
|
||||
# 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
|
||||
@ -611,7 +613,7 @@ def _draw_cartouche(c, box, lay, bbox, cx, cy, title, now, mm):
|
||||
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")
|
||||
# Flèche du nord géographique (la carte est orientée nord du quadrillage)
|
||||
# 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)
|
||||
@ -620,19 +622,19 @@ def _draw_cartouche(c, box, lay, bbox, cx, cy, title, now, mm):
|
||||
c.drawPath(p, stroke=0, fill=1)
|
||||
c.setFont("Helvetica-Bold", 6); c.drawCentredString(0, 6 * mm, "N")
|
||||
c.restoreState()
|
||||
# Note d'orientation : ligne à part (jamais sur la même hauteur que l'échelle
|
||||
# numérique — évite le chevauchement des deux textes alignés à droite/gauche).
|
||||
side = "ouest" if gamma >= 0 else "est"
|
||||
north_label = f"nord géographique à {abs(gamma):.2f}° à l'{side} du quadrillage".replace(".", ",")
|
||||
# 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"Centre L93 : X {_fmt_int(round(cx))} m Y {_fmt_int(round(cy))} m",
|
||||
f"Zone : {_fmt_int(round(bbox[2] - bbox[0]))} x {_fmt_int(round(bbox[3] - bbox[1]))} m",
|
||||
f"Exporté le {now:%d/%m/%Y %H:%M}",
|
||||
"Source : LiDAR HD (c) IGN - rendu lidar_rendu"):
|
||||
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
|
||||
@ -642,11 +644,11 @@ def _draw_cartouche(c, box, lay, bbox, cx, cy, title, now, mm):
|
||||
|
||||
def build_pdf(output_dir, lat, lon, paper="A4", orient="paysage", scale=2000,
|
||||
title=None, now=None, compress=True):
|
||||
"""Planche PDF d'une zone. Returns (octets PDF, nom de fichier).
|
||||
"""PDF sheet of an area. Returns (PDF bytes, file name).
|
||||
|
||||
Raises:
|
||||
ValueError: réglage invalide.
|
||||
NoDataError: aucune dalle du relief dans l'emprise.
|
||||
ValueError: invalid setting.
|
||||
NoDataError: no relief tile in the footprint.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
@ -661,18 +663,18 @@ def build_pdf(output_dir, lat, lon, paper="A4", orient="paysage", scale=2000,
|
||||
bbox = map_bbox(cx, cy, lay)
|
||||
img, _mask, cells = compose_l93(output_dir, bbox, pixel_size(lay))
|
||||
if not cells:
|
||||
raise NoDataError("aucune dalle du relief orienté dans cette zone")
|
||||
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 \
|
||||
"Relief orienté - " + ", ".join(f"{c_}_{r_}" for c_, r_ in cells[:4]) + \
|
||||
"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")
|
||||
# Image carte en JPEG (incorporée telle quelle : PDF léger)
|
||||
# Map image as JPEG (embedded as is: lightweight PDF)
|
||||
fd, jpg = tempfile.mkstemp(suffix=".jpg")
|
||||
os.close(fd)
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user