Refactor project layout and fix workspace/subagent paths
- Remove global workspace (workspace/) — all data migrated to .smallclaw/ - Move ppt/ skins & templates to .smallclaw/skills/presenter/ppt/ - Add .smallclaw/templates/ for user bootstrap files - Move dental_images to .smallclaw/databases/, update 1,485 DB entries - Fix subagent store paths to use SMALLCLAW_DATA_DIR instead of workspace.path - Per-user Telegram bots: MultiUserTelegramManager, vault-stored tokens - Fix Telegram token persistence (vault fallback, strip from config.json) - Fix session migration: delete global originals even when skipping - Delete unused root images, MD docs, tests/, smallclawworkspace/ artifacts - Add meteorologist skill, counselor skill, presenter skill scaffold - Strip workspace.path from config.json (was causing path confusion) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+442
-10
@@ -557,14 +557,18 @@ def make_title_slide(prs, slide_spec, spec, colors, is_dark, fs, bg_image=None):
|
||||
return slide
|
||||
|
||||
|
||||
def make_section_slide(prs, slide_spec, colors, fs):
|
||||
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])
|
||||
add_shape_rect(slide, Inches(0), Inches(0), SLIDE_W, SLIDE_H, colors.get("accent", "1668E3"))
|
||||
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="FFFFFF",
|
||||
title, font_size=Pt(fs["section"]), color_hex=text_color,
|
||||
bold=True, alignment=PP_ALIGN.CENTER)
|
||||
return slide
|
||||
|
||||
@@ -627,6 +631,40 @@ def make_content_slide(prs, slide_spec, project_dir, workspace_path, colors, is_
|
||||
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)
|
||||
@@ -712,6 +750,259 @@ def make_image_slide(prs, slide_spec, project_dir, workspace_path, colors, warni
|
||||
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):
|
||||
@@ -760,6 +1051,8 @@ def generate(spec: dict, workspace_path: str) -> dict:
|
||||
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"
|
||||
@@ -772,6 +1065,73 @@ def generate(spec: dict, workspace_path: str) -> dict:
|
||||
|
||||
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:
|
||||
@@ -809,9 +1169,6 @@ def generate(spec: dict, workspace_path: str) -> dict:
|
||||
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
|
||||
@@ -827,7 +1184,47 @@ def generate(spec: dict, workspace_path: str) -> dict:
|
||||
prs.slide_height = SLIDE_H
|
||||
start_slide_num = 0
|
||||
|
||||
for slide_spec in slides_spec:
|
||||
# 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)
|
||||
|
||||
slide_type = slide_spec.get("type") or "content"
|
||||
|
||||
try:
|
||||
@@ -845,6 +1242,11 @@ def generate(spec: dict, workspace_path: str) -> dict:
|
||||
# Not a skin name — might be a file path already resolved
|
||||
pass
|
||||
|
||||
# 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
|
||||
@@ -853,14 +1255,19 @@ def generate(spec: dict, workspace_path: str) -> dict:
|
||||
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)
|
||||
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)
|
||||
|
||||
@@ -868,11 +1275,36 @@ def generate(spec: dict, workspace_path: str) -> dict:
|
||||
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)
|
||||
@@ -893,7 +1325,7 @@ def generate(spec: dict, workspace_path: str) -> dict:
|
||||
|
||||
# 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)}/)"
|
||||
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):
|
||||
|
||||
Reference in New Issue
Block a user