#!/usr/bin/env python3 """pptx_gen.py — Generate PowerPoint files using python-pptx. Usage: python pptx_gen.py [workspace_path] Spec JSON format (same schema as the Node.js create_presentation tool): { "title": "Presentation Title", "filename": "output.pptx", // optional, default: presentation.pptx "theme": "light", // optional: "light" or "dark" "template": "business", // optional: template name "default_skin": "ocean", // optional: default skin for all slides "font_sizes": { // optional: override default font sizes (pt) "title": 36, "subtitle": 18, "slide_title": 24, "body": 16, "bullets": 16, "section": 32, "image_title": 22 }, "slides": [ { "type": "title", "title": "...", "subtitle": "..." }, { "type": "content", "title": "...", "bullets": ["a", "b"] }, { "type": "content", "title": "...", "body": "body text" }, { "type": "content", "title": "...", "content": "alias for body" }, { "type": "content", "title": "...", "bullet_points": ["alias for bullets"] }, { "type": "content", "title": "...", "font_size": 20 }, // per-slide override { "type": "section", "title": "Section Name" }, { "type": "image", "title": "...", "image_path": "photo.jpg" }, { ..., "background": "ocean" }, // skin name or image file path { ..., "background": "my_bg.jpg" }, // image file in project folder { ..., "notes": "speaker notes" } ] } Output: JSON printed to stdout {"success": true, "path": "...", "folder": "...", "slides": 10, "warnings": []} {"success": false, "error": "error message"} """ import sys import json import os import re import math import argparse # Force UTF-8 output on Windows if sys.platform == 'win32': import io sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8') 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, MSO_AUTO_SIZE from pptx.oxml.ns import qn from pptx.oxml import parse_xml from lxml import etree # ─── Paths ────────────────────────────────────────────────────────────────────── SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_ROOT = os.path.join(SCRIPT_DIR, '..') SKIN_DIR = os.path.join(PROJECT_ROOT, '.smallclaw', 'skills', 'presenter', 'ppt', 'skin') TEMPLATE_DIR = os.path.join(PROJECT_ROOT, '.smallclaw', 'skills', 'presenter', 'ppt', 'template') SKIN_EXTENSIONS = {'.png', '.jpg', '.jpeg'} # ─── Constants ────────────────────────────────────────────────────────────────── FONT = "Calibri" FONT_CJK = "Malgun Gothic" # Windows Korean font COLORS_LIGHT = { "title": "1A1A2E", "subtitle": "5F6F86", "body": "2D3748", "accent": "1668E3", "background": "FFFFFF", } COLORS_DARK = { "title": "FFFFFF", "subtitle": "C0C0C0", "body": "E0E0E0", "accent": "4C8DFF", "background": "1F242D", } # Default font sizes (overridable via spec.font_sizes) DEFAULT_FONT_SIZES = { "title": 40, "subtitle": 20, "slide_title": 28, "body": 18, "bullets": 18, "section": 36, "image_title": 24, } # Slide dimensions (LAYOUT_WIDE = 13.333 x 7.5 inches) SLIDE_W = Inches(13.333) SLIDE_H = Inches(7.5) # Layout constants for two-column slides (text + image) L_MARGIN = Inches(0.7) # left margin (aligns with title) R_MARGIN = Inches(0.3) # right margin COL_GAP = Inches(0.3) # gap between text and image columns CONTENT_W = SLIDE_W - L_MARGIN - R_MARGIN # ~12.43" # Dark skin names — slides with these backgrounds use light text DARK_SKINS = { 'Cave', 'Deep Sea', 'Dream', 'Galaxy', 'Imagination', 'Metal', 'Space', 'Universe', 'charcoal', 'midnight', 'ocean', 'sunset', 'forest_green', 'mint', 'navy', 'slate', 'burgundy', 'moss', 'plum', 'deep_red', 'teal', 'emerald', # luminance <140 → JS detects dark but were missing here 'Skin_film', 'Skin_theater', 'Skin_slate', 'Skin_navy', 'Skin_burgundy', 'Skin_moss', 'Skin_plum', 'Skin_deep_red', } # ─── Skin / Template Resolution ───────────────────────────────────────────────── def _build_skin_index(): """Build an index of skin name → file path.""" index = {} if os.path.isdir(SKIN_DIR): for f in os.listdir(SKIN_DIR): ext = os.path.splitext(f)[1].lower() if ext not in SKIN_EXTENSIONS: continue name = os.path.splitext(f)[0] index[name] = os.path.join(SKIN_DIR, f) return index SKIN_INDEX = _build_skin_index() SKIN_NAMES = sorted(SKIN_INDEX.keys(), key=lambda s: s.lower()) def _load_template_configs(): """Load template JSON configs from ppt/template/.""" configs = {} if os.path.isdir(TEMPLATE_DIR): for f in os.listdir(TEMPLATE_DIR): if not f.endswith('.json'): continue try: with open(os.path.join(TEMPLATE_DIR, f), 'r', encoding='utf-8') as fh: cfg = json.load(fh) configs[cfg.get('name', '').lower()] = cfg except Exception: pass return configs TEMPLATE_CONFIGS = _load_template_configs() TEMPLATE_NAMES = list(TEMPLATE_CONFIGS.keys()) def resolve_skin_bg(name, project_dir=None, workspace_path=None): """Resolve a skin name or image file path to an absolute file path. Checks: skin directory → project_dir → workspace_path. Returns (file_path, is_dark) or (None, False). """ # 1. Try skin name in ppt/skin/ if name in SKIN_INDEX: path = SKIN_INDEX[name] if os.path.isfile(path): return path, name in DARK_SKINS # Case-insensitive skin lookup name_lower = name.lower() for skin_name, skin_path in SKIN_INDEX.items(): if skin_name.lower() == name_lower and os.path.isfile(skin_path): return skin_path, skin_name in DARK_SKINS # 2. Try as file path relative to project_dir, then workspace if project_dir: candidate = os.path.join(project_dir, name) if os.path.isfile(candidate): return candidate, False if workspace_path: candidate = os.path.join(workspace_path, name) if os.path.isfile(candidate): return candidate, False # 3. Try as absolute path if os.path.isabs(name) and os.path.isfile(name): return name, False return None, False def resolve_template(name): """Resolve template by name. Returns config dict or None.""" if not name: return None return TEMPLATE_CONFIGS.get(name.lower()) def get_template_colors(tpl): """Extract colors dict from template config, falling back to LIGHT defaults.""" if tpl and 'colors' in tpl: return tpl['colors'] return COLORS_LIGHT def get_template_font_sizes(tpl): """Extract font size overrides from template config.""" sizes = dict(DEFAULT_FONT_SIZES) if tpl: for key in ('titleSlide', 'contentSlide', 'sectionSlide'): section = tpl.get(key, {}) mapping = { 'titleSize': ('title', 'slide_title'), 'subtitleSize': ('subtitle',), 'titleSize_slide': ('slide_title',), 'bodySize': ('body', 'bullets'), 'sectionTitleSize': ('section',), } for tpl_key, size_keys in mapping.items(): if tpl_key in section: for sk in size_keys: sizes[sk] = section[tpl_key] return sizes def is_dark_skin(name): """Check if a skin name is considered dark (light text on dark background).""" return name in DARK_SKINS # ─── Helpers ──────────────────────────────────────────────────────────────────── def clean_text(text) -> str: """Remove control characters / OOXML escape artifacts from text. Handles two cases: 1. Actual control chars (U+000B etc.) → space or stripped 2. Literal '_x000B_' strings from PDF extraction → stripped """ if not isinstance(text, str): text = str(text) if text is not None else "" # Strip literal OOXML hex-escape patterns like _x000B_, _x000D_, _x000A_ etc. # Also catch fragments like x000B_ (missing leading _ after prior removal) text = re.sub(r'_?x[0-9A-Fa-f]{4}_', ' ', text) # Strip actual control characters result = [] for ch in text: cp = ord(ch) if cp in (0x0B, 0x0C, 0x0D): # VT, FF, CR → space result.append(' ') elif cp < 0x20 and cp not in (0x09, 0x0A): # other C0 except HT/LF pass # strip elif 0x7F <= cp <= 0x9F: # DEL + C1 controls pass # strip else: result.append(ch) return re.sub(r' +', ' ', ''.join(result)).strip() def apply_img_filter(image_path: str, slide_spec: dict) -> str: """Apply image filter (blur/grayscale/contrast/bright/round) using Pillow. Returns the path to the processed image (temp file next to original). Returns original path if Pillow unavailable or no filter set. """ img_filter = slide_spec.get("img_filter") or "" if not img_filter or img_filter == "none": return image_path try: from PIL import Image, ImageFilter, ImageEnhance, ImageDraw import tempfile, os as _os img = Image.open(image_path).convert("RGBA") if img_filter == "grayscale": img = img.convert("L").convert("RGBA") elif img_filter == "blur": radius = max(1, int(slide_spec.get("img_blur_radius") or 5)) img = img.filter(ImageFilter.GaussianBlur(radius=radius)) elif img_filter == "contrast": img = ImageEnhance.Contrast(img.convert("RGB")).enhance(1.6) img = ImageEnhance.Color(img).enhance(1.2) img = img.convert("RGBA") elif img_filter == "bright": img = ImageEnhance.Brightness(img.convert("RGB")).enhance(1.4) img = img.convert("RGBA") elif img_filter == "round": pct = max(5, min(50, int(slide_spec.get("img_corner_radius") or 15))) radius_px = int(min(img.width, img.height) * pct / 100) mask = Image.new("L", img.size, 0) draw = ImageDraw.Draw(mask) draw.rounded_rectangle([0, 0, img.width - 1, img.height - 1], radius=radius_px, fill=255) img.putalpha(mask) # Save to temp file (PNG to support transparency) base = _os.path.splitext(image_path)[0] out_path = base + "_fx.png" img.save(out_path, "PNG") return out_path except Exception as e: print(f"[pptx_gen] img_filter '{img_filter}' failed: {e}", file=sys.stderr, flush=True) return image_path def slugify(text: str) -> str: """Convert a title to a safe folder name (matches TypeScript projectSlug logic).""" s = str(text or "presentation").strip() s = re.sub(r"\.pptx$", "", s, flags=re.IGNORECASE) s = re.sub(r'[^a-zA-Z0-9ㄱ-힣_\-]', "_", s) s = re.sub(r"_+", "_", s) s = s.strip("_").lower() return s[:60] or "presentation" def hex_to_rgb(hex_str: str) -> RGBColor: """Convert hex color string to RGBColor.""" h = hex_str.lstrip("#") return RGBColor(int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)) def get_font() -> str: """Return appropriate font with CJK fallback.""" return FONT_CJK def add_text_box(slide, left, top, width, height, text, font_size=Pt(16), color_hex="2D3748", bold=False, alignment=PP_ALIGN.LEFT, font_name=None): """Add a text box. Multi-line text (\n) uses for consistent alignment.""" txBox = slide.shapes.add_textbox(left, top, width, height) tf = txBox.text_frame tf.word_wrap = True tf.auto_size = MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE p = tf.paragraphs[0] p.alignment = alignment def _make_run(para, txt): r = para.add_run() r.text = txt r.font.size = font_size r.font.bold = bold r.font.color.rgb = hex_to_rgb(color_hex) r.font.name = font_name or get_font() return r lines = text.split('\n') _make_run(p, lines[0]) for line in lines[1:]: # keeps the same paragraph (and its alignment) for each visual line br = etree.SubElement(p._p, qn('a:br')) rPr = etree.SubElement(br, qn('a:rPr')) rPr.set('lang', 'ko-KR') rPr.set('sz', str(int(font_size.pt * 100))) rPr.set('b', '1' if bold else '0') rPr.set('dirty', '0') _make_run(p, line) return txBox def _fit_bullet_font_size(items, width_emu, height_emu, max_pt): """Return the largest font size (pt) where all bullets fit in the box.""" col_w_pt = width_emu / 12700 # EMU → pt (1pt = 12700 EMU) col_h_pt = height_emu / 12700 for fs in range(int(max_pt), 9, -1): space_after = min(8, max(4, fs // 3)) line_h = fs * 1.35 + space_after chars_per_line = max(1, int(col_w_pt / (fs * 0.52))) # Items may contain \n (soft breaks) — count each visual line separately total = 0 for it in items: sub_lines = str(it).split('\n') for sl in sub_lines: total += max(1, math.ceil((len(sl) + 2) / chars_per_line)) * line_h if total <= col_h_pt: return fs return 9 def add_bullet_list(slide, left, top, width, height, items, font_size=Pt(16), color_hex="2D3748", bullet_color_hex="1668E3", font_name=None): """Add a text box with bullet points. Empty items become line spacers (no bullet).""" txBox = slide.shapes.add_textbox(left, top, width, height) tf = txBox.text_frame tf.word_wrap = True tf.auto_size = MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE # Fit font size using only non-empty items real_items = [it for it in items if str(it).strip()] fitted_pt = _fit_bullet_font_size(real_items or items, width, height, font_size.pt) fitted_fs = Pt(fitted_pt) space_after_pt = min(8, max(4, fitted_pt // 3)) hang_emu = int(min(254000, max(152400, fitted_pt * 12700))) for i, item in enumerate(items): if i == 0: p = tf.paragraphs[0] else: p = tf.add_paragraph() is_spacer = not str(item).strip() if is_spacer: # 빈 줄 → bullet 없는 spacer 단락 p.space_after = Pt(fitted_pt * 0.6) pPr = p._p.get_or_add_pPr() etree.SubElement(pPr, qn('a:buNone')) r = p.add_run() r.text = '' r.font.size = Pt(max(6, fitted_pt // 2)) continue # Detect sub-bullet: lines starting with "- " or "– " or "— " raw_text = str(item) _sub_match = re.match(r'^[-–—]\s+(.*)', raw_text) is_sub = _sub_match is not None display_text = _sub_match.group(1) if is_sub else raw_text # Split display_text by \n for soft line breaks within same bullet _lines = display_text.split('\n') p.space_after = Pt(space_after_pt) if is_sub: # Level 1 sub-bullet (○) with larger left margin p.level = 1 sub_hang = int(min(254000, max(152400, fitted_pt * 12700))) pPr = p._p.get_or_add_pPr() pPr.set('marL', str(hang_emu + sub_hang)) pPr.set('indent', str(-sub_hang)) buClr = etree.SubElement(pPr, qn('a:buClr')) srgbClr = etree.SubElement(buClr, qn('a:srgbClr')) srgbClr.set('val', bullet_color_hex.upper().replace('#', '').zfill(6)) buChar = etree.SubElement(pPr, qn('a:buChar')) buChar.set('char', '–') else: # Level 0 main bullet (●) p.level = 0 # Hanging indent: wrapped lines align with text start after bullet pPr = p._p.get_or_add_pPr() pPr.set('marL', str(hang_emu)) pPr.set('indent', str(-hang_emu)) # Native PPTX bullet character (color + char) buClr = etree.SubElement(pPr, qn('a:buClr')) srgbClr = etree.SubElement(buClr, qn('a:srgbClr')) srgbClr.set('val', bullet_color_hex.upper().replace('#', '').zfill(6)) buChar = etree.SubElement(pPr, qn('a:buChar')) buChar.set('char', '●') # First line as normal run, subsequent lines as soft breaks text_run = p.add_run() text_run.text = _lines[0] text_run.font.size = fitted_fs text_run.font.color.rgb = hex_to_rgb(color_hex) text_run.font.name = font_name or get_font() for _ln in _lines[1:]: br = etree.SubElement(p._p, qn('a:br')) rPr = etree.SubElement(br, qn('a:rPr')) rPr.set('lang', 'ko-KR') rPr.set('sz', str(int(fitted_fs.pt * 100))) rPr.set('dirty', '0') text_run = p.add_run() text_run.text = _ln text_run.font.size = fitted_fs text_run.font.color.rgb = hex_to_rgb(color_hex) text_run.font.name = font_name or get_font() return txBox def set_slide_bg(slide, color_hex: str): """Set solid background color for a slide.""" bg = slide.background fill = bg.fill fill.solid() fill.fore_color.rgb = hex_to_rgb(color_hex) def set_slide_bg_image(slide, image_path: str): """Set a background image for a slide.""" bg = slide.background fill = bg.fill fill.background() # python-pptx doesn't have a direct bg-image API — add a full-slide image instead pic = slide.shapes.add_picture( image_path, Inches(0), Inches(0), SLIDE_W, SLIDE_H ) # Tag so pptx_preview.py can distinguish skin backgrounds from content images pic.name = "background_skin" # Move to back: add_picture appends to end (top layer) — move behind all content sp_tree = slide.shapes._spTree pic_elem = pic._element sp_tree.remove(pic_elem) sp_tree.insert(2, pic_elem) # index 2 = after nvGrpSpPr + grpSpPr def add_shape_rect(slide, left, top, width, height, color_hex: str): """Add a filled rectangle shape.""" from pptx.enum.shapes import MSO_SHAPE shape = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, left, top, width, height) shape.fill.solid() shape.fill.fore_color.rgb = hex_to_rgb(color_hex) shape.line.fill.background() return shape def set_shape_fill_opacity(shape, opacity_pct: float): """Make a solid-filled shape semi-transparent. opacity_pct: 0 (fully transparent) .. 100 (fully opaque). python-pptx has no fill.transparency setter (it is a silent no-op), so we inject the OOXML element directly into the solidFill color. """ spPr = shape._element.spPr fill = spPr.find(qn('a:solidFill')) if fill is None: return srgb = fill.find(qn('a:srgbClr')) if srgb is None: return alpha = srgb.find(qn('a:alpha')) if alpha is None: alpha = etree.SubElement(srgb, qn('a:alpha')) alpha.set('val', str(int(max(0, min(100, opacity_pct)) * 1000))) def fit_dimensions(img_w, img_h, max_w, max_h, mode="contain"): """Calculate dimensions preserving aspect ratio. mode='contain': fit entirely within max_w × max_h (default, no cropping) mode='cover': fill max_w × max_h completely, cropping if necessary """ # Tiny breathing margin — image fills nearly the full bounding box max_w = int(max_w * 1.0) max_h = int(max_h * 1.0) if img_w <= 0 or img_h <= 0: return max_w, max_h ratio = img_w / img_h box_ratio = max_w / max_h if max_h > 0 else 1 if mode == "cover": # Scale so image completely covers the box; overflow is cropped if ratio > box_ratio: # Wider than box → fit height, overflow width h = max_h w = max_h * ratio else: # Taller than box → fit width, overflow height w = max_w h = max_w / ratio return w, h # contain (default) if ratio > box_ratio: w = max_w h = max_w / ratio else: h = max_h w = max_h * ratio return w, h def _ensure_compatible_image(image_path: str) -> str: """Convert unsupported image formats to PNG for python-pptx compatibility. PowerPoint only supports JPEG, PNG, GIF, BMP, TIFF, EMF, WMF. AVIF, WebP, and other formats must be converted. We check the actual format via PIL rather than trusting the file extension, since downloaded images often have mismatched extensions (e.g. AVIF data saved as .jpg). """ # Formats that PowerPoint can embed directly PPTX_SUPPORTED = {'JPEG', 'PNG', 'GIF', 'BMP', 'TIFF'} try: from PIL import Image as PILImage with PILImage.open(image_path) as img: actual_format = img.format # e.g. 'AVIF', 'WEBP', 'JPEG', 'PNG' if actual_format and actual_format.upper() in PPTX_SUPPORTED: return image_path # Unsupported format (AVIF, WEBP, etc.) — convert to PNG if img.mode in ('RGBA', 'P', 'LA', 'L'): rgb_img = img.convert('RGB') else: rgb_img = img new_path = image_path + '.converted.png' rgb_img.save(new_path, 'PNG') print(f"[pptx_gen] Converted {actual_format} image to PNG: {os.path.basename(image_path)} -> {os.path.basename(new_path)}", file=sys.stderr, flush=True) return new_path except Exception: # PIL can't open it — return as-is and let downstream handle the failure return image_path def add_image_or_placeholder(slide, image_path, left, top, max_width, max_height, warnings, color_hex="FF0000", fit_mode="contain"): """Add an image preserving aspect ratio, or a subtle placeholder if not found/unsupported. fit_mode: 'contain' (default) — entire image visible, may leave empty bars 'cover' — fill the bounding box completely, cropping if needed """ if os.path.exists(image_path): converted_path = _ensure_compatible_image(image_path) print(f"[pptx_gen] add_image: {os.path.basename(image_path)} exists, converted={os.path.basename(converted_path) if converted_path != image_path else 'same'}", file=sys.stderr, flush=True) try: # Read native dimensions and compute aspect-ratio-preserving size from PIL import Image as PILImage with PILImage.open(converted_path) as img: img_w, img_h = img.size img_fmt = getattr(img, 'format', 'unknown') print(f"[pptx_gen] add_image: {img_fmt} {img_w}x{img_h} -> fit_mode={fit_mode}", file=sys.stderr, flush=True) fit_w, fit_h = fit_dimensions(img_w, img_h, max_width, max_height, mode=fit_mode) # Center within the bounding box both horizontally and vertically center_x = left + (max_width - fit_w) / 2 center_y = top + (max_height - fit_h) / 2 slide.shapes.add_picture(converted_path, center_x, center_y, fit_w, fit_h) print(f"[pptx_gen] add_image: OK, embedded at ({int(center_x)},{int(center_y)})", file=sys.stderr, flush=True) return except Exception as e: print(f"[pptx_gen] add_image: FAILED to embed {os.path.basename(image_path)}: {e}", file=sys.stderr, flush=True) # If image dimension reading fails, try with native size (no stretching) try: pic = slide.shapes.add_picture(converted_path, left, top) # Scale down if larger than max bounds native_w = pic.width native_h = pic.height if native_w > max_width or native_h > max_height: fit_w, fit_h = fit_dimensions(native_w, native_h, max_width, max_height, mode=fit_mode) pic.width = int(fit_w) pic.height = int(fit_h) center_x = left + (max_width - fit_w) / 2 center_y = top + (max_height - fit_h) / 2 pic.left = int(center_x) pic.top = int(center_y) else: center_x = left + (max_width - native_w) / 2 center_y = top + (max_height - native_h) / 2 pic.left = int(center_x) pic.top = int(center_y) print(f"[pptx_gen] add_image: fallback native size OK", file=sys.stderr, flush=True) return except Exception as e2: print(f"[pptx_gen] add_image: fallback also FAILED: {e2}", file=sys.stderr, flush=True) pass else: print(f"[pptx_gen] add_image: file NOT FOUND: {image_path}", file=sys.stderr, flush=True) # Draw a subtle gray placeholder instead of red error text add_shape_rect(slide, left, top, max_width, max_height, "E8E8E8") warnings.append(f"Image missing or unsupported: {image_path}") def resolve_image_path(image_path: str, project_dir: str, workspace_path: str) -> str: """Resolve image path: check absolute, then project_dir, then workspace, then search by filename.""" if os.path.isabs(image_path): if os.path.exists(image_path): print(f"[pptx_gen] resolve_image_path: {image_path} (absolute, exists)", file=sys.stderr, flush=True) return image_path else: print(f"[pptx_gen] resolve_image_path: {image_path} (absolute, NOT FOUND)", file=sys.stderr, flush=True) candidates = [ os.path.join(project_dir, image_path), os.path.join(workspace_path, image_path), ] for c in candidates: if os.path.exists(c): print(f"[pptx_gen] resolve_image_path: {image_path} -> {c} (found)", file=sys.stderr, flush=True) return c # Fallback: search by filename within project_dir only (NOT workspace-wide — generic names like # slide2_image.jpg would match other projects' images and corrupt the presentation) basename = os.path.basename(image_path).lower() for root, dirs, files in os.walk(project_dir): for f in files: if f.lower() == basename: resolved = os.path.join(root, f) print(f"[pptx_gen] resolve_image_path: {image_path} -> {resolved} (found in project_dir)", file=sys.stderr, flush=True) return resolved print(f"[pptx_gen] resolve_image_path: {image_path} NOT FOUND in {project_dir} or {workspace_path}", file=sys.stderr, flush=True) return candidates[0] # return first even if missing (placeholder will show) # ─── Auto Layout Helpers ──────────────────────────────────────────────────────── def _text_weight(slide_spec: dict): """Return (bullet_count, total_chars) for the text content of a slide.""" bullets = slide_spec.get("bullets") or slide_spec.get("bullet_points") or [] body = slide_spec.get("body") or slide_spec.get("content") or "" if bullets: return len(bullets), sum(len(str(b)) for b in bullets) return (1 if body else 0), len(body) def _auto_text_ratio(slide_spec: dict) -> float: """Compute text column share (0–1) based on content volume. img_scale (20–80) overrides auto: text_ratio = (100 - img_scale) / 100 ≤2 bullets / ≤120 chars → 0.32 image-dominant 3–4 bullets / ≤280 chars → 0.44 balanced 5+ bullets / >280 chars → 0.56 text-dominant """ img_scale = slide_spec.get("img_scale") if img_scale is not None: try: pct = max(20, min(80, int(img_scale))) return round((100 - pct) / 100, 2) except (TypeError, ValueError): pass n_bullets, n_chars = _text_weight(slide_spec) if n_bullets <= 2 and n_chars <= 120: return 0.32 elif n_bullets <= 4 and n_chars <= 280: return 0.44 else: return 0.56 # ─── Slide Generators ────────────────────────────────────────────────────────── def make_title_slide(prs, slide_spec, spec, colors, is_dark, fs, bg_image=None, project_dir=None, workspace_path=None, warnings=None): """Generate a title slide.""" slide = prs.slides.add_slide(prs.slide_layouts[6]) # blank layout title_col = colors["title"] subtitle_col = colors["subtitle"] # Background — a photo (slide image_path) takes precedence over the skin, # rendered full-bleed with a dark overlay + white text. This mirrors the # editor preview, which draws the title image as a darkened background. img_path = slide_spec.get("image_path") has_photo = bool(img_path) and project_dir is not None if has_photo: resolved = resolve_image_path(img_path, project_dir, workspace_path) add_image_or_placeholder(slide, resolved, Inches(0), Inches(0), SLIDE_W, SLIDE_H, warnings if warnings is not None else [], fit_mode="cover") # Full-slide dark overlay (~48% opaque black, matches editor rgba(0,0,0,.48)) overlay = add_shape_rect(slide, Inches(0), Inches(0), SLIDE_W, SLIDE_H, "000000") set_shape_fill_opacity(overlay, 48) # 48% opaque black, matches editor rgba(0,0,0,.48) title_col = "FFFFFF" subtitle_col = "E6E6E6" elif bg_image: set_slide_bg_image(slide, bg_image) elif is_dark: set_slide_bg(slide, colors["background"]) _title_font = slide_spec.get("_font_override") or None title = slide_spec.get("title") or spec.get("title") or "Untitled" title_pt = slide_spec.get("title_size") or fs["title"] y_pos = Inches(2.8) if (bg_image or has_photo) else Inches(2.5) title_lines = len(title.split('\n')) title_h = Inches(max(1.2, title_lines * title_pt * 1.5 / 72)) add_text_box(slide, Inches(0.8), y_pos, Inches(11.7), title_h, title, font_size=Pt(title_pt), color_hex=title_col, bold=True, alignment=PP_ALIGN.CENTER, font_name=_title_font) subtitle = slide_spec.get("subtitle") if subtitle: add_text_box(slide, Inches(1.5), y_pos + title_h + Inches(0.15), Inches(10.3), Inches(0.7), subtitle, font_size=Pt(fs["subtitle"]), color_hex=subtitle_col, alignment=PP_ALIGN.CENTER, font_name=_title_font) return slide def make_section_slide(prs, slide_spec, colors, fs, bg_image=None): """Generate a section divider slide.""" slide = prs.slides.add_slide(prs.slide_layouts[6]) if bg_image: set_slide_bg_image(slide, bg_image) else: add_shape_rect(slide, Inches(0), Inches(0), SLIDE_W, SLIDE_H, colors.get("accent", "1668E3")) title = slide_spec.get("title") or "Section" text_color = "FFFFFF" add_text_box(slide, Inches(1), Inches(2.5), Inches(11.3), Inches(1.5), title, font_size=Pt(fs["section"]), color_hex=text_color, bold=True, alignment=PP_ALIGN.CENTER) return slide def make_content_slide(prs, slide_spec, project_dir, workspace_path, colors, is_dark, fs, warnings, bg_image=None): """Generate a content slide. Layouts (slide_spec["layout"]): "split-right" (default) — small title top, text left, image right "split-left" — small title top, image left, text right "text" — full-width text, image ignored "fullscreen" — image fills slide, title/text overlaid at bottom """ layout = (slide_spec.get("layout") or "split-right").lower() slide = prs.slides.add_slide(prs.slide_layouts[6]) # Background if bg_image: set_slide_bg_image(slide, bg_image) elif is_dark: set_slide_bg(slide, colors["background"]) has_title = bool(slide_spec.get("title")) has_image = bool(slide_spec.get("image_path")) and layout != "text" _slide_font = slide_spec.get("_font_override") or None # text_y: 내용 블록 Y 위치 (슬라이드 높이 비율, 0~1). 편집창 드래그로 설정. _text_y = slide_spec.get("text_y") if _text_y is not None: content_y_override = SLIDE_H * float(_text_y) else: content_y_override = None # ── Fullscreen layout: image fills slide, text/title overlaid ───────────── if layout == "fullscreen" and has_image: resolved = resolve_image_path(slide_spec["image_path"], project_dir, workspace_path) add_image_or_placeholder(slide, resolved, Inches(0), Inches(0), SLIDE_W, SLIDE_H, warnings, fit_mode="cover") # Semi-transparent dark bar at bottom for text legibility bar_h = Inches(2.0) bar_y = SLIDE_H - bar_h bar_shape = add_shape_rect(slide, Inches(0), bar_y, SLIDE_W, bar_h, "000000") set_shape_fill_opacity(bar_shape, 55) # 55% opaque black # Title and body overlaid on dark bar fs_title_pt = slide_spec.get("title_size") or fs["slide_title"] if has_title: add_text_box(slide, Inches(0.6), bar_y + Inches(0.15), Inches(12.0), Inches(0.7), slide_spec["title"], font_size=Pt(fs_title_pt), color_hex="FFFFFF", bold=True) body_text = slide_spec.get("body") or slide_spec.get("content") bullets = slide_spec.get("bullets") or slide_spec.get("bullet_points") if bullets: body_text = " ● ".join(str(b) for b in bullets) if body_text: add_text_box(slide, Inches(0.6), bar_y + Inches(0.9), Inches(12.0), Inches(0.9), body_text, font_size=Pt(fs["body"]), color_hex="E0E0E0") return slide if content_y_override is not None: content_y = content_y_override else: content_y = Inches(1.5) if has_title else Inches(0.5) content_h = SLIDE_H - content_y - Inches(0.4) slide_title_pt = slide_spec.get("title_size") or fs["slide_title"] if has_title: add_text_box(slide, L_MARGIN, Inches(0.4), SLIDE_W - L_MARGIN - R_MARGIN, Inches(0.8), slide_spec["title"], font_size=Pt(slide_title_pt), color_hex=colors["title"], bold=True, font_name=_slide_font) add_shape_rect(slide, L_MARGIN, Inches(1.15), Inches(2), Inches(0.04), colors["accent"]) # ── Compare layout: two text columns side by side ───────────────────────── if layout == "compare": col_w = (CONTENT_W - COL_GAP) / 2 right_x = L_MARGIN + col_w + COL_GAP # Vertical divider div_x = L_MARGIN + col_w + COL_GAP / 2 - Inches(0.02) add_shape_rect(slide, div_x, content_y + Inches(0.1), Inches(0.04), content_h - Inches(0.2), colors["accent"]) for col_x, t_key, b_key, pts_key, body_key in [ (L_MARGIN, "left_title", "left_bullets", "left_points", "left_body"), (right_x, "right_title", "right_bullets", "right_points", "right_body"), ]: col_title = slide_spec.get(t_key) or "" col_bullets = slide_spec.get(b_key) or slide_spec.get(pts_key) or [] col_body = slide_spec.get(body_key) or "" sub_y = content_y sub_h = content_h if col_title: add_text_box(slide, col_x, sub_y, col_w, Inches(0.5), col_title, font_size=Pt(fs["slide_title"] - 2), color_hex=colors["accent"], bold=True) sub_y += Inches(0.6) sub_h -= Inches(0.6) if col_bullets: add_bullet_list(slide, col_x, sub_y, col_w, sub_h, col_bullets, font_size=Pt(fs["bullets"]), color_hex=colors["body"], bullet_color_hex=colors["accent"]) elif col_body: add_text_box(slide, col_x, sub_y, col_w, sub_h, col_body, font_size=Pt(fs["body"]), color_hex=colors["body"]) return slide # ── Column geometry ─────────────────────────────────────────────────────── if has_image: text_ratio = _auto_text_ratio(slide_spec) if layout == "split-left": # Image left, text right img_col_x = L_MARGIN img_col_w = CONTENT_W * (1 - text_ratio) - COL_GAP / 2 text_col_x = img_col_x + img_col_w + COL_GAP text_col_w = CONTENT_W * text_ratio - COL_GAP / 2 else: # split-right (default): text left, image right text_col_x = L_MARGIN text_col_w = CONTENT_W * text_ratio - COL_GAP / 2 img_col_x = L_MARGIN + text_col_w + COL_GAP img_col_w = CONTENT_W * (1 - text_ratio) - COL_GAP / 2 else: text_col_x = L_MARGIN text_col_w = CONTENT_W img_col_x = None img_col_w = None # ── Text content ────────────────────────────────────────────────────────── bullet_items = slide_spec.get("bullets") or slide_spec.get("bullet_points") body_text = slide_spec.get("body") or slide_spec.get("content") override = slide_spec.get("font_size") body_fs = Pt(override) if override else Pt(fs["body"]) bullet_fs = Pt(override) if override else Pt(fs["bullets"]) if bullet_items and len(bullet_items) > 0: add_bullet_list(slide, text_col_x, content_y, text_col_w, content_h, bullet_items, font_size=bullet_fs, color_hex=colors["body"], bullet_color_hex=colors["accent"], font_name=_slide_font) elif body_text: add_text_box(slide, text_col_x, content_y, text_col_w, content_h, body_text, font_size=body_fs, color_hex=colors["body"], font_name=_slide_font) # ── Image column / free position ───────────────────────────────────────── if has_image: resolved = resolve_image_path(slide_spec["image_path"], project_dir, workspace_path) # Free positioning: img_x/img_y/img_w/img_h as fractions (0-1) of slide fx = slide_spec.get("img_x") fy = slide_spec.get("img_y") fw = slide_spec.get("img_w") fh = slide_spec.get("img_h") if fx is not None and fy is not None: abs_x = SLIDE_W * float(fx) abs_y = SLIDE_H * float(fy) abs_w = SLIDE_W * float(fw) if fw is not None else img_col_w or SLIDE_W * 0.45 abs_h = SLIDE_H * float(fh) if fh is not None else content_h add_image_or_placeholder(slide, resolved, abs_x, abs_y, abs_w, abs_h, warnings, color_hex=colors["body"]) elif img_col_x is not None: add_image_or_placeholder(slide, resolved, img_col_x, content_y, img_col_w, content_h, warnings, color_hex=colors["body"]) return slide def make_image_slide(prs, slide_spec, project_dir, workspace_path, colors, warnings, is_dark, fs, bg_image=None): """Generate an image slide.""" slide = prs.slides.add_slide(prs.slide_layouts[6]) # Background if bg_image: set_slide_bg_image(slide, bg_image) elif is_dark: set_slide_bg(slide, colors["background"]) has_title = bool(slide_spec.get("title")) if has_title: add_text_box(slide, Inches(0.5), Inches(0.3), Inches(12.3), Inches(0.7), slide_spec["title"], font_size=Pt(fs["image_title"]), color_hex=colors["title"], bold=True) image_path = slide_spec.get("image_path") if image_path: resolved = resolve_image_path(image_path, project_dir, workspace_path) # Add padding so the photo doesn't bleed edge-to-edge; # use contain so the full photo is visible without cropping. h_pad = Inches(0.5) if has_title: img_top = Inches(1.2) v_pad = Inches(0.3) else: img_top = Inches(0.4) v_pad = Inches(0.4) add_image_or_placeholder(slide, resolved, h_pad, img_top, SLIDE_W - 2 * h_pad, SLIDE_H - img_top - v_pad, warnings, fit_mode="contain") return slide # ─── Table / Chart / Timeline Slide Generators ───────────────────────────────── def _style_table_cell(cell, text, font_pt, font_name, color_hex, bold=False, alignment=PP_ALIGN.LEFT, bg_hex=None): """Set text and styling on a python-pptx table cell.""" tf = cell.text_frame tf.word_wrap = True cell.text = str(text) if text is not None else "" p = tf.paragraphs[0] p.alignment = alignment if p.runs: run = p.runs[0] run.font.size = Pt(font_pt) run.font.bold = bold run.font.name = font_name run.font.color.rgb = hex_to_rgb(color_hex) if bg_hex: cell.fill.solid() cell.fill.fore_color.rgb = hex_to_rgb(bg_hex) def make_table_slide(prs, slide_spec, colors, is_dark, fs, warnings, bg_image=None): """Generate a table slide with optional header row and data rows.""" slide = prs.slides.add_slide(prs.slide_layouts[6]) if bg_image: set_slide_bg_image(slide, bg_image) elif is_dark: set_slide_bg(slide, colors["background"]) has_title = bool(slide_spec.get("title")) if has_title: add_text_box(slide, L_MARGIN, Inches(0.4), SLIDE_W - L_MARGIN - R_MARGIN, Inches(0.8), slide_spec["title"], font_size=Pt(fs["slide_title"]), color_hex=colors["title"], bold=True) add_shape_rect(slide, L_MARGIN, Inches(1.15), Inches(2), Inches(0.04), colors["accent"]) headers = slide_spec.get("headers") or [] rows = slide_spec.get("rows") or [] col_count = len(headers) if headers else (len(rows[0]) if rows else 0) if col_count == 0: return slide has_header_row = bool(headers) row_count = len(rows) + (1 if has_header_row else 0) if row_count == 0: return slide table_top = Inches(1.5) if has_title else Inches(0.6) HEADER_H = Inches(0.5) ROW_H = Inches(0.55) ideal_h = (HEADER_H if has_header_row else 0) + len(rows) * ROW_H available_h = SLIDE_H - table_top - Inches(0.4) table_h = min(ideal_h, available_h) if table_h < available_h: table_top = int(table_top + (available_h - table_h) // 2) table_w = int(SLIDE_W * 0.88) table_left = (SLIDE_W - table_w) // 2 tbl = slide.shapes.add_table(row_count, col_count, table_left, table_top, table_w, table_h).table # Set explicit row heights so python-pptx doesn't stretch them evenly for ri2 in range(row_count): tbl.rows[ri2].height = HEADER_H if (has_header_row and ri2 == 0) else ROW_H fn = get_font() body_pt = fs.get("body", 16) header_pt = min(body_pt, 15) if has_header_row: for j, h in enumerate(headers[:col_count]): _style_table_cell(tbl.cell(0, j), h, header_pt, fn, "FFFFFF", bold=True, alignment=PP_ALIGN.CENTER, bg_hex=colors.get("accent", "1668E3")) for i, row in enumerate(rows): ri = i + (1 if has_header_row else 0) if ri >= row_count: break if is_dark: bg = "2A3040" if i % 2 == 0 else "222836" else: bg = "F2F4F8" if i % 2 == 0 else "FFFFFF" for j, val in enumerate(row[:col_count]): _style_table_cell(tbl.cell(ri, j), val, body_pt, fn, colors.get("body", "2D3748"), bg_hex=bg) return slide def make_chart_slide(prs, slide_spec, colors, is_dark, fs, warnings, bg_image=None): """Generate a chart slide (column/bar/line/pie/doughnut).""" try: from pptx.chart.data import ChartData from pptx.enum.chart import XL_CHART_TYPE except ImportError: warnings.append("Chart support requires python-pptx >= 0.6.18") return prs.slides.add_slide(prs.slide_layouts[6]) slide = prs.slides.add_slide(prs.slide_layouts[6]) if bg_image: set_slide_bg_image(slide, bg_image) elif is_dark: set_slide_bg(slide, colors["background"]) has_title = bool(slide_spec.get("title")) if has_title: add_text_box(slide, L_MARGIN, Inches(0.4), SLIDE_W - L_MARGIN - R_MARGIN, Inches(0.8), slide_spec["title"], font_size=Pt(fs["slide_title"]), color_hex=colors["title"], bold=True) add_shape_rect(slide, L_MARGIN, Inches(1.15), Inches(2), Inches(0.04), colors["accent"]) type_map = { "bar": XL_CHART_TYPE.BAR_CLUSTERED, "bar_stacked": XL_CHART_TYPE.BAR_STACKED, "column": XL_CHART_TYPE.COLUMN_CLUSTERED, "column_stacked": XL_CHART_TYPE.COLUMN_STACKED, "line": XL_CHART_TYPE.LINE, "line_markers": XL_CHART_TYPE.LINE_MARKERS, "pie": XL_CHART_TYPE.PIE, "doughnut": XL_CHART_TYPE.DOUGHNUT, } xl_type = type_map.get((slide_spec.get("chart_type") or "column").lower(), XL_CHART_TYPE.COLUMN_CLUSTERED) categories = slide_spec.get("categories") or [] series_data = slide_spec.get("series") or [] if not categories or not series_data: warnings.append(f"Chart slide '{slide_spec.get('title', '')}' missing categories or series — skipped") return slide chart_data = ChartData() chart_data.categories = [str(c) for c in categories] for s in series_data: name = str(s.get("name") or s.get("label") or "Series") values = [float(v) if v is not None else 0.0 for v in (s.get("values") or s.get("data") or [])] chart_data.add_series(name, values) chart_top = Inches(1.5) if has_title else Inches(0.5) chart_frame = slide.shapes.add_chart( xl_type, L_MARGIN, chart_top, SLIDE_W - L_MARGIN - R_MARGIN, SLIDE_H - chart_top - Inches(0.4), chart_data, ) chart = chart_frame.chart # Hide built-in chart title (slide title is sufficient) chart.has_title = False if len(series_data) > 1: chart.has_legend = True # Apply white text on dark backgrounds if is_dark: from pptx.dml.color import RGBColor WHITE = RGBColor(0xFF, 0xFF, 0xFF) for _axis in [chart.value_axis, chart.category_axis]: try: _axis.tick_labels.font.color.rgb = WHITE except Exception: pass try: if _axis.has_title: for _para in _axis.axis_title.text_frame.paragraphs: for _run in _para.runs: _run.font.color.rgb = WHITE except Exception: pass if chart.has_legend: try: chart.legend.font.color.rgb = WHITE except Exception: pass for plot in chart.plots: try: plot.data_labels.font.color.rgb = WHITE except Exception: pass return slide def make_timeline_slide(prs, slide_spec, colors, is_dark, fs, warnings, bg_image=None): """Generate a horizontal timeline slide with alternating labels above/below.""" from pptx.enum.shapes import MSO_SHAPE_TYPE slide = prs.slides.add_slide(prs.slide_layouts[6]) if bg_image: set_slide_bg_image(slide, bg_image) elif is_dark: set_slide_bg(slide, colors["background"]) has_title = bool(slide_spec.get("title")) if has_title: add_text_box(slide, L_MARGIN, Inches(0.4), SLIDE_W - L_MARGIN - R_MARGIN, Inches(0.8), slide_spec["title"], font_size=Pt(fs["slide_title"]), color_hex=colors["title"], bold=True) add_shape_rect(slide, L_MARGIN, Inches(1.15), Inches(2), Inches(0.04), colors["accent"]) events = slide_spec.get("events") or [] if not events: return slide n = len(events) accent = colors.get("accent", "1668E3") body_color = colors.get("body", "2D3748") line_y = Inches(4.0) line_left = Inches(1.2) line_right = SLIDE_W - Inches(1.2) line_len = line_right - line_left # Horizontal axis bar add_shape_rect(slide, line_left, line_y - Inches(0.025), line_len, Inches(0.05), accent) dot_r = Inches(0.18) label_w = Inches(1.9) for i, event in enumerate(events): cx = line_left + (line_len * i / (n - 1) if n > 1 else line_len / 2) # Oval dot from pptx.enum.shapes import MSO_SHAPE dot = slide.shapes.add_shape(MSO_SHAPE.OVAL, cx - dot_r, line_y - dot_r, dot_r * 2, dot_r * 2) dot.fill.solid() dot.fill.fore_color.rgb = hex_to_rgb(accent) dot.line.fill.background() label = str(event.get("year") or event.get("label") or str(i + 1)) desc = str(event.get("text") or event.get("description") or "") lx = cx - label_w / 2 if i % 2 == 0: # Label above line add_text_box(slide, lx, line_y - Inches(1.35), label_w, Inches(0.5), label, font_size=Pt(14), color_hex=accent, bold=True, alignment=PP_ALIGN.CENTER) if desc: add_text_box(slide, lx, line_y - Inches(0.85), label_w, Inches(0.55), desc, font_size=Pt(11), color_hex=body_color, alignment=PP_ALIGN.CENTER) else: # Label below line add_text_box(slide, lx, line_y + Inches(0.35), label_w, Inches(0.5), label, font_size=Pt(14), color_hex=accent, bold=True, alignment=PP_ALIGN.CENTER) if desc: add_text_box(slide, lx, line_y + Inches(0.85), label_w, Inches(0.55), desc, font_size=Pt(11), color_hex=body_color, alignment=PP_ALIGN.CENTER) return slide # ─── Download Page Generator ──────────────────────────────────────────────────── def _create_download_page(pptx_path: str, download_url: str, title: str, slide_count: int): """Create a local download.html next to the PPTX so users can grab the file via browser.""" project_dir = os.path.dirname(pptx_path) filename = os.path.basename(pptx_path) html_path = os.path.join(project_dir, "download.html") # Build a relative link so the HTML works whether opened via file:// or served html = f""" {title} - 다운로드

