"""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 []), )