Files

98 lines
3.7 KiB
Go

// 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
}