Files
anonymisation/gui_v6/mask_editor_model.py
Domi31tls 13b79db417 feat(gui): éditeur de masques en fenêtre dédiée (GUI V6)
Remplace l'éditeur de masquage encastré dans l'onglet Configuration —
jugé inutilisable par Dom (document trop à l'étroit, non défilable) —
par une fenêtre dédiée où le document est majoritaire et réellement
navigable.

- gui_v6/mask_editor_model.py : couche logique pure (rectangles par
  page, conversions écran↔PDF, hit-test, sérialisation template)
  testable sans display ; réutilise MaskRect/Template de
  pdf_mask_designer → format de template inchangé (compat moteur).
- gui_v6/mask_editor_window.py : MaskEditorWindow (CTkToplevel)
  redimensionnable — canvas + scrollbars H+V câblées + molette (le
  manque qui rendait l'éditeur inutilisable), zoom + ajuster
  largeur/page, navigation pages, rectangles au glisser-déposer,
  sélection (clic) + suppression (Suppr / clic-droit), templates
  JSON/YAML, mode aperçu d'exemple sans PDF.
- tab_config.py : l'onglet Masquage lance la fenêtre dédiée ; retrait
  du canvas encastré et de ~290 lignes de code mort associé.
- tests/unit/test_gui_v6_mask_editor.py : 13 tests logique + 3 smoke
  headless (scrollbars, ajout/sélection/suppression, save/load
  roundtrip, câblage onglet→fenêtre).

Sans nouvelle dépendance. V5, moteur et app_aivanov non touchés.
221 tests unit OK (0 régression), self-test GUI V6 OK.
Verdict Qwen requis avant push/build/diffusion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:05:57 +02:00

173 lines
5.6 KiB
Python

"""Couche logique pure de l'éditeur de masques (testable sans display Tk).
Sépare la logique métier (rectangles par page, conversions écran↔PDF, hit-test,
sérialisation de templates) de la fenêtre Tk `MaskEditorWindow`. Le format de
template reste strictement compatible avec `pdf_mask_designer.Template` (et donc
avec le moteur d'anonymisation) : on réutilise `MaskRect`/`Template`.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Optional
from pdf_mask_designer import MaskRect, Template, clamp, rect_norm
# Bornes d'affichage de l'éditeur.
ZOOM_MIN = 0.25
ZOOM_MAX = 4.0
# Côté minimal (en points PDF) d'un rectangle dessiné ; en dessous, on ignore
# (évite les masques fantômes créés par un simple clic).
MIN_RECT_SIZE = 3.0
_DEFAULT_PAGE_SIZE = (595.0, 842.0)
def clamp_zoom(zoom: float) -> float:
return clamp(float(zoom), ZOOM_MIN, ZOOM_MAX)
def clamp_page(index: int, page_count: int) -> int:
if page_count <= 0:
return 0
return int(clamp(int(index), 0, page_count - 1))
def screen_to_pdf(cx: float, cy: float, zoom: float) -> tuple[float, float]:
"""Coordonnées canvas (espace scrollregion, origine 0,0) -> points PDF."""
z = zoom or 1.0
return (cx / z, cy / z)
def pdf_to_screen(px: float, py: float, zoom: float) -> tuple[float, float]:
return (px * zoom, py * zoom)
def fit_width_zoom(page_width_pt: float, viewport_px: float, padding: float = 24.0) -> float:
if page_width_pt <= 0:
return 1.0
usable = max(1.0, viewport_px - padding)
return usable / page_width_pt
def fit_page_zoom(
page_width_pt: float,
page_height_pt: float,
viewport_w_px: float,
viewport_h_px: float,
padding: float = 24.0,
) -> float:
if page_width_pt <= 0 or page_height_pt <= 0:
return 1.0
zw = max(1.0, viewport_w_px - padding) / page_width_pt
zh = max(1.0, viewport_h_px - padding) / page_height_pt
return min(zw, zh)
@dataclass
class MaskEditorModel:
"""État pur de l'éditeur : rectangles (toutes pages) + position courante."""
name: str = "template_masques"
page_index: int = 0
page_count: int = 0
page_sizes: list[tuple[float, float]] = field(default_factory=list)
masks: list[MaskRect] = field(default_factory=list)
# --- rectangles ---------------------------------------------------------
def add_rect(
self,
page: int,
x0: float,
y0: float,
x1: float,
y1: float,
label: str = "MASK",
) -> Optional[MaskRect]:
nx0, ny0, nx1, ny1 = rect_norm(float(x0), float(y0), float(x1), float(y1))
if (nx1 - nx0) < MIN_RECT_SIZE or (ny1 - ny0) < MIN_RECT_SIZE:
return None
rect = MaskRect(page=int(page), x0=nx0, y0=ny0, x1=nx1, y1=ny1, label=label)
self.masks.append(rect)
return rect
def masks_for_page(self, page: int) -> list[MaskRect]:
return [m for m in self.masks if m.page == page]
def rect_at(self, page: int, px: float, py: float) -> Optional[MaskRect]:
"""Rectangle le plus en avant (dernier dessiné) contenant le point."""
for rect in reversed(self.masks):
if rect.page != page:
continue
if rect.x0 <= px <= rect.x1 and rect.y0 <= py <= rect.y1:
return rect
return None
def delete_rect(self, rect: MaskRect) -> bool:
for idx, existing in enumerate(self.masks):
if existing is rect:
del self.masks[idx]
return True
return False
def clear_page(self, page: int) -> int:
before = len(self.masks)
self.masks = [m for m in self.masks if m.page != page]
return before - len(self.masks)
def clear_all(self) -> int:
count = len(self.masks)
self.masks.clear()
return count
def count_page(self, page: int) -> int:
return sum(1 for m in self.masks if m.page == page)
def count_total(self) -> int:
return len(self.masks)
# --- pages --------------------------------------------------------------
def set_page(self, index: int) -> int:
self.page_index = clamp_page(index, self.page_count)
return self.page_index
def current_page_size(self) -> tuple[float, float]:
if 0 <= self.page_index < len(self.page_sizes):
return self.page_sizes[self.page_index]
return _DEFAULT_PAGE_SIZE
def reference_page_size(self) -> tuple[float, float]:
if self.page_sizes:
return self.page_sizes[0]
return _DEFAULT_PAGE_SIZE
# --- templates (compat pdf_mask_designer.Template) ----------------------
def to_payload(self) -> dict[str, Any]:
width, height = self.reference_page_size()
return {
"version": 1,
"name": (self.name or "template_masques").strip() or "template_masques",
"page_size": {"width": float(width), "height": float(height)},
"masks": [
{
"page": int(m.page),
"x0": round(float(m.x0), 2),
"y0": round(float(m.y0), 2),
"x1": round(float(m.x1), 2),
"y1": round(float(m.y1), 2),
"label": str(m.label),
}
for m in self.masks
],
}
@classmethod
def from_payload(cls, payload: dict[str, Any]) -> "MaskEditorModel":
tpl = Template.from_dict(payload or {})
return cls(
name=tpl.name,
page_sizes=[tpl.page_size],
masks=list(tpl.masks or []),
)