mirror of
https://github.com/UiO-Gaming/mustafa.git
synced 2026-11-05 13:56:48 +01:00
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:
1 parent
14c3caedf5
commit
154d4de881
10 files changed
+243
-95
No files matched your search
@@ -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
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user