1
0
mirror of https://github.com/pythad/nider.git synced 2021-10-12 02:31:02 +03:00

Added support for images watermarking

This commit is contained in:
Vladyslav Ovchynnykov
2017-09-14 16:29:25 +03:00
parent c5f9eead0a
commit 6f9e6e7063
19 changed files with 351 additions and 22 deletions

View File

@@ -21,3 +21,11 @@ History
* Dropped shadow support for units
* Added outline support for units
* Made unit's font config as a separate class
0.4.0 (2017-09-14)
------------------
* Added ability to add watermarks to images

View File

@@ -26,13 +26,13 @@ nider
:target: https://pypi.python.org/pypi/nider
:alt: License
Python package to add text to images, textures and different backgrounds
Python package for text images generation and watermarking
* Free software: MIT license
* Documentation: https://nider.readthedocs.io.
``nider`` is an approach to make generation of text based images simple yet flexible. Creating of an image is as simple as describing units you want to be rendered to the image and choosing a method that will be used for drawing.
``nider`` is an approach to make generation of text images simple yet flexible. Creating of an image is as simple as describing units you want to be rendered to the image and choosing a method that will be used for drawing.
************
Installation
@@ -73,21 +73,32 @@ All of the featured images were drawn using ``nider`` package. Code used to gene
Example 1
========
=========
.. image:: https://github.com/pythad/nider/raw/master/examples/example1/result.png
:alt: example1
Example 2
========
=========
.. image:: https://github.com/pythad/nider/raw/master/examples/example2/result.png
:alt: example2
Example 3
========
=========
.. image:: https://github.com/pythad/nider/raw/master/examples/example3/result.png
:alt: example3
Example 4
========
=========
.. image:: https://github.com/pythad/nider/raw/master/examples/example4/result.png
:alt: example4
Watermark example 1
===================
.. image:: https://github.com/pythad/nider/raw/master/examples/add_watermark_example/result.jpg
:alt: add_watermark_example
Watermark example 2
===================
.. image:: https://github.com/pythad/nider/raw/master/examples/draw_on_bg_with_watermark_example/result.png
:alt: draw_on_bg_with_watermark_example

View File

@@ -11,7 +11,7 @@ nider\.models module
--------------------
.. automodule:: nider.models
:members: Header, Paragraph, Linkback, Content, Image, FacebookSquarePost, FacebookLandscapePost, TwitterPost, TwitterLargeCard, InstagramSquarePost, InstagramPortraitPost, InstagramLandscapePost
:members: Header, Paragraph, Linkback, Watermark, Content, Image, FacebookSquarePost, FacebookLandscapePost, TwitterPost, TwitterLargeCard, InstagramSquarePost, InstagramPortraitPost, InstagramLandscapePost
nider\.exceptions module
------------------------

View File

