Files

253 lines
7.8 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package ocr
import (
"bytes"
"context"
"errors"
"fmt"
"image"
"image/png"
"os/exec"
"sort"
"strconv"
"strings"
"time"
)
const (
discoveryTimeout = 2 * time.Second
recognitionTimeout = 15 * time.Second
maxCatalogBytes = 64 << 10
maxImageBytes = 64 << 20
maxStderrBytes = 16 << 10
)
type Layout int
const (
AutomaticPage Layout = 3
SingleBlock Layout = 6
SparseText Layout = 11
)
// Options is a per-job snapshot. Empty language chooses eng when installed,
// otherwise the first installed recognition language in sorted order. An
// explicit language expression is never replaced with a fallback.
type Options struct {
Language string
Layout Layout // Zero uses AutomaticPage; other unsupported values fail.
Scale int // Zero uses original size; 1 through 4 are explicit factors.
Grayscale bool
Invert bool
BlackWhite bool
Threshold int
}
// Catalog contains recognition languages, excluding orientation-only osd data.
// Discovery is on demand, not part of reader startup. Each request rescans so
// installing/removing data does not require restarting the reader.
type Catalog struct {
Languages []string
DefaultLanguage string
}
// Engine serializes its subprocesses. Call its blocking methods from a worker,
// never a native callback, GTK thread or the reader's event/input loop. A
// canceled call kills and reaps its process before releasing ownership.
type Engine struct {
gate chan struct{}
command func(context.Context, ...string) *exec.Cmd
}
// NewEngine performs no dependency lookup, native initialization or I/O.
func NewEngine() *Engine {
return &Engine{gate: make(chan struct{}, 1), command: func(ctx context.Context, args ...string) *exec.Cmd {
return exec.CommandContext(ctx, "tesseract", args...)
}}
}
func (engine *Engine) acquire(ctx context.Context) error {
select {
case engine.gate <- struct{}{}:
if err := ctx.Err(); err != nil {
<-engine.gate
return err
}
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (engine *Engine) Discover(ctx context.Context) (Catalog, error) {
ctx, cancel := context.WithTimeout(ctx, discoveryTimeout)
defer cancel()
if err := engine.acquire(ctx); err != nil {
return Catalog{}, err
}
defer func() { <-engine.gate }()
return engine.discover(ctx)
}
func (engine *Engine) discover(ctx context.Context) (Catalog, error) {
ctx, cancel := context.WithTimeout(ctx, discoveryTimeout)
defer cancel()
output, _, err := engine.run(ctx, nil, maxCatalogBytes, "--list-langs")
if err != nil {
if errors.Is(err, ErrRecognition) {
return Catalog{}, fmt.Errorf("%w: language discovery failed", ErrUnavailable)
}
return Catalog{}, err
}
return parse_catalog(string(output))
}
func parse_catalog(output string) (Catalog, error) {
lines := strings.Split(strings.TrimSpace(output), "\n")
header := strings.TrimSuffix(lines[0], "\r")
countStart := strings.LastIndex(header, "(")
if !strings.HasPrefix(header, "List of available languages in ") || !strings.HasSuffix(header, "):") || countStart < 0 {
return Catalog{}, fmt.Errorf("%w: invalid language catalog", ErrUnavailable)
}
count, err := strconv.Atoi(header[countStart+1 : len(header)-2])
if err != nil || count < 0 || count > 512 || count != len(lines)-1 {
return Catalog{}, fmt.Errorf("%w: invalid language catalog", ErrUnavailable)
}
var catalog Catalog
seen := make(map[string]bool)
for _, line := range lines[1:] {
language := strings.TrimSuffix(line, "\r")
if !valid_language(language) || seen[language] {
return Catalog{}, fmt.Errorf("%w: invalid language catalog", ErrUnavailable)
}
seen[language] = true
if language != "osd" {
catalog.Languages = append(catalog.Languages, language)
}
}
if len(catalog.Languages) == 0 {
return Catalog{}, fmt.Errorf("%w: no recognition language data installed", ErrUnavailable)
}
sort.Strings(catalog.Languages)
catalog.DefaultLanguage = catalog.Languages[0]
if seen["eng"] {
catalog.DefaultLanguage = "eng"
}
return catalog, nil
}
// valid_language permits upstream script/Latin-style identifiers, without
// accepting path traversal, options, whitespace or expression delimiters.
func valid_language(language string) bool {
if language == "" || len(language) > 128 {
return false
}
parts := strings.Split(language, "/")
if len(parts) > 2 || (len(parts) == 2 && parts[0] != "script") {
return false
}
for _, part := range parts {
if part == "" || part[0] == '-' {
return false
}
for _, char := range part {
if !(char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9' || char == '_' || char == '-') {
return false
}
}
}
return true
}
func (catalog Catalog) resolve_language(expression string) (string, error) {
if expression == "" {
return catalog.DefaultLanguage, nil
}
if len(expression) > 1024 {
return "", fmt.Errorf("%w: invalid language selection", ErrUnavailable)
}
seen := make(map[string]bool)
for _, language := range strings.Split(expression, "+") {
if !valid_language(language) || language == "osd" || seen[language] {
return "", fmt.Errorf("%w: invalid language selection", ErrUnavailable)
}
seen[language] = true
index := sort.SearchStrings(catalog.Languages, language)
if index == len(catalog.Languages) || catalog.Languages[index] != language {
return "", fmt.Errorf("%w: selected language data is not installed", ErrUnavailable)
}
}
return expression, nil
}
// Recognize encodes one owned image to PNG in memory and obtains text and
// coordinates from one TSV recognition invocation. The caller must not mutate
// pixels until this method returns. No images or text are logged or persisted.
func (engine *Engine) Recognize(ctx context.Context, pixels image.Image, options Options) (Result, error) {
ctx, cancel := context.WithTimeout(ctx, recognitionTimeout)
defer cancel()
if pixels == nil || !valid_dimensions(pixels.Bounds().Dx(), pixels.Bounds().Dy()) {
return Result{}, ErrInvalidImage
}
if options.Layout == 0 {
options.Layout = AutomaticPage
}
if options.Layout != AutomaticPage && options.Layout != SingleBlock && options.Layout != SparseText {
return Result{}, fmt.Errorf("%w: unsupported text layout", ErrRecognition)
}
if err := engine.acquire(ctx); err != nil {
return Result{}, err
}
defer func() { <-engine.gate }()
var err error
pixels, err = Preprocess(ctx, pixels, options)
if err != nil {
return Result{}, err
}
catalog, err := engine.discover(ctx)
if err != nil {
return Result{}, err
}
language, err := catalog.resolve_language(options.Language)
if err != nil {
return Result{}, err
}
encoded := &bounded_buffer{limit: maxImageBytes, ctx: ctx}
if err := png.Encode(encoded, pixels); err != nil {
if ctx.Err() != nil {
return Result{}, ctx.Err()
}
if errors.Is(err, ErrLimit) {
return Result{}, ErrLimit
}
return Result{}, ErrInvalidImage
}
output, diagnostics, err := engine.run(ctx, encoded.Bytes(), maxTSVBytes,
"stdin", "stdout", "-l", language, "--psm", strconv.Itoa(int(options.Layout)), "tsv")
// Tesseract may exit successfully after loading only part of a multi-language
// expression. Never silently accept that degraded selection.
if strings.Contains(string(diagnostics), "Error opening data file") ||
strings.Contains(string(diagnostics), "Failed loading language") ||
strings.Contains(string(diagnostics), "couldn't load any languages") {
if ctx.Err() != nil {
return Result{}, ctx.Err()
}
return Result{}, fmt.Errorf("%w: selected language data could not be loaded", ErrUnavailable)
}
if err != nil {
return Result{}, err
}
if err := ctx.Err(); err != nil {
return Result{}, err
}
result, err := ParseTSV(bytes.NewReader(output), pixels.Bounds().Dx(), pixels.Bounds().Dy())
if ctx.Err() != nil {
return Result{}, ctx.Err()
}
return result, err
}