#!/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]))