Hello, In the context of LibreOffice, the Hypra company will work on an accessibility non-regression check tool.
Basically, the idea is to design a tool which will check .ui files for accessibility issues: missing relations between widgets and labels, notably. The tool would just use lxml to parse the files and emit warnings for the found issues. Such a tool could be called by application build system so that developers get the warnings along other compiler warnings, and treated the same way. It could also be used in Continuous Integration reports. Of course, there are a lot of existing issues in applications, so we plan to add support for suppression rules, so that when the tool invocation is integrated, one can integrate an initial set of suppression rules which allows to start with a zero-warning state, and then for a start developers will try to stay without warning, and progressively fix existing issues and their corresponding suppression rules. One of the remaining questions we have (it's not blocking for our immediate development, though) is whether this tool should be integrated within LibreOffice, or within gtk. The latter would both allow more widespread use of the tool by other projects, and make the maintenance happen there, thus less work for LibreOffice :) We would like to provide it with a licence which is as permissive as possible, so that projects can easily integrate it, so would it be possible to keep it BSD-licenced inside gtk? Otherwise we'll probably open e.g. a github project just for hosting a BSD version, from which we will push updates to gtk and libreoffice (and any other project for which LGPL is problematic). I have attached our current version. It's not really finished or anything, it's just for showing what it could look like eventually. Samuel
#!/usr/bin/env python # # Copyright (c) 2018 Martin Pieuchot # Copyright (c) 2018 Samuel Thibault <sthiba...@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. # 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 <property name='mnemonic_widgets'>" "%s" % elm_lines(properties)) continue if len(properties) == 1: continue warn(filename, tree, obj, "no-label-for", "missing sub-element" ", expected single <property name='mnemonic_widgets'> or " "one or more <relation type='label-for'>") continue # Not a label # Case 1: has a <child internal-child="accessible"> 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 <child internal-child='accessible'>" "%s" % elm_lines(children)) 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: check with orca ## has an <accessibility> sub-element with a "member-of" ## <relation> 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" <relation> 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 <relation type='label-for' target='%s'>" "%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 <property name="label"> 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
_______________________________________________ gtk-devel-list mailing list gtk-devel-list@gnome.org https://mail.gnome.org/mailman/listinfo/gtk-devel-list