feat(imageutil): input decoding and grid centroid geometry

This commit is contained in:
2026-06-07 13:12:24 +01:00
parent d008ba1424
commit d438673a1f
2 changed files with 170 additions and 0 deletions
+118
View File
@@ -0,0 +1,118 @@
// Package imageutil decodes image/audio inputs (base64, data-URI, file path,
// http(s) URL) and computes grid geometry for tile-based captcha solving.
package imageutil
import (
"context"
"encoding/base64"
"fmt"
"io"
"net/http"
"os"
"regexp"
"strings"
"time"
)
var dataURIRe = regexp.MustCompile(`^data:([^;,]+)(;base64)?,(.*)$`)
// Point is a pixel coordinate.
type Point struct {
X int
Y int
}
// Decode resolves input (data-URI | bare base64 | file path | http(s) URL) to
// raw bytes plus a best-effort media type sniffed from the content.
func Decode(input string) ([]byte, string, error) {
input = strings.TrimSpace(input)
switch {
case strings.HasPrefix(input, "data:"):
m := dataURIRe.FindStringSubmatch(input)
if m == nil {
return nil, "", fmt.Errorf("malformed data URI")
}
var data []byte
var err error
if m[2] == ";base64" {
data, err = base64.StdEncoding.DecodeString(m[3])
} else {
data = []byte(m[3])
}
if err != nil {
return nil, "", err
}
return data, m[1], nil
case strings.HasPrefix(input, "http://"), strings.HasPrefix(input, "https://"):
return fetchURL(input)
default:
// File path if it exists; otherwise treat as bare base64.
if data, err := os.ReadFile(input); err == nil { //nolint:gosec // path is caller-provided automation input
return data, sniff(data), nil
}
data, err := base64.StdEncoding.DecodeString(input)
if err != nil {
return nil, "", fmt.Errorf("input is not a data-URI, URL, readable file, or base64: %w", err)
}
return data, sniff(data), nil
}
}
func fetchURL(url string) ([]byte, string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, "", err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, "", err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 300 {
return nil, "", fmt.Errorf("fetch %s: HTTP %d", url, resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
if err != nil {
return nil, "", err
}
mt := resp.Header.Get("Content-Type")
if mt == "" {
mt = sniff(data)
}
return data, mt, nil
}
// sniff returns a media type from magic bytes, defaulting to image/png.
func sniff(b []byte) string {
switch {
case len(b) >= 4 && b[0] == 0x89 && b[1] == 0x50 && b[2] == 0x4e && b[3] == 0x47:
return "image/png"
case len(b) >= 3 && b[0] == 0xff && b[1] == 0xd8 && b[2] == 0xff:
return "image/jpeg"
case len(b) >= 4 && b[0] == 'R' && b[1] == 'I' && b[2] == 'F' && b[3] == 'F':
return "audio/wav"
case len(b) >= 6 && string(b[:3]) == "ID3":
return "audio/mpeg"
default:
return "image/png"
}
}
// GridCentroids returns the pixel centre of each tile for a rows x cols grid
// laid over a width x height image, in row-major order (index 0 = top-left).
func GridCentroids(width, height, rows, cols int) []Point {
pts := make([]Point, 0, rows*cols)
cw := float64(width) / float64(cols)
ch := float64(height) / float64(rows)
for r := 0; r < rows; r++ {
for c := 0; c < cols; c++ {
pts = append(pts, Point{
X: int(cw*float64(c) + cw/2),
Y: int(ch*float64(r) + ch/2),
})
}
}
return pts
}
+52
View File
@@ -0,0 +1,52 @@
package imageutil
import (
"encoding/base64"
"os"
"path/filepath"
"testing"
)
func TestDecode(t *testing.T) {
raw := []byte{0x89, 0x50, 0x4e, 0x47} // PNG magic prefix
b64 := base64.StdEncoding.EncodeToString(raw)
t.Run("data uri", func(t *testing.T) {
data, mt, err := Decode("data:image/png;base64," + b64)
if err != nil || mt != "image/png" || string(data) != string(raw) {
t.Fatalf("data=%v mt=%q err=%v", data, mt, err)
}
})
t.Run("bare base64 sniffs png", func(t *testing.T) {
data, mt, err := Decode(b64)
if err != nil || mt != "image/png" || string(data) != string(raw) {
t.Fatalf("data=%v mt=%q err=%v", data, mt, err)
}
})
t.Run("file path", func(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "x.png")
if err := os.WriteFile(p, raw, 0o600); err != nil {
t.Fatal(err)
}
data, mt, err := Decode(p)
if err != nil || mt != "image/png" || string(data) != string(raw) {
t.Fatalf("data=%v mt=%q err=%v", data, mt, err)
}
})
}
func TestGridCentroids(t *testing.T) {
pts := GridCentroids(300, 300, 3, 3)
if len(pts) != 9 {
t.Fatalf("want 9 centroids, got %d", len(pts))
}
// index 0 = top-left tile centre at (50,50)
if pts[0].X != 50 || pts[0].Y != 50 {
t.Fatalf("centroid[0]=%+v", pts[0])
}
// index 4 = centre tile at (150,150)
if pts[4].X != 150 || pts[4].Y != 150 {
t.Fatalf("centroid[4]=%+v", pts[4])
}
}