From c8496160d316770f47f50d4d693d7e2c4885a824 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Dec 2025 02:36:56 +0000 Subject: [PATCH 1/2] Initial plan From 362ec1d2396fadbeac9fdf83cc76a500f7cc89a1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Dec 2025 02:45:43 +0000 Subject: [PATCH 2/2] Code review improvements: Add __init__.py, fix imports, type hints, trailing whitespace, and syntax errors Co-authored-by: stoday <5661087+stoday@users.noreply.github.com> --- ppt_tool/__init__.py | 8 +++++++ ppt_tool/converter.py | 16 +++++++------- ppt_tool/inspector.py | 27 ++++++++++++------------ ppt_tool/main.py | 25 +++++++++++----------- ppt_tool/modifier.py | 36 ++++++++++++++++--------------- ppt_tool/ppt_api.py | 49 +++++++++++++++++++++++++++++-------------- ppt_tool/test.py | 34 ++++++++++++++---------------- 7 files changed, 111 insertions(+), 84 deletions(-) create mode 100644 ppt_tool/__init__.py diff --git a/ppt_tool/__init__.py b/ppt_tool/__init__.py new file mode 100644 index 0000000..a8ea170 --- /dev/null +++ b/ppt_tool/__init__.py @@ -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" diff --git a/ppt_tool/converter.py b/ppt_tool/converter.py index f59ab5c..d2d8773 100644 --- a/ppt_tool/converter.py +++ b/ppt_tool/converter.py @@ -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() @@ -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) @@ -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}") @@ -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: @@ -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): diff --git a/ppt_tool/inspector.py b/ppt_tool/inspector.py index 10a64a0..c6c2e8d 100644 --- a/ppt_tool/inspector.py +++ b/ppt_tool/inspector.py @@ -1,6 +1,6 @@ import os from pptx import Presentation -from pptx.enum.shapes import MSO_SHAPE_TYPE + class PPTInspector: def __init__(self, converter): @@ -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)) @@ -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'): @@ -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) diff --git a/ppt_tool/main.py b/ppt_tool/main.py index 60c84ff..a4eddee 100644 --- a/ppt_tool/main.py +++ b/ppt_tool/main.py @@ -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.") @@ -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 @@ -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}") # 進行視覺驗證 @@ -115,5 +115,6 @@ def main(): else: print(f"[ERROR] {message}") + if __name__ == "__main__": main() diff --git a/ppt_tool/modifier.py b/ppt_tool/modifier.py index ea266cc..4e00b79 100644 --- a/ppt_tool/modifier.py +++ b/ppt_tool/modifier.py @@ -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 @@ -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: @@ -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 = [ @@ -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) @@ -244,21 +245,24 @@ 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:") @@ -266,10 +270,9 @@ def generate_and_execute(self, user_instruction, text_summary, pdf_path, ppt_pat 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): """ 使用視覺模型檢查修改後的簡報是否符合指令。 @@ -277,13 +280,13 @@ def validate_with_vision(self, user_instruction: str, ppt_path: 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) @@ -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): diff --git a/ppt_tool/ppt_api.py b/ppt_tool/ppt_api.py index e16276a..27b8968 100644 --- a/ppt_tool/ppt_api.py +++ b/ppt_tool/ppt_api.py @@ -2,12 +2,16 @@ Utility helpers for common PowerPoint edits using python-pptx. These wrap risky/verbose operations into safe, tested functions to reduce model hallucinations. """ +from typing import List, Tuple, Optional, Any, Union + from pptx import Presentation from pptx.util import Inches, Pt, Emu from pptx.dml.color import RGBColor from pptx.enum.text import PP_ALIGN, MSO_ANCHOR from pptx.enum.shapes import MSO_AUTO_SHAPE_TYPE, MSO_CONNECTOR, MSO_SHAPE_TYPE from pptx.oxml.xmlchemy import OxmlElement +from pptx.shapes.base import BaseShape +from pptx.slide import Slide # -------- Basic getters -------- @@ -16,7 +20,7 @@ def load_presentation(ppt_path: str) -> Presentation: return Presentation(ppt_path) -def get_slide(prs: Presentation, index: int): +def get_slide(prs: Presentation, index: int) -> Slide: """Safe slide access with 0-based index.""" if index < 0 or index >= len(prs.slides): raise IndexError(f"Slide index {index} out of range; total slides: {len(prs.slides)}") @@ -24,7 +28,7 @@ def get_slide(prs: Presentation, index: int): # -------- Shape utilities -------- -def delete_shapes_except(slide, shapes_to_keep): +def delete_shapes_except(slide: Slide, shapes_to_keep: List[Optional[BaseShape]]) -> None: """Delete all shapes except those in shapes_to_keep.""" keep_elements = {s.element for s in shapes_to_keep if s is not None} for shape in list(slide.shapes): @@ -34,7 +38,7 @@ def delete_shapes_except(slide, shapes_to_keep): sp.getparent().remove(sp) -def remove_connectors_and_lines(slide): +def remove_connectors_and_lines(slide: Slide) -> None: """Remove existing connector/line shapes.""" connector_type = getattr(MSO_SHAPE_TYPE, "CONNECTOR", None) for shape in list(slide.shapes): @@ -44,18 +48,18 @@ def remove_connectors_and_lines(slide): def add_rounded_textbox( - slide, + slide: Slide, text: str, - left, - top, - width, - height, - fill_rgb=(232, 244, 248), - text_rgb=(50, 50, 50), - font_size=20, - align=PP_ALIGN.CENTER, - vertical_anchor=MSO_ANCHOR.MIDDLE, -): + left: Union[int, Emu], + top: Union[int, Emu], + width: Union[int, Emu], + height: Union[int, Emu], + fill_rgb: Tuple[int, int, int] = (232, 244, 248), + text_rgb: Tuple[int, int, int] = (50, 50, 50), + font_size: int = 20, + align: Any = PP_ALIGN.CENTER, + vertical_anchor: Any = MSO_ANCHOR.MIDDLE, +) -> BaseShape: """Add a rounded rectangle with text and return the shape.""" shape = slide.shapes.add_shape(MSO_AUTO_SHAPE_TYPE.ROUNDED_RECTANGLE, left, top, width, height) @@ -95,7 +99,14 @@ def add_rounded_textbox( return shape -def add_arrow_between(slide, shape_from, shape_to, color_rgb=(70, 70, 70), width_pt=2.5, arrow_head=2): +def add_arrow_between( + slide: Slide, + shape_from: BaseShape, + shape_to: BaseShape, + color_rgb: Tuple[int, int, int] = (70, 70, 70), + width_pt: float = 2.5, + arrow_head: int = 2, +) -> BaseShape: """Add straight connector arrow between two shapes and return the connector.""" start_x = Emu(shape_from.left + shape_from.width) start_y = Emu(shape_from.top + shape_from.height // 2) @@ -115,7 +126,13 @@ def add_arrow_between(slide, shape_from, shape_to, color_rgb=(70, 70, 70), width # -------- Layout helpers -------- -def distribute_horizontally(slide_width, count, box_width, gap, margin=Inches(0.5)): +def distribute_horizontally( + slide_width: Union[int, Emu], + count: int, + box_width: Union[int, Emu], + gap: Union[int, Emu], + margin: Union[int, Emu] = Inches(0.5), +) -> List[int]: """ Compute left positions to distribute `count` boxes horizontally. Returns list of left positions (Emu). diff --git a/ppt_tool/test.py b/ppt_tool/test.py index c562cf6..757cf90 100644 --- a/ppt_tool/test.py +++ b/ppt_tool/test.py @@ -1,15 +1,13 @@ -from pptx import Presentation from pptx.util import Inches, Pt from pptx.dml.color import RGBColor -from pptx.enum.text import PP_ALIGN -from pptx.enum.shapes import MSO_AUTO_SHAPE_TYPE, MSO_CONNECTOR, MSO_SHAPE_TYPE -from pptx.enum.text import MSO_ANCHOR +from pptx.enum.text import PP_ALIGN, MSO_ANCHOR +from pptx.enum.shapes import MSO_SHAPE_TYPE from ppt_tool.ppt_api import ( - load_presentation, get_slide, delete_shapes_except, remove_connectors_and_lines, - add_rounded_textbox, add_arrow_between, distribute_horizontally + load_presentation, get_slide, add_rounded_textbox ) # Target PowerPoint file path +# NOTE: Update this path to your local presentation file ppt_path = r'C:\Users\today\Dropbox\MainStorage\P2025_PPTOR\presentation.pptx' # Load the presentation @@ -23,12 +21,9 @@ # Define the detailed descriptions for each step detailed_texts = [ - "乾性材料如麵粉、糖、泡打粉等,需要先用篩網過篩,確保沒有結塊,使蛋糕口感更細膩。混合時確保所有粉末均勻分佈,避免在烘烤時出現生粉團 -。", - "濕性材料包括雞蛋、牛奶、植物油或融化的奶油、香草精等。先將雞蛋打散,再陸續加入牛奶、油和香草精,攪拌至乳化狀態。這些液體要充分混合 -,才能更好地與乾性材料結合。", - "將濕性材料分三次左右加入乾性材料中,每次加入後用刮刀以「切拌」或「翻拌」的方式輕柔混合,直到沒有明顯的乾粉即可。避免過度攪拌,以免 -麵粉產生筋性,影響蛋糕的鬆軟度。輕柔攪拌是蛋糕成功的關鍵。" + "乾性材料如麵粉、糖、泡打粉等,需要先用篩網過篩,確保沒有結塊,使蛋糕口感更細膩。混合時確保所有粉末均勻分佈,避免在烘烤時出現生粉團。", + "濕性材料包括雞蛋、牛奶、植物油或融化的奶油、香草精等。先將雞蛋打散,再陸續加入牛奶、油和香草精,攪拌至乳化狀態。這些液體要充分混合,才能更好地與乾性材料結合。", + "將濕性材料分三次左右加入乾性材料中,每次加入後用刮刀以「切拌」或「翻拌」的方式輕柔混合,直到沒有明顯的乾粉即可。避免過度攪拌,以免麵粉產生筋性,影響蛋糕的鬆軟度。輕柔攪拌是蛋糕成功的關鍵。" ] # Find existing flowchart shapes to determine positioning for new description boxes @@ -68,15 +63,15 @@ # Apply specific text formatting as per critical requirements text_frame = new_description_box.text_frame - text_frame.word_wrap = True # Enable word wrap for detailed text - text_frame.vertical_anchor = MSO_ANCHOR.TOP # Align text to the top of the textbox + text_frame.word_wrap = True # Enable word wrap for detailed text + text_frame.vertical_anchor = MSO_ANCHOR.TOP # Align text to the top # Assuming the add_rounded_textbox helper places all text in the first paragraph p = text_frame.paragraphs[0] - p.alignment = PP_ALIGN.LEFT # Left align for detailed text + p.alignment = PP_ALIGN.LEFT # Left align for detailed text p.font.name = 'Microsoft JhengHei' # Set Chinese font - p.font.size = Pt(font_size_pt) # Set font size - p.font.color.rgb = RGBColor(*text_color_rgb) # Set text color + p.font.size = Pt(font_size_pt) # Set font size + p.font.color.rgb = RGBColor(*text_color_rgb) # Set text color # Add subtle shadow for depth, as per critical requirements if hasattr(new_description_box, 'shadow') and new_description_box.shadow is not None: @@ -87,4 +82,7 @@ shadow.distance = Pt(3) shadow.angle = 45 shadow.blur_radius = Pt(4) - shadow.transparency = 0.5 \ No newline at end of file + shadow.transparency = 0.5 + +# Save the modified presentation +prs.save(ppt_path)