#!/usr/bin/env python

# iccp
#
# International Color Consortium Profile
#
# Tools for manipulating ICC profiles.
#
# An ICC profile can be extracted from a PNG image (iCCP chunk).
#
#
# Non-standard ICCP tags.
#
# Apple use some (widespread but) non-standard tags.  These can be
# displayed in Apple's ColorSync Utility.
# - 'vcgt' (Video Card Gamma Tag).  Table to load into video
#    card LUT to apply gamma.
# - 'ndin' Apple display native information.
# - 'dscm' Apple multi-localized description strings.
# - 'mmod' Apple display make and model information.


# References
#
# [ICC 2001] ICC Specification ICC.1:2001-04 (Profile version 2.4.0)
# [ICC 2004] ICC Specification ICC.1:2004-10 (Profile version 4.2.0.0)

import argparse

# https://docs.python.org/3.8/library/pprint.html
import pprint
import struct
import sys
import warnings
import zlib

import png


class FormatError(Exception):
    pass


class EncodingError(Exception):
    pass


class Profile:
    """An International Color Consortium Profile (ICC Profile)."""

    def __init__(self):
        self.rawtagtable = None
        self.rawtagdict = {}
        self.d = dict()

    def from_file(self, inp, name="<unknown>"):
        # See [ICC 2004]
        profile = inp.read(128)
        if len(profile) < 128:
            raise FormatError("ICC Profile is too short.")
        (size,) = struct.unpack_from(">L", profile)
        profile += inp.read(size - len(profile))
        return self.from_bytes(profile, name)

    def from_bytes(self, profile, name="<unknown>"):
        self.d = dict()
        d = self.d
        if len(profile) < 128:
            raise FormatError("ICC Profile is too short.")
        d.update(
            zip(
                [
                    "size",
                    "preferredCMM",
                    "version",
                    "profileclass",
                    "colourspace",
                    "pcs",
                ],
                struct.unpack_from(">L4sL4s4s4s", profile),
            )
        )
        if len(profile) < d["size"]:
            warnings.warn(
                "Profile size declared to be %d, but only got %d bytes"
                % (d["size"], len(profile))
            )
        d["version"] = "%08x" % d["version"]
        d["created"] = readICCdatetime(profile[24:36])
        d.update(
            zip(
                ["acsp", "platform", "flag", "manufacturer", "model"],
                struct.unpack_from(">4s4sL4s4s", profile, 36),
            )
        )
        if d["acsp"] != b"acsp":
            warnings.warn("acsp field not present (not an ICC Profile?).")
        d["deviceattributes"] = profile[56:64]
        (d["intent"],) = struct.unpack_from(">L", profile, 64)
        d["pcsilluminant"] = readICCXYZNumber(profile[68:80])
        d["creator"] = profile[80:84]
        d["id"] = profile[84:100]

        (ntags,) = struct.unpack_from(">L", profile, 128)
        d["ntags"] = ntags
        fmt = "4s2L" * ntags
        # tag table
        tt = struct.unpack_from(">" + fmt, profile, 132)
        tt = group(tt, 3)

        # Could (should) detect 2 or more tags having the same sig.  But
        # we don't.  Two or more tags with the same sig is illegal per
        # the ICC spec.

        # Convert (sig,offset,size) triples into (sig,value) pairs.
        rawtag = [(sig, profile[offset : offset + size]) for sig, offset, size in tt]
        self.rawtagtable = rawtag
        self.rawtagdict = dict(rawtag)
        tag = dict()
        # Interpret the tags whose types we know about
        for sig, v in rawtag:
            if sig in tag:
                warnings.warn("Duplicate tag %r found.  Ignoring." % sig)
                continue
            v = ICCdecode(v)
            if v is not None:
                tag[sig] = v
        self.tag = tag
        return self

    def greyInput(self):
        """Adjust ``self.d`` dictionary for greyscale input device.
        ``profileclass`` is 'scnr', ``colourspace`` is 'GRAY', ``pcs``
        is 'XYZ '.
        """

        self.d.update(dict(profileclass="scnr", colourspace="GRAY", pcs="XYZ "))
        return self

    def rgb(self):
        """Adjust ``self.d`` dictionary for rgb device.
        ``profileclass`` is `mntr`, ``colourspace`` is `RGB `,
        ``pcs`` is 'XYZ '.
        """

        self.d.update(dict(profileclass="mntr", colourspace="RGB ", pcs="XYZ "))
        return self

    def maybeAddDefaults(self):
        if self.rawtagdict:
            return
        self._addTags(
            cprt=b"Copyright unknown.",
            desc=b"created by PyPNG/iccp",
            wtpt=D50(),
        )

    def addTags(self, **k):
        self.maybeAddDefaults()
        self._addTags(**k)

    def _addTags(self, **k):
        """Helper for :meth:`addTags`."""

        for tag, thing in k.items():
            if not isinstance(thing, (tuple, list)):
                thing = (thing,)
            typetag = defaulttagtype[tag]
            self.rawtagdict[tag] = encode(typetag, *thing)
        return self

    def write(self, out):
        """Write ICC Profile to the file."""

        if not self.rawtagtable:
            self.rawtagtable = self.rawtagdict.items()
        tags = tagblock(self.rawtagtable)
        self.writeHeader(out, 128 + len(tags))
        out.write(tags)
        out.flush()

        return self

    def writeHeader(self, out, size=999):
        """Add default values to the instance's `d` dictionary, then
        write a header out onto the file stream.  The size of the
        profile must be specified using the `size` argument.
        """

        def defaultkey(d, key, value):
            """Add ``[key]==value`` to the dictionary `d`, but only if
            it does not have that key already.
            """

            if key in d:
                return
            d[key] = value

        z = "\x00" * 4
        defaults = dict(
            preferredCMM=z,
            version="02000000",
            profileclass=z,
            colourspace=z,
            pcs="XYZ ",
            created=writeICCdatetime(),
            acsp="acsp",
            platform=z,
            flag=0,
            manufacturer=z,
            model=0,
            deviceattributes=0,
            intent=0,
            pcsilluminant=encodefuns()["XYZ"](*D50()),
            creator=z,
        )
        for k, v in defaults.items():
            defaultkey(self.d, k, v)

        # helper functions to do datatype conversions of header items
        def k(k):
            """Get item and use as is."""
            return self.d[k]

        def bs(k):
            """Get item and convert to bytes."""
            return bytes(self.d[k], "ascii")

        def x(k):
            """Get item and convert from hex to int."""
            return int(self.d[k], 16)

        header_items = [
            bs("preferredCMM"),
            x("version"),
            bs("profileclass"),
            bs("colourspace"),
            bs("pcs"),
            k("created"),
            bs("acsp"),
            bs("platform"),
            k("flag"),
            bs("manufacturer"),
            k("model"),
            k("deviceattributes"),
            k("intent"),
            k("pcsilluminant"),
            bs("creator"),
        ]

        out.write(struct.pack(">L4sL4s4s4s12s4s4sL4sLQL12s4s", size, *header_items))
        out.write(b"\x00" * 44)
        return self