{title}

슬라이드 {slide_count}장이 준비되었습니다.

프레젠테이션 다운로드
{pptx_path}
""" with open(html_path, "w", encoding="utf-8") as f: f.write(html) # ─── Main Generation ─────────────────────────────────────────────────────────── def generate(spec: dict, workspace_path: str) -> dict: """Generate a PPTX file from spec. Returns result dict.""" warnings = [] title = spec.get("title") or "Presentation" project_slug = slugify(title) # Derive filename from project slug if not explicitly provided filename = spec.get("filename", "").replace(" ", "_") if spec.get("filename") else f"{project_slug}.pptx" if not filename.endswith(".pptx"): filename += ".pptx" slides_spec = spec.get("slides") or [] theme = spec.get("theme") or "light" is_dark = theme == "dark" print(f"[pptx_gen] generate: title='{title}' slug='{project_slug}' slides={len(slides_spec)}", file=sys.stderr, flush=True) print(f"[pptx_gen] generate: workspace='{workspace_path}' theme='{theme}'", file=sys.stderr, flush=True) if not slides_spec: return {"success": False, "error": "spec.slides must be a non-empty array"} existing_path = spec.get("existing_path", "") is_edit = bool(existing_path) and os.path.exists(existing_path) default_skin = spec.get("default_skin") or "" # Auto-detect dark theme from existing presentation when editing without explicit theme. # Strategy 1: solid background fill → check luminance. # Auto-detect dark theme + extract background image from existing presentation. # When editing without explicit theme, inherit the visual style of the existing slides. inherited_bg_image = None # path to extracted background_skin image, if any # Skip auto-detection when default_skin is explicitly provided — derive darkness from skin name. if is_edit and not spec.get("theme") and default_skin: if is_dark_skin(default_skin): is_dark = True theme = "dark" elif is_edit and not spec.get("theme"): try: from pptx import Presentation as _Prs _prs_check = _Prs(existing_path) _detected = False _bg_image_path = None # Check each existing slide for background_skin or solid dark fill for _slide_check in _prs_check.slides: # Strategy 1: background_skin picture → extract image and reuse for _sh in _slide_check.shapes: if _sh.name == "background_skin": _detected = True try: _img = _sh.image _ext = _img.ext or "jpg" _bg_image_path = os.path.join( os.path.dirname(existing_path), f"_inherited_bg.{_ext}" ) with open(_bg_image_path, "wb") as _f: _f.write(_img.blob) except Exception: pass break if _detected: break # Strategy 2: solid dark fill try: _bg = _slide_check.background.fill if str(_bg.type) == "SOLID (1)": _rgb = _bg.fore_color.rgb if (int(_rgb[0]) + int(_rgb[1]) + int(_rgb[2])) / 765.0 < 0.4: _detected = True break except Exception: pass # Strategy 3: white/light font → dark bg for _sh in _slide_check.shapes: if _sh.has_text_frame and _sh.text_frame.text.strip(): try: _fc = _sh.text_frame.paragraphs[0].runs[0].font.color.rgb if (int(_fc[0]) + int(_fc[1]) + int(_fc[2])) / 765.0 > 0.7: _detected = True except Exception: pass break if _detected: break if _detected: is_dark = True theme = "dark" inherited_bg_image = _bg_image_path print(f"[pptx_gen] edit: auto-detected dark theme, bg_image={_bg_image_path}", file=sys.stderr, flush=True) except Exception: pass # Create project folder (no separate images/ subdir — images go directly in project dir) if is_edit: project_dir = os.path.dirname(existing_path) else: project_dir = os.path.join(workspace_path, project_slug) os.makedirs(project_dir, exist_ok=True) print(f"[pptx_gen] generate: project_dir='{project_dir}'", file=sys.stderr, flush=True) # Log slide specs with image info before processing for i, s in enumerate(slides_spec): ip = s.get("image_path") iu = s.get("image_url") if ip or iu: print(f"[pptx_gen] slide {i+1}: type={s.get('type','?')} image_path='{ip}' image_url='{iu[:60] if iu else ''}'", file=sys.stderr, flush=True) # Warn about any remaining image_url slides (downloading is now handled by TypeScript) for s in slides_spec: if s.get("image_url") and not s.get("image_path"): warnings.append(f"image_url not downloaded (TypeScript should handle this): {s['image_url'][:80]}") s.pop("image_url", None) if is_edit: output_path = existing_path else: output_path = os.path.join(project_dir, filename) # Font sizes: merge spec.font_sizes over defaults spec_fs = spec.get("font_sizes") or {} fs = {**DEFAULT_FONT_SIZES, **spec_fs} # Template: resolve and apply tpl_name = spec.get("template") or "business" tpl = resolve_template(tpl_name) tpl_colors = get_template_colors(tpl) # Determine base colors: dark theme overrides template if is_dark: colors = COLORS_DARK else: colors = tpl_colors if tpl_colors else COLORS_LIGHT # Create or open existing presentation if is_edit: prs = Presentation(existing_path) else: prs = Presentation() prs.slide_width = SLIDE_W prs.slide_height = SLIDE_H start_slide_num = 0 # Collect replace_index targets (1-based → 0-based) replace_targets = {} # slide_spec_index → zero-based position to replace for i, s in enumerate(slides_spec): ri = s.get("replace_index") if ri is not None: pos = int(ri) - 1 if is_edit and 0 <= pos < len(prs.slides): replace_targets[i] = pos else: warnings.append(f"replace_index {ri} out of range — slide will be appended instead") # Track the sldIdLst entry added for each spec index (for post-pass reordering) spec_sld_entries = {} # spec_index → sldIdLst XML element _SLIDE_TYPES = {"title", "content", "section", "image", "table", "chart", "timeline"} for spec_i, slide_spec in enumerate(slides_spec): # Normalize spec variants produced by different models slide_spec = dict(slide_spec) # shallow copy — don't mutate original # 1. Infer `type` from `layout` when missing (e.g. Kimi uses layout="chart") if not slide_spec.get("type"): raw_layout = (slide_spec.get("layout") or "").lower() if raw_layout in _SLIDE_TYPES: slide_spec["type"] = raw_layout slide_spec.pop("layout", None) # 2. camelCase chartType → chart_type if "chartType" in slide_spec and "chart_type" not in slide_spec: slide_spec["chart_type"] = slide_spec["chartType"] # 3. Nested table object → flatten (table / table_data both supported) for _tbl_key in ("table", "table_data"): if _tbl_key in slide_spec and isinstance(slide_spec.get(_tbl_key), dict): tbl = slide_spec[_tbl_key] if "headers" not in slide_spec and "headers" in tbl: slide_spec["headers"] = tbl["headers"] if "rows" not in slide_spec and "rows" in tbl: slide_spec["rows"] = tbl["rows"] # 4. content field is a list → treat as bullets if isinstance(slide_spec.get("content"), list) and "bullets" not in slide_spec: slide_spec["bullets"] = [str(b) for b in slide_spec["content"]] slide_spec.pop("content", None) # 5. Sanitize text fields — remove control chars (VT→space, etc.) for _tf in ("title", "subtitle", "body", "content", "notes"): if _tf in slide_spec and slide_spec[_tf]: slide_spec[_tf] = clean_text(slide_spec[_tf]) if "bullets" in slide_spec and isinstance(slide_spec["bullets"], list): slide_spec["bullets"] = [clean_text(b) for b in slide_spec["bullets"]] slide_type = slide_spec.get("type") or "content" try: # Resolve background skin/image for this slide bg_name = slide_spec.get("background") or default_skin or "" bg_img_path = slide_spec.get("bg_image") or "" # workspace-relative image path bg_image = None slide_is_dark = is_dark if bg_img_path: # Explicit image background from editor picker (workspace-relative path) bg_path, bg_dark = resolve_skin_bg(bg_img_path, project_dir, workspace_path) if bg_path: bg_image = bg_path slide_is_dark = bg_dark or is_dark elif bg_name: bg_path, bg_dark = resolve_skin_bg(bg_name, project_dir, workspace_path) if bg_path: bg_image = bg_path slide_is_dark = bg_dark or is_dark # Inherit background image from existing presentation if none specified if not bg_image and inherited_bg_image and os.path.exists(inherited_bg_image): bg_image = inherited_bg_image slide_is_dark = is_dark # use global theme, not always-dark # Pick colors for this slide: dark skin → light text on dark bg if slide_is_dark and not is_dark: slide_colors = COLORS_DARK elif not slide_is_dark and is_dark: slide_colors = tpl_colors if tpl_colors else COLORS_LIGHT else: slide_colors = colors # Apply per-slide custom font / text colors _title_color = slide_spec.get("title_color") or "" _body_color = slide_spec.get("body_color") or "" _font_family = slide_spec.get("font_family") or "" if _title_color or _body_color or _font_family: slide_colors = dict(slide_colors) # shallow copy — don't mutate global if _title_color: slide_colors["title"] = _title_color.lstrip("#") slide_colors["subtitle"] = _title_color.lstrip("#") if _body_color: slide_colors["body"] = _body_color.lstrip("#") if _font_family: slide_spec = dict(slide_spec) slide_spec["_font_override"] = _font_family # Apply image filter (blur / grayscale / contrast / round) _dbg_filter = slide_spec.get("img_filter") _dbg_ipath = slide_spec.get("image_path") print(f"[pptx_gen] slide img_filter={_dbg_filter!r} image_path={_dbg_ipath!r}", file=sys.stderr, flush=True) if _dbg_filter and _dbg_ipath: _resolved_for_fx = resolve_image_path(slide_spec["image_path"], project_dir, workspace_path) if os.path.exists(_resolved_for_fx): _fx_path = apply_img_filter(_resolved_for_fx, slide_spec) if _fx_path != _resolved_for_fx: slide_spec = dict(slide_spec) slide_spec["image_path"] = _fx_path else: print(f"[pptx_gen] img_filter SKIP: resolved path not found: {_resolved_for_fx!r}", file=sys.stderr, flush=True) if slide_type == "section": slide = make_section_slide(prs, slide_spec, slide_colors, fs, bg_image) elif slide_type == "title": slide = make_title_slide(prs, slide_spec, spec, slide_colors, slide_is_dark, fs, bg_image, project_dir, workspace_path, warnings) elif slide_type == "image": slide = make_image_slide(prs, slide_spec, project_dir, workspace_path, slide_colors, warnings, slide_is_dark, fs, bg_image) elif slide_type == "table": slide = make_table_slide(prs, slide_spec, slide_colors, slide_is_dark, fs, warnings, bg_image) elif slide_type == "chart": slide = make_chart_slide(prs, slide_spec, slide_colors, slide_is_dark, fs, warnings, bg_image) elif slide_type == "timeline": slide = make_timeline_slide(prs, slide_spec, slide_colors, slide_is_dark, fs, warnings, bg_image) else: # content or default slide = make_content_slide(prs, slide_spec, project_dir, workspace_path, slide_colors, slide_is_dark, fs, warnings, bg_image) # Speaker notes notes = slide_spec.get("notes") if notes and hasattr(slide, "notes_slide"): slide.notes_slide.notes_text_frame.text = notes # Track the newly added sldIdLst entry for replacement slides if spec_i in replace_targets: spec_sld_entries[spec_i] = prs.slides._sldIdLst[-1] except Exception as e: err_msg = f"Failed to generate slide (type={slide_type}, title={slide_spec.get('title','')[:30]}): {e}" warnings.append(err_msg) print(f"[pptx_gen] ERROR: {err_msg}", file=sys.stderr, flush=True) # Post-pass: now that new slides are added (safe filenames), remove old slides # and move new ones into their target positions. # IMPORTANT: add-before-remove avoids _next_slide_partname collisions. if replace_targets and is_edit: sldIdLst = prs.slides._sldIdLst # Remove old slides in reverse position order (keeps lower indices stable) for spec_i in sorted(replace_targets.keys(), key=lambda k: replace_targets[k], reverse=True): pos = replace_targets[spec_i] if 0 <= pos < len(prs.slides): slide_part = prs.slides[pos].part for rId, rel in list(prs.part.rels.items()): if rel.reltype.endswith('/slide') and rel.target_partname == slide_part.partname: prs.part.drop_rel(rId) break sldIdLst.remove(sldIdLst[pos]) # Move new slides from end to target positions (ascending order) for spec_i, target_pos in sorted(replace_targets.items(), key=lambda kv: kv[1]): entry = spec_sld_entries.get(spec_i) if entry is not None and entry in sldIdLst: sldIdLst.remove(entry) sldIdLst.insert(target_pos, entry) # Save try: prs.save(output_path) except Exception as e: print(f"[pptx_gen] ERROR: Failed to save PPTX to {output_path}: {e}", file=sys.stderr, flush=True) raise total_slides = len(prs.slides) added_slides = len(slides_spec) action = "updated" if (existing_path and os.path.exists(existing_path)) else "created" # Build download and preview links rel_for_link = os.path.relpath(output_path, workspace_path).replace("\\", "/") from urllib.parse import quote encoded_path = "/".join(quote(s, safe='') for s in rel_for_link.split("/")) download_url = f"/api/files/{encoded_path}" preview_url = f"/api/pptx/preview?path={quote(rel_for_link)}" # Compose stdout with download link so UI can render it inline # Do NOT include [Preview slides] link — it causes the model to think more work is needed stdout_text = f"Presentation {action}: [{os.path.basename(output_path)}]({download_url}) ({total_slides} total slides, {added_slides} added, folder: {os.path.basename(project_dir)}/)\nEXACT PATH (use this verbatim for future edits): {rel_for_link}" if warnings: stdout_text += "\n\nWarnings:\n" + "\n".join(f"- {w}" for w in warnings) if any("download" in w.lower() or "image" in w.lower() for w in warnings): stdout_text += "\n\nNote: Some images could not be embedded and appear as light-gray placeholders. This is expected — do NOT retry." # Create a local download.html page so users can download via browser even when the API gateway isn't rendering the link try: _create_download_page(output_path, download_url, title, total_slides) except Exception: pass return { "success": True, "path": output_path, "folder": project_slug, "filename": os.path.basename(output_path), "slides": total_slides, "added": added_slides, "warnings": warnings, "download_url": download_url, "preview_url": preview_url, "stdout": stdout_text, } # ─── CLI Entry Point ─────────────────────────────────────────────────────────── def main(): parser = argparse.ArgumentParser(description='Generate PPTX from spec JSON') parser.add_argument('spec_path', help='Path to spec JSON file') parser.add_argument('workspace_path', nargs='?', default=os.getcwd(), help='Workspace directory') parser.add_argument('--skin-dir', default=None, help='Override skin directory path') parser.add_argument('--template-dir', default=None, help='Override template directory path') args = parser.parse_args() spec_path = args.spec_path workspace_path = args.workspace_path # Override paths if provided by caller (keeps paths.ts as single source of truth) global SKIN_DIR, TEMPLATE_DIR, SKIN_INDEX if args.skin_dir: SKIN_DIR = args.skin_dir SKIN_INDEX = _build_skin_index() if args.template_dir: TEMPLATE_DIR = args.template_dir try: with open(spec_path, "r", encoding="utf-8") as f: spec = json.load(f) except Exception as e: print(json.dumps({"success": False, "error": f"Failed to read spec file: {e}"})) sys.exit(1) try: result = generate(spec, workspace_path) print(json.dumps(result, ensure_ascii=False)) except Exception as e: import traceback traceback.print_exc(file=sys.stderr) print(json.dumps({"success": False, "error": str(e)})) sys.exit(1) if __name__ == "__main__": main()