diff --git a/README.md b/README.md index 2ce3d7a..6817681 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,9 @@ mobilecli screenshot --device --scale 0.5 # Limit the largest dimension to 800 pixels, keeping aspect ratio mobilecli screenshot --device --max-size 800 +# Crop to a region (x,y,width,height in screen points), applied before scaling +mobilecli screenshot --device --clip 10,80,300,200 + # Save to specific path mobilecli screenshot --device --output screenshot.png diff --git a/agents/android/java/Screenshot.java b/agents/android/java/Screenshot.java index e288120..ec1748f 100644 --- a/agents/android/java/Screenshot.java +++ b/agents/android/java/Screenshot.java @@ -24,10 +24,13 @@ * Usage: * adb shell CLASSPATH=/data/local/tmp/mobilecli.dex app_process / \ * com.mobilenext.mobilecli.Screenshot [--format png|jpeg] [--quality 1-100] \ - * [--scale 0.0-1.0] [--max-size N] + * [--scale 0.0-1.0] [--max-size N] [--clip x,y,w,h --screen-width N] * * --max-size caps max(width, height) at N pixels keeping aspect ratio and * takes precedence over --scale. Neither ever upscales. + * --clip crops to the given rect (in screen points) before scaling; + * --screen-width (screen width in points) is required with it to map + * points to pixels of the captured bitmap. */ public class Screenshot { @@ -38,6 +41,8 @@ public static void main(String[] args) { double scale = 1.0; int maxSize = 0; String outPath = null; + int[] clip = null; + int screenWidth = 0; for (int i = 0; i + 1 < args.length; i += 2) { String flag = args[i]; @@ -50,6 +55,10 @@ public static void main(String[] args) { scale = Double.parseDouble(value); } else if (flag.equals("--max-size")) { maxSize = Integer.parseInt(value); + } else if (flag.equals("--clip")) { + clip = parseClip(value); + } else if (flag.equals("--screen-width")) { + screenWidth = Integer.parseInt(value); } else if (flag.equals("--out")) { outPath = value; } else { @@ -59,6 +68,9 @@ public static void main(String[] args) { exemptHiddenApis(); Bitmap bitmap = takeScreenshot(); + if (clip != null) { + bitmap = clipBitmap(bitmap, clip, screenWidth); + } bitmap = resize(bitmap, scale, maxSize); Bitmap.CompressFormat compressFormat = format.equals("jpeg") @@ -171,6 +183,42 @@ private static void connect(UiAutomation automation) throws Exception { } } + private static int[] parseClip(String value) { + String[] parts = value.split(",", -1); + if (parts.length != 4) { + throw new IllegalArgumentException("invalid --clip, expected x,y,width,height"); + } + int[] clip = new int[4]; + for (int i = 0; i < 4; i++) { + clip[i] = Integer.parseInt(parts[i]); + } + return clip; + } + + // Crops to clip {x, y, width, height} given in screen points, mapped to + // bitmap pixels via screenWidthPoints. Rects partially outside the bitmap + // are clamped (UI hierarchy bounds can overhang the screen); rects fully + // outside are an error. Mirrors cropRectInPixels in utils/image.go. + private static Bitmap clipBitmap(Bitmap bitmap, int[] clip, int screenWidthPoints) { + if (clip[2] <= 0 || clip[3] <= 0) { + throw new IllegalArgumentException("clip width and height must be positive"); + } + if (screenWidthPoints <= 0) { + throw new IllegalArgumentException("--screen-width is required with --clip"); + } + + double factor = (double) bitmap.getWidth() / screenWidthPoints; + int left = Math.max(0, (int) Math.round(clip[0] * factor)); + int top = Math.max(0, (int) Math.round(clip[1] * factor)); + int right = Math.min(bitmap.getWidth(), (int) Math.round((clip[0] + clip[2]) * factor)); + int bottom = Math.min(bitmap.getHeight(), (int) Math.round((clip[1] + clip[3]) * factor)); + + if (right <= left || bottom <= top) { + throw new IllegalArgumentException("clip rect is outside the screenshot bounds"); + } + return Bitmap.createBitmap(bitmap, left, top, right - left, bottom - top); + } + // maxSize caps max(width, height) and wins over scale; never upscales. private static Bitmap resize(Bitmap bitmap, double scale, int maxSize) { int width = bitmap.getWidth(); diff --git a/cli/screenshot.go b/cli/screenshot.go index ac648a8..e5c0f61 100644 --- a/cli/screenshot.go +++ b/cli/screenshot.go @@ -4,9 +4,12 @@ import ( "encoding/base64" "fmt" "os" + "strconv" + "strings" "github.com/mobile-next/mobilecli/commands" "github.com/mobile-next/mobilecli/devices" + "github.com/mobile-next/mobilecli/types" "github.com/mobile-next/mobilecli/utils" "github.com/spf13/cobra" ) @@ -14,6 +17,7 @@ import ( var ( screenshotScale float64 screenshotMaxSize int + screenshotClip string screencaptureScale float64 screencaptureFPS int screencaptureBitrate int @@ -24,17 +28,47 @@ const ( maxScreencaptureBitrate = 10_000_000 ) +// parseScreenshotClip parses an "x,y,width,height" flag value (in screen +// points); an empty value means no cropping. +func parseScreenshotClip(value string) (*types.ScreenElementRect, error) { + if value == "" { + return nil, nil + } + + parts := strings.Split(value, ",") + if len(parts) != 4 { + return nil, fmt.Errorf("invalid --clip %q, expected x,y,width,height", value) + } + + numbers := make([]int, 4) + for i, part := range parts { + number, err := strconv.Atoi(part) + if err != nil { + return nil, fmt.Errorf("invalid --clip %q, expected x,y,width,height", value) + } + numbers[i] = number + } + + return &types.ScreenElementRect{X: numbers[0], Y: numbers[1], Width: numbers[2], Height: numbers[3]}, nil +} + var screenshotCmd = &cobra.Command{ Use: "screenshot", Short: "Take a screenshot of a connected device", Long: `Takes a screenshot of a specified device (using its ID) and saves it locally as a PNG file. Supports iOS (real/simulator) and Android (real/emulator).`, RunE: func(cmd *cobra.Command, args []string) error { + rect, err := parseScreenshotClip(screenshotClip) + if err != nil { + return err + } + req := commands.ScreenshotRequest{ DeviceID: deviceId, Format: screenshotFormat, Quality: screenshotJpegQuality, Scale: screenshotScale, MaxSize: screenshotMaxSize, + Clip: rect, OutputPath: screenshotOutputPath, } @@ -157,6 +191,7 @@ func init() { screenshotCmd.Flags().IntVarP(&screenshotJpegQuality, "quality", "q", 90, "JPEG quality (1-100, only applies if format is jpeg)") screenshotCmd.Flags().Float64Var(&screenshotScale, "scale", 1.0, "Scale factor for screenshot (0.0-1.0)") screenshotCmd.Flags().IntVar(&screenshotMaxSize, "max-size", 0, "Maximum of width/height in pixels, keeping aspect ratio (takes precedence over --scale, 0 for no limit)") + screenshotCmd.Flags().StringVar(&screenshotClip, "clip", "", "Crop to x,y,width,height in screen points, applied before --scale/--max-size") // screencapture command flags screencaptureCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to capture from") diff --git a/cli/screenshot_test.go b/cli/screenshot_test.go new file mode 100644 index 0000000..07142ed --- /dev/null +++ b/cli/screenshot_test.go @@ -0,0 +1,30 @@ +package cli + +import ( + "testing" + + "github.com/mobile-next/mobilecli/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseScreenshotClip(t *testing.T) { + t.Run("empty value means no cropping", func(t *testing.T) { + rect, err := parseScreenshotClip("") + require.NoError(t, err) + assert.Nil(t, rect) + }) + + t.Run("parses x,y,width,height", func(t *testing.T) { + rect, err := parseScreenshotClip("10,20,30,40") + require.NoError(t, err) + assert.Equal(t, &types.ScreenElementRect{X: 10, Y: 20, Width: 30, Height: 40}, rect) + }) + + t.Run("rejects malformed values", func(t *testing.T) { + for _, value := range []string{"10,20,30", "a,b,c,d", "10 20 30 40", "10,20,30,40,50", "10,20,30,40x", "10,20,30,40,"} { + _, err := parseScreenshotClip(value) + assert.Error(t, err, "value %q should be rejected", value) + } + }) +} diff --git a/commands/screenshot.go b/commands/screenshot.go index 04be1a5..34f2668 100644 --- a/commands/screenshot.go +++ b/commands/screenshot.go @@ -10,16 +10,18 @@ import ( "time" "github.com/mobile-next/mobilecli/devices" + "github.com/mobile-next/mobilecli/types" ) // ScreenshotRequest represents the parameters for taking a screenshot type ScreenshotRequest struct { - DeviceID string `json:"deviceId"` - Format string `json:"format,omitempty"` // "png" or "jpeg" - Quality int `json:"quality,omitempty"` // 1-100, only used for JPEG - Scale float64 `json:"scale,omitempty"` // 0.0-1.0, 0 or 1.0 means no scaling - MaxSize int `json:"maxSize,omitempty"` // max(width, height) in pixels, takes precedence over Scale, 0 means no limit - OutputPath string `json:"outputPath,omitempty"` // file path, "-" for stdout, or empty for default naming + DeviceID string `json:"deviceId"` + Format string `json:"format,omitempty"` // "png" or "jpeg" + Quality int `json:"quality,omitempty"` // 1-100, only used for JPEG + Scale float64 `json:"scale,omitempty"` // 0.0-1.0, 0 or 1.0 means no scaling + MaxSize int `json:"maxSize,omitempty"` // max(width, height) in pixels, takes precedence over Scale, 0 means no limit + Clip *types.ScreenElementRect `json:"clip,omitempty"` // crop rect in screen points, applied before Scale/MaxSize + OutputPath string `json:"outputPath,omitempty"` // file path, "-" for stdout, or empty for default naming } // ScreenshotResponse represents the response for a screenshot command @@ -44,6 +46,27 @@ func validateScreenshotResize(scale float64, maxSize int) (float64, error) { return scale, nil } +// resolveScreenWidthForClip validates clip and returns the device screen +// width in points, needed to map the clip rect to pixels of the captured +// image. Returns 0 when clip is nil. +func resolveScreenWidthForClip(device devices.ControllableDevice, clip *types.ScreenElementRect) (int, error) { + if clip == nil { + return 0, nil + } + if clip.Width <= 0 || clip.Height <= 0 { + return 0, fmt.Errorf("clip width and height must be positive") + } + + info, err := device.Info() + if err != nil { + return 0, fmt.Errorf("error getting device info for clip cropping: %v", err) + } + if info.ScreenSize == nil || info.ScreenSize.Width <= 0 { + return 0, fmt.Errorf("device did not report a screen size, cannot crop to clip") + } + return info.ScreenSize.Width, nil +} + // ScreenshotCommand takes a screenshot of the specified device func ScreenshotCommand(req ScreenshotRequest) *CommandResponse { // Find the target device @@ -85,12 +108,19 @@ func ScreenshotCommand(req ScreenshotRequest) *CommandResponse { return NewErrorResponse(fmt.Errorf("failed to start agent on device %s: %v", targetDevice.ID(), err)) } - // Take screenshot; the device is responsible for format, quality, and scaling + screenWidthPoints, err := resolveScreenWidthForClip(targetDevice, req.Clip) + if err != nil { + return NewErrorResponse(err) + } + + // Take screenshot; the device is responsible for format, quality, cropping, and scaling imageBytes, err := targetDevice.TakeScreenshot(devices.ScreenshotOptions{ - Format: req.Format, - Quality: req.Quality, - Scale: req.Scale, - MaxSize: req.MaxSize, + Format: req.Format, + Quality: req.Quality, + Scale: req.Scale, + MaxSize: req.MaxSize, + Clip: req.Clip, + ScreenWidthPoints: screenWidthPoints, }) if err != nil { return NewErrorResponse(fmt.Errorf("error taking screenshot: %v", err)) diff --git a/devices/android.go b/devices/android.go index 224f3de..0bc258c 100644 --- a/devices/android.go +++ b/devices/android.go @@ -262,8 +262,8 @@ func (d *AndroidDevice) captureScreenshot(displayID string) ([]byte, error) { } func (d *AndroidDevice) TakeScreenshot(opts ScreenshotOptions) ([]byte, error) { - // prefer on-device capture+encode via the embedded dex: a downscaled jpeg - // crosses adb instead of screencap's full-size png + // prefer on-device capture+encode via the embedded dex: a cropped and/or + // downscaled image crosses adb instead of screencap's full-size png data, err := d.takeScreenshotWithDex(opts) if err == nil { return data, nil @@ -274,7 +274,7 @@ func (d *AndroidDevice) TakeScreenshot(opts ScreenshotOptions) ([]byte, error) { if err != nil { return nil, err } - return utils.ProcessScreenshot(data, opts.Format, opts.Quality, opts.Scale, opts.MaxSize) + return utils.ProcessScreenshot(data, opts.Format, opts.Quality, opts.Scale, opts.MaxSize, opts.Clip, opts.ScreenWidthPoints) } // takeScreenshotWithDex captures, scales, and encodes on-device via the @@ -298,13 +298,20 @@ func (d *AndroidDevice) takeScreenshotWithDex(opts ScreenshotOptions) ([]byte, e utils.Verbose("taking screenshot on-device via mobilecli.dex") + // all clip values are integers formatted with %d, safe to interpolate + clipArgs := "" + if opts.Clip != nil { + clipArgs = fmt.Sprintf(" --clip %d,%d,%d,%d --screen-width %d", + opts.Clip.X, opts.Clip.Y, opts.Clip.Width, opts.Clip.Height, opts.ScreenWidthPoints) + } + script := fmt.Sprintf( "out=/data/local/tmp/mobilecli-screenshot-$$.img; "+ "CLASSPATH=%s app_process / com.mobilenext.mobilecli.Screenshot "+ - "--format %s --quality %d --scale %s --max-size %d --out $out >/dev/null 2>&1; "+ + "--format %s --quality %d --scale %s --max-size %d%s --out $out >/dev/null 2>&1; "+ "cat $out 2>/dev/null; rm -f $out", androidDexPath, format, opts.Quality, - strconv.FormatFloat(opts.Scale, 'f', -1, 64), opts.MaxSize, + strconv.FormatFloat(opts.Scale, 'f', -1, 64), opts.MaxSize, clipArgs, ) data, err := d.runAdbCommand("exec-out", script) diff --git a/devices/common.go b/devices/common.go index 71c7ee9..e9625ad 100644 --- a/devices/common.go +++ b/devices/common.go @@ -98,11 +98,16 @@ func buildMjpegURL(port, fps int, scale float64) string { // ScreenshotOptions contains options for taking a screenshot. // MaxSize caps max(width, height) in pixels keeping aspect ratio and takes // precedence over Scale; neither ever upscales. +// Rect crops before Scale/MaxSize apply; it is expressed in screen points +// (the same units as dump.ui bounds) and mapped to pixels via +// ScreenWidthPoints, which is required when Rect is set. type ScreenshotOptions struct { - Format string // "png" or "jpeg" - Quality int // 1-100, only used for JPEG - Scale float64 // 0.0-1.0, 1.0 means no scaling - MaxSize int // 0 means no limit + Format string // "png" or "jpeg" + Quality int // 1-100, only used for JPEG + Scale float64 // 0.0-1.0, 1.0 means no scaling + MaxSize int // 0 means no limit + Clip *types.ScreenElementRect // nil means no cropping + ScreenWidthPoints int // screen width in points, required when Rect is set } // ScreenCaptureConfig contains configuration for screen capture operations diff --git a/devices/ios.go b/devices/ios.go index 6d95281..060d847 100644 --- a/devices/ios.go +++ b/devices/ios.go @@ -199,7 +199,7 @@ func (d *IOSDevice) TakeScreenshot(opts ScreenshotOptions) ([]byte, error) { if err != nil { return nil, err } - return utils.ProcessScreenshot(data, opts.Format, opts.Quality, opts.Scale, opts.MaxSize) + return utils.ProcessScreenshot(data, opts.Format, opts.Quality, opts.Scale, opts.MaxSize, opts.Clip, opts.ScreenWidthPoints) } func (d *IOSDevice) Reboot() error { diff --git a/devices/remote.go b/devices/remote.go index e8e0da0..a3fe4fc 100644 --- a/devices/remote.go +++ b/devices/remote.go @@ -112,6 +112,9 @@ func (r *RemoteDevice) TakeScreenshot(opts ScreenshotOptions) ([]byte, error) { if opts.MaxSize > 0 { p["maxSize"] = opts.MaxSize } + if opts.Clip != nil { + p["clip"] = opts.Clip + } resp, err := rpcCall[struct { Data string `json:"data"` }](r, "device.screenshot", p) diff --git a/devices/simulator.go b/devices/simulator.go index 0374635..b897285 100644 --- a/devices/simulator.go +++ b/devices/simulator.go @@ -91,7 +91,7 @@ func (s SimulatorDevice) TakeScreenshot(opts ScreenshotOptions) ([]byte, error) if err != nil { return nil, err } - return utils.ProcessScreenshot(data, opts.Format, opts.Quality, opts.Scale, opts.MaxSize) + return utils.ProcessScreenshot(data, opts.Format, opts.Quality, opts.Scale, opts.MaxSize, opts.Clip, opts.ScreenWidthPoints) } // Reboot shuts down and then boots the iOS simulator. diff --git a/server/server.go b/server/server.go index bef070f..194c847 100644 --- a/server/server.go +++ b/server/server.go @@ -19,6 +19,7 @@ import ( "github.com/google/uuid" "github.com/mobile-next/mobilecli/commands" "github.com/mobile-next/mobilecli/devices" + "github.com/mobile-next/mobilecli/types" "github.com/mobile-next/mobilecli/utils" ) @@ -101,11 +102,12 @@ type JSONRPCResponse struct { // ScreenshotParams represents the parameters for the screenshot request type ScreenshotParams struct { - DeviceID string `json:"deviceId"` - Format string `json:"format,omitempty"` // "png" or "jpeg" - Quality int `json:"quality,omitempty"` // 1-100, only used for JPEG - Scale float64 `json:"scale,omitempty"` // 0.0-1.0, 0 or 1.0 means no scaling - MaxSize int `json:"maxSize,omitempty"` // max(width, height) in pixels, takes precedence over scale, 0 means no limit + DeviceID string `json:"deviceId"` + Format string `json:"format,omitempty"` // "png" or "jpeg" + Quality int `json:"quality,omitempty"` // 1-100, only used for JPEG + Scale float64 `json:"scale,omitempty"` // 0.0-1.0, 0 or 1.0 means no scaling + MaxSize int `json:"maxSize,omitempty"` // max(width, height) in pixels, takes precedence over scale, 0 means no limit + Clip *types.ScreenElementRect `json:"clip,omitempty"` // crop rect in screen points, applied before scale/maxSize } // DevicesParams represents the parameters for the devices request @@ -420,6 +422,7 @@ func handleScreenshot(params json.RawMessage) (any, error) { Quality: screenshotParams.Quality, Scale: screenshotParams.Scale, MaxSize: screenshotParams.MaxSize, + Clip: screenshotParams.Clip, OutputPath: "-", // Always return base64 data for server } diff --git a/utils/image.go b/utils/image.go index deaa167..deff1ab 100644 --- a/utils/image.go +++ b/utils/image.go @@ -2,10 +2,12 @@ package utils import ( "bytes" + "fmt" "image" "image/jpeg" "image/png" + "github.com/mobile-next/mobilecli/types" "golang.org/x/image/draw" ) @@ -53,17 +55,55 @@ func resizeFactor(width, height int, scale float64, maxSize int) float64 { return factor } -// ProcessScreenshot resizes and re-encodes PNG screenshot bytes according to -// scale/maxSize (see resizeFactor) and format ("png" or "jpeg"). When no -// resize is needed and PNG is requested, the input is returned unchanged. -func ProcessScreenshot(pngBytes []byte, format string, quality int, scale float64, maxSize int) ([]byte, error) { +// cropRectInPixels maps clip rect (in screen points) to pixel coordinates of an +// imageWidth-wide screenshot and clamps it to the image bounds. Bounds from a +// UI hierarchy may slightly exceed the screen (e.g. partially scrolled +// elements), so out-of-bounds portions are clipped rather than rejected. +func cropRectInPixels(rect *types.ScreenElementRect, screenWidthPoints, imageWidth, imageHeight int) (image.Rectangle, error) { + if rect.Width <= 0 || rect.Height <= 0 { + return image.Rectangle{}, fmt.Errorf("clip width and height must be positive") + } + if screenWidthPoints <= 0 { + return image.Rectangle{}, fmt.Errorf("screen width is required for clip cropping") + } + + factor := float64(imageWidth) / float64(screenWidthPoints) + pixels := image.Rect( + int(float64(rect.X)*factor+0.5), + int(float64(rect.Y)*factor+0.5), + int(float64(rect.X+rect.Width)*factor+0.5), + int(float64(rect.Y+rect.Height)*factor+0.5), + ).Intersect(image.Rect(0, 0, imageWidth, imageHeight)) + + if pixels.Empty() { + return image.Rectangle{}, fmt.Errorf("clip rect is outside the screenshot bounds") + } + return pixels, nil +} + +// ProcessScreenshot crops, resizes and re-encodes PNG screenshot bytes. +// clip rect (in screen points, mapped to pixels via screenWidthPoints) is applied +// first, then scale/maxSize (see resizeFactor) against the cropped size, then +// encoding to format ("png" or "jpeg"). When nothing is to be done and PNG is +// requested, the input is returned unchanged. +func ProcessScreenshot(pngBytes []byte, format string, quality int, scale float64, maxSize int, rect *types.ScreenElementRect, screenWidthPoints int) ([]byte, error) { cfg, err := png.DecodeConfig(bytes.NewReader(pngBytes)) if err != nil { return nil, err } - factor := resizeFactor(cfg.Width, cfg.Height, scale, maxSize) - if factor == 1.0 { + width, height := cfg.Width, cfg.Height + var crop image.Rectangle + if rect != nil { + crop, err = cropRectInPixels(rect, screenWidthPoints, cfg.Width, cfg.Height) + if err != nil { + return nil, err + } + width, height = crop.Dx(), crop.Dy() + } + + factor := resizeFactor(width, height, scale, maxSize) + if rect == nil && factor == 1.0 { if format == "jpeg" { return ConvertPngToJpeg(pngBytes, quality) } @@ -75,8 +115,18 @@ func ProcessScreenshot(pngBytes []byte, format string, quality int, scale float6 return nil, err } - newWidth := max(1, int(float64(cfg.Width)*factor+0.5)) - newHeight := max(1, int(float64(cfg.Height)*factor+0.5)) + if rect != nil { + sub, ok := img.(interface { + SubImage(image.Rectangle) image.Image + }) + if !ok { + return nil, fmt.Errorf("unsupported image type for cropping") + } + img = sub.SubImage(crop) + } + + newWidth := max(1, int(float64(width)*factor+0.5)) + newHeight := max(1, int(float64(height)*factor+0.5)) resized := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight)) draw.CatmullRom.Scale(resized, resized.Bounds(), img, img.Bounds(), draw.Src, nil) diff --git a/utils/image_test.go b/utils/image_test.go index fe2810c..3d13e52 100644 --- a/utils/image_test.go +++ b/utils/image_test.go @@ -8,6 +8,7 @@ import ( "image/png" "testing" + "github.com/mobile-next/mobilecli/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -56,7 +57,7 @@ func TestResizeFactor(t *testing.T) { func TestProcessScreenshot_PngWithoutResizeIsReturnedUnchanged(t *testing.T) { original := makeTestPng(t, 64, 32) - out, err := ProcessScreenshot(original, "png", 90, 1.0, 0) + out, err := ProcessScreenshot(original, "png", 90, 1.0, 0, nil, 0) require.NoError(t, err) assert.Equal(t, original, out, "PNG with no resize should be passed through untouched") @@ -65,7 +66,7 @@ func TestProcessScreenshot_PngWithoutResizeIsReturnedUnchanged(t *testing.T) { func TestProcessScreenshot_ScaleHalvesDimensions(t *testing.T) { original := makeTestPng(t, 64, 32) - out, err := ProcessScreenshot(original, "png", 90, 0.5, 0) + out, err := ProcessScreenshot(original, "png", 90, 0.5, 0, nil, 0) require.NoError(t, err) width, height := decodeDimensions(t, out) @@ -76,7 +77,7 @@ func TestProcessScreenshot_ScaleHalvesDimensions(t *testing.T) { func TestProcessScreenshot_MaxSizeCapsLargestDimension(t *testing.T) { original := makeTestPng(t, 64, 32) - out, err := ProcessScreenshot(original, "jpeg", 90, 1.0, 16) + out, err := ProcessScreenshot(original, "jpeg", 90, 1.0, 16, nil, 0) require.NoError(t, err) assert.True(t, IsJPEG(out), "Output should be a JPEG") @@ -88,7 +89,7 @@ func TestProcessScreenshot_MaxSizeCapsLargestDimension(t *testing.T) { func TestProcessScreenshot_JpegWithoutResizeIsConverted(t *testing.T) { original := makeTestPng(t, 64, 32) - out, err := ProcessScreenshot(original, "jpeg", 90, 1.0, 0) + out, err := ProcessScreenshot(original, "jpeg", 90, 1.0, 0, nil, 0) require.NoError(t, err) assert.True(t, IsJPEG(out), "Output should be a JPEG") @@ -98,10 +99,91 @@ func TestProcessScreenshot_JpegWithoutResizeIsConverted(t *testing.T) { } func TestProcessScreenshot_InvalidPngReturnsError(t *testing.T) { - _, err := ProcessScreenshot([]byte("not a png"), "png", 90, 0.5, 0) + _, err := ProcessScreenshot([]byte("not a png"), "png", 90, 0.5, 0, nil, 0) assert.Error(t, err) } +func TestProcessScreenshot_RectCropsToElementBounds(t *testing.T) { + original := makeTestPng(t, 100, 200) + rect := &types.ScreenElementRect{X: 10, Y: 20, Width: 30, Height: 40} + + out, err := ProcessScreenshot(original, "png", 90, 1.0, 0, rect, 100) + + require.NoError(t, err) + width, height := decodeDimensions(t, out) + assert.Equal(t, 30, width) + assert.Equal(t, 40, height) + + img, err := png.Decode(bytes.NewReader(out)) + require.NoError(t, err) + r, g, _, _ := img.At(img.Bounds().Min.X, img.Bounds().Min.Y).RGBA() + assert.Equal(t, uint32(10), r>>8, "Top-left pixel should come from x=10 of the source") + assert.Equal(t, uint32(20), g>>8, "Top-left pixel should come from y=20 of the source") +} + +func TestProcessScreenshot_RectInPointsIsScaledToPixels(t *testing.T) { + original := makeTestPng(t, 200, 400) // 2x density: screen is 100 points wide + rect := &types.ScreenElementRect{X: 10, Y: 20, Width: 30, Height: 40} + + out, err := ProcessScreenshot(original, "png", 90, 1.0, 0, rect, 100) + + require.NoError(t, err) + width, height := decodeDimensions(t, out) + assert.Equal(t, 60, width) + assert.Equal(t, 80, height) +} + +func TestProcessScreenshot_RectAppliesBeforeMaxSize(t *testing.T) { + original := makeTestPng(t, 100, 200) + rect := &types.ScreenElementRect{X: 0, Y: 0, Width: 40, Height: 80} + + out, err := ProcessScreenshot(original, "png", 90, 1.0, 40, rect, 100) + + require.NoError(t, err) + width, height := decodeDimensions(t, out) + assert.Equal(t, 20, width, "maxSize should cap the cropped image, not the full screen") + assert.Equal(t, 40, height) +} + +func TestProcessScreenshot_RectPartiallyOutsideIsClamped(t *testing.T) { + original := makeTestPng(t, 100, 200) + rect := &types.ScreenElementRect{X: 80, Y: 180, Width: 50, Height: 50} + + out, err := ProcessScreenshot(original, "png", 90, 1.0, 0, rect, 100) + + require.NoError(t, err) + width, height := decodeDimensions(t, out) + assert.Equal(t, 20, width) + assert.Equal(t, 20, height) +} + +func TestProcessScreenshot_RectFullyOutsideReturnsError(t *testing.T) { + original := makeTestPng(t, 100, 200) + rect := &types.ScreenElementRect{X: 150, Y: 0, Width: 10, Height: 10} + + _, err := ProcessScreenshot(original, "png", 90, 1.0, 0, rect, 100) + + assert.ErrorContains(t, err, "outside the screenshot bounds") +} + +func TestProcessScreenshot_RectWithoutScreenWidthReturnsError(t *testing.T) { + original := makeTestPng(t, 100, 200) + rect := &types.ScreenElementRect{X: 0, Y: 0, Width: 10, Height: 10} + + _, err := ProcessScreenshot(original, "png", 90, 1.0, 0, rect, 0) + + assert.ErrorContains(t, err, "screen width") +} + +func TestProcessScreenshot_RectWithNonPositiveSizeReturnsError(t *testing.T) { + original := makeTestPng(t, 100, 200) + rect := &types.ScreenElementRect{X: 0, Y: 0, Width: 0, Height: 10} + + _, err := ProcessScreenshot(original, "png", 90, 1.0, 0, rect, 100) + + assert.ErrorContains(t, err, "must be positive") +} + func TestImageMagicByteDetection(t *testing.T) { pngData := makeTestPng(t, 8, 8) jpegData, err := ConvertPngToJpeg(pngData, 90)