import math
import os
import warnings
from PIL import Image as PIL_Image, ImageDraw, ImageEnhance
from nider.colors import color_to_rgb, get_img_dominant_color, generate_opposite_color, blend
from nider.core import Font, Text, MultilineTextUnit, SingleLineTextUnit
from nider.exceptions import (ImageGeneratorException, ImageSizeFixedWarning,
AutoGeneratedUnitColorUsedWarning, AutoGeneratedUnitOutlinecolorUsedWarning)
from nider.utils import get_random_bgcolor, get_random_texture, is_path_creatable
[docs]class Paragraph(MultilineTextUnit):
__doc__ = '''Class that represents a paragraph used in images''' + \
'\n\n' + MultilineTextUnit.__doc__
[docs]class Linkback(SingleLineTextUnit):
'''Class that represents a linkback used in images
Args:
text (str): text used for the unit.
font (nider.core.Font): font object that represents text's font.
color (str): string that represents a color. Must be compatible with `PIL.ImageColor <http://pillow.readthedocs.io/en/latest/reference/ImageColor.html>`_ `color names <http://pillow.readthedocs.io/en/latest/reference/ImageColor.html#color-names>`_
outline (nider.core.Outline): outline object that represents text's outline.
align ('left' or 'center' or 'right'): side with respect to which the text will be aligned.
bottom_padding (int): linkback’s bottom padding - padding (in pixels) between the bottom of the image and the linkback itself.
Raises:
nider.exceptions.InvalidAlignException: if ``align`` is not supported by nider.
nider.exceptions.DefaultFontWarning: if ``font.path`` is ``None``.
nider.exceptions.FontNotFoundWarning: if ``font.path`` does not exist.
'''
def __init__(self, text, font=None,
color=None, outline=None,
align='right', bottom_padding=20):
self.bottom_padding = bottom_padding
super().__init__(text=text, font=font, color=color, outline=outline,
align=align)
def _set_height(self):
'''Sets linkback\'s height'''
super()._set_height()
self.height += self.bottom_padding
[docs]class Watermark(Text):
'''Class that represents a watermark used in images
Args:
text (str): text to use.
font (nider.core.Font): font object that represents text's font.
color (str): string that represents a color. Must be compatible with `PIL.ImageColor <http://pillow.readthedocs.io/en/latest/reference/ImageColor.html>`_ `color names <http://pillow.readthedocs.io/en/latest/reference/ImageColor.html#color-names>`_
outline (nider.core.Outline): outline object that represents text's outline.
cross (bool): boolean flag that indicates whether watermark has to be drawn with a cross.
rotate_angle (int): angle to which watermark's text has to be rotated.
opacity (0 <= float <= 1): opacity level of the watermark (applies to both the text and the cross).
Raises:
nider.exceptions.ImageGeneratorException: if ``font`` is the default font.
nider.exceptions.DefaultFontWarning: if ``font.path`` is ``None``.
nider.exceptions.FontNotFoundWarning: if ``font.path`` does not exist.
Note:
Watermark tries to takes all available width of the image so providing ``font.size`` has no affect on the watermark.
'''
def __init__(self, text, font,
color=None, outline=None,
cross=True, rotate_angle=None,
opacity=0.25):
if font.is_default:
raise ImageGeneratorException(
'Watermark cannot be drawn using a default font. Please provide an existing font')
super().__init__(text=text, font=font, color=color, outline=outline)
self.cross = cross
self.rotate_angle = rotate_angle
self.opacity = opacity
def _adjust_fontsize(self, bg_image):
text_width, text_height = self.font.getsize(self.text)
angle = self.rotate_angle
# If the width of the image is bigger than the height of the image and we rotate
# our watermark it may go beyond the bounding box of the original image,
# that's why we need to take into consideration the actual width of the rotated
# waterwark inside the original image's bounding box
bg_image_w, bg_image_h = bg_image.size
if angle and (bg_image_w > bg_image_h):
max_wm_w = 2 * \
abs(bg_image_h / (2 * math.sin(math.radians(angle))))
else:
max_wm_w = bg_image_w
while text_width + text_height < max_wm_w:
self.font_object = Font(
self.font_object.path, self.font_object.size + 1)
self.font = self.font_object.font
text_width, text_height = self.font.getsize(self.text)
[docs]class Content:
'''Class that aggregates different units into a sigle object
Args:
paragraph (nider.models.Paragraph): paragraph used for in the content.
header (nider.models.Header): header used for in the content.
linkback (nider.models.Linkback): linkback used for in the content.
watermark (nider.models.Watermark): watermark used for in the content.
padding (int): content's padding - padding (in pixels) between the units.
Raises:
nider.exceptions.ImageGeneratorException: if neither of ``paragraph``, ``header`` or ``linkback`` is provided.
Note:
``padding`` is taken into account only if image is to get resized. If size allows content to fit freely, pre-calculated paddings will be used.
Note:
Content has to consist at least of one unit: header, paragraph or linkback.
'''
def __init__(self, paragraph=None, header=None, linkback=None, watermark=None, padding=45):
if not any((paragraph, header, linkback, watermark)):
raise ImageGeneratorException(
'Content has to consist at least of one unit.')
self.para = paragraph
self.header = header
self.linkback = linkback
self.watermark = watermark
self.padding = padding
self.depends_on_opposite_to_bg_color = not all(
unit.color for unit in [
self.para, self.header, self.linkback, self.watermark
] if unit
)
# Variable to check if the content fits into the img. Default is true,
# but it may changed by in Img._fix_image_size()
self.fits = True
self._set_content_height()
def _set_content_height(self):
'''Sets content\'s height'''
self.height = 0
if self.para:
self.height += 2 * self.padding + self.para.height
if self.header:
self.height += 1 * self.padding + self.header.height
if self.linkback:
self.height += self.linkback.height
[docs]class Image:
'''Base class for a text based image
Args:
content (nider.models.Content): content object that has units to be rendered.
fullpath (str): path where the image has to be saved.
width (int): width of the image to be generated.
height (int): height of the image to be generated.
title (str): title of the image. Serves as metadata for latter rendering in html. May be used as alt text of the image. If no title is provided ``content.header.text`` will be set as the value.
description (str): description of the image. Serves as metadata for latter rendering in html. May be used as description text of the image. If no description is provided ``content.paragraph.text`` will be set as the value.
Raises:
nider.exceptions.ImageGeneratorException: if the current user doesn't have sufficient permissions to create the file at passed ``fullpath``.
AttributeError: if width <= 0 or height <= 0.
'''
def __init__(self, content, fullpath, width=1080, height=1080, title=None, description=None):
self._set_content(content)
self._set_fullpath(fullpath)
self._set_image_size(width, height)
self._set_title(title)
self._set_description(description)
[docs] def draw_on_texture(self, texture_path=None):
'''Draws preinitialized image and its attributes on a texture
If ``texture_path`` is set to ``None``, random texture from ``nider/textures`` will be taken.
Args:
texture_path (str): path of the texture to use.
Raises:
FileNotFoundError: if texture file at path ``texture_path`` cannot be found.
nider.exceptions.ImageSizeFixedWarning: if the image size has to be adjusted to the provided content's size because the content takes too much space.
'''
if texture_path is None:
texture_path = get_random_texture()
elif not os.path.isfile(texture_path):
raise FileNotFoundError(
'Can\'t find texture {}. Please, choose an existing texture'.format(texture_path))
if self.content.depends_on_opposite_to_bg_color:
self.opposite_to_bg_color = generate_opposite_color(
get_img_dominant_color(texture_path)
)
self._create_image()
self._create_draw_object()
self._fill_image_with_texture(texture_path)
self._draw_content()
self._save()
[docs] def draw_on_bg(self, bgcolor=None):
'''Draws preinitialized image and its attributes on a colored background
If ``bgcolor`` is set to ``None``, random ``nider.colors.colormap.FLAT_UI_COLORS`` will be taken.
Args:
bgcolor (str): string that represents a background color. Must be compatible with `PIL.ImageColor <http://pillow.readthedocs.io/en/latest/reference/ImageColor.html>`_ `color names <http://pillow.readthedocs.io/en/latest/reference/ImageColor.html#color-names>`_
Raises:
nider.exceptions.ImageSizeFixedWarning: if the image size has to be adjusted to the provided content's size because the content takes too much space.
'''
self.bgcolor = color_to_rgb(
bgcolor) if bgcolor else get_random_bgcolor()
if self.content.depends_on_opposite_to_bg_color:
self.opposite_to_bg_color = generate_opposite_color(self.bgcolor)
self._create_image()
self._create_draw_object()
self._fill_image_with_color()
self._draw_content()
self._save()
[docs] def draw_on_image(self, image_path, image_enhancements=None, image_filters=None):
'''Draws preinitialized image and its attributes on an image
Args:
image_path (str): path of the image to draw on.
image_enhancements (itarable): itarable of tuples, each containing a class from ``PIL.ImageEnhance`` that will be applied and factor - a floating point value controlling the enhancement. Check `documentation <http://pillow.readthedocs.io/en/latest/reference/ImageEnhance.html>`_ of ``PIL.ImageEnhance`` for more info about availabe enhancements.
image_filters (itarable): itarable of filters from ``PIL.ImageFilter`` that will be applied. Check `documentation <http://pillow.readthedocs.io/en/latest/reference/ImageFilter.html>`_ of ``PIL.ImageFilter`` for more info about availabe filters.
Raises:
FileNotFoundError: if image file at path ``image_path`` cannot be found.
'''
if not os.path.isfile(image_path):
raise FileNotFoundError(
'Can\'t find image {}. Please, choose an existing image'.format(image_path))
self.image = PIL_Image.open(image_path)
if self.content.depends_on_opposite_to_bg_color:
self.opposite_to_bg_color = generate_opposite_color(
get_img_dominant_color(image_path)
)
if image_filters:
for image_filter in image_filters:
self.image = self.image.filter(image_filter)
if image_enhancements:
for enhancement in image_enhancements:
enhance_method, enhance_factor = enhancement[0], enhancement[1]
enhancer = enhance_method(self.image)
self.image = enhancer.enhance(enhance_factor)
self._create_draw_object()
self.width, self.height = self.image.size
self._draw_content()
self._save()
def _save(self):
'''Saves the image'''
self.image.save(self.fullpath)
def _set_content(self, content):
'''Sets content used in the image'''
self.content = content
self.header = content.header
self.para = content.para
self.linkback = content.linkback
self.watermark = content.watermark
def _set_fullpath(self, fullpath):
'''Sets path where to save the image'''
if is_path_creatable(fullpath):
self.fullpath = fullpath
else:
raise ImageGeneratorException(
"Is seems impossible to create a file in path {}".format(fullpath))
def _set_image_size(self, width, height):
'''Sets width and height of the image'''
if width <= 0 or height <= 0:
raise AttributeError(
"Width or height of the image have to be positive integers")
self.width = width
self.height = height
def _set_title(self, title):
'''Sets title of the image'''
if title:
self.title = title
elif self.content.header:
self.title = self.content.header.text
else:
self.title = ''
def _set_description(self, description):
'''Sets description of the image'''
if description:
self.description = description
elif self.content.para:
self.description = self.content.para.text
else:
self.description = ''
def _fix_image_size(self):
'''Fixes image's size'''
if self.content.height >= self.height:
warnings.warn(ImageSizeFixedWarning())
self.content.fits = False
self.height = self.content.height
def _create_image(self):
'''Creates a basic PIL image
Creates a basic PIL image previously fixing its size
'''
self._fix_image_size()
self.image = PIL_Image.new("RGBA", (self.width, self.height))
def _create_draw_object(self):
'''Creates a basic PIL Draw object'''
self.draw = ImageDraw.Draw(self.image)
def _fill_image_with_texture(self, texture_path):
'''Fills an image with a texture
Fills an image with a texture by reapiting it necessary number of times
Attributes:
texture_path (str): path of the texture to use
'''
texture = PIL_Image.open(texture_path, 'r')
texture_w, texture_h = texture.size
bg_w, bg_h = self.image.size
times_for_Ox = math.ceil(bg_w / texture_w)
times_for_Oy = math.ceil(bg_h / texture_h)
for y in range(times_for_Oy):
for x in range(times_for_Ox):
offset = (x * texture_w, y * texture_h)
self.image.paste(texture, offset)
def _fill_image_with_color(self):
'''Fills an image with a color
Fills an image with a color by creating a colored rectangle of the image
size
'''
self.draw.rectangle([(0, 0), self.image.size], fill=self.bgcolor)
def _prepare_content(self):
'''Prepares content for drawing'''
content = self.content
for unit in [content.header, content.para, content.linkback]:
if unit:
if not unit.color:
color_to_use = self.opposite_to_bg_color
# explicitly sets unit's color to disntinc to bg one
unit.color = color_to_use
warnings.warn(
AutoGeneratedUnitColorUsedWarning(unit, color_to_use))
if unit.outline and not unit.outline.color:
color_to_use = blend(unit.color, '#000', 0.2)
unit.outline.color = color_to_use
warnings.warn(
AutoGeneratedUnitOutlinecolorUsedWarning(unit, color_to_use))
def _draw_content(self):
'''Draws each unit of the content on the image'''
self._prepare_content()
if self.header:
self._draw_header()
if self.para:
self._draw_para()
if self.linkback:
self._draw_linkback()
if self.watermark:
self._draw_watermark()
def _draw_header(self):
'''Draws the header on the image'''
current_h = self.content.padding
self._draw_unit(current_h, self.header)
def _draw_para(self):
'''Draws the paragraph on the image'''
if self.content.fits:
# Trying to center everything
current_h = math.floor(
(self.height - self.para.height) / 2)
self._draw_unit(current_h, self.para)
else:
if self.header:
header_with_padding_height = 2 * self.content.padding + self.header.height
current_h = header_with_padding_height
else:
current_h = self.content.padding
self._draw_unit(current_h, self.para)
def _draw_linkback(self):
'''Draws a linkback on the image'''
current_h = self.height - self.linkback.height
self._draw_unit(current_h, self.linkback)
def _draw_watermark(self):
'''Draws a watermark on the image'''
watermark_image = PIL_Image.new('RGBA', self.image.size, (0, 0, 0, 0))
self.watermark._adjust_fontsize(self.image)
draw = ImageDraw.Draw(watermark_image, 'RGBA')
w_width, w_height = self.watermark.font.getsize(self.watermark.text)
draw.text(((watermark_image.size[0] - w_width) / 2,
(watermark_image.size[1] - w_height) / 2),
self.watermark.text, font=self.watermark.font,
fill=self.watermark.color)
if self.watermark.rotate_angle:
watermark_image = watermark_image.rotate(
self.watermark.rotate_angle, PIL_Image.BICUBIC)
alpha = watermark_image.split()[3]
alpha = ImageEnhance.Brightness(alpha).enhance(self.watermark.opacity)
watermark_image.putalpha(alpha)
# Because watermark can be rotated we create a separate image for cross
# so that it doesn't get rotated also. + It's impossible to drawn
# on a rotated image
if self.watermark.cross:
watermark_cross_image = PIL_Image.new(
'RGBA', self.image.size, (0, 0, 0, 0))
cross_draw = ImageDraw.Draw(watermark_cross_image, 'RGBA')
line_width = 1 + int(sum(self.image.size) / 2 * 0.007)
cross_draw.line(
(0, 0) + watermark_image.size,
fill=self.watermark.color,
width=line_width
)
cross_draw.line(
(0, watermark_image.size[1], watermark_image.size[0], 0),
fill=self.watermark.color,
width=line_width
)
watermark_cross_alpha = watermark_cross_image.split()[3]
watermark_cross_alpha = ImageEnhance.Brightness(
watermark_cross_alpha).enhance(self.watermark.opacity)
watermark_cross_image.putalpha(watermark_cross_alpha)
# Adds cross to the watermark
watermark_image = PIL_Image.composite(
watermark_cross_image, watermark_image, watermark_cross_image)
self.image = PIL_Image.composite(
watermark_image, self.image, watermark_image)
def _draw_unit(self, start_height, unit):
'''Draws the text and its outline on the image starting at specific height'''
current_h = start_height
try:
lines = unit.wrapped_lines
except AttributeError:
# text is a one-liner. Construct a list out of it for later usage
lines = [unit.text]
line_padding = getattr(unit, 'line_padding', None)
outline = unit.outline
font = unit.font
for line in lines:
w, h = self.draw.textsize(line, font=unit.font)
if unit.align == "center":
x = (self.width - w) / 2
elif unit.align == "left":
x = self.width * 0.075
elif unit.align == "right":
x = 0.925 * self.width - w
y = current_h
if outline:
# thin border
self.draw.text((x - outline.width, y), line, font=font,
fill=outline.color)
self.draw.text((x + outline.width, y), line, font=font,
fill=outline.color)
self.draw.text((x, y - outline.width), line, font=font,
fill=outline.color)
self.draw.text((x, y + outline.width), line, font=font,
fill=outline.color)
# thicker border
self.draw.text((x - outline.width, y - outline.width), line,
font=font, fill=outline.color)
self.draw.text((x + outline.width, y - outline.width), line,
font=font, fill=outline.color)
self.draw.text((x - outline.width, y + outline.width), line,
font=font, fill=outline.color)
self.draw.text((x + outline.width, y + outline.width), line,
font=font, fill=outline.color)
self.draw.text((x, y), line, unit.color, font=font)
if line_padding:
current_h += h + line_padding
[docs]class FacebookSquarePost(Image):
'''Alias of :class:`nider.models.Image` with ``width=470`` and ``height=470``'''
def __init__(self, *args, **kwargs):
kwargs['width'] = kwargs.get('width', 470)
kwargs['height'] = kwargs.get('height', 470)
super().__init__(*args, **kwargs)
[docs]class FacebookLandscapePost(Image):
'''Alias of :class:`nider.models.Image` with ``width=1024`` and ``height=512``'''
def __init__(self, *args, **kwargs):
kwargs['width'] = kwargs.get('width', 1024)
kwargs['height'] = kwargs.get('height', 512)
super().__init__(*args, **kwargs)
TwitterPost = FacebookLandscapePost
[docs]class InstagramSquarePost(Image):
'''Alias of :class:`nider.models.Image` with ``width=1080`` and ``height=1080``'''
def __init__(self, *args, **kwargs):
kwargs['width'] = kwargs.get('width', 1080)
kwargs['height'] = kwargs.get('height', 1080)
super().__init__(*args, **kwargs)
[docs]class InstagramPortraitPost(Image):
'''Alias of :class:`nider.models.Image` with ``width=1080`` and ``height=1350``'''
def __init__(self, *args, **kwargs):
kwargs['width'] = kwargs.get('width', 1080)
kwargs['height'] = kwargs.get('height', 1350)
super().__init__(*args, **kwargs)
[docs]class InstagramLandscapePost(Image):
'''Alias of :class:`nider.models.Image` with ``width=1080`` and ``height=566``'''
def __init__(self, *args, **kwargs):
kwargs['width'] = kwargs.get('width', 1080)
kwargs['height'] = kwargs.get('height', 566)
super().__init__(*args, **kwargs)