From 70a1605ec0f2ecc307f7b9a0410165c41b4b3826 Mon Sep 17 00:00:00 2001 From: Master Date: Thu, 9 Jul 2026 21:50:21 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20mesure=20texte=20PIL,=20fitter=20anti-d?= =?UTF-8?q?=C3=A9bordement=20et=20centrage=20vertical=20(C3,=20C3b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- measure.py | 236 +++++++++++++++++++++++++++++++++++++ patch_facilitator_c3.py | 194 ++++++++++++++++++++++++++++++ patch_render_engine_c3.py | 221 ++++++++++++++++++++++++++++++++++ patch_render_engine_c3b.py | 173 +++++++++++++++++++++++++++ 4 files changed, 824 insertions(+) create mode 100644 measure.py create mode 100644 patch_facilitator_c3.py create mode 100644 patch_render_engine_c3.py create mode 100644 patch_render_engine_c3b.py diff --git a/measure.py b/measure.py new file mode 100644 index 0000000..55f3ef1 --- /dev/null +++ b/measure.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +measure.py — Sliding Pipeline v11 · Chantier C3 (Moteur durci) +============================================================== +Mesure RÉELLE du texte (PIL/Pillow sur les TTF de assets/fonts/) en +remplacement de l'heuristique estimate_text_height, plus le fitter +anti-débordement (réduction par paliers de 1 pt, plancher 60 % du corps +nominal, puis troncature avec …). + +Déterminisme : même texte + même police + même corps + même largeur +→ même résultat, toujours. Cache par (fichier, corps). + +Dégradation gracieuse : si Pillow est absent ou si la police n'a pas de +TTF résolvable, retombe sur l'heuristique v2 (comportement identique à +l'existant) avec un warning affiché UNE fois par police. + +Config : FONTS_DIR (env, défaut ./assets/fonts). +Python 3.9. Dépendance optionnelle : Pillow (pip install Pillow +--break... non : sur GrosseBertha → pip3 install Pillow dans le venv). +""" + +import os +from pathlib import Path + +try: + from PIL import ImageFont + HAS_PIL = True +except ImportError: + HAS_PIL = False + +PT_TO_CM = 2.54 / 72.0 # 1 pt = 1/72 in +FONTS_DIR = Path(os.getenv("FONTS_DIR", "assets/fonts")) +DEFAULT_LINE_RATIO = 1.3 # aligné sur l'heuristique v2 (≈1.3 em) + +_font_cache = {} # (path, size) -> ImageFont +_path_cache = {} # (name_lower, bold) -> Path | None +_warned = set() + + +# ── Résolution des fichiers de police ──────────────────────────────────────── + +def _scan_fonts(): + if not FONTS_DIR.is_dir(): + return [] + out = [] + for f in sorted(FONTS_DIR.iterdir()): + if f.suffix.lower() in (".ttf", ".ttc", ".otf"): + out.append(f) + return out + +# Alias métriques (C3) : les decks déclarent Calibri/Cambria (safe-fonts +# corporate), mesurés ici sur leurs équivalents libres métrique-identiques. +FONT_ALIASES = { + "calibri": "carlito", + "cambria": "caladea", +} + +def resolve_font(name, bold=False): + """Chemin du fichier de police pour (nom, graisse), ou None. + Stratégie : suffixes MS classiques (calibrib, cambriab, -Bold), + puis correspondance par inclusion insensible à la casse.""" + key = ((name or "").lower(), bool(bold)) + _alias = FONT_ALIASES.get(key[0]) + if _alias: + key = (_alias, key[1]) + if key in _path_cache: + return _path_cache[key] + files = _scan_fonts() + base = key[0].replace(" ", "") + candidates = [] + if bold: + candidates += [base + "-bold", base + "b", base + "bold", base + "bd"] + candidates += [base + "-regular", base] + result = None + for cand in candidates: + for f in files: + stem = f.stem.lower().replace(" ", "").replace("_", "") + if stem == cand: + result = f + break + if result: + break + if result is None: # inclusion (ex Cambria.ttc) + for cand in candidates: + for f in files: + if cand in f.stem.lower().replace(" ", ""): + result = f + break + if result: + break + _path_cache[key] = result + return result + + +def _get_font(name, size_pt, bold=False): + path = resolve_font(name, bold) + if path is None and bold: + path = resolve_font(name, False) # graisse simulée : régulier + if path is None: + return None + ck = (str(path), int(size_pt)) + if ck not in _font_cache: + try: + _font_cache[ck] = ImageFont.truetype(str(path), int(size_pt)) + except Exception: + _font_cache[ck] = None + return _font_cache[ck] + + +def _warn_once(name): + if name not in _warned: + _warned.add(name) + print(" ~ measure : police '%s' sans TTF dans %s — heuristique." + % (name, FONTS_DIR)) + + +# ── Heuristique v2 (fallback, comportement identique à l'existant) ────────── + +def _heuristic_height_cm(text, size_pt, width_cm): + if not text: + return 0.0 + char_w_cm = size_pt * 0.0185 + chars_per_line = max(1, int(width_cm / char_w_cm)) + lines = 0 + for para in str(text).split("\n"): + lines += max(1, -(-len(para) // chars_per_line)) + return lines * size_pt * 0.0455 + + +# ── Mesure réelle ──────────────────────────────────────────────────────────── + +def _wrap_count(text, font, width_cm): + """Nombre de lignes après word-wrap à la largeur donnée (mesure PIL).""" + max_w_pt = width_cm / PT_TO_CM + total = 0 + space_w = font.getlength(" ") + for para in str(text).split("\n"): + words = para.split(" ") + if not words: + total += 1 + continue + lines, cur = 1, 0.0 + for word in words: + w = font.getlength(word) + if w > max_w_pt: # mot plus long que la zone + if cur > 0: + lines += 1 + lines += max(0, int(w // max_w_pt)) + cur = w % max_w_pt + continue + add = w if cur == 0 else space_w + w + if cur + add <= max_w_pt: + cur += add + else: + lines += 1 + cur = w + total += lines + return max(1, total) + + +def text_height_cm(text, font_name, size_pt, width_cm, + bold=False, line_ratio=None): + """Hauteur (cm) du texte rendu à cette largeur. Mesure réelle si + possible, heuristique v2 sinon.""" + if not text: + return 0.0 + ratio = line_ratio or DEFAULT_LINE_RATIO + if HAS_PIL and width_cm > 0: + font = _get_font(font_name, size_pt, bold) + if font is not None: + lines = _wrap_count(text, font, width_cm) + ascent, descent = font.getmetrics() + line_h_pt = max(ascent + descent, size_pt) * ratio \ + if ratio != DEFAULT_LINE_RATIO else (ascent + descent) * 1.15 + # (ascent+descent)*1.15 ≈ interligne simple PowerPoint ; + # ratio explicite (spacing fourni) prime. + if line_ratio: + line_h_pt = size_pt * ratio + return lines * line_h_pt * PT_TO_CM + _warn_once(font_name) + return _heuristic_height_cm(text, size_pt, width_cm) + + +# ── Fitter anti-débordement ────────────────────────────────────────────────── + +def fit_text(text, font_name, size_pt, width_cm, max_h_cm, + bold=False, min_ratio=0.6, line_ratio=None): + """Fait tenir le texte dans (width_cm × max_h_cm). + 1. Réduction du corps par pas de 1 pt, plancher min_ratio du nominal. + 2. Au plancher, troncature par mots avec « … ». + Retourne (size_final, text_final, truncated: bool). Déterministe.""" + text = str(text) + if not text or max_h_cm <= 0: + return size_pt, text, False + floor = max(6, int(round(size_pt * min_ratio))) + size = int(size_pt) + while size >= floor: + if text_height_cm(text, font_name, size, width_cm, + bold, line_ratio) <= max_h_cm: + return size, text, False + size -= 1 + size = floor + words = text.split(" ") + lo, hi = 1, len(words) + best = None + while lo <= hi: # dichotomie déterministe + mid = (lo + hi) // 2 + cand = " ".join(words[:mid]).rstrip(" ,;:.") + "…" + if text_height_cm(cand, font_name, size, width_cm, + bold, line_ratio) <= max_h_cm: + best, lo = cand, mid + 1 + else: + hi = mid - 1 + if best is None: + best = (words[0][:8] + "…") if words else "…" + return size, best, True + + +if __name__ == "__main__": + print("Pillow :", "présent" if HAS_PIL else "ABSENT (heuristique)") + print("FONTS_DIR :", FONTS_DIR.resolve(), + "(%d fichiers)" % len(_scan_fonts())) + for f in _scan_fonts(): + print(" -", f.name) + for name in ("Calibri", "Cambria"): + for b in (False, True): + p = resolve_font(name, b) + print("%s%s → %s" % (name, " bold" if b else "", + p.name if p else "heuristique")) + demo = ("La gouvernance des données transforme nos actifs en " + "avantage compétitif durable pour Pernod Ricard.") + h = text_height_cm(demo, "Calibri", 14, 10.0) + print("Hauteur démo 14 pt / 10 cm : %.2f cm" % h) + s, t, tr = fit_text(demo, "Calibri", 14, 10.0, 1.0) + print("Fit dans 1 cm : %d pt, tronqué=%s → %s" % (s, tr, t[:60])) diff --git a/patch_facilitator_c3.py b/patch_facilitator_c3.py new file mode 100644 index 0000000..0fc3f50 --- /dev/null +++ b/patch_facilitator_c3.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +patch_facilitator_c3.py — Chantier C3 (fusion des révisions ciblées) +==================================================================== +Patch strict de facilitator_v9.py : + P1. Insère merge_revision() : remplace, dans le dernier YAML complet + (project_state.json → dernier_yaml), les slides régénérées par la + révision ciblée (appariement par position), puis re-rend le deck + ENTIER. Le PPTX partiel « à recoller » disparaît du flux. + P2. Branche la fusion dans run_full_pipeline_pass juste avant le + rendu (suffix revision_ciblee uniquement). + P3. En cas de fusion réussie, l'état du projet est persisté comme un + deck complet (dernier_yaml/dernier_pptx à jour). + P4. Message utilisateur complété au site révision ciblée. + +Échec de fusion (pas de dernier_yaml, YAML illisible…) : comportement +actuel conservé à l'identique (PPTX partiel + message de recollage). + +Usage (dossier du pipeline, single-line) : + python3 patch_facilitator_c3.py +Compatible avant/après les patchs C1 et C2 (ancres indépendantes). +Vérifie chaque ancre, écrit .bak-c3, compile, idempotent. +""" + +import py_compile +import shutil +import sys +from pathlib import Path + +TARGET = Path("facilitator_v9.py") + +FUNC = ''' +def merge_revision(proj: "Project", partial_yaml_path: Path): + """Fusion YAML des révisions ciblées (chantier C3). + Remplace dans le dernier YAML complet les slides régénérées + (appariement par position ; positions inconnues ajoutées en fin). + Retourne le chemin du YAML complet fusionné, ou None si fusion + impossible (l'appelant conserve alors le flux partiel actuel).""" + state = proj.load_state() + last = state.get("dernier_yaml") or "" + if not last or not Path(last).exists(): + warn("Fusion : pas de YAML complet précédent — PPTX partiel " + "conservé.") + return None + try: + full = yaml.safe_load(Path(last).read_text(encoding="utf-8")) + part = yaml.safe_load( + partial_yaml_path.read_text(encoding="utf-8")) + except yaml.YAMLError as e: + warn(f"Fusion : YAML illisible ({e}).") + return None + if not isinstance(full, dict) or not full.get("slides"): + warn("Fusion : le YAML précédent ne contient pas de slides.") + return None + news = {} + for s in (part or {}).get("slides", []): + if isinstance(s, dict) and s.get("position"): + news[int(s["position"])] = s + if not news: + warn("Fusion : aucune slide positionnée dans la révision.") + return None + merged, replaced = [], 0 + for i, s in enumerate(full["slides"]): + pos = int(s.get("position", i + 1)) if isinstance(s, dict) \ + else i + 1 + if pos in news: + merged.append(news.pop(pos)) + replaced += 1 + else: + merged.append(s) + for pos in sorted(news): + merged.append(news[pos]) + full["slides"] = merged + out = proj.out("revision_fusion", "yaml") + out.write_text( + yaml.safe_dump(full, allow_unicode=True, sort_keys=False, + default_flow_style=False, width=100), + encoding="utf-8") + ok(f"Fusion : {replaced} slide(s) remplacée(s), " + f"{len(merged)} au total → {out.name}") + return out + +''' + +PATCHES = [ + # P2 — fusion avant le rendu (revision_ciblee) + ( + ' # Renommer la sortie selon le suffixe demandé\n' + ' if suffix != "input" and yaml_path:\n' + ' new_path = proj.out(suffix, "yaml")\n' + ' yaml_path.rename(new_path)\n' + ' yaml_path = new_path\n' + '\n' + ' manifest.file(yaml_path)\n', + ' # Renommer la sortie selon le suffixe demandé\n' + ' if suffix != "input" and yaml_path:\n' + ' new_path = proj.out(suffix, "yaml")\n' + ' yaml_path.rename(new_path)\n' + ' yaml_path = new_path\n' + '\n' + ' merged = False\n' + ' if suffix == "revision_ciblee" and yaml_path:\n' + ' fused = merge_revision(proj, yaml_path)\n' + ' if fused:\n' + ' yaml_path, merged = fused, True\n' + ' info("Rendu du deck COMPLET fusionné.")\n' + '\n' + ' manifest.file(yaml_path)\n', + ), + # P3 — persistance d'état si fusion + ( + ' elif suffix == "revision_ciblee":\n' + ' persist_generation(\n' + ' proj, markdown=markdown,\n' + ' journal_entry="Révision ciblée — slides ' + 'régénérées séparément.")\n', + ' elif suffix == "revision_ciblee":\n' + ' if merged:\n' + ' persist_generation(\n' + ' proj, markdown=markdown, yaml_path=yaml_path,\n' + ' pptx_path=pptx_path,\n' + ' journal_entry="Révision ciblée fusionnée — ' + 'deck complet régénéré.")\n' + ' else:\n' + ' persist_generation(\n' + ' proj, markdown=markdown,\n' + ' journal_entry="Révision ciblée — slides ' + 'régénérées séparément.")\n', + ), + # P4 — message utilisateur + ( + ' info("Ouvre ce fichier et copie-colle les slides dans ' + 'ton deck maître.")\n', + ' info("Ouvre ce fichier et copie-colle les slides dans ' + 'ton deck maître.")\n' + ' info("(Si la fusion YAML a réussi — voir ci-dessus — ' + 'le PPTX est déjà le deck complet.)")\n', + ), +] + +MARKER = "def merge_revision" +ANCHOR_SECTION = "# FLUX LIBRE — THE FREE DESIGNER" + + +def fail(msg): + print(" ! %s" % msg) + sys.exit(1) + + +def main(): + if not TARGET.exists(): + fail("%s introuvable — lancer depuis le dossier du pipeline." + % TARGET) + content = TARGET.read_text(encoding="utf-8") + if MARKER in content: + fail("Déjà patché (merge_revision présent) — rien à faire.") + + for i, (old, _) in enumerate(PATCHES, 1): + n = content.count(old) + if n == 0: + fail("Ancre du patch %d introuvable — facilitator inattendu." + % i) + if n > 1: + fail("Ancre du patch %d non unique (%d occurrences)." % (i, n)) + if content.count(ANCHOR_SECTION) != 1: + fail("Ancre de section FREE DESIGNER introuvable ou non unique.") + + shutil.copy2(TARGET, str(TARGET) + ".bak-fc3") + print(" + Sauvegarde : %s.bak-fc3" % TARGET) + + for old, new in PATCHES: + content = content.replace(old, new) + + lines = content.split("\n") + idx = next(i for i, l in enumerate(lines) if ANCHOR_SECTION in l) + ins = idx - 1 if lines[idx - 1].startswith("# ───") else idx + lines[ins:ins] = FUNC.split("\n") + content = "\n".join(lines) + + TARGET.write_text(content, encoding="utf-8") + print(" + 3 patchs + merge_revision appliqués.") + try: + py_compile.compile(str(TARGET), doraise=True) + print(" + Compilation OK.") + except py_compile.PyCompileError as e: + shutil.copy2(str(TARGET) + ".bak-fc3", TARGET) + fail("Erreur de compilation — fichier restauré :\n%s" % e) + print("\n La prochaine révision ciblée régénérera le deck complet " + "(revision_fusion.yaml).") + + +if __name__ == "__main__": + main() diff --git a/patch_render_engine_c3.py b/patch_render_engine_c3.py new file mode 100644 index 0000000..1a53502 --- /dev/null +++ b/patch_render_engine_c3.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +patch_render_engine_c3.py — Chantier C3 (Moteur durci) +====================================================== +Patch strict de render_engine_v2.py : + P1. Import optionnel de measure.py (mesure PIL réelle). + P2. estimate_text_height() délègue à la mesure réelle quand elle est + disponible (fallback : heuristique v2 inchangée) + ajoute + strip_markdown_tree() (nettoyage récursif du Markdown résiduel). + P3. Fitter anti-débordement dans _text() : si le texte mesuré dépasse + la zone, réduction par pas de 1 pt (plancher 60 % du nominal) puis + troncature avec « … » — chaque ajustement est tracé en console. + Désactivable par appel (fit=False) ou globalement (FIT_TEXT=0). + P4. Nettoyage Markdown appliqué à TOUTE donnée entrante de render(). + P5. Speaker notes : champ notes: par slide → zone notes PowerPoint. + +Usage (dossier du pipeline, single-line) : + python3 patch_render_engine_c3.py +Prérequis : measure.py à côté (sinon P1/P3 restent inertes, sans casse). +Vérifie chaque ancre, écrit render_engine_v2.py.bak-c3, compile, +idempotent. +""" + +import py_compile +import shutil +import sys +from pathlib import Path + +TARGET = Path("render_engine_v2.py") + +PATCHES = [ + # P1 — import measure (optionnel) + ( + "from pptx.util import Cm, Emu, Pt\n", + "from pptx.util import Cm, Emu, Pt\n" + "\n" + "try:\n" + " import measure\n" + " HAS_MEASURE = True\n" + "except ImportError:\n" + " HAS_MEASURE = False\n" + "FIT_TEXT = os.getenv(\"FIT_TEXT\", \"1\") != \"0\" # C3\n", + ), + # P2 — mesure réelle + strip_markdown_tree + ( + "def estimate_text_height(text: str, size_pt: int, width_cm: float)" + " -> float:\n" + " \"\"\"Hauteur estimée d'un texte (cm) pour une largeur donnée." + "\"\"\"\n" + " if not text:\n" + " return 0.0\n" + " char_w_cm = size_pt * 0.0185 # largeur moyenne d'un " + "caractère\n" + " chars_per_line = max(1, int(width_cm / char_w_cm))\n" + " lines = 0\n" + " for para in str(text).split(\"\\n\"):\n" + " lines += max(1, -(-len(para) // chars_per_line))\n" + " return lines * size_pt * 0.0455 # hauteur de ligne ≈ 1.3" + " em\n", + "def estimate_text_height(text: str, size_pt: int, width_cm: float," + "\n" + " font_name: str = \"Calibri\",\n" + " bold: bool = False) -> float:\n" + " \"\"\"Hauteur d'un texte (cm). Mesure réelle PIL si disponible" + " (C3),\n" + " sinon heuristique v2 inchangée.\"\"\"\n" + " if not text:\n" + " return 0.0\n" + " if HAS_MEASURE:\n" + " try:\n" + " return measure.text_height_cm(str(text), font_name,\n" + " size_pt, width_cm, bold)" + "\n" + " except Exception:\n" + " pass\n" + " char_w_cm = size_pt * 0.0185 # largeur moyenne d'un " + "caractère\n" + " chars_per_line = max(1, int(width_cm / char_w_cm))\n" + " lines = 0\n" + " for para in str(text).split(\"\\n\"):\n" + " lines += max(1, -(-len(para) // chars_per_line))\n" + " return lines * size_pt * 0.0455 # hauteur de ligne ≈ 1.3" + " em\n" + "\n" + "\n" + "_MD_RES = [\n" + " (re.compile(r\"\\*\\*(.+?)\\*\\*\"), r\"\\1\"),\n" + " (re.compile(r\"__(.+?)__\"), r\"\\1\"),\n" + " (re.compile(r\"`([^`]+)`\"), r\"\\1\"),\n" + " (re.compile(r\"^#{1,4}\\s+\"), \"\"),\n" + " (re.compile(r\"^[-•]\\s+\"), \"\"),\n" + "]\n" + "\n" + "\n" + "def strip_markdown_tree(node):\n" + " \"\"\"Nettoyage récursif du Markdown résiduel (C3) : gras,\n" + " italique, code inline, titres et puces en tête de valeur.\"\"\"" + "\n" + " if isinstance(node, dict):\n" + " return {k: strip_markdown_tree(v) for k, v in node.items()}" + "\n" + " if isinstance(node, list):\n" + " return [strip_markdown_tree(v) for v in node]\n" + " if isinstance(node, str):\n" + " s = node\n" + " for rx, rep in _MD_RES:\n" + " s = rx.sub(rep, s)\n" + " return s\n" + " return node\n", + ), + # P3 — fitter dans _text + ( + " def _text(self, slide, x, y, w, h, txt, *, font=None, size=14," + "\n" + " bold=False, italic=False, color=None, align=PP_ALIGN." + "LEFT,\n" + " anchor=MSO_ANCHOR.TOP, spacing=None, char_spacing=" + "None):\n" + " tb = slide.shapes.add_textbox(Cm(x), Cm(y), Cm(w), Cm(h))\n", + " def _text(self, slide, x, y, w, h, txt, *, font=None, size=14," + "\n" + " bold=False, italic=False, color=None, align=PP_ALIGN." + "LEFT,\n" + " anchor=MSO_ANCHOR.TOP, spacing=None, char_spacing=" + "None,\n" + " fit=True):\n" + " if (fit and FIT_TEXT and HAS_MEASURE and txt\n" + " and h and h > 0.3 and w and w > 0.5):\n" + " _ratio = (spacing / size) if spacing else None\n" + " _s, _t, _tr = measure.fit_text(\n" + " str(txt), font or self.F_BODY, size, w, h,\n" + " bold=bold, line_ratio=_ratio)\n" + " if _s != size or _tr:\n" + " print(f\" ~ fit slide \"\n" + " f\"{getattr(self, '_slide_num', '?')} : \"\n" + " f\"{size}→{_s} pt\"\n" + " + (\" +troncature\" if _tr else \"\"))\n" + " size, txt = _s, _t\n" + " tb = slide.shapes.add_textbox(Cm(x), Cm(y), Cm(w), Cm(h))\n", + ), + # P4 — strip à l'entrée de render() + ( + " if isinstance(data, str):\n" + " data = yaml.safe_load(data)\n" + " slides = data.get(\"slides\", data) if isinstance(data, " + "dict) else data\n", + " if isinstance(data, str):\n" + " data = yaml.safe_load(data)\n" + " data = strip_markdown_tree(data)\n" + " slides = data.get(\"slides\", data) if isinstance(data, " + "dict) else data\n", + ), + # P5 — speaker notes + ( + " if layout not in excluded and layout != " + "\"recommendation_card\":\n" + " self._footer(slide, i + 1)\n" + "\n" + " prs.save(output_path)\n", + " if layout not in excluded and layout != " + "\"recommendation_card\":\n" + " self._footer(slide, i + 1)\n" + " notes = sd.get(\"notes\") if isinstance(sd, dict) else" + " None\n" + " if notes:\n" + " slide.notes_slide.notes_text_frame.text = " + "str(notes)\n" + "\n" + " prs.save(output_path)\n", + ), +] + +MARKER = "strip_markdown_tree" + + +def fail(msg): + print(" ! %s" % msg) + sys.exit(1) + + +def main(): + if not TARGET.exists(): + fail("%s introuvable — lancer depuis le dossier du pipeline." + % TARGET) + if not Path("measure.py").exists(): + print(" ~ measure.py absent : le moteur restera sur l'heuristique" + " tant qu'il n'est pas déposé (patch appliqué quand même).") + content = TARGET.read_text(encoding="utf-8") + if MARKER in content: + fail("Déjà patché (strip_markdown_tree présent) — rien à faire.") + + for i, (old, _) in enumerate(PATCHES, 1): + n = content.count(old) + if n == 0: + fail("Ancre du patch %d introuvable — moteur inattendu." % i) + if n > 1: + fail("Ancre du patch %d non unique (%d occurrences)." % (i, n)) + + shutil.copy2(TARGET, str(TARGET) + ".bak-c3") + print(" + Sauvegarde : %s.bak-c3" % TARGET) + + for old, new in PATCHES: + content = content.replace(old, new) + + TARGET.write_text(content, encoding="utf-8") + print(" + 5 patchs appliqués.") + try: + py_compile.compile(str(TARGET), doraise=True) + print(" + Compilation OK.") + except py_compile.PyCompileError as e: + shutil.copy2(str(TARGET) + ".bak-c3", TARGET) + fail("Erreur de compilation — fichier restauré :\n%s" % e) + print("\n Prérequis mesure réelle : pip3 install Pillow (venv) et TTF" + " dans assets/fonts/") + print(" Diagnostic : python3 measure.py") + print(" Désactivation d'urgence du fitter : FIT_TEXT=0 dans .env") + + +if __name__ == "__main__": + main() diff --git a/patch_render_engine_c3b.py b/patch_render_engine_c3b.py new file mode 100644 index 0000000..46f83db --- /dev/null +++ b/patch_render_engine_c3b.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +patch_render_engine_c3b.py — Chantier C3b (centrage vertical) +============================================================= +Corrige deux défauts de centrage vertical révélés par les previews C2, +SANS toucher au helper _cy() (le centrage du contenu dans la zone est +un choix de design assumé — le contenu respire). + + P1. big_stat : la hauteur de bloc était codée en dur (block = 8.13), + sous-estimée par rapport au placement réel (chiffre à y, desc à + y+5.33, source à y+7.37 ≈ 8.3+). Résultat : _cy() centrait un + bloc faussé, l'ensemble penchait vers le bas. Correctif : hauteur + réelle calculée (avec/sans source) → centrage exact. Les offsets + internes deviennent relatifs à cette hauteur. + + P2. executive_summary : dans chaque carte (hauteur ch=3.68), le label + et le texte étaient posés à des offsets FIXES (y+0.56, y+1.57), + collés en haut, laissant du vide en bas de carte. Correctif : le + bloc label+texte est centré verticalement dans la carte (offsets + dérivés de ch), à côté du badge déjà centré. + +Aucune dépendance nouvelle ; réutilise measure via estimate_text_height +déjà présent. Vérifie chaque ancre, écrit .bak-c3b, compile, idempotent. + +Usage (dossier du pipeline, single-line) : + python3 patch_render_engine_c3b.py +""" + +import py_compile +import shutil +import sys +from pathlib import Path + +TARGET = Path("render_engine_v2.py") + +PATCHES = [ + # P1 — big_stat : hauteur réelle + ( + ' def _render_big_stat(self, slide, d):\n' + ' self._title(slide, pick(d, "titre", "title"))\n' + ' val = pick(d, "valeur", "stat", "chiffre", "value")\n' + ' desc = pick(d, "description", "texte", "label")\n' + ' src = pick(d, "source", "reference")\n' + ' block = 8.13\n' + ' y = self._cy(block)\n' + ' self._text(slide, 0, y, self.SLIDE_W, 5.1, val,\n' + ' font=self.F_DISPLAY, size=self.T["stat_hero"], ' + 'bold=True,\n' + ' color=self.C["coral"], align=PP_ALIGN.CENTER,\n' + ' anchor=MSO_ANCHOR.MIDDLE)\n' + ' self._text(slide, 6.86, y + 5.33, self.SLIDE_W - 13.72, ' + '2.0, desc,\n' + ' size=18, color=self.C["body"], ' + 'align=PP_ALIGN.CENTER)\n' + ' if src:\n' + ' self._text(slide, 6.86, y + 7.37, self.SLIDE_W - ' + '13.72, 0.9, src,\n' + ' size=11, italic=True, ' + 'color=self.C["muted"],\n' + ' align=PP_ALIGN.CENTER)\n', + + ' def _render_big_stat(self, slide, d):\n' + ' self._title(slide, pick(d, "titre", "title"))\n' + ' val = pick(d, "valeur", "stat", "chiffre", "value")\n' + ' desc = pick(d, "description", "texte", "label")\n' + ' src = pick(d, "source", "reference")\n' + ' # Hauteur réelle du bloc (C3b) : chiffre + desc ' + '(+ source)\n' + ' H_VAL, GAP_D, H_DESC, GAP_S, H_SRC = 5.1, 0.23, 2.0, ' + '0.14, 0.9\n' + ' block = H_VAL + GAP_D + H_DESC + (\n' + ' GAP_S + H_SRC if src else 0.0)\n' + ' y = self._cy(block)\n' + ' self._text(slide, 0, y, self.SLIDE_W, H_VAL, val,\n' + ' font=self.F_DISPLAY, size=self.T["stat_hero"], ' + 'bold=True,\n' + ' color=self.C["coral"], align=PP_ALIGN.CENTER,\n' + ' anchor=MSO_ANCHOR.MIDDLE)\n' + ' y_desc = y + H_VAL + GAP_D\n' + ' self._text(slide, 6.86, y_desc, self.SLIDE_W - 13.72, ' + 'H_DESC, desc,\n' + ' size=18, color=self.C["body"], ' + 'align=PP_ALIGN.CENTER)\n' + ' if src:\n' + ' y_src = y_desc + H_DESC + GAP_S\n' + ' self._text(slide, 6.86, y_src, self.SLIDE_W - 13.72, ' + 'H_SRC, src,\n' + ' size=11, italic=True, ' + 'color=self.C["muted"],\n' + ' align=PP_ALIGN.CENTER)\n', + ), + # P2 — executive_summary : bloc label+texte centré dans la carte + ( + ' self._text(slide, self.MX + 3.81, y + 0.56, 8.1, 1.0, ' + 'label,\n' + ' size=14, bold=True, color=col, ' + 'char_spacing=3)\n' + ' self._text(slide, self.MX + 3.81, y + 1.57,\n' + ' self.SLIDE_W - 2 * self.MX - 5.33, 1.8, ' + 'txt,\n' + ' size=self.T["body"], ' + 'color=self.C["body"])\n', + + ' # Bloc label+texte centré verticalement dans la carte ' + '(C3b)\n' + ' H_LBL, GAP_LT, H_TXT = 0.85, 0.18, 1.8\n' + ' blk = H_LBL + GAP_LT + H_TXT\n' + ' y_lbl = y + max(0.0, (ch - blk) / 2)\n' + ' self._text(slide, self.MX + 3.81, y_lbl, 8.1, H_LBL, ' + 'label,\n' + ' size=14, bold=True, color=col, ' + 'char_spacing=3,\n' + ' anchor=MSO_ANCHOR.MIDDLE)\n' + ' self._text(slide, self.MX + 3.81, y_lbl + H_LBL + ' + 'GAP_LT,\n' + ' self.SLIDE_W - 2 * self.MX - 5.33, H_TXT, ' + 'txt,\n' + ' size=self.T["body"], color=self.C["body"],' + '\n' + ' anchor=MSO_ANCHOR.MIDDLE)\n', + ), +] + +MARKER = "Hauteur réelle du bloc (C3b)" + + +def fail(msg): + print(" ! %s" % msg) + sys.exit(1) + + +def main(): + if not TARGET.exists(): + fail("%s introuvable — lancer depuis le dossier du pipeline." + % TARGET) + content = TARGET.read_text(encoding="utf-8") + if MARKER in content: + fail("Déjà patché (C3b présent) — rien à faire.") + + for i, (old, _) in enumerate(PATCHES, 1): + n = content.count(old) + if n == 0: + fail("Ancre du patch %d introuvable — moteur inattendu " + "(version différente ?)." % i) + if n > 1: + fail("Ancre du patch %d non unique (%d occurrences)." % (i, n)) + + shutil.copy2(TARGET, str(TARGET) + ".bak-c3b") + print(" + Sauvegarde : %s.bak-c3b" % TARGET) + + for old, new in PATCHES: + content = content.replace(old, new) + + TARGET.write_text(content, encoding="utf-8") + print(" + 2 patchs de centrage appliqués (big_stat, " + "executive_summary).") + try: + py_compile.compile(str(TARGET), doraise=True) + print(" + Compilation OK.") + except py_compile.PyCompileError as e: + shutil.copy2(str(TARGET) + ".bak-c3b", TARGET) + fail("Erreur de compilation — fichier restauré :\n%s" % e) + print("\n Re-rends et compare les previews : big_stat (chiffre " + "mieux centré),") + print(" executive_summary (texte centré dans les cartes). Vérifie " + "aussi from_to_pairs") + print(" (le rognage des labels a pu disparaître avec le fix polices " + "C3).") + + +if __name__ == "__main__": + main()