thumbnail-maker 0.1.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,597 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ 썸네일 렌더러 - DSL 기반 이미지 생성
5
+ """
6
+
7
+ from PIL import Image, ImageDraw, ImageFont, ImageFilter
8
+ import json
9
+ import re
10
+ import io
11
+ from typing import Dict, List, Tuple, Optional
12
+ import os
13
+ import pathlib
14
+
15
+ import requests
16
+ from fontTools.ttLib import TTFont
17
+ try:
18
+ import woff2 # from pywoff2
19
+ except Exception:
20
+ woff2 = None
21
+ try:
22
+ # Optional: WOFF -> OTF 변환기
23
+ from woff2otf import woff2otf
24
+ except Exception:
25
+ woff2otf = None
26
+
27
+
28
+ def sanitize(name: str) -> str:
29
+ """파일명 안전화"""
30
+ return re.sub(r'[^a-zA-Z0-9\-_]', '_', name)
31
+
32
+
33
+ class ThumbnailRenderer:
34
+ """썸네일 렌더러 클래스"""
35
+
36
+ # 해상도 프리셋 매핑
37
+ RESOLUTIONS = {
38
+ '16:9': (480, 270),
39
+ '9:16': (270, 480),
40
+ '4:3': (480, 360),
41
+ '1:1': (360, 360)
42
+ }
43
+
44
+ MARGIN = 20 # 여백
45
+ LINE_HEIGHT = 1.1 # 줄간격 배수
46
+ DEFAULT_OUTLINE_THICKNESS = 4 # 기본 외곽선 두께
47
+
48
+ @staticmethod
49
+ def get_resolution(dsl_resolution: Dict) -> Tuple[int, int]:
50
+ """해상도 계산"""
51
+ if not dsl_resolution or 'type' not in dsl_resolution:
52
+ return ThumbnailRenderer.RESOLUTIONS['16:9']
53
+
54
+ res_type = dsl_resolution['type']
55
+
56
+ if res_type == 'preset':
57
+ value = dsl_resolution.get('value', '16:9')
58
+ return ThumbnailRenderer.RESOLUTIONS.get(value, ThumbnailRenderer.RESOLUTIONS['16:9'])
59
+
60
+ elif res_type == 'custom':
61
+ w = int(dsl_resolution.get('width', 480))
62
+ h = int(dsl_resolution.get('height', 270))
63
+ return (w, h) if w > 0 and h > 0 else ThumbnailRenderer.RESOLUTIONS['16:9']
64
+
65
+ elif res_type == 'fixedRatio':
66
+ ratio_str = dsl_resolution.get('ratioValue', '16:9')
67
+ parts = ratio_str.split(':')
68
+ if len(parts) != 2:
69
+ return ThumbnailRenderer.RESOLUTIONS['16:9']
70
+
71
+ try:
72
+ rw, rh = float(parts[0]), float(parts[1])
73
+ if rw <= 0 or rh <= 0:
74
+ return ThumbnailRenderer.RESOLUTIONS['16:9']
75
+
76
+ aspect_ratio = rw / rh
77
+
78
+ if 'width' in dsl_resolution and dsl_resolution['width'] is not None:
79
+ width = int(dsl_resolution['width'])
80
+ if width > 0:
81
+ height = int(width / aspect_ratio)
82
+ return (width, height)
83
+
84
+ if 'height' in dsl_resolution and dsl_resolution['height'] is not None:
85
+ height = int(dsl_resolution['height'])
86
+ if height > 0:
87
+ width = int(height * aspect_ratio)
88
+ return (width, height)
89
+ except ValueError:
90
+ pass
91
+
92
+ return ThumbnailRenderer.RESOLUTIONS['16:9']
93
+
94
+ return ThumbnailRenderer.RESOLUTIONS['16:9']
95
+
96
+ @staticmethod
97
+ def parse_font_faces(texts: List[Dict]) -> List[Dict]:
98
+ """텍스트에서 폰트 페이스 추출"""
99
+ font_faces = []
100
+ seen = set()
101
+
102
+ for txt in texts:
103
+ if 'font' in txt and 'faces' in txt['font']:
104
+ for face in txt['font']['faces']:
105
+ key = f"{face.get('name')}|{face.get('url')}|{face.get('weight')}|{face.get('style')}"
106
+ if key not in seen:
107
+ seen.add(key)
108
+ font_faces.append(face)
109
+
110
+ return font_faces
111
+
112
+ # ---------- 폰트 유틸 ----------
113
+ @staticmethod
114
+ def _fonts_dir() -> str:
115
+ d = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'fonts')
116
+ return os.path.normpath(d)
117
+
118
+ @staticmethod
119
+ def _font_safe_filename(face: Dict) -> str:
120
+ url_path = ''
121
+ try:
122
+ # url의 경로 확장자 추출 시 실패 대비
123
+ from urllib.parse import urlparse
124
+ url_path = urlparse(face.get('url', '')).path
125
+ except Exception:
126
+ pass
127
+ ext = os.path.splitext(url_path)[1].lower() or '.ttf'
128
+ name = sanitize(face.get('name', 'Font'))
129
+ weight = sanitize(str(face.get('weight', 'normal')))
130
+ style = sanitize(str(face.get('style', 'normal')))
131
+ return f"{name}-{weight}-{style}{ext}"
132
+
133
+ @staticmethod
134
+ def _font_ttf_filename(face: Dict) -> str:
135
+ name = sanitize(face.get('name', 'Font'))
136
+ weight = sanitize(str(face.get('weight', 'normal')))
137
+ style = sanitize(str(face.get('style', 'normal')))
138
+ return f"{name}-{weight}-{style}.ttf"
139
+
140
+ @staticmethod
141
+ def _download(url: str, dest_path: str) -> None:
142
+ os.makedirs(os.path.dirname(dest_path), exist_ok=True)
143
+ r = requests.get(url, timeout=30)
144
+ r.raise_for_status()
145
+ with open(dest_path, 'wb') as f:
146
+ f.write(r.content)
147
+
148
+ @staticmethod
149
+ def _convert_woff_to_ttf(woff_path: str, ttf_path: str) -> None:
150
+ # 우선 woff -> otf 변환이 가능하면 사용
151
+ if woff2otf is not None:
152
+ otf_path = os.path.splitext(ttf_path)[0] + '.otf'
153
+ with open(woff_path, 'rb') as rf:
154
+ woff_bytes = rf.read()
155
+ otf_bytes = woff2otf(woff_bytes)
156
+ with open(otf_path, 'wb') as wf:
157
+ wf.write(otf_bytes)
158
+ return
159
+ # 폴백: fontTools를 사용한 시도 (환경에 따라 실패할 수 있음)
160
+ font = TTFont(woff_path)
161
+ font.flavor = None
162
+ font.save(ttf_path)
163
+
164
+ @staticmethod
165
+ def _convert_woff2_to_ttf(woff2_path: str, ttf_path: str) -> None:
166
+ if woff2 is None:
167
+ raise RuntimeError("pywoff2 모듈이 필요합니다 (requirements.txt 참고)")
168
+ # pywoff2는 파일 경로 기반 변환 지원
169
+ # 파일 내용을 직접 디코딩해서 저장하는 방식으로 처리
170
+ with open(woff2_path, 'rb') as f:
171
+ data = f.read()
172
+ decompressed = woff2.decompress(data)
173
+ with open(ttf_path, 'wb') as f:
174
+ f.write(decompressed)
175
+
176
+ @staticmethod
177
+ def ensure_fonts(texts: List[Dict]) -> None:
178
+ """DSL 내 faces를 다운로드/변환하여 Pillow가 읽을 수 있는 TTF로 보장"""
179
+ faces = ThumbnailRenderer.parse_font_faces(texts)
180
+ if not faces:
181
+ return
182
+ fonts_dir = ThumbnailRenderer._fonts_dir()
183
+ os.makedirs(fonts_dir, exist_ok=True)
184
+
185
+ for face in faces:
186
+ url = face.get('url')
187
+ if not url:
188
+ continue
189
+ original_name = ThumbnailRenderer._font_safe_filename(face)
190
+ original_path = os.path.join(fonts_dir, original_name)
191
+ ttf_name = ThumbnailRenderer._font_ttf_filename(face)
192
+ ttf_path = os.path.join(fonts_dir, ttf_name)
193
+ otf_path = os.path.splitext(ttf_path)[0] + '.otf'
194
+
195
+ # 이미 TTF가 있으면 스킵
196
+ if os.path.exists(ttf_path) or os.path.exists(otf_path):
197
+ continue
198
+
199
+ # 로컬 파일 또는 원격 URL 구분
200
+ from urllib.parse import urlparse
201
+ parsed = urlparse(url)
202
+ is_local = False
203
+ local_path = ''
204
+ if parsed.scheme == 'file':
205
+ is_local = True
206
+ local_path = os.path.abspath(parsed.path)
207
+ elif os.path.isabs(url) and os.path.exists(url):
208
+ is_local = True
209
+ local_path = os.path.abspath(url)
210
+
211
+ if is_local:
212
+ source_path = local_path
213
+ ext = pathlib.Path(source_path).suffix.lower()
214
+ else:
215
+ # 원격: 원본 없으면 다운로드
216
+ ext = pathlib.Path(original_name).suffix.lower()
217
+ if not os.path.exists(original_path):
218
+ try:
219
+ ThumbnailRenderer._download(url, original_path)
220
+ except Exception as e:
221
+ print(f"폰트 다운로드 실패: {url} -> {e}")
222
+ continue
223
+ source_path = original_path
224
+
225
+ # 확장자별 변환/복사
226
+ try:
227
+ if ext == '.ttf' or ext == '.otf':
228
+ if source_path != ttf_path:
229
+ # 확장자 유지 복사
230
+ target = ttf_path if ext == '.ttf' else otf_path
231
+ with open(source_path, 'rb') as rf, open(target, 'wb') as wf:
232
+ wf.write(rf.read())
233
+ elif ext == '.woff2':
234
+ ThumbnailRenderer._convert_woff2_to_ttf(source_path, ttf_path)
235
+ elif ext == '.woff':
236
+ ThumbnailRenderer._convert_woff_to_ttf(source_path, ttf_path)
237
+ else:
238
+ # 미지원 확장자는 시도만 해보고 실패시 스킵
239
+ try:
240
+ ThumbnailRenderer._convert_woff_to_ttf(source_path, ttf_path)
241
+ except Exception:
242
+ pass
243
+ except Exception as e:
244
+ print(f"폰트 변환 실패: {source_path} -> {ttf_path}, {e}")
245
+
246
+ @staticmethod
247
+ def split_lines(text: str) -> List[str]:
248
+ """텍스트를 줄 단위로 분리"""
249
+ return text.split('\n')
250
+
251
+ @staticmethod
252
+ def wrap_line_by_words(
253
+ draw: ImageDraw.ImageDraw,
254
+ text: str,
255
+ font: ImageFont.FreeTypeFont,
256
+ max_width: int
257
+ ) -> List[str]:
258
+ """단어 단위로 한 줄을 주어진 최대 폭에 맞게 개행한다.
259
+
260
+ - 공백으로 단어를 나눈 뒤, 누적 폭이 넘어가면 이전까지를 한 줄로 확정한다.
261
+ - 단어 하나가 max_width보다 커도 단어 단위 래핑 원칙상 강제 분할은 하지 않는다.
262
+ - 입력이 빈 문자열이면 ['']을 반환한다.
263
+ """
264
+ if text is None or text == '':
265
+ return ['']
266
+
267
+ words = text.split(' ')
268
+ lines: List[str] = []
269
+ current = ''
270
+
271
+ for word in words:
272
+ candidate = word if current == '' else current + ' ' + word
273
+ bbox = draw.textbbox((0, 0), candidate, font=font)
274
+ candidate_width = bbox[2] - bbox[0]
275
+ if candidate_width > max_width and current != '':
276
+ lines.append(current)
277
+ current = word
278
+ else:
279
+ current = candidate
280
+
281
+ if current != '':
282
+ lines.append(current)
283
+
284
+ return lines if lines else ['']
285
+
286
+ @staticmethod
287
+ def load_font(font_path: str, size: int, weight: str = 'normal', style: str = 'normal') -> ImageFont.FreeTypeFont:
288
+ """폰트 로드"""
289
+ try:
290
+ font = ImageFont.truetype(font_path, size)
291
+ return font
292
+ except Exception as e:
293
+ print(f"폰트 로드 실패: {font_path}, {e}")
294
+ try:
295
+ return ImageFont.load_default()
296
+ except:
297
+ return ImageFont.load_default()
298
+
299
+ @staticmethod
300
+ def get_text_dimensions(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.FreeTypeFont) -> Tuple[int, int]:
301
+ """텍스트 크기 측정"""
302
+ bbox = draw.textbbox((0, 0), text, font=font)
303
+ return (bbox[2] - bbox[0], bbox[3] - bbox[1])
304
+
305
+ @staticmethod
306
+ def draw_text_with_outline(
307
+ draw: ImageDraw.ImageDraw,
308
+ text: str,
309
+ position: Tuple[int, int],
310
+ font: ImageFont.FreeTypeFont,
311
+ fill: str,
312
+ outline: Optional[Dict] = None
313
+ ):
314
+ """외곽선과 함께 텍스트 그리기"""
315
+ x, y = position
316
+
317
+ # 외곽선 그리기
318
+ if outline and outline.get('color') and outline.get('thickness', 0) > 0:
319
+ thickness = outline['thickness']
320
+ outline_color = outline['color']
321
+
322
+ # text-shadow 효과를 위/아래/좌/우로 여러 번 그리기
323
+ for dx in range(-thickness, thickness + 1):
324
+ for dy in range(-thickness, thickness + 1):
325
+ draw.text((x + dx, y + dy), text, font=font, fill=outline_color)
326
+
327
+ # 메인 텍스트 그리기
328
+ draw.text((x, y), text, font=font, fill=fill)
329
+
330
+ @staticmethod
331
+ def render_background(
332
+ img: Image.Image,
333
+ bg_config: Dict,
334
+ width: int,
335
+ height: int
336
+ ):
337
+ """배경 렌더링"""
338
+ bg_type = bg_config.get('type', 'solid')
339
+
340
+ if bg_type == 'solid':
341
+ color = bg_config.get('color', '#ffffff')
342
+ fill = Image.new('RGB', (width, height), color)
343
+ img.paste(fill)
344
+
345
+ elif bg_type == 'gradient':
346
+ colors = bg_config.get('colors', ['#ffffff', '#000000'])
347
+ gradient = Image.new('RGB', (width, height), colors[0])
348
+
349
+ # 간단한 수평 그라디언트
350
+ for i in range(width):
351
+ ratio = i / width
352
+ r1, g1, b1 = [int(c) for c in (colors[0][1:3], colors[0][3:5], colors[0][5:7])]
353
+ r2, g2, b2 = [int(c) for c in (colors[-1][1:3], colors[-1][3:5], colors[-1][5:7])]
354
+
355
+ r = int(r1 + (r2 - r1) * ratio)
356
+ g = int(g1 + (g2 - g1) * ratio)
357
+ b = int(b1 + (b2 - b1) * ratio)
358
+
359
+ for j in range(height):
360
+ gradient.putpixel((i, j), (r, g, b))
361
+
362
+ img.paste(gradient)
363
+
364
+ elif bg_type == 'image':
365
+ img_path = bg_config.get('imagePath', '')
366
+
367
+ # base64 데이터 URL 처리
368
+ if img_path.startswith('data:image'):
369
+ # base64 디코딩 처리
370
+ import base64
371
+ header, encoded = img_path.split(',', 1)
372
+ image_data = base64.b64decode(encoded)
373
+ bg_img = Image.open(io.BytesIO(image_data))
374
+ else:
375
+ if not os.path.exists(img_path):
376
+ print(f"배경 이미지를 찾을 수 없음: {img_path}")
377
+ return
378
+
379
+ bg_img = Image.open(img_path)
380
+
381
+ # cover 알고리즘으로 리사이즈
382
+ img_ratio = bg_img.width / bg_img.height
383
+ canvas_ratio = width / height
384
+
385
+ if img_ratio > canvas_ratio:
386
+ # 이미지가 더 넓음: 높이 기준으로 맞춤
387
+ new_height = height
388
+ new_width = int(height * img_ratio)
389
+ bg_img = bg_img.resize((new_width, new_height), Image.Resampling.LANCZOS)
390
+ left = (new_width - width) // 2
391
+ bg_img = bg_img.crop((left, 0, left + width, height))
392
+ else:
393
+ # 이미지가 더 높음: 너비 기준으로 맞춤
394
+ new_width = width
395
+ new_height = int(width / img_ratio)
396
+ bg_img = bg_img.resize((new_width, new_height), Image.Resampling.LANCZOS)
397
+ top = (new_height - height) // 2
398
+ bg_img = bg_img.crop((0, top, width, top + height))
399
+
400
+ # 블러 효과 적용
401
+ image_blur = bg_config.get('imageBlur', 0)
402
+ if image_blur > 0:
403
+ bg_img = bg_img.filter(ImageFilter.GaussianBlur(radius=image_blur))
404
+
405
+ # 투명도 적용
406
+ image_opacity = bg_config.get('imageOpacity', 1.0)
407
+ if image_opacity < 1.0 and bg_img.mode in ('RGBA', 'LA'):
408
+ alpha = bg_img.split()[-1]
409
+ alpha = alpha.point(lambda p: int(p * image_opacity))
410
+ bg_img.putalpha(alpha)
411
+ elif image_opacity < 1.0:
412
+ bg_img = bg_img.convert('RGBA')
413
+ alpha = bg_img.split()[-1]
414
+ alpha = alpha.point(lambda p: int(p * image_opacity))
415
+ bg_img.putalpha(alpha)
416
+
417
+ # 배경 위에 붙이기
418
+ if bg_img.mode == 'RGBA':
419
+ img.paste(bg_img, (0, 0), bg_img)
420
+ else:
421
+ img.paste(bg_img, (0, 0))
422
+
423
+ @staticmethod
424
+ def render_thumbnail(dsl: Dict, output_path: str):
425
+ """DSL을 읽어서 썸네일 생성"""
426
+ thumbnail_config = dsl.get('Thumbnail', {})
427
+
428
+ # 해상도 결정
429
+ resolution = ThumbnailRenderer.get_resolution(thumbnail_config.get('Resolution', {}))
430
+ width, height = resolution
431
+
432
+ # 이미지 생성
433
+ img = Image.new('RGB', (width, height), '#ffffff')
434
+
435
+ # 배경 렌더링
436
+ if 'Background' in thumbnail_config:
437
+ ThumbnailRenderer.render_background(img, thumbnail_config['Background'], width, height)
438
+
439
+ # 텍스트 렌더링
440
+ if 'Texts' in thumbnail_config:
441
+ draw = ImageDraw.Draw(img)
442
+
443
+ for txt_config in thumbnail_config['Texts']:
444
+ if not txt_config.get('enabled', True):
445
+ continue
446
+
447
+ # 기본값 설정
448
+ content = txt_config.get('content', '')
449
+ fontSize = txt_config.get('fontSize', 48)
450
+ fontFamily = txt_config.get('font', {}).get('name', 'Arial')
451
+ color = txt_config.get('color', '#000000')
452
+ gridPosition = txt_config.get('gridPosition', 'tl')
453
+ fontWeight = txt_config.get('fontWeight', 'normal')
454
+ fontStyle = txt_config.get('fontStyle', 'normal')
455
+ lineHeight = txt_config.get('lineHeight', ThumbnailRenderer.LINE_HEIGHT)
456
+ wordWrap = txt_config.get('wordWrap', False)
457
+ outline = txt_config.get('outline')
458
+
459
+ # 외곽선 기본값
460
+ if outline and (not outline.get('thickness') or outline.get('thickness') < 0):
461
+ outline['thickness'] = ThumbnailRenderer.DEFAULT_OUTLINE_THICKNESS
462
+
463
+ # faces 기반 폰트 확보 (필요 시 다운로드/변환)
464
+ try:
465
+ ThumbnailRenderer.ensure_fonts(thumbnail_config.get('Texts', []))
466
+ except Exception as e:
467
+ print(f"폰트 확보 과정 경고: {e}")
468
+
469
+ # 확보된 TTF 경로 우선 시도
470
+ fonts_dir = ThumbnailRenderer._fonts_dir()
471
+ base_name = f"{sanitize(fontFamily)}-{sanitize(str(fontWeight))}-{sanitize(str(fontStyle))}"
472
+ ttf_candidate = os.path.join(fonts_dir, base_name + '.ttf')
473
+ otf_candidate = os.path.join(fonts_dir, base_name + '.otf')
474
+
475
+ # 로컬 정적 폰트 폴더(프로젝트 루트/fonts)도 탐색
476
+ legacy_ttf = os.path.join('fonts', f"{sanitize(fontFamily)}-{fontWeight}-{fontStyle}.ttf")
477
+ legacy_woff = os.path.join('fonts', f"{sanitize(fontFamily)}-{fontWeight}-{fontStyle}.woff")
478
+ font_path = None
479
+ if os.path.exists(ttf_candidate):
480
+ font_path = ttf_candidate
481
+ elif os.path.exists(otf_candidate):
482
+ font_path = otf_candidate
483
+ elif os.path.exists(legacy_ttf):
484
+ font_path = legacy_ttf
485
+ elif os.path.exists(legacy_woff):
486
+ # 가능한 경우 변환 시도 후 사용
487
+ try:
488
+ os.makedirs(fonts_dir, exist_ok=True)
489
+ conv_target_ttf = os.path.join(fonts_dir, base_name + '.ttf')
490
+ conv_target_otf = os.path.join(fonts_dir, base_name + '.otf')
491
+ if os.path.splitext(legacy_woff)[1].lower() == '.woff':
492
+ ThumbnailRenderer._convert_woff_to_ttf(legacy_woff, conv_target_ttf)
493
+ font_path = (
494
+ conv_target_ttf if os.path.exists(conv_target_ttf)
495
+ else (conv_target_otf if os.path.exists(conv_target_otf) else None)
496
+ )
497
+ except Exception:
498
+ font_path = None
499
+
500
+ # 폰트 로드 + 한글 폴백
501
+ font = None
502
+ if font_path and os.path.exists(font_path):
503
+ font = ThumbnailRenderer.load_font(font_path, fontSize)
504
+ if font is None:
505
+ # Windows 한글 폴백 (맑은 고딕)
506
+ for fallback in [
507
+ os.path.join(os.environ.get('WINDIR', 'C:\\Windows'), 'Fonts', 'malgun.ttf'),
508
+ os.path.join(os.environ.get('WINDIR', 'C:\\Windows'), 'Fonts', 'malgunsl.ttf'),
509
+ ]:
510
+ try:
511
+ if os.path.exists(fallback):
512
+ font = ImageFont.truetype(fallback, fontSize)
513
+ break
514
+ except Exception:
515
+ pass
516
+ if font is None:
517
+ try:
518
+ font = ImageFont.truetype("arial.ttf", fontSize)
519
+ except Exception:
520
+ font = ImageFont.load_default()
521
+
522
+ # 줄 분리 및 단어 단위 줄바꿈 처리
523
+ initial_lines = ThumbnailRenderer.split_lines(content)
524
+ effective_max_width = width - 2 * ThumbnailRenderer.MARGIN
525
+
526
+ if wordWrap:
527
+ processed_lines: List[str] = []
528
+ for init_line in initial_lines:
529
+ if init_line == '':
530
+ processed_lines.append('')
531
+ else:
532
+ wrapped = ThumbnailRenderer.wrap_line_by_words(
533
+ draw=draw,
534
+ text=init_line,
535
+ font=font,
536
+ max_width=effective_max_width,
537
+ )
538
+ processed_lines.extend(wrapped)
539
+ lines = processed_lines
540
+ else:
541
+ lines = initial_lines
542
+
543
+ # 라인 높이 계산
544
+ lh = int(fontSize * lineHeight)
545
+ totalTextHeight = len(lines) * lh
546
+
547
+ # 그리드 위치 결정
548
+ row = gridPosition[0] if len(gridPosition) > 0 else 't' # t, m, b
549
+ col = gridPosition[1] if len(gridPosition) > 1 else 'l' # l, c, r
550
+
551
+ # X 위치 결정
552
+ if col == 'l':
553
+ targetX = ThumbnailRenderer.MARGIN
554
+ textAlign = 'left'
555
+ elif col == 'c':
556
+ targetX = width // 2
557
+ textAlign = 'center'
558
+ else: # 'r'
559
+ targetX = width - ThumbnailRenderer.MARGIN
560
+ textAlign = 'right'
561
+
562
+ # Y 위치 결정
563
+ if row == 't':
564
+ baseY = ThumbnailRenderer.MARGIN
565
+ elif row == 'm':
566
+ baseY = (height // 2) - (totalTextHeight // 2)
567
+ else: # 'b'
568
+ baseY = height - ThumbnailRenderer.MARGIN - totalTextHeight
569
+
570
+ # 텍스트 그리기
571
+ for line_idx, line in enumerate(lines):
572
+ currentY = baseY + (line_idx * lh)
573
+
574
+ # 텍스트 크기 측정
575
+ bbox = draw.textbbox((0, 0), line, font=font)
576
+ textWidth = bbox[2] - bbox[0]
577
+
578
+ # 정렬에 따른 X 위치 조정
579
+ x = targetX
580
+ if textAlign == 'center':
581
+ x = targetX - textWidth // 2
582
+ elif textAlign == 'right':
583
+ x = targetX - textWidth
584
+
585
+ # 텍스트 그리기
586
+ if outline and outline.get('color') and outline.get('thickness', 0) > 0:
587
+ ThumbnailRenderer.draw_text_with_outline(
588
+ draw, line, (x, currentY), font, color, outline
589
+ )
590
+ else:
591
+ draw.text((x, currentY), line, font=font, fill=color)
592
+
593
+ # 저장
594
+ img.save(output_path, 'PNG')
595
+ print(f"[OK] 썸네일 생성 완료: {output_path}")
596
+
597
+