webcode-tk 1.2.12__tar.gz → 1.2.18__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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webcode-tk
3
- Version: 1.2.12
3
+ Version: 1.2.18
4
4
  Summary: A set of tools for inspecting HTML and CSS code
5
5
  License: MIT
6
6
  License-File: LICENSE.rst
@@ -23,6 +23,7 @@ Requires-Dist: mechanicalsoup (>=1.4.0,<2.0.0)
23
23
  Requires-Dist: nltk (>=3.8.1,<4.0.0)
24
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.12"
3
+ version = "1.2.18"
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", ]
@@ -28,6 +28,7 @@ lxml = "^6.0.0"
28
28
  mechanicalsoup = "^1.4.0"
29
29
  pytest-mock = "^3.15.1"
30
30
  pre-commit = "^4.5.0"
31
+ pytest = "^9.0.3"
31
32
 
32
33
  [tool.poetry.group.dev.dependencies]
33
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.12"
43
+ __version__ = "1.2.18"
@@ -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
@@ -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
@@ -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
@@ -871,12 +878,16 @@ def apply_rule_to_element(
871
878
  "specificity": rule_specificity,
872
879
  "source": "rule",
873
880
  "selector": selector,
874
- "css_file": css_source_info.get("filename")
875
- if css_source_info
876
- else None,
877
- "css_source_type": css_source_info.get("source_type")
878
- if css_source_info
879
- 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
+ ),
880
891
  }
881
892
 
882
893
  # Check if we should apply this font-size rule
@@ -911,12 +922,16 @@ def apply_rule_to_element(
911
922
  "specificity": rule_specificity,
912
923
  "source": "rule",
913
924
  "selector": selector,
914
- "css_file": css_source_info.get("filename")
915
- if css_source_info
916
- else None,
917
- "css_source_type": css_source_info.get("source_type")
918
- if css_source_info
919
- 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
+ ),
920
935
  "contrast_analysis": "indeterminate",
921
936
  "reason": "background_image_blocks_color_analysis",
922
937
  "original_background": property_value,
@@ -933,16 +948,22 @@ def apply_rule_to_element(
933
948
  "specificity": rule_specificity,
934
949
  "source": "rule",
935
950
  "selector": selector,
936
- "css_file": css_source_info.get("filename")
937
- if css_source_info
938
- else None,
939
- "css_source_type": css_source_info.get("source_type")
940
- if css_source_info
941
- 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
+ ),
942
961
  "contrast_analysis": "determinable",
943
- "original_background": property_value
944
- if property_value != effective_color
945
- else None,
962
+ "original_background": (
963
+ property_value
964
+ if property_value != effective_color
965
+ else None
966
+ ),
946
967
  }
947
968
 
948
969
  # Apply specificity logic and set the property
@@ -1000,12 +1021,16 @@ def apply_rule_to_element(
1000
1021
  "specificity": rule_specificity,
1001
1022
  "source": "rule",
1002
1023
  "selector": selector,
1003
- "css_file": css_source_info.get("filename")
1004
- if css_source_info
1005
- else None,
1006
- "css_source_type": css_source_info.get("source_type")
1007
- if css_source_info
1008
- 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
+ ),
1009
1034
  }
1010
1035
 
1011
1036
  return
@@ -1127,149 +1152,6 @@ def apply_inheritance(soup: BeautifulSoup, computed_styles: dict) -> None:
1127
1152
  apply_visual_background_inheritance(computed_styles)
1128
1153
 
1129
1154
 
