From 77cb6ef99f19e4dfe25243c151084dfbe9ba4d1e Mon Sep 17 00:00:00 2001 From: mscherer Date: Tue, 2 Dec 2025 19:26:30 +0100 Subject: [PATCH 1/2] Add AnsiRenderer for terminal output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new renderer that outputs ANSI-formatted text for terminal display. Features: - Colored headings (different colors per level, underlined h1/h2) - Bold, italic, underline, strikethrough styling - Colored inline code - Unicode box-drawing for tables - Blockquote bars with color - Bullet points (• or * fallback) - Task list checkboxes (☑/☐) - Super/subscript using Unicode characters - Symbol to emoji mapping (:heart: → ❤) - Configurable: colors, Unicode, terminal width Use cases: - CLI documentation viewers - Terminal-based Djot editors - Developer tooling - README previews in terminal --- examples/ansi-demo.php | 89 ++ src/Renderer/AnsiRenderer.php | 910 +++++++++++++++++++ tests/TestCase/Renderer/AnsiRendererTest.php | 245 +++++ 3 files changed, 1244 insertions(+) create mode 100644 examples/ansi-demo.php create mode 100644 src/Renderer/AnsiRenderer.php create mode 100644 tests/TestCase/Renderer/AnsiRendererTest.php diff --git a/examples/ansi-demo.php b/examples/ansi-demo.php new file mode 100644 index 0000000..1b6a8db --- /dev/null +++ b/examples/ansi-demo.php @@ -0,0 +1,89 @@ +#!/usr/bin/env php +convert($djot); +``` + +## Links and Images + +Visit [the Djot website](https://djot.net) for more info. + +![A sample image](photo.jpg) + +## Blockquotes + +> Djot is designed to be parsed without backtracking, +> making it fast and predictable. +> +> — John MacFarlane + +## Tables + +| Feature | Markdown | Djot | +|--------------|----------|------| +| Highlighting | No | Yes | +| Attributes | No | Yes | +| Consistent | No | Yes | + +## Definition List + +: Djot + A modern markup language designed for clarity. + +: Markdown + The predecessor with many flavors. + +## Task List + +- [ ] Learn Djot syntax +- [x] Install djot-php +- [ ] Build something awesome + +--- + +## Footnotes + +Here's a statement with a footnote[^1]. + +[^1]: This is the footnote content. +DJOT; + +$converter = new DjotConverter(); +$renderer = new AnsiRenderer(); + +// Parse and render +$document = $converter->parse($djot); +$output = $renderer->render($document); + +echo $output; diff --git a/src/Renderer/AnsiRenderer.php b/src/Renderer/AnsiRenderer.php new file mode 100644 index 0000000..bf1b85d --- /dev/null +++ b/src/Renderer/AnsiRenderer.php @@ -0,0 +1,910 @@ + + */ + protected array $orderedListCounters = []; + + public function __construct(int $terminalWidth = 80, bool $useColors = true, bool $useUnicode = true) + { + $this->terminalWidth = $terminalWidth; + $this->useColors = $useColors; + $this->useUnicode = $useUnicode; + } + + /** + * Set terminal width for wrapping + */ + public function setTerminalWidth(int $width): self + { + $this->terminalWidth = $width; + + return $this; + } + + /** + * Enable or disable ANSI colors + */ + public function setUseColors(bool $useColors): self + { + $this->useColors = $useColors; + + return $this; + } + + /** + * Enable or disable Unicode characters (bullets, box drawing) + */ + public function setUseUnicode(bool $useUnicode): self + { + $this->useUnicode = $useUnicode; + + return $this; + } + + public function render(Document $document): string + { + $output = $this->renderChildren($document); + + // Normalize multiple blank lines + $output = preg_replace("/\n{3,}/", "\n\n", $output) ?? $output; + + return trim($output) . "\n"; + } + + protected function renderNode(Node $node): string + { + return match (true) { + $node instanceof Document => $this->renderChildren($node), + $node instanceof Paragraph => $this->renderParagraph($node), + $node instanceof Heading => $this->renderHeading($node), + $node instanceof CodeBlock => $this->renderCodeBlock($node), + $node instanceof Comment => '', // Skip comments + $node instanceof RawBlock => $this->renderRawBlock($node), + $node instanceof BlockQuote => $this->renderBlockQuote($node), + $node instanceof ListBlock => $this->renderList($node), + $node instanceof ListItem => $this->renderListItem($node), + $node instanceof DefinitionList => $this->renderDefinitionList($node), + $node instanceof DefinitionTerm => $this->renderDefinitionTerm($node), + $node instanceof DefinitionDescription => $this->renderDefinitionDescription($node), + $node instanceof ThematicBreak => $this->renderThematicBreak(), + $node instanceof Div => $this->renderDiv($node), + $node instanceof Table => $this->renderTable($node), + $node instanceof LineBlock => $this->renderLineBlock($node), + $node instanceof Footnote => $this->renderFootnote($node), + $node instanceof Text => $node->getContent(), + $node instanceof Emphasis => $this->renderEmphasis($node), + $node instanceof Strong => $this->renderStrong($node), + $node instanceof Code => $this->renderCode($node), + $node instanceof Link => $this->renderLink($node), + $node instanceof Image => $this->renderImage($node), + $node instanceof HardBreak => "\n", + $node instanceof SoftBreak => ' ', + $node instanceof Superscript => $this->renderSuperscript($node), + $node instanceof Subscript => $this->renderSubscript($node), + $node instanceof Highlight => $this->renderHighlight($node), + $node instanceof Insert => $this->renderInsert($node), + $node instanceof Delete => $this->renderDelete($node), + $node instanceof Span => $this->renderChildren($node), + $node instanceof Math => $this->renderMath($node), + $node instanceof Symbol => $this->renderSymbol($node), + $node instanceof FootnoteRef => $this->renderFootnoteRef($node), + $node instanceof RawInline => '', // Skip raw inline + default => $this->renderChildren($node), + }; + } + + protected function renderChildren(Node $node): string + { + $output = ''; + foreach ($node->getChildren() as $child) { + $output .= $this->renderNode($child); + } + + return $output; + } + + protected function renderParagraph(Paragraph $node): string + { + $content = $this->renderChildren($node); + $prefix = $this->getBlockQuotePrefix(); + + if ($prefix !== '') { + $content = $this->prefixLines($content, $prefix); + } + + return $content . "\n\n"; + } + + protected function renderHeading(Heading $node): string + { + $level = $node->getLevel(); + $content = $this->renderChildren($node); + + // Color based on level + $color = match ($level) { + 1 => self::FG_BRIGHT_MAGENTA, + 2 => self::FG_BRIGHT_CYAN, + 3 => self::FG_BRIGHT_BLUE, + 4 => self::FG_BRIGHT_GREEN, + 5 => self::FG_BRIGHT_YELLOW, + default => self::FG_WHITE, + }; + + $styled = $this->style($content, self::BOLD . $color); + + // Add underline for h1 and h2 + if ($level <= 2) { + $underlineChar = $level === 1 ? '═' : '─'; + if (!$this->useUnicode) { + $underlineChar = $level === 1 ? '=' : '-'; + } + $underline = str_repeat($underlineChar, mb_strlen($content)); + $styled .= "\n" . $this->style($underline, $color); + } + + return $styled . "\n\n"; + } + + protected function renderCodeBlock(CodeBlock $node): string + { + $content = $node->getContent(); + $lang = $node->getLanguage(); + + $lines = explode("\n", rtrim($content)); + $output = ''; + + // Header with language + if ($lang !== null && $lang !== '') { + $header = $this->useUnicode + ? self::BOX_TOP_LEFT . str_repeat(self::BOX_HORIZONTAL, 2) . ' ' . $lang . ' ' + : '--- ' . $lang . ' '; + $output .= $this->style($header, self::DIM) . "\n"; + } + + // Code content with background + foreach ($lines as $line) { + $styledLine = $this->style(' ' . $line, self::FG_BRIGHT_WHITE); + $output .= $styledLine . "\n"; + } + + return $output . "\n"; + } + + protected function renderBlockQuote(BlockQuote $node): string + { + $this->blockQuoteDepth++; + $content = $this->renderChildren($node); + $this->blockQuoteDepth--; + + return $content; + } + + protected function getBlockQuotePrefix(): string + { + if ($this->blockQuoteDepth === 0) { + return ''; + } + + $bar = $this->useUnicode ? '│' : '|'; + $prefix = str_repeat($this->style($bar, self::FG_CYAN . self::DIM) . ' ', $this->blockQuoteDepth); + + return $prefix; + } + + protected function prefixLines(string $content, string $prefix): string + { + $lines = explode("\n", $content); + $prefixed = []; + foreach ($lines as $line) { + $prefixed[] = $prefix . $line; + } + + return implode("\n", $prefixed); + } + + protected function renderList(ListBlock $node): string + { + $this->listDepth++; + + if ($node->getListType() === ListBlock::TYPE_ORDERED) { + $this->orderedListCounters[$this->listDepth] = $node->getStart(); + } + + $output = $this->renderChildren($node); + $this->listDepth--; + + if ($this->listDepth === 0) { + $output .= "\n"; + } + + return $output; + } + + protected function renderListItem(ListItem $node): string + { + $indent = str_repeat(' ', $this->listDepth - 1); + $parent = $node->getParent(); + + if ($parent instanceof ListBlock && $parent->getListType() === ListBlock::TYPE_ORDERED) { + $num = $this->orderedListCounters[$this->listDepth] ?? 1; + $marker = $this->style($num . '.', self::FG_YELLOW); + $this->orderedListCounters[$this->listDepth] = $num + 1; + } else { + $bullet = $this->useUnicode ? self::BULLET : '*'; + $marker = $this->style($bullet, self::FG_CYAN); + } + + $content = trim($this->renderChildren($node)); + + // Handle task list items + if ($node->isTask()) { + $checkbox = $node->getChecked() + ? $this->style($this->useUnicode ? self::CHECKBOX_CHECKED : '[x]', self::FG_GREEN) + : $this->style($this->useUnicode ? self::CHECKBOX_UNCHECKED : '[ ]', self::FG_BRIGHT_BLACK); + $marker = $checkbox; + } + + return $indent . $marker . ' ' . $content . "\n"; + } + + protected function renderDefinitionList(DefinitionList $node): string + { + return $this->renderChildren($node) . "\n"; + } + + protected function renderDefinitionTerm(DefinitionTerm $node): string + { + $content = $this->renderChildren($node); + + return $this->style($content, self::BOLD . self::FG_YELLOW) . "\n"; + } + + protected function renderDefinitionDescription(DefinitionDescription $node): string + { + $content = trim($this->renderChildren($node)); + + return ' ' . $content . "\n"; + } + + protected function renderThematicBreak(): string + { + $char = $this->useUnicode ? '─' : '-'; + $line = str_repeat($char, min(40, $this->terminalWidth - 4)); + + return $this->style($line, self::DIM) . "\n\n"; + } + + protected function renderDiv(Div $node): string + { + return $this->renderChildren($node); + } + + protected function renderTable(Table $node): string + { + // First pass: calculate column widths + $colWidths = []; + $rows = []; + + foreach ($node->getChildren() as $row) { + if (!$row instanceof TableRow) { + continue; + } + + $cells = []; + $colIndex = 0; + foreach ($row->getChildren() as $cell) { + if (!$cell instanceof TableCell) { + continue; + } + $content = trim($this->renderChildren($cell)); + // Strip ANSI codes for width calculation + $plainContent = preg_replace('/\033\[[0-9;]*m/', '', $content) ?? $content; + $width = mb_strlen($plainContent); + $colWidths[$colIndex] = max($colWidths[$colIndex] ?? 0, $width); + $cells[] = ['content' => $content, 'plain' => $plainContent, 'isHeader' => $row->isHeader()]; + $colIndex++; + } + $rows[] = $cells; + } + + // Second pass: render table + $output = ''; + $isFirstRow = true; + $headerRendered = false; + + foreach ($rows as $rowIndex => $cells) { + // Top border for first row + if ($isFirstRow) { + $output .= $this->renderTableBorder($colWidths, 'top'); + $isFirstRow = false; + } + + // Render row + $output .= $this->renderTableRow($cells, $colWidths); + + // Separator after header + if (isset($cells[0]['isHeader']) && $cells[0]['isHeader'] && !$headerRendered) { + $output .= $this->renderTableBorder($colWidths, 'middle'); + $headerRendered = true; + } + } + + // Bottom border + $output .= $this->renderTableBorder($colWidths, 'bottom'); + + return $output . "\n"; + } + + /** + * @param array $colWidths + * @param string $position + */ + protected function renderTableBorder(array $colWidths, string $position): string + { + if (!$this->useUnicode) { + $total = array_sum($colWidths) + (count($colWidths) * 3) + 1; + + return '+' . str_repeat('-', $total - 2) . "+\n"; + } + + $left = match ($position) { + 'top' => self::BOX_TOP_LEFT, + 'middle' => self::BOX_T_RIGHT, + 'bottom' => self::BOX_BOTTOM_LEFT, + default => self::BOX_T_RIGHT, + }; + + $right = match ($position) { + 'top' => self::BOX_TOP_RIGHT, + 'middle' => self::BOX_T_LEFT, + 'bottom' => self::BOX_BOTTOM_RIGHT, + default => self::BOX_T_LEFT, + }; + + $cross = match ($position) { + 'top' => self::BOX_T_DOWN, + 'middle' => self::BOX_CROSS, + 'bottom' => self::BOX_T_UP, + default => self::BOX_CROSS, + }; + + $parts = []; + foreach ($colWidths as $width) { + $parts[] = str_repeat(self::BOX_HORIZONTAL, $width + 2); + } + + return $this->style($left . implode($cross, $parts) . $right, self::DIM) . "\n"; + } + + /** + * @param array $cells + * @param array $colWidths + */ + protected function renderTableRow(array $cells, array $colWidths): string + { + $separator = $this->useUnicode ? self::BOX_VERTICAL : '|'; + $styledSep = $this->style($separator, self::DIM); + + $parts = []; + foreach ($cells as $index => $cell) { + $width = $colWidths[$index] ?? 0; + $padding = $width - mb_strlen($cell['plain']); + $content = $cell['content'] . str_repeat(' ', $padding); + + if ($cell['isHeader']) { + $content = $this->style($cell['plain'] . str_repeat(' ', $padding), self::BOLD); + } + + $parts[] = ' ' . $content . ' '; + } + + return $styledSep . implode($styledSep, $parts) . $styledSep . "\n"; + } + + protected function renderLineBlock(LineBlock $node): string + { + return $this->renderChildren($node) . "\n"; + } + + protected function renderFootnote(Footnote $node): string + { + $label = $node->getLabel(); + $content = trim($this->renderChildren($node)); + $marker = $this->style('[' . $label . ']', self::FG_CYAN . self::DIM); + + return $marker . ' ' . $content . "\n"; + } + + protected function renderEmphasis(Emphasis $node): string + { + return $this->style($this->renderChildren($node), self::ITALIC); + } + + protected function renderStrong(Strong $node): string + { + return $this->style($this->renderChildren($node), self::BOLD); + } + + protected function renderCode(Code $node): string + { + $content = $node->getContent(); + + return $this->style($content, self::FG_BRIGHT_YELLOW); + } + + protected function renderLink(Link $node): string + { + $text = $this->renderChildren($node); + $url = $node->getDestination(); + + // OSC 8 hyperlink support (for terminals that support it) + // Format: \033]8;;URL\033\\TEXT\033]8;;\033\\ + $styled = $this->style($text, self::UNDERLINE . self::FG_BLUE); + + if ($url !== null && $url !== '' && $url !== $text) { + $styled .= $this->style(' (' . $url . ')', self::DIM); + } + + return $styled; + } + + protected function renderImage(Image $node): string + { + $alt = $node->getAlt(); + $marker = $this->style('[img:', self::FG_MAGENTA); + $altText = $alt !== '' ? ' ' . $alt : ''; + + return $marker . $altText . $this->style(']', self::FG_MAGENTA); + } + + protected function renderSuperscript(Superscript $node): string + { + $content = $this->renderChildren($node); + // Use Unicode superscript characters if possible + if ($this->useUnicode) { + return $this->toSuperscript($content); + } + + return '^' . $content; + } + + protected function renderSubscript(Subscript $node): string + { + $content = $this->renderChildren($node); + // Use Unicode subscript characters if possible + if ($this->useUnicode) { + return $this->toSubscript($content); + } + + return '_' . $content; + } + + protected function renderHighlight(Highlight $node): string + { + return $this->style($this->renderChildren($node), self::REVERSE . self::FG_YELLOW); + } + + protected function renderInsert(Insert $node): string + { + return $this->style($this->renderChildren($node), self::FG_GREEN . self::UNDERLINE); + } + + protected function renderDelete(Delete $node): string + { + return $this->style($this->renderChildren($node), self::STRIKETHROUGH . self::FG_RED); + } + + protected function renderMath(Math $node): string + { + $content = $node->getContent(); + + return $this->style($content, self::FG_BRIGHT_MAGENTA); + } + + protected function renderSymbol(Symbol $node): string + { + $name = $node->getName(); + + // Common emoji mappings + $emoji = match ($name) { + 'heart' => '❤', + 'star' => '★', + 'check' => '✓', + 'x' => '✗', + 'warning' => '⚠', + 'info' => 'ℹ', + 'arrow_right' => '→', + 'arrow_left' => '←', + 'arrow_up' => '↑', + 'arrow_down' => '↓', + 'sunny' => '☀', + 'cloud' => '☁', + 'thumbsup' => '👍', + 'thumbsdown' => '👎', + 'smile' => '😊', + 'sad' => '😢', + default => ':' . $name . ':', + }; + + return $this->useUnicode ? $emoji : ':' . $name . ':'; + } + + protected function renderFootnoteRef(FootnoteRef $node): string + { + $label = $node->getLabel(); + + return $this->style('[' . $label . ']', self::FG_CYAN . self::BOLD); + } + + protected function renderRawBlock(RawBlock $node): string + { + // Show raw blocks dimmed + $format = $node->getFormat(); + $content = $node->getContent(); + + return $this->style('[raw:' . $format . '] ' . $content, self::DIM) . "\n\n"; + } + + /** + * Apply ANSI styling if colors are enabled + */ + protected function style(string $text, string $codes): string + { + if (!$this->useColors) { + return $text; + } + + return $codes . $text . self::RESET; + } + + /** + * Convert text to Unicode superscript characters + */ + protected function toSuperscript(string $text): string + { + $map = [ + '0' => '⁰', + '1' => '¹', + '2' => '²', + '3' => '³', + '4' => '⁴', + '5' => '⁵', + '6' => '⁶', + '7' => '⁷', + '8' => '⁸', + '9' => '⁹', + '+' => '⁺', + '-' => '⁻', + '=' => '⁼', + '(' => '⁽', + ')' => '⁾', + 'n' => 'ⁿ', + 'i' => 'ⁱ', + ]; + + return strtr($text, $map); + } + + /** + * Convert text to Unicode subscript characters + */ + protected function toSubscript(string $text): string + { + $map = [ + '0' => '₀', + '1' => '₁', + '2' => '₂', + '3' => '₃', + '4' => '₄', + '5' => '₅', + '6' => '₆', + '7' => '₇', + '8' => '₈', + '9' => '₉', + '+' => '₊', + '-' => '₋', + '=' => '₌', + '(' => '₍', + ')' => '₎', + ]; + + return strtr($text, $map); + } +} diff --git a/tests/TestCase/Renderer/AnsiRendererTest.php b/tests/TestCase/Renderer/AnsiRendererTest.php new file mode 100644 index 0000000..fa2a9dd --- /dev/null +++ b/tests/TestCase/Renderer/AnsiRendererTest.php @@ -0,0 +1,245 @@ +converter = new DjotConverter(); + $this->renderer = new AnsiRenderer(); + } + + public function testRenderHeading(): void + { + $doc = $this->converter->parse("# Heading 1\n\n## Heading 2"); + $output = $this->renderer->render($doc); + + // Check that headings are styled (contain ANSI codes) + $this->assertStringContainsString("\033[", $output); + $this->assertStringContainsString('Heading 1', $output); + $this->assertStringContainsString('Heading 2', $output); + } + + public function testRenderEmphasisAndStrong(): void + { + $doc = $this->converter->parse('This is _emphasized_ and *strong* text.'); + $output = $this->renderer->render($doc); + + $this->assertStringContainsString('emphasized', $output); + $this->assertStringContainsString('strong', $output); + // Check for italic code + $this->assertStringContainsString("\033[3m", $output); + // Check for bold code + $this->assertStringContainsString("\033[1m", $output); + } + + public function testRenderInlineCode(): void + { + $doc = $this->converter->parse('Use the `print()` function.'); + $output = $this->renderer->render($doc); + + $this->assertStringContainsString('print()', $output); + // Should have yellow color for code + $this->assertStringContainsString("\033[93m", $output); + } + + public function testRenderCodeBlock(): void + { + $doc = $this->converter->parse("```php\necho 'hello';\n```"); + $output = $this->renderer->render($doc); + + $this->assertStringContainsString("echo 'hello';", $output); + $this->assertStringContainsString('php', $output); + } + + public function testRenderLink(): void + { + $doc = $this->converter->parse('Visit [Example](https://example.com).'); + $output = $this->renderer->render($doc); + + $this->assertStringContainsString('Example', $output); + $this->assertStringContainsString('https://example.com', $output); + // Check for underline + $this->assertStringContainsString("\033[4m", $output); + } + + public function testRenderImage(): void + { + $doc = $this->converter->parse('![A photo](image.jpg)'); + $output = $this->renderer->render($doc); + + $this->assertStringContainsString('[img:', $output); + $this->assertStringContainsString('A photo', $output); + } + + public function testRenderUnorderedList(): void + { + $doc = $this->converter->parse("- First\n- Second\n- Third"); + $output = $this->renderer->render($doc); + + $this->assertStringContainsString('First', $output); + $this->assertStringContainsString('Second', $output); + $this->assertStringContainsString('Third', $output); + // Should have bullet + $this->assertStringContainsString('•', $output); + } + + public function testRenderOrderedList(): void + { + $doc = $this->converter->parse("1. First\n2. Second\n3. Third"); + $output = $this->renderer->render($doc); + + $this->assertStringContainsString('1.', $output); + $this->assertStringContainsString('2.', $output); + $this->assertStringContainsString('3.', $output); + } + + public function testRenderBlockQuote(): void + { + $doc = $this->converter->parse("> A wise quote.\n>\n> Second paragraph."); + $output = $this->renderer->render($doc); + + $this->assertStringContainsString('A wise quote', $output); + // Should have quote bar (Unicode) + $this->assertStringContainsString('│', $output); + } + + public function testRenderTable(): void + { + $doc = $this->converter->parse("| A | B |\n|---|---|\n| 1 | 2 |"); + $output = $this->renderer->render($doc); + + $this->assertStringContainsString('A', $output); + $this->assertStringContainsString('B', $output); + $this->assertStringContainsString('1', $output); + $this->assertStringContainsString('2', $output); + // Should have box drawing characters + $this->assertStringContainsString('─', $output); + $this->assertStringContainsString('│', $output); + } + + public function testRenderThematicBreak(): void + { + $doc = $this->converter->parse("Before\n\n---\n\nAfter"); + $output = $this->renderer->render($doc); + + $this->assertStringContainsString('Before', $output); + $this->assertStringContainsString('After', $output); + $this->assertStringContainsString('─', $output); + } + + public function testRenderHighlight(): void + { + $doc = $this->converter->parse('This is {=highlighted=} text.'); + $output = $this->renderer->render($doc); + + $this->assertStringContainsString('highlighted', $output); + // Check for reverse video + $this->assertStringContainsString("\033[7m", $output); + } + + public function testRenderInsertDelete(): void + { + $doc = $this->converter->parse('Text with {+insert+} and {-delete-}.'); + $output = $this->renderer->render($doc); + + $this->assertStringContainsString('insert', $output); + $this->assertStringContainsString('delete', $output); + // Check for green (insert) and strikethrough (delete) + $this->assertStringContainsString("\033[32m", $output); + $this->assertStringContainsString("\033[9m", $output); + } + + public function testRenderSuperscriptSubscript(): void + { + $doc = $this->converter->parse('E=mc{^2^} and H{~2~}O'); + $output = $this->renderer->render($doc); + + // Should use Unicode super/subscript + $this->assertStringContainsString('²', $output); + $this->assertStringContainsString('₂', $output); + } + + public function testRenderSymbol(): void + { + $doc = $this->converter->parse('I :heart: this!'); + $output = $this->renderer->render($doc); + + $this->assertStringContainsString('❤', $output); + } + + public function testRenderDefinitionList(): void + { + $doc = $this->converter->parse(": Term\n\n Definition here."); + $output = $this->renderer->render($doc); + + $this->assertStringContainsString('Term', $output); + $this->assertStringContainsString('Definition here', $output); + } + + public function testRenderFootnote(): void + { + $doc = $this->converter->parse("Text[^1].\n\n[^1]: Footnote content."); + $output = $this->renderer->render($doc); + + $this->assertStringContainsString('[1]', $output); + $this->assertStringContainsString('Footnote content', $output); + } + + public function testDisableColors(): void + { + $renderer = new AnsiRenderer(80, false); + $doc = $this->converter->parse('*Bold text*'); + $output = $renderer->render($doc); + + // Should not contain any ANSI codes + $this->assertStringNotContainsString("\033[", $output); + $this->assertStringContainsString('Bold text', $output); + } + + public function testDisableUnicode(): void + { + $renderer = new AnsiRenderer(80, true, false); + $doc = $this->converter->parse("- Item 1\n- Item 2"); + $output = $renderer->render($doc); + + // Should use ASCII bullet instead of Unicode + $this->assertStringContainsString('*', $output); + $this->assertStringNotContainsString('•', $output); + } + + public function testSetTerminalWidth(): void + { + $renderer = new AnsiRenderer(); + $renderer->setTerminalWidth(40); + + $doc = $this->converter->parse('---'); + $output = $renderer->render($doc); + + // Thematic break should be limited + $plainLine = preg_replace('/\033\[[0-9;]*m/', '', $output) ?? $output; + $this->assertLessThanOrEqual(40, mb_strlen(trim($plainLine))); + } + + public function testFluentInterface(): void + { + $renderer = new AnsiRenderer(); + $result = $renderer + ->setTerminalWidth(100) + ->setUseColors(false) + ->setUseUnicode(false); + + $this->assertSame($renderer, $result); + } +} From 142ff6c8a6e315aba6b4a33bad1532795325d6c6 Mon Sep 17 00:00:00 2001 From: mscherer Date: Wed, 3 Dec 2025 00:55:28 +0100 Subject: [PATCH 2/2] Add AnsiRenderer cookbook documentation --- docs/README.md | 14 ++ docs/cookbook-ansi.md | 453 ++++++++++++++++++++++++++++++++++++++++++ docs/cookbook.md | 1 + 3 files changed, 468 insertions(+) create mode 100644 docs/cookbook-ansi.md diff --git a/docs/README.md b/docs/README.md index 19e5644..f726568 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,6 +11,7 @@ This directory contains detailed documentation for djot-php. - [Cookbook](cookbook.md) - Common customizations and recipes - [PlainText Cookbook](cookbook-plaintext.md) - PlainTextRenderer customizations - [Markdown Cookbook](cookbook-markdown.md) - MarkdownRenderer customizations +- [ANSI Cookbook](cookbook-ansi.md) - AnsiRenderer customizations - [Architecture](architecture.md) - Internal design - [Converters](converters.md) - Markdown/BBCode to Djot conversion - [Performance](performance.md) - Benchmarks and performance data @@ -338,6 +339,19 @@ echo $renderer->render($document); // Output: Hello **world**! ``` +### ANSI (Terminal) + +Render colorized output for terminals: + +```php +use Djot\Renderer\AnsiRenderer; + +$document = $converter->parse('# Hello *world*!'); +$renderer = new AnsiRenderer(); +echo $renderer->render($document); +// Output: Colorized heading with bold text +``` + ## File Operations ```php diff --git a/docs/cookbook-ansi.md b/docs/cookbook-ansi.md new file mode 100644 index 0000000..9e10448 --- /dev/null +++ b/docs/cookbook-ansi.md @@ -0,0 +1,453 @@ +# AnsiRenderer Cookbook + +Common recipes and customizations for the AnsiRenderer. + +## Table of Contents + +- [Basic Usage](#basic-usage) +- [Configuration Options](#configuration-options) +- [CLI Documentation Viewer](#cli-documentation-viewer) +- [Plain Text Fallback](#plain-text-fallback) +- [Custom Color Schemes](#custom-color-schemes) +- [Terminal Width Detection](#terminal-width-detection) +- [Pager Integration](#pager-integration) +- [Help Text Generation](#help-text-generation) +- [ASCII-Only Mode](#ascii-only-mode) + +## Basic Usage + +The AnsiRenderer converts Djot AST to ANSI-formatted text for terminal display: + +```php +use Djot\DjotConverter; +use Djot\Renderer\AnsiRenderer; + +$converter = new DjotConverter(); +$renderer = new AnsiRenderer(); + +$djot = '# Welcome + +This is *bold* and _italic_ text with `inline code`. + +- Item one +- Item two'; + +$document = $converter->parse($djot); +echo $renderer->render($document); +``` + +This produces colorized output with: +- Magenta bold heading with underline +- Bold text for strong emphasis +- Italic text for emphasis +- Yellow highlighted inline code +- Cyan bullet points + +## Configuration Options + +The AnsiRenderer supports three configuration options: + +```php +use Djot\Renderer\AnsiRenderer; + +// Constructor parameters +$renderer = new AnsiRenderer( + terminalWidth: 120, // For line wrapping (default: 80) + useColors: true, // ANSI color codes (default: true) + useUnicode: true, // Unicode bullets/boxes (default: true) +); + +// Or fluent setters +$renderer = new AnsiRenderer(); +$renderer + ->setTerminalWidth(100) + ->setUseColors(true) + ->setUseUnicode(true); +``` + +### Terminal Width + +Controls the width used for thematic breaks: + +```php +$renderer = new AnsiRenderer(terminalWidth: 60); +``` + +### Use Colors + +When disabled, outputs plain text without ANSI escape codes: + +```php +$renderer = new AnsiRenderer(useColors: false); +``` + +### Use Unicode + +When disabled, uses ASCII alternatives for bullets, checkboxes, and table borders: + +```php +$renderer = new AnsiRenderer(useUnicode: false); +``` + +Unicode enabled: +``` +• Item with bullet +☑ Completed task +☐ Pending task +┌───────┬───────┐ +│ Col 1 │ Col 2 │ +└───────┴───────┘ +``` + +Unicode disabled: +``` +* Item with bullet +[x] Completed task +[ ] Pending task ++---------------+ +| Col 1 | Col 2 | ++---------------+ +``` + +## CLI Documentation Viewer + +Build a documentation viewer for the terminal: + +```php +use Djot\DjotConverter; +use Djot\Renderer\AnsiRenderer; + +function showDocs(string $docPath): void +{ + $converter = new DjotConverter(); + $renderer = new AnsiRenderer( + terminalWidth: (int) exec('tput cols') ?: 80, + ); + + $djot = file_get_contents($docPath); + $document = $converter->parse($djot); + echo $renderer->render($document); +} + +// Usage +showDocs('docs/README.djot'); +``` + +## Plain Text Fallback + +For environments without ANSI support, disable colors and Unicode: + +```php +use Djot\DjotConverter; +use Djot\Renderer\AnsiRenderer; + +function renderForTerminal(string $djot): string +{ + $converter = new DjotConverter(); + + // Detect if terminal supports colors + $supportsColors = getenv('TERM') !== 'dumb' + && stream_isatty(STDOUT); + + $renderer = new AnsiRenderer( + useColors: $supportsColors, + useUnicode: $supportsColors, + ); + + $document = $converter->parse($djot); + return $renderer->render($document); +} +``` + +## Custom Color Schemes + +Create different visual themes by extending AnsiRenderer: + +```php +use Djot\Node\Block\Heading; +use Djot\Renderer\AnsiRenderer; + +class DarkThemeRenderer extends AnsiRenderer +{ + protected function renderHeading(Heading $node): string + { + $level = $node->getLevel(); + $content = $this->renderChildren($node); + + // Custom colors for dark terminals + $color = match ($level) { + 1 => self::FG_BRIGHT_GREEN, + 2 => self::FG_BRIGHT_YELLOW, + 3 => self::FG_BRIGHT_BLUE, + default => self::FG_WHITE, + }; + + $styled = $this->style($content, self::BOLD . $color); + + if ($level <= 2) { + $underlineChar = $this->useUnicode ? '━' : '='; + $underline = str_repeat($underlineChar, mb_strlen($content)); + $styled .= "\n" . $this->style($underline, $color); + } + + return $styled . "\n\n"; + } +} + +$renderer = new DarkThemeRenderer(); +``` + +### Minimal Theme + +A subdued color scheme: + +```php +use Djot\Node\Inline\Code; +use Djot\Renderer\AnsiRenderer; + +class MinimalRenderer extends AnsiRenderer +{ + protected function renderCode(Code $node): string + { + // Use dim instead of bright yellow + return $this->style($node->getContent(), self::DIM); + } +} +``` + +## Terminal Width Detection + +Automatically detect terminal width: + +```php +use Djot\DjotConverter; +use Djot\Renderer\AnsiRenderer; + +function getTerminalWidth(): int +{ + // Try tput first + $width = (int) @exec('tput cols 2>/dev/null'); + if ($width > 0) { + return $width; + } + + // Try stty + $output = @exec('stty size 2>/dev/null'); + if ($output) { + $parts = explode(' ', $output); + if (isset($parts[1])) { + return (int) $parts[1]; + } + } + + // Check environment variable + if (isset($_SERVER['COLUMNS'])) { + return (int) $_SERVER['COLUMNS']; + } + + // Default + return 80; +} + +$renderer = new AnsiRenderer(terminalWidth: getTerminalWidth()); +``` + +## Pager Integration + +Pipe output to `less` for long documents: + +```php +use Djot\DjotConverter; +use Djot\Renderer\AnsiRenderer; + +function viewWithPager(string $djot): void +{ + $converter = new DjotConverter(); + $renderer = new AnsiRenderer(); + + $document = $converter->parse($djot); + $output = $renderer->render($document); + + // Open pipe to less with ANSI support + $pager = popen('less -R', 'w'); + if ($pager) { + fwrite($pager, $output); + pclose($pager); + } else { + // Fallback to direct output + echo $output; + } +} + +// Usage +$longDoc = file_get_contents('very-long-document.djot'); +viewWithPager($longDoc); +``` + +## Help Text Generation + +Generate CLI help text from Djot: + +```php +use Djot\DjotConverter; +use Djot\Renderer\AnsiRenderer; + +function showHelp(): void +{ + $converter = new DjotConverter(); + $renderer = new AnsiRenderer(); + + $help = <<<'DJOT' +# myapp - A sample application + +## Usage + +`myapp [options] ` + +## Commands + +: init + Initialize a new project + +: build + Build the project + +: deploy + Deploy to production + +## Options + +| Option | Description | +|--------|-------------| +| `-h, --help` | Show help | +| `-v, --verbose` | Verbose output | +| `-q, --quiet` | Suppress output | + +## Examples + +```bash +myapp init my-project +myapp build --verbose +myapp deploy --env production +``` +DJOT; + + $document = $converter->parse($help); + echo $renderer->render($document); +} +``` + +## ASCII-Only Mode + +For legacy terminals or logging: + +```php +use Djot\DjotConverter; +use Djot\Renderer\AnsiRenderer; + +function renderAsciiOnly(string $djot): string +{ + $converter = new DjotConverter(); + $renderer = new AnsiRenderer( + useColors: false, + useUnicode: false, + ); + + $document = $converter->parse($djot); + return $renderer->render($document); +} + +$djot = <<<'DJOT' +# Task List + +- [x] Done +- [ ] Todo + +## Table + +| A | B | +|---|---| +| 1 | 2 | +DJOT; + +echo renderAsciiOnly($djot); +``` + +Output: +``` +Task List +========= + +[x] Done +[ ] Todo + +Table +----- + ++-------+ +| A | B | ++-------+ +| 1 | 2 | ++-------+ +``` + +## Supported Features + +The AnsiRenderer supports all Djot elements: + +| Element | Rendering | +|---------|-----------| +| Headings | Colored + underline (h1/h2) | +| Bold/Strong | ANSI bold | +| Italic/Emphasis | ANSI italic | +| Inline code | Yellow text | +| Code blocks | Indented with language header | +| Links | Blue underlined + URL in parentheses | +| Images | `[img: alt text]` | +| Lists | Cyan bullets (•) or yellow numbers | +| Task lists | Checkboxes (☑/☐) | +| Tables | Unicode box drawing | +| Blockquotes | Cyan vertical bars (│) | +| Highlights | Reverse video yellow | +| Insert | Green underlined | +| Delete | Red strikethrough | +| Superscript | Unicode (²) | +| Subscript | Unicode (₂) | +| Symbols | Emoji conversion | +| Thematic breaks | Horizontal line | +| Footnotes | Cyan markers | + +## ANSI Code Reference + +Available constants in AnsiRenderer: + +```php +// Styles +AnsiRenderer::RESET // Reset all styles +AnsiRenderer::BOLD // Bold text +AnsiRenderer::DIM // Dimmed text +AnsiRenderer::ITALIC // Italic text +AnsiRenderer::UNDERLINE // Underlined text +AnsiRenderer::STRIKETHROUGH // Strikethrough +AnsiRenderer::REVERSE // Reverse video + +// Foreground colors +AnsiRenderer::FG_BLACK, FG_RED, FG_GREEN, FG_YELLOW +AnsiRenderer::FG_BLUE, FG_MAGENTA, FG_CYAN, FG_WHITE +AnsiRenderer::FG_BRIGHT_BLACK, FG_BRIGHT_RED, etc. + +// Background colors +AnsiRenderer::BG_BLACK, BG_RED, BG_GREEN, etc. + +// Box drawing (Unicode) +AnsiRenderer::BOX_HORIZONTAL // ─ +AnsiRenderer::BOX_VERTICAL // │ +AnsiRenderer::BOX_TOP_LEFT // ┌ +AnsiRenderer::BOX_CROSS // ┼ +// etc. + +// Markers +AnsiRenderer::BULLET // • +AnsiRenderer::CHECKBOX_CHECKED // ☑ +AnsiRenderer::CHECKBOX_UNCHECKED // ☐ +``` diff --git a/docs/cookbook.md b/docs/cookbook.md index 60c470a..b46621a 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -1068,6 +1068,7 @@ echo $converter->convert($djot); For detailed customization of alternative renderers, see: - [PlainText Cookbook](cookbook-plaintext.md) - PlainTextRenderer customizations - [Markdown Cookbook](cookbook-markdown.md) - MarkdownRenderer customizations +- [ANSI Cookbook](cookbook-ansi.md) - AnsiRenderer customizations ### Plain Text Extraction