Files
cthulhu/internal/app/document_review.go

462 lines
18 KiB
Go

// Cthulhu screen reader.
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
//
// This program is free software: you can redistribute it and/or modify it
// under the terms of the GNU General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option)
// any later version.
//
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
// more details.
//
// You should have received a copy of the GNU General Public License along with
// this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
package app
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/presentation"
"git.stormux.org/storm/cthulhu/internal/review"
"git.stormux.org/storm/cthulhu/internal/sound"
)
func documentReviewCommand(command input.Command) (review.DocumentTarget, int, bool) {
switch command {
case input.DocumentNextHeading:
return review.DocumentHeading, 0, false
case input.DocumentPreviousHeading:
return review.DocumentHeading, 0, true
case input.DocumentNextHeadingAtLevel1:
return review.DocumentHeading, 1, false
case input.DocumentPreviousHeadingAtLevel1:
return review.DocumentHeading, 1, true
case input.DocumentNextHeadingAtLevel2:
return review.DocumentHeading, 2, false
case input.DocumentPreviousHeadingAtLevel2:
return review.DocumentHeading, 2, true
case input.DocumentNextHeadingAtLevel3:
return review.DocumentHeading, 3, false
case input.DocumentPreviousHeadingAtLevel3:
return review.DocumentHeading, 3, true
case input.DocumentNextHeadingAtLevel4:
return review.DocumentHeading, 4, false
case input.DocumentPreviousHeadingAtLevel4:
return review.DocumentHeading, 4, true
case input.DocumentNextHeadingAtLevel5:
return review.DocumentHeading, 5, false
case input.DocumentPreviousHeadingAtLevel5:
return review.DocumentHeading, 5, true
case input.DocumentNextHeadingAtLevel6:
return review.DocumentHeading, 6, false
case input.DocumentPreviousHeadingAtLevel6:
return review.DocumentHeading, 6, true
case input.DocumentNextLink:
return review.DocumentLink, 0, false
case input.DocumentPreviousLink:
return review.DocumentLink, 0, true
case input.DocumentNextButton:
return review.DocumentButton, 0, false
case input.DocumentPreviousButton:
return review.DocumentButton, 0, true
case input.DocumentNextCheckBox:
return review.DocumentCheckBox, 0, false
case input.DocumentPreviousCheckBox:
return review.DocumentCheckBox, 0, true
case input.DocumentNextList:
return review.DocumentList, 0, false
case input.DocumentPreviousList:
return review.DocumentList, 0, true
case input.DocumentNextSeparator:
return review.DocumentSeparator, 0, false
case input.DocumentPreviousSeparator:
return review.DocumentSeparator, 0, true
case input.DocumentNextBlockQuote:
return review.DocumentBlockQuote, 0, false
case input.DocumentPreviousBlockQuote:
return review.DocumentBlockQuote, 0, true
case input.DocumentNextComboBox:
return review.DocumentComboBox, 0, false
case input.DocumentPreviousComboBox:
return review.DocumentComboBox, 0, true
case input.DocumentNextEntry:
return review.DocumentEntry, 0, false
case input.DocumentPreviousEntry:
return review.DocumentEntry, 0, true
case input.DocumentNextFormField:
return review.DocumentFormField, 0, false
case input.DocumentPreviousFormField:
return review.DocumentFormField, 0, true
case input.DocumentNextImage:
return review.DocumentImage, 0, false
case input.DocumentPreviousImage:
return review.DocumentImage, 0, true
case input.DocumentNextLandmark:
return review.DocumentLandmark, 0, false
case input.DocumentPreviousLandmark:
return review.DocumentLandmark, 0, true
case input.DocumentNextListItem:
return review.DocumentListItem, 0, false
case input.DocumentPreviousListItem:
return review.DocumentListItem, 0, true
case input.DocumentNextLiveRegion:
return review.DocumentLiveRegion, 0, false
case input.DocumentPreviousLiveRegion:
return review.DocumentLiveRegion, 0, true
case input.DocumentNextParagraph:
return review.DocumentParagraph, 0, false
case input.DocumentPreviousParagraph:
return review.DocumentParagraph, 0, true
case input.DocumentNextRadioButton:
return review.DocumentRadioButton, 0, false
case input.DocumentPreviousRadioButton:
return review.DocumentRadioButton, 0, true
case input.DocumentNextTable:
return review.DocumentTable, 0, false
case input.DocumentPreviousTable:
return review.DocumentTable, 0, true
case input.DocumentNextUnvisitedLink:
return review.DocumentUnvisitedLink, 0, false
case input.DocumentPreviousUnvisitedLink:
return review.DocumentUnvisitedLink, 0, true
case input.DocumentNextVisitedLink:
return review.DocumentVisitedLink, 0, false
case input.DocumentPreviousVisitedLink:
return review.DocumentVisitedLink, 0, true
case input.DocumentNextClickable:
return review.DocumentClickable, 0, false
case input.DocumentPreviousClickable:
return review.DocumentClickable, 0, true
case input.DocumentNextChunk:
return review.DocumentChunk, 0, false
case input.DocumentPreviousChunk:
return review.DocumentChunk, 0, true
}
return 0, 0, false
}
func documentCaretCommand(command input.Command) review.DocumentCaretCommand {
switch command {
case input.DocumentTableLeft:
return review.TableLeft
case input.DocumentTableRight:
return review.TableRight
case input.DocumentTableUp:
return review.TableUp
case input.DocumentTableDown:
return review.TableDown
case input.DocumentTableFirst:
return review.TableFirst
case input.DocumentTableLast:
return review.TableLast
case input.DocumentCaretNextCharacter:
return review.CaretNextCharacter
case input.DocumentCaretPreviousCharacter:
return review.CaretPreviousCharacter
case input.DocumentCaretNextWord:
return review.CaretNextWord
case input.DocumentCaretPreviousWord:
return review.CaretPreviousWord
case input.DocumentCaretNextLine:
return review.CaretNextLine
case input.DocumentCaretPreviousLine:
return review.CaretPreviousLine
case input.DocumentCaretLineStart:
return review.CaretLineStart
case input.DocumentCaretLineEnd:
return review.CaretLineEnd
case input.DocumentCaretDocumentStart:
return review.CaretDocumentStart
case input.DocumentCaretDocumentEnd:
return review.CaretDocumentEnd
}
return 0
}
func presentDocumentCaretResult(logger *slog.Logger, speech speechEnqueuer, result review.Result, options presentation.Options) {
if logger.Enabled(context.Background(), slog.LevelDebug) {
logger.Debug("document caret navigation completed", "elapsed", result.Elapsed,
"capture_duration", result.CaptureDuration, "capture_reason", result.CaptureReason,
"moved", result.Moved, "command", result.DocumentCaretCommand.String(),
"bus", result.DocumentPosition.Object.ID().BusName,
"source_path", result.DocumentPosition.Object.ID().Path,
"virtual_caret_offset", result.DocumentPosition.Offset,
"native_text_start", result.DocumentCaretStart, "native_text_end", result.DocumentCaretEnd,
"line_fragments", document_line_ranges(result.DocumentCaretLine),
"caret_sync_error", result.CaretErr,
"braille_line_available", result.DocumentBrailleLine != nil || result.DocumentCaretLine != nil,
"braille_read_error", result.DocumentBrailleErr)
}
if result.Err != nil {
logger.Warn("document caret navigation failed", "error", result.Err)
speech.Enqueue(speechRequest{Text: "Could not find current location.", Role: "system", Cause: "document-caret"})
return
}
if !result.Moved && result.DocumentCaretText == "" {
return
}
text := result.DocumentCaretText
if text == " " {
text = "space"
}
if text == "\n" {
if !options.SpeakBlankLines {
return
}
text = "blank"
}
request := speechRequest{Text: text, Role: result.DocumentPosition.Snapshot.Role, Cause: "document-caret"}
request.Context = result.DocumentCaretContext
if len(request.Context) > 0 && request.Context[0].ID() == result.DocumentPosition.Snapshot.ID() {
request.Context = request.Context[1:]
}
if result.DocumentCaretCommand >= review.TableLeft && result.DocumentCaretCommand <= review.TableLast {
snapshot := result.DocumentPosition.Snapshot
prefix := result.DocumentTablePrefix
if result.Moved && options.SpeechVerbosityLevel != 0 && prefix != "" &&
(result.DocumentTableRowHeader != "" || result.DocumentTableColumnHeader != "") {
parts := []string{
presentation.TableHeaderText(result.DocumentTableRowHeader, "row header", options.SpeechVerbosityLevel),
presentation.TableHeaderText(result.DocumentTableColumnHeader, "column header", options.SpeechVerbosityLevel),
}
prefix = strings.TrimSpace(strings.Join(parts, " "))
if strings.HasPrefix(request.Text, result.DocumentTablePrefix+" ") {
request.Text = prefix + strings.TrimPrefix(request.Text, result.DocumentTablePrefix)
}
}
if result.Moved && options.Reading != nil && options.Reading.OnlySpeakDisplayedText {
request.Text = snapshot.Name
}
vertical := result.DocumentCaretCommand != review.TableLeft && result.DocumentCaretCommand != review.TableRight
if result.Moved && vertical && snapshot.TableRowText != "" && presentation.FullRowEnabled(snapshot, options) {
if options.Reading != nil && options.Reading.OnlySpeakDisplayedText {
request.Text = snapshot.TableRowText
} else {
request.Text = strings.TrimSpace(strings.Join([]string{prefix, snapshot.TableRowText, result.DocumentTableSuffix}, " "))
}
}
}
for _, ancestor := range result.DocumentCaretContext {
if ancestor.Role == "link" {
request.VoiceType = "hyperlink"
break
}
}
switch result.DocumentCaretCommand {
case review.CaretNextCharacter, review.CaretPreviousCharacter, review.CaretLineStart, review.CaretLineEnd:
request.Character = true
case review.CaretNextLine, review.CaretPreviousLine, review.CaretDocumentStart, review.CaretDocumentEnd,
review.CaretNextWord, review.CaretPreviousWord:
line := atspi.DocumentLine{Contents: []atspi.DocumentContent{{Snapshot: result.DocumentPosition.Snapshot, Text: text, RawText: result.DocumentCaretRawText}}}
if result.DocumentCaretCommand != review.CaretNextWord && result.DocumentCaretCommand != review.CaretPreviousWord {
request.Line = document_speech_line(line)
}
roles := map[string]bool{result.DocumentPosition.Snapshot.Role: true}
// Chromium commonly exposes link/heading text in static descendants.
// Python's web _generateRoleName includes those ancestor roles. Keep
// their identity separate from the leaf which owns the caret offset.
for _, ancestor := range result.DocumentCaretContext {
if (ancestor.Role != "link" && ancestor.Role != "heading") || roles[ancestor.Role] {
continue
}
roles[ancestor.Role] = true
ancestor.Name = ""
line.Contents = append(line.Contents, atspi.DocumentContent{Snapshot: ancestor})
}
spoken := presentation.DocumentLineSpeechWithOptions(line, options)
request.Text, request.Chunks = spoken.Text, spoken.Chunks
request.Sound = presentation.DocumentLineSoundCues(line, options)
fallback := options
fallback.RoleSoundPresentation = "sound_and_speech"
fallbackSpeech := presentation.DocumentLineSpeechWithOptions(line, fallback)
request.FallbackText, request.FallbackChunks = fallbackSpeech.Text, fallbackSpeech.Chunks
}
speech.Interrupt()
if request.Text == "" && len(request.Sound) == 0 {
return
}
speech.Enqueue(request)
}
func document_line_ranges(line *atspi.DocumentLine) []map[string]any {
if line == nil {
return nil
}
ranges := make([]map[string]any, 0, len(line.Contents))
for _, content := range line.Contents {
ranges = append(ranges, map[string]any{"bus": content.Snapshot.BusName,
"source_path": content.Snapshot.Path, "native_text_start": content.Start,
"native_text_end": content.End, "bounds": content.Bounds})
}
return ranges
}
func documentResultMatchesCurrent(result review.Result, current atspi.Snapshot) bool {
if result.DocumentFocus == current.ID() {
return true
}
return result.Moved && result.Err == nil &&
result.DocumentPosition.Snapshot.ID() == current.ID()
}
func presentDocumentResult(logger *slog.Logger, speech speechEnqueuer, result review.Result, options presentation.Options) {
if logger.Enabled(context.Background(), slog.LevelDebug) {
logger.Debug("document navigation completed", "elapsed", result.Elapsed,
"capture_duration", result.CaptureDuration, "capture_reason", result.CaptureReason,
"moved", result.Moved, "target", result.DocumentTarget,
"level", result.DocumentLevel, "backwards", result.DocumentBackwards,
"bus", result.DocumentPosition.Object.ID().BusName,
"source_path", result.DocumentPosition.Object.ID().Path,
"virtual_caret_offset", result.DocumentPosition.Offset,
"caret_sync_error", result.CaretErr,
"sound_geometry_error", result.DocumentSoundErr,
"braille_line_available", result.DocumentBrailleLine != nil,
"braille_read_error", result.DocumentBrailleErr)
}
text, fallbackText, role := "", "", "system"
var spoken, fallbackSpeech presentation.Speech
var cues []sound.Cue
switch {
case errors.Is(result.Err, atspi.ErrNotInDocument):
text = "Not in web document content."
case result.Err != nil:
logger.Warn("document navigation failed", "error", result.Err)
text = "Could not find current location."
case !result.Moved:
switch result.DocumentTarget {
case review.DocumentHeading:
if result.DocumentLevel == 0 {
text = "No more headings."
} else {
text = fmt.Sprintf("No more headings at level %d.", result.DocumentLevel)
}
case review.DocumentLink:
text = "No more links."
case review.DocumentButton:
text = "No more buttons."
case review.DocumentCheckBox:
text = "No more check boxes."
case review.DocumentList:
text = "No more lists."
case review.DocumentSeparator:
text = "No more separators."
case review.DocumentBlockQuote:
text = "No more blockquotes."
case review.DocumentComboBox:
text = "No more combo boxes."
case review.DocumentEntry:
text = "No more entries."
case review.DocumentFormField:
text = "No more form fields."
case review.DocumentImage:
text = "No more images."
case review.DocumentLandmark:
text = "No landmark found."
case review.DocumentListItem:
text = "No more list items."
case review.DocumentLiveRegion:
text = "No more live regions."
case review.DocumentParagraph:
text = "No more paragraphs."
case review.DocumentRadioButton:
text = "No more radio buttons."
case review.DocumentTable:
text = "No more tables."
case review.DocumentUnvisitedLink:
text = "No more unvisited links."
case review.DocumentVisitedLink:
text = "No more visited links."
case review.DocumentClickable:
text = "No more clickables."
case review.DocumentChunk:
text = "No more large objects."
}
default:
if link := result.DocumentPosition.LinkedHeading; link != nil {
roleOnly := *link
roleOnly.Name, roleOnly.Text = "", ""
line := atspi.DocumentLine{Contents: []atspi.DocumentContent{
{Snapshot: result.DocumentPosition.Snapshot}, {Snapshot: roleOnly},
}}
spoken = presentation.DocumentLineSpeechWithOptions(line, options)
text = spoken.Text
cues = presentation.DocumentLineSoundCues(line, options)
fallbackOptions := options
fallbackOptions.RoleSoundPresentation = "sound_and_speech"
fallbackSpeech = presentation.DocumentLineSpeechWithOptions(line, fallbackOptions)
fallbackText = fallbackSpeech.Text
} else {
spoken = presentation.FocusSpeechWithOptions(result.DocumentPosition.Snapshot, options)
text = spoken.Text
cues = presentation.SoundCues(result.DocumentPosition.Snapshot, options)
if options.RoleSoundPresentation == "sound_only" {
fallbackOptions := options
fallbackOptions.RoleSoundPresentation = "sound_and_speech"
fallbackSpeech = presentation.FocusSpeechWithOptions(result.DocumentPosition.Snapshot, fallbackOptions)
fallbackText = fallbackSpeech.Text
}
}
if len(cues) > 0 && logger.Enabled(context.Background(), slog.LevelDebug) {
// Record the captured geometry actually used for cue generation.
// Linked headings use the link's geometry independently of the heading.
attributes := []any{
"bus", result.DocumentPosition.Snapshot.BusName,
"source_path", result.DocumentPosition.Snapshot.Path,
"role", result.DocumentPosition.Snapshot.Role,
"spatialize_object_sounds", options.SpatializeObjectSounds,
"sound_geometry", result.DocumentPosition.Snapshot.SoundGeometry,
"cues", cues,
}
if link := result.DocumentPosition.LinkedHeading; link != nil {
attributes = append(attributes,
"linked_heading_bus", link.BusName,
"linked_heading_path", link.Path,
"linked_heading_sound_geometry", link.SoundGeometry)
}
logger.Debug("document navigation sound positioning", attributes...)
}
role = result.DocumentPosition.Snapshot.Role
if result.DocumentWrapped {
if result.DocumentBackwards {
spoken = spoken.Append("Wrapping to bottom.", ". ")
text = spoken.Text
fallbackSpeech = fallbackSpeech.Append("Wrapping to bottom.", ". ")
fallbackText = fallbackSpeech.Text
} else {
spoken = spoken.Append("Wrapping to top.", ". ")
text = spoken.Text
fallbackSpeech = fallbackSpeech.Append("Wrapping to top.", ". ")
fallbackText = fallbackSpeech.Text
}
}
if result.CaretErr != nil {
logger.Warn("document caret synchronization failed", "error", result.CaretErr)
spoken = spoken.Append("Could not move the browser caret.", ". ")
text = spoken.Text
fallbackSpeech = fallbackSpeech.Append("Could not move the browser caret.", ". ")
fallbackText = fallbackSpeech.Text
}
}
speech.Interrupt()
voiceType := ancestor_voice(result.DocumentCaretContext)
if result.DocumentPosition.LinkedHeading != nil {
voiceType = "hyperlink"
}
ancestors := result.DocumentCaretContext
if len(ancestors) > 0 && ancestors[0].ID() == result.DocumentPosition.Snapshot.ID() {
ancestors = ancestors[1:]
}
speech.Enqueue(speechRequest{Text: text, Chunks: spoken.WithText(text).Chunks, FallbackText: fallbackText, FallbackChunks: fallbackSpeech.WithText(fallbackText).Chunks, Role: role, Sound: cues, Cause: "document-navigation", Context: ancestors, VoiceType: voiceType})
}