Differences Between: [Versions 311 and 402] [Versions 400 and 402] [Versions 401 and 402]
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 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body