SPB Git forge

spb/uqo-imm1003

Public
7commits 1branches 0releases
189.5 MBsize
maindefault branch
6 h agolast push
JavaScript 68% CSS 32%
8.8 KB · 178 lines python
Raw Blame History
1"""rapport.py — intègre le « Rapport immobilier Québec » (article LaTeX, 26 sections, 67 figures PDF,228 tableaux générés, valeurs clés, données HPI ACI + FRED) sous forme de mini-livre web."""3from __future__ import annotations45import hashlib6import json7import os8import re9import shutil10import subprocess11from datetime import datetime1213from latex2html import Ctx, convert_chapter, strip_comments, read_group, plain_text14from bib import parse_bib1516REPORT_DIR = "Rapport_Immobilier_Quebec"171819def load_key_values(path: str) -> dict:20    vals = {}21    for m in re.finditer(r"\\newcommand\{\\(Vc[A-Za-z]+)\}\{([^}]*)\}", open(path, encoding="utf-8").read()):22        vals[m.group(1)] = m.group(2)23    return vals242526def preprocess(src_dir: str) -> tuple[str, list[dict], dict]:27    """Retourne (texte des sections, liste [{'key','num','title','tex','appendix'}], valeurs clés)."""28    main = open(os.path.join(src_dir, "rapport_immobilier_quebec.tex"), encoding="utf-8").read()29    vals = load_key_values(os.path.join(src_dir, "tables", "valeurs_cles.tex"))30    # corps : de la première \section à \bibliographystyle, puis annexes après \appendix31    start = main.index("\\section{Introduction}")32    bib_pos = main.index("\\bibliographystyle")33    app_pos = main.index("\\appendix")34    body = main[start:bib_pos] + "\n\\appendix\n" + main[app_pos + len("\\appendix"):main.index("\\end{document}")]35    # inputs de tableaux36    def inline_input(m):37        p = os.path.join(src_dir, m.group(1) + (".tex" if not m.group(1).endswith(".tex") else ""))38        return open(p, encoding="utf-8").read() if os.path.exists(p) else ""39    body = re.sub(r"\\input\{([^}]+)\}", inline_input, body)40    # valeurs clés41    for k, v in sorted(vals.items(), key=lambda kv: -len(kv[0])):42        body = re.sub(r"\\" + k + r"(\{\})?(?![A-Za-z])", lambda m, v=v: v, body)43    body = strip_comments(body)44    # découpage en sections → « chapitres »45    parts = re.split(r"(?=^\\section\{)", body, flags=re.M)46    sections = []47    appendix = False48    n = 049    for chunk in parts:50        if not chunk.strip():51            continue52        if not chunk.startswith("\\section{"):53            if "\\appendix" in chunk:54                appendix = True55            continue56        if "\\appendix" in chunk:57            # l'annexe commence après ce chunk58            chunk_main, _, rest = chunk.partition("\\appendix")59            chunk = chunk_main60            pending_app = True61        else:62            pending_app = False63        title, k = read_group(chunk, len("\\section"))64        if appendix:65            num = chr(ord("A") + sum(1 for s in sections if s["appendix"]))66        else:67            n += 168            num = str(n)69        tex = "\\chapter{" + title + "}" + chunk[k:]70        tex = tex.replace("\\subsubsection{", "\\subsection@@{").replace("\\subsection{", "\\section{").replace("\\subsection@@{", "\\subsection{")71        tex = tex.replace("\\clearpage", "")72        sections.append({"key": f"rapport-{num}", "num": num, "title": title, "tex": tex, "appendix": appendix})73        if pending_app:74            appendix = True75    return body, sections, vals767778def convert_figures(src_dir: str, images: list[str], cache_dir: str, out_dir: str) -> dict[int, str]:79    """Convertit les PDF de figures en SVG (cache) et les copie dans out_dir. Retourne {index: html}."""80    os.makedirs(cache_dir, exist_ok=True)81    os.makedirs(out_dir, exist_ok=True)82    out = {}83    for idx, rel in enumerate(images):84        src = os.path.join(src_dir, rel)85        if not os.path.exists(src):86            out[idx] = ""87            continue88        if rel.endswith(".png") or rel.endswith(".jpg"):89            name = os.path.basename(rel)90            shutil.copy2(src, os.path.join(out_dir, name))91            out[idx] = f'<span class="figimg"><img src="/assets/rapport/{name}" alt="" loading="lazy"></span>'92            continue93        h = hashlib.sha1((rel + str(os.path.getmtime(src))).encode()).hexdigest()[:12]94        name = os.path.splitext(os.path.basename(rel))[0] + "-" + h + ".svg"95        cached = os.path.join(cache_dir, name)96        if not os.path.exists(cached):97            subprocess.run(["pdftocairo", "-svg", src, cached], check=True, timeout=120)98        shutil.copy2(cached, os.path.join(out_dir, name))99        head = open(cached, encoding="utf-8", errors="replace").read(600)100        m = re.search(r'width="([\d.]+)pt" height="([\d.]+)pt"', head)101        attrs = f' width="{int(float(m.group(1)) * 4 / 3)}" height="{int(float(m.group(2)) * 4 / 3)}"' if m else ""102        out[idx] = f'<span class="figimg"><img src="/assets/rapport/{name}" alt="Figure du rapport" loading="lazy" decoding="async"{attrs}></span>'103    return out104105106def extract_hpi(src_dir: str) -> dict:107    """Extrait les séries HPI (mensuel non désaisonnalisé) des marchés d'intérêt."""108    import openpyxl109    path = os.path.join(src_dir, "data", "Not Seasonally Adjusted (M).xlsx")110    wb = openpyxl.load_workbook(path, read_only=True, data_only=True)111    wanted = {"AGGREGATE": "Canada", "QUEBEC": "Québec (province)", "MONTREAL_CMA": "RMR de Montréal", "QUEBEC_CMA": "RMR de Québec",112              "ESTRIE": "Estrie", "MAURICIE": "Mauricie", "CENTRE_DU_QUEBEC": "Centre-du-Québec", "ONTARIO": "Ontario",113              "GREATER_TORONTO": "Grand Toronto", "GREATER_VANCOUVER": "Grand Vancouver", "OTTAWA": "Ottawa", "CALGARY": "Calgary",114              "BRITISH_COLUMBIA": "Colombie-Britannique", "ALBERTA": "Alberta", "HALIFAX_DARTMOUTH": "Halifax", "WINNIPEG": "Winnipeg"}115    cols = ["Composite_HPI", "Single_Family_HPI", "One_Storey_HPI", "Two_Storey_HPI", "Townhouse_HPI", "Apartment_HPI", "Composite_Benchmark", "Single_Family_Benchmark", "Apartment_Benchmark"]116    dates = None117    series = {}118    for sheet, label in wanted.items():119        if sheet not in wb.sheetnames:120            continue121        ws = wb[sheet]122        rows = [r for r in ws.iter_rows(values_only=True) if r and r[0] is not None]123        header = [str(h) for h in rows[0]]124        idx = {c: header.index(c) for c in cols if c in header}125        data = [r for r in rows[1:] if isinstance(r[0], datetime)]126        d = [r[0].strftime("%Y-%m") for r in data]127        if dates is None or len(d) > len(dates):128            dates = d129        series[label] = {c: [(round(float(r[i]), 1) if isinstance(r[i], (int, float)) else None) for r in data] for c, i in idx.items()}130    return {"dates": dates, "series": series, "source": "ACI/CREA — Indice des prix des propriétés MLS®, mensuel non désaisonnalisé, janv. 2005 = 100"}131132133def extract_fred(src_dir: str) -> dict:134    meta = {"IR3TIB01CAM156N": ("Taux interbancaire 3 mois", "%"), "IRLTLT01CAM156N": ("Obligations 10 ans", "%"),135            "CPALTT01CAM659N": ("Inflation (IPC, variation annuelle)", "%"), "LRUNTTTTCAM156S": ("Taux de chômage", "%"),136            "DEXCAUS": ("Taux de change CAD/USD", "CAD par USD"), "NGDPRSAXDCCAQ": ("PIB réel (trimestriel)", "M$ 2017"),137            "POPTOTCAA647NWDB": ("Population", "habitants"), "CANCPIALLMINMEI": ("IPC (indice)", "2015 = 100")}138    out = {}139    for fid, (label, unit) in meta.items():140        p = os.path.join(src_dir, "data", "fred", fid + ".json")141        if not os.path.exists(p):142            continue143        d = json.load(open(p, encoding="utf-8"))144        obs = d.get("observations", []) if isinstance(d, dict) else d145        pts = []146        for o in obs:147            try:148                v = float(o["value"])149            except (ValueError, KeyError, TypeError):150                continue151            pts.append([o["date"][:7], round(v, 3)])152        if fid == "DEXCAUS":  # quotidien → mensuel (moyenne)153            agg = {}154            for dte, v in pts:155                agg.setdefault(dte, []).append(v)156            pts = [[k, round(sum(v) / len(v), 4)] for k, v in sorted(agg.items())]157        out[fid] = {"label": label, "unit": unit, "points": pts}158    return out159160161def build_report(ctx_code: str, root: str, out_dir: str, cache_dir: str, finalize_cb=None):162    """Convertit le rapport. Retourne dict {sections: [ChapterResult+meta], vals, bib, images_html, hpi, fred}."""163    src_dir = os.path.join(root, REPORT_DIR, "sources")164    body, sections, vals = preprocess(src_dir)165    bib = parse_bib(open(os.path.join(src_dir, "references.bib"), encoding="utf-8").read())166    ctx = Ctx(ctx_code + "-RAPPORT", bib)167    ctx.images = []168    results = []169    for s in sections:170        url = f"/rapport/{s['num']}/"171        r = convert_chapter(ctx, s["key"], s["num"], url, s["tex"])172        r.appendix = s["appendix"]173        results.append(r)174    img_html = convert_figures(src_dir, ctx.images, os.path.join(cache_dir, "rapport-fig"), os.path.join(out_dir, "assets", "rapport"))175    hpi = extract_hpi(src_dir)176    fred = extract_fred(src_dir)177    return {"sections": results, "vals": vals, "bib": bib, "ctx": ctx, "images": img_html, "hpi": hpi, "fred": fred, "src_dir": src_dir}178