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,9 @@
1
+ """
2
+ 썸네일 생성기 패키지
3
+ """
4
+
5
+ from .renderer import ThumbnailRenderer
6
+
7
+ __version__ = "0.1.0"
8
+ __all__ = ["ThumbnailRenderer"]
9
+
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ thumbnail_maker: 단일 엔트리포인트 (subcommands: gui, generate-thumbnail, genthumb, upload)
5
+ """
6
+
7
+ import sys
8
+ import argparse
9
+ import os
10
+
11
+ from .cli import main as generate_main, main_cli as genthumb_main
12
+ from .upload import upload_file
13
+
14
+
15
+ def main() -> None:
16
+ parser = argparse.ArgumentParser(prog='thumbnail_maker', description='썸네일 메이커')
17
+ subparsers = parser.add_subparsers(dest='command', required=True)
18
+
19
+ # gui
20
+ subparsers.add_parser('gui', help='GUI 실행')
21
+
22
+ # generate-thumbnail (DSL만 사용)
23
+ gen = subparsers.add_parser('generate-thumbnail', help='DSL로 썸네일 생성')
24
+ gen.add_argument('dsl', nargs='?', default='thumbnail.json', help='DSL 파일 경로')
25
+ gen.add_argument('-o', '--output', default='thumbnail.png', help='출력 파일 경로')
26
+ gen.add_argument('--upload', action='store_true', help='생성 후 자동 업로드')
27
+
28
+ # genthumb (간편 CLI: 제목/부제목 덮어쓰기 등)
29
+ gt = subparsers.add_parser('genthumb', help='간편 CLI로 썸네일 생성')
30
+ gt.add_argument('dsl', nargs='?', default='thumbnail.json', help='DSL 파일 경로')
31
+ gt.add_argument('-o', '--output', default='thumbnail.png', help='출력 파일 경로')
32
+ gt.add_argument('--title', help='제목 덮어쓰기 (\\n 또는 실제 줄바꿈 지원)')
33
+ gt.add_argument('--subtitle', help='부제목 덮어쓰기 (\\n 또는 실제 줄바꿈 지원)')
34
+ gt.add_argument('--bgImg', help='배경 이미지 경로')
35
+
36
+ # upload
37
+ upload_parser = subparsers.add_parser('upload', help='이미지 파일 업로드')
38
+ upload_parser.add_argument('file', help='업로드할 파일 경로')
39
+
40
+ args, unknown = parser.parse_known_args()
41
+
42
+ if args.command == 'gui':
43
+ # GUI 명령어일 때만 PySide6 관련 모듈 import
44
+ from .gui import main as gui_main
45
+ gui_main()
46
+ return
47
+
48
+ if args.command == 'generate-thumbnail':
49
+ # generate_main은 자체 argparse를 사용하므로 여기서 직접 동작 위임이 어려움
50
+ # 동일 기능을 직접 수행하기보다는 해당 모듈의 메인 로직을 그대로 호출하도록 유지
51
+ # 간단하게는 모듈 내부가 파일 인자를 읽도록 짜여 있으므로, 여기서 args를 재적용
52
+ # 하지만 기존 main()은 sys.argv를 파싱하므로, 안전하게 별도 경로로 수행
53
+ # 대신 renderer를 직접 호출하지 않고, cli.main의 구현을 차용하기 위해 임시 argv 구성
54
+ sys.argv = ['generate-thumbnail', args.dsl, '-o', args.output]
55
+ generate_main()
56
+
57
+ # 업로드 옵션이 있으면 업로드 수행
58
+ if args.upload:
59
+ output_path = args.output
60
+ if not os.path.isabs(output_path):
61
+ output_path = os.path.abspath(output_path)
62
+
63
+ if not os.path.exists(output_path):
64
+ print(f"오류: 출력 파일을 찾을 수 없습니다: {output_path}")
65
+ sys.exit(1)
66
+
67
+ print(f"업로드 중: {output_path}")
68
+ url = upload_file(output_path)
69
+ if url:
70
+ print(f"✅ 업로드 완료: {url}")
71
+ else:
72
+ print("❌ 업로드 실패")
73
+ sys.exit(1)
74
+ return
75
+
76
+ if args.command == 'genthumb':
77
+ # 동일 이유로 간편 CLI도 기존 파서를 활용하기 위해 argv 재구성
78
+ new_argv = ['genthumb']
79
+ if args.dsl:
80
+ new_argv.append(args.dsl)
81
+ if args.output:
82
+ new_argv += ['-o', args.output]
83
+ if args.title:
84
+ new_argv += ['--title', args.title]
85
+ if args.subtitle:
86
+ new_argv += ['--subtitle', args.subtitle]
87
+ if args.bgImg:
88
+ new_argv += ['--bgImg', args.bgImg]
89
+ sys.argv = new_argv
90
+ genthumb_main()
91
+ return
92
+
93
+ if args.command == 'upload':
94
+ file_path = args.file
95
+ if not os.path.exists(file_path):
96
+ print(f"오류: 파일을 찾을 수 없습니다: {file_path}")
97
+ sys.exit(1)
98
+
99
+ print(f"업로드 중: {file_path}")
100
+ url = upload_file(file_path)
101
+ if url:
102
+ print(f"✅ 업로드 완료: {url}")
103
+ else:
104
+ print("❌ 업로드 실패")
105
+ sys.exit(1)
106
+ return
107
+
108
+
109
+ if __name__ == '__main__':
110
+ main()
111
+
112
+
thumbnail_maker/cli.py ADDED
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ CLI 스크립트
5
+ """
6
+
7
+ import sys
8
+ import os
9
+ import json
10
+ import argparse
11
+ import base64
12
+ from .renderer import ThumbnailRenderer
13
+ import tempfile
14
+ import zipfile
15
+ import shutil
16
+
17
+
18
+ def main():
19
+ """메인 CLI 진입점"""
20
+ parser = argparse.ArgumentParser(description='썸네일 생성')
21
+ parser.add_argument('dsl', nargs='?', default='thumbnail.json', help='DSL 파일 경로')
22
+ parser.add_argument('-o', '--output', default='thumbnail.png', help='출력 파일 경로')
23
+
24
+ args = parser.parse_args()
25
+ staging = None
26
+ cwd_backup = os.getcwd()
27
+ # 출력 경로를 절대경로로 확보 (staging 디렉토리 변경 전)
28
+ output_path = args.output
29
+ if not os.path.isabs(output_path):
30
+ output_path = os.path.abspath(output_path)
31
+ try:
32
+ # .thl 패키지 지원: 임시 폴더에 풀어서 작업
33
+ if args.dsl.lower().endswith('.thl') and os.path.exists(args.dsl):
34
+ staging = tempfile.mkdtemp(prefix='thl_run_')
35
+ with zipfile.ZipFile(args.dsl, 'r') as zf:
36
+ zf.extractall(staging)
37
+ # 작업 디렉토리를 패키지 루트로 변경 (renderer의 'fonts/' 탐색을 위함)
38
+ os.chdir(staging)
39
+ dsl_path = os.path.join(staging, 'thumbnail.json')
40
+ else:
41
+ dsl_path = args.dsl
42
+
43
+ # DSL 파일 확인
44
+ if not os.path.exists(dsl_path):
45
+ print(f"오류: DSL 파일을 찾을 수 없습니다: {dsl_path}")
46
+ sys.exit(1)
47
+
48
+ # DSL 읽기
49
+ with open(dsl_path, 'r', encoding='utf-8') as f:
50
+ dsl = json.load(f)
51
+
52
+ # 썸네일 생성
53
+ ThumbnailRenderer.render_thumbnail(dsl, output_path)
54
+ finally:
55
+ try:
56
+ os.chdir(cwd_backup)
57
+ except Exception:
58
+ pass
59
+ if staging:
60
+ shutil.rmtree(staging, ignore_errors=True)
61
+
62
+
63
+ def main_cli():
64
+ """간편 CLI 진입점"""
65
+ parser = argparse.ArgumentParser(description='썸네일 생성 (간편 CLI)')
66
+ parser.add_argument('dsl', nargs='?', default='thumbnail.json', help='DSL 파일 경로')
67
+ parser.add_argument('-o', '--output', default='thumbnail.png', help='출력 파일 경로')
68
+ parser.add_argument('--title', help='제목 덮어쓰기 (\\n 또는 실제 줄바꿈 지원)')
69
+ parser.add_argument('--subtitle', help='부제목 덮어쓰기 (\\n 또는 실제 줄바꿈 지원)')
70
+ parser.add_argument('--bgImg', help='배경 이미지 경로')
71
+
72
+ args = parser.parse_args()
73
+
74
+ def normalize_text(s: str) -> str:
75
+ """CLI에서 전달된 텍스트의 줄바꿈 시퀀스를 실제 줄바꿈으로 변환"""
76
+ if s is None:
77
+ return s
78
+ # 리터럴 \n, \r\n, \r 처리
79
+ # 먼저 \r\n -> \n 으로 통일, 이후 리터럴 역슬래시-n 치환
80
+ s = s.replace('\r\n', '\n').replace('\r', '\n')
81
+ s = s.replace('\\n', '\n')
82
+ return s
83
+
84
+ staging = None
85
+ cwd_backup = os.getcwd()
86
+ # 출력 경로를 절대경로로 확보 (staging 디렉토리 변경 전)
87
+ output_path = args.output
88
+ if not os.path.isabs(output_path):
89
+ output_path = os.path.abspath(output_path)
90
+ try:
91
+ # .thl 패키지 지원
92
+ if args.dsl and args.dsl.lower().endswith('.thl') and os.path.exists(args.dsl):
93
+ staging = tempfile.mkdtemp(prefix='thl_run_')
94
+ with zipfile.ZipFile(args.dsl, 'r') as zf:
95
+ zf.extractall(staging)
96
+ os.chdir(staging)
97
+ dsl_path = os.path.join(staging, 'thumbnail.json')
98
+ else:
99
+ dsl_path = args.dsl
100
+
101
+ # DSL 파일 확인
102
+ if not os.path.exists(dsl_path):
103
+ print(f"오류: DSL 파일을 찾을 수 없습니다: {dsl_path}")
104
+ sys.exit(1)
105
+
106
+ # DSL 읽기
107
+ with open(dsl_path, 'r', encoding='utf-8') as f:
108
+ dsl = json.load(f)
109
+
110
+ # 배경 이미지 처리
111
+ if args.bgImg and os.path.exists(args.bgImg):
112
+ with open(args.bgImg, 'rb') as f:
113
+ image_data = f.read()
114
+ base64_str = base64.b64encode(image_data).decode('utf-8')
115
+ data_url = f"data:image/png;base64,{base64_str}"
116
+
117
+ dsl['Thumbnail']['Background']['type'] = 'image'
118
+ dsl['Thumbnail']['Background']['imagePath'] = data_url
119
+
120
+ # 제목/부제목 덮어쓰기
121
+ if 'Texts' in dsl.get('Thumbnail', {}):
122
+ for txt in dsl['Thumbnail']['Texts']:
123
+ if args.title and txt.get('type') == 'title':
124
+ txt['content'] = normalize_text(args.title)
125
+ if args.subtitle and txt.get('type') == 'subtitle':
126
+ txt['content'] = normalize_text(args.subtitle)
127
+
128
+ # 썸네일 생성
129
+ ThumbnailRenderer.render_thumbnail(dsl, output_path)
130
+ finally:
131
+ try:
132
+ os.chdir(cwd_backup)
133
+ except Exception:
134
+ pass
135
+ if staging:
136
+ shutil.rmtree(staging, ignore_errors=True)
137
+
138
+
139
+ if __name__ == '__main__':
140
+ main()
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ GUI 모듈 - 썸네일 생성 GUI 애플리케이션
5
+ """
6
+
7
+ from .main_window import main
8
+
9
+ __all__ = ['main']
10
+
@@ -0,0 +1,351 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ DSL 생성, 로드, 저장 관리 모듈
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import base64
10
+ import tempfile
11
+ import zipfile
12
+ import shutil
13
+ from typing import Dict
14
+
15
+ from ..renderer import ThumbnailRenderer, sanitize
16
+
17
+
18
+ class DSLManager:
19
+ """DSL 관리 클래스"""
20
+
21
+ @staticmethod
22
+ def generate_dsl(gui) -> Dict:
23
+ """GUI 위젯에서 DSL 생성"""
24
+ # 해상도 결정
25
+ res_mode = gui.res_mode.currentText()
26
+ if res_mode == 'preset':
27
+ resolution = {
28
+ 'type': 'preset',
29
+ 'value': gui.aspect_ratio.currentText()
30
+ }
31
+ elif res_mode == 'fixedRatio':
32
+ resolution = {
33
+ 'type': 'fixedRatio',
34
+ 'ratioValue': gui.aspect_ratio.currentText(),
35
+ 'width': gui.width_spin.value()
36
+ }
37
+ else: # custom
38
+ resolution = {
39
+ 'type': 'custom',
40
+ 'width': gui.width_spin.value(),
41
+ 'height': gui.height_spin.value()
42
+ }
43
+
44
+ # 배경 결정
45
+ bg_type = gui.bg_type.currentText()
46
+ if bg_type == 'image' and gui.bg_image_path.text():
47
+ # 이미지를 base64로 변환
48
+ with open(gui.bg_image_path.text(), 'rb') as f:
49
+ image_data = f.read()
50
+ base64_str = base64.b64encode(image_data).decode('utf-8')
51
+ data_url = f"data:image/png;base64,{base64_str}"
52
+
53
+ background = {
54
+ 'type': 'image',
55
+ 'imagePath': data_url,
56
+ 'imageOpacity': gui.bg_opacity.value() / 100.0,
57
+ 'imageBlur': gui.bg_blur.value()
58
+ }
59
+ elif bg_type == 'gradient':
60
+ background = {
61
+ 'type': 'gradient',
62
+ 'colors': [gui.bg_color, '#000000']
63
+ }
64
+ else: # solid
65
+ background = {
66
+ 'type': 'solid',
67
+ 'color': gui.bg_color
68
+ }
69
+
70
+ # 텍스트 설정
71
+ # 제목 폰트 소스 분기
72
+ title_use_local = gui.title_font_source.currentText() == '로컬 폰트 파일'
73
+ title_face_url = gui.title_font_file.text() if title_use_local and gui.title_font_file.text() else (gui.title_font_url.text() or 'https://fastly.jsdelivr.net/gh/projectnoonnu/noonfonts_2108@1.1/SBAggroB.woff')
74
+ # 부제목 폰트 소스 분기
75
+ subtitle_use_local = gui.subtitle_font_source.currentText() == '로컬 폰트 파일'
76
+ subtitle_face_url = gui.subtitle_font_file.text() if subtitle_use_local and gui.subtitle_font_file.text() else (gui.subtitle_font_url.text() or 'https://fastly.jsdelivr.net/gh/projectnoonnu/noonfonts_2108@1.1/SBAggroB.woff')
77
+
78
+ texts = [
79
+ {
80
+ 'type': 'title',
81
+ 'content': gui.title_text.toPlainText(),
82
+ 'gridPosition': gui.title_position.currentText(),
83
+ 'font': {
84
+ 'name': gui.title_font_name.text() or 'SBAggroB',
85
+ 'faces': [{
86
+ 'name': gui.title_font_name.text() or 'SBAggroB',
87
+ 'url': title_face_url,
88
+ 'weight': gui.title_font_weight.currentText() or 'bold',
89
+ 'style': gui.title_font_style.currentText() or 'normal'
90
+ }]
91
+ },
92
+ 'fontSize': gui.title_font_size.value(),
93
+ 'color': gui.title_color,
94
+ 'fontWeight': gui.title_font_weight.currentText() or 'bold',
95
+ 'fontStyle': gui.title_font_style.currentText() or 'normal',
96
+ 'lineHeight': 1.1,
97
+ 'wordWrap': gui.title_word_wrap.isChecked(),
98
+ 'outline': {
99
+ 'thickness': gui.title_outline_thickness.value(),
100
+ 'color': '#000000'
101
+ } if gui.title_outline_check.isChecked() else None,
102
+ 'enabled': True
103
+ }
104
+ ]
105
+
106
+ # 부제목 추가 (체크 여부와 관계없이 항상 추가하되, enabled 필드로 제어)
107
+ texts.append({
108
+ 'type': 'subtitle',
109
+ 'content': gui.subtitle_text.toPlainText(),
110
+ 'gridPosition': gui.subtitle_position.currentText(),
111
+ 'font': {
112
+ 'name': gui.subtitle_font_name.text() or 'SBAggroB',
113
+ 'faces': [{
114
+ 'name': gui.subtitle_font_name.text() or 'SBAggroB',
115
+ 'url': subtitle_face_url,
116
+ 'weight': gui.subtitle_font_weight.currentText() or 'normal',
117
+ 'style': gui.subtitle_font_style.currentText() or 'normal'
118
+ }]
119
+ },
120
+ 'fontSize': gui.subtitle_font_size.value(),
121
+ 'color': gui.subtitle_color,
122
+ 'fontWeight': gui.subtitle_font_weight.currentText() or 'normal',
123
+ 'fontStyle': gui.subtitle_font_style.currentText() or 'normal',
124
+ 'lineHeight': 1.1,
125
+ 'wordWrap': gui.subtitle_word_wrap.isChecked(),
126
+ 'outline': None,
127
+ 'enabled': gui.subtitle_visible.isChecked()
128
+ })
129
+
130
+ dsl = {
131
+ 'Thumbnail': {
132
+ 'Resolution': resolution,
133
+ 'Background': background,
134
+ 'Texts': texts
135
+ },
136
+ 'TemplateMeta': {
137
+ 'name': '',
138
+ 'shareable': False
139
+ }
140
+ }
141
+
142
+ return dsl
143
+
144
+ @staticmethod
145
+ def load_dsl_to_gui(gui, dsl: Dict):
146
+ """DSL 데이터를 GUI 위젯에 로드"""
147
+ thumbnail = dsl.get('Thumbnail', {})
148
+
149
+ # 해상도 설정
150
+ resolution = thumbnail.get('Resolution', {})
151
+ res_type = resolution.get('type', 'preset')
152
+ gui.res_mode.setCurrentText(res_type)
153
+
154
+ if res_type == 'preset':
155
+ gui.aspect_ratio.setCurrentText(resolution.get('value', '16:9'))
156
+ elif res_type == 'fixedRatio':
157
+ gui.aspect_ratio.setCurrentText(resolution.get('ratioValue', '16:9'))
158
+ gui.width_spin.setValue(resolution.get('width', 480))
159
+ else: # custom
160
+ gui.width_spin.setValue(resolution.get('width', 480))
161
+ gui.height_spin.setValue(resolution.get('height', 270))
162
+
163
+ # 배경 설정
164
+ background = thumbnail.get('Background', {})
165
+ bg_type = background.get('type', 'solid')
166
+ gui.bg_type.setCurrentText(bg_type)
167
+
168
+ if bg_type == 'solid':
169
+ gui.bg_color = background.get('color', '#a3e635')
170
+ elif bg_type == 'gradient':
171
+ colors = background.get('colors', [])
172
+ if colors:
173
+ gui.bg_color = colors[0]
174
+ elif bg_type == 'image':
175
+ img_path = background.get('imagePath', '')
176
+ if img_path.startswith('data:image'):
177
+ # base64 이미지는 임시 파일로 저장
178
+ header, encoded = img_path.split(',', 1)
179
+ image_data = base64.b64decode(encoded)
180
+ temp_path = os.path.join(tempfile.gettempdir(), 'thl_temp_bg.png')
181
+ with open(temp_path, 'wb') as f:
182
+ f.write(image_data)
183
+ gui.bg_image_path.setText(temp_path)
184
+ else:
185
+ gui.bg_image_path.setText(img_path)
186
+ gui.bg_opacity.setValue(int(background.get('imageOpacity', 1.0) * 100))
187
+ gui.bg_blur.setValue(background.get('imageBlur', 0))
188
+
189
+ # 텍스트 설정
190
+ texts = thumbnail.get('Texts', [])
191
+ subtitle_found = False
192
+ for txt in texts:
193
+ txt_type = txt.get('type')
194
+ if txt_type == 'title':
195
+ gui.title_text.setPlainText(txt.get('content', ''))
196
+ gui.title_position.setCurrentText(txt.get('gridPosition', 'tl'))
197
+ gui.title_font_size.setValue(txt.get('fontSize', 48))
198
+ gui.title_color = txt.get('color', '#4ade80')
199
+
200
+ font = txt.get('font', {})
201
+ font_name = font.get('name', 'SBAggroB')
202
+ gui.title_font_name.setText(font_name)
203
+
204
+ faces = font.get('faces', [])
205
+ if faces:
206
+ face = faces[0]
207
+ url = face.get('url', '')
208
+ if url and os.path.exists(url):
209
+ # 로컬 파일
210
+ gui.title_font_source.setCurrentText('로컬 폰트 파일')
211
+ gui.title_font_file.setText(url)
212
+ else:
213
+ # 웹 URL
214
+ gui.title_font_source.setCurrentText('웹 폰트 URL')
215
+ gui.title_font_url.setText(url)
216
+
217
+ gui.title_font_weight.setCurrentText(face.get('weight', 'bold'))
218
+ gui.title_font_style.setCurrentText(face.get('style', 'normal'))
219
+
220
+ outline = txt.get('outline')
221
+ if outline:
222
+ gui.title_outline_check.setChecked(True)
223
+ gui.title_outline_thickness.setValue(outline.get('thickness', 7))
224
+ else:
225
+ gui.title_outline_check.setChecked(False)
226
+
227
+ gui.title_word_wrap.setChecked(txt.get('wordWrap', False))
228
+
229
+ elif txt_type == 'subtitle':
230
+ subtitle_found = True
231
+ # enabled 필드를 확인하여 체크박스 상태 설정
232
+ enabled = txt.get('enabled', True)
233
+ gui.subtitle_visible.setChecked(enabled)
234
+ gui.subtitle_text.setPlainText(txt.get('content', ''))
235
+ gui.subtitle_position.setCurrentText(txt.get('gridPosition', 'bl'))
236
+ gui.subtitle_font_size.setValue(txt.get('fontSize', 24))
237
+ gui.subtitle_color = txt.get('color', '#ffffff')
238
+
239
+ font = txt.get('font', {})
240
+ font_name = font.get('name', 'SBAggroB')
241
+ gui.subtitle_font_name.setText(font_name)
242
+
243
+ faces = font.get('faces', [])
244
+ if faces:
245
+ face = faces[0]
246
+ url = face.get('url', '')
247
+ if url and os.path.exists(url):
248
+ # 로컬 파일
249
+ gui.subtitle_font_source.setCurrentText('로컬 폰트 파일')
250
+ gui.subtitle_font_file.setText(url)
251
+ else:
252
+ # 웹 URL
253
+ gui.subtitle_font_source.setCurrentText('웹 폰트 URL')
254
+ gui.subtitle_font_url.setText(url)
255
+
256
+ gui.subtitle_font_weight.setCurrentText(face.get('weight', 'normal'))
257
+ gui.subtitle_font_style.setCurrentText(face.get('style', 'normal'))
258
+
259
+ gui.subtitle_word_wrap.setChecked(txt.get('wordWrap', False))
260
+
261
+ # 부제목이 DSL에 없으면 체크 해제
262
+ if not subtitle_found:
263
+ gui.subtitle_visible.setChecked(False)
264
+
265
+ # 미리보기 업데이트
266
+ gui.update_preview()
267
+
268
+ @staticmethod
269
+ def save_thl_package(gui, file_path: str):
270
+ """현재 DSL과 사용 폰트를 묶어 .thl 패키지로 저장"""
271
+ if not hasattr(gui, 'current_dsl'):
272
+ gui.update_preview()
273
+ dsl = getattr(gui, 'current_dsl', DSLManager.generate_dsl(gui))
274
+
275
+ staging = None
276
+ try:
277
+ # 스테이징 디렉토리 구성
278
+ staging = tempfile.mkdtemp(prefix='thl_pkg_')
279
+ fonts_dir = os.path.join(staging, 'fonts')
280
+ os.makedirs(fonts_dir, exist_ok=True)
281
+
282
+ # 폰트 확보 및 복사
283
+ texts = dsl.get('Thumbnail', {}).get('Texts', [])
284
+ try:
285
+ # 프로젝트/fonts에 TTF 생성/보장
286
+ ThumbnailRenderer.ensure_fonts(texts)
287
+ except Exception as e:
288
+ print(f"폰트 확보 경고: {e}")
289
+
290
+ # faces를 순회하여 예상 파일명으로 복사
291
+ faces = ThumbnailRenderer.parse_font_faces(texts)
292
+ for face in faces:
293
+ ttf_name = f"{sanitize(face.get('name','Font'))}-{sanitize(str(face.get('weight','normal')))}-{sanitize(str(face.get('style','normal')))}.ttf"
294
+ src_path = os.path.join(ThumbnailRenderer._fonts_dir(), ttf_name)
295
+ if os.path.exists(src_path):
296
+ shutil.copy2(src_path, os.path.join(fonts_dir, ttf_name))
297
+
298
+ # thumbnail.json 저장 (원본 DSL 그대로)
299
+ with open(os.path.join(staging, 'thumbnail.json'), 'w', encoding='utf-8') as f:
300
+ json.dump(dsl, f, ensure_ascii=False, indent=2)
301
+
302
+ # zip -> .thl
303
+ with zipfile.ZipFile(file_path, 'w', compression=zipfile.ZIP_DEFLATED) as zf:
304
+ # 루트에 thumbnail.json
305
+ zf.write(os.path.join(staging, 'thumbnail.json'), arcname='thumbnail.json')
306
+ # fonts 폴더
307
+ if os.path.isdir(fonts_dir):
308
+ for name in os.listdir(fonts_dir):
309
+ zf.write(os.path.join(fonts_dir, name), arcname=os.path.join('fonts', name))
310
+ finally:
311
+ if staging:
312
+ shutil.rmtree(staging, ignore_errors=True)
313
+
314
+ @staticmethod
315
+ def load_thl_package(gui, file_path: str) -> Dict:
316
+ """.thl 패키지를 로드하여 DSL 반환"""
317
+ staging = None
318
+ cwd_backup = os.getcwd()
319
+ try:
320
+ # .thl 파일을 임시 디렉토리에 압축 해제
321
+ staging = tempfile.mkdtemp(prefix='thl_load_')
322
+ with zipfile.ZipFile(file_path, 'r') as zf:
323
+ zf.extractall(staging)
324
+
325
+ # thumbnail.json 읽기
326
+ dsl_path = os.path.join(staging, 'thumbnail.json')
327
+ if not os.path.exists(dsl_path):
328
+ raise FileNotFoundError('패키지에 thumbnail.json이 없습니다.')
329
+
330
+ with open(dsl_path, 'r', encoding='utf-8') as f:
331
+ dsl = json.load(f)
332
+
333
+ # 폰트 파일을 프로젝트 fonts 디렉토리로 복사
334
+ fonts_src_dir = os.path.join(staging, 'fonts')
335
+ if os.path.isdir(fonts_src_dir):
336
+ fonts_dst_dir = ThumbnailRenderer._fonts_dir()
337
+ os.makedirs(fonts_dst_dir, exist_ok=True)
338
+ for font_file in os.listdir(fonts_src_dir):
339
+ src_path = os.path.join(fonts_src_dir, font_file)
340
+ dst_path = os.path.join(fonts_dst_dir, font_file)
341
+ shutil.copy2(src_path, dst_path)
342
+
343
+ return dsl
344
+ finally:
345
+ try:
346
+ os.chdir(cwd_backup)
347
+ except Exception:
348
+ pass
349
+ if staging:
350
+ shutil.rmtree(staging, ignore_errors=True)
351
+
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ 폰트 관련 유틸리티 함수
5
+ """
6
+
7
+ import os
8
+
9
+ try:
10
+ from fontTools.ttLib import TTFont
11
+ except Exception:
12
+ TTFont = None
13
+
14
+
15
+ def infer_font_name_from_file(file_path: str) -> str:
16
+ """파일 경로에서 폰트 이름 추출"""
17
+ try:
18
+ ext = os.path.splitext(file_path)[1].lower()
19
+ if TTFont and ext in ('.ttf', '.otf') and os.path.exists(file_path):
20
+ tt = TTFont(file_path)
21
+ # Prefer full font name (nameID=4), fallback to font family (nameID=1)
22
+ name = None
23
+ for rec in tt['name'].names:
24
+ if rec.nameID in (4, 1):
25
+ try:
26
+ val = rec.toUnicode()
27
+ except Exception:
28
+ val = rec.string.decode(rec.getEncoding(), errors='ignore')
29
+ if val:
30
+ name = val
31
+ if rec.nameID == 4:
32
+ break
33
+ if name:
34
+ return name
35
+ except Exception:
36
+ pass
37
+ # Fallback: 파일명(확장자 제외)
38
+ return os.path.splitext(os.path.basename(file_path))[0]
39
+