feat: PPT Agent Skill - 专业演示文稿全流程 AI 生成助手
模拟顶级 PPT 设计公司的完整工作流,输出高质量 HTML 演示文稿 + 可编辑矢量 PPTX。 - 6步Pipeline: 需求调研->资料搜集->大纲策划->策划稿->风格+配图+HTML设计稿->后处理 - 8种预置风格 + 7种Bento Grid布局 + 6种卡片类型 - 专业排版系统(7级字号) + 色彩比例法则(60-30-10) + 跨页视觉叙事 - 8种纯CSS数据可视化 + 5种配图融入技法 - HTML->SVG->PPTX 全自动转换管线
This commit is contained in:
205
scripts/html_packager.py
Normal file
205
scripts/html_packager.py
Normal file
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env python3
|
||||
"""HTML 打包工具 -- 将多页 HTML 合并为可翻页的单文件预览
|
||||
|
||||
每页 HTML 放在独立的 iframe srcdoc 中,CSS 完全隔离,零冲突。
|
||||
|
||||
用法:
|
||||
python html_packager.py <slides_directory> [-o output.html] [--title "Title"]
|
||||
python html_packager.py ppt-output/slides/ -o ppt-output/preview.html
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import html as html_module
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def inline_images(html_content: str, html_dir: Path) -> str:
|
||||
"""将 HTML 中引用的本地图片转为 base64 内联。"""
|
||||
def replace_src(match):
|
||||
attr = match.group(1) # src= or url(
|
||||
path_str = match.group(2)
|
||||
closing = match.group(3) # " or )
|
||||
|
||||
# 处理绝对路径和相对路径
|
||||
img_path = Path(path_str)
|
||||
if not img_path.is_absolute():
|
||||
img_path = html_dir / path_str
|
||||
|
||||
if img_path.exists() and img_path.is_file():
|
||||
ext = img_path.suffix.lower().lstrip('.')
|
||||
mime = {'jpg': 'image/jpeg', 'jpeg': 'image/jpeg',
|
||||
'png': 'image/png', 'gif': 'image/gif',
|
||||
'svg': 'image/svg+xml', 'webp': 'image/webp'
|
||||
}.get(ext, f'image/{ext}')
|
||||
data = base64.b64encode(img_path.read_bytes()).decode()
|
||||
return f'{attr}data:{mime};base64,{data}{closing}'
|
||||
return match.group(0)
|
||||
|
||||
# 匹配 src="..." 和 url(...)
|
||||
html_content = re.sub(
|
||||
r'(src=["\'])([^"\']+?)(["\'])',
|
||||
replace_src, html_content)
|
||||
html_content = re.sub(
|
||||
r'(url\(["\']?)([^"\')\s]+?)(["\']?\))',
|
||||
replace_src, html_content)
|
||||
return html_content
|
||||
|
||||
|
||||
def build_preview(slide_files: list, title: str = "PPT Preview") -> str:
|
||||
"""构建可翻页的预览 HTML,每页用独立 iframe 实现 CSS 隔离。"""
|
||||
slides_srcdoc = []
|
||||
|
||||
for f in slide_files:
|
||||
html_dir = Path(f).parent
|
||||
with open(f, "r", encoding="utf-8") as fh:
|
||||
content = fh.read()
|
||||
|
||||
# 内联图片为 base64
|
||||
content = inline_images(content, html_dir)
|
||||
|
||||
# 转义为 srcdoc 安全内容(& -> & " -> ")
|
||||
escaped = html_module.escape(content, quote=True)
|
||||
slides_srcdoc.append(escaped)
|
||||
|
||||
total = len(slides_srcdoc)
|
||||
escaped_title = html_module.escape(title)
|
||||
|
||||
# 生成 iframe 列表
|
||||
iframes = []
|
||||
for i, srcdoc in enumerate(slides_srcdoc):
|
||||
display = "block" if i == 0 else "none"
|
||||
iframes.append(
|
||||
f'<iframe class="slide-frame" id="slide-{i}" '
|
||||
f'style="display:{display}" '
|
||||
f'srcdoc="{srcdoc}" '
|
||||
f'sandbox="allow-same-origin" '
|
||||
f'frameborder="0" scrolling="no"></iframe>'
|
||||
)
|
||||
|
||||
iframes_block = '\n'.join(iframes)
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{escaped_title}</title>
|
||||
<style>
|
||||
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||
body {{
|
||||
background: #0a0a0a;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
}}
|
||||
.toolbar {{
|
||||
position: fixed; top: 0; left: 0; right: 0; height: 48px;
|
||||
background: rgba(10,10,10,0.95); border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||
display: flex; align-items: center; justify-content: center; gap: 16px;
|
||||
z-index: 1000; backdrop-filter: blur(10px);
|
||||
}}
|
||||
.toolbar button {{
|
||||
background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2);
|
||||
color: #fff; padding: 6px 16px; border-radius: 6px; cursor: pointer;
|
||||
font-size: 14px; transition: background 0.2s;
|
||||
}}
|
||||
.toolbar button:hover {{ background: rgba(255,255,255,0.2); }}
|
||||
.toolbar button:disabled {{ opacity: 0.3; cursor: not-allowed; }}
|
||||
.page-info {{ font-size: 14px; color: rgba(255,255,255,0.7); min-width: 80px; text-align: center; }}
|
||||
.stage {{
|
||||
margin-top: 60px; width: 90vw; max-width: 1280px;
|
||||
aspect-ratio: 16/9; overflow: hidden;
|
||||
border-radius: 8px; box-shadow: 0 20px 60px rgba(0,0,0,0.5);
|
||||
background: #111; position: relative;
|
||||
}}
|
||||
.slide-frame {{
|
||||
width: 1280px; height: 720px;
|
||||
transform-origin: top left;
|
||||
position: absolute; top: 0; left: 0;
|
||||
border: none;
|
||||
}}
|
||||
.nav-hint {{
|
||||
position: fixed; bottom: 12px;
|
||||
color: rgba(255,255,255,0.25); font-size: 12px;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="toolbar">
|
||||
<button id="btn-prev" onclick="nav(-1)">Prev</button>
|
||||
<span class="page-info" id="page-info">1 / {total}</span>
|
||||
<button id="btn-next" onclick="nav(1)">Next</button>
|
||||
</div>
|
||||
<div class="stage" id="stage">
|
||||
{iframes_block}
|
||||
</div>
|
||||
<div class="nav-hint">Arrow keys to navigate</div>
|
||||
<script>
|
||||
let cur = 0;
|
||||
const frames = document.querySelectorAll('.slide-frame');
|
||||
const total = frames.length;
|
||||
const info = document.getElementById('page-info');
|
||||
const stage = document.getElementById('stage');
|
||||
|
||||
function resize() {{
|
||||
const sw = stage.clientWidth, sh = stage.clientHeight;
|
||||
const scale = Math.min(sw / 1280, sh / 720);
|
||||
frames.forEach(f => f.style.transform = 'scale(' + scale + ')');
|
||||
}}
|
||||
function show(i) {{
|
||||
frames.forEach((f, idx) => f.style.display = idx === i ? 'block' : 'none');
|
||||
info.textContent = (i+1) + ' / ' + total;
|
||||
document.getElementById('btn-prev').disabled = i === 0;
|
||||
document.getElementById('btn-next').disabled = i === total - 1;
|
||||
}}
|
||||
function nav(d) {{
|
||||
const n = cur + d;
|
||||
if (n >= 0 && n < total) {{ cur = n; show(cur); }}
|
||||
}}
|
||||
document.addEventListener('keydown', e => {{
|
||||
if (e.key==='ArrowLeft'||e.key==='ArrowUp') nav(-1);
|
||||
if (e.key==='ArrowRight'||e.key==='ArrowDown'||e.key===' ') nav(1);
|
||||
}});
|
||||
window.addEventListener('resize', resize);
|
||||
resize();
|
||||
show(0);
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="HTML Packager for PPT Agent")
|
||||
parser.add_argument("path", help="Directory containing slide HTML files")
|
||||
parser.add_argument("-o", "--output", default=None, help="Output HTML file")
|
||||
parser.add_argument("--title", default="PPT Preview", help="Title")
|
||||
args = parser.parse_args()
|
||||
|
||||
slides_dir = Path(args.path)
|
||||
if not slides_dir.is_dir():
|
||||
print(f"Error: {slides_dir} is not a directory", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
html_files = sorted(slides_dir.glob("*.html"))
|
||||
if not html_files:
|
||||
print(f"Error: No HTML files in {slides_dir}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
output_path = args.output or str(slides_dir.parent / "preview.html")
|
||||
|
||||
result = build_preview(html_files, title=args.title)
|
||||
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
f.write(result)
|
||||
|
||||
print(f"Created: {output_path} ({len(html_files)} slides)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user