webcode-tk 1.2.11__tar.gz → 1.2.17__tar.gz

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.
@@ -1,17 +1,16 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webcode-tk
3
- Version: 1.2.11
3
+ Version: 1.2.17
4
4
  Summary: A set of tools for inspecting HTML and CSS code
5
5
  License: MIT
6
6
  License-File: LICENSE.rst
7
7
  Keywords: html,validation,css
8
8
  Author: hundredvisionsguy
9
9
  Author-email: cwinikka@gmail.com
10
- Requires-Python: >=3.9.0,<4.0
10
+ Requires-Python: >=3.10,<4.0
11
11
  Classifier: License :: OSI Approved :: MIT License
12
12
  Classifier: Operating System :: OS Independent
13
13
  Classifier: Programming Language :: Python :: 3
14
- Classifier: Programming Language :: Python :: 3.9
15
14
  Classifier: Programming Language :: Python :: 3.10
16
15
  Classifier: Programming Language :: Python :: 3.11
17
16
  Classifier: Programming Language :: Python :: 3.12
@@ -22,7 +21,9 @@ Requires-Dist: file-clerk (>=1.0.9,<2.0.0)
22
21
  Requires-Dist: lxml (>=6.0.0,<7.0.0)
23
22
  Requires-Dist: mechanicalsoup (>=1.4.0,<2.0.0)
24
23
  Requires-Dist: nltk (>=3.8.1,<4.0.0)
24
+ Requires-Dist: pre-commit (>=4.5.0,<5.0.0)
25
25
  Requires-Dist: py-readability-metrics (>=1.4.5,<2.0.0)
26
+ Requires-Dist: pytest (>=9.0.3,<10.0.0)
26
27
  Requires-Dist: pytest-mock (>=3.15.1,<4.0.0)
27
28
  Requires-Dist: requests (>=2.28.1,<3.0.0)
28
29
  Requires-Dist: textatistic (>=0.0.1,<0.0.2)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "webcode-tk"
3
- version = "1.2.11"
3
+ version = "1.2.17"
4
4
  description = "A set of tools for inspecting HTML and CSS code"
5
5
  authors = ["hundredvisionsguy <cwinikka@gmail.com>"]
6
6
  keywords = ["html", "validation", "css", ]
@@ -16,7 +16,7 @@ classifiers = [
16
16
  ]
17
17
 
18
18
  [tool.poetry.dependencies]
19
- python = ">=3.9.0,<4.0"
19
+ python = ">=3.10,<4.0"
20
20
  bs4 = "^0.0.1"
21
21
  file-clerk = "^1.0.9"
22
22
  requests = "^2.28.1"
@@ -27,6 +27,8 @@ tinycss2 = "^1.4.0"
27
27
  lxml = "^6.0.0"
28
28
  mechanicalsoup = "^1.4.0"
29
29
  pytest-mock = "^3.15.1"
30
+ pre-commit = "^4.5.0"
31
+ pytest = "^9.0.3"
30
32
 
31
33
  [tool.poetry.group.dev.dependencies]
32
34
  pytest = "*"
@@ -40,4 +40,4 @@
40
40
  - **`validator_tools`**: sends HTML or CSS code to the W3C Validator to check
41
41
  for errors.
