diff --git a/docs/user.md b/docs/user.md index e01842d9..b6ebeb61 100644 --- a/docs/user.md +++ b/docs/user.md @@ -122,10 +122,13 @@ mark. Character echo, word echo, and cursor echo can be configured separately. Punctuation levels are `none`, `some`, `most`, and `all`. At `some` and `most`, -punctuation inside words, URLs, email addresses, and filenames is also named. +punctuation inside words, URLs, email addresses, and filenames is also named, +without adding sentence pauses. 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. A percent sign immediately after a digit is named at `some` +selected level too. Multiple dots in numeric sequences are named, so +`2026.10.31` is read as "2026 dot 10 dot 31" at `some` and `most`. +A percent sign immediately after a digit is named at `some` and `most`, so `100%` is read as "100 percent". Character review always names punctuation. Repeated-symbol summaries also handle multiple runs inside matching brackets, such as `[#####-----]`. diff --git a/internal/app/punctuation_test.go b/internal/app/punctuation_test.go index c29be076..6f0a5dc4 100644 --- a/internal/app/punctuation_test.go +++ b/internal/app/punctuation_test.go @@ -45,7 +45,7 @@ func TestSharedSpeechPunctuationAndPreparedCharacterFeedback(t *testing.T) { } } -func TestURLSpeechKeepsEmbeddedPunctuationAndOriginalClipboardText(t *testing.T) { +func TestSpeechKeepsEmbeddedPunctuationAndOriginalClipboardText(t *testing.T) { processor, _, err := punctuation.Load([]string{"../../config/punctuation/default.toml"}) if err != nil { t.Fatal(err) @@ -56,12 +56,19 @@ func TestURLSpeechKeepsEmbeddedPunctuationAndOriginalClipboardText(t *testing.T) 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) + for _, trial := range []struct{ input, want string }{ + {"https://stormux.org", "https colon slash slash stormux dot org"}, + {"2026.10.31", "2026 dot 10 dot 31"}, + {"3.14", "3.14"}, + } { + voice.messages = nil + echo.Say(trial.input) + if !reflect.DeepEqual(voice.messages, []string{trial.want}) { + t.Fatal("speech lost punctuation", trial.input, substitutions, voice.messages) + } + if echo.last != trial.input { + t.Fatal("clipboard echo lost original text", echo.last) + } } } } diff --git a/internal/punctuation/profile.go b/internal/punctuation/profile.go index e94044c5..09b15059 100644 --- a/internal/punctuation/profile.go +++ b/internal/punctuation/profile.go @@ -159,19 +159,35 @@ func (p *Processor) Character(char rune) string { // 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. A percent sign after a digit is -// also named at some and most. Sentence punctuation follows the selected level. +// contractions and numeric separators in decimals, grouped numbers, and times. +// Multiple dots in a numeric sequence are named. Embedded punctuation names +// do not add pause markers. A percent sign after a digit is also named at some +// and most. Sentence punctuation follows the selected level. func (p *Processor) Text(text, level string) string { selected := p.levels[level] var result strings.Builder chars := []rune(text) embeddedEnd := 0 embedded := false + numericEnd, numericDots := 0, 0 for index, char := range chars { + if unicode.IsDigit(char) && index >= numericEnd { + numericEnd, numericDots = index, 0 + for numericEnd < len(chars) { + if unicode.IsDigit(chars[numericEnd]) { + numericEnd++ + } else if chars[numericEnd] == '.' && numericEnd+1 < len(chars) && unicode.IsDigit(chars[numericEnd+1]) { + numericDots++ + numericEnd++ + } else { + break + } + } + } if index >= embeddedEnd { embeddedEnd = index + 1 embedded = false - if (level == "some" || level == "most") && (unicode.IsPunct(char) || unicode.IsSymbol(char)) { + if (level == "some" || level == "most" || level == "all") && (unicode.IsPunct(char) || unicode.IsSymbol(char)) { for embeddedEnd < len(chars) && (unicode.IsPunct(chars[embeddedEnd]) || unicode.IsSymbol(chars[embeddedEnd])) { embeddedEnd++ } @@ -180,7 +196,7 @@ func (p *Processor) Text(text, level string) string { 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) { + (strings.ContainsRune(",:", char) || char == '.' && numericDots <= 1) && unicode.IsDigit(before) && unicode.IsDigit(after) { embedded = false } } @@ -191,7 +207,7 @@ func (p *Processor) Text(text, level string) string { if selected[char] || (embedded || percentage) && known { result.WriteByte(' ') result.WriteString(p.Character(char)) - if strings.ContainsRune("',.;:?!", char) { + if !embedded && strings.ContainsRune("',.;:?!", char) { result.WriteRune(char) } result.WriteByte(' ') diff --git a/internal/punctuation/profile_test.go b/internal/punctuation/profile_test.go index 2fa27dbf..1b7a8784 100644 --- a/internal/punctuation/profile_test.go +++ b/internal/punctuation/profile_test.go @@ -40,7 +40,7 @@ func TestTextLevelsPreservePausesAndDoNotReprocessNames(t *testing.T) { for _, testCase := range []struct{ level, input, want string }{ {"none", "don't 3.14 [X]mute a_b", "don't 3.14 X mute a b"}, {"some", "a/b#c!", "a slash b hash c!"}, - {"most", "a,b!", "a comma, b bang! "}, + {"most", "a,b!", "a comma b bang! "}, {"all", "?", " question?? "}, {"all", "a b", "a space b"}, {"some", "é→Ω", "é→Ω"}, @@ -63,17 +63,17 @@ func TestEmbeddedPunctuation(t *testing.T) { 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"}, + {"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"}, + {"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) @@ -81,9 +81,9 @@ func TestEmbeddedPunctuation(t *testing.T) { } 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"}, + {"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) @@ -93,11 +93,44 @@ func TestEmbeddedPunctuation(t *testing.T) { if err != nil { t.Fatal(err) } - if got := french.Text("example.invalid", "some"); got != "example point. invalid" { + if got := french.Text("example.invalid", "some"); got != "example point invalid" { t.Fatal("embedded punctuation lost profile name", got) } } +func TestDottedNumericSequences(t *testing.T) { + processor, _, err := Load([]string{"../../config/punctuation/default.toml"}) + if err != nil { + t.Fatal(err) + } + for _, trial := range []struct{ input, want string }{ + {"2026.10.31", "2026 dot 10 dot 31"}, + {"(2026.10.31).", "2026 dot 10 dot 31 ."}, + {"v1.2.3 127.0.0.1", "v1 dot 2 dot 3 127 dot 0 dot 0 dot 1"}, + {"2026.10.31", "2026 dot 10 dot 31"}, + {"3.14 2026.10.31 2.5", "3.14 2026 dot 10 dot 31 2.5"}, + {"3.14. 2.5", "3.14. 2.5"}, + {"1,234.56 1.234,56 1,000,000 12:30:45", "1,234.56 1.234,56 1,000,000 12:30:45"}, + } { + got := strings.Join(strings.Fields(processor.Text(trial.input, "some")), " ") + if got != trial.want { + t.Errorf("%q: got %q, want %q", trial.input, got, trial.want) + } + } + for _, trial := range []struct{ level, want string }{ + {"some", "2026 dot 10 dot 31."}, + {"most", "2026 dot 10 dot 31 dot. "}, + {"all", "2026 dot 10 dot 31 dot. "}, + } { + if got := processor.Text("2026.10.31.", trial.level); got != trial.want { + t.Errorf("%s: incorrect embedded or sentence pause: %q", trial.level, got) + } + } + if got := processor.Text("2026.10.31", "none"); got != "2026.10.31" { + t.Fatal("none level changed", got) + } +} + func TestNumericPercentSpeech(t *testing.T) { processor, _, err := Load([]string{"../../config/punctuation/default.toml"}) if err != nil {