Source code for nider.models

import math
import os
import warnings

from PIL import Image as PIL_Image
from PIL import ImageDraw
from PIL import ImageEnhance

from nider.core import Font
from nider.core import Text
from nider.core import MultilineTextUnit
from nider.core import SingleLineTextUnit

from nider.utils import get_random_bgcolor
from nider.utils import get_random_texture
from nider.utils import is_path_creatable

from nider.colors import color_to_rgb
from nider.colors import get_img_dominant_color
from nider.colors import generate_opposite_color
from nider.colors import blend

from nider.exceptions import ImageGeneratorException
from nider.exceptions import ImageSizeFixedWarning
from nider.exceptions import AutoGeneratedUnitColorUsedWarning
from nider.exceptions import AutoGeneratedUnitOutlinecolorUsedWarning





[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. ''' # Variable to check if the content fits into the img. Default is true, # but it may changed by in Img._fix_image_size() fits = True 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 ) 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 TwitterLargeCard(Image): '''Alias of :class:`nider.models.Image` with ``width=506`` and ``height=506``''' def __init__(self, *args, **kwargs): kwargs['width'] = kwargs.get('width', 506) kwargs['height'] = kwargs.get('height', 506) super().__init__(*args, **kwargs)
[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)