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