fix: org_chart et renderers tolerent noeuds string ou dict (anti-crash)

This commit is contained in:
2026-06-20 07:53:10 +02:00
parent cc8e0be6ef
commit 821a4bcc3d
+46 -6
View File
@@ -38,8 +38,14 @@ def hex_to_rgb(h: str) -> RGBColor:
return RGBColor(int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
def pick(d: dict, *keys, default=""):
"""Accès tolérant : retourne la première clé non vide trouvée."""
def pick(d, *keys, default=""):
"""Accès tolérant : première clé non vide trouvée.
Si d est une chaîne (l'agent a produit 'Texte' au lieu de {label:'Texte'}),
on la retourne directement — évite les crashs 'str has no attribute get'."""
if isinstance(d, str):
return d
if not isinstance(d, dict):
return default
for k in keys:
v = d.get(k)
if v not in (None, ""):
@@ -47,6 +53,16 @@ def pick(d: dict, *keys, default=""):
return default
def as_label(item, *keys, default=""):
"""Normalise un élément de liste en texte : 'Texte' ou {label:'Texte'}."""
if isinstance(item, str):
return item
if isinstance(item, dict):
return pick(item, *(keys or ("label", "texte", "titre", "title")),
default=default)
return default
def estimate_text_height(text: str, size_pt: int, width_cm: float) -> float:
"""Hauteur estimée d'un texte (cm) pour une largeur donnée."""
if not text:
@@ -475,6 +491,8 @@ class RenderEngineV2:
y = self._cy(tot)
bd = self.components["badge"]["sizes"]["l"]
for i, st in enumerate(steps):
if isinstance(st, str):
st = {"titre": st}
self._card(slide, self.MX, y, self.SLIDE_W - 2 * self.MX, rh)
self._badge(slide, self.MX + 1.02 + bd / 2, y + rh / 2, bd,
st.get("numero", i + 1), font_size=28)
@@ -665,7 +683,8 @@ class RenderEngineV2:
HDR_H = 0.80
WS_H = 0.68
TASK_H = 0.78
n_tasks = sum(len(ws.get("tasks", [])) for ws in workstreams)
n_tasks = sum(len(ws.get("tasks", [])) for ws in workstreams
if isinstance(ws, dict))
total_h = HDR_H + len(workstreams)*WS_H + n_tasks*TASK_H
y0 = self._cy(total_h)
gx = self.MX + LBL_W + GAP_COL # grid x origin
@@ -687,6 +706,8 @@ class RenderEngineV2:
cur_y = y0 + HDR_H
for ws_idx, ws in enumerate(workstreams):
if not isinstance(ws, dict):
continue
ws_col = self.cycle[ws_idx % len(self.cycle)]
# Workstream row
self._rect(slide, self.MX, cur_y, self.SLIDE_W - 2*self.MX, WS_H, "#EEECEA")
@@ -696,6 +717,8 @@ class RenderEngineV2:
color=self.C["navy"], anchor=MSO_ANCHOR.MIDDLE)
cur_y += WS_H
for task in ws.get("tasks", []):
if not isinstance(task, dict):
continue
start = task.get("start", 0)
end = task.get("end", start + 1)
bg = self.C["white"]
@@ -837,6 +860,8 @@ class RenderEngineV2:
# Rows
for i, task in enumerate(tasks):
if not isinstance(task, dict):
continue
ry = y + HDR_H + i * ROW_H
bg = self.C["card"] if i % 2 == 0 else self.C["white"]
self._rect(slide, self.MX, ry, self.SLIDE_W - 2*self.MX, ROW_H, bg)
@@ -907,12 +932,25 @@ class RenderEngineV2:
# ── org_chart ─────────────────────────────────────────────────────────────
def _render_org_chart(self, slide, d):
self._title(slide, pick(d, "titre", "title"))
def _node(n):
"""Normalise un nœud : 'Texte'{label:'Texte'}, dict inchangé."""
if isinstance(n, str):
return {"label": n, "children": []}
if isinstance(n, dict):
return {"label": pick(n, "label", "titre", "title", "nom"),
"children": n.get("children", []) or []}
return {"label": str(n), "children": []}
root = d.get("root", {})
if isinstance(root, str):
root = {"label": root, "children": []}
if not root:
return
root = _node(root)
BOX_W, BOX_H = 3.81, 1.0
GAP_V, GAP_H = 1.27, 0.64
children = root.get("children", [])
children = [_node(c) for c in root.get("children", [])]
n_children = len(children)
# grandchildren count per child (max)
max_gd = max((len(c.get("children", [])) for c in children), default=0)
@@ -971,7 +1009,7 @@ class RenderEngineV2:
ln3.line.width = Pt(1.0)
# Grandchildren — sizing dynamique pour tenir dans la colonne
grandchildren = child.get("children", [])
grandchildren = [_node(g) for g in child.get("children", [])]
if grandchildren:
gd_y = child_y + BOX_H + GAP_V
n_gd = len(grandchildren)
@@ -1077,6 +1115,8 @@ class RenderEngineV2:
# Items as numbered circles
for i, item in enumerate(items):
if not isinstance(item, dict):
continue
ix = item.get("x", 50) # 0-100
iy = item.get("y", 50) # 0-100
px = mx0 + (ix / 100) * MAT_W
@@ -1099,7 +1139,7 @@ class RenderEngineV2:
size=9, bold=True, color=self.C["white"],
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
self._text(slide, lx + 0.6, leg_y - 0.35, 2.8, 0.6,
item.get("label", ""), size=10, color=self.C["body"])
as_label(item), size=10, color=self.C["body"])