Source code for erbsland.ansi_convert._converter

#  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()]