diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-15 05:54:39 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-15 05:54:39 +0000 |
commit | 267c6f2ac71f92999e969232431ba04678e7437e (patch) | |
tree | 358c9467650e1d0a1d7227a21dac2e3d08b622b2 /bin/gla11y | |
parent | Initial commit. (diff) | |
download | libreoffice-267c6f2ac71f92999e969232431ba04678e7437e.tar.xz libreoffice-267c6f2ac71f92999e969232431ba04678e7437e.zip |
Adding upstream version 4:24.2.0.upstream/4%24.2.0
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rwxr-xr-x | bin/gla11y | 1485 |
1 files changed, 1485 insertions, 0 deletions
diff --git a/bin/gla11y b/bin/gla11y new file mode 100755 index 0000000000..1f4bea984a --- /dev/null +++ b/bin/gla11y @@ -0,0 +1,1485 @@ +#!/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-2020 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. + +# Take LibreOffice (glade) .ui files and check for non accessible widgets + +# A white paper documents the rationale of the implementation: +# +# https://inria.hal.science/hal-02957129 + +from __future__ import print_function + +import os +import sys +import getopt +try: + import lxml.etree as ET + lxml = True +except ImportError: + if sys.version_info < (2,7): + print("gla11y needs lxml or python >= 2.7") + exit() + import xml.etree.ElementTree as ET + lxml = False + +howto_url = "https://wiki.documentfoundation.org/Development/Accessibility" + +# Toplevel widgets +widgets_toplevel = [ + 'GtkWindow', + 'GtkOffscreenWindow', + 'GtkApplicationWindow', + 'GtkDialog', + 'GtkFileChooserDialog', + 'GtkColorChooserDialog', + 'GtkFontChooserDialog', + 'GtkMessageDialog', + 'GtkRecentChooserDialog', + 'GtkAssistant', + 'GtkAppChooserDialog', + 'GtkPrintUnixDialog', + 'GtkShortcutsWindow', +] + +widgets_ignored = widgets_toplevel + [ + # Containers + 'GtkBox', + 'GtkGrid', + 'GtkNotebook', + 'GtkFrame', + 'GtkAspectFrame', + 'GtkListBox', + 'GtkFlowBox', + 'GtkOverlay', + 'GtkMenuBar', + 'GtkToolbar', + 'GtkToolpalette', + 'GtkPaned', + 'GtkHPaned', + 'GtkVPaned', + 'GtkButtonBox', + 'GtkHButtonBox', + 'GtkVButtonBox', + 'GtkLayout', + 'GtkFixed', + 'GtkEventBox', + 'GtkExpander', + 'GtkViewport', + 'GtkScrolledWindow', + 'GtkRevealer', + 'GtkSearchBar', + 'GtkHeaderBar', + 'GtkStack', + 'GtkPopover', + 'GtkPopoverMenu', + 'GtkActionBar', + 'GtkHandleBox', + 'GtkShortcutsSection', + 'GtkShortcutsGroup', + 'GtkTable', + + 'GtkVBox', + 'GtkHBox', + 'GtkToolItem', + 'GtkMenu', + + # Invisible actions + 'GtkSeparator', + 'GtkHSeparator', + 'GtkVSeparator', + 'GtkAction', + 'GtkToggleAction', + 'GtkActionGroup', + 'GtkCellRendererGraph', + 'GtkCellRendererPixbuf', + 'GtkCellRendererProgress', + 'GtkCellRendererSpin', + 'GtkCellRendererText', + 'GtkCellRendererToggle', + 'GtkSeparatorMenuItem', + 'GtkSeparatorToolItem', + + # Storage objects + 'GtkListStore', + 'GtkTreeStore', + 'GtkTreeModelFilter', + 'GtkTreeModelSort', + + 'GtkEntryBuffer', + 'GtkTextBuffer', + 'GtkTextTag', + 'GtkTextTagTable', + + 'GtkSizeGroup', + 'GtkWindowGroup', + 'GtkAccelGroup', + 'GtkAdjustment', + 'GtkEntryCompletion', + 'GtkIconFactory', + 'GtkStatusIcon', + 'GtkFileFilter', + 'GtkRecentFilter', + 'GtkRecentManager', + 'GThemedIcon', + + 'GtkTreeSelection', + + 'GtkListBoxRow', + 'GtkTreeViewColumn', + + # Useless to label + 'GtkScrollbar', + 'GtkHScrollbar', + 'GtkStatusbar', + 'GtkInfoBar', + + # These are actually labels + 'GtkLinkButton', + + # This precisely give a11y information :) + 'AtkObject', +] + +widgets_suffixignored = [ +] + +# These widgets always need a label +widgets_needlabel = [ + 'GtkEntry', + 'GtkSearchEntry', + 'GtkScale', + 'GtkHScale', + 'GtkVScale', + 'GtkSpinButton', + 'GtkSwitch', +] + +# These widgets normally have their own label +widgets_buttons = [ + 'GtkButton', + 'GtkToolButton', + 'GtkToggleButton', + 'GtkToggleToolButton', + 'GtkRadioButton', + 'GtkRadioToolButton', + 'GtkCheckButton', + 'GtkModelButton', + 'GtkLockButton', + 'GtkColorButton', + 'GtkMenuButton', + + 'GtkMenuItem', + 'GtkImageMenuItem', + 'GtkMenuToolButton', + 'GtkRadioMenuItem', + 'GtkCheckMenuItem', +] + +# These widgets are labels that can label other widgets +widgets_labels = [ + 'GtkLabel', + 'GtkAccelLabel', +] + +# The rest should probably be labelled if there are orphan labels + +# GtkSpinner +# GtkProgressBar +# GtkLevelBar + +# GtkComboBox +# GtkComboBoxText +# GtkFileChooserButton +# GtkAppChooserButton +# GtkFontButton +# GtkCalendar +# GtkColorChooserWidget + +# GtkCellView +# GtkTreeView +# GtkTextView +# GtkIconView + +# GtkImage +# GtkArrow +# GtkDrawingArea + +# GtkScaleButton +# GtkVolumeButton + + +# TODO: +# GtkColorPlane ? +# GtkColorScale ? +# GtkColorSwatch ? +# GtkFileChooserWidget ? +# GtkFishbowl ? +# GtkFontChooserWidget ? +# GtkIcon ? +# GtkInspector* ? +# GtkMagnifier ? +# GtkPathBar ? +# GtkPlacesSidebar ? +# GtkPlacesView ? +# GtkPrinterOptionWidget ? +# GtkStackCombo ? +# GtkStackSidebar ? +# GtkStackSwitcher ? + +progname = os.path.basename(sys.argv[0]) + +# This dictionary contains the set of suppression lines as read from the +# suppression file(s). It is merely indexed by the text of the suppression line +# and contains whether the suppressions was unused. +suppressions = {} + +# This dictionary is indexed like suppressions and returns a "file:line" string +# to report where in the suppression file the suppression was read +suppressions_to_line = {} + +# This dictionary is similar to the suppressions dictionary, but for false +# positives rather than suppressions +false_positives = {} + +# This dictionary is indexed by the xml id and returns the element object. +ids = {} +# This dictionary is indexed by the xml id and returns whether several objects +# have the same id. +ids_dup = {} + +# This dictionary is indexed by the xml id of an element A and returns the list +# of objects which are labelled-by A. +labelled_by_elm = {} + +# This dictionary is indexed by the xml id of an element A and returns the list +# of objects which are label-for A. +label_for_elm = {} + +# This dictionary is indexed by the xml id of an element A and returns the list +# of objects which have a mnemonic-for A. +mnemonic_for_elm = {} + +# Possibly a file name to put generated suppression lines in +gen_suppr = None +# The corresponding opened file +gen_supprfile = None +# A prefix to remove from file names in the generated suppression lines +suppr_prefix = "" + +# Possibly an opened file in which our output should also be written to. +outfile = None + +# Whether -p option was set, i.e. print XML class path instead of line number in +# the output +pflag = False + +# Whether we should warn about labels which are orphan +warn_orphan_labels = True + +# Number of errors +errors = 0 +# Number of suppressed errors +errexists = 0 +# Number of warnings +warnings = 0 +# Number of suppressed warnings +warnexists = 0 +# Number of fatal errors +fatals = 0 +# Number of suppressed fatal errors +fatalexists = 0 + +# List of warnings and errors which are fatal +# +# Format of each element: (enabled, type, class) +# See the is_enabled function: the list is traversed completely, each element +# can specify whether it enables or disables the warning, possibly the type of +# warning to be enabled/disabled, possibly the class of XML element for which it +# should be enabled. +# +# This mechanism matches the semantic of the parameters on the command line, +# each of which refining the semantic set by the previous parameters +dofatals = [ ] + +# List of warnings and errors which are enabled +# Same format as dofatals +enables = [ ] + +# buffers all printed output, so it isn't split in parallel builds +output_buffer = "" + +# +# XML browsing and printing functions +# + +def elm_parent(root, elm): + """ + Return the parent of the element. + """ + if lxml: + return elm.getparent() + else: + def find_parent(cur, elm): + for o in cur: + if o == elm: + return cur + parent = find_parent(o, elm) + if parent is not None: + return parent + return None + return find_parent(root, elm) + +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 = "//" + path + "[@id='%s']" % oid + else: + 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 + +# +# Warning/Error printing functions +# + +def elm_prefix(filename, elm): + """ + Return the display prefix of the element + """ + if elm == None or not lxml: + return "%s:" % filename + else: + return "%s:%u" % (filename, elm.sourceline) + +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 + if not name: + name = "'" + elm.tag + "'" + if lxml: + name += " line " + str(elm.sourceline) + return name + return "" + +def elm_name_line(elm): + """ + Return a display name of the element with line number + """ + if elm is not None: + name = elm_name(elm) + if lxml and " line " not in name: + name += "line " + str(elm.sourceline) + " " + return name + return "" + +def elm_line(elm): + """ + Return the line for the given element. + """ + if lxml: + return " line " + str(elm.sourceline) + else: + return "" + +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 elms_names_lines(elms): + """ + Return the list of names and lines for the given elements. + """ + return ', '.join([elm_name_line(elm) for elm in elms]) + +def elm_suppr(filename, tree, elm, msgtype, dogen): + """ + 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, suppr_prefix, pflag + + if suppressions or false_positives or gen_suppr is not None or pflag: + prefix = errpath(filename, tree, elm) + if prefix[0:len(suppr_prefix)] == suppr_prefix: + prefix = prefix[len(suppr_prefix):] + + if suppressions or false_positives or gen_suppr is not None: + suppr = '%s %s' % (prefix, msgtype) + + if gen_suppr is not None and msgtype is not None and dogen: + if gen_supprfile is None: + gen_supprfile = open(gen_suppr, 'w') + print(suppr, file=gen_supprfile) + else: + suppr = None + + if not pflag: + # Use user-friendly line numbers + prefix = elm_prefix(filename, elm) + if prefix[0:len(suppr_prefix)] == suppr_prefix: + prefix = prefix[len(suppr_prefix):] + + return (prefix, suppr) + +def is_enabled(elm, msgtype, l, default): + """ + Test whether warning type msgtype is enabled for elm in l + """ + enabled = default + for (enable, thetype, klass) in l: + # Match warning type + if thetype is not None: + if thetype != msgtype: + continue + # Match elm class + if klass is not None and elm is not None: + if klass != elm.attrib.get('class'): + continue + enabled = enable + return enabled + +def err(filename, tree, elm, msgtype, msg, error = True): + """ + Emit a warning or error for an element + """ + global errors, errexists, warnings, warnexists, fatals, fatalexists, output_buffer + + # Let user tune whether a warning or error + fatal = is_enabled(elm, msgtype, dofatals, error) + + # By default warnings and errors are enabled, but let user tune it + if not is_enabled(elm, msgtype, enables, True): + return + + (prefix, suppr) = elm_suppr(filename, tree, elm, msgtype, True) + if suppr in false_positives: + # That was actually expected + return + if suppr in suppressions: + # Suppressed + suppressions[suppr] = False + if fatal: + fatalexists += 1 + if error: + errexists += 1 + else: + warnexists += 1 + return + + if error: + errors += 1 + else: + warnings += 1 + if fatal: + fatals += 1 + + msg = "%s %s%s: %s%s" % (prefix, + "FATAL " if fatal else "", + "ERROR" if error else "WARNING", + elm_name(elm), msg) + output_buffer += msg + "\n" + if outfile is not None: + print(msg, file=outfile) + +def warn(filename, tree, elm, msgtype, msg): + """ + Emit a warning for an element + """ + err(filename, tree, elm, msgtype, msg, False) + +# +# Labelling testing functions +# + +def find_button_parent(root, elm): + """ + Find a parent which is a button + """ + if lxml: + parent = elm.getparent() + if parent is not None: + if parent.attrib.get('class') in widgets_buttons: + return parent + return find_button_parent(root, parent) + else: + def find_parent(cur, elm): + for o in cur: + if o == elm: + if cur.attrib.get('class') in widgets_buttons: + # we are the button, immediately above the target + return cur + else: + # we aren't the button, but target is over there + return True + parent = find_parent(o, elm) + if parent == True: + # It is over there, but didn't find a button yet + if cur.attrib.get('class') in widgets_buttons: + # we are the button + return cur + else: + return True + if parent is not None: + # we have the button parent over there + return parent + return None + parent = find_parent(root, elm) + if parent == True: + parent = None + return parent + + +def is_labelled_parent(elm): + """ + Return whether this element is a labelled parent + """ + klass = elm.attrib.get('class') + if klass in widgets_toplevel: + return True + if klass == 'GtkShortcutsGroup': + children = elm.findall("property[@name='title']") + if len(children) >= 1: + return True + if klass == 'GtkFrame' or klass == 'GtkNotebook': + children = elm.findall("child[@type='tab']") + elm.findall("child[@type='label']") + if len(children) >= 1: + return True + return False + +def elm_labelled_parent(root, elm): + """ + Return the first labelled parent of the element, which can thus be used as + the root of widgets with common labelled context + """ + + if lxml: + def find_labelled_parent(elm): + if is_labelled_parent(elm): + return elm + parent = elm.getparent() + if parent is None: + return None + return find_labelled_parent(parent) + parent = elm.getparent() + if parent is None: + return None + return find_labelled_parent(elm.getparent()) + else: + def find_labelled_parent(cur, elm): + if cur == elm: + # the target element is over there + return True + for o in cur: + parent = find_labelled_parent(o, elm) + if parent == True: + # target element is over there, check ourself + if is_labelled_parent(cur): + # yes, and we are the first ancestor of the target element + return cur + else: + # no, but target element is over there. + return True + if parent != None: + # the first ancestor of the target element was over there + return parent + return None + parent = find_labelled_parent(root, elm) + if parent == True: + parent = None + return parent + +def is_orphan_label(filename, tree, root, obj, orphan_root, doprint = False): + """ + Check whether this label has no accessibility relation, or doubtful relation + because another label labels the same target + """ + global label_for_elm, labelled_by_elm, mnemonic_for_elm, warnexists + + # label-for + label_for = obj.findall("accessibility/relation[@type='label-for']") + for rel in label_for: + target = rel.attrib['target'] + l = label_for_elm[target] + if len(l) > 1: + return True + + # mnemonic_widget + mnemonic_for = obj.findall("property[@name='mnemonic_widget']") + \ + obj.findall("property[@name='mnemonic-widget']") + for rel in mnemonic_for: + target = rel.text + l = mnemonic_for_elm[target] + if len(l) > 1: + return True + + if len(label_for) > 0: + # At least one label-for, we are not orphan. + return False + + if len(mnemonic_for) > 0: + # At least one mnemonic_widget, we are not orphan. + return False + + labelled_by = obj.findall("accessibility/relation[@type='labelled-by']") + if len(labelled_by) > 0: + # Oh, a labelled label, probably not to be labelling anything + return False + + # explicit role? + roles = [x.text for x in obj.findall("child[@internal-child='accessible']/object[@class='AtkObject']/property[@name='AtkObject::accessible-role']")] + roles += [x.attrib.get("type") for x in obj.findall("accessibility/role")] + if len(roles) > 1 and doprint: + err(filename, tree, obj, "multiple-role", "has multiple <child internal-child='accessible'><object class='AtkObject'><property name='AtkBoject::accessible-role'>" + "%s" % elms_lines(children)) + for role in roles: + if role == 'static' or role == 'ATK_ROLE_STATIC': + # This is static text, not meant to label anything + return False + + parent = elm_parent(root, obj) + if parent is not None: + childtype = parent.attrib.get('type') + if childtype is None: + childtype = parent.attrib.get('internal-child') + if parent.tag == 'child' and childtype == 'label' \ + or childtype == 'tab': + # This is a frame or a notebook label, not orphan. + return False + + if find_button_parent(root, obj) is not None: + # This label is part of a button + return False + + oid = obj.attrib.get('id') + if oid is not None: + if oid in labelled_by_elm: + # Some widget is labelled by us, we are not orphan. + # We should have had a label-for, will warn about it later. + return False + + # No label-for, no mnemonic-for, no labelled-by, we are orphan. + (_, suppr) = elm_suppr(filename, tree, obj, "orphan-label", False) + if suppr in false_positives: + # That was actually expected + return False + if suppr in suppressions: + # Warning suppressed for this label + if suppressions[suppr]: + warnexists += 1 + suppressions[suppr] = False + return False + + if doprint: + context = elm_name(orphan_root) + if context: + context = " within " + context + warn(filename, tree, obj, "orphan-label", "does not specify what it labels" + context) + return True + +def is_orphan_widget(filename, tree, root, obj, orphan, orphan_root, doprint = False): + """ + Check whether this widget has no accessibility relation. + """ + global warnexists + if obj.tag != 'object': + return False + + oid = obj.attrib.get('id') + klass = obj.attrib.get('class') + + # "Don't care" special case + if klass in widgets_ignored: + return False + for suffix in widgets_suffixignored: + if klass[-len(suffix):] == suffix: + return False + + # Widgets usual do not strictly require a label, i.e. a labelled parent + # is enough for context, but some do always need one. + requires_label = klass in widgets_needlabel + + labelled_by = obj.findall("accessibility/relation[@type='labelled-by']") + + # Labels special case + if klass in widgets_labels: + return False + + # Case 1: has an explicit <child internal-child="accessible"> sub-element + children = obj.findall("child[@internal-child='accessible']") + if len(children) > 1 and doprint: + err(filename, tree, obj, "multiple-accessible", "has multiple <child internal-child='accessible'>" + "%s" % elms_lines(children)) + if len(children) >= 1: + return False + + # Case 2: has an <accessibility> sub-element with a "labelled-by" + # <relation> pointing to an existing element. + if len(labelled_by) > 0: + return False + + # Case 3: has a label-for + if oid in label_for_elm: + return False + + # Case 4: has a mnemonic + if oid in mnemonic_for_elm: + return False + + # Case 5: Has a <property name="tooltip_text"> + tooltips = obj.findall("property[@name='tooltip_text']") + \ + obj.findall("property[@name='tooltip-text']") + if len(tooltips) > 1 and doprint: + err(filename, tree, obj, "multiple-tooltip", "has multiple tooltip_text properties") + if len(tooltips) >= 1 and klass != 'GtkCheckButton': + return False + + # Case 6: Has a <property name="placeholder_text"> + placeholders = obj.findall("property[@name='placeholder_text']") + \ + obj.findall("property[@name='placeholder-text']") + if len(placeholders) > 1 and doprint: + err(filename, tree, obj, "multiple-placeholder", "has multiple placeholder_text properties") + if len(placeholders) >= 1: + return False + + # Buttons usually don't need an external label, their own is enough, (but they do need one) + if klass in widgets_buttons: + + labels = obj.findall("property[@name='label']") + if len(labels) > 1 and doprint: + err(filename, tree, obj, "multiple-label", "has multiple label properties") + if len(labels) >= 1: + # Has a <property name="label"> + return False + + actions = obj.findall("property[@name='action_name']") + if len(actions) > 1 and doprint: + err(filename, tree, obj, "multiple-action_name", "has multiple action_name properties") + if len(actions) >= 1: + # Has a <property name="action_name"> + return False + + # Uses id as an action_name + if 'id' in obj.attrib: + if obj.attrib['id'].startswith(".uno:"): + return False + + gtklabels = obj.findall(".//object[@class='GtkLabel']") + obj.findall(".//object[@class='GtkAccelLabel']") + if len(gtklabels) >= 1: + # Has a custom label + return False + + # no label for a button, warn + if doprint: + warn(filename, tree, obj, "button-no-label", "does not have its own label") + if not is_enabled(obj, "button-no-label", enables, True): + # Warnings disabled + return False + (_, suppr) = elm_suppr(filename, tree, obj, "button-no-label", False) + if suppr in false_positives: + # That was actually expected + return False + if suppr in suppressions: + # Warning suppressed for this widget + if suppressions[suppr]: + warnexists += 1 + suppressions[suppr] = False + return False + return True + + # GtkImages special case + if klass == "GtkImage": + uses = [u for u in tree.iterfind(".//object/property[@name='image']") if u.text == oid] + if len(uses) > 0: + # This image is just used by another element, don't warn + # about the image itself, we probably want the warning on + # the element instead. + return False + + if find_button_parent(root, obj) is not None: + # This image is part of a button, we want the warning on the button + # instead, if any. + return False + + # GtkEntry special case + if klass == 'GtkEntry' or klass == 'GtkSearchEntry': + parent = elm_parent(root, obj) + if parent is not None: + if parent.tag == 'child' and \ + parent.attrib.get('internal-child') == "entry": + # This is an internal entry of another widget. Relations + # will be handled by that widget. + return False + + # GtkShortcutsShortcut special case + if klass == 'GtkShortcutsShortcut': + children = obj.findall("property[@name='title']") + if len(children) >= 1: + return False + + # Really no label, perhaps emit a warning + if not is_enabled(obj, "no-labelled-by", enables, True): + # Warnings disabled for this class of widgets + return False + (_, suppr) = elm_suppr(filename, tree, obj, "no-labelled-by", False) + if suppr in false_positives: + # That was actually expected + return False + if suppr in suppressions: + # Warning suppressed for this widget + if suppressions[suppr]: + warnexists += 1 + suppressions[suppr] = False + return False + + if not orphan: + # No orphan label, so probably the labelled parent provides enough + # context. + if requires_label: + # But these always need a label. + if doprint: + warn(filename, tree, obj, "no-labelled-by", "has no accessibility label") + return True + return False + + if doprint: + context = elm_name(orphan_root) + if context: + context = " within " + context + warn(filename, tree, obj, "no-labelled-by", "has no accessibility label while there are orphan labels" + context) + return True + +def orphan_items(filename, tree, root, elm): + """ + Check whether from some element there exists orphan labels and orphan widgets + """ + orphan_labels = False + orphan_widgets = False + if elm.attrib.get('class') in widgets_labels: + orphan_labels = is_orphan_label(filename, tree, root, elm, None) + else: + orphan_widgets = is_orphan_widget(filename, tree, root, elm, True, None) + for obj in elm: + # We are not interested in orphan labels under another labelled + # parent. This also allows to keep linear complexity. + if not is_labelled_parent(obj): + label, widget = orphan_items(filename, tree, root, obj) + if label: + orphan_labels = True + if widget: + orphan_widgets = True + if orphan_labels and orphan_widgets: + # No need to look up more + break + return orphan_labels, orphan_widgets + +# +# UI accessibility checks +# + +def check_props(filename, tree, root, elm, forward): + """ + Check the given list of relation properties + """ + props = elm.findall("property[@name='" + forward + "']") + for prop in props: + if prop.text not in ids: + err(filename, tree, elm, "undeclared-target", forward + " uses undeclared target '%s'" % prop.text) + return props + +def is_visible(obj): + visible = False + visible_prop = obj.findall("property[@name='visible']") + visible_len = len(visible_prop) + if visible_len: + visible_txt = visible_prop[visible_len - 1].text + if visible_txt.lower() == "true": + visible = True + elif visible_txt.lower() == "false": + visible = False + return visible + +def check_rels(filename, tree, root, elm, forward, backward = None): + """ + Check the relations given by forward + """ + oid = elm.attrib.get('id') + rels = elm.findall("accessibility/relation[@type='" + forward + "']") + for rel in rels: + target = rel.attrib['target'] + if target not in ids: + err(filename, tree, elm, "undeclared-target", forward + " uses undeclared target '%s'" % target) + elif backward is not None: + widget = ids[target] + backrels = widget.findall("accessibility/relation[@type='" + backward + "']") + if len([x for x in backrels if x.attrib['target'] == oid]) == 0: + err(filename, tree, elm, "missing-" + backward, "has " + forward + \ + ", but is not " + backward + " by " + elm_name_line(widget)) + return rels + +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, ids, label_for_elm, labelled_by_elm, mnemonic_for_elm + + def check_elm(orphan_root, obj, orphan_labels, orphan_widgets): + """ + Check one element, knowing that orphan_labels/widgets tell whether + there are orphan labels and widgets within orphan_root + """ + + oid = obj.attrib.get('id') + klass = obj.attrib.get('class') + + # "Don't care" special case + if klass in widgets_ignored: + return + for suffix in widgets_suffixignored: + if klass[-len(suffix):] == suffix: + return + + # Widgets usual do not strictly require a label, i.e. a labelled parent + # is enough for context, but some do always need one. + requires_label = klass in widgets_needlabel + + if oid is not None: + # Check that ids are unique + if oid in ids_dup: + if ids[oid] == obj: + # We are the first, warn + duplicates = tree.findall(".//object[@id='" + oid + "']") + err(filename, tree, obj, "duplicate-id", "has the same id as other elements " + elms_names_lines(duplicates)) + + # Check label-for and their dual labelled-by + label_for = check_rels(filename, tree, root, obj, "label-for", "labelled-by") + + # Check labelled-by and its dual label-for + labelled_by = check_rels(filename, tree, root, obj, "labelled-by", "label-for") + + visible = is_visible(obj) + + # warning message type "syntax" used: + # + # multiple-* => 2+ XML tags of the inspected element itself + # duplicate-* => 2+ XML tags of other elements referencing this element + + # Should have only one label + if len(labelled_by) >= 1: + if oid in mnemonic_for_elm: + warn(filename, tree, obj, "labelled-by-and-mnemonic", + "has both a mnemonic " + elm_name_line(mnemonic_for_elm[oid][0]) + "and labelled-by relation") + if len(labelled_by) > 1: + warn(filename, tree, obj, "multiple-labelled-by", "has multiple labelled-by relations") + + if oid in labelled_by_elm: + if len(labelled_by_elm[oid]) == 1: + paired = labelled_by_elm[oid][0] + if paired != None and visible != is_visible(paired): + warn(filename, tree, obj, "visibility-conflict", "visibility conflicts with paired " + elm_name_line(paired)) + + if oid in label_for_elm: + if len(label_for_elm[oid]) > 1: + warn(filename, tree, obj, "duplicate-label-for", "is referenced by multiple label-for " + elms_names_lines(label_for_elm[oid])) + elif len(label_for_elm[oid]) == 1: + paired = label_for_elm[oid][0] + if visible != is_visible(paired): + warn(filename, tree, obj, "visibility-conflict", "visibility conflicts with paired " + elm_name_line(paired)) + + if oid in mnemonic_for_elm: + if len(mnemonic_for_elm[oid]) > 1: + warn(filename, tree, obj, "duplicate-mnemonic", "is referenced by multiple mnemonic_widget " + elms_names_lines(mnemonic_for_elm[oid])) + + # Check controlled-by/controller-for + controlled_by = check_rels(filename, tree, root, obj, "controlled-by", "controller-for") + controller_for = check_rels(filename, tree, root, obj, "controlled-for", "controlled-by") + + # Labels special case + if klass in widgets_labels: + properties = check_props(filename, tree, root, obj, "mnemonic_widget") + \ + check_props(filename, tree, root, obj, "mnemonic-widget") + if len(properties) > 1: + err(filename, tree, obj, "multiple-mnemonic", "has multiple mnemonic_widgets properties" + "%s" % elms_lines(properties)) + + # Emit orphaning warnings + if warn_orphan_labels or orphan_widgets: + is_orphan_label(filename, tree, root, obj, orphan_root, True) + + # We are done with the label + return + + # Not a label, will perhaps need one + + # Emit orphaning warnings + is_orphan_widget(filename, tree, root, obj, orphan_labels, orphan_root, True) + + root = tree.getroot() + + # Flush ids and relations from previous files + ids = {} + ids_dup = {} + labelled_by_elm = {} + label_for_elm = {} + mnemonic_for_elm = {} + + # First pass to get links into hash tables, no warning, just record duplicates + for obj in root.iter('object'): + oid = obj.attrib.get('id') + if oid is not None: + if oid not in ids: + ids[oid] = obj + else: + ids_dup[oid] = True + + labelled_by = obj.findall("accessibility/relation[@type='labelled-by']") + for rel in labelled_by: + target = rel.attrib.get('target') + if target is not None: + if target not in labelled_by_elm: + labelled_by_elm[target] = [ obj ] + else: + labelled_by_elm[target].append(obj) + + label_for = obj.findall("accessibility/relation[@type='label-for']") + for rel in label_for: + target = rel.attrib.get('target') + if target is not None: + if target not in label_for_elm: + label_for_elm[target] = [ obj ] + else: + label_for_elm[target].append(obj) + + mnemonic_for = obj.findall("property[@name='mnemonic_widget']") + \ + obj.findall("property[@name='mnemonic-widget']") + for rel in mnemonic_for: + target = rel.text + if target is not None: + if target not in mnemonic_for_elm: + mnemonic_for_elm[target] = [ obj ] + else: + mnemonic_for_elm[target].append(obj) + + # Second pass, recursive depth-first, to be able to efficiently know whether + # there are orphan labels within a part of the tree. + def recurse(orphan_root, obj, orphan_labels, orphan_widgets): + if obj == root or is_labelled_parent(obj): + orphan_root = obj + orphan_labels, orphan_widgets = orphan_items(filename, tree, root, obj) + + if obj.tag == 'object': + check_elm(orphan_root, obj, orphan_labels, orphan_widgets) + + for o in obj: + recurse(orphan_root, o, orphan_labels, orphan_widgets) + + recurse(root, root, False, False) + +# +# Main +# + +def usage(fatal = True): + print("`%s' checks accessibility of glade .ui files" % progname) + print("") + print("Usage: %s [-p] [-g SUPPR_FILE] [-s SUPPR_FILE] [-f SUPPR_FILE] [-P PREFIX] [-o LOG_FILE] [file ...]" % progname) + print("") + 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, but count them") + print(" -f Suppress warnings given by file SUPPR_FILE completely") + print(" -P Remove PREFIX from file names in warnings") + print(" -o Also prints errors and warnings to given file") + print("") + print(" --widgets-FOO [+][CLASS1[,CLASS2[,...]]]") + print(" Give or extend one of the lists of widget classes, where FOO can be:") + print(" - toplevel : widgets to be considered toplevel windows") + print(" - ignored : widgets which do not need labelling (e.g. GtkBox)") + print(" - suffixignored : suffixes of widget classes which do not need labelling") + print(" - needlabel : widgets which always need labelling (e.g. GtkEntry)") + print(" - buttons : widgets which need their own label but not more") + print(" (e.g. GtkButton)") + print(" - labels : widgets which provide labels (e.g. GtkLabel)") + print(" --widgets-print print default widgets lists") + print("") + print(" --enable-all enable all warnings/dofatals (default)") + print(" --disable-all disable all warnings/dofatals") + print(" --fatal-all make all warnings dofatals") + print(" --not-fatal-all do not make all warnings dofatals (default)") + print("") + print(" --enable-type=TYPE enable warning/fatal type TYPE") + print(" --disable-type=TYPE disable warning/fatal type TYPE") + print(" --fatal-type=TYPE make warning type TYPE a fatal") + print(" --not-fatal-type=TYPE make warning type TYPE not a fatal") + print("") + print(" --enable-widgets=CLASS enable warning/fatal type CLASS") + print(" --disable-widgets=CLASS disable warning/fatal type CLASS") + print(" --fatal-widgets=CLASS make warning type CLASS a fatal") + print(" --not-fatal-widgets=CLASS make warning type CLASS not a fatal") + print("") + print(" --enable-specific=TYPE.CLASS enable warning/fatal type TYPE for widget") + print(" class CLASS") + print(" --disable-specific=TYPE.CLASS disable warning/fatal type TYPE for widget") + print(" class CLASS") + print(" --fatal-specific=TYPE.CLASS make warning type TYPE a fatal for widget") + print(" class CLASS") + print(" --not-fatal-specific=TYPE.CLASS make warning type TYPE not a fatal for widget") + print(" class CLASS") + print("") + print(" --disable-orphan-labels only warn about orphan labels when there are") + print(" orphan widgets in the same context") + print("") + print("Report bugs to <bugs@hypra.fr>") + sys.exit(2 if fatal else 0) + +def widgets_opt(widgets_list, arg): + """ + Replace or extend `widgets_list' with the list of classes contained in `arg' + """ + append = arg and arg[0] == '+' + if append: + arg = arg[1:] + + if arg: + widgets = arg.split(',') + else: + widgets = [] + + if not append: + del widgets_list[:] + + widgets_list.extend(widgets) + + +def main(): + global pflag, gen_suppr, gen_supprfile, suppressions, suppr_prefix, false_positives, dofatals, enables, dofatals, warn_orphan_labels + global widgets_toplevel, widgets_ignored, widgets_suffixignored, widgets_needlabel, widgets_buttons, widgets_labels + global outfile, output_buffer + + try: + opts, args = getopt.getopt(sys.argv[1:], "hpiIg:s:f:P:o:L:", [ + "help", + "version", + + "widgets-toplevel=", + "widgets-ignored=", + "widgets-suffixignored=", + "widgets-needlabel=", + "widgets-buttons=", + "widgets-labels=", + "widgets-print", + + "enable-all", + "disable-all", + "fatal-all", + "not-fatal-all", + + "enable-type=", + "disable-type=", + "fatal-type=", + "not-fatal-type=", + + "enable-widgets=", + "disable-widgets=", + "fatal-widgets=", + "not-fatal-widgets=", + + "enable-specific=", + "disable-specific=", + "fatal-specific=", + "not-fatal-specific=", + + "disable-orphan-labels", + ] ) + except getopt.GetoptError: + usage() + + suppr = None + false = None + out = None + filelist = None + + for o, a in opts: + if o == "--help" or o == "-h": + usage(False) + if o == "--version": + print("0.1") + sys.exit(0) + elif o == "-p": + pflag = True + elif o == "-g": + gen_suppr = a + elif o == "-s": + suppr = a + elif o == "-f": + false = a + elif o == "-P": + suppr_prefix = a + elif o == "-o": + out = a + elif o == "-L": + filelist = a + + elif o == "--widgets-toplevel": + widgets_opt(widgets_toplevel, a) + elif o == "--widgets-ignored": + widgets_opt(widgets_ignored, a) + elif o == "--widgets-suffixignored": + widgets_opt(widgets_suffixignored, a) + elif o == "--widgets-needlabel": + widgets_opt(widgets_needlabel, a) + elif o == "--widgets-buttons": + widgets_opt(widgets_buttons, a) + elif o == "--widgets-labels": + widgets_opt(widgets_labels, a) + elif o == "--widgets-print": + print("--widgets-toplevel '" + ','.join(widgets_toplevel) + "'") + print("--widgets-ignored '" + ','.join(widgets_ignored) + "'") + print("--widgets-suffixignored '" + ','.join(widgets_suffixignored) + "'") + print("--widgets-needlabel '" + ','.join(widgets_needlabel) + "'") + print("--widgets-buttons '" + ','.join(widgets_buttons) + "'") + print("--widgets-labels '" + ','.join(widgets_labels) + "'") + sys.exit(0) + + elif o == '--enable-all': + enables.append( (True, None, None) ) + elif o == '--disable-all': + enables.append( (False, None, None) ) + elif o == '--fatal-all': + dofatals.append( (True, None, None) ) + elif o == '--not-fatal-all': + dofatals.append( (False, None, None) ) + + elif o == '--enable-type': + enables.append( (True, a, None) ) + elif o == '--disable-type': + enables.append( (False, a, None) ) + elif o == '--fatal-type': + dofatals.append( (True, a, None) ) + elif o == '--not-fatal-type': + dofatals.append( (False, a, None) ) + + elif o == '--enable-widgets': + enables.append( (True, None, a) ) + elif o == '--disable-widgets': + enables.append( (False, None, a) ) + elif o == '--fatal-widgets': + dofatals.append( (True, None, a) ) + elif o == '--not-fatal-widgets': + dofatals.append( (False, None, a) ) + + elif o == '--enable-specific': + (thetype, klass) = a.split('.', 1) + enables.append( (True, thetype, klass) ) + elif o == '--disable-specific': + (thetype, klass) = a.split('.', 1) + enables.append( (False, thetype, klass) ) + elif o == '--fatal-specific': + (thetype, klass) = a.split('.', 1) + dofatals.append( (True, thetype, klass) ) + elif o == '--not-fatal-specific': + (thetype, klass) = a.split('.', 1) + dofatals.append( (False, thetype, klass) ) + + elif o == '--disable-orphan-labels': + warn_orphan_labels = False + + output_header = "" + + # Read suppression file before overwriting it + if suppr is not None: + try: + output_header += "Suppression file: " + suppr + "\n" + supprfile = open(suppr, 'r') + line_no = 0 + for line in supprfile.readlines(): + line_no = line_no + 1 + if line.startswith('#'): + continue + prefix = line.rstrip() + suppressions[prefix] = True + suppressions_to_line[prefix] = "%s:%u" % (suppr, line_no) + supprfile.close() + except IOError: + pass + + # Read false positives file + if false is not None: + try: + output_header += "False positive file: " + false + "\n" + falsefile = open(false, 'r') + for line in falsefile.readlines(): + if line.startswith('#'): + continue + prefix = line.rstrip() + false_positives[prefix] = True + falsefile.close() + except IOError: + pass + + if out is not None: + outfile = open(out, 'w') + + if filelist is not None: + try: + filelistfile = open(filelist, 'r') + for line in filelistfile.readlines(): + line = line.strip() + if line: + args += line.split(' ') + filelistfile.close() + except IOError: + err(filelist, None, None, "unable to read file list file") + + 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 + output_buffer += traceback.format_exc() + err(filename, None, None, "parse", "error parsing file") + + if errors > 0 or errexists > 0: + output_buffer += "%s new error%s" % (errors, 's' if errors != 1 else '') + if errexists > 0: + output_buffer += " (%s suppressed by %s, please fix %s)" % (errexists, suppr, 'them' if errexists > 1 else 'it') + output_buffer += "\n" + + if warnings > 0 or warnexists > 0: + output_buffer += "%s new warning%s" % (warnings, 's' if warnings != 1 else '') + if warnexists > 0: + output_buffer += " (%s suppressed by %s, please fix %s)" % (warnexists, suppr, 'them' if warnexists > 1 else 'it') + output_buffer += "\n" + + if fatals > 0 or fatalexists > 0: + output_buffer += "%s new fatal%s" % (fatals, 's' if fatals != 1 else '') + if fatalexists > 0: + output_buffer += " (%s suppressed by %s, please fix %s)" % (fatalexists, suppr, 'them' if fatalexists > 1 else 'it') + output_buffer += "\n" + + n = 0 + for (suppr,unused) in suppressions.items(): + if unused: + n += 1 + + if n > 0: + output_buffer += "%s suppression%s unused:\n" % (n, 's' if n != 1 else '') + for (suppr,unused) in suppressions.items(): + if unused: + output_buffer += " %s:%s\n" % (suppressions_to_line[suppr], suppr) + + if gen_supprfile is not None: + gen_supprfile.close() + if outfile is not None: + outfile.close() + + if gen_suppr is None: + if output_buffer != "": + output_buffer += "Explanations are available on " + howto_url + "\n" + + if fatals > 0: + print(output_header.rstrip() + "\n" + output_buffer) + sys.exit(1) + + if len(output_buffer) > 0: + print(output_header.rstrip() + "\n" + output_buffer) + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + pass + +# vim: set shiftwidth=4 softtabstop=4 expandtab: |