179 lines
6.1 KiB
Python
179 lines
6.1 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
render_card.py — 用模板渲染卡片图,Chrome headless 截图输出 PNG
|
||
|
||
用法:
|
||
python3 render_card.py \
|
||
--template poster-3-4 \
|
||
--out /tmp/card.png \
|
||
--line1 "OpenClaw" \
|
||
--line2 "有两层" \
|
||
--line3 "model 配置" \
|
||
--highlight "#22a854" \
|
||
--bg "#e6f5ef" \
|
||
--footer "公众号 · 早早集市"
|
||
|
||
模板位于 assets/templates/<template>.html(相对于本脚本所在的 skills/z-card-image/)
|
||
"""
|
||
|
||
import argparse, shutil, subprocess, sys, tempfile
|
||
from html import escape
|
||
from pathlib import Path
|
||
|
||
SKILL_DIR = Path(__file__).parent.parent
|
||
TEMPLATES_DIR = SKILL_DIR / "assets" / "templates"
|
||
ICONS_DIR = SKILL_DIR / "assets" / "icons"
|
||
WECHAT_SPLIT_DEFAULT_ICON = SKILL_DIR / "assets" / "icons" / "openclaw-logo.svg"
|
||
|
||
CHROME_PATHS = [
|
||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||
"google-chrome",
|
||
"chromium",
|
||
]
|
||
|
||
WECHAT_SPLIT_WINDOW_EXTRA_HEIGHT = 87
|
||
|
||
def find_chrome():
|
||
for p in CHROME_PATHS:
|
||
if Path(p).exists() or shutil.which(p):
|
||
return p
|
||
return None
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser()
|
||
ap.add_argument("--template", default="poster-3-4")
|
||
ap.add_argument("--out", required=True)
|
||
ap.add_argument("--line1", default="")
|
||
ap.add_argument("--line2", default="")
|
||
ap.add_argument("--line3", default="")
|
||
ap.add_argument("--hl1", action="store_true", help="第一行高亮")
|
||
ap.add_argument("--hl2", action="store_true", help="第二行高亮")
|
||
ap.add_argument("--hl3", action="store_true", help="第三行高亮")
|
||
ap.add_argument("--highlight", default="#22a854")
|
||
ap.add_argument("--bg", default="#e6f5ef")
|
||
ap.add_argument("--footer", default="公众号")
|
||
ap.add_argument("--icon", default=None, help="顶部图标路径,不传则自动判断")
|
||
ap.add_argument("--highlight-words", default="", help="要高亮的词,逗号分隔,如 '测试,openclaw'")
|
||
args = ap.parse_args()
|
||
|
||
if args.template == "wechat-cover-split":
|
||
lines = [line for line in [args.line1, args.line2, args.line3] if line]
|
||
args.line1 = lines[0] if lines else ""
|
||
args.line2 = "".join(lines[1:]) if len(lines) > 1 else ""
|
||
args.line3 = ""
|
||
args.hl2 = args.hl2 or args.hl3
|
||
args.hl3 = False
|
||
|
||
# 自动选图标
|
||
if args.icon:
|
||
icon_path = args.icon
|
||
elif args.template == "wechat-cover-split":
|
||
icon_path = WECHAT_SPLIT_DEFAULT_ICON
|
||
else:
|
||
texts = " ".join([args.line1, args.line2, args.line3]).lower()
|
||
if "openclaw" in texts:
|
||
icon_path = str(ICONS_DIR / "openclaw-logo.svg")
|
||
else:
|
||
icon_path = str(ICONS_DIR / "zzclub-logo-gray.svg")
|
||
|
||
tpl_path = TEMPLATES_DIR / f"{args.template}.html"
|
||
if not tpl_path.exists():
|
||
sys.exit(f"Template not found: {tpl_path}")
|
||
|
||
html = tpl_path.read_text(encoding="utf-8")
|
||
replacements = {
|
||
"{{MAIN_TEXT_LINE1}}": escape(args.line1),
|
||
"{{MAIN_TEXT_LINE2}}": escape(args.line2),
|
||
"{{MAIN_TEXT_LINE3}}": escape(args.line3),
|
||
"{{LINE1_CLASS}}": "highlight" if args.hl1 else "",
|
||
"{{LINE2_CLASS}}": "highlight" if args.hl2 else "",
|
||
"{{LINE3_CLASS}}": "highlight" if args.hl3 else "",
|
||
"{{HIGHLIGHT_COLOR}}": args.highlight,
|
||
"{{BG_COLOR}}": args.bg,
|
||
"{{FOOTER_TEXT}}": escape(args.footer),
|
||
"{{ICON_PATH}}": icon_path,
|
||
"{{FONT_PATH}}": str(SKILL_DIR / "assets" / "fonts" / "AlimamaShuHeiTi-Bold.ttf"),
|
||
"{{AVATAR_PATH}}": str(SKILL_DIR / "assets" / "icons" / "avatar_jinx_cartoon.jpg"),
|
||
}
|
||
for k, v in replacements.items():
|
||
html = html.replace(k, v)
|
||
|
||
# 词级高亮:把指定词用 <span class="highlight"> 包起来
|
||
# 注意:line 内容已经被 html.escape,所以匹配时用转义后的词
|
||
if args.highlight_words:
|
||
import re
|
||
words = [w.strip() for w in args.highlight_words.split(",") if w.strip()]
|
||
for word in words:
|
||
escaped_word = escape(word)
|
||
html = re.sub(
|
||
re.escape(escaped_word),
|
||
f'<span class="highlight">{escaped_word}</span>',
|
||
html
|
||
)
|
||
|
||
# 判断尺寸
|
||
size_map = {
|
||
"poster-3-4": (900, 1200),
|
||
"wechat-cover-split": (1340, 400),
|
||
}
|
||
w, h = size_map.get(args.template, (900, 1200))
|
||
|
||
chrome = find_chrome()
|
||
if not chrome:
|
||
sys.exit("Chrome/Chromium not found")
|
||
|
||
with tempfile.NamedTemporaryFile(suffix=".html", delete=False, mode="w", encoding="utf-8") as f:
|
||
f.write(html)
|
||
tmp_html = f.name
|
||
|
||
# 输出路径统一用 workspace/tmp/,不要用 /tmp/(飞书无法上传系统临时目录)
|
||
out = Path(args.out)
|
||
screenshot_path = out
|
||
window_h = h
|
||
if args.template == "wechat-cover-split":
|
||
window_h = h + WECHAT_SPLIT_WINDOW_EXTRA_HEIGHT
|
||
screenshot_path = out.with_name(f"{out.stem}.raw{out.suffix}")
|
||
|
||
cmd = [
|
||
chrome,
|
||
"--headless",
|
||
"--disable-gpu",
|
||
"--no-sandbox",
|
||
"--disable-web-security=false",
|
||
f"--screenshot={screenshot_path}",
|
||
f"--window-size={w},{window_h}",
|
||
f"file://{tmp_html}",
|
||
]
|
||
result = subprocess.run(cmd, capture_output=True)
|
||
if result.returncode != 0:
|
||
sys.exit(f"Chrome failed:\n{result.stderr.decode()}")
|
||
|
||
if args.template == "wechat-cover-split":
|
||
ffmpeg = shutil.which("ffmpeg")
|
||
if not ffmpeg:
|
||
sys.exit("wechat-cover-split 需要 ffmpeg 做顶部裁切")
|
||
crop_cmd = [
|
||
ffmpeg,
|
||
"-y",
|
||
"-loglevel",
|
||
"error",
|
||
"-i",
|
||
str(screenshot_path),
|
||
"-vf",
|
||
f"crop={w}:{h}:0:0",
|
||
"-frames:v",
|
||
"1",
|
||
str(out),
|
||
]
|
||
crop_result = subprocess.run(crop_cmd, capture_output=True)
|
||
screenshot_path.unlink(missing_ok=True)
|
||
if crop_result.returncode != 0:
|
||
sys.exit(f"ffmpeg crop failed:\n{crop_result.stderr.decode()}")
|
||
|
||
Path(tmp_html).unlink(missing_ok=True)
|
||
print(f"✅ Saved to {out}")
|
||
|
||
if __name__ == "__main__":
|
||
main()
|