From 7df1fa3ecbfb3976fe9e28258961316cbdb6217a Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Wed, 28 Oct 2026 20:52:18 -0400 Subject: [PATCH] Better handle punctuation inside words like email addresses, urls, file paths, etc. --- docs/user.md | 8 ++++-- internal/app/punctuation_test.go | 21 ++++++++++++++ internal/punctuation/profile.go | 38 ++++++++++++++++++++++---- internal/punctuation/profile_test.go | 41 ++++++++++++++++++++++++++++ 4 files changed, 101 insertions(+), 7 deletions(-) diff --git a/docs/user.md b/docs/user.md index 2619c8c5..253eb774 100644 --- a/docs/user.md +++ b/docs/user.md @@ -331,8 +331,12 @@ mark. - **Fenrir+Backspace** passes the next key through to the application. Character echo, word echo, and cursor echo can be configured separately. -Punctuation levels are `none`, `some`, `most`, and `all`. Character review still -names punctuation. Capital indicators, emoticons, repeated-symbol summaries, +Punctuation levels are `none`, `some`, `most`, and `all`. At `some` and `most`, +punctuation inside words, URLs, email addresses, and filenames is also named. +Apostrophes within words and single numeric separators such as those in `3.14`, +`1,000`, and `12:30` follow the selected level. Sentence punctuation follows the +selected level too. Character review always names punctuation. +Capital indicators, emoticons, repeated-symbol summaries, indentation feedback, and attribute feedback are configurable in Reading. **Fenrir+T** announces the time; press twice quickly for the date. diff --git a/internal/app/punctuation_test.go b/internal/app/punctuation_test.go index 5dc63fa4..8cbd6cab 100644 --- a/internal/app/punctuation_test.go +++ b/internal/app/punctuation_test.go @@ -45,6 +45,27 @@ func TestSharedSpeechPunctuationAndPreparedCharacterFeedback(t *testing.T) { } } +func TestURLSpeechKeepsEmbeddedPunctuationAndOriginalClipboardText(t *testing.T) { + processor, _, err := punctuation.Load([]string{"../../config/punctuation/default.toml"}) + if err != nil { + t.Fatal(err) + } + for _, substitutions := range []bool{false, true} { + settings := config.Defaults() + settings.Reading.Emoticons = substitutions + settings.Reading.RepeatedSymbols = substitutions + voice := &fakeVoice{} + echo := &echoSpeaker{Speaker: voice, punctuation: processor, punctuationLevel: "some", settings: &settings} + echo.Say("https://stormux.org") + if !reflect.DeepEqual(voice.messages, []string{"https colon: slash slash stormux dot. org"}) { + t.Fatal("URL speech lost punctuation", substitutions, voice.messages) + } + if echo.last != "https://stormux.org" { + t.Fatal("clipboard echo lost original URL", echo.last) + } + } +} + func TestReviewCharactersUseProfileRegardlessOfLevelAndKeepBoundaries(t *testing.T) { processor, _, err := punctuation.Load([]string{"../../config/punctuation/fr.toml"}) if err != nil { diff --git a/internal/punctuation/profile.go b/internal/punctuation/profile.go index d7082da7..3eb521d7 100644 --- a/internal/punctuation/profile.go +++ b/internal/punctuation/profile.go @@ -13,6 +13,7 @@ import ( "os" "path/filepath" "strings" + "unicode" "unicode/utf8" "git.stormux.org/storm/fenrir/internal/i18n" @@ -156,14 +157,37 @@ func (p *Processor) Character(char rune) string { return presentation.Character(char) } -// Text follows Python's levels and pause handling, retaining unspoken sentence -// punctuation and replacing other unspoken symbols with spaces. Numbers and -// contractions therefore retain their original punctuation at lower levels. +// Text retains Python's levels and pause handling. At some and most, symbols +// embedded between letters or numbers are also named, except apostrophes in +// contractions and single numeric separators. Sentence punctuation still follows +// the selected level. func (p *Processor) Text(text, level string) string { selected := p.levels[level] var result strings.Builder - for _, char := range text { - if selected[char] { + chars := []rune(text) + embeddedEnd := 0 + embedded := false + for index, char := range chars { + if index >= embeddedEnd { + embeddedEnd = index + 1 + embedded = false + if (level == "some" || level == "most") && (unicode.IsPunct(char) || unicode.IsSymbol(char)) { + for embeddedEnd < len(chars) && (unicode.IsPunct(chars[embeddedEnd]) || unicode.IsSymbol(chars[embeddedEnd])) { + embeddedEnd++ + } + embedded = index > 0 && embeddedEnd < len(chars) && + is_word_rune(chars[index-1]) && is_word_rune(chars[embeddedEnd]) + if embedded && embeddedEnd == index+1 { + before, after := chars[index-1], chars[embeddedEnd] + if char == '\'' && unicode.IsLetter(before) && unicode.IsLetter(after) || + strings.ContainsRune(".,:", char) && unicode.IsDigit(before) && unicode.IsDigit(after) { + embedded = false + } + } + } + } + _, known := p.symbols[char] + if selected[char] || embedded && known { result.WriteByte(' ') result.WriteString(p.Character(char)) if strings.ContainsRune("',.;:?!", char) { @@ -180,3 +204,7 @@ func (p *Processor) Text(text, level string) string { } return result.String() } + +func is_word_rune(char rune) bool { + return unicode.IsLetter(char) || unicode.IsNumber(char) || unicode.IsMark(char) +} diff --git a/internal/punctuation/profile_test.go b/internal/punctuation/profile_test.go index 4cef2e2e..c220fbd5 100644 --- a/internal/punctuation/profile_test.go +++ b/internal/punctuation/profile_test.go @@ -57,6 +57,47 @@ func TestTextLevelsPreservePausesAndDoNotReprocessNames(t *testing.T) { } } +func TestEmbeddedPunctuation(t *testing.T) { + processor, _, err := Load([]string{"../../config/punctuation/default.toml"}) + if err != nil { + t.Fatal(err) + } + for _, trial := range []struct{ input, want string }{ + {"https://stormux.org", "https colon: slash slash stormux dot. org"}, + {"Visit https://example.invalid/path?q=a&n=2.", "Visit https colon: slash slash example dot. invalid slash path question?? q equals a and n equals 2."}, + {"user@example.invalid", "user at example dot. invalid"}, + {"file_name.txt", "file line name dot. txt"}, + {"你好.世界 café:menu", "你好 dot. 世界 café colon: menu"}, + {"cafe\u0301.txt", "cafe\u0301 dot. txt"}, + {"don't can't", "don't can't"}, + {"3.14 1,000 12:30", "3.14 1,000 12:30"}, + {"Hello, world! End. Next: word?", "Hello, world! End. Next: word?"}, + {"(word) [word]", "word word"}, + {"a...b", "a dot. dot. dot. b"}, + } { + if got := strings.Join(strings.Fields(processor.Text(trial.input, "some")), " "); got != trial.want { + t.Errorf("%q: %q, want %q", trial.input, got, trial.want) + } + } + for _, trial := range []struct{ level, want string }{ + {"none", "https: stormux.org"}, + {"some", "https colon: slash slash stormux dot. org"}, + {"most", "https colon: slash slash stormux dot. org"}, + {"all", "https colon: slash slash stormux dot. org"}, + } { + if got := processor.Text("https://stormux.org", trial.level); got != trial.want { + t.Errorf("%s: %q, want %q", trial.level, got, trial.want) + } + } + french, _, err := Load([]string{"../../config/punctuation/fr.toml"}) + if err != nil { + t.Fatal(err) + } + if got := french.Text("example.invalid", "some"); got != "example point. invalid" { + t.Fatal("embedded punctuation lost profile name", got) + } +} + func TestCustomNamesAndInvalidProfiles(t *testing.T) { data, err := os.ReadFile("../../config/punctuation/default.toml") if err != nil {