Preserve native offsets after braille text expansion

Expanding embedded-object placeholders changes the displayed braille text
without changing native AT-SPI offsets. Several consumers still validated
native positions against the expanded string, which could reject a valid
line end after contraction, accept an offset beyond the native line after
expansion, or drop selection and text-attribute indicators on a plain-text
fragment following an embedded object.

Use RawText for native-offset validation and fragment comparisons whenever
a braille line is expanded. Apply the same rule to parent/child native-caret
handoff: identical displayed text must not authorize an offset transfer
when the underlying native strings differ. Expanded spans retain their
existing conservative presentation without native cell routes.

Restrict the assembled-line displayed-text fallback to lines from which
embedded-object placeholders were actually removed. Ordinary whitespace-only
lines now retain their spaces, cursor position, and native routing instead
of being replaced with the object's text or accessible name.

Add regressions for contracted and expanded native offset bounds, suffix
selection and attribute preservation, immutable line enrichment, ambiguous
caret transfer, and whitespace-only line routing. The regressions reproduced
the review findings before the fixes and pass afterward.

Validation:
- Full Go suite before fixes; full race and BrlAPI suites after fixes.
- Focused Braille and native-caret regressions, including race checks.
- go vet ./...; reader and BrlAPI helper builds; parity-ledger validation.
- Private Chromium linked-heading, selection, and browse-mode checks.
- Private Firefox baseline and linked-heading checks.
- git diff --check.

The Chromium fragment harness still fails its embedded-editor navigation
step, and the Firefox harness still times out in its New Tab test. Both
failures reproduce on the parent of the reviewed commit, 8ce5e5c, as well.
These failures are not claimed as passing checks.

The active reader was not replaced. Physical keyboard, audible speech, and
Braille acceptance remain for maintainer testing: check linked headings,
mixed inline-link text, selection indicators after links, and ordinary or
blank-line cursor routing in both browser families before submission.
This commit is contained in:
Storm Dragon committed 2026-11-03 01:54:25 -05:00
1 parent 8ce5e5cdad
commit 7ec6e4ccaf
7 files changed
+94 -5

No files matched your search

