pptx_from_rendered.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538
  1. #!/usr/bin/env python3
  2. """把「已经写好的视觉稿 HTML」直接转成可编辑 PPTX —— 不要求 HTML 满足任何硬约束。
  3. 和 html2pptx.js 的分工(别混用,见 references/editable-pptx.md 顶部的决策表):
  4. html2pptx.js HTML 还没写 → 按 4 条硬约束写出来,导出的文本框结构最干净
  5. 本脚本 HTML 已经写好且是视觉驱动的(flex / 居中 / 裸文字 / 背景图)
  6. → 零改造直接转;要继承甲方官方模板母版时也只能走这条
  7. 为什么能绕开那 4 条硬约束:它读的不是源码,是**浏览器渲染完之后**的
  8. getBoundingClientRect。flex、居中、自动换行浏览器都已经算成绝对坐标了,
  9. 所以「div 里有裸文字」「用了 flex」这些写法根本不构成问题。
  10. 四类元素对应 PowerPoint 的四种对象:
  11. text → 文本框(按 <br> 分段,每段一个框,各带自己的 runs 和行高)
  12. shape → 矩形 / 圆角矩形(卡片底、色条、分隔线、行内装饰块)
  13. img → 图片(CSS 圆角会烤进 alpha 通道)
  14. svg → 截成 PNG 的图片(图表拆成几百个矩形反而没法编辑,留图更实用)
  15. 用法:
  16. python3 pptx_from_rendered.py deck.html -o deck.pptx
  17. python3 pptx_from_rendered.py deck.html -o deck.pptx \\
  18. --template 客户模板.pptx --layout "内页" --skip-class logo
  19. 依赖:playwright(含 chromium)、python-pptx、Pillow
  20. """
  21. import argparse, asyncio, json, os, re, sys
  22. from PIL import Image, ImageDraw
  23. from pptx import Presentation
  24. from pptx.dml.color import RGBColor
  25. from pptx.enum.shapes import MSO_SHAPE
  26. from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
  27. from pptx.oxml.ns import qn
  28. from pptx.util import Emu, Pt
  29. EMU_PER_PT = 12700
  30. # ─────────────────────────────────────────────────────────────
  31. # 一、在浏览器里量:把渲染结果拆成元素清单
  32. # ─────────────────────────────────────────────────────────────
  33. JS = r"""
  34. (selector) => {
  35. const INLINE = ['em','b','i','strong','span','small','br','sup','sub','a','code','mark'];
  36. const px = v => parseFloat(v) || 0;
  37. // 把一段节点拆成 runs(每段连续同样式的文字一个 run)。
  38. //
  39. // ⚠️ 必须在这里做 HTML 的空白折叠。源码里的换行和缩进会变成真实的文本节点,
  40. // 浏览器按 white-space:normal 折叠掉(连续空白→一个空格,行首行尾丢弃),
  41. // 但 PPTX 没有这套规则——原样搬过去,那个 \n 在 PowerPoint 里就是一个真换行,
  42. // 会把后面的内容整段推到下一行去压住别的元素。
  43. const runsOf = (root) => {
  44. const out = [];
  45. let atLineStart = true, pendingSpace = false;
  46. const push = (node, styleEl) => {
  47. let t = node.textContent;
  48. if (!t) return;
  49. t = t.replace(/\s+/g, ' ');
  50. if (t === ' ') { if (!atLineStart) pendingSpace = true; return; }
  51. if (atLineStart) t = t.replace(/^ /, '');
  52. if (pendingSpace && !t.startsWith(' ')) t = ' ' + t;
  53. pendingSpace = false;
  54. if (!t) return;
  55. atLineStart = false;
  56. const cs = getComputedStyle(styleEl);
  57. out.push({
  58. t, fs: px(cs.fontSize), fw: cs.fontWeight, color: cs.color,
  59. ls: cs.letterSpacing === 'normal' ? 0 : px(cs.letterSpacing),
  60. italic: cs.fontStyle === 'italic',
  61. under: cs.textDecorationLine.includes('underline'),
  62. });
  63. };
  64. const walk = (node, styleEl) => {
  65. for (const n of node.childNodes) {
  66. if (n.nodeType === 3) push(n, styleEl);
  67. else if (n.tagName && n.tagName.toLowerCase() === 'br') {
  68. out.push({br: true}); atLineStart = true; pendingSpace = false;
  69. } else if (n.nodeType === 1) walk(n, n);
  70. }
  71. };
  72. walk(root, root);
  73. for (let i = out.length - 1; i >= 0 && !out[i].br; i--) {
  74. if (out[i].t) { out[i].t = out[i].t.replace(/ $/, ''); break; }
  75. }
  76. return out.filter(r => r.br || r.t);
  77. };
  78. // 量一组节点占的位置和行数。
  79. // 数行数不能拿每个矩形的 top 去重——同一行里字号不同的 run(一个 132px 的数字挨着
  80. // 62px 的说明)基线对齐、顶边却差一大截,会被当成两行。按 y 区间是否重叠来聚类。
  81. const measure = (nodes) => {
  82. const rng = document.createRange();
  83. rng.setStartBefore(nodes[0]);
  84. rng.setEndAfter(nodes[nodes.length - 1]);
  85. const bb = rng.getBoundingClientRect();
  86. const rects = [...rng.getClientRects()]
  87. .filter(q => q.width > 0.5 && q.height > 0.5)
  88. .sort((a, b) => a.top - b.top);
  89. const rows = [];
  90. for (const q of rects) {
  91. const last = rows[rows.length - 1];
  92. if (last && q.top < last.bottom - 2) last.bottom = Math.max(last.bottom, q.bottom);
  93. else rows.push({top: q.top, bottom: q.bottom});
  94. }
  95. return {bb, lines: Math.max(1, rows.length)};
  96. };
  97. const pages = [...document.querySelectorAll(selector)];
  98. return pages.map((pg) => {
  99. const pb = pg.getBoundingClientRect();
  100. const out = [];
  101. let svgSeq = 0;
  102. const walk = (el) => {
  103. const cs = getComputedStyle(el);
  104. const r = el.getBoundingClientRect();
  105. const tag = el.tagName.toLowerCase();
  106. if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return;
  107. const cls = (typeof el.className === 'string' ? el.className : '');
  108. const base = {tag, cls, x: r.left - pb.left, y: r.top - pb.top, w: r.width, h: r.height};
  109. if (tag === 'img') {
  110. out.push({...base, kind: 'img', src: el.getAttribute('src'),
  111. radius: px(cs.borderRadius), shadow: cs.boxShadow});
  112. return;
  113. }
  114. if (tag === 'svg' || tag === 'canvas') {
  115. out.push({...base, kind: 'svg', seq: svgSeq++, tagName: tag});
  116. return;
  117. }
  118. const hasText = el.innerText && el.innerText.trim().length > 0;
  119. const onlyInline = [...el.children].every(c => INLINE.includes(c.tagName.toLowerCase()));
  120. const bg = cs.backgroundColor;
  121. const painted = (bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent');
  122. const bdw = px(cs.borderTopWidth);
  123. if (painted || bdw > 0) { // 背景/描边先出矩形,画在文字之下
  124. out.push({...base, kind: 'shape', bg, bdw, bdc: cs.borderTopColor,
  125. radius: px(cs.borderRadius), shadow: cs.boxShadow, rot: 0});
  126. }
  127. if (hasText && onlyInline) {
  128. // 按 <br> 分段,每段单独出一个文本框。
  129. //
  130. // 为什么不整块出一个框:PowerPoint 的行距是段落级的,而视觉稿经常把不同字号的行
  131. // 放进同一个 div。整块套一个行距,小字那几行会被撑开、大字那行会被压,
  132. // 实测会出现相邻两行直接叠在一起。分段之后每段用自己的实际行高,就不会互相牵连。
  133. const groups = [[]];
  134. for (const n of el.childNodes) {
  135. if (n.nodeType === 1 && n.tagName.toLowerCase() === 'br') groups.push([]);
  136. else groups[groups.length - 1].push(n);
  137. }
  138. for (const g of groups) {
  139. const nodes = g.filter(n => n.nodeType !== 3 || n.textContent.trim());
  140. if (!nodes.length) continue;
  141. const {bb, lines} = measure(nodes);
  142. if (!bb.height) continue;
  143. const tmp = document.createElement('div');
  144. for (const n of g) tmp.appendChild(n.cloneNode(true));
  145. tmp.style.cssText = 'position:absolute;visibility:hidden';
  146. el.appendChild(tmp);
  147. const rs = runsOf(tmp);
  148. tmp.remove();
  149. if (!rs.length) continue;
  150. const fsMax = Math.max(px(cs.fontSize), ...rs.filter(r => !r.br).map(r => r.fs));
  151. out.push({
  152. tag, cls, kind: 'text',
  153. // x/w 用容器的:居中的段落要靠容器宽度维持居中语义,
  154. // 用段落自己收缩后的宽度会在换字体后左右飘。
  155. x: base.x, w: base.w,
  156. y: bb.top - pb.top, h: bb.height,
  157. fs: px(cs.fontSize), fsMax, fw: cs.fontWeight, color: cs.color,
  158. ls: cs.letterSpacing === 'normal' ? 0 : px(cs.letterSpacing),
  159. ta: cs.textAlign,
  160. lhEff: bb.height / lines, lines,
  161. wrap: lines > 1,
  162. runs: rs,
  163. });
  164. }
  165. // 行内的纯装饰色块(压在字上的删除线、高亮条)不能跟着文字被吞掉,
  166. // 单独出形状,且要画在文字之上。
  167. for (const c of el.children) {
  168. const ccs = getComputedStyle(c);
  169. const cbg = ccs.backgroundColor;
  170. if (c.textContent.trim() === '' &&
  171. cbg !== 'rgba(0, 0, 0, 0)' && cbg !== 'transparent') {
  172. const cr = c.getBoundingClientRect();
  173. let rot = 0;
  174. const m = ccs.transform.match(/matrix\(([^)]+)\)/);
  175. if (m) {
  176. const [a, b] = m[1].split(',').map(parseFloat);
  177. rot = Math.round(Math.atan2(b, a) * 180 / Math.PI * 10) / 10;
  178. }
  179. out.push({tag: c.tagName.toLowerCase(), cls: '', kind: 'shape',
  180. x: cr.left - pb.left, y: cr.top - pb.top, w: cr.width, h: cr.height,
  181. bg: cbg, bdw: px(ccs.borderTopWidth), bdc: ccs.borderTopColor,
  182. radius: px(ccs.borderRadius), shadow: ccs.boxShadow, rot});
  183. }
  184. }
  185. return;
  186. }
  187. for (const c of el.children) walk(c);
  188. };
  189. for (const c of pg.children) walk(c);
  190. return {w: pb.width, h: pb.height, els: out};
  191. });
  192. }
  193. """
  194. async def render(html, selector, asset_dir, scale):
  195. from playwright.async_api import async_playwright
  196. os.makedirs(asset_dir, exist_ok=True)
  197. async with async_playwright() as p:
  198. br = await p.chromium.launch()
  199. pg = await br.new_page(viewport={"width": 1920, "height": 1080},
  200. device_scale_factor=scale)
  201. await pg.goto("file://" + os.path.abspath(html))
  202. await pg.wait_for_timeout(2500)
  203. pages = await pg.evaluate(JS, selector)
  204. for pi, page in enumerate(pages): # SVG / canvas 单独截成 PNG
  205. for e in page["els"]:
  206. if e["kind"] == "svg":
  207. name = f"p{pi+1:02d}_{e['tagName']}{e['seq']}.png"
  208. await pg.locator(selector).nth(pi).locator(e["tagName"]).nth(e["seq"]) \
  209. .screenshot(path=os.path.join(asset_dir, name), omit_background=True)
  210. e["file"] = os.path.join(asset_dir, name)
  211. await br.close()
  212. return pages
  213. # ─────────────────────────────────────────────────────────────
  214. # 二、翻译成 PowerPoint 对象
  215. # ─────────────────────────────────────────────────────────────
  216. ALIGN = {"left": PP_ALIGN.LEFT, "center": PP_ALIGN.CENTER, "start": PP_ALIGN.LEFT,
  217. "right": PP_ALIGN.RIGHT, "end": PP_ALIGN.RIGHT, "justify": PP_ALIGN.JUSTIFY}
  218. def parse_color(css):
  219. """'rgb(r,g,b)' / 'rgba(r,g,b,a)' → (RGBColor, alpha)。"""
  220. m = re.findall(r"[\d.]+", css or "")
  221. if len(m) < 3:
  222. return None, 0.0
  223. r, g, b = (int(float(v)) for v in m[:3])
  224. return RGBColor(r, g, b), (float(m[3]) if len(m) > 3 else 1.0)
  225. def _alpha(clr_el, alpha):
  226. a = clr_el.makeelement(qn("a:alpha"), {})
  227. a.set("val", str(int(alpha * 100000)))
  228. clr_el.append(a)
  229. class Builder:
  230. def __init__(self, cfg):
  231. self.cfg = cfg
  232. self.round_dir = os.path.join(cfg.asset_dir, "_round")
  233. # ── 文本 ──────────────────────────────────────────────
  234. def set_run_font(self, run, r):
  235. f = run.font
  236. f.size = Pt(r["fs"] * self.k)
  237. fw = str(r["fw"])
  238. f.bold = int(fw) >= 600 if fw.isdigit() else fw in ("bold", "bolder")
  239. f.italic = r.get("italic", False)
  240. f.underline = r.get("under", False)
  241. f.name = self.cfg.font_latin # 只设了 latin,中文要另外指定
  242. col, alpha = parse_color(r["color"])
  243. if col:
  244. f.color.rgb = col
  245. rPr = run._r.get_or_add_rPr()
  246. for tag, val in (("a:ea", self.cfg.font_ea), ("a:cs", self.cfg.font_latin)):
  247. el = rPr.find(qn(tag))
  248. if el is None:
  249. el = rPr.makeelement(qn(tag), {})
  250. rPr.append(el)
  251. el.set("typeface", val)
  252. if r.get("ls"):
  253. rPr.set("spc", str(int(round(r["ls"] * self.k * 100)))) # 单位 1/100 pt
  254. if col and alpha < 1:
  255. solid = rPr.find(qn("a:solidFill"))
  256. if solid is not None and solid.find(qn("a:srgbClr")) is not None:
  257. _alpha(solid.find(qn("a:srgbClr")), alpha)
  258. def add_text(self, slide, e):
  259. """一个段落 → 一个文本框。
  260. 宽度要放余量:视觉稿里这些框常是 flex 收缩包裹的,宽度恰好等于文字宽度,
  261. 换到 PowerPoint 只要字体度量差一点点,最后一个字就会被挤到下一行。
  262. 本来就是单行的段落直接关掉自动换行,从根上不可能折行。
  263. """
  264. k = self.k
  265. wrap = e.get("wrap", True)
  266. fs = e.get("fsMax") or e["fs"] # 容器字号常常是继承来的小值,余量要按最大的字算
  267. pad = (fs * 0.25) if wrap else max(12.0, fs * 0.8)
  268. x, w = e["x"], e["w"] + pad
  269. ta = e.get("ta")
  270. if ta in ("center",):
  271. x -= pad / 2 # 居中框两边一起放,视觉中心不动
  272. elif ta in ("right", "end"):
  273. x -= pad
  274. box = slide.shapes.add_textbox(Pt(x * k), Pt(e["y"] * k), Pt(w * k), Pt(e["h"] * k))
  275. tf = box.text_frame
  276. tf.word_wrap = wrap
  277. tf.auto_size = None
  278. tf.vertical_anchor = MSO_ANCHOR.TOP
  279. tf.margin_left = tf.margin_right = tf.margin_top = tf.margin_bottom = 0
  280. paras = [[]]
  281. for r in e["runs"]:
  282. paras.append([]) if r.get("br") else paras[-1].append(r)
  283. paras = [p for p in paras if p] or [[]]
  284. for i, runs in enumerate(paras):
  285. p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
  286. p.alignment = ALIGN.get(ta, PP_ALIGN.LEFT)
  287. # 行距只对「这一段自己折了行」的框设。单行段落不设:量到的行盒高度比实际行间距
  288. # 大(那是字体行盒不是 CSS 行距),拿它当精确行距会把文字整体压下去;
  289. # 单行时交给 PowerPoint 按字号自然排,文字紧贴框顶,位置最准。
  290. if e.get("lines", 1) > 1 and e.get("lhEff"):
  291. p.line_spacing = Pt(e["lhEff"] * k)
  292. for r in runs:
  293. run = p.add_run()
  294. run.text = r["t"]
  295. self.set_run_font(run, r)
  296. return box
  297. # ── 形状 ──────────────────────────────────────────────
  298. def add_shape(self, slide, e):
  299. k = self.k
  300. rounded = e.get("radius", 0) >= 4
  301. shp = slide.shapes.add_shape(
  302. MSO_SHAPE.ROUNDED_RECTANGLE if rounded else MSO_SHAPE.RECTANGLE,
  303. Pt(e["x"] * k), Pt(e["y"] * k), Pt(e["w"] * k), Pt(e["h"] * k))
  304. shp.shadow.inherit = False
  305. col, alpha = parse_color(e.get("bg"))
  306. if col and alpha > 0:
  307. shp.fill.solid()
  308. shp.fill.fore_color.rgb = col
  309. if alpha < 1:
  310. sf = shp.fill._xPr.find(qn("a:solidFill"))
  311. _alpha(sf.find(qn("a:srgbClr")), alpha)
  312. else:
  313. shp.fill.background()
  314. bcol, balpha = parse_color(e.get("bdc"))
  315. if e.get("bdw", 0) > 0 and bcol and balpha > 0:
  316. shp.line.color.rgb = bcol
  317. shp.line.width = Pt(e["bdw"] * k)
  318. else:
  319. shp.line.fill.background()
  320. if e.get("rot"):
  321. shp.rotation = -e["rot"] # CSS 逆时针为负,PowerPoint 顺时针为正
  322. if rounded and e["w"] and e["h"]:
  323. # PowerPoint 的圆角是「短边的百分比」,换算回 CSS 的 px 半径
  324. shp.adjustments[0] = min(0.5, e["radius"] / min(e["w"], e["h"]))
  325. return shp
  326. # ── 图片 ──────────────────────────────────────────────
  327. def round_corners(self, path, radius_px, box_w):
  328. """PowerPoint 的图片没有圆角,把圆角烤进 PNG 的 alpha 通道。"""
  329. os.makedirs(self.round_dir, exist_ok=True)
  330. out = os.path.join(self.round_dir, f"{abs(hash((path, radius_px, box_w))) % 10**10}.png")
  331. if os.path.exists(out):
  332. return out
  333. im = Image.open(path).convert("RGBA")
  334. scale = im.width / box_w if box_w else 1
  335. r = int(min(radius_px * scale, min(im.size) / 2))
  336. mask = Image.new("L", im.size, 0)
  337. ImageDraw.Draw(mask).rounded_rectangle([0, 0, im.width - 1, im.height - 1],
  338. radius=r, fill=255)
  339. im.putalpha(mask)
  340. im.save(out)
  341. return out
  342. def add_img(self, slide, e):
  343. k = self.k
  344. src = e.get("file") or e["src"]
  345. if not src or src.startswith("data:"):
  346. return None
  347. path = src if os.path.isabs(src) else os.path.normpath(os.path.join(self.html_dir, src))
  348. if not os.path.exists(path):
  349. print(f" ⚠️ 找不到图片 {src}")
  350. return None
  351. if e.get("radius", 0) >= 2:
  352. path = self.round_corners(path, e["radius"], e["w"])
  353. pic = slide.shapes.add_picture(path, Pt(e["x"] * k), Pt(e["y"] * k),
  354. Pt(e["w"] * k), Pt(e["h"] * k))
  355. # box-shadow: rgba(...) 0 0 0 Npx 在视觉稿里通常是硬描边,翻译成图片边框
  356. m = re.match(r"rgba?\(([^)]+)\)\s+0px\s+0px\s+0px\s+([\d.]+)px", e.get("shadow") or "")
  357. if m:
  358. col, alpha = parse_color("rgba(" + m.group(1) + ")")
  359. if col and alpha > 0:
  360. pic.line.color.rgb = col
  361. pic.line.width = Pt(float(m.group(2)) * k)
  362. return pic
  363. # ── 装配 ──────────────────────────────────────────────
  364. def run(self, pages):
  365. cfg = self.cfg
  366. self.html_dir = os.path.dirname(os.path.abspath(cfg.html))
  367. if cfg.template:
  368. prs = Presentation(cfg.template)
  369. strip_slides(prs) # 模板自带的示例页删掉,母版/版式一个不动
  370. else:
  371. prs = Presentation()
  372. pw, ph = pages[0]["w"], pages[0]["h"]
  373. prs.slide_width, prs.slide_height = Pt(pw), Pt(ph) # 1 CSS px = 1 pt
  374. # HTML 画布宽 → 幻灯片宽 的比例。画布 1920px 配 26.667in(=1920pt) 时正好是 1.0。
  375. self.k = (prs.slide_width / EMU_PER_PT) / pages[0]["w"]
  376. if cfg.layout:
  377. layout = next((l for l in prs.slide_layouts if l.name == cfg.layout), None)
  378. if layout is None:
  379. names = " / ".join(l.name for l in prs.slide_layouts)
  380. sys.exit(f"❌ 模板里没有版式「{cfg.layout}」。可选:{names}")
  381. killed = unblock_layout(layout, prs.slide_width, prs.slide_height)
  382. else:
  383. layout = prs.slide_layouts[6] if len(prs.slide_layouts) > 6 else prs.slide_layouts[0]
  384. killed = []
  385. n_text = n_shape = n_img = 0
  386. for page in pages:
  387. slide = prs.slides.add_slide(layout)
  388. for sh in list(slide.shapes): # 版式带来的空占位符不留在页面上
  389. sh._element.getparent().remove(sh._element)
  390. if cfg.bg:
  391. set_bg(slide, cfg.bg)
  392. for e in page["els"]:
  393. if cfg.skip_class and cfg.skip_class in (e.get("cls") or "").split():
  394. continue
  395. if e["kind"] == "shape":
  396. self.add_shape(slide, e); n_shape += 1
  397. elif e["kind"] == "text":
  398. self.add_text(slide, e); n_text += 1
  399. else:
  400. if self.add_img(slide, e) is not None:
  401. n_img += 1
  402. prs.save(cfg.out)
  403. mb = os.path.getsize(cfg.out) / 1024 / 1024
  404. print(f"✅ {len(prs.slides)} 页 → {os.path.basename(cfg.out)}({mb:.1f}MB)")
  405. print(f" 文本框 {n_text} · 形状 {n_shape} · 图片 {n_img}"
  406. + (f" · 清掉版式遮罩 {killed}" if killed else ""))
  407. print(f" 画布 {prs.slide_width/914400:.3f}×{prs.slide_height/914400:.3f} inch"
  408. f"(缩放 {self.k:.4f})")
  409. # ─────────────────────────────────────────────────────────────
  410. # 三、模板相关的三个小手术
  411. # ─────────────────────────────────────────────────────────────
  412. def strip_slides(prs):
  413. """删掉模板自带的示例页;母版、版式、主题、色板一个不动。"""
  414. lst = prs.slides._sldIdLst
  415. for sld in list(lst):
  416. prs.part.drop_rel(sld.rId)
  417. lst.remove(sld)
  418. def unblock_layout(layout, W, H):
  419. """删掉版式里铺满全屏的纯色矩形。
  420. 有些官方模板的版式里放着一个和版式底色同色同尺寸的全屏矩形(冗余遮罩)。
  421. 留着它,页面上任何放在它下面的东西都会被挡住;删掉不改变版式的外观。
  422. """
  423. killed = []
  424. for sp in list(layout.shapes):
  425. full = (sp.left == 0 and sp.top == 0 and sp.width and sp.height
  426. and sp.width >= W and sp.height >= H)
  427. if full and b"<a:solidFill>" in sp._element.xml.encode():
  428. sp._element.getparent().remove(sp._element)
  429. killed.append(sp.name)
  430. return killed
  431. def set_bg(slide, rgb_hex):
  432. bg = slide._element.makeelement(qn("p:bg"), {})
  433. pr = slide._element.makeelement(qn("p:bgPr"), {})
  434. fill = slide._element.makeelement(qn("a:solidFill"), {})
  435. clr = slide._element.makeelement(qn("a:srgbClr"), {"val": rgb_hex.lstrip("#").upper()})
  436. fill.append(clr); pr.append(fill)
  437. pr.append(slide._element.makeelement(qn("a:effectLst"), {}))
  438. bg.append(pr)
  439. slide._element.find(qn("p:cSld")).insert(0, bg)
  440. # ─────────────────────────────────────────────────────────────
  441. def main():
  442. ap = argparse.ArgumentParser(description="视觉稿 HTML → 可编辑 PPTX(读渲染后坐标,不改 HTML)")
  443. ap.add_argument("html")
  444. ap.add_argument("-o", "--out", required=True, help="输出 .pptx")
  445. ap.add_argument("--selector", default=".slide, .s, section",
  446. help="每一页的 CSS 选择器(默认 '.slide, .s, section')")
  447. ap.add_argument("--template", help="以这个 .pptx 为基底,继承它的母版/版式/主题/色板")
  448. ap.add_argument("--layout", help="每页套用的版式名(配合 --template)")
  449. ap.add_argument("--skip-class", help="跳过带这个 class 的元素,例如 logo 由版式提供时填 logo")
  450. ap.add_argument("--font-latin", default="Microsoft YaHei", help="西文字体名")
  451. ap.add_argument("--font-ea", default="微软雅黑", help="东亚字体名")
  452. ap.add_argument("--bg", help="每页底色,如 05070B;不给则用版式底色")
  453. ap.add_argument("--asset-dir", help="SVG/圆角图的落地目录(默认 输出同级 _pptx_assets/)")
  454. ap.add_argument("--scale", type=int, default=3, help="SVG 截图倍数(默认 3)")
  455. ap.add_argument("--dump-json", help="把量到的元素清单写出来,便于排查")
  456. cfg = ap.parse_args()
  457. cfg.out = os.path.abspath(cfg.out)
  458. cfg.asset_dir = cfg.asset_dir or os.path.join(os.path.dirname(cfg.out), "_pptx_assets")
  459. if cfg.layout and not cfg.template:
  460. sys.exit("❌ --layout 需要配合 --template 使用")
  461. pages = asyncio.run(render(cfg.html, cfg.selector, cfg.asset_dir, cfg.scale))
  462. if not pages:
  463. sys.exit(f"❌ 选择器 '{cfg.selector}' 一页都没匹配到")
  464. if cfg.dump_json:
  465. json.dump(pages, open(cfg.dump_json, "w"), ensure_ascii=False, indent=1)
  466. kinds = {}
  467. for p in pages:
  468. for e in p["els"]:
  469. kinds[e["kind"]] = kinds.get(e["kind"], 0) + 1
  470. print(f"📐 量到 {len(pages)} 页 {kinds}")
  471. Builder(cfg).run(pages)
  472. if __name__ == "__main__":
  473. main()