├── wcag_zoo ├── validators │ ├── __init__.py │ ├── parade.py │ ├── glowworm.py │ ├── anteater.py │ ├── ayeaye.py │ ├── tarsier.py │ └── molerat.py ├── __init__.py ├── zookeeper.py ├── testrunner.py └── utils.py ├── tests └── html │ ├── static │ └── styles.css │ ├── ayeaye-no-access-keys.html │ ├── ayeaye-bad-access-keys.html │ ├── molerat-color-contrast-mobile-love.html │ ├── tarsier-good-headers.html │ ├── glowworm-focuses.html │ ├── anteater-alt-tags.html │ ├── molerat-color-contrast-mobile-hate.html │ ├── tarsier-bad-headers.html │ ├── molerat-color-contrast.html │ ├── parade-welcome-to-the-parade.html │ ├── parade-sick-anteater.html │ └── molerat-color-contrast-bad-fonts.html ├── requirements.txt ├── docs ├── _static │ └── README.txt ├── api.rst ├── development │ ├── scripts │ │ ├── untested │ │ │ ├── README.txt │ │ │ └── perl_wcag.pl │ │ ├── python_lib_wcag.py │ │ ├── ruby_wcag.rb │ │ ├── python_cli_wcag.py │ │ └── node_wcag.js │ ├── using_wcag_zoo_in_python.rst │ ├── using_wcag_zoo_not_in_python.rst │ └── test-guide.rst ├── includes │ └── indexes.rst ├── _themes │ └── wcaglabaster │ │ ├── __init__.py │ │ ├── globaltoc.html │ │ ├── navigation.html │ │ ├── theme.conf │ │ └── support.py ├── commands.rst ├── disclaimer.rst ├── run_demo_scripts.sh ├── Makefile ├── index.rst ├── make.bat ├── wcag.rst ├── faq.rst └── conf.py ├── setup.cfg ├── CHANGELOG.md ├── appveyor.yml ├── setup.py ├── .gitignore ├── tox.ini ├── LICENSE ├── .travis.yml └── README.rst /wcag_zoo/validators/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /wcag_zoo/__init__.py: -------------------------------------------------------------------------------- 1 | version = '0.2.6' 2 | -------------------------------------------------------------------------------- /tests/html/static/styles.css: -------------------------------------------------------------------------------- 1 | .invisible { 2 | display: none; 3 | } 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | lxml 2 | premailer~=3.1.1 3 | webcolors 4 | click 5 | xtermcolor 6 | -------------------------------------------------------------------------------- /docs/_static/README.txt: -------------------------------------------------------------------------------- 1 | This file ensures the directory is included in git if we ever add static files. -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | API documentation 2 | ================= 3 | 4 | .. automodule:: wcag_zoo.utils 5 | :members: -------------------------------------------------------------------------------- /docs/development/scripts/untested/README.txt: -------------------------------------------------------------------------------- 1 | /!\ BEWARE /!\ 2 | 3 | These files haven't been able to be built into the travis-ci build script. -------------------------------------------------------------------------------- /docs/includes/indexes.rst: -------------------------------------------------------------------------------- 1 | 2 | 3 | Indices and tables 4 | ================== 5 | 6 | * :ref:`genindex` 7 | * :ref:`modindex` 8 | * :ref:`search` 9 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = venv/*,tox/*,docs/*,testproject/* 3 | ignore = E501,E225,E123,E901 4 | max-line-length = 119 5 | 6 | [bdist_wheel] 7 | universal=1 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | - 0.2.5 2 | - Updated Premailer to 3.1.1, improved handling of pseudoclasses 3 | - 0.2.4 4 | - Allowed first header to be H2 #12 5 | - Fixed issue with minimised code not processing properly 6 | - 0.2.3 7 | - Changelog added -------------------------------------------------------------------------------- /tests/html/ayeaye-no-access-keys.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /docs/development/scripts/python_lib_wcag.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from wcag_zoo.validators.tarsier import Tarsier 3 | 4 | my_html = b"

1

