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:
+1
-1
@@ -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"
|
||||||
|
|||||||
@@ -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}"']
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user