svg2pptx.py: - 修复image opacity不生效(通过OOXML alphaModFix) - 修复环形图stroke渐变url(#id)引用不支持(fallback第一个stop颜色) - 修复viewBox内缩放不传递(g组scale累积到所有子元素) - 修复text baseline偏移(区分text-after-edge和auto) - 修复text-anchor:middle/end的x坐标偏移 - 添加--html-dir参数支持 html2svg.py: - 修复图片相对路径解析(以HTML文件所在目录为基准) - 新增3种CSS兜底预处理(background-clip:text、text-fill-color、mask-image) 新增 references/pipeline-compat.md: - HTML->SVG->PPTX管线兼容性规则文档 - CSS禁止清单、防偏移写法指南、防偏移checklist - 已整合到SKILL.md和prompts.md中引用 prompts.md: - 新增内联SVG防偏移约束(禁SVG text、用HTML叠加) 示例产物: - ppt-output/ 包含SU7示例的完整HTML/SVG/PPTX产物
995 lines
40 KiB
Python
995 lines
40 KiB
Python
#!/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('"', '').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, 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, scale, slide):
|
||
tag = self._tag(el)
|
||
try:
|
||
if tag == 'rect':
|
||
self._rect(el, sp, ox, oy, group_opacity, scale, slide)
|
||
elif tag == 'text':
|
||
self._text(el, sp, ox, oy, group_opacity, scale)
|
||
elif tag == 'circle':
|
||
self._circle(el, sp, ox, oy, group_opacity, scale)
|
||
elif tag == 'ellipse':
|
||
self._ellipse(el, sp, ox, oy, group_opacity, scale)
|
||
elif tag == 'line':
|
||
self._line(el, sp, ox, oy, scale)
|
||
elif tag == 'path':
|
||
self._path(el, sp, ox, oy, group_opacity, scale)
|
||
elif tag == 'image':
|
||
self._image(el, sp, ox, oy, group_opacity, scale, 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 累积:父级scale * 当前g的scale
|
||
child_scale = scale * sx # 假设sx==sy(等比缩放)
|
||
new_ox = ox + dx * scale
|
||
new_oy = oy + dy * scale
|
||
for c in el:
|
||
self._walk(c, sp, new_ox, new_oy,
|
||
child_opacity, child_scale, slide)
|
||
elif tag in ('defs', 'style', 'linearGradient', 'radialGradient',
|
||
'stop', 'pattern', 'clipPath', 'filter', 'mask'):
|
||
pass
|
||
else:
|
||
for c in el:
|
||
self._walk(c, sp, ox, oy, group_opacity, scale, 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, scale, slide):
|
||
x = (float(el.get('x', 0)) * scale) + ox
|
||
y = (float(el.get('y', 0)) * scale) + oy
|
||
w = float(el.get('width', 0)) * scale
|
||
h = float(el.get('height', 0)) * scale
|
||
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, scale):
|
||
"""每个 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', '')
|
||
anchor = el.get('text-anchor', 'start')
|
||
|
||
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)) * scale + ox
|
||
y = float(ts.get('y', 0)) * scale + 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)
|
||
# baseline偏移: text-after-edge -> y是底部减全高; auto -> y是baseline减85%
|
||
if 'after-edge' in baseline:
|
||
y -= fh
|
||
else:
|
||
y -= fh * 0.85
|
||
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)
|
||
# text-anchor 偏移: middle -> x减半宽, end -> x减全宽
|
||
if anchor == 'middle':
|
||
x -= cx_v / EMU_PX / 2
|
||
elif anchor == 'end':
|
||
x -= cx_v / EMU_PX
|
||
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)) * scale + ox
|
||
y = float(el.get('y', 0)) * scale + oy
|
||
fh = float(fsz)
|
||
# baseline偏移
|
||
if 'after-edge' in baseline:
|
||
y -= fh
|
||
else:
|
||
y -= fh * 0.85
|
||
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()
|
||
txt_w = len(txt) * float(fsz) * 0.7
|
||
# text-anchor 偏移
|
||
if anchor == 'middle':
|
||
x -= txt_w / 2
|
||
elif anchor == 'end':
|
||
x -= txt_w
|
||
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, scale):
|
||
cx_v = float(el.get('cx', 0)) * scale + ox
|
||
cy_v = float(el.get('cy', 0)) * scale + oy
|
||
r = float(el.get('r', 0)) * scale
|
||
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':
|
||
# stroke 引用渐变 -> 提取渐变的第一个 stop 颜色作为实色
|
||
gdef = self.grads.get(stroke_color[1])
|
||
if gdef and gdef.get('stops'):
|
||
first_stop = gdef['stops'][0]
|
||
sc = parse_color(first_stop['color_str'])
|
||
if sc and sc[0] != 'grad':
|
||
ln_children.append(_el('a:solidFill', children=[
|
||
_srgb(sc[0], int(sc[1] * el_opacity))
|
||
]))
|
||
# 也尝试用渐变填充(OOXML线条支持渐变)
|
||
if not ln_children and gdef:
|
||
grad_fill = _make_grad(gdef)
|
||
if grad_fill is not None:
|
||
ln_children.append(grad_fill)
|
||
elif 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, scale):
|
||
cx_v = float(el.get('cx', 0)) * scale + ox
|
||
cy_v = float(el.get('cy', 0)) * scale + oy
|
||
rx = float(el.get('rx', 0)) * scale
|
||
ry = float(el.get('ry', 0)) * scale
|
||
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, scale):
|
||
x1 = float(el.get('x1', 0)) * scale + ox
|
||
y1 = float(el.get('y1', 0)) * scale + oy
|
||
x2 = float(el.get('x2', 0)) * scale + ox
|
||
y2 = float(el.get('y2', 0)) * scale + 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, scale):
|
||
"""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) * scale) if scale != 1.0 else px(bx + ox),
|
||
px((by + oy) * scale) if scale != 1.0 else px(by + oy),
|
||
px(bw * scale), px(bh * scale),
|
||
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, opacity, scale, slide):
|
||
href = el.get(f'{{{XLINK_NS}}}href') or el.get('href', '')
|
||
x = float(el.get('x', 0)) * scale + ox
|
||
y = float(el.get('y', 0)) * scale + oy
|
||
w = float(el.get('width', 0)) * scale
|
||
h = float(el.get('height', 0)) * scale
|
||
el_opacity = float(el.get('opacity', '1')) * opacity
|
||
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
|
||
|
||
# 应用透明度(通过 OOXML alphaModFix)
|
||
if el_opacity < 0.99:
|
||
from pptx.oxml.ns import qn
|
||
sp_pr = pic._element.find(qn('p:spPr'))
|
||
if sp_pr is None:
|
||
sp_pr = pic._element.find(qn('pic:spPr'))
|
||
# 在 blipFill 的 blip 上设置 alphaModFix
|
||
blip = pic._element.find('.//' + qn('a:blip'))
|
||
if blip is not None:
|
||
alpha_val = int(el_opacity * 100000)
|
||
alpha_el = _el('a:alphaModFix', {'amt': str(alpha_val)})
|
||
blip.append(alpha_el)
|
||
|
||
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')
|
||
parser.add_argument('--html-dir', default=None,
|
||
help='HTML source directory (for future notes extraction)')
|
||
args = parser.parse_args()
|
||
convert(args.svg, args.output)
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|