Cthulhu is no longer a fork. Now it is a screen reader written in Golang.
This commit is contained in:
1 parent
a998fe67de
commit
069fc674dc
708 files changed
+112482
-532
No files matched your search
@@ -0,0 +1,278 @@
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user