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.
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.
+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.
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.
+18 -10
View File
@@ -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)
}
}
}
+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
// 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) {
+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) {
data, err := os.ReadFile("../../config/punctuation/default.toml")
if err != nil {
+28 -12
View File
@@ -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)
+21 -7
View File
@@ -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)
}
}
}
}