1130
- def apply_css_inheritance(computed_styles: dict) -> None:
1131
- """
1132
- Apply traditional CSS inheritance for inheritable properties.
1133
- Only inherit from the closest ancestor with an explicit rule.
1134
- """
1135
- inheritable_props = {"color", "font-size", "font-weight"}
1136
-
1137
- for child_element in computed_styles:
1138
- child_styles = computed_styles[child_element]
1139
-
1140
- # find closest ancestor with explicit value
1141
- for prop in inheritable_props:
1142
- child_prop = child_styles.get(prop)
1143
-
1144
- # Skip if child has explicit CSS rule (not default)
1145
- if child_prop and child_prop.get("source") not in [
1146
- "default",
1147
- None,
1148
- ]:
1149
- continue
1150
-
1151
- # Walk up tree to find first ancestor with explicit value
1152
- current = child_element.parent
1153
- inherited_value = None
1154
-
1155
- while current and not inherited_value:
1156
- if current in computed_styles:
1157
- parent_styles = computed_styles[current]
1158
- parent_prop = parent_styles.get(prop)
1159
-
1160
- # Found a parent with this property
1161
- if parent_prop:
1162
- # Only inherit if parent has explicit rule
1163
- if parent_prop.get("source") == "rule":
1164
- inherited_value = parent_prop
1165
- inherited_from = current
1166
- break # ← Stop at first explicit ancestor
1167
-
1168
- current = current.parent
1169
-
1170
- # Apply inherited value if found
1171
- if inherited_value:
1172
- child_styles[prop] = {
1173
- "value": inherited_value["value"],
1174
- "specificity": inherited_value["specificity"],
1175
- "source": "inheritance",
1176
- "selector": inherited_value.get("selector", "inherited"),
1177
- "css_file": inherited_value.get("css_file"),
1178
- "css_source_type": inherited_value.get("css_source_type"),
1179
- "inherited_from": inherited_from,
1180
- }
1181
-
1182
-
1183
- def apply_visual_background_inheritance(computed_styles: dict) -> None:
1184
- """
1185
- Apply visual background colors to elements without explicit backgrounds.
1186
-
1187
- Args:
1188
- computed_styles: a dictionary of elements with computed styles.
1189
- Returns:
1190
- None
1191
- """
1192
-
1193
- for element in computed_styles:
1194
- element_styles = computed_styles[element]
1195
-
1196
- # Check for BOTH background-color AND background
1197
- current_bg = element_styles.get("background-color")
1198
- has_background_shorthand = "background" in element_styles
1199
-
1200
- # If element has background shorthand, process it for images
1201
- if has_background_shorthand:
1202
- bg_value = element_styles["background"]["value"]
1203
-
1204
- # Check if background contains a raster image
1205
- if contains_raster_image(bg_value):
1206
- # Mark as contrast-indeterminate - cannot analyze
1207
- element_styles["background-color"] = {
1208
- "value": None,
1209
- "specificity": "000",
1210
- "contrast_analysis": "indeterminate",
1211
- "reason": "background_image_blocks_color_analysis",
1212
- "original_background": bg_value,
1213
- "visual_inheritance": False,
1214
- }
1215
- continue
1216
- else:
1217
- # Has usable background shorthand - skip inheritance
1218
- continue
1219
- if not current_bg:
1220
- continue
1221
-
1222
- bg_source = current_bg.get("source")
1223
- bg_value = current_bg.get("value")
1224
-
1225
- # Skip elements with explicit CSS rules
1226
- if bg_source == "rule":
1227
- continue
1228
-
1229
- # Only update elements that still have the default white background
1230
- # This prevents overwriting elements with existing proper backgrounds
1231
- if bg_source != "default":
1232
- continue
1233
-
1234
- # Element needs background inheritance (lines 1120-1141)
1235
- ancestor_bg = find_ancestor_background(element, computed_styles)
1236
-
1237
- if not ancestor_bg or ancestor_bg.get("source") == "default":
1238
- # No explicit background to inherit - keep browser default
1239
- continue
1240
-
1241
- if ancestor_bg.get("contrast_analysis") == "indeterminate":
1242
- # Ancestor is indeterminate - propagate that status
1243
- element_styles["background-color"] = {
1244
- "value": None,
1245
- "specificity": "000",
1246
- "contrast_analysis": "indeterminate",
1247
- "reason": ancestor_bg["reason"],
1248
- "inherited_from": ancestor_bg["source_element"],
1249
- "source": "visual_inheritance",
1250
- "original_background": ancestor_bg.get("original_background"),
1251
- }
1252
-
1253
- else:
1254
- # Normal inheritance - extract usable color
1255
- contrast_color = extract_contrast_color(ancestor_bg["value"])
1256
- effective_color = (
1257
- contrast_color if contrast_color else ancestor_bg["value"]
1258
- )
1259
- element_styles["background-color"] = {
1260
- "value": effective_color,
1261
- "specificity": "000",
1262
- "source": "visual_inheritance",
1263
- "inherited_from": ancestor_bg["source_element"],
1264
- "contrast_analysis": "determinable",
1265
- "original_background": (
1266
- ancestor_bg["value"]
1267
- if ancestor_bg["value"] != DEFAULT_GLOBAL_BACKGROUND
1268
- else None
1269
- ),
1270
- }
1271
-
1272
-
1273
1155
  def has_usable_background_color(background_prop: dict) -> bool:
