Files
sliding-automation/patches/patch_render_engine_c5a.py

328 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 # <c:doughnutChart>
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()