diff --git a/Dockerfile b/Dockerfile index 619772f..6db6007 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,9 @@ RUN .venv/bin/python -m nltk.downloader -d /usr/local/share/nltk_data stopwords COPY . . +# Let Graphviz find the bundled font for text in other scripts, like CJK +RUN mkdir -p /usr/local/share/fonts && ln -s /app/src/assets/fonts/NotoSansCJK-Bold.ttc /usr/local/share/fonts/ + ENV PATH="/app/.venv/bin:$PATH" VOLUME /app/logs diff --git a/src/assets/fonts/DejaVuSans-Bold-LICENSE.txt b/src/assets/fonts/DejaVuSans-Bold-LICENSE.txt deleted file mode 100644 index ce931a0..0000000 --- a/src/assets/fonts/DejaVuSans-Bold-LICENSE.txt +++ /dev/null @@ -1,51 +0,0 @@ -Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ -Upstream-Name: DejaVu fonts -Upstream-Author: Stepan Roh (original author), - see /usr/share/doc/fonts-dejavu-core/AUTHORS for full list -Source: https://dejavu-fonts.github.io/ - -Files: * -Copyright: Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. - Bitstream Vera is a trademark of Bitstream, Inc. - DejaVu changes are in public domain. -License: bitstream-vera - Permission is hereby granted, free of charge, to any person obtaining a copy - of the fonts accompanying this license ("Fonts") and associated - documentation files (the "Font Software"), to reproduce and distribute the - Font Software, including without limitation the rights to use, copy, merge, - publish, distribute, and/or sell copies of the Font Software, and to permit - persons to whom the Font Software is furnished to do so, subject to the - following conditions: - . - The above copyright and trademark notices and this permission notice shall - be included in all copies of one or more of the Font Software typefaces. - . - The Font Software may be modified, altered, or added to, and in particular - the designs of glyphs or characters in the Fonts may be modified and - additional glyphs or characters may be added to the Fonts, only if the fonts - are renamed to names not containing either the words "Bitstream" or the word - "Vera". - . - This License becomes null and void to the extent applicable to Fonts or Font - Software that has been modified and is distributed under the "Bitstream - Vera" names. - . - The Font Software may be sold as part of a larger software package but no - copy of one or more of the Font Software typefaces may be sold by itself. - . - THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS - OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, - TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME - FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING - ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, - WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF - THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE - FONT SOFTWARE. - . - Except as contained in this notice, the names of Gnome, the Gnome - Foundation, and Bitstream Inc., shall not be used in advertising or - otherwise to promote the sale, use or other dealings in this Font Software - without prior written authorization from the Gnome Foundation or Bitstream - Inc., respectively. For further information, contact: fonts at gnome dot - org. diff --git a/src/assets/fonts/DejaVuSans-Bold.ttf b/src/assets/fonts/DejaVuSans-Bold.ttf deleted file mode 100644 index 0a7fb3f..0000000 Binary files a/src/assets/fonts/DejaVuSans-Bold.ttf and /dev/null differ diff --git a/src/assets/fonts/NotoSansCJK-Bold-LICENSE.txt b/src/assets/fonts/NotoSansCJK-Bold-LICENSE.txt new file mode 100644 index 0000000..d952d62 --- /dev/null +++ b/src/assets/fonts/NotoSansCJK-Bold-LICENSE.txt @@ -0,0 +1,92 @@ +This Font Software is licensed under the SIL Open Font License, +Version 1.1. + +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/assets/fonts/NotoSansCJK-Bold.ttc b/src/assets/fonts/NotoSansCJK-Bold.ttc new file mode 100644 index 0000000..ce48b99 Binary files /dev/null and b/src/assets/fonts/NotoSansCJK-Bold.ttc differ diff --git a/src/assets/fonts/impact.ttf b/src/assets/fonts/impact.ttf deleted file mode 100644 index 2675688..0000000 Binary files a/src/assets/fonts/impact.ttf and /dev/null differ diff --git a/src/cogs/meme.py b/src/cogs/meme.py index b6d8514..1396a82 100644 --- a/src/cogs/meme.py +++ b/src/cogs/meme.py @@ -18,7 +18,6 @@ from moviepy import vfx from PIL import Image from PIL import ImageDraw from PIL import ImageEnhance -from PIL import ImageFont from PIL import UnidentifiedImageError from cogs.utils import discord_utils @@ -29,7 +28,6 @@ NIGHTCORE_SPEED = 1.33 # Resample first so the speed-up is the same whatever the source's sample rate is NIGHTCORE_FILTER = f"aresample=44100,asetrate=44100*{NIGHTCORE_SPEED},aresample=44100,bass=g=6:f=100:w=0.5,volume=0.8" MP3_BITRATES = [192, 160, 128, 112, 96, 80, 64] -CRAB_FONT = "./src/assets/fonts/DejaVuSans-Bold.ttf" class Meme(commands.Cog): @@ -149,9 +147,8 @@ class Meme(commands.Cog): template = cv2.imread("./src/assets/sivert_goodbad.jpg") # Draw text - font = "./src/assets/fonts/comic.ttf" - misc_utils.put_text_in_box(template, bad_text, (540, 0), (1080, 540), font) - misc_utils.put_text_in_box(template, good_text, (540, 540), (1080, 1080), font) + misc_utils.put_text_in_box(template, bad_text, (540, 0), (1080, 540)) + misc_utils.put_text_in_box(template, good_text, (540, 540), (1080, 1080)) # Save image to buffer _, buffer = cv2.imencode(".jpg", template) @@ -388,7 +385,7 @@ class Meme(commands.Cog): self.bot.logger.warning(f"Failed to remove temporary file {temp_file}") @staticmethod - def text_clip(text: str, font_size: int, stroke_width: int = 0) -> ImageClip: + def text_clip(text: str, font_size: int, stroke_width: int = 0, max_width: int | None = None) -> ImageClip: """ Render white text with a black outline as a transparent clip. moviepy's own TextClip cuts off everything below the tallest letters with Pillow 11.2 and newer, @@ -397,18 +394,27 @@ class Meme(commands.Cog): Parameters ---------- text (str): The text to render - font_size (int): The font size + font_size (int): The font size, unless the text has to be smaller to fit within max_width stroke_width (int): The width of the black outline + max_width (int | None): The widest the text can be in pixels. None means no limit Returns ---------- (ImageClip): The text with a transparent background """ - font = ImageFont.truetype(CRAB_FONT, font_size) - ascent, descent = font.getmetrics() + font = misc_utils.load_font(misc_utils.UNICODE_FONT, font_size) left, _, right, _ = font.getbbox(text, stroke_width=stroke_width, anchor="ls") + # The text is a single line, so shrink it until it fits. + # Scale by how much too wide it is to get close quickly, and step down at least one size each time + while max_width and right - left > max_width and font_size > 1: + font_size = max(1, min(font_size - 1, int(font_size * max_width / (right - left)))) + font = misc_utils.load_font(misc_utils.UNICODE_FONT, font_size) + left, _, right, _ = font.getbbox(text, stroke_width=stroke_width, anchor="ls") + + ascent, descent = font.getmetrics() + image = Image.new("RGBA", (right - left, ascent + descent + 2 * stroke_width), (0, 0, 0, 0)) ImageDraw.Draw(image).text( (-left, ascent + stroke_width), @@ -436,9 +442,18 @@ class Meme(commands.Cog): clip = VideoFileClip("./src/assets/crab.mp4") - top_part = Meme.text_clip(top_text, 60, stroke_width=2).with_position(("center", 300)) - middle_part = Meme.text_clip("____________________", 48).with_position(("center", "center")) - bottom_part = Meme.text_clip(bottom_text, 60, stroke_width=2).with_position(("center", 400)) + # Leave 5% of the video's width free on each side + max_width = int(clip.w * 0.9) + top_part = Meme.text_clip(top_text, 60, stroke_width=2, max_width=max_width) + # Long texts are shrunk to fit, so keep the top text on the baseline full-size text would have, + # right above the line, instead of letting it float up. Ascent scales with the font size + ascent, descent = misc_utils.load_font(misc_utils.UNICODE_FONT, 60).getmetrics() + top_ascent = round((top_part.h - 2 * 2) * ascent / (ascent + descent)) + top_part = top_part.with_position(("center", 300 + ascent - top_ascent)) + middle_part = Meme.text_clip("_" * 26, 48).with_position(("center", "center")) + bottom_part = Meme.text_clip(bottom_text, 60, stroke_width=2, max_width=max_width).with_position( + ("center", 400) + ) parts = [ part.with_start(11.0).with_duration(26.0).with_effects([vfx.CrossFadeIn(1)]) diff --git a/src/cogs/misc.py b/src/cogs/misc.py index 677d1a2..adffaaa 100644 --- a/src/cogs/misc.py +++ b/src/cogs/misc.py @@ -347,10 +347,12 @@ class Misc(commands.Cog): image.paste(user, (635, 94)) image.paste(heart, (311, 94), mask=mask) draw = ImageDraw.Draw(image) - font = ImageFont.truetype("./src/assets/fonts/comic.ttf", 86) - font_size = font.getbbox(f"{match_percent}%") - font_size = ((image.size[0] - font_size[2]) / 2, (image.size[1] - font_size[3]) / 2) - draw.text(font_size, f"{match_percent}%", font=font, fill=(255, 255, 255, 255)) + font = ImageFont.truetype(misc_utils.UNICODE_FONT, 86) + text = f"{match_percent}%" + # Center the drawn glyphs themselves, since the font's line height puts space above and below them + left, top, right, bottom = draw.textbbox((0, 0), text, font=font) + position = ((image.size[0] - (right - left)) / 2 - left, (image.size[1] - (bottom - top)) / 2 - top) + draw.text(position, text, font=font, fill=(255, 255, 255, 255)) # Save image output = BytesIO() diff --git a/src/cogs/user_facts.py b/src/cogs/user_facts.py index df95049..fc19e9d 100644 --- a/src/cogs/user_facts.py +++ b/src/cogs/user_facts.py @@ -408,6 +408,8 @@ class UserFacts(commands.Cog): graph.attr("graph", overlap="false", splines="True") graph.node_attr["style"] = "filled" graph.node_attr["shape"] = "circle" + # Display names can be in any script. The Dockerfile makes the bundled font available to Graphviz + graph.node_attr["fontname"] = "Noto Sans CJK JP" # Invoking user's node graph.node(str(user.id), f"{user.display_name}\n{mbti}", color="green") diff --git a/src/cogs/utils/misc_utils.py b/src/cogs/utils/misc_utils.py index 835e570..fb7ca62 100644 --- a/src/cogs/utils/misc_utils.py +++ b/src/cogs/utils/misc_utils.py @@ -1,8 +1,8 @@ import asyncio import datetime import functools +import re import subprocess -import textwrap from dataclasses import dataclass from math import ceil @@ -14,6 +14,8 @@ from PIL import Image from PIL import ImageDraw from PIL import ImageFont +UNICODE_FONT = "./src/assets/fonts/NotoSansCJK-Bold.ttc" + # Midnight in the bot's time zone. run.py sets it before loading the cogs MIDNIGHT = datetime.time(tzinfo=datetime.UTC) @@ -249,17 +251,92 @@ class Paginator: return self.get_page(self.current_page) +@functools.lru_cache(maxsize=256) +def load_font(font_path: str, font_size: int) -> ImageFont.FreeTypeFont: + """ + Load a font, reusing fonts that have already been loaded + + Parameters + ---------- + font_path (str): Path to the font file + font_size (int): The font size + + Returns + ---------- + (ImageFont.FreeTypeFont): The font + """ + + return ImageFont.truetype(font_path, font_size) + + +# Chinese, Japanese and Korean characters. Lines can break between any two of them +CJK_CHARACTER = re.compile(r"[\u2e80-\u9fff\uac00-\ud7af\uf900-\ufaff\uff00-\uffef]") + + +def wrap_text(text: str, font: ImageFont.FreeTypeFont, max_width: float, break_words: bool) -> str | None: + """ + Break text into lines that are at most max_width pixels wide. + Lines break at spaces, and between Chinese, Japanese and Korean characters + + Parameters + ---------- + text (str): The text to wrap + font (ImageFont.FreeTypeFont): The font the text will be drawn with + max_width (float): The maximum width of a line in pixels + break_words (bool): Whether to split words too wide for a line of their own between their letters + + Returns + ---------- + (str | None): The text with newlines added. None if a word is too wide and break_words is False + """ + + lines = [] + for paragraph in text.split("\n"): + # Pieces that can go on separate lines, with the separator to put before them on the same line + pieces = [] + for i, word in enumerate(paragraph.split(" ")): + separator = " " if i else "" + for piece in re.split(f"({CJK_CHARACTER.pattern})", word): + if piece or not pieces: + pieces.append((separator, piece)) + separator = "" + + line = "" + for separator, piece in pieces: + candidate = f"{line}{separator}{piece}" if line else piece + if font.getlength(candidate) <= max_width: + line = candidate + continue + + if line: + lines.append(line) + if font.getlength(piece) <= max_width: + line = piece + continue + if not break_words: + return None + + line = "" + for char in piece: + if line and font.getlength(line + char) > max_width: + lines.append(line) + line = "" + line += char + lines.append(line) + + return "\n".join(lines) + + def put_text_in_box( image: np.ndarray, text: str, top_left: tuple, bottom_right: tuple, - font_path: str = "./src/assets/fonts/impact.ttf", - font_size: int = 10, + font_path: str = UNICODE_FONT, color: tuple = (0, 0, 0), ): """ - Puts text inside a bounding box on the cv2 image with automatic text wrapping, using PIL for text rendering. + Puts text inside a bounding box on the cv2 image, wrapped and as large as it can be while still fitting Parameters ---------- @@ -267,8 +344,7 @@ def put_text_in_box( text (str): Text to be put on the image. top_left (tuple): (x, y) Top-left corner of the bounding box. bottom_right (tuple): (x, y) Bottom-right corner of the bounding box. - font_path (str): Path to the font file. Defaults to Impact font. - font_size (int): Initial font size. Defaults to 10. + font_path (str): Path to the font file. Defaults to Noto Sans CJK, which covers most scripts users write in. color (tuple): Text color in RGB. Defaults to (0, 0, 0). """ @@ -276,35 +352,44 @@ def put_text_in_box( image_pil = Image.fromarray(cv2.cvtColor(image, cv2.COLOR_BGR2RGB)) draw = ImageDraw.Draw(image_pil) - # Get coordinates of the bounding box + # Leave 5% padding on every side of the box x1, y1 = top_left x2, y2 = bottom_right - - # Get the width and height of the bounding box - # We multiply by 0.9 to give some padding w, h = (x2 - x1) * 0.9, (y2 - y1) * 0.9 - # Add newlines to the text. Here we have gone with 15 characters per line - # This is arbitrary, but seems to work well - text = textwrap.fill(text, width=15) + def layout(font_size: int, break_words: bool) -> tuple[ImageFont.FreeTypeFont, str, tuple] | None: + font = load_font(font_path, font_size) + wrapped = wrap_text(text, font, w, break_words) + if wrapped is None: + return None + return font, wrapped, draw.multiline_textbbox((0, 0), wrapped, font=font, align="center") - # Increase the font size as long as the next size still fits in the bounding box - # We also have to check the height, since the text might be too tall for the box - font = ImageFont.truetype(font_path, font_size) - while True: - next_font = ImageFont.truetype(font_path, font_size + 1) + def largest_fitting_size(break_words: bool) -> int: + # Larger fonts never fit better, so binary search for the largest size that fits. + # The font size can't usefully be larger than the box is tall. 0 means nothing fits + low, high = 0, max(1, int(h)) + while low < high: + middle = (low + high + 1) // 2 + result = layout(middle, break_words) + if result and result[2][2] - result[2][0] <= w and result[2][3] - result[2][1] <= h: + low = middle + else: + high = middle - 1 + return low - left, top, right, bottom = draw.multiline_textbbox((0, 0), text, font=next_font, align="center") + # Splitting words in the middle makes them hard to read, so only do it when keeping them whole + # would make the text less than half as big, like for one very long word + whole_words_size = largest_fitting_size(break_words=False) + split_words_size = largest_fitting_size(break_words=True) + if whole_words_size and whole_words_size * 2 >= split_words_size: + font, wrapped, (left, top, right, bottom) = layout(whole_words_size, break_words=False) + else: + font, wrapped, (left, top, right, bottom) = layout(max(1, split_words_size), break_words=True) - if right - left >= w or bottom - top >= h: - break - - font = next_font - font_size += 1 - - # Actually draw the text - center_of_box = ((x1 + x2) // 2, (y1 + y2) // 2) - draw.multiline_text(center_of_box, text, font=font, fill=color, align="center", anchor="mm") + # Center the drawn text itself in the box. Fonts leave space above and below the letters, + # so centering the line height would push short text like "a" off center + position = ((x1 + x2 - (right - left)) / 2 - left, (y1 + y2 - (bottom - top)) / 2 - top) + draw.multiline_text(position, wrapped, font=font, fill=color, align="center") # Convert back to cv2 image format and update the original image cv2_image = cv2.cvtColor(np.array(image_pil), cv2.COLOR_RGB2BGR)