├── .gitignore ├── LICENSE ├── README.md ├── pilmoji ├── __init__.py ├── core.py ├── helpers.py └── source.py ├── requirements.txt └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### VirtualEnv template 3 | # Virtualenv 4 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 5 | .Python 6 | [Bb]in 7 | [Ii]nclude 8 | [Ll]ib 9 | [Ll]ib64 10 | [Ll]ocal 11 | [Ss]cripts 12 | pyvenv.cfg 13 | .venv 14 | pip-selfcheck.json 15 | 16 | ### Python template 17 | # Byte-compiled / optimized / DLL files 18 | __pycache__/ 19 | *.py[cod] 20 | *$py.class 21 | 22 | # C extensions 23 | *.so 24 | 25 | # Distribution / packaging 26 | .Python 27 | build/ 28 | develop-eggs/ 29 | dist/ 30 | downloads/ 31 | eggs/ 32 | .eggs/ 33 | lib/ 34 | lib64/ 35 | parts/ 36 | sdist/ 37 | var/ 38 | wheels/ 39 | pip-wheel-metadata/ 40 | share/python-wheels/ 41 | *.egg-info/ 42 | .installed.cfg 43 | *.egg 44 | MANIFEST 45 | 46 | # PyInstaller 47 | # Usually these files are written by a python script from a template 48 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 49 | *.manifest 50 | *.spec 51 | 52 | # Installer logs 53 | pip-log.txt 54 | pip-delete-this-directory.txt 55 | 56 | # Unit test / coverage reports 57 | htmlcov/ 58 | .tox/ 59 | .nox/ 60 | .coverage 61 | .coverage.* 62 | .cache 63 | nosetests.xml 64 | coverage.xml 65 | *.cover 66 | *.py,cover 67 | .hypothesis/ 68 | .pytest_cache/ 69 | cover/ 70 | 71 | # Translations 72 | *.mo 73 | *.pot 74 | 75 | # Django stuff: 76 | *.log 77 | local_settings.py 78 | db.sqlite3 79 | db.sqlite3-journal 80 | 81 | # Flask stuff: 82 | instance/ 83 | .webassets-cache 84 | 85 | # Scrapy stuff: 86 | .scrapy 87 | 88 | # Sphinx documentation 89 | docs/_build/ 90 | 91 | # PyBuilder 92 | .pybuilder/ 93 | target/ 94 | 95 | # Jupyter Notebook 96 | .ipynb_checkpoints 97 | 98 | # IPython 99 | profile_default/ 100 | ipython_config.py 101 | 102 | # pyenv 103 | # For a library or package, you might want to ignore these files since the code is 104 | # intended to run in multiple environments; otherwise, check them in: 105 | # .python-version 106 | 107 | # pipenv 108 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 109 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 110 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 111 | # install all needed dependencies. 112 | #Pipfile.lock 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021-present jay3332 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pilmoji 2 | Pilmoji is an emoji renderer for [Pillow](https://github.com/python-pillow/Pillow/), 3 | Python's imaging library. 4 | 5 | Pilmoji comes equipped with support for both unicode emojis and Discord emojis. 6 | 7 | ## Features 8 | - Discord emoji support 9 | - Multi-line rendering support 10 | - Emoji position and/or size adjusting 11 | - Many built-in emoji sources 12 | - Optional caching 13 | 14 | ## Installation and Requirements 15 | You must have Python 3.8 or higher in order to install Pilmoji. 16 | 17 | Installation can be done with `pip`: 18 | ```shell 19 | $ pip install -U pilmoji 20 | ``` 21 | 22 | Optionally, you can add the `[requests]` option to install requests 23 | alongside Pilmoji: 24 | ```shell 25 | $ pip install -U pilmoji[requests] 26 | ``` 27 | 28 | The option is not required, instead if `requests` is not installed, 29 | Pilmoji will fallback to use the builtin `urllib`. 30 | 31 | You may also install from Github. 32 | 33 | ## Usage 34 | ```py 35 | from pilmoji import Pilmoji 36 | from PIL import Image, ImageFont 37 | 38 | 39 | my_string = ''' 40 | Hello, world! 👋 Here are some emojis: 🎨 🌊 😎 41 | I also support Discord emoji: <:rooThink:596576798351949847> 42 | ''' 43 | 44 | with Image.new('RGB', (550, 80), (255, 255, 255)) as image: 45 | font = ImageFont.truetype('arial.ttf', 24) 46 | 47 | with Pilmoji(image) as pilmoji: 48 | pilmoji.text((10, 10), my_string.strip(), (0, 0, 0), font) 49 | 50 | image.show() 51 | ``` 52 | 53 | #### Result 54 | ![Example result](https://jay.has-no-bra.in/f/j4iEcc.png) 55 | 56 | ## Switching emoji sources 57 | As seen from the example, Pilmoji defaults to the `Twemoji` emoji source. 58 | 59 | If you prefer emojis from a different source, for example Microsoft, simply 60 | set the `source` kwarg in the constructor to a source found in the 61 | `pilmoji.source` module: 62 | 63 | ```py 64 | from pilmoji.source import MicrosoftEmojiSource 65 | 66 | with Pilmoji(image, source=MicrosoftEmojiSource) as pilmoji: 67 | ... 68 | ``` 69 | 70 | ![results](https://jay.has-no-bra.in/f/suPfj0.png) 71 | 72 | It is also possible to create your own emoji sources via subclass. 73 | 74 | ## Fine adjustments 75 | If an emoji looks too small or too big, or out of place, you can make fine adjustments 76 | with the `emoji_scale_factor` and `emoji_position_offset` kwargs: 77 | 78 | ```py 79 | pilmoji.text((10, 10), my_string.strip(), (0, 0, 0), font, 80 | emoji_scale_factor=1.15, emoji_position_offset=(0, -2)) 81 | ``` 82 | 83 | ## Contributing 84 | Contributions are welcome. Make sure to follow [PEP-8](https://www.python.org/dev/peps/pep-0008/) 85 | styling guidelines. 86 | -------------------------------------------------------------------------------- /pilmoji/__init__.py: -------------------------------------------------------------------------------- 1 | from . import helpers, source 2 | from .core import Pilmoji 3 | from .helpers import * 4 | 5 | __version__ = '2.0.5' 6 | __author__ = 'jay3332' 7 | -------------------------------------------------------------------------------- /pilmoji/core.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import math 4 | 5 | import PIL 6 | from PIL import Image, ImageDraw, ImageFont 7 | 8 | from typing import Dict, Optional, SupportsInt, TYPE_CHECKING, Tuple, Type, TypeVar, Union 9 | 10 | from .helpers import NodeType, getsize, to_nodes 11 | from .source import BaseSource, HTTPBasedSource, Twemoji, _has_requests 12 | 13 | if TYPE_CHECKING: 14 | from io import BytesIO 15 | 16 | FontT = Union[ImageFont.ImageFont, ImageFont.FreeTypeFont, ImageFont.TransposedFont] 17 | ColorT = Union[int, Tuple[int, int, int], Tuple[int, int, int, int], str] 18 | 19 | 20 | P = TypeVar('P', bound='Pilmoji') 21 | 22 | __all__ = ( 23 | 'Pilmoji', 24 | ) 25 | 26 | 27 | class Pilmoji: 28 | """The main emoji rendering interface. 29 | 30 | .. note:: 31 | This should be used in a context manager. 32 | 33 | Parameters 34 | ---------- 35 | image: :class:`PIL.Image.Image` 36 | The Pillow image to render on. 37 | source: Union[:class:`~.BaseSource`, Type[:class:`~.BaseSource`]] 38 | The emoji image source to use. 39 | This defaults to :class:`~.TwitterEmojiSource`. 40 | cache: bool 41 | Whether or not to cache emojis given from source. 42 | Enabling this is recommended and by default. 43 | draw: :class:`PIL.ImageDraw.ImageDraw` 44 | The drawing instance to use. If left unfilled, 45 | a new drawing instance will be created. 46 | render_discord_emoji: bool 47 | Whether or not to render Discord emoji. Defaults to `True` 48 | emoji_scale_factor: float 49 | The default rescaling factor for emojis. Defaults to `1` 50 | emoji_position_offset: Tuple[int, int] 51 | A 2-tuple representing the x and y offset for emojis when rendering, 52 | respectively. Defaults to `(0, 0)` 53 | """ 54 | 55 | def __init__( 56 | self, 57 | image: Image.Image, 58 | *, 59 | source: Union[BaseSource, Type[BaseSource]] = Twemoji, 60 | cache: bool = True, 61 | draw: Optional[ImageDraw.ImageDraw] = None, 62 | render_discord_emoji: bool = True, 63 | emoji_scale_factor: float = 1.0, 64 | emoji_position_offset: Tuple[int, int] = (0, 0) 65 | ) -> None: 66 | self.image: Image.Image = image 67 | self.draw: ImageDraw.ImageDraw = draw 68 | 69 | if isinstance(source, type): 70 | if not issubclass(source, BaseSource): 71 | raise TypeError(f'source must inherit from BaseSource, not {source}.') 72 | 73 | source = source() 74 | 75 | elif not isinstance(source, BaseSource): 76 | raise TypeError(f'source must inherit from BaseSource, not {source.__class__}.') 77 | 78 | self.source: BaseSource = source 79 | 80 | self._cache: bool = cache 81 | self._closed: bool = False 82 | self._new_draw: bool = False 83 | 84 | self._render_discord_emoji: bool = render_discord_emoji 85 | self._default_emoji_scale_factor: float = emoji_scale_factor 86 | self._default_emoji_position_offset: Tuple[int, int] = emoji_position_offset 87 | 88 | self._emoji_cache: Dict[str, BytesIO] = {} 89 | self._discord_emoji_cache: Dict[int, BytesIO] = {} 90 | 91 | self._create_draw() 92 | 93 | def open(self) -> None: 94 | """Re-opens this renderer if it has been closed. 95 | This should rarely be called. 96 | 97 | Raises 98 | ------ 99 | ValueError 100 | The renderer is already open. 101 | """ 102 | if not self._closed: 103 | raise ValueError('Renderer is already open.') 104 | 105 | if _has_requests and isinstance(self.source, HTTPBasedSource): 106 | from requests import Session 107 | self.source._requests_session = Session() 108 | 109 | self._create_draw() 110 | self._closed = False 111 | 112 | def close(self) -> None: 113 | """Safely closes this renderer. 114 | 115 | .. note:: 116 | If you are using a context manager, this should not be called. 117 | 118 | Raises 119 | ------ 120 | ValueError 121 | The renderer has already been closed. 122 | """ 123 | if self._closed: 124 | raise ValueError('Renderer has already been closed.') 125 | 126 | if self._new_draw: 127 | del self.draw 128 | self.draw = None 129 | 130 | if _has_requests and isinstance(self.source, HTTPBasedSource): 131 | self.source._requests_session.close() 132 | 133 | if self._cache: 134 | for stream in self._emoji_cache.values(): 135 | stream.close() 136 | 137 | for stream in self._discord_emoji_cache.values(): 138 | stream.close() 139 | 140 | self._emoji_cache = {} 141 | self._discord_emoji_cache = {} 142 | 143 | self._closed = True 144 | 145 | def _create_draw(self) -> None: 146 | if self.draw is None: 147 | self._new_draw = True 148 | self.draw = ImageDraw.Draw(self.image) 149 | 150 | def _get_emoji(self, emoji: str, /) -> Optional[BytesIO]: 151 | if self._cache and emoji in self._emoji_cache: 152 | entry = self._emoji_cache[emoji] 153 | entry.seek(0) 154 | return entry 155 | 156 | if stream := self.source.get_emoji(emoji): 157 | if self._cache: 158 | self._emoji_cache[emoji] = stream 159 | 160 | stream.seek(0) 161 | return stream 162 | 163 | def _get_discord_emoji(self, id: SupportsInt, /) -> Optional[BytesIO]: 164 | id = int(id) 165 | 166 | if self._cache and id in self._discord_emoji_cache: 167 | entry = self._discord_emoji_cache[id] 168 | entry.seek(0) 169 | return entry 170 | 171 | if stream := self.source.get_discord_emoji(id): 172 | if self._cache: 173 | self._discord_emoji_cache[id] = stream 174 | 175 | stream.seek(0) 176 | return stream 177 | 178 | # this function was removed from pillow somewhere around 11.2 179 | # this is the same functin that pillow used 180 | # https://github.com/python-pillow/Pillow/blob/main/LICENSE 181 | def _multiline_spacing( 182 | self, 183 | font: ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont, 184 | spacing: float, 185 | stroke_width: float, 186 | ) -> float: 187 | return ( 188 | self.draw.textbbox((0, 0), "A", font, stroke_width=stroke_width)[3] 189 | + stroke_width 190 | + spacing 191 | ) 192 | 193 | def getsize( 194 | self, 195 | text: str, 196 | font: FontT = None, 197 | *, 198 | spacing: int = 4, 199 | emoji_scale_factor: float = None 200 | ) -> Tuple[int, int]: 201 | """Return the width and height of the text when rendered. 202 | This method supports multiline text. 203 | 204 | Parameters 205 | ---------- 206 | text: str 207 | The text to use. 208 | font 209 | The font of the text. 210 | spacing: int 211 | The spacing between lines, in pixels. 212 | Defaults to `4`. 213 | emoji_scalee_factor: float 214 | The rescaling factor for emojis. 215 | Defaults to the factor given in the class constructor, or `1`. 216 | """ 217 | if emoji_scale_factor is None: 218 | emoji_scale_factor = self._default_emoji_scale_factor 219 | 220 | return getsize(text, font, spacing=spacing, emoji_scale_factor=emoji_scale_factor) 221 | 222 | def text( 223 | self, 224 | xy: Tuple[int, int], 225 | text: str, 226 | fill: ColorT = None, 227 | font: FontT = None, 228 | anchor: str = None, 229 | spacing: int = 4, 230 | node_spacing: int = 0, 231 | align: str = "left", 232 | direction: str = None, 233 | features: str = None, 234 | language: str = None, 235 | stroke_width: int = 0, 236 | stroke_fill: ColorT = None, 237 | embedded_color: bool = False, 238 | *args, 239 | emoji_scale_factor: float = None, 240 | emoji_position_offset: Tuple[int, int] = None, 241 | **kwargs 242 | ) -> None: 243 | """Draws the string at the given position, with emoji rendering support. 244 | This method supports multiline text. 245 | 246 | .. note:: 247 | Some parameters have not been implemented yet. 248 | 249 | .. note:: 250 | The signature of this function is a superset of the signature of Pillow's `ImageDraw.text`. 251 | 252 | .. note:: 253 | Not all parameters are listed here. 254 | 255 | Parameters 256 | ---------- 257 | xy: Tuple[int, int] 258 | The position to render the text at. 259 | text: str 260 | The text to render. 261 | fill 262 | The fill color of the text. 263 | font 264 | The font to render the text with. 265 | spacing: int 266 | How many pixels there should be between lines. Defaults to `4` 267 | node_spacing: int 268 | How many pixels there should be between nodes (text/unicode_emojis/custom_emojis). Defaults to `0` 269 | emoji_scale_factor: float 270 | The rescaling factor for emojis. This can be used for fine adjustments. 271 | Defaults to the factor given in the class constructor, or `1`. 272 | emoji_position_offset: Tuple[int, int] 273 | The emoji position offset for emojis. This can be used for fine adjustments. 274 | Defaults to the offset given in the class constructor, or `(0, 0)`. 275 | """ 276 | 277 | if emoji_scale_factor is None: 278 | emoji_scale_factor = self._default_emoji_scale_factor 279 | 280 | if emoji_position_offset is None: 281 | emoji_position_offset = self._default_emoji_position_offset 282 | 283 | if font is None: 284 | font = ImageFont.load_default() 285 | 286 | # first we need to test the anchor 287 | # because we want to make the exact same positions transformations than the "ImageDraw"."text" function in PIL 288 | # https://github.com/python-pillow/Pillow/blob/66c244af3233b1cc6cc2c424e9714420aca109ad/src/PIL/ImageDraw.py#L449 289 | 290 | # also we are note using the "ImageDraw"."multiline_text" since when we are cuting the text in nodes 291 | # a lot of code could be simplify this way 292 | # https://github.com/python-pillow/Pillow/blob/66c244af3233b1cc6cc2c424e9714420aca109ad/src/PIL/ImageDraw.py#L567 293 | 294 | if anchor is None: 295 | anchor = "la" 296 | elif len(anchor) != 2: 297 | msg = "anchor must be a 2 character string" 298 | raise ValueError(msg) 299 | elif anchor[1] in "tb" and "\n" in text: 300 | msg = "anchor not supported for multiline text" 301 | raise ValueError(msg) 302 | 303 | # need to be checked here because we are not using the real "ImageDraw"."multiline_text" 304 | if direction == "ttb" and "\n" in text: 305 | msg = "ttb direction is unsupported for multiline text" 306 | raise ValueError(msg) 307 | 308 | def getink(fill): 309 | ink, fill = self.draw._getink(fill) 310 | if ink is None: 311 | return fill 312 | return ink 313 | 314 | x, y = xy 315 | original_x = x 316 | nodes = to_nodes(text) 317 | # get the distance between lines ( will be add to y between each line ) 318 | line_spacing = self._multiline_spacing(font, spacing, stroke_width) 319 | 320 | # I change a part of the logic of text writing because it couldn't work "the same as PIL" if I didn't 321 | nodes_line_to_print = [] 322 | widths = [] 323 | max_width = 0 324 | streams = {} 325 | mode = self.draw.fontmode 326 | if stroke_width == 0 and embedded_color: 327 | mode = "RGBA" 328 | ink = getink(fill) 329 | # we get the size taken by a " " to be drawn with the given options 330 | space_text_lenght = self.draw.textlength(" ", font, direction=direction, features=features, language=language, embedded_color=embedded_color) 331 | 332 | for node_id, line in enumerate(nodes): 333 | text_line = "" 334 | streams[node_id] = {} 335 | for line_id, node in enumerate(line): 336 | content = node.content 337 | stream = None 338 | if node.type is NodeType.emoji: 339 | stream = self._get_emoji(content) 340 | 341 | elif self._render_discord_emoji and node.type is NodeType.discord_emoji: 342 | stream = self._get_discord_emoji(content) 343 | 344 | if stream: 345 | streams[node_id][line_id] = stream 346 | 347 | if node.type is NodeType.text or not stream: 348 | # each text in the same line are concatenate 349 | text_line += node.content 350 | continue 351 | 352 | with Image.open(stream).convert('RGBA') as asset: 353 | width = round(emoji_scale_factor * font.size) 354 | ox, oy = emoji_position_offset 355 | size = round(width + ox + (node_spacing * 2)) 356 | # for every emoji we calculate the space needed to display it in the current text 357 | space_to_had = round(size / space_text_lenght) 358 | # we had the equivalent space as " " caracter in the line text 359 | text_line += "".join(" " for x in range(space_to_had)) 360 | 361 | #saving each line with the place to display emoji at the right place 362 | nodes_line_to_print.append(text_line) 363 | line_width = self.draw.textlength( 364 | text_line, font, direction=direction, features=features, language=language 365 | ) 366 | widths.append(line_width) 367 | max_width = max(max_width, line_width) 368 | 369 | # taking into acount the anchor to place the text in the right place 370 | if anchor[1] == "m": 371 | y -= (len(nodes) - 1) * line_spacing / 2.0 372 | elif anchor[1] == "d": 373 | y -= (len(nodes) - 1) * line_spacing 374 | 375 | for node_id, line in enumerate(nodes): 376 | # restore the original x wanted for each line 377 | x = original_x 378 | # some transformations should not be applied to y 379 | line_y = y 380 | width_difference = max_width - widths[node_id] 381 | 382 | # first align left by anchor 383 | if anchor[0] == "m": 384 | x -= width_difference / 2.0 385 | elif anchor[0] == "r": 386 | x -= width_difference 387 | 388 | # then align by align parameter 389 | if align == "left": 390 | pass 391 | elif align == "center": 392 | x += width_difference / 2.0 393 | elif align == "right": 394 | x += width_difference 395 | else: 396 | msg = 'align must be "left", "center" or "right"' 397 | raise ValueError(msg) 398 | 399 | # if this line hase text to display then we draw it all at once ( one time only per line ) 400 | if len(nodes_line_to_print[node_id]) > 0: 401 | self.draw.text( 402 | (x, line_y), 403 | nodes_line_to_print[node_id], 404 | fill=fill, 405 | font=font, 406 | anchor=anchor, 407 | spacing=spacing, 408 | align=align, 409 | direction=direction, 410 | features=features, 411 | language=language, 412 | stroke_width=stroke_width, 413 | stroke_fill=stroke_fill, 414 | embedded_color=embedded_color, 415 | *args, 416 | **kwargs 417 | ) 418 | 419 | coord = [] 420 | start = [] 421 | for i in range(2): 422 | coord.append(int((x, y)[i])) 423 | start.append(math.modf((x, y)[i])[0]) 424 | 425 | # respecting the way parameters are used in PIL to find the good x and y 426 | if ink is not None: 427 | stroke_ink = None 428 | if stroke_width: 429 | stroke_ink = getink(stroke_fill) if stroke_fill is not None else ink 430 | 431 | if stroke_ink is not None: 432 | ink = stroke_ink 433 | stroke_width = 0 434 | try: 435 | _, offset = font.getmask2( 436 | nodes_line_to_print[node_id], 437 | mode, 438 | direction=direction, 439 | features=features, 440 | language=language, 441 | stroke_width=stroke_width, 442 | anchor=anchor, 443 | ink=ink, 444 | start=start, 445 | *args, 446 | **kwargs, 447 | ) 448 | coord = coord[0] + offset[0], coord[1] + offset[1] 449 | except AttributeError: 450 | pass 451 | x, line_y = coord 452 | 453 | for line_id, node in enumerate(line): 454 | content = node.content 455 | 456 | # if node is text then we decale our x 457 | # but since the text line as already be drawn we do not need to draw text here anymore 458 | if node.type is NodeType.text or line_id not in streams[node_id]: 459 | if tuple(int(part) for part in PIL.__version__.split(".")) >= (9, 2, 0): 460 | width = int(font.getlength(content, direction=direction, features=features, language=language)) 461 | else: 462 | width, _ = font.getsize(content) 463 | x += node_spacing + width 464 | continue 465 | 466 | if line_id in streams[node_id]: 467 | with Image.open(streams[node_id][line_id]).convert('RGBA') as asset: 468 | width = round(emoji_scale_factor * font.size) 469 | size = width, round(math.ceil(asset.height / asset.width * width)) 470 | asset = asset.resize(size, Image.Resampling.LANCZOS) 471 | ox, oy = emoji_position_offset 472 | 473 | self.image.paste(asset, (round(x + ox), round(line_y + oy)), asset) 474 | 475 | x += node_spacing + width 476 | y += line_spacing 477 | 478 | def __enter__(self: P) -> P: 479 | return self 480 | 481 | def __exit__(self, *_) -> None: 482 | self.close() 483 | 484 | def __repr__(self) -> str: 485 | return f'' 486 | -------------------------------------------------------------------------------- /pilmoji/helpers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | 5 | from enum import Enum 6 | 7 | import emoji 8 | 9 | import PIL 10 | from PIL import ImageFont 11 | 12 | from typing import Dict, Final, List, NamedTuple, TYPE_CHECKING, Tuple 13 | 14 | if TYPE_CHECKING: 15 | from .core import FontT 16 | 17 | # This is actually way faster than it seems 18 | # Create a dictionary mapping English emoji descriptions to their unicode representations 19 | # Only include emojis that have an English description and are fully qualified 20 | language_pack: Dict[str, str] = { 21 | data['en']: emj 22 | for emj, data in emoji.EMOJI_DATA.items() 23 | if 'en' in data and data['status'] <= emoji.STATUS['fully_qualified'] 24 | } 25 | _UNICODE_EMOJI_REGEX = '|'.join(map(re.escape, sorted(language_pack.values(), key=len, reverse=True))) 26 | _DISCORD_EMOJI_REGEX = '' 27 | 28 | EMOJI_REGEX: Final[re.Pattern[str]] = re.compile(f'({_UNICODE_EMOJI_REGEX}|{_DISCORD_EMOJI_REGEX})') 29 | 30 | __all__ = ( 31 | 'EMOJI_REGEX', 32 | 'Node', 33 | 'NodeType', 34 | 'to_nodes', 35 | 'getsize' 36 | ) 37 | 38 | 39 | class NodeType(Enum): 40 | """|enum| 41 | 42 | Represents the type of a :class:`~.Node`. 43 | 44 | Attributes 45 | ---------- 46 | text 47 | This node is a raw text node. 48 | emoji 49 | This node is a unicode emoji. 50 | discord_emoji 51 | This node is a Discord emoji. 52 | """ 53 | 54 | text = 0 55 | emoji = 1 56 | discord_emoji = 2 57 | 58 | 59 | class Node(NamedTuple): 60 | """Represents a parsed node inside of a string. 61 | 62 | Attributes 63 | ---------- 64 | type: :class:`~.NodeType` 65 | The type of this node. 66 | content: str 67 | The contents of this node. 68 | """ 69 | 70 | type: NodeType 71 | content: str 72 | 73 | def __repr__(self) -> str: 74 | return f'' 75 | 76 | 77 | def _parse_line(line: str, /) -> List[Node]: 78 | nodes = [] 79 | 80 | for i, chunk in enumerate(EMOJI_REGEX.split(line)): 81 | if not chunk: 82 | continue 83 | 84 | if not i % 2: 85 | nodes.append(Node(NodeType.text, chunk)) 86 | continue 87 | 88 | if len(chunk) > 18: # This is guaranteed to be a Discord emoji 89 | node = Node(NodeType.discord_emoji, chunk.split(':')[-1][:-1]) 90 | else: 91 | node = Node(NodeType.emoji, chunk) 92 | 93 | nodes.append(node) 94 | 95 | return nodes 96 | 97 | 98 | def to_nodes(text: str, /) -> List[List[Node]]: 99 | """Parses a string of text into :class:`~.Node`s. 100 | 101 | This method will return a nested list, each element of the list 102 | being a list of :class:`~.Node`s and representing a line in the string. 103 | 104 | The string ``'Hello\nworld'`` would return something similar to 105 | ``[[Node('Hello')], [Node('world')]]``. 106 | 107 | Parameters 108 | ---------- 109 | text: str 110 | The text to parse into nodes. 111 | 112 | Returns 113 | ------- 114 | List[List[:class:`~.Node`]] 115 | """ 116 | return [_parse_line(line) for line in text.splitlines()] 117 | 118 | 119 | def getsize( 120 | text: str, 121 | font: FontT = None, 122 | *, 123 | spacing: int = 4, 124 | emoji_scale_factor: float = 1 125 | ) -> Tuple[int, int]: 126 | """Return the width and height of the text when rendered. 127 | This method supports multiline text. 128 | 129 | Parameters 130 | ---------- 131 | text: str 132 | The text to use. 133 | font 134 | The font of the text. 135 | spacing: int 136 | The spacing between lines, in pixels. 137 | Defaults to `4`. 138 | emoji_scale_factor: float 139 | The rescaling factor for emojis. 140 | Defaults to `1`. 141 | """ 142 | if font is None: 143 | font = ImageFont.load_default() 144 | 145 | x, y = 0, 0 146 | nodes = to_nodes(text) 147 | 148 | for line in nodes: 149 | this_x = 0 150 | for node in line: 151 | content = node.content 152 | 153 | if node.type is not NodeType.text: 154 | width = int(emoji_scale_factor * font.size) 155 | elif tuple(int(part) for part in PIL.__version__.split(".")) >= (9, 2, 0): 156 | width = int(font.getlength(content)) 157 | else: 158 | width, _ = font.getsize(content) 159 | 160 | this_x += width 161 | 162 | y += spacing + font.size 163 | 164 | if this_x > x: 165 | x = this_x 166 | 167 | return x, y - spacing 168 | -------------------------------------------------------------------------------- /pilmoji/source.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from io import BytesIO 3 | 4 | from urllib.request import Request, urlopen 5 | from urllib.error import HTTPError 6 | from urllib.parse import quote_plus 7 | 8 | from typing import Any, ClassVar, Dict, Optional 9 | 10 | try: 11 | import requests 12 | _has_requests = True 13 | except ImportError: 14 | requests = None 15 | _has_requests = False 16 | 17 | __all__ = ( 18 | 'BaseSource', 19 | 'HTTPBasedSource', 20 | 'DiscordEmojiSourceMixin', 21 | 'EmojiCDNSource', 22 | 'TwitterEmojiSource', 23 | 'AppleEmojiSource', 24 | 'GoogleEmojiSource', 25 | 'MicrosoftEmojiSource', 26 | 'FacebookEmojiSource', 27 | 'MessengerEmojiSource', 28 | 'EmojidexEmojiSource', 29 | 'JoyPixelsEmojiSource', 30 | 'SamsungEmojiSource', 31 | 'WhatsAppEmojiSource', 32 | 'MozillaEmojiSource', 33 | 'OpenmojiEmojiSource', 34 | 'TwemojiEmojiSource', 35 | 'FacebookMessengerEmojiSource', 36 | 'Twemoji', 37 | 'Openmoji', 38 | ) 39 | 40 | 41 | class BaseSource(ABC): 42 | """The base class for an emoji image source.""" 43 | 44 | @abstractmethod 45 | def get_emoji(self, emoji: str, /) -> Optional[BytesIO]: 46 | """Retrieves a :class:`io.BytesIO` stream for the image of the given emoji. 47 | 48 | Parameters 49 | ---------- 50 | emoji: str 51 | The emoji to retrieve. 52 | 53 | Returns 54 | ------- 55 | :class:`io.BytesIO` 56 | A bytes stream of the emoji. 57 | None 58 | An image for the emoji could not be found. 59 | """ 60 | raise NotImplementedError 61 | 62 | @abstractmethod 63 | def get_discord_emoji(self, id: int, /) -> Optional[BytesIO]: 64 | """Retrieves a :class:`io.BytesIO` stream for the image of the given Discord emoji. 65 | 66 | Parameters 67 | ---------- 68 | id: int 69 | The snowflake ID of the Discord emoji. 70 | 71 | Returns 72 | ------- 73 | :class:`io.BytesIO` 74 | A bytes stream of the emoji. 75 | None 76 | An image for the emoji could not be found. 77 | """ 78 | raise NotImplementedError 79 | 80 | def __repr__(self) -> str: 81 | return f'<{self.__class__.__name__}>' 82 | 83 | 84 | class HTTPBasedSource(BaseSource): 85 | """Represents an HTTP-based source.""" 86 | 87 | REQUEST_KWARGS: ClassVar[Dict[str, Any]] = { 88 | 'headers': {'User-Agent': 'Mozilla/5.0'} 89 | } 90 | 91 | def __init__(self) -> None: 92 | if _has_requests: 93 | self._requests_session = requests.Session() 94 | 95 | def request(self, url: str) -> bytes: 96 | """Makes a GET request to the given URL. 97 | 98 | If the `requests` library is installed, it will be used. 99 | If it is not installed, :meth:`urllib.request.urlopen` will be used instead. 100 | 101 | Parameters 102 | ---------- 103 | url: str 104 | The URL to request from. 105 | 106 | Returns 107 | ------- 108 | bytes 109 | 110 | Raises 111 | ------ 112 | Union[:class:`requests.HTTPError`, :class:`urllib.error.HTTPError`] 113 | There was an error requesting from the URL. 114 | """ 115 | if _has_requests: 116 | with self._requests_session.get(url, **self.REQUEST_KWARGS) as response: 117 | if response.ok: 118 | return response.content 119 | else: 120 | req = Request(url, **self.REQUEST_KWARGS) 121 | with urlopen(req) as response: 122 | return response.read() 123 | 124 | @abstractmethod 125 | def get_emoji(self, emoji: str, /) -> Optional[BytesIO]: 126 | raise NotImplementedError 127 | 128 | @abstractmethod 129 | def get_discord_emoji(self, id: int, /) -> Optional[BytesIO]: 130 | raise NotImplementedError 131 | 132 | 133 | class DiscordEmojiSourceMixin(HTTPBasedSource): 134 | """A mixin that adds Discord emoji functionality to another source.""" 135 | 136 | BASE_DISCORD_EMOJI_URL: ClassVar[str] = 'https://cdn.discordapp.com/emojis/' 137 | 138 | @abstractmethod 139 | def get_emoji(self, emoji: str, /) -> Optional[BytesIO]: 140 | raise NotImplementedError 141 | 142 | def get_discord_emoji(self, id: int, /) -> Optional[BytesIO]: 143 | url = self.BASE_DISCORD_EMOJI_URL + str(id) + '.png' 144 | _to_catch = HTTPError if not _has_requests else requests.HTTPError 145 | 146 | try: 147 | return BytesIO(self.request(url)) 148 | except _to_catch: 149 | pass 150 | 151 | 152 | class EmojiCDNSource(DiscordEmojiSourceMixin): 153 | """A base source that fetches emojis from https://emojicdn.elk.sh/.""" 154 | 155 | BASE_EMOJI_CDN_URL: ClassVar[str] = 'https://emojicdn.elk.sh/' 156 | STYLE: ClassVar[str] = None 157 | 158 | def get_emoji(self, emoji: str, /) -> Optional[BytesIO]: 159 | if self.STYLE is None: 160 | raise TypeError('STYLE class variable unfilled.') 161 | 162 | url = self.BASE_EMOJI_CDN_URL + quote_plus(emoji) + '?style=' + quote_plus(self.STYLE) 163 | _to_catch = HTTPError if not _has_requests else requests.HTTPError 164 | 165 | try: 166 | return BytesIO(self.request(url)) 167 | except _to_catch: 168 | pass 169 | 170 | 171 | class TwitterEmojiSource(EmojiCDNSource): 172 | """A source that uses Twitter-style emojis. These are also the ones used in Discord.""" 173 | STYLE = 'twitter' 174 | 175 | 176 | class AppleEmojiSource(EmojiCDNSource): 177 | """A source that uses Apple emojis.""" 178 | STYLE = 'apple' 179 | 180 | 181 | class GoogleEmojiSource(EmojiCDNSource): 182 | """A source that uses Google emojis.""" 183 | STYLE = 'google' 184 | 185 | 186 | class MicrosoftEmojiSource(EmojiCDNSource): 187 | """A source that uses Microsoft emojis.""" 188 | STYLE = 'microsoft' 189 | 190 | 191 | class SamsungEmojiSource(EmojiCDNSource): 192 | """A source that uses Samsung emojis.""" 193 | STYLE = 'samsung' 194 | 195 | 196 | class WhatsAppEmojiSource(EmojiCDNSource): 197 | """A source that uses WhatsApp emojis.""" 198 | STYLE = 'whatsapp' 199 | 200 | 201 | class FacebookEmojiSource(EmojiCDNSource): 202 | """A source that uses Facebook emojis.""" 203 | STYLE = 'facebook' 204 | 205 | 206 | class MessengerEmojiSource(EmojiCDNSource): 207 | """A source that uses Facebook Messenger's emojis.""" 208 | STYLE = 'messenger' 209 | 210 | 211 | class JoyPixelsEmojiSource(EmojiCDNSource): 212 | """A source that uses JoyPixels' emojis.""" 213 | STYLE = 'joypixels' 214 | 215 | 216 | class OpenmojiEmojiSource(EmojiCDNSource): 217 | """A source that uses Openmoji emojis.""" 218 | STYLE = 'openmoji' 219 | 220 | 221 | class EmojidexEmojiSource(EmojiCDNSource): 222 | """A source that uses Emojidex emojis.""" 223 | STYLE = 'emojidex' 224 | 225 | 226 | class MozillaEmojiSource(EmojiCDNSource): 227 | """A source that uses Mozilla's emojis.""" 228 | STYLE = 'mozilla' 229 | 230 | 231 | # Aliases 232 | Openmoji = OpenmojiEmojiSource 233 | FacebookMessengerEmojiSource = MessengerEmojiSource 234 | TwemojiEmojiSource = Twemoji = TwitterEmojiSource 235 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Pillow 2 | emoji 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import re 2 | from setuptools import setup 3 | 4 | 5 | with open('README.md', encoding='utf-8') as fp: 6 | readme = fp.read() 7 | 8 | with open('requirements.txt') as fp: 9 | requirements = fp.readlines() 10 | 11 | with open('pilmoji/__init__.py') as fp: 12 | contents = fp.read() 13 | 14 | try: 15 | version = re.search( 16 | r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', contents, re.M 17 | ).group(1) 18 | except AttributeError: 19 | raise RuntimeError('Could not identify version') from None 20 | 21 | try: 22 | author = re.search( 23 | r'^__author__\s*=\s*[\'"]([^\'"]*)[\'"]', contents, re.M 24 | ).group(1) 25 | except AttributeError: 26 | author = 'jay3332' 27 | 28 | 29 | setup( 30 | name='pilmoji', 31 | author=author, 32 | url='https://github.com/jay3332/pilmoji', 33 | project_urls={ 34 | "Issue tracker": "https://github.com/jay3332/pilmoji", 35 | "Discord": "https://discord.gg/FqtZ6akWpd" 36 | }, 37 | version=version, 38 | packages=['pilmoji'], 39 | license='MIT', 40 | description="Pilmoji is an emoji renderer for Pillow, Python's imaging library.", 41 | long_description=readme, 42 | long_description_content_type="text/markdown", 43 | include_package_data=True, 44 | install_requires=requirements, 45 | extras_require={ 46 | 'requests': ['requests'] 47 | }, 48 | python_requires='>=3.8.0', 49 | classifiers=[ 50 | 'License :: OSI Approved :: MIT License', 51 | 'Intended Audience :: Developers', 52 | 'Natural Language :: English', 53 | 'Operating System :: OS Independent', 54 | 'Programming Language :: Python :: 3.8', 55 | 'Programming Language :: Python :: 3.9', 56 | 'Topic :: Internet', 57 | 'Topic :: Software Development :: Libraries', 58 | 'Topic :: Software Development :: Libraries :: Python Modules', 59 | 'Topic :: Utilities', 60 | ] 61 | ) 62 | --------------------------------------------------------------------------------