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:
sunbigfly
2026-03-21 02:55:56 +08:00
commit 5e23feacad
9 changed files with 3684 additions and 0 deletions

542
scripts/html2svg.py Normal file
View File

@@ -0,0 +1,542 @@
#!/usr/bin/env python3
"""HTML -> 真矢量 SVG 转换(文字保留为可编辑 <text> 元素)
核心方案Puppeteer + dom-to-svg
- Puppeteer 在 headless 浏览器中打开 HTML
- dom-to-svg 直接将 DOM 树转为 SVG保留 <text> 元素
- 不经过 PDF 中转,文字不会变成 path
降级方案Puppeteer PDF + pdf2svg文字变 path不可编辑
首次运行自动安装依赖dom-to-svg, puppeteer, esbuild
用法:
python3 html2svg.py <html_dir_or_file> [-o output_dir]
"""
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
# Puppeteer + dom-to-svg bundle 注入脚本
CONVERT_SCRIPT = r"""
const puppeteer = require('puppeteer');
const fs = require('fs');
const path = require('path');
(async () => {
const config = JSON.parse(process.argv[2]);
const browser = await puppeteer.launch({
headless: 'new',
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu',
'--font-render-hinting=none']
});
for (const item of config.files) {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 720 });
await page.goto('file://' + item.html, {
waitUntil: 'networkidle0',
timeout: 30000
});
await new Promise(r => setTimeout(r, 500));
// 注入预打包的 dom-to-svg bundle
await page.addScriptTag({ path: config.bundlePath });
// 预处理:在 Node.js 端读取图片文件转 base64传给浏览器替换 src
// (浏览器端 canvas.toDataURL 会因 file:// CORS 被阻止)
const imgSrcs = await page.evaluate(() => {
const imgs = document.querySelectorAll('img');
return Array.from(imgs).map(img => img.getAttribute('src') || '');
});
const imgDataMap = {};
for (const src of imgSrcs) {
if (!src) continue;
// 处理 file:// 和绝对路径
let filePath = src;
if (filePath.startsWith('file://')) filePath = filePath.slice(7);
if (fs.existsSync(filePath)) {
const data = fs.readFileSync(filePath);
const ext = path.extname(filePath).slice(1) || 'png';
const mime = ext === 'jpg' ? 'image/jpeg' : `image/${ext}`;
imgDataMap[src] = `data:${mime};base64,${data.toString('base64')}`;
}
}
if (Object.keys(imgDataMap).length > 0) {
await page.evaluate((dataMap) => {
const imgs = document.querySelectorAll('img');
for (const img of imgs) {
const origSrc = img.getAttribute('src');
if (origSrc && dataMap[origSrc]) {
img.src = dataMap[origSrc];
}
}
}, imgDataMap);
// 等待图片重新渲染
await new Promise(r => setTimeout(r, 300));
}
// === 预处理:将 dom-to-svg 不支持的 CSS 特性转为真实 DOM ===
await page.evaluate(() => {
// 1. 物化伪元素 ::before / ::after -> 真实 span
// dom-to-svg 无法读取 CSS 伪元素,导致箭头/装饰丢失
const all = document.querySelectorAll('*');
for (const el of all) {
for (const pseudo of ['::before', '::after']) {
const style = getComputedStyle(el, pseudo);
const content = style.content;
if (!content || content === 'none' || content === '""' || content === "''") continue;
const w = parseFloat(style.width) || 0;
const h = parseFloat(style.height) || 0;
const bg = style.backgroundColor;
const border = style.borderTopWidth;
const borderColor = style.borderTopColor;
// 只处理有尺寸或有边框的伪元素(箭头/装饰块)
if ((w > 0 || h > 0 || parseFloat(border) > 0) && content !== 'normal') {
const span = document.createElement('span');
span.style.display = style.display === 'none' ? 'none' : 'inline-block';
span.style.position = style.position;
span.style.width = style.width;
span.style.height = style.height;
span.style.backgroundColor = bg;
span.style.borderTop = style.borderTop;
span.style.borderRight = style.borderRight;
span.style.borderBottom = style.borderBottom;
span.style.borderLeft = style.borderLeft;
span.style.transform = style.transform;
span.style.top = style.top;
span.style.left = style.left;
span.style.right = style.right;
span.style.bottom = style.bottom;
span.style.borderRadius = style.borderRadius;
span.setAttribute('data-pseudo', pseudo);
// 文本内容(去掉引号)
const textContent = content.replace(/^["']|["']$/g, '');
if (textContent && textContent !== 'normal' && textContent !== 'none') {
span.textContent = textContent;
span.style.color = style.color;
span.style.fontSize = style.fontSize;
span.style.fontWeight = style.fontWeight;
}
if (pseudo === '::before') {
el.insertBefore(span, el.firstChild);
} else {
el.appendChild(span);
}
}
}
}
// 2. 将 conic-gradient 环形图转为 SVG
// 查找带有 conic-gradient 背景的元素
for (const el of document.querySelectorAll('*')) {
const bg = el.style.background || el.style.backgroundImage || '';
const computed = getComputedStyle(el);
const bgImage = computed.backgroundImage || '';
if (!bgImage.includes('conic-gradient')) continue;
const rect = el.getBoundingClientRect();
const size = Math.min(rect.width, rect.height);
if (size <= 0) continue;
// 解析 conic-gradient 的百分比和颜色
const match = bgImage.match(/conic-gradient\(([^)]+)\)/);
if (!match) continue;
const gradStr = match[1];
// 提取百分比(典型格式: #color 0% 75%, #color2 75% 100%
const percMatch = gradStr.match(/([\d.]+)%/g);
let percentage = 75; // 默认
if (percMatch && percMatch.length >= 2) {
percentage = parseFloat(percMatch[1]);
}
// 提取颜色
const colorMatch = gradStr.match(/(#[0-9a-fA-F]{3,8}|rgb[a]?\([^)]+\))/g);
const mainColor = colorMatch ? colorMatch[0] : '#4CAF50';
const bgColor = colorMatch && colorMatch.length > 1 ? colorMatch[1] : '#e0e0e0';
// 创建 SVG 替换
const svgNS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(svgNS, 'svg');
svg.setAttribute('width', String(size));
svg.setAttribute('height', String(size));
svg.setAttribute('viewBox', `0 0 ${size} ${size}`);
svg.style.display = el.style.display || 'block';
svg.style.position = computed.position;
svg.style.top = computed.top;
svg.style.left = computed.left;
const cx = size / 2, cy = size / 2;
const r = size * 0.4;
const circumference = 2 * Math.PI * r;
const strokeWidth = size * 0.15;
// 背景圆环
const bgCircle = document.createElementNS(svgNS, 'circle');
bgCircle.setAttribute('cx', String(cx));
bgCircle.setAttribute('cy', String(cy));
bgCircle.setAttribute('r', String(r));
bgCircle.setAttribute('fill', 'none');
bgCircle.setAttribute('stroke', bgColor);
bgCircle.setAttribute('stroke-width', String(strokeWidth));
// 进度圆环
const fgCircle = document.createElementNS(svgNS, 'circle');
fgCircle.setAttribute('cx', String(cx));
fgCircle.setAttribute('cy', String(cy));
fgCircle.setAttribute('r', String(r));
fgCircle.setAttribute('fill', 'none');
fgCircle.setAttribute('stroke', mainColor);
fgCircle.setAttribute('stroke-width', String(strokeWidth));
fgCircle.setAttribute('stroke-dasharray', `${circumference * percentage / 100} ${circumference}`);
fgCircle.setAttribute('stroke-linecap', 'round');
fgCircle.setAttribute('transform', `rotate(-90 ${cx} ${cy})`);
svg.appendChild(bgCircle);
svg.appendChild(fgCircle);
// 保留子元素(如百分比文字),放到 foreignObject 不行
// 直接添加 SVG text
const textEl = el.querySelector('*');
if (el.textContent && el.textContent.trim()) {
const svgText = document.createElementNS(svgNS, 'text');
svgText.setAttribute('x', String(cx));
svgText.setAttribute('y', String(cy));
svgText.setAttribute('text-anchor', 'middle');
svgText.setAttribute('dominant-baseline', 'central');
svgText.setAttribute('fill', computed.color);
svgText.setAttribute('font-size', computed.fontSize);
svgText.setAttribute('font-weight', computed.fontWeight);
svgText.textContent = el.textContent.trim();
svg.appendChild(svgText);
}
el.style.background = 'none';
el.style.backgroundImage = 'none';
el.insertBefore(svg, el.firstChild);
}
// 3. 将 CSS border 三角形箭头修复
// 查找宽高为 0 但有 border 的元素CSS 三角形技巧)
for (const el of document.querySelectorAll('*')) {
const cs = getComputedStyle(el);
const w = parseFloat(cs.width);
const h = parseFloat(cs.height);
if (w !== 0 || h !== 0) continue;
const bt = parseFloat(cs.borderTopWidth) || 0;
const br = parseFloat(cs.borderRightWidth) || 0;
const bb = parseFloat(cs.borderBottomWidth) || 0;
const bl = parseFloat(cs.borderLeftWidth) || 0;
// 至少两个边框有宽度才是三角形
const borders = [bt, br, bb, bl].filter(v => v > 0);
if (borders.length < 2) continue;
const btc = cs.borderTopColor;
const brc = cs.borderRightColor;
const bbc = cs.borderBottomColor;
const blc = cs.borderLeftColor;
// 找有色边框(非 transparent
const nonTransparent = [];
if (bt > 0 && !btc.includes('0)') && btc !== 'transparent') nonTransparent.push({dir: 'top', size: bt, color: btc});
if (br > 0 && !brc.includes('0)') && brc !== 'transparent') nonTransparent.push({dir: 'right', size: br, color: brc});
if (bb > 0 && !bbc.includes('0)') && bbc !== 'transparent') nonTransparent.push({dir: 'bottom', size: bb, color: bbc});
if (bl > 0 && !blc.includes('0)') && blc !== 'transparent') nonTransparent.push({dir: 'left', size: bl, color: blc});
if (nonTransparent.length !== 1) continue;
// 用实际尺寸的 div 替换
const arrow = nonTransparent[0];
const totalW = bl + br;
const totalH = bt + bb;
el.style.width = totalW + 'px';
el.style.height = totalH + 'px';
el.style.border = 'none';
// 用 SVG 绘制三角形
const svgNS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(svgNS, 'svg');
svg.setAttribute('width', String(totalW));
svg.setAttribute('height', String(totalH));
svg.style.display = 'block';
svg.style.overflow = 'visible';
const polygon = document.createElementNS(svgNS, 'polygon');
let points = '';
if (arrow.dir === 'bottom') points = `0,0 ${totalW},0 ${totalW/2},${totalH}`;
else if (arrow.dir === 'top') points = `${totalW/2},0 0,${totalH} ${totalW},${totalH}`;
else if (arrow.dir === 'right') points = `0,0 ${totalW},${totalH/2} 0,${totalH}`;
else if (arrow.dir === 'left') points = `${totalW},0 0,${totalH/2} ${totalW},${totalH}`;
polygon.setAttribute('points', points);
polygon.setAttribute('fill', arrow.color);
svg.appendChild(polygon);
el.appendChild(svg);
}
});
await new Promise(r => setTimeout(r, 300));
// === 执行 DOM -> SVG 转换 ===
let svgString = await page.evaluate(async () => {
const { documentToSVG, inlineResources } = window.__domToSvg;
const svgDoc = documentToSVG(document);
await inlineResources(svgDoc.documentElement);
// 后处理:将 <text> 的 color 属性转为 fillSVG 标准)
const texts = svgDoc.querySelectorAll('text');
for (const t of texts) {
const c = t.getAttribute('color');
if (c && !t.getAttribute('fill')) {
t.setAttribute('fill', c);
t.removeAttribute('color');
}
}
return new XMLSerializer().serializeToString(svgDoc);
});
fs.writeFileSync(item.svg, svgString, 'utf-8');
console.log('SVG: ' + path.basename(item.html));
await page.close();
}
await browser.close();
console.log('Done: ' + config.files.length + ' SVGs');
})();
"""
# 降级 PDF 方案脚本
FALLBACK_SCRIPT = r"""
const puppeteer = require('puppeteer');
const fs = require('fs');
const path = require('path');
(async () => {
const config = JSON.parse(process.argv[2]);
const browser = await puppeteer.launch({
headless: 'new',
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu']
});
for (const item of config.files) {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 720 });
await page.goto('file://' + item.html, {
waitUntil: 'networkidle0',
timeout: 30000
});
await new Promise(r => setTimeout(r, 500));
await page.pdf({
path: item.pdf,
width: '1280px',
height: '720px',
printBackground: true,
preferCSSPageSize: true
});
console.log('PDF: ' + path.basename(item.html));
await page.close();
}
await browser.close();
console.log('Done: ' + config.files.length + ' PDFs');
})();
"""
# esbuild 打包入口
BUNDLE_ENTRY = """
import { documentToSVG, elementToSVG, inlineResources } from 'dom-to-svg';
window.__domToSvg = { documentToSVG, elementToSVG, inlineResources };
"""
def ensure_deps(work_dir: Path) -> tuple:
"""安装依赖,返回 (方案名, bundle路径)"""
# puppeteer
r = subprocess.run(
["node", "-e", "require('puppeteer')"],
capture_output=True, text=True, timeout=10, cwd=str(work_dir)
)
if r.returncode != 0:
print("Installing puppeteer...")
subprocess.run(["npm", "install", "puppeteer"],
capture_output=True, text=True, timeout=180, cwd=str(work_dir))
# dom-to-svg
r = subprocess.run(
["node", "-e", "require('dom-to-svg')"],
capture_output=True, text=True, timeout=10, cwd=str(work_dir)
)
if r.returncode != 0:
print("Installing dom-to-svg...")
subprocess.run(["npm", "install", "dom-to-svg"],
capture_output=True, text=True, timeout=60, cwd=str(work_dir))
r = subprocess.run(
["node", "-e", "require('dom-to-svg')"],
capture_output=True, text=True, timeout=10, cwd=str(work_dir)
)
if r.returncode != 0:
print("dom-to-svg unavailable, using pdf2svg fallback", file=sys.stderr)
return ("pdf2svg", None)
# 打包 dom-to-svg 为浏览器 bundle
bundle_path = work_dir / "dom-to-svg.bundle.js"
if not bundle_path.exists():
print("Building dom-to-svg browser bundle...")
entry_path = work_dir / ".bundle_entry.js"
entry_path.write_text(BUNDLE_ENTRY)
r = subprocess.run(
["npx", "-y", "esbuild", str(entry_path),
"--bundle", "--format=iife",
f"--outfile={bundle_path}", "--platform=browser"],
capture_output=True, text=True, timeout=60, cwd=str(work_dir)
)
if entry_path.exists():
entry_path.unlink()
if r.returncode != 0:
print(f"esbuild failed: {r.stderr}", file=sys.stderr)
return ("pdf2svg", None)
return ("dom-to-svg", str(bundle_path))
def convert_dom_to_svg(html_files, output_dir, work_dir, bundle_path):
"""用 dom-to-svg 方案转换"""
config = {
"bundlePath": bundle_path,
"files": [
{"html": str(f), "svg": str(output_dir / (f.stem + ".svg"))}
for f in html_files
]
}
script_path = work_dir / ".dom2svg_tmp.js"
script_path.write_text(CONVERT_SCRIPT)
try:
print(f"Converting {len(html_files)} HTML files (dom-to-svg, text editable)...")
r = subprocess.run(
["node", str(script_path), json.dumps(config)],
cwd=str(work_dir), timeout=300
)
if r.returncode != 0:
return False
# 验证是否有 <text> 元素
first_svg = output_dir / (html_files[0].stem + ".svg")
if first_svg.exists():
content = first_svg.read_text(errors="ignore")
text_count = content.count("<text ")
print(f"Text elements: {text_count} (editable in PPT)")
return True
finally:
if script_path.exists():
script_path.unlink()
def convert_pdf2svg(html_files, output_dir, work_dir):
"""降级方案Puppeteer PDF + pdf2svg"""
if not shutil.which("pdf2svg"):
print("pdf2svg not found. Install: sudo apt install pdf2svg", file=sys.stderr)
return False
pdf_tmp = work_dir / ".pdf_tmp"
pdf_tmp.mkdir(exist_ok=True)
config = {
"files": [
{"html": str(f), "pdf": str(pdf_tmp / (f.stem + ".pdf"))}
for f in html_files
]
}
script_path = work_dir / ".fallback_tmp.js"
script_path.write_text(FALLBACK_SCRIPT)
try:
print(f"Step 1/2: HTML -> PDF ({len(html_files)} files)...")
r = subprocess.run(
["node", str(script_path), json.dumps(config)],
cwd=str(work_dir), timeout=300
)
if r.returncode != 0:
return False
print("Step 2/2: PDF -> SVG (WARNING: text becomes paths, NOT editable)...")
success = 0
for item in config["files"]:
svg_name = Path(item["pdf"]).stem + ".svg"
svg_path = output_dir / svg_name
r = subprocess.run(
["pdf2svg", item["pdf"], str(svg_path)],
capture_output=True, text=True, timeout=30
)
if r.returncode == 0:
print(f" OK {svg_name}")
success += 1
return success > 0
finally:
if script_path.exists():
script_path.unlink()
if pdf_tmp.exists():
shutil.rmtree(pdf_tmp)
def convert(html_dir: Path, output_dir: Path) -> bool:
"""主转换入口"""
if html_dir.is_file():
html_files = [html_dir]
work_dir = html_dir.parent.parent
else:
html_files = sorted(html_dir.glob("*.html"))
work_dir = html_dir.parent
if not html_files:
print(f"No HTML files in {html_dir}", file=sys.stderr)
return False
output_dir.mkdir(parents=True, exist_ok=True)
method, bundle_path = ensure_deps(work_dir)
if method == "dom-to-svg" and bundle_path:
ok = convert_dom_to_svg(html_files, output_dir, work_dir, bundle_path)
if ok:
print(f"\nDone! {len(html_files)} SVGs -> {output_dir}")
return True
print("dom-to-svg failed, falling back to pdf2svg...")
return convert_pdf2svg(html_files, output_dir, work_dir)
def main():
if len(sys.argv) < 2:
print("Usage: python3 html2svg.py <html_dir_or_file> [-o output_dir]")
sys.exit(1)
html_path = Path(sys.argv[1]).resolve()
if "-o" in sys.argv:
idx = sys.argv.index("-o")
output_dir = Path(sys.argv[idx + 1]).resolve()
else:
output_dir = (html_path.parent if html_path.is_file() else html_path.parent) / "svg"
success = convert(html_path, output_dir)
sys.exit(0 if success else 1)
if __name__ == "__main__":
main()

