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]))
|
||||
Reference in New Issue
Block a user