SPB Git

spb/lou-ka

Public

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

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 (Aug 24, 2026) parent 872a202 Browse files

1 changed file +468 −39

Unified Split
modified louka/pdfgen.py +468 −39
@@ -2,8 +2,11 @@
2 # Lou-Ka — Agrégateur de logements à louer (province de Québec) 2 # Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 # Auteur : Simon-Pierre Boucher — contact@spboucher.ai 3 # Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 # pdfgen.py : génération PDF à l'identité visuelle officielle Lou-Ka 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, 5+# - fiche_pdf(uid) : fiche de propriété complète — tout ce que montre la
6−# description structurée, inclusions, quartier, à proximité, QR code) 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 # - rapport_pdf() : rapport global du marché (totaux, régions, histogramme 10 # - rapport_pdf() : rapport global du marché (totaux, régions, histogramme
8 # des loyers, types, villes, gestionnaires) 11 # des loyers, types, villes, gestionnaires)
9 # Palette identique au site : blanc #ffffff, marine #0b1330, orange #ff6a00, 12 # Palette identique au site : blanc #ffffff, marine #0b1330, orange #ff6a00,
@@ -76,7 +79,19 @@ def _fetch_image(url: str) -> ImageReader | None:
76 try: 79 try:
77 r = requests.get(url, timeout=IMG_TIMEOUT, headers={"User-Agent": UA}) 80 r = requests.get(url, timeout=IMG_TIMEOUT, headers={"User-Agent": UA})
78 r.raise_for_status() 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 except Exception: 95 except Exception:
81 return None 96 return None
82 97
@@ -189,13 +204,60 @@ class _Style:
189 y -= interligne 204 y -= interligne
190 return y 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 # Fiche de propriété 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 def fiche_pdf(uid: str) -> bytes | None: 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 con = db.connect() 261 con = db.connect()
200 row = con.execute("SELECT * FROM listings WHERE uid=?", (uid,)).fetchone() 262 row = con.execute("SELECT * FROM listings WHERE uid=?", (uid,)).fetchone()
201 if row is None: 263 if row is None:
@@ -218,16 +280,67 @@ def fiche_pdf(uid: str) -> bytes | None:
218 dauid if dauid and dauid != "hors-zone" else None) 280 dauid if dauid and dauid != "hors-zone" else None)
219 reg = json.loads((db.DB_PATH.parent / "sources.json").read_text("utf-8"))["sources"] 281 reg = json.loads((db.DB_PATH.parent / "sources.json").read_text("utf-8"))["sources"]
220 src_name = next((s["name"] for s in reg if s["id"] == l["source"]), l["source"]) 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 con.close() 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 buf = io.BytesIO() 324 buf = io.BytesIO()
224 c = rl_canvas.Canvas(buf, pagesize=letter) 325 c = rl_canvas.Canvas(buf, pagesize=letter)
225 c.setTitle(f"Lou-Ka — {l.get('title') or l.get('address')}") 326 c.setTitle(f"Lou-Ka — {l.get('title') or l.get('address')}")
226 s = _Style(c) 327 s = _Style(c)
227 s.fond() 328 s.fond()
228 y = s.entete("Fiche logement") 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 prix_txt = (_fmt_money(l["price"]) + f"{NBSP}/ mois") if l.get("price") \ 344 prix_txt = (_fmt_money(l["price"]) + f"{NBSP}/ mois") if l.get("price") \
232 else (l.get("price_label") or "Prix sur demande") 345 else (l.get("price_label") or "Prix sur demande")
233 c.setFont("Helvetica-Bold", 30) 346 c.setFont("Helvetica-Bold", 30)
@@ -236,7 +349,12 @@ def fiche_pdf(uid: str) -> bytes | None:
236 loyer_secteur = (quartier or {}).get("demographie", {}).get("loyer_moyen") \ 349 loyer_secteur = (quartier or {}).get("demographie", {}).get("loyer_moyen") \
237 if quartier else None 350 if quartier else None
238 bx = M + c.stringWidth(prix_txt, "Helvetica-Bold", 30) + 14 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 delta = (l["price"] - loyer_secteur) / loyer_secteur 358 delta = (l["price"] - loyer_secteur) / loyer_secteur
241 pct = f"{'+' if delta > 0 else '−'}{abs(round(delta * 100))}{NBSP}%" 359 pct = f"{'+' if delta > 0 else '−'}{abs(round(delta * 100))}{NBSP}%"
242 if delta <= -0.15: 360 if delta <= -0.15:
@@ -373,17 +491,333 @@ def fiche_pdf(uid: str) -> bytes | None:
373 c.drawString(xr + 12, y_incl, a[:38]) 491 c.drawString(xr + 12, y_incl, a[:38])
374 y_incl -= 11 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) 496+ # --- Analyse de prix Lou-Ka (juste valeur, fourchette, distribution)
379− s.pied("Données de l'annonce publiée par le gestionnaire — Lou-Ka est un " 497+ if fv and fv.get("fv"):
380− "agrégateur indépendant ; chaque fiche renvoie à l'annonce originale.", 1) 498+ hist = fv.get("histogram") or []
381− c.showPage() 499+ y = place(y, 168 if hist else 92)
382− s.fond() 500+ y = s.titre_section(y, "Analyse de prix Lou-Ka")
383− y = s.entete("Fiche logement · suite") 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 if quartier: 819 if quartier:
820+ y = place(y, 270)
387 y = s.titre_section(y, "Le quartier") 821 y = s.titre_section(y, "Le quartier")
388 demo = quartier.get("demographie") or {} 822 demo = quartier.get("demographie") or {}
389 tuiles = [("Revenu médian", _fmt_money(demo.get("revenu_median"))), 823 tuiles = [("Revenu médian", _fmt_money(demo.get("revenu_median"))),
@@ -394,19 +828,9 @@ def fiche_pdf(uid: str) -> bytes | None:
394 ("Diplôme universitaire", f"{round(demo['pct_univ'])}{NBSP}%" if demo.get("pct_univ") is not None else "—")] 828 ("Diplôme universitaire", f"{round(demo['pct_univ'])}{NBSP}%" if demo.get("pct_univ") is not None else "—")]
395 tw = (PAGE_W - 2 * M - 20) / 3 829 tw = (PAGE_W - 2 * M - 20) / 3
396 for i, (lab, val) in enumerate(tuiles): 830 for i, (lab, val) in enumerate(tuiles):
397− tx = M + (i % 3) * (tw + 10) 831+ s.tuile(M + (i % 3) * (tw + 10), y - 34 - (i // 3) * 44,
398− ty = y - 34 - (i // 3) * 44 832+ tw, 38, val, lab)
399− c.setFillColor(SURFACE) 833+ y -= 34 + 44 + 16
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
410 834
411 prox = quartier.get("proximite") or {} 835 prox = quartier.get("proximite") or {}
412 for k, lab in PROX_LABELS: 836 for k, lab in PROX_LABELS:
@@ -450,15 +874,15 @@ def fiche_pdf(uid: str) -> bytes | None:
450 x = s.pilule(x, y - 8, txt, fg, bg, stk) 874 x = s.pilule(x, y - 8, txt, fg, bg, stk)
451 y -= 30 875 y -= 30
452 876
453− # À proximité 877+ # --- À proximité (commodités du cache POI)
454 if poi: 878 if poi:
879+ lignes = (min(len(poi), 12) + 1) // 2
880+ y = place(y, 36 + lignes * 13)
455 y = s.titre_section(y, "À proximité") 881 y = s.titre_section(y, "À proximité")
456− col2 = y 882+ col_w = (PAGE_W - 2 * M) / 2
457 for i, p in enumerate(poi[:12]): 883 for i, p in enumerate(poi[:12]):
458− px = M if i % 2 == 0 else M + (PAGE_W - 2 * M) / 2 + 10 884+ px = M if i % 2 == 0 else M + col_w + 10
459− if i % 2 == 0 and i > 0: 885+ py = y - (i // 2) * 13
460− y -= 13
461− py = y
462 c.setFont("Helvetica", 8) 886 c.setFont("Helvetica", 8)
463 c.setFillColor(INK2) 887 c.setFillColor(INK2)
464 lab = POI_LABELS.get(p["cat"], p["cat"]) 888 lab = POI_LABELS.get(p["cat"], p["cat"])
@@ -466,11 +890,12 @@ def fiche_pdf(uid: str) -> bytes | None:
466 c.drawString(px, py, f"{lab} — {nom_poi}") 890 c.drawString(px, py, f"{lab} — {nom_poi}")
467 c.setFont("Courier-Bold", 7.5) 891 c.setFont("Courier-Bold", 7.5)
468 c.setFillColor(INK) 892 c.setFillColor(INK)
469− c.drawRightString(px + (PAGE_W - 2 * M) / 2 - 14, py, 893+ c.drawRightString(px + col_w - 14, py,
470 f"{_fmt_dist(p['dist_m'])} · {_marche(p['dist_m'])}") 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 y = s.titre_section(y, "Références") 899 y = s.titre_section(y, "Références")
475 qr = qrcode.make(f"https://www.lou-ka.com/logement/{uid}", box_size=4, border=1) 900 qr = qrcode.make(f"https://www.lou-ka.com/logement/{uid}", box_size=4, border=1)
476 qb = io.BytesIO() 901 qb = io.BytesIO()
@@ -488,10 +913,14 @@ def fiche_pdf(uid: str) -> bytes | None:
488 c.drawString(M + 82, y - 56, "Fiche à jour en ligne (scannez le code) :") 913 c.drawString(M + 82, y - 56, "Fiche à jour en ligne (scannez le code) :")
489 c.setFillColor(GREEN) 914 c.setFillColor(GREEN)
490 c.drawString(M + 82, y - 68, f"www.lou-ka.com/logement/{uid}") 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)" 923+ s.pied(_PIED_FICHE, page[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)
495 c.save() 924 c.save()
496 return buf.getvalue() 925 return buf.getvalue()
497 926
498 927
Menu

Theme