Found a few ways in which the Braille problem with browsers could resurface. Fixed now.
This commit is contained in:
1 parent
0a3d946ffa
commit
a47253d697
13 files changed
+292
-23
No files matched your search
@@ -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{})
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user