Cthulhu is no longer a fork. Now it is a screen reader written in Golang.

This commit is contained in:
Storm Dragon committed 2026-10-14 00:16:34 -04:00
1 parent a998fe67de
commit 069fc674dc
708 files changed
+112482 -532

No files matched your search

+247
View File
@@ -0,0 +1,247 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package ocr
import (
"bytes"
"context"
"encoding/json"
"errors"
"image"
"image/png"
"io"
"os/exec"
"time"
)
var (
ErrCapture = errors.New("OCR capture unavailable")
ErrSource = errors.New("OCR source unavailable")
ErrSourceChanged = errors.New("OCR source changed")
)
type SourceKind string
const (
ObjectSource SourceKind = "object"
WindowSource SourceKind = "window"
DesktopSource SourceKind = "desktop"
ClipboardSource SourceKind = "clipboard"
maxCaptureHeader = 4096
captureTimeout = 2 * time.Second
)
// CaptureRequest is an immutable job snapshot. Bounds are physical X11 root
// pixels, not GTK logical coordinates. The future target owner must convert
// provider coordinates before requesting capture and retain its object identity.
// Generation belongs to that owner, which must retire jobs on focus/lock/loss.
// Foreground is required even for clipboard capture. Window may name a child of
// Foreground (for example a Wine client); zero selects the foreground client.
type CaptureRequest struct {
Kind SourceKind
Bounds image.Rectangle
Foreground uint64
Window uint64
Generation uint64
}
func (request CaptureRequest) Validate() error {
if request.Foreground == 0 || request.Foreground > 0xffffffff || request.Window > 0xffffffff || request.Generation == 0 {
return ErrSource
}
switch request.Kind {
case ObjectSource:
if !valid_screen_rectangle(request.Bounds) {
return ErrSource
}
case WindowSource, DesktopSource, ClipboardSource:
if request.Bounds != (image.Rectangle{}) {
return ErrSource
}
default:
return ErrSource
}
if request.Kind != WindowSource && request.Window != 0 {
return ErrSource
}
return nil
}
// CaptureHeader precedes a PNG on the private pipe. Rectangles describe physical
// root pixels. No monitor topology, object identity or activation authorization
// is inferred from them. Clipboard pixels have no screen mapping.
type CaptureHeader struct {
Request CaptureRequest
SourceWindow uint64
RootBounds image.Rectangle
SourceBounds image.Rectangle
ScreenBounds image.Rectangle
Status string
}
type Capture struct {
CaptureHeader
Pixels image.Image
Transform Transform
}
// Capturer owns one helper at a time. It performs no startup I/O. Run Capture
// on a worker, never the input/event thread. The executable must be the reader
// binary supporting --ocr-capture; it is invoked directly without a shell.
type Capturer struct {
gate chan struct{}
command func(context.Context, ...string) *exec.Cmd
}
func NewCapturer(executable string) *Capturer {
return &Capturer{gate: make(chan struct{}, 1), command: func(ctx context.Context, args ...string) *exec.Cmd {
return exec.CommandContext(ctx, executable, args...)
}}
}
func (capturer *Capturer) Capture(ctx context.Context, request CaptureRequest) (Capture, error) {
if err := request.Validate(); err != nil {
return Capture{}, err
}
ctx, cancel := context.WithTimeout(ctx, captureTimeout)
defer cancel()
select {
case capturer.gate <- struct{}{}:
defer func() { <-capturer.gate }()
case <-ctx.Done():
return Capture{}, ctx.Err()
}
if err := ctx.Err(); err != nil {
return Capture{}, err
}
input, err := json.Marshal(request)
if err != nil {
return Capture{}, ErrSource
}
output, _, err := run_process(ctx, capturer.command, input, maxImageBytes+maxCaptureHeader, "--ocr-capture")
if err != nil {
if errors.Is(err, ErrUnavailable) || errors.Is(err, ErrRecognition) {
return Capture{}, ErrCapture
}
return Capture{}, err
}
capture, err := decode_capture(output, request)
if ctx.Err() != nil {
return Capture{}, ctx.Err()
}
return capture, err
}
// ReadCaptureRequest bounds and validates the helper's one request before any
// GTK/X11 initialization. Unknown fields and trailing messages are rejected.
func ReadCaptureRequest(input io.Reader) (CaptureRequest, error) {
data, err := io.ReadAll(io.LimitReader(input, maxCaptureHeader+1))
if err != nil || len(data) > maxCaptureHeader {
return CaptureRequest{}, ErrLimit
}
var request CaptureRequest
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if decoder.Decode(&request) != nil || decoder.Decode(new(any)) != io.EOF {
return CaptureRequest{}, ErrSource
}
return request, request.Validate()
}
// WriteCaptureResponse retains a complete bounded frame before publishing it.
// Failures contain a fixed status only; native diagnostics never cross the pipe.
func WriteCaptureResponse(output io.Writer, header CaptureHeader, pixels image.Image) error {
data, err := json.Marshal(header)
if err != nil || len(data)+1 > maxCaptureHeader {
return ErrLimit
}
buffer := &bounded_buffer{ctx: context.Background(), limit: maxImageBytes + maxCaptureHeader}
_, _ = buffer.Write(append(data, '\n'))
if header.Status == "" {
if pixels == nil || !valid_dimensions(pixels.Bounds().Dx(), pixels.Bounds().Dy()) {
return ErrInvalidImage
}
if err := png.Encode(buffer, pixels); err != nil {
return err
}
}
_, err = output.Write(buffer.Bytes())
return err
}
func decode_capture(data []byte, request CaptureRequest) (Capture, error) {
separator := bytes.IndexByte(data, '\n')
if separator < 0 || separator >= maxCaptureHeader || len(data) > maxImageBytes+maxCaptureHeader {
return Capture{}, ErrCapture
}
var header CaptureHeader
decoder := json.NewDecoder(bytes.NewReader(data[:separator]))
decoder.DisallowUnknownFields()
if decoder.Decode(&header) != nil || decoder.Decode(new(any)) != io.EOF || header.Request != request {
return Capture{}, ErrCapture
}
if header.Status != "" {
if len(data) != separator+1 {
return Capture{}, ErrCapture
}
switch header.Status {
case "unavailable":
return Capture{}, ErrCapture
case "source":
return Capture{}, ErrSource
case "changed":
return Capture{}, ErrSourceChanged
case "limit":
return Capture{}, ErrLimit
default:
return Capture{}, ErrCapture
}
}
payload := data[separator+1:]
config, err := png.DecodeConfig(bytes.NewReader(payload))
if err != nil {
return Capture{}, ErrCapture
}
if !valid_dimensions(config.Width, config.Height) {
return Capture{}, ErrLimit
}
transform := Transform{ImageContent: image.Rect(0, 0, config.Width, config.Height)}
if request.Kind == ClipboardSource {
if header.SourceWindow != 0 || header.RootBounds != (image.Rectangle{}) || header.SourceBounds != (image.Rectangle{}) || header.ScreenBounds != (image.Rectangle{}) {
return Capture{}, ErrCapture
}
} else {
if !valid_screen_rectangle(header.RootBounds) || !valid_screen_rectangle(header.SourceBounds) ||
!valid_screen_rectangle(header.ScreenBounds) || !header.ScreenBounds.In(header.RootBounds) || !header.ScreenBounds.In(header.SourceBounds) ||
header.ScreenBounds.Dx() != config.Width || header.ScreenBounds.Dy() != config.Height {
return Capture{}, ErrCapture
}
sourceWindow := request.Foreground
if request.Kind == WindowSource && request.Window != 0 {
sourceWindow = request.Window
}
if request.Kind == DesktopSource {
sourceWindow = 0
if header.SourceBounds != header.RootBounds || header.ScreenBounds != header.RootBounds {
return Capture{}, ErrCapture
}
} else if request.Kind == ObjectSource && header.ScreenBounds != request.Bounds.Intersect(header.SourceBounds).Intersect(header.RootBounds) {
return Capture{}, ErrCapture
}
if request.Kind == WindowSource && header.ScreenBounds != header.SourceBounds.Intersect(header.RootBounds) {
return Capture{}, ErrCapture
}
if header.SourceWindow != sourceWindow {
return Capture{}, ErrCapture
}
transform.HasScreenMapping, transform.ScreenBounds = true, header.ScreenBounds
}
payloadReader := bytes.NewReader(payload)
pixels, err := png.Decode(payloadReader)
if err != nil || payloadReader.Len() != 0 {
return Capture{}, ErrCapture
}
return Capture{CaptureHeader: header, Pixels: pixels, Transform: transform}, nil
}
+231
View File
@@ -0,0 +1,231 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package ocr
import (
"bufio"
"context"
"encoding/json"
"errors"
"image"
"image/color"
"os"
"os/exec"
"testing"
"time"
)
// Run only on a private display with a freshly built helper binary.
func TestNativeOCRCapture(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_OCR_CAPTURE") != "1" {
t.Skip("private Xvfb fixture only")
}
executable := os.Getenv("CTHULHU_OCR_CAPTURE_BINARY")
if executable == "" {
t.Fatal("set CTHULHU_OCR_CAPTURE_BINARY to a freshly built reader")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
fixture := exec.CommandContext(ctx, "/usr/bin/python3", "-u", "../../tests/ocr_capture_fixture.py")
fixture.Env = append(os.Environ(), "NO_AT_BRIDGE=1", "GTK_MODULES=")
stdin, err := fixture.StdinPipe()
if err != nil {
t.Fatal(err)
}
stdout, err := fixture.StdoutPipe()
if err != nil {
t.Fatal(err)
}
fixture.Stderr = os.Stderr
if err = fixture.Start(); err != nil {
t.Fatal(err)
}
defer func() { _ = stdin.Close(); _ = fixture.Wait() }()
scanner := bufio.NewScanner(stdout)
if !scanner.Scan() {
t.Fatal("fixture did not start")
}
var windows map[string]uint64
if err = json.Unmarshal(scanner.Bytes(), &windows); err != nil {
t.Fatal(err)
}
command := func(command map[string]any) map[string]uint64 {
t.Helper()
data, _ := json.Marshal(command)
if _, err := stdin.Write(append(data, '\n')); err != nil {
t.Fatal(err)
}
if !scanner.Scan() {
t.Fatal("fixture did not respond")
}
var state map[string]uint64
if err := json.Unmarshal(scanner.Bytes(), &state); err != nil {
t.Fatal(err)
}
return state
}
state := func() map[string]uint64 { return command(map[string]any{"action": "state"}) }
capturer := NewCapturer(executable)
request := CaptureRequest{Kind: WindowSource, Foreground: windows["window"], Generation: 1}
capture := func(request CaptureRequest) Capture {
t.Helper()
before := state()
result, err := capturer.Capture(ctx, request)
if err != nil {
t.Fatal(err)
}
after := state()
if before["focus"] != after["focus"] || before["windows"] != after["windows"] {
t.Fatalf("capture changed native focus/windows: %v -> %v", before, after)
}
return result
}
expectPixel := func(result Capture, point image.Point, want color.RGBA) {
t.Helper()
got := color.RGBAModel.Convert(result.Pixels.At(point.X, point.Y)).(color.RGBA)
if got != want {
t.Fatalf("pixel %v = %v, want %v", point, got, want)
}
}
// XGetImage uses physical pixels at both GDK scales. Provider conversion is
// intentionally a separate target-owner gate, not established by this test.
for _, scale := range []string{"1", "2"} {
t.Setenv("GDK_SCALE", scale)
result := capture(request)
if result.ScreenBounds != image.Rect(40, 30, 340, 230) || result.Pixels.Bounds() != image.Rect(0, 0, 300, 200) {
t.Fatalf("scale %s capture: %+v", scale, result.CaptureHeader)
}
expectPixel(result, image.Pt(0, 0), color.RGBA{0x33, 0x66, 0x99, 255})
expectPixel(result, image.Pt(10, 15), color.RGBA{0xab, 0xcd, 0xef, 255})
mapped, ok := result.Transform.ScreenRectangle(image.Rect(10, 15, 20, 25))
if !ok || mapped != image.Rect(50, 45, 60, 55) {
t.Fatalf("pixel mapping: %v, %v", mapped, ok)
}
}
t.Setenv("GDK_SCALE", "1")
// A usable X11 source must not be rejected solely by the session label.
// This tests policy on Xvfb, not capture on a real Wayland compositor.
t.Setenv("XDG_SESSION_TYPE", "wayland")
expectPixel(capture(request), image.Pt(0, 0), color.RGBA{0x33, 0x66, 0x99, 255})
t.Setenv("XDG_SESSION_TYPE", "x11")
childRequest := request
childRequest.Window = windows["child"]
child := capture(childRequest)
if child.ScreenBounds != image.Rect(50, 45, 130, 95) {
t.Fatalf("child capture: %+v", child.CaptureHeader)
}
expectPixel(child, image.Pt(0, 0), color.RGBA{0xab, 0xcd, 0xef, 255})
objectRequest := request
objectRequest.Kind, objectRequest.Bounds = ObjectSource, image.Rect(-10, -10, 90, 100)
if result := capture(objectRequest); result.ScreenBounds != image.Rect(40, 30, 90, 100) {
t.Fatalf("object clip: %+v", result.CaptureHeader)
}
objectRequest.Bounds = image.Rect(-100, -100, -1, -1)
if _, err := capturer.Capture(ctx, objectRequest); !errors.Is(err, ErrSource) {
t.Fatalf("offscreen source: %v", err)
}
desktopRequest := request
desktopRequest.Kind = DesktopSource
desktop := capture(desktopRequest)
if desktop.ScreenBounds != desktop.RootBounds || desktop.SourceWindow != 0 {
t.Fatalf("desktop: %+v", desktop.CaptureHeader)
}
expectPixel(desktop, image.Pt(350, 30), color.RGBA{0x99, 0x22, 0x44, 255})
if os.Getenv("CTHULHU_NATIVE_OCR") == "1" {
command(map[string]any{"action": "activate", "window": "painted"})
paintedRequest := request
paintedRequest.Foreground = windows["painted"]
painted := capture(paintedRequest)
result, err := NewEngine().Recognize(ctx, painted.Pixels, Options{})
if err != nil || result.Text != "OCR test window\nOpen Settings\nValue 42" {
t.Fatalf("captured X11 recognition: %q, %v", result.Text, err)
}
word, ok := result.WordAt(0)
mapped, mappedOK := painted.Transform.ScreenRectangle(word.Bounds)
if !ok || !mappedOK || !mapped.In(painted.ScreenBounds) {
t.Fatalf("recognized screen word mapping: %v, %v", mapped, mappedOK)
}
command(map[string]any{"action": "activate"})
}
clipboardRequest := request
clipboardRequest.Kind = ClipboardSource
clipboard := capture(clipboardRequest)
if clipboard.Transform.HasScreenMapping || clipboard.Pixels.Bounds() != image.Rect(0, 0, 640, 220) {
t.Fatalf("clipboard mapping: %+v", clipboard.CaptureHeader)
}
command(map[string]any{"action": "clipboard-text"})
if _, err := capturer.Capture(ctx, clipboardRequest); !errors.Is(err, ErrSource) {
t.Fatalf("text clipboard captured as image: %v", err)
}
// The earlier snapshot survives clipboard replacement and is recognized from
// those owned pixels rather than fetching the current clipboard again.
if os.Getenv("CTHULHU_NATIVE_OCR") == "1" {
result, err := NewEngine().Recognize(ctx, clipboard.Pixels, Options{})
if err != nil || result.Text != "OCR test window\nOpen Settings\nValue 42" {
t.Fatalf("captured clipboard recognition: %q, %v", result.Text, err)
}
}
// Synchronize on the real selection request, switch foreground during the
// native transfer, and release pixels. The helper must reject that result.
command(map[string]any{"action": "clipboard-block"})
completed := make(chan error, 1)
go func() { _, err := capturer.Capture(ctx, clipboardRequest); completed <- err }()
if !scanner.Scan() || scanner.Text() != `{"capture-request": 1}` {
t.Fatal("clipboard capture did not request its pixels")
}
command(map[string]any{"action": "activate", "window": "other"})
command(map[string]any{"action": "clipboard-release"})
if err := <-completed; !errors.Is(err, ErrSourceChanged) {
t.Fatalf("foreground change during capture: %v", err)
}
command(map[string]any{"action": "activate"})
go func() { _, err := capturer.Capture(ctx, clipboardRequest); completed <- err }()
if !scanner.Scan() || scanner.Text() != `{"capture-request": 1}` {
t.Fatal("geometry-change capture did not start")
}
command(map[string]any{"action": "resize", "width": 310, "height": 210})
command(map[string]any{"action": "clipboard-release"})
if err := <-completed; !errors.Is(err, ErrSourceChanged) {
t.Fatalf("geometry change during capture: %v", err)
}
command(map[string]any{"action": "resize", "width": 300, "height": 200})
// A provider that never replies must not hold the reader's worker forever.
go func() { _, err := capturer.Capture(ctx, clipboardRequest); completed <- err }()
if !scanner.Scan() || scanner.Text() != `{"capture-request": 1}` {
t.Fatal("blocked clipboard capture did not start")
}
if err := <-completed; !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("blocked native clipboard deadline: %v", err)
}
command(map[string]any{"action": "clipboard-text"})
command(map[string]any{"action": "overlay"})
expectPixel(capture(request), image.Pt(0, 0), color.RGBA{0xaa, 0x11, 0, 255})
command(map[string]any{"action": "move", "x": -20, "y": -10})
if result := capture(request); result.ScreenBounds != image.Rect(0, 0, 280, 190) || result.SourceBounds != image.Rect(-20, -10, 280, 190) {
t.Fatalf("negative window clip: %+v", result.CaptureHeader)
}
command(map[string]any{"action": "activate", "window": "other"})
if _, err := capturer.Capture(ctx, request); !errors.Is(err, ErrSourceChanged) {
t.Fatalf("foreground mismatch: %v", err)
}
childRequest.Foreground = windows["other"]
childRequest.Window = windows["child"]
if _, err := capturer.Capture(ctx, childRequest); !errors.Is(err, ErrSource) {
t.Fatalf("unrelated window accepted: %v", err)
}
command(map[string]any{"action": "activate"})
command(map[string]any{"action": "hide"})
if _, err := capturer.Capture(ctx, request); !errors.Is(err, ErrSource) {
t.Fatalf("hidden window: %v", err)
}
command(map[string]any{"action": "destroy"})
if _, err := capturer.Capture(ctx, request); !errors.Is(err, ErrSource) {
t.Fatalf("destroyed window: %v", err)
}
t.Setenv("DISPLAY", "")
if _, err := capturer.Capture(ctx, request); !errors.Is(err, ErrCapture) {
t.Fatalf("missing display: %v", err)
}
}
+232
View File
@@ -0,0 +1,232 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package ocr
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"errors"
"hash/crc32"
"image"
"image/color"
"io"
"math"
"os"
"os/exec"
"strconv"
"strings"
"syscall"
"testing"
"time"
)
func capture_request() CaptureRequest {
return CaptureRequest{Kind: ObjectSource, Bounds: image.Rect(-20, 10, 30, 50), Foreground: 123, Generation: 7}
}
func capture_frame(t *testing.T, header CaptureHeader, pixels image.Image) []byte {
t.Helper()
var output bytes.Buffer
if err := WriteCaptureResponse(&output, header, pixels); err != nil {
t.Fatal(err)
}
return output.Bytes()
}
func TestCaptureRequestRejectsUnownedOrInvalidSources(t *testing.T) {
base := capture_request()
if err := base.Validate(); err != nil {
t.Fatal(err)
}
mutations := []func(*CaptureRequest){
func(r *CaptureRequest) { r.Foreground = 0 },
func(r *CaptureRequest) { r.Foreground = 1 << 32 },
func(r *CaptureRequest) { r.Generation = 0 },
func(r *CaptureRequest) { r.Kind = "unknown" },
func(r *CaptureRequest) { r.Bounds = image.Rectangle{} },
func(r *CaptureRequest) { r.Bounds.Min.X = math.MinInt32 },
func(r *CaptureRequest) { r.Bounds = image.Rect(0, 0, MaxPixels+1, 1) },
func(r *CaptureRequest) { r.Window = 42 },
func(r *CaptureRequest) { r.Kind = WindowSource },
}
for _, mutate := range mutations {
request := base
mutate(&request)
if !errors.Is(request.Validate(), ErrSource) {
t.Fatalf("invalid request accepted: %+v", request)
}
}
for _, kind := range []SourceKind{WindowSource, DesktopSource, ClipboardSource} {
request := base
request.Kind, request.Bounds = kind, image.Rectangle{}
if err := request.Validate(); err != nil {
t.Fatal(err)
}
}
data, _ := json.Marshal(base)
request, err := ReadCaptureRequest(bytes.NewReader(data))
if err != nil || request != base {
t.Fatalf("request roundtrip: %+v, %v", request, err)
}
for _, invalid := range [][]byte{nil, []byte("{}"), append(append([]byte(nil), data...), data...), []byte(`{"Private":"generic"}`), bytes.Repeat([]byte{' '}, maxCaptureHeader+1)} {
if _, err := ReadCaptureRequest(bytes.NewReader(invalid)); err == nil {
t.Fatal("invalid private request accepted")
}
}
}
func TestCaptureFrameClippingAndGeneration(t *testing.T) {
request := capture_request()
header := CaptureHeader{Request: request, SourceWindow: request.Foreground,
RootBounds: image.Rect(0, 0, 100, 100), SourceBounds: image.Rect(-10, 0, 80, 80), ScreenBounds: image.Rect(0, 10, 30, 50)}
pixels := image.NewRGBA(image.Rect(0, 0, 30, 40))
pixels.Set(1, 2, color.RGBA{12, 34, 56, 255})
frame := capture_frame(t, header, pixels)
result, err := decode_capture(frame, request)
if err != nil {
t.Fatal(err)
}
if color.RGBAModel.Convert(result.Pixels.At(1, 2)) != pixels.At(1, 2) {
t.Fatal("pixels changed")
}
if _, err := decode_capture(append(append([]byte(nil), frame...), 'x'), request); !errors.Is(err, ErrCapture) {
t.Fatalf("trailing payload accepted: %v", err)
}
mapped, ok := result.Transform.ScreenRectangle(image.Rect(1, 2, 3, 4))
if !ok || mapped != image.Rect(1, 12, 3, 14) {
t.Fatalf("mapping: %v, %v", mapped, ok)
}
request.Generation++
if _, err := decode_capture(frame, request); !errors.Is(err, ErrCapture) {
t.Fatalf("old generation accepted: %v", err)
}
request.Generation--
for _, change := range []func(*CaptureHeader){
func(h *CaptureHeader) { h.ScreenBounds.Min.X = -1 },
func(h *CaptureHeader) { h.SourceBounds = image.Rect(50, 50, 80, 80) },
func(h *CaptureHeader) { h.SourceWindow++ },
func(h *CaptureHeader) { h.ScreenBounds.Max.X++ },
} {
bad := header
change(&bad)
if _, err := decode_capture(capture_frame(t, bad, pixels), request); err == nil {
t.Fatalf("invalid mapping accepted: %+v", bad)
}
}
}
func TestCaptureClipboardDoesNotMapToScreen(t *testing.T) {
request := CaptureRequest{Kind: ClipboardSource, Foreground: 123, Generation: 1}
header := CaptureHeader{Request: request}
pixels := image.NewRGBA(image.Rect(0, 0, 10, 10))
result, err := decode_capture(capture_frame(t, header, pixels), request)
if err != nil || result.Transform.HasScreenMapping {
t.Fatalf("clipboard: %+v, %v", result, err)
}
header.ScreenBounds = image.Rect(0, 0, 10, 10)
if _, err := decode_capture(capture_frame(t, header, pixels), request); err == nil {
t.Fatal("clipboard gained coordinates")
}
}
func TestCaptureProtocolErrorsAndPixelLimit(t *testing.T) {
request := capture_request()
for status, want := range map[string]error{"unavailable": ErrCapture, "source": ErrSource, "changed": ErrSourceChanged, "limit": ErrLimit, "Private diagnostics": ErrCapture} {
frame := capture_frame(t, CaptureHeader{Request: request, Status: status}, nil)
_, err := decode_capture(frame, request)
if !errors.Is(err, want) || strings.Contains(err.Error(), "Private") {
t.Fatalf("status %q: %v", status, err)
}
if _, err := decode_capture(append(frame, 'x'), request); !errors.Is(err, ErrCapture) {
t.Fatal("failure with payload accepted")
}
}
clipboard := CaptureRequest{Kind: ClipboardSource, Foreground: 123, Generation: 1}
frame := capture_frame(t, CaptureHeader{Request: clipboard}, image.NewRGBA(image.Rect(0, 0, 1, 1)))
start := bytes.IndexByte(frame, '\n') + 1
// A valid CRC and malicious IHDR are rejected before allocating pixel data.
binary.BigEndian.PutUint32(frame[start+16:start+20], 10000)
binary.BigEndian.PutUint32(frame[start+20:start+24], 10000)
binary.BigEndian.PutUint32(frame[start+29:start+33], crc32.ChecksumIEEE(frame[start+12:start+29]))
if _, err := decode_capture(frame, clipboard); !errors.Is(err, ErrLimit) {
t.Fatalf("oversize PNG: %v", err)
}
for _, invalid := range [][]byte{nil, []byte("{}\nnot a png"), bytes.Repeat([]byte{'x'}, maxCaptureHeader+1)} {
if _, err := decode_capture(invalid, request); !errors.Is(err, ErrCapture) {
t.Fatalf("invalid response: %v", err)
}
}
}
func TestCapturerMissingHelperAndCancellation(t *testing.T) {
request := capture_request()
if _, err := NewCapturer("/nonexistent-ocr-capture-helper").Capture(context.Background(), request); !errors.Is(err, ErrCapture) {
t.Fatalf("missing helper: %v", err)
}
executable, err := os.Executable()
if err != nil {
t.Fatal(err)
}
readyRead, readyWrite, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer readyRead.Close()
defer readyWrite.Close()
capturer := NewCapturer(executable)
capturer.command = func(ctx context.Context, args ...string) *exec.Cmd {
command := exec.CommandContext(ctx, executable, "-test.run=^TestCaptureChildProcess$", "--", args[0])
command.Env = append(os.Environ(), "CTHULHU_CAPTURE_TEST_CHILD=1")
command.ExtraFiles = []*os.File{readyWrite}
return command
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
completed := make(chan error, 1)
go func() { _, err := capturer.Capture(ctx, request); completed <- err }()
_ = readyRead.SetReadDeadline(time.Now().Add(5 * time.Second))
data := make([]byte, 64)
count, err := readyRead.Read(data)
if err != nil {
t.Fatal(err)
}
pid, err := strconv.Atoi(strings.TrimSpace(string(data[:count])))
if err != nil {
t.Fatal(err)
}
waitContext, waitCancel := context.WithCancel(context.Background())
waitCancel()
if _, err := capturer.Capture(waitContext, request); !errors.Is(err, context.Canceled) {
t.Fatalf("waiting cancellation: %v", err)
}
cancel()
select {
case err := <-completed:
if !errors.Is(err, context.Canceled) {
t.Fatalf("running cancellation: %v", err)
}
case <-time.After(3 * time.Second):
t.Fatal("capture did not join cancelled helper")
}
if err := syscall.Kill(pid, 0); !errors.Is(err, syscall.ESRCH) {
t.Fatalf("helper still alive or unreaped: %v", err)
}
}
func TestCaptureChildProcess(t *testing.T) {
if os.Getenv("CTHULHU_CAPTURE_TEST_CHILD") != "1" {
return
}
if _, err := io.ReadAll(os.Stdin); err != nil {
os.Exit(20)
}
ready := os.NewFile(3, "ready")
_, _ = ready.WriteString(strconv.Itoa(os.Getpid()) + "\n")
_ = ready.Close()
time.Sleep(time.Hour)
os.Exit(0)
}
+169
View File
@@ -0,0 +1,169 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package ocr
import (
"sort"
"strings"
)
type Unit uint8
const (
Character Unit = iota + 1
WordUnit
LineUnit
PageUnit
LineStart
LineEnd
DocumentStart
DocumentEnd
)
// Cursor owns a virtual rune offset. It has no native accessible identity and
// never moves application focus. A session discards it on retirement.
type Cursor struct {
result Result
text []rune
lines []Span
lineTexts []string
offset, column int
}
type Position struct {
Offset int
Text string
Line string
LineStart int
Cursor int // Rune offset within Line, including its end position.
Moved bool
}
type ReadLine struct {
Offset int
Text string
}
func NewCursor(result Result) *Cursor {
cursor := &Cursor{result: result, text: []rune(result.Text), lineTexts: strings.Split(result.Text, "\n")}
start := 0
for index, char := range cursor.text {
if char == '\n' {
cursor.lines = append(cursor.lines, Span{start, index})
start = index + 1
}
}
cursor.lines = append(cursor.lines, Span{start, len(cursor.text)})
return cursor
}
func (cursor *Cursor) line_index() int {
return sort.Search(len(cursor.lines), func(index int) bool { return cursor.lines[index].End >= cursor.offset })
}
func (cursor *Cursor) Current(unit Unit) Position {
lineIndex := cursor.line_index()
line := cursor.lines[lineIndex]
position := Position{Offset: cursor.offset, Line: cursor.lineTexts[lineIndex], LineStart: line.Start, Cursor: cursor.offset - line.Start}
switch unit {
case Character, LineStart, LineEnd:
if cursor.offset < len(cursor.text) {
position.Text = string(cursor.text[cursor.offset])
}
case WordUnit:
if word, ok := cursor.result.WordAt(cursor.offset); ok {
position.Text = word.Text
}
default:
position.Text = position.Line
}
return position
}
func (cursor *Cursor) Move(unit Unit, step int) Position {
previous := cursor.offset
lineIndex := cursor.line_index()
line := cursor.lines[lineIndex]
switch unit {
case Character:
cursor.offset = max(0, min(len(cursor.text), cursor.offset+step))
case WordUnit:
if step > 0 {
index := sort.Search(len(cursor.result.Words), func(index int) bool { return cursor.result.Words[index].Start > cursor.offset })
if index < len(cursor.result.Words) {
cursor.offset = cursor.result.Words[index].Start
}
} else if step < 0 {
index := sort.Search(len(cursor.result.Words), func(index int) bool { return cursor.result.Words[index].Start >= cursor.offset }) - 1
if index >= 0 {
cursor.offset = cursor.result.Words[index].Start
}
}
case LineUnit, PageUnit:
if unit == PageUnit {
step *= 20
}
next := max(0, min(len(cursor.lines)-1, lineIndex+step))
if next != lineIndex {
cursor.offset = cursor.lines[next].Start + min(cursor.column, cursor.lines[next].End-cursor.lines[next].Start)
}
case LineStart:
cursor.offset = line.Start
case LineEnd:
cursor.offset = line.End
case DocumentStart:
cursor.offset = 0
case DocumentEnd:
cursor.offset = len(cursor.text)
}
if unit != LineUnit && unit != PageUnit {
cursor.column = cursor.offset - cursor.lines[cursor.line_index()].Start
}
position := cursor.Current(unit)
position.Moved = previous != cursor.offset
return position
}
// Set moves only the virtual cursor. It rejects offsets outside the recognized
// text so a stale routing request cannot produce an invalid position.
func (cursor *Cursor) Set(offset int) (Position, bool) {
if offset < 0 || offset > len(cursor.text) {
return Position{}, false
}
cursor.offset = offset
cursor.column = offset - cursor.lines[cursor.line_index()].Start
return cursor.Current(Character), true
}
func (cursor *Cursor) Length() int { return len(cursor.text) }
func (cursor *Cursor) Offset() int { return cursor.offset }
func (cursor *Cursor) Text() string { return cursor.result.Text }
func (cursor *Cursor) CurrentWord() (Word, bool) { return cursor.result.WordAt(cursor.offset) }
func (cursor *Cursor) Slice(start, end int) (string, bool) {
if start < 0 || end < start || end > len(cursor.text) {
return "", false
}
return string(cursor.text[start:end]), true
}
// LinesFrom returns whole lines after the virtual cursor. The first line
// begins at the cursor position; following lines retain recognition order.
func (cursor *Cursor) LinesFrom() []ReadLine {
index := cursor.line_index()
first := index
parts := make([]ReadLine, 0, len(cursor.lines)-index)
for ; index < len(cursor.lines); index++ {
line := cursor.lines[index]
start := line.Start
if index == first {
start = cursor.offset
}
if start < line.End {
parts = append(parts, ReadLine{Offset: start, Text: string(cursor.text[start:line.End])})
}
}
return parts
}
+23
View File
@@ -0,0 +1,23 @@
package ocr
import "testing"
func TestCursorReadAllAndRoutingOffsets(t *testing.T) {
cursor := NewCursor(Result{Text: "é one\n\n猫 two"})
if _, ok := cursor.Set(2); !ok {
t.Fatal("valid rune offset rejected")
}
lines := cursor.LinesFrom()
if len(lines) != 2 || lines[0].Text != "one" || lines[0].Offset != 2 || lines[1].Text != "猫 two" || lines[1].Offset != 7 {
t.Fatalf("lines from cursor: %+v", lines)
}
if _, ok := cursor.Set(-1); ok {
t.Fatal("negative route accepted")
}
if _, ok := cursor.Set(cursor.Length() + 1); ok {
t.Fatal("out of range route accepted")
}
if got, ok := cursor.Slice(0, 1); !ok || got != "é" {
t.Fatalf("rune selection: %q %t", got, ok)
}
}
+59
View File
@@ -0,0 +1,59 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package ocr
import "testing"
func TestCursorUnicodeLinesAndEngineWordOrder(t *testing.T) {
result := Result{Text: "é e\u0301\n\nאב 42", Words: []Word{
{Text: "é", Span: Span{0, 1}}, {Text: "e\u0301", Span: Span{2, 4}},
{Text: "אב", Span: Span{6, 8}}, {Text: "42", Span: Span{9, 11}},
}}
cursor := NewCursor(result)
if got := cursor.Current(LineUnit); got.Text != "é e\u0301" || got.Offset != 0 || got.Cursor != 0 {
t.Fatalf("initial: %+v", got)
}
if got := cursor.Move(WordUnit, 1); got.Text != "e\u0301" || got.Offset != 2 || got.Cursor != 2 {
t.Fatalf("combining word: %+v", got)
}
if got := cursor.Move(Character, 1); got.Text != "\u0301" || got.Offset != 3 {
t.Fatalf("combining character: %+v", got)
}
if got := cursor.Move(LineUnit, 1); got.Text != "" || got.Offset != 5 || got.Cursor != 0 {
t.Fatalf("blank paragraph: %+v", got)
}
if got := cursor.Move(LineUnit, 1); got.Text != "אב 42" || got.Offset != 9 || got.Cursor != 3 {
t.Fatalf("preserved column: %+v", got)
}
if got := cursor.Move(WordUnit, -1); got.Text != "אב" || got.Offset != 6 {
t.Fatalf("RTL engine order: %+v", got)
}
if got := cursor.Move(DocumentEnd, 0); got.Offset != 11 || got.Cursor != 5 || got.Text != "אב 42" {
t.Fatalf("end: %+v", got)
}
if got := cursor.Move(Character, 1); got.Moved || got.Offset != 11 {
t.Fatalf("past end: %+v", got)
}
cursor.Move(DocumentStart, 0)
if got := cursor.Move(Character, -1); got.Moved || got.Offset != 0 {
t.Fatalf("before start: %+v", got)
}
}
func TestCursorLineEndsSeparatorsAndEmptyDocument(t *testing.T) {
cursor := NewCursor(Result{Text: "one\ntwo\n"})
if got := cursor.Move(LineEnd, 0); got.Offset != 3 || got.Text != "\n" || got.Cursor != 3 {
t.Fatalf("line end: %+v", got)
}
if got := cursor.Move(Character, 1); got.Offset != 4 || got.Text != "t" || got.Line != "two" || got.Cursor != 0 {
t.Fatalf("cross separator: %+v", got)
}
if got := cursor.Move(DocumentEnd, 0); got.Offset != 8 || got.Line != "" || got.Cursor != 0 {
t.Fatalf("trailing blank: %+v", got)
}
if got := NewCursor(Result{}).Move(Character, 1); got.Offset != 0 || got.Text != "" || got.Moved {
t.Fatalf("empty: %+v", got)
}
}
+252
View File
@@ -0,0 +1,252 @@
// 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
}
+431
View File
@@ -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)
}
+157
View File
@@ -0,0 +1,157 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
// Package ocr provides local recognition without build-time OCR dependencies.
// Captures, review sessions and native input remain separate owners.
package ocr
import (
"errors"
"image"
"math"
"sort"
"unicode"
"unicode/utf8"
)
var (
ErrUnavailable = errors.New("OCR not available")
ErrInvalidImage = errors.New("invalid OCR image")
ErrInvalidOutput = errors.New("invalid OCR output")
ErrLimit = errors.New("OCR limit exceeded")
ErrRecognition = errors.New("OCR recognition failed")
)
const (
MaxPixels = 16_000_000
MaxTextBytes = 1 << 20
maxTSVBytes = 8 << 20
maxWords = 100_000
maxRows = 200_000
)
// LineID preserves the full engine hierarchy, including line numbers which
// restart in each paragraph. Result order is engine order, not screen Y/X order.
type LineID struct {
Page, Block, Paragraph, Line int
}
// Span is a half-open range of Unicode code points, never UTF-8 bytes.
// Combining marks retain their original offsets; text is not normalized.
type Span struct {
Start, End int
}
type Word struct {
Text string
Confidence float64
Bounds image.Rectangle
Span
}
type Line struct {
ID LineID
Span
WordStart, WordEnd int
}
// Result owns plain text and word rectangles in recognition-image pixels.
// An empty document is a successful recognition with no text.
type Result struct {
Text string
Width, Height int
Words []Word
Lines []Line
}
// WordAt deliberately rejects separators and end-of-document positions.
// A later activation owner must validate source freshness before clicking.
func (result Result) WordAt(offset int) (Word, bool) {
index := sort.Search(len(result.Words), func(index int) bool {
return result.Words[index].End > offset
})
if index == len(result.Words) || offset < result.Words[index].Start {
return Word{}, false
}
word := result.Words[index]
runeIndex := 0
for _, char := range word.Text {
if runeIndex == offset-word.Start {
return word, !unicode.IsSpace(char)
}
runeIndex++
}
return Word{}, false
}
// ByteOffset explicitly bridges the reader's rune offsets to Go string slices.
// The offset at the end of the document is valid.
func (result Result) ByteOffset(offset int) (int, bool) {
if offset < 0 {
return 0, false
}
runeIndex := 0
for byteIndex := range result.Text {
if runeIndex == offset {
return byteIndex, true
}
runeIndex++
}
return len(result.Text), runeIndex == offset
}
// Transform maps the content rectangle within the processed image to the
// clipped capture rectangle on screen. ImageContent accounts for padding;
// independent ratios account for rounded scaling in each axis. A clipboard
// image has HasScreenMapping=false and cannot supply screen coordinates.
// This mapping says nothing about source identity, freshness or monitor gaps.
type Transform struct {
ImageContent image.Rectangle
ScreenBounds image.Rectangle
HasScreenMapping bool
}
// ScreenRectangle rounds outward and clips padding to the actual capture.
// Invalid, empty and entirely padded rectangles have no screen mapping.
func (transform Transform) ScreenRectangle(bounds image.Rectangle) (image.Rectangle, bool) {
content, screen := transform.ImageContent, transform.ScreenBounds
if !transform.HasScreenMapping || !valid_dimensions(content.Dx(), content.Dy()) ||
content.Min.X < 0 || content.Min.Y < 0 || content.Max.X > MaxPixels || content.Max.Y > MaxPixels ||
!valid_screen_rectangle(screen) {
return image.Rectangle{}, false
}
bounds = bounds.Intersect(content)
if bounds.Empty() {
return image.Rectangle{}, false
}
mapX := func(x int) float64 {
return float64(screen.Min.X) + float64(x-content.Min.X)*float64(screen.Dx())/float64(content.Dx())
}
mapY := func(y int) float64 {
return float64(screen.Min.Y) + float64(y-content.Min.Y)*float64(screen.Dy())/float64(content.Dy())
}
mapped := image.Rect(int(math.Floor(mapX(bounds.Min.X))), int(math.Floor(mapY(bounds.Min.Y))),
int(math.Ceil(mapX(bounds.Max.X))), int(math.Ceil(mapY(bounds.Max.Y))))
mapped = mapped.Intersect(screen)
return mapped, !mapped.Empty()
}
func valid_screen_rectangle(bounds image.Rectangle) bool {
return bounds.Min.X > math.MinInt32 && bounds.Min.Y > math.MinInt32 &&
bounds.Max.X <= math.MaxInt32 && bounds.Max.Y <= math.MaxInt32 &&
bounds.Max.X > bounds.Min.X && bounds.Max.Y > bounds.Min.Y &&
int64(bounds.Max.X)-int64(bounds.Min.X) <= MaxPixels &&
int64(bounds.Max.Y)-int64(bounds.Min.Y) <= MaxPixels
}
func valid_dimensions(width, height int) bool {
return width > 0 && height > 0 && height <= MaxPixels && width <= MaxPixels/height
}
func valid_word_bounds(bounds image.Rectangle, width, height int) bool {
return bounds.Min.X >= 0 && bounds.Min.Y >= 0 && !bounds.Empty() &&
bounds.Max.X <= width && bounds.Max.Y <= height
}
func rune_length(text string) int { return utf8.RuneCountInString(text) }
+90
View File
@@ -0,0 +1,90 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package ocr
import (
"image"
"math"
"testing"
)
func TestTransformScreenRectangle(t *testing.T) {
transform := Transform{
ImageContent: image.Rect(10, 20, 310, 221),
ScreenBounds: image.Rect(-120, -50, -20, 50),
HasScreenMapping: true,
}
for _, test := range []struct {
name string
bounds, expected image.Rectangle
valid bool
}{
{"full", transform.ImageContent, transform.ScreenBounds, true},
{"independent axes", image.Rect(13, 22, 19, 26), image.Rect(-119, -50, -117, -47), true},
{"partial padding", image.Rect(0, 0, 13, 22), image.Rect(-120, -50, -119, -49), true},
{"only padding", image.Rect(0, 0, 10, 20), image.Rectangle{}, false},
{"empty", image.Rectangle{}, image.Rectangle{}, false},
{"beyond image", image.Rect(400, 400, 410, 410), image.Rectangle{}, false},
} {
t.Run(test.name, func(t *testing.T) {
got, valid := transform.ScreenRectangle(test.bounds)
if got != test.expected || valid != test.valid {
t.Fatalf("mapped %v, %v; want %v, %v", got, valid, test.expected, test.valid)
}
})
}
transform.HasScreenMapping = false
if _, valid := transform.ScreenRectangle(transform.ImageContent); valid {
t.Fatal("clipboard image acquired screen coordinates")
}
transform.HasScreenMapping = true
transform.ScreenBounds = image.Rect(math.MinInt32, 0, math.MinInt32+100, 100)
if _, valid := transform.ScreenRectangle(transform.ImageContent); valid {
t.Fatal("unknown native origin acquired coordinates")
}
transform.ScreenBounds = image.Rect(-math.MaxInt32, 0, math.MaxInt32, 100)
if _, valid := transform.ScreenRectangle(transform.ImageContent); valid {
t.Fatal("overflow-sized screen rectangle acquired coordinates")
}
}
func TestRuneOffsetsAndWordHitTesting(t *testing.T) {
result := Result{
Text: "é 中文\ne\u0301",
Words: []Word{
{Text: "é", Span: Span{0, 1}},
{Text: "中文", Span: Span{2, 4}},
{Text: "e\u0301", Span: Span{5, 7}},
},
}
for offset, want := range []int{0, 2, 3, 6, 9, 10, 11, 13} {
got, valid := result.ByteOffset(offset)
if !valid || got != want {
t.Fatalf("byte offset %d = %d, %v; want %d", offset, got, valid, want)
}
}
for _, offset := range []int{-1, 1, 4, 7, 8} {
if word, valid := result.WordAt(offset); valid {
t.Fatalf("separator/end offset %d chose %q", offset, word.Text)
}
}
for _, offset := range []int{0, 2, 3, 5, 6} {
if _, valid := result.WordAt(offset); !valid {
t.Fatalf("word offset %d missing", offset)
}
}
for _, offset := range []int{-1, 8} {
if _, valid := result.ByteOffset(offset); valid {
t.Fatalf("invalid rune offset %d accepted", offset)
}
}
if offset, valid := (Result{}).ByteOffset(0); offset != 0 || !valid {
t.Fatal("empty document lost its end position")
}
phrase := Result{Text: "a b", Words: []Word{{Text: "a b", Span: Span{0, 3}}}}
if _, valid := phrase.WordAt(1); valid {
t.Fatal("whitespace inside an engine word acquired an activation target")
}
}
+74
View File
@@ -0,0 +1,74 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package ocr
import (
"context"
"image"
"os"
"strings"
"testing"
"time"
)
// This is a real installed-engine check, intentionally opt-in. Ordinary tests
// and the reader build must work with no tesseract executable or language data.
func TestNativeTesseract(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_OCR") != "1" {
t.Skip("set CTHULHU_NATIVE_OCR=1 to test installed Tesseract")
}
engine := NewEngine()
catalog, err := engine.Discover(context.Background())
if err != nil {
t.Fatal(err)
}
if language, err := catalog.resolve_language("eng"); err != nil || language != "eng" {
t.Fatal("the native painted-text fixture requires installed English data")
}
file, err := os.Open("testdata/painted-text.png")
if err != nil {
t.Fatal(err)
}
defer file.Close()
pixels, _, err := image.Decode(file)
if err != nil {
t.Fatal(err)
}
for _, layout := range []Layout{AutomaticPage, SingleBlock, SparseText} {
start := time.Now()
result, err := engine.Recognize(context.Background(), pixels, Options{Language: "eng", Layout: layout})
if err != nil {
t.Fatal(err)
}
// Accept ordinary spacing/case variation between engine/model versions;
// this simple fixture is a pipe/coordinate proof, not an accuracy corpus.
actual := strings.ToLower(strings.Join(strings.Fields(result.Text), " "))
if actual != "ocr test window open settings value 42" {
t.Fatalf("PSM %d did not read the known fixture: %q", layout, result.Text)
}
if len(result.Lines) != 3 || len(result.Words) != 7 {
t.Fatalf("PSM %d lost line/word structure: %+v", layout, result)
}
for _, word := range result.Words {
if !valid_word_bounds(word.Bounds, result.Width, result.Height) ||
word.Bounds.Min.X < 20 || word.Bounds.Min.Y < 15 || word.Bounds.Max.Y > 185 {
t.Fatalf("PSM %d word outside painted area: %+v", layout, word)
}
}
t.Logf("PSM %d: %d words, %d lines, total %s (includes catalog and PNG encoding)", layout, len(result.Words), len(result.Lines), time.Since(start))
}
scaled, err := engine.Recognize(context.Background(), pixels, Options{Language: "eng", Layout: SingleBlock, Scale: 2, Grayscale: true})
if err != nil {
t.Fatal(err)
}
if scaled.Width != pixels.Bounds().Dx()*2 || scaled.Height != pixels.Bounds().Dy()*2 || len(scaled.Words) == 0 {
t.Fatalf("scaled recognition did not retain image coordinates: %dx%d, %d words", scaled.Width, scaled.Height, len(scaled.Words))
}
for _, word := range scaled.Words {
if !valid_word_bounds(word.Bounds, scaled.Width, scaled.Height) {
t.Fatalf("invalid scaled word: %+v", word)
}
}
}
+30
View File
@@ -0,0 +1,30 @@
package ocr
import (
"strings"
"testing"
)
func TestPageMovementRetainsColumnAndBounds(t *testing.T) {
lines := make([]string, 45)
for index := range lines {
lines[index] = "abcd"
}
cursor := NewCursor(Result{Text: strings.Join(lines, "\n")})
cursor.Set(3)
if got := cursor.Move(PageUnit, 1); got.Line != "abcd" || got.Cursor != 3 || got.Offset != 103 {
t.Fatal(got)
}
if got := cursor.Move(PageUnit, 1); got.Offset != 203 {
t.Fatal(got)
}
if got := cursor.Move(PageUnit, 1); got.Offset != 223 {
t.Fatal(got)
}
if got := cursor.Move(PageUnit, 1); got.Moved {
t.Fatal(got)
}
if got := cursor.Move(PageUnit, -1); got.Offset != 123 {
t.Fatal(got)
}
}
+57
View File
@@ -0,0 +1,57 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package ocr
import (
"context"
"image"
"image/color"
)
// Preprocess returns owned pixels in recognition coordinates. Scale uses nearest
// neighbor sampling so recognized word boxes map back through the capture transform.
func Preprocess(ctx context.Context, source image.Image, options Options) (image.Image, error) {
if source == nil || !valid_dimensions(source.Bounds().Dx(), source.Bounds().Dy()) {
return nil, ErrInvalidImage
}
scale := options.Scale
if scale == 0 {
scale = 1
}
width, height := source.Bounds().Dx(), source.Bounds().Dy()
if scale < 1 || scale > 4 || height > MaxPixels/scale || !valid_dimensions(width*scale, height*scale) ||
options.Threshold < 0 || options.Threshold > 255 {
return nil, ErrLimit
}
if scale == 1 && !options.Grayscale && !options.Invert && !options.BlackWhite {
return source, nil
}
processed := image.NewRGBA(image.Rect(0, 0, width*scale, height*scale))
for y := 0; y < height*scale; y++ {
if y%32 == 0 && ctx.Err() != nil {
return nil, ctx.Err()
}
for x := 0; x < width*scale; x++ {
r, g, b, _ := source.At(source.Bounds().Min.X+x/scale, source.Bounds().Min.Y+y/scale).RGBA()
red, green, blue := uint8(r>>8), uint8(g>>8), uint8(b>>8)
gray := uint8((299*uint32(red) + 587*uint32(green) + 114*uint32(blue) + 500) / 1000)
if options.Grayscale || options.BlackWhite {
red, green, blue = gray, gray, gray
}
if options.Invert {
red, green, blue = 255-red, 255-green, 255-blue
}
if options.BlackWhite {
value := uint8(0)
if int(red) >= options.Threshold {
value = 255
}
red, green, blue = value, value, value
}
processed.SetRGBA(x, y, color.RGBA{R: red, G: green, B: blue, A: 255})
}
}
return processed, ctx.Err()
}
+47
View File
@@ -0,0 +1,47 @@
package ocr
import (
"context"
"image"
"image/color"
"testing"
)
func TestPreprocessScaleThresholdAndLimit(t *testing.T) {
source := image.NewRGBA(image.Rect(4, 5, 6, 6))
source.SetRGBA(4, 5, color.RGBA{R: 20, G: 20, B: 20, A: 255})
source.SetRGBA(5, 5, color.RGBA{R: 220, G: 220, B: 220, A: 255})
processed, err := Preprocess(context.Background(), source, Options{Scale: 2, BlackWhite: true, Threshold: 200})
if err != nil {
t.Fatal(err)
}
if processed.Bounds() != image.Rect(0, 0, 4, 2) {
t.Fatal(processed.Bounds())
}
for x := 0; x < 4; x++ {
r, _, _, _ := processed.At(x, 0).RGBA()
want := uint32(0)
if x >= 2 {
want = 0xffff
}
if r != want {
t.Fatalf("pixel %d = %d", x, r)
}
}
if _, err := Preprocess(context.Background(), source, Options{Scale: 5}); err != ErrLimit {
t.Fatal(err)
}
}
func TestPreprocessInversion(t *testing.T) {
source := image.NewRGBA(image.Rect(0, 0, 1, 1))
source.SetRGBA(0, 0, color.RGBA{R: 10, G: 20, B: 30, A: 255})
processed, err := Preprocess(context.Background(), source, Options{Invert: true})
if err != nil {
t.Fatal(err)
}
r, g, b, _ := processed.At(0, 0).RGBA()
if r != 245*257 || g != 235*257 || b != 225*257 {
t.Fatalf("inverted pixel %d %d %d", r, g, b)
}
}
+107
View File
@@ -0,0 +1,107 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package ocr
import (
"bytes"
"context"
"errors"
"os"
"os/exec"
"strings"
"syscall"
"time"
)
// bounded_buffer never retains more than limit bytes. Child-output overflow
// cancels its process immediately instead of leaving a stopped pipe reader.
type bounded_buffer struct {
data bytes.Buffer
limit int
ctx context.Context
cancel context.CancelFunc
overflow bool
}
func (buffer *bounded_buffer) Write(data []byte) (int, error) {
if err := buffer.ctx.Err(); err != nil {
return 0, err
}
if len(data) > buffer.limit-buffer.Len() {
buffer.overflow = true
if buffer.cancel != nil {
buffer.cancel()
}
return 0, ErrLimit
}
return buffer.data.Write(data)
}
func (buffer *bounded_buffer) Bytes() []byte { return buffer.data.Bytes() }
func (buffer *bounded_buffer) Len() int { return buffer.data.Len() }
func (engine *Engine) run(parent context.Context, input []byte, limit int, args ...string) ([]byte, []byte, error) {
return run_process(parent, engine.command, input, limit, args...)
}
func run_process(parent context.Context, factory func(context.Context, ...string) *exec.Cmd, input []byte, limit int, args ...string) ([]byte, []byte, error) {
ctx, cancel := context.WithCancel(parent)
defer cancel()
command := factory(ctx, args...)
// This reader targets Linux/Xorg. Own the whole process group, including
// descendants that might inherit pipes from a wrapper executable.
command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
kill := func() error {
if command.Process == nil {
return os.ErrProcessDone
}
err := syscall.Kill(-command.Process.Pid, syscall.SIGKILL)
if errors.Is(err, syscall.ESRCH) {
return os.ErrProcessDone
}
return err
}
command.Cancel = kill
command.WaitDelay = time.Second
command.Env = process_environment(command.Env)
command.Stdin = bytes.NewReader(input)
output := &bounded_buffer{limit: limit, ctx: ctx, cancel: cancel}
diagnostics := &bounded_buffer{limit: maxStderrBytes, ctx: ctx, cancel: cancel}
command.Stdout, command.Stderr = output, diagnostics
err := command.Run()
// WaitDelay closes inherited pipes after a bound, but os/exec only kills the
// direct child then. Retire any remaining group before admitting another job.
if command.Process != nil {
_ = kill()
}
if parent.Err() != nil {
return nil, nil, parent.Err()
}
if output.overflow || diagnostics.overflow {
return nil, nil, ErrLimit
}
if err != nil {
if errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist) || errors.Is(err, os.ErrPermission) {
return nil, nil, ErrUnavailable
}
// Never forward native stderr: it can contain captured text or paths.
return nil, diagnostics.Bytes(), ErrRecognition
}
return output.Bytes(), diagnostics.Bytes(), nil
}
func process_environment(environment []string) []string {
if environment == nil {
environment = os.Environ()
}
result := make([]string, 0, len(environment)+2)
for _, entry := range environment {
if !strings.HasPrefix(entry, "LC_ALL=") && !strings.HasPrefix(entry, "OMP_THREAD_LIMIT=") {
result = append(result, entry)
}
}
// Stable CLI diagnostics; avoid using every core for a desktop OCR request.
return append(result, "LC_ALL=C", "OMP_THREAD_LIMIT=1")
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+138
View File
@@ -0,0 +1,138 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package ocr
import (
"bufio"
"errors"
"fmt"
"image"
"io"
"math"
"strconv"
"strings"
"unicode"
"unicode/utf8"
)
const tsvHeader = "level\tpage_num\tblock_num\tpar_num\tline_num\tword_num\tleft\ttop\twidth\theight\tconf\ttext"
// ParseTSV assembles text and coordinates from a single Tesseract TSV stream.
// Malformed output is rejected atomically, with no recognized text in errors.
// The supplied dimensions must describe the actual recognition image.
func ParseTSV(input io.Reader, width, height int) (Result, error) {
if !valid_dimensions(width, height) {
return Result{}, ErrInvalidImage
}
limited := &io.LimitedReader{R: input, N: maxTSVBytes + 1}
scanner := bufio.NewScanner(limited)
scanner.Buffer(make([]byte, 4096), 64<<10)
if !scanner.Scan() || scanner.Text() != tsvHeader {
if errors.Is(scanner.Err(), bufio.ErrTooLong) {
return Result{}, ErrLimit
}
return Result{}, fmt.Errorf("%w: TSV header", ErrInvalidOutput)
}
result := Result{Width: width, Height: height}
var text strings.Builder
closedLines := make(map[LineID]bool)
offset, rows := 0, 0
for scanner.Scan() {
rows++
if rows > maxRows || limited.N <= 0 {
return Result{}, ErrLimit
}
fields := strings.SplitN(scanner.Text(), "\t", 12)
if len(fields) != 12 {
return Result{}, invalid_row(rows)
}
var numbers [10]int
for index := range numbers {
number, err := strconv.ParseInt(fields[index], 10, 32)
if err != nil || number < 0 {
return Result{}, invalid_row(rows)
}
numbers[index] = int(number)
}
level := numbers[0]
if level < 1 || level > 5 || numbers[1] < 1 {
return Result{}, invalid_row(rows)
}
for index := 2; index <= 5; index++ {
if (index <= level && numbers[index] < 1) || (index > level && numbers[index] != 0) {
return Result{}, invalid_row(rows)
}
}
confidence, err := strconv.ParseFloat(fields[10], 64)
if err != nil || math.IsNaN(confidence) || math.IsInf(confidence, 0) || confidence < -1 || confidence > 100 {
return Result{}, invalid_row(rows)
}
// Subtraction avoids overflowing 32-bit additions on ARM builds.
left, top, boxWidth, boxHeight := numbers[6], numbers[7], numbers[8], numbers[9]
if left > width || top > height || boxWidth > width-left || boxHeight > height-top {
return Result{}, invalid_row(rows)
}
wordText := fields[11]
if !utf8.ValidString(wordText) || strings.ContainsFunc(wordText, unicode.IsControl) {
return Result{}, invalid_row(rows)
}
if level != 5 {
if wordText != "" || confidence != -1 {
return Result{}, invalid_row(rows)
}
continue
}
if strings.TrimSpace(wordText) == "" {
continue
}
bounds := image.Rect(left, top, left+boxWidth, top+boxHeight)
if !valid_word_bounds(bounds, width, height) || strings.TrimSpace(wordText) != wordText {
return Result{}, invalid_row(rows)
}
separatorBytes := 0
if len(result.Words) != 0 {
separatorBytes = 1
}
if len(result.Words) >= maxWords || text.Len()+len(wordText)+separatorBytes > MaxTextBytes {
return Result{}, ErrLimit
}
id := LineID{numbers[1], numbers[2], numbers[3], numbers[4]}
if len(result.Lines) == 0 || result.Lines[len(result.Lines)-1].ID != id {
if closedLines[id] {
return Result{}, invalid_row(rows)
}
if len(result.Lines) != 0 {
closedLines[result.Lines[len(result.Lines)-1].ID] = true
text.WriteByte('\n')
offset++
}
result.Lines = append(result.Lines, Line{ID: id, Span: Span{Start: offset}, WordStart: len(result.Words)})
} else {
text.WriteByte(' ')
offset++
}
start := offset
text.WriteString(wordText)
offset += rune_length(wordText)
result.Words = append(result.Words, Word{wordText, confidence, bounds, Span{start, offset}})
line := &result.Lines[len(result.Lines)-1]
line.End, line.WordEnd = offset, len(result.Words)
}
if limited.N <= 0 {
return Result{}, ErrLimit
}
if scanner.Err() != nil {
if errors.Is(scanner.Err(), bufio.ErrTooLong) {
return Result{}, ErrLimit
}
return Result{}, fmt.Errorf("%w: reading TSV", ErrInvalidOutput)
}
result.Text = text.String()
return result, nil
}
func invalid_row(row int) error {
return fmt.Errorf("%w: TSV row %d", ErrInvalidOutput, row)
}
+135
View File
@@ -0,0 +1,135 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package ocr
import (
"errors"
"image"
"strings"
"testing"
"unicode/utf8"
)
func TestParseTSVHierarchyUnicodeAndEngineOrder(t *testing.T) {
data := tsvHeader + "\n" +
"1\t1\t0\t0\t0\t0\t0\t0\t200\t100\t-1\t\n" +
"2\t1\t1\t0\t0\t0\t0\t0\t200\t100\t-1\t\n" +
"3\t1\t1\t1\t0\t0\t0\t0\t200\t100\t-1\t\n" +
"4\t1\t1\t1\t1\t0\t0\t0\t200\t100\t-1\t\n" +
"5\t1\t1\t1\t1\t1\t100\t50\t30\t20\t0\té\n" +
"5\t1\t1\t1\t1\t2\t10\t50\t30\t20\t-1\t中文\n" +
"5\t1\t1\t2\t1\t1\t10\t10\t30\t20\t95.25\te\u0301\n" +
"5\t2\t1\t1\t1\t1\t10\t10\t30\t20\t85\tNext"
result, err := ParseTSV(strings.NewReader(data), 200, 100)
if err != nil {
t.Fatal(err)
}
if result.Text != "é 中文\ne\u0301\nNext" || len(result.Lines) != 3 || len(result.Words) != 4 {
t.Fatalf("wrong assembly: %+v", result)
}
if result.Words[1].Span != (Span{2, 4}) || result.Words[2].Span != (Span{5, 7}) ||
result.Words[1].Confidence != -1 || result.Words[0].Confidence != 0 ||
result.Words[0].Bounds != image.Rect(100, 50, 130, 70) {
t.Fatalf("lost Unicode offsets, low confidence or coordinates: %+v", result.Words)
}
if result.Lines[1].ID != (LineID{1, 1, 2, 1}) || result.Lines[1].Span != (Span{5, 7}) ||
result.Lines[1].WordStart != 2 || result.Lines[1].WordEnd != 3 {
t.Fatalf("lost paragraph/line ownership: %+v", result.Lines)
}
}
func TestParseTSVEmptyAndQuotedWords(t *testing.T) {
for _, data := range []string{tsvHeader + "\n", tsvHeader + "\r\n1\t1\t0\t0\t0\t0\t0\t0\t100\t100\t-1\t\r\n"} {
result, err := ParseTSV(strings.NewReader(data), 100, 100)
if err != nil || result.Text != "" || len(result.Words) != 0 {
t.Fatalf("empty result: %+v, %v", result, err)
}
}
data := tsvHeader + "\n5\t1\t1\t1\t1\t1\t0\t0\t10\t10\t99\t\"word\"\n"
result, err := ParseTSV(strings.NewReader(data), 100, 100)
if err != nil || result.Text != `"word"` {
t.Fatalf("TSV word treated as CSV quoting: %+v, %v", result, err)
}
}
func TestParseTSVRejectsMalformedOutputAtomically(t *testing.T) {
row := "5\t1\t1\t1\t1\t1\t0\t0\t10\t10\t90\tPrivate fixture text"
for _, bad := range []string{
"missing fields",
strings.Replace(row, "5\t", "6\t", 1),
strings.Replace(row, "5\t1", "5\t0", 1),
strings.Replace(row, "0\t0\t10\t10", "-1\t0\t10\t10", 1),
strings.Replace(row, "0\t0\t10\t10", "95\t0\t10\t10", 1),
strings.Replace(row, "0\t0\t10\t10", "0\t0\t0\t10", 1),
strings.Replace(row, "0\t0\t10\t10", "0\t0\t2147483647\t10", 1),
strings.Replace(row, "90\t", "NaN\t", 1),
strings.Replace(row, "90\t", "+Inf\t", 1),
strings.Replace(row, "90\t", "101\t", 1),
strings.Replace(row, "90\t", "-2\t", 1),
row + "\x00",
row + "\xff",
row + "\textra field",
row + " ",
} {
result, err := ParseTSV(strings.NewReader(tsvHeader+"\n"+row+"\n"+bad+"\n"), 100, 100)
if !errors.Is(err, ErrInvalidOutput) || result.Text != "" || len(result.Words) != 0 {
t.Fatalf("accepted malformed row or partial document: %+v, %v", result, err)
}
if strings.Contains(err.Error(), "Private") {
t.Fatal("recognized text leaked into error")
}
}
if _, err := ParseTSV(strings.NewReader("not a TSV header"), 100, 100); !errors.Is(err, ErrInvalidOutput) {
t.Fatal("invalid header accepted")
}
if _, err := ParseTSV(strings.NewReader(tsvHeader), MaxPixels, 2); !errors.Is(err, ErrInvalidImage) {
t.Fatal("pixel limit ignored")
}
}
func TestParseTSVLimitsAndRevisitedLine(t *testing.T) {
if _, err := ParseTSV(strings.NewReader(tsvHeader+"\n"+strings.Repeat("x", 64<<10)), 100, 100); !errors.Is(err, ErrLimit) {
t.Fatalf("unbounded row: %v", err)
}
row := "5\t1\t1\t1\t1\t1\t0\t0\t10\t10\t90\tword\n"
secondLine := strings.Replace(row, "1\t1\t1\t1\t1\t", "1\t1\t1\t2\t1\t", 1)
if _, err := ParseTSV(strings.NewReader(tsvHeader+"\n"+row+secondLine+row), 100, 100); !errors.Is(err, ErrInvalidOutput) {
t.Fatalf("revisited line accepted: %v", err)
}
largeWord := "5\t1\t1\t1\t1\t1\t0\t0\t10\t10\t90\t" + strings.Repeat("x", 60<<10) + "\n"
if _, err := ParseTSV(strings.NewReader(tsvHeader+"\n"+strings.Repeat(largeWord, 20)), 100, 100); !errors.Is(err, ErrLimit) {
t.Fatalf("text limit ignored: %v", err)
}
if _, err := ParseTSV(strings.NewReader(tsvHeader+"\n"+strings.Repeat(row, maxWords+1)), 100, 100); !errors.Is(err, ErrLimit) {
t.Fatalf("word limit ignored: %v", err)
}
}
func FuzzParseTSV(f *testing.F) {
f.Add(tsvHeader + "\n5\t1\t1\t1\t1\t1\t1\t1\t10\t10\t90\t中文\n")
f.Add("")
f.Fuzz(func(t *testing.T, data string) {
result, err := ParseTSV(strings.NewReader(data), 200, 100)
if err != nil {
if result.Text != "" || len(result.Words) != 0 {
t.Fatal("partial result escaped a parser failure")
}
return
}
if !utf8.ValidString(result.Text) || len(result.Text) > MaxTextBytes {
t.Fatal("invalid document text")
}
lastEnd := 0
for _, word := range result.Words {
start, startOK := result.ByteOffset(word.Start)
end, endOK := result.ByteOffset(word.End)
if !startOK || !endOK || word.Start < lastEnd || word.End <= word.Start ||
result.Text[start:end] != word.Text || !valid_word_bounds(word.Bounds, result.Width, result.Height) {
t.Fatal("invalid word map")
}
lastEnd = word.End
}
})
}