221 lines
7.4 KiB
Python
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()
|