820 lines
26 KiB
Python
820 lines
26 KiB
Python
|
# encoding: utf-8
|
||
|
# This file is part of ansi2html
|
||
|
# Convert ANSI (terminal) colours and attributes to HTML
|
||
|
# Copyright (C) 2012 Ralph Bean <rbean@redhat.com>
|
||
|
# Copyright (C) 2013 Sebastian Pipping <sebastian@pipping.org>
|
||
|
#
|
||
|
# Inspired by and developed off of the work by pixelbeat and blackjack.
|
||
|
#
|
||
|
# This program is free software: you can redistribute it and/or
|
||
|
# modify it under the terms of the GNU General Public License as
|
||
|
# published by the Free Software Foundation, either version 3 of
|
||
|
# the License, or (at your option) any later version.
|
||
|
#
|
||
|
# This program is distributed in the hope that it will be useful,
|
||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||
|
# General Public License for more details.
|
||
|
#
|
||
|
# You should have received a copy of the GNU General Public License
|
||
|
# along with this program. If not, see
|
||
|
# <http://www.gnu.org/licenses/>.
|
||
|
|
||
|
import io
|
||
|
import optparse
|
||
|
import re
|
||
|
import sys
|
||
|
from collections import OrderedDict
|
||
|
from typing import Iterator, List, Optional, Set, Tuple, Union
|
||
|
|
||
|
from ansi2html.style import (
|
||
|
SCHEME,
|
||
|
add_truecolor_style_rule,
|
||
|
get_styles,
|
||
|
pop_truecolor_styles,
|
||
|
)
|
||
|
|
||
|
if sys.version_info >= (3, 8):
|
||
|
from importlib.metadata import version
|
||
|
else:
|
||
|
from importlib_metadata import version
|
||
|
|
||
|
if sys.version_info >= (3, 8):
|
||
|
from typing import TypedDict
|
||
|
else:
|
||
|
from typing_extensions import TypedDict
|
||
|
|
||
|
|
||
|
ANSI_FULL_RESET = 0
|
||
|
ANSI_INTENSITY_INCREASED = 1
|
||
|
ANSI_INTENSITY_REDUCED = 2
|
||
|
ANSI_INTENSITY_NORMAL = 22
|
||
|
ANSI_STYLE_ITALIC = 3
|
||
|
ANSI_STYLE_NORMAL = 23
|
||
|
ANSI_BLINK_SLOW = 5
|
||
|
ANSI_BLINK_FAST = 6
|
||
|
ANSI_BLINK_OFF = 25
|
||
|
ANSI_UNDERLINE_ON = 4
|
||
|
ANSI_UNDERLINE_OFF = 24
|
||
|
ANSI_CROSSED_OUT_ON = 9
|
||
|
ANSI_CROSSED_OUT_OFF = 29
|
||
|
ANSI_VISIBILITY_ON = 28
|
||
|
ANSI_VISIBILITY_OFF = 8
|
||
|
ANSI_FOREGROUND_CUSTOM_MIN = 30
|
||
|
ANSI_FOREGROUND_CUSTOM_MAX = 37
|
||
|
ANSI_FOREGROUND = 38
|
||
|
ANSI_FOREGROUND_DEFAULT = 39
|
||
|
ANSI_BACKGROUND_CUSTOM_MIN = 40
|
||
|
ANSI_BACKGROUND_CUSTOM_MAX = 47
|
||
|
ANSI_BACKGROUND = 48
|
||
|
ANSI_BACKGROUND_DEFAULT = 49
|
||
|
ANSI_NEGATIVE_ON = 7
|
||
|
ANSI_NEGATIVE_OFF = 27
|
||
|
ANSI_FOREGROUND_HIGH_INTENSITY_MIN = 90
|
||
|
ANSI_FOREGROUND_HIGH_INTENSITY_MAX = 97
|
||
|
ANSI_BACKGROUND_HIGH_INTENSITY_MIN = 100
|
||
|
ANSI_BACKGROUND_HIGH_INTENSITY_MAX = 107
|
||
|
ANSI_256_COLOR_ID = 5
|
||
|
ANSI_TRUECOLOR_ID = 2
|
||
|
|
||
|
VT100_BOX_CODES = {
|
||
|
"0x71": "─",
|
||
|
"0x74": "├",
|
||
|
"0x75": "┤",
|
||
|
"0x76": "┴",
|
||
|
"0x77": "┬",
|
||
|
"0x78": "│",
|
||
|
"0x6a": "┘",
|
||
|
"0x6b": "┐",
|
||
|
"0x6c": "┌",
|
||
|
"0x6d": "└",
|
||
|
"0x6e": "┼",
|
||
|
}
|
||
|
|
||
|
# http://stackoverflow.com/a/15190498
|
||
|
_latex_template = """\\documentclass{scrartcl}
|
||
|
\\usepackage[utf8]{inputenc}
|
||
|
\\usepackage{fancyvrb}
|
||
|
\\usepackage[usenames,dvipsnames]{xcolor}
|
||
|
%% \\definecolor{red-sd}{HTML}{7ed2d2}
|
||
|
%(hyperref)s
|
||
|
\\title{%(title)s}
|
||
|
|
||
|
\\fvset{commandchars=\\\\\\{\\}}
|
||
|
|
||
|
\\begin{document}
|
||
|
|
||
|
\\begin{Verbatim}
|
||
|
%(content)s
|
||
|
\\end{Verbatim}
|
||
|
\\end{document}
|
||
|
"""
|
||
|
|
||
|
_html_template = """<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
|
||
|
<html>
|
||
|
<head>
|
||
|
<meta http-equiv="Content-Type" content="text/html; charset=%(output_encoding)s">
|
||
|
<title>%(title)s</title>
|
||
|
<style type="text/css">\n%(style)s\n</style>
|
||
|
</head>
|
||
|
<body class="body_foreground body_background" style="font-size: %(font_size)s;" >
|
||
|
<pre class="ansi2html-content">
|
||
|
%(content)s
|
||
|
</pre>
|
||
|
</body>
|
||
|
|
||
|
</html>
|
||
|
"""
|
||
|
|
||
|
|
||
|
class _State:
|
||
|
def __init__(self) -> None:
|
||
|
self.inside_span = False
|
||
|
self.reset()
|
||
|
|
||
|
def reset(self) -> None:
|
||
|
self.intensity: int = ANSI_INTENSITY_NORMAL
|
||
|
self.style: int = ANSI_STYLE_NORMAL
|
||
|
self.blink: int = ANSI_BLINK_OFF
|
||
|
self.underline: int = ANSI_UNDERLINE_OFF
|
||
|
self.crossedout: int = ANSI_CROSSED_OUT_OFF
|
||
|
self.visibility: int = ANSI_VISIBILITY_ON
|
||
|
self.foreground: Tuple[int, Optional[str]] = (ANSI_FOREGROUND_DEFAULT, None)
|
||
|
self.background: Tuple[int, Optional[str]] = (ANSI_BACKGROUND_DEFAULT, None)
|
||
|
self.negative: int = ANSI_NEGATIVE_OFF
|
||
|
|
||
|
def adjust(self, ansi_code: int, parameter: Optional[str] = None) -> None:
|
||
|
if ansi_code in (
|
||
|
ANSI_INTENSITY_INCREASED,
|
||
|
ANSI_INTENSITY_REDUCED,
|
||
|
ANSI_INTENSITY_NORMAL,
|
||
|
):
|
||
|
self.intensity = ansi_code
|
||
|
elif ansi_code in (ANSI_STYLE_ITALIC, ANSI_STYLE_NORMAL):
|
||
|
self.style = ansi_code
|
||
|
elif ansi_code in (ANSI_BLINK_SLOW, ANSI_BLINK_FAST, ANSI_BLINK_OFF):
|
||
|
self.blink = ansi_code
|
||
|
elif ansi_code in (ANSI_UNDERLINE_ON, ANSI_UNDERLINE_OFF):
|
||
|
self.underline = ansi_code
|
||
|
elif ansi_code in (ANSI_CROSSED_OUT_ON, ANSI_CROSSED_OUT_OFF):
|
||
|
self.crossedout = ansi_code
|
||
|
elif ansi_code in (ANSI_VISIBILITY_ON, ANSI_VISIBILITY_OFF):
|
||
|
self.visibility = ansi_code
|
||
|
elif ANSI_FOREGROUND_CUSTOM_MIN <= ansi_code <= ANSI_FOREGROUND_CUSTOM_MAX:
|
||
|
self.foreground = (ansi_code, None)
|
||
|
elif (
|
||
|
ANSI_FOREGROUND_HIGH_INTENSITY_MIN
|
||
|
<= ansi_code
|
||
|
<= ANSI_FOREGROUND_HIGH_INTENSITY_MAX
|
||
|
):
|
||
|
self.foreground = (ansi_code, None)
|
||
|
elif ansi_code == ANSI_FOREGROUND:
|
||
|
self.foreground = (ansi_code, parameter)
|
||
|
elif ansi_code == ANSI_FOREGROUND_DEFAULT:
|
||
|
self.foreground = (ansi_code, None)
|
||
|
elif ANSI_BACKGROUND_CUSTOM_MIN <= ansi_code <= ANSI_BACKGROUND_CUSTOM_MAX:
|
||
|
self.background = (ansi_code, None)
|
||
|
elif (
|
||
|
ANSI_BACKGROUND_HIGH_INTENSITY_MIN
|
||
|
<= ansi_code
|
||
|
<= ANSI_BACKGROUND_HIGH_INTENSITY_MAX
|
||
|
):
|
||
|
self.background = (ansi_code, None)
|
||
|
elif ansi_code == ANSI_BACKGROUND:
|
||
|
self.background = (ansi_code, parameter)
|
||
|
elif ansi_code == ANSI_BACKGROUND_DEFAULT:
|
||
|
self.background = (ansi_code, None)
|
||
|
elif ansi_code in (ANSI_NEGATIVE_ON, ANSI_NEGATIVE_OFF):
|
||
|
self.negative = ansi_code
|
||
|
|
||
|
def adjust_truecolor(self, ansi_code: int, r: int, g: int, b: int) -> None:
|
||
|
parameter = "{:03d}{:03d}{:03d}".format(
|
||
|
r, g, b
|
||
|
) # r=1, g=64, b=255 -> 001064255
|
||
|
|
||
|
is_foreground = ansi_code == ANSI_FOREGROUND
|
||
|
add_truecolor_style_rule(is_foreground, ansi_code, r, g, b, parameter)
|
||
|
if is_foreground:
|
||
|
self.foreground = (ansi_code, parameter)
|
||
|
else:
|
||
|
self.background = (ansi_code, parameter)
|
||
|
|
||
|
def to_css_classes(self) -> List[str]:
|
||
|
css_classes: List[str] = []
|
||
|
|
||
|
def append_unless_default(output: List[str], value: int, default: int) -> None:
|
||
|
if value != default:
|
||
|
css_class = "ansi%d" % value
|
||
|
output.append(css_class)
|
||
|
|
||
|
def append_color_unless_default(
|
||
|
output: List[str],
|
||
|
color: Tuple[int, Optional[str]],
|
||
|
default: int,
|
||
|
negative: bool,
|
||
|
neg_css_class: str,
|
||
|
) -> None:
|
||
|
value, parameter = color
|
||
|
if value != default:
|
||
|
prefix = "inv" if negative else "ansi"
|
||
|
css_class_index = (
|
||
|
str(value) if (parameter is None) else "%d-%s" % (value, parameter)
|
||
|
)
|
||
|
output.append(prefix + css_class_index)
|
||
|
elif negative:
|
||
|
output.append(neg_css_class)
|
||
|
|
||
|
append_unless_default(css_classes, self.intensity, ANSI_INTENSITY_NORMAL)
|
||
|
append_unless_default(css_classes, self.style, ANSI_STYLE_NORMAL)
|
||
|
append_unless_default(css_classes, self.blink, ANSI_BLINK_OFF)
|
||
|
append_unless_default(css_classes, self.underline, ANSI_UNDERLINE_OFF)
|
||
|
append_unless_default(css_classes, self.crossedout, ANSI_CROSSED_OUT_OFF)
|
||
|
append_unless_default(css_classes, self.visibility, ANSI_VISIBILITY_ON)
|
||
|
|
||
|
flip_fore_and_background = self.negative == ANSI_NEGATIVE_ON
|
||
|
append_color_unless_default(
|
||
|
css_classes,
|
||
|
self.foreground,
|
||
|
ANSI_FOREGROUND_DEFAULT,
|
||
|
flip_fore_and_background,
|
||
|
"inv_background",
|
||
|
)
|
||
|
append_color_unless_default(
|
||
|
css_classes,
|
||
|
self.background,
|
||
|
ANSI_BACKGROUND_DEFAULT,
|
||
|
flip_fore_and_background,
|
||
|
"inv_foreground",
|
||
|
)
|
||
|
|
||
|
return css_classes
|
||
|
|
||
|
|
||
|
class OSC_Link:
|
||
|
def __init__(self, url: str, text: str) -> None:
|
||
|
self.url = url
|
||
|
self.text = text
|
||
|
|
||
|
|
||
|
def map_vt100_box_code(char: str) -> str:
|
||
|
char_hex = hex(ord(char))
|
||
|
return VT100_BOX_CODES[char_hex] if char_hex in VT100_BOX_CODES else char
|
||
|
|
||
|
|
||
|
def _needs_extra_newline(text: str) -> bool:
|
||
|
if not text or text.endswith("\n"):
|
||
|
return False
|
||
|
return True
|
||
|
|
||
|
|
||
|
class CursorMoveUp:
|
||
|
pass
|
||
|
|
||
|
|
||
|
class Attributes(TypedDict):
|
||
|
dark_bg: bool
|
||
|
line_wrap: bool
|
||
|
font_size: str
|
||
|
body: str
|
||
|
styles: Set[str]
|
||
|
|
||
|
|
||
|
class Ansi2HTMLConverter:
|
||
|
"""Convert Ansi color codes to CSS+HTML
|
||
|
|
||
|
Example:
|
||
|
|
||
|
>>> conv = Ansi2HTMLConverter()
|
||
|
>>> ansi = " ".join(sys.stdin.readlines())
|
||
|
>>> html = conv.convert(ansi)
|
||
|
"""
|
||
|
|
||
|
def __init__(
|
||
|
self,
|
||
|
latex: bool = False,
|
||
|
inline: bool = False,
|
||
|
dark_bg: bool = True,
|
||
|
line_wrap: bool = True,
|
||
|
font_size: str = "normal",
|
||
|
linkify: bool = False,
|
||
|
escaped: bool = True,
|
||
|
markup_lines: bool = False,
|
||
|
output_encoding: str = "utf-8",
|
||
|
scheme: str = "ansi2html",
|
||
|
title: str = "",
|
||
|
) -> None:
|
||
|
|
||
|
self.latex = latex
|
||
|
self.inline = inline
|
||
|
self.dark_bg = dark_bg
|
||
|
self.line_wrap = line_wrap
|
||
|
self.font_size = font_size
|
||
|
self.linkify = linkify
|
||
|
self.escaped = escaped
|
||
|
self.markup_lines = markup_lines
|
||
|
self.output_encoding = output_encoding
|
||
|
self.scheme = scheme
|
||
|
self.title = title
|
||
|
self._attrs: Attributes
|
||
|
self.hyperref = False
|
||
|
if inline:
|
||
|
self.styles = dict(
|
||
|
[
|
||
|
(item.klass.strip("."), item)
|
||
|
for item in get_styles(self.dark_bg, self.line_wrap, self.scheme)
|
||
|
]
|
||
|
)
|
||
|
|
||
|
self.vt100_box_codes_prog = re.compile("\033\\(([B0])")
|
||
|
self.ansi_codes_prog = re.compile("\033\\[([\\d;:]*)([a-zA-z])")
|
||
|
self.url_matcher = re.compile(
|
||
|
r"(((((https?|ftps?|gopher|telnet|nntp)://)|"
|
||
|
r"(mailto:|news:))(%[0-9A-Fa-f]{2}|[-()_.!~*"
|
||
|
r"\';/?:@&=+$,A-Za-z0-9])+)([).!\';/?:,][\s])?)"
|
||
|
)
|
||
|
self.osc_link_re = re.compile("\033\\]8;;(.*?)\007(.*?)\033\\]8;;\007")
|
||
|
|
||
|
def do_linkify(self, line: str) -> str:
|
||
|
if not isinstance(line, str):
|
||
|
return line # If line is an object, e.g. OSC_Link, it
|
||
|
# will be expanded to a string later
|
||
|
if self.latex:
|
||
|
return self.url_matcher.sub(r"\\url{\1}", line)
|
||
|
return self.url_matcher.sub(r'<a href="\1">\1</a>', line)
|
||
|
|
||
|
def handle_osc_links(self, part: OSC_Link) -> str:
|
||
|
if self.latex:
|
||
|
self.hyperref = True
|
||
|
return """\\href{%s}{%s}""" % (part.url, part.text)
|
||
|
return """<a href="%s">%s</a>""" % (part.url, part.text)
|
||
|
|
||
|
def apply_regex(self, ansi: str) -> Tuple[str, Set[str]]:
|
||
|
styles_used: Set[str] = set()
|
||
|
all_parts = self._apply_regex(ansi, styles_used)
|
||
|
no_cursor_parts = self._collapse_cursor(all_parts)
|
||
|
no_cursor_parts = list(no_cursor_parts)
|
||
|
|
||
|
def _check_links(parts: List[Union[str, OSC_Link]]) -> Iterator[str]:
|
||
|
for part in parts:
|
||
|
if isinstance(part, str):
|
||
|
if self.linkify:
|
||
|
yield self.do_linkify(part)
|
||
|
else:
|
||
|
yield part
|
||
|
elif isinstance(part, OSC_Link):
|
||
|
yield self.handle_osc_links(part)
|
||
|
else:
|
||
|
yield part
|
||
|
|
||
|
parts = list(_check_links(no_cursor_parts))
|
||
|
combined = "".join(parts)
|
||
|
if self.markup_lines and not self.latex:
|
||
|
combined = "\n".join(
|
||
|
[
|
||
|
"""<span id="line-%i">%s</span>""" % (i, line)
|
||
|
for i, line in enumerate(combined.split("\n"))
|
||
|
]
|
||
|
)
|
||
|
return combined, styles_used
|
||
|
|
||
|
def _apply_regex(
|
||
|
self, ansi: str, styles_used: Set[str]
|
||
|
) -> Iterator[Union[str, OSC_Link, CursorMoveUp]]:
|
||
|
if self.escaped:
|
||
|
if (
|
||
|
self.latex
|
||
|
): # Known Perl function which does this: https://tex.stackexchange.com/questions/34580/escape-character-in-latex/119383#119383
|
||
|
specials = OrderedDict([])
|
||
|
else:
|
||
|
specials = OrderedDict(
|
||
|
[
|
||
|
("&", "&"),
|
||
|
("<", "<"),
|
||
|
(">", ">"),
|
||
|
]
|
||
|
)
|
||
|
for pattern, special in specials.items():
|
||
|
ansi = ansi.replace(pattern, special)
|
||
|
|
||
|
def _vt100_box_drawing() -> Iterator[str]:
|
||
|
last_end = 0 # the index of the last end of a code we've seen
|
||
|
box_drawing_mode = False
|
||
|
for match in self.vt100_box_codes_prog.finditer(ansi):
|
||
|
trailer = ansi[last_end : match.start()]
|
||
|
if box_drawing_mode:
|
||
|
for char in trailer:
|
||
|
yield map_vt100_box_code(char)
|
||
|
else:
|
||
|
yield trailer
|
||
|
last_end = match.end()
|
||
|
box_drawing_mode = match.groups()[0] == "0"
|
||
|
yield ansi[last_end:]
|
||
|
|
||
|
ansi = "".join(_vt100_box_drawing())
|
||
|
|
||
|
def _osc_link(ansi: str) -> Iterator[Union[str, OSC_Link]]:
|
||
|
last_end = 0
|
||
|
for match in self.osc_link_re.finditer(ansi):
|
||
|
trailer = ansi[last_end : match.start()]
|
||
|
yield trailer
|
||
|
url = match.groups()[0]
|
||
|
text = match.groups()[1]
|
||
|
yield OSC_Link(url, text)
|
||
|
last_end = match.end()
|
||
|
yield ansi[last_end:]
|
||
|
|
||
|
state = _State()
|
||
|
for part in _osc_link(ansi):
|
||
|
if isinstance(part, OSC_Link):
|
||
|
yield part
|
||
|
else:
|
||
|
yield from self._handle_ansi_code(part, styles_used, state)
|
||
|
if state.inside_span:
|
||
|
if self.latex:
|
||
|
yield "}"
|
||
|
else:
|
||
|
yield "</span>"
|
||
|
|
||
|
def _handle_ansi_code(
|
||
|
self, ansi: str, styles_used: Set[str], state: _State
|
||
|
) -> Iterator[Union[str, CursorMoveUp]]:
|
||
|
last_end = 0 # the index of the last end of a code we've seen
|
||
|
for match in self.ansi_codes_prog.finditer(ansi):
|
||
|
yield ansi[last_end : match.start()]
|
||
|
last_end = match.end()
|
||
|
|
||
|
params: Union[str, List[int]]
|
||
|
params, command = match.groups()
|
||
|
|
||
|
if command not in "mMA":
|
||
|
continue
|
||
|
|
||
|
# Special cursor-moving code. The only supported one.
|
||
|
if command == "A":
|
||
|
yield CursorMoveUp()
|
||
|
continue
|
||
|
|
||
|
while True:
|
||
|
param_len = len(params)
|
||
|
params = params.replace("::", ":")
|
||
|
params = params.replace(";;", ";")
|
||
|
if len(params) == param_len:
|
||
|
break
|
||
|
|
||
|
try:
|
||
|
params = [int(x) for x in re.split("[;:]", params)]
|
||
|
except ValueError:
|
||
|
params = [ANSI_FULL_RESET]
|
||
|
|
||
|
# Find latest reset marker
|
||
|
last_null_index = None
|
||
|
skip_after_index = -1
|
||
|
for i, v in enumerate(params):
|
||
|
if i <= skip_after_index:
|
||
|
continue
|
||
|
|
||
|
if v == ANSI_FULL_RESET:
|
||
|
last_null_index = i
|
||
|
elif v in (ANSI_FOREGROUND, ANSI_BACKGROUND):
|
||
|
try:
|
||
|
x_bit_color_id = params[i + 1]
|
||
|
except IndexError:
|
||
|
x_bit_color_id = -1
|
||
|
is_256_color = x_bit_color_id == ANSI_256_COLOR_ID
|
||
|
shift = 2 if is_256_color else 4
|
||
|
skip_after_index = i + shift
|
||
|
|
||
|
# Process reset marker, drop everything before
|
||
|
if last_null_index is not None:
|
||
|
params = params[last_null_index + 1 :]
|
||
|
if state.inside_span:
|
||
|
state.inside_span = False
|
||
|
if self.latex:
|
||
|
yield "}"
|
||
|
else:
|
||
|
yield "</span>"
|
||
|
state.reset()
|
||
|
|
||
|
if not params:
|
||
|
continue
|
||
|
|
||
|
# Turn codes into CSS classes
|
||
|
skip_after_index = -1
|
||
|
for i, v in enumerate(params):
|
||
|
if i <= skip_after_index:
|
||
|
continue
|
||
|
|
||
|
is_x_bit_color = v in (ANSI_FOREGROUND, ANSI_BACKGROUND)
|
||
|
try:
|
||
|
x_bit_color_id = params[i + 1]
|
||
|
except IndexError:
|
||
|
x_bit_color_id = -1
|
||
|
is_256_color = x_bit_color_id == ANSI_256_COLOR_ID
|
||
|
is_truecolor = x_bit_color_id == ANSI_TRUECOLOR_ID
|
||
|
if is_x_bit_color and is_256_color:
|
||
|
try:
|
||
|
parameter: Optional[str] = str(params[i + 2])
|
||
|
except IndexError:
|
||
|
continue
|
||
|
skip_after_index = i + 2
|
||
|
elif is_x_bit_color and is_truecolor:
|
||
|
try:
|
||
|
state.adjust_truecolor(
|
||
|
v, params[i + 2], params[i + 3], params[i + 4]
|
||
|
)
|
||
|
except IndexError:
|
||
|
continue
|
||
|
skip_after_index = i + 4
|
||
|
continue
|
||
|
else:
|
||
|
parameter = None
|
||
|
state.adjust(v, parameter=parameter)
|
||
|
|
||
|
if state.inside_span:
|
||
|
if self.latex:
|
||
|
yield "}"
|
||
|
else:
|
||
|
yield "</span>"
|
||
|
state.inside_span = False
|
||
|
|
||
|
css_classes = state.to_css_classes()
|
||
|
if not css_classes:
|
||
|
continue
|
||
|
styles_used.update(css_classes)
|
||
|
|
||
|
if self.inline:
|
||
|
self.styles.update(pop_truecolor_styles())
|
||
|
if self.latex:
|
||
|
style = [
|
||
|
self.styles[klass].kwl[0][1]
|
||
|
for klass in css_classes
|
||
|
if self.styles[klass].kwl[0][0] == "color"
|
||
|
]
|
||
|
yield "\\textcolor[HTML]{%s}{" % style[0]
|
||
|
else:
|
||
|
style = [
|
||
|
self.styles[klass].kw
|
||
|
for klass in css_classes
|
||
|
if klass in self.styles
|
||
|
]
|
||
|
yield '<span style="%s">' % "; ".join(style)
|
||
|
else:
|
||
|
if self.latex:
|
||
|
yield "\\textcolor{%s}{" % " ".join(css_classes)
|
||
|
else:
|
||
|
yield '<span class="%s">' % " ".join(css_classes)
|
||
|
state.inside_span = True
|
||
|
yield ansi[last_end:]
|
||
|
|
||
|
def _collapse_cursor(
|
||
|
self, parts: Iterator[Union[str, OSC_Link, CursorMoveUp]]
|
||
|
) -> List[Union[str, OSC_Link]]:
|
||
|
"""Act on any CursorMoveUp commands by deleting preceding tokens"""
|
||
|
|
||
|
final_parts: List[Union[str, OSC_Link]] = []
|
||
|
for part in parts:
|
||
|
|
||
|
# Throw out empty string tokens ("")
|
||
|
if not part:
|
||
|
continue
|
||
|
|
||
|
# Go back, deleting every token in the last 'line'
|
||
|
if isinstance(part, CursorMoveUp):
|
||
|
if final_parts:
|
||
|
final_parts.pop()
|
||
|
|
||
|
while final_parts and (
|
||
|
isinstance(final_parts[-1], OSC_Link)
|
||
|
or (
|
||
|
isinstance(final_parts[-1], str) and "\n" not in final_parts[-1]
|
||
|
)
|
||
|
):
|
||
|
final_parts.pop()
|
||
|
|
||
|
continue
|
||
|
|
||
|
# Otherwise, just pass this token forward
|
||
|
final_parts.append(part)
|
||
|
|
||
|
return final_parts
|
||
|
|
||
|
def prepare(
|
||
|
self, ansi: str = "", ensure_trailing_newline: bool = False
|
||
|
) -> Attributes:
|
||
|
"""Load the contents of 'ansi' into this object"""
|
||
|
|
||
|
body, styles = self.apply_regex(ansi)
|
||
|
|
||
|
if ensure_trailing_newline and _needs_extra_newline(body):
|
||
|
body += "\n"
|
||
|
|
||
|
self._attrs = {
|
||
|
"dark_bg": self.dark_bg,
|
||
|
"line_wrap": self.line_wrap,
|
||
|
"font_size": self.font_size,
|
||
|
"body": body,
|
||
|
"styles": styles,
|
||
|
}
|
||
|
|
||
|
return self._attrs
|
||
|
|
||
|
def convert(
|
||
|
self, ansi: str, full: bool = True, ensure_trailing_newline: bool = False
|
||
|
) -> str:
|
||
|
r"""
|
||
|
:param ansi: ANSI sequence to convert.
|
||
|
:param full: Whether to include the full HTML document or only the body.
|
||
|
:param ensure_trailing_newline: Ensures that ``\n`` character is present at the end of the output.
|
||
|
"""
|
||
|
attrs = self.prepare(ansi, ensure_trailing_newline=ensure_trailing_newline)
|
||
|
if not full:
|
||
|
return attrs["body"]
|
||
|
if self.latex:
|
||
|
_template = _latex_template
|
||
|
else:
|
||
|
_template = _html_template
|
||
|
all_styles = get_styles(self.dark_bg, self.line_wrap, self.scheme)
|
||
|
backgrounds = all_styles[:5]
|
||
|
used_styles = filter(
|
||
|
lambda e: e.klass.lstrip(".") in attrs["styles"], all_styles
|
||
|
)
|
||
|
|
||
|
return _template % {
|
||
|
"style": "\n".join(list(map(str, backgrounds + list(used_styles)))),
|
||
|
"title": self.title,
|
||
|
"font_size": self.font_size,
|
||
|
"content": attrs["body"],
|
||
|
"output_encoding": self.output_encoding,
|
||
|
"hyperref": "\\usepackage{hyperref}" if self.hyperref else "",
|
||
|
}
|
||
|
|
||
|
def produce_headers(self) -> str:
|
||
|
return '<style type="text/css">\n%(style)s\n</style>\n' % {
|
||
|
"style": "\n".join(
|
||
|
map(str, get_styles(self.dark_bg, self.line_wrap, self.scheme))
|
||
|
)
|
||
|
}
|
||
|
|
||
|
|
||
|
def main() -> None:
|
||
|
"""
|
||
|
$ ls --color=always | ansi2html > directories.html
|
||
|
$ sudo tail /var/log/messages | ccze -A | ansi2html > logs.html
|
||
|
$ task burndown | ansi2html > burndown.html
|
||
|
"""
|
||
|
|
||
|
scheme_names = sorted(SCHEME.keys())
|
||
|
version_str = version("ansi2html")
|
||
|
parser = optparse.OptionParser(
|
||
|
usage=main.__doc__, version="%%prog %s" % version_str
|
||
|
)
|
||
|
parser.add_option(
|
||
|
"-p",
|
||
|
"--partial",
|
||
|
dest="partial",
|
||
|
default=False,
|
||
|
action="store_true",
|
||
|
help="Process lines as them come in. No headers are produced.",
|
||
|
)
|
||
|
parser.add_option(
|
||
|
"-L",
|
||
|
"--latex",
|
||
|
dest="latex",
|
||
|
default=False,
|
||
|
action="store_true",
|
||
|
help="Export as LaTeX instead of HTML.",
|
||
|
)
|
||
|
parser.add_option(
|
||
|
"-i",
|
||
|
"--inline",
|
||
|
dest="inline",
|
||
|
default=False,
|
||
|
action="store_true",
|
||
|
help="Inline style without headers or template.",
|
||
|
)
|
||
|
parser.add_option(
|
||
|
"-H",
|
||
|
"--headers",
|
||
|
dest="headers",
|
||
|
default=False,
|
||
|
action="store_true",
|
||
|
help="Just produce the <style> tag.",
|
||
|
)
|
||
|
parser.add_option(
|
||
|
"-f",
|
||
|
"--font-size",
|
||
|
dest="font_size",
|
||
|
metavar="SIZE",
|
||
|
default="normal",
|
||
|
help="Set the global font size in the output.",
|
||
|
)
|
||
|
parser.add_option(
|
||
|
"-l",
|
||
|
"--light-background",
|
||
|
dest="light_background",
|
||
|
default=False,
|
||
|
action="store_true",
|
||
|
help="Set output to 'light background' mode.",
|
||
|
)
|
||
|
parser.add_option(
|
||
|
"-W",
|
||
|
"--no-line-wrap",
|
||
|
dest="no_line_wrap",
|
||
|
default=False,
|
||
|
action="store_true",
|
||
|
help="Disable line wrapping.",
|
||
|
)
|
||
|
parser.add_option(
|
||
|
"-a",
|
||
|
"--linkify",
|
||
|
dest="linkify",
|
||
|
default=False,
|
||
|
action="store_true",
|
||
|
help="Transform URLs into <a> links.",
|
||
|
)
|
||
|
parser.add_option(
|
||
|
"-u",
|
||
|
"--unescape",
|
||
|
dest="escaped",
|
||
|
default=True,
|
||
|
action="store_false",
|
||
|
help="Do not escape XML tags found in the input.",
|
||
|
)
|
||
|
parser.add_option(
|
||
|
"-m",
|
||
|
"--markup-lines",
|
||
|
dest="markup_lines",
|
||
|
default=False,
|
||
|
action="store_true",
|
||
|
help="Surround lines with <span id='line-n'>..</span>.",
|
||
|
)
|
||
|
parser.add_option(
|
||
|
"--input-encoding",
|
||
|
dest="input_encoding",
|
||
|
metavar="ENCODING",
|
||
|
default="utf-8",
|
||
|
help="Specify input encoding",
|
||
|
)
|
||
|
parser.add_option(
|
||
|
"--output-encoding",
|
||
|
dest="output_encoding",
|
||
|
metavar="ENCODING",
|
||
|
default="utf-8",
|
||
|
help="Specify output encoding",
|
||
|
)
|
||
|
parser.add_option(
|
||
|
"-s",
|
||
|
"--scheme",
|
||
|
dest="scheme",
|
||
|
metavar="SCHEME",
|
||
|
default="ansi2html",
|
||
|
choices=scheme_names,
|
||
|
help=(
|
||
|
"Specify color palette scheme. Default: %%default. Choices: %s"
|
||
|
% scheme_names
|
||
|
),
|
||
|
)
|
||
|
parser.add_option(
|
||
|
"-t", "--title", dest="output_title", default="", help="Specify output title"
|
||
|
)
|
||
|
|
||
|
opts, args = parser.parse_args()
|
||
|
|
||
|
conv = Ansi2HTMLConverter(
|
||
|
latex=opts.latex,
|
||
|
inline=opts.inline,
|
||
|
dark_bg=not opts.light_background,
|
||
|
line_wrap=not opts.no_line_wrap,
|
||
|
font_size=opts.font_size,
|
||
|
linkify=opts.linkify,
|
||
|
escaped=opts.escaped,
|
||
|
markup_lines=opts.markup_lines,
|
||
|
output_encoding=opts.output_encoding,
|
||
|
scheme=opts.scheme,
|
||
|
title=opts.output_title,
|
||
|
)
|
||
|
|
||
|
if hasattr(sys.stdin, "detach") and not isinstance(
|
||
|
sys.stdin, io.StringIO
|
||
|
): # e.g. during tests
|
||
|
input_buffer = sys.stdin.detach() # type: ignore
|
||
|
sys.stdin = io.TextIOWrapper(input_buffer, opts.input_encoding, "replace")
|
||
|
|
||
|
def _print(output_unicode: str, end: str = "\n") -> None:
|
||
|
if hasattr(sys.stdout, "buffer"):
|
||
|
output_bytes = (output_unicode + end).encode(opts.output_encoding)
|
||
|
sys.stdout.buffer.write(output_bytes)
|
||
|
else:
|
||
|
sys.stdout.write(output_unicode + end)
|
||
|
|
||
|
# Produce only the headers and quit
|
||
|
if opts.headers:
|
||
|
_print(conv.produce_headers(), end="")
|
||
|
return
|
||
|
|
||
|
full = not bool(opts.partial or opts.inline)
|
||
|
output = conv.convert(
|
||
|
"".join(sys.stdin.readlines()), full=full, ensure_trailing_newline=True
|
||
|
)
|
||
|
_print(output, end="")
|