initial commit

This commit is contained in:
klemek
2020-04-11 12:40:19 +02:00
commit 3fae24ba1e
93 changed files with 866 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
.env
.idea
+3
View File
@@ -0,0 +1,3 @@
# meme-otron
### WIP
+123
View File
@@ -0,0 +1,123 @@
import os
import traceback
import logging
import discord
import re
import tempfile
from datetime import datetime
from dotenv import load_dotenv
from meme_otron import img_factory as imgf
from meme_otron import meme_db as db
from meme_otron import utils
from meme_otron import main
VERSION = "1.0-dev"
t0 = datetime.now()
logging.basicConfig(format="[%(asctime)s][%(levelname)s][%(module)s] %(message)s", level=logging.INFO)
imgf.load_fonts()
db.load_memes()
# Loading token
load_dotenv()
token = os.getenv('DISCORD_TOKEN')
client = discord.Client()
def debug(message, txt):
"""
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}")
@client.event
async def on_ready():
"""
Called when client is connected
"""
# Change status
await client.change_presence(
activity=discord.Game(f"v{VERSION}"),
status=discord.Status.online
)
# Debug connected guilds
logging.info(f'{client.user} v{VERSION} has connected to Discord\nto the following guilds:')
for guild in client.guilds:
logging.info(f'- {guild.name}(id: {guild.id})')
@client.event
async def on_message(message):
"""
Called when a message is sent to any channel on any guild
:param (discord.Message) message: message sent
"""
# Ignore self messages
if message.author == client.user:
return
direct = message.channel.type == discord.ChannelType.private
if direct or client.user in message.mentions:
message.content = re.sub(r'<@[^>]+>', '', message.content).strip()
args = utils.parse_arguments(message.content)
debug(message, str(args))
if len(args) == 0 or args[0].lower().strip() == "help":
await message.channel.send(f"Hey {message.author.mention},\n"
f"You can generate a meme with the syntax\n"
f"```\n"
f"[template] \"text 1\" \"text 2\" ...\n"
f"```"
f"You can find a more detailed help and a list of templates at:\n"
f"<https://github.com/klemek/meme-otron/tree/master/discord>")
return
async with message.channel.typing():
left_wmark_text = None
if not direct and len(args) > 1:
f"By {message.author.display_name}"
img = main.compute(*args, left_wmark_text=left_wmark_text)
if img is None:
await message.channel.send(f"Template `{args[0]}` not found\n"
f"You can find a more detailed help and a list of templates at:\n"
f"<https://github.com/klemek/meme-otron/tree/master/discord>")
return
with tempfile.NamedTemporaryFile() as output:
img.save(output, format="JPEG")
response = None
if len(args) == 1:
response = f"Template `{args[0]}`:"
elif not direct:
response = f"A meme by {message.author.mention}:"
await message.channel.send(response,
file=discord.File(filename="meme.jpg", fp=output.name))
if not direct:
try:
await message.delete()
except discord.Forbidden:
pass
except discord.NotFound:
pass
# Launch client and rerun on errors
while True:
try:
client.run(token)
break # clean kill
except Exception as e:
t = datetime.now()
logging.error(f"Exception raised at {t:%Y-%m-%d %H:%M} : {repr(e)}")
fileName = f"error_{t:%Y-%m-%d_%H-%M-%S}.txt"
if os.path.exists(fileName):
logging.error("Two many errors, killing")
break
with open(fileName, 'w') as f:
f.write(f"Discord AI Dungeon 2 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"\r\n"
f"{traceback.format_exc()}")
+3
View File
@@ -0,0 +1,3 @@
discord
python-dotenv
requests
+25
View File
@@ -0,0 +1,25 @@
import os
import logging
from os import path
from meme_otron import img_factory as imgf
from meme_otron import meme_db
logging.basicConfig(format="[%(asctime)s][%(levelname)s][%(module)s] %(message)s", level=logging.DEBUG)
imgf.load_fonts()
meme_db.load_memes()
dst_dir = path.abspath("templates")
for f in os.listdir(dst_dir):
if path.isfile(path.join(dst_dir, f)):
os.unlink(path.join(dst_dir, f))
for meme_id in meme_db.DATA:
meme = meme_db.get_meme(meme_id)
if meme is not None:
img = imgf.make(meme.template, meme.texts, debug=True)
img.save(path.join(dst_dir, meme.template))
print(meme_id)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+146
View File
@@ -0,0 +1,146 @@
from PIL import Image, ImageFont, ImageDraw
import os
import os.path as path
import logging
DEFAULT_FONT = "arial"
DEFAULT_FONT_SIZE = 0.05
FONT_DIR = "../fonts"
TEMPLATES_DIR = "../templates"
FONTS = {}
logger = logging.getLogger("img_factory")
def load_fonts():
"""
TODO
"""
for file in [f for f in os.listdir(FONT_DIR) if path.isfile(path.join(FONT_DIR, f))]:
split = path.splitext(file)
if split[-1] == ".ttf":
try:
FONTS[split[0]] = ImageFont.truetype(path.join(FONT_DIR, file))
logger.info(f"Loaded font '{split[0]}'")
except OSError:
logger.error(f"Could not load font '{split[0]}'")
def make(template, texts, debug=False):
"""
TODO
:param (str) template:
:param (list of Text) texts:
:param (bool) debug:
:rtype: PIL.Image.Image
:return:
"""
try:
img = Image.open(path.join(TEMPLATES_DIR, template))
except OSError as e:
logger.error(f"Could not read template file '{template}': {e}")
return None
draw = ImageDraw.Draw(img)
for text in texts:
draw_text(draw, img.size, text, debug=debug)
return img
def draw_text(draw, size, text, debug=False):
"""
TODO
:param (PIL.ImageDraw.ImageDraw) draw: source image canvas
:param (int,int) size: source image size
:param (Text) text:
:param (bool) debug:
"""
# TODO rotation
# https://stackoverflow.com/questions/245447/how-do-i-draw-text-at-an-angle-using-pythons-pil
if text.text is not None and len(text.text.strip()) > 0:
if text.font is None:
text.font = DEFAULT_FONT
if text.font in FONTS:
text.text, font = fit_text(size, text)
draw.text(get_pos(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)
if debug:
draw.rectangle([(text.x_range[0] * size[0], text.y_range[0] * size[1]),
(text.x_range[1] * size[0], text.y_range[1] * size[1])],
None,
(128, 128, 128))
else:
logger.warning(f"Invalid font '{text.font}'")
def fit_text(size, text):
"""
:param (int,int) size: source image size
:param (Text) text:
:rtype: (str, PIL.ImageFont.FreeTypeFont)
:return:
"""
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]))
text_size = None
if text.font_size is None:
text.font_size = DEFAULT_FONT_SIZE
font_size = round(text.font_size * min(size)) + 1
font = FONTS[text.font]
t = ""
while (text_size is None or text_size[1] >= max_height) and font_size > 1:
font_size -= 1
font = font.font_variant(size=font_size)
words = text.text.split(" ")
t = ""
for word in words:
spacer = " "
if len(t) == 0:
spacer = ""
text_size = font.getsize_multiline(t + spacer + word, stroke_width=text.stroke_width * font_size)
if text_size[0] >= max_width:
t += "\n" + word
else:
t += spacer + word
text_size = font.getsize_multiline(t, stroke_width=text.stroke_width * font_size)
return t, font
def get_pos(size, text, font):
"""
TODO
: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])
max_x = round(text.x_range[1] * size[0])
min_y = round(text.y_range[0] * size[1])
max_y = round(text.y_range[1] * size[1])
pos_x = 0
pos_y = 0
text_size = font.getsize_multiline(text.text, stroke_width=text.stroke_width * font.size)
if text.position.value[0] == "S":
pos_y = min_y
elif text.position.value[0] == "C":
pos_y = round((min_y + max_y) / 2 - text_size[1] / 2)
elif text.position.value[0] == "N":
pos_y = max_y - text_size[1]
if text.position.value[1] == "W":
pos_x = min_x
elif text.position.value[1] == "C":
pos_x = round((min_x + max_x) / 2 - text_size[0] / 2)
elif text.position.value[1] == "E":
pos_x = max_x - text_size[0]
return pos_x, pos_y
+63
View File
@@ -0,0 +1,63 @@
import logging
import sys
import os
from .types import Text, Pos
from . import img_factory as imgf
from . import meme_db as db
logger = logging.getLogger("meme_otron")
right_wmark = Text("Made with meme-otron")
right_wmark.position = Pos.SE
right_wmark.fill = (128, 128, 128, 128)
right_wmark.font_size = 0.02
right_wmark.x_range = [0.005, 0.995]
right_wmark.y_range = [0.005, 0.995]
left_wmark = Text()
left_wmark.position = Pos.SW
left_wmark.fill = (128, 128, 128, 128)
left_wmark.font_size = 0.02
left_wmark.x_range = [0.005, 0.995]
left_wmark.y_range = [0.005, 0.995]
def compute(*args, left_wmark_text=None, debug=False):
"""
TODO
:param (str) left_wmark_text:
:param (bool) debug:
:param (str) args:
:rtype: PIL.Image.Image
:return:
"""
if len(args) < 1:
logger.warning("python3 meme_otron.py (meme_id) \"[text 1]\" \"[text 2]\" ... > file.jpg")
return None
meme_id = args[0]
meme = db.get_meme(meme_id)
if meme is None:
logger.warning(f"Meme template '{meme_id}' not found")
return None
if len(args) > 1:
for i in range(len(meme.texts)):
if i < len(args) - 1:
meme.texts[i].text = args[i + 1]
else:
meme.texts[i].text = ""
meme.texts += [right_wmark]
if left_wmark_text is not None:
left_wmark.text = left_wmark_text
meme.texts += [left_wmark]
return imgf.make(meme.template, meme.texts, debug=debug)
if __name__ == "__main__":
logging.basicConfig(format="[%(asctime)s][%(levelname)s][%(module)s] %(message)s", level=logging.DEBUG)
db.load_memes()
imgf.load_fonts()
img = compute(*sys.argv[1:])
with os.fdopen(os.dup(sys.stdout.fileno())) as output:
img.save(output, format="jpeg")
+173
View File
@@ -0,0 +1,173 @@
import json
import logging
from .types import Pos, Text, Meme
from . import utils
DATA_FILE = "../memes.json"
DATA = {}
ALIASES = {}
logger = logging.getLogger("meme_db")
def load_memes():
"""
TODO
"""
try:
with open(DATA_FILE) as f:
content = "".join(f.readlines())
raw_data = json.loads(content)
if not (isinstance(raw_data, list)):
raise TypeError(f"Root is not a list")
for i in range(len(raw_data)):
load_item(i, raw_data[i])
except OSError as e:
logger.error(f"Could not read data file: {e}")
except json.decoder.JSONDecodeError as e:
logger.error(f"Wrong JSON syntax '{DATA_FILE}': {e}")
except TypeError as e:
logger.error(f"Invalid data file: {e}")
def load_item(i, item):
"""
TODO
:param (int) i:
:param (dict) item:
"""
item_id = ""
try:
if not (isinstance(item, dict)):
raise TypeError(f"root is not a dict")
item_id = utils.read_key(item, "id")
if item_id in DATA:
raise NameError(f"id '{item_id}' already existing")
based_on = utils.read_key_safe(item, "based_on")
abstract = utils.read_key_safe(item, "abstract", False)
aliases = utils.read_key_safe(item, "aliases", [])
if not utils.is_list_of(aliases, [str]):
raise TypeError(f"'aliases' is not a list of str")
template = None
font = None
font_size = None
texts = None
if based_on is not None:
if based_on in DATA:
template = DATA[based_on].template
font = DATA[based_on].font
font_size = DATA[based_on].font_size
texts = DATA[based_on].clone_texts()
else:
raise NameError(f"Reference '{based_on}' not found in data, make sur it's placed before this one")
if not abstract:
template = utils.read_key(item, "template", template)
font = utils.read_key_safe(item, "font", font)
font_size = utils.read_key_safe(item, "font_size", font_size)
raw_texts = utils.read_key(item, "texts", texts)
if texts is None:
if not (isinstance(raw_texts, list)):
raise TypeError(f"'texts' is not a list")
texts = []
for j in range(len(raw_texts)):
raw_text = raw_texts[j]
try:
texts += [load_text(j, raw_text)]
except TypeError as e:
logger.warning(f"Item '{item_id}'({i}) / Text {j}: {e}")
if font is not None:
if not (isinstance(font, str)):
raise TypeError(f"'font' is not a str")
for text in texts:
if text.font is None:
text.font = font
if font_size is not None:
if not (isinstance(font_size, float)):
raise TypeError(f"'font_size' is not a float")
for text in texts:
if text.font_size is None:
text.font_size = font_size
if len(texts) == 0:
logger.warning(f"Item '{item_id}'({i}): no texts loaded")
else:
DATA[item_id] = Meme(item_id, aliases, abstract, template, font, font_size, texts)
for alias in aliases:
ALIASES[alias] = item_id
logger.info(f"Loaded meme '{item_id}' with {len(texts)} texts")
except KeyError as e:
logger.warning(f"Item '{item_id}'({i}): key {e} not found")
except TypeError as e:
logger.warning(f"Item '{item_id}'({i}): {e}")
except NameError as e:
logger.warning(f"Item '{item_id}'({i}): {e}")
def load_text(j, raw_text):
"""
TODO
:param (int) j:
:param (dict) raw_text:
:raises TypeError:
:rtype: Text
:return:
"""
if not (isinstance(raw_text, dict)):
raise TypeError(f"root is not a dict")
text = Text(f"text {j+1}")
if "font" in raw_text:
if not (isinstance(raw_text["font"], str)):
raise TypeError(f"'font' is not a str")
text.font = raw_text["font"]
if "x_range" in raw_text:
if not (utils.is_list_of(raw_text["x_range"], [int, float], 2)):
raise TypeError(f"'x_range' is not a list of 2 float")
text.x_range = raw_text["x_range"]
if "y_range" in raw_text:
if not (utils.is_list_of(raw_text["y_range"], [int, float], 2)):
raise TypeError(f"'y_range' is not a list of 2 float")
text.y_range = raw_text["y_range"]
if "position" in raw_text:
if raw_text["position"] not in [p.name for p in Pos]:
raise TypeError(f"'position' is not a valid position (ex: NW, E, SE, ...)")
text.position = [p for p in Pos if p.name == raw_text["position"]][0]
if "font_size" in raw_text:
if not (isinstance(raw_text["font_size"], float)):
raise TypeError(f"'font_size' is not a float")
text.font_size = raw_text["font_size"]
if "fill" in raw_text:
if not (utils.is_list_of(raw_text["fill"], [int], 3)):
raise TypeError(f"'fill' is not a list of 3 int")
text.fill = raw_text["fill"]
if "stroke_width" in raw_text:
if not (isinstance(raw_text["stroke_width"], float)):
raise TypeError(f"'stroke_width' is not a float")
text.stroke_width = raw_text["stroke_width"]
if "stroke_fill" in raw_text:
if not (utils.is_list_of(raw_text["stroke_fill"], [int], 3)):
raise TypeError(f"'stroke_fill' is not a list of 3 int")
text.stroke_fill = raw_text["stroke_fill"]
if "align" in raw_text:
if raw_text["align"] not in ["left", "center", "right"]:
raise TypeError(f"'align' is not 'left', 'center' or 'right'")
text.align = raw_text["align"]
return text
def get_meme(name):
"""
TODO
:param (str) name:
:rtype: Meme|None
:return:
"""
if name in DATA and not DATA[name].abstract:
return DATA[name].clone()
elif name in ALIASES:
return DATA[ALIASES[name]].clone()
else:
return None
+62
View File
@@ -0,0 +1,62 @@
from enum import Enum
import copy
class Pos(Enum):
"""
TODO
"""
NW = "NW"
N = "NC"
NE = "NE"
W = "CW"
CENTER = "CC"
E = "CE"
SW = "NW"
S = "NC"
SE = "NE"
class Meme:
"""
TODO
"""
def __init__(self, meme_id, aliases, abstract, template, font, font_size, texts):
self.id = meme_id
self.aliases = aliases
self.abstract = abstract
self.template = template
self.font = font
self.font_size = font_size
self.texts = texts
def clone_texts(self):
return copy.deepcopy(self.texts)
def clone(self):
return Meme(self.id,
self.aliases,
self.abstract,
self.template,
self.font,
self.font_size,
self.clone_texts())
class Text:
"""
TODO
"""
def __init__(self, text=None):
self.text = text
self.x_range = (0, 1)
self.y_range = (0, 1)
self.position = Pos.CENTER
self.font_size = None
self.fill = (0, 0, 0)
self.stroke_width = 0
self.stroke_fill = (0, 0, 0)
self.font = None
self.align = "center"
+83
View File
@@ -0,0 +1,83 @@
import re
def read_key_safe(d, k, default=None):
"""
TODO
:param (dict) d: source dict
:param (str) k: key to read
:param default: default value
:return:
"""
if k in d:
return d[k]
else:
return default
def read_key(d, k, default=None):
"""
TODO
:param (dict) d: source dict
:param (str) k: key to read
:param default: default value
:return:
"""
if k in d:
return d[k]
elif default is not None:
return default
else:
raise KeyError(k)
def is_list_of(obj, types, length=None):
"""
TODO
:param obj:
:param (list of type) types:
:param (int) length:
:rtype: bool
:return:
"""
if not (isinstance(obj, list)):
return False
for item in obj:
found = False
for t in types:
if isinstance(item, t):
found = True
break
if not found:
return False
if length is not None and len(obj) != length:
return False
return True
def is_url(s):
"""
TODO
:param (str) s:
:rtype: bool
:return:
"""
return False # TODO
args_regex = re.compile('"([^"]*)"|\'([^\']*)\'|([^ ]+)')
def parse_arguments(s):
"""
TODO
:param (str) s:
:rtype: list of str
:return:
"""
return [[g for g in m if len(g) > 0][0] for m in args_regex.findall(s)]
+183
View File
@@ -0,0 +1,183 @@
[{
"id": "2_panel_right",
"abstract": true,
"font": "arial",
"texts": [{
"x_range": [0.52, 0.98],
"y_range": [0.00, 0.50]
}, {
"x_range": [0.52, 0.98],
"y_range": [0.50, 1.00]
}]
},{
"id": "3_panel_right",
"abstract": true,
"font": "arial",
"texts": [{
"x_range": [0.52, 0.98],
"y_range": [0.00, 0.33]
},{
"x_range": [0.52, 0.98],
"y_range": [0.33, 0.67]
}, {
"x_range": [0.52, 0.98],
"y_range": [0.67, 1.00]
}]
},{
"id": "4_panel_right",
"abstract": true,
"font": "arial",
"texts": [{
"x_range": [0.52, 0.98],
"y_range": [0.00, 0.25]
},{
"x_range": [0.52, 0.98],
"y_range": [0.25, 0.50]
},{
"x_range": [0.52, 0.98],
"y_range": [0.50, 0.75]
},{
"x_range": [0.52, 0.98],
"y_range": [0.75, 1.00]
}]
},{
"id": "5_panel_right",
"abstract": true,
"font": "arial",
"texts": [{
"x_range": [0.52, 0.98],
"y_range": [0.00, 0.20]
},{
"x_range": [0.52, 0.98],
"y_range": [0.20, 0.40]
},{
"x_range": [0.52, 0.98],
"y_range": [0.40, 0.60]
},{
"x_range": [0.52, 0.98],
"y_range": [0.60, 0.80]
},{
"x_range": [0.52, 0.98],
"y_range": [0.80, 1.00]
}]
},{
"id": "2_panel_left",
"abstract": true,
"font": "arial",
"texts": [{
"x_range": [0.02, 0.48],
"y_range": [0.00, 0.50]
}, {
"x_range": [0.02, 0.48],
"y_range": [0.50, 1.00]
}]
},{
"id": "3_panel_left",
"abstract": true,
"font": "arial",
"texts": [{
"x_range": [0.02, 0.48],
"y_range": [0.00, 0.33]
},{
"x_range": [0.02, 0.48],
"y_range": [0.33, 0.67]
}, {
"x_range": [0.02, 0.48],
"y_range": [0.67, 1.00]
}]
},{
"id": "4_panel_left",
"abstract": true,
"font": "arial",
"texts": [{
"x_range": [0.02, 0.48],
"y_range": [0.00, 0.25]
},{
"x_range": [0.02, 0.48],
"y_range": [0.25, 0.50]
},{
"x_range": [0.02, 0.48],
"y_range": [0.50, 0.75]
},{
"x_range": [0.02, 0.48],
"y_range": [0.75, 1.00]
}]
},{
"id": "5_panel_left",
"abstract": true,
"font": "arial",
"texts": [{
"x_range": [0.02, 0.48],
"y_range": [0.00, 0.20]
},{
"x_range": [0.02, 0.48],
"y_range": [0.20, 0.40]
},{
"x_range": [0.02, 0.48],
"y_range": [0.40, 0.60]
},{
"x_range": [0.02, 0.48],
"y_range": [0.60, 0.80]
},{
"x_range": [0.02, 0.48],
"y_range": [0.80, 1.00]
}]
},{
"id": "drake",
"template": "drake.jpg",
"based_on": "2_panel_right"
},{
"id": "brain3",
"template": "brain3.jpg",
"based_on": "3_panel_left"
},{
"id": "brain4",
"aliases": ["brains"],
"template": "brain4.jpg",
"based_on": "4_panel_left"
},{
"id": "brain5",
"template": "brain5.jpg",
"based_on": "5_panel_left"
},{
"id": "disappointed",
"template": "disappointed.jpg",
"based_on": "2_panel_left"
},{
"id": "nope",
"template": "nope.jpg",
"based_on": "2_panel_right"
},{
"id": "pleasure3",
"aliases": ["satisfied3"],
"template": "pleasure3.jpg",
"based_on": "3_panel_left"
},{
"id": "pleasure4",
"aliases": ["pleasure","satisfied","satisfied4"],
"template": "pleasure4.jpg",
"based_on": "4_panel_left"
},{
"id": "tough2",
"aliases": ["tough", "fight"],
"template": "tough2.jpg",
"based_on": "2_panel_right"
},{
"id": "tough2bis",
"aliases": ["soft"],
"template": "tough2bis.jpg",
"based_on": "2_panel_right"
},{
"id": "tough3",
"template": "tough3.jpg",
"based_on": "3_panel_right"
},{
"id": "winnie2",
"aliases": ["winnie"],
"template": "winnie2.jpg",
"based_on": "2_panel_right"
},{
"id": "winnie3",
"template": "winnie3.jpg",
"based_on": "3_panel_right"
}]
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB