feat: mesure texte PIL, fitter anti-débordement et centrage vertical (C3, C3b)

This commit is contained in:
2026-07-09 21:50:21 +02:00
parent d8c0bf5d3a
commit 70a1605ec0
4 changed files with 824 additions and 0 deletions
+236
View File
@@ -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]))