SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
3 days agolast push
HTML 98.9% Python 0.6%

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>
Simon-Pierre Boucher committed 1 mo ago (Aug 24, 2026) parent 872a202

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