skills/ppt-station-skill/ppt_station/qa/deck_linter.py
wangyitong a65adcc2e5 Initial commit: merged, deduplicated, and vetted skill collection
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>
2026-07-13 14:47:12 +08:00

299 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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))