Version 6.1.4.1, tag libreoffice-6.1.4.1
[LibreOffice.git] / bin / gla11y
blobf18e2f48c960bb2dbc92790d091203b5db1e1af0
1 #!/usr/bin/env python
2 # -*- tab-width: 4; indent-tabs-mode: nil; py-indent-offset: 4 -*-
4 # This file is part of the LibreOffice project.
6 # This Source Code Form is subject to the terms of the Mozilla Public
7 # License, v. 2.0. If a copy of the MPL was not distributed with this
8 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
10 # This file incorporates work covered by the following license notice:
12 # Copyright (c) 2018 Martin Pieuchot
13 # Copyright (c) 2018 Samuel Thibault <sthibault@hypra.fr>
15 # Permission to use, copy, modify, and distribute this software for any
16 # purpose with or without fee is hereby granted, provided that the above
17 # copyright notice and this permission notice appear in all copies.
19 # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
20 # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
21 # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
22 # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
23 # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
24 # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
25 # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
27 # Take LibreOffice (glade) .ui files and check for non accessible widgets
29 from __future__ import print_function
31 import os
32 import sys
33 import getopt
34 try:
35 import lxml.etree as ET
36 lxml = True
37 except ImportError:
38 if sys.version_info < (2,7):
39 print("gla11y needs lxml or python >= 2.7")
40 exit()
41 import xml.etree.ElementTree as ET
42 lxml = False
44 # Toplevel widgets
45 widgets_toplevel = [
46 'GtkWindow',
47 'GtkOffscreenWindow',
48 'GtkApplicationWindow',
49 'GtkDialog',
50 'GtkAboutDialog',
51 'GtkFileChooserDialog',
52 'GtkColorChooserDialog',
53 'GtkFontChooserDialog',
54 'GtkMessageDialog',
55 'GtkRecentChooserDialog',
56 'GtkAssistant',
57 'GtkAppChooserDialog',
58 'GtkPrintUnixDialog',
59 'GtkShortcutsWindow',
62 widgets_ignored = widgets_toplevel + [
63 # Containers
64 'GtkBox',
65 'GtkGrid',
66 'GtkNotebook',
67 'GtkFrame',
68 'GtkAspectFrame',
69 'GtkListBox',
70 'GtkFlowBox',
71 'GtkOverlay',
72 'GtkMenuBar',
73 'GtkToolbar',
74 'GtkToolpalette',
75 'GtkPaned',
76 'GtkHPaned',
77 'GtkVPaned',
78 'GtkButtonBox',
79 'GtkHButtonBox',
80 'GtkVButtonBox',
81 'GtkLayout',
82 'GtkFixed',
83 'GtkEventBox',
84 'GtkExpander',
85 'GtkViewport',
86 'GtkScrolledWindow',
87 'GtkAlignment',
88 'GtkRevealer',
89 'GtkSearchBar',
90 'GtkHeaderBar',
91 'GtkStack',
92 'GtkStackSwticher',
93 'GtkPopover',
94 'GtkPopoverMenu',
95 'GtkActionBar',
96 'GtkHandleBox',
97 'GtkShortcutsSection',
98 'GtkShortcutsGroup',
99 'GtkTable',
101 'GtkVBox',
102 'GtkHBox',
103 'GtkToolItem',
104 'GtkMenu',
106 # Invisible actions
107 'GtkSeparator',
108 'GtkHSeparator',
109 'GtkVSeparator',
110 'GtkAction',
111 'GtkToggleAction',
112 'GtkActionGroup',
113 'GtkCellRendererGraph',
114 'GtkCellRendererPixbuf',
115 'GtkCellRendererProgress',
116 'GtkCellRendererSpin',
117 'GtkCellRendererText',
118 'GtkCellRendererToggle',
119 'GtkSeparatorMenuItem',
120 'GtkSeparatorToolItem',
122 # Storage objects
123 'GtkListStore',
124 'GtkTreeStore',
125 'GtkTreeModelFilter',
126 'GtkTreeModelSort',
128 'GtkEntryBuffer',
129 'GtkTextBuffer',
130 'GtkTextTag',
131 'GtkTextTagTable',
133 'GtkSizeGroup',
134 'GtkWindowGroup',
135 'GtkAccelGroup',
136 'GtkAdjustment',
137 'GtkEntryCompletion',
138 'GtkIconFactory',
139 'GtkStatusIcon',
140 'GtkFileFilter',
141 'GtkRecentFilter',
142 'GtkRecentManager',
143 'GThemedIcon',
145 'GtkTreeSelection',
147 'GtkListBoxRow',
149 # Useless to label
150 'GtkScrollbar',
151 'GtkHScrollbar',
152 'GtkStatusbar',
153 'GtkInfoBar',
155 # These are actually labels
156 'GtkLinkButton',
158 # This precisely give a11y information :)
159 'AtkObject',
162 widgets_suffixignored = [
165 # These widgets always need a label
166 widgets_needlabel = [
167 'GtkEntry',
168 'GtkSearchEntry',
169 'GtkScale',
170 'GtkHScale',
171 'GtkVScale',
172 'GtkSpinButton',
173 'GtkSwitch',
176 # These widgets normally have their own label
177 widgets_buttons = [
178 'GtkButton',
179 'GtkToolButton',
180 'GtkToggleButton',
181 'GtkToggleToolButton',
182 'GtkRadioButton',
183 'GtkRadioToolButton',
184 'GtkCheckButton',
185 'GtkModelButton',
186 'GtkLockButton',
187 'GtkColorButton',
188 'GtkMenuButton',
190 'GtkMenuItem',
191 'GtkImageMenuItem',
192 'GtkMenuToolButton',
193 'GtkRadioMenuItem',
194 'GtkCheckMenuItem',
197 # These widgets are labels that can label other widgets
198 widgets_labels = [
199 'GtkLabel',
200 'GtkAccelLabel',
203 # The rest should probably be labelled if there are orphan labels
205 # GtkSpinner
206 # GtkProgressBar
207 # GtkLevelBar
209 # GtkComboBox
210 # GtkComboBoxText
211 # GtkFileChooserButton
212 # GtkAppChooserButton
213 # GtkFontButton
214 # GtkCalendar
215 # GtkColorChooserWidget
217 # GtkCellView
218 # GtkTreeView
219 # GtkTreeViewColumn
220 # GtkTextView
221 # GtkIconView
223 # GtkImage
224 # GtkArrow
225 # GtkDrawingArea
227 # GtkScaleButton
228 # GtkVolumeButton
231 # TODO:
232 # GtkColorPlane ?
233 # GtkColorScale ?
234 # GtkColorSwatch ?
235 # GtkFileChooserWidget ?
236 # GtkFishbowl ?
237 # GtkFontChooserWidget ?
238 # GtkIcon ?
239 # GtkInspector* ?
240 # GtkMagnifier ?
241 # GtkPathBar ?
242 # GtkPlacesSidebar ?
243 # GtkPlacesView ?
244 # GtkPrinterOptionWidget ?
245 # GtkStackCombo ?
246 # GtkStackSidebar ?
247 # GtkStackSwitcher ?
249 progname = os.path.basename(sys.argv[0])
251 suppressions = {}
252 false_positives = {}
253 ids = {}
254 ids_dup = {}
255 labelled_by_elm = {}
256 label_for_elm = {}
257 mnemonic_for_elm = {}
259 gen_suppr = None
260 gen_supprfile = None
261 suppr_prefix = ""
262 outfile = None
264 pflag = False
266 warn_orphan_labels = True
268 errors = 0
269 errexists = 0
270 warnings = 0
271 warnexists = 0
272 fatals = 0
273 fatalexists = 0
275 enables = [ ]
276 dofatals = [ ]
279 # XML browsing and printing functions
282 def elm_parent(root, elm):
284 Return the parent of the element.
286 if lxml:
287 return elm.getparent()
288 else:
289 def find_parent(cur, elm):
290 for o in cur:
291 if o == elm:
292 return cur
293 parent = find_parent(o, elm)
294 if parent is not None:
295 return parent
296 return None
297 return find_parent(root, elm)
299 def step_elm(elm):
301 Return the XML class path step corresponding to elm.
302 This can be empty if the elm does not have any class or id.
304 step = elm.attrib.get('class')
305 if step is None:
306 step = ""
307 oid = elm.attrib.get('id')
308 if oid is not None:
309 oid = oid.encode('ascii','ignore').decode('ascii')
310 step += "[@id='%s']" % oid
311 if len(step) > 0:
312 step += '/'
313 return step
315 def find_elm(root, elm):
317 Return the XML class path of the element from the given root.
318 This is the slow version used when getparent is not available.
320 if root == elm:
321 return ""
322 for o in root:
323 path = find_elm(o, elm)
324 if path is not None:
325 step = step_elm(o)
326 return step + path
327 return None
329 def errpath(filename, tree, elm):
331 Return the XML class path of the element
333 if elm is None:
334 return ""
335 path = ""
336 if 'class' in elm.attrib:
337 path += elm.attrib['class']
338 oid = elm.attrib.get('id')
339 if oid is not None:
340 oid = oid.encode('ascii','ignore').decode('ascii')
341 path = "//" + path + "[@id='%s']" % oid
342 else:
343 if lxml:
344 elm = elm.getparent()
345 while elm is not None:
346 step = step_elm(elm)
347 path = step + path
348 elm = elm.getparent()
349 else:
350 path = find_elm(tree.getroot(), elm)[:-1]
351 path = filename + ':' + path
352 return path
355 # Warning/Error printing functions
358 def elm_prefix(filename, elm):
360 Return the display prefix of the element
362 if elm == None or not lxml:
363 return "%s:" % filename
364 else:
365 return "%s:%u" % (filename, elm.sourceline)
367 def elm_name(elm):
369 Return a display name of the element
371 if elm is not None:
372 name = ""
373 if 'class' in elm.attrib:
374 name = "'%s' " % elm.attrib['class']
375 if 'id' in elm.attrib:
376 id = elm.attrib['id'].encode('ascii','ignore').decode('ascii')
377 name += "'%s' " % id
378 if not name:
379 name = "'" + elm.tag + "'"
380 if lxml:
381 name += " line " + str(elm.sourceline)
382 return name
383 return ""
385 def elm_name_line(elm):
387 Return a display name of the element with line number
389 if elm is not None:
390 name = elm_name(elm)
391 if lxml and " line " not in name:
392 name += "line " + str(elm.sourceline) + " "
393 return name
394 return ""
396 def elm_line(elm):
398 Return the line for the given element.
400 if lxml:
401 return " line " + str(elm.sourceline)
402 else:
403 return ""
405 def elms_lines(elms):
407 Return the list of lines for the given elements.
409 if lxml:
410 return " lines " + ', '.join([str(l.sourceline) for l in elms])
411 else:
412 return ""
414 def elms_names_lines(elms):
416 Return the list of names and lines for the given elements.
418 return ', '.join([elm_name_line(elm) for elm in elms])
420 def elm_suppr(filename, tree, elm, msgtype, dogen):
422 Return the prefix to be displayed to the user and the suppression line for
423 the warning type "msgtype" for element "elm"
425 global gen_suppr, gen_supprfile, suppr_prefix, pflag
427 if suppressions or false_positives or gen_suppr is not None or pflag:
428 prefix = errpath(filename, tree, elm)
429 if prefix[0:len(suppr_prefix)] == suppr_prefix:
430 prefix = prefix[len(suppr_prefix):]
432 if suppressions or false_positives or gen_suppr is not None:
433 suppr = '%s %s' % (prefix, msgtype)
435 if gen_suppr is not None and msgtype is not None and dogen:
436 if gen_supprfile is None:
437 gen_supprfile = open(gen_suppr, 'w')
438 print(suppr, file=gen_supprfile)
439 else:
440 suppr = None
442 if not pflag:
443 # Use user-friendly line numbers
444 prefix = elm_prefix(filename, elm)
445 if prefix[0:len(suppr_prefix)] == suppr_prefix:
446 prefix = prefix[len(suppr_prefix):]
448 return (prefix, suppr)
450 def is_enabled(elm, msgtype, l, default):
452 Test whether warning type msgtype is enabled for elm in l
454 enabled = default
455 for (enable, thetype, klass) in l:
456 # Match warning type
457 if thetype is not None:
458 if thetype != msgtype:
459 continue
460 # Match elm class
461 if klass is not None and elm is not None:
462 if klass != elm.attrib.get('class'):
463 continue
464 enabled = enable
465 return enabled
467 def err(filename, tree, elm, msgtype, msg, error = True):
469 Emit a warning or error for an element
471 global errors, errexists, warnings, warnexists, fatals, fatalexists
473 # Let user tune whether a warning or error
474 fatal = is_enabled(elm, msgtype, dofatals, error)
476 # By default warnings and errors are enabled, but let user tune it
477 if not is_enabled(elm, msgtype, enables, True):
478 return
480 (prefix, suppr) = elm_suppr(filename, tree, elm, msgtype, True)
481 if suppr in false_positives:
482 # That was actually expected
483 return
484 if suppr in suppressions:
485 # Suppressed
486 suppressions[suppr] = False
487 if fatal:
488 fatalexists += 1
489 if error:
490 errexists += 1
491 else:
492 warnexists += 1
493 return
495 if error:
496 errors += 1
497 else:
498 warnings += 1
499 if fatal:
500 fatals += 1
502 msg = "%s %s%s: %s%s" % (prefix,
503 "FATAL " if fatal else "",
504 "ERROR" if error else "WARNING",
505 elm_name(elm), msg)
506 print(msg)
507 if outfile is not None:
508 print(msg, file=outfile)
510 def warn(filename, tree, elm, msgtype, msg):
512 Emit a warning for an element
514 err(filename, tree, elm, msgtype, msg, False)
517 # Labelling testing functions
520 def find_button_parent(root, elm):
522 Find a parent which is a button
524 if lxml:
525 parent = elm.getparent()
526 if parent is not None:
527 if parent.attrib.get('class') in widgets_buttons:
528 return parent
529 return find_button_parent(root, parent)
530 else:
531 def find_parent(cur, elm):
532 for o in cur:
533 if o == elm:
534 if cur.attrib.get('class') in widgets_buttons:
535 # we are the button, immediately above the target
536 return cur
537 else:
538 # we aren't the button, but target is over there
539 return True
540 parent = find_parent(o, elm)
541 if parent == True:
542 # It is over there, but didn't find a button yet
543 if cur.attrib.get('class') in widgets_buttons:
544 # we are the button
545 return cur
546 else:
547 return True
548 if parent is not None:
549 # we have the button parent over there
550 return parent
551 return None
552 parent = find_parent(root, elm)
553 if parent == True:
554 parent = None
555 return parent
558 def is_labelled_parent(elm):
560 Return whether this element is a labelled parent
562 klass = elm.attrib.get('class')
563 if klass in widgets_toplevel:
564 return True
565 if klass == 'GtkShortcutsGroup':
566 children = elm.findall("property[@name='title']")
567 if len(children) >= 1:
568 return True
569 if klass == 'GtkFrame' or klass == 'GtkNotebook':
570 children = elm.findall("child[@type='tab']") + elm.findall("child[@type='label']")
571 if len(children) >= 1:
572 return True
573 return False
575 def elm_labelled_parent(root, elm):
577 Return the first labelled parent of the element, which can thus be used as
578 the root of widgets with common labelled context
581 if lxml:
582 def find_labelled_parent(elm):
583 if is_labelled_parent(elm):
584 return elm
585 parent = elm.getparent()
586 if parent is None:
587 return None
588 return find_labelled_parent(parent)
589 parent = elm.getparent()
590 if parent is None:
591 return None
592 return find_labelled_parent(elm.getparent())
593 else:
594 def find_labelled_parent(cur, elm):
595 if cur == elm:
596 # the target element is over there
597 return True
598 for o in cur:
599 parent = find_labelled_parent(o, elm)
600 if parent == True:
601 # target element is over there, check ourself
602 if is_labelled_parent(cur):
603 # yes, and we are the first ancestor of the target element
604 return cur
605 else:
606 # no, but target element is over there.
607 return True
608 if parent != None:
609 # the first ancestor of the target element was over there
610 return parent
611 return None
612 parent = find_labelled_parent(root, elm)
613 if parent == True:
614 parent = None
615 return parent
617 def is_orphan_label(filename, tree, root, obj, orphan_root, doprint = False):
619 Check whether this label has no accessibility relation, or doubtful relation
620 because another label labels the same target
622 global label_for_elm, labelled_by_elm, mnemonic_for_elm, warnexists
624 # label-for
625 label_for = obj.findall("accessibility/relation[@type='label-for']")
626 for rel in label_for:
627 target = rel.attrib['target']
628 l = label_for_elm[target]
629 if len(l) > 1:
630 return True
632 # mnemonic_widget
633 mnemonic_for = obj.findall("property[@name='mnemonic_widget']") + \
634 obj.findall("property[@name='mnemonic-widget']")
635 for rel in mnemonic_for:
636 target = rel.text
637 l = mnemonic_for_elm[target]
638 if len(l) > 1:
639 return True
641 if len(label_for) > 0:
642 # At least one label-for, we are not orphan.
643 return False
645 if len(mnemonic_for) > 0:
646 # At least one mnemonic_widget, we are not orphan.
647 return False
649 labelled_by = obj.findall("accessibility/relation[@type='labelled-by']")
650 if len(labelled_by) > 0:
651 # Oh, a labelled label, probably not to be labelling anything
652 return False
654 # explicit role?
655 roles = [x.text for x in obj.findall("child[@internal-child='accessible']/object[@class='AtkObject']/property[@name='AtkObject::accessible-role']")]
656 roles += [x.attrib.get("type") for x in obj.findall("accessibility/role")]
657 if len(roles) > 1 and doprint:
658 err(filename, tree, obj, "multiple-role", "has multiple <child internal-child='accessible'><object class='AtkObject'><property name='AtkBoject::accessible-role'>"
659 "%s" % elms_lines(children))
660 for role in roles:
661 if role == 'static' or role == 'ATK_ROLE_STATIC':
662 # This is static text, not meant to label anything
663 return False
665 parent = elm_parent(root, obj)
666 if parent is not None:
667 childtype = parent.attrib.get('type')
668 if childtype is None:
669 childtype = parent.attrib.get('internal-child')
670 if parent.tag == 'child' and childtype == 'label' \
671 or childtype == 'tab':
672 # This is a frame or a notebook label, not orphan.
673 return False
675 if find_button_parent(root, obj) is not None:
676 # This label is part of a button
677 return False
679 oid = obj.attrib.get('oid')
680 if oid is not None:
681 if oid in labelled_by_elm:
682 # Some widget is labelled by us, we are not orphan.
683 # We should have had a label-for, will warn about it later.
684 return False
686 # No label-for, no mnemonic-for, no labelled-by, we are orphan.
687 (_, suppr) = elm_suppr(filename, tree, obj, "orphan-label", False)
688 if suppr in false_positives:
689 # That was actually expected
690 return False
691 if suppr in suppressions:
692 # Warning suppressed for this label
693 if suppressions[suppr]:
694 warnexists += 1
695 suppressions[suppr] = False
696 return False
698 if doprint:
699 context = elm_name(orphan_root)
700 if context:
701 context = " within " + context
702 warn(filename, tree, obj, "orphan-label", "does not specify what it labels" + context)
703 return True
705 def is_orphan_widget(filename, tree, root, obj, orphan, orphan_root, doprint = False):
707 Check whether this widget has no accessibility relation.
709 global warnexists
710 if obj.tag != 'object':
711 return False
713 oid = obj.attrib.get('id')
714 klass = obj.attrib.get('class')
716 # "Don't care" special case
717 if klass in widgets_ignored:
718 return False
719 for suffix in widgets_suffixignored:
720 if klass[-len(suffix):] == suffix:
721 return False
723 # Widgets usual do not strictly require a label, i.e. a labelled parent
724 # is enough for context, but some do always need one.
725 requires_label = klass in widgets_needlabel
727 labelled_by = obj.findall("accessibility/relation[@type='labelled-by']")
729 # Labels special case
730 if klass in widgets_labels:
731 return False
733 # Case 1: has an explicit <child internal-child="accessible"> sub-element
734 children = obj.findall("child[@internal-child='accessible']")
735 if len(children) > 1 and doprint:
736 err(filename, tree, obj, "multiple-accessible", "has multiple <child internal-child='accessible'>"
737 "%s" % elms_lines(children))
738 if len(children) >= 1:
739 return False
741 # Case 2: has an <accessibility> sub-element with a "labelled-by"
742 # <relation> pointing to an existing element.
743 if len(labelled_by) > 0:
744 return False
746 # Case 3: has a label-for
747 if oid in label_for_elm:
748 return False
750 # Case 4: has a mnemonic
751 if oid in mnemonic_for_elm:
752 return False
754 # Case 5: Has a <property name="tooltip_text">
755 tooltips = obj.findall("property[@name='tooltip_text']") + \
756 obj.findall("property[@name='tooltip-text']")
757 if len(tooltips) > 1 and doprint:
758 err(filename, tree, obj, "multiple-tooltip", "has multiple tooltip_text properties")
759 if len(tooltips) >= 1 and klass != 'GtkCheckButton':
760 return False
762 # Case 6: Has a <property name="placeholder_text">
763 placeholders = obj.findall("property[@name='placeholder_text']") + \
764 obj.findall("property[@name='placeholder-text']")
765 if len(placeholders) > 1 and doprint:
766 err(filename, tree, obj, "multiple-placeholder", "has multiple placeholder_text properties")
767 if len(placeholders) >= 1:
768 return False
770 # Buttons usually don't need an external label, their own is enough, (but they do need one)
771 if klass in widgets_buttons:
773 labels = obj.findall("property[@name='label']")
774 if len(labels) > 1 and doprint:
775 err(filename, tree, obj, "multiple-label", "has multiple label properties")
776 if len(labels) >= 1:
777 # Has a <property name="label">
778 return False
780 actions = obj.findall("property[@name='action_name']")
781 if len(actions) > 1 and doprint:
782 err(filename, tree, obj, "multiple-action_name", "has multiple action_name properties")
783 if len(actions) >= 1:
784 # Has a <property name="action_name">
785 return False
787 gtklabels = obj.findall(".//object[@class='GtkLabel']") + obj.findall(".//object[@class='GtkAccelLabel']")
788 if len(gtklabels) >= 1:
789 # Has a custom label
790 return False
792 # no label for a button, warn
793 if doprint:
794 warn(filename, tree, obj, "button-no-label", "does not have its own label");
795 if not is_enabled(obj, "button-no-label", enables, True):
796 # Warnings disabled
797 return False
798 (_, suppr) = elm_suppr(filename, tree, obj, "button-no-label", False)
799 if suppr in false_positives:
800 # That was actually expected
801 return False
802 if suppr in suppressions:
803 # Warning suppressed for this widget
804 if suppressions[suppr]:
805 warnexists += 1
806 suppressions[suppr] = False
807 return False
808 return True
810 # GtkImages special case
811 if klass == "GtkImage":
812 uses = [u for u in tree.iterfind(".//object/property[@name='image']") if u.text == oid]
813 if len(uses) > 0:
814 # This image is just used by another element, don't warn
815 # about the image itself, we probably want the warning on
816 # the element instead.
817 return False
819 if find_button_parent(root, obj) is not None:
820 # This image is part of a button, we want the warning on the button
821 # instead, if any.
822 return False
824 # GtkEntry special case
825 if klass == 'GtkEntry' or klass == 'GtkSearchEntry':
826 parent = elm_parent(root, obj)
827 if parent is not None:
828 if parent.tag == 'child' and \
829 parent.attrib.get('internal-child') == "entry":
830 # This is an internal entry of another widget. Relations
831 # will be handled by that widget.
832 return False
834 # GtkShortcutsShortcut special case
835 if klass == 'GtkShortcutsShortcut':
836 children = obj.findall("property[@name='title']")
837 if len(children) >= 1:
838 return False
841 # Really no label, perhaps emit a warning
842 if not is_enabled(obj, "no-labelled-by", enables, True):
843 # Warnings disabled for this class of widgets
844 return False
845 (_, suppr) = elm_suppr(filename, tree, obj, "no-labelled-by", False)
846 if suppr in false_positives:
847 # That was actually expected
848 return False
849 if suppr in suppressions:
850 # Warning suppressed for this widget
851 if suppressions[suppr]:
852 warnexists += 1
853 suppressions[suppr] = False
854 return False
856 if not orphan:
857 # No orphan label, so probably the labelled parent provides enough
858 # context.
859 if requires_label:
860 # But these always need a label.
861 if doprint:
862 warn(filename, tree, obj, "no-labelled-by", "has no accessibility label")
863 return True
864 return False
866 if doprint:
867 context = elm_name(orphan_root)
868 if context:
869 context = " within " + context
870 warn(filename, tree, obj, "no-labelled-by", "has no accessibility label while there are orphan labels" + context)
871 return True
873 def orphan_items(filename, tree, root, elm):
875 Check whether from some element there exists orphan labels and orphan widgets
877 orphan_labels = False
878 orphan_widgets = False
879 if elm.attrib.get('class') in widgets_labels:
880 orphan_labels = is_orphan_label(filename, tree, root, elm, None)
881 else:
882 orphan_widgets = is_orphan_widget(filename, tree, root, elm, True, None)
883 for obj in elm:
884 # We are not interested in orphan labels under another labelled
885 # parent. This also allows to keep linear complexity.
886 if not is_labelled_parent(obj):
887 label, widget = orphan_items(filename, tree, root, obj)
888 if label:
889 orphan_labels = True
890 if widget:
891 orphan_widgets = True
892 if orphan_labels and orphan_widgets:
893 # No need to look up more
894 break
895 return orphan_labels, orphan_widgets
898 # UI accessibility checks
901 def check_props(filename, tree, root, elm, forward):
903 Check the given list of relation properties
905 props = elm.findall("property[@name='" + forward + "']")
906 for prop in props:
907 if prop.text not in ids:
908 err(filename, tree, elm, "undeclared-target", forward + " uses undeclared target '%s'" % prop.text)
909 return props
911 def check_rels(filename, tree, root, elm, forward, backward = None):
913 Check the relations given by forward
915 oid = elm.attrib.get('id')
916 rels = elm.findall("accessibility/relation[@type='" + forward + "']")
917 for rel in rels:
918 target = rel.attrib['target']
919 if target not in ids:
920 err(filename, tree, elm, "undeclared-target", forward + " uses undeclared target '%s'" % target)
921 elif backward is not None:
922 widget = ids[target]
923 backrels = widget.findall("accessibility/relation[@type='" + backward + "']")
924 if len([x for x in backrels if x.attrib['target'] == oid]) == 0:
925 err(filename, tree, elm, "missing-" + backward, "has " + forward + \
926 ", but is not " + backward + " by " + elm_name_line(widget))
927 return rels
929 def check_a11y_relation(filename, tree):
931 Emit an error message if any of the 'object' elements of the XML
932 document represented by `root' doesn't comply with Accessibility
933 rules.
935 global widgets_ignored, ids, label_for_elm, labelled_by_elm, mnemonic_for_elm
937 def check_elm(orphan_root, obj, orphan_labels, orphan_widgets):
939 Check one element, knowing that orphan_labels/widgets tell whether
940 there are orphan labels and widgets within orphan_root
943 oid = obj.attrib.get('id')
944 klass = obj.attrib.get('class')
946 # "Don't care" special case
947 if klass in widgets_ignored:
948 return
949 for suffix in widgets_suffixignored:
950 if klass[-len(suffix):] == suffix:
951 return
953 # Widgets usual do not strictly require a label, i.e. a labelled parent
954 # is enough for context, but some do always need one.
955 requires_label = klass in widgets_needlabel
957 if oid is not None:
958 # Check that ids are unique
959 if oid in ids_dup:
960 if ids[oid] == obj:
961 # We are the first, warn
962 duplicates = tree.findall(".//object[@id='" + oid + "']")
963 err(filename, tree, obj, "duplicate-id", "has the same id as other elements " + elms_names_lines(duplicates))
965 # Check label-for and their dual labelled-by
966 label_for = check_rels(filename, tree, root, obj, "label-for", "labelled-by")
968 # Check labelled-by and its dual label-for
969 labelled_by = check_rels(filename, tree, root, obj, "labelled-by", "label-for")
971 # Should have only one label
972 if len(labelled_by) >= 1:
973 if oid in mnemonic_for_elm:
974 warn(filename, tree, obj, "labelled-by-and-mnemonic",
975 "has both a mnemonic " + elm_name_line(mnemonic_for_elm[oid][0]) + "and labelled-by relation")
976 if len(labelled_by) > 1:
977 warn(filename, tree, obj, "multiple-labelled-by", "has multiple labelled-by relations")
978 if oid in label_for_elm:
979 if len(label_for_elm[oid]) > 1:
980 warn(filename, tree, obj, "duplicate-label-for", "is referenced by multiple label-for " + elms_names_lines(label_for_elm[oid]))
981 if oid in mnemonic_for_elm:
982 if len(mnemonic_for_elm[oid]) > 1:
983 warn(filename, tree, obj, "duplicate-mnemonic", "is referenced by multiple mnemonic_widget " + elms_names_lines(mnemonic_for_elm[oid]))
985 # Check member-of
986 member_of = check_rels(filename, tree, root, obj, "member-of")
988 # Labels special case
989 if klass in widgets_labels:
990 properties = check_props(filename, tree, root, obj, "mnemonic_widget") + \
991 check_props(filename, tree, root, obj, "mnemonic-widget")
992 if len(properties) > 1:
993 err(filename, tree, obj, "multiple-mnemonic", "has multiple mnemonic_widgets properties"
994 "%s" % elms_lines(properties))
996 # Emit orphaning warnings
997 if warn_orphan_labels or orphan_widgets:
998 is_orphan_label(filename, tree, root, obj, orphan_root, True)
1000 # We are done with the label
1001 return
1003 # Not a label, will perhaps need one
1005 # Emit orphaning warnings
1006 is_orphan_widget(filename, tree, root, obj, orphan_labels, orphan_root, True)
1008 root = tree.getroot()
1010 # Flush ids and relations from previous files
1011 ids = {}
1012 ids_dup = {}
1013 labelled_by_elm = {}
1014 label_for_elm = {}
1015 mnemonic_for_elm = {}
1017 # First pass to get links into hash tables, no warning, just record duplicates
1018 for obj in root.iter('object'):
1019 oid = obj.attrib.get('id')
1020 if oid is not None:
1021 if oid not in ids:
1022 ids[oid] = obj
1023 else:
1024 ids_dup[oid] = True
1026 labelled_by = obj.findall("accessibility/relation[@type='labelled-by']")
1027 for rel in labelled_by:
1028 target = rel.attrib.get('target')
1029 if target is not None:
1030 if target not in labelled_by_elm:
1031 labelled_by_elm[target] = [ obj ]
1032 else:
1033 labelled_by_elm[target].append(obj)
1035 label_for = obj.findall("accessibility/relation[@type='label-for']")
1036 for rel in label_for:
1037 target = rel.attrib.get('target')
1038 if target is not None:
1039 if target not in label_for_elm:
1040 label_for_elm[target] = [ obj ]
1041 else:
1042 label_for_elm[target].append(obj)
1044 mnemonic_for = obj.findall("property[@name='mnemonic_widget']") + \
1045 obj.findall("property[@name='mnemonic-widget']")
1046 for rel in mnemonic_for:
1047 target = rel.text
1048 if target is not None:
1049 if target not in mnemonic_for_elm:
1050 mnemonic_for_elm[target] = [ obj ]
1051 else:
1052 mnemonic_for_elm[target].append(obj)
1054 # Second pass, recursive depth-first, to be able to efficiently know whether
1055 # there are orphan labels within a part of the tree.
1056 def recurse(orphan_root, obj, orphan_labels, orphan_widgets):
1057 if obj == root or is_labelled_parent(obj):
1058 orphan_root = obj
1059 orphan_labels, orphan_widgets = orphan_items(filename, tree, root, obj)
1061 if obj.tag == 'object':
1062 check_elm(orphan_root, obj, orphan_labels, orphan_widgets)
1064 for o in obj:
1065 recurse(orphan_root, o, orphan_labels, orphan_widgets)
1067 recurse(root, root, False, False)
1070 # Main
1073 def usage(fatal = True):
1074 print("`%s' checks accessibility of glade .ui files" % progname)
1075 print("")
1076 print("Usage: %s [-p] [-g SUPPR_FILE] [-s SUPPR_FILE] [-f SUPPR_FILE] [-P PREFIX] [-o LOG_FILE] [file ...]" % progname)
1077 print("")
1078 print(" -p Print XML class path instead of line number")
1079 print(" -g Generate suppression file SUPPR_FILE")
1080 print(" -s Suppress warnings given by file SUPPR_FILE, but count them")
1081 print(" -f Suppress warnings given by file SUPPR_FILE completely")
1082 print(" -P Remove PREFIX from file names in warnings")
1083 print(" -o Also prints errors and warnings to given file")
1084 print("")
1085 print(" --widgets-FOO [+][CLASS1[,CLASS2[,...]]]")
1086 print(" Give or extend one of the lists of widget classes, where FOO can be:")
1087 print(" - toplevel : widgets to be considered toplevel windows")
1088 print(" - ignored : widgets which do not need labelling (e.g. GtkBox)")
1089 print(" - suffixignored : suffixes of widget classes which do not need labelling")
1090 print(" - needlabel : widgets which always need labelling (e.g. GtkEntry)")
1091 print(" - buttons : widgets which need their own label but not more")
1092 print(" (e.g. GtkButton)")
1093 print(" - labels : widgets which provide labels (e.g. GtkLabel)")
1094 print(" --widgets-print print default widgets lists")
1095 print("")
1096 print(" --enable-all enable all warnings/dofatals (default)")
1097 print(" --disable-all disable all warnings/dofatals")
1098 print(" --fatal-all make all warnings dofatals")
1099 print(" --not-fatal-all do not make all warnings dofatals (default)")
1100 print("")
1101 print(" --enable-type=TYPE enable warning/fatal type TYPE")
1102 print(" --disable-type=TYPE disable warning/fatal type TYPE")
1103 print(" --fatal-type=TYPE make warning type TYPE an fatal")
1104 print(" --not-fatal-type=TYPE make warning type TYPE not an fatal")
1105 print("")
1106 print(" --enable-widgets=CLASS enable warning/fatal type CLASS")
1107 print(" --disable-widgets=CLASS disable warning/fatal type CLASS")
1108 print(" --fatal-widgets=CLASS make warning type CLASS an fatal")
1109 print(" --not-fatal-widgets=CLASS make warning type CLASS not an fatal")
1110 print("")
1111 print(" --enable-specific=TYPE.CLASS enable warning/fatal type TYPE for widget")
1112 print(" class CLASS")
1113 print(" --disable-specific=TYPE.CLASS disable warning/fatal type TYPE for widget")
1114 print(" class CLASS")
1115 print(" --fatal-specific=TYPE.CLASS make warning type TYPE an fatal for widget")
1116 print(" class CLASS")
1117 print(" --not-fatal-specific=TYPE.CLASS make warning type TYPE not an fatal for widget")
1118 print(" class CLASS")
1119 print("")
1120 print(" --disable-orphan-labels only warn about orphan labels when there are")
1121 print(" orphan widgets in the same context")
1122 print("")
1123 print("Report bugs to <bugs@hypra.fr>")
1124 sys.exit(2 if fatal else 0)
1126 def widgets_opt(widgets_list, arg):
1128 Replace or extend `widgets_list' with the list of classes contained in `arg'
1130 append = arg and arg[0] == '+'
1131 if append:
1132 arg = arg[1:]
1134 if arg:
1135 widgets = arg.split(',')
1136 else:
1137 widgets = []
1139 if not append:
1140 del widgets_list[:]
1142 widgets_list.extend(widgets)
1145 def main():
1146 global pflag, gen_suppr, gen_supprfile, suppressions, suppr_prefix, false_positives, dofatals, enables, dofatals, warn_orphan_labels
1147 global widgets_toplevel, widgets_ignored, widgets_suffixignored, widgets_needlabel, widgets_buttons, widgets_labels
1148 global outfile
1150 try:
1151 opts, args = getopt.getopt(sys.argv[1:], "hpiIg:s:f:P:o:L:", [
1152 "help",
1153 "version",
1155 "widgets-toplevel=",
1156 "widgets-ignored=",
1157 "widgets-suffixignored=",
1158 "widgets-needlabel=",
1159 "widgets-buttons=",
1160 "widgets-labels=",
1161 "widgets-print",
1163 "enable-all",
1164 "disable-all",
1165 "fatal-all",
1166 "not-fatal-all",
1168 "enable-type=",
1169 "disable-type=",
1170 "fatal-type=",
1171 "not-fatal-type=",
1173 "enable-widgets=",
1174 "disable-widgets=",
1175 "fatal-widgets=",
1176 "not-fatal-widgets=",
1178 "enable-specific=",
1179 "disable-specific=",
1180 "fatal-specific=",
1181 "not-fatal-specific=",
1183 "disable-orphan-labels",
1185 except getopt.GetoptError:
1186 usage()
1188 suppr = None
1189 false = None
1190 out = None
1191 filelist = None
1193 for o, a in opts:
1194 if o == "--help" or o == "-h":
1195 usage(False)
1196 if o == "--version":
1197 print("0.1")
1198 sys.exit(0)
1199 elif o == "-p":
1200 pflag = True
1201 elif o == "-g":
1202 gen_suppr = a
1203 elif o == "-s":
1204 suppr = a
1205 elif o == "-f":
1206 false = a
1207 elif o == "-P":
1208 suppr_prefix = a
1209 elif o == "-o":
1210 out = a
1211 elif o == "-L":
1212 filelist = a
1214 elif o == "--widgets-toplevel":
1215 widgets_opt(widgets_toplevel, a)
1216 elif o == "--widgets-ignored":
1217 widgets_opt(widgets_ignored, a)
1218 elif o == "--widgets-suffixignored":
1219 widgets_opt(widgets_suffixignored, a)
1220 elif o == "--widgets-needlabel":
1221 widgets_opt(widgets_needlabel, a)
1222 elif o == "--widgets-buttons":
1223 widgets_opt(widgets_buttons, a)
1224 elif o == "--widgets-labels":
1225 widgets_opt(widgets_labels, a)
1226 elif o == "--widgets-print":
1227 print("--widgets-toplevel '" + ','.join(widgets_toplevel) + "'")
1228 print("--widgets-ignored '" + ','.join(widgets_ignored) + "'")
1229 print("--widgets-suffixignored '" + ','.join(widgets_suffixignored) + "'")
1230 print("--widgets-needlabel '" + ','.join(widgets_needlabel) + "'")
1231 print("--widgets-buttons '" + ','.join(widgets_buttons) + "'")
1232 print("--widgets-labels '" + ','.join(widgets_labels) + "'")
1233 sys.exit(0)
1235 elif o == '--enable-all':
1236 enables.append( (True, None, None) )
1237 elif o == '--disable-all':
1238 enables.append( (False, None, None) )
1239 elif o == '--fatal-all':
1240 dofatals.append( (True, None, None) )
1241 elif o == '--not-fatal-all':
1242 dofatals.append( (False, None, None) )
1244 elif o == '--enable-type':
1245 enables.append( (True, a, None) )
1246 elif o == '--disable-type':
1247 enables.append( (False, a, None) )
1248 elif o == '--fatal-type':
1249 dofatals.append( (True, a, None) )
1250 elif o == '--not-fatal-type':
1251 dofatals.append( (False, a, None) )
1253 elif o == '--enable-widgets':
1254 enables.append( (True, None, a) )
1255 elif o == '--disable-widgets':
1256 enables.append( (False, None, a) )
1257 elif o == '--fatal-widgets':
1258 dofatals.append( (True, None, a) )
1259 elif o == '--not-fatal-widgets':
1260 dofatals.append( (False, None, a) )
1262 elif o == '--enable-specific':
1263 (thetype, klass) = a.split('.', 1)
1264 enables.append( (True, thetype, klass) )
1265 elif o == '--disable-specific':
1266 (thetype, klass) = a.split('.', 1)
1267 enables.append( (False, thetype, klass) )
1268 elif o == '--fatal-specific':
1269 (thetype, klass) = a.split('.', 1)
1270 dofatals.append( (True, thetype, klass) )
1271 elif o == '--not-fatal-specific':
1272 (thetype, klass) = a.split('.', 1)
1273 dofatals.append( (False, thetype, klass) )
1275 elif o == '--disable-orphan-labels':
1276 warn_orphan_labels = False
1278 # Read suppression file before overwriting it
1279 if suppr is not None:
1280 try:
1281 supprfile = open(suppr, 'r')
1282 for line in supprfile.readlines():
1283 prefix = line.rstrip()
1284 suppressions[prefix] = True
1285 supprfile.close()
1286 except IOError:
1287 pass
1289 # Read false positives file
1290 if false is not None:
1291 try:
1292 falsefile = open(false, 'r')
1293 for line in falsefile.readlines():
1294 prefix = line.rstrip()
1295 false_positives[prefix] = True
1296 falsefile.close()
1297 except IOError:
1298 pass
1300 if out is not None:
1301 outfile = open(out, 'w')
1303 if filelist is not None:
1304 try:
1305 filelistfile = open(filelist, 'r')
1306 for line in filelistfile.readlines():
1307 line = line.strip()
1308 if line:
1309 args += line.split(' ')
1310 filelistfile.close()
1311 except IOError:
1312 err(filelist, None, None, "unable to read file list file")
1314 for filename in args:
1315 try:
1316 tree = ET.parse(filename)
1317 except ET.ParseError:
1318 err(filename, None, None, "parse", "malformatted xml file")
1319 continue
1320 except IOError:
1321 err(filename, None, None, None, "unable to read file")
1322 continue
1324 try:
1325 check_a11y_relation(filename, tree)
1326 except Exception as error:
1327 import traceback
1328 traceback.print_exc()
1329 err(filename, None, None, "parse", "error parsing file")
1331 if errors > 0 or errexists > 0:
1332 estr = "%s new error%s" % (errors, 's' if errors > 1 else '')
1333 if errexists > 0:
1334 estr += " (%s suppressed by %s)" % (errexists, suppr)
1335 print(estr)
1337 if warnings > 0 or warnexists > 0:
1338 wstr = "%s new warning%s" % (warnings, 's' if warnings > 1 else '')
1339 if warnexists > 0:
1340 wstr += " (%s suppressed by %s)" % (warnexists, suppr)
1341 print(wstr)
1343 if fatals > 0 or fatalexists > 0:
1344 wstr = "%s new fatal%s" % (fatals, 's' if fatals > 1 else '')
1345 if fatalexists > 0:
1346 wstr += " (%s suppressed by %s)" % (fatalexists, suppr)
1347 print(wstr)
1349 n = 0
1350 for (suppr,unused) in suppressions.items():
1351 if unused:
1352 n += 1
1354 if n > 0:
1355 print("%s suppression%s unused" % (n, 's' if n > 1 else ''))
1357 if gen_supprfile is not None:
1358 gen_supprfile.close()
1359 if outfile is not None:
1360 outfile.close()
1361 if fatals > 0 and gen_suppr is None:
1362 print("Explanations are available on https://wiki.documentfoundation.org/Development/Accessibility")
1363 sys.exit(1)
1366 if __name__ == "__main__":
1367 try:
1368 main()
1369 except KeyboardInterrupt:
1370 pass
1372 # vim: set shiftwidth=4 softtabstop=4 expandtab: