// 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 atspi import ( "context" "errors" ) var ErrNotInDocument = errors.New("accessible is outside web document content") // DocumentContext identifies both the innermost document and the outermost // document containing an object. Ancestors run from the object to the tree // root. All data is copied under one tree lock; no native references escape. type DocumentContext struct { Document ObjectReference TopLevel ObjectReference Ancestors []Snapshot } // IsWebDocumentRole follows the shared Python web script's document roles. // Generic text/spreadsheet documents must not enable browser key handling. func IsWebDocumentRole(role string) bool { return role == "document web" || role == "document frame" || role == "embedded" } func (tree *AccessibleTree) DocumentContext(reference ObjectReference) (DocumentContext, error) { tree.mu.RLock() defer tree.mu.RUnlock() return tree.documentContextLocked(reference) } func (tree *AccessibleTree) documentContextLocked(reference ObjectReference) (DocumentContext, error) { entry, err := tree.resolveLocked(reference) if err != nil { return DocumentContext{}, err } var result DocumentContext for { if entry.snapshotInvalid { return DocumentContext{}, ErrStaleSnapshot } result.Ancestors = append(result.Ancestors, entry.snapshot) if IsWebDocumentRole(entry.snapshot.Role) { if !result.Document.Valid() { result.Document = reference } result.TopLevel = reference } if !entry.hasParent { break } id := entry.parent entry = tree.nodes[id] reference = newObjectReference(id, entry) } if !result.Document.Valid() { return DocumentContext{}, ErrNotInDocument } return result, nil } // DocumentNode is one owned semantic object in document tree order. Visible // does not mean on-screen: document navigation must include offscreen content. type DocumentNode struct { Reference ObjectReference Parent ObjectID Snapshot Snapshot Geometry Geometry Text VisibleText Hidden bool // ParentEditable is captured with the node so entry matching stays // constant-time instead of rescanning the complete document per candidate. ParentEditable bool Children []ObjectID } type DocumentSnapshot struct { Context DocumentContext Nodes []DocumentNode } // Document copies the top-level document, including embedded documents but // excluding browser chrome and other tabs. A stale/incomplete capture fails // atomically. It does not use flat review's viewport-limited text ranges. func (tree *AccessibleTree) Document(ctx context.Context, reference ObjectReference) (DocumentSnapshot, error) { tree.mu.RLock() defer tree.mu.RUnlock() if err := ctx.Err(); err != nil { return DocumentSnapshot{}, err } owner, err := tree.documentContextLocked(reference) if err != nil { return DocumentSnapshot{}, err } result := DocumentSnapshot{Context: owner} type pendingNode struct { id ObjectID hidden bool } pending := []pendingNode{{id: owner.TopLevel.ID()}} for len(pending) > 0 { if err := ctx.Err(); err != nil { return DocumentSnapshot{}, err } last := len(pending) - 1 next := pending[last] pending = pending[:last] entry := tree.nodes[next.id] if entry.snapshotInvalid { return DocumentSnapshot{}, ErrStaleSnapshot } if entry.childrenInvalid { return DocumentSnapshot{}, ErrIncompleteTree } if entry.textInvalid { return DocumentSnapshot{}, ErrStaleText } if entry.geometryInvalid { return DocumentSnapshot{}, ErrStaleGeometry } hidden := next.hidden || entry.geometry.VisibilityKnown && !entry.geometry.Visible parentEditable := false if entry.hasParent { parentEditable = tree.nodes[entry.parent].snapshot.Editable } result.Nodes = append(result.Nodes, DocumentNode{ Parent: entry.parent, Reference: newObjectReference(next.id, entry), Snapshot: entry.snapshot, Geometry: entry.geometry, Text: entry.text, Hidden: hidden, ParentEditable: parentEditable, Children: entry.children, }) for i := len(entry.children) - 1; i >= 0; i-- { pending = append(pending, pendingNode{entry.children[i], hidden}) } } populate_document_rows(&result) return result, nil }