1286 lines
41 KiB
Python
1286 lines
41 KiB
Python
# -*- coding: utf-8 -*-
|
|
|
|
# Copyright © 2008 Nicholas Marriott <nicholas.marriott@gmail.com>
|
|
# Copyright © 2016 Avi Halachmi <avihpit@yahoo.com>
|
|
# Copyright © 2018, 2019 Damir Jelić <poljar@termina.org.uk>
|
|
# Copyright © 2018, 2019 Denis Kasak <dkasak@termina.org.uk>
|
|
#
|
|
# Permission to use, copy, modify, and/or distribute this software for
|
|
# any purpose with or without fee is hereby granted, provided that the
|
|
# above copyright notice and this permission notice appear in all copies.
|
|
#
|
|
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
|
|
# SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER
|
|
# RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF
|
|
# CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
|
|
# CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
|
|
|
from __future__ import unicode_literals
|
|
|
|
import html
|
|
import re
|
|
import textwrap
|
|
|
|
# pylint: disable=redefined-builtin
|
|
from builtins import str
|
|
from collections import namedtuple
|
|
from typing import Dict, List, Optional, Union
|
|
|
|
import webcolors
|
|
from pygments import highlight
|
|
from pygments.formatter import Formatter, get_style_by_name
|
|
from pygments.lexers import get_lexer_by_name
|
|
from pygments.util import ClassNotFound
|
|
|
|
from . import globals as G
|
|
from .globals import W
|
|
from .utils import (string_strikethrough,
|
|
string_color_and_reset,
|
|
color_pair,
|
|
text_block,
|
|
colored_text_block)
|
|
|
|
try:
|
|
from HTMLParser import HTMLParser
|
|
except ImportError:
|
|
from html.parser import HTMLParser
|
|
|
|
|
|
class FormattedString:
|
|
__slots__ = ("text", "attributes")
|
|
|
|
def __init__(self, text, attributes):
|
|
self.attributes = DEFAULT_ATTRIBUTES.copy()
|
|
self.attributes.update(attributes)
|
|
self.text = text
|
|
|
|
|
|
class Formatted(object):
|
|
def __init__(self, substrings):
|
|
# type: (List[FormattedString]) -> None
|
|
self.substrings = substrings
|
|
|
|
def textwrapper(self, width, colors):
|
|
return textwrap.TextWrapper(
|
|
width=width,
|
|
initial_indent="{}> ".format(W.color(colors)),
|
|
subsequent_indent="{}> ".format(W.color(colors)),
|
|
)
|
|
|
|
def is_formatted(self):
|
|
# type: (Formatted) -> bool
|
|
for string in self.substrings:
|
|
if string.attributes != DEFAULT_ATTRIBUTES:
|
|
return True
|
|
return False
|
|
|
|
# TODO reverse video
|
|
@classmethod
|
|
def from_input_line(cls, line):
|
|
# type: (str) -> Formatted
|
|
"""Parses the weechat input line and produces formatted strings that
|
|
can be later converted to HTML or to a string for weechat's print
|
|
functions
|
|
"""
|
|
text = "" # type: str
|
|
substrings = [] # type: List[FormattedString]
|
|
attributes = DEFAULT_ATTRIBUTES.copy()
|
|
|
|
# If this is false, only IRC formatting characters will be parsed.
|
|
do_markdown = G.CONFIG.look.markdown_input
|
|
|
|
# Disallow backticks in URLs so that code blocks are unaffected by the
|
|
# URL handling
|
|
url_regex = r"\b[a-z]+://[^\s`]+"
|
|
|
|
# Escaped things are not markdown delimiters, so substitute them away
|
|
# when (quickly) looking for the last delimiters in the line.
|
|
# Additionally, URLs are ignored for the purposes of markdown
|
|
# delimiters.
|
|
# Note that the replacement needs to be the same length as the original
|
|
# for the indices to be correct.
|
|
escaped_masked = re.sub(
|
|
r"\\[\\*_`]|(?:" + url_regex + ")",
|
|
lambda m: "a" * len(m.group(0)),
|
|
line
|
|
)
|
|
|
|
def last_match_index(regex, offset_in_match):
|
|
matches = list(re.finditer(regex, escaped_masked))
|
|
return matches[-1].span()[0] + offset_in_match if matches else -1
|
|
|
|
# 'needs_word': whether the wrapper must surround words, for example
|
|
# '*italic*' and not '* not-italic *'.
|
|
# 'validate': whether it can occur within the current attributes
|
|
wrappers = {
|
|
"**": {
|
|
"key": "bold",
|
|
"last_index": last_match_index(r"\S\*\*", 1),
|
|
"needs_word": True,
|
|
"validate": lambda attrs: not attrs["code"],
|
|
},
|
|
"*": {
|
|
"key": "italic",
|
|
"last_index": last_match_index(r"\S\*($|[^*])", 1),
|
|
"needs_word": True,
|
|
"validate": lambda attrs: not attrs["code"],
|
|
},
|
|
"_": {
|
|
"key": "italic",
|
|
"last_index": last_match_index(r"\S_", 1),
|
|
"needs_word": True,
|
|
"validate": lambda attrs: not attrs["code"],
|
|
},
|
|
"`": {
|
|
"key": "code",
|
|
"last_index": last_match_index(r"`", 0),
|
|
"needs_word": False,
|
|
"validate": lambda attrs: True,
|
|
}
|
|
}
|
|
wrapper_init_chars = set(k[0] for k in wrappers.keys())
|
|
wrapper_max_len = max(len(k) for k in wrappers.keys())
|
|
|
|
irc_toggles = {
|
|
"\x02": "bold",
|
|
"\x1D": "italic",
|
|
"\x1F": "underline",
|
|
}
|
|
|
|
# Characters that consume a prefixed backslash
|
|
escapable_chars = wrapper_init_chars.copy()
|
|
escapable_chars.add("\\")
|
|
|
|
# Collect URL spans
|
|
url_spans = [m.span() for m in re.finditer(url_regex, line)]
|
|
url_spans.reverse() # we'll be popping from the end
|
|
|
|
# Whether we are currently in a URL
|
|
in_url = False
|
|
|
|
i = 0
|
|
while i < len(line):
|
|
# Update the 'in_url' flag. The first condition is not a while loop
|
|
# because URLs must contain '://', ensuring that we will not skip 2
|
|
# URLs in one iteration.
|
|
if url_spans and i >= url_spans[-1][1]:
|
|
in_url = False
|
|
url_spans.pop()
|
|
if url_spans and i >= url_spans[-1][0]:
|
|
in_url = True
|
|
|
|
# Markdown escape
|
|
if do_markdown and \
|
|
i + 1 < len(line) and line[i] == "\\" \
|
|
and (line[i + 1] in escapable_chars
|
|
if not attributes["code"]
|
|
else line[i + 1] == "`") \
|
|
and not in_url:
|
|
text += line[i + 1]
|
|
i = i + 2
|
|
|
|
# IRC bold/italic/underline
|
|
elif line[i] in irc_toggles and not attributes["code"]:
|
|
if text:
|
|
substrings.append(FormattedString(text, attributes.copy()))
|
|
text = ""
|
|
key = irc_toggles[line[i]]
|
|
attributes[key] = not attributes[key]
|
|
i = i + 1
|
|
|
|
# IRC reset
|
|
elif line[i] == "\x0F" and not attributes["code"]:
|
|
if text:
|
|
substrings.append(FormattedString(text, attributes.copy()))
|
|
text = ""
|
|
# Reset all the attributes
|
|
attributes = DEFAULT_ATTRIBUTES.copy()
|
|
i = i + 1
|
|
|
|
# IRC color
|
|
elif line[i] == "\x03" and not attributes["code"]:
|
|
if text:
|
|
substrings.append(FormattedString(text, attributes.copy()))
|
|
text = ""
|
|
i = i + 1
|
|
|
|
# check if it's a valid color, add it to the attributes
|
|
if line[i].isdigit():
|
|
color_string = line[i]
|
|
i = i + 1
|
|
|
|
if line[i].isdigit():
|
|
if color_string == "0":
|
|
color_string = line[i]
|
|
else:
|
|
color_string = color_string + line[i]
|
|
i = i + 1
|
|
|
|
attributes["fgcolor"] = color_line_to_weechat(color_string)
|
|
else:
|
|
attributes["fgcolor"] = None
|
|
|
|
# check if we have a background color
|
|
if line[i] == "," and line[i + 1].isdigit():
|
|
color_string = line[i + 1]
|
|
i = i + 2
|
|
|
|
if line[i].isdigit():
|
|
if color_string == "0":
|
|
color_string = line[i]
|
|
else:
|
|
color_string = color_string + line[i]
|
|
i = i + 1
|
|
|
|
attributes["bgcolor"] = color_line_to_weechat(color_string)
|
|
else:
|
|
attributes["bgcolor"] = None
|
|
|
|
# Markdown wrapper (emphasis/bold/code)
|
|
elif do_markdown and line[i] in wrapper_init_chars and not in_url:
|
|
for l in range(wrapper_max_len, 0, -1):
|
|
if i + l <= len(line) and line[i : i + l] in wrappers:
|
|
descriptor = wrappers[line[i : i + l]]
|
|
|
|
if not descriptor["validate"](attributes):
|
|
continue
|
|
|
|
if attributes[descriptor["key"]]:
|
|
# needs_word wrappers can only be turned off if
|
|
# preceded by non-whitespace
|
|
if (i >= 1 and not line[i - 1].isspace()) \
|
|
or not descriptor["needs_word"]:
|
|
if text:
|
|
# strip leading and trailing spaces and
|
|
# compress consecutive spaces in inline
|
|
# code blocks
|
|
if descriptor["key"] == "code":
|
|
text = re.sub(r"\s+", " ", text.strip())
|
|
substrings.append(
|
|
FormattedString(text, attributes.copy()))
|
|
text = ""
|
|
attributes[descriptor["key"]] = False
|
|
i = i + l
|
|
else:
|
|
text = text + line[i : i + l]
|
|
i = i + l
|
|
|
|
# Must have a chance of closing this, and needs_word
|
|
# wrappers must be followed by non-whitespace
|
|
elif descriptor["last_index"] >= i + l and \
|
|
(not line[i + l].isspace() or \
|
|
not descriptor["needs_word"]):
|
|
if text:
|
|
substrings.append(
|
|
FormattedString(text, attributes.copy()))
|
|
text = ""
|
|
attributes[descriptor["key"]] = True
|
|
i = i + l
|
|
|
|
else:
|
|
text = text + line[i : i + l]
|
|
i = i + l
|
|
|
|
break
|
|
|
|
else:
|
|
# No wrapper matched here (NOTE: cannot happen since all
|
|
# wrapper prefixes are also wrappers, but for completeness'
|
|
# sake)
|
|
text = text + line[i]
|
|
i = i + 1
|
|
|
|
# Normal text
|
|
else:
|
|
text = text + line[i]
|
|
i = i + 1
|
|
|
|
if text:
|
|
substrings.append(FormattedString(text, attributes))
|
|
|
|
return cls(substrings)
|
|
|
|
@classmethod
|
|
def from_html(cls, html):
|
|
# type: (str) -> Formatted
|
|
parser = MatrixHtmlParser()
|
|
parser.feed(html)
|
|
return cls(parser.get_substrings())
|
|
|
|
def to_html(self):
|
|
def add_attribute(string, name, value):
|
|
if name == "bold" and value:
|
|
return "{bold_on}{text}{bold_off}".format(
|
|
bold_on="<strong>", text=string, bold_off="</strong>"
|
|
)
|
|
if name == "italic" and value:
|
|
return "{italic_on}{text}{italic_off}".format(
|
|
italic_on="<em>", text=string, italic_off="</em>"
|
|
)
|
|
if name == "underline" and value:
|
|
return "{underline_on}{text}{underline_off}".format(
|
|
underline_on="<u>", text=string, underline_off="</u>"
|
|
)
|
|
if name == "strikethrough" and value:
|
|
return "{strike_on}{text}{strike_off}".format(
|
|
strike_on="<del>", text=string, strike_off="</del>"
|
|
)
|
|
if name == "quote" and value:
|
|
return "{quote_on}{text}{quote_off}".format(
|
|
quote_on="<blockquote>",
|
|
text=string,
|
|
quote_off="</blockquote>",
|
|
)
|
|
if name == "code" and value:
|
|
return "{code_on}{text}{code_off}".format(
|
|
code_on="<code>", text=string, code_off="</code>"
|
|
)
|
|
|
|
return string
|
|
|
|
def add_color(string, fgcolor, bgcolor):
|
|
fgcolor_string = ""
|
|
bgcolor_string = ""
|
|
|
|
if fgcolor:
|
|
fgcolor_string = " data-mx-color={}".format(
|
|
color_weechat_to_html(fgcolor)
|
|
)
|
|
|
|
if bgcolor:
|
|
bgcolor_string = " data-mx-bg-color={}".format(
|
|
color_weechat_to_html(bgcolor)
|
|
)
|
|
|
|
return "{color_on}{text}{color_off}".format(
|
|
color_on="<font{fg}{bg}>".format(
|
|
fg=fgcolor_string,
|
|
bg=bgcolor_string
|
|
),
|
|
text=string,
|
|
color_off="</font>",
|
|
)
|
|
|
|
def format_string(formatted_string):
|
|
text = formatted_string.text
|
|
attributes = formatted_string.attributes.copy()
|
|
|
|
# Escape HTML tag characters
|
|
text = text.replace("&", "&") \
|
|
.replace("<", "<") \
|
|
.replace(">", ">")
|
|
|
|
if attributes["code"]:
|
|
if attributes["preformatted"]:
|
|
# XXX: This can't really happen since there's no way of
|
|
# creating preformatted code blocks in weechat (because
|
|
# there is not multiline input), but I'm creating this
|
|
# branch as a note that it should be handled once we do
|
|
# implement them.
|
|
pass
|
|
else:
|
|
text = add_attribute(text, "code", True)
|
|
attributes.pop("code")
|
|
|
|
if attributes["fgcolor"] or attributes["bgcolor"]:
|
|
text = add_color(
|
|
text,
|
|
attributes["fgcolor"],
|
|
attributes["bgcolor"]
|
|
)
|
|
|
|
if attributes["fgcolor"]:
|
|
attributes.pop("fgcolor")
|
|
|
|
if attributes["bgcolor"]:
|
|
attributes.pop("bgcolor")
|
|
|
|
for key, value in attributes.items():
|
|
text = add_attribute(text, key, value)
|
|
|
|
return text
|
|
|
|
html_string = map(format_string, self.substrings)
|
|
return "".join(html_string)
|
|
|
|
# TODO do we want at least some formatting using unicode
|
|
# (strikethrough, quotes)?
|
|
def to_plain(self):
|
|
# type: () -> str
|
|
def strip_atribute(string, _, __):
|
|
return string
|
|
|
|
def format_string(formatted_string):
|
|
text = formatted_string.text
|
|
attributes = formatted_string.attributes
|
|
|
|
for key, value in attributes.items():
|
|
text = strip_atribute(text, key, value)
|
|
return text
|
|
|
|
plain_string = map(format_string, self.substrings)
|
|
return "".join(plain_string)
|
|
|
|
def to_weechat(self):
|
|
def add_attribute(string, name, value, attributes):
|
|
if not value:
|
|
return string
|
|
elif name == "bold":
|
|
return "{bold_on}{text}{bold_off}".format(
|
|
bold_on=W.color("bold"),
|
|
text=string,
|
|
bold_off=W.color("-bold"),
|
|
)
|
|
elif name == "italic":
|
|
return "{italic_on}{text}{italic_off}".format(
|
|
italic_on=W.color("italic"),
|
|
text=string,
|
|
italic_off=W.color("-italic"),
|
|
)
|
|
elif name == "underline":
|
|
return "{underline_on}{text}{underline_off}".format(
|
|
underline_on=W.color("underline"),
|
|
text=string,
|
|
underline_off=W.color("-underline"),
|
|
)
|
|
elif name == "strikethrough":
|
|
return string_strikethrough(string)
|
|
elif name == "quote":
|
|
quote_pair = color_pair(G.CONFIG.color.quote_fg,
|
|
G.CONFIG.color.quote_bg)
|
|
|
|
# Remove leading and trailing newlines; Riot sends an extra
|
|
# quoted "\n" when a user quotes a message.
|
|
string = string.strip("\n")
|
|
if len(string) == 0:
|
|
return string
|
|
|
|
if G.CONFIG.look.quote_wrap >= 0:
|
|
wrapper = self.textwrapper(G.CONFIG.look.quote_wrap, quote_pair)
|
|
return wrapper.fill(W.string_remove_color(string, ""))
|
|
else:
|
|
# Don't wrap, just add quote markers to all lines
|
|
return "{color_on}{text}{color_off}".format(
|
|
color_on=W.color(quote_pair),
|
|
text="> " + W.string_remove_color(string.replace("\n", "\n> "), ""),
|
|
color_off=W.color("resetcolor")
|
|
)
|
|
elif name == "code":
|
|
code_color_pair = color_pair(
|
|
G.CONFIG.color.untagged_code_fg,
|
|
G.CONFIG.color.untagged_code_bg
|
|
)
|
|
|
|
margin = G.CONFIG.look.code_block_margin
|
|
|
|
if attributes["preformatted"]:
|
|
# code block
|
|
|
|
try:
|
|
lexer = get_lexer_by_name(value)
|
|
except ClassNotFound:
|
|
if G.CONFIG.look.code_blocks:
|
|
return colored_text_block(
|
|
string,
|
|
margin=margin,
|
|
color_pair=code_color_pair)
|
|
else:
|
|
return string_color_and_reset(string,
|
|
code_color_pair)
|
|
|
|
try:
|
|
style = get_style_by_name(G.CONFIG.look.pygments_style)
|
|
except ClassNotFound:
|
|
style = "native"
|
|
|
|
if G.CONFIG.look.code_blocks:
|
|
code_block = text_block(string, margin=margin)
|
|
else:
|
|
code_block = string
|
|
|
|
# highlight adds a newline to the end of the string, remove
|
|
# it from the output
|
|
highlighted_code = highlight(
|
|
code_block,
|
|
lexer,
|
|
WeechatFormatter(style=style)
|
|
).rstrip()
|
|
|
|
return highlighted_code
|
|
else:
|
|
return string_color_and_reset(string, code_color_pair)
|
|
elif name == "fgcolor":
|
|
return "{color_on}{text}{color_off}".format(
|
|
color_on=W.color(value),
|
|
text=string,
|
|
color_off=W.color("resetcolor"),
|
|
)
|
|
elif name == "bgcolor":
|
|
return "{color_on}{text}{color_off}".format(
|
|
color_on=W.color("," + value),
|
|
text=string,
|
|
color_off=W.color("resetcolor"),
|
|
)
|
|
else:
|
|
return string
|
|
|
|
def format_string(formatted_string):
|
|
text = formatted_string.text
|
|
attributes = formatted_string.attributes
|
|
|
|
# We need to handle strikethrough first, since doing
|
|
# a strikethrough followed by other attributes succeeds in the
|
|
# terminal, but doing it the other way around results in garbage.
|
|
if "strikethrough" in attributes:
|
|
text = add_attribute(
|
|
text,
|
|
"strikethrough",
|
|
attributes["strikethrough"],
|
|
attributes
|
|
)
|
|
attributes.pop("strikethrough")
|
|
|
|
def indent(text, prefix):
|
|
return prefix + text.replace("\n", "\n{}".format(prefix))
|
|
|
|
for key, value in attributes.items():
|
|
if not value:
|
|
continue
|
|
|
|
# Don't use textwrap to quote the code
|
|
if key == "quote" and attributes["code"]:
|
|
continue
|
|
|
|
# Reflow inline code blocks
|
|
if key == "code" and not attributes["preformatted"]:
|
|
text = text.strip().replace('\n', ' ')
|
|
|
|
text = add_attribute(text, key, value, attributes)
|
|
|
|
# If we're quoted code add quotation marks now.
|
|
if key == "code" and attributes["quote"]:
|
|
fg = G.CONFIG.color.quote_fg
|
|
bg = G.CONFIG.color.quote_bg
|
|
text = indent(
|
|
text,
|
|
string_color_and_reset(">", color_pair(fg, bg)) + " ",
|
|
)
|
|
|
|
# If we're code don't remove multiple newlines blindly
|
|
if attributes["code"]:
|
|
return text
|
|
return re.sub(r"\n+", "\n", text)
|
|
|
|
weechat_strings = map(format_string, self.substrings)
|
|
|
|
# Remove duplicate \n elements from the list
|
|
strings = []
|
|
for string in weechat_strings:
|
|
if len(strings) == 0 or string != "\n" or string != strings[-1]:
|
|
strings.append(string)
|
|
|
|
return "".join(strings).strip()
|
|
|
|
|
|
DEFAULT_ATTRIBUTES = {
|
|
"bold": False,
|
|
"italic": False,
|
|
"underline": False,
|
|
"strikethrough": False,
|
|
"preformatted": False,
|
|
"quote": False,
|
|
"code": None,
|
|
"fgcolor": None,
|
|
"bgcolor": None,
|
|
} # type: Dict[str, Union[bool, Optional[str]]]
|
|
|
|
|
|
class MatrixHtmlParser(HTMLParser):
|
|
# TODO bullets
|
|
def __init__(self):
|
|
HTMLParser.__init__(self)
|
|
self.text = "" # type: str
|
|
self.substrings = [] # type: List[FormattedString]
|
|
self.attributes = DEFAULT_ATTRIBUTES.copy()
|
|
|
|
def unescape(self, text):
|
|
"""Shim to unescape HTML in both Python 2 and 3.
|
|
|
|
The instance method was deprecated in Python 3 and html.unescape
|
|
doesn't exist in Python 2 so this is needed.
|
|
"""
|
|
try:
|
|
return html.unescape(text)
|
|
except AttributeError:
|
|
return HTMLParser.unescape(self, text)
|
|
|
|
def add_substring(self, text, attrs):
|
|
fmt_string = FormattedString(text, attrs)
|
|
self.substrings.append(fmt_string)
|
|
|
|
def _toggle_attribute(self, attribute):
|
|
if self.text:
|
|
self.add_substring(self.text, self.attributes.copy())
|
|
self.text = ""
|
|
self.attributes[attribute] = not self.attributes[attribute]
|
|
|
|
def handle_starttag(self, tag, attrs):
|
|
if tag == "strong":
|
|
self._toggle_attribute("bold")
|
|
elif tag == "em":
|
|
self._toggle_attribute("italic")
|
|
elif tag == "u":
|
|
self._toggle_attribute("underline")
|
|
elif tag == "del":
|
|
self._toggle_attribute("strikethrough")
|
|
elif tag == "blockquote":
|
|
self._toggle_attribute("quote")
|
|
elif tag == "pre":
|
|
self._toggle_attribute("preformatted")
|
|
elif tag == "code":
|
|
lang = None
|
|
|
|
for key, value in attrs:
|
|
if key == "class":
|
|
if value.startswith("language-"):
|
|
lang = value.split("-", 1)[1]
|
|
|
|
lang = lang or "unknown"
|
|
|
|
if self.text:
|
|
self.add_substring(self.text, self.attributes.copy())
|
|
self.text = ""
|
|
self.attributes["code"] = lang
|
|
elif tag == "p":
|
|
if self.text:
|
|
self.add_substring(self.text, self.attributes.copy())
|
|
self.text = "\n"
|
|
self.add_substring(self.text, DEFAULT_ATTRIBUTES.copy())
|
|
self.text = ""
|
|
elif tag == "br":
|
|
if self.text:
|
|
self.add_substring(self.text, self.attributes.copy())
|
|
self.text = "\n"
|
|
self.add_substring(self.text, DEFAULT_ATTRIBUTES.copy())
|
|
self.text = ""
|
|
elif tag == "font":
|
|
for key, value in attrs:
|
|
if key in ["data-mx-color", "color"]:
|
|
color = color_html_to_weechat(value)
|
|
|
|
if not color:
|
|
continue
|
|
|
|
if self.text:
|
|
self.add_substring(self.text, self.attributes.copy())
|
|
self.text = ""
|
|
self.attributes["fgcolor"] = color
|
|
|
|
elif key in ["data-mx-bg-color"]:
|
|
color = color_html_to_weechat(value)
|
|
if not color:
|
|
continue
|
|
|
|
if self.text:
|
|
self.add_substring(self.text, self.attributes.copy())
|
|
self.text = ""
|
|
self.attributes["bgcolor"] = color
|
|
|
|
else:
|
|
pass
|
|
|
|
def handle_endtag(self, tag):
|
|
if tag == "strong":
|
|
self._toggle_attribute("bold")
|
|
elif tag == "em":
|
|
self._toggle_attribute("italic")
|
|
elif tag == "u":
|
|
self._toggle_attribute("underline")
|
|
elif tag == "del":
|
|
self._toggle_attribute("strikethrough")
|
|
elif tag == "pre":
|
|
self._toggle_attribute("preformatted")
|
|
elif tag == "code":
|
|
if self.text:
|
|
self.add_substring(self.text, self.attributes.copy())
|
|
self.text = ""
|
|
self.attributes["code"] = None
|
|
elif tag == "blockquote":
|
|
self._toggle_attribute("quote")
|
|
self.text = "\n"
|
|
self.add_substring(self.text, DEFAULT_ATTRIBUTES.copy())
|
|
self.text = ""
|
|
elif tag == "font":
|
|
if self.text:
|
|
self.add_substring(self.text, self.attributes.copy())
|
|
self.text = ""
|
|
self.attributes["fgcolor"] = None
|
|
else:
|
|
pass
|
|
|
|
def handle_data(self, data):
|
|
self.text += data
|
|
|
|
def handle_entityref(self, name):
|
|
self.text += self.unescape("&{};".format(name))
|
|
|
|
def handle_charref(self, name):
|
|
self.text += self.unescape("&#{};".format(name))
|
|
|
|
def get_substrings(self):
|
|
if self.text:
|
|
self.add_substring(self.text, self.attributes.copy())
|
|
|
|
return self.substrings
|
|
|
|
|
|
def color_line_to_weechat(color_string):
|
|
# type: (str) -> str
|
|
line_colors = {
|
|
"0": "white",
|
|
"1": "black",
|
|
"2": "blue",
|
|
"3": "green",
|
|
"4": "lightred",
|
|
"5": "red",
|
|
"6": "magenta",
|
|
"7": "brown",
|
|
"8": "yellow",
|
|
"9": "lightgreen",
|
|
"10": "cyan",
|
|
"11": "lightcyan",
|
|
"12": "lightblue",
|
|
"13": "lightmagenta",
|
|
"14": "darkgray",
|
|
"15": "gray",
|
|
"16": "52",
|
|
"17": "94",
|
|
"18": "100",
|
|
"19": "58",
|
|
"20": "22",
|
|
"21": "29",
|
|
"22": "23",
|
|
"23": "24",
|
|
"24": "17",
|
|
"25": "54",
|
|
"26": "53",
|
|
"27": "89",
|
|
"28": "88",
|
|
"29": "130",
|
|
"30": "142",
|
|
"31": "64",
|
|
"32": "28",
|
|
"33": "35",
|
|
"34": "30",
|
|
"35": "25",
|
|
"36": "18",
|
|
"37": "91",
|
|
"38": "90",
|
|
"39": "125",
|
|
"40": "124",
|
|
"41": "166",
|
|
"42": "184",
|
|
"43": "106",
|
|
"44": "34",
|
|
"45": "49",
|
|
"46": "37",
|
|
"47": "33",
|
|
"48": "19",
|
|
"49": "129",
|
|
"50": "127",
|
|
"51": "161",
|
|
"52": "196",
|
|
"53": "208",
|
|
"54": "226",
|
|
"55": "154",
|
|
"56": "46",
|
|
"57": "86",
|
|
"58": "51",
|
|
"59": "75",
|
|
"60": "21",
|
|
"61": "171",
|
|
"62": "201",
|
|
"63": "198",
|
|
"64": "203",
|
|
"65": "215",
|
|
"66": "227",
|
|
"67": "191",
|
|
"68": "83",
|
|
"69": "122",
|
|
"70": "87",
|
|
"71": "111",
|
|
"72": "63",
|
|
"73": "177",
|
|
"74": "207",
|
|
"75": "205",
|
|
"76": "217",
|
|
"77": "223",
|
|
"78": "229",
|
|
"79": "193",
|
|
"80": "157",
|
|
"81": "158",
|
|
"82": "159",
|
|
"83": "153",
|
|
"84": "147",
|
|
"85": "183",
|
|
"86": "219",
|
|
"87": "212",
|
|
"88": "16",
|
|
"89": "233",
|
|
"90": "235",
|
|
"91": "237",
|
|
"92": "239",
|
|
"93": "241",
|
|
"94": "244",
|
|
"95": "247",
|
|
"96": "250",
|
|
"97": "254",
|
|
"98": "231",
|
|
"99": "default",
|
|
}
|
|
|
|
assert color_string in line_colors
|
|
|
|
return line_colors[color_string]
|
|
|
|
|
|
# The functions color_dist_sq(), color_to_6cube(), and color_find_rgb
|
|
# are python ports of the same named functions from the tmux
|
|
# source, they are under the copyright of Nicholas Marriott, and Avi Halachmi
|
|
# under the ISC license.
|
|
# More info: https://github.com/tmux/tmux/blob/master/colour.c
|
|
|
|
|
|
def color_dist_sq(R, G, B, r, g, b):
|
|
# pylint: disable=invalid-name,too-many-arguments
|
|
# type: (int, int, int, int, int, int) -> int
|
|
return (R - r) * (R - r) + (G - g) * (G - g) + (B - b) * (B - b)
|
|
|
|
|
|
def color_to_6cube(v):
|
|
# pylint: disable=invalid-name
|
|
# type: (int) -> int
|
|
if v < 48:
|
|
return 0
|
|
if v < 114:
|
|
return 1
|
|
return (v - 35) // 40
|
|
|
|
|
|
def color_find_rgb(r, g, b):
|
|
# type: (int, int, int) -> int
|
|
"""Convert an RGB triplet to the xterm(1) 256 color palette.
|
|
|
|
xterm provides a 6x6x6 color cube (16 - 231) and 24 greys (232 - 255).
|
|
We map our RGB color to the closest in the cube, also work out the
|
|
closest grey, and use the nearest of the two.
|
|
|
|
Note that the xterm has much lower resolution for darker colors (they
|
|
are not evenly spread out), so our 6 levels are not evenly spread: 0x0,
|
|
0x5f (95), 0x87 (135), 0xaf (175), 0xd7 (215) and 0xff (255). Greys are
|
|
more evenly spread (8, 18, 28 ... 238).
|
|
"""
|
|
# pylint: disable=invalid-name
|
|
q2c = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff]
|
|
|
|
# Map RGB to 6x6x6 cube.
|
|
qr = color_to_6cube(r)
|
|
qg = color_to_6cube(g)
|
|
qb = color_to_6cube(b)
|
|
|
|
cr = q2c[qr]
|
|
cg = q2c[qg]
|
|
cb = q2c[qb]
|
|
|
|
# If we have hit the color exactly, return early.
|
|
if cr == r and cg == g and cb == b:
|
|
return 16 + (36 * qr) + (6 * qg) + qb
|
|
|
|
# Work out the closest grey (average of RGB).
|
|
grey_avg = (r + g + b) // 3
|
|
|
|
if grey_avg > 238:
|
|
grey_idx = 23
|
|
else:
|
|
grey_idx = (grey_avg - 3) // 10
|
|
|
|
grey = 8 + (10 * grey_idx)
|
|
|
|
# Is grey or 6x6x6 color closest?
|
|
d = color_dist_sq(cr, cg, cb, r, g, b)
|
|
|
|
if color_dist_sq(grey, grey, grey, r, g, b) < d:
|
|
idx = 232 + grey_idx
|
|
else:
|
|
idx = 16 + (36 * qr) + (6 * qg) + qb
|
|
|
|
return idx
|
|
|
|
|
|
def color_html_to_weechat(color):
|
|
# type: (str) -> str
|
|
# yapf: disable
|
|
weechat_basic_colors = {
|
|
(0, 0, 0): "black", # 0
|
|
(128, 0, 0): "red", # 1
|
|
(0, 128, 0): "green", # 2
|
|
(128, 128, 0): "brown", # 3
|
|
(0, 0, 128): "blue", # 4
|
|
(128, 0, 128): "magenta", # 5
|
|
(0, 128, 128): "cyan", # 6
|
|
(192, 192, 192): "default", # 7
|
|
(128, 128, 128): "gray", # 8
|
|
(255, 0, 0): "lightred", # 9
|
|
(0, 255, 0): "lightgreen", # 10
|
|
(255, 255, 0): "yellow", # 11
|
|
(0, 0, 255): "lightblue", # 12
|
|
(255, 0, 255): "lightmagenta", # 13
|
|
(0, 255, 255): "lightcyan", # 14
|
|
(255, 255, 255): "white", # 15
|
|
}
|
|
# yapf: enable
|
|
|
|
try:
|
|
rgb_color = webcolors.html5_parse_legacy_color(color)
|
|
except ValueError:
|
|
return ""
|
|
|
|
if rgb_color in weechat_basic_colors:
|
|
return weechat_basic_colors[rgb_color]
|
|
|
|
return str(color_find_rgb(*rgb_color))
|
|
|
|
|
|
def color_weechat_to_html(color):
|
|
# type: (str) -> str
|
|
# yapf: disable
|
|
weechat_basic_colors = {
|
|
"black": "0",
|
|
"red": "1",
|
|
"green": "2",
|
|
"brown": "3",
|
|
"blue": "4",
|
|
"magenta": "5",
|
|
"cyan": "6",
|
|
"default": "7",
|
|
"gray": "8",
|
|
"lightred": "9",
|
|
"lightgreen": "10",
|
|
"yellow": "11",
|
|
"lightblue": "12",
|
|
"lightmagenta": "13",
|
|
"lightcyan": "14",
|
|
"white": "15",
|
|
}
|
|
hex_colors = {
|
|
"0": "#000000",
|
|
"1": "#800000",
|
|
"2": "#008000",
|
|
"3": "#808000",
|
|
"4": "#000080",
|
|
"5": "#800080",
|
|
"6": "#008080",
|
|
"7": "#c0c0c0",
|
|
"8": "#808080",
|
|
"9": "#ff0000",
|
|
"10": "#00ff00",
|
|
"11": "#ffff00",
|
|
"12": "#0000ff",
|
|
"13": "#ff00ff",
|
|
"14": "#00ffff",
|
|
"15": "#ffffff",
|
|
"16": "#000000",
|
|
"17": "#00005f",
|
|
"18": "#000087",
|
|
"19": "#0000af",
|
|
"20": "#0000d7",
|
|
"21": "#0000ff",
|
|
"22": "#005f00",
|
|
"23": "#005f5f",
|
|
"24": "#005f87",
|
|
"25": "#005faf",
|
|
"26": "#005fd7",
|
|
"27": "#005fff",
|
|
"28": "#008700",
|
|
"29": "#00875f",
|
|
"30": "#008787",
|
|
"31": "#0087af",
|
|
"32": "#0087d7",
|
|
"33": "#0087ff",
|
|
"34": "#00af00",
|
|
"35": "#00af5f",
|
|
"36": "#00af87",
|
|
"37": "#00afaf",
|
|
"38": "#00afd7",
|
|
"39": "#00afff",
|
|
"40": "#00d700",
|
|
"41": "#00d75f",
|
|
"42": "#00d787",
|
|
"43": "#00d7af",
|
|
"44": "#00d7d7",
|
|
"45": "#00d7ff",
|
|
"46": "#00ff00",
|
|
"47": "#00ff5f",
|
|
"48": "#00ff87",
|
|
"49": "#00ffaf",
|
|
"50": "#00ffd7",
|
|
"51": "#00ffff",
|
|
"52": "#5f0000",
|
|
"53": "#5f005f",
|
|
"54": "#5f0087",
|
|
"55": "#5f00af",
|
|
"56": "#5f00d7",
|
|
"57": "#5f00ff",
|
|
"58": "#5f5f00",
|
|
"59": "#5f5f5f",
|
|
"60": "#5f5f87",
|
|
"61": "#5f5faf",
|
|
"62": "#5f5fd7",
|
|
"63": "#5f5fff",
|
|
"64": "#5f8700",
|
|
"65": "#5f875f",
|
|
"66": "#5f8787",
|
|
"67": "#5f87af",
|
|
"68": "#5f87d7",
|
|
"69": "#5f87ff",
|
|
"70": "#5faf00",
|
|
"71": "#5faf5f",
|
|
"72": "#5faf87",
|
|
"73": "#5fafaf",
|
|
"74": "#5fafd7",
|
|
"75": "#5fafff",
|
|
"76": "#5fd700",
|
|
"77": "#5fd75f",
|
|
"78": "#5fd787",
|
|
"79": "#5fd7af",
|
|
"80": "#5fd7d7",
|
|
"81": "#5fd7ff",
|
|
"82": "#5fff00",
|
|
"83": "#5fff5f",
|
|
"84": "#5fff87",
|
|
"85": "#5fffaf",
|
|
"86": "#5fffd7",
|
|
"87": "#5fffff",
|
|
"88": "#870000",
|
|
"89": "#87005f",
|
|
"90": "#870087",
|
|
"91": "#8700af",
|
|
"92": "#8700d7",
|
|
"93": "#8700ff",
|
|
"94": "#875f00",
|
|
"95": "#875f5f",
|
|
"96": "#875f87",
|
|
"97": "#875faf",
|
|
"98": "#875fd7",
|
|
"99": "#875fff",
|
|
"100": "#878700",
|
|
"101": "#87875f",
|
|
"102": "#878787",
|
|
"103": "#8787af",
|
|
"104": "#8787d7",
|
|
"105": "#8787ff",
|
|
"106": "#87af00",
|
|
"107": "#87af5f",
|
|
"108": "#87af87",
|
|
"109": "#87afaf",
|
|
"110": "#87afd7",
|
|
"111": "#87afff",
|
|
"112": "#87d700",
|
|
"113": "#87d75f",
|
|
"114": "#87d787",
|
|
"115": "#87d7af",
|
|
"116": "#87d7d7",
|
|
"117": "#87d7ff",
|
|
"118": "#87ff00",
|
|
"119": "#87ff5f",
|
|
"120": "#87ff87",
|
|
"121": "#87ffaf",
|
|
"122": "#87ffd7",
|
|
"123": "#87ffff",
|
|
"124": "#af0000",
|
|
"125": "#af005f",
|
|
"126": "#af0087",
|
|
"127": "#af00af",
|
|
"128": "#af00d7",
|
|
"129": "#af00ff",
|
|
"130": "#af5f00",
|
|
"131": "#af5f5f",
|
|
"132": "#af5f87",
|
|
"133": "#af5faf",
|
|
"134": "#af5fd7",
|
|
"135": "#af5fff",
|
|
"136": "#af8700",
|
|
"137": "#af875f",
|
|
"138": "#af8787",
|
|
"139": "#af87af",
|
|
"140": "#af87d7",
|
|
"141": "#af87ff",
|
|
"142": "#afaf00",
|
|
"143": "#afaf5f",
|
|
"144": "#afaf87",
|
|
"145": "#afafaf",
|
|
"146": "#afafd7",
|
|
"147": "#afafff",
|
|
"148": "#afd700",
|
|
"149": "#afd75f",
|
|
"150": "#afd787",
|
|
"151": "#afd7af",
|
|
"152": "#afd7d7",
|
|
"153": "#afd7ff",
|
|
"154": "#afff00",
|
|
"155": "#afff5f",
|
|
"156": "#afff87",
|
|
"157": "#afffaf",
|
|
"158": "#afffd7",
|
|
"159": "#afffff",
|
|
"160": "#d70000",
|
|
"161": "#d7005f",
|
|
"162": "#d70087",
|
|
"163": "#d700af",
|
|
"164": "#d700d7",
|
|
"165": "#d700ff",
|
|
"166": "#d75f00",
|
|
"167": "#d75f5f",
|
|
"168": "#d75f87",
|
|
"169": "#d75faf",
|
|
"170": "#d75fd7",
|
|
"171": "#d75fff",
|
|
"172": "#d78700",
|
|
"173": "#d7875f",
|
|
"174": "#d78787",
|
|
"175": "#d787af",
|
|
"176": "#d787d7",
|
|
"177": "#d787ff",
|
|
"178": "#d7af00",
|
|
"179": "#d7af5f",
|
|
"180": "#d7af87",
|
|
"181": "#d7afaf",
|
|
"182": "#d7afd7",
|
|
"183": "#d7afff",
|
|
"184": "#d7d700",
|
|
"185": "#d7d75f",
|
|
"186": "#d7d787",
|
|
"187": "#d7d7af",
|
|
"188": "#d7d7d7",
|
|
"189": "#d7d7ff",
|
|
"190": "#d7ff00",
|
|
"191": "#d7ff5f",
|
|
"192": "#d7ff87",
|
|
"193": "#d7ffaf",
|
|
"194": "#d7ffd7",
|
|
"195": "#d7ffff",
|
|
"196": "#ff0000",
|
|
"197": "#ff005f",
|
|
"198": "#ff0087",
|
|
"199": "#ff00af",
|
|
"200": "#ff00d7",
|
|
"201": "#ff00ff",
|
|
"202": "#ff5f00",
|
|
"203": "#ff5f5f",
|
|
"204": "#ff5f87",
|
|
"205": "#ff5faf",
|
|
"206": "#ff5fd7",
|
|
"207": "#ff5fff",
|
|
"208": "#ff8700",
|
|
"209": "#ff875f",
|
|
"210": "#ff8787",
|
|
"211": "#ff87af",
|
|
"212": "#ff87d7",
|
|
"213": "#ff87ff",
|
|
"214": "#ffaf00",
|
|
"215": "#ffaf5f",
|
|
"216": "#ffaf87",
|
|
"217": "#ffafaf",
|
|
"218": "#ffafd7",
|
|
"219": "#ffafff",
|
|
"220": "#ffd700",
|
|
"221": "#ffd75f",
|
|
"222": "#ffd787",
|
|
"223": "#ffd7af",
|
|
"224": "#ffd7d7",
|
|
"225": "#ffd7ff",
|
|
"226": "#ffff00",
|
|
"227": "#ffff5f",
|
|
"228": "#ffff87",
|
|
"229": "#ffffaf",
|
|
"230": "#ffffd7",
|
|
"231": "#ffffff",
|
|
"232": "#080808",
|
|
"233": "#121212",
|
|
"234": "#1c1c1c",
|
|
"235": "#262626",
|
|
"236": "#303030",
|
|
"237": "#3a3a3a",
|
|
"238": "#444444",
|
|
"239": "#4e4e4e",
|
|
"240": "#585858",
|
|
"241": "#626262",
|
|
"242": "#6c6c6c",
|
|
"243": "#767676",
|
|
"244": "#808080",
|
|
"245": "#8a8a8a",
|
|
"246": "#949494",
|
|
"247": "#9e9e9e",
|
|
"248": "#a8a8a8",
|
|
"249": "#b2b2b2",
|
|
"250": "#bcbcbc",
|
|
"251": "#c6c6c6",
|
|
"252": "#d0d0d0",
|
|
"253": "#dadada",
|
|
"254": "#e4e4e4",
|
|
"255": "#eeeeee"
|
|
}
|
|
|
|
# yapf: enable
|
|
if color in weechat_basic_colors:
|
|
return hex_colors[weechat_basic_colors[color]]
|
|
return hex_colors[color]
|
|
|
|
|
|
class WeechatFormatter(Formatter):
|
|
def __init__(self, **options):
|
|
Formatter.__init__(self, **options)
|
|
self.styles = {}
|
|
|
|
for token, style in self.style:
|
|
start = end = ""
|
|
if style["color"]:
|
|
start += "{}".format(
|
|
W.color(color_html_to_weechat(str(style["color"])))
|
|
)
|
|
end = "{}".format(W.color("resetcolor")) + end
|
|
if style["bold"]:
|
|
start += W.color("bold")
|
|
end = W.color("-bold") + end
|
|
if style["italic"]:
|
|
start += W.color("italic")
|
|
end = W.color("-italic") + end
|
|
if style["underline"]:
|
|
start += W.color("underline")
|
|
end = W.color("-underline") + end
|
|
self.styles[token] = (start, end)
|
|
|
|
def format(self, tokensource, outfile):
|
|
lastval = ""
|
|
lasttype = None
|
|
|
|
for ttype, value in tokensource:
|
|
while ttype not in self.styles:
|
|
ttype = ttype.parent
|
|
|
|
if ttype == lasttype:
|
|
lastval += value
|
|
else:
|
|
if lastval:
|
|
stylebegin, styleend = self.styles[lasttype]
|
|
outfile.write(stylebegin + lastval + styleend)
|
|
# set lastval/lasttype to current values
|
|
lastval = value
|
|
lasttype = ttype
|
|
|
|
if lastval:
|
|
stylebegin, styleend = self.styles[lasttype]
|
|
outfile.write(stylebegin + lastval + styleend)
|