More work on repeated string summarization.

This commit is contained in:
Storm Dragon committed 2026-10-29 14:45:51 -04:00
1 parent 3cf41e662c
commit 47f18fdd28
7 files changed
+122 -34

No files matched your search

+1 -1
View File
@@ -76,7 +76,7 @@ punctuation_level = "some"
# Whole whitespace-delimited emoticons, matching Python's names/boundaries. # Whole whitespace-delimited emoticons, matching Python's names/boundaries.
emoticons = true emoticons = true
# Optional summaries for repeated-symbol tokens, e.g. ##### -> 5 number. # 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. # Only symbols named at the selected punctuation level are summarized.
repeated_symbols = false repeated_symbols = false
# Minimum run length, 2–10000; default 4 means more than three repeats. # Minimum run length, 2–10000; default 4 means more than three repeats.
+4 -1
View File
@@ -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. punctuation inside words, URLs, email addresses, and filenames is also named.
Apostrophes within words and single numeric separators such as those in `3.14`, 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 `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, Capital indicators, emoticons, repeated-symbol summaries,
indentation feedback, and attribute feedback are configurable in Reading. indentation feedback, and attribute feedback are configurable in Reading.
+18 -10
View File
@@ -75,16 +75,24 @@ func TestPackageProgressBarSpeechKeepsOriginalClipboardText(t *testing.T) {
settings.Reading.RepeatedSymbols = true settings.Reading.RepeatedSymbols = true
voice := &fakeVoice{} voice := &fakeVoice{}
echo := &echoSpeaker{Speaker: voice, punctuation: processor, punctuationLevel: "some", settings: &settings} echo := &echoSpeaker{Speaker: voice, punctuation: processor, punctuationLevel: "some", settings: &settings}
text := "(123/189) upgrading nodejs-lts-jod" + strings.Repeat(" ", 157) + "[" + strings.Repeat("#", 120) + "] 100%" for _, trial := range []struct{ bar, want string }{
echo.Say(text) {strings.Repeat("#", 120), "120 number"},
if len(voice.messages) != 1 { {strings.Repeat("#", 110) + strings.Repeat("-", 10), "110 number 10 dash"},
t.Fatal("missing progress speech", voice.messages) {strings.Repeat("#", 90) + strings.Repeat("-", 10), "90 number 10 dash"},
} } {
if got := strings.Join(strings.Fields(voice.messages[0]), " "); got != "123 slash 189 upgrading nodejs dash lts dash jod 120 number 100" { voice.messages = nil
t.Fatal("progress bar did not summarize", got) text := "(123/189) upgrading nodejs-lts-jod" + strings.Repeat(" ", 157) + "[" + trial.bar + "] 100%"
} echo.Say(text)
if echo.last != text { if len(voice.messages) != 1 {
t.Fatal("clipboard echo lost original progress bar", echo.last) 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)
}
} }
} }
+4 -3
View File
@@ -159,8 +159,8 @@ func (p *Processor) Character(char rune) string {
// Text retains Python's levels and pause handling. At some and most, symbols // Text retains Python's levels and pause handling. At some and most, symbols
// embedded between letters or numbers are also named, except apostrophes in // embedded between letters or numbers are also named, except apostrophes in
// contractions and single numeric separators. Sentence punctuation still follows // contractions and single numeric separators. A percent sign after a digit is
// the selected level. // also named at some and most. Sentence punctuation 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
@@ -187,7 +187,8 @@ func (p *Processor) Text(text, level string) string {
} }
} }
_, known := p.symbols[char] _, 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.WriteByte(' ')
result.WriteString(p.Character(char)) result.WriteString(p.Character(char))
if strings.ContainsRune("',.;:?!", char) { if strings.ContainsRune("',.;:?!", char) {
+46
View File
@@ -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) { 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 {
+28 -12
View File
@@ -50,8 +50,8 @@ func (p *Processor) process_token(token, level string, options TextOptions) stri
repeated := token repeated := token
prefix, suffix := "", "" prefix, suffix := "", ""
// Progress bars commonly enclose a repeated run in brackets. Keep // Progress bars commonly enclose a repeated run in brackets. Keep
// those delimiters under ordinary punctuation handling, and require // those delimiters under ordinary punctuation handling. Inside them,
// the entire interior to repeat so paths and mixed tokens stay literal. // summarize each run, including partially filled bars such as [####----].
if len(token) >= 2 { if len(token) >= 2 {
for _, pair := range []string{"[]", "()", "{}", "<>"} { for _, pair := range []string{"[]", "()", "{}", "<>"} {
if token[0] == pair[0] && token[len(token)-1] == pair[1] { 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) chars := []rune(repeated)
if p.levels[level][char] && (unicode.IsPunct(char) || unicode.IsSymbol(char)) { for _, char := range chars {
count := 0 if !(unicode.IsPunct(char) || unicode.IsSymbol(char)) || prefix == "" && char != chars[0] {
for _, next := range repeated { return p.Text(token, level)
if next != char {
return p.Text(token, level)
}
count++
} }
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) name := p.Character(char)
// G13's requested English summary is "5 number". Keep ordinary // G13's requested English summary is "5 number". Keep ordinary
// hash feedback, custom names and localized profile names intact. // 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) 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) return p.Text(token, level)
+21 -7
View File
@@ -32,13 +32,17 @@ func TestTextSubstitutionsAndBoundaries(t *testing.T) {
{"(#####)", " 5 number "}, {"(#####)", " 5 number "},
{"{#####}", " 5 number "}, {"{#####}", " 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?"}, {"Oo", "WTF?"},
} { } {
if got := processor.Process_text(trial.text, "some", options); got != trial.want { if got := processor.Process_text(trial.text, "some", options); got != trial.want {
t.Errorf("%q: %q, want %q", trial.text, 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() { for _, level := range Levels() {
if got, want := processor.Process_text(text, level, options), processor.Text(text, level); got != want { 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) 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" { if got := custom.Process_text("////", "some", options); got != "4 path/separator" {
t.Fatal("custom name changed or processed twice", got) 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"}) french, _, err := Load([]string{"../../config/punctuation/fr.toml"})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -118,12 +125,19 @@ func TestBracketedRepeatedSymbolsRespectOptionsAndLevels(t *testing.T) {
{RepeatedSymbols: true, RepeatedSymbolsMin: 6}, {RepeatedSymbols: true, RepeatedSymbolsMin: 6},
{RepeatedSymbols: true, RepeatedSymbolsMin: 4}, {RepeatedSymbols: true, RepeatedSymbolsMin: 4},
} { } {
want := processor.Text("[#####]", level) for _, interior := range []string{"#####", "#####-----"} {
if options.RepeatedSymbols && options.RepeatedSymbolsMin == 4 && level != "none" { text := "[" + interior + "]"
want = processor.Text("[", level) + "5 number" + processor.Text("]", level) want := processor.Text(text, level)
} if options.RepeatedSymbols && options.RepeatedSymbolsMin == 4 && level != "none" {
if got := processor.Process_text("[#####]", level, options); got != want { summary := "5 number"
t.Errorf("level %s, options %+v: got %q, want %q", level, options, got, want) 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)
}
} }
} }
} }