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.
- thumbnail_maker/__init__.py +9 -0
- thumbnail_maker/__main__.py +112 -0
- thumbnail_maker/cli.py +140 -0
- thumbnail_maker/gui/__init__.py +10 -0
- thumbnail_maker/gui/dsl_manager.py +351 -0
- thumbnail_maker/gui/font_utils.py +39 -0
- thumbnail_maker/gui/handlers.py +299 -0
- thumbnail_maker/gui/main_window.py +177 -0
- thumbnail_maker/gui/preview_thread.py +28 -0
- thumbnail_maker/gui/widgets.py +386 -0
- thumbnail_maker/renderer.py +597 -0
- thumbnail_maker/upload.py +279 -0
- thumbnail_maker-0.1.6.dist-info/METADATA +159 -0
- thumbnail_maker-0.1.6.dist-info/RECORD +16 -0
- thumbnail_maker-0.1.6.dist-info/WHEEL +4 -0
- thumbnail_maker-0.1.6.dist-info/entry_points.txt +2 -0
|
@@ -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
|
+
|