
# pripamtopng
#
# Python Raster Image PAM to PNG

import array
import struct
import sys

import png

Description = """Convert NetPBM PAM/PNM format files to PNG."""


def read_pam_header(infile):
    """
    Read (the rest of a) PAM header.
    `infile` should be positioned immediately after the initial 'P7' line
    (at the beginning of the second line).
    Returns are as for `read_pnm_header`.
    """

    # Unlike PBM, PGM, and PPM, we can read the header a line at a time.
    header = dict()
    while True:
        line = infile.readline().strip()
        if line == b"ENDHDR":
            break
        if not line:
            raise EOFError("PAM ended prematurely")
        if line[0] == b"#":
            continue
        line = line.split(None, 1)
        key = line[0]
        if key not in header:
            header[key] = line[1]
        else:
            header[key] += b" " + line[1]

    required = [b"WIDTH", b"HEIGHT", b"DEPTH", b"MAXVAL"]
    required_str = b", ".join(required).decode("ascii")
    result = []
    for token in required:
        if token not in header:
            raise png.Error("PAM file must specify " + required_str)
        try:
            x = int(header[token])
        except ValueError:
            raise png.Error(required_str + " must all be valid integers")
        if x <= 0:
            raise png.Error(required_str + " must all be positive integers")
        result.append(x)

    return (b"P7",) + tuple(result)


def read_pnm_header(infile):
    """
    Read a PNM header, returning (format,width,height,depth,maxval).
    Also reads a PAM header (by using a helper function).
    `width` and `height` are in pixels.
    `depth` is the number of channels in the image;
    for PBM and PGM it is synthesized as 1, for PPM as 3;
    for PAM images it is read from the header.
    `maxval` is synthesized (as 1) for PBM images.
    """

    # Generally, see http://netpbm.sourceforge.net/doc/ppm.html
    # and http://netpbm.sourceforge.net/doc/pam.html

    # Technically 'P7' must be followed by a newline,
    # so by using rstrip() we are being liberal in what we accept.
    # I think this is acceptable.
    magic = infile.read(3).rstrip()
    if magic == b"P7":
        # PAM header parsing is completely different.
        return read_pam_header(infile)

    # Expected number of tokens in header (3 for P4, 4 for P6)
    expected = 4
    pbm = (b"P1", b"P4")
    if magic in pbm:
        expected = 3
    header = [magic]

    # We must read the rest of the header byte by byte because
    # the final whitespace character may not be a newline.
    # Of course all PNM files in the wild use a newline at this point,
    # but we are strong and so we avoid
    # the temptation to use readline.
    bs = bytearray()
    backs = bytearray()

    def next():
        if backs:
            c = bytes(backs[0:1])
            del backs[0]
        else:
            c = infile.read(1)
            if not c:
                raise png.Error("premature EOF reading PNM header")
        bs.extend(c)
        return c

    def backup():
        """Push last byte of token onto front of backs."""
        backs.insert(0, bs[-1])
        del bs[-1]

    def ignore():
        del bs[:]

    def tokens():
        ls = lexInit
        while True:
            token, ls = ls()
            if token:
                yield token

    def lexInit():
        c = next()
        # Skip comments
        if b"#" <= c <= b"#":
            while c not in b"\n\r":
                c = next()
            ignore()
            return None, lexInit
        # Skip whitespace (that precedes a token)
        if c.isspace():
            ignore()
            return None, lexInit
        if not c.isdigit():
            raise png.Error("unexpected byte %r found in header" % c)
        return None, lexNumber

    def lexNumber():
        # According to the specification it is legal to have comments
        # that appear in the middle of a token.
        # I've never seen it; and,
        # it's a bit awkward to code good lexers in Python (no goto).
        # So we break on such cases.
        c = next()
        while c.isdigit():
            c = next()
        backup()
        token = bs[:]
        ignore()
        return token, lexInit

    for token in tokens():
        # All "tokens" are decimal integers, so convert them here.
        header.append(int(token))
        if len(header) == expected:
            break

    final = next()
    if not final.isspace():
        raise png.Error("expected header to end with whitespace, not %r" % final)

    if magic in pbm:
        # synthesize a MAXVAL
        header.append(1)
    depth = (1, 3)[magic == b"P6"]
    return header[0], header[1], header[2], depth, header[3]


def convert_pnm_plain(w, infile, outfile):
    """
    Convert a plain PNM file containing raw pixel data into
    a PNG file with the parameters set in the writer object.
    Works for plain PGM formats.
    """

    # See convert_pnm_binary for the corresponding function for
    # binary PNM formats.

    rows = scan_rows_from_file_plain(infile, w.width, w.height, w.planes)
    w.write(outfile, rows)


