2026-06-15 22:09:49 +02:00
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
|
"""
|
|
|
|
|
|
============================================================
|
|
|
|
|
|
RENDER ENGINE V2 — Design System "PR Editorial"
|
|
|
|
|
|
SlidingAutomation / Pernod Ricard
|
|
|
|
|
|
============================================================
|
|
|
|
|
|
Principes (vs v1) :
|
|
|
|
|
|
1. Grille fixe 3 zones (titre / contenu / footer) définie
|
|
|
|
|
|
dans theme_v2.yaml — plus de coordonnées par layout.
|
|
|
|
|
|
2. Centrage vertical SYSTÉMATIQUE : chaque renderer calcule
|
|
|
|
|
|
la hauteur de son contenu puis appelle self._cy(h).
|
|
|
|
|
|
3. Sandwich dark/light, motif cercle, pas de barre d'accent.
|
|
|
|
|
|
4. Composants unifiés : _card(), _badge(), _text()…
|
|
|
|
|
|
Usage :
|
|
|
|
|
|
python3 render_engine_v2.py input.yaml output.pptx
|
|
|
|
|
|
(input YAML ou JSON, mêmes champs que v1)
|
|
|
|
|
|
============================================================
|
|
|
|
|
|
"""
|
|
|
|
|
|
import json
|
|
|
|
|
|
import os
|
|
|
|
|
|
import re
|
|
|
|
|
|
import sys
|
|
|
|
|
|
|
|
|
|
|
|
import yaml
|
|
|
|
|
|
from pptx import Presentation
|
|
|
|
|
|
from pptx.dml.color import RGBColor
|
|
|
|
|
|
from pptx.enum.shapes import MSO_SHAPE
|
2026-07-09 21:53:52 +02:00
|
|
|
|
from pptx.enum.dml import MSO_LINE_DASH_STYLE as MSO_LINE
|
2026-06-15 22:09:49 +02:00
|
|
|
|
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
|
|
|
|
|
|
from pptx.oxml.ns import qn
|
|
|
|
|
|
from pptx.util import Cm, Emu, Pt
|
|
|
|
|
|
|
2026-07-09 21:53:52 +02:00
|
|
|
|
try:
|
|
|
|
|
|
import measure
|
|
|
|
|
|
HAS_MEASURE = True
|
|
|
|
|
|
except ImportError:
|
|
|
|
|
|
HAS_MEASURE = False
|
|
|
|
|
|
FIT_TEXT = os.getenv("FIT_TEXT", "1") != "0" # C3
|
|
|
|
|
|
|
2026-06-15 22:09:49 +02:00
|
|
|
|
# ----------------------------------------------------------------
|
|
|
|
|
|
# Helpers généraux
|
|
|
|
|
|
# ----------------------------------------------------------------
|
|
|
|
|
|
def hex_to_rgb(h: str) -> RGBColor:
|
|
|
|
|
|
h = (h or "#000000").lstrip("#")
|
|
|
|
|
|
return RGBColor(int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-09 21:53:52 +02:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _image_size(path):
|
|
|
|
|
|
"""Dimensions (w, h) d'une image : Pillow si présent, sinon lecture
|
|
|
|
|
|
directe des en-têtes PNG/JPEG. None si indéterminable."""
|
|
|
|
|
|
try:
|
|
|
|
|
|
from PIL import Image
|
|
|
|
|
|
with Image.open(path) as im:
|
|
|
|
|
|
return im.size
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
import struct
|
|
|
|
|
|
try:
|
|
|
|
|
|
with open(path, "rb") as f:
|
|
|
|
|
|
head = f.read(26)
|
|
|
|
|
|
if head[:8] == b"\x89PNG\r\n\x1a\n":
|
|
|
|
|
|
w, h = struct.unpack(">II", head[16:24])
|
|
|
|
|
|
return int(w), int(h)
|
|
|
|
|
|
if head[:2] == b"\xff\xd8":
|
|
|
|
|
|
f.seek(2)
|
|
|
|
|
|
while True:
|
|
|
|
|
|
b2 = f.read(2)
|
|
|
|
|
|
if len(b2) < 2 or b2[0] != 0xFF:
|
|
|
|
|
|
return None
|
|
|
|
|
|
marker = b2[1]
|
|
|
|
|
|
if marker in (0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6,
|
|
|
|
|
|
0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE,
|
|
|
|
|
|
0xCF):
|
|
|
|
|
|
f.read(3)
|
|
|
|
|
|
h, w = struct.unpack(">HH", f.read(4))
|
|
|
|
|
|
return int(w), int(h)
|
|
|
|
|
|
ln = struct.unpack(">H", f.read(2))[0]
|
|
|
|
|
|
f.seek(ln - 2, 1)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-20 07:53:10 +02:00
|
|
|
|
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
|
2026-06-15 22:09:49 +02:00
|
|
|
|
for k in keys:
|
|
|
|
|
|
v = d.get(k)
|
|
|
|
|
|
if v not in (None, ""):
|
|
|
|
|
|
return v
|
|
|
|
|
|
return default
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-20 07:53:10 +02:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-09 21:53:52 +02:00
|
|
|
|
def estimate_text_height(text: str, size_pt: int, width_cm: float,
|
|
|
|
|
|
font_name: str = "Calibri",
|
|
|
|
|
|
bold: bool = False) -> float:
|
|
|
|
|
|
"""Hauteur d'un texte (cm). Mesure réelle PIL si disponible (C3),
|
|
|
|
|
|
sinon heuristique v2 inchangée."""
|
2026-06-15 22:09:49 +02:00
|
|
|
|
if not text:
|
|
|
|
|
|
return 0.0
|
2026-07-09 21:53:52 +02:00
|
|
|
|
if HAS_MEASURE:
|
|
|
|
|
|
try:
|
|
|
|
|
|
return measure.text_height_cm(str(text), font_name,
|
|
|
|
|
|
size_pt, width_cm, bold)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
2026-06-15 22:09:49 +02:00
|
|
|
|
char_w_cm = size_pt * 0.0185 # largeur moyenne d'un caractère
|
|
|
|
|
|
chars_per_line = max(1, int(width_cm / char_w_cm))
|
|
|
|
|
|
lines = 0
|
|
|
|
|
|
for para in str(text).split("\n"):
|
|
|
|
|
|
lines += max(1, -(-len(para) // chars_per_line))
|
|
|
|
|
|
return lines * size_pt * 0.0455 # hauteur de ligne ≈ 1.3 em
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-09 21:53:52 +02:00
|
|
|
|
_MD_RES = [
|
|
|
|
|
|
(re.compile(r"\*\*(.+?)\*\*"), r"\1"),
|
|
|
|
|
|
(re.compile(r"__(.+?)__"), r"\1"),
|
|
|
|
|
|
(re.compile(r"`([^`]+)`"), r"\1"),
|
|
|
|
|
|
(re.compile(r"^#{1,4}\s+"), ""),
|
|
|
|
|
|
(re.compile(r"^[-•]\s+"), ""),
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def strip_markdown_tree(node):
|
|
|
|
|
|
"""Nettoyage récursif du Markdown résiduel (C3) : gras,
|
|
|
|
|
|
italique, code inline, titres et puces en tête de valeur."""
|
|
|
|
|
|
if isinstance(node, dict):
|
|
|
|
|
|
return {k: strip_markdown_tree(v) for k, v in node.items()}
|
|
|
|
|
|
if isinstance(node, list):
|
|
|
|
|
|
return [strip_markdown_tree(v) for v in node]
|
|
|
|
|
|
if isinstance(node, str):
|
|
|
|
|
|
s = node
|
|
|
|
|
|
for rx, rep in _MD_RES:
|
|
|
|
|
|
s = rx.sub(rep, s)
|
|
|
|
|
|
return s
|
|
|
|
|
|
return node
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-15 22:09:49 +02:00
|
|
|
|
# ----------------------------------------------------------------
|
|
|
|
|
|
# Moteur
|
|
|
|
|
|
# ----------------------------------------------------------------
|
|
|
|
|
|
class RenderEngineV2:
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, theme_path="theme_v2.yaml",
|
|
|
|
|
|
layouts_path="layouts_v2.yaml",
|
|
|
|
|
|
components_path="components_v2.yaml"):
|
|
|
|
|
|
with open(theme_path, encoding="utf-8") as f:
|
|
|
|
|
|
self.theme = yaml.safe_load(f)
|
|
|
|
|
|
with open(layouts_path, encoding="utf-8") as f:
|
|
|
|
|
|
self.layouts = yaml.safe_load(f)["layouts"]
|
|
|
|
|
|
with open(components_path, encoding="utf-8") as f:
|
|
|
|
|
|
self.components = yaml.safe_load(f)["components"]
|
|
|
|
|
|
|
|
|
|
|
|
c = self.theme["colors"]
|
|
|
|
|
|
self.C = {
|
|
|
|
|
|
"navy": c["primary"]["navy"],
|
|
|
|
|
|
"navy2": c["primary"]["navy_light"],
|
|
|
|
|
|
"coral": c["accent"]["coral"],
|
|
|
|
|
|
"glacier": c["secondary"]["glacier"],
|
|
|
|
|
|
"slate": c["secondary"]["slate"],
|
|
|
|
|
|
"white": c["backgrounds"]["white"],
|
|
|
|
|
|
"card": c["backgrounds"]["card"],
|
|
|
|
|
|
"card_alt": c["backgrounds"]["card_alt"],
|
|
|
|
|
|
"body": c["text"]["body"],
|
|
|
|
|
|
"muted": c["text"]["muted"],
|
|
|
|
|
|
}
|
|
|
|
|
|
self.cycle = c["cycle"]
|
|
|
|
|
|
self.F_DISPLAY = self.theme["fonts"]["display"]
|
|
|
|
|
|
self.F_BODY = self.theme["fonts"]["body"]
|
|
|
|
|
|
self.T = self.theme["typography"]
|
|
|
|
|
|
g = self.theme["grid"]
|
|
|
|
|
|
self.MX = g["margin_x"]
|
|
|
|
|
|
self.TITLE_Y, self.TITLE_H = g["title_y"], g["title_h"]
|
|
|
|
|
|
self.CONT_Y, self.CONT_B = g["content_y"], g["content_b"]
|
|
|
|
|
|
self.CONT_H = self.CONT_B - self.CONT_Y
|
|
|
|
|
|
self.FOOTER_Y = g["footer_y"]
|
|
|
|
|
|
self.GAP, self.GAP_S = g["gap"], g["gap_small"]
|
|
|
|
|
|
self.SLIDE_W, self.SLIDE_H = 33.87, 19.05
|
|
|
|
|
|
self._section_counter = 0
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------- primitives ----------------
|
|
|
|
|
|
def _cy(self, content_h: float) -> float:
|
|
|
|
|
|
"""PRINCIPE 2 : top centré verticalement dans la zone contenu."""
|
|
|
|
|
|
return self.CONT_Y + max(0.0, (self.CONT_H - content_h) / 2)
|
|
|
|
|
|
|
|
|
|
|
|
def _text(self, slide, x, y, w, h, txt, *, font=None, size=14,
|
|
|
|
|
|
bold=False, italic=False, color=None, align=PP_ALIGN.LEFT,
|
2026-07-09 21:53:52 +02:00
|
|
|
|
anchor=MSO_ANCHOR.TOP, spacing=None, char_spacing=None,
|
|
|
|
|
|
fit=True):
|
|
|
|
|
|
if (fit and FIT_TEXT and HAS_MEASURE and txt
|
|
|
|
|
|
and h and h > 0.3 and w and w > 0.5):
|
|
|
|
|
|
_ratio = (spacing / size) if spacing else None
|
|
|
|
|
|
_s, _t, _tr = measure.fit_text(
|
|
|
|
|
|
str(txt), font or self.F_BODY, size, w, h,
|
|
|
|
|
|
bold=bold, line_ratio=_ratio)
|
|
|
|
|
|
if _s != size or _tr:
|
|
|
|
|
|
print(f" ~ fit slide "
|
|
|
|
|
|
f"{getattr(self, '_slide_num', '?')} : "
|
|
|
|
|
|
f"{size}→{_s} pt"
|
|
|
|
|
|
+ (" +troncature" if _tr else ""))
|
|
|
|
|
|
size, txt = _s, _t
|
2026-06-15 22:09:49 +02:00
|
|
|
|
tb = slide.shapes.add_textbox(Cm(x), Cm(y), Cm(w), Cm(h))
|
|
|
|
|
|
tf = tb.text_frame
|
|
|
|
|
|
tf.word_wrap = True
|
|
|
|
|
|
tf.vertical_anchor = anchor
|
|
|
|
|
|
tf.margin_left = tf.margin_right = 0
|
|
|
|
|
|
tf.margin_top = tf.margin_bottom = 0
|
|
|
|
|
|
p = tf.paragraphs[0]
|
|
|
|
|
|
p.alignment = align
|
|
|
|
|
|
if spacing:
|
|
|
|
|
|
p.line_spacing = Pt(spacing)
|
|
|
|
|
|
run = p.add_run()
|
|
|
|
|
|
run.text = str(txt)
|
|
|
|
|
|
run.font.name = font or self.F_BODY
|
|
|
|
|
|
run.font.size = Pt(size)
|
|
|
|
|
|
run.font.bold = bold
|
|
|
|
|
|
run.font.italic = italic
|
|
|
|
|
|
run.font.color.rgb = hex_to_rgb(color or self.C["body"])
|
|
|
|
|
|
if char_spacing:
|
|
|
|
|
|
run.font._rPr.set("spc", str(int(char_spacing * 100)))
|
|
|
|
|
|
return tb
|
|
|
|
|
|
|
|
|
|
|
|
def _rich(self, slide, x, y, w, h, parts, *, size=16,
|
|
|
|
|
|
anchor=MSO_ANCHOR.TOP, align=PP_ALIGN.LEFT):
|
|
|
|
|
|
"""Texte multi-runs : parts = [(txt, {bold, color, italic}), ...]"""
|
|
|
|
|
|
tb = slide.shapes.add_textbox(Cm(x), Cm(y), Cm(w), Cm(h))
|
|
|
|
|
|
tf = tb.text_frame
|
|
|
|
|
|
tf.word_wrap = True
|
|
|
|
|
|
tf.vertical_anchor = anchor
|
|
|
|
|
|
tf.margin_left = tf.margin_right = 0
|
|
|
|
|
|
tf.margin_top = tf.margin_bottom = 0
|
|
|
|
|
|
p = tf.paragraphs[0]
|
|
|
|
|
|
p.alignment = align
|
|
|
|
|
|
for txt, opt in parts:
|
|
|
|
|
|
r = p.add_run()
|
|
|
|
|
|
r.text = txt
|
|
|
|
|
|
r.font.name = self.F_BODY
|
|
|
|
|
|
r.font.size = Pt(size)
|
|
|
|
|
|
r.font.bold = opt.get("bold", False)
|
|
|
|
|
|
r.font.italic = opt.get("italic", False)
|
|
|
|
|
|
r.font.color.rgb = hex_to_rgb(opt.get("color", self.C["body"]))
|
|
|
|
|
|
return tb
|
|
|
|
|
|
|
|
|
|
|
|
def _rect(self, slide, x, y, w, h, color, *, rounded=False, radius=0.06):
|
|
|
|
|
|
shape_type = MSO_SHAPE.ROUNDED_RECTANGLE if rounded else MSO_SHAPE.RECTANGLE
|
|
|
|
|
|
sh = slide.shapes.add_shape(shape_type, Cm(x), Cm(y), Cm(w), Cm(h))
|
|
|
|
|
|
if rounded:
|
|
|
|
|
|
try:
|
|
|
|
|
|
sh.adjustments[0] = radius
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
sh.fill.solid()
|
|
|
|
|
|
sh.fill.fore_color.rgb = hex_to_rgb(color)
|
|
|
|
|
|
sh.line.fill.background()
|
|
|
|
|
|
sh.shadow.inherit = False
|
|
|
|
|
|
return sh
|
|
|
|
|
|
|
|
|
|
|
|
def _oval(self, slide, x, y, d, color, *, dy=None):
|
|
|
|
|
|
sh = slide.shapes.add_shape(MSO_SHAPE.OVAL, Cm(x), Cm(y),
|
|
|
|
|
|
Cm(d), Cm(dy if dy else d))
|
|
|
|
|
|
sh.fill.solid()
|
|
|
|
|
|
sh.fill.fore_color.rgb = hex_to_rgb(color)
|
|
|
|
|
|
sh.line.fill.background()
|
|
|
|
|
|
sh.shadow.inherit = False
|
|
|
|
|
|
return sh
|
|
|
|
|
|
|
|
|
|
|
|
def _shadow(self, shape):
|
|
|
|
|
|
"""Ombre douce via OOXML (non exposée par python-pptx)."""
|
|
|
|
|
|
cfg = self.theme["card_style"]["shadow"]
|
|
|
|
|
|
sp = shape._element.spPr
|
|
|
|
|
|
old = sp.find(qn("a:effectLst"))
|
|
|
|
|
|
if old is not None:
|
|
|
|
|
|
sp.remove(old)
|
|
|
|
|
|
el = sp.makeelement(qn("a:effectLst"), {})
|
|
|
|
|
|
shdw = el.makeelement(qn("a:outerShdw"), {
|
|
|
|
|
|
"blurRad": str(int(cfg["blur_pt"] * 12700)),
|
|
|
|
|
|
"dist": str(int(cfg["dist_pt"] * 12700)),
|
|
|
|
|
|
"dir": str(int(cfg["dir_deg"] * 60000)),
|
|
|
|
|
|
"rotWithShape": "0",
|
|
|
|
|
|
})
|
|
|
|
|
|
clr = shdw.makeelement(qn("a:srgbClr"),
|
|
|
|
|
|
{"val": cfg["color"].lstrip("#")})
|
|
|
|
|
|
alpha = clr.makeelement(qn("a:alpha"),
|
|
|
|
|
|
{"val": str(int(cfg["alpha_pct"] * 1000))})
|
|
|
|
|
|
clr.append(alpha)
|
|
|
|
|
|
shdw.append(clr)
|
|
|
|
|
|
el.append(shdw)
|
|
|
|
|
|
sp.append(el)
|
|
|
|
|
|
|
|
|
|
|
|
def _card(self, slide, x, y, w, h, fill=None):
|
|
|
|
|
|
"""PRINCIPE 5 : carte unifiée — coins arrondis + ombre douce."""
|
|
|
|
|
|
sh = self._rect(slide, x, y, w, h, fill or self.C["card"],
|
|
|
|
|
|
rounded=True)
|
|
|
|
|
|
self._shadow(sh)
|
|
|
|
|
|
return sh
|
|
|
|
|
|
|
|
|
|
|
|
def _badge(self, slide, cx, cy_, d, num, *, fill=None, font_size=None):
|
|
|
|
|
|
"""Rond numéroté centré sur (cx, cy_)."""
|
|
|
|
|
|
self._oval(slide, cx - d / 2, cy_ - d / 2, d, fill or self.C["navy"])
|
|
|
|
|
|
fs = font_size or max(14, int(d * 11))
|
|
|
|
|
|
self._text(slide, cx - d / 2, cy_ - d / 2, d, d, str(num),
|
|
|
|
|
|
font=self.F_DISPLAY, size=fs, bold=True,
|
|
|
|
|
|
color=self.C["white"], align=PP_ALIGN.CENTER,
|
|
|
|
|
|
anchor=MSO_ANCHOR.MIDDLE)
|
|
|
|
|
|
|
|
|
|
|
|
def _title(self, slide, txt):
|
|
|
|
|
|
self._text(slide, self.MX, self.TITLE_Y,
|
|
|
|
|
|
self.SLIDE_W - 2 * self.MX, self.TITLE_H, txt,
|
|
|
|
|
|
font=self.F_DISPLAY, size=self.T["slide_title"],
|
|
|
|
|
|
bold=True, color=self.C["navy"])
|
|
|
|
|
|
|
|
|
|
|
|
def _footer(self, slide, num):
|
|
|
|
|
|
sig = self.theme["signature"]["footer"]
|
|
|
|
|
|
self._text(slide, self.MX, self.FOOTER_Y, 2, 0.7, str(num),
|
|
|
|
|
|
size=sig["size"], color=sig["color"])
|
|
|
|
|
|
self._text(slide, self.SLIDE_W - self.MX - 10, self.FOOTER_Y, 10, 0.7,
|
|
|
|
|
|
sig["right"], size=sig["size"], color=sig["color"],
|
|
|
|
|
|
align=PP_ALIGN.RIGHT)
|
|
|
|
|
|
|
|
|
|
|
|
def _bg(self, slide, color):
|
|
|
|
|
|
slide.background.fill.solid()
|
|
|
|
|
|
slide.background.fill.fore_color.rgb = hex_to_rgb(color)
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------- renderers ----------------
|
|
|
|
|
|
def _render_cover_split(self, slide, d):
|
|
|
|
|
|
self._bg(slide, self.C["navy"])
|
|
|
|
|
|
for c in self.components["decor_circles"]["cover"]:
|
|
|
|
|
|
col = {"theme.primary.navy_light": self.C["navy2"],
|
|
|
|
|
|
"theme.accent.coral": self.C["coral"],
|
|
|
|
|
|
"theme.secondary.glacier": self.C["glacier"]}[c["color"]]
|
|
|
|
|
|
self._oval(slide, c["x"], c["y"], c["d"], col)
|
|
|
|
|
|
self._text(slide, self.MX, 2.5, 15, 1.0, "PERNOD RICARD",
|
|
|
|
|
|
size=13, bold=True, color=self.C["glacier"],
|
|
|
|
|
|
char_spacing=4)
|
|
|
|
|
|
self._text(slide, self.MX, 5.8, 24, 6.1,
|
|
|
|
|
|
pick(d, "titre", "title"),
|
|
|
|
|
|
font=self.F_DISPLAY, size=self.T["cover_title"],
|
|
|
|
|
|
bold=True, color=self.C["white"], spacing=52)
|
|
|
|
|
|
self._text(slide, self.MX, 12.7, 20.8, 1.5,
|
|
|
|
|
|
pick(d, "sous_titre", "subtitle", "tagline", "accroche"),
|
|
|
|
|
|
size=18, italic=True, color=self.C["coral"])
|
|
|
|
|
|
|
|
|
|
|
|
def _render_executive_summary(self, slide, d):
|
|
|
|
|
|
self._title(slide, pick(d, "titre", "title"))
|
|
|
|
|
|
labels = self.layouts["executive_summary"].get(
|
|
|
|
|
|
"labels", ["SITUATION", "COMPLICATION", "RÉSOLUTION"])
|
|
|
|
|
|
keys = ["situation", "complication", "resolution"]
|
|
|
|
|
|
colors = [self.C["navy"], self.C["coral"], self.C["glacier"]]
|
|
|
|
|
|
ch, gap = 3.68, 0.89
|
|
|
|
|
|
rows = [(labels[i], colors[i], pick(d, k)) for i, k in enumerate(keys)
|
|
|
|
|
|
if pick(d, k)]
|
|
|
|
|
|
tot = len(rows) * ch + (len(rows) - 1) * gap
|
|
|
|
|
|
y = self._cy(tot)
|
|
|
|
|
|
bd = self.components["badge"]["sizes"]["m"]
|
|
|
|
|
|
for i, (label, col, txt) in enumerate(rows):
|
|
|
|
|
|
self._card(slide, self.MX, y, self.SLIDE_W - 2 * self.MX, ch)
|
|
|
|
|
|
self._badge(slide, self.MX + 1.14 + bd / 2, y + ch / 2, bd, i + 1,
|
|
|
|
|
|
fill=col, font_size=22)
|
2026-07-09 21:53:52 +02:00
|
|
|
|
# Bloc label+texte centré verticalement dans la carte (C3b)
|
|
|
|
|
|
H_LBL, GAP_LT, H_TXT = 0.85, 0.18, 1.8
|
|
|
|
|
|
blk = H_LBL + GAP_LT + H_TXT
|
|
|
|
|
|
y_lbl = y + max(0.0, (ch - blk) / 2)
|
|
|
|
|
|
self._text(slide, self.MX + 3.81, y_lbl, 8.1, H_LBL, label,
|
|
|
|
|
|
size=14, bold=True, color=col, char_spacing=3,
|
|
|
|
|
|
anchor=MSO_ANCHOR.MIDDLE)
|
|
|
|
|
|
self._text(slide, self.MX + 3.81, y_lbl + H_LBL + GAP_LT,
|
|
|
|
|
|
self.SLIDE_W - 2 * self.MX - 5.33, H_TXT, txt,
|
|
|
|
|
|
size=self.T["body"], color=self.C["body"],
|
|
|
|
|
|
anchor=MSO_ANCHOR.MIDDLE)
|
2026-06-15 22:09:49 +02:00
|
|
|
|
y += ch + gap
|
|
|
|
|
|
|
|
|
|
|
|
def _render_section_divider(self, slide, d):
|
|
|
|
|
|
self._bg(slide, self.C["navy"])
|
|
|
|
|
|
self._section_counter += 1
|
|
|
|
|
|
num = str(d.get("numero", self._section_counter)).zfill(2)
|
|
|
|
|
|
self._text(slide, 20.6, 3.0, 12.7, 12.7, num,
|
|
|
|
|
|
font=self.F_DISPLAY, size=self.T["ghost_number"],
|
|
|
|
|
|
bold=True, color=self.C["navy2"],
|
|
|
|
|
|
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
|
|
|
|
|
|
self._oval(slide, self.MX, 6.48, 0.66, self.C["coral"])
|
|
|
|
|
|
self._text(slide, self.MX + 1.4, 4.83, 19.3, 4.1,
|
|
|
|
|
|
pick(d, "titre", "title"),
|
|
|
|
|
|
font=self.F_DISPLAY, size=self.T["section_title"],
|
|
|
|
|
|
bold=True, color=self.C["white"],
|
|
|
|
|
|
anchor=MSO_ANCHOR.MIDDLE)
|
|
|
|
|
|
|
|
|
|
|
|
def _render_big_stat(self, slide, d):
|
|
|
|
|
|
self._title(slide, pick(d, "titre", "title"))
|
|
|
|
|
|
val = pick(d, "valeur", "stat", "chiffre", "value")
|
|
|
|
|
|
desc = pick(d, "description", "texte", "label")
|
|
|
|
|
|
src = pick(d, "source", "reference")
|
2026-07-09 21:53:52 +02:00
|
|
|
|
# Hauteur réelle du bloc (C3b) : chiffre + desc (+ source)
|
|
|
|
|
|
H_VAL, GAP_D, H_DESC, GAP_S, H_SRC = 5.1, 0.23, 2.0, 0.14, 0.9
|
|
|
|
|
|
block = H_VAL + GAP_D + H_DESC + (
|
|
|
|
|
|
GAP_S + H_SRC if src else 0.0)
|
2026-06-15 22:09:49 +02:00
|
|
|
|
y = self._cy(block)
|
2026-07-09 21:53:52 +02:00
|
|
|
|
self._text(slide, 0, y, self.SLIDE_W, H_VAL, val,
|
2026-06-15 22:09:49 +02:00
|
|
|
|
font=self.F_DISPLAY, size=self.T["stat_hero"], bold=True,
|
|
|
|
|
|
color=self.C["coral"], align=PP_ALIGN.CENTER,
|
|
|
|
|
|
anchor=MSO_ANCHOR.MIDDLE)
|
2026-07-09 21:53:52 +02:00
|
|
|
|
y_desc = y + H_VAL + GAP_D
|
|
|
|
|
|
self._text(slide, 6.86, y_desc, self.SLIDE_W - 13.72, H_DESC, desc,
|
2026-06-15 22:09:49 +02:00
|
|
|
|
size=18, color=self.C["body"], align=PP_ALIGN.CENTER)
|
|
|
|
|
|
if src:
|
2026-07-09 21:53:52 +02:00
|
|
|
|
y_src = y_desc + H_DESC + GAP_S
|
|
|
|
|
|
self._text(slide, 6.86, y_src, self.SLIDE_W - 13.72, H_SRC, src,
|
2026-06-15 22:09:49 +02:00
|
|
|
|
size=11, italic=True, color=self.C["muted"],
|
|
|
|
|
|
align=PP_ALIGN.CENTER)
|
|
|
|
|
|
|
|
|
|
|
|
def _col_items(self, col_data):
|
|
|
|
|
|
"""Extrait une liste de textes depuis une colonne two_cols."""
|
|
|
|
|
|
items = col_data.get("bullets") or col_data.get("items") or []
|
|
|
|
|
|
out = []
|
|
|
|
|
|
for b in items:
|
|
|
|
|
|
out.append(b.get("texte", "") if isinstance(b, dict) else str(b))
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
def _render_two_cols_text(self, slide, d):
|
|
|
|
|
|
self._title(slide, pick(d, "titre", "title"))
|
|
|
|
|
|
left, right = d.get("left", {}), d.get("right", {})
|
|
|
|
|
|
col_w = (self.SLIDE_W - 2 * self.MX - 1.27) / 2
|
|
|
|
|
|
ch = 10.67
|
|
|
|
|
|
y = self._cy(ch)
|
|
|
|
|
|
hdr_h = self.components["header_band"]["height_cm"]
|
|
|
|
|
|
for i, (col, accent) in enumerate(
|
|
|
|
|
|
[(left, self.C["navy"]), (right, self.C["coral"])]):
|
|
|
|
|
|
x = self.MX + i * (col_w + 1.27)
|
|
|
|
|
|
self._card(slide, x, y, col_w, ch)
|
|
|
|
|
|
self._rect(slide, x, y, col_w, hdr_h, accent)
|
|
|
|
|
|
self._text(slide, x + 1.0, y, col_w - 2.0, hdr_h,
|
|
|
|
|
|
pick(col, "titre", "title", "header"),
|
|
|
|
|
|
size=18, bold=True, color=self.C["white"],
|
|
|
|
|
|
anchor=MSO_ANCHOR.MIDDLE)
|
|
|
|
|
|
items = self._col_items(col)
|
|
|
|
|
|
tb = slide.shapes.add_textbox(Cm(x + 1.14), Cm(y + hdr_h + 0.6),
|
|
|
|
|
|
Cm(col_w - 2.28),
|
|
|
|
|
|
Cm(ch - hdr_h - 1.2))
|
|
|
|
|
|
tf = tb.text_frame
|
|
|
|
|
|
tf.word_wrap = True
|
|
|
|
|
|
tf.vertical_anchor = MSO_ANCHOR.MIDDLE
|
|
|
|
|
|
first = True
|
|
|
|
|
|
for it in items:
|
|
|
|
|
|
p = tf.paragraphs[0] if first else tf.add_paragraph()
|
|
|
|
|
|
first = False
|
|
|
|
|
|
p.space_after = Pt(14)
|
|
|
|
|
|
r = p.add_run()
|
|
|
|
|
|
r.text = "• " + it
|
|
|
|
|
|
r.font.name = self.F_BODY
|
|
|
|
|
|
r.font.size = Pt(15)
|
|
|
|
|
|
r.font.color.rgb = hex_to_rgb(self.C["body"])
|
|
|
|
|
|
|
|
|
|
|
|
def _render_kpi_grid(self, slide, d):
|
|
|
|
|
|
self._title(slide, pick(d, "titre", "title"))
|
|
|
|
|
|
items = d.get("items", [])
|
|
|
|
|
|
n = max(1, len(items))
|
|
|
|
|
|
gap = 1.14
|
|
|
|
|
|
col_w = (self.SLIDE_W - 2 * self.MX - (n - 1) * gap) / n
|
|
|
|
|
|
ch = 9.14
|
|
|
|
|
|
y = self._cy(ch)
|
|
|
|
|
|
for i, it in enumerate(items):
|
|
|
|
|
|
x = self.MX + i * (col_w + gap)
|
|
|
|
|
|
self._card(slide, x, y, col_w, ch)
|
|
|
|
|
|
self._text(slide, x + 0.89, y + 0.89, col_w - 1.78, 1.14,
|
|
|
|
|
|
pick(it, "label", "titre"),
|
|
|
|
|
|
size=15, bold=True, color=self.C["slate"],
|
|
|
|
|
|
char_spacing=2)
|
|
|
|
|
|
# Taille adaptative : chiffre court = grand, texte long = réduit
|
|
|
|
|
|
_val = str(pick(it, "valeur", "value", "stat"))
|
|
|
|
|
|
_vlen = len(_val)
|
|
|
|
|
|
if _vlen <= 6:
|
|
|
|
|
|
_vsize = self.T["stat_card"] # 54pt — "20-40%", "+13%"
|
|
|
|
|
|
elif _vlen <= 12:
|
|
|
|
|
|
_vsize = 32 # "Baisse", "Conformité"
|
|
|
|
|
|
else:
|
|
|
|
|
|
_vsize = 22 # "Données fiables"
|
|
|
|
|
|
self._text(slide, x + 0.89, y + 2.41, col_w - 1.78, 3.68,
|
|
|
|
|
|
_val,
|
|
|
|
|
|
font=self.F_DISPLAY, size=_vsize,
|
|
|
|
|
|
bold=True, color=self.C["coral"],
|
|
|
|
|
|
anchor=MSO_ANCHOR.MIDDLE)
|
|
|
|
|
|
self._text(slide, x + 0.89, y + ch - 2.67, col_w - 1.78, 2.03,
|
|
|
|
|
|
pick(it, "description", "source", "detail"),
|
|
|
|
|
|
size=12, color=self.C["muted"])
|
|
|
|
|
|
|
|
|
|
|
|
def _render_key_message(self, slide, d):
|
|
|
|
|
|
self._bg(slide, self.C["navy"])
|
|
|
|
|
|
self._text(slide, self.MX, 1.78, 5.6, 6.1, "\u201C",
|
|
|
|
|
|
font=self.F_DISPLAY, size=200, bold=True,
|
|
|
|
|
|
color=self.C["coral"])
|
|
|
|
|
|
self._text(slide, 6.6, 5.84, 23.9, 4.32,
|
|
|
|
|
|
pick(d, "message", "texte", "citation"),
|
|
|
|
|
|
font=self.F_DISPLAY, size=32, bold=True,
|
|
|
|
|
|
color=self.C["white"], spacing=40)
|
|
|
|
|
|
detail = pick(d, "detail", "sous_message", "sous_texte", "soutien")
|
|
|
|
|
|
if detail:
|
|
|
|
|
|
self._text(slide, 6.6, 10.8, 21.6, 1.5, detail,
|
|
|
|
|
|
size=18, italic=True, color=self.C["glacier"])
|
|
|
|
|
|
|
|
|
|
|
|
def _render_circular_diagram(self, slide, d):
|
|
|
|
|
|
self._title(slide, pick(d, "titre", "title"))
|
|
|
|
|
|
segs = d.get("segments", [])
|
|
|
|
|
|
n = len(segs)
|
|
|
|
|
|
if not n:
|
|
|
|
|
|
return
|
|
|
|
|
|
D = 6.35
|
|
|
|
|
|
cxc = 8.64
|
|
|
|
|
|
cyc = self.CONT_Y + self.CONT_H / 2
|
|
|
|
|
|
# positions : triangle pour 3, sinon cercle de positions
|
|
|
|
|
|
if n == 3:
|
|
|
|
|
|
centers = [(cxc - 1.83, cyc - 1.57), (cxc + 1.83, cyc - 1.57),
|
|
|
|
|
|
(cxc, cyc + 1.57)]
|
|
|
|
|
|
else:
|
|
|
|
|
|
import math
|
|
|
|
|
|
r_orb = D * 0.62
|
|
|
|
|
|
centers = [(cxc + r_orb * math.cos(2 * math.pi * i / n - math.pi / 2),
|
|
|
|
|
|
cyc + r_orb * math.sin(2 * math.pi * i / n - math.pi / 2))
|
|
|
|
|
|
for i in range(n)]
|
|
|
|
|
|
for i, seg in enumerate(segs):
|
|
|
|
|
|
col = seg.get("couleur") or self.cycle[i % len(self.cycle)]
|
|
|
|
|
|
cx0, cy0 = centers[i]
|
|
|
|
|
|
sh = self._oval(slide, cx0 - D / 2, cy0 - D / 2, D, col)
|
|
|
|
|
|
sh.line.color.rgb = hex_to_rgb(self.C["white"])
|
|
|
|
|
|
sh.line.width = Pt(2)
|
|
|
|
|
|
for i, seg in enumerate(segs):
|
|
|
|
|
|
cx0, cy0 = centers[i]
|
|
|
|
|
|
below = cy0 > cyc # quadrant : numéro haut ou bas
|
|
|
|
|
|
ny = cy0 + (D / 2 - 1.83) * (1 if below else -1) - 0.7
|
|
|
|
|
|
self._text(slide, cx0 - 1.78, ny, 3.56, 1.4,
|
|
|
|
|
|
str(i + 1).zfill(2),
|
|
|
|
|
|
font=self.F_DISPLAY, size=22, bold=True,
|
|
|
|
|
|
color=self.C["white"], align=PP_ALIGN.CENTER)
|
|
|
|
|
|
# légende droite — centrée verticalement
|
|
|
|
|
|
lh = 2.67
|
|
|
|
|
|
leg_h = n * lh
|
|
|
|
|
|
ly = self._cy(leg_h)
|
|
|
|
|
|
for i, seg in enumerate(segs):
|
|
|
|
|
|
col = seg.get("couleur") or self.cycle[i % len(self.cycle)]
|
|
|
|
|
|
self._oval(slide, 17.8, ly + 0.2, 1.27, col)
|
|
|
|
|
|
self._text(slide, 19.7, ly, 12.7, 1.07,
|
|
|
|
|
|
f"{str(i + 1).zfill(2)} {seg.get('label', '')}",
|
|
|
|
|
|
size=17, bold=True, color=self.C["navy"])
|
|
|
|
|
|
self._text(slide, 19.7, ly + 1.07, 12.7, 1.0,
|
|
|
|
|
|
seg.get("description", ""),
|
|
|
|
|
|
size=14, color=self.C["body"])
|
|
|
|
|
|
ly += lh
|
|
|
|
|
|
|
|
|
|
|
|
def _render_default_bullets(self, slide, d):
|
|
|
|
|
|
self._title(slide, pick(d, "titre", "title"))
|
|
|
|
|
|
bullets = d.get("bullets", [])
|
|
|
|
|
|
rh = 2.54
|
|
|
|
|
|
tot = len(bullets) * rh
|
|
|
|
|
|
y = self._cy(tot)
|
|
|
|
|
|
mk = self.components["square_mark"]["size_cm"]
|
|
|
|
|
|
for b in bullets:
|
|
|
|
|
|
txt = b.get("texte", "") if isinstance(b, dict) else str(b)
|
|
|
|
|
|
self._rect(slide, self.MX + 0.25, y + (rh - 0.5) / 2 - mk / 2 + 0.25,
|
|
|
|
|
|
mk, mk, self.C["coral"])
|
|
|
|
|
|
# "Mot : explication" → mot en gras navy
|
|
|
|
|
|
m = re.match(r"^([^:]{2,30})\s*:\s*(.+)$", txt)
|
|
|
|
|
|
if m:
|
|
|
|
|
|
parts = [(m.group(1) + " — ",
|
|
|
|
|
|
{"bold": True, "color": self.C["navy"]}),
|
|
|
|
|
|
(m.group(2), {"color": self.C["body"]})]
|
|
|
|
|
|
else:
|
|
|
|
|
|
parts = [(txt, {"color": self.C["body"]})]
|
|
|
|
|
|
self._rich(slide, self.MX + 1.52, y,
|
|
|
|
|
|
self.SLIDE_W - 2 * self.MX - 1.52, rh - 0.5, parts,
|
|
|
|
|
|
size=self.T["body_large"], anchor=MSO_ANCHOR.MIDDLE)
|
|
|
|
|
|
y += rh
|
|
|
|
|
|
|
|
|
|
|
|
def _render_numbered_steps(self, slide, d):
|
|
|
|
|
|
self._title(slide, pick(d, "titre", "title"))
|
|
|
|
|
|
steps = d.get("steps", [])
|
|
|
|
|
|
rh, gap = 3.68, 0.76
|
|
|
|
|
|
tot = len(steps) * rh + (len(steps) - 1) * gap
|
|
|
|
|
|
y = self._cy(tot)
|
|
|
|
|
|
bd = self.components["badge"]["sizes"]["l"]
|
|
|
|
|
|
for i, st in enumerate(steps):
|
2026-06-20 07:53:10 +02:00
|
|
|
|
if isinstance(st, str):
|
|
|
|
|
|
st = {"titre": st}
|
2026-06-15 22:09:49 +02:00
|
|
|
|
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)
|
|
|
|
|
|
self._text(slide, self.MX + 4.32, y + 0.64, 14.2, 1.27,
|
|
|
|
|
|
pick(st, "titre", "title"),
|
|
|
|
|
|
size=19, bold=True, color=self.C["navy"])
|
|
|
|
|
|
self._text(slide, self.MX + 4.32, y + 1.98,
|
|
|
|
|
|
self.SLIDE_W - 2 * self.MX - 5.84, 1.27,
|
|
|
|
|
|
pick(st, "description", "detail"),
|
|
|
|
|
|
size=15, color=self.C["body"])
|
|
|
|
|
|
y += rh + gap
|
|
|
|
|
|
|
|
|
|
|
|
def _render_phases_timeline(self, slide, d):
|
|
|
|
|
|
self._title(slide, pick(d, "titre", "title"))
|
|
|
|
|
|
phases = d.get("phases", [])
|
|
|
|
|
|
n = max(1, len(phases))
|
|
|
|
|
|
gap = 1.02
|
|
|
|
|
|
col_w = (self.SLIDE_W - 2 * self.MX - (n - 1) * gap) / n
|
|
|
|
|
|
bh = 3.81
|
|
|
|
|
|
tot = bh + 2.41
|
|
|
|
|
|
y = self._cy(tot)
|
|
|
|
|
|
# ligne pointillée de connexion
|
|
|
|
|
|
ln = slide.shapes.add_connector(1, Cm(self.MX + col_w / 2),
|
|
|
|
|
|
Cm(y + bh / 2),
|
|
|
|
|
|
Cm(self.SLIDE_W - self.MX - col_w / 2),
|
|
|
|
|
|
Cm(y + bh / 2))
|
|
|
|
|
|
ln.line.color.rgb = hex_to_rgb(self.C["muted"])
|
|
|
|
|
|
ln.line.width = Pt(1.25)
|
|
|
|
|
|
ln.line.dash_style = 4 # MSO_LINE.DASH
|
|
|
|
|
|
for i, ph in enumerate(phases):
|
|
|
|
|
|
col = self.cycle[i % len(self.cycle)]
|
|
|
|
|
|
x = self.MX + i * (col_w + gap)
|
|
|
|
|
|
sh = self._rect(slide, x, y, col_w, bh, col, rounded=True,
|
|
|
|
|
|
radius=0.08)
|
|
|
|
|
|
self._shadow(sh)
|
|
|
|
|
|
self._text(slide, x, y, col_w, bh, pick(ph, "label", "titre"),
|
|
|
|
|
|
font=self.F_DISPLAY, size=24, bold=True,
|
|
|
|
|
|
color=self.C["white"], align=PP_ALIGN.CENTER,
|
|
|
|
|
|
anchor=MSO_ANCHOR.MIDDLE)
|
|
|
|
|
|
self._text(slide, x, y + bh + 0.64, col_w, 1.5,
|
|
|
|
|
|
pick(ph, "periode", "description"),
|
|
|
|
|
|
size=15, color=self.C["body"],
|
|
|
|
|
|
align=PP_ALIGN.CENTER)
|
|
|
|
|
|
|
|
|
|
|
|
def _render_recommendation_card(self, slide, d):
|
|
|
|
|
|
sbw = self.layouts["recommendation_card"].get("sidebar_width_cm", 9.9)
|
|
|
|
|
|
self._rect(slide, 0, 0, sbw, self.SLIDE_H, self.C["navy"])
|
|
|
|
|
|
bd = self.components["badge"]["sizes"]["xl"]
|
|
|
|
|
|
titre = pick(d, "titre", "title")
|
|
|
|
|
|
cta = pick(d, "cta")
|
|
|
|
|
|
# contenu sidebar — centré verticalement sur la slide
|
|
|
|
|
|
cta_h = max(2.0, estimate_text_height(cta, 13, sbw - 2.3) + 0.8) if cta else 0
|
|
|
|
|
|
inner = bd + 0.89 + 1.78 + (1.27 + cta_h if cta else 0)
|
|
|
|
|
|
sy = (self.SLIDE_H - inner) / 2
|
|
|
|
|
|
self._badge(slide, sbw / 2, sy + bd / 2, bd,
|
|
|
|
|
|
d.get("numero", 1), fill=self.C["coral"], font_size=40)
|
|
|
|
|
|
self._text(slide, 0.76, sy + bd + 0.89, sbw - 1.52, 1.78, titre,
|
|
|
|
|
|
font=self.F_DISPLAY, size=24, bold=True,
|
|
|
|
|
|
color=self.C["white"], align=PP_ALIGN.CENTER)
|
|
|
|
|
|
if cta:
|
|
|
|
|
|
self._text(slide, 1.14, sy + bd + 0.89 + 1.78 + 1.27,
|
|
|
|
|
|
sbw - 2.28, cta_h, cta,
|
|
|
|
|
|
size=13, italic=True, color=self.C["glacier"],
|
|
|
|
|
|
align=PP_ALIGN.CENTER)
|
|
|
|
|
|
# corps droit
|
|
|
|
|
|
cx = sbw + 1.52
|
|
|
|
|
|
cw = self.SLIDE_W - sbw - 3.04
|
|
|
|
|
|
bullets = d.get("bullets", [])
|
|
|
|
|
|
texts = [b.get("texte", "") if isinstance(b, dict) else str(b)
|
|
|
|
|
|
for b in bullets]
|
|
|
|
|
|
# dédoublonnage
|
|
|
|
|
|
seen, dedup = set(), []
|
|
|
|
|
|
for t in texts:
|
|
|
|
|
|
if t not in seen:
|
|
|
|
|
|
seen.add(t)
|
|
|
|
|
|
dedup.append(t)
|
|
|
|
|
|
hdr_h = 2.16
|
|
|
|
|
|
body_h = len(dedup) * 1.57 + 1.78
|
|
|
|
|
|
tot = hdr_h + body_h
|
|
|
|
|
|
y = (self.SLIDE_H - tot) / 2
|
|
|
|
|
|
headline = pick(d, "headline", "header")
|
|
|
|
|
|
if headline:
|
|
|
|
|
|
self._rect(slide, cx, y, cw, hdr_h, self.C["navy"])
|
|
|
|
|
|
self._text(slide, cx + 1.0, y, cw - 2.0, hdr_h, headline,
|
|
|
|
|
|
size=18, bold=True, color=self.C["white"],
|
|
|
|
|
|
anchor=MSO_ANCHOR.MIDDLE, char_spacing=2)
|
|
|
|
|
|
self._card(slide, cx, y + hdr_h, cw, body_h)
|
|
|
|
|
|
tb = slide.shapes.add_textbox(Cm(cx + 1.27), Cm(y + hdr_h + 0.89),
|
|
|
|
|
|
Cm(cw - 2.54), Cm(body_h - 1.78))
|
|
|
|
|
|
tf = tb.text_frame
|
|
|
|
|
|
tf.word_wrap = True
|
|
|
|
|
|
tf.vertical_anchor = MSO_ANCHOR.MIDDLE
|
|
|
|
|
|
first = True
|
|
|
|
|
|
for t in dedup:
|
|
|
|
|
|
p = tf.paragraphs[0] if first else tf.add_paragraph()
|
|
|
|
|
|
first = False
|
|
|
|
|
|
p.space_after = Pt(12)
|
|
|
|
|
|
r = p.add_run()
|
|
|
|
|
|
r.text = "• " + t
|
|
|
|
|
|
r.font.name = self.F_BODY
|
|
|
|
|
|
r.font.size = Pt(16)
|
|
|
|
|
|
r.font.color.rgb = hex_to_rgb(self.C["body"])
|
|
|
|
|
|
self._footer(slide, getattr(self, "_slide_num", ""))
|
|
|
|
|
|
|
|
|
|
|
|
def _render_end_slide(self, slide, d):
|
|
|
|
|
|
self._bg(slide, self.C["navy"])
|
|
|
|
|
|
for c in self.components["decor_circles"]["end"]:
|
|
|
|
|
|
col = {"theme.primary.navy_light": self.C["navy2"],
|
|
|
|
|
|
"theme.accent.coral": self.C["coral"]}[c["color"]]
|
|
|
|
|
|
self._oval(slide, c["x"], c["y"], c["d"], col)
|
|
|
|
|
|
self._text(slide, 4.06, 6.86, 25.7, 4.57,
|
|
|
|
|
|
pick(d, "message", "titre", "texte"),
|
|
|
|
|
|
font=self.F_DISPLAY, size=34, bold=True,
|
|
|
|
|
|
color=self.C["white"], align=PP_ALIGN.CENTER,
|
|
|
|
|
|
spacing=42)
|
|
|
|
|
|
self._text(slide, 4.06, 11.9, 25.7, 1.27, "Merci pour votre attention",
|
|
|
|
|
|
size=16, italic=True, color=self.C["glacier"],
|
|
|
|
|
|
align=PP_ALIGN.CENTER)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── NEW LAYOUTS — 8 extensions ────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
def _col_positions(self, n_cols, first_col_w=None):
|
|
|
|
|
|
"""Helper : retourne [(x, width), ...] pour chaque colonne."""
|
|
|
|
|
|
if first_col_w and n_cols > 1:
|
|
|
|
|
|
rest = (self.SLIDE_W - 2*self.MX - first_col_w) / (n_cols - 1)
|
|
|
|
|
|
return [(self.MX, first_col_w)] + [
|
|
|
|
|
|
(self.MX + first_col_w + i*rest, rest) for i in range(n_cols-1)]
|
|
|
|
|
|
col_w = (self.SLIDE_W - 2*self.MX) / max(1, n_cols)
|
|
|
|
|
|
return [(self.MX + i*col_w, col_w) for i in range(n_cols)]
|
|
|
|
|
|
|
|
|
|
|
|
# ── from_to_pairs ─────────────────────────────────────────────────────────
|
|
|
|
|
|
def _render_from_to_pairs(self, slide, d):
|
|
|
|
|
|
self._title(slide, pick(d, "titre", "title"))
|
|
|
|
|
|
pairs = d.get("pairs", [])
|
|
|
|
|
|
if not pairs:
|
|
|
|
|
|
return
|
|
|
|
|
|
lbl_from = pick(d, "label_from", "ÉTAT ACTUEL")
|
|
|
|
|
|
lbl_to = pick(d, "label_to", "ÉTAT CIBLE")
|
|
|
|
|
|
ARROW_W = 2.54
|
|
|
|
|
|
col_w = (self.SLIDE_W - 2*self.MX - ARROW_W) / 2
|
|
|
|
|
|
row_h, gap, hdr_h = 1.52, 0.28, 0.76
|
|
|
|
|
|
tot = hdr_h + gap + len(pairs) * (row_h + gap)
|
|
|
|
|
|
y = self._cy(tot)
|
|
|
|
|
|
ax = self.MX + col_w # arrow zone x
|
|
|
|
|
|
tx = ax + ARROW_W # TO column x
|
|
|
|
|
|
|
|
|
|
|
|
# Headers
|
|
|
|
|
|
self._text(slide, self.MX, y, col_w, hdr_h, lbl_from,
|
|
|
|
|
|
size=13, bold=True, color=self.C["muted"],
|
|
|
|
|
|
char_spacing=3, anchor=MSO_ANCHOR.MIDDLE)
|
|
|
|
|
|
self._text(slide, tx, y, col_w, hdr_h, lbl_to,
|
|
|
|
|
|
size=13, bold=True, color=self.C["navy"],
|
|
|
|
|
|
char_spacing=3, anchor=MSO_ANCHOR.MIDDLE)
|
|
|
|
|
|
y += hdr_h + gap
|
|
|
|
|
|
|
|
|
|
|
|
for pair in pairs:
|
|
|
|
|
|
# FROM card — blue tint
|
|
|
|
|
|
self._card(slide, self.MX, y, col_w, row_h, self.C["card_alt"])
|
|
|
|
|
|
self._text(slide, self.MX + 0.64, y, col_w - 1.27, row_h,
|
|
|
|
|
|
pick(pair, "from", "de", "avant"),
|
|
|
|
|
|
size=15, italic=True, color=self.C["slate"],
|
|
|
|
|
|
anchor=MSO_ANCHOR.MIDDLE)
|
|
|
|
|
|
# Arrow
|
|
|
|
|
|
self._text(slide, ax, y, ARROW_W, row_h, "→",
|
|
|
|
|
|
size=30, bold=True, color=self.C["coral"],
|
|
|
|
|
|
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
|
|
|
|
|
|
# TO card — warm
|
|
|
|
|
|
self._card(slide, tx, y, col_w, row_h)
|
|
|
|
|
|
self._text(slide, tx + 0.64, y, col_w - 1.27, row_h,
|
|
|
|
|
|
pick(pair, "to", "vers", "après"),
|
|
|
|
|
|
size=15, bold=True, color=self.C["navy"],
|
|
|
|
|
|
anchor=MSO_ANCHOR.MIDDLE)
|
|
|
|
|
|
y += row_h + gap
|
|
|
|
|
|
|
|
|
|
|
|
# ── gantt_timeline ────────────────────────────────────────────────────────
|
|
|
|
|
|
def _render_gantt_timeline(self, slide, d):
|
|
|
|
|
|
self._title(slide, pick(d, "titre", "title"))
|
|
|
|
|
|
periods = d.get("periods", [])
|
|
|
|
|
|
workstreams = d.get("workstreams", [])
|
|
|
|
|
|
if not periods or not workstreams:
|
|
|
|
|
|
return
|
|
|
|
|
|
n_p = len(periods)
|
|
|
|
|
|
LBL_W = 4.57
|
|
|
|
|
|
GAP_COL = 0.25
|
|
|
|
|
|
GRID_W = self.SLIDE_W - 2*self.MX - LBL_W - GAP_COL
|
|
|
|
|
|
col_w = GRID_W / n_p
|
|
|
|
|
|
HDR_H = 0.80
|
|
|
|
|
|
WS_H = 0.68
|
|
|
|
|
|
TASK_H = 0.78
|
2026-06-20 07:53:10 +02:00
|
|
|
|
n_tasks = sum(len(ws.get("tasks", [])) for ws in workstreams
|
|
|
|
|
|
if isinstance(ws, dict))
|
2026-06-15 22:09:49 +02:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
# Header
|
|
|
|
|
|
self._rect(slide, self.MX, y0, self.SLIDE_W - 2*self.MX, HDR_H, self.C["navy"])
|
|
|
|
|
|
for i, p in enumerate(periods):
|
|
|
|
|
|
self._text(slide, gx + i*col_w, y0, col_w, HDR_H, p,
|
|
|
|
|
|
size=10, bold=True, color=self.C["white"],
|
|
|
|
|
|
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
|
|
|
|
|
|
|
|
|
|
|
|
# Subtle vertical separators over full grid height
|
|
|
|
|
|
for i in range(1, n_p):
|
|
|
|
|
|
xl = gx + i * col_w
|
|
|
|
|
|
ln = slide.shapes.add_connector(1, Cm(xl), Cm(y0 + HDR_H),
|
|
|
|
|
|
Cm(xl), Cm(y0 + total_h))
|
|
|
|
|
|
ln.line.color.rgb = hex_to_rgb("#E0DEDB")
|
|
|
|
|
|
ln.line.width = Pt(0.5)
|
|
|
|
|
|
|
|
|
|
|
|
cur_y = y0 + HDR_H
|
|
|
|
|
|
for ws_idx, ws in enumerate(workstreams):
|
2026-06-20 07:53:10 +02:00
|
|
|
|
if not isinstance(ws, dict):
|
|
|
|
|
|
continue
|
2026-06-15 22:09:49 +02:00
|
|
|
|
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")
|
|
|
|
|
|
self._rect(slide, self.MX, cur_y, 0.28, WS_H, ws_col)
|
|
|
|
|
|
self._text(slide, self.MX + 0.50, cur_y, LBL_W - 0.50, WS_H,
|
|
|
|
|
|
ws.get("label", ""), size=12, bold=True,
|
|
|
|
|
|
color=self.C["navy"], anchor=MSO_ANCHOR.MIDDLE)
|
|
|
|
|
|
cur_y += WS_H
|
|
|
|
|
|
for task in ws.get("tasks", []):
|
2026-06-20 07:53:10 +02:00
|
|
|
|
if not isinstance(task, dict):
|
|
|
|
|
|
continue
|
2026-06-15 22:09:49 +02:00
|
|
|
|
start = task.get("start", 0)
|
|
|
|
|
|
end = task.get("end", start + 1)
|
|
|
|
|
|
bg = self.C["white"]
|
|
|
|
|
|
self._rect(slide, self.MX, cur_y, self.SLIDE_W - 2*self.MX, TASK_H, bg)
|
|
|
|
|
|
self._text(slide, self.MX + 0.50, cur_y, LBL_W - 0.50, TASK_H,
|
|
|
|
|
|
" " + task.get("label", ""), size=11,
|
|
|
|
|
|
color=self.C["body"], anchor=MSO_ANCHOR.MIDDLE)
|
|
|
|
|
|
bx = gx + start * col_w + 0.14
|
|
|
|
|
|
bw = (end - start) * col_w - 0.28
|
|
|
|
|
|
self._rect(slide, bx, cur_y + 0.15, bw, TASK_H - 0.30,
|
|
|
|
|
|
ws_col, rounded=True)
|
|
|
|
|
|
cur_y += TASK_H
|
|
|
|
|
|
# bottom separator
|
|
|
|
|
|
ln = slide.shapes.add_connector(1, Cm(self.MX), Cm(cur_y),
|
|
|
|
|
|
Cm(self.SLIDE_W - self.MX), Cm(cur_y))
|
|
|
|
|
|
ln.line.color.rgb = hex_to_rgb("#D8D4CF")
|
|
|
|
|
|
ln.line.width = Pt(0.5)
|
|
|
|
|
|
|
|
|
|
|
|
# ── yearly_timeline ───────────────────────────────────────────────────────
|
|
|
|
|
|
def _render_yearly_timeline(self, slide, d):
|
|
|
|
|
|
self._title(slide, pick(d, "titre", "title"))
|
|
|
|
|
|
milestones = d.get("milestones", [])
|
|
|
|
|
|
if not milestones:
|
|
|
|
|
|
return
|
|
|
|
|
|
n = len(milestones)
|
|
|
|
|
|
D = 1.27 # circle diameter
|
|
|
|
|
|
LBL_H = 1.52
|
|
|
|
|
|
LINE_Y_DELTA = LBL_H + 0.38 # gap above line for top labels
|
|
|
|
|
|
tot_h = LBL_H * 2 + D + 0.76
|
|
|
|
|
|
y_top = self._cy(tot_h)
|
|
|
|
|
|
line_y = y_top + LBL_H + 0.38 # centre of the line / circles
|
|
|
|
|
|
usable_w = self.SLIDE_W - 2*self.MX
|
|
|
|
|
|
step = usable_w / (n - 1) if n > 1 else usable_w
|
|
|
|
|
|
|
|
|
|
|
|
# Horizontal line
|
|
|
|
|
|
ln = slide.shapes.add_connector(1,
|
|
|
|
|
|
Cm(self.MX), Cm(line_y + D/2),
|
|
|
|
|
|
Cm(self.SLIDE_W - self.MX), Cm(line_y + D/2))
|
|
|
|
|
|
ln.line.color.rgb = hex_to_rgb(self.C["navy"])
|
|
|
|
|
|
ln.line.width = Pt(1.75)
|
|
|
|
|
|
|
|
|
|
|
|
for i, ms in enumerate(milestones):
|
|
|
|
|
|
cx = self.MX + i * step
|
|
|
|
|
|
active = ms.get("actif", False)
|
|
|
|
|
|
col = self.C["coral"] if active else self.C["navy"]
|
|
|
|
|
|
lbl_col = self.C["navy"] if active else self.C["body"]
|
|
|
|
|
|
lbl_sz = 13 if active else 12
|
|
|
|
|
|
|
|
|
|
|
|
# Circle
|
|
|
|
|
|
self._oval(slide, cx - D/2, line_y, D, col)
|
|
|
|
|
|
annee = str(pick(ms, "annee", "year", str(i+1)))
|
|
|
|
|
|
self._text(slide, cx - D/2, line_y, D, D, annee,
|
|
|
|
|
|
size=10, bold=True, color=self.C["white"],
|
|
|
|
|
|
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
|
|
|
|
|
|
|
|
|
|
|
|
# Label
|
|
|
|
|
|
lw = min(step * 0.85, 5.08) if n > 1 else usable_w
|
|
|
|
|
|
label = pick(ms, "label", "evenement", "event")
|
|
|
|
|
|
# Clamp : label ne sort jamais du slide
|
|
|
|
|
|
lx = max(self.MX, min(cx - lw/2, self.SLIDE_W - self.MX - lw))
|
|
|
|
|
|
if i % 2 == 0:
|
|
|
|
|
|
self._text(slide, lx, y_top, lw, LBL_H, label,
|
|
|
|
|
|
size=lbl_sz, bold=active, color=lbl_col,
|
|
|
|
|
|
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.BOTTOM)
|
|
|
|
|
|
else:
|
|
|
|
|
|
self._text(slide, lx, line_y + D + 0.25, lw, LBL_H, label,
|
|
|
|
|
|
size=lbl_sz, bold=active, color=lbl_col,
|
|
|
|
|
|
align=PP_ALIGN.CENTER)
|
|
|
|
|
|
|
|
|
|
|
|
# ── comparison_table ──────────────────────────────────────────────────────
|
|
|
|
|
|
def _render_comparison_table(self, slide, d):
|
|
|
|
|
|
self._title(slide, pick(d, "titre", "title"))
|
|
|
|
|
|
headers = d.get("headers", [])
|
|
|
|
|
|
rows = d.get("rows", [])
|
|
|
|
|
|
if not headers or not rows:
|
|
|
|
|
|
return
|
|
|
|
|
|
n_cols = len(headers)
|
|
|
|
|
|
n_rows = len(rows)
|
|
|
|
|
|
HDR_H = 1.02
|
|
|
|
|
|
ROW_H = min(1.27, (self.CONT_H - HDR_H - 0.5) / n_rows)
|
|
|
|
|
|
FIRST_W = 5.08
|
|
|
|
|
|
cols = self._col_positions(n_cols, FIRST_W if n_cols > 1 else None)
|
|
|
|
|
|
total_h = HDR_H + n_rows * ROW_H
|
|
|
|
|
|
y = self._cy(total_h)
|
|
|
|
|
|
|
|
|
|
|
|
# Header
|
|
|
|
|
|
self._rect(slide, self.MX, y, self.SLIDE_W - 2*self.MX, HDR_H, self.C["navy"])
|
|
|
|
|
|
for j, (x, w) in enumerate(cols):
|
|
|
|
|
|
self._text(slide, x + 0.33, y, w - 0.33, HDR_H, headers[j],
|
|
|
|
|
|
size=13, bold=True, color=self.C["white"],
|
|
|
|
|
|
anchor=MSO_ANCHOR.MIDDLE)
|
|
|
|
|
|
|
|
|
|
|
|
# Rows
|
|
|
|
|
|
for i, row in enumerate(rows):
|
|
|
|
|
|
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)
|
|
|
|
|
|
cells = list(row) if isinstance(row, (list, tuple)) else ([row.get("label", "")] + row.get("values", [])) if isinstance(row, dict) else [str(row)]
|
|
|
|
|
|
for j, (x, w) in enumerate(cols):
|
|
|
|
|
|
val = cells[j] if j < len(cells) else ""
|
|
|
|
|
|
self._text(slide, x + 0.33, ry, w - 0.33, ROW_H, str(val),
|
|
|
|
|
|
size=13, bold=(j == 0), color=self.C["navy"] if j == 0 else self.C["body"],
|
|
|
|
|
|
anchor=MSO_ANCHOR.MIDDLE)
|
|
|
|
|
|
# separator
|
|
|
|
|
|
ln = slide.shapes.add_connector(1, Cm(self.MX), Cm(ry + ROW_H),
|
|
|
|
|
|
Cm(self.SLIDE_W - self.MX), Cm(ry + ROW_H))
|
|
|
|
|
|
ln.line.color.rgb = hex_to_rgb("#E8E4E0")
|
|
|
|
|
|
ln.line.width = Pt(0.5)
|
|
|
|
|
|
|
|
|
|
|
|
# ── raci_table ────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _render_raci_table(self, slide, d):
|
|
|
|
|
|
self._title(slide, pick(d, "titre", "title"))
|
|
|
|
|
|
roles = d.get("roles", [])
|
|
|
|
|
|
tasks = d.get("tasks", [])
|
|
|
|
|
|
if not roles or not tasks:
|
|
|
|
|
|
return
|
|
|
|
|
|
RACI_COLORS = {"R": self.C["coral"], "A": self.C["navy"],
|
|
|
|
|
|
"C": self.C["slate"], "I": self.C["muted"]}
|
|
|
|
|
|
n_cols = len(roles) + 1
|
|
|
|
|
|
n_rows = len(tasks)
|
|
|
|
|
|
HDR_H = 1.02
|
|
|
|
|
|
ROW_H = min(1.14, (self.CONT_H - HDR_H - 0.5) / n_rows)
|
|
|
|
|
|
TASK_W = 6.35
|
|
|
|
|
|
ROLE_W = (self.SLIDE_W - 2*self.MX - TASK_W) / len(roles)
|
|
|
|
|
|
BADGE = 0.64
|
|
|
|
|
|
|
|
|
|
|
|
total_h = HDR_H + n_rows * ROW_H
|
|
|
|
|
|
y = self._cy(total_h)
|
|
|
|
|
|
|
|
|
|
|
|
# Header
|
|
|
|
|
|
self._rect(slide, self.MX, y, self.SLIDE_W - 2*self.MX, HDR_H, self.C["navy"])
|
|
|
|
|
|
self._text(slide, self.MX + 0.33, y, TASK_W - 0.33, HDR_H, "Activité",
|
|
|
|
|
|
size=12, bold=True, color=self.C["white"], anchor=MSO_ANCHOR.MIDDLE)
|
|
|
|
|
|
for j, role in enumerate(roles):
|
|
|
|
|
|
rx = self.MX + TASK_W + j * ROLE_W
|
|
|
|
|
|
self._text(slide, rx, y, ROLE_W, HDR_H, role,
|
|
|
|
|
|
size=11, bold=True, color=self.C["white"],
|
|
|
|
|
|
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
|
|
|
|
|
|
|
|
|
|
|
|
# Rows
|
|
|
|
|
|
for i, task in enumerate(tasks):
|
2026-06-20 07:53:10 +02:00
|
|
|
|
if not isinstance(task, dict):
|
|
|
|
|
|
continue
|
2026-06-15 22:09:49 +02:00
|
|
|
|
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)
|
|
|
|
|
|
self._text(slide, self.MX + 0.33, ry, TASK_W - 0.33, ROW_H,
|
|
|
|
|
|
task.get("label", ""), size=12, color=self.C["navy"],
|
|
|
|
|
|
anchor=MSO_ANCHOR.MIDDLE)
|
|
|
|
|
|
raci = task.get("raci", [])
|
|
|
|
|
|
for j, letter in enumerate(raci):
|
|
|
|
|
|
if j >= len(roles):
|
|
|
|
|
|
break
|
|
|
|
|
|
rx = self.MX + TASK_W + j * ROLE_W + (ROLE_W - BADGE) / 2
|
|
|
|
|
|
by = ry + (ROW_H - BADGE) / 2
|
|
|
|
|
|
col = RACI_COLORS.get(letter.upper(), self.C["muted"])
|
|
|
|
|
|
self._rect(slide, rx, by, BADGE, BADGE, col, rounded=True, radius=0.08)
|
|
|
|
|
|
self._text(slide, rx, by, BADGE, BADGE, letter.upper(),
|
|
|
|
|
|
size=13, bold=True, color=self.C["white"],
|
|
|
|
|
|
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
|
|
|
|
|
|
ln = slide.shapes.add_connector(1, Cm(self.MX), Cm(ry + ROW_H),
|
|
|
|
|
|
Cm(self.SLIDE_W - self.MX), Cm(ry + ROW_H))
|
|
|
|
|
|
ln.line.color.rgb = hex_to_rgb("#E8E4E0")
|
|
|
|
|
|
ln.line.width = Pt(0.5)
|
|
|
|
|
|
|
|
|
|
|
|
# ── process_arrow ─────────────────────────────────────────────────────────
|
|
|
|
|
|
def _render_process_arrow(self, slide, d):
|
|
|
|
|
|
self._title(slide, pick(d, "titre", "title"))
|
|
|
|
|
|
steps = d.get("steps", [])
|
|
|
|
|
|
if not steps:
|
|
|
|
|
|
return
|
|
|
|
|
|
n = len(steps)
|
|
|
|
|
|
ARR_W = 0.89
|
|
|
|
|
|
BOX_H = 2.67
|
|
|
|
|
|
DSC_H = 1.0
|
|
|
|
|
|
usable = self.SLIDE_W - 2*self.MX - ARR_W * (n - 1)
|
|
|
|
|
|
box_w = usable / n
|
|
|
|
|
|
tot_h = BOX_H + DSC_H + 0.38
|
|
|
|
|
|
y0 = self._cy(tot_h)
|
|
|
|
|
|
BADGE_D = 0.76
|
|
|
|
|
|
|
|
|
|
|
|
for i, step in enumerate(steps):
|
|
|
|
|
|
x = self.MX + i * (box_w + ARR_W)
|
|
|
|
|
|
col = self.cycle[i % len(self.cycle)]
|
|
|
|
|
|
# Box (dark bg = navy or cycle)
|
|
|
|
|
|
self._rect(slide, x, y0, box_w, BOX_H, col, rounded=True)
|
|
|
|
|
|
# Badge number top-center
|
|
|
|
|
|
self._oval(slide, x + (box_w - BADGE_D) / 2, y0 - BADGE_D/2,
|
|
|
|
|
|
BADGE_D, self.C["coral"])
|
|
|
|
|
|
self._text(slide, x + (box_w - BADGE_D) / 2, y0 - BADGE_D/2,
|
|
|
|
|
|
BADGE_D, BADGE_D, str(i + 1),
|
|
|
|
|
|
size=14, bold=True, color=self.C["white"],
|
|
|
|
|
|
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
|
|
|
|
|
|
# Title inside box
|
|
|
|
|
|
self._text(slide, x + 0.33, y0 + 0.38, box_w - 0.66, BOX_H - 0.76,
|
|
|
|
|
|
pick(step, "titre", "title", "label"),
|
|
|
|
|
|
size=15, bold=True, color=self.C["white"],
|
|
|
|
|
|
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
|
|
|
|
|
|
# Description below
|
|
|
|
|
|
self._text(slide, x, y0 + BOX_H + 0.28, box_w, DSC_H,
|
|
|
|
|
|
pick(step, "description", "detail"),
|
|
|
|
|
|
size=12, color=self.C["body"],
|
|
|
|
|
|
align=PP_ALIGN.CENTER)
|
|
|
|
|
|
# Connecting arrow (except last)
|
|
|
|
|
|
if i < n - 1:
|
|
|
|
|
|
ax = x + box_w
|
|
|
|
|
|
self._text(slide, ax, y0, ARR_W, BOX_H, "›",
|
|
|
|
|
|
size=28, bold=True, color=self.C["coral"],
|
|
|
|
|
|
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
|
|
|
|
|
|
|
|
|
|
|
|
# ── org_chart ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _render_org_chart(self, slide, d):
|
|
|
|
|
|
self._title(slide, pick(d, "titre", "title"))
|
2026-06-20 07:53:10 +02:00
|
|
|
|
|
|
|
|
|
|
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": []}
|
|
|
|
|
|
|
2026-06-15 22:09:49 +02:00
|
|
|
|
root = d.get("root", {})
|
2026-06-20 07:53:10 +02:00
|
|
|
|
if isinstance(root, str):
|
|
|
|
|
|
root = {"label": root, "children": []}
|
2026-06-15 22:09:49 +02:00
|
|
|
|
if not root:
|
|
|
|
|
|
return
|
2026-06-20 07:53:10 +02:00
|
|
|
|
root = _node(root)
|
2026-06-15 22:09:49 +02:00
|
|
|
|
BOX_W, BOX_H = 3.81, 1.0
|
|
|
|
|
|
GAP_V, GAP_H = 1.27, 0.64
|
2026-06-20 07:53:10 +02:00
|
|
|
|
children = [_node(c) for c in root.get("children", [])]
|
2026-06-15 22:09:49 +02:00
|
|
|
|
n_children = len(children)
|
|
|
|
|
|
# grandchildren count per child (max)
|
|
|
|
|
|
max_gd = max((len(c.get("children", [])) for c in children), default=0)
|
|
|
|
|
|
|
|
|
|
|
|
# Heights
|
|
|
|
|
|
levels = 1 + (1 if children else 0) + (1 if max_gd > 0 else 0)
|
|
|
|
|
|
tot_h = levels * BOX_H + (levels - 1) * GAP_V
|
|
|
|
|
|
cy_top = self._cy(tot_h)
|
|
|
|
|
|
|
|
|
|
|
|
# Root box (centered, navy)
|
|
|
|
|
|
root_x = (self.SLIDE_W - BOX_W) / 2
|
|
|
|
|
|
self._card(slide, root_x, cy_top, BOX_W, BOX_H, self.C["navy"])
|
|
|
|
|
|
self._text(slide, root_x + 0.33, cy_top, BOX_W - 0.66, BOX_H,
|
|
|
|
|
|
root.get("label", ""), size=14, bold=True,
|
|
|
|
|
|
color=self.C["white"], align=PP_ALIGN.CENTER,
|
|
|
|
|
|
anchor=MSO_ANCHOR.MIDDLE)
|
|
|
|
|
|
|
|
|
|
|
|
if not children:
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
# Children row
|
|
|
|
|
|
child_y = cy_top + BOX_H + GAP_V
|
|
|
|
|
|
total_children_w = n_children * BOX_W + (n_children - 1) * GAP_H
|
|
|
|
|
|
child_x_start = (self.SLIDE_W - total_children_w) / 2
|
|
|
|
|
|
|
|
|
|
|
|
# Connector from root bottom to children row
|
|
|
|
|
|
for i, child in enumerate(children):
|
|
|
|
|
|
cx = child_x_start + i * (BOX_W + GAP_H)
|
|
|
|
|
|
col = self.C["card"]
|
|
|
|
|
|
self._card(slide, cx, child_y, BOX_W, BOX_H, col)
|
|
|
|
|
|
self._text(slide, cx + 0.33, child_y, BOX_W - 0.66, BOX_H,
|
|
|
|
|
|
child.get("label", ""), size=13, bold=True,
|
|
|
|
|
|
color=self.C["navy"], align=PP_ALIGN.CENTER,
|
|
|
|
|
|
anchor=MSO_ANCHOR.MIDDLE)
|
|
|
|
|
|
# Line: root bottom → child top
|
|
|
|
|
|
mid_root_x = root_x + BOX_W / 2
|
|
|
|
|
|
mid_child_x = cx + BOX_W / 2
|
|
|
|
|
|
mid_y = cy_top + BOX_H + GAP_V / 2
|
|
|
|
|
|
# Vertical from root
|
|
|
|
|
|
ln = slide.shapes.add_connector(1,
|
|
|
|
|
|
Cm(mid_root_x), Cm(cy_top + BOX_H),
|
|
|
|
|
|
Cm(mid_root_x), Cm(mid_y))
|
|
|
|
|
|
ln.line.color.rgb = hex_to_rgb(self.C["navy"])
|
|
|
|
|
|
ln.line.width = Pt(1.0)
|
|
|
|
|
|
# Horizontal to child
|
|
|
|
|
|
ln2 = slide.shapes.add_connector(1,
|
|
|
|
|
|
Cm(mid_root_x if i == 0 else child_x_start + (n_children-1)*(BOX_W+GAP_H)/2 if i == n_children-1 else mid_child_x),
|
|
|
|
|
|
Cm(mid_y), Cm(mid_child_x), Cm(mid_y))
|
|
|
|
|
|
ln2.line.color.rgb = hex_to_rgb(self.C["navy"])
|
|
|
|
|
|
ln2.line.width = Pt(1.0)
|
|
|
|
|
|
# Vertical to child
|
|
|
|
|
|
ln3 = slide.shapes.add_connector(1,
|
|
|
|
|
|
Cm(mid_child_x), Cm(mid_y),
|
|
|
|
|
|
Cm(mid_child_x), Cm(child_y))
|
|
|
|
|
|
ln3.line.color.rgb = hex_to_rgb(self.C["navy"])
|
|
|
|
|
|
ln3.line.width = Pt(1.0)
|
|
|
|
|
|
|
|
|
|
|
|
# Grandchildren — sizing dynamique pour tenir dans la colonne
|
2026-06-20 07:53:10 +02:00
|
|
|
|
grandchildren = [_node(g) for g in child.get("children", [])]
|
2026-06-15 22:09:49 +02:00
|
|
|
|
if grandchildren:
|
|
|
|
|
|
gd_y = child_y + BOX_H + GAP_V
|
|
|
|
|
|
n_gd = len(grandchildren)
|
|
|
|
|
|
col_span = BOX_W + GAP_H # largeur allouée par parent
|
|
|
|
|
|
GD_GAP = 0.25
|
|
|
|
|
|
GD_W = min(BOX_W, (col_span - GD_GAP * (n_gd - 1)) / n_gd)
|
|
|
|
|
|
GD_W = max(1.78, GD_W)
|
|
|
|
|
|
gd_total = n_gd * GD_W + (n_gd - 1) * GD_GAP
|
|
|
|
|
|
gd_x_start = cx + BOX_W/2 - gd_total/2
|
|
|
|
|
|
mid_cy_x = cx + BOX_W/2
|
|
|
|
|
|
for k, gc in enumerate(grandchildren):
|
|
|
|
|
|
gx = gd_x_start + k * (GD_W + GD_GAP)
|
|
|
|
|
|
self._card(slide, gx, gd_y, GD_W, BOX_H, "#EAF0F8")
|
|
|
|
|
|
self._text(slide, gx + 0.20, gd_y, GD_W - 0.40, BOX_H,
|
|
|
|
|
|
gc.get("label", ""), size=10,
|
|
|
|
|
|
color=self.C["navy"], align=PP_ALIGN.CENTER,
|
|
|
|
|
|
anchor=MSO_ANCHOR.MIDDLE)
|
|
|
|
|
|
mid_gx = gx + GD_W/2
|
|
|
|
|
|
ln4 = slide.shapes.add_connector(1,
|
|
|
|
|
|
Cm(mid_cy_x), Cm(child_y + BOX_H),
|
|
|
|
|
|
Cm(mid_gx), Cm(gd_y))
|
|
|
|
|
|
ln4.line.color.rgb = hex_to_rgb(self.C["muted"])
|
|
|
|
|
|
ln4.line.width = Pt(0.75)
|
|
|
|
|
|
|
|
|
|
|
|
# ── matrix_2x2 ────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _render_matrix_2x2(self, slide, d):
|
|
|
|
|
|
self._title(slide, pick(d, "titre", "title"))
|
|
|
|
|
|
axis_x = d.get("axis_x", {})
|
|
|
|
|
|
axis_y = d.get("axis_y", {})
|
|
|
|
|
|
quadrants = d.get("quadrants", {})
|
|
|
|
|
|
items = d.get("items", [])
|
|
|
|
|
|
ITEM_D = 0.89
|
|
|
|
|
|
|
|
|
|
|
|
# Matrix zone
|
|
|
|
|
|
AXIS_LBL = 1.27
|
|
|
|
|
|
MAT_W = self.SLIDE_W - 2*self.MX - AXIS_LBL
|
|
|
|
|
|
MAT_H = self.CONT_H - AXIS_LBL - 0.3
|
|
|
|
|
|
tot_h = MAT_H + AXIS_LBL
|
|
|
|
|
|
y0 = self._cy(tot_h)
|
|
|
|
|
|
mx0 = self.MX + AXIS_LBL # matrix x start
|
|
|
|
|
|
|
|
|
|
|
|
# Quadrant backgrounds
|
|
|
|
|
|
hw, hh = MAT_W/2, MAT_H/2
|
|
|
|
|
|
Q_FILLS = ["#EAF0F8", self.C["card"], "#E8F4EC", "#FFF0EC"]
|
|
|
|
|
|
Q_POS = [(mx0, y0), (mx0+hw, y0), (mx0, y0+hh), (mx0+hw, y0+hh)]
|
|
|
|
|
|
for fill, (qx, qy) in zip(Q_FILLS, Q_POS):
|
|
|
|
|
|
self._rect(slide, qx, qy, hw, hh, fill)
|
|
|
|
|
|
|
|
|
|
|
|
# Axis lines
|
|
|
|
|
|
for lx, ly, ex, ey in [
|
|
|
|
|
|
(mx0, y0, mx0 + MAT_W, y0),
|
|
|
|
|
|
(mx0, y0 + MAT_H, mx0 + MAT_W, y0 + MAT_H),
|
|
|
|
|
|
(mx0, y0, mx0, y0 + MAT_H),
|
|
|
|
|
|
(mx0 + MAT_W, y0, mx0 + MAT_W, y0 + MAT_H),
|
|
|
|
|
|
(mx0 + hw, y0, mx0 + hw, y0 + MAT_H), # vertical mid
|
|
|
|
|
|
(mx0, y0 + hh, mx0 + MAT_W, y0 + hh), # horizontal mid
|
|
|
|
|
|
]:
|
|
|
|
|
|
ln = slide.shapes.add_connector(1, Cm(lx), Cm(ly), Cm(ex), Cm(ey))
|
|
|
|
|
|
ln.line.color.rgb = hex_to_rgb("#C8C4BE" if "mid" not in str(lx) else "#C8C4BE")
|
|
|
|
|
|
ln.line.width = Pt(1.0 if (lx == mx0+hw or ly == y0+hh) else 1.5)
|
|
|
|
|
|
|
|
|
|
|
|
# Quadrant labels (corners)
|
|
|
|
|
|
QD = {
|
|
|
|
|
|
"top_left": (mx0 + 0.3, y0 + 0.2),
|
|
|
|
|
|
"top_right": (mx0 + hw + 0.3, y0 + 0.2),
|
|
|
|
|
|
"bottom_left": (mx0 + 0.3, y0 + hh + 0.2),
|
|
|
|
|
|
"bottom_right": (mx0 + hw + 0.3, y0 + hh + 0.2),
|
|
|
|
|
|
}
|
|
|
|
|
|
for key, (qx, qy) in QD.items():
|
|
|
|
|
|
label = quadrants.get(key, "")
|
|
|
|
|
|
if label:
|
|
|
|
|
|
self._text(slide, qx, qy, hw - 0.5, 0.64, label,
|
|
|
|
|
|
size=11, italic=True, color=self.C["muted"])
|
|
|
|
|
|
|
|
|
|
|
|
# Axis labels
|
|
|
|
|
|
ax_lbl = pick(axis_x, "label", "x")
|
|
|
|
|
|
ay_lbl = pick(axis_y, "label", "y")
|
|
|
|
|
|
if ax_lbl:
|
|
|
|
|
|
self._text(slide, mx0, y0 + MAT_H + 0.12, MAT_W, AXIS_LBL, ax_lbl,
|
|
|
|
|
|
size=13, bold=True, color=self.C["navy"],
|
|
|
|
|
|
align=PP_ALIGN.CENTER)
|
|
|
|
|
|
if ay_lbl:
|
|
|
|
|
|
# Axe Y : flèche verticale + label court à gauche de la matrice
|
|
|
|
|
|
ln_ay = slide.shapes.add_connector(1,
|
|
|
|
|
|
Cm(self.MX + AXIS_LBL * 0.5), Cm(y0 + MAT_H),
|
|
|
|
|
|
Cm(self.MX + AXIS_LBL * 0.5), Cm(y0))
|
|
|
|
|
|
ln_ay.line.color.rgb = hex_to_rgb(self.C["navy"])
|
|
|
|
|
|
ln_ay.line.width = Pt(1.5)
|
|
|
|
|
|
self._text(slide, self.MX, y0 - 0.76, AXIS_LBL, 0.64,
|
|
|
|
|
|
f"↑ {ay_lbl}",
|
|
|
|
|
|
size=12, bold=True, color=self.C["navy"])
|
|
|
|
|
|
|
|
|
|
|
|
# Axis extremities
|
|
|
|
|
|
for lbl, pos, anchor in [
|
|
|
|
|
|
(pick(axis_x, "low", "low_x", ""), (mx0, y0 + MAT_H + 0.1), PP_ALIGN.LEFT),
|
|
|
|
|
|
(pick(axis_x, "high", "high_x", ""), (mx0 + MAT_W - 1.5, y0 + MAT_H + 0.1), PP_ALIGN.RIGHT),
|
|
|
|
|
|
(pick(axis_y, "high", "high_y", ""), (self.MX, y0 + 0.1), PP_ALIGN.CENTER),
|
|
|
|
|
|
(pick(axis_y, "low", "low_y", ""), (self.MX, y0 + MAT_H - 0.8), PP_ALIGN.CENTER),
|
|
|
|
|
|
]:
|
|
|
|
|
|
if lbl:
|
|
|
|
|
|
self._text(slide, pos[0], pos[1], 1.5, 0.5, lbl,
|
|
|
|
|
|
size=10, italic=True, color=self.C["muted"], align=anchor)
|
|
|
|
|
|
|
|
|
|
|
|
# Items as numbered circles
|
|
|
|
|
|
for i, item in enumerate(items):
|
2026-06-20 07:53:10 +02:00
|
|
|
|
if not isinstance(item, dict):
|
|
|
|
|
|
continue
|
2026-06-15 22:09:49 +02:00
|
|
|
|
ix = item.get("x", 50) # 0-100
|
|
|
|
|
|
iy = item.get("y", 50) # 0-100
|
|
|
|
|
|
px = mx0 + (ix / 100) * MAT_W
|
|
|
|
|
|
py = y0 + ((100 - iy) / 100) * MAT_H
|
|
|
|
|
|
self._oval(slide, px - ITEM_D/2, py - ITEM_D/2, ITEM_D, self.C["coral"])
|
|
|
|
|
|
self._text(slide, px - ITEM_D/2, py - ITEM_D/2, ITEM_D, ITEM_D,
|
|
|
|
|
|
str(i + 1), size=12, bold=True, color=self.C["white"],
|
|
|
|
|
|
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
|
|
|
|
|
|
|
|
|
|
|
|
# Legend below x-axis label
|
|
|
|
|
|
if items:
|
|
|
|
|
|
leg_y = y0 + MAT_H + AXIS_LBL * 0.55
|
|
|
|
|
|
leg_x = mx0
|
|
|
|
|
|
for i, item in enumerate(items):
|
|
|
|
|
|
lx = leg_x + i * 3.56
|
|
|
|
|
|
if lx + 3.3 > self.SLIDE_W - self.MX:
|
|
|
|
|
|
break
|
|
|
|
|
|
self._oval(slide, lx, leg_y - 0.3, 0.5, self.C["coral"])
|
|
|
|
|
|
self._text(slide, lx, leg_y - 0.3, 0.5, 0.5, str(i+1),
|
|
|
|
|
|
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,
|
2026-06-20 07:53:10 +02:00
|
|
|
|
as_label(item), size=10, color=self.C["body"])
|
2026-06-15 22:09:49 +02:00
|
|
|
|
|
|
|
|
|
|
|
2026-06-19 16:23:15 +02:00
|
|
|
|
|
|
|
|
|
|
# ── FLUX LIBRE — freeform (palier 3, charte imposée) ──────────────────────
|
|
|
|
|
|
#
|
|
|
|
|
|
# Une slide freeform : { layout: freeform, mode: light|dark, blocks: [...] }
|
|
|
|
|
|
# Chaque bloc est positionné sur une grille 12x12 (col 0-12, row 0-12),
|
|
|
|
|
|
# ce qui borne le positionnement et évite les débordements.
|
|
|
|
|
|
# Les couleurs ne peuvent être que des TOKENS de charte (imposé).
|
|
|
|
|
|
|
|
|
|
|
|
# Grille libre : 12 colonnes, 12 lignes, sur la zone utile (hors marges)
|
|
|
|
|
|
FREE_COLS = 12
|
|
|
|
|
|
FREE_ROWS = 12
|
|
|
|
|
|
|
|
|
|
|
|
# Tokens de couleur autorisés (charte imposée — aucune couleur arbitraire)
|
2026-07-12 08:14:57 +02:00
|
|
|
|
_TOKEN_RE = re.compile(r"^([a-z_]+)(?:@(\d{1,3}))?$")
|
|
|
|
|
|
|
2026-06-19 16:23:15 +02:00
|
|
|
|
def _token_color(self, token: str, default: str = None) -> str:
|
2026-07-12 08:14:57 +02:00
|
|
|
|
"""Tokens de la charte + dérivés paramétrés (C6, palier 4) :
|
|
|
|
|
|
'navy@15' = navy éclairci de 15 % vers blanc. L'espace couleur
|
|
|
|
|
|
reste engendré par la charte — jamais de hex."""
|
2026-06-19 16:23:15 +02:00
|
|
|
|
mapping = {
|
|
|
|
|
|
"navy": self.C["navy"], "navy_light": self.C["navy2"],
|
|
|
|
|
|
"coral": self.C["coral"], "glacier": self.C["glacier"],
|
|
|
|
|
|
"slate": self.C["slate"], "card": self.C["card"],
|
2026-07-12 08:14:57 +02:00
|
|
|
|
"card_alt": self.C["card_alt"],
|
2026-06-19 16:23:15 +02:00
|
|
|
|
"white": self.C["white"], "body": self.C["body"],
|
|
|
|
|
|
"muted": self.C["muted"],
|
|
|
|
|
|
}
|
2026-07-12 08:14:57 +02:00
|
|
|
|
m = self._TOKEN_RE.match((token or "").strip().lower())
|
|
|
|
|
|
if not m:
|
|
|
|
|
|
return default or self.C["navy"]
|
|
|
|
|
|
base = mapping.get(m.group(1))
|
|
|
|
|
|
if base is None:
|
|
|
|
|
|
return default or self.C["navy"]
|
|
|
|
|
|
if m.group(2) is not None:
|
|
|
|
|
|
pct = min(100, int(m.group(2)))
|
|
|
|
|
|
return _blend(base, "#FFFFFF", pct / 100.0)
|
|
|
|
|
|
return base
|
2026-06-19 16:23:15 +02:00
|
|
|
|
|
|
|
|
|
|
def _free_x(self, col: float) -> float:
|
|
|
|
|
|
"""Colonne de grille (0-12) → position x en cm (dans la zone utile)."""
|
|
|
|
|
|
usable = self.SLIDE_W - 2 * self.MX
|
|
|
|
|
|
return self.MX + (col / self.FREE_COLS) * usable
|
|
|
|
|
|
|
|
|
|
|
|
def _free_y(self, row: float) -> float:
|
|
|
|
|
|
"""Ligne de grille (0-12) → position y en cm (zone titre→footer)."""
|
|
|
|
|
|
top = self.TITLE_Y
|
|
|
|
|
|
usable = self.FOOTER_Y - 0.4 - top
|
|
|
|
|
|
return top + (row / self.FREE_ROWS) * usable
|
|
|
|
|
|
|
|
|
|
|
|
def _free_w(self, cols: float) -> float:
|
|
|
|
|
|
usable = self.SLIDE_W - 2 * self.MX
|
|
|
|
|
|
return (cols / self.FREE_COLS) * usable
|
|
|
|
|
|
|
|
|
|
|
|
def _free_h(self, rows: float) -> float:
|
|
|
|
|
|
usable = self.FOOTER_Y - 0.4 - self.TITLE_Y
|
|
|
|
|
|
return (rows / self.FREE_ROWS) * usable
|
|
|
|
|
|
|
2026-07-12 08:14:57 +02:00
|
|
|
|
FREE_SHAPES = {
|
|
|
|
|
|
"chevron": MSO_SHAPE.CHEVRON,
|
|
|
|
|
|
"arrow": MSO_SHAPE.RIGHT_ARROW,
|
|
|
|
|
|
"triangle": MSO_SHAPE.ISOSCELES_TRIANGLE,
|
|
|
|
|
|
"pill": MSO_SHAPE.ROUNDED_RECTANGLE,
|
|
|
|
|
|
"donut": MSO_SHAPE.DONUT,
|
|
|
|
|
|
"bracket_left": MSO_SHAPE.LEFT_BRACKET,
|
|
|
|
|
|
"bracket_right": MSO_SHAPE.RIGHT_BRACKET,
|
|
|
|
|
|
"oval": MSO_SHAPE.OVAL,
|
|
|
|
|
|
"diamond": MSO_SHAPE.DIAMOND,
|
|
|
|
|
|
"hexagon": MSO_SHAPE.HEXAGON,
|
|
|
|
|
|
"parallelogram": MSO_SHAPE.PARALLELOGRAM,
|
|
|
|
|
|
"moon": MSO_SHAPE.MOON,
|
|
|
|
|
|
}
|
|
|
|
|
|
_DARK_BASES = {"navy", "navy_light", "slate", "body"}
|
|
|
|
|
|
FREE_MAX_BLOCKS = 15
|
|
|
|
|
|
|
|
|
|
|
|
def _set_fill_alpha(self, sh, alpha_pct):
|
|
|
|
|
|
"""Transparence du remplissage (voile) — silencieux si le
|
|
|
|
|
|
shape n'a pas de solidFill (textbox…)."""
|
|
|
|
|
|
try:
|
|
|
|
|
|
srgb = sh._element.spPr.find(qn("a:solidFill")).find(
|
|
|
|
|
|
qn("a:srgbClr"))
|
|
|
|
|
|
a = srgb.makeelement(
|
|
|
|
|
|
qn("a:alpha"),
|
|
|
|
|
|
{"val": str(int(max(0.0, min(100.0,
|
|
|
|
|
|
float(alpha_pct))) * 1000))})
|
|
|
|
|
|
srgb.append(a)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
def _free_geom(self, blk, full_bleed):
|
|
|
|
|
|
"""Grille 12×12 : zone utile par défaut, slide entière en
|
|
|
|
|
|
full_bleed."""
|
|
|
|
|
|
col = float(blk.get("col", 0))
|
|
|
|
|
|
row = float(blk.get("row", 0))
|
|
|
|
|
|
wc = float(blk.get("w", 4))
|
|
|
|
|
|
hr = float(blk.get("h", 1))
|
|
|
|
|
|
if full_bleed:
|
|
|
|
|
|
return ((col / self.FREE_COLS) * self.SLIDE_W,
|
|
|
|
|
|
(row / self.FREE_ROWS) * self.SLIDE_H,
|
|
|
|
|
|
(wc / self.FREE_COLS) * self.SLIDE_W,
|
|
|
|
|
|
(hr / self.FREE_ROWS) * self.SLIDE_H)
|
|
|
|
|
|
return (self._free_x(col), self._free_y(row),
|
|
|
|
|
|
self._free_w(wc), self._free_h(hr))
|
|
|
|
|
|
|
|
|
|
|
|
def _free_decorate(self, sh, blk):
|
|
|
|
|
|
"""Propriétés transverses du palier 4 : alpha, rotation (pas
|
|
|
|
|
|
de 15°), border {color, weight}, radius. Silencieux quand une
|
|
|
|
|
|
propriété ne s'applique pas au type de shape."""
|
|
|
|
|
|
if sh is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
if blk.get("alpha") is not None:
|
|
|
|
|
|
self._set_fill_alpha(sh, blk["alpha"])
|
|
|
|
|
|
rot = blk.get("rotation")
|
|
|
|
|
|
if rot:
|
|
|
|
|
|
try:
|
|
|
|
|
|
sh.rotation = (round(float(rot) / 15.0) * 15) % 360
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
border = blk.get("border")
|
|
|
|
|
|
if isinstance(border, dict):
|
|
|
|
|
|
try:
|
|
|
|
|
|
sh.line.color.rgb = hex_to_rgb(self._token_color(
|
|
|
|
|
|
border.get("color"), self.C["navy"]))
|
|
|
|
|
|
sh.line.width = Pt(float(border.get("weight", 1.0)))
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
if blk.get("radius") is not None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
sh.adjustments[0] = max(0.0, min(
|
|
|
|
|
|
0.5, float(blk["radius"])))
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
2026-06-19 16:23:15 +02:00
|
|
|
|
def _render_freeform(self, slide, d):
|
|
|
|
|
|
mode = d.get("mode", "light")
|
2026-07-12 08:14:57 +02:00
|
|
|
|
bg = d.get("background")
|
2026-06-19 16:23:15 +02:00
|
|
|
|
on_dark = (mode == "dark")
|
2026-07-12 08:14:57 +02:00
|
|
|
|
if bg:
|
|
|
|
|
|
self._bg(slide, self._token_color(bg, self.C["white"]))
|
|
|
|
|
|
m = self._TOKEN_RE.match(str(bg).strip().lower())
|
|
|
|
|
|
if m and m.group(1) in self._DARK_BASES:
|
|
|
|
|
|
pct = int(m.group(2)) if m.group(2) is not None else 0
|
|
|
|
|
|
on_dark = pct < 45
|
|
|
|
|
|
else:
|
|
|
|
|
|
on_dark = False
|
|
|
|
|
|
elif on_dark:
|
2026-06-19 16:23:15 +02:00
|
|
|
|
self._bg(slide, self.C["navy"])
|
|
|
|
|
|
default_text = self.C["white"] if on_dark else self.C["body"]
|
|
|
|
|
|
|
2026-07-12 08:14:57 +02:00
|
|
|
|
blocks = d.get("blocks", [])
|
|
|
|
|
|
if len(blocks) > self.FREE_MAX_BLOCKS:
|
|
|
|
|
|
print(f" ~ freeform : {len(blocks)} blocs → "
|
|
|
|
|
|
f"{self.FREE_MAX_BLOCKS} (plafond moteur)")
|
|
|
|
|
|
blocks = blocks[:self.FREE_MAX_BLOCKS]
|
|
|
|
|
|
|
|
|
|
|
|
for blk in blocks:
|
2026-06-19 16:23:15 +02:00
|
|
|
|
btype = (blk.get("type") or "text").lower()
|
2026-07-12 08:14:57 +02:00
|
|
|
|
fb = bool(blk.get("full_bleed"))
|
|
|
|
|
|
x, y, w, h = self._free_geom(blk, fb)
|
|
|
|
|
|
sh = None
|
2026-06-19 16:23:15 +02:00
|
|
|
|
|
|
|
|
|
|
if btype == "rect":
|
2026-07-12 08:14:57 +02:00
|
|
|
|
sh = self._rect(slide, x, y, w, h,
|
|
|
|
|
|
self._token_color(blk.get("color"),
|
|
|
|
|
|
self.C["card"]),
|
|
|
|
|
|
rounded=blk.get("rounded", False))
|
2026-06-19 16:23:15 +02:00
|
|
|
|
|
|
|
|
|
|
elif btype == "card":
|
2026-07-12 08:14:57 +02:00
|
|
|
|
sh = self._card(slide, x, y, w, h,
|
|
|
|
|
|
self._token_color(blk.get("color"),
|
|
|
|
|
|
self.C["card"]))
|
2026-06-19 16:23:15 +02:00
|
|
|
|
|
|
|
|
|
|
elif btype == "circle":
|
|
|
|
|
|
d_cm = min(w, h)
|
2026-07-12 08:14:57 +02:00
|
|
|
|
sh = self._oval(slide, x, y, d_cm,
|
|
|
|
|
|
self._token_color(blk.get("color"),
|
|
|
|
|
|
self.C["coral"]))
|
|
|
|
|
|
|
|
|
|
|
|
elif btype == "shape":
|
|
|
|
|
|
kind = self.FREE_SHAPES.get(
|
|
|
|
|
|
str(blk.get("shape") or "oval").lower())
|
|
|
|
|
|
if kind is None:
|
|
|
|
|
|
print(f" ~ freeform : shape "
|
|
|
|
|
|
f"'{blk.get('shape')}' inconnue → oval")
|
|
|
|
|
|
kind = MSO_SHAPE.OVAL
|
|
|
|
|
|
sh = slide.shapes.add_shape(
|
|
|
|
|
|
kind, Cm(x), Cm(y), Cm(w), Cm(h))
|
|
|
|
|
|
sh.fill.solid()
|
|
|
|
|
|
sh.fill.fore_color.rgb = hex_to_rgb(
|
|
|
|
|
|
self._token_color(blk.get("color"),
|
|
|
|
|
|
self.C["coral"]))
|
|
|
|
|
|
sh.line.fill.background()
|
|
|
|
|
|
sh.shadow.inherit = False
|
|
|
|
|
|
_st = sh._element.find(qn("p:style"))
|
|
|
|
|
|
if _st is not None: # style implicite = ombre
|
|
|
|
|
|
sh._element.remove(_st)
|
|
|
|
|
|
if blk.get("text"):
|
|
|
|
|
|
tf = sh.text_frame
|
|
|
|
|
|
tf.word_wrap = True
|
|
|
|
|
|
tf.text = str(blk["text"])
|
|
|
|
|
|
p = tf.paragraphs[0]
|
|
|
|
|
|
p.alignment = PP_ALIGN.CENTER
|
|
|
|
|
|
for r in p.runs:
|
|
|
|
|
|
r.font.name = self.F_BODY
|
|
|
|
|
|
r.font.size = Pt(int(blk.get("size", 14)))
|
|
|
|
|
|
r.font.bold = True
|
|
|
|
|
|
r.font.color.rgb = hex_to_rgb(
|
|
|
|
|
|
self._token_color(blk.get("text_color"),
|
|
|
|
|
|
self.C["white"]))
|
2026-06-19 16:23:15 +02:00
|
|
|
|
|
|
|
|
|
|
elif btype == "line":
|
2026-07-12 08:14:57 +02:00
|
|
|
|
sh = slide.shapes.add_connector(
|
2026-06-19 16:23:15 +02:00
|
|
|
|
1, Cm(x), Cm(y), Cm(x + w), Cm(y + h))
|
2026-07-12 08:14:57 +02:00
|
|
|
|
sh.line.color.rgb = hex_to_rgb(
|
|
|
|
|
|
self._token_color(blk.get("color"),
|
|
|
|
|
|
self.C["muted"]))
|
|
|
|
|
|
sh.line.width = Pt(float(blk.get("weight", 1.25)))
|
|
|
|
|
|
if blk.get("dash"):
|
|
|
|
|
|
sh.line.dash_style = MSO_LINE.DASH
|
|
|
|
|
|
sh.shadow.inherit = False
|
|
|
|
|
|
_st = sh._element.find(qn("p:style"))
|
|
|
|
|
|
if _st is not None:
|
|
|
|
|
|
sh._element.remove(_st)
|
|
|
|
|
|
sh = None # pas de décoration fill sur ligne
|
2026-06-19 16:23:15 +02:00
|
|
|
|
|
2026-07-09 21:53:52 +02:00
|
|
|
|
elif btype == "image":
|
2026-07-12 08:14:57 +02:00
|
|
|
|
sh = self._image(slide, x, y, w, h,
|
|
|
|
|
|
blk.get("image") or blk.get("src", ""),
|
|
|
|
|
|
fit=str(blk.get("fit") or "cover"))
|
2026-07-09 21:53:52 +02:00
|
|
|
|
|
2026-06-19 16:23:15 +02:00
|
|
|
|
elif btype == "badge":
|
|
|
|
|
|
d_cm = min(w, h)
|
2026-07-12 08:14:57 +02:00
|
|
|
|
sh = self._badge(slide, x + d_cm / 2, y + d_cm / 2,
|
|
|
|
|
|
d_cm, blk.get("text", ""),
|
|
|
|
|
|
fill=self._token_color(
|
|
|
|
|
|
blk.get("color"), self.C["navy"]))
|
2026-06-19 16:23:15 +02:00
|
|
|
|
|
|
|
|
|
|
elif btype == "stat":
|
2026-07-12 08:14:57 +02:00
|
|
|
|
sh = self._text(slide, x, y, w, h, blk.get("text", ""),
|
|
|
|
|
|
font=self.F_DISPLAY,
|
|
|
|
|
|
size=int(blk.get("size", 72)),
|
|
|
|
|
|
bold=True,
|
|
|
|
|
|
color=self._token_color(
|
|
|
|
|
|
blk.get("color"), self.C["coral"]),
|
|
|
|
|
|
align=self._free_align(
|
|
|
|
|
|
blk.get("align", "left")),
|
|
|
|
|
|
anchor=MSO_ANCHOR.MIDDLE)
|
2026-06-19 16:23:15 +02:00
|
|
|
|
|
|
|
|
|
|
elif btype in ("title", "heading"):
|
2026-07-12 08:14:57 +02:00
|
|
|
|
sh = self._text(slide, x, y, w, h, blk.get("text", ""),
|
|
|
|
|
|
font=self.F_DISPLAY,
|
|
|
|
|
|
size=int(blk.get("size", 28)),
|
|
|
|
|
|
bold=True,
|
|
|
|
|
|
color=self._token_color(
|
|
|
|
|
|
blk.get("color"),
|
|
|
|
|
|
self.C["white"] if on_dark
|
|
|
|
|
|
else self.C["navy"]),
|
|
|
|
|
|
align=self._free_align(
|
|
|
|
|
|
blk.get("align", "left")),
|
|
|
|
|
|
anchor=MSO_ANCHOR.MIDDLE)
|
2026-06-19 16:23:15 +02:00
|
|
|
|
|
|
|
|
|
|
else: # text
|
2026-07-12 08:14:57 +02:00
|
|
|
|
font = self.F_DISPLAY if blk.get("serif") else self.F_BODY
|
|
|
|
|
|
sh = self._text(slide, x, y, w, h, blk.get("text", ""),
|
|
|
|
|
|
font=font,
|
|
|
|
|
|
size=int(blk.get("size", 16)),
|
|
|
|
|
|
bold=blk.get("bold", False),
|
|
|
|
|
|
italic=blk.get("italic", False),
|
|
|
|
|
|
color=self._token_color(
|
|
|
|
|
|
blk.get("color"), default_text),
|
|
|
|
|
|
align=self._free_align(
|
|
|
|
|
|
blk.get("align", "left")),
|
|
|
|
|
|
anchor=MSO_ANCHOR.TOP)
|
|
|
|
|
|
|
|
|
|
|
|
self._free_decorate(sh, blk)
|
2026-06-19 16:23:15 +02:00
|
|
|
|
|
|
|
|
|
|
def _free_align(self, a: str):
|
|
|
|
|
|
return {"left": PP_ALIGN.LEFT, "center": PP_ALIGN.CENTER,
|
|
|
|
|
|
"right": PP_ALIGN.RIGHT}.get((a or "left").lower(),
|
|
|
|
|
|
PP_ALIGN.LEFT)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-09 21:53:52 +02:00
|
|
|
|
# ---------------- assets & images (C4) ----------------
|
|
|
|
|
|
assets_dir = "assets" # surchargé par main() ou l'appelant
|
|
|
|
|
|
|
|
|
|
|
|
def _asset(self, name):
|
|
|
|
|
|
"""Résout un nom de fichier image vers un chemin existant :
|
|
|
|
|
|
chemin direct, puis assets_dir/name. None si introuvable."""
|
|
|
|
|
|
name = str(name or "").strip()
|
|
|
|
|
|
if not name:
|
|
|
|
|
|
return None
|
|
|
|
|
|
if os.path.isfile(name):
|
|
|
|
|
|
return name
|
|
|
|
|
|
cand = os.path.join(str(self.assets_dir or "assets"), name)
|
|
|
|
|
|
if os.path.isfile(cand):
|
|
|
|
|
|
return cand
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
def _image(self, slide, x, y, w, h, name, fit="cover"):
|
|
|
|
|
|
"""Insère une image dans la zone (x, y, w, h) SANS déformation.
|
|
|
|
|
|
cover : remplit la zone, recadrage centré symétrique (crop_*).
|
|
|
|
|
|
contain : letterbox centré dans la zone.
|
|
|
|
|
|
Image introuvable ou illisible → placeholder déterministe."""
|
|
|
|
|
|
path = self._asset(name)
|
|
|
|
|
|
size = _image_size(path) if path else None
|
|
|
|
|
|
if not path or not size or size[0] <= 0 or size[1] <= 0:
|
|
|
|
|
|
print(f" ~ image absente ou illisible : {name} → placeholder")
|
|
|
|
|
|
self._rect(slide, x, y, w, h, self.C["card_alt"])
|
|
|
|
|
|
self._text(slide, x, y, w, h, f"[image : {name}]",
|
|
|
|
|
|
size=12, color=self.C["muted"],
|
|
|
|
|
|
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE,
|
|
|
|
|
|
fit=False)
|
|
|
|
|
|
return None
|
|
|
|
|
|
iw, ih = size
|
|
|
|
|
|
target_ratio = w / h
|
|
|
|
|
|
img_ratio = iw / ih
|
|
|
|
|
|
if fit == "contain":
|
|
|
|
|
|
if img_ratio > target_ratio:
|
|
|
|
|
|
w2, h2 = w, w / img_ratio
|
|
|
|
|
|
x2, y2 = x, y + (h - h2) / 2
|
|
|
|
|
|
else:
|
|
|
|
|
|
h2, w2 = h, h * img_ratio
|
|
|
|
|
|
x2, y2 = x + (w - w2) / 2, y
|
|
|
|
|
|
return slide.shapes.add_picture(path, Cm(x2), Cm(y2),
|
|
|
|
|
|
Cm(w2), Cm(h2))
|
|
|
|
|
|
pic = slide.shapes.add_picture(path, Cm(x), Cm(y), Cm(w), Cm(h))
|
|
|
|
|
|
if img_ratio > target_ratio: # trop large → crop lat.
|
|
|
|
|
|
c = (1 - target_ratio / img_ratio) / 2
|
|
|
|
|
|
pic.crop_left = c
|
|
|
|
|
|
pic.crop_right = c
|
|
|
|
|
|
elif img_ratio < target_ratio: # trop haut → crop vert.
|
|
|
|
|
|
c = (1 - img_ratio / target_ratio) / 2
|
|
|
|
|
|
pic.crop_top = c
|
|
|
|
|
|
pic.crop_bottom = c
|
|
|
|
|
|
return pic
|
|
|
|
|
|
|
|
|
|
|
|
def _rect_alpha(self, slide, x, y, w, h, color, alpha_pct):
|
|
|
|
|
|
"""Rectangle de couleur avec transparence (voile) — OOXML."""
|
|
|
|
|
|
sh = self._rect(slide, x, y, w, h, color)
|
|
|
|
|
|
srgb = sh._element.spPr.find(qn("a:solidFill")).find(
|
|
|
|
|
|
qn("a:srgbClr"))
|
|
|
|
|
|
alpha = srgb.makeelement(
|
|
|
|
|
|
qn("a:alpha"), {"val": str(int(alpha_pct * 1000))})
|
|
|
|
|
|
srgb.append(alpha)
|
|
|
|
|
|
return sh
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------- renderers images (C4) ----------------
|
|
|
|
|
|
def _render_image_split(self, slide, d):
|
|
|
|
|
|
"""Image 40 % d'un côté (side: left|right) + titre et bullets.
|
|
|
|
|
|
L'image s'arrête au-dessus du footer (numéro de page lisible)."""
|
|
|
|
|
|
side = str(d.get("side") or "left").lower()
|
|
|
|
|
|
img_w = self.SLIDE_W * 0.40
|
|
|
|
|
|
img_h = self.FOOTER_Y - 0.2
|
|
|
|
|
|
ix = 0.0 if side != "right" else self.SLIDE_W - img_w
|
|
|
|
|
|
self._image(slide, ix, 0.0, img_w, img_h,
|
|
|
|
|
|
pick(d, "image", "img"), fit="cover")
|
|
|
|
|
|
legende = pick(d, "legende", "caption")
|
|
|
|
|
|
if legende:
|
|
|
|
|
|
bh = 1.15
|
|
|
|
|
|
self._rect(slide, ix, img_h - bh, img_w, bh, self.C["navy"])
|
|
|
|
|
|
self._text(slide, ix + 0.5, img_h - bh, img_w - 1.0, bh,
|
|
|
|
|
|
legende, size=11, color=self.C["white"],
|
|
|
|
|
|
anchor=MSO_ANCHOR.MIDDLE)
|
|
|
|
|
|
cx = self.MX if side == "right" else img_w + 1.5
|
|
|
|
|
|
cw = self.SLIDE_W - img_w - 1.5 - self.MX
|
|
|
|
|
|
self._text(slide, cx, self.TITLE_Y, cw, self.TITLE_H,
|
|
|
|
|
|
pick(d, "titre", "title"),
|
|
|
|
|
|
font=self.F_DISPLAY, size=self.T["slide_title"],
|
|
|
|
|
|
bold=True, color=self.C["navy"])
|
|
|
|
|
|
bullets = [b for b in (d.get("bullets") or [])][:6]
|
|
|
|
|
|
bh_i, gap = 1.9, 0.5
|
|
|
|
|
|
tot = len(bullets) * (bh_i + gap) - gap if bullets else 0
|
|
|
|
|
|
y = self._cy(tot)
|
|
|
|
|
|
for b in bullets:
|
|
|
|
|
|
self._oval(slide, cx, y + 0.28, 0.28, self.C["coral"])
|
|
|
|
|
|
self._text(slide, cx + 0.8, y, cw - 0.8, bh_i,
|
|
|
|
|
|
as_label(b, "texte", "text"),
|
|
|
|
|
|
size=self.T["body"], color=self.C["body"])
|
|
|
|
|
|
y += bh_i + gap
|
|
|
|
|
|
|
|
|
|
|
|
def _render_image_full(self, slide, d):
|
|
|
|
|
|
"""Image plein cadre + voile navy 60 % + titre display blanc.
|
|
|
|
|
|
Ouverture de chapitre visuelle (alternative à section_divider)."""
|
|
|
|
|
|
self._bg(slide, self.C["navy"])
|
|
|
|
|
|
self._image(slide, 0.0, 0.0, self.SLIDE_W, self.SLIDE_H,
|
|
|
|
|
|
pick(d, "image", "img"), fit="cover")
|
|
|
|
|
|
self._rect_alpha(slide, 0.0, 0.0, self.SLIDE_W, self.SLIDE_H,
|
|
|
|
|
|
self.C["navy"], 60)
|
|
|
|
|
|
self._oval(slide, self.MX, 6.48, 0.66, self.C["coral"])
|
|
|
|
|
|
self._text(slide, self.MX, 7.6, 26.0, 4.5,
|
|
|
|
|
|
pick(d, "titre", "title"),
|
|
|
|
|
|
font=self.F_DISPLAY, size=44, bold=True,
|
|
|
|
|
|
color=self.C["white"], spacing=48)
|
|
|
|
|
|
st = pick(d, "sous_titre", "subtitle")
|
|
|
|
|
|
if st:
|
|
|
|
|
|
self._text(slide, self.MX, 12.3, 22.0, 1.5, st,
|
|
|
|
|
|
size=16, italic=True, color=self.C["glacier"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------- 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------- 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------- 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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-15 22:09:49 +02:00
|
|
|
|
# ---------------- orchestration ----------------
|
|
|
|
|
|
REGISTRY = {
|
|
|
|
|
|
"cover_split": "_render_cover_split",
|
|
|
|
|
|
"executive_summary": "_render_executive_summary",
|
|
|
|
|
|
"section_divider": "_render_section_divider",
|
|
|
|
|
|
"big_stat": "_render_big_stat",
|
|
|
|
|
|
"two_cols_text": "_render_two_cols_text",
|
|
|
|
|
|
"kpi_grid": "_render_kpi_grid",
|
|
|
|
|
|
"key_message": "_render_key_message",
|
|
|
|
|
|
"circular_diagram": "_render_circular_diagram",
|
|
|
|
|
|
"default_bullets": "_render_default_bullets",
|
|
|
|
|
|
"numbered_steps": "_render_numbered_steps",
|
|
|
|
|
|
"phases_timeline": "_render_phases_timeline",
|
|
|
|
|
|
"recommendation_card": "_render_recommendation_card",
|
|
|
|
|
|
"end_slide": "_render_end_slide",
|
2026-06-19 16:23:15 +02:00
|
|
|
|
"freeform": "_render_freeform",
|
2026-06-15 22:09:49 +02:00
|
|
|
|
"from_to_pairs": "_render_from_to_pairs",
|
|
|
|
|
|
"gantt_timeline": "_render_gantt_timeline",
|
|
|
|
|
|
"yearly_timeline": "_render_yearly_timeline",
|
|
|
|
|
|
"comparison_table": "_render_comparison_table",
|
|
|
|
|
|
"raci_table": "_render_raci_table",
|
|
|
|
|
|
"process_arrow": "_render_process_arrow",
|
|
|
|
|
|
"org_chart": "_render_org_chart",
|
|
|
|
|
|
"matrix_2x2": "_render_matrix_2x2",
|
2026-07-09 21:53:52 +02:00
|
|
|
|
"agenda": "_render_agenda",
|
|
|
|
|
|
"pyramid": "_render_pyramid",
|
|
|
|
|
|
"waterfall": "_render_waterfall",
|
|
|
|
|
|
"heatmap_table": "_render_heatmap_table",
|
|
|
|
|
|
"funnel": "_render_funnel",
|
|
|
|
|
|
"bar_chart": "_render_bar_chart",
|
|
|
|
|
|
"line_chart": "_render_line_chart",
|
|
|
|
|
|
"donut_split": "_render_donut_split",
|
|
|
|
|
|
"image_split": "_render_image_split",
|
|
|
|
|
|
"image_full": "_render_image_full",
|
2026-06-15 22:09:49 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def render(self, data, output_path: str):
|
|
|
|
|
|
if isinstance(data, str):
|
|
|
|
|
|
data = yaml.safe_load(data)
|
2026-07-09 21:53:52 +02:00
|
|
|
|
data = strip_markdown_tree(data)
|
2026-06-15 22:09:49 +02:00
|
|
|
|
slides = data.get("slides", data) if isinstance(data, dict) else data
|
|
|
|
|
|
|
|
|
|
|
|
prs = Presentation()
|
|
|
|
|
|
prs.slide_width = Cm(self.SLIDE_W)
|
|
|
|
|
|
prs.slide_height = Cm(self.SLIDE_H)
|
|
|
|
|
|
blank = prs.slide_layouts[6]
|
|
|
|
|
|
self._section_counter = 0
|
|
|
|
|
|
excluded = set(self.theme["signature"]["footer_excluded_layouts"])
|
|
|
|
|
|
|
|
|
|
|
|
for i, sd in enumerate(slides):
|
|
|
|
|
|
slide = prs.slides.add_slide(blank)
|
|
|
|
|
|
layout = sd.get("layout", "default_bullets")
|
|
|
|
|
|
self._slide_num = i + 1
|
|
|
|
|
|
method = self.REGISTRY.get(layout)
|
|
|
|
|
|
if not method:
|
|
|
|
|
|
print(f" ⚠ Layout inconnu '{layout}' → default_bullets")
|
|
|
|
|
|
method = "_render_default_bullets"
|
|
|
|
|
|
layout = "default_bullets"
|
2026-06-19 16:23:15 +02:00
|
|
|
|
if layout == "freeform":
|
|
|
|
|
|
# Le freeform gère son fond lui-même selon sd["mode"]
|
|
|
|
|
|
if sd.get("mode", "light") == "light":
|
|
|
|
|
|
self._bg(slide, self.C["white"])
|
|
|
|
|
|
getattr(self, method)(slide, sd)
|
|
|
|
|
|
if sd.get("footer", True):
|
|
|
|
|
|
self._footer(slide, i + 1)
|
|
|
|
|
|
else:
|
|
|
|
|
|
mode = self.layouts.get(layout, {}).get("mode", "light")
|
|
|
|
|
|
if mode == "light":
|
|
|
|
|
|
self._bg(slide, self.C["white"])
|
|
|
|
|
|
getattr(self, method)(slide, sd)
|
|
|
|
|
|
if layout not in excluded and layout != "recommendation_card":
|
|
|
|
|
|
self._footer(slide, i + 1)
|
2026-07-09 21:53:52 +02:00
|
|
|
|
notes = sd.get("notes") if isinstance(sd, dict) else None
|
|
|
|
|
|
if notes:
|
|
|
|
|
|
slide.notes_slide.notes_text_frame.text = str(notes)
|
2026-06-15 22:09:49 +02:00
|
|
|
|
|
|
|
|
|
|
prs.save(output_path)
|
|
|
|
|
|
print(f"✓ PPTX généré : {output_path} ({len(slides)} slides)")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main():
|
|
|
|
|
|
import argparse
|
|
|
|
|
|
parser = argparse.ArgumentParser(
|
|
|
|
|
|
description="Sliding render_engine V2 — PR Editorial")
|
|
|
|
|
|
parser.add_argument("input_file", help="Fichier YAML/JSON de la présentation")
|
|
|
|
|
|
parser.add_argument("output", help="Chemin du PPTX à générer")
|
|
|
|
|
|
parser.add_argument("--theme", default="theme_v2.yaml")
|
|
|
|
|
|
parser.add_argument("--components", default="components_v2.yaml")
|
|
|
|
|
|
parser.add_argument("--layouts", default="layouts_v2.yaml")
|
2026-07-09 21:53:52 +02:00
|
|
|
|
parser.add_argument("--assets", default=None,
|
|
|
|
|
|
help="Dossier des images (défaut : <input>/../assets)")
|
2026-06-15 22:09:49 +02:00
|
|
|
|
args = parser.parse_args()
|
|
|
|
|
|
|
|
|
|
|
|
for f in [args.input_file, args.theme, args.components, args.layouts]:
|
|
|
|
|
|
if not os.path.exists(f):
|
|
|
|
|
|
print(f"✗ Fichier introuvable : {f}")
|
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
with open(args.input_file, encoding="utf-8") as f:
|
|
|
|
|
|
data = yaml.safe_load(f)
|
|
|
|
|
|
|
|
|
|
|
|
engine = RenderEngineV2(args.theme, args.layouts, args.components)
|
2026-07-09 21:53:52 +02:00
|
|
|
|
engine.assets_dir = args.assets or os.path.join(
|
|
|
|
|
|
os.path.dirname(os.path.abspath(args.input_file)),
|
|
|
|
|
|
"..", "assets")
|
2026-06-15 22:09:49 +02:00
|
|
|
|
engine.render(data, args.output)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
main()
|