"""Pure geometry helpers for text boxes / baselines. Coordinates in the tree are normalised 0..1; these helpers convert them to pixel space and produce the rotated quadrilaterals (``quad``) that the renderer and the eraser both consume. A *token* here is any dict with a ``box`` and (optionally) ``baseline_p1`` / ``baseline_p2`` — items and spans both qualify. """ from __future__ import annotations import copy import math # Lens reports the OCR baseline; the visual box is shifted down from it by a # fraction of the text height. These two tunables reproduce that shift. BASELINE_SHIFT: bool = True BASELINE_SHIFT_FACTOR: float = 0.40 Quad = list[tuple[float, float]] def ensure_box_fields(box: dict | None) -> dict: """Return a copy of ``box`` with derived fields (center, *_pct) filled in. Lens boxes only carry ``left/top/width/height``; the renderer also wants a ``center`` and percentage variants. Missing rotation defaults to 0. """ if not isinstance(box, dict): return {} b = copy.deepcopy(box) b.setdefault("rotation_deg", 0.0) b.setdefault("rotation_deg_css", 0.0) has_rect = all(k in b for k in ("left", "top", "width", "height")) if has_rect: if "center" not in b: b["center"] = { "x": b["left"] + b["width"] / 2.0, "y": b["top"] + b["height"] / 2.0, } b.setdefault("left_pct", b["left"] * 100.0) b.setdefault("top_pct", b["top"] * 100.0) b.setdefault("width_pct", b["width"] * 100.0) b.setdefault("height_pct", b["height"] * 100.0) return b def token_box_px(token: dict, W: int, H: int, pad_px: int = 0) -> tuple[int, int, int, int] | None: """Axis-aligned pixel bbox of a token's ``box`` (ignores rotation). Returns ``None`` if the box is degenerate or fully off-canvas. """ b = token.get("box") or {} left = int(round(float(b.get("left", 0.0)) * W)) - pad_px top = int(round(float(b.get("top", 0.0)) * H)) - pad_px right = int(round((float(b.get("left", 0.0)) + float(b.get("width", 0.0))) * W)) + pad_px bottom = int(round((float(b.get("top", 0.0)) + float(b.get("height", 0.0))) * H)) + pad_px left = max(0, min(W, left)) top = max(0, min(H, top)) right = max(0, min(W, right)) bottom = max(0, min(H, bottom)) if right <= left or bottom <= top: return None return left, top, right, bottom def token_quad_px(token: dict, W: int, H: int, pad_px: float = 0.0, apply_baseline_shift: bool = True) -> Quad | None: """Rotated quad built from a token's *baseline* + height. This is the precise text outline — it follows the baseline direction rather than an axis-aligned box, so it works for slanted text. Returns ``None`` for invalid / zero-length tokens. """ if not token.get("valid_text"): return None p1 = token.get("baseline_p1") or {} p2 = token.get("baseline_p2") or {} x1 = float(p1.get("x", 0.0)) * W y1 = float(p1.get("y", 0.0)) * H x2 = float(p2.get("x", 0.0)) * W y2 = float(p2.get("y", 0.0)) * H dx = x2 - x1 dy = y2 - y1 # Normalise direction so the baseline always points left->right-ish. if dx < 0 or (abs(dx) < 1e-12 and dy < 0): x1, y1, x2, y2 = x2, y2, x1, y1 dx, dy = x2 - x1, y2 - y1 length = math.hypot(dx, dy) if length <= 1e-9: return None ux, uy = dx / length, dy / length nx, ny = -uy, ux if ny < 0: nx, ny = -nx, -ny t0 = float(token.get("t0_raw") if token.get("t0_raw") is not None else 0.0) t1 = float(token.get("t1_raw") if token.get("t1_raw") is not None else 1.0) sx = x1 + ux * (t0 * length) sy = y1 + uy * (t0 * length) ex = x1 + ux * (t1 * length) ey = y1 + uy * (t1 * length) h = max(1.0, float(token.get("height_raw") or 0.0) * H) if apply_baseline_shift and BASELINE_SHIFT: shift = h * BASELINE_SHIFT_FACTOR sx += nx * shift sy += ny * shift ex += nx * shift ey += ny * shift pad = max(0.0, float(pad_px)) sx -= ux * pad sy -= uy * pad ex += ux * pad ey += uy * pad hh = (h / 2.0) + pad ox, oy = nx * hh, ny * hh return [(sx - ox, sy - oy), (ex - ox, ey - oy), (ex + ox, ey + oy), (sx + ox, sy + oy)] def token_box_quad_px(token: dict, W: int, H: int, pad_px: float = 0.0) -> Quad | None: """Rotated quad built from a token's *box* (left/top/width/height + angle). Unlike :func:`token_quad_px` this uses the axis box rotated about its centre — used where the baseline is not needed (e.g. bounds computation). """ b = token.get("box") or {} w = float(b.get("width", 0.0)) * W h = float(b.get("height", 0.0)) * H if w <= 0.0 or h <= 0.0: return None left = float(b.get("left", 0.0)) * W top = float(b.get("top", 0.0)) * H cx = left + (w / 2.0) cy = top + (h / 2.0) hw = (w / 2.0) + float(pad_px) hh = (h / 2.0) + float(pad_px) rad = math.radians(float(b.get("rotation_deg", 0.0))) c, s = math.cos(rad), math.sin(rad) out: Quad = [] for x, y in [(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)]: out.append((cx + (x * c - y * s), cy + (x * s + y * c))) return out def quad_bbox(quad: Quad, W: int, H: int) -> tuple[int, int, int, int] | None: """Integer axis-aligned bbox of a quad, clamped to the canvas.""" xs = [p[0] for p in quad] ys = [p[1] for p in quad] left = max(0, min(W, int(math.floor(min(xs))))) top = max(0, min(H, int(math.floor(min(ys))))) right = max(0, min(W, int(math.ceil(max(xs))))) bottom = max(0, min(H, int(math.ceil(max(ys))))) if right <= left or bottom <= top: return None return left, top, right, bottom def normalize_angle_deg(angle_deg: float) -> float: """Fold an angle into (-90, 90]. Text rotated 200° is visually the same as 20°; the renderer only cares about that folded value. """ while angle_deg <= -180.0: angle_deg += 360.0 while angle_deg > 180.0: angle_deg -= 360.0 if angle_deg < -90.0: angle_deg += 180.0 if angle_deg > 90.0: angle_deg -= 180.0 return angle_deg