diff --git a/README.md b/README.md
index 35045f2..8989d11 100644
--- a/README.md
+++ b/README.md
@@ -22,6 +22,8 @@ The component allows to crop images and configure the following properties for a
* max width (maximum crop width)
* max height (maximum crop height)
* rule of thirds (to show rule of thirds lines in the cropped area)
+* output MIME type (the format used to encode the cropped image: `image/png`, `image/jpeg` or `image/webp`; when unset it is auto-detected from the image source, and circular crops always use a transparency-capable format)
+* output quality (encoding quality between 0 and 1 for lossy formats such as `image/jpeg`)
The cropped image result can be obtain as a URI using `getCroppedImageDataUri` method
or as a Base64 encoded byte array by using `getCroppedImageBase64` method.
diff --git a/pom.xml b/pom.xml
index bdface9..da14320 100644
--- a/pom.xml
+++ b/pom.xml
@@ -4,7 +4,7 @@
com.flowingcode.vaadin.addons
image-crop-addon
- 1.2.3-SNAPSHOT
+ 1.3.0-SNAPSHOT
Image Crop Add-on
Image Crop Add-on for Vaadin Flow
https://www.flowingcode.com/en/open-source/
diff --git a/src/main/java/com/flowingcode/vaadin/addons/imagecrop/ImageCrop.java b/src/main/java/com/flowingcode/vaadin/addons/imagecrop/ImageCrop.java
index d860fed..509fa9b 100644
--- a/src/main/java/com/flowingcode/vaadin/addons/imagecrop/ImageCrop.java
+++ b/src/main/java/com/flowingcode/vaadin/addons/imagecrop/ImageCrop.java
@@ -337,6 +337,59 @@ public boolean isRuleOfThirds() {
return getState("ruleOfThirds", Boolean.class);
}
+ /**
+ * Sets the MIME type used to encode the cropped image (for example
+ * {@code "image/jpeg"} or {@code "image/png"}). Supported types are
+ * {@code image/png}, {@code image/jpeg} and {@code image/webp}; any other or
+ * unsupported value falls back to {@code image/png}.
+ *
+ *
+ * When left unset, the output format is auto-detected from the image source (its
+ * data URL MIME type or file extension). Circular crops are always encoded in a
+ * transparency-capable format regardless of this setting.
+ *
+ * @param outputMimeType the MIME type used to encode the cropped image
+ */
+ public void setOutputMimeType(String outputMimeType) {
+ setState("outputMimeType", outputMimeType);
+ }
+
+ /**
+ * Gets the MIME type used to encode the cropped image, or {@code null} if it is
+ * auto-detected from the image source.
+ *
+ * @return the configured output MIME type, or {@code null}
+ */
+ public String getOutputMimeType() {
+ if (getElement().getPropertyRaw("outputMimeType") == null) {
+ return null;
+ }
+ return getState("outputMimeType", String.class);
+ }
+
+ /**
+ * Sets the encoding quality used for lossy output formats such as
+ * {@code image/jpeg} and {@code image/webp}. Ignored for {@code image/png}.
+ * Defaults to {@code 1.0}.
+ *
+ * @param outputQuality a value between 0 and 1
+ */
+ public void setOutputQuality(double outputQuality) {
+ setState("outputQuality", outputQuality);
+ }
+
+ /**
+ * Gets the encoding quality used for lossy output formats.
+ *
+ * @return the output quality, between 0 and 1
+ */
+ public double getOutputQuality() {
+ if (getElement().getPropertyRaw("outputQuality") == null) {
+ return 1.0;
+ }
+ return getState("outputQuality", Double.class);
+ }
+
/**
* Returns the cropped image data URI.
*
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 85f9588..3c92c4f 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
@@ -23,6 +23,67 @@ import { JSXElementConstructor, ReactElement, useRef, useEffect } from "react";
import React from 'react';
import { type Crop, ReactCrop, PixelCrop, PercentCrop, makeAspectCrop, centerCrop, convertToPixelCrop } from "react-image-crop";
+// MIME types that HTMLCanvasElement.toDataURL can actually encode across browsers.
+// Anything else silently falls back to image/png, so we never emit it.
+const SUPPORTED_OUTPUT_TYPES = new Set(["image/png", "image/jpeg", "image/webp"]);
+
+/** Normalizes a MIME type: lowercased, parameters stripped (e.g. "image/JPEG; charset=x" -> "image/jpeg"). */
+function normalizeMimeType(mime: string | null | undefined): string | undefined {
+ if (!mime) {
+ return undefined;
+ }
+ const normalized = mime.toLowerCase().split(";")[0].trim();
+ return normalized || undefined;
+}
+
+/**
+ * Zero-network detection of the image MIME type from its source: reads it from a
+ * data URL prefix, or infers it from the file extension of a regular URL.
+ */
+function detectMimeTypeFromSrc(src: string | null | undefined): string | undefined {
+ if (!src) {
+ return undefined;
+ }
+ if (src.startsWith("data:")) {
+ // e.g. "data:image/png;base64,..." or "data:image/svg+xml,..."
+ const sep = src.search(/[;,]/);
+ return sep > 5 ? normalizeMimeType(src.substring(5, sep)) : undefined;
+ }
+ // Strip query/hash, then read the file extension.
+ const path = src.split(/[?#]/)[0];
+ const ext = path.substring(path.lastIndexOf(".") + 1).toLowerCase();
+ switch (ext) {
+ case "png": return "image/png";
+ case "jpg":
+ case "jpeg": return "image/jpeg";
+ case "webp": return "image/webp";
+ default: return undefined;
+ }
+}
+
+/**
+ * Resolves the MIME type used to encode the cropped output.
+ *
+ * Uses the explicitly requested type when given (normalized + validated against
+ * the supported set); otherwise auto-detects it from the image source. Falls
+ * back to "image/png" when the result is empty or unsupported. Circular crops
+ * are forced to a transparency-capable format, since JPEG has no alpha channel
+ * and would render the rounded corners black.
+ */
+function resolveOutputType(requested: string | null | undefined, src: string | null | undefined, circular: boolean): string {
+ let type = normalizeMimeType(requested);
+ if (!type) {
+ type = detectMimeTypeFromSrc(src);
+ }
+ if (!type || !SUPPORTED_OUTPUT_TYPES.has(type)) {
+ type = "image/png";
+ }
+ if (circular && type === "image/jpeg") {
+ type = "image/png";
+ }
+ return type;
+}
+
class ImageCropElement extends ReactAdapterElement {
protected render(hooks: RenderHooks): ReactElement> | null {
@@ -41,9 +102,14 @@ class ImageCropElement extends ReactAdapterElement {
const [maxWidth] = hooks.useState("maxWidth");
const [maxHeight] = hooks.useState("maxHeight");
const [ruleOfThirds] = hooks.useState("ruleOfThirds", false);
-
+ // Output format settings; also read via this.outputMimeType / this.outputQuality at crop time.
+ const [outputMimeType] = hooks.useState("outputMimeType");
+ const [outputQuality] = hooks.useState("outputQuality", 1.0);
+
// Track previous image dimensions to adjust crop proportionally when resizing
const prevImgSize = useRef<{ width: number; height: number } | null>(null);
+ // Skip the first run of the output-format effect (initial encoding is handled on image load)
+ const didMountRef = useRef(false);
/**
* Handles intial calculations on image load.
@@ -118,6 +184,21 @@ class ImageCropElement extends ReactAdapterElement {
return () => resizeObserver.disconnect();
}, [crop]);
+ /**
+ * Re-encodes the current crop on the client when the output format or quality
+ * changes, so the result stays in sync without relying on server-side
+ * state/JS ordering. Skips the initial mount (handled by onImageLoad).
+ */
+ useEffect(() => {
+ if (!didMountRef.current) {
+ didMountRef.current = true;
+ return;
+ }
+ if (crop) {
+ this._updateCroppedImage(crop);
+ }
+ }, [outputMimeType, outputQuality]);
+
const onChange = (c: Crop) => {
setCrop(c);
};
@@ -233,8 +314,9 @@ class ImageCropElement extends ReactAdapterElement {
ctx.restore();
- // get the cropped image
- let croppedImageDataUri = canvas.toDataURL("image/png", 1.0);
+ // encode the cropped image using the resolved output format
+ const outputType = resolveOutputType(this.outputMimeType, image.src, this.circularCrop);
+ let croppedImageDataUri = canvas.toDataURL(outputType, this.outputQuality ?? 1.0);
// dispatch the event containing cropped image
this.fireCroppedImageEvent(croppedImageDataUri);
diff --git a/src/test/java/com/flowingcode/vaadin/addons/imagecrop/ImageCropDemoView.java b/src/test/java/com/flowingcode/vaadin/addons/imagecrop/ImageCropDemoView.java
index 64e905a..4acd8f8 100644
--- a/src/test/java/com/flowingcode/vaadin/addons/imagecrop/ImageCropDemoView.java
+++ b/src/test/java/com/flowingcode/vaadin/addons/imagecrop/ImageCropDemoView.java
@@ -36,6 +36,7 @@ public class ImageCropDemoView extends TabbedDemo {
public ImageCropDemoView() {
addDemo(BasicImageCropDemo.class);
addDemo(UploadImageCropDemo.class);
+ addDemo(OutputFormatImageCropDemo.class);
setSizeFull();
}
}
diff --git a/src/test/java/com/flowingcode/vaadin/addons/imagecrop/OutputFormatImageCropDemo.java b/src/test/java/com/flowingcode/vaadin/addons/imagecrop/OutputFormatImageCropDemo.java
new file mode 100644
index 0000000..3bdc6ae
--- /dev/null
+++ b/src/test/java/com/flowingcode/vaadin/addons/imagecrop/OutputFormatImageCropDemo.java
@@ -0,0 +1,92 @@
+/*-
+ * #%L
+ * Image Crop Add-on
+ * %%
+ * Copyright (C) 2024-2025 Flowing Code
+ * %%
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * #L%
+ */
+
+package com.flowingcode.vaadin.addons.imagecrop;
+
+import com.flowingcode.vaadin.addons.demo.DemoSource;
+import com.vaadin.flow.component.button.Button;
+import com.vaadin.flow.component.html.Div;
+import com.vaadin.flow.component.html.Image;
+import com.vaadin.flow.component.html.Span;
+import com.vaadin.flow.component.orderedlayout.VerticalLayout;
+import com.vaadin.flow.component.select.Select;
+import com.vaadin.flow.router.PageTitle;
+import com.vaadin.flow.router.Route;
+
+@DemoSource
+@PageTitle("Image Crop Output Format")
+@SuppressWarnings("serial")
+@Route(value = "image-crop/output-format", layout = ImageCropDemoView.class)
+public class OutputFormatImageCropDemo extends VerticalLayout {
+
+ private static final String AUTO_DETECT = "Auto-detect";
+
+ private Div croppedResultDiv = new Div();
+
+ public OutputFormatImageCropDemo() {
+ add(new Span("Choose an output format and crop the image. "
+ + "When left on \"Auto-detect\", the format is inferred from the image source."));
+
+ ImageCrop imageCrop = new ImageCrop("images/empty-plant.png");
+ imageCrop.setCrop(new Crop("%", 25, 25, 50, 50)); // start with a centered selection
+
+ // Pick the MIME type used to encode the cropped image.
+ Select formatSelect = new Select<>();
+ formatSelect.setLabel("Output format");
+ formatSelect.setItems(AUTO_DETECT, "image/png", "image/jpeg", "image/webp");
+ formatSelect.setValue(AUTO_DETECT);
+ formatSelect.addValueChangeListener(e -> {
+ String value = e.getValue();
+ // Changing the format re-encodes the current crop automatically (client-side effect).
+ imageCrop.setOutputMimeType(AUTO_DETECT.equals(value) ? null : value);
+ });
+
+ add(formatSelect, imageCrop);
+
+ Button getCropButton = new Button("Get Cropped Image");
+ croppedResultDiv.setId("result-cropped-image-div");
+
+ getCropButton.addClickListener(e -> {
+ croppedResultDiv.removeAll();
+ String dataUri = imageCrop.getCroppedImageDataUri();
+ Span mimeType = new Span("Result MIME type: " + extractMimeType(dataUri));
+ mimeType.getStyle().set("display", "block");
+ croppedResultDiv.add(mimeType);
+ croppedResultDiv.add(new Image(dataUri, "cropped image"));
+ });
+
+ add(getCropButton, new Span("Crop Result:"), croppedResultDiv);
+ }
+
+ /** Reads the MIME type from the prefix of a {@code data:} URI, for display purposes. */
+ private static String extractMimeType(String dataUri) {
+ if (dataUri != null && dataUri.startsWith("data:")) {
+ int sep = dataUri.indexOf(';', 5);
+ if (sep < 0) {
+ sep = dataUri.indexOf(',', 5);
+ }
+ if (sep > 5) {
+ return dataUri.substring(5, sep);
+ }
+ }
+ return "unknown";
+ }
+
+}
diff --git a/src/test/java/com/flowingcode/vaadin/addons/imagecrop/test/ImageCropTest.java b/src/test/java/com/flowingcode/vaadin/addons/imagecrop/test/ImageCropTest.java
index 6b351d2..7c3314d 100644
--- a/src/test/java/com/flowingcode/vaadin/addons/imagecrop/test/ImageCropTest.java
+++ b/src/test/java/com/flowingcode/vaadin/addons/imagecrop/test/ImageCropTest.java
@@ -3,6 +3,7 @@
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@@ -45,6 +46,30 @@ public void testSetAndGetAspect() {
assertEquals(expectedAspect, Double.valueOf(imageCrop.getAspect()));
}
+ @Test
+ public void testSetAndGetOutputMimeType() {
+ String expectedMimeType = "image/jpeg";
+ imageCrop.setOutputMimeType(expectedMimeType);
+ assertEquals(expectedMimeType, imageCrop.getOutputMimeType());
+ }
+
+ @Test
+ public void testSetAndGetOutputQuality() {
+ double expectedQuality = 0.8;
+ imageCrop.setOutputQuality(expectedQuality);
+ assertEquals(expectedQuality, imageCrop.getOutputQuality(), 0.0);
+ }
+
+ @Test
+ public void testGetOutputMimeTypeDefaultsToNullWhenUnset() {
+ assertNull(imageCrop.getOutputMimeType());
+ }
+
+ @Test
+ public void testGetOutputQualityDefaultsToOneWhenUnset() {
+ assertEquals(1.0, imageCrop.getOutputQuality(), 0.0);
+ }
+
@Test
public void testEncodedCroppedImageEvent() {
String expectedCroppedImageUri = "croppedImageUri";