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