#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ patch_render_engine_c5a.py — Chantier C5 · lot 1 (charts natifs) ================================================================ Patch strict de render_engine_v2.py — trois layouts de données en graphiques NATIFS python-pptx (éditables dans PowerPoint, données modifiables par le lecteur — pas des images) : bar_chart (L42) — colonnes ou barres (horizontal: true), max 8 catégories × 3 séries, axe des valeurs masqué, étiquettes de valeurs affichées, style consulting. line_chart (L43) — courbes à marqueurs, max 12 points × 3 séries, gridlines très claires, dernier point de la série 1 étiqueté en corail gras (règle fixe). donut_split (L44) — anneau (60 % gauche, trou 65, segments au cycle PR) + valeur centrale + légende détaillée custom à droite (pastille, label, valeur). Style PR en dur : séries navy → coral → glacier, Calibri, aucune couleur libre. Bornes appliquées par troncature tracée (warning console) — les schémas C1 et les prompts bornent en amont. P1. Imports chart python-pptx. P2. Composants _num/_chart_base + 3 renderers (avant orchestration). P3. REGISTRY : les 3 entrées. Usage (dossier du pipeline, single-line) : python3 patch_render_engine_c5a.py Indépendant des patchs C3/C4 (tout ordre). Vérifie chaque ancre, écrit .bak-c5a, compile, idempotent. """ import py_compile import shutil import sys from pathlib import Path TARGET = Path("render_engine_v2.py") COMPONENTS = ''' # ---------------- charts natifs (C5 lot 1) ---------------- def _num(self, v, default=0.0): """Nombre robuste : int/float directs, chaînes '12,5' ou '12.5'.""" if isinstance(v, (int, float)): return float(v) try: return float(str(v).replace(",", ".").replace(" ", "")) except (TypeError, ValueError): return float(default) def _chart_series_colors(self): return [self.C["navy"], self.C["coral"], self.C["glacier"]] def _chart_base(self, slide, x, y, w, h, chart_type, chart_data): """Insertion + style de base commun (police, taille, couleur).""" gf = slide.shapes.add_chart(chart_type, Cm(x), Cm(y), Cm(w), Cm(h), chart_data) ch = gf.chart ch.font.name = self.F_BODY ch.font.size = Pt(11) ch.font.color.rgb = hex_to_rgb(self.C["body"]) return ch def _chart_caption(self, slide, d): """Unité (droite de la zone titre) et source (bas de page).""" unite = pick(d, "unite", "unit") if unite: self._text(slide, self.SLIDE_W - self.MX - 8.0, self.TITLE_Y + 0.35, 8.0, 0.8, f"en {unite}", size=12, italic=True, color=self.C["muted"], align=PP_ALIGN.RIGHT) source = pick(d, "source") if source: self._text(slide, self.MX, self.CONT_B - 0.55, self.SLIDE_W - 2 * self.MX, 0.55, f"Source : {source}", size=10, color=self.C["muted"]) def _chart_zone(self, d): """Zone du graphique (réserve la ligne source si présente).""" h = self.CONT_H - (0.7 if pick(d, "source") else 0.0) return self.MX, self.CONT_Y, self.SLIDE_W - 2 * self.MX, h def _render_bar_chart(self, slide, d): from pptx.chart.data import CategoryChartData from pptx.enum.chart import (XL_CHART_TYPE, XL_LEGEND_POSITION, XL_LABEL_POSITION) self._title(slide, pick(d, "titre", "title")) self._chart_caption(slide, d) cats = [as_label(c) for c in (d.get("categories") or [])] series = [s for s in (d.get("series") or []) if isinstance(s, dict)] if len(cats) > 8: print(f" ~ bar_chart : {len(cats)} catégories → 8 (borne)") cats = cats[:8] if len(series) > 3: print(f" ~ bar_chart : {len(series)} séries → 3 (borne)") series = series[:3] cd = CategoryChartData() cd.categories = cats for s in series: vals = [self._num(v) for v in (s.get("values") or [])][:len(cats)] vals += [0.0] * (len(cats) - len(vals)) cd.add_series(pick(s, "label", default="Série"), tuple(vals)) x, y, w, h = self._chart_zone(d) horiz = bool(d.get("horizontal")) ctype = (XL_CHART_TYPE.BAR_CLUSTERED if horiz else XL_CHART_TYPE.COLUMN_CLUSTERED) ch = self._chart_base(slide, x, y, w, h, ctype, cd) plot = ch.plots[0] plot.gap_width = 60 if len(series) > 1: plot.overlap = -10 for i, ser in enumerate(ch.series): ser.format.fill.solid() ser.format.fill.fore_color.rgb = hex_to_rgb( self._chart_series_colors()[i % 3]) ch.value_axis.visible = False ch.value_axis.has_major_gridlines = False cat_ax = ch.category_axis cat_ax.has_major_gridlines = False cat_ax.format.line.color.rgb = hex_to_rgb(self.C["slate"]) cat_ax.tick_labels.font.size = Pt(11) plot.has_data_labels = True dls = plot.data_labels dls.font.size = Pt(10) dls.font.bold = True dls.font.color.rgb = hex_to_rgb(self.C["body"]) dls.position = (XL_LABEL_POSITION.OUTSIDE_END) ch.has_legend = len(series) > 1 if ch.has_legend: ch.legend.position = XL_LEGEND_POSITION.BOTTOM ch.legend.include_in_layout = False ch.legend.font.size = Pt(11) def _render_line_chart(self, slide, d): from pptx.chart.data import CategoryChartData from pptx.enum.chart import (XL_CHART_TYPE, XL_LEGEND_POSITION, XL_LABEL_POSITION, XL_MARKER_STYLE) self._title(slide, pick(d, "titre", "title")) self._chart_caption(slide, d) pts = [as_label(p) for p in (d.get("points_x") or [])] series = [s for s in (d.get("series") or []) if isinstance(s, dict)] if len(pts) > 12: print(f" ~ line_chart : {len(pts)} points → 12 (borne)") pts = pts[:12] if len(series) > 3: print(f" ~ line_chart : {len(series)} séries → 3 (borne)") series = series[:3] cd = CategoryChartData() cd.categories = pts for s in series: vals = [self._num(v) for v in (s.get("values") or [])][:len(pts)] vals += [0.0] * (len(pts) - len(vals)) cd.add_series(pick(s, "label", default="Série"), tuple(vals)) x, y, w, h = self._chart_zone(d) ch = self._chart_base(slide, x, y, w, h, XL_CHART_TYPE.LINE_MARKERS, cd) for i, ser in enumerate(ch.series): col = hex_to_rgb(self._chart_series_colors()[i % 3]) ser.format.line.color.rgb = col ser.format.line.width = Pt(2.25) ser.smooth = False ser.marker.style = XL_MARKER_STYLE.CIRCLE ser.marker.size = 6 ser.marker.format.fill.solid() ser.marker.format.fill.fore_color.rgb = col ser.marker.format.line.fill.background() va = ch.value_axis va.has_major_gridlines = True va.major_gridlines.format.line.color.rgb = hex_to_rgb( self.C["card_alt"]) va.tick_labels.font.size = Pt(10) va.tick_labels.font.color.rgb = hex_to_rgb(self.C["muted"]) va.format.line.fill.background() cat_ax = ch.category_axis cat_ax.has_major_gridlines = False cat_ax.format.line.color.rgb = hex_to_rgb(self.C["slate"]) cat_ax.tick_labels.font.size = Pt(11) ch.has_legend = len(series) > 1 if ch.has_legend: ch.legend.position = XL_LEGEND_POSITION.BOTTOM ch.legend.include_in_layout = False ch.legend.font.size = Pt(11) # Règle fixe : dernier point de la série 1 étiqueté corail gras if series and pts: try: last = ch.series[0].points[len(pts) - 1] dl = last.data_label dl.position = XL_LABEL_POSITION.ABOVE v = self._num((series[0].get("values") or [0])[ min(len(pts), len(series[0].get("values") or [])) - 1]) txt = ("%g" % v) dl.text_frame.text = txt run = dl.text_frame.paragraphs[0].runs[0] run.font.size = Pt(12) run.font.bold = True run.font.color.rgb = hex_to_rgb(self.C["coral"]) run.font.name = self.F_BODY except Exception as e: print(f" ~ line_chart : étiquette dernier point sautée " f"({e})") def _render_donut_split(self, slide, d): from pptx.chart.data import CategoryChartData from pptx.enum.chart import XL_CHART_TYPE self._title(slide, pick(d, "titre", "title")) self._chart_caption(slide, d) segs = [s for s in (d.get("segments") or []) if isinstance(s, dict)] if len(segs) > 6: print(f" ~ donut_split : {len(segs)} segments → 6 (borne)") segs = segs[:6] labels = [pick(s, "label", default="—") for s in segs] vals = [self._num(pick(s, "valeur", "value")) for s in segs] cd = CategoryChartData() cd.categories = labels cd.add_series("Répartition", tuple(vals)) x, y, w, h = self._chart_zone(d) cw = w * 0.55 side = min(cw, h) cx0 = x + (cw - side) / 2 cy0 = y + (h - side) / 2 ch = self._chart_base(slide, cx0, cy0, side, side, XL_CHART_TYPE.DOUGHNUT, cd) ch.has_legend = False for i, pt in enumerate(ch.series[0].points): pt.format.fill.solid() pt.format.fill.fore_color.rgb = hex_to_rgb( self.cycle[i % len(self.cycle)]) pt.format.line.color.rgb = hex_to_rgb(self.C["white"]) pt.format.line.width = Pt(1.5) plot_el = ch.plots[0]._element # hs = plot_el.find(qn("c:holeSize")) if hs is None: hs = plot_el.makeelement(qn("c:holeSize"), {}) plot_el.append(hs) hs.set("val", "65") centre = pick(d, "valeur_centrale", "centre") if centre: self._text(slide, cx0, cy0 + side / 2 - 1.1, side, 2.2, centre, font=self.F_DISPLAY, size=24, bold=True, color=self.C["navy"], align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE, fit=False) # Légende détaillée custom à droite lx = x + cw + 1.2 lw = w - cw - 1.2 ih, gap = 1.35, 0.45 tot = len(segs) * (ih + gap) - gap if segs else 0 ly = self._cy(tot) for i, s in enumerate(segs): self._oval(slide, lx, ly + (ih - 0.5) / 2, 0.5, self.cycle[i % len(self.cycle)]) self._text(slide, lx + 1.0, ly, lw - 4.2, ih, pick(s, "label", default="—"), size=14, bold=True, color=self.C["body"], anchor=MSO_ANCHOR.MIDDLE) self._text(slide, lx + lw - 3.2, ly, 3.2, ih, "%g" % self._num(pick(s, "valeur", "value")), size=14, color=self.C["muted"], align=PP_ALIGN.RIGHT, anchor=MSO_ANCHOR.MIDDLE) ly += ih + gap ''' PATCHES = [ # P3 — REGISTRY ( " \"matrix_2x2\": \"_render_matrix_2x2\",\n", " \"matrix_2x2\": \"_render_matrix_2x2\",\n" " \"bar_chart\": \"_render_bar_chart\",\n" " \"line_chart\": \"_render_line_chart\",\n" " \"donut_split\": \"_render_donut_split\",\n", ), ] MARKER = "_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_bar_chart 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-c5a") print(" + Sauvegarde : %s.bak-c5a" % 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(" + Composants charts + 3 renderers + REGISTRY appliqués.") try: py_compile.compile(str(TARGET), doraise=True) print(" + Compilation OK.") except py_compile.PyCompileError as e: shutil.copy2(str(TARGET) + ".bak-c5a", TARGET) fail("Erreur de compilation — fichier restauré :\n%s" % e) print("\n Suite lot 1 : python3 patch_layouts_c5a.py puis " "propagation (prompt_injection_v2 + build_gallery).") if __name__ == "__main__": main()