#!/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 from pptx.enum.dml import MSO_LINE_DASH_STYLE as MSO_LINE from pptx.enum.text import MSO_ANCHOR, PP_ALIGN from pptx.oxml.ns import qn from pptx.util import Cm, Emu, Pt try: import measure HAS_MEASURE = True except ImportError: HAS_MEASURE = False FIT_TEXT = os.getenv("FIT_TEXT", "1") != "0" # C3 # ---------------------------------------------------------------- # 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)) 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 def pick(d, *keys, default=""): """Accès tolérant : première clé non vide trouvée. Si d est une chaîne (l'agent a produit 'Texte' au lieu de {label:'Texte'}), on la retourne directement — évite les crashs 'str has no attribute get'.""" if isinstance(d, str): return d if not isinstance(d, dict): return default for k in keys: v = d.get(k) if v not in (None, ""): return v return default def as_label(item, *keys, default=""): """Normalise un élément de liste en texte : 'Texte' ou {label:'Texte'}.""" if isinstance(item, str): return item if isinstance(item, dict): return pick(item, *(keys or ("label", "texte", "titre", "title")), default=default) return default def estimate_text_height(text: str, size_pt: int, width_cm: float, 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.""" if not text: return 0.0 if HAS_MEASURE: try: return measure.text_height_cm(str(text), font_name, size_pt, width_cm, bold) except Exception: pass 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 _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 # ---------------------------------------------------------------- # 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, 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 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) # 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) 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") # 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) y = self._cy(block) self._text(slide, 0, y, self.SLIDE_W, H_VAL, val, font=self.F_DISPLAY, size=self.T["stat_hero"], bold=True, color=self.C["coral"], align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE) y_desc = y + H_VAL + GAP_D self._text(slide, 6.86, y_desc, self.SLIDE_W - 13.72, H_DESC, desc, size=18, color=self.C["body"], align=PP_ALIGN.CENTER) if src: y_src = y_desc + H_DESC + GAP_S self._text(slide, 6.86, y_src, self.SLIDE_W - 13.72, H_SRC, src, 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): if isinstance(st, str): st = {"titre": st} self._card(slide, self.MX, y, self.SLIDE_W - 2 * self.MX, rh) self._badge(slide, self.MX + 1.02 + bd / 2, y + rh / 2, bd, st.get("numero", i + 1), font_size=28) 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 n_tasks = sum(len(ws.get("tasks", [])) for ws in workstreams if isinstance(ws, dict)) total_h = HDR_H + len(workstreams)*WS_H + n_tasks*TASK_H y0 = self._cy(total_h) gx = self.MX + LBL_W + GAP_COL # grid x origin # 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): if not isinstance(ws, dict): continue ws_col = self.cycle[ws_idx % len(self.cycle)] # Workstream row self._rect(slide, self.MX, cur_y, self.SLIDE_W - 2*self.MX, WS_H, "#EEECEA") 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", []): if not isinstance(task, dict): continue 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): if not isinstance(task, dict): continue ry = y + HDR_H + i * ROW_H bg = self.C["card"] if i % 2 == 0 else self.C["white"] self._rect(slide, self.MX, ry, self.SLIDE_W - 2*self.MX, ROW_H, bg) 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")) def _node(n): """Normalise un nœud : 'Texte' → {label:'Texte'}, dict inchangé.""" if isinstance(n, str): return {"label": n, "children": []} if isinstance(n, dict): return {"label": pick(n, "label", "titre", "title", "nom"), "children": n.get("children", []) or []} return {"label": str(n), "children": []} root = d.get("root", {}) if isinstance(root, str): root = {"label": root, "children": []} if not root: return root = _node(root) BOX_W, BOX_H = 3.81, 1.0 GAP_V, GAP_H = 1.27, 0.64 children = [_node(c) for c in root.get("children", [])] n_children = len(children) # grandchildren count per child (max) max_gd = max((len(c.get("children", [])) for c in children), default=0) # 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 grandchildren = [_node(g) for g in child.get("children", [])] 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): if not isinstance(item, dict): continue ix = item.get("x", 50) # 0-100 iy = item.get("y", 50) # 0-100 px = mx0 + (ix / 100) * MAT_W 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, as_label(item), size=10, color=self.C["body"]) # ── 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) def _token_color(self, token: str, default: str = None) -> str: 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"], "white": self.C["white"], "body": self.C["body"], "muted": self.C["muted"], } return mapping.get((token or "").strip().lower(), default or self.C["navy"]) 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 def _render_freeform(self, slide, d): mode = d.get("mode", "light") on_dark = (mode == "dark") if on_dark: self._bg(slide, self.C["navy"]) default_text = self.C["white"] if on_dark else self.C["body"] for blk in d.get("blocks", []): btype = (blk.get("type") or "text").lower() col = float(blk.get("col", 0)) row = float(blk.get("row", 0)) w_cols = float(blk.get("w", 4)) h_rows = float(blk.get("h", 1)) x, y = self._free_x(col), self._free_y(row) w, h = self._free_w(w_cols), self._free_h(h_rows) if btype == "rect": self._rect(slide, x, y, w, h, self._token_color(blk.get("color"), self.C["card"]), rounded=blk.get("rounded", False)) elif btype == "card": self._card(slide, x, y, w, h, self._token_color(blk.get("color"), self.C["card"])) elif btype == "circle": d_cm = min(w, h) self._oval(slide, x, y, d_cm, self._token_color(blk.get("color"), self.C["coral"])) elif btype == "line": ln = slide.shapes.add_connector( 1, Cm(x), Cm(y), Cm(x + w), Cm(y + h)) ln.line.color.rgb = hex_to_rgb( self._token_color(blk.get("color"), self.C["muted"])) ln.line.width = Pt(float(blk.get("weight", 1.25))) elif btype == "image": self._image(slide, x, y, w, h, blk.get("image") or blk.get("src", ""), fit=str(blk.get("fit") or "cover")) elif btype == "badge": d_cm = min(w, h) 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"])) elif btype == "stat": # Grand chiffre — display, corail par défaut 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) elif btype in ("title", "heading"): 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) else: # text # Police body, ou display si explicitement demandé font = self.F_DISPLAY if blk.get("serif") else self.F_BODY 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) 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) # ---------------- 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 # 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 # ---------------- 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", "freeform": "_render_freeform", "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", "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", } def render(self, data, output_path: str): if isinstance(data, str): data = yaml.safe_load(data) data = strip_markdown_tree(data) 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" 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) notes = sd.get("notes") if isinstance(sd, dict) else None if notes: slide.notes_slide.notes_text_frame.text = str(notes) 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") parser.add_argument("--assets", default=None, help="Dossier des images (défaut : /../assets)") 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) engine.assets_dir = args.assets or os.path.join( os.path.dirname(os.path.abspath(args.input_file)), "..", "assets") engine.render(data, args.output) if __name__ == "__main__": main()