"""Rendu HTML de la vue « Worst lines globale » — Sprint 72. Suite directe de ``picarones/core/worst_lines.py`` (extraction transversale). Pattern identique aux Sprints 41/43/62/67 : rendu **server-side**, pas de JavaScript, anti-injection systématique via ``html.escape``. Vue distincte du tableau gallery existant ----------------------------------------- La galerie OCR (vue ``view_gallery.html``) liste les documents les plus problématiques. Cette vue va plus fin : elle liste les **lignes individuelles** les plus problématiques, transversalement à tous les documents et moteurs. Complémentaire, pas redondante. """ from __future__ import annotations from html import escape as _e from typing import Optional from picarones.measurements.worst_lines import WorstLineEntry from picarones.core.diff_utils import compute_char_diff from picarones.report.render_helpers import color_traffic_light def _bg_for_cer(cer: float) -> str: """Beige clair sous le seuil catastrophique (0.30), gradient jaune → rouge au-delà. Le seuil dur à 0.30 préserve la sémantique « toléré jusqu'à 30 % pour un manuscrit difficile ». Au-delà, on entre en zone visible avec :func:`color_traffic_light` (low_is_good). """ f = max(0.0, min(1.0, cer)) if f < 0.3: return "#fff8dc" return color_traffic_light(f, low_is_good=True, scale_min=0.3, scale_max=1.0) def _render_diff_inline(reference: str, hypothesis: str) -> str: """Rendu HTML inline d'un diff caractère par caractère. - ``equal`` → texte normal - ``delete`` → fond rouge clair, barré (manquait dans hyp) - ``insert`` → fond vert clair (ajouté par hyp) - ``replace`` → fond rouge clair barré + fond vert clair pour la nouvelle valeur (côte à côte) """ if not reference and not hypothesis: return '∅' ops = compute_char_diff(reference or "", hypothesis or "") parts: list[str] = [] for op in ops: kind = op["op"] if kind == "equal": parts.append(_e(op["text"])) elif kind == "delete": parts.append( f'' f'{_e(op["text"])}' ) elif kind == "insert": parts.append( f'{_e(op["text"])}' ) elif kind == "replace": parts.append( f'' f'{_e(op["old"])}' f'{_e(op["new"])}' ) return "".join(parts) def build_worst_lines_table_html( entries: list[WorstLineEntry], labels: Optional[dict[str, str]] = None, ) -> str: """Construit le tableau HTML des worst lines. Retourne ``""`` si la liste est vide. Adaptive : si aucune entrée n'a de ``script_type``, la colonne strate est omise. """ if not entries: return "" labels = labels or {} title = labels.get("worst_lines_title", "Lignes les plus problématiques") note = labels.get( "worst_lines_note", "Top-N lignes du corpus classées par CER ligne décroissant. " "Diff caractère par caractère : rouge barré = manquant dans " "l'OCR, vert = ajouté par l'OCR.", ) rank_label = labels.get("worst_lines_rank_label", "Rang") cer_label = labels.get("worst_lines_cer_label", "CER") engine_label = labels.get("worst_lines_engine_label", "Moteur") doc_label = labels.get("worst_lines_doc_label", "Document") line_label = labels.get("worst_lines_line_label", "Ligne #") strata_label = labels.get("worst_lines_strata_label", "Strate") diff_label = labels.get("worst_lines_diff_label", "GT → OCR (diff)") has_strata = any(e.script_type for e in entries) parts = [ '
| ' f'{_e(col)} | ' ) parts.append("||||||
|---|---|---|---|---|---|---|
| {entry.rank} | ' ) parts.append( f'' f'{entry.cer * 100:.1f}% | ' ) parts.append( f'{_e(entry.engine_name)} | ' ) parts.append( f'{_e(entry.doc_id)} | ' ) parts.append( f'' f'{entry.line_index} | ' ) if has_strata: parts.append( f'' f'{_e(entry.script_type or "—")} | ' ) parts.append( f'' f'{_render_diff_inline(entry.gt_line, entry.hyp_line)} | ' ) parts.append("