Files

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
}