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,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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user