* @author Andreas Gohr * */ class Doku_Renderer_xhtml extends Doku_Renderer { /** @var array store the table of contents */ public $toc = []; /** @var array A stack of section edit data */ protected $sectionedits = []; /** @var int last section edit id, used by startSectionEdit */ protected $lastsecid = 0; /** @var array a list of footnotes, list starts at 1! */ protected $footnotes = []; /** @var int current section level */ protected $lastlevel = 0; /** @var array section node tracker */ protected $node = [0, 0, 0, 0, 0]; /** @var string temporary $doc store */ protected $store = ''; /** @var array global counter, for table classes etc. */ protected $_counter = []; // /** @var int counts the code and file blocks, used to provide download links */ protected $_codeblock = 0; /** @var array list of allowed URL schemes */ protected $schemes; /** * Register a new edit section range * * @param int $start The byte position for the edit start * @param array $data Associative array with section data: * Key 'name': the section name/title * Key 'target': the target for the section edit, * e.g. 'section' or 'table' * Key 'hid': header id * Key 'codeblockOffset': actual code block index * Key 'start': set in startSectionEdit(), * do not set yourself * Key 'range': calculated from 'start' and * $key in finishSectionEdit(), * do not set yourself * @return string A marker class for the starting HTML element * * @author Adrian Lang */ public function startSectionEdit($start, $data) { if (!is_array($data)) { msg( sprintf( 'startSectionEdit: $data "%s" is NOT an array! One of your plugins needs an update.', hsc((string)$data) ), -1 ); // @deprecated 2018-04-14, backward compatibility $args = func_get_args(); $data = []; if (isset($args[1])) $data['target'] = $args[1]; if (isset($args[2])) $data['name'] = $args[2]; if (isset($args[3])) $data['hid'] = $args[3]; } $data['secid'] = ++$this->lastsecid; $data['start'] = $start; $this->sectionedits[] = $data; return 'sectionedit' . $data['secid']; } /** * Finish an edit section range * * @param int $end The byte position for the edit end; null for the rest of the page * * @author Adrian Lang */ public function finishSectionEdit($end = null, $hid = null) { if (count($this->sectionedits) == 0) { return; } $data = array_pop($this->sectionedits); if (!is_null($end) && $end <= $data['start']) { return; } if (!is_null($hid)) { $data['hid'] .= $hid; } $data['range'] = $data['start'] . '-' . (is_null($end) ? '' : $end); unset($data['start']); $this->doc .= ''; } /** * Returns the format produced by this renderer. * * @return string always 'xhtml' */ public function getFormat() { return 'xhtml'; } /** * Initialize the document */ public function document_start() { //reset some internals $this->toc = []; } /** * Finalize the document */ public function document_end() { // Finish open section edits. while ($this->sectionedits !== []) { if ($this->sectionedits[count($this->sectionedits) - 1]['start'] <= 1) { // If there is only one section, do not write a section edit // marker. array_pop($this->sectionedits); } else { $this->finishSectionEdit(); } } if ($this->footnotes !== []) { $this->doc .= '
' . DOKU_LF; foreach ($this->footnotes as $id => $footnote) { // check its not a placeholder that indicates actual footnote text is elsewhere if (!str_starts_with($footnote, "@@FNT")) { // open the footnote and set the anchor and backlink $this->doc .= '
'; $this->doc .= ''; $this->doc .= $id . ') ' . DOKU_LF; // get any other footnotes that use the same markup $alt = array_keys($this->footnotes, "@@FNT$id"); foreach ($alt as $ref) { // set anchor and backlink for the other footnotes $this->doc .= ', '; $this->doc .= ($ref) . ') ' . DOKU_LF; } // add footnote markup and close this footnote $this->doc .= '
' . $footnote . '
'; $this->doc .= '
' . DOKU_LF; } } $this->doc .= '
' . DOKU_LF; } // Prepare the TOC global $conf; if ( $this->info['toc'] && is_array($this->toc) && $conf['tocminheads'] && count($this->toc) >= $conf['tocminheads'] ) { global $TOC; $TOC = $this->toc; } // make sure there are no empty paragraphs $this->doc = preg_replace('#

\s*

