#!/usr/bin/env python # # Copyright (c) 2018 Martin Pieuchot # Copyright (c) 2018 Samuel Thibault # # 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. # Take LibreOffice (glade) .ui files and check for non accessible widgets from __future__ import print_function import os import sys import getopt try: import lxml.etree as ET lxml = True except ImportError: import xml.etree.ElementTree as ET lxml = False widgets_ignored = [ 'GtkFrame', 'GtkWindow', 'GtkScrolledWindow', 'GtkDialog', 'GtkMessageDialog', 'GtkNotebook', # a lot of false positives there 'GtkButton', # Invisible actions 'GtkAlignment', 'GtkAdjustment', 'GtkBox', 'GtkVBox', 'GtkHBox', 'GtkButtonBox', 'GtkGrid', 'GtkSizeGroup', 'GtkSeparator', 'GtkExpander', 'GtkActionGroup', 'GtkViewport', 'GtkPaned', 'GtkCellRendererText', 'sfxlo-PriorityHBox', 'sfxlo-PriorityMergedHBox', 'sfxlo-ContextVBox', 'GtkScrollbar', 'GtkListBox', 'GtkStatusbar', # Storage objects 'GtkListStore', 'GtkTextBuffer', 'GtkTreeSelection', 'svtlo-ValueSet', # Menus are fine 'GtkMenu', 'GtkMenuItem', 'GtkRadioMenuItem', 'GtkSeparatorMenuItem', 'GtkCheckMenuItem', # Toolbars are fine 'GtkToolbar', 'GtkSeparatorToolItem', 'GtkToggleToolButton', 'GtkRadioToolButton', 'GtkMenuToolButton', 'GtkToolButton', 'GtkToolItem', 'sfxlo-NotebookbarToolBox', 'svtlo-ManagedMenuButton', 'vcllo-SmallButton', 'sfxlo-NotebookbarTabControl', 'sfxlo-DropdownBox', 'sfxlo-OptionalBox', 'AtkObject', ] # To include for LO for sure: # svxcorelo-SvxColorListBox # svxcorelo-SvxLanguageBox # foruilo-RefButton # sfxlo-SvxCharView # sfxlo-SidebarToolBox # foruilo-RefEdit # svtlo-SvSimpleTableContainer ? # svxcorelo-SvxCheckListBox ? # svtlo-SvTreeListBox ? standard_gtkbuttons = [ 'gtk-ok', 'gtk-cancel', 'gtk-help', 'gtk-close', 'gtk-revert-to-saved', ] progname = os.path.basename(sys.argv[0]) suppressions = {} gen_supprfile = None pflag = False Werror = False Wnone = False errors = 0 errexists = 0 warnings = 0 warnexists = 0 def step_elm(elm): """ Return the XML class path step corresponding to elm. This can be empty if the elm does not have any class or id. """ step = elm.attrib.get('class') if step is None: step = "" oid = elm.attrib.get('id') if oid is not None: oid = oid.encode('ascii','ignore').decode('ascii') step += "[@id='%s']" % oid if len(step) > 0: step += '/' return step def find_elm(root, elm): """ Return the XML class path of the element from the given root. This is the slow version used when getparent is not available. """ if root == elm: return "" for o in root: path = find_elm(o, elm) if path is not None: step = step_elm(o) return step + path return None def errpath(filename, tree, elm): """ Return the XML class path of the element """ if elm is None: return "" path = "" if 'class' in elm.attrib: path += elm.attrib['class'] oid = elm.attrib.get('id') if oid is not None: oid = oid.encode('ascii','ignore').decode('ascii') path += "[@id='%s']" % oid if lxml: elm = elm.getparent() while elm is not None: step = step_elm(elm) path = step + path elm = elm.getparent() else: path = find_elm(tree.getroot(), elm)[:-1] path = filename + ':' + path return path def errstr(elm): """ Return the line number of the element """ return str(elm.sourceline) def elm_prefix(filename, elm): """ Return the display prefix of the element """ if elm == None or not lxml: return "%s:" % filename else: return "%s:%s" % (filename, errstr(elm)) def elm_name(elm): """ Return a display name of the element """ if elm is not None: name = "" if 'class' in elm.attrib: name = "'%s' " % elm.attrib['class'] if 'id' in elm.attrib: id = elm.attrib['id'].encode('ascii','ignore').decode('ascii') name += "'%s' " % id return name return "" def elm_suppr(filename, tree, elm, msgtype): """ Return the prefix to be displayed to the user and the suppression line for the warning type "msgtype" for element "elm" """ global gen_suppr, gen_supprfile, pflag prefix = errpath(filename, tree, elm) suppr = '%s %s' % (prefix, msgtype) if gen_suppr is not None and msgtype is not None: if gen_supprfile is None: gen_supprfile = open(gen_suppr, 'w') print(suppr, file=gen_supprfile) if not pflag: # Use user-friendly line numbers prefix = elm_prefix(filename, elm) return (prefix, suppr) def err(filename, tree, elm, msgtype, msg): """ Emit an error for an element """ global errors, errexists, pflag (prefix, suppr) = elm_suppr(filename, tree, elm, msgtype) if suppr in suppressions: # Suppressed errexists += 1 return errors += 1 msg = "%s ERROR: %s%s" % (prefix, elm_name(elm), msg) print(msg) def warn(filename, tree, elm, msgtype, msg): """ Emit a warning for an element """ global Werror, Wnone, errors, errexists, warnings, warnexists, pflag if Wnone: return (prefix, suppr) = elm_suppr(filename, tree, elm, msgtype) if suppr in suppressions: # Suppressed if Werror: errexists += 1 else: warnexists += 1 return if Werror: errors += 1 else: warnings += 1 msg = "%s WARNING: %s%s" % (prefix, elm_name(elm), msg) print(msg) def check_objects(filename, tree, elm, objects, target): """ Check that objects contains exactly one object """ length = len(list(objects)) if length == 0: err(filename, tree, elm, "undeclared-target", "uses undeclared target '%s'" % target) elif length > 1: err(filename, tree, elm, "multiple-target", "several targets are named '%s'" % target) def check_props(filename, tree, root, props): """ Check the given list of relation properties """ for prop in props: objects = root.iterfind(".//object[@id='%s']" % prop.text) check_objects(filename, tree, prop, objects, prop.text) def check_rels(filename, tree, 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, tree, rel, targets, target) def elms_lines(elms): """ Return the list of lines for the given elements. """ if lxml: return ": lines " + ', '.join([str(l.sourceline) for l in elms]) else: return "" def check_a11y_relation(filename, tree): """ Emit an error message if any of the 'object' elements of the XML document represented by `root' doesn't comply with Accessibility rules. """ global widgets_ignored root = tree.getroot() for obj in root.iter('object'): if obj.attrib['class'] in widgets_ignored: continue label_for = obj.findall("accessibility/relation[@type='label-for']") check_rels(filename, tree, root, label_for) labelled_by = obj.findall("accessibility/relation[@type='labelled-by']") check_rels(filename, tree, root, labelled_by) member_of = obj.findall("accessibility/relation[@type='member-of']") check_rels(filename, tree, 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, tree, root, properties) if len(properties) > 1: err(filename, tree, obj, "multiple-mnemonic", "has too many sub-elements" ", expected single " "%s" % elm_lines(properties)) continue if len(properties) == 1: continue warn(filename, tree, obj, "no-label-for", "missing sub-element" ", expected single or " "one or more ") continue # Not a label # Case 1: has a sub-element children = obj.findall("child[@internal-child='accessible']") if children: if len(children) > 1: err(filename, tree, obj, "multiple-accessible", "has too many sub-elements" ", expected single " "%s" % elm_lines(children)) continue # Case 2: has an sub-element with a "labelled-by" # pointing to an existing element. if len(labelled_by) > 0: continue # TODO: check with orca ## has an sub-element with a "member-of" ## pointing to an existing element. #if len(member_of) > 0: # continue # Case 3/4: has an ID... oid = obj.attrib.get('id') if oid is not None: # ...referenced by a single "label-for" rels = root.iterfind(".//relation[@target='%s']" % oid) labelfor = [r for r in rels if r.attrib.get('type') == 'label-for'] if len(labelfor) == 1: continue if len(labelfor) > 1: err(filename, tree, obj, "multiple-label-for", "has too many elements" ", expected single " "%s" % (oid, elm_lines(labelfor))) continue # ...referenced by a single "mnemonic_widget" props = root.iterfind(".//property[@name='mnemonic_widget']") props = [p for p in props if p.text == oid] if len(props) == 1: continue if len(props) > 1: warn(filename, tree, obj, "multiple-mnemonic", "has multiple mnemonic_widget:" " lines %s" % elms_lines(props)) continue # Check for standard GtkButtons if obj.attrib['class'] == "GtkButton": labels = obj.findall("property[@name='label']") if len(labels) > 1: err(filename, tree, obj, "multiple-label", "has multiple label properties") if len(labels) == 1: # Has a if labels[0].text in standard_gtkbuttons: # And it's a standard button continue warn(filename, tree, obj, "no-labelled-by", "has no accessibility label") def usage(): print("%s [-W error|none] [-p] [-g SUPPR_FILE] [-s SUPPR_FILE] [-i WIDGET1,WIDGET2[,...]] [file ...]" % progname, file=sys.stderr) print(" -p print XML class path instead of line number"); print(" -g Generate suppression file SUPPR_FILE"); print(" -s Suppress warnings given by file SUPPR_FILE"); print(" -i Ignore warnings for widgets of a given class"); sys.exit(2) def main(): global pflag, Werror, Wnone, gen_suppr, gen_supprfile, suppressions, errors, widgets_ignored try: opts, args = getopt.getopt(sys.argv[1:], "W:piIg:s:") except getopt.GetoptError: usage() gen_suppr = None suppr = None ignore = False widgets = [] for o, a in opts: if o == "-W": if a == "error": Werror = True elif a == "none": Wnone = True elif o == "-p": pflag = True elif o == "-i": widgets = a.split(',') elif o == "-I": ignore = True elif o == "-g": gen_suppr = a elif o == "-s": suppr = a if ignore and widgets: usage() if ignore: widgets_ignored = [] elif widgets: widgets_ignored.extend(widgets) # Read suppression file before overwriting it if suppr is not None: try: supprfile = open(suppr, 'r') for line in supprfile.readlines(): prefix = line.rstrip() suppressions[prefix] = True supprfile.close() except IOError: pass for filename in args: try: tree = ET.parse(filename) except ET.ParseError: err(filename, None, None, "parse", "malformatted xml file") continue except IOError: err(filename, None, None, None, "unable to read file") continue try: check_a11y_relation(filename, tree) except Exception as error: import traceback traceback.print_exc() err(filename, None, None, "parse", "error parsing file") if errors > 0 or errexists > 0: estr = "%s new error%s" % (errors, 's' if errors > 1 else '') if errexists > 0: estr += " (%s suppressed by %s)" % (errexists, suppr) print(estr) if warnings > 0 or warnexists > 0: wstr = "%s new warning%s" % (warnings, 's' if warnings > 1 else '') if warnexists > 0: wstr += " (%s suppressed by %s)" % (warnexists, suppr) print(wstr) if errors > 0: sys.exit(1) if __name__ == "__main__": try: main() except KeyboardInterrupt: pass