def encodefuns():
    """Returns a dictionary mapping ICC type signature sig to encoding
    function.  Each function returns a string comprising the content of
    the encoded value.  To form the full value, the type sig and the 4
    zero bytes should be prefixed (8 bytes).
    """

    def desc(ascii):
        """Return textDescription type [ICC 2001] 6.5.17.  The ASCII part is
        filled in with the string `ascii`, the Unicode and ScriptCode parts
        are empty."""

        ascii += b"\x00"
        n = len(ascii)

        return struct.pack(">L%ds2LHB67s" % n, n, ascii, 0, 0, 0, 0, b"")

    def text(ascii):
        """Return textType [ICC 2001] 6.5.18."""

        return ascii + "\x00"

    def curv(f=None, n=256):
        """Return a curveType, [ICC 2001] 6.5.3.  If no arguments are
        supplied then a TRC for a linear response is generated (no entries).
        If an argument is supplied and it is a number (for *f* to be a
        number it  means that ``float(f)==f``) then a TRC for that
        gamma value is generated.
        Otherwise `f` is assumed to be a function that maps [0.0, 1.0] to
        [0.0, 1.0]; an `n` element table is generated for it.
        """

        if f is None:
            return struct.pack(">L", 0)
        try:
            if float(f) == f:
                return struct.pack(">LH", 1, int(round(f * 2 ** 8)))
        except (TypeError, ValueError):
            pass
        assert n >= 2
        table = []
        M = float(n - 1)
        for i in range(n):
            x = i / M
            table.append(int(round(f(x) * 65535)))
        return struct.pack(">L%dH" % n, n, *table)

    def XYZ(x, y, z):
        """
        Encode an (X,Y,Z) colour.
        """
        return struct.pack(">3l", *map(fs15f16, [x, y, z]))

    return locals()