42
42
  """
43
- __version__ = "1.2.11"
43
+ __version__ = "1.2.17"
@@ -0,0 +1,371 @@
1
+ """
2
+ _style_inheritance.py
3
+ By Chris Winikka
4
+
5
+ Internal utiliities for computing CSS inheritance.
6
+
7
+ This module provides optimized functions for computing CSS inheritance and
8
+ visual background inheritance during contrast analysis. It is used internally
9
+ by contrast_tools and is not part of the public API.
10
+
11
+ Functions:
12
+ apply_css_inheritance: Apply inheritable CSS properties top-down through
13
+ DOM.
14
+ apply_visual_background_inheritance: Propagate background colors via
15
+ ancestry.
16
+ find_ancestor_background: Locate first ancestor with explicit background.
17
+ apply_inheritance: Wrapper combining CSS and visual inheritance.
18
+ """
19
+
20
+ import re
21
+
22
+ from bs4 import Tag
23
+
24
+ DEFAULT_GLOBAL_BACKGROUND = "#ffffff"
25
+
26
+
27
+ def apply_css_inheritance(computed_styles: dict) -> None:
28
+ """
29
+ Apply CSS inheritance top-down (parent to children) instead of bottom-up.
30
+
31
+ This is MUCH faster than walking up the tree for each element.
32
+ O(n) instead of O(n*m*h) where n=elements, m=properties, h=tree_depth
33
+ """
34
+ inheritable_props = {"color", "font-size", "font-weight"}
35
+
36
+ # Build parent-child relationships
37
+ parent_to_children = {}
38
+ child_to_parent = {}
39
+
40
+ for element in computed_styles:
41
+ parent = element.parent
42
+ if parent and parent in computed_styles:
43
+ if parent not in parent_to_children:
44
+ parent_to_children[parent] = []
45
+ parent_to_children[parent].append(element)
46
+ child_to_parent[element] = parent
47
+
48
+ # Process top-down: parent values flow to children
49
+ visited = set()
50
+
51
+ def traverse_tree(element, inherited_props_from_ancestors):
52
+ """DFS traversal pushing inherited values down."""
53
+ if element in visited:
54
+ return
55
+ visited.add(element)
56
+
57
+ if element not in computed_styles:
58
+ return
59
+
60
+ element_styles = computed_styles[element]
61
+ props_for_children = inherited_props_from_ancestors.copy()
62
+
63
+ # For each inheritable property
64
+ for prop in inheritable_props:
65
+ current_prop = element_styles.get(prop)
66
+
67
+ # If element has explicit rule, use it (don't inherit)
68
+ if current_prop and current_prop.get("source") == "rule":
69
+ props_for_children[prop] = current_prop
70
+ # Otherwise inherit from ancestor if available
71
+ elif prop in inherited_props_from_ancestors:
72
+ inherited_value = inherited_props_from_ancestors[prop]
73
+ element_styles[prop] = {
74
+ "value": inherited_value["value"],
75
+ "specificity": inherited_value["specificity"],
76
+ "source": "inheritance",
77
+ "selector": inherited_value.get("selector", "inherited"),
78
+ "css_file": inherited_value.get("css_file"),
79
+ "css_source_type": inherited_value.get("css_source_type"),
80
+ "inherited_from": child_to_parent.get(element),
81
+ }
82
+ # Also pass inherited value to children
83
+ props_for_children[prop] = element_styles[prop]
84
+
85
+ # Recurse to children with updated inherited values
86
+ if element in parent_to_children:
87
+ for child in parent_to_children[element]:
88
+ traverse_tree(child, props_for_children)
89
+
90
+ # Start traversal from root elements (with no parent in computed_styles)
91
+ for element in computed_styles:
92
+ if element not in child_to_parent:
93
+ # This is a root element
94
+ traverse_tree(element, {})
95
+
96
+
97
+ def apply_visual_background_inheritance(computed_styles: dict) -> None:
98
+ """
99
+ Apply visual background colors to elements without explicit backgrounds.
100
+
101
+ Args:
102
+ computed_styles: a dictionary of elements with computed styles.
103
+ Returns:
104
+ None
105
+ """
106
+
107
+ for element in computed_styles:
108
+ element_styles = computed_styles[element]
109
+
110
+ # Check for BOTH background-color AND background
111
+ current_bg = element_styles.get("background-color")
112
+ has_background_shorthand = "background" in element_styles
113
+
114
+ # If element has background shorthand, process it for images
115
+ if has_background_shorthand:
116
+ bg_value = element_styles["background"]["value"]
117
+
118
+ # Check if background contains a raster image
119
+ if contains_raster_image(bg_value):
120
+ # Mark as contrast-indeterminate - cannot analyze
121
+ element_styles["background-color"] = {
122
+ "value": None,
123
+ "specificity": "000",
124
+ "contrast_analysis": "indeterminate",
125
+ "reason": "background_image_blocks_color_analysis",
126
+ "original_background": bg_value,
127
+ "visual_inheritance": False,
128
+ }
129
+ continue
130
+ else:
131
+ # Has usable background shorthand - skip inheritance
132
+ continue
133
+ if not current_bg:
134
+ continue
135
+
136
+ bg_source = current_bg.get("source")
137
+ bg_value = current_bg.get("value")
138
+
139
+ # Skip elements with explicit CSS rules
140
+ if bg_source == "rule":
141
+ continue
142
+
143
+ # Only update elements that still have the default white background
144
+ # This prevents overwriting elements with existing proper backgrounds
145
+ if bg_source != "default":
146
+ continue
147
+
148
+ # Element needs background inheritance (lines 1120-1141)
149
+ ancestor_bg = find_ancestor_background(element, computed_styles)
150
+
151
+ if not ancestor_bg or ancestor_bg.get("source") == "default":
152
+ # No explicit background to inherit - keep browser default
153
+ continue
154
+
155
+ if ancestor_bg.get("contrast_analysis") == "indeterminate":
156
+ # Ancestor is indeterminate - propagate that status
157
+ element_styles["background-color"] = {
158
+ "value": None,
159
+ "specificity": "000",
160
+ "contrast_analysis": "indeterminate",
161
+ "reason": ancestor_bg["reason"],
162
+ "inherited_from": ancestor_bg["source_element"],
163
+ "source": "visual_inheritance",
164
+ "original_background": ancestor_bg.get("original_background"),
165
+ }
166
+
167
+ else:
168
+ # Normal inheritance - extract usable color
169
+ contrast_color = extract_contrast_color(ancestor_bg["value"])
170
+ effective_color = (
171
+ contrast_color if contrast_color else ancestor_bg["value"]
172
+ )
173
+ element_styles["background-color"] = {
174
+ "value": effective_color,
175
+ "specificity": "000",
176
+ "source": "visual_inheritance",
177
+ "inherited_from": ancestor_bg["source_element"],
178
+ "contrast_analysis": "determinable",
179
+ "original_background": (
180
+ ancestor_bg["value"]
181
+ if ancestor_bg["value"] != DEFAULT_GLOBAL_BACKGROUND
182
+ else None
183
+ ),
184
+ }
185
+
186
+
187
+ def contains_raster_image(background_value: str) -> bool:
188
+ """Check if background contains a raster image that blocks color analysis.
189
+
190
+ Args:
191
+ background_value: the css value applied to the background property.
192
+
193
+ Returns:
194
+ bool: whether the background value includes a raster image or not.
195
+ """
196
+ # Add None check
197
+ if background_value is None:
198
+ return False
199
+
200
+ return bool(
201
+ re.search(
202
+ r"url\([^)]*\.(jpg|jpeg|png|gif|webp|bmp)", background_value, re.I
203
+ )
204
+ )
205
+
206
+
207
+ def extract_contrast_color(background_value: str) -> str:
208
+ """
209
+ Extract usable color for contrast analysis, or None.
210
+
211
+ Args:
212
+ background_value (str): CSS background property value.
213
+
214
+ Returns:
215
+ str: Color value suitable for contrast analysis, or None if no
216
+ usable color found.
217
+ """
218
+ # Add None check at the beginning
219
+ if background_value is None:
220
+ return None
221
+
222
+ # Remove raster images - they block color visibility
223
+ if re.search(
224
+ r"url\([^)]*\.(jpg|jpeg|png|gif|webp|bmp)", background_value, re.I
225
+ ):
226
+ return None
227
+
228
+ # Handle gradients - extract representative color
229
+ if "gradient" in background_value.lower():
230
+ return extract_gradient_contrast_color(background_value)
231
+
232
+ # Handle solid colors
233
+ re_pattern = r"(#[a-f0-9]{3,6}|rgb\([^)]+\)|rgba\([^)]+\)|hsl\([^)]+\)|"
234
+ re_pattern += r"hsla\([^)]+\)|[a-z]+)"
235
+ color_match = re.search(
236
+ re_pattern,
237
+ background_value,
238
+ re.I,
239
+ )
240
+ return color_match.group(1) if color_match else None
241
+
242
+
243
+ def find_ancestor_background(element: Tag, computed_styles: dict) -> dict:
244
+ """
245
+ Walk up DOM tree to find first ancestor with background.
246
+
247
+ Args:
248
+ element (Tag): BeautifulSoup Tag object to start searching from.
249
+ computed_styles (dict): Dictionary mapping elements to their
250
+ computed styles.
251
+
252
+ Returns:
253
+ dict: Dictionary containing 'value' (background color) and
254
+ 'source_element' (ancestor element or None).
255
+ """
256
+ current = element.parent
257
+
258
+ while current:
259
+ if current in computed_styles:
260
+ current_styles = computed_styles[current]
261
+
262
+ # Flag to track if we should skip this ancestor
263
+ skip_ancestor = False
264
+
265
+ # Check for background-color or background
266
+ for bg_prop in ["background-color", "background"]:
267
+ if bg_prop in current_styles:
268
+ bg_source = current_styles[bg_prop].get("source")
269
+
270
+ # Skip elements that only have visual inheritance
271
+ if bg_source == "visual_inheritance":
272
+ skip_ancestor = True
273
+ break # ✅ Break out of property loop
274
+
275
+ # Accept both "rule" and "default" sources
276
+ if bg_source in ["rule", "default"]:
277
+ bg_value = current_styles[bg_prop]["value"]
278
+ if bg_source == "default":
279
+ # Skip ancestors with only default backgrounds
280
+ skip_ancestor = True
281
+ break
282
+
283
+ # Does this ancestor already is indeterminate status
284
+ if (
285
+ current_styles[bg_prop].get("contrast_analysis")
286
+ == "indeterminate"
287
+ ):
288
+ return {
289
+ "value": None,
290
+ "source_element": current,
291
+ "contrast_analysis": "indeterminate",
292
+ "reason": "ancestor_has_background_image",
293
+ "original_background": current_styles[
294
+ bg_prop
295
+ ].get("original_background", bg_value),
296
+ }
297
+
298
+ # Skip None values and continue searching
299
+ if bg_value is None:
300
+ continue # Continue to next property
301
+
302
+ # Check if this background contains a raster image
303
+ if contains_raster_image(bg_value):
304
+ return {
305
+ "value": None,
306
+ "source_element": current,
307
+ "contrast_analysis": "indeterminate",
308
+ "reason": "ancestor_has_background_image",
309
+ "original_background": bg_value,
310
+ }
311
+
312
+ # Found explicit background
313
+ bg_items = current_styles[bg_prop].items()
314
+ return {
315
+ "value": bg_value,
316
+ "source_element": current,
317
+ "contrast_analysis": "determinable",
318
+ # Copy metadata from ancestor
319
+ **{
320
+ metadata_key: metadata_value
321
+ for metadata_key, metadata_value in bg_items
322
+ if metadata_key not in ["value"]
323
+ },
324
+ }
325
+
326
+ # If we flagged this ancestor to skip, move to next ancestor
327
+ if skip_ancestor:
328
+ current = current.parent
329
+ continue # ✅ Now this continues to next ancestor
330
+
331
+ current = current.parent
332
+
333
+ # No ancestor background found, use default
334
+ return {
335
+ "value": DEFAULT_GLOBAL_BACKGROUND,
336
+ "source_element": None,
337
+ "contrast_analysis": "determinable",
338
+ "source": "default",
339
+ }
340
+
341
+
342
+ def extract_gradient_contrast_color(background_value: str) -> str:
343
+ """
344
+ Extract representative color from gradient for contrast analysis.
345
+
346
+ Args:
347
+ background_value (str): CSS gradient value (e.g.,
348
+ 'linear-gradient(red, blue)').
349
+
350
+ Returns:
351
+ str: Representative color for contrast analysis, or None if no
352
+ color found.
353
+ """
354
+ # Add None check
355
+ if background_value is None:
356
+ return None
357
+
358
+ # Strategy: Use the final color in gradient (most visible for reading)
359
+ # This handles: linear-gradient(red, blue) -> blue
360
+ # radial-gradient(center, red, blue) -> blue
361
+
362
+ # Find all colors in gradient
363
+ color_pattern = r"(#[a-f0-9]{3,6}|rgb\([^)]+\)|rgba\([^)]+\)|"
364
+ color_pattern += r"hsl\([^)]+\)|hsla\([^)]+\)|[a-z]+)"
365
+ colors = re.findall(color_pattern, background_value, re.I)
366
+
367
+ if colors:
368
+ # Use last color (end of gradient)
369
+ return colors[-1]
370
+
371
+ return None
@@ -2,6 +2,7 @@
2
2
  A group of functions that work with css_tools and cascade_tools to provide
3
3
  reports on CSS animations.
4
4
  """
