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.
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.
+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) {
processor, _, err := punctuation.Load([]string{"../../config/punctuation/fr.toml"})
if err != nil {
+33 -5
View File
@@ -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)
}
+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) {
data, err := os.ReadFile("../../config/punctuation/default.toml")
if err != nil {