From 40b9451377012d9c9801845321c879bf062adf23 Mon Sep 17 00:00:00 2001 From: Master Date: Thu, 9 Jul 2026 21:50:30 +0200 Subject: [PATCH] feat: assets et images, layouts image_split/image_full (C4) --- patch_facilitator_c4.py | 142 +++++++++++++++++ patch_facilitator_c4b.py | 214 +++++++++++++++++++++++++ patch_layouts_c4.py | 112 +++++++++++++ patch_render_engine_c4.py | 319 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 787 insertions(+) create mode 100644 patch_facilitator_c4.py create mode 100644 patch_facilitator_c4b.py create mode 100644 patch_layouts_c4.py create mode 100644 patch_render_engine_c4.py diff --git a/patch_facilitator_c4.py b/patch_facilitator_c4.py new file mode 100644 index 0000000..c7736be --- /dev/null +++ b/patch_facilitator_c4.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +patch_facilitator_c4.py — Chantier C4 (Assets & images) +======================================================= +Patch strict de facilitator_v9.py : + P1. validate_freeform accepte le nouveau type de bloc « image ». + P2. /lire inventorie aussi projets//assets/ (images + dimensions) + et injecte la liste dans le contexte Narrator — le Narrator peut + alors référencer les fichiers par nom dans les layouts image_*. + Le dossier assets/ est créé au premier /lire s'il n'existe pas. + P3. Insère list_assets() (inventaire, dimensions via Pillow si + disponible, sinon noms seuls). + +Usage (dossier du pipeline, single-line) : + python3 patch_facilitator_c4.py +Compatible tout ordre avec C1/C2/C3. Vérifie chaque ancre, écrit +.bak-fc4, compile, idempotent. +""" + +import py_compile +import shutil +import sys +from pathlib import Path + +TARGET = Path("facilitator_v9.py") + +FUNC = ''' +def list_assets(proj: "Project"): + """Inventaire des images de projets//assets/ (chantier C4). + Crée le dossier au premier appel. Retourne un bloc texte destiné au + contexte Narrator, ou une chaîne vide si aucune image.""" + assets = proj.root / "assets" + assets.mkdir(exist_ok=True) + exts = {".png", ".jpg", ".jpeg", ".webp"} + files = sorted(p for p in assets.iterdir() + if p.suffix.lower() in exts and p.is_file()) + if not files: + return "" + lines = ["IMAGES DISPONIBLES DANS assets/ (utilisables dans les " + "layouts image_split / image_full et le bloc freeform " + "image, par leur nom de fichier) :"] + for p in files: + dims = "" + try: + from PIL import Image + with Image.open(p) as im: + dims = f" ({im.size[0]}×{im.size[1]})" + except Exception: + pass + lines.append(f"- {p.name}{dims}") + return "\\n".join(lines) + +''' + +PATCHES = [ + # P1 — type de bloc freeform « image » + ( + 'VALID_BLOCK_TYPES = {"title", "heading", "text", "stat", ' + '"circle",\n' + ' "badge", "card", "rect", "line"}\n', + 'VALID_BLOCK_TYPES = {"title", "heading", "text", "stat", ' + '"circle",\n' + ' "badge", "card", "rect", "line", "image"}\n', + ), + # P2 — /lire : inventaire des assets + ( + ' elif cmd == "/lire":\n' + ' context, new_files = load_documents(proj, ' + 'loaded_docs)\n' + ' if not new_files:\n' + ' info("Aucun nouveau document dans inputs/.")\n' + ' continue\n', + ' elif cmd == "/lire":\n' + ' context, new_files = load_documents(proj, ' + 'loaded_docs)\n' + ' assets_info = list_assets(proj)\n' + ' if assets_info:\n' + ' context = ((context or "").strip()\n' + ' + "\\n\\n" + assets_info).strip()\n' + ' ok(f"{len(assets_info.splitlines()) - 1} ' + 'image(s) dans assets/.")\n' + ' if not new_files and not assets_info:\n' + ' info("Aucun nouveau document dans inputs/ ni ' + 'image dans assets/.")\n' + ' continue\n', + ), +] + +MARKER = "def list_assets" +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é (list_assets 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-fc4") + print(" + Sauvegarde : %s.bak-fc4" % 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(" + 2 patchs + list_assets appliqués.") + try: + py_compile.compile(str(TARGET), doraise=True) + print(" + Compilation OK.") + except py_compile.PyCompileError as e: + shutil.copy2(str(TARGET) + ".bak-fc4", TARGET) + fail("Erreur de compilation — fichier restauré :\n%s" % e) + print("\n Dépose tes images dans projets//assets/ puis /lire " + "dans la session.") + + +if __name__ == "__main__": + main() diff --git a/patch_facilitator_c4b.py b/patch_facilitator_c4b.py new file mode 100644 index 0000000..074ef8e --- /dev/null +++ b/patch_facilitator_c4b.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +patch_facilitator_c4b.py — Correctif C4 (résolution des assets) +================================================================ +run_render() ne transmettait jamais --assets à render_engine_v2.py, +qui retombait sur son défaut /../assets. Ce défaut n'est correct +que si le YAML est dans projets//outputs/ (alors ../assets = +projets//assets/) — ce qui couvre le flux normal du pipeline, +mais PAS un YAML isolé à la racine (golden, test manuel). + +Patch : run_render accepte un paramètre assets_dir optionnel. Résolu +dans l'ordre : + 1. assets_dir explicitement fourni par l'appelant (le pipeline + normal le fournira désormais : projets//assets/). + 2. Sinon, si le YAML est sous PROJECTS_DIR, le dossier assets/ du + projet correspondant est déduit automatiquement. + 3. Sinon (golden, test manuel hors projet), fallback sur le défaut + du moteur (/../assets) — comportement inchangé pour ces cas. + +Tous les appelants internes de run_render (pipeline standard, flux +libre, révisions, merge C3, --render CLI) sont mis à jour pour passer +proj.root / "assets" quand un Project est en contexte. + +Usage (dossier du pipeline, single-line) : + python3 patch_facilitator_c4b.py +Compatible C1/C2/C3/C4 déjà appliqués (ancres indépendantes). Vérifie +chaque ancre, écrit .bak-c4b, compile, idempotent. +""" + +import py_compile +import shutil +import sys +from pathlib import Path + +TARGET = Path("facilitator_v9.py") + +PATCHES = [ + # P1 — signature + résolution + transmission --assets + ( + 'def run_render(yaml_path: Path) -> Optional[Path]:\n' + ' section("ÉTAPE 4 — RENDER ENGINE V2")\n' + ' if not Path(RENDER_ENGINE_PATH).exists():\n' + ' warn(f"{RENDER_ENGINE_PATH} introuvable.")\n' + ' return None\n' + ' for p in [THEME_PATH, COMPONENTS_PATH, LAYOUTS_PATH]:\n' + ' if not os.path.exists(p):\n' + ' warn(f"Config manquante : {p}")\n' + ' return None\n' + ' pptx_out = yaml_path.with_suffix(".pptx")\n' + ' info("Lancement de render_engine_v2.py...")\n' + ' info(f"Sortie : {pptx_out}")\n' + ' result = subprocess.run(\n' + ' [sys.executable, RENDER_ENGINE_PATH, str(yaml_path), ' + 'str(pptx_out),\n' + ' "--theme", THEME_PATH, "--components", ' + 'COMPONENTS_PATH,\n' + ' "--layouts", LAYOUTS_PATH],\n' + ' capture_output=True, text=True)\n', + + 'def run_render(yaml_path: Path,\n' + ' assets_dir: Optional[Path] = None) -> ' + 'Optional[Path]:\n' + ' section("ÉTAPE 4 — RENDER ENGINE V2")\n' + ' if not Path(RENDER_ENGINE_PATH).exists():\n' + ' warn(f"{RENDER_ENGINE_PATH} introuvable.")\n' + ' return None\n' + ' for p in [THEME_PATH, COMPONENTS_PATH, LAYOUTS_PATH]:\n' + ' if not os.path.exists(p):\n' + ' warn(f"Config manquante : {p}")\n' + ' return None\n' + ' pptx_out = yaml_path.with_suffix(".pptx")\n' + ' info("Lancement de render_engine_v2.py...")\n' + ' info(f"Sortie : {pptx_out}")\n' + ' if assets_dir is None:\n' + ' # Déduction depuis PROJECTS_DIR si le YAML y vit ' + '(C4b)\n' + ' try:\n' + ' rel = yaml_path.resolve().relative_to(\n' + ' Path(PROJECTS_DIR).resolve())\n' + ' assets_dir = Path(PROJECTS_DIR) / rel.parts[0] ' + '/ "assets"\n' + ' except ValueError:\n' + ' assets_dir = None # hors projet : défaut du ' + 'moteur\n' + ' cmd = [sys.executable, RENDER_ENGINE_PATH, str(yaml_path),' + '\n' + ' str(pptx_out),\n' + ' "--theme", THEME_PATH, "--components", ' + 'COMPONENTS_PATH,\n' + ' "--layouts", LAYOUTS_PATH]\n' + ' if assets_dir is not None:\n' + ' cmd += ["--assets", str(assets_dir)]\n' + ' info(f"Assets : {assets_dir}")\n' + ' result = subprocess.run(cmd, capture_output=True, ' + 'text=True)\n', + ), + # P2 — CLI --render : passe le dossier assets du projet si déductible + ( + ' if args.render:\n' + ' yaml_path = Path(args.render)\n' + ' if not yaml_path.exists():\n' + ' warn(f"Fichier introuvable : {yaml_path}")\n' + ' sys.exit(1)\n' + ' layouts = load_layouts()\n' + ' is_valid, message, _ = validate_yaml(\n' + ' yaml_path.read_text(encoding="utf-8"), layouts)\n' + ' (ok if is_valid else warn)(f"Validation : {message}")\n' + ' sys.exit(0 if run_render(yaml_path) else 1)\n', + + ' if args.render:\n' + ' yaml_path = Path(args.render)\n' + ' if not yaml_path.exists():\n' + ' warn(f"Fichier introuvable : {yaml_path}")\n' + ' sys.exit(1)\n' + ' layouts = load_layouts()\n' + ' is_valid, message, _ = validate_yaml(\n' + ' yaml_path.read_text(encoding="utf-8"), layouts)\n' + ' (ok if is_valid else warn)(f"Validation : {message}")\n' + ' local_assets = yaml_path.parent / "assets"\n' + ' assets_dir = local_assets if local_assets.is_dir() ' + 'else None\n' + ' sys.exit(0 if run_render(yaml_path, assets_dir) ' + 'else 1)\n', + ), +] + +# Les 3 appels internes de run_render(yaml_path) — même nom de +# variable partout, différenciés par leur contexte immédiat. +CALL_PATCHES = [ + ( + ' info("Rendu du deck COMPLET fusionné.")\n' + '\n' + ' manifest.file(yaml_path)\n' + ' pptx_path = run_render(yaml_path)\n', + ' info("Rendu du deck COMPLET fusionné.")\n' + '\n' + ' manifest.file(yaml_path)\n' + ' pptx_path = run_render(yaml_path, proj.root / ' + '"assets")\n', + ), + ( + ' if yaml_path and yaml_data:\n' + ' manifest.file(yaml_path)\n' + ' pptx_path = run_render(yaml_path)\n', + ' if yaml_path and yaml_data:\n' + ' manifest.file(yaml_path)\n' + ' pptx_path = run_render(yaml_path, proj.root ' + '/ "assets")\n', + ), + ( + ' if yaml_path and yaml_data:\n' + ' manifest.file(yaml_path)\n' + ' pptx_path = run_render(yaml_path)\n', + ' if yaml_path and yaml_data:\n' + ' manifest.file(yaml_path)\n' + ' pptx_path = run_render(yaml_path, proj.root / ' + '"assets")\n', + ), +] + +MARKER = "assets_dir: Optional[Path] = None" + + +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é (assets_dir présent) — rien à faire.") + if "def merge_revision" not in content: + fail("Prérequis manquant : patch_facilitator_c3.py doit être " + "appliqué avant celui-ci (C4b s'ancre sur le message de " + "fusion introduit par C3).") + + all_patches = PATCHES + CALL_PATCHES + for i, (old, _) in enumerate(all_patches, 1): + n = content.count(old) + if n == 0: + fail("Ancre du patch %d introuvable — facilitator inattendu." + " (Le fichier a peut-être déjà été modifié depuis les " + "patchs C1/C2/C3.)" % i) + if n > 1: + fail("Ancre du patch %d non unique (%d occurrences)." % (i, n)) + + shutil.copy2(TARGET, str(TARGET) + ".bak-c4b") + print(" + Sauvegarde : %s.bak-c4b" % TARGET) + + for old, new in all_patches: + content = content.replace(old, new) + + TARGET.write_text(content, encoding="utf-8") + print(" + %d patchs appliqués (2 structurels + %d appels internes)." + % (len(all_patches), len(CALL_PATCHES))) + try: + py_compile.compile(str(TARGET), doraise=True) + print(" + Compilation OK.") + except py_compile.PyCompileError as e: + shutil.copy2(str(TARGET) + ".bak-c4b", TARGET) + fail("Erreur de compilation — fichier restauré :\n%s" % e) + print("\n Pipeline normal : assets résolus automatiquement " + "(projets//assets/).") + print(" --render CLI : assets résolus si un dossier assets/ existe" + " à côté du YAML (sinon défaut moteur /../assets).") + + +if __name__ == "__main__": + main() diff --git a/patch_layouts_c4.py b/patch_layouts_c4.py new file mode 100644 index 0000000..b9c15c3 --- /dev/null +++ b/patch_layouts_c4.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +patch_layouts_c4.py — Chantier C4 (catalogue : image_split, image_full) +======================================================================= +Ajoute les deux layouts images à layouts_v2.yaml par APPEND sécurisé +(le fichier n'est jamais réécrit ni reformaté — protection des +commentaires et de la mise en forme, leçon de l'incident Le Chat) : + 1. Parse le fichier actuel, vérifie l'absence des deux clés. + 2. Vérifie que la dernière ligne significative appartient bien au + bloc layouts: (ligne indentée) — sinon refus. + 3. Sauvegarde .bak-c4, append du fragment, re-parse de contrôle. + 4. En cas d'échec du re-parse : restauration automatique. + +Après ce patch, propager : python3 prompt_injection_v2.py && python3 +build_gallery.py (le Designer voit les nouveaux layouts, la galerie et +le bloc skill sont régénérés). + +Usage (dossier du pipeline, single-line) : + python3 patch_layouts_c4.py +""" + +import shutil +import sys +from pathlib import Path + +import yaml + +TARGET = Path("layouts_v2.yaml") + +FRAGMENT = """ + # ── Chantier C4 — layouts images ────────────────────────────────── + image_split: + id: L50 + famille: Visuel + mode: light + champs: [titre, image, bullets, side, legende] + champs_requis: [titre, image, bullets] + agent_hint: >- + Image d'appui sur 40 % de la slide (side: left par défaut, right + possible) + titre et points clés (max 4 bullets). À utiliser quand + une image du dossier assets/ du projet illustre le propos ; + image = nom de fichier exact tel que listé par /lire. + legende (optionnelle) s'affiche sur un bandeau navy sous l'image. + + image_full: + id: L51 + famille: Visuel + mode: dark + champs: [titre, image, sous_titre] + champs_requis: [titre, image] + agent_hint: >- + Ouverture de chapitre visuelle : image plein cadre + voile navy + + titre display blanc. Alternative à section_divider quand un asset + du projet s'y prête. image = nom de fichier exact de assets/. +""" + + +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) + raw = TARGET.read_text(encoding="utf-8") + try: + data = yaml.safe_load(raw) + except yaml.YAMLError as e: + fail("layouts_v2.yaml actuel illisible : %s" % e) + layouts = (data or {}).get("layouts") + if not isinstance(layouts, dict): + fail("Bloc layouts: introuvable dans le fichier.") + for key in ("image_split", "image_full"): + if key in layouts: + fail("'%s' déjà présent — rien à faire." % key) + + # La dernière ligne significative doit être DANS le bloc layouts: + last = next((l for l in reversed(raw.splitlines()) + if l.strip() and not l.strip().startswith("#")), "") + if not last.startswith(" "): + fail("Le fichier ne se termine pas dans le bloc layouts: " + "(dernière ligne non indentée : %r) — fusion manuelle " + "requise." % last[:40]) + + shutil.copy2(TARGET, str(TARGET) + ".bak-c4") + print(" + Sauvegarde : %s.bak-c4" % TARGET) + + with open(TARGET, "a", encoding="utf-8") as f: + if not raw.endswith("\n"): + f.write("\n") + f.write(FRAGMENT) + + try: + check = yaml.safe_load(TARGET.read_text(encoding="utf-8")) + nl = check["layouts"] + assert "image_split" in nl and "image_full" in nl + assert len(nl) == len(layouts) + 2 + except Exception as e: + shutil.copy2(str(TARGET) + ".bak-c4", TARGET) + fail("Contrôle post-append échoué (%s) — fichier restauré." % e) + + print(" + image_split (L50) et image_full (L51) ajoutés " + "(%d layouts au total)." % (len(layouts) + 2)) + print("\n Propager : python3 prompt_injection_v2.py && python3 " + "build_gallery.py") + + +if __name__ == "__main__": + main() diff --git a/patch_render_engine_c4.py b/patch_render_engine_c4.py new file mode 100644 index 0000000..2f80d51 --- /dev/null +++ b/patch_render_engine_c4.py @@ -0,0 +1,319 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +patch_render_engine_c4.py — Chantier C4 (Assets & images) +========================================================= +Patch strict de render_engine_v2.py : + P1. _image_size() module-level : dimensions via Pillow, sinon + mini-parseur d'en-têtes PNG/JPEG (aucune dépendance). + P2. Composants : _asset() (résolution dans assets/ du projet), + _image() (insertion cover = recadrage centré symétrique, jamais de + déformation ; contain = letterbox centré), _rect_alpha() (voile de + couleur avec transparence OOXML), et les renderers + _render_image_split (image 40 %, side left/right, légende sur + bandeau navy) et _render_image_full (plein cadre + voile navy + 60 % + titre display blanc). + Image introuvable → placeholder card_alt + nom du fichier + (warning console), jamais de crash. + P3. Freeform : nouveau bloc {type: image, image: fichier, fit:}. + P4. REGISTRY : image_split, image_full. + P5. CLI : --assets (défaut : /../assets, soit + projets//assets/ dans le flux facilitator — aucun patch + facilitator nécessaire pour le rendu). + +Usage (dossier du pipeline, single-line) : + python3 patch_render_engine_c4.py +Indépendant du patch C3 (ancres distinctes, tout ordre). Vérifie chaque +ancre, écrit .bak-c4, compile, idempotent. +""" + +import py_compile +import shutil +import sys +from pathlib import Path + +TARGET = Path("render_engine_v2.py") + +IMAGE_SIZE_FUNC = ''' + +def _image_size(path): + """Dimensions (w, h) d'une image : Pillow si présent, sinon lecture + directe des en-têtes PNG/JPEG. None si indéterminable.""" + try: + from PIL import Image + with Image.open(path) as im: + return im.size + except Exception: + pass + import struct + try: + with open(path, "rb") as f: + head = f.read(26) + if head[:8] == b"\\x89PNG\\r\\n\\x1a\\n": + w, h = struct.unpack(">II", head[16:24]) + return int(w), int(h) + if head[:2] == b"\\xff\\xd8": + f.seek(2) + while True: + b2 = f.read(2) + if len(b2) < 2 or b2[0] != 0xFF: + return None + marker = b2[1] + if marker in (0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, + 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, + 0xCF): + f.read(3) + h, w = struct.unpack(">HH", f.read(4)) + return int(w), int(h) + ln = struct.unpack(">H", f.read(2))[0] + f.seek(ln - 2, 1) + except Exception: + pass + return None +''' + +COMPONENTS = ''' # ---------------- assets & images (C4) ---------------- + assets_dir = "assets" # surchargé par main() ou l'appelant + + def _asset(self, name): + """Résout un nom de fichier image vers un chemin existant : + chemin direct, puis assets_dir/name. None si introuvable.""" + name = str(name or "").strip() + if not name: + return None + if os.path.isfile(name): + return name + cand = os.path.join(str(self.assets_dir or "assets"), name) + if os.path.isfile(cand): + return cand + return None + + def _image(self, slide, x, y, w, h, name, fit="cover"): + """Insère une image dans la zone (x, y, w, h) SANS déformation. + cover : remplit la zone, recadrage centré symétrique (crop_*). + contain : letterbox centré dans la zone. + Image introuvable ou illisible → placeholder déterministe.""" + path = self._asset(name) + size = _image_size(path) if path else None + if not path or not size or size[0] <= 0 or size[1] <= 0: + print(f" ~ image absente ou illisible : {name} → placeholder") + self._rect(slide, x, y, w, h, self.C["card_alt"]) + self._text(slide, x, y, w, h, f"[image : {name}]", + size=12, color=self.C["muted"], + align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE, + fit=False) + return None + iw, ih = size + target_ratio = w / h + img_ratio = iw / ih + if fit == "contain": + if img_ratio > target_ratio: + w2, h2 = w, w / img_ratio + x2, y2 = x, y + (h - h2) / 2 + else: + h2, w2 = h, h * img_ratio + x2, y2 = x + (w - w2) / 2, y + return slide.shapes.add_picture(path, Cm(x2), Cm(y2), + Cm(w2), Cm(h2)) + pic = slide.shapes.add_picture(path, Cm(x), Cm(y), Cm(w), Cm(h)) + if img_ratio > target_ratio: # trop large → crop lat. + c = (1 - target_ratio / img_ratio) / 2 + pic.crop_left = c + pic.crop_right = c + elif img_ratio < target_ratio: # trop haut → crop vert. + c = (1 - img_ratio / target_ratio) / 2 + pic.crop_top = c + pic.crop_bottom = c + return pic + + def _rect_alpha(self, slide, x, y, w, h, color, alpha_pct): + """Rectangle de couleur avec transparence (voile) — OOXML.""" + sh = self._rect(slide, x, y, w, h, color) + srgb = sh._element.spPr.find(qn("a:solidFill")).find( + qn("a:srgbClr")) + alpha = srgb.makeelement( + qn("a:alpha"), {"val": str(int(alpha_pct * 1000))}) + srgb.append(alpha) + return sh + + # ---------------- renderers images (C4) ---------------- + def _render_image_split(self, slide, d): + """Image 40 % d'un côté (side: left|right) + titre et bullets. + L'image s'arrête au-dessus du footer (numéro de page lisible).""" + side = str(d.get("side") or "left").lower() + img_w = self.SLIDE_W * 0.40 + img_h = self.FOOTER_Y - 0.2 + ix = 0.0 if side != "right" else self.SLIDE_W - img_w + self._image(slide, ix, 0.0, img_w, img_h, + pick(d, "image", "img"), fit="cover") + legende = pick(d, "legende", "caption") + if legende: + bh = 1.15 + self._rect(slide, ix, img_h - bh, img_w, bh, self.C["navy"]) + self._text(slide, ix + 0.5, img_h - bh, img_w - 1.0, bh, + legende, size=11, color=self.C["white"], + anchor=MSO_ANCHOR.MIDDLE) + cx = self.MX if side == "right" else img_w + 1.5 + cw = self.SLIDE_W - img_w - 1.5 - self.MX + self._text(slide, cx, self.TITLE_Y, cw, self.TITLE_H, + pick(d, "titre", "title"), + font=self.F_DISPLAY, size=self.T["slide_title"], + bold=True, color=self.C["navy"]) + bullets = [b for b in (d.get("bullets") or [])][:6] + bh_i, gap = 1.9, 0.5 + tot = len(bullets) * (bh_i + gap) - gap if bullets else 0 + y = self._cy(tot) + for b in bullets: + self._oval(slide, cx, y + 0.28, 0.28, self.C["coral"]) + self._text(slide, cx + 0.8, y, cw - 0.8, bh_i, + as_label(b, "texte", "text"), + size=self.T["body"], color=self.C["body"]) + y += bh_i + gap + + def _render_image_full(self, slide, d): + """Image plein cadre + voile navy 60 % + titre display blanc. + Ouverture de chapitre visuelle (alternative à section_divider).""" + self._bg(slide, self.C["navy"]) + self._image(slide, 0.0, 0.0, self.SLIDE_W, self.SLIDE_H, + pick(d, "image", "img"), fit="cover") + self._rect_alpha(slide, 0.0, 0.0, self.SLIDE_W, self.SLIDE_H, + self.C["navy"], 60) + self._oval(slide, self.MX, 6.48, 0.66, self.C["coral"]) + self._text(slide, self.MX, 7.6, 26.0, 4.5, + pick(d, "titre", "title"), + font=self.F_DISPLAY, size=44, bold=True, + color=self.C["white"], spacing=48) + st = pick(d, "sous_titre", "subtitle") + if st: + self._text(slide, self.MX, 12.3, 22.0, 1.5, st, + size=16, italic=True, color=self.C["glacier"]) + +''' + +PATCHES = [ + # P1 — _image_size après hex_to_rgb + ( + "def hex_to_rgb(h: str) -> RGBColor:\n" + " h = (h or \"#000000\").lstrip(\"#\")\n" + " return RGBColor(int(h[0:2], 16), int(h[2:4], 16), " + "int(h[4:6], 16))\n", + "def hex_to_rgb(h: str) -> RGBColor:\n" + " h = (h or \"#000000\").lstrip(\"#\")\n" + " return RGBColor(int(h[0:2], 16), int(h[2:4], 16), " + "int(h[4:6], 16))\n" + + IMAGE_SIZE_FUNC, + ), + # P3 — bloc freeform image (après le bloc line) + ( + " elif btype == \"line\":\n" + " ln = slide.shapes.add_connector(\n" + " 1, Cm(x), Cm(y), Cm(x + w), Cm(y + h))\n" + " ln.line.color.rgb = hex_to_rgb(\n" + " self._token_color(blk.get(\"color\"), " + "self.C[\"muted\"]))\n" + " ln.line.width = Pt(float(blk.get(\"weight\", " + "1.25)))\n", + " elif btype == \"line\":\n" + " ln = slide.shapes.add_connector(\n" + " 1, Cm(x), Cm(y), Cm(x + w), Cm(y + h))\n" + " ln.line.color.rgb = hex_to_rgb(\n" + " self._token_color(blk.get(\"color\"), " + "self.C[\"muted\"]))\n" + " ln.line.width = Pt(float(blk.get(\"weight\", " + "1.25)))\n" + "\n" + " elif btype == \"image\":\n" + " self._image(slide, x, y, w, h,\n" + " blk.get(\"image\") or " + "blk.get(\"src\", \"\"),\n" + " fit=str(blk.get(\"fit\") or " + "\"cover\"))\n", + ), + # P4 — REGISTRY + ( + " \"matrix_2x2\": \"_render_matrix_2x2\",\n", + " \"matrix_2x2\": \"_render_matrix_2x2\",\n" + " \"image_split\": \"_render_image_split\",\n" + " \"image_full\": \"_render_image_full\",\n", + ), + # P5a — CLI --assets + ( + " parser.add_argument(\"--layouts\", default=\"layouts_v2.yaml" + "\")\n" + " args = parser.parse_args()\n", + " parser.add_argument(\"--layouts\", default=\"layouts_v2.yaml" + "\")\n" + " parser.add_argument(\"--assets\", default=None,\n" + " help=\"Dossier des images (défaut : " + "/../assets)\")\n" + " args = parser.parse_args()\n", + ), + # P5b — résolution du dossier assets + ( + " engine = RenderEngineV2(args.theme, args.layouts, " + "args.components)\n" + " engine.render(data, args.output)\n", + " engine = RenderEngineV2(args.theme, args.layouts, " + "args.components)\n" + " engine.assets_dir = args.assets or os.path.join(\n" + " os.path.dirname(os.path.abspath(args.input_file)),\n" + " \"..\", \"assets\")\n" + " engine.render(data, args.output)\n", + ), +] + +MARKER = "_render_image_split" +ANCHOR_ORCH = " # ---------------- orchestration ----------------" + + +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é (_render_image_split 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)) + if content.count(ANCHOR_ORCH) != 1: + fail("Ancre de la section orchestration introuvable ou non " + "unique.") + + shutil.copy2(TARGET, str(TARGET) + ".bak-c4") + print(" + Sauvegarde : %s.bak-c4" % TARGET) + + for old, new in PATCHES: + content = content.replace(old, new) + + # P2 — insertion des composants/renderers avant l'orchestration + lines = content.split("\n") + idx = next(i for i, l in enumerate(lines) if ANCHOR_ORCH in l) + lines[idx:idx] = COMPONENTS.split("\n") + content = "\n".join(lines) + + TARGET.write_text(content, encoding="utf-8") + print(" + 5 patchs + composants images appliqués.") + try: + py_compile.compile(str(TARGET), doraise=True) + print(" + Compilation OK.") + except py_compile.PyCompileError as e: + shutil.copy2(str(TARGET) + ".bak-c4", TARGET) + fail("Erreur de compilation — fichier restauré :\n%s" % e) + print("\n Suite C4 : python3 patch_layouts_c4.py (catalogue), " + "python3 patch_facilitator_c4.py (/lire + freeform),") + print(" puis python3 prompt_injection_v2.py && python3 " + "build_gallery.py (propagation).") + + +if __name__ == "__main__": + main()