// Fenrir console screen reader. // Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later // Package punctuation prepares speech independently of the speech driver. package punctuation import ( "bytes" "errors" "fmt" "io" "os" "path/filepath" "strings" "unicode" "unicode/utf8" "git.stormux.org/storm/fenrir/internal/i18n" "git.stormux.org/storm/fenrir/internal/presentation" "github.com/pelletier/go-toml/v2" ) type Profile struct { Version int `toml:"version"` Translate bool `toml:"translate"` Levels map[string]string `toml:"levels"` Symbols map[string]string `toml:"symbols"` } // Processor owns validated, immutable profile data. Replacements are emitted // once; punctuation inside a name is never processed again. type Processor struct { levels map[string]map[rune]bool symbols map[rune]string translate bool } func Levels() []string { return []string{"none", "some", "most", "all"} } func Validate_name(name string) error { if strings.TrimSpace(name) == "" || strings.ContainsAny(name, "\x00\r\n") { return fmt.Errorf("punctuation profile cannot be empty or contain control characters") } if extension := filepath.Ext(name); extension != "" && extension != ".toml" || strings.ContainsRune(name, filepath.Separator) && extension != ".toml" { return fmt.Errorf("punctuation profile must be a name or a .toml path") } if stem := strings.TrimSuffix(name, ".toml"); stem == "" || stem == "." || stem == ".." { return fmt.Errorf("invalid punctuation profile name") } return nil } func Paths(name, home string, uid int, bundledDirectory string) ([]string, error) { if err := Validate_name(name); err != nil { return nil, err } if strings.ContainsRune(name, filepath.Separator) { return []string{name}, nil } name = strings.TrimSuffix(name, ".toml") + ".toml" var paths []string if uid != 0 { paths = append(paths, filepath.Join(home, ".local/share/stormux/fenrirscreenreader/punctuation", name)) } paths = append(paths, filepath.Join("/etc/fenrirscreenreader/punctuation", name)) if bundledDirectory != "" { paths = append(paths, filepath.Join(bundledDirectory, name)) } return paths, nil } func Open(name string) (*Processor, string, error) { home, err := os.UserHomeDir() if err != nil { return nil, "", err } executable, err := os.Executable() if err != nil { return nil, "", err } paths, err := Paths(name, home, os.Geteuid(), filepath.Join(filepath.Dir(executable), "config", "punctuation")) if err != nil { return nil, "", err } return Load(paths) } func Load(paths []string) (*Processor, string, error) { for _, path := range paths { file, err := os.Open(path) if errors.Is(err, os.ErrNotExist) { continue } if err != nil { return nil, path, err } data, readErr := io.ReadAll(io.LimitReader(file, (1<<20)+1)) if err := errors.Join(readErr, file.Close()); err != nil { return nil, path, err } if len(data) > 1<<20 { return nil, path, fmt.Errorf("punctuation profile exceeds 1 MiB") } processor, err := Decode(data) return processor, path, err } return nil, "", fmt.Errorf("punctuation profile not found (searched %s)", strings.Join(paths, ", ")) } func Decode(data []byte) (*Processor, error) { var profile Profile if err := toml.NewDecoder(bytes.NewReader(data)).DisallowUnknownFields().Decode(&profile); err != nil { return nil, err } if profile.Version != 1 { return nil, fmt.Errorf("unsupported punctuation profile version %d", profile.Version) } processor := &Processor{levels: make(map[string]map[rune]bool), symbols: make(map[rune]string), translate: profile.Translate} for symbol, name := range profile.Symbols { if !utf8.ValidString(symbol) || utf8.RuneCountInString(symbol) != 1 || strings.TrimSpace(name) == "" || strings.ContainsAny(name, "\x00\r\n") { return nil, fmt.Errorf("invalid punctuation name for %q", symbol) } char, _ := utf8.DecodeRuneInString(symbol) processor.symbols[char] = name } for _, level := range Levels() { symbols, exists := profile.Levels[level] if !exists || level == "none" && symbols != "" { return nil, fmt.Errorf("invalid or missing punctuation level %q", level) } selected := make(map[rune]bool) for _, char := range symbols { if _, exists := processor.symbols[char]; !exists { return nil, fmt.Errorf("punctuation level %q has no name for %q", level, char) } selected[char] = true } processor.levels[level] = selected } if len(profile.Levels) != len(Levels()) { return nil, fmt.Errorf("unknown punctuation level") } if len(processor.symbols) == 0 { return nil, fmt.Errorf("punctuation profile has no symbols") } return processor, nil } func (p *Processor) Character(char rune) string { if name, exists := p.symbols[char]; exists { if p.translate { return i18n.T(name) } return name } return presentation.Character(char) } // 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 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" || level == "all") && (unicode.IsPunct(char) || unicode.IsSymbol(char)) { for embeddedEnd < len(chars) && (unicode.IsPunct(chars[embeddedEnd]) || unicode.IsSymbol(chars[embeddedEnd])) { embeddedEnd++ } embedded = index > 0 && embeddedEnd < len(chars) && is_word_rune(chars[index-1]) && is_word_rune(chars[embeddedEnd]) if embedded && embeddedEnd == index+1 { before, after := chars[index-1], chars[embeddedEnd] if char == '\'' && unicode.IsLetter(before) && unicode.IsLetter(after) || (strings.ContainsRune(",:", char) || char == '.' && numericDots <= 1) && unicode.IsDigit(before) && unicode.IsDigit(after) { embedded = false } } } } _, known := p.symbols[char] 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 !embedded && strings.ContainsRune("',.;:?!", char) { result.WriteRune(char) } result.WriteByte(' ') } else if strings.ContainsRune("'. ,;:?!-", char) { result.WriteRune(char) } else if _, known := p.symbols[char]; known || strings.ContainsRune("!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~ยง", char) { result.WriteByte(' ') } else { result.WriteRune(char) } } return result.String() } func is_word_rune(char rune) bool { return unicode.IsLetter(char) || unicode.IsNumber(char) || unicode.IsMark(char) }