-
Notifications
You must be signed in to change notification settings - Fork 0
/
tabfix.py
executable file
·361 lines (292 loc) · 13.8 KB
/
tabfix.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
# SPDX-License-Identifier: MIT
from lxml.etree import XMLParser, parse
import argparse
import os
import csv
from yaml import load, loader
# We need to use the 'huge' parser as these docs are really big
p = XMLParser(huge_tree=True)
def get_item(tree, dashboard_name, item):
zone = get_view(tree, dashboard_name, item)
if zone is None:
zone = get_button(tree, dashboard_name, item)
if zone is None:
zone = get_parameter(tree, dashboard_name, item)
if zone is None:
zone = get_filter(tree, dashboard_name, item)
if zone is None:
zone = get_text(tree, dashboard_name, item)
if zone is None:
zone = get_image(tree, dashboard_name, item)
if zone is None:
zone = get_highlighter_by_filter(tree, dashboard_name, item)
return zone
def get_parent_tag(item, tag):
while item is not None:
if item.tag == tag:
return item
item = item.getparent()
def get_parent_worksheet(item):
return get_parent_tag(item, 'worksheet')
def get_parent_dashboard(item):
return get_parent_tag(item, 'dashboard')
def get_parent_dashboard_name(item):
return get_parent_dashboard(item).get("name")
def get_parent_zone(item):
return get_parent_tag(item, 'zone')
def get_image(tree, dashboard_name, path):
match = "substring(@param, string-length(@param) - string-length('"+path+"') + 1) = '"+path+"'"
query = ".//dashboard[@name='" + dashboard_name + "']//zone[(@_.fcp.SetMembershipControl.false...type='bitmap' or @type='bitmap') and "+match+"]"
image = tree.xpath(query)
if image is not None and image.__len__() > 0:
zone = image[0]
return zone
return None
def get_text(tree, dashboard_name, text):
view = tree.xpath("//dashboard[@name='" + dashboard_name + "']//run[text()='" + text + "']")
if view is not None and view.__len__() > 0:
zone = get_parent_zone(view[0])
return zone
return None
def get_view(tree, dashboard_name, viewname):
view = tree.xpath("//dashboard[@name='" + dashboard_name + "']//zone[@name='" + viewname + "' and not(@param)]")
if view is not None and view.__len__() > 0:
zone = view[0]
return zone
return None
def get_button(tree, dashboard_name, caption):
button = tree.xpath("//dashboard[@name='" + dashboard_name + "']//zone//button//caption[text()='" + caption + "']")
if button is not None and button.__len__() > 0:
zone = get_parent_zone(button[0])
return zone
return None
def get_highlighter_by_filter(tree, dashboard_name, filter):
if filter.startswith("Highlight "):
filter = filter.split("Highlight ")[1]
highlighters = tree.xpath(".//dashboard[@name='"+dashboard_name+"']//zone[@type='highlighter' and contains(@param, ':" + filter + ":')]")
if highlighters is not None and highlighters.__len__() > 0:
return highlighters[0]
highlighters = tree.xpath(".//dashboard[@name='"+dashboard_name+"']//zone[@_.fcp.SetMembershipControl.true...type-v2='highlighter' and contains(@param, ':" + filter + ":')]")
if highlighters is not None and highlighters.__len__() > 0:
return highlighters[0]
return None
def get_parameter_by_alias(tree, alias):
# column caption='The First Parameter' datatype='string' name='[Parameter 1]'
reference = tree.xpath("//column[@caption='" + alias + "' and starts-with(@name, '[Parameter ')]")
if reference is not None and reference.__len__() > 0:
return reference[0].get("name")
return None
def get_parameter(tree, dashboard_name, parameter):
param = '[Parameters].['+parameter+']'
parameters = tree.xpath(".//dashboard[@name='"+dashboard_name+"']//zone[@param='" + param + "']")
if parameters is not None and parameters.__len__() > 0:
zone = parameters[0]
return zone
# Parameter with a custom title
parameters = tree.xpath(".//dashboard[@name='"+dashboard_name+"']//zone[@type='paramctrl']/formatted-text/run[text()='"+parameter+"']")
if parameters is not None and parameters.__len__() > 0:
zone = get_parent_zone(parameters[0])
return zone
# Parameter with a custom title for later versions
parameters = tree.xpath(".//dashboard[@name='"+dashboard_name+"']//zone[@_.fcp.SetMembershipControl.true...type-v2='paramctrl'='paramctrl']/formatted-text/run[text()='"+parameter+"']")
if parameters is not None and parameters.__len__() > 0:
zone = get_parent_zone(parameters[0])
return zone
# Parameters with aliases
reference = get_parameter_by_alias(tree, parameter)
if reference is not None:
param = '[Parameters].' + reference
parameters = tree.xpath(".//dashboard[@name='" + dashboard_name + "']//zone[@param='" + param + "']")
if parameters is not None and parameters.__len__() > 0:
zone = parameters[0]
return zone
return None
def get_filter_by_alias(tree, alias):
reference = tree.xpath("//column[@caption='" + alias + "']")
if reference is not None and reference.__len__() > 0:
name = reference[0].get("name")
if name.startswith('[') and name.endswith(']'):
name = name[1:-1]
return name
return None
def get_filter(tree, dashboard_name, filter):
filter_search = ':'+filter+':'
parameter = tree.xpath(".//dashboard[@name='"+dashboard_name+"']//zone[contains(@param, '" + filter_search + "')]")
if parameter is not None and parameter.__len__() > 0:
zone = parameter[0]
return zone
reference = get_filter_by_alias(tree, filter)
if reference is not None:
filter_search = ':'+reference+':'
filters = tree.xpath(".//dashboard[@name='"+dashboard_name+"']//zone[contains(@param, '" + filter_search + "')]")
if filters is not None and filters.__len__() > 0:
zone = filters[0]
return zone
return None
def check_accessibility(input_filename):
with open(input_filename, 'r', encoding='utf-8') as f: # open in readonly mode
tree = parse(f, parser=p)
warnings = []
warnings = warnings + check_alt_text(tree)
warnings = warnings + check_titles_and_captions(tree)
warnings = warnings + check_vertical_text(tree)
warnings = warnings + check_mark_labels(tree)
return warnings
def check_mark_labels(tree):
formats = tree.xpath("//worksheet[not(.//format[@attr='mark-labels-show'])]")
warnings = []
for format in formats:
sheet = get_parent_tag(format, 'worksheet').get("name")
zones = tree.xpath("//zone[@name='"+sheet+"']")
for zone in zones:
if zone.get('_.fcp.SetMembershipControl.false...type') is None and zone.get("type") is None:
dashboard_name = get_parent_dashboard_name(zone)
warnings.append(
{
"code": "B3",
"dashboard": dashboard_name,
"item": sheet,
"message": "B3 no mark labels for view '"+sheet+"' in dashboard '"+dashboard_name+"'"
})
return warnings
def check_vertical_text(tree):
formats = tree.xpath("//format[@attr='text-orientation' and (@value='-90' or @value='90')]")
warnings = []
for format in formats:
sheet = get_parent_tag(format, 'worksheet').get("name")
zones = tree.xpath("//zone[@name='"+sheet+"']")
for zone in zones:
if zone.get('_.fcp.SetMembershipControl.false...type') is None and zone.get("type") is None:
dashboard_name = get_parent_dashboard_name(zone)
warnings.append(
{
"code": "B1",
"dashboard": dashboard_name,
"item": sheet,
"message": "B3 text is rotated for view '"+sheet+"' in dashboard '"+dashboard_name+"'"
})
return warnings
def check_alt_text(tree):
warnings = []
images = tree.xpath("//zone[@_.fcp.SetMembershipControl.false...type='bitmap' or @type='bitmap']")
for image in images:
if not image.get("alt-text") or image.get("alt-text") == '':
dashboard_name = get_parent_dashboard_name(image)
short_name = image.get("param").split("/")[-1]
warnings.append(
{
"code": "A4",
"dashboard": dashboard_name,
"item": image.get("param"),
"message": "A4 image '"+short_name+"' with missing alternative text in dashboard '" + dashboard_name + "'"
})
return warnings
def check_titles_and_captions(tree):
warnings = []
zones = tree.xpath("//zone[@name]")
for zone in zones:
if zone.get('_.fcp.SetMembershipControl.false...type') is None and zone.get("type") is None:
dashboard_name = get_parent_dashboard_name(zone)
item = zone.get('name')
if zone.get('show-title') and zone.get('show-title') == 'false':
warnings.append(
{
"code": "A5",
"dashboard": dashboard_name,
"item": item,
"message": "A5 Object '" + item + "' in dashboard '" + dashboard_name + "' has no title"
})
if not zone.get('show-caption') or zone.get('show-caption') == 'false':
warnings.append(
{
"code": "A6",
"dashboard": dashboard_name,
"item": item,
"message": "A6 Object '" + item + "' in dashboard '" + dashboard_name + "' has no caption"
})
return warnings
def load_manifest(manifest_path):
with open(manifest_path, 'r') as file:
configuration = load(file, Loader=loader.SafeLoader)
return configuration
def fix_tabs(input_filename, output_filename, configuration):
with open(input_filename, 'r', encoding='utf-8') as f: # open in readonly mode
tree = parse(f, parser=p)
tree = fix_tabs_in_tree(tree, configuration)
tree.write(output_filename, encoding='utf-8')
def fix_tabs_in_tree(tree, configuration):
zone_id = 0
for dashboard_name in configuration:
tab_order = configuration[dashboard_name]
zone_id += 100
# Start providing IDs for the named items
for item in tab_order:
zone = get_item(tree, dashboard_name, item)
if zone is not None:
zone.set("id", zone_id.__str__())
zone.set("is-modified", '1')
zone_id += 1
else:
print("ERROR in manifest: object '"+item+"' does not exist in dashboard '"+dashboard_name+"'")
# Add IDs to everything else in document order
zones = tree.xpath('//dashboard[@name="' + dashboard_name + '"]//zone')
for zone in zones:
if not zone.get("is-modified"):
zone.set("id", zone_id.__str__())
zone_id += 1
dashboard = tree.find("//dashboard[@name='"+dashboard_name+"']")
# TODO handle device layouts properly
# Duplicate IDs in device layouts cause problems so get rid of them...
layouts = dashboard.find("devicelayouts")
if layouts is not None:
dashboard.remove(layouts)
return tree
if __name__ == "__main__":
argparser = argparse.ArgumentParser(description='Accessibility testing and tab focus order fixer for Tableau.')
argparser.add_argument('input_path', metavar='<input>', type=str, nargs=1, default='testing.twb',
help='The name of the Tableau file to process')
argparser.add_argument('output_path', metavar='<output>', type=str, nargs='?', default='output.twb',
help='The output file name')
argparser.add_argument('manifest_path', metavar='<manifest>', type=str, nargs='?', default='manifest.txt',
help='The manifest file')
argparser.add_argument('-t', action='store_true',
help='Just check for issues without modifying focus order')
argparser.add_argument('-c', action='store_true',
help='Output results in CSV format')
args = argparser.parse_args()
# Defaults
input_path = vars(args)['input_path'][0]
output_path = vars(args)['output_path']
manifest_path = vars(args)['manifest_path']
check_only = vars(args)['t']
csv_output = vars(args)['c']
if not os.path.exists(input_path):
print('Input workbook does not exist')
exit()
else:
print("Input workbook: "+input_path)
if check_only:
print("Only checking for issues, will not create output")
else:
print("Modifying tab order and checking issues")
print("Output workbook: " + output_path)
if not os.path.exists(manifest_path) and not check_only:
print('Manifest does not exist')
exit()
else:
print("Manifest: " + manifest_path)
# Load the configuration/manifest
configuration = load_manifest(manifest_path)
fix_tabs(input_path, output_path, configuration)
warnings = check_accessibility(input_path)
for warning in warnings:
print(warning.get("message"))
print("Note that this tool cannot check for a number of common accessibility issues (codes A1, A2, A3, A7, B2, "
"B4, B5, B6) and you should check these using other methods.")
if csv_output:
print("Saving a report of issues found in accessibility_report.csv")
field_names = warnings[0].keys()
with open('accessibility_report.csv', 'w', newline='') as csvFile:
writer = csv.DictWriter(csvFile, fieldnames=field_names)
writer.writeheader()
writer.writerows(warnings)