diff --git a/docs/user.md b/docs/user.md index b6ebeb61..a92ee12d 100644 --- a/docs/user.md +++ b/docs/user.md @@ -130,8 +130,10 @@ 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 `[#####-----]`. +punctuation. Repeated-symbol summaries handle multiple runs in symbol-only tokens, +including diffstats such as `+++++-----` and bracketed bars such as `[#####-----]`. +Each run must reach the configured minimum length and be selected by the current +punctuation level; shorter runs follow ordinary punctuation handling. Capital indicators, emoticons, repeated-symbol summaries, indentation feedback, and attribute feedback are configurable in Reading. diff --git a/internal/app/punctuation_test.go b/internal/app/punctuation_test.go index 6f0a5dc4..9536b1b3 100644 --- a/internal/app/punctuation_test.go +++ b/internal/app/punctuation_test.go @@ -73,6 +73,29 @@ func TestSpeechKeepsEmbeddedPunctuationAndOriginalClipboardText(t *testing.T) { } } +func TestDiffstatSpeechKeepsOriginalClipboardText(t *testing.T) { + processor, _, err := punctuation.Load([]string{"../../config/punctuation/default.toml"}) + if err != nil { + t.Fatal(err) + } + settings := config.Defaults() + settings.Reading.RepeatedSymbols = true + settings.Reading.RepeatedSymbolsMin = 4 + voice := &fakeVoice{} + echo := &echoSpeaker{Speaker: voice, punctuation: processor, punctuationLevel: "some", settings: &settings} + text := "docs/user.md | 7 +++++--" + echo.Say(text) + if len(voice.messages) != 1 { + t.Fatal("missing diffstat speech", voice.messages) + } + if got := strings.Join(strings.Fields(voice.messages[0]), " "); got != "docs slash user dot md 7 5 plus dash dash" { + t.Fatal("diffstat did not summarize", got) + } + if echo.last != text { + t.Fatal("clipboard echo lost original diffstat", echo.last) + } +} + func TestPackageProgressBarSpeechKeepsOriginalClipboardText(t *testing.T) { processor, _, err := punctuation.Load([]string{"../../config/punctuation/default.toml"}) if err != nil { diff --git a/internal/punctuation/text_processing.go b/internal/punctuation/text_processing.go index d923c70c..03d7301e 100644 --- a/internal/punctuation/text_processing.go +++ b/internal/punctuation/text_processing.go @@ -49,9 +49,9 @@ func (p *Processor) process_token(token, level string, options TextOptions) stri if options.RepeatedSymbols && options.RepeatedSymbolsMin >= 2 && token != "" { repeated := token prefix, suffix := "", "" - // Progress bars commonly enclose a repeated run in brackets. Keep - // those delimiters under ordinary punctuation handling. Inside them, - // summarize each run, including partially filled bars such as [####----]. + // Progress bars commonly enclose repeated runs in brackets. Keep + // those delimiters under ordinary punctuation handling. Symbol-only + // tokens, including diffstats such as +++++-----, summarize each run. if len(token) >= 2 { for _, pair := range []string{"[]", "()", "{}", "<>"} { if token[0] == pair[0] && token[len(token)-1] == pair[1] { @@ -63,7 +63,7 @@ func (p *Processor) process_token(token, level string, options TextOptions) stri } chars := []rune(repeated) for _, char := range chars { - if !(unicode.IsPunct(char) || unicode.IsSymbol(char)) || prefix == "" && char != chars[0] { + if !(unicode.IsPunct(char) || unicode.IsSymbol(char)) { return p.Text(token, level) } } diff --git a/internal/punctuation/text_processing_test.go b/internal/punctuation/text_processing_test.go index 0376028f..d9d5c55f 100644 --- a/internal/punctuation/text_processing_test.go +++ b/internal/punctuation/text_processing_test.go @@ -28,6 +28,11 @@ func TestTextSubstitutionsAndBoundaries(t *testing.T) { {"#### ####", "4 number 4 number"}, {"##### title #####", "5 number title 5 number"}, {"////", "4 slash"}, + {"+++++-----", "5 plus 5 dash"}, + {"+++++--", "5 plus dash dash "}, + {"--+++++", " dash dash 5 plus"}, + {"+++++-----++++", "5 plus 5 dash 4 plus"}, + {"[####)", " 4 number "}, {"[#####]", " 5 number "}, {"(#####)", " 5 number "}, {"{#####}", " 5 number "}, @@ -42,7 +47,7 @@ func TestTextSubstitutionsAndBoundaries(t *testing.T) { t.Errorf("%q: %q, want %q", trial.text, got, trial.want) } } - for _, text := range []string{"hello :)!", "path/to:)file", "https://example.invalid/:)", "https://example.invalid/:-/", "(o_O)", "###", "##--##", "a####b", "../....", "https://example.invalid/####", "10000", "aaaa", "🙂🙂🙂🙂", "", "[]", "[###]", "[####)", "[##--##]", "[a####b]", "[https://example.invalid/####]"} { + for _, text := range []string{"hello :)!", "path/to:)file", "https://example.invalid/:)", "https://example.invalid/:-/", "(o_O)", "###", "##--##", "a####b", "https://example.invalid/####", "10000", "aaaa", "🙂🙂🙂🙂", "", "[]", "[###]", "[##--##]", "[a####b]", "[https://example.invalid/####]"} { for _, level := range Levels() { if got, want := processor.Process_text(text, level, options), processor.Text(text, level); got != want { t.Errorf("%q, %s changed ordinary processing: %q, want %q", text, level, got, want) @@ -100,6 +105,9 @@ func TestSubstitutionOptionsAndProfileNames(t *testing.T) { if got := custom.Process_text("[////####]", "some", options); got != " 4 path/separator 4 number " { t.Fatal("mixed summary reprocessed custom name", got) } + if got := custom.Process_text("////####", "some", options); got != "4 path/separator 4 number" { + t.Fatal("unbracketed summary reprocessed custom name", got) + } french, _, err := Load([]string{"../../config/punctuation/fr.toml"}) if err != nil { t.Fatal(err) @@ -112,7 +120,7 @@ func TestSubstitutionOptionsAndProfileNames(t *testing.T) { } } -func TestBracketedRepeatedSymbolsRespectOptionsAndLevels(t *testing.T) { +func TestRepeatedSymbolsRespectOptionsAndLevels(t *testing.T) { processor, _, err := Load([]string{"../../config/punctuation/default.toml"}) if err != nil { t.Fatal(err) @@ -125,15 +133,23 @@ func TestBracketedRepeatedSymbolsRespectOptionsAndLevels(t *testing.T) { {RepeatedSymbols: true, RepeatedSymbolsMin: 6}, {RepeatedSymbols: true, RepeatedSymbolsMin: 4}, } { - for _, interior := range []string{"#####", "#####-----"} { - text := "[" + interior + "]" + for _, text := range []string{"[#####]", "[#####-----]", "+++++-----", "+++++..", "../...."} { want := processor.Text(text, level) if options.RepeatedSymbols && options.RepeatedSymbolsMin == 4 && level != "none" { - summary := "5 number" - if interior == "#####-----" { - summary += " 5 dash" + switch text { + case "[#####]": + want = processor.Text("[", level) + "5 number" + processor.Text("]", level) + case "[#####-----]": + want = processor.Text("[", level) + "5 number 5 dash" + processor.Text("]", level) + case "+++++-----": + want = "5 plus 5 dash" + case "+++++..": + want = "5 plus " + processor.Text("..", level) + case "../....": + if level == "most" || level == "all" { + want = processor.Text("..", level) + " " + processor.Text("/", level) + " 4 dot" + } } - want = processor.Text("[", level) + summary + processor.Text("]", level) } if got := processor.Process_text(text, level, options); got != want { t.Errorf("%q, level %s, options %+v: got %q, want %q", text, level, options, got, want)