| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538 |
- #!/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 → 文本框(按 <br> 分段,每段一个框,各带自己的 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) {
- // 按 <br> 分段,每段单独出一个文本框。
- //
- // 为什么不整块出一个框: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"<a:solidFill>" 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()
|