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>
173 lines
5.6 KiB
Python
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 []),
|
|
)
|