279 lines
9.2 KiB
Go
279 lines
9.2 KiB
Go
// Cthulhu screen reader.
|
|
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify it
|
|
// under the terms of the GNU General Public License as published by the Free
|
|
// Software Foundation, either version 3 of the License, or (at your option)
|
|
// any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful, but WITHOUT
|
|
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
|
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
|
// more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License along with
|
|
// this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
|
|
|
|
package review
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sort"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
|
)
|
|
|
|
var (
|
|
ErrNoReviewContent = errors.New("no visible content for flat review")
|
|
ErrLimitedReview = errors.New("visible text exceeds flat review capture limits")
|
|
)
|
|
|
|
const (
|
|
maxFlatZones = 16384
|
|
maxFlatTextBytes = 1 << 20
|
|
)
|
|
|
|
type flatZone struct {
|
|
object atspi.ObjectID
|
|
parent atspi.ObjectID
|
|
role, parentRole string
|
|
text string
|
|
start int
|
|
hasOffset bool
|
|
bounds atspi.Rectangle
|
|
runes []rune
|
|
words [][2]int
|
|
}
|
|
|
|
type flatLine struct{ zones []flatZone }
|
|
|
|
// buildFlatLines uses native visual ranges, followed by stable screen order.
|
|
// Like Python's default geometric rule, line membership requires overlapping
|
|
// vertical extents and centres within five pixels. Compare to the first zone
|
|
// so a staircase of overlapping objects cannot merge separate text rows.
|
|
func buildFlatLines(ctx context.Context, nodes []atspi.TreeNode) ([]flatLine, error) {
|
|
parents, nodeClips, hidden := flat_node_visibility(nodes)
|
|
zones, err := collect_flat_zones(ctx, nodes, parents, nodeClips, hidden)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return arrange_flat_lines(ctx, nodes, zones)
|
|
}
|
|
|
|
func sameFlatLine(a, b flatZone) bool {
|
|
if a.role == "scroll bar" || b.role == "scroll bar" {
|
|
return a.object == b.object
|
|
}
|
|
if a.parentRole == "menu bar" || b.parentRole == "menu bar" {
|
|
return a.parent == b.parent
|
|
}
|
|
ay, by := int64(a.bounds.Y), int64(b.bounds.Y)
|
|
ah, bh := int64(a.bounds.Height), int64(b.bounds.Height)
|
|
if max(ay, by) >= min(ay+ah, by+bh) {
|
|
return false
|
|
}
|
|
delta := (2*ay + ah) - (2*by + bh)
|
|
return delta >= -10 && delta <= 10
|
|
}
|
|
|
|
// flat_node_visibility follows parent clipping before collecting any text.
|
|
func flat_node_visibility(nodes []atspi.TreeNode) (map[atspi.ObjectID]atspi.Snapshot, map[atspi.ObjectID]atspi.Rectangle, map[atspi.ObjectID]bool) {
|
|
parents := make(map[atspi.ObjectID]atspi.Snapshot, len(nodes))
|
|
clips := make(map[atspi.ObjectID]atspi.Rectangle, len(nodes))
|
|
nodeClips := make(map[atspi.ObjectID]atspi.Rectangle, len(nodes))
|
|
hidden := make(map[atspi.ObjectID]bool, len(nodes))
|
|
for _, node := range nodes {
|
|
id := node.Snapshot.ID()
|
|
parent := parents[id].ID()
|
|
g := node.Geometry
|
|
hidden[id] = hidden[parent] || g.VisibilityKnown && (!g.Visible || !g.Showing)
|
|
clip := clips[parent]
|
|
nodeClip := clip
|
|
if g.BoundsKnown {
|
|
if clip.Valid() {
|
|
var ok bool
|
|
nodeClip, ok = clip.Intersection(g.Bounds)
|
|
if node.Snapshot.Role != "page tab" {
|
|
hidden[id] = hidden[id] || !ok
|
|
}
|
|
} else {
|
|
nodeClip = g.Bounds
|
|
}
|
|
}
|
|
nodeClips[id] = nodeClip
|
|
clips[id] = nodeClip
|
|
if node.Snapshot.Role == "page tab" && !hidden[id] {
|
|
// A tab header is not the viewport of its displayed page.
|
|
clips[id] = clip
|
|
}
|
|
for _, child := range node.Children {
|
|
parents[child] = node.Snapshot
|
|
}
|
|
}
|
|
return parents, nodeClips, hidden
|
|
}
|
|
|
|
// collect_flat_zones walks children before parents to avoid repeating container
|
|
// names and text. Password contents and partly clipped native runs stay excluded.
|
|
func collect_flat_zones(ctx context.Context, nodes []atspi.TreeNode, parents map[atspi.ObjectID]atspi.Snapshot, nodeClips map[atspi.ObjectID]atspi.Rectangle, hidden map[atspi.ObjectID]bool) ([]flatZone, error) {
|
|
var zones []flatZone
|
|
textBytes := 0
|
|
hasContent := make(map[atspi.ObjectID]bool, len(nodes))
|
|
// Reverse tree order allows container names to be suppressed when their
|
|
// children already supply displayed content.
|
|
for i := len(nodes) - 1; i >= 0; i-- {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
n := nodes[i]
|
|
id := n.Snapshot.ID()
|
|
parent := parents[id]
|
|
if hasContent[id] {
|
|
hasContent[parent.ID()] = true
|
|
}
|
|
if !n.Geometry.BoundsKnown || hidden[id] {
|
|
continue
|
|
}
|
|
if n.Text.Limited {
|
|
return nil, ErrLimitedReview
|
|
}
|
|
base := flatZone{object: id, parent: parent.ID(), role: n.Snapshot.Role, parentRole: parent.Role, bounds: nodeClips[id]}
|
|
before := len(zones)
|
|
if n.Snapshot.Role != "password text" {
|
|
for _, r := range n.Text.Ranges {
|
|
// Some web providers expose the same visible run on a
|
|
// container and its text child. Keep the child's source offset
|
|
// and speak the displayed run only once.
|
|
duplicate := false
|
|
if hasContent[id] {
|
|
for _, displayed := range zones {
|
|
if displayed.text != r.Text {
|
|
continue
|
|
}
|
|
if _, overlaps := displayed.bounds.Intersection(r.Bounds); !overlaps {
|
|
continue
|
|
}
|
|
for ancestor := displayed.parent; ancestor.Valid(); ancestor = parents[ancestor].ID() {
|
|
if ancestor == id {
|
|
duplicate = true
|
|
break
|
|
}
|
|
}
|
|
if duplicate {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if duplicate {
|
|
continue
|
|
}
|
|
// Native capture already clips text by character. Never keep a
|
|
// partially clipped run from another provider by changing only
|
|
// its rectangle; its text could still contain offscreen letters.
|
|
if clipped, ok := r.Bounds.Intersection(nodeClips[id]); !ok || clipped != r.Bounds {
|
|
continue
|
|
}
|
|
textBytes += len(r.Text)
|
|
if textBytes > maxFlatTextBytes || len(zones) >= maxFlatZones {
|
|
return nil, ErrLimitedReview
|
|
}
|
|
z := base
|
|
z.text, z.start, z.hasOffset, z.bounds = r.Text, r.Start, true, r.Bounds
|
|
if len(zones) > before {
|
|
previous := &zones[len(zones)-1]
|
|
if previous.start+utf8.RuneCountInString(previous.text) == r.Start && sameFlatLine(*previous, z) {
|
|
previous.text += r.Text
|
|
left := min(previous.bounds.X, r.Bounds.X)
|
|
top := min(previous.bounds.Y, r.Bounds.Y)
|
|
right := max(int64(previous.bounds.X)+int64(previous.bounds.Width), int64(r.Bounds.X)+int64(r.Bounds.Width))
|
|
bottom := max(int64(previous.bounds.Y)+int64(previous.bounds.Height), int64(r.Bounds.Y)+int64(r.Bounds.Height))
|
|
previous.bounds = atspi.Rectangle{X: left, Y: top, Width: int32(right - int64(left)), Height: int32(bottom - int64(top))}
|
|
continue
|
|
}
|
|
}
|
|
zones = append(zones, z)
|
|
}
|
|
}
|
|
if len(zones) == before && !hasContent[id] {
|
|
// Never derive review text from focus-speech Text, ValueText, or
|
|
// password contents. Names are synthetic and have no text offset.
|
|
base.text = n.Snapshot.Name
|
|
if base.text == "" {
|
|
switch n.Snapshot.Role {
|
|
case "entry", "text", "password text", "push button", "check box", "radio button", "toggle button", "combo box":
|
|
base.text = n.Snapshot.Role
|
|
}
|
|
}
|
|
if base.text != "" {
|
|
textBytes += len(base.text)
|
|
if textBytes > maxFlatTextBytes || len(zones) >= maxFlatZones {
|
|
return nil, ErrLimitedReview
|
|
}
|
|
zones = append(zones, base)
|
|
}
|
|
}
|
|
if len(zones) > before || hasContent[id] {
|
|
hasContent[parent.ID()] = true
|
|
}
|
|
}
|
|
return zones, nil
|
|
}
|
|
|
|
// arrange_flat_lines keeps stable provider order for geometry ties and computes
|
|
// Unicode word spans only after all bounded content has been collected.
|
|
func arrange_flat_lines(ctx context.Context, nodes []atspi.TreeNode, zones []flatZone) ([]flatLine, error) {
|
|
if len(zones) == 0 {
|
|
return nil, ErrNoReviewContent
|
|
}
|
|
// Restore tree order before stable geometry sorts (ties retain provider
|
|
// order). Ranges within each object retain increasing logical offsets.
|
|
order := make(map[atspi.ObjectID]int, len(nodes))
|
|
for i, n := range nodes {
|
|
order[n.Snapshot.ID()] = i
|
|
}
|
|
sort.SliceStable(zones, func(i, j int) bool {
|
|
if zones[i].bounds.Y != zones[j].bounds.Y {
|
|
return zones[i].bounds.Y < zones[j].bounds.Y
|
|
}
|
|
if order[zones[i].object] != order[zones[j].object] {
|
|
return order[zones[i].object] < order[zones[j].object]
|
|
}
|
|
return zones[i].start < zones[j].start
|
|
})
|
|
var lines []flatLine
|
|
for _, zone := range zones {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(lines) == 0 || !sameFlatLine(lines[len(lines)-1].zones[0], zone) {
|
|
lines = append(lines, flatLine{})
|
|
}
|
|
zone.runes = []rune(zone.text)
|
|
for start := 0; start < len(zone.runes); {
|
|
if unicode.IsSpace(zone.runes[start]) {
|
|
start++
|
|
continue
|
|
}
|
|
end := start + 1
|
|
for end < len(zone.runes) && !unicode.IsSpace(zone.runes[end]) {
|
|
end++
|
|
}
|
|
for end < len(zone.runes) && unicode.IsSpace(zone.runes[end]) {
|
|
end++
|
|
}
|
|
zone.words = append(zone.words, [2]int{start, end})
|
|
start = end
|
|
}
|
|
line := &lines[len(lines)-1]
|
|
line.zones = append(line.zones, zone)
|
|
}
|
|
for i := range lines {
|
|
sort.SliceStable(lines[i].zones, func(a, b int) bool { return lines[i].zones[a].bounds.X < lines[i].zones[b].bounds.X })
|
|
}
|
|
return lines, nil
|
|
}
|