feat: catalogue data vague 1 - charts, waterfall, heatmap, funnel, agenda, pyramid (C5)
This commit is contained in:
@@ -0,0 +1,120 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
patch_layouts_c5a.py — Chantier C5 · lot 1 (catalogue : charts natifs)
|
||||||
|
======================================================================
|
||||||
|
Ajoute bar_chart (L42), line_chart (L43) et donut_split (L44) à
|
||||||
|
layouts_v2.yaml par APPEND sécurisé (même mécanique que le patch C4 :
|
||||||
|
parse avant, contrôle de fin de bloc, backup, re-parse, restauration
|
||||||
|
automatique en cas d'échec — le fichier n'est jamais reformaté).
|
||||||
|
|
||||||
|
Après ce patch, propager : python3 prompt_injection_v2.py && python3
|
||||||
|
build_gallery.py
|
||||||
|
|
||||||
|
Usage (dossier du pipeline, single-line) :
|
||||||
|
python3 patch_layouts_c5a.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
TARGET = Path("layouts_v2.yaml")
|
||||||
|
|
||||||
|
FRAGMENT = """
|
||||||
|
# ── Chantier C5 lot 1 — charts natifs (éditables dans PowerPoint) ──
|
||||||
|
bar_chart:
|
||||||
|
id: L42
|
||||||
|
famille: Données
|
||||||
|
mode: light
|
||||||
|
champs: [titre, categories, series, unite, source, horizontal]
|
||||||
|
champs_requis: [titre, categories, series]
|
||||||
|
agent_hint: >-
|
||||||
|
Comparaison de valeurs par catégories — graphique NATIF éditable.
|
||||||
|
Max 8 catégories × 3 séries. series = liste de {label, values} ;
|
||||||
|
values = nombres SANS guillemets, alignés sur categories.
|
||||||
|
horizontal: true pour des barres (libellés longs). unite (ex M€)
|
||||||
|
et source optionnels. Couleurs imposées : navy, coral, glacier.
|
||||||
|
|
||||||
|
line_chart:
|
||||||
|
id: L43
|
||||||
|
famille: Données
|
||||||
|
mode: light
|
||||||
|
champs: [titre, points_x, series, unite, source]
|
||||||
|
champs_requis: [titre, points_x, series]
|
||||||
|
agent_hint: >-
|
||||||
|
Évolution temporelle — graphique NATIF éditable. Max 12 points ×
|
||||||
|
3 séries. points_x = libellés d'axe (mois, années...) ; series =
|
||||||
|
{label, values}, nombres sans guillemets. Le dernier point de la
|
||||||
|
première série est automatiquement mis en valeur (corail).
|
||||||
|
|
||||||
|
donut_split:
|
||||||
|
id: L44
|
||||||
|
famille: Données
|
||||||
|
mode: light
|
||||||
|
champs: [titre, segments, valeur_centrale, source]
|
||||||
|
champs_requis: [titre, segments]
|
||||||
|
agent_hint: >-
|
||||||
|
Répartition d'un tout : anneau à gauche + légende détaillée à
|
||||||
|
droite. 2 à 6 segments = {label, valeur} (nombres sans
|
||||||
|
guillemets). valeur_centrale (optionnelle) s'affiche au centre de
|
||||||
|
l'anneau (ex : total « 120 M€ »). Couleurs = cycle PR imposé.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
raw = TARGET.read_text(encoding="utf-8")
|
||||||
|
try:
|
||||||
|
data = yaml.safe_load(raw)
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
fail("layouts_v2.yaml actuel illisible : %s" % e)
|
||||||
|
layouts = (data or {}).get("layouts")
|
||||||
|
if not isinstance(layouts, dict):
|
||||||
|
fail("Bloc layouts: introuvable dans le fichier.")
|
||||||
|
for key in ("bar_chart", "line_chart", "donut_split"):
|
||||||
|
if key in layouts:
|
||||||
|
fail("'%s' déjà présent — rien à faire." % key)
|
||||||
|
|
||||||
|
last = next((l for l in reversed(raw.splitlines())
|
||||||
|
if l.strip() and not l.strip().startswith("#")), "")
|
||||||
|
if not last.startswith(" "):
|
||||||
|
fail("Le fichier ne se termine pas dans le bloc layouts: "
|
||||||
|
"(dernière ligne non indentée : %r) — fusion manuelle "
|
||||||
|
"requise." % last[:40])
|
||||||
|
|
||||||
|
shutil.copy2(TARGET, str(TARGET) + ".bak-c5a")
|
||||||
|
print(" + Sauvegarde : %s.bak-c5a" % TARGET)
|
||||||
|
|
||||||
|
with open(TARGET, "a", encoding="utf-8") as f:
|
||||||
|
if not raw.endswith("\n"):
|
||||||
|
f.write("\n")
|
||||||
|
f.write(FRAGMENT)
|
||||||
|
|
||||||
|
try:
|
||||||
|
check = yaml.safe_load(TARGET.read_text(encoding="utf-8"))
|
||||||
|
nl = check["layouts"]
|
||||||
|
assert all(k in nl for k in ("bar_chart", "line_chart",
|
||||||
|
"donut_split"))
|
||||||
|
assert len(nl) == len(layouts) + 3
|
||||||
|
except Exception as e:
|
||||||
|
shutil.copy2(str(TARGET) + ".bak-c5a", TARGET)
|
||||||
|
fail("Contrôle post-append échoué (%s) — fichier restauré." % e)
|
||||||
|
|
||||||
|
print(" + bar_chart (L42), line_chart (L43), donut_split (L44) "
|
||||||
|
"ajoutés (%d layouts au total)." % (len(layouts) + 3))
|
||||||
|
print("\n Propager : python3 prompt_injection_v2.py && python3 "
|
||||||
|
"build_gallery.py")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
patch_layouts_c5b.py — Chantier C5 lot 2 (catalogue : waterfall,
|
||||||
|
heatmap_table, funnel)
|
||||||
|
================================================================
|
||||||
|
Append sécurisé à layouts_v2.yaml (même mécanique verrouillée que C4 :
|
||||||
|
parse avant, contrôle du bloc layouts:, .bak, re-parse, restauration
|
||||||
|
auto). Après ce patch, propager : python3 prompt_injection_v2.py &&
|
||||||
|
python3 build_gallery.py.
|
||||||
|
|
||||||
|
Usage (dossier du pipeline, single-line) :
|
||||||
|
python3 patch_layouts_c5b.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
TARGET = Path("layouts_v2.yaml")
|
||||||
|
NEW_KEYS = ("waterfall", "heatmap_table", "funnel")
|
||||||
|
|
||||||
|
FRAGMENT = """
|
||||||
|
# ── Chantier C5 lot 2 — data & structure ──────────────────────────
|
||||||
|
waterfall:
|
||||||
|
id: L45
|
||||||
|
famille: Données
|
||||||
|
mode: light
|
||||||
|
champs: [titre, depart, marches, arrivee, unite, source]
|
||||||
|
champs_requis: [titre, depart, marches, arrivee]
|
||||||
|
agent_hint: >-
|
||||||
|
Pont de valeur (waterfall) : expliquer un écart entre deux
|
||||||
|
montants par des variations successives. depart et arrivee =
|
||||||
|
{label, valeur} ; marches = liste de {label, delta} SIGNÉ
|
||||||
|
(positif ou négatif, max 8). Le moteur calcule les cumuls —
|
||||||
|
ne jamais fournir de cumul. Idéal pour : évolution de budget,
|
||||||
|
pont d'effectifs, décomposition d'un résultat.
|
||||||
|
|
||||||
|
heatmap_table:
|
||||||
|
id: L46
|
||||||
|
famille: Comparaison
|
||||||
|
mode: light
|
||||||
|
champs: [titre, headers, rows, legende]
|
||||||
|
champs_requis: [titre, headers, rows]
|
||||||
|
agent_hint: >-
|
||||||
|
Tableau à intensité : évaluer plusieurs items sur plusieurs
|
||||||
|
critères. headers = colonnes (max 6) ; rows = {label,
|
||||||
|
scores} avec score ENTIER de 0 (faible) à 4 (fort), max
|
||||||
|
8 lignes. Le moteur traduit chaque score en teinte de navy —
|
||||||
|
aucune autre donnée. Idéal pour : maturité, couverture
|
||||||
|
fonctionnelle, cartographie de risques.
|
||||||
|
|
||||||
|
funnel:
|
||||||
|
id: L47
|
||||||
|
famille: Process
|
||||||
|
mode: light
|
||||||
|
champs: [titre, etapes, source]
|
||||||
|
champs_requis: [titre, etapes]
|
||||||
|
agent_hint: >-
|
||||||
|
Entonnoir de conversion : volumes décroissants d'étape en
|
||||||
|
étape. etapes = {label, valeur, description?} du haut vers le
|
||||||
|
bas, 3 à 5 étages. Largeurs proportionnelles aux valeurs
|
||||||
|
(plancher de lisibilité), dernier étage corail. Idéal pour :
|
||||||
|
pipeline commercial, adoption, qualification progressive.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
raw = TARGET.read_text(encoding="utf-8")
|
||||||
|
try:
|
||||||
|
data = yaml.safe_load(raw)
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
fail("layouts_v2.yaml actuel illisible : %s" % e)
|
||||||
|
layouts = (data or {}).get("layouts")
|
||||||
|
if not isinstance(layouts, dict):
|
||||||
|
fail("Bloc layouts: introuvable dans le fichier.")
|
||||||
|
for key in NEW_KEYS:
|
||||||
|
if key in layouts:
|
||||||
|
fail("'%s' déjà présent — rien à faire." % key)
|
||||||
|
|
||||||
|
last = next((l for l in reversed(raw.splitlines())
|
||||||
|
if l.strip() and not l.strip().startswith("#")), "")
|
||||||
|
if not last.startswith(" "):
|
||||||
|
fail("Le fichier ne se termine pas dans le bloc layouts: "
|
||||||
|
"(dernière ligne non indentée : %r) — fusion manuelle "
|
||||||
|
"requise." % last[:40])
|
||||||
|
|
||||||
|
shutil.copy2(TARGET, str(TARGET) + ".bak-c5b")
|
||||||
|
print(" + Sauvegarde : %s.bak-c5b" % TARGET)
|
||||||
|
|
||||||
|
with open(TARGET, "a", encoding="utf-8") as f:
|
||||||
|
if not raw.endswith("\n"):
|
||||||
|
f.write("\n")
|
||||||
|
f.write(FRAGMENT)
|
||||||
|
|
||||||
|
try:
|
||||||
|
check = yaml.safe_load(TARGET.read_text(encoding="utf-8"))
|
||||||
|
nl = check["layouts"]
|
||||||
|
assert all(k in nl for k in NEW_KEYS)
|
||||||
|
assert len(nl) == len(layouts) + len(NEW_KEYS)
|
||||||
|
except Exception as e:
|
||||||
|
shutil.copy2(str(TARGET) + ".bak-c5b", TARGET)
|
||||||
|
fail("Contrôle post-append échoué (%s) — fichier restauré." % e)
|
||||||
|
|
||||||
|
print(" + waterfall (L45), heatmap_table (L46), funnel (L47) "
|
||||||
|
"ajoutés (%d layouts au total)." % (len(layouts)
|
||||||
|
+ len(NEW_KEYS)))
|
||||||
|
print("\n Propager : python3 prompt_injection_v2.py && python3 "
|
||||||
|
"build_gallery.py")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
patch_layouts_c5c.py — Chantier C5 lot 3 (catalogue : agenda, pyramid)
|
||||||
|
======================================================================
|
||||||
|
Append sécurisé à layouts_v2.yaml (mécanique verrouillée C4/C5b).
|
||||||
|
Après ce patch, propager : python3 prompt_injection_v2.py && python3
|
||||||
|
build_gallery.py.
|
||||||
|
|
||||||
|
Usage (dossier du pipeline, single-line) :
|
||||||
|
python3 patch_layouts_c5c.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
TARGET = Path("layouts_v2.yaml")
|
||||||
|
NEW_KEYS = ("agenda", "pyramid")
|
||||||
|
|
||||||
|
FRAGMENT = """
|
||||||
|
# ── Chantier C5 lot 3 — retours v1 restylés ───────────────────────
|
||||||
|
agenda:
|
||||||
|
id: L40
|
||||||
|
famille: Structure
|
||||||
|
mode: light
|
||||||
|
champs: [titre, sections]
|
||||||
|
champs_requis: [titre, sections]
|
||||||
|
agent_hint: >-
|
||||||
|
Sommaire du deck : à placer en slide 2 pour toute présentation
|
||||||
|
de plus de 15 minutes. sections = liste de {label, numero?,
|
||||||
|
duree?, actif?}, 2 à 8 entrées. actif: true met la section en
|
||||||
|
corail (utile pour les rappels d'agenda en cours de deck).
|
||||||
|
duree (ex "10 min") s'affiche à droite en discret.
|
||||||
|
|
||||||
|
pyramid:
|
||||||
|
id: L41
|
||||||
|
famille: Concept
|
||||||
|
mode: light
|
||||||
|
champs: [titre, niveaux]
|
||||||
|
champs_requis: [titre, niveaux]
|
||||||
|
agent_hint: >-
|
||||||
|
Argumentation pyramidale : la conclusion au sommet, les
|
||||||
|
fondations à la base. niveaux = liste de {label, description?},
|
||||||
|
EXACTEMENT 3 ou 4 niveaux, du sommet vers la base. Largeurs
|
||||||
|
d'étages fixes gérées par le moteur. Idéal pour : message clé
|
||||||
|
et ses appuis, hiérarchie stratégie/tactiques/moyens.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
raw = TARGET.read_text(encoding="utf-8")
|
||||||
|
try:
|
||||||
|
data = yaml.safe_load(raw)
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
fail("layouts_v2.yaml actuel illisible : %s" % e)
|
||||||
|
layouts = (data or {}).get("layouts")
|
||||||
|
if not isinstance(layouts, dict):
|
||||||
|
fail("Bloc layouts: introuvable dans le fichier.")
|
||||||
|
for key in NEW_KEYS:
|
||||||
|
if key in layouts:
|
||||||
|
fail("'%s' déjà présent — rien à faire." % key)
|
||||||
|
|
||||||
|
last = next((l for l in reversed(raw.splitlines())
|
||||||
|
if l.strip() and not l.strip().startswith("#")), "")
|
||||||
|
if not last.startswith(" "):
|
||||||
|
fail("Le fichier ne se termine pas dans le bloc layouts: — "
|
||||||
|
"fusion manuelle requise.")
|
||||||
|
|
||||||
|
shutil.copy2(TARGET, str(TARGET) + ".bak-c5c")
|
||||||
|
print(" + Sauvegarde : %s.bak-c5c" % TARGET)
|
||||||
|
|
||||||
|
with open(TARGET, "a", encoding="utf-8") as f:
|
||||||
|
if not raw.endswith("\n"):
|
||||||
|
f.write("\n")
|
||||||
|
f.write(FRAGMENT)
|
||||||
|
|
||||||
|
try:
|
||||||
|
check = yaml.safe_load(TARGET.read_text(encoding="utf-8"))
|
||||||
|
nl = check["layouts"]
|
||||||
|
assert all(k in nl for k in NEW_KEYS)
|
||||||
|
assert len(nl) == len(layouts) + len(NEW_KEYS)
|
||||||
|
except Exception as e:
|
||||||
|
shutil.copy2(str(TARGET) + ".bak-c5c", TARGET)
|
||||||
|
fail("Contrôle post-append échoué (%s) — fichier restauré." % e)
|
||||||
|
|
||||||
|
print(" + agenda (L40) et pyramid (L41) ajoutés (%d layouts au "
|
||||||
|
"total)." % (len(layouts) + len(NEW_KEYS)))
|
||||||
|
print("\n Propager : python3 prompt_injection_v2.py && python3 "
|
||||||
|
"build_gallery.py")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
#!/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()
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
#!/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()
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
patch_render_engine_c5c.py — Chantier C5 · lot 3 (agenda, pyramid)
|
||||||
|
==================================================================
|
||||||
|
Patch strict de render_engine_v2.py. Réintroduit les deux layouts v1
|
||||||
|
disparus à la refonte, restylés PR Editorial. Aucun prérequis sur les
|
||||||
|
lots 1/2 (aucun helper chart utilisé).
|
||||||
|
|
||||||
|
agenda (L40) — sommaire typographique aéré : badge numéroté (navy ;
|
||||||
|
corail si actif: true), label (bold navy si actif), durée
|
||||||
|
alignée à droite en muted, filet séparateur card_alt.
|
||||||
|
Bornes : 2-8 sections (troncature tracée au-delà).
|
||||||
|
pyramid (L41) — pyramide à degrés : rectangles empilés centrés,
|
||||||
|
largeurs FIXES par étage (jamais recalculées) :
|
||||||
|
3 niveaux : 44 / 72 / 100 %
|
||||||
|
4 niveaux : 40 / 60 / 80 / 100 %
|
||||||
|
Couleurs fixes du sommet à la base : navy, navy_light,
|
||||||
|
slate, glacier (texte blanc, navy sur glacier). Descriptions
|
||||||
|
optionnelles alignées à droite de chaque étage (la pyramide
|
||||||
|
passe alors à 58 % de largeur) ; sans description, pyramide
|
||||||
|
centrée sur 80 %.
|
||||||
|
|
||||||
|
Usage (dossier du pipeline, single-line) :
|
||||||
|
python3 patch_render_engine_c5c.py
|
||||||
|
Vérifie chaque ancre, écrit .bak-c5c, compile, idempotent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import py_compile
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
TARGET = Path("render_engine_v2.py")
|
||||||
|
|
||||||
|
COMPONENTS = ''' # ---------------- structure & concept (C5 lot 3) ----------------
|
||||||
|
AGENDA_MAX = 8
|
||||||
|
PYRAMID_WIDTHS = {3: (0.44, 0.72, 1.0),
|
||||||
|
4: (0.40, 0.60, 0.80, 1.0)}
|
||||||
|
|
||||||
|
def _render_agenda(self, slide, d):
|
||||||
|
"""Sommaire : badges numérotés, section active en corail."""
|
||||||
|
self._title(slide, pick(d, "titre", "title"))
|
||||||
|
sections = (d.get("sections") or [])[:self.AGENDA_MAX]
|
||||||
|
if len(d.get("sections") or []) > self.AGENDA_MAX:
|
||||||
|
print(f" ~ agenda : sections tronquées à {self.AGENDA_MAX}")
|
||||||
|
if len(sections) < 2:
|
||||||
|
self._text(slide, self.MX, self.CONT_Y, 12, 1.0,
|
||||||
|
"[agenda : 2 sections minimum]", size=14,
|
||||||
|
color=self.C["muted"])
|
||||||
|
return
|
||||||
|
n = len(sections)
|
||||||
|
gap = 0.5
|
||||||
|
row_h = min(1.9, (self.CONT_H - gap * (n - 1)) / n)
|
||||||
|
tot = n * row_h + (n - 1) * gap
|
||||||
|
y = self._cy(tot)
|
||||||
|
bd = self.components["badge"]["sizes"]["m"]
|
||||||
|
x0 = self.MX + 1.5
|
||||||
|
w = self.SLIDE_W - 2 * self.MX - 3.0
|
||||||
|
for i, s in enumerate(sections):
|
||||||
|
if not isinstance(s, dict):
|
||||||
|
s = {"label": str(s)}
|
||||||
|
actif = bool(s.get("actif"))
|
||||||
|
num = s.get("numero", i + 1)
|
||||||
|
fill = self.C["coral"] if actif else self.C["navy"]
|
||||||
|
self._badge(slide, x0 + bd / 2, y + row_h / 2, bd, num,
|
||||||
|
fill=fill, font_size=20)
|
||||||
|
self._text(slide, x0 + bd + 1.0, y, w - bd - 6.0, row_h,
|
||||||
|
as_label(s, "label", "titre"),
|
||||||
|
size=20 if actif else 19, bold=actif,
|
||||||
|
color=self.C["navy"] if actif
|
||||||
|
else self.C["body"],
|
||||||
|
anchor=MSO_ANCHOR.MIDDLE)
|
||||||
|
duree = pick(s, "duree", "duration")
|
||||||
|
if duree:
|
||||||
|
self._text(slide, x0 + w - 4.5, y, 4.5, row_h, duree,
|
||||||
|
size=13, italic=True, color=self.C["muted"],
|
||||||
|
align=PP_ALIGN.RIGHT,
|
||||||
|
anchor=MSO_ANCHOR.MIDDLE, fit=False)
|
||||||
|
if i < n - 1:
|
||||||
|
self._rect(slide, x0 + bd + 1.0,
|
||||||
|
y + row_h + gap / 2 - 0.015,
|
||||||
|
w - bd - 1.0, 0.03, self.C["card"])
|
||||||
|
y += row_h + gap
|
||||||
|
|
||||||
|
def _render_pyramid(self, slide, d):
|
||||||
|
"""Pyramide à degrés : largeurs fixes, couleurs fixes."""
|
||||||
|
self._title(slide, pick(d, "titre", "title"))
|
||||||
|
niveaux = (d.get("niveaux") or d.get("levels") or [])[:4]
|
||||||
|
if len(niveaux) < 3:
|
||||||
|
self._text(slide, self.MX, self.CONT_Y, 12, 1.0,
|
||||||
|
"[pyramid : 3 ou 4 niveaux]", size=14,
|
||||||
|
color=self.C["muted"])
|
||||||
|
return
|
||||||
|
n = len(niveaux)
|
||||||
|
widths = self.PYRAMID_WIDTHS[n]
|
||||||
|
colors = [self.C["navy"], self.C["navy2"],
|
||||||
|
self.C["slate"], self.C["glacier"]]
|
||||||
|
if n == 3:
|
||||||
|
colors = [self.C["navy"], self.C["navy2"],
|
||||||
|
self.C["glacier"]]
|
||||||
|
has_desc = any(isinstance(nv, dict) and pick(nv, "description")
|
||||||
|
for nv in niveaux)
|
||||||
|
zone_w = self.SLIDE_W - 2 * self.MX
|
||||||
|
pyr_w = zone_w * (0.58 if has_desc else 0.80)
|
||||||
|
pyr_x = self.MX if has_desc else \
|
||||||
|
self.MX + (zone_w - pyr_w) / 2
|
||||||
|
cx = pyr_x + pyr_w / 2
|
||||||
|
gap = 0.15
|
||||||
|
avail = self.CONT_H - 0.4
|
||||||
|
stage_h = (avail - gap * (n - 1)) / n
|
||||||
|
y = self._cy(avail) + 0.2
|
||||||
|
for i, nv in enumerate(niveaux):
|
||||||
|
if not isinstance(nv, dict):
|
||||||
|
nv = {"label": str(nv)}
|
||||||
|
wt = widths[i] * pyr_w
|
||||||
|
color = colors[i]
|
||||||
|
txt_color = self.C["navy"] if color == self.C["glacier"] \
|
||||||
|
else self.C["white"]
|
||||||
|
self._rect(slide, cx - wt / 2, y, wt, stage_h, color)
|
||||||
|
self._text(slide, cx - wt / 2 + 0.3, y, wt - 0.6, stage_h,
|
||||||
|
as_label(nv, "label", "titre"),
|
||||||
|
size=16, bold=True, color=txt_color,
|
||||||
|
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
|
||||||
|
desc = pick(nv, "description", "detail")
|
||||||
|
if desc:
|
||||||
|
dx = pyr_x + pyr_w + 1.2
|
||||||
|
self._text(slide, dx, y,
|
||||||
|
self.SLIDE_W - self.MX - dx, stage_h, desc,
|
||||||
|
size=13, color=self.C["body"],
|
||||||
|
anchor=MSO_ANCHOR.MIDDLE)
|
||||||
|
y += stage_h + gap
|
||||||
|
|
||||||
|
'''
|
||||||
|
|
||||||
|
PATCHES = [
|
||||||
|
# REGISTRY
|
||||||
|
(
|
||||||
|
" \"matrix_2x2\": \"_render_matrix_2x2\",\n",
|
||||||
|
" \"matrix_2x2\": \"_render_matrix_2x2\",\n"
|
||||||
|
" \"agenda\": \"_render_agenda\",\n"
|
||||||
|
" \"pyramid\": \"_render_pyramid\",\n",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
MARKER = "_render_agenda"
|
||||||
|
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_agenda 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-c5c")
|
||||||
|
print(" + Sauvegarde : %s.bak-c5c" % 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(" + Renderers agenda + pyramid + 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-c5c", TARGET)
|
||||||
|
fail("Erreur de compilation — fichier restauré :\n%s" % e)
|
||||||
|
print("\n Suite : python3 patch_layouts_c5c.py puis propagation "
|
||||||
|
"(prompt_injection_v2 + build_gallery).")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user