# Tag type defaults.
# Most tags can only have one or a few tag types.
# When encoding, we associate a default tag type with each tag so that
# the encoding is implicit.
defaulttagtype = dict(
    A2B0="mft1",
    A2B1="mft1",
    A2B2="mft1",
    bXYZ="XYZ",
    bTRC="curv",
    B2A0="mft1",
    B2A1="mft1",
    B2A2="mft1",
    calt="dtim",
    targ="text",
    chad="sf32",
    chrm="chrm",
    cprt="desc",
    crdi="crdi",
    dmnd="desc",
    dmdd="desc",
    devs="",
    gamt="mft1",
    kTRC="curv",
    gXYZ="XYZ",
    gTRC="curv",
    lumi="XYZ",
    meas="",
    bkpt="XYZ",
    wtpt="XYZ",
    ncol="",
    ncl2="",
    resp="",
    pre0="mft1",
    pre1="mft1",
    pre2="mft1",
    desc="desc",
    pseq="",
    psd0="data",
    psd1="data",
    psd2="data",
    psd3="data",
    ps2s="data",
    ps2i="data",
    rXYZ="XYZ",
    rTRC="curv",
    scrd="desc",
    scrn="",
    tech="sig",
    bfd="",
    vued="desc",
    view="view",
)


def encode(tsig, *args):
    """Encode a Python value as an ICC type.
    `tsig` is the type signature to encode, as a string
    (the first 4 bytes of the encoded value, see [ICC 2004] section 10).
    """

    fun = encodefuns()
    if tsig not in fun:
        raise EncodingError("No encoder for type %r." % tsig)
    v = fun[tsig](*args)
    # convert tsig to bytes and pad with spaces.
    tsig = bytes((tsig + "   ")[:4], "ascii")
    return tsig + (b"\x00" * 4) + v


def tagblock(tag):
    """`tag` should be a list of (*signature*, *element*) pairs, where
    *signature* (the key) is a length 4 string, and *element* is the
    content of the tag element (a bytes instance).

    The entire tag block (consisting of first a table and then the
    element data) is constructed and returned as a bytes
    instance.
    """

    n = len(tag)
    tablelen = 12 * n

    # Build the tag table in two parts.  A list of 12-byte tags, and a
    # string of element data.  Offset is the offset from the start of
    # the profile to the start of the element data (so the offset for
    # the next element is this offset plus the length of the element
    # string so far).
    offset = 128 + tablelen + 4
    # The table.  As a bytes instance.
    table = b""
    # The element data
    element = b""
    for k, v in tag:
        k = bytes(k, "ascii")
        table += struct.pack(">4s2L", k, offset + len(element), len(v))
        element += v
    return struct.pack(">L", n) + table + element


def print_iccp(out, inp):
    """Print to `out` a model for the icc profile in the (open)
    file `inp`.
    """

    profile = Profile().from_file(inp)
    pprint.pprint(profile.d, stream=out)
    print([x[0] for x in profile.rawtagtable], file=out)
    pprint.pprint(profile.tag, stream=out, compact=True)


def profileFromPNG(inp):
    """
    Extract profile from PNG file.  Return (*profile*, *name*) pair.
    Where `profile` is the sequence of bytes for the profile
    actual, and `name` is the name in the PNG file.
    """
    r = png.Reader(file=inp)
    _, chunk = r.chunk_of_type("iCCP")
    i = chunk.index(b"\x00")
    name = chunk[:i]
    compression = chunk[i + 1]
    assert compression == 0
    profile = zlib.decompress(chunk[i + 2 :])
    return profile, name


def iccp_from_png(out, png):
    """Extract ICC Profile from (open) PNG file `inp` and
    write it to the file `out`.
    """

    out.write(profileFromPNG(png)[0])


def fs15f16(x):
    """Convert float to ICC s15Fixed16Number (as a Python ``int``)."""

    return int(round(x * 2 ** 16))


def D50():
    """Return D50 illuminant as an (X,Y,Z) triple."""

    # See [ICC 2001] A.1
    return (0.9642, 1.0000, 0.8249)


def writeICCdatetime(t=None):
    """`t` should be a gmtime tuple (as returned from
    ``time.gmtime()``).  If not supplied, the current time will be used.
    Return an ICC dateTimeNumber in a 12 element bytes instance.
    """

    import time

    if t is None:
        t = time.gmtime()
    return struct.pack(">6H", *t[:6])


def readICCdatetime(s):
    """Convert from 12 byte ICC representation of dateTimeNumber to
    ISO8601 string. See [ICC 2004] 5.1.1"""

    return "%04d-%02d-%02dT%02d:%02d:%02dZ" % struct.unpack(">6H", s)


