diff --git a/.gitignore b/.gitignore index ee8e4fc..d8dc0a7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Singletons .env /__init__.py +.table.png # IDE config folders .idea diff --git a/requirements.txt b/requirements.txt index b861505..05143d8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,6 @@ bottle==0.12.19 +Pillow~=9.0.1 python-dotenv==0.19.2 slackclient==2.9.3 sentry-sdk[bottle]==0.16.2 +tabulate~=0.8.9 diff --git a/slack_bot/messenger.py b/slack_bot/messenger.py index c130e5c..389b42a 100644 --- a/slack_bot/messenger.py +++ b/slack_bot/messenger.py @@ -59,7 +59,6 @@ def compose_message(event: GitHubEvent) -> tuple[str, str | None]: message: str = "" details: str | None = None - # TODO: Beautify messages. if event.type == EventType.BRANCH_CREATED: message = f"Branch created by {event.user}: `{event.ref}`" elif event.type == EventType.BRANCH_DELETED: diff --git a/utils/font-style.ttf b/utils/font-style.ttf new file mode 100644 index 0000000..67bbd42 Binary files /dev/null and b/utils/font-style.ttf differ diff --git a/utils/table.py b/utils/table.py new file mode 100644 index 0000000..daf0d47 --- /dev/null +++ b/utils/table.py @@ -0,0 +1,107 @@ +""" +Contains the `Table` class, to convert an iterable into an image of a table. +""" + +from math import ceil + +from PIL import ImageFont, Image, ImageDraw, ImageChops +from tabulate import tabulate + +MARGIN_PIXELS = 2 +IMAGE_PATH = "../.table.png" + + +class Table: + """ + Wrapper for a single method (`iter_to_image`), for consistency's sake only. + """ + + @staticmethod + def data_to_image(headers: list[str], rows: list[list[str]]): + """ + Converts the passed iterable into an image of a table and saves it in the `.table` file. + """ + lines = Table._data_to_table(headers, rows) + Table._table_to_image(lines) + + @staticmethod + def _data_to_table(headers: list[str], rows: list[list[str]]) -> list[str]: + """ + Uses the `tabulate` package to create a table using ASCII characters. + :param headers: List of headers of the table. + :param rows: Row-wise data in the table. + :return: ASCII table as a list of lines. + """ + table_string = tabulate( + rows, + headers=headers, + tablefmt="grid", + ) + return table_string.splitlines() + + @staticmethod + def _table_to_image(lines: list[str]) -> None: + """ + Draws the passed ASCII table using `Pillow` and save to `IMAGE_PATH`. + :param lines: ASCII table as a list of lines. + """ + font = ImageFont.truetype(font="font-style.ttf", size=40) + + image, draw, max_line_height = Table._draw_background(lines, font) + Table._draw_text(draw, lines, font, max_line_height) + Table._save(image) + + @staticmethod + def _draw_background( + lines: list[str], + font: ImageFont, + ) -> tuple[Image, ImageDraw, int]: + # Calculate dimensions + tallest_line = max(lines, key=lambda line: font.getsize(line)[1]) + widest_line = max(lines, key=lambda line: font.getsize(line)[0]) + max_line_height, max_line_width = ( + Table._fpt_to_px(font.getsize(tallest_line)[1]), + Table._fpt_to_px(font.getsize(widest_line)[0]), + ) + image_height = ceil(max_line_height * 0.8 * len(lines) + 2 * MARGIN_PIXELS) + image_width = ceil(max_line_width + 2 * MARGIN_PIXELS) + + # Draw the background + background_color = 255 + image = Image.new("L", (image_width, image_height), color=background_color) + draw = ImageDraw.Draw(image) + + return image, draw, max_line_height + + @staticmethod + def _draw_text( + draw: ImageDraw, + lines: list[str], + font: ImageFont, + max_line_height: int, + ): + for i, line in enumerate(lines): + draw.text( + (MARGIN_PIXELS, round(MARGIN_PIXELS + i * max_line_height * 0.8)), + line, + fill=0, + font=font, + ) + + @staticmethod + def _fpt_to_px(points: int) -> int: + """ + Converts font points to pixels + :param points: Font points value + :return: Pixels value + """ + return round(points * 96 / 72) + + @staticmethod + def _save(image: Image): + background = image.getpixel((0, 0)) + border = Image.new("L", image.size, background) + diff = ImageChops.difference(image, border) + bbox = diff.getbbox() + image = image.crop(bbox) if bbox else image + image.save(IMAGE_PATH)