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,89 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Package ocrcapture is used only by the short-lived, headless native helper.
|
||||
// The reader's worker uses ocr.Capturer and never calls GTK/X11 capture in-process.
|
||||
package ocrcapture
|
||||
|
||||
/*
|
||||
#cgo pkg-config: gtk+-3.0 x11
|
||||
#include "native.h"
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"image"
|
||||
"image/png"
|
||||
"io"
|
||||
"os"
|
||||
"runtime"
|
||||
"unsafe"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/ocr"
|
||||
)
|
||||
|
||||
func Run(ctx context.Context, input io.Reader, output io.Writer) error {
|
||||
request, err := ocr.ReadCaptureRequest(input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
header := ocr.CaptureHeader{Request: request, Status: "unavailable"}
|
||||
runtime.LockOSThread()
|
||||
defer runtime.UnlockOSThread()
|
||||
// This backend captures physical X11 pixels, including Xwayland when it
|
||||
// provides them. Native display/source checks decide availability.
|
||||
_ = os.Setenv("GDK_BACKEND", "x11")
|
||||
_ = os.Setenv("NO_AT_BRIDGE", "1")
|
||||
kind := map[ocr.SourceKind]int{ocr.ObjectSource: 1, ocr.WindowSource: 2, ocr.DesktopSource: 3, ocr.ClipboardSource: 4}[request.Kind]
|
||||
native := C.cthulhu_ocr_capture(C.int(kind), C.ulong(request.Foreground), C.ulong(request.Window),
|
||||
C.int(request.Bounds.Min.X), C.int(request.Bounds.Min.Y), C.int(request.Bounds.Max.X), C.int(request.Bounds.Max.Y))
|
||||
defer C.cthulhu_ocr_capture_free(&native)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if native.status != 0 {
|
||||
header.Status = map[int]string{1: "unavailable", 2: "source", 3: "changed", 4: "limit"}[int(native.status)]
|
||||
return ocr.WriteCaptureResponse(output, header, nil)
|
||||
}
|
||||
header.Status = ""
|
||||
var pixels image.Image
|
||||
if request.Kind == ocr.ClipboardSource {
|
||||
data := C.GoBytes(unsafe.Pointer(native.clipboard), native.clipboardLength)
|
||||
config, decodeErr := png.DecodeConfig(bytes.NewReader(data))
|
||||
if decodeErr != nil {
|
||||
header.Status = "source"
|
||||
} else if config.Width <= 0 || config.Height <= 0 || config.Height > ocr.MaxPixels || config.Width > ocr.MaxPixels/config.Height {
|
||||
header.Status = "limit"
|
||||
} else {
|
||||
pixels, decodeErr = png.Decode(bytes.NewReader(data))
|
||||
if decodeErr != nil {
|
||||
header.Status = "source"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
header.SourceWindow = uint64(native.sourceWindow)
|
||||
header.RootBounds = image.Rect(0, 0, int(native.rootWidth), int(native.rootHeight))
|
||||
header.SourceBounds = image.Rect(int(native.sourceX), int(native.sourceY), int(native.sourceX)+int(native.sourceWidth), int(native.sourceY)+int(native.sourceHeight))
|
||||
header.ScreenBounds = image.Rect(int(native.x), int(native.y), int(native.x)+int(native.width), int(native.y)+int(native.height))
|
||||
width, height := int(native.width), int(native.height)
|
||||
stride := int(C.gdk_pixbuf_get_rowstride(native.pixels))
|
||||
data := C.GoBytes(unsafe.Pointer(C.gdk_pixbuf_get_pixels(native.pixels)), C.int((height-1)*stride+width*3))
|
||||
owned := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
for row := range height {
|
||||
for column := range width {
|
||||
source := row*stride + column*3
|
||||
target := row*owned.Stride + column*4
|
||||
copy(owned.Pix[target:target+3], data[source:source+3])
|
||||
owned.Pix[target+3] = 255
|
||||
}
|
||||
}
|
||||
pixels = owned
|
||||
}
|
||||
return ocr.WriteCaptureResponse(output, header, pixels)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "native.h"
|
||||
#include <gdk/gdkx.h>
|
||||
#include <X11/Xatom.h>
|
||||
#include <X11/Xutil.h>
|
||||
#include <stdint.h>
|
||||
|
||||
// Status codes: unavailable, source unavailable, source changed, limit.
|
||||
static Window active_window(Display *display, Window root) {
|
||||
Atom type;
|
||||
int format;
|
||||
unsigned long count, remaining;
|
||||
unsigned char *data = NULL;
|
||||
Window window = None;
|
||||
if (XGetWindowProperty(display, root,
|
||||
XInternAtom(display, "_NET_ACTIVE_WINDOW", False), 0, 1, False,
|
||||
XA_WINDOW, &type, &format, &count, &remaining, &data) == Success &&
|
||||
data && type == XA_WINDOW && format == 32 && count == 1)
|
||||
window = *(Window *)data;
|
||||
if (data) XFree(data);
|
||||
return window;
|
||||
}
|
||||
|
||||
static int is_descendant(Display *display, Window window, Window ancestor) {
|
||||
for (int depth = 0; window && depth < 64; depth++) {
|
||||
if (window == ancestor) return 1;
|
||||
Window root, parent, *children = NULL;
|
||||
unsigned int count;
|
||||
int ok = XQueryTree(display, window, &root, &parent, &children, &count);
|
||||
if (children) XFree(children);
|
||||
if (!ok || parent == window) break;
|
||||
window = parent;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int window_bounds(Display *display, Window root, Window window,
|
||||
int *x, int *y, int *width, int *height) {
|
||||
XWindowAttributes attributes;
|
||||
Window child;
|
||||
if (!XGetWindowAttributes(display, window, &attributes) ||
|
||||
attributes.map_state != IsViewable || attributes.class != InputOutput ||
|
||||
!XTranslateCoordinates(display, window, root, 0, 0, x, y, &child)) return 0;
|
||||
*width = attributes.width;
|
||||
*height = attributes.height;
|
||||
return *width > 0 && *height > 0;
|
||||
}
|
||||
|
||||
static guchar component(unsigned long pixel, unsigned long mask) {
|
||||
if (!mask) return 0;
|
||||
unsigned long value = pixel & mask;
|
||||
while (!(mask & 1)) { mask >>= 1; value >>= 1; }
|
||||
return (guchar)(((uint64_t)value * 255 + mask / 2) / mask);
|
||||
}
|
||||
|
||||
OCRCapture cthulhu_ocr_capture(int kind, unsigned long foreground,
|
||||
unsigned long window, int x, int y, int right, int bottom) {
|
||||
OCRCapture result = {.status = 1};
|
||||
if (!gtk_init_check(NULL, NULL)) return result;
|
||||
GdkDisplay *gdkDisplay = gdk_display_get_default();
|
||||
if (!GDK_IS_X11_DISPLAY(gdkDisplay)) return result;
|
||||
Display *display = gdk_x11_display_get_xdisplay(gdkDisplay);
|
||||
Window root = DefaultRootWindow(display);
|
||||
gdk_x11_display_error_trap_push(gdkDisplay);
|
||||
result.status = 2;
|
||||
if (active_window(display, root) != foreground) { result.status = 3; goto done; }
|
||||
Window source = window ? window : foreground;
|
||||
int sourceX, sourceY, sourceWidth, sourceHeight;
|
||||
if (!is_descendant(display, source, foreground) || !window_bounds(display, root,
|
||||
source, &sourceX, &sourceY, &sourceWidth, &sourceHeight)) goto done;
|
||||
|
||||
if (kind == 4) {
|
||||
// Accept PNG image data only. Never fetch text, URIs or private formats.
|
||||
GtkClipboard *clipboard = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD);
|
||||
GtkSelectionData *selection = gtk_clipboard_wait_for_contents(clipboard,
|
||||
gdk_atom_intern_static_string("image/png"));
|
||||
if (!selection) goto done;
|
||||
int length = gtk_selection_data_get_length(selection);
|
||||
if (length > 64 * 1024 * 1024) result.status = 4;
|
||||
else if (length > 0 && gtk_selection_data_get_format(selection) == 8) {
|
||||
result.clipboard = g_memdup2(gtk_selection_data_get_data(selection), length);
|
||||
result.clipboardLength = length;
|
||||
result.status = 0;
|
||||
}
|
||||
gtk_selection_data_free(selection);
|
||||
} else {
|
||||
XWindowAttributes rootAttributes;
|
||||
if (!XGetWindowAttributes(display, root, &rootAttributes)) goto done;
|
||||
// TrueColor pixels are physical root pixels even with GDK_SCALE=2.
|
||||
// Indexed/direct-color visuals would require a different color contract.
|
||||
if (rootAttributes.visual->class != TrueColor) { result.status = 1; goto done; }
|
||||
result.rootWidth = rootAttributes.width;
|
||||
result.rootHeight = rootAttributes.height;
|
||||
result.sourceWindow = kind == 3 ? 0 : source;
|
||||
result.sourceX = kind == 3 ? 0 : sourceX;
|
||||
result.sourceY = kind == 3 ? 0 : sourceY;
|
||||
result.sourceWidth = kind == 3 ? result.rootWidth : sourceWidth;
|
||||
result.sourceHeight = kind == 3 ? result.rootHeight : sourceHeight;
|
||||
int64_t left = result.sourceX, top = result.sourceY;
|
||||
int64_t endX = left + result.sourceWidth, endY = top + result.sourceHeight;
|
||||
if (kind == 1) {
|
||||
left = MAX(left, x); top = MAX(top, y);
|
||||
endX = MIN(endX, right); endY = MIN(endY, bottom);
|
||||
}
|
||||
left = MAX(left, 0); top = MAX(top, 0);
|
||||
endX = MIN(endX, result.rootWidth); endY = MIN(endY, result.rootHeight);
|
||||
if (endX <= left || endY <= top) goto done;
|
||||
int64_t width = endX - left, height = endY - top;
|
||||
if (height > 16000000 || width > 16000000 / height) { result.status = 4; goto done; }
|
||||
result.x = left; result.y = top; result.width = width; result.height = height;
|
||||
XImage *pixels = XGetImage(display, root, result.x, result.y,
|
||||
result.width, result.height, AllPlanes, ZPixmap);
|
||||
if (!pixels) { result.status = 1; goto done; }
|
||||
result.pixels = gdk_pixbuf_new(GDK_COLORSPACE_RGB, FALSE, 8, result.width, result.height);
|
||||
if (!result.pixels) { XDestroyImage(pixels); result.status = 1; goto done; }
|
||||
guchar *rgb = gdk_pixbuf_get_pixels(result.pixels);
|
||||
int stride = gdk_pixbuf_get_rowstride(result.pixels);
|
||||
for (int row = 0; row < result.height; row++) {
|
||||
for (int column = 0; column < result.width; column++) {
|
||||
unsigned long pixel = XGetPixel(pixels, column, row);
|
||||
guchar *target = rgb + row * stride + column * 3;
|
||||
target[0] = component(pixel, rootAttributes.visual->red_mask);
|
||||
target[1] = component(pixel, rootAttributes.visual->green_mask);
|
||||
target[2] = component(pixel, rootAttributes.visual->blue_mask);
|
||||
}
|
||||
}
|
||||
XDestroyImage(pixels);
|
||||
result.status = 0;
|
||||
}
|
||||
// Reject movement, destruction or a foreground switch during pixel/clipboard
|
||||
// transfer. Later activation must still recapture/revalidate independently.
|
||||
if (result.status == 0) {
|
||||
int nextX, nextY, nextWidth, nextHeight;
|
||||
if (active_window(display, root) != foreground ||
|
||||
!window_bounds(display, root, source, &nextX, &nextY, &nextWidth, &nextHeight) ||
|
||||
nextX != sourceX || nextY != sourceY ||
|
||||
nextWidth != sourceWidth || nextHeight != sourceHeight) result.status = 3;
|
||||
}
|
||||
done:
|
||||
XSync(display, False);
|
||||
if (gdk_x11_display_error_trap_pop(gdkDisplay)) result.status = 2;
|
||||
if (result.status) cthulhu_ocr_capture_free(&result);
|
||||
return result;
|
||||
}
|
||||
|
||||
void cthulhu_ocr_capture_free(OCRCapture *capture) {
|
||||
if (capture->pixels) g_object_unref(capture->pixels);
|
||||
g_free(capture->clipboard);
|
||||
capture->pixels = NULL;
|
||||
capture->clipboard = NULL;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#pragma once
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
typedef struct {
|
||||
int status;
|
||||
unsigned long sourceWindow;
|
||||
int rootWidth, rootHeight;
|
||||
int sourceX, sourceY, sourceWidth, sourceHeight;
|
||||
int x, y, width, height;
|
||||
GdkPixbuf *pixels;
|
||||
guchar *clipboard;
|
||||
int clipboardLength;
|
||||
} OCRCapture;
|
||||
|
||||
OCRCapture cthulhu_ocr_capture(int kind, unsigned long foreground,
|
||||
unsigned long window, int x, int y, int right, int bottom);
|
||||
void cthulhu_ocr_capture_free(OCRCapture *capture);
|
||||
Reference in new issue
Block a user