// 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 data *documentData } // 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) { view, err := tree.DocumentView(ctx, reference) if err != nil { return DocumentSnapshot{}, err } view.Nodes = append([]DocumentNode(nil), view.Nodes...) view.data = nil return view, nil } // DocumentView validates the complete document atomically and shares its // immutable data. Like NavigationDocument, the returned Nodes are read-only. func (tree *AccessibleTree) DocumentView(ctx context.Context, reference ObjectReference) (DocumentSnapshot, error) { tree.mu.Lock() defer tree.mu.Unlock() view, err := tree.navigation_document_locked(ctx, reference) if err != nil { return DocumentSnapshot{}, err } if err := tree.validate_document_range_locked(ctx, view, 0, len(view.Nodes)-1); err != nil { return DocumentSnapshot{}, err } return view, nil } // NavigationDocument shares immutable document data until Replace or removal // changes the tree. Callers must not modify Nodes or their nested slices. Dirty // entries remain in the view: navigation validates the path it actually uses // with ValidateDocumentRange rather than recapturing unrelated page updates. func (tree *AccessibleTree) NavigationDocument(ctx context.Context, reference ObjectReference) (DocumentSnapshot, error) { tree.mu.Lock() defer tree.mu.Unlock() return tree.navigation_document_locked(ctx, reference) } func (tree *AccessibleTree) navigation_document_locked(ctx context.Context, reference ObjectReference) (DocumentSnapshot, error) { if err := ctx.Err(); err != nil { return DocumentSnapshot{}, err } owner, err := tree.documentContextLocked(reference) if err != nil { return DocumentSnapshot{}, err } if data := tree.documents[owner.TopLevel.ID()]; data != nil { return DocumentSnapshot{Context: owner, Nodes: data.nodes, data: data}, nil } capacity := 128 if owner.TopLevel.ID() == tree.root { capacity = len(tree.nodes) } result := DocumentSnapshot{Context: owner, Nodes: make([]DocumentNode, 0, capacity)} 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, present := tree.nodes[next.id] if !present { return DocumentSnapshot{}, ErrIncompleteTree } 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}) } } result.data = new_document_data(result.Nodes) populate_document_rows(&result) if tree.documents == nil { tree.documents = make(map[ObjectID]*documentData) } tree.documents[owner.TopLevel.ID()] = result.data return result, nil }