# Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
# SPDX-License-Identifier: Apache-2.0
"""
Output converter implementations for simulated terminal history.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from html import escape
from ._model import (
DEFAULT_STYLE,
Character,
CharacterStyle,
SGR_BACKGROUND_CODES,
SGR_FOREGROUND_CODES,
)
from ._screen import ScreenBuffer
[docs]
class Converter(ABC):
"""Base class for terminal state converters."""
[docs]
@abstractmethod
def convert(self, screen: ScreenBuffer) -> str:
"""Convert the full screen + history into a serialized output format."""
def _iter_trimmed_rows(self, screen: ScreenBuffer) -> list[list[Character]]:
rows = [self._trim_right(row) for row in screen.all_rows()]
while rows and not rows[-1]:
rows.pop()
return rows
@staticmethod
def _trim_right(row: list[Character]) -> list[Character]:
last_index = -1
for index, cell in enumerate(row):
if cell.continuation:
continue
if cell.text != " ":
last_index = index
if last_index == -1:
return []
result: list[Character] = []
for cell in row[: last_index + 1]:
if cell.continuation:
continue
result.append(cell)
return result
[docs]
class TextConverter(Converter):
"""Converter to plain text with all ANSI formatting removed."""
[docs]
def convert(self, screen: ScreenBuffer) -> str:
rows = self._iter_trimmed_rows(screen)
return "\n".join("".join(cell.text for cell in row) for row in rows)
[docs]
class AnsiConverter(Converter):
"""Converter to compact ANSI-encoded text output."""
def __init__(self, esc_char: str = "\x1b") -> None:
if len(esc_char) != 1:
raise ValueError("esc_char must be exactly one character")
self._esc = esc_char
[docs]
def convert(self, screen: ScreenBuffer) -> str:
rows = self._iter_trimmed_rows(screen)
if not rows:
return ""
output: list[str] = []
current_style = DEFAULT_STYLE
for row_index, row in enumerate(rows):
if row_index:
output.append("\n")
for cell in row:
codes = self._style_transition(current_style, cell.style)
if codes:
output.append(self._sgr(codes))
current_style = cell.style
output.append(cell.text)
if current_style != DEFAULT_STYLE:
output.append(self._sgr([0]))
return "".join(output)
def _sgr(self, codes: list[int]) -> str:
joined = ";".join(str(code) for code in codes)
return f"{self._esc}[{joined}m"
@staticmethod
def _style_transition(current: CharacterStyle, target: CharacterStyle) -> list[int]:
if current == target:
return []
codes: list[int] = []
if current.bold and not target.bold:
codes.append(22)
if current.dim and not target.dim and 22 not in codes:
codes.append(22)
if current.italic and not target.italic:
codes.append(23)
if current.underline and not target.underline:
codes.append(24)
if current.blink and not target.blink:
codes.append(25)
if current.reverse and not target.reverse:
codes.append(27)
if current.hidden and not target.hidden:
codes.append(28)
if current.strike and not target.strike:
codes.append(29)
if current.foreground is not None and target.foreground is None:
codes.append(39)
if current.background is not None and target.background is None:
codes.append(49)
if target.bold and not current.bold:
codes.append(1)
if target.dim and not current.dim:
codes.append(2)
if target.italic and not current.italic:
codes.append(3)
if target.underline and not current.underline:
codes.append(4)
if target.blink and not current.blink:
codes.append(5)
if target.reverse and not current.reverse:
codes.append(7)
if target.hidden and not current.hidden:
codes.append(8)
if target.strike and not current.strike:
codes.append(9)
if target.foreground is not None and target.foreground != current.foreground:
code = SGR_FOREGROUND_CODES.get(target.foreground)
if code is not None:
codes.append(code)
if target.background is not None and target.background != current.background:
code = SGR_BACKGROUND_CODES.get(target.background)
if code is not None:
codes.append(code)
if not codes:
return [0]
return codes
[docs]
class HtmlConverter(Converter):
"""Converter to compact HTML using reusable CSS classes."""
def __init__(self, class_prefix: str = "erbsland-ansi") -> None:
if not class_prefix:
raise ValueError("class_prefix must not be empty")
self._prefix = class_prefix
[docs]
def convert(self, screen: ScreenBuffer) -> str:
rows = self._iter_trimmed_rows(screen)
output: list[str] = [f'<div class="{self._prefix}-block"><pre>']
open_classes: tuple[str, ...] | None = None
for row_index, row in enumerate(rows):
if row_index:
output.append("\n")
for cell in row:
classes = tuple(self._style_classes(cell.style))
if classes != open_classes:
if open_classes is not None:
output.append("</span>")
if classes:
joined = " ".join(classes)
output.append(f'<span class="{joined}">')
open_classes = classes
else:
open_classes = None
output.append(escape(cell.text, quote=False))
if open_classes is not None:
output.append("</span>")
output.append("</pre></div>")
return "".join(output)
def _style_classes(self, style: CharacterStyle) -> list[str]:
return [f"{self._prefix}-{name}" for name in style.html_classes()]