Expand embedded objects in braille document lines

Structural navigation to a heading whose native text is a single embedded-object placeholder for its child link showed only that placeholder in braille while speech used the expanded title. Braille line reads now expand embedded objects like speech capture, preserving RawText and native offsets; expanded lines carry no cell routes or native selection/attribute dots and only a line-start cursor. Frame assembly strips any remaining placeholders and falls back to displayed text so a placeholder never reaches the display. Regression tests cover placeholder-only, mixed, expanded, assembled, and empty headings plus native linked-heading and Firefox mixed-line braille. Maintainer confirms physical braille and speech agree on YouTube headings.
This commit is contained in:
hjozwiak committed 2026-11-03 00:17:08 -05:00
1 parent 60f7f7a2e9
commit 8ce5e5cdad
7 files changed
+245 -21

No files matched your search

+82 -14
View File
@@ -32,19 +32,27 @@ func brailleDocumentFrame(result review.Result, options config.BrailleOptions, c
return linkedHeadingBraille(frame, result.DocumentPosition.LinkedHeading, options, textLength)
}
frame := braille.Frame{Cursor: -1, WordWrap: options.WordWrap}
appendContent := func(source atspi.Snapshot, raw, separator string, sourceStart int) {
// native reports that raw maps one-to-one onto native offsets at
// sourceStart. Expanded or placeholder-stripped text keeps display
// order but gets no routes or native-offset indicators, and only a
// caret at the very start of the span can be placed honestly.
appendContent := func(source atspi.Snapshot, raw, separator string, sourceStart int, native bool) {
frame.Text += separator
start := utf8.RuneCountInString(frame.Text)
frame.Text += raw
length := utf8.RuneCountInString(raw)
if source.ID() == snapshot.ID() && result.DocumentPosition.Offset >= sourceStart && result.DocumentPosition.Offset <= sourceStart+length {
frame.Cursor = start + result.DocumentPosition.Offset - sourceStart
if native {
frame.Cursor = start + result.DocumentPosition.Offset - sourceStart
} else if result.DocumentPosition.Offset == sourceStart {
frame.Cursor = start
}
}
if source.ID().Valid() && source.Role != "password text" && length > 0 {
if native && source.ID().Valid() && source.Role != "password text" && length > 0 {
frame.Routes = append(frame.Routes, braille.TextRoute{Start: start, End: start + length,
SourceStart: sourceStart, TextLength: length, BusName: source.BusName, Path: source.Path, SourceText: raw})
}
if options.SelectionIndicator != 0 && source.SelectionEnd > source.SelectionStart {
if native && options.SelectionIndicator != 0 && source.SelectionEnd > source.SelectionStart {
frame.Dots = append(frame.Dots, make([]byte, utf8.RuneCountInString(frame.Text)-len(frame.Dots))...)
for offset := max(source.SelectionStart, sourceStart); offset < min(source.SelectionEnd, sourceStart+length); offset++ {
frame.Dots[start+offset-sourceStart] |= byte(options.SelectionIndicator)
@@ -60,7 +68,7 @@ func brailleDocumentFrame(result review.Result, options config.BrailleOptions, c
frame.Dots[offset] |= byte(options.LinkIndicator)
}
}
if len(configured) != 0 {
if native && len(configured) != 0 {
if len(source.TextAttributeRuns) != 0 {
for _, run := range source.TextAttributeRuns {
markTextAttributeRun(&frame, run, sourceStart, start, length, configured[0])
@@ -79,12 +87,38 @@ func brailleDocumentFrame(result review.Result, options config.BrailleOptions, c
if content.Snapshot.Role == "password text" {
continue
}
appendContent(content.Snapshot, raw, content.Separator, content.Start)
// A placeholder fragment stands for child objects presented
// as their own contents; drop it instead of duplicating them.
native := !strings.ContainsRune(raw, '\ufffc')
if !native {
raw = stripEmbeddedObjects(raw)
}
appendContent(content.Snapshot, raw, content.Separator, content.Start, native)
}
if 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 != "" {
frame.Text, frame.Routes, frame.Dots = text, nil, nil
if frame.Cursor > 0 {
frame.Cursor = 0
}
}
}
} else {
snapshot.SelectionStart, snapshot.SelectionEnd = line.SelectionStart, line.SelectionEnd
snapshot.TextAttributeRuns = line.TextAttributeRuns
appendContent(snapshot, strings.TrimRight(line.Text, "\r\n"), "", line.Start)
text := strings.TrimRight(line.Text, "\r\n")
native := !line.Expanded && !strings.ContainsRune(text, '\ufffc')
if !native {
text = stripEmbeddedObjects(text)
if strings.TrimSpace(text) == "" {
text = brailleDisplayedText(snapshot)
}
}
if native {
snapshot.SelectionStart, snapshot.SelectionEnd = line.SelectionStart, line.SelectionEnd
snapshot.TextAttributeRuns = line.TextAttributeRuns
}
appendContent(snapshot, text, "", line.Start, native)
}
textLength := utf8.RuneCountInString(frame.Text)
if frame.Cursor < 0 || frame.Cursor > textLength {
@@ -163,6 +197,23 @@ func brailleFocusFrameWithOptions(s atspi.Snapshot, options config.BrailleOption
return braille_context_frame(braille_object_frame(s, options, configured...), s, options)
}
// Embedded-object placeholders stand for child accessibles in native text.
// They are structure, not displayed characters, and must never reach a frame.
func stripEmbeddedObjects(value string) string {
return strings.ReplaceAll(value, "\ufffc", "")
}
// brailleDisplayedText returns the text speech uses for an object whose
// native line is only placeholders: expanded text first, then the name.
func brailleDisplayedText(snapshot atspi.Snapshot) string {
for _, candidate := range []string{snapshot.Text, snapshot.Name} {
if candidate = strings.TrimSpace(candidate); candidate != "" && !strings.ContainsRune(candidate, '\ufffc') {
return candidate
}
}
return ""
}
func braille_object_frame(s atspi.Snapshot, options config.BrailleOptions, configured ...config.TextAttributeSettings) braille.Frame {
if frame, handled := libreOfficeBrailleFrame(s, options); handled {
return frame
@@ -174,6 +225,19 @@ func braille_object_frame(s atspi.Snapshot, options config.BrailleOptions, confi
label = ""
}
line := strings.TrimRight(s.BrailleLine, "\r\n")
// A line containing embedded-object placeholders cannot map cells
// to native offsets. Prefer the expanded capture of the same line,
// then real surrounding text, then the accessible name.
native := !strings.ContainsRune(line, '\ufffc')
if !native {
if expanded := strings.TrimSpace(s.Text); expanded != "" && !strings.ContainsRune(expanded, '\ufffc') {
line = expanded
} else if stripped := stripEmbeddedObjects(line); strings.TrimSpace(stripped) != "" {
line = stripped
} else {
line = brailleDisplayedText(s)
}
}
if label == line {
label = ""
}
@@ -186,7 +250,11 @@ func braille_object_frame(s atspi.Snapshot, options config.BrailleOptions, confi
cursor := -1
relative := s.BrailleCaret - s.BrailleLineStart
if s.TextCaretKnown && relative >= 0 && relative <= utf8.RuneCountInString(s.BrailleLine) {
cursor = utf8.RuneCountInString(prefix) + min(relative, utf8.RuneCountInString(line))
if native {
cursor = utf8.RuneCountInString(prefix) + min(relative, utf8.RuneCountInString(line))
} else if relative == 0 {
cursor = utf8.RuneCountInString(prefix)
}
}
if role != "terminal" && role != "heading" && !options.DisableEOL {
text += " $l"
@@ -194,7 +262,7 @@ func braille_object_frame(s atspi.Snapshot, options config.BrailleOptions, confi
text += " "
}
frame := braille.Frame{Text: text, Cursor: cursor, WordWrap: options.WordWrap}
if options.SelectionIndicator != 0 && s.SelectionEnd > s.SelectionStart {
if native && options.SelectionIndicator != 0 && s.SelectionEnd > s.SelectionStart {
frame.Dots = make([]byte, utf8.RuneCountInString(frame.Text))
start := utf8.RuneCountInString(prefix)
for source := max(s.SelectionStart, s.BrailleLineStart); source < min(s.SelectionEnd, s.BrailleLineStart+utf8.RuneCountInString(line)); source++ {
@@ -216,7 +284,7 @@ func braille_object_frame(s atspi.Snapshot, options config.BrailleOptions, confi
break
}
}
if len(configured) != 0 {
if native && len(configured) != 0 {
if len(s.TextAttributeRuns) != 0 {
for _, run := range s.TextAttributeRuns {
markTextAttributeRun(&frame, run, s.BrailleLineStart, utf8.RuneCountInString(prefix), utf8.RuneCountInString(line), configured[0])
@@ -226,7 +294,7 @@ func braille_object_frame(s atspi.Snapshot, options config.BrailleOptions, confi
}
}
// Only actual text and its EOL marker route; labels and state suffixes do not.
if s.TextCaretKnown && s.ID().Valid() {
if native && s.TextCaretKnown && s.ID().Valid() {
frame.Routes = []braille.TextRoute{{Start: utf8.RuneCountInString(prefix), End: utf8.RuneCountInString(text),
SourceStart: s.BrailleLineStart, TextLength: utf8.RuneCountInString(line),
BusName: s.BusName, Path: s.Path, SourceText: s.BrailleLine}}
@@ -341,7 +409,7 @@ func braille_object_frame(s atspi.Snapshot, options config.BrailleOptions, confi
case "progress bar":
text = brailleJoin(name, value, roleName)
default:
displayed := s.Text
displayed := stripEmbeddedObjects(s.Text)
if displayed == name {
displayed = ""
}