fix: record an uploaded image's dimensions

Every uploaded image was invisible, and the endpoint that serves them was not
the reason — it answers 200 with the right bytes. The browser never asked for
them.

Upload stored null for width and height, so the renderer had nothing to lay out
with and fell back to 1x1. A 1x1 box with `loading="lazy"` never intersects the
viewport, so the fetch is never made: the figure was not slow or broken, it was
never requested.

Dimensions now come from the file header — PNG, GIF and JPEG, read directly
rather than decoded. ImageIO would pull in `java.desktop`, and a runtime image
without that module would fail every upload rather than one figure. WebP and
SVG are left unread and answer "unknown", which is true: WebP has three header
shapes and an SVG may carry no pixel size at all.

The offsets are covered by tests against real bytes. They are not something
review can check by eye, and the JPEG case walks past an earlier segment —
reading the first one it finds would have produced a confident wrong answer.
This commit is contained in:
DongHyeonka
2026-08-21 17:57:09 +09:00
parent 96521a94d4
commit e65b9e2c33
3 changed files with 202 additions and 2 deletions
@@ -0,0 +1,87 @@
package dev.caskeleton.application.techlog.studio.service;
import java.util.Optional;
/**
* 업로드한 그림의 픽셀 크기를 헤더에서 읽는다.
*
* <p>업로드가 이 값을 기록하지 않아 모든 그림이 화면에서 사라졌다. 렌더러는 치수를 모르면 자리를
* 잡을 수 없고, 그때 쓰던 대체값 1×1 이 {@code loading="lazy"} 와 만나 브라우저가 영영 가져오지
* 않는 상자가 됐다. 화면 쪽은 모르는 치수를 정직하게 비우도록 고쳤고, 이쪽은 애초에 알 수 있는
* 값을 기록한다.
*
* <p>디코딩하지 않고 헤더만 읽는다. {@code ImageIO} 는 {@code java.desktop} 모듈을 끌어오고
* 런타임 이미지가 그것을 담고 있으리라는 보장이 없다 — 없는 배포에서 업로드가 통째로 실패하는
* 것보다, 아는 형식의 헤더 몇 바이트를 직접 읽는 편이 낫다. 모르는 형식은 비운다: 크기를 모르는
* 것과 크기가 0 인 것은 다르고, 화면은 그 둘을 구분한다.
*/
public final class ImageDimensions {
/** 픽셀 크기. 둘 다 양수일 때만 만든다. */
public record Size(int width, int height) {}
private ImageDimensions() {}
public static Optional<Size> 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<Size> png(byte[] c) {
if (c.length < 24) {
return Optional.empty();
}
return size(int32(c, 16), int32(c, 20));
}
/** 논리 화면 기술자. 리틀엔디언 16비트 둘. */
private static Optional<Size> 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<Size> 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> size(int width, int height) {
return width > 0 && height > 0 ? Optional.of(new Size(width, height)) : Optional.empty();
}
}
@@ -82,6 +82,11 @@ public class UploadStudioAssetUseCase implements CommandUseCase<UploadAssetComma
"the uploaded content is not one of the media types this Studio accepts");
}
// 저장하기 전에 읽는다. 바이트는 여기 이미 있고, 나중에 다시 받아 오면 저장소 왕복이 한 번
// 더 생긴다.
java.util.Optional<ImageDimensions.Size> 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<UploadAssetComma
storedKey,
input.originalFilename(),
input.byteSize(),
null,
null,
// 헤더에서 읽는다. 이 둘이 비어 있으면 화면이 그림의 자리를 잡지 못한다.
dimensions.map(ImageDimensions.Size::width).orElse(null),
dimensions.map(ImageDimensions.Size::height).orElse(null),
sha256(content),
input.altText(),
input.decorative(),
@@ -0,0 +1,107 @@
package dev.caskeleton.application.techlog.studio.service;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.Optional;
import org.junit.jupiter.api.Test;
/**
* 헤더 오프셋은 눈으로 맞는지 알 수 없다. 업로드가 치수를 기록하지 않아 모든 그림이 화면에서
* 사라진 적이 있으므로, 이 값을 읽는 코드는 실제 바이트로 확인한다.
*/
class ImageDimensionsTest {
private static byte[] png(int width, int height) {
byte[] bytes = new byte[24];
bytes[0] = (byte) 0x89;
bytes[1] = 'P';
bytes[2] = 'N';
bytes[3] = 'G';
// 8..15 는 청크 길이와 "IHDR"; 폭·높이는 16 부터다.
writeInt(bytes, 16, width);
writeInt(bytes, 20, height);
return bytes;
}
private static void writeInt(byte[] bytes, int at, int value) {
bytes[at] = (byte) (value >>> 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", "<svg/>".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());
}
}