After commit c018f5fd29 Fenrir is being switched over to go. If you do not want to be part of the testing process, please switch to master.

This commit is contained in:
Storm Dragon committed 2026-10-27 20:30:55 -04:00
1 parent c018f5fd29
commit 0e50de4c76
1047 files changed
+58364 -63747

No files matched your search

+182
View File
@@ -0,0 +1,182 @@
// 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/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 follows Python's levels and pause handling, retaining unspoken sentence
// punctuation and replacing other unspoken symbols with spaces. Numbers and
// contractions therefore retain their original punctuation at lower levels.
func (p *Processor) Text(text, level string) string {
selected := p.levels[level]
var result strings.Builder
for _, char := range text {
if selected[char] {
result.WriteByte(' ')
result.WriteString(p.Character(char))
if 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()
}