diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ImageDimensions.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ImageDimensions.java new file mode 100644 index 0000000..3ad3551 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ImageDimensions.java @@ -0,0 +1,87 @@ +package dev.caskeleton.application.techlog.studio.service; + +import java.util.Optional; + +/** + * 업로드한 그림의 픽셀 크기를 헤더에서 읽는다. + * + *

업로드가 이 값을 기록하지 않아 모든 그림이 화면에서 사라졌다. 렌더러는 치수를 모르면 자리를 + * 잡을 수 없고, 그때 쓰던 대체값 1×1 이 {@code loading="lazy"} 와 만나 브라우저가 영영 가져오지 + * 않는 상자가 됐다. 화면 쪽은 모르는 치수를 정직하게 비우도록 고쳤고, 이쪽은 애초에 알 수 있는 + * 값을 기록한다. + * + *

디코딩하지 않고 헤더만 읽는다. {@code ImageIO} 는 {@code java.desktop} 모듈을 끌어오고 + * 런타임 이미지가 그것을 담고 있으리라는 보장이 없다 — 없는 배포에서 업로드가 통째로 실패하는 + * 것보다, 아는 형식의 헤더 몇 바이트를 직접 읽는 편이 낫다. 모르는 형식은 비운다: 크기를 모르는 + * 것과 크기가 0 인 것은 다르고, 화면은 그 둘을 구분한다. + */ +public final class ImageDimensions { + + /** 픽셀 크기. 둘 다 양수일 때만 만든다. */ + public record Size(int width, int height) {} + + private ImageDimensions() {} + + public static Optional of(String mediaType, byte[] content) { + if (mediaType == null || content == null) { + return Optional.empty(); + } + return switch (mediaType) { + case "image/png" -> png(content); + case "image/gif" -> gif(content); + case "image/jpeg" -> jpeg(content); + // WebP 와 SVG 는 읽지 않는다. WebP 는 VP8/VP8L/VP8X 세 갈래로 형식이 갈리고, SVG 는 + // 픽셀 크기가 없을 수도 있는 벡터다 — 둘 다 "모른다" 가 정직한 답이다. + default -> Optional.empty(); + }; + } + + /** IHDR 은 항상 첫 청크이고, 폭·높이가 그 앞 8바이트다. */ + private static Optional png(byte[] c) { + if (c.length < 24) { + return Optional.empty(); + } + return size(int32(c, 16), int32(c, 20)); + } + + /** 논리 화면 기술자. 리틀엔디언 16비트 둘. */ + private static Optional gif(byte[] c) { + if (c.length < 10) { + return Optional.empty(); + } + return size((c[6] & 0xFF) | ((c[7] & 0xFF) << 8), (c[8] & 0xFF) | ((c[9] & 0xFF) << 8)); + } + + /** + * SOF 마커까지 세그먼트를 건너뛴다. 어느 SOF 인지는 상관없다 — 어떤 것이든 그 안의 높이·폭이 + * 그림의 크기다. + */ + private static Optional jpeg(byte[] c) { + int i = 2; + while (i + 9 < c.length) { + if ((c[i] & 0xFF) != 0xFF) { + i++; + continue; + } + int marker = c[i + 1] & 0xFF; + // SOF0..SOF15, 단 DHT(C4)·JPG(C8)·DAC(CC) 는 SOF 가 아니다. + if (marker >= 0xC0 && marker <= 0xCF && marker != 0xC4 && marker != 0xC8 && marker != 0xCC) { + return size((c[i + 7] & 0xFF) << 8 | (c[i + 8] & 0xFF), (c[i + 5] & 0xFF) << 8 | (c[i + 6] & 0xFF)); + } + int length = (c[i + 2] & 0xFF) << 8 | (c[i + 3] & 0xFF); + if (length < 2) { + return Optional.empty(); + } + i += 2 + length; + } + return Optional.empty(); + } + + private static int int32(byte[] c, int at) { + return ((c[at] & 0xFF) << 24) | ((c[at + 1] & 0xFF) << 16) | ((c[at + 2] & 0xFF) << 8) | (c[at + 3] & 0xFF); + } + + private static Optional size(int width, int height) { + return width > 0 && height > 0 ? Optional.of(new Size(width, height)) : Optional.empty(); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UploadStudioAssetUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UploadStudioAssetUseCase.java index 89bfcc6..bc258d2 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UploadStudioAssetUseCase.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UploadStudioAssetUseCase.java @@ -82,6 +82,11 @@ public class UploadStudioAssetUseCase implements CommandUseCase dimensions = + ImageDimensions.of(mediaType, content); + UUID assetId = idGenerator.get(); String assetKey = assetKeyFor(input.originalFilename(), assetId); String objectKey = "techlog/assets/" + assetId; @@ -107,8 +112,9 @@ public class UploadStudioAssetUseCase implements CommandUseCase>> 24); + bytes[at + 1] = (byte) (value >>> 16); + bytes[at + 2] = (byte) (value >>> 8); + bytes[at + 3] = (byte) value; + } + + private static byte[] gif(int width, int height) { + byte[] bytes = new byte[10]; + bytes[0] = 'G'; + bytes[1] = 'I'; + bytes[2] = 'F'; + bytes[3] = '8'; + // 리틀엔디언이다 — PNG 와 반대다. + bytes[6] = (byte) (width & 0xFF); + bytes[7] = (byte) (width >>> 8); + bytes[8] = (byte) (height & 0xFF); + bytes[9] = (byte) (height >>> 8); + return bytes; + } + + /** APP0 세그먼트 하나를 건너뛴 뒤 SOF0 이 오는, 흔한 배치. */ + private static byte[] jpeg(int width, int height) { + byte[] bytes = new byte[2 + 4 + 12 + 11]; + int i = 0; + bytes[i++] = (byte) 0xFF; + bytes[i++] = (byte) 0xD8; + bytes[i++] = (byte) 0xFF; + bytes[i++] = (byte) 0xE0; + bytes[i++] = 0; + bytes[i++] = 14; // 길이 = 자기 자신 2 + 내용 12 + i += 12; + bytes[i++] = (byte) 0xFF; + bytes[i++] = (byte) 0xC0; + bytes[i++] = 0; + bytes[i++] = 11; + bytes[i++] = 8; // 정밀도 + bytes[i++] = (byte) (height >>> 8); + bytes[i++] = (byte) height; + bytes[i++] = (byte) (width >>> 8); + bytes[i] = (byte) width; + return bytes; + } + + @Test + void readsPngDimensionsFromTheHeader() { + assertThat(ImageDimensions.of("image/png", png(1920, 1080))) + .contains(new ImageDimensions.Size(1920, 1080)); + } + + @Test + void readsGifDimensionsLittleEndian() { + assertThat(ImageDimensions.of("image/gif", gif(640, 480))) + .contains(new ImageDimensions.Size(640, 480)); + } + + @Test + void readsJpegDimensionsPastAnEarlierSegment() { + // 세그먼트를 건너뛰지 못하면 APP0 의 내용을 크기로 읽는다 — 그 실수가 여기서 걸린다. + assertThat(ImageDimensions.of("image/jpeg", jpeg(800, 600))) + .contains(new ImageDimensions.Size(800, 600)); + } + + @Test + void answersEmptyForFormatsItDoesNotRead() { + // 모르는 것과 0 은 다르다. 화면이 그 둘을 구분하므로 여기서 섞으면 안 된다. + assertThat(ImageDimensions.of("image/webp", new byte[64])).isEqualTo(Optional.empty()); + assertThat(ImageDimensions.of("image/svg+xml", "".getBytes(java.nio.charset.StandardCharsets.UTF_8))).isEqualTo(Optional.empty()); + } + + @Test + void answersEmptyForTruncatedOrAbsentContent() { + assertThat(ImageDimensions.of("image/png", new byte[8])).isEqualTo(Optional.empty()); + assertThat(ImageDimensions.of("image/png", null)).isEqualTo(Optional.empty()); + assertThat(ImageDimensions.of(null, png(10, 10))).isEqualTo(Optional.empty()); + } + + @Test + void answersEmptyWhenTheHeaderClaimsNoArea() { + assertThat(ImageDimensions.of("image/png", png(0, 480))).isEqualTo(Optional.empty()); + } +}