File Coverage

blib/lib/Test/XHTML/WAI.pm
Criterion Covered Total %
statement 24 213 11.2
branch 0 116 0.0
condition 0 56 0.0
subroutine 8 34 23.5
pod 7 7 100.0
total 39 426 9.1


line stmt bran cond sub pod time code
1             package Test::XHTML::WAI;
2              
3 2     2   13728 use strict;
  2         4  
  2         81  
4 2     2   12 use warnings;
  2         4  
  2         78  
5              
6 2     2   11 use vars qw($VERSION);
  2         5  
  2         221  
7             $VERSION = '0.12';
8              
9             #----------------------------------------------------------------------------
10              
11             =head1 NAME
12              
13             Test::XHTML::WAI - Basic WAI compliance checks.
14              
15             =head1 SYNOPSIS
16              
17             my $txw = Test::XHTML::WAI->new();
18              
19             $txw->validate($content); # run compliance checks
20             my $results = $txw->results(); # retrieve results
21              
22             $txw->clear(); # clear all current errors and results
23             $txw->errors(); # all current errors reported
24             $txw->errstr(); # basic error message
25              
26             $txw->logfile($file); # logfile for verbose messages
27             $txw->logclean(1); # 1 = overwrite, 0 = append (default)
28              
29             =head1 DESCRIPTION
30              
31             This module attempts to check WAI compliance. Currently only basic checks are
32             implemented, but more comprehensive checks are planned.
33              
34             =cut
35              
36             # -------------------------------------
37             # Library Modules
38              
39 2     2   11 use base qw(Class::Accessor::Fast);
  2         24  
  2         211  
40 2     2   12 use File::Basename;
  2         3  
  2         165  
41 2     2   10 use File::Path;
  2         4  
  2         87  
42 2     2   11 use HTML::TokeParser;
  2         4  
  2         63  
43 2     2   12 use Data::Dumper;
  2         6  
  2         7766  
44              
45             # -------------------------------------
46             # Variables
47              
48             my @RESULTS = qw( PASS FAIL );
49              
50             my $FIXED = $HTML::TokeParser::VERSION >= 3.69 ? 1 : 0;
51              
52             # For a full list of valid W3C DTD types, please see
53             # http://www.w3.org/QA/2002/04/valid-dtd-list.html
54             my %declarations = (
55             'xhtml1-strict.dtd' => 2,
56             'xhtml1-transitional.dtd' => 2,
57             'xhtml1-frameset.dtd' => 2,
58             'html401-strict.dtd' => 1,
59             'html401-loose.dtd' => 1,
60             'html401-frameset.dtd' => 1,
61             );
62              
63             my @TAGS = (
64             # list taken from http://www.w3schools.com/tags/default.asp
65             'a', 'abbr', 'acronym', 'address', 'applet', 'area',
66             'b', 'base', 'basefont', 'bdo', 'big', 'blockquote', 'body', 'br', 'button',
67             'caption', 'center', 'cite', 'code', 'col', 'colgroup',
68             'dd', 'del', 'dfn', 'dir', 'div', 'dl', 'dt',
69             'em',
70             'fieldset', 'font', 'form', 'frame', 'framset',
71             'head', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'html',
72             'i', 'iframe', 'img', 'input', 'ins',
73             'kbd',
74             'label', 'legend', 'li', 'link',
75             'map', 'menu', 'meta',
76             'noframes', 'noscript',
77             'object', 'ol', 'optgroup', 'option',
78             'p', 'param', 'pre',
79             'q',
80             's', 'samp', 'script', 'select', 'small', 'span', 'strike', 'strong', 'style', 'sub',
81             'table', 'tbody', 'td', 'textarea', 'tfoot', 'th', 'thead', 'title', 'tr', 'tt',
82             'u', 'ul',
83             'var',
84              
85             '/form'
86             );
87              
88             # -------------------------------------
89             # Public Methods
90              
91             sub new {
92 0     0 1   my $proto = shift; # get the class name
93 0   0       my $class = ref($proto) || $proto;
94              
95             # private data
96 0           my $self = {level => 1, dtdtype => 0};
97 0           $self->{RESULTS}{$_} = 0 for(@RESULTS);
98              
99 0           bless ($self, $class);
100 0           return $self;
101             }
102              
103             sub DESTROY {
104 0     0     my $self = shift;
105             }
106              
107             __PACKAGE__->mk_accessors(qw( logfile logclean ));
108              
109 0     0 1   sub validate { _process_checks(@_); }
110 0     0 1   sub results { _process_results(@_); }
111              
112 0     0 1   sub clear { my $self = shift; $self->{ERRORS} = undef; $self->_reset_results(); }
  0            
  0            
