249 lines
7.8 KiB
Go
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()
|
|
}
|