def readICCXYZNumber(s):
    """Convert from 12 byte ICC representation of XYZNumber to (x,y,z)
    triple of floats.  See [ICC 2004] 5.1.11"""

    return s15f16l(s)


def s15f16l(s):
    """Convert sequence of ICC s15Fixed16 to list of float."""
    # Note: As long as float has at least 32 bits of mantissa, all
    # values are preserved.
    n = len(s) // 4
    t = struct.unpack(">%dl" % n, s)
    return [(2 ** -16) * v for v in t]


# Several types and their byte encodings are defined by [ICC 2004]
# section 10.  When encoded, a value begins with a 4 byte type
# signature.  We use the same 4 byte type signature in the names of the
# Python functions that decode the type into a Pythonic representation.


def ICCdecode(s):
    """Take an ICC encoded tag, and dispatch on its type signature
    (first 4 bytes) to decode it into a Python value.  Pair (*sig*,
    *value*) is returned, where *sig* is a 4 byte string, and *value* is
    some Python value determined by the content and type.
    """

    sig = s[0:4]
    sig = str(sig, "ascii").strip()
    f = dict(
        text=RDtext,
        XYZ=RDXYZ,
        curv=RDcurv,
        vcgt=RDvcgt,
        sf32=RDsf32,
    )
    if sig not in f:
        return None
    return (sig, f[sig](s))


def RDXYZ(s):
    """Convert ICC XYZType to rank 1 array of trimulus values."""

    # See [ICC 2001] 6.5.26
    assert s[0:4] == b"XYZ "
    return readICCXYZNumber(s[8:])


def RDsf32(s):
    """Convert ICC s15Fixed16ArrayType to list of float."""
    # See [ICC 2004] 10.18
    assert s[0:4] == b"sf32"
    return s15f16l(s[8:])


def RDmluc(s):
    """Convert ICC multiLocalizedUnicodeType.  This types encodes
    several strings together with a language/country code for each
    string.  A list of (*lc*, *string*) pairs is returned where *lc* is
    the 4 byte language/country code, and *string* is the string
    corresponding to that code.  It seems unlikely that the same
    language/country code will appear more than once with different
    strings, but the ICC standard does not prohibit it.
    """

    # See [ICC 2004] 10.13
    assert s[0:4] == b"mluc"
    n, sz = struct.unpack_from(">2L", s, 8)
    assert sz == 12
    record = []
    for i in range(n):
        lc, l, o = struct.unpack_from("4s2L", s, 16 + 12 * n)
        record.append(lc, s[o : o + l])
    # How are strings encoded?
    return record


def RDtext(s):
    """Convert ICC textType to Python string."""
    # Note: type not specified or used in [ICC 2004], only in older
    # [ICC 2001].
    # See [ICC 2001] 6.5.18
    assert s[0:4] == b"text"
    return s[8:-1]


def RDcurv(s):
    """Convert ICC curveType."""
    # See [ICC 2001] 6.5.3
    assert s[0:4] == b"curv"
    (count,) = struct.unpack_from(">L", s, 8)
    if count == 0:
        return dict(gamma=1)
    table = struct.unpack_from(">%dH" % count, s, 12)
    if count == 1:
        return dict(gamma=table[0] * 2 ** -8)
    return table


def RDvcgt(s):
    """Convert Apple CMVideoCardGammaType."""
    # See
    # http://developer.apple.com/documentation/GraphicsImaging/Reference/ColorSync_Manager/Reference/reference.html#//apple_ref/c/tdef/CMVideoCardGammaType
    assert s[0:4] == b"vcgt"
    (tagtype,) = struct.unpack_from(">L", s, 8)
    if tagtype != 0:
        return s[8:]
    if tagtype == 0:
        # Table.
        channels, count, size = struct.unpack_from(">3H", s, 12)
        if size == 1:
            fmt = "B"
        elif size == 2:
            fmt = "H"
        else:
            return s[8:]
        n = len(s[18:]) // size
        t = struct.unpack_from(">%d%s" % (n, fmt), s, 18)
        t = group(t, count)
        return size, t
    return s[8:]


def group(s, n):
    return zip(*[iter(s)] * n)


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

    parser = argparse.ArgumentParser()
    parser.add_argument("--png", type=png.cli_open)
    parser.add_argument("icc", nargs="?", type=png.cli_open)
    args = parser.parse_args(argv)

    if args.png is not None:
        return iccp_from_png(png.binary_stdout(), args.png)
    if args.icc is None:
        parser.print_help()
        return 0
        
    return print_iccp(sys.stdout, args.icc)


if __name__ == "__main__":
    sys.exit(main())
