Files
homeclaw/scripts/pptx_gen.py
T
2026-04-29 11:45:59 +09:00

756 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""pptx_gen.py — Generate PowerPoint files using python-pptx.
Usage: python pptx_gen.py <spec_json_path> [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
# 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
# ─── Paths ──────────────────────────────────────────────────────────────────────
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
PROJECT_ROOT = os.path.join(SCRIPT_DIR, '..')
SKIN_DIR = os.path.join(PROJECT_ROOT, 'ppt', 'skin')
TEMPLATE_DIR = os.path.join(PROJECT_ROOT, '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": 36,
"subtitle": 18,
"slide_title": 24,
"body": 16,
"bullets": 16,
"section": 32,
"image_title": 22,
}
# Slide dimensions (LAYOUT_WIDE = 13.333 x 7.5 inches)
SLIDE_W = Inches(13.333)
SLIDE_H = Inches(7.5)
# 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',
'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 slugify(text: str) -> str:
"""Convert a title to a safe folder name."""
s = str(text or "presentation").strip()
s = re.sub(r"\.pptx$", "", s, flags=re.IGNORECASE)
s = re.sub(r'[\(\)<>:"/\\|?*]+', "_", s)
s = re.sub(r"\s+", "_", s)
s = re.sub(r"_+", "_", s)
s = s.strip("_")
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 with a single paragraph."""
txBox = slide.shapes.add_textbox(left, top, width, height)
tf = txBox.text_frame
tf.word_wrap = True
p = tf.paragraphs[0]
p.alignment = alignment
run = p.add_run()
run.text = text
run.font.size = font_size
run.font.bold = bold
run.font.color.rgb = hex_to_rgb(color_hex)
run.font.name = font_name or get_font()
return txBox
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."""
txBox = slide.shapes.add_textbox(left, top, width, height)
tf = txBox.text_frame
tf.word_wrap = True
for i, item in enumerate(items):
if i == 0:
p = tf.paragraphs[0]
else:
p = tf.add_paragraph()
p.space_after = Pt(4)
p.level = 0
# Add bullet character manually for reliable rendering
bullet_run = p.add_run()
bullet_run.text = "● "
bullet_run.font.size = font_size
bullet_run.font.color.rgb = hex_to_rgb(bullet_color_hex)
bullet_run.font.name = font_name or get_font()
text_run = p.add_run()
text_run.text = str(item)
text_run.font.size = font_size
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
slide.shapes.add_picture(
image_path, Inches(0), Inches(0), SLIDE_W, SLIDE_H
)
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 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
"""
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 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 red placeholder if not found.
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):
try:
# Read native dimensions and compute aspect-ratio-preserving size
from PIL import Image as PILImage
with PILImage.open(image_path) as img:
img_w, img_h = img.size
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(image_path, center_x, center_y, fit_w, fit_h)
return
except Exception:
# If image dimension reading fails, try with native size (no stretching)
try:
pic = slide.shapes.add_picture(image_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)
return
except Exception:
pass
add_text_box(slide, left, top, max_width, max_height,
f"[Image not found: {image_path}]",
font_size=Pt(14), color_hex=color_hex,
alignment=PP_ALIGN.CENTER)
warnings.append(f"Image not found: {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):
return image_path
candidates = [
os.path.join(project_dir, image_path),
os.path.join(workspace_path, image_path),
]
for c in candidates:
if os.path.exists(c):
return c
# Fallback: search by filename in all subdirectories
basename = os.path.basename(image_path).lower()
for root, dirs, files in os.walk(workspace_path):
for f in files:
if f.lower() == basename:
return os.path.join(root, f)
return candidates[0] # return first even if missing (placeholder will show)
# ─── Slide Generators ──────────────────────────────────────────────────────────
def make_title_slide(prs, slide_spec, spec, colors, is_dark, fs, bg_image=None):
"""Generate a title slide."""
slide = prs.slides.add_slide(prs.slide_layouts[6]) # blank layout
# Background
if bg_image:
set_slide_bg_image(slide, bg_image)
elif is_dark:
set_slide_bg(slide, colors["background"])
title = slide_spec.get("title") or spec.get("title") or "Untitled"
y_pos = Inches(2.8) if bg_image else Inches(2.5)
add_text_box(slide, Inches(0.8), y_pos, Inches(11.7), Inches(1.2),
title, font_size=Pt(fs["title"]), color_hex=colors["title"],
bold=True, alignment=PP_ALIGN.CENTER)
subtitle = slide_spec.get("subtitle")
if subtitle:
add_text_box(slide, Inches(1.5), y_pos + Inches(1.3), Inches(10.3), Inches(0.7),
subtitle, font_size=Pt(fs["subtitle"]), color_hex=colors["subtitle"],
alignment=PP_ALIGN.CENTER)
return slide
def make_section_slide(prs, slide_spec, colors, fs):
"""Generate a section divider slide."""
slide = prs.slides.add_slide(prs.slide_layouts[6])
add_shape_rect(slide, Inches(0), Inches(0), SLIDE_W, SLIDE_H, colors.get("accent", "1668E3"))
title = slide_spec.get("title") or "Section"
add_text_box(slide, Inches(1), Inches(2.5), Inches(11.3), Inches(1.5),
title, font_size=Pt(fs["section"]), color_hex="FFFFFF",
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 with title + bullets/body."""
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"))
content_y = Inches(1.5) if has_title else Inches(0.5)
if has_title:
add_text_box(slide, Inches(0.6), Inches(0.4), Inches(12.1), Inches(0.8),
slide_spec["title"], font_size=Pt(fs["slide_title"]),
color_hex=colors["title"], bold=True)
# Accent underline
add_shape_rect(slide, Inches(0.6), Inches(1.15), Inches(2), Inches(0.04),
colors["accent"])
# Get bullet items (check aliases)
bullet_items = slide_spec.get("bullets") or slide_spec.get("bullet_points")
body_text = slide_spec.get("body") or slide_spec.get("content")
# Per-slide font_size override
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, Inches(0.8), content_y, Inches(11.7), Inches(5),
bullet_items, font_size=bullet_fs,
color_hex=colors["body"],
bullet_color_hex=colors["accent"])
elif body_text:
add_text_box(slide, Inches(0.8), content_y, Inches(11.7), Inches(5),
body_text, font_size=body_fs, color_hex=colors["body"])
# Image on content slide (right side)
image_path = slide_spec.get("image_path")
if image_path:
resolved = resolve_image_path(image_path, project_dir, workspace_path)
img_y = Inches(1.5) if has_title else Inches(0.5)
img_max_h = Inches(5) if has_title else Inches(6)
add_image_or_placeholder(slide, resolved,
Inches(7), img_y, Inches(5.5), img_max_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)
# Image slides use full-slide bounding box with cover mode
# so photos completely fill the available area (cropping if needed)
if has_title:
img_y = Inches(1.1)
max_h = SLIDE_H - Inches(1.1)
else:
img_y = Inches(0)
max_h = SLIDE_H
add_image_or_placeholder(slide, resolved,
Inches(0), img_y, SLIDE_W, max_h,
warnings, fit_mode="cover")
return slide
# ─── 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"
slides_spec = spec.get("slides") or []
theme = spec.get("theme") or "light"
is_dark = theme == "dark"
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)
# 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)
# Download image_url slides into project_dir
# Set a browser-like User-Agent so image hosts (Wikipedia, Unsplash, etc.) don't block us
from urllib.request import build_opener, Request
_img_opener = build_opener()
_img_opener.addheaders = [('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36')]
for i, slide_spec in enumerate(slides_spec):
image_url = slide_spec.get("image_url")
if image_url:
try:
ext = os.path.splitext(image_url.split("/")[-1].split("?")[0])[1] or ".jpg"
fname = f"slide{i + 1}_image{ext}"
dest = os.path.join(project_dir, fname)
req = Request(image_url, headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'})
with _img_opener.open(req, timeout=30) as resp:
with open(dest, 'wb') as f:
f.write(resp.read())
slide_spec["image_path"] = fname
except Exception as e:
err_msg = f"Failed to download image from {image_url}: {e}"
warnings.append(err_msg)
print(f"[pptx_gen] WARNING: {err_msg}", file=sys.stderr, flush=True)
slide_spec.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)
# Default skin from spec or empty
default_skin = spec.get("default_skin") or ""
# 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
for slide_spec in slides_spec:
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_image = None
slide_is_dark = is_dark
if 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
else:
# Not a skin name — might be a file path already resolved
pass
# 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
# Section slides always use accent fill — no background image
if slide_type == "section":
slide = make_section_slide(prs, slide_spec, slide_colors, fs)
elif slide_type == "title":
slide = make_title_slide(prs, slide_spec, spec, slide_colors, slide_is_dark, fs, bg_image)
elif slide_type == "image":
slide = make_image_slide(prs, slide_spec, project_dir, workspace_path,
slide_colors, warnings, slide_is_dark, fs, 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
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)
# 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
# Build download and preview links
relative_path = f"{project_slug}/{filename}"
from urllib.parse import quote
encoded_path = "/".join(quote(s, safe='') for s in relative_path.split("/"))
download_url = f"/api/files/{encoded_path}"
preview_url = f"/api/pptx/preview?path={quote(relative_path)}"
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)}/)"
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 appear as red placeholders. This is expected — do NOT retry."
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():
if len(sys.argv) < 2:
print(json.dumps({"success": False, "error": "Usage: python pptx_gen.py <spec_json_path> [workspace_path]"}))
sys.exit(1)
spec_path = sys.argv[1]
workspace_path = sys.argv[2] if len(sys.argv) > 2 else os.getcwd()
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()