Make saved mIRC files openable

This commit is contained in:
Isaiah Odhner 2023-07-11 18:16:25 -04:00
parent 645f1ec127
commit 0eea55e7f6

View File

@ -316,6 +316,25 @@ palette = [
"rgb(255,128,64)", "rgb(255,128,64)",
] ]
irc_palette: list[tuple[int, str, str, str]] = [
(0, "White", "rgb(255,255,255)", "#FFFFFF"),
(1, "Black", "rgb(0,0,0)", "#000000"),
(2, "Navy", "rgb(0,0,127)", "#00007F"),
(3, "Green", "rgb(0,147,0)", "#009300"),
(4, "Red", "rgb(255,0,0)", "#FF0000"),
(5, "Maroon", "rgb(127,0,0)", "#7F0000"),
(6, "Purple", "rgb(156,0,156)", "#9C009C"),
(7, "Orange", "rgb(252,127,0)", "#FC7F00"),
(8, "Yellow", "rgb(255,255,0)", "#FFFF00"),
(9, "Light Green", "rgb(0,252,0)", "#00FC00"),
(10, "Teal", "rgb(0,147,147)", "#009393"),
(11, "Cyan", "rgb(0,255,255)", "#00FFFF"),
(12, "Royal blue", "rgb(0,0,252)", "#0000FC"),
(13, "Magenta", "rgb(255,0,255)", "#FF00FF"),
(14, "Gray", "rgb(127,127,127)", "#7F7F7F"),
(15, "Light Gray", "rgb(210,210,210)", "#D2D2D2"),
]
class ToolsBox(Container): class ToolsBox(Container):
"""Widget containing tool buttons""" """Widget containing tool buttons"""
@ -866,24 +885,7 @@ class AnsiArtDocument:
renderable = self.get_renderable() renderable = self.get_renderable()
console = self.get_console(render_contents=False) console = self.get_console(render_contents=False)
segments = renderable.render(console=console) segments = renderable.render(console=console)
irc_palette = [
(0, "White", "rgb(255,255,255)", "#FFFFFF"),
(1, "Black", "rgb(0,0,0)", "#000000"),
(2, "Navy", "rgb(0,0,127)", "#00007F"),
(3, "Green", "rgb(0,147,0)", "#009300"),
(4, "Red", "rgb(255,0,0)", "#FF0000"),
(5, "Maroon", "rgb(127,0,0)", "#7F0000"),
(6, "Purple", "rgb(156,0,156)", "#9C009C"),
(7, "Orange", "rgb(252,127,0)", "#FC7F00"),
(8, "Yellow", "rgb(255,255,0)", "#FFFF00"),
(9, "Light Green", "rgb(0,252,0)", "#00FC00"),
(10, "Teal", "rgb(0,147,147)", "#009393"),
(11, "Cyan", "rgb(0,255,255)", "#00FFFF"),
(12, "Royal blue", "rgb(0,0,252)", "#0000FC"),
(13, "Magenta", "rgb(255,0,255)", "#FF00FF"),
(14, "Gray", "rgb(127,127,127)", "#7F7F7F"),
(15, "Light Gray", "rgb(210,210,210)", "#D2D2D2"),
]
def color_distance(a: Color, b: Color) -> float: def color_distance(a: Color, b: Color) -> float:
"""Perceptual color distance between two colors.""" """Perceptual color distance between two colors."""
# https://www.compuphase.com/cmetric.htm # https://www.compuphase.com/cmetric.htm
@ -903,6 +905,8 @@ class AnsiArtDocument:
closest_color = index closest_color = index
closest_distance = distance closest_distance = distance
return closest_color return closest_color
# TODO: simplify after converting to IRC colors, to remove unnecessary color codes
irc_text = "" irc_text = ""
for text, style, _ in Segment.filter_control( for text, style, _ in Segment.filter_control(
Segment.simplify(segments) Segment.simplify(segments)
@ -987,7 +991,84 @@ class AnsiArtDocument:
for x, char in enumerate(line): for x, char in enumerate(line):
document.ch[y][x] = char document.ch[y][x] = char
return document return document
@staticmethod
def from_irc(text: str, default_bg: str = "#ffffff", default_fg: str = "#000000") -> 'AnsiArtDocument':
"""Creates a document from text with mIRC codes."""
document = AnsiArtDocument(0, 0, default_bg, default_fg)
# Minimum size of 1x1, so that the document is never empty.
width = 1
height = 1
x = 0
y = 0
bg_color = default_bg
fg_color = default_fg
color_escape = "\x03"
color_escape_re = re.compile(r"\x03(\d{1,2})?(?:,(\d{1,2}))?")
reset_escape = "\x0F"
index = 0
while index < len(text):
char = text[index]
if char == color_escape:
match = color_escape_re.match(text[index:])
if match:
index += len(match.group(0))
bg_color = default_bg
fg_color = default_fg
if match.group(1):
fg_color = irc_palette[int(match.group(1))][3]
if match.group(2):
bg_color = irc_palette[int(match.group(2))][3]
continue
if char == reset_escape:
index += 1
bg_color = default_bg
fg_color = default_fg
continue
if char == "\n":
width = max(width, x)
x = 0
y += 1
height = max(height, y)
index += 1
continue
# Handle a single character, adding rows/columns as needed.
while len(document.ch) <= y:
document.ch.append([])
document.bg.append([])
document.fg.append([])
while len(document.ch[y]) <= x:
document.ch[y].append(' ')
document.bg[y].append(default_bg)
document.fg[y].append(default_fg)
document.ch[y][x] = char
document.bg[y][x] = bg_color
document.fg[y][x] = fg_color
width = max(x + 1, width)
height = max(y + 1, height)
x += 1
index += 1
document.width = width
document.height = height
# Handle minimum height.
while len(document.ch) <= document.height:
document.ch.append([])
document.bg.append([])
document.fg.append([])
# Pad rows to a consistent width.
for y in range(document.height):
for x in range(len(document.ch[y]), document.width):
document.ch[y].append(' ')
document.bg[y].append(default_bg)
document.fg[y].append(default_fg)
return document
@staticmethod @staticmethod
def from_ansi(text: str, default_bg: str = "#ffffff", default_fg: str = "#000000", max_width: int = 100000) -> 'AnsiArtDocument': def from_ansi(text: str, default_bg: str = "#ffffff", default_fg: str = "#000000", max_width: int = 100000) -> 'AnsiArtDocument':
"""Creates a document from the given ANSI text.""" """Creates a document from the given ANSI text."""
@ -1577,6 +1658,8 @@ class AnsiArtDocument:
return AnsiArtDocument.from_image_format(content) return AnsiArtDocument.from_image_format(content)
elif format_id == "ANSI": elif format_id == "ANSI":
return AnsiArtDocument.from_ansi(content.decode('utf-8'), default_bg, default_fg) return AnsiArtDocument.from_ansi(content.decode('utf-8'), default_bg, default_fg)
elif format_id == "IRC":
return AnsiArtDocument.from_irc(content.decode('utf-8'), default_bg, default_fg)
elif format_id == "PLAINTEXT": elif format_id == "PLAINTEXT":
return AnsiArtDocument.from_plain(content.decode('utf-8'), default_bg, default_fg) return AnsiArtDocument.from_plain(content.decode('utf-8'), default_bg, default_fg)
elif format_id == "SVG": elif format_id == "SVG":
@ -2955,20 +3038,16 @@ class PaintApp(App[None]):
# Note: image formats will lose any FOREGROUND color information. # Note: image formats will lose any FOREGROUND color information.
# This could be considered part of the text information, but could be mentioned. # This could be considered part of the text information, but could be mentioned.
# Also, it could be confusing if a file uses a lot of full block characters (█). # Also, it could be confusing if a file uses a lot of full block characters (█).
non_openable = format_id in ("HTML", "RICH_CONSOLE_MARKUP", "IRC") or (format_id in Image.SAVE and not format_id in Image.OPEN) non_openable = format_id in ("HTML", "RICH_CONSOLE_MARKUP") or (format_id in Image.SAVE and not format_id in Image.OPEN)
supports_text_and_color = format_id in ("ANSI", "SVG", "HTML", "RICH_CONSOLE_MARKUP", "IRC") supports_text_and_color = format_id in ("ANSI", "SVG", "HTML", "RICH_CONSOLE_MARKUP", "IRC")
if format_id == "PLAINTEXT": # Note: "IRC" format supports text and color, but only limited colors,
# so it still needs a warning.
if format_id in ["PLAINTEXT", "IRC"]:
self.confirm_lose_color_information(lambda: callback(True)) self.confirm_lose_color_information(lambda: callback(True))
elif format_id in SAVE_DISABLED_FORMATS: elif format_id in SAVE_DISABLED_FORMATS:
# We will show an error when attempting to encode. # We will show an error when attempting to encode.
# Any warning here would just be annoying preamble to the error. # Any warning here would just be annoying preamble to the error.
callback(False) callback(False)
elif format_id == "IRC":
# mIRC codes support only a limited color palette, so warn about color loss.
# Don't reload the file, because it's not openable.
# TODO: make it openable, it's the only way to preview the color loss properly,
# and would be nice anyway.
self.confirm_save_non_openable_file(lambda: self.confirm_lose_color_information(lambda: callback(False)))
elif supports_text_and_color: elif supports_text_and_color:
# This is handled before Pillow's image formats, so that bespoke format support overrides Pillow. # This is handled before Pillow's image formats, so that bespoke format support overrides Pillow.
if non_openable: if non_openable: