[ Index ]

PHP Cross Reference of DokuWiki

title

Body

[close]

/inc/parser/ -> xhtml.php (source)

   1  <?php
   2  
   3  use dokuwiki\ChangeLog\MediaChangeLog;
   4  use dokuwiki\File\MediaResolver;
   5  use dokuwiki\File\PageResolver;
   6  use dokuwiki\Utf8\PhpString;
   7  use SimplePie\Author;
   8  
   9  /**
  10   * Renderer for XHTML output
  11   *
  12   * This is DokuWiki's main renderer used to display page content in the wiki
  13   *
  14   * @author Harry Fuecks <hfuecks@gmail.com>
  15   * @author Andreas Gohr <andi@splitbrain.org>
  16   *
  17   */
  18  class Doku_Renderer_xhtml extends Doku_Renderer
  19  {
  20      /** @var array store the table of contents */
  21      public $toc = [];
  22  
  23      /** @var array A stack of section edit data */
  24      protected $sectionedits = [];
  25  
  26      /** @var int last section edit id, used by startSectionEdit */
  27      protected $lastsecid = 0;
  28  
  29      /** @var array a list of footnotes, list starts at 1! */
  30      protected $footnotes = [];
  31  
  32      /** @var int current section level */
  33      protected $lastlevel = 0;
  34      /** @var array section node tracker */
  35      protected $node = [0, 0, 0, 0, 0];
  36  
  37      /** @var string temporary $doc store */
  38      protected $store = '';
  39  
  40      /** @var array global counter, for table classes etc. */
  41      protected $_counter = []; //
  42  
  43      /** @var int counts the code and file blocks, used to provide download links */
  44      protected $_codeblock = 0;
  45  
  46      /** @var array list of allowed URL schemes */
  47      protected $schemes;
  48  
  49      /**
  50       * Register a new edit section range
  51       *
  52       * @param int $start The byte position for the edit start
  53       * @param array $data Associative array with section data:
  54       *                       Key 'name': the section name/title
  55       *                       Key 'target': the target for the section edit,
  56       *                                     e.g. 'section' or 'table'
  57       *                       Key 'hid': header id
  58       *                       Key 'codeblockOffset': actual code block index
  59       *                       Key 'start': set in startSectionEdit(),
  60       *                                    do not set yourself
  61       *                       Key 'range': calculated from 'start' and
  62       *                                    $key in finishSectionEdit(),
  63       *                                    do not set yourself
  64       * @return string  A marker class for the starting HTML element
  65       *
  66       * @author Adrian Lang <lang@cosmocode.de>
  67       */
  68      public function startSectionEdit($start, $data)
  69      {
  70          if (!is_array($data)) {
  71              msg(
  72                  sprintf(
  73                      'startSectionEdit: $data "%s" is NOT an array! One of your plugins needs an update.',
  74                      hsc((string)$data)
  75                  ),
  76                  -1
  77              );
  78  
  79              // @deprecated 2018-04-14, backward compatibility
  80              $args = func_get_args();
  81              $data = [];
  82              if (isset($args[1])) $data['target'] = $args[1];
  83              if (isset($args[2])) $data['name'] = $args[2];
  84              if (isset($args[3])) $data['hid'] = $args[3];
  85          }
  86          $data['secid'] = ++$this->lastsecid;
  87          $data['start'] = $start;
  88          $this->sectionedits[] = $data;
  89          return 'sectionedit' . $data['secid'];
  90      }
  91  
  92      /**
  93       * Finish an edit section range
  94       *
  95       * @param int $end The byte position for the edit end; null for the rest of the page
  96       *
  97       * @author Adrian Lang <lang@cosmocode.de>
  98       */
  99      public function finishSectionEdit($end = null, $hid = null)
 100      {
 101          if (count($this->sectionedits) == 0) {
 102              return;
 103          }
 104          $data = array_pop($this->sectionedits);
 105          if (!is_null($end) && $end <= $data['start']) {
 106              return;
 107          }
 108          if (!is_null($hid)) {
 109              $data['hid'] .= $hid;
 110          }
 111          $data['range'] = $data['start'] . '-' . (is_null($end) ? '' : $end);
 112          unset($data['start']);
 113          $this->doc .= '<!-- EDIT' . hsc(json_encode($data, JSON_THROW_ON_ERROR)) . ' -->';
 114      }
 115  
 116      /**
 117       * Returns the format produced by this renderer.
 118       *
 119       * @return string always 'xhtml'
 120       */
 121      public function getFormat()
 122      {
 123          return 'xhtml';
 124      }
 125  
 126      /**
 127       * Initialize the document
 128       */
 129      public function document_start()
 130      {
 131          //reset some internals
 132          $this->toc = [];
 133      }
 134  
 135      /**
 136       * Finalize the document
 137       */
 138      public function document_end()
 139      {
 140          // Finish open section edits.
 141          while ($this->sectionedits !== []) {
 142              if ($this->sectionedits[count($this->sectionedits) - 1]['start'] <= 1) {
 143                  // If there is only one section, do not write a section edit
 144                  // marker.
 145                  array_pop($this->sectionedits);
 146              } else {
 147                  $this->finishSectionEdit();
 148              }
 149          }
 150  
 151          if ($this->footnotes !== []) {
 152              $this->doc .= '<div class="footnotes">' . DOKU_LF;
 153  
 154              foreach ($this->footnotes as $id => $footnote) {
 155                  // check its not a placeholder that indicates actual footnote text is elsewhere
 156                  if (!str_starts_with($footnote, "@@FNT")) {
 157                      // open the footnote and set the anchor and backlink
 158                      $this->doc .= '<div class="fn">';
 159                      $this->doc .= '<sup><a href="#fnt__' . $id . '" id="fn__' . $id . '" class="fn_bot">';
 160                      $this->doc .= $id . ')</a></sup> ' . DOKU_LF;
 161  
 162                      // get any other footnotes that use the same markup
 163                      $alt = array_keys($this->footnotes, "@@FNT$id");
 164  
 165                      foreach ($alt as $ref) {
 166                          // set anchor and backlink for the other footnotes
 167                          $this->doc .= ', <sup><a href="#fnt__' . ($ref) . '" id="fn__' . ($ref) . '" class="fn_bot">';
 168                          $this->doc .= ($ref) . ')</a></sup> ' . DOKU_LF;
 169                      }
 170  
 171                      // add footnote markup and close this footnote
 172                      $this->doc .= '<div class="content">' . $footnote . '</div>';
 173                      $this->doc .= '</div>' . DOKU_LF;
 174                  }
 175              }
 176              $this->doc .= '</div>' . DOKU_LF;
 177          }
 178  
 179          // Prepare the TOC
 180          global $conf;
 181          if (
 182              $this->info['toc'] &&
 183              is_array($this->toc) &&
 184              $conf['tocminheads'] && count($this->toc) >= $conf['tocminheads']
 185          ) {
 186              global $TOC;
 187              $TOC = $this->toc;
 188          }
 189  
 190          // make sure there are no empty paragraphs
 191          $this->doc = preg_replace('#<p>\s*</p>#', '', $this->doc);
 192      }
 193  
 194      /**
 195       * Add an item to the TOC
 196       *
 197       * @param string $id the hash link
 198       * @param string $text the text to display
 199       * @param int $level the nesting level
 200       */
 201      public function toc_additem($id, $text, $level)
 202      {
 203          global $conf;
 204  
 205          //handle TOC
 206          if ($level >= $conf['toptoclevel'] && $level <= $conf['maxtoclevel']) {
 207              $this->toc[] = html_mktocitem($id, $text, $level - $conf['toptoclevel'] + 1);
 208          }
 209      }
 210  
 211      /**
 212       * Render a heading
 213       *
 214       * @param string $text the text to display
 215       * @param int $level header level
 216       * @param int $pos byte position in the original source
 217       * @param bool $returnonly whether to return html or write to doc attribute
 218       * @return void|string writes to doc attribute or returns html depends on $returnonly
 219       */
 220      public function header($text, $level, $pos, $returnonly = false)
 221      {
 222          global $conf;
 223  
 224          if (blank($text)) return; //skip empty headlines
 225  
 226          $hid = $this->_headerToLink($text, true);
 227  
 228          //only add items within configured levels
 229          $this->toc_additem($hid, $text, $level);
 230  
 231          // adjust $node to reflect hierarchy of levels
 232          $this->node[$level - 1]++;
 233          if ($level < $this->lastlevel) {
 234              for ($i = 0; $i < $this->lastlevel - $level; $i++) {
 235                  $this->node[$this->lastlevel - $i - 1] = 0;
 236              }
 237          }
 238          $this->lastlevel = $level;
 239  
 240          if (
 241              $level <= $conf['maxseclevel'] &&
 242              $this->sectionedits !== [] &&
 243              $this->sectionedits[count($this->sectionedits) - 1]['target'] === 'section'
 244          ) {
 245              $this->finishSectionEdit($pos - 1);
 246          }
 247  
 248          // build the header
 249          $header = DOKU_LF . '<h' . $level;
 250          if ($level <= $conf['maxseclevel']) {
 251              $data = [];
 252              $data['target'] = 'section';
 253              $data['name'] = $text;
 254              $data['hid'] = $hid;
 255              $data['codeblockOffset'] = $this->_codeblock;
 256              $header .= ' class="' . $this->startSectionEdit($pos, $data) . '"';
 257          }
 258          $header .= ' id="' . $hid . '">';
 259          $header .= $this->_xmlEntities($text);
 260          $header .= "</h$level>" . DOKU_LF;
 261  
 262          if ($returnonly) {
 263              return $header;
 264          } else {
 265              $this->doc .= $header;
 266          }
 267      }
 268  
 269      /**
 270       * Open a new section
 271       *
 272       * @param int $level section level (as determined by the previous header)
 273       */
 274      public function section_open($level)
 275      {
 276          $this->doc .= '<div class="level' . $level . '">' . DOKU_LF;
 277      }
 278  
 279      /**
 280       * Close the current section
 281       */
 282      public function section_close()
 283      {
 284          $this->doc .= DOKU_LF . '</div>' . DOKU_LF;
 285      }
 286  
 287      /**
 288       * Render plain text data
 289       *
 290       * @param $text
 291       */
 292      public function cdata($text)
 293      {
 294          $this->doc .= $this->_xmlEntities($text);
 295      }
 296  
 297      /**
 298       * Open a paragraph
 299       */
 300      public function p_open()
 301      {
 302          $this->doc .= DOKU_LF . '<p>' . DOKU_LF;
 303      }
 304  
 305      /**
 306       * Close a paragraph
 307       */
 308      public function p_close()
 309      {
 310          $this->doc .= DOKU_LF . '</p>' . DOKU_LF;
 311      }
 312  
 313      /**
 314       * Create a line break
 315       */
 316      public function linebreak()
 317      {
 318          $this->doc .= '<br/>' . DOKU_LF;
 319      }
 320  
 321      /**
 322       * Create a horizontal line
 323       */
 324      public function hr()
 325      {
 326          $this->doc .= '<hr />' . DOKU_LF;
 327      }
 328  
 329      /**
 330       * Start strong (bold) formatting
 331       */
 332      public function strong_open()
 333      {
 334          $this->doc .= '<strong>';
 335      }
 336  
 337      /**
 338       * Stop strong (bold) formatting
 339       */
 340      public function strong_close()
 341      {
 342          $this->doc .= '</strong>';
 343      }
 344  
 345      /**
 346       * Start emphasis (italics) formatting
 347       */
 348      public function emphasis_open()
 349      {
 350          $this->doc .= '<em>';
 351      }
 352  
 353      /**
 354       * Stop emphasis (italics) formatting
 355       */
 356      public function emphasis_close()
 357      {
 358          $this->doc .= '</em>';
 359      }
 360  
 361      /**
 362       * Start underline formatting
 363       */
 364      public function underline_open()
 365      {
 366          $this->doc .= '<em class="u">';
 367      }
 368  
 369      /**
 370       * Stop underline formatting
 371       */
 372      public function underline_close()
 373      {
 374          $this->doc .= '</em>';
 375      }
 376  
 377      /**
 378       * Start monospace formatting
 379       */
 380      public function monospace_open()
 381      {
 382          $this->doc .= '<code>';
 383      }
 384  
 385      /**
 386       * Stop monospace formatting
 387       */
 388      public function monospace_close()
 389      {
 390          $this->doc .= '</code>';
 391      }
 392  
 393      /**
 394       * Start a subscript
 395       */
 396      public function subscript_open()
 397      {
 398          $this->doc .= '<sub>';
 399      }
 400  
 401      /**
 402       * Stop a subscript
 403       */
 404      public function subscript_close()
 405      {
 406          $this->doc .= '</sub>';
 407      }
 408  
 409      /**
 410       * Start a superscript
 411       */
 412      public function superscript_open()
 413      {
 414          $this->doc .= '<sup>';
 415      }
 416  
 417      /**
 418       * Stop a superscript
 419       */
 420      public function superscript_close()
 421      {
 422          $this->doc .= '</sup>';
 423      }
 424  
 425      /**
 426       * Start deleted (strike-through) formatting
 427       */
 428      public function deleted_open()
 429      {
 430          $this->doc .= '<del>';
 431      }
 432  
 433      /**
 434       * Stop deleted (strike-through) formatting
 435       */
 436      public function deleted_close()
 437      {
 438          $this->doc .= '</del>';
 439      }
 440  
 441      /**
 442       * Callback for footnote start syntax
 443       *
 444       * All following content will go to the footnote instead of
 445       * the document. To achieve this the previous rendered content
 446       * is moved to $store and $doc is cleared
 447       *
 448       * @author Andreas Gohr <andi@splitbrain.org>
 449       */
 450      public function footnote_open()
 451      {
 452  
 453          // move current content to store and record footnote
 454          $this->store = $this->doc;
 455          $this->doc = '';
 456      }
 457  
 458      /**
 459       * Callback for footnote end syntax
 460       *
 461       * All rendered content is moved to the $footnotes array and the old
 462       * content is restored from $store again
 463       *
 464       * @author Andreas Gohr
 465       */
 466      public function footnote_close()
 467      {
 468          /** @var $fnid int takes track of seen footnotes, assures they are unique even across multiple docs FS#2841 */
 469          static $fnid = 0;
 470          // assign new footnote id (we start at 1)
 471          $fnid++;
 472  
 473          // recover footnote into the stack and restore old content
 474          $footnote = $this->doc;
 475          $this->doc = $this->store;
 476          $this->store = '';
 477  
 478          // check to see if this footnote has been seen before
 479          $i = array_search($footnote, $this->footnotes);
 480  
 481          if ($i === false) {
 482              // its a new footnote, add it to the $footnotes array
 483              $this->footnotes[$fnid] = $footnote;
 484          } else {
 485              // seen this one before, save a placeholder
 486              $this->footnotes[$fnid] = "@@FNT" . ($i);
 487          }
 488  
 489          // output the footnote reference and link
 490          $this->doc .= sprintf(
 491              '<sup><a href="#fn__%d" id="fnt__%d" class="fn_top">%d)</a></sup>',
 492              $fnid,
 493              $fnid,
 494              $fnid
 495          );
 496      }
 497  
 498      /**
 499       * Open an unordered list
 500       *
 501       * @param string|string[] $classes css classes - have to be valid, do not pass unfiltered user input
 502       */
 503      public function listu_open($classes = null)
 504      {
 505          $class = '';
 506          if ($classes !== null) {
 507              if (is_array($classes)) $classes = implode(' ', $classes);
 508              $class = " class=\"$classes\"";
 509          }
 510          $this->doc .= "<ul$class>" . DOKU_LF;
 511      }
 512  
 513      /**
 514       * Close an unordered list
 515       */
 516      public function listu_close()
 517      {
 518          $this->doc .= '</ul>' . DOKU_LF;
 519      }
 520  
 521      /**
 522       * Open an ordered list
 523       *
 524       * @param string|string[] $classes css classes - have to be valid, do not pass unfiltered user input
 525       */
 526      public function listo_open($classes = null)
 527      {
 528          $class = '';
 529          if ($classes !== null) {
 530              if (is_array($classes)) $classes = implode(' ', $classes);
 531              $class = " class=\"$classes\"";
 532          }
 533          $this->doc .= "<ol$class>" . DOKU_LF;
 534      }
 535  
 536      /**
 537       * Close an ordered list
 538       */
 539      public function listo_close()
 540      {
 541          $this->doc .= '</ol>' . DOKU_LF;
 542      }
 543  
 544      /**
 545       * Open a list item
 546       *
 547       * @param int $level the nesting level
 548       * @param bool $node true when a node; false when a leaf
 549       */
 550      public function listitem_open($level, $node = false)
 551      {
 552          $branching = $node ? ' node' : '';
 553          $this->doc .= '<li class="level' . $level . $branching . '">';
 554      }
 555  
 556      /**
 557       * Close a list item
 558       */
 559      public function listitem_close()
 560      {
 561          $this->doc .= '</li>' . DOKU_LF;
 562      }
 563  
 564      /**
 565       * Start the content of a list item
 566       */
 567      public function listcontent_open()
 568      {
 569          $this->doc .= '<div class="li">';
 570      }
 571  
 572      /**
 573       * Stop the content of a list item
 574       */
 575      public function listcontent_close()
 576      {
 577          $this->doc .= '</div>' . DOKU_LF;
 578      }
 579  
 580      /**
 581       * Output unformatted $text
 582       *
 583       * Defaults to $this->cdata()
 584       *
 585       * @param string $text
 586       */
 587      public function unformatted($text)
 588      {
 589          $this->doc .= $this->_xmlEntities($text);
 590      }
 591  
 592      /**
 593       * Start a block quote
 594       */
 595      public function quote_open()
 596      {
 597          $this->doc .= '<blockquote><div class="no">' . DOKU_LF;
 598      }
 599  
 600      /**
 601       * Stop a block quote
 602       */
 603      public function quote_close()
 604      {
 605          $this->doc .= '</div></blockquote>' . DOKU_LF;
 606      }
 607  
 608      /**
 609       * Output preformatted text
 610       *
 611       * @param string $text
 612       */
 613      public function preformatted($text)
 614      {
 615          $this->doc .= '<pre class="code">' . trim($this->_xmlEntities($text), "\n\r") . '</pre>' . DOKU_LF;
 616      }
 617  
 618      /**
 619       * Display text as file content, optionally syntax highlighted
 620       *
 621       * @param string $text text to show
 622       * @param string $language programming language to use for syntax highlighting
 623       * @param string $filename file path label
 624       * @param array $options assoziative array with additional geshi options
 625       */
 626      public function file($text, $language = null, $filename = null, $options = null)
 627      {
 628          $this->_highlight('file', $text, $language, $filename, $options);
 629      }
 630  
 631      /**
 632       * Display text as code content, optionally syntax highlighted
 633       *
 634       * @param string $text text to show
 635       * @param string $language programming language to use for syntax highlighting
 636       * @param string $filename file path label
 637       * @param array $options assoziative array with additional geshi options
 638       */
 639      public function code($text, $language = null, $filename = null, $options = null)
 640      {
 641          $this->_highlight('code', $text, $language, $filename, $options);
 642      }
 643  
 644      /**
 645       * Use GeSHi to highlight language syntax in code and file blocks
 646       *
 647       * @param string $type code|file
 648       * @param string $text text to show
 649       * @param string $language programming language to use for syntax highlighting
 650       * @param string $filename file path label
 651       * @param array $options assoziative array with additional geshi options
 652       * @author Andreas Gohr <andi@splitbrain.org>
 653       */
 654      public function _highlight($type, $text, $language = null, $filename = null, $options = null)
 655      {
 656          global $ID;
 657          global $lang;
 658          global $INPUT;
 659  
 660          $language = preg_replace(PREG_PATTERN_VALID_LANGUAGE, '', $language ?? '');
 661  
 662          if ($filename) {
 663              // add icon
 664              [$ext] = mimetype($filename, false);
 665              $class = preg_replace('/[^_\-a-z0-9]+/i', '_', $ext);
 666              $class = 'mediafile mf_' . $class;
 667  
 668              $offset = 0;
 669              if ($INPUT->has('codeblockOffset')) {
 670                  $offset = $INPUT->str('codeblockOffset');
 671              }
 672              $this->doc .= '<dl class="' . $type . '">' . DOKU_LF;
 673              $this->doc .= '<dt><a href="' .
 674                  exportlink(
 675                      $ID,
 676                      'code',
 677                      ['codeblock' => $offset + $this->_codeblock]
 678                  ) . '" title="' . $lang['download'] . '" class="' . $class . '">';
 679              $this->doc .= hsc($filename);
 680              $this->doc .= '</a></dt>' . DOKU_LF . '<dd>';
 681          }
 682  
 683          if (str_starts_with($text, "\n")) {
 684              $text = substr($text, 1);
 685          }
 686          if (str_ends_with($text, "\n")) {
 687              $text = substr($text, 0, -1);
 688          }
 689  
 690          if (empty($language)) { // empty is faster than is_null and can prevent '' string
 691              $this->doc .= '<pre class="' . $type . '">' . $this->_xmlEntities($text) . '</pre>' . DOKU_LF;
 692          } else {
 693              $class = 'code'; //we always need the code class to make the syntax highlighting apply
 694              if ($type != 'code') $class .= ' ' . $type;
 695  
 696              $this->doc .= "<pre class=\"$class $language\">" .
 697                  p_xhtml_cached_geshi($text, $language, '', $options) .
 698                  '</pre>' . DOKU_LF;
 699          }
 700  
 701          if ($filename) {
 702              $this->doc .= '</dd></dl>' . DOKU_LF;
 703          }
 704  
 705          $this->_codeblock++;
 706      }
 707  
 708      /**
 709       * Format an acronym
 710       *
 711       * Uses $this->acronyms
 712       *
 713       * @param string $acronym
 714       */
 715      public function acronym($acronym)
 716      {
 717  
 718          if (array_key_exists($acronym, $this->acronyms)) {
 719              $title = $this->_xmlEntities($this->acronyms[$acronym]);
 720  
 721              $this->doc .= '<abbr title="' . $title
 722                  . '">' . $this->_xmlEntities($acronym) . '</abbr>';
 723          } else {
 724              $this->doc .= $this->_xmlEntities($acronym);
 725          }
 726      }
 727  
 728      /**
 729       * Format a smiley
 730       *
 731       * Uses $this->smiley
 732       *
 733       * @param string $smiley
 734       */
 735      public function smiley($smiley)
 736      {
 737          if (isset($this->smileys[$smiley])) {
 738              $this->doc .= '<img src="' . DOKU_BASE . 'lib/images/smileys/' . $this->smileys[$smiley] .
 739                  '" class="icon smiley" alt="' . $this->_xmlEntities($smiley) . '" />';
 740          } else {
 741              $this->doc .= $this->_xmlEntities($smiley);
 742          }
 743      }
 744  
 745      /**
 746       * Format an entity
 747       *
 748       * Entities are basically small text replacements
 749       *
 750       * Uses $this->entities
 751       *
 752       * @param string $entity
 753       */
 754      public function entity($entity)
 755      {
 756          if (array_key_exists($entity, $this->entities)) {
 757              $this->doc .= $this->entities[$entity];
 758          } else {
 759              $this->doc .= $this->_xmlEntities($entity);
 760          }
 761      }
 762  
 763      /**
 764       * Typographically format a multiply sign
 765       *
 766       * Example: ($x=640, $y=480) should result in "640×480"
 767       *
 768       * @param string|int $x first value
 769       * @param string|int $y second value
 770       */
 771      public function multiplyentity($x, $y)
 772      {
 773          $this->doc .= "$x&times;$y";
 774      }
 775  
 776      /**
 777       * Render an opening single quote char (language specific)
 778       */
 779      public function singlequoteopening()
 780      {
 781          global $lang;
 782          $this->doc .= $lang['singlequoteopening'];
 783      }
 784  
 785      /**
 786       * Render a closing single quote char (language specific)
 787       */
 788      public function singlequoteclosing()
 789      {
 790          global $lang;
 791          $this->doc .= $lang['singlequoteclosing'];
 792      }
 793  
 794      /**
 795       * Render an apostrophe char (language specific)
 796       */
 797      public function apostrophe()
 798      {
 799          global $lang;
 800          $this->doc .= $lang['apostrophe'];
 801      }
 802  
 803      /**
 804       * Render an opening double quote char (language specific)
 805       */
 806      public function doublequoteopening()
 807      {
 808          global $lang;
 809          $this->doc .= $lang['doublequoteopening'];
 810      }
 811  
 812      /**
 813       * Render an closinging double quote char (language specific)
 814       */
 815      public function doublequoteclosing()
 816      {
 817          global $lang;
 818          $this->doc .= $lang['doublequoteclosing'];
 819      }
 820  
 821      /**
 822       * Render a CamelCase link
 823       *
 824       * @param string $link The link name
 825       * @param bool $returnonly whether to return html or write to doc attribute
 826       * @return void|string writes to doc attribute or returns html depends on $returnonly
 827       *
 828       * @see http://en.wikipedia.org/wiki/CamelCase
 829       */
 830      public function camelcaselink($link, $returnonly = false)
 831      {
 832          if ($returnonly) {
 833              return $this->internallink($link, $link, null, true);
 834          } else {
 835              $this->internallink($link, $link);
 836          }
 837      }
 838  
 839      /**
 840       * Render a page local link
 841       *
 842       * @param string $hash hash link identifier
 843       * @param string $name name for the link
 844       * @param bool $returnonly whether to return html or write to doc attribute
 845       * @return void|string writes to doc attribute or returns html depends on $returnonly
 846       */
 847      public function locallink($hash, $name = null, $returnonly = false)
 848      {
 849          global $ID;
 850          $name = $this->_getLinkTitle($name, $hash, $isImage);
 851          $hash = $this->_headerToLink($hash);
 852          $title = $ID . ' ↵';
 853  
 854          $doc = '<a href="#' . $hash . '" title="' . $title . '" class="wikilink1">';
 855          $doc .= $name;
 856          $doc .= '</a>';
 857  
 858          if ($returnonly) {
 859              return $doc;
 860          } else {
 861              $this->doc .= $doc;
 862          }
 863      }
 864  
 865      /**
 866       * Render an internal Wiki Link
 867       *
 868       * $search,$returnonly & $linktype are not for the renderer but are used
 869       * elsewhere - no need to implement them in other renderers
 870       *
 871       * @param string $id pageid
 872       * @param string|null $name link name
 873       * @param string|null $search adds search url param
 874       * @param bool $returnonly whether to return html or write to doc attribute
 875       * @param string $linktype type to set use of headings
 876       * @return void|string writes to doc attribute or returns html depends on $returnonly
 877       * @author Andreas Gohr <andi@splitbrain.org>
 878       */
 879      public function internallink($id, $name = null, $search = null, $returnonly = false, $linktype = 'content')
 880      {
 881          global $conf;
 882          global $ID;
 883          global $INFO;
 884  
 885          $params = '';
 886          $parts = explode('?', $id, 2);
 887          if (count($parts) === 2) {
 888              $id = $parts[0];
 889              $params = $parts[1];
 890          }
 891  
 892          // For empty $id we need to know the current $ID
 893          // We need this check because _simpleTitle needs
 894          // correct $id and resolve_pageid() use cleanID($id)
 895          // (some things could be lost)
 896          if ($id === '') {
 897              $id = $ID;
 898          }
 899  
 900          // default name is based on $id as given
 901          $default = $this->_simpleTitle($id);
 902  
 903          // now first resolve and clean up the $id
 904          $id = (new PageResolver($ID))->resolveId($id, $this->date_at, true);
 905          $exists = page_exists($id, $this->date_at, false, true);
 906  
 907          $link = [];
 908          $name = $this->_getLinkTitle($name, $default, $isImage, $id, $linktype);
 909          if (!$isImage) {
 910              if ($exists) {
 911                  $class = 'wikilink1';
 912              } else {
 913                  $class = 'wikilink2';
 914                  $link['rel'] = 'nofollow';
 915              }
 916          } else {
 917              $class = 'media';
 918          }
 919  
 920          //keep hash anchor
 921          [$id, $hash] = sexplode('#', $id, 2);
 922          if (!empty($hash)) $hash = $this->_headerToLink($hash);
 923  
 924          //prepare for formating
 925          $link['target'] = $conf['target']['wiki'];
 926          $link['style'] = '';
 927          $link['pre'] = '';
 928          $link['suf'] = '';
 929          $link['more'] = 'data-wiki-id="' . $id . '"'; // id is already cleaned
 930          $link['class'] = $class;
 931          if ($this->date_at) {
 932              $params = $params . '&at=' . rawurlencode($this->date_at);
 933          }
 934          $link['url'] = wl($id, $params);
 935          $link['name'] = $name;
 936          $link['title'] = $id;
 937          //add search string
 938          if ($search) {
 939              ($conf['userewrite']) ? $link['url'] .= '?' : $link['url'] .= '&amp;';
 940              if (is_array($search)) {
 941                  $search = array_map('rawurlencode', $search);
 942                  $link['url'] .= 's[]=' . implode('&amp;s[]=', $search);
 943              } else {
 944                  $link['url'] .= 's=' . rawurlencode($search);
 945              }
 946          }
 947  
 948          //keep hash
 949          if ($hash) $link['url'] .= '#' . $hash;
 950  
 951          //output formatted
 952          if ($returnonly) {
 953              return $this->_formatLink($link);
 954          } else {
 955              $this->doc .= $this->_formatLink($link);
 956          }
 957      }
 958  
 959      /**
 960       * Render an external link
 961       *
 962       * @param string $url full URL with scheme
 963       * @param string|array $name name for the link, array for media file
 964       * @param bool $returnonly whether to return html or write to doc attribute
 965       * @return void|string writes to doc attribute or returns html depends on $returnonly
 966       */
 967      public function externallink($url, $name = null, $returnonly = false)
 968      {
 969          global $conf;
 970  
 971          $name = $this->_getLinkTitle($name, $url, $isImage);
 972  
 973          // url might be an attack vector, only allow registered protocols
 974          if (is_null($this->schemes)) $this->schemes = getSchemes();
 975          [$scheme] = explode('://', $url);
 976          $scheme = strtolower($scheme);
 977          if (!in_array($scheme, $this->schemes)) $url = '';
 978  
 979          // is there still an URL?
 980          if (!$url) {
 981              if ($returnonly) {
 982                  return $name;
 983              } else {
 984                  $this->doc .= $name;
 985              }
 986              return;
 987          }
 988  
 989          // set class
 990          if (!$isImage) {
 991              $class = 'urlextern';
 992          } else {
 993              $class = 'media';
 994          }
 995  
 996          //prepare for formating
 997          $link = [];
 998          $link['target'] = $conf['target']['extern'];
 999          $link['style'] = '';
1000          $link['pre'] = '';
1001          $link['suf'] = '';
1002          $link['more'] = '';
1003          $link['class'] = $class;
1004          $link['url'] = $url;
1005          $link['rel'] = '';
1006  
1007          $link['name'] = $name;
1008          $link['title'] = $this->_xmlEntities($url);
1009          if ($conf['relnofollow']) $link['rel'] .= ' ugc nofollow';
1010          if ($conf['target']['extern']) $link['rel'] .= ' noopener';
1011  
1012          //output formatted
1013          if ($returnonly) {
1014              return $this->_formatLink($link);
1015          } else {
1016              $this->doc .= $this->_formatLink($link);
1017          }
1018      }
1019  
1020      /**
1021       * Render an interwiki link
1022       *
1023       * You may want to use $this->_resolveInterWiki() here
1024       *
1025       * @param string $match original link - probably not much use
1026       * @param string|array $name name for the link, array for media file
1027       * @param string $wikiName indentifier (shortcut) for the remote wiki
1028       * @param string $wikiUri the fragment parsed from the original link
1029       * @param bool $returnonly whether to return html or write to doc attribute
1030       * @return void|string writes to doc attribute or returns html depends on $returnonly
1031       */
1032      public function interwikilink($match, $name, $wikiName, $wikiUri, $returnonly = false)
1033      {
1034          global $conf;
1035  
1036          $link = [];
1037          $link['target'] = $conf['target']['interwiki'];
1038          $link['pre'] = '';
1039          $link['suf'] = '';
1040          $link['more'] = '';
1041          $link['name'] = $this->_getLinkTitle($name, $wikiUri, $isImage);
1042          $link['rel'] = '';
1043  
1044          //get interwiki URL
1045          $exists = null;
1046          $url = $this->_resolveInterWiki($wikiName, $wikiUri, $exists);
1047  
1048          if (!$isImage) {
1049              $class = preg_replace('/[^_\-a-z0-9]+/i', '_', $wikiName);
1050              $link['class'] = "interwiki iw_$class";
1051          } else {
1052              $link['class'] = 'media';
1053          }
1054  
1055          //do we stay at the same server? Use local target
1056          if (strpos($url, DOKU_URL) === 0 || strpos($url, DOKU_BASE) === 0) {
1057              $link['target'] = $conf['target']['wiki'];
1058          }
1059          if ($exists !== null && !$isImage) {
1060              if ($exists) {
1061                  $link['class'] .= ' wikilink1';
1062              } else {
1063                  $link['class'] .= ' wikilink2';
1064                  $link['rel'] .= ' nofollow';
1065              }
1066          }
1067          if ($conf['target']['interwiki']) $link['rel'] .= ' noopener';
1068  
1069          $link['url'] = $url;
1070          $link['title'] = $this->_xmlEntities($link['url']);
1071  
1072          // output formatted
1073          if ($returnonly) {
1074              if ($url == '') return $link['name'];
1075              return $this->_formatLink($link);
1076          } elseif ($url == '') {
1077              $this->doc .= $link['name'];
1078          } else $this->doc .= $this->_formatLink($link);
1079      }
1080  
1081      /**
1082       * Link to windows share
1083       *
1084       * @param string $url the link
1085       * @param string|array $name name for the link, array for media file
1086       * @param bool $returnonly whether to return html or write to doc attribute
1087       * @return void|string writes to doc attribute or returns html depends on $returnonly
1088       */
1089      public function windowssharelink($url, $name = null, $returnonly = false)
1090      {
1091          global $conf;
1092  
1093          //simple setup
1094          $link = [];
1095          $link['target'] = $conf['target']['windows'];
1096          $link['pre'] = '';
1097          $link['suf'] = '';
1098          $link['style'] = '';
1099  
1100          $link['name'] = $this->_getLinkTitle($name, $url, $isImage);
1101          if (!$isImage) {
1102              $link['class'] = 'windows';
1103          } else {
1104              $link['class'] = 'media';
1105          }
1106  
1107          $link['title'] = $this->_xmlEntities($url);
1108          $url = str_replace('\\', '/', $url);
1109          $url = 'file:///' . $url;
1110          $link['url'] = $url;
1111  
1112          //output formatted
1113          if ($returnonly) {
1114              return $this->_formatLink($link);
1115          } else {
1116              $this->doc .= $this->_formatLink($link);
1117          }
1118      }
1119  
1120      /**
1121       * Render a linked E-Mail Address
1122       *
1123       * Honors $conf['mailguard'] setting
1124       *
1125       * @param string $address Email-Address
1126       * @param string|array $name name for the link, array for media file
1127       * @param bool $returnonly whether to return html or write to doc attribute
1128       * @return void|string writes to doc attribute or returns html depends on $returnonly
1129       */
1130      public function emaillink($address, $name = null, $returnonly = false)
1131      {
1132          global $conf;
1133          //simple setup
1134          $link = [];
1135          $link['target'] = '';
1136          $link['pre'] = '';
1137          $link['suf'] = '';
1138          $link['style'] = '';
1139          $link['more'] = '';
1140  
1141          $name = $this->_getLinkTitle($name, '', $isImage);
1142          if (!$isImage) {
1143              $link['class'] = 'mail';
1144          } else {
1145              $link['class'] = 'media';
1146          }
1147  
1148          $address = $this->_xmlEntities($address);
1149          $address = obfuscate($address);
1150  
1151          $title = $address;
1152  
1153          if (empty($name)) {
1154              $name = $address;
1155          }
1156  
1157          if ($conf['mailguard'] == 'visible') $address = rawurlencode($address);
1158  
1159          $link['url'] = 'mailto:' . $address;
1160          $link['name'] = $name;
1161          $link['title'] = $title;
1162  
1163          //output formatted
1164          if ($returnonly) {
1165              return $this->_formatLink($link);
1166          } else {
1167              $this->doc .= $this->_formatLink($link);
1168          }
1169      }
1170  
1171      /**
1172       * Render an internal media file
1173       *
1174       * @param string $src media ID
1175       * @param string $title descriptive text
1176       * @param string $align left|center|right
1177       * @param int $width width of media in pixel
1178       * @param int $height height of media in pixel
1179       * @param string $cache cache|recache|nocache
1180       * @param string $linking linkonly|detail|nolink
1181       * @param bool $return return HTML instead of adding to $doc
1182       * @return void|string writes to doc attribute or returns html depends on $return
1183       */
1184      public function internalmedia(
1185          $src,
1186          $title = null,
1187          $align = null,
1188          $width = null,
1189          $height = null,
1190          $cache = null,
1191          $linking = null,
1192          $return = false
1193      ) {
1194          global $ID;
1195          if (strpos($src, '#') !== false) {
1196              [$src, $hash] = sexplode('#', $src, 2);
1197          }
1198          $src = (new MediaResolver($ID))->resolveId($src, $this->date_at, true);
1199          $exists = media_exists($src);
1200  
1201          $noLink = false;
1202          $render = $linking != 'linkonly';
1203          $link = $this->_getMediaLinkConf($src, $title, $align, $width, $height, $cache, $render);
1204  
1205          [$ext, $mime] = mimetype($src, false);
1206          if (str_starts_with($mime, 'image') && $render) {
1207              $link['url'] = ml(
1208                  $src,
1209                  [
1210                      'id' => $ID,
1211                      'cache' => $cache,
1212                      'rev' => $this->_getLastMediaRevisionAt($src)
1213                  ],
1214                  ($linking == 'direct')
1215              );
1216          } elseif (($mime == 'application/x-shockwave-flash' || media_supportedav($mime)) && $render) {
1217              // don't link movies
1218              $noLink = true;
1219          } else {
1220              // add file icons
1221              $class = preg_replace('/[^_\-a-z0-9]+/i', '_', $ext);
1222              $link['class'] .= ' mediafile mf_' . $class;
1223              $link['url'] = ml(
1224                  $src,
1225                  [
1226                      'id' => $ID,
1227                      'cache' => $cache,
1228                      'rev' => $this->_getLastMediaRevisionAt($src)
1229                  ],
1230                  true
1231              );
1232              if ($exists) $link['title'] .= ' (' . filesize_h(filesize(mediaFN($src))) . ')';
1233          }
1234  
1235          if (!empty($hash)) $link['url'] .= '#' . $hash;
1236  
1237          //markup non existing files
1238          if (!$exists) {
1239              $link['class'] .= ' wikilink2';
1240          }
1241  
1242          //output formatted
1243          if ($return) {
1244              if ($linking == 'nolink' || $noLink) {
1245                  return $link['name'];
1246              } else {
1247                  return $this->_formatLink($link);
1248              }
1249          } elseif ($linking == 'nolink' || $noLink) {
1250              $this->doc .= $link['name'];
1251          } else {
1252              $this->doc .= $this->_formatLink($link);
1253          }
1254      }
1255  
1256      /**
1257       * Render an external media file
1258       *
1259       * @param string $src full media URL
1260       * @param string $title descriptive text
1261       * @param string $align left|center|right
1262       * @param int $width width of media in pixel
1263       * @param int $height height of media in pixel
1264       * @param string $cache cache|recache|nocache
1265       * @param string $linking linkonly|detail|nolink
1266       * @param bool $return return HTML instead of adding to $doc
1267       * @return void|string writes to doc attribute or returns html depends on $return
1268       */
1269      public function externalmedia(
1270          $src,
1271          $title = null,
1272          $align = null,
1273          $width = null,
1274          $height = null,
1275          $cache = null,
1276          $linking = null,
1277          $return = false
1278      ) {
1279          if (link_isinterwiki($src)) {
1280              [$shortcut, $reference] = sexplode('>', $src, 2, '');
1281              $exists = null;
1282              $src = $this->_resolveInterWiki($shortcut, $reference, $exists);
1283              if ($src == '' && empty($title)) {
1284                  // make sure at least something will be shown in this case
1285                  $title = $reference;
1286              }
1287          }
1288          [$src, $hash] = sexplode('#', $src, 2);
1289          $noLink = false;
1290          if ($src == '') {
1291              // only output plaintext without link if there is no src
1292              $noLink = true;
1293          }
1294          $render = $linking != 'linkonly';
1295          $link = $this->_getMediaLinkConf($src, $title, $align, $width, $height, $cache, $render);
1296  
1297          $link['url'] = ml($src, ['cache' => $cache]);
1298  
1299          [$ext, $mime] = mimetype($src, false);
1300          if (str_starts_with($mime, 'image') && $render) {
1301              // link only jpeg images
1302              // if ($ext != 'jpg' && $ext != 'jpeg') $noLink = true;
1303          } elseif (($mime == 'application/x-shockwave-flash' || media_supportedav($mime)) && $render) {
1304              // don't link movies
1305              $noLink = true;
1306          } else {
1307              // add file icons
1308              $class = preg_replace('/[^_\-a-z0-9]+/i', '_', $ext);
1309              $link['class'] .= ' mediafile mf_' . $class;
1310          }
1311  
1312          if ($hash) $link['url'] .= '#' . $hash;
1313  
1314          //output formatted
1315          if ($return) {
1316              if ($linking == 'nolink' || $noLink) return $link['name'];
1317              else return $this->_formatLink($link);
1318          } elseif ($linking == 'nolink' || $noLink) {
1319              $this->doc .= $link['name'];
1320          } else $this->doc .= $this->_formatLink($link);
1321      }
1322  
1323      /**
1324       * Renders an RSS feed
1325       *
1326       * @param string $url URL of the feed
1327       * @param array $params Finetuning of the output
1328       *
1329       * @author Andreas Gohr <andi@splitbrain.org>
1330       */
1331      public function rss($url, $params)
1332      {
1333          global $lang;
1334          global $conf;
1335  
1336          require_once (DOKU_INC . 'inc/FeedParser.php');
1337          $feed = new FeedParser();
1338          $feed->set_feed_url($url);
1339  
1340          //disable warning while fetching
1341          if (!defined('DOKU_E_LEVEL')) {
1342              $elvl = error_reporting(E_ERROR);
1343          }
1344          $rc = $feed->init();
1345          if (isset($elvl)) {
1346              error_reporting($elvl);
1347          }
1348  
1349          if ($params['nosort']) $feed->enable_order_by_date(false);
1350  
1351          //decide on start and end
1352          if ($params['reverse']) {
1353              $mod = -1;
1354              $start = $feed->get_item_quantity() - 1;
1355              $end = $start - ($params['max']);
1356              $end = ($end < -1) ? -1 : $end;
1357          } else {
1358              $mod = 1;
1359              $start = 0;
1360              $end = $feed->get_item_quantity();
1361              $end = ($end > $params['max']) ? $params['max'] : $end;
1362          }
1363  
1364          $this->doc .= '<ul class="rss">';
1365          if ($rc) {
1366              for ($x = $start; $x != $end; $x += $mod) {
1367                  $item = $feed->get_item($x);
1368                  $this->doc .= '<li><div class="li">';
1369  
1370                  $lnkurl = $item->get_permalink();
1371                  $title = html_entity_decode($item->get_title(), ENT_QUOTES, 'UTF-8');
1372  
1373                  // support feeds without links
1374                  if ($lnkurl) {
1375                      $this->externallink($item->get_permalink(), $title);
1376                  } else {
1377                      $this->doc .= ' ' . hsc($item->get_title());
1378                  }
1379                  if ($params['author']) {
1380                      $author = $item->get_author(0);
1381                      if ($author instanceof Author) {
1382                          $name = $author->get_name();
1383                          if (!$name) $name = $author->get_email();
1384                          if ($name) $this->doc .= ' ' . $lang['by'] . ' ' . hsc($name);
1385                      }
1386                  }
1387                  if ($params['date']) {
1388                      $this->doc .= ' (' . $item->get_local_date($conf['dformat']) . ')';
1389                  }
1390                  if ($params['details']) {
1391                      $desc = $item->get_description();
1392                      $desc = strip_tags($desc);
1393                      $desc = html_entity_decode($desc, ENT_QUOTES, 'UTF-8');
1394                      $this->doc .= '<div class="detail">';
1395                      $this->doc .= hsc($desc);
1396                      $this->doc .= '</div>';
1397                  }
1398  
1399                  $this->doc .= '</div></li>';
1400              }
1401          } else {
1402              $this->doc .= '<li><div class="li">';
1403              $this->doc .= '<em>' . $lang['rssfailed'] . '</em>';
1404              $this->externallink($url);
1405              if ($conf['allowdebug']) {
1406                  $this->doc .= '<!--' . hsc($feed->error) . '-->';
1407              }
1408              $this->doc .= '</div></li>';
1409          }
1410          $this->doc .= '</ul>';
1411      }
1412  
1413      /**
1414       * Start a table
1415       *
1416       * @param int $maxcols maximum number of columns
1417       * @param int $numrows NOT IMPLEMENTED
1418       * @param int $pos byte position in the original source
1419       * @param string|string[] $classes css classes - have to be valid, do not pass unfiltered user input
1420       */
1421      public function table_open($maxcols = null, $numrows = null, $pos = null, $classes = null)
1422      {
1423          // initialize the row counter used for classes
1424          $this->_counter['row_counter'] = 0;
1425          $class = 'table';
1426          if ($classes !== null) {
1427              if (is_array($classes)) $classes = implode(' ', $classes);
1428              $class .= ' ' . $classes;
1429          }
1430          if ($pos !== null) {
1431              $hid = $this->_headerToLink($class, true);
1432              $data = [];
1433              $data['target'] = 'table';
1434              $data['name'] = '';
1435              $data['hid'] = $hid;
1436              $class .= ' ' . $this->startSectionEdit($pos, $data);
1437          }
1438          $this->doc .= '<div class="' . $class . '"><table class="inline">' .
1439              DOKU_LF;
1440      }
1441  
1442      /**
1443       * Close a table
1444       *
1445       * @param int $pos byte position in the original source
1446       */
1447      public function table_close($pos = null)
1448      {
1449          $this->doc .= '</table></div>' . DOKU_LF;
1450          if ($pos !== null) {
1451              $this->finishSectionEdit($pos);
1452          }
1453      }
1454  
1455      /**
1456       * Open a table header
1457       */
1458      public function tablethead_open()
1459      {
1460          $this->doc .= DOKU_TAB . '<thead>' . DOKU_LF;
1461      }
1462  
1463      /**
1464       * Close a table header
1465       */
1466      public function tablethead_close()
1467      {
1468          $this->doc .= DOKU_TAB . '</thead>' . DOKU_LF;
1469      }
1470  
1471      /**
1472       * Open a table body
1473       */
1474      public function tabletbody_open()
1475      {
1476          $this->doc .= DOKU_TAB . '<tbody>' . DOKU_LF;
1477      }
1478  
1479      /**
1480       * Close a table body
1481       */
1482      public function tabletbody_close()
1483      {
1484          $this->doc .= DOKU_TAB . '</tbody>' . DOKU_LF;
1485      }
1486  
1487      /**
1488       * Open a table footer
1489       */
1490      public function tabletfoot_open()
1491      {
1492          $this->doc .= DOKU_TAB . '<tfoot>' . DOKU_LF;
1493      }
1494  
1495      /**
1496       * Close a table footer
1497       */
1498      public function tabletfoot_close()
1499      {
1500          $this->doc .= DOKU_TAB . '</tfoot>' . DOKU_LF;
1501      }
1502  
1503      /**
1504       * Open a table row
1505       *
1506       * @param string|string[] $classes css classes - have to be valid, do not pass unfiltered user input
1507       */
1508      public function tablerow_open($classes = null)
1509      {
1510          // initialize the cell counter used for classes
1511          $this->_counter['cell_counter'] = 0;
1512          $class = 'row' . $this->_counter['row_counter']++;
1513          if ($classes !== null) {
1514              if (is_array($classes)) $classes = implode(' ', $classes);
1515              $class .= ' ' . $classes;
1516          }
1517          $this->doc .= DOKU_TAB . '<tr class="' . $class . '">' . DOKU_LF . DOKU_TAB . DOKU_TAB;
1518      }
1519  
1520      /**
1521       * Close a table row
1522       */
1523      public function tablerow_close()
1524      {
1525          $this->doc .= DOKU_LF . DOKU_TAB . '</tr>' . DOKU_LF;
1526      }
1527  
1528      /**
1529       * Open a table header cell
1530       *
1531       * @param int $colspan
1532       * @param string $align left|center|right
1533       * @param int $rowspan
1534       * @param string|string[] $classes css classes - have to be valid, do not pass unfiltered user input
1535       */
1536      public function tableheader_open($colspan = 1, $align = null, $rowspan = 1, $classes = null)
1537      {
1538          $class = 'class="col' . $this->_counter['cell_counter']++;
1539          if (!is_null($align)) {
1540              $class .= ' ' . $align . 'align';
1541          }
1542          if ($classes !== null) {
1543              if (is_array($classes)) $classes = implode(' ', $classes);
1544              $class .= ' ' . $classes;
1545          }
1546          $class .= '"';
1547          $this->doc .= '<th ' . $class;
1548          if ($colspan > 1) {
1549              $this->_counter['cell_counter'] += $colspan - 1;
1550              $this->doc .= ' colspan="' . $colspan . '"';
1551          }
1552          if ($rowspan > 1) {
1553              $this->doc .= ' rowspan="' . $rowspan . '"';
1554          }
1555          $this->doc .= '>';
1556      }
1557  
1558      /**
1559       * Close a table header cell
1560       */
1561      public function tableheader_close()
1562      {
1563          $this->doc .= '</th>';
1564      }
1565  
1566      /**
1567       * Open a table cell
1568       *
1569       * @param int $colspan
1570       * @param string $align left|center|right
1571       * @param int $rowspan
1572       * @param string|string[] $classes css classes - have to be valid, do not pass unfiltered user input
1573       */
1574      public function tablecell_open($colspan = 1, $align = null, $rowspan = 1, $classes = null)
1575      {
1576          $class = 'class="col' . $this->_counter['cell_counter']++;
1577          if (!is_null($align)) {
1578              $class .= ' ' . $align . 'align';
1579          }
1580          if ($classes !== null) {
1581              if (is_array($classes)) $classes = implode(' ', $classes);
1582              $class .= ' ' . $classes;
1583          }
1584          $class .= '"';
1585          $this->doc .= '<td ' . $class;
1586          if ($colspan > 1) {
1587              $this->_counter['cell_counter'] += $colspan - 1;
1588              $this->doc .= ' colspan="' . $colspan . '"';
1589          }
1590          if ($rowspan > 1) {
1591              $this->doc .= ' rowspan="' . $rowspan . '"';
1592          }
1593          $this->doc .= '>';
1594      }
1595  
1596      /**
1597       * Close a table cell
1598       */
1599      public function tablecell_close()
1600      {
1601          $this->doc .= '</td>';
1602      }
1603  
1604      /**
1605       * Returns the current header level.
1606       * (required e.g. by the filelist plugin)
1607       *
1608       * @return int The current header level
1609       */
1610      public function getLastlevel()
1611      {
1612          return $this->lastlevel;
1613      }
1614  
1615      #region Utility functions
1616  
1617      /**
1618       * Build a link
1619       *
1620       * Assembles all parts defined in $link returns HTML for the link
1621       *
1622       * @param array $link attributes of a link
1623       * @return string
1624       *
1625       * @author Andreas Gohr <andi@splitbrain.org>
1626       */
1627      public function _formatLink($link)
1628      {
1629          //make sure the url is XHTML compliant (skip mailto)
1630          if (!str_starts_with($link['url'], 'mailto:')) {
1631              $link['url'] = str_replace('&', '&amp;', $link['url']);
1632              $link['url'] = str_replace('&amp;amp;', '&amp;', $link['url']);
1633          }
1634          //remove double encodings in titles
1635          $link['title'] = str_replace('&amp;amp;', '&amp;', $link['title']);
1636  
1637          // be sure there are no bad chars in url or title
1638          // (we can't do this for name because it can contain an img tag)
1639          $link['url'] = strtr($link['url'], ['>' => '%3E', '<' => '%3C', '"' => '%22']);
1640          $link['title'] = strtr($link['title'], ['>' => '&gt;', '<' => '&lt;', '"' => '&quot;']);
1641  
1642          $ret = '';
1643          $ret .= $link['pre'];
1644          $ret .= '<a href="' . $link['url'] . '"';
1645          if (!empty($link['class'])) $ret .= ' class="' . $link['class'] . '"';
1646          if (!empty($link['target'])) $ret .= ' target="' . $link['target'] . '"';
1647          if (!empty($link['title'])) $ret .= ' title="' . $link['title'] . '"';
1648          if (!empty($link['style'])) $ret .= ' style="' . $link['style'] . '"';
1649          if (!empty($link['rel'])) $ret .= ' rel="' . trim($link['rel']) . '"';
1650          if (!empty($link['more'])) $ret .= ' ' . $link['more'];
1651          $ret .= '>';
1652          $ret .= $link['name'];
1653          $ret .= '</a>';
1654          $ret .= $link['suf'];
1655          return $ret;
1656      }
1657  
1658      /**
1659       * Renders internal and external media
1660       *
1661       * @param string $src media ID
1662       * @param string $title descriptive text
1663       * @param string $align left|center|right
1664       * @param int $width width of media in pixel
1665       * @param int $height height of media in pixel
1666       * @param string $cache cache|recache|nocache
1667       * @param bool $render should the media be embedded inline or just linked
1668       * @return string
1669       * @author Andreas Gohr <andi@splitbrain.org>
1670       */
1671      public function _media(
1672          $src,
1673          $title = null,
1674          $align = null,
1675          $width = null,
1676          $height = null,
1677          $cache = null,
1678          $render = true
1679      ) {
1680  
1681          $ret = '';
1682  
1683          [$ext, $mime] = mimetype($src);
1684          if (str_starts_with($mime, 'image')) {
1685              // first get the $title
1686              if (!is_null($title)) {
1687                  $title = $this->_xmlEntities($title);
1688              } elseif ($ext == 'jpg' || $ext == 'jpeg') {
1689                  //try to use the caption from IPTC/EXIF
1690                  require_once (DOKU_INC . 'inc/JpegMeta.php');
1691                  $jpeg = new JpegMeta(mediaFN($src));
1692                  $cap = $jpeg->getTitle();
1693                  if (!empty($cap)) {
1694                      $title = $this->_xmlEntities($cap);
1695                  }
1696              }
1697              if (!$render) {
1698                  // if the picture is not supposed to be rendered
1699                  // return the title of the picture
1700                  if ($title === null || $title === "") {
1701                      // just show the sourcename
1702                      $title = $this->_xmlEntities(PhpString::basename(noNS($src)));
1703                  }
1704                  return $title;
1705              }
1706              //add image tag
1707              $ret .= '<img src="' . ml(
1708                  $src,
1709                  [
1710                          'w' => $width,
1711                          'h' => $height,
1712                          'cache' => $cache,
1713                          'rev' => $this->_getLastMediaRevisionAt($src)
1714                      ]
1715              ) . '"';
1716              $ret .= ' class="media' . $align . '"';
1717              $ret .= ' loading="lazy"';
1718  
1719              if ($title) {
1720                  $ret .= ' title="' . $title . '"';
1721                  $ret .= ' alt="' . $title . '"';
1722              } else {
1723                  $ret .= ' alt=""';
1724              }
1725  
1726              if (!is_null($width)) {
1727                  $ret .= ' width="' . $this->_xmlEntities($width) . '"';
1728              }
1729  
1730              if (!is_null($height)) {
1731                  $ret .= ' height="' . $this->_xmlEntities($height) . '"';
1732              }
1733  
1734              $ret .= ' />';
1735          } elseif (media_supportedav($mime, 'video') || media_supportedav($mime, 'audio')) {
1736              // first get the $title
1737              $title ??= false;
1738              if (!$render) {
1739                  // if the file is not supposed to be rendered
1740                  // return the title of the file (just the sourcename if there is no title)
1741                  return $this->_xmlEntities($title ?: PhpString::basename(noNS($src)));
1742              }
1743  
1744              $att = [];
1745              $att['class'] = "media$align";
1746              if ($title) {
1747                  $att['title'] = $title;
1748              }
1749  
1750              if (media_supportedav($mime, 'video')) {
1751                  //add video
1752                  $ret .= $this->_video($src, $width, $height, $att);
1753              }
1754              if (media_supportedav($mime, 'audio')) {
1755                  //add audio
1756                  $ret .= $this->_audio($src, $att);
1757              }
1758          } elseif ($mime == 'application/x-shockwave-flash') {
1759              if (!$render) {
1760                  // if the flash is not supposed to be rendered
1761                  // return the title of the flash
1762                  if (!$title) {
1763                      // just show the sourcename
1764                      $title = PhpString::basename(noNS($src));
1765                  }
1766                  return $this->_xmlEntities($title);
1767              }
1768  
1769              $att = [];
1770              $att['class'] = "media$align";
1771              if ($align == 'right') $att['align'] = 'right';
1772              if ($align == 'left') $att['align'] = 'left';
1773              $ret .= html_flashobject(
1774                  ml($src, ['cache' => $cache], true, '&'),
1775                  $width,
1776                  $height,
1777                  ['quality' => 'high'],
1778                  null,
1779                  $att,
1780                  $this->_xmlEntities($title)
1781              );
1782          } elseif ($title) {
1783              // well at least we have a title to display
1784              $ret .= $this->_xmlEntities($title);
1785          } else {
1786              // just show the sourcename
1787              $ret .= $this->_xmlEntities(PhpString::basename(noNS($src)));
1788          }
1789  
1790          return $ret;
1791      }
1792  
1793      /**
1794       * Escape string for output
1795       *
1796       * @param $string
1797       * @return string
1798       */
1799      public function _xmlEntities($string)
1800      {
1801          return hsc($string);
1802      }
1803  
1804  
1805      /**
1806       * Construct a title and handle images in titles
1807       *
1808       * @param string|array $title either string title or media array
1809       * @param string $default default title if nothing else is found
1810       * @param bool $isImage will be set to true if it's a media file
1811       * @param null|string $id linked page id (used to extract title from first heading)
1812       * @param string $linktype content|navigation
1813       * @return string      HTML of the title, might be full image tag or just escaped text
1814       * @author Harry Fuecks <hfuecks@gmail.com>
1815       */
1816      public function _getLinkTitle($title, $default, &$isImage, $id = null, $linktype = 'content')
1817      {
1818          $isImage = false;
1819          if (is_array($title)) {
1820              $isImage = true;
1821              return $this->_imageTitle($title);
1822          } elseif (is_null($title) || trim($title) == '') {
1823              if (useHeading($linktype) && $id) {
1824                  $heading = p_get_first_heading($id);
1825                  if (!blank($heading)) {
1826                      return $this->_xmlEntities($heading);
1827                  }
1828              }
1829              return $this->_xmlEntities($default);
1830          } else {
1831              return $this->_xmlEntities($title);
1832          }
1833      }
1834  
1835      /**
1836       * Returns HTML code for images used in link titles
1837       *
1838       * @param array $img
1839       * @return string HTML img tag or similar
1840       * @author Andreas Gohr <andi@splitbrain.org>
1841       */
1842      public function _imageTitle($img)
1843      {
1844          global $ID;
1845  
1846          // some fixes on $img['src']
1847          // see internalmedia() and externalmedia()
1848          [$img['src']] = explode('#', $img['src'], 2);
1849          if ($img['type'] == 'internalmedia') {
1850              $img['src'] = (new MediaResolver($ID))->resolveId($img['src'], $this->date_at, true);
1851          }
1852  
1853          return $this->_media(
1854              $img['src'],
1855              $img['title'],
1856              $img['align'],
1857              $img['width'],
1858              $img['height'],
1859              $img['cache']
1860          );
1861      }
1862  
1863      /**
1864       * helperfunction to return a basic link to a media
1865       *
1866       * used in internalmedia() and externalmedia()
1867       *
1868       * @param string $src media ID
1869       * @param string $title descriptive text
1870       * @param string $align left|center|right
1871       * @param int $width width of media in pixel
1872       * @param int $height height of media in pixel
1873       * @param string $cache cache|recache|nocache
1874       * @param bool $render should the media be embedded inline or just linked
1875       * @return array associative array with link config
1876       * @author   Pierre Spring <pierre.spring@liip.ch>
1877       */
1878      public function _getMediaLinkConf($src, $title, $align, $width, $height, $cache, $render)
1879      {
1880          global $conf;
1881  
1882          $link = [];
1883          $link['class'] = 'media';
1884          $link['style'] = '';
1885          $link['pre'] = '';
1886          $link['suf'] = '';
1887          $link['more'] = '';
1888          $link['target'] = $conf['target']['media'];
1889          if ($conf['target']['media']) $link['rel'] = 'noopener';
1890          $link['title'] = $this->_xmlEntities($src);
1891          $link['name'] = $this->_media($src, $title, $align, $width, $height, $cache, $render);
1892  
1893          return $link;
1894      }
1895  
1896      /**
1897       * Embed video(s) in HTML
1898       *
1899       * @param string $src - ID of video to embed
1900       * @param int $width - width of the video in pixels
1901       * @param int $height - height of the video in pixels
1902       * @param array $atts - additional attributes for the <video> tag
1903       * @return string
1904       * @author Schplurtz le Déboulonné <Schplurtz@laposte.net>
1905       *
1906       * @author Anika Henke <anika@selfthinker.org>
1907       */
1908      public function _video($src, $width, $height, $atts = null)
1909      {
1910          // prepare width and height
1911          if (is_null($atts)) $atts = [];
1912          $atts['width'] = (int)$width;
1913          $atts['height'] = (int)$height;
1914          if (!$atts['width']) $atts['width'] = 320;
1915          if (!$atts['height']) $atts['height'] = 240;
1916  
1917          $posterUrl = '';
1918          $files = [];
1919          $tracks = [];
1920          $isExternal = media_isexternal($src);
1921  
1922          if ($isExternal) {
1923              // take direct source for external files
1924              [/* ext */, $srcMime] = mimetype($src);
1925              $files[$srcMime] = $src;
1926          } else {
1927              // prepare alternative formats
1928              $extensions = ['webm', 'ogv', 'mp4'];
1929              $files = media_alternativefiles($src, $extensions);
1930              $poster = media_alternativefiles($src, ['jpg', 'png']);
1931              $tracks = media_trackfiles($src);
1932              if (!empty($poster)) {
1933                  $posterUrl = ml(reset($poster), '', true, '&');
1934              }
1935          }
1936  
1937          $out = '';
1938          // open video tag
1939          $out .= '<video ' . buildAttributes($atts) . ' controls="controls"';
1940          if ($posterUrl) $out .= ' poster="' . hsc($posterUrl) . '"';
1941          $out .= '>' . NL;
1942          $fallback = '';
1943  
1944          // output source for each alternative video format
1945          foreach ($files as $mime => $file) {
1946              if ($isExternal) {
1947                  $url = $file;
1948                  $linkType = 'externalmedia';
1949              } else {
1950                  $url = ml($file, '', true, '&');
1951                  $linkType = 'internalmedia';
1952              }
1953              $title = empty($atts['title'])
1954                  ? $this->_xmlEntities(PhpString::basename(noNS($file)))
1955                  : $atts['title'];
1956  
1957              $out .= '<source src="' . hsc($url) . '" type="' . $mime . '" />' . NL;
1958              // alternative content (just a link to the file)
1959              $fallback .= $this->$linkType(
1960                  $file,
1961                  $title,
1962                  null,
1963                  null,
1964                  null,
1965                  $cache = null,
1966                  $linking = 'linkonly',
1967                  $return = true
1968              );
1969          }
1970  
1971          // output each track if any
1972          foreach ($tracks as $trackid => $info) {
1973              [$kind, $srclang] = array_map('hsc', $info);
1974              $out .= "<track kind=\"$kind\" srclang=\"$srclang\" ";
1975              $out .= "label=\"$srclang\" ";
1976              $out .= 'src="' . ml($trackid, '', true) . '">' . NL;
1977          }
1978  
1979          // finish
1980          $out .= $fallback;
1981          $out .= '</video>' . NL;
1982          return $out;
1983      }
1984  
1985      /**
1986       * Embed audio in HTML
1987       *
1988       * @param string $src - ID of audio to embed
1989       * @param array $atts - additional attributes for the <audio> tag
1990       * @return string
1991       * @author Anika Henke <anika@selfthinker.org>
1992       *
1993       */
1994      public function _audio($src, $atts = [])
1995      {
1996          $files = [];
1997          $isExternal = media_isexternal($src);
1998  
1999          if ($isExternal) {
2000              // take direct source for external files
2001              [/* ext */, $srcMime] = mimetype($src);
2002              $files[$srcMime] = $src;
2003          } else {
2004              // prepare alternative formats
2005              $extensions = ['ogg', 'mp3', 'wav'];
2006              $files = media_alternativefiles($src, $extensions);
2007          }
2008  
2009          $out = '';
2010          // open audio tag
2011          $out .= '<audio ' . buildAttributes($atts) . ' controls="controls">' . NL;
2012          $fallback = '';
2013  
2014          // output source for each alternative audio format
2015          foreach ($files as $mime => $file) {
2016              if ($isExternal) {
2017                  $url = $file;
2018                  $linkType = 'externalmedia';
2019              } else {
2020                  $url = ml($file, '', true, '&');
2021                  $linkType = 'internalmedia';
2022              }
2023              $title = $atts['title'] ?: $this->_xmlEntities(PhpString::basename(noNS($file)));
2024  
2025              $out .= '<source src="' . hsc($url) . '" type="' . $mime . '" />' . NL;
2026              // alternative content (just a link to the file)
2027              $fallback .= $this->$linkType(
2028                  $file,
2029                  $title,
2030                  null,
2031                  null,
2032                  null,
2033                  $cache = null,
2034                  $linking = 'linkonly',
2035                  $return = true
2036              );
2037          }
2038  
2039          // finish
2040          $out .= $fallback;
2041          $out .= '</audio>' . NL;
2042          return $out;
2043      }
2044  
2045      /**
2046       * _getLastMediaRevisionAt is a helperfunction to internalmedia() and _media()
2047       * which returns an existing media revision less or equal to rev or date_at
2048       *
2049       * @param string $media_id
2050       * @access protected
2051       * @return string revision ('' for current)
2052       * @author lisps
2053       */
2054      protected function _getLastMediaRevisionAt($media_id)
2055      {
2056          if (!$this->date_at || media_isexternal($media_id)) return '';
2057          $changelog = new MediaChangeLog($media_id);
2058          return $changelog->getLastRevisionAt($this->date_at);
2059      }
2060  
2061      #endregion
2062  }
2063  
2064  //Setup VIM: ex: et ts=4 :