From 47f18fdd281f9ab31241fb5efacf7b0fe56f413e Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Thu, 29 Oct 2026 14:45:51 -0400 Subject: [PATCH] More work on repeated string summarization. --- config/settings/settings.toml | 2 +- docs/user.md | 5 ++- internal/app/punctuation_test.go | 28 +++++++----- internal/punctuation/profile.go | 7 +-- internal/punctuation/profile_test.go | 46 ++++++++++++++++++++ internal/punctuation/text_processing.go | 40 ++++++++++++----- internal/punctuation/text_processing_test.go | 28 +++++++++--- 7 files changed, 122 insertions(+), 34 deletions(-) diff --git a/config/settings/settings.toml b/config/settings/settings.toml index 0687036d..0e23f3cb 100644 --- a/config/settings/settings.toml +++ b/config/settings/settings.toml @@ -76,7 +76,7 @@ punctuation_level = "some" # Whole whitespace-delimited emoticons, matching Python's names/boundaries. emoticons = true # Optional summaries for repeated-symbol tokens, e.g. ##### -> 5 number. -# Matching brackets around a run are allowed, e.g. [#####]. +# Matching brackets may enclose multiple runs, e.g. [#####-----]. # Only symbols named at the selected punctuation level are summarized. repeated_symbols = false # Minimum run length, 2–10000; default 4 means more than three repeats. diff --git a/docs/user.md b/docs/user.md index 90087fc9..dbab6820 100644 --- a/docs/user.md +++ b/docs/user.md @@ -119,7 +119,10 @@ 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. +selected level too. 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 `[#####-----]`. 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 0846c3d2..c29be076 100644 --- a/internal/app/punctuation_test.go +++ b/internal/app/punctuation_test.go @@ -75,16 +75,24 @@ func TestPackageProgressBarSpeechKeepsOriginalClipboardText(t *testing.T) { settings.Reading.RepeatedSymbols = true voice := &fakeVoice{} echo := &echoSpeaker{Speaker: voice, punctuation: processor, punctuationLevel: "some", settings: &settings} - text := "(123/189) upgrading nodejs-lts-jod" + strings.Repeat(" ", 157) + "[" + strings.Repeat("#", 120) + "] 100%" - echo.Say(text) - if len(voice.messages) != 1 { - t.Fatal("missing progress speech", voice.messages) - } - if got := strings.Join(strings.Fields(voice.messages[0]), " "); got != "123 slash 189 upgrading nodejs dash lts dash jod 120 number 100" { - t.Fatal("progress bar did not summarize", got) - } - if echo.last != text { - t.Fatal("clipboard echo lost original progress bar", echo.last) + for _, trial := range []struct{ bar, want string }{ + {strings.Repeat("#", 120), "120 number"}, + {strings.Repeat("#", 110) + strings.Repeat("-", 10), "110 number 10 dash"}, + {strings.Repeat("#", 90) + strings.Repeat("-", 10), "90 number 10 dash"}, + } { + voice.messages = nil + text := "(123/189) upgrading nodejs-lts-jod" + strings.Repeat(" ", 157) + "[" + trial.bar + "] 100%" + echo.Say(text) + if len(voice.messages) != 1 { + t.Fatal("missing progress speech", voice.messages) + } + want := "123 slash 189 upgrading nodejs dash lts dash jod " + trial.want + " 100 percent" + if got := strings.Join(strings.Fields(voice.messages[0]), " "); got != want { + t.Fatal("progress bar did not summarize", got) + } + if echo.last != text { + t.Fatal("clipboard echo lost original progress bar", echo.last) + } } } diff --git a/internal/punctuation/profile.go b/internal/punctuation/profile.go index 3eb521d7..e94044c5 100644 --- a/internal/punctuation/profile.go +++ b/internal/punctuation/profile.go @@ -159,8 +159,8 @@ 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. Sentence punctuation still follows -// the selected level. +// contractions and single numeric separators. 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 @@ -187,7 +187,8 @@ func (p *Processor) Text(text, level string) string { } } _, known := p.symbols[char] - if selected[char] || embedded && known { + percentage := (level == "some" || level == "most") && char == '%' && index > 0 && unicode.IsDigit(chars[index-1]) + if selected[char] || (embedded || percentage) && known { result.WriteByte(' ') result.WriteString(p.Character(char)) if strings.ContainsRune("',.;:?!", char) { diff --git a/internal/punctuation/profile_test.go b/internal/punctuation/profile_test.go index c220fbd5..2fa27dbf 100644 --- a/internal/punctuation/profile_test.go +++ b/internal/punctuation/profile_test.go @@ -98,6 +98,52 @@ func TestEmbeddedPunctuation(t *testing.T) { } } +func TestNumericPercentSpeech(t *testing.T) { + processor, _, err := Load([]string{"../../config/punctuation/default.toml"}) + if err != nil { + t.Fatal(err) + } + for _, trial := range []struct{ text, want string }{ + {"100%", "100 percent"}, + {"0%", "0 percent"}, + {"12.5%", "12.5 percent"}, + {"-5%", "dash 5 percent"}, + {"(100%)", "100 percent"}, + {"100%, done", "100 percent , done"}, + {"100%", "100 percent"}, + {"%", ""}, + {"text%", "text"}, + {"100 %", "100"}, + {"don't 3.14 1,000 12:30", "don't 3.14 1,000 12:30"}, + } { + for _, options := range []TextOptions{{}, {Emoticons: true, RepeatedSymbols: true, RepeatedSymbolsMin: 4}} { + if got := strings.Join(strings.Fields(processor.Process_text(trial.text, "some", options)), " "); got != trial.want { + t.Errorf("%q, options %+v: got %q, want %q", trial.text, options, got, trial.want) + } + } + } + for _, level := range Levels() { + want := "100 percent" + if level == "none" { + want = "100" + } + if got := strings.Join(strings.Fields(processor.Text("100%", level)), " "); got != want { + t.Errorf("level %s: got %q, want %q", level, got, want) + } + } + data, err := os.ReadFile("../../config/punctuation/default.toml") + if err != nil { + t.Fatal(err) + } + custom, err := Decode([]byte(strings.Replace(string(data), `"%" = "percent"`, `"%" = "per/cent"`, 1))) + if err != nil { + t.Fatal(err) + } + if got := custom.Text("100%", "some"); got != "100 per/cent " { + t.Fatal("percent suffix lost or reprocessed profile name", got) + } +} + func TestCustomNamesAndInvalidProfiles(t *testing.T) { data, err := os.ReadFile("../../config/punctuation/default.toml") if err != nil { diff --git a/internal/punctuation/text_processing.go b/internal/punctuation/text_processing.go index 27354e76..d923c70c 100644 --- a/internal/punctuation/text_processing.go +++ b/internal/punctuation/text_processing.go @@ -50,8 +50,8 @@ func (p *Processor) process_token(token, level string, options TextOptions) stri repeated := token prefix, suffix := "", "" // Progress bars commonly enclose a repeated run in brackets. Keep - // those delimiters under ordinary punctuation handling, and require - // the entire interior to repeat so paths and mixed tokens stay literal. + // those delimiters under ordinary punctuation handling. Inside them, + // summarize each run, including partially filled bars such as [####----]. if len(token) >= 2 { for _, pair := range []string{"[]", "()", "{}", "<>"} { if token[0] == pair[0] && token[len(token)-1] == pair[1] { @@ -61,16 +61,25 @@ func (p *Processor) process_token(token, level string, options TextOptions) stri } } } - char, _ := utf8.DecodeRuneInString(repeated) - if p.levels[level][char] && (unicode.IsPunct(char) || unicode.IsSymbol(char)) { - count := 0 - for _, next := range repeated { - if next != char { - return p.Text(token, level) - } - count++ + chars := []rune(repeated) + for _, char := range chars { + if !(unicode.IsPunct(char) || unicode.IsSymbol(char)) || prefix == "" && char != chars[0] { + return p.Text(token, level) } - if count >= options.RepeatedSymbolsMin { + } + var result strings.Builder + summarized := false + for start := 0; start < len(chars); { + char := chars[start] + end := start + 1 + for end < len(chars) && chars[end] == char { + end++ + } + count := end - start + if start > 0 { + result.WriteByte(' ') + } + if p.levels[level][char] && count >= options.RepeatedSymbolsMin { name := p.Character(char) // G13's requested English summary is "5 number". Keep ordinary // hash feedback, custom names and localized profile names intact. @@ -80,8 +89,15 @@ func (p *Processor) process_token(token, level string, options TextOptions) stri name = i18n.T(name) } } - return p.Text(prefix, level) + i18n.Format("{0} {1}", strconv.Itoa(count), name) + p.Text(suffix, level) + result.WriteString(i18n.Format("{0} {1}", strconv.Itoa(count), name)) + summarized = true + } else { + result.WriteString(p.Text(string(chars[start:end]), level)) } + start = end + } + if summarized { + return p.Text(prefix, level) + result.String() + p.Text(suffix, level) } } return p.Text(token, level) diff --git a/internal/punctuation/text_processing_test.go b/internal/punctuation/text_processing_test.go index 5ffa955f..0376028f 100644 --- a/internal/punctuation/text_processing_test.go +++ b/internal/punctuation/text_processing_test.go @@ -32,13 +32,17 @@ func TestTextSubstitutionsAndBoundaries(t *testing.T) { {"(#####)", " 5 number "}, {"{#####}", " 5 number "}, {"<#####>", " 5 number "}, + {"[#####-----]", " 5 number 5 dash "}, + {"[-----#####]", " 5 dash 5 number "}, + {"[#####----#####]", " 5 number 4 dash 5 number "}, + {"[#####--]", " 5 number dash dash "}, {"Oo", "WTF?"}, } { if got := processor.Process_text(trial.text, "some", options); got != trial.want { 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) @@ -93,6 +97,9 @@ func TestSubstitutionOptionsAndProfileNames(t *testing.T) { if got := custom.Process_text("////", "some", options); got != "4 path/separator" { t.Fatal("custom name changed or processed twice", got) } + if got := custom.Process_text("[////####]", "some", options); got != " 4 path/separator 4 number " { + t.Fatal("mixed summary reprocessed custom name", got) + } french, _, err := Load([]string{"../../config/punctuation/fr.toml"}) if err != nil { t.Fatal(err) @@ -118,12 +125,19 @@ func TestBracketedRepeatedSymbolsRespectOptionsAndLevels(t *testing.T) { {RepeatedSymbols: true, RepeatedSymbolsMin: 6}, {RepeatedSymbols: true, RepeatedSymbolsMin: 4}, } { - want := processor.Text("[#####]", level) - if options.RepeatedSymbols && options.RepeatedSymbolsMin == 4 && level != "none" { - want = processor.Text("[", level) + "5 number" + processor.Text("]", level) - } - if got := processor.Process_text("[#####]", level, options); got != want { - t.Errorf("level %s, options %+v: got %q, want %q", level, options, got, want) + for _, interior := range []string{"#####", "#####-----"} { + text := "[" + interior + "]" + want := processor.Text(text, level) + if options.RepeatedSymbols && options.RepeatedSymbolsMin == 4 && level != "none" { + summary := "5 number" + if interior == "#####-----" { + summary += " 5 dash" + } + 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) + } } } }