Spaces:
Running
Running
# gemini_ppt_generator.py | |
import os | |
import json | |
import requests | |
import tempfile | |
from io import BytesIO | |
from PIL import Image | |
import gradio as gr | |
import google.generativeai as genai | |
from pptx import Presentation | |
from pptx.util import Inches, Pt | |
from pptx.enum.text import PP_ALIGN | |
from pptx.dml.color import RGBColor | |
from slide_themes import SlideThemeManager | |
from ppt_analyzer import PPTAnalyzer | |
class GeminiPPTGenerator: | |
def __init__(self): | |
self.pexels_headers = {} | |
self.gemini_model = None | |
self.config_file = "config.json" | |
# 載入已保存的API金鑰 | |
self.load_config() | |
# 初始化版型管理器 | |
self.theme_manager = SlideThemeManager() | |
# 16:9 簡報尺寸 (單位:英吋) | |
self.slide_width = self.theme_manager.slide_width | |
self.slide_height = self.theme_manager.slide_height | |
# 圖片風格 | |
self.image_styles = self.theme_manager.image_styles | |
def load_config(self): | |
"""從config.json載入API金鑰""" | |
try: | |
if os.path.exists(self.config_file): | |
with open(self.config_file, 'r', encoding='utf-8') as f: | |
config = json.load(f) | |
gemini_key = config.get('gemini_api_key', '') | |
pexels_key = config.get('pexels_api_key', '') | |
if gemini_key and pexels_key: | |
self.setup_apis(gemini_key, pexels_key) | |
return gemini_key, pexels_key | |
except Exception as e: | |
print(f"載入配置錯誤: {e}") | |
return '', '' | |
def save_config(self, gemini_api_key, pexels_api_key): | |
"""保存API金鑰到config.json""" | |
try: | |
config = { | |
'gemini_api_key': gemini_api_key, | |
'pexels_api_key': pexels_api_key | |
} | |
with open(self.config_file, 'w', encoding='utf-8') as f: | |
json.dump(config, f, ensure_ascii=False, indent=2) | |
return True | |
except Exception as e: | |
print(f"保存配置錯誤: {e}") | |
return False | |
def get_saved_keys(self): | |
"""獲取已保存的API金鑰""" | |
try: | |
if os.path.exists(self.config_file): | |
with open(self.config_file, 'r', encoding='utf-8') as f: | |
config = json.load(f) | |
return config.get('gemini_api_key', ''), config.get('pexels_api_key', '') | |
except: | |
pass | |
return '', '' | |
def setup_apis(self, gemini_api_key, pexels_api_key): | |
"""設定 API 金鑰""" | |
try: | |
# 設定 Gemini API | |
if gemini_api_key: | |
genai.configure(api_key=gemini_api_key) | |
self.gemini_model = genai.GenerativeModel('gemini-2.5-flash-preview-05-20') | |
# 設定 Pexels API | |
if pexels_api_key: | |
self.pexels_headers = { | |
"Authorization": pexels_api_key | |
} | |
return True, "✅ API 設定成功" | |
except Exception as e: | |
return False, f"❌ API 設定失敗:{str(e)}" | |
def generate_content_with_gemini(self, topic, slide_count=5): | |
"""使用 Gemini 生成簡報內容""" | |
prompt = f""" | |
請為主題「{topic}」製作一個 {slide_count} 頁的簡報大綱,以 JSON 格式回傳。 | |
格式要求: | |
{{ | |
"title": "簡報主標題", | |
"subtitle": "簡報副標題", | |
"title_keywords": "主題相關的英文關鍵字,用於搜尋標題頁和結尾頁圖片", | |
"slides": [ | |
{{ | |
"title": "投影片標題", | |
"content": [ | |
"重點1", | |
"重點2", | |
"重點3" | |
], | |
"image_keywords": "英文關鍵字,用於搜尋相關圖片" | |
}} | |
] | |
}} | |
要求: | |
1. 內容要專業且有邏輯性 | |
2. 每頁 3-4 個重點 | |
3. title_keywords 要用英文,描述主題相關的圖片搜尋關鍵字(3-5個詞) | |
4. image_keywords 要用英文,描述該投影片適合的圖片內容 | |
5. 關鍵字要具體明確,例如 "business meeting", "technology innovation", "data analysis" | |
6. 使用繁體中文(除了 title_keywords 和 image_keywords) | |
7. 第一頁是概述介紹,最後一頁是結論總結 | |
8. 請直接回傳 JSON,不要包含其他文字說明,也不可以有"**"等不必要的markdown符號 | |
""" | |
try: | |
if self.gemini_model: | |
response = self.gemini_model.generate_content(prompt) | |
content = response.text | |
# 清理回應內容,提取 JSON | |
content = content.strip() | |
if content.startswith('```json'): | |
content = content[7:] | |
if content.endswith('```'): | |
content = content[:-3] | |
# 尋找 JSON 開始和結束位置 | |
start = content.find('{') | |
end = content.rfind('}') + 1 | |
if start != -1 and end > start: | |
json_str = content[start:end] | |
return json.loads(json_str) | |
else: | |
raise ValueError("無法在回應中找到有效的 JSON") | |
else: | |
return self.get_default_structure_with_images(topic) | |
except Exception as e: | |
print(f"Gemini API 錯誤: {e}") | |
return self.get_default_structure_with_images(topic) | |
def get_default_structure_with_images(self, topic): | |
"""預設簡報結構(含圖片關鍵字)""" | |
# 生成簡單的英文關鍵字 | |
title_keywords = "business presentation professional meeting" | |
if "科技" in topic or "技術" in topic: | |
title_keywords = "technology innovation digital development" | |
elif "教育" in topic or "學習" in topic: | |
title_keywords = "education learning academic study" | |
elif "醫療" in topic or "健康" in topic: | |
title_keywords = "healthcare medical health wellness" | |
elif "環境" in topic or "環保" in topic: | |
title_keywords = "environment sustainability green nature" | |
elif "經濟" in topic or "金融" in topic: | |
title_keywords = "economics finance business economy" | |
return { | |
"title": f"{topic} 簡報", | |
"subtitle": "由 AI 自動生成", | |
"title_keywords": title_keywords, | |
"slides": [ | |
{ | |
"title": "簡介與背景", | |
"content": [ | |
"主題背景介紹", | |
"研究目的與範圍", | |
"簡報架構說明" | |
], | |
"image_keywords": "presentation introduction business" | |
}, | |
{ | |
"title": "主要內容分析", | |
"content": [ | |
"核心概念說明", | |
"重要特點分析", | |
"相關案例討論" | |
], | |
"image_keywords": "analysis data research content" | |
}, | |
{ | |
"title": "深入探討", | |
"content": [ | |
"優勢與機會識別", | |
"挑戰與問題分析", | |
"影響因素評估" | |
], | |
"image_keywords": "strategy planning discussion" | |
}, | |
{ | |
"title": "解決方案與建議", | |
"content": [ | |
"策略建議提出", | |
"實施方法規劃", | |
"預期效果評估" | |
], | |
"image_keywords": "solution implementation strategy" | |
}, | |
{ | |
"title": "結論與展望", | |
"content": [ | |
"重點總結回顧", | |
"未來發展趨勢", | |
"行動建議提出" | |
], | |
"image_keywords": "conclusion future success" | |
} | |
] | |
} | |
def search_pexels_with_style(self, keywords, image_style="professional", per_page=10): | |
"""根據風格搜尋 Pexels 圖片""" | |
if not self.pexels_headers: | |
return None | |
# 先嘗試純主題關鍵字搜尋 | |
url = "https://api.pexels.com/v1/search" | |
params = { | |
"query": keywords, | |
"per_page": per_page, | |
"orientation": "landscape", | |
"size": "medium" | |
} | |
try: | |
response = requests.get(url, headers=self.pexels_headers, params=params) | |
if response.status_code == 200: | |
data = response.json() | |
if data["photos"] and len(data["photos"]) >= 3: | |
return data["photos"] | |
# 如果純主題搜尋結果不足,再組合風格關鍵字 | |
style_modifier = self.image_styles.get(image_style, "") | |
enhanced_keywords = f"{keywords} {style_modifier}" | |
params["query"] = enhanced_keywords | |
response = requests.get(url, headers=self.pexels_headers, params=params) | |
if response.status_code == 200: | |
data = response.json() | |
return data["photos"] if data["photos"] else None | |
return None | |
except Exception as e: | |
print(f"Pexels API 錯誤: {e}") | |
return None | |
def select_best_image(self, photos, slide_title=""): | |
"""從多張圖片中選擇最適合的""" | |
if not photos: | |
return None | |
# 選擇解析度較高的圖片 | |
best_photo = photos[0] | |
for photo in photos[:3]: | |
if photo["width"] * photo["height"] > best_photo["width"] * best_photo["height"]: | |
best_photo = photo | |
return best_photo["src"]["medium"] | |
def download_image(self, image_url): | |
"""下載圖片並返回檔案路徑""" | |
if not image_url: | |
return None | |
try: | |
response = requests.get(image_url) | |
if response.status_code == 200: | |
temp_dir = tempfile.mkdtemp() | |
image_path = os.path.join(temp_dir, "slide_image.jpg") | |
# 處理圖片 | |
image = Image.open(BytesIO(response.content)) | |
# 調整圖片大小 | |
max_size = (800, 600) | |
image.thumbnail(max_size, Image.Resampling.LANCZOS) | |
# 轉換並儲存 | |
if image.mode in ("RGBA", "P"): | |
image = image.convert("RGB") | |
image.save(image_path, "JPEG", quality=85) | |
return image_path | |
return None | |
except Exception as e: | |
print(f"圖片下載錯誤: {e}") | |
return None | |
def add_image_to_slide(self, slide, image_path, theme): | |
"""將圖片添加到投影片,保持比例避免變形""" | |
if not image_path or not os.path.exists(image_path): | |
return | |
try: | |
image_area = theme["image_area"] | |
# 目標區域 | |
target_left = Inches(image_area["left"]) | |
target_top = Inches(image_area["top"]) | |
target_width = Inches(image_area["width"]) | |
target_height = Inches(image_area["height"]) | |
# 載入圖片獲取原始尺寸 | |
from PIL import Image as PILImage | |
with PILImage.open(image_path) as img: | |
original_width, original_height = img.size | |
original_ratio = original_width / original_height | |
# 計算目標比例 | |
target_ratio = target_width.inches / target_height.inches | |
# 根據比例計算實際顯示尺寸,保持圖片比例 | |
if original_ratio > target_ratio: | |
# 圖片較寬,以寬度為準 | |
actual_width = target_width | |
actual_height = Inches(target_width.inches / original_ratio) | |
# 垂直置中 | |
actual_top = Inches(target_top.inches + (target_height.inches - actual_height.inches) / 2) | |
actual_left = target_left | |
else: | |
# 圖片較高,以高度為準 | |
actual_height = target_height | |
actual_width = Inches(target_height.inches * original_ratio) | |
# 水平置中 | |
actual_left = Inches(target_left.inches + (target_width.inches - actual_width.inches) / 2) | |
actual_top = target_top | |
# 添加圖片 | |
picture = slide.shapes.add_picture(image_path, actual_left, actual_top, actual_width, actual_height) | |
except Exception as e: | |
print(f"添加圖片錯誤: {e}") | |
# 降級處理:如果計算失敗,使用原來的方式 | |
try: | |
left = Inches(image_area["left"]) | |
top = Inches(image_area["top"]) | |
width = Inches(image_area["width"]) | |
height = Inches(image_area["height"]) | |
slide.shapes.add_picture(image_path, left, top, width, height) | |
except: | |
pass | |
def setup_slide_content(self, slide, slide_data, theme): | |
"""設定投影片內容,使用正確的位置""" | |
try: | |
# 設置背景和裝飾元素 | |
self.theme_manager.setup_slide_background_and_layout(slide, theme) | |
# 設定標題 | |
title_shape = slide.shapes.title | |
title_shape.text = slide_data["title"] | |
# 調整標題位置和尺寸 | |
title_area = theme["title_area"] | |
title_shape.left = Inches(title_area["left"]) | |
title_shape.top = Inches(title_area["top"]) | |
title_shape.width = Inches(title_area["width"]) | |
title_shape.height = Inches(title_area["height"]) | |
self.theme_manager.format_title(title_shape, theme, 34, self.get_font_name) | |
# 移除預設內容佔位符(如果存在) | |
shapes_to_remove = [] | |
for shape in slide.shapes: | |
try: | |
if hasattr(shape, 'placeholder_format') and shape.placeholder_format is not None: | |
if shape.placeholder_format.type == 2: # 內容佔位符 | |
shapes_to_remove.append(shape) | |
except: | |
continue | |
for shape in shapes_to_remove: | |
try: | |
sp = shape.element | |
sp.getparent().remove(sp) | |
except: | |
continue | |
# 設定內容區域 | |
content_area = theme["content_area"] | |
# 創建帶背景的內容框 | |
self.theme_manager.create_content_box_with_background(slide, theme, content_area) | |
# 創建新的內容文字框 | |
left = Inches(content_area["left"]) | |
top = Inches(content_area["top"]) | |
width = Inches(content_area["width"]) | |
height = Inches(content_area["height"]) | |
textbox = slide.shapes.add_textbox(left, top, width, height) | |
text_frame = textbox.text_frame | |
# 設定文字框屬性 | |
text_frame.margin_left = Inches(0.15) | |
text_frame.margin_right = Inches(0.15) | |
text_frame.margin_top = Inches(0.1) | |
text_frame.margin_bottom = Inches(0.1) | |
text_frame.word_wrap = True | |
text_frame.auto_size = None # 不自動調整大小 | |
# 清除預設文字 | |
text_frame.clear() | |
# 添加內容 | |
for i, point in enumerate(slide_data["content"]): | |
if i == 0: | |
p = text_frame.paragraphs[0] | |
else: | |
p = text_frame.add_paragraph() | |
p.text = f"• {point}" | |
p.level = 0 | |
p.space_after = Pt(10) # 段落間距 | |
self.theme_manager.format_content(p, theme, 22, self.get_font_name) | |
except Exception as e: | |
print(f"設定投影片內容錯誤詳細: {str(e)}") | |
import traceback | |
print(f"錯誤追蹤: {traceback.format_exc()}") | |
def adjust_content_layout(self, slide, layout_type): | |
"""這個方法已被 setup_slide_content 取代,保留以免錯誤""" | |
pass | |
def get_font_name(self): | |
"""獲取中文字型名稱""" | |
# 檢查是否有自定義中文字型檔案 | |
font_path = os.path.join(os.path.dirname(__file__), "cht.ttf") | |
if os.path.exists(font_path): | |
return "cht" # 使用自定義字型 | |
else: | |
# 備用字型選擇 | |
return "Arial Unicode MS" # 通用 Unicode 字型 | |
def format_title_with_shadow(self, shape, theme, font_size): | |
"""格式化標題並添加陰影效果以提高可讀性""" | |
self.theme_manager.format_title(shape, theme, font_size, self.get_font_name) | |
try: | |
paragraph = shape.text_frame.paragraphs[0] | |
paragraph.font.color.rgb = RGBColor(255, 255, 255) # 白色文字在深色背景上更清楚 | |
paragraph.alignment = PP_ALIGN.CENTER | |
paragraph.font.bold = True | |
except: | |
pass | |
def create_presentation_with_images(self, topic, theme_name="商務專業", | |
slide_count=5, image_style="professional"): | |
"""建立包含圖片的簡報""" | |
# 生成內容結構 | |
structure = self.generate_content_with_gemini(topic, slide_count) | |
theme = self.theme_manager.get_theme(theme_name) | |
# 建立 16:9 簡報 | |
prs = Presentation() | |
prs.slide_width = self.slide_width | |
prs.slide_height = self.slide_height | |
# 建立標題頁 | |
title_slide = prs.slides.add_slide(prs.slide_layouts[0]) | |
title_shape = title_slide.shapes.title | |
subtitle_shape = title_slide.placeholders[1] | |
title_shape.text = structure["title"] | |
subtitle_shape.text = structure["subtitle"] | |
# 調整標題頁版面 (16:9) | |
title_shape.left = Inches(1.0) | |
title_shape.top = Inches(2.0) | |
title_shape.width = Inches(11.333) | |
title_shape.height = Inches(1.5) | |
subtitle_shape.left = Inches(1.0) | |
subtitle_shape.top = Inches(4.0) | |
subtitle_shape.width = Inches(11.333) | |
subtitle_shape.height = Inches(1.0) | |
# 格式化標題頁 - 加強文字可讀性 | |
self.format_title_with_shadow(title_shape, theme, 54) | |
self.format_title_with_shadow(subtitle_shape, theme, 32) | |
# 為標題頁添加主題相關圖片 - 使用AI生成的英文關鍵字 | |
main_keywords = structure.get("title_keywords", f"{topic} introduction overview") | |
title_photos = self.search_pexels_with_style(main_keywords, image_style, per_page=15) | |
if title_photos: | |
title_image_url = self.select_best_image(title_photos, structure["title"]) | |
if title_image_url: | |
title_image_path = self.download_image(title_image_url) | |
if title_image_path: | |
# 標題頁使用半透明背景 | |
self.add_title_background_with_overlay(title_slide, title_image_path, theme) | |
# 建立內容頁 | |
for i, slide_data in enumerate(structure["slides"]): | |
slide = prs.slides.add_slide(prs.slide_layouts[1]) | |
# 設定內容和版面 | |
self.setup_slide_content(slide, slide_data, theme) | |
# 搜尋並添加圖片 | |
keywords = slide_data.get("image_keywords", f"{topic} slide {i+1}") | |
photos = self.search_pexels_with_style(keywords, image_style) | |
if photos: | |
image_url = self.select_best_image(photos, slide_data["title"]) | |
if image_url: | |
image_path = self.download_image(image_url) | |
if image_path: | |
self.add_image_to_slide(slide, image_path, theme) | |
# 建立感謝頁 | |
self.add_thank_you_slide(prs, theme, image_style, topic, structure) | |
return prs, structure | |
def search_pexels_image_for_title(self, keywords, topic, per_page=10): | |
"""專門為標題頁搜尋圖片,優先考慮主題相關性""" | |
if not self.pexels_headers: | |
return None | |
# 先嘗試純主題搜尋 | |
topic_keywords = f"{topic} background" | |
url = "https://api.pexels.com/v1/search" | |
params = { | |
"query": topic_keywords, | |
"per_page": per_page, | |
"orientation": "landscape", | |
"size": "medium" | |
} | |
try: | |
response = requests.get(url, headers=self.pexels_headers, params=params) | |
if response.status_code == 200: | |
data = response.json() | |
if data["photos"]: | |
return data["photos"] | |
# 如果主題搜尋沒結果,使用通用關鍵字 | |
fallback_params = { | |
"query": "professional presentation background", | |
"per_page": per_page, | |
"orientation": "landscape", | |
"size": "medium" | |
} | |
response = requests.get(url, headers=self.pexels_headers, params=fallback_params) | |
if response.status_code == 200: | |
data = response.json() | |
return data["photos"] if data["photos"] else None | |
return None | |
except Exception as e: | |
print(f"Pexels API 錯誤: {e}") | |
return None | |
def add_title_background_with_overlay(self, slide, image_path, theme): | |
"""為標題頁添加帶有文字背景框的背景圖片""" | |
try: | |
# 添加背景圖片 | |
picture = slide.shapes.add_picture( | |
image_path, | |
Inches(0), | |
Inches(0), | |
self.slide_width, | |
self.slide_height | |
) | |
# 移到背景層 | |
picture.element.getparent().remove(picture.element) | |
slide.shapes._spTree.insert(2, picture.element) | |
except Exception as e: | |
print(f"添加標題背景錯誤: {e}") | |
# 降級處理:直接添加背景圖片 | |
try: | |
picture = slide.shapes.add_picture( | |
image_path, | |
Inches(0), | |
Inches(0), | |
self.slide_width, | |
self.slide_height | |
) | |
picture.element.getparent().remove(picture.element) | |
slide.shapes._spTree.insert(2, picture.element) | |
except: | |
pass | |
def add_title_background(self, slide, image_path): | |
"""為標題頁添加背景圖片(保留原方法以免錯誤)""" | |
self.add_title_background_with_overlay(slide, image_path, None) | |
def add_thank_you_slide(self, prs, theme, image_style, topic, structure): | |
"""添加感謝頁""" | |
thank_slide = prs.slides.add_slide(prs.slide_layouts[5]) | |
# 感謝頁使用主題關鍵字加上結尾相關詞彙 | |
title_keywords = structure.get("title_keywords", f"{topic} success conclusion achievement") | |
thank_keywords = f"{title_keywords} success conclusion achievement" | |
thank_photos = self.search_pexels_with_style(thank_keywords, image_style, per_page=12) | |
if thank_photos: | |
thank_image_url = self.select_best_image(thank_photos) | |
if thank_image_url: | |
thank_image_path = self.download_image(thank_image_url) | |
if thank_image_path: | |
self.add_title_background_with_overlay(thank_slide, thank_image_path, theme) | |
# 添加感謝文字背景框 | |
text_bg = thank_slide.shapes.add_shape( | |
1, # 矩形 | |
Inches(2.5), | |
Inches(2.0), | |
Inches(8.333), | |
Inches(3.5) | |
) | |
fill = text_bg.fill | |
fill.solid() | |
fill.fore_color.rgb = RGBColor(255, 255, 255) # 白色背景 | |
text_bg.line.color.rgb = theme["accent_color"] if theme else RGBColor(79, 129, 189) | |
text_bg.line.width = Pt(3) | |
# 添加感謝文字 (16:9 居中位置) | |
left = Inches(3.0) | |
top = Inches(2.5) | |
width = Inches(7.333) | |
height = Inches(2.5) | |
textbox = thank_slide.shapes.add_textbox(left, top, width, height) | |
text_frame = textbox.text_frame | |
text_frame.text = "謝謝聆聽\nThank You" | |
for paragraph in text_frame.paragraphs: | |
paragraph.font.name = self.get_font_name() | |
paragraph.font.size = Pt(60) | |
paragraph.font.color.rgb = theme["title_color"] if theme else RGBColor(31, 73, 125) | |
paragraph.alignment = PP_ALIGN.CENTER | |
paragraph.font.bold = True | |
def save_presentation(self, prs, filename): | |
"""儲存簡報""" | |
temp_dir = tempfile.mkdtemp() | |
filepath = os.path.join(temp_dir, filename) | |
prs.save(filepath) | |
return filepath | |
def generate_preview_text(self, structure): | |
"""生成簡報預覽文字""" | |
preview = f"📊 {structure['title']}\n" | |
preview += f" {structure['subtitle']}\n\n" | |
for i, slide in enumerate(structure['slides'], 1): | |
preview += f"{i}. {slide['title']}\n" | |
for point in slide['content'][:2]: # 只顯示前兩個重點 | |
preview += f" • {point}\n" | |
if len(slide['content']) > 2: | |
preview += f" • ...(共 {len(slide['content'])} 個重點)\n" | |
preview += "\n" | |
return preview | |
def analyze_and_restyle_ppt(gemini_api_key, pexels_api_key, uploaded_file, theme_name, image_style): | |
"""分析並重新設計上傳的簡報""" | |
if not uploaded_file: | |
return None, "", "❌ 請上傳PPT文件" | |
generator = GeminiPPTGenerator() | |
# 如果API金鑰為空,嘗試從已保存的配置載入 | |
if not gemini_api_key.strip() or not pexels_api_key.strip(): | |
saved_gemini, saved_pexels = generator.get_saved_keys() | |
if not gemini_api_key.strip(): | |
gemini_api_key = saved_gemini | |
if not pexels_api_key.strip(): | |
pexels_api_key = saved_pexels | |
# 檢查輸入 | |
if not gemini_api_key.strip(): | |
return None, "", "❌ 請輸入 Gemini API 金鑰" | |
if not pexels_api_key.strip(): | |
return None, "", "❌ 請輸入 Pexels API 金鑰" | |
try: | |
# 設定 API | |
success, message = generator.setup_apis(gemini_api_key, pexels_api_key) | |
if not success: | |
return None, "", message | |
# 創建分析器 | |
analyzer = PPTAnalyzer( | |
gemini_model=generator.gemini_model, | |
pexels_headers=generator.pexels_headers, | |
image_styles=generator.image_styles | |
) | |
# 分析上傳的PPT | |
analysis_result = analyzer.analyze_ppt_file(uploaded_file.name) | |
if not analysis_result: | |
return None, "", "❌ 無法分析PPT文件,請確認文件格式正確" | |
# 套用新主題和添加圖片 | |
processed_prs, processed_slides = analyzer.apply_theme_to_presentation( | |
uploaded_file.name, theme_name, image_style, analysis_result | |
) | |
if not processed_prs: | |
return None, "", "❌ 處理PPT文件時發生錯誤" | |
# 生成分析報告 | |
report = analyzer.generate_analysis_report(analysis_result, processed_slides) | |
# 儲存處理後的簡報 | |
original_name = os.path.splitext(os.path.basename(uploaded_file.name))[0] | |
filename = f"{original_name}_{theme_name}_{image_style}_restyled.pptx" | |
output_path = analyzer.save_processed_presentation(processed_prs, filename) | |
if not output_path: | |
return None, "", "❌ 儲存處理後的簡報時發生錯誤" | |
success_msg = f"✅ 成功重新設計《{original_name}》!\n" | |
success_msg += f"🎨 套用主題:{theme_name}\n" | |
success_msg += f"🖼️ 圖片風格:{image_style}\n" | |
success_msg += f"📄 處理了 {len(processed_slides)} 張投影片" | |
return output_path, report, success_msg | |
except Exception as e: | |
import traceback | |
error_details = traceback.format_exc() | |
print(f"詳細錯誤: {error_details}") | |
return None, "", f"❌ 處理失敗:{str(e)}" | |
def generate_ppt_with_gemini(gemini_api_key, pexels_api_key, topic, theme, slide_count, image_style): | |
"""生成簡報的主要函數""" | |
generator = GeminiPPTGenerator() | |
# 如果API金鑰為空,嘗試從已保存的配置載入 | |
if not gemini_api_key.strip() or not pexels_api_key.strip(): | |
saved_gemini, saved_pexels = generator.get_saved_keys() | |
if not gemini_api_key.strip(): | |
gemini_api_key = saved_gemini | |
if not pexels_api_key.strip(): | |
pexels_api_key = saved_pexels | |
# 檢查輸入 | |
if not gemini_api_key.strip(): | |
return None, "", "❌ 請輸入 Gemini API 金鑰" | |
if not pexels_api_key.strip(): | |
return None, "", "❌ 請輸入 Pexels API 金鑰" | |
if not topic.strip(): | |
return None, "", "❌ 請輸入簡報主題" | |
try: | |
# 設定 API | |
success, message = generator.setup_apis(gemini_api_key, pexels_api_key) | |
if not success: | |
return None, "", message | |
# 保存API金鑰到配置檔案 | |
generator.save_config(gemini_api_key, pexels_api_key) | |
# 生成簡報 | |
prs, structure = generator.create_presentation_with_images( | |
topic, theme, slide_count, image_style | |
) | |
# 生成預覽 | |
preview = generator.generate_preview_text(structure) | |
# 儲存檔案 | |
filename = f"{topic.replace(' ', '_')}_{image_style}_簡報.pptx" | |
filepath = generator.save_presentation(prs, filename) | |
success_msg = f"✅ 成功生成《{topic}》{image_style}風格簡報!({slide_count} 頁,含圖片)" | |
return filepath, preview, success_msg | |
except Exception as e: | |
import traceback | |
error_details = traceback.format_exc() | |
print(f"詳細錯誤: {error_details}") | |
return None, "", f"❌ 生成失敗:{str(e)}" | |
# Gradio 介面 | |
def create_gemini_interface(): | |
"""建立 Gradio 介面""" | |
# 檢查是否已有保存的API金鑰 | |
generator = GeminiPPTGenerator() | |
saved_gemini, saved_pexels = generator.get_saved_keys() | |
keys_exist = bool(saved_gemini and saved_pexels) | |
with gr.Blocks(title="Gemini AI 圖文簡報生成器", theme=gr.themes.Soft()) as iface: | |
gr.Markdown("# 🤖 Gemini AI 智能圖文簡報生成器") | |
gr.Markdown("**使用 Google Gemini 2.0 + Pexels 圖庫**,智能生成專業圖文簡報,或改造現有簡報") | |
# API 設定區域(共用) | |
if keys_exist: | |
with gr.Group(): | |
gr.Markdown("### ✅ API 金鑰已配置") | |
gr.Markdown("API 金鑰已從 config.json 載入,可直接使用。如需更新金鑰,請刪除 config.json 檔案後重新啟動。") | |
# 隱藏的輸入框,用於傳遞已保存的金鑰 | |
gemini_api_input = gr.Textbox(value=saved_gemini, visible=False) | |
pexels_api_input = gr.Textbox(value=saved_pexels, visible=False) | |
else: | |
with gr.Group(): | |
gr.Markdown("### 🔑 API 設定") | |
with gr.Row(): | |
gemini_api_input = gr.Textbox( | |
label="🤖 Gemini API Key", | |
placeholder="請輸入你的 Gemini API 金鑰", | |
type="password", | |
info="免費額度,前往 https://ai.google.dev/ 獲取" | |
) | |
pexels_api_input = gr.Textbox( | |
label="📸 Pexels API Key", | |
placeholder="請輸入你的 Pexels API 金鑰", | |
type="password", | |
info="免費 200次/月,前往 https://www.pexels.com/api/ 獲取" | |
) | |
# 選項卡 | |
with gr.Tabs(): | |
# 原有的生成功能 | |
with gr.TabItem("🆕 創建新簡報"): | |
# 主要設定區域 | |
with gr.Row(): | |
with gr.Column(scale=2): | |
topic_input = gr.Textbox( | |
label="📝 簡報主題", | |
placeholder="請輸入具體的簡報主題...", | |
value="人工智慧在現代教育中的應用與挑戰" | |
) | |
with gr.Row(): | |
# 從主題管理器獲取所有主題名稱 | |
generator = GeminiPPTGenerator() | |
theme_dropdown = gr.Dropdown( | |
choices=generator.theme_manager.get_all_theme_names(), | |
value="商務專業", | |
label="🎨 版型風格" | |
) | |
image_style_dropdown = gr.Dropdown( | |
choices=["professional", "creative", "minimalist", "modern", "natural", "technology"], | |
value="professional", | |
label="🖼️ 圖片風格" | |
) | |
slide_count = gr.Slider( | |
minimum=3, | |
maximum=20, | |
value=6, | |
step=1, | |
label="📄 投影片數量" | |
) | |
generate_btn = gr.Button("🚀 生成專業簡報", variant="primary", size="lg") | |
with gr.Column(scale=1): | |
status_output = gr.Textbox(label="📊 生成狀態", interactive=False) | |
file_output = gr.File(label="📁 下載簡報") | |
# 預覽區域 | |
with gr.Group(): | |
gr.Markdown("### 📋 簡報預覽") | |
preview_output = gr.Textbox( | |
label="內容大綱", | |
placeholder="生成後將顯示簡報大綱...", | |
lines=8, | |
interactive=False | |
) | |
# 新增的簡報改造功能 | |
with gr.TabItem("🔄 改造現有簡報"): | |
gr.Markdown("### 📤 上傳並改造您的簡報") | |
gr.Markdown("上傳現有的PPT文件,AI將分析內容並套用新的版型設計,自動為每頁添加相關圖片") | |
with gr.Row(): | |
with gr.Column(scale=2): | |
# 文件上傳 | |
upload_file = gr.File( | |
label="📎 上傳PPT文件", | |
file_types=[".pptx", ".ppt"], | |
type="filepath" | |
) | |
with gr.Row(): | |
# 主題選擇 | |
upload_theme_dropdown = gr.Dropdown( | |
choices=generator.theme_manager.get_all_theme_names(), | |
value="商務專業", | |
label="🎨 套用版型風格" | |
) | |
upload_image_style_dropdown = gr.Dropdown( | |
choices=["professional", "creative", "minimalist", "modern", "natural", "technology"], | |
value="professional", | |
label="🖼️ 圖片風格" | |
) | |
analyze_btn = gr.Button("🔍 分析並改造簡報", variant="primary", size="lg") | |
with gr.Column(scale=1): | |
upload_status_output = gr.Textbox(label="📊 處理狀態", interactive=False) | |
upload_file_output = gr.File(label="📁 下載改造後簡報") | |
# 分析報告區域 | |
with gr.Group(): | |
gr.Markdown("### 📋 分析報告") | |
analysis_report = gr.Textbox( | |
label="處理詳情", | |
placeholder="上傳並處理後將顯示分析報告...", | |
lines=8, | |
interactive=False | |
) | |
# 說明區域 | |
with gr.Accordion("📖 使用說明與功能特色", open=False): | |
gr.Markdown(""" | |
### 🌟 核心特色 | |
#### 🤖 Google Gemini 2.0 Flash | |
- **最新模型**:使用 Gemini 2.0 Flash Preview 版本 | |
- **免費額度**:Google 提供慷慨的免費使用額度 | |
- **中文優化**:對繁體中文有優秀的理解和生成能力 | |
- **結構化輸出**:精確生成 JSON 格式的簡報結構 | |
- **內容分析**:智能分析現有簡報內容,生成適合的圖片搜尋關鍵字 | |
#### 📸 Pexels 圖片整合 | |
- **百萬圖庫**:Pexels 提供高品質免費圖片 | |
- **智能匹配**:AI 為每頁生成最適合的搜尋關鍵字 | |
- **風格選擇**:6 種圖片風格滿足不同需求 | |
- **自動配圖**:每張投影片自動配上相關圖片 | |
- **智能避重**:首頁和結尾使用不同關鍵字避免重複圖片 | |
#### 🎨 專業版面設計 | |
- **8 種版型**:商務、科技、創意、學術、簡約、橙色、紫色、藍綠風格 | |
- **智能排版**:根據版型自動調整圖文位置 | |
- **色彩搭配**:專業的色彩主題設計,高對比度確保文字清晰 | |
- **中文字型**:完美支援繁體中文顯示 | |
- **背景漸變**:精美的漸變背景和裝飾元素 | |
#### 🔄 簡報改造功能 | |
- **檔案分析**:智能分析上傳的PPT文件結構和內容 | |
- **表格檢測**:自動識別包含表格的投影片,只套用配色不添加圖片 | |
- **版型套用**:將現有簡報套用全新的專業版型設計 | |
- **AI配圖**:為每頁內容生成專屬的圖片搜尋關鍵字並自動配圖 | |
- **空間計算**:智能計算可用空間,合理放置圖片避免覆蓋原有內容 | |
### 📋 使用步驟 | |
#### 🆕 創建新簡報 | |
1. **獲取 API 金鑰**: | |
- Gemini API:前往 [Google AI Studio](https://ai.google.dev/) 免費申請 | |
- Pexels API:前往 [Pexels API](https://www.pexels.com/api/) 免費申請(200次/日) | |
2. **輸入 API 金鑰**:在上方輸入框中填入你的 API 金鑰(僅需輸入一次,會自動保存到 config.json) | |
3. **設定簡報參數**: | |
- 輸入具體明確的簡報主題 | |
- 選擇適合的版型和圖片風格 | |
- 設定所需的投影片數量 | |
4. **生成簡報**:點擊生成按鈕,系統將自動完成所有工作 | |
5. **下載使用**:獲得完整的 .pptx 檔案,可直接在 PowerPoint 中使用 | |
#### 🔄 改造現有簡報 | |
1. **上傳PPT文件**:支援 .pptx 和 .ppt 格式 | |
2. **選擇版型風格**:從8種專業版型中選擇適合的風格 | |
3. **選擇圖片風格**:選擇與內容匹配的圖片風格 | |
4. **開始分析改造**:AI將自動分析每頁內容並套用新設計 | |
5. **查看分析報告**:了解每頁的處理詳情和圖片添加情況 | |
6. **下載改造後簡報**:獲得全新設計的簡報文件 | |
### 💡 專業建議 | |
- **主題要具體**:「AI在醫療診斷的應用」比「人工智慧」效果更好 | |
- **選對風格**:商務場合用「professional」,創意展示用「creative」 | |
- **適當頁數**:建議 5-8 頁,內容豐富但不冗長 | |
- **測試 API**:第一次使用建議先測試 API 連接是否正常 | |
### 🔧 技術特點 | |
- **純 Python 實現**:不需要安裝 Microsoft Office | |
- **即時生成**:通常 30-60 秒完成整個簡報 | |
- **高品質輸出**:生成的 .pptx 檔案完全相容 PowerPoint | |
- **跨平台支援**:Windows、macOS、Linux 都能正常使用 | |
""") | |
# 事件綁定 | |
generate_btn.click( | |
fn=generate_ppt_with_gemini, | |
inputs=[ | |
gemini_api_input, | |
pexels_api_input, | |
topic_input, | |
theme_dropdown, | |
slide_count, | |
image_style_dropdown | |
], | |
outputs=[file_output, preview_output, status_output] | |
) | |
analyze_btn.click( | |
fn=analyze_and_restyle_ppt, | |
inputs=[ | |
gemini_api_input, | |
pexels_api_input, | |
upload_file, | |
upload_theme_dropdown, | |
upload_image_style_dropdown | |
], | |
outputs=[upload_file_output, analysis_report, upload_status_output] | |
) | |
return iface | |
if __name__ == "__main__": | |
# 啟動應用 | |
iface = create_gemini_interface() | |
iface.launch( | |
server_name="127.0.0.1", | |
server_port=7860, | |
share=False, | |
inbrowser=True | |
) |