diff options
author | Samuel Thibault <sthibault@hypra.fr> | 2018-02-16 13:22:10 +0100 |
---|---|---|
committer | Thorsten Behrens <Thorsten.Behrens@CIB.de> | 2018-02-20 22:21:48 +0100 |
commit | 226697ae27ef451cad404256e83eef88262f16d1 (patch) | |
tree | 30b3361c0bb8338139f3331473688a94318373ef | |
parent | 88560550021908b7877b7c02b4601b92f97ea7d4 (diff) |
Integrate initial version of gla11y tool in the build system
This is part of integrating an accessibility non-regression tool. This
adds checks in configure.ac for the presence of python lxml which we will
need, and adds support for calling the tool at build time, to check for
definite UI errors. For now, that only emits errors for missing or duplicate
accessibility relation targets, and senseless relations: a label being
mnemonic for several widgets.
Change-Id: Idda91b15b9a9e0322d16db33dfac8e03f2aa518c
Reviewed-on: https://gerrit.libreoffice.org/49856
Tested-by: Jenkins <ci@libreoffice.org>
Reviewed-by: Thorsten Behrens <Thorsten.Behrens@CIB.de>
-rwxr-xr-x | bin/gla11y | 216 | ||||
-rw-r--r-- | config_host.mk.in | 1 | ||||
-rw-r--r-- | configure.ac | 10 | ||||
-rw-r--r-- | solenv/gbuild/TargetLocations.mk | 2 | ||||
-rw-r--r-- | solenv/gbuild/UIConfig.mk | 26 |
5 files changed, 254 insertions, 1 deletions
diff --git a/bin/gla11y b/bin/gla11y new file mode 100755 index 000000000000..d0619133ad0f --- /dev/null +++ b/bin/gla11y @@ -0,0 +1,216 @@ +#!/usr/bin/env python +# -*- tab-width: 4; indent-tabs-mode: nil; py-indent-offset: 4 -*- +# +# This file is part of the LibreOffice project. +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This file incorporates work covered by the following license notice: +# +# Copyright (c) 2018 Martin Pieuchot +# Copyright (c) 2018 Samuel Thibault <sthibault@hypra.fr> +# +# Permission to use, copy, modify, and distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# +# vim: set shiftwidth=4 softtabstop=4 expandtab: + +# Take LibreOffice (glade) .ui files and check for non accessible widgets + +from __future__ import print_function + +import os +import sys +import getopt +import lxml.etree as ET + +progname = os.path.basename(sys.argv[0]) +Werror = False +Wnone = False +errors = 0 +warnings = 0 + + +def errstr(elm): + """ + Print the line number of the element + """ + + return str(elm.sourceline) + +def err(filename, elm, msg): + global errors + + if elm == None: + prefix = "%s:" % filename + else: + prefix = "%s:%s" % (filename, errstr(elm)) + + errors += 1 + msg = "%s ERROR: %s" % (prefix, msg) + print(msg.encode('ascii', 'ignore')) + + +def warn(filename, elm, msg): + global Werror, Wnone, errors, warnings + + if Wnone: + return + + prefix = "%s:%s" % (filename, errstr(elm)) + + if Werror: + errors += 1 + else: + warnings += 1 + + msg = "%s WARNING: %s" % (prefix, msg) + print(msg.encode('ascii', 'ignore')) + + +def check_objects(filename, elm, objects, target): + """ + Check that objects contains exactly one object + """ + length = len(list(objects)) + if length == 0: + err(filename, elm, "use of undeclared target '%s'" % target) + elif length > 1: + err(filename, elm, "sevral targets are named '%s'" % target) + +def check_props(filename, root, props): + """ + Check the given list of relation properties + """ + for prop in props: + objects = root.iterfind(".//object[@id='%s']" % prop.text) + check_objects(filename, prop, objects, prop.text) + +def check_rels(filename, root, rels): + """ + Check the given list of relations + """ + for rel in rels: + target = rel.attrib['target'] + targets = root.iterfind(".//object[@id='%s']" % target) + check_objects(filename, rel, targets, target) + +def check_a11y_relation(filename, root): + """ + Emit an error message if any of the 'object' elements of the XML + document represented by `root' doesn't comply with Accessibility + rules. + """ + + for obj in root.iter('object'): + + label_for = obj.findall("accessibility/relation[@type='label-for']") + check_rels(filename, root, label_for) + + labelled_by = obj.findall("accessibility/relation[@type='labelled-by']") + check_rels(filename, root, labelled_by) + + member_of = obj.findall("accessibility/relation[@type='member-of']") + check_rels(filename, root, member_of) + + if obj.attrib['class'] == 'GtkLabel': + # Case 0: A 'GtkLabel' must contain one or more "label-for" + # pointing to existing elements or... + if len(label_for) > 0: + continue + + # ...a single "mnemonic_widget" + properties = obj.findall("property[@name='mnemonic_widget']") + check_props(filename, root, properties) + if len(properties) > 1: + # It does not make sense for a label to be a mnemonic for + # several actions. + lines = ', '.join([str(p.sourceline) for p in properties]) + err(filename, obj, "too many sub-elements" + ", expected single <property name='mnemonic_widgets'>" + ": lines %s" % lines) + continue + if len(properties) == 1: + continue + # TODO: warn that it is a label for nothing + continue + + # Case 1: has a <child internal-child="accessible"> sub-element + children = obj.findall("child[@internal-child='accessible']") + if children: + if len(children) > 1: + lines = ', '.join([str(c.sourceline) for c in children]) + err(filename, obj, "too many sub-elements" + ", expected single <child internal-child='accessible'>" + ": lines %s" % lines) + continue + + # Case 2: has an <accessibility> sub-element with a "labelled-by" + # <relation> pointing to an existing element. + if len(labelled_by) > 0: + continue + + # TODO: after a few more checks and false-positives filtering, warn + # that this does not have a label + + +def usage(): + print("%s [-W error|none] [file ...]" % progname, + file=sys.stderr) + sys.exit(2) + + +def main(): + global Werror, Wnone, errors + + try: + opts, args = getopt.getopt(sys.argv[1:], "pW:") + except getopt.GetoptError: + usage() + + for o, a in opts: + if o == "-W": + if a == "error": + Werror = True + elif a == "none": + Wnone = True + + if not args: + sys.exit("%s: no input files" % progname) + + for filename in args: + try: + tree = ET.parse(filename) + except ET.ParseError: + err(filename, None, "malformatted xml file") + except IOError: + err(filename, None, "unable to read file") + + try: + check_a11y_relation(filename, tree.getroot()) + except Exception as error: + import traceback + traceback.print_exc() + err(filename, None, "error parsing file") + + + if errors > 0: + sys.exit(1) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + pass diff --git a/config_host.mk.in b/config_host.mk.in index 8ce71275ca5c..f4d7d67a6601 100644 --- a/config_host.mk.in +++ b/config_host.mk.in @@ -460,6 +460,7 @@ export PTHREAD_LIBS=@PTHREAD_LIBS@ export PYTHON_CFLAGS=$(gb_SPACE)@PYTHON_CFLAGS@ export PYTHON_FOR_BUILD=@PYTHON_FOR_BUILD@ export PYTHON_LIBS=$(gb_SPACE)@PYTHON_LIBS@ +export PYTHON_LXML=@PYTHON_LXML@ export PYTHON_VERSION=@PYTHON_VERSION@ export PYTHON_VERSION_MAJOR=@PYTHON_VERSION_MAJOR@ export PYTHON_VERSION_MINOR=@PYTHON_VERSION_MINOR@ diff --git a/configure.ac b/configure.ac index e20e91e7fa42..479968be94b9 100644 --- a/configure.ac +++ b/configure.ac @@ -8148,10 +8148,19 @@ if test $enable_python = system; then fi dnl By now enable_python should be "system", "internal" or "no" +PYTHON_LXML= case $enable_python in system) SYSTEM_PYTHON=TRUE + AC_MSG_CHECKING([for python lxml]) + if $PYTHON -c "import lxml.etree as ET" ; then + PYTHON_LXML=TRUE + AC_MSG_RESULT([yes]) + else + AC_MSG_RESULT([no, will not be able to check UI accessibility]) + fi + dnl Check if the headers really work save_CPPFLAGS="$CPPFLAGS" CPPFLAGS="$CPPFLAGS $PYTHON_CFLAGS" @@ -8213,6 +8222,7 @@ AC_SUBST(DISABLE_PYTHON) AC_SUBST(SYSTEM_PYTHON) AC_SUBST(PYTHON_CFLAGS) AC_SUBST(PYTHON_LIBS) +AC_SUBST(PYTHON_LXML) AC_SUBST(PYTHON_VERSION) AC_SUBST(PYTHON_VERSION_MAJOR) AC_SUBST(PYTHON_VERSION_MINOR) diff --git a/solenv/gbuild/TargetLocations.mk b/solenv/gbuild/TargetLocations.mk index a8310655039a..06ec8bea0f43 100644 --- a/solenv/gbuild/TargetLocations.mk +++ b/solenv/gbuild/TargetLocations.mk @@ -156,6 +156,7 @@ gb_ScpTemplateTarget_get_target = $(abspath $(WORKDIR)/ScpTemplateTarget/$(dir $ gb_SdiTarget_get_target = $(WORKDIR)/SdiTarget/$(1) gb_ThesaurusIndexTarget_get_target = $(WORKDIR)/ThesaurusIndexTarget/$(basename $(1)).idx gb_UIConfig_get_imagelist_target = $(WORKDIR)/UIConfig/$(1).ilst +gb_UIConfig_get_a11yerrors_target = $(WORKDIR)/UIConfig/$(1).a11yerrors gb_UIConfig_get_target = $(WORKDIR)/UIConfig/$(1).done gb_UIImageListTarget_get_target = $(WORKDIR)/UIImageListTarget/$(1).ilst gb_UIMenubarTarget_get_target = $(WORKDIR)/UIMenubarTarget/$(1).xml @@ -280,6 +281,7 @@ $(eval $(call gb_Helper_make_clean_targets,\ CppunitTestFakeExecutable \ CustomTarget \ ExternalProject \ + UIA11YErrorsTarget \ UIConfig \ UIImageListTarget \ UIMenubarTarget \ diff --git a/solenv/gbuild/UIConfig.mk b/solenv/gbuild/UIConfig.mk index 01bfdbaea5b6..fb8762e8a09c 100644 --- a/solenv/gbuild/UIConfig.mk +++ b/solenv/gbuild/UIConfig.mk @@ -94,6 +94,7 @@ endef # * UIConfig/<name> containing all nontranslatable files gb_UIConfig_INSTDIR := $(LIBO_SHARE_FOLDER)/config/soffice.cfg +gb_UIConfig_a11yerrors_COMMAND = $(SRCDIR)/bin/gla11y $(dir $(call gb_UIConfig_get_target,%)).dir : $(if $(wildcard $(dir $@)),,mkdir -p $(dir $@)) @@ -101,7 +102,7 @@ $(dir $(call gb_UIConfig_get_target,%)).dir : $(dir $(call gb_UIConfig_get_target,%))%/.dir : $(if $(wildcard $(dir $@)),,mkdir -p $(dir $@)) -$(call gb_UIConfig_get_target,%) : $(call gb_UIConfig_get_imagelist_target,%) +$(call gb_UIConfig_get_target,%) : $(call gb_UIConfig_get_imagelist_target,%) $(call gb_UIConfig_get_a11yerrors_target,%) $(call gb_Output_announce,$*,$(true),UIC,2) $(call gb_Helper_abbreviate_dirs,\ touch $@ \ @@ -117,6 +118,25 @@ $(call gb_UIConfig_get_clean_target,%) : rm -f $(call gb_UIConfig_get_target,$*) \ ) +define gb_UIConfig_a11yerrors__command +$(call gb_Output_announce,$(2),$(true),UIA,1) +$(call gb_Helper_abbreviate_dirs,\ + $(gb_UIConfig_a11yerrors_COMMAND) -W none $(UIFILE) > $@ +) +endef + +$(call gb_UIConfig_get_a11yerrors_target,%) : $(gb_UIConfig_a11yerrors_COMMAND) +ifeq ($(PYTHON_LXML),TRUE) + $(call gb_UIConfig_a11yerrors__command,$@,$*) +else + touch $@ +endif + +.PHONY : $(call gb_UIA11YErrorsTarget_get_clean_target,%) +$(call gb_UIA11YErrorsTarget_get_clean_target,%) : + $(call gb_Output_announce,$*,$(false),UIA,2) + rm -f $(call gb_UIConfig_get_a11yerrors_target,$*) + gb_UIConfig_get_packagename = UIConfig/$(1) gb_UIConfig_get_packagesetname = UIConfig/$(1) @@ -138,6 +158,7 @@ $(call gb_PackageSet_add_package,$(call gb_UIConfig_get_packagesetname,$(1)),$(c $(call gb_UIConfig_get_target,$(1)) :| $(dir $(call gb_UIConfig_get_target,$(1))).dir $(call gb_UIConfig_get_imagelist_target,$(1)) :| $(dir $(call gb_UIConfig_get_imagelist_target,$(1))).dir +$(call gb_UIConfig_get_a11yerrors_target,$(1)) :| $(dir $(call gb_UIConfig_get_a11yerrors_target,$(1))).dir $(call gb_UIConfig_get_target,$(1)) : $(call gb_PackageSet_get_target,$(call gb_UIConfig_get_packagesetname,$(1))) $(call gb_UIConfig_get_clean_target,$(1)) : $(call gb_PackageSet_get_clean_target,$(call gb_UIConfig_get_packagesetname,$(1))) @@ -168,6 +189,9 @@ $(call gb_UIConfig_get_imagelist_target,$(1)) : UI_IMAGELISTS += $(call gb_UIIma $(call gb_UIConfig_get_imagelist_target,$(1)) : $(call gb_UIImageListTarget_get_target,$(2)) $(call gb_UIConfig_get_clean_target,$(1)) : $(call gb_UIImageListTarget_get_clean_target,$(2)) +$(call gb_UIConfig_get_a11yerrors_target,$(1)) : UIFILE := $(SRCDIR)/$(2).ui +$(call gb_UIConfig_get_clean_target,$(1)) : $(call gb_UIA11YErrorsTarget_get_clean_target,$(2)) + endef gb_UIConfig_ALLFILES:= |