This is wrong, it should be h2" 5 | instance = Tarsier() 6 | results = instance.validate_document(my_html) 7 | 8 | print("/no/tmp/dir", len(results['failures']), "failures") 9 | -------------------------------------------------------------------------------- /tests/html/ayeaye-bad-access-keys.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /docs/_themes/wcaglabaster/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | version = "0.0.1" 4 | 5 | 6 | def update_context(app, pagename, templatename, context, doctree): 7 | context['alabaster_version'] = version 8 | 9 | def setup(app): 10 | app.connect('html-page-context', update_context) 11 | return {'version': version.__version__, 12 | 'parallel_read_safe': True} 13 | -------------------------------------------------------------------------------- /docs/commands.rst: -------------------------------------------------------------------------------- 1 | Command list 2 | ============ 3 | 4 | .. autoclass:: wcag_zoo.validators.anteater.Anteater 5 | .. autoclass:: wcag_zoo.validators.ayeaye.Ayeaye 6 | .. autoclass:: wcag_zoo.validators.glowworm.Glowworm 7 | .. autoclass:: wcag_zoo.validators.molerat.Molerat 8 | .. autoclass:: wcag_zoo.validators.parade.Parade 9 | .. autoclass:: wcag_zoo.validators.tarsier.Tarsier 10 | -------------------------------------------------------------------------------- /docs/_themes/wcaglabaster/globaltoc.html: -------------------------------------------------------------------------------- 1 | {# 2 | basic/globaltoc.html 3 | ~~~~~~~~~~~~~~~~~~~~ 4 | 5 | Sphinx sidebar template: global table of contents. 6 | 7 | :copyright: Copyright 2007-2017 by the Sphinx team, see AUTHORS. 8 | :license: BSD, see LICENSE for details. 9 | #} 10 |

{{ _('Table Of Contents') }}

11 | {{ toctree() }} -------------------------------------------------------------------------------- /docs/_themes/wcaglabaster/navigation.html: -------------------------------------------------------------------------------- 1 |

{{ _('Navigation') }}

2 | {{ toctree(includehidden=theme_sidebar_includehidden, collapse=theme_sidebar_collapse) }} 3 | {% if theme_extra_nav_links %} 4 |
5 | 10 | {% endif %} 11 | -------------------------------------------------------------------------------- /docs/disclaimer.rst: -------------------------------------------------------------------------------- 1 | Disclaimer 2 | ========== 3 | 4 | WCAG-Zoo does not *gaurantee* complete compliance with the Web Content Accessibility Guidelines. 5 | There are many aspects of the WCAG recommendations that are subjective and are difficult 6 | or even impossible to verify automatically. 7 | 8 | Where WCAG compliance is a contractual or legislative requirement, always speak 9 | to an accesibility expert. 10 | -------------------------------------------------------------------------------- /docs/development/scripts/ruby_wcag.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'json' 3 | require 'tempfile' 4 | 5 | my_html = "

1

This is wrong, it should be h2" 6 | 7 | tmp_file = Tempfile.new('foo') 8 | tmp_file.write(my_html) 9 | tmp_file.close 10 | 11 | results = `zookeeper tarsier #{tmp_file.path} -F` 12 | json_results = JSON.parse(results) 13 | print json_results[0][0], " ", json_results[0][1]['failures'].size, " failures\n" 14 | -------------------------------------------------------------------------------- /docs/development/using_wcag_zoo_in_python.rst: -------------------------------------------------------------------------------- 1 | Using WCAG-Zoo validators in python 2 | =================================== 3 | 4 | As WCAG-Zoo is a native Python 3 library, it can be imported and used in Python 3 scripts easily. 5 | 6 | Once installed from ``pip`` (``pip install wcag_zoo``), load any of the validators 7 | and call ``validate_document`` on an instance as shown in the example below. 8 | 9 | 10 | .. literalinclude:: scripts/python_lib_wcag.py 11 | :language: python 12 | -------------------------------------------------------------------------------- /docs/development/scripts/untested/perl_wcag.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env perl 2 | require File::Temp; 3 | use File::Temp (); 4 | use File::Temp qw/ :seekable /; 5 | use JSON; 6 | 7 | $my_html = "

This is wrong, it should be h1"; 8 | 9 | $tmp = File::Temp->new(); 10 | print $tmp $my_html; 11 | $tmp->seek( 0, SEEK_END ); 12 | 13 | $fn = $tmp->filename; 14 | 15 | $results = `zookeeper tarsier $fn -J`; 16 | @json_results = decode_json($results); 17 | $filename = @{@{@json_results[0]}[0]}[0]; 18 | $len = scalar @{@{@{@{@json_results[0]}[0]}[1]}{'failures'}}; 19 | print "$filename $len failures\n"; 20 | -------------------------------------------------------------------------------- /docs/run_demo_scripts.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # install requirements for scripts 4 | npm install temp 5 | 6 | # Python 3 stuff 7 | export LC_ALL=C.UTF-8 8 | export LANG=C.UTF-8 9 | 10 | let errored_builds=0 11 | for SCRIPT in ./docs/development/scripts/* 12 | do 13 | if [ -f $SCRIPT -a -x $SCRIPT ] 14 | then 15 | echo -n $SCRIPT " ... " 16 | OUT=`$SCRIPT` 17 | if [[ $OUT == *"1 failures"* ]] 18 | then 19 | echo "good" 20 | else 21 | let errored_builds=$((errored_builds+1)) 22 | echo "bad" 23 | echo $OUT 24 | fi 25 | fi 26 | done 27 | 28 | exit $errored_builds 29 | -------------------------------------------------------------------------------- /docs/development/scripts/python_cli_wcag.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2 2 | from __future__ import print_function 3 | import json 4 | import tempfile 5 | import subprocess 6 | 7 | my_html = "

