SPB Git forge

spb/rent-ka

Public
8commits 1branches 0releases
7.4 MBsize
maindefault branch
19 days agolast push
Python 68.8% TypeScript 18.6% CSS 8.7% JavaScript 3.3% HTML 0.6%
65.6 KB · 1,519 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Rent-Ka — Rental listings aggregator (Canada, outside Québec)3# Author: Simon-Pierre Boucher — contact@spboucher.ai4# pdfgen.py : génération PDF à l'identité visuelle officielle Rent-Ka5#   - fiche_pdf(uid)  : fiche de propriété complète — tout ce que montre la6#     fiche web : photos, prix + analyse Rent-Ka (juste valeur, histogramme),7#     chips, description structurée, inclusions, coût réel mensuel,8#     historique Rent-Ka (+ vies antérieures), passeport de l'immeuble,9#     gestionnaire (fiche Google + avis synchronisés), registre des loyers,10#     historique TAL, inondation, qualité de l'air, coût d'électricité,11#     commerces/transport, essence, KA Scores, score hiver, quartier,12#     à proximité, QR13#   - rapport_pdf()   : rapport global du marché (totaux, régions, histogramme14#     des loyers, types, villes, gestionnaires)15#   Palette identique au site : blanc #ffffff, marine #0b1330, orange #ff6a00,16#   bleu vif #2258ff, gris pâle #f2f2f2. Polices de base (Helvetica) stylées17#   par la mise en page — aucune dépendance de police externe.18# -----------------------------------------------------------------------------19from __future__ import annotations2021import io22import json23import math24import time2526import qrcode27import requests28from reportlab.lib.colors import HexColor29from reportlab.lib.pagesizes import letter30from reportlab.lib.utils import ImageReader31from reportlab.pdfgen import canvas as rl_canvas3233from . import db3435# --- palette Rent-Ka (identité officielle) ------------------------------------36PAPER = HexColor("#ffffff")37SURFACE = HexColor("#ffffff")38INK = HexColor("#0b1330")          # bleu marine profond39INK2 = HexColor("#3e4560")40INK3 = HexColor("#8a90a5")41GREEN = HexColor("#2258ff")        # accent secondaire : bleu vif42GREEN_DEEP = HexColor("#0b1330")43LIME = HexColor("#ff6a00")         # accent d'action : orange vif44LIME_SOFT = HexColor("#fff1e6")45AMBER = HexColor("#ff6a00")46AMBER_SOFT = HexColor("#fff1e6")4748PAGE_W, PAGE_H = letter49M = 40          # marge50IMG_TIMEOUT = 651UA = "RentKaBot/1.0 (+https://www.rent-ka.com/bot; contact@spboucher.ai)"5253POI_LABELS = {54    "epicerie": "Épicerie", "depanneur": "Dépanneur", "pharmacie": "Pharmacie",55    "ecole": "École", "garderie": "Garderie", "parc": "Parc", "bus": "Arrêt de bus",56    "metro": "Métro", "gym": "Gym", "cafe": "Café", "clinique": "Clinique",57    "hopital": "Hôpital", "bibliotheque": "Bibliothèque",58}59PROX_LABELS = [60    ("prox_epicerie", "Épiceries"), ("prox_transport", "Transport en commun"),61    ("prox_parc", "Parcs"), ("prox_ecole_prim", "Écoles primaires"),62    ("prox_sante", "Soins de santé"), ("prox_pharmacie", "Pharmacies"),63]6465NBSP = " "666768def _fmt_money(v) -> str:69    return f"{v:,.0f}".replace(",", f"{NBSP}") + f"{NBSP}$" if v is not None else "—"707172def _fmt_dist(m_: float) -> str:73    return (f"{round(m_ / 10) * 10}{NBSP}m" if m_ < 100074            else f"{m_ / 1000:.1f}".replace(".", ",") + f"{NBSP}km")757677def _marche(m_: float) -> str:78    return f"≈{NBSP}{max(1, round(m_ * 1.3 / 80))}{NBSP}min à pied"798081def _fetch_image(url: str) -> ImageReader | None:82    try:83        r = requests.get(url, timeout=IMG_TIMEOUT, headers={"User-Agent": UA})84        r.raise_for_status()85        raw = r.content86        try:87            # les photos source dépassent parfois 5 Mo chacune : sans88            # redimensionnement le PDF grimpe à 30 Mo89            from PIL import Image90            im = Image.open(io.BytesIO(raw))91            im.thumbnail((1200, 1200))92            buf = io.BytesIO()93            im.convert("RGB").save(buf, "JPEG", quality=82)94            buf.seek(0)95            return ImageReader(buf)96        except Exception:97            return ImageReader(io.BytesIO(raw))98    except Exception:99        return None100101102class _Style:103    """Petits composants du style « éditorial sharp » sur un canvas."""104105    def __init__(self, c: rl_canvas.Canvas):106        self.c = c107108    def fond(self):109        self.c.setFillColor(PAPER)110        self.c.rect(0, 0, PAGE_W, PAGE_H, stroke=0, fill=1)111112    def logo(self, x: float, y: float, taille: float = 22):113        """Wordmark Rent·Ka : « Lou » encre + boîte encre avec « Ka » lime."""114        c = self.c115        c.setFont("Helvetica-Bold", taille)116        c.setFillColor(INK)117        c.drawString(x, y, "Lou")118        w = c.stringWidth("Lou", "Helvetica-Bold", taille)119        bw = c.stringWidth("Ka", "Helvetica-Bold", taille) + 8120        c.saveState()121        c.translate(x + w + 3 + bw / 2, y + taille * 0.32)122        c.rotate(-3)123        c.setFillColor(INK)124        c.roundRect(-bw / 2, -taille * 0.62, bw, taille * 1.15, 4, stroke=0, fill=1)125        c.setFillColor(LIME)126        c.drawCentredString(0, -taille * 0.30, "Ka")127        c.restoreState()128        return x + w + 6 + bw129130    def entete(self, titre: str):131        c = self.c132        self.logo(M, PAGE_H - M - 16)133        c.setFont("Courier-Bold", 8)134        c.setFillColor(INK3)135        c.drawRightString(PAGE_W - M, PAGE_H - M - 6,136                          titre.upper())137        c.drawRightString(PAGE_W - M, PAGE_H - M - 16,138                          time.strftime("GÉNÉRÉ LE %Y-%m-%d · WWW.LOU-KA.COM"))139        c.setStrokeColor(INK)140        c.setLineWidth(2)141        c.line(M, PAGE_H - M - 26, PAGE_W - M, PAGE_H - M - 26)142        return PAGE_H - M - 40   # y de départ du contenu143144    def pied(self, texte: str, page: int | None = None):145        c = self.c146        c.setStrokeColor(INK)147        c.setLineWidth(1)148        c.line(M, M + 16, PAGE_W - M, M + 16)149        c.setFont("Helvetica", 6.5)150        c.setFillColor(INK3)151        c.drawString(M, M + 6, texte[:150])152        if page:153            c.setFont("Courier-Bold", 7)154            c.drawRightString(PAGE_W - M, M + 6, f"P.{page}")155156    def pilule(self, x: float, y: float, texte: str, fg=LIME, bg=INK,157               stroke=None, taille: float = 8) -> float:158        """Chip arrondie ; retourne le x suivant."""159        c = self.c160        w = c.stringWidth(texte, "Helvetica-Bold", taille) + 14161        c.setFillColor(bg)162        if stroke:163            c.setStrokeColor(stroke)164            c.setLineWidth(1.2)165        c.roundRect(x, y, w, taille + 9, (taille + 9) / 2,166                    stroke=1 if stroke else 0, fill=1)167        c.setFillColor(fg)168        c.setFont("Helvetica-Bold", taille)169        c.drawString(x + 7, y + 5, texte)170        return x + w + 6171172    def titre_section(self, y: float, texte: str) -> float:173        c = self.c174        c.setFont("Helvetica-Bold", 12.5)175        c.setFillColor(INK)176        c.drawString(M, y, texte)177        c.setStrokeColor(LIME)178        c.setLineWidth(3)179        w = c.stringWidth(texte, "Helvetica-Bold", 12.5)180        c.line(M, y - 4, M + w, y - 4)181        return y - 18182183    def paragraphe(self, x: float, y: float, texte: str, largeur: float,184                   taille: float = 8.5, couleur=INK2, interligne: float = 11.5,185                   max_lignes: int = 100) -> float:186        """Texte multi-lignes avec découpe aux mots ; retourne le y suivant."""187        c = self.c188        c.setFont("Helvetica", taille)189        c.setFillColor(couleur)190        mots = texte.split()191        ligne = ""192        n = 0193        for mot in mots:194            essai = (ligne + " " + mot).strip()195            if c.stringWidth(essai, "Helvetica", taille) <= largeur:196                ligne = essai197            else:198                c.drawString(x, y, ligne)199                y -= interligne200                n += 1201                ligne = mot202                if n >= max_lignes - 1:203                    ligne += " …"204                    break205        if ligne:206            c.drawString(x, y, ligne)207            y -= interligne208        return y209210    def tuile(self, x: float, y: float, w: float, h: float,211              val: str, lab: str):212        """Tuile KPI blanche à ombre décalée (x, y = coin inférieur gauche)."""213        c = self.c214        c.setFillColor(INK)215        c.roundRect(x + 2.5, y - 2.5, w, h, 5, stroke=0, fill=1)216        c.setFillColor(SURFACE)217        c.setStrokeColor(INK)218        c.setLineWidth(1.2)219        c.roundRect(x, y, w, h, 5, stroke=1, fill=1)220        c.setFont("Helvetica-Bold", 12)221        c.setFillColor(INK)222        c.drawString(x + 8, y + h - 20, str(val))223        c.setFont("Courier", 6.2)224        c.setFillColor(INK3)225        c.drawString(x + 8, y + 7, lab.upper()[: max(4, int(w / 4.2))])226227228# ---------------------------------------------------------------------------229# Fiche de propriété230# ---------------------------------------------------------------------------231232_MOIS = ["janvier", "février", "mars", "avril", "mai", "juin", "juillet",233         "août", "septembre", "octobre", "novembre", "décembre"]234235236def _fmt_mois(d: str | None) -> str:237    """« 2017-11-04 » → « novembre 2017 » (sinon la valeur telle quelle)."""238    if not d:239        return "—"240    try:241        an, mois = d[:7].split("-")242        return f"{_MOIS[int(mois) - 1]} {an}"243    except Exception:244        return d245246247def _fmt_ts(ts, jour: bool = False) -> str:248    """Timestamp unix → « 6 août 2026 » (jour=True) ou « août 2026 »."""249    if not ts:250        return "—"251    t = time.localtime(ts)252    mois = f"{_MOIS[t.tm_mon - 1]} {t.tm_year}"253    return f"{t.tm_mday} {mois}" if jour else mois254255256_VERDICTS = {257    "sous": ("Sous le marché", GREEN_DEEP, LIME_SOFT, GREEN),258    "marche": ("Dans le marché", INK2, SURFACE, INK),259    "sur": ("Au-dessus du marché", HexColor("#8a5a12"), AMBER_SOFT, AMBER),260}261262_PIED_FICHE = ("Données de l'annonce publiée par le gestionnaire — Rent-Ka est "263               "un agrégateur indépendant. Enrichissements (analyse de prix, "264               "registre, TAL, environnement) fournis à titre indicatif, sans "265               "valeur juridique.")266267def fiche_pdf(uid: str) -> bytes | None:268    """PDF de la fiche du logement `uid` — reflète TOUS les blocs de la fiche269    web : prix + analyse Rent-Ka, photos, description, inclusions, registre des270    loyers, historique TAL, risque d'inondation, qualité de l'air, coût271    d'électricité, commerces/transport, essence, KA Scores, quartier,272    à proximité, références + QR. None si introuvable."""273    con = db.connect()274    row = con.execute("SELECT * FROM listings WHERE uid=?", (uid,)).fetchone()275    if row is None:276        con.close()277        return None278    l = dict(row)279    l["amenities"] = json.loads(l.get("amenities") or "[]")280    l["images"] = json.loads(l.get("images") or "[]")281    l["details"] = json.loads(l.get("details") or "{}")282    l["digest"] = json.loads(l["digest"]) if l.get("digest") else None283    poi = []284    quartier = None285    if l.get("lat") is not None:286        key = f"{round(l['lat'], 4)},{round(l['lng'], 4)}"287        pr = con.execute("SELECT pois FROM poi_cache WHERE coord_key=?", (key,)).fetchone()288        poi = json.loads(pr["pois"]) if pr else []289    reg = json.loads((db.DB_PATH.parent / "sources.json").read_text("utf-8"))["sources"]290    src_name = next((s["name"] for s in reg if s["id"] == l["source"]), l["source"])291    kasc = None292    if l.get("coord_key"):293        kr = con.execute("SELECT walk, transit, bike, calme, services,"294                         " global AS glob FROM kascores WHERE coord_key=?",295                         (l["coord_key"],)).fetchone()296        if kr is not None:297            kasc = dict(kr)298    price_history = [dict(r) for r in con.execute(299        "SELECT ts, price FROM price_log WHERE uid=? ORDER BY ts DESC LIMIT 6",300        (uid,)).fetchall()]301    con.close()302303    # --- enrichissements de la fiche (mêmes modules que les blocs du site) ---304    lat, lng = l.get("lat"), l.get("lng")305    geo = lat is not None and lng is not None306307    def _safe(fn, *a, **k):308        try:309            return fn(*a, **k)310        except Exception:311            return None312313    from . import fairvalue as fv_mod314    fv = _safe(fv_mod.explain, uid)315    # Québec-only layers removed in Rent-Ka (Hydro-Québec, rent registry,316    # TAL, flood zones, air, gas, quartier, real-cost): their PDF sections317    # are simply skipped (guards below never fire)318    rdl_d = tal_d = inon = air_d = gaz_d = com = hyd = cout = None319    # rental intelligence dossier (same modules as the site blocks)320    from . import building as bld_mod321    from . import history as hist_mod322    from . import hiver as hiv_mod323    from . import managers as mgr_mod324    from . import recycled as rec_mod325    histo = _safe(hist_mod.timeline, uid)326    recyc = _safe(rec_mod.lookup, uid)327    imm = (_safe(bld_mod.fiche, l["building_key"])328           if l.get("building_key") else None)329    hiv = _safe(hiv_mod.score, lat, lng) if geo else None330    gest = _safe(mgr_mod.fiche, l["source"])331332    buf = io.BytesIO()333    c = rl_canvas.Canvas(buf, pagesize=letter)334    c.setTitle(f"Rent-Ka — {l.get('title') or l.get('address')}")335    s = _Style(c)336    s.fond()337    y = s.entete("Fiche logement")338    page = [1]339340    def saut_page() -> float:341        s.pied(_PIED_FICHE, page[0])342        c.showPage()343        page[0] += 1344        s.fond()345        return s.entete("Fiche logement · suite")346347    def place(y_: float, besoin: float) -> float:348        """Saute de page si `besoin` points ne tiennent plus sous y_."""349        return saut_page() if (y_ - besoin) < M + 34 else y_350351    # --- bloc prix + badge marché (verdict du modèle Rent-Ka en priorité)352    prix_txt = (_fmt_money(l["price"]) + f"{NBSP}/ mois") if l.get("price") \353        else (l.get("price_label") or "Prix sur demande")354    c.setFont("Helvetica-Bold", 30)355    c.setFillColor(INK)356    c.drawString(M, y - 24, prix_txt)357    loyer_secteur = (quartier or {}).get("demographie", {}).get("loyer_moyen") \358        if quartier else None359    bx = M + c.stringWidth(prix_txt, "Helvetica-Bold", 30) + 14360    if l.get("price") and fv and fv.get("verdict") in _VERDICTS:361        dev = fv.get("deviation") or 0362        pct = f"{'+' if dev > 0 else '−'}{abs(round(dev * 100))}{NBSP}%"363        lab, fg, bg, stk = _VERDICTS[fv["verdict"]]364        s.pilule(bx, y - 22, f"{pct} vs juste valeur · {lab}", fg, bg, stk)365    elif l.get("price") and loyer_secteur:366        delta = (l["price"] - loyer_secteur) / loyer_secteur367        pct = f"{'+' if delta > 0 else '−'}{abs(round(delta * 100))}{NBSP}%"368        if delta <= -0.15:369            s.pilule(bx, y - 22, f"{pct} vs secteur · Bon deal", GREEN_DEEP, LIME_SOFT, GREEN)370        elif delta <= 0.10:371            s.pilule(bx, y - 22, f"{pct} vs secteur · Dans le marché", INK2, SURFACE, INK)372        else:373            s.pilule(bx, y - 22, f"{pct} vs secteur · Au-dessus du marché",374                     HexColor("#8a5a12"), AMBER_SOFT, AMBER)375    y -= 44376377    # --- titre + adresse378    c.setFont("Helvetica-Bold", 13)379    c.setFillColor(INK)380    c.drawString(M, y, (l.get("title") or l.get("address") or "")[:80])381    y -= 14382    c.setFont("Helvetica", 9)383    c.setFillColor(INK2)384    loc = " · ".join(x for x in (l.get("address"), l.get("sector"), l.get("city"))385                     if x and x != l.get("title"))386    c.drawString(M, y, loc[:110])387    y -= 20388389    # --- chips clés390    x = M391    chips = []392    if l.get("unit_type"):393        chips.append(l["unit_type"])394    ad = l.get("availability_date")395    if ad == "now":396        chips.append("Libre maintenant")397    elif ad:398        chips.append(f"Dispo {ad}")399    if l.get("area_sqft"):400        chips.append(f"{round(l['area_sqft'])}{NBSP}pi²")401    if l.get("furnished"):402        chips.append("Meublé")403    if l.get("pets"):404        chips.append({"oui": "Animaux acceptés", "non": "Animaux refusés",405                      "conditions": "Animaux sous conditions"}.get(l["pets"], l["pets"]))406    if l["details"].get("floor") is not None:407        chips.append(f"{l['details']['floor']}e étage")408    for chip in chips[:7]:409        x = s.pilule(x, y - 6, chip)410    y -= 30411412    # --- photos : 1 grande + 3 vignettes413    imgs = [im for im in (_fetch_image(u) for u in l["images"][:4]) if im]414    if imgs:415        big_h = 170416        big_w = big_h * 16 / 10.5417        c.setStrokeColor(INK)418        c.setLineWidth(1.5)419        try:420            c.drawImage(imgs[0], M, y - big_h, big_w, big_h,421                        preserveAspectRatio=True, anchor="c", mask="auto")422        except Exception:423            pass424        c.rect(M, y - big_h, big_w, big_h, stroke=1, fill=0)425        tx = M + big_w + 10426        th = (big_h - 16) / 3427        for i, im in enumerate(imgs[1:4]):428            ty = y - (i + 1) * th - i * 8429            try:430                c.drawImage(im, tx, ty, PAGE_W - M - tx, th,431                            preserveAspectRatio=True, anchor="c", mask="auto")432                c.rect(tx, ty, PAGE_W - M - tx, th, stroke=1, fill=0)433            except Exception:434                pass435        y -= big_h + 18436437    # --- « En bref » (digest) sur bandeau lime438    dg = l.get("digest")439    if dg and dg.get("en_bref"):440        c.setFillColor(LIME_SOFT)441        c.setStrokeColor(GREEN)442        c.setLineWidth(1.2)443        c.roundRect(M, y - 40, PAGE_W - 2 * M, 44, 6, stroke=1, fill=1)444        s.paragraphe(M + 10, y - 8, "EN BREF — " + dg["en_bref"],445                     PAGE_W - 2 * M - 20, taille=8.5, couleur=GREEN_DEEP,446                     max_lignes=3)447        y -= 54448449    # --- description (2-3 sections max) | inclusions en colonne droite450    col_g = PAGE_W * 0.56 - M451    y_desc = s.titre_section(y, "Description")452    if dg and dg.get("sections"):453        for sec in dg["sections"][:3]:454            c.setFont("Helvetica-Bold", 9)455            c.setFillColor(INK)456            c.drawString(M, y_desc, sec["titre"][:60])457            y_desc -= 12458            y_desc = s.paragraphe(M, y_desc, sec["texte"], col_g, max_lignes=6)459            y_desc -= 4460            if y_desc < 150:461                break462    elif l.get("description"):463        y_desc = s.paragraphe(M, y_desc, l["description"], col_g, max_lignes=16)464465    xr = M + col_g + 24466    y_incl = s_titre_droite(c, y, xr, "Inclusions et commodités")467    d = l["details"]468    badges = []469    inc = d.get("inclusions") or {}470    for k, lab in (("heating", "Chauffage inclus"), ("electricity", "Électricité incluse"),471                   ("hot_water", "Eau chaude incluse"), ("internet", "Internet inclus")):472        if inc.get(k):473            badges.append(lab)474    app = d.get("appliances") or {}475    if app.get("dishwasher"):476        badges.append("Lave-vaisselle")477    if app.get("washer_dryer"):478        badges.append("Laveuse-sécheuse")479    for k, lab in (("ac", "Climatisation"), ("elevator", "Ascenseur"),480                   ("balcony", "Balcon"), ("pool", "Piscine"), ("gym", "Gym"),481                   ("laundry", "Buanderie"), ("storage", "Rangement")):482        if d.get(k):483            badges.append(lab)484    if (d.get("parking") or {}).get("available"):485        badges.append("Stationnement")486    if l.get("furnished"):487        badges.append("Meublé")488    c.setFont("Helvetica", 8.5)489    for b in badges[:12]:490        c.setFillColor(GREEN)491        c.drawString(xr, y_incl, "✓")492        c.setFillColor(INK2)493        c.drawString(xr + 12, y_incl, b)494        y_incl -= 12495    autres = [a for a in l["amenities"]496              if not any(b.lower() in a.lower() or a.lower() in b.lower() for b in badges)]497    for a in autres[: max(0, 14 - len(badges))]:498        c.setFillColor(INK3)499        c.drawString(xr + 12, y_incl, a[:38])500        y_incl -= 11501502    y = min(y_desc, y_incl) - 16503504    # --- Analyse de prix Rent-Ka (juste valeur, fourchette, distribution)505    if fv and fv.get("fv"):506        hist = fv.get("histogram") or []507        y = place(y, 168 if hist else 92)508        y = s.titre_section(y, "Analyse de prix Rent-Ka")509        jv = _fmt_money(round(fv["fv"]))510        c.setFont("Helvetica-Bold", 17)511        c.setFillColor(INK)512        c.drawString(M, y - 10, jv)513        c.setFont("Helvetica", 8.5)514        c.setFillColor(INK2)515        c.drawString(M + c.stringWidth(jv, "Helvetica-Bold", 17) + 8, y - 10,516                     "/ mois — juste valeur estimée par le modèle Rent-Ka")517        meta = []518        if fv.get("fv_low") and fv.get("fv_high"):519            meta.append(f"fourchette {_fmt_money(round(fv['fv_low']))} – "520                        f"{_fmt_money(round(fv['fv_high']))}")521        if fv.get("confidence"):522            meta.append(f"confiance {fv['confidence']}")523        if fv.get("comps"):524            meta.append(f"{fv['comps']} comparables")525        if fv.get("segment_n"):526            meta.append(f"segment de {fv['segment_n']} annonces")527        c.setFont("Helvetica", 8)528        c.setFillColor(INK3)529        c.drawString(M, y - 24, " · ".join(meta)[:115])530        if l.get("price") and fv.get("verdict") in _VERDICTS:531            dev = fv.get("deviation") or 0532            pct = f"{'+' if dev > 0 else '−'}{abs(round(dev * 100))}{NBSP}%"533            lab, fg, bg, stk = _VERDICTS[fv["verdict"]]534            s.pilule(M, y - 48, f"{pct} vs juste valeur · {lab}", fg, bg, stk)535        y -= 58536        if hist:537            ch_h = 40538            lo, hi = hist[0]["x0"], hist[-1]["x1"]539            larg = PAGE_W - 2 * M540            base = y - ch_h - 6541            if hi > lo and fv.get("fv_low") and fv.get("fv_high"):542                bx0 = M + larg * max(0.0, (fv["fv_low"] - lo) / (hi - lo))543                bx1 = M + larg * min(1.0, (fv["fv_high"] - lo) / (hi - lo))544                c.setFillColor(LIME_SOFT)545                c.rect(bx0, base, max(2, bx1 - bx0), ch_h, stroke=0, fill=1)546            max_n = max((h["n"] for h in hist), default=1) or 1547            bw = larg / len(hist)548            c.setFillColor(GREEN)549            for i, h in enumerate(hist):550                bh = ch_h * h["n"] / max_n551                if bh > 0:552                    c.rect(M + i * bw + 1, base, bw - 2, max(0.6, bh),553                           stroke=0, fill=1)554            if hi > lo and l.get("price"):555                px = M + larg * (min(max(l["price"], lo), hi) - lo) / (hi - lo)556                c.setStrokeColor(LIME)557                c.setLineWidth(2)558                c.line(px, base - 3, px, base + ch_h + 3)559                c.setFont("Courier-Bold", 6.5)560                c.setFillColor(LIME)561                c.drawString(min(max(px - 28, M), PAGE_W - M - 92),562                             base + ch_h + 6,563                             f"CE LOYER · {_fmt_money(l['price'])}")564            c.setFont("Helvetica", 6.5)565            c.setFillColor(INK3)566            c.drawString(M, base - 9, _fmt_money(lo))567            c.drawRightString(PAGE_W - M, base - 9,568                              _fmt_money(hi) + " — distribution des loyers du "569                                               "segment de référence")570            y = base - 20571        ph = [p for p in price_history if p.get("price")]572        if len(ph) >= 2 and ph[0]["price"] != ph[-1]["price"]:573            c.setFont("Helvetica", 8)574            c.setFillColor(INK2)575            chn = " → ".join(_fmt_money(p["price"]) for p in reversed(ph[:4]))576            c.drawString(M, y - 2, f"Historique de prix de l'annonce : {chn}")577            y -= 16578579    # --- Coût réel mensuel (loyer + frais, chaque poste étiqueté honnêtement)580    if cout and cout.get("loyer") is not None:581        lignes_cr = cout.get("lignes") or []582        y = place(y, 58 + len(lignes_cr) * 12 + 46)583        y = s.titre_section(y, "Coût réel mensuel")584        stat_cr = {"included": ("INCLUS", GREEN), "observed": ("OBSERVÉ", INK),585                   "estimated": ("ESTIMÉ", LIME), "unknown": ("INCONNU", INK3)}586        for li in lignes_cr:587            lab, col = stat_cr.get(li.get("statut"), stat_cr["unknown"])588            c.setFont("Helvetica", 8.5)589            c.setFillColor(INK2)590            c.drawString(M, y, (li.get("poste") or "")[:46])591            c.setFont("Courier-Bold", 6.5)592            c.setFillColor(col)593            c.drawString(M + 230, y, lab)594            m_ = li.get("montant")595            if m_ is not None and m_ > 0:596                mtx = _fmt_money(m_)597            elif m_ == 0 and li.get("statut") == "included":598                mtx = f"0{NBSP}$"599            else:600                mtx = "—"601            c.setFont("Helvetica-Bold", 8.5)602            c.setFillColor(INK)603            c.drawRightString(PAGE_W - M, y, mtx)604            y -= 12605        if cout.get("total_estime") is not None:606            c.setStrokeColor(INK)607            c.setLineWidth(1.2)608            c.line(M, y + 8, PAGE_W - M, y + 8)609            c.setFont("Helvetica-Bold", 10)610            c.setFillColor(INK)611            c.drawString(M, y - 4, "Total estimé")612            tot = f"≈{NBSP}{_fmt_money(cout['total_estime'])} / mois"613            if cout.get("annuel_estime") is not None:614                tot += f"   ·   ≈{NBSP}{_fmt_money(cout['annuel_estime'])} / an"615            c.drawRightString(PAGE_W - M, y - 4, tot)616            y -= 18617        if cout.get("postes_inconnus"):618            y = s.paragraphe(M, y - 2, "Postes non chiffrables avec les données "619                                       "publiées : "620                                       + ", ".join(cout["postes_inconnus"]).lower()621                                       + " — le total réel peut être plus élevé.",622                             PAGE_W - 2 * M, taille=7.5,623                             couleur=HexColor("#8a5a12"), interligne=9.5,624                             max_lignes=3)625        pi2_d = cout.get("pi2")626        if pi2_d and pi2_d.get("valeur") is not None:627            v = f"{pi2_d['valeur']:.2f}".replace(".", ",")628            suite = f"{v}{NBSP}$/pi²"629            if pi2_d.get("percentile_secteur") is not None:630                suite += (f" — moins cher que {100 - pi2_d['percentile_secteur']}"631                          f"{NBSP}% des {pi2_d.get('n_secteur')} comparables du "632                          "secteur (~2 km)")633            elif pi2_d.get("percentile_ville") is not None:634                suite += (f" — moins cher que {100 - pi2_d['percentile_ville']}"635                          f"{NBSP}% des {pi2_d.get('n_ville')} comparables "636                          f"({pi2_d.get('portee_ville', '')})")637            c.setFont("Helvetica", 8)638            c.setFillColor(INK2)639            c.drawString(M, y - 2, suite[:120])640            y -= 12641        y -= 8642643    # --- Historique Rent-Ka (observations réelles des synchronisations)644    if histo:645        tl = (histo.get("timeline") or [])[:8]646        matches = ((recyc or {}).get("matches") or [])[:2]647        y = place(y, 46 + len(tl) * 11 + len(matches) * 22 + 18)648        y = s.titre_section(y, "Historique Rent-Ka")649        entete_h = (f"Suivie depuis {_fmt_ts(histo.get('premiere_observation'), jour=True)}"650                    f" · {histo.get('jours_en_ligne', 0)} jour"651                    f"{'s' if (histo.get('jours_en_ligne') or 0) > 1 else ''} en ligne"652                    f" · {histo.get('modifications', 0)} modification"653                    f"{'s' if (histo.get('modifications') or 0) > 1 else ''} observée"654                    f"{'s' if (histo.get('modifications') or 0) > 1 else ''}")655        pi_, pa_ = histo.get("prix_initial"), histo.get("prix_actuel")656        if pi_ is not None and pa_ is not None and pi_ != pa_:657            var_ = histo.get("variation")658            entete_h += f" · {_fmt_money(pi_)} → {_fmt_money(pa_)}"659            if var_ is not None:660                entete_h += (f" ({'+' if var_ > 0 else '−'}"661                             f"{abs(round(var_ * 100))}{NBSP}%)")662        c.setFont("Helvetica-Bold", 8.5)663        c.setFillColor(INK)664        c.drawString(M, y - 2, entete_h[:120])665        y -= 16666        ev_labels = {"description": "Description modifiée",667                     "superficie": "Superficie modifiée",668                     "dispo": "Disponibilité modifiée",669                     "inclusions": "Inclusions modifiées",670                     "photos": "Photos modifiées",671                     "disparition": "Annonce retirée",672                     "reapparition": "Annonce republiée"}673        if tl:674            for it in tl:675                c.setFont("Courier-Bold", 7)676                c.setFillColor(INK3)677                c.drawString(M, y, _fmt_ts(it.get("ts"), jour=True).upper())678                c.setFont("Helvetica", 8)679                c.setFillColor(INK2)680                if it.get("type") == "prix":681                    if it.get("prix_avant") is not None:682                        sens = ("baissé" if it["prix_avant"] > (it.get("prix") or 0)683                                else "monté")684                        txt_ev = (f"Prix {sens} de {_fmt_money(it['prix_avant'])} "685                                  f"à {_fmt_money(it.get('prix'))}")686                    else:687                        txt_ev = f"Premier prix observé : {_fmt_money(it.get('prix'))}"688                else:689                    txt_ev = ev_labels.get(it.get("type"), it.get("type") or "")690                c.drawString(M + 78, y, txt_ev[:95])691                y -= 11692        else:693            c.setFont("Helvetica", 8)694            c.setFillColor(INK2)695            c.drawString(M, y, "Aucune modification observée depuis la première "696                               "synchronisation de cette annonce.")697            y -= 11698        for m_ in matches:699            suite = f"Vie antérieure probable ({m_.get('confiance')}{NBSP}% de confiance)"700            if m_.get("prix") is not None:701                suite += f" — affiché {_fmt_money(m_['prix'])}"702            if m_.get("derniere_observation"):703                suite += f" jusqu'en {_fmt_ts(m_['derniere_observation'])}"704            c.setFont("Helvetica-Bold", 8)705            c.setFillColor(HexColor("#8a5a12"))706            c.drawString(M, y - 2, suite[:115])707            y -= 10708            c.setFont("Helvetica", 7)709            c.setFillColor(INK3)710            c.drawString(M, y - 2, ("signaux : " + " · ".join(m_.get("signaux") or []))[:125])711            y -= 12712        y -= 8713714    # --- Passeport de l'immeuble (ce que Rent-Ka observe du bâtiment)715    if imm and (imm.get("annonces_total") or 0) >= 2:716        y = place(y, 128)717        y = s.titre_section(y, "Passeport de l'immeuble")718        tw = (PAGE_W - 2 * M - 30) / 4719        rot = imm.get("rotation") or {}720        pres = imm.get("pression_loyers")721        tuiles_im = [722            (str(imm["annonces_total"]),723             f"annonces obs. ({imm.get('annonces_actives', 0)} actives)"),724            (f"≥{NBSP}{imm.get('unites_estimees', 0)}", "unités estimées"),725            (_fmt_money(imm.get("loyer_median")), "loyer médian (actives)"),726            (f"{imm['pi2_median']:.2f}".replace(".", ",") + f"{NBSP}$"727             if imm.get("pi2_median") is not None else "—", "médiane $/pi²"),728        ]729        for i, (val, lab) in enumerate(tuiles_im):730            s.tuile(M + i * (tw + 10), y - 44, tw, 38, val, lab)731        y -= 58732        par_cc = imm.get("loyer_median_par_cc") or {}733        if par_cc:734            parts = [f"{cc}{NBSP}ch. {_fmt_money(v)}"735                     for cc, v in sorted(par_cc.items())]736            c.setFont("Helvetica", 8)737            c.setFillColor(INK2)738            c.drawString(M, y, ("Loyer médian par nombre de chambres : "739                                + " · ".join(parts))[:118])740            y -= 13741        c.setFont("Helvetica-Bold", 8)742        c.setFillColor(INK)743        c.drawString(M, y, "Rotation des logements :")744        c.setFont("Helvetica", 8)745        c.setFillColor(INK2)746        if rot.get("statut") == "calculated":747            c.drawString(M + 118, y,748                         (f"{rot.get('classe', '')} — {rot.get('annonces_12m')} "749                          f"annonces sur 12 mois pour ≥{NBSP}"750                          f"{rot.get('unites_estimees')} unités")[:100])751        else:752            c.drawString(M + 118, y, "données insuffisantes")753        y -= 12754        c.setFont("Helvetica-Bold", 8)755        c.setFillColor(INK)756        c.drawString(M, y, "Pression sur les loyers :")757        c.setFont("Helvetica", 8)758        c.setFillColor(INK2)759        if pres:760            v12 = pres.get("variation_12m") or 0761            c.drawString(M + 118, y,762                         (f"{'+' if v12 > 0 else '−'}{abs(round(v12 * 100))}"763                          f"{NBSP}% sur 12 mois — médiane des prix d'entrée "764                          f"{_fmt_money(pres.get('mediane_12_24m'))} → "765                          f"{_fmt_money(pres.get('mediane_12m'))} "766                          f"({pres.get('n_12_24m')} vs {pres.get('n_12m')} annonces)")[:110])767        else:768            c.drawString(M + 118, y, "échantillon insuffisant")769        y -= 12770        c.setFont("Helvetica", 6.5)771        c.setFillColor(INK3)772        c.drawString(M, y - 2, (f"Immeuble suivi par Rent-Ka depuis "773                                f"{_fmt_ts(imm.get('premiere_observation'))} — "774                                "indicateurs calculés uniquement sur les annonces "775                                "observées (pas un recensement du bâtiment).")[:135])776        y -= 16777778    # --- Qui gère ce logement? (fiche Google associée + avis synchronisés)779    g_ = (gest or {}).get("google_maps")780    if gest and g_ and g_.get("statut") != "non_associe":781        avis_g = gest.get("avis") or {}782        dist_g = avis_g.get("distribution") or {}783        tot_g = sum(dist_g.values()) if dist_g else 0784        y = place(y, 108 + (5 * 11 if tot_g else 0))785        y = s.titre_section(y, "Qui gère ce logement?")786        c.setFont("Helvetica-Bold", 10.5)787        c.setFillColor(INK)788        c.drawString(M, y - 2, (gest.get("nom") or "")[:60])789        n_act = gest.get("annonces_actives") or 0790        c.setFont("Helvetica", 8)791        c.setFillColor(INK2)792        c.drawString(M + c.stringWidth((gest.get("nom") or "")[:60],793                                       "Helvetica-Bold", 10.5) + 10, y - 2,794                     f"{n_act} annonce{'s' if n_act > 1 else ''} active"795                     f"{'s' if n_act > 1 else ''} sur Rent-Ka")796        y -= 16797        if g_.get("note") is not None:798            note_t = f"{g_['note']:.1f}".replace(".", ",")799            s.pilule(M, y - 10, f"Note Google {note_t} / 5", LIME, INK)800            c.setFont("Helvetica", 8)801            c.setFillColor(INK2)802            c.drawString(M + 110, y - 5,803                         (f"{g_.get('nombre_avis', 0)}{NBSP}avis Google — fiche "804                          f"« {g_.get('nom', '')} »")[:90])805            y -= 22806        if tot_g:807            bw_g = 150.0808            for n_ in (5, 4, 3, 2, 1):809                cnt = dist_g.get(str(n_), 0)810                c.setFont("Courier-Bold", 7)811                c.setFillColor(INK2)812                c.drawString(M, y, f"{n_}*")813                c.setFillColor(SURFACE)814                c.setStrokeColor(INK)815                c.setLineWidth(0.8)816                c.rect(M + 22, y - 1, bw_g, 7, stroke=1, fill=1)817                if cnt:818                    c.setFillColor(GREEN if n_ >= 4 else (LIME if n_ <= 2 else INK3))819                    c.rect(M + 22, y - 1, bw_g * cnt / tot_g, 7, stroke=0, fill=1)820                c.setFont("Courier-Bold", 7)821                c.setFillColor(INK)822                c.drawString(M + 22 + bw_g + 6, y, str(cnt))823                y -= 11824            y -= 2825        stats_g = []826        if avis_g.get("moyenne_12m") is not None:827            stats_g.append(f"moyenne des 12 derniers mois : "828                           f"{avis_g['moyenne_12m']:.1f}".replace(".", ",")829                           + f" ({avis_g.get('n_12m')} avis)")830        if avis_g.get("tendance"):831            stats_g.append(avis_g["tendance"])832        if avis_g.get("plaintes_frequentes"):833            stats_g.append("plaintes récurrentes : "834                           + ", ".join(avis_g["plaintes_frequentes"][:4]))835        if stats_g:836            y = s.paragraphe(M, y, " · ".join(stats_g), PAGE_W - 2 * M,837                             taille=8, couleur=INK2, interligne=10,838                             max_lignes=2)839            y -= 2840        conf_g = round((g_.get("confiance_association") or 0) * 100)841        y = s.paragraphe(M, y - 2, f"Fiche Google Maps associée automatiquement "842                                   f"(confiance {conf_g}{NBSP}%). Statistiques sur "843                                   f"les {avis_g.get('n', 0)} avis synchronisés dans "844                                   "la base Rent-Ka — échantillon des plus récents, "845                                   "pas une lecture humaine.",846                         PAGE_W - 2 * M, taille=6.5, couleur=INK3,847                         interligne=8.5, max_lignes=3)848        y -= 6849850    # --- Registre des loyers (déclarations citoyennes à proximité)851    if rdl_d and rdl_d.get("n"):852        comp = rdl_d.get("items") or []853        y = place(y, 128 + min(len(comp), 8) * 12)854        y = s.titre_section(y, "Registre des loyers — déclarations citoyennes")855        tw = (PAGE_W - 2 * M - 20) / 3856        med_rec = rdl_d.get("median_recent")857        tuiles = [(str(rdl_d["n"]), f"loyers déclarés à <{rdl_d['radius_m']} m"),858                  (_fmt_money(rdl_d.get("median")), "médiane du secteur"),859                  (_fmt_money(med_rec) if med_rec else "—",860                   f"médiane depuis 2023 ({rdl_d.get('n_recent', 0)} décl.)")]861        for i, (val, lab) in enumerate(tuiles):862            s.tuile(M + i * (tw + 10), y - 44, tw, 38, val, lab)863        y -= 56864        rooms = rdl_d.get("by_rooms") or {}865        if rooms:866            def _rk(k: str) -> float:867                try:868                    return float(k)869                except ValueError:870                    return 99.0871            parts = [f"{k}{NBSP}ch. {_fmt_money(v['median'])} ({v['n']})"872                     for k, v in sorted(rooms.items(), key=lambda kv: _rk(kv[0]))]873            c.setFont("Helvetica", 8)874            c.setFillColor(INK2)875            c.drawString(M, y, ("Médianes par taille : " + " · ".join(parts))[:118])876            y -= 14877        if comp:878            c.setFont("Courier-Bold", 7)879            c.setFillColor(INK3)880            for lab, xoff in (("ADRESSE", 0), ("CH.", 280), ("DÉBUT DE BAIL", 320),881                              ("DIST.", 420), ("LOYER", 480)):882                c.drawString(M + xoff, y, lab)883            y -= 4884            c.setStrokeColor(INK)885            c.setLineWidth(1)886            c.line(M, y, PAGE_W - M, y)887            y -= 11888            for it in comp[:8]:889                c.setFont("Helvetica", 8)890                c.setFillColor(INK2)891                c.drawString(M, y, (it.get("address") or "")[:52])892                c.drawString(M + 280, y, str(it["rooms"]) if it.get("rooms") is not None else "—")893                c.drawString(M + 320, y, _fmt_mois(it.get("date")))894                c.drawString(M + 420, y, _fmt_dist(it["dist_m"]))895                c.setFont("Helvetica-Bold", 8)896                c.setFillColor(INK)897                c.drawString(M + 480, y, _fmt_money(it.get("price")))898                y -= 12899        c.setFont("Helvetica", 6.5)900        c.setFillColor(INK3)901        c.drawString(M, y - 2, "Loyers déclarés volontairement par des locataires "902                               "au Registre des loyers (registre-des-loyers.ca) — "903                               "données citoyennes non vérifiées.")904        y -= 16905906    # --- Historique au TAL (décisions SOQUIJ à l'adresse)907    if tal_d and tal_d.get("status") in ("ok", "pending"):908        decs = (tal_d.get("decisions") or [])[:6]909        y = place(y, 70 + len(decs) * 24)910        y = s.titre_section(y, "Historique au Tribunal administratif du logement")911        if tal_d["status"] == "pending":912            c.setFont("Helvetica", 8.5)913            c.setFillColor(INK2)914            c.drawString(M, y - 4, "Adresse en file de vérification auprès des "915                                   "décisions publiées du TAL (résultat sur la "916                                   "fiche en ligne sous peu).")917            y -= 18918        elif not decs:919            s.pilule(M, y - 12, "Aucune décision trouvée", GREEN_DEEP,920                     LIME_SOFT, GREEN)921            c.setFont("Helvetica", 8.5)922            c.setFillColor(INK2)923            c.drawString(M + 130, y - 7, "Aucune décision publiée du TAL n'a été "924                                         "repérée à cette adresse.")925            y -= 26926        else:927            n = tal_d.get("n") or len(decs)928            cl = tal_d.get("contre_locataire") or 0929            c.setFont("Helvetica-Bold", 9)930            c.setFillColor(INK)931            c.drawString(M, y - 4,932                         f"{n} décision{'s' if n > 1 else ''} publiée"933                         f"{'s' if n > 1 else ''} à cette adresse — "934                         f"{cl} à l'initiative du propriétaire.")935            y -= 20936            for dec in decs:937                qui = {"locateur": "demande du propriétaire",938                       "locataire": "demande du locataire"}.get(939                    dec.get("demandeur") or "", "demandeur inconnu")940                c.setFont("Helvetica-Bold", 8)941                c.setFillColor(INK)942                c.drawString(M, y, _fmt_mois(dec.get("date")))943                c.setFont("Helvetica", 8)944                c.setFillColor(INK2)945                suite = qui946                if dec.get("verdict"):947                    suite += f" · demande {dec['verdict']}"948                if dec.get("tags"):949                    suite += " · " + ", ".join(dec["tags"][:3])950                c.drawString(M + 78, y, suite[:88])951                c.setFont("Courier-Bold", 7)952                c.setFillColor(GREEN)953                c.drawRightString(PAGE_W - M, y, dec.get("citation") or "SOQUIJ")954                y -= 12955            y -= 6956        c.setFont("Helvetica", 6.5)957        c.setFillColor(INK3)958        c.drawString(M, y - 2, "Décisions publiques diffusées par SOQUIJ, repérées "959                               "par correspondance d'adresse — une décision peut "960                               "concerner un autre logement du même immeuble.")961        y -= 16962963    # --- Risque d'inondation (BDZI)964    if inon:965        zones = (inon.get("zones") or [])[:3]966        y = place(y, 56 + len(zones) * 12)967        y = s.titre_section(y, "Risque d'inondation")968        statut = inon.get("statut")969        sev = {"eleve": "récurrence élevée", "modere": "récurrence modérée",970               "present": "zone cartographiée"}.get(inon.get("severite") or "", "")971        if statut == "en_zone":972            s.pilule(M, y - 12, f"En zone inondable ({sev})",973                     HexColor("#a12622"), HexColor("#fde8e8"), HexColor("#a12622"))974        elif statut == "a_proximite":975            d0 = zones[0]["distance_m"] if zones else "?"976            s.pilule(M, y - 12, f"Zone inondable à ~{d0} m",977                     HexColor("#8a5a12"), AMBER_SOFT, AMBER)978        elif statut == "hors_zone":979            s.pilule(M, y - 12, "Hors des zones inondables cartographiées",980                     GREEN_DEEP, LIME_SOFT, GREEN)981        else:982            s.pilule(M, y - 12, "Secteur non couvert par la cartographie BDZI",983                     INK2, SURFACE, INK)984        y -= 26985        for z in zones:986            c.setFont("Helvetica", 8)987            c.setFillColor(INK2)988            c.drawString(M, y, f"{z['type']} — à {z['distance_m']} m"989                               + (f" (rapport {z['date_rapport']})"990                                  if z.get("date_rapport") else ""))991            y -= 12992        y -= 8993994    # --- Qualité de l'air (RSQAQ)995    if air_d and air_d.get("station"):996        mes = air_d.get("mesures") or {}997        y = place(y, 44 + len(mes) * 11)998        y = s.titre_section(y, "Qualité de l'air")999        c.setFont("Helvetica", 8.5)1000        c.setFillColor(INK2)1001        c.drawString(M, y - 2,1002                     f"Station {air_d['station']} ({air_d['ville']}), à "1003                     + f"{air_d['distance_km']}".replace(".", ",") + f"{NBSP}km "1004                     "du logement — moyennes annuelles :")1005        y -= 161006        for pol, m_ in list(mes.items())[:5]:1007            c.setFont("Helvetica-Bold", 8)1008            c.setFillColor(INK)1009            c.drawString(M, y, pol)1010            c.setFont("Helvetica", 8)1011            c.setFillColor(INK2)1012            val = f"{m_['moyenne']:.1f}".replace(".", ",") if m_.get("moyenne") is not None else "—"1013            txt = f"{val}{NBSP}{m_.get('unite', '')} ({m_.get('annee', '')})"1014            if m_.get("ref"):1015                txt += f" · référence {m_.get('ref_nom', '')} : {m_['ref']}"1016            c.drawString(M + 56, y, txt[:95])1017            y -= 111018        y -= 810191020    # --- Coût d'électricité (Hydro-Québec)1021    if hyd and hyd.get("disponible"):1022        y = place(y, 52)1023        y = s.titre_section(y, "Coût d'électricité (Hydro-Québec)")1024        c.setFont("Helvetica-Bold", 15)1025        c.setFillColor(INK)1026        mens = f"≈{NBSP}{_fmt_money(round(hyd['cout_mensuel']))} / mois"1027        c.drawString(M, y - 8, mens)1028        c.setFont("Helvetica", 8.5)1029        c.setFillColor(INK2)1030        suite = f"{_fmt_money(round(hyd['cout_annuel']))} / an"1031        if hyd.get("kwh_annuel"):1032            suite += f" · {round(hyd['kwh_annuel']):,}".replace(",", NBSP) + f"{NBSP}kWh/an"1033        suite += " — estimation officielle Hydro-Québec pour cette adresse."1034        c.drawString(M + c.stringWidth(mens, "Helvetica-Bold", 15) + 10, y - 8, suite)1035        y -= 2610361037    # --- Commerces et transport1038    if com and (com.get("commerces") or com.get("transit")):1039        entrees = ([(t["commerce"], t["nom"], t["dist_m"])1040                    for t in (com.get("transit") or [])[:4]]1041                   + [(i["commerce"], i["nom"], i["dist_m"])1042                      for i in (com.get("commerces") or [])[:10]])1043        lignes = (len(entrees) + 1) // 21044        y = place(y, 36 + lignes * 13)1045        y = s.titre_section(y, "Commerces et transport")1046        col_w = (PAGE_W - 2 * M) / 21047        for i, (cat, nom, dist) in enumerate(entrees):1048            px = M if i % 2 == 0 else M + col_w + 101049            py = y - (i // 2) * 131050            c.setFont("Helvetica", 8)1051            c.setFillColor(INK2)1052            nomc = nom if len(nom) <= 22 else nom[:21] + "…"1053            c.drawString(px, py, f"{cat} — {nomc}")1054            c.setFont("Courier-Bold", 7.5)1055            c.setFillColor(INK)1056            c.drawRightString(px + col_w - 14, py, _fmt_dist(dist))1057        y -= lignes * 13 + 1210581059    # --- Essence à proximité1060    if gaz_d and gaz_d.get("stations"):1061        sts = gaz_d["stations"][:3]1062        y = place(y, 40 + len(sts) * 11)1063        y = s.titre_section(y, "Essence à proximité")1064        if gaz_d.get("mediane_regulier"):1065            c.setFont("Helvetica", 8.5)1066            c.setFillColor(INK2)1067            c.drawString(M, y - 2, "Prix médian de l'essence régulière à <5 km : "1068                         + f"{gaz_d['mediane_regulier']}".replace(".", ",")1069                         + f"{NBSP}¢/L")1070            y -= 141071        for st in sts:1072            c.setFont("Helvetica", 8)1073            c.setFillColor(INK2)1074            reg_p = (f"{st['regulier']}".replace(".", ",") + f"{NBSP}¢/L"1075                     if st.get("regulier") else "—")1076            c.drawString(M, y, f"{st['nom']} — {_fmt_dist(st['dist_m'])} : {reg_p}"1077                               + (" · la moins chère" if st.get("moins_chere") else ""))1078            y -= 111079        y -= 810801081    # --- KA Scores (immeuble et environnement)1082    if kasc and kasc.get("glob") is not None:1083        from . import kascores as ks_mod1084        y = place(y, 92)1085        y = s.titre_section(y, "KA Scores")1086        paires = [("Global", kasc.get("glob")), ("À pied", kasc.get("walk")),1087                  ("Transport", kasc.get("transit")), ("Vélo", kasc.get("bike")),1088                  ("Calme", kasc.get("calme")), ("Services", kasc.get("services"))]1089        tw = (PAGE_W - 2 * M - 5 * 8) / 61090        for i, (lab, v) in enumerate(paires):1091            val = "—" if v is None else str(round(v))1092            sub = ks_mod.label(v) or ""1093            s.tuile(M + i * (tw + 8), y - 46, tw, 42, val,1094                    f"{lab} · {sub}" if sub else lab)1095        y -= 6210961097    # --- Vie quotidienne en hiver (quotidien à pied, même à −20 °C)1098    if hiv:1099        det_h = hiv.get("detail") or []1100        y = place(y, 52 + len(det_h) * 11 + 14)1101        y = s.titre_section(y, "Vie quotidienne en hiver")1102        c.setFont("Helvetica-Bold", 17)1103        c.setFillColor(INK)1104        sc_t = str(hiv.get("score", "—"))1105        c.drawString(M, y - 8, sc_t)1106        s.pilule(M + c.stringWidth(sc_t, "Helvetica-Bold", 17) + 10, y - 12,1107                 (hiv.get("classe") or "").upper())1108        c.setFont("Helvetica", 8)1109        c.setFillColor(INK3)1110        c.drawString(M + c.stringWidth(sc_t, "Helvetica-Bold", 17) + 105, y - 7,1111                     "quotidien à pied, même à −20 °C")1112        y -= 261113        for cr_ in det_h:1114            c.setFont("Helvetica-Bold", 8)1115            c.setFillColor(INK)1116            c.drawString(M, y, (cr_.get("critere") or "")[:24])1117            c.setFont("Helvetica", 8)1118            c.setFillColor(INK2)1119            if cr_.get("distance_m") is not None:1120                val_h = f"≈{NBSP}{cr_.get('minutes')}{NBSP}min à pied"1121                if cr_.get("nom"):1122                    val_h += f" ({cr_['nom']})"1123            else:1124                val_h = cr_.get("note") or "—"1125            c.drawString(M + 90, y, val_h[:80])1126            c.setFont("Courier-Bold", 7.5)1127            c.setFillColor(INK)1128            c.drawRightString(PAGE_W - M, y, str(cr_.get("score", "—")))1129            y -= 111130        c.setFont("Helvetica", 6.5)1131        c.setFillColor(INK3)1132        c.drawString(M, y - 2, (hiv.get("methode") or "")[:135])1133        y -= 1611341135    # --- Le quartier1136    if quartier:1137        y = place(y, 270)1138        y = s.titre_section(y, "Le quartier")1139        demo = quartier.get("demographie") or {}1140        tuiles = [("Revenu médian", _fmt_money(demo.get("revenu_median"))),1141                  ("Ménages locataires", f"{round(demo['pct_locataires'])}{NBSP}%" if demo.get("pct_locataires") is not None else "—"),1142                  ("Loyer moyen secteur", _fmt_money(demo.get("loyer_moyen"))),1143                  ("Âge médian", f"{round(demo['age_median'])} ans" if demo.get("age_median") is not None else "—"),1144                  ("Français à la maison", f"{round(demo['pct_francais'])}{NBSP}%" if demo.get("pct_francais") is not None else "—"),1145                  ("Diplôme universitaire", f"{round(demo['pct_univ'])}{NBSP}%" if demo.get("pct_univ") is not None else "—")]1146        tw = (PAGE_W - 2 * M - 20) / 31147        for i, (lab, val) in enumerate(tuiles):1148            s.tuile(M + (i % 3) * (tw + 10), y - 34 - (i // 3) * 44,1149                    tw, 38, val, lab)1150        y -= 34 + 44 + 1611511152        prox = quartier.get("proximite") or {}1153        for k, lab in PROX_LABELS:1154            if k not in prox:1155                continue1156            v = max(0.0, min(1.0, prox[k]))1157            c.setFont("Helvetica", 8)1158            c.setFillColor(INK2)1159            c.drawString(M, y, lab)1160            bx0, bw_ = M + 130, PAGE_W - 2 * M - 1601161            c.setFillColor(SURFACE)1162            c.setStrokeColor(INK)1163            c.setLineWidth(0.8)1164            c.roundRect(bx0, y - 1, bw_, 7, 3.5, stroke=1, fill=1)1165            c.setFillColor(GREEN)1166            c.roundRect(bx0, y - 1, bw_ * v, 7, 3.5, stroke=0, fill=1)1167            c.setFont("Courier-Bold", 7.5)1168            c.setFillColor(INK)1169            c.drawRightString(PAGE_W - M, y, str(round(v * 100)))1170            y -= 141171        y -= 41172        badges_q = []1173        ch = quartier.get("chaleur")1174        if ch:1175            if ch["classe"] <= 3:1176                badges_q.append(("Îlot de fraîcheur", GREEN_DEEP, LIME_SOFT, GREEN))1177            elif ch["classe"] >= 7:1178                badges_q.append((f"Îlot de chaleur (+{ch['ecart']:.1f} °C)".replace(".", ","),1179                                 HexColor("#8a5a12"), AMBER_SOFT, AMBER))1180        cr = quartier.get("crime")1181        if cr and cr.get("type") == "igc" and cr.get("indice_canada"):1182            dlt = round(100 * (cr["indice"] - cr["indice_canada"]) / cr["indice_canada"])1183            badges_q.append((f"Criminalité {abs(dlt)}{NBSP}% "1184                             f"{'sous' if dlt <= 0 else 'au-dessus de'} la moyenne canadienne",1185                             INK2, SURFACE, INK))1186        elif cr and cr.get("type") == "points":1187            badges_q.append((f"{cr['douze_mois']} actes criminels à <500 m (12 mois)",1188                             INK2, SURFACE, INK))1189        x = M1190        for txt, fg, bg, stk in badges_q:1191            x = s.pilule(x, y - 8, txt, fg, bg, stk)1192        y -= 3011931194    # --- À proximité (commodités du cache POI)1195    if poi:1196        lignes = (min(len(poi), 12) + 1) // 21197        y = place(y, 36 + lignes * 13)1198        y = s.titre_section(y, "À proximité")1199        col_w = (PAGE_W - 2 * M) / 21200        for i, p in enumerate(poi[:12]):1201            px = M if i % 2 == 0 else M + col_w + 101202            py = y - (i // 2) * 131203            c.setFont("Helvetica", 8)1204            c.setFillColor(INK2)1205            lab = POI_LABELS.get(p["cat"], p["cat"])1206            nom_poi = p["name"][:19] + "…" if len(p["name"]) > 20 else p["name"]1207            c.drawString(px, py, f"{lab} — {nom_poi}")1208            c.setFont("Courier-Bold", 7.5)1209            c.setFillColor(INK)1210            c.drawRightString(px + col_w - 14, py,1211                              f"{_fmt_dist(p['dist_m'])} · {_marche(p['dist_m'])}")1212        y -= lignes * 13 + 1412131214    # --- Références : QR + gestionnaire + source1215    y = place(y, 120)1216    y = s.titre_section(y, "Références")1217    qr = qrcode.make(f"https://www.rent-ka.com/listing/{uid}", box_size=4, border=1)1218    qb = io.BytesIO()1219    qr.save(qb, format="PNG")1220    c.drawImage(ImageReader(io.BytesIO(qb.getvalue())), M, y - 74, 70, 70)1221    c.setFont("Helvetica-Bold", 9)1222    c.setFillColor(INK)1223    c.drawString(M + 82, y - 14, f"Gestionnaire : {src_name}")1224    c.setFont("Helvetica", 8)1225    c.setFillColor(INK2)1226    c.drawString(M + 82, y - 28, "Annonce originale :")1227    c.setFillColor(GREEN)1228    c.drawString(M + 82, y - 40, (l.get("url") or "")[:90])1229    c.setFillColor(INK2)1230    c.drawString(M + 82, y - 56, "Fiche à jour en ligne (scannez le code) :")1231    c.setFillColor(GREEN)1232    c.drawString(M + 82, y - 68, f"www.rent-ka.com/listing/{uid}")1233    y -= 861234    c.setFont("Helvetica", 6.5)1235    c.setFillColor(INK3)1236    c.drawString(M, y, "Sources : Statistique Canada (2021), INSPQ (CC-BY 4.0), "1237                       "Ville de Montréal (CC-BY 4.0), OpenStreetMap, BDZI, RSQAQ, "1238                       "Hydro-Québec, registre-des-loyers.ca, SOQUIJ, gazquebec.ca.")12391240    s.pied(_PIED_FICHE, page[0])1241    c.save()1242    return buf.getvalue()124312441245def s_titre_droite(c, y, x, texte):1246    c.setFont("Helvetica-Bold", 12.5)1247    c.setFillColor(INK)1248    c.drawString(x, y, texte)1249    c.setStrokeColor(LIME)1250    c.setLineWidth(3)1251    c.line(x, y - 4, x + c.stringWidth(texte, "Helvetica-Bold", 12.5), y - 4)1252    return y - 18125312541255# ---------------------------------------------------------------------------1256# Rapport global du marché1257# ---------------------------------------------------------------------------12581259_REGIONS = [1260    ("Québec métro", {"Québec", "Lévis", "Saint-Augustin-de-Desmaures", "L'Ancienne-Lorette", "Pont-Rouge", "Shannon"}),1261    ("Outaouais", {"Gatineau", "Chelsea", "Thurso", "Perkins", "Maniwaki", "Val-des-Monts"}),1262    ("Estrie / Montérégie-Est", {"Sherbrooke", "Magog", "Orford", "East Angus", "Waterville", "Granby", "Waterloo", "Bromont", "Cowansville", "Richmond"}),1263    ("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"}),1264    ("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"}),1265    ("Bas-Saint-Laurent / Gaspésie", {"Rimouski", "Rivière-du-Loup", "Matane", "Saint-Ulric", "Amqui", "Le Bic", "New Richmond", "Carleton-sur-Mer", "Gaspé"}),1266    ("Saguenay–Lac-Saint-Jean", {"Saguenay", "Alma", "Chicoutimi", "Jonquière", "Chambord", "La Malbaie"}),1267    ("Abitibi-Témiscamingue", {"Rouyn-Noranda", "Val-d'Or", "Amos", "Malartic"}),1268    ("Côte-Nord", {"Sept-Îles", "Port-Cartier", "Baie-Comeau", "Forestville"}),1269    ("Chaudière-Appalaches", {"Saint-Georges", "Sainte-Marie", "Thetford Mines", "Montmagny", "Vallée-Jonction", "Scott", "Saint-Isidore", "La Guadeloupe", "Saint-Raphaël"}),1270]127112721273def _region_de(city: str) -> str:1274    for nom, villes in _REGIONS:1275        if city in villes:1276            return nom1277    return "Grand Montréal & environs"127812791280def rapport_pdf() -> bytes:1281    """Rapport global du marché locatif Rent-Ka (multi-pages).12821283    Tous les chiffres viennent de marketstats.compute() — la même source1284    que la page Statistiques du site.1285    """1286    from . import marketstats1287    st = marketstats.compute()1288    reg_file = json.loads((db.DB_PATH.parent / "sources.json").read_text("utf-8"))["sources"]1289    noms = {s["id"]: s["name"] for s in reg_file}12901291    t = st["totals"]1292    total, moy, med, gps = t["total"], t["avg"] or 0, t["median"] or 0, t["gps_pct"]1293    n_sources = t["sources"]12941295    buf = io.BytesIO()1296    c = rl_canvas.Canvas(buf, pagesize=letter)1297    c.setTitle("Rent-Ka — Rapport du marché locatif")1298    s = _Style(c)12991300    # ---- page 1 : couverture + grands chiffres + régions1301    s.fond()1302    s.logo(M, PAGE_H - 130, 42)1303    c.setFont("Helvetica-Bold", 24)1304    c.setFillColor(INK)1305    c.drawString(M, PAGE_H - 185, "Rapport du marché locatif")1306    c.setFont("Courier-Bold", 9)1307    c.setFillColor(INK3)1308    c.drawString(M, PAGE_H - 205,1309                 time.strftime("PROVINCE DE QUÉBEC · GÉNÉRÉ LE %Y-%m-%d · WWW.LOU-KA.COM"))13101311    grands = [(f"{total:,}".replace(",", NBSP), "annonces actives"),1312              (str(n_sources), "gestionnaires connectés"),1313              (_fmt_money(moy), "loyer moyen"),1314              (_fmt_money(med), "loyer médian"),1315              (f"{gps}{NBSP}%", "annonces géolocalisées")]1316    tw = (PAGE_W - 2 * M - 4 * 10) / 51317    for i, (val, lab) in enumerate(grands):1318        tx = M + i * (tw + 10)1319        ty = PAGE_H - 2851320        c.setFillColor(INK)1321        c.roundRect(tx + 3, ty - 3, tw, 58, 6, stroke=0, fill=1)   # ombre décalée1322        c.setFillColor(SURFACE)1323        c.setStrokeColor(INK)1324        c.setLineWidth(1.5)1325        c.roundRect(tx, ty, tw, 58, 6, stroke=1, fill=1)1326        c.setFont("Helvetica-Bold", 15)1327        c.setFillColor(INK)1328        c.drawCentredString(tx + tw / 2, ty + 32, val)1329        c.setFont("Courier", 6.3)1330        c.setFillColor(INK3)1331        c.drawCentredString(tx + tw / 2, ty + 14, lab.upper())13321333    y = s.titre_section(PAGE_H - 330, "Couverture par région")1334    c.setFont("Courier-Bold", 7)1335    c.setFillColor(INK3)1336    for lab, xoff in (("RÉGION", 0), ("ANNONCES", 280), ("SOURCES", 360),1337                      ("LOYER MOYEN", 430)):1338        c.drawString(M + xoff, y, lab)1339    y -= 41340    c.setStrokeColor(INK)1341    c.setLineWidth(1)1342    c.line(M, y, PAGE_W - M, y)1343    y -= 141344    regions = st["by_region"]1345    max_n = max((r["count"] for r in regions), default=1)1346    for r in regions:1347        c.setFont("Helvetica-Bold", 8.5)1348        c.setFillColor(INK)1349        c.drawString(M, y, r["key"])1350        c.setFillColor(LIME)1351        c.setStrokeColor(INK)1352        c.setLineWidth(0.7)1353        bw = 90 * r["count"] / max_n1354        c.roundRect(M + 180, y - 1, max(3, bw), 8, 3, stroke=1, fill=1)1355        c.setFont("Helvetica", 8.5)1356        c.setFillColor(INK2)1357        c.drawRightString(M + 330, y, f"{r['count']:,}".replace(",", NBSP))1358        c.drawRightString(M + 400, y, str(r["sources"]))1359        c.drawRightString(M + 500, y, _fmt_money(r["avg_price"]) if r["avg_price"] else "—")1360        y -= 151361    s.pied("Rapport généré automatiquement à partir des annonces publiques "1362           "agrégées par Rent-Ka. Loyers : bornes 300–10 000 $.", 1)1363    c.showPage()13641365    # ---- page 2 : histogramme des loyers + par type1366    s.fond()1367    y = s.entete("Rapport du marché · loyers")1368    y = s.titre_section(y, "Distribution des loyers")1369    hist = st["histogram"]1370    max_c = max((h["count"] for h in hist), default=1) or 11371    ch_h, ch_y = 150, y - 1701372    bw = (PAGE_W - 2 * M) / len(hist)1373    for i, h in enumerate(hist):1374        bh = ch_h * h["count"] / max_c1375        bx = M + i * bw1376        borne = h["hi"] is None or h["lo"] == 01377        c.setFillColor(INK3 if borne else GREEN)1378        c.setStrokeColor(INK)1379        c.setLineWidth(0.6)1380        c.rect(bx + 2, ch_y, bw - 4, max(1, bh), stroke=1, fill=1)1381        if h["count"] and h["count"] > max_c * 0.06:1382            c.setFont("Courier-Bold", 6)1383            c.setFillColor(INK)1384            c.drawCentredString(bx + bw / 2, ch_y + bh + 3, str(h["count"]))1385        c.setFont("Helvetica", 5.6)1386        c.setFillColor(INK3)1387        lab = f"<{h['hi']}" if h["lo"] == 0 else (f"{h['lo']}+" if h["hi"] is None1388                                                  else str(h["lo"]))1389        c.drawCentredString(bx + bw / 2, ch_y - 9, lab)1390    y = ch_y - 3013911392    y = s.titre_section(y, "Par taille de logement")1393    c.setFont("Courier-Bold", 7)1394    c.setFillColor(INK3)1395    for lab, xoff in (("TAILLE", 0), ("ANNONCES", 160), ("LOYER MOYEN", 260),1396                      ("LOYER MIN", 360)):1397        c.drawString(M + xoff, y, lab)1398    y -= 41399    c.line(M, y, PAGE_W - M, y)1400    y -= 131401    for r in st["by_type"][:9]:1402        c.setFont("Helvetica-Bold", 8.5)1403        c.setFillColor(INK)1404        c.drawString(M, y, r["key"])1405        c.setFont("Helvetica", 8.5)1406        c.setFillColor(INK2)1407        c.drawRightString(M + 220, y, str(r["count"]))1408        c.drawRightString(M + 330, y, _fmt_money(r["avg_price"]) if r["avg_price"] else "—")1409        c.drawRightString(M + 430, y, _fmt_money(r["min_price"]) if r["min_price"] else "—")1410        y -= 131411    s.pied("Rent-Ka — agrégateur indépendant. Chaque annonce renvoie à la "1412           "source originale du gestionnaire.", 2)1413    c.showPage()14141415    # ---- page 3 : offre, inclusions, prix au pi², baisses de prix1416    s.fond()1417    y = s.entete("Rapport du marché · offre")1418    o = st["offre"]1419    y = s.titre_section(y, "Inclusions et caractéristiques du parc")1420    carac = [("Chauffage inclus", o["chauffage_pct"]),1421             ("Électricité incluse", o["electricite_pct"]),1422             ("Eau chaude incluse", o["eau_chaude_pct"]),1423             ("Internet inclus", o["internet_pct"]),1424             ("Climatisation", o["clim_pct"]),1425             ("Stationnement", o["stationnement_pct"]),1426             ("Balcon", o["balcon_pct"]),1427             ("Meublé", o["furnished_pct"])]1428    for lab, v in carac:1429        if v is None:1430            continue1431        c.setFont("Helvetica", 8.5)1432        c.setFillColor(INK2)1433        c.drawString(M, y, lab)1434        bx0, bw_ = M + 150, PAGE_W - 2 * M - 2001435        c.setFillColor(SURFACE)1436        c.setStrokeColor(INK)1437        c.setLineWidth(0.8)1438        c.roundRect(bx0, y - 1, bw_, 8, 4, stroke=1, fill=1)1439        c.setFillColor(GREEN)1440        c.roundRect(bx0, y - 1, bw_ * min(1, v / 100), 8, 4, stroke=0, fill=1)1441        c.setFont("Courier-Bold", 8)1442        c.setFillColor(INK)1443        c.drawRightString(PAGE_W - M, y, f"{v}{NBSP}%")1444        y -= 151445    y -= 61446    c.setFont("Helvetica", 8)1447    c.setFillColor(INK3)1448    c.drawString(M, y, f"Disponibles maintenant : {o['dispo_now']:,} · à date future : "1449                       f"{o['dispo_date']:,} · superficie moyenne : "1450                       f"{o['superficie_moyenne'] or '—'} pi² "1451                       f"({o['superficie_connue']:,} annonces la publient)"1452                       .replace(",", NBSP))1453    y -= 2414541455    if o["prix_pi2"]:1456        y = s.titre_section(y, "Prix au pied carré (loyer / superficie)")1457        for r in o["prix_pi2"][:6]:1458            c.setFont("Helvetica-Bold", 8.5)1459            c.setFillColor(INK)1460            c.drawString(M, y, r["key"])1461            c.setFont("Helvetica", 8.5)1462            c.setFillColor(INK2)1463            c.drawString(M + 70, y, f"{r['val']:.2f}".replace(".", ",") +1464                         f"{NBSP}$/pi² · {r['count']} annonces")1465            y -= 131466        y -= 1014671468    if st["baisses"]:1469        y = s.titre_section(y, "Baisses de prix récentes (30 jours)")1470        for b in st["baisses"][:8]:1471            c.setFont("Helvetica", 8.5)1472            c.setFillColor(INK2)1473            c.drawString(M, y, f"{(b['title'] or b['uid'])[:46]} — {b['city'] or ''}")1474            c.setFont("Courier-Bold", 8)1475            c.setFillColor(GREEN)1476            c.drawRightString(PAGE_W - M, y,1477                              f"{_fmt_money(b['avant'])} → {_fmt_money(b['apres'])} "1478                              f"({b['pct']}{NBSP}%)".replace(".", ","))1479            y -= 131480    s.pied("Caractéristiques dérivées des annonces publiées ; les inclusions "1481           "non mentionnées par une source ne sont pas comptées.", 3)1482    c.showPage()14831484    # ---- page 4 : top villes + top gestionnaires1485    s.fond()1486    y = s.entete("Rapport du marché · détail")1487    y = s.titre_section(y, "Top 20 des villes")1488    col_w = (PAGE_W - 2 * M) / 21489    for i, r in enumerate(st["by_city"][:20]):1490        vx = M if i < 10 else M + col_w1491        vy = y - (i % 10) * 131492        c.setFont("Helvetica", 8.5)1493        c.setFillColor(INK2)1494        c.drawString(vx, vy, f"{i + 1:>2}. {r['key']}")1495        c.setFont("Courier-Bold", 8)1496        c.setFillColor(INK)1497        c.drawRightString(vx + col_w - 24, vy, f"{r['count']:,}".replace(",", NBSP))1498    y -= 10 * 13 + 1614991500    y = s.titre_section(y, "Top 20 des gestionnaires")1501    for i, r in enumerate(st["by_source"][:20]):1502        vx = M if i < 10 else M + col_w1503        vy = y - (i % 10) * 131504        c.setFont("Helvetica", 8.5)1505        c.setFillColor(INK2)1506        c.drawString(vx, vy, f"{i + 1:>2}. {noms.get(r['key'], r['key'])[:34]}")1507        c.setFont("Courier-Bold", 8)1508        c.setFillColor(INK)1509        c.drawRightString(vx + col_w - 24, vy, f"{r['count']:,}".replace(",", NBSP))1510    y -= 10 * 13 + 2015111512    c.setFont("Helvetica", 7.5)1513    c.setFillColor(INK3)1514    c.drawString(M, y, "Sources de données de quartier : Statistique Canada (Recensement 2021, "1515                       "licence ouverte), INSPQ (CC-BY 4.0), Ville de Montréal (CC-BY 4.0), OpenStreetMap.")1516    s.pied("© Rent-Ka — www.rent-ka.com · rapport non contractuel, généré automatiquement.", 4)1517    c.save()1518    return buf.getvalue()1519