From 9ecadc414c49ffb58fde8afed4bf87b008a71e30 Mon Sep 17 00:00:00 2001 From: Paola De Bartolo Date: Fri, 5 Jun 2026 17:36:10 -0300 Subject: [PATCH] fix: size the cropped image at its natural resolution The cropped output was derived from the displayed image and scaled by devicePixelRatio, so crops were too small when the image was scaled down to fit and too large on high-density displays. Close #26 Close #21 --- .../resources/frontend/src/image-crop.tsx | 46 +++++++++++++++---- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/src/main/resources/META-INF/resources/frontend/src/image-crop.tsx b/src/main/resources/META-INF/resources/frontend/src/image-crop.tsx index 2f49170..85f9588 100644 --- a/src/main/resources/META-INF/resources/frontend/src/image-crop.tsx +++ b/src/main/resources/META-INF/resources/frontend/src/image-crop.tsx @@ -161,34 +161,60 @@ class ImageCropElement extends ReactAdapterElement { ); } + /** + * Draws the selected crop region onto an off-screen canvas and dispatches the + * resulting data URI through a {@code cropped-image} event. + * + *

The crop rectangle reported by react-image-crop is expressed in the + * image's displayed (rendered) pixels, which can be smaller or larger + * than the image's intrinsic resolution when the browser scales it to fit the + * layout. The selected region is mapped back to the source's natural + * pixels using {@code scaleX}/{@code scaleY} for both the source rectangle and + * the output canvas, so the cropped image keeps the original resolution of the + * selected area rather than the (smaller or larger) on-screen size (see issue + * #26).

+ * + *

Note: a {@code px} crop is measured in rendered pixels, so the exported + * size is the rendered crop scaled to natural resolution, not necessarily the + * configured pixel value. The output is not multiplied by + * {@code window.devicePixelRatio}, so the original pixels are used verbatim + * instead of being upsampled on high-density displays (see issue #21).

+ */ public _updateCroppedImage(crop: PixelCrop|PercentCrop) { const image = this.querySelector("img"); if (crop && image) { crop = convertToPixelCrop(crop, image.width, image.height); - + // create a canvas element to draw the cropped image const canvas = document.createElement("canvas"); // draw the image on the canvas const ccrop = crop; + + // Ratio between the image's natural resolution and its displayed size. + // Greater than 1 when the image is scaled down to fit the screen. const scaleX = image.naturalWidth / image.width; const scaleY = image.naturalHeight / image.height; const ctx = canvas.getContext("2d"); - const pixelRatio = window.devicePixelRatio; - canvas.width = ccrop.width * pixelRatio; - canvas.height = ccrop.height * pixelRatio; + + // Size the output in the crop region's natural pixels so the cropped + // image keeps the source's resolution rather than the on-screen size. + const outWidth = Math.round(ccrop.width * scaleX); + const outHeight = Math.round(ccrop.height * scaleY); + + // Setting canvas dimensions resets the 2D context, so it must happen + // before any drawing/clipping state is configured below. + canvas.width = outWidth; + canvas.height = outHeight; if (ctx) { - ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); ctx.imageSmoothingQuality = "high"; ctx.save(); if (this.circularCrop) { - canvas.width = ccrop.width; - canvas.height = ccrop.height; ctx.beginPath(); - ctx.arc(ccrop.width / 2, ccrop.height / 2, ccrop.height / 2, 0, Math.PI * 2, true); + ctx.arc(outWidth / 2, outHeight / 2, outHeight / 2, 0, Math.PI * 2, true); ctx.closePath(); ctx.clip(); } @@ -201,8 +227,8 @@ class ImageCropElement extends ReactAdapterElement { ccrop.height * scaleY, 0, 0, - ccrop.width, - ccrop.height + outWidth, + outHeight ); ctx.restore();