|
1 |
+# ----------------------------------------------------------------------------- |
|
2 |
+# Immo-Ka — Agrégateur de propriétés à vendre (province de Québec) |
|
3 |
+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
|
4 |
+# pdffiche.py : fiche PDF complète d'une propriété — reflète TOUS les blocs de |
|
5 |
+# la fiche web : prix + verdict Vrai-Prix, photos, description, inclusions, |
|
6 |
+# caractéristiques, pièces, analyse Vrai-Prix (jauge + rôle foncier), |
|
7 |
+# historique de prix, inondation, qualité de l'air, coût d'électricité, |
|
8 |
+# commerces/transport, essence, quartier, références + QR. |
|
9 |
+# Style « éditorial sharp » (bordures encre, ombres décalées dures, tuiles |
|
10 |
+# KPI, micro-étiquettes Courier) — palette du site : crème #f5f3ee, encre |
|
11 |
+# #141814, accent cerise #e23744. Pagination dynamique : saut_page()/place(), |
|
12 |
+# aucune section coupée entre deux pages. |
|
13 |
+# ----------------------------------------------------------------------------- |
|
14 |
+from __future__ import annotations |
|
15 |
+ |
|
16 |
+import io |
|
17 |
+import json |
|
18 |
+import time |
|
19 |
+ |
|
20 |
+import qrcode |
|
21 |
+import requests |
|
22 |
+from reportlab.lib.colors import HexColor |
|
23 |
+from reportlab.lib.pagesizes import letter |
|
24 |
+from reportlab.lib.utils import ImageReader |
|
25 |
+from reportlab.pdfgen import canvas as rl_canvas |
|
26 |
+ |
|
27 |
+from . import db |
|
28 |
+ |
|
29 |
+# --- palette Immo-Ka (identité officielle du site) ---------------------------- |
|
30 |
+PAPER = HexColor("#f5f3ee") |
|
31 |
+SURFACE = HexColor("#ffffff") |
|
32 |
+INK = HexColor("#141814") |
|
33 |
+INK2 = HexColor("#4d5551") |
|
34 |
+INK3 = HexColor("#8b928c") |
|
35 |
+ACCENT = HexColor("#e23744") # cerise |
|
36 |
+ACCENT_SOFT = HexColor("#fbe0e2") |
|
37 |
+ACCENT_DEEP = HexColor("#a8232e") |
|
38 |
+GREEN_FG = HexColor("#1c5c41") # verdict « sous l'estimation » (site .vp-under) |
|
39 |
+GREEN_BG = HexColor("#e2f0e6") |
|
40 |
+RED_FG = HexColor("#8a2b2b") # verdict « au-dessus » (site .vp-over) |
|
41 |
+RED_BG = HexColor("#f6e2e2") |
|
42 |
+AMBER_FG = HexColor("#8a5a12") |
|
43 |
+AMBER_BG = HexColor("#faf0dc") |
|
44 |
+ |
|
45 |
+PAGE_W, PAGE_H = letter |
|
46 |
+M = 40 # marge |
|
47 |
+IMG_TIMEOUT = 6 |
|
48 |
+UA = "ImmoKaBot/1.0 (+https://www.immo-ka.com; contact@spboucher.ai)" |
|
49 |
+ |
|
50 |
+NBSP = " " # espace fine insécable (mise en forme française) |
|
51 |
+ |
|
52 |
+# clés de details jamais affichées (mêmes exclusions que la fiche web) |
|
53 |
+DETAIL_HIDDEN = { |
|
54 |
+ "pieces", "price_from", "cover_thumb", "photo_captions", "img_audited", |
|
55 |
+ "needs_image_review", "postal_code", "region", "transaction", |
|
56 |
+ "prix_pi2", "prix_m2", "listing_origin_url", |
|
57 |
+} |
|
58 |
+ |
|
59 |
+PROX_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 |
+] |
|
64 |
+ |
|
65 |
+_MOIS = ["janvier", "février", "mars", "avril", "mai", "juin", "juillet", |
|
66 |
+ "août", "septembre", "octobre", "novembre", "décembre"] |
|
67 |
+ |
|
68 |
+_PIED_FICHE = ("Données de l'annonce publiée par l'agence — Immo-Ka est un " |
|
69 |
+ "agrégateur indépendant. Enrichissements fournis à titre " |
|
70 |
+ "indicatif, sans valeur légale.") |
|
71 |
+ |
|
72 |
+ |
|
73 |
+def _fmt_money(v) -> str: |
|
74 |
+ return f"{v:,.0f}".replace(",", f"{NBSP}") + f"{NBSP}$" if v is not None else "—" |
|
75 |
+ |
|
76 |
+ |
|
77 |
+def _fmt_dist(m_: float) -> str: |
|
78 |
+ return (f"{round(m_ / 10) * 10}{NBSP}m" if m_ < 1000 |
|
79 |
+ else f"{m_ / 1000:.1f}".replace(".", ",") + f"{NBSP}km") |
|
80 |
+ |
|
81 |
+ |
|
82 |
+def _fmt_mois(d) -> str: |
|
83 |
+ """« 2017-11-04 » ou epoch → « novembre 2017 » (sinon valeur telle quelle).""" |
|
84 |
+ if d is None: |
|
85 |
+ return "—" |
|
86 |
+ try: |
|
87 |
+ if isinstance(d, (int, float)): |
|
88 |
+ st = time.localtime(d) |
|
89 |
+ return f"{_MOIS[st.tm_mon - 1]} {st.tm_year}" |
|
90 |
+ an, mois = str(d)[:7].split("-") |
|
91 |
+ return f"{_MOIS[int(mois) - 1]} {an}" |
|
92 |
+ except Exception: |
|
93 |
+ return str(d) |
|
94 |
+ |
|
95 |
+ |
|
96 |
+def _fmt_jour(ts: float) -> str: |
|
97 |
+ """epoch → « 4 novembre 2024 » (« 1er » pour le premier du mois).""" |
|
98 |
+ st = time.localtime(ts) |
|
99 |
+ j = "1er" if st.tm_mday == 1 else str(st.tm_mday) |
|
100 |
+ return f"{j} {_MOIS[st.tm_mon - 1]} {st.tm_year}" |
|
101 |
+ |
|
102 |
+ |
|
103 |
+def _fetch_image(url: str) -> ImageReader | None: |
|
104 |
+ try: |
|
105 |
+ r = requests.get(url, timeout=IMG_TIMEOUT, headers={"User-Agent": UA}) |
|
106 |
+ r.raise_for_status() |
|
107 |
+ raw = r.content |
|
108 |
+ try: |
|
109 |
+ # les photos source dépassent parfois 5 Mo chacune : sans |
|
110 |
+ # redimensionnement le PDF grimpe à plusieurs dizaines de Mo |
|
111 |
+ from PIL import Image |
|
112 |
+ im = Image.open(io.BytesIO(raw)) |
|
113 |
+ im.thumbnail((1000, 1000)) |
|
114 |
+ buf = io.BytesIO() |
|
115 |
+ im.convert("RGB").save(buf, "JPEG", quality=78) |
|
116 |
+ buf.seek(0) |
|
117 |
+ return ImageReader(buf) |
|
118 |
+ except Exception: |
|
119 |
+ return ImageReader(io.BytesIO(raw)) |
|
120 |
+ except Exception: |
|
121 |
+ return None |
|
122 |
+ |
|
123 |
+ |
|
124 |
+class _Style: |
|
125 |
+ """Petits composants du style « éditorial sharp » sur un canvas.""" |
|
126 |
+ |
|
127 |
+ def __init__(self, c: rl_canvas.Canvas): |
|
128 |
+ self.c = c |
|
129 |
+ |
|
130 |
+ def fond(self): |
|
131 |
+ self.c.setFillColor(PAPER) |
|
132 |
+ self.c.rect(0, 0, PAGE_W, PAGE_H, stroke=0, fill=1) |
|
133 |
+ |
|
134 |
+ def logo(self, x: float, y: float, taille: float = 22): |
|
135 |
+ """Wordmark Immo·Ka : « Immo » encre + boîte encre avec « Ka » cerise.""" |
|
136 |
+ c = self.c |
|
137 |
+ c.setFont("Helvetica-Bold", taille) |
|
138 |
+ c.setFillColor(INK) |
|
139 |
+ c.drawString(x, y, "Immo") |
|
140 |
+ w = c.stringWidth("Immo", "Helvetica-Bold", taille) |
|
141 |
+ bw = c.stringWidth("Ka", "Helvetica-Bold", taille) + 8 |
|
142 |
+ c.saveState() |
|
143 |
+ c.translate(x + w + 3 + bw / 2, y + taille * 0.32) |
|
144 |
+ c.rotate(-3) |
|
145 |
+ c.setFillColor(INK) |
|
146 |
+ c.roundRect(-bw / 2, -taille * 0.62, bw, taille * 1.15, 4, stroke=0, fill=1) |
|
147 |
+ c.setFillColor(ACCENT) |
|
148 |
+ c.drawCentredString(0, -taille * 0.30, "Ka") |
|
149 |
+ c.restoreState() |
|
150 |
+ return x + w + 6 + bw |
|
151 |
+ |
|
152 |
+ def entete(self, titre: str): |
|
153 |
+ c = self.c |
|
154 |
+ self.logo(M, PAGE_H - M - 16) |
|
155 |
+ c.setFont("Courier-Bold", 8) |
|
156 |
+ c.setFillColor(INK3) |
|
157 |
+ c.drawRightString(PAGE_W - M, PAGE_H - M - 6, titre.upper()) |
|
158 |
+ c.drawRightString(PAGE_W - M, PAGE_H - M - 16, |
|
159 |
+ time.strftime("GÉNÉRÉ LE %Y-%m-%d · WWW.IMMO-KA.COM")) |
|
160 |
+ c.setStrokeColor(INK) |
|
161 |
+ c.setLineWidth(2) |
|
162 |
+ c.line(M, PAGE_H - M - 26, PAGE_W - M, PAGE_H - M - 26) |
|
163 |
+ return PAGE_H - M - 40 # y de départ du contenu |
|
164 |
+ |
|
165 |
+ def pied(self, texte: str, page: int | None = None): |
|
166 |
+ c = self.c |
|
167 |
+ c.setStrokeColor(INK) |
|
168 |
+ c.setLineWidth(1) |
|
169 |
+ c.line(M, M + 16, PAGE_W - M, M + 16) |
|
170 |
+ c.setFont("Helvetica", 6.5) |
|
171 |
+ c.setFillColor(INK3) |
|
172 |
+ c.drawString(M, M + 6, texte[:150]) |
|
173 |
+ if page: |
|
174 |
+ c.setFont("Courier-Bold", 7) |
|
175 |
+ c.drawRightString(PAGE_W - M, M + 6, f"P.{page}") |
|
176 |
+ |
|
177 |
+ def pilule(self, x: float, y: float, texte: str, fg=ACCENT, bg=INK, |
|
178 |
+ stroke=None, taille: float = 8) -> float: |
|
179 |
+ """Chip arrondie ; retourne le x suivant.""" |
|
180 |
+ c = self.c |
|
181 |
+ w = c.stringWidth(texte, "Helvetica-Bold", taille) + 14 |
|
182 |
+ c.setFillColor(bg) |
|
183 |
+ if stroke: |
|
184 |
+ c.setStrokeColor(stroke) |
|
185 |
+ c.setLineWidth(1.2) |
|
186 |
+ c.roundRect(x, y, w, taille + 9, (taille + 9) / 2, |
|
187 |
+ stroke=1 if stroke else 0, fill=1) |
|
188 |
+ c.setFillColor(fg) |
|
189 |
+ c.setFont("Helvetica-Bold", taille) |
|
190 |
+ c.drawString(x + 7, y + 5, texte) |
|
191 |
+ return x + w + 6 |
|
192 |
+ |
|
193 |
+ def titre_section(self, y: float, texte: str) -> float: |
|
194 |
+ c = self.c |
|
195 |
+ # carré accent à ombre dure (signature de la passe éditoriale du site) |
|
196 |
+ c.setFillColor(INK) |
|
197 |
+ c.rect(M + 2, y - 1.5, 9, 9, stroke=0, fill=1) |
|
198 |
+ c.setFillColor(ACCENT) |
|
199 |
+ c.setStrokeColor(INK) |
|
200 |
+ c.setLineWidth(1) |
|
201 |
+ c.rect(M, y + 0.5, 9, 9, stroke=1, fill=1) |
|
202 |
+ c.setFont("Helvetica-Bold", 12.5) |
|
203 |
+ c.setFillColor(INK) |
|
204 |
+ c.drawString(M + 16, y, texte) |
|
205 |
+ c.setStrokeColor(ACCENT) |
|
206 |
+ c.setLineWidth(3) |
|
207 |
+ w = c.stringWidth(texte, "Helvetica-Bold", 12.5) |
|
208 |
+ c.line(M + 16, y - 4, M + 16 + w, y - 4) |
|
209 |
+ return y - 18 |
|
210 |
+ |
|
211 |
+ def paragraphe(self, x: float, y: float, texte: str, largeur: float, |
|
212 |
+ taille: float = 8.5, couleur=INK2, interligne: float = 11.5, |
|
213 |
+ max_lignes: int = 100) -> float: |
|
214 |
+ """Texte multi-lignes avec découpe aux mots ; retourne le y suivant.""" |
|
215 |
+ c = self.c |
|
216 |
+ c.setFont("Helvetica", taille) |
|
217 |
+ c.setFillColor(couleur) |
|
218 |
+ mots = texte.split() |
|
219 |
+ ligne = "" |
|
220 |
+ n = 0 |
|
221 |
+ for mot in mots: |
|
222 |
+ essai = (ligne + " " + mot).strip() |
|
223 |
+ if c.stringWidth(essai, "Helvetica", taille) <= largeur: |
|
224 |
+ ligne = essai |
|
225 |
+ else: |
|
226 |
+ c.drawString(x, y, ligne) |
|
227 |
+ y -= interligne |
|
228 |
+ n += 1 |
|
229 |
+ ligne = mot |
|
230 |
+ if n >= max_lignes - 1: |
|
231 |
+ ligne += " …" |
|
232 |
+ break |
|
233 |
+ if ligne: |
|
234 |
+ c.drawString(x, y, ligne) |
|
235 |
+ y -= interligne |
|
236 |
+ return y |
|
237 |
+ |
|
238 |
+ def nb_lignes(self, texte: str, largeur: float, taille: float = 8.5, |
|
239 |
+ max_lignes: int = 100) -> int: |
|
240 |
+ """Nombre de lignes qu'occupera `paragraphe` (pour réserver la place).""" |
|
241 |
+ c = self.c |
|
242 |
+ mots = texte.split() |
|
243 |
+ ligne = "" |
|
244 |
+ n = 1 |
|
245 |
+ for mot in mots: |
|
246 |
+ essai = (ligne + " " + mot).strip() |
|
247 |
+ if c.stringWidth(essai, "Helvetica", taille) <= largeur: |
|
248 |
+ ligne = essai |
|
249 |
+ else: |
|
250 |
+ n += 1 |
|
251 |
+ ligne = mot |
|
252 |
+ if n >= max_lignes: |
|
253 |
+ break |
|
254 |
+ return min(n, max_lignes) |
|
255 |
+ |
|
256 |
+ def tuile(self, x: float, y: float, w: float, h: float, |
|
257 |
+ val: str, lab: str): |
|
258 |
+ """Tuile KPI blanche à ombre décalée (x, y = coin inférieur gauche).""" |
|
259 |
+ c = self.c |
|
260 |
+ c.setFillColor(INK) |
|
261 |
+ c.roundRect(x + 2.5, y - 2.5, w, h, 5, stroke=0, fill=1) |
|
262 |
+ c.setFillColor(SURFACE) |
|
263 |
+ c.setStrokeColor(INK) |
|
264 |
+ c.setLineWidth(1.2) |
|
265 |
+ c.roundRect(x, y, w, h, 5, stroke=1, fill=1) |
|
266 |
+ c.setFont("Helvetica-Bold", 12) |
|
267 |
+ c.setFillColor(INK) |
|
268 |
+ c.drawString(x + 8, y + h - 20, str(val)) |
|
269 |
+ c.setFont("Courier", 6.2) |
|
270 |
+ c.setFillColor(INK3) |
|
271 |
+ c.drawString(x + 8, y + 7, lab.upper()[: max(4, int(w / 4.2))]) |
|
272 |
+ |
|
273 |
+ |
|
274 |
+def _titre_droite(c, y, x, texte): |
|
275 |
+ c.setFillColor(INK) |
|
276 |
+ c.rect(x + 2, y - 1.5, 9, 9, stroke=0, fill=1) |
|
277 |
+ c.setFillColor(ACCENT) |
|
278 |
+ c.setStrokeColor(INK) |
|
279 |
+ c.setLineWidth(1) |
|
280 |
+ c.rect(x, y + 0.5, 9, 9, stroke=1, fill=1) |
|
281 |
+ c.setFont("Helvetica-Bold", 12.5) |
|
282 |
+ c.setFillColor(INK) |
|
283 |
+ c.drawString(x + 16, y, texte) |
|
284 |
+ c.setStrokeColor(ACCENT) |
|
285 |
+ c.setLineWidth(3) |
|
286 |
+ c.line(x + 16, y - 4, x + 16 + c.stringWidth(texte, "Helvetica-Bold", 12.5), y - 4) |
|
287 |
+ return y - 18 |
|
288 |
+ |
|
289 |
+ |
|
290 |
+def _verdict_vraiprix(price, vp): |
|
291 |
+ """Pastille verdict identique au site : ±% vs estimation, masqué si |
|
292 |
+ |pct| > 60 (prix non comparable : terrain, loyer…).""" |
|
293 |
+ if not price or not vp or vp.get("value") in (None, 0): |
|
294 |
+ return None |
|
295 |
+ pct = round((price - vp["value"]) / vp["value"] * 100) |
|
296 |
+ if abs(pct) > 60: |
|
297 |
+ return None |
|
298 |
+ if pct >= 1: |
|
299 |
+ return (f"▲{NBSP}{pct}{NBSP}% au-dessus de l'estimation", |
|
300 |
+ RED_FG, RED_BG, ACCENT_DEEP) |
|
301 |
+ if pct <= -1: |
|
302 |
+ return (f"▼{NBSP}{abs(pct)}{NBSP}% sous l'estimation", |
|
303 |
+ GREEN_FG, GREEN_BG, GREEN_FG) |
|
304 |
+ return (f"≈{NBSP}Prix aligné sur l'estimation", INK2, SURFACE, INK) |
|
305 |
+ |
|
306 |
+ |
|
307 |
+def fiche_pdf(uid: str) -> bytes | None: |
|
308 |
+ """PDF de la fiche de la propriété `uid` — reflète tous les blocs de la |
|
309 |
+ fiche web ; None si introuvable.""" |
|
310 |
+ con = db.connect() |
|
311 |
+ row = con.execute("SELECT * FROM listings WHERE uid=?", (uid,)).fetchone() |
|
312 |
+ if row is None: |
|
313 |
+ con.close() |
|
314 |
+ return None |
|
315 |
+ l = dict(row) |
|
316 |
+ l["features"] = json.loads(l.get("features") or "[]") |
|
317 |
+ l["images"] = json.loads(l.get("images") or "[]") |
|
318 |
+ l["details"] = json.loads(l.get("details") or "{}") |
|
319 |
+ try: |
|
320 |
+ l["vraiprix"] = json.loads(l["vraiprix"]) or None if l.get("vraiprix") else None |
|
321 |
+ except (ValueError, TypeError): |
|
322 |
+ l["vraiprix"] = None |
|
323 |
+ price_history = [dict(r) for r in con.execute( |
|
324 |
+ "SELECT ts, price FROM price_log WHERE uid=? ORDER BY ts DESC LIMIT 10", |
|
325 |
+ (uid,)).fetchall()] |
|
326 |
+ quartier = None |
|
327 |
+ lat, lng = l.get("lat"), l.get("lng") |
|
328 |
+ geo = lat is not None and lng is not None |
|
329 |
+ if geo: |
|
330 |
+ from . import quartier as qmod |
|
331 |
+ dauid = l.get("dauid") |
|
332 |
+ try: |
|
333 |
+ quartier = qmod.fiche_quartier( |
|
334 |
+ lat, lng, l.get("city") or "", |
|
335 |
+ dauid if dauid and dauid != "hors-zone" else None) |
|
336 |
+ except Exception: |
|
337 |
+ quartier = None |
|
338 |
+ try: |
|
339 |
+ reg = json.loads( |
|
340 |
+ (db.DB_PATH.parent / "sources.json").read_text("utf-8"))["sources"] |
|
341 |
+ src_name = next((s["name"] for s in reg if s["id"] == l["source"]), |
|
342 |
+ l["source"]) |
|
343 |
+ except Exception: |
|
344 |
+ src_name = l["source"] |
|
345 |
+ con.close() |
|
346 |
+ |
|
347 |
+ # --- enrichissements (mêmes modules que les blocs de la fiche web) ------- |
|
348 |
+ def _safe(fn, *a, **k): |
|
349 |
+ try: |
|
350 |
+ return fn(*a, **k) |
|
351 |
+ except Exception: |
|
352 |
+ return None |
|
353 |
+ |
|
354 |
+ from . import air as air_mod |
|
355 |
+ from . import commerces as com_mod |
|
356 |
+ from . import gaz as gaz_mod |
|
357 |
+ from . import hydro as hydro_mod |
|
358 |
+ from . import inondation as inon_mod |
|
359 |
+ inon = _safe(inon_mod.lookup, lat, lng) if geo else None |
|
360 |
+ air_d = _safe(air_mod.lookup, lat, lng) if geo else None |
|
361 |
+ gaz_d = _safe(gaz_mod.nearby, lat, lng) if geo else None |
|
362 |
+ com = _safe(com_mod.nearby, lat, lng) if geo else None |
|
363 |
+ hyd = _safe(hydro_mod.estimate, adresse=l.get("address") or "", |
|
364 |
+ uid=uid, lat=lat, lng=lng, solve=False) |
|
365 |
+ |
|
366 |
+ d = l["details"] |
|
367 |
+ vp = l.get("vraiprix") |
|
368 |
+ location = (d.get("transaction") == "location") |
|
369 |
+ |
|
370 |
+ buf = io.BytesIO() |
|
371 |
+ c = rl_canvas.Canvas(buf, pagesize=letter) |
|
372 |
+ c.setTitle(f"Immo-Ka — {l.get('address') or l.get('title') or uid}") |
|
373 |
+ s = _Style(c) |
|
374 |
+ s.fond() |
|
375 |
+ y = s.entete("Fiche propriété") |
|
376 |
+ page = [1] |
|
377 |
+ |
|
378 |
+ def saut_page() -> float: |
|
379 |
+ s.pied(_PIED_FICHE, page[0]) |
|
380 |
+ c.showPage() |
|
381 |
+ page[0] += 1 |
|
382 |
+ s.fond() |
|
383 |
+ return s.entete("Fiche propriété · suite") |
|
384 |
+ |
|
385 |
+ def place(y_: float, besoin: float) -> float: |
|
386 |
+ """Saute de page si `besoin` points ne tiennent plus sous y_.""" |
|
387 |
+ return saut_page() if (y_ - besoin) < M + 34 else y_ |
|
388 |
+ |
|
389 |
+ # --- héro : prix + pastille verdict Vrai-Prix ----------------------------- |
|
390 |
+ prix_txt = (_fmt_money(l["price"]) + (f"{NBSP}/ mois" if location else "")) \ |
|
391 |
+ if l.get("price") else (l.get("price_label") or "Prix sur demande") |
|
392 |
+ c.setFont("Helvetica-Bold", 30) |
|
393 |
+ c.setFillColor(INK) |
|
394 |
+ c.drawString(M, y - 24, prix_txt) |
|
395 |
+ bx = M + c.stringWidth(prix_txt, "Helvetica-Bold", 30) + 14 |
|
396 |
+ verd = _verdict_vraiprix(l.get("price"), vp) |
|
397 |
+ if verd: |
|
398 |
+ txt, fg, bg, stk = verd |
|
399 |
+ bx = s.pilule(bx, y - 22, txt, fg, bg, stk) |
|
400 |
+ if l.get("property_type"): |
|
401 |
+ s.pilule(bx, y - 22, l["property_type"], ACCENT, INK) |
|
402 |
+ y -= 44 |
|
403 |
+ |
|
404 |
+ # --- titre + adresse ------------------------------------------------------- |
|
405 |
+ titre = l.get("address") or l.get("title") or "" |
|
406 |
+ c.setFont("Helvetica-Bold", 13) |
|
407 |
+ c.setFillColor(INK) |
|
408 |
+ c.drawString(M, y, titre[:80]) |
|
409 |
+ y -= 14 |
|
410 |
+ loc = " · ".join(x for x in (l.get("sector"), l.get("city"), l.get("region")) |
|
411 |
+ if x) |
|
412 |
+ c.setFont("Helvetica", 9) |
|
413 |
+ c.setFillColor(INK2) |
|
414 |
+ c.drawString(M, y, loc[:110]) |
|
415 |
+ y -= 20 |
|
416 |
+ |
|
417 |
+ # --- chips clés (mêmes specs que le héro du site) -------------------------- |
|
418 |
+ chips = [] |
|
419 |
+ if l.get("bedrooms") is not None: |
|
420 |
+ chips.append(f"{l['bedrooms']}{NBSP}ch.") |
|
421 |
+ if l.get("bathrooms") is not None: |
|
422 |
+ chips.append(f"{l['bathrooms']}{NBSP}sdb") |
|
423 |
+ if l.get("powder_rooms"): |
|
424 |
+ chips.append(f"{l['powder_rooms']}{NBSP}s. d'eau") |
|
425 |
+ if l.get("area_sqft"): |
|
426 |
+ chips.append(f"{round(l['area_sqft']):,}".replace(",", NBSP) + f"{NBSP}pi²") |
|
427 |
+ if l.get("lot_sqft"): |
|
428 |
+ chips.append(f"Terrain {round(l['lot_sqft']):,}".replace(",", NBSP) |
|
429 |
+ + f"{NBSP}pi²") |
|
430 |
+ if l.get("year_built"): |
|
431 |
+ chips.append(f"Construit en {l['year_built']}") |
|
432 |
+ if l.get("price") and l.get("area_sqft") and l["area_sqft"] > 200: |
|
433 |
+ chips.append(f"{round(l['price'] / l['area_sqft'])}{NBSP}$/pi²") |
|
434 |
+ if l.get("mls"): |
|
435 |
+ chips.append(f"MLS {l['mls']}") |
|
436 |
+ if l.get("first_seen"): |
|
437 |
+ dom = max(0, int((time.time() - l["first_seen"]) // 86400)) |
|
438 |
+ chips.append(f"{dom}{NBSP}j sur Immo-Ka") |
|
439 |
+ x = M |
|
440 |
+ for chip in chips[:8]: |
|
441 |
+ x = s.pilule(x, y - 6, chip) |
|
442 |
+ y -= 30 |
|
443 |
+ |
|
444 |
+ # --- photos : 1 grande + 3 vignettes --------------------------------------- |
|
445 |
+ imgs = [im for im in (_fetch_image(u) for u in l["images"][:4]) if im] |
|
446 |
+ if imgs: |
|
447 |
+ big_h = 170 |
|
448 |
+ big_w = big_h * 16 / 10.5 |
|
449 |
+ c.setStrokeColor(INK) |
|
450 |
+ c.setLineWidth(1.5) |
|
451 |
+ try: |
|
452 |
+ c.drawImage(imgs[0], M, y - big_h, big_w, big_h, |
|
453 |
+ preserveAspectRatio=True, anchor="c", mask="auto") |
|
454 |
+ except Exception: |
|
455 |
+ pass |
|
456 |
+ c.rect(M, y - big_h, big_w, big_h, stroke=1, fill=0) |
|
457 |
+ tx = M + big_w + 10 |
|
458 |
+ th = (big_h - 16) / 3 |
|
459 |
+ for i, im in enumerate(imgs[1:4]): |
|
460 |
+ ty = y - (i + 1) * th - i * 8 |
|
461 |
+ try: |
|
462 |
+ c.drawImage(im, tx, ty, PAGE_W - M - tx, th, |
|
463 |
+ preserveAspectRatio=True, anchor="c", mask="auto") |
|
464 |
+ c.rect(tx, ty, PAGE_W - M - tx, th, stroke=1, fill=0) |
|
465 |
+ except Exception: |
|
466 |
+ pass |
|
467 |
+ y -= big_h + 18 |
|
468 |
+ |
|
469 |
+ # --- baisse/hausse de prix (bandeau, comme le site) ------------------------- |
|
470 |
+ ph = [p for p in price_history if p.get("price")] |
|
471 |
+ if len(ph) >= 2 and ph[0]["price"] != ph[1]["price"]: |
|
472 |
+ y = place(y, 34) |
|
473 |
+ de, a = ph[1]["price"], ph[0]["price"] |
|
474 |
+ bas = a < de |
|
475 |
+ c.setFillColor(GREEN_BG if bas else RED_BG) |
|
476 |
+ c.setStrokeColor(GREEN_FG if bas else RED_FG) |
|
477 |
+ c.setLineWidth(1.2) |
|
478 |
+ c.roundRect(M, y - 20, PAGE_W - 2 * M, 24, 5, stroke=1, fill=1) |
|
479 |
+ c.setFont("Helvetica-Bold", 9) |
|
480 |
+ c.setFillColor(GREEN_FG if bas else RED_FG) |
|
481 |
+ c.drawString(M + 10, y - 12, |
|
482 |
+ f"{'▼' if bas else '▲'} Prix passé de {_fmt_money(de)} à " |
|
483 |
+ f"{_fmt_money(a)} le {_fmt_jour(ph[0]['ts'])}") |
|
484 |
+ y -= 34 |
|
485 |
+ |
|
486 |
+ # --- description | inclusions (2 colonnes, comme le site) ------------------- |
|
487 |
+ col_g = PAGE_W * 0.56 - M |
|
488 |
+ feats = [str(f) for f in (l.get("features") or []) if f] |
|
489 |
+ desc = (l.get("description") or "").strip() |
|
490 |
+ if desc or feats: |
|
491 |
+ besoin = 30 + max( |
|
492 |
+ s.nb_lignes(desc, col_g, max_lignes=26) * 11.5 if desc else 0, |
|
493 |
+ min(len(feats), 18) * 12) |
|
494 |
+ y = place(y, min(besoin, 340)) |
|
495 |
+ y_desc = y |
|
496 |
+ if desc: |
|
497 |
+ y_desc = s.titre_section(y, "Description") |
|
498 |
+ y_desc = s.paragraphe(M, y_desc, desc, col_g, max_lignes=26) |
|
499 |
+ y_incl = y |
|
500 |
+ if feats: |
|
501 |
+ xr = M + col_g + 24 |
|
502 |
+ y_incl = _titre_droite(c, y, xr, "Inclusions et caractéristiques") |
|
503 |
+ c.setFont("Helvetica", 8.5) |
|
504 |
+ for f in feats[:18]: |
|
505 |
+ c.setFillColor(ACCENT_DEEP) |
|
506 |
+ c.drawString(xr, y_incl, "✓") |
|
507 |
+ c.setFillColor(INK2) |
|
508 |
+ c.drawString(xr + 12, y_incl, f[:42]) |
|
509 |
+ y_incl -= 12 |
|
510 |
+ if len(feats) > 18: |
|
511 |
+ c.setFillColor(INK3) |
|
512 |
+ c.drawString(xr + 12, y_incl, f"+ {len(feats) - 18} autres…") |
|
513 |
+ y_incl -= 12 |
|
514 |
+ y = min(y_desc, y_incl) - 16 |
|
515 |
+ |
|
516 |
+ # --- caractéristiques (details, mêmes exclusions que le site) --------------- |
|
517 |
+ dets = [(str(k), str(v)) for k, v in d.items() |
|
518 |
+ if k not in DETAIL_HIDDEN and v not in (None, "", [], {}) |
|
519 |
+ and str(v).strip("-— ") != "" and not isinstance(v, (dict, list))] |
|
520 |
+ if dets: |
|
521 |
+ col_w = PAGE_W - 2 * M |
|
522 |
+ besoin = 40 + sum( |
|
523 |
+ s.nb_lignes(v, col_w - 190, taille=8, max_lignes=4) * 11 + 3 |
|
524 |
+ for _, v in dets) |
|
525 |
+ y = place(y, min(besoin, 420)) |
|
526 |
+ y = s.titre_section(y, "Caractéristiques") |
|
527 |
+ c.setFont("Courier-Bold", 7) |
|
528 |
+ c.setFillColor(INK3) |
|
529 |
+ c.drawString(M, y, "DÉTAIL") |
|
530 |
+ c.drawString(M + 180, y, "VALEUR") |
|
531 |
+ y -= 4 |
|
532 |
+ c.setStrokeColor(INK) |
|
533 |
+ c.setLineWidth(1.5) |
|
534 |
+ c.line(M, y, PAGE_W - M, y) |
|
535 |
+ y -= 12 |
|
536 |
+ for i, (k, v) in enumerate(dets): |
|
537 |
+ nl = s.nb_lignes(v, col_w - 190, taille=8, max_lignes=4) |
|
538 |
+ y = place(y, nl * 11 + 6) |
|
539 |
+ if i % 2 == 0: # zébrage éditorial |
|
540 |
+ c.setFillColor(SURFACE) |
|
541 |
+ c.rect(M - 3, y - (nl - 1) * 11 - 3, col_w + 6, nl * 11 + 2, |
|
542 |
+ stroke=0, fill=1) |
|
543 |
+ c.setFont("Helvetica-Bold", 8) |
|
544 |
+ c.setFillColor(INK) |
|
545 |
+ c.drawString(M, y, k[:38]) |
|
546 |
+ y = s.paragraphe(M + 180, y, v, col_w - 190, taille=8, |
|
547 |
+ interligne=11, max_lignes=4) |
|
548 |
+ y -= 3 |
|
549 |
+ y -= 10 |
|
550 |
+ |
|
551 |
+ # --- pièces (details.pieces) ------------------------------------------------- |
|
552 |
+ pieces = d.get("pieces") or [] |
|
553 |
+ if isinstance(pieces, list) and pieces: |
|
554 |
+ rows = pieces[:24] |
|
555 |
+ y = place(y, 46 + len(rows) * 12) |
|
556 |
+ y = s.titre_section(y, "Pièces") |
|
557 |
+ c.setFont("Courier-Bold", 7) |
|
558 |
+ c.setFillColor(INK3) |
|
559 |
+ for lab, xoff in (("PIÈCE", 0), ("NIVEAU", 190), ("DIMENSIONS", 330), |
|
560 |
+ ("REVÊTEMENT", 430)): |
|
561 |
+ c.drawString(M + xoff, y, lab) |
|
562 |
+ y -= 4 |
|
563 |
+ c.setStrokeColor(INK) |
|
564 |
+ c.setLineWidth(1.5) |
|
565 |
+ c.line(M, y, PAGE_W - M, y) |
|
566 |
+ y -= 11 |
|
567 |
+ for i, p in enumerate(rows): |
|
568 |
+ if i % 2 == 0: |
|
569 |
+ c.setFillColor(SURFACE) |
|
570 |
+ c.rect(M - 3, y - 3, PAGE_W - 2 * M + 6, 12, stroke=0, fill=1) |
|
571 |
+ c.setFont("Helvetica-Bold", 8) |
|
572 |
+ c.setFillColor(INK) |
|
573 |
+ c.drawString(M, y, str(p.get("nom") or "—")[:36]) |
|
574 |
+ c.setFont("Helvetica", 8) |
|
575 |
+ c.setFillColor(INK2) |
|
576 |
+ c.drawString(M + 190, y, str(p.get("niveau") or "—")[:24]) |
|
577 |
+ c.drawString(M + 330, y, str(p.get("dimensions") or "—")[:18]) |
|
578 |
+ c.drawString(M + 430, y, str(p.get("revetement") or "—")[:22]) |
|
579 |
+ y -= 12 |
|
580 |
+ y -= 10 |
|
581 |
+ |
|
582 |
+ # --- analyse Vrai-Prix (valeur, jauge, rôle foncier) ------------------------- |
|
583 |
+ if vp and vp.get("value") is not None: |
|
584 |
+ role = [(lab, vp.get(k)) for k, lab in |
|
585 |
+ (("valeur_role", "Évaluation au rôle"), |
|
586 |
+ ("valeur_terrain", "Valeur du terrain"), |
|
587 |
+ ("valeur_batiment", "Valeur du bâtiment")) |
|
588 |
+ if vp.get(k)] |
|
589 |
+ besoin = 96 + (56 if vp.get("low") is not None else 0) \ |
|
590 |
+ + (62 if role else 0) + 18 |
|
591 |
+ y = place(y, besoin) |
|
592 |
+ y = s.titre_section(y, "Analyse Vrai-Prix") |
|
593 |
+ val_txt = _fmt_money(round(vp["value"])) |
|
594 |
+ c.setFont("Helvetica-Bold", 17) |
|
595 |
+ c.setFillColor(INK) |
|
596 |
+ c.drawString(M, y - 10, val_txt) |
|
597 |
+ c.setFont("Helvetica", 8.5) |
|
598 |
+ c.setFillColor(INK2) |
|
599 |
+ c.drawString(M + c.stringWidth(val_txt, "Helvetica-Bold", 17) + 8, |
|
600 |
+ y - 10, "— valeur marchande estimée par le modèle Vrai-Prix") |
|
601 |
+ meta = [] |
|
602 |
+ if vp.get("confidence"): |
|
603 |
+ meta.append(f"confiance {vp['confidence']}" |
|
604 |
+ + (f" ({vp['confidence_pct']}{NBSP}%)" |
|
605 |
+ if vp.get("confidence_pct") else "")) |
|
606 |
+ if vp.get("annee_construction_role"): |
|
607 |
+ meta.append(f"construction {vp['annee_construction_role']} (rôle)") |
|
608 |
+ if vp.get("superficie_terrain_role_m2"): |
|
609 |
+ meta.append( |
|
610 |
+ f"terrain {round(vp['superficie_terrain_role_m2']):,}".replace(",", NBSP) |
|
611 |
+ + f"{NBSP}m² (rôle)") |
|
612 |
+ if meta: |
|
613 |
+ c.setFont("Helvetica", 8) |
|
614 |
+ c.setFillColor(INK3) |
|
615 |
+ c.drawString(M, y - 24, " · ".join(meta)[:115]) |
|
616 |
+ verd = _verdict_vraiprix(l.get("price"), vp) |
|
617 |
+ if verd: |
|
618 |
+ txt, fg, bg, stk = verd |
|
619 |
+ s.pilule(M, y - 48, txt, fg, bg, stk) |
|
620 |
+ y -= 58 |
|
621 |
+ lo, hi = vp.get("low"), vp.get("high") |
|
622 |
+ if lo is not None and hi is not None and hi > lo: |
|
623 |
+ larg = PAGE_W - 2 * M |
|
624 |
+ base = y - 22 |
|
625 |
+ span = hi - lo |
|
626 |
+ c.setFillColor(ACCENT_SOFT) |
|
627 |
+ c.setStrokeColor(INK) |
|
628 |
+ c.setLineWidth(1) |
|
629 |
+ c.roundRect(M, base, larg, 12, 6, stroke=1, fill=1) |
|
630 |
+ |
|
631 |
+ def _pos(v): |
|
632 |
+ return M + larg * max(0.02, min(0.98, (v - lo) / span)) |
|
633 |
+ |
|
634 |
+ px = _pos(vp["value"]) |
|
635 |
+ c.setStrokeColor(ACCENT_DEEP) |
|
636 |
+ c.setLineWidth(2.5) |
|
637 |
+ c.line(px, base - 3, px, base + 15) |
|
638 |
+ c.setFont("Courier-Bold", 6.5) |
|
639 |
+ c.setFillColor(ACCENT_DEEP) |
|
640 |
+ c.drawString(min(max(px - 30, M), PAGE_W - M - 100), base + 18, |
|
641 |
+ f"ESTIMATION · {_fmt_money(round(vp['value']))}") |
|
642 |
+ if l.get("price") and lo * 0.7 <= l["price"] <= hi * 1.3: |
|
643 |
+ ax = _pos(l["price"]) |
|
644 |
+ c.setStrokeColor(INK) |
|
645 |
+ c.setLineWidth(2) |
|
646 |
+ c.line(ax, base - 3, ax, base + 15) |
|
647 |
+ c.setFont("Courier-Bold", 6.5) |
|
648 |
+ c.setFillColor(INK) |
|
649 |
+ c.drawString(min(max(ax - 30, M), PAGE_W - M - 116), base - 12, |
|
650 |
+ f"PRIX DEMANDÉ · {_fmt_money(l['price'])}") |
|
651 |
+ c.setFont("Helvetica", 6.5) |
|
652 |
+ c.setFillColor(INK3) |
|
653 |
+ c.drawString(M, base - 24, _fmt_money(round(lo))) |
|
654 |
+ c.drawRightString(PAGE_W - M, base - 24, |
|
655 |
+ _fmt_money(round(hi)) + " — fourchette d'estimation") |
|
656 |
+ y = base - 36 |
|
657 |
+ if role: |
|
658 |
+ tw = (PAGE_W - 2 * M - (len(role) - 1) * 10) / len(role) |
|
659 |
+ for i, (lab, v) in enumerate(role): |
|
660 |
+ s.tuile(M + i * (tw + 10), y - 44, tw, 38, |
|
661 |
+ _fmt_money(round(v)), lab) |
|
662 |
+ y -= 56 |
|
663 |
+ c.setFont("Helvetica", 6.5) |
|
664 |
+ c.setFillColor(INK3) |
|
665 |
+ c.drawString(M, y - 2, "Estimation automatisée Vrai-Prix (vrai-prix.ca) " |
|
666 |
+ "croisée au rôle d'évaluation foncière — ne " |
|
667 |
+ "remplace pas une évaluation professionnelle.") |
|
668 |
+ y -= 16 |
|
669 |
+ |
|
670 |
+ # --- historique de prix ------------------------------------------------------- |
|
671 |
+ if len(ph) >= 2: |
|
672 |
+ rows = ph[:8] |
|
673 |
+ y = place(y, 46 + len(rows) * 12) |
|
674 |
+ y = s.titre_section(y, "Historique du prix demandé") |
|
675 |
+ c.setFont("Courier-Bold", 7) |
|
676 |
+ c.setFillColor(INK3) |
|
677 |
+ for lab, xoff in (("DATE", 0), ("PRIX AFFICHÉ", 190), ("VARIATION", 330)): |
|
678 |
+ c.drawString(M + xoff, y, lab) |
|
679 |
+ y -= 4 |
|
680 |
+ c.setStrokeColor(INK) |
|
681 |
+ c.setLineWidth(1.5) |
|
682 |
+ c.line(M, y, PAGE_W - M, y) |
|
683 |
+ y -= 11 |
|
684 |
+ for i, p in enumerate(rows): |
|
685 |
+ prec = rows[i + 1]["price"] if i + 1 < len(rows) else None |
|
686 |
+ c.setFont("Helvetica", 8) |
|
687 |
+ c.setFillColor(INK2) |
|
688 |
+ c.drawString(M, y, _fmt_jour(p["ts"])) |
|
689 |
+ c.setFont("Helvetica-Bold", 8) |
|
690 |
+ c.setFillColor(INK) |
|
691 |
+ c.drawString(M + 190, y, _fmt_money(p["price"])) |
|
692 |
+ if prec and p.get("price"): |
|
693 |
+ delta = p["price"] - prec |
|
694 |
+ c.setFont("Courier-Bold", 7.5) |
|
695 |
+ c.setFillColor(GREEN_FG if delta < 0 |
|
696 |
+ else (RED_FG if delta > 0 else INK3)) |
|
697 |
+ sig = "▼" if delta < 0 else ("▲" if delta > 0 else "=") |
|
698 |
+ c.drawString(M + 330, y, |
|
699 |
+ f"{sig} {_fmt_money(abs(delta))}" if delta else "=") |
|
700 |
+ y -= 12 |
|
701 |
+ y -= 10 |
|
702 |
+ |
|
703 |
+ # --- risque d'inondation (BDZI) ------------------------------------------------ |
|
704 |
+ if inon: |
|
705 |
+ zones = (inon.get("zones") or [])[:3] |
|
706 |
+ y = place(y, 56 + len(zones) * 12) |
|
707 |
+ y = s.titre_section(y, "Risque d'inondation") |
|
708 |
+ statut = inon.get("statut") |
|
709 |
+ sev = {"eleve": "récurrence élevée", "modere": "récurrence modérée", |
|
710 |
+ "present": "zone cartographiée"}.get(inon.get("severite") or "", "") |
|
711 |
+ if statut == "en_zone": |
|
712 |
+ s.pilule(M, y - 12, f"En zone inondable ({sev})" if sev |
|
713 |
+ else "En zone inondable", RED_FG, RED_BG, RED_FG) |
|
714 |
+ elif statut == "a_proximite": |
|
715 |
+ d0 = zones[0]["distance_m"] if zones else "?" |
|
716 |
+ s.pilule(M, y - 12, f"Zone inondable à ~{d0} m", |
|
717 |
+ AMBER_FG, AMBER_BG, AMBER_FG) |
|
718 |
+ elif statut == "hors_zone": |
|
719 |
+ s.pilule(M, y - 12, "Hors des zones inondables cartographiées", |
|
720 |
+ GREEN_FG, GREEN_BG, GREEN_FG) |
|
721 |
+ else: |
|
722 |
+ s.pilule(M, y - 12, "Secteur non couvert par la cartographie BDZI", |
|
723 |
+ INK2, SURFACE, INK) |
|
724 |
+ y -= 26 |
|
725 |
+ for z in zones: |
|
726 |
+ c.setFont("Helvetica", 8) |
|
727 |
+ c.setFillColor(INK2) |
|
728 |
+ c.drawString(M, y, f"{z.get('type') or 'Zone'} — à {z.get('distance_m')} m" |
|
729 |
+ + (f" (rapport {z['date_rapport']})" |
|
730 |
+ if z.get("date_rapport") else "")) |
|
731 |
+ y -= 12 |
|
732 |
+ y -= 8 |
|
733 |
+ |
|
734 |
+ # --- qualité de l'air (RSQAQ) --------------------------------------------------- |
|
735 |
+ if air_d and air_d.get("station"): |
|
736 |
+ mes = air_d.get("mesures") or {} |
|
737 |
+ y = place(y, 44 + len(mes) * 11) |
|
738 |
+ y = s.titre_section(y, "Qualité de l'air") |
|
739 |
+ c.setFont("Helvetica", 8.5) |
|
740 |
+ c.setFillColor(INK2) |
|
741 |
+ c.drawString(M, y - 2, |
|
742 |
+ f"Station {air_d['station']} ({air_d['ville']}), à " |
|
743 |
+ + f"{air_d['distance_km']}".replace(".", ",") + f"{NBSP}km " |
|
744 |
+ "de la propriété — moyennes annuelles :") |
|
745 |
+ y -= 16 |
|
746 |
+ for pol, m_ in list(mes.items())[:5]: |
|
747 |
+ c.setFont("Helvetica-Bold", 8) |
|
748 |
+ c.setFillColor(INK) |
|
749 |
+ c.drawString(M, y, pol) |
|
750 |
+ c.setFont("Helvetica", 8) |
|
751 |
+ c.setFillColor(INK2) |
|
752 |
+ val = (f"{m_['moyenne']:.1f}".replace(".", ",") |
|
753 |
+ if m_.get("moyenne") is not None else "—") |
|
754 |
+ txt = f"{val}{NBSP}{m_.get('unite', '')} ({m_.get('annee', '')})" |
|
755 |
+ if m_.get("ref"): |
|
756 |
+ txt += f" · référence {m_.get('ref_nom', '')} : {m_['ref']}" |
|
757 |
+ c.drawString(M + 56, y, txt[:95]) |
|
758 |
+ y -= 11 |
|
759 |
+ y -= 8 |
|
760 |
+ |
|
761 |
+ # --- coût d'électricité (Hydro-Québec) ------------------------------------------- |
|
762 |
+ if hyd and hyd.get("disponible"): |
|
763 |
+ y = place(y, 52) |
|
764 |
+ y = s.titre_section(y, "Coût d'électricité (Hydro-Québec)") |
|
765 |
+ c.setFont("Helvetica-Bold", 15) |
|
766 |
+ c.setFillColor(INK) |
|
767 |
+ mens = f"≈{NBSP}{_fmt_money(round(hyd['cout_mensuel']))} / mois" |
|
768 |
+ c.drawString(M, y - 8, mens) |
|
769 |
+ c.setFont("Helvetica", 8.5) |
|
770 |
+ c.setFillColor(INK2) |
|
771 |
+ suite = f"{_fmt_money(round(hyd['cout_annuel']))} / an" |
|
772 |
+ if hyd.get("kwh_annuel"): |
|
773 |
+ suite += (f" · {round(hyd['kwh_annuel']):,}".replace(",", NBSP) |
|
774 |
+ + f"{NBSP}kWh/an") |
|
775 |
+ suite += " — estimation officielle Hydro-Québec pour cette adresse." |
|
776 |
+ c.drawString(M + c.stringWidth(mens, "Helvetica-Bold", 15) + 10, y - 8, suite) |
|
777 |
+ y -= 26 |
|
778 |
+ |
|
779 |
+ # --- commerces et transport -------------------------------------------------------- |
|
780 |
+ if com and (com.get("commerces") or com.get("transit")): |
|
781 |
+ entrees = ([(t["commerce"], t["nom"], t["dist_m"]) |
|
782 |
+ for t in (com.get("transit") or [])[:4]] |
|
783 |
+ + [(i["commerce"], i["nom"], i["dist_m"]) |
|
784 |
+ for i in (com.get("commerces") or [])[:12]]) |
|
785 |
+ lignes = (len(entrees) + 1) // 2 |
|
786 |
+ y = place(y, 36 + lignes * 13) |
|
787 |
+ y = s.titre_section(y, "Commerces et transport") |
|
788 |
+ col_w = (PAGE_W - 2 * M) / 2 |
|
789 |
+ for i, (cat, nom, dist) in enumerate(entrees): |
|
790 |
+ px = M if i % 2 == 0 else M + col_w + 10 |
|
791 |
+ py = y - (i // 2) * 13 |
|
792 |
+ c.setFont("Helvetica", 8) |
|
793 |
+ c.setFillColor(INK2) |
|
794 |
+ nomc = nom if len(nom) <= 22 else nom[:21] + "…" |
|
795 |
+ c.drawString(px, py, f"{cat} — {nomc}") |
|
796 |
+ c.setFont("Courier-Bold", 7.5) |
|
797 |
+ c.setFillColor(INK) |
|
798 |
+ c.drawRightString(px + col_w - 14, py, _fmt_dist(dist)) |
|
799 |
+ y -= lignes * 13 + 12 |
|
800 |
+ |
|
801 |
+ # --- essence à proximité -------------------------------------------------------------- |
|
802 |
+ if gaz_d and gaz_d.get("stations"): |
|
803 |
+ sts = gaz_d["stations"][:3] |
|
804 |
+ y = place(y, 40 + len(sts) * 11) |
|
805 |
+ y = s.titre_section(y, "Essence à proximité") |
|
806 |
+ if gaz_d.get("mediane_regulier"): |
|
807 |
+ c.setFont("Helvetica", 8.5) |
|
808 |
+ c.setFillColor(INK2) |
|
809 |
+ c.drawString(M, y - 2, "Prix médian de l'essence régulière à <5 km : " |
|
810 |
+ + f"{gaz_d['mediane_regulier']}".replace(".", ",") |
|
811 |
+ + f"{NBSP}¢/L") |
|
812 |
+ y -= 14 |
|
813 |
+ for st in sts: |
|
814 |
+ c.setFont("Helvetica", 8) |
|
815 |
+ c.setFillColor(INK2) |
|
816 |
+ reg_p = (f"{st['regulier']}".replace(".", ",") + f"{NBSP}¢/L" |
|
817 |
+ if st.get("regulier") else "—") |
|
818 |
+ c.drawString(M, y, f"{st['nom']} — {_fmt_dist(st['dist_m'])} : {reg_p}" |
|
819 |
+ + (" · la moins chère" if st.get("moins_chere") else "")) |
|
820 |
+ y -= 11 |
|
821 |
+ y -= 8 |
|
822 |
+ |
|
823 |
+ # --- le quartier ------------------------------------------------------------------------- |
|
824 |
+ if quartier and (quartier.get("demographie") or quartier.get("proximite") |
|
825 |
+ or quartier.get("chaleur") or quartier.get("crime")): |
|
826 |
+ demo = quartier.get("demographie") or {} |
|
827 |
+ prox = quartier.get("proximite") or {} |
|
828 |
+ prox_aff = [(k, lab) for k, lab in PROX_LABELS |
|
829 |
+ if isinstance(prox.get(k), (int, float))] |
|
830 |
+ besoin = 44 + (94 if demo else 0) + len(prox_aff) * 14 + 44 |
|
831 |
+ y = place(y, besoin) |
|
832 |
+ y = s.titre_section(y, "Le quartier") |
|
833 |
+ c.setFont("Helvetica", 7.5) |
|
834 |
+ c.setFillColor(INK3) |
|
835 |
+ c.drawString(M, y, "Secteur immédiat de la propriété (aire de diffusion " |
|
836 |
+ "du recensement 2021, ± 500 habitants).") |
|
837 |
+ y -= 14 |
|
838 |
+ if demo: |
|
839 |
+ tuiles = [("Revenu médian", _fmt_money(demo.get("revenu_median"))), |
|
840 |
+ ("Ménages locataires", |
|
841 |
+ f"{round(demo['pct_locataires'])}{NBSP}%" |
|
842 |
+ if demo.get("pct_locataires") is not None else "—"), |
|
843 |
+ ("Loyer moyen secteur", _fmt_money(demo.get("loyer_moyen"))), |
|
844 |
+ ("Âge médian", |
|
845 |
+ f"{round(demo['age_median'])} ans" |
|
846 |
+ if demo.get("age_median") is not None else "—"), |
|
847 |
+ ("Français à la maison", |
|
848 |
+ f"{round(demo['pct_francais'])}{NBSP}%" |
|
849 |
+ if demo.get("pct_francais") is not None else "—"), |
|
850 |
+ ("Diplôme universitaire", |
|
851 |
+ f"{round(demo['pct_univ'])}{NBSP}%" |
|
852 |
+ if demo.get("pct_univ") is not None else "—")] |
|
853 |
+ tw = (PAGE_W - 2 * M - 20) / 3 |
|
854 |
+ for i, (lab, val) in enumerate(tuiles): |
|
855 |
+ s.tuile(M + (i % 3) * (tw + 10), y - 34 - (i // 3) * 44, |
|
856 |
+ tw, 38, val, lab) |
|
857 |
+ y -= 34 + 44 + 16 |
|
858 |
+ for k, lab in prox_aff: |
|
859 |
+ v = max(0.0, min(1.0, prox[k])) |
|
860 |
+ c.setFont("Helvetica", 8) |
|
861 |
+ c.setFillColor(INK2) |
|
862 |
+ c.drawString(M, y, lab) |
|
863 |
+ bx0, bw_ = M + 130, PAGE_W - 2 * M - 160 |
|
864 |
+ c.setFillColor(SURFACE) |
|
865 |
+ c.setStrokeColor(INK) |
|
866 |
+ c.setLineWidth(0.8) |
|
867 |
+ c.roundRect(bx0, y - 1, bw_, 7, 3.5, stroke=1, fill=1) |
|
868 |
+ c.setFillColor(ACCENT) |
|
869 |
+ c.roundRect(bx0, y - 1, max(3, bw_ * v), 7, 3.5, stroke=0, fill=1) |
|
870 |
+ c.setFont("Courier-Bold", 7.5) |
|
871 |
+ c.setFillColor(INK) |
|
872 |
+ c.drawRightString(PAGE_W - M, y, str(round(v * 100))) |
|
873 |
+ y -= 14 |
|
874 |
+ if prox_aff: |
|
875 |
+ c.setFont("Helvetica", 6.5) |
|
876 |
+ c.setFillColor(INK3) |
|
877 |
+ c.drawString(M, y, "Accessibilité 0–100 — mesures de proximité de " |
|
878 |
+ "Statistique Canada.") |
|
879 |
+ y -= 12 |
|
880 |
+ badges_q = [] |
|
881 |
+ ch = quartier.get("chaleur") |
|
882 |
+ if ch: |
|
883 |
+ if ch.get("classe", 5) <= 3: |
|
884 |
+ badges_q.append(("Îlot de fraîcheur", GREEN_FG, GREEN_BG, GREEN_FG)) |
|
885 |
+ elif ch.get("classe", 5) >= 7: |
|
886 |
+ ec = (f" (+{ch['ecart']:.1f} °C)".replace(".", ",") |
|
887 |
+ if ch.get("ecart") is not None else "") |
|
888 |
+ badges_q.append((f"Îlot de chaleur{ec}", |
|
889 |
+ AMBER_FG, AMBER_BG, AMBER_FG)) |
|
890 |
+ cr = quartier.get("crime") |
|
891 |
+ if cr and cr.get("type") == "igc" and cr.get("indice_canada"): |
|
892 |
+ dlt = round(100 * (cr["indice"] - cr["indice_canada"]) |
|
893 |
+ / cr["indice_canada"]) |
|
894 |
+ badges_q.append((f"Criminalité {abs(dlt)}{NBSP}% " |
|
895 |
+ f"{'sous' if dlt <= 0 else 'au-dessus de'} " |
|
896 |
+ "la moyenne canadienne", INK2, SURFACE, INK)) |
|
897 |
+ elif cr and cr.get("type") == "points": |
|
898 |
+ badges_q.append((f"{cr['douze_mois']} actes criminels à <500 m " |
|
899 |
+ "(12 mois)", INK2, SURFACE, INK)) |
|
900 |
+ if badges_q: |
|
901 |
+ x = M |
|
902 |
+ for txt, fg, bg, stk in badges_q: |
|
903 |
+ x = s.pilule(x, y - 8, txt, fg, bg, stk) |
|
904 |
+ y -= 26 |
|
905 |
+ y -= 6 |
|
906 |
+ |
|
907 |
+ # --- références : QR + courtier + source ------------------------------------------------ |
|
908 |
+ y = place(y, 140) |
|
909 |
+ y = s.titre_section(y, "Références") |
|
910 |
+ qr = qrcode.make(f"https://www.immo-ka.com/propriete/{uid}", |
|
911 |
+ box_size=4, border=1) |
|
912 |
+ qb = io.BytesIO() |
|
913 |
+ qr.save(qb, format="PNG") |
|
914 |
+ c.drawImage(ImageReader(io.BytesIO(qb.getvalue())), M, y - 74, 70, 70) |
|
915 |
+ yy = y - 14 |
|
916 |
+ c.setFont("Helvetica-Bold", 9) |
|
917 |
+ c.setFillColor(INK) |
|
918 |
+ courtier = l.get("broker_name") or d.get("Courtier inscripteur") |
|
919 |
+ if courtier: |
|
920 |
+ ligne = f"Courtier : {courtier}" |
|
921 |
+ if l.get("broker_phone"): |
|
922 |
+ ligne += f" · {l['broker_phone']}" |
|
923 |
+ c.drawString(M + 82, yy, ligne[:95]) |
|
924 |
+ yy -= 13 |
|
925 |
+ c.drawString(M + 82, yy, f"Agence : {src_name}") |
|
926 |
+ yy -= 14 |
|
927 |
+ c.setFont("Helvetica", 8) |
|
928 |
+ c.setFillColor(INK2) |
|
929 |
+ c.drawString(M + 82, yy, "Annonce originale :") |
|
930 |
+ yy -= 11 |
|
931 |
+ c.setFillColor(ACCENT_DEEP) |
|
932 |
+ c.drawString(M + 82, yy, |
|
933 |
+ (l.get("url") or d.get("listing_origin_url") or "")[:90]) |
|
934 |
+ yy -= 14 |
|
935 |
+ c.setFillColor(INK2) |
|
936 |
+ c.drawString(M + 82, yy, "Fiche à jour en ligne (scannez le code) :") |
|
937 |
+ yy -= 11 |
|
938 |
+ c.setFillColor(ACCENT_DEEP) |
|
939 |
+ c.drawString(M + 82, yy, f"www.immo-ka.com/propriete/{uid}") |
|
940 |
+ y -= 90 |
|
941 |
+ s.paragraphe(M, y, "Sources : annonce de l'agence, Vrai-Prix (vrai-prix.ca), " |
|
942 |
+ "rôle d'évaluation foncière, Statistique Canada (2021), " |
|
943 |
+ "INSPQ (CC-BY 4.0), Ville de Montréal (CC-BY 4.0), BDZI, " |
|
944 |
+ "RSQAQ, Hydro-Québec, gazquebec.ca, OpenStreetMap.", |
|
945 |
+ PAGE_W - 2 * M, taille=6.5, couleur=INK3, interligne=9) |
|
946 |
+ |
|
947 |
+ s.pied(_PIED_FICHE, page[0]) |
|
948 |
+ c.save() |
|
949 |
+ return buf.getvalue() |