feat: application des chantiers C1-C5 au moteur, facilitator et catalogue
This commit is contained in:
+264
-22
@@ -63,12 +63,14 @@ API_KEY = os.getenv("MISTRAL_API_KEY")
|
|||||||
NARRATOR_ID = os.getenv("NARRATOR_AGENT_ID")
|
NARRATOR_ID = os.getenv("NARRATOR_AGENT_ID")
|
||||||
DESIGNER_ID = os.getenv("DESIGNER_AGENT_ID")
|
DESIGNER_ID = os.getenv("DESIGNER_AGENT_ID")
|
||||||
ENCODER_ID = os.getenv("ENCODER_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")
|
FREE_DESIGNER_ID = os.getenv("FREE_DESIGNER_AGENT_ID")
|
||||||
PROJECTS_DIR = Path(os.getenv("PROJECTS_DIR", "./projets"))
|
PROJECTS_DIR = Path(os.getenv("PROJECTS_DIR", "./projets"))
|
||||||
RENDER_ENGINE_PATH = os.getenv("RENDER_ENGINE_PATH", "render_engine_v2.py")
|
RENDER_ENGINE_PATH = os.getenv("RENDER_ENGINE_PATH", "render_engine_v2.py")
|
||||||
THEME_PATH = os.getenv("THEME_PATH", "theme_v2.yaml")
|
THEME_PATH = os.getenv("THEME_PATH", "theme_v2.yaml")
|
||||||
COMPONENTS_PATH = os.getenv("COMPONENTS_PATH", "components_v2.yaml")
|
COMPONENTS_PATH = os.getenv("COMPONENTS_PATH", "components_v2.yaml")
|
||||||
LAYOUTS_PATH = os.getenv("LAYOUTS_PATH", "layouts_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"))
|
CONTEXT_MAX_CHARS = int(os.getenv("CONTEXT_MAX_CHARS", "12000"))
|
||||||
TRILIUM_API_URL = os.getenv("TRILIUM_API_URL", "")
|
TRILIUM_API_URL = os.getenv("TRILIUM_API_URL", "")
|
||||||
TRILIUM_API_KEY = os.getenv("TRILIUM_API_KEY", "")
|
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",
|
"/formalise": "Transformer le plan compact validé en Markdown pour le Designer",
|
||||||
"/valider": "Valider le Markdown formalisé → passer au Designer",
|
"/valider": "Valider le Markdown formalisé → passer au Designer",
|
||||||
"/plan": "Réafficher le dernier plan compact en entier",
|
"/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",
|
"/aide": "Afficher cette aide",
|
||||||
"/quitter": "Abandonner et revenir au menu projet",
|
"/quitter": "Abandonner et revenir au menu projet",
|
||||||
}
|
}
|
||||||
@@ -678,8 +681,13 @@ def run_narrator(session: AgentSession, proj: Project) -> Optional[str]:
|
|||||||
|
|
||||||
elif cmd == "/lire":
|
elif cmd == "/lire":
|
||||||
context, new_files = load_documents(proj, loaded_docs)
|
context, new_files = load_documents(proj, loaded_docs)
|
||||||
if not new_files:
|
assets_info = list_assets(proj)
|
||||||
info("Aucun nouveau document dans inputs/.")
|
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
|
continue
|
||||||
loaded_docs |= new_files
|
loaded_docs |= new_files
|
||||||
if not session.started:
|
if not session.started:
|
||||||
@@ -718,6 +726,13 @@ def run_narrator(session: AgentSession, proj: Project) -> Optional[str]:
|
|||||||
warn("Aucun plan compact pour l'instant.")
|
warn("Aucun plan compact pour l'instant.")
|
||||||
continue
|
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":
|
elif cmd == "/formalise":
|
||||||
if not session.started:
|
if not session.started:
|
||||||
warn("Commence par établir le plan compact."); continue
|
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
|
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/<slug>/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
|
# 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",
|
VALID_TOKENS = {"navy", "navy_light", "coral", "glacier", "slate",
|
||||||
"card", "white", "body", "muted"}
|
"card", "white", "body", "muted"}
|
||||||
VALID_BLOCK_TYPES = {"title", "heading", "text", "stat", "circle",
|
VALID_BLOCK_TYPES = {"title", "heading", "text", "stat", "circle",
|
||||||
"badge", "card", "rect", "line"}
|
"badge", "card", "rect", "line", "image"}
|
||||||
|
|
||||||
|
|
||||||
def validate_freeform(raw: str):
|
def validate_freeform(raw: str):
|
||||||
@@ -1112,7 +1254,8 @@ def run_free_designer(session: AgentSession, markdown: str, proj: Project):
|
|||||||
# ÉTAPE 4 — RENDER ENGINE
|
# É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")
|
section("ÉTAPE 4 — RENDER ENGINE V2")
|
||||||
if not Path(RENDER_ENGINE_PATH).exists():
|
if not Path(RENDER_ENGINE_PATH).exists():
|
||||||
warn(f"{RENDER_ENGINE_PATH} introuvable.")
|
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")
|
pptx_out = yaml_path.with_suffix(".pptx")
|
||||||
info("Lancement de render_engine_v2.py...")
|
info("Lancement de render_engine_v2.py...")
|
||||||
info(f"Sortie : {pptx_out}")
|
info(f"Sortie : {pptx_out}")
|
||||||
result = subprocess.run(
|
if assets_dir is None:
|
||||||
[sys.executable, RENDER_ENGINE_PATH, str(yaml_path), str(pptx_out),
|
# Déduction depuis PROJECTS_DIR si le YAML y vit (C4b)
|
||||||
"--theme", THEME_PATH, "--components", COMPONENTS_PATH,
|
try:
|
||||||
"--layouts", LAYOUTS_PATH],
|
rel = yaml_path.resolve().relative_to(
|
||||||
capture_output=True, text=True)
|
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:
|
if result.returncode == 0:
|
||||||
ok(result.stdout.strip() or f"PPTX généré : {pptx_out}")
|
ok(result.stdout.strip() or f"PPTX généré : {pptx_out}")
|
||||||
return pptx_out
|
return pptx_out
|
||||||
@@ -1137,6 +1291,46 @@ def run_render(yaml_path: Path) -> Optional[Path]:
|
|||||||
return None
|
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 <pptx>_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
|
# ARCHIVAGE TRILIUM
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -1212,9 +1406,13 @@ def run_full_pipeline_pass(narrator, markdown, layouts, proj, manifest,
|
|||||||
continue
|
continue
|
||||||
manifest.step("designer", caracteres=len(plan))
|
manifest.step("designer", caracteres=len(plan))
|
||||||
|
|
||||||
encoder = AgentSession(ENCODER_ID)
|
if ENCODER_MODE == "schema":
|
||||||
yaml_str, yaml_data, yaml_path, tries = run_encoder(
|
yaml_str, yaml_data, yaml_path, tries = run_encoder_schema(
|
||||||
encoder, plan, layouts, proj, scope_encoder)
|
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)
|
manifest.step("encoder", tentatives_correction=tries)
|
||||||
if yaml_str is None:
|
if yaml_str is None:
|
||||||
info("Retour au Designer...")
|
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.rename(new_path)
|
||||||
yaml_path = 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)
|
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))
|
manifest.step("render", succes=bool(pptx_path))
|
||||||
# Persister l'état du projet (seulement en révision complète :
|
# Persister l'état du projet (seulement en révision complète :
|
||||||
# le ciblé ne représente pas l'état complet du deck)
|
# 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,
|
pptx_path=pptx_path,
|
||||||
journal_entry="Révision structurante — deck complet régénéré.")
|
journal_entry="Révision structurante — deck complet régénéré.")
|
||||||
elif suffix == "revision_ciblee":
|
elif suffix == "revision_ciblee":
|
||||||
persist_generation(
|
if merged:
|
||||||
proj, markdown=markdown,
|
persist_generation(
|
||||||
journal_entry="Révision ciblée — slides régénérées séparément.")
|
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
|
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")
|
section("SLIDES RÉVISÉES GÉNÉRÉES")
|
||||||
ok(f"PPTX des slides révisées : {pptx_path}")
|
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("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:
|
else:
|
||||||
warn("Les slides révisées n'ont pas pu être générées.")
|
warn("Les slides révisées n'ont pas pu être générées.")
|
||||||
return new_markdown
|
return new_markdown
|
||||||
@@ -1316,6 +1529,7 @@ def run_revision(narrator, last_markdown, layouts, proj, manifest):
|
|||||||
if pptx_path and pptx_path.exists():
|
if pptx_path and pptx_path.exists():
|
||||||
section("DECK COMPLET RÉGÉNÉRÉ")
|
section("DECK COMPLET RÉGÉNÉRÉ")
|
||||||
ok(f"Nouveau PPTX complet : {pptx_path}")
|
ok(f"Nouveau PPTX complet : {pptx_path}")
|
||||||
|
maybe_preview(pptx_path)
|
||||||
else:
|
else:
|
||||||
warn("Le deck n'a pas pu être régénéré.")
|
warn("Le deck n'a pas pu être régénéré.")
|
||||||
return new_markdown
|
return new_markdown
|
||||||
@@ -1375,13 +1589,14 @@ def run_creation(proj, layouts):
|
|||||||
|
|
||||||
if yaml_path and yaml_data:
|
if yaml_path and yaml_data:
|
||||||
manifest.file(yaml_path)
|
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))
|
manifest.step("render", succes=bool(pptx_path))
|
||||||
if pptx_path and pptx_path.exists():
|
if pptx_path and pptx_path.exists():
|
||||||
manifest.file(pptx_path)
|
manifest.file(pptx_path)
|
||||||
section("PRÉSENTATION GÉNÉRÉE (flux libre)")
|
section("PRÉSENTATION GÉNÉRÉE (flux libre)")
|
||||||
ok(f"Fichier PPTX : {pptx_path}")
|
ok(f"Fichier PPTX : {pptx_path}")
|
||||||
ok(f"Taille : {pptx_path.stat().st_size/1024:.1f} Ko")
|
ok(f"Taille : {pptx_path.stat().st_size/1024:.1f} Ko")
|
||||||
|
maybe_preview(pptx_path)
|
||||||
else:
|
else:
|
||||||
warn("Le PPTX n'a pas pu être généré.")
|
warn("Le PPTX n'a pas pu être généré.")
|
||||||
persist_generation(
|
persist_generation(
|
||||||
@@ -1420,9 +1635,15 @@ def run_creation(proj, layouts):
|
|||||||
continue
|
continue
|
||||||
manifest.step("designer", caracteres=len(plan))
|
manifest.step("designer", caracteres=len(plan))
|
||||||
|
|
||||||
encoder = AgentSession(ENCODER_ID)
|
if ENCODER_MODE == "schema" and not express:
|
||||||
yaml_str, yaml_data, yaml_path, tries = run_encoder(
|
yaml_str, yaml_data, yaml_path, tries = run_encoder_schema(
|
||||||
encoder, plan, layouts, proj)
|
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)
|
manifest.step("encoder", tentatives_correction=tries)
|
||||||
if yaml_str is None:
|
if yaml_str is None:
|
||||||
if express:
|
if express:
|
||||||
@@ -1434,13 +1655,14 @@ def run_creation(proj, layouts):
|
|||||||
|
|
||||||
if yaml_path and yaml_data:
|
if yaml_path and yaml_data:
|
||||||
manifest.file(yaml_path)
|
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))
|
manifest.step("render", succes=bool(pptx_path))
|
||||||
if pptx_path and pptx_path.exists():
|
if pptx_path and pptx_path.exists():
|
||||||
manifest.file(pptx_path)
|
manifest.file(pptx_path)
|
||||||
section("PRÉSENTATION GÉNÉRÉE")
|
section("PRÉSENTATION GÉNÉRÉE")
|
||||||
ok(f"Fichier PPTX : {pptx_path}")
|
ok(f"Fichier PPTX : {pptx_path}")
|
||||||
ok(f"Taille : {pptx_path.stat().st_size/1024:.1f} Ko")
|
ok(f"Taille : {pptx_path.stat().st_size/1024:.1f} Ko")
|
||||||
|
maybe_preview(pptx_path)
|
||||||
else:
|
else:
|
||||||
warn("Le PPTX n'a pas pu être généré.")
|
warn("Le PPTX n'a pas pu être généré.")
|
||||||
info(f"Le YAML est dans : {proj.outputs}")
|
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
|
reprime_narrator(narrator, proj) # réamorçage avec contexte
|
||||||
proj.log("Reprise du projet pour révision.")
|
proj.log("Reprise du projet pour révision.")
|
||||||
markdown = state.get("dernier_markdown", "")
|
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)
|
revision_loop(narrator, markdown, layouts, proj, manifest)
|
||||||
|
|
||||||
elif choix == "2":
|
elif choix == "2":
|
||||||
@@ -1570,8 +1801,17 @@ def main():
|
|||||||
description="Sliding facilitator v8 — flux libre (Free Designer)")
|
description="Sliding facilitator v8 — flux libre (Free Designer)")
|
||||||
parser.add_argument("--render", metavar="YAML",
|
parser.add_argument("--render", metavar="YAML",
|
||||||
help="Rendu direct d'un YAML existant, sans agents")
|
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()
|
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:
|
if args.render:
|
||||||
yaml_path = Path(args.render)
|
yaml_path = Path(args.render)
|
||||||
if not yaml_path.exists():
|
if not yaml_path.exists():
|
||||||
@@ -1581,7 +1821,9 @@ def main():
|
|||||||
is_valid, message, _ = validate_yaml(
|
is_valid, message, _ = validate_yaml(
|
||||||
yaml_path.read_text(encoding="utf-8"), layouts)
|
yaml_path.read_text(encoding="utf-8"), layouts)
|
||||||
(ok if is_valid else warn)(f"Validation : {message}")
|
(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()
|
run_pipeline()
|
||||||
|
|
||||||
|
|||||||
+132
@@ -227,3 +227,135 @@ layouts:
|
|||||||
items = [{label, x, y}] avec x/y de 0 à 100. Max 8 items.
|
items = [{label, x, y}] avec x/y de 0 à 100. Max 8 items.
|
||||||
champs: [titre, axis_x, axis_y, quadrants, items]
|
champs: [titre, axis_x, axis_y, quadrants, items]
|
||||||
champs_requis: [titre, 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.
|
||||||
|
|||||||
@@ -389,7 +389,7 @@ def main():
|
|||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Injection des contraintes v2 dans les prompts agents")
|
description="Injection des contraintes v2 dans les prompts agents")
|
||||||
parser.add_argument("--layouts", default="layouts_v2.yaml")
|
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")
|
parser.add_argument("--encoder", default="prompt_the_encoder_v2.md")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -417,12 +417,15 @@ def main():
|
|||||||
Path("agent_constraints_v2.md").write_text(constraints, encoding="utf-8")
|
Path("agent_constraints_v2.md").write_text(constraints, encoding="utf-8")
|
||||||
print(" + Généré : agent_constraints_v2.md")
|
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), {
|
inject(Path(args.designer), {
|
||||||
"{{LAYOUTS_CATALOGUE}}": catalogue,
|
"{{LAYOUTS_CATALOGUE}}": catalogue,
|
||||||
"{{SEQUENCING_RULES}}": SEQUENCING_RULES,
|
"{{SEQUENCING_RULES}}": SEQUENCING_RULES,
|
||||||
"{{NARRATIVE_PATTERNS}}": NARRATIVE_PATTERNS,
|
"{{NARRATIVE_PATTERNS}}": NARRATIVE_PATTERNS,
|
||||||
}, Path("prompt_the_designer_injected_v2.md"))
|
}, Path(designer_out))
|
||||||
|
|
||||||
# 3. Encoder injecté
|
# 3. Encoder injecté
|
||||||
inject(Path(args.encoder), {
|
inject(Path(args.encoder), {
|
||||||
|
|||||||
+790
-12
@@ -26,10 +26,18 @@ import yaml
|
|||||||
from pptx import Presentation
|
from pptx import Presentation
|
||||||
from pptx.dml.color import RGBColor
|
from pptx.dml.color import RGBColor
|
||||||
from pptx.enum.shapes import MSO_SHAPE
|
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.enum.text import MSO_ANCHOR, PP_ALIGN
|
||||||
from pptx.oxml.ns import qn
|
from pptx.oxml.ns import qn
|
||||||
from pptx.util import Cm, Emu, Pt
|
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
|
# 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))
|
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=""):
|
def pick(d, *keys, default=""):
|
||||||
"""Accès tolérant : première clé non vide trouvée.
|
"""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'}),
|
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
|
return default
|
||||||
|
|
||||||
|
|
||||||
def estimate_text_height(text: str, size_pt: int, width_cm: float) -> float:
|
def estimate_text_height(text: str, size_pt: int, width_cm: float,
|
||||||
"""Hauteur estimée d'un texte (cm) pour une largeur donnée."""
|
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:
|
if not text:
|
||||||
return 0.0
|
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
|
char_w_cm = size_pt * 0.0185 # largeur moyenne d'un caractère
|
||||||
chars_per_line = max(1, int(width_cm / char_w_cm))
|
chars_per_line = max(1, int(width_cm / char_w_cm))
|
||||||
lines = 0
|
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
|
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
|
# Moteur
|
||||||
# ----------------------------------------------------------------
|
# ----------------------------------------------------------------
|
||||||
@@ -124,7 +213,20 @@ class RenderEngineV2:
|
|||||||
|
|
||||||
def _text(self, slide, x, y, w, h, txt, *, font=None, size=14,
|
def _text(self, slide, x, y, w, h, txt, *, font=None, size=14,
|
||||||
bold=False, italic=False, color=None, align=PP_ALIGN.LEFT,
|
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))
|
tb = slide.shapes.add_textbox(Cm(x), Cm(y), Cm(w), Cm(h))
|
||||||
tf = tb.text_frame
|
tf = tb.text_frame
|
||||||
tf.word_wrap = True
|
tf.word_wrap = True
|
||||||
@@ -282,11 +384,17 @@ class RenderEngineV2:
|
|||||||
self._card(slide, self.MX, y, self.SLIDE_W - 2 * self.MX, ch)
|
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,
|
self._badge(slide, self.MX + 1.14 + bd / 2, y + ch / 2, bd, i + 1,
|
||||||
fill=col, font_size=22)
|
fill=col, font_size=22)
|
||||||
self._text(slide, self.MX + 3.81, y + 0.56, 8.1, 1.0, label,
|
# Bloc label+texte centré verticalement dans la carte (C3b)
|
||||||
size=14, bold=True, color=col, char_spacing=3)
|
H_LBL, GAP_LT, H_TXT = 0.85, 0.18, 1.8
|
||||||
self._text(slide, self.MX + 3.81, y + 1.57,
|
blk = H_LBL + GAP_LT + H_TXT
|
||||||
self.SLIDE_W - 2 * self.MX - 5.33, 1.8, txt,
|
y_lbl = y + max(0.0, (ch - blk) / 2)
|
||||||
size=self.T["body"], color=self.C["body"])
|
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
|
y += ch + gap
|
||||||
|
|
||||||
def _render_section_divider(self, slide, d):
|
def _render_section_divider(self, slide, d):
|
||||||
@@ -309,16 +417,21 @@ class RenderEngineV2:
|
|||||||
val = pick(d, "valeur", "stat", "chiffre", "value")
|
val = pick(d, "valeur", "stat", "chiffre", "value")
|
||||||
desc = pick(d, "description", "texte", "label")
|
desc = pick(d, "description", "texte", "label")
|
||||||
src = pick(d, "source", "reference")
|
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)
|
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,
|
font=self.F_DISPLAY, size=self.T["stat_hero"], bold=True,
|
||||||
color=self.C["coral"], align=PP_ALIGN.CENTER,
|
color=self.C["coral"], align=PP_ALIGN.CENTER,
|
||||||
anchor=MSO_ANCHOR.MIDDLE)
|
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)
|
size=18, color=self.C["body"], align=PP_ALIGN.CENTER)
|
||||||
if src:
|
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"],
|
size=11, italic=True, color=self.C["muted"],
|
||||||
align=PP_ALIGN.CENTER)
|
align=PP_ALIGN.CENTER)
|
||||||
|
|
||||||
@@ -1222,6 +1335,11 @@ class RenderEngineV2:
|
|||||||
self._token_color(blk.get("color"), self.C["muted"]))
|
self._token_color(blk.get("color"), self.C["muted"]))
|
||||||
ln.line.width = Pt(float(blk.get("weight", 1.25)))
|
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":
|
elif btype == "badge":
|
||||||
d_cm = min(w, h)
|
d_cm = min(w, h)
|
||||||
self._badge(slide, x + d_cm / 2, y + d_cm / 2, d_cm,
|
self._badge(slide, x + d_cm / 2, y + d_cm / 2, d_cm,
|
||||||
@@ -1268,6 +1386,647 @@ class RenderEngineV2:
|
|||||||
PP_ALIGN.LEFT)
|
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 # <c:doughnutChart>
|
||||||
|
hs = plot_el.find(qn("c:holeSize"))
|
||||||
|
if hs is None:
|
||||||
|
hs = plot_el.makeelement(qn("c:holeSize"), {})
|
||||||
|
plot_el.append(hs)
|
||||||
|
hs.set("val", "65")
|
||||||
|
centre = pick(d, "valeur_centrale", "centre")
|
||||||
|
if centre:
|
||||||
|
self._text(slide, cx0, cy0 + side / 2 - 1.1, side, 2.2,
|
||||||
|
centre, font=self.F_DISPLAY, size=24, bold=True,
|
||||||
|
color=self.C["navy"], align=PP_ALIGN.CENTER,
|
||||||
|
anchor=MSO_ANCHOR.MIDDLE, fit=False)
|
||||||
|
# Légende détaillée custom à droite
|
||||||
|
lx = x + cw + 1.2
|
||||||
|
lw = w - cw - 1.2
|
||||||
|
ih, gap = 1.35, 0.45
|
||||||
|
tot = len(segs) * (ih + gap) - gap if segs else 0
|
||||||
|
ly = self._cy(tot)
|
||||||
|
for i, s in enumerate(segs):
|
||||||
|
self._oval(slide, lx, ly + (ih - 0.5) / 2, 0.5,
|
||||||
|
self.cycle[i % len(self.cycle)])
|
||||||
|
self._text(slide, lx + 1.0, ly, lw - 4.2, ih,
|
||||||
|
pick(s, "label", default="—"),
|
||||||
|
size=14, bold=True, color=self.C["body"],
|
||||||
|
anchor=MSO_ANCHOR.MIDDLE)
|
||||||
|
self._text(slide, lx + lw - 3.2, ly, 3.2, ih,
|
||||||
|
"%g" % self._num(pick(s, "valeur", "value")),
|
||||||
|
size=14, color=self.C["muted"],
|
||||||
|
align=PP_ALIGN.RIGHT, anchor=MSO_ANCHOR.MIDDLE)
|
||||||
|
ly += ih + gap
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- data & structure (C5 lot 2) ----------------
|
||||||
|
WF_MAX_STEPS = 8
|
||||||
|
HM_MAX_COLS = 6
|
||||||
|
HM_MAX_ROWS = 8
|
||||||
|
FUNNEL_MAX = 5
|
||||||
|
|
||||||
|
def _render_waterfall(self, slide, d):
|
||||||
|
"""Pont de valeur : cumuls calculés, jamais fournis."""
|
||||||
|
self._title(slide, pick(d, "titre", "title"))
|
||||||
|
self._chart_caption(slide, d)
|
||||||
|
zx, zy, zw, zh = self._chart_zone(d)
|
||||||
|
dep = d.get("depart") or {}
|
||||||
|
arr = d.get("arrivee") or {}
|
||||||
|
marches = (d.get("marches") or [])[:self.WF_MAX_STEPS]
|
||||||
|
if len(d.get("marches") or []) > self.WF_MAX_STEPS:
|
||||||
|
print(f" ~ waterfall : marches tronquées à "
|
||||||
|
f"{self.WF_MAX_STEPS}")
|
||||||
|
v0 = self._num(pick(dep, "valeur", "value", default=0))
|
||||||
|
cums = [v0]
|
||||||
|
for m in marches:
|
||||||
|
cums.append(cums[-1] + self._num(pick(m, "delta", default=0)))
|
||||||
|
v_end = cums[-1]
|
||||||
|
declared = self._num(pick(arr, "valeur", "value",
|
||||||
|
default=v_end), default=v_end)
|
||||||
|
if abs(declared - v_end) > 1e-9:
|
||||||
|
print(f" ~ waterfall : arrivée déclarée {declared:g} ≠ "
|
||||||
|
f"cumul {v_end:g} — le cumul fait foi")
|
||||||
|
chart_top = zy + 0.7 # réserve étiquettes hautes
|
||||||
|
chart_b = zy + zh - 1.3 # réserve labels bas
|
||||||
|
chart_h = chart_b - chart_top
|
||||||
|
vmin = min(0.0, min(cums))
|
||||||
|
vmax = max(max(cums), v0, 0.0)
|
||||||
|
span = (vmax - vmin) or 1.0
|
||||||
|
hpu = chart_h / span
|
||||||
|
base_y = chart_top + vmax * hpu # y de la valeur 0
|
||||||
|
n = len(marches) + 2
|
||||||
|
slot = zw / n
|
||||||
|
bar_w = slot * 0.62
|
||||||
|
|
||||||
|
def bar(i, v_from, v_to, color):
|
||||||
|
x = zx + i * slot + (slot - bar_w) / 2
|
||||||
|
y1 = base_y - max(v_from, v_to) * hpu
|
||||||
|
h = max(0.06, abs(v_to - v_from) * hpu)
|
||||||
|
self._rect(slide, x, y1, bar_w, h, color)
|
||||||
|
return x, y1
|
||||||
|
|
||||||
|
def val_label(i, y_ref, text, color):
|
||||||
|
self._text(slide, zx + i * slot, y_ref - 0.62, slot, 0.55,
|
||||||
|
text, size=11, bold=True, color=color,
|
||||||
|
align=PP_ALIGN.CENTER, fit=False)
|
||||||
|
|
||||||
|
def cat_label(i, label):
|
||||||
|
self._text(slide, zx + i * slot + 0.05, chart_b + 0.15,
|
||||||
|
slot - 0.1, 1.05, label, size=10,
|
||||||
|
color=self.C["slate"], align=PP_ALIGN.CENTER)
|
||||||
|
|
||||||
|
def connector(x1, x2, level):
|
||||||
|
conn = slide.shapes.add_connector(
|
||||||
|
1, Cm(x1), Cm(base_y - level * hpu),
|
||||||
|
Cm(x2), Cm(base_y - level * hpu))
|
||||||
|
conn.line.color.rgb = hex_to_rgb(self.C["muted"])
|
||||||
|
conn.line.width = Pt(1.0)
|
||||||
|
conn.line.dash_style = MSO_LINE.DASH
|
||||||
|
conn.shadow.inherit = False
|
||||||
|
|
||||||
|
ln = slide.shapes.add_connector(
|
||||||
|
1, Cm(zx), Cm(base_y), Cm(zx + zw), Cm(base_y))
|
||||||
|
ln.line.color.rgb = hex_to_rgb(self.C["muted"])
|
||||||
|
ln.line.width = Pt(0.75)
|
||||||
|
ln.shadow.inherit = False
|
||||||
|
x, y1 = bar(0, 0.0, v0, self.C["navy"])
|
||||||
|
val_label(0, y1, f"{v0:g}", self.C["navy"])
|
||||||
|
cat_label(0, as_label(dep, "label", default="Départ"))
|
||||||
|
prev_x_end = x + bar_w
|
||||||
|
for i, m in enumerate(marches, start=1):
|
||||||
|
c_prev, c_cur = cums[i - 1], cums[i]
|
||||||
|
delta = c_cur - c_prev
|
||||||
|
color = self.C["coral"] if delta >= 0 else self.C["slate"]
|
||||||
|
x, y1 = bar(i, c_prev, c_cur, color)
|
||||||
|
sign = "+" if delta >= 0 else "\u2212"
|
||||||
|
val_label(i, y1, f"{sign}{abs(delta):g}", color)
|
||||||
|
cat_label(i, as_label(m, "label"))
|
||||||
|
connector(prev_x_end, x, c_prev)
|
||||||
|
prev_x_end = x + bar_w
|
||||||
|
i = n - 1
|
||||||
|
x, y1 = bar(i, 0.0, v_end, self.C["navy"])
|
||||||
|
val_label(i, y1 if v_end >= 0 else base_y, f"{v_end:g}",
|
||||||
|
self.C["navy"])
|
||||||
|
cat_label(i, as_label(arr, "label", default="Arrivée"))
|
||||||
|
connector(prev_x_end, x, v_end)
|
||||||
|
|
||||||
|
def _render_heatmap_table(self, slide, d):
|
||||||
|
"""Grille d'intensité : score 0-4 → 5 teintes du navy."""
|
||||||
|
self._title(slide, pick(d, "titre", "title"))
|
||||||
|
headers = [as_label(h) for h in (d.get("headers") or [])][
|
||||||
|
:self.HM_MAX_COLS]
|
||||||
|
rows = (d.get("rows") or [])[:self.HM_MAX_ROWS]
|
||||||
|
if not headers or not rows:
|
||||||
|
self._text(slide, self.MX, self.CONT_Y, 10, 1.0,
|
||||||
|
"[données manquantes]", size=14,
|
||||||
|
color=self.C["muted"])
|
||||||
|
return
|
||||||
|
tints = [_blend("#FFFFFF", self.C["navy"], t)
|
||||||
|
for t in (0.10, 0.28, 0.50, 0.74, 1.0)]
|
||||||
|
legende = pick(d, "legende", "legend")
|
||||||
|
label_w = 6.5
|
||||||
|
gap = 0.12
|
||||||
|
w_total = self.SLIDE_W - 2 * self.MX
|
||||||
|
col_w = (w_total - label_w - gap * len(headers)) / len(headers)
|
||||||
|
head_h = 0.9
|
||||||
|
leg_h = 0.8 if legende else 0.0
|
||||||
|
row_h = min(1.55, (self.CONT_H - head_h - leg_h
|
||||||
|
- gap * (len(rows) + 1)) / len(rows))
|
||||||
|
total_h = head_h + gap + len(rows) * (row_h + gap) + leg_h
|
||||||
|
y = self._cy(total_h)
|
||||||
|
for j, h in enumerate(headers):
|
||||||
|
x = self.MX + label_w + gap + j * (col_w + gap)
|
||||||
|
self._text(slide, x, y, col_w, head_h, h, size=12,
|
||||||
|
bold=True, color=self.C["slate"],
|
||||||
|
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
|
||||||
|
cy = y + head_h + gap
|
||||||
|
for r in rows:
|
||||||
|
self._text(slide, self.MX, cy, label_w, row_h,
|
||||||
|
as_label(r, "label"), size=13, bold=True,
|
||||||
|
color=self.C["body"], anchor=MSO_ANCHOR.MIDDLE)
|
||||||
|
scores = (r.get("scores") if isinstance(r, dict) else []) or []
|
||||||
|
for j in range(len(headers)):
|
||||||
|
s = int(self._num(scores[j] if j < len(scores) else 0))
|
||||||
|
if not 0 <= s <= 4:
|
||||||
|
print(f" ~ heatmap : score {s} hors 0-4 — clampé")
|
||||||
|
s = max(0, min(4, s))
|
||||||
|
x = self.MX + label_w + gap + j * (col_w + gap)
|
||||||
|
self._rect(slide, x, cy, col_w, row_h, tints[s])
|
||||||
|
self._text(slide, x, cy, col_w, row_h, str(s), size=12,
|
||||||
|
bold=True,
|
||||||
|
color=self.C["white"] if s >= 2
|
||||||
|
else self.C["navy"],
|
||||||
|
align=PP_ALIGN.CENTER,
|
||||||
|
anchor=MSO_ANCHOR.MIDDLE, fit=False)
|
||||||
|
cy += row_h + gap
|
||||||
|
if legende:
|
||||||
|
self._text(slide, self.MX, cy + 0.05, w_total, 0.6, legende,
|
||||||
|
size=10, italic=True, color=self.C["muted"])
|
||||||
|
|
||||||
|
def _render_funnel(self, slide, d):
|
||||||
|
"""Entonnoir : trapèzes proportionnels, plancher 30 %,
|
||||||
|
dernier étage corail."""
|
||||||
|
self._title(slide, pick(d, "titre", "title"))
|
||||||
|
self._chart_caption(slide, d)
|
||||||
|
zx, zy, zw, zh = self._chart_zone(d)
|
||||||
|
etapes = (d.get("etapes") or [])[:self.FUNNEL_MAX]
|
||||||
|
if len(etapes) < 2:
|
||||||
|
self._text(slide, zx, zy, 12, 1.0,
|
||||||
|
"[funnel : 2 étapes minimum]", size=14,
|
||||||
|
color=self.C["muted"])
|
||||||
|
return
|
||||||
|
vals = [max(0.0, self._num(pick(e, "valeur", "value",
|
||||||
|
default=0)))
|
||||||
|
for e in etapes]
|
||||||
|
vmax = max(vals) or 1.0
|
||||||
|
has_desc = any(pick(e, "description") for e in etapes)
|
||||||
|
fun_w = zw * (0.58 if has_desc else 0.80)
|
||||||
|
cx = zx + fun_w / 2
|
||||||
|
gap = 0.18
|
||||||
|
n = len(etapes)
|
||||||
|
stage_h = (zh - 0.4 - gap * (n - 1)) / n
|
||||||
|
widths = [max(0.30, v / vmax) * fun_w for v in vals]
|
||||||
|
y = zy + 0.2
|
||||||
|
for i, e in enumerate(etapes):
|
||||||
|
wt = widths[i]
|
||||||
|
wb = widths[i + 1] if i + 1 < n else widths[i] * 0.72
|
||||||
|
color = self.C["coral"] if i == n - 1 else _blend(self.C["navy"], self.C["glacier"],
|
||||||
|
0.5 * i / max(1, n - 1))
|
||||||
|
fb = slide.shapes.build_freeform(
|
||||||
|
cx - wt / 2, y, scale=360000)
|
||||||
|
fb.add_line_segments(
|
||||||
|
[(cx + wt / 2, y),
|
||||||
|
(cx + wb / 2, y + stage_h),
|
||||||
|
(cx - wb / 2, y + stage_h)], close=True)
|
||||||
|
sh = fb.convert_to_shape()
|
||||||
|
sh.fill.solid()
|
||||||
|
sh.fill.fore_color.rgb = hex_to_rgb(color)
|
||||||
|
sh.line.fill.background()
|
||||||
|
sh.shadow.inherit = False
|
||||||
|
label = as_label(e, "label")
|
||||||
|
val = self._num(pick(e, "valeur", "value", default=0))
|
||||||
|
self._text(slide, cx - wt / 2, y + stage_h / 2 - 0.78,
|
||||||
|
wt, 0.8, label, size=14, bold=True,
|
||||||
|
color=self.C["white"], align=PP_ALIGN.CENTER,
|
||||||
|
anchor=MSO_ANCHOR.BOTTOM)
|
||||||
|
self._text(slide, cx - wt / 2, y + stage_h / 2 + 0.06,
|
||||||
|
wt, 0.6, f"{val:g}", size=12,
|
||||||
|
color=self.C["white"], align=PP_ALIGN.CENTER,
|
||||||
|
fit=False)
|
||||||
|
desc = pick(e, "description")
|
||||||
|
if desc:
|
||||||
|
dx = zx + fun_w + 1.2
|
||||||
|
self._text(slide, dx, y, zx + zw - dx, stage_h, desc,
|
||||||
|
size=13, color=self.C["body"],
|
||||||
|
anchor=MSO_ANCHOR.MIDDLE)
|
||||||
|
y += stage_h + gap
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- structure & concept (C5 lot 3) ----------------
|
||||||
|
AGENDA_MAX = 8
|
||||||
|
PYRAMID_WIDTHS = {3: (0.44, 0.72, 1.0),
|
||||||
|
4: (0.40, 0.60, 0.80, 1.0)}
|
||||||
|
|
||||||
|
def _render_agenda(self, slide, d):
|
||||||
|
"""Sommaire : badges numérotés, section active en corail."""
|
||||||
|
self._title(slide, pick(d, "titre", "title"))
|
||||||
|
sections = (d.get("sections") or [])[:self.AGENDA_MAX]
|
||||||
|
if len(d.get("sections") or []) > self.AGENDA_MAX:
|
||||||
|
print(f" ~ agenda : sections tronquées à {self.AGENDA_MAX}")
|
||||||
|
if len(sections) < 2:
|
||||||
|
self._text(slide, self.MX, self.CONT_Y, 12, 1.0,
|
||||||
|
"[agenda : 2 sections minimum]", size=14,
|
||||||
|
color=self.C["muted"])
|
||||||
|
return
|
||||||
|
n = len(sections)
|
||||||
|
gap = 0.5
|
||||||
|
row_h = min(1.9, (self.CONT_H - gap * (n - 1)) / n)
|
||||||
|
tot = n * row_h + (n - 1) * gap
|
||||||
|
y = self._cy(tot)
|
||||||
|
bd = self.components["badge"]["sizes"]["m"]
|
||||||
|
x0 = self.MX + 1.5
|
||||||
|
w = self.SLIDE_W - 2 * self.MX - 3.0
|
||||||
|
for i, s in enumerate(sections):
|
||||||
|
if not isinstance(s, dict):
|
||||||
|
s = {"label": str(s)}
|
||||||
|
actif = bool(s.get("actif"))
|
||||||
|
num = s.get("numero", i + 1)
|
||||||
|
fill = self.C["coral"] if actif else self.C["navy"]
|
||||||
|
self._badge(slide, x0 + bd / 2, y + row_h / 2, bd, num,
|
||||||
|
fill=fill, font_size=20)
|
||||||
|
self._text(slide, x0 + bd + 1.0, y, w - bd - 6.0, row_h,
|
||||||
|
as_label(s, "label", "titre"),
|
||||||
|
size=20 if actif else 19, bold=actif,
|
||||||
|
color=self.C["navy"] if actif
|
||||||
|
else self.C["body"],
|
||||||
|
anchor=MSO_ANCHOR.MIDDLE)
|
||||||
|
duree = pick(s, "duree", "duration")
|
||||||
|
if duree:
|
||||||
|
self._text(slide, x0 + w - 4.5, y, 4.5, row_h, duree,
|
||||||
|
size=13, italic=True, color=self.C["muted"],
|
||||||
|
align=PP_ALIGN.RIGHT,
|
||||||
|
anchor=MSO_ANCHOR.MIDDLE, fit=False)
|
||||||
|
if i < n - 1:
|
||||||
|
self._rect(slide, x0 + bd + 1.0,
|
||||||
|
y + row_h + gap / 2 - 0.015,
|
||||||
|
w - bd - 1.0, 0.03, self.C["card"])
|
||||||
|
y += row_h + gap
|
||||||
|
|
||||||
|
def _render_pyramid(self, slide, d):
|
||||||
|
"""Pyramide à degrés : largeurs fixes, couleurs fixes."""
|
||||||
|
self._title(slide, pick(d, "titre", "title"))
|
||||||
|
niveaux = (d.get("niveaux") or d.get("levels") or [])[:4]
|
||||||
|
if len(niveaux) < 3:
|
||||||
|
self._text(slide, self.MX, self.CONT_Y, 12, 1.0,
|
||||||
|
"[pyramid : 3 ou 4 niveaux]", size=14,
|
||||||
|
color=self.C["muted"])
|
||||||
|
return
|
||||||
|
n = len(niveaux)
|
||||||
|
widths = self.PYRAMID_WIDTHS[n]
|
||||||
|
colors = [self.C["navy"], self.C["navy2"],
|
||||||
|
self.C["slate"], self.C["glacier"]]
|
||||||
|
if n == 3:
|
||||||
|
colors = [self.C["navy"], self.C["navy2"],
|
||||||
|
self.C["glacier"]]
|
||||||
|
has_desc = any(isinstance(nv, dict) and pick(nv, "description")
|
||||||
|
for nv in niveaux)
|
||||||
|
zone_w = self.SLIDE_W - 2 * self.MX
|
||||||
|
pyr_w = zone_w * (0.58 if has_desc else 0.80)
|
||||||
|
pyr_x = self.MX if has_desc else self.MX + (zone_w - pyr_w) / 2
|
||||||
|
cx = pyr_x + pyr_w / 2
|
||||||
|
gap = 0.15
|
||||||
|
avail = self.CONT_H - 0.4
|
||||||
|
stage_h = (avail - gap * (n - 1)) / n
|
||||||
|
y = self._cy(avail) + 0.2
|
||||||
|
for i, nv in enumerate(niveaux):
|
||||||
|
if not isinstance(nv, dict):
|
||||||
|
nv = {"label": str(nv)}
|
||||||
|
wt = widths[i] * pyr_w
|
||||||
|
color = colors[i]
|
||||||
|
txt_color = self.C["navy"] if color == self.C["glacier"] else self.C["white"]
|
||||||
|
self._rect(slide, cx - wt / 2, y, wt, stage_h, color)
|
||||||
|
self._text(slide, cx - wt / 2 + 0.3, y, wt - 0.6, stage_h,
|
||||||
|
as_label(nv, "label", "titre"),
|
||||||
|
size=16, bold=True, color=txt_color,
|
||||||
|
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
|
||||||
|
desc = pick(nv, "description", "detail")
|
||||||
|
if desc:
|
||||||
|
dx = pyr_x + pyr_w + 1.2
|
||||||
|
self._text(slide, dx, y,
|
||||||
|
self.SLIDE_W - self.MX - dx, stage_h, desc,
|
||||||
|
size=13, color=self.C["body"],
|
||||||
|
anchor=MSO_ANCHOR.MIDDLE)
|
||||||
|
y += stage_h + gap
|
||||||
|
|
||||||
|
|
||||||
# ---------------- orchestration ----------------
|
# ---------------- orchestration ----------------
|
||||||
REGISTRY = {
|
REGISTRY = {
|
||||||
"cover_split": "_render_cover_split",
|
"cover_split": "_render_cover_split",
|
||||||
@@ -1292,11 +2051,22 @@ class RenderEngineV2:
|
|||||||
"process_arrow": "_render_process_arrow",
|
"process_arrow": "_render_process_arrow",
|
||||||
"org_chart": "_render_org_chart",
|
"org_chart": "_render_org_chart",
|
||||||
"matrix_2x2": "_render_matrix_2x2",
|
"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):
|
def render(self, data, output_path: str):
|
||||||
if isinstance(data, str):
|
if isinstance(data, str):
|
||||||
data = yaml.safe_load(data)
|
data = yaml.safe_load(data)
|
||||||
|
data = strip_markdown_tree(data)
|
||||||
slides = data.get("slides", data) if isinstance(data, dict) else data
|
slides = data.get("slides", data) if isinstance(data, dict) else data
|
||||||
|
|
||||||
prs = Presentation()
|
prs = Presentation()
|
||||||
@@ -1329,6 +2099,9 @@ class RenderEngineV2:
|
|||||||
getattr(self, method)(slide, sd)
|
getattr(self, method)(slide, sd)
|
||||||
if layout not in excluded and layout != "recommendation_card":
|
if layout not in excluded and layout != "recommendation_card":
|
||||||
self._footer(slide, i + 1)
|
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)
|
prs.save(output_path)
|
||||||
print(f"✓ PPTX généré : {output_path} ({len(slides)} slides)")
|
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("--theme", default="theme_v2.yaml")
|
||||||
parser.add_argument("--components", default="components_v2.yaml")
|
parser.add_argument("--components", default="components_v2.yaml")
|
||||||
parser.add_argument("--layouts", default="layouts_v2.yaml")
|
parser.add_argument("--layouts", default="layouts_v2.yaml")
|
||||||
|
parser.add_argument("--assets", default=None,
|
||||||
|
help="Dossier des images (défaut : <input>/../assets)")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
for f in [args.input_file, args.theme, args.components, args.layouts]:
|
for f in [args.input_file, args.theme, args.components, args.layouts]:
|
||||||
@@ -1354,6 +2129,9 @@ def main():
|
|||||||
data = yaml.safe_load(f)
|
data = yaml.safe_load(f)
|
||||||
|
|
||||||
engine = RenderEngineV2(args.theme, args.layouts, args.components)
|
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)
|
engine.render(data, args.output)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user