Files
sliding-automation/measure.py
T

237 lines
8.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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]))