1274
1156
  """
1275
1157
  Check if background property contains usable color for contrast.
@@ -1299,193 +1181,6 @@ def has_usable_background_color(background_prop: dict) -> bool:
1299
1181
  )
1300
1182
 
1301
1183
 
1302
- def contains_raster_image(background_value: str) -> bool:
1303
- """Check if background contains a raster image that blocks color analysis.
1304
-
1305
- Args:
1306
- background_value: the css value applied to the background property.
1307
-
1308
- Returns:
1309
- bool: whether the background value includes a raster image or not.
1310
- """
1311
- # Add None check
1312
- if background_value is None:
1313
- return False
1314
-
1315
- return bool(
1316
- re.search(
1317
- r"url\([^)]*\.(jpg|jpeg|png|gif|webp|bmp)", background_value, re.I
1318
- )
1319
- )
1320
-
1321
-
1322
- def extract_contrast_color(background_value: str) -> str:
1323
- """
1324
- Extract usable color for contrast analysis, or None.
1325
-
1326
- Args:
1327
- background_value (str): CSS background property value.
1328
-
1329
- Returns:
1330
- str: Color value suitable for contrast analysis, or None if no
1331
- usable color found.
1332
- """
1333
- # Add None check at the beginning
1334
- if background_value is None:
1335
- return None
1336
-
1337
- # Remove raster images - they block color visibility
1338
- if re.search(
1339
- r"url\([^)]*\.(jpg|jpeg|png|gif|webp|bmp)", background_value, re.I
1340
- ):
1341
- return None
1342
-
1343
- # Handle gradients - extract representative color
1344
- if "gradient" in background_value.lower():
1345
- return extract_gradient_contrast_color(background_value)
1346
-
1347
- # Handle solid colors
1348
- re_pattern = r"(#[a-f0-9]{3,6}|rgb\([^)]+\)|rgba\([^)]+\)|hsl\([^)]+\)|"
1349
- re_pattern += r"hsla\([^)]+\)|[a-z]+)"
1350
- color_match = re.search(
1351
- re_pattern,
1352
- background_value,
1353
- re.I,
1354
- )
1355
- return color_match.group(1) if color_match else None
1356
-
1357
-
1358
- def extract_gradient_contrast_color(background_value: str) -> str:
1359
- """
1360
- Extract representative color from gradient for contrast analysis.
1361
-
1362
- Args:
1363
- background_value (str): CSS gradient value (e.g.,
1364
- 'linear-gradient(red, blue)').
1365
-
1366
- Returns:
1367
- str: Representative color for contrast analysis, or None if no
1368
- color found.
1369
- """
1370
- # Add None check
1371
- if background_value is None:
1372
- return None
1373
-
1374
- # Strategy: Use the final color in gradient (most visible for reading)
1375
- # This handles: linear-gradient(red, blue) -> blue
1376
- # radial-gradient(center, red, blue) -> blue
1377
-
1378
- # Find all colors in gradient
1379
- color_pattern = r"(#[a-f0-9]{3,6}|rgb\([^)]+\)|rgba\([^)]+\)|"
1380
- color_pattern += r"hsl\([^)]+\)|hsla\([^)]+\)|[a-z]+)"
1381
- colors = re.findall(color_pattern, background_value, re.I)
1382
-
1383
- if colors:
1384
- # Use last color (end of gradient)
1385
- return colors[-1]
1386
-
1387
- return None
1388
-
1389
-
1390
- def find_ancestor_background(element: Tag, computed_styles: dict) -> dict:
1391
- """
1392
- Walk up DOM tree to find first ancestor with background.
1393
-
1394
- Args:
1395
- element (Tag): BeautifulSoup Tag object to start searching from.
1396
- computed_styles (dict): Dictionary mapping elements to their
1397
- computed styles.
1398
-
1399
- Returns:
1400
- dict: Dictionary containing 'value' (background color) and
1401
- 'source_element' (ancestor element or None).
1402
- """
1403
- current = element.parent
1404
-
1405
- while current:
1406
- if current in computed_styles:
1407
- current_styles = computed_styles[current]
1408
-
1409
- # Flag to track if we should skip this ancestor
1410
- skip_ancestor = False
1411
-
1412
- # Check for background-color or background
1413
- for bg_prop in ["background-color", "background"]:
1414
- if bg_prop in current_styles:
1415
- bg_source = current_styles[bg_prop].get("source")
1416
-
1417
- # Skip elements that only have visual inheritance
1418
- if bg_source == "visual_inheritance":
1419
- skip_ancestor = True
1420
- break # ✅ Break out of property loop
1421
-
1422
- # Accept both "rule" and "default" sources
1423
- if bg_source in ["rule", "default"]:
1424
- bg_value = current_styles[bg_prop]["value"]
1425
- if bg_source == "default":
1426
- # Skip ancestors with only default backgrounds
1427
- skip_ancestor = True
1428
- break
1429
-
1430
- # Does this ancestor already is indeterminate status
1431
- if (
1432
- current_styles[bg_prop].get("contrast_analysis")
1433
- == "indeterminate"
1434
- ):
1435
- return {
1436
- "value": None,
1437
- "source_element": current,
1438
- "contrast_analysis": "indeterminate",
1439
- "reason": "ancestor_has_background_image",
1440
- "original_background": current_styles[
1441
- bg_prop
1442
- ].get("original_background", bg_value),
1443
- }
1444
-
1445
- # Skip None values and continue searching
1446
- if bg_value is None:
1447
- continue # Continue to next property
1448
-
1449
- # Check if this background contains a raster image
1450
- if contains_raster_image(bg_value):
1451
- return {
1452
- "value": None,
1453
- "source_element": current,
1454
- "contrast_analysis": "indeterminate",
1455
- "reason": "ancestor_has_background_image",
1456
- "original_background": bg_value,
1457
- }
1458
-
1459
- # Found explicit background
1460
- bg_items = current_styles[bg_prop].items()
1461
- return {
1462
- "value": bg_value,
1463
- "source_element": current,
1464
- "contrast_analysis": "determinable",
1465
- # Copy metadata from ancestor
1466
- **{
1467
- metadata_key: metadata_value
1468
- for metadata_key, metadata_value in bg_items
1469
- if metadata_key not in ["value"]
1470
- },
1471
- }
1472
-
1473
- # If we flagged this ancestor to skip, move to next ancestor
1474
- if skip_ancestor:
1475
- current = current.parent
1476
- continue # ✅ Now this continues to next ancestor
1477
-
1478
- current = current.parent
1479
-
1480
- # No ancestor background found, use default
1481
- return {
1482
- "value": DEFAULT_GLOBAL_BACKGROUND,
1483
- "source_element": None,
1484
- "contrast_analysis": "determinable",
1485
- "source": "default",
1486
- }
1487
-
1488
-
1489
1184
  def is_inheritable_property(property_name: str) -> bool:
1490
1185
  """
