diff --git a/pyproject.toml b/pyproject.toml index 84e706c..3cbde15 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual-webterm" -version = "0.3.7" +version = "0.3.8" description = "Serve terminal sessions over the web" authors = ["Will McGugan "] license = "MIT" diff --git a/src/textual_webterm/svg_exporter.py b/src/textual_webterm/svg_exporter.py index d5c8f35..9f88c2a 100644 --- a/src/textual_webterm/svg_exporter.py +++ b/src/textual_webterm/svg_exporter.py @@ -222,6 +222,13 @@ def render_terminal_svg( if classes: attrs.append(f'class="{" ".join(classes)}"') + # For horizontal box-drawing spans, use textLength to ensure correct width + # This prevents gaps caused by font rendering of ─ being narrower than char_width + if _is_all_horizontal_box_drawing(text) and len(text) > 1: + span_width = columns * char_width + attrs.append(f'textLength="{span_width:.1f}"') + attrs.append('lengthAdjust="spacing"') + parts.append(f'{_escape_xml(text)}') x += columns * char_width @@ -274,6 +281,23 @@ def _is_box_drawing_vertical_or_corner(char: str) -> bool: return code not in horizontal_chars +# Set of horizontal box-drawing character codes for span detection +_HORIZONTAL_BOX_CHARS = { + 0x2500, 0x2501, # ─ ━ + 0x2504, 0x2505, # ┄ ┅ (horizontal dashed) + 0x2508, 0x2509, # ┈ ┉ (horizontal dashed) + 0x254C, 0x254D, # ╌ ╍ (horizontal dashed) + 0x2550, # ═ +} + + +def _is_all_horizontal_box_drawing(text: str) -> bool: + """Check if text consists entirely of horizontal box-drawing characters.""" + if not text: + return False + return all(ord(c) in _HORIZONTAL_BOX_CHARS for c in text) + + def _should_break_span(current_text: str, new_char: str) -> bool: """Check if we should break the span before adding new_char. diff --git a/tests/test_svg_exporter.py b/tests/test_svg_exporter.py index c72217f..6e495ef 100644 --- a/tests/test_svg_exporter.py +++ b/tests/test_svg_exporter.py @@ -10,6 +10,7 @@ from textual_webterm.svg_exporter import ( _build_row_spans, _color_to_hex, _escape_xml, + _is_all_horizontal_box_drawing, _is_box_drawing_vertical_or_corner, _should_break_span, render_terminal_svg, @@ -387,6 +388,30 @@ class TestBoxDrawingHelpers: """Horizontal lines can merge with each other.""" assert _should_break_span("─", "─") is False assert _should_break_span("━", "━") is False + + def test_is_all_horizontal_box_drawing_empty(self) -> None: + """Empty string returns False.""" + assert _is_all_horizontal_box_drawing("") is False + + def test_is_all_horizontal_box_drawing_normal_text(self) -> None: + """Normal text returns False.""" + assert _is_all_horizontal_box_drawing("Hello") is False + assert _is_all_horizontal_box_drawing("ABC") is False + + def test_is_all_horizontal_box_drawing_horizontal_lines(self) -> None: + """Horizontal box chars return True.""" + assert _is_all_horizontal_box_drawing("─") is True + assert _is_all_horizontal_box_drawing("───") is True + assert _is_all_horizontal_box_drawing("━━━") is True + assert _is_all_horizontal_box_drawing("═══") is True + + def test_is_all_horizontal_box_drawing_mixed(self) -> None: + """Mixed content returns False.""" + assert _is_all_horizontal_box_drawing("─A─") is False + assert _is_all_horizontal_box_drawing("│──") is False # vertical at start + + +class TestRenderTerminalSvg: """Tests for render_terminal_svg function.""" def _char( @@ -797,6 +822,20 @@ class TestEdgeCases: assert "─" in svg assert "┐" in svg + def test_horizontal_lines_use_textlength(self) -> None: + """Horizontal line spans use textLength for correct width.""" + buffer = [[ + self._char("╭"), + self._char("─"), + self._char("─"), + self._char("─"), + self._char("╮"), + ]] + svg = render_terminal_svg(buffer, width=5, height=1) + # Horizontal lines should have textLength attribute + assert 'textLength="24.0"' in svg + assert 'lengthAdjust="spacing"' in svg + def test_ansi_bright_colors(self) -> None: """All bright ANSI colors render.""" colors = ["brightred", "brightgreen", "brightyellow",