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