Add custom SVG exporter, remove Rich from screenshot rendering

- Created svg_exporter.py with direct pyte-to-SVG rendering
- Eliminates Rich's export_svg() quirks (clip path count mismatch)
- Added 63 comprehensive tests for SVG exporter
- Removed Rich imports from local_server.py, terminal_session.py,
  app_session.py, and cli.py
- Replaced RichHandler with standard logging.basicConfig
- Replaced @rich.repr.auto with standard __repr__ methods
- Rich is no longer directly imported (still transitive via textual-serve)

Bump version to 0.3.0
This commit is contained in:
GitHub Copilot
2026-01-24 17:11:20 +00:00
parent d4acdbb4f1
commit d5a060d6aa
9 changed files with 934 additions and 165 deletions
+3 -7
View File
@@ -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"<AppSession {self.command!r} id={self.session_id!r} returncode={returncode}>"
async def open(self, width: int = 80, height: int = 24) -> None:
"""Open the process."""
+2 -4
View File
@@ -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")
+6 -123
View File
@@ -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 "</style>" in svg:
svg = svg.replace("</style>", override + "</style>", 1)
else:
svg = svg.replace("<svg ", f"<svg><style>{override}</style> ", 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=(
'<svg class="rich-terminal" viewBox="0 0 {terminal_width} {terminal_height}" '
'xmlns="http://www.w3.org/2000/svg">'
'<style>{styles}</style>'
'<defs>'
'<clipPath id="{unique_id}-clip-terminal">'
'<rect x="0" y="0" width="{terminal_width}" height="{terminal_height}" />'
'</clipPath>'
'{lines}'
'</defs>'
'<g clip-path="url(#{unique_id}-clip-terminal)">'
'<rect x="0" y="0" width="{terminal_width}" height="{terminal_height}" fill="#000" />'
'{backgrounds}'
'<g class="{unique_id}-matrix">{matrix}</g>'
'</g>'
'</svg>'
),
)
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
+289
View File
@@ -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'<svg xmlns="http://www.w3.org/2000/svg" '
f'viewBox="0 0 {svg_width:.1f} {svg_height:.1f}" '
f'class="terminal-svg">'
)
parts.append(f"<title>{_escape_xml(title)}</title>")
# Style definitions
parts.append("<defs><style>")
parts.append(
f".terminal-bg {{ fill: {background}; }}"
f".terminal-text {{ "
f"font-family: {FONT_FAMILY}; "
f"font-size: {font_size}px; "
f"fill: {foreground}; "
f"white-space: pre; "
f"}}"
f".bold {{ font-weight: bold; }}"
f".italic {{ font-style: italic; }}"
f".underline {{ text-decoration: underline; }}"
)
parts.append("</style></defs>")
# Background rectangle
parts.append(
f'<rect class="terminal-bg" x="0" y="0" '
f'width="{svg_width:.1f}" height="{svg_height:.1f}"/>'
)
# Text content group
parts.append('<g class="terminal-text">')
# 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'<text y="{y:.1f}">')
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'<rect x="{x:.1f}" y="{bg_y:.1f}" '
f'width="{bg_width:.1f}" height="{actual_line_height:.1f}" '
f'fill="{span["bg"]}"/>',
)
parts.append(f'<tspan {" ".join(attrs)}>{_escape_xml(text)}</tspan>')
x += len(text) * char_width
parts.append("</text>")
parts.append("</g>")
parts.append("</svg>")
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
+2 -5
View File
@@ -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)