openfree commited on
Commit
4f4ea1d
·
verified ·
1 Parent(s): 7a1ad36

Create ppt_generator.py

Browse files
Files changed (1) hide show
  1. ppt_generator.py +971 -0
ppt_generator.py ADDED
@@ -0,0 +1,971 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+
3
+ import os
4
+ import re
5
+ import json
6
+ import tempfile
7
+ import random
8
+ from typing import Dict, List, Optional, Tuple
9
+ from loguru import logger
10
+
11
+ # PPT 관련 라이브러리
12
+ try:
13
+ from pptx import Presentation
14
+ from pptx.util import Inches, Pt
15
+ from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
16
+ from pptx.dml.color import RGBColor
17
+ from pptx.enum.shapes import MSO_SHAPE
18
+ from pptx.chart.data import CategoryChartData
19
+ from pptx.enum.chart import XL_CHART_TYPE, XL_LEGEND_POSITION
20
+ PPTX_AVAILABLE = True
21
+ except ImportError:
22
+ PPTX_AVAILABLE = False
23
+ logger.warning("python-pptx 라이브러리가 설치되지 않았습니다. pip install python-pptx")
24
+
25
+ from PIL import Image
26
+
27
+ # 로컬 모듈 imports (이 파일들도 분리 필요)
28
+ from design_themes import DESIGN_THEMES
29
+ from diagram_utils import (
30
+ detect_diagram_type_with_score,
31
+ generate_diagram_json,
32
+ generate_diagram_locally,
33
+ DIAGRAM_GENERATORS_AVAILABLE
34
+ )
35
+ from image_generation import (
36
+ generate_cover_image_prompt,
37
+ generate_conclusion_image_prompt,
38
+ generate_diverse_prompt,
39
+ generate_flux_prompt,
40
+ pick_flux_style,
41
+ generate_ai_image_via_3d_api,
42
+ AI_IMAGE_ENABLED
43
+ )
44
+ from emoji_utils import has_emoji, get_emoji_for_content
45
+
46
+
47
+ ##############################################################################
48
+ # Slide Layout Helper Functions
49
+ ##############################################################################
50
+ def clean_slide_placeholders(slide):
51
+ """슬라이드에서 사용하지 않는 모든 placeholder 제거"""
52
+ shapes_to_remove = []
53
+
54
+ for shape in slide.shapes:
55
+ # Placeholder인지 확인
56
+ if hasattr(shape, 'placeholder_format') and shape.placeholder_format:
57
+ # 텍스트가 있는 경우
58
+ if shape.has_text_frame:
59
+ text = shape.text_frame.text.strip()
60
+ # 비어있거나 기본 placeholder 텍스트인 경우
61
+ if (not text or
62
+ '텍스트를 입력하십시오' in text or
63
+ '텍스트 입력' in text or
64
+ 'Click to add' in text or
65
+ 'Content Placeholder' in text or
66
+ '제목 추가' in text or
67
+ '부제목 추가' in text or
68
+ '제목을 입력하십시오' in text or
69
+ '부제목을 입력하십시오' in text or
70
+ '마스터 제목 스타일 편집' in text or
71
+ '마스터 텍스트 스타일' in text or
72
+ '제목 없는 슬라이드' in text):
73
+ shapes_to_remove.append(shape)
74
+ else:
75
+ # 텍스트 프레임이 없는 placeholder도 제거
76
+ shapes_to_remove.append(shape)
77
+
78
+ # 제거 실행
79
+ for shape in shapes_to_remove:
80
+ try:
81
+ sp = shape._element
82
+ sp.getparent().remove(sp)
83
+ except Exception as e:
84
+ logger.warning(f"Failed to remove placeholder: {e}")
85
+ pass # 이미 제거된 경우 무시
86
+
87
+
88
+ def force_font_size(text_frame, font_size_pt: int, theme: Dict):
89
+ """Force font size for all paragraphs and runs in a text frame"""
90
+ if not text_frame:
91
+ return
92
+
93
+ try:
94
+ # Ensure paragraphs exist
95
+ if not hasattr(text_frame, 'paragraphs'):
96
+ return
97
+
98
+ for paragraph in text_frame.paragraphs:
99
+ try:
100
+ # Set paragraph level font
101
+ if hasattr(paragraph, 'font'):
102
+ paragraph.font.size = Pt(font_size_pt)
103
+ paragraph.font.name = theme['fonts']['body']
104
+ paragraph.font.color.rgb = theme['colors']['text']
105
+
106
+ # Set run level font (most important for actual rendering)
107
+ if hasattr(paragraph, 'runs'):
108
+ for run in paragraph.runs:
109
+ run.font.size = Pt(font_size_pt)
110
+ run.font.name = theme['fonts']['body']
111
+ run.font.color.rgb = theme['colors']['text']
112
+
113
+ # If paragraph has no runs but has text, create a run
114
+ if paragraph.text and (not hasattr(paragraph, 'runs') or len(paragraph.runs) == 0):
115
+ # Force creation of runs by modifying text
116
+ temp_text = paragraph.text
117
+ paragraph.text = temp_text # This creates runs
118
+ if hasattr(paragraph, 'runs'):
119
+ for run in paragraph.runs:
120
+ run.font.size = Pt(font_size_pt)
121
+ run.font.name = theme['fonts']['body']
122
+ run.font.color.rgb = theme['colors']['text']
123
+ except Exception as e:
124
+ logger.warning(f"Error setting font for paragraph: {e}")
125
+ continue
126
+ except Exception as e:
127
+ logger.warning(f"Error in force_font_size: {e}")
128
+
129
+
130
+ def apply_theme_to_slide(slide, theme: Dict, layout_type: str = 'title_content'):
131
+ """Apply design theme to a slide with consistent styling"""
132
+ # 먼저 모든 placeholder 정리
133
+ clean_slide_placeholders(slide)
134
+
135
+ # Add colored background shape for all slides
136
+ bg_shape = slide.shapes.add_shape(
137
+ MSO_SHAPE.RECTANGLE, 0, 0, Inches(10), Inches(5.625)
138
+ )
139
+ bg_shape.fill.solid()
140
+
141
+ # Use lighter background for content slides
142
+ if layout_type in ['title_content', 'two_content', 'comparison']:
143
+ # Light background with subtle gradient effect
144
+ bg_shape.fill.fore_color.rgb = theme['colors']['background']
145
+
146
+ # Add accent strip at top
147
+ accent_strip = slide.shapes.add_shape(
148
+ MSO_SHAPE.RECTANGLE, 0, 0, Inches(10), Inches(0.5)
149
+ )
150
+ accent_strip.fill.solid()
151
+ accent_strip.fill.fore_color.rgb = theme['colors']['primary']
152
+ accent_strip.line.fill.background()
153
+
154
+ # Add bottom accent
155
+ bottom_strip = slide.shapes.add_shape(
156
+ MSO_SHAPE.RECTANGLE, 0, Inches(5.125), Inches(10), Inches(0.5)
157
+ )
158
+ bottom_strip.fill.solid()
159
+ bottom_strip.fill.fore_color.rgb = theme['colors']['secondary']
160
+ bottom_strip.fill.transparency = 0.7
161
+ bottom_strip.line.fill.background()
162
+
163
+ else:
164
+ # Section headers get primary color background
165
+ bg_shape.fill.fore_color.rgb = theme['colors']['primary']
166
+
167
+ bg_shape.line.fill.background()
168
+
169
+ # Move background shapes to back
170
+ slide.shapes._spTree.remove(bg_shape._element)
171
+ slide.shapes._spTree.insert(2, bg_shape._element)
172
+
173
+
174
+ def add_gradient_background(slide, color1: RGBColor, color2: RGBColor):
175
+ """Add gradient-like background to slide using shapes"""
176
+ # Note: python-pptx doesn't directly support gradients in backgrounds,
177
+ # so we'll create a gradient effect using overlapping shapes
178
+ left = top = 0
179
+ width = Inches(10)
180
+ height = Inches(5.625)
181
+
182
+ # Add base color rectangle
183
+ shape1 = slide.shapes.add_shape(
184
+ MSO_SHAPE.RECTANGLE, left, top, width, height
185
+ )
186
+ shape1.fill.solid()
187
+ shape1.fill.fore_color.rgb = color1
188
+ shape1.line.fill.background()
189
+
190
+ # Add semi-transparent overlay for gradient effect
191
+ shape2 = slide.shapes.add_shape(
192
+ MSO_SHAPE.RECTANGLE, left, top, width, Inches(2.8)
193
+ )
194
+ shape2.fill.solid()
195
+ shape2.fill.fore_color.rgb = color2
196
+ shape2.fill.transparency = 0.5
197
+ shape2.line.fill.background()
198
+
199
+ # Move shapes to back
200
+ slide.shapes._spTree.remove(shape1._element)
201
+ slide.shapes._spTree.remove(shape2._element)
202
+ slide.shapes._spTree.insert(2, shape1._element)
203
+ slide.shapes._spTree.insert(3, shape2._element)
204
+
205
+
206
+ def add_decorative_shapes(slide, theme: Dict):
207
+ """Add decorative shapes to enhance visual appeal"""
208
+ try:
209
+ # Placeholder 정리 먼저 실행
210
+ clean_slide_placeholders(slide)
211
+
212
+ # Add corner accent circle
213
+ shape1 = slide.shapes.add_shape(
214
+ MSO_SHAPE.OVAL,
215
+ Inches(9.3), Inches(4.8),
216
+ Inches(0.7), Inches(0.7)
217
+ )
218
+ shape1.fill.solid()
219
+ shape1.fill.fore_color.rgb = theme['colors']['accent']
220
+ shape1.fill.transparency = 0.3
221
+ shape1.line.fill.background()
222
+
223
+ # Add smaller accent
224
+ shape2 = slide.shapes.add_shape(
225
+ MSO_SHAPE.OVAL,
226
+ Inches(0.1), Inches(0.1),
227
+ Inches(0.4), Inches(0.4)
228
+ )
229
+ shape2.fill.solid()
230
+ shape2.fill.fore_color.rgb = theme['colors']['secondary']
231
+ shape2.fill.transparency = 0.5
232
+ shape2.line.fill.background()
233
+
234
+ except Exception as e:
235
+ logger.warning(f"Failed to add decorative shapes: {e}")
236
+
237
+
238
+ def create_chart_slide(slide, chart_data: Dict, theme: Dict):
239
+ """Create a chart on the slide based on data"""
240
+ try:
241
+ # Add chart
242
+ x, y, cx, cy = Inches(1), Inches(2), Inches(8), Inches(4.5)
243
+
244
+ # Prepare chart data
245
+ chart_data_obj = CategoryChartData()
246
+
247
+ # Simple bar chart example
248
+ if 'columns' in chart_data and 'sample_data' in chart_data:
249
+ # Use first numeric column for chart
250
+ numeric_cols = []
251
+ for col in chart_data['columns']:
252
+ try:
253
+ # Check if column has numeric data
254
+ float(chart_data['sample_data'][0].get(col, 0))
255
+ numeric_cols.append(col)
256
+ except:
257
+ pass
258
+
259
+ if numeric_cols:
260
+ categories = [str(row.get(chart_data['columns'][0], ''))
261
+ for row in chart_data['sample_data'][:5]]
262
+ chart_data_obj.categories = categories
263
+
264
+ for col in numeric_cols[:3]: # Max 3 series
265
+ values = [float(row.get(col, 0))
266
+ for row in chart_data['sample_data'][:5]]
267
+ chart_data_obj.add_series(col, values)
268
+
269
+ chart = slide.shapes.add_chart(
270
+ XL_CHART_TYPE.COLUMN_CLUSTERED, x, y, cx, cy, chart_data_obj
271
+ ).chart
272
+
273
+ # Style the chart
274
+ chart.has_legend = True
275
+ chart.legend.position = XL_LEGEND_POSITION.BOTTOM
276
+ except Exception as e:
277
+ logger.warning(f"Chart creation failed: {e}")
278
+ # If chart fails, add a text placeholder instead
279
+ textbox = slide.shapes.add_textbox(x, y, cx, cy)
280
+ text_frame = textbox.text_frame
281
+ text_frame.text = "Data Chart (Chart generation failed)"
282
+ text_frame.paragraphs[0].font.size = Pt(16)
283
+ text_frame.paragraphs[0].font.color.rgb = theme['colors']['secondary']
284
+
285
+
286
+ ##############################################################################
287
+ # Main PPT Generation Function
288
+ ##############################################################################
289
+ def create_advanced_ppt_from_content(
290
+ slides_data: list,
291
+ topic: str,
292
+ theme_name: str,
293
+ include_charts: bool = False,
294
+ include_ai_image: bool = False,
295
+ include_diagrams: bool = False,
296
+ include_flux_images: bool = False
297
+ ) -> str:
298
+ """Create advanced PPT file with enhanced visual content
299
+ 표지 3D 1장 + 일반 다이어그램 2장 + FLUX 스타일 4장 이상 + 3D 이미지 2장 이상"""
300
+ if not PPTX_AVAILABLE:
301
+ raise ImportError("python-pptx library is required")
302
+
303
+ prs = Presentation()
304
+ theme = DESIGN_THEMES.get(theme_name, DESIGN_THEMES['professional'])
305
+
306
+ # Set slide size (16:9)
307
+ prs.slide_width = Inches(10)
308
+ prs.slide_height = Inches(5.625)
309
+
310
+ # 이미지 카운터 및 추적
311
+ image_count_3d = 0
312
+ flux_style_count = 0
313
+ diagram_count = 0
314
+ max_flux_style = 4 # FLUX 스타일 다이어그램 최소 4개로 증가
315
+ max_diagrams = 2 # 기존 다이어그램 2개
316
+ max_3d_content = 2 # 컨텐츠 슬라이드용 3D 이미지 최소 2개
317
+ content_3d_count = 0 # 컨텐츠 슬라이드 3D 이미지 카운터
318
+
319
+ # 다이어그램이 필요한 슬라이드를 미리 분석
320
+ diagram_candidates = []
321
+ if include_diagrams:
322
+ for i, slide_data in enumerate(slides_data):
323
+ title = slide_data.get('title', '')
324
+ content = slide_data.get('content', '')
325
+ diagram_type, score = detect_diagram_type_with_score(title, content)
326
+ if diagram_type and score > 0:
327
+ diagram_candidates.append((i, diagram_type, score))
328
+
329
+ # 필요도 점수가 높은 순으로 정렬하고 상위 2개만 선택
330
+ diagram_candidates.sort(key=lambda x: x[2], reverse=True)
331
+ diagram_candidates = diagram_candidates[:max_diagrams]
332
+ diagram_slide_indices = [x[0] for x in diagram_candidates]
333
+ else:
334
+ diagram_slide_indices = []
335
+
336
+ # FLUX 스타일 다이어그램을 생성할 슬라이드 선택 (비중 증가)
337
+ flux_style_indices = []
338
+ if include_flux_images:
339
+ # 다이어그램과 겹치지 않는 슬라이드 중에서 선택
340
+ available_slides = [i for i in range(len(slides_data)) if i not in diagram_slide_indices]
341
+
342
+ # 전체 슬라이드의 40% 이상을 FLUX 스타일로 (최소 4개)
343
+ target_flux_count = max(max_flux_style, int(len(available_slides) * 0.4))
344
+
345
+ # 2장마다 하나씩 선택하되, 최소 4개는 보장
346
+ for i in available_slides:
347
+ if len(flux_style_indices) < target_flux_count:
348
+ if i % 2 == 0 or len(flux_style_indices) < max_flux_style:
349
+ flux_style_indices.append(i)
350
+
351
+ # 추가 3D 이미지를 생성할 슬라이드 선택 (FLUX와 다이어그램이 없는 슬라이드)
352
+ additional_3d_indices = []
353
+ if include_ai_image:
354
+ used_indices = set(diagram_slide_indices + flux_style_indices)
355
+ available_for_3d = [i for i in range(len(slides_data)) if i not in used_indices]
356
+
357
+ # 전략적으로 3D 이미지 배치 (중간과 후반부에)
358
+ if len(available_for_3d) >= max_3d_content:
359
+ # 중간 지점
360
+ mid_point = len(slides_data) // 2
361
+ # 3/4 지점
362
+ three_quarter_point = (3 * len(slides_data)) // 4
363
+
364
+ # 중간 지점 근처에서 하나
365
+ for i in range(max(0, mid_point - 2), min(len(slides_data), mid_point + 3)):
366
+ if i in available_for_3d and len(additional_3d_indices) < 1:
367
+ additional_3d_indices.append(i)
368
+ break
369
+
370
+ # 3/4 지점 근처에서 하나
371
+ for i in range(max(0, three_quarter_point - 2), min(len(slides_data), three_quarter_point + 3)):
372
+ if i in available_for_3d and i not in additional_3d_indices and len(additional_3d_indices) < 2:
373
+ additional_3d_indices.append(i)
374
+ break
375
+
376
+ # 부족하면 랜덤하게 추가
377
+ remaining = [i for i in available_for_3d if i not in additional_3d_indices]
378
+ while len(additional_3d_indices) < max_3d_content and remaining:
379
+ idx = remaining.pop(0)
380
+ additional_3d_indices.append(idx)
381
+
382
+ logger.info(f"Visual distribution planning:")
383
+ logger.info(f"- Diagram slides (max {max_diagrams}): {diagram_slide_indices}")
384
+ logger.info(f"- FLUX style slides (min {max_flux_style}): {flux_style_indices[:max_flux_style]} (total: {len(flux_style_indices)})")
385
+ logger.info(f"- Additional 3D slides (min {max_3d_content}): {additional_3d_indices}")
386
+
387
+ # ─────────────────────────────────────────────────────────
388
+ # 1) 제목 슬라이드(표지) 생성
389
+ # ─────────────────────────────────────────────────────────
390
+ title_slide_layout = prs.slide_layouts[6] # Blank layout 사용
391
+ slide = prs.slides.add_slide(title_slide_layout)
392
+
393
+ # Placeholder 정리
394
+ clean_slide_placeholders(slide)
395
+
396
+ # 배경 그라디언트
397
+ add_gradient_background(slide, theme['colors']['primary'], theme['colors']['secondary'])
398
+
399
+ # 제목 추가 (직접 텍스트박스로)
400
+ title_box = slide.shapes.add_textbox(
401
+ Inches(0.5), Inches(1.0), Inches(9), Inches(1.2)
402
+ )
403
+ title_frame = title_box.text_frame
404
+ title_frame.text = topic
405
+ title_frame.word_wrap = True
406
+
407
+ p = title_frame.paragraphs[0]
408
+ p.font.name = theme['fonts']['title']
409
+ p.font.size = Pt(36)
410
+ p.font.bold = True
411
+ p.font.color.rgb = RGBColor(255, 255, 255)
412
+ p.alignment = PP_ALIGN.CENTER
413
+
414
+ # 부제목 추가
415
+ subtitle_box = slide.shapes.add_textbox(
416
+ Inches(0.5), Inches(2.2), Inches(9), Inches(0.9)
417
+ )
418
+ subtitle_frame = subtitle_box.text_frame
419
+ subtitle_frame.word_wrap = True
420
+
421
+ p2 = subtitle_frame.paragraphs[0]
422
+ p2.font.name = theme['fonts']['subtitle']
423
+ p2.font.size = Pt(20)
424
+ p2.font.color.rgb = RGBColor(255, 255, 255)
425
+ p2.alignment = PP_ALIGN.CENTER
426
+
427
+ # 표지 이미지 (3D API)
428
+ if include_ai_image and AI_IMAGE_ENABLED:
429
+ logger.info("Generating AI cover image via 3D API...")
430
+
431
+ prompt_3d = generate_cover_image_prompt(topic)
432
+ ai_image_path = generate_ai_image_via_3d_api(prompt_3d)
433
+
434
+ if ai_image_path and os.path.exists(ai_image_path):
435
+ try:
436
+ img = Image.open(ai_image_path)
437
+ img_width, img_height = img.size
438
+
439
+ max_width = Inches(3.5)
440
+ max_height = Inches(2.5)
441
+
442
+ ratio = img_height / img_width
443
+ img_w = max_width
444
+ img_h = max_width * ratio
445
+
446
+ if img_h > max_height:
447
+ img_h = max_height
448
+ img_w = max_height / ratio
449
+
450
+ left = prs.slide_width - img_w - Inches(0.5)
451
+ top = prs.slide_height - img_h - Inches(0.8)
452
+
453
+ pic = slide.shapes.add_picture(ai_image_path, left, top, width=img_w, height=img_h)
454
+ pic.shadow.inherit = False
455
+ pic.shadow.visible = True
456
+ pic.shadow.blur_radius = Pt(15)
457
+ pic.shadow.distance = Pt(8)
458
+ pic.shadow.angle = 45
459
+
460
+ caption_box = slide.shapes.add_textbox(
461
+ left, top - Inches(0.3),
462
+ img_w, Inches(0.3)
463
+ )
464
+ caption_tf = caption_box.text_frame
465
+ caption_tf.text = "AI Generated - 3D Style"
466
+ caption_p = caption_tf.paragraphs[0]
467
+ caption_p.font.size = Pt(10)
468
+ caption_p.font.color.rgb = RGBColor(255, 255, 255)
469
+ caption_p.alignment = PP_ALIGN.CENTER
470
+
471
+ image_count_3d += 1
472
+
473
+ # 임시 파일 정리
474
+ try:
475
+ os.unlink(ai_image_path)
476
+ except:
477
+ pass
478
+
479
+ except Exception as e:
480
+ logger.error(f"Failed to add cover image: {e}")
481
+
482
+ add_decorative_shapes(slide, theme)
483
+
484
+ # ─────────────────────────────────────────────────────────
485
+ # 2) 컨텐츠 슬라이드 생성
486
+ # ─────────────────────────────────────────────────────────
487
+ for i, slide_data in enumerate(slides_data):
488
+ layout_type = slide_data.get('layout', 'title_content')
489
+
490
+ logger.info(f"Creating slide {i+1}: {slide_data.get('title', 'No title')}")
491
+ logger.debug(f"Content length: {len(slide_data.get('content', ''))}")
492
+
493
+ # 항상 빈 레이아웃 사용
494
+ slide_layout = prs.slide_layouts[6] # Blank layout
495
+ slide = prs.slides.add_slide(slide_layout)
496
+
497
+ # Placeholder 정리
498
+ clean_slide_placeholders(slide)
499
+
500
+ # Apply theme
501
+ apply_theme_to_slide(slide, theme, layout_type)
502
+
503
+ # Add bridge phrase if available (previous slide transition)
504
+ if i > 0 and slide_data.get('bridge_phrase'):
505
+ bridge_box = slide.shapes.add_textbox(
506
+ Inches(0.5), Inches(0.1), Inches(9), Inches(0.3)
507
+ )
508
+ bridge_tf = bridge_box.text_frame
509
+ bridge_tf.text = slide_data['bridge_phrase']
510
+ bridge_tf.word_wrap = True
511
+
512
+ for paragraph in bridge_tf.paragraphs:
513
+ paragraph.font.size = Pt(12)
514
+ paragraph.font.italic = True
515
+ paragraph.font.color.rgb = theme['colors']['secondary']
516
+ paragraph.alignment = PP_ALIGN.LEFT
517
+
518
+ # 제목 추가 (직접 텍스트박스로)
519
+ title_box = slide.shapes.add_textbox(
520
+ Inches(0.5), Inches(0.5), Inches(9), Inches(1)
521
+ )
522
+ title_frame = title_box.text_frame
523
+ title_frame.text = slide_data.get('title', 'No Title')
524
+ title_frame.word_wrap = True
525
+
526
+ # 제목 스타일 적용
527
+ for paragraph in title_frame.paragraphs:
528
+ if layout_type == 'section_header':
529
+ paragraph.font.size = Pt(28)
530
+ paragraph.font.color.rgb = RGBColor(255, 255, 255)
531
+ paragraph.alignment = PP_ALIGN.CENTER
532
+ else:
533
+ paragraph.font.size = Pt(24)
534
+ paragraph.font.color.rgb = theme['colors']['primary']
535
+ paragraph.font.bold = True
536
+ paragraph.font.name = theme['fonts']['title']
537
+
538
+ # 슬라이드 정보
539
+ slide_title = slide_data.get('title', '')
540
+ slide_content = slide_data.get('content', '')
541
+
542
+ # 결론/하이라이트 슬라이드 감지
543
+ is_conclusion_slide = any(word in slide_title.lower() for word in
544
+ ['결론', 'conclusion', '요약', 'summary', '핵심', 'key',
545
+ '마무리', 'closing', '정리', 'takeaway', '시사점', 'implication'])
546
+
547
+ # 시각적 요소 추가 여부 결정
548
+ should_add_visual = False
549
+ visual_type = None
550
+
551
+ # 1. 다이어그램 모듈 사용
552
+ if i in diagram_slide_indices and diagram_count < max_diagrams:
553
+ should_add_visual = True
554
+ diagram_info = next(x for x in diagram_candidates if x[0] == i)
555
+ visual_type = ('diagram', diagram_info[1])
556
+ diagram_count += 1
557
+ # 2. 결론 슬라이드 이미지 (3D API)
558
+ elif is_conclusion_slide and include_ai_image:
559
+ should_add_visual = True
560
+ visual_type = ('conclusion_image', None)
561
+ # 3. FLUX 스타일 다이어그램 이미지 (증가된 비중)
562
+ elif i in flux_style_indices and flux_style_count < len(flux_style_indices):
563
+ should_add_visual = True
564
+ visual_type = ('flux_style_diagram', None)
565
+ flux_style_count += 1
566
+ # 4. 추가 3D 이미지 (새로 추가)
567
+ elif i in additional_3d_indices and content_3d_count < max_3d_content:
568
+ should_add_visual = True
569
+ visual_type = ('content_3d_image', None)
570
+ content_3d_count += 1
571
+
572
+ # 시각적 요소가 있는 경우 좌-우 레이아웃 적용
573
+ if should_add_visual and layout_type not in ['section_header']:
574
+ # 좌측에 텍스트 배치
575
+ left_box = slide.shapes.add_textbox(
576
+ Inches(0.5), Inches(1.5), Inches(4.5), Inches(3.5)
577
+ )
578
+ left_tf = left_box.text_frame
579
+ left_tf.clear()
580
+ left_tf.text = slide_content
581
+ left_tf.word_wrap = True
582
+ force_font_size(left_tf, 14, theme)
583
+
584
+ # Apply emoji bullets
585
+ for paragraph in left_tf.paragraphs:
586
+ text = paragraph.text.strip()
587
+ if text and text.startswith(('-', '•', '●')) and not has_emoji(text):
588
+ clean_text = text.lstrip('-•● ')
589
+ emoji = get_emoji_for_content(clean_text)
590
+ paragraph.text = f"{emoji} {clean_text}"
591
+ force_font_size(left_tf, 14, theme)
592
+
593
+ # 우측에 시각적 요소 추가
594
+ visual_added = False
595
+
596
+ if visual_type[0] == 'diagram' and DIAGRAM_GENERATORS_AVAILABLE:
597
+ # 기존 다이어그램 생성
598
+ logger.info(f"Generating {visual_type[1]} for slide {i+1} (Diagram {diagram_count}/{max_diagrams})")
599
+ diagram_json = generate_diagram_json(slide_title, slide_content, visual_type[1])
600
+
601
+ if diagram_json:
602
+ diagram_path = generate_diagram_locally(diagram_json, visual_type[1], "png")
603
+ if diagram_path and os.path.exists(diagram_path):
604
+ try:
605
+ pic = slide.shapes.add_picture(
606
+ diagram_path,
607
+ Inches(5.2), Inches(1.5),
608
+ width=Inches(4.3), height=Inches(3.0)
609
+ )
610
+ visual_added = True
611
+
612
+ caption_box = slide.shapes.add_textbox(
613
+ Inches(5.2), Inches(4.6), Inches(4.3), Inches(0.3)
614
+ )
615
+ caption_tf = caption_box.text_frame
616
+ caption_tf.text = f"{visual_type[1]} Diagram"
617
+ caption_p = caption_tf.paragraphs[0]
618
+ caption_p.font.size = Pt(10)
619
+ caption_p.font.color.rgb = theme['colors']['secondary']
620
+ caption_p.alignment = PP_ALIGN.CENTER
621
+
622
+ try:
623
+ os.unlink(diagram_path)
624
+ except:
625
+ pass
626
+ except Exception as e:
627
+ logger.error(f"Failed to add diagram: {e}")
628
+
629
+ elif visual_type[0] == 'conclusion_image':
630
+ # 결론 슬라이드용 3D 이미지 생성
631
+ logger.info(f"Generating conclusion image for slide {i+1}")
632
+ prompt_3d = generate_conclusion_image_prompt(slide_title, slide_content)
633
+
634
+ selected_image = generate_ai_image_via_3d_api(prompt_3d)
635
+
636
+ if selected_image and os.path.exists(selected_image):
637
+ try:
638
+ pic = slide.shapes.add_picture(
639
+ selected_image,
640
+ Inches(5.2), Inches(1.5),
641
+ width=Inches(4.3), height=Inches(3.0)
642
+ )
643
+ visual_added = True
644
+ image_count_3d += 1
645
+
646
+ caption_box = slide.shapes.add_textbox(
647
+ Inches(5.2), Inches(4.6), Inches(4.3), Inches(0.3)
648
+ )
649
+ caption_tf = caption_box.text_frame
650
+ caption_tf.text = "Key Takeaway Visualization - 3D Style"
651
+ caption_p = caption_tf.paragraphs[0]
652
+ caption_p.font.size = Pt(10)
653
+ caption_p.font.color.rgb = theme['colors']['secondary']
654
+ caption_p.alignment = PP_ALIGN.CENTER
655
+
656
+ try:
657
+ os.unlink(selected_image)
658
+ except:
659
+ pass
660
+ except Exception as e:
661
+ logger.error(f"Failed to add conclusion image: {e}")
662
+
663
+ elif visual_type[0] == 'flux_style_diagram':
664
+ # FLUX 스타일 다이어그램을 3D API로 생성
665
+ logger.info(
666
+ f"Generating FLUX-style diagram image for slide {i+1} "
667
+ f"(FLUX style {flux_style_count}/{len(flux_style_indices)})"
668
+ )
669
+
670
+ # FLUX 스타일 선택 및 프롬프트 생성
671
+ style_key = pick_flux_style(i)
672
+ logger.info(f"[FLUX Style] Selected: {style_key}")
673
+
674
+ # FLUX 스타일 프롬프트를 3D API용으로 변환
675
+ flux_prompt = generate_flux_prompt(slide_title, slide_content, style_key)
676
+
677
+ # 3D API용 프롬프트로 변환 (wbgmsst 추가 및 3D 요소 강조)
678
+ prompt_3d = f"wbgmsst, 3D {style_key.lower()} style, {flux_prompt}, isometric 3D perspective"
679
+
680
+ selected_image = generate_ai_image_via_3d_api(prompt_3d)
681
+
682
+ if selected_image and os.path.exists(selected_image):
683
+ try:
684
+ pic = slide.shapes.add_picture(
685
+ selected_image,
686
+ Inches(5.2), Inches(1.5),
687
+ width=Inches(4.3), height=Inches(3.0)
688
+ )
689
+ visual_added = True
690
+ image_count_3d += 1
691
+
692
+ # 캡션에 FLUX 스타일 표시
693
+ caption_box = slide.shapes.add_textbox(
694
+ Inches(5.2), Inches(4.6), Inches(4.3), Inches(0.3)
695
+ )
696
+ caption_tf = caption_box.text_frame
697
+ caption_tf.text = f"AI Generated - {style_key} Style (3D)"
698
+ caption_p = caption_tf.paragraphs[0]
699
+ caption_p.font.size = Pt(10)
700
+ caption_p.font.color.rgb = theme['colors']['secondary']
701
+ caption_p.alignment = PP_ALIGN.CENTER
702
+
703
+ logger.info(f"[3D API] Successfully generated {style_key} style image")
704
+
705
+ except Exception as e:
706
+ logger.error(f"Failed to add FLUX-style image: {e}")
707
+ finally:
708
+ # 임시 파일 정리
709
+ if selected_image and os.path.exists(selected_image):
710
+ try:
711
+ os.unlink(selected_image)
712
+ except:
713
+ pass
714
+
715
+ elif visual_type[0] == 'content_3d_image':
716
+ # 추가 3D 이미지 생성
717
+ logger.info(f"Generating additional 3D image for slide {i+1} ({content_3d_count}/{max_3d_content})")
718
+ prompt_3d = generate_diverse_prompt(slide_title, slide_content, i)
719
+
720
+ selected_image = generate_ai_image_via_3d_api(prompt_3d)
721
+
722
+ if selected_image and os.path.exists(selected_image):
723
+ try:
724
+ pic = slide.shapes.add_picture(
725
+ selected_image,
726
+ Inches(5.2), Inches(1.5),
727
+ width=Inches(4.3), height=Inches(3.0)
728
+ )
729
+ visual_added = True
730
+ image_count_3d += 1
731
+
732
+ caption_box = slide.shapes.add_textbox(
733
+ Inches(5.2), Inches(4.6), Inches(4.3), Inches(0.3)
734
+ )
735
+ caption_tf = caption_box.text_frame
736
+ caption_tf.text = "Content Visualization - 3D Style"
737
+ caption_p = caption_tf.paragraphs[0]
738
+ caption_p.font.size = Pt(10)
739
+ caption_p.font.color.rgb = theme['colors']['secondary']
740
+ caption_p.alignment = PP_ALIGN.CENTER
741
+
742
+ try:
743
+ os.unlink(selected_image)
744
+ except:
745
+ pass
746
+ except Exception as e:
747
+ logger.error(f"Failed to add content 3D image: {e}")
748
+
749
+ # 시각적 요소가 추가되지 않은 경우 플레이스홀더 추가
750
+ if not visual_added:
751
+ placeholder_box = slide.shapes.add_textbox(
752
+ Inches(5.2), Inches(2.5), Inches(4.3), Inches(1.0)
753
+ )
754
+ placeholder_tf = placeholder_box.text_frame
755
+ placeholder_tf.text = f"{visual_type[1] if visual_type[0] == 'diagram' else 'Visual'} Placeholder"
756
+ placeholder_tf.paragraphs[0].font.size = Pt(14)
757
+ placeholder_tf.paragraphs[0].font.color.rgb = theme['colors']['secondary']
758
+ placeholder_tf.paragraphs[0].alignment = PP_ALIGN.CENTER
759
+
760
+ else:
761
+ # 기본 레이아웃 (시각적 요소 없음)
762
+ if layout_type == 'section_header':
763
+ content = slide_data.get('content', '')
764
+ if content:
765
+ logger.info(f"Adding content to section header slide {i+1}: {content[:50]}...")
766
+ textbox = slide.shapes.add_textbox(
767
+ Inches(1), Inches(3.5), Inches(8), Inches(1.5)
768
+ )
769
+ tf = textbox.text_frame
770
+ tf.clear()
771
+ tf.text = content
772
+ tf.word_wrap = True
773
+
774
+ for paragraph in tf.paragraphs:
775
+ paragraph.font.name = theme['fonts']['body']
776
+ paragraph.font.size = Pt(16)
777
+ paragraph.font.color.rgb = RGBColor(255, 255, 255)
778
+ paragraph.alignment = PP_ALIGN.CENTER
779
+
780
+ line = slide.shapes.add_shape(
781
+ MSO_SHAPE.RECTANGLE, Inches(3), Inches(3.2), Inches(4), Pt(4)
782
+ )
783
+ line.fill.solid()
784
+ line.fill.fore_color.rgb = RGBColor(255, 255, 255)
785
+ line.line.fill.background()
786
+
787
+ elif layout_type == 'two_content':
788
+ content = slide_data.get('content', '')
789
+ if content:
790
+ logger.info(f"Creating two-column layout for slide {i+1}")
791
+ content_lines = content.split('\n')
792
+ mid_point = len(content_lines) // 2
793
+
794
+ # Left column
795
+ left_box = slide.shapes.add_textbox(
796
+ Inches(0.5), Inches(1.5), Inches(4.5), Inches(3.5)
797
+ )
798
+ left_tf = left_box.text_frame
799
+ left_tf.clear()
800
+ left_content = '\n'.join(content_lines[:mid_point])
801
+ if left_content:
802
+ left_tf.text = left_content
803
+ left_tf.word_wrap = True
804
+ force_font_size(left_tf, 14, theme)
805
+
806
+ for paragraph in left_tf.paragraphs:
807
+ text = paragraph.text.strip()
808
+ if text and text.startswith(('-', '•', '●')) and not has_emoji(text):
809
+ clean_text = text.lstrip('-•● ')
810
+ emoji = get_emoji_for_content(clean_text)
811
+ paragraph.text = f"{emoji} {clean_text}"
812
+ force_font_size(left_tf, 14, theme)
813
+
814
+ # Right column
815
+ right_box = slide.shapes.add_textbox(
816
+ Inches(5), Inches(1.5), Inches(4.5), Inches(3.5)
817
+ )
818
+ right_tf = right_box.text_frame
819
+ right_tf.clear()
820
+ right_content = '\n'.join(content_lines[mid_point:])
821
+ if right_content:
822
+ right_tf.text = right_content
823
+ right_tf.word_wrap = True
824
+ force_font_size(right_tf, 14, theme)
825
+
826
+ for paragraph in right_tf.paragraphs:
827
+ text = paragraph.text.strip()
828
+ if text and text.startswith(('-', '•', '●')) and not has_emoji(text):
829
+ clean_text = text.lstrip('-•● ')
830
+ emoji = get_emoji_for_content(clean_text)
831
+ paragraph.text = f"{emoji} {clean_text}"
832
+ force_font_size(right_tf, 14, theme)
833
+
834
+ else:
835
+ # Regular content
836
+ content = slide_data.get('content', '')
837
+
838
+ logger.info(f"Slide {i+1} - Content to add: '{content[:100]}...' (length: {len(content)})")
839
+
840
+ if include_charts and slide_data.get('chart_data'):
841
+ create_chart_slide(slide, slide_data['chart_data'], theme)
842
+
843
+ if content and content.strip():
844
+ textbox = slide.shapes.add_textbox(
845
+ Inches(0.5), Inches(1.5), Inches(9), Inches(3.5)
846
+ )
847
+
848
+ tf = textbox.text_frame
849
+ tf.clear()
850
+
851
+ tf.text = content.strip()
852
+ tf.word_wrap = True
853
+
854
+ tf.margin_left = Inches(0.1)
855
+ tf.margin_right = Inches(0.1)
856
+ tf.margin_top = Inches(0.05)
857
+ tf.margin_bottom = Inches(0.05)
858
+
859
+ force_font_size(tf, 16, theme)
860
+
861
+ for p_idx, paragraph in enumerate(tf.paragraphs):
862
+ if paragraph.text.strip():
863
+ text = paragraph.text.strip()
864
+ if text.startswith(('-', '•', '●')) and not has_emoji(text):
865
+ clean_text = text.lstrip('-•● ')
866
+ emoji = get_emoji_for_content(clean_text)
867
+ paragraph.text = f"{emoji} {clean_text}"
868
+
869
+ if paragraph.runs:
870
+ for run in paragraph.runs:
871
+ run.font.size = Pt(16)
872
+ run.font.name = theme['fonts']['body']
873
+ run.font.color.rgb = theme['colors']['text']
874
+ else:
875
+ paragraph.font.size = Pt(16)
876
+ paragraph.font.name = theme['fonts']['body']
877
+ paragraph.font.color.rgb = theme['colors']['text']
878
+
879
+ paragraph.space_before = Pt(6)
880
+ paragraph.space_after = Pt(6)
881
+ paragraph.line_spacing = 1.3
882
+
883
+ logger.info(f"Successfully added content to slide {i+1}")
884
+ else:
885
+ logger.warning(f"Slide {i+1} has no content or empty content")
886
+
887
+ # Add slide notes if available
888
+ if slide_data.get('notes'):
889
+ try:
890
+ notes_slide = slide.notes_slide
891
+ notes_text_frame = notes_slide.notes_text_frame
892
+ notes_text_frame.text = slide_data.get('notes', '')
893
+ except Exception as e:
894
+ logger.warning(f"Failed to add slide notes: {e}")
895
+
896
+ # Add slide number
897
+ slide_number_bg = slide.shapes.add_shape(
898
+ MSO_SHAPE.ROUNDED_RECTANGLE,
899
+ Inches(8.3), Inches(5.0), Inches(1.5), Inches(0.5)
900
+ )
901
+ slide_number_bg.fill.solid()
902
+ slide_number_bg.fill.fore_color.rgb = theme['colors']['primary']
903
+ slide_number_bg.fill.transparency = 0.8
904
+ slide_number_bg.line.fill.background()
905
+
906
+ slide_number_box = slide.shapes.add_textbox(
907
+ Inches(8.3), Inches(5.05), Inches(1.5), Inches(0.4)
908
+ )
909
+ slide_number_frame = slide_number_box.text_frame
910
+ slide_number_frame.text = f"{i + 1} / {len(slides_data)}"
911
+ slide_number_frame.paragraphs[0].font.size = Pt(10)
912
+ slide_number_frame.paragraphs[0].font.color.rgb = RGBColor(255, 255, 255)
913
+ slide_number_frame.paragraphs[0].font.bold = False
914
+ slide_number_frame.paragraphs[0].alignment = PP_ALIGN.CENTER
915
+
916
+ if i % 2 == 0:
917
+ accent_shape = slide.shapes.add_shape(
918
+ MSO_SHAPE.OVAL,
919
+ Inches(9.6), Inches(0.1),
920
+ Inches(0.2), Inches(0.2)
921
+ )
922
+ accent_shape.fill.solid()
923
+ accent_shape.fill.fore_color.rgb = theme['colors']['accent']
924
+ accent_shape.line.fill.background()
925
+
926
+ # 이미지 생성 로그
927
+ logger.info(f"Total visual elements generated:")
928
+ logger.info(f"- 3D API images: {image_count_3d} total")
929
+ logger.info(f" - Cover: 1")
930
+ logger.info(f" - Content slides: {content_3d_count}")
931
+ logger.info(f" - Conclusion: {1 if any('conclusion' in str(v) for v in locals().values()) else 0}")
932
+ logger.info(f" - FLUX-style: {flux_style_count}")
933
+ logger.info(f"- Traditional diagrams: {diagram_count}")
934
+
935
+ # Add thank you slide
936
+ thank_you_layout = prs.slide_layouts[6] # Blank layout
937
+ thank_you_slide = prs.slides.add_slide(thank_you_layout)
938
+
939
+ # Placeholder 정리
940
+ clean_slide_placeholders(thank_you_slide)
941
+
942
+ add_gradient_background(thank_you_slide, theme['colors']['secondary'], theme['colors']['primary'])
943
+
944
+ # Thank you 제목 추가
945
+ thank_you_title_box = thank_you_slide.shapes.add_textbox(
946
+ Inches(0.5), Inches(2.0), Inches(9), Inches(1.5)
947
+ )
948
+ thank_you_title_frame = thank_you_title_box.text_frame
949
+ thank_you_title_frame.text = "Thank You"
950
+ thank_you_title_frame.word_wrap = True
951
+
952
+ for paragraph in thank_you_title_frame.paragraphs:
953
+ paragraph.font.size = Pt(36)
954
+ paragraph.font.bold = True
955
+ paragraph.font.color.rgb = RGBColor(255, 255, 255)
956
+ paragraph.alignment = PP_ALIGN.CENTER
957
+ paragraph.font.name = theme['fonts']['title']
958
+
959
+ info_box = thank_you_slide.shapes.add_textbox(
960
+ Inches(2), Inches(3.5), Inches(6), Inches(1)
961
+ )
962
+ info_tf = info_box.text_frame
963
+ info_tf.text = "AI-Generated Presentation"
964
+ info_tf.paragraphs[0].font.size = Pt(18)
965
+ info_tf.paragraphs[0].font.color.rgb = RGBColor(255, 255, 255)
966
+ info_tf.paragraphs[0].alignment = PP_ALIGN.CENTER
967
+
968
+ # Save to temporary file
969
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".pptx") as tmp_file:
970
+ prs.save(tmp_file.name)
971
+ return tmp_file.name