5
+
5
6
  from collections.abc import Iterable
6
7
  from typing import Union
7
8
 
@@ -8,6 +8,7 @@ all styles applied to every element on the page.
8
8
  With that, it scans each stylesheet and applies each style one at a time
9
9
  to all elements and their children (if applicable) of a page.
10
10
  """
11
+
11
12
  import ctypes
12
13
  import re
13
14
  from collections import abc
@@ -1539,6 +1540,16 @@ def targets_element_directly(element: Element, selector: str) -> bool:
1539
1540
  elif selector_type == "id_selector":
1540
1541
  attributes = element.attributes
1541
1542
  if attributes:
1543
+ if not isinstance(attributes, dict):
1544
+ try:
1545
+ attributes = dict(attributes)
1546
+ except Exception:
1547
+ msg = "This is not a dictionary and cannot be converted"
1548
+ msg += " into one!"
1549
+ raise ValueError(msg)
1550
+ else:
1551
+ data_type = str(type(attributes))
1552
+ print(data_type)
1542
1553
  id_attribute = attributes.get("id")
1543
1554
  if id_attribute:
1544
1555
  targets = selector in attributes.get("id")
@@ -231,7 +231,7 @@ def is_a_keyword(word: str) -> bool:
231
231
  return is_keyword
232
232
 
233
233
 
234
- def get_hex_by_keyword(word: (str)) -> str:
234
+ def get_hex_by_keyword(word: str) -> str:
235
235
  """returns the hex value of a keyword (if valid).
