432 lines
14 KiB
Go
432 lines
14 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package ocr
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"image"
|
|
"image/png"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestCatalogAndOrderedLanguages(t *testing.T) {
|
|
catalog, err := parse_catalog("List of available languages in \"/data\" (4):\nfra\nosd\nscript/Latin\neng\n")
|
|
if err != nil || catalog.DefaultLanguage != "eng" ||
|
|
!reflect.DeepEqual(catalog.Languages, []string{"eng", "fra", "script/Latin"}) {
|
|
t.Fatalf("catalog: %+v, %v", catalog, err)
|
|
}
|
|
for _, expression := range []string{"", "fra+eng", "script/Latin"} {
|
|
got, err := catalog.resolve_language(expression)
|
|
want := expression
|
|
if want == "" {
|
|
want = "eng"
|
|
}
|
|
if err != nil || got != want {
|
|
t.Fatalf("language expression %q: %q, %v", expression, got, err)
|
|
}
|
|
}
|
|
for _, expression := range []string{"osd", "deu", "fra+missing", "eng+eng", "eng+", "+eng", "../eng", "-l eng", "eng;command", "fra\neng"} {
|
|
if _, err := catalog.resolve_language(expression); !errors.Is(err, ErrUnavailable) {
|
|
t.Fatalf("invalid/uninstalled expression %q accepted: %v", expression, err)
|
|
}
|
|
}
|
|
other, err := parse_catalog("List of available languages in \"/data\" (2):\nfra\ndeu\n")
|
|
if err != nil || other.DefaultLanguage != "deu" {
|
|
t.Fatalf("default without English: %+v, %v", other, err)
|
|
}
|
|
}
|
|
|
|
func TestMissingAndMalformedCatalogs(t *testing.T) {
|
|
for _, output := range []string{
|
|
"", "Private fixture text",
|
|
"List of available languages in \"/data\" (0):\n",
|
|
"List of available languages in \"/data\" (1):\nosd\n",
|
|
"List of available languages in \"/data\" (2):\neng\neng\n",
|
|
"List of available languages in \"/data\" (1):\neng\nfra\n",
|
|
"List of available languages in \"/data\" (1):\n../eng\n",
|
|
} {
|
|
_, err := parse_catalog(output)
|
|
if !errors.Is(err, ErrUnavailable) || !strings.HasPrefix(err.Error(), "OCR not available") || strings.Contains(err.Error(), "Private") {
|
|
t.Fatalf("unavailable message: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func test_engine(t *testing.T, mode string) *Engine {
|
|
t.Helper()
|
|
executable, err := os.Executable()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
engine := NewEngine()
|
|
engine.command = func(ctx context.Context, args ...string) *exec.Cmd {
|
|
command := exec.CommandContext(ctx, executable, append([]string{"-test.run=^TestOCRChildProcess$", "--"}, args...)...)
|
|
command.Env = append(os.Environ(), "CTHULHU_OCR_TEST_PROCESS=1", "CTHULHU_OCR_TEST_MODE="+mode)
|
|
return command
|
|
}
|
|
return engine
|
|
}
|
|
|
|
func TestEngineRecognizesOneTSVInvocation(t *testing.T) {
|
|
engine := test_engine(t, "success")
|
|
original := engine.command
|
|
var invocations [][]string
|
|
engine.command = func(ctx context.Context, args ...string) *exec.Cmd {
|
|
invocations = append(invocations, append([]string(nil), args...))
|
|
return original(ctx, args...)
|
|
}
|
|
// A nonzero source origin is encoded as a normalized recognition image.
|
|
pixels := image.NewRGBA(image.Rect(-5, 10, 95, 110))
|
|
result, err := engine.Recognize(context.Background(), pixels, Options{Language: "fra+eng", Layout: SingleBlock})
|
|
if err != nil || result.Text != "é 中文" || result.Width != 100 || result.Height != 100 {
|
|
t.Fatalf("recognition: %+v, %v", result, err)
|
|
}
|
|
want := [][]string{{"--list-langs"}, {"stdin", "stdout", "-l", "fra+eng", "--psm", "6", "tsv"}}
|
|
if !reflect.DeepEqual(invocations, want) {
|
|
t.Fatalf("invocations: %v, want %v", invocations, want)
|
|
}
|
|
if result.Words[1].Span != (Span{2, 4}) {
|
|
t.Fatal("process result lost rune offsets")
|
|
}
|
|
if _, err := engine.Discover(context.Background()); err != nil {
|
|
t.Fatalf("engine did not release process ownership: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestEngineMissingExecutableAndUnavailableLanguage(t *testing.T) {
|
|
engine := NewEngine() // Constructing it never checks or loads OCR dependencies.
|
|
engine.command = func(ctx context.Context, args ...string) *exec.Cmd {
|
|
return exec.CommandContext(ctx, "/nonexistent-ocr-test-engine", args...)
|
|
}
|
|
if _, err := engine.Discover(context.Background()); !errors.Is(err, ErrUnavailable) {
|
|
t.Fatalf("missing executable: %v", err)
|
|
}
|
|
if _, err := engine.Recognize(context.Background(), image.NewGray(image.Rect(0, 0, 100, 100)), Options{}); !errors.Is(err, ErrUnavailable) {
|
|
t.Fatalf("missing executable recognition: %v", err)
|
|
}
|
|
engine = test_engine(t, "success")
|
|
original := engine.command
|
|
var invocations int
|
|
engine.command = func(ctx context.Context, args ...string) *exec.Cmd {
|
|
invocations++
|
|
return original(ctx, args...)
|
|
}
|
|
if _, err := engine.Recognize(context.Background(), image.NewGray(image.Rect(0, 0, 100, 100)), Options{Language: "missing"}); !errors.Is(err, ErrUnavailable) || invocations != 1 {
|
|
t.Fatalf("missing language was substituted or invoked: %v, %d", err, invocations)
|
|
}
|
|
}
|
|
|
|
func TestDefaultEngineWithoutRuntimeDependencies(t *testing.T) {
|
|
// Use the real constructor and default executable lookup, with no helper
|
|
// injection. Even initialization and an attempted job need no installed OCR.
|
|
t.Setenv("PATH", t.TempDir())
|
|
engine := NewEngine()
|
|
if _, err := engine.Discover(context.Background()); !errors.Is(err, ErrUnavailable) || err.Error() != "OCR not available" {
|
|
t.Fatalf("missing default backend: %v", err)
|
|
}
|
|
if _, err := engine.Recognize(context.Background(), image.NewGray(image.Rect(0, 0, 100, 100)), Options{}); !errors.Is(err, ErrUnavailable) {
|
|
t.Fatalf("recognition without dependencies: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestEngineFailuresAndOutputBounds(t *testing.T) {
|
|
for _, test := range []struct {
|
|
mode string
|
|
want error
|
|
}{
|
|
{"failure", ErrRecognition},
|
|
{"invalid", ErrInvalidOutput},
|
|
{"partial-language", ErrUnavailable},
|
|
{"missing-data", ErrUnavailable},
|
|
{"no-languages", ErrUnavailable},
|
|
{"stdout-limit", ErrLimit},
|
|
{"stderr-limit", ErrLimit},
|
|
{"catalog-limit", ErrLimit},
|
|
} {
|
|
t.Run(test.mode, func(t *testing.T) {
|
|
engine := test_engine(t, test.mode)
|
|
result, err := engine.Recognize(context.Background(), image.NewGray(image.Rect(0, 0, 100, 100)), Options{})
|
|
if !errors.Is(err, test.want) || result.Text != "" || len(result.Words) != 0 {
|
|
t.Fatalf("result: %+v, error: %v; want %v", result, err, test.want)
|
|
}
|
|
if strings.Contains(err.Error(), "Private") {
|
|
t.Fatal("native diagnostics leaked into error")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEngineRejectsInvalidImageAndLayoutBeforeStartingChild(t *testing.T) {
|
|
engine := NewEngine()
|
|
engine.command = func(context.Context, ...string) *exec.Cmd {
|
|
t.Fatal("child invoked for invalid request")
|
|
return nil
|
|
}
|
|
for _, pixels := range []image.Image{nil, image.NewGray(image.Rectangle{}), oversized_image{}} {
|
|
if _, err := engine.Recognize(context.Background(), pixels, Options{}); !errors.Is(err, ErrInvalidImage) {
|
|
t.Fatalf("invalid image: %v", err)
|
|
}
|
|
}
|
|
if _, err := engine.Recognize(context.Background(), image.NewGray(image.Rect(0, 0, 100, 100)), Options{Layout: 4}); !errors.Is(err, ErrRecognition) {
|
|
t.Fatalf("invalid layout: %v", err)
|
|
}
|
|
}
|
|
|
|
type oversized_image struct{ image.Image }
|
|
|
|
func (oversized_image) Bounds() image.Rectangle { return image.Rect(0, 0, MaxPixels, 2) }
|
|
|
|
func TestEngineCancellationReapsChildAndUnblocksWaitingRequest(t *testing.T) {
|
|
t.Run("cancel", func(t *testing.T) { check_child_cancellation(t, false) })
|
|
t.Run("deadline", func(t *testing.T) { check_child_cancellation(t, true) })
|
|
}
|
|
|
|
func check_child_cancellation(t *testing.T, deadline bool) {
|
|
t.Helper()
|
|
engine := test_engine(t, "wait")
|
|
original := engine.command
|
|
readyRead, readyWrite, err := os.Pipe()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer readyRead.Close()
|
|
defer readyWrite.Close()
|
|
children := make(chan *exec.Cmd, 2)
|
|
engine.command = func(ctx context.Context, args ...string) *exec.Cmd {
|
|
command := original(ctx, args...)
|
|
if args[0] == "stdin" {
|
|
command.ExtraFiles = []*os.File{readyWrite}
|
|
children <- command
|
|
}
|
|
return command
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
if deadline {
|
|
cancel()
|
|
ctx, cancel = context.WithTimeout(context.Background(), 2*time.Second)
|
|
}
|
|
defer cancel()
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
_, err := engine.Recognize(ctx, image.NewGray(image.Rect(0, 0, 100, 100)), Options{})
|
|
done <- err
|
|
}()
|
|
if err := readyRead.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := io.ReadFull(readyRead, make([]byte, 1)); err != nil {
|
|
t.Fatalf("child not ready: %v", err)
|
|
}
|
|
// A second call must not overlap recognition; it can cancel while waiting.
|
|
waitCtx, waitCancel := context.WithTimeout(context.Background(), 40*time.Millisecond)
|
|
defer waitCancel()
|
|
if _, err := engine.Discover(waitCtx); !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Fatalf("waiting call failed to cancel: %v", err)
|
|
}
|
|
want := error(context.Canceled)
|
|
if deadline {
|
|
want = context.DeadlineExceeded
|
|
} else {
|
|
cancel()
|
|
}
|
|
select {
|
|
case err := <-done:
|
|
if !errors.Is(err, want) {
|
|
t.Fatalf("cancel: %v", err)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("canceled recognition did not join")
|
|
}
|
|
child := <-children
|
|
if child.ProcessState == nil {
|
|
t.Fatal("child Wait was not joined")
|
|
}
|
|
if _, err := syscall.Wait4(child.Process.Pid, nil, syscall.WNOHANG, nil); !errors.Is(err, syscall.ECHILD) {
|
|
t.Fatalf("child was not reaped: %v", err)
|
|
}
|
|
if _, err := engine.Discover(context.Background()); err != nil {
|
|
t.Fatalf("ownership not released after cancellation: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestEngineRetiresDescendantsHoldingPipes(t *testing.T) {
|
|
engine := test_engine(t, "orphan")
|
|
original := engine.command
|
|
readyRead, readyWrite, err := os.Pipe()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer readyRead.Close()
|
|
defer readyWrite.Close()
|
|
engine.command = func(ctx context.Context, args ...string) *exec.Cmd {
|
|
command := original(ctx, args...)
|
|
if args[0] == "stdin" {
|
|
command.ExtraFiles = []*os.File{readyWrite}
|
|
}
|
|
return command
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
_, err := engine.Recognize(ctx, image.NewGray(image.Rect(0, 0, 100, 100)), Options{})
|
|
done <- err
|
|
}()
|
|
if err := readyRead.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
line, err := bufio.NewReader(readyRead).ReadString('\n')
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pid, err := strconv.Atoi(strings.TrimSpace(line))
|
|
if err != nil || pid <= 0 {
|
|
t.Fatalf("invalid descendant PID: %q, %v", line, err)
|
|
}
|
|
select {
|
|
case err := <-done:
|
|
if !errors.Is(err, ErrRecognition) {
|
|
t.Fatalf("inherited pipes did not fail within WaitDelay: %v", err)
|
|
}
|
|
case <-ctx.Done():
|
|
t.Fatal("inherited pipes stalled process cleanup")
|
|
}
|
|
// SIGKILL delivery and adoption by init are asynchronous. A zombie has exited
|
|
// and holds no pipes; the parent engine cannot reap an adopted grandchild.
|
|
deadline := time.NewTimer(time.Second)
|
|
defer deadline.Stop()
|
|
poll := time.NewTicker(10 * time.Millisecond)
|
|
defer poll.Stop()
|
|
for {
|
|
status, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid))
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
break
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fields := strings.Fields(string(status[strings.LastIndexByte(string(status), ')')+1:]))
|
|
if len(fields) != 0 && fields[0] == "Z" {
|
|
break
|
|
}
|
|
select {
|
|
case <-deadline.C:
|
|
t.Fatal("descendant survived process-group cleanup")
|
|
case <-poll.C:
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEngineExpiredDeadlineStartsNoChild(t *testing.T) {
|
|
engine := NewEngine()
|
|
engine.command = func(context.Context, ...string) *exec.Cmd {
|
|
t.Fatal("started child after expired deadline")
|
|
return nil
|
|
}
|
|
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
|
|
defer cancel()
|
|
if _, err := engine.Discover(ctx); !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Fatalf("expired discovery: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBoundedBufferCannotBypassWriteLimit(t *testing.T) {
|
|
buffer := &bounded_buffer{limit: 5, ctx: context.Background()}
|
|
if _, err := io.Copy(buffer, strings.NewReader("0123456789")); !errors.Is(err, ErrLimit) || buffer.Len() > 5 {
|
|
t.Fatalf("writer limit bypassed: %d bytes, %v", buffer.Len(), err)
|
|
}
|
|
}
|
|
|
|
// TestOCRChildProcess is an executable process fixture, not a mock of exec.Wait.
|
|
func TestOCRChildProcess(t *testing.T) {
|
|
if os.Getenv("CTHULHU_OCR_TEST_PROCESS") != "1" {
|
|
return
|
|
}
|
|
args := os.Args
|
|
for len(args) != 0 && args[0] != "--" {
|
|
args = args[1:]
|
|
}
|
|
if len(args) < 2 {
|
|
os.Exit(20)
|
|
}
|
|
args = args[1:]
|
|
mode := os.Getenv("CTHULHU_OCR_TEST_MODE")
|
|
if args[0] == "hold-pipes" {
|
|
ready := os.NewFile(3, "ready")
|
|
_, _ = fmt.Fprintln(ready, os.Getpid())
|
|
_ = ready.Close()
|
|
time.Sleep(time.Hour)
|
|
os.Exit(0)
|
|
}
|
|
if args[0] == "--list-langs" {
|
|
switch mode {
|
|
case "no-languages":
|
|
fmt.Print("List of available languages in \"/data\" (1):\nosd\n")
|
|
case "catalog-limit":
|
|
fmt.Print(strings.Repeat("x", maxCatalogBytes+1))
|
|
default:
|
|
fmt.Print("List of available languages in \"/data\" (3):\neng\nfra\nosd\n")
|
|
}
|
|
os.Exit(0)
|
|
}
|
|
if os.Getenv("OMP_THREAD_LIMIT") != "1" || os.Getenv("LC_ALL") != "C" {
|
|
os.Exit(21)
|
|
}
|
|
encoded, err := io.ReadAll(io.LimitReader(os.Stdin, 1<<20))
|
|
if err != nil {
|
|
os.Exit(22)
|
|
}
|
|
pixels, err := png.Decode(bytes.NewReader(encoded))
|
|
if err != nil || pixels.Bounds() != image.Rect(0, 0, 100, 100) {
|
|
os.Exit(23)
|
|
}
|
|
switch mode {
|
|
case "orphan":
|
|
child := exec.Command(os.Args[0], "-test.run=^TestOCRChildProcess$", "--", "hold-pipes")
|
|
child.Env = os.Environ()
|
|
child.Stdout, child.Stderr = os.Stdout, os.Stderr
|
|
child.ExtraFiles = []*os.File{os.NewFile(3, "ready")}
|
|
if err := child.Start(); err != nil {
|
|
os.Exit(24)
|
|
}
|
|
case "failure":
|
|
fmt.Fprint(os.Stderr, "Private fixture diagnostics")
|
|
os.Exit(1)
|
|
case "missing-data":
|
|
fmt.Fprint(os.Stderr, "Error opening data file /private/data/eng.traineddata\n")
|
|
os.Exit(1)
|
|
case "partial-language":
|
|
fmt.Fprint(os.Stderr, "Failed loading language 'fra'\n")
|
|
case "invalid":
|
|
fmt.Print("Private malformed TSV")
|
|
os.Exit(0)
|
|
case "stdout-limit":
|
|
fmt.Print(strings.Repeat("x", maxTSVBytes+1))
|
|
os.Exit(0)
|
|
case "stderr-limit":
|
|
fmt.Fprint(os.Stderr, strings.Repeat("x", maxStderrBytes+1))
|
|
os.Exit(0)
|
|
case "wait":
|
|
ready := os.NewFile(3, "ready")
|
|
_, _ = ready.Write([]byte{1})
|
|
_ = ready.Close()
|
|
time.Sleep(time.Hour)
|
|
}
|
|
fmt.Print(tsvHeader + "\n5\t1\t1\t1\t1\t1\t0\t0\t10\t10\t95\té\n5\t1\t1\t1\t1\t2\t20\t0\t10\t10\t90\t中文\n")
|
|
os.Exit(0)
|
|
}
|