thumbnail-maker 0.1.1__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.

Potentially problematic release.


This version of thumbnail-maker might be problematic. Click here for more details.

thumbnail_maker/gui.py ADDED
@@ -0,0 +1,861 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ PySide6 기반 썸네일 생성 GUI
5
+ """
6
+
7
+ import sys
8
+ import json
9
+ import os
10
+ import base64
11
+ from PySide6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
12
+ QHBoxLayout, QLabel, QPushButton, QColorDialog,
13
+ QSpinBox, QTextEdit, QCheckBox, QComboBox,
14
+ QGroupBox, QTabWidget, QLineEdit, QSlider,
15
+ QFileDialog, QMessageBox, QGridLayout)
16
+ from PySide6.QtCore import Qt, Signal, QThread
17
+ from PySide6.QtGui import QColor, QFont
18
+ from .renderer import ThumbnailRenderer, sanitize
19
+ try:
20
+ from fontTools.ttLib import TTFont
21
+ except Exception:
22
+ TTFont = None
23
+ import tempfile
24
+ import zipfile
25
+ import shutil
26
+
27
+
28
+ class PreviewThread(QThread):
29
+ """미리보기 생성 스레드"""
30
+ preview_ready = Signal(str) # preview file path
31
+
32
+ def __init__(self, dsl):
33
+ super().__init__()
34
+ self.dsl = dsl
35
+ self.error_message = None
36
+
37
+ def run(self):
38
+ preview_path = 'preview_temp.png'
39
+ try:
40
+ ThumbnailRenderer.render_thumbnail(self.dsl, preview_path)
41
+ self.preview_ready.emit(preview_path)
42
+ except Exception as e:
43
+ self.error_message = str(e)
44
+ self.preview_ready.emit('')
45
+
46
+
47
+ class ThumbnailGUI(QMainWindow):
48
+ """메인 GUI 클래스"""
49
+
50
+ def __init__(self):
51
+ super().__init__()
52
+ self.setWindowTitle('썸네일 생성기')
53
+ self.setGeometry(100, 100, 1200, 800)
54
+
55
+ # 메인 위젯
56
+ main_widget = QWidget()
57
+ self.setCentralWidget(main_widget)
58
+
59
+ # 메인 레이아웃
60
+ main_layout = QHBoxLayout(main_widget)
61
+
62
+ # 왼쪽: 미리보기
63
+ preview_widget = self.create_preview_widget()
64
+ main_layout.addWidget(preview_widget, 2)
65
+
66
+ # 오른쪽: 설정 패널
67
+ settings_widget = self.create_settings_widget()
68
+ main_layout.addWidget(settings_widget, 1)
69
+
70
+ # 기본값 초기화
71
+ self.init_default_values()
72
+
73
+ def create_preview_widget(self):
74
+ """미리보기 위젯 생성"""
75
+ group = QGroupBox('미리보기')
76
+ layout = QVBoxLayout()
77
+
78
+ self.preview_label = QLabel('미리보기가 여기에 표시됩니다')
79
+ self.preview_label.setMinimumSize(480, 270)
80
+ self.preview_label.setStyleSheet('border: 2px solid gray; background: white;')
81
+ self.preview_label.setAlignment(Qt.AlignCenter)
82
+
83
+ btn_layout = QHBoxLayout()
84
+
85
+ self.preview_btn = QPushButton('미리보기 생성')
86
+ self.preview_btn.clicked.connect(self.generate_preview)
87
+
88
+ self.save_btn = QPushButton('저장')
89
+ self.save_btn.clicked.connect(self.save_thumbnail)
90
+
91
+ self.show_dsl_btn = QPushButton('DSL 보기')
92
+ self.show_dsl_btn.clicked.connect(self.show_dsl_dialog)
93
+
94
+ self.save_dsl_btn = QPushButton('DSL 저장')
95
+ self.save_dsl_btn.clicked.connect(self.save_dsl)
96
+
97
+ self.save_thl_btn = QPushButton('패키지 저장(.thl)')
98
+ self.save_thl_btn.clicked.connect(self.save_thl_package)
99
+
100
+ btn_layout.addWidget(self.preview_btn)
101
+ btn_layout.addWidget(self.save_btn)
102
+ btn_layout.addWidget(self.show_dsl_btn)
103
+ btn_layout.addWidget(self.save_dsl_btn)
104
+ btn_layout.addWidget(self.save_thl_btn)
105
+
106
+ layout.addWidget(self.preview_label)
107
+ layout.addLayout(btn_layout)
108
+ group.setLayout(layout)
109
+
110
+ return group
111
+
112
+ def create_settings_widget(self):
113
+ """설정 위젯 생성"""
114
+ scroll = QWidget()
115
+ layout = QVBoxLayout()
116
+
117
+ # 탭 위젯
118
+ tabs = QTabWidget()
119
+
120
+ # 해상도 탭
121
+ res_tab = self.create_resolution_tab()
122
+ tabs.addTab(res_tab, '해상도')
123
+
124
+ # 배경 탭
125
+ bg_tab = self.create_background_tab()
126
+ tabs.addTab(bg_tab, '배경')
127
+
128
+ # 제목 탭
129
+ title_tab = self.create_title_tab()
130
+ tabs.addTab(title_tab, '제목')
131
+
132
+ # 부제목 탭
133
+ subtitle_tab = self.create_subtitle_tab()
134
+ tabs.addTab(subtitle_tab, '부제목')
135
+
136
+ layout.addWidget(tabs)
137
+ scroll.setLayout(layout)
138
+
139
+ return scroll
140
+
141
+ def create_resolution_tab(self):
142
+ """해상도 설정 탭"""
143
+ widget = QWidget()
144
+ layout = QVBoxLayout()
145
+
146
+ # 모드 선택
147
+ self.res_mode = QComboBox()
148
+ self.res_mode.addItems(['preset', 'fixedRatio', 'custom'])
149
+ self.res_mode.currentTextChanged.connect(self.on_resolution_mode_changed)
150
+
151
+ layout.addWidget(QLabel('크기 모드:'))
152
+ layout.addWidget(self.res_mode)
153
+
154
+ # 비율 선택
155
+ self.aspect_ratio = QComboBox()
156
+ self.aspect_ratio.addItems(['16:9', '9:16', '4:3', '1:1'])
157
+ self.aspect_ratio.currentTextChanged.connect(self.update_preview)
158
+
159
+ layout.addWidget(QLabel('비율:'))
160
+ layout.addWidget(self.aspect_ratio)
161
+
162
+ # 너비/높이
163
+ self.width_spin = QSpinBox()
164
+ self.width_spin.setRange(100, 2000)
165
+ self.width_spin.setValue(480)
166
+ self.width_spin.valueChanged.connect(self.update_preview)
167
+
168
+ self.height_spin = QSpinBox()
169
+ self.height_spin.setRange(100, 2000)
170
+ self.height_spin.setValue(270)
171
+ self.height_spin.valueChanged.connect(self.update_preview)
172
+
173
+ layout.addWidget(QLabel('너비:'))
174
+ layout.addWidget(self.width_spin)
175
+ layout.addWidget(QLabel('높이:'))
176
+ layout.addWidget(self.height_spin)
177
+
178
+ layout.addStretch()
179
+ widget.setLayout(layout)
180
+ return widget
181
+
182
+ def create_background_tab(self):
183
+ """배경 설정 탭"""
184
+ widget = QWidget()
185
+ layout = QVBoxLayout()
186
+
187
+ # 배경 타입
188
+ 유형 = QComboBox()
189
+ 유형.addItems(['solid', 'gradient', 'image'])
190
+ 유형.currentTextChanged.connect(self.update_preview)
191
+ self.bg_type = 유형
192
+
193
+ layout.addWidget(QLabel('배경 타입:'))
194
+ layout.addWidget(유형)
195
+
196
+ # 배경 색상
197
+ self.bg_color_btn = QPushButton('색상 선택')
198
+ self.bg_color_btn.clicked.connect(self.select_bg_color)
199
+ self.bg_color = '#a3e635'
200
+
201
+ layout.addWidget(QLabel('배경 색상:'))
202
+ layout.addWidget(self.bg_color_btn)
203
+
204
+ # 배경 이미지 경로
205
+ self.bg_image_path = QLineEdit()
206
+ self.bg_image_path.setPlaceholderText('이미지 파일 경로')
207
+
208
+ bg_img_btn = QPushButton('이미지 선택')
209
+ bg_img_btn.clicked.connect(self.select_background_image)
210
+
211
+ layout.addWidget(QLabel('배경 이미지:'))
212
+ layout.addWidget(self.bg_image_path)
213
+ layout.addWidget(bg_img_btn)
214
+
215
+ # 이미지 투명도
216
+ self.bg_opacity = QSlider(Qt.Horizontal)
217
+ self.bg_opacity.setRange(0, 100)
218
+ self.bg_opacity.setValue(100)
219
+ self.bg_opacity.valueChanged.connect(self.update_preview)
220
+
221
+ layout.addWidget(QLabel('이미지 투명도:'))
222
+ layout.addWidget(self.bg_opacity)
223
+
224
+ # 이미지 블러
225
+ self.bg_blur = QSlider(Qt.Horizontal)
226
+ self.bg_blur.setRange(0, 20)
227
+ self.bg_blur.setValue(0)
228
+ self.bg_blur.valueChanged.connect(self.update_preview)
229
+
230
+ layout.addWidget(QLabel('이미지 블러:'))
231
+ layout.addWidget(self.bg_blur)
232
+
233
+ layout.addStretch()
234
+ widget.setLayout(layout)
235
+ return widget
236
+
237
+ def create_title_tab(self):
238
+ """제목 설정 탭"""
239
+ widget = QWidget()
240
+ layout = QVBoxLayout()
241
+
242
+ # 제목 텍스트
243
+ self.title_text = QTextEdit()
244
+ self.title_text.setPlaceholderText('제목 텍스트 입력 (여러 줄 가능)')
245
+ self.title_text.textChanged.connect(self.update_preview)
246
+
247
+ layout.addWidget(QLabel('제목 텍스트:'))
248
+ layout.addWidget(self.title_text)
249
+
250
+ # 폰트 설정 모드 (웹/로컬)
251
+ self.title_font_source = QComboBox()
252
+ self.title_font_source.addItems(['웹 폰트 URL', '로컬 폰트 파일'])
253
+ self.title_font_source.currentTextChanged.connect(self.update_preview)
254
+
255
+ layout.addWidget(QLabel('폰트 소스:'))
256
+ layout.addWidget(self.title_font_source)
257
+
258
+ # 폰트 설정 (이름/URL/굵기/스타일)
259
+ self.title_font_name = QLineEdit()
260
+ self.title_font_name.setPlaceholderText('예: SBAggroB')
261
+ self.title_font_name.textChanged.connect(self.update_preview)
262
+
263
+ self.title_font_url = QLineEdit()
264
+ self.title_font_url.setPlaceholderText('예: https://.../SBAggroB.woff')
265
+ self.title_font_url.textChanged.connect(self.update_preview)
266
+
267
+ # 로컬 파일 경로 + 선택 버튼
268
+ row_title_local = QHBoxLayout()
269
+ self.title_font_file = QLineEdit()
270
+ self.title_font_file.setPlaceholderText('예: C:/Windows/Fonts/malgun.ttf')
271
+ self.title_font_file.textChanged.connect(self.on_title_font_file_changed)
272
+ btn_title_font_browse = QPushButton('찾기')
273
+ def _pick_title_font():
274
+ path, _ = QFileDialog.getOpenFileName(self, '폰트 파일 선택', '', 'Fonts (*.ttf *.otf *.woff *.woff2)')
275
+ if path:
276
+ self.title_font_file.setText(path)
277
+ # 파일 선택 시에는 이름을 강제로 세팅
278
+ self.set_title_font_name_from_path(path)
279
+ btn_title_font_browse.clicked.connect(_pick_title_font)
280
+ row_title_local.addWidget(self.title_font_file)
281
+ row_title_local.addWidget(btn_title_font_browse)
282
+
283
+ self.title_font_weight = QComboBox()
284
+ self.title_font_weight.addItems(['normal', 'bold'])
285
+ self.title_font_weight.currentTextChanged.connect(self.update_preview)
286
+
287
+ self.title_font_style = QComboBox()
288
+ self.title_font_style.addItems(['normal', 'italic'])
289
+ self.title_font_style.currentTextChanged.connect(self.update_preview)
290
+
291
+ layout.addWidget(QLabel('폰트 이름:'))
292
+ layout.addWidget(self.title_font_name)
293
+ # URL/로컬 입력 영역
294
+ self.label_title_font_url = QLabel('폰트 URL (WOFF/WOFF2/TTF):')
295
+ layout.addWidget(self.label_title_font_url)
296
+ layout.addWidget(self.title_font_url)
297
+ self.label_title_font_file = QLabel('로컬 폰트 파일 경로:')
298
+ layout.addWidget(self.label_title_font_file)
299
+ layout.addLayout(row_title_local)
300
+ layout.addWidget(QLabel('폰트 굵기/스타일:'))
301
+ row_font = QHBoxLayout()
302
+ row_font.addWidget(self.title_font_weight)
303
+ row_font.addWidget(self.title_font_style)
304
+ layout.addLayout(row_font)
305
+
306
+ # 제목 색상
307
+ self.title_color_btn = QPushButton('색상 선택')
308
+ self.title_color_btn.clicked.connect(self.select_title_color)
309
+ self.title_color = '#4ade80'
310
+
311
+ layout.addWidget(QLabel('제목 색상:'))
312
+ layout.addWidget(self.title_color_btn)
313
+
314
+ # 폰트 크기
315
+ self.title_font_size = QSpinBox()
316
+ self.title_font_size.setRange(8, 200)
317
+ self.title_font_size.setValue(48)
318
+ self.title_font_size.valueChanged.connect(self.update_preview)
319
+
320
+ layout.addWidget(QLabel('폰트 크기:'))
321
+ layout.addWidget(self.title_font_size)
322
+
323
+ # 외곽선
324
+ self.title_outline_check = QCheckBox('외곽선 사용')
325
+ self.title_outline_check.stateChanged.connect(self.update_preview)
326
+
327
+ self.title_outline_thickness = QSpinBox()
328
+ self.title_outline_thickness.setRange(1, 20)
329
+ self.title_outline_thickness.setValue(7)
330
+ self.title_outline_thickness.valueChanged.connect(self.update_preview)
331
+
332
+ layout.addWidget(self.title_outline_check)
333
+ layout.addWidget(QLabel('외곽선 두께:'))
334
+ layout.addWidget(self.title_outline_thickness)
335
+
336
+ # 위치 (9 그리드)
337
+ self.title_position = QComboBox()
338
+ self.title_position.addItems(['tl', 'tc', 'tr', 'ml', 'mc', 'mr', 'bl', 'bc', 'br'])
339
+ self.title_position.currentTextChanged.connect(self.update_preview)
340
+
341
+ layout.addWidget(QLabel('위치:'))
342
+ layout.addWidget(self.title_position)
343
+
344
+ layout.addStretch()
345
+ widget.setLayout(layout)
346
+ return widget
347
+
348
+ def create_subtitle_tab(self):
349
+ """부제목 설정 탭"""
350
+ widget = QWidget()
351
+ layout = QVBoxLayout()
352
+
353
+ # 부제목 표시 여부
354
+ self.subtitle_visible = QCheckBox('부제목 표시')
355
+ self.subtitle_visible.setChecked(True)
356
+ self.subtitle_visible.stateChanged.connect(self.update_preview)
357
+
358
+ layout.addWidget(self.subtitle_visible)
359
+
360
+ # 부제목 텍스트
361
+ self.subtitle_text = QTextEdit()
362
+ self.subtitle_text.setPlaceholderText('부제목 텍스트 입력 (여러 줄 가능)')
363
+ self.subtitle_text.textChanged.connect(self.update_preview)
364
+
365
+ layout.addWidget(QLabel('부제목 텍스트:'))
366
+ layout.addWidget(self.subtitle_text)
367
+
368
+ # 폰트 설정 모드 (웹/로컬)
369
+ self.subtitle_font_source = QComboBox()
370
+ self.subtitle_font_source.addItems(['웹 폰트 URL', '로컬 폰트 파일'])
371
+ self.subtitle_font_source.currentTextChanged.connect(self.update_preview)
372
+
373
+ layout.addWidget(QLabel('폰트 소스:'))
374
+ layout.addWidget(self.subtitle_font_source)
375
+
376
+ # 폰트 설정 (이름/URL/굵기/스타일)
377
+ self.subtitle_font_name = QLineEdit()
378
+ self.subtitle_font_name.setPlaceholderText('예: SBAggroB')
379
+ self.subtitle_font_name.textChanged.connect(self.update_preview)
380
+
381
+ self.subtitle_font_url = QLineEdit()
382
+ self.subtitle_font_url.setPlaceholderText('예: https://.../SBAggroB.woff')
383
+ self.subtitle_font_url.textChanged.connect(self.update_preview)
384
+
385
+ # 로컬 파일 경로 + 선택 버튼
386
+ row_sub_local = QHBoxLayout()
387
+ self.subtitle_font_file = QLineEdit()
388
+ self.subtitle_font_file.setPlaceholderText('예: C:/Windows/Fonts/malgun.ttf')
389
+ self.subtitle_font_file.textChanged.connect(self.on_subtitle_font_file_changed)
390
+ btn_sub_font_browse = QPushButton('찾기')
391
+ def _pick_sub_font():
392
+ path, _ = QFileDialog.getOpenFileName(self, '폰트 파일 선택', '', 'Fonts (*.ttf *.otf *.woff *.woff2)')
393
+ if path:
394
+ self.subtitle_font_file.setText(path)
395
+ # 파일 선택 시에는 이름을 강제로 세팅
396
+ self.set_subtitle_font_name_from_path(path)
397
+ btn_sub_font_browse.clicked.connect(_pick_sub_font)
398
+ row_sub_local.addWidget(self.subtitle_font_file)
399
+ row_sub_local.addWidget(btn_sub_font_browse)
400
+
401
+ self.subtitle_font_weight = QComboBox()
402
+ self.subtitle_font_weight.addItems(['normal', 'bold'])
403
+ self.subtitle_font_weight.currentTextChanged.connect(self.update_preview)
404
+
405
+ self.subtitle_font_style = QComboBox()
406
+ self.subtitle_font_style.addItems(['normal', 'italic'])
407
+ self.subtitle_font_style.currentTextChanged.connect(self.update_preview)
408
+
409
+ layout.addWidget(QLabel('폰트 이름:'))
410
+ layout.addWidget(self.subtitle_font_name)
411
+ self.label_subtitle_font_url = QLabel('폰트 URL (WOFF/WOFF2/TTF):')
412
+ layout.addWidget(self.label_subtitle_font_url)
413
+ layout.addWidget(self.subtitle_font_url)
414
+ self.label_subtitle_font_file = QLabel('로컬 폰트 파일 경로:')
415
+ layout.addWidget(self.label_subtitle_font_file)
416
+ layout.addLayout(row_sub_local)
417
+ layout.addWidget(QLabel('폰트 굵기/스타일:'))
418
+ row_sub_font = QHBoxLayout()
419
+ row_sub_font.addWidget(self.subtitle_font_weight)
420
+ row_sub_font.addWidget(self.subtitle_font_style)
421
+ layout.addLayout(row_sub_font)
422
+
423
+ # 부제목 색상
424
+ self.subtitle_color_btn = QPushButton('색상 선택')
425
+ self.subtitle_color_btn.clicked.connect(self.select_subtitle_color)
426
+ self.subtitle_color = '#ffffff'
427
+
428
+ layout.addWidget(QLabel('부제목 색상:'))
429
+ layout.addWidget(self.subtitle_color_btn)
430
+
431
+ # 폰트 크기
432
+ self.subtitle_font_size = QSpinBox()
433
+ self.subtitle_font_size.setRange(8, 200)
434
+ self.subtitle_font_size.setValue(24)
435
+ self.subtitle_font_size.valueChanged.connect(self.update_preview)
436
+
437
+ layout.addWidget(QLabel('폰트 크기:'))
438
+ layout.addWidget(self.subtitle_font_size)
439
+
440
+ # 위치 (9 그리드)
441
+ self.subtitle_position = QComboBox()
442
+ self.subtitle_position.addItems(['tl', 'tc', 'tr', 'ml', 'mc', 'mr', 'bl', 'bc', 'br'])
443
+ self.subtitle_position.setCurrentText('bl')
444
+ self.subtitle_position.currentTextChanged.connect(self.update_preview)
445
+
446
+ layout.addWidget(QLabel('위치:'))
447
+ layout.addWidget(self.subtitle_position)
448
+
449
+ layout.addStretch()
450
+ widget.setLayout(layout)
451
+ return widget
452
+
453
+ def init_default_values(self):
454
+ """기본값 초기화"""
455
+ self.title_text.setPlainText('10초만에\n썸네일 만드는 법')
456
+ self.subtitle_text.setPlainText('쉽고 빠르게 썸네일을 만드는 법\n= 퀵썸네일 쓰기')
457
+ # 기본 폰트 값 (노느늘 SBAggroB)
458
+ self.title_font_name.setText('SBAggroB')
459
+ self.title_font_url.setText('https://fastly.jsdelivr.net/gh/projectnoonnu/noonfonts_2108@1.1/SBAggroB.woff')
460
+ self.title_font_weight.setCurrentText('bold')
461
+ self.title_font_style.setCurrentText('normal')
462
+
463
+ self.subtitle_font_name.setText('SBAggroB')
464
+ self.subtitle_font_url.setText('https://fastly.jsdelivr.net/gh/projectnoonnu/noonfonts_2108@1.1/SBAggroB.woff')
465
+ self.subtitle_font_weight.setCurrentText('normal')
466
+ self.subtitle_font_style.setCurrentText('normal')
467
+ self.update_preview()
468
+
469
+ def on_resolution_mode_changed(self, mode):
470
+ """해상도 모드 변경 시 처리"""
471
+ if mode == 'preset':
472
+ self.aspect_ratio.setEnabled(True)
473
+ elif mode == 'fixedRatio':
474
+ self.aspect_ratio.setEnabled(True)
475
+ else: # custom
476
+ self.aspect_ratio.setEnabled(False)
477
+ self.update_preview()
478
+
479
+ def select_bg_color(self):
480
+ """배경 색상 선택"""
481
+ color = QColorDialog.getColor(QColor(self.bg_color))
482
+ if color.isValid():
483
+ self.bg_color = color.name()
484
+ self.update_preview()
485
+
486
+ def select_title_color(self):
487
+ """제목 색상 선택"""
488
+ color = QColorDialog.getColor(QColor(self.title_color))
489
+ if color.isValid():
490
+ self.title_color = color.name()
491
+ self.update_preview()
492
+
493
+ def select_subtitle_color(self):
494
+ """부제목 색상 선택"""
495
+ color = QColorDialog.getColor(QColor(self.subtitle_color))
496
+ if color.isValid():
497
+ self.subtitle_color = color.name()
498
+ self.update_preview()
499
+
500
+ def select_background_image(self):
501
+ """배경 이미지 선택"""
502
+ file_path, _ = QFileDialog.getOpenFileName(
503
+ self, '배경 이미지 선택', '', 'Images (*.png *.jpg *.jpeg *.gif *.bmp)'
504
+ )
505
+ if file_path:
506
+ self.bg_image_path.setText(file_path)
507
+ self.update_preview()
508
+
509
+ def generate_dsl(self):
510
+ """DSL 생성"""
511
+ # 해상도 결정
512
+ res_mode = self.res_mode.currentText()
513
+ if res_mode == 'preset':
514
+ resolution = {
515
+ 'type': 'preset',
516
+ 'value': self.aspect_ratio.currentText()
517
+ }
518
+ elif res_mode == 'fixedRatio':
519
+ resolution = {
520
+ 'type': 'fixedRatio',
521
+ 'ratioValue': self.aspect_ratio.currentText(),
522
+ 'width': self.width_spin.value()
523
+ }
524
+ else: # custom
525
+ resolution = {
526
+ 'type': 'custom',
527
+ 'width': self.width_spin.value(),
528
+ 'height': self.height_spin.value()
529
+ }
530
+
531
+ # 배경 결정
532
+ bg_type = self.bg_type.currentText()
533
+ if bg_type == 'image' and self.bg_image_path.text():
534
+ # 이미지를 base64로 변환
535
+ with open(self.bg_image_path.text(), 'rb') as f:
536
+ image_data = f.read()
537
+ base64_str = base64.b64encode(image_data).decode('utf-8')
538
+ data_url = f"data:image/png;base64,{base64_str}"
539
+
540
+ background = {
541
+ 'type': 'image',
542
+ 'imagePath': data_url,
543
+ 'imageOpacity': self.bg_opacity.value() / 100.0,
544
+ 'imageBlur': self.bg_blur.value()
545
+ }
546
+ elif bg_type == 'gradient':
547
+ background = {
548
+ 'type': 'gradient',
549
+ 'colors': [self.bg_color, '#000000']
550
+ }
551
+ else: # solid
552
+ background = {
553
+ 'type': 'solid',
554
+ 'color': self.bg_color
555
+ }
556
+
557
+ # 텍스트 설정
558
+ # 제목 폰트 소스 분기
559
+ title_use_local = self.title_font_source.currentText() == '로컬 폰트 파일'
560
+ title_face_url = self.title_font_file.text() if title_use_local and self.title_font_file.text() else (self.title_font_url.text() or 'https://fastly.jsdelivr.net/gh/projectnoonnu/noonfonts_2108@1.1/SBAggroB.woff')
561
+ # 부제목 폰트 소스 분기
562
+ subtitle_use_local = self.subtitle_font_source.currentText() == '로컬 폰트 파일'
563
+ subtitle_face_url = self.subtitle_font_file.text() if subtitle_use_local and self.subtitle_font_file.text() else (self.subtitle_font_url.text() or 'https://fastly.jsdelivr.net/gh/projectnoonnu/noonfonts_2108@1.1/SBAggroB.woff')
564
+
565
+ texts = [
566
+ {
567
+ 'type': 'title',
568
+ 'content': self.title_text.toPlainText(),
569
+ 'gridPosition': self.title_position.currentText(),
570
+ 'font': {
571
+ 'name': self.title_font_name.text() or 'SBAggroB',
572
+ 'faces': [{
573
+ 'name': self.title_font_name.text() or 'SBAggroB',
574
+ 'url': title_face_url,
575
+ 'weight': self.title_font_weight.currentText() or 'bold',
576
+ 'style': self.title_font_style.currentText() or 'normal'
577
+ }]
578
+ },
579
+ 'fontSize': self.title_font_size.value(),
580
+ 'color': self.title_color,
581
+ 'fontWeight': self.title_font_weight.currentText() or 'bold',
582
+ 'fontStyle': self.title_font_style.currentText() or 'normal',
583
+ 'lineHeight': 1.1,
584
+ 'wordWrap': False,
585
+ 'outline': {
586
+ 'thickness': self.title_outline_thickness.value(),
587
+ 'color': '#000000'
588
+ } if self.title_outline_check.isChecked() else None,
589
+ 'enabled': True
590
+ }
591
+ ]
592
+
593
+ # 부제목 추가
594
+ if self.subtitle_visible.isChecked():
595
+ texts.append({
596
+ 'type': 'subtitle',
597
+ 'content': self.subtitle_text.toPlainText(),
598
+ 'gridPosition': self.subtitle_position.currentText(),
599
+ 'font': {
600
+ 'name': self.subtitle_font_name.text() or 'SBAggroB',
601
+ 'faces': [{
602
+ 'name': self.subtitle_font_name.text() or 'SBAggroB',
603
+ 'url': subtitle_face_url,
604
+ 'weight': self.subtitle_font_weight.currentText() or 'normal',
605
+ 'style': self.subtitle_font_style.currentText() or 'normal'
606
+ }]
607
+ },
608
+ 'fontSize': self.subtitle_font_size.value(),
609
+ 'color': self.subtitle_color,
610
+ 'fontWeight': self.subtitle_font_weight.currentText() or 'normal',
611
+ 'fontStyle': self.subtitle_font_style.currentText() or 'normal',
612
+ 'lineHeight': 1.1,
613
+ 'wordWrap': False,
614
+ 'outline': None,
615
+ 'enabled': True
616
+ })
617
+
618
+ dsl = {
619
+ 'Thumbnail': {
620
+ 'Resolution': resolution,
621
+ 'Background': background,
622
+ 'Texts': texts
623
+ },
624
+ 'TemplateMeta': {
625
+ 'name': '',
626
+ 'shareable': False
627
+ }
628
+ }
629
+
630
+ return dsl
631
+
632
+ def update_preview(self):
633
+ """미리보기 업데이트"""
634
+ # URL/로컬 입력 영역 가시성 토글
635
+ is_title_local = self.title_font_source.currentText() == '로컬 폰트 파일'
636
+ self.label_title_font_url.setVisible(not is_title_local)
637
+ self.title_font_url.setVisible(not is_title_local)
638
+ self.label_title_font_file.setVisible(is_title_local)
639
+ self.title_font_file.setVisible(is_title_local)
640
+ # 파일 찾기 버튼은 레이아웃에 포함되어 있어 개별 위젯 접근 불가하므로 입력창 표시로 충분
641
+
642
+ is_sub_local = self.subtitle_font_source.currentText() == '로컬 폰트 파일'
643
+ self.label_subtitle_font_url.setVisible(not is_sub_local)
644
+ self.subtitle_font_url.setVisible(not is_sub_local)
645
+ self.label_subtitle_font_file.setVisible(is_sub_local)
646
+ self.subtitle_font_file.setVisible(is_sub_local)
647
+
648
+ dsl = self.generate_dsl()
649
+ self.current_dsl = dsl
650
+
651
+ # ---------- 폰트 이름 자동 추출 ----------
652
+ @staticmethod
653
+ def _infer_font_name_from_file(file_path: str) -> str:
654
+ try:
655
+ ext = os.path.splitext(file_path)[1].lower()
656
+ if TTFont and ext in ('.ttf', '.otf') and os.path.exists(file_path):
657
+ tt = TTFont(file_path)
658
+ # Prefer full font name (nameID=4), fallback to font family (nameID=1)
659
+ name = None
660
+ for rec in tt['name'].names:
661
+ if rec.nameID in (4, 1):
662
+ try:
663
+ val = rec.toUnicode()
664
+ except Exception:
665
+ val = rec.string.decode(rec.getEncoding(), errors='ignore')
666
+ if val:
667
+ name = val
668
+ if rec.nameID == 4:
669
+ break
670
+ if name:
671
+ return name
672
+ except Exception:
673
+ pass
674
+ # Fallback: 파일명(확장자 제외)
675
+ return os.path.splitext(os.path.basename(file_path))[0]
676
+
677
+ def set_title_font_name_from_path(self, path: str) -> None:
678
+ inferred = self._infer_font_name_from_file(path)
679
+ if inferred:
680
+ self.title_font_name.setText(inferred)
681
+ self.update_preview()
682
+
683
+ def set_subtitle_font_name_from_path(self, path: str) -> None:
684
+ inferred = self._infer_font_name_from_file(path)
685
+ if inferred:
686
+ self.subtitle_font_name.setText(inferred)
687
+ self.update_preview()
688
+
689
+ def on_title_font_file_changed(self):
690
+ # 경로를 직접 입력한 경우: 이름 칸이 비어 있을 때만 채움
691
+ path = self.title_font_file.text().strip()
692
+ if path and not self.title_font_name.text().strip():
693
+ self.set_title_font_name_from_path(path)
694
+ else:
695
+ self.update_preview()
696
+
697
+ def on_subtitle_font_file_changed(self):
698
+ # 경로를 직접 입력한 경우: 이름 칸이 비어 있을 때만 채움
699
+ path = self.subtitle_font_file.text().strip()
700
+ if path and not self.subtitle_font_name.text().strip():
701
+ self.set_subtitle_font_name_from_path(path)
702
+ else:
703
+ self.update_preview()
704
+
705
+ def generate_preview(self):
706
+ """미리보기 생성"""
707
+ if not hasattr(self, 'current_dsl'):
708
+ self.update_preview()
709
+
710
+ self.preview_btn.setEnabled(False)
711
+ self.preview_btn.setText('생성 중...')
712
+
713
+ # 스레드에서 생성
714
+ self.preview_thread = PreviewThread(self.current_dsl)
715
+ self.preview_thread.preview_ready.connect(self.on_preview_ready)
716
+ self.preview_thread.start()
717
+
718
+ def on_preview_ready(self, file_path):
719
+ """미리보기 준비됨"""
720
+ from PySide6.QtGui import QPixmap
721
+ if file_path and os.path.exists(file_path):
722
+ pixmap = QPixmap(file_path)
723
+ self.preview_label.setPixmap(pixmap.scaled(
724
+ 480, 270, Qt.KeepAspectRatio, Qt.SmoothTransformation
725
+ ))
726
+ else:
727
+ msg = self.preview_thread.error_message or '미리보기 생성 중 오류가 발생했습니다.'
728
+ QMessageBox.critical(self, '에러', msg)
729
+
730
+ self.preview_btn.setEnabled(True)
731
+ self.preview_btn.setText('미리보기 생성')
732
+
733
+ def save_thumbnail(self):
734
+ """썸네일 저장"""
735
+ if not hasattr(self, 'current_dsl'):
736
+ QMessageBox.warning(self, '경고', '먼저 미리보기를 생성해주세요.')
737
+ return
738
+
739
+ file_path, _ = QFileDialog.getSaveFileName(
740
+ self, '썸네일 저장', 'thumbnail.png', 'Images (*.png)'
741
+ )
742
+
743
+ if file_path:
744
+ try:
745
+ ThumbnailRenderer.render_thumbnail(self.current_dsl, file_path)
746
+ QMessageBox.information(self, '완료', f'저장 완료: {file_path}')
747
+ except Exception as e:
748
+ QMessageBox.critical(self, '에러', f'저장 실패: {e}')
749
+
750
+ def show_dsl_dialog(self):
751
+ """현재 DSL을 JSON으로 출력하는 다이얼로그"""
752
+ if not hasattr(self, 'current_dsl'):
753
+ self.update_preview()
754
+ dsl = getattr(self, 'current_dsl', self.generate_dsl())
755
+ try:
756
+ text = json.dumps(dsl, ensure_ascii=False, indent=2)
757
+ except Exception:
758
+ text = str(dsl)
759
+
760
+ from PySide6.QtWidgets import QDialog, QVBoxLayout, QPlainTextEdit, QDialogButtonBox
761
+ dlg = QDialog(self)
762
+ dlg.setWindowTitle('현재 DSL 보기')
763
+ v = QVBoxLayout(dlg)
764
+ editor = QPlainTextEdit()
765
+ editor.setPlainText(text)
766
+ editor.setReadOnly(True)
767
+ v.addWidget(editor)
768
+ btns = QDialogButtonBox(QDialogButtonBox.Close)
769
+ btns.rejected.connect(dlg.reject)
770
+ btns.accepted.connect(dlg.accept)
771
+ v.addWidget(btns)
772
+ dlg.resize(700, 500)
773
+ dlg.exec()
774
+
775
+ def save_dsl(self):
776
+ """현재 DSL을 JSON 파일로 저장"""
777
+ if not hasattr(self, 'current_dsl'):
778
+ self.update_preview()
779
+ dsl = getattr(self, 'current_dsl', self.generate_dsl())
780
+
781
+ file_path, _ = QFileDialog.getSaveFileName(
782
+ self, 'DSL 저장', 'thumbnail.json', 'JSON (*.json)'
783
+ )
784
+ if not file_path:
785
+ return
786
+ try:
787
+ with open(file_path, 'w', encoding='utf-8') as f:
788
+ json.dump(dsl, f, ensure_ascii=False, indent=2)
789
+ QMessageBox.information(self, '완료', f'DSL 저장 완료: {file_path}')
790
+ except Exception as e:
791
+ QMessageBox.critical(self, '에러', f'DSL 저장 실패: {e}')
792
+
793
+ def save_thl_package(self):
794
+ """현재 DSL과 사용 폰트를 묶어 .thl 패키지로 저장"""
795
+ if not hasattr(self, 'current_dsl'):
796
+ self.update_preview()
797
+ dsl = getattr(self, 'current_dsl', self.generate_dsl())
798
+
799
+ # 저장 경로 선택
800
+ file_path, _ = QFileDialog.getSaveFileName(
801
+ self, '패키지 저장', 'thumbnail.thl', 'Thumbnail Package (*.thl)'
802
+ )
803
+ if not file_path:
804
+ return
805
+ try:
806
+ # 스테이징 디렉토리 구성
807
+ staging = tempfile.mkdtemp(prefix='thl_pkg_')
808
+ fonts_dir = os.path.join(staging, 'fonts')
809
+ os.makedirs(fonts_dir, exist_ok=True)
810
+
811
+ # 폰트 확보 및 복사
812
+ texts = dsl.get('Thumbnail', {}).get('Texts', [])
813
+ try:
814
+ # 프로젝트/fonts에 TTF 생성/보장
815
+ ThumbnailRenderer.ensure_fonts(texts)
816
+ except Exception as e:
817
+ print(f"폰트 확보 경고: {e}")
818
+
819
+ # faces를 순회하여 예상 파일명으로 복사
820
+ faces = ThumbnailRenderer.parse_font_faces(texts)
821
+ copied = 0
822
+ for face in faces:
823
+ ttf_name = f"{sanitize(face.get('name','Font'))}-{sanitize(str(face.get('weight','normal')))}-{sanitize(str(face.get('style','normal')))}.ttf"
824
+ src_path = os.path.join(ThumbnailRenderer._fonts_dir(), ttf_name)
825
+ if os.path.exists(src_path):
826
+ shutil.copy2(src_path, os.path.join(fonts_dir, ttf_name))
827
+ copied += 1
828
+
829
+ # thumbnail.json 저장 (원본 DSL 그대로)
830
+ with open(os.path.join(staging, 'thumbnail.json'), 'w', encoding='utf-8') as f:
831
+ json.dump(dsl, f, ensure_ascii=False, indent=2)
832
+
833
+ # zip -> .thl
834
+ with zipfile.ZipFile(file_path, 'w', compression=zipfile.ZIP_DEFLATED) as zf:
835
+ # 루트에 thumbnail.json
836
+ zf.write(os.path.join(staging, 'thumbnail.json'), arcname='thumbnail.json')
837
+ # fonts 폴더
838
+ if os.path.isdir(fonts_dir):
839
+ for name in os.listdir(fonts_dir):
840
+ zf.write(os.path.join(fonts_dir, name), arcname=os.path.join('fonts', name))
841
+
842
+ QMessageBox.information(self, '완료', f'패키지 저장 완료: {file_path}')
843
+ except Exception as e:
844
+ QMessageBox.critical(self, '에러', f'패키지 저장 실패: {e}')
845
+ finally:
846
+ try:
847
+ shutil.rmtree(staging, ignore_errors=True)
848
+ except Exception:
849
+ pass
850
+
851
+
852
+ def main():
853
+ app = QApplication(sys.argv)
854
+ window = ThumbnailGUI()
855
+ window.show()
856
+ sys.exit(app.exec())
857
+
858
+
859
+ if __name__ == '__main__':
860
+ main()
861
+