1

This is wrong, it should be h2" 8 | 9 | tmp_file = tempfile.NamedTemporaryFile() 10 | tmp_file.write(my_html) 11 | tmp_file.seek(0) 12 | 13 | process = subprocess.Popen( 14 | ["zookeeper", "tarsier", tmp_file.name, "-F"], 15 | stdout=subprocess.PIPE 16 | ) 17 | 18 | results = process.communicate()[0] 19 | json_results = json.loads(results) 20 | 21 | print(json_results[0][0], 22 | len(json_results[0][1]['failures']), 23 | "failures" 24 | ) 25 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | # 2 | # Some help for Python on AppVeyor: 3 | # * https://packaging.python.org/appveyor/ 4 | # * https://ci.appveyor.com/tools/validate-yaml 5 | 6 | build: false 7 | 8 | environment: 9 | matrix: 10 | - PYTHON_HOME: "C:/Python35" 11 | - PYTHON_HOME: "C:/Python35-x64" 12 | - PYTHON_HOME: "C:/Python36" 13 | - PYTHON_HOME: "C:/Python36-x64" 14 | install: 15 | - cmd: | 16 | set PATH=%PYTHON_HOME%;%PYTHON_HOME%\Scripts;%PATH% 17 | python -m pip install --upgrade pip 18 | pip install tox 19 | pip install . 20 | 21 | # command to run tests 22 | test_script: 23 | - cmd: tox -e zoo-windows --skip-missing-interpreters 24 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = WCAG-Zoo 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. WCAG-Zoo documentation master file, created by 2 | sphinx-quickstart on Tue Dec 27 15:43:50 2016. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | WCAG Zoo - Scripts for accessbility checking in integrated tests! 7 | ================================================================= 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | :caption: Contents: 12 | 13 | api.rst 14 | commands.rst 15 | faq.rst 16 | development/test-guide.rst 17 | development/using_wcag_zoo_not_in_python.rst 18 | development/using_wcag_zoo_in_python.rst 19 | wcag.rst 20 | disclaimer.rst 21 | 22 | .. include:: ../README.rst 23 | :start-after: rtd-inclusion-marker 24 | 25 | .. include:: includes/indexes.rst 26 | -------------------------------------------------------------------------------- /tests/html/molerat-color-contrast-mobile-love.html: -------------------------------------------------------------------------------- 1 | 3 | 4 | 15 | 16 | 17 |
18 | I hate mobile phones, but if you are 19 | reading this page on a mobile you'll never know!! 20 |
21 |

22 | Welcome to my page! 23 |

24 | 25 | 26 | -------------------------------------------------------------------------------- /tests/html/tarsier-good-headers.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

Heading 1

4 |

Heading 2

5 |

Heading 3

6 |

Heading 1

7 |

Heading 1

8 |

Heading 2

9 |

Heading 2

10 |

Heading 1

11 |

Heading 2

12 |

Heading 2

13 |

Heading 3

14 |

Heading 1

15 |

Heading 2

16 |

Heading 3

17 |

Heading 3

18 |
Heading 5
19 |
Heading 6
20 |

Heading 3

21 |

Heading 2

