Release 2.8.1: pptx-wizard slide editor + fixes

- 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>
This commit is contained in:
kim
2026-05-30 20:14:01 +09:00
co-authored by Claude Opus 4.8
parent 22bfc3ac27
commit 957a13f98c
17 changed files with 4867 additions and 103 deletions
+275 -39
View File
@@ -51,6 +51,9 @@ 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 ──────────────────────────────────────────────────────────────────────
@@ -109,6 +112,7 @@ 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',
}
@@ -228,6 +232,76 @@ def is_dark_skin(name):
# ─── 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()
@@ -252,19 +326,34 @@ def get_font() -> str:
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."""
"""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
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()
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
@@ -276,8 +365,12 @@ def _fit_bullet_font_size(items, width_emu, height_emu, max_pt):
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)))
total = sum(max(1, math.ceil((len(str(it)) + 2) / chars_per_line)) * line_h
for it in items)
# 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
@@ -285,15 +378,18 @@ def _fit_bullet_font_size(items, width_emu, height_emu, max_pt):
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."""
"""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
fitted_pt = _fit_bullet_font_size(items, width, height, font_size.pt)
# 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:
@@ -301,22 +397,76 @@ def add_bullet_list(slide, left, top, width, height, items, font_size=Pt(16),
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)
p.level = 0
# Add bullet character manually for reliable rendering
bullet_run = p.add_run()
bullet_run.text = "● "
bullet_run.font.size = fitted_fs
bullet_run.font.color.rgb = hex_to_rgb(bullet_color_hex)
bullet_run.font.name = font_name or get_font()
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 = str(item)
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
@@ -339,6 +489,11 @@ def set_slide_bg_image(slide, image_path: str):
)
# 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):
@@ -519,10 +674,18 @@ def _text_weight(slide_spec: dict):
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
@@ -544,17 +707,21 @@ def make_title_slide(prs, slide_spec, spec, colors, is_dark, fs, bg_image=None):
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)
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)
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 + Inches(1.3), Inches(10.3), Inches(0.7),
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)
alignment=PP_ALIGN.CENTER, font_name=_title_font)
return slide
@@ -594,6 +761,14 @@ def make_content_slide(prs, slide_spec, project_dir, workspace_path, colors, is_
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:
@@ -609,9 +784,10 @@ def make_content_slide(prs, slide_spec, project_dir, workspace_path, colors, is_
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["slide_title"]),
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")
@@ -622,13 +798,17 @@ def make_content_slide(prs, slide_spec, project_dir, workspace_path, colors, is_
body_text, font_size=Pt(fs["body"]), color_hex="E0E0E0")
return slide
content_y = Inches(1.5) if has_title else Inches(0.5)
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(fs["slide_title"]),
color_hex=colors["title"], bold=True)
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"])
@@ -698,17 +878,33 @@ def make_content_slide(prs, slide_spec, project_dir, workspace_path, colors, is_
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"])
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"])
body_text, font_size=body_fs, color_hex=colors["body"],
font_name=_slide_font)
# ── Image column ──────────────────────────────────────────────────────────
if has_image and img_col_x is not None:
# ── Image column / free position ─────────────────────────────────────────
if has_image:
resolved = resolve_image_path(slide_spec["image_path"], project_dir, workspace_path)
add_image_or_placeholder(slide, resolved,
img_col_x, content_y, img_col_w, content_h,
warnings, color_hex=colors["body"])
# 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
@@ -1226,22 +1422,33 @@ def generate(spec: dict, workspace_path: str) -> dict:
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_name:
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
else:
# 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):
@@ -1256,6 +1463,35 @@ def generate(spec: dict, workspace_path: str) -> dict:
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":