// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package atspi import ( "errors" "image" "math" "git.stormux.org/storm/cthulhu/internal/platform/xorg" ) var ErrOCRGeometry = errors.New("OCR source bounds unavailable") // OCRGeometry is an owned snapshot. ProviderBounds/WindowBounds use AT-SPI // coordinates; ScreenBounds uses physical X11 root pixels. It authorizes // capture only, never activation. Foreground must still match at capture time. type OCRGeometry struct { Object ObjectID ProviderBounds, WindowBounds Rectangle ScreenBounds image.Rectangle Foreground ForegroundState } // Match the source's own fresh top-level window, allowing the same proportional // scaling supported by the capture contract. Origins must match either the // physical or uniformly scaled root. Unknown origins are rejected. Use measured // per-axis ratios, not DPI. func ocr_screen_bounds(bounds, window Rectangle, client xorg.Foreground) (image.Rectangle, error) { if !bounds.Valid() || !window.Valid() || !client.Known || client.Window == 0 { return image.Rectangle{}, ErrOCRGeometry } inner := Rectangle{int32(client.X), int32(client.Y), int32(client.Width), int32(client.Height)} outer := Rectangle{int32(client.OuterX), int32(client.OuterY), int32(client.OuterWidth), int32(client.OuterHeight)} matched := Rectangle{} // Exact matches take precedence over a proportional decoration match. for _, candidate := range []Rectangle{inner, outer} { if window == candidate { matched = candidate break } } if !matched.Valid() { for _, candidate := range []Rectangle{inner, outer} { if ocr_window_matches(window, candidate) { if matched.Valid() && matched != candidate { return image.Rectangle{}, ErrOCRGeometry } matched = candidate } } } if !matched.Valid() { return image.Rectangle{}, ErrOCRGeometry } clipped, ok := bounds.Intersection(window) if !ok { return image.Rectangle{}, ErrOCRGeometry } // Use exact integer edge ratios. Provider windows can exceed the OCR image // pixel cap while the chosen object/zone remains a small valid capture. map_edge := func(value int64, origin, extent, screenOrigin, screenExtent int32, upper bool) int { numerator := (value - int64(origin)) * int64(screenExtent) if upper { numerator += int64(extent) - 1 } return int(int64(screenOrigin) + numerator/int64(extent)) } mapped := image.Rect(map_edge(int64(clipped.X), window.X, window.Width, matched.X, matched.Width, false), map_edge(int64(clipped.Y), window.Y, window.Height, matched.Y, matched.Height, false), map_edge(int64(clipped.X)+int64(clipped.Width), window.X, window.Width, matched.X, matched.Width, true), map_edge(int64(clipped.Y)+int64(clipped.Height), window.Y, window.Height, matched.Y, matched.Height, true)) if mapped.Empty() { return image.Rectangle{}, ErrOCRGeometry } return mapped, nil } // GTK can scale the root origin as well as dimensions; Chromium may retain // a physical root origin. Admit these two measured contracts only. Other // monitor-dependent origin conventions need separate evidence. func ocr_window_matches(provider, native Rectangle) bool { if !provider.Valid() || !native.Valid() { return false } scaleX, scaleY := float64(native.Width)/float64(provider.Width), float64(native.Height)/float64(provider.Height) if scaleX < 0.5 || scaleX > 3 || scaleY < 0.5 || scaleY > 3 || math.Abs(scaleX-scaleY) > 0.01*math.Max(scaleX, scaleY) { return false } return provider.X == native.X && provider.Y == native.Y || math.Abs(float64(provider.X)*scaleX-float64(native.X)) <= 1 && math.Abs(float64(provider.Y)*scaleY-float64(native.Y)) <= 1 }