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