Files

228 lines
7.4 KiB
Go

// 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)
}