diff --git a/scripts/notebook_tools/scan_slidev_overflow.py b/scripts/notebook_tools/scan_slidev_overflow.py new file mode 100644 index 00000000000..bb7c1def935 --- /dev/null +++ b/scripts/notebook_tools/scan_slidev_overflow.py @@ -0,0 +1,184 @@ +"""Detection du debordement par le bas des slides exportees en PNG (EPIC #11508, lot L2). + +Reinstrumentation de la passe S3-acculturation (issue #11508, section 2) : une +slide deborde quand de l'encre atteint les N dernieres lignes de pixels du PNG +exporte -- le contenu est coupe au rendu, et le defaut est invisible dans le +``.md`` source. Methode : sur chaque PNG du repertoire d'export +(``slidev export --format png``), compter les pixels d'encre (luminance sous le +seuil) dans les ``--rows`` dernieres lignes ; une slide est signalee OVERFLOW si +le ratio encre / largeur mesuree depasse ``--threshold``. + +Deux choix mesures, pas devines : + +- ``--ignore-right-frac`` (defaut 0.10) retire de la mesure la bande droite de + la slide, ou le theme rend le numero de page -- sans elle, toute slide + numerotee serait signalee et l'instrument ne distinguerait plus rien. Le + numero est de l'encre legitime, pas un debordement. +- l'encre est relative au fond dominant de la slide (mesure sur la ligne du + haut, tolerance ``BG_TOLERANCE``) : une page de titre sombre n'est pas un + debordement -- seul ce qui DIFFERE du fond compte, sur fond clair comme + sur fond sombre. + +Ce scan mesure, il ne qualifie pas : une slide signalee se relit a l'oeil sur +le PNG (planches-contact puis pleine taille) avant tout correctif -- le +mandat de l'EPIC exige la verification visuelle, et une lane sans vision ne +prononce pas qu'un rendu est correct (model-delegation). + +L'instrument repond de façon bruyante aux entrees qu'il ne sait pas lire : +repertoire absent, vide de PNG, ou PNG illisible sortent en erreur nommee sur +stderr avec un exit non nul -- jamais un tableau vide qui se lirait comme +« aucun debordement ». C'est le mode de panne consigne par la note de methode +de l'EPIC (six instruments faux en une session, dont le resultat etait +systematiquement plus petit et plus propre que la verite). + +Usage: + python scan_slidev_overflow.py # table lisible + python scan_slidev_overflow.py --json # sortie machine + python scan_slidev_overflow.py --rows 6 --threshold 0.01 +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + +try: + from PIL import Image +except ImportError: # pragma: no cover - environnement sans Pillow + print("PIL (Pillow) est requis : pip install Pillow", file=sys.stderr) + sys.exit(2) + +BG_TOLERANCE = 12 # un pixel a moins de ceci du fond n'est PAS de l'encre +DEFAULT_ROWS = 6 # les 6 dernieres lignes, comme la passe S3 +DEFAULT_THRESHOLD = 0.01 # 1 % de la largeur mesurable en encre -> OVERFLOW +DEFAULT_IGNORE_RIGHT = 0.10 # bande droite retiree (numero de page du theme) + + +def _natural_key(path: Path): + """Cle de tri numerique : 1, 2, ... 10, 11 -- pas 1, 10, 11, 2.""" + return [ + int(part) if part.isdigit() else part.lower() + for part in re.split(r"(\d+)", path.name) + ] + + +def _ink_ratio(png: Path, rows: int, ignore_right_frac: float) -> float: + """Ratio encre / largeur mesurable sur les ``rows`` dernieres lignes du PNG. + + L'encre est RELATIVE au fond dominant de la slide (mesure sur la ligne du + haut) : sur un deck a pages sombres, le fond sombre n'est pas un + debordement -- seule la matiere qui DIFFERE du fond compte. Sur fond + blanc, un pixel sombre differe du fond ; sur fond sombre, un pixel clair + aussi. Les deux cas se mesurent avec la meme regle. + + Leve une exception nommee si le PNG est illisible -- l'appelant la rapporte, + il ne l'avale pas. + """ + with Image.open(png) as img: + gray = img.convert("L") + w, h = gray.size + band = gray.crop((0, h - rows, w, h)) + # fond dominant = valeur la plus frequente de l'image entiere : une + # cover a degrade ou a fond image est pleine page par design, la ligne + # du haut seule ne la represente pas -- la mode de l'image, si + histogram = gray.histogram() + background = histogram.index(max(histogram)) + measurable = max(1, int(band.width * (1.0 - ignore_right_frac))) + pixels = band.tobytes() + # bytes ligne par ligne : sauter la bande droite exige de trancher CHAQUE + # ligne, pas les premiers measurable*rows octets (sinon l'encre de la bande + # droite glisse dans la mesure des lignes paires) + ink = 0 + for r in range(band.height): + row = pixels[r * band.width : (r + 1) * band.width] + ink += sum(1 for v in row[:measurable] if abs(v - background) > BG_TOLERANCE) + return ink / (measurable * band.height) + + +def scan_export_dir(export_dir: Path, rows: int, threshold: float, + ignore_right_frac: float) -> dict: + """Scanne un repertoire d'export PNG ; retourne le rapport par slide.""" + if not export_dir.is_dir(): + raise FileNotFoundError(f"repertoire d'export introuvable : {export_dir}") + pngs = sorted(export_dir.glob("*.png"), key=_natural_key) + if not pngs: + raise FileNotFoundError(f"aucun PNG dans {export_dir}") + + slides, unreadable = [], [] + for png in pngs: + try: + ratio = _ink_ratio(png, rows, ignore_right_frac) + except Exception as exc: # PNG corrompu / format inattendu : nommer, pas taire + unreadable.append({"slide": png.name, "error": f"{type(exc).__name__}: {exc}"}) + continue + slides.append({ + "slide": png.name, + "ink_ratio": round(ratio, 5), + "overflow": ratio >= threshold, + }) + + if not slides: + raise RuntimeError( + f"tous les PNG de {export_dir} sont illisibles : " + f"{[u['slide'] for u in unreadable]}" + ) + + flagged = [s for s in slides if s["overflow"]] + return { + "export_dir": str(export_dir), + "rows": rows, + "threshold": threshold, + "ignore_right_frac": ignore_right_frac, + "slides_total": len(slides), + "slides_overflow": len(flagged), + "slides": slides, + "unreadable": unreadable, + } + + +def _print_table(report: dict) -> None: + print(f"export : {report['export_dir']}") + print(f"mesure : {report['rows']} dernieres lignes, seuil {report['threshold']}, " + f"bande droite ignoree {report['ignore_right_frac']:.0%}") + print(f"verdict : {report['slides_overflow']}/{report['slides_total']} slides OVERFLOW") + for u in report["unreadable"]: + print(f"ILLISIBLE {u['slide']} -- {u['error']}", file=sys.stderr) + for s in report["slides"]: + if s["overflow"]: + print(f" OVERFLOW {s['slide']:>18} encre {s['ink_ratio']:.1%}") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("export_dir", type=Path, + help="repertoire des PNG exportes (slidev export --format png)") + parser.add_argument("--rows", type=int, default=DEFAULT_ROWS, + help=f"nombre de lignes de pixels en bas (defaut {DEFAULT_ROWS})") + parser.add_argument("--threshold", type=float, default=DEFAULT_THRESHOLD, + help=f"ratio d'encre declenchant OVERFLOW (defaut {DEFAULT_THRESHOLD})") + parser.add_argument("--ignore-right-frac", type=float, default=DEFAULT_IGNORE_RIGHT, + help="fraction droite retiree de la mesure, zone du numero de page " + f"(defaut {DEFAULT_IGNORE_RIGHT})") + parser.add_argument("--json", action="store_true", help="sortie machine") + args = parser.parse_args(argv) + + try: + report = scan_export_dir(args.export_dir, args.rows, args.threshold, + args.ignore_right_frac) + except (FileNotFoundError, RuntimeError) as exc: + print(f"ERREUR : {exc}", file=sys.stderr) + return 2 + + if args.json: + print(json.dumps(report, ensure_ascii=False, indent=2)) + else: + _print_table(report) + # exit 1 si des debordements sont mesures : l'instrument peut servir de gate + return 1 if report["slides_overflow"] else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/notebook_tools/tests/test_scan_slidev_overflow.py b/scripts/notebook_tools/tests/test_scan_slidev_overflow.py new file mode 100644 index 00000000000..5fea8b51938 --- /dev/null +++ b/scripts/notebook_tools/tests/test_scan_slidev_overflow.py @@ -0,0 +1,93 @@ +"""Tests de scan_slidev_overflow (EPIC #11508 L2) — PNG synthétiques couvrant les verdicts.""" + +import sys +from pathlib import Path + +from PIL import Image, ImageDraw + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from scan_slidev_overflow import main, scan_export_dir # noqa: E402 + + +def _png(path: Path, w: int, h: int, rects): + """Écrit un PNG blanc avec des rectangles noirs (x0, y0, x1, y1).""" + img = Image.new("L", (w, h), 255) + draw = ImageDraw.Draw(img) + for x0, y0, x1, y1 in rects: + draw.rectangle([x0, y0, x1, y1], fill=0) + img.save(path) + + +def _make_export(root: Path): + d = root / "export" + d.mkdir() + # 1.png : contenu au centre, bas propre -> pas de débordement + _png(d / "1.png", 200, 100, [(20, 20, 180, 60)]) + # 2.png : barre noire mordant les 2 dernières lignes sur 60 % de la largeur + _png(d / "2.png", 200, 100, [(20, 20, 140, 99)]) + # 3.png : encre uniquement dans la bande droite ignorée (numéro de page) + _png(d / "3.png", 200, 100, [(185, 97, 195, 99)]) + return d + + +def test_overflow_detected_and_clean_passed(tmp_path): + report = scan_export_dir(_make_export(tmp_path), rows=6, threshold=0.01, + ignore_right_frac=0.10) + verdicts = {s["slide"]: s["overflow"] for s in report["slides"]} + assert verdicts == {"1.png": False, "2.png": True, "3.png": False} + + +def test_natural_sort_order(tmp_path): + d = _make_export(tmp_path) + _png(d / "10.png", 200, 100, [(20, 20, 180, 60)]) + report = scan_export_dir(d, rows=6, threshold=0.01, ignore_right_frac=0.10) + assert [s["slide"] for s in report["slides"]] == ["1.png", "2.png", "3.png", "10.png"] + + +def test_page_number_zone_is_measured_when_not_ignored(tmp_path): + """Sans bande ignorée, l'encre du numéro de page compte : garde anti-régression + du choix --ignore-right-frac par défaut (sinon toute slide numérotée déborde).""" + report = scan_export_dir(_make_export(tmp_path), rows=6, threshold=0.01, + ignore_right_frac=0.0) + verdicts = {s["slide"]: s["overflow"] for s in report["slides"]} + assert verdicts["3.png"] is True + + +def test_unreadable_png_reported_not_silent(tmp_path): + d = _make_export(tmp_path) + (d / "corrupt.png").write_bytes(b"pas un png") + report = scan_export_dir(d, rows=6, threshold=0.01, ignore_right_frac=0.10) + names = [u["slide"] for u in report["unreadable"]] + assert names == ["corrupt.png"] + assert len(report["slides"]) == 3 # les lisibles restent mesurées + + +def test_missing_dir_is_a_named_error(tmp_path): + try: + scan_export_dir(tmp_path / "absent", rows=6, threshold=0.01, + ignore_right_frac=0.10) + raised = False + except FileNotFoundError: + raised = True + assert raised + + +def test_empty_dir_is_a_named_error(tmp_path): + d = tmp_path / "vide" + d.mkdir() + try: + scan_export_dir(d, rows=6, threshold=0.01, ignore_right_frac=0.10) + raised = False + except FileNotFoundError: + raised = True + assert raised + + +def test_main_exit_codes(tmp_path, capsys): + d = _make_export(tmp_path) + assert main([str(d)]) == 1 # un débordement mesuré -> exit 1 + assert main([str(d), "--json"]) == 1 + assert main([str(tmp_path / "absent")]) == 2 # entrée illisible -> exit 2 + out = capsys.readouterr().out + assert "1/3 slides OVERFLOW" in out