#', '', $this->doc); } /** * Add an item to the TOC * * @param string $id the hash link * @param string $text the text to display * @param int $level the nesting level */ public function toc_additem($id, $text, $level) { global $conf; //handle TOC if ($level >= $conf['toptoclevel'] && $level <= $conf['maxtoclevel']) { $this->toc[] = html_mktocitem($id, $text, $level - $conf['toptoclevel'] + 1); } } /** * Render a heading * * @param string $text the text to display * @param int $level header level * @param int $pos byte position in the original source * @param bool $returnonly whether to return html or write to doc attribute * @return void|string writes to doc attribute or returns html depends on $returnonly */ public function header($text, $level, $pos, $returnonly = false) { global $conf; if (blank($text)) return; //skip empty headlines $hid = $this->_headerToLink($text, true); //only add items within configured levels $this->toc_additem($hid, $text, $level); // adjust $node to reflect hierarchy of levels $this->node[$level - 1]++; if ($level < $this->lastlevel) { for ($i = 0; $i < $this->lastlevel - $level; $i++) { $this->node[$this->lastlevel - $i - 1] = 0; } } $this->lastlevel = $level; if ( $level <= $conf['maxseclevel'] && $this->sectionedits !== [] && $this->sectionedits[count($this->sectionedits) - 1]['target'] === 'section' ) { $this->finishSectionEdit($pos - 1); } // build the header $header = DOKU_LF . '_codeblock; $header .= ' class="' . $this->startSectionEdit($pos, $data) . '"'; } $header .= ' id="' . $hid . '">'; $header .= $this->_xmlEntities($text); $header .= "" . DOKU_LF; if ($returnonly) { return $header; } else { $this->doc .= $header; } } /** * Open a new section * * @param int $level section level (as determined by the previous header) */ public function section_open($level) { $this->doc .= '
' . DOKU_LF; } /** * Close the current section */ public function section_close() { $this->doc .= DOKU_LF . '
' . DOKU_LF; } /** * Render plain text data * * @param $text */ public function cdata($text) { $this->doc .= $this->_xmlEntities($text); } /** * Open a paragraph */ public function p_open() { $this->doc .= DOKU_LF . '

' . DOKU_LF; } /** * Close a paragraph */ public function p_close() { $this->doc .= DOKU_LF . '