236
236
 
237
237
  Args:
@@ -19,6 +19,7 @@ One thing led to another, yada yada, then I realized that this could
19
19
  be a useful tool in my web grading projects, and there you have it:
20
20
  `color_tools`.
21
21
  """
22
+
22
23
  import re
23
24
 
24
25
  from webcode_tk import color_keywords
@@ -313,7 +314,7 @@ def has_alpha_channel(code: str) -> bool:
313
314
  """
314
315
  has_alpha = False
315
316
  if "#" in code:
316
- if len(code) == 9:
317
+ if len(code) == 9 or len(code) == 5:
317
318
  has_alpha = True
318
319
  if "hsla(" in code:
319
320
  has_alpha = True
@@ -321,6 +322,10 @@ def has_alpha_channel(code: str) -> bool:
321
322
  has_alpha = True
322
323
  if "rgba(" in code:
323
324
  has_alpha = True
325
+ if "rgb" in code and "/" in code:
326
+ has_alpha = True
327
+ if "transparent" in code:
328
+ has_alpha = True
324
329
  return has_alpha
325
330
 
326
331
 
@@ -31,6 +31,7 @@ Constants:
31
31
  mathematical contrast ratios and size thresholds that determine WCAG
32
32
  compliance.
33
33
  """
34
+
34
35
  import copy
35
36
  import re
36
37
 
@@ -40,6 +41,13 @@ from bs4 import NavigableString
40
41
  from bs4 import Tag
41
42
  from file_clerk import clerk
42
43
 
44
+ from webcode_tk._style_inheritance import (
45
+ apply_css_inheritance,
46
+ apply_visual_background_inheritance,
47
+ contains_raster_image,
48
+ DEFAULT_GLOBAL_BACKGROUND,
49
+ extract_contrast_color,
50
+ )
43
51
  from webcode_tk import color_tools
44
52
  from webcode_tk import css_tools
45
53
  from webcode_tk import font_tools
@@ -49,7 +57,6 @@ from webcode_tk.font_tools import split_value_unit
49
57
 
50
58
  # Browser default styling constants
51
59
  DEFAULT_GLOBAL_COLOR = "#000000"
52
- DEFAULT_GLOBAL_BACKGROUND = "#ffffff"
53
60
  DEFAULT_LINK_COLOR = "#0000EE"
54
61
  DEFAULT_LINK_VISITED = "#551A8B"
55
62
  ROOT_FONT_SIZE = 16
@@ -166,20 +173,6 @@ def generate_contrast_report(
166
173
  for file in project_files:
167
174
  errors = project_files.get(file)
168
175
 
169
- # Check for global selectors (html, *, or body) and calculate contrast
170
- global_color_result = css_tools.get_global_color_report(project_path)
171
-
172
- if isinstance(global_color_result, list):
173
- for result in global_color_result:
174
- if file in result:
175
- # Always add global color failures
176
- if "fail" in result:
177
- error_msg = result.split(file)[1]
178
- msg = f"fail: in {file}, {error_msg.strip()}"
179
- # Add to errors list if not already there
180
- if msg not in project_files[file]:
181
- project_files[file].append(msg)
182
-
183
176
  # Now append to report
184
177
  if errors:
185
178
  if "has no CSS" in errors[0]:
@@ -885,12 +878,16 @@ def apply_rule_to_element(
885
878
  "specificity": rule_specificity,
886
879
  "source": "rule",
887
880
  "selector": selector,
888
- "css_file": css_source_info.get("filename")
889
- if css_source_info
890
- else None,
891
- "css_source_type": css_source_info.get("source_type")
892
- if css_source_info
893
- else None,
881
+ "css_file": (
882
+ css_source_info.get("filename")
883
+ if css_source_info
884
+ else None
885
+ ),
886
+ "css_source_type": (
887
+ css_source_info.get("source_type")
888
+ if css_source_info
889
+ else None
890
+ ),
894
891
  }
895
892
 
896
893
  # Check if we should apply this font-size rule
@@ -925,12 +922,16 @@ def apply_rule_to_element(
925
922
  "specificity": rule_specificity,
926
923
  "source": "rule",
927
924
  "selector": selector,
928
- "css_file": css_source_info.get("filename")
929
- if css_source_info
930
- else None,
931
- "css_source_type": css_source_info.get("source_type")
932
- if css_source_info
933
- else None,
925
+ "css_file": (
926
+ css_source_info.get("filename")
927
+ if css_source_info
928
+ else None
929
+ ),
930
+ "css_source_type": (
931
+ css_source_info.get("source_type")
932
+ if css_source_info
933
+ else None
934
+ ),
934
935
  "contrast_analysis": "indeterminate",
935
936
  "reason": "background_image_blocks_color_analysis",
936
937
  "original_background": property_value,
@@ -947,16 +948,22 @@ def apply_rule_to_element(
947
948
  "specificity": rule_specificity,
948
949
  "source": "rule",
949
950
  "selector": selector,
950
- "css_file": css_source_info.get("filename")
951
- if css_source_info
952
- else None,
953
- "css_source_type": css_source_info.get("source_type")
954
- if css_source_info
955
- else None,
951
+ "css_file": (
952
+ css_source_info.get("filename")
953
+ if css_source_info
954
+ else None
955
+ ),
956
+ "css_source_type": (
957
+ css_source_info.get("source_type")
958
+ if css_source_info
959
+ else None
960
+ ),
956
961
  "contrast_analysis": "determinable",
957
- "original_background": property_value
958
- if property_value != effective_color
959
- else None,
962
+ "original_background": (
963
+ property_value
964
+ if property_value != effective_color
965
+ else None
966
+ ),
960
967
  }
961
968
 
962
969
  # Apply specificity logic and set the property
@@ -1014,12 +1021,16 @@ def apply_rule_to_element(
1014
1021
  "specificity": rule_specificity,
1015
1022
  "source": "rule",
1016
1023
  "selector": selector,
1017
- "css_file": css_source_info.get("filename")
1018
- if css_source_info
1019
- else None,
1020
- "css_source_type": css_source_info.get("source_type")
1021
- if css_source_info
1022
- else None,
1024
+ "css_file": (
1025
+ css_source_info.get("filename")
1026
+ if css_source_info
1027
+ else None
1028
+ ),
1029
+ "css_source_type": (
1030
+ css_source_info.get("source_type")
1031
+ if css_source_info
1032
+ else None
1033
+ ),
1023
1034
  }
1024
1035
 
1025
1036
  return
@@ -1141,149 +1152,6 @@ def apply_inheritance(soup: BeautifulSoup, computed_styles: dict) -> None:
1141
1152
  apply_visual_background_inheritance(computed_styles)
1142
1153
 
1143
1154
 
1144
- def apply_css_inheritance(computed_styles: dict) -> None:
1145
- """
1146
- Apply traditional CSS inheritance for inheritable properties.
1147
- Only inherit from the closest ancestor with an explicit rule.
1148
- """
1149
- inheritable_props = {"color", "font-size", "font-weight"}
1150
-
1151
- for child_element in computed_styles:
1152
- child_styles = computed_styles[child_element]
1153
-
1154
- # find closest ancestor with explicit value
1155
- for prop in inheritable_props:
1156
- child_prop = child_styles.get(prop)
1157
-
1158
- # Skip if child has explicit CSS rule (not default)
1159
- if child_prop and child_prop.get("source") not in [
1160
- "default",
1161
- None,
1162
- ]:
1163
- continue
1164
-
1165
- # Walk up tree to find first ancestor with explicit value
1166
- current = child_element.parent
1167
- inherited_value = None
1168
-
1169
- while current and not inherited_value:
1170
- if current in computed_styles:
1171
- parent_styles = computed_styles[current]
1172
- parent_prop = parent_styles.get(prop)
1173
-
1174
- # Found a parent with this property
1175
- if parent_prop:
1176
- # Only inherit if parent has explicit rule
1177
- if parent_prop.get("source") == "rule":
1178
- inherited_value = parent_prop
1179
- inherited_from = current
1180
- break # ← Stop at first explicit ancestor
1181
-
1182
- current = current.parent
1183
-
1184
- # Apply inherited value if found
1185
- if inherited_value:
1186
- child_styles[prop] = {
1187
- "value": inherited_value["value"],
1188
- "specificity": inherited_value["specificity"],
1189
- "source": "inheritance",
1190
- "selector": inherited_value.get("selector", "inherited"),
1191
- "css_file": inherited_value.get("css_file"),
1192
- "css_source_type": inherited_value.get("css_source_type"),
1193
- "inherited_from": inherited_from,
1194
- }
1195
-
1196
-
1197
- def apply_visual_background_inheritance(computed_styles: dict) -> None:
1198
- """
1199
- Apply visual background colors to elements without explicit backgrounds.
1200
-
1201
- Args:
1202
- computed_styles: a dictionary of elements with computed styles.
1203
- Returns:
1204
- None
1205
- """
1206
-
1207
- for element in computed_styles:
1208
- element_styles = computed_styles[element]
1209
-
1210
- # Check for BOTH background-color AND background
1211
- current_bg = element_styles.get("background-color")
1212
- has_background_shorthand = "background" in element_styles
1213
-
1214
- # If element has background shorthand, process it for images
1215
- if has_background_shorthand:
1216
- bg_value = element_styles["background"]["value"]
1217
-
1218
- # Check if background contains a raster image
1219
- if contains_raster_image(bg_value):
1220
- # Mark as contrast-indeterminate - cannot analyze
1221
- element_styles["background-color"] = {
1222
- "value": None,
1223
- "specificity": "000",
1224
- "contrast_analysis": "indeterminate",
1225
- "reason": "background_image_blocks_color_analysis",
1226
- "original_background": bg_value,
1227
- "visual_inheritance": False,
1228
- }
1229
- continue
1230
- else:
1231
- # Has usable background shorthand - skip inheritance
1232
- continue
1233
- if not current_bg:
1234
- continue
1235
-
1236
- bg_source = current_bg.get("source")
1237
- bg_value = current_bg.get("value")
1238
-
1239
- # Skip elements with explicit CSS rules
1240
- if bg_source == "rule":
1241
- continue
1242
-
1243
- # Only update elements that still have the default white background
1244
- # This prevents overwriting elements with existing proper backgrounds
1245
- if bg_source != "default":
1246
- continue
1247
-
1248
- # Element needs background inheritance (lines 1120-1141)
1249
- ancestor_bg = find_ancestor_background(element, computed_styles)
1250
-
1251
- if not ancestor_bg or ancestor_bg.get("source") == "default":
1252
- # No explicit background to inherit - keep browser default
1253
- continue
1254
-
1255
- if ancestor_bg.get("contrast_analysis") == "indeterminate":
1256
- # Ancestor is indeterminate - propagate that status
1257
- element_styles["background-color"] = {
1258
- "value": None,
1259
- "specificity": "000",
1260
- "contrast_analysis": "indeterminate",
1261
- "reason": ancestor_bg["reason"],
1262
- "inherited_from": ancestor_bg["source_element"],
1263
- "source": "visual_inheritance",
1264
- "original_background": ancestor_bg.get("original_background"),
1265
- }
1266
-
1267
- else:
1268
- # Normal inheritance - extract usable color
1269
- contrast_color = extract_contrast_color(ancestor_bg["value"])
1270
- effective_color = (
1271
- contrast_color if contrast_color else ancestor_bg["value"]
1272
- )
1273
- element_styles["background-color"] = {
1274
- "value": effective_color,
1275
- "specificity": "000",
1276
- "source": "visual_inheritance",
1277
- "inherited_from": ancestor_bg["source_element"],
1278
- "contrast_analysis": "determinable",
1279
- "original_background": (
1280
- ancestor_bg["value"]
1281
- if ancestor_bg["value"] != DEFAULT_GLOBAL_BACKGROUND
1282
- else None
1283
- ),
1284
- }
1285
-
1286
-
1287
1155
  def has_usable_background_color(background_prop: dict) -> bool:
1288
1156
  """