205
scripts/html_packager.py Normal file
View 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 安全内容(& -> &amp; " -> &quot;
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()

942
scripts/svg2pptx.py Normal file
View File

@@ -0,0 +1,942 @@
#!/usr/bin/env python3
"""SVG to PPTX -- 将 SVG 元素解析为原生 OOXML 形状
支持: rect, text+tspan, circle, ellipse, line, path, image(data URI + file)
linearGradient, radialGradient, transform(translate/scale/matrix)
group opacity 传递, 首屏 rect 自动设为幻灯片背景
用法:
python svg2pptx.py <svg_dir_or_file> -o output.pptx
"""
import argparse
import base64
import io
import math
import re
import sys
from pathlib import Path
from lxml import etree
from pptx import Presentation
from pptx.util import Emu
# -------------------------------------------------------------------
# 常量
# -------------------------------------------------------------------
SVG_NS = 'http://www.w3.org/2000/svg'
XLINK_NS = 'http://www.w3.org/1999/xlink'
NS = {
'a': 'http://schemas.openxmlformats.org/drawingml/2006/main',
'r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
'p': 'http://schemas.openxmlformats.org/presentationml/2006/main',
}
EMU_PX = 9525
SLIDE_W = 12192000
SLIDE_H = 6858000
# CSS 完整命名颜色表(常用子集)
CSS_COLORS = {
'aliceblue': 'f0f8ff', 'antiquewhite': 'faebd7', 'aqua': '00ffff',
'aquamarine': '7fffd4', 'azure': 'f0ffff', 'beige': 'f5f5dc',
'bisque': 'ffe4c4', 'black': '000000', 'blanchedalmond': 'ffebcd',
'blue': '0000ff', 'blueviolet': '8a2be2', 'brown': 'a52a2a',
'burlywood': 'deb887', 'cadetblue': '5f9ea0', 'chartreuse': '7fff00',
'chocolate': 'd2691e', 'coral': 'ff7f50', 'cornflowerblue': '6495ed',
'cornsilk': 'fff8dc', 'crimson': 'dc143c', 'cyan': '00ffff',
'darkblue': '00008b', 'darkcyan': '008b8b', 'darkgoldenrod': 'b8860b',
'darkgray': 'a9a9a9', 'darkgreen': '006400', 'darkgrey': 'a9a9a9',
'darkkhaki': 'bdb76b', 'darkmagenta': '8b008b', 'darkolivegreen': '556b2f',
'darkorange': 'ff8c00', 'darkorchid': '9932cc', 'darkred': '8b0000',
'darksalmon': 'e9967a', 'darkseagreen': '8fbc8f', 'darkslateblue': '483d8b',
'darkslategray': '2f4f4f', 'darkturquoise': '00ced1', 'darkviolet': '9400d3',
'deeppink': 'ff1493', 'deepskyblue': '00bfff', 'dimgray': '696969',
'dodgerblue': '1e90ff', 'firebrick': 'b22222', 'floralwhite': 'fffaf0',
'forestgreen': '228b22', 'fuchsia': 'ff00ff', 'gainsboro': 'dcdcdc',
'ghostwhite': 'f8f8ff', 'gold': 'ffd700', 'goldenrod': 'daa520',
'gray': '808080', 'green': '008000', 'greenyellow': 'adff2f',
'grey': '808080', 'honeydew': 'f0fff0', 'hotpink': 'ff69b4',
'indianred': 'cd5c5c', 'indigo': '4b0082', 'ivory': 'fffff0',
'khaki': 'f0e68c', 'lavender': 'e6e6fa', 'lawngreen': '7cfc00',
'lemonchiffon': 'fffacd', 'lightblue': 'add8e6', 'lightcoral': 'f08080',
'lightcyan': 'e0ffff', 'lightgoldenrodyellow': 'fafad2', 'lightgray': 'd3d3d3',
'lightgreen': '90ee90', 'lightpink': 'ffb6c1', 'lightsalmon': 'ffa07a',
'lightseagreen': '20b2aa', 'lightskyblue': '87cefa', 'lightslategray': '778899',
'lightsteelblue': 'b0c4de', 'lightyellow': 'ffffe0', 'lime': '00ff00',
'limegreen': '32cd32', 'linen': 'faf0e6', 'magenta': 'ff00ff',
'maroon': '800000', 'mediumaquamarine': '66cdaa', 'mediumblue': '0000cd',
'mediumorchid': 'ba55d3', 'mediumpurple': '9370db', 'mediumseagreen': '3cb371',
'mediumslateblue': '7b68ee', 'mediumspringgreen': '00fa9a',
'mediumturquoise': '48d1cc', 'mediumvioletred': 'c71585', 'midnightblue': '191970',
'mintcream': 'f5fffa', 'mistyrose': 'ffe4e1', 'moccasin': 'ffe4b5',
'navajowhite': 'ffdead', 'navy': '000080', 'oldlace': 'fdf5e6',
'olive': '808000', 'olivedrab': '6b8e23', 'orange': 'ffa500',
'orangered': 'ff4500', 'orchid': 'da70d6', 'palegoldenrod': 'eee8aa',
'palegreen': '98fb98', 'paleturquoise': 'afeeee', 'palevioletred': 'db7093',
'papayawhip': 'ffefd5', 'peachpuff': 'ffdab9', 'peru': 'cd853f',
'pink': 'ffc0cb', 'plum': 'dda0dd', 'powderblue': 'b0e0e6',
'purple': '800080', 'rebeccapurple': '663399', 'red': 'ff0000',
'rosybrown': 'bc8f8f', 'royalblue': '4169e1', 'saddlebrown': '8b4513',
'salmon': 'fa8072', 'sandybrown': 'f4a460', 'seagreen': '2e8b57',
'seashell': 'fff5ee', 'sienna': 'a0522d', 'silver': 'c0c0c0',
'skyblue': '87ceeb', 'slateblue': '6a5acd', 'slategray': '708090',
'snow': 'fffafa', 'springgreen': '00ff7f', 'steelblue': '4682b4',
'tan': 'd2b48c', 'teal': '008080', 'thistle': 'd8bfd8',
'tomato': 'ff6347', 'turquoise': '40e0d0', 'violet': 'ee82ee',
'wheat': 'f5deb3', 'white': 'ffffff', 'whitesmoke': 'f5f5f5',
'yellow': 'ffff00', 'yellowgreen': '9acd32',
}
# 字体回退链
FONT_FALLBACK = {
'PingFang SC': 'Microsoft YaHei',
'SF Pro Display': 'Arial',
'Helvetica Neue': 'Arial',
'Helvetica': 'Arial',
'system-ui': 'Microsoft YaHei',
'sans-serif': 'Microsoft YaHei',
}
def px(v):
return int(float(v) * EMU_PX)
def font_sz(svg_px):
return max(100, int(float(svg_px) * 75))
def strip_unit(v):
return re.sub(r'[a-z%]+', '', str(v))
def resolve_font(ff_str):
"""解析 font-family 字符串,返回 PPT 可用字体。"""
ff_str = ff_str.replace('&quot;', '').replace('"', '').replace("'", '')
fonts = [f.strip() for f in ff_str.split(',') if f.strip()]
for f in fonts:
if f in FONT_FALLBACK:
return FONT_FALLBACK[f]
if f and f not in ('sans-serif', 'serif', 'monospace', 'system-ui'):
return f
return 'Microsoft YaHei'
# -------------------------------------------------------------------
# 颜色解析(完整 CSS 命名颜色)
# -------------------------------------------------------------------
def parse_color(s):
if not s or s.strip() == 'none':
return None
s = s.strip()
if s.startswith('url('):
m = re.search(r'#([\w-]+)', s)
return ('grad', m.group(1)) if m else None
m = re.match(r'rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)', s)
if m:
r, g, b = int(m.group(1)), int(m.group(2)), int(m.group(3))
a = float(m.group(4)) if m.group(4) else 1.0
return (f'{r:02x}{g:02x}{b:02x}', int(a * 100000))
if s.startswith('#'):
h = s[1:]
if len(h) == 3:
h = h[0]*2 + h[1]*2 + h[2]*2
return (h.lower().ljust(6, '0')[:6], 100000)
c = CSS_COLORS.get(s.lower())
return (c, 100000) if c else None
# -------------------------------------------------------------------
# OOXML 元素构造
# -------------------------------------------------------------------
def _el(tag, attrib=None, text=None, children=None):
pre, local = tag.split(':') if ':' in tag else ('a', tag)
el = etree.Element(f'{{{NS[pre]}}}{local}')
if attrib:
for k, v in attrib.items():
el.set(k, str(v))
if text is not None:
el.text = str(text)
for c in (children or []):
if c is not None:
el.append(c)
return el
def _srgb(hex6, alpha=100000):
el = _el('a:srgbClr', {'val': hex6})
if alpha < 100000:
el.append(_el('a:alpha', {'val': str(alpha)}))
return el
def make_fill(fill_str, grads, opacity=1.0):
c = parse_color(fill_str)
if c is None:
return _el('a:noFill')
if c[0] == 'grad':
gdef = grads.get(c[1])
return _make_grad(gdef) if gdef else _el('a:noFill')
hex6, alpha = c
alpha = int(alpha * opacity)
return _el('a:solidFill', children=[_srgb(hex6, alpha)])
def _make_grad(gdef):
gs_lst = _el('a:gsLst')
for stop in gdef['stops']:
pos = int(stop['offset'] * 1000)
sc = parse_color(stop['color_str'])
if not sc or sc[0] == 'grad':
continue
hex6, alpha = sc
alpha = int(alpha * stop.get('opacity', 1.0))
gs_lst.append(_el('a:gs', {'pos': str(pos)}, children=[_srgb(hex6, alpha)]))
if gdef.get('type') == 'radial':
# 径向渐变
path = _el('a:path', {'path': 'circle'}, children=[
_el('a:fillToRect', {'l': '50000', 't': '50000', 'r': '50000', 'b': '50000'})
])
return _el('a:gradFill', {'rotWithShape': '1'}, children=[gs_lst, path])
else:
# 线性渐变
dx = gdef.get('x2', 1) - gdef.get('x1', 0)
dy = gdef.get('y2', 1) - gdef.get('y1', 0)
ang = int(math.degrees(math.atan2(dy, dx)) * 60000)
if ang < 0:
ang += 21600000
lin = _el('a:lin', {'ang': str(ang), 'scaled': '0'})
return _el('a:gradFill', children=[gs_lst, lin])
def make_line(stroke_str, stroke_w=1):
c = parse_color(stroke_str)
if not c or c[0] == 'grad':
return None
hex6, alpha = c
w = max(1, int(float(strip_unit(stroke_w)) * 12700))
return _el('a:ln', {'w': str(w)},
children=[_el('a:solidFill', children=[_srgb(hex6, alpha)])])
def make_shape(sid, name, x, y, cx, cy, preset='rect',
fill_el=None, line_el=None, rx=0, geom_el=None):
sp = _el('p:sp')
sp.append(_el('p:nvSpPr', children=[
_el('p:cNvPr', {'id': str(sid), 'name': name}),
_el('p:cNvSpPr'), _el('p:nvPr'),
]))
sp_pr = _el('p:spPr')
sp_pr.append(_el('a:xfrm', children=[
_el('a:off', {'x': str(max(0, int(x))), 'y': str(max(0, int(y)))}),
_el('a:ext', {'cx': str(max(0, int(cx))), 'cy': str(max(0, int(cy)))}),
]))
if geom_el is not None:
sp_pr.append(geom_el)
else:
geom = _el('a:prstGeom', {'prst': preset})
av = _el('a:avLst')
if preset == 'roundRect' and rx > 0:
shorter = max(min(cx, cy), 1)
adj = min(50000, int(rx / (shorter / 2) * 50000))
av.append(_el('a:gd', {'name': 'adj', 'fmla': f'val {adj}'}))
geom.append(av)
sp_pr.append(geom)
sp_pr.append(fill_el if fill_el is not None else _el('a:noFill'))
if line_el is not None:
sp_pr.append(line_el)
sp.append(sp_pr)
return sp
def make_textbox(sid, name, x, y, cx, cy, paragraphs):
"""paragraphs = [[{text,sz,bold,hex,alpha,font}, ...], ...]"""
sp = _el('p:sp')
sp.append(_el('p:nvSpPr', children=[
_el('p:cNvPr', {'id': str(sid), 'name': name}),
_el('p:cNvSpPr', {'txBox': '1'}), _el('p:nvPr'),
]))
sp.append(_el('p:spPr', children=[
_el('a:xfrm', children=[
_el('a:off', {'x': str(max(0, int(x))), 'y': str(max(0, int(y)))}),
_el('a:ext', {'cx': str(max(0, int(cx))), 'cy': str(max(0, int(cy)))}),
]),
_el('a:prstGeom', {'prst': 'rect'}, children=[_el('a:avLst')]),
_el('a:noFill'), _el('a:ln', children=[_el('a:noFill')]),
]))
tx = _el('p:txBody', children=[
_el('a:bodyPr', {'wrap': 'none', 'lIns': '0', 'tIns': '0',
'rIns': '0', 'bIns': '0', 'anchor': 't'}),
_el('a:lstStyle'),
])
for runs in paragraphs:
p_el = _el('a:p')
for run in runs:
rpr_a = {'lang': 'zh-CN', 'dirty': '0'}
if run.get('sz'):
rpr_a['sz'] = str(run['sz'])
if run.get('bold'):
rpr_a['b'] = '1'
rpr = _el('a:rPr', rpr_a)
rpr.append(_el('a:solidFill', children=[
_srgb(run.get('hex', '000000'), run.get('alpha', 100000))
]))
font = run.get('font', 'Microsoft YaHei')
rpr.append(_el('a:latin', {'typeface': font}))
rpr.append(_el('a:ea', {'typeface': font}))
p_el.append(_el('a:r', children=[rpr, _el('a:t', text=run.get('text', ''))]))
tx.append(p_el)
sp.append(tx)
return sp
# -------------------------------------------------------------------
# SVG Path 解析器 -> OOXML custGeom
# -------------------------------------------------------------------
_PATH_RE = re.compile(r'([mMzZlLhHvVcCsSqQtTaA])|([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)')
def parse_path_to_custgeom(d_str, bbox):
"""SVG path d -> OOXML a:custGeom 元素。bbox=(x,y,w,h) 用于坐标偏移。"""
bx, by, bw, bh = bbox
scale = 100000 # OOXML 路径坐标空间
def coord(v, is_x=True):
base = bw if is_x else bh
offset = bx if is_x else by
if base <= 0:
return 0
return int((float(v) - offset) / base * scale)
tokens = _PATH_RE.findall(d_str)
items = []
for cmd_match, num_match in tokens:
if cmd_match:
items.append(cmd_match)
elif num_match:
items.append(float(num_match))
path_el = _el('a:path', {'w': str(scale), 'h': str(scale)})
i = 0
cx_p, cy_p = 0, 0 # current point (absolute)
cmd = None
rel = False
while i < len(items):
if isinstance(items[i], str):
cmd = items[i].lower()
rel = items[i].islower()
i += 1
if cmd == 'z':
path_el.append(_el('a:close'))
continue
if cmd is None:
i += 1
continue
try:
if cmd == 'm':
x, y = float(items[i]), float(items[i+1])
if rel:
x += cx_p; y += cy_p
cx_p, cy_p = x, y
path_el.append(_el('a:moveTo', children=[
_el('a:pt', {'x': str(coord(x, True)), 'y': str(coord(y, False))})
]))
i += 2
cmd = 'l' # implicit lineTo after moveTo
elif cmd == 'l':
x, y = float(items[i]), float(items[i+1])
if rel:
x += cx_p; y += cy_p
cx_p, cy_p = x, y
path_el.append(_el('a:lnTo', children=[
_el('a:pt', {'x': str(coord(x, True)), 'y': str(coord(y, False))})
]))
i += 2
elif cmd == 'h':
x = float(items[i])
if rel:
x += cx_p
cx_p = x
path_el.append(_el('a:lnTo', children=[
_el('a:pt', {'x': str(coord(cx_p, True)), 'y': str(coord(cy_p, False))})
]))
i += 1
elif cmd == 'v':
y = float(items[i])
if rel:
y += cy_p
cy_p = y
path_el.append(_el('a:lnTo', children=[
_el('a:pt', {'x': str(coord(cx_p, True)), 'y': str(coord(cy_p, False))})
]))
i += 1
elif cmd == 'c':
x1, y1 = float(items[i]), float(items[i+1])
x2, y2 = float(items[i+2]), float(items[i+3])
x, y = float(items[i+4]), float(items[i+5])
if rel:
x1 += cx_p; y1 += cy_p
x2 += cx_p; y2 += cy_p
x += cx_p; y += cy_p
cx_p, cy_p = x, y
path_el.append(_el('a:cubicBezTo', children=[
_el('a:pt', {'x': str(coord(x1, True)), 'y': str(coord(y1, False))}),
_el('a:pt', {'x': str(coord(x2, True)), 'y': str(coord(y2, False))}),
_el('a:pt', {'x': str(coord(x, True)), 'y': str(coord(y, False))}),
]))
i += 6
elif cmd in ('s', 'q', 't', 'a'):
# 简化处理:跳过复杂曲线
skip = {'s': 4, 'q': 4, 't': 2, 'a': 7}.get(cmd, 2)
i += skip
else:
i += 1
except (IndexError, ValueError):
i += 1
cust_geom = _el('a:custGeom', children=[
_el('a:avLst'), _el('a:gdLst'), _el('a:ahLst'), _el('a:cxnLst'),
_el('a:rect', {'l': 'l', 't': 't', 'r': 'r', 'b': 'b'}),
_el('a:pathLst', children=[path_el]),
])
return cust_geom
# -------------------------------------------------------------------
# SVG -> PPTX 转换器
# -------------------------------------------------------------------
class SvgConverter:
def __init__(self, on_progress=None):
self.sid = 100
self.grads = {}
self.bg_set = False # 是否已设置幻灯片背景
self.on_progress = on_progress # 进度回调 (i, total, filename)
self.stats = {'shapes': 0, 'skipped': 0, 'errors': 0}
def _id(self):
self.sid += 1
return self.sid
def convert(self, svg_path, slide):
self.bg_set = False
self.stats = {'shapes': 0, 'skipped': 0, 'errors': 0}
tree = etree.parse(str(svg_path))
root = tree.getroot()
self._parse_grads(root)
sp_tree = None
for d in slide._element.iter():
if d.tag.endswith('}spTree'):
sp_tree = d
break
if sp_tree is None:
return
self._walk(root, sp_tree, 0, 0, 1.0, slide)
def _parse_grads(self, root):
self.grads = {}
pct = lambda v: float(v.rstrip('%')) / 100 if '%' in str(v) else float(v)
for g in root.iter(f'{{{SVG_NS}}}linearGradient'):
gid = g.get('id')
if not gid:
continue
stops = []
for s in g.findall(f'{{{SVG_NS}}}stop'):
off = s.get('offset', '0%')
off = float(off.rstrip('%')) if '%' in off else float(off) * 100
stops.append({'offset': off, 'color_str': s.get('stop-color', '#000'),
'opacity': float(s.get('stop-opacity', '1'))})
self.grads[gid] = {
'type': 'linear', 'stops': stops,
'x1': pct(g.get('x1', '0%')), 'y1': pct(g.get('y1', '0%')),
'x2': pct(g.get('x2', '100%')), 'y2': pct(g.get('y2', '100%')),
}
for g in root.iter(f'{{{SVG_NS}}}radialGradient'):
gid = g.get('id')
if not gid:
continue
stops = []
for s in g.findall(f'{{{SVG_NS}}}stop'):
off = s.get('offset', '0%')
off = float(off.rstrip('%')) if '%' in off else float(off) * 100
stops.append({'offset': off, 'color_str': s.get('stop-color', '#000'),
'opacity': float(s.get('stop-opacity', '1'))})
self.grads[gid] = {'type': 'radial', 'stops': stops}
def _tag(self, el):
t = el.tag
return t.split('}')[1] if isinstance(t, str) and '}' in t else (t if isinstance(t, str) else '')
def _parse_transform(self, el):
"""解析 transform -> (dx, dy, sx, sy)。"""
t = el.get('transform', '')
dx, dy, sx, sy = 0.0, 0.0, 1.0, 1.0
# translate
m = re.search(r'translate\(\s*([\d.\-]+)[,\s]+([\d.\-]+)', t)
if m:
dx, dy = float(m.group(1)), float(m.group(2))
# scale
m = re.search(r'scale\(\s*([\d.\-]+)(?:[,\s]+([\d.\-]+))?\s*\)', t)
if m:
sx = float(m.group(1))
sy = float(m.group(2)) if m.group(2) else sx
# matrix(a,b,c,d,e,f) -> e=translateX, f=translateY
m = re.search(r'matrix\(\s*([\d.\-]+)[,\s]+([\d.\-]+)[,\s]+([\d.\-]+)[,\s]+([\d.\-]+)[,\s]+([\d.\-]+)[,\s]+([\d.\-]+)', t)
if m:
dx = float(m.group(5))
dy = float(m.group(6))
sx = float(m.group(1))
sy = float(m.group(4))
return dx, dy, sx, sy
def _walk(self, el, sp, ox, oy, group_opacity, slide):
tag = self._tag(el)
try:
if tag == 'rect':
self._rect(el, sp, ox, oy, group_opacity, slide)
elif tag == 'text':
self._text(el, sp, ox, oy, group_opacity)
elif tag == 'circle':
self._circle(el, sp, ox, oy, group_opacity)
elif tag == 'ellipse':
self._ellipse(el, sp, ox, oy, group_opacity)
elif tag == 'line':
self._line(el, sp, ox, oy)
elif tag == 'path':
self._path(el, sp, ox, oy, group_opacity)
elif tag == 'image':
self._image(el, sp, ox, oy, slide)
elif tag == 'g':
dx, dy, sx, sy = self._parse_transform(el)
el_opacity = float(el.get('opacity', '1'))
child_opacity = group_opacity * el_opacity
# scale 只应用于 delta不缩放父级偏移
new_ox = ox + dx
new_oy = oy + dy
for c in el:
self._walk(c, sp, new_ox, new_oy,
child_opacity, slide)
elif tag in ('defs', 'style', 'linearGradient', 'radialGradient',
'stop', 'pattern', 'clipPath', 'filter', 'mask'):
pass # 跳过定义元素(不跳过被 mask 的内容元素)
else:
for c in el:
self._walk(c, sp, ox, oy, group_opacity, slide)
except Exception as e:
self.stats['errors'] += 1
print(f" Warning: {tag} element failed: {e}", file=sys.stderr)
def _rect(self, el, sp, ox, oy, opacity, slide):
x = float(el.get('x', 0)) + ox
y = float(el.get('y', 0)) + oy
w = float(el.get('width', 0))
h = float(el.get('height', 0))
if w <= 0 or h <= 0:
return
# 过滤面积 < 4px 的纯装饰元素
if w < 4 and h < 4:
self.stats['skipped'] += 1
return
fill_s = el.get('fill', '')
stroke_s = el.get('stroke', '')
c = parse_color(fill_s)
# 跳过全透明无边框矩形
if c and c[0] != 'grad' and c[1] == 0 and not stroke_s:
return
el_opacity = float(el.get('opacity', '1')) * opacity
# 首个全屏 rect -> 幻灯片背景
if not self.bg_set and w >= 1270 and h >= 710:
self.bg_set = True
bg = slide._element.find(f'.//{{{NS["p"]}}}bg')
if bg is None:
cSld = slide._element.find(f'{{{NS["p"]}}}cSld')
if cSld is not None:
bg_el = _el('p:bg', children=[
_el('p:bgPr', children=[
make_fill(fill_s, self.grads, el_opacity),
_el('a:effectLst'),
])
])
cSld.insert(0, bg_el)
return # 不再作为形状添加
r = max(float(el.get('rx', 0)), float(el.get('ry', 0)))
preset = 'roundRect' if r > 0 else 'rect'
fill_el = make_fill(fill_s, self.grads, el_opacity)
line_el = make_line(stroke_s, el.get('stroke-width', '1')) if stroke_s else None
shape = make_shape(self._id(), f'R{self.sid}',
px(x), px(y), px(w), px(h),
preset=preset, fill_el=fill_el, line_el=line_el, rx=px(r))
sp.append(shape)
self.stats['shapes'] += 1
def _text(self, el, sp, ox, oy, opacity):
"""每个 tspan 保持独立文本框,保留精确 x/y 坐标。"""
fill_s = el.get('fill', el.get('color', ''))
fsz = el.get('font-size', '14px').replace('px', '')
fw = el.get('font-weight', '')
ff = el.get('font-family', '')
baseline = el.get('dominant-baseline', '')
tspans = list(el.findall(f'{{{SVG_NS}}}tspan'))
if tspans:
for ts in tspans:
txt = ts.text
if not txt or not txt.strip():
continue
x = float(ts.get('x', 0)) + ox
y = float(ts.get('y', 0)) + oy
tlen = float(ts.get('textLength', 0))
ts_fsz = ts.get('font-size', fsz).replace('px', '')
ts_fw = ts.get('font-weight', fw)
ts_fill = ts.get('fill', fill_s)
ts_ff = ts.get('font-family', ff)
fh = float(ts_fsz)
if 'after-edge' in baseline:
y -= fh
c = parse_color(ts_fill)
hex6 = c[0] if c and c[0] != 'grad' else '000000'
alpha = c[1] if c and c[0] != 'grad' else 100000
alpha = int(alpha * opacity)
cx_v = px(tlen) if tlen > 0 else px(len(txt) * float(ts_fsz) * 0.7)
cy_v = px(fh * 1.5)
run = {
'text': txt.strip(), 'sz': font_sz(ts_fsz),
'bold': ts_fw in ('bold', '700', '800', '900'),
'hex': hex6, 'alpha': alpha,
'font': resolve_font(ts_ff),
}
shape = make_textbox(self._id(), f'T{self.sid}',
px(x), px(y), cx_v, cy_v, [[run]])
sp.append(shape)
self.stats['shapes'] += 1
elif el.text and el.text.strip():
x = float(el.get('x', 0)) + ox
y = float(el.get('y', 0)) + oy
fh = float(fsz)
if 'after-edge' in baseline:
y -= fh
c = parse_color(fill_s)
hex6 = c[0] if c and c[0] != 'grad' else '000000'
alpha = c[1] if c and c[0] != 'grad' else 100000
alpha = int(alpha * opacity)
txt = el.text.strip()
run = {
'text': txt, 'sz': font_sz(fsz),
'bold': fw in ('bold', '700', '800', '900'),
'hex': hex6, 'alpha': alpha, 'font': resolve_font(ff),
}
shape = make_textbox(self._id(), f'T{self.sid}',
px(x), px(y),
px(len(txt) * float(fsz) * 0.7),
px(fh * 1.5), [[run]])
sp.append(shape)
self.stats['shapes'] += 1
def _circle(self, el, sp, ox, oy, opacity):
cx_v = float(el.get('cx', 0)) + ox
cy_v = float(el.get('cy', 0)) + oy
r = float(el.get('r', 0))
if r <= 0 or r < 2:
self.stats['skipped'] += 1
return
el_opacity = float(el.get('opacity', '1')) * opacity
fill_s = el.get('fill', '')
stroke_s = el.get('stroke', '')
stroke_w_s = el.get('stroke-width', '1')
dasharray = el.get('stroke-dasharray', '')
# 环形图特殊处理fill=none + stroke + dasharray -> OOXML arc + 粗描边
if (fill_s == 'none' or not fill_s) and stroke_s and dasharray:
sw = float(strip_unit(stroke_w_s))
# 解析 dasharray (格式: "188.1 188.5" 或 "113.097px, 150.796px")
dash_parts = [float(strip_unit(p.strip())) for p in dasharray.replace(',', ' ').split() if p.strip()]
if len(dash_parts) >= 2:
circumference = 2 * math.pi * r
arc_len = dash_parts[0]
angle_pct = min(arc_len / circumference, 1.0)
# 检查 rotate transform
transform = el.get('transform', '')
start_angle = 0
rot_m = re.search(r'rotate\(\s*([\d.\-]+)', transform)
if rot_m:
start_angle = float(rot_m.group(1))
# SVG -> PowerPoint 角度转换
# SVG rotate(-90) = 从 12 点钟方向开始
# PowerPoint arc: adj1=startAngle, adj2=endAngle (从3点钟顺时针, 60000单位/度)
ppt_start = (start_angle + 90) % 360
sweep = angle_pct * 360
ppt_end = (ppt_start + sweep) % 360
adj1 = int(ppt_start * 60000)
adj2 = int(ppt_end * 60000)
# 用 arc 预设 (只画弧线轮廓) + 粗描边 = 环形弧
geom = _el('a:prstGeom', {'prst': 'arc'})
av = _el('a:avLst')
av.append(_el('a:gd', {'name': 'adj1', 'fmla': f'val {adj1}'}))
av.append(_el('a:gd', {'name': 'adj2', 'fmla': f'val {adj2}'}))
geom.append(av)
# 描边颜色 = SVG 的 stroke 颜色
stroke_color = parse_color(stroke_s)
ln_children = []
if stroke_color and stroke_color[0] != 'grad':
ln_children.append(_el('a:solidFill', children=[
_srgb(stroke_color[0], int(stroke_color[1] * el_opacity))
]))
ln_children.append(_el('a:round'))
line_el = _el('a:ln', {'w': str(int(sw * 12700))}, children=ln_children)
shape = _el('p:sp')
shape.append(_el('p:nvSpPr', children=[
_el('p:cNvPr', {'id': str(self._id()), 'name': f'Arc{self.sid}'}),
_el('p:cNvSpPr'), _el('p:nvPr'),
]))
sp_pr = _el('p:spPr')
sp_pr.append(_el('a:xfrm', children=[
_el('a:off', {'x': str(max(0, px(cx_v - r))),
'y': str(max(0, px(cy_v - r)))}),
_el('a:ext', {'cx': str(px(2 * r)),
'cy': str(px(2 * r))}),
]))
sp_pr.append(geom)
sp_pr.append(_el('a:noFill'))
sp_pr.append(line_el)
shape.append(sp_pr)
sp.append(shape)
self.stats['shapes'] += 1
return
# fill=none + stroke (无dasharray) -> 空心圆 + 粗描边
if (fill_s == 'none' or not fill_s) and stroke_s and stroke_s != 'none':
sw = float(strip_unit(stroke_w_s))
stroke_color = parse_color(stroke_s)
ln_children = []
if stroke_color and stroke_color[0] != 'grad':
ln_children.append(_el('a:solidFill', children=[
_srgb(stroke_color[0], int(stroke_color[1] * el_opacity))
]))
ln_children.append(_el('a:round'))
line_el = _el('a:ln', {'w': str(int(sw * 12700))}, children=ln_children)
sp.append(make_shape(self._id(), f'C{self.sid}',
px(cx_v - r), px(cy_v - r), px(2*r), px(2*r),
preset='ellipse',
fill_el=_el('a:noFill'),
line_el=line_el))
self.stats['shapes'] += 1
return
# 普通圆形
fill_el = make_fill(fill_s, self.grads, el_opacity)
line_el = make_line(stroke_s, stroke_w_s) if stroke_s and stroke_s != 'none' else None
sp.append(make_shape(self._id(), f'C{self.sid}',
px(cx_v - r), px(cy_v - r), px(2*r), px(2*r),
preset='ellipse', fill_el=fill_el, line_el=line_el))
self.stats['shapes'] += 1
def _ellipse(self, el, sp, ox, oy, opacity):
cx_v = float(el.get('cx', 0)) + ox
cy_v = float(el.get('cy', 0)) + oy
rx = float(el.get('rx', 0))
ry = float(el.get('ry', 0))
if rx <= 0 or ry <= 0:
return
el_opacity = float(el.get('opacity', '1')) * opacity
fill_el = make_fill(el.get('fill', ''), self.grads, el_opacity)
sp.append(make_shape(self._id(), f'E{self.sid}',
px(cx_v - rx), px(cy_v - ry), px(2*rx), px(2*ry),
preset='ellipse', fill_el=fill_el))
self.stats['shapes'] += 1
def _line(self, el, sp, ox, oy):
x1 = float(el.get('x1', 0)) + ox
y1 = float(el.get('y1', 0)) + oy
x2 = float(el.get('x2', 0)) + ox
y2 = float(el.get('y2', 0)) + oy
line_el = make_line(el.get('stroke', '#000'), el.get('stroke-width', '1'))
if line_el is None:
return
mx, my = min(x1, x2), min(y1, y2)
w, h = abs(x2 - x1) or 1, abs(y2 - y1) or 1
shape = make_shape(self._id(), f'L{self.sid}',
px(mx), px(my), px(w), px(h),
preset='line', fill_el=_el('a:noFill'), line_el=line_el)
xfrm = shape.find(f'.//{{{NS["a"]}}}xfrm')
if x1 > x2:
xfrm.set('flipH', '1')
if y1 > y2:
xfrm.set('flipV', '1')
sp.append(shape)
self.stats['shapes'] += 1
def _path(self, el, sp, ox, oy, opacity):
"""SVG <path> -> OOXML custGeom 形状。"""
d = el.get('d', '')
if not d or 'nan' in d:
return
# 计算 bounding box简化从 path 数据提取所有数字坐标)
nums = re.findall(r'[+-]?(?:\d+\.?\d*|\.\d+)', d)
if len(nums) < 4:
return
coords = [float(n) for n in nums]
xs = coords[0::2]
ys = coords[1::2] if len(coords) > 1 else [0]
bx, by = min(xs), min(ys)
bw = max(xs) - bx or 1
bh = max(ys) - by or 1
# 过滤极小路径
if bw < 4 and bh < 4:
self.stats['skipped'] += 1
return
geom_el = parse_path_to_custgeom(d, (bx, by, bw, bh))
el_opacity = float(el.get('opacity', '1')) * opacity
fill_el = make_fill(el.get('fill', ''), self.grads, el_opacity)
line_el = make_line(el.get('stroke', ''), el.get('stroke-width', '1')) if el.get('stroke') else None
shape = make_shape(self._id(), f'P{self.sid}',
px(bx + ox), px(by + oy), px(bw), px(bh),
fill_el=fill_el, line_el=line_el, geom_el=geom_el)
sp.append(shape)
self.stats['shapes'] += 1
def _image(self, el, sp, ox, oy, slide):
href = el.get(f'{{{XLINK_NS}}}href') or el.get('href', '')
x = float(el.get('x', 0)) + ox
y = float(el.get('y', 0)) + oy
w = float(el.get('width', 0))
h = float(el.get('height', 0))
if not href or w <= 0 or h <= 0:
return
img_source = None
if href.startswith('data:'):
m = re.match(r'data:image/\w+;base64,(.*)', href, re.DOTALL)
if m:
img_source = io.BytesIO(base64.b64decode(m.group(1)))
elif href.startswith('file://'):
p = Path(href.replace('file://', ''))
if p.exists():
img_source = str(p)
elif not href.startswith('http'):
p = Path(href)
if p.exists():
img_source = str(p)
if img_source is None:
return
# 获取图片原始尺寸以计算宽高比
try:
from PIL import Image as PILImage
if isinstance(img_source, io.BytesIO):
img_source.seek(0)
pil_img = PILImage.open(img_source)
img_w, img_h = pil_img.size
# 不 close -- PIL close 会关掉底层 BytesIO
del pil_img
img_source.seek(0)
else:
with PILImage.open(img_source) as pil_img:
img_w, img_h = pil_img.size
except ImportError:
# 没有 PIL退回直接拉伸
pic = slide.shapes.add_picture(img_source,
Emu(px(x)), Emu(px(y)),
Emu(px(w)), Emu(px(h)))
self.stats['shapes'] += 1
return
# object-fit: cover -- 按比例放大到覆盖容器,然后裁剪
container_w = px(w)
container_h = px(h)
img_ratio = img_w / img_h
container_ratio = container_w / container_h
if img_ratio > container_ratio:
# 图片更宽 -> 按高度填满,裁剪左右
scale_h = container_h
scale_w = int(scale_h * img_ratio)
else:
# 图片更高 -> 按宽度填满,裁剪上下
scale_w = container_w
scale_h = int(scale_w / img_ratio)
# 放置缩放后的图片(居中裁剪)
offset_x = (scale_w - container_w) / 2
offset_y = (scale_h - container_h) / 2
pic = slide.shapes.add_picture(img_source,
Emu(px(x)), Emu(px(y)),
Emu(scale_w), Emu(scale_h))
# 用 crop 实现裁剪(值为比例 0.0-1.0
if scale_w > 0 and scale_h > 0:
crop_lr = offset_x / scale_w # 左右各裁多少比例
crop_tb = offset_y / scale_h # 上下各裁多少比例
pic.crop_left = crop_lr
pic.crop_right = crop_lr
pic.crop_top = crop_tb
pic.crop_bottom = crop_tb
self.stats['shapes'] += 1
# -------------------------------------------------------------------
# 主流程
# -------------------------------------------------------------------
def convert(svg_input, output_path, on_progress=None):
svg_input = Path(svg_input)
if svg_input.is_file():
svg_files = [svg_input]
elif svg_input.is_dir():
svg_files = sorted(svg_input.glob('*.svg'))
else:
print(f"Error: {svg_input} not found", file=sys.stderr)
sys.exit(1)
if not svg_files:
print("Error: No SVG files found", file=sys.stderr)
sys.exit(1)
prs = Presentation()
prs.slide_width = Emu(SLIDE_W)
prs.slide_height = Emu(SLIDE_H)
blank = prs.slide_layouts[6]
converter = SvgConverter(on_progress=on_progress)
total = len(svg_files)
for i, svg_file in enumerate(svg_files):
slide = prs.slides.add_slide(blank)
converter.convert(svg_file, slide)
s = converter.stats
print(f" [{i+1}/{total}] {svg_file.name} "
f"({s['shapes']} shapes, {s['skipped']} skipped, {s['errors']} errors)")
if on_progress:
on_progress(i + 1, total, svg_file.name)
prs.save(str(output_path))
print(f"Saved: {output_path} ({total} slides)")
def main():
parser = argparse.ArgumentParser(description="SVG to PPTX (native shapes)")
parser.add_argument('svg', help='SVG file or directory')
parser.add_argument('-o', '--output', default='presentation.pptx')
args = parser.parse_args()
convert(args.svg, args.output)
if __name__ == '__main__':
main()