"""pptx_preview.py: 把 .pptx 渲成 PNG 预览图(无头 Chrome),用于**肉眼验收版面**。 quality_check 只查"越界/溢出/配色"等结构问题,看不出"好不好看"。本脚本把每页 按形状坐标还原成 HTML → Chrome 截图 → PNG,让人(或模型用 Read)真看一眼版面层次、 留白、对齐、配色观感。支持本 skill 用到的形状子集:矩形/圆角矩形/渐变块/文本框/图片。 用法: python pptx_preview.py -o [--pages 1,4,6] 产物:/p01.png p02.png ...(每页一张,2x 超采样) 依赖:本机 Chrome / Edge(同 render_bg.py)。非本 skill 生成的复杂 pptx 可能还原不全。 """ from __future__ import annotations import argparse import html as _html import subprocess import tempfile from pathlib import Path from pptx import Presentation from pptx.enum.dml import MSO_FILL, MSO_COLOR_TYPE from pptx.enum.shapes import MSO_SHAPE_TYPE from pptx.enum.text import PP_ALIGN, MSO_ANCHOR from pptx.oxml.ns import qn from render_bg import find_browser # 复用浏览器定位 EMU = 914400 PXI = 96 # px per inch def _rgb(color): try: if color.type == MSO_COLOR_TYPE.RGB: return "#" + str(color.rgb) except (AttributeError, TypeError, KeyError, ValueError): pass return None def _fill_css(shape): """返回 (css背景, 是否有填充)。支持纯色 / 线性渐变。""" try: f = shape.fill if f.type == MSO_FILL.SOLID: c = _rgb(f.fore_color) return (c, True) if c else (None, False) if f.type == MSO_FILL.GRADIENT: stops = [] for gs in f.gradient_stops: c = _rgb(gs.color) if c: stops.append(f"{c} {int(gs.position * 100)}%") if len(stops) >= 2: try: ang = f.gradient_angle except (AttributeError, ValueError, TypeError): ang = 90 # pptx 角度→css:0=左→右 即 css 90deg return (f"linear-gradient({90 + (ang or 0)}deg,{','.join(stops)})", True) except (AttributeError, TypeError, KeyError, ValueError): pass return (None, False) def _round_px(shape, w, h): try: adj = shape.adjustments[0] return adj * min(w, h) except (IndexError, AttributeError, ValueError, TypeError): return 0 def _line(shape): try: ln = shape.line c = _rgb(ln.color) if c and ln.width is not None and ln.width > 0: return c, max(1, ln.width / EMU * PXI) except (AttributeError, TypeError, KeyError, ValueError): pass return None, 0 def _anchor_flex(tf): a = tf.vertical_anchor if a == MSO_ANCHOR.MIDDLE: return "center" if a == MSO_ANCHOR.BOTTOM: return "flex-end" return "flex-start" def _align_css(p): return {PP_ALIGN.CENTER: "center", PP_ALIGN.RIGHT: "right"}.get( p.alignment, "left") def _para_html(p): align = _align_css(p) runs = [] size = 18 color = "#1F1F1F" bold = False for r in p.runs: if r.font.size: size = r.font.size.pt c = _rgb(r.font.color) if c: color = c bold = bool(r.font.bold) runs.append(_html.escape(r.text or "")) txt = "".join(runs) or _html.escape(p.text or "") if not txt.strip(): return "" lh = 1.25 return (f'
{txt}
') def slide_html(slide, imgdir: Path, idx: int) -> str: parts = [] for s_i, sh in enumerate(slide.shapes): try: l = sh.left / EMU * PXI t = sh.top / EMU * PXI w = sh.width / EMU * PXI h = sh.height / EMU * PXI except (TypeError, AttributeError): continue base = (f"position:absolute;left:{l:.1f}px;top:{t:.1f}px;" f"width:{w:.1f}px;height:{h:.1f}px;box-sizing:border-box;") # 图片 try: is_pic = sh.shape_type == MSO_SHAPE_TYPE.PICTURE except (AttributeError, ValueError): is_pic = False if is_pic: try: blob = sh.image.blob ext = sh.image.ext fp = imgdir / f"p{idx}_{s_i}.{ext}" fp.write_bytes(blob) parts.append(f'') except (AttributeError, KeyError, ValueError): pass continue # 形状填充 / 圆角 / 描边 css = base bg, has = _fill_css(sh) if has: css += f"background:{bg};" # 椭圆/圆 → 50% 圆角(badge/dot/hub 才显示成圆,不然是方块) prst = None try: g = sh._element.spPr.find(qn("a:prstGeom")) prst = g.get("prst") if g is not None else None except (AttributeError, TypeError): pass if prst == "ellipse": css += "border-radius:50%;" else: r = _round_px(sh, w, h) if r > 0: css += f"border-radius:{r:.1f}px;" lc, lw = _line(sh) if lc: css += f"border:{lw:.1f}px solid {lc};" # 文本 inner = "" if sh.has_text_frame and (sh.text_frame.text or "").strip(): tf = sh.text_frame css += ("display:flex;flex-direction:column;padding:2px 6px;" f"justify-content:{_anchor_flex(tf)};") inner = "".join(_para_html(p) for p in tf.paragraphs) if has or r > 0 or lc or inner: parts.append(f'
{inner}
') body = "\n".join(parts) return (f'
{body}
') def render(html_str: str, out: Path): browser = find_browser() with tempfile.TemporaryDirectory() as td: hp = Path(td) / "s.html" hp.write_text(f"" f"{html_str}", encoding="utf-8") subprocess.run([browser, "--headless", "--disable-gpu", "--hide-scrollbars", "--force-device-scale-factor=2", "--window-size=1280,720", f"--screenshot={out}", hp.resolve().as_uri()], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) def main(): ap = argparse.ArgumentParser() ap.add_argument("pptx", type=Path) ap.add_argument("-o", "--out", type=Path, required=True) ap.add_argument("--pages", default=None, help="如 1,4,6;省略=全部") args = ap.parse_args() args.out.mkdir(parents=True, exist_ok=True) imgdir = args.out / "_img" imgdir.mkdir(exist_ok=True) prs = Presentation(str(args.pptx)) want = (set(int(x) for x in args.pages.split(",")) if args.pages else None) for i, slide in enumerate(prs.slides, 1): if want and i not in want: continue out = args.out / f"p{i:02d}.png" render(slide_html(slide, imgdir, i), out) print(f"[ok] {out}" if out.exists() else f"[fail] p{i}") if __name__ == "__main__": main()