+21
-5
@@ -1,5 +1,15 @@
|
|||||||
version: 2.1
|
version: 2.1
|
||||||
|
|
||||||
|
workflows:
|
||||||
|
main:
|
||||||
|
jobs:
|
||||||
|
- unit-tests
|
||||||
|
- build-docs:
|
||||||
|
filters:
|
||||||
|
branches:
|
||||||
|
only:
|
||||||
|
- master
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-docs:
|
build-docs:
|
||||||
docker:
|
docker:
|
||||||
@@ -19,8 +29,14 @@ jobs:
|
|||||||
git diff-index --quiet HEAD || git commit -m 'Automated README [ci skip]'
|
git diff-index --quiet HEAD || git commit -m 'Automated README [ci skip]'
|
||||||
git push origin master
|
git push origin master
|
||||||
name: Building docs
|
name: Building docs
|
||||||
|
unit-tests:
|
||||||
workflows:
|
docker:
|
||||||
main:
|
- image: circleci/python:latest
|
||||||
jobs:
|
steps:
|
||||||
- build-docs
|
- checkout
|
||||||
|
- run:
|
||||||
|
command: |
|
||||||
|
sudo pip install -r requirements.txt
|
||||||
|
sudo pip install pytest
|
||||||
|
python -m pytest ./tests/unit
|
||||||
|
name: Unit tests
|
||||||
@@ -5,3 +5,4 @@ error_*.txt
|
|||||||
tmp
|
tmp
|
||||||
.key*
|
.key*
|
||||||
*.pyc
|
*.pyc
|
||||||
|
.pytest_cache
|
||||||
|
|||||||
+36
-43
@@ -8,8 +8,8 @@ import sys
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from meme_otron import img_factory as imgf
|
from meme_otron import img_factory
|
||||||
from meme_otron import meme_db as db
|
from meme_otron import meme_db
|
||||||
from meme_otron import utils
|
from meme_otron import utils
|
||||||
from meme_otron import meme_otron
|
from meme_otron import meme_otron
|
||||||
from meme_otron import VERSION
|
from meme_otron import VERSION
|
||||||
@@ -26,19 +26,17 @@ if token is None:
|
|||||||
logging.error("No token was loaded, please verify your .env file")
|
logging.error("No token was loaded, please verify your .env file")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
imgf.load_fonts()
|
img_factory.load_fonts()
|
||||||
db.load_memes()
|
meme_db.load_memes()
|
||||||
|
|
||||||
client = discord.Client()
|
client = discord.Client()
|
||||||
|
|
||||||
SENT = {}
|
SENT = {}
|
||||||
|
|
||||||
def debug(message, txt):
|
|
||||||
|
def debug(message: discord.Message, txt: str):
|
||||||
"""
|
"""
|
||||||
Print a log with the context of the current event
|
Print a log with the context of the current event
|
||||||
|
|
||||||
:param (discord.Message) message: message that triggered the event
|
|
||||||
:param (str) txt: text of the log
|
|
||||||
"""
|
"""
|
||||||
logging.info(f"{message.guild} > #{message.channel}: {txt}")
|
logging.info(f"{message.guild} > #{message.channel}: {txt}")
|
||||||
|
|
||||||
@@ -59,13 +57,7 @@ async def on_ready():
|
|||||||
logging.info(f'- {guild.name}(id: {guild.id})')
|
logging.info(f'- {guild.name}(id: {guild.id})')
|
||||||
|
|
||||||
|
|
||||||
async def delete(message):
|
async def delete(message: discord.Message) -> bool:
|
||||||
"""
|
|
||||||
Delete a discord message
|
|
||||||
|
|
||||||
:param (discord.Message) message:
|
|
||||||
:rtype: bool
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
await message.delete()
|
await message.delete()
|
||||||
return True
|
return True
|
||||||
@@ -77,24 +69,22 @@ async def delete(message):
|
|||||||
|
|
||||||
|
|
||||||
@client.event
|
@client.event
|
||||||
async def on_message(message):
|
async def on_message(message: discord.Message):
|
||||||
"""
|
"""
|
||||||
Called when a message is sent to any channel on any guild
|
Called when a message is sent to any channel on any guild
|
||||||
|
|
||||||
:param (discord.Message) message: message sent
|
|
||||||
"""
|
"""
|
||||||
# Ignore self messages
|
# Ignore self messages
|
||||||
if message.author == client.user:
|
if message.author == client.user:
|
||||||
return
|
return
|
||||||
|
|
||||||
direct = message.channel.type == discord.ChannelType.private
|
is_direct = message.channel.type == discord.ChannelType.private
|
||||||
|
|
||||||
if not direct:
|
if not is_direct:
|
||||||
mid = f'{message.guild.id}/{message.channel.id}/{message.author.id}'
|
message_id = f'{message.guild.id}/{message.channel.id}/{message.author.id}'
|
||||||
else:
|
else:
|
||||||
mid = message.author.id
|
message_id = message.author.id
|
||||||
|
|
||||||
if direct or client.user in message.mentions:
|
if is_direct or client.user in message.mentions:
|
||||||
message.content = re.sub(r'<@[^>]+>', '', message.content).strip()
|
message.content = re.sub(r'<@[^>]+>', '', message.content).strip()
|
||||||
args = utils.parse_arguments(message.content)
|
args = utils.parse_arguments(message.content)
|
||||||
debug(message, str(args))
|
debug(message, str(args))
|
||||||
@@ -112,11 +102,11 @@ async def on_message(message):
|
|||||||
return
|
return
|
||||||
if len(args) > 0 and args[0].lower().strip() == "list":
|
if len(args) > 0 and args[0].lower().strip() == "list":
|
||||||
await message.channel.send(f"Here is a list of all known templates:\n"
|
await message.channel.send(f"Here is a list of all known templates:\n"
|
||||||
f"```{', '.join(db.LIST)}```")
|
f"```{', '.join(meme_db.LIST)}```")
|
||||||
return
|
return
|
||||||
if len(args) > 0 and args[0].lower().strip() == "delete":
|
if len(args) > 0 and args[0].lower().strip() == "delete":
|
||||||
if mid in SENT and len(SENT[mid]) > 0 and await delete(SENT[mid][-1]):
|
if message_id in SENT and len(SENT[message_id]) > 0 and await delete(SENT[message_id][-1]):
|
||||||
if not direct:
|
if not is_direct:
|
||||||
await delete(message)
|
await delete(message)
|
||||||
else:
|
else:
|
||||||
await message.add_reaction("⚠")
|
await message.add_reaction("⚠")
|
||||||
@@ -126,26 +116,29 @@ async def on_message(message):
|
|||||||
if len(args) > 1 and message.author.display_name is not None:
|
if len(args) > 1 and message.author.display_name is not None:
|
||||||
left_wmark_text = f"By {message.author.display_name}"
|
left_wmark_text = f"By {message.author.display_name}"
|
||||||
logging.info(args[0])
|
logging.info(args[0])
|
||||||
meme_id = re.sub(r'[^\w ]', "", args[0])
|
meme_id = re.sub(r'[^A-Za-z0-9 _]', "", args[0]).strip()
|
||||||
args[0] = meme_id
|
args[0] = meme_id
|
||||||
img = meme_otron.compute(*args, left_wmark_text=left_wmark_text)
|
img = meme_otron.compute(*args, left_wmark_text=left_wmark_text)
|
||||||
if img is None:
|
if img is None:
|
||||||
if len(meme_id) == 0:
|
if len(meme_id) == 0:
|
||||||
response = f":warning: Template not found\n"
|
response = f":warning: Template not found\n"
|
||||||
else:
|
else:
|
||||||
hint = db.find_nearest(meme_id)
|
hint = meme_db.find_nearest(meme_id)
|
||||||
response = f":warning: Template `{meme_id}` not found\n"
|
response = f":warning: Template `{meme_id}` not found\n"
|
||||||
if hint is not None:
|
if hint is not None:
|
||||||
response += f"Did you mean `{hint}`?\n"
|
response += f"Did you mean `{hint}`?\n"
|
||||||
response += f"You can find a more detailed help and a list of templates at:\n" \
|
response += f"You can find a more detailed help and a list of templates at:\n" \
|
||||||
f"<{DOC_URL}>"
|
f"<{DOC_URL}>"
|
||||||
await message.channel.send(response)
|
if len(response) >= 2000:
|
||||||
|
await message.channel.send(f"{message.author.mention} ... really?")
|
||||||
|
else:
|
||||||
|
await message.channel.send(response)
|
||||||
else:
|
else:
|
||||||
with tempfile.NamedTemporaryFile(delete=False) as output:
|
with tempfile.NamedTemporaryFile(delete=False) as output:
|
||||||
img.save(output, format="JPEG")
|
img.save(output, format="JPEG")
|
||||||
response = None
|
response = None
|
||||||
if len(args) == 1:
|
if len(args) == 1:
|
||||||
meme = db.get_meme(meme_id)
|
meme = meme_db.get_meme(meme_id)
|
||||||
response = f"Template `{meme.id}`:"
|
response = f"Template `{meme.id}`:"
|
||||||
if len(meme.aliases) > 0:
|
if len(meme.aliases) > 0:
|
||||||
response += f"\n- Aliases: `{'`, `'.join(meme.aliases)}`"
|
response += f"\n- Aliases: `{'`, `'.join(meme.aliases)}`"
|
||||||
@@ -155,18 +148,18 @@ async def on_message(message):
|
|||||||
f"\n```{meme.id} \"" + \
|
f"\n```{meme.id} \"" + \
|
||||||
"\" \"".join([f"text {i + 1}" for i in range(meme.texts_len)]) + \
|
"\" \"".join([f"text {i + 1}" for i in range(meme.texts_len)]) + \
|
||||||
"\"```"
|
"\"```"
|
||||||
elif not direct:
|
elif not is_direct:
|
||||||
response = f"A meme by {message.author.mention}:"
|
response = f"A meme by {message.author.mention}:"
|
||||||
if mid not in SENT:
|
if message_id not in SENT:
|
||||||
SENT[mid] = []
|
SENT[message_id] = []
|
||||||
response = await message.channel.send(response,
|
response = await message.channel.send(response,
|
||||||
file=discord.File(filename="meme.jpg", fp=output.name))
|
file=discord.File(filename="meme.jpg", fp=output.name))
|
||||||
SENT[mid] += [response]
|
SENT[message_id] += [response]
|
||||||
try:
|
try:
|
||||||
os.remove(output.name)
|
os.remove(output.name)
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
pass
|
pass
|
||||||
if not direct:
|
if not is_direct:
|
||||||
await delete(message)
|
await delete(message)
|
||||||
|
|
||||||
|
|
||||||
@@ -176,14 +169,14 @@ while True:
|
|||||||
client.run(token)
|
client.run(token)
|
||||||
break # clean kill
|
break # clean kill
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
t = datetime.now()
|
exception_time = datetime.now()
|
||||||
logging.error(f"Exception raised at {t:%Y-%m-%d %H:%M} : {repr(e)}")
|
logging.error(f"Exception raised at {exception_time:%Y-%m-%d %H:%M} : {repr(e)}")
|
||||||
fileName = f"error_{t:%Y-%m-%d_%H-%M-%S}.txt"
|
fileName = f"error_{exception_time:%Y-%m-%d_%H-%M-%S}.txt"
|
||||||
if os.path.exists(fileName):
|
if os.path.exists(fileName):
|
||||||
logging.error("Two many errors, killing")
|
logging.error("Two many errors, killing")
|
||||||
break
|
break
|
||||||
with open(fileName, 'w') as f:
|
with open(fileName, 'w') as exception_file:
|
||||||
f.write(f"Discord AI Dungeon 2 v{VERSION} started at {t0:%Y-%m-%d %H:%M}\r\n"
|
exception_file.write(f"Meme-Otron v{VERSION} started at {t0:%Y-%m-%d %H:%M}\r\n"
|
||||||
f"Exception raised at {t:%Y-%m-%d %H:%M}\r\n"
|
f"Exception raised at {exception_time:%Y-%m-%d %H:%M}\r\n"
|
||||||
f"\r\n"
|
f"\r\n"
|
||||||
f"{traceback.format_exc()}")
|
f"{traceback.format_exc()}")
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ Tag the bot and use the above syntax to get started. In addition, you can use th
|
|||||||
|
|
||||||
To get the template info, just send the meme id without texts.
|
To get the template info, just send the meme id without texts.
|
||||||
|
|
||||||
|
> Tip : You can use `\\n` in your texts to add a line break
|
||||||
|
|
||||||
Enjoy the full experience of this bot by using direct messages to keep your server free of spam.
|
Enjoy the full experience of this bot by using direct messages to keep your server free of spam.
|
||||||
|
|
||||||
## CLI features
|
## CLI features
|
||||||
|
|||||||
+15
-15
@@ -2,36 +2,36 @@ import os
|
|||||||
import logging
|
import logging
|
||||||
import PIL
|
import PIL
|
||||||
from os import path
|
from os import path
|
||||||
from meme_otron import img_factory as imgf
|
from meme_otron import img_factory
|
||||||
from meme_otron import meme_db
|
from meme_otron import meme_db
|
||||||
from meme_otron import utils
|
from meme_otron import utils
|
||||||
|
|
||||||
logging.basicConfig(format="[%(asctime)s][%(levelname)s][%(module)s] %(message)s", level=logging.WARNING)
|
logging.basicConfig(format="[%(asctime)s][%(levelname)s][%(module)s] %(message)s", level=logging.WARNING)
|
||||||
|
|
||||||
imgf.load_fonts()
|
img_factory.load_fonts()
|
||||||
meme_db.load_memes()
|
meme_db.load_memes()
|
||||||
|
|
||||||
dst_dir = utils.relative_path(__file__, "templates")
|
templates_dir = utils.relative_path(__file__, "templates")
|
||||||
prev_dir = utils.relative_path(__file__, "preview")
|
preview_dir = utils.relative_path(__file__, "preview")
|
||||||
doc_file = utils.relative_path(__file__, "README.md")
|
doc_file = utils.relative_path(__file__, "README.md")
|
||||||
|
|
||||||
COLUMNS = 3
|
COLUMNS = 3
|
||||||
IMG_HEIGHT = 400
|
IMG_HEIGHT = 400
|
||||||
|
|
||||||
|
|
||||||
def make_empty(target_dir):
|
def make_empty(target_dir: str):
|
||||||
if path.exists(target_dir):
|
if path.exists(target_dir):
|
||||||
for f in os.listdir(target_dir):
|
for file in os.listdir(target_dir):
|
||||||
if path.isfile(path.join(target_dir, f)):
|
if path.isfile(path.join(target_dir, file)):
|
||||||
os.unlink(path.join(target_dir, f))
|
os.unlink(path.join(target_dir, file))
|
||||||
else:
|
else:
|
||||||
os.mkdir(target_dir)
|
os.mkdir(target_dir)
|
||||||
|
|
||||||
|
|
||||||
make_empty(dst_dir)
|
make_empty(templates_dir)
|
||||||
make_empty(prev_dir)
|
make_empty(preview_dir)
|
||||||
|
|
||||||
ids = sorted(meme_db.LIST)
|
id_list = sorted(meme_db.LIST)
|
||||||
|
|
||||||
doc_content = "|" * (COLUMNS + 1) \
|
doc_content = "|" * (COLUMNS + 1) \
|
||||||
+ "\n|" + ":---:|" * COLUMNS
|
+ "\n|" + ":---:|" * COLUMNS
|
||||||
@@ -40,14 +40,14 @@ info_line = None
|
|||||||
img_line = None
|
img_line = None
|
||||||
|
|
||||||
i = None
|
i = None
|
||||||
for i, meme_id in enumerate(ids):
|
for i, meme_id in enumerate(id_list):
|
||||||
meme = meme_db.get_meme(meme_id)
|
meme = meme_db.get_meme(meme_id)
|
||||||
img = imgf.make(meme.template, meme.texts, debug=True)
|
img = img_factory.build_image(meme.template, meme.texts, debug=True)
|
||||||
if img is not None:
|
if img is not None:
|
||||||
img.save(path.join(dst_dir, meme.template))
|
img.save(path.join(templates_dir, meme.template))
|
||||||
size = (round(img.size[0] * IMG_HEIGHT / img.size[1]), IMG_HEIGHT)
|
size = (round(img.size[0] * IMG_HEIGHT / img.size[1]), IMG_HEIGHT)
|
||||||
img2 = img.resize(size, resample=PIL.Image.LANCZOS)
|
img2 = img.resize(size, resample=PIL.Image.LANCZOS)
|
||||||
img2.save(path.join(prev_dir, meme.template))
|
img2.save(path.join(preview_dir, meme.template))
|
||||||
if i % COLUMNS == 0:
|
if i % COLUMNS == 0:
|
||||||
if info_line is not None and img_line is not None:
|
if info_line is not None and img_line is not None:
|
||||||
doc_content += info_line + img_line
|
doc_content += info_line + img_line
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
VERSION = "1.2-dev"
|
VERSION = "1.2"
|
||||||
|
|||||||
+15
-21
@@ -1,19 +1,19 @@
|
|||||||
import logging
|
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from . import img_factory as imgf
|
from . import img_factory
|
||||||
from . import meme_db as db
|
from . import meme_db
|
||||||
from . import meme_otron
|
from . import meme_otron
|
||||||
|
from . import utils
|
||||||
from . import VERSION
|
from . import VERSION
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
db.load_memes()
|
meme_db.load_memes()
|
||||||
imgf.load_fonts()
|
img_factory.load_fonts()
|
||||||
|
|
||||||
# TODO better arguments reading (-h, -o, -v)
|
# TODO better arguments reading (-h, -o, -v)
|
||||||
|
|
||||||
if len(sys.argv) <= 1 or sys.argv[1].lower().strip() == "help" or "-h" in sys.argv:
|
if len(sys.argv) <= 1 or utils.read_argument(sys.argv, "help", "--help", "-h"):
|
||||||
print(f"Meme-Otron v{VERSION}"
|
print(f"Meme-Otron v{VERSION}"
|
||||||
"python -m meme_otron -h\n"
|
"python -m meme_otron -h\n"
|
||||||
"python -m meme_otron (meme_id) \"[text 1]\" \"[text 2]\" ... > file.jpg\n"
|
"python -m meme_otron (meme_id) \"[text 1]\" \"[text 2]\" ... > file.jpg\n"
|
||||||
@@ -21,29 +21,23 @@ if __name__ == "__main__":
|
|||||||
file=sys.stderr)
|
file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
else:
|
else:
|
||||||
output_f = None
|
output_file = utils.read_argument(sys.argv, "-o", "--output", valued=True, delete=True)
|
||||||
if "-o" in sys.argv:
|
|
||||||
i = sys.argv.index("-o")
|
|
||||||
if len(sys.argv) >= i:
|
|
||||||
output_f = sys.argv[i + 1]
|
|
||||||
del sys.argv[i + 1]
|
|
||||||
del sys.argv[i]
|
|
||||||
img = meme_otron.compute(*sys.argv[1:])
|
img = meme_otron.compute(*sys.argv[1:])
|
||||||
if img is None:
|
if img is None:
|
||||||
hint = db.find_nearest(sys.argv[1])
|
proposal = meme_db.find_nearest(sys.argv[1])
|
||||||
if hint is not None:
|
if proposal is not None:
|
||||||
print(f"Did you mean '{hint}'?", file=sys.stderr)
|
print(f"Did you mean '{proposal}'?", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if output_f is None:
|
if output_file is None:
|
||||||
with os.fdopen(os.dup(sys.stdout.fileno())) as output:
|
with os.fdopen(os.dup(sys.stdout.fileno())) as output:
|
||||||
img.save(output, format="jpeg")
|
img.save(output, format="jpeg")
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
img.save(output_f)
|
img.save(output_file)
|
||||||
print(f"Wrote '{output_f}'")
|
print(f"Wrote '{output_file}'")
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
print(f"Cannot write '{output_f}': {e}", file=sys.stderr)
|
print(f"Cannot write '{output_file}': {e}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
print(f"Cannot write '{output_f}': {e}", file=sys.stderr)
|
print(f"Cannot write '{output_file}': {e}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|||||||
+19
-43
@@ -1,9 +1,11 @@
|
|||||||
|
from typing import List, Optional, Tuple
|
||||||
from PIL import Image, ImageFont, ImageDraw
|
from PIL import Image, ImageFont, ImageDraw
|
||||||
import os
|
import os
|
||||||
import os.path as path
|
import os.path as path
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from . import utils
|
from . import utils
|
||||||
|
from .types import Text
|
||||||
|
|
||||||
FONT_DIR = utils.relative_path(__file__, "..", "fonts")
|
FONT_DIR = utils.relative_path(__file__, "..", "fonts")
|
||||||
TEMPLATES_DIR = utils.relative_path(__file__, "..", "templates")
|
TEMPLATES_DIR = utils.relative_path(__file__, "..", "templates")
|
||||||
@@ -24,14 +26,7 @@ def load_fonts():
|
|||||||
logger.error(f"Could not load font '{split[0]}'")
|
logger.error(f"Could not load font '{split[0]}'")
|
||||||
|
|
||||||
|
|
||||||
def make(template, texts, debug=False):
|
def build_image(template: str, texts: List[Text], debug: bool = False) -> Optional[Image.Image]:
|
||||||
"""
|
|
||||||
:param (str) template:
|
|
||||||
:param (list of Text) texts:
|
|
||||||
:param (bool) debug:
|
|
||||||
:rtype: PIL.Image.Image
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
img = Image.open(path.join(TEMPLATES_DIR, template)).convert(mode='RGBA')
|
img = Image.open(path.join(TEMPLATES_DIR, template)).convert(mode='RGBA')
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
@@ -45,19 +40,13 @@ def make(template, texts, debug=False):
|
|||||||
return img.convert(mode='RGB')
|
return img.convert(mode='RGB')
|
||||||
|
|
||||||
|
|
||||||
def draw_text(draw, img, text, debug=False):
|
def draw_text(draw: ImageDraw.ImageDraw, img: Image.Image, text: Text, debug: bool = False):
|
||||||
"""
|
|
||||||
:param (PIL.ImageDraw.ImageDraw) draw: source image canvas
|
|
||||||
:param (PIL.Image.Image) img: source image
|
|
||||||
:param (Text) text:
|
|
||||||
:param (bool) debug:
|
|
||||||
"""
|
|
||||||
if text.text is not None and len(text.text.strip()) > 0:
|
if text.text is not None and len(text.text.strip()) > 0:
|
||||||
text.init() # load default values
|
text.init() # load default values
|
||||||
if text.font in FONTS:
|
if text.font in FONTS:
|
||||||
text.text, font = fit_text(img.size, text)
|
text.text, font = fit_text(img.size, text)
|
||||||
if text.angle == 0:
|
if text.angle == 0:
|
||||||
draw.text(get_pos(img.size, text, font), text.text, fill=text.fill, align=text.align, font=font,
|
draw.text(get_text_pos(img.size, text, font), text.text, fill=text.fill, align=text.align, font=font,
|
||||||
stroke_width=round(text.stroke_width * font.size), stroke_fill=text.stroke_fill)
|
stroke_width=round(text.stroke_width * font.size), stroke_fill=text.stroke_fill)
|
||||||
if debug:
|
if debug:
|
||||||
draw.rectangle([(text.x_range[0] * img.size[0], text.y_range[0] * img.size[1]),
|
draw.rectangle([(text.x_range[0] * img.size[0], text.y_range[0] * img.size[1]),
|
||||||
@@ -70,7 +59,7 @@ def draw_text(draw, img, text, debug=False):
|
|||||||
center_y = (text.y_range[0] + text.y_range[1]) * img.size[1] / 2
|
center_y = (text.y_range[0] + text.y_range[1]) * img.size[1] / 2
|
||||||
txt_img = Image.new('RGBA', (width, height))
|
txt_img = Image.new('RGBA', (width, height))
|
||||||
txt_draw = ImageDraw.Draw(txt_img)
|
txt_draw = ImageDraw.Draw(txt_img)
|
||||||
txt_draw.text(get_pos(img.size, text, font, relative=True), text.text, fill=text.fill,
|
txt_draw.text(get_text_pos(img.size, text, font, relative=True), text.text, fill=text.fill,
|
||||||
align=text.align, font=font, stroke_width=round(text.stroke_width * font.size),
|
align=text.align, font=font, stroke_width=round(text.stroke_width * font.size),
|
||||||
stroke_fill=text.stroke_fill)
|
stroke_fill=text.stroke_fill)
|
||||||
if debug:
|
if debug:
|
||||||
@@ -84,43 +73,30 @@ def draw_text(draw, img, text, debug=False):
|
|||||||
logger.warning(f"Invalid font '{text.font}'")
|
logger.warning(f"Invalid font '{text.font}'")
|
||||||
|
|
||||||
|
|
||||||
def fit_text(size, text):
|
def fit_text(size: Tuple[int, int], text: Text) -> Tuple[str, ImageFont.FreeTypeFont]:
|
||||||
"""
|
|
||||||
:param (int,int) size: source image size
|
|
||||||
:param (Text) text:
|
|
||||||
:rtype: (str, PIL.ImageFont.FreeTypeFont)
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
# TODO rework this function
|
|
||||||
max_width = round(size[0] * (text.x_range[1] - text.x_range[0]))
|
max_width = round(size[0] * (text.x_range[1] - text.x_range[0]))
|
||||||
max_height = round(size[1] * (text.y_range[1] - text.y_range[0]))
|
max_height = round(size[1] * (text.y_range[1] - text.y_range[0]))
|
||||||
text_size = None
|
text_size = None
|
||||||
font_size = round(text.font_size * min(size)) + 1
|
font_size = round(text.font_size * min(size)) + 1
|
||||||
font = FONTS[text.font]
|
font = FONTS[text.font]
|
||||||
t = ""
|
text_content = ""
|
||||||
while (text_size is None or text_size[0] >= max_width or text_size[1] >= max_height) and font_size > 1:
|
while (text_size is None or text_size[0] >= max_width or text_size[1] >= max_height) and font_size > 1:
|
||||||
font_size -= 1
|
font_size -= 1
|
||||||
font = font.font_variant(size=font_size)
|
font = font.font_variant(size=font_size)
|
||||||
k = 0 # number of lines
|
n_lines = 0
|
||||||
while k == 0 or (t is not None and text_size[0] >= max_width):
|
while n_lines == 0 or (text_content is not None and text_size[0] >= max_width):
|
||||||
k += 1
|
n_lines += 1
|
||||||
t = utils.justify_text(text.text, k)
|
text_content = utils.justify_text(text.text, n_lines)
|
||||||
if t is not None:
|
if text_content is not None:
|
||||||
text_size = font.getsize_multiline(t, stroke_width=text.stroke_width * font_size)
|
text_size = font.getsize_multiline(text_content, stroke_width=text.stroke_width * font_size)
|
||||||
if t is None:
|
if text_content is None:
|
||||||
# max break attained
|
# max break attained
|
||||||
text_size = None # restart
|
text_size = None # retry
|
||||||
return t, font
|
return text_content, font
|
||||||
|
|
||||||
|
|
||||||
def get_pos(size, text, font, relative=False):
|
def get_text_pos(size: Tuple[int, int], text: Text,
|
||||||
"""
|
font: ImageFont.FreeTypeFont, relative: bool = False) -> Tuple[int, int]:
|
||||||
:param (int,int) size: source image size
|
|
||||||
:param (Text) text:
|
|
||||||
:param (PIL.ImageFont.FreeTypeFont) font:
|
|
||||||
:rtype (int,int)
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
min_x = round(text.x_range[0] * size[0])
|
min_x = round(text.x_range[0] * size[0])
|
||||||
max_x = round(text.x_range[1] * size[0])
|
max_x = round(text.x_range[1] * size[0])
|
||||||
min_y = round(text.y_range[0] * size[1])
|
min_y = round(text.y_range[0] * size[1])
|
||||||
|
|||||||
+15
-33
@@ -1,3 +1,4 @@
|
|||||||
|
from typing import Optional
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -13,17 +14,14 @@ LIST = []
|
|||||||
logger = logging.getLogger("meme_db")
|
logger = logging.getLogger("meme_db")
|
||||||
|
|
||||||
|
|
||||||
def load_memes(purge=False):
|
def load_memes(purge: bool = False):
|
||||||
"""
|
|
||||||
:param (bool) purge:
|
|
||||||
"""
|
|
||||||
global DATA, ALIASES
|
global DATA, ALIASES
|
||||||
if purge:
|
if purge:
|
||||||
DATA = {}
|
DATA = {}
|
||||||
ALIASES = {}
|
ALIASES = {}
|
||||||
try:
|
try:
|
||||||
with open(DATA_FILE) as f:
|
with open(DATA_FILE) as input_file:
|
||||||
content = "".join(f.readlines())
|
content = "".join(input_file.readlines())
|
||||||
raw_data = json.loads(content)
|
raw_data = json.loads(content)
|
||||||
if not (isinstance(raw_data, list)):
|
if not (isinstance(raw_data, list)):
|
||||||
raise TypeError(f"Root is not a list")
|
raise TypeError(f"Root is not a list")
|
||||||
@@ -37,12 +35,9 @@ def load_memes(purge=False):
|
|||||||
logger.error(f"Invalid data file: {e}")
|
logger.error(f"Invalid data file: {e}")
|
||||||
|
|
||||||
|
|
||||||
def load_item(i, item):
|
def load_item(i: int, item: dict):
|
||||||
"""
|
|
||||||
:param (int) i:
|
|
||||||
:param (dict) item:
|
|
||||||
"""
|
|
||||||
global LIST
|
global LIST
|
||||||
|
# TODO reduce complexity
|
||||||
item_id = ""
|
item_id = ""
|
||||||
try:
|
try:
|
||||||
if not (isinstance(item, dict)):
|
if not (isinstance(item, dict)):
|
||||||
@@ -68,13 +63,13 @@ def load_item(i, item):
|
|||||||
raw_texts = utils.read_key(item, "texts", meme.texts, types=[dict], is_list=True)
|
raw_texts = utils.read_key(item, "texts", meme.texts, types=[dict], is_list=True)
|
||||||
if "texts" in item:
|
if "texts" in item:
|
||||||
meme.texts = []
|
meme.texts = []
|
||||||
c = 1
|
current_text = 1
|
||||||
for j in range(len(raw_texts)):
|
for j in range(len(raw_texts)):
|
||||||
raw_text = raw_texts[j]
|
raw_text = raw_texts[j]
|
||||||
try:
|
try:
|
||||||
text = load_text(c, raw_text)
|
text = load_text(current_text, raw_text)
|
||||||
if text.text_ref is None:
|
if text.text_ref is None:
|
||||||
c += 1
|
current_text += 1
|
||||||
elif text.text_ref < 1 or text.text_ref > len(meme.texts):
|
elif text.text_ref < 1 or text.text_ref > len(meme.texts):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Item '{item_id}'({i + 1}) / Text {j + 1}: invalid text reference {text.text_ref}")
|
f"Item '{item_id}'({i + 1}) / Text {j + 1}: invalid text reference {text.text_ref}")
|
||||||
@@ -90,7 +85,7 @@ def load_item(i, item):
|
|||||||
text.style_ref -= 1
|
text.style_ref -= 1
|
||||||
text.update(meme.texts[text.style_ref])
|
text.update(meme.texts[text.style_ref])
|
||||||
meme.texts += [text]
|
meme.texts += [text]
|
||||||
meme.texts_len = c - 1
|
meme.texts_len = current_text - 1
|
||||||
except TypeError as e:
|
except TypeError as e:
|
||||||
logger.warning(f"Item '{item_id}'({i + 1}) / Text {j + 1}: {e}")
|
logger.warning(f"Item '{item_id}'({i + 1}) / Text {j + 1}: {e}")
|
||||||
for text in meme.texts:
|
for text in meme.texts:
|
||||||
@@ -117,17 +112,9 @@ def load_item(i, item):
|
|||||||
logger.warning(f"Item '{item_id}'({i + 1}): {e}")
|
logger.warning(f"Item '{item_id}'({i + 1}): {e}")
|
||||||
|
|
||||||
|
|
||||||
def load_text(c, raw_text, text=None):
|
def load_text(current_text: int, raw_text: dict, text: Optional[Text] = None) -> Text:
|
||||||
"""
|
|
||||||
:param (int) c:
|
|
||||||
:param (dict) raw_text:
|
|
||||||
:param (Text|None) text:
|
|
||||||
:raises TypeError:
|
|
||||||
:rtype: Text
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
if text is None:
|
if text is None:
|
||||||
text = Text(f"text {c}")
|
text = Text(f"text {current_text}")
|
||||||
text.font = utils.read_key_safe(raw_text, "font", text.font, types=[str])
|
text.font = utils.read_key_safe(raw_text, "font", text.font, types=[str])
|
||||||
text.x_range = utils.read_key_safe(raw_text, "x_range", types=[float, int], is_list=True, is_list_size=2)
|
text.x_range = utils.read_key_safe(raw_text, "x_range", types=[float, int], is_list=True, is_list_size=2)
|
||||||
text.y_range = utils.read_key_safe(raw_text, "y_range", types=[float, int], is_list=True, is_list_size=2)
|
text.y_range = utils.read_key_safe(raw_text, "y_range", types=[float, int], is_list=True, is_list_size=2)
|
||||||
@@ -150,12 +137,7 @@ def load_text(c, raw_text, text=None):
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
def get_meme(name):
|
def get_meme(name: str) -> Optional[Meme]:
|
||||||
"""
|
|
||||||
:param (str) name:
|
|
||||||
:rtype: Meme|None
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
name = name.lower().strip().replace(" ", "_")
|
name = name.lower().strip().replace(" ", "_")
|
||||||
if name in ALIASES:
|
if name in ALIASES:
|
||||||
return DATA[ALIASES[name]].clone()
|
return DATA[ALIASES[name]].clone()
|
||||||
@@ -163,6 +145,6 @@ def get_meme(name):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def find_nearest(word):
|
def find_nearest(word: str) -> str:
|
||||||
word = word.lower().strip().replace(" ", "_")
|
word = word.lower().strip().replace(" ", "_")
|
||||||
return utils.find_nearest(word, ALIASES.keys())
|
return utils.find_nearest(word, list(ALIASES))
|
||||||
|
|||||||
@@ -59,4 +59,4 @@ def compute(*args, left_wmark_text=None, debug=False):
|
|||||||
if left_wmark_text is not None:
|
if left_wmark_text is not None:
|
||||||
left_wmark.text = left_wmark_text
|
left_wmark.text = left_wmark_text
|
||||||
meme.texts += [left_wmark]
|
meme.texts += [left_wmark]
|
||||||
return imgf.make(meme.template, meme.texts, debug=debug)
|
return imgf.build_image(meme.template, meme.texts, debug=debug)
|
||||||
|
|||||||
+5
-7
@@ -1,3 +1,4 @@
|
|||||||
|
from typing import Optional
|
||||||
from enum import IntEnum
|
from enum import IntEnum
|
||||||
import copy
|
import copy
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ class Pos(IntEnum):
|
|||||||
|
|
||||||
|
|
||||||
class Meme:
|
class Meme:
|
||||||
def __init__(self, meme_id):
|
def __init__(self, meme_id: str):
|
||||||
self.id = meme_id
|
self.id = meme_id
|
||||||
self.aliases = []
|
self.aliases = []
|
||||||
self.abstract = None
|
self.abstract = None
|
||||||
@@ -28,7 +29,7 @@ class Meme:
|
|||||||
self.texts = None
|
self.texts = None
|
||||||
self.texts_len = 0
|
self.texts_len = 0
|
||||||
|
|
||||||
def clone(self):
|
def clone(self) -> 'Meme':
|
||||||
return copy.deepcopy(self)
|
return copy.deepcopy(self)
|
||||||
|
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ class Text:
|
|||||||
base_properties = ["font", "font_size", "fill", "stroke_width",
|
base_properties = ["font", "font_size", "fill", "stroke_width",
|
||||||
"stroke_fill", "align", "position"]
|
"stroke_fill", "align", "position"]
|
||||||
|
|
||||||
def __init__(self, text=None):
|
def __init__(self, text: Optional[str] = None):
|
||||||
self.text = text
|
self.text = text
|
||||||
self.text_ref = None
|
self.text_ref = None
|
||||||
|
|
||||||
@@ -56,10 +57,7 @@ class Text:
|
|||||||
self.align = None
|
self.align = None
|
||||||
self.position = None
|
self.position = None
|
||||||
|
|
||||||
def update(self, base):
|
def update(self, base: 'Text'):
|
||||||
"""
|
|
||||||
:param (Text) base:
|
|
||||||
"""
|
|
||||||
for prop in Text.base_properties:
|
for prop in Text.base_properties:
|
||||||
if getattr(self, prop) is None:
|
if getattr(self, prop) is None:
|
||||||
setattr(self, prop, getattr(base, prop))
|
setattr(self, prop, getattr(base, prop))
|
||||||
|
|||||||
+27
-3
@@ -79,10 +79,16 @@ def parse_arguments(src: str) -> List[str]:
|
|||||||
|
|
||||||
|
|
||||||
def find_nearest(word: str, wlist: List[str], threshold: int = 5) -> Optional[str]:
|
def find_nearest(word: str, wlist: List[str], threshold: int = 5) -> Optional[str]:
|
||||||
found = min([(distance(word, w) - abs(len(w) - len(word)), w) for w in wlist], key=lambda v: v[0])
|
distances = [
|
||||||
if found[0] > threshold:
|
(distance(word, w), # distance
|
||||||
|
abs(len(w) - len(word)), # length diff
|
||||||
|
w)
|
||||||
|
for w in wlist]
|
||||||
|
distances.sort(key=lambda v: v[1]) # sort by length diff to get the closest (in length) first
|
||||||
|
found = min(distances, key=lambda v: v[0] - v[1]) # get the closest in lev. distance
|
||||||
|
if found[0] - found[1] > threshold: # distance is too much
|
||||||
return None
|
return None
|
||||||
return found[1]
|
return found[2]
|
||||||
|
|
||||||
|
|
||||||
def justify_text(src: str, n_lines: int) -> Optional[str]:
|
def justify_text(src: str, n_lines: int) -> Optional[str]:
|
||||||
@@ -137,3 +143,21 @@ def safe_index(src: Union[str, list], pattern, start: int = 0):
|
|||||||
return src.index(pattern, start)
|
return src.index(pattern, start)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def read_argument(args: List[str], *names: str, valued: bool = False, delete: bool = False):
|
||||||
|
for i, arg in enumerate(args):
|
||||||
|
if arg.lower() in names:
|
||||||
|
if delete:
|
||||||
|
del args[i]
|
||||||
|
i -= 1
|
||||||
|
if not valued:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
v = None
|
||||||
|
if i + 1 < len(args):
|
||||||
|
v = args[i + 1]
|
||||||
|
if delete:
|
||||||
|
del args[i + 1]
|
||||||
|
return v
|
||||||
|
return None
|
||||||
|
|||||||
@@ -128,3 +128,18 @@ class Test(TestCase):
|
|||||||
self.assertEqual([5, 9, 15], utils.place_line_breaks([5.2, 14.3, 15.2], [3, 5, 9, 15, 18]))
|
self.assertEqual([5, 9, 15], utils.place_line_breaks([5.2, 14.3, 15.2], [3, 5, 9, 15, 18]))
|
||||||
self.assertEqual([5, 9, 15, 18], utils.place_line_breaks([5.2, 14.3, 14.5, 15.2], [3, 5, 9, 15, 18]))
|
self.assertEqual([5, 9, 15, 18], utils.place_line_breaks([5.2, 14.3, 14.5, 15.2], [3, 5, 9, 15, 18]))
|
||||||
self.assertEqual([5, 9, 15, 18], utils.place_line_breaks([5.2, 14.3, 14.5, 15.2], [3, 5, 9, 15, 18, 20]))
|
self.assertEqual([5, 9, 15, 18], utils.place_line_breaks([5.2, 14.3, 14.5, 15.2], [3, 5, 9, 15, 18, 20]))
|
||||||
|
|
||||||
|
def test_read_argument(self):
|
||||||
|
self.assertIsNone(utils.read_argument(["test", "-o", "test"], "--output"))
|
||||||
|
self.assertTrue(utils.read_argument(["test", "-O", "test"], "--output", "-o"))
|
||||||
|
self.assertIsNone(utils.read_argument(["test", "-o"], "-o", valued=True))
|
||||||
|
self.assertEqual("test1", utils.read_argument(["test", "-o", "test1", "-o", "test2"], "-o", valued=True))
|
||||||
|
args = ["test", "-o", "test1"]
|
||||||
|
self.assertTrue(utils.read_argument(args, "-o", delete=True))
|
||||||
|
self.assertEqual(["test", "test1"], args)
|
||||||
|
args = ["test", "-o", "test1"]
|
||||||
|
self.assertEqual("test1", utils.read_argument(args, "-o", valued=True, delete=True))
|
||||||
|
self.assertEqual(["test"], args)
|
||||||
|
args = ["test", "-o"]
|
||||||
|
self.assertIsNone(utils.read_argument(args, "-o", valued=True, delete=True))
|
||||||
|
self.assertEqual(["test"], args)
|
||||||
+3
-3
@@ -4,13 +4,13 @@ import time
|
|||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
from os import path
|
from os import path
|
||||||
from meme_otron import img_factory as imgf
|
from meme_otron import img_factory
|
||||||
from meme_otron import meme_db
|
from meme_otron import meme_db
|
||||||
from meme_otron import utils
|
from meme_otron import utils
|
||||||
|
|
||||||
logging.basicConfig(format="%(message)s", level=logging.WARNING)
|
logging.basicConfig(format="%(message)s", level=logging.WARNING)
|
||||||
|
|
||||||
imgf.load_fonts()
|
img_factory.load_fonts()
|
||||||
|
|
||||||
db_file = utils.relative_path(__file__, "..", meme_db.DATA_FILE)
|
db_file = utils.relative_path(__file__, "..", meme_db.DATA_FILE)
|
||||||
templates_dir = utils.relative_path(__file__, "..", "templates")
|
templates_dir = utils.relative_path(__file__, "..", "templates")
|
||||||
@@ -30,7 +30,7 @@ while True:
|
|||||||
count = 0
|
count = 0
|
||||||
for meme_id in meme_db.LIST:
|
for meme_id in meme_db.LIST:
|
||||||
meme = meme_db.get_meme(meme_id)
|
meme = meme_db.get_meme(meme_id)
|
||||||
img = imgf.make(meme.template, meme.texts, debug=True)
|
img = img_factory.build_image(meme.template, meme.texts, debug=True)
|
||||||
if img is not None:
|
if img is not None:
|
||||||
img.save(path.join(dst_dir, meme.template))
|
img.save(path.join(dst_dir, meme.template))
|
||||||
count += 1
|
count += 1
|
||||||
|
|||||||
Reference in New Issue
Block a user