Files

249 lines
7.8 KiB
Go

// 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 atspi
/*
#cgo pkg-config: atspi-2
#include <atspi/atspi.h>
#include <string.h>
// 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()
}