add z-card-image
This commit is contained in:
parent
d10843ad47
commit
137766440e
@ -16,6 +16,7 @@ RUN apt-get update && apt-get install -y \
|
||||
ca-certificates \
|
||||
libpq-dev \
|
||||
chromium \
|
||||
ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 安装Node.js (支持npx命令)
|
||||
|
||||
@ -17,6 +17,7 @@ RUN sed -i 's|http://deb.debian.org|http://mirrors.aliyun.com|g' /etc/apt/source
|
||||
ca-certificates \
|
||||
libpq-dev \
|
||||
chromium \
|
||||
ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 安装Node.js (支持npx命令)
|
||||
|
||||
85
skills_developing/ai-ppt-generator/SKILL.md
Normal file
85
skills_developing/ai-ppt-generator/SKILL.md
Normal file
@ -0,0 +1,85 @@
|
||||
---
|
||||
name: ai-ppt-generator
|
||||
description: Generate PPT with Baidu AI. Smart template selection based on content.
|
||||
metadata: { "openclaw": { "emoji": "📑", "requires": { "bins": ["python3"], "env":["BAIDU_API_KEY"]},"primaryEnv":"BAIDU_API_KEY" } }
|
||||
---
|
||||
|
||||
# AI PPT Generator
|
||||
|
||||
Generate PPT using Baidu AI with intelligent template selection.
|
||||
|
||||
## Smart Workflow
|
||||
1. **User provides PPT topic**
|
||||
2. **Agent asks**: "Want to choose a template style?"
|
||||
3. **If yes** → Show styles from `ppt_theme_list.py` → User picks → Use `generate_ppt.py` with chosen `tpl_id` and real `style_id`
|
||||
4. **If no** → Use `random_ppt_theme.py` (auto-selects appropriate template based on topic content)
|
||||
|
||||
## Intelligent Template Selection
|
||||
`random_ppt_theme.py` analyzes the topic and suggests appropriate template:
|
||||
- **Business topics** → 企业商务 style
|
||||
- **Technology topics** → 未来科技 style
|
||||
- **Education topics** → 卡通手绘 style
|
||||
- **Creative topics** → 创意趣味 style
|
||||
- **Cultural topics** → 中国风 or 文化艺术 style
|
||||
- **Year-end reports** → 年终总结 style
|
||||
- **Minimalist design** → 扁平简约 style
|
||||
- **Artistic content** → 文艺清新 style
|
||||
|
||||
## Scripts
|
||||
- `scripts/ppt_theme_list.py` - List all available templates with style_id and tpl_id
|
||||
- `scripts/random_ppt_theme.py` - Smart template selection + generate PPT
|
||||
- `scripts/generate_ppt.py` - Generate PPT with specific template (uses real style_id and tpl_id from API)
|
||||
|
||||
## Key Features
|
||||
- **Smart categorization**: Analyzes topic content to suggest appropriate style
|
||||
- **Fallback logic**: If template not found, automatically uses random selection
|
||||
- **Complete parameters**: Properly passes both style_id and tpl_id to API
|
||||
|
||||
## Usage Examples
|
||||
```bash
|
||||
# List all templates with IDs
|
||||
python3 scripts/ppt_theme_list.py
|
||||
|
||||
# Smart automatic selection (recommended for most users)
|
||||
python3 scripts/random_ppt_theme.py --query "人工智能发展趋势报告"
|
||||
|
||||
# Specific template with proper style_id
|
||||
python3 scripts/generate_ppt.py --query "儿童英语课件" --tpl_id 106
|
||||
|
||||
# Specific template with auto-suggested category
|
||||
python3 scripts/random_ppt_theme.py --query "企业年度总结" --category "企业商务"
|
||||
```
|
||||
|
||||
## Agent Steps
|
||||
1. Get PPT topic from user
|
||||
2. Ask: "Want to choose a template style?"
|
||||
3. **If user says YES**:
|
||||
- Run `ppt_theme_list.py` to show available templates
|
||||
- User selects a template (note the tpl_id)
|
||||
- Run `generate_ppt.py --query "TOPIC" --tpl_id ID`
|
||||
4. **If user says NO**:
|
||||
- Run `random_ppt_theme.py --query "TOPIC"`
|
||||
- Script will auto-select appropriate template based on topic
|
||||
5. Set timeout to 300 seconds (PPT generation takes 2-5 minutes)
|
||||
6. Monitor output, wait for `is_end: true` to get final PPT URL
|
||||
|
||||
## Output Examples
|
||||
**During generation:**
|
||||
```json
|
||||
{"status": "PPT生成中", "run_time": 45}
|
||||
```
|
||||
|
||||
**Final result:**
|
||||
```json
|
||||
{
|
||||
"status": "PPT导出结束",
|
||||
"is_end": true,
|
||||
"data": {"ppt_url": "https://image0.bj.bcebos.com/...ppt"}
|
||||
}
|
||||
```
|
||||
|
||||
## Technical Notes
|
||||
- **API integration**: Fetches real style_id from Baidu API for each template
|
||||
- **Error handling**: If template not found, falls back to random selection
|
||||
- **Timeout**: Generation takes 2-5 minutes, set sufficient timeout
|
||||
- **Streaming**: Uses streaming API, wait for `is_end: true` before considering complete
|
||||
6
skills_developing/ai-ppt-generator/_meta.json
Normal file
6
skills_developing/ai-ppt-generator/_meta.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"ownerId": "kn7akgt520t01vgs2tzx7yk6m180kt26",
|
||||
"slug": "ai-ppt-generator",
|
||||
"version": "1.1.4",
|
||||
"publishedAt": 1773656502997
|
||||
}
|
||||
148
skills_developing/ai-ppt-generator/scripts/generate_ppt.py
Normal file
148
skills_developing/ai-ppt-generator/scripts/generate_ppt.py
Normal file
@ -0,0 +1,148 @@
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
import time
|
||||
|
||||
import requests
|
||||
import json
|
||||
import argparse
|
||||
|
||||
URL_PREFIX = "https://qianfan.baidubce.com/v2/tools/ai_ppt/"
|
||||
|
||||
|
||||
class Style:
|
||||
def __init__(self, style_id, tpl_id):
|
||||
self.style_id = style_id
|
||||
self.tpl_id = tpl_id
|
||||
|
||||
|
||||
class Outline:
|
||||
def __init__(self, chat_id, query_id, title, outline):
|
||||
self.chat_id = chat_id
|
||||
self.query_id = query_id
|
||||
self.title = title
|
||||
self.outline = outline
|
||||
|
||||
|
||||
def get_ppt_theme(api_key: str):
|
||||
"""Get a random PPT theme"""
|
||||
headers = {
|
||||
"Authorization": "Bearer %s" % api_key,
|
||||
}
|
||||
response = requests.post(URL_PREFIX + "get_ppt_theme", headers=headers)
|
||||
result = response.json()
|
||||
if "errno" in result and result["errno"] != 0:
|
||||
raise RuntimeError(result["errmsg"])
|
||||
|
||||
style_index = random.randint(0, len(result["data"]["ppt_themes"]) - 1)
|
||||
theme = result["data"]["ppt_themes"][style_index]
|
||||
return Style(style_id=theme["style_id"], tpl_id=theme["tpl_id"])
|
||||
|
||||
|
||||
def ppt_outline_generate(api_key: str, query: str):
|
||||
"""Generate PPT outline"""
|
||||
headers = {
|
||||
"Authorization": "Bearer %s" % api_key,
|
||||
"X-Appbuilder-From": "openclaw",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
headers.setdefault('Accept', 'text/event-stream')
|
||||
headers.setdefault('Cache-Control', 'no-cache')
|
||||
headers.setdefault('Connection', 'keep-alive')
|
||||
params = {
|
||||
"query": query,
|
||||
}
|
||||
title = ""
|
||||
outline = ""
|
||||
chat_id = ""
|
||||
query_id = ""
|
||||
with requests.post(URL_PREFIX + "generate_outline", headers=headers, json=params, stream=True) as response:
|
||||
for line in response.iter_lines():
|
||||
line = line.decode('utf-8')
|
||||
if line and line.startswith("data:"):
|
||||
data_str = line[5:].strip()
|
||||
delta = json.loads(data_str)
|
||||
if not title:
|
||||
title = delta["title"]
|
||||
chat_id = delta["chat_id"]
|
||||
query_id = delta["query_id"]
|
||||
outline += delta["outline"]
|
||||
|
||||
return Outline(chat_id=chat_id, query_id=query_id, title=title, outline=outline)
|
||||
|
||||
|
||||
def ppt_generate(api_key: str, query: str, style_id: int = 0, tpl_id: int = None, web_content: str = None):
|
||||
"""Generate PPT - simple version"""
|
||||
headers = {
|
||||
"Authorization": "Bearer %s" % api_key,
|
||||
"Content-Type": "application/json",
|
||||
"X-Appbuilder-From": "openclaw",
|
||||
}
|
||||
|
||||
# Get theme
|
||||
if tpl_id is None:
|
||||
# Random theme
|
||||
style = get_ppt_theme(api_key)
|
||||
style_id = style.style_id
|
||||
tpl_id = style.tpl_id
|
||||
print(f"Using random template (tpl_id: {tpl_id})", file=sys.stderr)
|
||||
else:
|
||||
# Specific theme - use provided style_id (default 0)
|
||||
print(f"Using template tpl_id: {tpl_id}, style_id: {style_id}", file=sys.stderr)
|
||||
|
||||
# Generate outline
|
||||
outline = ppt_outline_generate(api_key, query)
|
||||
|
||||
# Generate PPT
|
||||
headers.setdefault('Accept', 'text/event-stream')
|
||||
headers.setdefault('Cache-Control', 'no-cache')
|
||||
headers.setdefault('Connection', 'keep-alive')
|
||||
params = {
|
||||
"query_id": int(outline.query_id),
|
||||
"chat_id": int(outline.chat_id),
|
||||
"query": query,
|
||||
"outline": outline.outline,
|
||||
"title": outline.title,
|
||||
"style_id": style_id,
|
||||
"tpl_id": tpl_id,
|
||||
"web_content": web_content,
|
||||
"enable_save_bos": True,
|
||||
}
|
||||
with requests.post(URL_PREFIX + "generate_ppt_by_outline", headers=headers, json=params, stream=True) as response:
|
||||
if response.status_code != 200:
|
||||
print(f"request failed, status code is {response.status_code}, error message is {response.text}")
|
||||
return []
|
||||
for line in response.iter_lines():
|
||||
line = line.decode('utf-8')
|
||||
if line and line.startswith("data:"):
|
||||
data_str = line[5:].strip()
|
||||
yield json.loads(data_str)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Generate PPT")
|
||||
parser.add_argument("--query", "-q", type=str, required=True, help="PPT topic")
|
||||
parser.add_argument("--style_id", "-si", type=int, default=0, help="Style ID (default: 0)")
|
||||
parser.add_argument("--tpl_id", "-tp", type=int, help="Template ID (optional)")
|
||||
parser.add_argument("--web_content", "-wc", type=str, default=None, help="Web content")
|
||||
args = parser.parse_args()
|
||||
|
||||
api_key = os.getenv("BAIDU_API_KEY")
|
||||
if not api_key:
|
||||
print("Error: BAIDU_API_KEY must be set in environment.")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
start_time = int(time.time())
|
||||
results = ppt_generate(api_key, args.query, args.style_id, args.tpl_id, args.web_content)
|
||||
|
||||
for result in results:
|
||||
if "is_end" in result and result["is_end"]:
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
end_time = int(time.time())
|
||||
print(json.dumps({"status": result["status"], "run_time": end_time - start_time}))
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
43
skills_developing/ai-ppt-generator/scripts/ppt_theme_list.py
Normal file
43
skills_developing/ai-ppt-generator/scripts/ppt_theme_list.py
Normal file
@ -0,0 +1,43 @@
|
||||
import os
|
||||
import sys
|
||||
import requests
|
||||
import json
|
||||
|
||||
|
||||
def ppt_theme_list(api_key: str):
|
||||
url = "https://qianfan.baidubce.com/v2/tools/ai_ppt/get_ppt_theme"
|
||||
headers = {
|
||||
"Authorization": "Bearer %s" % api_key,
|
||||
"X-Appbuilder-From": "openclaw",
|
||||
}
|
||||
response = requests.post(url, headers=headers)
|
||||
result = response.json()
|
||||
if "errno" in result and result["errno"] != 0:
|
||||
raise RuntimeError(result["errmsg"])
|
||||
themes = []
|
||||
count = 0
|
||||
for theme in result["data"]["ppt_themes"]:
|
||||
count += 1
|
||||
if count > 100:
|
||||
break
|
||||
themes.append({
|
||||
"style_name_list": theme["style_name_list"],
|
||||
"style_id": theme["style_id"],
|
||||
"tpl_id": theme["tpl_id"],
|
||||
})
|
||||
return themes
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
api_key = os.getenv("BAIDU_API_KEY")
|
||||
if not api_key:
|
||||
print("Error: BAIDU_API_KEY must be set in environment.")
|
||||
sys.exit(1)
|
||||
try:
|
||||
results = ppt_theme_list(api_key)
|
||||
print(json.dumps(results, ensure_ascii=False, indent=2))
|
||||
except Exception as e:
|
||||
exc_type, exc_value, exc_traceback = sys.exc_info()
|
||||
print(f"error type:{exc_type}")
|
||||
print(f"error message:{exc_value}")
|
||||
sys.exit(1)
|
||||
321
skills_developing/ai-ppt-generator/scripts/random_ppt_theme.py
Normal file
321
skills_developing/ai-ppt-generator/scripts/random_ppt_theme.py
Normal file
@ -0,0 +1,321 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Random PPT Theme Selector
|
||||
If user doesn't select a PPT template, this script will randomly select one
|
||||
from the available templates and generate PPT.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import random
|
||||
import argparse
|
||||
import subprocess
|
||||
import time
|
||||
def get_available_themes():
|
||||
"""Get available PPT themes"""
|
||||
try:
|
||||
api_key = os.getenv("BAIDU_API_KEY")
|
||||
if not api_key:
|
||||
print("Error: BAIDU_API_KEY environment variable not set", file=sys.stderr)
|
||||
return []
|
||||
|
||||
# Import the function from ppt_theme_list.py
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, script_dir)
|
||||
|
||||
from ppt_theme_list import ppt_theme_list as get_themes
|
||||
themes = get_themes(api_key)
|
||||
return themes
|
||||
except Exception as e:
|
||||
print(f"Error getting themes: {e}", file=sys.stderr)
|
||||
return []
|
||||
|
||||
|
||||
|
||||
def categorize_themes(themes):
|
||||
"""Categorize themes by style for better random selection"""
|
||||
categorized = {
|
||||
"企业商务": [],
|
||||
"文艺清新": [],
|
||||
"卡通手绘": [],
|
||||
"扁平简约": [],
|
||||
"中国风": [],
|
||||
"年终总结": [],
|
||||
"创意趣味": [],
|
||||
"文化艺术": [],
|
||||
"未来科技": [],
|
||||
"默认": []
|
||||
}
|
||||
|
||||
for theme in themes:
|
||||
style_names = theme.get("style_name_list", [])
|
||||
if not style_names:
|
||||
categorized["默认"].append(theme)
|
||||
continue
|
||||
|
||||
added = False
|
||||
for style_name in style_names:
|
||||
if style_name in categorized:
|
||||
categorized[style_name].append(theme)
|
||||
added = True
|
||||
break
|
||||
|
||||
if not added:
|
||||
categorized["默认"].append(theme)
|
||||
|
||||
return categorized
|
||||
|
||||
|
||||
def select_random_theme_by_category(categorized_themes, preferred_category=None):
|
||||
"""Select a random theme, optionally preferring a specific category"""
|
||||
# If preferred category specified and has themes, use it
|
||||
if preferred_category and preferred_category in categorized_themes:
|
||||
if categorized_themes[preferred_category]:
|
||||
return random.choice(categorized_themes[preferred_category])
|
||||
|
||||
# Otherwise, select from all non-empty categories
|
||||
available_categories = []
|
||||
for category, themes in categorized_themes.items():
|
||||
if themes:
|
||||
available_categories.append(category)
|
||||
|
||||
if not available_categories:
|
||||
return None
|
||||
|
||||
# Weighted random selection: prefer non-default categories
|
||||
weights = []
|
||||
for category in available_categories:
|
||||
if category == "默认":
|
||||
weights.append(0.5) # Lower weight for default
|
||||
else:
|
||||
weights.append(2.0) # Higher weight for specific styles
|
||||
|
||||
# Normalize weights
|
||||
total_weight = sum(weights)
|
||||
weights = [w/total_weight for w in weights]
|
||||
|
||||
selected_category = random.choices(available_categories, weights=weights, k=1)[0]
|
||||
return random.choice(categorized_themes[selected_category])
|
||||
|
||||
|
||||
def suggest_category_by_query(query):
|
||||
"""Suggest template category based on query keywords - enhanced version"""
|
||||
query_lower = query.lower()
|
||||
|
||||
# Comprehensive keyword mapping with priority order
|
||||
keyword_mapping = [
|
||||
# Business & Corporate (highest priority for formal content)
|
||||
("企业商务", [
|
||||
"企业", "公司", "商务", "商业", "商务", "商业计划", "商业报告",
|
||||
"营销", "市场", "销售", "财务", "会计", "审计", "投资", "融资",
|
||||
"战略", "管理", "运营", "人力资源", "hr", "董事会", "股东",
|
||||
"年报", "季报", "财报", "业绩", "kpi", "okr", "商业计划书",
|
||||
"提案", "策划", "方案", "报告", "总结", "规划", "计划"
|
||||
]),
|
||||
|
||||
# Technology & Future Tech
|
||||
("未来科技", [
|
||||
"未来", "科技", "人工智能", "ai", "机器学习", "深度学习",
|
||||
"大数据", "云计算", "区块链", "物联网", "iot", "5g", "6g",
|
||||
"量子计算", "机器人", "自动化", "智能制造", "智慧城市",
|
||||
"虚拟现实", "vr", "增强现实", "ar", "元宇宙", "数字孪生",
|
||||
"芯片", "半导体", "集成电路", "电子", "通信", "网络",
|
||||
"网络安全", "信息安全", "数字化", "数字化转型",
|
||||
"科幻", "高科技", "前沿科技", "科技创新", "技术"
|
||||
]),
|
||||
|
||||
# Education & Children
|
||||
("卡通手绘", [
|
||||
"卡通", "动画", "动漫", "儿童", "幼儿", "小学生", "中学生",
|
||||
"教育", "教学", "课件", "教案", "学习", "培训", "教程",
|
||||
"趣味", "有趣", "可爱", "活泼", "生动", "绘本", "漫画",
|
||||
"手绘", "插画", "图画", "图形", "游戏", "玩乐", "娱乐"
|
||||
]),
|
||||
|
||||
# Year-end & Summary
|
||||
("年终总结", [
|
||||
"年终", "年度", "季度", "月度", "周报", "日报",
|
||||
"总结", "回顾", "汇报", "述职", "考核", "评估",
|
||||
"成果", "成绩", "业绩", "绩效", "目标", "完成",
|
||||
"工作汇报", "工作总结", "年度报告", "季度报告"
|
||||
]),
|
||||
|
||||
# Minimalist & Modern Design
|
||||
("扁平简约", [
|
||||
"简约", "简洁", "简单", "极简", "现代", "当代",
|
||||
"设计", "视觉", "ui", "ux", "用户体验", "用户界面",
|
||||
"科技感", "数字感", "数据", "图表", "图形", "信息图",
|
||||
"分析", "统计", "报表", "dashboard", "仪表板",
|
||||
"互联网", "web", "移动", "app", "应用", "软件"
|
||||
]),
|
||||
|
||||
# Chinese Traditional
|
||||
("中国风", [
|
||||
"中国", "中华", "传统", "古典", "古风", "古代",
|
||||
"文化", "文明", "历史", "国学", "东方", "水墨",
|
||||
"书法", "国画", "诗词", "古文", "经典", "传统节日",
|
||||
"春节", "中秋", "端午", "节气", "风水", "易经",
|
||||
"儒", "道", "佛", "禅", "茶道", "瓷器", "丝绸"
|
||||
]),
|
||||
|
||||
# Cultural & Artistic
|
||||
("文化艺术", [
|
||||
"文化", "艺术", "文艺", "美学", "审美", "创意",
|
||||
"创作", "作品", "展览", "博物馆", "美术馆", "画廊",
|
||||
"音乐", "舞蹈", "戏剧", "戏曲", "电影", "影视",
|
||||
"摄影", "绘画", "雕塑", "建筑", "设计", "时尚",
|
||||
"文学", "诗歌", "小说", "散文", "哲学", "思想"
|
||||
]),
|
||||
|
||||
# Artistic & Fresh
|
||||
("文艺清新", [
|
||||
"文艺", "清新", "小清新", "治愈", "温暖", "温柔",
|
||||
"浪漫", "唯美", "优雅", "精致", "细腻", "柔和",
|
||||
"自然", "生态", "环保", "绿色", "植物", "花卉",
|
||||
"风景", "旅行", "游记", "生活", "日常", "情感"
|
||||
]),
|
||||
|
||||
# Creative & Fun
|
||||
("创意趣味", [
|
||||
"创意", "创新", "创造", "发明", "新奇", "新颖",
|
||||
"独特", "个性", "特色", "趣味", "有趣", "好玩",
|
||||
"幽默", "搞笑", "笑话", "娱乐", "休闲", "放松",
|
||||
"脑洞", "想象力", "灵感", "点子", "想法", "概念"
|
||||
]),
|
||||
|
||||
# Academic & Research
|
||||
("默认", [
|
||||
"研究", "学术", "科学", "论文", "课题", "项目",
|
||||
"实验", "调查", "分析", "理论", "方法", "技术",
|
||||
"医学", "健康", "医疗", "生物", "化学", "物理",
|
||||
"数学", "工程", "建筑", "法律", "政治", "经济",
|
||||
"社会", "心理", "教育", "学习", "知识", "信息"
|
||||
])
|
||||
]
|
||||
|
||||
# Check each category with its keywords
|
||||
for category, keywords in keyword_mapping:
|
||||
for keyword in keywords:
|
||||
if keyword in query_lower:
|
||||
return category
|
||||
|
||||
# If no match found, analyze query length and content
|
||||
words = query_lower.split()
|
||||
if len(words) <= 3:
|
||||
# Short query, likely specific - use "默认" or tech-related
|
||||
if any(word in query_lower for word in ["ai", "vr", "ar", "iot", "5g", "tech"]):
|
||||
return "未来科技"
|
||||
return "默认"
|
||||
else:
|
||||
# Longer query, analyze word frequency
|
||||
word_counts = {}
|
||||
for word in words:
|
||||
if len(word) > 1: # Ignore single characters
|
||||
word_counts[word] = word_counts.get(word, 0) + 1
|
||||
|
||||
# Check for business indicators
|
||||
business_words = ["报告", "总结", "计划", "方案", "业绩", "销售", "市场"]
|
||||
if any(word in word_counts for word in business_words):
|
||||
return "企业商务"
|
||||
|
||||
# Check for tech indicators
|
||||
tech_words = ["技术", "科技", "数据", "数字", "智能", "系统"]
|
||||
if any(word in word_counts for word in tech_words):
|
||||
return "未来科技"
|
||||
|
||||
# Default fallback
|
||||
return "默认"
|
||||
|
||||
|
||||
def generate_ppt_with_random_theme(query, preferred_category=None):
|
||||
"""Generate PPT with randomly selected theme"""
|
||||
# Get available themes
|
||||
themes = get_available_themes()
|
||||
if not themes:
|
||||
print("Error: No available themes found", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Categorize themes
|
||||
categorized = categorize_themes(themes)
|
||||
|
||||
# Select random theme
|
||||
selected_theme = select_random_theme_by_category(categorized, preferred_category)
|
||||
if not selected_theme:
|
||||
print("Error: Could not select a theme", file=sys.stderr)
|
||||
return False
|
||||
|
||||
style_id = selected_theme.get("style_id", 0)
|
||||
tpl_id = selected_theme.get("tpl_id")
|
||||
style_names = selected_theme.get("style_name_list", ["默认"])
|
||||
|
||||
print(f"Selected template: {style_names[0]} (tpl_id: {tpl_id})", file=sys.stderr)
|
||||
|
||||
# Generate PPT
|
||||
script_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "generate_ppt.py")
|
||||
|
||||
try:
|
||||
# Run generate_ppt.py with the selected theme
|
||||
cmd = [
|
||||
sys.executable, script_path,
|
||||
"--query", query,
|
||||
"--tpl_id", str(tpl_id),
|
||||
"--style_id", str(style_id)
|
||||
]
|
||||
|
||||
start_time = int(time.time())
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
universal_newlines=True
|
||||
)
|
||||
|
||||
# Stream output
|
||||
for line in process.stdout:
|
||||
line = line.strip()
|
||||
if line:
|
||||
try:
|
||||
data = json.loads(line)
|
||||
if "is_end" in data and data["is_end"]:
|
||||
print(json.dumps(data, ensure_ascii=False))
|
||||
else:
|
||||
end_time = int(time.time())
|
||||
print(json.dumps({"status": data.get("status", "生成中"), "run_time": end_time - start_time}, ensure_ascii=False))
|
||||
except json.JSONDecodeError:
|
||||
# Just print non-JSON output
|
||||
print(line)
|
||||
|
||||
process.wait()
|
||||
return process.returncode == 0
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error generating PPT: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Generate PPT with random theme selection")
|
||||
parser.add_argument("--query", "-q", type=str, required=True, help="PPT主题/内容")
|
||||
parser.add_argument("--category", "-c", type=str, help="Preferred category (企业商务/文艺清新/卡通手绘/扁平简约/中国风/年终总结/创意趣味/文化艺术/未来科技)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Determine preferred category
|
||||
preferred_category = args.category
|
||||
if not preferred_category:
|
||||
preferred_category = suggest_category_by_query(args.query)
|
||||
if preferred_category:
|
||||
print(f"Auto-suggested category: {preferred_category}", file=sys.stderr)
|
||||
|
||||
# Generate PPT
|
||||
success = generate_ppt_with_random_theme(args.query, preferred_category)
|
||||
|
||||
if not success:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
50
skills_developing/z-card-image/README.md
Normal file
50
skills_developing/z-card-image/README.md
Normal file
@ -0,0 +1,50 @@
|
||||
# z-card-image
|
||||
|
||||
文字卡片图片生成器,把文案渲染成 PNG,支持公众号、小红书等多种配色风格。
|
||||
|
||||
## 效果
|
||||
|
||||
- 输入一段文字,自动排版成精美卡片图
|
||||
- 支持多行文字 + 高亮、品牌 footer、顶部图标
|
||||
- 默认模板:3:4 竖版(900×1200)
|
||||
|
||||
## 依赖
|
||||
|
||||
- Python 3
|
||||
- Google Chrome(macOS:`/Applications/Google Chrome.app`)
|
||||
|
||||
## 自定义品牌信息
|
||||
|
||||
默认配色和 footer 是「早早集市」的风格,改成你自己的只需修改 `SKILL.md` 里的**平台预设**表格:
|
||||
|
||||
```markdown
|
||||
## 平台预设
|
||||
|
||||
| 平台 | `--footer` | `--bg` | `--highlight` |
|
||||
|------|-----------|--------|--------------|
|
||||
| 公众号(默认) | `公众号 · 你的名字` | `#e6f5ef` | `#22a854` |
|
||||
| 小红书 | `小红书 · 你的名字` | `#fdecea` | `#e53935` |
|
||||
```
|
||||
|
||||
`render_card.py` 支持的所有参数:
|
||||
|
||||
| 参数 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `--footer` | 底部署名文字 | `公众号 · 早早集市` |
|
||||
| `--bg` | 背景色(hex) | `#e6f5ef` |
|
||||
| `--highlight` | 高亮色(hex) | `#22a854` |
|
||||
| `--icon` | 顶部图标路径 | 自动判断 |
|
||||
| `--template` | 模板名 | `xiaolvs-cover` |
|
||||
|
||||
把 `--footer` 改成你的公众号名,`--bg` / `--highlight` 改成你的品牌色,就是你的专属风格了。
|
||||
|
||||
## 替换 Logo
|
||||
|
||||
顶部图标在 `assets/icons/` 目录,替换对应 SVG/JPG 文件即可,文件名保持一致。
|
||||
|
||||
## 新增模板
|
||||
|
||||
1. 新建 `assets/templates/<name>.html`
|
||||
2. 在 `scripts/render_card.py` 的 `size_map` 里注册尺寸
|
||||
3. 在 `SKILL.md` 模板索引中添加一行
|
||||
4. 创建 `references/<name>.md` 记录参数和字数上限
|
||||
90
skills_developing/z-card-image/SKILL.md
Normal file
90
skills_developing/z-card-image/SKILL.md
Normal file
@ -0,0 +1,90 @@
|
||||
---
|
||||
name: z-card-image
|
||||
version: 1.1.0
|
||||
description: 生成配图、封面图、卡片图、文字海报、公众号文章封面图、微信公众号头图、X 风格帖子分享图、帖子长图、社媒帖子长图。适用于帖子类型数据、post data、social posts、tweet/thread、转发推文、转发帖子、小绿书配图、图片封面、card image。
|
||||
metadata:
|
||||
openclaw:
|
||||
requires:
|
||||
bins:
|
||||
- python3
|
||||
- google-chrome
|
||||
---
|
||||
|
||||
# z-card-image
|
||||
|
||||
将用户提供的文案渲染成 PNG 卡片图。
|
||||
支持短文案封面图、长文分页图、X 风格帖子分享长图,以及公众号文章封面图。只要输入是“帖子类型数据”并希望导出成 X 风格长图,都应走 `x-like-posts`。
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Python 3
|
||||
- Google Chrome(macOS:`/Applications/Google Chrome.app`;Linux:`chromium` 需修改脚本路径)
|
||||
|
||||
## 执行流程
|
||||
|
||||
0. **环境提示**(用户触发时检测一次,有问题给提示,不中止流程):
|
||||
- `python3 --version` → 失败则告知:「⚠️ 未检测到 Python 3,渲染可能失败」
|
||||
- 检查 Chrome 路径 → 失败则提示安装
|
||||
|
||||
1. **识别场景**:
|
||||
- 短文案封面图 → `poster-3-4`
|
||||
- 长文分页图 → `article-3-4`
|
||||
- X 风格帖子分享图 / 帖子长图 / 帖子类型数据 → `x-like-posts`
|
||||
- 公众号文章封面图 → `wechat-cover-split`
|
||||
2. **查模板规则**:根据模板在「模板索引」中找到对应规范文档,读取后按其规则处理文案和参数。**如用户要求高亮:整行用 `--hl1/hl2/hl3`,按词用 `--highlight-words`(逗号分隔),两者可同时使用,不能忽略**
|
||||
3. **确认署名**:先询问用户底部署名文字(`--footer`),如:「请告诉我你的署名,例如"公众号 · 你的名字"」。用户未回答或要求跳过时,使用脚本默认值。
|
||||
4. **确定配色**:拿到署名后,再确定配色方案:
|
||||
- 用户提到"小红书配图" → 推荐方案 B(热情红)
|
||||
- 用户提到"小绿书"或"公众号配图" → 推荐方案 A(清新绿)
|
||||
- 用户提到"推特长图"/"X 风格" → 使用 `render_x_like_posts.py` 自带默认值(Twitter 蓝白灰)
|
||||
- 其他场景 → 询问用户选择下方配色方案:
|
||||
|
||||
| 方案 | 风格 | `--bg` | `--highlight` | 适用场景 |
|
||||
|------|------|--------|--------------|---------|
|
||||
| A. 清新绿 | 公众号/小绿书 | `#e6f5ef` | `#22a854` | 公众号配图、小绿书 |
|
||||
| B. 热情红 | 小红书 | `#fdecea` | `#e53935` | 小红书配图 |
|
||||
| C. 科技蓝 | 知乎/技术 | `#e8f4fd` | `#1976d2` | 技术文章、知乎 |
|
||||
| D. 暖橙黄 | 活力/营销 | `#fff8e1` | `#f57c00` | 活动海报、营销 |
|
||||
| E. 优雅紫 | 时尚/文艺 | `#f3e5f5` | `#7b1fa2` | 文艺、时尚类 |
|
||||
| F. 经典黑白 | 极简 | `#f5f5f5` | `#212121` | 极简风格 |
|
||||
|
||||
用户也可自定义 `--bg` 和 `--highlight`。用户未回答或要求跳过时,使用脚本默认值,不做额外覆盖。
|
||||
5. **渲染输出**:
|
||||
- `poster-3-4` → 执行 `render_card.py`
|
||||
- `article-3-4` → 执行 `render_article.py`
|
||||
- `x-like-posts` → 执行 `render_x_like_posts.py`
|
||||
- `wechat-cover-split` → 执行 `render_card.py`
|
||||
- 默认 `--out` 填 `tmp/...png`;如用户指定导出位置,可直接传绝对路径或相对路径
|
||||
6. **输出产物**:生成 PNG 到指定路径,供后续发送、裁切或复用;如需给外部工具上传,仍应避免写入系统 `/tmp/`
|
||||
|
||||
## x-like-posts 导航
|
||||
|
||||
`x-like-posts` 用于“帖子类型数据 → X 风格分享长图”。
|
||||
|
||||
当命中这条路线时,继续读取:
|
||||
|
||||
- [references/x-like-posts.md](references/x-like-posts.md):输入 JSON 格式、可显示字段、时间规则、导出规则
|
||||
- [references/tweet-thread.md](references/tweet-thread.md):旧命名兼容说明
|
||||
|
||||
## 输入校验
|
||||
|
||||
- **比例不存在**:驳回请求,告知当前支持的比例列表,询问是否新增模板
|
||||
- **文案超出模板字数上限**:先自动拆分/缩写后再渲染,不要直接塞入
|
||||
- **帖子过多**:按规范拆成多张 `Part 1 / Part 2`,不要把超长内容强行塞进一张
|
||||
- **公众号封面标题过长**:先压缩成 2~3 行短标题,再渲染,不能把完整长标题硬塞进模板
|
||||
|
||||
## 模板索引
|
||||
|
||||
| 模板名 | 比例 | 尺寸 | 用途 | 规范文档 |
|
||||
|--------|------|------|------|---------|
|
||||
| `poster-3-4` | 3:4 | 900×1200 | 文字海报(金句/大字报/封面) | [references/poster-3-4.md](references/poster-3-4.md) |
|
||||
| `article-3-4` | 3:4 | 900×1200 | 长文分页卡片 | [references/article-3-4.md](references/article-3-4.md) |
|
||||
| `x-like-posts` | 自适应长图 | 900px 宽 | X 风格帖子分享长图 | [references/x-like-posts.md](references/x-like-posts.md) |
|
||||
| `wechat-cover-split` | 335:100 | 1340×400 | 公众号文章封面长条图(左标题右 icon) | [references/wechat-cover-split.md](references/wechat-cover-split.md) |
|
||||
|
||||
## 新增模板
|
||||
|
||||
1. 新建 `assets/templates/<name>.html`
|
||||
2. 在 `render_card.py` 的 `size_map` 里注册尺寸
|
||||
3. 在上方模板索引中添加一行
|
||||
4. 创建对应 `references/<name>.md`,记录该模板的参数、字数上限、配图选取规则
|
||||
6
skills_developing/z-card-image/_meta.json
Normal file
6
skills_developing/z-card-image/_meta.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"ownerId": "kn7f5x5z4a9avydehdqe6px1598222s5",
|
||||
"slug": "z-card-image",
|
||||
"version": "1.1.0",
|
||||
"publishedAt": 1773287088721
|
||||
}
|
||||
Binary file not shown.
15
skills_developing/z-card-image/assets/icons/clover.svg
Normal file
15
skills_developing/z-card-image/assets/icons/clover.svg
Normal file
@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||
<!-- 四叶草 - 四个圆形叶片 + 茎 -->
|
||||
<!-- 左叶 -->
|
||||
<ellipse cx="35" cy="50" rx="18" ry="13" fill="#22a854" transform="rotate(-45 35 50)"/>
|
||||
<!-- 右叶 -->
|
||||
<ellipse cx="65" cy="50" rx="18" ry="13" fill="#22a854" transform="rotate(45 65 50)"/>
|
||||
<!-- 上叶 -->
|
||||
<ellipse cx="50" cy="35" rx="13" ry="18" fill="#27c25e" transform="rotate(45 50 35)"/>
|
||||
<!-- 下叶 -->
|
||||
<ellipse cx="50" cy="65" rx="13" ry="18" fill="#27c25e" transform="rotate(-45 50 65)"/>
|
||||
<!-- 中心 -->
|
||||
<circle cx="50" cy="50" r="7" fill="#1a8a40"/>
|
||||
<!-- 茎 -->
|
||||
<path d="M50 57 Q55 70 52 82" stroke="#1a8a40" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 758 B |
58
skills_developing/z-card-image/assets/icons/jinx-face.svg
Normal file
58
skills_developing/z-card-image/assets/icons/jinx-face.svg
Normal file
@ -0,0 +1,58 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 280" width="200" height="280">
|
||||
<!-- 左辫子 -->
|
||||
<path d="M52 80 Q20 120 18 160 Q16 190 22 220 Q26 240 30 260"
|
||||
stroke="#7ec8e3" stroke-width="14" fill="none" stroke-linecap="round"/>
|
||||
<path d="M52 80 Q18 122 16 162 Q14 192 20 222 Q24 242 28 262"
|
||||
stroke="#5ab4d4" stroke-width="6" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<!-- 右辫子 -->
|
||||
<path d="M148 80 Q180 120 182 160 Q184 190 178 220 Q174 240 170 260"
|
||||
stroke="#7ec8e3" stroke-width="14" fill="none" stroke-linecap="round"/>
|
||||
<path d="M148 80 Q182 122 184 162 Q186 192 180 222 Q176 242 172 262"
|
||||
stroke="#5ab4d4" stroke-width="6" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<!-- 辫子末端绑带 -->
|
||||
<ellipse cx="30" cy="260" rx="8" ry="5" fill="#c084fc" transform="rotate(-20 30 260)"/>
|
||||
<ellipse cx="170" cy="260" rx="8" ry="5" fill="#c084fc" transform="rotate(20 170 260)"/>
|
||||
|
||||
<!-- 头发顶部 -->
|
||||
<path d="M50 75 Q70 30 100 25 Q130 30 150 75 Q130 60 100 58 Q70 60 50 75Z" fill="#7ec8e3"/>
|
||||
<!-- 刘海散发 -->
|
||||
<path d="M60 75 Q65 55 72 70" fill="#7ec8e3"/>
|
||||
<path d="M80 62 Q82 45 90 62" fill="#7ec8e3"/>
|
||||
|
||||
<!-- 脸 -->
|
||||
<ellipse cx="100" cy="110" rx="46" ry="54" fill="#f0e6d8"/>
|
||||
|
||||
<!-- 眉毛 - 细挑上扬 -->
|
||||
<path d="M70 82 Q80 76 90 80" stroke="#5ab4d4" stroke-width="2.5" fill="none" stroke-linecap="round"/>
|
||||
<path d="M110 80 Q120 76 130 82" stroke="#5ab4d4" stroke-width="2.5" fill="none" stroke-linecap="round"/>
|
||||
|
||||
<!-- 眼睛 - 大而狂 -->
|
||||
<!-- 左眼 -->
|
||||
<ellipse cx="80" cy="102" rx="14" ry="12" fill="white"/>
|
||||
<ellipse cx="80" cy="103" rx="9" ry="9" fill="#6b9fff"/>
|
||||
<ellipse cx="80" cy="103" rx="5" ry="5" fill="#1a1a2e"/>
|
||||
<circle cx="83" cy="100" r="2.5" fill="white"/>
|
||||
<!-- 左眼下眼线 -->
|
||||
<path d="M67 107 Q80 112 93 107" stroke="#5ab4d4" stroke-width="1.5" fill="none"/>
|
||||
|
||||
<!-- 右眼 -->
|
||||
<ellipse cx="120" cy="102" rx="14" ry="12" fill="white"/>
|
||||
<ellipse cx="120" cy="103" rx="9" ry="9" fill="#6b9fff"/>
|
||||
<ellipse cx="120" cy="103" rx="5" ry="5" fill="#1a1a2e"/>
|
||||
<circle cx="123" cy="100" r="2.5" fill="white"/>
|
||||
<!-- 右眼下眼线 -->
|
||||
<path d="M107 107 Q120 112 133 107" stroke="#5ab4d4" stroke-width="1.5" fill="none"/>
|
||||
|
||||
<!-- 鼻子 - 极简 -->
|
||||
<path d="M97 120 Q100 126 103 120" stroke="#c4a882" stroke-width="1.5" fill="none" stroke-linecap="round"/>
|
||||
|
||||
<!-- 嘴巴 - 疯狂大笑 -->
|
||||
<path d="M78 140 Q90 133 100 134 Q110 133 122 140" stroke="#1a1a1a" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
<path d="M78 140 Q100 156 122 140 Q110 148 100 149 Q90 148 78 140Z" fill="#d45a5a"/>
|
||||
<!-- 牙齿 -->
|
||||
<path d="M85 141 Q100 153 115 141 Q100 145 85 141Z" fill="white"/>
|
||||
|
||||
<!-- 脸颊腮红 -->
|
||||
<ellipse cx="68" cy="118" rx="10" ry="6" fill="#f4a0b0" opacity="0.4"/>
|
||||
<ellipse cx="132" cy="118" rx="10" ry="6" fill="#f4a0b0" opacity="0.4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@ -0,0 +1,41 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 120" width="100" height="120">
|
||||
<!-- OpenClaw 小胖龙虾 -->
|
||||
<!-- 身体 -->
|
||||
<ellipse cx="50" cy="70" rx="26" ry="32" fill="#e8472a"/>
|
||||
<!-- 头部 -->
|
||||
<ellipse cx="50" cy="42" rx="20" ry="18" fill="#e8472a"/>
|
||||
<!-- 眼睛 -->
|
||||
<circle cx="42" cy="37" r="5" fill="white"/>
|
||||
<circle cx="58" cy="37" r="5" fill="white"/>
|
||||
<circle cx="43" cy="37" r="3" fill="#222"/>
|
||||
<circle cx="59" cy="37" r="3" fill="#222"/>
|
||||
<!-- 眼睛高光 -->
|
||||
<circle cx="44" cy="36" r="1" fill="white"/>
|
||||
<circle cx="60" cy="36" r="1" fill="white"/>
|
||||
<!-- 触角 -->
|
||||
<path d="M42 30 Q36 18 30 12" stroke="#c03820" stroke-width="2.5" fill="none" stroke-linecap="round"/>
|
||||
<path d="M58 30 Q64 18 70 12" stroke="#c03820" stroke-width="2.5" fill="none" stroke-linecap="round"/>
|
||||
<circle cx="30" cy="12" r="3" fill="#c03820"/>
|
||||
<circle cx="70" cy="12" r="3" fill="#c03820"/>
|
||||
<!-- 大螯 左 -->
|
||||
<path d="M30 58 Q14 52 12 44 Q10 36 18 34 Q24 32 26 38" stroke="#c03820" stroke-width="3" fill="#e8472a" stroke-linejoin="round"/>
|
||||
<ellipse cx="19" cy="38" rx="8" ry="6" fill="#e8472a" transform="rotate(-20 19 38)"/>
|
||||
<!-- 大螯 右 -->
|
||||
<path d="M70 58 Q86 52 88 44 Q90 36 82 34 Q76 32 74 38" stroke="#c03820" stroke-width="3" fill="#e8472a" stroke-linejoin="round"/>
|
||||
<ellipse cx="81" cy="38" rx="8" ry="6" fill="#e8472a" transform="rotate(20 81 38)"/>
|
||||
<!-- 腿 左侧 -->
|
||||
<line x1="30" y1="68" x2="18" y2="76" stroke="#c03820" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<line x1="28" y1="78" x2="16" y2="86" stroke="#c03820" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<line x1="30" y1="88" x2="20" y2="96" stroke="#c03820" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<!-- 腿 右侧 -->
|
||||
<line x1="70" y1="68" x2="82" y2="76" stroke="#c03820" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<line x1="72" y1="78" x2="84" y2="86" stroke="#c03820" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<line x1="70" y1="88" x2="80" y2="96" stroke="#c03820" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<!-- 尾巴 -->
|
||||
<path d="M38 98 Q44 108 50 110 Q56 108 62 98" fill="#e8472a" stroke="#c03820" stroke-width="1.5"/>
|
||||
<path d="M42 100 Q50 112 58 100" fill="#c03820" opacity="0.5"/>
|
||||
<!-- 肚子纹路 -->
|
||||
<path d="M40 60 Q50 64 60 60" stroke="#c03820" stroke-width="1.5" fill="none" opacity="0.6"/>
|
||||
<path d="M38 70 Q50 75 62 70" stroke="#c03820" stroke-width="1.5" fill="none" opacity="0.6"/>
|
||||
<path d="M40 80 Q50 85 60 80" stroke="#c03820" stroke-width="1.5" fill="none" opacity="0.6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@ -0,0 +1,22 @@
|
||||
<svg viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Lobster Claw Silhouette -->
|
||||
<path d="M60 10 C30 10 15 35 15 55 C15 75 30 95 45 100 L45 110 L55 110 L55 100 C55 100 60 102 65 100 L65 110 L75 110 L75 100 C90 95 105 75 105 55 C105 35 90 10 60 10Z" fill="url(#lobster-gradient)" class="claw-body"/>
|
||||
<!-- Left Claw -->
|
||||
<path d="M20 45 C5 40 0 50 5 60 C10 70 20 65 25 55 C28 48 25 45 20 45Z" fill="url(#lobster-gradient)" class="claw-left"/>
|
||||
<!-- Right Claw -->
|
||||
<path d="M100 45 C115 40 120 50 115 60 C110 70 100 65 95 55 C92 48 95 45 100 45Z" fill="url(#lobster-gradient)" class="claw-right"/>
|
||||
<!-- Antenna -->
|
||||
<path d="M45 15 Q35 5 30 8" stroke="#ff6b6b" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M75 15 Q85 5 90 8" stroke="#ff6b6b" stroke-width="2" stroke-linecap="round"/>
|
||||
<!-- Eyes -->
|
||||
<circle cx="45" cy="35" r="6" fill="#0d1117"/>
|
||||
<circle cx="75" cy="35" r="6" fill="#0d1117"/>
|
||||
<circle cx="46" cy="34" r="2" fill="#00e5ff"/>
|
||||
<circle cx="76" cy="34" r="2" fill="#00e5ff"/>
|
||||
<defs>
|
||||
<linearGradient id="lobster-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ff4d4d"/>
|
||||
<stop offset="100%" stop-color="#e8312a"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="_图层_2" data-name="图层 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 446.8 446.8">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #ccc;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: none;
|
||||
stroke: #ccc;
|
||||
stroke-miterlimit: 10;
|
||||
stroke-width: 10px;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="_图层_1-2" data-name="图层 1">
|
||||
<path class="cls-2" d="M55.51,363.1C23.97,325.24,5,276.54,5,223.4,5,102.78,102.78,5,223.4,5c54.85,0,104.98,20.22,143.34,53.61"/>
|
||||
<path class="cls-2" d="M387.72,79.53c33.67,38.43,54.08,88.77,54.08,143.87,0,120.62-97.78,218.4-218.4,218.4-58.26,0-111.2-22.82-150.36-60"/>
|
||||
<g>
|
||||
<path class="cls-1" d="M89.56,81.07v46.78c0,4.2,3.94,7.6,8.8,7.6h132.97c7.57,0,11.61,7.71,6.61,12.62l-157.95,155.15c-1.41,1.39-2.19,3.17-2.19,5.02v12.26c0,6.92,9.85,10.24,15.28,5.15l255.31-239.42c5.19-4.87,1.19-12.75-6.48-12.75H98.36c-4.86,0-8.8,3.4-8.8,7.6Z"/>
|
||||
<path class="cls-1" d="M363.12,133.88v-16.59c0-6.92-9.85-10.24-15.28-5.15l-255.69,239.78c-5.19,4.87-1.19,12.75,6.48,12.75h261.57c4.86,0,8.8-3.4,8.8-7.6v-47.32c0-4.2-3.94-7.6-8.8-7.6h-148.54c-7.61,0-11.63-7.77-6.56-12.66l155.79-150.55c1.44-1.39,2.24-3.2,2.24-5.06Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
76
skills_developing/z-card-image/assets/styles/md.css
Normal file
76
skills_developing/z-card-image/assets/styles/md.css
Normal file
@ -0,0 +1,76 @@
|
||||
/*
|
||||
* md.css — Markdown 内容渲染样式
|
||||
* 用于 article-3-4 模板,供用户自定义覆盖。
|
||||
*
|
||||
* 注意:{{HIGHLIGHT_COLOR}} 由脚本在运行时内联替换到模板 <style> 里覆盖,
|
||||
* 此文件中的颜色为默认值(莫兰迪绿 #3d6b4f),用户可直接修改此文件。
|
||||
*/
|
||||
|
||||
/* ── 段落 ── */
|
||||
.content p { margin-bottom: 20px; }
|
||||
.content p:last-child { margin-bottom: 0; }
|
||||
|
||||
/* ── 加粗 / 斜体 ── */
|
||||
.content strong, .content b { color: #3d6b4f; font-weight: 800; }
|
||||
.content em, .content i { color: #555; font-style: italic; }
|
||||
|
||||
/* ── 标题 ── */
|
||||
.content h1, .content h2, .content h3, .content h4 {
|
||||
font-weight: 800;
|
||||
line-height: 1.4;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 16px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
.content h2 {
|
||||
font-size: 34px;
|
||||
border-left: 5px solid #3d6b4f;
|
||||
padding-left: 14px;
|
||||
color: #3d6b4f;
|
||||
}
|
||||
.content h3 { font-size: 30px; color: #3d6b4f; }
|
||||
|
||||
/* ── 列表 ── */
|
||||
.content ul, .content ol { margin: 0 0 20px 0; padding-left: 36px; }
|
||||
.content li { margin-bottom: 8px; line-height: 1.75; }
|
||||
.content ul li::marker { color: #3d6b4f; }
|
||||
.content ol li::marker { color: #3d6b4f; font-weight: 700; }
|
||||
|
||||
/* ── blockquote ── */
|
||||
.content blockquote {
|
||||
border-left: 4px solid #3d6b4f;
|
||||
background: rgba(61,107,79,0.07);
|
||||
margin: 0 0 20px 0;
|
||||
padding: 10px 18px;
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
.content blockquote p { margin: 0; color: #444; font-style: italic; font-size: 27px; }
|
||||
|
||||
/* ── inline code ── */
|
||||
.content code {
|
||||
font-family: "SF Mono", "Fira Code", "Consolas", monospace;
|
||||
font-size: 24px;
|
||||
background: rgba(61,107,79,0.1);
|
||||
color: #3d6b4f;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ── code block ── */
|
||||
.content pre {
|
||||
background: #1e2a22;
|
||||
border-radius: 8px;
|
||||
padding: 18px 22px;
|
||||
margin-bottom: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.content pre code {
|
||||
background: transparent;
|
||||
color: #b8f0c8;
|
||||
font-size: 22px;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* ── hr ── */
|
||||
.content hr { border: none; border-top: 1px solid rgba(0,0,0,0.1); margin: 20px 0; }
|
||||
138
skills_developing/z-card-image/assets/templates/article-3-4.html
Normal file
138
skills_developing/z-card-image/assets/templates/article-3-4.html
Normal file
@ -0,0 +1,138 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<!--
|
||||
Template: article-3-4
|
||||
Ratio: 3:4 (900×1200)
|
||||
用途: 长文分页卡片,每张展示一页正文内容(支持完整 MD 语法)
|
||||
Parameters:
|
||||
{{TITLE}} — 文章标题
|
||||
{{CONTENT_HTML}} — 该页内容(markdown 转换后的 HTML)
|
||||
{{PAGE_LABEL}} — 页码,如 "1 / 4"
|
||||
{{BOTTOM_TIP}} — 底部提示,如 "← 滑动查看更多" 或 "· 全文完"
|
||||
{{HIGHLIGHT_COLOR}} — 品牌高亮色(默认 #3d6b4f)
|
||||
{{BG_COLOR}} — 背景色(默认 #f9fcfa)
|
||||
{{FOOTER_TEXT}} — 水印文字
|
||||
{{ICON_PATH}} — 顶部图标绝对路径
|
||||
{{AVATAR_PATH}} — 头像绝对路径
|
||||
{{FONT_PATH}} — 字体绝对路径
|
||||
{{MD_CSS_PATH}} — md.css 绝对路径(assets/styles/md.css)
|
||||
-->
|
||||
|
||||
<!-- 布局样式(框架,不含 MD 内容样式) -->
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: "AlimamaShuHeiTi";
|
||||
src: url("{{FONT_PATH}}") format("truetype");
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
width: 900px;
|
||||
height: 1200px;
|
||||
background-color: {{BG_COLOR}};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 60px 72px;
|
||||
font-family: "PingFang SC", "Noto Sans SC", "Hiragino Sans GB", sans-serif;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 顶部栏 */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 40px;
|
||||
flex-shrink: 0;
|
||||
gap: 16px;
|
||||
}
|
||||
.header img.logo { width: 44px; height: 44px; flex-shrink: 0; }
|
||||
.header .title {
|
||||
flex: 1;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
font-family: "AlimamaShuHeiTi", "PingFang SC", sans-serif;
|
||||
color: #888;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.header .page-label {
|
||||
font-size: 22px;
|
||||
color: #bbb;
|
||||
letter-spacing: 1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background: rgba(0,0,0,0.08);
|
||||
margin-bottom: 40px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
font-size: 30px;
|
||||
color: #2a2a2a;
|
||||
line-height: 1.85;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.footer .watermark {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.footer .watermark img { width: 28px; height: 28px; border-radius: 50%; object-fit: cover; }
|
||||
.footer .watermark span { font-size: 20px; color: #555; letter-spacing: 1px; }
|
||||
.footer .tip { font-size: 19px; color: #aaa; }
|
||||
</style>
|
||||
|
||||
<!-- MD 内容样式:用户可修改 assets/styles/md.css 自定义 -->
|
||||
<link rel="stylesheet" href="file://{{MD_CSS_PATH}}">
|
||||
|
||||
<!-- 品牌色覆盖(将 md.css 中的默认色替换为当前 {{HIGHLIGHT_COLOR}}) -->
|
||||
<style>
|
||||
.content strong, .content b { color: {{HIGHLIGHT_COLOR}}; }
|
||||
.content h2 { border-left-color: {{HIGHLIGHT_COLOR}}; color: {{HIGHLIGHT_COLOR}}; }
|
||||
.content h3 { color: {{HIGHLIGHT_COLOR}}; }
|
||||
.content ul li::marker, .content ol li::marker { color: {{HIGHLIGHT_COLOR}}; }
|
||||
.content blockquote { border-left-color: {{HIGHLIGHT_COLOR}}; }
|
||||
.content code { color: {{HIGHLIGHT_COLOR}}; background: color-mix(in srgb, {{HIGHLIGHT_COLOR}} 12%, white); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<img class="logo" src="{{ICON_PATH}}" alt="icon">
|
||||
<span class="title">{{TITLE}}</span>
|
||||
<span class="page-label">{{PAGE_LABEL}}</span>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="content">
|
||||
{{CONTENT_HTML}}
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<div class="watermark">
|
||||
<img src="{{AVATAR_PATH}}" alt="Jinx">
|
||||
<span>✦ {{FOOTER_TEXT}}</span>
|
||||
</div>
|
||||
<span class="tip">{{BOTTOM_TIP}}</span>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
102
skills_developing/z-card-image/assets/templates/poster-3-4.html
Normal file
102
skills_developing/z-card-image/assets/templates/poster-3-4.html
Normal file
@ -0,0 +1,102 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<!--
|
||||
Template: image-3-4
|
||||
Ratio: 3:4 (900×1200)
|
||||
用途: 3:4 比例通用封面配图
|
||||
Parameters (replaced via script before rendering):
|
||||
{{MAIN_TEXT_LINE1}} — 第一行文字
|
||||
{{MAIN_TEXT_LINE2}} — 第二行文字(可为空)
|
||||
{{MAIN_TEXT_LINE3}} — 第三行文字(可为空)
|
||||
{{LINE1_CLASS}} — "highlight" 或 ""
|
||||
{{LINE2_CLASS}} — "highlight" 或 ""
|
||||
{{LINE3_CLASS}} — "highlight" 或 ""
|
||||
{{HIGHLIGHT_COLOR}} — 高亮色 hex,默认 #22a854
|
||||
{{BG_COLOR}} — 背景色 hex,默认 #e6f5ef
|
||||
{{FOOTER_TEXT}} — 底部版权/来源文字
|
||||
{{ICON_PATH}} — 顶部图标绝对路径
|
||||
-->
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: "AlimamaShuHeiTi";
|
||||
src: url("{{FONT_PATH}}") format("truetype");
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
width: 900px;
|
||||
height: 1200px;
|
||||
background-color: {{BG_COLOR}};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 80px 90px;
|
||||
font-family: "AlimamaShuHeiTi", "PingFang SC", sans-serif;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.deco-top {
|
||||
margin-bottom: 80px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.deco-top img {
|
||||
width: 108px;
|
||||
height: 108px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.text-block { margin-top: 0; align-self: flex-start; }
|
||||
.line {
|
||||
font-size: 108px;
|
||||
font-weight: 900;
|
||||
line-height: 1.15;
|
||||
color: #1a1a1a;
|
||||
letter-spacing: -2px;
|
||||
}
|
||||
.line.highlight { color: {{HIGHLIGHT_COLOR}}; }
|
||||
.highlight { color: {{HIGHLIGHT_COLOR}}; }
|
||||
.watermark {
|
||||
margin-top: 80px;
|
||||
align-self: flex-start;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.watermark img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.watermark span {
|
||||
font-size: 24px;
|
||||
color: #555;
|
||||
font-family: "AlimamaShuHeiTi", "PingFang SC", sans-serif;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="deco-top">
|
||||
<img src="{{ICON_PATH}}" alt="icon">
|
||||
</div>
|
||||
<div class="text-block">
|
||||
<div class="line {{LINE1_CLASS}}" id="l1">{{MAIN_TEXT_LINE1}}</div>
|
||||
<div class="line {{LINE2_CLASS}}" id="l2">{{MAIN_TEXT_LINE2}}</div>
|
||||
<div class="line {{LINE3_CLASS}}" id="l3">{{MAIN_TEXT_LINE3}}</div>
|
||||
</div>
|
||||
<script>
|
||||
['l1','l2','l3'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el.textContent.trim()) el.style.display = 'none';
|
||||
});
|
||||
</script>
|
||||
<div class="watermark">
|
||||
<img src="{{AVATAR_PATH}}" alt="Jinx">
|
||||
<span>✦ {{FOOTER_TEXT}}</span>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,238 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<!--
|
||||
Template: tweet-thread
|
||||
Width: 900px
|
||||
Height: dynamic
|
||||
用途: 多条推特消息转成长图,模仿 Twitter/X 极简转发排版
|
||||
Parameters:
|
||||
{{BG_COLOR}} — 页面背景色
|
||||
{{CARD_BG}} — 卡片背景色
|
||||
{{TEXT_COLOR}} — 正文颜色
|
||||
{{MUTED_COLOR}} — 次级文字颜色
|
||||
{{BORDER_COLOR}} — 卡片分隔线颜色
|
||||
{{ACCENT_COLOR}} — 品牌强调色
|
||||
{{AUTHOR_NAME}} — 作者名
|
||||
{{AUTHOR_HANDLE}} — 作者 handle
|
||||
{{AUTHOR_AVATAR}} — 头像绝对路径
|
||||
{{HEADER_LABEL}} — 顶部说明
|
||||
{{FOOTER_TEXT}} — 底部来源
|
||||
{{FONT_PATH}} — 标题字体
|
||||
{{TWEET_ITEMS}} — 推文列表 HTML
|
||||
-->
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: "AlimamaShuHeiTi";
|
||||
src: url("{{FONT_PATH}}") format("truetype");
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
width: 900px;
|
||||
background: {{BG_COLOR}};
|
||||
color: {{TEXT_COLOR}};
|
||||
padding: 48px;
|
||||
font-family: "TwitterChirp", "SF Pro Display", "Helvetica Neue", "PingFang SC", sans-serif;
|
||||
}
|
||||
.sheet {
|
||||
width: 100%;
|
||||
background: {{CARD_BG}};
|
||||
border: 1px solid {{BORDER_COLOR}};
|
||||
border-radius: 36px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 28px 60px rgba(15, 20, 25, 0.08);
|
||||
}
|
||||
.hero {
|
||||
padding: 34px 36px 24px;
|
||||
border-bottom: 1px solid {{BORDER_COLOR}};
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(29, 155, 240, 0.09), transparent 34%),
|
||||
linear-gradient(180deg, rgba(255,255,255,0.95), rgba(255,255,255,1));
|
||||
}
|
||||
.hero-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 18px;
|
||||
color: {{MUTED_COLOR}};
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.hero-label .dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: {{ACCENT_COLOR}};
|
||||
}
|
||||
.hero-title {
|
||||
font-family: "AlimamaShuHeiTi", "SF Pro Display", sans-serif;
|
||||
font-size: 44px;
|
||||
line-height: 1.12;
|
||||
letter-spacing: -1px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.hero-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.hero-meta img {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
background: #eef3f4;
|
||||
}
|
||||
.hero-meta-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.hero-meta-text .name {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: {{TEXT_COLOR}};
|
||||
}
|
||||
.hero-meta-text .handle {
|
||||
font-size: 20px;
|
||||
color: {{MUTED_COLOR}};
|
||||
}
|
||||
.thread {
|
||||
padding: 0 36px;
|
||||
}
|
||||
.tweet {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
padding: 26px 0;
|
||||
border-bottom: 1px solid {{BORDER_COLOR}};
|
||||
}
|
||||
.tweet:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.avatar-wrap {
|
||||
width: 58px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.avatar-wrap img {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
background: #eef3f4;
|
||||
}
|
||||
.tweet-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.tweet-top {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.tweet-top .name {
|
||||
font-size: 23px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.tweet-top .handle,
|
||||
.tweet-top .meta {
|
||||
font-size: 20px;
|
||||
color: {{MUTED_COLOR}};
|
||||
}
|
||||
.tweet-text {
|
||||
font-size: 30px;
|
||||
line-height: 1.55;
|
||||
color: {{TEXT_COLOR}};
|
||||
word-break: break-word;
|
||||
white-space: normal;
|
||||
}
|
||||
.tweet-text p + p {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.tweet-text a {
|
||||
color: {{ACCENT_COLOR}};
|
||||
text-decoration: none;
|
||||
}
|
||||
.tweet-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
font-size: 18px;
|
||||
color: {{MUTED_COLOR}};
|
||||
}
|
||||
.tweet-index {
|
||||
color: {{ACCENT_COLOR}};
|
||||
font-weight: 700;
|
||||
}
|
||||
.tweet-stats {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
text-align: right;
|
||||
}
|
||||
.tweet-stats .stat strong {
|
||||
font-weight: 700;
|
||||
color: {{TEXT_COLOR}};
|
||||
margin-right: 4px;
|
||||
}
|
||||
.sheet-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 22px 36px 28px;
|
||||
border-top: 1px solid {{BORDER_COLOR}};
|
||||
color: {{MUTED_COLOR}};
|
||||
font-size: 18px;
|
||||
background: linear-gradient(180deg, rgba(248,250,251,0.86), rgba(255,255,255,1));
|
||||
}
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.brand-mark {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: {{ACCENT_COLOR}};
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<section class="sheet">
|
||||
<header class="hero">
|
||||
<div class="hero-label">
|
||||
<span class="dot"></span>
|
||||
<span>{{HEADER_LABEL}}</span>
|
||||
</div>
|
||||
<h1 class="hero-title">Tweet Thread</h1>
|
||||
<div class="hero-meta">
|
||||
<img src="{{AUTHOR_AVATAR}}" alt="avatar">
|
||||
<div class="hero-meta-text">
|
||||
<span class="name">{{AUTHOR_NAME}}</span>
|
||||
<span class="handle">{{AUTHOR_HANDLE}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="thread">
|
||||
{{TWEET_ITEMS}}
|
||||
</main>
|
||||
|
||||
<footer class="sheet-footer">
|
||||
<div class="brand">
|
||||
<span class="brand-mark"></span>
|
||||
<span>{{FOOTER_TEXT}}</span>
|
||||
</div>
|
||||
<span>Minimal Twitter-style export</span>
|
||||
</footer>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,155 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<!--
|
||||
Template: wechat-cover-split
|
||||
Ratio: 335:100 (left 2.35:1 + right 1:1)
|
||||
用途: 公众号文章封面长条图,左文案右 icon,可裁成两张
|
||||
Parameters:
|
||||
{{MAIN_TEXT_LINE1}} — 第一行文字
|
||||
{{MAIN_TEXT_LINE2}} — 第二行文字
|
||||
{{MAIN_TEXT_LINE3}} — 预留,不显示
|
||||
{{LINE1_CLASS}} — "highlight" 或 ""
|
||||
{{LINE2_CLASS}} — "highlight" 或 ""
|
||||
{{LINE3_CLASS}} — "highlight" 或 ""
|
||||
{{HIGHLIGHT_COLOR}} — 高亮色
|
||||
{{BG_COLOR}} — 背景色
|
||||
{{FOOTER_TEXT}} — 底部文字
|
||||
{{ICON_PATH}} — 右侧 icon 绝对路径
|
||||
{{FONT_PATH}} — 字体绝对路径
|
||||
-->
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: "AlimamaShuHeiTi";
|
||||
src: url("{{FONT_PATH}}") format("truetype");
|
||||
}
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: {{BG_COLOR}};
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
color: #111;
|
||||
font-family: "AlimamaShuHeiTi", "PingFang SC", sans-serif;
|
||||
}
|
||||
.canvas {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
background: {{BG_COLOR}};
|
||||
}
|
||||
.left {
|
||||
width: 940px;
|
||||
height: 400px;
|
||||
padding: 34px 48px 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(255,255,255,0.38), transparent 30%),
|
||||
linear-gradient(180deg, rgba(255,255,255,0.08), rgba(255,255,255,0)),
|
||||
{{BG_COLOR}};
|
||||
}
|
||||
.eyebrow {
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.5px;
|
||||
color: rgba(0,0,0,0.38);
|
||||
}
|
||||
.text-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-width: 820px;
|
||||
}
|
||||
.line {
|
||||
font-size: 72px;
|
||||
line-height: 1.02;
|
||||
letter-spacing: -1.5px;
|
||||
color: #111;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: clip;
|
||||
}
|
||||
.line.highlight { color: {{HIGHLIGHT_COLOR}}; }
|
||||
.highlight { color: {{HIGHLIGHT_COLOR}}; }
|
||||
.line.empty {
|
||||
display: none;
|
||||
}
|
||||
.footer {
|
||||
position: absolute;
|
||||
left: 48px;
|
||||
bottom: 24px;
|
||||
font-size: 16px;
|
||||
color: rgba(0,0,0,0.42);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.right {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
border-left: 1px solid rgba(0,0,0,0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255,255,255,0.34), rgba(255,255,255,0.06)),
|
||||
color-mix(in srgb, {{HIGHLIGHT_COLOR}} 7%, white);
|
||||
position: relative;
|
||||
}
|
||||
.icon-shell {
|
||||
width: 232px;
|
||||
height: 232px;
|
||||
border-radius: 56px;
|
||||
background: rgba(255,255,255,0.82);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 22px 50px rgba(0,0,0,0.08);
|
||||
padding: 24px;
|
||||
}
|
||||
.icon-shell img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: 28px;
|
||||
}
|
||||
.grid-mark {
|
||||
position: absolute;
|
||||
inset: 20px;
|
||||
border: 1px solid rgba(0,0,0,0.05);
|
||||
border-radius: 28px;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="canvas">
|
||||
<section class="left">
|
||||
<div class="eyebrow">{{FOOTER_TEXT}}</div>
|
||||
<div class="text-block">
|
||||
<div class="line {{LINE1_CLASS}}" id="l1">{{MAIN_TEXT_LINE1}}</div>
|
||||
<div class="line {{LINE2_CLASS}}" id="l2">{{MAIN_TEXT_LINE2}}</div>
|
||||
</div>
|
||||
<div class="footer"></div>
|
||||
</section>
|
||||
|
||||
<aside class="right">
|
||||
<div class="grid-mark"></div>
|
||||
<div class="icon-shell">
|
||||
<img src="{{ICON_PATH}}" alt="icon">
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
['l1', 'l2'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el.textContent.trim()) el.classList.add('empty');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,246 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<!--
|
||||
Template: x-like-posts
|
||||
Width: 900px
|
||||
Height: dynamic
|
||||
用途: 多条帖子转成长图,模仿 X / Twitter 极简分享排版
|
||||
Parameters:
|
||||
{{BG_COLOR}} — 页面背景色
|
||||
{{CARD_BG}} — 卡片背景色
|
||||
{{TEXT_COLOR}} — 正文颜色
|
||||
{{MUTED_COLOR}} — 次级文字颜色
|
||||
{{BORDER_COLOR}} — 卡片分隔线颜色
|
||||
{{ACCENT_COLOR}} — 品牌强调色
|
||||
{{AUTHOR_NAME}} — 作者名
|
||||
{{AUTHOR_HANDLE}} — 作者 handle
|
||||
{{AUTHOR_AVATAR}} — 头像绝对路径
|
||||
{{HEADER_LABEL}} — 顶部说明
|
||||
{{TIME_RANGE_LABEL}} — 顶部日期
|
||||
{{FOOTER_TEXT}} — 底部来源
|
||||
{{FONT_PATH}} — 标题字体
|
||||
{{TWEET_ITEMS}} — 帖子列表 HTML
|
||||
-->
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: "AlimamaShuHeiTi";
|
||||
src: url("{{FONT_PATH}}") format("truetype");
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
width: 900px;
|
||||
background: {{BG_COLOR}};
|
||||
color: {{TEXT_COLOR}};
|
||||
padding: 48px;
|
||||
font-family: "TwitterChirp", "SF Pro Display", "Helvetica Neue", "PingFang SC", sans-serif;
|
||||
}
|
||||
.sheet {
|
||||
width: 100%;
|
||||
background: {{CARD_BG}};
|
||||
border: 1px solid {{BORDER_COLOR}};
|
||||
border-radius: 36px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 28px 60px rgba(15, 20, 25, 0.08);
|
||||
}
|
||||
.hero {
|
||||
padding: 34px 36px 24px;
|
||||
border-bottom: 1px solid {{BORDER_COLOR}};
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(29, 155, 240, 0.09), transparent 34%),
|
||||
linear-gradient(180deg, rgba(255,255,255,0.95), rgba(255,255,255,1));
|
||||
}
|
||||
.hero-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 18px;
|
||||
color: {{MUTED_COLOR}};
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.hero-label .dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: {{ACCENT_COLOR}};
|
||||
}
|
||||
.hero-title {
|
||||
font-family: "AlimamaShuHeiTi", "SF Pro Display", sans-serif;
|
||||
font-size: 44px;
|
||||
line-height: 1.12;
|
||||
letter-spacing: -1px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.hero-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.hero-meta img {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
background: #eef3f4;
|
||||
}
|
||||
.hero-meta-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.hero-meta-text .name {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: {{TEXT_COLOR}};
|
||||
}
|
||||
.hero-meta-text .handle {
|
||||
font-size: 20px;
|
||||
color: {{MUTED_COLOR}};
|
||||
}
|
||||
.hero-time {
|
||||
margin-top: 14px;
|
||||
font-size: 18px;
|
||||
color: {{MUTED_COLOR}};
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.thread {
|
||||
padding: 0 36px;
|
||||
}
|
||||
.tweet {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
padding: 26px 0;
|
||||
border-bottom: 1px solid {{BORDER_COLOR}};
|
||||
}
|
||||
.tweet:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.avatar-wrap {
|
||||
width: 58px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.avatar-wrap img {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
background: #eef3f4;
|
||||
}
|
||||
.tweet-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.tweet-top {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.tweet-top .name {
|
||||
font-size: 23px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.tweet-top .handle,
|
||||
.tweet-top .meta {
|
||||
font-size: 20px;
|
||||
color: {{MUTED_COLOR}};
|
||||
}
|
||||
.tweet-text {
|
||||
font-size: 30px;
|
||||
line-height: 1.55;
|
||||
color: {{TEXT_COLOR}};
|
||||
word-break: break-word;
|
||||
white-space: normal;
|
||||
}
|
||||
.tweet-text p + p {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.tweet-text a {
|
||||
color: {{ACCENT_COLOR}};
|
||||
text-decoration: none;
|
||||
}
|
||||
.tweet-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
font-size: 18px;
|
||||
color: {{MUTED_COLOR}};
|
||||
}
|
||||
.tweet-index {
|
||||
color: {{ACCENT_COLOR}};
|
||||
font-weight: 700;
|
||||
}
|
||||
.tweet-stats {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
text-align: right;
|
||||
}
|
||||
.tweet-stats .stat strong {
|
||||
font-weight: 700;
|
||||
color: {{TEXT_COLOR}};
|
||||
margin-right: 4px;
|
||||
}
|
||||
.sheet-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 22px 36px 28px;
|
||||
border-top: 1px solid {{BORDER_COLOR}};
|
||||
color: {{MUTED_COLOR}};
|
||||
font-size: 18px;
|
||||
background: linear-gradient(180deg, rgba(248,250,251,0.86), rgba(255,255,255,1));
|
||||
}
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.brand-mark {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: {{ACCENT_COLOR}};
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<section class="sheet">
|
||||
<header class="hero">
|
||||
<div class="hero-label">
|
||||
<span class="dot"></span>
|
||||
<span>{{HEADER_LABEL}}</span>
|
||||
</div>
|
||||
<h1 class="hero-title">X-like Posts</h1>
|
||||
<div class="hero-meta">
|
||||
<img src="{{AUTHOR_AVATAR}}" alt="avatar">
|
||||
<div class="hero-meta-text">
|
||||
<span class="name">{{AUTHOR_NAME}}</span>
|
||||
<span class="handle">{{AUTHOR_HANDLE}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-time">{{TIME_RANGE_LABEL}}</div>
|
||||
</header>
|
||||
|
||||
<main class="thread">
|
||||
{{TWEET_ITEMS}}
|
||||
</main>
|
||||
|
||||
<footer class="sheet-footer">
|
||||
<div class="brand">
|
||||
<span class="brand-mark"></span>
|
||||
<span>{{FOOTER_TEXT}}</span>
|
||||
</div>
|
||||
<span>X-like share export</span>
|
||||
</footer>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
73
skills_developing/z-card-image/references/article-3-4.md
Normal file
73
skills_developing/z-card-image/references/article-3-4.md
Normal file
@ -0,0 +1,73 @@
|
||||
# article-3-4 模板规范
|
||||
|
||||
## 模板信息
|
||||
|
||||
- 比例:3:4
|
||||
- 尺寸:900 × 1200px
|
||||
- 用途:长文章分页卡片,适合公众号/小红书多图传播
|
||||
|
||||
## LLM 分段公式
|
||||
|
||||
使用此模板前,**LLM 需先自行计算分页**,不依赖脚本机械切分。
|
||||
|
||||
### 容量估算
|
||||
|
||||
```
|
||||
内容区有效高度 ≈ 1200 - 顶栏100 - 分割线50 - 底栏80 - 上下padding120 = 850px
|
||||
|
||||
字号:30px
|
||||
行高:1.85 → 每行实际高度 ≈ 30 × 1.85 = 55px
|
||||
内容区有效宽度 = 900 - 左右padding144 = 756px
|
||||
|
||||
每行可放中文字符 ≈ 756 / 30 = 25 个
|
||||
每页可放行数 ≈ 850 / 55 = 15 行
|
||||
每页中文字符容量 ≈ 25 × 15 = 375 字
|
||||
|
||||
英文字符宽度约为中文的 0.5,折算:2 英文字符 ≈ 1 中文字符
|
||||
段落间距约消耗 24px ≈ 0.5 行,每个段落边界扣 0.5 行
|
||||
```
|
||||
|
||||
### LLM 分段步骤
|
||||
|
||||
1. 统计全文总中文当量字符数(英文/数字按 0.5 折算)
|
||||
2. 计算估算页数 = ceil(总字符数 / 340)(预留 10% 余量给段落间距)
|
||||
3. 按**语义边界**切分,优先在段落结尾处分页,不截断句子
|
||||
4. 每页内容整理成纯文本(段落间空行分隔)
|
||||
5. 将各页文本按顺序传入脚本,脚本负责渲染
|
||||
|
||||
### 示例
|
||||
|
||||
全文 800 中文当量字符 → 估算 ceil(800/340) = 3 页 → 按语义切成 3 段
|
||||
|
||||
## 脚本参数
|
||||
|
||||
```bash
|
||||
python3 render_article.py \
|
||||
--title "文章标题" \
|
||||
--text "该页正文内容(纯文本,段落间空行分隔)" \
|
||||
--page-num 1 \
|
||||
--page-total 3 \
|
||||
--out /path/to/workspace/tmp/card_01.png \
|
||||
[--highlight "#22a854"] \
|
||||
[--bg "#e6f5ef"] \
|
||||
[--footer "公众号 · 早早集市"]
|
||||
```
|
||||
|
||||
> 注意:LLM 调用时每页单独调用一次脚本,传入该页文本和对应页码。
|
||||
|
||||
## 字数上限
|
||||
|
||||
每页建议不超过 340 中文当量字符(脚本实际执行时取 90% = 306 字作为安全上限,宁少勿多)。
|
||||
|
||||
脚本分段规则(无需 LLM 介入):
|
||||
1. 优先在段落边界处分页
|
||||
2. 段落过长时,在句子结束符(。!?…)处截断
|
||||
3. 找不到句末符,退而在逗号/分号处截
|
||||
4. 实在没有分隔符,截到安全上限的 85%
|
||||
|
||||
## 水印/底栏
|
||||
|
||||
- 默认:`公众号 · 早早集市`
|
||||
- 小红书:`小红书 · 阿康`
|
||||
- 非最后一页自动显示「← 滑动查看更多」
|
||||
- 最后一页显示「· 全文完」
|
||||
80
skills_developing/z-card-image/references/poster-3-4.md
Normal file
80
skills_developing/z-card-image/references/poster-3-4.md
Normal file
@ -0,0 +1,80 @@
|
||||
# image-3-4 模板规范
|
||||
|
||||
比例:3:4 | 尺寸:900×1200 | 用途:小绿书 / 公众号 / 小红书通用封面
|
||||
|
||||
## 渲染命令
|
||||
|
||||
```bash
|
||||
python3 skills/z-card-image/scripts/render_card.py \
|
||||
--template poster-3-4 \
|
||||
--out tmp/card.png \
|
||||
--line1 "OpenClaw" \
|
||||
--line2 "有两层" \
|
||||
--line3 "model 配置" \
|
||||
--highlight "#22a854" \
|
||||
--bg "#e6f5ef" \
|
||||
--footer "公众号 · 你的名字" \
|
||||
--icon {agent_dir}/dataset/icon.[png/jpg]
|
||||
```
|
||||
|
||||
## 参数说明
|
||||
|
||||
| 参数 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `--line1` | 空 | 第一行文字 |
|
||||
| `--line2` | 空 | 第二行文字(空则隐藏)|
|
||||
| `--line3` | 空 | 第三行文字(空则隐藏)|
|
||||
| `--hl1` | 关闭 | 整行高亮:第一行 |
|
||||
| `--hl2` | 关闭 | 整行高亮:第二行 |
|
||||
| `--hl3` | 关闭 | 整行高亮:第三行 |
|
||||
| `--highlight-words` | 空 | 按词高亮,逗号分隔,如 `OpenClaw,GPT-5.4`(跨行生效)|
|
||||
| `--highlight` | `#22a854` | 高亮/强调色 |
|
||||
| `--bg` | `#e6f5ef` | 背景色 |
|
||||
| `--footer` | `公众号 · 早早集市` | 底部文字 |
|
||||
| `--icon` | 自动判断 | 顶部图标路径,不传则按内容自动选 |
|
||||
|
||||
## icon
|
||||
|
||||
默认使用:
|
||||
|
||||
`{agent_dir}/dataset/icon.[png/jpg]`
|
||||
|
||||
显式传 `--icon {agent_dir}/dataset/icon.[png/jpg]` 。
|
||||
|
||||
## 高亮文字处理规则
|
||||
|
||||
**两种高亮方式,按需选择:**
|
||||
|
||||
### 1. 整行高亮(`--hl1` / `--hl2` / `--hl3`)
|
||||
整行文字渲染为高亮色。适合"某一行是关键句"的场景。
|
||||
|
||||
> 示例:第二行要高亮
|
||||
> → `--line1 "GPT-5.4 发布了" --line2 "能控电脑" --hl2`
|
||||
|
||||
### 2. 按词高亮(`--highlight-words`)
|
||||
在任意行中,将指定词语渲染为高亮色,其余文字保持黑色。适合"某几个关键词"的场景。
|
||||
|
||||
> 示例:高亮 OpenClaw 和 GPT-5.4
|
||||
> → `--line1 "GPT-5.4 最适合" --line2 "OpenClaw 使用" --highlight-words "GPT-5.4,OpenClaw"`
|
||||
|
||||
**优先级规则(有歧义时):**
|
||||
- 用户说「高亮第X行」→ 用 `--hlX`
|
||||
- 用户说「高亮某个词/某几个词」→ 用 `--highlight-words`
|
||||
- 两者可以同时使用
|
||||
- 若用户无明确要求,默认将最关键词/句用 `--highlight-words` 标出
|
||||
|
||||
|
||||
|
||||
| 位置 | 最多字数 | 说明 |
|
||||
|------|---------|------|
|
||||
| 每行(line1/2/3) | **6~7 个汉字** / **10~12 个英文字符** | 字号 108px,可用宽 720px |
|
||||
| 三行合计 | **≤ 20 字** | 超出则横向溢出,无法使用 |
|
||||
|
||||
> 用户文案超出时,先帮忙拆分/缩写到上限内,再渲染,不要直接塞入模板。
|
||||
|
||||
## 配图选取原则
|
||||
|
||||
| 条件 | 使用图标 | 文件 |
|
||||
|------|---------|------|
|
||||
| 任意行含 `openclaw`(不区分大小写) | OpenClaw 小龙虾 | `assets/icons/openclaw-logo.svg` |
|
||||
| 其他(默认) | 博客站 logo(灰色) | `assets/icons/zzclub-logo-gray.svg` |
|
||||
14
skills_developing/z-card-image/references/tweet-thread.md
Normal file
14
skills_developing/z-card-image/references/tweet-thread.md
Normal file
@ -0,0 +1,14 @@
|
||||
# tweet-thread 兼容说明
|
||||
|
||||
`tweet-thread` 已升级为更通用的 `x-like-posts` 路线。
|
||||
|
||||
请改读:
|
||||
|
||||
[references/x-like-posts.md](x-like-posts.md)
|
||||
|
||||
兼容规则:
|
||||
|
||||
- 新正式脚本是 `render_x_like_posts.py`
|
||||
- `render_tweet_thread.py` 仅保留兼容包装
|
||||
- 旧参数 `--tweet` / `--tweets-file` 仍可继续使用
|
||||
- 新推荐参数是 `--post` / `--posts-file`
|
||||
@ -0,0 +1,81 @@
|
||||
# wechat-cover-split 模板规范
|
||||
|
||||
比例:`335:100`(左 `2.35:1` + 右 `1:1`) | 尺寸:1340×400 | 用途:公众号文章封面图
|
||||
|
||||
> 该模板渲染时会额外调用 `ffmpeg` 做顶部精确裁切,以适配 Chrome 在短横幅截图下的视口偏差。
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 用户说"公众号文章封面图"
|
||||
- 用户说"微信公众号头图 / 封面长图"
|
||||
- 需要一张图同时切出左侧横图和右侧方图
|
||||
|
||||
## 切图规则
|
||||
|
||||
整张图由两部分组成:
|
||||
|
||||
- 左侧:`940×400`,比例 `2.35:1`,放标题文案
|
||||
- 右侧:`400×400`,比例 `1:1`,放 icon
|
||||
|
||||
如果业务端需要拆图使用:
|
||||
|
||||
- 左图:取左侧 `940×400`
|
||||
- 右图:取右侧 `400×400`
|
||||
|
||||
## 渲染命令
|
||||
|
||||
```bash
|
||||
python3 skills/z-card-image/scripts/render_card.py \
|
||||
--template wechat-cover-split \
|
||||
--out tmp/wechat-cover.png \
|
||||
--line1 "OpenAI 收购 Promptfoo" \
|
||||
--line2 "意味着什么" \
|
||||
--highlight "#22a854" \
|
||||
--bg "#eef7f2" \
|
||||
--footer "公众号 · 你的名字" \
|
||||
--icon {agent_dir}/dataset/icon.[png|jpg]
|
||||
```
|
||||
|
||||
## 参数说明
|
||||
|
||||
| 参数 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `--line1` | 空 | 第一行标题 |
|
||||
| `--line2` | 空 | 第二行标题 |
|
||||
| `--line3` | 空 | 预留;该模板会自动并入第二行,不单独显示 |
|
||||
| `--hl1/hl2/hl3` | 关闭 | 整行高亮 |
|
||||
| `--highlight-words` | 空 | 按词高亮 |
|
||||
| `--highlight` | `#22a854` | 强调色 |
|
||||
| `--bg` | `#e6f5ef` | 背景色 |
|
||||
| `--footer` | `公众号 · 你的名字` | 公众号名称 |
|
||||
| `--icon` | 默认 logo | 右侧 1:1 区域 icon |
|
||||
|
||||
## icon
|
||||
|
||||
默认使用:
|
||||
|
||||
`{agent_dir}/dataset/icon.[png/jpg]`
|
||||
|
||||
显式传 `--icon {agent_dir}/dataset/icon.[png/jpg]` 。
|
||||
|
||||
## 字数限制
|
||||
|
||||
这个模板本质上仍是"大字报"风格,文案不能长:
|
||||
|
||||
| 位置 | 建议字数 | 说明 |
|
||||
|------|---------|------|
|
||||
| 单行 | 12~18 个汉字 | 优先单行呈现 |
|
||||
| 总行数 | ≤ 2 行 | 超出会明显破坏长条图视觉 |
|
||||
| 总字数 | ≤ 28 个汉字 | 再长应先缩写或拆标题 |
|
||||
|
||||
优先做法:
|
||||
|
||||
1. 先抽标题主干
|
||||
2. 优先单行,必要时拆成 2 行
|
||||
3. 关键词可用整行或按词高亮
|
||||
|
||||
## 使用规则
|
||||
|
||||
- 用户明确说"公众号文章封面图"时,优先用本模板
|
||||
- 用户只是要常规封面 / 金句图,仍使用 `poster-3-4`
|
||||
- **强规则:公众号文章封面图必须至少有一个高亮词**(`--highlight-words` 或 `--hl1/hl2/hl3`),不可省略
|
||||
140
skills_developing/z-card-image/references/x-like-posts.md
Normal file
140
skills_developing/z-card-image/references/x-like-posts.md
Normal file
@ -0,0 +1,140 @@
|
||||
# x-like-posts 模板规范
|
||||
|
||||
比例:自适应长图 | 宽度:900px | 用途:将多条帖子整理成一张 X / Twitter 风格分享长图
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 用户要“X 风格分享图”
|
||||
- 用户给的是帖子类型数据,不一定来自 Twitter
|
||||
- 用户希望保留 X 的阅读感,但以通用帖子分享图方式导出
|
||||
|
||||
## 渲染命令
|
||||
|
||||
```bash
|
||||
python3 skills/z-card-image/scripts/render_x_like_posts.py \
|
||||
--author "OpenAI" \
|
||||
--handle "@OpenAI" \
|
||||
--post "第一条帖子内容" \
|
||||
--post "第二条帖子内容" \
|
||||
--out tmp/x-like-posts.png
|
||||
```
|
||||
|
||||
如帖子较多,优先写入 JSON 文件后传入:
|
||||
|
||||
```bash
|
||||
python3 skills/z-card-image/scripts/render_x_like_posts.py \
|
||||
--author "OpenAI" \
|
||||
--handle "@OpenAI" \
|
||||
--posts-file tmp/posts.json \
|
||||
--out /absolute/path/to/output/x-like-posts.png
|
||||
```
|
||||
|
||||
## JSON 输入格式
|
||||
|
||||
`--posts-file` 读取一个 JSON 数组,支持两种结构。
|
||||
|
||||
### 1. 字符串数组
|
||||
|
||||
最简格式,每个元素只提供正文:
|
||||
|
||||
```json
|
||||
[
|
||||
"post 1",
|
||||
"post 2"
|
||||
]
|
||||
```
|
||||
|
||||
此格式下模板只显示:
|
||||
|
||||
- 正文
|
||||
- 外层参数提供的作者名 / handle / avatar
|
||||
|
||||
### 2. 对象数组
|
||||
|
||||
推荐格式,每条帖子可带时间和互动信息:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"text": "post 1",
|
||||
"created_at": "2026-03-11T04:39:46.000Z",
|
||||
"url": "https://x.com/foo/status/1",
|
||||
"favorite_count": 31,
|
||||
"retweet_count": 4
|
||||
},
|
||||
{
|
||||
"text": "post 2"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
字段规则:
|
||||
|
||||
| 字段 | 必填 | 说明 | 当前是否显示 |
|
||||
|------|------|------|--------------|
|
||||
| `text` | 是 | 帖子正文 | 是 |
|
||||
| `created_at` | 否 | 帖子发布时间,建议 ISO 8601 | 是 |
|
||||
| `url` | 否 | 原帖链接 | 是(显示为 `x.com` 标记) |
|
||||
| `favorite_count` | 否 | 点赞数 | 是 |
|
||||
| `retweet_count` | 否 | 转发 / repost 数 | 是 |
|
||||
|
||||
### 当前模板显示逻辑
|
||||
|
||||
- `text`:显示为正文
|
||||
- `created_at`:显示在每条帖子底部,并用于顶部日期标签
|
||||
- `favorite_count`:大于 0 时显示
|
||||
- `retweet_count`:大于 0 时显示
|
||||
- `url`:存在时显示 `x.com` 来源标记
|
||||
- 作者名、handle、avatar:不从 JSON 内层读取,而是由外层参数 `--author`、`--handle`、`--avatar` 控制
|
||||
|
||||
### 兼容说明
|
||||
|
||||
- 旧参数 `--tweet` / `--tweets-file` 仍可用
|
||||
- 旧命名 `tweet-thread` 已并入 `x-like-posts`
|
||||
- 如果输入来自 `ingest-service`,优先使用它的 `created_at` 作为发布时间
|
||||
|
||||
## 参数说明
|
||||
|
||||
| 参数 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `--author` | `Unknown Author` | 作者名 |
|
||||
| `--handle` | `@twitter` | 作者 handle |
|
||||
| `--post` | 空 | 单条帖子内容,可重复传多次 |
|
||||
| `--posts-file` | 空 | 帖子 JSON 文件 |
|
||||
| `--out` | 必填 | 输出 PNG 路径,支持相对路径或绝对路径 |
|
||||
| `--header-label` | `X-like 帖子分享图` | 顶部说明文案 |
|
||||
| `--footer` | `整理转发 · via z-card-image` | 底部来源文案 |
|
||||
| `--bg` | `#f5f8fa` | 页面背景 |
|
||||
| `--card-bg` | `#ffffff` | 卡片背景 |
|
||||
| `--text` | `#0f1419` | 正文颜色 |
|
||||
| `--muted` | `#536471` | 次级文字颜色 |
|
||||
| `--border` | `#e6ecf0` | 分隔线颜色 |
|
||||
| `--accent` | `#1d9bf0` | 强调色 |
|
||||
| `--avatar` | 默认头像 | 作者头像 |
|
||||
|
||||
## 内容处理规则
|
||||
|
||||
1. 一条帖子对应一个卡片区块,按输入顺序排列
|
||||
2. 保留段落换行;空行只用于分段,不额外显示
|
||||
3. 多条帖子合成一张长图,画布高度按内容长度自动估算
|
||||
4. 若帖子过多导致总高度接近上限,优先按语义拆成多张图,不要强行塞满一张
|
||||
5. 时间信息默认取帖子 `created_at`;顶部只显示日期,每条帖子底部显示单条发布时间
|
||||
|
||||
## 时间规则
|
||||
|
||||
- 时间字段优先使用 `created_at`
|
||||
- 不使用 `first_seen_at` 作为新闻发布时间,它只是 ingest 入库时间
|
||||
- 当前模板按 `Asia/Shanghai (UTC+8)` 展示时间
|
||||
- 顶部只显示日期 `YYYY-MM-DD`
|
||||
- 每条帖子底部显示精确到分钟的发布时间
|
||||
|
||||
## 导出规则
|
||||
|
||||
- 支持导出到具体位置:`--out` 可传相对路径或绝对路径
|
||||
- 脚本会自动创建目标目录
|
||||
- 如果后续还要通过消息工具发图,输出仍建议放在当前 workspace 内,避免系统临时目录上传失败
|
||||
|
||||
## 使用规则
|
||||
|
||||
- 用户明确提到“X 风格分享图 / 帖子分享图 / 帖子长图”时,优先使用本模板
|
||||
- 用户只是要一句金句封面图时,仍使用 `poster-3-4`
|
||||
266
skills_developing/z-card-image/scripts/render_article.py
Normal file
266
skills_developing/z-card-image/scripts/render_article.py
Normal file
@ -0,0 +1,266 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
render_article.py — 将文本渲染成单张 article-3-4 卡片图
|
||||
|
||||
LLM 负责分页逻辑,每页单独调用本脚本。
|
||||
|
||||
用法(单页模式,推荐):
|
||||
python3 render_article.py \
|
||||
--title "文章标题" \
|
||||
--text "该页正文(段落间空行分隔)" \
|
||||
--page-num 1 \
|
||||
--page-total 3 \
|
||||
--out /path/to/workspace/tmp/card_01.png \
|
||||
[--highlight "#22a854"] \
|
||||
[--bg "#e6f5ef"] \
|
||||
[--footer "公众号 · 早早集市"]
|
||||
|
||||
用法(批量模式,兼容保留):
|
||||
python3 render_article.py \
|
||||
--title "文章标题" \
|
||||
--text "全文..." \
|
||||
--out-dir /path/to/output \
|
||||
[--chars-per-page 280]
|
||||
"""
|
||||
|
||||
import argparse, shutil, subprocess, sys, tempfile, re
|
||||
from html import escape
|
||||
from pathlib import Path
|
||||
|
||||
SKILL_DIR = Path(__file__).parent.parent
|
||||
TEMPLATE_PATH = SKILL_DIR / "assets" / "templates" / "article-3-4.html"
|
||||
MD_CSS_PATH = SKILL_DIR / "assets" / "styles" / "md.css"
|
||||
ICONS_DIR = SKILL_DIR / "assets" / "icons"
|
||||
FONTS_DIR = SKILL_DIR / "assets" / "fonts"
|
||||
|
||||
CHROME_PATHS = [
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
"google-chrome",
|
||||
"chromium",
|
||||
]
|
||||
|
||||
W, H = 900, 1200
|
||||
CHARS_PER_PAGE = 280
|
||||
|
||||
|
||||
def find_chrome():
|
||||
for p in CHROME_PATHS:
|
||||
if Path(p).exists() or shutil.which(p):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def split_at_sentence_boundary(text: str, limit: int) -> tuple:
|
||||
"""
|
||||
在 limit 字符以内,找最后一个句子结束符(。!?…)处截断。
|
||||
宁少勿多:找不到就在最后一个逗号/分号处截,再找不到就硬截到 limit*0.85。
|
||||
返回 (taken, rest)
|
||||
"""
|
||||
if len(text) <= limit:
|
||||
return text, ''
|
||||
|
||||
# 在 limit 范围内从后往前找强句末
|
||||
strong_ends = set('。!?…\n')
|
||||
weak_ends = set(',;,.;')
|
||||
|
||||
candidate = -1
|
||||
for i in range(min(limit, len(text)) - 1, max(limit // 2, 0) - 1, -1):
|
||||
if text[i] in strong_ends:
|
||||
candidate = i + 1
|
||||
break
|
||||
|
||||
if candidate == -1:
|
||||
# 找弱分隔符
|
||||
for i in range(min(limit, len(text)) - 1, max(limit // 2, 0) - 1, -1):
|
||||
if text[i] in weak_ends:
|
||||
candidate = i + 1
|
||||
break
|
||||
|
||||
if candidate == -1:
|
||||
# 实在没有,保守截到 85%
|
||||
candidate = int(limit * 0.85)
|
||||
|
||||
return text[:candidate].strip(), text[candidate:].strip()
|
||||
|
||||
|
||||
def split_text_into_pages(text: str, chars_per_page: int) -> list:
|
||||
"""
|
||||
按段落优先、句子边界兜底的分页逻辑。
|
||||
宁少勿多:每页预留 10% buffer,不塞满。
|
||||
"""
|
||||
safe_limit = int(chars_per_page * 0.9) # 保守上限
|
||||
paragraphs = [p.strip() for p in re.split(r'\n{2,}', text.strip()) if p.strip()]
|
||||
|
||||
pages = []
|
||||
current_chunks = []
|
||||
current_len = 0
|
||||
|
||||
for para in paragraphs:
|
||||
# 超长段落先按句子边界切碎
|
||||
while len(para) > safe_limit:
|
||||
taken, para = split_at_sentence_boundary(para, safe_limit)
|
||||
if current_chunks:
|
||||
pages.append(current_chunks)
|
||||
current_chunks = []
|
||||
current_len = 0
|
||||
pages.append([taken])
|
||||
|
||||
if not para:
|
||||
continue
|
||||
|
||||
# 加入当前页会不会超限
|
||||
if current_len + len(para) > safe_limit and current_chunks:
|
||||
pages.append(current_chunks)
|
||||
current_chunks = []
|
||||
current_len = 0
|
||||
|
||||
current_chunks.append(para)
|
||||
current_len += len(para)
|
||||
|
||||
if current_chunks:
|
||||
pages.append(current_chunks)
|
||||
|
||||
return pages
|
||||
|
||||
|
||||
def text_to_html(text: str) -> str:
|
||||
"""把文本整体交给 markdown 渲染,支持完整 MD 语法"""
|
||||
try:
|
||||
import markdown as md_lib
|
||||
except ImportError:
|
||||
sys.exit('需要安装 markdown 库:pip install markdown')
|
||||
return md_lib.markdown(text, extensions=['fenced_code', 'tables', 'nl2br'])
|
||||
|
||||
|
||||
def md_to_html(text: str) -> str:
|
||||
"""把 Markdown 转成 HTML 片段,需要 pip install markdown"""
|
||||
try:
|
||||
import markdown
|
||||
return markdown.markdown(text, extensions=['fenced_code', 'tables', 'nl2br'])
|
||||
except ImportError:
|
||||
sys.exit('需要安装 markdown 库:pip install markdown')
|
||||
|
||||
|
||||
def render_page(chrome, tpl, out_path, title, content_html, page_label, bottom_tip,
|
||||
highlight, bg, footer, icon_path, avatar_path, font_path, md_css_path=''):
|
||||
html = tpl
|
||||
replacements = {
|
||||
'{{MD_CSS_PATH}}': str(md_css_path) if md_css_path else '',
|
||||
'{{TITLE}}': escape(title),
|
||||
'{{CONTENT_HTML}}': content_html,
|
||||
'{{PAGE_LABEL}}': escape(page_label),
|
||||
'{{BOTTOM_TIP}}': escape(bottom_tip),
|
||||
'{{HIGHLIGHT_COLOR}}': highlight,
|
||||
'{{BG_COLOR}}': bg,
|
||||
'{{FOOTER_TEXT}}': escape(footer),
|
||||
'{{ICON_PATH}}': icon_path,
|
||||
'{{AVATAR_PATH}}': avatar_path,
|
||||
'{{FONT_PATH}}': font_path,
|
||||
}
|
||||
for k, v in replacements.items():
|
||||
html = html.replace(k, v)
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix='.html', delete=False, mode='w', encoding='utf-8') as f:
|
||||
f.write(html)
|
||||
tmp_html = f.name
|
||||
|
||||
cmd = [
|
||||
chrome, '--headless', '--disable-gpu', '--no-sandbox',
|
||||
f'--screenshot={out_path}',
|
||||
f'--window-size={W},{H}',
|
||||
f'file://{tmp_html}',
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True)
|
||||
Path(tmp_html).unlink(missing_ok=True)
|
||||
if result.returncode != 0:
|
||||
sys.exit(f'Chrome failed:\n{result.stderr.decode()}')
|
||||
print(f'✅ {out_path}')
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('--title', required=True)
|
||||
ap.add_argument('--text', default='')
|
||||
ap.add_argument('--text-file', default='')
|
||||
# 单页模式
|
||||
ap.add_argument('--page-num', type=int, default=0)
|
||||
ap.add_argument('--page-total', type=int, default=0)
|
||||
ap.add_argument('--out', default='')
|
||||
# 批量模式
|
||||
ap.add_argument('--out-dir', default='')
|
||||
ap.add_argument('--chars-per-page', type=int, default=CHARS_PER_PAGE)
|
||||
ap.add_argument('--md', action='store_true', help='输入为 Markdown,自动转 HTML 渲染')
|
||||
# 样式
|
||||
ap.add_argument('--highlight', default='#3d6b4f')
|
||||
ap.add_argument('--bg', default='#f9fcfa')
|
||||
ap.add_argument('--footer', default='公众号 · 早早集市')
|
||||
ap.add_argument('--icon', default='')
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.text_file:
|
||||
text = Path(args.text_file).read_text(encoding='utf-8')
|
||||
elif args.text:
|
||||
text = args.text
|
||||
else:
|
||||
sys.exit('需要 --text 或 --text-file')
|
||||
|
||||
chrome = find_chrome()
|
||||
if not chrome:
|
||||
sys.exit('Chrome/Chromium not found')
|
||||
|
||||
# MD 和纯文本共用同一模板,都走 markdown 渲染
|
||||
tpl = TEMPLATE_PATH.read_text(encoding='utf-8')
|
||||
icon_path = args.icon or str(ICONS_DIR / 'zzclub-logo-gray.svg')
|
||||
avatar_path = str(ICONS_DIR / 'avatar_jinx_cartoon.jpg')
|
||||
font_path = str(FONTS_DIR / 'AlimamaShuHeiTi-Bold.ttf')
|
||||
md_css_path = str(MD_CSS_PATH)
|
||||
|
||||
def to_content_html(t: str) -> str:
|
||||
return text_to_html(t) # text_to_html 已内置 markdown 渲染
|
||||
|
||||
# 单页模式
|
||||
if args.page_num > 0 and args.out:
|
||||
page_total = args.page_total if args.page_total > 0 else args.page_num
|
||||
page_label = f'{args.page_num} / {page_total}'
|
||||
bottom_tip = '· 全文完' if args.page_num == page_total else '← 滑动查看更多'
|
||||
render_page(
|
||||
chrome=chrome, tpl=tpl, out_path=Path(args.out),
|
||||
title=args.title, content_html=to_content_html(text),
|
||||
page_label=page_label, bottom_tip=bottom_tip,
|
||||
highlight=args.highlight, bg=args.bg, footer=args.footer,
|
||||
icon_path=icon_path, avatar_path=avatar_path, font_path=font_path,
|
||||
md_css_path=md_css_path,
|
||||
)
|
||||
return
|
||||
|
||||
# 批量模式
|
||||
if not args.out_dir:
|
||||
sys.exit('需要 --out (单页模式) 或 --out-dir (批量模式)')
|
||||
|
||||
pages = split_text_into_pages(text, args.chars_per_page)
|
||||
total = len(pages)
|
||||
print(f'共 {total} 页')
|
||||
out_dir = Path(args.out_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
for i, chunks in enumerate(pages, 1):
|
||||
parts = []
|
||||
for chunk in chunks:
|
||||
c = escape(chunk)
|
||||
c = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', c)
|
||||
parts.append(f'<p>{c}</p>')
|
||||
content_html = '\n'.join(parts)
|
||||
page_label = f'{i} / {total}'
|
||||
bottom_tip = '· 全文完' if i == total else '← 滑动查看更多'
|
||||
render_page(
|
||||
chrome=chrome, tpl=tpl, out_path=out_dir / f'card_{i:02d}.png',
|
||||
title=args.title, content_html=content_html,
|
||||
page_label=page_label, bottom_tip=bottom_tip,
|
||||
highlight=args.highlight, bg=args.bg, footer=args.footer,
|
||||
icon_path=icon_path, avatar_path=avatar_path, font_path=font_path,
|
||||
)
|
||||
print(f'\n🎉 完成,共输出 {total} 张图到 {out_dir}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
178
skills_developing/z-card-image/scripts/render_card.py
Normal file
178
skills_developing/z-card-image/scripts/render_card.py
Normal file
@ -0,0 +1,178 @@
|
||||
#!/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()
|
||||
@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
兼容入口:tweet-thread 已升级为 x-like-posts。
|
||||
请优先使用 render_x_like_posts.py。
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
|
||||
runpy.run_path(str(Path(__file__).with_name("render_x_like_posts.py")), run_name="__main__")
|
||||
295
skills_developing/z-card-image/scripts/render_x_like_posts.py
Normal file
295
skills_developing/z-card-image/scripts/render_x_like_posts.py
Normal file
@ -0,0 +1,295 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
render_x_like_posts.py — 将多条帖子渲染为一张 X-like 风格长图 PNG
|
||||
|
||||
推荐用法:
|
||||
python3 render_x_like_posts.py \
|
||||
--author "OpenAI" \
|
||||
--handle "@OpenAI" \
|
||||
--post "第一条帖子" \
|
||||
--post "第二条帖子" \
|
||||
--out /path/to/workspace/tmp/x-like-posts.png
|
||||
|
||||
也支持:
|
||||
--posts-file posts.json
|
||||
|
||||
posts.json 格式:
|
||||
["post one", "post two"]
|
||||
或
|
||||
[{"text": "post one"}, {"text": "post two"}]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from html import escape
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
SKILL_DIR = Path(__file__).parent.parent
|
||||
TEMPLATE_PATH = SKILL_DIR / "assets" / "templates" / "x-like-posts.html"
|
||||
ICONS_DIR = SKILL_DIR / "assets" / "icons"
|
||||
FONTS_DIR = SKILL_DIR / "assets" / "fonts"
|
||||
|
||||
CHROME_PATHS = [
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
"google-chrome",
|
||||
"chromium",
|
||||
]
|
||||
|
||||
WIDTH = 900
|
||||
MIN_HEIGHT = 1200
|
||||
MAX_HEIGHT = 16000
|
||||
TEXT_WIDTH_CHARS = 26
|
||||
DISPLAY_TZ = ZoneInfo("Asia/Shanghai")
|
||||
DISPLAY_TZ_LABEL = "UTC+8"
|
||||
|
||||
|
||||
def find_chrome():
|
||||
for path in CHROME_PATHS:
|
||||
if Path(path).exists() or shutil.which(path):
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def weighted_length(text: str) -> float:
|
||||
total = 0.0
|
||||
for ch in text:
|
||||
if ch == "\n":
|
||||
total += 8.0
|
||||
elif ord(ch) < 128:
|
||||
total += 0.55
|
||||
else:
|
||||
total += 1.0
|
||||
return total
|
||||
|
||||
|
||||
def estimate_post_height(text: str) -> int:
|
||||
paragraphs = [p for p in text.splitlines() if p.strip()] or [text]
|
||||
wrapped_lines = 0
|
||||
for paragraph in paragraphs:
|
||||
logical_lines = max(1, math.ceil(weighted_length(paragraph) / TEXT_WIDTH_CHARS))
|
||||
wrapped_lines += logical_lines
|
||||
wrapped_lines += max(0, len(paragraphs) - 1)
|
||||
return 120 + wrapped_lines * 48
|
||||
|
||||
|
||||
def estimate_canvas_height(posts: Iterable[str]) -> int:
|
||||
total = 210
|
||||
for post in posts:
|
||||
total += estimate_post_height(post) + 20
|
||||
return max(MIN_HEIGHT, min(MAX_HEIGHT, total + 120))
|
||||
|
||||
|
||||
def load_posts(args) -> list[dict]:
|
||||
posts = []
|
||||
raw_posts = list(args.post) + list(args.tweet)
|
||||
if raw_posts:
|
||||
posts.extend([{"text": text.strip()} for text in raw_posts if text.strip()])
|
||||
|
||||
input_file = args.posts_file or args.tweets_file
|
||||
if input_file:
|
||||
raw = Path(input_file).read_text(encoding="utf-8")
|
||||
parsed = json.loads(raw)
|
||||
if not isinstance(parsed, list):
|
||||
sys.exit("posts file must be a JSON array")
|
||||
for item in parsed:
|
||||
if isinstance(item, str) and item.strip():
|
||||
posts.append({"text": item.strip()})
|
||||
elif isinstance(item, dict) and str(item.get("text", "")).strip():
|
||||
posts.append(
|
||||
{
|
||||
"text": str(item["text"]).strip(),
|
||||
"created_at": str(item.get("created_at", "")).strip(),
|
||||
"url": str(item.get("url", "")).strip(),
|
||||
"favorite_count": item.get("favorite_count", 0),
|
||||
"retweet_count": item.get("retweet_count", 0),
|
||||
}
|
||||
)
|
||||
|
||||
if not posts:
|
||||
sys.exit("需要至少一条帖子:传 --post/--tweet 或 --posts-file/--tweets-file")
|
||||
|
||||
return posts
|
||||
|
||||
|
||||
def text_to_html(text: str) -> str:
|
||||
parts = []
|
||||
for block in text.splitlines():
|
||||
if not block.strip():
|
||||
continue
|
||||
parts.append(f"<p>{escape(block)}</p>")
|
||||
return "".join(parts) if parts else f"<p>{escape(text)}</p>"
|
||||
|
||||
|
||||
def parse_created_at(created_at: str):
|
||||
if not created_at:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(created_at.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def format_created_at(created_at: str) -> str:
|
||||
if not created_at:
|
||||
return ""
|
||||
dt = parse_created_at(created_at)
|
||||
if not dt:
|
||||
return created_at
|
||||
local_dt = dt.astimezone(DISPLAY_TZ)
|
||||
return local_dt.strftime(f"%Y-%m-%d %H:%M {DISPLAY_TZ_LABEL}")
|
||||
|
||||
|
||||
def build_date_label(posts: list[dict]) -> str:
|
||||
created_times = []
|
||||
for post in posts:
|
||||
dt = parse_created_at(str(post.get("created_at", "")).strip())
|
||||
if dt:
|
||||
created_times.append(dt.astimezone(DISPLAY_TZ))
|
||||
if not created_times:
|
||||
return "日期未知"
|
||||
|
||||
created_times.sort(reverse=True)
|
||||
return created_times[0].strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def to_int(value) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def build_stats(post: dict) -> str:
|
||||
parts = []
|
||||
created_at = format_created_at(str(post.get("created_at", "")).strip())
|
||||
if created_at:
|
||||
parts.append(f'<span class="stat">{escape(created_at)}</span>')
|
||||
|
||||
repost_count = to_int(post.get("retweet_count", 0))
|
||||
favorite_count = to_int(post.get("favorite_count", 0))
|
||||
if repost_count > 0:
|
||||
parts.append(f'<span class="stat"><strong>{repost_count}</strong> RT</span>')
|
||||
if favorite_count > 0:
|
||||
parts.append(f'<span class="stat"><strong>{favorite_count}</strong> Likes</span>')
|
||||
|
||||
if str(post.get("url", "")).strip():
|
||||
parts.append('<span class="stat">x.com</span>')
|
||||
|
||||
return "".join(parts) if parts else '<span class="stat">Forwarded from X</span>'
|
||||
|
||||
|
||||
def build_post_items(posts: list[dict], author: str, handle: str, avatar_path: str) -> str:
|
||||
total = len(posts)
|
||||
items = []
|
||||
for index, post in enumerate(posts, 1):
|
||||
text = str(post.get("text", "")).strip()
|
||||
items.append(
|
||||
f"""
|
||||
<article class="tweet">
|
||||
<div class="avatar-wrap">
|
||||
<img src="{avatar_path}" alt="avatar">
|
||||
</div>
|
||||
<div class="tweet-main">
|
||||
<div class="tweet-top">
|
||||
<span class="name">{escape(author)}</span>
|
||||
<span class="handle">{escape(handle)}</span>
|
||||
<span class="meta">· Post</span>
|
||||
</div>
|
||||
<div class="tweet-text">{text_to_html(text)}</div>
|
||||
<div class="tweet-footer">
|
||||
<span class="tweet-index">{index} / {total}</span>
|
||||
<span class="tweet-stats">{build_stats(post)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
""".strip()
|
||||
)
|
||||
return "\n".join(items)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--author", default="Unknown Author")
|
||||
ap.add_argument("--handle", default="@twitter")
|
||||
ap.add_argument("--post", action="append", default=[], help="单条帖子内容,可重复传入")
|
||||
ap.add_argument("--posts-file", default="", help="JSON 数组文件,元素为字符串或 {text}")
|
||||
ap.add_argument("--tweet", action="append", default=[], help="兼容旧参数:单条推文内容,可重复传入")
|
||||
ap.add_argument("--tweets-file", default="", help="兼容旧参数:JSON 数组文件,元素为字符串或 {text}")
|
||||
ap.add_argument("--header-label", default="X-like 帖子分享图")
|
||||
ap.add_argument("--footer", default="整理转发 · via z-card-image")
|
||||
ap.add_argument("--bg", default="#f5f8fa")
|
||||
ap.add_argument("--card-bg", default="#ffffff")
|
||||
ap.add_argument("--text", default="#0f1419")
|
||||
ap.add_argument("--muted", default="#536471")
|
||||
ap.add_argument("--border", default="#e6ecf0")
|
||||
ap.add_argument("--accent", default="#1d9bf0")
|
||||
ap.add_argument("--avatar", default="")
|
||||
ap.add_argument("--out", required=True)
|
||||
args = ap.parse_args()
|
||||
|
||||
chrome = find_chrome()
|
||||
if not chrome:
|
||||
sys.exit("Chrome/Chromium not found")
|
||||
|
||||
posts = load_posts(args)
|
||||
avatar_path = args.avatar or str(ICONS_DIR / "avatar_jinx_cartoon.jpg")
|
||||
font_path = str(FONTS_DIR / "AlimamaShuHeiTi-Bold.ttf")
|
||||
|
||||
template = TEMPLATE_PATH.read_text(encoding="utf-8")
|
||||
post_items = build_post_items(posts, args.author, args.handle, avatar_path)
|
||||
replacements = {
|
||||
"{{BG_COLOR}}": args.bg,
|
||||
"{{CARD_BG}}": args.card_bg,
|
||||
"{{TEXT_COLOR}}": args.text,
|
||||
"{{MUTED_COLOR}}": args.muted,
|
||||
"{{BORDER_COLOR}}": args.border,
|
||||
"{{ACCENT_COLOR}}": args.accent,
|
||||
"{{AUTHOR_NAME}}": escape(args.author),
|
||||
"{{AUTHOR_HANDLE}}": escape(args.handle),
|
||||
"{{AUTHOR_AVATAR}}": avatar_path,
|
||||
"{{HEADER_LABEL}}": escape(args.header_label),
|
||||
"{{TIME_RANGE_LABEL}}": escape(build_date_label(posts)),
|
||||
"{{FOOTER_TEXT}}": escape(args.footer),
|
||||
"{{FONT_PATH}}": font_path,
|
||||
"{{TWEET_ITEMS}}": post_items,
|
||||
}
|
||||
for key, value in replacements.items():
|
||||
template = template.replace(key, value)
|
||||
|
||||
height = estimate_canvas_height([str(post.get("text", "")) for post in posts])
|
||||
out = Path(args.out)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".html", delete=False, mode="w", encoding="utf-8") as handle:
|
||||
handle.write(template)
|
||||
tmp_html = handle.name
|
||||
|
||||
cmd = [
|
||||
chrome,
|
||||
"--headless",
|
||||
"--disable-gpu",
|
||||
"--no-sandbox",
|
||||
"--hide-scrollbars",
|
||||
f"--window-size={WIDTH},{height}",
|
||||
f"--screenshot={out}",
|
||||
f"file://{tmp_html}",
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True)
|
||||
Path(tmp_html).unlink(missing_ok=True)
|
||||
if result.returncode != 0:
|
||||
sys.exit(f"Chrome failed:\n{result.stderr.decode()}")
|
||||
|
||||
print(f"✅ Saved to {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Reference in New Issue
Block a user