feat(pdf): fiche PDF complète — analyse de prix, registre, TAL, environnement, KA Scores
Le PDF de fiche reflète désormais tout le contenu de la fiche web : analyse de prix Lou-Ka (juste valeur, verdict, histogramme), registre des loyers (KPI + tableau), historique TAL, inondation, qualité de l'air, électricité, commerces/transport, essence, KA Scores et quartier, avec pagination dynamique multi-pages. Les photos sont redimensionnées (max 1200 px, JPEG q82) : le PDF passe de 30 Mo à ~400 Ko sur une annonce enrichie. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 changed file +468 −39
modified
louka/pdfgen.py
+468 −39
@@ -2,8 +2,11 @@ | ||
| 2 | 2 | # Lou-Ka — Agrégateur de logements à louer (province de Québec) |
| 3 | 3 | # Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| 4 | 4 | # pdfgen.py : génération PDF à l'identité visuelle officielle Lou-Ka |
| 5 | −# - fiche_pdf(uid) : fiche de propriété (photos, prix + badge marché, chips, | |
| 6 | −# description structurée, inclusions, quartier, à proximité, QR code) | |
| 5 | +# - fiche_pdf(uid) : fiche de propriété complète — tout ce que montre la | |
| 6 | +# fiche web : photos, prix + analyse Lou-Ka (juste valeur, histogramme), | |
| 7 | +# chips, description structurée, inclusions, registre des loyers, | |
| 8 | +# historique TAL, inondation, qualité de l'air, coût d'électricité, | |
| 9 | +# commerces/transport, essence, KA Scores, quartier, à proximité, QR | |
| 7 | 10 | # - rapport_pdf() : rapport global du marché (totaux, régions, histogramme |
| 8 | 11 | # des loyers, types, villes, gestionnaires) |
| 9 | 12 | # Palette identique au site : blanc #ffffff, marine #0b1330, orange #ff6a00, |
@@ -76,7 +79,19 @@ def _fetch_image(url: str) -> ImageReader | None: | ||
| 76 | 79 | try: |
| 77 | 80 | r = requests.get(url, timeout=IMG_TIMEOUT, headers={"User-Agent": UA}) |
| 78 | 81 | r.raise_for_status() |
| 79 | − return ImageReader(io.BytesIO(r.content)) | |
| 82 | + raw = r.content | |
| 83 | + try: | |
| 84 | + # les photos source dépassent parfois 5 Mo chacune : sans | |
| 85 | + # redimensionnement le PDF grimpe à 30 Mo | |
| 86 | + from PIL import Image | |
| 87 | + im = Image.open(io.BytesIO(raw)) | |
| 88 | + im.thumbnail((1200, 1200)) | |
| 89 | + buf = io.BytesIO() | |
| 90 | + im.convert("RGB").save(buf, "JPEG", quality=82) | |
| 91 | + buf.seek(0) | |
| 92 | + return ImageReader(buf) | |
| 93 | + except Exception: | |
| 94 | + return ImageReader(io.BytesIO(raw)) | |
| 80 | 95 | except Exception: |
| 81 | 96 | return None |
| 82 | 97 | |
@@ -189,13 +204,60 @@ class _Style: | ||
| 189 | 204 | y -= interligne |
| 190 | 205 | return y |
| 191 | 206 | |
| 207 | + def tuile(self, x: float, y: float, w: float, h: float, | |
| 208 | + val: str, lab: str): | |
| 209 | + """Tuile KPI blanche à ombre décalée (x, y = coin inférieur gauche).""" | |
| 210 | + c = self.c | |
| 211 | + c.setFillColor(INK) | |
| 212 | + c.roundRect(x + 2.5, y - 2.5, w, h, 5, stroke=0, fill=1) | |
| 213 | + c.setFillColor(SURFACE) | |
| 214 | + c.setStrokeColor(INK) | |
| 215 | + c.setLineWidth(1.2) | |
| 216 | + c.roundRect(x, y, w, h, 5, stroke=1, fill=1) | |
| 217 | + c.setFont("Helvetica-Bold", 12) | |
| 218 | + c.setFillColor(INK) | |
| 219 | + c.drawString(x + 8, y + h - 20, str(val)) | |
| 220 | + c.setFont("Courier", 6.2) | |
| 221 | + c.setFillColor(INK3) | |
| 222 | + c.drawString(x + 8, y + 7, lab.upper()[: max(4, int(w / 4.2))]) | |
| 223 | + | |
| 192 | 224 | |
| 193 | 225 | # --------------------------------------------------------------------------- |
| 194 | 226 | # Fiche de propriété |
| 195 | 227 | # --------------------------------------------------------------------------- |
| 196 | 228 | |
| 229 | +_MOIS = ["janvier", "février", "mars", "avril", "mai", "juin", "juillet", | |
| 230 | + "août", "septembre", "octobre", "novembre", "décembre"] | |
| 231 | + | |
| 232 | + | |
| 233 | +def _fmt_mois(d: str | None) -> str: | |
| 234 | + """« 2017-11-04 » → « novembre 2017 » (sinon la valeur telle quelle).""" | |
| 235 | + if not d: | |
| 236 | + return "—" | |
| 237 | + try: | |
| 238 | + an, mois = d[:7].split("-") | |
| 239 | + return f"{_MOIS[int(mois) - 1]} {an}" | |
| 240 | + except Exception: | |
| 241 | + return d | |
| 242 | + | |
| 243 | + | |
| 244 | +_VERDICTS = { | |
| 245 | + "sous": ("Sous le marché", GREEN_DEEP, LIME_SOFT, GREEN), | |
| 246 | + "marche": ("Dans le marché", INK2, SURFACE, INK), | |
| 247 | + "sur": ("Au-dessus du marché", HexColor("#8a5a12"), AMBER_SOFT, AMBER), | |
| 248 | +} | |
| 249 | + | |
| 250 | +_PIED_FICHE = ("Données de l'annonce publiée par le gestionnaire — Lou-Ka est " | |
| 251 | + "un agrégateur indépendant. Enrichissements (analyse de prix, " | |
| 252 | + "registre, TAL, environnement) fournis à titre indicatif, sans " | |
| 253 | + "valeur juridique.") | |
| 254 | + | |
| 197 | 255 | def fiche_pdf(uid: str) -> bytes | None: |
| 198 | − """PDF de la fiche du logement `uid`. None si introuvable.""" | |
| 256 | + """PDF de la fiche du logement `uid` — reflète TOUS les blocs de la fiche | |
| 257 | + web : prix + analyse Lou-Ka, photos, description, inclusions, registre des | |
| 258 | + loyers, historique TAL, risque d'inondation, qualité de l'air, coût | |
| 259 | + d'électricité, commerces/transport, essence, KA Scores, quartier, | |
| 260 | + à proximité, références + QR. None si introuvable.""" | |
| 199 | 261 | con = db.connect() |
| 200 | 262 | row = con.execute("SELECT * FROM listings WHERE uid=?", (uid,)).fetchone() |
| 201 | 263 | if row is None: |
@@ -218,16 +280,67 @@ def fiche_pdf(uid: str) -> bytes | None: | ||
| 218 | 280 | dauid if dauid and dauid != "hors-zone" else None) |
| 219 | 281 | reg = json.loads((db.DB_PATH.parent / "sources.json").read_text("utf-8"))["sources"] |
| 220 | 282 | src_name = next((s["name"] for s in reg if s["id"] == l["source"]), l["source"]) |
| 283 | + kasc = None | |
| 284 | + if l.get("coord_key"): | |
| 285 | + kr = con.execute("SELECT walk, transit, bike, calme, services," | |
| 286 | + " global AS glob FROM kascores WHERE coord_key=?", | |
| 287 | + (l["coord_key"],)).fetchone() | |
| 288 | + if kr is not None: | |
| 289 | + kasc = dict(kr) | |
| 290 | + price_history = [dict(r) for r in con.execute( | |
| 291 | + "SELECT ts, price FROM price_log WHERE uid=? ORDER BY ts DESC LIMIT 6", | |
| 292 | + (uid,)).fetchall()] | |
| 221 | 293 | con.close() |
| 222 | 294 | |
| 295 | + # --- enrichissements de la fiche (mêmes modules que les blocs du site) --- | |
| 296 | + lat, lng = l.get("lat"), l.get("lng") | |
| 297 | + geo = lat is not None and lng is not None | |
| 298 | + | |
| 299 | + def _safe(fn, *a, **k): | |
| 300 | + try: | |
| 301 | + return fn(*a, **k) | |
| 302 | + except Exception: | |
| 303 | + return None | |
| 304 | + | |
| 305 | + from . import air as air_mod | |
| 306 | + from . import commerces as com_mod | |
| 307 | + from . import fairvalue as fv_mod | |
| 308 | + from . import gaz as gaz_mod | |
| 309 | + from . import hydro as hydro_mod | |
| 310 | + from . import inondation as inon_mod | |
| 311 | + from . import rdl as rdl_mod | |
| 312 | + from . import tal as tal_mod | |
| 313 | + fv = _safe(fv_mod.explain, uid) | |
| 314 | + rdl_d = _safe(rdl_mod.nearby, lat, lng) if geo else None | |
| 315 | + tal_d = (_safe(tal_mod.lookup, l["address"], l.get("city")) | |
| 316 | + if l.get("address") else None) | |
| 317 | + inon = _safe(inon_mod.lookup, lat, lng) if geo else None | |
| 318 | + air_d = _safe(air_mod.lookup, lat, lng) if geo else None | |
| 319 | + gaz_d = _safe(gaz_mod.nearby, lat, lng) if geo else None | |
| 320 | + com = _safe(com_mod.nearby, lat, lng) if geo else None | |
| 321 | + hyd = _safe(hydro_mod.estimate, adresse=l.get("address") or "", | |
| 322 | + uid=uid, lat=lat, lng=lng, solve=False) | |
| 323 | + | |
| 223 | 324 | buf = io.BytesIO() |
| 224 | 325 | c = rl_canvas.Canvas(buf, pagesize=letter) |
| 225 | 326 | c.setTitle(f"Lou-Ka — {l.get('title') or l.get('address')}") |
| 226 | 327 | s = _Style(c) |
| 227 | 328 | s.fond() |
| 228 | 329 | y = s.entete("Fiche logement") |
| 330 | + page = [1] | |
| 331 | + | |
| 332 | + def saut_page() -> float: | |
| 333 | + s.pied(_PIED_FICHE, page[0]) | |
| 334 | + c.showPage() | |
| 335 | + page[0] += 1 | |
| 336 | + s.fond() | |
| 337 | + return s.entete("Fiche logement · suite") | |
| 229 | 338 | |
| 230 | − # --- bloc prix + badge marché | |
| 339 | + def place(y_: float, besoin: float) -> float: | |
| 340 | + """Saute de page si `besoin` points ne tiennent plus sous y_.""" | |
| 341 | + return saut_page() if (y_ - besoin) < M + 34 else y_ | |
| 342 | + | |
| 343 | + # --- bloc prix + badge marché (verdict du modèle Lou-Ka en priorité) | |
| 231 | 344 | prix_txt = (_fmt_money(l["price"]) + f"{NBSP}/ mois") if l.get("price") \ |
| 232 | 345 | else (l.get("price_label") or "Prix sur demande") |
| 233 | 346 | c.setFont("Helvetica-Bold", 30) |
@@ -236,7 +349,12 @@ def fiche_pdf(uid: str) -> bytes | None: | ||
| 236 | 349 | loyer_secteur = (quartier or {}).get("demographie", {}).get("loyer_moyen") \ |
| 237 | 350 | if quartier else None |
| 238 | 351 | bx = M + c.stringWidth(prix_txt, "Helvetica-Bold", 30) + 14 |
| 239 | − if l.get("price") and loyer_secteur: | |
| 352 | + if l.get("price") and fv and fv.get("verdict") in _VERDICTS: | |
| 353 | + dev = fv.get("deviation") or 0 | |
| 354 | + pct = f"{'+' if dev > 0 else '−'}{abs(round(dev * 100))}{NBSP}%" | |
| 355 | + lab, fg, bg, stk = _VERDICTS[fv["verdict"]] | |
| 356 | + s.pilule(bx, y - 22, f"{pct} vs juste valeur · {lab}", fg, bg, stk) | |
| 357 | + elif l.get("price") and loyer_secteur: | |
| 240 | 358 | delta = (l["price"] - loyer_secteur) / loyer_secteur |
| 241 | 359 | pct = f"{'+' if delta > 0 else '−'}{abs(round(delta * 100))}{NBSP}%" |
| 242 | 360 | if delta <= -0.15: |
@@ -373,17 +491,333 @@ def fiche_pdf(uid: str) -> bytes | None: | ||
| 373 | 491 | c.drawString(xr + 12, y_incl, a[:38]) |
| 374 | 492 | y_incl -= 11 |
| 375 | 493 | |
| 376 | − y = min(y_desc, y_incl) - 8 | |
| 494 | + y = min(y_desc, y_incl) - 16 | |
| 377 | 495 | |
| 378 | − # --- pied page 1 + page 2 (quartier / à proximité / références) | |
| 379 | − s.pied("Données de l'annonce publiée par le gestionnaire — Lou-Ka est un " | |
| 380 | − "agrégateur indépendant ; chaque fiche renvoie à l'annonce originale.", 1) | |
| 381 | − c.showPage() | |
| 382 | − s.fond() | |
| 383 | − y = s.entete("Fiche logement · suite") | |
| 496 | + # --- Analyse de prix Lou-Ka (juste valeur, fourchette, distribution) | |
| 497 | + if fv and fv.get("fv"): | |
| 498 | + hist = fv.get("histogram") or [] | |
| 499 | + y = place(y, 168 if hist else 92) | |
| 500 | + y = s.titre_section(y, "Analyse de prix Lou-Ka") | |
| 501 | + jv = _fmt_money(round(fv["fv"])) | |
| 502 | + c.setFont("Helvetica-Bold", 17) | |
| 503 | + c.setFillColor(INK) | |
| 504 | + c.drawString(M, y - 10, jv) | |
| 505 | + c.setFont("Helvetica", 8.5) | |
| 506 | + c.setFillColor(INK2) | |
| 507 | + c.drawString(M + c.stringWidth(jv, "Helvetica-Bold", 17) + 8, y - 10, | |
| 508 | + "/ mois — juste valeur estimée par le modèle Lou-Ka") | |
| 509 | + meta = [] | |
| 510 | + if fv.get("fv_low") and fv.get("fv_high"): | |
| 511 | + meta.append(f"fourchette {_fmt_money(round(fv['fv_low']))} – " | |
| 512 | + f"{_fmt_money(round(fv['fv_high']))}") | |
| 513 | + if fv.get("confidence"): | |
| 514 | + meta.append(f"confiance {fv['confidence']}") | |
| 515 | + if fv.get("comps"): | |
| 516 | + meta.append(f"{fv['comps']} comparables") | |
| 517 | + if fv.get("segment_n"): | |
| 518 | + meta.append(f"segment de {fv['segment_n']} annonces") | |
| 519 | + c.setFont("Helvetica", 8) | |
| 520 | + c.setFillColor(INK3) | |
| 521 | + c.drawString(M, y - 24, " · ".join(meta)[:115]) | |
| 522 | + if l.get("price") and fv.get("verdict") in _VERDICTS: | |
| 523 | + dev = fv.get("deviation") or 0 | |
| 524 | + pct = f"{'+' if dev > 0 else '−'}{abs(round(dev * 100))}{NBSP}%" | |
| 525 | + lab, fg, bg, stk = _VERDICTS[fv["verdict"]] | |
| 526 | + s.pilule(M, y - 48, f"{pct} vs juste valeur · {lab}", fg, bg, stk) | |
| 527 | + y -= 58 | |
| 528 | + if hist: | |
| 529 | + ch_h = 40 | |
| 530 | + lo, hi = hist[0]["x0"], hist[-1]["x1"] | |
| 531 | + larg = PAGE_W - 2 * M | |
| 532 | + base = y - ch_h - 6 | |
| 533 | + if hi > lo and fv.get("fv_low") and fv.get("fv_high"): | |
| 534 | + bx0 = M + larg * max(0.0, (fv["fv_low"] - lo) / (hi - lo)) | |
| 535 | + bx1 = M + larg * min(1.0, (fv["fv_high"] - lo) / (hi - lo)) | |
| 536 | + c.setFillColor(LIME_SOFT) | |
| 537 | + c.rect(bx0, base, max(2, bx1 - bx0), ch_h, stroke=0, fill=1) | |
| 538 | + max_n = max((h["n"] for h in hist), default=1) or 1 | |
| 539 | + bw = larg / len(hist) | |
| 540 | + c.setFillColor(GREEN) | |
| 541 | + for i, h in enumerate(hist): | |
| 542 | + bh = ch_h * h["n"] / max_n | |
| 543 | + if bh > 0: | |
| 544 | + c.rect(M + i * bw + 1, base, bw - 2, max(0.6, bh), | |
| 545 | + stroke=0, fill=1) | |
| 546 | + if hi > lo and l.get("price"): | |
| 547 | + px = M + larg * (min(max(l["price"], lo), hi) - lo) / (hi - lo) | |
| 548 | + c.setStrokeColor(LIME) | |
| 549 | + c.setLineWidth(2) | |
| 550 | + c.line(px, base - 3, px, base + ch_h + 3) | |
| 551 | + c.setFont("Courier-Bold", 6.5) | |
| 552 | + c.setFillColor(LIME) | |
| 553 | + c.drawString(min(max(px - 28, M), PAGE_W - M - 92), | |
| 554 | + base + ch_h + 6, | |
| 555 | + f"CE LOYER · {_fmt_money(l['price'])}") | |
| 556 | + c.setFont("Helvetica", 6.5) | |
| 557 | + c.setFillColor(INK3) | |
| 558 | + c.drawString(M, base - 9, _fmt_money(lo)) | |
| 559 | + c.drawRightString(PAGE_W - M, base - 9, | |
| 560 | + _fmt_money(hi) + " — distribution des loyers du " | |
| 561 | + "segment de référence") | |
| 562 | + y = base - 20 | |
| 563 | + ph = [p for p in price_history if p.get("price")] | |
| 564 | + if len(ph) >= 2 and ph[0]["price"] != ph[-1]["price"]: | |
| 565 | + c.setFont("Helvetica", 8) | |
| 566 | + c.setFillColor(INK2) | |
| 567 | + chn = " → ".join(_fmt_money(p["price"]) for p in reversed(ph[:4])) | |
| 568 | + c.drawString(M, y - 2, f"Historique de prix de l'annonce : {chn}") | |
| 569 | + y -= 16 | |
| 570 | + | |
| 571 | + # --- Registre des loyers (déclarations citoyennes à proximité) | |
| 572 | + if rdl_d and rdl_d.get("n"): | |
| 573 | + comp = rdl_d.get("items") or [] | |
| 574 | + y = place(y, 128 + min(len(comp), 8) * 12) | |
| 575 | + y = s.titre_section(y, "Registre des loyers — déclarations citoyennes") | |
| 576 | + tw = (PAGE_W - 2 * M - 20) / 3 | |
| 577 | + med_rec = rdl_d.get("median_recent") | |
| 578 | + tuiles = [(str(rdl_d["n"]), f"loyers déclarés à <{rdl_d['radius_m']} m"), | |
| 579 | + (_fmt_money(rdl_d.get("median")), "médiane du secteur"), | |
| 580 | + (_fmt_money(med_rec) if med_rec else "—", | |
| 581 | + f"médiane depuis 2023 ({rdl_d.get('n_recent', 0)} décl.)")] | |
| 582 | + for i, (val, lab) in enumerate(tuiles): | |
| 583 | + s.tuile(M + i * (tw + 10), y - 44, tw, 38, val, lab) | |
| 584 | + y -= 56 | |
| 585 | + rooms = rdl_d.get("by_rooms") or {} | |
| 586 | + if rooms: | |
| 587 | + def _rk(k: str) -> float: | |
| 588 | + try: | |
| 589 | + return float(k) | |
| 590 | + except ValueError: | |
| 591 | + return 99.0 | |
| 592 | + parts = [f"{k}{NBSP}ch. {_fmt_money(v['median'])} ({v['n']})" | |
| 593 | + for k, v in sorted(rooms.items(), key=lambda kv: _rk(kv[0]))] | |
| 594 | + c.setFont("Helvetica", 8) | |
| 595 | + c.setFillColor(INK2) | |
| 596 | + c.drawString(M, y, ("Médianes par taille : " + " · ".join(parts))[:118]) | |
| 597 | + y -= 14 | |
| 598 | + if comp: | |
| 599 | + c.setFont("Courier-Bold", 7) | |
| 600 | + c.setFillColor(INK3) | |
| 601 | + for lab, xoff in (("ADRESSE", 0), ("CH.", 280), ("DÉBUT DE BAIL", 320), | |
| 602 | + ("DIST.", 420), ("LOYER", 480)): | |
| 603 | + c.drawString(M + xoff, y, lab) | |
| 604 | + y -= 4 | |
| 605 | + c.setStrokeColor(INK) | |
| 606 | + c.setLineWidth(1) | |
| 607 | + c.line(M, y, PAGE_W - M, y) | |
| 608 | + y -= 11 | |
| 609 | + for it in comp[:8]: | |
| 610 | + c.setFont("Helvetica", 8) | |
| 611 | + c.setFillColor(INK2) | |
| 612 | + c.drawString(M, y, (it.get("address") or "")[:52]) | |
| 613 | + c.drawString(M + 280, y, str(it["rooms"]) if it.get("rooms") is not None else "—") | |
| 614 | + c.drawString(M + 320, y, _fmt_mois(it.get("date"))) | |
| 615 | + c.drawString(M + 420, y, _fmt_dist(it["dist_m"])) | |
| 616 | + c.setFont("Helvetica-Bold", 8) | |
| 617 | + c.setFillColor(INK) | |
| 618 | + c.drawString(M + 480, y, _fmt_money(it.get("price"))) | |
| 619 | + y -= 12 | |
| 620 | + c.setFont("Helvetica", 6.5) | |
| 621 | + c.setFillColor(INK3) | |
| 622 | + c.drawString(M, y - 2, "Loyers déclarés volontairement par des locataires " | |
| 623 | + "au Registre des loyers (registre-des-loyers.ca) — " | |
| 624 | + "données citoyennes non vérifiées.") | |
| 625 | + y -= 16 | |
| 626 | + | |
| 627 | + # --- Historique au TAL (décisions SOQUIJ à l'adresse) | |
| 628 | + if tal_d and tal_d.get("status") in ("ok", "pending"): | |
| 629 | + decs = (tal_d.get("decisions") or [])[:6] | |
| 630 | + y = place(y, 70 + len(decs) * 24) | |
| 631 | + y = s.titre_section(y, "Historique au Tribunal administratif du logement") | |
| 632 | + if tal_d["status"] == "pending": | |
| 633 | + c.setFont("Helvetica", 8.5) | |
| 634 | + c.setFillColor(INK2) | |
| 635 | + c.drawString(M, y - 4, "Adresse en file de vérification auprès des " | |
| 636 | + "décisions publiées du TAL (résultat sur la " | |
| 637 | + "fiche en ligne sous peu).") | |
| 638 | + y -= 18 | |
| 639 | + elif not decs: | |
| 640 | + s.pilule(M, y - 12, "Aucune décision trouvée", GREEN_DEEP, | |
| 641 | + LIME_SOFT, GREEN) | |
| 642 | + c.setFont("Helvetica", 8.5) | |
| 643 | + c.setFillColor(INK2) | |
| 644 | + c.drawString(M + 130, y - 7, "Aucune décision publiée du TAL n'a été " | |
| 645 | + "repérée à cette adresse.") | |
| 646 | + y -= 26 | |
| 647 | + else: | |
| 648 | + n = tal_d.get("n") or len(decs) | |
| 649 | + cl = tal_d.get("contre_locataire") or 0 | |
| 650 | + c.setFont("Helvetica-Bold", 9) | |
| 651 | + c.setFillColor(INK) | |
| 652 | + c.drawString(M, y - 4, | |
| 653 | + f"{n} décision{'s' if n > 1 else ''} publiée" | |
| 654 | + f"{'s' if n > 1 else ''} à cette adresse — " | |
| 655 | + f"{cl} à l'initiative du propriétaire.") | |
| 656 | + y -= 20 | |
| 657 | + for dec in decs: | |
| 658 | + qui = {"locateur": "demande du propriétaire", | |
| 659 | + "locataire": "demande du locataire"}.get( | |
| 660 | + dec.get("demandeur") or "", "demandeur inconnu") | |
| 661 | + c.setFont("Helvetica-Bold", 8) | |
| 662 | + c.setFillColor(INK) | |
| 663 | + c.drawString(M, y, _fmt_mois(dec.get("date"))) | |
| 664 | + c.setFont("Helvetica", 8) | |
| 665 | + c.setFillColor(INK2) | |
| 666 | + suite = qui | |
| 667 | + if dec.get("verdict"): | |
| 668 | + suite += f" · demande {dec['verdict']}" | |
| 669 | + if dec.get("tags"): | |
| 670 | + suite += " · " + ", ".join(dec["tags"][:3]) | |
| 671 | + c.drawString(M + 78, y, suite[:88]) | |
| 672 | + c.setFont("Courier-Bold", 7) | |
| 673 | + c.setFillColor(GREEN) | |
| 674 | + c.drawRightString(PAGE_W - M, y, dec.get("citation") or "SOQUIJ") | |
| 675 | + y -= 12 | |
| 676 | + y -= 6 | |
| 677 | + c.setFont("Helvetica", 6.5) | |
| 678 | + c.setFillColor(INK3) | |
| 679 | + c.drawString(M, y - 2, "Décisions publiques diffusées par SOQUIJ, repérées " | |
| 680 | + "par correspondance d'adresse — une décision peut " | |
| 681 | + "concerner un autre logement du même immeuble.") | |
| 682 | + y -= 16 | |
| 683 | + | |
| 684 | + # --- Risque d'inondation (BDZI) | |
| 685 | + if inon: | |
| 686 | + zones = (inon.get("zones") or [])[:3] | |
| 687 | + y = place(y, 56 + len(zones) * 12) | |
| 688 | + y = s.titre_section(y, "Risque d'inondation") | |
| 689 | + statut = inon.get("statut") | |
| 690 | + sev = {"eleve": "récurrence élevée", "modere": "récurrence modérée", | |
| 691 | + "present": "zone cartographiée"}.get(inon.get("severite") or "", "") | |
| 692 | + if statut == "en_zone": | |
| 693 | + s.pilule(M, y - 12, f"En zone inondable ({sev})", | |
| 694 | + HexColor("#a12622"), HexColor("#fde8e8"), HexColor("#a12622")) | |
| 695 | + elif statut == "a_proximite": | |
| 696 | + d0 = zones[0]["distance_m"] if zones else "?" | |
| 697 | + s.pilule(M, y - 12, f"Zone inondable à ~{d0} m", | |
| 698 | + HexColor("#8a5a12"), AMBER_SOFT, AMBER) | |
| 699 | + elif statut == "hors_zone": | |
| 700 | + s.pilule(M, y - 12, "Hors des zones inondables cartographiées", | |
| 701 | + GREEN_DEEP, LIME_SOFT, GREEN) | |
| 702 | + else: | |
| 703 | + s.pilule(M, y - 12, "Secteur non couvert par la cartographie BDZI", | |
| 704 | + INK2, SURFACE, INK) | |
| 705 | + y -= 26 | |
| 706 | + for z in zones: | |
| 707 | + c.setFont("Helvetica", 8) | |
| 708 | + c.setFillColor(INK2) | |
| 709 | + c.drawString(M, y, f"{z['type']} — à {z['distance_m']} m" | |
| 710 | + + (f" (rapport {z['date_rapport']})" | |
| 711 | + if z.get("date_rapport") else "")) | |
| 712 | + y -= 12 | |
| 713 | + y -= 8 | |
| 714 | + | |
| 715 | + # --- Qualité de l'air (RSQAQ) | |
| 716 | + if air_d and air_d.get("station"): | |
| 717 | + mes = air_d.get("mesures") or {} | |
| 718 | + y = place(y, 44 + len(mes) * 11) | |
| 719 | + y = s.titre_section(y, "Qualité de l'air") | |
| 720 | + c.setFont("Helvetica", 8.5) | |
| 721 | + c.setFillColor(INK2) | |
| 722 | + c.drawString(M, y - 2, | |
| 723 | + f"Station {air_d['station']} ({air_d['ville']}), à " | |
| 724 | + + f"{air_d['distance_km']}".replace(".", ",") + f"{NBSP}km " | |
| 725 | + "du logement — moyennes annuelles :") | |
| 726 | + y -= 16 | |
| 727 | + for pol, m_ in list(mes.items())[:5]: | |
| 728 | + c.setFont("Helvetica-Bold", 8) | |
| 729 | + c.setFillColor(INK) | |
| 730 | + c.drawString(M, y, pol) | |
| 731 | + c.setFont("Helvetica", 8) | |
| 732 | + c.setFillColor(INK2) | |
| 733 | + val = f"{m_['moyenne']:.1f}".replace(".", ",") if m_.get("moyenne") is not None else "—" | |
| 734 | + txt = f"{val}{NBSP}{m_.get('unite', '')} ({m_.get('annee', '')})" | |
| 735 | + if m_.get("ref"): | |
| 736 | + txt += f" · référence {m_.get('ref_nom', '')} : {m_['ref']}" | |
| 737 | + c.drawString(M + 56, y, txt[:95]) | |
| 738 | + y -= 11 | |
| 739 | + y -= 8 | |
| 740 | + | |
| 741 | + # --- Coût d'électricité (Hydro-Québec) | |
| 742 | + if hyd and hyd.get("disponible"): | |
| 743 | + y = place(y, 52) | |
| 744 | + y = s.titre_section(y, "Coût d'électricité (Hydro-Québec)") | |
| 745 | + c.setFont("Helvetica-Bold", 15) | |
| 746 | + c.setFillColor(INK) | |
| 747 | + mens = f"≈{NBSP}{_fmt_money(round(hyd['cout_mensuel']))} / mois" | |
| 748 | + c.drawString(M, y - 8, mens) | |
| 749 | + c.setFont("Helvetica", 8.5) | |
| 750 | + c.setFillColor(INK2) | |
| 751 | + suite = f"{_fmt_money(round(hyd['cout_annuel']))} / an" | |
| 752 | + if hyd.get("kwh_annuel"): | |
| 753 | + suite += f" · {round(hyd['kwh_annuel']):,}".replace(",", NBSP) + f"{NBSP}kWh/an" | |
| 754 | + suite += " — estimation officielle Hydro-Québec pour cette adresse." | |
| 755 | + c.drawString(M + c.stringWidth(mens, "Helvetica-Bold", 15) + 10, y - 8, suite) | |
| 756 | + y -= 26 | |
| 384 | 757 | |
| 385 | − # Le quartier | |
| 758 | + # --- Commerces et transport | |
| 759 | + if com and (com.get("commerces") or com.get("transit")): | |
| 760 | + entrees = ([(t["commerce"], t["nom"], t["dist_m"]) | |
| 761 | + for t in (com.get("transit") or [])[:4]] | |
| 762 | + + [(i["commerce"], i["nom"], i["dist_m"]) | |
| 763 | + for i in (com.get("commerces") or [])[:10]]) | |
| 764 | + lignes = (len(entrees) + 1) // 2 | |
| 765 | + y = place(y, 36 + lignes * 13) | |
| 766 | + y = s.titre_section(y, "Commerces et transport") | |
| 767 | + col_w = (PAGE_W - 2 * M) / 2 | |
| 768 | + for i, (cat, nom, dist) in enumerate(entrees): | |
| 769 | + px = M if i % 2 == 0 else M + col_w + 10 | |
| 770 | + py = y - (i // 2) * 13 | |
| 771 | + c.setFont("Helvetica", 8) | |
| 772 | + c.setFillColor(INK2) | |
| 773 | + nomc = nom if len(nom) <= 22 else nom[:21] + "…" | |
| 774 | + c.drawString(px, py, f"{cat} — {nomc}") | |
| 775 | + c.setFont("Courier-Bold", 7.5) | |
| 776 | + c.setFillColor(INK) | |
| 777 | + c.drawRightString(px + col_w - 14, py, _fmt_dist(dist)) | |
| 778 | + y -= lignes * 13 + 12 | |
| 779 | + | |
| 780 | + # --- Essence à proximité | |
| 781 | + if gaz_d and gaz_d.get("stations"): | |
| 782 | + sts = gaz_d["stations"][:3] | |
| 783 | + y = place(y, 40 + len(sts) * 11) | |
| 784 | + y = s.titre_section(y, "Essence à proximité") | |
| 785 | + if gaz_d.get("mediane_regulier"): | |
| 786 | + c.setFont("Helvetica", 8.5) | |
| 787 | + c.setFillColor(INK2) | |
| 788 | + c.drawString(M, y - 2, "Prix médian de l'essence régulière à <5 km : " | |
| 789 | + + f"{gaz_d['mediane_regulier']}".replace(".", ",") | |
| 790 | + + f"{NBSP}¢/L") | |
| 791 | + y -= 14 | |
| 792 | + for st in sts: | |
| 793 | + c.setFont("Helvetica", 8) | |
| 794 | + c.setFillColor(INK2) | |
| 795 | + reg_p = (f"{st['regulier']}".replace(".", ",") + f"{NBSP}¢/L" | |
| 796 | + if st.get("regulier") else "—") | |
| 797 | + c.drawString(M, y, f"{st['nom']} — {_fmt_dist(st['dist_m'])} : {reg_p}" | |
| 798 | + + (" · la moins chère" if st.get("moins_chere") else "")) | |
| 799 | + y -= 11 | |
| 800 | + y -= 8 | |
| 801 | + | |
| 802 | + # --- KA Scores (immeuble et environnement) | |
| 803 | + if kasc and kasc.get("glob") is not None: | |
| 804 | + from . import kascores as ks_mod | |
| 805 | + y = place(y, 92) | |
| 806 | + y = s.titre_section(y, "KA Scores") | |
| 807 | + paires = [("Global", kasc.get("glob")), ("À pied", kasc.get("walk")), | |
| 808 | + ("Transport", kasc.get("transit")), ("Vélo", kasc.get("bike")), | |
| 809 | + ("Calme", kasc.get("calme")), ("Services", kasc.get("services"))] | |
| 810 | + tw = (PAGE_W - 2 * M - 5 * 8) / 6 | |
| 811 | + for i, (lab, v) in enumerate(paires): | |
| 812 | + val = "—" if v is None else str(round(v)) | |
| 813 | + sub = ks_mod.label(v) or "" | |
| 814 | + s.tuile(M + i * (tw + 8), y - 46, tw, 42, val, | |
| 815 | + f"{lab} · {sub}" if sub else lab) | |
| 816 | + y -= 62 | |
| 817 | + | |
| 818 | + # --- Le quartier | |
| 386 | 819 | if quartier: |
| 820 | + y = place(y, 270) | |
| 387 | 821 | y = s.titre_section(y, "Le quartier") |
| 388 | 822 | demo = quartier.get("demographie") or {} |
| 389 | 823 | tuiles = [("Revenu médian", _fmt_money(demo.get("revenu_median"))), |
@@ -394,19 +828,9 @@ def fiche_pdf(uid: str) -> bytes | None: | ||
| 394 | 828 | ("Diplôme universitaire", f"{round(demo['pct_univ'])}{NBSP}%" if demo.get("pct_univ") is not None else "—")] |
| 395 | 829 | tw = (PAGE_W - 2 * M - 20) / 3 |
| 396 | 830 | for i, (lab, val) in enumerate(tuiles): |
| 397 | − tx = M + (i % 3) * (tw + 10) | |
| 398 | − ty = y - 34 - (i // 3) * 44 | |
| 399 | − c.setFillColor(SURFACE) | |
| 400 | − c.setStrokeColor(INK) | |
| 401 | − c.setLineWidth(1.2) | |
| 402 | − c.roundRect(tx, ty, tw, 38, 5, stroke=1, fill=1) | |
| 403 | − c.setFont("Helvetica-Bold", 12) | |
| 404 | − c.setFillColor(INK) | |
| 405 | − c.drawString(tx + 8, ty + 20, val) | |
| 406 | − c.setFont("Courier", 6.5) | |
| 407 | − c.setFillColor(INK3) | |
| 408 | − c.drawString(tx + 8, ty + 8, lab.upper()) | |
| 409 | − y -= 34 + 2 * 44 + 6 | |
| 831 | + s.tuile(M + (i % 3) * (tw + 10), y - 34 - (i // 3) * 44, | |
| 832 | + tw, 38, val, lab) | |
| 833 | + y -= 34 + 44 + 16 | |
| 410 | 834 | |
| 411 | 835 | prox = quartier.get("proximite") or {} |
| 412 | 836 | for k, lab in PROX_LABELS: |
@@ -450,15 +874,15 @@ def fiche_pdf(uid: str) -> bytes | None: | ||
| 450 | 874 | x = s.pilule(x, y - 8, txt, fg, bg, stk) |
| 451 | 875 | y -= 30 |
| 452 | 876 | |
| 453 | − # À proximité | |
| 877 | + # --- À proximité (commodités du cache POI) | |
| 454 | 878 | if poi: |
| 879 | + lignes = (min(len(poi), 12) + 1) // 2 | |
| 880 | + y = place(y, 36 + lignes * 13) | |
| 455 | 881 | y = s.titre_section(y, "À proximité") |
| 456 | − col2 = y | |
| 882 | + col_w = (PAGE_W - 2 * M) / 2 | |
| 457 | 883 | for i, p in enumerate(poi[:12]): |
| 458 | − px = M if i % 2 == 0 else M + (PAGE_W - 2 * M) / 2 + 10 | |
| 459 | − if i % 2 == 0 and i > 0: | |
| 460 | − y -= 13 | |
| 461 | − py = y | |
| 884 | + px = M if i % 2 == 0 else M + col_w + 10 | |
| 885 | + py = y - (i // 2) * 13 | |
| 462 | 886 | c.setFont("Helvetica", 8) |
| 463 | 887 | c.setFillColor(INK2) |
| 464 | 888 | lab = POI_LABELS.get(p["cat"], p["cat"]) |
@@ -466,11 +890,12 @@ def fiche_pdf(uid: str) -> bytes | None: | ||
| 466 | 890 | c.drawString(px, py, f"{lab} — {nom_poi}") |
| 467 | 891 | c.setFont("Courier-Bold", 7.5) |
| 468 | 892 | c.setFillColor(INK) |
| 469 | − c.drawRightString(px + (PAGE_W - 2 * M) / 2 - 14, py, | |
| 893 | + c.drawRightString(px + col_w - 14, py, | |
| 470 | 894 | f"{_fmt_dist(p['dist_m'])} · {_marche(p['dist_m'])}") |
| 471 | − y -= 26 | |
| 895 | + y -= lignes * 13 + 14 | |
| 472 | 896 | |
| 473 | − # Références : QR + gestionnaire + source | |
| 897 | + # --- Références : QR + gestionnaire + source | |
| 898 | + y = place(y, 120) | |
| 474 | 899 | y = s.titre_section(y, "Références") |
| 475 | 900 | qr = qrcode.make(f"https://www.lou-ka.com/logement/{uid}", box_size=4, border=1) |
| 476 | 901 | qb = io.BytesIO() |
@@ -488,10 +913,14 @@ def fiche_pdf(uid: str) -> bytes | None: | ||
| 488 | 913 | c.drawString(M + 82, y - 56, "Fiche à jour en ligne (scannez le code) :") |
| 489 | 914 | c.setFillColor(GREEN) |
| 490 | 915 | c.drawString(M + 82, y - 68, f"www.lou-ka.com/logement/{uid}") |
| 916 | + y -= 86 | |
| 917 | + c.setFont("Helvetica", 6.5) | |
| 918 | + c.setFillColor(INK3) | |
| 919 | + c.drawString(M, y, "Sources : Statistique Canada (2021), INSPQ (CC-BY 4.0), " | |
| 920 | + "Ville de Montréal (CC-BY 4.0), OpenStreetMap, BDZI, RSQAQ, " | |
| 921 | + "Hydro-Québec, registre-des-loyers.ca, SOQUIJ, gazquebec.ca.") | |
| 491 | 922 | |
| 492 | − s.pied("Quartier : Statistique Canada (Recensement 2021), INSPQ (CC-BY 4.0)" | |
| 493 | − + (", Ville de Montréal (CC-BY 4.0)" if (quartier or {}).get("crime", {}).get("type") == "points" else "") | |
| 494 | − + " · Commodités : OpenStreetMap. Prix et disponibilités : ceux affichés par la source.", 2) | |
| 923 | + s.pied(_PIED_FICHE, page[0]) | |
| 495 | 924 | c.save() |
| 496 | 925 | return buf.getvalue() |
| 497 | 926 | |
| 498 | 927 | |