Sources: extracted from two upstream archives (skill-repo, skills-main),
merged with the following policy:
- 15 broken symlinks (pointing to /Users/jameslee/.cc-switch/skills or
../../.agents/skills on a foreign machine) discarded
- 3 real name collisions with identical content (ai-pair, ifind-http-api,
zhipu-websearch) kept as one copy
- Functional overlaps deduped keeping the strongest variant:
- docx family: kept docx (official, full toolchain) + docx-cn
(GB/T 9704 Chinese official-document constants),
dropped docx_writer (no scripts, name collided with docx)
- humanizer family: kept humanizer-zh (6 zh reference docs),
dropped humanizer (en, redundant for CN workflow)
- Skills that only ran in a foreign environment removed:
ablemind-ops, app-publish, hlb-design-system, openclaw-adj-skill,
claude-driver
- alphapai excluded from this public repo because its SKILL.md hard-coded
live credentials
Result: 25 skills, 572 files, ~7.5 MB.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
548 lines
20 KiB
Python
548 lines
20 KiB
Python
"""可组合图表构件 — 每个 block 渲染到指定 (x, y, w, h) 区域
|
|
|
|
设计原则:
|
|
- 所有 block 函数签名: (slide, data, theme, x, y, w, h, **kwargs)
|
|
- 不涉及页面级元素(标题/页眉/页脚),只负责区域内渲染
|
|
- 可被 layout_diagram / layout_composite 组合到一页
|
|
- 也可被其他布局函数直接调用
|
|
"""
|
|
|
|
import math
|
|
from pptx.util import Inches, Pt, Emu
|
|
from pptx.enum.shapes import MSO_SHAPE
|
|
from .helpers import (add_text, add_rect, add_rounded_rect, add_shape,
|
|
_rgb, add_line)
|
|
|
|
|
|
# ── Block Registry ─────────────────────────────────────────────────────
|
|
|
|
BLOCK_REGISTRY = {}
|
|
|
|
|
|
def _register(name):
|
|
def decorator(fn):
|
|
BLOCK_REGISTRY[name] = fn
|
|
return fn
|
|
return decorator
|
|
|
|
|
|
def render_block(slide, block_spec, theme):
|
|
"""根据 block_spec 分派渲染
|
|
|
|
block_spec: {
|
|
"type": str, # BLOCK_REGISTRY 中的名称
|
|
"area": [x, y, w, h], # 渲染区域(英寸)
|
|
... # 其余字段透传给 block 函数
|
|
}
|
|
"""
|
|
block_type = block_spec["type"]
|
|
if block_type not in BLOCK_REGISTRY:
|
|
raise ValueError(f"未知 block 类型: {block_type}。可用: {list(BLOCK_REGISTRY.keys())}")
|
|
|
|
area = block_spec.get("area")
|
|
if not area or len(area) != 4:
|
|
raise ValueError(f"block 需要 area=[x, y, w, h],收到: {area}")
|
|
|
|
fn = BLOCK_REGISTRY[block_type]
|
|
x, y, w, h = area
|
|
# 透传除 type/area 外的所有字段
|
|
data = {k: v for k, v in block_spec.items() if k not in ("type", "area")}
|
|
fn(slide, data, theme, x, y, w, h)
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# Block 1: 流程图 (Process Flow)
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
@_register("process_flow")
|
|
def block_process_flow(slide, data, theme, x, y, w, h, **kwargs):
|
|
"""流程图 — 步骤框 + 箭头连接
|
|
|
|
data:
|
|
items: [{"label": str, "desc": str?, "icon": str?}]
|
|
direction: "horizontal" | "vertical" (默认 horizontal)
|
|
style: "box" | "chevron" (默认 box)
|
|
"""
|
|
items = data["items"]
|
|
n = len(items)
|
|
if n == 0:
|
|
return
|
|
|
|
direction = data.get("direction", "horizontal")
|
|
|
|
if direction == "horizontal":
|
|
_flow_horizontal(slide, items, theme, x, y, w, h, data.get("style", "box"))
|
|
else:
|
|
_flow_vertical(slide, items, theme, x, y, w, h, data.get("style", "box"))
|
|
|
|
|
|
def _flow_horizontal(slide, items, theme, x, y, w, h, style):
|
|
n = len(items)
|
|
arrow_w = 0.35
|
|
total_arrows = arrow_w * (n - 1)
|
|
box_w = (w - total_arrows) / n
|
|
box_h = h
|
|
|
|
for i, item in enumerate(items):
|
|
bx = x + i * (box_w + arrow_w)
|
|
color = item.get("color", theme["primary"])
|
|
|
|
if style == "chevron":
|
|
shape = add_shape(slide, MSO_SHAPE.CHEVRON,
|
|
bx, y, box_w, box_h, fill=color)
|
|
else:
|
|
add_rounded_rect(slide, bx, y, box_w, box_h, fill=color)
|
|
|
|
# 图标/序号
|
|
icon = item.get("icon", str(i + 1))
|
|
add_text(slide, icon,
|
|
x=bx, y=y + 0.15, w=box_w, h=0.45,
|
|
font_size=20, color="FFFFFF",
|
|
bold=True, align="center",
|
|
font_name=theme["header_font"])
|
|
|
|
# 标签
|
|
add_text(slide, item["label"],
|
|
x=bx + 0.15, y=y + 0.65, w=box_w - 0.3, h=0.45,
|
|
font_size=theme["body_size"] + 1, color="FFFFFF",
|
|
bold=True, align="center",
|
|
font_name=theme["header_font"])
|
|
|
|
# 描述
|
|
if item.get("desc"):
|
|
add_text(slide, item["desc"],
|
|
x=bx + 0.15, y=y + 1.10, w=box_w - 0.3, h=box_h - 1.25,
|
|
font_size=theme["body_size"] - 1, color="FFFFFF",
|
|
align="center",
|
|
font_name=theme["body_font"])
|
|
|
|
# 箭头
|
|
if i < n - 1:
|
|
ax = bx + box_w + 0.02
|
|
ay = y + box_h / 2 - 0.20
|
|
add_shape(slide, MSO_SHAPE.CHEVRON,
|
|
ax, ay, arrow_w - 0.04, 0.40,
|
|
fill=theme.get("text_muted", "9E9E9E"))
|
|
|
|
|
|
def _flow_vertical(slide, items, theme, x, y, w, h, style):
|
|
n = len(items)
|
|
arrow_h = 0.30
|
|
total_arrows = arrow_h * (n - 1)
|
|
box_h = (h - total_arrows) / n
|
|
box_w = w
|
|
|
|
for i, item in enumerate(items):
|
|
by = y + i * (box_h + arrow_h)
|
|
color = item.get("color", theme["primary"])
|
|
|
|
add_rounded_rect(slide, x, by, box_w, box_h, fill=color)
|
|
|
|
icon = item.get("icon", str(i + 1))
|
|
# 左侧序号圆
|
|
add_shape(slide, MSO_SHAPE.OVAL,
|
|
x + 0.20, by + (box_h - 0.50) / 2, 0.50, 0.50,
|
|
fill=theme["accent"])
|
|
add_text(slide, icon,
|
|
x=x + 0.20, y=by + (box_h - 0.50) / 2, w=0.50, h=0.50,
|
|
font_size=14, color="FFFFFF",
|
|
bold=True, align="center", valign="middle",
|
|
font_name=theme["header_font"])
|
|
|
|
# 标签 + 描述
|
|
tx = x + 0.90
|
|
tw = box_w - 1.10
|
|
add_text(slide, item["label"],
|
|
x=tx, y=by + 0.10, w=tw, h=0.35,
|
|
font_size=theme["body_size"] + 1, color="FFFFFF",
|
|
bold=True, font_name=theme["header_font"])
|
|
if item.get("desc"):
|
|
add_text(slide, item["desc"],
|
|
x=tx, y=by + 0.45, w=tw, h=box_h - 0.55,
|
|
font_size=theme["body_size"] - 1, color="FFFFFF",
|
|
font_name=theme["body_font"])
|
|
|
|
# 向下箭头
|
|
if i < n - 1:
|
|
ax = x + box_w / 2 - 0.15
|
|
ay = by + box_h + 0.02
|
|
add_shape(slide, MSO_SHAPE.DOWN_ARROW,
|
|
ax, ay, 0.30, arrow_h - 0.04,
|
|
fill=theme.get("text_muted", "9E9E9E"))
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# Block 2: 时间线 (Timeline)
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
@_register("timeline")
|
|
def block_timeline(slide, data, theme, x, y, w, h, **kwargs):
|
|
"""时间线 — 水平轴 + 上下交替的里程碑
|
|
|
|
data:
|
|
items: [{"date": str, "label": str, "desc": str?}]
|
|
"""
|
|
items = data["items"]
|
|
n = len(items)
|
|
if n == 0:
|
|
return
|
|
|
|
# 时间轴位于区域垂直中央
|
|
axis_y = y + h * 0.45
|
|
axis_thickness = 0.03
|
|
|
|
# 主轴线
|
|
add_rect(slide, x, axis_y, w, axis_thickness, fill=theme["primary"])
|
|
|
|
# 节点间距
|
|
margin = 0.4 # 左右留白
|
|
span = w - 2 * margin
|
|
gap = span / max(n - 1, 1)
|
|
|
|
for i, item in enumerate(items):
|
|
cx = x + margin + i * gap # 节点圆心 x
|
|
is_above = (i % 2 == 0) # 上下交替
|
|
|
|
# 节点圆
|
|
dot_r = 0.15
|
|
add_shape(slide, MSO_SHAPE.OVAL,
|
|
cx - dot_r, axis_y - dot_r + axis_thickness / 2,
|
|
dot_r * 2, dot_r * 2,
|
|
fill=theme["accent"])
|
|
|
|
# 竖线
|
|
line_h = h * 0.22
|
|
if is_above:
|
|
line_top = axis_y - line_h - dot_r
|
|
add_rect(slide, cx - 0.008, line_top, 0.016, line_h,
|
|
fill=theme["border"])
|
|
else:
|
|
line_top = axis_y + axis_thickness + dot_r
|
|
add_rect(slide, cx - 0.008, line_top, 0.016, line_h,
|
|
fill=theme["border"])
|
|
|
|
# 文字
|
|
text_w = gap * 0.9 if gap > 0.5 else 1.5
|
|
text_x = cx - text_w / 2
|
|
|
|
if is_above:
|
|
# 日期在最上面
|
|
add_text(slide, item["date"],
|
|
x=text_x, y=y, w=text_w, h=0.35,
|
|
font_size=theme["body_size"] - 1, color=theme["accent"],
|
|
bold=True, align="center",
|
|
font_name=theme["header_font"])
|
|
# 标签
|
|
label_y = y + 0.30
|
|
add_text(slide, item["label"],
|
|
x=text_x, y=label_y, w=text_w, h=0.35,
|
|
font_size=theme["body_size"], color=theme["text_dark"],
|
|
bold=True, align="center",
|
|
font_name=theme["header_font"])
|
|
if item.get("desc"):
|
|
add_text(slide, item["desc"],
|
|
x=text_x, y=label_y + 0.32, w=text_w, h=0.50,
|
|
font_size=theme["body_size"] - 2,
|
|
color=theme["text_muted"],
|
|
align="center",
|
|
font_name=theme["body_font"])
|
|
else:
|
|
# 标签在轴下方
|
|
label_y = line_top + line_h + 0.05
|
|
add_text(slide, item["date"],
|
|
x=text_x, y=label_y, w=text_w, h=0.35,
|
|
font_size=theme["body_size"] - 1, color=theme["accent"],
|
|
bold=True, align="center",
|
|
font_name=theme["header_font"])
|
|
add_text(slide, item["label"],
|
|
x=text_x, y=label_y + 0.30, w=text_w, h=0.35,
|
|
font_size=theme["body_size"], color=theme["text_dark"],
|
|
bold=True, align="center",
|
|
font_name=theme["header_font"])
|
|
if item.get("desc"):
|
|
add_text(slide, item["desc"],
|
|
x=text_x, y=label_y + 0.62, w=text_w, h=0.50,
|
|
font_size=theme["body_size"] - 2,
|
|
color=theme["text_muted"],
|
|
align="center",
|
|
font_name=theme["body_font"])
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# Block 3: 金字塔 (Pyramid)
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
@_register("pyramid")
|
|
def block_pyramid(slide, data, theme, x, y, w, h, **kwargs):
|
|
"""金字塔 — 从上到下逐层加宽的层叠结构
|
|
|
|
data:
|
|
items: [{"label": str, "desc": str?, "color": str?}]
|
|
(从塔尖到塔底排列)
|
|
inverted: bool (默认 False; True 则为漏斗,上宽下窄)
|
|
"""
|
|
items = data["items"]
|
|
n = len(items)
|
|
if n == 0:
|
|
return
|
|
|
|
inverted = data.get("inverted", False)
|
|
gap = 0.06 # 层间间隙
|
|
layer_h = (h - gap * (n - 1)) / n
|
|
min_w = w * 0.25 # 最窄层宽度
|
|
|
|
for i, item in enumerate(items):
|
|
# 宽度从窄到宽(金字塔)或从宽到窄(漏斗)
|
|
if inverted:
|
|
ratio = 1 - (i / max(n - 1, 1)) * (1 - min_w / w)
|
|
else:
|
|
ratio = min_w / w + (i / max(n - 1, 1)) * (1 - min_w / w)
|
|
|
|
lw = w * ratio
|
|
lx = x + (w - lw) / 2
|
|
ly = y + i * (layer_h + gap)
|
|
|
|
# 颜色: 用户自定义 > 自动渐变
|
|
if item.get("color"):
|
|
color = item["color"]
|
|
else:
|
|
# 从 primary 到 accent 的渐变效果:奇偶交替
|
|
color = theme["primary"] if i % 2 == 0 else theme.get("secondary", theme["accent"])
|
|
|
|
add_rounded_rect(slide, lx, ly, lw, layer_h, fill=color)
|
|
|
|
# 标签
|
|
add_text(slide, item["label"],
|
|
x=lx + 0.15, y=ly + 0.05, w=lw - 0.3, h=layer_h * 0.5,
|
|
font_size=theme["body_size"] + 1, color="FFFFFF",
|
|
bold=True, align="center", valign="middle",
|
|
font_name=theme["header_font"])
|
|
|
|
# 描述
|
|
if item.get("desc"):
|
|
add_text(slide, item["desc"],
|
|
x=lx + 0.15, y=ly + layer_h * 0.45, w=lw - 0.3,
|
|
h=layer_h * 0.50,
|
|
font_size=theme["body_size"] - 1, color="FFFFFF",
|
|
align="center", valign="top",
|
|
font_name=theme["body_font"])
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# Block 4: 对比栏 (Comparison)
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
@_register("comparison")
|
|
def block_comparison(slide, data, theme, x, y, w, h, **kwargs):
|
|
"""对比栏 — 2~4 列并排对比
|
|
|
|
data:
|
|
columns: [{
|
|
"title": str,
|
|
"items": [str],
|
|
"color": str?, # 标题栏颜色
|
|
"highlight": bool? # 高亮列(推荐方案)
|
|
}]
|
|
"""
|
|
columns = data["columns"]
|
|
n = len(columns)
|
|
if n == 0:
|
|
return
|
|
|
|
col_gap = 0.20
|
|
col_w = (w - col_gap * (n - 1)) / n
|
|
header_h = 0.65
|
|
|
|
default_colors = [theme["primary"], theme.get("secondary", theme["accent"]),
|
|
theme["accent"], theme.get("text_muted", "9E9E9E")]
|
|
|
|
for i, col in enumerate(columns):
|
|
cx = x + i * (col_w + col_gap)
|
|
is_highlight = col.get("highlight", False)
|
|
col_color = col.get("color", default_colors[i % len(default_colors)])
|
|
|
|
# 标题栏
|
|
add_rounded_rect(slide, cx, y, col_w, header_h, fill=col_color)
|
|
add_text(slide, col["title"],
|
|
x=cx, y=y, w=col_w, h=header_h,
|
|
font_size=theme["body_size"] + 2, color="FFFFFF",
|
|
bold=True, align="center", valign="middle",
|
|
font_name=theme["header_font"])
|
|
|
|
# 内容区背景
|
|
content_y = y + header_h + 0.05
|
|
content_h = h - header_h - 0.05
|
|
bg = "F0F4F8" if is_highlight else theme.get("table_zebra_even", "F8FAFC")
|
|
add_rect(slide, cx, content_y, col_w, content_h, fill=bg)
|
|
|
|
# 高亮边框
|
|
if is_highlight:
|
|
border_w = 0.03
|
|
add_rect(slide, cx, content_y, border_w, content_h, fill=col_color)
|
|
add_rect(slide, cx + col_w - border_w, content_y, border_w, content_h,
|
|
fill=col_color)
|
|
|
|
# 列表项
|
|
items = col.get("items", [])
|
|
item_y = content_y + 0.15
|
|
for text in items:
|
|
# bullet 小方块
|
|
add_rect(slide, cx + 0.20, item_y + 0.12, 0.07, 0.07,
|
|
fill=col_color)
|
|
add_text(slide, text,
|
|
x=cx + 0.38, y=item_y, w=col_w - 0.55, h=0.35,
|
|
font_size=theme["body_size"], color=theme["text_dark"],
|
|
font_name=theme["body_font"])
|
|
item_y += 0.40
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# Block 5: 图标网格 (Icon Grid)
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
@_register("icon_grid")
|
|
def block_icon_grid(slide, data, theme, x, y, w, h, **kwargs):
|
|
"""图标网格 — 网格排列的图标+文字卡片
|
|
|
|
data:
|
|
items: [{"icon": str, "label": str, "desc": str?, "color": str?}]
|
|
cols: int (默认自动: <=4 用 2 列, <=6 用 3 列, >6 用 4 列)
|
|
"""
|
|
items = data["items"]
|
|
n = len(items)
|
|
if n == 0:
|
|
return
|
|
|
|
# 自动列数
|
|
cols = data.get("cols")
|
|
if cols is None:
|
|
if n <= 4:
|
|
cols = 2
|
|
elif n <= 6:
|
|
cols = 3
|
|
else:
|
|
cols = 4
|
|
|
|
rows = math.ceil(n / cols)
|
|
gap_x = 0.20
|
|
gap_y = 0.15
|
|
card_w = (w - gap_x * (cols - 1)) / cols
|
|
card_h = (h - gap_y * (rows - 1)) / rows
|
|
|
|
icon_size = 0.55
|
|
|
|
for i, item in enumerate(items):
|
|
col = i % cols
|
|
row = i // cols
|
|
cx = x + col * (card_w + gap_x)
|
|
cy = y + row * (card_h + gap_y)
|
|
|
|
icon_color = item.get("color", theme["primary"])
|
|
|
|
# 卡片背景(极浅)
|
|
add_rect(slide, cx, cy, card_w, card_h,
|
|
fill=theme.get("table_zebra_even", "F8FAFC"))
|
|
|
|
# 左侧色条
|
|
add_rect(slide, cx, cy, 0.04, card_h, fill=icon_color)
|
|
|
|
# 图标圆
|
|
add_shape(slide, MSO_SHAPE.OVAL,
|
|
cx + 0.20, cy + 0.20, icon_size, icon_size,
|
|
fill=icon_color)
|
|
add_text(slide, item["icon"],
|
|
x=cx + 0.20, y=cy + 0.20, w=icon_size, h=icon_size,
|
|
font_size=14, color="FFFFFF",
|
|
bold=True, align="center", valign="middle",
|
|
font_name=theme["header_font"])
|
|
|
|
# 标签
|
|
tx = cx + 0.20 + icon_size + 0.15
|
|
tw = card_w - icon_size - 0.55
|
|
add_text(slide, item["label"],
|
|
x=tx, y=cy + 0.15, w=tw, h=0.35,
|
|
font_size=theme["body_size"] + 1, color=theme["text_dark"],
|
|
bold=True, font_name=theme["header_font"])
|
|
|
|
# 描述
|
|
if item.get("desc"):
|
|
add_text(slide, item["desc"],
|
|
x=tx, y=cy + 0.50, w=tw, h=card_h - 0.65,
|
|
font_size=theme["body_size"] - 1,
|
|
color=theme["text_muted"],
|
|
font_name=theme["body_font"])
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# Block 6: 矩阵/象限 (Matrix / Quadrant)
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
@_register("matrix")
|
|
def block_matrix(slide, data, theme, x, y, w, h, **kwargs):
|
|
"""2x2 矩阵/象限图
|
|
|
|
data:
|
|
quadrants: [
|
|
{"label": str, "desc": str?, "color": str?}, # 左上
|
|
{"label": str, "desc": str?, "color": str?}, # 右上
|
|
{"label": str, "desc": str?, "color": str?}, # 左下
|
|
{"label": str, "desc": str?, "color": str?}, # 右下
|
|
]
|
|
x_label: str? # X 轴标签
|
|
y_label: str? # Y 轴标签
|
|
"""
|
|
quads = data["quadrants"]
|
|
if len(quads) < 4:
|
|
quads = quads + [{"label": ""}] * (4 - len(quads))
|
|
|
|
gap = 0.08
|
|
qw = (w - gap) / 2
|
|
qh = (h - gap) / 2
|
|
|
|
default_colors = [
|
|
theme["primary"],
|
|
theme.get("secondary", theme["accent"]),
|
|
theme["accent"],
|
|
theme.get("text_muted", "9E9E9E"),
|
|
]
|
|
|
|
positions = [
|
|
(x, y), # 左上
|
|
(x + qw + gap, y), # 右上
|
|
(x, y + qh + gap), # 左下
|
|
(x + qw + gap, y + qh + gap), # 右下
|
|
]
|
|
|
|
for i, (qx, qy) in enumerate(positions):
|
|
q = quads[i]
|
|
color = q.get("color", default_colors[i])
|
|
add_rounded_rect(slide, qx, qy, qw, qh, fill=color)
|
|
|
|
add_text(slide, q["label"],
|
|
x=qx + 0.15, y=qy + 0.15, w=qw - 0.3, h=0.45,
|
|
font_size=theme["body_size"] + 2, color="FFFFFF",
|
|
bold=True, align="center",
|
|
font_name=theme["header_font"])
|
|
|
|
if q.get("desc"):
|
|
add_text(slide, q["desc"],
|
|
x=qx + 0.15, y=qy + 0.60, w=qw - 0.3, h=qh - 0.80,
|
|
font_size=theme["body_size"] - 1, color="FFFFFF",
|
|
align="center",
|
|
font_name=theme["body_font"])
|
|
|
|
# 轴标签
|
|
if data.get("x_label"):
|
|
add_text(slide, data["x_label"],
|
|
x=x, y=y + h + 0.02, w=w, h=0.30,
|
|
font_size=theme["body_size"] - 1, color=theme["text_muted"],
|
|
align="center", font_name=theme["body_font"])
|
|
if data.get("y_label"):
|
|
# Y 轴标签放在左侧
|
|
add_text(slide, data["y_label"],
|
|
x=x - 0.50, y=y + h * 0.35, w=0.45, h=0.40,
|
|
font_size=theme["body_size"] - 1, color=theme["text_muted"],
|
|
align="center", font_name=theme["body_font"])
|
|
|
|
|