Ruby-Cogs/levelup/generator/pilmojisrc/core.py
Valerie 477974d53c
Some checks are pending
Run pre-commit / Run pre-commit (push) Waiting to run
Upload 2 Cogs & Update README
2025-05-23 01:30:53 -04:00

340 lines
10 KiB
Python

from __future__ import annotations
import logging
import math
from typing import (
TYPE_CHECKING,
Dict,
Optional,
SupportsInt,
Tuple,
Type,
TypeVar,
Union,
)
from PIL import Image, ImageDraw, ImageFont
from .helpers import NodeType, getsize, to_nodes
from .source import BaseSource, HTTPBasedSource, Twemoji, _has_requests
if TYPE_CHECKING:
from io import BytesIO
FontT = Union[ImageFont.ImageFont, ImageFont.FreeTypeFont, ImageFont.TransposedFont]
ColorT = Union[int, Tuple[int, int, int], Tuple[int, int, int, int], str]
P = TypeVar("P", bound="Pilmoji")
log = logging.getLogger("red.vrt.pilmoji")
__all__ = ("Pilmoji",)
class Pilmoji:
"""The main emoji rendering interface.
.. note::
This should be used in a context manager.
Parameters
----------
image: :class:`PIL.Image.Image`
The Pillow image to render on.
source: Union[:class:`~.BaseSource`, Type[:class:`~.BaseSource`]]
The emoji image source to use.
This defaults to :class:`~.TwitterEmojiSource`.
cache: bool
Whether or not to cache emojis given from source.
Enabling this is recommended and by default.
draw: :class:`PIL.ImageDraw.ImageDraw`
The drawing instance to use. If left unfilled,
a new drawing instance will be created.
render_discord_emoji: bool
Whether or not to render Discord emoji. Defaults to `True`
emoji_scale_factor: float
The default rescaling factor for emojis. Defaults to `1`
emoji_position_offset: Tuple[int, int]
A 2-tuple representing the x and y offset for emojis when rendering,
respectively. Defaults to `(0, 0)`
"""
def __init__(
self,
image: Image.Image,
*,
source: Union[BaseSource, Type[BaseSource]] = Twemoji,
cache: bool = True,
draw: Optional[ImageDraw.ImageDraw] = None,
render_discord_emoji: bool = True,
emoji_scale_factor: float = 1.0,
emoji_position_offset: Tuple[int, int] = (0, 0),
) -> None:
self.image: Image.Image = image
self.draw: ImageDraw.ImageDraw = draw
if isinstance(source, type):
if not issubclass(source, BaseSource):
raise TypeError(f"source must inherit from BaseSource, not {source}.")
source = source()
elif not isinstance(source, BaseSource):
raise TypeError(f"source must inherit from BaseSource, not {source.__class__}.")
self.source: BaseSource = source
self._cache: bool = cache
self._closed: bool = False
self._new_draw: bool = False
self._render_discord_emoji: bool = render_discord_emoji
self._default_emoji_scale_factor: float = emoji_scale_factor
self._default_emoji_position_offset: Tuple[int, int] = emoji_position_offset
self._emoji_cache: Dict[str, BytesIO] = {}
self._discord_emoji_cache: Dict[int, BytesIO] = {}
self._create_draw()
def open(self) -> None:
"""Re-opens this renderer if it has been closed.
This should rarely be called.
Raises
------
ValueError
The renderer is already open.
"""
if not self._closed:
raise ValueError("Renderer is already open.")
if _has_requests and isinstance(self.source, HTTPBasedSource):
from requests import Session
self.source._requests_session = Session()
self._create_draw()
self._closed = False
def close(self) -> None:
"""Safely closes this renderer.
.. note::
If you are using a context manager, this should not be called.
Raises
------
ValueError
The renderer has already been closed.
"""
if self._closed:
raise ValueError("Renderer has already been closed.")
if self._new_draw:
del self.draw
self.draw = None
if _has_requests and isinstance(self.source, HTTPBasedSource):
self.source._requests_session.close()
if self._cache:
for stream in self._emoji_cache.values():
stream.close()
for stream in self._discord_emoji_cache.values():
stream.close()
self._emoji_cache = {}
self._discord_emoji_cache = {}
self._closed = True
def _create_draw(self) -> None:
if self.draw is None:
self._new_draw = True
self.draw = ImageDraw.Draw(self.image)
def _get_emoji(self, emoji: str, /) -> Optional[BytesIO]:
if self._cache and emoji in self._emoji_cache:
entry = self._emoji_cache[emoji]
entry.seek(0)
return entry
if stream := self.source.get_emoji(emoji):
if self._cache:
self._emoji_cache[emoji] = stream
stream.seek(0)
return stream
def _get_discord_emoji(self, id: SupportsInt, /) -> Optional[BytesIO]:
id = int(id)
if self._cache and id in self._discord_emoji_cache:
entry = self._discord_emoji_cache[id]
entry.seek(0)
return entry
if stream := self.source.get_discord_emoji(id):
if self._cache:
self._discord_emoji_cache[id] = stream
stream.seek(0)
return stream
def getsize(
self,
text: str,
font: FontT = None,
*,
spacing: int = 4,
emoji_scale_factor: float = None,
) -> Tuple[int, int]:
"""Return the width and height of the text when rendered.
This method supports multiline text.
Parameters
----------
text: str
The text to use.
font
The font of the text.
spacing: int
The spacing between lines, in pixels.
Defaults to `4`.
emoji_scalee_factor: float
The rescaling factor for emojis.
Defaults to the factor given in the class constructor, or `1`.
"""
if emoji_scale_factor is None:
emoji_scale_factor = self._default_emoji_scale_factor
return getsize(text, font, spacing=spacing, emoji_scale_factor=emoji_scale_factor)
def text(
self,
xy: Tuple[int, int],
text: str,
fill: ColorT = None,
font: FontT = None,
anchor: str = None,
spacing: int = 4,
align: str = "left",
direction: str = None,
features: str = None,
language: str = None,
stroke_width: int = 0,
stroke_fill: ColorT = None,
embedded_color: bool = False,
*args,
emoji_scale_factor: float = None,
emoji_position_offset: Tuple[int, int] = None,
**kwargs,
) -> None:
"""Draws the string at the given position, with emoji rendering support.
This method supports multiline text.
.. note::
Some parameters have not been implemented yet.
.. note::
The signature of this function is a superset of the signature of Pillow's `ImageDraw.text`.
.. note::
Not all parameters are listed here.
Parameters
----------
xy: Tuple[int, int]
The position to render the text at.
text: str
The text to render.
fill
The fill color of the text.
font
The font to render the text with.
spacing: int
How many pixels there should be between lines. Defaults to `4`
emoji_scale_factor: float
The rescaling factor for emojis. This can be used for fine adjustments.
Defaults to the factor given in the class constructor, or `1`.
emoji_position_offset: Tuple[int, int]
The emoji position offset for emojis. This can be used for fine adjustments.
Defaults to the offset given in the class constructor, or `(0, 0)`.
"""
if emoji_scale_factor is None:
emoji_scale_factor = self._default_emoji_scale_factor
if emoji_position_offset is None:
emoji_position_offset = self._default_emoji_position_offset
if font is None:
font = ImageFont.load_default()
args = (
fill,
font,
anchor,
spacing,
align,
direction,
features,
language,
stroke_width,
stroke_fill,
embedded_color,
*args,
)
x, y = xy
original_x = x
nodes = to_nodes(text)
for line in nodes:
x = original_x
for node in line:
content = node.content
try:
width = font.getbbox(content)[2]
except AttributeError:
width = int(font.getlength(content))
if node.type is NodeType.text:
self.draw.text((x, y), content, *args, **kwargs)
x += width
continue
stream = None
if node.type is NodeType.emoji:
stream = self._get_emoji(content)
elif self._render_discord_emoji and node.type is NodeType.discord_emoji:
stream = self._get_discord_emoji(content)
if not stream:
self.draw.text((x, y), content, *args, **kwargs)
x += width
continue
with Image.open(stream).convert("RGBA") as asset:
width = int(emoji_scale_factor * font.size)
size = max(30, width), max(30, math.ceil(asset.height / asset.width * width))
log.debug("Resizing emoji %r to %r", content, size)
asset = asset.resize(size, Image.Resampling.LANCZOS)
ox, oy = emoji_position_offset
self.image.paste(asset, (x + ox, y + oy), asset)
x += width
y += spacing + font.size
def __enter__(self: P) -> P:
return self
def __exit__(self, *_) -> None:
self.close()
def __repr__(self) -> str:
return f"<Pilmoji source={self.source} cache={self._cache}>"