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