#!/usr/bin/env python3
"""把「已经写好的视觉稿 HTML」直接转成可编辑 PPTX —— 不要求 HTML 满足任何硬约束。
和 html2pptx.js 的分工(别混用,见 references/editable-pptx.md 顶部的决策表):
html2pptx.js HTML 还没写 → 按 4 条硬约束写出来,导出的文本框结构最干净
本脚本 HTML 已经写好且是视觉驱动的(flex / 居中 / 裸文字 / 背景图)
→ 零改造直接转;要继承甲方官方模板母版时也只能走这条
为什么能绕开那 4 条硬约束:它读的不是源码,是**浏览器渲染完之后**的
getBoundingClientRect。flex、居中、自动换行浏览器都已经算成绝对坐标了,
所以「div 里有裸文字」「用了 flex」这些写法根本不构成问题。
四类元素对应 PowerPoint 的四种对象:
text → 文本框(按
分段,每段一个框,各带自己的 runs 和行高)
shape → 矩形 / 圆角矩形(卡片底、色条、分隔线、行内装饰块)
img → 图片(CSS 圆角会烤进 alpha 通道)
svg → 截成 PNG 的图片(图表拆成几百个矩形反而没法编辑,留图更实用)
用法:
python3 pptx_from_rendered.py deck.html -o deck.pptx
python3 pptx_from_rendered.py deck.html -o deck.pptx \\
--template 客户模板.pptx --layout "内页" --skip-class logo
依赖:playwright(含 chromium)、python-pptx、Pillow
"""
import argparse, asyncio, json, os, re, sys
from PIL import Image, ImageDraw
from pptx import Presentation
from pptx.dml.color import RGBColor
from pptx.enum.shapes import MSO_SHAPE
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
from pptx.oxml.ns import qn
from pptx.util import Emu, Pt
EMU_PER_PT = 12700
# ─────────────────────────────────────────────────────────────
# 一、在浏览器里量:把渲染结果拆成元素清单
# ─────────────────────────────────────────────────────────────
JS = r"""
(selector) => {
const INLINE = ['em','b','i','strong','span','small','br','sup','sub','a','code','mark'];
const px = v => parseFloat(v) || 0;
// 把一段节点拆成 runs(每段连续同样式的文字一个 run)。
//
// ⚠️ 必须在这里做 HTML 的空白折叠。源码里的换行和缩进会变成真实的文本节点,
// 浏览器按 white-space:normal 折叠掉(连续空白→一个空格,行首行尾丢弃),
// 但 PPTX 没有这套规则——原样搬过去,那个 \n 在 PowerPoint 里就是一个真换行,
// 会把后面的内容整段推到下一行去压住别的元素。
const runsOf = (root) => {
const out = [];
let atLineStart = true, pendingSpace = false;
const push = (node, styleEl) => {
let t = node.textContent;
if (!t) return;
t = t.replace(/\s+/g, ' ');
if (t === ' ') { if (!atLineStart) pendingSpace = true; return; }
if (atLineStart) t = t.replace(/^ /, '');
if (pendingSpace && !t.startsWith(' ')) t = ' ' + t;
pendingSpace = false;
if (!t) return;
atLineStart = false;
const cs = getComputedStyle(styleEl);
out.push({
t, fs: px(cs.fontSize), fw: cs.fontWeight, color: cs.color,
ls: cs.letterSpacing === 'normal' ? 0 : px(cs.letterSpacing),
italic: cs.fontStyle === 'italic',
under: cs.textDecorationLine.includes('underline'),
});
};
const walk = (node, styleEl) => {
for (const n of node.childNodes) {
if (n.nodeType === 3) push(n, styleEl);
else if (n.tagName && n.tagName.toLowerCase() === 'br') {
out.push({br: true}); atLineStart = true; pendingSpace = false;
} else if (n.nodeType === 1) walk(n, n);
}
};
walk(root, root);
for (let i = out.length - 1; i >= 0 && !out[i].br; i--) {
if (out[i].t) { out[i].t = out[i].t.replace(/ $/, ''); break; }
}
return out.filter(r => r.br || r.t);
};
// 量一组节点占的位置和行数。
// 数行数不能拿每个矩形的 top 去重——同一行里字号不同的 run(一个 132px 的数字挨着
// 62px 的说明)基线对齐、顶边却差一大截,会被当成两行。按 y 区间是否重叠来聚类。
const measure = (nodes) => {
const rng = document.createRange();
rng.setStartBefore(nodes[0]);
rng.setEndAfter(nodes[nodes.length - 1]);
const bb = rng.getBoundingClientRect();
const rects = [...rng.getClientRects()]
.filter(q => q.width > 0.5 && q.height > 0.5)
.sort((a, b) => a.top - b.top);
const rows = [];
for (const q of rects) {
const last = rows[rows.length - 1];
if (last && q.top < last.bottom - 2) last.bottom = Math.max(last.bottom, q.bottom);
else rows.push({top: q.top, bottom: q.bottom});
}
return {bb, lines: Math.max(1, rows.length)};
};
const pages = [...document.querySelectorAll(selector)];
return pages.map((pg) => {
const pb = pg.getBoundingClientRect();
const out = [];
let svgSeq = 0;
const walk = (el) => {
const cs = getComputedStyle(el);
const r = el.getBoundingClientRect();
const tag = el.tagName.toLowerCase();
if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return;
const cls = (typeof el.className === 'string' ? el.className : '');
const base = {tag, cls, x: r.left - pb.left, y: r.top - pb.top, w: r.width, h: r.height};
if (tag === 'img') {
out.push({...base, kind: 'img', src: el.getAttribute('src'),
radius: px(cs.borderRadius), shadow: cs.boxShadow});
return;
}
if (tag === 'svg' || tag === 'canvas') {
out.push({...base, kind: 'svg', seq: svgSeq++, tagName: tag});
return;
}
const hasText = el.innerText && el.innerText.trim().length > 0;
const onlyInline = [...el.children].every(c => INLINE.includes(c.tagName.toLowerCase()));
const bg = cs.backgroundColor;
const painted = (bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent');
const bdw = px(cs.borderTopWidth);
if (painted || bdw > 0) { // 背景/描边先出矩形,画在文字之下
out.push({...base, kind: 'shape', bg, bdw, bdc: cs.borderTopColor,
radius: px(cs.borderRadius), shadow: cs.boxShadow, rot: 0});
}
if (hasText && onlyInline) {
// 按
分段,每段单独出一个文本框。
//
// 为什么不整块出一个框:PowerPoint 的行距是段落级的,而视觉稿经常把不同字号的行
// 放进同一个 div。整块套一个行距,小字那几行会被撑开、大字那行会被压,
// 实测会出现相邻两行直接叠在一起。分段之后每段用自己的实际行高,就不会互相牵连。
const groups = [[]];
for (const n of el.childNodes) {
if (n.nodeType === 1 && n.tagName.toLowerCase() === 'br') groups.push([]);
else groups[groups.length - 1].push(n);
}
for (const g of groups) {
const nodes = g.filter(n => n.nodeType !== 3 || n.textContent.trim());
if (!nodes.length) continue;
const {bb, lines} = measure(nodes);
if (!bb.height) continue;
const tmp = document.createElement('div');
for (const n of g) tmp.appendChild(n.cloneNode(true));
tmp.style.cssText = 'position:absolute;visibility:hidden';
el.appendChild(tmp);
const rs = runsOf(tmp);
tmp.remove();
if (!rs.length) continue;
const fsMax = Math.max(px(cs.fontSize), ...rs.filter(r => !r.br).map(r => r.fs));
out.push({
tag, cls, kind: 'text',
// x/w 用容器的:居中的段落要靠容器宽度维持居中语义,
// 用段落自己收缩后的宽度会在换字体后左右飘。
x: base.x, w: base.w,
y: bb.top - pb.top, h: bb.height,
fs: px(cs.fontSize), fsMax, fw: cs.fontWeight, color: cs.color,
ls: cs.letterSpacing === 'normal' ? 0 : px(cs.letterSpacing),
ta: cs.textAlign,
lhEff: bb.height / lines, lines,
wrap: lines > 1,
runs: rs,
});
}
// 行内的纯装饰色块(压在字上的删除线、高亮条)不能跟着文字被吞掉,
// 单独出形状,且要画在文字之上。
for (const c of el.children) {
const ccs = getComputedStyle(c);
const cbg = ccs.backgroundColor;
if (c.textContent.trim() === '' &&
cbg !== 'rgba(0, 0, 0, 0)' && cbg !== 'transparent') {
const cr = c.getBoundingClientRect();
let rot = 0;
const m = ccs.transform.match(/matrix\(([^)]+)\)/);
if (m) {
const [a, b] = m[1].split(',').map(parseFloat);
rot = Math.round(Math.atan2(b, a) * 180 / Math.PI * 10) / 10;
}
out.push({tag: c.tagName.toLowerCase(), cls: '', kind: 'shape',
x: cr.left - pb.left, y: cr.top - pb.top, w: cr.width, h: cr.height,
bg: cbg, bdw: px(ccs.borderTopWidth), bdc: ccs.borderTopColor,
radius: px(ccs.borderRadius), shadow: ccs.boxShadow, rot});
}
}
return;
}
for (const c of el.children) walk(c);
};
for (const c of pg.children) walk(c);
return {w: pb.width, h: pb.height, els: out};
});
}
"""
async def render(html, selector, asset_dir, scale):
from playwright.async_api import async_playwright
os.makedirs(asset_dir, exist_ok=True)
async with async_playwright() as p:
br = await p.chromium.launch()
pg = await br.new_page(viewport={"width": 1920, "height": 1080},
device_scale_factor=scale)
await pg.goto("file://" + os.path.abspath(html))
await pg.wait_for_timeout(2500)
pages = await pg.evaluate(JS, selector)
for pi, page in enumerate(pages): # SVG / canvas 单独截成 PNG
for e in page["els"]:
if e["kind"] == "svg":
name = f"p{pi+1:02d}_{e['tagName']}{e['seq']}.png"
await pg.locator(selector).nth(pi).locator(e["tagName"]).nth(e["seq"]) \
.screenshot(path=os.path.join(asset_dir, name), omit_background=True)
e["file"] = os.path.join(asset_dir, name)
await br.close()
return pages
# ─────────────────────────────────────────────────────────────
# 二、翻译成 PowerPoint 对象
# ─────────────────────────────────────────────────────────────
ALIGN = {"left": PP_ALIGN.LEFT, "center": PP_ALIGN.CENTER, "start": PP_ALIGN.LEFT,
"right": PP_ALIGN.RIGHT, "end": PP_ALIGN.RIGHT, "justify": PP_ALIGN.JUSTIFY}
def parse_color(css):
"""'rgb(r,g,b)' / 'rgba(r,g,b,a)' → (RGBColor, alpha)。"""
m = re.findall(r"[\d.]+", css or "")
if len(m) < 3:
return None, 0.0
r, g, b = (int(float(v)) for v in m[:3])
return RGBColor(r, g, b), (float(m[3]) if len(m) > 3 else 1.0)
def _alpha(clr_el, alpha):
a = clr_el.makeelement(qn("a:alpha"), {})
a.set("val", str(int(alpha * 100000)))
clr_el.append(a)
class Builder:
def __init__(self, cfg):
self.cfg = cfg
self.round_dir = os.path.join(cfg.asset_dir, "_round")
# ── 文本 ──────────────────────────────────────────────
def set_run_font(self, run, r):
f = run.font
f.size = Pt(r["fs"] * self.k)
fw = str(r["fw"])
f.bold = int(fw) >= 600 if fw.isdigit() else fw in ("bold", "bolder")
f.italic = r.get("italic", False)
f.underline = r.get("under", False)
f.name = self.cfg.font_latin # 只设了 latin,中文要另外指定
col, alpha = parse_color(r["color"])
if col:
f.color.rgb = col
rPr = run._r.get_or_add_rPr()
for tag, val in (("a:ea", self.cfg.font_ea), ("a:cs", self.cfg.font_latin)):
el = rPr.find(qn(tag))
if el is None:
el = rPr.makeelement(qn(tag), {})
rPr.append(el)
el.set("typeface", val)
if r.get("ls"):
rPr.set("spc", str(int(round(r["ls"] * self.k * 100)))) # 单位 1/100 pt
if col and alpha < 1:
solid = rPr.find(qn("a:solidFill"))
if solid is not None and solid.find(qn("a:srgbClr")) is not None:
_alpha(solid.find(qn("a:srgbClr")), alpha)
def add_text(self, slide, e):
"""一个段落 → 一个文本框。
宽度要放余量:视觉稿里这些框常是 flex 收缩包裹的,宽度恰好等于文字宽度,
换到 PowerPoint 只要字体度量差一点点,最后一个字就会被挤到下一行。
本来就是单行的段落直接关掉自动换行,从根上不可能折行。
"""
k = self.k
wrap = e.get("wrap", True)
fs = e.get("fsMax") or e["fs"] # 容器字号常常是继承来的小值,余量要按最大的字算
pad = (fs * 0.25) if wrap else max(12.0, fs * 0.8)
x, w = e["x"], e["w"] + pad
ta = e.get("ta")
if ta in ("center",):
x -= pad / 2 # 居中框两边一起放,视觉中心不动
elif ta in ("right", "end"):
x -= pad
box = slide.shapes.add_textbox(Pt(x * k), Pt(e["y"] * k), Pt(w * k), Pt(e["h"] * k))
tf = box.text_frame
tf.word_wrap = wrap
tf.auto_size = None
tf.vertical_anchor = MSO_ANCHOR.TOP
tf.margin_left = tf.margin_right = tf.margin_top = tf.margin_bottom = 0
paras = [[]]
for r in e["runs"]:
paras.append([]) if r.get("br") else paras[-1].append(r)
paras = [p for p in paras if p] or [[]]
for i, runs in enumerate(paras):
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
p.alignment = ALIGN.get(ta, PP_ALIGN.LEFT)
# 行距只对「这一段自己折了行」的框设。单行段落不设:量到的行盒高度比实际行间距
# 大(那是字体行盒不是 CSS 行距),拿它当精确行距会把文字整体压下去;
# 单行时交给 PowerPoint 按字号自然排,文字紧贴框顶,位置最准。
if e.get("lines", 1) > 1 and e.get("lhEff"):
p.line_spacing = Pt(e["lhEff"] * k)
for r in runs:
run = p.add_run()
run.text = r["t"]
self.set_run_font(run, r)
return box
# ── 形状 ──────────────────────────────────────────────
def add_shape(self, slide, e):
k = self.k
rounded = e.get("radius", 0) >= 4
shp = slide.shapes.add_shape(
MSO_SHAPE.ROUNDED_RECTANGLE if rounded else MSO_SHAPE.RECTANGLE,
Pt(e["x"] * k), Pt(e["y"] * k), Pt(e["w"] * k), Pt(e["h"] * k))
shp.shadow.inherit = False
col, alpha = parse_color(e.get("bg"))
if col and alpha > 0:
shp.fill.solid()
shp.fill.fore_color.rgb = col
if alpha < 1:
sf = shp.fill._xPr.find(qn("a:solidFill"))
_alpha(sf.find(qn("a:srgbClr")), alpha)
else:
shp.fill.background()
bcol, balpha = parse_color(e.get("bdc"))
if e.get("bdw", 0) > 0 and bcol and balpha > 0:
shp.line.color.rgb = bcol
shp.line.width = Pt(e["bdw"] * k)
else:
shp.line.fill.background()
if e.get("rot"):
shp.rotation = -e["rot"] # CSS 逆时针为负,PowerPoint 顺时针为正
if rounded and e["w"] and e["h"]:
# PowerPoint 的圆角是「短边的百分比」,换算回 CSS 的 px 半径
shp.adjustments[0] = min(0.5, e["radius"] / min(e["w"], e["h"]))
return shp
# ── 图片 ──────────────────────────────────────────────
def round_corners(self, path, radius_px, box_w):
"""PowerPoint 的图片没有圆角,把圆角烤进 PNG 的 alpha 通道。"""
os.makedirs(self.round_dir, exist_ok=True)
out = os.path.join(self.round_dir, f"{abs(hash((path, radius_px, box_w))) % 10**10}.png")
if os.path.exists(out):
return out
im = Image.open(path).convert("RGBA")
scale = im.width / box_w if box_w else 1
r = int(min(radius_px * scale, min(im.size) / 2))
mask = Image.new("L", im.size, 0)
ImageDraw.Draw(mask).rounded_rectangle([0, 0, im.width - 1, im.height - 1],
radius=r, fill=255)
im.putalpha(mask)
im.save(out)
return out
def add_img(self, slide, e):
k = self.k
src = e.get("file") or e["src"]
if not src or src.startswith("data:"):
return None
path = src if os.path.isabs(src) else os.path.normpath(os.path.join(self.html_dir, src))
if not os.path.exists(path):
print(f" ⚠️ 找不到图片 {src}")
return None
if e.get("radius", 0) >= 2:
path = self.round_corners(path, e["radius"], e["w"])
pic = slide.shapes.add_picture(path, Pt(e["x"] * k), Pt(e["y"] * k),
Pt(e["w"] * k), Pt(e["h"] * k))
# box-shadow: rgba(...) 0 0 0 Npx 在视觉稿里通常是硬描边,翻译成图片边框
m = re.match(r"rgba?\(([^)]+)\)\s+0px\s+0px\s+0px\s+([\d.]+)px", e.get("shadow") or "")
if m:
col, alpha = parse_color("rgba(" + m.group(1) + ")")
if col and alpha > 0:
pic.line.color.rgb = col
pic.line.width = Pt(float(m.group(2)) * k)
return pic
# ── 装配 ──────────────────────────────────────────────
def run(self, pages):
cfg = self.cfg
self.html_dir = os.path.dirname(os.path.abspath(cfg.html))
if cfg.template:
prs = Presentation(cfg.template)
strip_slides(prs) # 模板自带的示例页删掉,母版/版式一个不动
else:
prs = Presentation()
pw, ph = pages[0]["w"], pages[0]["h"]
prs.slide_width, prs.slide_height = Pt(pw), Pt(ph) # 1 CSS px = 1 pt
# HTML 画布宽 → 幻灯片宽 的比例。画布 1920px 配 26.667in(=1920pt) 时正好是 1.0。
self.k = (prs.slide_width / EMU_PER_PT) / pages[0]["w"]
if cfg.layout:
layout = next((l for l in prs.slide_layouts if l.name == cfg.layout), None)
if layout is None:
names = " / ".join(l.name for l in prs.slide_layouts)
sys.exit(f"❌ 模板里没有版式「{cfg.layout}」。可选:{names}")
killed = unblock_layout(layout, prs.slide_width, prs.slide_height)
else:
layout = prs.slide_layouts[6] if len(prs.slide_layouts) > 6 else prs.slide_layouts[0]
killed = []
n_text = n_shape = n_img = 0
for page in pages:
slide = prs.slides.add_slide(layout)
for sh in list(slide.shapes): # 版式带来的空占位符不留在页面上
sh._element.getparent().remove(sh._element)
if cfg.bg:
set_bg(slide, cfg.bg)
for e in page["els"]:
if cfg.skip_class and cfg.skip_class in (e.get("cls") or "").split():
continue
if e["kind"] == "shape":
self.add_shape(slide, e); n_shape += 1
elif e["kind"] == "text":
self.add_text(slide, e); n_text += 1
else:
if self.add_img(slide, e) is not None:
n_img += 1
prs.save(cfg.out)
mb = os.path.getsize(cfg.out) / 1024 / 1024
print(f"✅ {len(prs.slides)} 页 → {os.path.basename(cfg.out)}({mb:.1f}MB)")
print(f" 文本框 {n_text} · 形状 {n_shape} · 图片 {n_img}"
+ (f" · 清掉版式遮罩 {killed}" if killed else ""))
print(f" 画布 {prs.slide_width/914400:.3f}×{prs.slide_height/914400:.3f} inch"
f"(缩放 {self.k:.4f})")
# ─────────────────────────────────────────────────────────────
# 三、模板相关的三个小手术
# ─────────────────────────────────────────────────────────────
def strip_slides(prs):
"""删掉模板自带的示例页;母版、版式、主题、色板一个不动。"""
lst = prs.slides._sldIdLst
for sld in list(lst):
prs.part.drop_rel(sld.rId)
lst.remove(sld)
def unblock_layout(layout, W, H):
"""删掉版式里铺满全屏的纯色矩形。
有些官方模板的版式里放着一个和版式底色同色同尺寸的全屏矩形(冗余遮罩)。
留着它,页面上任何放在它下面的东西都会被挡住;删掉不改变版式的外观。
"""
killed = []
for sp in list(layout.shapes):
full = (sp.left == 0 and sp.top == 0 and sp.width and sp.height
and sp.width >= W and sp.height >= H)
if full and b"" in sp._element.xml.encode():
sp._element.getparent().remove(sp._element)
killed.append(sp.name)
return killed
def set_bg(slide, rgb_hex):
bg = slide._element.makeelement(qn("p:bg"), {})
pr = slide._element.makeelement(qn("p:bgPr"), {})
fill = slide._element.makeelement(qn("a:solidFill"), {})
clr = slide._element.makeelement(qn("a:srgbClr"), {"val": rgb_hex.lstrip("#").upper()})
fill.append(clr); pr.append(fill)
pr.append(slide._element.makeelement(qn("a:effectLst"), {}))
bg.append(pr)
slide._element.find(qn("p:cSld")).insert(0, bg)
# ─────────────────────────────────────────────────────────────
def main():
ap = argparse.ArgumentParser(description="视觉稿 HTML → 可编辑 PPTX(读渲染后坐标,不改 HTML)")
ap.add_argument("html")
ap.add_argument("-o", "--out", required=True, help="输出 .pptx")
ap.add_argument("--selector", default=".slide, .s, section",
help="每一页的 CSS 选择器(默认 '.slide, .s, section')")
ap.add_argument("--template", help="以这个 .pptx 为基底,继承它的母版/版式/主题/色板")
ap.add_argument("--layout", help="每页套用的版式名(配合 --template)")
ap.add_argument("--skip-class", help="跳过带这个 class 的元素,例如 logo 由版式提供时填 logo")
ap.add_argument("--font-latin", default="Microsoft YaHei", help="西文字体名")
ap.add_argument("--font-ea", default="微软雅黑", help="东亚字体名")
ap.add_argument("--bg", help="每页底色,如 05070B;不给则用版式底色")
ap.add_argument("--asset-dir", help="SVG/圆角图的落地目录(默认 输出同级 _pptx_assets/)")
ap.add_argument("--scale", type=int, default=3, help="SVG 截图倍数(默认 3)")
ap.add_argument("--dump-json", help="把量到的元素清单写出来,便于排查")
cfg = ap.parse_args()
cfg.out = os.path.abspath(cfg.out)
cfg.asset_dir = cfg.asset_dir or os.path.join(os.path.dirname(cfg.out), "_pptx_assets")
if cfg.layout and not cfg.template:
sys.exit("❌ --layout 需要配合 --template 使用")
pages = asyncio.run(render(cfg.html, cfg.selector, cfg.asset_dir, cfg.scale))
if not pages:
sys.exit(f"❌ 选择器 '{cfg.selector}' 一页都没匹配到")
if cfg.dump_json:
json.dump(pages, open(cfg.dump_json, "w"), ensure_ascii=False, indent=1)
kinds = {}
for p in pages:
for e in p["els"]:
kinds[e["kind"]] = kinds.get(e["kind"], 0) + 1
print(f"📐 量到 {len(pages)} 页 {kinds}")
Builder(cfg).run(pages)
if __name__ == "__main__":
main()