Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.

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  }