|
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() |