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