diff --git a/build.gradle b/build.gradle index c31b8b1..dd6a253 100644 --- a/build.gradle +++ b/build.gradle @@ -102,15 +102,14 @@ dependencies { compileOnly 'org.jetbrains:annotations:26.0.2-1' - // https://mvnrepository.com/artifact/com.twelvemonkeys.imageio/imageio-webp - runtimeOnly 'com.twelvemonkeys.imageio:imageio-webp:3.12.0' - // https://mvnrepository.com/artifact/com.twelvemonkeys.imageio/imageio-batik runtimeOnly 'com.twelvemonkeys.imageio:imageio-batik:3.12.0' // https://mvnrepository.com/artifact/org.apache.xmlgraphics/batik-all runtimeOnly 'org.apache.xmlgraphics:batik-all:1.19' + implementation 'com.drewnoakes:metadata-extractor:2.19.0' + // Custom testing testImplementation 'org.testcontainers:testcontainers:1.21.4' testImplementation 'org.testcontainers:junit-jupiter:1.21.4' diff --git a/src/integrationTest/resources/willow.HEIC b/src/integrationTest/resources/willow.HEIC new file mode 100644 index 0000000..e3570b2 Binary files /dev/null and b/src/integrationTest/resources/willow.HEIC differ diff --git a/src/main/java/app/mealsmadeeasy/api/image/S3ImageService.java b/src/main/java/app/mealsmadeeasy/api/image/S3ImageService.java index 58e2aa4..70741c4 100644 --- a/src/main/java/app/mealsmadeeasy/api/image/S3ImageService.java +++ b/src/main/java/app/mealsmadeeasy/api/image/S3ImageService.java @@ -7,6 +7,14 @@ import app.mealsmadeeasy.api.user.User; import app.mealsmadeeasy.api.util.MimeTypeService; import app.mealsmadeeasy.api.util.NoSuchEntityWithIdException; import app.mealsmadeeasy.api.util.NoSuchEntityWithUsernameAndFilenameException; +import com.drew.imaging.ImageMetadataReader; +import com.drew.imaging.ImageProcessingException; +import com.drew.metadata.Directory; +import com.drew.metadata.Metadata; +import com.drew.metadata.MetadataException; +import com.drew.metadata.jpeg.JpegDirectory; +import com.drew.metadata.png.PngDirectory; +import com.drew.metadata.webp.WebpDirectory; import org.jetbrains.annotations.Nullable; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Pageable; @@ -103,6 +111,33 @@ public class S3ImageService implements ImageService { return didTransfer; } + private record HeightWidth(int height, int width) {} + + private HeightWidth getHeightAndWidthFromMetadata( + InputStream inputStream, + Class directoryClass, + int heightTag, + int widthTag, + String debugName + ) throws IOException { + try { + final Metadata metadata = ImageMetadataReader.readMetadata(inputStream); + final D directory = metadata.getFirstDirectoryOfType(directoryClass); + if (directory == null) { + throw new RuntimeException("Unable to get " + directoryClass.getSimpleName() + " for " + debugName); + } + if (!directory.containsTag(heightTag)) { + throw new RuntimeException("Unable to find height tag for " + debugName); + } + if (!directory.containsTag(widthTag)) { + throw new RuntimeException("Unable to find width tag for " + debugName); + } + return new HeightWidth(directory.getInt(heightTag), directory.getInt(widthTag)); + } catch (ImageProcessingException | MetadataException e) { + throw new RuntimeException(e); + } + } + /** * @apiNote Consumes and closes the {@link java.io.InputStream}. * @@ -137,15 +172,43 @@ public class S3ImageService implements ImageService { this.imageBucketName, filename, mimeType, toStore, objectSize ); - final BufferedImage bufferedImage = ImageIO.read(toRead); - if (bufferedImage == null) { - throw new ImageException( - ImageException.Type.UNSUPPORTED_IMAGE_TYPE, - "ImageIO could not read image: " + userFilename - ); + final HeightWidth hw; + switch (mimeType) { + case "image/jpeg" -> { + hw = this.getHeightAndWidthFromMetadata( + toRead, + JpegDirectory.class, + JpegDirectory.TAG_IMAGE_HEIGHT, + JpegDirectory.TAG_IMAGE_WIDTH, + userFilename + ); + } + case "image/png" -> { + hw = this.getHeightAndWidthFromMetadata( + toRead, + PngDirectory.class, + PngDirectory.TAG_IMAGE_HEIGHT, + PngDirectory.TAG_IMAGE_WIDTH, + userFilename + ); + } + case "image/webp" -> { + hw = this.getHeightAndWidthFromMetadata( + toRead, + WebpDirectory.class, + WebpDirectory.TAG_IMAGE_HEIGHT, + WebpDirectory.TAG_IMAGE_WIDTH, + userFilename + ); + } + case "image/svg+xml" -> { + final BufferedImage bufferedImage = ImageIO.read(toRead); + hw = new HeightWidth(bufferedImage.getHeight(), bufferedImage.getWidth()); + } + default -> throw new RuntimeException("Unsupported mime type: " + mimeType); } - final int height = bufferedImage.getHeight(); - final int width = bufferedImage.getWidth(); + final int height = hw.height(); + final int width = hw.width(); toRead.close(); toStore.close(); diff --git a/src/main/java/app/mealsmadeeasy/api/util/MimeTypeService.java b/src/main/java/app/mealsmadeeasy/api/util/MimeTypeService.java index c9fd78d..20dc9ca 100644 --- a/src/main/java/app/mealsmadeeasy/api/util/MimeTypeService.java +++ b/src/main/java/app/mealsmadeeasy/api/util/MimeTypeService.java @@ -19,7 +19,7 @@ public class MimeTypeService { final Matcher m = extensionPattern.matcher(userFilename); if (m.matches()) { final String extension = m.group(1); - return switch (extension) { + return switch (extension.toLowerCase()) { case "jpg", "jpeg" -> IMAGE_JPEG; case "png" -> IMAGE_PNG; case "svg" -> IMAGE_SVG; @@ -32,7 +32,7 @@ public class MimeTypeService { } public String getExtension(String mimeType) { - return switch (mimeType) { + return switch (mimeType.toLowerCase()) { case IMAGE_JPEG -> "jpg"; case IMAGE_PNG -> "png"; case IMAGE_SVG -> "svg";