Add SVG CSS: dominant-baseline and text-rendering for proper alignment

- Added dominant-baseline: text-before-edge for proper vertical text positioning
- Added text-rendering: optimizeLegibility for crisper text
- Simplified y-position calculation (top-aligned with baseline)
- Added tests for box drawing character detection helpers
- Added test for CSS properties
- Removed unreachable dead code paths (empty span checks)
- svg_exporter.py now has 100% test coverage

Version bump to 0.3.7
This commit is contained in:
GitHub Copilot
2026-01-24 19:14:28 +00:00
parent c032911c79
commit 076bf4cd5d
3 changed files with 76 additions and 16 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "textual-webterm" name = "textual-webterm"
version = "0.3.6" version = "0.3.7"
description = "Serve terminal sessions over the web" description = "Serve terminal sessions over the web"
authors = ["Will McGugan <will@textualize.io>"] authors = ["Will McGugan <will@textualize.io>"]
license = "MIT" license = "MIT"
+5 -14
View File
@@ -146,6 +146,8 @@ def render_terminal_svg(
f"font-size: {font_size}px; " f"font-size: {font_size}px; "
f"fill: {foreground}; " f"fill: {foreground}; "
f"white-space: pre; " f"white-space: pre; "
f"dominant-baseline: text-before-edge; "
f"text-rendering: optimizeLegibility; "
f"}}" f"}}"
f".bold {{ font-weight: bold; }}" f".bold {{ font-weight: bold; }}"
f".italic {{ font-style: italic; }}" f".italic {{ font-style: italic; }}"
@@ -164,7 +166,8 @@ def render_terminal_svg(
# Render each row # Render each row
for row_idx, row_data in enumerate(screen_buffer): for row_idx, row_data in enumerate(screen_buffer):
y = 10 + (row_idx + 1) * actual_line_height - (actual_line_height - font_size) / 2 # With dominant-baseline: text-before-edge, text top aligns to y
y = 10 + row_idx * actual_line_height
# Build spans for this row, grouping consecutive chars with same style # Build spans for this row, grouping consecutive chars with same style
spans = _build_row_spans(row_data, foreground, background) spans = _build_row_spans(row_data, foreground, background)
@@ -178,24 +181,16 @@ def render_terminal_svg(
x = 10.0 # Starting x position with padding x = 10.0 # Starting x position with padding
for span in spans: for span in spans:
text = span["text"]
columns = span["columns"] columns = span["columns"]
# Background needs a separate rect (collected before text) # Background needs a separate rect (collected before text)
if span["has_bg"] and span["bg"] != background: if span["has_bg"] and span["bg"] != background:
bg_width = columns * char_width bg_width = columns * char_width
bg_y = y - font_size + 2
row_bg_rects.append( row_bg_rects.append(
f'<rect x="{x:.1f}" y="{bg_y:.1f}" ' f'<rect x="{x:.1f}" y="{y:.1f}" '
f'width="{bg_width:.1f}" height="{actual_line_height:.1f}" ' f'width="{bg_width:.1f}" height="{actual_line_height:.1f}" '
f'fill="{span["bg"]}"/>' f'fill="{span["bg"]}"/>'
) )
if not text:
# Skip truly empty spans (wide char placeholders already counted)
x += columns * char_width
continue
x += columns * char_width x += columns * char_width
# Add background rects first # Add background rects first
@@ -208,10 +203,6 @@ def render_terminal_svg(
for span in spans: for span in spans:
text = span["text"] text = span["text"]
columns = span["columns"] columns = span["columns"]
if not text:
# Skip truly empty spans
x += columns * char_width
continue
# Build tspan attributes # Build tspan attributes
attrs = [f'x="{x:.1f}"'] attrs = [f'x="{x:.1f}"']
+70 -1
View File
@@ -10,6 +10,8 @@ from textual_webterm.svg_exporter import (
_build_row_spans, _build_row_spans,
_color_to_hex, _color_to_hex,
_escape_xml, _escape_xml,
_is_box_drawing_vertical_or_corner,
_should_break_span,
render_terminal_svg, render_terminal_svg,
) )
@@ -330,7 +332,61 @@ class TestBuildRowSpans:
assert spans[1]["columns"] == 1 assert spans[1]["columns"] == 1
class TestRenderTerminalSvg: class TestBoxDrawingHelpers:
"""Tests for box drawing character detection helpers."""
def test_is_box_drawing_empty_char(self) -> None:
"""Empty string returns False."""
assert _is_box_drawing_vertical_or_corner("") is False
def test_is_box_drawing_regular_char(self) -> None:
"""Regular ASCII characters return False."""
assert _is_box_drawing_vertical_or_corner("A") is False
assert _is_box_drawing_vertical_or_corner(" ") is False
assert _is_box_drawing_vertical_or_corner("1") is False
def test_is_box_drawing_horizontal_lines(self) -> None:
"""Horizontal box drawing lines return False (can merge)."""
assert _is_box_drawing_vertical_or_corner("") is False # U+2500
assert _is_box_drawing_vertical_or_corner("") is False # U+2501
assert _is_box_drawing_vertical_or_corner("") is False # U+2550
def test_is_box_drawing_vertical_lines(self) -> None:
"""Vertical box drawing lines return True (need precise positioning)."""
assert _is_box_drawing_vertical_or_corner("") is True # U+2502
assert _is_box_drawing_vertical_or_corner("") is True # U+2503
assert _is_box_drawing_vertical_or_corner("") is True # U+2551
def test_is_box_drawing_corners(self) -> None:
"""Corner box drawing characters return True."""
assert _is_box_drawing_vertical_or_corner("") is True
assert _is_box_drawing_vertical_or_corner("") is True
assert _is_box_drawing_vertical_or_corner("") is True
assert _is_box_drawing_vertical_or_corner("") is True
assert _is_box_drawing_vertical_or_corner("") is True
assert _is_box_drawing_vertical_or_corner("") is True
assert _is_box_drawing_vertical_or_corner("") is True
assert _is_box_drawing_vertical_or_corner("") is True
def test_should_break_span_empty_current(self) -> None:
"""Empty current text never breaks."""
assert _should_break_span("", "A") is False
assert _should_break_span("", "") is False
def test_should_break_span_normal_chars(self) -> None:
"""Normal characters don't break spans."""
assert _should_break_span("A", "B") is False
assert _should_break_span("Hello", "!") is False
def test_should_break_span_vertical_line(self) -> None:
"""Vertical lines cause breaks."""
assert _should_break_span("A", "") is True
assert _should_break_span("", "A") is True
def test_should_break_span_horizontal_lines_merge(self) -> None:
"""Horizontal lines can merge with each other."""
assert _should_break_span("", "") is False
assert _should_break_span("", "") is False
"""Tests for render_terminal_svg function.""" """Tests for render_terminal_svg function."""
def _char( def _char(
@@ -365,6 +421,19 @@ class TestRenderTerminalSvg:
assert svg.endswith("</svg>") assert svg.endswith("</svg>")
assert 'xmlns="http://www.w3.org/2000/svg"' in svg assert 'xmlns="http://www.w3.org/2000/svg"' in svg
def test_css_properties(self) -> None:
"""SVG includes essential CSS properties for proper rendering."""
svg = render_terminal_svg([], width=80, height=24)
# Check for proper baseline alignment
assert "dominant-baseline: text-before-edge" in svg
# Check for legibility optimization
assert "text-rendering: optimizeLegibility" in svg
# Check for monospace font
assert "font-family:" in svg
assert "monospace" in svg
# Check for pre whitespace handling
assert "white-space: pre" in svg
def test_buffer_with_empty_rows(self) -> None: def test_buffer_with_empty_rows(self) -> None:
"""Buffer with rows containing only empty cells produces valid SVG.""" """Buffer with rows containing only empty cells produces valid SVG."""
# Row with only empty placeholder cells (no actual characters) # Row with only empty placeholder cells (no actual characters)