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