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]))
|