diff --git a/pyproject.toml b/pyproject.toml index 27197ba..71562db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual-webterm" -version = "0.2.10" +version = "0.3.0" description = "Serve terminal sessions over the web" authors = ["Will McGugan "] license = "MIT" diff --git a/src/textual_webterm/app_session.py b/src/textual_webterm/app_session.py index 1a05466..5a0d258 100644 --- a/src/textual_webterm/app_session.py +++ b/src/textual_webterm/app_session.py @@ -11,7 +11,6 @@ from enum import Enum, auto from time import monotonic from typing import TYPE_CHECKING -import rich.repr from importlib_metadata import version from . import constants @@ -41,7 +40,6 @@ class ProcessState(Enum): return self.name -@rich.repr.auto(angular=True) class AppSession(Session): """Runs a single app process.""" @@ -128,11 +126,9 @@ class AppSession(Session): """Check if the app session is still running.""" return self._state == ProcessState.RUNNING - def __rich_repr__(self) -> rich.repr.Result: - yield self.command - yield "id", self.session_id - if self._process is not None: - yield "returncode", self._process.returncode, None + def __repr__(self) -> str: + returncode = self._process.returncode if self._process else None + return f"" async def open(self, width: int = 80, height: int = 24) -> None: """Open the process.""" diff --git a/src/textual_webterm/cli.py b/src/textual_webterm/cli.py index 0ccb95b..b329db4 100644 --- a/src/textual_webterm/cli.py +++ b/src/textual_webterm/cli.py @@ -10,17 +10,15 @@ from pathlib import Path import click from importlib_metadata import version -from rich.logging import RichHandler from . import constants from .local_server import LocalServer -FORMAT = "%(message)s" +FORMAT = "%(asctime)s %(levelname)s %(message)s" logging.basicConfig( level="DEBUG" if constants.DEBUG else "INFO", format=FORMAT, - datefmt="[%X]", - handlers=[RichHandler(show_path=False)], + datefmt="%X", ) log = logging.getLogger("textual-webterm") diff --git a/src/textual_webterm/local_server.py b/src/textual_webterm/local_server.py index 7355aa5..cd39f95 100644 --- a/src/textual_webterm/local_server.py +++ b/src/textual_webterm/local_server.py @@ -5,19 +5,14 @@ from __future__ import annotations import asyncio import contextlib import hashlib -import io import json import logging -import re import signal from pathlib import Path from typing import TYPE_CHECKING import aiohttp from aiohttp import WSMsgType, web -from rich.console import Console -from rich.style import Style -from rich.text import Text from . import constants from .docker_stats import DockerStatsCollector, render_sparkline_svg @@ -26,6 +21,7 @@ from .identity import generate from .poller import Poller from .session import SessionConnector from .session_manager import SessionManager +from .svg_exporter import render_terminal_svg from .types import Meta, RouteKey, SessionID if TYPE_CHECKING: @@ -38,50 +34,6 @@ DEFAULT_TERMINAL_SIZE = (132, 45) SCREENSHOT_CACHE_SECONDS = 1.0 SCREENSHOT_MAX_CACHE_SECONDS = 60.0 -SVG_MONO_FONT_STACK = ( - 'ui-monospace, "SFMono-Regular", "FiraCode Nerd Font", "FiraMono Nerd Font", ' - '"Fira Code", "Roboto Mono", Menlo, Monaco, Consolas, "Liberation Mono", ' - '"DejaVu Sans Mono", "Courier New", monospace' -) - -# Map pyte color names to Rich-compatible names -# pyte uses different naming conventions than Rich for some colors -PYTE_TO_RICH_COLOR = { - # Bright colors (pyte concatenates, Rich uses underscore) - "brightblack": "bright_black", - "brightred": "bright_red", - "brightgreen": "bright_green", - "brightbrown": "bright_yellow", # bright brown = bright yellow - "brightyellow": "bright_yellow", - "brightblue": "bright_blue", - "brightmagenta": "bright_magenta", - "bfightmagenta": "bright_magenta", # typo in pyte's BG_AIXTERM - "brightcyan": "bright_cyan", - "brightwhite": "bright_white", - # Standard colors - "brown": "yellow", # pyte uses 'brown' for ANSI color 33 (yellow) -} - - -def _pyte_color_to_rich(color: str) -> str: - """Convert pyte color to Rich-compatible color string. - - Handles: - - Named color mappings (e.g., 'brown' -> 'yellow') - - Bright color name format (e.g., 'brightred' -> 'bright_red') - - Hex colors from 256-color/truecolor (e.g., 'ff8700' -> '#ff8700') - """ - if color == "default": - return color - # Check mapping first - if color in PYTE_TO_RICH_COLOR: - return PYTE_TO_RICH_COLOR[color] - # If it looks like a hex color without #, add it - if len(color) == 6 and all(c in "0123456789abcdefABCDEF" for c in color): - return f"#{color}" - return color - - WEBTERM_STATIC_PATH = Path(__file__).parent / "static" @@ -129,22 +81,6 @@ class LocalClientConnector(SessionConnector): await self.server.handle_session_close(self.session_id, self.route_key) -def _rewrite_svg_fonts(svg: str) -> str: - """Make Rich SVG output self-contained and aligned with our monospace styling.""" - - # Rich export_svg embeds @font-face rules that reference external CDNs. - svg = re.sub(r"@font-face\s*\{.*?\}\s*", "", svg, flags=re.DOTALL) - - # Force our local monospace stack even if Rich sets font-family to Fira Code. - override = f"\ntext {{ font-family: {SVG_MONO_FONT_STACK} !important; }}\n" - if "" in svg: - svg = svg.replace("", override + "", 1) - else: - svg = svg.replace(" ", 1) - - return svg - - class LocalServer: def mark_route_activity(self, route_key: str) -> None: now = asyncio.get_event_loop().time() @@ -575,68 +511,15 @@ class LocalServer: return cached_response def _render_svg() -> str: - # Use the session's screen buffer directly - this has the correct - # dimensions matching the actual terminal, preventing wrapping issues - # Add extra height for Rich's clip path generation quirk - console = Console( - record=True, width=screen_width, height=screen_height + 2, file=io.StringIO() - ) - - for row_data in screen_buffer: - line = Text() - for char in row_data: - char_data = char["data"] - - # Skip empty placeholder cells (after wide characters) - if not char_data: - continue - - # Build Rich style from pyte character attributes - # Convert pyte color names to Rich-compatible format - style_kwargs = {} - if char["fg"] != "default": - style_kwargs["color"] = _pyte_color_to_rich(char["fg"]) - if char["bg"] != "default": - style_kwargs["bgcolor"] = _pyte_color_to_rich(char["bg"]) - if char["bold"]: - style_kwargs["bold"] = True - if char["italics"]: - style_kwargs["italic"] = True - if char["underscore"]: - style_kwargs["underline"] = True - if char["reverse"]: - style_kwargs["reverse"] = True - - if style_kwargs: - line.append(char_data, Style(**style_kwargs)) - else: - line.append(char_data) - - console.print(line, highlight=False) - - return console.export_svg( + # Use custom SVG exporter - simpler and more reliable than Rich + return render_terminal_svg( + screen_buffer, + width=screen_width, + height=screen_height, title="textual-webterm", - code_format=( - '' - '' - '' - '' - '' - '' - '{lines}' - '' - '' - '' - '{backgrounds}' - '{matrix}' - '' - '' - ), ) svg = await asyncio.to_thread(_render_svg) - svg = _rewrite_svg_fonts(svg) etag = hashlib.sha1(svg.encode("utf-8"), usedforsecurity=False).hexdigest() self._screenshot_cache[route_key] = (asyncio.get_event_loop().time(), svg) self._screenshot_cache_etag[route_key] = etag diff --git a/src/textual_webterm/svg_exporter.py b/src/textual_webterm/svg_exporter.py new file mode 100644 index 0000000..4ad85e9 --- /dev/null +++ b/src/textual_webterm/svg_exporter.py @@ -0,0 +1,289 @@ +"""Custom SVG exporter for terminal screenshots. + +Generates SVG directly from pyte screen buffer, avoiding Rich's export_svg() quirks. +""" + +from __future__ import annotations + +import html +from typing import TypedDict + +# ANSI color names to hex values (standard 16-color palette) +ANSI_COLORS: dict[str, str] = { + # Normal colors + "black": "#000000", + "red": "#cc0000", + "green": "#4e9a06", + "yellow": "#c4a000", + "blue": "#3465a4", + "magenta": "#75507b", + "cyan": "#06989a", + "white": "#d3d7cf", + # Bright colors + "brightblack": "#555753", + "brightred": "#ef2929", + "brightgreen": "#8ae234", + "brightyellow": "#fce94f", + "brightblue": "#729fcf", + "brightmagenta": "#ad7fa8", + "brightcyan": "#34e2e2", + "brightwhite": "#eeeeec", + # Alternative names + "gray": "#555753", + "grey": "#555753", + "lightgray": "#d3d7cf", + "lightgrey": "#d3d7cf", + "brown": "#c4a000", +} + +# Default colors +DEFAULT_FG = "#d3d7cf" +DEFAULT_BG = "#000000" + +# Font settings +FONT_FAMILY = ( + 'ui-monospace, "SFMono-Regular", "FiraCode Nerd Font", "FiraMono Nerd Font", ' + '"Fira Code", "Roboto Mono", Menlo, Monaco, Consolas, "Liberation Mono", ' + '"DejaVu Sans Mono", "Courier New", monospace' +) +FONT_SIZE = 14 +LINE_HEIGHT = 1.2 +CHAR_WIDTH = 8.4 # Approximate width of monospace character at 14px + + +class CharData(TypedDict): + """Character data from pyte screen buffer.""" + + data: str + fg: str + bg: str + bold: bool + italics: bool + underscore: bool + reverse: bool + + +def _color_to_hex(color: str, is_foreground: bool = True) -> str: + """Convert pyte color to hex value.""" + if color == "default": + return DEFAULT_FG if is_foreground else DEFAULT_BG + + # Already a hex color + if color.startswith("#"): + return color + + # Named color lookup (case-insensitive) + lower = color.lower() + if lower in ANSI_COLORS: + return ANSI_COLORS[lower] + + # RGB format "rgb(r,g,b)" - rarely used but handle it + if lower.startswith("rgb("): + # Not common in terminal output, return default + return DEFAULT_FG if is_foreground else DEFAULT_BG + + return DEFAULT_FG if is_foreground else DEFAULT_BG + + +def _escape_xml(text: str) -> str: + """Escape special XML characters.""" + return html.escape(text, quote=True) + + +def render_terminal_svg( + screen_buffer: list[list[CharData]], + width: int, + height: int, + *, + title: str = "Terminal", + font_size: int = FONT_SIZE, + char_width: float = CHAR_WIDTH, + line_height: float = LINE_HEIGHT, + background: str = DEFAULT_BG, + foreground: str = DEFAULT_FG, +) -> str: + """Render terminal screen buffer to SVG. + + Args: + screen_buffer: 2D list of CharData dicts from pyte + width: Terminal width in columns + height: Terminal height in rows + title: SVG title (for accessibility) + font_size: Font size in pixels + char_width: Width of a single character + line_height: Line height multiplier + background: Background color + foreground: Default foreground color + + Returns: + SVG string + """ + # Calculate dimensions + actual_line_height = font_size * line_height + svg_width = width * char_width + 20 # Add padding + svg_height = height * actual_line_height + 20 + + # Start building SVG + parts: list[str] = [] + parts.append( + f'' + ) + parts.append(f"{_escape_xml(title)}") + + # Style definitions + parts.append("") + + # Background rectangle + parts.append( + f'' + ) + + # Text content group + parts.append('') + + # Render each row + for row_idx, row_data in enumerate(screen_buffer): + y = 10 + (row_idx + 1) * actual_line_height - (actual_line_height - font_size) / 2 + + # Build spans for this row, grouping consecutive chars with same style + spans = _build_row_spans(row_data, foreground, background) + + if not spans: + continue + + # Start text element for this row + parts.append(f'') + + x = 10.0 # Starting x position with padding + for span in spans: + text = span["text"] + if not text or (text.isspace() and not span["has_bg"]): + # Skip empty spans without background, but advance position + x += len(text) * char_width + continue + + # Build tspan attributes + attrs = [f'x="{x:.1f}"'] + + # Foreground color + if span["fg"] != foreground: + attrs.append(f'fill="{span["fg"]}"') + + # Style classes + classes = [] + if span["bold"]: + classes.append("bold") + if span["italic"]: + classes.append("italic") + if span["underline"]: + classes.append("underline") + if classes: + attrs.append(f'class="{" ".join(classes)}"') + + # Background needs a separate rect + if span["has_bg"] and span["bg"] != background: + bg_width = len(text) * char_width + bg_y = y - font_size + 2 + parts.insert( + -1, # Insert before current text element + f'', + ) + + parts.append(f'{_escape_xml(text)}') + x += len(text) * char_width + + parts.append("") + + parts.append("") + parts.append("") + + return "".join(parts) + + +class _Span(TypedDict): + """A span of text with consistent styling.""" + + text: str + fg: str + bg: str + bold: bool + italic: bool + underline: bool + has_bg: bool + + +def _build_row_spans( + row_data: list[CharData], + default_fg: str, + default_bg: str, +) -> list[_Span]: + """Build styled spans from row data, merging consecutive chars with same style.""" + if not row_data: + return [] + + spans: list[_Span] = [] + current_span: _Span | None = None + + for char in row_data: + char_data = char["data"] + + # Skip empty placeholder cells (after wide characters) + if not char_data: + continue + + # Get colors, handling reverse video + fg = _color_to_hex(char["fg"], is_foreground=True) + bg = _color_to_hex(char["bg"], is_foreground=False) + + if char["reverse"]: + fg, bg = bg, fg + + has_bg = bg != default_bg + + # Check if we can extend current span + if ( + current_span is not None + and current_span["fg"] == fg + and current_span["bg"] == bg + and current_span["bold"] == char["bold"] + and current_span["italic"] == char["italics"] + and current_span["underline"] == char["underscore"] + and current_span["has_bg"] == has_bg + ): + current_span["text"] += char_data + else: + # Start new span + if current_span is not None: + spans.append(current_span) + current_span = { + "text": char_data, + "fg": fg, + "bg": bg, + "bold": char["bold"], + "italic": char["italics"], + "underline": char["underscore"], + "has_bg": has_bg, + } + + if current_span is not None: + spans.append(current_span) + + return spans diff --git a/src/textual_webterm/terminal_session.py b/src/textual_webterm/terminal_session.py index c1589ed..0f20f69 100644 --- a/src/textual_webterm/terminal_session.py +++ b/src/textual_webterm/terminal_session.py @@ -14,7 +14,6 @@ from collections import deque from typing import TYPE_CHECKING import pyte -import rich.repr from importlib_metadata import version from .session import Session, SessionConnector @@ -33,7 +32,6 @@ DEFAULT_SCREEN_WIDTH = 132 DEFAULT_SCREEN_HEIGHT = 45 -@rich.repr.auto class TerminalSession(Session): """A session that manages a terminal.""" @@ -61,9 +59,8 @@ class TerminalSession(Session): self._last_height = DEFAULT_SCREEN_HEIGHT super().__init__() - def __rich_repr__(self) -> rich.repr.Result: - yield "session_id", self.session_id - yield "command", self.command + def __repr__(self) -> str: + return f"TerminalSession(session_id={self.session_id!r}, command={self.command!r})" async def open(self, width: int = 80, height: int = 24) -> None: log.info("Opening terminal session %s with command: %s", self.session_id, self.command) diff --git a/tests/test_local_server_unit.py b/tests/test_local_server_unit.py index 79cc228..a4a21ff 100644 --- a/tests/test_local_server_unit.py +++ b/tests/test_local_server_unit.py @@ -9,7 +9,6 @@ from textual_webterm.config import App, Config from textual_webterm.local_server import ( LocalClientConnector, LocalServer, - _rewrite_svg_fonts, ) @@ -238,8 +237,7 @@ class TestLocalServerHelpers: response = await server._handle_screenshot(request) assert response.content_type == "image/svg+xml" assert "Known" in resp.text - def test_rewrite_svg_fonts_removes_font_face_and_forces_stack(self): - svg = ( - '' - '' - 'hi' - '' - ) - out = _rewrite_svg_fonts(svg) - assert "@font-face" not in out - assert "cdnjs.cloudflare.com" not in out - assert "ui-monospace" in out - - def test_rewrite_svg_fonts_injects_style_if_missing(self): - svg = 'hi' - out = _rewrite_svg_fonts(svg) - assert "ui-monospace" in out - @pytest.mark.asyncio async def test_cached_screenshot_etag_returns_304(self, server_with_no_apps): request = MagicMock() diff --git a/tests/test_svg_exporter.py b/tests/test_svg_exporter.py new file mode 100644 index 0000000..101c623 --- /dev/null +++ b/tests/test_svg_exporter.py @@ -0,0 +1,625 @@ +"""Extensive tests for the custom SVG exporter.""" + +from __future__ import annotations + +from textual_webterm.svg_exporter import ( + ANSI_COLORS, + DEFAULT_BG, + DEFAULT_FG, + CharData, + _build_row_spans, + _color_to_hex, + _escape_xml, + render_terminal_svg, +) + + +class TestColorToHex: + """Tests for _color_to_hex function.""" + + def test_default_foreground(self) -> None: + """Default color returns DEFAULT_FG for foreground.""" + assert _color_to_hex("default", is_foreground=True) == DEFAULT_FG + + def test_default_background(self) -> None: + """Default color returns DEFAULT_BG for background.""" + assert _color_to_hex("default", is_foreground=False) == DEFAULT_BG + + def test_hex_color_passthrough(self) -> None: + """Hex colors pass through unchanged.""" + assert _color_to_hex("#ff0000") == "#ff0000" + assert _color_to_hex("#123456") == "#123456" + assert _color_to_hex("#AABBCC") == "#AABBCC" + + def test_named_colors(self) -> None: + """Named ANSI colors map correctly.""" + assert _color_to_hex("red") == ANSI_COLORS["red"] + assert _color_to_hex("green") == ANSI_COLORS["green"] + assert _color_to_hex("blue") == ANSI_COLORS["blue"] + assert _color_to_hex("white") == ANSI_COLORS["white"] + assert _color_to_hex("black") == ANSI_COLORS["black"] + + def test_bright_colors(self) -> None: + """Bright color variants map correctly.""" + assert _color_to_hex("brightred") == ANSI_COLORS["brightred"] + assert _color_to_hex("brightgreen") == ANSI_COLORS["brightgreen"] + assert _color_to_hex("brightblue") == ANSI_COLORS["brightblue"] + + def test_case_insensitive(self) -> None: + """Color names are case-insensitive.""" + assert _color_to_hex("RED") == ANSI_COLORS["red"] + assert _color_to_hex("Green") == ANSI_COLORS["green"] + assert _color_to_hex("BRIGHTBLUE") == ANSI_COLORS["brightblue"] + + def test_unknown_color_returns_default(self) -> None: + """Unknown color names return default.""" + assert _color_to_hex("unknowncolor", is_foreground=True) == DEFAULT_FG + assert _color_to_hex("unknowncolor", is_foreground=False) == DEFAULT_BG + + def test_gray_aliases(self) -> None: + """Gray/grey aliases work.""" + assert _color_to_hex("gray") == ANSI_COLORS["gray"] + assert _color_to_hex("grey") == ANSI_COLORS["grey"] + assert _color_to_hex("lightgray") == ANSI_COLORS["lightgray"] + assert _color_to_hex("lightgrey") == ANSI_COLORS["lightgrey"] + + +class TestEscapeXml: + """Tests for XML escaping.""" + + def test_no_special_chars(self) -> None: + """Plain text passes through unchanged.""" + assert _escape_xml("hello world") == "hello world" + + def test_less_than(self) -> None: + """Less than is escaped.""" + assert _escape_xml("<") == "<" + assert _escape_xml("a < b") == "a < b" + + def test_greater_than(self) -> None: + """Greater than is escaped.""" + assert _escape_xml(">") == ">" + assert _escape_xml("a > b") == "a > b" + + def test_ampersand(self) -> None: + """Ampersand is escaped.""" + assert _escape_xml("&") == "&" + assert _escape_xml("a & b") == "a & b" + + def test_quotes(self) -> None: + """Quotes are escaped.""" + assert _escape_xml('"') == """ + assert _escape_xml("'") == "'" + + def test_mixed_special_chars(self) -> None: + """Multiple special chars are all escaped.""" + assert _escape_xml('') == ( + "<script>"alert"</script>" + ) + + def test_unicode_preserved(self) -> None: + """Unicode characters are preserved.""" + assert _escape_xml("你好世界") == "你好世界" + assert _escape_xml("🎉🚀") == "🎉🚀" + + +class TestBuildRowSpans: + """Tests for _build_row_spans function.""" + + def _char( + self, + data: str, + fg: str = "default", + bg: str = "default", + bold: bool = False, + italics: bool = False, + underscore: bool = False, + reverse: bool = False, + ) -> CharData: + """Helper to create CharData.""" + return { + "data": data, + "fg": fg, + "bg": bg, + "bold": bold, + "italics": italics, + "underscore": underscore, + "reverse": reverse, + } + + def test_empty_row(self) -> None: + """Empty row returns no spans.""" + assert _build_row_spans([], DEFAULT_FG, DEFAULT_BG) == [] + + def test_single_char(self) -> None: + """Single character produces one span.""" + row = [self._char("A")] + spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG) + assert len(spans) == 1 + assert spans[0]["text"] == "A" + + def test_consecutive_same_style_merged(self) -> None: + """Consecutive chars with same style are merged.""" + row = [self._char("H"), self._char("e"), self._char("l"), self._char("l"), self._char("o")] + spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG) + assert len(spans) == 1 + assert spans[0]["text"] == "Hello" + + def test_different_colors_split(self) -> None: + """Different colors create separate spans.""" + row = [ + self._char("R", fg="red"), + self._char("G", fg="green"), + self._char("B", fg="blue"), + ] + spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG) + assert len(spans) == 3 + assert spans[0]["text"] == "R" + assert spans[0]["fg"] == ANSI_COLORS["red"] + assert spans[1]["text"] == "G" + assert spans[1]["fg"] == ANSI_COLORS["green"] + assert spans[2]["text"] == "B" + assert spans[2]["fg"] == ANSI_COLORS["blue"] + + def test_same_color_merged(self) -> None: + """Same color chars are merged.""" + row = [ + self._char("A", fg="red"), + self._char("B", fg="red"), + self._char("C", fg="red"), + ] + spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG) + assert len(spans) == 1 + assert spans[0]["text"] == "ABC" + assert spans[0]["fg"] == ANSI_COLORS["red"] + + def test_bold_creates_new_span(self) -> None: + """Bold attribute creates new span.""" + row = [ + self._char("N"), + self._char("B", bold=True), + self._char("N"), + ] + spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG) + assert len(spans) == 3 + assert spans[0]["bold"] is False + assert spans[1]["bold"] is True + assert spans[1]["text"] == "B" + assert spans[2]["bold"] is False + + def test_italic_creates_new_span(self) -> None: + """Italic attribute creates new span.""" + row = [ + self._char("N"), + self._char("I", italics=True), + self._char("N"), + ] + spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG) + assert len(spans) == 3 + assert spans[1]["italic"] is True + + def test_underline_creates_new_span(self) -> None: + """Underline attribute creates new span.""" + row = [ + self._char("N"), + self._char("U", underscore=True), + self._char("N"), + ] + spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG) + assert len(spans) == 3 + assert spans[1]["underline"] is True + + def test_reverse_swaps_colors(self) -> None: + """Reverse video swaps foreground and background.""" + row = [self._char("R", fg="red", bg="blue", reverse=True)] + spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG) + assert len(spans) == 1 + # Colors should be swapped + assert spans[0]["fg"] == ANSI_COLORS["blue"] + assert spans[0]["bg"] == ANSI_COLORS["red"] + + def test_background_color_tracked(self) -> None: + """Background color is tracked in has_bg flag.""" + row = [ + self._char("N"), + self._char("B", bg="red"), + self._char("N"), + ] + spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG) + assert spans[0]["has_bg"] is False + assert spans[1]["has_bg"] is True + assert spans[2]["has_bg"] is False + + def test_wide_char_placeholder_skipped(self) -> None: + """Empty placeholder cells (after wide chars) are skipped.""" + row = [ + self._char("A"), + self._char("中"), # Wide char + self._char(""), # Placeholder - should be skipped + self._char("B"), + ] + spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG) + # Should merge into single span since all default style + assert len(spans) == 1 + assert spans[0]["text"] == "A中B" + + def test_multiple_wide_chars(self) -> None: + """Multiple wide characters handled correctly.""" + row = [ + self._char("日"), + self._char(""), + self._char("本"), + self._char(""), + self._char("語"), + self._char(""), + ] + spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG) + assert len(spans) == 1 + assert spans[0]["text"] == "日本語" + + def test_emoji_with_placeholder(self) -> None: + """Emoji characters with placeholders handled.""" + row = [ + self._char("🎉"), + self._char(""), + self._char(" "), + self._char("🚀"), + self._char(""), + ] + spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG) + assert len(spans) == 1 + assert spans[0]["text"] == "🎉 🚀" + + def test_mixed_styles_complex(self) -> None: + """Complex mix of styles produces correct spans.""" + row = [ + self._char("H", fg="red", bold=True), + self._char("e", fg="red", bold=True), + self._char("l", fg="green"), + self._char("l", fg="green"), + self._char("o", fg="blue", italics=True), + ] + spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG) + assert len(spans) == 3 + assert spans[0]["text"] == "He" + assert spans[0]["bold"] is True + assert spans[1]["text"] == "ll" + assert spans[1]["bold"] is False + assert spans[2]["text"] == "o" + assert spans[2]["italic"] is True + + +class TestRenderTerminalSvg: + """Tests for render_terminal_svg function.""" + + def _char( + self, + data: str, + fg: str = "default", + bg: str = "default", + bold: bool = False, + italics: bool = False, + underscore: bool = False, + reverse: bool = False, + ) -> CharData: + """Helper to create CharData.""" + return { + "data": data, + "fg": fg, + "bg": bg, + "bold": bold, + "italics": italics, + "underscore": underscore, + "reverse": reverse, + } + + def _make_buffer(self, rows: list[str]) -> list[list[CharData]]: + """Create simple buffer from strings.""" + return [[self._char(c) for c in row] for row in rows] + + def test_empty_buffer(self) -> None: + """Empty buffer produces valid SVG.""" + svg = render_terminal_svg([], width=80, height=24) + assert svg.startswith("") + assert 'xmlns="http://www.w3.org/2000/svg"' in svg + + def test_basic_text_output(self) -> None: + """Basic text is included in SVG.""" + buffer = self._make_buffer(["Hello, World!"]) + svg = render_terminal_svg(buffer, width=80, height=24) + assert "Hello, World!" in svg + + def test_multiline_output(self) -> None: + """Multiple lines render correctly.""" + buffer = self._make_buffer(["Line 1", "Line 2", "Line 3"]) + svg = render_terminal_svg(buffer, width=80, height=24) + assert "Line 1" in svg + assert "Line 2" in svg + assert "Line 3" in svg + # Should have 3 text elements + assert svg.count("&test"]) + svg = render_terminal_svg(buffer, width=80, height=24) + assert "<script>" in svg + assert "&test" in svg + assert "