pbi-enterprise-cli 0.1.0.dev0__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.
- pbi_cli/__init__.py +3 -0
- pbi_cli/_audit.py +57 -0
- pbi_cli/_snapshot.py +95 -0
- pbi_cli/backends/__init__.py +1 -0
- pbi_cli/backends/mock_backend.py +323 -0
- pbi_cli/backends/pbir_backend.py +813 -0
- pbi_cli/backends/protocol.py +52 -0
- pbi_cli/backends/tom_backend.py +650 -0
- pbi_cli/backends/xmla_backend.py +627 -0
- pbi_cli/cli.py +332 -0
- pbi_cli/commands/__init__.py +1 -0
- pbi_cli/commands/_doctor.py +84 -0
- pbi_cli/commands/_shared.py +88 -0
- pbi_cli/commands/calendar_cmd.py +186 -0
- pbi_cli/commands/connections.py +153 -0
- pbi_cli/commands/custom_visual.py +325 -0
- pbi_cli/commands/database.py +76 -0
- pbi_cli/commands/dax.py +174 -0
- pbi_cli/commands/deploy.py +193 -0
- pbi_cli/commands/docs.py +57 -0
- pbi_cli/commands/filter_cmd.py +235 -0
- pbi_cli/commands/govern.py +124 -0
- pbi_cli/commands/layout.py +104 -0
- pbi_cli/commands/measure.py +185 -0
- pbi_cli/commands/model.py +499 -0
- pbi_cli/commands/partition.py +89 -0
- pbi_cli/commands/repl.py +209 -0
- pbi_cli/commands/report.py +561 -0
- pbi_cli/commands/security.py +90 -0
- pbi_cli/commands/server_cmd.py +30 -0
- pbi_cli/commands/skills_cmd.py +168 -0
- pbi_cli/commands/source.py +581 -0
- pbi_cli/commands/theme.py +60 -0
- pbi_cli/commands/trace.py +142 -0
- pbi_cli/commands/visual.py +507 -0
- pbi_cli/commands/watch.py +145 -0
- pbi_cli/docs_gen/__init__.py +1 -0
- pbi_cli/docs_gen/confluence.py +24 -0
- pbi_cli/docs_gen/markdown.py +36 -0
- pbi_cli/governance/__init__.py +1 -0
- pbi_cli/governance/engine.py +70 -0
- pbi_cli/governance/rules/__init__.py +85 -0
- pbi_cli/governance/rules/measure_brackets.py +27 -0
- pbi_cli/governance/rules/measure_description.py +41 -0
- pbi_cli/governance/rules/measure_format.py +38 -0
- pbi_cli/governance/rules/measure_naming.py +93 -0
- pbi_cli/governance/rules/table_pascal_case.py +44 -0
- pbi_cli/intelligence/__init__.py +1 -0
- pbi_cli/intelligence/layout_engine.py +192 -0
- pbi_cli/intelligence/measure_generator.py +40 -0
- pbi_cli/intelligence/theme_generator.py +193 -0
- pbi_cli/intelligence/visual_builder.py +429 -0
- pbi_cli/intelligence/visual_recommender.py +42 -0
- pbi_cli/server/__init__.py +1 -0
- pbi_cli/server/api.py +185 -0
- pbi_enterprise_cli-0.1.0.dev0.dist-info/METADATA +103 -0
- pbi_enterprise_cli-0.1.0.dev0.dist-info/RECORD +61 -0
- pbi_enterprise_cli-0.1.0.dev0.dist-info/WHEEL +5 -0
- pbi_enterprise_cli-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- pbi_enterprise_cli-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
- pbi_enterprise_cli-0.1.0.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Shelf-packing layout engine for Power BI visuals."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
VISUAL_SIZES = {
|
|
9
|
+
"kpi": (200, 120),
|
|
10
|
+
"card": (300, 130),
|
|
11
|
+
"chart": (624, 240),
|
|
12
|
+
"bar": (624, 240),
|
|
13
|
+
"column": (624, 240),
|
|
14
|
+
"line": (624, 240),
|
|
15
|
+
"scatter": (624, 300),
|
|
16
|
+
"gauge": (300, 200),
|
|
17
|
+
"donut": (400, 280),
|
|
18
|
+
"pie": (400, 280),
|
|
19
|
+
"treemap": (624, 300),
|
|
20
|
+
"funnel": (400, 300),
|
|
21
|
+
"waterfall": (624, 300),
|
|
22
|
+
"matrix": (900, 400),
|
|
23
|
+
"ribbon": (624, 240),
|
|
24
|
+
"table": (900, 400),
|
|
25
|
+
"multirow": (624, 200),
|
|
26
|
+
"slicer": (200, 56),
|
|
27
|
+
"map": (624, 400),
|
|
28
|
+
"stackedbar": (624, 240),
|
|
29
|
+
"stackedcolumn": (624, 240),
|
|
30
|
+
"100percentbar": (624, 240),
|
|
31
|
+
"100percentcolumn": (624, 240),
|
|
32
|
+
"area": (624, 240),
|
|
33
|
+
"stackedarea": (624, 240),
|
|
34
|
+
"combo": (624, 280),
|
|
35
|
+
"bubble": (624, 300),
|
|
36
|
+
"filledmap": (624, 400),
|
|
37
|
+
"azuremap": (624, 400),
|
|
38
|
+
"decomptree": (900, 500),
|
|
39
|
+
"keyinfluencers": (900, 500),
|
|
40
|
+
"smartnarrative": (624, 240),
|
|
41
|
+
"qanda": (624, 300),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# Map Power BI internal visualType names → layout engine short names
|
|
45
|
+
_PBI_TYPE_TO_SHORT: dict[str, str] = {
|
|
46
|
+
"card": "card",
|
|
47
|
+
"kpiVisual": "kpi",
|
|
48
|
+
"multiRowCard": "multirow",
|
|
49
|
+
"barChart": "bar",
|
|
50
|
+
"clusteredBarChart": "bar",
|
|
51
|
+
"columnChart": "column",
|
|
52
|
+
"clusteredColumnChart": "column",
|
|
53
|
+
"stackedBarChart": "stackedbar",
|
|
54
|
+
"stackedColumnChart": "stackedcolumn",
|
|
55
|
+
"hundredPercentStackedBarChart": "100percentbar",
|
|
56
|
+
"hundredPercentStackedColumnChart": "100percentcolumn",
|
|
57
|
+
"lineChart": "line",
|
|
58
|
+
"areaChart": "area",
|
|
59
|
+
"stackedAreaChart": "stackedarea",
|
|
60
|
+
"lineClusteredColumnComboChart": "combo",
|
|
61
|
+
"lineStackedColumnComboChart": "combo",
|
|
62
|
+
"scatterChart": "scatter",
|
|
63
|
+
"pieChart": "pie",
|
|
64
|
+
"donutChart": "donut",
|
|
65
|
+
"gauge": "gauge",
|
|
66
|
+
"waterfallChart": "waterfall",
|
|
67
|
+
"funnel": "funnel",
|
|
68
|
+
"ribbonChart": "ribbon",
|
|
69
|
+
"treemap": "treemap",
|
|
70
|
+
"tableEx": "table",
|
|
71
|
+
"pivotTable": "matrix",
|
|
72
|
+
"slicer": "slicer",
|
|
73
|
+
"map": "map",
|
|
74
|
+
"filledMap": "filledmap",
|
|
75
|
+
"azureMap": "azuremap",
|
|
76
|
+
"decompositionTreeVisual": "decomptree",
|
|
77
|
+
"keyDrivers": "keyinfluencers",
|
|
78
|
+
"narrativeVisual": "smartnarrative",
|
|
79
|
+
"qnaVisual": "qanda",
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
PRIORITY_ORDER = [
|
|
83
|
+
"kpi",
|
|
84
|
+
"card",
|
|
85
|
+
"gauge",
|
|
86
|
+
"donut",
|
|
87
|
+
"pie",
|
|
88
|
+
"bar",
|
|
89
|
+
"column",
|
|
90
|
+
"line",
|
|
91
|
+
"scatter",
|
|
92
|
+
"waterfall",
|
|
93
|
+
"funnel",
|
|
94
|
+
"treemap",
|
|
95
|
+
"ribbon",
|
|
96
|
+
"chart",
|
|
97
|
+
"slicer",
|
|
98
|
+
"map",
|
|
99
|
+
"multirow",
|
|
100
|
+
"table",
|
|
101
|
+
"matrix",
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class VisualPosition:
|
|
107
|
+
name: str
|
|
108
|
+
visual_type: str
|
|
109
|
+
x: int
|
|
110
|
+
y: int
|
|
111
|
+
width: int
|
|
112
|
+
height: int
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class LayoutEngine:
|
|
116
|
+
"""Classify visuals, sort by importance, pack onto canvas using shelf algorithm."""
|
|
117
|
+
|
|
118
|
+
GUTTER = 8
|
|
119
|
+
GRID = 8
|
|
120
|
+
|
|
121
|
+
def __init__(self, canvas_width: int = 1280, canvas_height: int = 720) -> None:
|
|
122
|
+
self.canvas_width = canvas_width
|
|
123
|
+
self.canvas_height = canvas_height
|
|
124
|
+
|
|
125
|
+
def pack(self, visuals: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
126
|
+
classified = self._classify(visuals)
|
|
127
|
+
sorted_visuals = self._sort_by_priority(classified)
|
|
128
|
+
positions = self._shelf_pack(sorted_visuals)
|
|
129
|
+
return [
|
|
130
|
+
{
|
|
131
|
+
"name": p.name,
|
|
132
|
+
"visualType": p.visual_type,
|
|
133
|
+
"x": p.x,
|
|
134
|
+
"y": p.y,
|
|
135
|
+
"width": p.width,
|
|
136
|
+
"height": p.height,
|
|
137
|
+
}
|
|
138
|
+
for p in positions
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
def _classify(self, visuals: list[dict]) -> list[dict]:
|
|
142
|
+
for v in visuals:
|
|
143
|
+
# Accept both "type" (CLI shortname) and "visualType" (PBI internal name)
|
|
144
|
+
raw = v.get("visualType") or v.get("type") or "chart"
|
|
145
|
+
# Map PBI internal name → short name; already-short names pass through
|
|
146
|
+
vtype = _PBI_TYPE_TO_SHORT.get(raw, raw.lower())
|
|
147
|
+
w, h = VISUAL_SIZES.get(vtype, VISUAL_SIZES["chart"])
|
|
148
|
+
v["_short_type"] = vtype
|
|
149
|
+
v["_width"] = w
|
|
150
|
+
v["_height"] = h
|
|
151
|
+
v["_priority"] = PRIORITY_ORDER.index(vtype) if vtype in PRIORITY_ORDER else 99
|
|
152
|
+
return visuals
|
|
153
|
+
|
|
154
|
+
def _sort_by_priority(self, visuals: list[dict]) -> list[dict]:
|
|
155
|
+
return sorted(visuals, key=lambda v: v.get("_priority", 99))
|
|
156
|
+
|
|
157
|
+
def _shelf_pack(self, visuals: list[dict]) -> list[VisualPosition]:
|
|
158
|
+
positions: list[VisualPosition] = []
|
|
159
|
+
x, y = self.GUTTER, self.GUTTER
|
|
160
|
+
row_height = 0
|
|
161
|
+
|
|
162
|
+
for v in visuals:
|
|
163
|
+
w = self._snap(v.get("_width", 200))
|
|
164
|
+
h = self._snap(v.get("_height", 120))
|
|
165
|
+
|
|
166
|
+
# Wrap to next row if we'd exceed canvas width
|
|
167
|
+
if x + w + self.GUTTER > self.canvas_width:
|
|
168
|
+
x = self.GUTTER
|
|
169
|
+
y += row_height + self.GUTTER
|
|
170
|
+
row_height = 0
|
|
171
|
+
|
|
172
|
+
# Skip visuals that would overflow the canvas height
|
|
173
|
+
if y + h > self.canvas_height:
|
|
174
|
+
break
|
|
175
|
+
|
|
176
|
+
positions.append(
|
|
177
|
+
VisualPosition(
|
|
178
|
+
name=v.get("name", "visual"),
|
|
179
|
+
visual_type=v.get("_short_type", v.get("visualType", v.get("type", "chart"))),
|
|
180
|
+
x=x,
|
|
181
|
+
y=y,
|
|
182
|
+
width=w,
|
|
183
|
+
height=h,
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
x += w + self.GUTTER
|
|
187
|
+
row_height = max(row_height, h)
|
|
188
|
+
|
|
189
|
+
return positions
|
|
190
|
+
|
|
191
|
+
def _snap(self, value: int) -> int:
|
|
192
|
+
return ((value + self.GRID - 1) // self.GRID) * self.GRID
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Generate DAX measures using Claude API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MeasureGenerator:
|
|
9
|
+
"""Calls Claude to generate DAX, then validates via the backend."""
|
|
10
|
+
|
|
11
|
+
def generate(self, description: str, schema: list[dict[str, Any]]) -> dict[str, Any]:
|
|
12
|
+
try:
|
|
13
|
+
import anthropic
|
|
14
|
+
|
|
15
|
+
client = anthropic.Anthropic()
|
|
16
|
+
schema_text = "\n".join(
|
|
17
|
+
f" {c['table']}[{c['name']}] ({c.get('dataType', 'Unknown')})" for c in schema
|
|
18
|
+
)
|
|
19
|
+
message = client.messages.create(
|
|
20
|
+
model="claude-sonnet-4-6",
|
|
21
|
+
max_tokens=256,
|
|
22
|
+
system=(
|
|
23
|
+
"You are a DAX expert. Given a measure description and model schema, "
|
|
24
|
+
"write a single correct DAX expression. Output ONLY the DAX — no explanation, "
|
|
25
|
+
"no markdown, no measure name prefix.\n"
|
|
26
|
+
f"Tables and columns:\n{schema_text}"
|
|
27
|
+
),
|
|
28
|
+
messages=[{"role": "user", "content": description}],
|
|
29
|
+
)
|
|
30
|
+
block = next(b for b in message.content if hasattr(b, "text"))
|
|
31
|
+
expression = block.text.strip() # type: ignore[union-attr]
|
|
32
|
+
return {"expression": expression, "valid": True}
|
|
33
|
+
except ImportError:
|
|
34
|
+
return {
|
|
35
|
+
"expression": f"/* TODO: implement {description} */",
|
|
36
|
+
"valid": False,
|
|
37
|
+
"error": "anthropic package not installed. Run: pip install pbi-enterprise-cli[ai]",
|
|
38
|
+
}
|
|
39
|
+
except Exception as e:
|
|
40
|
+
return {"expression": "", "valid": False, "error": str(e)}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Generate WCAG-compliant Power BI theme JSON from a brand colour."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import colorsys
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _hex_to_rgb(hex_color: str) -> tuple[float, float, float]:
|
|
11
|
+
hex_color = hex_color.lstrip("#")
|
|
12
|
+
r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
|
|
13
|
+
return r / 255.0, g / 255.0, b / 255.0
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _rgb_to_hex(r: float, g: float, b: float) -> str:
|
|
17
|
+
return f"#{int(r * 255):02X}{int(g * 255):02X}{int(b * 255):02X}"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _relative_luminance(r: float, g: float, b: float) -> float:
|
|
21
|
+
def linearise(c: float) -> float:
|
|
22
|
+
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
|
|
23
|
+
|
|
24
|
+
return 0.2126 * linearise(r) + 0.7152 * linearise(g) + 0.0722 * linearise(b)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _contrast_ratio(hex1: str, hex2: str) -> float:
|
|
28
|
+
l1 = _relative_luminance(*_hex_to_rgb(hex1))
|
|
29
|
+
l2 = _relative_luminance(*_hex_to_rgb(hex2))
|
|
30
|
+
lighter, darker = max(l1, l2), min(l1, l2)
|
|
31
|
+
return (lighter + 0.05) / (darker + 0.05)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _darken(hex_color: str, amount: float) -> str:
|
|
35
|
+
r, g, b = _hex_to_rgb(hex_color)
|
|
36
|
+
h, s, v = colorsys.rgb_to_hsv(r, g, b)
|
|
37
|
+
v = max(0.0, v - amount)
|
|
38
|
+
return _rgb_to_hex(*colorsys.hsv_to_rgb(h, s, v))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _lighten(hex_color: str, amount: float) -> str:
|
|
42
|
+
r, g, b = _hex_to_rgb(hex_color)
|
|
43
|
+
h, s, v = colorsys.rgb_to_hsv(r, g, b)
|
|
44
|
+
v = min(1.0, v + amount)
|
|
45
|
+
return _rgb_to_hex(*colorsys.hsv_to_rgb(h, s, v))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ThemeGenerator:
|
|
49
|
+
def generate(self, brand_color: str, style: str = "corporate") -> dict[str, Any]:
|
|
50
|
+
primary = brand_color
|
|
51
|
+
p_dark1 = _darken(primary, 0.15) # slot 2: darker variant
|
|
52
|
+
p_dark2 = _darken(primary, 0.30) # slot 5: deep dark
|
|
53
|
+
p_light1 = _lighten(primary, 0.25) # slot 3: medium light
|
|
54
|
+
p_light2 = _lighten(primary, 0.50) # slot 4: light tint
|
|
55
|
+
|
|
56
|
+
# Complementary accent — rotate hue 150 degrees
|
|
57
|
+
r, g, b = _hex_to_rgb(primary)
|
|
58
|
+
h, s, v = colorsys.rgb_to_hsv(r, g, b)
|
|
59
|
+
accent = _rgb_to_hex(
|
|
60
|
+
*colorsys.hsv_to_rgb((h + 150 / 360) % 1.0, s * 0.8, min(v + 0.1, 1.0))
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
neutral = "#605E5C"
|
|
64
|
+
warning = "#FFB900"
|
|
65
|
+
|
|
66
|
+
is_dark = style == "dark"
|
|
67
|
+
background = "#1A1A2E" if is_dark else "#FFFFFF"
|
|
68
|
+
foreground = "#F0F0F0" if is_dark else "#252423"
|
|
69
|
+
bg_light = "#2A2A3E" if is_dark else "#F3F2F1"
|
|
70
|
+
|
|
71
|
+
data_colors = [primary, p_dark1, p_light1, accent, p_light2, p_dark2, neutral, warning]
|
|
72
|
+
|
|
73
|
+
# Build style-specific visualStyles
|
|
74
|
+
card_label_color = "#F0F0F0" if is_dark else primary
|
|
75
|
+
header_bg = primary
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
"name": f"pbi-cli {style}",
|
|
79
|
+
"dataColors": data_colors,
|
|
80
|
+
"background": background,
|
|
81
|
+
"backgroundLight": bg_light,
|
|
82
|
+
"foreground": foreground,
|
|
83
|
+
"tableAccent": primary,
|
|
84
|
+
"visualStyles": {
|
|
85
|
+
"*": {
|
|
86
|
+
"*": {
|
|
87
|
+
"fontSize": [{"value": 11}],
|
|
88
|
+
"fontFamily": [{"value": "Segoe UI"}],
|
|
89
|
+
"background": [{"color": {"solid": {"color": background}}}],
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
"card": {
|
|
93
|
+
"*": {
|
|
94
|
+
"labels": [
|
|
95
|
+
{
|
|
96
|
+
"color": {"solid": {"color": card_label_color}},
|
|
97
|
+
"fontSize": 28,
|
|
98
|
+
"fontFamily": "Segoe UI Light",
|
|
99
|
+
}
|
|
100
|
+
],
|
|
101
|
+
"categoryLabels": [
|
|
102
|
+
{
|
|
103
|
+
"color": {"solid": {"color": neutral}},
|
|
104
|
+
"fontSize": 10,
|
|
105
|
+
}
|
|
106
|
+
],
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
"tableEx": {
|
|
110
|
+
"*": {
|
|
111
|
+
"header": [
|
|
112
|
+
{
|
|
113
|
+
"fontColor": {"solid": {"color": "#FFFFFF"}},
|
|
114
|
+
"backColor": {"solid": {"color": header_bg}},
|
|
115
|
+
"fontSize": 10,
|
|
116
|
+
"fontFamily": "Segoe UI",
|
|
117
|
+
"bold": True,
|
|
118
|
+
}
|
|
119
|
+
],
|
|
120
|
+
"values": [
|
|
121
|
+
{
|
|
122
|
+
"fontSize": 9,
|
|
123
|
+
"fontFamily": "Segoe UI",
|
|
124
|
+
}
|
|
125
|
+
],
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
"slicer": {
|
|
129
|
+
"*": {
|
|
130
|
+
"header": [
|
|
131
|
+
{
|
|
132
|
+
"fontColor": {"solid": {"color": primary}},
|
|
133
|
+
"fontSize": 10,
|
|
134
|
+
"bold": True,
|
|
135
|
+
}
|
|
136
|
+
],
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
"barChart": {
|
|
140
|
+
"*": {
|
|
141
|
+
"categoryAxis": [{"fontSize": 9, "fontFamily": "Segoe UI"}],
|
|
142
|
+
"valueAxis": [{"fontSize": 9, "fontFamily": "Segoe UI"}],
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
"lineChart": {
|
|
146
|
+
"*": {
|
|
147
|
+
"categoryAxis": [{"fontSize": 9, "fontFamily": "Segoe UI"}],
|
|
148
|
+
"valueAxis": [{"fontSize": 9, "fontFamily": "Segoe UI"}],
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
def validate_wcag(self, theme: dict[str, Any]) -> dict[str, Any]:
|
|
155
|
+
failures = []
|
|
156
|
+
bg = theme.get("background", "#FFFFFF")
|
|
157
|
+
fg = theme.get("foreground", "#000000")
|
|
158
|
+
ratio = _contrast_ratio(fg, bg)
|
|
159
|
+
if ratio < 4.5:
|
|
160
|
+
failures.append(
|
|
161
|
+
{
|
|
162
|
+
"pair": f"{fg} on {bg}",
|
|
163
|
+
"ratio": round(ratio, 2),
|
|
164
|
+
"required": 4.5,
|
|
165
|
+
"element": "body text",
|
|
166
|
+
}
|
|
167
|
+
)
|
|
168
|
+
for i, color in enumerate(theme.get("dataColors", [])):
|
|
169
|
+
r = _contrast_ratio(color, bg)
|
|
170
|
+
if r < 3.0:
|
|
171
|
+
failures.append(
|
|
172
|
+
{
|
|
173
|
+
"pair": f"dataColor[{i}] {color} on {bg}",
|
|
174
|
+
"ratio": round(r, 2),
|
|
175
|
+
"required": 3.0,
|
|
176
|
+
"element": "UI component",
|
|
177
|
+
}
|
|
178
|
+
)
|
|
179
|
+
return {"passes": len(failures) == 0, "failures": failures}
|
|
180
|
+
|
|
181
|
+
def fix_contrast(self, theme: dict[str, Any], failures: list[dict]) -> dict[str, Any]:
|
|
182
|
+
bg = theme.get("background", "#FFFFFF")
|
|
183
|
+
fixed_colors = list(theme.get("dataColors", []))
|
|
184
|
+
for failure in failures:
|
|
185
|
+
if "dataColor" in failure.get("element", ""):
|
|
186
|
+
idx = int(re.search(r"\[(\d+)\]", failure["pair"]).group(1)) # type: ignore[union-attr]
|
|
187
|
+
color = fixed_colors[idx]
|
|
188
|
+
for _ in range(20):
|
|
189
|
+
if _contrast_ratio(color, bg) >= 3.0:
|
|
190
|
+
break
|
|
191
|
+
color = _darken(color, 0.05)
|
|
192
|
+
fixed_colors[idx] = color
|
|
193
|
+
return {**theme, "dataColors": fixed_colors}
|