import asyncio import json import os import subprocess import sys import tempfile import uuid import zipfile from typing import Optional from google import genai from google.genai.types import GenerateContentConfig from services.database import async_session_maker from models.sql.slide import SlideModel from sqlmodel import select from utils.get_env import get_google_api_key_env, get_google_model_env def _get_gemini_client(): key = get_google_api_key_env() if not key: raise ValueError("GOOGLE_API_KEY is required for template codegen") return genai.Client() def _get_model() -> str: model = get_google_model_env() or "gemini-2.0-flash" # Strip "models/" prefix if present if model.startswith("models/"): model = model[len("models/"):] return model def analyze_pptx_template(pptx_path: str) -> dict: """ Extract layout names, placeholder types, fonts, and colors from a PPTX template. """ try: from pptx import Presentation from pptx.util import Pt prs = Presentation(pptx_path) result = { "slide_width_emu": prs.slide_width, "slide_height_emu": prs.slide_height, "slide_layouts": [], "theme_colors": [], "theme_fonts": [], } # Extract slide layouts for layout in prs.slide_layouts: layout_info = { "name": layout.name, "placeholders": [], } for ph in layout.placeholders: ph_info = { "idx": ph.placeholder_format.idx, "type": str(ph.placeholder_format.type), "name": ph.name, } try: ph_info["left_emu"] = ph.left ph_info["top_emu"] = ph.top ph_info["width_emu"] = ph.width ph_info["height_emu"] = ph.height except Exception: pass layout_info["placeholders"].append(ph_info) result["slide_layouts"].append(layout_info) # Extract theme colors try: theme_element = prs.core_properties except Exception: pass # Extract slides if any (template slides) result["existing_slides"] = len(prs.slides) return result except ImportError: raise RuntimeError("python-pptx is required for template analysis. Install with: pip install python-pptx") except Exception as e: raise RuntimeError(f"Failed to analyze PPTX template: {e}") def clear_template_slides(pptx_path: str, output_path: str) -> None: """ Strip all slides from PPTX zip while preserving masters, layouts, and theme. """ import shutil shutil.copy2(pptx_path, output_path) with zipfile.ZipFile(output_path, 'r') as zin: names = zin.namelist() # Identify slide files to remove slide_files = [n for n in names if n.startswith("ppt/slides/slide") and n.endswith(".xml")] slide_rels = [n for n in names if n.startswith("ppt/slides/_rels/slide") and n.endswith(".rels")] to_remove = set(slide_files + slide_rels) # Rewrite zip without slide files tmp_path = output_path + ".tmp" with zipfile.ZipFile(output_path, 'r') as zin: with zipfile.ZipFile(tmp_path, 'w', zipfile.ZIP_DEFLATED) as zout: for item in zin.infolist(): if item.filename in to_remove: continue # Fix [Content_Types].xml to remove slide references if item.filename == "[Content_Types].xml": content = zin.read(item.filename).decode("utf-8") for sf in slide_files: # Remove Override for each slide part_name = "/" + sf import re content = re.sub( r'', '', content ) # Remove from ppt/_rels/presentation.xml.rels zout.writestr(item, content.encode("utf-8")) elif item.filename == "ppt/_rels/presentation.xml.rels": content = zin.read(item.filename).decode("utf-8") import re # Remove Relationship entries pointing to slides/slide*.xml content = re.sub( r']*Target="slides/slide\d+\.xml"[^/]*/?>', '', content ) zout.writestr(item, content.encode("utf-8")) elif item.filename == "ppt/presentation.xml": content = zin.read(item.filename).decode("utf-8") import re # Remove entries content = re.sub(r'', '', content) zout.writestr(item, content.encode("utf-8")) else: zout.writestr(item, zin.read(item.filename)) import shutil shutil.move(tmp_path, output_path) def _build_slide_context(slides: list) -> str: """Build a text summary of slide content for the LLM.""" lines = [] for i, slide in enumerate(slides): lines.append(f"\n## Slide {i + 1}") content = slide.content or {} for key, val in content.items(): if key.startswith("__") or not val: continue if isinstance(val, list): lines.append(f" {key}:") for item in val[:6]: if isinstance(item, dict): text = item.get("text") or item.get("title") or item.get("description") or str(item) lines.append(f" - {text[:200]}") else: lines.append(f" - {str(item)[:200]}") elif isinstance(val, dict): text = val.get("text") or val.get("title") or str(val) lines.append(f" {key}: {str(text)[:300]}") else: lines.append(f" {key}: {str(val)[:300]}") return "\n".join(lines) def _build_codegen_prompt(template_info: dict, slide_context: str, custom_prompt: str = "") -> str: layout_summary = json.dumps(template_info.get("slide_layouts", [])[:8], indent=2) return f"""You are a Python expert specializing in python-pptx. Generate Python code that populates a PowerPoint template with the provided content. ## Template Information - Slide layouts available: {len(template_info.get('slide_layouts', []))} - Layout details: {layout_summary} ## Presentation Content {slide_context} ## Requirements 1. Use python-pptx to open the template file at `template_path` variable (already defined) 2. Save the output to `output_path` variable (already defined) 3. Add one slide per content slide shown above 4. Match layout names from the template to content type (title slide, content slide, etc.) 5. Set text for all placeholders appropriately 6. Handle font sizes and colors from template — do NOT override them unless necessary 7. If a layout has no matching content, use the first available layout 8. Keep bullet points as separate text runs {f"## Additional Instructions{chr(10)}{custom_prompt}" if custom_prompt else ""} ## Output Format Output ONLY valid Python code. No explanations, no markdown code blocks. The code should start with `from pptx import Presentation` and end with `prs.save(output_path)`. Variables already defined before your code runs: - `template_path: str` — path to the cleared template PPTX - `output_path: str` — path where the result should be saved """ async def generate_pptx_from_template( template_path: str, presentation_id: str, custom_prompt: str = "", output_path: Optional[str] = None, ) -> dict: """ Full pipeline: analyze template → load slides → LLM code-gen → execute → return path. """ if output_path is None: tmp_dir = os.environ.get("TEMP_DIRECTORY", tempfile.gettempdir()) output_path = os.path.join(tmp_dir, f"codegen_{uuid.uuid4().hex}.pptx") # 1. Analyze template template_info = analyze_pptx_template(template_path) # 2. Clear slides from template copy cleared_path = template_path + ".cleared.pptx" try: clear_template_slides(template_path, cleared_path) except Exception as e: # If clearing fails, use the original template import shutil shutil.copy2(template_path, cleared_path) # 3. Load slides from DB try: async with async_session_maker() as session: result = await session.execute( select(SlideModel) .where(SlideModel.presentation == uuid.UUID(presentation_id)) .where(SlideModel.deleted_at == None) .order_by(SlideModel.index) ) slides = result.scalars().all() except Exception as e: raise RuntimeError(f"Failed to load slides from database: {e}") if not slides: raise ValueError("Presentation has no slides") slide_context = _build_slide_context(list(slides)) prompt = _build_codegen_prompt(template_info, slide_context, custom_prompt) # 4. Call LLM client = _get_gemini_client() model = _get_model() try: response = client.models.generate_content( model=model, contents=prompt, config=GenerateContentConfig( max_output_tokens=8192, temperature=0.1, ), ) code = response.text or "" except Exception as e: raise RuntimeError(f"LLM code generation failed: {e}") # Clean up code block markers if present code = code.strip() if code.startswith("```python"): code = code[9:] if code.startswith("```"): code = code[3:] if code.endswith("```"): code = code[:-3] code = code.strip() # 5. Execute generated code exec_result = _execute_generated_code(code, cleared_path, output_path) if not exec_result["success"]: # Retry once with error feedback retry_prompt = prompt + f"\n\n## Error from previous attempt\nYour code failed with this error:\n{exec_result['error']}\nFix the issue and output corrected code only." try: response2 = client.models.generate_content( model=model, contents=retry_prompt, config=GenerateContentConfig( max_output_tokens=8192, temperature=0.1, ), ) code2 = response2.text or "" code2 = code2.strip() if code2.startswith("```python"): code2 = code2[9:] if code2.startswith("```"): code2 = code2[3:] if code2.endswith("```"): code2 = code2[:-3] code2 = code2.strip() except Exception: raise RuntimeError(f"Code generation failed: {exec_result['error']}") exec_result2 = _execute_generated_code(code2, cleared_path, output_path) if not exec_result2["success"]: raise RuntimeError(f"Code execution failed after retry: {exec_result2['error']}") # Cleanup temp try: os.unlink(cleared_path) except Exception: pass return {"output_path": output_path, "slide_count": len(slides)} def _execute_generated_code(code: str, template_path: str, output_path: str) -> dict: """Execute the LLM-generated python-pptx code in a subprocess.""" # Wrap code with variable definitions full_code = f""" template_path = {repr(template_path)} output_path = {repr(output_path)} {code} """ try: result = subprocess.run( [sys.executable, "-c", full_code], capture_output=True, text=True, timeout=60, ) if result.returncode != 0: return {"success": False, "error": result.stderr[:2000]} if not os.path.exists(output_path): return {"success": False, "error": "Code ran but did not produce output file"} return {"success": True, "error": None} except subprocess.TimeoutExpired: return {"success": False, "error": "Code execution timed out (60s)"} except Exception as e: return {"success": False, "error": str(e)}