diff --git a/src/bot/cogs/features/_latex_utilities.py b/src/bot/cogs/features/_latex_utilities.py new file mode 100644 index 0000000..c97a6e6 --- /dev/null +++ b/src/bot/cogs/features/_latex_utilities.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import logging +from pathlib import Path +from typing import TYPE_CHECKING + +import discord + +if TYPE_CHECKING: + from .latex import LaTeX + +logger = logging.getLogger(__name__) + + +class LatexEditModal(discord.ui.Modal, title="Edit LaTeX"): + equation = discord.ui.TextInput( + label="LaTeX equation", + style=discord.TextStyle.paragraph, + required=True, + ) + text_color = discord.ui.TextInput(label="Text color", required=False) + bg_color = discord.ui.TextInput(label="Background color", required=False) + dpi = discord.ui.TextInput(label="DPI", required=False) + + def __init__(self, view: "LatexView"): + super().__init__() + + self.view = view + + self.equation.default = view.equation + self.text_color.default = view.text_color + self.bg_color.default = view.bg_color + self.dpi.default = str(view.dpi) + + async def on_submit(self, interaction: discord.Interaction): + self.view.equation = self.equation.value + + if self.text_color.value: + self.view.text_color = self.text_color.value + + if self.bg_color.value: + self.view.bg_color = self.bg_color.value + + if self.dpi.value: + try: + self.view.dpi = int(self.dpi.value) + except ValueError: + logger.exception( + "%s input a non-number value for DIP in the /latex command.", + interaction.user.display_name, + ) + + await interaction.response.defer(ephemeral=True) + + embed, file = await self.view.render() + + await interaction.edit_original_response( + embed=embed, + attachments=[file] if file else [], + view=self.view, + ) + + +class LatexView(discord.ui.View): + def __init__( + self, + latex: LaTeX, + interaction: discord.Interaction, + equation: str, + bg_color: str, + text_color: str, + dpi: int, + ): + super().__init__(timeout=300) + + self.latex = latex + self.interaction = interaction + self.equation = equation + self.bg_color = bg_color + self.text_color = text_color + self.dpi = dpi + + async def render(self) -> tuple[discord.Embed, discord.File | None]: + embed = await self.latex.render( + self.equation, + self.bg_color, + self.text_color, + self.dpi, + ) + + if embed.image.url and await self.latex.latex_available(embed.image.url): + return embed, None + + invalid = discord.File( + Path.cwd().joinpath("src", "bot", "static", "images", "invalid.png"), + filename="invalid.png", + ) + embed.set_image(url="attachment://invalid.png") + return embed, invalid + + @discord.ui.button(label="Edit", emoji="✏️", style=discord.ButtonStyle.primary) + async def edit( + self, interaction: discord.Interaction, button: discord.ui.Button + ) -> None: + await interaction.response.send_modal(LatexEditModal(self)) + + @discord.ui.button(label="Send", emoji="✅", style=discord.ButtonStyle.success) + async def publish( + self, interaction: discord.Interaction, button: discord.ui.Button + ) -> None: + await interaction.response.defer(ephemeral=True) + + embed = await self.latex.render( + self.equation, + self.bg_color, + self.text_color, + self.dpi, + ) + + if embed.image.url is None or not await self.latex.latex_available( + embed.image.url + ): + await interaction.followup.send( + content="Failed to display the LaTeX equation.", + ephemeral=True, + ) + return + + await interaction.followup.send(embed=embed) + + for child in self.children: + if isinstance(child, discord.ui.Button): + child.disabled = True + + await self.interaction.edit_original_response(view=self) diff --git a/src/bot/cogs/features/latex.py b/src/bot/cogs/features/latex.py index 4977c05..3d85abd 100644 --- a/src/bot/cogs/features/latex.py +++ b/src/bot/cogs/features/latex.py @@ -12,6 +12,7 @@ from src.bot.cogs import BaseCog from src.bot.cogs.constants import LATEX_COLORS +from src.bot.cogs.features._latex_utilities import LatexView logger = logging.getLogger(__name__) @@ -54,6 +55,8 @@ async def cog_unload(self) -> None: @app_commands.command(description="Show the available LaTeX colors.") async def latex_colors(self, interaction: discord.Interaction) -> None: + logger.info("%s used the /latex_colors command.", interaction.user.display_name) + file = discord.File( Path.cwd().joinpath("src", "bot", "static", "images", "colors.png"), filename="colors.png", @@ -66,52 +69,45 @@ async def latex_colors(self, interaction: discord.Interaction) -> None: await interaction.response.send_message(file=file, embed=embed, ephemeral=True) async def latex_available(self, image_path: str) -> bool: + """ + Check whether the CodeCogs JSON response reports a valid equation. + """ try: if not self.session: return False - async with self.session.get(image_path) as response: - return response.status == 200 - except aiohttp.ClientError: - logger.exception("Failed to fetch LaTeX image from CodeCogs") + + json_path = image_path.replace( + "png.image?", + "png.json?", + 1, + ) + + async with self.session.get(json_path) as response: + if response.status != 200: + return False + data = await response.json(content_type="text/json") + + return data["latex"]["valid"] + + except (aiohttp.ClientError, KeyError, TypeError, ValueError): + logger.exception("Failed to validate LaTeX equation with CodeCogs") return False def normalize_color(self, value: str, default: str) -> str: normalized = value.strip().replace(" ", "").lower() return LATEX_COLORS.get(normalized, default) - @app_commands.command() - async def latex( - self, - interaction: discord.Interaction, - equation: str, - *, - bg_color: str = "Black", - txt_color: str = "White", - dpi: int = 300, - ) -> None: - """ - Send a mathematical equation using LaTeX. - - Parameters - ---------- - equation : LaTeX equation (do not include `$` delimiters). - bg_color : Background color. See `/latex_colors` for available colors. - txt_color : Text color. See `/latex_colors` for available colors. - dpi : Image resolution in DPI (200–500). - """ - logger.info( - "%s used the %s command.", - interaction.user.display_name, - interaction.command, - ) - - # check for valid user specified color for text and background + async def render( + self, equation: str, bg_color: str, txt_color: str, dpi: int + ) -> discord.Embed: + # Check for valid user specified color for text and background. bg_color = self.normalize_color(bg_color, "Black") txt_color = self.normalize_color(txt_color, "White") + if txt_color == bg_color: txt_color = "White" if bg_color == "Black" else "Black" - # handle necessary character replacements + # Handle necessary character replacements. character_replacements = { "\n": r"\n", "\r": r"\r", @@ -120,17 +116,13 @@ async def latex( "\x0c": r"\f", "\x0b": r"\v", "\x07": r"\a", - "%": r"\%", - "&": r"\&", - "#": r"%23", + "#": "&hash;", " ": "&space;", } + for old, new in character_replacements.items(): + equation = equation.replace(old, new) - for e, c in character_replacements.items(): - equation = equation.replace(e, c) - - # prevent user specified dpi from being too big/small - dpi = max(200, min(dpi, 500)) + dpi = max(200, min(dpi, 800)) image_path = ( rf"https://latex.codecogs.com/png.image?" @@ -141,18 +133,52 @@ async def latex( rf"\colorbox{{{bg_color}}}{{${equation}$}}" ) - if await self.latex_available(image_path): - embed = embed_info( - "LaTeX", - equation.replace("&space;", " "), - image_path, - ) - # Send the embed with the image - await interaction.response.send_message(embed=embed) - else: - await interaction.response.send_message( - f"Failed to fetch the image ({image_path}) from the API", ephemeral=True - ) + return embed_info( + "LaTeX", + equation.replace("&space;", " "), + image_path, + ) + + @app_commands.command() + async def latex( + self, + interaction: discord.Interaction, + equation: str, + *, + bg_color: str = "Black", + txt_color: str = "White", + dpi: int = 300, + ) -> None: + """ + Send a mathematical equation using LaTeX. + + Parameters + ---------- + equation : LaTeX equation (do not include `$` delimiters). + bg_color : Background color. See `/latex_colors` for available colors. + txt_color : Text color. See `/latex_colors` for available colors. + dpi : Image resolution in DPI (200–800). + """ + logger.info("%s used the /latex command.", interaction.user.display_name) + + view = LatexView( + self, + interaction, + equation, + bg_color, + txt_color, + dpi, + ) + + await interaction.response.defer(ephemeral=True) + + embed, file = await view.render() + + await interaction.edit_original_response( + embed=embed, + attachments=[file] if file else [], + view=view, + ) async def setup(bot: commands.Bot) -> None: diff --git a/src/bot/static/images/invalid.png b/src/bot/static/images/invalid.png new file mode 100644 index 0000000..dd70902 Binary files /dev/null and b/src/bot/static/images/invalid.png differ