pptlint 0.3.1__py3-none-any.whl
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.
- decklint/__init__.py +3 -0
- decklint/__main__.py +4 -0
- decklint/cli.py +140 -0
- decklint/comparison.py +191 -0
- decklint/comparison_report.py +218 -0
- decklint/compat.py +13 -0
- decklint/model.py +613 -0
- decklint/readiness.py +149 -0
- decklint/render.py +182 -0
- decklint/report.py +251 -0
- decklint/rules.py +500 -0
- decklint/schema.py +82 -0
- decklint/scoring.py +105 -0
- pptlint/__init__.py +5 -0
- pptlint/__main__.py +4 -0
- pptlint/benchmark.py +120 -0
- pptlint/cli.py +1 -0
- pptlint/comparison.py +1 -0
- pptlint/comparison_report.py +1 -0
- pptlint/model.py +1 -0
- pptlint/readiness.py +1 -0
- pptlint/render.py +1 -0
- pptlint/report.py +1 -0
- pptlint/rules.py +1 -0
- pptlint/schema.py +1 -0
- pptlint/scoring.py +1 -0
- pptlint-0.3.1.dist-info/METADATA +139 -0
- pptlint-0.3.1.dist-info/RECORD +31 -0
- pptlint-0.3.1.dist-info/WHEEL +4 -0
- pptlint-0.3.1.dist-info/entry_points.txt +3 -0
- pptlint-0.3.1.dist-info/licenses/LICENSE +21 -0
decklint/__init__.py
ADDED
decklint/__main__.py
ADDED
decklint/cli.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .comparison import ComparisonError, load_audit_report
|
|
8
|
+
from .comparison_report import build_comparison_report, write_comparison_reports
|
|
9
|
+
from .model import DeckLoadError, load_deck
|
|
10
|
+
from .render import RenderError, render_deck
|
|
11
|
+
from .report import build_report, write_reports
|
|
12
|
+
from .rules import audit_deck
|
|
13
|
+
from .scoring import score_findings
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
SEVERITY_RANK = {"low": 1, "medium": 2, "high": 3, "critical": 4}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _score_value(value: str) -> int:
|
|
20
|
+
score = int(value)
|
|
21
|
+
if not 0 <= score <= 100:
|
|
22
|
+
raise argparse.ArgumentTypeError("score must be between 0 and 100")
|
|
23
|
+
return score
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _add_check_arguments(command: argparse.ArgumentParser, *, output: str, fail_on: str) -> None:
|
|
27
|
+
command.add_argument("input", type=Path, help="PPTX file to check")
|
|
28
|
+
command.add_argument("--output", type=Path, default=Path(output), help="Report filename prefix")
|
|
29
|
+
command.add_argument("--profile", choices=("baseline", "ai-generated"), default="baseline")
|
|
30
|
+
command.add_argument("--renderer", choices=("auto", "wireframe", "libreoffice"), default="auto")
|
|
31
|
+
command.add_argument("--soffice-path", help="Optional path to the LibreOffice soffice executable")
|
|
32
|
+
command.add_argument("--fail-on", choices=("none", "low", "medium", "high", "critical"), default=fail_on)
|
|
33
|
+
command.add_argument("--min-score", type=_score_value, default=None)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
37
|
+
parser = argparse.ArgumentParser(
|
|
38
|
+
prog="pptlint", description="Preflight checks for AI-generated PowerPoint files."
|
|
39
|
+
)
|
|
40
|
+
subcommands = parser.add_subparsers(dest="command", required=True)
|
|
41
|
+
check = subcommands.add_parser("check", help="Check whether a PPTX is ready to deliver.")
|
|
42
|
+
_add_check_arguments(check, output="pptlint-report", fail_on="none")
|
|
43
|
+
audit = subcommands.add_parser("audit", help="Audit a PPTX and write offline HTML and JSON reports.")
|
|
44
|
+
_add_check_arguments(audit, output="decklint-report", fail_on="high")
|
|
45
|
+
compare = subcommands.add_parser("compare", help="Compare two DeckLint audit reports.")
|
|
46
|
+
compare.add_argument("before", type=Path, help="Before audit JSON report")
|
|
47
|
+
compare.add_argument("after", type=Path, help="After audit JSON report")
|
|
48
|
+
compare.add_argument(
|
|
49
|
+
"--output",
|
|
50
|
+
type=Path,
|
|
51
|
+
default=Path("decklint-comparison"),
|
|
52
|
+
help="Comparison report filename prefix",
|
|
53
|
+
)
|
|
54
|
+
compare.add_argument(
|
|
55
|
+
"--fail-on-regression",
|
|
56
|
+
choices=("none", "low", "medium", "high", "critical"),
|
|
57
|
+
default="high",
|
|
58
|
+
)
|
|
59
|
+
return parser
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _threshold_failed(findings, threshold: str) -> bool:
|
|
63
|
+
if threshold == "none":
|
|
64
|
+
return False
|
|
65
|
+
threshold_rank = SEVERITY_RANK[threshold]
|
|
66
|
+
return any(
|
|
67
|
+
finding.confidence == "high" and SEVERITY_RANK[finding.severity] >= threshold_rank
|
|
68
|
+
for finding in findings
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _run_audit(args: argparse.Namespace) -> int:
|
|
73
|
+
try:
|
|
74
|
+
deck = load_deck(args.input.expanduser())
|
|
75
|
+
findings = audit_deck(deck, profile=args.profile)
|
|
76
|
+
scores = score_findings(findings)
|
|
77
|
+
rendering = render_deck(
|
|
78
|
+
deck,
|
|
79
|
+
source=args.input.expanduser(),
|
|
80
|
+
renderer=args.renderer,
|
|
81
|
+
soffice_path=args.soffice_path,
|
|
82
|
+
)
|
|
83
|
+
report = build_report(deck, findings, scores, rendering, profile=args.profile)
|
|
84
|
+
html_path, json_path = write_reports(args.output.expanduser(), report)
|
|
85
|
+
except (DeckLoadError, RenderError, OSError, ValueError) as exc:
|
|
86
|
+
print(f"PPTLint could not check the file: {exc}", file=sys.stderr)
|
|
87
|
+
return 2
|
|
88
|
+
|
|
89
|
+
status = report["readiness"]["status"]
|
|
90
|
+
result_label = {
|
|
91
|
+
"ready": "Ready to send",
|
|
92
|
+
"review": "Check before sending",
|
|
93
|
+
"blocked": "Fix before sending",
|
|
94
|
+
}[status]
|
|
95
|
+
print(f"PPTLint result: {result_label}")
|
|
96
|
+
actions = report["priorityActions"]
|
|
97
|
+
if actions:
|
|
98
|
+
print("Next actions:")
|
|
99
|
+
for action in actions:
|
|
100
|
+
slide = action.get("slideIndex")
|
|
101
|
+
location = f"Slide {slide}" if slide else "Whole file"
|
|
102
|
+
print(f"- {location}: {action['impact']}")
|
|
103
|
+
else:
|
|
104
|
+
print("No high-confidence delivery problem was found.")
|
|
105
|
+
print(f"Open the HTML report for the highlighted slides: {html_path}")
|
|
106
|
+
print(f"JSON report: {json_path}")
|
|
107
|
+
failed = _threshold_failed(findings, args.fail_on)
|
|
108
|
+
if args.command == "check" and report["readiness"]["status"] == "blocked":
|
|
109
|
+
failed = True
|
|
110
|
+
if args.min_score is not None and scores.overall < args.min_score:
|
|
111
|
+
failed = True
|
|
112
|
+
return 1 if failed else 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _run_compare(args: argparse.Namespace) -> int:
|
|
116
|
+
try:
|
|
117
|
+
before = load_audit_report(args.before.expanduser())
|
|
118
|
+
after = load_audit_report(args.after.expanduser())
|
|
119
|
+
report = build_comparison_report(
|
|
120
|
+
before,
|
|
121
|
+
after,
|
|
122
|
+
threshold=args.fail_on_regression,
|
|
123
|
+
)
|
|
124
|
+
html_path, json_path = write_comparison_reports(args.output.expanduser(), report)
|
|
125
|
+
except (ComparisonError, OSError, ValueError) as exc:
|
|
126
|
+
print(f"DeckLint could not compare the reports: {exc}", file=sys.stderr)
|
|
127
|
+
return 2
|
|
128
|
+
|
|
129
|
+
overall = report["scores"]["overall"]
|
|
130
|
+
print(f"PPTLint comparison {overall['before']} -> {overall['after']} ({overall['delta']:+d})")
|
|
131
|
+
print(f"HTML report: {html_path}")
|
|
132
|
+
print(f"JSON report: {json_path}")
|
|
133
|
+
return 0 if report["gate"]["passed"] else 1
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def main(argv: list[str] | None = None) -> int:
|
|
137
|
+
args = build_parser().parse_args(argv)
|
|
138
|
+
if args.command == "compare":
|
|
139
|
+
return _run_compare(args)
|
|
140
|
+
return _run_audit(args)
|
decklint/comparison.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
MAX_REPORT_BYTES = 100 * 1024 * 1024
|
|
10
|
+
SEVERITY_RANK = {"low": 1, "medium": 2, "high": 3, "critical": 4}
|
|
11
|
+
COMPARISON_FINDING_FIELDS = (
|
|
12
|
+
"id",
|
|
13
|
+
"rule_id",
|
|
14
|
+
"category",
|
|
15
|
+
"severity",
|
|
16
|
+
"confidence",
|
|
17
|
+
"message",
|
|
18
|
+
"evidence",
|
|
19
|
+
"remediation",
|
|
20
|
+
"slide_index",
|
|
21
|
+
"shape_id",
|
|
22
|
+
"bbox",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ComparisonError(ValueError):
|
|
27
|
+
"""Raised when audit reports cannot be compared safely."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class FindingMatch:
|
|
32
|
+
resolved: list[dict[str, object]]
|
|
33
|
+
persistent: list[dict[str, object]]
|
|
34
|
+
new: list[dict[str, object]]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _group_key(item: dict[str, object]) -> tuple[str, int]:
|
|
38
|
+
return (str(item.get("rule_id", "")), int(item.get("slide_index") or 0))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _sort_key(item: dict[str, object]) -> tuple[object, ...]:
|
|
42
|
+
candidate = item.get("bbox")
|
|
43
|
+
bbox = candidate if isinstance(candidate, dict) else {}
|
|
44
|
+
return (
|
|
45
|
+
float(bbox.get("x", 0)),
|
|
46
|
+
float(bbox.get("y", 0)),
|
|
47
|
+
float(bbox.get("w", 0)),
|
|
48
|
+
float(bbox.get("h", 0)),
|
|
49
|
+
str(item.get("shape_id") or ""),
|
|
50
|
+
str(item.get("evidence", "")),
|
|
51
|
+
str(item.get("id", "")),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def match_findings(
|
|
56
|
+
before: list[dict[str, object]],
|
|
57
|
+
after: list[dict[str, object]],
|
|
58
|
+
) -> FindingMatch:
|
|
59
|
+
grouped_before: dict[tuple[str, int], list[dict[str, object]]] = defaultdict(list)
|
|
60
|
+
grouped_after: dict[tuple[str, int], list[dict[str, object]]] = defaultdict(list)
|
|
61
|
+
for item in before:
|
|
62
|
+
grouped_before[_group_key(item)].append(item)
|
|
63
|
+
for item in after:
|
|
64
|
+
grouped_after[_group_key(item)].append(item)
|
|
65
|
+
|
|
66
|
+
resolved: list[dict[str, object]] = []
|
|
67
|
+
persistent: list[dict[str, object]] = []
|
|
68
|
+
new: list[dict[str, object]] = []
|
|
69
|
+
for key in sorted(set(grouped_before) | set(grouped_after)):
|
|
70
|
+
left = sorted(grouped_before[key], key=_sort_key)
|
|
71
|
+
right = sorted(grouped_after[key], key=_sort_key)
|
|
72
|
+
paired = min(len(left), len(right))
|
|
73
|
+
persistent.extend({"before": left[index], "after": right[index]} for index in range(paired))
|
|
74
|
+
resolved.extend(left[paired:])
|
|
75
|
+
new.extend(right[paired:])
|
|
76
|
+
return FindingMatch(resolved=resolved, persistent=persistent, new=new)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _dict_field(container: dict[str, object], key: str) -> dict[str, object]:
|
|
80
|
+
value = container.get(key)
|
|
81
|
+
if not isinstance(value, dict):
|
|
82
|
+
raise ComparisonError(f"Audit report field must be an object: {key}")
|
|
83
|
+
return value
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _list_field(container: dict[str, object], key: str) -> list[dict[str, object]]:
|
|
87
|
+
value = container.get(key)
|
|
88
|
+
if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
|
|
89
|
+
raise ComparisonError(f"Audit report field must be an object array: {key}")
|
|
90
|
+
return value
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _comparison_finding(item: dict[str, object]) -> dict[str, object]:
|
|
94
|
+
"""Keep the stable v1 comparison shape when reading richer v2 reports."""
|
|
95
|
+
return {field: item.get(field) for field in COMPARISON_FINDING_FIELDS}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _delta_map(
|
|
99
|
+
before: dict[str, object],
|
|
100
|
+
after: dict[str, object],
|
|
101
|
+
) -> dict[str, dict[str, int]]:
|
|
102
|
+
return {
|
|
103
|
+
key: {
|
|
104
|
+
"before": int(before.get(key, 0)),
|
|
105
|
+
"after": int(after.get(key, 0)),
|
|
106
|
+
"delta": int(after.get(key, 0)) - int(before.get(key, 0)),
|
|
107
|
+
}
|
|
108
|
+
for key in sorted(set(before) | set(after))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def compare_reports(
|
|
113
|
+
before: dict[str, object],
|
|
114
|
+
after: dict[str, object],
|
|
115
|
+
*,
|
|
116
|
+
threshold: str = "high",
|
|
117
|
+
) -> dict[str, object]:
|
|
118
|
+
if threshold not in {"none", *SEVERITY_RANK}:
|
|
119
|
+
raise ComparisonError(f"Unsupported regression threshold: {threshold}")
|
|
120
|
+
before_findings = [_comparison_finding(item) for item in _list_field(before, "findings")]
|
|
121
|
+
after_findings = [_comparison_finding(item) for item in _list_field(after, "findings")]
|
|
122
|
+
matches = match_findings(before_findings, after_findings)
|
|
123
|
+
before_scores = _dict_field(before, "scores")
|
|
124
|
+
after_scores = _dict_field(after, "scores")
|
|
125
|
+
score_before = int(before_scores.get("overall", 0))
|
|
126
|
+
score_after = int(after_scores.get("overall", 0))
|
|
127
|
+
reasons: list[str] = []
|
|
128
|
+
if threshold != "none" and score_after < score_before:
|
|
129
|
+
reasons.append("overall-score-decreased")
|
|
130
|
+
if threshold != "none":
|
|
131
|
+
threshold_rank = SEVERITY_RANK[threshold]
|
|
132
|
+
if any(
|
|
133
|
+
item.get("confidence") == "high"
|
|
134
|
+
and SEVERITY_RANK.get(str(item.get("severity")), 0) >= threshold_rank
|
|
135
|
+
for item in matches.new
|
|
136
|
+
):
|
|
137
|
+
reasons.append("new-high-confidence-finding")
|
|
138
|
+
return {
|
|
139
|
+
"schemaVersion": "decklint-comparison/v1",
|
|
140
|
+
"scores": {
|
|
141
|
+
"overall": {
|
|
142
|
+
"before": score_before,
|
|
143
|
+
"after": score_after,
|
|
144
|
+
"delta": score_after - score_before,
|
|
145
|
+
},
|
|
146
|
+
"categories": _delta_map(
|
|
147
|
+
_dict_field(before_scores, "categories"),
|
|
148
|
+
_dict_field(after_scores, "categories"),
|
|
149
|
+
),
|
|
150
|
+
},
|
|
151
|
+
"severity": _delta_map(
|
|
152
|
+
_dict_field(before, "summary"),
|
|
153
|
+
_dict_field(after, "summary"),
|
|
154
|
+
),
|
|
155
|
+
"resolved": matches.resolved,
|
|
156
|
+
"persistent": matches.persistent,
|
|
157
|
+
"new": matches.new,
|
|
158
|
+
"gate": {
|
|
159
|
+
"threshold": threshold,
|
|
160
|
+
"passed": not reasons,
|
|
161
|
+
"failureReasons": reasons,
|
|
162
|
+
},
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def load_audit_report(path: Path) -> dict[str, object]:
|
|
167
|
+
path = Path(path)
|
|
168
|
+
if not path.is_file():
|
|
169
|
+
raise ComparisonError(f"Audit report not found: {path.name}")
|
|
170
|
+
if path.stat().st_size > MAX_REPORT_BYTES:
|
|
171
|
+
raise ComparisonError("Audit report exceeds the 100 MiB safety limit")
|
|
172
|
+
try:
|
|
173
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
174
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
175
|
+
raise ComparisonError(f"Invalid audit report JSON: {path.name}") from exc
|
|
176
|
+
if not isinstance(payload, dict):
|
|
177
|
+
raise ComparisonError("Audit report root must be a JSON object")
|
|
178
|
+
if payload.get("schemaVersion") not in {"decklint-report/v1", "pptlint-report/v2"}:
|
|
179
|
+
raise ComparisonError("Audit report must use decklint-report/v1 or pptlint-report/v2")
|
|
180
|
+
for key in (
|
|
181
|
+
"file",
|
|
182
|
+
"scores",
|
|
183
|
+
"summary",
|
|
184
|
+
"findings",
|
|
185
|
+
"slides",
|
|
186
|
+
"profile",
|
|
187
|
+
"renderer",
|
|
188
|
+
):
|
|
189
|
+
if key not in payload:
|
|
190
|
+
raise ComparisonError(f"Audit report is missing required field: {key}")
|
|
191
|
+
return payload
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import html
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from . import __version__
|
|
8
|
+
from .comparison import ComparisonError, compare_reports
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _object(container: dict[str, object], key: str) -> dict[str, object]:
|
|
12
|
+
value = container.get(key)
|
|
13
|
+
if not isinstance(value, dict):
|
|
14
|
+
raise ComparisonError(f"Audit report field must be an object: {key}")
|
|
15
|
+
return value
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _identity(report: dict[str, object]) -> dict[str, object]:
|
|
19
|
+
file_info = _object(report, "file")
|
|
20
|
+
renderer = _object(report, "renderer")
|
|
21
|
+
return {
|
|
22
|
+
"sourceSchema": str(report.get("schemaVersion", "")),
|
|
23
|
+
"profile": str(report.get("profile", "")),
|
|
24
|
+
"file": {
|
|
25
|
+
"name": str(file_info.get("name", "")),
|
|
26
|
+
"sha256": str(file_info.get("sha256", "")),
|
|
27
|
+
"slides": int(file_info.get("slides", 0)),
|
|
28
|
+
},
|
|
29
|
+
"renderer": {
|
|
30
|
+
"requested": str(renderer.get("requested", "")),
|
|
31
|
+
"used": str(renderer.get("used", "")),
|
|
32
|
+
"status": str(renderer.get("status", "")),
|
|
33
|
+
"detail": str(renderer.get("detail", "")),
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _slide_map(report: dict[str, object]) -> dict[int, dict[str, object]]:
|
|
39
|
+
slides = report.get("slides")
|
|
40
|
+
if not isinstance(slides, list):
|
|
41
|
+
raise ComparisonError("Audit report field must be an array: slides")
|
|
42
|
+
mapped: dict[int, dict[str, object]] = {}
|
|
43
|
+
for item in slides:
|
|
44
|
+
if not isinstance(item, dict):
|
|
45
|
+
raise ComparisonError("Audit report slides must contain objects")
|
|
46
|
+
index = int(item.get("index", 0))
|
|
47
|
+
if index <= 0 or index in mapped:
|
|
48
|
+
raise ComparisonError("Audit report slide indices must be unique positive integers")
|
|
49
|
+
mapped[index] = {
|
|
50
|
+
"index": index,
|
|
51
|
+
"title": str(item.get("title", "")),
|
|
52
|
+
"titleSource": str(item.get("titleSource", "none")),
|
|
53
|
+
"preview": str(item.get("preview", "")),
|
|
54
|
+
}
|
|
55
|
+
return mapped
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _pair_slides(
|
|
59
|
+
before: dict[str, object],
|
|
60
|
+
after: dict[str, object],
|
|
61
|
+
) -> list[dict[str, object]]:
|
|
62
|
+
before_slides = _slide_map(before)
|
|
63
|
+
after_slides = _slide_map(after)
|
|
64
|
+
return [
|
|
65
|
+
{
|
|
66
|
+
"index": index,
|
|
67
|
+
"before": before_slides.get(index),
|
|
68
|
+
"after": after_slides.get(index),
|
|
69
|
+
}
|
|
70
|
+
for index in sorted(set(before_slides) | set(after_slides))
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _agent_summary(core: dict[str, object]) -> str:
|
|
75
|
+
scores = _object(core, "scores")
|
|
76
|
+
overall = _object(scores, "overall")
|
|
77
|
+
gate = _object(core, "gate")
|
|
78
|
+
status = "通过" if gate.get("passed") else "未通过"
|
|
79
|
+
return (
|
|
80
|
+
f"比较门禁{status};总分从 {overall['before']} 变为 {overall['after']}"
|
|
81
|
+
f"(变化 {int(overall['delta']):+d});已解决 {len(core['resolved'])} 项,"
|
|
82
|
+
f"持续存在 {len(core['persistent'])} 项,新增 {len(core['new'])} 项。"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def build_comparison_report(
|
|
87
|
+
before: dict[str, object],
|
|
88
|
+
after: dict[str, object],
|
|
89
|
+
*,
|
|
90
|
+
threshold: str,
|
|
91
|
+
) -> dict[str, object]:
|
|
92
|
+
core = compare_reports(before, after, threshold=threshold)
|
|
93
|
+
return {
|
|
94
|
+
"schemaVersion": "decklint-comparison/v1",
|
|
95
|
+
"toolVersion": __version__,
|
|
96
|
+
"before": _identity(before),
|
|
97
|
+
"after": _identity(after),
|
|
98
|
+
"scores": core["scores"],
|
|
99
|
+
"severity": core["severity"],
|
|
100
|
+
"resolved": core["resolved"],
|
|
101
|
+
"persistent": core["persistent"],
|
|
102
|
+
"new": core["new"],
|
|
103
|
+
"gate": core["gate"],
|
|
104
|
+
"slides": _pair_slides(before, after),
|
|
105
|
+
"agentSummary": _agent_summary(core),
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _escape(value: object) -> str:
|
|
110
|
+
return html.escape(str(value), quote=True)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _finding_list(title: str, items: list[dict[str, object]], css_class: str) -> str:
|
|
114
|
+
rendered = "".join(
|
|
115
|
+
"<li>"
|
|
116
|
+
f"<code>{_escape(item.get('rule_id', ''))}</code>"
|
|
117
|
+
f"<strong>第 {_escape(item.get('slide_index') or '—')} 页</strong>"
|
|
118
|
+
f"<span>{_escape(item.get('message', ''))}</span>"
|
|
119
|
+
"</li>"
|
|
120
|
+
for item in items
|
|
121
|
+
)
|
|
122
|
+
if not rendered:
|
|
123
|
+
rendered = "<li class=empty>无</li>"
|
|
124
|
+
return (
|
|
125
|
+
f'<section class="finding-group {css_class}"><h2>{_escape(title)}</h2><ul>{rendered}</ul></section>'
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _render_comparison_html(report: dict[str, object]) -> str:
|
|
130
|
+
before = _object(report, "before")
|
|
131
|
+
after = _object(report, "after")
|
|
132
|
+
before_file = _object(before, "file")
|
|
133
|
+
after_file = _object(after, "file")
|
|
134
|
+
scores = _object(report, "scores")
|
|
135
|
+
overall = _object(scores, "overall")
|
|
136
|
+
categories = _object(scores, "categories")
|
|
137
|
+
gate = _object(report, "gate")
|
|
138
|
+
category_names = {
|
|
139
|
+
"integrity": "完整性",
|
|
140
|
+
"readability": "可读性",
|
|
141
|
+
"editability": "可编辑性",
|
|
142
|
+
"consistency": "一致性",
|
|
143
|
+
"accessibility": "无障碍",
|
|
144
|
+
}
|
|
145
|
+
score_cards = "".join(
|
|
146
|
+
f"<article><span>{_escape(category_names.get(name, name))}</span>"
|
|
147
|
+
f"<b>{_escape(change['before'])} → {_escape(change['after'])}</b>"
|
|
148
|
+
f"<em>{int(change['delta']):+d}</em></article>"
|
|
149
|
+
for name, change in categories.items()
|
|
150
|
+
if isinstance(change, dict)
|
|
151
|
+
)
|
|
152
|
+
slide_cards = ""
|
|
153
|
+
slides = report.get("slides")
|
|
154
|
+
assert isinstance(slides, list)
|
|
155
|
+
for pair in slides:
|
|
156
|
+
assert isinstance(pair, dict)
|
|
157
|
+
index = int(pair["index"])
|
|
158
|
+
left = pair.get("before") if isinstance(pair.get("before"), dict) else {}
|
|
159
|
+
right = pair.get("after") if isinstance(pair.get("after"), dict) else {}
|
|
160
|
+
slide_cards += (
|
|
161
|
+
f'<article class="slide-pair"><header><span>第 {index:02d} 页</span>'
|
|
162
|
+
f"<h2>{_escape(right.get('title') or left.get('title') or '未命名页面')}</h2></header>"
|
|
163
|
+
'<div class="preview"><figure><figcaption>改造前</figcaption>'
|
|
164
|
+
f'<img alt="第 {index} 页改造前预览" src="{_escape(left.get("preview", ""))}"></figure>'
|
|
165
|
+
"<figure><figcaption>改造后</figcaption>"
|
|
166
|
+
f'<img alt="第 {index} 页改造后预览" src="{_escape(right.get("preview", ""))}"></figure></div></article>'
|
|
167
|
+
)
|
|
168
|
+
resolved = report.get("resolved")
|
|
169
|
+
new = report.get("new")
|
|
170
|
+
persistent = report.get("persistent")
|
|
171
|
+
assert isinstance(resolved, list) and isinstance(new, list) and isinstance(persistent, list)
|
|
172
|
+
persistent_after = [
|
|
173
|
+
item["after"] for item in persistent if isinstance(item, dict) and isinstance(item.get("after"), dict)
|
|
174
|
+
]
|
|
175
|
+
findings = (
|
|
176
|
+
_finding_list("已解决", resolved, "resolved")
|
|
177
|
+
+ _finding_list("持续存在", persistent_after, "persistent")
|
|
178
|
+
+ _finding_list("新增问题", new, "new")
|
|
179
|
+
)
|
|
180
|
+
raw_json = json.dumps(report, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
181
|
+
safe_json = raw_json.replace("&", "\\u0026").replace("<", "\\u003c").replace(">", "\\u003e")
|
|
182
|
+
gate_label = "通过" if gate.get("passed") else "未通过"
|
|
183
|
+
return f"""<!doctype html>
|
|
184
|
+
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
185
|
+
<title>PPTLint 比较报告 · {_escape(after_file["name"])}</title>
|
|
186
|
+
<style>
|
|
187
|
+
:root{{--ink:#10233f;--paper:#f2f0eb;--panel:#fff;--muted:#667085;--good:#18794e;--warn:#b54708;--bad:#b42318}}
|
|
188
|
+
*{{box-sizing:border-box}}body{{margin:0;background:var(--paper);color:var(--ink);font:15px/1.65 "PingFang SC","Microsoft YaHei",system-ui,sans-serif}}
|
|
189
|
+
main{{max-width:1180px;margin:auto;padding:52px 24px 88px}}header.hero{{border-bottom:3px solid var(--ink);padding-bottom:28px}}
|
|
190
|
+
.kicker{{font-size:12px;font-weight:700;letter-spacing:.14em;color:#8a3d22}}h1{{font-size:clamp(38px,7vw,76px);line-height:1.05;margin:10px 0}}.hero p{{color:var(--muted);margin:0}}
|
|
191
|
+
.overall{{display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:22px;background:var(--panel);padding:28px;margin:28px 0}}.overall strong{{font-size:54px}}.overall i{{font-size:34px;color:var(--good)}}.overall .after{{text-align:right}}.gate{{font-weight:700;color:{"var(--good)" if gate.get("passed") else "var(--bad)"}}}
|
|
192
|
+
.scores{{display:grid;grid-template-columns:repeat(5,1fr);gap:12px}}.scores article{{background:var(--panel);padding:16px;border-top:3px solid var(--ink)}}.scores span,.scores b,.scores em{{display:block}}.scores em{{color:var(--good);font-style:normal}}
|
|
193
|
+
.groups{{display:grid;grid-template-columns:repeat(3,1fr);gap:18px;margin:42px 0}}.finding-group{{background:var(--panel);padding:20px;border-top:5px solid var(--muted)}}.resolved{{border-color:var(--good)}}.new{{border-color:var(--bad)}}ul{{list-style:none;margin:0;padding:0}}li{{padding:9px 0;border-top:1px solid #e4e7ec}}li code,li strong,li span{{display:block}}li code{{font-size:11px;color:var(--muted)}}
|
|
194
|
+
.slide-grid{{display:grid;gap:24px}}.slide-pair{{background:var(--panel);padding:20px}}.slide-pair header{{display:flex;gap:18px;align-items:baseline}}.slide-pair h2{{margin:0 0 12px}}.preview{{display:grid;grid-template-columns:1fr 1fr;gap:14px}}figure{{margin:0}}figcaption{{font-weight:700;margin-bottom:6px}}img{{display:block;width:100%;background:#e4e7ec;min-height:32px}}
|
|
195
|
+
@media(max-width:820px){{.scores,.groups{{grid-template-columns:1fr 1fr}}.preview{{grid-template-columns:1fr}}}}@media(max-width:560px){{.scores,.groups{{grid-template-columns:1fr}}}}
|
|
196
|
+
</style></head><body><main>
|
|
197
|
+
<header class="hero"><span class="kicker">PPTLINT · POWERPOINT 修改前后检查</span><h1>改造前后比较报告</h1><p>{_escape(before_file["name"])} → {_escape(after_file["name"])}</p></header>
|
|
198
|
+
<section class="overall"><div><span>改造前</span><strong>{_escape(overall["before"])}</strong></div><i>{int(overall["delta"]):+d}</i><div class="after"><span>改造后</span><strong>{_escape(overall["after"])}</strong></div></section>
|
|
199
|
+
<p class="gate">回归门禁:{gate_label}</p><section class="scores">{score_cards}</section>
|
|
200
|
+
<section class="groups">{findings}</section><section class="slide-grid">{slide_cards}</section>
|
|
201
|
+
<script id="decklint-comparison-data" type="application/json">{safe_json}</script>
|
|
202
|
+
</main></body></html>"""
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def write_comparison_reports(
|
|
206
|
+
output_stem: Path,
|
|
207
|
+
report: dict[str, object],
|
|
208
|
+
) -> tuple[Path, Path]:
|
|
209
|
+
output_stem = Path(output_stem)
|
|
210
|
+
output_stem.parent.mkdir(parents=True, exist_ok=True)
|
|
211
|
+
html_path = Path(f"{output_stem}.html")
|
|
212
|
+
json_path = Path(f"{output_stem}.json")
|
|
213
|
+
html_path.write_text(_render_comparison_html(report), encoding="utf-8")
|
|
214
|
+
json_path.write_text(
|
|
215
|
+
json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
|
216
|
+
encoding="utf-8",
|
|
217
|
+
)
|
|
218
|
+
return html_path, json_path
|
decklint/compat.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from .cli import main as pptlint_main
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main(argv: list[str] | None = None) -> int:
|
|
9
|
+
print(
|
|
10
|
+
"DeckLint is now PPTLint. The decklint command remains available through v0.4; migrate to `pptlint check`.",
|
|
11
|
+
file=sys.stderr,
|
|
12
|
+
)
|
|
13
|
+
return pptlint_main(argv)
|