350 lines
14 KiB
Python
350 lines
14 KiB
Python
#!/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()
|