Search moodle.org's
Developer Documentation

See Release Notes

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

Differences Between: [Versions 311 and 403] [Versions 400 and 403] [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  use tool_brickfield\local\htmlchecker\brickfield_accessibility_report_item;
  20  use tool_brickfield\manager;
  21  
  22  /**
  23   * This handles importing DOM objects, adding items to the report and provides a few DOM-traversing methods
  24   *
  25   * @package    tool_brickfield
  26   * @copyright  2020 onward: Brickfield Education Labs, www.brickfield.ie
  27   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  28   */
  29  class brickfield_accessibility_test {
  30      /** @var object The DOMDocument object */
  31      public $dom;
  32  
  33      /** @var object The brickfieldCSS object */
  34      public $css;
  35  
  36      /** @var array The path for the request */
  37      public $path;
  38  
  39      /** @var bool Whether the test can be used in a CMS (content without HTML head) */
  40      public $cms = true;
  41  
  42      /** @var string The base path for this request */
  43      public $basepath;
  44  
  45      /** @var array An array of ReportItem objects */
  46      public $report = array();
  47  
  48      /** @var int The fallback severity level for all tests */
  49      public $defaultseverity = \tool_brickfield\local\htmlchecker\brickfield_accessibility::BA_TEST_SUGGESTION;
  50  
  51      /** @var array An array of all the extensions that are images */
  52      public $imageextensions = array('gif', 'jpg', 'png', 'jpeg', 'tiff', 'svn');
  53  
  54      /** @var string The language domain */
  55      public $lang = 'en';
  56  
  57      /** @var array An array of translatable strings */
  58      public $strings = array('en' => '');
  59  
  60      /** @var mixed Any additional options passed by htmlchecker. */
  61      public $options;
  62  
  63      /**
  64       * The class constructor. We pass items by reference so we can alter the DOM if necessary
  65       * @param object $dom The DOMDocument object
  66       * @param object $css The brickfieldCSS object
  67       * @param array $path The path of this request
  68       * @param string $languagedomain The langauge domain to user
  69       * @param mixed $options Any additional options passed by htmlchecker.
  70       */
  71      public function __construct(&$dom, &$css, &$path, $languagedomain = 'en', $options = null) {
  72          $this->dom = $dom;
  73          $this->css = $css;
  74          $this->path = $path;
  75          $this->lang = $languagedomain;
  76          $this->options = $options;
  77          $this->report = array();
  78          $this->check();
  79      }
  80  
  81      /**
  82       * Helper method to collect the report from this test. Some
  83       * tests do additional cleanup by overriding this method
  84       * @return array An array of ReportItem objects
  85       */
  86      public function get_report(): array {
  87          $this->report['severity'] = $this->defaultseverity;
  88          return $this->report;
  89      }
  90  
  91      /**
  92       * Returns the default severity of the test
  93       * @return int The severity level
  94       */
  95      public function get_severity(): int {
  96          return $this->defaultseverity;
  97      }
  98  
  99      /**
 100       * Adds a new ReportItem to this current tests collection of reports.
 101       * Most reports pertain to a particular element (like an IMG with no Alt attribute);
 102       * however, some are document-level and just either pass or don't pass
 103       * @param object $element The DOMElement object that pertains to this report
 104       * @param string $message An additional message to add to the report
 105       * @param bool $pass Whether or not this report passed
 106       * @param object $state Extra information about the error state
 107       * @param bool $manual Whether the report needs a manual check
 108       */
 109      public function add_report($element = null, $message = null, $pass = null, $state = null, $manual = null) {
 110          $report          = new brickfield_accessibility_report_item();
 111          $report->element = $element;
 112          $report->message = $message;
 113          $report->pass    = $pass;
 114          $report->state   = $state;
 115          $report->manual  = $manual;
 116          $report->line    = $report->get_line();
 117          $this->report[]  = $report;
 118      }
 119  
 120      /**
 121       * Retrieves the full path for a file.
 122       * @param string $file The path to a file
 123       * @return string The absolute path to the file.
 124       */
 125      public function get_path($file): string {
 126          if ((substr($file, 0, 7) == 'http://') || (substr($file, 0, 8) == 'https://')) {
 127              return $file;
 128          }
 129          $file = explode('/', $file);
 130          if (count($file) == 1) {
 131              return implode('/', $this->path) . '/' . $file[0];
 132          }
 133  
 134          $path = $this->path;
 135          foreach ($file as $directory) {
 136              if ($directory == '..') {
 137                  array_pop($path);
 138              } else {
 139                  $filepath[] = $directory;
 140              }
 141          }
 142          return implode('/', $path) .'/'. implode('/', $filepath);
 143      }
 144  
 145      /**
 146       * Returns a translated variable. If the translation is unavailable, English is returned
 147       * Because tests only really have one string array, we can get all of this info locally
 148       * @return mixed The translation for the object
 149       */
 150      public function translation() {
 151          if (isset($this->strings[$this->lang])) {
 152              return $this->strings[$this->lang];
 153          }
 154          if (isset($this->strings['en'])) {
 155              return $this->strings['en'];
 156          }
 157          return false;
 158      }
 159  
 160      /**
 161       * Helper method to find all the elements that fit a particular query
 162       * in the document (either by tag name, or by attributes from the htmlElements object)
 163       * @param mixed $tags Either a single tag name in a string, or an array of tag names
 164       * @param string $options The kind of option to select an element by (see htmlElements)
 165       * @param bool $value The value of the above option
 166       * @return array An array of elements that fit the description
 167       */
 168      public function get_all_elements($tags = null, string $options = '', bool $value = true): array {
 169          if (!is_array($tags)) {
 170              $tags = [$tags];
 171          }
 172          if ($options !== '') {
 173              $temp = new html_elements();
 174              $tags = $temp->get_elements_by_option($options, $value);
 175          }
 176          $result = [];
 177  
 178          if (!is_array($tags)) {
 179              return [];
 180          }
 181          foreach ($tags as $tag) {
 182              $elements = $this->dom->getElementsByTagName($tag);
 183              if ($elements) {
 184                  foreach ($elements as $element) {
 185                      $result[] = $element;
 186                  }
 187              }
 188          }
 189          if (count($result) == 0) {
 190              return [];
 191          }
 192          return $result;
 193      }
 194  
 195      /**
 196       * Returns true if an element has a child with a given tag name
 197       * @param object $element A DOMElement object
 198       * @param string $childtag The tag name of the child to find
 199       * @return bool TRUE if the element does have a child with
 200       *              the given tag name, otherwise FALSE
 201       */
 202      public function element_has_child($element, string $childtag): bool {
 203          foreach ($element->childNodes as $child) {
 204              if (property_exists($child, 'tagName') && $child->tagName == $childtag) {
 205                  return true;
 206              }
 207          }
 208          return false;
 209      }
 210  
 211      /**
 212       * Returns the first ancestor reached of a tag, or false if it hits
 213       * the document root or a given tag.
 214       * @param object $element A DOMElement object
 215       * @param string $ancestortag The name of the tag we are looking for
 216       * @param string $limittag Where to stop searching
 217       * @return bool
 218       */
 219      public function get_element_ancestor($element, string $ancestortag, string $limittag = 'body') {
 220          while (property_exists($element, 'parentNode')) {
 221              if ($element->parentNode->tagName == $ancestortag) {
 222                  return $element->parentNode;
 223              }
 224              if ($element->parentNode->tagName == $limittag) {
 225                  return false;
 226              }
 227              $element = $element->parentNode;
 228          }
 229          return false;
 230      }
 231  
 232      /**
 233       * Finds all the elements with a given tag name that has
 234       * an attribute
 235       * @param string $tag The tag name to search for
 236       * @param string $attribute The attribute to search on
 237       * @param bool $unique Whether we only want one result per attribute
 238       * @return array An array of DOMElements with the attribute
 239       *               value as the key.
 240       */
 241      public function get_elements_by_attribute(string $tag, string $attribute, bool $unique = false): array {
 242          $results = array();
 243          foreach ($this->get_all_elements($tag) as $element) {
 244              if ($element->hasAttribute($attribute)) {
 245                  if ($unique) {
 246                      $results[$element->getAttribute($attribute)] = $element;
 247                  } else {
 248                      $results[$element->getAttribute($attribute)][] = $element;
 249                  }
 250              }
 251          }
 252          return $results;
 253      }
 254  
 255      /**
 256       * Returns the next element after the current one.
 257       * @param object $element A DOMElement object
 258       * @return mixed FALSE if there is no other element, or a DOMElement object
 259       */
 260      public function get_next_element($element) {
 261          $parent = $element->parentNode;
 262          $next = false;
 263          foreach ($parent->childNodes as $child) {
 264              if ($next) {
 265                  return $child;
 266              }
 267              if ($child->isSameNode($element)) {
 268                  $next = true;
 269              }
 270          }
 271          return false;
 272      }
 273  
 274      /**
 275       * To minimize notices, this compares an object's property to the valus
 276       * and returns true or false. False will also be returned if the object is
 277       * not really an object, or if the property doesn't exist at all
 278       * @param object $object The object too look at
 279       * @param string $property The name of the property
 280       * @param mixed $value The value to check against
 281       * @param bool $trim Whether the property value should be trimmed
 282       * @param bool $lower Whether the property value should be compared on lower case
 283       *
 284       * @return bool
 285       */
 286      public function property_is_equal($object, string $property, $value, bool $trim = false, bool $lower = false) {
 287          if (!is_object($object)) {
 288              return false;
 289          }
 290          if (!property_exists($object, $property)) {
 291              return false;
 292          }
 293          $propertyvalue = $object->$property;
 294          if ($trim) {
 295              $propertyvalue = trim($propertyvalue);
 296              $value = trim($value);
 297          }
 298          if ($lower) {
 299              $propertyvalue = strtolower($propertyvalue);
 300              $value = strtolower($value);
 301          }
 302          return ($propertyvalue == $value);
 303      }
 304  
 305      /**
 306       * Returns the parent of an elment that has a given tag Name, but
 307       * stops the search if it hits the $limiter tag
 308       * @param object $element The DOMElement object to search on
 309       * @param string $tagname The name of the tag of the parent to find
 310       * @param string $limiter The tag name of the element to stop searching on
 311       *               regardless of the results (like search for a parent "P" tag
 312       *               of this node but stop if you reach "body")
 313       * @return mixed FALSE if no parent is found, or the DOMElement object of the found parent
 314       */
 315      public function get_parent($element, string $tagname, string $limiter) {
 316          while ($element) {
 317              if ($element->tagName == $tagname) {
 318                  return $element;
 319              }
 320              if ($element->tagName == $limiter) {
 321                  return false;
 322              }
 323              $element = $element->parentNode;
 324          }
 325          return false;
 326      }
 327  
 328      /**
 329       * Returns if a GIF files is animated or not http://us.php.net/manual/en/function.imagecreatefromgif.php#88005
 330       * @param string $filename
 331       * @return int
 332       */
 333      public function image_is_animated($filename): int {
 334          if (!($fh = @fopen($filename, 'rb'))) {
 335              return false;
 336          }
 337          $count = 0;
 338          // An animated gif contains multiple "frames", with each frame having a
 339          // header made up of:
 340          // * a static 4-byte sequence (\x00\x21\xF9\x04)
 341          // * 4 variable bytes
 342          // * a static 2-byte sequence (\x00\x2C).
 343  
 344          // We read through the file til we reach the end of the file, or we've found
 345          // at least 2 frame headers.
 346          while (!feof($fh) && $count < 2) {
 347              $chunk = fread($fh, 1024 * 100); // Read 100kb at a time.
 348              $count += preg_match_all('#\x00\x21\xF9\x04.{4}\x00\x2C#s', $chunk, $matches);
 349          }
 350  
 351          fclose($fh);
 352          return $count > 1;
 353      }
 354  
 355      /**
 356       * Returns if there are any printable/readable characters within an element.
 357       * This finds both node values or images with alt text.
 358       * @param object $element The given element to look at
 359       * @return bool TRUE if contains readable text, FALSE if otherwise
 360       */
 361      public function element_contains_readable_text($element): bool {
 362          if (is_a($element, 'DOMText')) {
 363              if (trim($element->wholeText) != '') {
 364                  return true;
 365              }
 366          } else {
 367              if (trim($element->nodeValue) != '' ||
 368                  ($element->hasAttribute('alt') && trim($element->getAttribute('alt')) != '')) {
 369                      return true;
 370              }
 371              if (method_exists($element, 'hasChildNodes') && $element->hasChildNodes()) {
 372                  foreach ($element->childNodes as $child) {
 373                      if ($this->element_contains_readable_text($child)) {
 374                          return true;
 375                      }
 376                  }
 377              }
 378          }
 379          return false;
 380      }
 381  
 382      /**
 383       * Returns an array of the newwindowphrases for all enabled language packs.
 384       * @return array of the newwindowphrases for all enabled language packs.
 385       */
 386      public static function get_all_newwindowphrases(): array {
 387          // Need to process all enabled lang versions of newwindowphrases.
 388          return static::get_all_phrases('newwindowphrases');
 389      }
 390  
 391      /**
 392       * Returns an array of the invalidlinkphrases for all enabled language packs.
 393       * @return array of the invalidlinkphrases for all enabled language packs.
 394       */
 395      public static function get_all_invalidlinkphrases(): array {
 396          // Need to process all enabled lang versions of invalidlinkphrases.
 397          return static::get_all_phrases('invalidlinkphrases');
 398      }
 399  
 400      /**
 401       * Returns an array of the relevant phrases for all enabled language packs.
 402       * @param string $stringname the language string identifier you want get the phrases for.
 403       * @return array of the invalidlinkphrases for all enabled language packs.
 404       */
 405      protected static function get_all_phrases(string $stringname): array {
 406          $stringmgr = get_string_manager();
 407          $allstrings = [];
 408  
 409          // Somehow, an invalid string was requested. Add exception handling for this in the future.
 410          if (!$stringmgr->string_exists($stringname, manager::PLUGINNAME)) {
 411              return $allstrings;
 412          }
 413  
 414          // Need to process all enabled lang versions of invalidlinkphrases.
 415          $enabledlangs = $stringmgr->get_list_of_translations();
 416          foreach ($enabledlangs as $lang => $value) {
 417              $tmpstring = (string)new \lang_string($stringname, manager::PLUGINNAME, null, $lang);
 418              $tmplangarray = explode('|', $tmpstring);
 419              $allstrings = array_merge($allstrings, $tmplangarray);
 420          }
 421          // Removing duplicates if a lang is enabled, yet using default 'en' due to no relevant lang file.
 422          $allstrings = array_unique($allstrings);
 423          return $allstrings;
 424      }
 425  
 426      /**
 427       * Assesses whether a string contains any readable text, which is text that
 428       * contains any characters other than whitespace characters.
 429       *
 430       * @param string $text
 431       * @return bool
 432       */
 433      public static function is_text_readable(string $text): bool {
 434          // These characters in order are a space, tab, line feed, carriage return,
 435          // NUL-byte, vertical tab and non-breaking space unicode character \xc2\xa0.
 436          $emptycharacters = " \t\n\r\0\x0B\xc2\xa0";
 437          return trim($text, $emptycharacters) != '';
 438      }
 439  }