commit 0982ded5a10effe5713139cb11c7c6d611c35e51 Author: yuki Date: Thu May 29 19:00:15 2025 -0400 First release diff --git a/README.md b/README.md new file mode 100644 index 0000000..e3a127b --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +# ANSI/VT100 text renderer for DokuWiki + +Renders page as text with ANSI escape codes. Perfect for viewing in a terminal with an utility such as curl. + +## Install + +Install to `/lib/plugins/ansi`, with this exact name. Refer to http://www.dokuwiki.org/plugins for more info. + +## Usage + +`?do=export_ansi` + +## Features + + - Bold, italics, underline, deleted, etc. + - Fancy code boxes + - Links (uses OSC 8) + - Nice headers (uses double-height lettering) + +## Licence + +Copyright (c) JP "Yuki" Savard + +Licensed under GPLv2 (same as DokuWiki) + +Inspired by the [text](http://www.dokuwiki.org/plugin:text) plugin but better \ No newline at end of file diff --git a/plugin.info.txt b/plugin.info.txt new file mode 100644 index 0000000..ba14cd0 --- /dev/null +++ b/plugin.info.txt @@ -0,0 +1,7 @@ +base ansi +author JP Savard +email yuki@a39.ca +date 2025-05-27 +name ANSI/VT100 text renderer +desc Renders pages as text with ANSI/VT100 escape codes. +url https://a39.dev/a39/dokuwiki-plugin-ansi diff --git a/renderer.php b/renderer.php new file mode 100644 index 0000000..f132152 --- /dev/null +++ b/renderer.php @@ -0,0 +1,650 @@ + + */ +class renderer_plugin_ansi extends Doku_Renderer +{ + public const ESC = "\x1b"; + public const CSI = self::ESC."["; + public const OSC = self::ESC."]"; + public const ST = self::ESC."\\"; + + public $toc = []; + protected $footnotes = []; + protected $store = ''; + protected $nSpan = 0; + protected $separator = ''; + protected $list = []; + protected $listtype = ''; + + /** @inheritdoc */ + function getFormat() + { + return 'ansi'; + } + + /** @inheritdoc */ + public function document_start() + { + global $ID; + + $this->doc = ''; + $this->toc = array(); + $this->footnotes = array(); + $this->store = ''; + $this->nSpan = 0; + $this->separator = ''; + + $meta = array(); + $meta['format']['ansi']['Content-Type'] = 'text/ansi; charset=utf-8'; + p_set_metadata($ID, $meta); + } + + /** @inheritdoc */ + public function document_end() + { + if (count($this->footnotes) > 0) { + $this->doc .= DOKU_LF; + $this->hr(); + + $id = 0; + foreach ($this->footnotes as $footnote) { + $id++; // the number of the current footnote + + // check its not a placeholder that indicates actual footnote text is elsewhere + if (substr($footnote, 0, 5) != "@@FNT") { + $this->doc .= self::CSI."1m" . $id . ') ' . self::CSI."22m"; + // get any other footnotes that use the same markup + $alt = array_keys($this->footnotes, "@@FNT$id"); + if (count($alt)) { + foreach ($alt as $ref) { + $this->doc .= self::CSI."1m" . ($ref + 1) . ') ' . self::CSI."22m"; + } + } + $this->doc .= $footnote . 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('#' . DOKU_LF . '\s*' . DOKU_LF . '\s*' . DOKU_LF . '#', DOKU_LF . DOKU_LF, $this->doc); + } + + /** @inheritdoc */ + public function header($text, $level, $pos, $returnonly = false) + { + if($level <= 2) + { + $this->doc .= ($level == 1 ? self::CSI."7m" : "") . self::ESC."#3" . $text . DOKU_LF . self::ESC."#4" . $text . ($level == 1 ? self::CSI."27m" : "") . DOKU_LF . DOKU_LF; + } + else + { + $this->doc .= self::CSI."7m" . str_pad("", 5-$level) . $text . str_pad("", 5-$level) . self::CSI."27m" . DOKU_LF . DOKU_LF; + } + } + + /** @inheritdoc */ + public function section_open($levels) + { + } + + /** @inheritdoc */ + public function section_close() + { + $this->doc .= DOKU_LF; + } + + /** @inheritdoc */ + public function cdata($text) + { + $this->doc .= $text; + } + + /** @inheritdoc */ + public function p_open() + { + } + + /** @inheritdoc */ + public function p_close() + { + $this->doc .= DOKU_LF . DOKU_LF; + } + + /** @inheritdoc */ + public function linebreak() + { + $this->doc .= DOKU_LF; + } + + /** @inheritdoc */ + public function hr() + { + $this->doc .= '╺━━━━━━━━━━━━━━╸' . DOKU_LF . DOKU_LF; + } + + /** @inheritdoc */ + public function strong_open() + { + $this->doc .= self::CSI . "1m"; + } + + /** @inheritdoc */ + public function strong_close() + { + $this->doc .= self::CSI . "22m"; + } + + /** @inheritdoc */ + public function emphasis_open() + { + $this->doc .= self::CSI . "3m"; + } + + /** @inheritdoc */ + public function emphasis_close() + { + $this->doc .= self::CSI . "23m"; + } + + /** @inheritdoc */ + public function underline_open() + { + $this->doc .= self::CSI . "4m"; + } + + /** @inheritdoc */ + public function underline_close() + { + $this->doc .= self::CSI . "24m"; + } + + /** @inheritdoc */ + public function monospace_open() + { + $this->doc .= self::CSI . "11m"; + } + + /** @inheritdoc */ + public function monospace_close() + { + $this->doc .= self::CSI . "10m"; + } + + /** @inheritdoc */ + public function deleted_open() + { + $this->doc .= self::CSI . "9m"; + } + + /** @inheritdoc */ + public function deleted_close() + { + $this->doc .= self::CSI . "29m"; + } + + /** @inheritdoc */ + public function superscript_open() + { + $this->doc .= self::CSI . "73m"; + } + + /** @inheritdoc */ + public function superscript_close() + { + $this->doc .= self::CSI . "75m"; + } + + /** @inheritdoc */ + public function subscript_open() + { + $this->doc .= self::CSI . "74m"; + } + + /** @inheritdoc */ + public function subscript_close() + { + $this->doc .= self::CSI . "75m"; + } + + /** @inheritdoc */ + public function footnote_open() + { + $this->store = $this->doc; + $this->doc = ''; + } + + /** @inheritdoc */ + public function footnote_close() + { + // 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 + $id = count($this->footnotes) + 1; + $this->footnotes[] = $footnote; + } else { + // seen this one before, translate the index to an id and save a placeholder + $i++; + $id = count($this->footnotes) + 1; + $this->footnotes[] = "@@FNT" . ($i); + } + + // output the footnote reference and link + $this->doc .= self::CSI."1m[" . $id . "]" . self::CSI."22m"; + } + + /** @inheritdoc */ + public function listu_open($classes = null) + { + $this->listtype = "u"; + } + + /** @inheritdoc */ + public function listu_close() + { + $this->doc .= DOKU_LF; + } + + /** @inheritdoc */ + public function listo_open($classes = null) + { + $this->listtype = "o"; + $this->list[] = 0; + } + + /** @inheritdoc */ + public function listo_close() + { + $this->doc .= DOKU_LF; + array_pop($this->list); + } + + /** @inheritdoc */ + public function listitem_open($level, $node = false) + { + if($this->listtype=="o") $this->list[count($this->list)-1]++; + $this->doc .= self::CSI . "1m" . str_pad("", $level*2) . ($this->listtype == "u" ? "*" : end($this->list).".") . self::CSI . "22m"; + } + + /** @inheritdoc */ + public function listitem_close() + { + } + + /** @inheritdoc */ + public function listcontent_open() + { + } + + /** @inheritdoc */ + public function listcontent_close() + { + $this->doc .= DOKU_LF; + } + + /** @inheritdoc */ + public function quote_open() + { + $this->doc .= " ┇ "; + } + + /** @inheritdoc */ + public function quote_close() + { + $this->doc .= DOKU_LF; + } + + /** @inheritdoc */ + public function preformatted($text) + { + $this->_highlight('pre', $text); + } + + /** @inheritdoc */ + public function file($text, $language = null, $filename = null, $options = null) + { + $this->_highlight('file', $text, $language, $filename, $options); + } + + /** @inheritdoc */ + public function code($text, $language = null, $filename = null, $options = null) + { + $this->_highlight('code', $text, $language, $filename, $options); + } + + public function _highlight($type, $text, $language = null, $filename = null, $options = null) + { + $types = ["pre" => "", "file" => "\xf0\x9f\x93\x84", "code" => "\xe2\x8c\xa8\xef\xb8\x8f"]; + $txt = explode("\n", $text); + $this->doc .= "┏━╸" . $types[$type] . ($language === null ? "" : " [" . $language . "]") . ($filename === null ? "" : " " . $filename) . DOKU_LF; + foreach($txt as $line) + $this->doc .= "┃ " . $line . DOKU_LF; + $this->doc .= "┗━╸" . DOKU_LF; + } + + /** @inheritdoc */ + public function acronym($acronym) + { + if (array_key_exists($acronym, $this->acronyms)) { + $title = $this->acronyms[$acronym]; + $this->doc .= $acronym . ' (' . $title . ')'; + } else { + $this->doc .= $acronym; + } + } + + /** @inheritdoc */ + public function smiley($smiley) + { + if (isset($this->smileys[$smiley])) { + // TODO: sixels + $this->doc .= $smiley; + } else { + $this->doc .= $smiley; + } + } + + /** @inheritdoc */ + public function entity($entity) + { + if (array_key_exists($entity, $this->entities)) { + $this->doc .= $this->entities[$entity]; + } else { + $this->doc .= $entity; + } + } + + /** @inheritdoc */ + public function multiplyentity($x, $y) + { + $this->doc .= $x . '×' . $y; + } + + /** @inheritdoc */ + public function singlequoteopening() + { + global $lang; + $this->doc .= $lang['singlequoteopening']; + } + + /** @inheritdoc */ + public function singlequoteclosing() + { + global $lang; + $this->doc .= $lang['singlequoteclosing']; + } + + /** @inheritdoc */ + public function apostrophe() + { + global $lang; + $this->doc .= $lang['apostrophe']; + } + + /** @inheritdoc */ + public function doublequoteopening() + { + global $lang; + $this->doc .= $lang['doublequoteopening']; + } + + /** @inheritdoc */ + public function doublequoteclosing() + { + global $lang; + $this->doc .= $lang['doublequoteclosing']; + } + + /** @inheritdoc */ + public function camelcaselink($link, $returnonly = false) + { + return $this->internallink($link, $link); + } + + /** @inheritdoc */ + public function locallink($hash, $name = null, $returnonly = false) + { + $name = $this->_getLinkTitle($name, $hash, $isImage); + $this->doc .= $name; + } + + /** @inheritdoc */ + public function internallink($id, $name = null, $search = null, $returnonly = false, $linktype = 'content') + { + global $ID; + + $params = ''; + $parts = explode('?', $id, 2); + if (count($parts) === 2) { + $id = $parts[0]; + $params = $parts[1]; + } + + if($id === '') $id = $ID; + + $default = $this->_simpleTitle($id); + $resolver = new PageResolver($ID); + $id = $resolver->resolveId($id, $this->date_at, true); + $exists = page_exists($id, $this->date_at, false, true); + + $name = $this->_getLinkTitle($name, $default, $isImage, $id, $linktype); + + $link = self::CSI . ($exists ? "35" : "31") . ";4m" . $this->_formatLink($id, $this->_getFullLink($id), $name) . self::CSI."24;39m"; + if($returnonly) + return $link; + else + $this->doc .= $link; + } + + /** @inheritdoc */ + public function externallink($url, $name = null, $returnonly = false) + { + $name = $this->_getLinkTitle($name, $url, $isImage); + $link = self::CSI."95;4m" . $this->_formatLink($url, $url, $name) . self::CSI."24;39m"; + if($returnonly) + return $link; + else + $this->doc .= $link; + } + + /** @inheritdoc */ + public function interwikilink($match, $name, $wikiName, $wikiUri, $returnonly = false) + { + $name = $this->_getLinkTitle($name, $wikiUri, $isImage); + $exists = null; + $uri = $this->_resolveInterWiki($wikiName, $wikiUri, $exists); + $link = self::CSI.($exists ? "95" : "91").";4m" . $this->_formatLink($match, $uri, $name) . self::CSI."24;39m"; + if($returnonly) + return $link; + else + $this->doc .= $link; + } + + /** @inheritdoc */ + public function windowssharelink($url, $name = null, $returnonly = false) + { + $name = $this->_getLinkTitle($name, $url, $isImage); + $uri = str_replace('\\', '/', $url); + $uri = 'file://' . $uri; + $link = self::CSI."95;4m" . $this->_formatLink($url, $uri, $name) . self::CSI."24;39m"; + if($returnonly) + return $link; + else + $this->doc .= $link; + } + + /** @inheritdoc */ + public function emaillink($address, $name = null, $returnonly = false) + { + $name = $this->_getLinkTitle($name, '', $isImage); + $address = html_entity_decode(obfuscate($address), ENT_QUOTES, 'UTF-8'); + if (empty($name)) { + $name = $address; + } + $link = self::CSI."95;4m" . $this->_formatLink($address, "mailto:".$address, $name) . self::CSI."24;39m"; + if($returnonly) + return $link; + else + $this->doc .= $link; + } + + /** @inheritdoc */ + public function internalmedia($src, $title = null, $align = null, $width = null, + $height = null, $cache = null, $linking = null, $return = false) + { + $this->doc .= "{{" . $src . "}}"; + } + + /** @inheritdoc */ + public function externalmedia($src, $title = null, $align = null, $width = null, + $height = null, $cache = null, $linking = null, $return = false) + { + $this->doc .= "{{" . $src . "}}"; + } + + /** @inheritdoc */ + public function rss($url, $params) + { + $this->doc .= "{{rss>" . $url . "}}" . DOKU_LF; + } + + /** @inheritdoc */ + public function table_open($maxcols = null, $numrows = null, $pos = null, $classes = null) + { + + } + + /** @inheritdoc */ + public function table_close($pos = null) + { + $this->doc .= DOKU_LF; + } + + /** @inheritdoc */ + public function tablethead_open() + { + } + + /** @inheritdoc */ + public function tablethead_close() + { + } + + /** @inheritdoc */ + public function tabletfoot_open() + { + } + + /** @inheritdoc */ + public function tabletfoot_close() + { + } + + /** @inheritdoc */ + public function tabletbody_open() + { + } + + /** @inheritdoc */ + public function tabletbody_close() + { + } + + /** @inheritdoc */ + public function tablerow_open($classes = null) + { + $this->separator = ''; + } + + /** @inheritdoc */ + public function tablerow_close() + { + $this->doc .= DOKU_LF; + } + + /** @inheritdoc */ + public function tableheader_open($colspan = 1, $align = null, $rowspan = 1, $classes = null) + { + $this->tablecell_open($colspan, $align, $rowspan, $classes); + } + + /** @inheritdoc */ + public function tableheader_close() + { + $this->tablecell_close(); + } + + /** @inheritdoc */ + public function tablecell_open($colspan = 1, $align = null, $rowspan = 1, $classes = null) + { + $this->nSpan = $colspan; + $this->doc .= $this->separator; + $this->separator = ' | '; + } + + /** @inheritdoc */ + public function tablecell_close() + { + if ($this->nSpan > 0) { + $this->doc .= str_repeat(',', $this->nSpan - 1); + } + $this->nSpan = 0; + } + + /** @inheritdoc */ + public function _getLinkTitle($title, $default, &$isImage, $id = null, $linktype = 'content') + { + $isImage = false; + if (is_array($title)) { + $isImage = true; + if (!is_null($default) && ($default != $title['title'])) + return $default . " " . $title['title']; + else + return $title['title']; + } elseif (is_null($title) || trim($title) == '') { + if (useHeading($linktype) && $id) { + $heading = p_get_first_heading($id); + if ($heading) { + return $heading; + } + } + return $default; + } else { + return $title; + } + } + + public function _getFullLink($id) + { + global $conf; + $base = $conf['baseurl'].$conf['basedir']; + if ($conf['useslash']) { + $id = strtr($id, ':', '/'); + } + return $base . $id; + } + + public function _formatLink($id, $url, $name) + { + return self::OSC."8;;" . $url . self::ST . $name . ($id == $name ? "" : " [" . $id . "]") . self::OSC."8;;" . self::ST; + } +}