render user text with noto sans cjk and fit it properly

Draw text that users write with Noto Sans CJK Bold so Chinese, Japanese,
Korean, Cyrillic and Greek no longer show up as boxes. It replaces
DejaVu Sans and Impact, and Comic Sans everywhere but the CS2 bingo.
The Dockerfile makes the font available to Graphviz for /mbti.

put_text_in_box now finds the largest font size where the text, wrapped
to the box's pixel width, fits, and centers the drawn text. Long texts
used to overflow and single letters were pushed out of their box.
Words are only split when keeping them whole would halve the size.

Long crab rave texts shrink to fit the video instead of going past its
edges, and the divider line is a bit longer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
leanderandClaude Opus 5.5 committed 2026-11-01 20:28:00 +01:00
1 parent 14c3caedf5
commit 154d4de881
10 files changed
+243 -95

No files matched your search

+3
View File
@@ -16,6 +16,9 @@ RUN .venv/bin/python -m nltk.downloader -d /usr/local/share/nltk_data stopwords
COPY . . 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" ENV PATH="/app/.venv/bin:$PATH"
VOLUME /app/logs VOLUME /app/logs
@@ -1,51 +0,0 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: DejaVu fonts
Upstream-Author: Stepan Roh <src@users.sourceforge.net> (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.
Binary file not shown.
@@ -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.
Binary file not shown.
Binary file not shown.
+27 -12
View File
@@ -18,7 +18,6 @@ from moviepy import vfx
from PIL import Image from PIL import Image
from PIL import ImageDraw from PIL import ImageDraw
from PIL import ImageEnhance from PIL import ImageEnhance
from PIL import ImageFont
from PIL import UnidentifiedImageError from PIL import UnidentifiedImageError
from cogs.utils import discord_utils 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 # 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" 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] MP3_BITRATES = [192, 160, 128, 112, 96, 80, 64]
CRAB_FONT = "./src/assets/fonts/DejaVuSans-Bold.ttf"
class Meme(commands.Cog): class Meme(commands.Cog):
@@ -149,9 +147,8 @@ class Meme(commands.Cog):
template = cv2.imread("./src/assets/sivert_goodbad.jpg") template = cv2.imread("./src/assets/sivert_goodbad.jpg")
# Draw text # Draw text
font = "./src/assets/fonts/comic.ttf" misc_utils.put_text_in_box(template, bad_text, (540, 0), (1080, 540))
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))
misc_utils.put_text_in_box(template, good_text, (540, 540), (1080, 1080), font)
# Save image to buffer # Save image to buffer
_, buffer = cv2.imencode(".jpg", template) _, 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}") self.bot.logger.warning(f"Failed to remove temporary file {temp_file}")
@staticmethod @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. 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, 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 Parameters
---------- ----------
text (str): The text to render 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 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 Returns
---------- ----------
(ImageClip): The text with a transparent background (ImageClip): The text with a transparent background
""" """
font = ImageFont.truetype(CRAB_FONT, font_size) font = misc_utils.load_font(misc_utils.UNICODE_FONT, font_size)
ascent, descent = font.getmetrics()
left, _, right, _ = font.getbbox(text, stroke_width=stroke_width, anchor="ls") 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)) image = Image.new("RGBA", (right - left, ascent + descent + 2 * stroke_width), (0, 0, 0, 0))
ImageDraw.Draw(image).text( ImageDraw.Draw(image).text(
(-left, ascent + stroke_width), (-left, ascent + stroke_width),
@@ -436,9 +442,18 @@ class Meme(commands.Cog):
clip = VideoFileClip("./src/assets/crab.mp4") clip = VideoFileClip("./src/assets/crab.mp4")
top_part = Meme.text_clip(top_text, 60, stroke_width=2).with_position(("center", 300)) # Leave 5% of the video's width free on each side
middle_part = Meme.text_clip("____________________", 48).with_position(("center", "center")) max_width = int(clip.w * 0.9)
bottom_part = Meme.text_clip(bottom_text, 60, stroke_width=2).with_position(("center", 400)) 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 = [ parts = [
part.with_start(11.0).with_duration(26.0).with_effects([vfx.CrossFadeIn(1)]) part.with_start(11.0).with_duration(26.0).with_effects([vfx.CrossFadeIn(1)])
+6 -4
View File
@@ -347,10 +347,12 @@ class Misc(commands.Cog):
image.paste(user, (635, 94)) image.paste(user, (635, 94))
image.paste(heart, (311, 94), mask=mask) image.paste(heart, (311, 94), mask=mask)
draw = ImageDraw.Draw(image) draw = ImageDraw.Draw(image)
font = ImageFont.truetype("./src/assets/fonts/comic.ttf", 86) font = ImageFont.truetype(misc_utils.UNICODE_FONT, 86)
font_size = font.getbbox(f"{match_percent}%") text = f"{match_percent}%"
font_size = ((image.size[0] - font_size[2]) / 2, (image.size[1] - font_size[3]) / 2) # Center the drawn glyphs themselves, since the font's line height puts space above and below them
draw.text(font_size, f"{match_percent}%", font=font, fill=(255, 255, 255, 255)) 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 # Save image
output = BytesIO() output = BytesIO()
+2
View File
@@ -408,6 +408,8 @@ class UserFacts(commands.Cog):
graph.attr("graph", overlap="false", splines="True") graph.attr("graph", overlap="false", splines="True")
graph.node_attr["style"] = "filled" graph.node_attr["style"] = "filled"
graph.node_attr["shape"] = "circle" 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 # Invoking user's node
graph.node(str(user.id), f"{user.display_name}\n{mbti}", color="green") graph.node(str(user.id), f"{user.display_name}\n{mbti}", color="green")
+113 -28
View File
@@ -1,8 +1,8 @@
import asyncio import asyncio
import datetime import datetime
import functools import functools
import re
import subprocess import subprocess
import textwrap
from dataclasses import dataclass from dataclasses import dataclass
from math import ceil from math import ceil
@@ -14,6 +14,8 @@ from PIL import Image
from PIL import ImageDraw from PIL import ImageDraw
from PIL import ImageFont 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 in the bot's time zone. run.py sets it before loading the cogs
MIDNIGHT = datetime.time(tzinfo=datetime.UTC) MIDNIGHT = datetime.time(tzinfo=datetime.UTC)
@@ -249,17 +251,92 @@ class Paginator:
return self.get_page(self.current_page) 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( def put_text_in_box(
image: np.ndarray, image: np.ndarray,
text: str, text: str,
top_left: tuple, top_left: tuple,
bottom_right: tuple, bottom_right: tuple,
font_path: str = "./src/assets/fonts/impact.ttf", font_path: str = UNICODE_FONT,
font_size: int = 10,
color: tuple = (0, 0, 0), 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 Parameters
---------- ----------
@@ -267,8 +344,7 @@ def put_text_in_box(
text (str): Text to be put on the image. text (str): Text to be put on the image.
top_left (tuple): (x, y) Top-left corner of the bounding box. top_left (tuple): (x, y) Top-left corner of the bounding box.
bottom_right (tuple): (x, y) Bottom-right 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_path (str): Path to the font file. Defaults to Noto Sans CJK, which covers most scripts users write in.
font_size (int): Initial font size. Defaults to 10.
color (tuple): Text color in RGB. Defaults to (0, 0, 0). 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)) image_pil = Image.fromarray(cv2.cvtColor(image, cv2.COLOR_BGR2RGB))
draw = ImageDraw.Draw(image_pil) draw = ImageDraw.Draw(image_pil)
# Get coordinates of the bounding box # Leave 5% padding on every side of the box
x1, y1 = top_left x1, y1 = top_left
x2, y2 = bottom_right 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 w, h = (x2 - x1) * 0.9, (y2 - y1) * 0.9
# Add newlines to the text. Here we have gone with 15 characters per line def layout(font_size: int, break_words: bool) -> tuple[ImageFont.FreeTypeFont, str, tuple] | None:
# This is arbitrary, but seems to work well font = load_font(font_path, font_size)
text = textwrap.fill(text, width=15) 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 def largest_fitting_size(break_words: bool) -> int:
# We also have to check the height, since the text might be too tall for the box # Larger fonts never fit better, so binary search for the largest size that fits.
font = ImageFont.truetype(font_path, font_size) # The font size can't usefully be larger than the box is tall. 0 means nothing fits
while True: low, high = 0, max(1, int(h))
next_font = ImageFont.truetype(font_path, font_size + 1) 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: # Center the drawn text itself in the box. Fonts leave space above and below the letters,
break # 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)
font = next_font draw.multiline_text(position, wrapped, font=font, fill=color, align="center")
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")
# Convert back to cv2 image format and update the original image # Convert back to cv2 image format and update the original image
cv2_image = cv2.cvtColor(np.array(image_pil), cv2.COLOR_RGB2BGR) cv2_image = cv2.cvtColor(np.array(image_pil), cv2.COLOR_RGB2BGR)