1289
1157
  Check if background property contains usable color for contrast.
@@ -1313,193 +1181,6 @@ def has_usable_background_color(background_prop: dict) -> bool:
1313
1181
  )
1314
1182
 
1315
1183
 
1316
- def contains_raster_image(background_value: str) -> bool:
1317
- """Check if background contains a raster image that blocks color analysis.
1318
-
1319
- Args:
1320
- background_value: the css value applied to the background property.
1321
-
1322
- Returns:
1323
- bool: whether the background value includes a raster image or not.
1324
- """
1325
- # Add None check
1326
- if background_value is None:
1327
- return False
1328
-
1329
- return bool(
1330
- re.search(
1331
- r"url\([^)]*\.(jpg|jpeg|png|gif|webp|bmp)", background_value, re.I
1332
- )
1333
- )
1334
-
1335
-
1336
- def extract_contrast_color(background_value: str) -> str:
1337
- """
1338
- Extract usable color for contrast analysis, or None.
1339
-
1340
- Args:
1341
- background_value (str): CSS background property value.
1342
-
1343
- Returns:
1344
- str: Color value suitable for contrast analysis, or None if no
1345
- usable color found.
1346
- """
1347
- # Add None check at the beginning
1348
- if background_value is None:
1349
- return None
1350
-
1351
- # Remove raster images - they block color visibility
1352
- if re.search(
1353
- r"url\([^)]*\.(jpg|jpeg|png|gif|webp|bmp)", background_value, re.I
1354
- ):
1355
- return None
1356
-
1357
- # Handle gradients - extract representative color
1358
- if "gradient" in background_value.lower():
1359
- return extract_gradient_contrast_color(background_value)
1360
-
1361
- # Handle solid colors
1362
- re_pattern = r"(#[a-f0-9]{3,6}|rgb\([^)]+\)|rgba\([^)]+\)|hsl\([^)]+\)|"
1363
- re_pattern += r"hsla\([^)]+\)|[a-z]+)"
1364
- color_match = re.search(
1365
- re_pattern,
1366
- background_value,
1367
- re.I,
1368
- )
1369
- return color_match.group(1) if color_match else None
1370
-
1371
-
1372
- def extract_gradient_contrast_color(background_value: str) -> str:
1373
- """
1374
- Extract representative color from gradient for contrast analysis.
1375
-
1376
- Args:
1377
- background_value (str): CSS gradient value (e.g.,
1378
- 'linear-gradient(red, blue)').
1379
-
1380
- Returns:
1381
- str: Representative color for contrast analysis, or None if no
1382
- color found.
1383
- """
1384
- # Add None check
1385
- if background_value is None:
1386
- return None
1387
-
1388
- # Strategy: Use the final color in gradient (most visible for reading)
1389
- # This handles: linear-gradient(red, blue) -> blue
1390
- # radial-gradient(center, red, blue) -> blue
1391
-
1392
- # Find all colors in gradient
1393
- color_pattern = r"(#[a-f0-9]{3,6}|rgb\([^)]+\)|rgba\([^)]+\)|"
1394
- color_pattern += r"hsl\([^)]+\)|hsla\([^)]+\)|[a-z]+)"
1395
- colors = re.findall(color_pattern, background_value, re.I)
1396
-
1397
- if colors:
1398
- # Use last color (end of gradient)
1399
- return colors[-1]
1400
-
1401
- return None
1402
-
1403
-
1404
- def find_ancestor_background(element: Tag, computed_styles: dict) -> dict:
1405
- """
1406
- Walk up DOM tree to find first ancestor with background.
1407
-
1408
- Args:
1409
- element (Tag): BeautifulSoup Tag object to start searching from.
1410
- computed_styles (dict): Dictionary mapping elements to their
1411
- computed styles.
1412
-
1413
- Returns:
1414
- dict: Dictionary containing 'value' (background color) and
1415
- 'source_element' (ancestor element or None).
1416
- """
1417
- current = element.parent
1418
-
1419
- while current:
1420
- if current in computed_styles:
1421
- current_styles = computed_styles[current]
1422
-
1423
- # Flag to track if we should skip this ancestor
1424
- skip_ancestor = False
1425
-
1426
- # Check for background-color or background
1427
- for bg_prop in ["background-color", "background"]:
1428
- if bg_prop in current_styles:
1429
- bg_source = current_styles[bg_prop].get("source")
1430
-
1431
- # Skip elements that only have visual inheritance
1432
- if bg_source == "visual_inheritance":
1433
- skip_ancestor = True
1434
- break # ✅ Break out of property loop
1435
-
1436
- # Accept both "rule" and "default" sources
1437
- if bg_source in ["rule", "default"]:
1438
- bg_value = current_styles[bg_prop]["value"]
1439
- if bg_source == "default":
1440
- # Skip ancestors with only default backgrounds
1441
- skip_ancestor = True
1442
- break
1443
-
1444
- # Does this ancestor already is indeterminate status
1445
- if (
1446
- current_styles[bg_prop].get("contrast_analysis")
1447
- == "indeterminate"
1448
- ):
1449
- return {
1450
- "value": None,
1451
- "source_element": current,
1452
- "contrast_analysis": "indeterminate",
1453
- "reason": "ancestor_has_background_image",
1454
- "original_background": current_styles[
1455
- bg_prop
1456
- ].get("original_background", bg_value),
1457
- }
1458
-
1459
- # Skip None values and continue searching
1460
- if bg_value is None:
1461
- continue # Continue to next property
1462
-
1463
- # Check if this background contains a raster image
1464
- if contains_raster_image(bg_value):
1465
- return {
1466
- "value": None,
1467
- "source_element": current,
1468
- "contrast_analysis": "indeterminate",
1469
- "reason": "ancestor_has_background_image",
1470
- "original_background": bg_value,
1471
- }
1472
-
1473
- # Found explicit background
1474
- bg_items = current_styles[bg_prop].items()
1475
- return {
1476
- "value": bg_value,
1477
- "source_element": current,
1478
- "contrast_analysis": "determinable",
1479
- # Copy metadata from ancestor
1480
- **{
1481
- metadata_key: metadata_value
1482
- for metadata_key, metadata_value in bg_items
1483
- if metadata_key not in ["value"]
1484
- },
1485
- }
1486
-
1487
- # If we flagged this ancestor to skip, move to next ancestor
1488
- if skip_ancestor:
1489
- current = current.parent
1490
- continue # ✅ Now this continues to next ancestor
1491
-
1492
- current = current.parent
1493
-
1494
- # No ancestor background found, use default
1495
- return {
1496
- "value": DEFAULT_GLOBAL_BACKGROUND,
1497
- "source_element": None,
1498
- "contrast_analysis": "determinable",
1499
- "source": "default",
1500
- }
1501
-
1502
-
1503
1184
  def is_inheritable_property(property_name: str) -> bool:
1504
1185
  """
1505
1186
  Determines if a CSS property is inheritable by default.
@@ -1557,25 +1238,6 @@ def analyze_elements_for_contrast(
1557
1238
  "contrast_analysis"
1558
1239
  )
1559
1240
 
1560
- # Check if this element inherited both text and background colors
1561
- text_color_source_type = extract_property_source(
1562
- element_data["color"]
1563
- ).get("source_type")
1564
- bg_color_source_type = extract_property_source(
1565
- element_data["background-color"]
1566
- ).get("source_type")
1567
-
1568
- # Determine if this is a global color failure
1569
- inherited_text = text_color_source_type in [
1570
- "inherited",
1571
- "browser_default",
1572
- ]
1573
- inherited_bg = bg_color_source_type in [
1574
- "visual_inheritance",
1575
- "browser_default",
1576
- ]
1577
- is_global_failure = inherited_text and inherited_bg
1578
-
1579
1241
  if is_large:
1580
1242
  aaa_results = contrast_report.get("Large AAA")
1581
1243
  aa_results = contrast_report.get("Large AA")
@@ -1607,7 +1269,6 @@ def analyze_elements_for_contrast(
1607
1269
  "wcag_aa_pass": aa_results,
1608
1270
  "wcag_aaa_pass": aaa_results,
1609
1271
  "contrast_analysis": contrast_analysis,
1610
- "global_color_failure": is_global_failure,
1611
1272
  }
1612
1273
 
1613
1274
  # add to results
@@ -1684,9 +1345,11 @@ def extract_property_source(property_data: dict) -> dict:
1684
1345
  "source_type": "visual_inheritance",
1685
1346
  "css_file": "visual_cascade",
1686
1347
  "selector": "ancestor_background",
1687
- "inherited_from": getattr(inherited_element, "name", None)
1688
- if inherited_element
1689
- else None,
1348
+ "inherited_from": (
1349
+ getattr(inherited_element, "name", None)
1350
+ if inherited_element
1351
+ else None
1352
+ ),
1690
1353
  }
1691
1354
  elif source_type == "rule":
1692
1355
  return {
@@ -2,6 +2,7 @@
2
2
  This module is a set of tools to analyze CSS syntax as well as properties
3
3
  and values.
4
4
  """
