Skip to content

Commit 430fc5a

Browse files
authored
Merge pull request #80 from KB-Hackathon/refact/image-size-font
[refact] 이미지 사이즈에 따른 폰트 크기 결정
2 parents 562e864 + 9a3ef41 commit 430fc5a

1 file changed

Lines changed: 161 additions & 121 deletions

File tree

src/main/java/hackathon/kb/chakchak/domain/product/service/ProductImageOverlayService.java

Lines changed: 161 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -13,194 +13,234 @@
1313
import java.awt.*;
1414
import java.awt.font.FontRenderContext;
1515
import java.awt.font.GlyphVector;
16-
import java.awt.geom.Rectangle2D;
1716
import java.awt.image.BufferedImage;
1817
import java.io.ByteArrayInputStream;
1918
import java.io.ByteArrayOutputStream;
2019
import java.net.URL;
20+
import java.net.URLConnection;
21+
import java.util.Arrays;
22+
import java.util.HashSet;
23+
import java.util.Set;
2124

2225
@Service
2326
@RequiredArgsConstructor
2427
@Transactional
2528
public class ProductImageOverlayService {
2629
private final S3StorageService s3StorageService;
2730

28-
public String processFirstImage(String src, String companyName, String title, String line2) {
31+
// 기준 해상도/폰트 (810x1215에서 title=43, sub=34)
32+
private static final int BASE_W = 810;
33+
private static final int BASE_H = 1215;
34+
private static final int BASE_TITLE = 43;
35+
private static final int BASE_SUB = 34;
36+
37+
// 클램프(과도한 확대/축소 방지)
38+
private static final int MIN_TITLE = 12, MAX_TITLE = 140;
39+
private static final int MIN_SUB = 10, MAX_SUB = 120;
2940

30-
// 텍스트
31-
String line1 = companyName + " | " + title;
41+
// 폰트 후보
42+
private static final String[] FONT_CANDIDATES = {
43+
"Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans CJK KR", "NanumGothic"
44+
};
3245

33-
// 한글 폰트 주의: 서버/OS에 따라 폰트명이 다릅니다. (아래 순서대로 시도)
34-
Font titleFont = pickFont(new String[]{
35-
"Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans CJK KR", "NanumGothic"
36-
}, Font.BOLD, Math.round(43)); // 제목 크기
37-
Font subFont = pickFont(new String[]{
38-
"Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans CJK KR", "NanumGothic"
39-
}, Font.PLAIN, Math.round(34)); // 부제 크기
46+
public String processFirstImage(String src, String companyName, String title, String line2) {
47+
String line1 = (companyName == null ? "" : companyName) + " | " + (title == null ? "" : title);
4048

4149
try {
42-
return overlayBottomLeft(src, line1, line2, titleFont, subFont).toString();
50+
URL uploaded = overlayBottomLeftScaled(src, line1.trim(), safe(line2));
51+
return uploaded.toString();
4352
} catch (Exception e) {
4453
throw new BusinessException(ResponseCode.S3_UPLOAD_FAIL);
4554
}
4655
}
4756

48-
private BufferedImage cropToTwoThree(BufferedImage src) {
49-
int w = src.getWidth();
50-
int h = src.getHeight();
51-
double targetRatio = 2.0 / 3.0;
52-
53-
int newW = w;
54-
int newH = h;
55-
56-
double currentRatio = (double) w / h;
57-
58-
if (currentRatio > targetRatio) {
59-
// 원본이 더 가로로 넓음 → 가로를 줄여야 함
60-
newW = (int) Math.round(h * targetRatio);
61-
} else {
62-
// 원본이 더 세로로 길거나 같음 → 세로를 줄여야 함
63-
newH = (int) Math.round(w / targetRatio);
64-
}
65-
66-
int x = (w - newW) / 2;
67-
int y = (h - newH) / 2;
68-
69-
return src.getSubimage(x, y, newW, newH);
70-
}
71-
72-
private URL overlayBottomLeft(String src,
73-
String title, String subtitle,
74-
Font titleFont, Font subtitleFont) throws Exception {
57+
/** 이미지 로딩 → 2:3 크롭 → 폰트 스케일 → 폭 초과 시 2차 축소 → 하단 그라데이션 + 좌측 정렬 텍스트 → S3 업로드 */
58+
private URL overlayBottomLeftScaled(String src,
59+
String title, String subtitle) throws Exception {
7560

76-
// 1. 원본 불러오기
77-
BufferedImage img = ImageIO.read(new URL(src));
61+
// 1) 이미지 로드 (타임아웃)
62+
BufferedImage img = readImageWithTimeout(src, 5000, 5000);
7863

79-
// 2. 정사각형으로 크롭
64+
// 2) 2:3 비율로 크롭
8065
img = cropToTwoThree(img);
8166

8267
int w = img.getWidth();
8368
int h = img.getHeight();
8469

85-
Graphics2D g = img.createGraphics();
86-
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
87-
g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
88-
89-
// 하단 그라데이션
90-
int padY = Math.max(40, h / 20);
91-
int barH = padY + (int)(getTextHeight(g, titleFont) + getTextHeight(g, subtitleFont)) + padY;
92-
paintBottomGradient(g, w, h, barH);
93-
94-
// 좌측 패딩 (글자와 화면 왼쪽 간격)
95-
int padX = Math.max(40, w / 30);
96-
97-
// 텍스트 Y 배치 (하단 기준)
98-
int y = h - padY - (int)getTextHeight(g, subtitleFont);
99-
100-
// 부제
101-
g.setFont(subtitleFont);
102-
drawLeftAlignedOutlinedText(g, subtitle, padX, y, Color.WHITE, new Color(0,0,0,150), 2f);
103-
104-
// 제목 (그 위에)
105-
y -= (int)getTextHeight(g, titleFont) + Math.max(10, h/200);
106-
g.setFont(titleFont);
107-
drawLeftAlignedOutlinedText(g, title, padX, y, Color.WHITE, new Color(0,0,0,180), 3f);
70+
// 3) 해상도 기반 1차 스케일
71+
double scale = Math.min(w / (double) BASE_W, h / (double) BASE_H);
72+
int titleSize = clamp((int) Math.round(BASE_TITLE * scale), MIN_TITLE, MAX_TITLE);
73+
int subSize = clamp((int) Math.round(BASE_SUB * scale), MIN_SUB, MAX_SUB);
74+
75+
Font titleFont = pickFont(FONT_CANDIDATES, Font.BOLD, titleSize);
76+
Font subtitleFont = pickFont(FONT_CANDIDATES, Font.PLAIN, subSize);
77+
78+
// ===== 마진 설정 (여기서 조절) =====
79+
int padLeft = Math.max(40, (int)Math.round(w * 0.05)); // 왼쪽 마진
80+
int padRight = Math.max(40, (int)Math.round(w * 0.05)); // 오른쪽 마진
81+
int padBottom = Math.max(40, (int)Math.round(h * 0.05)); // 아래쪽 마진
82+
int spacing = Math.max(10, h / 200); // 제목–부제 간격
83+
// ==================================
84+
85+
// 텍스트 최대 폭: 좌/우 마진을 제외한 영역
86+
int maxWidthPx = w - padLeft - padRight;
87+
88+
// 미리 렌더링 컨텍스트로 실제 폭 측정
89+
Graphics2D gMeasure = img.createGraphics();
90+
gMeasure.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
91+
FontRenderContext frc = gMeasure.getFontRenderContext();
92+
93+
double titleWidth = stringWidth(titleFont, title, frc);
94+
double subWidth = (subtitle == null || subtitle.isBlank()) ? 0 : stringWidth(subtitleFont, subtitle, frc);
95+
gMeasure.dispose();
96+
97+
double overRatio = 1.0;
98+
if (titleWidth > maxWidthPx) overRatio = Math.max(overRatio, titleWidth / maxWidthPx);
99+
if (subWidth > maxWidthPx) overRatio = Math.max(overRatio, subWidth / maxWidthPx);
100+
101+
if (overRatio > 1.0) {
102+
double k = 0.98 / overRatio; // 살짝 여유
103+
titleSize = clamp((int)Math.floor(titleSize * k), MIN_TITLE, MAX_TITLE);
104+
subSize = clamp((int)Math.floor(subSize * k), MIN_SUB, MAX_SUB);
105+
titleFont = titleFont.deriveFont((float) titleSize);
106+
subtitleFont = subtitleFont.deriveFont((float) subSize);
107+
}
108108

109-
g.dispose();
109+
// 5) 실제 그리기
110+
Graphics2D g = img.createGraphics();
111+
try {
112+
g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
113+
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
114+
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
115+
g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
116+
g.setRenderingHint(RenderingHints.KEY_FRACTIONALMETRICS, RenderingHints.VALUE_FRACTIONALMETRICS_ON);
117+
118+
float titleH = textHeight(g, titleFont);
119+
float subH = (subtitle == null || subtitle.isBlank()) ? 0 : textHeight(g, subtitleFont);
120+
121+
// 그라데이션 바 높이 = 아래 패딩 + 텍스트(제목 + 부제 + 간격) + 아래 패딩
122+
int textBlockH = Math.round(titleH + (subH > 0 ? (spacing + subH) : 0));
123+
int barH = padBottom + textBlockH + padBottom;
124+
paintBottomGradient(g, w, h, barH);
125+
126+
// 베이스라인: 하단 마진 기준
127+
int baselineY = h - padBottom;
128+
129+
// 부제 (아래쪽)
130+
if (subH > 0) {
131+
g.setFont(subtitleFont);
132+
float subStroke = Math.max(1.5f, subSize * 0.06f);
133+
drawLeftAlignedOutlinedText(g, subtitle, padLeft, baselineY,
134+
Color.WHITE, new Color(0,0,0,170), subStroke);
135+
baselineY -= Math.round(subH + spacing);
136+
}
137+
138+
// 제목 (그 위)
139+
g.setFont(titleFont);
140+
float titleStroke = Math.max(2.0f, titleSize * 0.07f);
141+
drawLeftAlignedOutlinedText(g, title, padLeft, baselineY,
142+
Color.WHITE, new Color(0,0,0,200), titleStroke);
143+
144+
} finally {
145+
g.dispose();
146+
}
110147

111-
// 3. BufferedImage → ByteArrayOutputStream 변환
148+
// 6) JPEG 인코딩 → S3 업로드
112149
ByteArrayOutputStream os = new ByteArrayOutputStream();
113150
ImageIO.write(img, "jpg", os);
114-
115-
// 4. ByteArrayOutputStream → MultipartFile 변환
116151
byte[] bytes = os.toByteArray();
117-
MultipartFile multipartFile = new MockMultipartFile(
118-
"file",
119-
"overlayed.jpg",
120-
"image/jpeg",
121-
new ByteArrayInputStream(bytes)
122-
);
123152

124-
// 5. S3 업로드 호출
125-
return s3StorageService.uploadImages(multipartFile); // 기존 S3 업로드 서비스 호출
153+
MultipartFile mf = new MockMultipartFile(
154+
"file", "overlayed.jpg", "image/jpeg", new ByteArrayInputStream(bytes)
155+
);
156+
return s3StorageService.uploadImages(mf);
126157
}
127158

128159
/** 왼쪽 정렬 + 외곽선 텍스트 */
129160
private void drawLeftAlignedOutlinedText(Graphics2D g, String text, int leftX, int baselineY,
130-
Color fillColor, Color strokeColor, float strokeWidth) {
161+
Color fillColor, Color strokeColor, float strokeWidth) {
162+
if (text == null || text.isBlank()) return;
131163
FontRenderContext frc = g.getFontRenderContext();
132164
GlyphVector gv = g.getFont().createGlyphVector(frc, text);
133165
Shape shape = gv.getOutline(leftX, baselineY);
134166

135-
136-
// 내부 채움
137-
g.setColor(fillColor);
138-
g.fill(shape);
167+
Graphics2D g2 = (Graphics2D) g.create();
168+
try {
169+
g2.setStroke(new BasicStroke(strokeWidth, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
170+
g2.setColor(strokeColor);
171+
g2.draw(shape); // 외곽선
172+
g2.setColor(fillColor);
173+
g2.fill(shape); // 내부 채움
174+
} finally {
175+
g2.dispose();
176+
}
139177
}
140178

141-
/** 하단에 투명 → 블랙 반투명 그라데이션 */
179+
/** 하단에 투명 → 반투명 블랙 그라데이션 */
142180
private void paintBottomGradient(Graphics2D g, int width, int height, int barH) {
143181
GradientPaint gp = new GradientPaint(
144182
0, height - barH, new Color(0,0,0,0),
145-
0, height, new Color(0,0,0,170)
183+
0, height, new Color(0,0,0,170)
146184
);
147185
Paint old = g.getPaint();
148186
g.setPaint(gp);
149187
g.fillRect(0, height - barH, width, barH);
150188
g.setPaint(old);
151189
}
152190

153-
/** 상단에 위쪽이 짙은 반투명 블랙 → 투명 그라데이션 */
154-
private void paintTopGradient(Graphics2D g, int width, int height) {
155-
GradientPaint gp = new GradientPaint(
156-
0, 0, new Color(0,0,0,170),
157-
0, height, new Color(0,0,0,0)
158-
);
159-
Paint old = g.getPaint();
160-
g.setPaint(gp);
161-
g.fillRect(0, 0, width, height);
162-
g.setPaint(old);
163-
}
164-
165-
/** 가운데 정렬 + 외곽선 텍스트 */
166-
private void drawCenteredOutlinedText(Graphics2D g, String text, int centerX, int baselineY,
167-
Color fillColor, Color strokeColor, float strokeWidth) {
168-
FontRenderContext frc = g.getFontRenderContext();
169-
GlyphVector gv = g.getFont().createGlyphVector(frc, text);
170-
Shape shape = gv.getOutline();
171-
Rectangle2D bounds = shape.getBounds2D();
172-
173-
// 가운데 정렬을 위해 x 오프셋
174-
double x = centerX - bounds.getCenterX();
175-
double y = baselineY - bounds.getY(); // baseline 보정
176-
177-
Graphics2D g2 = (Graphics2D) g.create();
178-
g2.translate(x, y);
191+
/** 2:3 비율로 중앙 크롭 */
192+
private BufferedImage cropToTwoThree(BufferedImage src) {
193+
int w = src.getWidth();
194+
int h = src.getHeight();
195+
double target = 2.0 / 3.0; // width / height
179196

180-
// 스트로크(외곽선)
181-
g2.setStroke(new BasicStroke(strokeWidth, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
182-
g2.setColor(strokeColor);
183-
g2.draw(shape);
197+
int newW = w, newH = h;
198+
double cur = w / (double) h;
184199

185-
// 내부 채움
186-
g2.setColor(fillColor);
187-
g2.fill(shape);
200+
if (cur > target) newW = (int) Math.round(h * target); // 가로가 더 넓음 → 가로 잘라냄
201+
else newH = (int) Math.round(w / target); // 세로가 더 김 → 세로 잘라냄
188202

189-
g2.dispose();
203+
int x = (w - newW) / 2;
204+
int y = (h - newH) / 2;
205+
return src.getSubimage(x, y, newW, newH);
190206
}
191207

192-
private float getTextHeight(Graphics2D g, Font font) {
193-
FontMetrics fm = g.getFontMetrics(font);
208+
/** 텍스트 높이(= ascent + descent) */
209+
private float textHeight(Graphics2D g, Font f) {
210+
FontMetrics fm = g.getFontMetrics(f);
194211
return fm.getAscent() + fm.getDescent();
195212
}
196213

214+
/** 문자열 폭 */
215+
private double stringWidth(Font f, String s, FontRenderContext frc) {
216+
if (s == null || s.isBlank()) return 0;
217+
return f.getStringBounds(s, frc).getWidth();
218+
}
219+
197220
/** 가능한 폰트명을 순회하며 첫 가용 폰트를 고름 (없으면 Dialog 대체) */
198221
private Font pickFont(String[] candidates, int style, int size) {
199222
GraphicsEnvironment ge = GraphicsEnvironment.getLocalGraphicsEnvironment();
200-
var installed = java.util.Set.of(ge.getAvailableFontFamilyNames());
223+
Set<String> installed = new HashSet<>(Arrays.asList(ge.getAvailableFontFamilyNames()));
201224
for (String name : candidates) {
202225
if (installed.contains(name)) return new Font(name, style, size);
203226
}
204227
return new Font("Dialog", style, size);
205228
}
229+
230+
/** 네트워크 이미지 로드에 타임아웃 부여 */
231+
private BufferedImage readImageWithTimeout(String src, int connectMs, int readMs) throws Exception {
232+
if (src.startsWith("http://") || src.startsWith("https://")) {
233+
URLConnection conn = new URL(src).openConnection();
234+
conn.setConnectTimeout(connectMs);
235+
conn.setReadTimeout(readMs);
236+
try (var in = conn.getInputStream()) {
237+
return ImageIO.read(in);
238+
}
239+
} else {
240+
return ImageIO.read(new java.io.File(src));
241+
}
242+
}
243+
244+
private static int clamp(int v, int lo, int hi) { return Math.max(lo, Math.min(hi, v)); }
245+
private static String safe(String s) { return s == null ? "" : s; }
206246
}

0 commit comments

Comments
 (0)