feat: mesure texte PIL, fitter anti-débordement et centrage vertical (C3, C3b)
This commit is contained in:
+236
@@ -0,0 +1,236 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
measure.py — Sliding Pipeline v11 · Chantier C3 (Moteur durci)
|
||||||
|
==============================================================
|
||||||
|
Mesure RÉELLE du texte (PIL/Pillow sur les TTF de assets/fonts/) en
|
||||||
|
remplacement de l'heuristique estimate_text_height, plus le fitter
|
||||||
|
anti-débordement (réduction par paliers de 1 pt, plancher 60 % du corps
|
||||||
|
nominal, puis troncature avec …).
|
||||||
|
|
||||||
|
Déterminisme : même texte + même police + même corps + même largeur
|
||||||
|
→ même résultat, toujours. Cache par (fichier, corps).
|
||||||
|
|
||||||
|
Dégradation gracieuse : si Pillow est absent ou si la police n'a pas de
|
||||||
|
TTF résolvable, retombe sur l'heuristique v2 (comportement identique à
|
||||||
|
l'existant) avec un warning affiché UNE fois par police.
|
||||||
|
|
||||||
|
Config : FONTS_DIR (env, défaut ./assets/fonts).
|
||||||
|
Python 3.9. Dépendance optionnelle : Pillow (pip install Pillow
|
||||||
|
--break... non : sur GrosseBertha → pip3 install Pillow dans le venv).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import ImageFont
|
||||||
|
HAS_PIL = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_PIL = False
|
||||||
|
|
||||||
|
PT_TO_CM = 2.54 / 72.0 # 1 pt = 1/72 in
|
||||||
|
FONTS_DIR = Path(os.getenv("FONTS_DIR", "assets/fonts"))
|
||||||
|
DEFAULT_LINE_RATIO = 1.3 # aligné sur l'heuristique v2 (≈1.3 em)
|
||||||
|
|
||||||
|
_font_cache = {} # (path, size) -> ImageFont
|
||||||
|
_path_cache = {} # (name_lower, bold) -> Path | None
|
||||||
|
_warned = set()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Résolution des fichiers de police ────────────────────────────────────────
|
||||||
|
|
||||||
|
def _scan_fonts():
|
||||||
|
if not FONTS_DIR.is_dir():
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
for f in sorted(FONTS_DIR.iterdir()):
|
||||||
|
if f.suffix.lower() in (".ttf", ".ttc", ".otf"):
|
||||||
|
out.append(f)
|
||||||
|
return out
|
||||||
|
|
||||||
|
# Alias métriques (C3) : les decks déclarent Calibri/Cambria (safe-fonts
|
||||||
|
# corporate), mesurés ici sur leurs équivalents libres métrique-identiques.
|
||||||
|
FONT_ALIASES = {
|
||||||
|
"calibri": "carlito",
|
||||||
|
"cambria": "caladea",
|
||||||
|
}
|
||||||
|
|
||||||
|
def resolve_font(name, bold=False):
|
||||||
|
"""Chemin du fichier de police pour (nom, graisse), ou None.
|
||||||
|
Stratégie : suffixes MS classiques (calibrib, cambriab, -Bold),
|
||||||
|
puis correspondance par inclusion insensible à la casse."""
|
||||||
|
key = ((name or "").lower(), bool(bold))
|
||||||
|
_alias = FONT_ALIASES.get(key[0])
|
||||||
|
if _alias:
|
||||||
|
key = (_alias, key[1])
|
||||||
|
if key in _path_cache:
|
||||||
|
return _path_cache[key]
|
||||||
|
files = _scan_fonts()
|
||||||
|
base = key[0].replace(" ", "")
|
||||||
|
candidates = []
|
||||||
|
if bold:
|
||||||
|
candidates += [base + "-bold", base + "b", base + "bold", base + "bd"]
|
||||||
|
candidates += [base + "-regular", base]
|
||||||
|
result = None
|
||||||
|
for cand in candidates:
|
||||||
|
for f in files:
|
||||||
|
stem = f.stem.lower().replace(" ", "").replace("_", "")
|
||||||
|
if stem == cand:
|
||||||
|
result = f
|
||||||
|
break
|
||||||
|
if result:
|
||||||
|
break
|
||||||
|
if result is None: # inclusion (ex Cambria.ttc)
|
||||||
|
for cand in candidates:
|
||||||
|
for f in files:
|
||||||
|
if cand in f.stem.lower().replace(" ", ""):
|
||||||
|
result = f
|
||||||
|
break
|
||||||
|
if result:
|
||||||
|
break
|
||||||
|
_path_cache[key] = result
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _get_font(name, size_pt, bold=False):
|
||||||
|
path = resolve_font(name, bold)
|
||||||
|
if path is None and bold:
|
||||||
|
path = resolve_font(name, False) # graisse simulée : régulier
|
||||||
|
if path is None:
|
||||||
|
return None
|
||||||
|
ck = (str(path), int(size_pt))
|
||||||
|
if ck not in _font_cache:
|
||||||
|
try:
|
||||||
|
_font_cache[ck] = ImageFont.truetype(str(path), int(size_pt))
|
||||||
|
except Exception:
|
||||||
|
_font_cache[ck] = None
|
||||||
|
return _font_cache[ck]
|
||||||
|
|
||||||
|
|
||||||
|
def _warn_once(name):
|
||||||
|
if name not in _warned:
|
||||||
|
_warned.add(name)
|
||||||
|
print(" ~ measure : police '%s' sans TTF dans %s — heuristique."
|
||||||
|
% (name, FONTS_DIR))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Heuristique v2 (fallback, comportement identique à l'existant) ──────────
|
||||||
|
|
||||||
|
def _heuristic_height_cm(text, size_pt, width_cm):
|
||||||
|
if not text:
|
||||||
|
return 0.0
|
||||||
|
char_w_cm = size_pt * 0.0185
|
||||||
|
chars_per_line = max(1, int(width_cm / char_w_cm))
|
||||||
|
lines = 0
|
||||||
|
for para in str(text).split("\n"):
|
||||||
|
lines += max(1, -(-len(para) // chars_per_line))
|
||||||
|
return lines * size_pt * 0.0455
|
||||||
|
|
||||||
|
|
||||||
|
# ── Mesure réelle ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _wrap_count(text, font, width_cm):
|
||||||
|
"""Nombre de lignes après word-wrap à la largeur donnée (mesure PIL)."""
|
||||||
|
max_w_pt = width_cm / PT_TO_CM
|
||||||
|
total = 0
|
||||||
|
space_w = font.getlength(" ")
|
||||||
|
for para in str(text).split("\n"):
|
||||||
|
words = para.split(" ")
|
||||||
|
if not words:
|
||||||
|
total += 1
|
||||||
|
continue
|
||||||
|
lines, cur = 1, 0.0
|
||||||
|
for word in words:
|
||||||
|
w = font.getlength(word)
|
||||||
|
if w > max_w_pt: # mot plus long que la zone
|
||||||
|
if cur > 0:
|
||||||
|
lines += 1
|
||||||
|
lines += max(0, int(w // max_w_pt))
|
||||||
|
cur = w % max_w_pt
|
||||||
|
continue
|
||||||
|
add = w if cur == 0 else space_w + w
|
||||||
|
if cur + add <= max_w_pt:
|
||||||
|
cur += add
|
||||||
|
else:
|
||||||
|
lines += 1
|
||||||
|
cur = w
|
||||||
|
total += lines
|
||||||
|
return max(1, total)
|
||||||
|
|
||||||
|
|
||||||
|
def text_height_cm(text, font_name, size_pt, width_cm,
|
||||||
|
bold=False, line_ratio=None):
|
||||||
|
"""Hauteur (cm) du texte rendu à cette largeur. Mesure réelle si
|
||||||
|
possible, heuristique v2 sinon."""
|
||||||
|
if not text:
|
||||||
|
return 0.0
|
||||||
|
ratio = line_ratio or DEFAULT_LINE_RATIO
|
||||||
|
if HAS_PIL and width_cm > 0:
|
||||||
|
font = _get_font(font_name, size_pt, bold)
|
||||||
|
if font is not None:
|
||||||
|
lines = _wrap_count(text, font, width_cm)
|
||||||
|
ascent, descent = font.getmetrics()
|
||||||
|
line_h_pt = max(ascent + descent, size_pt) * ratio \
|
||||||
|
if ratio != DEFAULT_LINE_RATIO else (ascent + descent) * 1.15
|
||||||
|
# (ascent+descent)*1.15 ≈ interligne simple PowerPoint ;
|
||||||
|
# ratio explicite (spacing fourni) prime.
|
||||||
|
if line_ratio:
|
||||||
|
line_h_pt = size_pt * ratio
|
||||||
|
return lines * line_h_pt * PT_TO_CM
|
||||||
|
_warn_once(font_name)
|
||||||
|
return _heuristic_height_cm(text, size_pt, width_cm)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Fitter anti-débordement ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def fit_text(text, font_name, size_pt, width_cm, max_h_cm,
|
||||||
|
bold=False, min_ratio=0.6, line_ratio=None):
|
||||||
|
"""Fait tenir le texte dans (width_cm × max_h_cm).
|
||||||
|
1. Réduction du corps par pas de 1 pt, plancher min_ratio du nominal.
|
||||||
|
2. Au plancher, troncature par mots avec « … ».
|
||||||
|
Retourne (size_final, text_final, truncated: bool). Déterministe."""
|
||||||
|
text = str(text)
|
||||||
|
if not text or max_h_cm <= 0:
|
||||||
|
return size_pt, text, False
|
||||||
|
floor = max(6, int(round(size_pt * min_ratio)))
|
||||||
|
size = int(size_pt)
|
||||||
|
while size >= floor:
|
||||||
|
if text_height_cm(text, font_name, size, width_cm,
|
||||||
|
bold, line_ratio) <= max_h_cm:
|
||||||
|
return size, text, False
|
||||||
|
size -= 1
|
||||||
|
size = floor
|
||||||
|
words = text.split(" ")
|
||||||
|
lo, hi = 1, len(words)
|
||||||
|
best = None
|
||||||
|
while lo <= hi: # dichotomie déterministe
|
||||||
|
mid = (lo + hi) // 2
|
||||||
|
cand = " ".join(words[:mid]).rstrip(" ,;:.") + "…"
|
||||||
|
if text_height_cm(cand, font_name, size, width_cm,
|
||||||
|
bold, line_ratio) <= max_h_cm:
|
||||||
|
best, lo = cand, mid + 1
|
||||||
|
else:
|
||||||
|
hi = mid - 1
|
||||||
|
if best is None:
|
||||||
|
best = (words[0][:8] + "…") if words else "…"
|
||||||
|
return size, best, True
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("Pillow :", "présent" if HAS_PIL else "ABSENT (heuristique)")
|
||||||
|
print("FONTS_DIR :", FONTS_DIR.resolve(),
|
||||||
|
"(%d fichiers)" % len(_scan_fonts()))
|
||||||
|
for f in _scan_fonts():
|
||||||
|
print(" -", f.name)
|
||||||
|
for name in ("Calibri", "Cambria"):
|
||||||
|
for b in (False, True):
|
||||||
|
p = resolve_font(name, b)
|
||||||
|
print("%s%s → %s" % (name, " bold" if b else "",
|
||||||
|
p.name if p else "heuristique"))
|
||||||
|
demo = ("La gouvernance des données transforme nos actifs en "
|
||||||
|
"avantage compétitif durable pour Pernod Ricard.")
|
||||||
|
h = text_height_cm(demo, "Calibri", 14, 10.0)
|
||||||
|
print("Hauteur démo 14 pt / 10 cm : %.2f cm" % h)
|
||||||
|
s, t, tr = fit_text(demo, "Calibri", 14, 10.0, 1.0)
|
||||||
|
print("Fit dans 1 cm : %d pt, tronqué=%s → %s" % (s, tr, t[:60]))
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
patch_facilitator_c3.py — Chantier C3 (fusion des révisions ciblées)
|
||||||
|
====================================================================
|
||||||
|
Patch strict de facilitator_v9.py :
|
||||||
|
P1. Insère merge_revision() : remplace, dans le dernier YAML complet
|
||||||
|
(project_state.json → dernier_yaml), les slides régénérées par la
|
||||||
|
révision ciblée (appariement par position), puis re-rend le deck
|
||||||
|
ENTIER. Le PPTX partiel « à recoller » disparaît du flux.
|
||||||
|
P2. Branche la fusion dans run_full_pipeline_pass juste avant le
|
||||||
|
rendu (suffix revision_ciblee uniquement).
|
||||||
|
P3. En cas de fusion réussie, l'état du projet est persisté comme un
|
||||||
|
deck complet (dernier_yaml/dernier_pptx à jour).
|
||||||
|
P4. Message utilisateur complété au site révision ciblée.
|
||||||
|
|
||||||
|
Échec de fusion (pas de dernier_yaml, YAML illisible…) : comportement
|
||||||
|
actuel conservé à l'identique (PPTX partiel + message de recollage).
|
||||||
|
|
||||||
|
Usage (dossier du pipeline, single-line) :
|
||||||
|
python3 patch_facilitator_c3.py
|
||||||
|
Compatible avant/après les patchs C1 et C2 (ancres indépendantes).
|
||||||
|
Vérifie chaque ancre, écrit .bak-c3, compile, idempotent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import py_compile
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
TARGET = Path("facilitator_v9.py")
|
||||||
|
|
||||||
|
FUNC = '''
|
||||||
|
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
|
||||||
|
|
||||||
|
'''
|
||||||
|
|
||||||
|
PATCHES = [
|
||||||
|
# P2 — fusion avant le rendu (revision_ciblee)
|
||||||
|
(
|
||||||
|
' # Renommer la sortie selon le suffixe demandé\n'
|
||||||
|
' if suffix != "input" and yaml_path:\n'
|
||||||
|
' new_path = proj.out(suffix, "yaml")\n'
|
||||||
|
' yaml_path.rename(new_path)\n'
|
||||||
|
' yaml_path = new_path\n'
|
||||||
|
'\n'
|
||||||
|
' manifest.file(yaml_path)\n',
|
||||||
|
' # Renommer la sortie selon le suffixe demandé\n'
|
||||||
|
' if suffix != "input" and yaml_path:\n'
|
||||||
|
' new_path = proj.out(suffix, "yaml")\n'
|
||||||
|
' yaml_path.rename(new_path)\n'
|
||||||
|
' yaml_path = new_path\n'
|
||||||
|
'\n'
|
||||||
|
' merged = False\n'
|
||||||
|
' if suffix == "revision_ciblee" and yaml_path:\n'
|
||||||
|
' fused = merge_revision(proj, yaml_path)\n'
|
||||||
|
' if fused:\n'
|
||||||
|
' yaml_path, merged = fused, True\n'
|
||||||
|
' info("Rendu du deck COMPLET fusionné.")\n'
|
||||||
|
'\n'
|
||||||
|
' manifest.file(yaml_path)\n',
|
||||||
|
),
|
||||||
|
# P3 — persistance d'état si fusion
|
||||||
|
(
|
||||||
|
' elif suffix == "revision_ciblee":\n'
|
||||||
|
' persist_generation(\n'
|
||||||
|
' proj, markdown=markdown,\n'
|
||||||
|
' journal_entry="Révision ciblée — slides '
|
||||||
|
'régénérées séparément.")\n',
|
||||||
|
' elif suffix == "revision_ciblee":\n'
|
||||||
|
' if merged:\n'
|
||||||
|
' persist_generation(\n'
|
||||||
|
' proj, markdown=markdown, yaml_path=yaml_path,\n'
|
||||||
|
' pptx_path=pptx_path,\n'
|
||||||
|
' journal_entry="Révision ciblée fusionnée — '
|
||||||
|
'deck complet régénéré.")\n'
|
||||||
|
' else:\n'
|
||||||
|
' persist_generation(\n'
|
||||||
|
' proj, markdown=markdown,\n'
|
||||||
|
' journal_entry="Révision ciblée — slides '
|
||||||
|
'régénérées séparément.")\n',
|
||||||
|
),
|
||||||
|
# P4 — message utilisateur
|
||||||
|
(
|
||||||
|
' info("Ouvre ce fichier et copie-colle les slides dans '
|
||||||
|
'ton deck maître.")\n',
|
||||||
|
' info("Ouvre ce fichier et copie-colle les slides dans '
|
||||||
|
'ton deck maître.")\n'
|
||||||
|
' info("(Si la fusion YAML a réussi — voir ci-dessus — '
|
||||||
|
'le PPTX est déjà le deck complet.)")\n',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
MARKER = "def merge_revision"
|
||||||
|
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é (merge_revision 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-fc3")
|
||||||
|
print(" + Sauvegarde : %s.bak-fc3" % 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(" + 3 patchs + merge_revision appliqués.")
|
||||||
|
try:
|
||||||
|
py_compile.compile(str(TARGET), doraise=True)
|
||||||
|
print(" + Compilation OK.")
|
||||||
|
except py_compile.PyCompileError as e:
|
||||||
|
shutil.copy2(str(TARGET) + ".bak-fc3", TARGET)
|
||||||
|
fail("Erreur de compilation — fichier restauré :\n%s" % e)
|
||||||
|
print("\n La prochaine révision ciblée régénérera le deck complet "
|
||||||
|
"(revision_fusion.yaml).")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
patch_render_engine_c3.py — Chantier C3 (Moteur durci)
|
||||||
|
======================================================
|
||||||
|
Patch strict de render_engine_v2.py :
|
||||||
|
P1. Import optionnel de measure.py (mesure PIL réelle).
|
||||||
|
P2. estimate_text_height() délègue à la mesure réelle quand elle est
|
||||||
|
disponible (fallback : heuristique v2 inchangée) + ajoute
|
||||||
|
strip_markdown_tree() (nettoyage récursif du Markdown résiduel).
|
||||||
|
P3. Fitter anti-débordement dans _text() : si le texte mesuré dépasse
|
||||||
|
la zone, réduction par pas de 1 pt (plancher 60 % du nominal) puis
|
||||||
|
troncature avec « … » — chaque ajustement est tracé en console.
|
||||||
|
Désactivable par appel (fit=False) ou globalement (FIT_TEXT=0).
|
||||||
|
P4. Nettoyage Markdown appliqué à TOUTE donnée entrante de render().
|
||||||
|
P5. Speaker notes : champ notes: par slide → zone notes PowerPoint.
|
||||||
|
|
||||||
|
Usage (dossier du pipeline, single-line) :
|
||||||
|
python3 patch_render_engine_c3.py
|
||||||
|
Prérequis : measure.py à côté (sinon P1/P3 restent inertes, sans casse).
|
||||||
|
Vérifie chaque ancre, écrit render_engine_v2.py.bak-c3, compile,
|
||||||
|
idempotent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import py_compile
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
TARGET = Path("render_engine_v2.py")
|
||||||
|
|
||||||
|
PATCHES = [
|
||||||
|
# P1 — import measure (optionnel)
|
||||||
|
(
|
||||||
|
"from pptx.util import Cm, Emu, Pt\n",
|
||||||
|
"from pptx.util import Cm, Emu, Pt\n"
|
||||||
|
"\n"
|
||||||
|
"try:\n"
|
||||||
|
" import measure\n"
|
||||||
|
" HAS_MEASURE = True\n"
|
||||||
|
"except ImportError:\n"
|
||||||
|
" HAS_MEASURE = False\n"
|
||||||
|
"FIT_TEXT = os.getenv(\"FIT_TEXT\", \"1\") != \"0\" # C3\n",
|
||||||
|
),
|
||||||
|
# P2 — mesure réelle + strip_markdown_tree
|
||||||
|
(
|
||||||
|
"def estimate_text_height(text: str, size_pt: int, width_cm: float)"
|
||||||
|
" -> float:\n"
|
||||||
|
" \"\"\"Hauteur estimée d'un texte (cm) pour une largeur donnée."
|
||||||
|
"\"\"\"\n"
|
||||||
|
" if not text:\n"
|
||||||
|
" return 0.0\n"
|
||||||
|
" char_w_cm = size_pt * 0.0185 # largeur moyenne d'un "
|
||||||
|
"caractère\n"
|
||||||
|
" chars_per_line = max(1, int(width_cm / char_w_cm))\n"
|
||||||
|
" lines = 0\n"
|
||||||
|
" for para in str(text).split(\"\\n\"):\n"
|
||||||
|
" lines += max(1, -(-len(para) // chars_per_line))\n"
|
||||||
|
" return lines * size_pt * 0.0455 # hauteur de ligne ≈ 1.3"
|
||||||
|
" em\n",
|
||||||
|
"def estimate_text_height(text: str, size_pt: int, width_cm: float,"
|
||||||
|
"\n"
|
||||||
|
" font_name: str = \"Calibri\",\n"
|
||||||
|
" bold: bool = False) -> float:\n"
|
||||||
|
" \"\"\"Hauteur d'un texte (cm). Mesure réelle PIL si disponible"
|
||||||
|
" (C3),\n"
|
||||||
|
" sinon heuristique v2 inchangée.\"\"\"\n"
|
||||||
|
" if not text:\n"
|
||||||
|
" return 0.0\n"
|
||||||
|
" if HAS_MEASURE:\n"
|
||||||
|
" try:\n"
|
||||||
|
" return measure.text_height_cm(str(text), font_name,\n"
|
||||||
|
" size_pt, width_cm, bold)"
|
||||||
|
"\n"
|
||||||
|
" except Exception:\n"
|
||||||
|
" pass\n"
|
||||||
|
" char_w_cm = size_pt * 0.0185 # largeur moyenne d'un "
|
||||||
|
"caractère\n"
|
||||||
|
" chars_per_line = max(1, int(width_cm / char_w_cm))\n"
|
||||||
|
" lines = 0\n"
|
||||||
|
" for para in str(text).split(\"\\n\"):\n"
|
||||||
|
" lines += max(1, -(-len(para) // chars_per_line))\n"
|
||||||
|
" return lines * size_pt * 0.0455 # hauteur de ligne ≈ 1.3"
|
||||||
|
" em\n"
|
||||||
|
"\n"
|
||||||
|
"\n"
|
||||||
|
"_MD_RES = [\n"
|
||||||
|
" (re.compile(r\"\\*\\*(.+?)\\*\\*\"), r\"\\1\"),\n"
|
||||||
|
" (re.compile(r\"__(.+?)__\"), r\"\\1\"),\n"
|
||||||
|
" (re.compile(r\"`([^`]+)`\"), r\"\\1\"),\n"
|
||||||
|
" (re.compile(r\"^#{1,4}\\s+\"), \"\"),\n"
|
||||||
|
" (re.compile(r\"^[-•]\\s+\"), \"\"),\n"
|
||||||
|
"]\n"
|
||||||
|
"\n"
|
||||||
|
"\n"
|
||||||
|
"def strip_markdown_tree(node):\n"
|
||||||
|
" \"\"\"Nettoyage récursif du Markdown résiduel (C3) : gras,\n"
|
||||||
|
" italique, code inline, titres et puces en tête de valeur.\"\"\""
|
||||||
|
"\n"
|
||||||
|
" if isinstance(node, dict):\n"
|
||||||
|
" return {k: strip_markdown_tree(v) for k, v in node.items()}"
|
||||||
|
"\n"
|
||||||
|
" if isinstance(node, list):\n"
|
||||||
|
" return [strip_markdown_tree(v) for v in node]\n"
|
||||||
|
" if isinstance(node, str):\n"
|
||||||
|
" s = node\n"
|
||||||
|
" for rx, rep in _MD_RES:\n"
|
||||||
|
" s = rx.sub(rep, s)\n"
|
||||||
|
" return s\n"
|
||||||
|
" return node\n",
|
||||||
|
),
|
||||||
|
# P3 — fitter dans _text
|
||||||
|
(
|
||||||
|
" def _text(self, slide, x, y, w, h, txt, *, font=None, size=14,"
|
||||||
|
"\n"
|
||||||
|
" bold=False, italic=False, color=None, align=PP_ALIGN."
|
||||||
|
"LEFT,\n"
|
||||||
|
" anchor=MSO_ANCHOR.TOP, spacing=None, char_spacing="
|
||||||
|
"None):\n"
|
||||||
|
" tb = slide.shapes.add_textbox(Cm(x), Cm(y), Cm(w), Cm(h))\n",
|
||||||
|
" def _text(self, slide, x, y, w, h, txt, *, font=None, size=14,"
|
||||||
|
"\n"
|
||||||
|
" bold=False, italic=False, color=None, align=PP_ALIGN."
|
||||||
|
"LEFT,\n"
|
||||||
|
" anchor=MSO_ANCHOR.TOP, spacing=None, char_spacing="
|
||||||
|
"None,\n"
|
||||||
|
" fit=True):\n"
|
||||||
|
" if (fit and FIT_TEXT and HAS_MEASURE and txt\n"
|
||||||
|
" and h and h > 0.3 and w and w > 0.5):\n"
|
||||||
|
" _ratio = (spacing / size) if spacing else None\n"
|
||||||
|
" _s, _t, _tr = measure.fit_text(\n"
|
||||||
|
" str(txt), font or self.F_BODY, size, w, h,\n"
|
||||||
|
" bold=bold, line_ratio=_ratio)\n"
|
||||||
|
" if _s != size or _tr:\n"
|
||||||
|
" print(f\" ~ fit slide \"\n"
|
||||||
|
" f\"{getattr(self, '_slide_num', '?')} : \"\n"
|
||||||
|
" f\"{size}→{_s} pt\"\n"
|
||||||
|
" + (\" +troncature\" if _tr else \"\"))\n"
|
||||||
|
" size, txt = _s, _t\n"
|
||||||
|
" tb = slide.shapes.add_textbox(Cm(x), Cm(y), Cm(w), Cm(h))\n",
|
||||||
|
),
|
||||||
|
# P4 — strip à l'entrée de render()
|
||||||
|
(
|
||||||
|
" if isinstance(data, str):\n"
|
||||||
|
" data = yaml.safe_load(data)\n"
|
||||||
|
" slides = data.get(\"slides\", data) if isinstance(data, "
|
||||||
|
"dict) else data\n",
|
||||||
|
" if isinstance(data, str):\n"
|
||||||
|
" data = yaml.safe_load(data)\n"
|
||||||
|
" data = strip_markdown_tree(data)\n"
|
||||||
|
" slides = data.get(\"slides\", data) if isinstance(data, "
|
||||||
|
"dict) else data\n",
|
||||||
|
),
|
||||||
|
# P5 — speaker notes
|
||||||
|
(
|
||||||
|
" if layout not in excluded and layout != "
|
||||||
|
"\"recommendation_card\":\n"
|
||||||
|
" self._footer(slide, i + 1)\n"
|
||||||
|
"\n"
|
||||||
|
" prs.save(output_path)\n",
|
||||||
|
" if layout not in excluded and layout != "
|
||||||
|
"\"recommendation_card\":\n"
|
||||||
|
" self._footer(slide, i + 1)\n"
|
||||||
|
" notes = sd.get(\"notes\") if isinstance(sd, dict) else"
|
||||||
|
" None\n"
|
||||||
|
" if notes:\n"
|
||||||
|
" slide.notes_slide.notes_text_frame.text = "
|
||||||
|
"str(notes)\n"
|
||||||
|
"\n"
|
||||||
|
" prs.save(output_path)\n",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
MARKER = "strip_markdown_tree"
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
if not Path("measure.py").exists():
|
||||||
|
print(" ~ measure.py absent : le moteur restera sur l'heuristique"
|
||||||
|
" tant qu'il n'est pas déposé (patch appliqué quand même).")
|
||||||
|
content = TARGET.read_text(encoding="utf-8")
|
||||||
|
if MARKER in content:
|
||||||
|
fail("Déjà patché (strip_markdown_tree 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))
|
||||||
|
|
||||||
|
shutil.copy2(TARGET, str(TARGET) + ".bak-c3")
|
||||||
|
print(" + Sauvegarde : %s.bak-c3" % TARGET)
|
||||||
|
|
||||||
|
for old, new in PATCHES:
|
||||||
|
content = content.replace(old, new)
|
||||||
|
|
||||||
|
TARGET.write_text(content, encoding="utf-8")
|
||||||
|
print(" + 5 patchs appliqués.")
|
||||||
|
try:
|
||||||
|
py_compile.compile(str(TARGET), doraise=True)
|
||||||
|
print(" + Compilation OK.")
|
||||||
|
except py_compile.PyCompileError as e:
|
||||||
|
shutil.copy2(str(TARGET) + ".bak-c3", TARGET)
|
||||||
|
fail("Erreur de compilation — fichier restauré :\n%s" % e)
|
||||||
|
print("\n Prérequis mesure réelle : pip3 install Pillow (venv) et TTF"
|
||||||
|
" dans assets/fonts/")
|
||||||
|
print(" Diagnostic : python3 measure.py")
|
||||||
|
print(" Désactivation d'urgence du fitter : FIT_TEXT=0 dans .env")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
patch_render_engine_c3b.py — Chantier C3b (centrage vertical)
|
||||||
|
=============================================================
|
||||||
|
Corrige deux défauts de centrage vertical révélés par les previews C2,
|
||||||
|
SANS toucher au helper _cy() (le centrage du contenu dans la zone est
|
||||||
|
un choix de design assumé — le contenu respire).
|
||||||
|
|
||||||
|
P1. big_stat : la hauteur de bloc était codée en dur (block = 8.13),
|
||||||
|
sous-estimée par rapport au placement réel (chiffre à y, desc à
|
||||||
|
y+5.33, source à y+7.37 ≈ 8.3+). Résultat : _cy() centrait un
|
||||||
|
bloc faussé, l'ensemble penchait vers le bas. Correctif : hauteur
|
||||||
|
réelle calculée (avec/sans source) → centrage exact. Les offsets
|
||||||
|
internes deviennent relatifs à cette hauteur.
|
||||||
|
|
||||||
|
P2. executive_summary : dans chaque carte (hauteur ch=3.68), le label
|
||||||
|
et le texte étaient posés à des offsets FIXES (y+0.56, y+1.57),
|
||||||
|
collés en haut, laissant du vide en bas de carte. Correctif : le
|
||||||
|
bloc label+texte est centré verticalement dans la carte (offsets
|
||||||
|
dérivés de ch), à côté du badge déjà centré.
|
||||||
|
|
||||||
|
Aucune dépendance nouvelle ; réutilise measure via estimate_text_height
|
||||||
|
déjà présent. Vérifie chaque ancre, écrit .bak-c3b, compile, idempotent.
|
||||||
|
|
||||||
|
Usage (dossier du pipeline, single-line) :
|
||||||
|
python3 patch_render_engine_c3b.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import py_compile
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
TARGET = Path("render_engine_v2.py")
|
||||||
|
|
||||||
|
PATCHES = [
|
||||||
|
# P1 — big_stat : hauteur réelle
|
||||||
|
(
|
||||||
|
' def _render_big_stat(self, slide, d):\n'
|
||||||
|
' self._title(slide, pick(d, "titre", "title"))\n'
|
||||||
|
' val = pick(d, "valeur", "stat", "chiffre", "value")\n'
|
||||||
|
' desc = pick(d, "description", "texte", "label")\n'
|
||||||
|
' src = pick(d, "source", "reference")\n'
|
||||||
|
' block = 8.13\n'
|
||||||
|
' y = self._cy(block)\n'
|
||||||
|
' self._text(slide, 0, y, self.SLIDE_W, 5.1, val,\n'
|
||||||
|
' font=self.F_DISPLAY, size=self.T["stat_hero"], '
|
||||||
|
'bold=True,\n'
|
||||||
|
' color=self.C["coral"], align=PP_ALIGN.CENTER,\n'
|
||||||
|
' anchor=MSO_ANCHOR.MIDDLE)\n'
|
||||||
|
' self._text(slide, 6.86, y + 5.33, self.SLIDE_W - 13.72, '
|
||||||
|
'2.0, desc,\n'
|
||||||
|
' size=18, color=self.C["body"], '
|
||||||
|
'align=PP_ALIGN.CENTER)\n'
|
||||||
|
' if src:\n'
|
||||||
|
' self._text(slide, 6.86, y + 7.37, self.SLIDE_W - '
|
||||||
|
'13.72, 0.9, src,\n'
|
||||||
|
' size=11, italic=True, '
|
||||||
|
'color=self.C["muted"],\n'
|
||||||
|
' align=PP_ALIGN.CENTER)\n',
|
||||||
|
|
||||||
|
' def _render_big_stat(self, slide, d):\n'
|
||||||
|
' self._title(slide, pick(d, "titre", "title"))\n'
|
||||||
|
' val = pick(d, "valeur", "stat", "chiffre", "value")\n'
|
||||||
|
' desc = pick(d, "description", "texte", "label")\n'
|
||||||
|
' src = pick(d, "source", "reference")\n'
|
||||||
|
' # Hauteur réelle du bloc (C3b) : chiffre + desc '
|
||||||
|
'(+ source)\n'
|
||||||
|
' H_VAL, GAP_D, H_DESC, GAP_S, H_SRC = 5.1, 0.23, 2.0, '
|
||||||
|
'0.14, 0.9\n'
|
||||||
|
' block = H_VAL + GAP_D + H_DESC + (\n'
|
||||||
|
' GAP_S + H_SRC if src else 0.0)\n'
|
||||||
|
' y = self._cy(block)\n'
|
||||||
|
' self._text(slide, 0, y, self.SLIDE_W, H_VAL, val,\n'
|
||||||
|
' font=self.F_DISPLAY, size=self.T["stat_hero"], '
|
||||||
|
'bold=True,\n'
|
||||||
|
' color=self.C["coral"], align=PP_ALIGN.CENTER,\n'
|
||||||
|
' anchor=MSO_ANCHOR.MIDDLE)\n'
|
||||||
|
' y_desc = y + H_VAL + GAP_D\n'
|
||||||
|
' self._text(slide, 6.86, y_desc, self.SLIDE_W - 13.72, '
|
||||||
|
'H_DESC, desc,\n'
|
||||||
|
' size=18, color=self.C["body"], '
|
||||||
|
'align=PP_ALIGN.CENTER)\n'
|
||||||
|
' if src:\n'
|
||||||
|
' y_src = y_desc + H_DESC + GAP_S\n'
|
||||||
|
' self._text(slide, 6.86, y_src, self.SLIDE_W - 13.72, '
|
||||||
|
'H_SRC, src,\n'
|
||||||
|
' size=11, italic=True, '
|
||||||
|
'color=self.C["muted"],\n'
|
||||||
|
' align=PP_ALIGN.CENTER)\n',
|
||||||
|
),
|
||||||
|
# P2 — executive_summary : bloc label+texte centré dans la carte
|
||||||
|
(
|
||||||
|
' self._text(slide, self.MX + 3.81, y + 0.56, 8.1, 1.0, '
|
||||||
|
'label,\n'
|
||||||
|
' size=14, bold=True, color=col, '
|
||||||
|
'char_spacing=3)\n'
|
||||||
|
' self._text(slide, self.MX + 3.81, y + 1.57,\n'
|
||||||
|
' self.SLIDE_W - 2 * self.MX - 5.33, 1.8, '
|
||||||
|
'txt,\n'
|
||||||
|
' size=self.T["body"], '
|
||||||
|
'color=self.C["body"])\n',
|
||||||
|
|
||||||
|
' # Bloc label+texte centré verticalement dans la carte '
|
||||||
|
'(C3b)\n'
|
||||||
|
' H_LBL, GAP_LT, H_TXT = 0.85, 0.18, 1.8\n'
|
||||||
|
' blk = H_LBL + GAP_LT + H_TXT\n'
|
||||||
|
' y_lbl = y + max(0.0, (ch - blk) / 2)\n'
|
||||||
|
' self._text(slide, self.MX + 3.81, y_lbl, 8.1, H_LBL, '
|
||||||
|
'label,\n'
|
||||||
|
' size=14, bold=True, color=col, '
|
||||||
|
'char_spacing=3,\n'
|
||||||
|
' anchor=MSO_ANCHOR.MIDDLE)\n'
|
||||||
|
' self._text(slide, self.MX + 3.81, y_lbl + H_LBL + '
|
||||||
|
'GAP_LT,\n'
|
||||||
|
' self.SLIDE_W - 2 * self.MX - 5.33, H_TXT, '
|
||||||
|
'txt,\n'
|
||||||
|
' size=self.T["body"], color=self.C["body"],'
|
||||||
|
'\n'
|
||||||
|
' anchor=MSO_ANCHOR.MIDDLE)\n',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
MARKER = "Hauteur réelle du bloc (C3b)"
|
||||||
|
|
||||||
|
|
||||||
|
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é (C3b 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 "
|
||||||
|
"(version différente ?)." % i)
|
||||||
|
if n > 1:
|
||||||
|
fail("Ancre du patch %d non unique (%d occurrences)." % (i, n))
|
||||||
|
|
||||||
|
shutil.copy2(TARGET, str(TARGET) + ".bak-c3b")
|
||||||
|
print(" + Sauvegarde : %s.bak-c3b" % TARGET)
|
||||||
|
|
||||||
|
for old, new in PATCHES:
|
||||||
|
content = content.replace(old, new)
|
||||||
|
|
||||||
|
TARGET.write_text(content, encoding="utf-8")
|
||||||
|
print(" + 2 patchs de centrage appliqués (big_stat, "
|
||||||
|
"executive_summary).")
|
||||||
|
try:
|
||||||
|
py_compile.compile(str(TARGET), doraise=True)
|
||||||
|
print(" + Compilation OK.")
|
||||||
|
except py_compile.PyCompileError as e:
|
||||||
|
shutil.copy2(str(TARGET) + ".bak-c3b", TARGET)
|
||||||
|
fail("Erreur de compilation — fichier restauré :\n%s" % e)
|
||||||
|
print("\n Re-rends et compare les previews : big_stat (chiffre "
|
||||||
|
"mieux centré),")
|
||||||
|
print(" executive_summary (texte centré dans les cartes). Vérifie "
|
||||||
|
"aussi from_to_pairs")
|
||||||
|
print(" (le rognage des labels a pu disparaître avec le fix polices "
|
||||||
|
"C3).")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user