diff --git a/src/textual_webterm/svg_exporter.py b/src/textual_webterm/svg_exporter.py index ed4d85b..80d9527 100644 --- a/src/textual_webterm/svg_exporter.py +++ b/src/textual_webterm/svg_exporter.py @@ -178,9 +178,10 @@ def render_terminal_svg( x = 10.0 # Starting x position with padding for span in spans: text = span["text"] + columns = span["columns"] if not text or (text.isspace() and not span["has_bg"]): # Skip empty spans without background, but advance position - x += len(text) * char_width + x += columns * char_width continue # Build tspan attributes @@ -203,7 +204,7 @@ def render_terminal_svg( # Background needs a separate rect if span["has_bg"] and span["bg"] != background: - bg_width = len(text) * char_width + bg_width = columns * char_width bg_y = y - font_size + 2 parts.insert( -1, # Insert before current text element @@ -213,7 +214,7 @@ def render_terminal_svg( ) parts.append(f'{_escape_xml(text)}') - x += len(text) * char_width + x += columns * char_width parts.append("") @@ -227,6 +228,7 @@ class _Span(TypedDict): """A span of text with consistent styling.""" text: str + columns: int # Number of terminal columns this span occupies fg: str bg: str bold: bool @@ -250,8 +252,11 @@ def _build_row_spans( for char in row_data: char_data = char["data"] - # Skip empty placeholder cells (after wide characters) + # Empty placeholder cells (after wide characters) count as a column + # but don't add text if not char_data: + if current_span is not None: + current_span["columns"] += 1 continue # Get colors, handling reverse video @@ -274,12 +279,14 @@ def _build_row_spans( and current_span["has_bg"] == has_bg ): current_span["text"] += char_data + current_span["columns"] += 1 else: # Start new span if current_span is not None: spans.append(current_span) current_span = { "text": char_data, + "columns": 1, "fg": fg, "bg": bg, "bold": char["bold"], diff --git a/tests/test_svg_exporter.py b/tests/test_svg_exporter.py index 8430740..cbe23c4 100644 --- a/tests/test_svg_exporter.py +++ b/tests/test_svg_exporter.py @@ -238,17 +238,18 @@ class TestBuildRowSpans: assert spans[2]["has_bg"] is False def test_wide_char_placeholder_skipped(self) -> None: - """Empty placeholder cells (after wide chars) are skipped.""" + """Empty placeholder cells (after wide chars) are skipped but counted in columns.""" row = [ self._char("A"), self._char("中"), # Wide char - self._char(""), # Placeholder - should be skipped + self._char(""), # Placeholder - should be skipped but counted self._char("B"), ] spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG) # Should merge into single span since all default style assert len(spans) == 1 assert spans[0]["text"] == "A中B" + assert spans[0]["columns"] == 4 # 1 + 1 + 1(placeholder) + 1 def test_multiple_wide_chars(self) -> None: """Multiple wide characters handled correctly.""" @@ -263,6 +264,7 @@ class TestBuildRowSpans: spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG) assert len(spans) == 1 assert spans[0]["text"] == "日本語" + assert spans[0]["columns"] == 6 # Each wide char + placeholder = 2 columns def test_emoji_with_placeholder(self) -> None: """Emoji characters with placeholders handled.""" @@ -276,6 +278,7 @@ class TestBuildRowSpans: spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG) assert len(spans) == 1 assert spans[0]["text"] == "🎉 🚀" + assert spans[0]["columns"] == 5 # 2 + 1 + 2 def test_mixed_styles_complex(self) -> None: """Complex mix of styles produces correct spans."""