1030 lines
42 KiB
Python
1030 lines
42 KiB
Python
#!/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 _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 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:
|
||
resolved = os.path.join(root, f)
|
||
print(f"[pptx_gen] resolve_image_path: {image_path} -> {resolved} (found by filename search)", 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)
|
||
|
||
|
||
# ─── 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
|
||
|
||
|
||
# ─── Image Download ─────────────────────────────────────────────────────────────
|
||
|
||
# Known defunct/redirect-heavy image domains that need replacement
|
||
_DEFUNCT_IMAGE_DOMAINS = {
|
||
'source.unsplash.com', # Shut down — redirect to images.unsplash.com
|
||
'unsplash.it', # Redirects to picsum.photos
|
||
}
|
||
|
||
# Free image APIs that don't require auth and return actual image bytes
|
||
_FREE_IMAGE_APIS = {
|
||
'picsum.photos': 'https://picsum.photos/1200/800',
|
||
'dummyimage.com': 'https://dummyimage.com/1200x800/cccccc/666666.png&text=Image',
|
||
}
|
||
|
||
|
||
# ─── Image Download Helpers ─────────────────────────────────────────────────────
|
||
|
||
# Byte-level magic numbers for image format detection
|
||
_IMAGE_SIGNATURES = {
|
||
b'\xff\xd8\xff': '.jpg',
|
||
b'\x89PNG\r\n\x1a\n': '.png',
|
||
b'GIF87a': '.gif',
|
||
b'GIF89a': '.gif',
|
||
b'RIFF': '.webp', # WebP starts with RIFF...WEBP
|
||
b'BM': '.bmp',
|
||
b'II\x2a\x00': '.tiff',
|
||
b'MM\x00\x2a': '.tiff',
|
||
}
|
||
|
||
def _detect_image_ext(data: bytes) -> str:
|
||
"""Detect image format from magic bytes; returns extension or '.jpg' as fallback."""
|
||
for sig, ext in _IMAGE_SIGNATURES.items():
|
||
if data[:len(sig)] == sig:
|
||
return ext
|
||
return '.jpg'
|
||
|
||
def _detect_image_ext_from_headers(content_type: str) -> str:
|
||
"""Guess extension from Content-Type header."""
|
||
ct = (content_type or '').lower().strip()
|
||
mapping = {
|
||
'image/jpeg': '.jpg', 'image/jpg': '.jpg',
|
||
'image/png': '.png', 'image/gif': '.gif',
|
||
'image/webp': '.webp', 'image/bmp': '.bmp',
|
||
'image/tiff': '.tiff',
|
||
}
|
||
return mapping.get(ct.split(';')[0].strip(), '')
|
||
|
||
|
||
def _download_images(slides_spec, project_dir, warnings, max_retries=3):
|
||
"""Download image_url slides into project_dir with retry, browser headers, and validation."""
|
||
import time
|
||
from urllib.parse import urlparse, urlencode, urlunparse
|
||
try:
|
||
import requests
|
||
_HAS_REQUESTS = True
|
||
except ImportError:
|
||
_HAS_REQUESTS = False
|
||
|
||
# Pre-process: fix defunct URLs and clean up known-bad domains
|
||
for slide_spec in slides_spec:
|
||
url = slide_spec.get("image_url", "")
|
||
if not url:
|
||
continue
|
||
parsed = urlparse(url)
|
||
domain = (parsed.netloc or '').lower()
|
||
|
||
# source.unsplash.com is shut down — rewrite to images.unsplash.com
|
||
if domain == 'source.unsplash.com':
|
||
url = url.replace('source.unsplash.com', 'images.unsplash.com', 1)
|
||
slide_spec["image_url"] = url
|
||
domain = 'images.unsplash.com'
|
||
|
||
# Flag defunct domains as warnings
|
||
if domain in _DEFUNCT_IMAGE_DOMAINS:
|
||
warnings.append(f"Image URL uses a defunct service ({domain}). Slide may have a placeholder image.")
|
||
slide_spec.pop("image_url", None)
|
||
continue
|
||
|
||
# Build a browser-like session
|
||
if _HAS_REQUESTS:
|
||
session = requests.Session()
|
||
session.headers.update({
|
||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||
'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
|
||
'Accept-Language': 'en-US,en;q=0.9,ko;q=0.8',
|
||
'Accept-Encoding': 'gzip, deflate, br',
|
||
'Sec-Fetch-Dest': 'image',
|
||
'Sec-Fetch-Mode': 'no-cors',
|
||
'Sec-Fetch-Site': 'cross-site',
|
||
'Cache-Control': 'no-cache',
|
||
})
|
||
else:
|
||
from urllib.request import build_opener, Request
|
||
from urllib.error import URLError, HTTPError
|
||
_img_opener = build_opener()
|
||
_ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
|
||
_img_opener.addheaders = [('User-Agent', _ua)]
|
||
|
||
for i, slide_spec in enumerate(slides_spec):
|
||
image_url = slide_spec.get("image_url")
|
||
if not image_url:
|
||
continue
|
||
|
||
# Unsplash-specific: add resize params for reliable download
|
||
download_url = image_url
|
||
if 'images.unsplash.com' in image_url or 'unsplash.com' in image_url:
|
||
sep = '&' if '?' in image_url else '?'
|
||
# Request a reasonable size with good quality; Unsplash respects these params
|
||
download_url = f"{image_url}{sep}w=1200&q=80&auto=format"
|
||
|
||
# Determine file extension from URL, falling back to detection
|
||
url_path = download_url.split("?")[0]
|
||
ext = os.path.splitext(url_path.split("/")[-1])[1].lower()
|
||
if ext not in ('.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff'):
|
||
ext = '' # Will be determined from response
|
||
|
||
last_error = None
|
||
for attempt in range(1, max_retries + 1):
|
||
try:
|
||
if _HAS_REQUESTS:
|
||
resp = session.get(download_url, timeout=30, allow_redirects=True)
|
||
if resp.status_code == 403 and 'images.unsplash.com' in download_url:
|
||
# Unsplash might need a source param; retry with source identifier
|
||
sep = '&' if '?' in download_url else '?'
|
||
retry_url = f"{download_url}{sep}source=smallclaw"
|
||
resp = session.get(retry_url, timeout=30, allow_redirects=True)
|
||
if resp.status_code != 200:
|
||
raise ValueError(f"HTTP {resp.status_code}")
|
||
data = resp.content
|
||
content_type = resp.headers.get('Content-Type', '')
|
||
else:
|
||
req = Request(download_url, headers={
|
||
'User-Agent': _ua,
|
||
'Accept': 'image/*,*/*;q=0.8',
|
||
})
|
||
with _img_opener.open(req, timeout=30) as resp_obj:
|
||
if resp_obj.getcode() != 200:
|
||
raise HTTPError(download_url, resp_obj.getcode(), f"HTTP {resp_obj.getcode()}", resp_obj.headers, None)
|
||
data = resp_obj.read()
|
||
content_type = resp_obj.headers.get('Content-Type', '')
|
||
|
||
# Validate: must have some content
|
||
if not data or len(data) < 16:
|
||
raise ValueError(f"Response too small ({len(data) if data else 0} bytes)")
|
||
|
||
# Validate: reject clearly non-image content (HTML pages, etc.)
|
||
ct_lower = (content_type or '').lower().split(';')[0].strip()
|
||
if ct_lower and ct_lower not in ('image/jpeg', 'image/jpg', 'image/png',
|
||
'image/gif', 'image/webp', 'image/bmp', 'image/tiff',
|
||
'application/octet-stream', 'binary/octet-stream', ''):
|
||
# If content-type is text/html or similar, it's not an image
|
||
if ct_lower.startswith('text/') or ct_lower in ('application/html', 'application/xml'):
|
||
raise ValueError(f"Non-image Content-Type: {content_type}")
|
||
# For other unknown types, check magic bytes instead of rejecting
|
||
|
||
# Determine extension from content or magic bytes
|
||
if not ext:
|
||
ext_from_ct = _detect_image_ext_from_headers(content_type)
|
||
ext_from_magic = _detect_image_ext(data)
|
||
ext = ext_from_ct or ext_from_magic or '.jpg'
|
||
|
||
fname = f"slide{i + 1}_image{ext}"
|
||
dest = os.path.join(project_dir, fname)
|
||
with open(dest, 'wb') as f:
|
||
f.write(data)
|
||
|
||
slide_spec["image_path"] = fname
|
||
last_error = None
|
||
print(f"[pptx_gen] Downloaded image for slide {i+1}: {len(data)} bytes -> {fname}", file=sys.stderr, flush=True)
|
||
break
|
||
except Exception as e:
|
||
last_error = e
|
||
if attempt < max_retries:
|
||
wait = 2 ** attempt
|
||
print(f"[pptx_gen] Download attempt {attempt}/{max_retries} failed for slide {i+1}: {e}. Retrying in {wait}s...", file=sys.stderr, flush=True)
|
||
time.sleep(wait)
|
||
else:
|
||
print(f"[pptx_gen] Download failed after {max_retries} attempts for slide {i+1}: {e}", file=sys.stderr, flush=True)
|
||
|
||
if last_error:
|
||
domain = ''
|
||
try:
|
||
from urllib.parse import urlparse
|
||
domain = urlparse(image_url).netloc
|
||
except Exception:
|
||
pass
|
||
err_msg = f"Failed to download image for slide {i+1} from {domain or image_url}: {last_error}"
|
||
warnings.append(err_msg)
|
||
|
||
slide_spec.pop("image_url", None)
|
||
|
||
|
||
# ─── 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"""<!DOCTYPE html>
|
||
<html lang="ko">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<title>{title} - 다운로드</title>
|
||
<style>
|
||
body {{ font-family: 'Malgun Gothic', sans-serif; max-width: 600px; margin: 60px auto; padding: 20px; text-align: center; background: #f8f9fa; }}
|
||
.card {{ background: white; border-radius: 12px; padding: 40px 30px; box-shadow: 0 4px 20px rgba(0,0,0,0.08); }}
|
||
h1 {{ color: #1a1a2e; font-size: 24px; margin-bottom: 10px; }}
|
||
p {{ color: #5f6f86; margin-bottom: 30px; }}
|
||
.btn {{ display: inline-block; background: #1668e3; color: white; text-decoration: none; padding: 14px 36px; border-radius: 8px; font-size: 16px; font-weight: bold; transition: background 0.2s; }}
|
||
.btn:hover {{ background: #1255bb; }}
|
||
.meta {{ margin-top: 20px; font-size: 12px; color: #888; }}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="card">
|
||
<h1>{title}</h1>
|
||
<p>슬라이드 {slide_count}장이 준비되었습니다.</p>
|
||
<a class="btn" href="{filename}" download>프레젠테이션 다운로드</a>
|
||
<div class="meta">{pptx_path}</div>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
"""
|
||
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"
|
||
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)
|
||
|
||
# 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)
|
||
|
||
# Download image_url slides into project_dir
|
||
_download_images(slides_spec, project_dir, warnings)
|
||
|
||
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 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():
|
||
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() |