diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..292ea36da12fc9f0e6c7cf402ee9807a4ea9ad54 --- /dev/null +++ b/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2013, Branko Majic +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above copyright notice, this + list of conditions and the following disclaimer in the documentation and/or + other materials provided with the distribution. + + Neither the name of Branko Majic nor the names of any other + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.rst b/README.rst new file mode 100644 index 0000000000000000000000000000000000000000..f5c2ec598bb8781bedee5a8e5f799899a1f1d71b --- /dev/null +++ b/README.rst @@ -0,0 +1,16 @@ +Pydenticon +========== + +Pydenticon is a small utility library that can be used for deterministically +generating identicons based on the hash of provided data. + +The implementation is a port of the Sigil identicon implementation from: + +* https://github.com/cupcake/sigil/ + +Pydenticon provides a couple of extensions of its own when compared to the +original Sigil implementation, like: + +* Ability to supply custom digest algorithms (allowing for larger identicons if + digest provides enough entropy). +* Ability to specify a rectangle for identicon size.. diff --git a/pydenticon/__init__.py b/pydenticon/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..147e63b48f417d4b81aad72eb75d8a183ba1a269 --- /dev/null +++ b/pydenticon/__init__.py @@ -0,0 +1,325 @@ +# For digest operations. +import hashlib + +# For saving the images from Pillow. +from io import BytesIO + +# Pillow for Image processing. +from PIL import Image, ImageDraw + + +class Generator(object): + """ + Factory class that can be used for generating the identicons + deterministically based on hash of the passed data. + + Resulting identicons are images of requested size with optional padding. The + identicon (without padding) consists out of M x N blocks, laid out in a + rectangle, where M is the number of blocks in each column, while N is number + of blocks in each row. + + Each block is a smallself rectangle on its own, filled using the foreground or + background colour. + + The foreground is picked randomly, based on the passed data, from the list + of foreground colours set during initialisation of the generator. + + The blocks are always laid-out in such a way that the identicon will be + symterical by the Y axis. The center of symetry will be the central column + of blocks. + + Simply put, the generated identicons are small symmetric mosaics with + optional padding. + """ + + def __init__(self, rows, columns, digest=hashlib.md5, foreground=["#000000"], background="#ffffff"): + """ + Initialises an instance of identicon generator. The instance can be used + for creating identicons with differing image formats, sizes, and with + different padding. + + Arguments: + + rows - Number of block rows in an identicon. + + columns - Number of block columns in an identicon. + + digest - Digest class that should be used for the user's data. The + class should support accepting a single constructor argument for + passing the data on which the digest will be run. Instances of the + class should also support a single hexdigest() method that should + return a digest of passed data as a hex string. Default is + hashlib.md5. Selection of the digest will limit the maximum values + that can be set for rows and columns. Digest needs to be able to + generate (columns / 2 + columns % 2) * rows + 8 bits of entropy. + + foreground - List of colours which should be used for drawing the + identicon. Each element should be a string of format supported by the + PIL.ImageColor module. Default is ["#000000"] (only black). + + background - Colour (single) which should be used for background and + padding, represented as a string of format supported by the + PIL.ImageColor module. Default is "#ffffff" (white). + """ + + # Check if the digest produces sufficient entropy for identicon + # generation. + entropy_provided = len(digest("test").hexdigest()) / 2 * 8 + entropy_required = (columns / 2 + columns % 2) * rows + 8 + + if entropy_provided < entropy_required: + raise ValueError("Passed digest '%s' is not capable of providing %d bits of entropy" % (str(digest), entropy_required)) + + # Set the expected digest size. This is used later on to detect if + # passed data is a digest already or not. + self.digest_entropy = entropy_provided + + self.rows = rows + self.columns = columns + + self.foreground = foreground + self.background = background + + self.digest = digest + + def _get_bit(self, n, hash_bytes): + """ + Determines if the n-th bit of passed bytes is 1 or 0. + + Arguments: + + hash_bytes - List of hash byte values for which the n-th bit value + should be checked. Each element of the list should be an integer from + 0 to 255. + + Returns: + + True if the bit is 1. False if the bit is 0. + """ + + if hash_bytes[n / 8] >> int(8 - ((n % 8) + 1)) & 1 == 1: + return True + + return False + + def _generate_matrix(self, hash_bytes): + """ + Generates matrix that describes which blocks should be coloured. + + Arguments: + hash_bytes - List of hash byte values for which the identicon is being + generated. Each element of the list should be an integer from 0 to + 255. + + Returns: + List of rows, where each element in a row is boolean. True means the + foreground colour should be used, False means a background colour + should be used. + """ + + # Since the identicon needs to be symmetric, we'll need to work on half + # the columns (rounded-up), and reflect where necessary. + half_columns = self.columns / 2 + self.columns % 2 + cells = self.rows * half_columns + + # Initialise the matrix (list of rows) that will be returned. + matrix = [[False] * self.columns for _ in range(self.rows)] + + # Process the cells one by one. + for cell in range(cells): + + # If the bit from hash correpsonding to this cell is 1, mark the + # cell as foreground one. Do not use first byte (since that one is + # used for determining the foreground colour. + if self._get_bit(cell, hash_bytes[1:]): + + # Determine the cell coordinates in matrix. + column = cell / self.columns + row = cell % self.rows + + # Mark the cell and its reflection. Central column may get + # marked twice, but we don't care. + matrix[row][column] = True + matrix[row][self.columns - column - 1] = True + + return matrix + + def _data_to_digest_byte_list(self, data): + """ + Creates digest of data, returning it as a list where every element is a + single byte of digest (an integer between 0 and 255). + + No digest will be calculated on the data if the passed data is already a + valid hex string representation of digest, and the passed value will be + used as digest in hex string format instead. + + Arguments: + + data - Raw data or hex string representation of existing digest for + which a list of one-byte digest values should be returned. + + Returns: + + List of integers where each element is between 0 and 255, and + repesents a single byte of a data digest. + """ + + # If data seems to provide identical amount of entropy as digest, it + # could be a hex digest already. + if len(data) / 2 == self.digest_entropy / 8: + try: + digest = data.decode("hex") + except TypeError: + digest = self.digest(data).hexdigest() + else: + digest = self.digest(data).hexdigest() + + return [int(digest[i * 2:i * 2 + 2], 16) for i in range(16)] + + def _generate_png(self, matrix, width, height, padding, foreground, background): + """ + Generates an identicon image in the PNG format out of the passed block + matrix, with the requested width, height, padding, foreground colour, + and background colour. + + Arguments: + + matrix - Matrix describing which blocks in the identicon should be + painted with foreground (background if inverted) colour. + + width - Width of resulting identicon image in pixels. + + height - Height of resulting identicon image in pixels. + + padding - Tuple describing padding around the generated identicon. The + tuple should consist out of four values, where each value is the + number of pixels to use for padding. The order in tuple is: top, + bottom, left, right. + + foreground - Colour which should be used for foreground (filled + blocks), represented as a string of format supported by the + PIL.ImageColor module. + + background - Colour which should be used for background and padding, + represented as a string of format supported by the PIL.ImageColor + module. + + Returns: + + Identicon image in PNG format, returned as a byte list. + """ + + # Set-up a new image object, setting the background to provided value. + image = Image.new("RGB", (width + padding[2] + padding[3], height + padding[0] + padding[1]), background) + + # Set-up a draw image (for drawing the blocks). + draw = ImageDraw.Draw(image) + + # Calculate the block widht and height. + block_width = width / self.columns + block_height = height / self.rows + + # Go through all the elements of a matrix, and draw the rectangles. + for row, row_columns in enumerate(matrix): + for column, cell in enumerate(row_columns): + if cell: + # Set-up the coordinates for a block. + x1 = padding[2] + column * block_width + y1 = padding[0] + row * block_height + x2 = padding[2] + (column + 1) * block_width + y2 = padding[0] + (row + 1) * block_height + + # Draw the rectangle. + draw.rectangle((x1, y1, x2, y2), fill=foreground) + + # Set-up a stream where image will be saved. + stream = BytesIO() + + # Save the image to stream. + image.save(stream, format="png", optimize=True) + image_raw = stream.getvalue() + stream.close() + + # Return the resulting PNG. + return image_raw + + def _generate_ascii(self, matrix, foreground, background): + """ + Generates an identicon "image" in the ASCII format. The image will just + output the matrix used to generate the identicon. + + Arguments: + + matrix - Matrix describing which blocks in the identicon should be + painted with foreground (background if inverted) colour. + + foreground - Character which should be used for representing + foreground. + + background - Character which should be used for representing + background. + + Returns: + + ASCII representation of an identicon image, where one block is one + character. + """ + + return "\n".join(["".join([foreground if cell else background for cell in row]) for row in matrix]) + + def generate(self, data, width, height, padding=(0, 0, 0, 0), output_format="png", inverted=False): + """ + Generates an identicon image with requested width, height, padding, and + output format, optionally inverting the colours in the indeticon + (swapping background and foreground colours) if requested. + + Arguments: + + data - Hashed or raw data that will be used for generating the + identicon. + + width - Width of resulting identicon image in pixels. + + height - Height of resulting identicon image in pixels. + + padding - Tuple describing padding around the generated identicon. The + tuple should consist out of four values, where each value is the + number of pixels to use for padding. The order in tuple is: top, + bottom, left, right. + + output_format - Output format of resulting identicon image. Supported + formats are: "png", "ascii". Default is "png". + + inverted - Specifies whether the block colours should be inverted or + not. Default is False. + + Returns: + + Byte representation of an identicon image. + """ + + # Calculate the digest, and get byte list. + digest_byte_list = self._data_to_digest_byte_list(data) + + # Create the matrix describing which block should be filled-in. + matrix = self._generate_matrix(digest_byte_list) + + # Determine the background and foreground colours. + if output_format == "png": + background = self.background + foreground = self.foreground[digest_byte_list[0] % len(self.foreground)] + elif output_format == "ascii": + foreground = "+" + background = "-" + + # Swtich the colours if inverted image was requested. + if inverted: + foreground, background = background, foreground + + # Generate the identicon in requested format. + if output_format == "png": + return self._generate_png(matrix, width, height, padding, foreground, background) + if output_format == "ascii": + return self._generate_ascii(matrix, foreground, background) + else: + raise ValueError("Unsupported format requested: %s" % output_format) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000000000000000000000000000000000000..982b02c2d203c1dad307876754d7e370911962a9 --- /dev/null +++ b/setup.py @@ -0,0 +1,36 @@ +import os +from setuptools import setup + +README = open(os.path.join(os.path.dirname(__file__), 'README.rst')).read() +INSTALL_REQUIREMENTS = ["Pillow"] +TEST_REQUIREMENTS = [] + +# allow setup.py to be run from any path +os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) + +setup( + name='pydenticon', + version='0.0-dev', + packages=['pydenticon'], + include_package_data=True, + license='BSD', # example license + description='Library for generating identicons. Port of Sigil (https://github.com/cupcake/sigil) with enhancements.', + long_description=README, + url='https://github.com/azaghal/pydenticon', + author='Branko Majic', + author_email='branko@majic.rs', + install_requires=INSTALL_REQUIREMENTS, + tests_require=TEST_REQUIREMENTS, + classifiers=[ + 'Environment :: Other Environment', + 'Environment :: Web Environment', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: BSD License', + 'Operating System :: OS Independent', + 'Programming Language :: Python', + 'Programming Language :: Python :: 2.7', + 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', + 'Topic :: Multimedia :: Graphics', + 'Topic :: Software Development :: Libraries', + ], +)