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>
299 lines
12 KiB
Python
299 lines
12 KiB
Python
"""DeckLinter — 生成 PPT 后自动审计,检查 JP 规范合规性
|
||
|
||
检查项:
|
||
ERROR: Header 存在性 (非封面/结论页 y < 0.5")
|
||
ERROR: Footer 存在性 (非封面/结论页 y > 7.0")
|
||
WARN: 标题字号越界 (内容页标题 > 18pt)
|
||
WARN: 图表颜色数 (单图表系列色 > 3)
|
||
ERROR: 元素越界 (right > 13.0" 或 bottom > 7.5")
|
||
WARN: 对齐偏差 (同页多图 top 偏差 > 0.05")
|
||
|
||
用法:
|
||
linter = DeckLinter("output.pptx")
|
||
report = linter.run()
|
||
linter.print_summary(report)
|
||
"""
|
||
|
||
import json
|
||
import sys
|
||
from pathlib import Path
|
||
from pptx import Presentation
|
||
from pptx.util import Inches, Emu
|
||
|
||
# EMU (English Metric Units) 到 inches 的转换因子
|
||
EMU_PER_INCH = 914400
|
||
|
||
# 无需 header/footer 的页面类型关键词
|
||
_SKIP_KEYWORDS = {
|
||
"封面", "cover", "title", "结论", "conclusion", "divider", "分隔",
|
||
"总结", "summary", "appendix", "附录", "要点总结", "investment summary",
|
||
}
|
||
|
||
|
||
class DeckLinter:
|
||
"""PPT 合规性检查器"""
|
||
|
||
# 静态阈值(用于字号/对齐等不依赖尺寸的规则)
|
||
HEADER_Y_MAX = 0.6 # header textbox 的 y 上限
|
||
FOOTER_Y_MIN = 6.8 # footer textbox 的 y 下限
|
||
TITLE_SIZE_MAX = 18 # 内容页标题字号上限 (pt)
|
||
ALIGN_TOLERANCE = 0.05 # 对齐偏差容忍度 (inches)
|
||
|
||
def __init__(self, pptx_path: str):
|
||
self.path = Path(pptx_path)
|
||
self.prs = Presentation(str(self.path))
|
||
# 动态阈值 — 从 PPT 实际尺寸计算边界
|
||
sw_inches = self.prs.slide_width / EMU_PER_INCH
|
||
sh_inches = self.prs.slide_height / EMU_PER_INCH
|
||
self.max_right = sw_inches + 0.067 # 小容差
|
||
self.max_bottom = sh_inches
|
||
|
||
def run(self) -> dict:
|
||
"""执行所有检查,返回报告
|
||
|
||
Returns:
|
||
{
|
||
"file": str,
|
||
"total_slides": int,
|
||
"issues": [
|
||
{"slide": int, "severity": "ERROR"|"WARN", "rule": str, "detail": str}
|
||
],
|
||
"summary": {"errors": int, "warnings": int}
|
||
}
|
||
"""
|
||
issues = []
|
||
|
||
for idx, slide in enumerate(self.prs.slides):
|
||
slide_num = idx + 1
|
||
is_skip = self._is_skip_slide(slide)
|
||
|
||
if not is_skip:
|
||
issues.extend(self._check_header(slide, slide_num))
|
||
issues.extend(self._check_footer(slide, slide_num))
|
||
|
||
issues.extend(self._check_bounds(slide, slide_num))
|
||
issues.extend(self._check_title_size(slide, slide_num, is_skip))
|
||
issues.extend(self._check_alignment(slide, slide_num))
|
||
if not is_skip:
|
||
issues.extend(self._check_insight_text(slide, slide_num))
|
||
|
||
errors = sum(1 for i in issues if i["severity"] == "ERROR")
|
||
warnings = sum(1 for i in issues if i["severity"] == "WARN")
|
||
|
||
return {
|
||
"file": str(self.path),
|
||
"total_slides": len(self.prs.slides),
|
||
"issues": issues,
|
||
"summary": {"errors": errors, "warnings": warnings},
|
||
}
|
||
|
||
def _is_skip_slide(self, slide) -> bool:
|
||
"""判断是否为封面/结论/分隔页(不需要 header/footer)
|
||
|
||
启发式:
|
||
1. 文本包含关键词(封面/title/结论/conclusion/divider)
|
||
2. 有全幅装饰矩形(宽度 > 12",高度 < 0.1",y 接近 0 或底部)
|
||
3. 有大色块(宽度 > 4",高度 > 7")用于分隔页
|
||
4. 无 header 区域的小字标题文本(只有大字 > 20pt)
|
||
"""
|
||
has_small_header = False
|
||
has_decoration_bar = False
|
||
has_large_block = False
|
||
|
||
sw_inches = self.prs.slide_width / EMU_PER_INCH
|
||
sh_inches = self.prs.slide_height / EMU_PER_INCH
|
||
|
||
for shape in slide.shapes:
|
||
if shape.has_text_frame:
|
||
text = shape.text_frame.text.lower()
|
||
for kw in _SKIP_KEYWORDS:
|
||
if kw in text:
|
||
return True
|
||
|
||
w_inches = shape.width / EMU_PER_INCH if shape.width else 0
|
||
h_inches = shape.height / EMU_PER_INCH if shape.height else 0
|
||
y_inches = shape.top / EMU_PER_INCH if shape.top else 0
|
||
|
||
# 全幅装饰条(顶部/底部装饰线)— 宽度 > 90% 幻灯片宽度
|
||
if w_inches > sw_inches * 0.9 and h_inches < 0.15:
|
||
has_decoration_bar = True
|
||
|
||
# 大色块(分隔页左侧色块)— 宽度 > 30% 且高度 > 85%
|
||
if w_inches > sw_inches * 0.3 and h_inches > sh_inches * 0.85:
|
||
has_large_block = True
|
||
|
||
# 检查是否有 header 区域的标准小字标题
|
||
if shape.has_text_frame and y_inches < self.HEADER_Y_MAX:
|
||
for para in shape.text_frame.paragraphs:
|
||
for run in para.runs:
|
||
if run.font.size:
|
||
pt = run.font.size / 12700
|
||
if pt <= 20:
|
||
has_small_header = True
|
||
|
||
# 如果有装饰条但没有小字 header,大概率是封面/结论页
|
||
if has_decoration_bar and not has_small_header:
|
||
return True
|
||
|
||
# 大色块 = 分隔页
|
||
if has_large_block:
|
||
return True
|
||
|
||
return False
|
||
|
||
def _check_header(self, slide, slide_num) -> list:
|
||
"""检查 header 存在性: 非封面页必须有 y < HEADER_Y_MAX 的文本框"""
|
||
for shape in slide.shapes:
|
||
if shape.has_text_frame and shape.text_frame.text.strip():
|
||
y_inches = shape.top / EMU_PER_INCH
|
||
if y_inches < self.HEADER_Y_MAX:
|
||
return []
|
||
return [self._issue(slide_num, "ERROR", "header_missing",
|
||
"No header text box found (y < 0.6\")")]
|
||
|
||
def _check_footer(self, slide, slide_num) -> list:
|
||
"""检查 footer 存在性: 非封面页必须有 y > FOOTER_Y_MIN 的文本框"""
|
||
for shape in slide.shapes:
|
||
if shape.has_text_frame and shape.text_frame.text.strip():
|
||
y_inches = shape.top / EMU_PER_INCH
|
||
if y_inches > self.FOOTER_Y_MIN:
|
||
return []
|
||
return [self._issue(slide_num, "ERROR", "footer_missing",
|
||
"No footer text box found (y > 6.8\")")]
|
||
|
||
def _check_bounds(self, slide, slide_num) -> list:
|
||
"""检查元素是否越界"""
|
||
issues = []
|
||
for shape in slide.shapes:
|
||
if shape.left is None or shape.top is None:
|
||
continue
|
||
right = (shape.left + shape.width) / EMU_PER_INCH
|
||
bottom = (shape.top + shape.height) / EMU_PER_INCH
|
||
if right > self.max_right:
|
||
issues.append(self._issue(
|
||
slide_num, "ERROR", "bounds_overflow",
|
||
f"Shape '{shape.name}' right={right:.2f}\" > {self.max_right:.2f}\""))
|
||
if bottom > self.max_bottom + 0.1: # small tolerance for rounding
|
||
issues.append(self._issue(
|
||
slide_num, "ERROR", "bounds_overflow",
|
||
f"Shape '{shape.name}' bottom={bottom:.2f}\" > {self.max_bottom:.2f}\""))
|
||
return issues
|
||
|
||
def _check_title_size(self, slide, slide_num, is_skip) -> list:
|
||
"""检查标题字号是否越界"""
|
||
if is_skip:
|
||
return []
|
||
issues = []
|
||
for shape in slide.shapes:
|
||
if not shape.has_text_frame:
|
||
continue
|
||
y_inches = shape.top / EMU_PER_INCH
|
||
if y_inches > self.HEADER_Y_MAX:
|
||
continue
|
||
# 这是一个 header 区域的文本框
|
||
for para in shape.text_frame.paragraphs:
|
||
for run in para.runs:
|
||
if run.font.size and run.font.size > Emu(self.TITLE_SIZE_MAX * 12700):
|
||
pt = run.font.size / 12700
|
||
issues.append(self._issue(
|
||
slide_num, "WARN", "title_size_overflow",
|
||
f"Title font {pt:.0f}pt > {self.TITLE_SIZE_MAX}pt"))
|
||
return issues
|
||
|
||
def _check_alignment(self, slide, slide_num) -> list:
|
||
"""检查同页多图的 top 对齐偏差"""
|
||
chart_tops = []
|
||
for shape in slide.shapes:
|
||
if shape.has_chart:
|
||
chart_tops.append(shape.top / EMU_PER_INCH)
|
||
|
||
if len(chart_tops) < 2:
|
||
return []
|
||
|
||
issues = []
|
||
base = chart_tops[0]
|
||
for i, t in enumerate(chart_tops[1:], 1):
|
||
diff = abs(t - base)
|
||
if diff > self.ALIGN_TOLERANCE:
|
||
issues.append(self._issue(
|
||
slide_num, "WARN", "chart_misaligned",
|
||
f"Chart {i+1} top={t:.3f}\" vs chart 1 top={base:.3f}\" "
|
||
f"(diff={diff:.3f}\")"))
|
||
return issues
|
||
|
||
def _check_insight_text(self, slide, slide_num) -> list:
|
||
"""WARN: 图表页无 insight 文本"""
|
||
# 仅检查含图表的页面
|
||
has_chart = any(shape.has_chart for shape in slide.shapes)
|
||
if not has_chart:
|
||
return []
|
||
|
||
# insight 区间:header divider 下方、图表上方
|
||
# 4:3 和 16:9 高度均为 7.5",y 布局相同;
|
||
# 范围基于 divider_y(0.80) 到 content_y(1.00) + insight_h(0.55)
|
||
sh_inches = self.prs.slide_height / EMU_PER_INCH
|
||
insight_y_min = sh_inches * 0.11 # ~0.83" for 7.5"
|
||
insight_y_max = sh_inches * 0.22 # ~1.65" for 7.5"
|
||
|
||
for shape in slide.shapes:
|
||
if not shape.has_text_frame:
|
||
continue
|
||
y_inches = shape.top / EMU_PER_INCH
|
||
if insight_y_min < y_inches < insight_y_max:
|
||
text = shape.text_frame.text.strip()
|
||
if len(text) > 15:
|
||
return [] # 有 insight 文本
|
||
|
||
return [self._issue(slide_num, "WARN", "insight_missing",
|
||
"Chart page has no insight text (recommend 2-3 line summary)")]
|
||
|
||
@staticmethod
|
||
def _issue(slide_num, severity, rule, detail) -> dict:
|
||
return {"slide": slide_num, "severity": severity, "rule": rule, "detail": detail}
|
||
|
||
def print_summary(self, report: dict, file=None):
|
||
"""输出彩色终端摘要"""
|
||
file = file or sys.stderr
|
||
errors = report["summary"]["errors"]
|
||
warnings = report["summary"]["warnings"]
|
||
total = len(report["issues"])
|
||
|
||
print(f"\n{'='*60}", file=file)
|
||
print(f"DeckLinter: {report['file']}", file=file)
|
||
print(f"Slides: {report['total_slides']} | "
|
||
f"Errors: {errors} | Warnings: {warnings}", file=file)
|
||
print(f"{'='*60}", file=file)
|
||
|
||
for issue in report["issues"]:
|
||
tag = "ERR " if issue["severity"] == "ERROR" else "WARN"
|
||
print(f" [{tag}] Slide {issue['slide']}: "
|
||
f"{issue['rule']} - {issue['detail']}", file=file)
|
||
|
||
if total == 0:
|
||
print(" All checks passed.", file=file)
|
||
print(file=file)
|
||
|
||
def to_json(self, report: dict) -> str:
|
||
"""输出 JSON 报告"""
|
||
return json.dumps(report, ensure_ascii=False, indent=2)
|
||
|
||
def write_to_notes(self, report: dict):
|
||
"""将 lint 结果写入 PPT 的 notes"""
|
||
for issue in report["issues"]:
|
||
slide_idx = issue["slide"] - 1
|
||
if slide_idx >= len(self.prs.slides):
|
||
continue
|
||
slide = self.prs.slides[slide_idx]
|
||
notes_slide = slide.notes_slide if slide.has_notes_slide else slide.notes_slide
|
||
tf = notes_slide.notes_text_frame
|
||
existing = tf.text or ""
|
||
lint_line = f"[{issue['severity']}] {issue['rule']}: {issue['detail']}"
|
||
if lint_line not in existing:
|
||
if existing:
|
||
tf.text = existing + "\n" + lint_line
|
||
else:
|
||
tf.text = lint_line
|
||
|
||
# 保存
|
||
self.prs.save(str(self.path))
|