#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ patch_render_engine_c5b.py — Chantier C5 · lot 2 (waterfall, heatmap, funnel) ===================================================================== Patch strict de render_engine_v2.py. PRÉREQUIS : lot 1 appliqué (patch_render_engine_c5a.py) — les renderers du lot 2 réutilisent _chart_header/_chart_source ; le patch refuse de s'appliquer sinon. P1. Import MSO_LINE (connecteurs pointillés) + _blend() module-level (mélange déterministe de deux couleurs hex). P2. Trois renderers : waterfall (L45) — pont de valeur en rectangles : cumuls CALCULÉS par le renderer (l'agent ne positionne rien), delta+ corail / delta− slate, départ et arrivée navy pleins, connecteurs pointillés, étiquettes signées. Si la valeur d'arrivée déclarée diverge du cumul calculé : warning et le cumul fait foi (cohérence visuelle). heatmap_table (L46) — grille d'intensité : score entier 0-4 → 5 teintes précalculées du navy vers blanc, texte du score en contraste automatique, aucune couleur libre. Scores hors bornes : clamp + warning. funnel (L47) — entonnoir en vrais trapèzes (freeform builder) : largeurs proportionnelles aux valeurs, plancher 30 %, raccord exact entre étages, dernier étage corail (règle fixe), descriptions alignées à droite. P3. REGISTRY : waterfall, heatmap_table, funnel. Usage (dossier du pipeline, single-line) : python3 patch_render_engine_c5b.py Vérifie chaque ancre, écrit .bak-c5b, compile, idempotent. """ import py_compile import shutil import sys from pathlib import Path TARGET = Path("render_engine_v2.py") BLEND_FUNC = ''' def _blend(hex_a: str, hex_b: str, t: float) -> str: """Mélange linéaire déterministe de deux couleurs hex (t: 0→a, 1→b).""" a = (hex_a or "#000000").lstrip("#") b = (hex_b or "#FFFFFF").lstrip("#") t = max(0.0, min(1.0, float(t))) out = "".join( "%02X" % round(int(a[i:i + 2], 16) + (int(b[i:i + 2], 16) - int(a[i:i + 2], 16)) * t) for i in (0, 2, 4)) return "#" + out ''' COMPONENTS = ''' # ---------------- data & structure (C5 lot 2) ---------------- WF_MAX_STEPS = 8 HM_MAX_COLS = 6 HM_MAX_ROWS = 8 FUNNEL_MAX = 5 def _render_waterfall(self, slide, d): """Pont de valeur : cumuls calculés, jamais fournis.""" self._title(slide, pick(d, "titre", "title")) self._chart_caption(slide, d) zx, zy, zw, zh = self._chart_zone(d) dep = d.get("depart") or {} arr = d.get("arrivee") or {} marches = (d.get("marches") or [])[:self.WF_MAX_STEPS] if len(d.get("marches") or []) > self.WF_MAX_STEPS: print(f" ~ waterfall : marches tronquées à " f"{self.WF_MAX_STEPS}") v0 = self._num(pick(dep, "valeur", "value", default=0)) cums = [v0] for m in marches: cums.append(cums[-1] + self._num(pick(m, "delta", default=0))) v_end = cums[-1] declared = self._num(pick(arr, "valeur", "value", default=v_end), default=v_end) if abs(declared - v_end) > 1e-9: print(f" ~ waterfall : arrivée déclarée {declared:g} ≠ " f"cumul {v_end:g} — le cumul fait foi") chart_top = zy + 0.7 # réserve étiquettes hautes chart_b = zy + zh - 1.3 # réserve labels bas chart_h = chart_b - chart_top vmin = min(0.0, min(cums)) vmax = max(max(cums), v0, 0.0) span = (vmax - vmin) or 1.0 hpu = chart_h / span base_y = chart_top + vmax * hpu # y de la valeur 0 n = len(marches) + 2 slot = zw / n bar_w = slot * 0.62 def bar(i, v_from, v_to, color): x = zx + i * slot + (slot - bar_w) / 2 y1 = base_y - max(v_from, v_to) * hpu h = max(0.06, abs(v_to - v_from) * hpu) self._rect(slide, x, y1, bar_w, h, color) return x, y1 def val_label(i, y_ref, text, color): self._text(slide, zx + i * slot, y_ref - 0.62, slot, 0.55, text, size=11, bold=True, color=color, align=PP_ALIGN.CENTER, fit=False) def cat_label(i, label): self._text(slide, zx + i * slot + 0.05, chart_b + 0.15, slot - 0.1, 1.05, label, size=10, color=self.C["slate"], align=PP_ALIGN.CENTER) def connector(x1, x2, level): conn = slide.shapes.add_connector( 1, Cm(x1), Cm(base_y - level * hpu), Cm(x2), Cm(base_y - level * hpu)) conn.line.color.rgb = hex_to_rgb(self.C["muted"]) conn.line.width = Pt(1.0) conn.line.dash_style = MSO_LINE.DASH conn.shadow.inherit = False ln = slide.shapes.add_connector( 1, Cm(zx), Cm(base_y), Cm(zx + zw), Cm(base_y)) ln.line.color.rgb = hex_to_rgb(self.C["muted"]) ln.line.width = Pt(0.75) ln.shadow.inherit = False x, y1 = bar(0, 0.0, v0, self.C["navy"]) val_label(0, y1, f"{v0:g}", self.C["navy"]) cat_label(0, as_label(dep, "label", default="Départ")) prev_x_end = x + bar_w for i, m in enumerate(marches, start=1): c_prev, c_cur = cums[i - 1], cums[i] delta = c_cur - c_prev color = self.C["coral"] if delta >= 0 else self.C["slate"] x, y1 = bar(i, c_prev, c_cur, color) sign = "+" if delta >= 0 else "\\u2212" val_label(i, y1, f"{sign}{abs(delta):g}", color) cat_label(i, as_label(m, "label")) connector(prev_x_end, x, c_prev) prev_x_end = x + bar_w i = n - 1 x, y1 = bar(i, 0.0, v_end, self.C["navy"]) val_label(i, y1 if v_end >= 0 else base_y, f"{v_end:g}", self.C["navy"]) cat_label(i, as_label(arr, "label", default="Arrivée")) connector(prev_x_end, x, v_end) def _render_heatmap_table(self, slide, d): """Grille d'intensité : score 0-4 → 5 teintes du navy.""" self._title(slide, pick(d, "titre", "title")) headers = [as_label(h) for h in (d.get("headers") or [])][ :self.HM_MAX_COLS] rows = (d.get("rows") or [])[:self.HM_MAX_ROWS] if not headers or not rows: self._text(slide, self.MX, self.CONT_Y, 10, 1.0, "[données manquantes]", size=14, color=self.C["muted"]) return tints = [_blend("#FFFFFF", self.C["navy"], t) for t in (0.10, 0.28, 0.50, 0.74, 1.0)] legende = pick(d, "legende", "legend") label_w = 6.5 gap = 0.12 w_total = self.SLIDE_W - 2 * self.MX col_w = (w_total - label_w - gap * len(headers)) / len(headers) head_h = 0.9 leg_h = 0.8 if legende else 0.0 row_h = min(1.55, (self.CONT_H - head_h - leg_h - gap * (len(rows) + 1)) / len(rows)) total_h = head_h + gap + len(rows) * (row_h + gap) + leg_h y = self._cy(total_h) for j, h in enumerate(headers): x = self.MX + label_w + gap + j * (col_w + gap) self._text(slide, x, y, col_w, head_h, h, size=12, bold=True, color=self.C["slate"], align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE) cy = y + head_h + gap for r in rows: self._text(slide, self.MX, cy, label_w, row_h, as_label(r, "label"), size=13, bold=True, color=self.C["body"], anchor=MSO_ANCHOR.MIDDLE) scores = (r.get("scores") if isinstance(r, dict) else []) \ or [] for j in range(len(headers)): s = int(self._num(scores[j] if j < len(scores) else 0)) if not 0 <= s <= 4: print(f" ~ heatmap : score {s} hors 0-4 — clampé") s = max(0, min(4, s)) x = self.MX + label_w + gap + j * (col_w + gap) self._rect(slide, x, cy, col_w, row_h, tints[s]) self._text(slide, x, cy, col_w, row_h, str(s), size=12, bold=True, color=self.C["white"] if s >= 2 else self.C["navy"], align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE, fit=False) cy += row_h + gap if legende: self._text(slide, self.MX, cy + 0.05, w_total, 0.6, legende, size=10, italic=True, color=self.C["muted"]) def _render_funnel(self, slide, d): """Entonnoir : trapèzes proportionnels, plancher 30 %, dernier étage corail.""" self._title(slide, pick(d, "titre", "title")) self._chart_caption(slide, d) zx, zy, zw, zh = self._chart_zone(d) etapes = (d.get("etapes") or [])[:self.FUNNEL_MAX] if len(etapes) < 2: self._text(slide, zx, zy, 12, 1.0, "[funnel : 2 étapes minimum]", size=14, color=self.C["muted"]) return vals = [max(0.0, self._num(pick(e, "valeur", "value", default=0))) for e in etapes] vmax = max(vals) or 1.0 has_desc = any(pick(e, "description") for e in etapes) fun_w = zw * (0.58 if has_desc else 0.80) cx = zx + fun_w / 2 gap = 0.18 n = len(etapes) stage_h = (zh - 0.4 - gap * (n - 1)) / n widths = [max(0.30, v / vmax) * fun_w for v in vals] y = zy + 0.2 for i, e in enumerate(etapes): wt = widths[i] wb = widths[i + 1] if i + 1 < n else widths[i] * 0.72 color = self.C["coral"] if i == n - 1 else \ _blend(self.C["navy"], self.C["glacier"], 0.5 * i / max(1, n - 1)) fb = slide.shapes.build_freeform( cx - wt / 2, y, scale=360000) fb.add_line_segments( [(cx + wt / 2, y), (cx + wb / 2, y + stage_h), (cx - wb / 2, y + stage_h)], close=True) sh = fb.convert_to_shape() sh.fill.solid() sh.fill.fore_color.rgb = hex_to_rgb(color) sh.line.fill.background() sh.shadow.inherit = False label = as_label(e, "label") val = self._num(pick(e, "valeur", "value", default=0)) self._text(slide, cx - wt / 2, y + stage_h / 2 - 0.78, wt, 0.8, label, size=14, bold=True, color=self.C["white"], align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.BOTTOM) self._text(slide, cx - wt / 2, y + stage_h / 2 + 0.06, wt, 0.6, f"{val:g}", size=12, color=self.C["white"], align=PP_ALIGN.CENTER, fit=False) desc = pick(e, "description") if desc: dx = zx + fun_w + 1.2 self._text(slide, dx, y, zx + zw - dx, stage_h, desc, size=13, color=self.C["body"], anchor=MSO_ANCHOR.MIDDLE) y += stage_h + gap ''' PATCHES = [ # P1 — import MSO_LINE + _blend ( "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" + BLEND_FUNC, ), # P1b — import MSO_LINE ( "from pptx.enum.shapes import MSO_SHAPE\n", "from pptx.enum.shapes import MSO_SHAPE\n" "from pptx.enum.dml import MSO_LINE_DASH_STYLE as MSO_LINE\n", ), # P3 — REGISTRY ( " \"matrix_2x2\": \"_render_matrix_2x2\",\n", " \"matrix_2x2\": \"_render_matrix_2x2\",\n" " \"waterfall\": \"_render_waterfall\",\n" " \"heatmap_table\": \"_render_heatmap_table\",\n" " \"funnel\": \"_render_funnel\",\n", ), ] MARKER = "_render_waterfall" PREREQ = "_render_bar_chart" 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_waterfall présent) — rien à faire.") if PREREQ not in content: fail("Lot 1 non appliqué (_render_bar_chart absent) — lancer " "d'abord patch_render_engine_c5a.py.") 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-c5b") print(" + Sauvegarde : %s.bak-c5b" % 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_ORCH in l) lines[idx:idx] = COMPONENTS.split("\n") content = "\n".join(lines) TARGET.write_text(content, encoding="utf-8") print(" + 3 patchs + renderers lot 2 appliqués.") try: py_compile.compile(str(TARGET), doraise=True) print(" + Compilation OK.") except py_compile.PyCompileError as e: shutil.copy2(str(TARGET) + ".bak-c5b", TARGET) fail("Erreur de compilation — fichier restauré :\n%s" % e) print("\n Suite : python3 patch_layouts_c5b.py puis propagation " "(prompt_injection_v2 + build_gallery).") if __name__ == "__main__": main()