5
+
5
6
  import re
6
7
  from typing import Union
7
8
 
@@ -1234,7 +1235,7 @@ def get_colors_from_gradient(gradient: str) -> list:
1234
1235
  return colors
1235
1236
 
1236
1237
 
1237
- def get_comment_positions(code: (str)) -> Union[list, None]:
1238
+ def get_comment_positions(code: str) -> Union[list, None]:
1238
1239
  """looks for index positions of first opening and closing comment.
1239
1240
 
1240
1241
  From this function, you can create a slice of a comment from the
@@ -1383,7 +1384,11 @@ def get_global_color_details(rulesets: Union[list, tuple]) -> list:
1383
1384
  global_selectors = ("html", "body", ":root", "*")
1384
1385
  global_rulesets = []
1385
1386
  for ruleset in rulesets:
1386
- if ruleset.selector in global_selectors:
1387
+ if (
1388
+ ruleset.selector in global_selectors
1389
+ or "html" in ruleset.selector[:4]
1390
+ or "body" in ruleset.selector[:4]
1391
+ ):
1387
1392
  selector = ruleset.selector
1388
1393
  background_color = ""
1389
1394
  color = ""
@@ -1392,10 +1397,6 @@ def get_global_color_details(rulesets: Union[list, tuple]) -> list:
1392
1397
  background_color = declaration.value
1393
1398
  elif declaration.property == "color":
1394
1399
  color = declaration.value
1395
- if is_gradient(color):
1396
- colors = process_gradient(color)
1397
- todo = input("We have colors: " + colors)
1398
- print(todo)
1399
1400
  elif declaration.property == "background":
1400
1401
  background_color = declaration.value
1401
1402
  if is_gradient(background_color):
@@ -2452,22 +2453,93 @@ def get_global_color_report(project_dir: str, level="aaa") -> list:
2452
2453
  for data in all_file_data:
2453
2454
  filename = data[0]
2454
2455
  passes = []
2456
+ global_data = {
2457
+ "selector": "",
2458
+ "specificity": "",
2459
+ "bg_color": "",
2460
+ "color": "",
2461
+ "contrast_ratio": 0.0,
2462
+ "passes_normal": False,
2463
+ "passes_large": False,
2464
+ }
2455
2465
  for sheet in data[1]:
2456
2466
  rules = sheet.rulesets
2457
2467
  global_color_data = get_global_color_details(rules)
2458
2468
  if global_color_data:
2459
- for item in global_color_data:
2460
- file, result = get_color_data(filename, item, level)
2461
- if "fail" in result:
2462
- passes.append(f"fail: {file} {result}")
2469
+ previous_selector = global_data.get("selector")
2470
+ if previous_selector:
2471
+ previous_data = global_data.copy()
2472
+
2473
+ data = global_color_data[0]
2474
+ current_selector = data.get("selector")
2475
+ global_data["selector"] = current_selector
2476
+
2477
+ current_specificity = get_specificity(current_selector)
2478
+ global_data["specificity"] = current_specificity
2479
+
2480
+ bg_color = data.get("background-color")
2481
+ global_data["bg_color"] = bg_color
2482
+
2483
+ color = data.get("color")
2484
+ global_data["color"] = color
2485
+
2486
+ ratio = data.get("contrast_ratio")
2487
+ global_data["contrast_ratio"] = ratio
2488
+
2489
+ if level == "aaa":
2490
+ passes_normal = data.get("passes_normal_aaa")
2491
+ passes_large = data.get("passes_large_aaa")
2492
+ else:
2493
+ passes_normal = data.get("passes_normal_aa")
2494
+ passes_large = data.get("passes_large_aa")
2495
+
2496
+ global_data["passes_normal"] = passes_normal
2497
+ global_data["passes_large"] = passes_large
2498
+
2499
+ # If there was a previous selector
2500
+ if previous_selector:
2501
+ previous_specificity = previous_data.get("specificity")
2502
+
2503
+ # If previous specificity is higher, we need everything
2504
+ # from previous
2505
+ if previous_specificity > current_specificity:
2506
+ # Keep all from previous data
2507
+ for key, val in previous_data.items():
2508
+ if key in ["bg_color", "color"] and val:
2509
+ global_data[key] = val
2463
2510
  else:
2464
- passes.append(f"pass: {file} {result}")
2465
- if passes:
2466
- details = ""
2467
- for detail in passes:
2468
- details += detail
2511
+ # we already overrode everything
2512
+ # Anything missing? We need to pull from previous data
2513
+ for key, val in global_data.items():
2514
+ if not val:
2515
+ new_val = previous_data.get(key)
2516
+ if new_val:
2517
+ global_data[key] = new_val
2518
+ # reassess color contrast for global_data
2519
+ bg_color = global_data.get("bg_color")
2520
+ color = global_data.get("color")
2521
+ if not bg_color or not color:
2522
+ details = f"fail: {filename} does not apply "
2523
+ if not bg_color and not color:
2524
+ details += "neither background nor text color."
2525
+ elif not bg_color:
2526
+ details += "background color."
2527
+ else:
2528
+ details += "text color."
2469
2529
  else:
2470
- details = f"fail: {filename} does NOT apply global colors"
2530
+ bg_hex = color_tools.get_hex(bg_color)
2531
+ color_hex = color_tools.get_hex(color)
2532
+ contrast_ratio = color_tools.contrast_ratio(bg_hex, color_hex)
2533
+ normal_level = f"Normal {level.upper()}"
2534
+ passes = color_tools.passes_color_contrast(
2535
+ normal_level, bg_hex, color_hex
2536
+ )
2537
+ if passes:
2538
+ details = f"pass: {filename} applies global colors and passes"
2539
+ details += f" Normal {level.upper()} at a ratio of "
2540
+ details += f"{contrast_ratio}:1."
2541
+ else:
2542
+ details = f"fail: {filename} does NOT apply global colors"
2471
2543
  report.append(details)
2472
2544
  if not report:
2473
2545
  report.append("fail: no html files to apply color styles to")
@@ -21,6 +21,7 @@ I will also not support calculating `ch` or `ex` since those are
21
21
  dependent upon the font-family itself and require information from the
22
22
  font-file or some other hidden calculation from the browser.
23
23
  """
24
+
24
25
  import re
25
26
  from typing import Any
26
27
  from typing import Union
@@ -12,6 +12,7 @@ elements were present or not.
12
12
  Typical usage example:
13
13
  ``
14
14
  """
15
+
15
16
  import os
16
17
  import re
17
18
  from typing import Union
@@ -15,6 +15,7 @@ According to the article...
15
15
  blocks of text
16
16
  - Use hypertext to split up long information into multiple pages"
17
17
  """
18
+
18
19
  import re
19
20
  from collections.abc import Sequence
20
21
  from typing import Union
File without changes
File without changes