Better handle punctuation inside words like email addresses, urls, file paths, etc.

This commit is contained in:
Storm Dragon committed 2026-10-28 20:52:18 -04:00
1 parent 7c53cb39d4
commit 7df1fa3ecb
4 files changed
+101 -7

No files matched your search

+6 -2
View File
@@ -331,8 +331,12 @@ mark.
- **Fenrir+Backspace** passes the next key through to the application. - **Fenrir+Backspace** passes the next key through to the application.
Character echo, word echo, and cursor echo can be configured separately. Character echo, word echo, and cursor echo can be configured separately.
Punctuation levels are `none`, `some`, `most`, and `all`. Character review still Punctuation levels are `none`, `some`, `most`, and `all`. At `some` and `most`,
names punctuation. Capital indicators, emoticons, repeated-symbol summaries, 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. indentation feedback, and attribute feedback are configurable in Reading.
**Fenrir+T** announces the time; press twice quickly for the date. **Fenrir+T** announces the time; press twice quickly for the date.
+21
View File
@@ -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) { func TestReviewCharactersUseProfileRegardlessOfLevelAndKeepBoundaries(t *testing.T) {
processor, _, err := punctuation.Load([]string{"../../config/punctuation/fr.toml"}) processor, _, err := punctuation.Load([]string{"../../config/punctuation/fr.toml"})
if err != nil { if err != nil {
+33 -5
View File
@@ -13,6 +13,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"unicode"
"unicode/utf8" "unicode/utf8"
"git.stormux.org/storm/fenrir/internal/i18n" "git.stormux.org/storm/fenrir/internal/i18n"
@@ -156,14 +157,37 @@ func (p *Processor) Character(char rune) string {
return presentation.Character(char) return presentation.Character(char)
} }
// Text follows Python's levels and pause handling, retaining unspoken sentence // Text retains Python's levels and pause handling. At some and most, symbols
// punctuation and replacing other unspoken symbols with spaces. Numbers and // embedded between letters or numbers are also named, except apostrophes in
// contractions therefore retain their original punctuation at lower levels. // contractions and single numeric separators. Sentence punctuation still follows
// the selected level.
func (p *Processor) Text(text, level string) string { func (p *Processor) Text(text, level string) string {
selected := p.levels[level] selected := p.levels[level]
var result strings.Builder var result strings.Builder
for _, char := range text { chars := []rune(text)
if selected[char] { 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.WriteByte(' ')
result.WriteString(p.Character(char)) result.WriteString(p.Character(char))
if strings.ContainsRune("',.;:?!", char) { if strings.ContainsRune("',.;:?!", char) {
@@ -180,3 +204,7 @@ func (p *Processor) Text(text, level string) string {
} }
return result.String() return result.String()
} }
func is_word_rune(char rune) bool {
return unicode.IsLetter(char) || unicode.IsNumber(char) || unicode.IsMark(char)
}
+41
View File
@@ -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) { func TestCustomNamesAndInvalidProfiles(t *testing.T) {
data, err := os.ReadFile("../../config/punctuation/default.toml") data, err := os.ReadFile("../../config/punctuation/default.toml")
if err != nil { if err != nil {