+3 -1
View File
@@ -79,6 +79,7 @@ func brailleDocumentFrame(result review.Result, options config.BrailleOptions, c
}
}
if assembled != nil {
strippedObjects := false
for index, content := range assembled.Contents {
raw := content.RawText
if index == len(assembled.Contents)-1 {
@@ -91,11 +92,12 @@ func brailleDocumentFrame(result review.Result, options config.BrailleOptions, c
// as their own contents; drop it instead of duplicating them.
native := !strings.ContainsRune(raw, '\ufffc')
if !native {
strippedObjects = true
raw = stripEmbeddedObjects(raw)
}
appendContent(content.Snapshot, raw, content.Separator, content.Start, native)
}
if strings.TrimSpace(frame.Text) == "" {
if strippedObjects && strings.TrimSpace(frame.Text) == "" {
// Nothing but placeholders survived assembly; fall back to
// the displayed text speech would use for this object.
if text := brailleDisplayedText(snapshot); text != "" {
+18
View File
@@ -481,6 +481,24 @@ func TestBrowseBrailleAssembledLineDropsEmbeddedObjectPlaceholders(t *testing.T)
}
}
func TestBrowseBrailleAssembledWhitespaceKeepsNativeMapping(t *testing.T) {
snapshot := atspi.Snapshot{BusName: ":1.999", Path: "/text", Role: "static", Name: "Accessible label", Text: "Other text"}
tree, err := atspi.NewAccessibleTree(snapshot.ID(), []atspi.TreeNode{{Snapshot: snapshot}})
if err != nil {
t.Fatal(err)
}
object, _ := tree.Reference(snapshot.ID())
result := review.Result{DocumentEpoch: 1, DocumentFocus: snapshot.ID(),
DocumentPosition: review.DocumentPosition{Document: object, Snapshot: snapshot, Offset: 1},
DocumentCaretLine: &atspi.DocumentLine{Contents: []atspi.DocumentContent{{
Snapshot: snapshot, Start: 0, End: 2, RawText: " ",
}}}}
frame := brailleDocumentFrame(result, config.DefaultSettings().Braille)
if frame.Text != " " || frame.Cursor != 1 || len(frame.Routes) != 1 || frame.Routes[0].SourceText != " " {
t.Fatalf("ordinary whitespace replaced by object fallback: %+v", frame)
}
}
func (*tableBrailleCapturer) ReadDocumentCaret(context.Context, atspi.ObjectReference, int, atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) {
return atspi.DocumentCaretText{}, nil
}
+10 -2
View File
@@ -40,7 +40,7 @@ func (worker *Worker) readDocumentBraille(ctx context.Context, result Result) Re
return result
}
if line.Start < 0 || result.DocumentPosition.Offset < line.Start ||
result.DocumentPosition.Offset-line.Start > utf8.RuneCountInString(line.Text) || line.Text == "" {
result.DocumentPosition.Offset-line.Start > utf8.RuneCountInString(documentBrailleNativeText(line)) || line.Text == "" {
result.DocumentBrailleErr = atspi.ErrNoDocumentCaret
return result
}
@@ -62,7 +62,7 @@ func (worker *Worker) readDocumentBraille(ctx context.Context, result Result) Re
}
}
start, end := content.Start-selected.Start, content.Start-selected.Start+utf8.RuneCountInString(content.RawText)
runes := []rune(selected.Text)
runes := []rune(documentBrailleNativeText(selected))
if start < 0 || end > len(runes) || string(runes[start:end]) != content.RawText {
continue
}
@@ -73,3 +73,11 @@ func (worker *Worker) readDocumentBraille(ctx context.Context, result Result) Re
}
return result
}
// Braille expansion changes display characters, never the native offset domain.
func documentBrailleNativeText(line atspi.DocumentCaretText) string {
if line.Expanded {
return line.RawText
}
return line.Text
}
+43
View File
@@ -7,6 +7,7 @@ package review
import (
"context"
"errors"
"reflect"
"testing"
"time"
@@ -79,3 +80,45 @@ func TestDocumentBrailleReadsOnlyEnabledAcceptedNavigation(t *testing.T) {
t.Fatalf("braille read failure changed successful navigation: %+v", got)
}
}
func TestDocumentBrailleExpandedLineUsesNativeOffsets(t *testing.T) {
reader := &documentBrailleReader{fakeCapturer: &fakeCapturer{}, line: atspi.DocumentCaretText{
Start: 0, End: 5, RawText: "a\ufffc bc", Text: "a bc", Expanded: true,
}}
worker, err := NewWorker(reader, time.Second)
if err != nil {
t.Fatal(err)
}
cursor := newCaretTextCursor(t, caretTextReader{webID("/text"): reader.line.RawText})
object, _ := cursor.tree.Reference(webID("/text"))
worker.SyncFocus(object.ID())
worker.SetDocumentBrailleEnabled(true)
result := Result{Moved: true, DocumentFocus: object.ID(),
DocumentPosition: DocumentPosition{Object: object, Offset: 5}}
got := worker.readDocumentBraille(context.Background(), result)
if got.DocumentBrailleLine == nil || got.DocumentBrailleErr != nil {
t.Fatalf("valid native line end rejected after contraction: %+v", got)
}
reader.line.Text = "aLong link bc"
reader.line.SelectionStart, reader.line.SelectionEnd = 3, 5
reader.line.TextAttributeRuns = []atspi.TextAttributeRun{{Start: 3, End: 5, Values: map[string]string{"weight": "700"}}}
result.DocumentPosition.Offset = 3
result.DocumentCaretLine = &atspi.DocumentLine{Contents: []atspi.DocumentContent{{
Reference: object, Snapshot: atspi.Snapshot{BusName: object.ID().BusName, Path: object.ID().Path},
Start: 3, End: 5, RawText: "bc",
}}}
got = worker.readDocumentBraille(context.Background(), result)
snapshot := got.DocumentCaretLine.Contents[0].Snapshot
if snapshot.SelectionStart != 3 || snapshot.SelectionEnd != 5 || !reflect.DeepEqual(snapshot.TextAttributeRuns, reader.line.TextAttributeRuns) {
t.Fatalf("native suffix indicators lost after display expansion: %+v", snapshot)
}
if result.DocumentCaretLine.Contents[0].Snapshot.SelectionEnd != 0 {
t.Fatal("enrichment mutated the original line")
}
result.DocumentPosition.Offset = 6
got = worker.readDocumentBraille(context.Background(), result)
if got.DocumentBrailleLine != nil || !errors.Is(got.DocumentBrailleErr, atspi.ErrNoDocumentCaret) {
t.Fatalf("display length accepted an invalid native offset: %+v", got)
}
}
+2 -1
View File
@@ -79,7 +79,8 @@ func native_caret_node(ctx context.Context, reader DocumentCaretReader, doc atsp
if err != nil {
return atspi.DocumentNode{}, 0, err
}
if parentText.Start != childText.Start || parentText.End != childText.End || parentText.Text == "" || parentText.Text != childText.Text {
parentNative, childNative := documentBrailleNativeText(parentText), documentBrailleNativeText(childText)
if parentText.Start != childText.Start || parentText.End != childText.End || parentNative == "" || parentNative != childNative {
return atspi.DocumentNode{}, 0, atspi.ErrNoDocumentCaret
}
node = child
@@ -90,6 +90,23 @@ func TestNativeCaretRejectsAmbiguousOffsetDomains(t *testing.T) {
}
}
type expandedCaretReader map[atspi.ObjectID]atspi.DocumentCaretText
func (reader expandedCaretReader) ReadDocumentCaret(_ context.Context, object atspi.ObjectReference, _ int, _ atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) {
return reader[object.ID()], nil
}
func TestNativeCaretRejectsMatchingExpandedTextWithDifferentNativeOffsets(t *testing.T) {
cursor := mirroredCaretCursor(t, caretTextReader{webID("/paragraph"): "\ufffcxy", webID("/text"): "axy"}, false)
reader := expandedCaretReader{
webID("/paragraph"): {Start: 0, End: 3, RawText: "\ufffcxy", Text: "axy", Expanded: true},
webID("/text"): {Start: 0, End: 3, RawText: "axy", Text: "axy"},
}
if err := cursor.syncNativeCaret(context.Background(), reader, webID("/paragraph"), 1); !errors.Is(err, atspi.ErrNoDocumentCaret) {
t.Fatalf("expanded display text authorized native offset transfer: %v", err)
}
}
func TestWorkerNativeCaretHandoffPrecedesNextArrow(t *testing.T) {
text := caretTextReader{webID("/text"): "When autocomplete"}
navigator := &boundaryNavigator{fakeCapturer: &fakeCapturer{}, caretTextReader: text}
+1 -1
View File
@@ -21,7 +21,7 @@ import "strings"
const Name = "Cthulhu"
const Version = "2026.11.02"
const Version = "2026.11.03"
const Channel = "hunter"
var Build = Version