113 0     0 1   sub errors { my $self = shift; return $self->{ERRORS}; }
  0            
114 0     0 1   sub errstr { my $self = shift; return $self->_print_errors(); }
  0            
115              
116             sub level {
117 0     0 1   my ($self,$level) = @_;
118 0 0 0       $self->{level} = $level if(defined $level && $level =~ /^[123]$/);
119 0           return $self->{level};
120             }
121              
122             # -------------------------------------
123             # Private Methods
124              
125             sub _process_results {
126 0     0     my $self = shift;
127 0           my %results = map {$_ => $self->{RESULTS}{$_}} @RESULTS;
  0            
128 0           $self->_log( sprintf "%8s%d\n", "$_:", $results{$_} ) for(@RESULTS);
129 0           return \%results;
130             }
131              
132             sub _reset_results {
133 0     0     my $self = shift;
134 0           $self->{RESULTS}{$_} = 0 for(@RESULTS);
135             }
136              
137             sub _print_errors {
138 0     0     my $self = shift;
139 0           my $str = "\nErrors:\n" ;
140 0           my $i = 1;
141 0           for my $error (@{$self->{ERRORS}}) {
  0            
142 0           $str .= "$i. $error->{error}: $error->{message}";
143 0 0         $str .= " [$error->{ref}]" if($error->{ref});
144 0 0 0       $str .= " [row $error->{row}, column $error->{col}]" if($error->{row} && $error->{col} && $FIXED);
      0        
145 0           $str .= "\n";
146 0           $i++;
147             }
148 0           return $str;
149             }
150              
151             # -------------------------------------
152             # Subroutines
153              
154             # TODO
155             # (AA) check for absolute rather than relative table cell values
156             # (A) label associated with each input id
157              
158             sub _process_checks {
159 0     0     my $self = shift;
160 0           my $html = shift;
161              
162             # clear data from previous tests.
163 0           $self->{$_} = undef for(qw(input label form links));
164              
165             #push @{ $self->{ERRORS} }, {
166             # error => "debug",
167             # message => "VERSION=$HTML::TokeParser::VERSION, FIXED=$FIXED"
168             #};
169              
170             #use Data::Dumper;
171             #print STDERR "#html=".Dumper($html);
172              
173 0 0         if($html) {
174 0 0         my $p = $FIXED
175             ? HTML::TokeParser->new( \$html,
176             start => "'S',tagname,attr,attrseq,text,line,column",
177             end => "'E',tagname,text,line,column"
178             )
179             : HTML::TokeParser->new( \$html );
180              
181             #print STDERR "#p=".Dumper($p);
182              
183             # determine declaration and the case requirements
184 0           my $token = $p->get_token();
185 0 0 0       if($token && $token->[0] eq 'D') {
186 0           my $declaration = $token->[1];
187 0           $declaration =~ s/\s+/ /sg;
188 0           for my $type (keys %declarations) {
189 0 0         if($declaration =~ /$type/) {
190 0           $self->{dtdtype} = $declarations{$type};
191 0           last;
192             }
193             }
194             } else {
195 0           $p->unget_token($token);
196             }
197              
198 0           while( my $tag = $p->get_tag( @TAGS ) ) {
199              
200             # force lower case
201 0           $tag->[0] = lc $tag->[0];
202              
203 0 0         if($tag->[0] eq 'form') {
    0          
    0          
    0          
    0          
    0          
    0          
    0          
    0          
    0          
    0          
204 0   0       $self->{form} = { id => ($tag->[1]{id} || $tag->[1]{name}) };
205             } elsif($tag->[0] eq '/form') {
206 0           $self->_check_form($tag);
207 0           $self->{form} = undef;
208              
209             } elsif($tag->[0] eq 'input') {
210 0           $self->_check_form_submit($tag);
211 0           $self->_check_form_control($tag);
212             } elsif($tag->[0] =~ /^(select|textarea)$/) {
213 0           $self->_check_form_control($tag);
214             } elsif($tag->[0] eq 'label') {
215 0           $self->_check_label($tag);
216              
217             } elsif($tag->[0] eq 'object') {
218 0           $self->_check_object($tag,$p);
219             } elsif($tag->[0] eq 'img') {
220 0           $self->_check_image($tag);
221             } elsif($tag->[0] eq 'a') {
222 0           $self->_check_link($tag);
223             } elsif($tag->[0] =~ /^(i|b)$/) {
224 0           $self->_check_format($tag);
225              
226             # need to confirm
227             #} elsif($tag->[0] eq 'map') {
228             # $self->_check_title($tag);
229              
230             } elsif($tag->[0] eq 'table') {
231 0           $self->_check_title_summary($tag);
232 0           $self->_check_width($tag);
233 0           $self->_check_height($tag);
234             } elsif($tag->[0] =~ /^(th|td)$/) {
235 0           $self->_check_width($tag);
236 0           $self->_check_height($tag);
237             }
238             }
239              
240 0           $self->_check_labelling();
241              
242             } else {
243 0           push @{ $self->{ERRORS} }, {
  0            
244             #ref => 'Best Practices Recommedation only',
245             error => "missing content",
246             message => 'no XHTML content found'
247             };
248             }
249              
250 0 0         if($self->{ERRORS}) {
251 0           $self->_log( "FAIL\n" );
252 0           $self->{RESULTS}{FAIL}++;
253             } else {
254 0           $self->_log( "PASS\n" );
255 0           $self->{RESULTS}{PASS}++;
256             }
257             }
258              
259             # -------------------------------------
260             # Private Methods : Check Routines
261              
262             sub _check_form {
263 0     0     my ($self,$tag) = @_;
264              
265 0 0         if(!$self->{form}{submit}) {
266 0   0       push @{ $self->{ERRORS} }, {
  0            
267             ref => 'WCAG v2 3.2.2 (A)', #E872
268             error => "W001",
269             message => 'no submit button in form (' . ( $self->{form}{id} || '' ) . ')',
270             row => $tag->[2],
271             col => $tag->[3]
272             };
273             }
274             }
275              
276             sub _check_form_control {
277 0     0     my ($self,$tag) = @_;
278              
279 0 0 0       if($tag->[1]{id}) {
    0          
    0          
280 0 0         if($self->{input}{ $tag->[1]{id} }) {
281 0           push @{ $self->{ERRORS} }, {
  0            
282             ref => 'WCAG v2 4.1.1 (A)', #894
283             error => "W002",
284             message => "all <$tag->[0]> tags require a unique id ($tag->[1]{id})",
285             row => $tag->[4],
286             col => $tag->[5]
287             };
288             } else {
289 0 0         $self->{input}{ $tag->[1]{id} }{type} = ($tag->[0] =~ /select|textarea/ ? $tag->[0] : $tag->[1]{type});
290 0           $self->{input}{ $tag->[1]{id} }{title} = $tag->[1]{title};
291 0           $self->{input}{ $tag->[1]{id} }{row} = $tag->[4];
292 0           $self->{input}{ $tag->[1]{id} }{column} = $tag->[5];
293 0 0 0       $self->{input}{ $tag->[1]{id} }{active} = ($tag->[1]{disabled} || $tag->[1]{readonly} ? 0 : 1);
294             }
295              
296             } elsif($tag->[1]{type} && $tag->[1]{type} =~ /hidden|submit|reset|button/) {
297 0           return;
298              
299             #} elsif($tag->[1]{disabled} || $tag->[1]{readonly}) {
300             # return;
301              
302             } elsif(!$tag->[1]{title}) {
303 0           push @{ $self->{ERRORS} }, {
  0            
304             ref => 'WCAG v2 1.1.1 (A)', #E866
305             error => "W003",
306             message => "all <$tag->[0]> tags require a
307             row => $tag->[4],
308             col => $tag->[5]
309             };
310             }
311             }
312              
313             sub _check_form_submit {
314 0     0     my ($self,$tag) = @_;
315              
316 0 0 0       if($tag->[1]{type} && $tag->[1]{type} eq 'submit') {
317 0 0         if(%{$self->{form}}) {
  0            
318 0           $self->{form}{submit} = 1;
319             } else {
320 0           push @{ $self->{ERRORS} }, {
  0            
321             #ref => 'Best Practices Recommedation only',
322             error => "CW001",
323             message => 'submit button should be associated with a form',
324             row => $tag->[4],
325             col => $tag->[5]
326             };
327             }
328             }
329             }
330              
331             sub _check_label {
332 0     0     my ($self,$tag) = @_;
333              
334 0 0         if($tag->[1]{for}) {
335 0 0         if($self->{label}{ $tag->[1]{for} }) {
336 0           push @{ $self->{ERRORS} }, {
  0            
337             #ref => 'Best Practices Recommedation only',
338             error => "CW002",
339             message => "all <$tag->[0]> tags should reference a unique id ($tag->[1]{for})",
340             row => $tag->[4],
341             col => $tag->[5]
342             };
343             } else {
344 0           $self->{label}{ $tag->[1]{for} }{type} = 'label';
345 0           $self->{label}{ $tag->[1]{for} }{row} = $tag->[4];
346 0           $self->{label}{ $tag->[1]{for} }{column} = $tag->[5];
347             }
348             } else {
349 0           push @{ $self->{ERRORS} }, {
  0            
350             ref => 'WCAG v2 1.3.1 (A)', #885
351             error => "W004",
352             message => "all <$tag->[0]> tags must reference an tag id",
353             row => $tag->[4],
354             col => $tag->[5]
355             };
356             }
357             }
358              
359             sub _check_image {
360 0     0     my ($self,$tag) = @_;
361              
362 0 0         return if(defined $tag->[1]{alt});
363              
364 0           push @{ $self->{ERRORS} }, {
  0            
365             ref => 'WCAG v2 1.1.1 (A)', #E860
366             error => "W005",
367             message => "no alt attribute in <$tag->[0]> tag ($tag->[1]{src})",
368             row => $tag->[4],
369             col => $tag->[5]
370             };
371             }
372              
373             sub _check_link {
374 0     0     my ($self,$tag) = @_;
375              
376 0 0         return unless(defined $tag->[1]{href}); # ignore named anchors
377              
378 0 0         if($tag->[1]{title}) {
379 0 0 0       if($self->{links}{ $tag->[1]{href} } && $self->{links}{ $tag->[1]{href} } ne $tag->[1]{title}) {
380 0           push @{ $self->{ERRORS} }, {
  0            
381             ref => 'WCAG v2 2.4.4 (A)', #E898
382             error => "W006",
383             message => "repeated links should use the same titles ($tag->[1]{href}, '$self->{links}{ $tag->[1]{href} }' => '$tag->[1]{title}')",
384             row => $tag->[4],
385             col => $tag->[5]
386             };
387             } else {
388 0           $self->{links}{ $tag->[1]{href} } = $tag->[1]{title};
389             }
390 0           return;
391             }
392              
393 0           push @{ $self->{ERRORS} }, {
  0            
394             ref => 'WCAG v2 1.1.1 (A)', #E871
395             error => "W007",
396             message => "no title attribute in a tag ($tag->[1]{href}, '$tag->[3]')",
397             row => $tag->[4],
398             col => $tag->[5]
399             };
400             }
401              
402             sub _check_format {
403 0     0     my ($self,$tag) = @_;
404              
405 0           my %formats = (
406             'i' => 'em',
407             'b' => 'strong'
408             );
409              
410 0 0         return unless($formats{$tag->[0]});
411              
412 0           push @{ $self->{ERRORS} }, {
  0            
413             ref => 'WCAG v2 1.3.1 (A)', #E892
414             error => "W008",
415             message => "use CSS for presentation effects, or use <$formats{$tag->[0]}> for emphasis not <$tag->[0]> tag",
416             row => $tag->[4],
417             col => $tag->[5]
418             };
419             }
420              
421             sub _check_title {
422 0     0     my ($self,$tag) = @_;
423              
424 0 0         return if(defined $tag->[1]{title});
425              
426 0           push @{ $self->{ERRORS} }, {
  0            
427             #ref => 'WCAG v2 1.1.1 (A)',
428             error => "W009",
429             message => "no title attribute in <$tag->[0]> tag",
430             row => $tag->[4],
431             col => $tag->[5]
432             };
433             }
434              
435             sub _check_title_summary {
436 0     0     my ($self,$tag) = @_;
437              
438 0 0 0       return if(defined $tag->[1]{title} || defined $tag->[1]{summary});
439              
440 0           push @{ $self->{ERRORS} }, {
  0            
441             ref => 'WCAG v2 1.3.1 (A)', #E879
442             error => "W010",
443             message => "no title or summary attribute in <$tag->[0]> tag",
444             row => $tag->[4],
445             col => $tag->[5]
446             };
447             }
448              
449             sub _check_width {
450 0     0     my ($self,$tag) = @_;
451              
452 0 0         return unless($self->{level} > 1);
453 0 0 0       return unless(defined $tag->[1]{width} && $tag->[1]{width} =~ /^\d+$/);
454              
455 0           push @{ $self->{ERRORS} }, {
  0            
456             ref => 'WCAG v2 1.4.4 (AA)', #E910
457             error => "W011",
458             message => "use relative (or CSS), rather than absolute units for width attribute in <$tag->[0]> tag",
459             row => $tag->[4],
460             col => $tag->[5]
461             };
462             }
463              
464             sub _check_height {
465 0     0     my ($self,$tag) = @_;
466              
467 0 0         return unless($self->{level} > 1);
468 0 0 0       return unless(defined $tag->[1]{height} && $tag->[1]{height} =~ /^\d+$/);
469              
470 0           push @{ $self->{ERRORS} }, {
  0            
471             ref => 'WCAG v2 1.4.4 (AA)', #E910
472             error => "W012",
473             message => "use relative (or CSS), rather than absolute units for height attribute in <$tag->[0]> tag",
474             row => $tag->[4],
475             col => $tag->[5]
476             };
477             }
478              
479             sub _check_object {
480 0     0     my ($self,$tag,$p) = @_;
481              
482             # do we have simple text?
483 0           my $x = $p->get_text();
484 0           $x =~ s/\s+//gs;
485 0 0         return if($x);
486              
487 0           my @token;
488             my $found;
489 0           while( my $t = $p->get_token() ) {
490 0           unshift @token, $t;
491 0 0 0       next unless($t->[0] eq 'S' || $t->[0] eq 'E');
492              
493 0 0 0       if($t->[0] eq 'E' && $t->[1] eq 'object') {
    0 0        
    0 0        
494 0           last;
495             } elsif($t->[0] eq 'S' && $t->[1] eq 'p') {
496 0           $x = $p->get_text();
497 0           $x =~ s/\s+//gs;
498 0 0         $found = 1 if($x);
499             } elsif($t->[0] eq 'S' && $t->[1] eq 'img') {
500 0 0         $found = 1 if($t->[2]{alt});
501             }
502              
503 0 0         last if($found);
504             }
505              
506             # put back tokens
507 0           $p->unget_token($_) for(@token);
508              
509 0 0         return if($found);
510              
511 0           push @{ $self->{ERRORS} }, {
  0            
512             ref => 'WCAG v2 1.1.1 (A)', #E865
513             error => "W013",
514             message => qq{No alternative text (e.g.

or ) found for tag},

515             row => $tag->[4],
516             col => $tag->[5]
517             };
518             }
519              
520             sub _check_labelling {
521 0     0     my ($self) = @_;
522              
523 0           for my $input (keys %{$self->{input}}) {
  0            
524 0 0 0       next if($self->{input}{$input}{type} && $self->{input}{$input}{type} =~ /hidden|submit|reset|button/);
525 0 0         next if($self->{label}{$input});
526 0 0         next if($self->{input}{$input}{title});
527             #next if($self->{input}{$input}{active} == 0);
528              
529 0           push @{ $self->{ERRORS} }, {
  0            
530             ref => 'WCAG v2 1.1.1 (A)', #E866
531             error => "W014",
532             message => "all <$self->{input}{$input}{type}> tags require a unique
533             row => $self->{input}{$input}{row},
534             col => $self->{input}{$input}{column}
535             };
536             }
537              
538 0           for my $input (keys %{$self->{label}}) {
  0            
539 0 0         next if($self->{input}{$input});
540              
541 0           push @{ $self->{ERRORS} }, {
  0            
542             ref => 'WCAG v2 1.3.1 (A)', #E895
543             error => "W015",
544             message => "all
545             row => $self->{label}{$input}{row},
546             col => $self->{label}{$input}{column}
547             };
548             }
549             }
550              
551             # -------------------------------------
552             # Private Methods : Other
553              
554             sub _log {
555 0     0     my $self = shift;
556 0 0         my $log = $self->logfile or return;
557 0 0         mkpath(dirname($log)) unless(-f $log);
558              
559 0 0         my $mode = $self->logclean ? 'w+' : 'a+';
560 0           $self->logclean(0);
561              
562 0 0         my $fh = IO::File->new($log,$mode) or die "Cannot write to log file [$log]: $!\n";
563 0           print $fh @_;
564 0           $fh->close;
565             }
566              
567             1;
568              
569             __END__