Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 1
<?php
2
// This file is part of Moodle - http://moodle.org/
3
//
4
// Moodle is free software: you can redistribute it and/or modify
5
// it under the terms of the GNU General Public License as published by
6
// the Free Software Foundation, either version 3 of the License, or
7
// (at your option) any later version.
8
//
9
// Moodle is distributed in the hope that it will be useful,
10
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
// GNU General Public License for more details.
13
//
14
// You should have received a copy of the GNU General Public License
15
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
16
 
17
namespace tool_brickfield\local\htmlchecker\common;
18
 
19
/**
20
 * Parse content to check CSS validity.
21
 *
22
 * This class first parses all the CSS in the document and prepares an index of CSS styles to be used by accessibility tests
23
 * to determine color and positioning.
24
 *
25
 * First, in loadCSS we get all the inline and linked style sheet information and merge it into a large CSS file string.
26
 *
27
 * Second, in setStyles we use XPath queries to find all the DOM elements which are effected by CSS styles and then
28
 * build up an index in style_index of all the CSS styles keyed by an attriute we attach to all DOM objects to lookup
29
 * the style quickly.
30
 *
31
 * Most of the second step is to get around the problem where XPath DOMNodeList objects are only marginally referential
32
 * to the original elements and cannot be altered directly.
33
 *
34
 * @package    tool_brickfield
35
 * @copyright  2020 onward: Brickfield Education Labs, www.brickfield.ie
36
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
37
 */
