Fix wide character alignment in SVG exporter
Track column count separately from character count to properly handle wide characters (CJK, emoji) that occupy 2 terminal columns but have a single character + empty placeholder in pyte buffer.
This commit is contained in:
@@ -178,9 +178,10 @@ 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"]
|
text = span["text"]
|
||||||
|
columns = span["columns"]
|
||||||
if not text or (text.isspace() and not span["has_bg"]):
|
if not text or (text.isspace() and not span["has_bg"]):
|
||||||
# Skip empty spans without background, but advance position
|
# Skip empty spans without background, but advance position
|
||||||
x += len(text) * char_width
|
x += columns * char_width
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Build tspan attributes
|
# Build tspan attributes
|
||||||
@@ -203,7 +204,7 @@ def render_terminal_svg(
|
|||||||
|
|
||||||
# Background needs a separate rect
|
# Background needs a separate rect
|
||||||
if span["has_bg"] and span["bg"] != background:
|
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
|
bg_y = y - font_size + 2
|
||||||
parts.insert(
|
parts.insert(
|
||||||
-1, # Insert before current text element
|
-1, # Insert before current text element
|
||||||
@@ -213,7 +214,7 @@ def render_terminal_svg(
|
|||||||
)
|
)
|
||||||
|
|
||||||
parts.append(f'<tspan {" ".join(attrs)}>{_escape_xml(text)}</tspan>')
|
parts.append(f'<tspan {" ".join(attrs)}>{_escape_xml(text)}</tspan>')
|
||||||
x += len(text) * char_width
|
x += columns * char_width
|
||||||
|
|
||||||
parts.append("</text>")
|
parts.append("</text>")
|
||||||
|
|
||||||
@@ -227,6 +228,7 @@ class _Span(TypedDict):
|
|||||||
"""A span of text with consistent styling."""
|
"""A span of text with consistent styling."""
|
||||||
|
|
||||||
text: str
|
text: str
|
||||||
|
columns: int # Number of terminal columns this span occupies
|
||||||
fg: str
|
fg: str
|
||||||
bg: str
|
bg: str
|
||||||
bold: bool
|
bold: bool
|
||||||
@@ -250,8 +252,11 @@ def _build_row_spans(
|
|||||||
for char in row_data:
|
for char in row_data:
|
||||||
char_data = char["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 not char_data:
|
||||||
|
if current_span is not None:
|
||||||
|
current_span["columns"] += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Get colors, handling reverse video
|
# Get colors, handling reverse video
|
||||||
@@ -274,12 +279,14 @@ def _build_row_spans(
|
|||||||
and current_span["has_bg"] == has_bg
|
and current_span["has_bg"] == has_bg
|
||||||
):
|
):
|
||||||
current_span["text"] += char_data
|
current_span["text"] += char_data
|
||||||
|
current_span["columns"] += 1
|
||||||
else:
|
else:
|
||||||
# Start new span
|
# Start new span
|
||||||
if current_span is not None:
|
if current_span is not None:
|
||||||
spans.append(current_span)
|
spans.append(current_span)
|
||||||
current_span = {
|
current_span = {
|
||||||
"text": char_data,
|
"text": char_data,
|
||||||
|
"columns": 1,
|
||||||
"fg": fg,
|
"fg": fg,
|
||||||
"bg": bg,
|
"bg": bg,
|
||||||
"bold": char["bold"],
|
"bold": char["bold"],
|
||||||
|
|||||||
@@ -238,17 +238,18 @@ class TestBuildRowSpans:
|
|||||||
assert spans[2]["has_bg"] is False
|
assert spans[2]["has_bg"] is False
|
||||||
|
|
||||||
def test_wide_char_placeholder_skipped(self) -> None:
|
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 = [
|
row = [
|
||||||
self._char("A"),
|
self._char("A"),
|
||||||
self._char("中"), # Wide char
|
self._char("中"), # Wide char
|
||||||
self._char(""), # Placeholder - should be skipped
|
self._char(""), # Placeholder - should be skipped but counted
|
||||||
self._char("B"),
|
self._char("B"),
|
||||||
]
|
]
|
||||||
spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG)
|
spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG)
|
||||||
# Should merge into single span since all default style
|
# Should merge into single span since all default style
|
||||||
assert len(spans) == 1
|
assert len(spans) == 1
|
||||||
assert spans[0]["text"] == "A中B"
|
assert spans[0]["text"] == "A中B"
|
||||||
|
assert spans[0]["columns"] == 4 # 1 + 1 + 1(placeholder) + 1
|
||||||
|
|
||||||
def test_multiple_wide_chars(self) -> None:
|
def test_multiple_wide_chars(self) -> None:
|
||||||
"""Multiple wide characters handled correctly."""
|
"""Multiple wide characters handled correctly."""
|
||||||
@@ -263,6 +264,7 @@ class TestBuildRowSpans:
|
|||||||
spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG)
|
spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG)
|
||||||
assert len(spans) == 1
|
assert len(spans) == 1
|
||||||
assert spans[0]["text"] == "日本語"
|
assert spans[0]["text"] == "日本語"
|
||||||
|
assert spans[0]["columns"] == 6 # Each wide char + placeholder = 2 columns
|
||||||
|
|
||||||
def test_emoji_with_placeholder(self) -> None:
|
def test_emoji_with_placeholder(self) -> None:
|
||||||
"""Emoji characters with placeholders handled."""
|
"""Emoji characters with placeholders handled."""
|
||||||
@@ -276,6 +278,7 @@ class TestBuildRowSpans:
|
|||||||
spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG)
|
spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG)
|
||||||
assert len(spans) == 1
|
assert len(spans) == 1
|
||||||
assert spans[0]["text"] == "🎉 🚀"
|
assert spans[0]["text"] == "🎉 🚀"
|
||||||
|
assert spans[0]["columns"] == 5 # 2 + 1 + 2
|
||||||
|
|
||||||
def test_mixed_styles_complex(self) -> None:
|
def test_mixed_styles_complex(self) -> None:
|
||||||
"""Complex mix of styles produces correct spans."""
|
"""Complex mix of styles produces correct spans."""
|
||||||
|
|||||||
Reference in New Issue
Block a user