Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.2.x will end 22 April 2024 (12 months).
  • Bug fixes for security issues in 4.2.x will end 7 October 2024 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.1.x is supported too.

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  }