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