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'] . '-' . (is_null($end) ? '' : $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 = 0; 686 if ($INPUT->has('codeblockOffset')) { 687 $offset = $INPUT->str('codeblockOffset'); 688 } 689 $this->doc .= '<dl class="' . $type . '">' . DOKU_LF; 690 $this->doc .= '<dt><a href="' . 691 exportlink( 692 $ID, 693 'code', 694 ['codeblock' => $offset + $this->_codeblock] 695 ) . '" title="' . $lang['download'] . '" class="' . $class . '">'; 696 $this->doc .= hsc($filename); 697 $this->doc .= '</a></dt>' . DOKU_LF . '<dd>'; 698 } 699 700 if (str_starts_with($text, "\n")) { 701 $text = substr($text, 1); 702 } 703 if (str_ends_with($text, "\n")) { 704 $text = substr($text, 0, -1); 705 } 706 707 if (empty($language)) { // empty is faster than is_null and can prevent '' string 708 $this->doc .= '<pre class="' . $type . '">' . $this->_xmlEntities($text) . '</pre>' . DOKU_LF; 709 } else { 710 $class = 'code'; //we always need the code class to make the syntax highlighting apply 711 if ($type != 'code') $class .= ' ' . $type; 712 713 $this->doc .= "<pre class=\"$class $language\">" . 714 p_xhtml_cached_geshi($text, $language, '', $options) . 715 '</pre>' . DOKU_LF; 716 } 717 718 if ($filename) { 719 $this->doc .= '</dd></dl>' . DOKU_LF; 720 } 721 722 $this->_codeblock++; 723 } 724 725 /** 726 * Format an acronym 727 * 728 * Uses $this->acronyms 729 * 730 * @param string $acronym 731 */ 732 public function acronym($acronym) 733 { 734 735 if (array_key_exists($acronym, $this->acronyms)) { 736 $title = $this->_xmlEntities($this->acronyms[$acronym]); 737 738 $this->doc .= '<abbr title="' . $title 739 . '">' . $this->_xmlEntities($acronym) . '</abbr>'; 740 } else { 741 $this->doc .= $this->_xmlEntities($acronym); 742 } 743 } 744 745 /** 746 * Format a smiley 747 * 748 * Uses $this->smiley 749 * 750 * @param string $smiley 751 */ 752 public function smiley($smiley) 753 { 754 if (isset($this->smileys[$smiley])) { 755 $this->doc .= '<img src="' . DOKU_BASE . 'lib/images/smileys/' . $this->smileys[$smiley] . 756 '" class="icon smiley" alt="' . $this->_xmlEntities($smiley) . '" />'; 757 } else { 758 $this->doc .= $this->_xmlEntities($smiley); 759 } 760 } 761 762 /** 763 * Format an entity 764 * 765 * Entities are basically small text replacements 766 * 767 * Uses $this->entities 768 * 769 * @param string $entity 770 */ 771 public function entity($entity) 772 { 773 if (array_key_exists($entity, $this->entities)) { 774 $this->doc .= $this->entities[$entity]; 775 } else { 776 $this->doc .= $this->_xmlEntities($entity); 777 } 778 } 779 780 /** 781 * Typographically format a multiply sign 782 * 783 * Example: ($x=640, $y=480) should result in "640×480" 784 * 785 * @param string|int $x first value 786 * @param string|int $y second value 787 */ 788 public function multiplyentity($x, $y) 789 { 790 $this->doc .= "$x×$y"; 791 } 792 793 /** 794 * Render an opening single quote char (language specific) 795 */ 796 public function singlequoteopening() 797 { 798 global $lang; 799 $this->doc .= $lang['singlequoteopening']; 800 } 801 802 /** 803 * Render a closing single quote char (language specific) 804 */ 805 public function singlequoteclosing() 806 { 807 global $lang; 808 $this->doc .= $lang['singlequoteclosing']; 809 } 810 811 /** 812 * Render an apostrophe char (language specific) 813 */ 814 public function apostrophe() 815 { 816 global $lang; 817 $this->doc .= $lang['apostrophe']; 818 } 819 820 /** 821 * Render an opening double quote char (language specific) 822 */ 823 public function doublequoteopening() 824 { 825 global $lang; 826 $this->doc .= $lang['doublequoteopening']; 827 } 828 829 /** 830 * Render an closinging double quote char (language specific) 831 */ 832 public function doublequoteclosing() 833 { 834 global $lang; 835 $this->doc .= $lang['doublequoteclosing']; 836 } 837 838 /** 839 * Render a CamelCase link 840 * 841 * @param string $link The link name 842 * @param bool $returnonly whether to return html or write to doc attribute 843 * @return void|string writes to doc attribute or returns html depends on $returnonly 844 * 845 * @see http://en.wikipedia.org/wiki/CamelCase 846 */ 847 public function camelcaselink($link, $returnonly = false) 848 { 849 if ($returnonly) { 850 return $this->internallink($link, $link, null, true); 851 } else { 852 $this->internallink($link, $link); 853 } 854 } 855 856 /** 857 * Render a page local link 858 * 859 * @param string $hash hash link identifier 860 * @param string $name name for the link 861 * @param bool $returnonly whether to return html or write to doc attribute 862 * @return void|string writes to doc attribute or returns html depends on $returnonly 863 */ 864 public function locallink($hash, $name = null, $returnonly = false) 865 { 866 global $ID; 867 $name = $this->_getLinkTitle($name, $hash, $isImage); 868 $hash = $this->_headerToLink($hash); 869 $title = $ID . ' ↵'; 870 871 $doc = '<a href="#' . $hash . '" title="' . $title . '" class="wikilink1">'; 872 $doc .= $name; 873 $doc .= '</a>'; 874 875 if ($returnonly) { 876 return $doc; 877 } else { 878 $this->doc .= $doc; 879 } 880 } 881 882 /** 883 * Render an internal Wiki Link 884 * 885 * $search,$returnonly & $linktype are not for the renderer but are used 886 * elsewhere - no need to implement them in other renderers 887 * 888 * @param string $id pageid 889 * @param string|null $name link name 890 * @param string|null $search adds search url param 891 * @param bool $returnonly whether to return html or write to doc attribute 892 * @param string $linktype type to set use of headings 893 * @return void|string writes to doc attribute or returns html depends on $returnonly 894 * @author Andreas Gohr <andi@splitbrain.org> 895 */ 896 public function internallink($id, $name = null, $search = null, $returnonly = false, $linktype = 'content') 897 { 898 global $conf; 899 global $ID; 900 global $INFO; 901 902 $params = ''; 903 $parts = explode('?', $id, 2); 904 if (count($parts) === 2) { 905 $id = $parts[0]; 906 $params = $parts[1]; 907 } 908 909 // For empty $id we need to know the current $ID 910 // We need this check because _simpleTitle needs 911 // correct $id and resolve_pageid() use cleanID($id) 912 // (some things could be lost) 913 if ($id === '') { 914 $id = $ID; 915 } 916 917 // default name is based on $id as given 918 $default = $this->_simpleTitle($id); 919 920 // now first resolve and clean up the $id 921 $id = (new PageResolver($ID))->resolveId($id, $this->date_at, true); 922 $exists = page_exists($id, $this->date_at, false, true); 923 924 $link = []; 925 $name = $this->_getLinkTitle($name, $default, $isImage, $id, $linktype); 926 if (!$isImage) { 927 if ($exists) { 928 $class = 'wikilink1'; 929 } else { 930 $class = 'wikilink2'; 931 $link['rel'] = 'nofollow'; 932 } 933 } else { 934 $class = 'media'; 935 } 936 937 //keep hash anchor 938 [$id, $hash] = sexplode('#', $id, 2); 939 if (!empty($hash)) $hash = $this->_headerToLink($hash); 940 941 //prepare for formating 942 $link['target'] = $conf['target']['wiki']; 943 $link['style'] = ''; 944 $link['pre'] = ''; 945 $link['suf'] = ''; 946 $link['more'] = 'data-wiki-id="' . $id . '"'; // id is already cleaned 947 $link['class'] = $class; 948 if ($this->date_at) { 949 $params = $params . '&at=' . rawurlencode($this->date_at); 950 } 951 $link['url'] = wl($id, $params); 952 $link['name'] = $name; 953 $link['title'] = $id; 954 //add search string 955 if ($search) { 956 ($conf['userewrite']) ? $link['url'] .= '?' : $link['url'] .= '&'; 957 if (is_array($search)) { 958 $search = array_map(rawurlencode(...), $search); 959 $link['url'] .= 's[]=' . implode('&s[]=', $search); 960 } else { 961 $link['url'] .= 's=' . rawurlencode($search); 962 } 963 } 964 965 //keep hash 966 if ($hash) $link['url'] .= '#' . $hash; 967 968 //output formatted 969 if ($returnonly) { 970 return $this->_formatLink($link); 971 } else { 972 $this->doc .= $this->_formatLink($link); 973 } 974 } 975 976 /** 977 * Render an external link 978 * 979 * @param string $url full URL with scheme 980 * @param string|array $name name for the link, array for media file 981 * @param bool $returnonly whether to return html or write to doc attribute 982 * @return void|string writes to doc attribute or returns html depends on $returnonly 983 */ 984 public function externallink($url, $name = null, $returnonly = false) 985 { 986 global $conf; 987 988 $name = $this->_getLinkTitle($name, $url, $isImage); 989 990 // url might be an attack vector, only allow registered protocols 991 if (is_null($this->schemes)) $this->schemes = getSchemes(); 992 [$scheme] = explode('://', $url); 993 $scheme = strtolower($scheme); 994 if (!in_array($scheme, $this->schemes)) $url = ''; 995 996 // is there still an URL? 997 if (!$url) { 998 if ($returnonly) { 999 return $name; 1000 } else { 1001 $this->doc .= $name; 1002 } 1003 return; 1004 } 1005 1006 // set class 1007 if (!$isImage) { 1008 $class = 'urlextern'; 1009 } else { 1010 $class = 'media'; 1011 } 1012 1013 //prepare for formating 1014 $link = []; 1015 $link['target'] = $conf['target']['extern']; 1016 $link['style'] = ''; 1017 $link['pre'] = ''; 1018 $link['suf'] = ''; 1019 $link['more'] = ''; 1020 $link['class'] = $class; 1021 $link['url'] = $url; 1022 $link['rel'] = ''; 1023 1024 $link['name'] = $name; 1025 $link['title'] = $this->_xmlEntities($url); 1026 if ($conf['relnofollow']) $link['rel'] .= ' ugc nofollow'; 1027 if ($conf['target']['extern']) $link['rel'] .= ' noopener'; 1028 1029 //output formatted 1030 if ($returnonly) { 1031 return $this->_formatLink($link); 1032 } else { 1033 $this->doc .= $this->_formatLink($link); 1034 } 1035 } 1036 1037 /** 1038 * Render an interwiki link 1039 * 1040 * You may want to use $this->_resolveInterWiki() here 1041 * 1042 * @param string $match original link - probably not much use 1043 * @param string|array $name name for the link, array for media file 1044 * @param string $wikiName indentifier (shortcut) for the remote wiki 1045 * @param string $wikiUri the fragment parsed from the original link 1046 * @param bool $returnonly whether to return html or write to doc attribute 1047 * @return void|string writes to doc attribute or returns html depends on $returnonly 1048 */ 1049 public function interwikilink($match, $name, $wikiName, $wikiUri, $returnonly = false) 1050 { 1051 global $conf; 1052 1053 $link = []; 1054 $link['target'] = $conf['target']['interwiki']; 1055 $link['pre'] = ''; 1056 $link['suf'] = ''; 1057 $link['more'] = ''; 1058 $link['name'] = $this->_getLinkTitle($name, $wikiUri, $isImage); 1059 $link['rel'] = ''; 1060 1061 //get interwiki URL 1062 $exists = null; 1063 $url = $this->_resolveInterWiki($wikiName, $wikiUri, $exists); 1064 1065 if (!$isImage) { 1066 $class = preg_replace('/[^_\-a-z0-9]+/i', '_', $wikiName); 1067 $link['class'] = "interwiki iw_$class"; 1068 } else { 1069 $link['class'] = 'media'; 1070 } 1071 1072 //do we stay at the same server? Use local target 1073 if (str_starts_with($url, DOKU_URL) || str_starts_with($url, DOKU_BASE)) { 1074 $link['target'] = $conf['target']['wiki']; 1075 } 1076 if ($exists !== null && !$isImage) { 1077 if ($exists) { 1078 $link['class'] .= ' wikilink1'; 1079 } else { 1080 $link['class'] .= ' wikilink2'; 1081 $link['rel'] .= ' nofollow'; 1082 } 1083 } 1084 if ($conf['target']['interwiki']) $link['rel'] .= ' noopener'; 1085 1086 $link['url'] = $url; 1087 $link['title'] = $this->_xmlEntities($link['url']); 1088 1089 // output formatted 1090 if ($returnonly) { 1091 if ($url == '') return $link['name']; 1092 return $this->_formatLink($link); 1093 } elseif ($url == '') { 1094 $this->doc .= $link['name']; 1095 } else $this->doc .= $this->_formatLink($link); 1096 } 1097 1098 /** 1099 * Link to windows share 1100 * 1101 * @param string $url the link 1102 * @param string|array $name name for the link, array for media file 1103 * @param bool $returnonly whether to return html or write to doc attribute 1104 * @return void|string writes to doc attribute or returns html depends on $returnonly 1105 */ 1106 public function windowssharelink($url, $name = null, $returnonly = false) 1107 { 1108 global $conf; 1109 1110 //simple setup 1111 $link = []; 1112 $link['target'] = $conf['target']['windows']; 1113 $link['pre'] = ''; 1114 $link['suf'] = ''; 1115 $link['style'] = ''; 1116 1117 $link['name'] = $this->_getLinkTitle($name, $url, $isImage); 1118 if (!$isImage) { 1119 $link['class'] = 'windows'; 1120 } else { 1121 $link['class'] = 'media'; 1122 } 1123 1124 $link['title'] = $this->_xmlEntities($url); 1125 $url = str_replace('\\', '/', $url); 1126 $url = 'file:///' . $url; 1127 $link['url'] = $url; 1128 1129 //output formatted 1130 if ($returnonly) { 1131 return $this->_formatLink($link); 1132 } else { 1133 $this->doc .= $this->_formatLink($link); 1134 } 1135 } 1136 1137 /** 1138 * Render a linked E-Mail Address 1139 * 1140 * Honors $conf['mailguard'] setting 1141 * 1142 * @param string $address Email-Address 1143 * @param string|array $name name for the link, array for media file 1144 * @param bool $returnonly whether to return html or write to doc attribute 1145 * @return void|string writes to doc attribute or returns html depends on $returnonly 1146 */ 1147 public function emaillink($address, $name = null, $returnonly = false) 1148 { 1149 //simple setup 1150 $link = []; 1151 $link['target'] = ''; 1152 $link['pre'] = ''; 1153 $link['suf'] = ''; 1154 $link['style'] = ''; 1155 $link['more'] = ''; 1156 1157 $name = $this->_getLinkTitle($name, '', $isImage); 1158 if (!$isImage) { 1159 $link['class'] = 'mail'; 1160 } else { 1161 $link['class'] = 'media'; 1162 } 1163 1164 $display = MailUtils::obfuscate($address); 1165 $href = MailUtils::obfuscateUrl($address); 1166 1167 $title = $display; 1168 1169 if (empty($name)) { 1170 $name = $display; 1171 } 1172 1173 $link['url'] = 'mailto:' . $href; 1174 $link['name'] = $name; 1175 $link['title'] = $title; 1176 1177 //output formatted 1178 if ($returnonly) { 1179 return $this->_formatLink($link); 1180 } else { 1181 $this->doc .= $this->_formatLink($link); 1182 } 1183 } 1184 1185 /** 1186 * Render an internal media file 1187 * 1188 * @param string $src media ID 1189 * @param string $title descriptive text 1190 * @param string $align left|center|right 1191 * @param int $width width of media in pixel 1192 * @param int $height height of media in pixel 1193 * @param string $cache cache|recache|nocache 1194 * @param string $linking linkonly|detail|nolink 1195 * @param bool $return return HTML instead of adding to $doc 1196 * @return void|string writes to doc attribute or returns html depends on $return 1197 */ 1198 public function internalmedia( 1199 $src, 1200 $title = null, 1201 $align = null, 1202 $width = null, 1203 $height = null, 1204 $cache = null, 1205 $linking = null, 1206 $return = false 1207 ) { 1208 global $ID; 1209 if (str_contains($src, '#')) { 1210 [$src, $hash] = sexplode('#', $src, 2); 1211 } 1212 $src = (new MediaResolver($ID))->resolveId($src, $this->date_at, true); 1213 $exists = media_exists($src); 1214 1215 $noLink = false; 1216 $render = $linking != 'linkonly'; 1217 $link = $this->_getMediaLinkConf($src, $title, $align, $width, $height, $cache, $render); 1218 1219 [$ext, $mime] = mimetype($src, false); 1220 if (str_starts_with($mime, 'image') && $render) { 1221 $link['url'] = ml( 1222 $src, 1223 [ 1224 'id' => $ID, 1225 'cache' => $cache, 1226 'rev' => $this->_getLastMediaRevisionAt($src) 1227 ], 1228 ($linking == 'direct') 1229 ); 1230 } elseif (($mime == 'application/x-shockwave-flash' || media_supportedav($mime)) && $render) { 1231 // don't link movies 1232 $noLink = true; 1233 } else { 1234 // add file icons 1235 $class = preg_replace('/[^_\-a-z0-9]+/i', '_', $ext); 1236 $link['class'] .= ' mediafile mf_' . $class; 1237 $link['url'] = ml( 1238 $src, 1239 [ 1240 'id' => $ID, 1241 'cache' => $cache, 1242 'rev' => $this->_getLastMediaRevisionAt($src) 1243 ], 1244 true 1245 ); 1246 if ($exists) $link['title'] .= ' (' . filesize_h(filesize(mediaFN($src))) . ')'; 1247 } 1248 1249 if (!empty($hash)) $link['url'] .= '#' . $hash; 1250 1251 //markup non existing files 1252 if (!$exists) { 1253 $link['class'] .= ' wikilink2'; 1254 } 1255 1256 //output formatted 1257 if ($return) { 1258 if ($linking == 'nolink' || $noLink) { 1259 return $link['name']; 1260 } else { 1261 return $this->_formatLink($link); 1262 } 1263 } elseif ($linking == 'nolink' || $noLink) { 1264 $this->doc .= $link['name']; 1265 } else { 1266 $this->doc .= $this->_formatLink($link); 1267 } 1268 } 1269 1270 /** 1271 * Render an external media file 1272 * 1273 * @param string $src full media URL 1274 * @param string $title descriptive text 1275 * @param string $align left|center|right 1276 * @param int $width width of media in pixel 1277 * @param int $height height of media in pixel 1278 * @param string $cache cache|recache|nocache 1279 * @param string $linking linkonly|detail|nolink 1280 * @param bool $return return HTML instead of adding to $doc 1281 * @return void|string writes to doc attribute or returns html depends on $return 1282 */ 1283 public function externalmedia( 1284 $src, 1285 $title = null, 1286 $align = null, 1287 $width = null, 1288 $height = null, 1289 $cache = null, 1290 $linking = null, 1291 $return = false 1292 ) { 1293 if (link_isinterwiki($src)) { 1294 [$shortcut, $reference] = sexplode('>', $src, 2, ''); 1295 $exists = null; 1296 $src = $this->_resolveInterWiki($shortcut, $reference, $exists); 1297 if ($src == '' && empty($title)) { 1298 // make sure at least something will be shown in this case 1299 $title = $reference; 1300 } 1301 } 1302 [$src, $hash] = sexplode('#', $src, 2); 1303 $noLink = false; 1304 if ($src == '') { 1305 // only output plaintext without link if there is no src 1306 $noLink = true; 1307 } 1308 $render = $linking != 'linkonly'; 1309 $link = $this->_getMediaLinkConf($src, $title, $align, $width, $height, $cache, $render); 1310 1311 $link['url'] = ml($src, ['cache' => $cache]); 1312 1313 [$ext, $mime] = mimetype($src, false); 1314 if (str_starts_with($mime, 'image') && $render) { 1315 // link only jpeg images 1316 // if ($ext != 'jpg' && $ext != 'jpeg') $noLink = true; 1317 } elseif (($mime == 'application/x-shockwave-flash' || media_supportedav($mime)) && $render) { 1318 // don't link movies 1319 $noLink = true; 1320 } else { 1321 // add file icons 1322 $class = preg_replace('/[^_\-a-z0-9]+/i', '_', $ext); 1323 $link['class'] .= ' mediafile mf_' . $class; 1324 } 1325 1326 if ($hash) $link['url'] .= '#' . $hash; 1327 1328 //output formatted 1329 if ($return) { 1330 if ($linking == 'nolink' || $noLink) return $link['name']; 1331 else return $this->_formatLink($link); 1332 } elseif ($linking == 'nolink' || $noLink) { 1333 $this->doc .= $link['name']; 1334 } else $this->doc .= $this->_formatLink($link); 1335 } 1336 1337 /** 1338 * Renders an RSS feed 1339 * 1340 * @param string $url URL of the feed 1341 * @param array $params Finetuning of the output 1342 * 1343 * @author Andreas Gohr <andi@splitbrain.org> 1344 */ 1345 public function rss($url, $params) 1346 { 1347 global $lang; 1348 global $conf; 1349 1350 $feed = new FeedParser(); 1351 $feed->set_feed_url($url); 1352 1353 //disable warning while fetching 1354 if (!defined('DOKU_E_LEVEL')) { 1355 $elvl = error_reporting(E_ERROR); 1356 } 1357 $rc = $feed->init(); 1358 if (isset($elvl)) { 1359 error_reporting($elvl); 1360 } 1361 1362 if ($params['nosort']) $feed->enable_order_by_date(false); 1363 1364 //decide on start and end 1365 if ($params['reverse']) { 1366 $mod = -1; 1367 $start = $feed->get_item_quantity() - 1; 1368 $end = $start - ($params['max']); 1369 $end = ($end < -1) ? -1 : $end; 1370 } else { 1371 $mod = 1; 1372 $start = 0; 1373 $end = $feed->get_item_quantity(); 1374 $end = ($end > $params['max']) ? $params['max'] : $end; 1375 } 1376 1377 $this->doc .= '<ul class="rss">'; 1378 if ($rc) { 1379 for ($x = $start; $x != $end; $x += $mod) { 1380 $item = $feed->get_item($x); 1381 $this->doc .= '<li><div class="li">'; 1382 1383 $lnkurl = $item->get_permalink(); 1384 $title = html_entity_decode($item->get_title(), ENT_QUOTES, 'UTF-8'); 1385 1386 // support feeds without links 1387 if ($lnkurl) { 1388 $this->externallink($item->get_permalink(), $title); 1389 } else { 1390 $this->doc .= ' ' . hsc($item->get_title()); 1391 } 1392 if ($params['author']) { 1393 $author = $item->get_author(0); 1394 if ($author instanceof Author) { 1395 $name = $author->get_name(); 1396 if (!$name) $name = $author->get_email(); 1397 if ($name) $this->doc .= ' ' . $lang['by'] . ' ' . hsc($name); 1398 } 1399 } 1400 if ($params['date']) { 1401 $this->doc .= ' (' . $item->get_local_date($conf['dformat']) . ')'; 1402 } 1403 if ($params['details']) { 1404 $desc = $item->get_description(); 1405 $desc = strip_tags($desc); 1406 $desc = html_entity_decode($desc, ENT_QUOTES, 'UTF-8'); 1407 $this->doc .= '<div class="detail">'; 1408 $this->doc .= hsc($desc); 1409 $this->doc .= '</div>'; 1410 } 1411 1412 $this->doc .= '</div></li>'; 1413 } 1414 } else { 1415 $this->doc .= '<li><div class="li">'; 1416 $this->doc .= '<em>' . $lang['rssfailed'] . '</em>'; 1417 $this->externallink($url); 1418 if ($conf['allowdebug']) { 1419 $this->doc .= '<!--' . hsc($feed->error) . '-->'; 1420 } 1421 $this->doc .= '</div></li>'; 1422 } 1423 $this->doc .= '</ul>'; 1424 } 1425 1426 /** 1427 * Start a table 1428 * 1429 * @param int $maxcols maximum number of columns 1430 * @param int $numrows NOT IMPLEMENTED 1431 * @param int $pos byte position in the original source 1432 * @param string|string[] $classes css classes - have to be valid, do not pass unfiltered user input 1433 */ 1434 public function table_open($maxcols = null, $numrows = null, $pos = null, $classes = null) 1435 { 1436 // initialize the row counter used for classes 1437 $this->_counter['row_counter'] = 0; 1438 $class = 'table'; 1439 if ($classes !== null) { 1440 if (is_array($classes)) $classes = implode(' ', $classes); 1441 $class .= ' ' . $classes; 1442 } 1443 if ($pos !== null) { 1444 $hid = $this->_headerToLink($class, true); 1445 $data = []; 1446 $data['target'] = 'table'; 1447 $data['name'] = ''; 1448 $data['hid'] = $hid; 1449 $class .= ' ' . $this->startSectionEdit($pos, $data); 1450 } 1451 $this->doc .= '<div class="' . $class . '"><table class="inline">' . 1452 DOKU_LF; 1453 } 1454 1455 /** 1456 * Close a table 1457 * 1458 * @param int $pos byte position in the original source 1459 */ 1460 public function table_close($pos = null) 1461 { 1462 $this->doc .= '</table></div>' . DOKU_LF; 1463 if ($pos !== null) { 1464 $this->finishSectionEdit($pos); 1465 } 1466 } 1467 1468 /** 1469 * Open a table header 1470 */ 1471 public function tablethead_open() 1472 { 1473 $this->doc .= DOKU_TAB . '<thead>' . DOKU_LF; 1474 } 1475 1476 /** 1477 * Close a table header 1478 */ 1479 public function tablethead_close() 1480 { 1481 $this->doc .= DOKU_TAB . '</thead>' . DOKU_LF; 1482 } 1483 1484 /** 1485 * Open a table body 1486 */ 1487 public function tabletbody_open() 1488 { 1489 $this->doc .= DOKU_TAB . '<tbody>' . DOKU_LF; 1490 } 1491 1492 /** 1493 * Close a table body 1494 */ 1495 public function tabletbody_close() 1496 { 1497 $this->doc .= DOKU_TAB . '</tbody>' . DOKU_LF; 1498 } 1499 1500 /** 1501 * Open a table footer 1502 */ 1503 public function tabletfoot_open() 1504 { 1505 $this->doc .= DOKU_TAB . '<tfoot>' . DOKU_LF; 1506 } 1507 1508 /** 1509 * Close a table footer 1510 */ 1511 public function tabletfoot_close() 1512 { 1513 $this->doc .= DOKU_TAB . '</tfoot>' . DOKU_LF; 1514 } 1515 1516 /** 1517 * Open a table row 1518 * 1519 * @param string|string[] $classes css classes - have to be valid, do not pass unfiltered user input 1520 */ 1521 public function tablerow_open($classes = null) 1522 { 1523 // initialize the cell counter used for classes 1524 $this->_counter['cell_counter'] = 0; 1525 $class = 'row' . $this->_counter['row_counter']++; 1526 if ($classes !== null) { 1527 if (is_array($classes)) $classes = implode(' ', $classes); 1528 $class .= ' ' . $classes; 1529 } 1530 $this->doc .= DOKU_TAB . '<tr class="' . $class . '">' . DOKU_LF . DOKU_TAB . DOKU_TAB; 1531 } 1532 1533 /** 1534 * Close a table row 1535 */ 1536 public function tablerow_close() 1537 { 1538 $this->doc .= DOKU_LF . DOKU_TAB . '</tr>' . DOKU_LF; 1539 } 1540 1541 /** 1542 * Open a table header cell 1543 * 1544 * @param int $colspan 1545 * @param string $align left|center|right 1546 * @param int $rowspan 1547 * @param string|string[] $classes css classes - have to be valid, do not pass unfiltered user input 1548 */ 1549 public function tableheader_open($colspan = 1, $align = null, $rowspan = 1, $classes = null) 1550 { 1551 $class = 'class="col' . $this->_counter['cell_counter']++; 1552 if (!is_null($align)) { 1553 $class .= ' ' . $align . 'align'; 1554 } 1555 if ($classes !== null) { 1556 if (is_array($classes)) $classes = implode(' ', $classes); 1557 $class .= ' ' . $classes; 1558 } 1559 $class .= '"'; 1560 $this->doc .= '<th ' . $class; 1561 if ($colspan > 1) { 1562 $this->_counter['cell_counter'] += $colspan - 1; 1563 $this->doc .= ' colspan="' . $colspan . '"'; 1564 } 1565 if ($rowspan > 1) { 1566 $this->doc .= ' rowspan="' . $rowspan . '"'; 1567 } 1568 $this->doc .= '>'; 1569 } 1570 1571 /** 1572 * Close a table header cell 1573 */ 1574 public function tableheader_close() 1575 { 1576 $this->doc .= '</th>'; 1577 } 1578 1579 /** 1580 * Open a table cell 1581 * 1582 * @param int $colspan 1583 * @param string $align left|center|right 1584 * @param int $rowspan 1585 * @param string|string[] $classes css classes - have to be valid, do not pass unfiltered user input 1586 */ 1587 public function tablecell_open($colspan = 1, $align = null, $rowspan = 1, $classes = null) 1588 { 1589 $class = 'class="col' . $this->_counter['cell_counter']++; 1590 if (!is_null($align)) { 1591 $class .= ' ' . $align . 'align'; 1592 } 1593 if ($classes !== null) { 1594 if (is_array($classes)) $classes = implode(' ', $classes); 1595 $class .= ' ' . $classes; 1596 } 1597 $class .= '"'; 1598 $this->doc .= '<td ' . $class; 1599 if ($colspan > 1) { 1600 $this->_counter['cell_counter'] += $colspan - 1; 1601 $this->doc .= ' colspan="' . $colspan . '"'; 1602 } 1603 if ($rowspan > 1) { 1604 $this->doc .= ' rowspan="' . $rowspan . '"'; 1605 } 1606 $this->doc .= '>'; 1607 } 1608 1609 /** 1610 * Close a table cell 1611 */ 1612 public function tablecell_close() 1613 { 1614 $this->doc .= '</td>'; 1615 } 1616 1617 /** 1618 * Returns the current header level. 1619 * (required e.g. by the filelist plugin) 1620 * 1621 * @return int The current header level 1622 */ 1623 public function getLastlevel() 1624 { 1625 return $this->lastlevel; 1626 } 1627 1628 #region Utility functions 1629 1630 /** 1631 * Build a link 1632 * 1633 * Assembles all parts defined in $link returns HTML for the link 1634 * 1635 * @param array $link attributes of a link 1636 * @return string 1637 * 1638 * @author Andreas Gohr <andi@splitbrain.org> 1639 */ 1640 public function _formatLink($link) 1641 { 1642 //make sure the url is XHTML compliant (skip mailto) 1643 if (!str_starts_with($link['url'], 'mailto:')) { 1644 $link['url'] = str_replace('&', '&', $link['url']); 1645 $link['url'] = str_replace('&amp;', '&', $link['url']); 1646 } 1647 //remove double encodings in titles 1648 $link['title'] = str_replace('&amp;', '&', $link['title']); 1649 1650 // be sure there are no bad chars in url or title 1651 // (we can't do this for name because it can contain an img tag) 1652 $link['url'] = strtr($link['url'], ['>' => '%3E', '<' => '%3C', '"' => '%22']); 1653 $link['title'] = strtr($link['title'], ['>' => '>', '<' => '<', '"' => '"']); 1654 1655 $ret = ''; 1656 $ret .= $link['pre']; 1657 $ret .= '<a href="' . $link['url'] . '"'; 1658 if (!empty($link['class'])) $ret .= ' class="' . $link['class'] . '"'; 1659 if (!empty($link['target'])) $ret .= ' target="' . $link['target'] . '"'; 1660 if (!empty($link['title'])) $ret .= ' title="' . $link['title'] . '"'; 1661 if (!empty($link['style'])) $ret .= ' style="' . $link['style'] . '"'; 1662 if (!empty($link['rel'])) $ret .= ' rel="' . trim($link['rel']) . '"'; 1663 if (!empty($link['more'])) $ret .= ' ' . $link['more']; 1664 $ret .= '>'; 1665 $ret .= $link['name']; 1666 $ret .= '</a>'; 1667 $ret .= $link['suf']; 1668 return $ret; 1669 } 1670 1671 /** 1672 * Renders internal and external media 1673 * 1674 * @param string $src media ID 1675 * @param string $title descriptive text 1676 * @param string $align left|center|right 1677 * @param int $width width of media in pixel 1678 * @param int $height height of media in pixel 1679 * @param string $cache cache|recache|nocache 1680 * @param bool $render should the media be embedded inline or just linked 1681 * @return string 1682 * @author Andreas Gohr <andi@splitbrain.org> 1683 */ 1684 public function _media( 1685 $src, 1686 $title = null, 1687 $align = null, 1688 $width = null, 1689 $height = null, 1690 $cache = null, 1691 $render = true 1692 ) { 1693 1694 $ret = ''; 1695 1696 [$ext, $mime] = mimetype($src); 1697 if (str_starts_with($mime, 'image')) { 1698 // first get the $title 1699 if (!is_null($title)) { 1700 $title = $this->_xmlEntities($title); 1701 } elseif ($ext == 'jpg' || $ext == 'jpeg') { 1702 //try to use the caption from IPTC/EXIF 1703 require_once(DOKU_INC . 'inc/JpegMeta.php'); 1704 $jpeg = new JpegMeta(mediaFN($src)); 1705 $cap = $jpeg->getTitle(); 1706 if (!empty($cap)) { 1707 $title = $this->_xmlEntities($cap); 1708 } 1709 } 1710 if (!$render) { 1711 // if the picture is not supposed to be rendered 1712 // return the title of the picture 1713 if ($title === null || $title === "") { 1714 // just show the sourcename 1715 $title = $this->_xmlEntities(PhpString::basename(noNS($src))); 1716 } 1717 return $title; 1718 } 1719 //add image tag 1720 $ret .= '<img src="' . ml( 1721 $src, 1722 [ 1723 'w' => $width, 1724 'h' => $height, 1725 'cache' => $cache, 1726 'rev' => $this->_getLastMediaRevisionAt($src) 1727 ] 1728 ) . '"'; 1729 $ret .= ' class="media' . $align . '"'; 1730 $ret .= ' loading="lazy"'; 1731 1732 if ($title) { 1733 $ret .= ' title="' . $title . '"'; 1734 $ret .= ' alt="' . $title . '"'; 1735 } else { 1736 $ret .= ' alt=""'; 1737 } 1738 1739 if (!is_null($width)) { 1740 $ret .= ' width="' . $this->_xmlEntities($width) . '"'; 1741 } 1742 1743 if (!is_null($height)) { 1744 $ret .= ' height="' . $this->_xmlEntities($height) . '"'; 1745 } 1746 1747 $ret .= ' />'; 1748 } elseif (media_supportedav($mime, 'video') || media_supportedav($mime, 'audio')) { 1749 // first get the $title 1750 $title ??= false; 1751 if (!$render) { 1752 // if the file is not supposed to be rendered 1753 // return the title of the file (just the sourcename if there is no title) 1754 return $this->_xmlEntities($title ?: PhpString::basename(noNS($src))); 1755 } 1756 1757 $att = []; 1758 $att['class'] = "media$align"; 1759 if ($title) { 1760 $att['title'] = $title; 1761 } 1762 1763 if (media_supportedav($mime, 'video')) { 1764 //add video 1765 $ret .= $this->_video($src, $width, $height, $att); 1766 } 1767 if (media_supportedav($mime, 'audio')) { 1768 //add audio 1769 $ret .= $this->_audio($src, $att); 1770 } 1771 } elseif ($mime == 'application/x-shockwave-flash') { 1772 if (!$render) { 1773 // if the flash is not supposed to be rendered 1774 // return the title of the flash 1775 if (!$title) { 1776 // just show the sourcename 1777 $title = PhpString::basename(noNS($src)); 1778 } 1779 return $this->_xmlEntities($title); 1780 } 1781 1782 $att = []; 1783 $att['class'] = "media$align"; 1784 if ($align == 'right') $att['align'] = 'right'; 1785 if ($align == 'left') $att['align'] = 'left'; 1786 $ret .= html_flashobject( 1787 ml($src, ['cache' => $cache], true, '&'), 1788 $width, 1789 $height, 1790 ['quality' => 'high'], 1791 null, 1792 $att, 1793 $this->_xmlEntities($title) 1794 ); 1795 } elseif ($title) { 1796 // well at least we have a title to display 1797 $ret .= $this->_xmlEntities($title); 1798 } else { 1799 // just show the sourcename 1800 $ret .= $this->_xmlEntities(PhpString::basename(noNS($src))); 1801 } 1802 1803 return $ret; 1804 } 1805 1806 /** 1807 * Escape string for output 1808 * 1809 * @param $string 1810 * @return string 1811 */ 1812 public function _xmlEntities($string) 1813 { 1814 return hsc($string); 1815 } 1816 1817 1818 /** 1819 * Construct a title and handle images in titles 1820 * 1821 * @param string|array $title either string title or media array 1822 * @param string $default default title if nothing else is found 1823 * @param bool $isImage will be set to true if it's a media file 1824 * @param null|string $id linked page id (used to extract title from first heading) 1825 * @param string $linktype content|navigation 1826 * @return string HTML of the title, might be full image tag or just escaped text 1827 * @author Harry Fuecks <hfuecks@gmail.com> 1828 */ 1829 public function _getLinkTitle($title, $default, &$isImage, $id = null, $linktype = 'content') 1830 { 1831 $isImage = false; 1832 if (is_array($title)) { 1833 $isImage = true; 1834 return $this->_imageTitle($title); 1835 } elseif (is_null($title) || trim($title) == '') { 1836 if (useHeading($linktype) && $id) { 1837 $heading = p_get_first_heading($id); 1838 if (!blank($heading)) { 1839 return $this->_xmlEntities($heading); 1840 } 1841 } 1842 return $this->_xmlEntities($default); 1843 } else { 1844 return $this->_xmlEntities($title); 1845 } 1846 } 1847 1848 /** 1849 * Returns HTML code for images used in link titles 1850 * 1851 * @param array $img 1852 * @return string HTML img tag or similar 1853 * @author Andreas Gohr <andi@splitbrain.org> 1854 */ 1855 public function _imageTitle($img) 1856 { 1857 global $ID; 1858 1859 // some fixes on $img['src'] 1860 // see internalmedia() and externalmedia() 1861 [$img['src']] = explode('#', $img['src'], 2); 1862 if ($img['type'] == 'internalmedia') { 1863 $img['src'] = (new MediaResolver($ID))->resolveId($img['src'], $this->date_at, true); 1864 } 1865 1866 return $this->_media( 1867 $img['src'], 1868 $img['title'], 1869 $img['align'], 1870 $img['width'], 1871 $img['height'], 1872 $img['cache'] 1873 ); 1874 } 1875 1876 /** 1877 * helperfunction to return a basic link to a media 1878 * 1879 * used in internalmedia() and externalmedia() 1880 * 1881 * @param string $src media ID 1882 * @param string $title descriptive text 1883 * @param string $align left|center|right 1884 * @param int $width width of media in pixel 1885 * @param int $height height of media in pixel 1886 * @param string $cache cache|recache|nocache 1887 * @param bool $render should the media be embedded inline or just linked 1888 * @return array associative array with link config 1889 * @author Pierre Spring <pierre.spring@liip.ch> 1890 */ 1891 public function _getMediaLinkConf($src, $title, $align, $width, $height, $cache, $render) 1892 { 1893 global $conf; 1894 1895 $link = []; 1896 $link['class'] = 'media'; 1897 $link['style'] = ''; 1898 $link['pre'] = ''; 1899 $link['suf'] = ''; 1900 $link['more'] = ''; 1901 $link['target'] = $conf['target']['media']; 1902 if ($conf['target']['media']) $link['rel'] = 'noopener'; 1903 $link['title'] = $this->_xmlEntities($src); 1904 $link['name'] = $this->_media($src, $title, $align, $width, $height, $cache, $render); 1905 1906 return $link; 1907 } 1908 1909 /** 1910 * Embed video(s) in HTML 1911 * 1912 * @param string $src - ID of video to embed 1913 * @param int $width - width of the video in pixels 1914 * @param int $height - height of the video in pixels 1915 * @param array $atts - additional attributes for the <video> tag 1916 * @return string 1917 * @author Schplurtz le Déboulonné <Schplurtz@laposte.net> 1918 * 1919 * @author Anika Henke <anika@selfthinker.org> 1920 */ 1921 public function _video($src, $width, $height, $atts = null) 1922 { 1923 // prepare width and height 1924 if (is_null($atts)) $atts = []; 1925 $atts['width'] = (int)$width; 1926 $atts['height'] = (int)$height; 1927 if (!$atts['width']) $atts['width'] = 320; 1928 if (!$atts['height']) $atts['height'] = 240; 1929 1930 $posterUrl = ''; 1931 $files = []; 1932 $tracks = []; 1933 $isExternal = media_isexternal($src); 1934 1935 if ($isExternal) { 1936 // take direct source for external files 1937 [/* ext */, $srcMime] = mimetype($src); 1938 $files[$srcMime] = $src; 1939 } else { 1940 // prepare alternative formats 1941 $extensions = ['webm', 'ogv', 'mp4']; 1942 $files = media_alternativefiles($src, $extensions); 1943 $poster = media_alternativefiles($src, ['jpg', 'png']); 1944 $tracks = media_trackfiles($src); 1945 if (!empty($poster)) { 1946 $posterUrl = ml(reset($poster), '', true, '&'); 1947 } 1948 } 1949 1950 $out = ''; 1951 // open video tag 1952 $out .= '<video ' . buildAttributes($atts) . ' controls="controls"'; 1953 if ($posterUrl) $out .= ' poster="' . hsc($posterUrl) . '"'; 1954 $out .= '>' . NL; 1955 $fallback = ''; 1956 1957 // output source for each alternative video format 1958 foreach ($files as $mime => $file) { 1959 if ($isExternal) { 1960 $url = $file; 1961 $linkType = 'externalmedia'; 1962 } else { 1963 $url = ml($file, '', true, '&'); 1964 $linkType = 'internalmedia'; 1965 } 1966 $title = empty($atts['title']) 1967 ? $this->_xmlEntities(PhpString::basename(noNS($file))) 1968 : $atts['title']; 1969 1970 $out .= '<source src="' . hsc($url) . '" type="' . $mime . '" />' . NL; 1971 // alternative content (just a link to the file) 1972 $fallback .= $this->$linkType( 1973 $file, 1974 $title, 1975 null, 1976 null, 1977 null, 1978 $cache = null, 1979 $linking = 'linkonly', 1980 $return = true 1981 ); 1982 } 1983 1984 // output each track if any 1985 foreach ($tracks as $trackid => $info) { 1986 [$kind, $srclang] = array_map(hsc(...), $info); 1987 $out .= "<track kind=\"$kind\" srclang=\"$srclang\" "; 1988 $out .= "label=\"$srclang\" "; 1989 $out .= 'src="' . ml($trackid, '', true) . '">' . NL; 1990 } 1991 1992 // finish 1993 $out .= $fallback; 1994 $out .= '</video>' . NL; 1995 return $out; 1996 } 1997 1998 /** 1999 * Embed audio in HTML 2000 * 2001 * @param string $src - ID of audio to embed 2002 * @param array $atts - additional attributes for the <audio> tag 2003 * @return string 2004 * @author Anika Henke <anika@selfthinker.org> 2005 * 2006 */ 2007 public function _audio($src, $atts = []) 2008 { 2009 $files = []; 2010 $isExternal = media_isexternal($src); 2011 2012 if ($isExternal) { 2013 // take direct source for external files 2014 [/* ext */, $srcMime] = mimetype($src); 2015 $files[$srcMime] = $src; 2016 } else { 2017 // prepare alternative formats 2018 $extensions = ['ogg', 'mp3', 'wav']; 2019 $files = media_alternativefiles($src, $extensions); 2020 } 2021 2022 $out = ''; 2023 // open audio tag 2024 $out .= '<audio ' . buildAttributes($atts) . ' controls="controls">' . NL; 2025 $fallback = ''; 2026 2027 // output source for each alternative audio format 2028 foreach ($files as $mime => $file) { 2029 if ($isExternal) { 2030 $url = $file; 2031 $linkType = 'externalmedia'; 2032 } else { 2033 $url = ml($file, '', true, '&'); 2034 $linkType = 'internalmedia'; 2035 } 2036 $title = $atts['title'] ?: $this->_xmlEntities(PhpString::basename(noNS($file))); 2037 2038 $out .= '<source src="' . hsc($url) . '" type="' . $mime . '" />' . NL; 2039 // alternative content (just a link to the file) 2040 $fallback .= $this->$linkType( 2041 $file, 2042 $title, 2043 null, 2044 null, 2045 null, 2046 $cache = null, 2047 $linking = 'linkonly', 2048 $return = true 2049 ); 2050 } 2051 2052 // finish 2053 $out .= $fallback; 2054 $out .= '</audio>' . NL; 2055 return $out; 2056 } 2057 2058 /** 2059 * _getLastMediaRevisionAt is a helperfunction to internalmedia() and _media() 2060 * which returns an existing media revision less or equal to rev or date_at 2061 * 2062 * @param string $media_id 2063 * @access protected 2064 * @return string revision ('' for current) 2065 * @author lisps 2066 */ 2067 protected function _getLastMediaRevisionAt($media_id) 2068 { 2069 if (!$this->date_at || media_isexternal($media_id)) return ''; 2070 $changelog = new MediaChangeLog($media_id); 2071 return $changelog->getLastRevisionAt($this->date_at); 2072 } 2073 2074 #endregion 2075} 2076 2077//Setup VIM: ex: et ts=4 : 2078