// 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 /* #cgo pkg-config: atspi-2 #include #include // Bound the Go copy even when an application returns an oversized line. static int review_text_length(const gchar *text, int limit) { if (!text) return -1; return (int)strnlen(text, limit + 1); } */ import "C" import ( "context" "errors" "strings" "time" "unicode/utf8" ) // Expansion follows ScriptUtilities.expandEOCs for the captured line only. // All native references stay on the GLib owner. Bound nested provider work // and output separately; one native call is also bounded by libatspi's timeout. type embeddedTextBudget struct { nodes, characters int deadline time.Time } func expandNativeEmbeddedText(source *C.AtspiAccessible, content string, start, depth int, budget *embeddedTextBudget) string { var result strings.Builder offset := start - 1 for _, char := range content { offset++ if budget.characters <= 0 { break } if char != '\ufffc' { result.WriteRune(char) budget.characters-- continue } if depth >= 8 || budget.nodes <= 0 || time.Now().After(budget.deadline) { continue } budget.nodes-- result.WriteString(expandNativeEmbeddedChild(source, offset, depth+1, budget)) } return result.String() } func expandNativeEmbeddedChild(source *C.AtspiAccessible, offset, depth int, budget *embeddedTextBudget) string { hypertext := C.atspi_accessible_get_hypertext_iface(source) if hypertext == nil { return "" } defer C.g_object_unref(C.gpointer(hypertext)) var nativeErr *C.GError index := C.atspi_hypertext_get_link_index(hypertext, C.gint(offset), &nativeErr) if nativeErr != nil { C.g_error_free(nativeErr) return "" } if index < 0 { return "" } link := C.atspi_hypertext_get_link(hypertext, index, nil) if link == nil { return "" } defer C.g_object_unref(C.gpointer(link)) child := C.atspi_hyperlink_get_object(link, 0, nil) if child == nil { return "" } defer C.g_object_unref(C.gpointer(child)) role := C.atspi_accessible_get_role(child, nil) if role == C.ATSPI_ROLE_PASSWORD_TEXT { return "" } text := C.atspi_accessible_get_text_iface(child) if text == nil { return "" } defer C.g_object_unref(C.gpointer(text)) count := C.atspi_text_get_character_count(text, nil) if count <= 0 || time.Now().After(budget.deadline) { return "" } content := C.atspi_text_get_text(text, 0, min(count, C.gint(maxSnapshotFieldLength)), nil) if content == nil { return "" } defer C.g_free(C.gpointer(content)) length := int(C.review_text_length(content, C.int(maxSnapshotFieldLength*utf8.UTFMax))) if length < 0 || length > maxSnapshotFieldLength*utf8.UTFMax { return "" } expanded := expandNativeEmbeddedText(child, C.GoStringN(content, C.int(length)), 0, depth, budget) switch role { case C.ATSPI_ROLE_HEADING, C.ATSPI_ROLE_LIST, C.ATSPI_ROLE_LIST_ITEM, C.ATSPI_ROLE_PARAGRAPH, C.ATSPI_ROLE_SECTION, C.ATSPI_ROLE_TABLE, C.ATSPI_ROLE_TABLE_CELL, C.ATSPI_ROLE_TABLE_ROW: if budget.characters > 0 { expanded += " " budget.characters-- } } return expanded } type nativeReviewText struct { text *C.AtspiText terminal bool } func (source nativeReviewText) count(ctx context.Context) (int, error) { if err := ctx.Err(); err != nil { return 0, err } var nativeErr *C.GError count := C.atspi_text_get_character_count(source.text, &nativeErr) if nativeErr != nil { C.g_error_free(nativeErr) return 0, errors.New("query review character count") } if source.terminal && count > 0 { return terminalCharacterCount(ctx, int(count), source.characterExists) } return int(count), ctx.Err() } func (source nativeReviewText) characterExists(ctx context.Context, offset int) (bool, error) { if err := ctx.Err(); err != nil { return false, err } var nativeErr *C.GError text := C.atspi_text_get_text(source.text, C.gint(offset), C.gint(offset+1), &nativeErr) if text != nil { defer C.g_free(C.gpointer(text)) } if nativeErr != nil { C.g_error_free(nativeErr) return false, errors.New("query terminal review character") } length := int(C.review_text_length(text, C.int(utf8.UTFMax))) if length < 0 || length > utf8.UTFMax { return false, errors.New("invalid terminal review character") } value := C.GoStringN(text, C.int(length)) if !utf8.ValidString(value) || utf8.RuneCountInString(value) > 1 { return false, errors.New("invalid terminal review character") } return length != 0, ctx.Err() } func (source nativeReviewText) line(ctx context.Context, offset int) (TextRange, error) { if err := ctx.Err(); err != nil { return TextRange{}, err } var nativeErr *C.GError line := C.atspi_text_get_string_at_offset(source.text, C.gint(offset), C.ATSPI_TEXT_GRANULARITY_LINE, &nativeErr) if line != nil { defer C.g_boxed_free(C.atspi_text_range_get_type(), C.gpointer(line)) } if nativeErr != nil { C.g_error_free(nativeErr) return TextRange{}, errors.New("query review line") } if line == nil { return TextRange{}, errors.New("missing review line") } length := int(C.review_text_length(line.content, C.int(maxReviewLineCharacters*utf8.UTFMax))) if length < 0 || length > maxReviewLineCharacters*utf8.UTFMax { return TextRange{}, errors.New("oversized or missing review line content") } return TextRange{Start: int(line.start_offset), End: int(line.end_offset), Text: C.GoStringN(line.content, C.int(length))}, ctx.Err() } func (source nativeReviewText) bounds(ctx context.Context, start, end int) (Rectangle, error) { if err := ctx.Err(); err != nil { return Rectangle{}, err } var nativeErr *C.GError bounds := C.atspi_text_get_range_extents(source.text, C.gint(start), C.gint(end), C.ATSPI_COORD_TYPE_SCREEN, &nativeErr) if bounds != nil { defer C.g_free(C.gpointer(bounds)) } if nativeErr != nil { C.g_error_free(nativeErr) return Rectangle{}, errors.New("query review range bounds") } if bounds == nil { return Rectangle{}, errors.New("missing review range bounds") } rect := Rectangle{int32(bounds.x), int32(bounds.y), int32(bounds.width), int32(bounds.height)} if !rect.Valid() { return Rectangle{}, errors.New("invalid review range bounds") } return rect, ctx.Err() } // Only called from captureNativeTree on the adapter's GLib thread. Unsupported, // hidden, protected, and failed text queries remain unavailable to review. func textFromNative(ctx context.Context, accessible *C.AtspiAccessible, geometry Geometry, role string, clip Rectangle) (VisibleText, error) { if err := ctx.Err(); err != nil { return VisibleText{}, err } if !geometry.BoundsKnown || !clip.Valid() || role == "password text" { return VisibleText{}, nil } text := C.atspi_accessible_get_text_iface(accessible) if text == nil { return VisibleText{}, ctx.Err() } defer C.g_object_unref(C.gpointer(text)) result, err := captureVisibleText(ctx, nativeReviewText{text: text, terminal: role == "terminal"}, clip) if err != nil { return VisibleText{}, ctx.Err() } if result.Known && ctx.Err() == nil { var nativeErr *C.GError offset := int(C.atspi_text_get_caret_offset(text, &nativeErr)) if nativeErr != nil { C.g_error_free(nativeErr) } else if offset >= 0 { result.CaretKnown, result.CaretOffset = true, offset } } return result, ctx.Err() }