Skill Tracer
Skills › Scientific Agent Skills › pptx-posters › check_palette.py

check_palette.py

142 lines · skills/pptx-posters/scripts/check_palette.py at 5f2bc03

1#!/usr/bin/env python3
2"""Report declared WCAG contrast and heuristic palette separation."""
3
4from __future__ import annotations
5
6import argparse
7import itertools
8import sys
9from typing import Any
11from _common import CliError, contrast_ratio, emit_json, parse_hex_color, relative_luminance
12from _manifest import load_and_validate_manifest
14THRESHOLDS = {"normal_text": 4.5, "large_text": 3.0, "non_text": 3.0}
17def _lstar(color: str) -> float:
18 luminance = relative_luminance(color)
19 delta = 6.0 / 29.0
20 transformed = (
21 luminance ** (1.0 / 3.0)
22 if luminance > delta**3
23 else luminance / (3.0 * delta**2) + 4.0 / 29.0
24 )
25 return 116.0 * transformed - 16.0
28def audit_palette(document: dict[str, Any]) -> dict[str, Any]:
29 palette = document["palette"]
30 colors = {
31 color_id: parse_hex_color(value, context=f"palette.colors.{color_id}")
32 for color_id, value in palette["colors"].items()
33 }
34 declared: list[dict[str, Any]] = []
35 issues: list[dict[str, Any]] = []
36 for pair in palette["contrast_pairs"]:
37 foreground = colors[pair["foreground_color_id"]]
38 background = colors[pair["background_color_id"]]
39 ratio = contrast_ratio(foreground, background)
40 threshold = THRESHOLDS[pair["usage"]]
41 passed = ratio + 1e-9 >= threshold
42 declared.append(
43 {
44 "id": pair["id"],
45 "foreground_color_id": pair["foreground_color_id"],
46 "foreground": foreground,
47 "background_color_id": pair["background_color_id"],
48 "background": background,
49 "usage": pair["usage"],
50 "ratio": round(ratio, 3),
51 "threshold": threshold,
52 "pass": passed,
53 }
54 )
55 if not passed:
56 issues.append(
57 {
58 "code": "DECLARED_CONTRAST_FAILURE",
59 "pair_id": pair["id"],
60 "message": (
61 f"{ratio:.2f}:1 is below {threshold:.1f}:1 for "
62 f"{pair['usage']}"
63 ),
64 }
65 )
67 pairwise: list[dict[str, Any]] = []
68 for first_id, second_id in itertools.combinations(colors, 2):
69 first = colors[first_id]
70 second = colors[second_id]
71 pairwise.append(
72 {
73 "first_color_id": first_id,
74 "second_color_id": second_id,
75 "contrast_ratio": round(contrast_ratio(first, second), 3),
76 "grayscale_delta_lstar": round(abs(_lstar(first) - _lstar(second)), 3),
77 }
78 )
79 return {
80 "schema_version": "1.0",
81 "pass": not issues,
82 "declared_contrast_pairs": declared,
83 "pairwise_screen": pairwise,
84 "redundant_encoding_confirmed": palette[
85 "data_series_redundant_encoding"
86 ],
87 "issues": issues,
88 "standards_basis": {
89 "normal_text": "WCAG 2.2 SC 1.4.3, 4.5:1",
90 "large_text": (
91 "WCAG 2.2 SC 1.4.3, 3:1; large text is at least 18 pt, "
92 "or 14 pt and bold"
93 ),
94 "non_text": (
95 "WCAG 2.2 SC 1.4.11, 3:1 for graphical parts required "
96 "to understand content"
97 ),
98 },
99 "pairwise_notice": (
100 "Pairwise contrast and CIE L* separation are screening data, not a "
101 "color-vision accessibility certification. Keep direct labels and "
102 "redundant shape, marker, pattern, or line-style encoding."
103 ),
104 }
107def build_parser() -> argparse.ArgumentParser:
108 parser = argparse.ArgumentParser(
109 description=(
110 "Audit an approved poster manifest's declared sRGB foreground/background "
111 "pairs against WCAG 2.2 and report heuristic pairwise separation. "
112 "No network is used."
113 )
114 )
115 parser.add_argument("manifest", help="approved local poster manifest JSON")
116 parser.add_argument("--output", help="optional new JSON report path")
117 return parser
120def main(argv: list[str] | None = None) -> int:
121 parser = build_parser()
122 try:
123 args = parser.parse_args(argv)
124 _, document, validation = load_and_validate_manifest(
125 args.manifest,
126 verify_assets=False,
127 require_approval=True,
128 enforce_contrast=False,
129 )
130 report = audit_palette(document)
131 report["manifest"] = {
132 "path": validation["manifest_path"],
133 "content_sha256": validation["content_sha256"],
134 }
135 emit_json(report, output=args.output)
136 return 0 if report["pass"] else 1
137 except CliError as exc:
138 parser.exit(2, f"error: {exc}\n")
141if __name__ == "__main__":
142 sys.exit(main())