38
class brickfield_accessibility_css {
39
 
40
    /** @var object The DOMDocument object of the current document */
41
    public $dom;
42
 
43
    /** @var string The URI of the current document */
44
    public $uri;
45
 
46
    /** @var string The type of request (inherited from the main htmlchecker object) */
47
    public $type;
48
 
49
    /** @var array An array of all the CSS elements and attributes */
50
    public $css;
51
 
52
    /** @var string Additional CSS information (usually for CMS mode requests) */
53
    public $cssstring;
54
 
55
    /** @var bool Whether or not we are running in CMS mode */
56
    public $cmsmode;
57
 
58
    /** @var array An array of all the strings which means the current style inherts from above */
59
    public $inheritancestrings = ['inherit', 'currentColor'];
60
 
61
    /** @var array An array of all the styles keyed by the new attribute brickfield_accessibility_style_index */
62
    public $styleindex = [];
63
 
64
    /** @var int The next index ID to be applied to a node to lookup later in style_index */
65
    public $nextindex = 0;
66
 
67
    /** @var array A list of all the elements which support deprecated styles such as 'background' or 'bgcolor' */
68
    public $deprecatedstyleelements = ['body', 'table', 'tr', 'td', 'th'];
69
 
70
    /** @var array */
71
    public array $path = [];
72
 
73
    /** @var array To store additional CSS files to load. */
74
    public array $css_files = [];
75
 
76
    /**
77
     * Class constructor. We are just building and importing variables here and then loading the CSS
78
     * @param \DOMDocument $dom The DOMDocument object
79
     * @param string $uri The URI of the request
80
     * @param string $type The type of request
81
     * @param array $path
82
     * @param bool $cmsmode Whether we are running in CMS mode
83
     * @param array $cssfiles An array of additional CSS files to load
84
     */
85
    public function __construct(\DOMDocument &$dom, string $uri, string $type, array $path, bool $cmsmode = false,
86
                                array $cssfiles = []) {
87
        $this->dom =& $dom;
88
        $this->type = $type;
89
        $this->uri = $uri;
90
        $this->path = $path;
91
        $this->cmsmode = $cmsmode;
92
        $this->css_files = $cssfiles;
93
    }
94
 
95
    /**
96
     * Loads all the CSS files from the document using LINK elements or @import commands
97
     */
98
    private function load_css() {
99
        if (count($this->css_files) > 0) {
100
            $css = $this->css_files;
101
        } else {
102
            $css = [];
103
            $headerstyles = $this->dom->getElementsByTagName('style');
104
            foreach ($headerstyles as $headerstyle) {
105
                if ($headerstyle->nodeValue) {
106
                    $this->cssstring .= $headerstyle->nodeValue;
107
                }
108
            }
109
            $stylesheets = $this->dom->getElementsByTagName('link');
110
 
111
            foreach ($stylesheets as $style) {
112
                if ($style->hasAttribute('rel') &&
113
                    (strtolower($style->getAttribute('rel')) == 'stylesheet') &&
114
                    ($style->getAttribute('media') != 'print')) {
115
                        $css[] = $style->getAttribute('href');
116
                }
117
            }
118
        }
119
        foreach ($css as $sheet) {
120
            $this->load_uri($sheet);
121
        }
122
        $this->load_imported_files();
123
        $this->cssstring = str_replace(':link', '', $this->cssstring);
124
        $this->format_css();
125
    }
126
 
127
    /**
128
     * Imports files from the CSS file using @import commands
129
     */
130
    private function load_imported_files() {
131
        $matches = [];
132
        preg_match_all('/@import (.*?);/i', $this->cssstring, $matches);
133
        if (count($matches[1]) == 0) {
134
            return null;
135
        }
136
        foreach ($matches[1] as $match) {
137
            $this->load_uri(trim(str_replace('url', '', $match), '"\')('));
138
        }
139
        preg_replace('/@import (.*?);/i', '', $this->cssstring);
140
    }
141
 
142
    /**
143
     * Returns a specificity count to the given selector.
144
     * Higher specificity means it overrides other styles.
145
     * @param string $selector The CSS Selector
146
     * @return int $specifity
147
     */
148
    public function get_specificity(string $selector): int {
149
        $selector = $this->parse_selector($selector);
150
        if ($selector[0][0] == ' ') {
151
            unset($selector[0][0]);
152
        }
153
        $selector = $selector[0];
154
        $specificity = 0;
155
        foreach ($selector as $part) {
156
            switch(substr(str_replace('*', '', $part), 0, 1)) {
157
                case '.':
158
                    $specificity += 10;
159
                case '#':
160
                    $specificity += 100;
161
                case ':':
162
                    $specificity++;
163
                default:
164
                    $specificity++;
165
            }
166
            if (strpos($part, '[id=') != false) {
167
                $specificity += 100;
168
            }
169
        }
170
        return $specificity;
171
    }
172
 
173
    /**
174
     * Interface method for tests to call to lookup the style information for a given DOMNode
175
     * @param \stdClass $element A DOMElement/DOMNode object
176
     * @return array An array of style information (can be empty)
177
     */
178
    public function get_style($element): array {
179
        // To prevent having to parse CSS unless the info is needed,
180
        // we check here if CSS has been set, and if not, run off the parsing now.
181
        if (!is_a($element, 'DOMElement')) {
182
            return [];
183
        }
184
        $style = $this->get_node_style($element);
185
        if (isset($style['background-color']) || isset($style['color'])) {
186
            $style = $this->walkup_tree_for_inheritance($element, $style);
187
        }
188
        if ($element->hasAttribute('style')) {
189
            $inlinestyles = explode(';', $element->getAttribute('style'));
190
            foreach ($inlinestyles as $inlinestyle) {
191
                $s = explode(':', $inlinestyle);
192
 
193
                if (isset($s[1])) {    // Edit:  Make sure the style attribute doesn't have a trailing.
194
                    $style[trim($s[0])] = trim(strtolower($s[1]));
195
                }
196
            }
197
        }
198
        if ($element->tagName === 'strong') {
199
            $style['font-weight'] = 'bold';
200
        }
201
        if ($element->tagName === 'em') {
202
            $style['font-style'] = 'italic';
203
        }
204
        if (!is_array($style)) {
205
            return [];
206
        }
207
        return $style;
208
    }
209
 
210
    /**
211
     * Adds a selector to the CSS index
212
     * @param string $key The CSS selector
213
     * @param string $codestr The CSS Style code string
214
     * @return null
215
     */
216
    private function add_selector(string $key, string $codestr) {
217
        if (strpos($key, '@import') !== false) {
218
            return null;
219
        }
220
        $key = strtolower($key);
221
        $codestr = strtolower($codestr);
222
        if (!isset($this->css[$key])) {
223
            $this->css[$key] = array();
224
        }
225
        $codes = explode(';', $codestr);
226
        if (count($codes) > 0) {
227
            foreach ($codes as $code) {
228
                $code = trim($code);
229
                $explode = explode(':', $code, 2);
230
                if (count($explode) > 1) {
231
                    list($codekey, $codevalue) = $explode;
232
                    if (strlen($codekey) > 0) {
233
                        $this->css[$key][trim($codekey)] = trim($codevalue);
234
                    }
235
                }
236
            }
237
        }
238
    }
239
 
240
    /**
241
     * Returns the style from the CSS index for a given element by first
242
     * looking into its tag bucket then iterating over every item for an
243
     * element that matches
244
     * @param \stdClass $element
245
     * @return array An array of all the style elements that _directly_ apply to that element (ignoring inheritance)
246
     */
247
    private function get_node_style($element): array {
248
        $style = [];
249
 
250
        if ($element->hasAttribute('brickfield_accessibility_style_index')) {
251
            $style = $this->styleindex[$element->getAttribute('brickfield_accessibility_style_index')];
252
        }
253
        // To support the deprecated 'bgcolor' attribute.
254
        if ($element->hasAttribute('bgcolor') &&  in_array($element->tagName, $this->deprecatedstyleelements)) {
255
            $style['background-color'] = $element->getAttribute('bgcolor');
256
        }
257
        if ($element->hasAttribute('style')) {
258
            $inlinestyles = explode(';', $element->getAttribute('style'));
259
            foreach ($inlinestyles as $inlinestyle) {
260
                $s = explode(':', $inlinestyle);
261
                if (isset($s[1])) {    // Edit:  Make sure the style attribute doesn't have a trailing.
262
                    $style[trim($s[0])] = trim(strtolower($s[1]));
263
                }
264
            }
265
        }
266
 
267
        return $style;
268
    }
269
 
270
    /**
271
     * A helper function to walk up the DOM tree to the end to build an array of styles.
272
     * @param \stdClass $element The DOMNode object to walk up from
273
     * @param array $style The current style built for the node
274
     * @return array The array of the DOM element, altered if it was overruled through css inheritance
275
     */
276
    private function walkup_tree_for_inheritance($element, array $style): array {
277
        while (property_exists($element->parentNode, 'tagName')) {
278
            $parentstyle = $this->get_node_style($element->parentNode);
279
            if (is_array($parentstyle)) {
280
                foreach ($parentstyle as $k => $v) {
281
                    if (!isset($style[$k])) {
282
                        $style[$k] = $v;
283
                    }
284
 
285
                    if ((!isset($style['background-color'])) || strtolower($style['background-color']) == strtolower("#FFFFFF")) {
286
                        if ($k == 'background-color') {
287
                            $style['background-color'] = $v;
288
                        }
289
                    }
290
 
291
                    if ((!isset($style['color'])) || strtolower($style['color']) == strtolower("#000000")) {
292
                        if ($k == 'color') {
293
                            $style['color'] = $v;
294
                        }
295
                    }
296
                }
297
            }
298
            $element = $element->parentNode;
299
        }
300
        return $style;
301
    }
302
 
303
    /**
304
     * Loads a CSS file from a URI
305
     * @param string $rel The URI of the CSS file
306
     */
307
    private function load_uri(string $rel) {
308
        if ($this->type == 'file') {
309
            $uri = substr($this->uri, 0, strrpos($this->uri, '/')) .'/'.$rel;
310
        } else {
311
            $bfao = new \tool_brickfield\local\htmlchecker\brickfield_accessibility();
312
            $uri = $bfao->get_absolute_path($this->uri, $rel);
313
        }
314
        $this->cssstring .= @file_get_contents($uri);
315
 
316
    }
317
 
318
    /**
319
     * Formats the CSS to be ready to import into an array of styles
320
     * @return bool Whether there were elements imported or not
321
     */
322
    private function format_css(): bool {
323
        // Remove comments.
324
        $str = preg_replace("/\/\*(.*)?\*\//Usi", "", $this->cssstring);
325
        // Parse this csscode.
326
        $parts = explode("}", $str);
327
        if (count($parts) > 0) {
328
            foreach ($parts as $part) {
329
                if (strpos($part, '{') !== false) {
330
                    list($keystr, $codestr) = explode("{", $part);
331
                    $keys = explode(", ", trim($keystr));
332
                    if (count($keys) > 0) {
333
                        foreach ($keys as $key) {
334
                            if (strlen($key) > 0) {
335
                                $key = str_replace("\n", "", $key);
336
                                $key = str_replace("\\", "", $key);
337
                                $this->add_selector($key, trim($codestr));
338
                            }
339
                        }
340
                    }
341
                }
342
            }
343
        }
344
        return (count($this->css) > 0);
345
    }
346
 
347
    /**
348
     * Converts a CSS selector to an Xpath query
349
     * @param string $selector The selector to convert
350
     * @return string An Xpath query string
351
     */
352
    private function get_xpath(string $selector): string {
353
        $query = $this->parse_selector($selector);
354
 
355
        $xpath = '//';
356
        foreach ($query[0] as $k => $q) {
357
            if ($q == ' ' && $k) {
358
                $xpath .= '//';
359
            } else if ($q == '>' && $k) {
360
                $xpath .= '/';
361
            } else if (substr($q, 0, 1) == '#') {
362
                $xpath .= '[ @id = "' . str_replace('#', '', $q) . '" ]';
363
            } else if (substr($q, 0, 1) == '.') {
364
                $xpath .= '[ @class = "' . str_replace('.', '', $q) . '" ]';
365
            } else if (substr($q, 0, 1) == '[') {
366
                $xpath .= str_replace('[id', '[ @ id', $q);
367
            } else {
368
                $xpath .= trim($q);
369
            }
370
        }
371
        return str_replace('//[', '//*[', str_replace('//[ @', '//*[ @', $xpath));
372
    }
373
 
374
    /**
375
     * Checks that a string is really a regular character
376
     * @param string $char The character
377
     * @return bool Whether the string is a character
378
     */
379
    private function is_char(string $char): bool {
380
        return extension_loaded('mbstring') ? mb_eregi('\w', $char) : preg_match('@\w@', $char);
381
    }
382
 
383
    /**
384
     * Parses a CSS selector into an array of rules.
385
     * @param string $query The CSS Selector query
386
     * @return array An array of the CSS Selector parsed into rule segments
387
     */
388
    private function parse_selector(string $query): array {
389
        // Clean spaces.
390
        $query = trim(preg_replace('@\s+@', ' ', preg_replace('@\s*(>|\\+|~)\s*@', '\\1', $query)));
391
        $queries = [[]];
392
        if (!$query) {
393
            return $queries;
394
        }
395
        $return =& $queries[0];
396
        $specialchars = ['>', ' '];
397
        $specialcharsmapping = [];
398
        $strlen = mb_strlen($query);
399
        $classchars = ['.', '-'];
400
        $pseudochars = ['-'];
401
        $tagchars = ['*', '|', '-'];
402
        // Split multibyte string
403
        // http://code.google.com/p/phpquery/issues/detail?id=76.
404
        $newquery = [];
405
        for ($i = 0; $i < $strlen; $i++) {
406
            $newquery[] = mb_substr($query, $i, 1);
407
        }
408
        $query = $newquery;
409
        // It works, but i dont like it...
410
        $i = 0;
411
        while ($i < $strlen) {
412
            $c = $query[$i];
413
            $tmp = '';
414
            // TAG.
415
            if ($this->is_char($c) || in_array($c, $tagchars)) {
416
                while (isset($query[$i]) && ($this->is_char($query[$i]) || in_array($query[$i], $tagchars))) {
417
                    $tmp .= $query[$i];
418
                    $i++;
419
                }
420
                $return[] = $tmp;
421
                // IDs.
422
            } else if ( $c == '#') {
423
                $i++;
424
                while (isset($query[$i]) && ($this->is_char($query[$i]) || $query[$i] == '-')) {
425
                    $tmp .= $query[$i];
426
                    $i++;
427
                }
428
                $return[] = '#'.$tmp;
429
                // SPECIAL CHARS.
430
            } else if (in_array($c, $specialchars)) {
431
                $return[] = $c;
432
                $i++;
433
                // MAPPED SPECIAL CHARS.
434
            } else if ( isset($specialcharsmapping[$c])) {
435
                $return[] = $specialcharsmapping[$c];
436
                $i++;
437
                // COMMA.
438
            } else if ( $c == ',') {
439
                $queries[] = [];
440
                $return =& $queries[count($queries) - 1];
441
                $i++;
442
                while (isset($query[$i]) && $query[$i] == ' ') {
443
                    $i++;
444
                }
445
                // CLASSES.
446
            } else if ($c == '.') {
447
                while (isset($query[$i]) && ($this->is_char($query[$i]) || in_array($query[$i], $classchars))) {
448
                    $tmp .= $query[$i];
449
                    $i++;
450
                }
451
                $return[] = $tmp;
452
                // General Sibling Selector.
453
            } else if ($c == '~') {
454
                $spaceallowed = true;
455
                $tmp .= $query[$i++];
456
                while (isset($query[$i])
457
                    && ($this->is_char($query[$i])
458
                        || in_array($query[$i], $classchars)
459
                        || $query[$i] == '*'
460
                        || ($query[$i] == ' ' && $spaceallowed)
461
                    )) {
462
                    if ($query[$i] != ' ') {
463
                        $spaceallowed = false;
464
                    }
465
                    $tmp .= $query[$i];
466
                    $i++;
467
                }
468
                $return[] = $tmp;
469
                // Adjacent sibling selectors.
470
            } else if ($c == '+') {
471
                $spaceallowed = true;
472
                $tmp .= $query[$i++];
473
                while (isset($query[$i])
474
                    && ($this->is_char($query[$i])
475
                        || in_array($query[$i], $classchars)
476
                        || $query[$i] == '*'
477
                        || ($spaceallowed && $query[$i] == ' ')
478
                    )) {
479
                    if ($query[$i] != ' ') {
480
                        $spaceallowed = false;
481
                    }
482
                    $tmp .= $query[$i];
483
                    $i++;
484
                }
485
                $return[] = $tmp;
486
                // ATTRS.
487
            } else if ($c == '[') {
488
                $stack = 1;
489
                $tmp .= $c;
490
                while (isset($query[++$i])) {
491
                    $tmp .= $query[$i];
492
                    if ( $query[$i] == '[') {
493
                        $stack++;
494
                    } else if ( $query[$i] == ']') {
495
                        $stack--;
496
                        if (!$stack) {
497
                            break;
498
                        }
499
                    }
500
                }
501
                $return[] = $tmp;
502
                $i++;
503
                // PSEUDO CLASSES.
504
            } else if ($c == ':') {
505
                $stack = 1;
506
                $tmp .= $query[$i++];
507
                while (isset($query[$i]) && ($this->is_char($query[$i]) || in_array($query[$i], $pseudochars))) {
508
                    $tmp .= $query[$i];
509
                    $i++;
510
                }
511
                // With arguments?
512
                if (isset($query[$i]) && $query[$i] == '(') {
513
                    $tmp .= $query[$i];
514
                    $stack = 1;
515
                    while (isset($query[++$i])) {
516
                        $tmp .= $query[$i];
517
                        if ( $query[$i] == '(') {
518
                            $stack++;
519
                        } else if ( $query[$i] == ')') {
520
                            $stack--;
521
                            if (!$stack) {
522
                                break;
523
                            }
524
                        }
525
                    }
526
                    $return[] = $tmp;
527
                    $i++;
528
                } else {
529
                    $return[] = $tmp;
530
                }
531
            } else {
532
                $i++;
533
            }
534
        }
535
        foreach ($queries as $k => $q) {
536
            if (isset($q[0])) {
537
                if (isset($q[0][0]) && $q[0][0] == ':') {
538
                    array_unshift($queries[$k], '*');
539
                }
540
                if ($q[0] != '>') {
541
                    array_unshift($queries[$k], ' ');
542
                }
543
            }
544
        }
545
        return $queries;
546
    }
547
}