-
Notifications
You must be signed in to change notification settings - Fork 95
Add --preprocess-ansi flag for HTML and SVG export #114
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
ZayanKhan-12
wants to merge
6
commits into
Textualize:main
Choose a base branch
from
ZayanKhan-12:ansi-preprocess
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 2 commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
7d4e47d
Add ANSI preprocessing test environment
ZayanKhan-12 331c5d6
Add --preprocess-ansi flag for HTML and SVG export
ZayanKhan-12 e040fa1
Update src/rich_cli/__main__.py
ZayanKhan-12 cf06a22
Update test.sh
ZayanKhan-12 62ff462
Update test.patch
ZayanKhan-12 753e7b0
Update setup-tests.sh
ZayanKhan-12 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| #!/bin/bash | ||
| set -e | ||
| cat > test.patch <<'PATCH' | ||
| <PASTE the full test.patch content from the problem description here> | ||
| PATCH | ||
| git apply test.patch | ||
| rm test.patch | ||
| chmod +x test.sh | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -395,6 +395,12 @@ class OptionHighlighter(RegexHighlighter): | |
| @click.option( | ||
| "--export-svg", metavar="PATH", default="", help="Write SVG to [b]PATH[/b]." | ||
| ) | ||
| @click.option( | ||
| "--preprocess-ansi", | ||
| is_flag=True, | ||
| help="Interpret and convert ANSI escape sequences before exporting HTML or SVG.", | ||
| ) | ||
|
|
||
| @click.option("--pager", is_flag=True, help="Display in an interactive pager.") | ||
| @click.option("--version", "-v", is_flag=True, help="Print version and exit.") | ||
| def main( | ||
|
|
@@ -440,6 +446,7 @@ def main( | |
| force_terminal: bool = False, | ||
| export_html: str = "", | ||
| export_svg: str = "", | ||
| preprocess_ansi: bool = False, | ||
| pager: bool = False, | ||
| ): | ||
| """Rich toolbox for console output.""" | ||
|
|
@@ -720,17 +727,63 @@ def print_usage() -> None: | |
| except Exception as error: | ||
| on_error("failed to print resource", error) | ||
|
|
||
| if export_html: | ||
| from rich.ansi import AnsiDecoder | ||
| from rich.console import Group | ||
|
|
||
| def preprocess_ansi_text(text: str) -> "RenderableType": | ||
|
||
| """Convert ANSI escape sequences into Rich renderables.""" | ||
| from rich.ansi import AnsiDecoder | ||
| from rich.console import Group | ||
ZayanKhan-12 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| from rich.text import Text | ||
|
|
||
| try: | ||
| console.save_html(export_html, clear=False) | ||
| except Exception as error: | ||
| on_error("failed to save HTML", error) | ||
| decoder = AnsiDecoder() | ||
| renderables = [] | ||
| for part in decoder.decode(text): | ||
| if isinstance(part, str): | ||
| renderables.append(Text(part)) | ||
| else: | ||
| renderables.append(part) | ||
| if not renderables: | ||
| return Text(text) | ||
| return Group(*renderables) | ||
| except Exception: | ||
| return Text(text) | ||
|
|
||
| if export_svg: | ||
| # --- Export handling with optional ANSI preprocessing --- | ||
| if export_html or export_svg: | ||
| try: | ||
| console.save_svg(export_svg, clear=False) | ||
| if preprocess_ansi: | ||
| # Capture what was printed so far | ||
| # Capture everything the console has rendered so far as ANSI text | ||
| ansi_data = console.export_text(clear=False) | ||
| decoder = AnsiDecoder() | ||
| renderables = list(decoder.decode(ansi_data)) | ||
|
|
||
| # Make sure everything is a valid Rich renderable | ||
| final_renderables = [] | ||
| for item in renderables: | ||
| if isinstance(item, str): | ||
| final_renderables.append(Text(item)) | ||
| else: | ||
| final_renderables.append(item) | ||
|
|
||
| # Combine and re-render | ||
| temp_console = Console(record=True) | ||
| temp_console.print(Group(*final_renderables)) | ||
|
|
||
|
|
||
| if export_html: | ||
| temp_console.save_html(export_html, clear=False) | ||
| if export_svg: | ||
| temp_console.save_svg(export_svg, clear=False) | ||
| else: | ||
| if export_html: | ||
| console.save_html(export_html, clear=False) | ||
| if export_svg: | ||
| console.save_svg(export_svg, clear=False) | ||
| except Exception as error: | ||
| on_error("failed to save SVG", error) | ||
| on_error("failed to save export", error) | ||
|
|
||
|
|
||
| def render_csv( | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| <PASTE EVERYTHING BETWEEN "diff --git a/test.sh ..." and "EOF" from the problem description> | ||
ZayanKhan-12 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| #!/bin/bash | ||
|
|
||
| set -e | ||
|
|
||
| if [ "$1" == "base" ]; then | ||
| echo "Running base tests..." | ||
| python -m pytest tests/ -k "not ansi_preprocessing" | ||
| elif [ "$1" == "new" ]; then | ||
| echo "Running new tests..." | ||
| python -m pytest tests/test_ansi_preprocessing.py -v | ||
| else | ||
| echo "Usage: ./test.sh [base|new]" | ||
| echo " base - Run existing tests (excluding ANSI preprocessing tests)" | ||
| echo " new - Run ANSI preprocessing tests" | ||
| exit 1 | ||
| fi | ||
|
|
||
|
|
||
|
|
||
|
|
||
|
|
||
|
|
||
|
|
||
|
|
||
|
|
||
|
|
||
|
|
||
|
|
||
|
|
||
ZayanKhan-12 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,251 @@ | ||
| import re | ||
| from pathlib import Path | ||
| from click.testing import CliRunner | ||
| import xml.etree.ElementTree as ET | ||
| import pytest | ||
|
|
||
| from rich_cli.__main__ import main | ||
|
|
||
| ANSI_ESCAPE_RE = re.compile(r'\x1b\[[0-9;?]*[A-Za-z]') | ||
|
|
||
|
|
||
| def contains_ansi(s: str) -> bool: | ||
| return bool(ANSI_ESCAPE_RE.search(s)) | ||
|
|
||
|
|
||
| def validate_svg_structure(svg_content: str) -> bool: | ||
| """Validate SVG structure using XML parsing.""" | ||
| try: | ||
| ET.fromstring(svg_content) | ||
|
|
||
| if '<svg' not in svg_content: | ||
| return False | ||
|
|
||
| has_svg_tag = re.search(r'<svg[^>]*>', svg_content) is not None | ||
| has_closing_svg_tag = '</svg>' in svg_content | ||
|
|
||
| return has_svg_tag and has_closing_svg_tag | ||
| except ET.ParseError: | ||
| return False | ||
|
|
||
|
|
||
| def validate_html_structure(html_content: str) -> bool: | ||
| """Validate HTML structure using robust parsing that handles real-world HTML output.""" | ||
| try: | ||
| lowered = html_content.lower() | ||
|
|
||
| has_doctype = '<!doctype html>' in lowered | ||
| has_html_tag = '<html' in lowered | ||
| has_head_tag = '<head' in lowered | ||
| has_body_tag = '<body' in lowered | ||
|
|
||
| if has_doctype: | ||
| if not (has_html_tag and has_head_tag and has_body_tag): | ||
| return False | ||
|
|
||
| try: | ||
| clean_html = re.sub(r'<!DOCTYPE[^>]*>', '', html_content, flags=re.IGNORECASE) | ||
| ET.fromstring(clean_html) | ||
| return True | ||
| except ET.ParseError: | ||
| return validate_html_tag_balance(html_content) | ||
| else: | ||
| return validate_html_tag_balance(html_content) | ||
|
|
||
| except Exception: | ||
| return basic_html_sanity_check(html_content) | ||
|
|
||
|
|
||
| def basic_html_sanity_check(html_content: str) -> bool: | ||
| """Basic sanity check for HTML content without strict parsing.""" | ||
| patterns = [ | ||
| r'<[a-zA-Z][^>]*>', | ||
| r'</[a-zA-Z]+>', | ||
| ] | ||
|
|
||
| has_tags = any(re.search(pattern, html_content) for pattern in patterns) | ||
|
|
||
| open_tags = len(re.findall(r'<([a-zA-Z]+)(?:\s[^>]*)?>', html_content)) | ||
| close_tags = len(re.findall(r'</([a-zA-Z]+)>', html_content)) | ||
|
|
||
| reasonable_balance = abs(open_tags - close_tags) < 5 | ||
|
|
||
| return has_tags and reasonable_balance | ||
|
|
||
|
|
||
| def validate_html_tag_balance(html_content: str) -> bool: | ||
| """Validate that HTML tags are properly balanced.""" | ||
| try: | ||
| open_tags = [] | ||
| tag_pattern = re.compile(r'</?([a-zA-Z][a-zA-Z0-9]*)[^>]*>') | ||
|
|
||
| for match in tag_pattern.finditer(html_content): | ||
| tag = match.group(1).lower() | ||
| full_tag = match.group(0) | ||
|
|
||
| if full_tag.startswith('</'): | ||
| if not open_tags or open_tags[-1] != tag: | ||
| return False | ||
| open_tags.pop() | ||
| elif not full_tag.endswith('/>'): | ||
| if tag not in ('br', 'hr', 'img', 'input', 'meta', 'link', '!doctype'): | ||
| open_tags.append(tag) | ||
|
|
||
| return len(open_tags) == 0 | ||
| except Exception: | ||
| return False | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def runner(): | ||
| return CliRunner() | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def temp_svg_file(tmp_path): | ||
| return str(tmp_path / "test.svg") | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def temp_html_file(tmp_path): | ||
| return str(tmp_path / "test.html") | ||
|
|
||
|
|
||
| def assert_styled_and_no_ansi(content: str, *, is_svg: bool = False): | ||
| """Assert that content contains styling and no raw ANSI.""" | ||
| assert not contains_ansi(content), "Found raw ANSI escape sequences in output" | ||
| if is_svg: | ||
| assert ('style=' in content or '<style' in content or 'fill=' in content), ( | ||
| "No styling detected in SVG output" | ||
| ) | ||
| else: | ||
| assert ('style=' in content or '<style' in content or 'class=' in content or 'color:' in content), ( | ||
| "No styling detected in HTML output" | ||
| ) | ||
|
|
||
|
|
||
| # === TESTS START HERE === | ||
|
|
||
|
|
||
| def test_export_svg_with_ansi_preprocessing(runner, temp_svg_file): | ||
| result = runner.invoke( | ||
| main, | ||
| ["-p", "[green]hello[/green]", "--export-svg", temp_svg_file, "--preprocess-ansi"], | ||
| ) | ||
| assert result.exit_code == 0, f"Command failed: {result.output}" | ||
|
|
||
| svg = Path(temp_svg_file).read_text() | ||
| assert "hello" in svg and "[green]" not in svg | ||
| assert validate_svg_structure(svg) | ||
| assert_styled_and_no_ansi(svg, is_svg=True) | ||
|
|
||
|
|
||
| def test_export_html_with_ansi_preprocessing(runner, temp_html_file): | ||
| result = runner.invoke( | ||
| main, | ||
| ["-p", "[green]hello[/green]", "--export-html", temp_html_file, "--preprocess-ansi"], | ||
| ) | ||
| assert result.exit_code == 0 | ||
| html = Path(temp_html_file).read_text() | ||
| assert "hello" in html and "[green]" not in html | ||
| assert validate_html_structure(html) | ||
| assert_styled_and_no_ansi(html) | ||
|
|
||
|
|
||
| def test_pipe_ansi_to_svg_export(runner, temp_svg_file): | ||
| ansi = "\033[32mhello\033[0m" | ||
| result = runner.invoke( | ||
| main, | ||
| ["--export-svg", temp_svg_file, "-", "--preprocess-ansi"], | ||
| input=ansi, | ||
| ) | ||
| assert result.exit_code == 0 | ||
| svg = Path(temp_svg_file).read_text() | ||
| assert "hello" in svg | ||
| assert_styled_and_no_ansi(svg, is_svg=True) | ||
| assert validate_svg_structure(svg) | ||
|
|
||
|
|
||
| def test_pipe_ansi_to_html_export(runner, temp_html_file): | ||
| ansi = "\033[32mhello\033[0m" | ||
| result = runner.invoke( | ||
| main, | ||
| ["--export-html", temp_html_file, "-", "--preprocess-ansi"], | ||
| input=ansi, | ||
| ) | ||
| assert result.exit_code == 0 | ||
| html = Path(temp_html_file).read_text() | ||
| assert "hello" in html | ||
| assert_styled_and_no_ansi(html) | ||
| assert validate_html_structure(html) | ||
|
|
||
|
|
||
| def test_preprocess_ansi_enabled_svg_styling(runner, temp_svg_file): | ||
| result = runner.invoke( | ||
| main, | ||
| ["--export-svg", temp_svg_file, "-", "--preprocess-ansi"], | ||
| input="\033[32mhello\033[0m", | ||
| ) | ||
| assert result.exit_code == 0 | ||
| svg = Path(temp_svg_file).read_text() | ||
| assert "hello" in svg | ||
| assert not contains_ansi(svg) | ||
| assert any(tag in svg for tag in ("style=", "fill=", "class=")) | ||
|
|
||
|
|
||
| def test_preprocess_ansi_enabled_html_styling(runner, temp_html_file): | ||
| ansi = "\033[32mhello\033[0m" | ||
| result = runner.invoke( | ||
| main, | ||
| ["--export-html", temp_html_file, "-", "--preprocess-ansi"], | ||
| input=ansi, | ||
| ) | ||
| assert result.exit_code == 0 | ||
| html = Path(temp_html_file).read_text() | ||
| assert "hello" in html | ||
| assert not contains_ansi(html) | ||
| assert any(t in html for t in ("style=", "color:", "<style")) | ||
|
|
||
|
|
||
| def test_mixed_ansi_and_rich_markup(runner, temp_html_file): | ||
| mixed = "\033[32mANSI green\033[0m and [blue]rich blue[/blue]" | ||
| result = runner.invoke( | ||
| main, | ||
| ["--export-html", temp_html_file, "-", "--preprocess-ansi"], | ||
| input=mixed, | ||
| ) | ||
| assert result.exit_code == 0 | ||
| html = Path(temp_html_file).read_text() | ||
| assert all(k in html for k in ("ANSI green", "rich blue")) | ||
| assert_styled_and_no_ansi(html) | ||
| assert validate_html_structure(html) | ||
|
|
||
|
|
||
| def test_special_characters_handling(runner, temp_html_file): | ||
| special = 'Text <>&"\' and \033[32mANSI\033[0m' | ||
| result = runner.invoke( | ||
| main, | ||
| ["--export-html", temp_html_file, "-", "--preprocess-ansi"], | ||
| input=special, | ||
| ) | ||
| assert result.exit_code == 0 | ||
| html = Path(temp_html_file).read_text() | ||
| assert "<" in html | ||
| assert ">" in html | ||
| assert "&" in html | ||
| assert """ in html or """ in html | ||
|
|
||
|
|
||
| def test_unicode_and_binary_safety(runner, temp_html_file): | ||
| uni = "Unicode 中文 Español 🚀\033[32mGreen\033[0m" | ||
| result = runner.invoke( | ||
| main, | ||
| ["--export-html", temp_html_file, "-", "--preprocess-ansi"], | ||
| input=uni, | ||
| ) | ||
| assert result.exit_code == 0 | ||
| html = Path(temp_html_file).read_text() | ||
| assert "Unicode" in html | ||
| assert "Green" in html | ||
| assert validate_html_structure(html) | ||
|
|
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.