diff --git a/core/docs/image-fitting.md b/core/docs/image-fitting.md new file mode 100644 index 0000000..81dcbdf --- /dev/null +++ b/core/docs/image-fitting.md @@ -0,0 +1,14 @@ +# Opaque image fitting + +`ImageScaler.fitOpaque(source, maxWidth, maxHeight, background)` returns an +independent RGB BufferedImage. It fits within positive bounds without enlargement, +keeps aspect ratio to the nearest pixel (minimum one pixel per dimension), and +composites transparent pixels onto the supplied opaque AWT Color. Bilinear +interpolation is used. The source is unchanged and graphics resources are released. + +This operation is independent of file format and wire encoding. Callers own image +decoding, any source subsampling, and target quantization. Existing PNG scaling +methods retain their original behavior. + +`ImageScalerTest` verifies fitting, no enlargement, transparent/partial-alpha +composition, narrow images, source preservation, and invalid bounds. diff --git a/core/src/main/java/de/neitzel/core/image/ImageScaler.java b/core/src/main/java/de/neitzel/core/image/ImageScaler.java index bc357f9..2fc7633 100644 --- a/core/src/main/java/de/neitzel/core/image/ImageScaler.java +++ b/core/src/main/java/de/neitzel/core/image/ImageScaler.java @@ -17,6 +17,41 @@ import java.io.InputStream; @Slf4j public class ImageScaler { + /** + * Fits an image within positive bounds without enlargement, retaining aspect ratio + * to the nearest pixel (at least one pixel per dimension). Composites alpha onto + * the supplied opaque background and returns an independent RGB image. + * + * @param source source pixels, never modified + * @param maxWidth maximum output width + * @param maxHeight maximum output height + * @param background opaque background colour + * @return fitted image with no alpha channel + * @throws IllegalArgumentException for non-positive bounds or translucent background + */ + public static BufferedImage fitOpaque(final BufferedImage source, final int maxWidth, + final int maxHeight, final Color background) { + java.util.Objects.requireNonNull(source, "source"); + java.util.Objects.requireNonNull(background, "background"); + if (maxWidth < 1 || maxHeight < 1 || background.getAlpha() != 255) + throw new IllegalArgumentException("Positive bounds and an opaque background are required"); + double scale = Math.min(1.0, Math.min((double) maxWidth / source.getWidth(), + (double) maxHeight / source.getHeight())); + int width = Math.max(1, (int) Math.round(source.getWidth() * scale)); + int height = Math.max(1, (int) Math.round(source.getHeight() * scale)); + BufferedImage result = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = result.createGraphics(); + try { + graphics.setColor(background); + graphics.fillRect(0, 0, width, height); + graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + graphics.drawImage(source, 0, 0, width, height, null); + } finally { + graphics.dispose(); + } + return result; + } + /** * Specifies the target image format for scaling operations. *
diff --git a/core/src/test/java/de/neitzel/core/image/ImageScalerTest.java b/core/src/test/java/de/neitzel/core/image/ImageScalerTest.java new file mode 100644 index 0000000..aa6b678 --- /dev/null +++ b/core/src/test/java/de/neitzel/core/image/ImageScalerTest.java @@ -0,0 +1,27 @@ +package de.neitzel.core.image; + +import java.awt.Color; +import java.awt.image.BufferedImage; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class ImageScalerTest { + @Test void fitsWithoutUpscalingAndCompositesTransparency() { + BufferedImage source = new BufferedImage(128,64,BufferedImage.TYPE_INT_ARGB); + BufferedImage result = ImageScaler.fitOpaque(source,64,64,Color.BLUE); + assertEquals(64,result.getWidth()); + assertEquals(32,result.getHeight()); + assertEquals(Color.BLUE.getRGB(),result.getRGB(0,0)); + assertEquals(0,source.getRGB(0,0)); + BufferedImage tiny = new BufferedImage(1,1,BufferedImage.TYPE_INT_ARGB); + tiny.setRGB(0,0,0x80ff0000); + result = ImageScaler.fitOpaque(tiny,64,64,Color.BLUE); + assertEquals(1,result.getWidth()); + Color mixed = new Color(result.getRGB(0,0)); + assertEquals(128,mixed.getRed(),1); + assertEquals(127,mixed.getBlue(),1); + BufferedImage narrow = ImageScaler.fitOpaque(new BufferedImage(1,1000,BufferedImage.TYPE_INT_ARGB),64,64,Color.BLACK); + assertEquals(1,narrow.getWidth()); assertEquals(64,narrow.getHeight()); + assertThrows(IllegalArgumentException.class, () -> ImageScaler.fitOpaque(tiny,0,64,Color.BLACK)); + } +}