228 lines
7.4 KiB
Go
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)
|
|
}
|