320 lines
13 KiB
Python
320 lines
13 KiB
Python
#!/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()
|