From e8ab1199680450718a753b0ed59c20ed35ee2c70 Mon Sep 17 00:00:00 2001 From: Arkadiy Illarionov <qarkai@gmail.com> Date: Sun, 15 May 2022 18:10:16 +0300 Subject: [PATCH] Replace ImageMagick convert with pure Python xpm2png png.py module is from PyPNG library https://github.com/drj11/pypng --- themes/Makefile.am | 5 +- themes/generator_common.py | 9 +- themes/png.py | 2356 ++++++++++++++++++++++++++++++++++++ themes/xpm2png.py | 92 ++ 4 files changed, 2456 insertions(+), 6 deletions(-) create mode 100644 themes/png.py create mode 100644 themes/xpm2png.py diff --git a/themes/Makefile.am b/themes/Makefile.am index d906af3..1e45db4 100644 --- a/themes/Makefile.am +++ b/themes/Makefile.am @@ -3,4 +3,7 @@ SUBDIRS = windowck windowck-dark -EXTRA_DIST = generator_common.py +EXTRA_DIST = \ + generator_common.py \ + png.py \ + xpm2png.py diff --git a/themes/generator_common.py b/themes/generator_common.py index fe4403b..3078294 100644 --- a/themes/generator_common.py +++ b/themes/generator_common.py @@ -20,9 +20,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. """ import shutil -import subprocess from dataclasses import dataclass from os import linesep +from pathlib import Path + +from xpm2png import xpm2png @dataclass @@ -137,10 +139,7 @@ def build_xfwm4(icons: dict, active: IconMap, inactive: IconMap): def build_unity(icons: dict): for name, icon in icons.items(): generate(name, icon) - try: - subprocess.call(["convert", f"{name}.xpm", f"{name}.png"]) - except FileNotFoundError as e: - print(f"Can't convert {name}.xpm to {name}.png: {e.strerror} '{e.filename}'" ) + xpm2png(Path(f"{name}.xpm"), Path(f"{name}.png")) for i in ("close", "maximize", "minimize", "menu", "unmaximize"): try: diff --git a/themes/png.py b/themes/png.py new file mode 100644 index 0000000..a1c689f --- /dev/null +++ b/themes/png.py @@ -0,0 +1,2356 @@ +#!/usr/bin/env python + +# png.py - PNG encoder/decoder in pure Python +# +# Copyright (C) 2006 Johann C. Rocholl <johann@browsershots.org> +# Portions Copyright (C) 2009 David Jones <drj@pobox.com> +# And probably portions Copyright (C) 2006 Nicko van Someren <nicko@nicko.org> +# +# Original concept by Johann C. Rocholl. +# +# LICENCE (MIT) +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +The ``png`` module can read and write PNG files. + +Installation and Overview +------------------------- + +``pip install pypng`` + +For help, type ``import png; help(png)`` in your python interpreter. + +A good place to start is the :class:`Reader` and :class:`Writer` classes. + +Coverage of PNG formats is fairly complete; +all allowable bit depths (1/2/4/8/16/24/32/48/64 bits per pixel) and +colour combinations are supported: + +- greyscale (1/2/4/8/16 bit); +- RGB, RGBA, LA (greyscale with alpha) with 8/16 bits per channel; +- colour mapped images (1/2/4/8 bit). + +Interlaced images, +which support a progressive display when downloading, +are supported for both reading and writing. + +A number of optional chunks can be specified (when writing) +and understood (when reading): ``tRNS``, ``bKGD``, ``gAMA``. + +The ``sBIT`` chunk can be used to specify precision for +non-native bit depths. + +Requires Python 3.5 or higher. +Installation is trivial, +but see the ``README.txt`` file (with the source distribution) for details. + +Full use of all features will need some reading of the PNG specification +http://www.w3.org/TR/2003/REC-PNG-20031110/. + +The package also comes with command line utilities. + +- ``pripamtopng`` converts + `Netpbm <http://netpbm.sourceforge.net/>`_ PAM/PNM files to PNG; +- ``pripngtopam`` converts PNG to file PAM/PNM. + +There are a few more for simple PNG manipulations. + +Spelling and Terminology +------------------------ + +Generally British English spelling is used in the documentation. +So that's "greyscale" and "colour". +This not only matches the author's native language, +it's also used by the PNG specification. + +Colour Models +------------- + +The major colour models supported by PNG (and hence by PyPNG) are: + +- greyscale; +- greyscale--alpha; +- RGB; +- RGB--alpha. + +Also referred to using the abbreviations: L, LA, RGB, RGBA. +Each letter codes a single channel: +*L* is for Luminance or Luma or Lightness (greyscale images); +*A* stands for Alpha, the opacity channel +(used for transparency effects, but higher values are more opaque, +so it makes sense to call it opacity); +*R*, *G*, *B* stand for Red, Green, Blue (colour image). + +Lists, arrays, sequences, and so on +----------------------------------- + +When getting pixel data out of this module (reading) and +presenting data to this module (writing) there are +a number of ways the data could be represented as a Python value. + +The preferred format is a sequence of *rows*, +which each row being a sequence of *values*. +In this format, the values are in pixel order, +with all the values from all the pixels in a row +being concatenated into a single sequence for that row. + +Consider an image that is 3 pixels wide by 2 pixels high, and each pixel +has RGB components: + +Sequence of rows:: + + list([R,G,B, R,G,B, R,G,B], + [R,G,B, R,G,B, R,G,B]) + +Each row appears as its own list, +but the pixels are flattened so that three values for one pixel +simply follow the three values for the previous pixel. + +This is the preferred because +it provides a good compromise between space and convenience. +PyPNG regards itself as at liberty to replace any sequence type with +any sufficiently compatible other sequence type; +in practice each row is an array (``bytearray`` or ``array.array``). + +To allow streaming the outer list is sometimes +an iterator rather than an explicit list. + +An alternative format is a single array holding all the values. + +Array of values:: + + [R,G,B, R,G,B, R,G,B, + R,G,B, R,G,B, R,G,B] + +The entire image is one single giant sequence of colour values. +Generally an array will be used (to save space), not a list. + +The top row comes first, +and within each row the pixels are ordered from left-to-right. +Within a pixel the values appear in the order R-G-B-A +(or L-A for greyscale--alpha). + +There is another format, which should only be used with caution. +It is mentioned because it is used internally, +is close to what lies inside a PNG file itself, +and has some support from the public API. +This format is called *packed*. +When packed, each row is a sequence of bytes (integers from 0 to 255), +just as it is before PNG scanline filtering is applied. +When the bit depth is 8 this is the same as a sequence of rows; +when the bit depth is less than 8 (1, 2 and 4), +several pixels are packed into each byte; +when the bit depth is 16 each pixel value is decomposed into 2 bytes +(and `packed` is a misnomer). +This format is used by the :meth:`Writer.write_packed` method. +It isn't usually a convenient format, +but may be just right if the source data for +the PNG image comes from something that uses a similar format +(for example, 1-bit BMPs, or another PNG file). +""" + +__version__ = "0.0.21" + +import collections +import io # For io.BytesIO +import itertools +import math +# http://www.python.org/doc/2.4.4/lib/module-operator.html +import operator +import re +import struct +import sys +# http://www.python.org/doc/2.4.4/lib/module-warnings.html +import warnings +import zlib + +from array import array + + +__all__ = ['Image', 'Reader', 'Writer', 'write_chunks', 'from_array'] + + +# The PNG signature. +# http://www.w3.org/TR/PNG/#5PNG-file-signature +signature = struct.pack('8B', 137, 80, 78, 71, 13, 10, 26, 10) + +# The xstart, ystart, xstep, ystep for the Adam7 interlace passes. +adam7 = ((0, 0, 8, 8), + (4, 0, 8, 8), + (0, 4, 4, 8), + (2, 0, 4, 4), + (0, 2, 2, 4), + (1, 0, 2, 2), + (0, 1, 1, 2)) + + +def adam7_generate(width, height): + """ + Generate the coordinates for the reduced scanlines + of an Adam7 interlaced image + of size `width` by `height` pixels. + + Yields a generator for each pass, + and each pass generator yields a series of (x, y, xstep) triples, + each one identifying a reduced scanline consisting of + pixels starting at (x, y) and taking every xstep pixel to the right. + """ + + for xstart, ystart, xstep, ystep in adam7: + if xstart >= width: + continue + yield ((xstart, y, xstep) for y in range(ystart, height, ystep)) + + +# Models the 'pHYs' chunk (used by the Reader) +Resolution = collections.namedtuple('_Resolution', 'x y unit_is_meter') + + +def group(s, n): + return list(zip(* [iter(s)] * n)) + + +def isarray(x): + return isinstance(x, array) + + +def check_palette(palette): + """ + Check a palette argument (to the :class:`Writer` class) for validity. + Returns the palette as a list if okay; + raises an exception otherwise. + """ + + # None is the default and is allowed. + if palette is None: + return None + + p = list(palette) + if not (0 < len(p) <= 256): + raise ProtocolError( + "a palette must have between 1 and 256 entries," + " see https://www.w3.org/TR/PNG/#11PLTE") + seen_triple = False + for i, t in enumerate(p): + if len(t) not in (3, 4): + raise ProtocolError( + "palette entry %d: entries must be 3- or 4-tuples." % i) + if len(t) == 3: + seen_triple = True + if seen_triple and len(t) == 4: + raise ProtocolError( + "palette entry %d: all 4-tuples must precede all 3-tuples" % i) + for x in t: + if int(x) != x or not(0 <= x <= 255): + raise ProtocolError( + "palette entry %d: " + "values must be integer: 0 <= x <= 255" % i) + return p + + +def check_sizes(size, width, height): + """ + Check that these arguments, if supplied, are consistent. + Return a (width, height) pair. + """ + + if not size: + return width, height + + if len(size) != 2: + raise ProtocolError( + "size argument should be a pair (width, height)") + if width is not None and width != size[0]: + raise ProtocolError( + "size[0] (%r) and width (%r) should match when both are used." + % (size[0], width)) + if height is not None and height != size[1]: + raise ProtocolError( + "size[1] (%r) and height (%r) should match when both are used." + % (size[1], height)) + return size + + +def check_color(c, greyscale, which): + """ + Checks that a colour argument for transparent or background options + is the right form. + Returns the colour + (which, if it's a bare integer, is "corrected" to a 1-tuple). + """ + + if c is None: + return c + if greyscale: + try: + len(c) + except TypeError: + c = (c,) + if len(c) != 1: + raise ProtocolError("%s for greyscale must be 1-tuple" % which) + if not is_natural(c[0]): + raise ProtocolError( + "%s colour for greyscale must be integer" % which) + else: + if not (len(c) == 3 and + is_natural(c[0]) and + is_natural(c[1]) and + is_natural(c[2])): + raise ProtocolError( + "%s colour must be a triple of integers" % which) + return c + + +class Error(Exception): + def __str__(self): + return self.__class__.__name__ + ': ' + ' '.join(self.args) + + +class FormatError(Error): + """ + Problem with input file format. + In other words, PNG file does not conform to + the specification in some way and is invalid. + """ + + +class ProtocolError(Error): + """ + Problem with the way the programming interface has been used, + or the data presented to it. + """ + + +class ChunkError(FormatError): + pass + + +class Default: + """The default for the greyscale parameter.""" + + +class Writer: + """ + PNG encoder in pure Python. + """ + + def __init__(self, width=None, height=None, + size=None, + greyscale=Default, + alpha=False, + bitdepth=8, + palette=None, + transparent=None, + background=None, + gamma=None, + compression=None, + interlace=False, + planes=None, + colormap=None, + maxval=None, + chunk_limit=2**20, + x_pixels_per_unit=None, + y_pixels_per_unit=None, + unit_is_meter=False): + """ + Create a PNG encoder object. + + Arguments: + + width, height + Image size in pixels, as two separate arguments. + size + Image size (w,h) in pixels, as single argument. + greyscale + Pixels are greyscale, not RGB. + alpha + Input data has alpha channel (RGBA or LA). + bitdepth + Bit depth: from 1 to 16 (for each channel). + palette + Create a palette for a colour mapped image (colour type 3). + transparent + Specify a transparent colour (create a ``tRNS`` chunk). + background + Specify a default background colour (create a ``bKGD`` chunk). + gamma + Specify a gamma value (create a ``gAMA`` chunk). + compression + zlib compression level: 0 (none) to 9 (more compressed); + default: -1 or None. + interlace + Create an interlaced image. + chunk_limit + Write multiple ``IDAT`` chunks to save memory. + x_pixels_per_unit + Number of pixels a unit along the x axis (write a + `pHYs` chunk). + y_pixels_per_unit + Number of pixels a unit along the y axis (write a + `pHYs` chunk). Along with `x_pixel_unit`, this gives + the pixel size ratio. + unit_is_meter + `True` to indicate that the unit (for the `pHYs` + chunk) is metre. + + The image size (in pixels) can be specified either by using the + `width` and `height` arguments, or with the single `size` + argument. + If `size` is used it should be a pair (*width*, *height*). + + The `greyscale` argument indicates whether input pixels + are greyscale (when true), or colour (when false). + The default is true unless `palette=` is used. + + The `alpha` argument (a boolean) specifies + whether input pixels have an alpha channel (or not). + + `bitdepth` specifies the bit depth of the source pixel values. + Each channel may have a different bit depth. + Each source pixel must have values that are + an integer between 0 and ``2**bitdepth-1``, where + `bitdepth` is the bit depth for the corresponding channel. + For example, 8-bit images have values between 0 and 255. + PNG only stores images with bit depths of + 1,2,4,8, or 16 (the same for all channels). + When `bitdepth` is not one of these values or where + channels have different bit depths, + the next highest valid bit depth is selected, + and an ``sBIT`` (significant bits) chunk is generated + that specifies the original precision of the source image. + In this case the supplied pixel values will be rescaled to + fit the range of the selected bit depth. + + The PNG file format supports many bit depth / colour model + combinations, but not all. + The details are somewhat arcane + (refer to the PNG specification for full details). + Briefly: + Bit depths < 8 (1,2,4) are only allowed with greyscale and + colour mapped images; + colour mapped images cannot have bit depth 16. + + For colour mapped images + (in other words, when the `palette` argument is specified) + the `bitdepth` argument must match one of + the valid PNG bit depths: 1, 2, 4, or 8. + (It is valid to have a PNG image with a palette and + an ``sBIT`` chunk, but the meaning is slightly different; + it would be awkward to use the `bitdepth` argument for this.) + + The `palette` option, when specified, + causes a colour mapped image to be created: + the PNG colour type is set to 3; + `greyscale` must not be true; `alpha` must not be true; + `transparent` must not be set. + The bit depth must be 1,2,4, or 8. + When a colour mapped image is created, + the pixel values are palette indexes and + the `bitdepth` argument specifies the size of these indexes + (not the size of the colour values in the palette). + + The palette argument value should be a sequence of 3- or + 4-tuples. + 3-tuples specify RGB palette entries; + 4-tuples specify RGBA palette entries. + All the 4-tuples (if present) must come before all the 3-tuples. + A ``PLTE`` chunk is created; + if there are 4-tuples then a ``tRNS`` chunk is created as well. + The ``PLTE`` chunk will contain all the RGB triples in the same + sequence; + the ``tRNS`` chunk will contain the alpha channel for + all the 4-tuples, in the same sequence. + Palette entries are always 8-bit. + + If specified, the `transparent` and `background` parameters must be + a tuple with one element for each channel in the image. + Either a 3-tuple of integer (RGB) values for a colour image, or + a 1-tuple of a single integer for a greyscale image. + + If specified, the `gamma` parameter must be a positive number + (generally, a `float`). + A ``gAMA`` chunk will be created. + Note that this will not change the values of the pixels as + they appear in the PNG file, + they are assumed to have already + been converted appropriately for the gamma specified. + + The `compression` argument specifies the compression level to + be used by the ``zlib`` module. + Values from 1 to 9 (highest) specify compression. + 0 means no compression. + -1 and ``None`` both mean that the ``zlib`` module uses + the default level of compression (which is generally acceptable). + + If `interlace` is true then an interlaced image is created + (using PNG's so far only interlace method, *Adam7*). + This does not affect how the pixels should be passed in, + rather it changes how they are arranged into the PNG file. + On slow connexions interlaced images can be + partially decoded by the browser to give + a rough view of the image that is + successively refined as more image data appears. + + .. note :: + + Enabling the `interlace` option requires the entire image + to be processed in working memory. + + `chunk_limit` is used to limit the amount of memory used whilst + compressing the image. + In order to avoid using large amounts of memory, + multiple ``IDAT`` chunks may be created. + """ + + # At the moment the `planes` argument is ignored; + # its purpose is to act as a dummy so that + # ``Writer(x, y, **info)`` works, where `info` is a dictionary + # returned by Reader.read and friends. + # Ditto for `colormap`. + + width, height = check_sizes(size, width, height) + del size + + if not is_natural(width) or not is_natural(height): + raise ProtocolError("width and height must be integers") + if width <= 0 or height <= 0: + raise ProtocolError("width and height must be greater than zero") + # http://www.w3.org/TR/PNG/#7Integers-and-byte-order + if width > 2 ** 31 - 1 or height > 2 ** 31 - 1: + raise ProtocolError("width and height cannot exceed 2**31-1") + + if alpha and transparent is not None: + raise ProtocolError( + "transparent colour not allowed with alpha channel") + + # bitdepth is either single integer, or tuple of integers. + # Convert to tuple. + try: + len(bitdepth) + except TypeError: + bitdepth = (bitdepth, ) + for b in bitdepth: + valid = is_natural(b) and 1 <= b <= 16 + if not valid: + raise ProtocolError( + "each bitdepth %r must be a positive integer <= 16" % + (bitdepth,)) + + # Calculate channels, and + # expand bitdepth to be one element per channel. + palette = check_palette(palette) + alpha = bool(alpha) + colormap = bool(palette) + if greyscale is Default and palette: + greyscale = False + greyscale = bool(greyscale) + if colormap: + color_planes = 1 + planes = 1 + else: + color_planes = (3, 1)[greyscale] + planes = color_planes + alpha + if len(bitdepth) == 1: + bitdepth *= planes + + bitdepth, self.rescale = check_bitdepth_rescale( + palette, + bitdepth, + transparent, alpha, greyscale) + + # These are assertions, because above logic should have + # corrected or raised all problematic cases. + if bitdepth < 8: + assert greyscale or palette + assert not alpha + if bitdepth > 8: + assert not palette + + transparent = check_color(transparent, greyscale, 'transparent') + background = check_color(background, greyscale, 'background') + + # It's important that the true boolean values + # (greyscale, alpha, colormap, interlace) are converted + # to bool because Iverson's convention is relied upon later on. + self.width = width + self.height = height + self.transparent = transparent + self.background = background + self.gamma = gamma + self.greyscale = greyscale + self.alpha = alpha + self.colormap = colormap + self.bitdepth = int(bitdepth) + self.compression = compression + self.chunk_limit = chunk_limit + self.interlace = bool(interlace) + self.palette = palette + self.x_pixels_per_unit = x_pixels_per_unit + self.y_pixels_per_unit = y_pixels_per_unit + self.unit_is_meter = bool(unit_is_meter) + + self.color_type = (4 * self.alpha + + 2 * (not greyscale) + + 1 * self.colormap) + assert self.color_type in (0, 2, 3, 4, 6) + + self.color_planes = color_planes + self.planes = planes + # :todo: fix for bitdepth < 8 + self.psize = (self.bitdepth / 8) * self.planes + + def write(self, outfile, rows): + """ + Write a PNG image to the output file. + `rows` should be an iterable that yields each row + (each row is a sequence of values). + The rows should be the rows of the original image, + so there should be ``self.height`` rows of + ``self.width * self.planes`` values. + If `interlace` is specified (when creating the instance), + then an interlaced PNG file will be written. + Supply the rows in the normal image order; + the interlacing is carried out internally. + + .. note :: + + Interlacing requires the entire image to be in working memory. + """ + + # Values per row + vpr = self.width * self.planes + + def check_rows(rows): + """ + Yield each row in rows, + but check each row first (for correct width). + """ + for i, row in enumerate(rows): + try: + wrong_length = len(row) != vpr + except TypeError: + # When using an itertools.ichain object or + # other generator not supporting __len__, + # we set this to False to skip the check. + wrong_length = False + if wrong_length: + # Note: row numbers start at 0. + raise ProtocolError( + "Expected %d values but got %d values, in row %d" % + (vpr, len(row), i)) + yield row + + if self.interlace: + fmt = 'BH'[self.bitdepth > 8] + a = array(fmt, itertools.chain(*check_rows(rows))) + return self.write_array(outfile, a) + + nrows = self.write_passes(outfile, check_rows(rows)) + if nrows != self.height: + raise ProtocolError( + "rows supplied (%d) does not match height (%d)" % + (nrows, self.height)) + return nrows + + def write_passes(self, outfile, rows): + """ + Write a PNG image to the output file. + + Most users are expected to find the :meth:`write` or + :meth:`write_array` method more convenient. + + The rows should be given to this method in the order that + they appear in the output file. + For straightlaced images, this is the usual top to bottom ordering. + For interlaced images the rows should have been interlaced before + passing them to this function. + + `rows` should be an iterable that yields each row + (each row being a sequence of values). + """ + + # Ensure rows are scaled (to 4-/8-/16-bit), + # and packed into bytes. + + if self.rescale: + rows = rescale_rows(rows, self.rescale) + + if self.bitdepth < 8: + rows = pack_rows(rows, self.bitdepth) + elif self.bitdepth == 16: + rows = unpack_rows(rows) + + return self.write_packed(outfile, rows) + + def write_packed(self, outfile, rows): + """ + Write PNG file to `outfile`. + `rows` should be an iterator that yields each packed row; + a packed row being a sequence of packed bytes. + + The rows have a filter byte prefixed and + are then compressed into one or more IDAT chunks. + They are not processed any further, + so if bitdepth is other than 1, 2, 4, 8, 16, + the pixel values should have been scaled + before passing them to this method. + + This method does work for interlaced images but it is best avoided. + For interlaced images, the rows should be + presented in the order that they appear in the file. + """ + + self.write_preamble(outfile) + + # http://www.w3.org/TR/PNG/#11IDAT + if self.compression is not None: + compressor = zlib.compressobj(self.compression) + else: + compressor = zlib.compressobj() + + # data accumulates bytes to be compressed for the IDAT chunk; + # it's compressed when sufficiently large. + data = bytearray() + + # raise i scope out of the for loop. set to -1, because the for loop + # sets i to 0 on the first pass + i = -1 + for i, row in enumerate(rows): + # Add "None" filter type. + # Currently, it's essential that this filter type be used + # for every scanline as + # we do not mark the first row of a reduced pass image; + # that means we could accidentally compute + # the wrong filtered scanline if we used + # "up", "average", or "paeth" on such a line. + data.append(0) + data.extend(row) + if len(data) > self.chunk_limit: + compressed = compressor.compress(data) + if len(compressed): + write_chunk(outfile, b'IDAT', compressed) + data = bytearray() + + compressed = compressor.compress(bytes(data)) + flushed = compressor.flush() + if len(compressed) or len(flushed): + write_chunk(outfile, b'IDAT', compressed + flushed) + # http://www.w3.org/TR/PNG/#11IEND + write_chunk(outfile, b'IEND') + return i + 1 + + def write_preamble(self, outfile): + # http://www.w3.org/TR/PNG/#5PNG-file-signature + outfile.write(signature) + + # http://www.w3.org/TR/PNG/#11IHDR + write_chunk(outfile, b'IHDR', + struct.pack("!2I5B", self.width, self.height, + self.bitdepth, self.color_type, + 0, 0, self.interlace)) + + # See :chunk:order + # http://www.w3.org/TR/PNG/#11gAMA + if self.gamma is not None: + write_chunk(outfile, b'gAMA', + struct.pack("!L", int(round(self.gamma * 1e5)))) + + # See :chunk:order + # http://www.w3.org/TR/PNG/#11sBIT + if self.rescale: + write_chunk( + outfile, b'sBIT', + struct.pack('%dB' % self.planes, + * [s[0] for s in self.rescale])) + + # :chunk:order: Without a palette (PLTE chunk), + # ordering is relatively relaxed. + # With one, gAMA chunk must precede PLTE chunk + # which must precede tRNS and bKGD. + # See http://www.w3.org/TR/PNG/#5ChunkOrdering + if self.palette: + p, t = make_palette_chunks(self.palette) + write_chunk(outfile, b'PLTE', p) + if t: + # tRNS chunk is optional; + # Only needed if palette entries have alpha. + write_chunk(outfile, b'tRNS', t) + + # http://www.w3.org/TR/PNG/#11tRNS + if self.transparent is not None: + if self.greyscale: + fmt = "!1H" + else: + fmt = "!3H" + write_chunk(outfile, b'tRNS', + struct.pack(fmt, *self.transparent)) + + # http://www.w3.org/TR/PNG/#11bKGD + if self.background is not None: + if self.greyscale: + fmt = "!1H" + else: + fmt = "!3H" + write_chunk(outfile, b'bKGD', + struct.pack(fmt, *self.background)) + + # http://www.w3.org/TR/PNG/#11pHYs + if (self.x_pixels_per_unit is not None and + self.y_pixels_per_unit is not None): + tup = (self.x_pixels_per_unit, + self.y_pixels_per_unit, + int(self.unit_is_meter)) + write_chunk(outfile, b'pHYs', struct.pack("!LLB", *tup)) + + def write_array(self, outfile, pixels): + """ + Write an array that holds all the image values + as a PNG file on the output file. + See also :meth:`write` method. + """ + + if self.interlace: + if type(pixels) != array: + # Coerce to array type + fmt = 'BH'[self.bitdepth > 8] + pixels = array(fmt, pixels) + return self.write_passes( + outfile, + self.array_scanlines_interlace(pixels) + ) + else: + return self.write_passes( + outfile, + self.array_scanlines(pixels) + ) + + def array_scanlines(self, pixels): + """ + Generates rows (each a sequence of values) from + a single array of values. + """ + + # Values per row + vpr = self.width * self.planes + stop = 0 + for y in range(self.height): + start = stop + stop = start + vpr + yield pixels[start:stop] + + def array_scanlines_interlace(self, pixels): + """ + Generator for interlaced scanlines from an array. + `pixels` is the full source image as a single array of values. + The generator yields each scanline of the reduced passes in turn, + each scanline being a sequence of values. + """ + + # http://www.w3.org/TR/PNG/#8InterlaceMethods + # Array type. + fmt = 'BH'[self.bitdepth > 8] + # Value per row + vpr = self.width * self.planes + + # Each iteration generates a scanline starting at (x, y) + # and consisting of every xstep pixels. + for lines in adam7_generate(self.width, self.height): + for x, y, xstep in lines: + # Pixels per row (of reduced image) + ppr = int(math.ceil((self.width - x) / float(xstep))) + # Values per row (of reduced image) + reduced_row_len = ppr * self.planes + if xstep == 1: + # Easy case: line is a simple slice. + offset = y * vpr + yield pixels[offset: offset + vpr] + continue + # We have to step by xstep, + # which we can do one plane at a time + # using the step in Python slices. + row = array(fmt) + # There's no easier way to set the length of an array + row.extend(pixels[0:reduced_row_len]) + offset = y * vpr + x * self.planes + end_offset = (y + 1) * vpr + skip = self.planes * xstep + for i in range(self.planes): + row[i::self.planes] = \ + pixels[offset + i: end_offset: skip] + yield row + + +def write_chunk(outfile, tag, data=b''): + """ + Write a PNG chunk to the output file, including length and + checksum. + """ + + data = bytes(data) + # http://www.w3.org/TR/PNG/#5Chunk-layout + outfile.write(struct.pack("!I", len(data))) + outfile.write(tag) + outfile.write(data) + checksum = zlib.crc32(tag) + checksum = zlib.crc32(data, checksum) + checksum &= 2 ** 32 - 1 + outfile.write(struct.pack("!I", checksum)) + + +def write_chunks(out, chunks): + """Create a PNG file by writing out the chunks.""" + + out.write(signature) + for chunk in chunks: + write_chunk(out, *chunk) + + +def rescale_rows(rows, rescale): + """ + Take each row in rows (an iterator) and yield + a fresh row with the pixels scaled according to + the rescale parameters in the list `rescale`. + Each element of `rescale` is a tuple of + (source_bitdepth, target_bitdepth), + with one element per channel. + """ + + # One factor for each channel + fs = [float(2 ** s[1] - 1)/float(2 ** s[0] - 1) + for s in rescale] + + # Assume all target_bitdepths are the same + target_bitdepths = set(s[1] for s in rescale) + assert len(target_bitdepths) == 1 + (target_bitdepth, ) = target_bitdepths + typecode = 'BH'[target_bitdepth > 8] + + # Number of channels + n_chans = len(rescale) + + for row in rows: + rescaled_row = array(typecode, iter(row)) + for i in range(n_chans): + channel = array( + typecode, + (int(round(fs[i] * x)) for x in row[i::n_chans])) + rescaled_row[i::n_chans] = channel + yield rescaled_row + + +def pack_rows(rows, bitdepth): + """Yield packed rows that are a byte array. + Each byte is packed with the values from several pixels. + """ + + assert bitdepth < 8 + assert 8 % bitdepth == 0 + + # samples per byte + spb = int(8 / bitdepth) + + def make_byte(block): + """Take a block of (2, 4, or 8) values, + and pack them into a single byte. + """ + + res = 0 + for v in block: + res = (res << bitdepth) + v + return res + + for row in rows: + a = bytearray(row) + # Adding padding bytes so we can group into a whole + # number of spb-tuples. + n = float(len(a)) + extra = math.ceil(n / spb) * spb - n + a.extend([0] * int(extra)) + # Pack into bytes. + # Each block is the samples for one byte. + blocks = group(a, spb) + yield bytearray(make_byte(block) for block in blocks) + + +def unpack_rows(rows): + """Unpack each row from being 16-bits per value, + to being a sequence of bytes. + """ + for row in rows: + fmt = '!%dH' % len(row) + yield bytearray(struct.pack(fmt, *row)) + + +def make_palette_chunks(palette): + """ + Create the byte sequences for a ``PLTE`` and + if necessary a ``tRNS`` chunk. + Returned as a pair (*p*, *t*). + *t* will be ``None`` if no ``tRNS`` chunk is necessary. + """ + + p = bytearray() + t = bytearray() + + for x in palette: + p.extend(x[0:3]) + if len(x) > 3: + t.append(x[3]) + if t: + return p, t + return p, None + + +def check_bitdepth_rescale( + palette, bitdepth, transparent, alpha, greyscale): + """ + Returns (bitdepth, rescale) pair. + """ + + if palette: + if len(bitdepth) != 1: + raise ProtocolError( + "with palette, only a single bitdepth may be used") + (bitdepth, ) = bitdepth + if bitdepth not in (1, 2, 4, 8): + raise ProtocolError( + "with palette, bitdepth must be 1, 2, 4, or 8") + if transparent is not None: + raise ProtocolError("transparent and palette not compatible") + if alpha: + raise ProtocolError("alpha and palette not compatible") + if greyscale: + raise ProtocolError("greyscale and palette not compatible") + return bitdepth, None + + # No palette, check for sBIT chunk generation. + + if greyscale and not alpha: + # Single channel, L. + (bitdepth,) = bitdepth + if bitdepth in (1, 2, 4, 8, 16): + return bitdepth, None + if bitdepth > 8: + targetbitdepth = 16 + elif bitdepth == 3: + targetbitdepth = 4 + else: + assert bitdepth in (5, 6, 7) + targetbitdepth = 8 + return targetbitdepth, [(bitdepth, targetbitdepth)] + + assert alpha or not greyscale + + depth_set = tuple(set(bitdepth)) + if depth_set in [(8,), (16,)]: + # No sBIT required. + (bitdepth, ) = depth_set + return bitdepth, None + + targetbitdepth = (8, 16)[max(bitdepth) > 8] + return targetbitdepth, [(b, targetbitdepth) for b in bitdepth] + + +# Regex for decoding mode string +RegexModeDecode = re.compile("(LA?|RGBA?);?([0-9]*)", flags=re.IGNORECASE) + + +def from_array(a, mode=None, info={}): + """ + Create a PNG :class:`Image` object from a 2-dimensional array. + One application of this function is easy PIL-style saving: + ``png.from_array(pixels, 'L').save('foo.png')``. + + Unless they are specified using the *info* parameter, + the PNG's height and width are taken from the array size. + The first axis is the height; the second axis is the + ravelled width and channel index. + The array is treated is a sequence of rows, + each row being a sequence of values (``width*channels`` in number). + So an RGB image that is 16 pixels high and 8 wide will + occupy a 2-dimensional array that is 16x24 + (each row will be 8*3 = 24 sample values). + + *mode* is a string that specifies the image colour format in a + PIL-style mode. It can be: + + ``'L'`` + greyscale (1 channel) + ``'LA'`` + greyscale with alpha (2 channel) + ``'RGB'`` + colour image (3 channel) + ``'RGBA'`` + colour image with alpha (4 channel) + + The mode string can also specify the bit depth + (overriding how this function normally derives the bit depth, + see below). + Appending ``';16'`` to the mode will cause the PNG to be + 16 bits per channel; + any decimal from 1 to 16 can be used to specify the bit depth. + + When a 2-dimensional array is used *mode* determines how many + channels the image has, and so allows the width to be derived from + the second array dimension. + + The array is expected to be a ``numpy`` array, + but it can be any suitable Python sequence. + For example, a list of lists can be used: + ``png.from_array([[0, 255, 0], [255, 0, 255]], 'L')``. + The exact rules are: ``len(a)`` gives the first dimension, height; + ``len(a[0])`` gives the second dimension. + It's slightly more complicated than that because + an iterator of rows can be used, and it all still works. + Using an iterator allows data to be streamed efficiently. + + The bit depth of the PNG is normally taken from + the array element's datatype + (but if *mode* specifies a bitdepth then that is used instead). + The array element's datatype is determined in a way which + is supposed to work both for ``numpy`` arrays and for Python + ``array.array`` objects. + A 1 byte datatype will give a bit depth of 8, + a 2 byte datatype will give a bit depth of 16. + If the datatype does not have an implicit size, + like the above example where it is a plain Python list of lists, + then a default of 8 is used. + + The *info* parameter is a dictionary that can + be used to specify metadata (in the same style as + the arguments to the :class:`png.Writer` class). + For this function the keys that are useful are: + + height + overrides the height derived from the array dimensions and + allows *a* to be an iterable. + width + overrides the width derived from the array dimensions. + bitdepth + overrides the bit depth derived from the element datatype + (but must match *mode* if that also specifies a bit depth). + + Generally anything specified in the *info* dictionary will + override any implicit choices that this function would otherwise make, + but must match any explicit ones. + For example, if the *info* dictionary has a ``greyscale`` key then + this must be true when mode is ``'L'`` or ``'LA'`` and + false when mode is ``'RGB'`` or ``'RGBA'``. + """ + + # We abuse the *info* parameter by modifying it. Take a copy here. + # (Also typechecks *info* to some extent). + info = dict(info) + + # Syntax check mode string. + match = RegexModeDecode.match(mode) + if not match: + raise Error("mode string should be 'RGB' or 'L;16' or similar.") + + mode, bitdepth = match.groups() + if bitdepth: + bitdepth = int(bitdepth) + + # Colour format. + if 'greyscale' in info: + if bool(info['greyscale']) != ('L' in mode): + raise ProtocolError("info['greyscale'] should match mode.") + info['greyscale'] = 'L' in mode + + alpha = 'A' in mode + if 'alpha' in info: + if bool(info['alpha']) != alpha: + raise ProtocolError("info['alpha'] should match mode.") + info['alpha'] = alpha + + # Get bitdepth from *mode* if possible. + if bitdepth: + if info.get("bitdepth") and bitdepth != info['bitdepth']: + raise ProtocolError( + "bitdepth (%d) should match bitdepth of info (%d)." % + (bitdepth, info['bitdepth'])) + info['bitdepth'] = bitdepth + + # Fill in and/or check entries in *info*. + # Dimensions. + width, height = check_sizes( + info.get("size"), + info.get("width"), + info.get("height")) + if width: + info["width"] = width + if height: + info["height"] = height + + if "height" not in info: + try: + info['height'] = len(a) + except TypeError: + raise ProtocolError( + "len(a) does not work, supply info['height'] instead.") + + planes = len(mode) + if 'planes' in info: + if info['planes'] != planes: + raise Error("info['planes'] should match mode.") + + # In order to work out whether we the array is 2D or 3D we need its + # first row, which requires that we take a copy of its iterator. + # We may also need the first row to derive width and bitdepth. + a, t = itertools.tee(a) + row = next(t) + del t + + testelement = row + if 'width' not in info: + width = len(row) // planes + info['width'] = width + + if 'bitdepth' not in info: + try: + dtype = testelement.dtype + # goto the "else:" clause. Sorry. + except AttributeError: + try: + # Try a Python array.array. + bitdepth = 8 * testelement.itemsize + except AttributeError: + # We can't determine it from the array element's datatype, + # use a default of 8. + bitdepth = 8 + else: + # If we got here without exception, + # we now assume that the array is a numpy array. + if dtype.kind == 'b': + bitdepth = 1 + else: + bitdepth = 8 * dtype.itemsize + info['bitdepth'] = bitdepth + + for thing in ["width", "height", "bitdepth", "greyscale", "alpha"]: + assert thing in info + + return Image(a, info) + + +# So that refugee's from PIL feel more at home. Not documented. +fromarray = from_array + + +class Image: + """A PNG image. You can create an :class:`Image` object from + an array of pixels by calling :meth:`png.from_array`. It can be + saved to disk with the :meth:`save` method. + """ + + def __init__(self, rows, info): + """ + .. note :: + + The constructor is not public. Please do not call it. + """ + + self.rows = rows + self.info = info + + def save(self, file): + """Save the image to the named *file*. + + See `.write()` if you already have an open file object. + + In general, you can only call this method once; + after it has been called the first time the PNG image is written, + the source data will have been streamed, and + cannot be streamed again. + """ + + w = Writer(**self.info) + + with open(file, 'wb') as fd: + w.write(fd, self.rows) + + def write(self, file): + """Write the image to the open file object. + + See `.save()` if you have a filename. + + In general, you can only call this method once; + after it has been called the first time the PNG image is written, + the source data will have been streamed, and + cannot be streamed again. + """ + + w = Writer(**self.info) + w.write(file, self.rows) + + +class Reader: + """ + Pure Python PNG decoder in pure Python. + """ + + def __init__(self, _guess=None, filename=None, file=None, bytes=None): + """ + The constructor expects exactly one keyword argument. + If you supply a positional argument instead, + it will guess the input type. + Choose from the following keyword arguments: + + filename + Name of input file (a PNG file). + file + A file-like object (object with a read() method). + bytes + ``bytes`` or ``bytearray`` with PNG data. + + """ + keywords_supplied = ( + (_guess is not None) + + (filename is not None) + + (file is not None) + + (bytes is not None)) + if keywords_supplied != 1: + raise TypeError("Reader() takes exactly 1 argument") + + # Will be the first 8 bytes, later on. See validate_signature. + self.signature = None + self.transparent = None + # A pair of (len,type) if a chunk has been read but its data and + # checksum have not (in other words the file position is just + # past the 4 bytes that specify the chunk type). + # See preamble method for how this is used. + self.atchunk = None + + if _guess is not None: + if isarray(_guess): + bytes = _guess + elif isinstance(_guess, str): + filename = _guess + elif hasattr(_guess, 'read'): + file = _guess + + if bytes is not None: + self.file = io.BytesIO(bytes) + elif filename is not None: + self.file = open(filename, "rb") + elif file is not None: + self.file = file + else: + raise ProtocolError("expecting filename, file or bytes array") + + def chunk(self, lenient=False): + """ + Read the next PNG chunk from the input file; + returns a (*type*, *data*) tuple. + *type* is the chunk's type as a byte string + (all PNG chunk types are 4 bytes long). + *data* is the chunk's data content, as a byte string. + + If the optional `lenient` argument evaluates to `True`, + checksum failures will raise warnings rather than exceptions. + """ + + self.validate_signature() + + # http://www.w3.org/TR/PNG/#5Chunk-layout + if not self.atchunk: + self.atchunk = self._chunk_len_type() + if not self.atchunk: + raise ChunkError("No more chunks.") + length, type = self.atchunk + self.atchunk = None + + data = self.file.read(length) + if len(data) != length: + raise ChunkError( + 'Chunk %s too short for required %i octets.' + % (type, length)) + checksum = self.file.read(4) + if len(checksum) != 4: + raise ChunkError('Chunk %s too short for checksum.' % type) + verify = zlib.crc32(type) + verify = zlib.crc32(data, verify) + verify = struct.pack('!I', verify) + if checksum != verify: + (a, ) = struct.unpack('!I', checksum) + (b, ) = struct.unpack('!I', verify) + message = ("Checksum error in %s chunk: 0x%08X != 0x%08X." + % (type.decode('ascii'), a, b)) + if lenient: + warnings.warn(message, RuntimeWarning) + else: + raise ChunkError(message) + return type, data + + def chunks(self): + """Return an iterator that will yield each chunk as a + (*chunktype*, *content*) pair. + """ + + while True: + t, v = self.chunk() + yield t, v + if t == b'IEND': + break + + def undo_filter(self, filter_type, scanline, previous): + """ + Undo the filter for a scanline. + `scanline` is a sequence of bytes that + does not include the initial filter type byte. + `previous` is decoded previous scanline + (for straightlaced images this is the previous pixel row, + but for interlaced images, it is + the previous scanline in the reduced image, + which in general is not the previous pixel row in the final image). + When there is no previous scanline + (the first row of a straightlaced image, + or the first row in one of the passes in an interlaced image), + then this argument should be ``None``. + + The scanline will have the effects of filtering removed; + the result will be returned as a fresh sequence of bytes. + """ + + # :todo: Would it be better to update scanline in place? + result = scanline + + if filter_type == 0: + return result + + if filter_type not in (1, 2, 3, 4): + raise FormatError( + 'Invalid PNG Filter Type. ' + 'See http://www.w3.org/TR/2003/REC-PNG-20031110/#9Filters .') + + # Filter unit. The stride from one pixel to the corresponding + # byte from the previous pixel. Normally this is the pixel + # size in bytes, but when this is smaller than 1, the previous + # byte is used instead. + fu = max(1, self.psize) + + # For the first line of a pass, synthesize a dummy previous + # line. An alternative approach would be to observe that on the + # first line 'up' is the same as 'null', 'paeth' is the same + # as 'sub', with only 'average' requiring any special case. + if not previous: + previous = bytearray([0] * len(scanline)) + + # Call appropriate filter algorithm. Note that 0 has already + # been dealt with. + fn = (None, + undo_filter_sub, + undo_filter_up, + undo_filter_average, + undo_filter_paeth)[filter_type] + fn(fu, scanline, previous, result) + return result + + def _deinterlace(self, raw): + """ + Read raw pixel data, undo filters, deinterlace, and flatten. + Return a single array of values. + """ + + # Values per row (of the target image) + vpr = self.width * self.planes + + # Values per image + vpi = vpr * self.height + # Interleaving writes to the output array randomly + # (well, not quite), so the entire output array must be in memory. + # Make a result array, and make it big enough. + if self.bitdepth > 8: + a = array('H', [0] * vpi) + else: + a = bytearray([0] * vpi) + source_offset = 0 + + for lines in adam7_generate(self.width, self.height): + # The previous (reconstructed) scanline. + # `None` at the beginning of a pass + # to indicate that there is no previous line. + recon = None + for x, y, xstep in lines: + # Pixels per row (reduced pass image) + ppr = int(math.ceil((self.width - x) / float(xstep))) + # Row size in bytes for this pass. + row_size = int(math.ceil(self.psize * ppr)) + + filter_type = raw[source_offset] + source_offset += 1 + scanline = raw[source_offset: source_offset + row_size] + source_offset += row_size + recon = self.undo_filter(filter_type, scanline, recon) + # Convert so that there is one element per pixel value + flat = self._bytes_to_values(recon, width=ppr) + if xstep == 1: + assert x == 0 + offset = y * vpr + a[offset: offset + vpr] = flat + else: + offset = y * vpr + x * self.planes + end_offset = (y + 1) * vpr + skip = self.planes * xstep + for i in range(self.planes): + a[offset + i: end_offset: skip] = \ + flat[i:: self.planes] + + return a + + def _iter_bytes_to_values(self, byte_rows): + """ + Iterator that yields each scanline; + each scanline being a sequence of values. + `byte_rows` should be an iterator that yields + the bytes of each row in turn. + """ + + for row in byte_rows: + yield self._bytes_to_values(row) + + def _bytes_to_values(self, bs, width=None): + """Convert a packed row of bytes into a row of values. + Result will be a freshly allocated object, + not shared with the argument. + """ + + if self.bitdepth == 8: + return bytearray(bs) + if self.bitdepth == 16: + return array('H', + struct.unpack('!%dH' % (len(bs) // 2), bs)) + + assert self.bitdepth < 8 + if width is None: + width = self.width + # Samples per byte + spb = 8 // self.bitdepth + out = bytearray() + mask = 2**self.bitdepth - 1 + shifts = [self.bitdepth * i + for i in reversed(list(range(spb)))] + for o in bs: + out.extend([mask & (o >> i) for i in shifts]) + return out[:width] + + def _iter_straight_packed(self, byte_blocks): + """Iterator that undoes the effect of filtering; + yields each row as a sequence of packed bytes. + Assumes input is straightlaced. + `byte_blocks` should be an iterable that yields the raw bytes + in blocks of arbitrary size. + """ + + # length of row, in bytes + rb = self.row_bytes + a = bytearray() + # The previous (reconstructed) scanline. + # None indicates first line of image. + recon = None + for some_bytes in byte_blocks: + a.extend(some_bytes) + while len(a) >= rb + 1: + filter_type = a[0] + scanline = a[1: rb + 1] + del a[: rb + 1] + recon = self.undo_filter(filter_type, scanline, recon) + yield recon + if len(a) != 0: + # :file:format We get here with a file format error: + # when the available bytes (after decompressing) do not + # pack into exact rows. + raise FormatError('Wrong size for decompressed IDAT chunk.') + assert len(a) == 0 + + def validate_signature(self): + """ + If signature (header) has not been read then read and + validate it; otherwise do nothing. + No signature (empty read()) will raise EOFError; + An invalid signature will raise FormatError. + EOFError is raised to make possible the case where + a program can read multiple PNG files from the same stream. + The end of the stream can be distinguished from non-PNG files + or corrupted PNG files. + """ + + if self.signature: + return + self.signature = self.file.read(8) + if len(self.signature) == 0: + raise EOFError("End of PNG stream.") + if self.signature != signature: + raise FormatError("PNG file has invalid signature.") + + def preamble(self, lenient=False): + """ + Extract the image metadata by reading + the initial part of the PNG file up to + the start of the ``IDAT`` chunk. + All the chunks that precede the ``IDAT`` chunk are + read and either processed for metadata or discarded. + + If the optional `lenient` argument evaluates to `True`, + checksum failures will raise warnings rather than exceptions. + """ + + self.validate_signature() + + while True: + if not self.atchunk: + self.atchunk = self._chunk_len_type() + if self.atchunk is None: + raise FormatError('This PNG file has no IDAT chunks.') + if self.atchunk[1] == b'IDAT': + return + self.process_chunk(lenient=lenient) + + def _chunk_len_type(self): + """ + Reads just enough of the input to + determine the next chunk's length and type; + return a (*length*, *type*) pair where *type* is a byte sequence. + If there are no more chunks, ``None`` is returned. + """ + + x = self.file.read(8) + if not x: + return None + if len(x) != 8: + raise FormatError( + 'End of file whilst reading chunk length and type.') + length, type = struct.unpack('!I4s', x) + if length > 2 ** 31 - 1: + raise FormatError('Chunk %s is too large: %d.' % (type, length)) + # Check that all bytes are in valid ASCII range. + # https://www.w3.org/TR/2003/REC-PNG-20031110/#5Chunk-layout + type_bytes = set(bytearray(type)) + if not(type_bytes <= set(range(65, 91)) | set(range(97, 123))): + raise FormatError( + 'Chunk %r has invalid Chunk Type.' + % list(type)) + return length, type + + def process_chunk(self, lenient=False): + """ + Process the next chunk and its data. + This only processes the following chunk types: + ``IHDR``, ``PLTE``, ``bKGD``, ``tRNS``, ``gAMA``, ``sBIT``, ``pHYs``. + All other chunk types are ignored. + + If the optional `lenient` argument evaluates to `True`, + checksum failures will raise warnings rather than exceptions. + """ + + type, data = self.chunk(lenient=lenient) + method = '_process_' + type.decode('ascii') + m = getattr(self, method, None) + if m: + m(data) + + def _process_IHDR(self, data): + # http://www.w3.org/TR/PNG/#11IHDR + if len(data) != 13: + raise FormatError('IHDR chunk has incorrect length.') + (self.width, self.height, self.bitdepth, self.color_type, + self.compression, self.filter, + self.interlace) = struct.unpack("!2I5B", data) + + check_bitdepth_colortype(self.bitdepth, self.color_type) + + if self.compression != 0: + raise FormatError( + "Unknown compression method %d" % self.compression) + if self.filter != 0: + raise FormatError( + "Unknown filter method %d," + " see http://www.w3.org/TR/2003/REC-PNG-20031110/#9Filters ." + % self.filter) + if self.interlace not in (0, 1): + raise FormatError( + "Unknown interlace method %d, see " + "http://www.w3.org/TR/2003/REC-PNG-20031110/#8InterlaceMethods" + " ." + % self.interlace) + + # Derived values + # http://www.w3.org/TR/PNG/#6Colour-values + colormap = bool(self.color_type & 1) + greyscale = not(self.color_type & 2) + alpha = bool(self.color_type & 4) + color_planes = (3, 1)[greyscale or colormap] + planes = color_planes + alpha + + self.colormap = colormap + self.greyscale = greyscale + self.alpha = alpha + self.color_planes = color_planes + self.planes = planes + self.psize = float(self.bitdepth) / float(8) * planes + if int(self.psize) == self.psize: + self.psize = int(self.psize) + self.row_bytes = int(math.ceil(self.width * self.psize)) + # Stores PLTE chunk if present, and is used to check + # chunk ordering constraints. + self.plte = None + # Stores tRNS chunk if present, and is used to check chunk + # ordering constraints. + self.trns = None + # Stores sBIT chunk if present. + self.sbit = None + + def _process_PLTE(self, data): + # http://www.w3.org/TR/PNG/#11PLTE + if self.plte: + warnings.warn("Multiple PLTE chunks present.") + self.plte = data + if len(data) % 3 != 0: + raise FormatError( + "PLTE chunk's length should be a multiple of 3.") + if len(data) > (2 ** self.bitdepth) * 3: + raise FormatError("PLTE chunk is too long.") + if len(data) == 0: + raise FormatError("Empty PLTE is not allowed.") + + def _process_bKGD(self, data): + try: + if self.colormap: + if not self.plte: + warnings.warn( + "PLTE chunk is required before bKGD chunk.") + self.background = struct.unpack('B', data) + else: + self.background = struct.unpack("!%dH" % self.color_planes, + data) + except struct.error: + raise FormatError("bKGD chunk has incorrect length.") + + def _process_tRNS(self, data): + # http://www.w3.org/TR/PNG/#11tRNS + self.trns = data + if self.colormap: + if not self.plte: + warnings.warn("PLTE chunk is required before tRNS chunk.") + else: + if len(data) > len(self.plte) / 3: + # Was warning, but promoted to Error as it + # would otherwise cause pain later on. + raise FormatError("tRNS chunk is too long.") + else: + if self.alpha: + raise FormatError( + "tRNS chunk is not valid with colour type %d." % + self.color_type) + try: + self.transparent = \ + struct.unpack("!%dH" % self.color_planes, data) + except struct.error: + raise FormatError("tRNS chunk has incorrect length.") + + def _process_gAMA(self, data): + try: + self.gamma = struct.unpack("!L", data)[0] / 100000.0 + except struct.error: + raise FormatError("gAMA chunk has incorrect length.") + + def _process_sBIT(self, data): + self.sbit = data + if (self.colormap and len(data) != 3 or + not self.colormap and len(data) != self.planes): + raise FormatError("sBIT chunk has incorrect length.") + + def _process_pHYs(self, data): + # http://www.w3.org/TR/PNG/#11pHYs + self.phys = data + fmt = "!LLB" + if len(data) != struct.calcsize(fmt): + raise FormatError("pHYs chunk has incorrect length.") + self.x_pixels_per_unit, self.y_pixels_per_unit, unit = \ + struct.unpack(fmt, data) + self.unit_is_meter = bool(unit) + + def read(self, lenient=False): + """ + Read the PNG file and decode it. + Returns (`width`, `height`, `rows`, `info`). + + May use excessive memory. + + `rows` is a sequence of rows; + each row is a sequence of values. + + If the optional `lenient` argument evaluates to True, + checksum failures will raise warnings rather than exceptions. + """ + + def iteridat(): + """Iterator that yields all the ``IDAT`` chunks as strings.""" + while True: + type, data = self.chunk(lenient=lenient) + if type == b'IEND': + # http://www.w3.org/TR/PNG/#11IEND + break + if type != b'IDAT': + continue + # type == b'IDAT' + # http://www.w3.org/TR/PNG/#11IDAT + if self.colormap and not self.plte: + warnings.warn("PLTE chunk is required before IDAT chunk") + yield data + + self.preamble(lenient=lenient) + raw = decompress(iteridat()) + + if self.interlace: + def rows_from_interlace(): + """Yield each row from an interlaced PNG.""" + # It's important that this iterator doesn't read + # IDAT chunks until it yields the first row. + bs = bytearray(itertools.chain(*raw)) + arraycode = 'BH'[self.bitdepth > 8] + # Like :meth:`group` but + # producing an array.array object for each row. + values = self._deinterlace(bs) + vpr = self.width * self.planes + for i in range(0, len(values), vpr): + row = array(arraycode, values[i:i+vpr]) + yield row + rows = rows_from_interlace() + else: + rows = self._iter_bytes_to_values(self._iter_straight_packed(raw)) + info = dict() + for attr in 'greyscale alpha planes bitdepth interlace'.split(): + info[attr] = getattr(self, attr) + info['size'] = (self.width, self.height) + for attr in 'gamma transparent background'.split(): + a = getattr(self, attr, None) + if a is not None: + info[attr] = a + if getattr(self, 'x_pixels_per_unit', None): + info['physical'] = Resolution(self.x_pixels_per_unit, + self.y_pixels_per_unit, + self.unit_is_meter) + if self.plte: + info['palette'] = self.palette() + return self.width, self.height, rows, info + + def read_flat(self): + """ + Read a PNG file and decode it into a single array of values. + Returns (*width*, *height*, *values*, *info*). + + May use excessive memory. + + `values` is a single array. + + The :meth:`read` method is more stream-friendly than this, + because it returns a sequence of rows. + """ + + x, y, pixel, info = self.read() + arraycode = 'BH'[info['bitdepth'] > 8] + pixel = array(arraycode, itertools.chain(*pixel)) + return x, y, pixel, info + + def palette(self, alpha='natural'): + """ + Returns a palette that is a sequence of 3-tuples or 4-tuples, + synthesizing it from the ``PLTE`` and ``tRNS`` chunks. + These chunks should have already been processed (for example, + by calling the :meth:`preamble` method). + All the tuples are the same size: + 3-tuples if there is no ``tRNS`` chunk, + 4-tuples when there is a ``tRNS`` chunk. + + Assumes that the image is colour type + 3 and therefore a ``PLTE`` chunk is required. + + If the `alpha` argument is ``'force'`` then an alpha channel is + always added, forcing the result to be a sequence of 4-tuples. + """ + + if not self.plte: + raise FormatError( + "Required PLTE chunk is missing in colour type 3 image.") + plte = group(array('B', self.plte), 3) + if self.trns or alpha == 'force': + trns = array('B', self.trns or []) + trns.extend([255] * (len(plte) - len(trns))) + plte = list(map(operator.add, plte, group(trns, 1))) + return plte + + def asDirect(self): + """ + Returns the image data as a direct representation of + an ``x * y * planes`` array. + This removes the need for callers to deal with + palettes and transparency themselves. + Images with a palette (colour type 3) are converted to RGB or RGBA; + images with transparency (a ``tRNS`` chunk) are converted to + LA or RGBA as appropriate. + When returned in this format the pixel values represent + the colour value directly without needing to refer + to palettes or transparency information. + + Like the :meth:`read` method this method returns a 4-tuple: + + (*width*, *height*, *rows*, *info*) + + This method normally returns pixel values with + the bit depth they have in the source image, but + when the source PNG has an ``sBIT`` chunk it is inspected and + can reduce the bit depth of the result pixels; + pixel values will be reduced according to the bit depth + specified in the ``sBIT`` chunk. + PNG nerds should note a single result bit depth is + used for all channels: + the maximum of the ones specified in the ``sBIT`` chunk. + An RGB565 image will be rescaled to 6-bit RGB666. + + The *info* dictionary that is returned reflects + the `direct` format and not the original source image. + For example, an RGB source image with a ``tRNS`` chunk + to represent a transparent colour, + will start with ``planes=3`` and ``alpha=False`` for the + source image, + but the *info* dictionary returned by this method + will have ``planes=4`` and ``alpha=True`` because + an alpha channel is synthesized and added. + + *rows* is a sequence of rows; + each row being a sequence of values + (like the :meth:`read` method). + + All the other aspects of the image data are not changed. + """ + + self.preamble() + + # Simple case, no conversion necessary. + if not self.colormap and not self.trns and not self.sbit: + return self.read() + + x, y, pixels, info = self.read() + + if self.colormap: + info['colormap'] = False + info['alpha'] = bool(self.trns) + info['bitdepth'] = 8 + info['planes'] = 3 + bool(self.trns) + plte = self.palette() + + def iterpal(pixels): + for row in pixels: + row = [plte[x] for x in row] + yield array('B', itertools.chain(*row)) + pixels = iterpal(pixels) + elif self.trns: + # It would be nice if there was some reasonable way + # of doing this without generating a whole load of + # intermediate tuples. But tuples does seem like the + # easiest way, with no other way clearly much simpler or + # much faster. (Actually, the L to LA conversion could + # perhaps go faster (all those 1-tuples!), but I still + # wonder whether the code proliferation is worth it) + it = self.transparent + maxval = 2 ** info['bitdepth'] - 1 + planes = info['planes'] + info['alpha'] = True + info['planes'] += 1 + typecode = 'BH'[info['bitdepth'] > 8] + + def itertrns(pixels): + for row in pixels: + # For each row we group it into pixels, then form a + # characterisation vector that says whether each + # pixel is opaque or not. Then we convert + # True/False to 0/maxval (by multiplication), + # and add it as the extra channel. + row = group(row, planes) + opa = map(it.__ne__, row) + opa = map(maxval.__mul__, opa) + opa = list(zip(opa)) # convert to 1-tuples + yield array( + typecode, + itertools.chain(*map(operator.add, row, opa))) + pixels = itertrns(pixels) + targetbitdepth = None + if self.sbit: + sbit = struct.unpack('%dB' % len(self.sbit), self.sbit) + targetbitdepth = max(sbit) + if targetbitdepth > info['bitdepth']: + raise Error('sBIT chunk %r exceeds bitdepth %d' % + (sbit, self.bitdepth)) + if min(sbit) <= 0: + raise Error('sBIT chunk %r has a 0-entry' % sbit) + if targetbitdepth: + shift = info['bitdepth'] - targetbitdepth + info['bitdepth'] = targetbitdepth + + def itershift(pixels): + for row in pixels: + yield [p >> shift for p in row] + pixels = itershift(pixels) + return x, y, pixels, info + + def _as_rescale(self, get, targetbitdepth): + """Helper used by :meth:`asRGB8` and :meth:`asRGBA8`.""" + + width, height, pixels, info = get() + maxval = 2**info['bitdepth'] - 1 + targetmaxval = 2**targetbitdepth - 1 + factor = float(targetmaxval) / float(maxval) + info['bitdepth'] = targetbitdepth + + def iterscale(): + for row in pixels: + yield [int(round(x * factor)) for x in row] + if maxval == targetmaxval: + return width, height, pixels, info + else: + return width, height, iterscale(), info + + def asRGB8(self): + """ + Return the image data as an RGB pixels with 8-bits per sample. + This is like the :meth:`asRGB` method except that + this method additionally rescales the values so that + they are all between 0 and 255 (8-bit). + In the case where the source image has a bit depth < 8 + the transformation preserves all the information; + where the source image has bit depth > 8, then + rescaling to 8-bit values loses precision. + No dithering is performed. + Like :meth:`asRGB`, + an alpha channel in the source image will raise an exception. + + This function returns a 4-tuple: + (*width*, *height*, *rows*, *info*). + *width*, *height*, *info* are as per the :meth:`read` method. + + *rows* is the pixel data as a sequence of rows. + """ + + return self._as_rescale(self.asRGB, 8) + + def asRGBA8(self): + """ + Return the image data as RGBA pixels with 8-bits per sample. + This method is similar to :meth:`asRGB8` and :meth:`asRGBA`: + The result pixels have an alpha channel, *and* + values are rescaled to the range 0 to 255. + The alpha channel is synthesized if necessary + (with a small speed penalty). + """ + + return self._as_rescale(self.asRGBA, 8) + + def asRGB(self): + """ + Return image as RGB pixels. + RGB colour images are passed through unchanged; + greyscales are expanded into RGB triplets + (there is a small speed overhead for doing this). + + An alpha channel in the source image will raise an exception. + + The return values are as for the :meth:`read` method except that + the *info* reflect the returned pixels, not the source image. + In particular, + for this method ``info['greyscale']`` will be ``False``. + """ + + width, height, pixels, info = self.asDirect() + if info['alpha']: + raise Error("will not convert image with alpha channel to RGB") + if not info['greyscale']: + return width, height, pixels, info + info['greyscale'] = False + info['planes'] = 3 + + if info['bitdepth'] > 8: + def newarray(): + return array('H', [0]) + else: + def newarray(): + return bytearray([0]) + + def iterrgb(): + for row in pixels: + a = newarray() * 3 * width + for i in range(3): + a[i::3] = row + yield a + return width, height, iterrgb(), info + + def asRGBA(self): + """ + Return image as RGBA pixels. + Greyscales are expanded into RGB triplets; + an alpha channel is synthesized if necessary. + The return values are as for the :meth:`read` method except that + the *info* reflect the returned pixels, not the source image. + In particular, for this method + ``info['greyscale']`` will be ``False``, and + ``info['alpha']`` will be ``True``. + """ + + width, height, pixels, info = self.asDirect() + if info['alpha'] and not info['greyscale']: + return width, height, pixels, info + typecode = 'BH'[info['bitdepth'] > 8] + maxval = 2**info['bitdepth'] - 1 + maxbuffer = struct.pack('=' + typecode, maxval) * 4 * width + + if info['bitdepth'] > 8: + def newarray(): + return array('H', maxbuffer) + else: + def newarray(): + return bytearray(maxbuffer) + + if info['alpha'] and info['greyscale']: + # LA to RGBA + def convert(): + for row in pixels: + # Create a fresh target row, then copy L channel + # into first three target channels, and A channel + # into fourth channel. + a = newarray() + convert_la_to_rgba(row, a) + yield a + elif info['greyscale']: + # L to RGBA + def convert(): + for row in pixels: + a = newarray() + convert_l_to_rgba(row, a) + yield a + else: + assert not info['alpha'] and not info['greyscale'] + # RGB to RGBA + + def convert(): + for row in pixels: + a = newarray() + convert_rgb_to_rgba(row, a) + yield a + info['alpha'] = True + info['greyscale'] = False + info['planes'] = 4 + return width, height, convert(), info + + +def decompress(data_blocks): + """ + `data_blocks` should be an iterable that + yields the compressed data (from the ``IDAT`` chunks). + This yields decompressed byte strings. + """ + + # Currently, with no max_length parameter to decompress, + # this routine will do one yield per IDAT chunk: Not very + # incremental. + d = zlib.decompressobj() + # Each IDAT chunk is passed to the decompressor, then any + # remaining state is decompressed out. + for data in data_blocks: + # :todo: add a max_length argument here to limit output size. + yield bytearray(d.decompress(data)) + yield bytearray(d.flush()) + + +def check_bitdepth_colortype(bitdepth, colortype): + """ + Check that `bitdepth` and `colortype` are both valid, + and specified in a valid combination. + Returns (None) if valid, raise an Exception if not valid. + """ + + if bitdepth not in (1, 2, 4, 8, 16): + raise FormatError("invalid bit depth %d" % bitdepth) + if colortype not in (0, 2, 3, 4, 6): + raise FormatError("invalid colour type %d" % colortype) + # Check indexed (palettized) images have 8 or fewer bits + # per pixel; check only indexed or greyscale images have + # fewer than 8 bits per pixel. + if colortype & 1 and bitdepth > 8: + raise FormatError( + "Indexed images (colour type %d) cannot" + " have bitdepth > 8 (bit depth %d)." + " See http://www.w3.org/TR/2003/REC-PNG-20031110/#table111 ." + % (bitdepth, colortype)) + if bitdepth < 8 and colortype not in (0, 3): + raise FormatError( + "Illegal combination of bit depth (%d)" + " and colour type (%d)." + " See http://www.w3.org/TR/2003/REC-PNG-20031110/#table111 ." + % (bitdepth, colortype)) + + +def is_natural(x): + """A non-negative integer.""" + try: + is_integer = int(x) == x + except (TypeError, ValueError): + return False + return is_integer and x >= 0 + + +def undo_filter_sub(filter_unit, scanline, previous, result): + """Undo sub filter.""" + + ai = 0 + # Loops starts at index fu. Observe that the initial part + # of the result is already filled in correctly with + # scanline. + for i in range(filter_unit, len(result)): + x = scanline[i] + a = result[ai] + result[i] = (x + a) & 0xff + ai += 1 + + +def undo_filter_up(filter_unit, scanline, previous, result): + """Undo up filter.""" + + for i in range(len(result)): + x = scanline[i] + b = previous[i] + result[i] = (x + b) & 0xff + + +def undo_filter_average(filter_unit, scanline, previous, result): + """Undo up filter.""" + + ai = -filter_unit + for i in range(len(result)): + x = scanline[i] + if ai < 0: + a = 0 + else: + a = result[ai] + b = previous[i] + result[i] = (x + ((a + b) >> 1)) & 0xff + ai += 1 + + +def undo_filter_paeth(filter_unit, scanline, previous, result): + """Undo Paeth filter.""" + + # Also used for ci. + ai = -filter_unit + for i in range(len(result)): + x = scanline[i] + if ai < 0: + a = c = 0 + else: + a = result[ai] + c = previous[ai] + b = previous[i] + p = a + b - c + pa = abs(p - a) + pb = abs(p - b) + pc = abs(p - c) + if pa <= pb and pa <= pc: + pr = a + elif pb <= pc: + pr = b + else: + pr = c + result[i] = (x + pr) & 0xff + ai += 1 + + +def convert_la_to_rgba(row, result): + for i in range(3): + result[i::4] = row[0::2] + result[3::4] = row[1::2] + + +def convert_l_to_rgba(row, result): + """ + Convert a grayscale image to RGBA. + This method assumes the alpha channel in result is + already correctly initialized. + """ + for i in range(3): + result[i::4] = row + + +def convert_rgb_to_rgba(row, result): + """ + Convert an RGB image to RGBA. + This method assumes the alpha channel in result is + already correctly initialized. + """ + for i in range(3): + result[i::4] = row[i::3] + + +# Only reason to include this in this module is that +# several utilities need it, and it is small. +def binary_stdin(): + """ + A sys.stdin that returns bytes. + """ + + return sys.stdin.buffer + + +def binary_stdout(): + """ + A sys.stdout that accepts bytes. + """ + + stdout = sys.stdout.buffer + + # On Windows the C runtime file orientation needs changing. + if sys.platform == "win32": + import msvcrt + import os + msvcrt.setmode(sys.stdout.fileno(), os.O_BINARY) + + return stdout + + +def cli_open(path): + if path == "-": + return binary_stdin() + return open(path, "rb") + + +def main(argv): + """ + Run command line PNG. + Which reports version. + """ + print(__version__, __file__) + + +if __name__ == '__main__': + try: + main(sys.argv) + except Error as e: + print(e, file=sys.stderr) diff --git a/themes/xpm2png.py b/themes/xpm2png.py new file mode 100644 index 0000000..88616ad --- /dev/null +++ b/themes/xpm2png.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 + +import re +from dataclasses import dataclass +from pathlib import Path + +import png + +STRIP_CHARS = '", ' + + +@dataclass +class XpmConfig: + width: int + height: int + colors: int + char: int + + +def get_xpm_config(config_str: str): + s = config_str.strip(STRIP_CHARS).split(' ') + width = int(s[0]) + height = int(s[1]) + colors = int(s[2]) + ch = int(s[3]) + return XpmConfig(width, height, colors, ch) + + +def get_colors(xpm: list, config: XpmConfig): + colors = {} + for i, line in enumerate(xpm): + if i >= config.colors: + break + line = line.strip(STRIP_CHARS) + ch = line[:config.char] + if 'none' in line.lower(): + color = 'none' + else: + x = line.find('c #') + s = line[x + 3:] + color = '#' + line[5] + line[6] + line[9] + line[10] + line[13] + line[14] if (len(s) > 6) else '#' + s + + colors[ch] = color + return colors + + +def hex2rgb(hex_str: str): + hex_re = re.compile(r'^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$', re.IGNORECASE) + result = hex_re.match(hex_str) + if not result: + return None + return { + 'r': int(result[1], 16), + 'g': int(result[2], 16), + 'b': int(result[3], 16), + } + + +def get_pixels(xpm: list, config: XpmConfig, colors: dict): + pixels = [] + for i, line in enumerate(xpm): + row = [] + line = line.strip(STRIP_CHARS) + for j in range(0, len(line), config.char): + ch = line[j:j + config.char] + color = colors[ch] + try: + if color == 'none': + p = [255, 255, 255, 0] + else: + rgb = hex2rgb(color) + p = [rgb['r'], rgb['g'], rgb['b'], 255] + row.extend(p) + except Exception as e: + print(f'Error parsing line {i} ({line}): {e}') + pixels.append(row) + return pixels + + +def xpm2png(xpm_file: Path, png_file: Path): + xpm = xpm_file.read_text().splitlines() + xpm = xpm[2:-1] # skip header + + xpm_cfg = get_xpm_config(xpm[0]) + xpm = xpm[1:] # skip config + + colors = get_colors(xpm, xpm_cfg) + xpm = xpm[xpm_cfg.colors:] # skip colors + + pixels = get_pixels(xpm, xpm_cfg, colors) + + return png.from_array(pixels, 'RGBA').save(png_file) -- GitLab