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

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()