# ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de propriétés à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # pdffiche.py : fiche PDF complète d'une propriété — reflète TOUS les blocs de # la fiche web : prix + verdict Vrai-Prix, photos, description, inclusions, # caractéristiques, pièces, analyse Vrai-Prix (jauge + rôle foncier), # historique de prix, inondation, qualité de l'air, coût d'électricité, # commerces/transport, essence, quartier, références + QR. # Style « éditorial sharp » (bordures encre, ombres décalées dures, tuiles # KPI, micro-étiquettes Courier) — palette du site : crème #f5f3ee, encre # #141814, accent cerise #e23744. Pagination dynamique : saut_page()/place(), # aucune section coupée entre deux pages. # ----------------------------------------------------------------------------- from __future__ import annotations import io import json 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 Immo-Ka (identité officielle du site) ---------------------------- PAPER = HexColor("#f5f3ee") SURFACE = HexColor("#ffffff") INK = HexColor("#141814") INK2 = HexColor("#4d5551") INK3 = HexColor("#8b928c") ACCENT = HexColor("#e23744") # cerise ACCENT_SOFT = HexColor("#fbe0e2") ACCENT_DEEP = HexColor("#a8232e") GREEN_FG = HexColor("#1c5c41") # verdict « sous l'estimation » (site .vp-under) GREEN_BG = HexColor("#e2f0e6") RED_FG = HexColor("#8a2b2b") # verdict « au-dessus » (site .vp-over) RED_BG = HexColor("#f6e2e2") AMBER_FG = HexColor("#8a5a12") AMBER_BG = HexColor("#faf0dc") PAGE_W, PAGE_H = letter M = 40 # marge IMG_TIMEOUT = 6 UA = "ImmoKaBot/1.0 (+https://www.immo-ka.com; contact@spboucher.ai)" NBSP = " " # espace fine insécable (mise en forme française) # clés de details jamais affichées (mêmes exclusions que la fiche web) DETAIL_HIDDEN = { "pieces", "price_from", "cover_thumb", "photo_captions", "img_audited", "needs_image_review", "postal_code", "region", "transaction", "prix_pi2", "prix_m2", "listing_origin_url", } 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"), ] _MOIS = ["janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août", "septembre", "octobre", "novembre", "décembre"] _PIED_FICHE = ("Données de l'annonce publiée par l'agence — Immo-Ka est un " "agrégateur indépendant. Enrichissements fournis à titre " "indicatif, sans valeur légale.") 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 _fmt_mois(d) -> str: """« 2017-11-04 » ou epoch → « novembre 2017 » (sinon valeur telle quelle).""" if d is None: return "—" try: if isinstance(d, (int, float)): st = time.localtime(d) return f"{_MOIS[st.tm_mon - 1]} {st.tm_year}" an, mois = str(d)[:7].split("-") return f"{_MOIS[int(mois) - 1]} {an}" except Exception: return str(d) def _fmt_jour(ts: float) -> str: """epoch → « 4 novembre 2024 » (« 1er » pour le premier du mois).""" st = time.localtime(ts) j = "1er" if st.tm_mday == 1 else str(st.tm_mday) return f"{j} {_MOIS[st.tm_mon - 1]} {st.tm_year}" def _fetch_image(url: str) -> ImageReader | None: try: r = requests.get(url, timeout=IMG_TIMEOUT, headers={"User-Agent": UA}) r.raise_for_status() raw = r.content try: # les photos source dépassent parfois 5 Mo chacune : sans # redimensionnement le PDF grimpe à plusieurs dizaines de Mo from PIL import Image im = Image.open(io.BytesIO(raw)) im.thumbnail((1000, 1000)) buf = io.BytesIO() im.convert("RGB").save(buf, "JPEG", quality=78) buf.seek(0) return ImageReader(buf) except Exception: return ImageReader(io.BytesIO(raw)) except Exception: return None class _Style: """Petits composants du style « éditorial sharp » sur un canvas.""" def __init__(self, c: rl_canvas.Canvas): self.c = c def fond(self): self.c.setFillColor(PAPER) self.c.rect(0, 0, PAGE_W, PAGE_H, stroke=0, fill=1) def logo(self, x: float, y: float, taille: float = 22): """Wordmark Immo·Ka : « Immo » encre + boîte encre avec « Ka » cerise.""" c = self.c c.setFont("Helvetica-Bold", taille) c.setFillColor(INK) c.drawString(x, y, "Immo") w = c.stringWidth("Immo", "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(ACCENT) 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.IMMO-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=ACCENT, 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 # carré accent à ombre dure (signature de la passe éditoriale du site) c.setFillColor(INK) c.rect(M + 2, y - 1.5, 9, 9, stroke=0, fill=1) c.setFillColor(ACCENT) c.setStrokeColor(INK) c.setLineWidth(1) c.rect(M, y + 0.5, 9, 9, stroke=1, fill=1) c.setFont("Helvetica-Bold", 12.5) c.setFillColor(INK) c.drawString(M + 16, y, texte) c.setStrokeColor(ACCENT) c.setLineWidth(3) w = c.stringWidth(texte, "Helvetica-Bold", 12.5) c.line(M + 16, y - 4, M + 16 + w, y - 4) return y - 18 def paragraphe(self, x: float, y: float, texte: str, largeur: float, taille: float = 8.5, couleur=INK2, interligne: float = 11.5, max_lignes: int = 100) -> float: """Texte multi-lignes avec découpe aux mots ; retourne le y suivant.""" c = self.c c.setFont("Helvetica", taille) c.setFillColor(couleur) mots = texte.split() ligne = "" n = 0 for mot in mots: essai = (ligne + " " + mot).strip() if c.stringWidth(essai, "Helvetica", taille) <= largeur: ligne = essai else: c.drawString(x, y, ligne) y -= interligne n += 1 ligne = mot if n >= max_lignes - 1: ligne += " …" break if ligne: c.drawString(x, y, ligne) y -= interligne return y def nb_lignes(self, texte: str, largeur: float, taille: float = 8.5, max_lignes: int = 100) -> int: """Nombre de lignes qu'occupera `paragraphe` (pour réserver la place).""" c = self.c mots = texte.split() ligne = "" n = 1 for mot in mots: essai = (ligne + " " + mot).strip() if c.stringWidth(essai, "Helvetica", taille) <= largeur: ligne = essai else: n += 1 ligne = mot if n >= max_lignes: break return min(n, max_lignes) def tuile(self, x: float, y: float, w: float, h: float, val: str, lab: str): """Tuile KPI blanche à ombre décalée (x, y = coin inférieur gauche).""" c = self.c c.setFillColor(INK) c.roundRect(x + 2.5, y - 2.5, w, h, 5, stroke=0, fill=1) c.setFillColor(SURFACE) c.setStrokeColor(INK) c.setLineWidth(1.2) c.roundRect(x, y, w, h, 5, stroke=1, fill=1) c.setFont("Helvetica-Bold", 12) c.setFillColor(INK) c.drawString(x + 8, y + h - 20, str(val)) c.setFont("Courier", 6.2) c.setFillColor(INK3) c.drawString(x + 8, y + 7, lab.upper()[: max(4, int(w / 4.2))]) def _titre_droite(c, y, x, texte): c.setFillColor(INK) c.rect(x + 2, y - 1.5, 9, 9, stroke=0, fill=1) c.setFillColor(ACCENT) c.setStrokeColor(INK) c.setLineWidth(1) c.rect(x, y + 0.5, 9, 9, stroke=1, fill=1) c.setFont("Helvetica-Bold", 12.5) c.setFillColor(INK) c.drawString(x + 16, y, texte) c.setStrokeColor(ACCENT) c.setLineWidth(3) c.line(x + 16, y - 4, x + 16 + c.stringWidth(texte, "Helvetica-Bold", 12.5), y - 4) return y - 18 def _verdict_vraiprix(price, vp): """Pastille verdict identique au site : ±% vs estimation, masqué si |pct| > 60 (prix non comparable : terrain, loyer…).""" if not price or not vp or vp.get("value") in (None, 0): return None pct = round((price - vp["value"]) / vp["value"] * 100) if abs(pct) > 60: return None if pct >= 1: return (f"▲{NBSP}{pct}{NBSP}% au-dessus de l'estimation", RED_FG, RED_BG, ACCENT_DEEP) if pct <= -1: return (f"▼{NBSP}{abs(pct)}{NBSP}% sous l'estimation", GREEN_FG, GREEN_BG, GREEN_FG) return (f"≈{NBSP}Prix aligné sur l'estimation", INK2, SURFACE, INK) def fiche_pdf(uid: str) -> bytes | None: """PDF de la fiche de la propriété `uid` — reflète tous les blocs de la fiche web ; 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["features"] = json.loads(l.get("features") or "[]") l["images"] = json.loads(l.get("images") or "[]") l["details"] = json.loads(l.get("details") or "{}") try: l["vraiprix"] = json.loads(l["vraiprix"]) or None if l.get("vraiprix") else None except (ValueError, TypeError): l["vraiprix"] = None price_history = [dict(r) for r in con.execute( "SELECT ts, price FROM price_log WHERE uid=? ORDER BY ts DESC LIMIT 10", (uid,)).fetchall()] quartier = None lat, lng = l.get("lat"), l.get("lng") geo = lat is not None and lng is not None if geo: from . import quartier as qmod dauid = l.get("dauid") try: quartier = qmod.fiche_quartier( lat, lng, l.get("city") or "", dauid if dauid and dauid != "hors-zone" else None) except Exception: quartier = None try: 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"]) except Exception: src_name = l["source"] con.close() # --- enrichissements (mêmes modules que les blocs de la fiche web) ------- def _safe(fn, *a, **k): try: return fn(*a, **k) except Exception: return None from . import air as air_mod from . import commerces as com_mod from . import gaz as gaz_mod from . import hydro as hydro_mod from . import inondation as inon_mod inon = _safe(inon_mod.lookup, lat, lng) if geo else None air_d = _safe(air_mod.lookup, lat, lng) if geo else None gaz_d = _safe(gaz_mod.nearby, lat, lng) if geo else None com = _safe(com_mod.nearby, lat, lng) if geo else None hyd = _safe(hydro_mod.estimate, adresse=l.get("address") or "", uid=uid, lat=lat, lng=lng, solve=False) d = l["details"] vp = l.get("vraiprix") location = (d.get("transaction") == "location") buf = io.BytesIO() c = rl_canvas.Canvas(buf, pagesize=letter) c.setTitle(f"Immo-Ka — {l.get('address') or l.get('title') or uid}") s = _Style(c) s.fond() y = s.entete("Fiche propriété") page = [1] def saut_page() -> float: s.pied(_PIED_FICHE, page[0]) c.showPage() page[0] += 1 s.fond() return s.entete("Fiche propriété · suite") def place(y_: float, besoin: float) -> float: """Saute de page si `besoin` points ne tiennent plus sous y_.""" return saut_page() if (y_ - besoin) < M + 34 else y_ # --- héro : prix + pastille verdict Vrai-Prix ----------------------------- prix_txt = (_fmt_money(l["price"]) + (f"{NBSP}/ mois" if location else "")) \ 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) bx = M + c.stringWidth(prix_txt, "Helvetica-Bold", 30) + 14 verd = _verdict_vraiprix(l.get("price"), vp) if verd: txt, fg, bg, stk = verd bx = s.pilule(bx, y - 22, txt, fg, bg, stk) if l.get("property_type"): s.pilule(bx, y - 22, l["property_type"], ACCENT, INK) y -= 44 # --- titre + adresse ------------------------------------------------------- titre = l.get("address") or l.get("title") or "" c.setFont("Helvetica-Bold", 13) c.setFillColor(INK) c.drawString(M, y, titre[:80]) y -= 14 loc = " · ".join(x for x in (l.get("sector"), l.get("city"), l.get("region")) if x) c.setFont("Helvetica", 9) c.setFillColor(INK2) c.drawString(M, y, loc[:110]) y -= 20 # --- chips clés (mêmes specs que le héro du site) -------------------------- chips = [] if l.get("bedrooms") is not None: chips.append(f"{l['bedrooms']}{NBSP}ch.") if l.get("bathrooms") is not None: chips.append(f"{l['bathrooms']}{NBSP}sdb") if l.get("powder_rooms"): chips.append(f"{l['powder_rooms']}{NBSP}s. d'eau") if l.get("area_sqft"): chips.append(f"{round(l['area_sqft']):,}".replace(",", NBSP) + f"{NBSP}pi²") if l.get("lot_sqft"): chips.append(f"Terrain {round(l['lot_sqft']):,}".replace(",", NBSP) + f"{NBSP}pi²") if l.get("year_built"): chips.append(f"Construit en {l['year_built']}") if l.get("price") and l.get("area_sqft") and l["area_sqft"] > 200: chips.append(f"{round(l['price'] / l['area_sqft'])}{NBSP}$/pi²") if l.get("mls"): chips.append(f"MLS {l['mls']}") if l.get("first_seen"): dom = max(0, int((time.time() - l["first_seen"]) // 86400)) chips.append(f"{dom}{NBSP}j sur Immo-Ka") x = M for chip in chips[:8]: 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 # --- baisse/hausse de prix (bandeau, comme le site) ------------------------- ph = [p for p in price_history if p.get("price")] if len(ph) >= 2 and ph[0]["price"] != ph[1]["price"]: y = place(y, 34) de, a = ph[1]["price"], ph[0]["price"] bas = a < de c.setFillColor(GREEN_BG if bas else RED_BG) c.setStrokeColor(GREEN_FG if bas else RED_FG) c.setLineWidth(1.2) c.roundRect(M, y - 20, PAGE_W - 2 * M, 24, 5, stroke=1, fill=1) c.setFont("Helvetica-Bold", 9) c.setFillColor(GREEN_FG if bas else RED_FG) c.drawString(M + 10, y - 12, f"{'▼' if bas else '▲'} Prix passé de {_fmt_money(de)} à " f"{_fmt_money(a)} le {_fmt_jour(ph[0]['ts'])}") y -= 34 # --- description | inclusions (2 colonnes, comme le site) ------------------- col_g = PAGE_W * 0.56 - M feats = [str(f) for f in (l.get("features") or []) if f] desc = (l.get("description") or "").strip() if desc or feats: besoin = 30 + max( s.nb_lignes(desc, col_g, max_lignes=26) * 11.5 if desc else 0, min(len(feats), 18) * 12) y = place(y, min(besoin, 340)) y_desc = y if desc: y_desc = s.titre_section(y, "Description") y_desc = s.paragraphe(M, y_desc, desc, col_g, max_lignes=26) y_incl = y if feats: xr = M + col_g + 24 y_incl = _titre_droite(c, y, xr, "Inclusions et caractéristiques") c.setFont("Helvetica", 8.5) for f in feats[:18]: c.setFillColor(ACCENT_DEEP) c.drawString(xr, y_incl, "✓") c.setFillColor(INK2) c.drawString(xr + 12, y_incl, f[:42]) y_incl -= 12 if len(feats) > 18: c.setFillColor(INK3) c.drawString(xr + 12, y_incl, f"+ {len(feats) - 18} autres…") y_incl -= 12 y = min(y_desc, y_incl) - 16 # --- caractéristiques (details, mêmes exclusions que le site) --------------- dets = [(str(k), str(v)) for k, v in d.items() if k not in DETAIL_HIDDEN and v not in (None, "", [], {}) and str(v).strip("-— ") != "" and not isinstance(v, (dict, list))] if dets: col_w = PAGE_W - 2 * M besoin = 40 + sum( s.nb_lignes(v, col_w - 190, taille=8, max_lignes=4) * 11 + 3 for _, v in dets) y = place(y, min(besoin, 420)) y = s.titre_section(y, "Caractéristiques") c.setFont("Courier-Bold", 7) c.setFillColor(INK3) c.drawString(M, y, "DÉTAIL") c.drawString(M + 180, y, "VALEUR") y -= 4 c.setStrokeColor(INK) c.setLineWidth(1.5) c.line(M, y, PAGE_W - M, y) y -= 12 for i, (k, v) in enumerate(dets): nl = s.nb_lignes(v, col_w - 190, taille=8, max_lignes=4) y = place(y, nl * 11 + 6) if i % 2 == 0: # zébrage éditorial c.setFillColor(SURFACE) c.rect(M - 3, y - (nl - 1) * 11 - 3, col_w + 6, nl * 11 + 2, stroke=0, fill=1) c.setFont("Helvetica-Bold", 8) c.setFillColor(INK) c.drawString(M, y, k[:38]) y = s.paragraphe(M + 180, y, v, col_w - 190, taille=8, interligne=11, max_lignes=4) y -= 3 y -= 10 # --- pièces (details.pieces) ------------------------------------------------- pieces = d.get("pieces") or [] if isinstance(pieces, list) and pieces: rows = pieces[:24] y = place(y, 46 + len(rows) * 12) y = s.titre_section(y, "Pièces") c.setFont("Courier-Bold", 7) c.setFillColor(INK3) for lab, xoff in (("PIÈCE", 0), ("NIVEAU", 190), ("DIMENSIONS", 330), ("REVÊTEMENT", 430)): c.drawString(M + xoff, y, lab) y -= 4 c.setStrokeColor(INK) c.setLineWidth(1.5) c.line(M, y, PAGE_W - M, y) y -= 11 for i, p in enumerate(rows): if i % 2 == 0: c.setFillColor(SURFACE) c.rect(M - 3, y - 3, PAGE_W - 2 * M + 6, 12, stroke=0, fill=1) c.setFont("Helvetica-Bold", 8) c.setFillColor(INK) c.drawString(M, y, str(p.get("nom") or "—")[:36]) c.setFont("Helvetica", 8) c.setFillColor(INK2) c.drawString(M + 190, y, str(p.get("niveau") or "—")[:24]) c.drawString(M + 330, y, str(p.get("dimensions") or "—")[:18]) c.drawString(M + 430, y, str(p.get("revetement") or "—")[:22]) y -= 12 y -= 10 # --- analyse Vrai-Prix (valeur, jauge, rôle foncier) ------------------------- if vp and vp.get("value") is not None: role = [(lab, vp.get(k)) for k, lab in (("valeur_role", "Évaluation au rôle"), ("valeur_terrain", "Valeur du terrain"), ("valeur_batiment", "Valeur du bâtiment")) if vp.get(k)] besoin = 96 + (56 if vp.get("low") is not None else 0) \ + (62 if role else 0) + 18 y = place(y, besoin) y = s.titre_section(y, "Analyse Vrai-Prix") val_txt = _fmt_money(round(vp["value"])) c.setFont("Helvetica-Bold", 17) c.setFillColor(INK) c.drawString(M, y - 10, val_txt) c.setFont("Helvetica", 8.5) c.setFillColor(INK2) c.drawString(M + c.stringWidth(val_txt, "Helvetica-Bold", 17) + 8, y - 10, "— valeur marchande estimée par le modèle Vrai-Prix") meta = [] if vp.get("confidence"): meta.append(f"confiance {vp['confidence']}" + (f" ({vp['confidence_pct']}{NBSP}%)" if vp.get("confidence_pct") else "")) if vp.get("annee_construction_role"): meta.append(f"construction {vp['annee_construction_role']} (rôle)") if vp.get("superficie_terrain_role_m2"): meta.append( f"terrain {round(vp['superficie_terrain_role_m2']):,}".replace(",", NBSP) + f"{NBSP}m² (rôle)") if meta: c.setFont("Helvetica", 8) c.setFillColor(INK3) c.drawString(M, y - 24, " · ".join(meta)[:115]) verd = _verdict_vraiprix(l.get("price"), vp) if verd: txt, fg, bg, stk = verd s.pilule(M, y - 48, txt, fg, bg, stk) y -= 58 lo, hi = vp.get("low"), vp.get("high") if lo is not None and hi is not None and hi > lo: larg = PAGE_W - 2 * M base = y - 22 span = hi - lo c.setFillColor(ACCENT_SOFT) c.setStrokeColor(INK) c.setLineWidth(1) c.roundRect(M, base, larg, 12, 6, stroke=1, fill=1) def _pos(v): return M + larg * max(0.02, min(0.98, (v - lo) / span)) px = _pos(vp["value"]) c.setStrokeColor(ACCENT_DEEP) c.setLineWidth(2.5) c.line(px, base - 3, px, base + 15) c.setFont("Courier-Bold", 6.5) c.setFillColor(ACCENT_DEEP) c.drawString(min(max(px - 30, M), PAGE_W - M - 100), base + 18, f"ESTIMATION · {_fmt_money(round(vp['value']))}") if l.get("price") and lo * 0.7 <= l["price"] <= hi * 1.3: ax = _pos(l["price"]) c.setStrokeColor(INK) c.setLineWidth(2) c.line(ax, base - 3, ax, base + 15) c.setFont("Courier-Bold", 6.5) c.setFillColor(INK) c.drawString(min(max(ax - 30, M), PAGE_W - M - 116), base - 12, f"PRIX DEMANDÉ · {_fmt_money(l['price'])}") c.setFont("Helvetica", 6.5) c.setFillColor(INK3) c.drawString(M, base - 24, _fmt_money(round(lo))) c.drawRightString(PAGE_W - M, base - 24, _fmt_money(round(hi)) + " — fourchette d'estimation") y = base - 36 if role: tw = (PAGE_W - 2 * M - (len(role) - 1) * 10) / len(role) for i, (lab, v) in enumerate(role): s.tuile(M + i * (tw + 10), y - 44, tw, 38, _fmt_money(round(v)), lab) y -= 56 c.setFont("Helvetica", 6.5) c.setFillColor(INK3) c.drawString(M, y - 2, "Estimation automatisée Vrai-Prix (vrai-prix.ca) " "croisée au rôle d'évaluation foncière — ne " "remplace pas une évaluation professionnelle.") y -= 16 # --- historique de prix ------------------------------------------------------- if len(ph) >= 2: rows = ph[:8] y = place(y, 46 + len(rows) * 12) y = s.titre_section(y, "Historique du prix demandé") c.setFont("Courier-Bold", 7) c.setFillColor(INK3) for lab, xoff in (("DATE", 0), ("PRIX AFFICHÉ", 190), ("VARIATION", 330)): c.drawString(M + xoff, y, lab) y -= 4 c.setStrokeColor(INK) c.setLineWidth(1.5) c.line(M, y, PAGE_W - M, y) y -= 11 for i, p in enumerate(rows): prec = rows[i + 1]["price"] if i + 1 < len(rows) else None c.setFont("Helvetica", 8) c.setFillColor(INK2) c.drawString(M, y, _fmt_jour(p["ts"])) c.setFont("Helvetica-Bold", 8) c.setFillColor(INK) c.drawString(M + 190, y, _fmt_money(p["price"])) if prec and p.get("price"): delta = p["price"] - prec c.setFont("Courier-Bold", 7.5) c.setFillColor(GREEN_FG if delta < 0 else (RED_FG if delta > 0 else INK3)) sig = "▼" if delta < 0 else ("▲" if delta > 0 else "=") c.drawString(M + 330, y, f"{sig} {_fmt_money(abs(delta))}" if delta else "=") y -= 12 y -= 10 # --- risque d'inondation (BDZI) ------------------------------------------------ if inon: zones = (inon.get("zones") or [])[:3] y = place(y, 56 + len(zones) * 12) y = s.titre_section(y, "Risque d'inondation") statut = inon.get("statut") sev = {"eleve": "récurrence élevée", "modere": "récurrence modérée", "present": "zone cartographiée"}.get(inon.get("severite") or "", "") if statut == "en_zone": s.pilule(M, y - 12, f"En zone inondable ({sev})" if sev else "En zone inondable", RED_FG, RED_BG, RED_FG) elif statut == "a_proximite": d0 = zones[0]["distance_m"] if zones else "?" s.pilule(M, y - 12, f"Zone inondable à ~{d0} m", AMBER_FG, AMBER_BG, AMBER_FG) elif statut == "hors_zone": s.pilule(M, y - 12, "Hors des zones inondables cartographiées", GREEN_FG, GREEN_BG, GREEN_FG) else: s.pilule(M, y - 12, "Secteur non couvert par la cartographie BDZI", INK2, SURFACE, INK) y -= 26 for z in zones: c.setFont("Helvetica", 8) c.setFillColor(INK2) c.drawString(M, y, f"{z.get('type') or 'Zone'} — à {z.get('distance_m')} m" + (f" (rapport {z['date_rapport']})" if z.get("date_rapport") else "")) y -= 12 y -= 8 # --- qualité de l'air (RSQAQ) --------------------------------------------------- if air_d and air_d.get("station"): mes = air_d.get("mesures") or {} y = place(y, 44 + len(mes) * 11) y = s.titre_section(y, "Qualité de l'air") c.setFont("Helvetica", 8.5) c.setFillColor(INK2) c.drawString(M, y - 2, f"Station {air_d['station']} ({air_d['ville']}), à " + f"{air_d['distance_km']}".replace(".", ",") + f"{NBSP}km " "de la propriété — moyennes annuelles :") y -= 16 for pol, m_ in list(mes.items())[:5]: c.setFont("Helvetica-Bold", 8) c.setFillColor(INK) c.drawString(M, y, pol) c.setFont("Helvetica", 8) c.setFillColor(INK2) val = (f"{m_['moyenne']:.1f}".replace(".", ",") if m_.get("moyenne") is not None else "—") txt = f"{val}{NBSP}{m_.get('unite', '')} ({m_.get('annee', '')})" if m_.get("ref"): txt += f" · référence {m_.get('ref_nom', '')} : {m_['ref']}" c.drawString(M + 56, y, txt[:95]) y -= 11 y -= 8 # --- coût d'électricité (Hydro-Québec) ------------------------------------------- if hyd and hyd.get("disponible"): y = place(y, 52) y = s.titre_section(y, "Coût d'électricité (Hydro-Québec)") c.setFont("Helvetica-Bold", 15) c.setFillColor(INK) mens = f"≈{NBSP}{_fmt_money(round(hyd['cout_mensuel']))} / mois" c.drawString(M, y - 8, mens) c.setFont("Helvetica", 8.5) c.setFillColor(INK2) suite = f"{_fmt_money(round(hyd['cout_annuel']))} / an" if hyd.get("kwh_annuel"): suite += (f" · {round(hyd['kwh_annuel']):,}".replace(",", NBSP) + f"{NBSP}kWh/an") suite += " — estimation officielle Hydro-Québec pour cette adresse." c.drawString(M + c.stringWidth(mens, "Helvetica-Bold", 15) + 10, y - 8, suite) y -= 26 # --- commerces et transport -------------------------------------------------------- if com and (com.get("commerces") or com.get("transit")): entrees = ([(t["commerce"], t["nom"], t["dist_m"]) for t in (com.get("transit") or [])[:4]] + [(i["commerce"], i["nom"], i["dist_m"]) for i in (com.get("commerces") or [])[:12]]) lignes = (len(entrees) + 1) // 2 y = place(y, 36 + lignes * 13) y = s.titre_section(y, "Commerces et transport") col_w = (PAGE_W - 2 * M) / 2 for i, (cat, nom, dist) in enumerate(entrees): px = M if i % 2 == 0 else M + col_w + 10 py = y - (i // 2) * 13 c.setFont("Helvetica", 8) c.setFillColor(INK2) nomc = nom if len(nom) <= 22 else nom[:21] + "…" c.drawString(px, py, f"{cat} — {nomc}") c.setFont("Courier-Bold", 7.5) c.setFillColor(INK) c.drawRightString(px + col_w - 14, py, _fmt_dist(dist)) y -= lignes * 13 + 12 # --- essence à proximité -------------------------------------------------------------- if gaz_d and gaz_d.get("stations"): sts = gaz_d["stations"][:3] y = place(y, 40 + len(sts) * 11) y = s.titre_section(y, "Essence à proximité") if gaz_d.get("mediane_regulier"): c.setFont("Helvetica", 8.5) c.setFillColor(INK2) c.drawString(M, y - 2, "Prix médian de l'essence régulière à <5 km : " + f"{gaz_d['mediane_regulier']}".replace(".", ",") + f"{NBSP}¢/L") y -= 14 for st in sts: c.setFont("Helvetica", 8) c.setFillColor(INK2) reg_p = (f"{st['regulier']}".replace(".", ",") + f"{NBSP}¢/L" if st.get("regulier") else "—") c.drawString(M, y, f"{st['nom']} — {_fmt_dist(st['dist_m'])} : {reg_p}" + (" · la moins chère" if st.get("moins_chere") else "")) y -= 11 y -= 8 # --- le quartier ------------------------------------------------------------------------- if quartier and (quartier.get("demographie") or quartier.get("proximite") or quartier.get("chaleur") or quartier.get("crime")): demo = quartier.get("demographie") or {} prox = quartier.get("proximite") or {} prox_aff = [(k, lab) for k, lab in PROX_LABELS if isinstance(prox.get(k), (int, float))] besoin = 44 + (94 if demo else 0) + len(prox_aff) * 14 + 44 y = place(y, besoin) y = s.titre_section(y, "Le quartier") c.setFont("Helvetica", 7.5) c.setFillColor(INK3) c.drawString(M, y, "Secteur immédiat de la propriété (aire de diffusion " "du recensement 2021, ± 500 habitants).") y -= 14 if demo: tuiles = [("Revenu médian", _fmt_money(demo.get("revenu_median"))), ("Ménages locataires", f"{round(demo['pct_locataires'])}{NBSP}%" if demo.get("pct_locataires") is not None else "—"), ("Loyer moyen secteur", _fmt_money(demo.get("loyer_moyen"))), ("Âge médian", f"{round(demo['age_median'])} ans" if demo.get("age_median") is not None else "—"), ("Français à la maison", f"{round(demo['pct_francais'])}{NBSP}%" if demo.get("pct_francais") is not None else "—"), ("Diplôme universitaire", f"{round(demo['pct_univ'])}{NBSP}%" if demo.get("pct_univ") is not None else "—")] tw = (PAGE_W - 2 * M - 20) / 3 for i, (lab, val) in enumerate(tuiles): s.tuile(M + (i % 3) * (tw + 10), y - 34 - (i // 3) * 44, tw, 38, val, lab) y -= 34 + 44 + 16 for k, lab in prox_aff: 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(ACCENT) c.roundRect(bx0, y - 1, max(3, 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 if prox_aff: c.setFont("Helvetica", 6.5) c.setFillColor(INK3) c.drawString(M, y, "Accessibilité 0–100 — mesures de proximité de " "Statistique Canada.") y -= 12 badges_q = [] ch = quartier.get("chaleur") if ch: if ch.get("classe", 5) <= 3: badges_q.append(("Îlot de fraîcheur", GREEN_FG, GREEN_BG, GREEN_FG)) elif ch.get("classe", 5) >= 7: ec = (f" (+{ch['ecart']:.1f} °C)".replace(".", ",") if ch.get("ecart") is not None else "") badges_q.append((f"Îlot de chaleur{ec}", AMBER_FG, AMBER_BG, AMBER_FG)) 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)) if badges_q: x = M for txt, fg, bg, stk in badges_q: x = s.pilule(x, y - 8, txt, fg, bg, stk) y -= 26 y -= 6 # --- références : QR + courtier + source ------------------------------------------------ y = place(y, 140) y = s.titre_section(y, "Références") qr = qrcode.make(f"https://www.immo-ka.com/propriete/{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) yy = y - 14 c.setFont("Helvetica-Bold", 9) c.setFillColor(INK) courtier = l.get("broker_name") or d.get("Courtier inscripteur") if courtier: ligne = f"Courtier : {courtier}" if l.get("broker_phone"): ligne += f" · {l['broker_phone']}" c.drawString(M + 82, yy, ligne[:95]) yy -= 13 c.drawString(M + 82, yy, f"Agence : {src_name}") yy -= 14 c.setFont("Helvetica", 8) c.setFillColor(INK2) c.drawString(M + 82, yy, "Annonce originale :") yy -= 11 c.setFillColor(ACCENT_DEEP) c.drawString(M + 82, yy, (l.get("url") or d.get("listing_origin_url") or "")[:90]) yy -= 14 c.setFillColor(INK2) c.drawString(M + 82, yy, "Fiche à jour en ligne (scannez le code) :") yy -= 11 c.setFillColor(ACCENT_DEEP) c.drawString(M + 82, yy, f"www.immo-ka.com/propriete/{uid}") y -= 90 s.paragraphe(M, y, "Sources : annonce de l'agence, Vrai-Prix (vrai-prix.ca), " "rôle d'évaluation foncière, Statistique Canada (2021), " "INSPQ (CC-BY 4.0), Ville de Montréal (CC-BY 4.0), BDZI, " "RSQAQ, Hydro-Québec, gazquebec.ca, OpenStreetMap.", PAGE_W - 2 * M, taille=6.5, couleur=INK3, interligne=9) s.pied(_PIED_FICHE, page[0]) c.save() return buf.getvalue()