Other memes: - SoyPhone Utility functions added: - Square aspect ratio cropper (square_crop) - Circle profile pic maker (circle_pfp) - PIL alpha_composite helper (alpha_comp_prep) - Text wrapping and bounding helpers - Tuple adder (tuple_add) - iPhone timestamp generator for top bar (get_iphone_time_s) Also added: - Meme abstract base class (in pseudbot/meme/abc.py) - Post styling information (in pseudbot/meme/post_styles.py)canonical
@ -0,0 +1,100 @@
|
||||
""" |
||||
Test of Discord fake post pixmap generator. |
||||
""" |
||||
from PIL import Image |
||||
|
||||
from pseudbot.meme.post import Post |
||||
from pseudbot.meme.phone_screenshot import PhoneScreenshot |
||||
from pseudbot.meme.soyphone import SoyPhone |
||||
|
||||
tt = { |
||||
"channel_name": "staff-feet-pics", |
||||
"posts": [ |
||||
{ |
||||
"screen_name": "Owen", |
||||
"pfp": "media/phew/EyeJ-4nWEAAFybq.jpg", |
||||
"text": "I walk up 4 flights of stairs every single day", |
||||
"timestamp": "Today at 5:07 AM", |
||||
}, |
||||
{ |
||||
"screen_name": "Owen", |
||||
"pfp": "media/phew/EyeJ-4nWEAAFybq.jpg", |
||||
"text": "I walk up 4 flights of stairs every single day", |
||||
"timestamp": "Today at 5:07 AM", |
||||
}, |
||||
{ |
||||
"screen_name": "Owen", |
||||
"pfp": "media/phew/EyeJ-4nWEAAFybq.jpg", |
||||
"text": "I walk up 4 flights of stairs every single day", |
||||
"timestamp": "Today at 5:07 AM", |
||||
}, |
||||
{ |
||||
"screen_name": "Owen", |
||||
"pfp": "media/phew/EyeJ-4nWEAAFybq.jpg", |
||||
"text": "I walk up 4 flights of stairs every single day", |
||||
"timestamp": "Today at 5:07 AM", |
||||
}, |
||||
{ |
||||
"screen_name": "Owen", |
||||
"pfp": "media/phew/EyeJ-4nWEAAFybq.jpg", |
||||
"text": "I walk up 4 flights of stairs every single day", |
||||
"timestamp": "Today at 5:07 AM", |
||||
}, |
||||
{ |
||||
"screen_name": "Owen", |
||||
"pfp": "media/phew/EyeJ-4nWEAAFybq.jpg", |
||||
"text": "I walk up 4 flights of stairs every single day", |
||||
"timestamp": "Today at 5:07 AM", |
||||
}, |
||||
{ |
||||
"screen_name": "Owen", |
||||
"pfp": "media/phew/EyeJ-4nWEAAFybq.jpg", |
||||
"text": "I walk up 4 flights of stairs every single day", |
||||
"timestamp": "Today at 5:07 AM", |
||||
}, |
||||
{ |
||||
"screen_name": "Owen", |
||||
"pfp": "media/phew/EyeJ-4nWEAAFybq.jpg", |
||||
"text": "I walk up 4 flights of stairs every single day", |
||||
"timestamp": "Today at 5:07 AM", |
||||
}, |
||||
{ |
||||
"screen_name": "Owen", |
||||
"pfp": "media/bonk/E4chSR5WQAQ_Hn3.jpg", |
||||
"text": "it has an orange tip", |
||||
"timestamp": "Today at 5:07 AM", |
||||
}, |
||||
{ |
||||
"screen_name": "Owen", |
||||
"pfp": "media/phew/EyeJ-4nWEAAFybq.jpg", |
||||
"text": "go look in the fields for it", |
||||
"timestamp": "Today at 5:07 AM", |
||||
}, |
||||
], |
||||
} |
||||
bt = "Owen #1" |
||||
|
||||
# p = Post(top_text=tt, bottom_text=bt, style="discord") |
||||
# pi = p.get_pixmap() |
||||
# pi.show() |
||||
|
||||
# This Post() generates an error pixmap |
||||
# q = Post(top_text={}, bottom_text=bt, style="discord") |
||||
# qi = q.get_pixmap() |
||||
# qi.show() |
||||
|
||||
# Makes a fake Discord iPhone screenshot |
||||
r = PhoneScreenshot(top_text=tt, bottom_text=bt) |
||||
ri = r.get_pixmap() |
||||
# ri.show() |
||||
|
||||
# Puts the above screenshot pixmap in an iPhone |
||||
s = SoyPhone(screenshot=ri) |
||||
si = s.get_pixmap() |
||||
si.show() |
||||
|
||||
# Puts an arbitrary pixmap in an iPhone |
||||
# i = Image.open("media/phew/EyeJ-4nWEAAFybq.jpg").convert("RGBA") |
||||
# t = SoyPhone(screenshot=i) |
||||
# ti = t.get_pixmap() |
||||
# ti.show() |
After Width: | Height: | Size: 1.1 MiB |
After Width: | Height: | Size: 70 KiB |
After Width: | Height: | Size: 38 KiB |
@ -0,0 +1,145 @@
|
||||
from abc import ABCMeta, abstractmethod |
||||
from PIL import Image, ImageDraw, ImageFont |
||||
import typing |
||||
|
||||
from pseudbot.pastas import concat_pasta |
||||
from pseudbot.util import styled_wrap, text_wrap |
||||
|
||||
|
||||
class Meme(metaclass=ABCMeta): |
||||
fonts = { |
||||
"old_console": "font/clacon.ttf", |
||||
"phone_sans": "font/Cantarell-VF.otf", |
||||
} |
||||
styles = {} |
||||
style_methods = {} |
||||
offsets = {} |
||||
error = False |
||||
image = None |
||||
reason = "" |
||||
post = None |
||||
posts = None |
||||
|
||||
def __init__( |
||||
self, |
||||
top_text: dict = None, |
||||
bottom_text: str = None, |
||||
): |
||||
self.top_text = top_text |
||||
self.bottom_text = bottom_text |
||||
|
||||
def init_style_posts(self): |
||||
self.check_posts() |
||||
self.check_style() |
||||
|
||||
def check_posts(self): |
||||
if not "posts" in self.top_text or len(self.top_text["posts"]) < 1: |
||||
self.error = True |
||||
self.reason = 'No "posts" in top_text!' |
||||
else: |
||||
self.posts = self.top_text["posts"] |
||||
self.post = self.top_text["posts"][0] |
||||
|
||||
if not "screen_name" in self.post: |
||||
self.post["screen_name"] = "⁉" |
||||
if not "text" in self.post: |
||||
self.post["text"] = concat_pasta() |
||||
if not "timestamp" in self.post: |
||||
self.post["timestamp"] = "Today at time" |
||||
|
||||
def check_style(self): |
||||
if self.style not in self.styles: |
||||
self.error = True |
||||
self.reason = "Invalid style!" |
||||
elif self.style not in self.style_methods: |
||||
self.error = True |
||||
self.reason = "Style not implemented!" |
||||
|
||||
@abstractmethod |
||||
def help(self) -> str: |
||||
""" |
||||
User-callable help for meme generator. |
||||
""" |
||||
pass |
||||
|
||||
@abstractmethod |
||||
def mk_pixmap(self): |
||||
pass |
||||
|
||||
def get_styles(self) -> [str]: |
||||
return self.styles |
||||
|
||||
def help(self) -> str: |
||||
""" |
||||
User-callable help for meme generator. |
||||
""" |
||||
return "TODO" |
||||
|
||||
def get_text(self) -> tuple: |
||||
return (self.top_text, self.bottom_text) |
||||
|
||||
def get_pixmap(self): |
||||
if self.image is None: |
||||
if self.error is True: |
||||
return self.error_pixmap(reason=self.reason) |
||||
else: |
||||
self.mk_pixmap() |
||||
|
||||
return self.image |
||||
|
||||
def draw_styled_text( |
||||
self, img: Image, text: str, style: dict, offset: tuple = None |
||||
) -> Image: |
||||
if offset is None: |
||||
offset = style["offset"] |
||||
|
||||
if "wrap" in style: |
||||
text = styled_wrap(text, style["wrap"]) |
||||
|
||||
align = style["align"] if "align" in style else "left" |
||||
|
||||
image = self.draw_text( |
||||
img=img, |
||||
text=text, |
||||
offset=offset, |
||||
size=style["size"], |
||||
fill=style["color"], |
||||
variation=style["style"], |
||||
fontname=style["font"], |
||||
align=align, |
||||
) |
||||
return image |
||||
|
||||
def draw_text( |
||||
self, |
||||
img: Image, |
||||
text: str, |
||||
offset: tuple, |
||||
size: int, |
||||
fill: tuple, |
||||
variation: str = "Regular", |
||||
fontname: str = "phone_sans", |
||||
align="left", |
||||
) -> Image: |
||||
font = ImageFont.truetype(self.fonts[fontname], size) |
||||
font.set_variation_by_name(variation) |
||||
|
||||
d = ImageDraw.Draw(img) |
||||
d.text(xy=offset, text=text, fill=fill, font=font, align=align) |
||||
|
||||
return img |
||||
|
||||
def error_pixmap(self, reason: str) -> Image: |
||||
efont = ImageFont.truetype("font/clacon.ttf", 70) |
||||
|
||||
reason = text_wrap(text=reason, width=16, height=5) |
||||
|
||||
with Image.open("img/pseud-error.png").convert("RGBA") as base_img: |
||||
text_img = Image.new("RGBA", base_img.size, (255, 255, 255, 0)) |
||||
d = ImageDraw.Draw(text_img) |
||||
|
||||
d.text((292, 466), reason, font=efont, fill=(0, 0, 0, 255)) |
||||
|
||||
out = Image.alpha_composite(base_img, text_img) |
||||
|
||||
return out |
@ -0,0 +1,184 @@
|
||||
from .abc import Meme |
||||
from PIL import Image, ImageDraw, ImageFont |
||||
import typing |
||||
|
||||
from pseudbot.meme.post import Post |
||||
from pseudbot.util import ( |
||||
alpha_comp_prep, |
||||
circle_pfp, |
||||
get_iphone_time_s, |
||||
text_wrap, |
||||
tuple_add, |
||||
) |
||||
|
||||
|
||||
class PhoneScreenshot(Meme): |
||||
from .post_styles import styles, time_style |
||||
|
||||
def __init__( |
||||
self, |
||||
top_text: dict = None, |
||||
bottom_text: str = None, |
||||
style: str = "discord", |
||||
width: int = 1200, |
||||
height: int = 2340, |
||||
notification: str = None, |
||||
): |
||||
self.top_text = top_text |
||||
self.bottom_text = bottom_text |
||||
self.style = style |
||||
self.width = width |
||||
self.height = height |
||||
self.notification = notification |
||||
|
||||
self.style_methods = {"discord": self.mk_pixmap} |
||||
|
||||
self.init_style_posts() |
||||
|
||||
if self.notification is not None: |
||||
self.notification_style = { |
||||
"offset": (102, 91), |
||||
"no_text_img": "templates/iphone/iphone-noti-grindr-text.png", |
||||
"icon": { |
||||
"offset": (26, 34), |
||||
"size": (62, 62), |
||||
"files": { |
||||
"grindr": "templates/iphone/icons/notification/grindr.png" |
||||
}, |
||||
}, |
||||
"text": { |
||||
"app_name": { |
||||
"font": "phone_sans", |
||||
"style": "Regular", |
||||
"size": 35, |
||||
"offset": (106, 53), |
||||
}, |
||||
"heading": { |
||||
"font": "phone_sans", |
||||
"style": "Regular", |
||||
"size": 35, |
||||
"offset": (34, 128), |
||||
}, |
||||
"body": { |
||||
"font": "phone_sans", |
||||
"style": "Regular", |
||||
"size": 35, |
||||
"offset": (34, 181), |
||||
}, |
||||
}, |
||||
} |
||||
|
||||
def overlay_noti(base_img: Image) -> Image: |
||||
return base_img |
||||
|
||||
def mk_bars(self) -> Image: |
||||
base_img = Image.new("RGBA", (self.width, self.height), (0, 0, 0, 0)) |
||||
|
||||
for bar in self.styles[self.style]["bars"]: |
||||
is_top = False |
||||
is_bottom = False |
||||
do_time = False |
||||
bg_img_path = bar["bg_img"] |
||||
|
||||
if "top" in bar: |
||||
if bar["top"] is True: |
||||
is_top = True |
||||
do_time = True |
||||
if self.notification is not None: |
||||
do_time = False |
||||
if "alt_bg_img" in bar: |
||||
bg_img_path = self.bar["alt_bg_img"] |
||||
|
||||
if "bottom" in bar: |
||||
if bar["bottom"] is True: |
||||
is_bottom = True |
||||
|
||||
with Image.open(bg_img_path).convert("RGBA") as _bar_img: |
||||
with alpha_comp_prep( |
||||
Image.open(bg_img_path).convert("RGBA"), |
||||
size=base_img.size, |
||||
offset=bar["offset"], |
||||
) as bar_img: |
||||
if "text" in bar: |
||||
try: |
||||
if bar["text"]["content"] in self.top_text: |
||||
if "format" in bar["text"]: |
||||
bar_text = bar["text"]["format"].replace( |
||||
"%%%", |
||||
self.top_text[bar["text"]["content"]], |
||||
) |
||||
else: |
||||
bar_text = self.top_text[ |
||||
bar["text"]["content"] |
||||
] |
||||
else: |
||||
bar_text = bar["text"]["default"] |
||||
except KeyError: |
||||
bar_text = "default text" |
||||
|
||||
bar_img = self.draw_text( |
||||
img=bar_img, |
||||
text=bar_text, |
||||
offset=tuple_add( |
||||
bar["text"]["offset"], bar["offset"] |
||||
), |
||||
size=bar["text"]["size"], |
||||
fill=bar["text"]["color"], |
||||
variation=bar["text"]["style"], |
||||
fontname=bar["text"]["font"], |
||||
) |
||||
|
||||
if do_time is True: |
||||
bar_img = self.draw_text( |
||||
img=bar_img, |
||||
text=get_iphone_time_s(), |
||||
offset=self.time_style["offset"], |
||||
size=self.time_style["size"], |
||||
fill=self.time_style["fill"][self.style], |
||||
variation=self.time_style["style"], |
||||
fontname=self.time_style["font"], |
||||
) |
||||
|
||||
base_img = Image.alpha_composite(base_img, bar_img) |
||||
|
||||
if is_bottom is True: |
||||
self.bottom_y_offset = 0 - _bar_img.size[1] |
||||
|
||||
if self.notification is not None: |
||||
base_img = self.overlay_noti(base_img) |
||||
|
||||
return base_img |
||||
|
||||
def arrange_posts(self) -> Image: |
||||
post_base = Image.new( |
||||
"RGBA", |
||||
(self.width, self.height), |
||||
self.styles[self.style]["background"], |
||||
) |
||||
|
||||
x_offset = 33 |
||||
y_offset = self.height |
||||
|
||||
if hasattr(self, "bottom_y_offset"): |
||||
y_offset = y_offset + self.bottom_y_offset |
||||
|
||||
for post in reversed(self.posts): |
||||
p = Post( |
||||
top_text={"posts": [post]}, |
||||
bottom_text=self.bottom_text, |
||||
style=self.style, |
||||
) |
||||
post_img = p.get_pixmap() |
||||
y_offset = y_offset - post_img.size[1] |
||||
post_base.paste(post_img, box=(x_offset, y_offset)) |
||||
|
||||
if y_offset < 0: |
||||
break |
||||
|
||||
return post_base |
||||
|
||||
def mk_pixmap(self): |
||||
bars = self.mk_bars() # Must be done before making posts! |
||||
posts = self.arrange_posts() |
||||
|
||||
self.image = Image.alpha_composite(posts, bars) |
@ -0,0 +1,126 @@
|
||||
from .abc import Meme |
||||
from PIL import Image, ImageDraw, ImageFont |
||||
import typing |
||||
|
||||
from pseudbot.util import ( |
||||
_text_wrap, |
||||
alpha_comp_prep, |
||||
circle_pfp, |
||||
styled_wrap, |
||||
text_wrap, |
||||
tuple_add, |
||||
) |
||||
|
||||
|
||||
class Post(Meme): |
||||
from .post_styles import styles |
||||
|
||||
def __init__( |
||||
self, |
||||
top_text: dict = None, |
||||
bottom_text: str = None, |
||||
style: str = "discord", |
||||
): |
||||
self.top_text = top_text |
||||
self.bottom_text = bottom_text |
||||
self.style = style |
||||
|
||||
self.style_methods = {"discord": self.draw_discord_post} |
||||
|
||||
self.init_style_posts() |
||||
|
||||
def get_post_dimens(self) -> int: |
||||
max_dimens = (0, 0) |
||||
offsets_add = {} |
||||
post_items = dict( |
||||
sorted( |
||||
self.styles[self.style]["fields"].items(), |
||||
key=lambda s: s[1]["offset"][1], |
||||
) |
||||
) |
||||
while len(post_items) > 0: |
||||
items = [k for k in post_items.keys()] |
||||
for k in items: |
||||
offset_add = (0, 0) |
||||
if "offset_add" in post_items[k]: |
||||
if k in offsets_add: |
||||
offset_add = offsets_add[k] |
||||
else: |
||||
continue |
||||
if k not in self.post: |
||||
if "default" in post_items[k]: |
||||
self.post[k] = post_items[k]["default"] |
||||
else: |
||||
self.post[k] = "default text" |
||||
|
||||
wrap = post_items[k]["wrap"] |
||||
lines = _text_wrap(self.post[k], wrap[0], wrap[1]) |
||||
font = ImageFont.truetype( |
||||
self.fonts[post_items[k]["font"]], post_items[k]["size"] |
||||
) |
||||
size = font.getsize("\n".join(lines)) |
||||
if len(lines) > 1: |
||||
size = (size[0], size[1] * len(lines)) |
||||
|
||||
offset = tuple_add(post_items[k]["offset"], offset_add) |
||||
_max_dimens = tuple_add(offset, size) |
||||
|
||||
max_dimens = ( |
||||
max(_max_dimens[0], max_dimens[0]), |
||||
max(_max_dimens[1], max_dimens[1]), |
||||
) |
||||
|
||||
if "offset_for" in post_items[k]: |
||||
for dependent in post_items[k]["offset_for"]: |
||||
add_x = 0 |
||||
add_y = 0 |
||||
if "x" in post_items[k]["offset_for"][dependent]: |
||||
add_x = _max_dimens[0] |
||||
if "y" in post_items[k]["offset_for"][dependent]: |
||||
add_y = _max_dimens[1] |
||||
|
||||
offsets_add[dependent] = (add_x, add_y) |
||||
|
||||
self.offsets[k] = offset |
||||
post_items.pop(k) |
||||
|
||||
max_dimens = tuple_add(max_dimens, self.styles[self.style]["margin"]) |
||||
return max_dimens |
||||
|
||||
def draw_post(self) -> Image: |
||||
pass |
||||
|
||||
def draw_discord_post(self) -> Image: |
||||
pfp_size = 132 |
||||
|
||||
if self.error is True: |
||||
return self.error_pixmap(self.reason) |
||||
else: |
||||
dimens = self.get_post_dimens() |
||||
|
||||
post_base = Image.new( |
||||
"RGBA", dimens, self.styles[self.style]["background"] |
||||
) |
||||
|
||||
# Draw circle profile pic |
||||
with alpha_comp_prep( |
||||
circle_pfp( |
||||
Image.open(self.post["pfp"]).convert("RGBA"), |
||||
size=pfp_size, |
||||
), |
||||
size=post_base.size, |
||||
) as pfp: |
||||
post_base = Image.alpha_composite(post_base, pfp) |
||||
|
||||
for field in self.styles[self.style]["fields"].keys(): |
||||
post_base = self.draw_styled_text( |
||||
img=post_base, |
||||
text=self.post[field], |
||||
style=self.styles[self.style]["fields"][field], |
||||
offset=self.offsets[field], |
||||
) |
||||
|
||||
return post_base |
||||
|
||||
def mk_pixmap(self): |
||||
self.image = self.style_methods[self.style]() |
@ -0,0 +1,81 @@
|
||||
styles = { |
||||
"discord": { |
||||
"margin": (0, 43), |
||||
"fields": { |
||||
"screen_name": { |
||||
"font": "phone_sans", |
||||
"style": "Extra Bold", |
||||
"offset": (169, 0), |
||||
"size": 59, |
||||
"color": (255, 255, 255, 255), |
||||
"default": "XxX_HungDaddy_XxX_69", |
||||
"offset_for": { |
||||
"timestamp": "x", |
||||
"text": "y", |
||||
}, |
||||
"wrap": (30, 1), |
||||
}, |
||||
"text": { |
||||
"font": "phone_sans", |
||||
"style": "Regular", |
||||
"offset": (169, 2), |
||||
"offset_add": True, |
||||
"size": 59, |
||||
"color": (255, 255, 255, 255), |
||||
"wrap": (40, 20), |
||||
}, |
||||
"timestamp": { |
||||
"font": "phone_sans", |
||||
"style": "Regular", |
||||
"offset": (66, 17), |
||||
"offset_add": True, |
||||
"size": 35, |
||||
"color": (112, 115, 122, 255), |
||||
"default": "Today at 5:07 AM", |
||||
"wrap": (30, 1), |
||||
}, |
||||
}, |
||||
"background": (54, 57, 64, 255), |
||||
"bars": [ |
||||
{ |
||||
"top": True, |
||||
"bg_img": "templates/discord-phone/discord-heading-notxt-no-notification-notime.png", |
||||
"alt_bg_img": "templates/discord-phone/discord-heading-notxt.png", |
||||
"offset": (0, 0), |
||||
"text": { |
||||
"font": "phone_sans", |
||||
"style": "Bold", |
||||
"offset": (286, 174), |
||||
"size": 58, |
||||
"color": (255, 255, 255, 255), |
||||
"content": "channel_name", |
||||
"default": "chat", |
||||
}, |
||||
}, |
||||
{ |
||||
"bottom": True, |
||||
"bg_img": "templates/discord-phone/discord-iphone-bottom-bar.png", |
||||
"offset": (0, 2080), |
||||
"text": { |
||||
"font": "phone_sans", |
||||
"style": "Regular", |
||||
"offset": (380, 62), |
||||
"size": 49, |
||||
"color": (112, 115, 122, 255), |
||||
"format": "Message #%%%", |
||||
"content": "channel_name", |
||||
"default": "Message #chat", |
||||
}, |
||||
}, |
||||
], |
||||
}, |
||||
"twitter": {}, |
||||
} |
||||
|
||||
time_style = { |
||||
"size": 54, |
||||
"offset": (48, 38), |
||||
"font": "phone_sans", |
||||
"style": "Bold", |
||||
"fill": {"discord": (255, 255, 255, 255)}, |
||||
} |
@ -0,0 +1,35 @@
|
||||
from .abc import Meme |
||||
from PIL import Image |
||||
import typing |
||||
from pseudbot.util import alpha_comp_prep |
||||
|
||||
|
||||
class SoyPhone(Meme): |
||||
def __init__( |
||||
self, |
||||
screenshot: Image = None, |
||||
): |
||||
self.screenshot = screenshot |
||||
|
||||
if self.screenshot is None: |
||||
self.error = True |
||||
self.reason = 'No "screenshot" provided for iPhone!' |
||||
|
||||
def mk_pixmap(self): |
||||
with Image.open("templates/soyphone/soyphone-13.png").convert( |
||||
"RGBA" |
||||
) as overlay_img: |
||||
self.image = Image.new("RGBA", overlay_img.size, (0, 0, 0, 255)) |
||||
|
||||
self.screenshot = self.screenshot.resize( |
||||
(378, 804), resample=Image.BICUBIC |
||||
) |
||||
self.screenshot = self.screenshot.rotate( |
||||
4.8, resample=Image.BICUBIC, expand=True |
||||
) |
||||
self.screenshot = alpha_comp_prep( |
||||
self.screenshot, size=overlay_img.size, offset=(27, 32) |
||||
) |
||||
|
||||
overlay_img = Image.alpha_composite(self.screenshot, overlay_img) |
||||
self.image = Image.alpha_composite(self.image, overlay_img) |
After Width: | Height: | Size: 31 KiB |
After Width: | Height: | Size: 16 KiB |
After Width: | Height: | Size: 20 KiB |
After Width: | Height: | Size: 3.2 KiB |
After Width: | Height: | Size: 96 KiB |
After Width: | Height: | Size: 376 KiB |