| |
1 | +# ----------------------------------------------------------------------------- |
| |
2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) |
| |
3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| |
4 | +# pdfgen.py : génération PDF au style « éditorial sharp » de Lou-Ka |
| |
5 | +# - fiche_pdf(uid) : fiche de propriété (photos, prix + badge marché, chips, |
| |
6 | +# description structurée, inclusions, quartier, à proximité, QR code) |
| |
7 | +# - rapport_pdf() : rapport global du marché (totaux, régions, histogramme |
| |
8 | +# des loyers, types, villes, gestionnaires) |
| |
9 | +# Palette identique au site : crème #f5f3ee, encre #141814, lime #d9f26b, |
| |
10 | +# vert #1c5c41. Polices de base (Helvetica/Courier) stylées par la mise en |
| |
11 | +# page — aucune dépendance de police externe. |
| |
12 | +# ----------------------------------------------------------------------------- |
| |
13 | +from __future__ import annotations |
| |
14 | + |
| |
15 | +import io |
| |
16 | +import json |
| |
17 | +import math |
| |
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 Lou-Ka --------------------------------------------------------- |
| |
30 | +PAPER = HexColor("#f5f3ee") |
| |
31 | +SURFACE = HexColor("#ffffff") |
| |
32 | +INK = HexColor("#141814") |
| |
33 | +INK2 = HexColor("#4d5551") |
| |
34 | +INK3 = HexColor("#8b928c") |
| |
35 | +GREEN = HexColor("#1c5c41") |
| |
36 | +GREEN_DEEP = HexColor("#123f2e") |
| |
37 | +LIME = HexColor("#d9f26b") |
| |
38 | +LIME_SOFT = HexColor("#f0f9d2") |
| |
39 | +AMBER = HexColor("#e8a33d") |
| |
40 | +AMBER_SOFT = HexColor("#fdf3e2") |
| |
41 | + |
| |
42 | +PAGE_W, PAGE_H = letter |
| |
43 | +M = 40 # marge |
| |
44 | +IMG_TIMEOUT = 6 |
| |
45 | +UA = "LouKaBot/1.0 (+https://www.lou-ka.com/bot; contact@spboucher.ai)" |
| |
46 | + |
| |
47 | +POI_LABELS = { |
| |
48 | + "epicerie": "Épicerie", "depanneur": "Dépanneur", "pharmacie": "Pharmacie", |
| |
49 | + "ecole": "École", "garderie": "Garderie", "parc": "Parc", "bus": "Arrêt de bus", |
| |
50 | + "metro": "Métro", "gym": "Gym", "cafe": "Café", "clinique": "Clinique", |
| |
51 | + "hopital": "Hôpital", "bibliotheque": "Bibliothèque", |
| |
52 | +} |
| |
53 | +PROX_LABELS = [ |
| |
54 | + ("prox_epicerie", "Épiceries"), ("prox_transport", "Transport en commun"), |
| |
55 | + ("prox_parc", "Parcs"), ("prox_ecole_prim", "Écoles primaires"), |
| |
56 | + ("prox_sante", "Soins de santé"), ("prox_pharmacie", "Pharmacies"), |
| |
57 | +] |
| |
58 | + |
| |
59 | +NBSP = " " |
| |
60 | + |
| |
61 | + |
| |
62 | +def _fmt_money(v) -> str: |
| |
63 | + return f"{v:,.0f}".replace(",", f"{NBSP}") + f"{NBSP}$" if v is not None else "—" |
| |
64 | + |
| |
65 | + |
| |
66 | +def _fmt_dist(m_: float) -> str: |
| |
67 | + return (f"{round(m_ / 10) * 10}{NBSP}m" if m_ < 1000 |
| |
68 | + else f"{m_ / 1000:.1f}".replace(".", ",") + f"{NBSP}km") |
| |
69 | + |
| |
70 | + |
| |
71 | +def _marche(m_: float) -> str: |
| |
72 | + return f"≈{NBSP}{max(1, round(m_ * 1.3 / 80))}{NBSP}min à pied" |
| |
73 | + |
| |
74 | + |
| |
75 | +def _fetch_image(url: str) -> ImageReader | None: |
| |
76 | + try: |
| |
77 | + r = requests.get(url, timeout=IMG_TIMEOUT, headers={"User-Agent": UA}) |
| |
78 | + r.raise_for_status() |
| |
79 | + return ImageReader(io.BytesIO(r.content)) |
| |
80 | + except Exception: |
| |
81 | + return None |
| |
82 | + |
| |
83 | + |
| |
84 | +class _Style: |
| |
85 | + """Petits composants du style « éditorial sharp » sur un canvas.""" |
| |
86 | + |
| |
87 | + def __init__(self, c: rl_canvas.Canvas): |
| |
88 | + self.c = c |
| |
89 | + |
| |
90 | + def fond(self): |
| |
91 | + self.c.setFillColor(PAPER) |
| |
92 | + self.c.rect(0, 0, PAGE_W, PAGE_H, stroke=0, fill=1) |
| |
93 | + |
| |
94 | + def logo(self, x: float, y: float, taille: float = 22): |
| |
95 | + """Wordmark Lou·Ka : « Lou » encre + boîte encre avec « Ka » lime.""" |
| |
96 | + c = self.c |
| |
97 | + c.setFont("Helvetica-Bold", taille) |
| |
98 | + c.setFillColor(INK) |
| |
99 | + c.drawString(x, y, "Lou") |
| |
100 | + w = c.stringWidth("Lou", "Helvetica-Bold", taille) |
| |
101 | + bw = c.stringWidth("Ka", "Helvetica-Bold", taille) + 8 |
| |
102 | + c.saveState() |
| |
103 | + c.translate(x + w + 3 + bw / 2, y + taille * 0.32) |
| |
104 | + c.rotate(-3) |
| |
105 | + c.setFillColor(INK) |
| |
106 | + c.roundRect(-bw / 2, -taille * 0.62, bw, taille * 1.15, 4, stroke=0, fill=1) |
| |
107 | + c.setFillColor(LIME) |
| |
108 | + c.drawCentredString(0, -taille * 0.30, "Ka") |
| |
109 | + c.restoreState() |
| |
110 | + return x + w + 6 + bw |
| |
111 | + |
| |
112 | + def entete(self, titre: str): |
| |
113 | + c = self.c |
| |
114 | + self.logo(M, PAGE_H - M - 16) |
| |
115 | + c.setFont("Courier-Bold", 8) |
| |
116 | + c.setFillColor(INK3) |
| |
117 | + c.drawRightString(PAGE_W - M, PAGE_H - M - 6, |
| |
118 | + titre.upper()) |
| |
119 | + c.drawRightString(PAGE_W - M, PAGE_H - M - 16, |
| |
120 | + time.strftime("GÉNÉRÉ LE %Y-%m-%d · WWW.LOU-KA.COM")) |
| |
121 | + c.setStrokeColor(INK) |
| |
122 | + c.setLineWidth(2) |
| |
123 | + c.line(M, PAGE_H - M - 26, PAGE_W - M, PAGE_H - M - 26) |
| |
124 | + return PAGE_H - M - 40 # y de départ du contenu |
| |
125 | + |
| |
126 | + def pied(self, texte: str, page: int | None = None): |
| |
127 | + c = self.c |
| |
128 | + c.setStrokeColor(INK) |
| |
129 | + c.setLineWidth(1) |
| |
130 | + c.line(M, M + 16, PAGE_W - M, M + 16) |
| |
131 | + c.setFont("Helvetica", 6.5) |
| |
132 | + c.setFillColor(INK3) |
| |
133 | + c.drawString(M, M + 6, texte[:150]) |
| |
134 | + if page: |
| |
135 | + c.setFont("Courier-Bold", 7) |
| |
136 | + c.drawRightString(PAGE_W - M, M + 6, f"P.{page}") |
| |
137 | + |
| |
138 | + def pilule(self, x: float, y: float, texte: str, fg=LIME, bg=INK, |
| |
139 | + stroke=None, taille: float = 8) -> float: |
| |
140 | + """Chip arrondie ; retourne le x suivant.""" |
| |
141 | + c = self.c |
| |
142 | + w = c.stringWidth(texte, "Helvetica-Bold", taille) + 14 |
| |
143 | + c.setFillColor(bg) |
| |
144 | + if stroke: |
| |
145 | + c.setStrokeColor(stroke) |
| |
146 | + c.setLineWidth(1.2) |
| |
147 | + c.roundRect(x, y, w, taille + 9, (taille + 9) / 2, |
| |
148 | + stroke=1 if stroke else 0, fill=1) |
| |
149 | + c.setFillColor(fg) |
| |
150 | + c.setFont("Helvetica-Bold", taille) |
| |
151 | + c.drawString(x + 7, y + 5, texte) |
| |
152 | + return x + w + 6 |
| |
153 | + |
| |
154 | + def titre_section(self, y: float, texte: str) -> float: |
| |
155 | + c = self.c |
| |
156 | + c.setFont("Helvetica-Bold", 12.5) |
| |
157 | + c.setFillColor(INK) |
| |
158 | + c.drawString(M, y, texte) |
| |
159 | + c.setStrokeColor(LIME) |
| |
160 | + c.setLineWidth(3) |
| |
161 | + w = c.stringWidth(texte, "Helvetica-Bold", 12.5) |
| |
162 | + c.line(M, y - 4, M + w, y - 4) |
| |
163 | + return y - 18 |
| |
164 | + |
| |
165 | + def paragraphe(self, x: float, y: float, texte: str, largeur: float, |
| |
166 | + taille: float = 8.5, couleur=INK2, interligne: float = 11.5, |
| |
167 | + max_lignes: int = 100) -> float: |
| |
168 | + """Texte multi-lignes avec découpe aux mots ; retourne le y suivant.""" |
| |
169 | + c = self.c |
| |
170 | + c.setFont("Helvetica", taille) |
| |
171 | + c.setFillColor(couleur) |
| |
172 | + mots = texte.split() |
| |
173 | + ligne = "" |
| |
174 | + n = 0 |
| |
175 | + for mot in mots: |
| |
176 | + essai = (ligne + " " + mot).strip() |
| |
177 | + if c.stringWidth(essai, "Helvetica", taille) <= largeur: |
| |
178 | + ligne = essai |
| |
179 | + else: |
| |
180 | + c.drawString(x, y, ligne) |
| |
181 | + y -= interligne |
| |
182 | + n += 1 |
| |
183 | + ligne = mot |
| |
184 | + if n >= max_lignes - 1: |
| |
185 | + ligne += " …" |
| |
186 | + break |
| |
187 | + if ligne: |
| |
188 | + c.drawString(x, y, ligne) |
| |
189 | + y -= interligne |
| |
190 | + return y |
| |
191 | + |
| |
192 | + |
| |
193 | +# --------------------------------------------------------------------------- |
| |
194 | +# Fiche de propriété |
| |
195 | +# --------------------------------------------------------------------------- |
| |
196 | + |
| |
197 | +def fiche_pdf(uid: str) -> bytes | None: |
| |
198 | + """PDF de la fiche du logement `uid`. None si introuvable.""" |
| |
199 | + con = db.connect() |
| |
200 | + row = con.execute("SELECT * FROM listings WHERE uid=?", (uid,)).fetchone() |
| |
201 | + if row is None: |
| |
202 | + con.close() |
| |
203 | + return None |
| |
204 | + l = dict(row) |
| |
205 | + l["amenities"] = json.loads(l.get("amenities") or "[]") |
| |
206 | + l["images"] = json.loads(l.get("images") or "[]") |
| |
207 | + l["details"] = json.loads(l.get("details") or "{}") |
| |
208 | + l["digest"] = json.loads(l["digest"]) if l.get("digest") else None |
| |
209 | + poi = [] |
| |
210 | + quartier = None |
| |
211 | + if l.get("lat") is not None: |
| |
212 | + key = f"{round(l['lat'], 4)},{round(l['lng'], 4)}" |
| |
213 | + pr = con.execute("SELECT pois FROM poi_cache WHERE coord_key=?", (key,)).fetchone() |
| |
214 | + poi = json.loads(pr["pois"]) if pr else [] |
| |
215 | + from . import quartier as qmod |
| |
216 | + dauid = l.get("dauid") |
| |
217 | + quartier = qmod.fiche_quartier(l["lat"], l["lng"], l.get("city") or "", |
| |
218 | + dauid if dauid and dauid != "hors-zone" else None) |
| |
219 | + reg = json.loads((db.DB_PATH.parent / "sources.json").read_text("utf-8"))["sources"] |
| |
220 | + src_name = next((s["name"] for s in reg if s["id"] == l["source"]), l["source"]) |
| |
221 | + con.close() |
| |
222 | + |
| |
223 | + buf = io.BytesIO() |
| |
224 | + c = rl_canvas.Canvas(buf, pagesize=letter) |
| |
225 | + c.setTitle(f"Lou-Ka — {l.get('title') or l.get('address')}") |
| |
226 | + s = _Style(c) |
| |
227 | + s.fond() |
| |
228 | + y = s.entete("Fiche logement") |
| |
229 | + |
| |
230 | + # --- bloc prix + badge marché |
| |
231 | + prix_txt = (_fmt_money(l["price"]) + f"{NBSP}/ mois") if l.get("price") \ |
| |
232 | + else (l.get("price_label") or "Prix sur demande") |
| |
233 | + c.setFont("Helvetica-Bold", 30) |
| |
234 | + c.setFillColor(INK) |
| |
235 | + c.drawString(M, y - 24, prix_txt) |
| |
236 | + loyer_secteur = (quartier or {}).get("demographie", {}).get("loyer_moyen") \ |
| |
237 | + if quartier else None |
| |
238 | + bx = M + c.stringWidth(prix_txt, "Helvetica-Bold", 30) + 14 |
| |
239 | + if l.get("price") and loyer_secteur: |
| |
240 | + delta = (l["price"] - loyer_secteur) / loyer_secteur |
| |
241 | + pct = f"{'+' if delta > 0 else '−'}{abs(round(delta * 100))}{NBSP}%" |
| |
242 | + if delta <= -0.15: |
| |
243 | + s.pilule(bx, y - 22, f"{pct} vs secteur · Bon deal", GREEN_DEEP, LIME_SOFT, GREEN) |
| |
244 | + elif delta <= 0.10: |
| |
245 | + s.pilule(bx, y - 22, f"{pct} vs secteur · Dans le marché", INK2, SURFACE, INK) |
| |
246 | + else: |
| |
247 | + s.pilule(bx, y - 22, f"{pct} vs secteur · Au-dessus du marché", |
| |
248 | + HexColor("#8a5a12"), AMBER_SOFT, AMBER) |
| |
249 | + y -= 44 |
| |
250 | + |
| |
251 | + # --- titre + adresse |
| |
252 | + c.setFont("Helvetica-Bold", 13) |
| |
253 | + c.setFillColor(INK) |
| |
254 | + c.drawString(M, y, (l.get("title") or l.get("address") or "")[:80]) |
| |
255 | + y -= 14 |
| |
256 | + c.setFont("Helvetica", 9) |
| |
257 | + c.setFillColor(INK2) |
| |
258 | + loc = " · ".join(x for x in (l.get("address"), l.get("sector"), l.get("city")) |
| |
259 | + if x and x != l.get("title")) |
| |
260 | + c.drawString(M, y, loc[:110]) |
| |
261 | + y -= 20 |
| |
262 | + |
| |
263 | + # --- chips clés |
| |
264 | + x = M |
| |
265 | + chips = [] |
| |
266 | + if l.get("unit_type"): |
| |
267 | + chips.append(l["unit_type"]) |
| |
268 | + ad = l.get("availability_date") |
| |
269 | + if ad == "now": |
| |
270 | + chips.append("Libre maintenant") |
| |
271 | + elif ad: |
| |
272 | + chips.append(f"Dispo {ad}") |
| |
273 | + if l.get("area_sqft"): |
| |
274 | + chips.append(f"{round(l['area_sqft'])}{NBSP}pi²") |
| |
275 | + if l.get("furnished"): |
| |
276 | + chips.append("Meublé") |
| |
277 | + if l.get("pets"): |
| |
278 | + chips.append({"oui": "Animaux acceptés", "non": "Animaux refusés", |
| |
279 | + "conditions": "Animaux sous conditions"}.get(l["pets"], l["pets"])) |
| |
280 | + if l["details"].get("floor") is not None: |
| |
281 | + chips.append(f"{l['details']['floor']}e étage") |
| |
282 | + for chip in chips[:7]: |
| |
283 | + x = s.pilule(x, y - 6, chip) |
| |
284 | + y -= 30 |
| |
285 | + |
| |
286 | + # --- photos : 1 grande + 3 vignettes |
| |
287 | + imgs = [im for im in (_fetch_image(u) for u in l["images"][:4]) if im] |
| |
288 | + if imgs: |
| |
289 | + big_h = 170 |
| |
290 | + big_w = big_h * 16 / 10.5 |
| |
291 | + c.setStrokeColor(INK) |
| |
292 | + c.setLineWidth(1.5) |
| |
293 | + try: |
| |
294 | + c.drawImage(imgs[0], M, y - big_h, big_w, big_h, |
| |
295 | + preserveAspectRatio=True, anchor="c", mask="auto") |
| |
296 | + except Exception: |
| |
297 | + pass |
| |
298 | + c.rect(M, y - big_h, big_w, big_h, stroke=1, fill=0) |
| |
299 | + tx = M + big_w + 10 |
| |
300 | + th = (big_h - 16) / 3 |
| |
301 | + for i, im in enumerate(imgs[1:4]): |
| |
302 | + ty = y - (i + 1) * th - i * 8 |
| |
303 | + try: |
| |
304 | + c.drawImage(im, tx, ty, PAGE_W - M - tx, th, |
| |
305 | + preserveAspectRatio=True, anchor="c", mask="auto") |
| |
306 | + c.rect(tx, ty, PAGE_W - M - tx, th, stroke=1, fill=0) |
| |
307 | + except Exception: |
| |
308 | + pass |
| |
309 | + y -= big_h + 18 |
| |
310 | + |
| |
311 | + # --- « En bref » (digest) sur bandeau lime |
| |
312 | + dg = l.get("digest") |
| |
313 | + if dg and dg.get("en_bref"): |
| |
314 | + c.setFillColor(LIME_SOFT) |
| |
315 | + c.setStrokeColor(GREEN) |
| |
316 | + c.setLineWidth(1.2) |
| |
317 | + c.roundRect(M, y - 40, PAGE_W - 2 * M, 44, 6, stroke=1, fill=1) |
| |
318 | + s.paragraphe(M + 10, y - 8, "EN BREF — " + dg["en_bref"], |
| |
319 | + PAGE_W - 2 * M - 20, taille=8.5, couleur=GREEN_DEEP, |
| |
320 | + max_lignes=3) |
| |
321 | + y -= 54 |
| |
322 | + |
| |
323 | + # --- description (2-3 sections max) | inclusions en colonne droite |
| |
324 | + col_g = PAGE_W * 0.56 - M |
| |
325 | + y_desc = s.titre_section(y, "Description") |
| |
326 | + if dg and dg.get("sections"): |
| |
327 | + for sec in dg["sections"][:3]: |
| |
328 | + c.setFont("Helvetica-Bold", 9) |
| |
329 | + c.setFillColor(INK) |
| |
330 | + c.drawString(M, y_desc, sec["titre"][:60]) |
| |
331 | + y_desc -= 12 |
| |
332 | + y_desc = s.paragraphe(M, y_desc, sec["texte"], col_g, max_lignes=6) |
| |
333 | + y_desc -= 4 |
| |
334 | + if y_desc < 150: |
| |
335 | + break |
| |
336 | + elif l.get("description"): |
| |
337 | + y_desc = s.paragraphe(M, y_desc, l["description"], col_g, max_lignes=16) |
| |
338 | + |
| |
339 | + xr = M + col_g + 24 |
| |
340 | + y_incl = s_titre_droite(c, y, xr, "Inclusions et commodités") |
| |
341 | + d = l["details"] |
| |
342 | + badges = [] |
| |
343 | + inc = d.get("inclusions") or {} |
| |
344 | + for k, lab in (("heating", "Chauffage inclus"), ("electricity", "Électricité incluse"), |
| |
345 | + ("hot_water", "Eau chaude incluse"), ("internet", "Internet inclus")): |
| |
346 | + if inc.get(k): |
| |
347 | + badges.append(lab) |
| |
348 | + app = d.get("appliances") or {} |
| |
349 | + if app.get("dishwasher"): |
| |
350 | + badges.append("Lave-vaisselle") |
| |
351 | + if app.get("washer_dryer"): |
| |
352 | + badges.append("Laveuse-sécheuse") |
| |
353 | + for k, lab in (("ac", "Climatisation"), ("elevator", "Ascenseur"), |
| |
354 | + ("balcony", "Balcon"), ("pool", "Piscine"), ("gym", "Gym"), |
| |
355 | + ("laundry", "Buanderie"), ("storage", "Rangement")): |
| |
356 | + if d.get(k): |
| |
357 | + badges.append(lab) |
| |
358 | + if (d.get("parking") or {}).get("available"): |
| |
359 | + badges.append("Stationnement") |
| |
360 | + if l.get("furnished"): |
| |
361 | + badges.append("Meublé") |
| |
362 | + c.setFont("Helvetica", 8.5) |
| |
363 | + for b in badges[:12]: |
| |
364 | + c.setFillColor(GREEN) |
| |
365 | + c.drawString(xr, y_incl, "✓") |
| |
366 | + c.setFillColor(INK2) |
| |
367 | + c.drawString(xr + 12, y_incl, b) |
| |
368 | + y_incl -= 12 |
| |
369 | + autres = [a for a in l["amenities"] |
| |
370 | + if not any(b.lower() in a.lower() or a.lower() in b.lower() for b in badges)] |
| |
371 | + for a in autres[: max(0, 14 - len(badges))]: |
| |
372 | + c.setFillColor(INK3) |
| |
373 | + c.drawString(xr + 12, y_incl, a[:38]) |
| |
374 | + y_incl -= 11 |
| |
375 | + |
| |
376 | + y = min(y_desc, y_incl) - 8 |
| |
377 | + |
| |
378 | + # --- pied page 1 + page 2 (quartier / à proximité / références) |
| |
379 | + s.pied("Données de l'annonce publiée par le gestionnaire — Lou-Ka est un " |
| |
380 | + "agrégateur indépendant ; chaque fiche renvoie à l'annonce originale.", 1) |
| |
381 | + c.showPage() |
| |
382 | + s.fond() |
| |
383 | + y = s.entete("Fiche logement · suite") |
| |
384 | + |
| |
385 | + # Le quartier |
| |
386 | + if quartier: |
| |
387 | + y = s.titre_section(y, "Le quartier") |
| |
388 | + demo = quartier.get("demographie") or {} |
| |
389 | + tuiles = [("Revenu médian", _fmt_money(demo.get("revenu_median"))), |
| |
390 | + ("Ménages locataires", f"{round(demo['pct_locataires'])}{NBSP}%" if demo.get("pct_locataires") is not None else "—"), |
| |
391 | + ("Loyer moyen secteur", _fmt_money(demo.get("loyer_moyen"))), |
| |
392 | + ("Âge médian", f"{round(demo['age_median'])} ans" if demo.get("age_median") is not None else "—"), |
| |
393 | + ("Français à la maison", f"{round(demo['pct_francais'])}{NBSP}%" if demo.get("pct_francais") is not None else "—"), |
| |
394 | + ("Diplôme universitaire", f"{round(demo['pct_univ'])}{NBSP}%" if demo.get("pct_univ") is not None else "—")] |
| |
395 | + tw = (PAGE_W - 2 * M - 20) / 3 |
| |
396 | + for i, (lab, val) in enumerate(tuiles): |
| |
397 | + tx = M + (i % 3) * (tw + 10) |
| |
398 | + ty = y - 34 - (i // 3) * 44 |
| |
399 | + c.setFillColor(SURFACE) |
| |
400 | + c.setStrokeColor(INK) |
| |
401 | + c.setLineWidth(1.2) |
| |
402 | + c.roundRect(tx, ty, tw, 38, 5, stroke=1, fill=1) |
| |
403 | + c.setFont("Helvetica-Bold", 12) |
| |
404 | + c.setFillColor(INK) |
| |
405 | + c.drawString(tx + 8, ty + 20, val) |
| |
406 | + c.setFont("Courier", 6.5) |
| |
407 | + c.setFillColor(INK3) |
| |
408 | + c.drawString(tx + 8, ty + 8, lab.upper()) |
| |
409 | + y -= 34 + 2 * 44 + 6 |
| |
410 | + |
| |
411 | + prox = quartier.get("proximite") or {} |
| |
412 | + for k, lab in PROX_LABELS: |
| |
413 | + if k not in prox: |
| |
414 | + continue |
| |
415 | + v = max(0.0, min(1.0, prox[k])) |
| |
416 | + c.setFont("Helvetica", 8) |
| |
417 | + c.setFillColor(INK2) |
| |
418 | + c.drawString(M, y, lab) |
| |
419 | + bx0, bw_ = M + 130, PAGE_W - 2 * M - 160 |
| |
420 | + c.setFillColor(SURFACE) |
| |
421 | + c.setStrokeColor(INK) |
| |
422 | + c.setLineWidth(0.8) |
| |
423 | + c.roundRect(bx0, y - 1, bw_, 7, 3.5, stroke=1, fill=1) |
| |
424 | + c.setFillColor(GREEN) |
| |
425 | + c.roundRect(bx0, y - 1, bw_ * v, 7, 3.5, stroke=0, fill=1) |
| |
426 | + c.setFont("Courier-Bold", 7.5) |
| |
427 | + c.setFillColor(INK) |
| |
428 | + c.drawRightString(PAGE_W - M, y, str(round(v * 100))) |
| |
429 | + y -= 14 |
| |
430 | + y -= 4 |
| |
431 | + badges_q = [] |
| |
432 | + ch = quartier.get("chaleur") |
| |
433 | + if ch: |
| |
434 | + if ch["classe"] <= 3: |
| |
435 | + badges_q.append(("Îlot de fraîcheur", GREEN_DEEP, LIME_SOFT, GREEN)) |
| |
436 | + elif ch["classe"] >= 7: |
| |
437 | + badges_q.append((f"Îlot de chaleur (+{ch['ecart']:.1f} °C)".replace(".", ","), |
| |
438 | + HexColor("#8a5a12"), AMBER_SOFT, AMBER)) |
| |
439 | + cr = quartier.get("crime") |
| |
440 | + if cr and cr.get("type") == "igc" and cr.get("indice_canada"): |
| |
441 | + dlt = round(100 * (cr["indice"] - cr["indice_canada"]) / cr["indice_canada"]) |
| |
442 | + badges_q.append((f"Criminalité {abs(dlt)}{NBSP}% " |
| |
443 | + f"{'sous' if dlt <= 0 else 'au-dessus de'} la moyenne canadienne", |
| |
444 | + INK2, SURFACE, INK)) |
| |
445 | + elif cr and cr.get("type") == "points": |
| |
446 | + badges_q.append((f"{cr['douze_mois']} actes criminels à <500 m (12 mois)", |
| |
447 | + INK2, SURFACE, INK)) |
| |
448 | + x = M |
| |
449 | + for txt, fg, bg, stk in badges_q: |
| |
450 | + x = s.pilule(x, y - 8, txt, fg, bg, stk) |
| |
451 | + y -= 30 |
| |
452 | + |
| |
453 | + # À proximité |
| |
454 | + if poi: |
| |
455 | + y = s.titre_section(y, "À proximité") |
| |
456 | + col2 = y |
| |
457 | + for i, p in enumerate(poi[:12]): |
| |
458 | + px = M if i % 2 == 0 else M + (PAGE_W - 2 * M) / 2 + 10 |
| |
459 | + if i % 2 == 0 and i > 0: |
| |
460 | + y -= 13 |
| |
461 | + py = y |
| |
462 | + c.setFont("Helvetica", 8) |
| |
463 | + c.setFillColor(INK2) |
| |
464 | + lab = POI_LABELS.get(p["cat"], p["cat"]) |
| |
465 | + nom_poi = p["name"][:19] + "…" if len(p["name"]) > 20 else p["name"] |
| |
466 | + c.drawString(px, py, f"{lab} — {nom_poi}") |
| |
467 | + c.setFont("Courier-Bold", 7.5) |
| |
468 | + c.setFillColor(INK) |
| |
469 | + c.drawRightString(px + (PAGE_W - 2 * M) / 2 - 14, py, |
| |
470 | + f"{_fmt_dist(p['dist_m'])} · {_marche(p['dist_m'])}") |
| |
471 | + y -= 26 |
| |
472 | + |
| |
473 | + # Références : QR + gestionnaire + source |
| |
474 | + y = s.titre_section(y, "Références") |
| |
475 | + qr = qrcode.make(f"https://www.lou-ka.com/logement/{uid}", box_size=4, border=1) |
| |
476 | + qb = io.BytesIO() |
| |
477 | + qr.save(qb, format="PNG") |
| |
478 | + c.drawImage(ImageReader(io.BytesIO(qb.getvalue())), M, y - 74, 70, 70) |
| |
479 | + c.setFont("Helvetica-Bold", 9) |
| |
480 | + c.setFillColor(INK) |
| |
481 | + c.drawString(M + 82, y - 14, f"Gestionnaire : {src_name}") |
| |
482 | + c.setFont("Helvetica", 8) |
| |
483 | + c.setFillColor(INK2) |
| |
484 | + c.drawString(M + 82, y - 28, "Annonce originale :") |
| |
485 | + c.setFillColor(GREEN) |
| |
486 | + c.drawString(M + 82, y - 40, (l.get("url") or "")[:90]) |
| |
487 | + c.setFillColor(INK2) |
| |
488 | + c.drawString(M + 82, y - 56, "Fiche à jour en ligne (scannez le code) :") |
| |
489 | + c.setFillColor(GREEN) |
| |
490 | + c.drawString(M + 82, y - 68, f"www.lou-ka.com/logement/{uid}") |
| |
491 | + |
| |
492 | + s.pied("Quartier : Statistique Canada (Recensement 2021), INSPQ (CC-BY 4.0)" |
| |
493 | + + (", Ville de Montréal (CC-BY 4.0)" if (quartier or {}).get("crime", {}).get("type") == "points" else "") |
| |
494 | + + " · Commodités : OpenStreetMap. Prix et disponibilités : ceux affichés par la source.", 2) |
| |
495 | + c.save() |
| |
496 | + return buf.getvalue() |
| |
497 | + |
| |
498 | + |
| |
499 | +def s_titre_droite(c, y, x, texte): |
| |
500 | + c.setFont("Helvetica-Bold", 12.5) |
| |
501 | + c.setFillColor(INK) |
| |
502 | + c.drawString(x, y, texte) |
| |
503 | + c.setStrokeColor(LIME) |
| |
504 | + c.setLineWidth(3) |
| |
505 | + c.line(x, y - 4, x + c.stringWidth(texte, "Helvetica-Bold", 12.5), y - 4) |
| |
506 | + return y - 18 |
| |
507 | + |
| |
508 | + |
| |
509 | +# --------------------------------------------------------------------------- |
| |
510 | +# Rapport global du marché |
| |
511 | +# --------------------------------------------------------------------------- |
| |
512 | + |
| |
513 | +_REGIONS = [ |
| |
514 | + ("Québec métro", {"Québec", "Lévis", "Saint-Augustin-de-Desmaures", "L'Ancienne-Lorette", "Pont-Rouge", "Shannon"}), |
| |
515 | + ("Outaouais", {"Gatineau", "Chelsea", "Thurso", "Perkins", "Maniwaki", "Val-des-Monts"}), |
| |
516 | + ("Estrie / Montérégie-Est", {"Sherbrooke", "Magog", "Orford", "East Angus", "Waterville", "Granby", "Waterloo", "Bromont", "Cowansville", "Richmond"}), |
| |
517 | + ("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"}), |
| |
518 | + ("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"}), |
| |
519 | + ("Bas-Saint-Laurent / Gaspésie", {"Rimouski", "Rivière-du-Loup", "Matane", "Saint-Ulric", "Amqui", "Le Bic", "New Richmond", "Carleton-sur-Mer", "Gaspé"}), |
| |
520 | + ("Saguenay–Lac-Saint-Jean", {"Saguenay", "Alma", "Chicoutimi", "Jonquière", "Chambord", "La Malbaie"}), |
| |
521 | + ("Abitibi-Témiscamingue", {"Rouyn-Noranda", "Val-d'Or", "Amos", "Malartic"}), |
| |
522 | + ("Côte-Nord", {"Sept-Îles", "Port-Cartier", "Baie-Comeau", "Forestville"}), |
| |
523 | + ("Chaudière-Appalaches", {"Saint-Georges", "Sainte-Marie", "Thetford Mines", "Montmagny", "Vallée-Jonction", "Scott", "Saint-Isidore", "La Guadeloupe", "Saint-Raphaël"}), |
| |
524 | +] |
| |
525 | + |
| |
526 | + |
| |
527 | +def _region_de(city: str) -> str: |
| |
528 | + for nom, villes in _REGIONS: |
| |
529 | + if city in villes: |
| |
530 | + return nom |
| |
531 | + return "Grand Montréal & environs" |
| |
532 | + |
| |
533 | + |
| |
534 | +def rapport_pdf() -> bytes: |
| |
535 | + """Rapport global du marché locatif Lou-Ka (multi-pages).""" |
| |
536 | + con = db.connect() |
| |
537 | + rows = con.execute( |
| |
538 | + "SELECT city, source, price, unit_type FROM listings WHERE active=1").fetchall() |
| |
539 | + reg_file = json.loads((db.DB_PATH.parent / "sources.json").read_text("utf-8"))["sources"] |
| |
540 | + noms = {s["id"]: s["name"] for s in reg_file} |
| |
541 | + n_sources = con.execute( |
| |
542 | + "SELECT COUNT(DISTINCT source) c FROM listings WHERE active=1").fetchone()["c"] |
| |
543 | + gps = con.execute( |
| |
544 | + "SELECT ROUND(100.0*SUM(lat IS NOT NULL)/COUNT(*),1) p FROM listings WHERE active=1" |
| |
545 | + ).fetchone()["p"] |
| |
546 | + con.close() |
| |
547 | + |
| |
548 | + total = len(rows) |
| |
549 | + prix = sorted(r["price"] for r in rows if r["price"] and 300 <= r["price"] <= 10000) |
| |
550 | + moy = round(sum(prix) / len(prix)) if prix else 0 |
| |
551 | + med = round(prix[len(prix) // 2]) if prix else 0 |
| |
552 | + |
| |
553 | + par_region: dict[str, dict] = {} |
| |
554 | + for r in rows: |
| |
555 | + reg = _region_de(r["city"] or "") |
| |
556 | + d = par_region.setdefault(reg, {"n": 0, "prix": [], "sources": set()}) |
| |
557 | + d["n"] += 1 |
| |
558 | + d["sources"].add(r["source"]) |
| |
559 | + if r["price"] and 300 <= r["price"] <= 10000: |
| |
560 | + d["prix"].append(r["price"]) |
| |
561 | + |
| |
562 | + par_ville: dict[str, int] = {} |
| |
563 | + par_source: dict[str, int] = {} |
| |
564 | + par_type: dict[str, list] = {} |
| |
565 | + for r in rows: |
| |
566 | + par_ville[r["city"] or "?"] = par_ville.get(r["city"] or "?", 0) + 1 |
| |
567 | + par_source[r["source"]] = par_source.get(r["source"], 0) + 1 |
| |
568 | + if r["unit_type"]: |
| |
569 | + par_type.setdefault(r["unit_type"], []).append(r["price"]) |
| |
570 | + |
| |
571 | + buf = io.BytesIO() |
| |
572 | + c = rl_canvas.Canvas(buf, pagesize=letter) |
| |
573 | + c.setTitle("Lou-Ka — Rapport du marché locatif") |
| |
574 | + s = _Style(c) |
| |
575 | + |
| |
576 | + # ---- page 1 : couverture + grands chiffres + régions |
| |
577 | + s.fond() |
| |
578 | + s.logo(M, PAGE_H - 130, 42) |
| |
579 | + c.setFont("Helvetica-Bold", 24) |
| |
580 | + c.setFillColor(INK) |
| |
581 | + c.drawString(M, PAGE_H - 185, "Rapport du marché locatif") |
| |
582 | + c.setFillColor(SURFACE) |
| |
583 | + c.setStrokeColor(INK) |
| |
584 | + c.setFont("Courier-Bold", 9) |
| |
585 | + c.setFillColor(INK3) |
| |
586 | + c.drawString(M, PAGE_H - 205, |
| |
587 | + time.strftime("PROVINCE DE QUÉBEC · GÉNÉRÉ LE %Y-%m-%d · WWW.LOU-KA.COM")) |
| |
588 | + |
| |
589 | + grands = [(f"{total:,}".replace(",", NBSP), "annonces actives"), |
| |
590 | + (str(n_sources), "gestionnaires connectés"), |
| |
591 | + (_fmt_money(moy), "loyer moyen"), |
| |
592 | + (_fmt_money(med), "loyer médian"), |
| |
593 | + (f"{gps}{NBSP}%", "annonces géolocalisées")] |
| |
594 | + tw = (PAGE_W - 2 * M - 4 * 10) / 5 |
| |
595 | + for i, (val, lab) in enumerate(grands): |
| |
596 | + tx = M + i * (tw + 10) |
| |
597 | + ty = PAGE_H - 285 |
| |
598 | + c.setFillColor(INK) |
| |
599 | + c.roundRect(tx + 3, ty - 3, tw, 58, 6, stroke=0, fill=1) # ombre décalée |
| |
600 | + c.setFillColor(SURFACE) |
| |
601 | + c.setStrokeColor(INK) |
| |
602 | + c.setLineWidth(1.5) |
| |
603 | + c.roundRect(tx, ty, tw, 58, 6, stroke=1, fill=1) |
| |
604 | + c.setFont("Helvetica-Bold", 15) |
| |
605 | + c.setFillColor(INK) |
| |
606 | + c.drawCentredString(tx + tw / 2, ty + 32, val) |
| |
607 | + c.setFont("Courier", 6.3) |
| |
608 | + c.setFillColor(INK3) |
| |
609 | + c.drawCentredString(tx + tw / 2, ty + 14, lab.upper()) |
| |
610 | + |
| |
611 | + y = s.titre_section(PAGE_H - 330, "Couverture par région") |
| |
612 | + c.setFont("Courier-Bold", 7) |
| |
613 | + c.setFillColor(INK3) |
| |
614 | + for lab, xoff in (("RÉGION", 0), ("ANNONCES", 280), ("SOURCES", 360), |
| |
615 | + ("LOYER MOYEN", 430)): |
| |
616 | + c.drawString(M + xoff, y, lab) |
| |
617 | + y -= 4 |
| |
618 | + c.setStrokeColor(INK) |
| |
619 | + c.setLineWidth(1) |
| |
620 | + c.line(M, y, PAGE_W - M, y) |
| |
621 | + y -= 14 |
| |
622 | + ordre = sorted(par_region.items(), key=lambda kv: -kv[1]["n"]) |
| |
623 | + max_n = max(d["n"] for _, d in ordre) |
| |
624 | + for nom, d in ordre: |
| |
625 | + c.setFont("Helvetica-Bold", 8.5) |
| |
626 | + c.setFillColor(INK) |
| |
627 | + c.drawString(M, y, nom) |
| |
628 | + # barre proportionnelle |
| |
629 | + c.setFillColor(LIME) |
| |
630 | + c.setStrokeColor(INK) |
| |
631 | + c.setLineWidth(0.7) |
| |
632 | + bw = 90 * d["n"] / max_n |
| |
633 | + c.roundRect(M + 180, y - 1, max(3, bw), 8, 3, stroke=1, fill=1) |
| |
634 | + c.setFont("Helvetica", 8.5) |
| |
635 | + c.setFillColor(INK2) |
| |
636 | + c.drawRightString(M + 330, y, f"{d['n']:,}".replace(",", NBSP)) |
| |
637 | + c.drawRightString(M + 400, y, str(len(d["sources"]))) |
| |
638 | + pm = round(sum(d["prix"]) / len(d["prix"])) if d["prix"] else None |
| |
639 | + c.drawRightString(M + 500, y, _fmt_money(pm) if pm else "—") |
| |
640 | + y -= 15 |
| |
641 | + s.pied("Rapport généré automatiquement à partir des annonces publiques " |
| |
642 | + "agrégées par Lou-Ka. Loyers : bornes 300–10 000 $.", 1) |
| |
643 | + c.showPage() |
| |
644 | + |
| |
645 | + # ---- page 2 : histogramme des loyers + par type |
| |
646 | + s.fond() |
| |
647 | + y = s.entete("Rapport du marché · loyers") |
| |
648 | + y = s.titre_section(y, "Distribution des loyers") |
| |
649 | + lo, hi, step = 400, 3200, 200 |
| |
650 | + classes = [0] * ((hi - lo) // step + 2) |
| |
651 | + for p in prix: |
| |
652 | + if p < lo: |
| |
653 | + classes[0] += 1 |
| |
654 | + elif p >= hi: |
| |
655 | + classes[-1] += 1 |
| |
656 | + else: |
| |
657 | + classes[1 + int((p - lo) // step)] += 1 |
| |
658 | + max_c = max(classes) or 1 |
| |
659 | + ch_h, ch_y = 150, y - 170 |
| |
660 | + bw = (PAGE_W - 2 * M) / len(classes) |
| |
661 | + for i, n in enumerate(classes): |
| |
662 | + bh = ch_h * n / max_c |
| |
663 | + bx = M + i * bw |
| |
664 | + c.setFillColor(GREEN if i not in (0, len(classes) - 1) else INK3) |
| |
665 | + c.setStrokeColor(INK) |
| |
666 | + c.setLineWidth(0.6) |
| |
667 | + c.rect(bx + 2, ch_y, bw - 4, max(1, bh), stroke=1, fill=1) |
| |
668 | + if n and n > max_c * 0.06: |
| |
669 | + c.setFont("Courier-Bold", 6) |
| |
670 | + c.setFillColor(INK) |
| |
671 | + c.drawCentredString(bx + bw / 2, ch_y + bh + 3, str(n)) |
| |
672 | + c.setFont("Helvetica", 5.6) |
| |
673 | + c.setFillColor(INK3) |
| |
674 | + lab = "<400" if i == 0 else (f"{hi}+" if i == len(classes) - 1 |
| |
675 | + else str(lo + (i - 1) * step)) |
| |
676 | + c.drawCentredString(bx + bw / 2, ch_y - 9, lab) |
| |
677 | + y = ch_y - 30 |
| |
678 | + |
| |
679 | + y = s.titre_section(y, "Par taille de logement") |
| |
680 | + types = sorted(par_type.items(), key=lambda kv: -len(kv[1]))[:9] |
| |
681 | + c.setFont("Courier-Bold", 7) |
| |
682 | + c.setFillColor(INK3) |
| |
683 | + for lab, xoff in (("TAILLE", 0), ("ANNONCES", 160), ("LOYER MOYEN", 260), |
| |
684 | + ("LOYER MIN", 360)): |
| |
685 | + c.drawString(M + xoff, y, lab) |
| |
686 | + y -= 4 |
| |
687 | + c.line(M, y, PAGE_W - M, y) |
| |
688 | + y -= 13 |
| |
689 | + for t, ps in types: |
| |
690 | + pv = [p for p in ps if p and 300 <= p <= 10000] |
| |
691 | + c.setFont("Helvetica-Bold", 8.5) |
| |
692 | + c.setFillColor(INK) |
| |
693 | + c.drawString(M, y, t) |
| |
694 | + c.setFont("Helvetica", 8.5) |
| |
695 | + c.setFillColor(INK2) |
| |
696 | + c.drawRightString(M + 220, y, str(len(ps))) |
| |
697 | + c.drawRightString(M + 330, y, _fmt_money(round(sum(pv) / len(pv))) if pv else "—") |
| |
698 | + c.drawRightString(M + 430, y, _fmt_money(min(pv)) if pv else "—") |
| |
699 | + y -= 13 |
| |
700 | + s.pied("Lou-Ka — agrégateur indépendant. Chaque annonce renvoie à la " |
| |
701 | + "source originale du gestionnaire.", 2) |
| |
702 | + c.showPage() |
| |
703 | + |
| |
704 | + # ---- page 3 : top villes + top gestionnaires |
| |
705 | + s.fond() |
| |
706 | + y = s.entete("Rapport du marché · détail") |
| |
707 | + y = s.titre_section(y, "Top 20 des villes") |
| |
708 | + top_v = sorted(par_ville.items(), key=lambda kv: -kv[1])[:20] |
| |
709 | + col_w = (PAGE_W - 2 * M) / 2 |
| |
710 | + for i, (v, n) in enumerate(top_v): |
| |
711 | + vx = M if i < 10 else M + col_w |
| |
712 | + vy = y - (i % 10) * 13 |
| |
713 | + c.setFont("Helvetica", 8.5) |
| |
714 | + c.setFillColor(INK2) |
| |
715 | + c.drawString(vx, vy, f"{i + 1:>2}. {v}") |
| |
716 | + c.setFont("Courier-Bold", 8) |
| |
717 | + c.setFillColor(INK) |
| |
718 | + c.drawRightString(vx + col_w - 24, vy, f"{n:,}".replace(",", NBSP)) |
| |
719 | + y -= 10 * 13 + 16 |
| |
720 | + |
| |
721 | + y = s.titre_section(y, "Top 20 des gestionnaires") |
| |
722 | + top_s = sorted(par_source.items(), key=lambda kv: -kv[1])[:20] |
| |
723 | + for i, (sid, n) in enumerate(top_s): |
| |
724 | + vx = M if i < 10 else M + col_w |
| |
725 | + vy = y - (i % 10) * 13 |
| |
726 | + c.setFont("Helvetica", 8.5) |
| |
727 | + c.setFillColor(INK2) |
| |
728 | + c.drawString(vx, vy, f"{i + 1:>2}. {noms.get(sid, sid)[:34]}") |
| |
729 | + c.setFont("Courier-Bold", 8) |
| |
730 | + c.setFillColor(INK) |
| |
731 | + c.drawRightString(vx + col_w - 24, vy, f"{n:,}".replace(",", NBSP)) |
| |
732 | + y -= 10 * 13 + 20 |
| |
733 | + |
| |
734 | + c.setFont("Helvetica", 7.5) |
| |
735 | + c.setFillColor(INK3) |
| |
736 | + c.drawString(M, y, "Sources de données de quartier : Statistique Canada (Recensement 2021, " |
| |
737 | + "licence ouverte), INSPQ (CC-BY 4.0), Ville de Montréal (CC-BY 4.0), OpenStreetMap.") |
| |
738 | + s.pied("© Lou-Ka — www.lou-ka.com · rapport non contractuel, généré automatiquement.", 3) |
| |
739 | + c.save() |
| |
740 | + return buf.getvalue() |