diff --git a/setup-tests.sh b/setup-tests.sh
new file mode 100644
index 0000000..c57b927
--- /dev/null
+++ b/setup-tests.sh
@@ -0,0 +1,3 @@
+#!/bin/bash
+set -e
+chmod +x test.sh
diff --git a/src/rich_cli/__main__.py b/src/rich_cli/__main__.py
index 98df6bb..9f0cbd2 100644
--- a/src/rich_cli/__main__.py
+++ b/src/rich_cli/__main__.py
@@ -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,61 @@ 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.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(
diff --git a/test.patch b/test.patch
new file mode 100644
index 0000000..73f9260
--- /dev/null
+++ b/test.patch
@@ -0,0 +1 @@
+# (Delete the file entirely; no replacement lines)
diff --git a/test.sh b/test.sh
new file mode 100755
index 0000000..d2701b2
--- /dev/null
+++ b/test.sh
@@ -0,0 +1,16 @@
+#!/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
diff --git a/tests/test_ansi_preprocessing.py b/tests/test_ansi_preprocessing.py
new file mode 100644
index 0000000..4c8b70c
--- /dev/null
+++ b/tests/test_ansi_preprocessing.py
@@ -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 '' 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 = '' in lowered
+ has_html_tag = ']*>', '', 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 '