22 | 23 | 24 | -------------------------------------------------------------------------------- /docs/development/scripts/node_wcag.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | var temp = require('temp'), 3 | fs = require('fs'), 4 | exec = require('child_process').exec; 5 | 6 | temp.open('wcag', function(err, info) { 7 | if (!err) { 8 | fs.write( 9 | info.fd, 10 | "

Heading 1

This is wrong, it should be h2", 11 | function(err){ 12 | /* Ignore, we don't care */ 13 | } 14 | ); 15 | fs.close(info.fd, function(err) { 16 | exec("zookeeper tarsier '" + info.path + "' -F", 17 | function(err, stdout) { 18 | results = stdout; 19 | json_results = JSON.parse(results); 20 | console.log( 21 | json_results[0][0], 22 | json_results[0][1].failures.length, 23 | "failures" 24 | ); 25 | } 26 | ); 27 | }); 28 | } 29 | }); 30 | -------------------------------------------------------------------------------- /tests/html/glowworm-focuses.html: -------------------------------------------------------------------------------- 1 | 5 | 6 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=WCAG-Zoo 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 20 | echo.installed, then set the SPHINXBUILD environment variable to point 21 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 22 | echo.may add the Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /tests/html/anteater-alt-tags.html: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | This is terrible, this bunny has no alt tag. How will people know how cute it is? 11 | This image is purely decorative, so a blank alt tag is needed. 12 | This puppy is so gosh darn cute I needed people to know 13 | This bat is hiding, it won't be seen 14 | This birdy isn't even worth looking at 15 | 16 | -------------------------------------------------------------------------------- /wcag_zoo/zookeeper.py: -------------------------------------------------------------------------------- 1 | import click 2 | import os 3 | from wcag_zoo.utils import get_wcag_class 4 | 5 | 6 | class Zookeeper(click.MultiCommand): 7 | 8 | def list_commands(self, ctx): 9 | rv = [] 10 | for filename in os.listdir(os.path.join(os.path.dirname(__file__), 'validators')): 11 | if ( 12 | filename.endswith('.py') and 13 | not filename.startswith('_') and 14 | not filename.startswith('.') 15 | ): 16 | rv.append(filename[:-3]) 17 | rv.sort() 18 | return rv 19 | 20 | def get_command(self, ctx, name): 21 | cmd = get_wcag_class(name) 22 | return cmd.as_cli() 23 | 24 | 25 | @click.command(cls=Zookeeper) 26 | def zookeeper(): 27 | """ 28 | Zookeeper collates all of the WCAG-Zoo commands into a single command line tool to limit 29 | collision with other commands. 30 | """ 31 | pass 32 | 33 | 34 | if __name__ == "__main__": 35 | zookeeper() 36 | -------------------------------------------------------------------------------- /tests/html/molerat-color-contrast-mobile-hate.html: -------------------------------------------------------------------------------- 1 | 6 | 7 | 18 | 19 | 20 |
21 | I hate mobile phones, but if you are 22 | reading this page on a mobile you'll never know!! 23 |
24 |
25 | And I'll make sure you never see this either! 26 | And the zookeeper will never tell! 27 |
28 |

29 | Welcome to my page! 30 |

31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /tests/html/tarsier-bad-headers.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

Heading 2

4 |

Heading 1

5 |

Heading 1

6 |

Heading 2

7 |

Heading 2

8 |

This heading needs a H3 first

9 |
This heading needs a H5 first!
10 |

Heading 1

11 |

Heading 2

12 |

Heading 2

13 |

Heading 3

14 |

Heading 1

15 |

Heading 2

16 |

Heading 3

17 |

Heading 4

18 |
Heading 5
19 |
Heading 6
20 |

Heading 4

21 |
This heading needs a H5 first!
22 |

Heading 2

23 | 24 | 25 | -------------------------------------------------------------------------------- /tests/html/molerat-color-contrast.html: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 20 | 21 | 22 |

Tom Haverford

23 | 26 |

27 | This is hard to read. 28 | 29 | This is better! 30 | 31 |

32 | 33 | 34 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup, find_packages 3 | 4 | import wcag_zoo 5 | VERSION = wcag_zoo.version 6 | 7 | with open(os.path.join(os.path.dirname(__file__), 'README.rst')) as readme: 8 | README = readme.read() 9 | 10 | # allow setup.py to be run from any path 11 | os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) 12 | 13 | setup( 14 | name='wcag-zoo', 15 | version=VERSION, 16 | packages=find_packages(), 17 | include_package_data=True, 18 | license='', 19 | description='', 20 | long_description=README, 21 | url='https://github.com/data61/wcag-zoo', 22 | author='Samuel Spencer', 23 | author_email='sam@aristotlemetadata.com', 24 | classifiers=[ 25 | 'Development Status :: 5 - Production/Stable', 26 | 27 | 'Environment :: Web Environment', 28 | 'Intended Audience :: Information Technology', 29 | 'Intended Audience :: Science/Research', 30 | 31 | 'Operating System :: OS Independent', 32 | 'Programming Language :: Python :: 3 :: Only', 33 | ], 34 | entry_points={ 35 | 'console_scripts': [ 36 | 'zookeeper = wcag_zoo.zookeeper:zookeeper', 37 | ], 38 | }, 39 | install_requires=[ 40 | "lxml", 41 | "premailer", 42 | "webcolors", 43 | "click", 44 | "xtermcolor", 45 | ], 46 | 47 | ) 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | -------------------------------------------------------------------------------- /tests/html/parade-welcome-to-the-parade.html: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 20 | 21 | 22 |

Header 1

23 |

Bad header!

24 |

Tom Haverford

25 | 28 |

29 | This is hard to read. 30 | 31 | This is better! 32 | 33 |

34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /tests/html/parade-sick-anteater.html: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 21 | 22 | 23 |

Header 1

24 |

Bad header!

25 |

Tom Haverford

26 | 29 |

30 | This is hard to read. 31 | 32 | This is better! 33 | 34 |

35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | skipsdist = True 3 | envlist = 4 | {py35,py36}-zoo-{windows,linux} 5 | flake8 6 | scripts-linux 7 | docs-linux 8 | dogfood 9 | 10 | [testenv:docs-linux] 11 | changedir= docs 12 | commands= 13 | sphinx-build -nW -b html -d {envtmpdir}/doctrees . {envtmpdir}/html 14 | 15 | [testenv:dogfood] 16 | deps = 17 | -r{toxinidir}/requirements.txt 18 | . 19 | sphinx 20 | commands= 21 | sphinx-build -nW -b html -d {envtmpdir}/doctrees docs/. {envtmpdir}/html 22 | /bin/bash -c 'LC_ALL=C.UTF-8 LANG=C.UTF-8 zookeeper parade {envtmpdir}/html/*.html --staticpath {envtmpdir}/html/ -AA -v1 -C headerlink' 23 | /bin/bash -c 'LC_ALL=C.UTF-8 LANG=C.UTF-8 zookeeper parade {envtmpdir}/html/development/*.html --staticpath {envtmpdir}/html/development/ -AA -v1 -C headerlink' 24 | 25 | [testenv] 26 | setenv = 27 | PYTHONPATH = {toxinidir}:{toxinidir} 28 | platform = 29 | windows: win32 30 | linux: linux 31 | passenv = 32 | LC_ALL 33 | LANG 34 | deps = 35 | -r{toxinidir}/requirements.txt 36 | . 37 | docs: sphinx 38 | flake8: flake8>=2.0,<3.0 39 | windows: pypiwin32 40 | zoo: coverage 41 | commands = 42 | flake8: flake8 43 | scripts: {toxinidir}/docs/run_demo_scripts.sh 44 | ; scripts: zookeeper 45 | ; scripts: /bin/bash -c 'node {toxinidir}/docs/development/scripts/node_wcag.js' 46 | zoo-linux: coverage run --branch --parallel-mode --source=wcag_zoo wcag_zoo/testrunner.py {toxinidir}/tests/html/ 47 | zoo-windows: coverage run --branch --parallel-mode --source=wcag_zoo wcag_zoo/testrunner.py ./tests/html/anteater-alt-tags.html 48 | -------------------------------------------------------------------------------- /tests/html/molerat-color-contrast-bad-fonts.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 18 | 19 | 20 |
21 | This is hard to read. 22 |
23 | This is bigger and has better contrast! 24 |
25 |
26 |
27 | > 28 | Even when bolder its still harder to read because its too small. 29 | 30 |
31 |
32 | Here is big text specified in pixels. 33 |

34 | And just a bit bigger. 35 |

36 |
37 |
38 | Here is text specified in pixels with a contrast ratio of 3.6 - this will never pass AAA compliance. 39 |

40 | Doesn't matter how big this is, its still too low contrast. 41 |

42 |
43 | 44 | -------------------------------------------------------------------------------- /docs/development/using_wcag_zoo_not_in_python.rst: -------------------------------------------------------------------------------- 1 | Using WCAG-Zoo in other languages 2 | ================================= 3 | 4 | Below are a small number of example scripts that show how to call the WCAG-Zoo scripts 5 | from a number of target languages to provide runtime support for accessibility checking. 6 | 7 | All of the following snippets will either: 8 | 9 | * Store a specified string ``my_html`` as the temporary file accessed by the variable ``tmp_file`` or 10 | * Pass a specified string ``my_html`` into the command via stdin 11 | 12 | Then: 13 | 14 | 1. Execute the WCAG command ``wcag_zoo.validators.tarsier`` using Python and store the result as ``results`` 15 | 2. Capture the ``results`` string and parse it from JSON into the variable ``json_results`` 16 | 3. Prints the number of failures for the file 17 | 18 | All of the following scripts are public domain samples and not guaranteed to work in production in any way. 19 | All scripts should output something similar to ``/tmp/wcag117015-32930-onps7o 1 failures`` 20 | 21 | 22 | Node.JS 23 | ------- 24 | File based: 25 | 26 | Assuming you have `temp `_ installed using ``npm install temp``: 27 | 28 | .. literalinclude:: scripts/node_wcag.js 29 | :language: javascript 30 | 31 | 32 | 33 | Perl 34 | ---- 35 | 36 | File based: 37 | 38 | .. literalinclude:: scripts/untested/perl_wcag.pl 39 | :language: perl 40 | 41 | 42 | Python 43 | ------ 44 | Included for reference, but WCAG-Zoo can be `used in Python by importing 45 | validators directly `__. 46 | 47 | File based: 48 | 49 | .. literalinclude:: scripts/python_cli_wcag.py 50 | :language: python 51 | 52 | Ruby 53 | ---- 54 | Assuming you have installed ``json`` like so: ``gem install json`` 55 | 56 | File based: 57 | 58 | .. literalinclude:: scripts/ruby_wcag.rb 59 | :language: ruby 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD License 2 | 3 | Copyright (c) 2016 Samuel Spencer 4 | 5 | Redistribution and use in source and binary forms, with or without modification, 6 | are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its contributors 16 | may be used to endorse or promote products derived from this software without 17 | specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 20 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 21 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 23 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 24 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 25 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 26 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 28 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | 30 | As a condition of this licence, you agree that where you make any adaptations, 31 | modifications, further developments, or additional features available to CSIRO 32 | or the public in connection with your access to the Software, you do so on the 33 | terms of the BSD 3-Clause Licence template, a copy available at: 34 | http://opensource.org/licenses/BSD-3-Clause. 35 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - '3.5' 4 | - '3.6' 5 | install: 6 | - pip install tox 7 | - pip install travis-tox 8 | - pip install -e . 9 | script: 10 | - pip uninstall setuptools -y && pip install 'setuptools>34.0' --ignore-installed # needed for updated version of html5lib 11 | - tox -e zoo-linux --skip-missing-interpreters 12 | - tox -e scripts-linux --skip-missing-interpreters 13 | after_success: 14 | - coverage report 15 | - coveralls 16 | 17 | jobs: 18 | include: 19 | - stage: stylecheck 20 | python: 3.5 21 | script: tox -e flake8 --skip-missing-interpreters 22 | 23 | - stage: documentation 24 | python: 3.5 25 | script: tox -e docs --skip-missing-interpreters 26 | 27 | - stage: documentation-accessibility 28 | python: 3.5 29 | script: tox -e dogfood --skip-missing-interpreters 30 | 31 | - stage: deploy 32 | script: skip 33 | deploy: 34 | provider: pypi 35 | user: Samuel.Spencer 36 | skip_upload_docs: true 37 | password: 38 | secure: hJVN74vhyDjG22Ei5/gz0g0d/KfaW2kzjx85c3pT+4jmbTOC3OcGN7Exj+/2wEAygvQDmlijRGJ0yldL3IpE0+N7TASs0/6dNf8fMoG0zRTcuu1ywhUhSgqntRGMjRBpbXeVUeb6ydeAciDkrkmfyuGVQd6bXJQeCxcdU1X5iJ+mnbS9Lu1oUfXt6MZroMImwh+ZCvDn95e8QMFbSmO/3OE7CBtBiCaUMemlgzf1THJsw0JoO/GaU37NEjmsnt6xg8EcXRi/h+Pznnr+8LEs6gqcNRg8uS0mh3e6B3ynvovY6Pr8YdAazG6FTFezc3eQWjViQyBxHGbCcY1z0w7bATa+BT18k2/I6dfF6MFoitPtTmJ9tQEB1HOdL/ngJoYEa2TQxxsCal4qqCWNEgP6O5Jl7uK4u1y00wXQ1M3scGmP1kxu+4DWY9ywfnBvbF8SNKLd4qEkdxQIkZBYd7/4aU/3pHFVE1f+J7LSzLt8gIy/hnLCB23T/DEaLnQJDIBlP4Kcpb277vkayVjuRuGNfU5QXf29Sj4PKvXbVS9gtYDY8U5KheOhb7MmWTlq+WfGvyrpgLfasFQDjWGOqs4oJ4LDO4IJ6BG40LX/OozTXoiT1//DcwDU34SJQlfTiTidZMeOq1fdpQNG84Tig2MkuRnmvO8LF5uIS0I6G3GWP6U= 39 | on: 40 | branch: master 41 | distributions: sdist bdist_wheel 42 | repo: Aristotle-Metadata-Enterprises/wcag-zoo 43 | condition: $TRAVIS_PYTHON_VERSION = '3.5' # Only release on one thread 44 | -------------------------------------------------------------------------------- /wcag_zoo/validators/parade.py: -------------------------------------------------------------------------------- 1 | import os 2 | import click 3 | from wcag_zoo.utils import WCAGCommand, get_wcag_class 4 | 5 | 6 | class Parade(WCAGCommand): 7 | """ 8 | Run a number of validators together across a file or collection of files in a single command. 9 | """ 10 | 11 | def __init__(self, *args, **kwargs): 12 | self.exclude_validators = list(kwargs.pop('exclude_validators', [])) 13 | super(Parade, self).__init__(*args, **kwargs) 14 | 15 | def validate_document(self, html): 16 | self.tree = self.get_tree(html) 17 | rv = sorted([ 18 | filename[:-3] 19 | for filename in os.listdir(os.path.dirname(__file__)) 20 | if ( 21 | filename.endswith('.py') and 22 | not filename.startswith('_') and 23 | not filename.startswith('.') and 24 | filename[:-3] not in ['parade'] + self.exclude_validators 25 | ) 26 | ]) 27 | 28 | total_results = { 29 | "success": self.success, 30 | "failures": self.failures, 31 | "warnings": self.warnings, 32 | "skipped": self.skipped 33 | } 34 | 35 | for validator_name in rv: 36 | cmd = get_wcag_class(validator_name) 37 | instance = cmd(**self.kwargs) 38 | instance._tree = self.tree 39 | results = instance.validate_document(html) 40 | for k, v in results.items(): 41 | total_results[k].update(v) 42 | return total_results 43 | 44 | @classmethod 45 | def as_cli(cls): 46 | """ 47 | Exposes the WCAG validator as a click-based command line interface tool. 48 | """ 49 | return click.option( 50 | '--exclude_validators', '-E', multiple=True, type=str, help='Repeatable argument to prevent certain validators from being run' 51 | )(super(Parade, cls).as_cli()) 52 | 53 | if __name__ == "__main__": 54 | cli = Parade.as_cli() 55 | cli() 56 | -------------------------------------------------------------------------------- /docs/development/test-guide.rst: -------------------------------------------------------------------------------- 1 | Guide to writing tests for commands 2 | =================================== 3 | 4 | To make writing tests easier, tests can be declaratively written in HTML using ``data-*`` attributes to specify 5 | the command to check a HTML document against, and the expected errors. 6 | 7 | The available attributes are: 8 | 9 | * ``data-wcag-test-command`` (only on the root ``html`` element) - the specific zookeeper 10 | validator to use for the HTML file. 11 | * ``data-wcag-arg-\*`` (only on the root ``html`` element) - attributes starting with 12 | ``data-wcag-arg-`` specify arguments to pass when running the given command. 13 | Each attribute constitutes a key/value pair, with the key 14 | corresponding to everything captured by the asterisk (``*``) above. 15 | All values are evaluated in Python and are expected to be valid Python 16 | literals - as such numbers are treated as numbers, booleans are booleans, and strings need to be 17 | double quoted. 18 | * ``data-wcag-failure-code`` and ``data-wcag-warning-code`` - can be located on any element in the body. 19 | These specify both that a given node should produce a failure or warning when using the above command 20 | and arguments, as well as the expected error code. 21 | 22 | Below is an example that tests the ``molerat`` command, for WCAG 2.0-AA compliance 23 | and checks using a specific media rule to check a page for use on small screens. 24 | 25 | .. literalinclude:: ../../tests/html/molerat-color-contrast-mobile-hate.html 26 | :language: html 27 | 28 | This is equivilent to running the following at the command line:: 29 | 30 | zookeeper molerat the_file_above.html \ 31 | --level=AA \ 32 | --media_rules='max-width: 600px' \ 33 | --skip_these_classes=sneaky 34 | 35 | or the following python command:: 36 | 37 | from wcag_zoo.validators.molerat import Molerat 38 | 39 | validator = Molerat( 40 | level="AA", 41 | media_rules=['max-width: 600px'], 42 | skip_these_classes=["sneaky"] 43 | ).validate_document(the_text_above) 44 | -------------------------------------------------------------------------------- /wcag_zoo/validators/glowworm.py: -------------------------------------------------------------------------------- 1 | from wcag_zoo.utils import WCAGCommand 2 | 3 | # https://www.w3.org/TR/WCAG20-TECHS/G149.html 4 | # https://www.w3.org/TR/UNDERSTANDING-WCAG20/navigation-mechanisms-focus-visible.html 5 | 6 | 7 | class Glowworm(WCAGCommand): 8 | """ 9 | Glowworm checks for supressed focus outlines. 10 | """ 11 | 12 | animal = """ 13 | A glow-worm, or glowworm, is an insect. Other names for glow-worms are 14 | fire-fly and lightning bug. 15 | 16 | There are several insects given this name. Most are beetles, but one is 17 | a fly, Arachnocampa. They are nocturnal, active during the night. 18 | They have special organs that can produce light. This is used to find 19 | mates. The patterns in which the beetles flash is unique per species. 20 | 21 | - https://simple.wikipedia.org/wiki/Glow-worm 22 | """ 23 | 24 | xpath = '/html/body//*' 25 | 26 | error_codes = { 27 | 'glowworm-1': "ELement focus hidden without alternate styling", 28 | } 29 | premolar_kwargs = { 30 | "exclude_pseudoclasses": False 31 | } 32 | 33 | def skip_element(self, node): 34 | if node.tag in ['script', 'style']: 35 | return True 36 | 37 | def validate_element(self, node): 38 | style = node.get('style', "") 39 | 40 | if ":focus{outline:none}" in style or style.startswith("focus{outline:none}"): 41 | message = ( 42 | u"Input or element has suppressed focus styling - {xpath}" 43 | ).format( 44 | xpath=node.getroottree().getpath(node), 45 | ) 46 | 47 | self.add_failure(**{ 48 | 'guideline': '2.4.5', 49 | 'technique': 'G149', 50 | 'node': node, 51 | 'message': message, 52 | 'error_code': 'glowworm-1' 53 | }) 54 | else: 55 | self.add_success( 56 | guideline='2.4.7', 57 | technique='G149', 58 | node=node 59 | ) 60 | 61 | if __name__ == "__main__": 62 | cli = Glowworm.as_cli() 63 | cli() 64 | -------------------------------------------------------------------------------- /wcag_zoo/validators/anteater.py: -------------------------------------------------------------------------------- 1 | from wcag_zoo.utils import WCAGCommand 2 | 3 | # https://www.w3.org/TR/WCAG20-TECHS/H37.html 4 | # https://www.w3.org/TR/WCAG20-TECHS/H67.html 5 | 6 | 7 | class Anteater(WCAGCommand): 8 | """ 9 | Anteater checks for alt and title attributes in image tags in HTML against the requirements of the WCAG2.0 standard 10 | """ 11 | 12 | animal = """ 13 | Anteaters eat ants and termites. They have long, sharp claws and a long, 14 | sticky tongue. The tongue can be up to 60 cm long, as long as a person's 15 | arm. The anteater opens an ant nest with its claws. Then it licks up the 16 | ants with its tongue. 17 | 18 | - https://simple.wikipedia.org/wiki/Anteater 19 | """ 20 | 21 | xpath = '/html/body//img' 22 | 23 | error_codes = { 24 | 'anteater-1': "Missing alt tag on image for element", 25 | 'anteater-2': "Blank alt tag on image for element", 26 | } 27 | 28 | def validate_element(self, node): 29 | if node.get('alt') is None: 30 | message = ( 31 | u"Missing alt tag on image for element - {xpath}" 32 | u"\n Image was: {img_url}" 33 | ).format( 34 | xpath=node.getroottree().getpath(node), 35 | img_url=node.get('src') 36 | ) 37 | 38 | self.add_failure(**{ 39 | 'guideline': '1.1.1', 40 | 'technique': 'H37', 41 | 'node': node, 42 | 'message': message, 43 | 'error_code': 'anteater-1' 44 | }) 45 | elif node.get('alt') == "": 46 | message = ( 47 | u"Blank alt tag on image for element - {xpath}" 48 | u"\n Image was: {img_url}" 49 | u"\n Only use blank alt tags when an image is purely decorative." 50 | ).format( 51 | xpath=node.getroottree().getpath(node), 52 | img_url=node.get('src') 53 | ) 54 | 55 | self.add_warning(**{ 56 | 'guideline': '1.1.1', 57 | 'technique': 'H37', 58 | 'node': node, 59 | 'message': message, 60 | 'error_code': 'anteater-2' 61 | }) 62 | else: 63 | self.add_success( 64 | guideline='1.1.1', 65 | technique='H37', 66 | node=node 67 | ) 68 | 69 | if __name__ == "__main__": 70 | cli = Anteater.as_cli() 71 | cli() 72 | -------------------------------------------------------------------------------- /docs/wcag.rst: -------------------------------------------------------------------------------- 1 | WCAG guideline index and validator reference 2 | ============================================ 3 | 4 | This is an index of Web Accessibility Content Guidelines that can be verified using WCAG-Zoo 5 | as well as the techniques used for verification and the validators which perform the validation. 6 | 7 | - `1.1.1 - Nontext Content `_ 8 | 9 | * `H37: Using alt attributes on img elements 10 | `_ - ``anteater`` 11 | 12 | - `1.3.1 - Info and Relationships `_ 13 | 14 | * `H42: Using h1-h6 to identify headings 15 | `_ - ``tarsier`` 16 | 17 | - `1.4.3 - Contrast (Minimum) `_ 18 | 19 | * `G18: Ensuring that a contrast ratio of at least 4.5:1 exists between text (and images of text) and background behind the text 20 | `_ - ``molerat`` 21 | * `G145: Ensuring that a contrast ratio of at least 3:1 exists between text (and images of text) and background behind the text 22 | `_ - ``molerat`` (large text only) 23 | 24 | - `1.4.6 - Contrast (Enhanced) `_ 25 | 26 | * `G17: Ensuring that a contrast ratio of at least 7:1 exists between text (and images of text) and background behind the text 27 | `_ - ``molerat`` (using AAA compliance) 28 | * `G18: Ensuring that a contrast ratio of at least 4.5:1 exists between text (and images of text) and background behind the text 29 | `_ - ``molerat`` (large text only, using AAA compliance) 30 | 31 | - `2.1.1 - Keyboard `_ 32 | 33 | * `G90: Providing keyboard-triggered event handlers 34 | `_ - ``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 `_". 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. `_ 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 `_:: 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 | --------------------------------------------------------------------------------