237 lines
8.6 KiB
Python
237 lines
8.6 KiB
Python
#!/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]))
|