From a47253d697c1aa44dea80646cad99a4fe163c7f1 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Tue, 3 Nov 2026 16:48:39 -0500 Subject: [PATCH] Found a few ways in which the Braille problem with browsers could resurface. Fixed now. --- README.md | 11 +++++ internal/app/braille_presentation.go | 9 ++-- internal/app/braille_presentation_test.go | 20 ++++++++ internal/app/linked_headings_native_test.go | 48 ++++++++++++++++++ internal/atspi/adapter_document_native.go | 13 ++--- internal/atspi/adapter_snapshot_native.go | 19 +++++-- internal/atspi/document_caret.go | 27 +++++++++- internal/atspi/document_caret_test.go | 55 +++++++++++++++++++++ internal/atspi/document_lines_native.go | 8 +-- internal/atspi/firefox_native_test.go | 23 +++++++++ internal/atspi/text_embedded_native_test.go | 41 +++++++++++++++ internal/atspi/text_native.go | 39 ++++++++++++++- tests/browser-linked-headings.html | 2 + 13 files changed, 292 insertions(+), 23 deletions(-) create mode 100644 internal/atspi/text_embedded_native_test.go diff --git a/README.md b/README.md index 5601a02..84b8c57 100644 --- a/README.md +++ b/README.md @@ -508,6 +508,17 @@ Stale native placement gets one refresh and retry at the same target and offset. Braille routing also recognizes the temporary caret produced by clearing a selection, so it cannot reset the routed reading position. +Browser Braille expands embedded links and preserves that text when native +caret events repair line offsets. If a descendant read fails or reaches its +expansion limit, a clean accessible name can recover a range covering the whole +object. Wrapped line fragments retain their own content. Names are also stripped +of embedded-object placeholders before calculating Braille label offsets. +After rebuilding, check H/Shift+H in Firefox and Chromium on linked headings, +headings containing linked images, and wrapped headings. Compare speech with +Braille, then check arrow reading, selection, panning and ordinary text routing. +Expanded text has no native cell routes because its offsets differ from the +browser's raw text. + Reading a final ellipsis avoids the provider's native line-boundary request: Chromium can calculate a boundary past truncated text and abort the browser. At that character, Cthulhu reads a bounded logical line using explicit line diff --git a/internal/app/braille_presentation.go b/internal/app/braille_presentation.go index 7a81a4d..24e4819 100644 --- a/internal/app/braille_presentation.go +++ b/internal/app/braille_presentation.go @@ -21,11 +21,12 @@ func brailleDocumentFrame(result review.Result, options config.BrailleOptions, c if line == nil && assembled == nil || snapshot.Role == "password text" { frame := brailleFocusFrameWithOptions(snapshot, options, configured...) frame.Routes = nil - if snapshot.Role == "link" && snapshot.ExplicitName && strings.TrimSpace(snapshot.Name) != "" && !snapshot.Editable { + name := strings.TrimSpace(stripEmbeddedObjects(snapshot.Name)) + if snapshot.Role == "link" && snapshot.ExplicitName && name != "" && !snapshot.Editable { // Accessible-label review has no native text route. - frame.Cursor = min(result.DocumentPosition.Offset, utf8.RuneCountInString(strings.TrimSpace(snapshot.Name))) + frame.Cursor = min(result.DocumentPosition.Offset, utf8.RuneCountInString(name)) } - textLength := utf8.RuneCountInString(snapshot.Name) + textLength := utf8.RuneCountInString(name) if textLength == 0 { textLength = utf8.RuneCountInString(snapshot.Text) } @@ -220,7 +221,7 @@ func braille_object_frame(s atspi.Snapshot, options config.BrailleOptions, confi if frame, handled := libreOfficeBrailleFrame(s, options); handled { return frame } - name, role := strings.TrimSpace(s.Name), s.ControlRole() + name, role := strings.TrimSpace(stripEmbeddedObjects(s.Name)), s.ControlRole() if role == "terminal" || brailleTextRole(role) && s.TextCaretKnown { label := name if role == "terminal" || role == "heading" || role == "static" { diff --git a/internal/app/braille_presentation_test.go b/internal/app/braille_presentation_test.go index 0e379c9..0e8adca 100644 --- a/internal/app/braille_presentation_test.go +++ b/internal/app/braille_presentation_test.go @@ -454,6 +454,26 @@ func TestBrailleFocusEmbeddedObjectLineUsesDisplayedText(t *testing.T) { } } +func TestBrailleHeadingNameCannotLeakEmbeddedObjects(t *testing.T) { + options := config.DefaultSettings().Braille + heading := atspi.NewSnapshot("/heading", "Before \ufffc after", "heading", "", "") + heading.HeadingLevel = 2 + result := review.Result{DocumentPosition: review.DocumentPosition{Snapshot: heading}} + frame := brailleDocumentFrame(result, options) + if strings.ContainsRune(frame.Text, '\ufffc') || frame.Text != "Before after h2" { + t.Fatalf("heading name fallback = %+v", frame) + } + // A label before a routable line must be cleaned before calculating + // cursor, indicator and routing offsets. + entry := atspi.NewSnapshot("/entry", "Label\ufffc", "entry", "", "猫") + entry.BusName, entry.TextCaretKnown = ":1.999", true + entry.BrailleLine, entry.BrailleCaret = "猫", 0 + frame = brailleFocusFrameWithOptions(entry, options) + if frame.Text != "Label 猫 $l" || frame.Cursor != 6 || len(frame.Routes) != 1 || frame.Routes[0].Start != 6 { + t.Fatalf("cleaned label offsets = %+v", frame) + } +} + func TestBrowseBrailleAssembledLineDropsEmbeddedObjectPlaceholders(t *testing.T) { heading := atspi.NewSnapshot("/heading", "Cool video title", "heading", "", "") heading.BusName = ":1.999" diff --git a/internal/app/linked_headings_native_test.go b/internal/app/linked_headings_native_test.go index 256a451..88e2706 100644 --- a/internal/app/linked_headings_native_test.go +++ b/internal/app/linked_headings_native_test.go @@ -78,6 +78,54 @@ func TestNativeLinkedHeadings(t *testing.T) { t.Run("sound geometry after scrolling", func(t *testing.T) { testLinkedHeadingSoundGeometry(t, ctx, adapter, focus) }) + t.Run("linked image heading", func(t *testing.T) { + worker, err := review.NewWorker(adapter, 2*time.Second) + if err != nil { + t.Fatal(err) + } + worker.SetDocumentBrailleEnabled(true) + worker.SyncFocus(focus) + if err := worker.Start(ctx); err != nil { + t.Fatal(err) + } + defer worker.Stop(context.Background()) + for _, name := range []string{"Ordinary heading", "Before Linked image title after"} { + if err := worker.MoveDocument(review.DocumentHeading, 4, false); err != nil { + t.Fatal(err) + } + select { + case result := <-worker.Results(): + frame := brailleDocumentFrame(result, config.DefaultSettings().Braille) + if result.Err != nil || !result.Moved || !strings.Contains(frame.Text, name) || strings.ContainsRune(frame.Text, '\ufffc') { + t.Fatalf("image heading braille=%q error=%v read error=%v", frame.Text, result.Err, result.DocumentBrailleErr) + } + if os.Getenv("CTHULHU_NATIVE_FIREFOX") == "1" && name == "Before Linked image title after" { + deadline := time.NewTimer(3 * time.Second) + defer deadline.Stop() + caret: + for { + select { + case event := <-adapter.Events(): + if event.Type != atspi.CaretEvent || event.Source.ID() != result.DocumentPosition.Snapshot.ID() { + continue + } + caretFrame := brailleFocusFrameWithOptions(event.Source, config.DefaultSettings().Braille) + if !strings.Contains(caretFrame.Text, name) || strings.ContainsRune(caretFrame.Text, '\ufffc') { + t.Fatalf("image heading caret braille=%q native=%q", caretFrame.Text, event.Source.BrailleLine) + } + break caret + case <-deadline.C: + t.Fatal("image heading native caret event missing") + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + } + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + }) for _, tc := range []struct { level int name, title string diff --git a/internal/atspi/adapter_document_native.go b/internal/atspi/adapter_document_native.go index cbdd6a1..1e6795a 100644 --- a/internal/atspi/adapter_document_native.go +++ b/internal/atspi/adapter_document_native.go @@ -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{}) diff --git a/internal/atspi/adapter_snapshot_native.go b/internal/atspi/adapter_snapshot_native.go index 4ea0aed..e2ccf60 100644 --- a/internal/atspi/adapter_snapshot_native.go +++ b/internal/atspi/adapter_snapshot_native.go @@ -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)) diff --git a/internal/atspi/document_caret.go b/internal/atspi/document_caret.go index a39f425..caff9e2 100644 --- a/internal/atspi/document_caret.go +++ b/internal/atspi/document_caret.go @@ -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 "" diff --git a/internal/atspi/document_caret_test.go b/internal/atspi/document_caret_test.go index 596594c..66374fb 100644 --- a/internal/atspi/document_caret_test.go +++ b/internal/atspi/document_caret_test.go @@ -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) + } +} diff --git a/internal/atspi/document_lines_native.go b/internal/atspi/document_lines_native.go index eaceb0e..f656b61 100644 --- a/internal/atspi/document_lines_native.go +++ b/internal/atspi/document_lines_native.go @@ -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) } diff --git a/internal/atspi/firefox_native_test.go b/internal/atspi/firefox_native_test.go index 9fc5ea7..f6c7429 100644 --- a/internal/atspi/firefox_native_test.go +++ b/internal/atspi/firefox_native_test.go @@ -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 { diff --git a/internal/atspi/text_embedded_native_test.go b/internal/atspi/text_embedded_native_test.go new file mode 100644 index 0000000..a14763e --- /dev/null +++ b/internal/atspi/text_embedded_native_test.go @@ -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) + } + } +} diff --git a/internal/atspi/text_native.go b/internal/atspi/text_native.go index 153fbd3..8f6b154 100644 --- a/internal/atspi/text_native.go +++ b/internal/atspi/text_native.go @@ -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: diff --git a/tests/browser-linked-headings.html b/tests/browser-linked-headings.html index 6be5b96..c0de098 100644 --- a/tests/browser-linked-headings.html +++ b/tests/browser-linked-headings.html @@ -9,6 +9,8 @@

#1 Username Example date

Post destination

Date destination

Ordinary heading

+

Before Linked image title after

+

Image destination

Choose: First link and Second link

First destination

Second destination

Offscreen first topic