340 lines
10 KiB
Python
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}>"
|