# ----------------------------------------------------------------------------- # Rent-Ka — Rental listings aggregator (Canada, outside Québec) # Author: Simon-Pierre Boucher — contact@spboucher.ai # pdfgen.py : génération PDF à l'identité visuelle officielle Rent-Ka # - fiche_pdf(uid) : fiche de propriété complète — tout ce que montre la # fiche web : photos, prix + analyse Rent-Ka (juste valeur, histogramme), # chips, description structurée, inclusions, coût réel mensuel, # historique Rent-Ka (+ vies antérieures), passeport de l'immeuble, # gestionnaire (fiche Google + avis synchronisés), registre des loyers, # historique TAL, inondation, qualité de l'air, coût d'électricité, # commerces/transport, essence, KA Scores, score hiver, quartier, # à proximité, QR # - rapport_pdf() : rapport global du marché (totaux, régions, histogramme # des loyers, types, villes, gestionnaires) # Palette identique au site : blanc #ffffff, marine #0b1330, orange #ff6a00, # bleu vif #2258ff, gris pâle #f2f2f2. Polices de base (Helvetica) stylées # par la mise en page — aucune dépendance de police externe. # ----------------------------------------------------------------------------- from __future__ import annotations import io import json import math import time import qrcode import requests from reportlab.lib.colors import HexColor from reportlab.lib.pagesizes import letter from reportlab.lib.utils import ImageReader from reportlab.pdfgen import canvas as rl_canvas from . import db # --- palette Rent-Ka (identité officielle) ------------------------------------ PAPER = HexColor("#ffffff") SURFACE = HexColor("#ffffff") INK = HexColor("#0b1330") # bleu marine profond INK2 = HexColor("#3e4560") INK3 = HexColor("#8a90a5") GREEN = HexColor("#2258ff") # accent secondaire : bleu vif GREEN_DEEP = HexColor("#0b1330") LIME = HexColor("#ff6a00") # accent d'action : orange vif LIME_SOFT = HexColor("#fff1e6") AMBER = HexColor("#ff6a00") AMBER_SOFT = HexColor("#fff1e6") PAGE_W, PAGE_H = letter M = 40 # marge IMG_TIMEOUT = 6 UA = "RentKaBot/1.0 (+https://www.rent-ka.com/bot; contact@spboucher.ai)" POI_LABELS = { "epicerie": "Épicerie", "depanneur": "Dépanneur", "pharmacie": "Pharmacie", "ecole": "École", "garderie": "Garderie", "parc": "Parc", "bus": "Arrêt de bus", "metro": "Métro", "gym": "Gym", "cafe": "Café", "clinique": "Clinique", "hopital": "Hôpital", "bibliotheque": "Bibliothèque", } PROX_LABELS = [ ("prox_epicerie", "Épiceries"), ("prox_transport", "Transport en commun"), ("prox_parc", "Parcs"), ("prox_ecole_prim", "Écoles primaires"), ("prox_sante", "Soins de santé"), ("prox_pharmacie", "Pharmacies"), ] NBSP = " " def _fmt_money(v) -> str: return f"{v:,.0f}".replace(",", f"{NBSP}") + f"{NBSP}$" if v is not None else "—" def _fmt_dist(m_: float) -> str: return (f"{round(m_ / 10) * 10}{NBSP}m" if m_ < 1000 else f"{m_ / 1000:.1f}".replace(".", ",") + f"{NBSP}km") def _marche(m_: float) -> str: return f"≈{NBSP}{max(1, round(m_ * 1.3 / 80))}{NBSP}min à pied" def _fetch_image(url: str) -> ImageReader | None: try: r = requests.get(url, timeout=IMG_TIMEOUT, headers={"User-Agent": UA}) r.raise_for_status() raw = r.content try: # les photos source dépassent parfois 5 Mo chacune : sans # redimensionnement le PDF grimpe à 30 Mo from PIL import Image im = Image.open(io.BytesIO(raw)) im.thumbnail((1200, 1200)) buf = io.BytesIO() im.convert("RGB").save(buf, "JPEG", quality=82) buf.seek(0) return ImageReader(buf) except Exception: return ImageReader(io.BytesIO(raw)) except Exception: return None class _Style: """Petits composants du style « éditorial sharp » sur un canvas.""" def __init__(self, c: rl_canvas.Canvas): self.c = c def fond(self): self.c.setFillColor(PAPER) self.c.rect(0, 0, PAGE_W, PAGE_H, stroke=0, fill=1) def logo(self, x: float, y: float, taille: float = 22): """Wordmark Rent·Ka : « Lou » encre + boîte encre avec « Ka » lime.""" c = self.c c.setFont("Helvetica-Bold", taille) c.setFillColor(INK) c.drawString(x, y, "Lou") w = c.stringWidth("Lou", "Helvetica-Bold", taille) bw = c.stringWidth("Ka", "Helvetica-Bold", taille) + 8 c.saveState() c.translate(x + w + 3 + bw / 2, y + taille * 0.32) c.rotate(-3) c.setFillColor(INK) c.roundRect(-bw / 2, -taille * 0.62, bw, taille * 1.15, 4, stroke=0, fill=1) c.setFillColor(LIME) c.drawCentredString(0, -taille * 0.30, "Ka") c.restoreState() return x + w + 6 + bw def entete(self, titre: str): c = self.c self.logo(M, PAGE_H - M - 16) c.setFont("Courier-Bold", 8) c.setFillColor(INK3) c.drawRightString(PAGE_W - M, PAGE_H - M - 6, titre.upper()) c.drawRightString(PAGE_W - M, PAGE_H - M - 16, time.strftime("GÉNÉRÉ LE %Y-%m-%d · WWW.LOU-KA.COM")) c.setStrokeColor(INK) c.setLineWidth(2) c.line(M, PAGE_H - M - 26, PAGE_W - M, PAGE_H - M - 26) return PAGE_H - M - 40 # y de départ du contenu def pied(self, texte: str, page: int | None = None): c = self.c c.setStrokeColor(INK) c.setLineWidth(1) c.line(M, M + 16, PAGE_W - M, M + 16) c.setFont("Helvetica", 6.5) c.setFillColor(INK3) c.drawString(M, M + 6, texte[:150]) if page: c.setFont("Courier-Bold", 7) c.drawRightString(PAGE_W - M, M + 6, f"P.{page}") def pilule(self, x: float, y: float, texte: str, fg=LIME, bg=INK, stroke=None, taille: float = 8) -> float: """Chip arrondie ; retourne le x suivant.""" c = self.c w = c.stringWidth(texte, "Helvetica-Bold", taille) + 14 c.setFillColor(bg) if stroke: c.setStrokeColor(stroke) c.setLineWidth(1.2) c.roundRect(x, y, w, taille + 9, (taille + 9) / 2, stroke=1 if stroke else 0, fill=1) c.setFillColor(fg) c.setFont("Helvetica-Bold", taille) c.drawString(x + 7, y + 5, texte) return x + w + 6 def titre_section(self, y: float, texte: str) -> float: c = self.c c.setFont("Helvetica-Bold", 12.5) c.setFillColor(INK) c.drawString(M, y, texte) c.setStrokeColor(LIME) c.setLineWidth(3) w = c.stringWidth(texte, "Helvetica-Bold", 12.5) c.line(M, y - 4, M + w, y - 4) return y - 18 def paragraphe(self, x: float, y: float, texte: str, largeur: float, taille: float = 8.5, couleur=INK2, interligne: float = 11.5, max_lignes: int = 100) -> float: """Texte multi-lignes avec découpe aux mots ; retourne le y suivant.""" c = self.c c.setFont("Helvetica", taille) c.setFillColor(couleur) mots = texte.split() ligne = "" n = 0 for mot in mots: essai = (ligne + " " + mot).strip() if c.stringWidth(essai, "Helvetica", taille) <= largeur: ligne = essai else: c.drawString(x, y, ligne) y -= interligne n += 1 ligne = mot if n >= max_lignes - 1: ligne += " …" break if ligne: c.drawString(x, y, ligne) y -= interligne return y def tuile(self, x: float, y: float, w: float, h: float, val: str, lab: str): """Tuile KPI blanche à ombre décalée (x, y = coin inférieur gauche).""" c = self.c c.setFillColor(INK) c.roundRect(x + 2.5, y - 2.5, w, h, 5, stroke=0, fill=1) c.setFillColor(SURFACE) c.setStrokeColor(INK) c.setLineWidth(1.2) c.roundRect(x, y, w, h, 5, stroke=1, fill=1) c.setFont("Helvetica-Bold", 12) c.setFillColor(INK) c.drawString(x + 8, y + h - 20, str(val)) c.setFont("Courier", 6.2) c.setFillColor(INK3) c.drawString(x + 8, y + 7, lab.upper()[: max(4, int(w / 4.2))]) # --------------------------------------------------------------------------- # Fiche de propriété # --------------------------------------------------------------------------- _MOIS = ["janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août", "septembre", "octobre", "novembre", "décembre"] def _fmt_mois(d: str | None) -> str: """« 2017-11-04 » → « novembre 2017 » (sinon la valeur telle quelle).""" if not d: return "—" try: an, mois = d[:7].split("-") return f"{_MOIS[int(mois) - 1]} {an}" except Exception: return d def _fmt_ts(ts, jour: bool = False) -> str: """Timestamp unix → « 6 août 2026 » (jour=True) ou « août 2026 ».""" if not ts: return "—" t = time.localtime(ts) mois = f"{_MOIS[t.tm_mon - 1]} {t.tm_year}" return f"{t.tm_mday} {mois}" if jour else mois _VERDICTS = { "sous": ("Sous le marché", GREEN_DEEP, LIME_SOFT, GREEN), "marche": ("Dans le marché", INK2, SURFACE, INK), "sur": ("Au-dessus du marché", HexColor("#8a5a12"), AMBER_SOFT, AMBER), } _PIED_FICHE = ("Données de l'annonce publiée par le gestionnaire — Rent-Ka est " "un agrégateur indépendant. Enrichissements (analyse de prix, " "registre, TAL, environnement) fournis à titre indicatif, sans " "valeur juridique.") def fiche_pdf(uid: str) -> bytes | None: """PDF de la fiche du logement `uid` — reflète TOUS les blocs de la fiche web : prix + analyse Rent-Ka, photos, description, inclusions, registre des loyers, historique TAL, risque d'inondation, qualité de l'air, coût d'électricité, commerces/transport, essence, KA Scores, quartier, à proximité, références + QR. None si introuvable.""" con = db.connect() row = con.execute("SELECT * FROM listings WHERE uid=?", (uid,)).fetchone() if row is None: con.close() return None l = dict(row) l["amenities"] = json.loads(l.get("amenities") or "[]") l["images"] = json.loads(l.get("images") or "[]") l["details"] = json.loads(l.get("details") or "{}") l["digest"] = json.loads(l["digest"]) if l.get("digest") else None poi = [] quartier = None if l.get("lat") is not None: key = f"{round(l['lat'], 4)},{round(l['lng'], 4)}" pr = con.execute("SELECT pois FROM poi_cache WHERE coord_key=?", (key,)).fetchone() poi = json.loads(pr["pois"]) if pr else [] reg = json.loads((db.DB_PATH.parent / "sources.json").read_text("utf-8"))["sources"] src_name = next((s["name"] for s in reg if s["id"] == l["source"]), l["source"]) kasc = None if l.get("coord_key"): kr = con.execute("SELECT walk, transit, bike, calme, services," " global AS glob FROM kascores WHERE coord_key=?", (l["coord_key"],)).fetchone() if kr is not None: kasc = dict(kr) price_history = [dict(r) for r in con.execute( "SELECT ts, price FROM price_log WHERE uid=? ORDER BY ts DESC LIMIT 6", (uid,)).fetchall()] con.close() # --- enrichissements de la fiche (mêmes modules que les blocs du site) --- lat, lng = l.get("lat"), l.get("lng") geo = lat is not None and lng is not None def _safe(fn, *a, **k): try: return fn(*a, **k) except Exception: return None from . import fairvalue as fv_mod fv = _safe(fv_mod.explain, uid) # Québec-only layers removed in Rent-Ka (Hydro-Québec, rent registry, # TAL, flood zones, air, gas, quartier, real-cost): their PDF sections # are simply skipped (guards below never fire) rdl_d = tal_d = inon = air_d = gaz_d = com = hyd = cout = None # rental intelligence dossier (same modules as the site blocks) from . import building as bld_mod from . import history as hist_mod from . import hiver as hiv_mod from . import managers as mgr_mod from . import recycled as rec_mod histo = _safe(hist_mod.timeline, uid) recyc = _safe(rec_mod.lookup, uid) imm = (_safe(bld_mod.fiche, l["building_key"]) if l.get("building_key") else None) hiv = _safe(hiv_mod.score, lat, lng) if geo else None gest = _safe(mgr_mod.fiche, l["source"]) buf = io.BytesIO() c = rl_canvas.Canvas(buf, pagesize=letter) c.setTitle(f"Rent-Ka — {l.get('title') or l.get('address')}") s = _Style(c) s.fond() y = s.entete("Fiche logement") page = [1] def saut_page() -> float: s.pied(_PIED_FICHE, page[0]) c.showPage() page[0] += 1 s.fond() return s.entete("Fiche logement · suite") def place(y_: float, besoin: float) -> float: """Saute de page si `besoin` points ne tiennent plus sous y_.""" return saut_page() if (y_ - besoin) < M + 34 else y_ # --- bloc prix + badge marché (verdict du modèle Rent-Ka en priorité) prix_txt = (_fmt_money(l["price"]) + f"{NBSP}/ mois") if l.get("price") \ else (l.get("price_label") or "Prix sur demande") c.setFont("Helvetica-Bold", 30) c.setFillColor(INK) c.drawString(M, y - 24, prix_txt) loyer_secteur = (quartier or {}).get("demographie", {}).get("loyer_moyen") \ if quartier else None bx = M + c.stringWidth(prix_txt, "Helvetica-Bold", 30) + 14 if l.get("price") and fv and fv.get("verdict") in _VERDICTS: dev = fv.get("deviation") or 0 pct = f"{'+' if dev > 0 else '−'}{abs(round(dev * 100))}{NBSP}%" lab, fg, bg, stk = _VERDICTS[fv["verdict"]] s.pilule(bx, y - 22, f"{pct} vs juste valeur · {lab}", fg, bg, stk) elif l.get("price") and loyer_secteur: delta = (l["price"] - loyer_secteur) / loyer_secteur pct = f"{'+' if delta > 0 else '−'}{abs(round(delta * 100))}{NBSP}%" if delta <= -0.15: s.pilule(bx, y - 22, f"{pct} vs secteur · Bon deal", GREEN_DEEP, LIME_SOFT, GREEN) elif delta <= 0.10: s.pilule(bx, y - 22, f"{pct} vs secteur · Dans le marché", INK2, SURFACE, INK) else: s.pilule(bx, y - 22, f"{pct} vs secteur · Au-dessus du marché", HexColor("#8a5a12"), AMBER_SOFT, AMBER) y -= 44 # --- titre + adresse c.setFont("Helvetica-Bold", 13) c.setFillColor(INK) c.drawString(M, y, (l.get("title") or l.get("address") or "")[:80]) y -= 14 c.setFont("Helvetica", 9) c.setFillColor(INK2) loc = " · ".join(x for x in (l.get("address"), l.get("sector"), l.get("city")) if x and x != l.get("title")) c.drawString(M, y, loc[:110]) y -= 20 # --- chips clés x = M chips = [] if l.get("unit_type"): chips.append(l["unit_type"]) ad = l.get("availability_date") if ad == "now": chips.append("Libre maintenant") elif ad: chips.append(f"Dispo {ad}") if l.get("area_sqft"): chips.append(f"{round(l['area_sqft'])}{NBSP}pi²") if l.get("furnished"): chips.append("Meublé") if l.get("pets"): chips.append({"oui": "Animaux acceptés", "non": "Animaux refusés", "conditions": "Animaux sous conditions"}.get(l["pets"], l["pets"])) if l["details"].get("floor") is not None: chips.append(f"{l['details']['floor']}e étage") for chip in chips[:7]: x = s.pilule(x, y - 6, chip) y -= 30 # --- photos : 1 grande + 3 vignettes imgs = [im for im in (_fetch_image(u) for u in l["images"][:4]) if im] if imgs: big_h = 170 big_w = big_h * 16 / 10.5 c.setStrokeColor(INK) c.setLineWidth(1.5) try: c.drawImage(imgs[0], M, y - big_h, big_w, big_h, preserveAspectRatio=True, anchor="c", mask="auto") except Exception: pass c.rect(M, y - big_h, big_w, big_h, stroke=1, fill=0) tx = M + big_w + 10 th = (big_h - 16) / 3 for i, im in enumerate(imgs[1:4]): ty = y - (i + 1) * th - i * 8 try: c.drawImage(im, tx, ty, PAGE_W - M - tx, th, preserveAspectRatio=True, anchor="c", mask="auto") c.rect(tx, ty, PAGE_W - M - tx, th, stroke=1, fill=0) except Exception: pass y -= big_h + 18 # --- « En bref » (digest) sur bandeau lime dg = l.get("digest") if dg and dg.get("en_bref"): c.setFillColor(LIME_SOFT) c.setStrokeColor(GREEN) c.setLineWidth(1.2) c.roundRect(M, y - 40, PAGE_W - 2 * M, 44, 6, stroke=1, fill=1) s.paragraphe(M + 10, y - 8, "EN BREF — " + dg["en_bref"], PAGE_W - 2 * M - 20, taille=8.5, couleur=GREEN_DEEP, max_lignes=3) y -= 54 # --- description (2-3 sections max) | inclusions en colonne droite col_g = PAGE_W * 0.56 - M y_desc = s.titre_section(y, "Description") if dg and dg.get("sections"): for sec in dg["sections"][:3]: c.setFont("Helvetica-Bold", 9) c.setFillColor(INK) c.drawString(M, y_desc, sec["titre"][:60]) y_desc -= 12 y_desc = s.paragraphe(M, y_desc, sec["texte"], col_g, max_lignes=6) y_desc -= 4 if y_desc < 150: break elif l.get("description"): y_desc = s.paragraphe(M, y_desc, l["description"], col_g, max_lignes=16) xr = M + col_g + 24 y_incl = s_titre_droite(c, y, xr, "Inclusions et commodités") d = l["details"] badges = [] inc = d.get("inclusions") or {} for k, lab in (("heating", "Chauffage inclus"), ("electricity", "Électricité incluse"), ("hot_water", "Eau chaude incluse"), ("internet", "Internet inclus")): if inc.get(k): badges.append(lab) app = d.get("appliances") or {} if app.get("dishwasher"): badges.append("Lave-vaisselle") if app.get("washer_dryer"): badges.append("Laveuse-sécheuse") for k, lab in (("ac", "Climatisation"), ("elevator", "Ascenseur"), ("balcony", "Balcon"), ("pool", "Piscine"), ("gym", "Gym"), ("laundry", "Buanderie"), ("storage", "Rangement")): if d.get(k): badges.append(lab) if (d.get("parking") or {}).get("available"): badges.append("Stationnement") if l.get("furnished"): badges.append("Meublé") c.setFont("Helvetica", 8.5) for b in badges[:12]: c.setFillColor(GREEN) c.drawString(xr, y_incl, "✓") c.setFillColor(INK2) c.drawString(xr + 12, y_incl, b) y_incl -= 12 autres = [a for a in l["amenities"] if not any(b.lower() in a.lower() or a.lower() in b.lower() for b in badges)] for a in autres[: max(0, 14 - len(badges))]: c.setFillColor(INK3) c.drawString(xr + 12, y_incl, a[:38]) y_incl -= 11 y = min(y_desc, y_incl) - 16 # --- Analyse de prix Rent-Ka (juste valeur, fourchette, distribution) if fv and fv.get("fv"): hist = fv.get("histogram") or [] y = place(y, 168 if hist else 92) y = s.titre_section(y, "Analyse de prix Rent-Ka") jv = _fmt_money(round(fv["fv"])) c.setFont("Helvetica-Bold", 17) c.setFillColor(INK) c.drawString(M, y - 10, jv) c.setFont("Helvetica", 8.5) c.setFillColor(INK2) c.drawString(M + c.stringWidth(jv, "Helvetica-Bold", 17) + 8, y - 10, "/ mois — juste valeur estimée par le modèle Rent-Ka") meta = [] if fv.get("fv_low") and fv.get("fv_high"): meta.append(f"fourchette {_fmt_money(round(fv['fv_low']))} – " f"{_fmt_money(round(fv['fv_high']))}") if fv.get("confidence"): meta.append(f"confiance {fv['confidence']}") if fv.get("comps"): meta.append(f"{fv['comps']} comparables") if fv.get("segment_n"): meta.append(f"segment de {fv['segment_n']} annonces") c.setFont("Helvetica", 8) c.setFillColor(INK3) c.drawString(M, y - 24, " · ".join(meta)[:115]) if l.get("price") and fv.get("verdict") in _VERDICTS: dev = fv.get("deviation") or 0 pct = f"{'+' if dev > 0 else '−'}{abs(round(dev * 100))}{NBSP}%" lab, fg, bg, stk = _VERDICTS[fv["verdict"]] s.pilule(M, y - 48, f"{pct} vs juste valeur · {lab}", fg, bg, stk) y -= 58 if hist: ch_h = 40 lo, hi = hist[0]["x0"], hist[-1]["x1"] larg = PAGE_W - 2 * M base = y - ch_h - 6 if hi > lo and fv.get("fv_low") and fv.get("fv_high"): bx0 = M + larg * max(0.0, (fv["fv_low"] - lo) / (hi - lo)) bx1 = M + larg * min(1.0, (fv["fv_high"] - lo) / (hi - lo)) c.setFillColor(LIME_SOFT) c.rect(bx0, base, max(2, bx1 - bx0), ch_h, stroke=0, fill=1) max_n = max((h["n"] for h in hist), default=1) or 1 bw = larg / len(hist) c.setFillColor(GREEN) for i, h in enumerate(hist): bh = ch_h * h["n"] / max_n if bh > 0: c.rect(M + i * bw + 1, base, bw - 2, max(0.6, bh), stroke=0, fill=1) if hi > lo and l.get("price"): px = M + larg * (min(max(l["price"], lo), hi) - lo) / (hi - lo) c.setStrokeColor(LIME) c.setLineWidth(2) c.line(px, base - 3, px, base + ch_h + 3) c.setFont("Courier-Bold", 6.5) c.setFillColor(LIME) c.drawString(min(max(px - 28, M), PAGE_W - M - 92), base + ch_h + 6, f"CE LOYER · {_fmt_money(l['price'])}") c.setFont("Helvetica", 6.5) c.setFillColor(INK3) c.drawString(M, base - 9, _fmt_money(lo)) c.drawRightString(PAGE_W - M, base - 9, _fmt_money(hi) + " — distribution des loyers du " "segment de référence") y = base - 20 ph = [p for p in price_history if p.get("price")] if len(ph) >= 2 and ph[0]["price"] != ph[-1]["price"]: c.setFont("Helvetica", 8) c.setFillColor(INK2) chn = " → ".join(_fmt_money(p["price"]) for p in reversed(ph[:4])) c.drawString(M, y - 2, f"Historique de prix de l'annonce : {chn}") y -= 16 # --- Coût réel mensuel (loyer + frais, chaque poste étiqueté honnêtement) if cout and cout.get("loyer") is not None: lignes_cr = cout.get("lignes") or [] y = place(y, 58 + len(lignes_cr) * 12 + 46) y = s.titre_section(y, "Coût réel mensuel") stat_cr = {"included": ("INCLUS", GREEN), "observed": ("OBSERVÉ", INK), "estimated": ("ESTIMÉ", LIME), "unknown": ("INCONNU", INK3)} for li in lignes_cr: lab, col = stat_cr.get(li.get("statut"), stat_cr["unknown"]) c.setFont("Helvetica", 8.5) c.setFillColor(INK2) c.drawString(M, y, (li.get("poste") or "")[:46]) c.setFont("Courier-Bold", 6.5) c.setFillColor(col) c.drawString(M + 230, y, lab) m_ = li.get("montant") if m_ is not None and m_ > 0: mtx = _fmt_money(m_) elif m_ == 0 and li.get("statut") == "included": mtx = f"0{NBSP}$" else: mtx = "—" c.setFont("Helvetica-Bold", 8.5) c.setFillColor(INK) c.drawRightString(PAGE_W - M, y, mtx) y -= 12 if cout.get("total_estime") is not None: c.setStrokeColor(INK) c.setLineWidth(1.2) c.line(M, y + 8, PAGE_W - M, y + 8) c.setFont("Helvetica-Bold", 10) c.setFillColor(INK) c.drawString(M, y - 4, "Total estimé") tot = f"≈{NBSP}{_fmt_money(cout['total_estime'])} / mois" if cout.get("annuel_estime") is not None: tot += f" · ≈{NBSP}{_fmt_money(cout['annuel_estime'])} / an" c.drawRightString(PAGE_W - M, y - 4, tot) y -= 18 if cout.get("postes_inconnus"): y = s.paragraphe(M, y - 2, "Postes non chiffrables avec les données " "publiées : " + ", ".join(cout["postes_inconnus"]).lower() + " — le total réel peut être plus élevé.", PAGE_W - 2 * M, taille=7.5, couleur=HexColor("#8a5a12"), interligne=9.5, max_lignes=3) pi2_d = cout.get("pi2") if pi2_d and pi2_d.get("valeur") is not None: v = f"{pi2_d['valeur']:.2f}".replace(".", ",") suite = f"{v}{NBSP}$/pi²" if pi2_d.get("percentile_secteur") is not None: suite += (f" — moins cher que {100 - pi2_d['percentile_secteur']}" f"{NBSP}% des {pi2_d.get('n_secteur')} comparables du " "secteur (~2 km)") elif pi2_d.get("percentile_ville") is not None: suite += (f" — moins cher que {100 - pi2_d['percentile_ville']}" f"{NBSP}% des {pi2_d.get('n_ville')} comparables " f"({pi2_d.get('portee_ville', '')})") c.setFont("Helvetica", 8) c.setFillColor(INK2) c.drawString(M, y - 2, suite[:120]) y -= 12 y -= 8 # --- Historique Rent-Ka (observations réelles des synchronisations) if histo: tl = (histo.get("timeline") or [])[:8] matches = ((recyc or {}).get("matches") or [])[:2] y = place(y, 46 + len(tl) * 11 + len(matches) * 22 + 18) y = s.titre_section(y, "Historique Rent-Ka") entete_h = (f"Suivie depuis {_fmt_ts(histo.get('premiere_observation'), jour=True)}" f" · {histo.get('jours_en_ligne', 0)} jour" f"{'s' if (histo.get('jours_en_ligne') or 0) > 1 else ''} en ligne" f" · {histo.get('modifications', 0)} modification" f"{'s' if (histo.get('modifications') or 0) > 1 else ''} observée" f"{'s' if (histo.get('modifications') or 0) > 1 else ''}") pi_, pa_ = histo.get("prix_initial"), histo.get("prix_actuel") if pi_ is not None and pa_ is not None and pi_ != pa_: var_ = histo.get("variation") entete_h += f" · {_fmt_money(pi_)} → {_fmt_money(pa_)}" if var_ is not None: entete_h += (f" ({'+' if var_ > 0 else '−'}" f"{abs(round(var_ * 100))}{NBSP}%)") c.setFont("Helvetica-Bold", 8.5) c.setFillColor(INK) c.drawString(M, y - 2, entete_h[:120]) y -= 16 ev_labels = {"description": "Description modifiée", "superficie": "Superficie modifiée", "dispo": "Disponibilité modifiée", "inclusions": "Inclusions modifiées", "photos": "Photos modifiées", "disparition": "Annonce retirée", "reapparition": "Annonce republiée"} if tl: for it in tl: c.setFont("Courier-Bold", 7) c.setFillColor(INK3) c.drawString(M, y, _fmt_ts(it.get("ts"), jour=True).upper()) c.setFont("Helvetica", 8) c.setFillColor(INK2) if it.get("type") == "prix": if it.get("prix_avant") is not None: sens = ("baissé" if it["prix_avant"] > (it.get("prix") or 0) else "monté") txt_ev = (f"Prix {sens} de {_fmt_money(it['prix_avant'])} " f"à {_fmt_money(it.get('prix'))}") else: txt_ev = f"Premier prix observé : {_fmt_money(it.get('prix'))}" else: txt_ev = ev_labels.get(it.get("type"), it.get("type") or "") c.drawString(M + 78, y, txt_ev[:95]) y -= 11 else: c.setFont("Helvetica", 8) c.setFillColor(INK2) c.drawString(M, y, "Aucune modification observée depuis la première " "synchronisation de cette annonce.") y -= 11 for m_ in matches: suite = f"Vie antérieure probable ({m_.get('confiance')}{NBSP}% de confiance)" if m_.get("prix") is not None: suite += f" — affiché {_fmt_money(m_['prix'])}" if m_.get("derniere_observation"): suite += f" jusqu'en {_fmt_ts(m_['derniere_observation'])}" c.setFont("Helvetica-Bold", 8) c.setFillColor(HexColor("#8a5a12")) c.drawString(M, y - 2, suite[:115]) y -= 10 c.setFont("Helvetica", 7) c.setFillColor(INK3) c.drawString(M, y - 2, ("signaux : " + " · ".join(m_.get("signaux") or []))[:125]) y -= 12 y -= 8 # --- Passeport de l'immeuble (ce que Rent-Ka observe du bâtiment) if imm and (imm.get("annonces_total") or 0) >= 2: y = place(y, 128) y = s.titre_section(y, "Passeport de l'immeuble") tw = (PAGE_W - 2 * M - 30) / 4 rot = imm.get("rotation") or {} pres = imm.get("pression_loyers") tuiles_im = [ (str(imm["annonces_total"]), f"annonces obs. ({imm.get('annonces_actives', 0)} actives)"), (f"≥{NBSP}{imm.get('unites_estimees', 0)}", "unités estimées"), (_fmt_money(imm.get("loyer_median")), "loyer médian (actives)"), (f"{imm['pi2_median']:.2f}".replace(".", ",") + f"{NBSP}$" if imm.get("pi2_median") is not None else "—", "médiane $/pi²"), ] for i, (val, lab) in enumerate(tuiles_im): s.tuile(M + i * (tw + 10), y - 44, tw, 38, val, lab) y -= 58 par_cc = imm.get("loyer_median_par_cc") or {} if par_cc: parts = [f"{cc}{NBSP}ch. {_fmt_money(v)}" for cc, v in sorted(par_cc.items())] c.setFont("Helvetica", 8) c.setFillColor(INK2) c.drawString(M, y, ("Loyer médian par nombre de chambres : " + " · ".join(parts))[:118]) y -= 13 c.setFont("Helvetica-Bold", 8) c.setFillColor(INK) c.drawString(M, y, "Rotation des logements :") c.setFont("Helvetica", 8) c.setFillColor(INK2) if rot.get("statut") == "calculated": c.drawString(M + 118, y, (f"{rot.get('classe', '')} — {rot.get('annonces_12m')} " f"annonces sur 12 mois pour ≥{NBSP}" f"{rot.get('unites_estimees')} unités")[:100]) else: c.drawString(M + 118, y, "données insuffisantes") y -= 12 c.setFont("Helvetica-Bold", 8) c.setFillColor(INK) c.drawString(M, y, "Pression sur les loyers :") c.setFont("Helvetica", 8) c.setFillColor(INK2) if pres: v12 = pres.get("variation_12m") or 0 c.drawString(M + 118, y, (f"{'+' if v12 > 0 else '−'}{abs(round(v12 * 100))}" f"{NBSP}% sur 12 mois — médiane des prix d'entrée " f"{_fmt_money(pres.get('mediane_12_24m'))} → " f"{_fmt_money(pres.get('mediane_12m'))} " f"({pres.get('n_12_24m')} vs {pres.get('n_12m')} annonces)")[:110]) else: c.drawString(M + 118, y, "échantillon insuffisant") y -= 12 c.setFont("Helvetica", 6.5) c.setFillColor(INK3) c.drawString(M, y - 2, (f"Immeuble suivi par Rent-Ka depuis " f"{_fmt_ts(imm.get('premiere_observation'))} — " "indicateurs calculés uniquement sur les annonces " "observées (pas un recensement du bâtiment).")[:135]) y -= 16 # --- Qui gère ce logement? (fiche Google associée + avis synchronisés) g_ = (gest or {}).get("google_maps") if gest and g_ and g_.get("statut") != "non_associe": avis_g = gest.get("avis") or {} dist_g = avis_g.get("distribution") or {} tot_g = sum(dist_g.values()) if dist_g else 0 y = place(y, 108 + (5 * 11 if tot_g else 0)) y = s.titre_section(y, "Qui gère ce logement?") c.setFont("Helvetica-Bold", 10.5) c.setFillColor(INK) c.drawString(M, y - 2, (gest.get("nom") or "")[:60]) n_act = gest.get("annonces_actives") or 0 c.setFont("Helvetica", 8) c.setFillColor(INK2) c.drawString(M + c.stringWidth((gest.get("nom") or "")[:60], "Helvetica-Bold", 10.5) + 10, y - 2, f"{n_act} annonce{'s' if n_act > 1 else ''} active" f"{'s' if n_act > 1 else ''} sur Rent-Ka") y -= 16 if g_.get("note") is not None: note_t = f"{g_['note']:.1f}".replace(".", ",") s.pilule(M, y - 10, f"Note Google {note_t} / 5", LIME, INK) c.setFont("Helvetica", 8) c.setFillColor(INK2) c.drawString(M + 110, y - 5, (f"{g_.get('nombre_avis', 0)}{NBSP}avis Google — fiche " f"« {g_.get('nom', '')} »")[:90]) y -= 22 if tot_g: bw_g = 150.0 for n_ in (5, 4, 3, 2, 1): cnt = dist_g.get(str(n_), 0) c.setFont("Courier-Bold", 7) c.setFillColor(INK2) c.drawString(M, y, f"{n_}*") c.setFillColor(SURFACE) c.setStrokeColor(INK) c.setLineWidth(0.8) c.rect(M + 22, y - 1, bw_g, 7, stroke=1, fill=1) if cnt: c.setFillColor(GREEN if n_ >= 4 else (LIME if n_ <= 2 else INK3)) c.rect(M + 22, y - 1, bw_g * cnt / tot_g, 7, stroke=0, fill=1) c.setFont("Courier-Bold", 7) c.setFillColor(INK) c.drawString(M + 22 + bw_g + 6, y, str(cnt)) y -= 11 y -= 2 stats_g = [] if avis_g.get("moyenne_12m") is not None: stats_g.append(f"moyenne des 12 derniers mois : " f"{avis_g['moyenne_12m']:.1f}".replace(".", ",") + f" ({avis_g.get('n_12m')} avis)") if avis_g.get("tendance"): stats_g.append(avis_g["tendance"]) if avis_g.get("plaintes_frequentes"): stats_g.append("plaintes récurrentes : " + ", ".join(avis_g["plaintes_frequentes"][:4])) if stats_g: y = s.paragraphe(M, y, " · ".join(stats_g), PAGE_W - 2 * M, taille=8, couleur=INK2, interligne=10, max_lignes=2) y -= 2 conf_g = round((g_.get("confiance_association") or 0) * 100) y = s.paragraphe(M, y - 2, f"Fiche Google Maps associée automatiquement " f"(confiance {conf_g}{NBSP}%). Statistiques sur " f"les {avis_g.get('n', 0)} avis synchronisés dans " "la base Rent-Ka — échantillon des plus récents, " "pas une lecture humaine.", PAGE_W - 2 * M, taille=6.5, couleur=INK3, interligne=8.5, max_lignes=3) y -= 6 # --- Registre des loyers (déclarations citoyennes à proximité) if rdl_d and rdl_d.get("n"): comp = rdl_d.get("items") or [] y = place(y, 128 + min(len(comp), 8) * 12) y = s.titre_section(y, "Registre des loyers — déclarations citoyennes") tw = (PAGE_W - 2 * M - 20) / 3 med_rec = rdl_d.get("median_recent") tuiles = [(str(rdl_d["n"]), f"loyers déclarés à <{rdl_d['radius_m']} m"), (_fmt_money(rdl_d.get("median")), "médiane du secteur"), (_fmt_money(med_rec) if med_rec else "—", f"médiane depuis 2023 ({rdl_d.get('n_recent', 0)} décl.)")] for i, (val, lab) in enumerate(tuiles): s.tuile(M + i * (tw + 10), y - 44, tw, 38, val, lab) y -= 56 rooms = rdl_d.get("by_rooms") or {} if rooms: def _rk(k: str) -> float: try: return float(k) except ValueError: return 99.0 parts = [f"{k}{NBSP}ch. {_fmt_money(v['median'])} ({v['n']})" for k, v in sorted(rooms.items(), key=lambda kv: _rk(kv[0]))] c.setFont("Helvetica", 8) c.setFillColor(INK2) c.drawString(M, y, ("Médianes par taille : " + " · ".join(parts))[:118]) y -= 14 if comp: c.setFont("Courier-Bold", 7) c.setFillColor(INK3) for lab, xoff in (("ADRESSE", 0), ("CH.", 280), ("DÉBUT DE BAIL", 320), ("DIST.", 420), ("LOYER", 480)): c.drawString(M + xoff, y, lab) y -= 4 c.setStrokeColor(INK) c.setLineWidth(1) c.line(M, y, PAGE_W - M, y) y -= 11 for it in comp[:8]: c.setFont("Helvetica", 8) c.setFillColor(INK2) c.drawString(M, y, (it.get("address") or "")[:52]) c.drawString(M + 280, y, str(it["rooms"]) if it.get("rooms") is not None else "—") c.drawString(M + 320, y, _fmt_mois(it.get("date"))) c.drawString(M + 420, y, _fmt_dist(it["dist_m"])) c.setFont("Helvetica-Bold", 8) c.setFillColor(INK) c.drawString(M + 480, y, _fmt_money(it.get("price"))) y -= 12 c.setFont("Helvetica", 6.5) c.setFillColor(INK3) c.drawString(M, y - 2, "Loyers déclarés volontairement par des locataires " "au Registre des loyers (registre-des-loyers.ca) — " "données citoyennes non vérifiées.") y -= 16 # --- Historique au TAL (décisions SOQUIJ à l'adresse) if tal_d and tal_d.get("status") in ("ok", "pending"): decs = (tal_d.get("decisions") or [])[:6] y = place(y, 70 + len(decs) * 24) y = s.titre_section(y, "Historique au Tribunal administratif du logement") if tal_d["status"] == "pending": c.setFont("Helvetica", 8.5) c.setFillColor(INK2) c.drawString(M, y - 4, "Adresse en file de vérification auprès des " "décisions publiées du TAL (résultat sur la " "fiche en ligne sous peu).") y -= 18 elif not decs: s.pilule(M, y - 12, "Aucune décision trouvée", GREEN_DEEP, LIME_SOFT, GREEN) c.setFont("Helvetica", 8.5) c.setFillColor(INK2) c.drawString(M + 130, y - 7, "Aucune décision publiée du TAL n'a été " "repérée à cette adresse.") y -= 26 else: n = tal_d.get("n") or len(decs) cl = tal_d.get("contre_locataire") or 0 c.setFont("Helvetica-Bold", 9) c.setFillColor(INK) c.drawString(M, y - 4, f"{n} décision{'s' if n > 1 else ''} publiée" f"{'s' if n > 1 else ''} à cette adresse — " f"{cl} à l'initiative du propriétaire.") y -= 20 for dec in decs: qui = {"locateur": "demande du propriétaire", "locataire": "demande du locataire"}.get( dec.get("demandeur") or "", "demandeur inconnu") c.setFont("Helvetica-Bold", 8) c.setFillColor(INK) c.drawString(M, y, _fmt_mois(dec.get("date"))) c.setFont("Helvetica", 8) c.setFillColor(INK2) suite = qui if dec.get("verdict"): suite += f" · demande {dec['verdict']}" if dec.get("tags"): suite += " · " + ", ".join(dec["tags"][:3]) c.drawString(M + 78, y, suite[:88]) c.setFont("Courier-Bold", 7) c.setFillColor(GREEN) c.drawRightString(PAGE_W - M, y, dec.get("citation") or "SOQUIJ") y -= 12 y -= 6 c.setFont("Helvetica", 6.5) c.setFillColor(INK3) c.drawString(M, y - 2, "Décisions publiques diffusées par SOQUIJ, repérées " "par correspondance d'adresse — une décision peut " "concerner un autre logement du même immeuble.") y -= 16 # --- Risque d'inondation (BDZI) if inon: zones = (inon.get("zones") or [])[:3] y = place(y, 56 + len(zones) * 12) y = s.titre_section(y, "Risque d'inondation") statut = inon.get("statut") sev = {"eleve": "récurrence élevée", "modere": "récurrence modérée", "present": "zone cartographiée"}.get(inon.get("severite") or "", "") if statut == "en_zone": s.pilule(M, y - 12, f"En zone inondable ({sev})", HexColor("#a12622"), HexColor("#fde8e8"), HexColor("#a12622")) elif statut == "a_proximite": d0 = zones[0]["distance_m"] if zones else "?" s.pilule(M, y - 12, f"Zone inondable à ~{d0} m", HexColor("#8a5a12"), AMBER_SOFT, AMBER) elif statut == "hors_zone": s.pilule(M, y - 12, "Hors des zones inondables cartographiées", GREEN_DEEP, LIME_SOFT, GREEN) else: s.pilule(M, y - 12, "Secteur non couvert par la cartographie BDZI", INK2, SURFACE, INK) y -= 26 for z in zones: c.setFont("Helvetica", 8) c.setFillColor(INK2) c.drawString(M, y, f"{z['type']} — à {z['distance_m']} m" + (f" (rapport {z['date_rapport']})" if z.get("date_rapport") else "")) y -= 12 y -= 8 # --- Qualité de l'air (RSQAQ) if air_d and air_d.get("station"): mes = air_d.get("mesures") or {} y = place(y, 44 + len(mes) * 11) y = s.titre_section(y, "Qualité de l'air") c.setFont("Helvetica", 8.5) c.setFillColor(INK2) c.drawString(M, y - 2, f"Station {air_d['station']} ({air_d['ville']}), à " + f"{air_d['distance_km']}".replace(".", ",") + f"{NBSP}km " "du logement — moyennes annuelles :") y -= 16 for pol, m_ in list(mes.items())[:5]: c.setFont("Helvetica-Bold", 8) c.setFillColor(INK) c.drawString(M, y, pol) c.setFont("Helvetica", 8) c.setFillColor(INK2) val = f"{m_['moyenne']:.1f}".replace(".", ",") if m_.get("moyenne") is not None else "—" txt = f"{val}{NBSP}{m_.get('unite', '')} ({m_.get('annee', '')})" if m_.get("ref"): txt += f" · référence {m_.get('ref_nom', '')} : {m_['ref']}" c.drawString(M + 56, y, txt[:95]) y -= 11 y -= 8 # --- Coût d'électricité (Hydro-Québec) if hyd and hyd.get("disponible"): y = place(y, 52) y = s.titre_section(y, "Coût d'électricité (Hydro-Québec)") c.setFont("Helvetica-Bold", 15) c.setFillColor(INK) mens = f"≈{NBSP}{_fmt_money(round(hyd['cout_mensuel']))} / mois" c.drawString(M, y - 8, mens) c.setFont("Helvetica", 8.5) c.setFillColor(INK2) suite = f"{_fmt_money(round(hyd['cout_annuel']))} / an" if hyd.get("kwh_annuel"): suite += f" · {round(hyd['kwh_annuel']):,}".replace(",", NBSP) + f"{NBSP}kWh/an" suite += " — estimation officielle Hydro-Québec pour cette adresse." c.drawString(M + c.stringWidth(mens, "Helvetica-Bold", 15) + 10, y - 8, suite) y -= 26 # --- Commerces et transport if com and (com.get("commerces") or com.get("transit")): entrees = ([(t["commerce"], t["nom"], t["dist_m"]) for t in (com.get("transit") or [])[:4]] + [(i["commerce"], i["nom"], i["dist_m"]) for i in (com.get("commerces") or [])[:10]]) lignes = (len(entrees) + 1) // 2 y = place(y, 36 + lignes * 13) y = s.titre_section(y, "Commerces et transport") col_w = (PAGE_W - 2 * M) / 2 for i, (cat, nom, dist) in enumerate(entrees): px = M if i % 2 == 0 else M + col_w + 10 py = y - (i // 2) * 13 c.setFont("Helvetica", 8) c.setFillColor(INK2) nomc = nom if len(nom) <= 22 else nom[:21] + "…" c.drawString(px, py, f"{cat} — {nomc}") c.setFont("Courier-Bold", 7.5) c.setFillColor(INK) c.drawRightString(px + col_w - 14, py, _fmt_dist(dist)) y -= lignes * 13 + 12 # --- Essence à proximité if gaz_d and gaz_d.get("stations"): sts = gaz_d["stations"][:3] y = place(y, 40 + len(sts) * 11) y = s.titre_section(y, "Essence à proximité") if gaz_d.get("mediane_regulier"): c.setFont("Helvetica", 8.5) c.setFillColor(INK2) c.drawString(M, y - 2, "Prix médian de l'essence régulière à <5 km : " + f"{gaz_d['mediane_regulier']}".replace(".", ",") + f"{NBSP}¢/L") y -= 14 for st in sts: c.setFont("Helvetica", 8) c.setFillColor(INK2) reg_p = (f"{st['regulier']}".replace(".", ",") + f"{NBSP}¢/L" if st.get("regulier") else "—") c.drawString(M, y, f"{st['nom']} — {_fmt_dist(st['dist_m'])} : {reg_p}" + (" · la moins chère" if st.get("moins_chere") else "")) y -= 11 y -= 8 # --- KA Scores (immeuble et environnement) if kasc and kasc.get("glob") is not None: from . import kascores as ks_mod y = place(y, 92) y = s.titre_section(y, "KA Scores") paires = [("Global", kasc.get("glob")), ("À pied", kasc.get("walk")), ("Transport", kasc.get("transit")), ("Vélo", kasc.get("bike")), ("Calme", kasc.get("calme")), ("Services", kasc.get("services"))] tw = (PAGE_W - 2 * M - 5 * 8) / 6 for i, (lab, v) in enumerate(paires): val = "—" if v is None else str(round(v)) sub = ks_mod.label(v) or "" s.tuile(M + i * (tw + 8), y - 46, tw, 42, val, f"{lab} · {sub}" if sub else lab) y -= 62 # --- Vie quotidienne en hiver (quotidien à pied, même à −20 °C) if hiv: det_h = hiv.get("detail") or [] y = place(y, 52 + len(det_h) * 11 + 14) y = s.titre_section(y, "Vie quotidienne en hiver") c.setFont("Helvetica-Bold", 17) c.setFillColor(INK) sc_t = str(hiv.get("score", "—")) c.drawString(M, y - 8, sc_t) s.pilule(M + c.stringWidth(sc_t, "Helvetica-Bold", 17) + 10, y - 12, (hiv.get("classe") or "").upper()) c.setFont("Helvetica", 8) c.setFillColor(INK3) c.drawString(M + c.stringWidth(sc_t, "Helvetica-Bold", 17) + 105, y - 7, "quotidien à pied, même à −20 °C") y -= 26 for cr_ in det_h: c.setFont("Helvetica-Bold", 8) c.setFillColor(INK) c.drawString(M, y, (cr_.get("critere") or "")[:24]) c.setFont("Helvetica", 8) c.setFillColor(INK2) if cr_.get("distance_m") is not None: val_h = f"≈{NBSP}{cr_.get('minutes')}{NBSP}min à pied" if cr_.get("nom"): val_h += f" ({cr_['nom']})" else: val_h = cr_.get("note") or "—" c.drawString(M + 90, y, val_h[:80]) c.setFont("Courier-Bold", 7.5) c.setFillColor(INK) c.drawRightString(PAGE_W - M, y, str(cr_.get("score", "—"))) y -= 11 c.setFont("Helvetica", 6.5) c.setFillColor(INK3) c.drawString(M, y - 2, (hiv.get("methode") or "")[:135]) y -= 16 # --- Le quartier if quartier: y = place(y, 270) y = s.titre_section(y, "Le quartier") demo = quartier.get("demographie") or {} tuiles = [("Revenu médian", _fmt_money(demo.get("revenu_median"))), ("Ménages locataires", f"{round(demo['pct_locataires'])}{NBSP}%" if demo.get("pct_locataires") is not None else "—"), ("Loyer moyen secteur", _fmt_money(demo.get("loyer_moyen"))), ("Âge médian", f"{round(demo['age_median'])} ans" if demo.get("age_median") is not None else "—"), ("Français à la maison", f"{round(demo['pct_francais'])}{NBSP}%" if demo.get("pct_francais") is not None else "—"), ("Diplôme universitaire", f"{round(demo['pct_univ'])}{NBSP}%" if demo.get("pct_univ") is not None else "—")] tw = (PAGE_W - 2 * M - 20) / 3 for i, (lab, val) in enumerate(tuiles): s.tuile(M + (i % 3) * (tw + 10), y - 34 - (i // 3) * 44, tw, 38, val, lab) y -= 34 + 44 + 16 prox = quartier.get("proximite") or {} for k, lab in PROX_LABELS: if k not in prox: continue v = max(0.0, min(1.0, prox[k])) c.setFont("Helvetica", 8) c.setFillColor(INK2) c.drawString(M, y, lab) bx0, bw_ = M + 130, PAGE_W - 2 * M - 160 c.setFillColor(SURFACE) c.setStrokeColor(INK) c.setLineWidth(0.8) c.roundRect(bx0, y - 1, bw_, 7, 3.5, stroke=1, fill=1) c.setFillColor(GREEN) c.roundRect(bx0, y - 1, bw_ * v, 7, 3.5, stroke=0, fill=1) c.setFont("Courier-Bold", 7.5) c.setFillColor(INK) c.drawRightString(PAGE_W - M, y, str(round(v * 100))) y -= 14 y -= 4 badges_q = [] ch = quartier.get("chaleur") if ch: if ch["classe"] <= 3: badges_q.append(("Îlot de fraîcheur", GREEN_DEEP, LIME_SOFT, GREEN)) elif ch["classe"] >= 7: badges_q.append((f"Îlot de chaleur (+{ch['ecart']:.1f} °C)".replace(".", ","), HexColor("#8a5a12"), AMBER_SOFT, AMBER)) cr = quartier.get("crime") if cr and cr.get("type") == "igc" and cr.get("indice_canada"): dlt = round(100 * (cr["indice"] - cr["indice_canada"]) / cr["indice_canada"]) badges_q.append((f"Criminalité {abs(dlt)}{NBSP}% " f"{'sous' if dlt <= 0 else 'au-dessus de'} la moyenne canadienne", INK2, SURFACE, INK)) elif cr and cr.get("type") == "points": badges_q.append((f"{cr['douze_mois']} actes criminels à <500 m (12 mois)", INK2, SURFACE, INK)) x = M for txt, fg, bg, stk in badges_q: x = s.pilule(x, y - 8, txt, fg, bg, stk) y -= 30 # --- À proximité (commodités du cache POI) if poi: lignes = (min(len(poi), 12) + 1) // 2 y = place(y, 36 + lignes * 13) y = s.titre_section(y, "À proximité") col_w = (PAGE_W - 2 * M) / 2 for i, p in enumerate(poi[:12]): px = M if i % 2 == 0 else M + col_w + 10 py = y - (i // 2) * 13 c.setFont("Helvetica", 8) c.setFillColor(INK2) lab = POI_LABELS.get(p["cat"], p["cat"]) nom_poi = p["name"][:19] + "…" if len(p["name"]) > 20 else p["name"] c.drawString(px, py, f"{lab} — {nom_poi}") c.setFont("Courier-Bold", 7.5) c.setFillColor(INK) c.drawRightString(px + col_w - 14, py, f"{_fmt_dist(p['dist_m'])} · {_marche(p['dist_m'])}") y -= lignes * 13 + 14 # --- Références : QR + gestionnaire + source y = place(y, 120) y = s.titre_section(y, "Références") qr = qrcode.make(f"https://www.rent-ka.com/listing/{uid}", box_size=4, border=1) qb = io.BytesIO() qr.save(qb, format="PNG") c.drawImage(ImageReader(io.BytesIO(qb.getvalue())), M, y - 74, 70, 70) c.setFont("Helvetica-Bold", 9) c.setFillColor(INK) c.drawString(M + 82, y - 14, f"Gestionnaire : {src_name}") c.setFont("Helvetica", 8) c.setFillColor(INK2) c.drawString(M + 82, y - 28, "Annonce originale :") c.setFillColor(GREEN) c.drawString(M + 82, y - 40, (l.get("url") or "")[:90]) c.setFillColor(INK2) c.drawString(M + 82, y - 56, "Fiche à jour en ligne (scannez le code) :") c.setFillColor(GREEN) c.drawString(M + 82, y - 68, f"www.rent-ka.com/listing/{uid}") y -= 86 c.setFont("Helvetica", 6.5) c.setFillColor(INK3) c.drawString(M, y, "Sources : Statistique Canada (2021), INSPQ (CC-BY 4.0), " "Ville de Montréal (CC-BY 4.0), OpenStreetMap, BDZI, RSQAQ, " "Hydro-Québec, registre-des-loyers.ca, SOQUIJ, gazquebec.ca.") s.pied(_PIED_FICHE, page[0]) c.save() return buf.getvalue() def s_titre_droite(c, y, x, texte): c.setFont("Helvetica-Bold", 12.5) c.setFillColor(INK) c.drawString(x, y, texte) c.setStrokeColor(LIME) c.setLineWidth(3) c.line(x, y - 4, x + c.stringWidth(texte, "Helvetica-Bold", 12.5), y - 4) return y - 18 # --------------------------------------------------------------------------- # Rapport global du marché # --------------------------------------------------------------------------- _REGIONS = [ ("Québec métro", {"Québec", "Lévis", "Saint-Augustin-de-Desmaures", "L'Ancienne-Lorette", "Pont-Rouge", "Shannon"}), ("Outaouais", {"Gatineau", "Chelsea", "Thurso", "Perkins", "Maniwaki", "Val-des-Monts"}), ("Estrie / Montérégie-Est", {"Sherbrooke", "Magog", "Orford", "East Angus", "Waterville", "Granby", "Waterloo", "Bromont", "Cowansville", "Richmond"}), ("Mauricie / Centre-du-Québec", {"Trois-Rivières", "Bécancour", "Shawinigan", "Drummondville", "Victoriaville", "Notre-Dame-du-Bon-Conseil", "Saint-Léonard-d'Aston", "Louiseville", "Nicolet", "Wickham", "Saint-Narcisse", "Saint-Nicéphore"}), ("Lanaudière / Laurentides", {"Joliette", "Saint-Jérôme", "Berthierville", "Saint-Ambroise-de-Kildare", "Saint-Gabriel-de-Brandon", "Lachute", "Brownsburg-Chatham", "Saint-Charles-Borromée", "Mirabel", "Sainte-Agathe-des-Monts", "Sainte-Thérèse", "Blainville", "Charlemagne", "Notre-Dame-des-Prairies"}), ("Bas-Saint-Laurent / Gaspésie", {"Rimouski", "Rivière-du-Loup", "Matane", "Saint-Ulric", "Amqui", "Le Bic", "New Richmond", "Carleton-sur-Mer", "Gaspé"}), ("Saguenay–Lac-Saint-Jean", {"Saguenay", "Alma", "Chicoutimi", "Jonquière", "Chambord", "La Malbaie"}), ("Abitibi-Témiscamingue", {"Rouyn-Noranda", "Val-d'Or", "Amos", "Malartic"}), ("Côte-Nord", {"Sept-Îles", "Port-Cartier", "Baie-Comeau", "Forestville"}), ("Chaudière-Appalaches", {"Saint-Georges", "Sainte-Marie", "Thetford Mines", "Montmagny", "Vallée-Jonction", "Scott", "Saint-Isidore", "La Guadeloupe", "Saint-Raphaël"}), ] def _region_de(city: str) -> str: for nom, villes in _REGIONS: if city in villes: return nom return "Grand Montréal & environs" def rapport_pdf() -> bytes: """Rapport global du marché locatif Rent-Ka (multi-pages). Tous les chiffres viennent de marketstats.compute() — la même source que la page Statistiques du site. """ from . import marketstats st = marketstats.compute() reg_file = json.loads((db.DB_PATH.parent / "sources.json").read_text("utf-8"))["sources"] noms = {s["id"]: s["name"] for s in reg_file} t = st["totals"] total, moy, med, gps = t["total"], t["avg"] or 0, t["median"] or 0, t["gps_pct"] n_sources = t["sources"] buf = io.BytesIO() c = rl_canvas.Canvas(buf, pagesize=letter) c.setTitle("Rent-Ka — Rapport du marché locatif") s = _Style(c) # ---- page 1 : couverture + grands chiffres + régions s.fond() s.logo(M, PAGE_H - 130, 42) c.setFont("Helvetica-Bold", 24) c.setFillColor(INK) c.drawString(M, PAGE_H - 185, "Rapport du marché locatif") c.setFont("Courier-Bold", 9) c.setFillColor(INK3) c.drawString(M, PAGE_H - 205, time.strftime("PROVINCE DE QUÉBEC · GÉNÉRÉ LE %Y-%m-%d · WWW.LOU-KA.COM")) grands = [(f"{total:,}".replace(",", NBSP), "annonces actives"), (str(n_sources), "gestionnaires connectés"), (_fmt_money(moy), "loyer moyen"), (_fmt_money(med), "loyer médian"), (f"{gps}{NBSP}%", "annonces géolocalisées")] tw = (PAGE_W - 2 * M - 4 * 10) / 5 for i, (val, lab) in enumerate(grands): tx = M + i * (tw + 10) ty = PAGE_H - 285 c.setFillColor(INK) c.roundRect(tx + 3, ty - 3, tw, 58, 6, stroke=0, fill=1) # ombre décalée c.setFillColor(SURFACE) c.setStrokeColor(INK) c.setLineWidth(1.5) c.roundRect(tx, ty, tw, 58, 6, stroke=1, fill=1) c.setFont("Helvetica-Bold", 15) c.setFillColor(INK) c.drawCentredString(tx + tw / 2, ty + 32, val) c.setFont("Courier", 6.3) c.setFillColor(INK3) c.drawCentredString(tx + tw / 2, ty + 14, lab.upper()) y = s.titre_section(PAGE_H - 330, "Couverture par région") c.setFont("Courier-Bold", 7) c.setFillColor(INK3) for lab, xoff in (("RÉGION", 0), ("ANNONCES", 280), ("SOURCES", 360), ("LOYER MOYEN", 430)): c.drawString(M + xoff, y, lab) y -= 4 c.setStrokeColor(INK) c.setLineWidth(1) c.line(M, y, PAGE_W - M, y) y -= 14 regions = st["by_region"] max_n = max((r["count"] for r in regions), default=1) for r in regions: c.setFont("Helvetica-Bold", 8.5) c.setFillColor(INK) c.drawString(M, y, r["key"]) c.setFillColor(LIME) c.setStrokeColor(INK) c.setLineWidth(0.7) bw = 90 * r["count"] / max_n c.roundRect(M + 180, y - 1, max(3, bw), 8, 3, stroke=1, fill=1) c.setFont("Helvetica", 8.5) c.setFillColor(INK2) c.drawRightString(M + 330, y, f"{r['count']:,}".replace(",", NBSP)) c.drawRightString(M + 400, y, str(r["sources"])) c.drawRightString(M + 500, y, _fmt_money(r["avg_price"]) if r["avg_price"] else "—") y -= 15 s.pied("Rapport généré automatiquement à partir des annonces publiques " "agrégées par Rent-Ka. Loyers : bornes 300–10 000 $.", 1) c.showPage() # ---- page 2 : histogramme des loyers + par type s.fond() y = s.entete("Rapport du marché · loyers") y = s.titre_section(y, "Distribution des loyers") hist = st["histogram"] max_c = max((h["count"] for h in hist), default=1) or 1 ch_h, ch_y = 150, y - 170 bw = (PAGE_W - 2 * M) / len(hist) for i, h in enumerate(hist): bh = ch_h * h["count"] / max_c bx = M + i * bw borne = h["hi"] is None or h["lo"] == 0 c.setFillColor(INK3 if borne else GREEN) c.setStrokeColor(INK) c.setLineWidth(0.6) c.rect(bx + 2, ch_y, bw - 4, max(1, bh), stroke=1, fill=1) if h["count"] and h["count"] > max_c * 0.06: c.setFont("Courier-Bold", 6) c.setFillColor(INK) c.drawCentredString(bx + bw / 2, ch_y + bh + 3, str(h["count"])) c.setFont("Helvetica", 5.6) c.setFillColor(INK3) lab = f"<{h['hi']}" if h["lo"] == 0 else (f"{h['lo']}+" if h["hi"] is None else str(h["lo"])) c.drawCentredString(bx + bw / 2, ch_y - 9, lab) y = ch_y - 30 y = s.titre_section(y, "Par taille de logement") c.setFont("Courier-Bold", 7) c.setFillColor(INK3) for lab, xoff in (("TAILLE", 0), ("ANNONCES", 160), ("LOYER MOYEN", 260), ("LOYER MIN", 360)): c.drawString(M + xoff, y, lab) y -= 4 c.line(M, y, PAGE_W - M, y) y -= 13 for r in st["by_type"][:9]: c.setFont("Helvetica-Bold", 8.5) c.setFillColor(INK) c.drawString(M, y, r["key"]) c.setFont("Helvetica", 8.5) c.setFillColor(INK2) c.drawRightString(M + 220, y, str(r["count"])) c.drawRightString(M + 330, y, _fmt_money(r["avg_price"]) if r["avg_price"] else "—") c.drawRightString(M + 430, y, _fmt_money(r["min_price"]) if r["min_price"] else "—") y -= 13 s.pied("Rent-Ka — agrégateur indépendant. Chaque annonce renvoie à la " "source originale du gestionnaire.", 2) c.showPage() # ---- page 3 : offre, inclusions, prix au pi², baisses de prix s.fond() y = s.entete("Rapport du marché · offre") o = st["offre"] y = s.titre_section(y, "Inclusions et caractéristiques du parc") carac = [("Chauffage inclus", o["chauffage_pct"]), ("Électricité incluse", o["electricite_pct"]), ("Eau chaude incluse", o["eau_chaude_pct"]), ("Internet inclus", o["internet_pct"]), ("Climatisation", o["clim_pct"]), ("Stationnement", o["stationnement_pct"]), ("Balcon", o["balcon_pct"]), ("Meublé", o["furnished_pct"])] for lab, v in carac: if v is None: continue c.setFont("Helvetica", 8.5) c.setFillColor(INK2) c.drawString(M, y, lab) bx0, bw_ = M + 150, PAGE_W - 2 * M - 200 c.setFillColor(SURFACE) c.setStrokeColor(INK) c.setLineWidth(0.8) c.roundRect(bx0, y - 1, bw_, 8, 4, stroke=1, fill=1) c.setFillColor(GREEN) c.roundRect(bx0, y - 1, bw_ * min(1, v / 100), 8, 4, stroke=0, fill=1) c.setFont("Courier-Bold", 8) c.setFillColor(INK) c.drawRightString(PAGE_W - M, y, f"{v}{NBSP}%") y -= 15 y -= 6 c.setFont("Helvetica", 8) c.setFillColor(INK3) c.drawString(M, y, f"Disponibles maintenant : {o['dispo_now']:,} · à date future : " f"{o['dispo_date']:,} · superficie moyenne : " f"{o['superficie_moyenne'] or '—'} pi² " f"({o['superficie_connue']:,} annonces la publient)" .replace(",", NBSP)) y -= 24 if o["prix_pi2"]: y = s.titre_section(y, "Prix au pied carré (loyer / superficie)") for r in o["prix_pi2"][:6]: c.setFont("Helvetica-Bold", 8.5) c.setFillColor(INK) c.drawString(M, y, r["key"]) c.setFont("Helvetica", 8.5) c.setFillColor(INK2) c.drawString(M + 70, y, f"{r['val']:.2f}".replace(".", ",") + f"{NBSP}$/pi² · {r['count']} annonces") y -= 13 y -= 10 if st["baisses"]: y = s.titre_section(y, "Baisses de prix récentes (30 jours)") for b in st["baisses"][:8]: c.setFont("Helvetica", 8.5) c.setFillColor(INK2) c.drawString(M, y, f"{(b['title'] or b['uid'])[:46]} — {b['city'] or ''}") c.setFont("Courier-Bold", 8) c.setFillColor(GREEN) c.drawRightString(PAGE_W - M, y, f"{_fmt_money(b['avant'])} → {_fmt_money(b['apres'])} " f"({b['pct']}{NBSP}%)".replace(".", ",")) y -= 13 s.pied("Caractéristiques dérivées des annonces publiées ; les inclusions " "non mentionnées par une source ne sont pas comptées.", 3) c.showPage() # ---- page 4 : top villes + top gestionnaires s.fond() y = s.entete("Rapport du marché · détail") y = s.titre_section(y, "Top 20 des villes") col_w = (PAGE_W - 2 * M) / 2 for i, r in enumerate(st["by_city"][:20]): vx = M if i < 10 else M + col_w vy = y - (i % 10) * 13 c.setFont("Helvetica", 8.5) c.setFillColor(INK2) c.drawString(vx, vy, f"{i + 1:>2}. {r['key']}") c.setFont("Courier-Bold", 8) c.setFillColor(INK) c.drawRightString(vx + col_w - 24, vy, f"{r['count']:,}".replace(",", NBSP)) y -= 10 * 13 + 16 y = s.titre_section(y, "Top 20 des gestionnaires") for i, r in enumerate(st["by_source"][:20]): vx = M if i < 10 else M + col_w vy = y - (i % 10) * 13 c.setFont("Helvetica", 8.5) c.setFillColor(INK2) c.drawString(vx, vy, f"{i + 1:>2}. {noms.get(r['key'], r['key'])[:34]}") c.setFont("Courier-Bold", 8) c.setFillColor(INK) c.drawRightString(vx + col_w - 24, vy, f"{r['count']:,}".replace(",", NBSP)) y -= 10 * 13 + 20 c.setFont("Helvetica", 7.5) c.setFillColor(INK3) c.drawString(M, y, "Sources de données de quartier : Statistique Canada (Recensement 2021, " "licence ouverte), INSPQ (CC-BY 4.0), Ville de Montréal (CC-BY 4.0), OpenStreetMap.") s.pied("© Rent-Ka — www.rent-ka.com · rapport non contractuel, généré automatiquement.", 4) c.save() return buf.getvalue()