@topmindspace/tms-skills 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +76 -0
- package/README.md +9 -3
- package/bin/tms-skills.js +200 -160
- package/package.json +12 -9
- package/top-ppt-html/README.md +162 -254
- package/top-ppt-html/SKILL.md +126 -111
- package/top-ppt-html/assets/examples/2026-09-09-architecture-graphite-dark.html +3926 -3926
- package/top-ppt-html/assets/examples/2026-09-09-architecture-graphite-dark.model.json +167 -167
- package/top-ppt-html/assets/examples/2026-09-09-presentation-business-blue.html +4325 -4325
- package/top-ppt-html/assets/examples/2026-09-09-presentation-business-blue.model.json +320 -320
- package/top-ppt-html/assets/examples/2026-09-09-research-mckinsey.html +5527 -5527
- package/top-ppt-html/assets/examples/2026-09-09-research-mckinsey.model.json +913 -913
- package/top-ppt-html/assets/pptx-export.js +1944 -1944
- package/top-ppt-html/assets/style-gallery.html +589 -589
- package/top-ppt-html/assets/templates/architecture.html +3728 -3728
- package/top-ppt-html/assets/templates/engine.css +840 -840
- package/top-ppt-html/assets/templates/presentation.html +3738 -3738
- package/top-ppt-html/assets/templates/research.html +4017 -4017
- package/top-ppt-html/assets/templates/ui.js +520 -520
- package/top-ppt-html/assets/theme-overview-architecture.png +0 -0
- package/top-ppt-html/assets/theme-overview-research.png +0 -0
- package/top-ppt-html/assets/theme-overview.png +0 -0
- package/top-ppt-html/evals/prompts.csv +15 -15
- package/top-ppt-html/evals/rubric.schema.json +25 -25
- package/top-ppt-html/evals/run_evals.py +220 -220
- package/top-ppt-html/evals/trace.example.json +16 -16
- package/top-ppt-html/package-lock.json +186 -0
- package/top-ppt-html/package.json +11 -11
- package/top-ppt-html/references/charts-basic.md +624 -624
- package/top-ppt-html/references/charts-discipline.md +110 -108
- package/top-ppt-html/references/charts-extended.md +482 -482
- package/top-ppt-html/references/charts.md +37 -28
- package/top-ppt-html/references/components-atoms.md +624 -624
- package/top-ppt-html/references/components.md +30 -30
- package/top-ppt-html/references/content-rules.md +510 -490
- package/top-ppt-html/references/default-surface.md +61 -0
- package/top-ppt-html/references/design-system.md +478 -478
- package/top-ppt-html/references/failure-modes.md +227 -214
- package/top-ppt-html/references/high-fidelity.md +127 -127
- package/top-ppt-html/references/icons.md +176 -397
- package/top-ppt-html/references/infographics-stats.md +308 -308
- package/top-ppt-html/references/infographics-structure.md +226 -226
- package/top-ppt-html/references/infographics.md +43 -43
- package/top-ppt-html/references/layout-grammar.md +325 -315
- package/top-ppt-html/references/layouts-architecture.md +108 -108
- package/top-ppt-html/references/layouts-combo.md +600 -600
- package/top-ppt-html/references/layouts-research.md +160 -160
- package/top-ppt-html/references/modes.md +259 -254
- package/top-ppt-html/references/outline-design.md +275 -275
- package/top-ppt-html/references/playbook.md +284 -266
- package/top-ppt-html/references/pptx-export.md +227 -209
- package/top-ppt-html/references/presentation-craft.md +69 -0
- package/top-ppt-html/references/styles.md +370 -370
- package/top-ppt-html/references/tech-design.md +138 -138
- package/top-ppt-html/scripts/audit_css.py +109 -109
- package/top-ppt-html/scripts/audit_docs.py +176 -176
- package/top-ppt-html/scripts/audit_skill.py +231 -220
- package/top-ppt-html/scripts/audit_styles.py +351 -351
- package/top-ppt-html/scripts/build_examples.py +55 -2276
- package/top-ppt-html/scripts/build_pptx.js +2386 -2380
- package/top-ppt-html/scripts/capture_theme_overview.js +79 -78
- package/top-ppt-html/scripts/checks_html.py +166 -127
- package/top-ppt-html/scripts/cross_verify.py +298 -294
- package/top-ppt-html/scripts/env_probe.py +158 -158
- package/top-ppt-html/scripts/extract_model.py +210 -210
- package/top-ppt-html/scripts/extract_snippet.py +374 -374
- package/top-ppt-html/scripts/gen_channel_a.js +214 -214
- package/top-ppt-html/scripts/layout-constants.json +111 -18
- package/top-ppt-html/scripts/lib_layout_regions.js +410 -412
- package/top-ppt-html/scripts/measure_height.py +178 -178
- package/top-ppt-html/scripts/negative_tests.py +433 -307
- package/top-ppt-html/scripts/package_skill.py +294 -291
- package/top-ppt-html/scripts/prepare_images.py +341 -341
- package/top-ppt-html/scripts/probe_image_export.py +187 -188
- package/top-ppt-html/scripts/quality_gate.py +309 -301
- package/top-ppt-html/scripts/recommend_layout.py +384 -0
- package/top-ppt-html/scripts/regression.py +308 -308
- package/top-ppt-html/scripts/render_compare.py +275 -275
- package/top-ppt-html/scripts/render_from_model.py +698 -698
- package/top-ppt-html/scripts/scaffold_report.py +1054 -1054
- package/top-ppt-html/scripts/section-file-map.json +104 -104
- package/top-ppt-html/scripts/smoke_pptx.sh +30 -0
- package/top-ppt-html/scripts/sync_runtime.py +659 -662
- package/top-ppt-html/scripts/validate_pptx.py +1579 -1510
- package/top-ppt-html/scripts/validate_report.py +1755 -1456
- package/top-ppt-html/assets/examples/2026-09-09-architecture-spectrum.html +0 -3926
- package/top-ppt-html/assets/examples/2026-09-09-architecture-spectrum.model.json +0 -168
- package/top-ppt-html/assets/examples/2026-09-09-presentation-apple-mono.html +0 -4325
- package/top-ppt-html/assets/examples/2026-09-09-presentation-apple-mono.model.json +0 -321
- package/top-ppt-html/assets/examples/2026-09-09-presentation-brand-red.html +0 -4325
- package/top-ppt-html/assets/examples/2026-09-09-presentation-brand-red.model.json +0 -321
- package/top-ppt-html/assets/examples/2026-09-09-research-deep-teal.html +0 -5527
- package/top-ppt-html/assets/examples/2026-09-09-research-deep-teal.model.json +0 -914
- package/top-ppt-html/assets/examples/2026-09-09-research-indigo-violet.html +0 -5527
- package/top-ppt-html/assets/examples/2026-09-09-research-indigo-violet.model.json +0 -914
- package/top-ppt-html/assets/examples/2026-09-09-research-warm-sand.html +0 -5527
- package/top-ppt-html/assets/examples/2026-09-09-research-warm-sand.model.json +0 -914
- package/top-ppt-html/assets/theme-overview-presentation.png +0 -0
- package/top-ppt-html/references/design-system-engine.md +0 -235
- package/top-ppt-html/references/industry-benchmark.md +0 -105
- package/top-ppt-html/references/reform-plan.md +0 -252
- package/top-ppt-html/scripts/layout_slots.json +0 -830
|
@@ -1,1510 +1,1579 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
# -*- coding: utf-8 -*-
|
|
3
|
-
"""TopPPT HTML· PPTX 质检(交付硬门禁)
|
|
4
|
-
|
|
5
|
-
用法:
|
|
6
|
-
python validate_pptx.py <报告.pptx> [--strict] [--model=报告.model.json] [--json-out=报告.json]
|
|
7
|
-
[--allow-shape-charts]
|
|
8
|
-
[--deep --emit-manifest=报告.manifest.json] # 深度模式(高保真/1:1)
|
|
9
|
-
输出约定:
|
|
10
|
-
**stdout 恒为完整 JSON 报告**(供 probe_image_export.py / 流水线直接 json.loads 消费);
|
|
11
|
-
`--json-out` 额外落盘一份;人读建议 `--json-out` 后用编辑器查看。
|
|
12
|
-
|
|
13
|
-
检查面:
|
|
14
|
-
① 包结构:可解析 PPTX(ZIP + presentation.xml + sldSz),16:9 比例
|
|
15
|
-
② 可编辑性:全原生形状/文本框/表格;图片默认必须为 0(pictures=0),
|
|
16
|
-
仅当模型显式声明 section.image / split.right.image 时放行对应数量的图片(PICTURES_NOT_DECLARED 硬拦);
|
|
17
|
-
配图占位(image.placeholder)走原生形状、不计入图片数;
|
|
18
|
-
另拦图片版式/裁切非法、单页多图超限、相对路径文件缺失(IMAGE_LAYOUT_INVALID / IMAGE_FIT_INVALID /
|
|
19
|
-
IMAGE_ITEMS_TOO_MANY / IMAGE_SRC_MISSING)与外链 src(IMAGE_SRC_EXTERNAL)
|
|
20
|
-
③ 版式安全:元素越界 / 负坐标 / 非正尺寸(PowerPoint 会判损坏)、页内文本溢出估算、
|
|
21
|
-
覆盖率与信息密度、字体下限、占位符文本
|
|
22
|
-
③b 色值合法性:所有 srgbClr val 必须是 6 位 hex(不带 # 前缀)——编码色板与图表系列色
|
|
23
|
-
由双引擎直写 OOXML,单源一旦带 # 就是非法色值(PowerPoint 静默忽略/触发修复)
|
|
24
|
-
→ INVALID_HEX_COLOR 硬拦
|
|
25
|
-
④ 内容保真(--model):页数结构、章节/封面/收尾标题落位、逐页内容覆盖(≥roundtripMin 关键串,
|
|
26
|
-
比对语料 = 幻灯片形状文本 ∪ 演讲者备注 ∪ 关联 chart part 文本——原生图表类别标签与 notes-only
|
|
27
|
-
字段不得被误判为内容丢失)、主题 token 亮暗一致、模型图表必须落成原生可编辑图表(MODEL_CHART_COUNT)、
|
|
28
|
-
图片源非外链
|
|
29
|
-
⑤ --strict:0 errors / 0 warnings 才通过(退出码 0);否则 1(errors)或 2(warnings)
|
|
30
|
-
|
|
31
|
-
零第三方依赖(纯标准库);python-pptx 交叉裁判见 scripts/cross_verify.py(可选)。
|
|
32
|
-
"""
|
|
33
|
-
from __future__ import annotations
|
|
34
|
-
|
|
35
|
-
import argparse
|
|
36
|
-
import json
|
|
37
|
-
import re
|
|
38
|
-
import sys
|
|
39
|
-
import zipfile
|
|
40
|
-
from functools import lru_cache
|
|
41
|
-
from pathlib import Path
|
|
42
|
-
from typing import Any
|
|
43
|
-
|
|
44
|
-
# Windows GBK 控制台兜底:强制 UTF-8 输出
|
|
45
|
-
try:
|
|
46
|
-
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
|
47
|
-
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
|
|
48
|
-
except Exception:
|
|
49
|
-
pass
|
|
50
|
-
from xml.etree import ElementTree as ET
|
|
51
|
-
|
|
52
|
-
NS = {
|
|
53
|
-
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
|
|
54
|
-
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
|
|
55
|
-
"c": "http://schemas.openxmlformats.org/drawingml/2006/chart",
|
|
56
|
-
}
|
|
57
|
-
GLOBAL_MIN_FONT_PT = 6.5 # 全篇字体下限(低于此值投影/打印不可读)
|
|
58
|
-
LARGE_IMAGE_AREA_RATIO = 0.40 # 单图面积告警阈值(未声明的图片命中即告警)
|
|
59
|
-
FULL_SLIDE_IMAGE_RATIO = 0.90
|
|
60
|
-
TEXT_OVERFLOW_TOLERANCE = 1.18 # 文本溢出估算容差(18%,与 cross_verify 一致)
|
|
61
|
-
# 下两项只是读不到 layout-constants.json 时的兵底;真值走 containers.textMetrics(与 build_pptx.js 同源)
|
|
62
|
-
LINE_FACTOR = 1.45 # 行高系数
|
|
63
|
-
EM_ASCII_RATIO = 0.52 # 拉丁/数字相对 CJK 的字宽比
|
|
64
|
-
UNBALANCED_GAP_RATIO = 0.28 # 右/下留白告警阈值
|
|
65
|
-
MIN_EDGE_MARGIN_IN = 0.02 # 元素不得贴边/越界的安全边距(英寸)
|
|
66
|
-
|
|
67
|
-
# OOXML ST_HexColorRGB = 6 位 hex;# 前缀 / 3 位缩写 / 非 hex 一律非法(PowerPoint 会忽略或触发修复)
|
|
68
|
-
HEX_COLOR_RE = re.compile(r'srgbClr val="([^"]*)"')
|
|
69
|
-
HEX6_RE = re.compile(r"[0-9A-Fa-f]{6}")
|
|
70
|
-
|
|
71
|
-
# --strict 下升级为 error 的告警码(其余告警仍以 warning 计,同样阻止 strict 通过)
|
|
72
|
-
STRICT_FAILURE_CODES = {
|
|
73
|
-
"INVALID_SHAPE_BOUNDS",
|
|
74
|
-
"SHAPE_OUTSIDE_SLIDE",
|
|
75
|
-
"PICTURES_NOT_ALLOWED",
|
|
76
|
-
"PICTURES_NOT_DECLARED",
|
|
77
|
-
"PICTURES_UNDER_DECLARED",
|
|
78
|
-
"FULL_SLIDE_BACKGROUND_RISK",
|
|
79
|
-
"NO_NATIVE_TEXT_WITH_EDITABLE_TEXT_REQUIRED",
|
|
80
|
-
"FONT_SIZE_BELOW_FOOTER_MIN",
|
|
81
|
-
"MODEL_ROUNDTRIP_SLIDE_COUNT",
|
|
82
|
-
"MODEL_ROUNDTRIP_TITLE_MISSING",
|
|
83
|
-
"MODEL_ROUNDTRIP_COVER_TITLE",
|
|
84
|
-
"MODEL_ROUNDTRIP_CLOSING_TITLE",
|
|
85
|
-
"MODEL_ROUNDTRIP_CONTENT_MISSING",
|
|
86
|
-
"MODEL_CHART_COUNT",
|
|
87
|
-
"MODEL_CHART_DATATABLE",
|
|
88
|
-
"MODEL_CHART_NOTES_MISSING",
|
|
89
|
-
"FONT_SIZE_OFF_SCALE",
|
|
90
|
-
"MODEL_THEME_BG_MISMATCH",
|
|
91
|
-
"IMAGE_SRC_EXTERNAL",
|
|
92
|
-
# 容器级与表格级硬门禁(比页面级越界更严格)
|
|
93
|
-
"CONTAINER_OVERFLOW",
|
|
94
|
-
"TABLE_SEMANTIC_TYPE",
|
|
95
|
-
"TABLE_DENSITY",
|
|
96
|
-
# 素材图片模型门禁(版式/裁切/多图数量/文件缺失;外链 src 见 IMAGE_SRC_EXTERNAL)
|
|
97
|
-
"IMAGE_LAYOUT_INVALID",
|
|
98
|
-
"IMAGE_FIT_INVALID",
|
|
99
|
-
"IMAGE_ITEMS_TOO_MANY",
|
|
100
|
-
"IMAGE_SRC_MISSING",
|
|
101
|
-
# v9:截图级真缺陷纳入 strict(原 WARN/缺失会「假过」)
|
|
102
|
-
"HTML_TAG_IN_TEXT",
|
|
103
|
-
"TITLE_ONLY_PAGE",
|
|
104
|
-
"UNDERFILL_PAGE",
|
|
105
|
-
"LOW_TEXT_DENSITY",
|
|
106
|
-
"LOW_CONTENT_DENSITY",
|
|
107
|
-
"EMPTY_OR_UNMEASURABLE_SLIDE",
|
|
108
|
-
"UNBALANCED_EMPTY_SPACE",
|
|
109
|
-
"UNJUSTIFIED_LARGE_IMAGE",
|
|
110
|
-
"TEXT_OVERFLOW_ESTIMATE",
|
|
111
|
-
"TEXT_INCOMPLETE",
|
|
112
|
-
"CHART_SKEW_INVALID",
|
|
113
|
-
"CHART_OVERSIZE",
|
|
114
|
-
"LAYOUT_MULTI_FOCUS",
|
|
115
|
-
"LAYOUT_ALIGN_DRIFT",
|
|
116
|
-
"LAYOUT_LABEL_COLLIDE",
|
|
117
|
-
}
|
|
118
|
-
# 深度模式专属校验码(--deep 时并入 STRICT_FAILURE_CODES;默认不跑,保持轻量)
|
|
119
|
-
DEEP_STRICT_CODES = {
|
|
120
|
-
"CONTINUOUS_TEXT_FLOW",
|
|
121
|
-
"ANCHOR_TITLE_MISALIGNED",
|
|
122
|
-
}
|
|
123
|
-
PLACEHOLDER_RE = re.compile(
|
|
124
|
-
r"\b(?:TODO|TBD)\b|Lorem ipsum|Click to add|单击此处添加",
|
|
125
|
-
re.IGNORECASE,
|
|
126
|
-
)
|
|
127
|
-
SLIDE_RE = re.compile(r"ppt/slides/slide(\d+)\.xml$")
|
|
128
|
-
# 探针跳过的非「可见正文」键:色值/图标/链接/标识/类型/量程单位等元数据,
|
|
129
|
-
# 以及 image.hint/src/alt 这类素材声明或只写进备注的交付指引。
|
|
130
|
-
# chart.labels / note / lead / soWhat / footnote / centerLabel 等仍作探针——
|
|
131
|
-
# 它们的落点(形状文本 / 备注 / chart part)由 _roundtrip_corpus 并集覆盖。
|
|
132
|
-
_PROBE_SKIP_KEYS = {
|
|
133
|
-
"colors", "color", "icon", "href", "id", "type", "exhibitNo", "max", "unit",
|
|
134
|
-
"hint", "src", "alt", "fit", "layout", "placeholder", "ratio", "dataTable",
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
def issue(code: str, message: str, *, slide: int | None = None) -> dict[str, Any]:
|
|
139
|
-
item: dict[str, Any] = {"code": code, "message": message}
|
|
140
|
-
if slide is not None:
|
|
141
|
-
item["slide"] = slide
|
|
142
|
-
return item
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
def read_xml(archive: zipfile.ZipFile, name: str) -> ET.Element:
|
|
146
|
-
return ET.fromstring(archive.read(name))
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
def find_slide_names(archive: zipfile.ZipFile) -> list[str]:
|
|
150
|
-
matched: list[tuple[int, str]] = []
|
|
151
|
-
for name in archive.namelist():
|
|
152
|
-
result = SLIDE_RE.fullmatch(name)
|
|
153
|
-
if result:
|
|
154
|
-
matched.append((int(result.group(1)), name))
|
|
155
|
-
return [name for _, name in sorted(matched)]
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
def _slide_rels(archive: zipfile.ZipFile, slide_name: str) -> list[str]:
|
|
159
|
-
"""该幻灯片 rels 里的 Target 列表(无 rels 返回空列表)。"""
|
|
160
|
-
base = slide_name.rsplit("/", 1)[-1]
|
|
161
|
-
rels_name = f"ppt/slides/_rels/{base}.rels"
|
|
162
|
-
try:
|
|
163
|
-
rels = read_xml(archive, rels_name)
|
|
164
|
-
except (KeyError, ET.ParseError):
|
|
165
|
-
return []
|
|
166
|
-
return [(rel.get("Target") or "") for rel in rels]
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
def _resolve_slide_part(slide_name: str, target: str) -> str:
|
|
170
|
-
"""把 rels Target(常为 ../charts/chart1.xml)解析成包内绝对 part 路径。"""
|
|
171
|
-
from posixpath import dirname, normpath, join
|
|
172
|
-
return normpath(join(dirname(slide_name), target))
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
def slide_notes_text(archive: zipfile.ZipFile, slide_name: str) -> str:
|
|
176
|
-
"""取该页演讲者备注的纯文本(经 slide rels 定位 notesSlide;无备注返回空串)。"""
|
|
177
|
-
target = next((t for t in _slide_rels(archive, slide_name) if "notesSlide" in t), "")
|
|
178
|
-
if not target:
|
|
179
|
-
return ""
|
|
180
|
-
part = _resolve_slide_part(slide_name, target)
|
|
181
|
-
try:
|
|
182
|
-
return text_content(read_xml(archive, part))
|
|
183
|
-
except (KeyError, ET.ParseError):
|
|
184
|
-
return ""
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
def slide_chart_text(archive: zipfile.ZipFile, slide_name: str) -> str:
|
|
188
|
-
"""取该页关联 chart part 的文本(类别/系列/缓存值)。
|
|
189
|
-
|
|
190
|
-
原生数据图表的 labels 落在 ppt/charts/chartN.xml 的 c:pt/c:v,不在幻灯片
|
|
191
|
-
形状 a:t 里——往返保真比对必须把 chart part 并入语料,否则类别标签必然 MISS。
|
|
192
|
-
"""
|
|
193
|
-
chunks: list[str] = []
|
|
194
|
-
for target in _slide_rels(archive, slide_name):
|
|
195
|
-
if "charts/chart" not in target.replace("\\", "/"):
|
|
196
|
-
continue
|
|
197
|
-
part = _resolve_slide_part(slide_name, target)
|
|
198
|
-
try:
|
|
199
|
-
root = read_xml(archive, part)
|
|
200
|
-
except (KeyError, ET.ParseError):
|
|
201
|
-
continue
|
|
202
|
-
chunks.append(text_content(root))
|
|
203
|
-
for node in root.findall(".//c:v", NS):
|
|
204
|
-
if node.text and node.text.strip():
|
|
205
|
-
chunks.append(node.text.strip())
|
|
206
|
-
return " ".join(chunks).strip()
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
def shape_bounds(element: ET.Element) -> tuple[int, int, int, int] | None:
|
|
210
|
-
xfrm = element.find(".//a:xfrm", NS)
|
|
211
|
-
if xfrm is None:
|
|
212
|
-
return None
|
|
213
|
-
offset = xfrm.find("a:off", NS)
|
|
214
|
-
extent = xfrm.find("a:ext", NS)
|
|
215
|
-
if offset is None or extent is None:
|
|
216
|
-
return None
|
|
217
|
-
try:
|
|
218
|
-
return (
|
|
219
|
-
int(offset.get("x", "0")),
|
|
220
|
-
int(offset.get("y", "0")),
|
|
221
|
-
int(extent.get("cx", "0")),
|
|
222
|
-
int(extent.get("cy", "0")),
|
|
223
|
-
)
|
|
224
|
-
except ValueError:
|
|
225
|
-
return None
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
def text_content(element: ET.Element) -> str:
|
|
229
|
-
return " ".join((node.text or "") for node in element.findall(".//a:t", NS)).strip()
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
def _all_run_sizes(root: ET.Element) -> list[float]:
|
|
233
|
-
"""页内所有非空文本 run 的字号(pt)——只取真正承载文字的 run,避免空占位样式干扰。"""
|
|
234
|
-
out: list[float] = []
|
|
235
|
-
for run in root.findall(".//a:r", NS):
|
|
236
|
-
text = "".join(t.text or "" for t in run.findall("a:t", NS))
|
|
237
|
-
if not text.strip():
|
|
238
|
-
continue
|
|
239
|
-
pr = run.find("a:rPr", NS)
|
|
240
|
-
raw = pr.get("sz") if pr is not None else None
|
|
241
|
-
if raw is None:
|
|
242
|
-
continue
|
|
243
|
-
try:
|
|
244
|
-
out.append(int(raw) / 100.0)
|
|
245
|
-
except (TypeError, ValueError):
|
|
246
|
-
continue
|
|
247
|
-
return out
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
def font_sizes_pt(element: ET.Element) -> list[float]:
|
|
251
|
-
sizes: list[float] = []
|
|
252
|
-
for node in element.findall(".//a:rPr", NS) + element.findall(".//a:defRPr", NS):
|
|
253
|
-
raw_size = node.get("sz")
|
|
254
|
-
if raw_size is None:
|
|
255
|
-
continue
|
|
256
|
-
try:
|
|
257
|
-
size = int(raw_size) / 100
|
|
258
|
-
except ValueError:
|
|
259
|
-
continue
|
|
260
|
-
if size > 0:
|
|
261
|
-
sizes.append(size)
|
|
262
|
-
return sizes
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
def est_text_width_in(text: str, font_pt: float) -> float:
|
|
266
|
-
"""估算文本宽度(英寸):CJK 1em、ASCII emAsciiRatio(与 build_pptx.js estLines 同口径)。"""
|
|
267
|
-
ratio = _containers_cfg()["emAsciiRatio"]
|
|
268
|
-
em = sum(1.0 if ord(c) > 0x2E80 else ratio for c in text)
|
|
269
|
-
return em * font_pt / 72.0
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
def text_overflow_check(shape: ET.Element, slide_no: int) -> list[dict[str, Any]]:
|
|
273
|
-
"""文本溢出估算:逐段估宽 × 可容行数 vs 文本框高度。"""
|
|
274
|
-
out: list[dict[str, Any]] = []
|
|
275
|
-
box = shape_bounds(shape)
|
|
276
|
-
if box is None:
|
|
277
|
-
return out
|
|
278
|
-
_, _, cx, cy = box
|
|
279
|
-
w_in, h_in = cx / 914400, cy / 914400
|
|
280
|
-
if w_in <= 0 or h_in <= 0:
|
|
281
|
-
return out
|
|
282
|
-
lf = _containers_cfg()["lineFactor"]
|
|
283
|
-
for para in shape.findall(".//a:p", NS):
|
|
284
|
-
line = "".join((t.text or "") for t in para.findall(".//a:t", NS))
|
|
285
|
-
if not line.strip():
|
|
286
|
-
continue
|
|
287
|
-
sizes = font_sizes_pt(para)
|
|
288
|
-
fz = min(sizes) if sizes else 12.0
|
|
289
|
-
est_w = est_text_width_in(line, fz)
|
|
290
|
-
lines_avail = max(1, int(h_in / (fz / 72.0 * lf)))
|
|
291
|
-
capacity = w_in * lines_avail
|
|
292
|
-
if est_w > capacity * TEXT_OVERFLOW_TOLERANCE:
|
|
293
|
-
out.append(issue(
|
|
294
|
-
"TEXT_OVERFLOW_ESTIMATE",
|
|
295
|
-
f'文本可能溢出:"{line[:18]}…" 估算 {est_w:.1f}in > 容量 {capacity:.1f}in({fz:.1f}pt × {lines_avail} 行)。',
|
|
296
|
-
slide=slide_no,
|
|
297
|
-
))
|
|
298
|
-
return out
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
@lru_cache(maxsize=1)
|
|
302
|
-
def _containers_cfg() -> dict[str, Any]:
|
|
303
|
-
"""容器内边距与锚点容差(单源 scripts/layout-constants.json 的 containers / anchorTolerance)。"""
|
|
304
|
-
lc_path = Path(__file__).resolve().parent / "layout-constants.json"
|
|
305
|
-
try:
|
|
306
|
-
lc = json.loads(lc_path.read_text(encoding="utf-8"))
|
|
307
|
-
except (OSError, json.JSONDecodeError):
|
|
308
|
-
return {"pad": {}, "minPad": 0.06, "anchorKeyPx": 6, "fontShrink": {},
|
|
309
|
-
"lineFactor": LINE_FACTOR, "emAsciiRatio": EM_ASCII_RATIO}
|
|
310
|
-
cont = lc.get("containers") or {}
|
|
311
|
-
anch = lc.get("anchorTolerance") or {}
|
|
312
|
-
tm = cont.get("textMetrics") or {}
|
|
313
|
-
return {
|
|
314
|
-
"pad": cont.get("pad") or {},
|
|
315
|
-
"minPad": float(cont.get("minPad") or 0.06),
|
|
316
|
-
"anchorKeyPx": float(anch.get("keyPx") or 6),
|
|
317
|
-
"fontShrink": cont.get("fontShrink") or {},
|
|
318
|
-
"overflowRule": cont.get("overflowRule") or "",
|
|
319
|
-
"lineFactor": float(tm.get("lineFactor") or LINE_FACTOR),
|
|
320
|
-
"emAsciiRatio": float(tm.get("emAsciiRatio") or EM_ASCII_RATIO),
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
def _shape_object_name(shape: ET.Element) -> str:
|
|
325
|
-
el = shape.find(".//p:cNvPr", NS)
|
|
326
|
-
return (el.get("name") if el is not None else "") or ""
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
def _infer_container_pad(shape: ET.Element, pad_map: dict[str, float], min_pad: float) -> tuple[float, str]:
|
|
330
|
-
"""推断容器类型,返回 (pad_in, kind)。
|
|
331
|
-
|
|
332
|
-
优先读引擎写入的 objectName(`tr:<kind>`,见 build_pptx.js trName);
|
|
333
|
-
未标注时回落 minPad——文本框坐标通常已由引擎 inset,再按 card/panel 大 pad
|
|
334
|
-
扣会双重计算,导致误报。仅在显式标注 soWhat/band/card/… 时才用对应 pad。
|
|
335
|
-
"""
|
|
336
|
-
name = _shape_object_name(shape)
|
|
337
|
-
kind = ""
|
|
338
|
-
if name.startswith("tr:"):
|
|
339
|
-
kind = name[3:].split("|")[0].strip()
|
|
340
|
-
if kind and kind in pad_map:
|
|
341
|
-
return float(pad_map[kind]), kind
|
|
342
|
-
if kind:
|
|
343
|
-
return min_pad, kind
|
|
344
|
-
# 启发式兜底:仅对明确的 so-what / 待核实文本用对应 pad(其余 minPad)
|
|
345
|
-
text = text_content(shape)
|
|
346
|
-
if "SO WHAT" in text:
|
|
347
|
-
return float(pad_map.get("soWhat") or min_pad), "soWhat"
|
|
348
|
-
if "待核实" in text:
|
|
349
|
-
return float(pad_map.get("band") or min_pad), "band"
|
|
350
|
-
return min_pad, "text"
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
def container_overflow_check(shape: ET.Element, slide_no: int) -> list[dict[str, Any]]:
|
|
354
|
-
"""容器级溢出:文字不得越过**归属容器**边界——即使没超出页面画布。
|
|
355
|
-
|
|
356
|
-
容器类型优先来自引擎 objectName(tr:<kind>);无标注时按 minPad 安全边距判定。
|
|
357
|
-
溢出处置:优先优化内容(列表化/精炼/拆页/换组合),其次调整容器,最后才是有限缩字号。
|
|
358
|
-
"""
|
|
359
|
-
out: list[dict[str, Any]] = []
|
|
360
|
-
cfg = _containers_cfg()
|
|
361
|
-
pad_map = cfg["pad"] or {}
|
|
362
|
-
min_pad = cfg["minPad"]
|
|
363
|
-
pad, kind = _infer_container_pad(shape, pad_map, min_pad)
|
|
364
|
-
box = shape_bounds(shape)
|
|
365
|
-
if box is None:
|
|
366
|
-
return out
|
|
367
|
-
_, _, cx, cy = box
|
|
368
|
-
w_in = cx / 914400 - 2 * pad
|
|
369
|
-
h_in = cy / 914400 - 2 * pad
|
|
370
|
-
if w_in <= 0.05 or h_in <= 0.02:
|
|
371
|
-
return out
|
|
372
|
-
lf = cfg["lineFactor"]
|
|
373
|
-
for para in shape.findall(".//a:p", NS):
|
|
374
|
-
line = "".join((t.text or "") for t in para.findall(".//a:t", NS))
|
|
375
|
-
if not line.strip():
|
|
376
|
-
continue
|
|
377
|
-
sizes = font_sizes_pt(para)
|
|
378
|
-
fz = min(sizes) if sizes else 12.0
|
|
379
|
-
est_w = est_text_width_in(line, fz)
|
|
380
|
-
lines_avail = max(1, int(h_in / (fz / 72.0 * lf)))
|
|
381
|
-
capacity = w_in * lines_avail
|
|
382
|
-
if est_w > capacity * TEXT_OVERFLOW_TOLERANCE:
|
|
383
|
-
out.append(issue(
|
|
384
|
-
"CONTAINER_OVERFLOW",
|
|
385
|
-
f'文本越过归属容器({kind},已扣内边距 {pad:.2f}in):"{line[:18]}…" '
|
|
386
|
-
f'估算 {est_w:.1f}in > 容器可用 {capacity:.1f}in。'
|
|
387
|
-
f'处置:①列表化/精炼 ②调容器/换行/拆页 ③有限缩字号(floor 见 containers.fontShrink)。',
|
|
388
|
-
slide=slide_no,
|
|
389
|
-
))
|
|
390
|
-
return out
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
def table_checks(table: ET.Element, slide_no: int) -> list[dict[str, Any]]:
|
|
394
|
-
"""表格语义字号与密度。
|
|
395
|
-
|
|
396
|
-
- TABLE_SEMANTIC_TYPE:句子型单元格(≥12 字)不得用 micro 档字号(≤9.5pt)。
|
|
397
|
-
- TABLE_DENSITY:单元格大面积空洞(空单元格占比 >0.6 且总数 ≥12)——字号没低于下限但版面塌陷。
|
|
398
|
-
"""
|
|
399
|
-
out: list[dict[str, Any]] = []
|
|
400
|
-
cells = table.findall(".//a:tc", NS)
|
|
401
|
-
if not cells:
|
|
402
|
-
return out
|
|
403
|
-
empty = 0
|
|
404
|
-
for cell in cells:
|
|
405
|
-
text = text_content(cell).strip()
|
|
406
|
-
if not text:
|
|
407
|
-
empty += 1
|
|
408
|
-
continue
|
|
409
|
-
sizes = font_sizes_pt(cell)
|
|
410
|
-
if sizes and len(text) >= 12 and min(sizes) <= 9.5:
|
|
411
|
-
out.append(issue(
|
|
412
|
-
"TABLE_SEMANTIC_TYPE",
|
|
413
|
-
f'表格句子型内容({len(text)} 字)用了 micro 档字号 {min(sizes):.1f}pt:'
|
|
414
|
-
"表格正文/行动项/解释句必须 T7/T10(body 档),T11 仅限轴标签/单位/列短标签。",
|
|
415
|
-
slide=slide_no,
|
|
416
|
-
))
|
|
417
|
-
if len(cells) >= 12 and empty / len(cells) > 0.6:
|
|
418
|
-
out.append(issue(
|
|
419
|
-
"TABLE_DENSITY",
|
|
420
|
-
f"表格 {len(cells)} 个单元格中 {empty} 个为空({empty / len(cells):.0%})——"
|
|
421
|
-
"表格密度与内容不匹配(阅读重心塌陷);请精简行列或换承载形态。",
|
|
422
|
-
slide=slide_no,
|
|
423
|
-
))
|
|
424
|
-
return out
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
def continuous_text_flow_check(shapes: list[ET.Element], slide_no: int) -> list[dict[str, Any]]:
|
|
428
|
-
"""连续文本流(深度模式):语义连续句不得拆成多个独立文本框。
|
|
429
|
-
|
|
430
|
-
启发式:同一水平带上紧邻(间距 <0.06in)的两个文本框,前框以非句末标点结尾且后框以
|
|
431
|
-
非标点/非数字开头、字号相同 → 判定为可能被拆分的连续句(应改用同一文本框内富文本)。
|
|
432
|
-
"""
|
|
433
|
-
out: list[dict[str, Any]] = []
|
|
434
|
-
items = []
|
|
435
|
-
for shape in shapes:
|
|
436
|
-
text = text_content(shape)
|
|
437
|
-
box = shape_bounds(shape)
|
|
438
|
-
if not text or box is None:
|
|
439
|
-
continue
|
|
440
|
-
sizes = font_sizes_pt(shape)
|
|
441
|
-
items.append((box, text, (min(sizes) if sizes else 12.0)))
|
|
442
|
-
tail_re = re.compile(r"[^\s。!?;:!?;:))】」”\"]$")
|
|
443
|
-
head_re = re.compile(r"^[^,。!?;:、,.;:!?))】」”\"0-9]")
|
|
444
|
-
# 只判「句」不判「标签」:两侧都必须是句长文本(轴标签/图例/矩阵行头一律排除)
|
|
445
|
-
MIN_SENTENCE_CHARS = 12
|
|
446
|
-
for i, (box_a, text_a, sz_a) in enumerate(items):
|
|
447
|
-
if len(text_a) < MIN_SENTENCE_CHARS:
|
|
448
|
-
continue
|
|
449
|
-
ax, ay, aw, ah = box_a
|
|
450
|
-
for box_b, text_b, sz_b in items[i + 1:]:
|
|
451
|
-
if len(text_b) < MIN_SENTENCE_CHARS:
|
|
452
|
-
continue
|
|
453
|
-
bx, by, bw, bh = box_b
|
|
454
|
-
if abs(sz_a - sz_b) > 0.6:
|
|
455
|
-
continue
|
|
456
|
-
# 同一水平带(纵向重叠 >50%)且后框紧跟在右
|
|
457
|
-
overlap = min(ay + ah, by + bh) - max(ay, by)
|
|
458
|
-
if overlap < min(ah, bh) * 0.5:
|
|
459
|
-
continue
|
|
460
|
-
gap = bx - (ax + aw)
|
|
461
|
-
if -0.02 * 914400 < gap < 0.06 * 914400:
|
|
462
|
-
if tail_re.search(text_a) and head_re.match(text_b):
|
|
463
|
-
out.append(issue(
|
|
464
|
-
"CONTINUOUS_TEXT_FLOW",
|
|
465
|
-
f'疑似拆分连续句:"{text_a[-10:]}" + "{text_b[:10]}"(同一水平带紧邻的两个文本框)——'
|
|
466
|
-
"语义连续句应放在同一文本框内用富文本高亮。",
|
|
467
|
-
slide=slide_no,
|
|
468
|
-
))
|
|
469
|
-
break
|
|
470
|
-
return out
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
def skill_version() -> str:
|
|
474
|
-
"""技能版本(单源 layout-constants.json 的 version)——manifest 不再手写版本号。"""
|
|
475
|
-
lc_path = Path(__file__).resolve().parent / "layout-constants.json"
|
|
476
|
-
try:
|
|
477
|
-
lc = json.loads(lc_path.read_text(encoding="utf-8"))
|
|
478
|
-
return str(lc.get("version") or "0.0")
|
|
479
|
-
except (OSError, json.JSONDecodeError):
|
|
480
|
-
return "0.0"
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
def page_margin_in() -> float:
|
|
484
|
-
"""版心左边界(英寸,layout-constants.json 的 page.mx)。"""
|
|
485
|
-
lc_path = Path(__file__).resolve().parent / "layout-constants.json"
|
|
486
|
-
try:
|
|
487
|
-
lc = json.loads(lc_path.read_text(encoding="utf-8"))
|
|
488
|
-
return float((lc.get("page") or {}).get("mx") or 0.6)
|
|
489
|
-
except (OSError, json.JSONDecodeError):
|
|
490
|
-
return 0.6
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
def _image_spec() -> dict[str, Any]:
|
|
494
|
-
"""素材图片规格与配图占位约定(单源 layout-constants.json 的 imageSpec)。"""
|
|
495
|
-
lc_path = Path(__file__).resolve().parent / "layout-constants.json"
|
|
496
|
-
try:
|
|
497
|
-
lc = json.loads(lc_path.read_text(encoding="utf-8"))
|
|
498
|
-
spec = lc.get("imageSpec") or {}
|
|
499
|
-
return spec if isinstance(spec, dict) else {}
|
|
500
|
-
except (OSError, json.JSONDecodeError):
|
|
501
|
-
return {}
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
def title_anchor_info(shapes: list[ET.Element]) -> dict[str, Any] | None:
|
|
505
|
-
"""页头标题锚点信息(最大字号的靠上文本块):用于锚点检查与 manifest 登记。"""
|
|
506
|
-
best = None
|
|
507
|
-
for shape in shapes:
|
|
508
|
-
text = text_content(shape)
|
|
509
|
-
box = shape_bounds(shape)
|
|
510
|
-
if not text or box is None or len(text) < 6:
|
|
511
|
-
continue
|
|
512
|
-
sizes = font_sizes_pt(shape)
|
|
513
|
-
fz = max(sizes) if sizes else 0.0
|
|
514
|
-
if fz < 18: # 只锚定页头标题(大字号)
|
|
515
|
-
continue
|
|
516
|
-
if best is None or box[1] < best[0][1]:
|
|
517
|
-
best = (box, text, fz)
|
|
518
|
-
if best is None:
|
|
519
|
-
return None
|
|
520
|
-
(bx, by, bw, bh), text, fz = best
|
|
521
|
-
mx = page_margin_in()
|
|
522
|
-
return {
|
|
523
|
-
"text": text[:40],
|
|
524
|
-
"left_in": round(bx / 914400, 4),
|
|
525
|
-
"top_in": round(by / 914400, 4),
|
|
526
|
-
"font_pt": fz,
|
|
527
|
-
"expected_left_in": mx,
|
|
528
|
-
"delta_in": round(bx / 914400 - mx, 4),
|
|
529
|
-
"tolerance_in": round(_containers_cfg()["anchorKeyPx"] / 96, 4),
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
""
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
)
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
for shape in shapes
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
for
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
if
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
)
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
"
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
"""
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
return
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
)
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
)
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
report["
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
report
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
)
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
for
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
if
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
if
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""TopPPT HTML· PPTX 质检(交付硬门禁)
|
|
4
|
+
|
|
5
|
+
用法:
|
|
6
|
+
python validate_pptx.py <报告.pptx> [--strict] [--model=报告.model.json] [--json-out=报告.json]
|
|
7
|
+
[--allow-shape-charts]
|
|
8
|
+
[--deep --emit-manifest=报告.manifest.json] # 深度模式(高保真/1:1)
|
|
9
|
+
输出约定:
|
|
10
|
+
**stdout 恒为完整 JSON 报告**(供 probe_image_export.py / 流水线直接 json.loads 消费);
|
|
11
|
+
`--json-out` 额外落盘一份;人读建议 `--json-out` 后用编辑器查看。
|
|
12
|
+
|
|
13
|
+
检查面:
|
|
14
|
+
① 包结构:可解析 PPTX(ZIP + presentation.xml + sldSz),16:9 比例
|
|
15
|
+
② 可编辑性:全原生形状/文本框/表格;图片默认必须为 0(pictures=0),
|
|
16
|
+
仅当模型显式声明 section.image / split.right.image 时放行对应数量的图片(PICTURES_NOT_DECLARED 硬拦);
|
|
17
|
+
配图占位(image.placeholder)走原生形状、不计入图片数;
|
|
18
|
+
另拦图片版式/裁切非法、单页多图超限、相对路径文件缺失(IMAGE_LAYOUT_INVALID / IMAGE_FIT_INVALID /
|
|
19
|
+
IMAGE_ITEMS_TOO_MANY / IMAGE_SRC_MISSING)与外链 src(IMAGE_SRC_EXTERNAL)
|
|
20
|
+
③ 版式安全:元素越界 / 负坐标 / 非正尺寸(PowerPoint 会判损坏)、页内文本溢出估算、
|
|
21
|
+
覆盖率与信息密度、字体下限、占位符文本
|
|
22
|
+
③b 色值合法性:所有 srgbClr val 必须是 6 位 hex(不带 # 前缀)——编码色板与图表系列色
|
|
23
|
+
由双引擎直写 OOXML,单源一旦带 # 就是非法色值(PowerPoint 静默忽略/触发修复)
|
|
24
|
+
→ INVALID_HEX_COLOR 硬拦
|
|
25
|
+
④ 内容保真(--model):页数结构、章节/封面/收尾标题落位、逐页内容覆盖(≥roundtripMin 关键串,
|
|
26
|
+
比对语料 = 幻灯片形状文本 ∪ 演讲者备注 ∪ 关联 chart part 文本——原生图表类别标签与 notes-only
|
|
27
|
+
字段不得被误判为内容丢失)、主题 token 亮暗一致、模型图表必须落成原生可编辑图表(MODEL_CHART_COUNT)、
|
|
28
|
+
图片源非外链
|
|
29
|
+
⑤ --strict:0 errors / 0 warnings 才通过(退出码 0);否则 1(errors)或 2(warnings)
|
|
30
|
+
|
|
31
|
+
零第三方依赖(纯标准库);python-pptx 交叉裁判见 scripts/cross_verify.py(可选)。
|
|
32
|
+
"""
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import argparse
|
|
36
|
+
import json
|
|
37
|
+
import re
|
|
38
|
+
import sys
|
|
39
|
+
import zipfile
|
|
40
|
+
from functools import lru_cache
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from typing import Any
|
|
43
|
+
|
|
44
|
+
# Windows GBK 控制台兜底:强制 UTF-8 输出
|
|
45
|
+
try:
|
|
46
|
+
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
|
47
|
+
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
|
|
48
|
+
except Exception:
|
|
49
|
+
pass
|
|
50
|
+
from xml.etree import ElementTree as ET
|
|
51
|
+
|
|
52
|
+
NS = {
|
|
53
|
+
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
|
|
54
|
+
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
|
|
55
|
+
"c": "http://schemas.openxmlformats.org/drawingml/2006/chart",
|
|
56
|
+
}
|
|
57
|
+
GLOBAL_MIN_FONT_PT = 6.5 # 全篇字体下限(低于此值投影/打印不可读)
|
|
58
|
+
LARGE_IMAGE_AREA_RATIO = 0.40 # 单图面积告警阈值(未声明的图片命中即告警)
|
|
59
|
+
FULL_SLIDE_IMAGE_RATIO = 0.90
|
|
60
|
+
TEXT_OVERFLOW_TOLERANCE = 1.18 # 文本溢出估算容差(18%,与 cross_verify 一致)
|
|
61
|
+
# 下两项只是读不到 layout-constants.json 时的兵底;真值走 containers.textMetrics(与 build_pptx.js 同源)
|
|
62
|
+
LINE_FACTOR = 1.45 # 行高系数
|
|
63
|
+
EM_ASCII_RATIO = 0.52 # 拉丁/数字相对 CJK 的字宽比
|
|
64
|
+
UNBALANCED_GAP_RATIO = 0.28 # 右/下留白告警阈值
|
|
65
|
+
MIN_EDGE_MARGIN_IN = 0.02 # 元素不得贴边/越界的安全边距(英寸)
|
|
66
|
+
|
|
67
|
+
# OOXML ST_HexColorRGB = 6 位 hex;# 前缀 / 3 位缩写 / 非 hex 一律非法(PowerPoint 会忽略或触发修复)
|
|
68
|
+
HEX_COLOR_RE = re.compile(r'srgbClr val="([^"]*)"')
|
|
69
|
+
HEX6_RE = re.compile(r"[0-9A-Fa-f]{6}")
|
|
70
|
+
|
|
71
|
+
# --strict 下升级为 error 的告警码(其余告警仍以 warning 计,同样阻止 strict 通过)
|
|
72
|
+
STRICT_FAILURE_CODES = {
|
|
73
|
+
"INVALID_SHAPE_BOUNDS",
|
|
74
|
+
"SHAPE_OUTSIDE_SLIDE",
|
|
75
|
+
"PICTURES_NOT_ALLOWED",
|
|
76
|
+
"PICTURES_NOT_DECLARED",
|
|
77
|
+
"PICTURES_UNDER_DECLARED",
|
|
78
|
+
"FULL_SLIDE_BACKGROUND_RISK",
|
|
79
|
+
"NO_NATIVE_TEXT_WITH_EDITABLE_TEXT_REQUIRED",
|
|
80
|
+
"FONT_SIZE_BELOW_FOOTER_MIN",
|
|
81
|
+
"MODEL_ROUNDTRIP_SLIDE_COUNT",
|
|
82
|
+
"MODEL_ROUNDTRIP_TITLE_MISSING",
|
|
83
|
+
"MODEL_ROUNDTRIP_COVER_TITLE",
|
|
84
|
+
"MODEL_ROUNDTRIP_CLOSING_TITLE",
|
|
85
|
+
"MODEL_ROUNDTRIP_CONTENT_MISSING",
|
|
86
|
+
"MODEL_CHART_COUNT",
|
|
87
|
+
"MODEL_CHART_DATATABLE",
|
|
88
|
+
"MODEL_CHART_NOTES_MISSING",
|
|
89
|
+
"FONT_SIZE_OFF_SCALE",
|
|
90
|
+
"MODEL_THEME_BG_MISMATCH",
|
|
91
|
+
"IMAGE_SRC_EXTERNAL",
|
|
92
|
+
# 容器级与表格级硬门禁(比页面级越界更严格)
|
|
93
|
+
"CONTAINER_OVERFLOW",
|
|
94
|
+
"TABLE_SEMANTIC_TYPE",
|
|
95
|
+
"TABLE_DENSITY",
|
|
96
|
+
# 素材图片模型门禁(版式/裁切/多图数量/文件缺失;外链 src 见 IMAGE_SRC_EXTERNAL)
|
|
97
|
+
"IMAGE_LAYOUT_INVALID",
|
|
98
|
+
"IMAGE_FIT_INVALID",
|
|
99
|
+
"IMAGE_ITEMS_TOO_MANY",
|
|
100
|
+
"IMAGE_SRC_MISSING",
|
|
101
|
+
# v9:截图级真缺陷纳入 strict(原 WARN/缺失会「假过」)
|
|
102
|
+
"HTML_TAG_IN_TEXT",
|
|
103
|
+
"TITLE_ONLY_PAGE",
|
|
104
|
+
"UNDERFILL_PAGE",
|
|
105
|
+
"LOW_TEXT_DENSITY",
|
|
106
|
+
"LOW_CONTENT_DENSITY",
|
|
107
|
+
"EMPTY_OR_UNMEASURABLE_SLIDE",
|
|
108
|
+
"UNBALANCED_EMPTY_SPACE",
|
|
109
|
+
"UNJUSTIFIED_LARGE_IMAGE",
|
|
110
|
+
"TEXT_OVERFLOW_ESTIMATE",
|
|
111
|
+
"TEXT_INCOMPLETE",
|
|
112
|
+
"CHART_SKEW_INVALID",
|
|
113
|
+
"CHART_OVERSIZE",
|
|
114
|
+
"LAYOUT_MULTI_FOCUS",
|
|
115
|
+
"LAYOUT_ALIGN_DRIFT",
|
|
116
|
+
"LAYOUT_LABEL_COLLIDE",
|
|
117
|
+
}
|
|
118
|
+
# 深度模式专属校验码(--deep 时并入 STRICT_FAILURE_CODES;默认不跑,保持轻量)
|
|
119
|
+
DEEP_STRICT_CODES = {
|
|
120
|
+
"CONTINUOUS_TEXT_FLOW",
|
|
121
|
+
"ANCHOR_TITLE_MISALIGNED",
|
|
122
|
+
}
|
|
123
|
+
PLACEHOLDER_RE = re.compile(
|
|
124
|
+
r"\b(?:TODO|TBD)\b|Lorem ipsum|Click to add|单击此处添加",
|
|
125
|
+
re.IGNORECASE,
|
|
126
|
+
)
|
|
127
|
+
SLIDE_RE = re.compile(r"ppt/slides/slide(\d+)\.xml$")
|
|
128
|
+
# 探针跳过的非「可见正文」键:色值/图标/链接/标识/类型/量程单位等元数据,
|
|
129
|
+
# 以及 image.hint/src/alt 这类素材声明或只写进备注的交付指引。
|
|
130
|
+
# chart.labels / note / lead / soWhat / footnote / centerLabel 等仍作探针——
|
|
131
|
+
# 它们的落点(形状文本 / 备注 / chart part)由 _roundtrip_corpus 并集覆盖。
|
|
132
|
+
_PROBE_SKIP_KEYS = {
|
|
133
|
+
"colors", "color", "icon", "href", "id", "type", "exhibitNo", "max", "unit",
|
|
134
|
+
"hint", "src", "alt", "fit", "layout", "placeholder", "ratio", "dataTable",
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def issue(code: str, message: str, *, slide: int | None = None) -> dict[str, Any]:
|
|
139
|
+
item: dict[str, Any] = {"code": code, "message": message}
|
|
140
|
+
if slide is not None:
|
|
141
|
+
item["slide"] = slide
|
|
142
|
+
return item
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def read_xml(archive: zipfile.ZipFile, name: str) -> ET.Element:
|
|
146
|
+
return ET.fromstring(archive.read(name))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def find_slide_names(archive: zipfile.ZipFile) -> list[str]:
|
|
150
|
+
matched: list[tuple[int, str]] = []
|
|
151
|
+
for name in archive.namelist():
|
|
152
|
+
result = SLIDE_RE.fullmatch(name)
|
|
153
|
+
if result:
|
|
154
|
+
matched.append((int(result.group(1)), name))
|
|
155
|
+
return [name for _, name in sorted(matched)]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _slide_rels(archive: zipfile.ZipFile, slide_name: str) -> list[str]:
|
|
159
|
+
"""该幻灯片 rels 里的 Target 列表(无 rels 返回空列表)。"""
|
|
160
|
+
base = slide_name.rsplit("/", 1)[-1]
|
|
161
|
+
rels_name = f"ppt/slides/_rels/{base}.rels"
|
|
162
|
+
try:
|
|
163
|
+
rels = read_xml(archive, rels_name)
|
|
164
|
+
except (KeyError, ET.ParseError):
|
|
165
|
+
return []
|
|
166
|
+
return [(rel.get("Target") or "") for rel in rels]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _resolve_slide_part(slide_name: str, target: str) -> str:
|
|
170
|
+
"""把 rels Target(常为 ../charts/chart1.xml)解析成包内绝对 part 路径。"""
|
|
171
|
+
from posixpath import dirname, normpath, join
|
|
172
|
+
return normpath(join(dirname(slide_name), target))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def slide_notes_text(archive: zipfile.ZipFile, slide_name: str) -> str:
|
|
176
|
+
"""取该页演讲者备注的纯文本(经 slide rels 定位 notesSlide;无备注返回空串)。"""
|
|
177
|
+
target = next((t for t in _slide_rels(archive, slide_name) if "notesSlide" in t), "")
|
|
178
|
+
if not target:
|
|
179
|
+
return ""
|
|
180
|
+
part = _resolve_slide_part(slide_name, target)
|
|
181
|
+
try:
|
|
182
|
+
return text_content(read_xml(archive, part))
|
|
183
|
+
except (KeyError, ET.ParseError):
|
|
184
|
+
return ""
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def slide_chart_text(archive: zipfile.ZipFile, slide_name: str) -> str:
|
|
188
|
+
"""取该页关联 chart part 的文本(类别/系列/缓存值)。
|
|
189
|
+
|
|
190
|
+
原生数据图表的 labels 落在 ppt/charts/chartN.xml 的 c:pt/c:v,不在幻灯片
|
|
191
|
+
形状 a:t 里——往返保真比对必须把 chart part 并入语料,否则类别标签必然 MISS。
|
|
192
|
+
"""
|
|
193
|
+
chunks: list[str] = []
|
|
194
|
+
for target in _slide_rels(archive, slide_name):
|
|
195
|
+
if "charts/chart" not in target.replace("\\", "/"):
|
|
196
|
+
continue
|
|
197
|
+
part = _resolve_slide_part(slide_name, target)
|
|
198
|
+
try:
|
|
199
|
+
root = read_xml(archive, part)
|
|
200
|
+
except (KeyError, ET.ParseError):
|
|
201
|
+
continue
|
|
202
|
+
chunks.append(text_content(root))
|
|
203
|
+
for node in root.findall(".//c:v", NS):
|
|
204
|
+
if node.text and node.text.strip():
|
|
205
|
+
chunks.append(node.text.strip())
|
|
206
|
+
return " ".join(chunks).strip()
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def shape_bounds(element: ET.Element) -> tuple[int, int, int, int] | None:
|
|
210
|
+
xfrm = element.find(".//a:xfrm", NS)
|
|
211
|
+
if xfrm is None:
|
|
212
|
+
return None
|
|
213
|
+
offset = xfrm.find("a:off", NS)
|
|
214
|
+
extent = xfrm.find("a:ext", NS)
|
|
215
|
+
if offset is None or extent is None:
|
|
216
|
+
return None
|
|
217
|
+
try:
|
|
218
|
+
return (
|
|
219
|
+
int(offset.get("x", "0")),
|
|
220
|
+
int(offset.get("y", "0")),
|
|
221
|
+
int(extent.get("cx", "0")),
|
|
222
|
+
int(extent.get("cy", "0")),
|
|
223
|
+
)
|
|
224
|
+
except ValueError:
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def text_content(element: ET.Element) -> str:
|
|
229
|
+
return " ".join((node.text or "") for node in element.findall(".//a:t", NS)).strip()
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _all_run_sizes(root: ET.Element) -> list[float]:
|
|
233
|
+
"""页内所有非空文本 run 的字号(pt)——只取真正承载文字的 run,避免空占位样式干扰。"""
|
|
234
|
+
out: list[float] = []
|
|
235
|
+
for run in root.findall(".//a:r", NS):
|
|
236
|
+
text = "".join(t.text or "" for t in run.findall("a:t", NS))
|
|
237
|
+
if not text.strip():
|
|
238
|
+
continue
|
|
239
|
+
pr = run.find("a:rPr", NS)
|
|
240
|
+
raw = pr.get("sz") if pr is not None else None
|
|
241
|
+
if raw is None:
|
|
242
|
+
continue
|
|
243
|
+
try:
|
|
244
|
+
out.append(int(raw) / 100.0)
|
|
245
|
+
except (TypeError, ValueError):
|
|
246
|
+
continue
|
|
247
|
+
return out
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def font_sizes_pt(element: ET.Element) -> list[float]:
|
|
251
|
+
sizes: list[float] = []
|
|
252
|
+
for node in element.findall(".//a:rPr", NS) + element.findall(".//a:defRPr", NS):
|
|
253
|
+
raw_size = node.get("sz")
|
|
254
|
+
if raw_size is None:
|
|
255
|
+
continue
|
|
256
|
+
try:
|
|
257
|
+
size = int(raw_size) / 100
|
|
258
|
+
except ValueError:
|
|
259
|
+
continue
|
|
260
|
+
if size > 0:
|
|
261
|
+
sizes.append(size)
|
|
262
|
+
return sizes
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def est_text_width_in(text: str, font_pt: float) -> float:
|
|
266
|
+
"""估算文本宽度(英寸):CJK 1em、ASCII emAsciiRatio(与 build_pptx.js estLines 同口径)。"""
|
|
267
|
+
ratio = _containers_cfg()["emAsciiRatio"]
|
|
268
|
+
em = sum(1.0 if ord(c) > 0x2E80 else ratio for c in text)
|
|
269
|
+
return em * font_pt / 72.0
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def text_overflow_check(shape: ET.Element, slide_no: int) -> list[dict[str, Any]]:
|
|
273
|
+
"""文本溢出估算:逐段估宽 × 可容行数 vs 文本框高度。"""
|
|
274
|
+
out: list[dict[str, Any]] = []
|
|
275
|
+
box = shape_bounds(shape)
|
|
276
|
+
if box is None:
|
|
277
|
+
return out
|
|
278
|
+
_, _, cx, cy = box
|
|
279
|
+
w_in, h_in = cx / 914400, cy / 914400
|
|
280
|
+
if w_in <= 0 or h_in <= 0:
|
|
281
|
+
return out
|
|
282
|
+
lf = _containers_cfg()["lineFactor"]
|
|
283
|
+
for para in shape.findall(".//a:p", NS):
|
|
284
|
+
line = "".join((t.text or "") for t in para.findall(".//a:t", NS))
|
|
285
|
+
if not line.strip():
|
|
286
|
+
continue
|
|
287
|
+
sizes = font_sizes_pt(para)
|
|
288
|
+
fz = min(sizes) if sizes else 12.0
|
|
289
|
+
est_w = est_text_width_in(line, fz)
|
|
290
|
+
lines_avail = max(1, int(h_in / (fz / 72.0 * lf)))
|
|
291
|
+
capacity = w_in * lines_avail
|
|
292
|
+
if est_w > capacity * TEXT_OVERFLOW_TOLERANCE:
|
|
293
|
+
out.append(issue(
|
|
294
|
+
"TEXT_OVERFLOW_ESTIMATE",
|
|
295
|
+
f'文本可能溢出:"{line[:18]}…" 估算 {est_w:.1f}in > 容量 {capacity:.1f}in({fz:.1f}pt × {lines_avail} 行)。',
|
|
296
|
+
slide=slide_no,
|
|
297
|
+
))
|
|
298
|
+
return out
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@lru_cache(maxsize=1)
|
|
302
|
+
def _containers_cfg() -> dict[str, Any]:
|
|
303
|
+
"""容器内边距与锚点容差(单源 scripts/layout-constants.json 的 containers / anchorTolerance)。"""
|
|
304
|
+
lc_path = Path(__file__).resolve().parent / "layout-constants.json"
|
|
305
|
+
try:
|
|
306
|
+
lc = json.loads(lc_path.read_text(encoding="utf-8"))
|
|
307
|
+
except (OSError, json.JSONDecodeError):
|
|
308
|
+
return {"pad": {}, "minPad": 0.06, "anchorKeyPx": 6, "fontShrink": {},
|
|
309
|
+
"lineFactor": LINE_FACTOR, "emAsciiRatio": EM_ASCII_RATIO}
|
|
310
|
+
cont = lc.get("containers") or {}
|
|
311
|
+
anch = lc.get("anchorTolerance") or {}
|
|
312
|
+
tm = cont.get("textMetrics") or {}
|
|
313
|
+
return {
|
|
314
|
+
"pad": cont.get("pad") or {},
|
|
315
|
+
"minPad": float(cont.get("minPad") or 0.06),
|
|
316
|
+
"anchorKeyPx": float(anch.get("keyPx") or 6),
|
|
317
|
+
"fontShrink": cont.get("fontShrink") or {},
|
|
318
|
+
"overflowRule": cont.get("overflowRule") or "",
|
|
319
|
+
"lineFactor": float(tm.get("lineFactor") or LINE_FACTOR),
|
|
320
|
+
"emAsciiRatio": float(tm.get("emAsciiRatio") or EM_ASCII_RATIO),
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _shape_object_name(shape: ET.Element) -> str:
|
|
325
|
+
el = shape.find(".//p:cNvPr", NS)
|
|
326
|
+
return (el.get("name") if el is not None else "") or ""
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _infer_container_pad(shape: ET.Element, pad_map: dict[str, float], min_pad: float) -> tuple[float, str]:
|
|
330
|
+
"""推断容器类型,返回 (pad_in, kind)。
|
|
331
|
+
|
|
332
|
+
优先读引擎写入的 objectName(`tr:<kind>`,见 build_pptx.js trName);
|
|
333
|
+
未标注时回落 minPad——文本框坐标通常已由引擎 inset,再按 card/panel 大 pad
|
|
334
|
+
扣会双重计算,导致误报。仅在显式标注 soWhat/band/card/… 时才用对应 pad。
|
|
335
|
+
"""
|
|
336
|
+
name = _shape_object_name(shape)
|
|
337
|
+
kind = ""
|
|
338
|
+
if name.startswith("tr:"):
|
|
339
|
+
kind = name[3:].split("|")[0].strip()
|
|
340
|
+
if kind and kind in pad_map:
|
|
341
|
+
return float(pad_map[kind]), kind
|
|
342
|
+
if kind:
|
|
343
|
+
return min_pad, kind
|
|
344
|
+
# 启发式兜底:仅对明确的 so-what / 待核实文本用对应 pad(其余 minPad)
|
|
345
|
+
text = text_content(shape)
|
|
346
|
+
if "SO WHAT" in text:
|
|
347
|
+
return float(pad_map.get("soWhat") or min_pad), "soWhat"
|
|
348
|
+
if "待核实" in text:
|
|
349
|
+
return float(pad_map.get("band") or min_pad), "band"
|
|
350
|
+
return min_pad, "text"
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def container_overflow_check(shape: ET.Element, slide_no: int) -> list[dict[str, Any]]:
|
|
354
|
+
"""容器级溢出:文字不得越过**归属容器**边界——即使没超出页面画布。
|
|
355
|
+
|
|
356
|
+
容器类型优先来自引擎 objectName(tr:<kind>);无标注时按 minPad 安全边距判定。
|
|
357
|
+
溢出处置:优先优化内容(列表化/精炼/拆页/换组合),其次调整容器,最后才是有限缩字号。
|
|
358
|
+
"""
|
|
359
|
+
out: list[dict[str, Any]] = []
|
|
360
|
+
cfg = _containers_cfg()
|
|
361
|
+
pad_map = cfg["pad"] or {}
|
|
362
|
+
min_pad = cfg["minPad"]
|
|
363
|
+
pad, kind = _infer_container_pad(shape, pad_map, min_pad)
|
|
364
|
+
box = shape_bounds(shape)
|
|
365
|
+
if box is None:
|
|
366
|
+
return out
|
|
367
|
+
_, _, cx, cy = box
|
|
368
|
+
w_in = cx / 914400 - 2 * pad
|
|
369
|
+
h_in = cy / 914400 - 2 * pad
|
|
370
|
+
if w_in <= 0.05 or h_in <= 0.02:
|
|
371
|
+
return out
|
|
372
|
+
lf = cfg["lineFactor"]
|
|
373
|
+
for para in shape.findall(".//a:p", NS):
|
|
374
|
+
line = "".join((t.text or "") for t in para.findall(".//a:t", NS))
|
|
375
|
+
if not line.strip():
|
|
376
|
+
continue
|
|
377
|
+
sizes = font_sizes_pt(para)
|
|
378
|
+
fz = min(sizes) if sizes else 12.0
|
|
379
|
+
est_w = est_text_width_in(line, fz)
|
|
380
|
+
lines_avail = max(1, int(h_in / (fz / 72.0 * lf)))
|
|
381
|
+
capacity = w_in * lines_avail
|
|
382
|
+
if est_w > capacity * TEXT_OVERFLOW_TOLERANCE:
|
|
383
|
+
out.append(issue(
|
|
384
|
+
"CONTAINER_OVERFLOW",
|
|
385
|
+
f'文本越过归属容器({kind},已扣内边距 {pad:.2f}in):"{line[:18]}…" '
|
|
386
|
+
f'估算 {est_w:.1f}in > 容器可用 {capacity:.1f}in。'
|
|
387
|
+
f'处置:①列表化/精炼 ②调容器/换行/拆页 ③有限缩字号(floor 见 containers.fontShrink)。',
|
|
388
|
+
slide=slide_no,
|
|
389
|
+
))
|
|
390
|
+
return out
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def table_checks(table: ET.Element, slide_no: int) -> list[dict[str, Any]]:
|
|
394
|
+
"""表格语义字号与密度。
|
|
395
|
+
|
|
396
|
+
- TABLE_SEMANTIC_TYPE:句子型单元格(≥12 字)不得用 micro 档字号(≤9.5pt)。
|
|
397
|
+
- TABLE_DENSITY:单元格大面积空洞(空单元格占比 >0.6 且总数 ≥12)——字号没低于下限但版面塌陷。
|
|
398
|
+
"""
|
|
399
|
+
out: list[dict[str, Any]] = []
|
|
400
|
+
cells = table.findall(".//a:tc", NS)
|
|
401
|
+
if not cells:
|
|
402
|
+
return out
|
|
403
|
+
empty = 0
|
|
404
|
+
for cell in cells:
|
|
405
|
+
text = text_content(cell).strip()
|
|
406
|
+
if not text:
|
|
407
|
+
empty += 1
|
|
408
|
+
continue
|
|
409
|
+
sizes = font_sizes_pt(cell)
|
|
410
|
+
if sizes and len(text) >= 12 and min(sizes) <= 9.5:
|
|
411
|
+
out.append(issue(
|
|
412
|
+
"TABLE_SEMANTIC_TYPE",
|
|
413
|
+
f'表格句子型内容({len(text)} 字)用了 micro 档字号 {min(sizes):.1f}pt:'
|
|
414
|
+
"表格正文/行动项/解释句必须 T7/T10(body 档),T11 仅限轴标签/单位/列短标签。",
|
|
415
|
+
slide=slide_no,
|
|
416
|
+
))
|
|
417
|
+
if len(cells) >= 12 and empty / len(cells) > 0.6:
|
|
418
|
+
out.append(issue(
|
|
419
|
+
"TABLE_DENSITY",
|
|
420
|
+
f"表格 {len(cells)} 个单元格中 {empty} 个为空({empty / len(cells):.0%})——"
|
|
421
|
+
"表格密度与内容不匹配(阅读重心塌陷);请精简行列或换承载形态。",
|
|
422
|
+
slide=slide_no,
|
|
423
|
+
))
|
|
424
|
+
return out
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def continuous_text_flow_check(shapes: list[ET.Element], slide_no: int) -> list[dict[str, Any]]:
|
|
428
|
+
"""连续文本流(深度模式):语义连续句不得拆成多个独立文本框。
|
|
429
|
+
|
|
430
|
+
启发式:同一水平带上紧邻(间距 <0.06in)的两个文本框,前框以非句末标点结尾且后框以
|
|
431
|
+
非标点/非数字开头、字号相同 → 判定为可能被拆分的连续句(应改用同一文本框内富文本)。
|
|
432
|
+
"""
|
|
433
|
+
out: list[dict[str, Any]] = []
|
|
434
|
+
items = []
|
|
435
|
+
for shape in shapes:
|
|
436
|
+
text = text_content(shape)
|
|
437
|
+
box = shape_bounds(shape)
|
|
438
|
+
if not text or box is None:
|
|
439
|
+
continue
|
|
440
|
+
sizes = font_sizes_pt(shape)
|
|
441
|
+
items.append((box, text, (min(sizes) if sizes else 12.0)))
|
|
442
|
+
tail_re = re.compile(r"[^\s。!?;:!?;:))】」”\"]$")
|
|
443
|
+
head_re = re.compile(r"^[^,。!?;:、,.;:!?))】」”\"0-9]")
|
|
444
|
+
# 只判「句」不判「标签」:两侧都必须是句长文本(轴标签/图例/矩阵行头一律排除)
|
|
445
|
+
MIN_SENTENCE_CHARS = 12
|
|
446
|
+
for i, (box_a, text_a, sz_a) in enumerate(items):
|
|
447
|
+
if len(text_a) < MIN_SENTENCE_CHARS:
|
|
448
|
+
continue
|
|
449
|
+
ax, ay, aw, ah = box_a
|
|
450
|
+
for box_b, text_b, sz_b in items[i + 1:]:
|
|
451
|
+
if len(text_b) < MIN_SENTENCE_CHARS:
|
|
452
|
+
continue
|
|
453
|
+
bx, by, bw, bh = box_b
|
|
454
|
+
if abs(sz_a - sz_b) > 0.6:
|
|
455
|
+
continue
|
|
456
|
+
# 同一水平带(纵向重叠 >50%)且后框紧跟在右
|
|
457
|
+
overlap = min(ay + ah, by + bh) - max(ay, by)
|
|
458
|
+
if overlap < min(ah, bh) * 0.5:
|
|
459
|
+
continue
|
|
460
|
+
gap = bx - (ax + aw)
|
|
461
|
+
if -0.02 * 914400 < gap < 0.06 * 914400:
|
|
462
|
+
if tail_re.search(text_a) and head_re.match(text_b):
|
|
463
|
+
out.append(issue(
|
|
464
|
+
"CONTINUOUS_TEXT_FLOW",
|
|
465
|
+
f'疑似拆分连续句:"{text_a[-10:]}" + "{text_b[:10]}"(同一水平带紧邻的两个文本框)——'
|
|
466
|
+
"语义连续句应放在同一文本框内用富文本高亮。",
|
|
467
|
+
slide=slide_no,
|
|
468
|
+
))
|
|
469
|
+
break
|
|
470
|
+
return out
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def skill_version() -> str:
|
|
474
|
+
"""技能版本(单源 layout-constants.json 的 version)——manifest 不再手写版本号。"""
|
|
475
|
+
lc_path = Path(__file__).resolve().parent / "layout-constants.json"
|
|
476
|
+
try:
|
|
477
|
+
lc = json.loads(lc_path.read_text(encoding="utf-8"))
|
|
478
|
+
return str(lc.get("version") or "0.0")
|
|
479
|
+
except (OSError, json.JSONDecodeError):
|
|
480
|
+
return "0.0"
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def page_margin_in() -> float:
|
|
484
|
+
"""版心左边界(英寸,layout-constants.json 的 page.mx)。"""
|
|
485
|
+
lc_path = Path(__file__).resolve().parent / "layout-constants.json"
|
|
486
|
+
try:
|
|
487
|
+
lc = json.loads(lc_path.read_text(encoding="utf-8"))
|
|
488
|
+
return float((lc.get("page") or {}).get("mx") or 0.6)
|
|
489
|
+
except (OSError, json.JSONDecodeError):
|
|
490
|
+
return 0.6
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _image_spec() -> dict[str, Any]:
|
|
494
|
+
"""素材图片规格与配图占位约定(单源 layout-constants.json 的 imageSpec)。"""
|
|
495
|
+
lc_path = Path(__file__).resolve().parent / "layout-constants.json"
|
|
496
|
+
try:
|
|
497
|
+
lc = json.loads(lc_path.read_text(encoding="utf-8"))
|
|
498
|
+
spec = lc.get("imageSpec") or {}
|
|
499
|
+
return spec if isinstance(spec, dict) else {}
|
|
500
|
+
except (OSError, json.JSONDecodeError):
|
|
501
|
+
return {}
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def title_anchor_info(shapes: list[ET.Element]) -> dict[str, Any] | None:
|
|
505
|
+
"""页头标题锚点信息(最大字号的靠上文本块):用于锚点检查与 manifest 登记。"""
|
|
506
|
+
best = None
|
|
507
|
+
for shape in shapes:
|
|
508
|
+
text = text_content(shape)
|
|
509
|
+
box = shape_bounds(shape)
|
|
510
|
+
if not text or box is None or len(text) < 6:
|
|
511
|
+
continue
|
|
512
|
+
sizes = font_sizes_pt(shape)
|
|
513
|
+
fz = max(sizes) if sizes else 0.0
|
|
514
|
+
if fz < 18: # 只锚定页头标题(大字号)
|
|
515
|
+
continue
|
|
516
|
+
if best is None or box[1] < best[0][1]:
|
|
517
|
+
best = (box, text, fz)
|
|
518
|
+
if best is None:
|
|
519
|
+
return None
|
|
520
|
+
(bx, by, bw, bh), text, fz = best
|
|
521
|
+
mx = page_margin_in()
|
|
522
|
+
return {
|
|
523
|
+
"text": text[:40],
|
|
524
|
+
"left_in": round(bx / 914400, 4),
|
|
525
|
+
"top_in": round(by / 914400, 4),
|
|
526
|
+
"font_pt": fz,
|
|
527
|
+
"expected_left_in": mx,
|
|
528
|
+
"delta_in": round(bx / 914400 - mx, 4),
|
|
529
|
+
"tolerance_in": round(_containers_cfg()["anchorKeyPx"] / 96, 4),
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def chrome_footer_y_in(shapes: list[ET.Element], height: int) -> float | None:
|
|
535
|
+
"""页码 chrome y(英寸):只认页码形文本(如 3 / 14),避免把 so-what/图注当页脚。"""
|
|
536
|
+
zones = ((json.loads(Path(__file__).with_name("layout-constants.json").read_text(encoding="utf-8"))
|
|
537
|
+
.get("layoutSystem") or {}).get("zones") or {})
|
|
538
|
+
chrome = zones.get("chromePct") or [18, 24]
|
|
539
|
+
bottom_frac = float(chrome[1] if isinstance(chrome, list) and len(chrome) > 1 else 24) / 100.0
|
|
540
|
+
threshold = int(height * (1.0 - bottom_frac))
|
|
541
|
+
ys: list[float] = []
|
|
542
|
+
pager = re.compile(r"^\s*\d+\s*/\s*\d+\s*$|^\s*\d+\s*$")
|
|
543
|
+
for shape in shapes:
|
|
544
|
+
text = (text_content(shape) or "").strip()
|
|
545
|
+
box = shape_bounds(shape)
|
|
546
|
+
if not text or box is None:
|
|
547
|
+
continue
|
|
548
|
+
_x, y, _cx, _cy = box
|
|
549
|
+
if y < threshold:
|
|
550
|
+
continue
|
|
551
|
+
if not pager.match(text):
|
|
552
|
+
continue
|
|
553
|
+
sizes = font_sizes_pt(shape)
|
|
554
|
+
if sizes and max(sizes) > 14:
|
|
555
|
+
continue
|
|
556
|
+
ys.append(y / 914400.0)
|
|
557
|
+
if not ys:
|
|
558
|
+
return None
|
|
559
|
+
return round(sum(ys) / len(ys), 4)
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def anchor_check(shapes: list[ET.Element], slide_no: int, width: int) -> list[dict[str, Any]]:
|
|
563
|
+
"""空间锚点注册(深度模式):标题/页头块的左边距必须对齐版心左边界。
|
|
564
|
+
|
|
565
|
+
容差取自 layout-constants.json 的 anchorTolerance.keyPx(默认 6px @96dpi ≈ 0.0625in);
|
|
566
|
+
这是"相对位置还原"的确定性代理——不需要参考图即可复现。
|
|
567
|
+
"""
|
|
568
|
+
out: list[dict[str, Any]] = []
|
|
569
|
+
info = title_anchor_info(shapes)
|
|
570
|
+
if info is None:
|
|
571
|
+
return out
|
|
572
|
+
if abs(info["delta_in"]) > info["tolerance_in"]:
|
|
573
|
+
out.append(issue(
|
|
574
|
+
"ANCHOR_TITLE_MISALIGNED",
|
|
575
|
+
f'页头标题左边距 {info["left_in"]:.3f}in 偏离版心左边界 {info["expected_left_in"]:.3f}in'
|
|
576
|
+
f'(偏差 {info["delta_in"]:+.3f}in > 容差 {info["tolerance_in"]:.3f}in):"{info["text"][:16]}…"',
|
|
577
|
+
slide=slide_no,
|
|
578
|
+
))
|
|
579
|
+
return out
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def inspect_slide(
|
|
583
|
+
root: ET.Element,
|
|
584
|
+
slide_number: int,
|
|
585
|
+
width: int,
|
|
586
|
+
height: int,
|
|
587
|
+
deep: bool = False,
|
|
588
|
+
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
589
|
+
warnings: list[dict[str, Any]] = []
|
|
590
|
+
shapes = root.findall(".//p:sp", NS)
|
|
591
|
+
pictures = root.findall(".//p:pic", NS)
|
|
592
|
+
graphic_frames = root.findall(".//p:graphicFrame", NS)
|
|
593
|
+
charts = root.findall(".//c:chart", NS)
|
|
594
|
+
tables = root.findall(".//a:tbl", NS)
|
|
595
|
+
|
|
596
|
+
native_text_shapes = sum(1 for shape in shapes if text_content(shape))
|
|
597
|
+
all_elements = [*shapes, *pictures, *graphic_frames]
|
|
598
|
+
bounds: list[tuple[int, int, int, int]] = []
|
|
599
|
+
slide_area = max(width * height, 1)
|
|
600
|
+
font_sizes = [size for shape in shapes for size in font_sizes_pt(shape)]
|
|
601
|
+
picture_area_ratios: list[float] = []
|
|
602
|
+
margin_emu = int(MIN_EDGE_MARGIN_IN * 914400)
|
|
603
|
+
|
|
604
|
+
for element in all_elements:
|
|
605
|
+
box = shape_bounds(element)
|
|
606
|
+
if box is None:
|
|
607
|
+
continue
|
|
608
|
+
bounds.append(box)
|
|
609
|
+
x, y, cx, cy = box
|
|
610
|
+
if cx <= 0 or cy <= 0 or x < 0 or y < 0:
|
|
611
|
+
warnings.append(
|
|
612
|
+
issue(
|
|
613
|
+
"INVALID_SHAPE_BOUNDS",
|
|
614
|
+
"元素尺寸非正或坐标为负;PowerPoint 可能判为损坏文件。",
|
|
615
|
+
slide=slide_number,
|
|
616
|
+
)
|
|
617
|
+
)
|
|
618
|
+
# 越界判定带安全边距:贴边/超出画布都会在导出/打印时被裁切
|
|
619
|
+
if x < -margin_emu or y < -margin_emu or \
|
|
620
|
+
x + cx > width + margin_emu or y + cy > height + margin_emu:
|
|
621
|
+
warnings.append(
|
|
622
|
+
issue(
|
|
623
|
+
"SHAPE_OUTSIDE_SLIDE",
|
|
624
|
+
f"元素超出 {width // 914400}×{height // 914400}in 画布(每页高度稳定的硬约束:内容不得越界)。",
|
|
625
|
+
slide=slide_number,
|
|
626
|
+
)
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
for shape in shapes:
|
|
630
|
+
warnings.extend(text_overflow_check(shape, slide_number))
|
|
631
|
+
warnings.extend(container_overflow_check(shape, slide_number))
|
|
632
|
+
|
|
633
|
+
for table in tables:
|
|
634
|
+
warnings.extend(table_checks(table, slide_number))
|
|
635
|
+
|
|
636
|
+
if deep:
|
|
637
|
+
warnings.extend(continuous_text_flow_check(shapes, slide_number))
|
|
638
|
+
warnings.extend(anchor_check(shapes, slide_number, width))
|
|
639
|
+
|
|
640
|
+
combined_text = " ".join(filter(None, (text_content(shape) for shape in shapes)))
|
|
641
|
+
if PLACEHOLDER_RE.search(combined_text):
|
|
642
|
+
warnings.append(
|
|
643
|
+
issue("PLACEHOLDER_TEXT", "页面上残留作者占位符文本(TODO/TBD/Click to add 等)。",
|
|
644
|
+
slide=slide_number)
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
for picture in pictures:
|
|
648
|
+
box = shape_bounds(picture)
|
|
649
|
+
if box is None:
|
|
650
|
+
continue
|
|
651
|
+
_, _, cx, cy = box
|
|
652
|
+
ratio = max(0.0, (cx * cy) / slide_area)
|
|
653
|
+
picture_area_ratios.append(ratio)
|
|
654
|
+
if ratio >= FULL_SLIDE_IMAGE_RATIO:
|
|
655
|
+
warnings.append(
|
|
656
|
+
issue("FULL_SLIDE_BACKGROUND_RISK",
|
|
657
|
+
"单张图片覆盖 ≥90% 页面(整页底图风险;TopPPT HTML 交付基线为全原生可编辑)。",
|
|
658
|
+
slide=slide_number)
|
|
659
|
+
)
|
|
660
|
+
elif ratio >= LARGE_IMAGE_AREA_RATIO:
|
|
661
|
+
warnings.append(
|
|
662
|
+
issue("UNJUSTIFIED_LARGE_IMAGE",
|
|
663
|
+
"单张图片覆盖 ≥40% 页面(TopPPT HTML 要求零图片,请改为原生形状/图表)。",
|
|
664
|
+
slide=slide_number)
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
total_picture_area_ratio = round(min(sum(picture_area_ratios), 1.0), 4)
|
|
668
|
+
max_picture_area_ratio = round(max(picture_area_ratios, default=0.0), 4)
|
|
669
|
+
|
|
670
|
+
if font_sizes:
|
|
671
|
+
min_font_size = min(font_sizes)
|
|
672
|
+
max_font_size = max(font_sizes)
|
|
673
|
+
if min_font_size < GLOBAL_MIN_FONT_PT:
|
|
674
|
+
warnings.append(
|
|
675
|
+
issue("FONT_SIZE_BELOW_FOOTER_MIN",
|
|
676
|
+
f"存在 {min_font_size:.1f}pt 文本;全篇下限 {GLOBAL_MIN_FONT_PT:.1f}pt。",
|
|
677
|
+
slide=slide_number)
|
|
678
|
+
)
|
|
679
|
+
else:
|
|
680
|
+
min_font_size = None
|
|
681
|
+
max_font_size = None
|
|
682
|
+
|
|
683
|
+
if bounds:
|
|
684
|
+
left = min(x for x, _, _, _ in bounds)
|
|
685
|
+
top = min(y for _, y, _, _ in bounds)
|
|
686
|
+
right = max(x + cx for x, _, cx, _ in bounds)
|
|
687
|
+
bottom = max(y + cy for _, y, _, cy in bounds)
|
|
688
|
+
coverage = max(0.0, min(1.0, ((right - left) * (bottom - top)) / slide_area))
|
|
689
|
+
right_gap = max(0, width - right) / max(width, 1)
|
|
690
|
+
bottom_gap = max(0, height - bottom) / max(height, 1)
|
|
691
|
+
# v9:任一轴留白过大即失衡(原「右+下同时」过严,漏掉上重下空等截图问题)
|
|
692
|
+
if (right_gap > UNBALANCED_GAP_RATIO or bottom_gap > UNBALANCED_GAP_RATIO) and len(all_elements) >= 2:
|
|
693
|
+
warnings.append(
|
|
694
|
+
issue("UNBALANCED_EMPTY_SPACE",
|
|
695
|
+
f"留白失衡(右 {right_gap:.0%} / 下 {bottom_gap:.0%},阈值 {UNBALANCED_GAP_RATIO:.0%})。",
|
|
696
|
+
slide=slide_number)
|
|
697
|
+
)
|
|
698
|
+
else:
|
|
699
|
+
coverage = 0.0
|
|
700
|
+
warnings.append(
|
|
701
|
+
issue("EMPTY_OR_UNMEASURABLE_SLIDE", "页面没有任何可测量的原生元素。", slide=slide_number)
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
if len(all_elements) <= 1 and not pictures:
|
|
705
|
+
warnings.append(
|
|
706
|
+
issue("LOW_CONTENT_DENSITY", "页面元素 ≤1 个;检查信息密度。", slide=slide_number)
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
if native_text_shapes >= 2 and len(combined_text) < 25 and not pictures:
|
|
710
|
+
warnings.append(
|
|
711
|
+
issue("LOW_TEXT_DENSITY", "多个文本框但总字符 <25;信息密度不足。", slide=slide_number)
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
if re.search(r"EXHIBIT\s*\d", combined_text, re.IGNORECASE) and "SO WHAT" not in combined_text:
|
|
715
|
+
warnings.append(
|
|
716
|
+
issue("EXHIBIT_PAGE_MISSING_SOWHAT",
|
|
717
|
+
"页面带 Exhibit 编号但没有 'SO WHAT' 结论条(research R2 版式)。",
|
|
718
|
+
slide=slide_number)
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
# v9:HTML 标签源码泄漏进 PPTX 文本(模型字段未净化时原样露出)
|
|
722
|
+
tag_leaks = re.findall(
|
|
723
|
+
r"</?(?:a|strong|span|div|em|p)\b[^>]{0,40}|class=[\"']cite[\"']|href=",
|
|
724
|
+
combined_text, re.IGNORECASE)
|
|
725
|
+
if tag_leaks:
|
|
726
|
+
warnings.append(
|
|
727
|
+
issue("HTML_TAG_IN_TEXT",
|
|
728
|
+
f"文本含 HTML 标签源码泄漏 {tag_leaks[:3]}(模型字段须纯文本,引用写 [n])。",
|
|
729
|
+
slide=slide_number)
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
# v9:标题空页(去页码/页眉后几乎无正文)
|
|
733
|
+
body_chars = len(re.sub(r"\s+", "", combined_text))
|
|
734
|
+
# 页码形如 "3 / 22" 或纯数字
|
|
735
|
+
body_wo_pager = re.sub(r"\b\d+\s*/\s*\d+\b", "", combined_text)
|
|
736
|
+
body_wo_pager = re.sub(r"^\s*\d+\s*$", "", body_wo_pager, flags=re.M)
|
|
737
|
+
if body_chars < 40 and native_text_shapes <= 2 and not pictures and not tables and not charts:
|
|
738
|
+
warnings.append(
|
|
739
|
+
issue("TITLE_ONLY_PAGE",
|
|
740
|
+
f"页面可测文本仅 {body_chars} 字且无图/表/图表(疑似只有标题)。",
|
|
741
|
+
slide=slide_number)
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
metrics = {
|
|
745
|
+
"slide": slide_number,
|
|
746
|
+
"native_text_shapes": native_text_shapes,
|
|
747
|
+
"native_graphic_shapes": len(shapes) + len(graphic_frames),
|
|
748
|
+
"pictures": len(pictures),
|
|
749
|
+
"charts": len(charts),
|
|
750
|
+
"tables": len(tables),
|
|
751
|
+
"element_count": len(all_elements),
|
|
752
|
+
"coverage_ratio": round(coverage, 4),
|
|
753
|
+
"picture_area_ratio": total_picture_area_ratio,
|
|
754
|
+
"max_picture_area_ratio": max_picture_area_ratio,
|
|
755
|
+
"min_font_size_pt": round(min_font_size, 2) if min_font_size is not None else None,
|
|
756
|
+
"max_font_size_pt": round(max_font_size, 2) if max_font_size is not None else None,
|
|
757
|
+
"font_sizes_pt": sorted({round(v, 2) for v in _all_run_sizes(root)}),
|
|
758
|
+
"text_characters": len(combined_text),
|
|
759
|
+
"anchor": title_anchor_info(shapes),
|
|
760
|
+
"chrome_footer_y_in": chrome_footer_y_in(shapes, height),
|
|
761
|
+
}
|
|
762
|
+
return metrics, warnings
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def _norm_text(value: str) -> str:
|
|
766
|
+
"""比较前去除所有空白(PPTX 文本跨 run 拼接可能插空格/换行)。"""
|
|
767
|
+
return "".join((value or "").split())
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _model_probe_strings(section: dict[str, Any], limit: int = 12) -> list[str]:
|
|
771
|
+
"""高保真探针:抽取 section 的代表性文本串(≥6 字,剔除色值等非渲染字段值)。"""
|
|
772
|
+
probes: list[str] = []
|
|
773
|
+
|
|
774
|
+
def walk(node: Any) -> None:
|
|
775
|
+
if len(probes) >= limit:
|
|
776
|
+
return
|
|
777
|
+
if isinstance(node, dict):
|
|
778
|
+
for key, child in node.items():
|
|
779
|
+
if key in _PROBE_SKIP_KEYS:
|
|
780
|
+
continue
|
|
781
|
+
walk(child)
|
|
782
|
+
elif isinstance(node, list):
|
|
783
|
+
for child in node:
|
|
784
|
+
walk(child)
|
|
785
|
+
elif isinstance(node, str):
|
|
786
|
+
text = node.strip()
|
|
787
|
+
if len(text) >= 6 and not text.startswith("#"):
|
|
788
|
+
probes.append(text)
|
|
789
|
+
|
|
790
|
+
walk(section)
|
|
791
|
+
return probes
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def slide_bg_color(slide_root: ET.Element) -> str | None:
|
|
795
|
+
"""取幻灯片背景色(精导固定写 p:bg/a:solidFill/a:srgbClr)。"""
|
|
796
|
+
for node in slide_root.findall(".//p:bg//a:srgbClr", NS):
|
|
797
|
+
value = (node.get("val") or "").strip()
|
|
798
|
+
if value:
|
|
799
|
+
return value.upper()
|
|
800
|
+
return None
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def validate_model_theme(
|
|
804
|
+
model: dict[str, Any] | None,
|
|
805
|
+
slide_bgs: list[str | None],
|
|
806
|
+
) -> list[dict[str, Any]]:
|
|
807
|
+
"""主题一致性:PPTX 实际背景 token 与 model.style × model.theme 对照
|
|
808
|
+
(防 HTML 与 PPTX 亮暗不一致;封面/金句/收尾用 accent/ink 全幅底,天然不命中任一 token,不误报)。"""
|
|
809
|
+
warnings: list[dict[str, Any]] = []
|
|
810
|
+
if not model or not slide_bgs:
|
|
811
|
+
return warnings
|
|
812
|
+
style = str(model.get("style") or "").strip()
|
|
813
|
+
theme = str(model.get("theme") or "light").strip().lower()
|
|
814
|
+
if not style or theme not in {"light", "dark"}:
|
|
815
|
+
return warnings
|
|
816
|
+
lc_path = Path(__file__).resolve().parent / "layout-constants.json"
|
|
817
|
+
try:
|
|
818
|
+
lc = json.loads(lc_path.read_text(encoding="utf-8"))
|
|
819
|
+
except (OSError, json.JSONDecodeError):
|
|
820
|
+
return warnings
|
|
821
|
+
light_bg = str(((lc.get("styles") or {}).get(style) or {}).get("bg") or "").upper()
|
|
822
|
+
dark_bg = str(((lc.get("stylesDark") or {}).get(style) or {}).get("bg") or "").upper()
|
|
823
|
+
if not light_bg and not dark_bg:
|
|
824
|
+
return warnings
|
|
825
|
+
expected = light_bg if theme == "light" else dark_bg
|
|
826
|
+
opposite = dark_bg if theme == "light" else light_bg
|
|
827
|
+
found = {bg for bg in slide_bgs if bg}
|
|
828
|
+
if opposite and opposite in found and expected and expected not in found:
|
|
829
|
+
first = next(i + 1 for i, bg in enumerate(slide_bgs) if bg == opposite)
|
|
830
|
+
warnings.append(
|
|
831
|
+
issue(
|
|
832
|
+
"MODEL_THEME_BG_MISMATCH",
|
|
833
|
+
f"model.theme={theme} 期望页面底色 {expected},实际用了相反主题底色 {opposite}"
|
|
834
|
+
"(HTML 与 PPTX 亮暗将不一致)。",
|
|
835
|
+
slide=first,
|
|
836
|
+
)
|
|
837
|
+
)
|
|
838
|
+
return warnings
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
_CHART_REG_CACHE: dict[str, Any] | None = None
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def _chart_registry() -> dict[str, Any]:
|
|
845
|
+
"""图表登记四元组(单源 scripts/layout-constants.json 的 charts.registry)。"""
|
|
846
|
+
global _CHART_REG_CACHE
|
|
847
|
+
if _CHART_REG_CACHE is None:
|
|
848
|
+
lc_path = Path(__file__).resolve().parent / "layout-constants.json"
|
|
849
|
+
try:
|
|
850
|
+
lc = json.loads(lc_path.read_text(encoding="utf-8"))
|
|
851
|
+
reg = ((lc.get("charts") or {}).get("registry") or {})
|
|
852
|
+
_CHART_REG_CACHE = {k: v for k, v in reg.items() if not str(k).startswith("$")}
|
|
853
|
+
except (OSError, json.JSONDecodeError):
|
|
854
|
+
_CHART_REG_CACHE = {}
|
|
855
|
+
return _CHART_REG_CACHE
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def _chart_channel(chart_type: str) -> str:
|
|
859
|
+
"""图表类型 → 交付通道(native 原生可编辑 / shape 形状还原);未知类型按 native 保守处理。"""
|
|
860
|
+
spec = _chart_registry().get(str(chart_type or "bar").lower()) or {}
|
|
861
|
+
return str(spec.get("pptx") or "native")
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def _chart_data_table_mode(chart: dict[str, Any]) -> str:
|
|
865
|
+
"""数据表策略:图表级 > 登记表默认(appendix 收敛为页内表格)。"""
|
|
866
|
+
spec = _chart_registry().get(str(chart.get("type") or "bar").lower()) or {}
|
|
867
|
+
mode = str(chart.get("dataTable") or spec.get("dataTable") or "notes").lower()
|
|
868
|
+
return "inline" if mode == "appendix" else mode
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def _section_chart(sec: dict[str, Any]) -> dict[str, Any] | None:
|
|
872
|
+
"""取章节页的图表对象(chart 页型 / split 右图),无则 None。"""
|
|
873
|
+
st = sec.get("type") or ""
|
|
874
|
+
if st in {"bar", "donut", "exhibit", "halftable"}:
|
|
875
|
+
c = sec.get("chart") or {}
|
|
876
|
+
if isinstance(c, dict) and c.get("labels") and c.get("values"):
|
|
877
|
+
return c
|
|
878
|
+
elif st == "split":
|
|
879
|
+
right = sec.get("right") or {}
|
|
880
|
+
if isinstance(right, dict) and (right.get("type") or "bar") != "table" \
|
|
881
|
+
and right.get("labels") and right.get("values"):
|
|
882
|
+
return right
|
|
883
|
+
return None
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def _model_chart_specs(model: dict[str, Any]) -> list[dict[str, Any]]:
|
|
887
|
+
"""模型里所有带数据图表的通道与数据表策略(按 charts.registry 分通道断言)。"""
|
|
888
|
+
specs: list[dict[str, Any]] = []
|
|
889
|
+
for sec in (model.get("sections") or []):
|
|
890
|
+
if not isinstance(sec, dict):
|
|
891
|
+
continue
|
|
892
|
+
chart = _section_chart(sec)
|
|
893
|
+
if chart is None:
|
|
894
|
+
continue
|
|
895
|
+
specs.append({
|
|
896
|
+
"type": str(chart.get("type") or "bar").lower(),
|
|
897
|
+
"channel": _chart_channel(chart.get("type") or "bar"),
|
|
898
|
+
"dataTable": _chart_data_table_mode(chart),
|
|
899
|
+
"pageType": sec.get("type") or "",
|
|
900
|
+
})
|
|
901
|
+
return specs
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def validate_font_scale(model: dict[str, Any] | None,
|
|
905
|
+
slides: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
906
|
+
"""全篇字号必须出自该模式的 modeTypeScale(或有限缩字号阶梯)。
|
|
907
|
+
|
|
908
|
+
字号是三模式密度契约的落地方式:任何硬编码字号或比例尺映射失灵,都会绕过
|
|
909
|
+
契约而不被任何现有门禁发现(页面看上去“只是略微不同”)。
|
|
910
|
+
"""
|
|
911
|
+
out: list[dict[str, Any]] = []
|
|
912
|
+
if not model:
|
|
913
|
+
return out
|
|
914
|
+
lc_path = Path(__file__).resolve().parent / "layout-constants.json"
|
|
915
|
+
try:
|
|
916
|
+
lc = json.loads(lc_path.read_text(encoding="utf-8"))
|
|
917
|
+
except (OSError, json.JSONDecodeError):
|
|
918
|
+
return out
|
|
919
|
+
mode = str(model.get("mode") or "presentation")
|
|
920
|
+
scale = (lc.get("modeTypeScale") or {}).get(mode) or {}
|
|
921
|
+
if not scale:
|
|
922
|
+
return out
|
|
923
|
+
ladder = ((lc.get("containers") or {}).get("fontShrink") or {}).get("ladder") or []
|
|
924
|
+
allowed = {round(float(v), 2) for v in scale.values()} | {round(float(v), 2) for v in ladder}
|
|
925
|
+
for s in slides:
|
|
926
|
+
alien = [v for v in (s.get("font_sizes_pt") or []) if round(float(v), 2) not in allowed]
|
|
927
|
+
if alien:
|
|
928
|
+
out.append(issue(
|
|
929
|
+
"FONT_SIZE_OFF_SCALE",
|
|
930
|
+
f"第 {s['slide']} 页出现比例尺外的字号 {sorted(set(alien))}({mode} 档允许 "
|
|
931
|
+
f"{sorted(allowed)}):字号须走 sz()/modeSize() 映射,不得硬编码。",
|
|
932
|
+
slide=s["slide"]))
|
|
933
|
+
return out
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def _roundtrip_corpus(slide_text: str, note: str = "", chart_text: str = "") -> str:
|
|
937
|
+
"""往返保真比对语料 = 幻灯片形状文本 ∪ 演讲者备注 ∪ chart part 文本(去空白)。
|
|
938
|
+
|
|
939
|
+
探针可能命中三类合法落点:版面形状、演讲者备注(note/lead/口径)、原生图表
|
|
940
|
+
类别与缓存值。只比形状文本会把后两类误判成 MODEL_ROUNDTRIP_CONTENT_MISSING。
|
|
941
|
+
"""
|
|
942
|
+
return _norm_text(" ".join(x for x in (slide_text, note, chart_text) if x))
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def validate_model_roundtrip(
|
|
946
|
+
model: dict[str, Any] | None,
|
|
947
|
+
slide_texts: list[str],
|
|
948
|
+
slide_count: int,
|
|
949
|
+
chart_count: int = -1,
|
|
950
|
+
allow_shape_charts: bool = False,
|
|
951
|
+
slide_table_counts: list[int] | None = None,
|
|
952
|
+
slide_notes: list[str] | None = None,
|
|
953
|
+
slide_chart_texts: list[str] | None = None,
|
|
954
|
+
) -> list[dict[str, Any]]:
|
|
955
|
+
"""模型→PPTX 往返保真:页数结构与关键文本落位(内容不丢不串页)。
|
|
956
|
+
关键串比对语料 = 形状文本 ∪ 备注 ∪ chart part(见 _roundtrip_corpus)。
|
|
957
|
+
architecture 极简形态可省略 agenda(封面 + sections + 收尾)。"""
|
|
958
|
+
warnings: list[dict[str, Any]] = []
|
|
959
|
+
if not model:
|
|
960
|
+
return warnings
|
|
961
|
+
sections = [s for s in (model.get("sections") or []) if isinstance(s, dict)]
|
|
962
|
+
has_agenda = bool(model.get("agenda"))
|
|
963
|
+
expected = len(sections) + 2 + (1 if has_agenda else 0) # 封面 + 大纲(可选) + sections + 收尾
|
|
964
|
+
if slide_count != expected:
|
|
965
|
+
warnings.append(
|
|
966
|
+
issue(
|
|
967
|
+
"MODEL_ROUNDTRIP_SLIDE_COUNT",
|
|
968
|
+
f"模型应为 {expected} 页(封面 + {'大纲 + ' if has_agenda else ''}{len(sections)} 章节 + 收尾),"
|
|
969
|
+
f"实际 {slide_count} 页。",
|
|
970
|
+
)
|
|
971
|
+
)
|
|
972
|
+
first_sec = 3 if has_agenda else 2
|
|
973
|
+
for i, sec in enumerate(sections):
|
|
974
|
+
slide_no = first_sec + i
|
|
975
|
+
if slide_no > len(slide_texts):
|
|
976
|
+
break
|
|
977
|
+
title = str(sec.get("title") or "").strip()
|
|
978
|
+
if title and title not in slide_texts[slide_no - 1]:
|
|
979
|
+
warnings.append(
|
|
980
|
+
issue("MODEL_ROUNDTRIP_TITLE_MISSING",
|
|
981
|
+
f"第 {i + 1} 章标题「{title[:36]}」未落在第 {slide_no} 页。", slide=slide_no)
|
|
982
|
+
)
|
|
983
|
+
probes = _model_probe_strings(sec)
|
|
984
|
+
if probes:
|
|
985
|
+
slide_text = _roundtrip_corpus(
|
|
986
|
+
slide_texts[slide_no - 1],
|
|
987
|
+
(slide_notes[slide_no - 1] if slide_notes and slide_no - 1 < len(slide_notes) else ""),
|
|
988
|
+
(slide_chart_texts[slide_no - 1]
|
|
989
|
+
if slide_chart_texts and slide_no - 1 < len(slide_chart_texts) else ""),
|
|
990
|
+
)
|
|
991
|
+
hits = sum(1 for p in probes if _norm_text(p) in slide_text)
|
|
992
|
+
# v9:保真下限 0.80(原 0.50 放行半页截断/空白)
|
|
993
|
+
min_ratio = 0.80
|
|
994
|
+
try:
|
|
995
|
+
lc_q = json.loads(
|
|
996
|
+
(Path(__file__).resolve().parent / "layout-constants.json")
|
|
997
|
+
.read_text(encoding="utf-8"))
|
|
998
|
+
min_ratio = float((lc_q.get("qualityGates") or {}).get("roundtripMin") or 0.8)
|
|
999
|
+
except Exception:
|
|
1000
|
+
pass
|
|
1001
|
+
if hits < len(probes) * min_ratio:
|
|
1002
|
+
warnings.append(
|
|
1003
|
+
issue("MODEL_ROUNDTRIP_CONTENT_MISSING",
|
|
1004
|
+
f"第 {i + 1} 章仅 {hits}/{len(probes)} 个关键串落在第 {slide_no} 页"
|
|
1005
|
+
f"(内容保真 <{min_ratio:.0%},疑似截断/空白)。",
|
|
1006
|
+
slide=slide_no)
|
|
1007
|
+
)
|
|
1008
|
+
model_title = str(model.get("title") or "").strip()
|
|
1009
|
+
if model_title and slide_texts and model_title not in slide_texts[0]:
|
|
1010
|
+
warnings.append(issue("MODEL_ROUNDTRIP_COVER_TITLE", "模型封面标题未出现在第 1 页。"))
|
|
1011
|
+
# 原生数据图表硬门禁(按类型断言):charts.registry 中 pptx=native 的图表必须落成
|
|
1012
|
+
# chart part(可编辑数据);pptx=shape 的图表走高保真形状还原 + 数据表,不要求 chart part。
|
|
1013
|
+
# (allow_shape_charts=True 仅供 A 通道预览引擎回归豁免——交付通道一律要求原生图表)
|
|
1014
|
+
specs = _model_chart_specs(model)
|
|
1015
|
+
expected_native = sum(1 for s in specs if s["channel"] == "native")
|
|
1016
|
+
if not allow_shape_charts and chart_count >= 0 and expected_native > chart_count:
|
|
1017
|
+
warnings.append(
|
|
1018
|
+
issue(
|
|
1019
|
+
"MODEL_CHART_COUNT",
|
|
1020
|
+
f"模型含 {expected_native} 个原生通道数据图表,但 PPTX 只有 {chart_count} 个原生 chart part;"
|
|
1021
|
+
"原生通道图表必须是可编辑数据图表(addChart),不接受形状拼图。",
|
|
1022
|
+
)
|
|
1023
|
+
)
|
|
1024
|
+
# 数据可追溯硬门禁:非原生(形状还原)图表的 dataTable 不得为 off;
|
|
1025
|
+
# 声明 dataTable=inline 的图表,其所在页必须真的落有原生表格。
|
|
1026
|
+
for s in specs:
|
|
1027
|
+
if s["channel"] != "shape" or s["dataTable"] != "off":
|
|
1028
|
+
continue
|
|
1029
|
+
# 口径与 sync_runtime.py 一致:登记表**自身**声明 dataTable=off 的类型(纯装饰微图,
|
|
1030
|
+
# 如 sparkline)允许不附数据表;只有「形状通道 + 登记表默认非 off 却被显式关掉」才拦截。
|
|
1031
|
+
reg_default = str((_chart_registry().get(s["type"]) or {}).get("dataTable") or "notes").lower()
|
|
1032
|
+
if reg_default == "off":
|
|
1033
|
+
continue
|
|
1034
|
+
warnings.append(
|
|
1035
|
+
issue(
|
|
1036
|
+
"MODEL_CHART_DATATABLE",
|
|
1037
|
+
f"{s['pageType']} 页的 {s['type']} 图表为形状还原(非原生),"
|
|
1038
|
+
"dataTable 不得为 off —— 必须至少 notes(数据写入演讲者备注)保证数据可追溯。",
|
|
1039
|
+
)
|
|
1040
|
+
)
|
|
1041
|
+
if slide_table_counts is not None:
|
|
1042
|
+
for i, sec in enumerate(sections):
|
|
1043
|
+
slide_no = first_sec + i
|
|
1044
|
+
if slide_no - 1 >= len(slide_table_counts):
|
|
1045
|
+
break
|
|
1046
|
+
chart = _section_chart(sec)
|
|
1047
|
+
if chart is None:
|
|
1048
|
+
continue
|
|
1049
|
+
# donut 页型的图例即数据列(几何已被环形+图例占满),inline 收敛为 notes(数据入备注)
|
|
1050
|
+
if (sec.get("type") or "") == "donut":
|
|
1051
|
+
continue
|
|
1052
|
+
if _chart_data_table_mode(chart) == "inline" and slide_table_counts[slide_no - 1] <= 0:
|
|
1053
|
+
warnings.append(
|
|
1054
|
+
issue(
|
|
1055
|
+
"MODEL_CHART_DATATABLE",
|
|
1056
|
+
f"第 {i + 1} 章图表声明 dataTable=inline,但第 {slide_no} 页没有原生表格(数据表未落位)。",
|
|
1057
|
+
slide=slide_no,
|
|
1058
|
+
)
|
|
1059
|
+
)
|
|
1060
|
+
# notes 策略声称「数据写入演讲者备注」——不核对就只是一句承诺:
|
|
1061
|
+
# 备注丢了,用户打开 PPTX 无从得知,图表数据就此不可追溯。
|
|
1062
|
+
# 仅对交付通道生效:A 预览引擎不产 notesSlide(预览也看不到备注),与
|
|
1063
|
+
# allow_shape_charts 同一道通道能力边界。
|
|
1064
|
+
if slide_notes is not None and not allow_shape_charts:
|
|
1065
|
+
for i, sec in enumerate(sections):
|
|
1066
|
+
slide_no = first_sec + i
|
|
1067
|
+
if slide_no - 1 >= len(slide_notes):
|
|
1068
|
+
break
|
|
1069
|
+
chart = _section_chart(sec)
|
|
1070
|
+
if chart is None or _chart_data_table_mode(chart) != "notes":
|
|
1071
|
+
continue
|
|
1072
|
+
if "数据表" not in (slide_notes[slide_no - 1] or ""):
|
|
1073
|
+
warnings.append(
|
|
1074
|
+
issue(
|
|
1075
|
+
"MODEL_CHART_NOTES_MISSING",
|
|
1076
|
+
f"第 {i + 1} 章图表声明 dataTable=notes,但第 {slide_no} 页的演讲者备注里没有数据表(数据不可追溯)。",
|
|
1077
|
+
slide=slide_no,
|
|
1078
|
+
)
|
|
1079
|
+
)
|
|
1080
|
+
closing = model.get("closing") or {}
|
|
1081
|
+
closing_title = str((closing.get("title") if isinstance(closing, dict) else "") or "").strip()
|
|
1082
|
+
if closing_title and slide_texts and closing_title not in slide_texts[-1]:
|
|
1083
|
+
warnings.append(issue("MODEL_ROUNDTRIP_CLOSING_TITLE", "模型收尾标题未出现在最后一页。"))
|
|
1084
|
+
if isinstance(closing, dict) and slide_texts:
|
|
1085
|
+
closing_probes = _model_probe_strings(closing)
|
|
1086
|
+
if closing_probes:
|
|
1087
|
+
slide_text = _roundtrip_corpus(
|
|
1088
|
+
slide_texts[-1],
|
|
1089
|
+
(slide_notes[-1] if slide_notes else ""),
|
|
1090
|
+
(slide_chart_texts[-1] if slide_chart_texts else ""),
|
|
1091
|
+
)
|
|
1092
|
+
hits = sum(1 for p in closing_probes if _norm_text(p) in slide_text)
|
|
1093
|
+
if hits * 2 < len(closing_probes):
|
|
1094
|
+
warnings.append(
|
|
1095
|
+
issue("MODEL_ROUNDTRIP_CONTENT_MISSING",
|
|
1096
|
+
f"收尾页仅 {hits}/{len(closing_probes)} 个关键串落在最后一页(内容保真)。")
|
|
1097
|
+
)
|
|
1098
|
+
return warnings
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
def empty_report(path: Path) -> dict[str, Any]:
|
|
1102
|
+
return {
|
|
1103
|
+
"file": str(path),
|
|
1104
|
+
"summary": {
|
|
1105
|
+
"slide_count": 0,
|
|
1106
|
+
"width_emu": 0,
|
|
1107
|
+
"height_emu": 0,
|
|
1108
|
+
"aspect_ratio": 0.0,
|
|
1109
|
+
"native_text_shapes": 0,
|
|
1110
|
+
"native_graphic_shapes": 0,
|
|
1111
|
+
"pictures": 0,
|
|
1112
|
+
"charts": 0,
|
|
1113
|
+
"tables": 0,
|
|
1114
|
+
},
|
|
1115
|
+
"errors": [],
|
|
1116
|
+
"warnings": [],
|
|
1117
|
+
"slides": [],
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
def promote_strict_failures(report: dict[str, Any], deep: bool = False) -> None:
|
|
1122
|
+
codes = set(STRICT_FAILURE_CODES)
|
|
1123
|
+
if deep:
|
|
1124
|
+
codes |= DEEP_STRICT_CODES
|
|
1125
|
+
existing = {(item.get("code"), item.get("slide"), item.get("message")) for item in report["errors"]}
|
|
1126
|
+
for warning in report["warnings"]:
|
|
1127
|
+
if warning.get("code") not in codes:
|
|
1128
|
+
continue
|
|
1129
|
+
key = (warning.get("code"), warning.get("slide"), warning.get("message"))
|
|
1130
|
+
if key in existing:
|
|
1131
|
+
continue
|
|
1132
|
+
failure = dict(warning)
|
|
1133
|
+
failure["strict_failure"] = True
|
|
1134
|
+
report["errors"].append(failure)
|
|
1135
|
+
existing.add(key)
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
def validate_chrome_drift(slides: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
1140
|
+
"""跨页 chrome(页脚/页码)y 一致性:相对中位数漂移超过 chromeDriftIn → WARN。
|
|
1141
|
+
|
|
1142
|
+
封面/收尾页版式不同(常无页脚或 y 不同)豁免;仅比内容页。严格交付下任意 WARN 会挡
|
|
1143
|
+
smoke,故容差偏宽、只抓明显漂移。
|
|
1144
|
+
"""
|
|
1145
|
+
out: list[dict[str, Any]] = []
|
|
1146
|
+
if len(slides) < 4:
|
|
1147
|
+
return out
|
|
1148
|
+
first_n = slides[0].get("slide")
|
|
1149
|
+
last_n = slides[-1].get("slide")
|
|
1150
|
+
ys = [(s.get("slide"), s.get("chrome_footer_y_in"))
|
|
1151
|
+
for s in slides
|
|
1152
|
+
if s.get("chrome_footer_y_in") is not None
|
|
1153
|
+
and s.get("slide") not in (first_n, last_n)]
|
|
1154
|
+
if len(ys) < 3:
|
|
1155
|
+
return out
|
|
1156
|
+
vals = sorted(y for _, y in ys)
|
|
1157
|
+
mid = vals[len(vals) // 2]
|
|
1158
|
+
try:
|
|
1159
|
+
lc = json.loads(Path(__file__).with_name("layout-constants.json").read_text(encoding="utf-8"))
|
|
1160
|
+
max_delta = float(((lc.get("qualityGates") or {}).get("chromeDriftIn") or {}).get("maxAbsDeltaIn") or 0.2)
|
|
1161
|
+
except (OSError, json.JSONDecodeError, TypeError, ValueError):
|
|
1162
|
+
max_delta = 0.2
|
|
1163
|
+
drifted = [(n, y) for n, y in ys if abs(float(y) - mid) > max_delta]
|
|
1164
|
+
for n, y in drifted[:4]:
|
|
1165
|
+
out.append(issue(
|
|
1166
|
+
"CHROME_DRIFT",
|
|
1167
|
+
f"第{n}页页脚/页码 y={y:.3f}in 相对中位 {mid:.3f}in 漂移 {abs(y-mid):+.3f}in "
|
|
1168
|
+
f"(容差 {max_delta}in);跨页 chrome 应锁死几何。",
|
|
1169
|
+
slide=n,
|
|
1170
|
+
))
|
|
1171
|
+
return out
|
|
1172
|
+
|
|
1173
|
+
|
|
1174
|
+
def validate_pptx(
|
|
1175
|
+
path: str | Path,
|
|
1176
|
+
model_path: str | Path | None = None,
|
|
1177
|
+
*,
|
|
1178
|
+
strict: bool = False,
|
|
1179
|
+
allow_shape_charts: bool = False,
|
|
1180
|
+
deep: bool = False,
|
|
1181
|
+
) -> dict[str, Any]:
|
|
1182
|
+
source = Path(path)
|
|
1183
|
+
report = empty_report(source)
|
|
1184
|
+
model: dict[str, Any] | None = None
|
|
1185
|
+
if model_path is not None:
|
|
1186
|
+
try:
|
|
1187
|
+
model = json.loads(Path(model_path).read_text(encoding="utf-8"))
|
|
1188
|
+
if not isinstance(model, dict):
|
|
1189
|
+
model = None
|
|
1190
|
+
report["warnings"].append(issue("MODEL_INVALID", "--model 文件根节点必须是 JSON 对象。"))
|
|
1191
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
1192
|
+
report["warnings"].append(issue("MODEL_INVALID", f"无法解析 --model JSON:{exc}"))
|
|
1193
|
+
|
|
1194
|
+
if not source.exists():
|
|
1195
|
+
report["errors"].append(issue("FILE_NOT_FOUND", f"文件不存在:{source}"))
|
|
1196
|
+
return report
|
|
1197
|
+
|
|
1198
|
+
try:
|
|
1199
|
+
with zipfile.ZipFile(source) as archive:
|
|
1200
|
+
names = set(archive.namelist())
|
|
1201
|
+
required = {"[Content_Types].xml", "ppt/presentation.xml"}
|
|
1202
|
+
missing = sorted(required - names)
|
|
1203
|
+
if missing:
|
|
1204
|
+
report["errors"].append(issue("MISSING_PACKAGE_PART", f"缺少必需部件:{', '.join(missing)}"))
|
|
1205
|
+
return report
|
|
1206
|
+
|
|
1207
|
+
presentation = read_xml(archive, "ppt/presentation.xml")
|
|
1208
|
+
slide_size = presentation.find("p:sldSz", NS)
|
|
1209
|
+
if slide_size is None:
|
|
1210
|
+
report["errors"].append(issue("MISSING_SLIDE_SIZE", "ppt/presentation.xml 无 p:sldSz。"))
|
|
1211
|
+
return report
|
|
1212
|
+
|
|
1213
|
+
width = int(slide_size.get("cx", "0"))
|
|
1214
|
+
height = int(slide_size.get("cy", "0"))
|
|
1215
|
+
if width <= 0 or height <= 0:
|
|
1216
|
+
report["errors"].append(issue("INVALID_SLIDE_SIZE", f"页面尺寸非法:{width}×{height} EMU。"))
|
|
1217
|
+
return report
|
|
1218
|
+
|
|
1219
|
+
ratio = width / height
|
|
1220
|
+
report["summary"].update({
|
|
1221
|
+
"width_emu": width,
|
|
1222
|
+
"height_emu": height,
|
|
1223
|
+
"aspect_ratio": round(ratio, 4),
|
|
1224
|
+
})
|
|
1225
|
+
if not 1.75 <= ratio <= 1.79:
|
|
1226
|
+
report["warnings"].append(
|
|
1227
|
+
issue("NON_WIDESCREEN_ASPECT", f"页面比例 {ratio:.4f},应为约 16:9。")
|
|
1228
|
+
)
|
|
1229
|
+
|
|
1230
|
+
# 色值合法性:编码色板(c1–c5)与图表系列色经双引擎直写 srgbClr val——
|
|
1231
|
+
# 单源若带 # 前缀会原样落进 OOXML(非法,PowerPoint 静默忽略/触发修复)。
|
|
1232
|
+
bad_colors: list[str] = []
|
|
1233
|
+
for part in archive.namelist():
|
|
1234
|
+
if not part.endswith(".xml"):
|
|
1235
|
+
continue
|
|
1236
|
+
try:
|
|
1237
|
+
body = archive.read(part).decode("utf-8", "replace")
|
|
1238
|
+
except KeyError:
|
|
1239
|
+
continue
|
|
1240
|
+
for match in HEX_COLOR_RE.finditer(body):
|
|
1241
|
+
if not HEX6_RE.fullmatch(match.group(1)):
|
|
1242
|
+
bad_colors.append(f"{part}: {match.group(0)}")
|
|
1243
|
+
if bad_colors:
|
|
1244
|
+
report["errors"].append(issue(
|
|
1245
|
+
"INVALID_HEX_COLOR",
|
|
1246
|
+
f"非法 srgbClr 色值 {len(bad_colors)} 处(须为 6 位 hex,不带 #):"
|
|
1247
|
+
+ "; ".join(bad_colors[:4])))
|
|
1248
|
+
|
|
1249
|
+
slide_names = find_slide_names(archive)
|
|
1250
|
+
if not slide_names:
|
|
1251
|
+
report["errors"].append(issue("NO_SLIDES", "未找到 ppt/slides/slideN.xml。"))
|
|
1252
|
+
return report
|
|
1253
|
+
|
|
1254
|
+
slide_texts: list[str] = []
|
|
1255
|
+
slide_bgs: list[str | None] = []
|
|
1256
|
+
slide_notes: list[str] = []
|
|
1257
|
+
slide_chart_texts: list[str] = []
|
|
1258
|
+
for slide_number, slide_name in enumerate(slide_names, start=1):
|
|
1259
|
+
try:
|
|
1260
|
+
slide_root = read_xml(archive, slide_name)
|
|
1261
|
+
except (KeyError, ET.ParseError) as exc:
|
|
1262
|
+
report["errors"].append(
|
|
1263
|
+
issue("INVALID_SLIDE_XML", f"无法解析 {slide_name}:{exc}", slide=slide_number)
|
|
1264
|
+
)
|
|
1265
|
+
continue
|
|
1266
|
+
metrics, warnings = inspect_slide(slide_root, slide_number, width, height, deep=deep)
|
|
1267
|
+
report["slides"].append(metrics)
|
|
1268
|
+
report["warnings"].extend(warnings)
|
|
1269
|
+
slide_texts.append(text_content(slide_root))
|
|
1270
|
+
slide_bgs.append(slide_bg_color(slide_root))
|
|
1271
|
+
slide_notes.append(slide_notes_text(archive, slide_name))
|
|
1272
|
+
slide_chart_texts.append(slide_chart_text(archive, slide_name))
|
|
1273
|
+
|
|
1274
|
+
report["summary"]["slide_count"] = len(slide_names)
|
|
1275
|
+
for field in ("native_text_shapes", "native_graphic_shapes", "pictures", "charts", "tables"):
|
|
1276
|
+
report["summary"][field] = sum(slide[field] for slide in report["slides"])
|
|
1277
|
+
|
|
1278
|
+
# P1-6 chrome 一致性:页脚/页码 y 相对中位数漂移
|
|
1279
|
+
report["warnings"].extend(validate_chrome_drift(report["slides"]))
|
|
1280
|
+
|
|
1281
|
+
# 图片门禁:默认零图片(pictures=0 全原生可编辑);
|
|
1282
|
+
# 仅当模型显式声明 section.image / split.right.image 时才允许对应数量的图片落地。
|
|
1283
|
+
# 配图占位(image.placeholder)走原生形状,不计入图片数;版式/裁切/多图数量另做硬门禁。
|
|
1284
|
+
declared_images = count_model_images(model) if model_path is not None else 0
|
|
1285
|
+
n_pics = report["summary"]["pictures"]
|
|
1286
|
+
img_issues: list[tuple[str, str]] = []
|
|
1287
|
+
if model_path is not None and model is not None:
|
|
1288
|
+
img_issues = model_image_issues(model, _image_spec(),
|
|
1289
|
+
Path(model_path).resolve().parent)
|
|
1290
|
+
for code, msg in img_issues:
|
|
1291
|
+
report["warnings"].append(issue(code, msg))
|
|
1292
|
+
if declared_images:
|
|
1293
|
+
# 声明的图片属预期版面 → 大图面积告警豁免(仍保留 ≥90% 整页底图风险)
|
|
1294
|
+
report["warnings"] = [w for w in report["warnings"]
|
|
1295
|
+
if w["code"] != "UNJUSTIFIED_LARGE_IMAGE"]
|
|
1296
|
+
if n_pics > declared_images:
|
|
1297
|
+
report["warnings"].append(
|
|
1298
|
+
issue("PICTURES_NOT_DECLARED",
|
|
1299
|
+
f"PPTX 含 {n_pics} 张图片,但模型只声明 {declared_images} 张"
|
|
1300
|
+
f"(未声明的图片不允许:全原生可编辑是交付基线)。")
|
|
1301
|
+
)
|
|
1302
|
+
# 反向门禁:声明了却没落地 = 引擎静默回落占位(图片损坏/超限/编码不支持)。
|
|
1303
|
+
# 缺失路径已由 IMAGE_SRC_MISSING 单独报出,此处按可用声明数扣除后再比,避免重复计数。
|
|
1304
|
+
unusable = sum(1 for code, _ in img_issues
|
|
1305
|
+
if code in ("IMAGE_SRC_MISSING", "IMAGE_SRC_EXTERNAL"))
|
|
1306
|
+
expected = declared_images - unusable
|
|
1307
|
+
if n_pics < expected:
|
|
1308
|
+
report["warnings"].append(
|
|
1309
|
+
issue("PICTURES_UNDER_DECLARED",
|
|
1310
|
+
f"PPTX 只落地 {n_pics} 张图片,但模型有 {expected} 张可用声明"
|
|
1311
|
+
f"({declared_images} 声明 − {unusable} 路径异常)。"
|
|
1312
|
+
f"引擎可能已静默回落配图占位:请检查图片是否损坏/超出体积上限/编码不受支持。")
|
|
1313
|
+
)
|
|
1314
|
+
for src in model_image_srcs(model):
|
|
1315
|
+
if re.match(r"\s*(?:https?:)?//", src):
|
|
1316
|
+
report["warnings"].append(
|
|
1317
|
+
issue("IMAGE_SRC_EXTERNAL",
|
|
1318
|
+
f"模型 image.src 为外链({src[:48]}…):只允许 data: 内联或相对路径。")
|
|
1319
|
+
)
|
|
1320
|
+
elif n_pics > 0:
|
|
1321
|
+
report["warnings"].append(
|
|
1322
|
+
issue("PICTURES_NOT_ALLOWED",
|
|
1323
|
+
f"PPTX 含 {n_pics} 张图片;TopPPT HTML 默认要求 pictures=0(全原生可编辑)。"
|
|
1324
|
+
f"如确需图片,请在模型中显式声明 section.image。")
|
|
1325
|
+
)
|
|
1326
|
+
if report["summary"]["native_text_shapes"] == 0:
|
|
1327
|
+
report["warnings"].append(
|
|
1328
|
+
issue("NO_NATIVE_TEXT_WITH_EDITABLE_TEXT_REQUIRED",
|
|
1329
|
+
"PPTX 无原生文本形状;可编辑性不达标。")
|
|
1330
|
+
)
|
|
1331
|
+
|
|
1332
|
+
if model_path is not None:
|
|
1333
|
+
report["warnings"].extend(
|
|
1334
|
+
validate_model_roundtrip(
|
|
1335
|
+
model, slide_texts, report["summary"]["slide_count"],
|
|
1336
|
+
chart_count=report["summary"]["charts"],
|
|
1337
|
+
allow_shape_charts=allow_shape_charts,
|
|
1338
|
+
slide_table_counts=[s.get("tables", 0) for s in report["slides"]],
|
|
1339
|
+
slide_notes=slide_notes,
|
|
1340
|
+
slide_chart_texts=slide_chart_texts,
|
|
1341
|
+
)
|
|
1342
|
+
)
|
|
1343
|
+
report["warnings"].extend(validate_model_theme(model, slide_bgs))
|
|
1344
|
+
report["warnings"].extend(validate_font_scale(model, report["slides"]))
|
|
1345
|
+
except zipfile.BadZipFile:
|
|
1346
|
+
report["errors"].append(issue("INVALID_PPTX_ZIP", "文件不是可读的 PPTX ZIP 包。"))
|
|
1347
|
+
except (ET.ParseError, KeyError, ValueError) as exc:
|
|
1348
|
+
report["errors"].append(issue("INVALID_PACKAGE_XML", str(exc)))
|
|
1349
|
+
|
|
1350
|
+
if strict:
|
|
1351
|
+
promote_strict_failures(report, deep=deep)
|
|
1352
|
+
|
|
1353
|
+
return report
|
|
1354
|
+
|
|
1355
|
+
|
|
1356
|
+
def build_manifest(report: dict[str, Any], model_path: str | Path | None,
|
|
1357
|
+
deep: bool = False) -> dict[str, Any]:
|
|
1358
|
+
"""深度模式 manifest:锚点注册 / 容器清单 / 数据表清单 / 图表通道清单 / 图片资产登记。
|
|
1359
|
+
|
|
1360
|
+
这是"按需深度模式"的机器可验证产物——不做 SHA-256 冻结与逐页人工验收(保持轻量),
|
|
1361
|
+
只把可复现的事实登记下来,供人工复核与渲染对照使用。
|
|
1362
|
+
"""
|
|
1363
|
+
model: dict[str, Any] | None = None
|
|
1364
|
+
if model_path is not None:
|
|
1365
|
+
try:
|
|
1366
|
+
loaded = json.loads(Path(model_path).read_text(encoding="utf-8"))
|
|
1367
|
+
model = loaded if isinstance(loaded, dict) else None
|
|
1368
|
+
except (OSError, json.JSONDecodeError):
|
|
1369
|
+
model = None
|
|
1370
|
+
slides = report.get("slides") or []
|
|
1371
|
+
specs = _model_chart_specs(model) if model else []
|
|
1372
|
+
native_expected = sum(1 for s in specs if s["channel"] == "native")
|
|
1373
|
+
shape_expected = len(specs) - native_expected
|
|
1374
|
+
charts_actual = int((report.get("summary") or {}).get("charts", 0))
|
|
1375
|
+
anchors: list[dict[str, Any]] = []
|
|
1376
|
+
for slide in slides:
|
|
1377
|
+
info = slide.get("anchor")
|
|
1378
|
+
if not info:
|
|
1379
|
+
anchors.append({"slide": slide["slide"], "status": "no-title-anchor"})
|
|
1380
|
+
continue
|
|
1381
|
+
passed = abs(info["delta_in"]) <= info["tolerance_in"]
|
|
1382
|
+
anchors.append({
|
|
1383
|
+
"slide": slide["slide"], "title": info["text"],
|
|
1384
|
+
"left_in": info["left_in"], "expected_left_in": info["expected_left_in"],
|
|
1385
|
+
"delta_in": info["delta_in"], "tolerance_in": info["tolerance_in"],
|
|
1386
|
+
"status": "passed" if passed else "failed",
|
|
1387
|
+
})
|
|
1388
|
+
data_tables = [
|
|
1389
|
+
{"index": i + 1, "pageType": s["pageType"], "chart": s["type"],
|
|
1390
|
+
"channel": s["channel"], "dataTable": s["dataTable"]}
|
|
1391
|
+
for i, s in enumerate(specs)
|
|
1392
|
+
]
|
|
1393
|
+
for slide in slides:
|
|
1394
|
+
if slide.get("tables"):
|
|
1395
|
+
data_tables.append({"slide": slide["slide"], "tables": slide["tables"], "source": "pptx"})
|
|
1396
|
+
pad = _containers_cfg()["minPad"]
|
|
1397
|
+
return {
|
|
1398
|
+
"file": report.get("file"),
|
|
1399
|
+
"version": skill_version(),
|
|
1400
|
+
"mode": (model or {}).get("mode"),
|
|
1401
|
+
"style": (model or {}).get("style"),
|
|
1402
|
+
"theme": (model or {}).get("theme") or "light",
|
|
1403
|
+
"deep": deep,
|
|
1404
|
+
"slide_count": (report.get("summary") or {}).get("slide_count", 0),
|
|
1405
|
+
"chartChannels": {
|
|
1406
|
+
"expectedNative": native_expected,
|
|
1407
|
+
"expectedShape": shape_expected,
|
|
1408
|
+
"actualChartParts": charts_actual,
|
|
1409
|
+
"nativeOk": charts_actual >= native_expected,
|
|
1410
|
+
},
|
|
1411
|
+
"dataTables": data_tables,
|
|
1412
|
+
"images": {
|
|
1413
|
+
"declared": count_model_images(model) if model else 0,
|
|
1414
|
+
"actual": int((report.get("summary") or {}).get("pictures", 0)),
|
|
1415
|
+
"placeholders": count_model_placeholders(model) if model else 0,
|
|
1416
|
+
"layouts": [
|
|
1417
|
+
{"section": i, "layout": str(img.get("layout") or
|
|
1418
|
+
("grid" if len(img.get("items") or []) > 1 else "full")).lower(),
|
|
1419
|
+
"fit": str(img.get("fit") or _image_spec().get("fitDefault") or "cover").lower(),
|
|
1420
|
+
"srcs": len(_image_real_srcs(img)), "placeholder": bool(img.get("placeholder"))}
|
|
1421
|
+
for i, img in _model_image_holders(model)
|
|
1422
|
+
] if model else [],
|
|
1423
|
+
},
|
|
1424
|
+
"anchors": anchors,
|
|
1425
|
+
"containers": [
|
|
1426
|
+
{"slide": s["slide"], "text_shapes": s.get("native_text_shapes", 0), "min_pad_in": pad}
|
|
1427
|
+
for s in slides
|
|
1428
|
+
],
|
|
1429
|
+
"counts": {
|
|
1430
|
+
"errors": len(report.get("errors") or []),
|
|
1431
|
+
"warnings": len(report.get("warnings") or []),
|
|
1432
|
+
},
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
|
|
1436
|
+
def _model_image_holders(model: dict[str, Any] | None) -> list[tuple[int, dict[str, Any]]]:
|
|
1437
|
+
"""页内承载素材图片的容器(section.image / split.right.image),带页序号便于定位。"""
|
|
1438
|
+
out: list[tuple[int, dict[str, Any]]] = []
|
|
1439
|
+
if not isinstance(model, dict):
|
|
1440
|
+
return out
|
|
1441
|
+
for i, sec in enumerate(model.get("sections") or []):
|
|
1442
|
+
if not isinstance(sec, dict):
|
|
1443
|
+
continue
|
|
1444
|
+
if isinstance(sec.get("image"), dict):
|
|
1445
|
+
out.append((i, sec["image"]))
|
|
1446
|
+
right = sec.get("right")
|
|
1447
|
+
if isinstance(right, dict) and isinstance(right.get("image"), dict):
|
|
1448
|
+
out.append((i, right["image"]))
|
|
1449
|
+
return out
|
|
1450
|
+
|
|
1451
|
+
|
|
1452
|
+
def _image_real_srcs(img: dict[str, Any]) -> list[str]:
|
|
1453
|
+
"""图片对象的真实 src(image.src + image.items[].src);占位符不计(走原生形状)。"""
|
|
1454
|
+
if not isinstance(img, dict) or img.get("placeholder"):
|
|
1455
|
+
return []
|
|
1456
|
+
srcs: list[str] = []
|
|
1457
|
+
if isinstance(img.get("src"), str) and img["src"].strip():
|
|
1458
|
+
srcs.append(img["src"])
|
|
1459
|
+
for it in (img.get("items") or []):
|
|
1460
|
+
if isinstance(it, dict) and isinstance(it.get("src"), str) and it["src"].strip():
|
|
1461
|
+
srcs.append(it["src"])
|
|
1462
|
+
elif isinstance(it, str) and it.strip():
|
|
1463
|
+
srcs.append(it)
|
|
1464
|
+
return srcs
|
|
1465
|
+
|
|
1466
|
+
|
|
1467
|
+
def count_model_images(model: dict[str, Any] | None) -> int:
|
|
1468
|
+
"""统计模型显式声明的**真实图片**数(section.image / split.right.image 的 src 与 items)。
|
|
1469
|
+
TopPPT HTML 默认零图片(pictures=0 全原生可编辑);仅当模型显式声明时才允许图片落地。
|
|
1470
|
+
配图占位(image.placeholder)由原生形状渲染,不计入图片数。"""
|
|
1471
|
+
n = 0
|
|
1472
|
+
for _, img in _model_image_holders(model):
|
|
1473
|
+
n += len(_image_real_srcs(img))
|
|
1474
|
+
return n
|
|
1475
|
+
|
|
1476
|
+
|
|
1477
|
+
def model_image_srcs(model: dict[str, Any] | None) -> list[str]:
|
|
1478
|
+
out: list[str] = []
|
|
1479
|
+
for _, img in _model_image_holders(model):
|
|
1480
|
+
out.extend(_image_real_srcs(img))
|
|
1481
|
+
return out
|
|
1482
|
+
|
|
1483
|
+
|
|
1484
|
+
def count_model_placeholders(model: dict[str, Any] | None) -> int:
|
|
1485
|
+
"""配图占位页数(原生占位框;交付前建议用户替换为真实素材)。"""
|
|
1486
|
+
return sum(1 for _, img in _model_image_holders(model) if img.get("placeholder"))
|
|
1487
|
+
|
|
1488
|
+
|
|
1489
|
+
def model_image_issues(model: dict[str, Any] | None, spec: dict[str, Any],
|
|
1490
|
+
base_dir: str | Path | None = None) -> list[tuple[str, str]]:
|
|
1491
|
+
"""图片模型硬门禁:版式 / 裁切 / 多图数量 / 外链 src / 相对路径文件缺失。
|
|
1492
|
+
与 validate_report.py 同口径(单源 layout-constants.json imageSpec);
|
|
1493
|
+
相对路径以**模型文件所在目录**为锚(与 build_pptx.js 的 resolveImagePath 同规则)。"""
|
|
1494
|
+
issues: list[tuple[str, str]] = []
|
|
1495
|
+
layouts = {str(x).lower() for x in (spec.get("layouts") or
|
|
1496
|
+
["full", "half", "bleed", "grid", "compare", "wall"])}
|
|
1497
|
+
fits = {str(x).lower() for x in (spec.get("fitEnum") or ["cover", "contain"])}
|
|
1498
|
+
max_per_page = int(spec.get("maxPerPage") or 6)
|
|
1499
|
+
base = Path(base_dir) if base_dir else None
|
|
1500
|
+
for i, img in _model_image_holders(model):
|
|
1501
|
+
items = img.get("items") or []
|
|
1502
|
+
layout = str(img.get("layout") or ("grid" if len(items) > 1 else "full")).lower()
|
|
1503
|
+
if layout not in layouts:
|
|
1504
|
+
issues.append(("IMAGE_LAYOUT_INVALID",
|
|
1505
|
+
f"sections[{i}].image.layout={layout!r} 未登记(应为 {'/'.join(sorted(layouts))})"))
|
|
1506
|
+
if img.get("fit") and str(img["fit"]).lower() not in fits:
|
|
1507
|
+
issues.append(("IMAGE_FIT_INVALID",
|
|
1508
|
+
f"sections[{i}].image.fit={img['fit']!r} 未登记(应为 cover/contain)"))
|
|
1509
|
+
multi = {str(x).lower() for x in (spec.get("multiLayouts") or ["grid", "compare", "wall"])}
|
|
1510
|
+
if len(items) > max_per_page:
|
|
1511
|
+
issues.append(("IMAGE_ITEMS_TOO_MANY",
|
|
1512
|
+
f"sections[{i}].image.items 共 {len(items)} 张,超出单页上限 {max_per_page}"))
|
|
1513
|
+
if layout in multi and not img.get("placeholder") and len(items) < 2:
|
|
1514
|
+
issues.append(("IMAGE_ITEMS_TOO_MANY",
|
|
1515
|
+
f"sections[{i}].image.layout={layout!r} 属多图版式,items 需 ≥2 张(当前 {len(items)})"))
|
|
1516
|
+
for src in _image_real_srcs(img):
|
|
1517
|
+
if re.match(r"\s*(?:https?:)?//", src):
|
|
1518
|
+
issues.append(("IMAGE_SRC_EXTERNAL",
|
|
1519
|
+
f"sections[{i}].image 含外链 src({src[:48]}…):只允许 data: 内联或相对路径"))
|
|
1520
|
+
continue
|
|
1521
|
+
if base is None or src.startswith("data:"):
|
|
1522
|
+
continue
|
|
1523
|
+
p = Path(src)
|
|
1524
|
+
cand = p if p.is_absolute() else (base / p)
|
|
1525
|
+
if not cand.exists():
|
|
1526
|
+
issues.append(("IMAGE_SRC_MISSING",
|
|
1527
|
+
f"sections[{i}].image.src 指向的文件不存在({src[:60]},"
|
|
1528
|
+
f"按模型目录解析为 {cand}):图片会回落成占位框,请修正路径或用 data: 内联"))
|
|
1529
|
+
return issues
|
|
1530
|
+
|
|
1531
|
+
|
|
1532
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
1533
|
+
parser = argparse.ArgumentParser(
|
|
1534
|
+
description="TopPPT HTML PPTX 质检:结构 / 可编辑性 / 版式安全 / 内容保真。"
|
|
1535
|
+
)
|
|
1536
|
+
parser.add_argument("pptx", help="PPTX 文件路径")
|
|
1537
|
+
parser.add_argument("--model", help="TopPPT HTML model.json(extract_model.py 产物):往返保真检查")
|
|
1538
|
+
parser.add_argument("--allow-shape-charts", action="store_true",
|
|
1539
|
+
help="豁免 MODEL_CHART_COUNT 与 MODEL_CHART_NOTES_MISSING(仅 A 通道预览引擎回归;"
|
|
1540
|
+
"交付通道要求原生图表与备注数据表)")
|
|
1541
|
+
parser.add_argument("--strict", action="store_true", help="0 errors / 0 warnings 才通过")
|
|
1542
|
+
parser.add_argument("--deep", action="store_true",
|
|
1543
|
+
help="深度模式(高保真/1:1 诉求):额外跑连续文本流与空间锚点检查,并纳入 strict 门禁")
|
|
1544
|
+
parser.add_argument("--json-out", help="可选:把 UTF-8 JSON 报告写到该路径")
|
|
1545
|
+
parser.add_argument("--emit-manifest", metavar="PATH",
|
|
1546
|
+
help="深度模式:把 manifest(锚点/容器/数据表/图表通道/图片资产)写到该路径")
|
|
1547
|
+
return parser
|
|
1548
|
+
|
|
1549
|
+
|
|
1550
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1551
|
+
args = build_parser().parse_args(argv)
|
|
1552
|
+
report = validate_pptx(
|
|
1553
|
+
args.pptx,
|
|
1554
|
+
model_path=args.model,
|
|
1555
|
+
strict=args.strict,
|
|
1556
|
+
allow_shape_charts=args.allow_shape_charts,
|
|
1557
|
+
deep=args.deep,
|
|
1558
|
+
)
|
|
1559
|
+
if args.emit_manifest:
|
|
1560
|
+
manifest = build_manifest(report, args.model, deep=args.deep)
|
|
1561
|
+
target = Path(args.emit_manifest)
|
|
1562
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
1563
|
+
target.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
1564
|
+
print(f'manifest 已写出: {target}')
|
|
1565
|
+
payload = json.dumps(report, ensure_ascii=False, indent=2)
|
|
1566
|
+
if args.json_out:
|
|
1567
|
+
output = Path(args.json_out)
|
|
1568
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
1569
|
+
output.write_text(payload + "\n", encoding="utf-8")
|
|
1570
|
+
print(payload)
|
|
1571
|
+
if report["errors"]:
|
|
1572
|
+
return 1
|
|
1573
|
+
if args.strict and report["warnings"]:
|
|
1574
|
+
return 2
|
|
1575
|
+
return 0
|
|
1576
|
+
|
|
1577
|
+
|
|
1578
|
+
if __name__ == "__main__":
|
|
1579
|
+
sys.exit(main())
|