"""Export PDF d'une zone : planche d'impression terrain du relief orienté. 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. """ 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") SCALES = (1000, 2000, 5000, 10000) DPI = {"A4": 300, "A3": 250} # borne la mémoire du Pi (~36 Mo en 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) _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).""" 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): """Géométrie d'une planche ; ValueError si un réglage est invalide.""" if paper not in PAPERS_MM: raise ValueError(f"format inconnu : {paper} (A4 ou A3)") if orient not in ORIENTS: raise ValueError(f"orientation inconnue : {orient} (portrait ou paysage)") try: scale = int(scale) except (TypeError, ValueError): raise ValueError(f"échelle invalide : {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)") 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): """Centre WGS84 → Lambert 93 ; ValueError si non fini.""" 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") cx, cy = wgs84_to_l93(lon, lat) if not (math.isfinite(cx) and math.isfinite(cy)): raise ValueError("centre hors du domaine Lambert 93") return cx, cy def map_bbox(cx, cy, lay): """Emprise terrain L93 de la zone carte (papier × échelle).""" 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.""" return max(NATIVE_RES_M, lay.scale * 0.0254 / lay.dpi) def grid_step(scale): """Pas du quadrillage L93 (m) selon l'échelle.""" 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): """Cadre imprimable pour la carte : emprise L93 et coins WGS84 (NO, NE, SE, SO).""" 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])} # 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). NODATA_RGB = (38, 38, 41) _NODATA_TOLERANCE = 3 # écart par canal toléré (rééchantillonnage) _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.""" 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): """Motif blanc à hachures grises (zones sans donnée).""" 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): """Image RGB de l'emprise L93 à px_size m/px, masque des pixels peints et dalles contributrices. Hors données : blanc hachuré.""" 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 # Classes de densité de points sol (pts/m²) : rouge = donnée faible. 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"), ] def lab_to_rgb(L, a, b): """CIELAB (D65) → sRGB 8 bits, même formule que la carte (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): """Couleur d'une orientation de pente (0 = N, sens horaire), comme la rose de la carte.""" 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²).""" color = DENSITY_CLASSES[0][2] for low, _label, c in DENSITY_CLASSES: if v >= low: color = c return color def _pdf_text(s): """Texte encodable par les polices standard (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.""" 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).""" 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): """Aucune dalle du relief orienté dans l'emprise demandée.""" _JPEG_QUALITY = 90 _SCALEBAR_STEPS = (10, 20, 25, 50, 100, 200, 250, 500, 1000, 2000) def _fmt_int(n): return f"{int(n):,}".replace(",", " ") def _hex(rgb): return "#%02x%02x%02x" % tuple(rgb) def _convergence_deg(cx, cy): """Angle (°) du nord géographique par rapport au nord du quadrillage L93.""" 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 _panel_boxes(lay): """Rectangles (x, y, w, h) mm du bandeau : légende, qualité, cartouche.""" 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): """Quadrillage L93 + valeurs en marge + coins WGS84.""" 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}".replace(".", ",") 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}".replace(".", ",") 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) txt = f"{lat:.5f} N {lon:.5f} E" (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")) def _draw_legend(c, box, mm): """Barre de clarté + rose des orientations + texte de 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) 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("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 = 11.0, 5.0 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.5) for label, deg in (("N", 0), ("E", 90), ("S", 180), ("O", 270)): a = math.radians(deg) c.drawCentredString((rcx + (r_out + 2) * math.sin(a)) * mm, (rcy + (r_out + 2) * 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 ty = rcy - r_out - 6 c.setFont("Helvetica", 5.8) for line in VIZ_LEGENDS[LAYER]["legend"].split("\n"): for part in line.split(" | "): if ty < y + 1: return c.drawString(x * mm, ty * mm, _pdf_text(part)) ty -= 2.9 def _draw_quality(c, box, bbox, zq, mm): """Encart qualité : miniature de densité sol + chiffres clés.""" from reportlab.lib.colors import HexColor, black, Color 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 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 c.setFillColor(Color(0.85, 0.85, 0.85)) c.rect(mx * mm, my * mm, mw * mm, mh * mm, stroke=0, fill=1) # gris = non renseigné 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.setFont("Helvetica", 5.8) c.drawString(lx * mm, ly * mm, _pdf_text("Points sol / 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(Color(0.85, 0.85, 0.85)); 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("non renseigné")) # Chiffres clés lines = [] if zq["density_mean"] is None: lines.append("Densité sol : non renseigné") 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} %") if zq["acq_start"] is None: lines.append("Acquisition : non renseigné") 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}") if zq["missing_relief"]: lines.append("Sans relief : " + ", ".join(f"{c_}_{r_}" for c_, r_ in zq["missing_relief"])) if zq["missing_quality"]: lines.append("Qualité non renseignée : " + ", ".join( f"{c_}_{r_}" for c_, r_ in zq["missing_quality"])) ty = min(my, ly) - 3.5 c.setFont("Helvetica", 5.8) for line in lines: if ty < y + 1: break c.drawString(x * mm, ty * mm, _pdf_text(line)) ty -= 2.9 def _draw_cartouche(c, box, lay, bbox, cx, cy, title, now, mm): """Titre, échelle graphique et numérique, nord, date, source.""" from reportlab.lib.colors import black, white x, y, w, h = box c.setFillColor(black); c.setFont("Helvetica-Bold", 10) c.drawString(x * mm, (y + h - 5) * mm, _pdf_text(title)[:80]) c.setFont("Helvetica", 7) c.drawString(x * mm, (y + h - 9) * mm, _pdf_text(f"Échelle 1:{_fmt_int(lay.scale)} - {lay.paper} {lay.orient} - {lay.dpi} dpi")) # Échelle graphique : longueur ronde ≤ 40 % de la largeur du bloc 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") # Flèche du nord géographique (la carte est orientée nord du quadrillage) gamma = _convergence_deg(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() c.setFont("Helvetica", 5.5) c.drawRightString((x + w) * mm, (ay - 6) * mm, _pdf_text(f"convergence L93 {gamma:+.2f}°".replace(".", ","))) ty = by - 7 c.setFont("Helvetica", 6) for line in (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"): if ty < y + 1: break c.drawString(x * mm, ty * mm, _pdf_text(line)) ty -= 3.0 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). Raises: ValueError: réglage invalide. NoDataError: aucune dalle du relief dans l'emprise. """ 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("aucune dalle du relief orienté dans cette zone") 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]) + \ (" ..." 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) 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