#!/usr/bin/env python3 """pptx_preview.py — Convert PPTX slides to PNG images for preview. Usage: python pptx_preview.py Output: PNG images saved to /slide_1.png, slide_2.png, ... Stdout: JSON {"success":true,"images":["slide_1.png",...],"count":N} or {"success":false,"error":"..."} Strategy: 1. PPTX → PDF → PNGs via LibreOffice + PyMuPDF (best quality) 2. Fallback: direct image export via LibreOffice 3. Fallback: python-pptx + Pillow (text/card-based previews, no LibreOffice needed) """ import sys import json import os import subprocess import shutil import time import tempfile # ─── Paths ────────────────────────────────────────────────────────────────────── SOFFICE = r"C:\Program Files\LibreOffice\program\soffice.exe" if not os.path.exists(SOFFICE): for alt in [ r"C:\Program Files (x86)\LibreOffice\program\soffice.exe", "/usr/bin/soffice", "/usr/bin/libreoffice", "/usr/local/bin/soffice", "/usr/local/bin/libreoffice", ]: if os.path.exists(alt): SOFFICE = alt break if not os.path.exists(SOFFICE): import shutil as _shutil _found = _shutil.which("soffice") or _shutil.which("libreoffice") if _found: SOFFICE = _found HAS_LIBREOFFICE = os.path.exists(SOFFICE) def log(msg): """Log diagnostic message to stderr.""" print(f"[preview] {msg}", file=sys.stderr, flush=True) def wait_for_file(filepath, timeout=5, interval=0.2): """Wait until a file exists and is readable.""" start = time.time() while time.time() - start < timeout: if os.path.exists(filepath): try: with open(filepath, 'rb') as f: f.read(1) return True except (IOError, OSError): pass time.sleep(interval) return False def convert_pptx_to_pdf(pptx_path: str, output_dir: str) -> str: """Convert PPTX to PDF using LibreOffice. Returns PDF path.""" # Use a unique profile dir to avoid LibreOffice lock contention profile_dir = os.path.join(tempfile.gettempdir(), f"lo_preview_{os.getpid()}_{int(time.time())}") os.makedirs(profile_dir, exist_ok=True) try: result = subprocess.run( [ SOFFICE, "--headless", "--convert-to", "pdf", "--outdir", output_dir, "-env:UserInstallation=file:///" + profile_dir.replace("\\", "/"), pptx_path, ], capture_output=True, text=True, timeout=60, ) if result.returncode != 0: log(f"LibreOffice stderr: {result.stderr[:500]}") log(f"LibreOffice stdout: {result.stdout[:500]}") raise RuntimeError(f"LibreOffice exited with code {result.returncode}") # LibreOffice names the PDF after the input file base = os.path.splitext(os.path.basename(pptx_path))[0] pdf_path = os.path.join(output_dir, base + ".pdf") if not os.path.exists(pdf_path): for f in os.listdir(output_dir): if f.lower().endswith(".pdf"): pdf_path = os.path.join(output_dir, f) break if not os.path.exists(pdf_path): raise RuntimeError("LibreOffice did not create a PDF file") return pdf_path finally: try: shutil.rmtree(profile_dir, ignore_errors=True) except Exception: pass def convert_pdf_to_images(pdf_path: str, output_dir: str, dpi: int = 150) -> list: """Convert PDF pages to PNG images using PyMuPDF. Returns list of filenames.""" import fitz # Disable MuPDF error/warning messages (they print to stdout and corrupt JSON output) try: fitz.set_messages() except (TypeError, AttributeError): pass try: doc = fitz.open(pdf_path) except Exception as e: # Handle structure tree errors and other PDF issues by raising to trigger fallback error_msg = str(e).lower() if "structure tree" in error_msg or "no common ancestor" in error_msg: raise RuntimeError(f"PDF structure tree error: {e}") raise log(f"PDF has {doc.page_count} pages") pad = len(str(doc.page_count)) images = [] for i in range(doc.page_count): try: page = doc[i] pix = page.get_pixmap(dpi=dpi) filename = f"slide_{str(i + 1).zfill(pad)}.png" out_path = os.path.join(output_dir, filename) pix.save(out_path) images.append(filename) log(f" Saved page {i+1}/{doc.page_count}") except Exception as e: log(f" Failed page {i+1}: {e}") doc.close() return images def convert_pptx_to_images_direct(pptx_path: str, output_dir: str) -> list: """Fallback: try LibreOffice direct image export.""" profile_dir = os.path.join(tempfile.gettempdir(), f"lo_direct_{os.getpid()}_{int(time.time())}") os.makedirs(profile_dir, exist_ok=True) try: result = subprocess.run( [ SOFFICE, "--headless", "--convert-to", "png", "--outdir", output_dir, "-env:UserInstallation=file:///" + profile_dir.replace("\\", "/"), pptx_path, ], capture_output=True, text=True, timeout=60, ) images = [] for f in sorted(os.listdir(output_dir)): if f.lower().endswith(".png"): images.append(f) return images finally: try: shutil.rmtree(profile_dir, ignore_errors=True) except Exception: pass def _has_cjk(text: str) -> bool: """Detect CJK (Chinese/Japanese/Korean) characters in text.""" for ch in text: cp = ord(ch) if (0x4E00 <= cp <= 0x9FFF or # CJK Unified 0xAC00 <= cp <= 0xD7AF or # Hangul Syllables 0x3040 <= cp <= 0x309F or # Hiragana 0x30A0 <= cp <= 0x30FF or # Katakana 0xFF00 <= cp <= 0xFFEF): # Fullwidth Forms return True return False def _get_font(size: int = 24, bold: bool = False, text: str = ""): """Try to load a system font, fall back to default. If text contains CJK characters, prefer CJK-capable fonts (malgun, msgothic, Yu Gothic) over Latin-only fonts (arial). """ from PIL import ImageFont cjk = _has_cjk(text) if text else False # CJK-capable fonts first when CJK text is detected if cjk: cjk_candidates = [ "malgunbd.ttf" if bold else "malgun.ttf", "C:\\Windows\\Fonts\\malgunbd.ttf" if bold else "C:\\Windows\\Fonts\\malgun.ttf", "malgunsl.ttf", "C:\\Windows\\Fonts\\malgunsl.ttf", "msgothic.ttc", "C:\\Windows\\Fonts\\msgothic.ttc", "YuGothB.ttc" if bold else "YuGothR.ttc", "C:\\Windows\\Fonts\\YuGothB.ttc" if bold else "C:\\Windows\\Fonts\\YuGothR.ttc", "meiryo.ttc", "C:\\Windows\\Fonts\\meiryo.ttc", "NotoSansCJK-Regular.ttc", ] for name in cjk_candidates: try: return ImageFont.truetype(name, size) except (IOError, OSError): continue # Latin / general fonts candidates = [ "arialbd.ttf" if bold else "arial.ttf", "Arial Bold.ttf" if bold else "Arial.ttf", "arial.ttf", "Arial.ttf", "calibrib.ttf" if bold else "calibri.ttf", "Calibri Bold.ttf" if bold else "Calibri.ttf", "DejaVuSans-Bold.ttf" if bold else "DejaVuSans.ttf", "consolab.ttf" if bold else "consola.ttf", "C:\\Windows\\Fonts\\arialbd.ttf" if bold else "C:\\Windows\\Fonts\\arial.ttf", "C:\\Windows\\Fonts\\calibri.ttf", "C:\\Windows\\Fonts\\consola.ttf", ] # Fallback to CJK fonts even for Latin if nothing else works if not cjk: candidates += [ "malgun.ttf", "C:\\Windows\\Fonts\\malgun.ttf", ] for name in candidates: try: return ImageFont.truetype(name, size) except (IOError, OSError): continue return ImageFont.load_default() def _fit_dimensions(img_w: int, img_h: int, max_w: int, max_h: int): """Calculate display dimensions that fit within max_w x max_h.""" ratio = img_w / img_h box_ratio = max_w / max_h if ratio > box_ratio: return int(max_w), int(max_w / ratio) else: return int(max_h * ratio), int(max_h) def generate_card_preview(pptx_path: str, output_dir: str) -> list: """Generate card-based slide previews using python-pptx + Pillow. This is the fallback when LibreOffice is not available. Produces a simple preview card for each slide showing title, key content, and images. """ from pptx import Presentation from PIL import Image as PILImage, ImageDraw, ImageFont import io prs = Presentation(pptx_path) images = [] W, H = 960, 540 # 16:9 aspect ratio # Color palette matching the PPTX template defaults BG_LIGHT = (255, 255, 255) BG_DARK = (31, 36, 45) TITLE_COLOR = (26, 26, 46) SUBTITLE_COLOR = (95, 111, 134) BODY_COLOR = (45, 55, 72) ACCENT_COLOR = (22, 104, 227) SECTION_BG = (22, 104, 227) SECTION_TITLE_COLOR = (255, 255, 255) for i, slide in enumerate(prs.slides): # Detect slide type from layout name layout_name = (slide.slide_layout.name or "").lower() if slide.slide_layout else "" is_title = "title" in layout_name is_section = "section" in layout_name # Extract text and images from slide slide_w = prs.slide_width or 1 slide_h = prs.slide_height or 1 texts = [] slide_images = [] for shape in slide.shapes: if shape.has_text_frame: for para in shape.text_frame.paragraphs: text = para.text.strip() if text: texts.append(text) # Extract embedded images (PICTURE shape_type == 13) # Skip skin background shapes (tagged "background_skin" by pptx_gen.py) if shape.shape_type == 13: if getattr(shape, 'name', '') == 'background_skin': continue try: image = shape.image pil_img = PILImage.open(io.BytesIO(image.blob)) slide_images.append(pil_img) except Exception: pass title = texts[0] if texts else f"Slide {i + 1}" body_lines = texts[1:] if len(texts) > 1 else [] # Determine colors if is_section: bg = SECTION_BG title_color = SECTION_TITLE_COLOR accent_color = SECTION_TITLE_COLOR else: bg = BG_LIGHT title_color = TITLE_COLOR accent_color = ACCENT_COLOR # Determine layout: side-by-side if images present has_images = len(slide_images) > 0 text_width = 400 if has_images else 864 img_x = 460 if has_images else W img = PILImage.new("RGB", (W, H), bg) draw = ImageDraw.Draw(img) # Draw accent line (for non-section slides) if not is_section: draw.rectangle([48, 80, 160, 84], fill=accent_color) # Title display_title = title[:80] + ("..." if len(title) > 80 else "") title_font = _get_font(32, bold=True, text=display_title) draw.text((48, 24), display_title, fill=title_color, font=title_font) # Body / bullet lines y = 100 if body_lines: body_font = _get_font(16, text=" ".join(body_lines[:3])) bullet_font = _get_font(16) max_lines = 6 if has_images else 10 for line_idx, line in enumerate(body_lines[:max_lines]): display_line = line[:80] + ("..." if len(line) > 80 else "") # Draw bullet draw.text((72, y), "●", fill=accent_color, font=bullet_font) draw.text((96, y), display_line, fill=BODY_COLOR if not is_section else SECTION_TITLE_COLOR, font=body_font) y += 28 if len(body_lines) > max_lines: draw.text((72, y), f"... +{len(body_lines) - max_lines} more", fill=SUBTITLE_COLOR, font=_get_font(14)) y += 28 # Draw images on the right side if slide_images: img_y = 80 remaining_h = H - img_y - 40 per_img_h = remaining_h // len(slide_images[:2]) for pil_img in slide_images[:2]: fit_w, fit_h = _fit_dimensions(pil_img.width, pil_img.height, W - img_x - 20, per_img_h) thumb = pil_img.convert("RGB").resize((fit_w, fit_h), PILImage.LANCZOS) paste_x = img_x + (W - img_x - 20 - fit_w) // 2 img.paste(thumb, (paste_x, img_y)) img_y += fit_h + 12 # Slide number num_font = _get_font(12) draw.text((W - 60, H - 30), str(i + 1), fill=SUBTITLE_COLOR, font=num_font) filename = f"slide_{str(i + 1).zfill(len(str(len(slides))))}.png" img.save(os.path.join(output_dir, filename)) images.append(filename) return images def generate_preview(pptx_path: str, output_dir: str) -> dict: """Generate PNG previews of PPTX slides.""" if not os.path.exists(pptx_path): return {"success": False, "error": f"File not found: {pptx_path}"} os.makedirs(output_dir, exist_ok=True) # Clean any existing preview images for f in os.listdir(output_dir): if f.lower().endswith((".png", ".pdf")): os.remove(os.path.join(output_dir, f)) # Wait for PPTX file to be fully written (handles race condition) if not wait_for_file(pptx_path, timeout=5): return {"success": False, "error": f"PPTX file not ready: {pptx_path}"} images = [] errors = [] # Strategy 1: PPTX → PDF → PNG (best quality, requires LibreOffice + PyMuPDF) if HAS_LIBREOFFICE: try: log("Trying LibreOffice PDF route...") pdf_path = convert_pptx_to_pdf(pptx_path, output_dir) log(f"PDF created: {pdf_path}") try: images = convert_pdf_to_images(pdf_path, output_dir, dpi=150) log(f"PDF → PNG success: {len(images)} images") except ImportError: log("PyMuPDF not available, will try direct export") except Exception as e: errors.append(f"PyMuPDF failed: {str(e)[:200]}") log(f"PyMuPDF failed: {e}") # Clean up intermediate PDF try: if os.path.exists(pdf_path): os.remove(pdf_path) except OSError: pass except Exception as e: errors.append(f"PDF route failed: {str(e)[:200]}") log(f"LibreOffice PDF failed: {e}") # Strategy 2: SKIP — LibreOffice --convert-to png only exports the first slide, # producing a single-image preview. Fall through to Strategy 3 instead. # Strategy 3: python-pptx + Pillow card-based preview (no external deps) if not images: try: log("Falling back to card-based preview...") images = generate_card_preview(pptx_path, output_dir) log(f"Card preview success: {len(images)} images") except Exception as e: errors.append(f"Card preview failed: {str(e)[:200]}") log(f"Card preview failed: {e}") if not images: return {"success": False, "error": "; ".join(errors) if errors else "No preview method available"} return {"success": True, "images": images, "count": len(images)} def main(): if len(sys.argv) < 3: print(json.dumps({"success": False, "error": "Usage: python pptx_preview.py "})) sys.exit(1) pptx_path = sys.argv[1] output_dir = sys.argv[2] result = generate_preview(pptx_path, output_dir) print(json.dumps(result, ensure_ascii=False)) sys.exit(0 if result["success"] else 1) if __name__ == "__main__": main()