zcbot/skills/ppt/scripts/pptx_preview.py

221 lines
7.4 KiB
Python

"""pptx_preview.py: 把 .pptx 渲成 PNG 预览图(无头 Chrome),用于**肉眼验收版面**。
quality_check 只查"越界/溢出/配色"等结构问题,看不出"好不好看"。本脚本把每页
按形状坐标还原成 HTML → Chrome 截图 → PNG,让人(或模型用 Read)真看一眼版面层次、
留白、对齐、配色观感。支持本 skill 用到的形状子集:矩形/圆角矩形/渐变块/文本框/图片。
用法:
python pptx_preview.py <deck.pptx> -o <out_dir> [--pages 1,4,6]
产物:<out_dir>/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'<div style="text-align:{align};font-size:{size}px;color:{color};'
f'font-weight:{"700" if bold else "400"};line-height:{lh}">{txt}</div>')
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'<img src="{fp.as_uri()}" style="{base}'
f'object-fit:cover"/>')
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'<div style="{css}">{inner}</div>')
body = "\n".join(parts)
return (f'<div style="position:relative;width:1280px;height:720px;'
f'overflow:hidden;background:#fff;font-family:\'Microsoft YaHei\','
f'Arial,sans-serif">{body}</div>')
def render(html_str: str, out: Path):
browser = find_browser()
with tempfile.TemporaryDirectory() as td:
hp = Path(td) / "s.html"
hp.write_text(f"<!doctype html><meta charset=utf-8>"
f"<style>html,body{{margin:0}}</style>{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()