`_ - ``ayeaye`` (check for clashes with accesskeys).
35 |
36 |
37 | Future additions
38 | ----------------
39 |
40 | The following guidelines and techniques have been identified as potential
41 | additions to the WCAG-Zoo.
42 |
43 | - 1.1.1 - Nontext Content
44 |
45 | * H53, H44
46 | * H36 - Make sure input tags with src have alt or text
47 | * H30 - Make sure links have text
48 |
49 | - 1.2.3 - Audio Description or Media Alternative (Prerecorded) - H96
50 | - 1.3.1 - Info and Relationships
51 | - 2.4.1 - Bypass blocks - H69
52 | - 2.4.2 - Page titled - H25 + G88 (very basic)
53 | - 2.4.7 - Focus Visible - ( for :focus)
54 | - 3.1.1 - Language of Page - H57
55 | - 4.1.1 - Parsing H74, H75
56 |
--------------------------------------------------------------------------------
/docs/_themes/wcaglabaster/theme.conf:
--------------------------------------------------------------------------------
1 | [theme]
2 | inherit = alabaster
3 | stylesheet = alabaster.css
4 | pygments_style = alabaster.support.Alabaster
5 |
6 | [options]
7 | logo =
8 | logo_name = false
9 | logo_text_align = left
10 | description =
11 | description_font_style = normal
12 | github_user =
13 | github_repo =
14 | github_button = true
15 | github_banner = false
16 | github_type = watch
17 | github_count = true
18 | travis_button = false
19 | codecov_button = false
20 | gratipay_user =
21 | gittip_user =
22 | analytics_id =
23 | touch_icon =
24 | canonical_url =
25 | extra_nav_links =
26 | sidebar_includehidden = true
27 | sidebar_collapse = true
28 | show_powered_by = true
29 | show_related = false
30 |
31 | gray_1 = #444
32 | gray_2 = #EEE
33 | gray_3 = #AAA
34 |
35 | pink_1 = #FCC
36 | pink_2 = #FAA
37 | pink_3 = #D52C2C
38 |
39 | base_bg = #fff
40 | base_text = #000
41 | hr_border = #B1B4B6
42 | body_bg =
43 | body_text = #3E4349
44 | body_text_align = left
45 | footer_text = #11
46 | link = #004B6B
47 | link_hover = #6D4100
48 | sidebar_header =
49 | sidebar_text = #555
50 | sidebar_link =
51 | sidebar_link_underscore = #999
52 | sidebar_search_button = #CCC
53 | sidebar_list = #000
54 | sidebar_hr =
55 | anchor = #DDD
56 | anchor_hover_fg =
57 | anchor_hover_bg = #EAEAEA
58 | table_border = #888
59 | shadow =
60 |
61 | # Admonition options
62 | ## basic level
63 | admonition_bg =
64 | admonition_border = #CCC
65 | note_bg =
66 | note_border = #CCC
67 | seealso_bg =
68 | seealso_border = #CCC
69 |
70 | ## critical level
71 | danger_bg =
72 | danger_border =
73 | danger_shadow =
74 | error_bg =
75 | error_border =
76 | error_shadow =
77 |
78 | ## normal level
79 | tip_bg =
80 | tip_border = #CCC
81 | hint_bg =
82 | hint_border = #CCC
83 | important_bg =
84 | important_border = #CCC
85 |
86 | ## warning level
87 | caution_bg =
88 | caution_border =
89 | attention_bg =
90 | attention_border =
91 | warn_bg =
92 | warn_border =
93 |
94 | topic_bg =
95 | code_highlight_bg =
96 | highlight_bg = #FAF3E8
97 | xref_border = #fff
98 | xref_bg = #FBFBFB
99 | admonition_xref_border = #fafafa
100 | admonition_xref_bg =
101 | footnote_bg = #FDFDFD
102 | footnote_border =
103 | pre_bg =
104 | narrow_sidebar_bg = #333
105 | narrow_sidebar_fg = #FFF
106 | narrow_sidebar_link =
107 | font_size = 17px
108 | caption_font_size = inherit
109 | viewcode_target_bg = #ffd
110 | code_bg = #ecf0f3
111 | code_text = #222
112 | code_hover = #EEE
113 | code_font_size = 0.9em
114 | code_font_family = 'Consolas', 'Menlo', 'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono', monospace
115 | font_family = 'goudy old style', 'minion pro', 'bell mt', Georgia, 'Hiragino Mincho Pro', serif
116 | head_font_family = 'Garamond', 'Georgia', serif
117 | caption_font_family = inherit
118 | code_highlight = #FFC
119 | page_width = 940px
120 | sidebar_width = 220px
121 | fixed_sidebar = false
122 |
--------------------------------------------------------------------------------
/wcag_zoo/validators/ayeaye.py:
--------------------------------------------------------------------------------
1 | from wcag_zoo.utils import WCAGCommand
2 |
3 | error_codes = {
4 | 1: "Duplicate `accesskey` attribute '{key}' found. First seen at element {elem}",
5 | 2: "Blank `accesskey` attribute found at element {elem}",
6 | 3: "No `accesskey` attributes found, consider adding some to improve keyboard accessibility",
7 | }
8 |
9 |
10 | class Ayeaye(WCAGCommand):
11 | """
12 | Checks for the existance of access key attributes within a HTML document and confirms their uniqueness.
13 | Fails if any duplicate access keys are found in the document
14 | Warns if no access keys are found in the document
15 | """
16 |
17 | animal = """
18 | The aye-aye is a lemur which lives in rain forests of Madagascar, a large island off the southeast coast of Africa.
19 | The aye-aye has rodent-like teeth and a special thin middle finger to get at the insect grubs under tree bark.
20 |
21 | - https://simple.wikipedia.org/wiki/Aye-aye
22 | """
23 | xpath = '/html/body//*[@accesskey]'
24 | error_codes = {
25 | 'ayeaye-1': "Duplicate `accesskey` attribute '{key}' found. First seen at element {elem}",
26 | 'ayeaye-2': "Blank `accesskey` attribute found at element {elem}",
27 | 'ayeaye-3-warning': "No `accesskey` attributes found, consider adding some to improve keyboard accessibility",
28 | }
29 |
30 | def validate_document(self, html):
31 | self.tree = self.get_tree(html)
32 |
33 | # find all nodes that have access keys
34 | self.found_keys = {}
35 | self.run_validation_loop()
36 | if len(self.tree.xpath('/html/body//*[@accesskey]')) == 0:
37 | self.add_warning(
38 | guideline='2.1.1',
39 | technique='G202',
40 | node=self.tree.xpath('/html/body')[0],
41 | message=Ayeaye.error_codes['ayeaye-3-warning'],
42 | error_code='ayeaye-3-warning',
43 | )
44 |
45 | return {
46 | "success": self.success,
47 | "failures": self.failures,
48 | "warnings": self.warnings,
49 | "skipped": self.skipped
50 | }
51 |
52 | def validate_element(self, node):
53 | access_key = node.get('accesskey')
54 | if not access_key:
55 | # Blank or empty
56 | self.add_failure(
57 | guideline='2.1.1',
58 | technique='G202',
59 | node=node,
60 | error_code='ayeaye-2',
61 | message=Ayeaye.error_codes['ayeaye-2'].format(elem=node.getroottree().getpath(node)),
62 | )
63 | elif access_key not in self.found_keys.keys():
64 | self.add_success(
65 | guideline='2.1.1',
66 | technique='G202',
67 | node=node
68 | )
69 | self.found_keys[access_key] = node.getroottree().getpath(node)
70 | else:
71 | self.add_failure(
72 | guideline='2.1.1',
73 | technique='G202',
74 | node=node,
75 | error_code='ayeaye-1',
76 | message=Ayeaye.error_codes['ayeaye-1'].format(key=access_key, elem=self.found_keys[access_key]),
77 | )
78 |
79 | if __name__ == "__main__":
80 | cli = Ayeaye.as_cli()
81 | cli()
82 |
--------------------------------------------------------------------------------
/wcag_zoo/validators/tarsier.py:
--------------------------------------------------------------------------------
1 | from wcag_zoo.utils import WCAGCommand
2 |
3 |
4 | class Tarsier(WCAGCommand):
5 | """
6 | Tarsier reads heading levels in HTML documents (H1,H2,...) to verify the order and completion
7 | of headings against the requirements of the WCAG2.0 standard.
8 | """
9 |
10 | animal = """
11 | The tarsiers are prosimian (non-monkey) primates. They got their name
12 | from the long bones in their feet.
13 | They are now placed in the suborder Haplorhini, together with the
14 | simians (monkeys).
15 |
16 | Tarsiers have huge eyes and long feet, and catch the insects by jumping at them.
17 | During the night they wait quietly, listening for the sound of an insect moving nearby.
18 |
19 | - https://simple.wikipedia.org/wiki/Tarsier
20 | """
21 |
22 | xpath = '/html/body//*[%s]' % (" or ".join(['self::h%d' % x for x in range(1, 7)]))
23 |
24 | error_codes = {
25 | 'tarsier-1': "Incorrect header found at {elem} - H{bad} should be H{good}, text in header was {text}",
26 | 'tarsier-2-warning': "{not_h1} header seen before the first H1. Text in header was {text}",
27 | }
28 |
29 | def run_validation_loop(self, xpath=None, validator=None):
30 | if xpath is None:
31 | xpath = self.xpath
32 | headers = []
33 | for node in self.tree.xpath(xpath):
34 | if self.check_skip_element(node):
35 | continue
36 | depth = int(node.tag[1])
37 | headers.append(depth)
38 | depth = 0
39 | for node in self.tree.xpath(xpath):
40 | h = int(node.tag[1])
41 | if h == depth:
42 | self.add_success(
43 | guideline='1.3.1',
44 | technique='H42',
45 | node=node
46 | )
47 | elif h == depth + 1:
48 | self.add_success(
49 | guideline='1.3.1',
50 | technique='H42',
51 | node=node
52 | )
53 | elif h < depth:
54 | self.add_success(
55 | guideline='1.3.1',
56 | technique='H42',
57 | node=node
58 | )
59 | elif depth == 0:
60 | if h != 1:
61 | self.add_warning(
62 | guideline='1.3.1',
63 | technique='H42',
64 | node=node,
65 | message=Tarsier.error_codes['tarsier-2-warning'].format(
66 | not_h1=node.tag, text=node.text,
67 | ),
68 | error_code='tarsier-2-warning'
69 | )
70 | else:
71 | self.add_success(
72 | guideline='1.3.1',
73 | technique='H42',
74 | node=node
75 | )
76 | else:
77 | self.add_failure(
78 | guideline='1.3.1',
79 | technique='H42',
80 | node=node,
81 | message=Tarsier.error_codes['tarsier-1'].format(
82 | elem=node.getroottree().getpath(node),
83 | good=depth + 1,
84 | bad=h,
85 | text=node.text
86 | ),
87 | error_code='tarsier-1'
88 | )
89 | depth = h
90 |
91 | if __name__ == "__main__":
92 | cli = Tarsier.as_cli()
93 | cli()
94 |
--------------------------------------------------------------------------------
/docs/_themes/wcaglabaster/support.py:
--------------------------------------------------------------------------------
1 | from pygments.style import Style
2 | from pygments.token import Keyword, Name, Comment, String, Error, \
3 | Number, Operator, Generic, Whitespace, Punctuation, Other, Literal
4 |
5 | from alabaster.support import Alabaster
6 | from sphinx.pygments_styles import SphinxStyle
7 |
8 |
9 | # Originally based on FlaskyStyle which was based on 'tango'.
10 | class WCAGlabaster(Alabaster):
11 | background_color = "#f8f8f8" # doesn't seem to override CSS 'pre' styling?
12 | default_style = ""
13 |
14 | styles = SphinxStyle.styles.copy()
15 | styles.update({
16 | # # No corresponding class for the following:
17 | # #Text: "", # class: ''
18 | # Whitespace: "underline #f8f8f8", # class: 'w'
19 | # Error: "#a40000 border:#ef2929", # class: 'err'
20 | # Other: "#000000", # class 'x'
21 |
22 | # Comment: "italic #8f5902", # class: 'c'
23 | # Comment.Preproc: "noitalic", # class: 'cp'
24 |
25 | # Keyword: "bold #004461", # class: 'k'
26 | # Keyword.Constant: "bold #004461", # class: 'kc'
27 | # Keyword.Declaration: "bold #004461", # class: 'kd'
28 | # Keyword.Namespace: "bold #004461", # class: 'kn'
29 | # Keyword.Pseudo: "bold #004461", # class: 'kp'
30 | # Keyword.Reserved: "bold #004461", # class: 'kr'
31 | # Keyword.Type: "bold #004461", # class: 'kt'
32 |
33 | # Operator: "#582800", # class: 'o'
34 | # Operator.Word: "bold #004461", # class: 'ow' - like keywords
35 |
36 | # Punctuation: "bold #000000", # class: 'p'
37 |
38 | # # because special names such as Name.Class, Name.Function, etc.
39 | # # are not recognized as such later in the parsing, we choose them
40 | # # to look the same as ordinary variables.
41 | # Name: "#0e84b5", # class: 'n'
42 | # Name.Attribute: "#c4a000", # class: 'na' - to be revised
43 | # Name.Builtin: "#004461", # class: 'nb'
44 | # Name.Builtin.Pseudo: "#3465a4", # class: 'bp'
45 | Name.Class: "#006b9c", # class: 'nc' - to be revised
46 | # Name.Constant: "#000000", # class: 'no' - to be revised
47 | # Name.Decorator: "#888", # class: 'nd' - to be revised
48 | # Name.Entity: "#ce5c00", # class: 'ni'
49 | # Name.Exception: "bold #cc0000", # class: 'ne'
50 | # Name.Function: "#000000", # class: 'nf'
51 | # Name.Property: "#000000", # class: 'py'
52 | # Name.Label: "#f57900", # class: 'nl'
53 | Name.Namespace: "#006b9c", # class: 'nn' - to be revised
54 | # Name.Other: "#000000", # class: 'nx'
55 | # Name.Tag: "bold #004461", # class: 'nt' - like a keyword
56 | # Name.Variable: "#000000", # class: 'nv' - to be revised
57 | # Name.Variable.Class: "#000000", # class: 'vc' - to be revised
58 | # Name.Variable.Global: "#000000", # class: 'vg' - to be revised
59 | # Name.Variable.Instance: "#000000", # class: 'vi' - to be revised
60 |
61 | # Number: "#990000", # class: 'm'
62 |
63 | # Literal: "#000000", # class: 'l'
64 | # Literal.Date: "#000000", # class: 'ld'
65 |
66 | String: "#358100", # class: 's'
67 | # String.Backtick: "#4e9a06", # class: 'sb'
68 | # String.Char: "#4e9a06", # class: 'sc'
69 | # String.Doc: "italic #8f5902", # class: 'sd' - like a comment
70 | # String.Double: "#4e9a06", # class: 's2'
71 | # String.Escape: "#4e9a06", # class: 'se'
72 | # String.Heredoc: "#4e9a06", # class: 'sh'
73 | # String.Interpol: "#4e9a06", # class: 'si'
74 | # String.Other: "#4e9a06", # class: 'sx'
75 | # String.Regex: "#4e9a06", # class: 'sr'
76 | # String.Single: "#4e9a06", # class: 's1'
77 | # String.Symbol: "#4e9a06", # class: 'ss'
78 |
79 | # Generic: "#000000", # class: 'g'
80 | # Generic.Deleted: "#a40000", # class: 'gd'
81 | # Generic.Emph: "italic #000000", # class: 'ge'
82 | # Generic.Error: "#ef2929", # class: 'gr'
83 | # Generic.Heading: "bold #000080", # class: 'gh'
84 | # Generic.Inserted: "#00A000", # class: 'gi'
85 | # Generic.Output: "#888", # class: 'go'
86 | # Generic.Prompt: "#745334", # class: 'gp'
87 | # Generic.Strong: "bold #000000", # class: 'gs'
88 | # Generic.Subheading: "bold #800080", # class: 'gu'
89 | # Generic.Traceback: "bold #a40000", # class: 'gt'
90 | })
91 |
--------------------------------------------------------------------------------
/docs/faq.rst:
--------------------------------------------------------------------------------
1 | Frequently Asked Questions
2 | ==========================
3 |
4 | Can I use this to check my sites accessibility at different breakpoints?
5 | ------------------------------------------------------------------------
6 |
7 | Yes! Making sure your site is accessible at different screen sizes is important so
8 | this is vitally important. By default, WCAG-Zoo validators ignore ``@media`` rules, but
9 | if you are using CSS ``@media`` rules to provide different CSS rules to different users,
10 | you can declare which media rules to check against when running commands.
11 |
12 | These can be added using the ``--media_rules`` command line flag (``-M``) or using the
13 | ``media_rules`` argument in Python. Any CSS ``@media`` rule that matches against *any* of
14 | the listed ``media_rules`` to check will be used, *even if they conflict*.
15 |
16 | For example, below are some of the media rules used in the
17 | `Twitter Bootstrap CSS framework `_ ::
18 |
19 | 1. @media (max-device-width: 480px) and (orientation: landscape) {
20 | 2. @media (max-width: 767px) {
21 | 3. @media screen and (max-width: 767px) {
22 | 4. @media (min-width: 768px) {
23 | 5. @media (min-width: 768px) and (max-width: 991px) {
24 | 6. @media screen and (min-width: 768px) {
25 | 7. @media (min-width: 992px) {
26 | 8. @media (min-width: 992px) and (max-width: 1199px) {
27 | 9. @media (min-width: 1200px) {
28 |
29 | The following command will check rules 4, 5 and 6 as all contain the string ``(min-width: 768px)``::
30 |
31 | zookeeper molerat --media_rules="(min-width: 768px)"
32 |
33 | Note that this command will check media rules where the maximum width is 767px
34 | and the minimum width is 768px::
35 |
36 | zookeeper molerat -M="(min-width: 768px)" -M="(max-width: 767px)"
37 |
38 | In reality a browser would never render these as the rules conflict, but zookeeper isn't that smart yet.
39 |
40 |
41 | Why is it important to check the accesibility of hidden elements?
42 | -----------------------------------------------------------------
43 |
44 | Elements such as these often have their visibility toggled using Javascript in a browser, as such testing hidden elements ensures that
45 | if they become visible after rendering in the browser they conform to accessibility guidelines.
46 |
47 | By default, all WCAG commands check that hidden elements are valid, however they also accept a ``ignore_hidden`` argument
48 | (or ``-H`` on the command line) that prevents validation of elements that are hidden in CSS,
49 | such as those contained in elements that have a ``display:none`` or ``visibility:hidden`` directive.
50 |
51 | Why does my page fail a contrast check when the contrast between foreground text color and a background image is really high?
52 | -----------------------------------------------------------------------------------------------------------------------------
53 |
54 | Molerat can't see images and determines text contrast by checking the contrast between the calculated CSS rules for the
55 | foreground color (``color``) and background color (``background-color``) of a HTML element. If the element hasn't got a
56 |
57 | Consider white text in a div with a black background *image* but no background color, inside a div with a white back ground, like that
58 | demonstrated below ::
59 |
60 | +--------------------------------------------------+
61 | | (1) Black text / White background |
62 | | |
63 | | +-----------------------------------------+ |
64 | | | | |
65 | | | (2) White text / Transparent background | |
66 | | | Black bckrgound image | |
67 | | | | |
68 | | +-----------------------------------------+ |
69 | +--------------------------------------------------+
70 |
71 | In the above example, until the image loads the text in div (2) is invisible.
72 | If the connection is interrupted or a user has images disabled, the text would be unreadable.
73 | **The ideal way to resolve this is to add a background color to the inner ``div`` to ensure all users can read it.**
74 | If this isn't possible, to resolve this error, add the class or id to the appropriate exclusion rule. For example, from the command line::
75 |
76 | zookeeper molerat somefile.html --skip_these_classes=inner
77 | zookeeper molerat somefile.html --skip_these_ids=hero_text
78 |
79 | Or when calling as a module::
80 |
81 | Molerat(..., skip_these_classes=['inner'])
82 | Molerat(..., skip_these_ids=['hero_text'])
83 |
84 | Why doesn't WCAG-Zoo support Python 2?
85 | --------------------------------------
86 | Python 2 is on a long deprecation cycle, and a number of big libraries (such as Django)
87 | are beginning the process to remove Python 2 support entirely. Making WCAG-Zoo
88 | Python 3 only made building it much easier and removed the need for Python2/3 hacks
89 | to support both properly.
90 |
91 | If you are building a Python 2 tool and absolutely need support you have a number of options
92 |
93 | * Download the code to a place your Python 2 code can import it
94 | * Use the demonstration scripts as way to run the WCAG-Zoo command line tools from
95 | within Python 2 code using ``subprocess`` and parse the JSON
96 | * Consider how import Python 2 is to you or your users and port your code to Python 3
97 | (its not as painful as you think now and there are benefits)
98 |
--------------------------------------------------------------------------------
/docs/conf.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #
3 | # WCAG-Zoo documentation build configuration file, created by
4 | # sphinx-quickstart on Tue Dec 27 15:43:50 2016.
5 | #
6 | # This file is execfile()d with the current directory set to its
7 | # containing dir.
8 | #
9 | # Note that not all possible configuration values are present in this
10 | # autogenerated file.
11 | #
12 | # All configuration values have a default; values that are commented out
13 | # serve to show the default.
14 |
15 | # If extensions (or modules to document with autodoc) are in another directory,
16 | # add these directories to sys.path here. If the directory is relative to the
17 | # documentation root, use os.path.abspath to make it absolute, like shown here.
18 | #
19 | import os
20 | import sys
21 | sys.path.insert(0, os.path.abspath('..'))
22 | sys.path.insert(0, os.path.abspath(os.path.join('.','_themes')))
23 |
24 | import wcag_zoo
25 | VERSION = wcag_zoo.version
26 |
27 | # -- General configuration ------------------------------------------------
28 |
29 | # If your documentation needs a minimal Sphinx version, state it here.
30 | #
31 | # needs_sphinx = '1.0'
32 |
33 | # Add any Sphinx extension module names here, as strings. They can be
34 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
35 | # ones.
36 | extensions = ['sphinx.ext.autodoc',
37 | 'sphinx.ext.doctest',
38 | 'sphinx.ext.todo',
39 | 'sphinx.ext.coverage']
40 |
41 | # Add any paths that contain templates here, relative to this directory.
42 | templates_path = ['_templates']
43 |
44 | # The suffix(es) of source filenames.
45 | # You can specify multiple suffix as a list of string:
46 | #
47 | # source_suffix = ['.rst', '.md']
48 | source_suffix = '.rst'
49 |
50 | # The master toctree document.
51 | master_doc = 'index'
52 |
53 | # General information about the project.
54 | project = u'WCAG-Zoo'
55 | copyright = u'2016, Samuel Spencer'
56 | author = u'Samuel Spencer'
57 |
58 | # The version info for the project you're documenting, acts as replacement for
59 | # |version| and |release|, also used in various other places throughout the
60 | # built documents.
61 | #
62 | # The short X.Y version.
63 | version = VERSION
64 | # The full version, including alpha/beta/rc tags.
65 | release = VERSION
66 |
67 | # The language for content autogenerated by Sphinx. Refer to documentation
68 | # for a list of supported languages.
69 | #
70 | # This is also used if you do content translation via gettext catalogs.
71 | # Usually you set "language" from the command line for these cases.
72 | language = None
73 |
74 | # List of patterns, relative to source directory, that match files and
75 | # directories to ignore when looking for source files.
76 | # This patterns also effect to html_static_path and html_extra_path
77 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
78 |
79 | # The name of the Pygments (syntax highlighting) style to use.
80 | # pygments_style = 'sphinx'
81 | pygments_style = 'wcaglabaster.support.WCAGlabaster'
82 |
83 | # If true, `todo` and `todoList` produce output, else they produce nothing.
84 | todo_include_todos = True
85 |
86 |
87 | # -- Options for HTML output ----------------------------------------------
88 |
89 | # The theme to use for HTML and HTML Help pages. See the documentation for
90 | # a list of builtin themes.
91 | #
92 | html_theme_path = ["_themes", ]
93 | html_theme = 'wcaglabaster'
94 |
95 | # Theme options are theme-specific and customize the look and feel of a theme
96 | # further. For a list of options available for each theme, see the
97 | # documentation.
98 | #
99 | # html_theme_options = {}
100 |
101 | # Add any paths that contain custom static files (such as style sheets) here,
102 | # relative to this directory. They are copied after the builtin static files,
103 | # so a file named "default.css" will overwrite the builtin "default.css".
104 | html_static_path = ['_static']
105 |
106 |
107 | # -- Options for HTMLHelp output ------------------------------------------
108 |
109 | # Output file base name for HTML help builder.
110 | htmlhelp_basename = 'WCAG-Zoodoc'
111 |
112 |
113 | # -- Options for LaTeX output ---------------------------------------------
114 |
115 | latex_elements = {
116 | # The paper size ('letterpaper' or 'a4paper').
117 | #
118 | # 'papersize': 'letterpaper',
119 |
120 | # The font size ('10pt', '11pt' or '12pt').
121 | #
122 | # 'pointsize': '10pt',
123 |
124 | # Additional stuff for the LaTeX preamble.
125 | #
126 | # 'preamble': '',
127 |
128 | # Latex figure (float) alignment
129 | #
130 | # 'figure_align': 'htbp',
131 | }
132 |
133 | # Grouping the document tree into LaTeX files. List of tuples
134 | # (source start file, target name, title,
135 | # author, documentclass [howto, manual, or own class]).
136 | latex_documents = [
137 | (master_doc, 'WCAG-Zoo.tex', u'WCAG-Zoo Documentation',
138 | u'Samuel Spencer', 'manual'),
139 | ]
140 |
141 | html_sidebars = { '**': ['globaltoc.html', 'localtoc.html', 'relations.html', 'sourcelink.html', 'searchbox.html'], }
142 |
143 |
144 | # -- Options for manual page output ---------------------------------------
145 |
146 | # One entry per manual page. List of tuples
147 | # (source start file, name, description, authors, manual section).
148 | man_pages = [
149 | (master_doc, 'wcag-zoo', u'WCAG-Zoo Documentation',
150 | [author], 1)
151 | ]
152 |
153 |
154 | # -- Options for Texinfo output -------------------------------------------
155 |
156 | # Grouping the document tree into Texinfo files. List of tuples
157 | # (source start file, target name, title, author,
158 | # dir menu entry, description, category)
159 | texinfo_documents = [
160 | (master_doc, 'WCAG-Zoo', u'WCAG-Zoo Documentation',
161 | author, 'WCAG-Zoo', 'One line description of project.',
162 | 'Miscellaneous'),
163 | ]
164 |
165 | suppress_warnings = [
166 | 'image.nonlocal_uri',
167 | ]
168 |
169 |
--------------------------------------------------------------------------------
/wcag_zoo/testrunner.py:
--------------------------------------------------------------------------------
1 | import click
2 | from lxml import etree
3 | from ast import literal_eval
4 | import sys
5 | import os
6 | from utils import get_wcag_class
7 | from wcag_zoo.utils import make_flat
8 |
9 |
10 | class ValidationError(Exception):
11 | def __init__(self, message, *args):
12 | self.message = message # without this you may get DeprecationWarning
13 | super(ValidationError, self).__init__(message, *args)
14 |
15 |
16 | def test_file(filename):
17 | parser = etree.HTMLParser()
18 | path = os.path.dirname(filename)
19 | tree = etree.parse(filename, parser)
20 | root = tree.xpath("/html")[0]
21 |
22 | command = root.get('data-wcag-test-command')
23 |
24 | kwargs = dict(
25 | (arg.lstrip('data-wcag-arg-'), literal_eval(val))
26 | for arg, val in root.items()
27 | if arg.startswith('data-wcag-arg-')
28 | )
29 |
30 | test_cls = get_wcag_class(command)
31 | staticpath = kwargs.pop('staticpath', None)
32 | if staticpath:
33 | kwargs['staticpath'] = os.path.join(path, staticpath)
34 | instance = test_cls(**kwargs)
35 |
36 | with open(filename, "rb") as file:
37 | html = file.read()
38 | results = instance.validate_document(html)
39 | test_failures = []
40 | for level in ['failure', 'warning']:
41 | level_plural = level + "s"
42 | error_attr = "data-wcag-%s-code" % level
43 |
44 | # Test the nodes that we're told fail, are expected to fail
45 | _results = make_flat(results[level_plural])
46 | for result in _results:
47 | # print(result)
48 | err_code = tree.xpath(result['xpath'])[0].get(error_attr, "not given")
49 | if result['error_code'] != err_code:
50 | test_failures.append(
51 | (
52 | "Validation failured for node [{xpath}], expected {level} but no error code was found\n"
53 | " Expected error code was [{error_code}], stated error was [{err_code}]: \n{message}"
54 | ).format(
55 | xpath=result['xpath'],
56 | level=level,
57 | error_code=result['error_code'],
58 | message=result['message'],
59 | err_code=err_code
60 | )
61 | )
62 |
63 | for node in tree.xpath("//*[@%s]" % error_attr):
64 | this_path = node.getroottree().getpath(node)
65 | failed_paths = dict([(result['xpath'], result) for result in _results])
66 |
67 | error_code = node.get(error_attr, "")
68 | if this_path not in failed_paths.keys():
69 | test_failures.append(
70 | (
71 | "Test HTML states expected {level} for node [{xpath}], but the node did not fail as expected\n"
72 | " This node did not fail at all!"
73 | ).format(
74 | xpath=this_path,
75 | level=level,
76 | )
77 | )
78 | elif failed_paths[this_path].get('error_code') != error_code:
79 | test_failures.append(
80 | (
81 | "Test HTML states expected {level} for node [{xpath}], but the node did not fail as expected\n"
82 | " Expected error is was: {error}"
83 | ).format(
84 | xpath=this_path,
85 | level=level,
86 | error=failed_paths[this_path]
87 | )
88 | )
89 | if test_failures:
90 | raise ValidationError("\n ".join(test_failures))
91 |
92 |
93 | def test_files(filenames):
94 | failed = 0
95 | for f in filenames:
96 | print("Testing %s ... " % f, end="")
97 | try:
98 | test_file(f)
99 | print('\x1b[1;32m' + 'ok' + '\x1b[0m')
100 | except ValidationError as v:
101 | failed += 1
102 | print('\x1b[1;31m' + 'failed' + '\x1b[0m')
103 | print(" ", v.message)
104 | except:
105 | raise
106 | failed += 1
107 | print('\x1b[1;31m' + 'error!' + '\x1b[0m')
108 | if len(filenames) == 1:
109 | raise
110 | return failed == 0
111 |
112 |
113 | def test_command_lines(filenames):
114 | """
115 | These tests are much let thorough and just assert the command runs, and has the right number of errors.
116 | """
117 |
118 | import subprocess
119 | failed = 0
120 | for filename in filenames:
121 | print("Testing %s from command line ... " % filename, end="")
122 |
123 | parser = etree.HTMLParser()
124 | path = os.path.dirname(filename)
125 | tree = etree.parse(filename, parser)
126 | root = tree.xpath("/html")[0]
127 |
128 | command = root.get('data-wcag-test-command')
129 |
130 | kwargs = dict(
131 | (arg.lstrip('data-wcag-arg-'), literal_eval(val))
132 | for arg, val in root.items()
133 | if arg.startswith('data-wcag-arg-')
134 | )
135 |
136 | staticpath = kwargs.pop('staticpath', None)
137 | if staticpath:
138 | kwargs['staticpath'] = os.path.join(path, staticpath)
139 |
140 | args = []
141 | for arg, val in kwargs.items():
142 | if val is True:
143 | # a flag
144 | args.append("--%s" % arg)
145 | elif type(val) is list:
146 | for v in val:
147 | args.append("--%s=%s" % (arg, v))
148 | else:
149 | args.append("--%s=%s" % (arg, val))
150 |
151 | process = subprocess.Popen(
152 | ["zookeeper", command, filename] + args,
153 | stdout=subprocess.PIPE
154 | )
155 |
156 | results = process.communicate()[0].decode('utf-8')
157 |
158 | try:
159 | assert(
160 | "{num_fails} errors, {num_warns} warnings".format(
161 | num_fails=len(tree.xpath("//*[@data-wcag-failure-code]")),
162 | num_warns=len(tree.xpath("//*[@data-wcag-warning-code]")),
163 | ) in results
164 | )
165 | print('\x1b[1;32m' + 'ok' + '\x1b[0m')
166 | except ValidationError as v:
167 | failed += 1
168 | print('\x1b[1;31m' + 'failed' + '\x1b[0m')
169 | print(" ", v.message)
170 | except:
171 | failed += 1
172 | print('\x1b[1;31m' + 'error!' + '\x1b[0m')
173 | if len(filenames) == 1:
174 | raise
175 | return failed == 0
176 |
177 |
178 | @click.command()
179 | @click.argument('filenames', required=True, nargs=-1)
180 | def runner(filenames):
181 | if len(filenames) == 1 and os.path.isdir(filenames[0]):
182 | dir_name = filenames[0]
183 | filenames = [
184 | os.path.join(os.path.abspath(dir_name), f)
185 | for f in os.listdir(dir_name)
186 | if os.path.isfile(os.path.join(dir_name, f))
187 | ]
188 | all_good = all([
189 | test_files(filenames),
190 | # test_command_lines(filenames)
191 | ])
192 |
193 | if not all_good:
194 | sys.exit(1)
195 | else:
196 | sys.exit(0)
197 |
198 | if __name__ == "__main__":
199 | runner()
200 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | WCAG Zoo - Scripts for automated accessiblity validation
2 | ========================================================
3 |
4 | |wcag-zoo-aa-badge| |appveyor| |travis| |coverage| |pypi| |docs|
5 |
6 | .. |appveyor| image:: https://ci.appveyor.com/api/projects/status/uyo3jx1em3cmjrku?svg=true
7 | :target: https://ci.appveyor.com/project/LegoStormtroopr/wcag-zoo
8 | :alt: Appveyor testing status
9 |
10 | .. |travis| image:: https://travis-ci.org/data61/wcag-zoo.svg?branch=master
11 | :target: https://travis-ci.org/data61/wcag-zoo
12 | :alt: Travis-CI testing status
13 |
14 | .. |coverage| image:: https://coveralls.io/repos/github/data61/wcag-zoo/badge.svg
15 | :target: https://coveralls.io/github/data61/wcag-zoo
16 | :alt: Coveralls code coverage
17 |
18 | .. |pypi| image:: https://badge.fury.io/py/wcag-zoo.svg
19 | :target: https://badge.fury.io/py/wcag-zoo
20 | :alt: Current version on PyPI
21 |
22 | .. |docs| image:: https://readthedocs.org/projects/wcag-zoo/badge/?version=latest
23 | :target: http://wcag-zoo.readthedocs.io/en/latest/?badge=latest
24 | :alt: Documentation Status
25 |
26 | .. rtd-inclusion-marker
27 |
28 | What is it?
29 | -----------
30 |
31 | WCAG-Zoo is a set of command line tools that help provide basic validation of HTML
32 | against the accessibility guidelines laid out by the W3C Web Content Accessibility Guidelines 2.0.
33 |
34 | Each tool checks against a limited set of these and is designed to return simple text output and returns an
35 | error (or success) code so it can be integrated into continuous build tools like Travis-CI or Jenkins.
36 | It can even be imported into your Python code for additional functionality.
37 |
38 | Why should I care about accessibility guidelines?
39 | -------------------------------------------------
40 |
41 | Accessibility means that everyone can use your site. We often forget that not everyone
42 | has perfect vision - or even has vision at all! Complete or partial blindess, color-blindness or just old-age
43 | can all impact how readily accessible your website can be.
44 |
45 | By building accessibility checking into your build scripts you can be relatively certain that all people can
46 | readily use your website. And if you come across an issue, you identify it early - before you hit production
47 | and they start causing problems for people.
48 |
49 | Plus, integrating accessibility into your build scripts shows that you really care about the usability of your site.
50 | These tools won't pick up every issue around accessibility, but they'll pick up enough (and do so automatically)
51 | and helps demonstrate a commitment to accessibility where possible.
52 |
53 | That sounds like a lot of work, is it really that useful?
54 | ---------------------------------------------------------
55 |
56 | Granted, accessibility is tough - and you might question how useful it is.
57 | If you have an app targeted to a very niche demographic and are working on tight timeframes,
58 | maybe accessibility isn't important right now.
59 |
60 | But some industries, such as Government, Healthcare, Legal and Retail all care **a lot** about WCAG compliance.
61 | To the point that in some areas it is legislated or mandated.
62 | In some cases not complying with certain accessibility guidelines `can even get sued `_
63 | can lead to large, expensive lawsuits!
64 |
65 | If you care about working in any of the above sectors, being able to *prove* you are compliant can be a big plus,
66 | and having that proof built in to your testing suite means identiying issues earlier before they are a problem.
67 |
68 | But all my pages are dynamically created and I use a CSS pre-processor
69 | ----------------------------------------------------------------------
70 |
71 | Doesn't matter. If you can generate them, you can output your HTML and CSS in a build script
72 | and feed them into the WCAG-Zoo via the command line.
73 |
74 |
75 | But I have lots of user-generated content! How can I possibly test that?
76 | ------------------------------------------------------------------------
77 |
78 | It doesn't matter if your site is mostly user-generated pages. Testing what you can sets a good example
79 | to your users. Plus many front-end WYSIWYG editors have their own compliance checkers too.
80 | This also sets a good example to your end-users as they know that the rest of the site is WCAG-Compliant
81 | so they should probably endevour to make sure their own content is too.
82 |
83 | Since this is a Python library if you are building a dynamic site where end users can edit HTML that
84 | uses Python on the server side you can import any of the validators directly into your code
85 | so you can confirm that the user created markup is valid as well.
86 |
87 | Lastly, if you are building a dynamic site in a language other than Python you can run any of the command
88 | line scripts with the ``--json`` or ``-J`` flag and this will produce a JSON output that can be parsed and
89 | used in your preferred target language.
90 |
91 | For details on this see the section in the documentation titled "`Using WCAG-Zoo in languages other than Python /wcag-zoo.readthedocs.io/en/latest/development/using_wcag_zoo_not_in_python.html>`_".
92 |
93 | Do I have to check *every* page?
94 | --------------------------------
95 |
96 | The good news is probably not. If your CSS is reused across across lots of your site
97 | then checking a handful of generate pages is probably good enough.
98 |
99 | You convinced me, how do I use it?
100 | ----------------------------------
101 |
102 | Two ways:
103 |
104 | 1. `In your build and tests scripts, generate some HTML files and use the command line tools so that
105 | you can verify your that the CSS and HTML you output can be read. /wcag-zoo.readthedocs.io/en/latest/development/using_wcag_zoo_not_in_python.html>`_
106 |
107 | 2. `If you are using Python, once installed from pip, you can import any or all of the tools and
108 | inspect the messages and errors directly using /wcag-zoo.readthedocs.io/en/latest/development/using_wcag_zoo_in_python.html>`_::
109 |
110 | from wcag_zoo.molerat import molerat
111 | messages = molerat(html=some_text, ... )
112 | assert len(messages['failed']) == 0
113 |
114 |
115 | I've done all that can I have a badge?
116 | --------------------------------------
117 |
118 | Of course! You are on the honour system with these for now. So if you use WCAG-Zoo in your tests
119 | and like Github-like badges, pick one of these:
120 |
121 | * |wcag-zoo-aa-badge| ``https://img.shields.io/badge/WCAG_Zoo-AA-green.svg``
122 | * |wcag-zoo-aaa-badge| ``https://img.shields.io/badge/WCAG_Zoo-AAA-green.svg``
123 |
124 | .. |wcag-zoo-aa-badge| image:: https://img.shields.io/badge/WCAG_Zoo-AA-green.svg
125 | :target: https://github.com/data61/wcag-zoo/wiki/Compliance-Statement
126 | :alt: Example badge for WCAG-Zoo Double-A compliance
127 |
128 | .. |wcag-zoo-aaa-badge| image:: https://img.shields.io/badge/WCAG_Zoo-AAA-green.svg
129 | :target: https://github.com/data61/wcag-zoo/wiki/Compliance-Statement
130 | :alt: Example badge for WCAG-Zoo Triple-A compliance
131 |
132 | ReSTructured Text::
133 |
134 | .. image:: https://img.shields.io/badge/WCAG_Zoo-AA-green.svg
135 | :target: https://github.com/data61/wcag-zoo/wiki/Compliance-Statement
136 | :alt: This repository is WCAG-Zoo compliant
137 |
138 | Markdown::
139 |
140 | ![This repository is WCAG-Zoo compliant][wcag-zoo-logo]
141 |
142 | [wcag-zoo-logo]: https://img.shields.io/badge/WCAG_Zoo-AA-green.svg "WCAG-Zoo Compliant"
143 |
144 | Installing
145 | ----------
146 |
147 | * Stable: ``pip3 install wcag-zoo``
148 | * Development: ``pip3 install https://github.com/LegoStormtroopr/wcag-zoo``
149 |
150 |
151 | How to Use
152 | ----------
153 |
154 | All WCAG-Zoo commands are exposed through ``zookeeper`` from the command line.
155 |
156 | Current critters include:
157 |
158 | * Anteater - checks ``img`` tags for alt tags::
159 |
160 | zookeeper anteater your_file.html --level=AA
161 |
162 | * Ayeaye - checks for the presence and uniqueness of accesskeys::
163 |
164 | zookeeper ayeaye your_file.html --level=AA
165 |
166 | * Molerat - color contrast checking::
167 |
168 | zookeeper molerat your_file.html --level=AA
169 |
170 | * Parade - runs all validators against the given files with allowable exclusions::
171 |
172 | zookeeper parade your_file.html --level=AA
173 |
174 | * Tarsier - tree traveral to check headings are correct::
175 |
176 | zookeeper tarsier your_file.html --level=AA
177 |
178 | For more help on zookeeper from the command line run::
179 |
180 | zookeeper --help
181 |
182 | Or for help on a specific command::
183 |
184 | zookeeper ayeaye --help
185 |
186 | Limitations
187 | -----------
188 |
189 | At this point, WCAG-Zoo commands **do not** handle nested media queries, but they do support
190 | single level media queries. So this will be interpreted::
191 |
192 | @media (min-width: 600px) and (max-width: 800px) {
193 | .this_rule_works {color:red}
194 | }
195 |
196 | But this won't (plus this isn't supported across some browsers)::
197 |
198 | @media (min-width: 600px) {
199 | @media (max-width: 800px) {
200 | .this_rule_wont_work {color:red}
201 | }
202 | }
203 |
--------------------------------------------------------------------------------
/wcag_zoo/validators/molerat.py:
--------------------------------------------------------------------------------
1 | from __future__ import print_function, division
2 | import webcolors
3 | from xtermcolor import colorize
4 | from wcag_zoo.utils import WCAGCommand, get_applicable_styles, nice_console_text
5 | from decimal import Decimal as D
6 |
7 | import logging
8 | import cssutils
9 | cssutils.log.setLevel(logging.CRITICAL)
10 |
11 | WCAG_LUMINOCITY_RATIO_THRESHOLD = {
12 | "AA": {
13 | 'normal': 4.5,
14 | 'large': 3,
15 | },
16 | "AAA": {
17 | 'normal': 7,
18 | 'large': 4.5,
19 | }
20 | }
21 |
22 | TECHNIQUE = {
23 | "AA": {
24 | 'normal': "G18",
25 | 'large': "G145",
26 | },
27 | "AAA": {
28 | 'normal': "G17",
29 | 'large': "G18",
30 | }
31 | }
32 |
33 |
34 | def normalise_color(color):
35 | rgba_color = None
36 | color = color.split("!", 1)[0].strip() # remove any '!important' declarations
37 | color = color.strip(";").strip("}") # Dang minimisers
38 |
39 | if "transparent" in color or "inherit" in color:
40 | rgba_color = [0, 0, 0, 0.0]
41 | elif color.startswith('rgb('):
42 | rgba_color = list(map(int, color.split('(')[1].split(')')[0].split(', ')))
43 | elif color.startswith('rgba('):
44 | rgba_color = list(map(float, color.split('(')[1].split(')')[0].split(', ')))
45 | else:
46 | funcs = [
47 | webcolors.hex_to_rgb,
48 | webcolors.name_to_rgb,
49 | webcolors.rgb_percent_to_rgb
50 | ]
51 |
52 | for func in funcs:
53 | try:
54 | rgba_color = list(func(color))
55 | break
56 | except:
57 | continue
58 |
59 | if rgba_color is None:
60 | rgba_color = [0, 0, 0, 1]
61 | else:
62 | rgba_color = (list(rgba_color) + [1])[:4]
63 | return rgba_color
64 |
65 |
66 | def calculate_luminocity(r=0, g=0, b=0):
67 | # Calculates luminocity according to
68 | # https://www.w3.org/TR/WCAG20-TECHS/G17.html#G17-tests
69 |
70 | x = []
71 | for C in r, g, b:
72 | c = C / D('255.0')
73 | if c < D('0.03928'):
74 | x.append(c / D('12.92'))
75 | else:
76 | x.append(((c + D('0.055')) / D('1.055')) ** D('2.4'))
77 |
78 | R, G, B = x
79 |
80 | L = D('0.2126') * R + D('0.7152') * G + D('0.0722') * B
81 | return L
82 |
83 |
84 | def generate_opaque_color(color_stack):
85 | # http://stackoverflow.com/questions/10781953/determine-rgba-colour-received-by-combining-two-colours
86 |
87 | colors = []
88 | # Take colors back off the stack until we get one with an alpha of 1.0
89 | for c in color_stack[::-1]:
90 | if int(c[3]) == 0:
91 | continue
92 | colors.append(c)
93 | if c[3] == 1.0:
94 | break
95 |
96 | red, green, blue, alpha = colors[0]
97 |
98 | for r, g, b, a in colors[1:]:
99 | if a == 0:
100 | # Skip transparent colors
101 | continue
102 | da = 1 - a
103 | alpha = alpha + a * da
104 | red = (red * D('0.25') + r * a * da) / alpha
105 | green = (green * D('0.25') + g * a * da) / alpha
106 | blue = (blue * D('0.25') + b * a * da) / alpha
107 |
108 | return [int(red), int(green), int(blue)]
109 |
110 |
111 | def calculate_font_size(font_stack):
112 | """
113 | From a list of font declarations with absolute and relative fonts, generate an approximate rendered font-size in point (not pixels).
114 | """
115 | font_size = 10 # 10 pt *not 10px*!!
116 |
117 | for font_declarations in font_stack:
118 | if font_declarations.get('font-size', None):
119 | size = font_declarations.get('font-size')
120 | elif font_declarations.get('font', None):
121 | # Font-size should be the first in a declaration, so we can just use it and split down below.
122 | size = font_declarations.get('font')
123 |
124 | if 'pt' in size:
125 | font_size = int(size.split('pt')[0])
126 | elif 'px' in size:
127 | font_size = int(size.split('px')[0]) * D('0.75') # WCAG claims about 0.75 pt per px
128 | elif '%' in size:
129 | font_size = font_size * D(size.split('%')[0]) / 100
130 | # TODO: em and en
131 | return font_size
132 |
133 |
134 | def is_font_bold(font_stack):
135 | """
136 | From a list of font declarations determine the font weight.
137 | """
138 | # Note: Bolder isn't relative!!
139 | is_bold = False
140 |
141 | for font_declarations in font_stack:
142 | weight = font_declarations.get('font-weight', "")
143 | if 'bold' in weight or 'bold' in font_declarations.get('font', ""):
144 | # Its bold! THe rest of the rules don't matter
145 | return True
146 | elif '0' in weight:
147 | # its a number!
148 | # Return if it is bold. The rest of the rules don't matter
149 | return int(weight) > 500 # TODO: Whats the threshold for 'bold'??
150 | # TODO: What if weight is defined in the 'font' rule?
151 |
152 | return is_bold
153 |
154 |
155 | def calculate_luminocity_ratio(foreground, background):
156 | L2, L1 = sorted([
157 | calculate_luminocity(*foreground),
158 | calculate_luminocity(*background),
159 | ])
160 |
161 | return (L1 + D('0.05')) / (L2 + D('0.05'))
162 |
163 |
164 | class Molerat(WCAGCommand):
165 | """
166 | Molerat checks color contrast in a HTML string against the WCAG2.0 standard
167 |
168 | It checks foreground colors against background colors taking into account
169 | opacity values and font-size to conform to WCAG2.0 Guidelines 1.4.3 & 1.4.6.
170 |
171 | However, it *doesn't* check contrast between foreground colors and background images.
172 |
173 | Paradoxically:
174 |
175 | a failed molerat check doesn't mean your page doesn't conform to WCAG2.0
176 |
177 | but a successful molerat check doesn't mean your page will conform either...
178 |
179 | Command line tools aren't a replacement for good user testing!
180 | """
181 |
182 | animal = """
183 | The naked mole rat, (or sand puppy) is a burrowing rodent.
184 | The species is native to parts of East Africa. It is one of only two known eusocial mammals.
185 |
186 | The animal has unusual features, adapted to its harsh underground environment.
187 | The animals do not feel pain in their skin. They also have a very low metabolism.
188 |
189 | - https://simple.wikipedia.org/wiki/Naked_mole_rat
190 | """
191 |
192 | xpath = '/html/body//*[text()!=""]'
193 |
194 | error_codes = {
195 | 'molerat-1': u"Insufficient contrast ({r:.2f}) for text at element - {xpath}",
196 | 'molerat-2': u"Insufficient contrast ({r:.2f}) for large text element at element- {xpath}"
197 | }
198 |
199 | def skip_element(self, node):
200 |
201 | if node.text is None or node.text.strip() == "":
202 | return True
203 | if node.tag in ['script', 'style']:
204 | return True
205 |
206 | def validate_element(self, node):
207 |
208 | # set some sensible defaults that we can recognise while debugging.
209 | colors = [[1, 2, 3, 1]] # Black-ish
210 | backgrounds = [[254, 253, 252, 1]] # White-ish
211 | fonts = [{'font-size': '10pt', 'font-weight': 'normal'}]
212 |
213 | for styles in get_applicable_styles(node):
214 | if "color" in styles.keys():
215 | colors.append(normalise_color(styles['color']))
216 | if "background-color" in styles.keys():
217 | backgrounds.append(normalise_color(styles['background-color']))
218 | font_rules = {}
219 | for rule in styles.keys():
220 | if 'font' in rule:
221 | font_rules[rule] = styles[rule]
222 | fonts.append(font_rules)
223 |
224 | font_size = calculate_font_size(fonts)
225 | font_is_bold = is_font_bold(fonts)
226 | foreground = generate_opaque_color(colors)
227 | background = generate_opaque_color(backgrounds)
228 | ratio = calculate_luminocity_ratio(foreground, background)
229 |
230 | font_size_type = 'normal'
231 | error_code = 'molerat-1'
232 | technique = "G18"
233 | if font_size >= 18 or font_size >= 14 and font_is_bold:
234 | font_size_type = 'large'
235 | error_code = 'molerat-2'
236 |
237 | ratio_threshold = WCAG_LUMINOCITY_RATIO_THRESHOLD[self.level][font_size_type]
238 | technique = TECHNIQUE[self.level][font_size_type]
239 |
240 | if ratio < ratio_threshold:
241 | disp_text = nice_console_text(node.text)
242 | message = (
243 | self.error_codes[error_code] +
244 | u"\n Computed rgb values are == Foreground {fg} / Background {bg}"
245 | u"\n Text was: {text}"
246 | u"\n Colored text was: {color_text}"
247 | u"\n Computed font-size was: {font_size} {bold} ({font_size_type})"
248 | ).format(
249 | xpath=node.getroottree().getpath(node),
250 | text=disp_text,
251 | fg=foreground,
252 | bg=background,
253 | r=ratio,
254 | font_size=font_size,
255 | bold=['normal', 'bold'][font_is_bold],
256 | font_size_type=font_size_type,
257 | color_text=colorize(
258 | disp_text,
259 | rgb=int('0x%s' % webcolors.rgb_to_hex(foreground)[1:], 16),
260 | bg=int('0x%s' % webcolors.rgb_to_hex(background)[1:], 16),
261 | )
262 | )
263 |
264 | if self.kwargs.get('verbosity', 1) > 2:
265 | if ratio < WCAG_LUMINOCITY_RATIO_THRESHOLD.get(self.level).get('normal'):
266 | message += u"\n Hint: Increase the contrast of this text to fix this error"
267 | elif font_size_type is 'normal':
268 | message += u"\n Hint: Increase the contrast, size or font-weight of the text to fix this error"
269 | elif font_is_bold:
270 | message += u"\n Hint: Increase the contrast or size of the text to fix this error"
271 | elif font_size_type is 'large':
272 | message += u"\n Hint: Increase the contrast or font-weight of the text to fix this error"
273 |
274 | self.add_failure(
275 | guideline='1.4.3',
276 | technique=technique,
277 | node=node,
278 | message=message,
279 | error_code=error_code
280 | )
281 | else:
282 | # I like what you got!
283 | self.add_success(
284 | guideline='1.4.3',
285 | technique=technique,
286 | node=node
287 | )
288 |
289 |
290 | if __name__ == "__main__":
291 | cli = Molerat.as_cli()
292 | cli()
293 |
--------------------------------------------------------------------------------
/wcag_zoo/utils.py:
--------------------------------------------------------------------------------
1 | from __future__ import print_function
2 | from lxml import etree
3 | import click
4 | import os
5 | import sys
6 | from io import BytesIO
7 | import logging
8 | from premailer import Premailer
9 | from premailer.premailer import _cache_parse_css_string
10 |
11 | # From Premailer
12 | import cssutils
13 | import re
14 | cssutils.log.setLevel(logging.CRITICAL)
15 | _element_selector_regex = re.compile(r'(^|\s)\w')
16 | FILTER_PSEUDOSELECTORS = [':last-child', ':first-child', ':nth-child', ":focus"]
17 |
18 |
19 | class Premoler(Premailer):
20 | def __init__(self, *args, **kwargs):
21 | self.media_rules = kwargs.pop('media_rules', [])
22 | super().__init__(*args, **kwargs)
23 |
24 | # We have to override this because an absolute path is from root, not the curent dir.
25 | def _load_external(self, url):
26 | """loads an external stylesheet from a remote url or local path
27 | """
28 | import codecs
29 | from premailer.premailer import ExternalNotFoundError, urljoin
30 | if url.startswith('//'):
31 | # then we have to rely on the base_url
32 | if self.base_url and 'https://' in self.base_url:
33 | url = 'https:' + url
34 | else:
35 | url = 'http:' + url
36 |
37 | if url.startswith('http://') or url.startswith('https://'):
38 | css_body = self._load_external_url(url)
39 | else:
40 | stylefile = url
41 | if not os.path.isabs(stylefile):
42 | stylefile = os.path.abspath(
43 | os.path.join(self.base_path or '', stylefile)
44 | )
45 | elif os.path.isabs(stylefile): # <--- This is the if branch we added
46 | stylefile = os.path.abspath(
47 | os.path.join(self.base_path or '', stylefile[1:])
48 | )
49 | if os.path.exists(stylefile):
50 | with codecs.open(stylefile, encoding='utf-8') as f:
51 | css_body = f.read()
52 | elif self.base_url:
53 | url = urljoin(self.base_url, url)
54 | return self._load_external(url)
55 | else:
56 | raise ExternalNotFoundError(stylefile)
57 |
58 | return css_body
59 |
60 | def _parse_css_string(self, css_body, validate=True):
61 | # We override this so we can do our rules altering for media queries
62 | if self.cache_css_parsing:
63 | sheet = _cache_parse_css_string(css_body, validate=validate)
64 | else:
65 | sheet = cssutils.parseString(css_body, validate=validate)
66 |
67 | _rules = []
68 | for rule in sheet:
69 | if rule.type == rule.MEDIA_RULE:
70 | if any([media in rule.media.mediaText for media in self.media_rules]):
71 | for r in rule:
72 | _rules.append(r)
73 | elif rule.type == rule.STYLE_RULE:
74 | _rules.append(rule)
75 |
76 | return _rules
77 |
78 |
79 | def print_if(*args, **kwargs):
80 | check = kwargs.pop('check', False)
81 | if check and len(args) > 0 and args[0]:
82 | # Only print if there is something to print
83 | print(*args, **kwargs)
84 |
85 |
86 | def nice_console_text(text):
87 | text = text.strip().replace('\n', ' ').replace('\r', ' ').replace('\t', ' ')
88 | if len(text) > 70:
89 | text = text[:70] + "..."
90 | return text
91 |
92 |
93 | def get_applicable_styles(node):
94 | """
95 | Generates a list of dictionaries that contains all the styles that *could* influence the style of an element.
96 |
97 | This is the collection of all styles from an element and all it parent elements.
98 |
99 | Returns a list, with each list item being a dictionary with keys that correspond to CSS styles
100 | and the values are the corresponding values for each ancestor element.
101 | """
102 | styles = []
103 | for parent in node.xpath('ancestor-or-self::*[@style]'):
104 | style = parent.get('style', "")
105 | style = style.rstrip(";")
106 |
107 | if not style:
108 | continue
109 |
110 | styles.append(
111 | dict([
112 | tuple(
113 | s.strip().split(':', 1)
114 | )
115 | for s in style.split(';')
116 | ])
117 | )
118 | return styles
119 |
120 |
121 | def build_msg(node, **kwargs):
122 | """
123 | Assistance method that builds a dictionary error message with appropriate
124 | references to the node
125 | """
126 | error_dict = kwargs
127 | error_dict.update({
128 | 'xpath': node.getroottree().getpath(node),
129 | 'classes': node.get('class'),
130 | 'id': node.get('id'),
131 | })
132 | return error_dict
133 |
134 |
135 | def get_wcag_class(command):
136 | from importlib import import_module
137 | module = import_module("wcag_zoo.validators.%s" % command.lower())
138 | klass = getattr(module, command.title())
139 | return klass
140 |
141 |
142 | class WCAGCommand(object):
143 | """
144 | The base class for all WCAG validation commands
145 | """
146 | animal = None
147 | level = 'AA'
148 | premolar_kwargs = {}
149 |
150 | def __init__(self, *args, **kwargs):
151 | self.skip_these_classes = kwargs.get('skip_these_classes', [])
152 | self.skip_these_ids = kwargs.get('skip_these_ids', [])
153 | self.level = kwargs.get('level', "AA")
154 | self.kwargs = kwargs
155 |
156 | self.success = {}
157 | self.failures = {}
158 | self.warnings = {}
159 | self.skipped = {}
160 |
161 | def add_success(self, **kwargs):
162 | self.add_to_dict(self.success, **kwargs)
163 |
164 | def add_to_dict(self, _dict, **kwargs):
165 | guideline = kwargs['guideline']
166 | technique = kwargs['technique']
167 | g = _dict.get(guideline, {})
168 | g[technique] = g.get(technique, [])
169 | g[technique].append(build_msg(**kwargs))
170 | _dict[guideline] = g
171 |
172 | def add_failure(self, **kwargs):
173 | self.add_to_dict(self.failures, **kwargs)
174 |
175 | def add_warning(self, **kwargs):
176 | self.add_to_dict(self.warnings, **kwargs)
177 | # self.warnings.append(build_msg(**kwargs))
178 |
179 | def add_skipped(self, **kwargs):
180 | self.add_to_dict(self.skipped, **kwargs)
181 | # self.skipped.append(build_msg(**kwargs))
182 |
183 | def skip_element(self, node):
184 | """
185 | Method for adding extra checks to determine if an HTML element should be skipped by the validation loop.
186 |
187 | Override this to add custom skip logic to a wcag command.
188 |
189 | Return true to skip validation of the given node.
190 | """
191 | return False
192 |
193 | def check_skip_element(self, node):
194 | """
195 | Performs checking to see if an element can be skipped for validation, including check if it has an id or class to skip,
196 | or if it has a CSS rule to hide it.
197 |
198 | THis class calls ``WCAGCommand.skip_element`` to get any additional skip logic, override ``skip_element`` not this method to
199 | add custom skip logic.
200 |
201 | Returns True if the node is to be skipped.
202 | """
203 | skip_node = False
204 | skip_message = []
205 | for cc in node.get('class', "").split(' '):
206 | if cc in self.skip_these_classes:
207 | skip_message.append("Skipped [%s] because node matches class [%s]\n Text was: [%s]" % (self.tree.getpath(node), cc, node.text))
208 | skip_node = True
209 | if node.get('id', None) in self.skip_these_ids:
210 | skip_message.append("Skipped [%s] because node id is [%s]\n Text was: [%s]" % (self.tree.getpath(node), node.get('id'), node.text))
211 | skip_node = True
212 | if self.skip_element(node):
213 | skip_node = True
214 |
215 | for styles in get_applicable_styles(node):
216 | # skip hidden elements
217 | if self.kwargs.get('ignore_hidden', False):
218 | if "display" in styles.keys() and styles['display'].lower() == 'none':
219 | skip_message.append(
220 | "Skipped [%s] because display is none is [%s]\n Text was: [%s]" % (self.tree.getpath(node), node.get('id'), node.text)
221 | )
222 | skip_node = True
223 | if "visibility" in styles.keys() and styles['visibility'].lower() == 'hidden':
224 | skip_message.append(
225 | "Skipped [%s] because visibility is hidden is [%s]\n Text was: [%s]" % (self.tree.getpath(node), node.get('id'), node.text)
226 | )
227 | skip_node = True
228 |
229 | if skip_node:
230 | self.add_skipped(
231 | node=node,
232 | message="\n ".join(skip_message),
233 | guideline='skipped',
234 | technique='skipped',
235 | )
236 | return skip_node
237 |
238 | def validate_document(self, html):
239 | """
240 | Main validation method - validates an entire document, single node from a HTML tree.
241 |
242 | **Note**: This checks the validitity of the whole document
243 | and executes the validation loop.
244 |
245 | By default, returns a dictionary with the number of successful checks,
246 | and a list of failures, warnings and skipped elements.
247 | """
248 | self.tree = self.get_tree(html)
249 | self.validate_whole_document(html)
250 | self.run_validation_loop()
251 |
252 | return {
253 | "success": self.success,
254 | "failures": self.failures,
255 | "warnings": self.warnings,
256 | "skipped": self.skipped
257 | }
258 |
259 | def validate_whole_document(self, html):
260 | """
261 | Validates an entire document from a HTML element tree.
262 | Errors and warnings are attached to the instances ``failures`` and ``warnings``
263 | properties.
264 |
265 | **Note**: This checks the validatity of the whole document, but does not execute the validation loop.
266 |
267 | By default, returns nothing.
268 | """
269 | pass
270 |
271 | def validate_element(self, node):
272 | """
273 | Validate a single node from a HTML element tree. Errors and warnings are attached to the instances ``failures`` and ``warnings``
274 | properties.
275 |
276 | By default, returns nothing.
277 | """
278 | pass
279 |
280 | def get_tree(self, html):
281 | if not hasattr(self, '_tree'):
282 | # Pre-parse
283 | parser = etree.HTMLParser()
284 | html = etree.parse(BytesIO(html), parser).getroot()
285 | kwargs = dict(
286 | exclude_pseudoclasses=True,
287 | method="html",
288 | preserve_internal_links=True,
289 | base_path=self.kwargs.get("staticpath", "."),
290 | include_star_selectors=True,
291 | strip_important=False,
292 | disable_validation=True,
293 | media_rules=self.kwargs.get('media_rules', [])
294 | )
295 | kwargs.update(self.premolar_kwargs)
296 | self._tree = Premoler(
297 | html,
298 | **kwargs
299 | ).transform()
300 | return self._tree
301 |
302 | def run_validation_loop(self, xpath=None, validator=None):
303 | """
304 | Runs validation of elements that match an xpath using the given validation method. By default runs `self.validate_element`
305 | """
306 | if xpath is None:
307 | xpath = self.xpath
308 | for element in self.tree.xpath(xpath):
309 | if self.check_skip_element(element):
310 | continue
311 | if not validator:
312 | self.validate_element(element)
313 | else:
314 | validator(element)
315 |
316 | def validate_file(self, filename):
317 | """
318 | Validates a file given as a string filenames
319 |
320 | By returns a dictionary of results from ``validate_document``.
321 | """
322 | with open(filename) as file:
323 | html = file.read()
324 |
325 | results = self.validate_document(html)
326 | return results
327 |
328 | def validate_files(self, *filenames):
329 | """
330 | Validates the files given as a list of strings of filenames
331 |
332 | By default, returns nothing.
333 | """
334 | pass
335 |
336 | @classmethod
337 | def as_cli(cls):
338 | """
339 | Exposes the WCAG validator as a click-based command line interface tool.
340 | """
341 | @click.command(help=cls.__doc__)
342 | @click.argument('filenames', required=False, nargs=-1, type=click.File('rb'))
343 | @click.option('--level', type=click.Choice(['AA', 'AAA', 'A']), default=None, help='WCAG level to test against. Defaults to AA.')
344 | @click.option('-A', 'short_level', count=True, help='Shortcut for settings WCAG level, repeatable (also -AA, -AAA ')
345 | @click.option('--staticpath', default='.', help='Directory path to static files.')
346 | @click.option('--skip_these_classes', '-C', default=[], multiple=True, type=str, help='Repeatable argument of CSS classes for HTML elements to *not* validate')
347 | @click.option('--skip_these_ids', '-I', default=[], multiple=True, type=str, help='Repeatable argument of ids for HTML elements to *not* validate')
348 | @click.option('--ignore_hidden', '-H', default=False, is_flag=True, help='Validate elements that are hidden by CSS rules')
349 | @click.option('--animal', default=False, is_flag=True, help='')
350 | @click.option('--warnings_as_errors', '-W', default=False, is_flag=True, help='Treat warnings as errors')
351 | @click.option('--verbosity', '-v', type=int, default=1, help='Specify how much text to output during processing')
352 | @click.option('--json', '-J', default=False, is_flag=True, help='Prints a json dump of results, with nested guidelines and techniques, instead of human readable results')
353 | @click.option('--flat_json', '-F', default=False, is_flag=True, help='Prints a json dump of results as a collection of flat lists, instead of human readable results')
354 | @click.option('--media_rules', "-M", multiple=True, type=str, help='Specify a media rule to enforce')
355 | def cli(*args, **kwargs):
356 | total_results = []
357 | filenames = kwargs.pop('filenames')
358 | short_level = kwargs.pop('short_level', 'AA')
359 | kwargs['level'] = kwargs['level'] or 'A' * min(short_level, 3) or 'AA'
360 | verbosity = kwargs.get('verbosity')
361 | json_dump = kwargs.get('json')
362 | flat_json_dump = kwargs.get('flat_json')
363 | warnings_as_errors = kwargs.pop('warnings_as_errors', False)
364 | kwargs['skip_these_classes'] = [c.strip() for c in kwargs.get('skip_these_classes') if c]
365 | kwargs['skip_these_ids'] = [c.strip() for c in kwargs.get('skip_these_ids') if c]
366 | if kwargs.pop('animal', None):
367 | print(cls.animal)
368 | sys.exit(0)
369 | klass = cls(*args, **kwargs)
370 | if len(filenames) == 0:
371 | f = click.get_text_stream('stdin')
372 | filenames = [f]
373 |
374 | if json_dump:
375 | import json
376 | output = []
377 | for file in filenames:
378 | try:
379 | html = file.read()
380 | results = klass.validate_document(html)
381 | except:
382 | raise
383 | results = {'failures': ["Exception thrown"]}
384 | output.append((file.name, results))
385 | total_results.append(results)
386 |
387 | print(json.dumps(output))
388 | elif flat_json_dump:
389 | import json
390 | output = []
391 | for file in filenames:
392 | try:
393 | html = file.read()
394 | results = klass.validate_document(html)
395 | except:
396 | raise
397 | results = {'failures': ["Exception thrown"]}
398 | output.append((
399 | file.name,
400 | {
401 | "failures": make_flat(results.get('failures', {})),
402 | "warnings": make_flat(results.get('warnings', {})),
403 | "skipped": make_flat(results.get('skipped', {})),
404 | "success": make_flat(results.get('success', {}))
405 | }
406 | ))
407 |
408 | print(json.dumps(output))
409 | else:
410 | for f in filenames:
411 | try:
412 | filename = f.name
413 | print_if(
414 | "Starting - {filename} ... ".format(filename=filename), end="",
415 | check=verbosity>0
416 | )
417 | html = f.read()
418 | results = klass.validate_document(html)
419 |
420 | if verbosity == 1:
421 | if len(results['failures']) > 0:
422 | print('\x1b[1;31m' + 'failed' + '\x1b[0m')
423 | else:
424 | print('\x1b[1;32m' + 'ok' + '\x1b[0m')
425 | else:
426 | print()
427 |
428 | failures = make_flat(results.get('failures', {}))
429 | warnings = make_flat(results.get('warnings', {}))
430 | skipped = make_flat(results.get('skipped', {}))
431 | success = make_flat(results.get('success', {}))
432 |
433 | print_if(
434 | "\n".join([
435 | "ERROR - {message}".format(message=r['message'])
436 | for r in failures
437 | ]),
438 | check=verbosity>1
439 | )
440 | print_if(
441 | "\n".join([
442 | "WARNING - {message}".format(message=r['message'])
443 | for r in warnings
444 | ]),
445 | check=verbosity>2
446 | )
447 | print_if(
448 | "\n".join([
449 | "Skipped - {message}".format(message=r['message'])
450 | for r in skipped
451 | ]),
452 | check=verbosity>2
453 | )
454 |
455 | print_if(
456 | "Finished - {filename}".format(filename=filename),
457 | check=verbosity>1
458 | )
459 | print_if(
460 | "\n".join([
461 | " - {num_fail} failed",
462 | " - {num_warn} warnings",
463 | " - {num_good} succeeded",
464 | " - {num_skip} skipped",
465 | ]).format(
466 | num_fail=len(failures),
467 | num_warn=len(warnings),
468 | num_skip=len(skipped),
469 | num_good=len(success)
470 | ),
471 | check=verbosity>1
472 | )
473 | total_results.append(results)
474 | except IOError:
475 | print("Tested at WCAG2.0 %s Level" % kwargs['level'])
476 |
477 | print("Tested at WCAG2.0 %s Level" % kwargs['level'])
478 | print(
479 | "{n_errors} errors, {n_warnings} warnings in {n_files} files".format(
480 | n_errors=sum([len(r['failures']) for r in total_results]),
481 | n_warnings=sum([len(r['warnings']) for r in total_results]),
482 | n_files=len(filenames)
483 | )
484 | )
485 | if sum([len(r['failures']) for r in total_results]):
486 | sys.exit(1)
487 | elif warnings_as_errors and sum([len(r['warnings']) for r in total_results]):
488 | sys.exit(1)
489 | else:
490 | sys.exit(0)
491 |
492 | return cli
493 |
494 |
495 | def make_flat(_dict):
496 | return [
497 | r for guidelines in _dict.values()
498 | for techniques in guidelines.values()
499 | for r in techniques
500 | ]
501 |
--------------------------------------------------------------------------------