def scan_rows_from_file_plain(infile, width, height, planes):
    """
    Generate a sequence of rows from the input file `infile`.
    The input file should be in a "Netpbm-like" plain format.
    The input file should be positioned at the beginning of the
    first value (that is, immediately after the header).
    The number of pixels to read is taken from
    the image dimensions (`width`, `height`, `planes`).

    Each row is yielded as a single sequence of values.
    """

    # Values per row
    vpr = width * planes

    values = []
    rows_output = 0

    # The core problem is that input lines (text lines) may not
    # correspond with pixel rows. We use two nested loops.
    # The outer loop reads the input one text line at a time;
    # this will contain a whole number of values, which are
    # added to the `values` list.
    # The inner loop strips the first `vpr` values from the
    # list, until there aren't enough.
    # Note we can't tell how many iterations the inner loop will
    # run for, it could be 0 (if not enough values were read to
    # make a whole pixel row) or many (if the entire image were
    # on one input line), or somewhere in between.
    # In PNM there is in general no requirement to have
    # correspondence between text lines and pixel rows.

    for inp in infile:
        values.extend(map(int, inp.split()))
        while len(values) >= vpr:
            yield values[:vpr]
            del values[:vpr]
            rows_output += 1
            if rows_output >= height:
                # Diagnostic here if there are spare values?
                return
    # Diagnostic here for early EOF?


def convert_pnm_binary(w, infile, outfile):
    """
    Convert a PNM file containing raw pixel data into
    a PNG file with the parameters set in the writer object.
    Works for (binary) PGM, PPM, and PAM formats.
    """

    rows = scan_rows_from_file(infile, w.width, w.height, w.planes, w.bitdepth)
    w.write(outfile, rows)


def scan_rows_from_file(infile, width, height, planes, bitdepth):
    """
    Generate a sequence of rows from the input file `infile`.
    The input file should be in a "Netpbm-like" binary format.
    The input file should be positioned at the beginning of the first pixel.
    The number of pixels to read is taken from
    the image dimensions (`width`, `height`, `planes`);
    the number of bytes per value is implied by `bitdepth`.
    Each row is yielded as a single sequence of values.
    """

    # Values per row
    vpr = width * planes
    # Bytes per row
    bpr = vpr
    if bitdepth > 8:
        assert bitdepth == 16
        bpr *= 2
        fmt = ">%dH" % vpr

        def line():
            return array.array("H", struct.unpack(fmt, infile.read(bpr)))

    else:

        def line():
            return array.array("B", infile.read(bpr))

    for y in range(height):
        yield line()


def parse_args(args):
    """
    Create a parser and parse the command line arguments.
    """
    from argparse import ArgumentParser

    parser = ArgumentParser(description=Description)
    version = "%(prog)s " + png.__version__
    parser.add_argument("--version", action="version", version=version)
    parser.add_argument(
        "-c",
        "--compression",
        type=int,
        metavar="level",
        help="zlib compression level (0-9)",
    )
    parser.add_argument(
        "input",
        nargs="?",
        default="-",
        type=png.cli_open,
        metavar="PAM/PNM",
        help="input PAM/PNM file to convert",
    )
    args = parser.parse_args(args)
    return args


def main(argv=None):
    if argv is None:
        argv = sys.argv

    args = parse_args(argv[1:])

    # Prepare input and output files
    infile = args.input

    # Call after parsing, so that --version and --help work.
    outfile = png.binary_stdout()

    # Encode PNM to PNG
    format, width, height, depth, maxval = read_pnm_header(infile)

    ok_formats = (b"P2", b"P5", b"P6", b"P7")
    if format not in ok_formats:
        raise NotImplementedError("file format %s not supported" % format)

    # The NetPBM depth (number of channels) completely
    # determines the PNG format.
    # Observe:
    # - L, LA, RGB, RGBA are the 4 modes supported by PNG;
    # - they correspond to 1, 2, 3, 4 channels respectively.
    # We use the number of channels in the source image to
    # determine which one we have.
    # We ignore the NetPBM image type and the PAM TUPLTYPE.
    greyscale = depth <= 2
    pamalpha = depth in (2, 4)
    supported = [2 ** x - 1 for x in range(1, 17)]
    try:
        mi = supported.index(maxval)
    except ValueError:
        raise NotImplementedError(
            "input maxval (%s) not in supported list %s" % (maxval, str(supported))
        )
    bitdepth = mi + 1
    writer = png.Writer(
        width,
        height,
        greyscale=greyscale,
        bitdepth=bitdepth,
        alpha=pamalpha,
        compression=args.compression,
    )

    plain = format in (b"P1", b"P2", b"P3")
    if plain:
        convert_pnm_plain(writer, infile, outfile)
    else:
        convert_pnm_binary(writer, infile, outfile)


if __name__ == "__main__":
    try:
        sys.exit(main())
    except png.Error as e:
        print(e, file=sys.stderr)
        sys.exit(99)
