- Fix pptx-wizard slide editor modal opening off-screen (header clipped) when a saved drag position is restored on a fresh page load: clear the CSS centering transform and clamp coordinates into the viewport. - Add pptx-wizard.html and python-runner.html web UIs. - Bump version to 2.8.1. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1630 lines
69 KiB
Python
1630 lines
69 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
|
||
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 <a:br/> 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:]:
|
||
# <a:br/> 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 <a:br/> 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 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):
|
||
"""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_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 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=colors["title"],
|
||
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=colors["subtitle"],
|
||
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
|
||
add_shape_rect(slide, Inches(0), bar_y, SLIDE_W, bar_h, "000000")
|
||
bar_shape = slide.shapes[-1]
|
||
bar_shape.fill.fore_color.rgb = hex_to_rgb("000000")
|
||
bar_shape.fill.transparency = 0.45
|
||
# 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"""<!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"
|
||
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)
|
||
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() |