1491
1186
  Determines if a CSS property is inheritable by default.
@@ -1650,9 +1345,11 @@ def extract_property_source(property_data: dict) -> dict:
1650
1345
  "source_type": "visual_inheritance",
1651
1346
  "css_file": "visual_cascade",
1652
1347
  "selector": "ancestor_background",
1653
- "inherited_from": getattr(inherited_element, "name", None)
1654
- if inherited_element
1655
- else None,
1348
+ "inherited_from": (
1349
+ getattr(inherited_element, "name", None)
1350
+ if inherited_element
1351
+ else None
1352
+ ),
1656
1353
  }
1657
1354
  elif source_type == "rule":
1658
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
@@ -1361,6 +1362,8 @@ def get_font_families(sheet: Stylesheet) -> list:
1361
1362
  # always take the last family as it would be an override in CSS
1362
1363
  family = families[-1]
1363
1364
  font_families.append({"selector": selector, "family": family})
1365
+ else:
1366
+ print()
1364
1367
  return font_families
1365
1368
 
1366
1369
 
@@ -21,11 +21,15 @@ 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
28
+ from file_clerk import clerk
27
29
 
28
30
  from webcode_tk import css_tools
31
+ from webcode_tk import html_tools as html
32
+
29
33
 
30
34
  absolute_keyword_regex = (
31
35
  r"\b(?:xx-small|x-small|small(?!-caps)|medium|large|x-large|"
@@ -356,6 +360,103 @@ def is_valid_shorthand(value: str) -> bool:
356
360
  return is_valid
357
361
 
358
362
 
363
+ def get_google_font_report(project_dir: str, min=1, max=2) -> str:
364
+ """determines if the page is using the min number of google fonts
365
+
366
+ We're looking for three things in each web page:
367
+ 1) a link in the head to the googlefonts API,
368
+ 2) each of those fonts being applied, and
369
+ 3) at least the minimum number of unique Google fonts and no more than the
370
+ max #.
371
+
372
+ Args:
373
+ project_dir: the folder where the files reside
374
+ min: minimum number of Google font families in use
375
+ max: maximum number of Google font families in use
376
+
377
+ Returns:
378
+ report: a pass/fail report with accompanying data
379
+ """
380
+
381
+ # Loop through all HTML docs and their styles
382
+ report = []
383
+ styles = css_tools.get_styles_by_html_files(project_dir)
384
+ for data in styles:
385
+
386
+ # Step 1: get all linked Google fonts
387
+ file = data.get("file")
388
+ filename = clerk.get_file_name(file)
389
+ links = html.get_elements("link", file)
390
+ google_fonts_linked = []
391
+
392
+ for link in links:
393
+ href = link.attrs.get("href")
394
+ if (
395
+ "fonts.googleapis.com" in href
396
+ and href != "https://fonts.googleapis.com"
397
+ ):
398
+ fonts = get_google_font_data(href)
399
+ if isinstance(fonts, list):
400
+ google_fonts_linked += fonts
401
+ else:
402
+ google_fonts_linked.append(fonts)
403
+
404
+ # Check to see the linked fonts match required number.
405
+ num_fonts = len(google_fonts_linked)
406
+ if num_fonts < min:
407
+ data = f"fail: {filename} does not link to min number of Google "
408
+ data += "fonts."
409
+ report.append(data)
410
+ continue
411
+
412
+ # if we have any google fonts, are they being used?
413
+ stylesheets = data.get("stylesheets")
414
+ fonts_used = []
415
+ for sheet in stylesheets:
416
+ families = []
417
+ font_data = css_tools.get_font_families(sheet)
418
+ if font_data:
419
+ families = extract_families(font_data)
420
+ if families:
421
+ for family in google_fonts_linked:
422
+ if family in families:
423
+ if family not in fonts_used:
424
+ fonts_used.append(family)
425
+
426
+ num_used = len(fonts_used)
427
+ if num_used >= min and num_used <= max:
428
+ data = f"pass: {filename} uses the correct number of Google fonts"
429
+ report.append(data)
430
+ else:
431
+ data = f"fail: {filename} with {num_used} font families does not "
432
+ data += "have enough applied Google fonts"
433
+ report.append(data)
434
+ return report
435
+
436
+
437
+ def extract_families(family_data: list) -> list:
438
+ families = []
439
+ for datum in family_data:
440
+ family_value = datum.get("family")
441
+ family = family_value.split(",")[0]
442
+ family = family.strip()
443
+ family = family.replace('"', "")
444
+ family = family.replace("'", "")
445
+ families.append(family)
446
+ return families
447
+
448
+
449
+ def get_google_font_data(href: str) -> list:
450
+ font_families = []
451
+ font_data = href.split("family=")
452
+ font_data.pop(0)
453
+ for item in font_data:
454
+ family = item.split(":")[0]
455
+ family = family.replace("+", " ")
456
+ font_families.append(family)
457
+ return font_families
458
+
459
+
359
460
  if __name__ == "__main__":
360
461
  sample_styles = """
361
462
  /* <absolute-size> values */
@@ -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