@@ -34,8 +34,10 @@ Example
from nider.core import Font
from nider.core import Outline
from nider.models import Header
header = Header(text='Your super interesting title!',
font=Font('/home/me/.local/share/fonts/Roboto/Roboto-Bold.ttf', 30),
text_width=40,
@@ -59,8 +61,10 @@ Example
from nider.core import Font
from nider.core import Outline
from nider.models import Paragraph
para = Paragraph(text='Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.',
font=Font('/home/me/.local/share/fonts/Roboto/Roboto-Bold.ttf', 30),
text_width=65,
@@ -82,8 +86,10 @@ Example
from nider.core import Font
from nider.core import Outline
from nider.models import Linkback
linkback = Linkback(text='foo.com | @username',
font=Font('/home/me/.local/share/fonts/Roboto/Roboto-Bold.ttf', 30),
color='#ededed',
@@ -118,6 +124,7 @@ Example
from nider.models import Linkback
from nider.models import Paragraph
para = Paragraph(...)
linkback = Linkback(...)
@@ -144,6 +151,7 @@ Example
from nider.models import Content
from nider.models import Image
content = Content(...)
img = Image(content,
@@ -211,6 +219,7 @@ Example
from nider.models import Content
from nider.models import Image
content = Content(...)
img = Image(content,
@@ -229,7 +238,7 @@ Check the full example `here <https://github.com/pythad/nider/blob/master/exampl
``nider`` comes with a `huge bundle of textures <https://github.com/pythad/nider/tree/master/nider/textures>`_. As for now you need to copy them to your machine if you want to use any of them.
``Image.draw_on_bg``
=========================
====================
.. automethod:: nider.models.Image.draw_on_bg
@@ -241,6 +250,7 @@ Example
from nider.models import Content
from nider.models import Image
content = Content(...)
img = Image(content,
@@ -254,7 +264,7 @@ Example
Check the full example `here <https://github.com/pythad/nider/blob/master/examples/draw_on_bg_example/script.py>`_ .
``Image.draw_on_image``
=========================
=======================
.. automethod:: nider.models.Image.draw_on_image
@@ -266,6 +276,7 @@ Examples
from nider.models import Content
from nider.models import Image
content = Content(...)
img = Image(content,
@@ -290,4 +301,68 @@ Check the full example `here <https://github.com/pythad/nider/blob/master/exampl
============
That's it. After any of draw methods has been called and successfully completed the new image will be saved to ``Image.fullpath``.
That's it. After any of draw methods has been called and successfully completed the new image will be saved to ``Image.fullpath``.
*****************
Adding watermarks
*****************
``nider`` comes with built-in support for adding watermarks to your images.
First of all you need to create an instanse of :class:`nider.models.Watermark` class.
.. autoclass:: nider.models.Watermark
Example
=======
.. code-block:: python
watermark = Watermark(text='COPYRIGHT',
font=Font('/home/me/.local/share/fonts/Roboto/Roboto-Bold.ttf'),
color='#111',
cross=True,
rotate_angle=-45,
opacity=0.35
)
============
After this you can either add watermark to you ``Content`` instance and draw watermark on ``nider`` generated images:
.. code-block:: python
from nider.models import Content
from nider.models import Image
from nider.models import Watermark
watermark = Watermark(...)
content = Content(..., watermark=watermark)
img = Image(content,
fullpath='example.png',
width=500,
height=500
)
img.draw_on_bg('#efefef')
or you can add a watermark to an existing image using :func:`nider.tools.add_watermark`:
.. autofunction:: nider.tools.add_watermark
Example
=======
.. code-block:: python
from nider.models import Watermark
from nider.tools import add_watermark
watermark = Watermark(...)
add_watermark('path/to/my/img', watermark)

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -0,0 +1,17 @@
from nider.core import Font
from nider.models import Watermark
from nider.tools import add_watermark
# TODO: change this fontpath to the fontpath on your machine
roboto_fonts_folder = '/home/ovd/.local/share/fonts/Roboto/'
watermark = Watermark(text='COPYRIGHT',
font=Font(roboto_fonts_folder + 'Roboto-Medium.ttf', 20),
color='#111',
cross=True
)
add_watermark('bg.jpg', watermark, 'result.jpg')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View File

@@ -0,0 +1,33 @@
from nider.core import Font
from nider.models import Paragraph
from nider.models import Watermark
from nider.models import Content
from nider.models import Image
# TODO: change this fontpath to the fontpath on your machine
fonts_folder = '/home/ovd/.local/share/fonts/Roboto/'
para = Paragraph(text='Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.',
font=Font(fonts_folder + 'Roboto-Medium.ttf', 20),
text_width=49,
align='center',
color='#121212'
)
watermark = Watermark(text='COPYRIGHT',
font=Font(fonts_folder + 'Roboto-Medium.ttf'),
color='#111',
cross=True
)
content = Content(para, watermark=watermark)
img = Image(content,
width=500,
height=500,
fullpath='result.png',
)
img.draw_on_bg('#efefef')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 381 KiB

After

Width:  |  Height:  |  Size: 380 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

After

Width:  |  Height:  |  Size: 243 KiB

View File

@@ -4,6 +4,7 @@ from nider.utils import get_font
from nider.mixins import MultilineTextMixin
from nider.mixins import AlignMixin
from nider.colors.utils import color_to_rgb
@@ -18,6 +19,7 @@ class Font:
nider.exceptions.DefaultFontWarning: if ``path`` is ``None``.
nider.exceptions.FontNotFoundWarning: if ``path`` does not exist.
'''
is_default = False
def __init__(self, path=None, size=18):
self.path = path
@@ -27,6 +29,7 @@ class Font:
def _set_font(self):
'''Sets object's font'''
self.font = get_font(self.path, self.size)
self.is_default = self.font.is_default
class Outline:
@@ -50,7 +53,17 @@ class Outline:
class Text:
'''Base class for the text'''
'''
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.
Raises:
nider.exceptions.DefaultFontWarning: if ``font.path`` is ``None``.
nider.exceptions.FontNotFoundWarning: if ``font.path`` does not exist.
'''
def __init__(self, text, font, color, outline):
self._set_text(text)
@@ -69,7 +82,19 @@ class Text:
class MultilineText(MultilineTextMixin, Text):
'''Base class for the multiline text'''
'''
Args:
text (str): text used for the unit.
font (nider.core.Font): font object that represents text's font.
text_width (int): units's text width - number of characters in a line.
line_padding (int): unit's line padding - padding (in pixels) between the lines.
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.
Raises:
AttributeError: if ``text_width`` < 0.
nider.exceptions.DefaultFontWarning: if ``font.path`` is ``None``.
nider.exceptions.FontNotFoundWarning: if ``font.path`` does not exist.'''
def __init__(self, text_width, line_padding, *args, **kwargs):
MultilineTextMixin.__init__(

View File

@@ -25,6 +25,14 @@ class FontNotFoundWarning(ImageGeneratorWarning):
"Font {} hasn't been found. Default font has been set instead".format(fontpath_provided))
class FontNotFoundException(ImageGeneratorWarning):
'''Exception raised when font cannot be found'''
def __init__(self, fontpath_provided):
super().__init__(
"Font {} hasn't been found.".format(fontpath_provided))
class DefaultFontWarning(ImageGeneratorWarning):
'''Warning raised when default font was used'''

View File

@@ -4,7 +4,10 @@ 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
@@ -45,7 +48,7 @@ class Linkback(SingleLineTextUnit):
bottom_padding (int): linkbacks 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.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.
'''
@@ -63,6 +66,61 @@ class Linkback(SingleLineTextUnit):
self.height += self.bottom_padding
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)
class Content:
'''Class that aggregates different units into a sigle object
@@ -70,6 +128,7 @@ class Content:
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:
@@ -85,17 +144,18 @@ class Content:
# but it may changed by in Img._fix_image_size()
fits = True
def __init__(self, paragraph=None, header=None, linkback=None, padding=45):
if not any((paragraph, header, 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.para, self.header, self.linkback, self.watermark
] if unit
)
self._set_content_height()
@@ -123,7 +183,7 @@ class Image:
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 has sufficient permissions to create the file at passed ``fullpath``.
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.
'''
@@ -224,6 +284,7 @@ class Image:
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'''
@@ -331,6 +392,8 @@ class Image:
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'''
@@ -357,6 +420,54 @@ class 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

26
nider/tools.py Normal file
View File

@@ -0,0 +1,26 @@
from nider.models import Image
from nider.models import Content
def add_watermark(image_path, watermark, new_path=None,
image_enhancements=None, image_filters=None):
'''Function to add watermarks to images
Args:
image_path (str): path of the image to which watermark has to be added.
watermark (nider.models.Watermark): watermark object.
new_path (str): path where the image has to be saved. **If set to None (default option), initial image will be overwritten.**
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.
nider.exceptions.ImageGeneratorException: if the current user doesn't have sufficient permissions to create the file at passed ``new_path``.
'''
if new_path is None:
new_path = image_path
content = Content(watermark=watermark)
new_image = Image(content, fullpath=new_path)
new_image.draw_on_image(image_path,
image_enhancements=image_enhancements,
image_filters=image_filters)

View File

@@ -29,11 +29,16 @@ def get_font(fontfullpath, fontsize):
'''
if fontfullpath is None:
warnings.warn(DefaultFontWarning())
return ImageFont.load_default()
font = ImageFont.load_default()
font.is_default = True
elif not os.path.exists(fontfullpath):
warnings.warn(FontNotFoundWarning(fontfullpath))
return ImageFont.load_default()
return ImageFont.truetype(fontfullpath, fontsize)
font = ImageFont.load_default()
font.is_default = True
else:
font = ImageFont.truetype(fontfullpath, fontsize)
font.is_default = False
return font
def is_path_creatable(pathname):

View File

@@ -9,6 +9,7 @@ from PIL import ImageDraw
from PIL import ImageEnhance
from PIL import ImageFilter
from nider.core import Font
from nider.core import Outline
from nider.core import MultilineTextUnit
@@ -21,6 +22,7 @@ from nider.models import InstagramLandscapePost
from nider.models import InstagramPortraitPost
from nider.models import InstagramSquarePost
from nider.models import Linkback
from nider.models import Watermark
from nider.models import Paragraph
from nider.models import TwitterLargeCard
from nider.models import TwitterPost
@@ -43,6 +45,13 @@ class TestLinkback(unittest.TestCase):
self.assertEqual(self.linkback.height, 31)
class TestWatermarkBehavior(unittest.TestCase):
def test_watermark_initialization_with_default_font(self):
with self.assertRaises(ImageGeneratorException):
Watermark(text='foo', font=Font())
class TestContent(unittest.TestCase):
def test_initialization_without_any_units(self):

View File

@@ -27,11 +27,12 @@ class TestGetFont(unittest.TestCase):
def test_existent_font(self, load_default_mock,
path_exists_mock, truetype_mock):
path_exists_mock.return_value = True
truetype_mock.return_value = 'existent_font'
return_mock = mock.MagicMock()
truetype_mock.return_value = return_mock
font = get_font(
fontfullpath=os.path.abspath('/foo/bar/'),
fontsize=15)
self.assertTrue(font, 'existent_font')
self.assertTrue(font, return_mock)
self.assertFalse(load_default_mock.called)