feat: assets et images, layouts image_split/image_full (C4)
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
patch_facilitator_c4.py — Chantier C4 (Assets & images)
|
||||
=======================================================
|
||||
Patch strict de facilitator_v9.py :
|
||||
P1. validate_freeform accepte le nouveau type de bloc « image ».
|
||||
P2. /lire inventorie aussi projets/<slug>/assets/ (images + dimensions)
|
||||
et injecte la liste dans le contexte Narrator — le Narrator peut
|
||||
alors référencer les fichiers par nom dans les layouts image_*.
|
||||
Le dossier assets/ est créé au premier /lire s'il n'existe pas.
|
||||
P3. Insère list_assets() (inventaire, dimensions via Pillow si
|
||||
disponible, sinon noms seuls).
|
||||
|
||||
Usage (dossier du pipeline, single-line) :
|
||||
python3 patch_facilitator_c4.py
|
||||
Compatible tout ordre avec C1/C2/C3. Vérifie chaque ancre, écrit
|
||||
.bak-fc4, compile, idempotent.
|
||||
"""
|
||||
|
||||
import py_compile
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
TARGET = Path("facilitator_v9.py")
|
||||
|
||||
FUNC = '''
|
||||
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)
|
||||
|
||||
'''
|
||||
|
||||
PATCHES = [
|
||||
# P1 — type de bloc freeform « image »
|
||||
(
|
||||
'VALID_BLOCK_TYPES = {"title", "heading", "text", "stat", '
|
||||
'"circle",\n'
|
||||
' "badge", "card", "rect", "line"}\n',
|
||||
'VALID_BLOCK_TYPES = {"title", "heading", "text", "stat", '
|
||||
'"circle",\n'
|
||||
' "badge", "card", "rect", "line", "image"}\n',
|
||||
),
|
||||
# P2 — /lire : inventaire des assets
|
||||
(
|
||||
' elif cmd == "/lire":\n'
|
||||
' context, new_files = load_documents(proj, '
|
||||
'loaded_docs)\n'
|
||||
' if not new_files:\n'
|
||||
' info("Aucun nouveau document dans inputs/.")\n'
|
||||
' continue\n',
|
||||
' elif cmd == "/lire":\n'
|
||||
' context, new_files = load_documents(proj, '
|
||||
'loaded_docs)\n'
|
||||
' assets_info = list_assets(proj)\n'
|
||||
' if assets_info:\n'
|
||||
' context = ((context or "").strip()\n'
|
||||
' + "\\n\\n" + assets_info).strip()\n'
|
||||
' ok(f"{len(assets_info.splitlines()) - 1} '
|
||||
'image(s) dans assets/.")\n'
|
||||
' if not new_files and not assets_info:\n'
|
||||
' info("Aucun nouveau document dans inputs/ ni '
|
||||
'image dans assets/.")\n'
|
||||
' continue\n',
|
||||
),
|
||||
]
|
||||
|
||||
MARKER = "def list_assets"
|
||||
ANCHOR_SECTION = "# FLUX LIBRE — THE FREE DESIGNER"
|
||||
|
||||
|
||||
def fail(msg):
|
||||
print(" ! %s" % msg)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
if not TARGET.exists():
|
||||
fail("%s introuvable — lancer depuis le dossier du pipeline."
|
||||
% TARGET)
|
||||
content = TARGET.read_text(encoding="utf-8")
|
||||
if MARKER in content:
|
||||
fail("Déjà patché (list_assets présent) — rien à faire.")
|
||||
|
||||
for i, (old, _) in enumerate(PATCHES, 1):
|
||||
n = content.count(old)
|
||||
if n == 0:
|
||||
fail("Ancre du patch %d introuvable — facilitator inattendu."
|
||||
% i)
|
||||
if n > 1:
|
||||
fail("Ancre du patch %d non unique (%d occurrences)." % (i, n))
|
||||
if content.count(ANCHOR_SECTION) != 1:
|
||||
fail("Ancre de section FREE DESIGNER introuvable ou non unique.")
|
||||
|
||||
shutil.copy2(TARGET, str(TARGET) + ".bak-fc4")
|
||||
print(" + Sauvegarde : %s.bak-fc4" % TARGET)
|
||||
|
||||
for old, new in PATCHES:
|
||||
content = content.replace(old, new)
|
||||
|
||||
lines = content.split("\n")
|
||||
idx = next(i for i, l in enumerate(lines) if ANCHOR_SECTION in l)
|
||||
ins = idx - 1 if lines[idx - 1].startswith("# ───") else idx
|
||||
lines[ins:ins] = FUNC.split("\n")
|
||||
content = "\n".join(lines)
|
||||
|
||||
TARGET.write_text(content, encoding="utf-8")
|
||||
print(" + 2 patchs + list_assets appliqués.")
|
||||
try:
|
||||
py_compile.compile(str(TARGET), doraise=True)
|
||||
print(" + Compilation OK.")
|
||||
except py_compile.PyCompileError as e:
|
||||
shutil.copy2(str(TARGET) + ".bak-fc4", TARGET)
|
||||
fail("Erreur de compilation — fichier restauré :\n%s" % e)
|
||||
print("\n Dépose tes images dans projets/<slug>/assets/ puis /lire "
|
||||
"dans la session.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
patch_facilitator_c4b.py — Correctif C4 (résolution des assets)
|
||||
================================================================
|
||||
run_render() ne transmettait jamais --assets à render_engine_v2.py,
|
||||
qui retombait sur son défaut <yaml>/../assets. Ce défaut n'est correct
|
||||
que si le YAML est dans projets/<slug>/outputs/ (alors ../assets =
|
||||
projets/<slug>/assets/) — ce qui couvre le flux normal du pipeline,
|
||||
mais PAS un YAML isolé à la racine (golden, test manuel).
|
||||
|
||||
Patch : run_render accepte un paramètre assets_dir optionnel. Résolu
|
||||
dans l'ordre :
|
||||
1. assets_dir explicitement fourni par l'appelant (le pipeline
|
||||
normal le fournira désormais : projets/<slug>/assets/).
|
||||
2. Sinon, si le YAML est sous PROJECTS_DIR, le dossier assets/ du
|
||||
projet correspondant est déduit automatiquement.
|
||||
3. Sinon (golden, test manuel hors projet), fallback sur le défaut
|
||||
du moteur (<yaml>/../assets) — comportement inchangé pour ces cas.
|
||||
|
||||
Tous les appelants internes de run_render (pipeline standard, flux
|
||||
libre, révisions, merge C3, --render CLI) sont mis à jour pour passer
|
||||
proj.root / "assets" quand un Project est en contexte.
|
||||
|
||||
Usage (dossier du pipeline, single-line) :
|
||||
python3 patch_facilitator_c4b.py
|
||||
Compatible C1/C2/C3/C4 déjà appliqués (ancres indépendantes). Vérifie
|
||||
chaque ancre, écrit .bak-c4b, compile, idempotent.
|
||||
"""
|
||||
|
||||
import py_compile
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
TARGET = Path("facilitator_v9.py")
|
||||
|
||||
PATCHES = [
|
||||
# P1 — signature + résolution + transmission --assets
|
||||
(
|
||||
'def run_render(yaml_path: Path) -> Optional[Path]:\n'
|
||||
' section("ÉTAPE 4 — RENDER ENGINE V2")\n'
|
||||
' if not Path(RENDER_ENGINE_PATH).exists():\n'
|
||||
' warn(f"{RENDER_ENGINE_PATH} introuvable.")\n'
|
||||
' return None\n'
|
||||
' for p in [THEME_PATH, COMPONENTS_PATH, LAYOUTS_PATH]:\n'
|
||||
' if not os.path.exists(p):\n'
|
||||
' warn(f"Config manquante : {p}")\n'
|
||||
' return None\n'
|
||||
' pptx_out = yaml_path.with_suffix(".pptx")\n'
|
||||
' info("Lancement de render_engine_v2.py...")\n'
|
||||
' info(f"Sortie : {pptx_out}")\n'
|
||||
' result = subprocess.run(\n'
|
||||
' [sys.executable, RENDER_ENGINE_PATH, str(yaml_path), '
|
||||
'str(pptx_out),\n'
|
||||
' "--theme", THEME_PATH, "--components", '
|
||||
'COMPONENTS_PATH,\n'
|
||||
' "--layouts", LAYOUTS_PATH],\n'
|
||||
' capture_output=True, text=True)\n',
|
||||
|
||||
'def run_render(yaml_path: Path,\n'
|
||||
' assets_dir: Optional[Path] = None) -> '
|
||||
'Optional[Path]:\n'
|
||||
' section("ÉTAPE 4 — RENDER ENGINE V2")\n'
|
||||
' if not Path(RENDER_ENGINE_PATH).exists():\n'
|
||||
' warn(f"{RENDER_ENGINE_PATH} introuvable.")\n'
|
||||
' return None\n'
|
||||
' for p in [THEME_PATH, COMPONENTS_PATH, LAYOUTS_PATH]:\n'
|
||||
' if not os.path.exists(p):\n'
|
||||
' warn(f"Config manquante : {p}")\n'
|
||||
' return None\n'
|
||||
' pptx_out = yaml_path.with_suffix(".pptx")\n'
|
||||
' info("Lancement de render_engine_v2.py...")\n'
|
||||
' info(f"Sortie : {pptx_out}")\n'
|
||||
' if assets_dir is None:\n'
|
||||
' # Déduction depuis PROJECTS_DIR si le YAML y vit '
|
||||
'(C4b)\n'
|
||||
' try:\n'
|
||||
' rel = yaml_path.resolve().relative_to(\n'
|
||||
' Path(PROJECTS_DIR).resolve())\n'
|
||||
' assets_dir = Path(PROJECTS_DIR) / rel.parts[0] '
|
||||
'/ "assets"\n'
|
||||
' except ValueError:\n'
|
||||
' assets_dir = None # hors projet : défaut du '
|
||||
'moteur\n'
|
||||
' cmd = [sys.executable, RENDER_ENGINE_PATH, str(yaml_path),'
|
||||
'\n'
|
||||
' str(pptx_out),\n'
|
||||
' "--theme", THEME_PATH, "--components", '
|
||||
'COMPONENTS_PATH,\n'
|
||||
' "--layouts", LAYOUTS_PATH]\n'
|
||||
' if assets_dir is not None:\n'
|
||||
' cmd += ["--assets", str(assets_dir)]\n'
|
||||
' info(f"Assets : {assets_dir}")\n'
|
||||
' result = subprocess.run(cmd, capture_output=True, '
|
||||
'text=True)\n',
|
||||
),
|
||||
# P2 — CLI --render : passe le dossier assets du projet si déductible
|
||||
(
|
||||
' if args.render:\n'
|
||||
' yaml_path = Path(args.render)\n'
|
||||
' if not yaml_path.exists():\n'
|
||||
' warn(f"Fichier introuvable : {yaml_path}")\n'
|
||||
' sys.exit(1)\n'
|
||||
' layouts = load_layouts()\n'
|
||||
' is_valid, message, _ = validate_yaml(\n'
|
||||
' yaml_path.read_text(encoding="utf-8"), layouts)\n'
|
||||
' (ok if is_valid else warn)(f"Validation : {message}")\n'
|
||||
' sys.exit(0 if run_render(yaml_path) else 1)\n',
|
||||
|
||||
' if args.render:\n'
|
||||
' yaml_path = Path(args.render)\n'
|
||||
' if not yaml_path.exists():\n'
|
||||
' warn(f"Fichier introuvable : {yaml_path}")\n'
|
||||
' sys.exit(1)\n'
|
||||
' layouts = load_layouts()\n'
|
||||
' is_valid, message, _ = validate_yaml(\n'
|
||||
' yaml_path.read_text(encoding="utf-8"), layouts)\n'
|
||||
' (ok if is_valid else warn)(f"Validation : {message}")\n'
|
||||
' local_assets = yaml_path.parent / "assets"\n'
|
||||
' assets_dir = local_assets if local_assets.is_dir() '
|
||||
'else None\n'
|
||||
' sys.exit(0 if run_render(yaml_path, assets_dir) '
|
||||
'else 1)\n',
|
||||
),
|
||||
]
|
||||
|
||||
# Les 3 appels internes de run_render(yaml_path) — même nom de
|
||||
# variable partout, différenciés par leur contexte immédiat.
|
||||
CALL_PATCHES = [
|
||||
(
|
||||
' info("Rendu du deck COMPLET fusionné.")\n'
|
||||
'\n'
|
||||
' manifest.file(yaml_path)\n'
|
||||
' pptx_path = run_render(yaml_path)\n',
|
||||
' info("Rendu du deck COMPLET fusionné.")\n'
|
||||
'\n'
|
||||
' manifest.file(yaml_path)\n'
|
||||
' pptx_path = run_render(yaml_path, proj.root / '
|
||||
'"assets")\n',
|
||||
),
|
||||
(
|
||||
' if yaml_path and yaml_data:\n'
|
||||
' manifest.file(yaml_path)\n'
|
||||
' pptx_path = run_render(yaml_path)\n',
|
||||
' if yaml_path and yaml_data:\n'
|
||||
' manifest.file(yaml_path)\n'
|
||||
' pptx_path = run_render(yaml_path, proj.root '
|
||||
'/ "assets")\n',
|
||||
),
|
||||
(
|
||||
' if yaml_path and yaml_data:\n'
|
||||
' manifest.file(yaml_path)\n'
|
||||
' pptx_path = run_render(yaml_path)\n',
|
||||
' if yaml_path and yaml_data:\n'
|
||||
' manifest.file(yaml_path)\n'
|
||||
' pptx_path = run_render(yaml_path, proj.root / '
|
||||
'"assets")\n',
|
||||
),
|
||||
]
|
||||
|
||||
MARKER = "assets_dir: Optional[Path] = None"
|
||||
|
||||
|
||||
def fail(msg):
|
||||
print(" ! %s" % msg)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
if not TARGET.exists():
|
||||
fail("%s introuvable — lancer depuis le dossier du pipeline."
|
||||
% TARGET)
|
||||
content = TARGET.read_text(encoding="utf-8")
|
||||
if MARKER in content:
|
||||
fail("Déjà patché (assets_dir présent) — rien à faire.")
|
||||
if "def merge_revision" not in content:
|
||||
fail("Prérequis manquant : patch_facilitator_c3.py doit être "
|
||||
"appliqué avant celui-ci (C4b s'ancre sur le message de "
|
||||
"fusion introduit par C3).")
|
||||
|
||||
all_patches = PATCHES + CALL_PATCHES
|
||||
for i, (old, _) in enumerate(all_patches, 1):
|
||||
n = content.count(old)
|
||||
if n == 0:
|
||||
fail("Ancre du patch %d introuvable — facilitator inattendu."
|
||||
" (Le fichier a peut-être déjà été modifié depuis les "
|
||||
"patchs C1/C2/C3.)" % i)
|
||||
if n > 1:
|
||||
fail("Ancre du patch %d non unique (%d occurrences)." % (i, n))
|
||||
|
||||
shutil.copy2(TARGET, str(TARGET) + ".bak-c4b")
|
||||
print(" + Sauvegarde : %s.bak-c4b" % TARGET)
|
||||
|
||||
for old, new in all_patches:
|
||||
content = content.replace(old, new)
|
||||
|
||||
TARGET.write_text(content, encoding="utf-8")
|
||||
print(" + %d patchs appliqués (2 structurels + %d appels internes)."
|
||||
% (len(all_patches), len(CALL_PATCHES)))
|
||||
try:
|
||||
py_compile.compile(str(TARGET), doraise=True)
|
||||
print(" + Compilation OK.")
|
||||
except py_compile.PyCompileError as e:
|
||||
shutil.copy2(str(TARGET) + ".bak-c4b", TARGET)
|
||||
fail("Erreur de compilation — fichier restauré :\n%s" % e)
|
||||
print("\n Pipeline normal : assets résolus automatiquement "
|
||||
"(projets/<slug>/assets/).")
|
||||
print(" --render CLI : assets résolus si un dossier assets/ existe"
|
||||
" à côté du YAML (sinon défaut moteur <yaml>/../assets).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
patch_layouts_c4.py — Chantier C4 (catalogue : image_split, image_full)
|
||||
=======================================================================
|
||||
Ajoute les deux layouts images à layouts_v2.yaml par APPEND sécurisé
|
||||
(le fichier n'est jamais réécrit ni reformaté — protection des
|
||||
commentaires et de la mise en forme, leçon de l'incident Le Chat) :
|
||||
1. Parse le fichier actuel, vérifie l'absence des deux clés.
|
||||
2. Vérifie que la dernière ligne significative appartient bien au
|
||||
bloc layouts: (ligne indentée) — sinon refus.
|
||||
3. Sauvegarde .bak-c4, append du fragment, re-parse de contrôle.
|
||||
4. En cas d'échec du re-parse : restauration automatique.
|
||||
|
||||
Après ce patch, propager : python3 prompt_injection_v2.py && python3
|
||||
build_gallery.py (le Designer voit les nouveaux layouts, la galerie et
|
||||
le bloc skill sont régénérés).
|
||||
|
||||
Usage (dossier du pipeline, single-line) :
|
||||
python3 patch_layouts_c4.py
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
TARGET = Path("layouts_v2.yaml")
|
||||
|
||||
FRAGMENT = """
|
||||
# ── 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/.
|
||||
"""
|
||||
|
||||
|
||||
def fail(msg):
|
||||
print(" ! %s" % msg)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
if not TARGET.exists():
|
||||
fail("%s introuvable — lancer depuis le dossier du pipeline."
|
||||
% TARGET)
|
||||
raw = TARGET.read_text(encoding="utf-8")
|
||||
try:
|
||||
data = yaml.safe_load(raw)
|
||||
except yaml.YAMLError as e:
|
||||
fail("layouts_v2.yaml actuel illisible : %s" % e)
|
||||
layouts = (data or {}).get("layouts")
|
||||
if not isinstance(layouts, dict):
|
||||
fail("Bloc layouts: introuvable dans le fichier.")
|
||||
for key in ("image_split", "image_full"):
|
||||
if key in layouts:
|
||||
fail("'%s' déjà présent — rien à faire." % key)
|
||||
|
||||
# La dernière ligne significative doit être DANS le bloc layouts:
|
||||
last = next((l for l in reversed(raw.splitlines())
|
||||
if l.strip() and not l.strip().startswith("#")), "")
|
||||
if not last.startswith(" "):
|
||||
fail("Le fichier ne se termine pas dans le bloc layouts: "
|
||||
"(dernière ligne non indentée : %r) — fusion manuelle "
|
||||
"requise." % last[:40])
|
||||
|
||||
shutil.copy2(TARGET, str(TARGET) + ".bak-c4")
|
||||
print(" + Sauvegarde : %s.bak-c4" % TARGET)
|
||||
|
||||
with open(TARGET, "a", encoding="utf-8") as f:
|
||||
if not raw.endswith("\n"):
|
||||
f.write("\n")
|
||||
f.write(FRAGMENT)
|
||||
|
||||
try:
|
||||
check = yaml.safe_load(TARGET.read_text(encoding="utf-8"))
|
||||
nl = check["layouts"]
|
||||
assert "image_split" in nl and "image_full" in nl
|
||||
assert len(nl) == len(layouts) + 2
|
||||
except Exception as e:
|
||||
shutil.copy2(str(TARGET) + ".bak-c4", TARGET)
|
||||
fail("Contrôle post-append échoué (%s) — fichier restauré." % e)
|
||||
|
||||
print(" + image_split (L50) et image_full (L51) ajoutés "
|
||||
"(%d layouts au total)." % (len(layouts) + 2))
|
||||
print("\n Propager : python3 prompt_injection_v2.py && python3 "
|
||||
"build_gallery.py")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,319 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
patch_render_engine_c4.py — Chantier C4 (Assets & images)
|
||||
=========================================================
|
||||
Patch strict de render_engine_v2.py :
|
||||
P1. _image_size() module-level : dimensions via Pillow, sinon
|
||||
mini-parseur d'en-têtes PNG/JPEG (aucune dépendance).
|
||||
P2. Composants : _asset() (résolution dans assets/ du projet),
|
||||
_image() (insertion cover = recadrage centré symétrique, jamais de
|
||||
déformation ; contain = letterbox centré), _rect_alpha() (voile de
|
||||
couleur avec transparence OOXML), et les renderers
|
||||
_render_image_split (image 40 %, side left/right, légende sur
|
||||
bandeau navy) et _render_image_full (plein cadre + voile navy
|
||||
60 % + titre display blanc).
|
||||
Image introuvable → placeholder card_alt + nom du fichier
|
||||
(warning console), jamais de crash.
|
||||
P3. Freeform : nouveau bloc {type: image, image: fichier, fit:}.
|
||||
P4. REGISTRY : image_split, image_full.
|
||||
P5. CLI : --assets (défaut : <dossier du yaml>/../assets, soit
|
||||
projets/<slug>/assets/ dans le flux facilitator — aucun patch
|
||||
facilitator nécessaire pour le rendu).
|
||||
|
||||
Usage (dossier du pipeline, single-line) :
|
||||
python3 patch_render_engine_c4.py
|
||||
Indépendant du patch C3 (ancres distinctes, tout ordre). Vérifie chaque
|
||||
ancre, écrit .bak-c4, compile, idempotent.
|
||||
"""
|
||||
|
||||
import py_compile
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
TARGET = Path("render_engine_v2.py")
|
||||
|
||||
IMAGE_SIZE_FUNC = '''
|
||||
|
||||
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
|
||||
'''
|
||||
|
||||
COMPONENTS = ''' # ---------------- 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"])
|
||||
|
||||
'''
|
||||
|
||||
PATCHES = [
|
||||
# P1 — _image_size après hex_to_rgb
|
||||
(
|
||||
"def hex_to_rgb(h: str) -> RGBColor:\n"
|
||||
" h = (h or \"#000000\").lstrip(\"#\")\n"
|
||||
" return RGBColor(int(h[0:2], 16), int(h[2:4], 16), "
|
||||
"int(h[4:6], 16))\n",
|
||||
"def hex_to_rgb(h: str) -> RGBColor:\n"
|
||||
" h = (h or \"#000000\").lstrip(\"#\")\n"
|
||||
" return RGBColor(int(h[0:2], 16), int(h[2:4], 16), "
|
||||
"int(h[4:6], 16))\n"
|
||||
+ IMAGE_SIZE_FUNC,
|
||||
),
|
||||
# P3 — bloc freeform image (après le bloc line)
|
||||
(
|
||||
" elif btype == \"line\":\n"
|
||||
" ln = slide.shapes.add_connector(\n"
|
||||
" 1, Cm(x), Cm(y), Cm(x + w), Cm(y + h))\n"
|
||||
" ln.line.color.rgb = hex_to_rgb(\n"
|
||||
" self._token_color(blk.get(\"color\"), "
|
||||
"self.C[\"muted\"]))\n"
|
||||
" ln.line.width = Pt(float(blk.get(\"weight\", "
|
||||
"1.25)))\n",
|
||||
" elif btype == \"line\":\n"
|
||||
" ln = slide.shapes.add_connector(\n"
|
||||
" 1, Cm(x), Cm(y), Cm(x + w), Cm(y + h))\n"
|
||||
" ln.line.color.rgb = hex_to_rgb(\n"
|
||||
" self._token_color(blk.get(\"color\"), "
|
||||
"self.C[\"muted\"]))\n"
|
||||
" ln.line.width = Pt(float(blk.get(\"weight\", "
|
||||
"1.25)))\n"
|
||||
"\n"
|
||||
" elif btype == \"image\":\n"
|
||||
" self._image(slide, x, y, w, h,\n"
|
||||
" blk.get(\"image\") or "
|
||||
"blk.get(\"src\", \"\"),\n"
|
||||
" fit=str(blk.get(\"fit\") or "
|
||||
"\"cover\"))\n",
|
||||
),
|
||||
# P4 — REGISTRY
|
||||
(
|
||||
" \"matrix_2x2\": \"_render_matrix_2x2\",\n",
|
||||
" \"matrix_2x2\": \"_render_matrix_2x2\",\n"
|
||||
" \"image_split\": \"_render_image_split\",\n"
|
||||
" \"image_full\": \"_render_image_full\",\n",
|
||||
),
|
||||
# P5a — CLI --assets
|
||||
(
|
||||
" parser.add_argument(\"--layouts\", default=\"layouts_v2.yaml"
|
||||
"\")\n"
|
||||
" args = parser.parse_args()\n",
|
||||
" parser.add_argument(\"--layouts\", default=\"layouts_v2.yaml"
|
||||
"\")\n"
|
||||
" parser.add_argument(\"--assets\", default=None,\n"
|
||||
" help=\"Dossier des images (défaut : "
|
||||
"<input>/../assets)\")\n"
|
||||
" args = parser.parse_args()\n",
|
||||
),
|
||||
# P5b — résolution du dossier assets
|
||||
(
|
||||
" engine = RenderEngineV2(args.theme, args.layouts, "
|
||||
"args.components)\n"
|
||||
" engine.render(data, args.output)\n",
|
||||
" engine = RenderEngineV2(args.theme, args.layouts, "
|
||||
"args.components)\n"
|
||||
" engine.assets_dir = args.assets or os.path.join(\n"
|
||||
" os.path.dirname(os.path.abspath(args.input_file)),\n"
|
||||
" \"..\", \"assets\")\n"
|
||||
" engine.render(data, args.output)\n",
|
||||
),
|
||||
]
|
||||
|
||||
MARKER = "_render_image_split"
|
||||
ANCHOR_ORCH = " # ---------------- orchestration ----------------"
|
||||
|
||||
|
||||
def fail(msg):
|
||||
print(" ! %s" % msg)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
if not TARGET.exists():
|
||||
fail("%s introuvable — lancer depuis le dossier du pipeline."
|
||||
% TARGET)
|
||||
content = TARGET.read_text(encoding="utf-8")
|
||||
if MARKER in content:
|
||||
fail("Déjà patché (_render_image_split présent) — rien à faire.")
|
||||
|
||||
for i, (old, _) in enumerate(PATCHES, 1):
|
||||
n = content.count(old)
|
||||
if n == 0:
|
||||
fail("Ancre du patch %d introuvable — moteur inattendu." % i)
|
||||
if n > 1:
|
||||
fail("Ancre du patch %d non unique (%d occurrences)." % (i, n))
|
||||
if content.count(ANCHOR_ORCH) != 1:
|
||||
fail("Ancre de la section orchestration introuvable ou non "
|
||||
"unique.")
|
||||
|
||||
shutil.copy2(TARGET, str(TARGET) + ".bak-c4")
|
||||
print(" + Sauvegarde : %s.bak-c4" % TARGET)
|
||||
|
||||
for old, new in PATCHES:
|
||||
content = content.replace(old, new)
|
||||
|
||||
# P2 — insertion des composants/renderers avant l'orchestration
|
||||
lines = content.split("\n")
|
||||
idx = next(i for i, l in enumerate(lines) if ANCHOR_ORCH in l)
|
||||
lines[idx:idx] = COMPONENTS.split("\n")
|
||||
content = "\n".join(lines)
|
||||
|
||||
TARGET.write_text(content, encoding="utf-8")
|
||||
print(" + 5 patchs + composants images appliqués.")
|
||||
try:
|
||||
py_compile.compile(str(TARGET), doraise=True)
|
||||
print(" + Compilation OK.")
|
||||
except py_compile.PyCompileError as e:
|
||||
shutil.copy2(str(TARGET) + ".bak-c4", TARGET)
|
||||
fail("Erreur de compilation — fichier restauré :\n%s" % e)
|
||||
print("\n Suite C4 : python3 patch_layouts_c4.py (catalogue), "
|
||||
"python3 patch_facilitator_c4.py (/lire + freeform),")
|
||||
print(" puis python3 prompt_injection_v2.py && python3 "
|
||||
"build_gallery.py (propagation).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user