Taka005 commited on
Commit
a28bbcf
·
1 Parent(s): dd8721a
modules/emojistore.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import orjson
2
+ import sqlite3
3
+ import requests
4
+ import time
5
+ import math
6
+ import logging
7
+
8
+ CACHE_EXPIRE_TIME = 60 * 60 * 12
9
+
10
+ logger = logging.getLogger('EmojiStore')
11
+
12
+ class EmojiStore:
13
+
14
+ def __init__(self, db, **kwargs):
15
+ self.db: sqlite3.Connection = db
16
+ self.db.row_factory = sqlite3.Row
17
+ cur = self.db.cursor()
18
+ cur.execute('CREATE TABLE IF NOT EXISTS emoji_cache(host TEXT, data TEXT, last_updated INTEGER)')
19
+ cur.close()
20
+
21
+ self.emoji_cache = {}
22
+ if kwargs.get('session'):
23
+ self.session = kwargs['session']
24
+ else:
25
+ self.session = requests.Session()
26
+ self.session.headers['User-Agent'] = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36'
27
+
28
+ def _generate_emoji_url(self, host, emoji: dict):
29
+ if emoji.get('url'):
30
+ return emoji['url']
31
+ else:
32
+ # v>=13?
33
+ return f'https://{host}/emoji/{emoji["name"]}.webp'
34
+
35
+ def _fetch_nodeinfo(self, host):
36
+ r = self.session.get(f'https://{host}/.well-known/nodeinfo')
37
+ if r.status_code != 200:
38
+ logger.getChild('fetch_nodeinfo').error(f'Failed to fetch nodeinfo for {host} (well-known/nodeinfo)')
39
+ raise Exception(f'Failed to fetch nodeinfo for {host}')
40
+ res = orjson.loads(r.content)
41
+ if res.get('links'):
42
+ for link in res['links']:
43
+ if link['rel'].endswith('nodeinfo.diaspora.software/ns/schema/2.0'):
44
+ r2 = self.session.get(link['href'])
45
+ if r2.status_code != 200:
46
+ logger.getChild('fetch_nodeinfo').error(f'Failed to fetch nodeinfo for {host} (nodeinfo)')
47
+ raise Exception(f'Failed to fetch nodeinfo for {host}')
48
+ return orjson.loads(r2.content)
49
+ logger.getChild('fetch_nodeinfo').error(f'Failed to fetch nodeinfo for {host}')
50
+ raise Exception(f'Failed to fetch nodeinfo for {host}')
51
+
52
+ def _fetch_emoji_data(self, host):
53
+ logger.getChild('fetch_emoji_data').info(f'Fetching emoji data for {host}')
54
+ try:
55
+ ni = self._fetch_nodeinfo(host)
56
+ r = self.session.post(f'https://{host}/api/meta', headers={'Content-Type': 'application/json'}, data=b'{}')
57
+ if r.status_code != 200 and r.status_code != 404:
58
+ logger.getChild('fetch_emoji_data').error(f'Failed to fetch emoji data for {host} (api/meta)')
59
+ raise Exception(f'Failed to fetch emoji data for {host}')
60
+ if r.status_code != 404:
61
+ meta = orjson.loads(r.content)
62
+ v = meta['version'].split('.')
63
+ # Misskey v13以降は別エンドポイントに問い合わせ
64
+ if ni['software']['name'] == 'misskey' and int(v[0]) >= 13:
65
+ r2 = self.session.post(f'https://{host}/api/emojis', headers={'Content-Type': 'application/json'}, data=b'{}')
66
+ if r2.status_code != 200:
67
+ logger.getChild('fetch_emoji_data').error(f'Failed to fetch emoji data for {host} (Misskey v13)')
68
+ raise Exception(f'Failed to fetch emoji data for {host} (Misskey v13)')
69
+ return orjson.loads(r2.content)['emojis']
70
+ else:
71
+ return meta['emojis']
72
+ else:
73
+ # Mastodon/Pleroma?
74
+ r3 = self.session.get(f'https://{host}/api/v1/custom_emojis')
75
+ if r3.status_code != 200:
76
+ logger.getChild('fetch_emoji_data').error(f'Failed to fetch emoji data for {host} (mastodon, pleroma)')
77
+ raise Exception(f'Failed to fetch emoji data for {host} (mastodon, pleroma)')
78
+ res = orjson.loads(r3.content)
79
+ # Misskey形式に変換
80
+ return [{'name': x['shortcode'], 'url': x['static_url'], 'aliases': [''], 'category': ''} for x in res]
81
+ except:
82
+ return []
83
+
84
+ def _download(self, host):
85
+ emoji_data = self._fetch_emoji_data(host)
86
+ cur = self.db.cursor()
87
+ cur.execute('REPLACE INTO emoji_cache(host, data, last_updated) VALUES (?, ?, ?)', (host, orjson.dumps(emoji_data), math.floor(time.time())))
88
+ self.db.commit()
89
+ self.emoji_cache[host] = emoji_data
90
+
91
+ def _load(self, host) -> list:
92
+ emojis = []
93
+ if host in self.emoji_cache.keys():
94
+ emojis = self.emoji_cache[host]
95
+ else:
96
+ cur = self.db.cursor()
97
+ cur.execute('SELECT * FROM emoji_cache WHERE host = ?', (host,))
98
+ row = cur.fetchone()
99
+ if row is None:
100
+ logger.getChild('load').error(f'emoji data not found. fetching')
101
+ self._download(host)
102
+ return self._load(host)
103
+ else:
104
+ expire = CACHE_EXPIRE_TIME
105
+ # 前回取得失敗してる?
106
+ if row['data'] == '[]':
107
+ expire = 60 * 5
108
+ if math.floor(time.time()) - row['last_updated'] > expire:
109
+ logger.getChild('load').error(f'emoji cache expired. refreshing')
110
+ self._download(host)
111
+ return self._load(host)
112
+ self.emoji_cache[host] = orjson.loads(row['data'])
113
+ emojis = self.emoji_cache[host]
114
+
115
+ return emojis
116
+
117
+ # ----------------------
118
+
119
+ def refresh(self, host):
120
+ self._download(host)
121
+
122
+ def find_by_keyword(self, host, k) -> list:
123
+ emojis = self._load(host)
124
+ res = []
125
+ for emoji in emojis:
126
+ if k in emoji['name'].lower():
127
+ res.append({'name': emoji['name'], 'url': self._generate_emoji_url(host, emoji)})
128
+ return res
129
+
130
+ def find_by_alias(self, host, t) -> list:
131
+ emojis = self._load(host)
132
+ res = []
133
+ for emoji in emojis:
134
+ if t in emoji['aliases']:
135
+ res.append({'name': emoji['name'], 'url': self._generate_emoji_url(host, emoji)})
136
+ return res
137
+
138
+ def get(self, host, name):
139
+ emojis = self._load(host)
140
+ for emoji in emojis:
141
+ if emoji['name'] == name:
142
+ return {'name': emoji['name'], 'url': self._generate_emoji_url(host, emoji)}
143
+ return None
pilmoji/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ from . import helpers, source
2
+ from .core import Pilmoji
3
+ from .helpers import *
4
+
5
+ __version__ = '2.0.1-CBT-1'
6
+ __author__ = 'jay3332'
pilmoji/core.py ADDED
@@ -0,0 +1,352 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from optparse import Option
5
+
6
+ from PIL import Image, ImageDraw, ImageFont, UnidentifiedImageError
7
+ from typing import Dict, Optional, SupportsInt, TYPE_CHECKING, Tuple, Type, TypeVar, Union
8
+
9
+ from .helpers import NodeType, getsize, to_nodes
10
+ from .source import BaseSource, HTTPBasedSource, Twemoji, _has_requests
11
+
12
+ if TYPE_CHECKING:
13
+ from io import BytesIO
14
+
15
+ FontT = Union[ImageFont.ImageFont, ImageFont.FreeTypeFont, ImageFont.TransposedFont]
16
+ ColorT = Union[int, Tuple[int, int, int], Tuple[int, int, int, int], str]
17
+
18
+
19
+ P = TypeVar('P', bound='Pilmoji')
20
+
21
+ __all__ = (
22
+ 'Pilmoji',
23
+ )
24
+
25
+
26
+ class Pilmoji:
27
+ """The main emoji rendering interface.
28
+
29
+ .. note::
30
+ This should be used in a context manager.
31
+
32
+ Parameters
33
+ ----------
34
+ image: :class:`PIL.Image.Image`
35
+ The Pillow image to render on.
36
+ source: Union[:class:`~.BaseSource`, Type[:class:`~.BaseSource`]]
37
+ The emoji image source to use.
38
+ This defaults to :class:`~.TwitterEmojiSource`.
39
+ cache: bool
40
+ Whether or not to cache emojis given from source.
41
+ Enabling this is recommended and by default.
42
+ draw: :class:`PIL.ImageDraw.ImageDraw`
43
+ The drawing instance to use. If left unfilled,
44
+ a new drawing instance will be created.
45
+ render_discord_emoji: bool
46
+ Whether or not to render Discord emoji. Defaults to `True`
47
+ emoji_scale_factor: float
48
+ The default rescaling factor for emojis. Defaults to `1`
49
+ emoji_position_offset: Tuple[int, int]
50
+ A 2-tuple representing the x and y offset for emojis when rendering,
51
+ respectively. Defaults to `(0, 0)`
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ image: Image.Image,
57
+ *,
58
+ source: Union[BaseSource, Type[BaseSource]] = Twemoji,
59
+ cache: bool = True,
60
+ draw: Optional[ImageDraw.ImageDraw] = None,
61
+ render_discord_emoji: bool = True,
62
+ emoji_scale_factor: float = 1.0,
63
+ emoji_position_offset: Tuple[int, int] = (0, 0)
64
+ ) -> None:
65
+ self.image: Image.Image = image
66
+ self.draw: ImageDraw.ImageDraw = draw
67
+
68
+ if isinstance(source, type):
69
+ if not issubclass(source, BaseSource):
70
+ raise TypeError(f'source must inherit from BaseSource, not {source}.')
71
+
72
+ source = source()
73
+
74
+ elif not isinstance(source, BaseSource):
75
+ raise TypeError(f'source must inherit from BaseSource, not {source.__class__}.')
76
+
77
+ self.source: BaseSource = source
78
+
79
+ self._cache: bool = bool(cache)
80
+ self._closed: bool = False
81
+ self._new_draw: bool = False
82
+
83
+ self._render_discord_emoji: bool = bool(render_discord_emoji)
84
+ self._default_emoji_scale_factor: float = emoji_scale_factor
85
+ self._default_emoji_position_offset: Tuple[int, int] = emoji_position_offset
86
+
87
+ self._emoji_cache: Dict[str, BytesIO] = {}
88
+ self._fedi_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
+ def _get_fedi_emoji(self, url: str, /) -> Optional[BytesIO]:
179
+
180
+ if self._cache and url in self._fedi_emoji_cache:
181
+ entry = self._fedi_emoji_cache[url]
182
+ entry.seek(0)
183
+ return entry
184
+
185
+ if stream := self.source.get_fedi_emoji(url):
186
+ if self._cache:
187
+ self._fedi_emoji_cache[url] = stream
188
+
189
+ stream.seek(0)
190
+ return stream
191
+
192
+ def getsize(
193
+ self,
194
+ text: str,
195
+ font: FontT = None,
196
+ *,
197
+ spacing: int = 4,
198
+ emoji_scale_factor: float = None
199
+ ) -> Tuple[int, int]:
200
+ """Return the width and height of the text when rendered.
201
+ This method supports multiline text.
202
+
203
+ Parameters
204
+ ----------
205
+ text: str
206
+ The text to use.
207
+ font
208
+ The font of the text.
209
+ spacing: int
210
+ The spacing between lines, in pixels.
211
+ Defaults to `4`.
212
+ emoji_scalee_factor: float
213
+ The rescaling factor for emojis.
214
+ Defaults to the factor given in the class constructor, or `1`.
215
+ """
216
+ if emoji_scale_factor is None:
217
+ emoji_scale_factor = self._default_emoji_scale_factor
218
+
219
+ return getsize(text, font, spacing=spacing, emoji_scale_factor=emoji_scale_factor)
220
+
221
+ def text(
222
+ self,
223
+ xy: Tuple[int, int],
224
+ text: str,
225
+ fill: ColorT = None,
226
+ font: FontT = None,
227
+ anchor: str = None,
228
+ spacing: int = 4,
229
+ align: str = "left",
230
+ direction: str = None,
231
+ features: str = None,
232
+ language: str = None,
233
+ stroke_width: int = 0,
234
+ stroke_fill: ColorT = None,
235
+ embedded_color: bool = False,
236
+ *args,
237
+ emojis: list = None,
238
+ emoji_scale_factor: float = None,
239
+ emoji_position_offset: Tuple[int, int] = None,
240
+ **kwargs
241
+ ) -> None:
242
+ """Draws the string at the given position, with emoji rendering support.
243
+ This method supports multiline text.
244
+
245
+ .. note::
246
+ Some parameters have not been implemented yet.
247
+
248
+ .. note::
249
+ The signature of this function is a superset of the signature of Pillow's `ImageDraw.text`.
250
+
251
+ .. note::
252
+ Not all parameters are listed here.
253
+
254
+ Parameters
255
+ ----------
256
+ xy: Tuple[int, int]
257
+ The position to render the text at.
258
+ text: str
259
+ The text to render.
260
+ fill
261
+ The fill color of the text.
262
+ font
263
+ The font to render the text with.
264
+ spacing: int
265
+ How many pixels there should be between lines. Defaults to `4`
266
+ emoji_scale_factor: float
267
+ The rescaling factor for emojis. This can be used for fine adjustments.
268
+ Defaults to the factor given in the class constructor, or `1`.
269
+ emoji_position_offset: Tuple[int, int]
270
+ The emoji position offset for emojis. The can be used for fine adjustments.
271
+ Defaults to the offset given in the class constructor, or `(0, 0)`.
272
+ """
273
+
274
+ if emoji_scale_factor is None:
275
+ emoji_scale_factor = self._default_emoji_scale_factor
276
+
277
+ if emoji_position_offset is None:
278
+ emoji_position_offset = self._default_emoji_position_offset
279
+
280
+ if font is None:
281
+ font = ImageFont.load_default()
282
+
283
+ args = (
284
+ fill,
285
+ font,
286
+ anchor,
287
+ spacing,
288
+ align,
289
+ direction,
290
+ features,
291
+ language,
292
+ stroke_width,
293
+ stroke_fill,
294
+ embedded_color,
295
+ *args
296
+ )
297
+
298
+ x, y = xy
299
+ original_x = x
300
+ nodes = to_nodes(text, emojis=emojis)
301
+
302
+ for line in nodes:
303
+ x = original_x
304
+
305
+ for node in line:
306
+ content = node.content
307
+ width, height = font.getsize(content)
308
+
309
+ if node.type is NodeType.text:
310
+ self.draw.text((x, y), content, *args, **kwargs)
311
+ x += width
312
+ continue
313
+
314
+ stream = None
315
+ if node.type is NodeType.emoji:
316
+ stream = self._get_emoji(content)
317
+
318
+ elif self._render_discord_emoji and node.type is NodeType.discord_emoji:
319
+ stream = self._get_discord_emoji(content)
320
+
321
+ elif node.type is NodeType.fedi_emoji:
322
+ stream = self._get_fedi_emoji(content)
323
+
324
+ if not stream:
325
+ self.draw.text((x, y), content, *args, **kwargs)
326
+ x += width
327
+ continue
328
+
329
+ try:
330
+ with Image.open(stream).convert('RGBA') as asset:
331
+ width = int(emoji_scale_factor * font.size)
332
+ size = width, math.ceil(asset.height / asset.width * width)
333
+ asset = asset.resize(size, Image.ANTIALIAS)
334
+
335
+ ox, oy = emoji_position_offset
336
+ self.image.paste(asset, (x + ox, y + oy), asset)
337
+ except UnidentifiedImageError:
338
+ self.draw.text((x, y), content, *args, **kwargs)
339
+ x += width
340
+ continue
341
+
342
+ x += width
343
+ y += spacing + font.size
344
+
345
+ def __enter__(self: P) -> P:
346
+ return self
347
+
348
+ def __exit__(self, *_) -> None:
349
+ self.close()
350
+
351
+ def __repr__(self) -> str:
352
+ return f'<Pilmoji source={self.source} cache={self._cache}>'
pilmoji/helpers.py ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ from enum import Enum
6
+
7
+ from emoji import unicode_codes
8
+ from PIL import ImageFont
9
+
10
+ from typing import Dict, Final, List, NamedTuple, TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ from .core import FontT
14
+
15
+ # This is actually way faster than it seems
16
+ language_pack: Dict[str, str] = unicode_codes.get_emoji_unicode_dict('en')
17
+ _UNICODE_EMOJI_REGEX = '|'.join(map(re.escape, sorted(language_pack.values(), key=len, reverse=True)))
18
+ _DISCORD_EMOJI_REGEX = '<a?:[a-zA-Z0-9_]{2,32}:[0-9]{17,22}>'
19
+ _FEDI_EMOJI_REGEX = ':[a-zA-Z0-9_]+:'
20
+
21
+ EMOJI_REGEX: Final[re.Pattern[str]] = re.compile(f'({_UNICODE_EMOJI_REGEX}|{_DISCORD_EMOJI_REGEX}|{_FEDI_EMOJI_REGEX})')
22
+
23
+ __all__ = (
24
+ 'EMOJI_REGEX',
25
+ 'Node',
26
+ 'NodeType',
27
+ 'to_nodes',
28
+ 'getsize'
29
+ )
30
+
31
+
32
+ class NodeType(Enum):
33
+ """|enum|
34
+
35
+ Represents the type of a :class:`~.Node`.
36
+
37
+ Attributes
38
+ ----------
39
+ text
40
+ This node is a raw text node.
41
+ emoji
42
+ This node is a unicode emoji.
43
+ discord_emoji
44
+ This node is a Discord emoji.
45
+ fedi_emoji
46
+ This node is a Fediverse emoji.
47
+ """
48
+
49
+ text = 0
50
+ emoji = 1
51
+ discord_emoji = 2
52
+ fedi_emoji = 3
53
+
54
+
55
+ class Node(NamedTuple):
56
+ """Represents a parsed node inside of a string.
57
+
58
+ Attributes
59
+ ----------
60
+ type: :class:`~.NodeType`
61
+ The type of this node.
62
+ content: str
63
+ The contents of this node.
64
+ """
65
+
66
+ type: NodeType
67
+ content: str
68
+
69
+ def __repr__(self) -> str:
70
+ return f'<Node type={self.type.name!r} content={self.content!r}>'
71
+
72
+
73
+ def _parse_line(line: str, /, emojis: list = []) -> List[Node]:
74
+ nodes = []
75
+
76
+ for i, chunk in enumerate(EMOJI_REGEX.split(line)):
77
+ if not chunk:
78
+ continue
79
+
80
+ if not i % 2:
81
+ nodes.append(Node(NodeType.text, chunk))
82
+ continue
83
+ # fedi emojiであるかどうかチェックする
84
+ if chunk.startswith(':') and chunk.endswith(':'):
85
+ emoji_name = chunk.replace(':', '')
86
+ for e in emojis:
87
+ if e['name'] == emoji_name:
88
+ # 存在するならノード変換
89
+ node = Node(NodeType.fedi_emoji, e['url'])
90
+ break
91
+ else:
92
+ # 存在しない場合テキスト扱い
93
+ node = Node(NodeType.text, chunk)
94
+ elif len(chunk) > 18: # This is guaranteed to be a Discord emoji
95
+ node = Node(NodeType.discord_emoji, chunk.split(':')[-1][:-1])
96
+ else:
97
+ node = Node(NodeType.emoji, chunk)
98
+
99
+ nodes.append(node)
100
+
101
+ return nodes
102
+
103
+
104
+ def to_nodes(text: str, /, emojis: list = []) -> List[List[Node]]:
105
+ """Parses a string of text into :class:`~.Node`s.
106
+
107
+ This method will return a nested list, each element of the list
108
+ being a list of :class:`~.Node`s and representing a line in the string.
109
+
110
+ The string ``'Hello\nworld'`` would return something similar to
111
+ ``[[Node('Hello')], [Node('world')]]``.
112
+
113
+ Parameters
114
+ ----------
115
+ text: str
116
+ The text to parse into nodes.
117
+
118
+ Returns
119
+ -------
120
+ List[List[:class:`~.Node`]]
121
+ """
122
+ return [_parse_line(line, emojis=emojis) for line in text.splitlines()]
123
+
124
+
125
+ def getsize(
126
+ text: str,
127
+ font: FontT = None,
128
+ *,
129
+ spacing: int = 4,
130
+ emoji_scale_factor: float = 1
131
+ ) -> Tuple[int, int]:
132
+ """Return the width and height of the text when rendered.
133
+ This method supports multiline text.
134
+
135
+ Parameters
136
+ ----------
137
+ text: str
138
+ The text to use.
139
+ font
140
+ The font of the text.
141
+ spacing: int
142
+ The spacing between lines, in pixels.
143
+ Defaults to `4`.
144
+ emoji_scale_factor: float
145
+ The rescaling factor for emojis.
146
+ Defaults to `1`.
147
+ """
148
+ if font is None:
149
+ font = ImageFont.load_default()
150
+
151
+ x, y = 0, 0
152
+ nodes = to_nodes(text)
153
+
154
+ for line in nodes:
155
+ this_x = 0
156
+ for node in line:
157
+ content = node.content
158
+
159
+ if node.type is not NodeType.text:
160
+ width = int(emoji_scale_factor * font.size)
161
+ else:
162
+ width, _ = font.getsize(content)
163
+
164
+ this_x += width
165
+
166
+ y += spacing + font.size
167
+
168
+ if this_x > x:
169
+ x = this_x
170
+
171
+ return x, y - spacing
pilmoji/source.py ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ 'FediEmojiSource',
22
+ 'EmojiCDNSource',
23
+ 'TwitterEmojiSource',
24
+ 'AppleEmojiSource',
25
+ 'GoogleEmojiSource',
26
+ 'MicrosoftEmojiSource',
27
+ 'FacebookEmojiSource',
28
+ 'MessengerEmojiSource',
29
+ 'EmojidexEmojiSource',
30
+ 'JoyPixelsEmojiSource',
31
+ 'SamsungEmojiSource',
32
+ 'WhatsAppEmojiSource',
33
+ 'MozillaEmojiSource',
34
+ 'OpenmojiEmojiSource',
35
+ 'TwemojiEmojiSource',
36
+ 'FacebookMessengerEmojiSource',
37
+ 'Twemoji',
38
+ 'Openmoji',
39
+ )
40
+
41
+
42
+ class BaseSource(ABC):
43
+ """The base class for an emoji image source."""
44
+
45
+ @abstractmethod
46
+ def get_emoji(self, emoji: str, /) -> Optional[BytesIO]:
47
+ """Retrieves a :class:`io.BytesIO` stream for the image of the given emoji.
48
+
49
+ Parameters
50
+ ----------
51
+ emoji: str
52
+ The emoji to retrieve.
53
+
54
+ Returns
55
+ -------
56
+ :class:`io.BytesIO`
57
+ A bytes stream of the emoji.
58
+ None
59
+ An image for the emoji could not be found.
60
+ """
61
+ raise NotImplementedError
62
+
63
+ @abstractmethod
64
+ def get_discord_emoji(self, id: int, /) -> Optional[BytesIO]:
65
+ """Retrieves a :class:`io.BytesIO` stream for the image of the given Discord emoji.
66
+
67
+ Parameters
68
+ ----------
69
+ id: int
70
+ The snowflake ID of the Discord emoji.
71
+
72
+ Returns
73
+ -------
74
+ :class:`io.BytesIO`
75
+ A bytes stream of the emoji.
76
+ None
77
+ An image for the emoji could not be found.
78
+ """
79
+ raise NotImplementedError
80
+
81
+ def __repr__(self) -> str:
82
+ return f'<{self.__class__.__name__}>'
83
+
84
+
85
+ class HTTPBasedSource(BaseSource):
86
+ """Represents an HTTP-based source."""
87
+
88
+ REQUEST_KWARGS: ClassVar[Dict[str, Any]] = {
89
+ 'headers': {'User-Agent': 'Mozilla/5.0'}
90
+ }
91
+
92
+ def __init__(self) -> None:
93
+ if _has_requests:
94
+ self._requests_session = requests.Session()
95
+
96
+ def request(self, url: str) -> bytes:
97
+ """Makes a GET request to the given URL.
98
+
99
+ If the `requests` library is installed, it will be used.
100
+ If it is not installed, :meth:`urllib.request.urlopen` will be used instead.
101
+
102
+ Parameters
103
+ ----------
104
+ url: str
105
+ The URL to request from.
106
+
107
+ Returns
108
+ -------
109
+ bytes
110
+
111
+ Raises
112
+ ------
113
+ Union[:class:`requests.HTTPError`, :class:`urllib.error.HTTPError`]
114
+ There was an error requesting from the URL.
115
+ """
116
+ if _has_requests:
117
+ with self._requests_session.get(url, **self.REQUEST_KWARGS) as response:
118
+ if response.ok:
119
+ return response.content
120
+ else:
121
+ req = Request(url, **self.REQUEST_KWARGS)
122
+ with urlopen(req) as response:
123
+ return response.read()
124
+
125
+ @abstractmethod
126
+ def get_emoji(self, emoji: str, /) -> Optional[BytesIO]:
127
+ raise NotImplementedError
128
+
129
+ @abstractmethod
130
+ def get_discord_emoji(self, id: int, /) -> Optional[BytesIO]:
131
+ raise NotImplementedError
132
+
133
+ class FediEmojiSourceMixin(HTTPBasedSource):
134
+
135
+ @abstractmethod
136
+ def get_emoji(self, emoji: str, /) -> Optional[BytesIO]:
137
+ raise NotImplementedError
138
+
139
+ @abstractmethod
140
+ def get_discord_emoji(self, id: int, /) -> Optional[BytesIO]:
141
+ raise NotImplementedError
142
+
143
+ def get_fedi_emoji(self, url: dict, /) -> Optional[BytesIO]:
144
+
145
+ _to_catch = HTTPError if not _has_requests else requests.HTTPError
146
+
147
+ try:
148
+ return BytesIO(self.request(url))
149
+ except _to_catch:
150
+ pass
151
+
152
+ class DiscordEmojiSourceMixin(HTTPBasedSource):
153
+ """A mixin that adds Discord emoji functionality to another source."""
154
+
155
+ BASE_DISCORD_EMOJI_URL: ClassVar[str] = 'https://cdn.discordapp.com/emojis/'
156
+
157
+ @abstractmethod
158
+ def get_emoji(self, emoji: str, /) -> Optional[BytesIO]:
159
+ raise NotImplementedError
160
+
161
+ def get_discord_emoji(self, id: int, /) -> Optional[BytesIO]:
162
+ url = self.BASE_DISCORD_EMOJI_URL + str(id) + '.png'
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 EmojiCDNSource(DiscordEmojiSourceMixin, FediEmojiSourceMixin):
172
+ """A base source that fetches emojis from https://emojicdn.elk.sh/."""
173
+
174
+ BASE_EMOJI_CDN_URL: ClassVar[str] = 'https://emojicdn.elk.sh/'
175
+ STYLE: ClassVar[str] = None
176
+
177
+ def get_emoji(self, emoji: str, /) -> Optional[BytesIO]:
178
+ if self.STYLE is None:
179
+ raise TypeError('STYLE class variable unfilled.')
180
+
181
+ url = self.BASE_EMOJI_CDN_URL + quote_plus(emoji) + '?style=' + quote_plus(self.STYLE)
182
+ _to_catch = HTTPError if not _has_requests else requests.HTTPError
183
+
184
+ try:
185
+ return BytesIO(self.request(url))
186
+ except _to_catch:
187
+ pass
188
+
189
+
190
+ class TwitterEmojiSource(EmojiCDNSource):
191
+ """A source that uses Twitter-style emojis. These are also the ones used in Discord."""
192
+ STYLE = 'twitter'
193
+
194
+
195
+ class AppleEmojiSource(EmojiCDNSource):
196
+ """A source that uses Apple emojis."""
197
+ STYLE = 'apple'
198
+
199
+
200
+ class GoogleEmojiSource(EmojiCDNSource):
201
+ """A source that uses Google emojis."""
202
+ STYLE = 'google'
203
+
204
+
205
+ class MicrosoftEmojiSource(EmojiCDNSource):
206
+ """A source that uses Microsoft emojis."""
207
+ STYLE = 'microsoft'
208
+
209
+
210
+ class SamsungEmojiSource(EmojiCDNSource):
211
+ """A source that uses Samsung emojis."""
212
+ STYLE = 'samsung'
213
+
214
+
215
+ class WhatsAppEmojiSource(EmojiCDNSource):
216
+ """A source that uses WhatsApp emojis."""
217
+ STYLE = 'whatsapp'
218
+
219
+
220
+ class FacebookEmojiSource(EmojiCDNSource):
221
+ """A source that uses Facebook emojis."""
222
+ STYLE = 'facebook'
223
+
224
+
225
+ class MessengerEmojiSource(EmojiCDNSource):
226
+ """A source that uses Facebook Messenger's emojis."""
227
+ STYLE = 'messenger'
228
+
229
+
230
+ class JoyPixelsEmojiSource(EmojiCDNSource):
231
+ """A source that uses JoyPixels' emojis."""
232
+ STYLE = 'joypixels'
233
+
234
+
235
+ class OpenmojiEmojiSource(EmojiCDNSource):
236
+ """A source that uses Openmoji emojis."""
237
+ STYLE = 'openmoji'
238
+
239
+
240
+ class EmojidexEmojiSource(EmojiCDNSource):
241
+ """A source that uses Emojidex emojis."""
242
+ STYLE = 'emojidex'
243
+
244
+
245
+ class MozillaEmojiSource(EmojiCDNSource):
246
+ """A source that uses Mozilla's emojis."""
247
+ STYLE = 'mozilla'
248
+
249
+
250
+ # Aliases
251
+ Openmoji = OpenmojiEmojiSource
252
+ FacebookMessengerEmojiSource = MessengerEmojiSource
253
+ TwemojiEmojiSource = Twemoji = TwitterEmojiSource