1<?php 2 3use dokuwiki\ChangeLog\MediaChangeLog; 4use dokuwiki\File\MediaResolver; 5use dokuwiki\File\PageResolver; 6use dokuwiki\Utf8\PhpString; 7use 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 */ 18class 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×$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'] .= '&'; 940 if (is_array($search)) { 941 $search = array_map('rawurlencode', $search); 942 $link['url'] .= 's[]=' . implode('&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('&', '&', $link['url']); 1632 $link['url'] = str_replace('&amp;', '&', $link['url']); 1633 } 1634 //remove double encodings in titles 1635 $link['title'] = str_replace('&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'], ['>' => '>', '<' => '<', '"' => '"']); 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 : 2065