3701a3df31
Background rects now extend 0.5px in both width and height to create a slight overlap, eliminating visible sub-pixel gaps when viewing SVG screenshots at high zoom levels.
251 lines
7.7 KiB
Python
251 lines
7.7 KiB
Python
"""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 # Width of monospace character at 14px (typically ~0.57 ratio)
|
|
|
|
|
|
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 with #
|
|
if color.startswith("#"):
|
|
return color
|
|
|
|
# Hex color without # prefix (pyte's 256-color/truecolor format)
|
|
# Check if it looks like a hex color (6 hex digits)
|
|
if len(color) == 6 and all(c in "0123456789abcdefABCDEF" for c in color):
|
|
return f"#{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
|
|
# Note: We use alphabetic baseline (default) and offset text y by font_size
|
|
# to align text top with rect top. This is more compatible across browsers
|
|
# than dominant-baseline: text-before-edge which has Safari issues.
|
|
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"text-rendering: optimizeLegibility; "
|
|
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 - use explicit x position for EACH character
|
|
# to ensure pixel-perfect alignment regardless of font metrics
|
|
for row_idx, row_data in enumerate(screen_buffer):
|
|
# rect_y is the top of the cell
|
|
rect_y = 10 + row_idx * actual_line_height
|
|
# text_y is the baseline position (alphabetic baseline = bottom of lowercase letters)
|
|
# For most fonts, baseline is roughly at font_size from top of em box
|
|
text_y = rect_y + font_size
|
|
|
|
if not row_data:
|
|
continue
|
|
|
|
# Collect background rects and text spans
|
|
row_bg_rects: list[str] = []
|
|
row_tspans: list[str] = []
|
|
|
|
# Track current style for potential span merging (only merge if same style AND adjacent)
|
|
col = 0
|
|
while col < len(row_data):
|
|
char = row_data[col]
|
|
char_data = char["data"]
|
|
|
|
# Skip empty placeholder cells (after wide characters)
|
|
if not char_data:
|
|
col += 1
|
|
continue
|
|
|
|
x = 10.0 + col * char_width
|
|
|
|
# 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
|
|
|
|
# Count columns for this character (wide chars take 2)
|
|
char_cols = 1
|
|
if col + 1 < len(row_data) and not row_data[col + 1]["data"]:
|
|
char_cols = 2 # Wide character
|
|
|
|
# Background rect if not default
|
|
# Add 0.5px overlap in both directions to eliminate sub-pixel gaps at high zoom
|
|
if bg != background:
|
|
bg_width = char_cols * char_width + 0.5
|
|
row_bg_rects.append(
|
|
f'<rect x="{x:.1f}" y="{rect_y:.1f}" '
|
|
f'width="{bg_width:.1f}" height="{actual_line_height + 0.5:.1f}" '
|
|
f'fill="{bg}"/>'
|
|
)
|
|
|
|
# Build tspan with explicit x position
|
|
attrs = [f'x="{x:.1f}"']
|
|
|
|
if fg != foreground:
|
|
attrs.append(f'fill="{fg}"')
|
|
|
|
classes = []
|
|
if char["bold"]:
|
|
classes.append("bold")
|
|
if char["italics"]:
|
|
classes.append("italic")
|
|
if char["underscore"]:
|
|
classes.append("underline")
|
|
if classes:
|
|
attrs.append(f'class="{" ".join(classes)}"')
|
|
|
|
row_tspans.append(f'<tspan {" ".join(attrs)}>{_escape_xml(char_data)}</tspan>')
|
|
|
|
col += char_cols
|
|
|
|
# Add background rects first, then text
|
|
if row_bg_rects or row_tspans:
|
|
parts.extend(row_bg_rects)
|
|
if row_tspans:
|
|
parts.append(f'<text y="{text_y:.1f}">')
|
|
parts.extend(row_tspans)
|
|
parts.append("</text>")
|
|
|
|
parts.append("</g>")
|
|
parts.append("</svg>")
|
|
|
|
return "".join(parts)
|