#!/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()