See Release Notes
Long Term Support Release
Differences Between: [Versions 401 and 402] [Versions 401 and 403]
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 /** 71 * Class constructor. We are just building and importing variables here and then loading the CSS 72 * @param \DOMDocument $dom The DOMDocument object 73 * @param string $uri The URI of the request 74 * @param string $type The type of request 75 * @param array $path 76 * @param bool $cmsmode Whether we are running in CMS mode 77 * @param array $cssfiles An array of additional CSS files to load 78 */ 79 public function __construct(\DOMDocument &$dom, string $uri, string $type, array $path, bool $cmsmode = false, 80 array $cssfiles = []) { 81 $this->dom =& $dom; 82 $this->type = $type; 83 $this->uri = $uri; 84 $this->path = $path; 85 $this->cmsmode = $cmsmode; 86 $this->css_files = $cssfiles; 87 } 88 89 /** 90 * Loads all the CSS files from the document using LINK elements or @import commands 91 */ 92 private function load_css() { 93 if (count($this->css_files) > 0) { 94 $css = $this->css_files; 95 } else { 96 $css = []; 97 $headerstyles = $this->dom->getElementsByTagName('style'); 98 foreach ($headerstyles as $headerstyle) { 99 if ($headerstyle->nodeValue) { 100 $this->cssstring .= $headerstyle->nodeValue; 101 } 102 } 103 $stylesheets = $this->dom->getElementsByTagName('link'); 104 105 foreach ($stylesheets as $style) { 106 if ($style->hasAttribute('rel') && 107 (strtolower($style->getAttribute('rel')) == 'stylesheet') && 108 ($style->getAttribute('media') != 'print')) { 109 $css[] = $style->getAttribute('href'); 110 } 111 } 112 } 113 foreach ($css as $sheet) { 114 $this->load_uri($sheet); 115 } 116 $this->load_imported_files(); 117 $this->cssstring = str_replace(':link', '', $this->cssstring); 118 $this->format_css(); 119 } 120 121 /** 122 * Imports files from the CSS file using @import commands 123 */ 124 private function load_imported_files() { 125 $matches = []; 126 preg_match_all('/@import (.*?);/i', $this->cssstring, $matches); 127 if (count($matches[1]) == 0) { 128 return null; 129 } 130 foreach ($matches[1] as $match) { 131 $this->load_uri(trim(str_replace('url', '', $match), '"\')(')); 132 } 133 preg_replace('/@import (.*?);/i', '', $this->cssstring); 134 } 135 136 /** 137 * Returns a specificity count to the given selector. 138 * Higher specificity means it overrides other styles. 139 * @param string $selector The CSS Selector 140 * @return int $specifity 141 */ 142 public function get_specificity(string $selector): int { 143 $selector = $this->parse_selector($selector); 144 if ($selector[0][0] == ' ') { 145 unset($selector[0][0]); 146 } 147 $selector = $selector[0]; 148 $specificity = 0; 149 foreach ($selector as $part) { 150 switch(substr(str_replace('*', '', $part), 0, 1)) { 151 case '.': 152 $specificity += 10; 153 case '#': 154 $specificity += 100; 155 case ':': 156 $specificity++; 157 default: 158 $specificity++; 159 } 160 if (strpos($part, '[id=') != false) { 161 $specificity += 100; 162 } 163 } 164 return $specificity; 165 } 166 167 /** 168 * Interface method for tests to call to lookup the style information for a given DOMNode 169 * @param \stdClass $element A DOMElement/DOMNode object 170 * @return array An array of style information (can be empty) 171 */ 172 public function get_style($element): array { 173 // To prevent having to parse CSS unless the info is needed, 174 // we check here if CSS has been set, and if not, run off the parsing now. 175 if (!is_a($element, 'DOMElement')) { 176 return []; 177 } 178 $style = $this->get_node_style($element); 179 if (isset($style['background-color']) || isset($style['color'])) { 180 $style = $this->walkup_tree_for_inheritance($element, $style); 181 } 182 if ($element->hasAttribute('style')) { 183 $inlinestyles = explode(';', $element->getAttribute('style')); 184 foreach ($inlinestyles as $inlinestyle) { 185 $s = explode(':', $inlinestyle); 186 187 if (isset($s[1])) { // Edit: Make sure the style attribute doesn't have a trailing. 188 $style[trim($s[0])] = trim(strtolower($s[1])); 189 } 190 } 191 } 192 if ($element->tagName === 'strong') { 193 $style['font-weight'] = 'bold'; 194 } 195 if ($element->tagName === 'em') { 196 $style['font-style'] = 'italic'; 197 } 198 if (!is_array($style)) { 199 return []; 200 } 201 return $style; 202 } 203 204 /** 205 * Adds a selector to the CSS index 206 * @param string $key The CSS selector 207 * @param string $codestr The CSS Style code string 208 * @return null 209 */ 210 private function add_selector(string $key, string $codestr) { 211 if (strpos($key, '@import') !== false) { 212 return null; 213 } 214 $key = strtolower($key); 215 $codestr = strtolower($codestr); 216 if (!isset($this->css[$key])) { 217 $this->css[$key] = array(); 218 } 219 $codes = explode(';', $codestr); 220 if (count($codes) > 0) { 221 foreach ($codes as $code) { 222 $code = trim($code); 223 $explode = explode(':', $code, 2); 224 if (count($explode) > 1) { 225 list($codekey, $codevalue) = $explode; 226 if (strlen($codekey) > 0) { 227 $this->css[$key][trim($codekey)] = trim($codevalue); 228 } 229 } 230 } 231 } 232 } 233 234 /** 235 * Returns the style from the CSS index for a given element by first 236 * looking into its tag bucket then iterating over every item for an 237 * element that matches 238 * @param \stdClass $element 239 * @return array An array of all the style elements that _directly_ apply to that element (ignoring inheritance) 240 */ 241 private function get_node_style($element): array { 242 $style = []; 243 244 if ($element->hasAttribute('brickfield_accessibility_style_index')) { 245 $style = $this->styleindex[$element->getAttribute('brickfield_accessibility_style_index')]; 246 } 247 // To support the deprecated 'bgcolor' attribute. 248 if ($element->hasAttribute('bgcolor') && in_array($element->tagName, $this->deprecatedstyleelements)) { 249 $style['background-color'] = $element->getAttribute('bgcolor'); 250 } 251 if ($element->hasAttribute('style')) { 252 $inlinestyles = explode(';', $element->getAttribute('style')); 253 foreach ($inlinestyles as $inlinestyle) { 254 $s = explode(':', $inlinestyle); 255 if (isset($s[1])) { // Edit: Make sure the style attribute doesn't have a trailing. 256 $style[trim($s[0])] = trim(strtolower($s[1])); 257 } 258 } 259 } 260 261 return $style; 262 } 263 264 /** 265 * A helper function to walk up the DOM tree to the end to build an array of styles. 266 * @param \stdClass $element The DOMNode object to walk up from 267 * @param array $style The current style built for the node 268 * @return array The array of the DOM element, altered if it was overruled through css inheritance 269 */ 270 private function walkup_tree_for_inheritance($element, array $style): array { 271 while (property_exists($element->parentNode, 'tagName')) { 272 $parentstyle = $this->get_node_style($element->parentNode); 273 if (is_array($parentstyle)) { 274 foreach ($parentstyle as $k => $v) { 275 if (!isset($style[$k])) { 276 $style[$k] = $v; 277 } 278 279 if ((!isset($style['background-color'])) || strtolower($style['background-color']) == strtolower("#FFFFFF")) { 280 if ($k == 'background-color') { 281 $style['background-color'] = $v; 282 } 283 } 284 285 if ((!isset($style['color'])) || strtolower($style['color']) == strtolower("#000000")) { 286 if ($k == 'color') { 287 $style['color'] = $v; 288 } 289 } 290 } 291 } 292 $element = $element->parentNode; 293 } 294 return $style; 295 } 296 297 /** 298 * Loads a CSS file from a URI 299 * @param string $rel The URI of the CSS file 300 */ 301 private function load_uri(string $rel) { 302 if ($this->type == 'file') { 303 $uri = substr($this->uri, 0, strrpos($this->uri, '/')) .'/'.$rel; 304 } else { 305 $bfao = new \tool_brickfield\local\htmlchecker\brickfield_accessibility(); 306 $uri = $bfao->get_absolute_path($this->uri, $rel); 307 } 308 $this->cssstring .= @file_get_contents($uri); 309 310 } 311 312 /** 313 * Formats the CSS to be ready to import into an array of styles 314 * @return bool Whether there were elements imported or not 315 */ 316 private function format_css(): bool { 317 // Remove comments. 318 $str = preg_replace("/\/\*(.*)?\*\//Usi", "", $this->cssstring); 319 // Parse this csscode. 320 $parts = explode("}", $str); 321 if (count($parts) > 0) { 322 foreach ($parts as $part) { 323 if (strpos($part, '{') !== false) { 324 list($keystr, $codestr) = explode("{", $part); 325 $keys = explode(", ", trim($keystr)); 326 if (count($keys) > 0) { 327 foreach ($keys as $key) { 328 if (strlen($key) > 0) { 329 $key = str_replace("\n", "", $key); 330 $key = str_replace("\\", "", $key); 331 $this->add_selector($key, trim($codestr)); 332 } 333 } 334 } 335 } 336 } 337 } 338 return (count($this->css) > 0); 339 } 340 341 /** 342 * Converts a CSS selector to an Xpath query 343 * @param string $selector The selector to convert 344 * @return string An Xpath query string 345 */ 346 private function get_xpath(string $selector): string { 347 $query = $this->parse_selector($selector); 348 349 $xpath = '//'; 350 foreach ($query[0] as $k => $q) { 351 if ($q == ' ' && $k) { 352 $xpath .= '//'; 353 } else if ($q == '>' && $k) { 354 $xpath .= '/'; 355 } else if (substr($q, 0, 1) == '#') { 356 $xpath .= '[ @id = "' . str_replace('#', '', $q) . '" ]'; 357 } else if (substr($q, 0, 1) == '.') { 358 $xpath .= '[ @class = "' . str_replace('.', '', $q) . '" ]'; 359 } else if (substr($q, 0, 1) == '[') { 360 $xpath .= str_replace('[id', '[ @ id', $q); 361 } else { 362 $xpath .= trim($q); 363 } 364 } 365 return str_replace('//[', '//*[', str_replace('//[ @', '//*[ @', $xpath)); 366 } 367 368 /** 369 * Checks that a string is really a regular character 370 * @param string $char The character 371 * @return bool Whether the string is a character 372 */ 373 private function is_char(string $char): bool { 374 return extension_loaded('mbstring') ? mb_eregi('\w', $char) : preg_match('@\w@', $char); 375 } 376 377 /** 378 * Parses a CSS selector into an array of rules. 379 * @param string $query The CSS Selector query 380 * @return array An array of the CSS Selector parsed into rule segments 381 */ 382 private function parse_selector(string $query): array { 383 // Clean spaces. 384 $query = trim(preg_replace('@\s+@', ' ', preg_replace('@\s*(>|\\+|~)\s*@', '\\1', $query))); 385 $queries = [[]]; 386 if (!$query) { 387 return $queries; 388 } 389 $return =& $queries[0]; 390 $specialchars = ['>', ' ']; 391 $specialcharsmapping = []; 392 $strlen = mb_strlen($query); 393 $classchars = ['.', '-']; 394 $pseudochars = ['-']; 395 $tagchars = ['*', '|', '-']; 396 // Split multibyte string 397 // http://code.google.com/p/phpquery/issues/detail?id=76. 398 $newquery = []; 399 for ($i = 0; $i < $strlen; $i++) { 400 $newquery[] = mb_substr($query, $i, 1); 401 } 402 $query = $newquery; 403 // It works, but i dont like it... 404 $i = 0; 405 while ($i < $strlen) { 406 $c = $query[$i]; 407 $tmp = ''; 408 // TAG. 409 if ($this->is_char($c) || in_array($c, $tagchars)) { 410 while (isset($query[$i]) && ($this->is_char($query[$i]) || in_array($query[$i], $tagchars))) { 411 $tmp .= $query[$i]; 412 $i++; 413 } 414 $return[] = $tmp; 415 // IDs. 416 } else if ( $c == '#') { 417 $i++; 418 while (isset($query[$i]) && ($this->is_char($query[$i]) || $query[$i] == '-')) { 419 $tmp .= $query[$i]; 420 $i++; 421 } 422 $return[] = '#'.$tmp; 423 // SPECIAL CHARS. 424 } else if (in_array($c, $specialchars)) { 425 $return[] = $c; 426 $i++; 427 // MAPPED SPECIAL CHARS. 428 } else if ( isset($specialcharsmapping[$c])) { 429 $return[] = $specialcharsmapping[$c]; 430 $i++; 431 // COMMA. 432 } else if ( $c == ',') { 433 $queries[] = []; 434 $return =& $queries[count($queries) - 1]; 435 $i++; 436 while (isset($query[$i]) && $query[$i] == ' ') { 437 $i++; 438 } 439 // CLASSES. 440 } else if ($c == '.') { 441 while (isset($query[$i]) && ($this->is_char($query[$i]) || in_array($query[$i], $classchars))) { 442 $tmp .= $query[$i]; 443 $i++; 444 } 445 $return[] = $tmp; 446 // General Sibling Selector. 447 } else if ($c == '~') { 448 $spaceallowed = true; 449 $tmp .= $query[$i++]; 450 while (isset($query[$i]) 451 && ($this->is_char($query[$i]) 452 || in_array($query[$i], $classchars) 453 || $query[$i] == '*' 454 || ($query[$i] == ' ' && $spaceallowed) 455 )) { 456 if ($query[$i] != ' ') { 457 $spaceallowed = false; 458 } 459 $tmp .= $query[$i]; 460 $i++; 461 } 462 $return[] = $tmp; 463 // Adjacent sibling selectors. 464 } else if ($c == '+') { 465 $spaceallowed = true; 466 $tmp .= $query[$i++]; 467 while (isset($query[$i]) 468 && ($this->is_char($query[$i]) 469 || in_array($query[$i], $classchars) 470 || $query[$i] == '*' 471 || ($spaceallowed && $query[$i] == ' ') 472 )) { 473 if ($query[$i] != ' ') { 474 $spaceallowed = false; 475 } 476 $tmp .= $query[$i]; 477 $i++; 478 } 479 $return[] = $tmp; 480 // ATTRS. 481 } else if ($c == '[') { 482 $stack = 1; 483 $tmp .= $c; 484 while (isset($query[++$i])) { 485 $tmp .= $query[$i]; 486 if ( $query[$i] == '[') { 487 $stack++; 488 } else if ( $query[$i] == ']') { 489 $stack--; 490 if (!$stack) { 491 break; 492 } 493 } 494 } 495 $return[] = $tmp; 496 $i++; 497 // PSEUDO CLASSES. 498 } else if ($c == ':') { 499 $stack = 1; 500 $tmp .= $query[$i++]; 501 while (isset($query[$i]) && ($this->is_char($query[$i]) || in_array($query[$i], $pseudochars))) { 502 $tmp .= $query[$i]; 503 $i++; 504 } 505 // With arguments? 506 if (isset($query[$i]) && $query[$i] == '(') { 507 $tmp .= $query[$i]; 508 $stack = 1; 509 while (isset($query[++$i])) { 510 $tmp .= $query[$i]; 511 if ( $query[$i] == '(') { 512 $stack++; 513 } else if ( $query[$i] == ')') { 514 $stack--; 515 if (!$stack) { 516 break; 517 } 518 } 519 } 520 $return[] = $tmp; 521 $i++; 522 } else { 523 $return[] = $tmp; 524 } 525 } else { 526 $i++; 527 } 528 } 529 foreach ($queries as $k => $q) { 530 if (isset($q[0])) { 531 if (isset($q[0][0]) && $q[0][0] == ':') { 532 array_unshift($queries[$k], '*'); 533 } 534 if ($q[0] != '>') { 535 array_unshift($queries[$k], ' '); 536 } 537 } 538 } 539 return $queries; 540 } 541 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body