skills/ppt-station-skill/ppt_station/qa/deck_linter.py

299 lines
12 KiB
Python
Raw Permalink Normal View History

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