From b938b1119a5a6bef42555d7e98bd2fc5c24452cb Mon Sep 17 00:00:00 2001 From: Master Date: Thu, 9 Jul 2026 21:53:52 +0200 Subject: [PATCH] feat: application des chantiers C1-C5 au moteur, facilitator et catalogue --- facilitator_v9.py | 286 +++++++++++++-- layouts_v2.yaml | 132 +++++++ prompt_injection_v2.py | 9 +- render_engine_v2.py | 802 ++++++++++++++++++++++++++++++++++++++++- 4 files changed, 1192 insertions(+), 37 deletions(-) diff --git a/facilitator_v9.py b/facilitator_v9.py index 1434e47..82d5cf3 100644 --- a/facilitator_v9.py +++ b/facilitator_v9.py @@ -63,12 +63,14 @@ API_KEY = os.getenv("MISTRAL_API_KEY") NARRATOR_ID = os.getenv("NARRATOR_AGENT_ID") DESIGNER_ID = os.getenv("DESIGNER_AGENT_ID") ENCODER_ID = os.getenv("ENCODER_AGENT_ID") +ENCODER_MODE = os.getenv("ENCODER_MODE", "agent") # agent | schema (C1) FREE_DESIGNER_ID = os.getenv("FREE_DESIGNER_AGENT_ID") PROJECTS_DIR = Path(os.getenv("PROJECTS_DIR", "./projets")) RENDER_ENGINE_PATH = os.getenv("RENDER_ENGINE_PATH", "render_engine_v2.py") THEME_PATH = os.getenv("THEME_PATH", "theme_v2.yaml") COMPONENTS_PATH = os.getenv("COMPONENTS_PATH", "components_v2.yaml") LAYOUTS_PATH = os.getenv("LAYOUTS_PATH", "layouts_v2.yaml") +PREVIEW_SCRIPT = os.getenv("PREVIEW_SCRIPT", "./preview.sh") # C2 CONTEXT_MAX_CHARS = int(os.getenv("CONTEXT_MAX_CHARS", "12000")) TRILIUM_API_URL = os.getenv("TRILIUM_API_URL", "") TRILIUM_API_KEY = os.getenv("TRILIUM_API_KEY", "") @@ -92,6 +94,7 @@ SLASH_CMDS = { "/formalise": "Transformer le plan compact validé en Markdown pour le Designer", "/valider": "Valider le Markdown formalisé → passer au Designer", "/plan": "Réafficher le dernier plan compact en entier", + "/afficher": "Réafficher la dernière réponse du Narrator en entier (sans troncature)", "/aide": "Afficher cette aide", "/quitter": "Abandonner et revenir au menu projet", } @@ -678,8 +681,13 @@ def run_narrator(session: AgentSession, proj: Project) -> Optional[str]: elif cmd == "/lire": context, new_files = load_documents(proj, loaded_docs) - if not new_files: - info("Aucun nouveau document dans inputs/.") + assets_info = list_assets(proj) + if assets_info: + context = ((context or "").strip() + + "\n\n" + assets_info).strip() + ok(f"{len(assets_info.splitlines()) - 1} image(s) dans assets/.") + if not new_files and not assets_info: + info("Aucun nouveau document dans inputs/ ni image dans assets/.") continue loaded_docs |= new_files if not session.started: @@ -718,6 +726,13 @@ def run_narrator(session: AgentSession, proj: Project) -> Optional[str]: warn("Aucun plan compact pour l'instant.") continue + elif cmd == "/afficher": + if last_response: + print("\n" + textwrap.indent(last_response, " ")) + else: + warn("Pas encore de réponse à afficher.") + continue + elif cmd == "/formalise": if not session.started: warn("Commence par établir le plan compact."); continue @@ -994,6 +1009,133 @@ def run_encoder(session: AgentSession, plan: str, layouts: dict, return None, None, None, attempts + +def run_encoder_schema(plan: str, layouts: dict, proj: "Project"): + """Encoder structuré (chantier C1) : chat/completions + json_schema + strict Mistral, slide par slide. Même contrat de retour que + run_encoder : (yaml_str, data, path, attempts) — attempts = nb de + slides en échec. Fallback automatique sur l'Encoder agent si le + module manque ou si aucune slide n'est encodée.""" + section("ÉTAPE 3 — THE ENCODER (structured outputs)") + try: + import encoder_schema as enc + except ImportError as e: + warn(f"encoder_schema.py indisponible ({e}) — bascule mode agent.") + return run_encoder(AgentSession(ENCODER_ID), plan, layouts, proj) + try: + import schemas as sch + for issue in (sch.verify_against_layouts(layouts) if layouts else []): + warn(f"Schéma vs layouts_v2 : {issue}") + except ImportError: + warn("schemas.py absent — vérification de cohérence sautée.") + info(f"Encodage slide par slide ({enc.DEFAULT_MODEL}, temp 0)...") + data, usage, errors = enc.encode_plan(plan, API_KEY, progress=info) + nb = len(data.get("slides", [])) + ok(f"{nb} slides encodées — tokens : {usage.get('total_tokens', 0)} " + f"(prompt {usage.get('prompt_tokens', 0)} / " + f"completion {usage.get('completion_tokens', 0)})") + for e in errors: + warn(e) + if not nb: + warn("Aucune slide encodée — bascule mode agent.") + return run_encoder(AgentSession(ENCODER_ID), plan, layouts, proj) + yaml_str = enc.to_yaml(data) + is_valid, message, _ = validate_yaml(yaml_str, layouts) + if is_valid: + ok(f"YAML valide : {message}") + else: + warn(f"Validation : {message}") + if errors: + print("\n [1] Continuer sans les slides en échec") + print(" [2] Basculer sur l'Encoder agent (deck complet)") + print(" [0] Abandonner") + choix = ask("Votre choix :") + if choix == "2": + return run_encoder(AgentSession(ENCODER_ID), plan, layouts, proj) + if choix == "0": + return None, None, None, len(errors) + path = save_text(yaml_str, proj.out("input", "yaml")) + return yaml_str, data, path, len(errors) + + + +def merge_revision(proj: "Project", partial_yaml_path: Path): + """Fusion YAML des révisions ciblées (chantier C3). + Remplace dans le dernier YAML complet les slides régénérées + (appariement par position ; positions inconnues ajoutées en fin). + Retourne le chemin du YAML complet fusionné, ou None si fusion + impossible (l'appelant conserve alors le flux partiel actuel).""" + state = proj.load_state() + last = state.get("dernier_yaml") or "" + if not last or not Path(last).exists(): + warn("Fusion : pas de YAML complet précédent — PPTX partiel " + "conservé.") + return None + try: + full = yaml.safe_load(Path(last).read_text(encoding="utf-8")) + part = yaml.safe_load( + partial_yaml_path.read_text(encoding="utf-8")) + except yaml.YAMLError as e: + warn(f"Fusion : YAML illisible ({e}).") + return None + if not isinstance(full, dict) or not full.get("slides"): + warn("Fusion : le YAML précédent ne contient pas de slides.") + return None + news = {} + for s in (part or {}).get("slides", []): + if isinstance(s, dict) and s.get("position"): + news[int(s["position"])] = s + if not news: + warn("Fusion : aucune slide positionnée dans la révision.") + return None + merged, replaced = [], 0 + for i, s in enumerate(full["slides"]): + pos = int(s.get("position", i + 1)) if isinstance(s, dict) else i + 1 + if pos in news: + merged.append(news.pop(pos)) + replaced += 1 + else: + merged.append(s) + for pos in sorted(news): + merged.append(news[pos]) + full["slides"] = merged + out = proj.out("revision_fusion", "yaml") + out.write_text( + yaml.safe_dump(full, allow_unicode=True, sort_keys=False, + default_flow_style=False, width=100), + encoding="utf-8") + ok(f"Fusion : {replaced} slide(s) remplacée(s), " + f"{len(merged)} au total → {out.name}") + return out + + + +def list_assets(proj: "Project"): + """Inventaire des images de projets//assets/ (chantier C4). + Crée le dossier au premier appel. Retourne un bloc texte destiné au + contexte Narrator, ou une chaîne vide si aucune image.""" + assets = proj.root / "assets" + assets.mkdir(exist_ok=True) + exts = {".png", ".jpg", ".jpeg", ".webp"} + files = sorted(p for p in assets.iterdir() + if p.suffix.lower() in exts and p.is_file()) + if not files: + return "" + lines = ["IMAGES DISPONIBLES DANS assets/ (utilisables dans les " + "layouts image_split / image_full et le bloc freeform " + "image, par leur nom de fichier) :"] + for p in files: + dims = "" + try: + from PIL import Image + with Image.open(p) as im: + dims = f" ({im.size[0]}×{im.size[1]})" + except Exception: + pass + lines.append(f"- {p.name}{dims}") + return "\n".join(lines) + + # ───────────────────────────────────────────────────────────────────────────── # FLUX LIBRE — THE FREE DESIGNER # ───────────────────────────────────────────────────────────────────────────── @@ -1001,7 +1143,7 @@ def run_encoder(session: AgentSession, plan: str, layouts: dict, VALID_TOKENS = {"navy", "navy_light", "coral", "glacier", "slate", "card", "white", "body", "muted"} VALID_BLOCK_TYPES = {"title", "heading", "text", "stat", "circle", - "badge", "card", "rect", "line"} + "badge", "card", "rect", "line", "image"} def validate_freeform(raw: str): @@ -1112,7 +1254,8 @@ def run_free_designer(session: AgentSession, markdown: str, proj: Project): # ÉTAPE 4 — RENDER ENGINE # ───────────────────────────────────────────────────────────────────────────── -def run_render(yaml_path: Path) -> Optional[Path]: +def run_render(yaml_path: Path, + assets_dir: Optional[Path] = None) -> Optional[Path]: section("ÉTAPE 4 — RENDER ENGINE V2") if not Path(RENDER_ENGINE_PATH).exists(): warn(f"{RENDER_ENGINE_PATH} introuvable.") @@ -1124,11 +1267,22 @@ def run_render(yaml_path: Path) -> Optional[Path]: pptx_out = yaml_path.with_suffix(".pptx") info("Lancement de render_engine_v2.py...") info(f"Sortie : {pptx_out}") - result = subprocess.run( - [sys.executable, RENDER_ENGINE_PATH, str(yaml_path), str(pptx_out), - "--theme", THEME_PATH, "--components", COMPONENTS_PATH, - "--layouts", LAYOUTS_PATH], - capture_output=True, text=True) + if assets_dir is None: + # Déduction depuis PROJECTS_DIR si le YAML y vit (C4b) + try: + rel = yaml_path.resolve().relative_to( + Path(PROJECTS_DIR).resolve()) + assets_dir = Path(PROJECTS_DIR) / rel.parts[0] / "assets" + except ValueError: + assets_dir = None # hors projet : défaut du moteur + cmd = [sys.executable, RENDER_ENGINE_PATH, str(yaml_path), + str(pptx_out), + "--theme", THEME_PATH, "--components", COMPONENTS_PATH, + "--layouts", LAYOUTS_PATH] + if assets_dir is not None: + cmd += ["--assets", str(assets_dir)] + info(f"Assets : {assets_dir}") + result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode == 0: ok(result.stdout.strip() or f"PPTX généré : {pptx_out}") return pptx_out @@ -1137,6 +1291,46 @@ def run_render(yaml_path: Path) -> Optional[Path]: return None + +def run_preview(pptx_path: Path, wait: bool = False) -> bool: + """Aperçus PNG par slide via preview.sh (chantier C2). + wait=False : lancement en arrière-plan (le DS218 est lent), sortie + consignée dans _preview.log. wait=True : bloquant (CLI).""" + script = Path(PREVIEW_SCRIPT).resolve() + if not script.exists(): + warn(f"{PREVIEW_SCRIPT} introuvable — aperçus indisponibles.") + return False + out_dir = pptx_path.parent / f"{pptx_path.stem}_previews" + if wait: + info("Génération des aperçus (quelques minutes sur le NAS)...") + r = subprocess.run([str(script), str(pptx_path)], + capture_output=True, text=True) + if r.returncode == 0: + ok(f"Aperçus : {out_dir}") + ok(f"Galerie : {out_dir / 'index.html'}") + return True + warn("Échec de la génération des aperçus :") + print(textwrap.indent((r.stderr or r.stdout or "?").strip(), + " ")) + return False + log = pptx_path.parent / f"{pptx_path.stem}_preview.log" + with open(log, "w", encoding="utf-8") as lf: + subprocess.Popen([str(script), str(pptx_path)], + stdout=lf, stderr=subprocess.STDOUT) + info(f"Aperçus en arrière-plan → {out_dir}") + info(f"Suivi : {log}") + return True + + +def maybe_preview(pptx_path) -> None: + """Propose la génération des aperçus après une sortie PPTX.""" + if not pptx_path or not Path(PREVIEW_SCRIPT).exists(): + return + if ask("Générer les aperçus PNG ? (o/N) :").lower() in ( + "o", "oui", "y", "yes"): + run_preview(pptx_path, wait=False) + + # ───────────────────────────────────────────────────────────────────────────── # ARCHIVAGE TRILIUM # ───────────────────────────────────────────────────────────────────────────── @@ -1212,9 +1406,13 @@ def run_full_pipeline_pass(narrator, markdown, layouts, proj, manifest, continue manifest.step("designer", caracteres=len(plan)) - encoder = AgentSession(ENCODER_ID) - yaml_str, yaml_data, yaml_path, tries = run_encoder( - encoder, plan, layouts, proj, scope_encoder) + if ENCODER_MODE == "schema": + yaml_str, yaml_data, yaml_path, tries = run_encoder_schema( + plan, layouts, proj) + else: + encoder = AgentSession(ENCODER_ID) + yaml_str, yaml_data, yaml_path, tries = run_encoder( + encoder, plan, layouts, proj, scope_encoder) manifest.step("encoder", tentatives_correction=tries) if yaml_str is None: info("Retour au Designer...") @@ -1226,8 +1424,15 @@ def run_full_pipeline_pass(narrator, markdown, layouts, proj, manifest, yaml_path.rename(new_path) yaml_path = new_path + merged = False + if suffix == "revision_ciblee" and yaml_path: + fused = merge_revision(proj, yaml_path) + if fused: + yaml_path, merged = fused, True + info("Rendu du deck COMPLET fusionné.") + manifest.file(yaml_path) - pptx_path = run_render(yaml_path) + pptx_path = run_render(yaml_path, proj.root / "assets") manifest.step("render", succes=bool(pptx_path)) # Persister l'état du projet (seulement en révision complète : # le ciblé ne représente pas l'état complet du deck) @@ -1237,9 +1442,15 @@ def run_full_pipeline_pass(narrator, markdown, layouts, proj, manifest, pptx_path=pptx_path, journal_entry="Révision structurante — deck complet régénéré.") elif suffix == "revision_ciblee": - persist_generation( - proj, markdown=markdown, - journal_entry="Révision ciblée — slides régénérées séparément.") + if merged: + persist_generation( + proj, markdown=markdown, yaml_path=yaml_path, + pptx_path=pptx_path, + journal_entry="Révision ciblée fusionnée — deck complet régénéré.") + else: + persist_generation( + proj, markdown=markdown, + journal_entry="Révision ciblée — slides régénérées séparément.") return pptx_path, markdown @@ -1304,6 +1515,8 @@ def run_revision(narrator, last_markdown, layouts, proj, manifest): section("SLIDES RÉVISÉES GÉNÉRÉES") ok(f"PPTX des slides révisées : {pptx_path}") info("Ouvre ce fichier et copie-colle les slides dans ton deck maître.") + info("(Si la fusion YAML a réussi — voir ci-dessus — le PPTX est déjà le deck complet.)") + maybe_preview(pptx_path) else: warn("Les slides révisées n'ont pas pu être générées.") return new_markdown @@ -1316,6 +1529,7 @@ def run_revision(narrator, last_markdown, layouts, proj, manifest): if pptx_path and pptx_path.exists(): section("DECK COMPLET RÉGÉNÉRÉ") ok(f"Nouveau PPTX complet : {pptx_path}") + maybe_preview(pptx_path) else: warn("Le deck n'a pas pu être régénéré.") return new_markdown @@ -1375,13 +1589,14 @@ def run_creation(proj, layouts): if yaml_path and yaml_data: manifest.file(yaml_path) - pptx_path = run_render(yaml_path) + pptx_path = run_render(yaml_path, proj.root / "assets") manifest.step("render", succes=bool(pptx_path)) if pptx_path and pptx_path.exists(): manifest.file(pptx_path) section("PRÉSENTATION GÉNÉRÉE (flux libre)") ok(f"Fichier PPTX : {pptx_path}") ok(f"Taille : {pptx_path.stat().st_size/1024:.1f} Ko") + maybe_preview(pptx_path) else: warn("Le PPTX n'a pas pu être généré.") persist_generation( @@ -1420,9 +1635,15 @@ def run_creation(proj, layouts): continue manifest.step("designer", caracteres=len(plan)) - encoder = AgentSession(ENCODER_ID) - yaml_str, yaml_data, yaml_path, tries = run_encoder( - encoder, plan, layouts, proj) + if ENCODER_MODE == "schema" and not express: + yaml_str, yaml_data, yaml_path, tries = run_encoder_schema( + plan, layouts, proj) + else: + if ENCODER_MODE == "schema" and express: + info("Mode express → Encoder agent (plan non annoté).") + encoder = AgentSession(ENCODER_ID) + yaml_str, yaml_data, yaml_path, tries = run_encoder( + encoder, plan, layouts, proj) manifest.step("encoder", tentatives_correction=tries) if yaml_str is None: if express: @@ -1434,13 +1655,14 @@ def run_creation(proj, layouts): if yaml_path and yaml_data: manifest.file(yaml_path) - pptx_path = run_render(yaml_path) + pptx_path = run_render(yaml_path, proj.root / "assets") manifest.step("render", succes=bool(pptx_path)) if pptx_path and pptx_path.exists(): manifest.file(pptx_path) section("PRÉSENTATION GÉNÉRÉE") ok(f"Fichier PPTX : {pptx_path}") ok(f"Taille : {pptx_path.stat().st_size/1024:.1f} Ko") + maybe_preview(pptx_path) else: warn("Le PPTX n'a pas pu être généré.") info(f"Le YAML est dans : {proj.outputs}") @@ -1499,6 +1721,15 @@ def run_reprise(proj, layouts): reprime_narrator(narrator, proj) # réamorçage avec contexte proj.log("Reprise du projet pour révision.") markdown = state.get("dernier_markdown", "") + # On est déjà en révision (choix explicite ci-dessus) : on lance + # directement la session de travail, sans repasser par un menu + # "veux-tu réviser ?" redondant. + markdown = run_revision(narrator, markdown, layouts, proj, manifest) + manifest.save( + proj.outputs / + f"{proj.slug}_{datetime.now():%Y%m%d_%H%M%S}_manifest.json") + # Boucle de suite : une fois cette révision faite, proposer d'itérer + # encore ou de terminer — ce menu-ci n'est pas redondant. revision_loop(narrator, markdown, layouts, proj, manifest) elif choix == "2": @@ -1570,8 +1801,17 @@ def main(): description="Sliding facilitator v8 — flux libre (Free Designer)") parser.add_argument("--render", metavar="YAML", help="Rendu direct d'un YAML existant, sans agents") + parser.add_argument("--preview", metavar="PPTX", + help="Aperçus PNG d'un PPTX existant (C2)") args = parser.parse_args() + if args.preview: + p = Path(args.preview) + if not p.exists(): + warn(f"Fichier introuvable : {p}") + sys.exit(1) + sys.exit(0 if run_preview(p, wait=True) else 1) + if args.render: yaml_path = Path(args.render) if not yaml_path.exists(): @@ -1581,7 +1821,9 @@ def main(): is_valid, message, _ = validate_yaml( yaml_path.read_text(encoding="utf-8"), layouts) (ok if is_valid else warn)(f"Validation : {message}") - sys.exit(0 if run_render(yaml_path) else 1) + local_assets = yaml_path.parent / "assets" + assets_dir = local_assets if local_assets.is_dir() else None + sys.exit(0 if run_render(yaml_path, assets_dir) else 1) run_pipeline() diff --git a/layouts_v2.yaml b/layouts_v2.yaml index 28a5096..49be874 100644 --- a/layouts_v2.yaml +++ b/layouts_v2.yaml @@ -227,3 +227,135 @@ layouts: items = [{label, x, y}] avec x/y de 0 à 100. Max 8 items. champs: [titre, axis_x, axis_y, quadrants, items] champs_requis: [titre, items] + + # ── Chantier C4 — layouts images ────────────────────────────────── + image_split: + id: L50 + famille: Visuel + mode: light + champs: [titre, image, bullets, side, legende] + champs_requis: [titre, image, bullets] + agent_hint: >- + Image d'appui sur 40 % de la slide (side: left par défaut, right + possible) + titre et points clés (max 4 bullets). À utiliser quand + une image du dossier assets/ du projet illustre le propos ; + image = nom de fichier exact tel que listé par /lire. + legende (optionnelle) s'affiche sur un bandeau navy sous l'image. + + image_full: + id: L51 + famille: Visuel + mode: dark + champs: [titre, image, sous_titre] + champs_requis: [titre, image] + agent_hint: >- + Ouverture de chapitre visuelle : image plein cadre + voile navy + + titre display blanc. Alternative à section_divider quand un asset + du projet s'y prête. image = nom de fichier exact de assets/. + + # ── Chantier C5 lot 1 — charts natifs (éditables dans PowerPoint) ── + bar_chart: + id: L42 + famille: Données + mode: light + champs: [titre, categories, series, unite, source, horizontal] + champs_requis: [titre, categories, series] + agent_hint: >- + Comparaison de valeurs par catégories — graphique NATIF éditable. + Max 8 catégories × 3 séries. series = liste de {label, values} ; + values = nombres SANS guillemets, alignés sur categories. + horizontal: true pour des barres (libellés longs). unite (ex M€) + et source optionnels. Couleurs imposées : navy, coral, glacier. + + line_chart: + id: L43 + famille: Données + mode: light + champs: [titre, points_x, series, unite, source] + champs_requis: [titre, points_x, series] + agent_hint: >- + Évolution temporelle — graphique NATIF éditable. Max 12 points × + 3 séries. points_x = libellés d'axe (mois, années...) ; series = + {label, values}, nombres sans guillemets. Le dernier point de la + première série est automatiquement mis en valeur (corail). + + donut_split: + id: L44 + famille: Données + mode: light + champs: [titre, segments, valeur_centrale, source] + champs_requis: [titre, segments] + agent_hint: >- + Répartition d'un tout : anneau à gauche + légende détaillée à + droite. 2 à 6 segments = {label, valeur} (nombres sans + guillemets). valeur_centrale (optionnelle) s'affiche au centre de + l'anneau (ex : total « 120 M€ »). Couleurs = cycle PR imposé. + + # ── Chantier C5 lot 2 — data & structure ────────────────────────── + waterfall: + id: L45 + famille: Données + mode: light + champs: [titre, depart, marches, arrivee, unite, source] + champs_requis: [titre, depart, marches, arrivee] + agent_hint: >- + Pont de valeur (waterfall) : expliquer un écart entre deux + montants par des variations successives. depart et arrivee = + {label, valeur} ; marches = liste de {label, delta} SIGNÉ + (positif ou négatif, max 8). Le moteur calcule les cumuls — + ne jamais fournir de cumul. Idéal pour : évolution de budget, + pont d'effectifs, décomposition d'un résultat. + + heatmap_table: + id: L46 + famille: Comparaison + mode: light + champs: [titre, headers, rows, legende] + champs_requis: [titre, headers, rows] + agent_hint: >- + Tableau à intensité : évaluer plusieurs items sur plusieurs + critères. headers = colonnes (max 6) ; rows = {label, + scores} avec score ENTIER de 0 (faible) à 4 (fort), max + 8 lignes. Le moteur traduit chaque score en teinte de navy — + aucune autre donnée. Idéal pour : maturité, couverture + fonctionnelle, cartographie de risques. + + funnel: + id: L47 + famille: Process + mode: light + champs: [titre, etapes, source] + champs_requis: [titre, etapes] + agent_hint: >- + Entonnoir de conversion : volumes décroissants d'étape en + étape. etapes = {label, valeur, description?} du haut vers le + bas, 3 à 5 étages. Largeurs proportionnelles aux valeurs + (plancher de lisibilité), dernier étage corail. Idéal pour : + pipeline commercial, adoption, qualification progressive. + + # ── Chantier C5 lot 3 — retours v1 restylés ─────────────────────── + agenda: + id: L40 + famille: Structure + mode: light + champs: [titre, sections] + champs_requis: [titre, sections] + agent_hint: >- + Sommaire du deck : à placer en slide 2 pour toute présentation + de plus de 15 minutes. sections = liste de {label, numero?, + duree?, actif?}, 2 à 8 entrées. actif: true met la section en + corail (utile pour les rappels d'agenda en cours de deck). + duree (ex "10 min") s'affiche à droite en discret. + + pyramid: + id: L41 + famille: Concept + mode: light + champs: [titre, niveaux] + champs_requis: [titre, niveaux] + agent_hint: >- + Argumentation pyramidale : la conclusion au sommet, les + fondations à la base. niveaux = liste de {label, description?}, + EXACTEMENT 3 ou 4 niveaux, du sommet vers la base. Largeurs + d'étages fixes gérées par le moteur. Idéal pour : message clé + et ses appuis, hiérarchie stratégie/tactiques/moyens. diff --git a/prompt_injection_v2.py b/prompt_injection_v2.py index 7522421..1578472 100644 --- a/prompt_injection_v2.py +++ b/prompt_injection_v2.py @@ -389,7 +389,7 @@ def main(): parser = argparse.ArgumentParser( description="Injection des contraintes v2 dans les prompts agents") parser.add_argument("--layouts", default="layouts_v2.yaml") - parser.add_argument("--designer", default="prompt_the_designer_v2.md") + parser.add_argument("--designer", default="prompt_the_designer_v3.md") parser.add_argument("--encoder", default="prompt_the_encoder_v2.md") args = parser.parse_args() @@ -417,12 +417,15 @@ def main(): Path("agent_constraints_v2.md").write_text(constraints, encoding="utf-8") print(" + Généré : agent_constraints_v2.md") - # 2. Designer injecté + # 2. Designer injecté — nom de sortie dérivé du template + # (prompt_the_designer_v3.md → prompt_the_designer_injected_v3.md) + designer_out = Path(args.designer).name.replace( + "_v3.md", "_injected_v3.md").replace("_v2.md", "_injected_v2.md") inject(Path(args.designer), { "{{LAYOUTS_CATALOGUE}}": catalogue, "{{SEQUENCING_RULES}}": SEQUENCING_RULES, "{{NARRATIVE_PATTERNS}}": NARRATIVE_PATTERNS, - }, Path("prompt_the_designer_injected_v2.md")) + }, Path(designer_out)) # 3. Encoder injecté inject(Path(args.encoder), { diff --git a/render_engine_v2.py b/render_engine_v2.py index 7c09e20..d67b72c 100644 --- a/render_engine_v2.py +++ b/render_engine_v2.py @@ -26,10 +26,18 @@ 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 # ---------------------------------------------------------------- @@ -38,6 +46,54 @@ def hex_to_rgb(h: str) -> RGBColor: return RGBColor(int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)) +def _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'}), @@ -63,10 +119,19 @@ def as_label(item, *keys, default=""): return default -def estimate_text_height(text: str, size_pt: int, width_cm: float) -> float: - """Hauteur estimée d'un texte (cm) pour une largeur donnée.""" +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 @@ -75,6 +140,30 @@ def estimate_text_height(text: str, size_pt: int, width_cm: float) -> float: 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 # ---------------------------------------------------------------- @@ -124,7 +213,20 @@ class RenderEngineV2: 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): + 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 @@ -282,11 +384,17 @@ class RenderEngineV2: 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) - self._text(slide, self.MX + 3.81, y + 0.56, 8.1, 1.0, label, - size=14, bold=True, color=col, char_spacing=3) - self._text(slide, self.MX + 3.81, y + 1.57, - self.SLIDE_W - 2 * self.MX - 5.33, 1.8, txt, - size=self.T["body"], color=self.C["body"]) + # 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): @@ -309,16 +417,21 @@ class RenderEngineV2: val = pick(d, "valeur", "stat", "chiffre", "value") desc = pick(d, "description", "texte", "label") src = pick(d, "source", "reference") - block = 8.13 + # 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, 5.1, val, + 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) - self._text(slide, 6.86, y + 5.33, self.SLIDE_W - 13.72, 2.0, desc, + 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: - self._text(slide, 6.86, y + 7.37, self.SLIDE_W - 13.72, 0.9, 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) @@ -1222,6 +1335,11 @@ class RenderEngineV2: 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, @@ -1268,6 +1386,647 @@ class RenderEngineV2: 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", @@ -1292,11 +2051,22 @@ class RenderEngineV2: "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() @@ -1329,6 +2099,9 @@ class RenderEngineV2: 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)") @@ -1343,6 +2116,8 @@ def main(): 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]: @@ -1354,6 +2129,9 @@ def main(): 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)