Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions ppt_tool/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""
PPT Secretary - A PowerPoint editing tool powered by Google Gemini AI.

This package provides utilities for creating, inspecting, and modifying
PowerPoint presentations through natural language instructions.
"""

__version__ = "0.1.0"
16 changes: 8 additions & 8 deletions ppt_tool/converter.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import os
import sys
import subprocess
import platform
from pathlib import Path


class PPTConverter:
def __init__(self):
self.engine = self._detect_engine()
Expand Down Expand Up @@ -37,14 +37,14 @@ def _detect_engine(self):
return "libreoffice"
except (FileNotFoundError, subprocess.CalledProcessError):
continue

return "none"

def convert_to_pdf(self, ppt_path: str, output_dir: str) -> str:
"""將 PPT 轉換為 PDF,回傳 PDF 路徑"""
ppt_path = str(Path(ppt_path).resolve())
output_dir = str(Path(output_dir).resolve())

if not os.path.exists(output_dir):
os.makedirs(output_dir)

Expand All @@ -66,13 +66,13 @@ def _convert_with_com(self, ppt_path, pdf_path):
try:
ppt_app = win32com.client.Dispatch("PowerPoint.Application")
pres = ppt_app.Presentations.Open(ppt_path, WithWindow=False)

# 檢查是否有投影片,空簡報無法轉 PDF
if pres.Slides.Count == 0:
print("[WARN] Presentation has no slides, skipping PDF conversion.")
return None
pres.SaveAs(pdf_path, 32) # 32 = ppSaveAsPDF

pres.SaveAs(pdf_path, 32) # 32 = ppSaveAsPDF
return pdf_path
except Exception as e:
print(f"[ERROR] COM Conversion Error: {e}")
Expand All @@ -81,7 +81,7 @@ def _convert_with_com(self, ppt_path, pdf_path):
if pres:
pres.Close()
# 不關閉 Application,以免影響使用者正在開啟的其他 PPT
# if ppt_app: ppt_app.Quit()
# if ppt_app: ppt_app.Quit()

def _convert_with_libreoffice(self, ppt_path, output_dir):
try:
Expand All @@ -94,7 +94,7 @@ def _convert_with_libreoffice(self, ppt_path, output_dir):
"--outdir", output_dir
]
subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)

filename = Path(ppt_path).stem
expected_pdf = os.path.join(output_dir, f"{filename}.pdf")
if os.path.exists(expected_pdf):
Expand Down
27 changes: 14 additions & 13 deletions ppt_tool/inspector.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import os
from pptx import Presentation
from pptx.enum.shapes import MSO_SHAPE_TYPE


class PPTInspector:
def __init__(self, converter):
Expand Down Expand Up @@ -28,32 +28,33 @@ def _get_text_summary(self, ppt_path):
summary = []
summary.append(f"Presentation: {os.path.basename(ppt_path)}")
summary.append(f"Total Slides: {len(prs.slides)}")

for i, slide in enumerate(prs.slides):
slide_info = [f"\n--- Slide {i+1} ---"]

# Layout
slide_info.append(f"Layout: {slide.slide_layout.name}")

# Title
if slide.shapes.title and slide.shapes.title.text:
slide_info.append(f"Title: '{slide.shapes.title.text}'")

# Elements
elements = []
for shape in slide.shapes:
# Skip title as it's already handled
if shape == slide.shapes.title:
continue

shape_desc = f"- Type: {shape.shape_type}"

# Text content
if shape.has_text_frame and shape.text.strip():
text = shape.text.replace('\n', ' ')
if len(text) > 50: text = text[:50] + "..."
if len(text) > 50:
text = text[:50] + "..."
shape_desc += f", Text: '{text}'"

# Geometry
try:
left_pt = int(float(shape.left.pt))
Expand All @@ -63,7 +64,7 @@ def _get_text_summary(self, ppt_path):
shape_desc += f", Pos: ({left_pt}, {top_pt}), Size: {width_pt}x{height_pt}"
except Exception:
shape_desc += ", Pos/Size: (unreadable)"

# Color (Simplified) - 安全地讀取顏色
try:
if hasattr(shape, 'fill'):
Expand All @@ -77,13 +78,13 @@ def _get_text_summary(self, ppt_path):
pass

elements.append(shape_desc)

if elements:
slide_info.append("Elements:")
slide_info.extend(elements)
else:
slide_info.append("Elements: (None)")

summary.append("\n".join(slide_info))

return "\n".join(summary)
25 changes: 13 additions & 12 deletions ppt_tool/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,23 +37,23 @@ def main():
project_root = Path(__file__).parent.parent
env_path = project_root / ".env"
load_dotenv(dotenv_path=env_path)

print("[INFO] PPT Secretary (Gemini Powered) Started")
print("-----------------------------------------")
if debug_mode:
print("[INFO] Debug mode ON: Generated code will be printed before execution.")

# 初始化模組
converter = PPTConverter()
inspector = PPTInspector(converter)
modifier = PPTModifier()

# 預設檔案名稱
ppt_path = Path(args.ppt).expanduser()
if not ppt_path.is_absolute():
ppt_path = (Path.cwd() / ppt_path).resolve()
current_ppt = str(ppt_path)

print(f"[INFO] Target File: {current_ppt}")
if not ppt_path.exists():
print("[WARN] File does not exist. It will be created upon first instruction.")
Expand All @@ -63,14 +63,14 @@ def main():
user_input = input("\n[USER]: ").strip()
except EOFError:
break

if not user_input:
continue

if user_input.lower() in ['exit', 'quit']:
print("Bye! [EXIT]")
break

# 1. 如果檔案不存在,且指令是建立,則先建立空檔案
if not ppt_path.exists():
from pptx import Presentation
Expand All @@ -83,16 +83,16 @@ def main():
print("[INFO] Inspecting presentation...")
text_summary, pdf_path = inspector.inspect(current_ppt)
print("[INFO] Inspection finished.")

# 3. Modifier (大腦 + 手)
success, message = modifier.generate_and_execute(
user_input,
text_summary,
pdf_path,
user_input,
text_summary,
pdf_path,
current_ppt,
debug=debug_mode
)

if success:
print(f"[SUCCESS] {message}")
# 進行視覺驗證
Expand All @@ -115,5 +115,6 @@ def main():
else:
print(f"[ERROR] {message}")


if __name__ == "__main__":
main()
36 changes: 19 additions & 17 deletions ppt_tool/modifier.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import os
import re
import traceback
import google.generativeai as genai
from pptx import Presentation
from pathlib import Path
from ppt_tool.converter import PPTConverter

Expand All @@ -10,19 +10,20 @@
if "GOOGLE_API_KEY" not in os.environ:
print("[WARN] Warning: GOOGLE_API_KEY not found in environment variables.")


class PPTModifier:
def __init__(self):
try:
genai.configure(api_key=os.environ.get("GOOGLE_API_KEY"))

# 從環境變數讀取模型設定,若無則使用預設值
self.text_model_name = os.environ.get("GEMINI_TEXT_MODEL", "gemini-2.5-flash")
self.vision_model_name = os.environ.get("GEMINI_VISION_MODEL", "gemini-2.5-flash")

# 初始化兩個模型
self.text_model = genai.GenerativeModel(self.text_model_name)
self.vision_model = genai.GenerativeModel(self.vision_model_name)

print(f"[INFO] Text model: {self.text_model_name}")
print(f"[INFO] Vision model: {self.vision_model_name}")
except Exception as e:
Expand Down Expand Up @@ -55,7 +56,7 @@ def generate_and_execute(self, user_instruction, text_summary, pdf_path, ppt_pat
model = self.text_model
model_name = self.text_model_name
print(f"[INFO] Using text model ({model_name})...")

print("[INFO] Building prompt for Gemini...")
# 準備 Prompt
prompt_parts = [
Expand Down Expand Up @@ -226,7 +227,7 @@ def generate_and_execute(self, user_instruction, text_summary, pdf_path, ppt_pat
print("[INFO] Calling Gemini model...")
response = model.generate_content(prompt_parts)
code = self._extract_code(response.text)

print("[INFO] Executing generated code...")
if debug:
print("\n[DEBUG] Generated code:\n" + "="*60)
Expand All @@ -244,46 +245,48 @@ def generate_and_execute(self, user_instruction, text_summary, pdf_path, ppt_pat
]
if not any(marker in code for marker in helper_markers):
return False, "Generated code did not use required helper APIs; please retry."

# 檢查檔案是否被鎖定(例如在 PowerPoint 中開啟)
if self._is_file_locked(ppt_path):
print("[WARN] PowerPoint file is currently open in another application.")
print("[WARN] Please close PowerPoint and press Enter to continue...")
input()

# print(code) # Debug use

# 執行程式碼
# Security Note: This exec() call runs AI-generated code with limited globals.
# While globals are restricted to pptx-related modules, this still poses inherent
# security risks. Only use this tool with trusted inputs and in controlled environments.
# 為了安全,限制 globals,但允許 pptx 相關庫
exec(code, exec_globals)

return True, "Modification applied successfully."

except Exception as e:
# 輸出生成的程式碼供除錯
print("\n[DEBUG] Generated code that caused error:")
print("=" * 60)
print(code)
print("=" * 60)
print("\n[DEBUG] Full traceback:")
import traceback
traceback.print_exc()
return False, f"Error: {e}"

def validate_with_vision(self, user_instruction: str, ppt_path: str):
"""
使用視覺模型檢查修改後的簡報是否符合指令。
回傳 (ok: bool, message: str)
"""
if not self.vision_model:
return False, "Vision model not initialized."

print("[INFO] Validating result with vision model...")
converter = PPTConverter()
pdf_path = converter.convert_to_pdf(ppt_path, output_dir="./temp_visuals")
if not pdf_path or not os.path.exists(pdf_path):
return False, "Validation skipped: PDF conversion failed."

try:
print(f"[INFO] Uploading updated PDF for validation: {pdf_path}")
pdf_file = genai.upload_file(pdf_path)
Expand All @@ -307,12 +310,11 @@ def validate_with_vision(self, user_instruction: str, ppt_path: str):

def _is_file_locked(self, filepath):
"""檢查檔案是否被其他程式鎖定"""
import os
if not os.path.exists(filepath):
return False
try:
# 嘗試以獨占模式開啟檔案
with open(filepath, 'r+b') as f:
with open(filepath, 'r+b'):
pass
return False
except (IOError, PermissionError):
Expand Down
Loading