Cthulhu is no longer a fork. Now it is a screen reader written in Golang.
This commit is contained in:
1 parent
a998fe67de
commit
069fc674dc
708 files changed
+112482
-532
No files matched your search
@@ -0,0 +1,431 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in new issue
Block a user