# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # pdfgen.py : génération PDF au style « éditorial sharp » de Lou-Ka # - fiche_pdf(uid) : fiche de propriété (photos, prix + badge marché, chips, # description structurée, inclusions, quartier, à proximité, QR code) # - rapport_pdf() : rapport global du marché (totaux, régions, histogramme # des loyers, types, villes, gestionnaires) # Palette identique au site : crème #f5f3ee, encre #141814, lime #d9f26b, # vert #1c5c41. Polices de base (Helvetica/Courier) 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 Lou-Ka --------------------------------------------------------- PAPER = HexColor("#f5f3ee") SURFACE = HexColor("#ffffff") INK = HexColor("#141814") INK2 = HexColor("#4d5551") INK3 = HexColor("#8b928c") GREEN = HexColor("#1c5c41") GREEN_DEEP = HexColor("#123f2e") LIME = HexColor("#d9f26b") LIME_SOFT = HexColor("#f0f9d2") AMBER = HexColor("#e8a33d") AMBER_SOFT = HexColor("#fdf3e2") PAGE_W, PAGE_H = letter M = 40 # marge IMG_TIMEOUT = 6 UA = "LouKaBot/1.0 (+https://www.lou-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() return ImageReader(io.BytesIO(r.content)) 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 Lou·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 # --------------------------------------------------------------------------- # Fiche de propriété # --------------------------------------------------------------------------- def fiche_pdf(uid: str) -> bytes | None: """PDF de la fiche du logement `uid`. 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 [] from . import quartier as qmod dauid = l.get("dauid") quartier = qmod.fiche_quartier(l["lat"], l["lng"], l.get("city") or "", dauid if dauid and dauid != "hors-zone" else None) 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"]) con.close() buf = io.BytesIO() c = rl_canvas.Canvas(buf, pagesize=letter) c.setTitle(f"Lou-Ka — {l.get('title') or l.get('address')}") s = _Style(c) s.fond() y = s.entete("Fiche logement") # --- bloc prix + badge marché 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 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) - 8 # --- pied page 1 + page 2 (quartier / à proximité / références) s.pied("Données de l'annonce publiée par le gestionnaire — Lou-Ka est un " "agrégateur indépendant ; chaque fiche renvoie à l'annonce originale.", 1) c.showPage() s.fond() y = s.entete("Fiche logement · suite") # Le quartier if quartier: 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): tx = M + (i % 3) * (tw + 10) ty = y - 34 - (i // 3) * 44 c.setFillColor(SURFACE) c.setStrokeColor(INK) c.setLineWidth(1.2) c.roundRect(tx, ty, tw, 38, 5, stroke=1, fill=1) c.setFont("Helvetica-Bold", 12) c.setFillColor(INK) c.drawString(tx + 8, ty + 20, val) c.setFont("Courier", 6.5) c.setFillColor(INK3) c.drawString(tx + 8, ty + 8, lab.upper()) y -= 34 + 2 * 44 + 6 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é if poi: y = s.titre_section(y, "À proximité") col2 = y for i, p in enumerate(poi[:12]): px = M if i % 2 == 0 else M + (PAGE_W - 2 * M) / 2 + 10 if i % 2 == 0 and i > 0: y -= 13 py = y 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 + (PAGE_W - 2 * M) / 2 - 14, py, f"{_fmt_dist(p['dist_m'])} · {_marche(p['dist_m'])}") y -= 26 # Références : QR + gestionnaire + source y = s.titre_section(y, "Références") qr = qrcode.make(f"https://www.lou-ka.com/logement/{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.lou-ka.com/logement/{uid}") s.pied("Quartier : Statistique Canada (Recensement 2021), INSPQ (CC-BY 4.0)" + (", Ville de Montréal (CC-BY 4.0)" if (quartier or {}).get("crime", {}).get("type") == "points" else "") + " · Commodités : OpenStreetMap. Prix et disponibilités : ceux affichés par la source.", 2) 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 Lou-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("Lou-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 Lou-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("Lou-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("© Lou-Ka — www.lou-ka.com · rapport non contractuel, généré automatiquement.", 4) c.save() return buf.getvalue()