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