' . DOKU_LF; } /** * Create a line break */ public function linebreak() { $this->doc .= '
' . DOKU_LF; } /** * Create a horizontal line */ public function hr() { $this->doc .= '
' . DOKU_LF; } /** * Start strong (bold) formatting */ public function strong_open() { $this->doc .= ''; } /** * Stop strong (bold) formatting */ public function strong_close() { $this->doc .= ''; } /** * Start emphasis (italics) formatting */ public function emphasis_open() { $this->doc .= ''; } /** * Stop emphasis (italics) formatting */ public function emphasis_close() { $this->doc .= ''; } /** * Start underline formatting */ public function underline_open() { $this->doc .= ''; } /** * Stop underline formatting */ public function underline_close() { $this->doc .= ''; } /** * Start monospace formatting */ public function monospace_open() { $this->doc .= ''; } /** * Stop monospace formatting */ public function monospace_close() { $this->doc .= ''; } /** * Start a subscript */ public function subscript_open() { $this->doc .= ''; } /** * Stop a subscript */ public function subscript_close() { $this->doc .= ''; } /** * Start a superscript */ public function superscript_open() { $this->doc .= ''; } /** * Stop a superscript */ public function superscript_close() { $this->doc .= ''; } /** * Start deleted (strike-through) formatting */ public function deleted_open() { $this->doc .= ''; } /** * Stop deleted (strike-through) formatting */ public function deleted_close() { $this->doc .= ''; } /** * Callback for footnote start syntax * * All following content will go to the footnote instead of * the document. To achieve this the previous rendered content * is moved to $store and $doc is cleared * * @author Andreas Gohr */ public function footnote_open() { // move current content to store and record footnote $this->store = $this->doc; $this->doc = ''; } /** * Callback for footnote end syntax * * All rendered content is moved to the $footnotes array and the old * content is restored from $store again * * @author Andreas Gohr */ public function footnote_close() { /** @var $fnid int takes track of seen footnotes, assures they are unique even across multiple docs FS#2841 */ static $fnid = 0; // assign new footnote id (we start at 1) $fnid++; // recover footnote into the stack and restore old content $footnote = $this->doc; $this->doc = $this->store; $this->store = ''; // check to see if this footnote has been seen before $i = array_search($footnote, $this->footnotes); if ($i === false) { // its a new footnote, add it to the $footnotes array $this->footnotes[$fnid] = $footnote; } else { // seen this one before, save a placeholder $this->footnotes[$fnid] = "@@FNT" . ($i); } // output the footnote reference and link $this->doc .= sprintf( '%d)', $fnid, $fnid, $fnid ); } /** * Open an unordered list * * @param string|string[] $classes css classes - have to be valid, do not pass unfiltered user input */ public function listu_open($classes = null) { $class = ''; if ($classes !== null) { if (is_array($classes)) $classes = implode(' ', $classes); $class = " class=\"$classes\""; } $this->doc .= "" . DOKU_LF; } /** * Close an unordered list */ public function listu_close() { $this->doc .= '' . DOKU_LF; } /** * Open an ordered list * * @param string|string[] $classes css classes - have to be valid, do not pass unfiltered user input */ public function listo_open($classes = null) { $class = ''; if ($classes !== null) { if (is_array($classes)) $classes = implode(' ', $classes); $class = " class=\"$classes\""; } $this->doc .= "" . DOKU_LF; } /** * Close an ordered list */ public function listo_close() { $this->doc .= '' . DOKU_LF; } /** * Open a list item * * @param int $level the nesting level * @param bool $node true when a node; false when a leaf */ public function listitem_open($level, $node = false) { $branching = $node ? ' node' : ''; $this->doc .= '
  • '; } /** * Close a list item */ public function listitem_close() { $this->doc .= '
  • ' . DOKU_LF; } /** * Start the content of a list item */ public function listcontent_open() { $this->doc .= '
    '; } /** * Stop the content of a list item */ public function listcontent_close() { $this->doc .= '
    ' . DOKU_LF; } /** * Output unformatted $text * * Defaults to $this->cdata() * * @param string $text */ public function unformatted($text) { $this->doc .= $this->_xmlEntities($text); } /** * Start a block quote */ public function quote_open() { $this->doc .= '
    ' . DOKU_LF; } /** * Stop a block quote */ public function quote_close() { $this->doc .= '
    ' . DOKU_LF; } /** * Output preformatted text * * @param string $text */ public function preformatted($text) { $this->doc .= '
    ' . trim($this->_xmlEntities($text), "\n\r") . '
    ' . DOKU_LF; } /** * Display text as file content, optionally syntax highlighted * * @param string $text text to show * @param string $language programming language to use for syntax highlighting * @param string $filename file path label * @param array $options assoziative array with additional geshi options */ public function file($text, $language = null, $filename = null, $options = null) { $this->_highlight('file', $text, $language, $filename, $options); } /** * Display text as code content, optionally syntax highlighted * * @param string $text text to show * @param string $language programming language to use for syntax highlighting * @param string $filename file path label * @param array $options assoziative array with additional geshi options */ public function code($text, $language = null, $filename = null, $options = null) { $this->_highlight('code', $text, $language, $filename, $options); } /** * Use GeSHi to highlight language syntax in code and file blocks * * @param string $type code|file * @param string $text text to show * @param string $language programming language to use for syntax highlighting * @param string $filename file path label * @param array $options assoziative array with additional geshi options * @author Andreas Gohr */ public function _highlight($type, $text, $language = null, $filename = null, $options = null) { global $ID; global $lang; global $INPUT; $language = preg_replace(PREG_PATTERN_VALID_LANGUAGE, '', $language ?? ''); if ($filename) { // add icon [$ext] = mimetype($filename, false); $class = preg_replace('/[^_\-a-z0-9]+/i', '_', $ext); $class = 'mediafile mf_' . $class; $offset = 0; if ($INPUT->has('codeblockOffset')) { $offset = $INPUT->str('codeblockOffset'); } $this->doc .= '
    ' . DOKU_LF; $this->doc .= '
    '; $this->doc .= hsc($filename); $this->doc .= '
    ' . DOKU_LF . '
    '; } if (str_starts_with($text, "\n")) { $text = substr($text, 1); } if (str_ends_with($text, "\n")) { $text = substr($text, 0, -1); } if (empty($language)) { // empty is faster than is_null and can prevent '' string $this->doc .= '
    ' . $this->_xmlEntities($text) . '
    ' . DOKU_LF; } else { $class = 'code'; //we always need the code class to make the syntax highlighting apply if ($type != 'code') $class .= ' ' . $type; $this->doc .= "
    " .
                    p_xhtml_cached_geshi($text, $language, '', $options) .
                    '
    ' . DOKU_LF; } if ($filename) { $this->doc .= '
    ' . DOKU_LF; } $this->_codeblock++; } /** * Format an acronym * * Uses $this->acronyms * * @param string $acronym */ public function acronym($acronym) { if (array_key_exists($acronym, $this->acronyms)) { $title = $this->_xmlEntities($this->acronyms[$acronym]); $this->doc .= '' . $this->_xmlEntities($acronym) . ''; } else { $this->doc .= $this->_xmlEntities($acronym); } } /** * Format a smiley * * Uses $this->smiley * * @param string $smiley */ public function smiley($smiley) { if (isset($this->smileys[$smiley])) { $this->doc .= '' . $this->_xmlEntities($smiley) . ''; } else { $this->doc .= $this->_xmlEntities($smiley); } } /** * Format an entity * * Entities are basically small text replacements * * Uses $this->entities * * @param string $entity */ public function entity($entity) { if (array_key_exists($entity, $this->entities)) { $this->doc .= $this->entities[$entity]; } else { $this->doc .= $this->_xmlEntities($entity); } } /** * Typographically format a multiply sign * * Example: ($x=640, $y=480) should result in "640×480" * * @param string|int $x first value * @param string|int $y second value */ public function multiplyentity($x, $y) { $this->doc .= "$x×$y"; } /** * Render an opening single quote char (language specific) */ public function singlequoteopening() { global $lang; $this->doc .= $lang['singlequoteopening']; } /** * Render a closing single quote char (language specific) */ public function singlequoteclosing() { global $lang; $this->doc .= $lang['singlequoteclosing']; } /** * Render an apostrophe char (language specific) */ public function apostrophe() { global $lang; $this->doc .= $lang['apostrophe']; } /** * Render an opening double quote char (language specific) */ public function doublequoteopening() { global $lang; $this->doc .= $lang['doublequoteopening']; } /** * Render an closinging double quote char (language specific) */ public function doublequoteclosing() { global $lang; $this->doc .= $lang['doublequoteclosing']; } /** * Render a CamelCase link * * @param string $link The link name * @param bool $returnonly whether to return html or write to doc attribute * @return void|string writes to doc attribute or returns html depends on $returnonly * * @see http://en.wikipedia.org/wiki/CamelCase */ public function camelcaselink($link, $returnonly = false) { if ($returnonly) { return $this->internallink($link, $link, null, true); } else { $this->internallink($link, $link); } } /** * Render a page local link * * @param string $hash hash link identifier * @param string $name name for the link * @param bool $returnonly whether to return html or write to doc attribute * @return void|string writes to doc attribute or returns html depends on $returnonly */ public function locallink($hash, $name = null, $returnonly = false) { global $ID; $name = $this->_getLinkTitle($name, $hash, $isImage); $hash = $this->_headerToLink($hash); $title = $ID . ' ↵'; $doc = ''; $doc .= $name; $doc .= ''; if ($returnonly) { return $doc; } else { $this->doc .= $doc; } } /** * Render an internal Wiki Link * * $search,$returnonly & $linktype are not for the renderer but are used * elsewhere - no need to implement them in other renderers * * @param string $id pageid * @param string|null $name link name * @param string|null $search adds search url param * @param bool $returnonly whether to return html or write to doc attribute * @param string $linktype type to set use of headings * @return void|string writes to doc attribute or returns html depends on $returnonly * @author Andreas Gohr */ public function internallink($id, $name = null, $search = null, $returnonly = false, $linktype = 'content') { global $conf; global $ID; global $INFO; $params = ''; $parts = explode('?', $id, 2); if (count($parts) === 2) { $id = $parts[0]; $params = $parts[1]; } // For empty $id we need to know the current $ID // We need this check because _simpleTitle needs // correct $id and resolve_pageid() use cleanID($id) // (some things could be lost) if ($id === '') { $id = $ID; } // default name is based on $id as given $default = $this->_simpleTitle($id); // now first resolve and clean up the $id $id = (new PageResolver($ID))->resolveId($id, $this->date_at, true); $exists = page_exists($id, $this->date_at, false, true); $link = []; $name = $this->_getLinkTitle($name, $default, $isImage, $id, $linktype); if (!$isImage) { if ($exists) { $class = 'wikilink1'; } else { $class = 'wikilink2'; $link['rel'] = 'nofollow'; } } else { $class = 'media'; } //keep hash anchor [$id, $hash] = sexplode('#', $id, 2); if (!empty($hash)) $hash = $this->_headerToLink($hash); //prepare for formating $link['target'] = $conf['target']['wiki']; $link['style'] = ''; $link['pre'] = ''; $link['suf'] = ''; $link['more'] = 'data-wiki-id="' . $id . '"'; // id is already cleaned $link['class'] = $class; if ($this->date_at) { $params = $params . '&at=' . rawurlencode($this->date_at); } $link['url'] = wl($id, $params); $link['name'] = $name; $link['title'] = $id; //add search string if ($search) { ($conf['userewrite']) ? $link['url'] .= '?' : $link['url'] .= '&'; if (is_array($search)) { $search = array_map('rawurlencode', $search); $link['url'] .= 's[]=' . implode('&s[]=', $search); } else { $link['url'] .= 's=' . rawurlencode($search); } } //keep hash if ($hash) $link['url'] .= '#' . $hash; //output formatted if ($returnonly) { return $this->_formatLink($link); } else { $this->doc .= $this->_formatLink($link); } } /** * Render an external link * * @param string $url full URL with scheme * @param string|array $name name for the link, array for media file * @param bool $returnonly whether to return html or write to doc attribute * @return void|string writes to doc attribute or returns html depends on $returnonly */ public function externallink($url, $name = null, $returnonly = false) { global $conf; $name = $this->_getLinkTitle($name, $url, $isImage); // url might be an attack vector, only allow registered protocols if (is_null($this->schemes)) $this->schemes = getSchemes(); [$scheme] = explode('://', $url); $scheme = strtolower($scheme); if (!in_array($scheme, $this->schemes)) $url = ''; // is there still an URL? if (!$url) { if ($returnonly) { return $name; } else { $this->doc .= $name; } return; } // set class if (!$isImage) { $class = 'urlextern'; } else { $class = 'media'; } //prepare for formating $link = []; $link['target'] = $conf['target']['extern']; $link['style'] = ''; $link['pre'] = ''; $link['suf'] = ''; $link['more'] = ''; $link['class'] = $class; $link['url'] = $url; $link['rel'] = ''; $link['name'] = $name; $link['title'] = $this->_xmlEntities($url); if ($conf['relnofollow']) $link['rel'] .= ' ugc nofollow'; if ($conf['target']['extern']) $link['rel'] .= ' noopener'; //output formatted if ($returnonly) { return $this->_formatLink($link); } else { $this->doc .= $this->_formatLink($link); } } /** * Render an interwiki link * * You may want to use $this->_resolveInterWiki() here * * @param string $match original link - probably not much use * @param string|array $name name for the link, array for media file * @param string $wikiName indentifier (shortcut) for the remote wiki * @param string $wikiUri the fragment parsed from the original link * @param bool $returnonly whether to return html or write to doc attribute * @return void|string writes to doc attribute or returns html depends on $returnonly */ public function interwikilink($match, $name, $wikiName, $wikiUri, $returnonly = false) { global $conf; $link = []; $link['target'] = $conf['target']['interwiki']; $link['pre'] = ''; $link['suf'] = ''; $link['more'] = ''; $link['name'] = $this->_getLinkTitle($name, $wikiUri, $isImage); $link['rel'] = ''; //get interwiki URL $exists = null; $url = $this->_resolveInterWiki($wikiName, $wikiUri, $exists); if (!$isImage) { $class = preg_replace('/[^_\-a-z0-9]+/i', '_', $wikiName); $link['class'] = "interwiki iw_$class"; } else { $link['class'] = 'media'; } //do we stay at the same server? Use local target if (strpos($url, DOKU_URL) === 0 || strpos($url, DOKU_BASE) === 0) { $link['target'] = $conf['target']['wiki']; } if ($exists !== null && !$isImage) { if ($exists) { $link['class'] .= ' wikilink1'; } else { $link['class'] .= ' wikilink2'; $link['rel'] .= ' nofollow'; } } if ($conf['target']['interwiki']) $link['rel'] .= ' noopener'; $link['url'] = $url; $link['title'] = $this->_xmlEntities($link['url']); // output formatted if ($returnonly) { if ($url == '') return $link['name']; return $this->_formatLink($link); } elseif ($url == '') { $this->doc .= $link['name']; } else $this->doc .= $this->_formatLink($link); } /** * Link to windows share * * @param string $url the link * @param string|array $name name for the link, array for media file * @param bool $returnonly whether to return html or write to doc attribute * @return void|string writes to doc attribute or returns html depends on $returnonly */ public function windowssharelink($url, $name = null, $returnonly = false) { global $conf; //simple setup $link = []; $link['target'] = $conf['target']['windows']; $link['pre'] = ''; $link['suf'] = ''; $link['style'] = ''; $link['name'] = $this->_getLinkTitle($name, $url, $isImage); if (!$isImage) { $link['class'] = 'windows'; } else { $link['class'] = 'media'; } $link['title'] = $this->_xmlEntities($url); $url = str_replace('\\', '/', $url); $url = 'file:///' . $url; $link['url'] = $url; //output formatted if ($returnonly) { return $this->_formatLink($link); } else { $this->doc .= $this->_formatLink($link); } } /** * Render a linked E-Mail Address * * Honors $conf['mailguard'] setting * * @param string $address Email-Address * @param string|array $name name for the link, array for media file * @param bool $returnonly whether to return html or write to doc attribute * @return void|string writes to doc attribute or returns html depends on $returnonly */ public function emaillink($address, $name = null, $returnonly = false) { global $conf; //simple setup $link = []; $link['target'] = ''; $link['pre'] = ''; $link['suf'] = ''; $link['style'] = ''; $link['more'] = ''; $name = $this->_getLinkTitle($name, '', $isImage); if (!$isImage) { $link['class'] = 'mail'; } else { $link['class'] = 'media'; } $address = $this->_xmlEntities($address); $address = obfuscate($address); $title = $address; if (empty($name)) { $name = $address; } if ($conf['mailguard'] == 'visible') $address = rawurlencode($address); $link['url'] = 'mailto:' . $address; $link['name'] = $name; $link['title'] = $title; //output formatted if ($returnonly) { return $this->_formatLink($link); } else { $this->doc .= $this->_formatLink($link); } } /** * Render an internal media file * * @param string $src media ID * @param string $title descriptive text * @param string $align left|center|right * @param int $width width of media in pixel * @param int $height height of media in pixel * @param string $cache cache|recache|nocache * @param string $linking linkonly|detail|nolink * @param bool $return return HTML instead of adding to $doc * @return void|string writes to doc attribute or returns html depends on $return */ public function internalmedia( $src, $title = null, $align = null, $width = null, $height = null, $cache = null, $linking = null, $return = false ) { global $ID; if (strpos($src, '#') !== false) { [$src, $hash] = sexplode('#', $src, 2); } $src = (new MediaResolver($ID))->resolveId($src, $this->date_at, true); $exists = media_exists($src); $noLink = false; $render = $linking != 'linkonly'; $link = $this->_getMediaLinkConf($src, $title, $align, $width, $height, $cache, $render); [$ext, $mime] = mimetype($src, false); if (str_starts_with($mime, 'image') && $render) { $link['url'] = ml( $src, [ 'id' => $ID, 'cache' => $cache, 'rev' => $this->_getLastMediaRevisionAt($src) ], ($linking == 'direct') ); } elseif (($mime == 'application/x-shockwave-flash' || media_supportedav($mime)) && $render) { // don't link movies $noLink = true; } else { // add file icons $class = preg_replace('/[^_\-a-z0-9]+/i', '_', $ext); $link['class'] .= ' mediafile mf_' . $class; $link['url'] = ml( $src, [ 'id' => $ID, 'cache' => $cache, 'rev' => $this->_getLastMediaRevisionAt($src) ], true ); if ($exists) $link['title'] .= ' (' . filesize_h(filesize(mediaFN($src))) . ')'; } if (!empty($hash)) $link['url'] .= '#' . $hash; //markup non existing files if (!$exists) { $link['class'] .= ' wikilink2'; } //output formatted if ($return) { if ($linking == 'nolink' || $noLink) { return $link['name']; } else { return $this->_formatLink($link); } } elseif ($linking == 'nolink' || $noLink) { $this->doc .= $link['name']; } else { $this->doc .= $this->_formatLink($link); } } /** * Render an external media file * * @param string $src full media URL * @param string $title descriptive text * @param string $align left|center|right * @param int $width width of media in pixel * @param int $height height of media in pixel * @param string $cache cache|recache|nocache * @param string $linking linkonly|detail|nolink * @param bool $return return HTML instead of adding to $doc * @return void|string writes to doc attribute or returns html depends on $return */ public function externalmedia( $src, $title = null, $align = null, $width = null, $height = null, $cache = null, $linking = null, $return = false ) { if (link_isinterwiki($src)) { [$shortcut, $reference] = sexplode('>', $src, 2, ''); $exists = null; $src = $this->_resolveInterWiki($shortcut, $reference, $exists); if ($src == '' && empty($title)) { // make sure at least something will be shown in this case $title = $reference; } } [$src, $hash] = sexplode('#', $src, 2); $noLink = false; if ($src == '') { // only output plaintext without link if there is no src $noLink = true; } $render = $linking != 'linkonly'; $link = $this->_getMediaLinkConf($src, $title, $align, $width, $height, $cache, $render); $link['url'] = ml($src, ['cache' => $cache]); [$ext, $mime] = mimetype($src, false); if (str_starts_with($mime, 'image') && $render) { // link only jpeg images // if ($ext != 'jpg' && $ext != 'jpeg') $noLink = true; } elseif (($mime == 'application/x-shockwave-flash' || media_supportedav($mime)) && $render) { // don't link movies $noLink = true; } else { // add file icons $class = preg_replace('/[^_\-a-z0-9]+/i', '_', $ext); $link['class'] .= ' mediafile mf_' . $class; } if ($hash) $link['url'] .= '#' . $hash; //output formatted if ($return) { if ($linking == 'nolink' || $noLink) return $link['name']; else return $this->_formatLink($link); } elseif ($linking == 'nolink' || $noLink) { $this->doc .= $link['name']; } else $this->doc .= $this->_formatLink($link); } /** * Renders an RSS feed * * @param string $url URL of the feed * @param array $params Finetuning of the output * * @author Andreas Gohr */ public function rss($url, $params) { global $lang; global $conf; $feed = new FeedParser(); $feed->set_feed_url($url); //disable warning while fetching if (!defined('DOKU_E_LEVEL')) { $elvl = error_reporting(E_ERROR); } $rc = $feed->init(); if (isset($elvl)) { error_reporting($elvl); } if ($params['nosort']) $feed->enable_order_by_date(false); //decide on start and end if ($params['reverse']) { $mod = -1; $start = $feed->get_item_quantity() - 1; $end = $start - ($params['max']); $end = ($end < -1) ? -1 : $end; } else { $mod = 1; $start = 0; $end = $feed->get_item_quantity(); $end = ($end > $params['max']) ? $params['max'] : $end; } $this->doc .= '
      '; if ($rc) { for ($x = $start; $x != $end; $x += $mod) { $item = $feed->get_item($x); $this->doc .= '
    • '; $lnkurl = $item->get_permalink(); $title = html_entity_decode($item->get_title(), ENT_QUOTES, 'UTF-8'); // support feeds without links if ($lnkurl) { $this->externallink($item->get_permalink(), $title); } else { $this->doc .= ' ' . hsc($item->get_title()); } if ($params['author']) { $author = $item->get_author(0); if ($author instanceof Author) { $name = $author->get_name(); if (!$name) $name = $author->get_email(); if ($name) $this->doc .= ' ' . $lang['by'] . ' ' . hsc($name); } } if ($params['date']) { $this->doc .= ' (' . $item->get_local_date($conf['dformat']) . ')'; } if ($params['details']) { $desc = $item->get_description(); $desc = strip_tags($desc); $desc = html_entity_decode($desc, ENT_QUOTES, 'UTF-8'); $this->doc .= '
      '; $this->doc .= hsc($desc); $this->doc .= '
      '; } $this->doc .= '
    • '; } } else { $this->doc .= '
    • '; $this->doc .= '' . $lang['rssfailed'] . ''; $this->externallink($url); if ($conf['allowdebug']) { $this->doc .= ''; } $this->doc .= '
    • '; } $this->doc .= '
    '; } /** * Start a table * * @param int $maxcols maximum number of columns * @param int $numrows NOT IMPLEMENTED * @param int $pos byte position in the original source * @param string|string[] $classes css classes - have to be valid, do not pass unfiltered user input */ public function table_open($maxcols = null, $numrows = null, $pos = null, $classes = null) { // initialize the row counter used for classes $this->_counter['row_counter'] = 0; $class = 'table'; if ($classes !== null) { if (is_array($classes)) $classes = implode(' ', $classes); $class .= ' ' . $classes; } if ($pos !== null) { $hid = $this->_headerToLink($class, true); $data = []; $data['target'] = 'table'; $data['name'] = ''; $data['hid'] = $hid; $class .= ' ' . $this->startSectionEdit($pos, $data); } $this->doc .= '
    ' . DOKU_LF; } /** * Close a table * * @param int $pos byte position in the original source */ public function table_close($pos = null) { $this->doc .= '
    ' . DOKU_LF; if ($pos !== null) { $this->finishSectionEdit($pos); } } /** * Open a table header */ public function tablethead_open() { $this->doc .= DOKU_TAB . '' . DOKU_LF; } /** * Close a table header */ public function tablethead_close() { $this->doc .= DOKU_TAB . '' . DOKU_LF; } /** * Open a table body */ public function tabletbody_open() { $this->doc .= DOKU_TAB . '' . DOKU_LF; } /** * Close a table body */ public function tabletbody_close() { $this->doc .= DOKU_TAB . '' . DOKU_LF; } /** * Open a table footer */ public function tabletfoot_open() { $this->doc .= DOKU_TAB . '' . DOKU_LF; } /** * Close a table footer */ public function tabletfoot_close() { $this->doc .= DOKU_TAB . '' . DOKU_LF; } /** * Open a table row * * @param string|string[] $classes css classes - have to be valid, do not pass unfiltered user input */ public function tablerow_open($classes = null) { // initialize the cell counter used for classes $this->_counter['cell_counter'] = 0; $class = 'row' . $this->_counter['row_counter']++; if ($classes !== null) { if (is_array($classes)) $classes = implode(' ', $classes); $class .= ' ' . $classes; } $this->doc .= DOKU_TAB . '' . DOKU_LF . DOKU_TAB . DOKU_TAB; } /** * Close a table row */ public function tablerow_close() { $this->doc .= DOKU_LF . DOKU_TAB . '' . DOKU_LF; } /** * Open a table header cell * * @param int $colspan * @param string $align left|center|right * @param int $rowspan * @param string|string[] $classes css classes - have to be valid, do not pass unfiltered user input */ public function tableheader_open($colspan = 1, $align = null, $rowspan = 1, $classes = null) { $class = 'class="col' . $this->_counter['cell_counter']++; if (!is_null($align)) { $class .= ' ' . $align . 'align'; } if ($classes !== null) { if (is_array($classes)) $classes = implode(' ', $classes); $class .= ' ' . $classes; } $class .= '"'; $this->doc .= ' 1) { $this->_counter['cell_counter'] += $colspan - 1; $this->doc .= ' colspan="' . $colspan . '"'; } if ($rowspan > 1) { $this->doc .= ' rowspan="' . $rowspan . '"'; } $this->doc .= '>'; } /** * Close a table header cell */ public function tableheader_close() { $this->doc .= ''; } /** * Open a table cell * * @param int $colspan * @param string $align left|center|right * @param int $rowspan * @param string|string[] $classes css classes - have to be valid, do not pass unfiltered user input */ public function tablecell_open($colspan = 1, $align = null, $rowspan = 1, $classes = null) { $class = 'class="col' . $this->_counter['cell_counter']++; if (!is_null($align)) { $class .= ' ' . $align . 'align'; } if ($classes !== null) { if (is_array($classes)) $classes = implode(' ', $classes); $class .= ' ' . $classes; } $class .= '"'; $this->doc .= ' 1) { $this->_counter['cell_counter'] += $colspan - 1; $this->doc .= ' colspan="' . $colspan . '"'; } if ($rowspan > 1) { $this->doc .= ' rowspan="' . $rowspan . '"'; } $this->doc .= '>'; } /** * Close a table cell */ public function tablecell_close() { $this->doc .= ''; } /** * Returns the current header level. * (required e.g. by the filelist plugin) * * @return int The current header level */ public function getLastlevel() { return $this->lastlevel; } #region Utility functions /** * Build a link * * Assembles all parts defined in $link returns HTML for the link * * @param array $link attributes of a link * @return string * * @author Andreas Gohr */ public function _formatLink($link) { //make sure the url is XHTML compliant (skip mailto) if (!str_starts_with($link['url'], 'mailto:')) { $link['url'] = str_replace('&', '&', $link['url']); $link['url'] = str_replace('&amp;', '&', $link['url']); } //remove double encodings in titles $link['title'] = str_replace('&amp;', '&', $link['title']); // be sure there are no bad chars in url or title // (we can't do this for name because it can contain an img tag) $link['url'] = strtr($link['url'], ['>' => '%3E', '<' => '%3C', '"' => '%22']); $link['title'] = strtr($link['title'], ['>' => '>', '<' => '<', '"' => '"']); $ret = ''; $ret .= $link['pre']; $ret .= ' */ public function _media( $src, $title = null, $align = null, $width = null, $height = null, $cache = null, $render = true ) { $ret = ''; [$ext, $mime] = mimetype($src); if (str_starts_with($mime, 'image')) { // first get the $title if (!is_null($title)) { $title = $this->_xmlEntities($title); } elseif ($ext == 'jpg' || $ext == 'jpeg') { //try to use the caption from IPTC/EXIF require_once(DOKU_INC . 'inc/JpegMeta.php'); $jpeg = new JpegMeta(mediaFN($src)); $cap = $jpeg->getTitle(); if (!empty($cap)) { $title = $this->_xmlEntities($cap); } } if (!$render) { // if the picture is not supposed to be rendered // return the title of the picture if ($title === null || $title === "") { // just show the sourcename $title = $this->_xmlEntities(PhpString::basename(noNS($src))); } return $title; } //add image tag $ret .= '_xmlEntities($width) . '"'; } if (!is_null($height)) { $ret .= ' height="' . $this->_xmlEntities($height) . '"'; } $ret .= ' />'; } elseif (media_supportedav($mime, 'video') || media_supportedav($mime, 'audio')) { // first get the $title $title ??= false; if (!$render) { // if the file is not supposed to be rendered // return the title of the file (just the sourcename if there is no title) return $this->_xmlEntities($title ?: PhpString::basename(noNS($src))); } $att = []; $att['class'] = "media$align"; if ($title) { $att['title'] = $title; } if (media_supportedav($mime, 'video')) { //add video $ret .= $this->_video($src, $width, $height, $att); } if (media_supportedav($mime, 'audio')) { //add audio $ret .= $this->_audio($src, $att); } } elseif ($mime == 'application/x-shockwave-flash') { if (!$render) { // if the flash is not supposed to be rendered // return the title of the flash if (!$title) { // just show the sourcename $title = PhpString::basename(noNS($src)); } return $this->_xmlEntities($title); } $att = []; $att['class'] = "media$align"; if ($align == 'right') $att['align'] = 'right'; if ($align == 'left') $att['align'] = 'left'; $ret .= html_flashobject( ml($src, ['cache' => $cache], true, '&'), $width, $height, ['quality' => 'high'], null, $att, $this->_xmlEntities($title) ); } elseif ($title) { // well at least we have a title to display $ret .= $this->_xmlEntities($title); } else { // just show the sourcename $ret .= $this->_xmlEntities(PhpString::basename(noNS($src))); } return $ret; } /** * Escape string for output * * @param $string * @return string */ public function _xmlEntities($string) { return hsc($string); } /** * Construct a title and handle images in titles * * @param string|array $title either string title or media array * @param string $default default title if nothing else is found * @param bool $isImage will be set to true if it's a media file * @param null|string $id linked page id (used to extract title from first heading) * @param string $linktype content|navigation * @return string HTML of the title, might be full image tag or just escaped text * @author Harry Fuecks */ public function _getLinkTitle($title, $default, &$isImage, $id = null, $linktype = 'content') { $isImage = false; if (is_array($title)) { $isImage = true; return $this->_imageTitle($title); } elseif (is_null($title) || trim($title) == '') { if (useHeading($linktype) && $id) { $heading = p_get_first_heading($id); if (!blank($heading)) { return $this->_xmlEntities($heading); } } return $this->_xmlEntities($default); } else { return $this->_xmlEntities($title); } } /** * Returns HTML code for images used in link titles * * @param array $img * @return string HTML img tag or similar * @author Andreas Gohr */ public function _imageTitle($img) { global $ID; // some fixes on $img['src'] // see internalmedia() and externalmedia() [$img['src']] = explode('#', $img['src'], 2); if ($img['type'] == 'internalmedia') { $img['src'] = (new MediaResolver($ID))->resolveId($img['src'], $this->date_at, true); } return $this->_media( $img['src'], $img['title'], $img['align'], $img['width'], $img['height'], $img['cache'] ); } /** * helperfunction to return a basic link to a media * * used in internalmedia() and externalmedia() * * @param string $src media ID * @param string $title descriptive text * @param string $align left|center|right * @param int $width width of media in pixel * @param int $height height of media in pixel * @param string $cache cache|recache|nocache * @param bool $render should the media be embedded inline or just linked * @return array associative array with link config * @author Pierre Spring */ public function _getMediaLinkConf($src, $title, $align, $width, $height, $cache, $render) { global $conf; $link = []; $link['class'] = 'media'; $link['style'] = ''; $link['pre'] = ''; $link['suf'] = ''; $link['more'] = ''; $link['target'] = $conf['target']['media']; if ($conf['target']['media']) $link['rel'] = 'noopener'; $link['title'] = $this->_xmlEntities($src); $link['name'] = $this->_media($src, $title, $align, $width, $height, $cache, $render); return $link; } /** * Embed video(s) in HTML * * @param string $src - ID of video to embed * @param int $width - width of the video in pixels * @param int $height - height of the video in pixels * @param array $atts - additional attributes for the