|
13 | 13 | import java.awt.*; |
14 | 14 | import java.awt.font.FontRenderContext; |
15 | 15 | import java.awt.font.GlyphVector; |
16 | | -import java.awt.geom.Rectangle2D; |
17 | 16 | import java.awt.image.BufferedImage; |
18 | 17 | import java.io.ByteArrayInputStream; |
19 | 18 | import java.io.ByteArrayOutputStream; |
20 | 19 | import java.net.URL; |
| 20 | +import java.net.URLConnection; |
| 21 | +import java.util.Arrays; |
| 22 | +import java.util.HashSet; |
| 23 | +import java.util.Set; |
21 | 24 |
|
22 | 25 | @Service |
23 | 26 | @RequiredArgsConstructor |
24 | 27 | @Transactional |
25 | 28 | public class ProductImageOverlayService { |
26 | 29 | private final S3StorageService s3StorageService; |
27 | 30 |
|
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; |
29 | 40 |
|
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 | + }; |
32 | 45 |
|
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); |
40 | 48 |
|
41 | 49 | try { |
42 | | - return overlayBottomLeft(src, line1, line2, titleFont, subFont).toString(); |
| 50 | + URL uploaded = overlayBottomLeftScaled(src, line1.trim(), safe(line2)); |
| 51 | + return uploaded.toString(); |
43 | 52 | } catch (Exception e) { |
44 | 53 | throw new BusinessException(ResponseCode.S3_UPLOAD_FAIL); |
45 | 54 | } |
46 | 55 | } |
47 | 56 |
|
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 { |
75 | 60 |
|
76 | | - // 1. 원본 불러오기 |
77 | | - BufferedImage img = ImageIO.read(new URL(src)); |
| 61 | + // 1) 이미지 로드 (타임아웃) |
| 62 | + BufferedImage img = readImageWithTimeout(src, 5000, 5000); |
78 | 63 |
|
79 | | - // 2. 정사각형으로 크롭 |
| 64 | + // 2) 2:3 비율로 크롭 |
80 | 65 | img = cropToTwoThree(img); |
81 | 66 |
|
82 | 67 | int w = img.getWidth(); |
83 | 68 | int h = img.getHeight(); |
84 | 69 |
|
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 | + } |
108 | 108 |
|
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 | + } |
110 | 147 |
|
111 | | - // 3. BufferedImage → ByteArrayOutputStream 변환 |
| 148 | + // 6) JPEG 인코딩 → S3 업로드 |
112 | 149 | ByteArrayOutputStream os = new ByteArrayOutputStream(); |
113 | 150 | ImageIO.write(img, "jpg", os); |
114 | | - |
115 | | - // 4. ByteArrayOutputStream → MultipartFile 변환 |
116 | 151 | byte[] bytes = os.toByteArray(); |
117 | | - MultipartFile multipartFile = new MockMultipartFile( |
118 | | - "file", |
119 | | - "overlayed.jpg", |
120 | | - "image/jpeg", |
121 | | - new ByteArrayInputStream(bytes) |
122 | | - ); |
123 | 152 |
|
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); |
126 | 157 | } |
127 | 158 |
|
128 | 159 | /** 왼쪽 정렬 + 외곽선 텍스트 */ |
129 | 160 | 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; |
131 | 163 | FontRenderContext frc = g.getFontRenderContext(); |
132 | 164 | GlyphVector gv = g.getFont().createGlyphVector(frc, text); |
133 | 165 | Shape shape = gv.getOutline(leftX, baselineY); |
134 | 166 |
|
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 | + } |
139 | 177 | } |
140 | 178 |
|
141 | | - /** 하단에 투명 → 블랙 반투명 그라데이션 */ |
| 179 | + /** 하단에 투명 → 반투명 블랙 그라데이션 바 */ |
142 | 180 | private void paintBottomGradient(Graphics2D g, int width, int height, int barH) { |
143 | 181 | GradientPaint gp = new GradientPaint( |
144 | 182 | 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) |
146 | 184 | ); |
147 | 185 | Paint old = g.getPaint(); |
148 | 186 | g.setPaint(gp); |
149 | 187 | g.fillRect(0, height - barH, width, barH); |
150 | 188 | g.setPaint(old); |
151 | 189 | } |
152 | 190 |
|
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 |
179 | 196 |
|
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; |
184 | 199 |
|
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); // 세로가 더 김 → 세로 잘라냄 |
188 | 202 |
|
189 | | - g2.dispose(); |
| 203 | + int x = (w - newW) / 2; |
| 204 | + int y = (h - newH) / 2; |
| 205 | + return src.getSubimage(x, y, newW, newH); |
190 | 206 | } |
191 | 207 |
|
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); |
194 | 211 | return fm.getAscent() + fm.getDescent(); |
195 | 212 | } |
196 | 213 |
|
| 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 | + |
197 | 220 | /** 가능한 폰트명을 순회하며 첫 가용 폰트를 고름 (없으면 Dialog 대체) */ |
198 | 221 | private Font pickFont(String[] candidates, int style, int size) { |
199 | 222 | GraphicsEnvironment ge = GraphicsEnvironment.getLocalGraphicsEnvironment(); |
200 | | - var installed = java.util.Set.of(ge.getAvailableFontFamilyNames()); |
| 223 | + Set<String> installed = new HashSet<>(Arrays.asList(ge.getAvailableFontFamilyNames())); |
201 | 224 | for (String name : candidates) { |
202 | 225 | if (installed.contains(name)) return new Font(name, style, size); |
203 | 226 | } |
204 | 227 | return new Font("Dialog", style, size); |
205 | 228 | } |
| 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; } |
206 | 246 | } |
0 commit comments