| 4 | # pdfgen.py : génération PDF à l'identité visuelle officielle Lou-Ka |
4 | # pdfgen.py : génération PDF à l'identité visuelle officielle Lou-Ka |
| 5 | # - fiche_pdf(uid) : fiche de propriété complète — tout ce que montre la |
5 | # - fiche_pdf(uid) : fiche de propriété complète — tout ce que montre la |
| 6 | # fiche web : photos, prix + analyse Lou-Ka (juste valeur, histogramme), |
6 | # fiche web : photos, prix + analyse Lou-Ka (juste valeur, histogramme), |
| 7 | −# chips, description structurée, inclusions, registre des loyers, |
7 | +# chips, description structurée, inclusions, coût réel mensuel, |
| |
8 | +# historique Lou-Ka (+ vies antérieures), passeport de l'immeuble, |
| |
9 | +# gestionnaire (fiche Google + avis synchronisés), registre des loyers, |
| 8 | # historique TAL, inondation, qualité de l'air, coût d'électricité, |
10 | # historique TAL, inondation, qualité de l'air, coût d'électricité, |
| 9 | −# commerces/transport, essence, KA Scores, quartier, à proximité, QR |
11 | +# commerces/transport, essence, KA Scores, score hiver, quartier, |
| |
12 | +# à proximité, QR |
| 10 | # - rapport_pdf() : rapport global du marché (totaux, régions, histogramme |
13 | # - rapport_pdf() : rapport global du marché (totaux, régions, histogramme |
| 11 | # des loyers, types, villes, gestionnaires) |
14 | # des loyers, types, villes, gestionnaires) |
| 12 | # Palette identique au site : blanc #ffffff, marine #0b1330, orange #ff6a00, |
15 | # Palette identique au site : blanc #ffffff, marine #0b1330, orange #ff6a00, |
| 241 | return d |
244 | return d |
| 242 | |
245 | |
| 243 | |
246 | |
| |
247 | +def _fmt_ts(ts, jour: bool = False) -> str: |
| |
248 | + """Timestamp unix → « 6 août 2026 » (jour=True) ou « août 2026 ».""" |
| |
249 | + if not ts: |
| |
250 | + return "—" |
| |
251 | + t = time.localtime(ts) |
| |
252 | + mois = f"{_MOIS[t.tm_mon - 1]} {t.tm_year}" |
| |
253 | + return f"{t.tm_mday} {mois}" if jour else mois |
| |
254 | + |
| |
255 | + |
| 244 | _VERDICTS = { |
256 | _VERDICTS = { |
| 245 | "sous": ("Sous le marché", GREEN_DEEP, LIME_SOFT, GREEN), |
257 | "sous": ("Sous le marché", GREEN_DEEP, LIME_SOFT, GREEN), |
| 246 | "marche": ("Dans le marché", INK2, SURFACE, INK), |
258 | "marche": ("Dans le marché", INK2, SURFACE, INK), |
| 320 | com = _safe(com_mod.nearby, lat, lng) if geo else None |
332 | com = _safe(com_mod.nearby, lat, lng) if geo else None |
| 321 | hyd = _safe(hydro_mod.estimate, adresse=l.get("address") or "", |
333 | hyd = _safe(hydro_mod.estimate, adresse=l.get("address") or "", |
| 322 | uid=uid, lat=lat, lng=lng, solve=False) |
334 | uid=uid, lat=lat, lng=lng, solve=False) |
| |
335 | + # dossier d'intelligence locative (mêmes modules que les blocs du site) |
| |
336 | + from . import building as bld_mod |
| |
337 | + from . import coutreel as cr_mod |
| |
338 | + from . import history as hist_mod |
| |
339 | + from . import hiver as hiv_mod |
| |
340 | + from . import managers as mgr_mod |
| |
341 | + from . import recycled as rec_mod |
| |
342 | + cout = _safe(cr_mod.compute, uid) |
| |
343 | + histo = _safe(hist_mod.timeline, uid) |
| |
344 | + recyc = _safe(rec_mod.lookup, uid) |
| |
345 | + imm = (_safe(bld_mod.fiche, l["building_key"]) |
| |
346 | + if l.get("building_key") else None) |
| |
347 | + hiv = _safe(hiv_mod.score, lat, lng) if geo else None |
| |
348 | + gest = _safe(mgr_mod.fiche, l["source"]) |
| 323 | |
349 | |
| 324 | buf = io.BytesIO() |
350 | buf = io.BytesIO() |
| 325 | c = rl_canvas.Canvas(buf, pagesize=letter) |
351 | c = rl_canvas.Canvas(buf, pagesize=letter) |
| 568 | c.drawString(M, y - 2, f"Historique de prix de l'annonce : {chn}") |
594 | c.drawString(M, y - 2, f"Historique de prix de l'annonce : {chn}") |
| 569 | y -= 16 |
595 | y -= 16 |
| 570 | |
596 | |
| |
597 | + # --- Coût réel mensuel (loyer + frais, chaque poste étiqueté honnêtement) |
| |
598 | + if cout and cout.get("loyer") is not None: |
| |
599 | + lignes_cr = cout.get("lignes") or [] |
| |
600 | + y = place(y, 58 + len(lignes_cr) * 12 + 46) |
| |
601 | + y = s.titre_section(y, "Coût réel mensuel") |
| |
602 | + stat_cr = {"included": ("INCLUS", GREEN), "observed": ("OBSERVÉ", INK), |
| |
603 | + "estimated": ("ESTIMÉ", LIME), "unknown": ("INCONNU", INK3)} |
| |
604 | + for li in lignes_cr: |
| |
605 | + lab, col = stat_cr.get(li.get("statut"), stat_cr["unknown"]) |
| |
606 | + c.setFont("Helvetica", 8.5) |
| |
607 | + c.setFillColor(INK2) |
| |
608 | + c.drawString(M, y, (li.get("poste") or "")[:46]) |
| |
609 | + c.setFont("Courier-Bold", 6.5) |
| |
610 | + c.setFillColor(col) |
| |
611 | + c.drawString(M + 230, y, lab) |
| |
612 | + m_ = li.get("montant") |
| |
613 | + if m_ is not None and m_ > 0: |
| |
614 | + mtx = _fmt_money(m_) |
| |
615 | + elif m_ == 0 and li.get("statut") == "included": |
| |
616 | + mtx = f"0{NBSP}$" |
| |
617 | + else: |
| |
618 | + mtx = "—" |
| |
619 | + c.setFont("Helvetica-Bold", 8.5) |
| |
620 | + c.setFillColor(INK) |
| |
621 | + c.drawRightString(PAGE_W - M, y, mtx) |
| |
622 | + y -= 12 |
| |
623 | + if cout.get("total_estime") is not None: |
| |
624 | + c.setStrokeColor(INK) |
| |
625 | + c.setLineWidth(1.2) |
| |
626 | + c.line(M, y + 8, PAGE_W - M, y + 8) |
| |
627 | + c.setFont("Helvetica-Bold", 10) |
| |
628 | + c.setFillColor(INK) |
| |
629 | + c.drawString(M, y - 4, "Total estimé") |
| |
630 | + tot = f"≈{NBSP}{_fmt_money(cout['total_estime'])} / mois" |
| |
631 | + if cout.get("annuel_estime") is not None: |
| |
632 | + tot += f" · ≈{NBSP}{_fmt_money(cout['annuel_estime'])} / an" |
| |
633 | + c.drawRightString(PAGE_W - M, y - 4, tot) |
| |
634 | + y -= 18 |
| |
635 | + if cout.get("postes_inconnus"): |
| |
636 | + y = s.paragraphe(M, y - 2, "Postes non chiffrables avec les données " |
| |
637 | + "publiées : " |
| |
638 | + + ", ".join(cout["postes_inconnus"]).lower() |
| |
639 | + + " — le total réel peut être plus élevé.", |
| |
640 | + PAGE_W - 2 * M, taille=7.5, |
| |
641 | + couleur=HexColor("#8a5a12"), interligne=9.5, |
| |
642 | + max_lignes=3) |
| |
643 | + pi2_d = cout.get("pi2") |
| |
644 | + if pi2_d and pi2_d.get("valeur") is not None: |
| |
645 | + v = f"{pi2_d['valeur']:.2f}".replace(".", ",") |
| |
646 | + suite = f"{v}{NBSP}$/pi²" |
| |
647 | + if pi2_d.get("percentile_secteur") is not None: |
| |
648 | + suite += (f" — moins cher que {100 - pi2_d['percentile_secteur']}" |
| |
649 | + f"{NBSP}% des {pi2_d.get('n_secteur')} comparables du " |
| |
650 | + "secteur (~2 km)") |
| |
651 | + elif pi2_d.get("percentile_ville") is not None: |
| |
652 | + suite += (f" — moins cher que {100 - pi2_d['percentile_ville']}" |
| |
653 | + f"{NBSP}% des {pi2_d.get('n_ville')} comparables " |
| |
654 | + f"({pi2_d.get('portee_ville', '')})") |
| |
655 | + c.setFont("Helvetica", 8) |
| |
656 | + c.setFillColor(INK2) |
| |
657 | + c.drawString(M, y - 2, suite[:120]) |
| |
658 | + y -= 12 |
| |
659 | + y -= 8 |
| |
660 | + |
| |
661 | + # --- Historique Lou-Ka (observations réelles des synchronisations) |
| |
662 | + if histo: |
| |
663 | + tl = (histo.get("timeline") or [])[:8] |
| |
664 | + matches = ((recyc or {}).get("matches") or [])[:2] |
| |
665 | + y = place(y, 46 + len(tl) * 11 + len(matches) * 22 + 18) |
| |
666 | + y = s.titre_section(y, "Historique Lou-Ka") |
| |
667 | + entete_h = (f"Suivie depuis {_fmt_ts(histo.get('premiere_observation'), jour=True)}" |
| |
668 | + f" · {histo.get('jours_en_ligne', 0)} jour" |
| |
669 | + f"{'s' if (histo.get('jours_en_ligne') or 0) > 1 else ''} en ligne" |
| |
670 | + f" · {histo.get('modifications', 0)} modification" |
| |
671 | + f"{'s' if (histo.get('modifications') or 0) > 1 else ''} observée" |
| |
672 | + f"{'s' if (histo.get('modifications') or 0) > 1 else ''}") |
| |
673 | + pi_, pa_ = histo.get("prix_initial"), histo.get("prix_actuel") |
| |
674 | + if pi_ is not None and pa_ is not None and pi_ != pa_: |
| |
675 | + var_ = histo.get("variation") |
| |
676 | + entete_h += f" · {_fmt_money(pi_)} → {_fmt_money(pa_)}" |
| |
677 | + if var_ is not None: |
| |
678 | + entete_h += (f" ({'+' if var_ > 0 else '−'}" |
| |
679 | + f"{abs(round(var_ * 100))}{NBSP}%)") |
| |
680 | + c.setFont("Helvetica-Bold", 8.5) |
| |
681 | + c.setFillColor(INK) |
| |
682 | + c.drawString(M, y - 2, entete_h[:120]) |
| |
683 | + y -= 16 |
| |
684 | + ev_labels = {"description": "Description modifiée", |
| |
685 | + "superficie": "Superficie modifiée", |
| |
686 | + "dispo": "Disponibilité modifiée", |
| |
687 | + "inclusions": "Inclusions modifiées", |
| |
688 | + "photos": "Photos modifiées", |
| |
689 | + "disparition": "Annonce retirée", |
| |
690 | + "reapparition": "Annonce republiée"} |
| |
691 | + if tl: |
| |
692 | + for it in tl: |
| |
693 | + c.setFont("Courier-Bold", 7) |
| |
694 | + c.setFillColor(INK3) |
| |
695 | + c.drawString(M, y, _fmt_ts(it.get("ts"), jour=True).upper()) |
| |
696 | + c.setFont("Helvetica", 8) |
| |
697 | + c.setFillColor(INK2) |
| |
698 | + if it.get("type") == "prix": |
| |
699 | + if it.get("prix_avant") is not None: |
| |
700 | + sens = ("baissé" if it["prix_avant"] > (it.get("prix") or 0) |
| |
701 | + else "monté") |
| |
702 | + txt_ev = (f"Prix {sens} de {_fmt_money(it['prix_avant'])} " |
| |
703 | + f"à {_fmt_money(it.get('prix'))}") |
| |
704 | + else: |
| |
705 | + txt_ev = f"Premier prix observé : {_fmt_money(it.get('prix'))}" |
| |
706 | + else: |
| |
707 | + txt_ev = ev_labels.get(it.get("type"), it.get("type") or "") |
| |
708 | + c.drawString(M + 78, y, txt_ev[:95]) |
| |
709 | + y -= 11 |
| |
710 | + else: |
| |
711 | + c.setFont("Helvetica", 8) |
| |
712 | + c.setFillColor(INK2) |
| |
713 | + c.drawString(M, y, "Aucune modification observée depuis la première " |
| |
714 | + "synchronisation de cette annonce.") |
| |
715 | + y -= 11 |
| |
716 | + for m_ in matches: |
| |
717 | + suite = f"Vie antérieure probable ({m_.get('confiance')}{NBSP}% de confiance)" |
| |
718 | + if m_.get("prix") is not None: |
| |
719 | + suite += f" — affiché {_fmt_money(m_['prix'])}" |
| |
720 | + if m_.get("derniere_observation"): |
| |
721 | + suite += f" jusqu'en {_fmt_ts(m_['derniere_observation'])}" |
| |
722 | + c.setFont("Helvetica-Bold", 8) |
| |
723 | + c.setFillColor(HexColor("#8a5a12")) |
| |
724 | + c.drawString(M, y - 2, suite[:115]) |
| |
725 | + y -= 10 |
| |
726 | + c.setFont("Helvetica", 7) |
| |
727 | + c.setFillColor(INK3) |
| |
728 | + c.drawString(M, y - 2, ("signaux : " + " · ".join(m_.get("signaux") or []))[:125]) |
| |
729 | + y -= 12 |
| |
730 | + y -= 8 |
| |
731 | + |
| |
732 | + # --- Passeport de l'immeuble (ce que Lou-Ka observe du bâtiment) |
| |
733 | + if imm and (imm.get("annonces_total") or 0) >= 2: |
| |
734 | + y = place(y, 128) |
| |
735 | + y = s.titre_section(y, "Passeport de l'immeuble") |
| |
736 | + tw = (PAGE_W - 2 * M - 30) / 4 |
| |
737 | + rot = imm.get("rotation") or {} |
| |
738 | + pres = imm.get("pression_loyers") |
| |
739 | + tuiles_im = [ |
| |
740 | + (str(imm["annonces_total"]), |
| |
741 | + f"annonces obs. ({imm.get('annonces_actives', 0)} actives)"), |
| |
742 | + (f"≥{NBSP}{imm.get('unites_estimees', 0)}", "unités estimées"), |
| |
743 | + (_fmt_money(imm.get("loyer_median")), "loyer médian (actives)"), |
| |
744 | + (f"{imm['pi2_median']:.2f}".replace(".", ",") + f"{NBSP}$" |
| |
745 | + if imm.get("pi2_median") is not None else "—", "médiane $/pi²"), |
| |
746 | + ] |
| |
747 | + for i, (val, lab) in enumerate(tuiles_im): |
| |
748 | + s.tuile(M + i * (tw + 10), y - 44, tw, 38, val, lab) |
| |
749 | + y -= 58 |
| |
750 | + par_cc = imm.get("loyer_median_par_cc") or {} |
| |
751 | + if par_cc: |
| |
752 | + parts = [f"{cc}{NBSP}ch. {_fmt_money(v)}" |
| |
753 | + for cc, v in sorted(par_cc.items())] |
| |
754 | + c.setFont("Helvetica", 8) |
| |
755 | + c.setFillColor(INK2) |
| |
756 | + c.drawString(M, y, ("Loyer médian par nombre de chambres : " |
| |
757 | + + " · ".join(parts))[:118]) |
| |
758 | + y -= 13 |
| |
759 | + c.setFont("Helvetica-Bold", 8) |
| |
760 | + c.setFillColor(INK) |
| |
761 | + c.drawString(M, y, "Rotation des logements :") |
| |
762 | + c.setFont("Helvetica", 8) |
| |
763 | + c.setFillColor(INK2) |
| |
764 | + if rot.get("statut") == "calculated": |
| |
765 | + c.drawString(M + 118, y, |
| |
766 | + (f"{rot.get('classe', '')} — {rot.get('annonces_12m')} " |
| |
767 | + f"annonces sur 12 mois pour ≥{NBSP}" |
| |
768 | + f"{rot.get('unites_estimees')} unités")[:100]) |
| |
769 | + else: |
| |
770 | + c.drawString(M + 118, y, "données insuffisantes") |
| |
771 | + y -= 12 |
| |
772 | + c.setFont("Helvetica-Bold", 8) |
| |
773 | + c.setFillColor(INK) |
| |
774 | + c.drawString(M, y, "Pression sur les loyers :") |
| |
775 | + c.setFont("Helvetica", 8) |
| |
776 | + c.setFillColor(INK2) |
| |
777 | + if pres: |
| |
778 | + v12 = pres.get("variation_12m") or 0 |
| |
779 | + c.drawString(M + 118, y, |
| |
780 | + (f"{'+' if v12 > 0 else '−'}{abs(round(v12 * 100))}" |
| |
781 | + f"{NBSP}% sur 12 mois — médiane des prix d'entrée " |
| |
782 | + f"{_fmt_money(pres.get('mediane_12_24m'))} → " |
| |
783 | + f"{_fmt_money(pres.get('mediane_12m'))} " |
| |
784 | + f"({pres.get('n_12_24m')} vs {pres.get('n_12m')} annonces)")[:110]) |
| |
785 | + else: |
| |
786 | + c.drawString(M + 118, y, "échantillon insuffisant") |
| |
787 | + y -= 12 |
| |
788 | + c.setFont("Helvetica", 6.5) |
| |
789 | + c.setFillColor(INK3) |
| |
790 | + c.drawString(M, y - 2, (f"Immeuble suivi par Lou-Ka depuis " |
| |
791 | + f"{_fmt_ts(imm.get('premiere_observation'))} — " |
| |
792 | + "indicateurs calculés uniquement sur les annonces " |
| |
793 | + "observées (pas un recensement du bâtiment).")[:135]) |
| |
794 | + y -= 16 |
| |
795 | + |
| |
796 | + # --- Qui gère ce logement? (fiche Google associée + avis synchronisés) |
| |
797 | + g_ = (gest or {}).get("google_maps") |
| |
798 | + if gest and g_ and g_.get("statut") != "non_associe": |
| |
799 | + avis_g = gest.get("avis") or {} |
| |
800 | + dist_g = avis_g.get("distribution") or {} |
| |
801 | + tot_g = sum(dist_g.values()) if dist_g else 0 |
| |
802 | + y = place(y, 108 + (5 * 11 if tot_g else 0)) |
| |
803 | + y = s.titre_section(y, "Qui gère ce logement?") |
| |
804 | + c.setFont("Helvetica-Bold", 10.5) |
| |
805 | + c.setFillColor(INK) |
| |
806 | + c.drawString(M, y - 2, (gest.get("nom") or "")[:60]) |
| |
807 | + n_act = gest.get("annonces_actives") or 0 |
| |
808 | + c.setFont("Helvetica", 8) |
| |
809 | + c.setFillColor(INK2) |
| |
810 | + c.drawString(M + c.stringWidth((gest.get("nom") or "")[:60], |
| |
811 | + "Helvetica-Bold", 10.5) + 10, y - 2, |
| |
812 | + f"{n_act} annonce{'s' if n_act > 1 else ''} active" |
| |
813 | + f"{'s' if n_act > 1 else ''} sur Lou-Ka") |
| |
814 | + y -= 16 |
| |
815 | + if g_.get("note") is not None: |
| |
816 | + note_t = f"{g_['note']:.1f}".replace(".", ",") |
| |
817 | + s.pilule(M, y - 10, f"Note Google {note_t} / 5", LIME, INK) |
| |
818 | + c.setFont("Helvetica", 8) |
| |
819 | + c.setFillColor(INK2) |
| |
820 | + c.drawString(M + 110, y - 5, |
| |
821 | + (f"{g_.get('nombre_avis', 0)}{NBSP}avis Google — fiche " |
| |
822 | + f"« {g_.get('nom', '')} »")[:90]) |
| |
823 | + y -= 22 |
| |
824 | + if tot_g: |
| |
825 | + bw_g = 150.0 |
| |
826 | + for n_ in (5, 4, 3, 2, 1): |
| |
827 | + cnt = dist_g.get(str(n_), 0) |
| |
828 | + c.setFont("Courier-Bold", 7) |
| |
829 | + c.setFillColor(INK2) |
| |
830 | + c.drawString(M, y, f"{n_}*") |
| |
831 | + c.setFillColor(SURFACE) |
| |
832 | + c.setStrokeColor(INK) |
| |
833 | + c.setLineWidth(0.8) |
| |
834 | + c.rect(M + 22, y - 1, bw_g, 7, stroke=1, fill=1) |
| |
835 | + if cnt: |
| |
836 | + c.setFillColor(GREEN if n_ >= 4 else (LIME if n_ <= 2 else INK3)) |
| |
837 | + c.rect(M + 22, y - 1, bw_g * cnt / tot_g, 7, stroke=0, fill=1) |
| |
838 | + c.setFont("Courier-Bold", 7) |
| |
839 | + c.setFillColor(INK) |
| |
840 | + c.drawString(M + 22 + bw_g + 6, y, str(cnt)) |
| |
841 | + y -= 11 |
| |
842 | + y -= 2 |
| |
843 | + stats_g = [] |
| |
844 | + if avis_g.get("moyenne_12m") is not None: |
| |
845 | + stats_g.append(f"moyenne des 12 derniers mois : " |
| |
846 | + f"{avis_g['moyenne_12m']:.1f}".replace(".", ",") |
| |
847 | + + f" ({avis_g.get('n_12m')} avis)") |
| |
848 | + if avis_g.get("tendance"): |
| |
849 | + stats_g.append(avis_g["tendance"]) |
| |
850 | + if avis_g.get("plaintes_frequentes"): |
| |
851 | + stats_g.append("plaintes récurrentes : " |
| |
852 | + + ", ".join(avis_g["plaintes_frequentes"][:4])) |
| |
853 | + if stats_g: |
| |
854 | + y = s.paragraphe(M, y, " · ".join(stats_g), PAGE_W - 2 * M, |
| |
855 | + taille=8, couleur=INK2, interligne=10, |
| |
856 | + max_lignes=2) |
| |
857 | + y -= 2 |
| |
858 | + conf_g = round((g_.get("confiance_association") or 0) * 100) |
| |
859 | + y = s.paragraphe(M, y - 2, f"Fiche Google Maps associée automatiquement " |
| |
860 | + f"(confiance {conf_g}{NBSP}%). Statistiques sur " |
| |
861 | + f"les {avis_g.get('n', 0)} avis synchronisés dans " |
| |
862 | + "la base Lou-Ka — échantillon des plus récents, " |
| |
863 | + "pas une lecture humaine.", |
| |
864 | + PAGE_W - 2 * M, taille=6.5, couleur=INK3, |
| |
865 | + interligne=8.5, max_lignes=3) |
| |
866 | + y -= 6 |
| |
867 | + |
| 571 | # --- Registre des loyers (déclarations citoyennes à proximité) |
868 | # --- Registre des loyers (déclarations citoyennes à proximité) |
| 572 | if rdl_d and rdl_d.get("n"): |
869 | if rdl_d and rdl_d.get("n"): |
| 573 | comp = rdl_d.get("items") or [] |
870 | comp = rdl_d.get("items") or [] |
| 815 | f"{lab} · {sub}" if sub else lab) |
1112 | f"{lab} · {sub}" if sub else lab) |
| 816 | y -= 62 |
1113 | y -= 62 |
| 817 | |
1114 | |
| |
1115 | + # --- Vie quotidienne en hiver (quotidien à pied, même à −20 °C) |
| |
1116 | + if hiv: |
| |
1117 | + det_h = hiv.get("detail") or [] |
| |
1118 | + y = place(y, 52 + len(det_h) * 11 + 14) |
| |
1119 | + y = s.titre_section(y, "Vie quotidienne en hiver") |
| |
1120 | + c.setFont("Helvetica-Bold", 17) |
| |
1121 | + c.setFillColor(INK) |
| |
1122 | + sc_t = str(hiv.get("score", "—")) |
| |
1123 | + c.drawString(M, y - 8, sc_t) |
| |
1124 | + s.pilule(M + c.stringWidth(sc_t, "Helvetica-Bold", 17) + 10, y - 12, |
| |
1125 | + (hiv.get("classe") or "").upper()) |
| |
1126 | + c.setFont("Helvetica", 8) |
| |
1127 | + c.setFillColor(INK3) |
| |
1128 | + c.drawString(M + c.stringWidth(sc_t, "Helvetica-Bold", 17) + 105, y - 7, |
| |
1129 | + "quotidien à pied, même à −20 °C") |
| |
1130 | + y -= 26 |
| |
1131 | + for cr_ in det_h: |
| |
1132 | + c.setFont("Helvetica-Bold", 8) |
| |
1133 | + c.setFillColor(INK) |
| |
1134 | + c.drawString(M, y, (cr_.get("critere") or "")[:24]) |
| |
1135 | + c.setFont("Helvetica", 8) |
| |
1136 | + c.setFillColor(INK2) |
| |
1137 | + if cr_.get("distance_m") is not None: |
| |
1138 | + val_h = f"≈{NBSP}{cr_.get('minutes')}{NBSP}min à pied" |
| |
1139 | + if cr_.get("nom"): |
| |
1140 | + val_h += f" ({cr_['nom']})" |
| |
1141 | + else: |
| |
1142 | + val_h = cr_.get("note") or "—" |
| |
1143 | + c.drawString(M + 90, y, val_h[:80]) |
| |
1144 | + c.setFont("Courier-Bold", 7.5) |
| |
1145 | + c.setFillColor(INK) |
| |
1146 | + c.drawRightString(PAGE_W - M, y, str(cr_.get("score", "—"))) |
| |
1147 | + y -= 11 |
| |
1148 | + c.setFont("Helvetica", 6.5) |
| |
1149 | + c.setFillColor(INK3) |
| |
1150 | + c.drawString(M, y - 2, (hiv.get("methode") or "")[:135]) |
| |
1151 | + y -= 16 |
| |
1152 | + |
| 818 | # --- Le quartier |
1153 | # --- Le quartier |
| 819 | if quartier: |
1154 | if quartier: |
| 820 | y = place(y, 270) |
1155 | y = place(y, 270) |
| 821 | |
1156 | |