Found a few ways in which the Braille problem with browsers could resurface. Fixed now.

This commit is contained in:
Storm Dragon committed 2026-11-03 16:48:39 -05:00
1 parent 0a3d946ffa
commit a47253d697
13 files changed
+292 -23

No files matched your search

+3 -10
View File
@@ -352,19 +352,12 @@ func (adapter *Adapter) readNativeDocumentCaret(ctx context.Context, position Ob
if err != nil {
return DocumentCaretText{}, err
}
line.RawText = line.Text
// Braille must show the same descendant text speech expands, not
// embedded-object placeholders. RawText and native offsets stay
// unexpanded; Expanded tells consumers the mapping is degraded.
if unit == DocumentCaretBrailleLine && !snapshot.Editable && strings.ContainsRune(line.Text, '\ufffc') {
deadline := time.Now().Add(100 * time.Millisecond)
if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) {
deadline = ctxDeadline
}
budget := embeddedTextBudget{nodes: 32, characters: maxSnapshotFieldLength, deadline: deadline}
if expanded := expandNativeEmbeddedText(source, line.Text, line.Start, 0, &budget); expanded != line.Text {
line.Text, line.Expanded = expanded, true
}
line.RawText = line.Text
if unit == DocumentCaretBrailleLine {
line = expandNativeDocumentText(ctx, source, snapshot, line)
}
if unit == DocumentCaretBrailleLine && adapter.captureTextAttributes.Load() {
line.TextAttributeRuns = native_text_attribute_runs(text, line.Start, line.End, TextAttributeRun{})
+15 -4
View File
@@ -10,9 +10,10 @@ package atspi
import "C"
import (
"context"
"fmt"
"strings"
"time"
"unicode/utf8"
)
func nativeError(operation string, nativeError *C.GError) error {
@@ -61,6 +62,7 @@ func snapshotFromNativeWithOptions(source *C.AtspiAccessible, text, caretCharact
textContent,
C.GoString(captured.caretCharacter),
)
characterCount := int(captured.characterCount)
snapshot.TextCaretKnown = captured.text != nil && captured.textStartOffset >= 0
if text && snapshot.Role != "password text" {
switch snapshot.Role {
@@ -71,6 +73,12 @@ func snapshotFromNativeWithOptions(source *C.AtspiAccessible, text, caretCharact
if snapshot.TextCaretKnown {
captureBrailleLine(&snapshot, textContent, C.GoString(captured.textBeforeCaret), int(captured.textStartOffset))
if text := C.atspi_accessible_get_text(source); text != nil {
// Caret captures omit whole-document metadata. An incomplete
// expansion still needs the native count to know whether a name
// can safely replace this entire line.
if caretCharacter && strings.ContainsRune(textContent, '\ufffc') && characterCount <= 0 {
characterCount = int(C.atspi_text_get_character_count(text, nil))
}
snapshot.TextAttributeRun = native_text_attribute_run(text, snapshot.BrailleCaret)
snapshot.Misspelled = TextAttributesMisspelled(snapshot.TextAttributeRun.Values)
if len(captureAttributes) != 0 && captureAttributes[0] {
@@ -93,9 +101,12 @@ func snapshotFromNativeWithOptions(source *C.AtspiAccessible, text, caretCharact
}
}
if caretCharacter && captured.editable == 0 && snapshot.Role != "terminal" && strings.ContainsRune(textContent, '\ufffc') {
budget := embeddedTextBudget{nodes: 32, characters: maxSnapshotFieldLength, deadline: time.Now().Add(100 * time.Millisecond)}
// Hypertext offsets refer to raw characters, before whitespace folding.
snapshot.Text = sanitizeSnapshotField(expandNativeEmbeddedText(source, textContent, int(captured.textStartOffset), 0, &budget))
start := int(captured.textStartOffset)
line := expandNativeDocumentText(context.Background(), source, snapshot, DocumentCaretText{
Start: start, End: start + utf8.RuneCountInString(textContent), Text: textContent, CharacterCount: characterCount,
})
snapshot.Text = sanitizeSnapshotField(line.Text)
}
snapshot.BusName = sanitizeSnapshotField(C.GoString(captured.busName))
if snapshot.Role == "radio button" {
@@ -148,7 +159,7 @@ func snapshotFromNativeWithOptions(source *C.AtspiAccessible, text, caretCharact
snapshot.TableColumnHeaders = sanitizeSnapshotField(C.GoString(captured.tableColumnHeaders))
C.g_free(C.gpointer(captured.tableRowHeaders))
C.g_free(C.gpointer(captured.tableColumnHeaders))
snapshot.CharacterCount = int(captured.characterCount)
snapshot.CharacterCount = characterCount
snapshot.EmbeddedObjectCount = int(captured.embeddedObjectCount)
snapshot.XMLRoles = sanitizeSnapshotField(C.GoString(captured.xmlRoles))
snapshot.HTMLTag = sanitizeSnapshotField(C.GoString(captured.htmlTag))
+26 -1
View File
@@ -1,6 +1,9 @@
package atspi
import "unicode/utf8"
import (
"strings"
"unicode/utf8"
)
// DocumentCaretUnit selects the AT-SPI text granularity for virtual browsing.
type DocumentCaretUnit uint8
@@ -39,6 +42,28 @@ type DocumentCaretText struct {
Bounds Rectangle
}
func (line *DocumentCaretText) applyExpandedText(expanded, name string, incomplete bool) {
line.RawText = line.Text
// An accessible name describes the whole object. It can recover a failed
// descendant read only when this range covers that entire object; using it
// on a wrapped line would repeat unrelated text and move the reading point.
if incomplete && line.CharacterCount > 0 && line.Start == 0 && line.End == line.CharacterCount &&
strings.TrimSpace(name) != "" && !strings.ContainsRune(name, '\ufffc') {
expanded = name
}
line.Text, line.Expanded = expanded, expanded != line.RawText
}
func (snapshot *Snapshot) applyDocumentCaretLine(line DocumentCaretText, offset int) {
// A caret capture already expanded this exact native line. Preserve that
// text instead of replacing it with the raw line used for offset repair.
if snapshot.BrailleLine != line.RawText || snapshot.BrailleLineStart != line.Start || snapshot.CharacterCount != line.CharacterCount {
snapshot.Text = sanitizeSnapshotField(line.Text)
}
snapshot.BrailleLine, snapshot.BrailleLineStart, snapshot.BrailleCaret = line.RawText, line.Start, offset
snapshot.CharacterCount = line.CharacterCount
}
func documentCaretCharacter(value string) string {
if value == "" {
return ""
+55
View File
@@ -13,3 +13,58 @@ func TestDocumentCaretCharacterPreservesDelimiters(t *testing.T) {
}
}
}
func TestIncompleteEmbeddedTextRecoversOnlyWholeObject(t *testing.T) {
for _, test := range []struct {
name, label, expanded, want string
start, end, count int
incomplete bool
}{
{"failed child", "Before Linked title after", "Before after", "Before Linked title after", 0, 14, 14, true},
{"complete expansion", "Different author label", "Before Linked title after", "Before Linked title after", 0, 14, 14, false},
{"wrapped first line", "Whole object label", "Before after", "Before after", 0, 14, 28, true},
{"wrapped last line", "Whole object label", "Before after", "Before after", 14, 28, 28, true},
{"unknown count", "Whole object label", "Before after", "Before after", 0, 14, 0, true},
{"invalid name", "Before \ufffc after", "Before after", "Before after", 0, 14, 14, true},
{"empty name", " ", "Before after", "Before after", 0, 14, 14, true},
} {
t.Run(test.name, func(t *testing.T) {
line := DocumentCaretText{Text: "Before \ufffc after", Start: test.start, End: test.end, CharacterCount: test.count,
SelectionStart: 8, SelectionEnd: 10, TextAttributeRuns: []TextAttributeRun{{Start: 8, End: 10}}}
line.applyExpandedText(test.expanded, test.label, test.incomplete)
if line.Text != test.want || line.RawText != "Before \ufffc after" || !line.Expanded ||
line.Start != test.start || line.End != test.end || line.CharacterCount != test.count ||
line.SelectionStart != 8 || line.SelectionEnd != 10 || line.TextAttributeRuns[0].Start != 8 {
t.Fatalf("expanded line = %+v", line)
}
})
}
}
func TestCaretLineRepairPreservesOnlyMatchingExpansion(t *testing.T) {
snapshot := Snapshot{Text: "Before Linked title after", BrailleLine: "Before \ufffc after", BrailleLineStart: 10, CharacterCount: 24, TextCaretKnown: true}
line := DocumentCaretText{Text: "Before \ufffc after", RawText: "Before \ufffc after", Start: 10, End: 24, CharacterCount: 24}
snapshot.applyDocumentCaretLine(line, 18)
if snapshot.Text != "Before Linked title after" || snapshot.BrailleLine != line.RawText || snapshot.BrailleCaret != 18 || snapshot.CharacterCount != 24 {
t.Fatalf("matching expansion lost: %+v", snapshot)
}
// A newly available count permits whole-object recovery after the
// initial snapshot could not establish whether its line was complete.
snapshot.CharacterCount = 0
line.Text = "Before Recovered title after"
snapshot.applyDocumentCaretLine(line, 18)
if snapshot.Text != "Before Recovered title after" {
t.Fatalf("newly recovered expansion ignored: %+v", snapshot)
}
// Equal raw strings on different lines must not reuse the old expansion.
line.Start, line.End, line.Text = 0, 14, "Before New title after"
snapshot.applyDocumentCaretLine(line, 0)
if snapshot.Text != "Before New title after" || snapshot.BrailleLineStart != 0 || snapshot.BrailleCaret != 0 {
t.Fatalf("new line inherited stale expansion: %+v", snapshot)
}
line.Text, line.RawText = "New plain 猫", "New plain 猫"
snapshot.applyDocumentCaretLine(line, 2)
if snapshot.Text != "New plain 猫" || snapshot.BrailleLine != "New plain 猫" || snapshot.BrailleCaret != 2 {
t.Fatalf("plain line repair lost text or spacing: %+v", snapshot)
}
}
+5 -3
View File
@@ -112,7 +112,9 @@ func (adapter *Adapter) repairNativeCaretLine(source *C.AtspiAccessible, snapsho
if err != nil || line.Start < 0 || line.End <= line.Start {
return
}
snapshot.Text = sanitizeSnapshotField(line.Text)
snapshot.BrailleLine, snapshot.BrailleLineStart, snapshot.BrailleCaret = line.Text, line.Start, offset
snapshot.CharacterCount = count
line.RawText = line.Text
if snapshot.BrailleLine != line.RawText || snapshot.BrailleLineStart != line.Start || snapshot.CharacterCount != count {
line = expandNativeDocumentText(ctx, source, *snapshot, line)
}
snapshot.applyDocumentCaretLine(line, offset)
}
+23
View File
@@ -169,6 +169,29 @@ chromeFocus:
if !mixedLine.Expanded || strings.ContainsRune(mixedLine.Text, '\ufffc') || !strings.Contains(mixedLine.Text, "Inline message link") {
t.Fatalf("mixed braille line not expanded: %+v", mixedLine)
}
if err := adapter.SetDocumentCaretPosition(ctx, parent, event.Source.ID(), 0); err != nil {
t.Fatalf("place mixed-line caret: %v", err)
}
caretDeadline := time.NewTimer(3 * time.Second)
caretEvent:
for {
select {
case caret := <-adapter.Events():
if caret.Type != CaretEvent || caret.Source.ID() != parent.ID() {
continue
}
caretDeadline.Stop()
if caret.Source.BrailleLine != mixedLine.RawText || !strings.Contains(caret.Source.Text, "Inline message link") || strings.ContainsRune(caret.Source.Text, '\ufffc') {
t.Fatalf("caret repair lost embedded text: text=%q raw=%q", caret.Source.Text, caret.Source.BrailleLine)
}
break caretEvent
case <-caretDeadline.C:
t.Fatal("mixed-line caret event missing")
case <-ctx.Done():
caretDeadline.Stop()
t.Fatal(ctx.Err())
}
}
var paragraph ObjectReference
captured, err := page.Document(ctx, ref)
if err != nil {
@@ -0,0 +1,41 @@
package atspi
import (
"context"
"strings"
"testing"
"time"
)
func TestEmbeddedTextBudgetReportsIncompleteExpansion(t *testing.T) {
for _, test := range []struct {
name, raw, want string
depth int
budget embeddedTextBudget
incomplete bool
}{
{"nodes", "Before \ufffc after", "Before after", 0, embeddedTextBudget{nodes: 0, characters: 100, deadline: time.Now().Add(time.Hour)}, true},
{"deadline", "Before \ufffc after", "Before after", 0, embeddedTextBudget{nodes: 32, characters: 100, deadline: time.Time{}}, true},
{"depth", "Before \ufffc after", "Before after", 8, embeddedTextBudget{nodes: 32, characters: 100, deadline: time.Now().Add(time.Hour)}, true},
{"characters", "猫text", "猫", 0, embeddedTextBudget{nodes: 32, characters: 1}, true},
{"complete plain text", "猫text", "猫text", 0, embeddedTextBudget{nodes: 32, characters: 5}, false},
} {
t.Run(test.name, func(t *testing.T) {
// These budgets stop before any native child query. No provider
// reference is needed to exercise the actual expansion limits.
got := expandNativeEmbeddedText(nil, test.raw, 0, test.depth, &test.budget)
if got != test.want || test.budget.incomplete != test.incomplete || strings.ContainsRune(got, '\ufffc') {
t.Fatalf("text=%q incomplete=%t", got, test.budget.incomplete)
}
})
}
}
func TestEmbeddedDocumentTextSkipsProtectedAndEditableObjects(t *testing.T) {
for _, snapshot := range []Snapshot{{Role: "password text"}, {Role: "terminal"}, {Role: "entry", Editable: true}} {
line := expandNativeDocumentText(context.Background(), nil, snapshot, DocumentCaretText{Text: "\ufffc", Start: 0, End: 1, CharacterCount: 1})
if line.Text != "\ufffc" || line.RawText != "\ufffc" || line.Expanded {
t.Fatalf("unexpected expansion for %s: %+v", snapshot.Role, line)
}
}
}
+38 -1
View File
@@ -43,6 +43,25 @@ import (
type embeddedTextBudget struct {
nodes, characters int
deadline time.Time
incomplete bool
}
// The display text may expand, but every offset continues to describe RawText.
// Keep the same bounded expansion and recovery rules for browse reads and
// native caret snapshots.
func expandNativeDocumentText(ctx context.Context, source *C.AtspiAccessible, snapshot Snapshot, line DocumentCaretText) DocumentCaretText {
line.RawText = line.Text
if snapshot.Editable || snapshot.Role == "password text" || snapshot.Role == "terminal" || !strings.ContainsRune(line.Text, '\ufffc') {
return line
}
deadline := time.Now().Add(100 * time.Millisecond)
if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) {
deadline = ctxDeadline
}
budget := embeddedTextBudget{nodes: 32, characters: maxSnapshotFieldLength, deadline: deadline}
expanded := expandNativeEmbeddedText(source, line.Text, line.Start, 0, &budget)
line.applyExpandedText(expanded, snapshot.Name, budget.incomplete)
return line
}
func expandNativeEmbeddedText(source *C.AtspiAccessible, content string, start, depth int, budget *embeddedTextBudget) string {
@@ -51,6 +70,7 @@ func expandNativeEmbeddedText(source *C.AtspiAccessible, content string, start,
for _, char := range content {
offset++
if budget.characters <= 0 {
budget.incomplete = true
break
}
if char != '\ufffc' {
@@ -59,6 +79,7 @@ func expandNativeEmbeddedText(source *C.AtspiAccessible, content string, start,
continue
}
if depth >= 8 || budget.nodes <= 0 || time.Now().After(budget.deadline) {
budget.incomplete = true
continue
}
budget.nodes--
@@ -70,6 +91,7 @@ func expandNativeEmbeddedText(source *C.AtspiAccessible, content string, start,
func expandNativeEmbeddedChild(source *C.AtspiAccessible, offset, depth int, budget *embeddedTextBudget) string {
hypertext := C.atspi_accessible_get_hypertext_iface(source)
if hypertext == nil {
budget.incomplete = true
return ""
}
defer C.g_object_unref(C.gpointer(hypertext))
@@ -77,18 +99,22 @@ func expandNativeEmbeddedChild(source *C.AtspiAccessible, offset, depth int, bud
index := C.atspi_hypertext_get_link_index(hypertext, C.gint(offset), &nativeErr)
if nativeErr != nil {
C.g_error_free(nativeErr)
budget.incomplete = true
return ""
}
if index < 0 {
budget.incomplete = true
return ""
}
link := C.atspi_hypertext_get_link(hypertext, index, nil)
if link == nil {
budget.incomplete = true
return ""
}
defer C.g_object_unref(C.gpointer(link))
child := C.atspi_hyperlink_get_object(link, 0, nil)
if child == nil {
budget.incomplete = true
return ""
}
defer C.g_object_unref(C.gpointer(child))
@@ -98,23 +124,34 @@ func expandNativeEmbeddedChild(source *C.AtspiAccessible, offset, depth int, bud
}
text := C.atspi_accessible_get_text_iface(child)
if text == nil {
budget.incomplete = true
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) {
budget.incomplete = true
return ""
}
if count > C.gint(maxSnapshotFieldLength) {
budget.incomplete = true
}
content := C.atspi_text_get_text(text, 0, min(count, C.gint(maxSnapshotFieldLength)), nil)
if content == nil {
budget.incomplete = true
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 {
budget.incomplete = true
return ""
}
expanded := expandNativeEmbeddedText(child, C.GoStringN(content, C.int(length)), 0, depth, budget)
value := C.GoStringN(content, C.int(length))
if !utf8.ValidString(value) || utf8.RuneCountInString(value) != min(int(count), maxSnapshotFieldLength) {
budget.incomplete = true
}
expanded := expandNativeEmbeddedText(child, value, 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: