"""A low-level library to edit and create images in the GIF format.
Follows the [GIF89a specification](https://www.w3.org/Graphics/GIF/spec-gif89a.txt) to an unneededly great degree"""
class dataBlocks: # data blocks (a thing that gif uses)
        """Class used internally by other classes.
Data sub-blocks are defined in section 15 of the GIF89a spec."""
        def fromBytes(byteData):
                """Given a bytes consisting of data sub-blocks optionally followed by additional data, returns the bytes represented by the data sub-blocks."""
                data = b''
                while byteData[0]:
                        dataLen = byteData[0]+1
                        data += byteData[1:dataLen]
                        byteData = byteData[dataLen:]
                return data
        def toBytes(data):
                """Given a bytes consisting of raw data, returns the bytes encoded as data sub-blocks."""
                byteData = b''
                while len(data):
                        if len(data)>=255:
                                nextBlockLen = 255
                        else:
                                nextBlockLen = len(data)
                        byteData += nextBlockLen.to_bytes(1,"big")
                        byteData += data[:nextBlockLen]
                        data = data[nextBlockLen:]
                byteData += b'\x00'
                return byteData
        def remainingData(byteData): # given raw byte data, return everything *after* data blocks
                """Given a bytes consisting of data sub-blocks followed by additional data, returns the additional data."""
                while byteData[0]: byteData = byteData[byteData[0]+1:]
                return byteData[1:]

class application(): # application extension
        """The application extension is defined in section 26 of the GIF89a spec.
identifier: string. "Sequence of eight printable ASCII characters used to identify the application owning the Application Extension."
authCode: bytes. "Sequence of three bytes used to authenticate the Application Identifier. An Application program may use an algorithm to compute a binary code that uniquely identifies it as the application owning the Application Extension."
data: bytes. Application data."""
        def __init__(self,data='',identifier='PLACEHOL',authCode=b'DER'):
                self.identifier = identifier
                self.authCode = authCode
                self.data = data
        def fromBytes(self,byteData):
                assert byteData[:3] == b'\x21\xff\x0b' # beginning of application extension
                self.identifier = byteData[3:11].decode("us-ascii")
                self.authCode = byteData[11:14]
                self.data = dataBlocks.fromBytes(byteData[14:])
                return dataBlocks.remainingData(byteData[14:])
        def toBytes(self):
                byteData = b'\x21\xff\x0b'
                assert len(self.identifier) == 8
                assert len(self.authCode) == 3
                byteData += bytes(self.identifier,"us-ascii") + self.authCode
                byteData += dataBlocks.toBytes(self.data)
                return byteData
        def loop(self,loops=0): # the widely supported one, looping animation
                """Overwrites an application with the Netscape Animation extension, commonly supported.
loops: int. If loops is 0, the animation will repeat indefinitely. Otherwise, it will repeat `loops` times."""
                self.identifier = "NETSCAPE"
                self.authCode = b'2.0'
                self.data = b'\x01'+loops.to_bytes(2,"little")
                return toBytes(self)

class comment():
        """The comment extension is defined in section 24 of the GIF89a spec.
data: string."""
        def __init__(self, data=''):
                self.data = data
        def fromBytes(self,byteData,encoding="us-ascii"): # us-ascii is not strictly required as the Comment block encoding
                assert byteData[:2] == b'\x21\xfe'
                self.data = dataBlocks.fromBytes(byteData[2:]).decode(encoding)
                return dataBlocks.remainingData(byteData[2:])
        def toBytes(self,encoding="us-ascii"):
                return b'\x21\xfe'+dataBlocks.toBytes(bytes(self.data,encoding))

class graphicControl():
        """The graphic control extension is defined in section 23 of the GIF89a spec.
The graphic control block's scope is the gif.frame or gif.plainText following it.
disposalMethod: int.
       "0 -   No disposal specified. The decoder is not required to take any action.
        1 -   Do not dispose. The graphic is to be left in place.
        2 -   Restore to background color. The area used by the graphic must be restored to the background color.
        3 -   Restore to previous. The decoder is required to restore the area overwritten by the graphic with what was there prior to rendering the graphic."
userInput: bool. "Indicates whether or not user input isexpected before continuing."
delayTime: int. "If not 0, this field specifies the number of hundredths (1/100) of a second to wait before continuing with the processing of the Data Stream."
transIndex: int (or None). "The Transparency Index is such that when encountered, the corresponding pixel of the display device is not modified and processing goes on to the next pixel."
"""
        def __init__(self, disposalMethod=1, userInput=False, delayTime=0, transIndex=None):
                self.disposalMethod=disposalMethod
                self.userInput = userInput
                self.delayTime = delayTime
                self.transIndex = transIndex
        def fromBytes(self,byteData):
                assert byteData[:2] == b'\x21\xf9'
                data = dataBlocks.fromBytes(byteData[2:])
                self.disposalMethod = (data[0]>>2)&7
                self.userInput = bool(data[0]&2)
                self.delayTime = int.from_bytes(data[1:3],"little")
                self.transIndex = data[3] if data[0]&1 else None
                return dataBlocks.remainingData(byteData[2:])
        def toBytes(self):
                data = b''
                data += ((self.disposalMethod<<2)|(int(self.userInput)<<1)|(int(self.transIndex!=None))).to_bytes(1,"big")
                data += self.delayTime.to_bytes(2,"little")
                data += self.transIndex.to_bytes(1,"big") if type(self.transIndex) == int else b'\x00'
                return b'\x21\xf9' + dataBlocks.toBytes(data)

class plainText():
        """The Plain Text extension is defined in section 25 of the GIF89a spec.
data: str. "The textual data will be encoded with the 7-bit printable ASCII characters."
foregroundIndex: int. "Index into the Global Color Table to be used to render the text foreground."
backgroundIndex: int. "Index into the Global Color Table to be used to render the text background."
width: int. "Width of the text grid in pixels."
height: int. "Height of the text grid in pixels."
x: int. "Column number, in pixels, of the left edge of the text grid, with respect to the left edge of the Logical Screen."
y: int. "Row number, in pixels, of the top edge of the text grid, with respect to the top edge of the Logical Screen."
cellWidth: int. "Width, in pixels, of each cell in the grid."
cellHeight: int. "Height, in pixels, of each cell in the grid."
"""
        def __init__(self, data='',foregroundIndex=0, backgroundIndex=1, width=0, height=0,x=0, y=0, cellWidth=8,cellHeight=8):
                self.x = x
                self.y = y
                self.width = width
                self.height = height
                self.cellWidth = cellWidth
                self.cellHeight = cellHeight
                self.foregroundIndex = foregroundIndex
                self.backgroundIndex = backgroundIndex
                self.data = data
        def fromBytes(self,byteData):
                assert byteData[:3] == b'\x21\x01\x0c'
                self.x = int.from_bytes(byteData[3:5],"little")
                self.y = int.from_bytes(byteData[5:7],"little")
                self.width = int.from_bytes(byteData[7:9],"little")
                self.height = int.from_bytes(byteData[9:11],"little")
                self.cellWidth = byteData[11]
                self.cellHeight = byteData[12]
                self.foregroundIndex = byteData[13]
                self.backgroundIndex = byteData[14]
                self.data = dataBlocks.fromBytes(byteData[15:]).decode("us-ascii")
                return dataBlocks.remainingData(byteData[15:])
        def toBytes(self):
                byteData = b'\x21\x01\x0c'
                byteData += self.x.to_bytes(2,"little")
                byteData += self.y.to_bytes(2,"little")
                byteData += (self.width//self.cellWidth*self.cellWidth).to_bytes(2,"little")
                byteData += (self.height//self.cellHeight*self.cellHeight).to_bytes(2,"little")
                byteData += self.cellWidth.to_bytes(1,"big")
                byteData += self.cellHeight.to_bytes(1,"big")
                byteData += self.foregroundIndex.to_bytes(1,"big")
                byteData += self.backgroundIndex.to_bytes(1,"big")
                byteData += dataBlocks.toBytes(bytes(self.data,"us-ascii"))
                return byteData

class unknownExtension():
        """This class is for extensions that are not part of the GIF89a spec, but are rudimentarily readable.
type: int. Label ranges are defined in Section 12 of the GIF89a spec.
data: bytes. raw data contained in the extension."""
        def __init__(self, data=b'', TYPE=59):
                self.type = TYPE
                self.data = data
        def fromBytes(self,byteData):
                assert byteData[0] == 0x21
                self.type = byteData[1]
                self.data = dataBlocks.fromBytes(byteData[2:])
                return dataBlocks.remainingData(byteData[2:])
        def toBytes(self):
                byteData = b'\x21'
                byteData += self.type.to_bytes(1,"big")
                byteData += dataBlocks.toBytes(self.data)
                return byteData

class frame():
        """The Image Descriptor is defined in section 20 of the GIF89a spec.
data: list of ints. every item is a pixel, represented by its index in the palette.
width: int.
height: int.
x: int.
y: int.
palette: list of lists of ints, or None. Local colour palette; only needs to be used if different from the containing gif.image's palette.
The lists contain three ints, representing the red, green, and blue components of the colour.
interlace: bool."""
        def __init__(self, data=[], width=0, height=0, x=0, y=0, palette=None, interlace=False):
                self.interlace = interlace
                self.palette = palette
                self.x = x
                self.y = y
                self.width = width
                self.height = height
                self.data = data
        def fromBytes(self,byteData):
                assert byteData[0] == 0x2c
                self.x = int.from_bytes(byteData[1:3],"little")
                self.y = int.from_bytes(byteData[3:5],"little")
                self.width = int.from_bytes(byteData[5:7],"little")
                self.height = int.from_bytes(byteData[7:9],"little")
                packed = byteData[9]
                byteData = byteData[10:]
                lTableExists = packed&128
                lTableSize = 6<<(packed&7)
                self.interlace = bool(packed&64)
                if lTableExists:
                        lTable = byteData[:lTableSize]
                        byteData = byteData[lTableSize:]
                        self.palette = []
                        while len(lTable):
                                self.palette.append(list(lTable[:3]))
                                lTable = lTable[3:]
                else:
                        self.palette = None
                # lzw decoding
                lzwMin = byteData[0]
                lzwData = dataBlocks.fromBytes(byteData[1:])
                lzwDict = [(i,) for i in range(1<<lzwMin)] + ["clear", "end"]
                wordSize = lzwMin+1
                # decode lzw data here
                words = int.from_bytes(lzwData,"little")
                decoded = ["clear"]
                while words:
                        word = words&((1<<wordSize)-1) # extract current word
                        words = words>>wordSize
                        assert word <= len(lzwDict)
                        if word == len(lzwDict):
                                lzwDict.append(decoded[-1]+(decoded[-1][0],))
                        elif lzwDict[word] == "end": break
                        elif lzwDict[word] == "clear":
                                lzwDict = [(i,) for i in range(1<<lzwMin)] + ["clear", "end"]
                                wordSize = lzwMin+1 # redundant code oof
                        elif type(decoded[-1])!=str: # previous item isn't CLEAR so add item to dict
                             lzwDict.append(decoded[-1]+(lzwDict[word][0],))
                        decoded.append(lzwDict[word])
                        if len(lzwDict)==1<<wordSize and wordSize<12: wordSize+=1
                imageData = []
                for item in decoded:
                        if type(item)==str: continue
                        imageData += list(item)
                # and deinterlace if interlaced
                if self.interlace:
                        interlaceBounds = (self.height//8+1,(self.height-4)//8+1,(self.height-2)//4+1,(self.height-1)//2+1)
                        groups = []
                        for b in interlaceBounds:
                                groups.append(imageData[:b*self.width])
                                imageData = imageData[b*self.width:]
                        imageData = []
                        for i in range(interlaceBounds[0]):
                                imageData += groups[0][:self.width]+\
                                        groups[3][:self.width]+\
                                        groups[2][:self.width]+\
                                        groups[3][self.width:self.width*2]+\
                                        groups[1][:self.width]+\
                                        groups[3][self.width*2:self.width*3]+\
                                        groups[2][self.width:self.width*2]+\
                                        groups[3][self.width*3:self.width*4] # this is a mess
                                groups[0] = groups[0][self.width:]
                                groups[1] = groups[1][self.width:]
                                groups[2] = groups[2][self.width*2:]
                                groups[3] = groups[3][self.width*4:]
                self.data = imageData
                return dataBlocks.remainingData(byteData[1:])
        def toBytes(self):
                byteData = b'\x2c'
                byteData += self.x.to_bytes(2,"little")
                byteData += self.y.to_bytes(2,"little")
                byteData += self.width.to_bytes(2,"little")
                byteData += self.height.to_bytes(2,"little")
                packed = 64 if self.interlace else 0
                if self.palette:
                        packed |= 128 # palette flag
                        assert len(self.palette)<=256
                        packed |= (len(self.palette)>128)+\
                                  (len(self.palette)>64)+\
                                  (len(self.palette)>32)+\
                                  (len(self.palette)>16)+\
                                  (len(self.palette)>8)+\
                                  (len(self.palette)>4)+\
                                  (len(self.palette)>2) # palette length field
                        byteData += packed.to_bytes(1,"big")
                        paletteSize = 2<<(packed&7) # size of palette as dictated by palette length field
                        for colour in self.palette: byteData += bytes(colour) # add the colours we have
                        if paletteSize > len(self.palette): byteData += b'\x00\x00\x00' * (paletteSize - len(self.palette)) # if the palette length isn't a power of 2, add #000000 until it is
                else:
                        byteData += packed.to_bytes(1,"big")
                if self.interlace: # interlace image data if needed (this is also a mess)
                        groups = [[],[],[],[]]
                        for i in range(0,self.height+8,8):
                                d = self.data[i*self.width:]
                                groups[0] += d[:self.width]
                                groups[1] += d[self.width*4:self.width*5]
                                groups[2] += d[self.width*2:self.width*3]
                                groups[2] += d[self.width*6:self.width*7]
                                groups[3] += d[self.width:self.width*2]
                                groups[3] += d[self.width*3:self.width*4]
                                groups[3] += d[self.width*5:self.width*6]
                                groups[3] += d[self.width*7:self.width*8]
                        imageData = groups[0]+groups[1]+groups[2]+groups[3]
                else:
                        imageData = self.data
                # lzw encoding
                maxValue = max(self.data) # is this a good idea
                lzwMin = (maxValue>=128)+(maxValue>=64)+(maxValue>=32)+(maxValue>=16)+(maxValue>=8)+(maxValue>=4)+2
                lzwDict = [(i,) for i in range(1<<lzwMin)] + ["clear", "end"]
                wordSize = lzwMin+1
                words = 1<<lzwMin # initial clear code
                lzwPackedCount = wordSize # number of bits that have been packed so far, because reasons
                while imageData:
                        leng = 1
                        while tuple(imageData[:leng+1]) in lzwDict and leng < len(imageData): leng += 1
                        words |= lzwDict.index(tuple(imageData[:leng]))<<lzwPackedCount
                        lzwPackedCount += wordSize
                        lzwDict.append(tuple(imageData[:leng+1]))
                        imageData = imageData[leng:]
                        if len(lzwDict)>1<<wordSize:
                                if wordSize<12: wordSize += 1
                                else: # clear (because it's before 1991-01 /s)
                                        words |= 1<<lzwMin<<lzwPackedCount # clear code
                                        lzwPackedCount += wordSize
                                        lzwDict = [(i,) for i in range(1<<lzwMin)] + ["clear", "end"]
                                        wordSize = lzwMin+1
                words |= ((1<<lzwMin)+1)<<lzwPackedCount # end code
                lzwPackedCount += wordSize
                lzwData = words.to_bytes((lzwPackedCount-1)//8+1,"little")
                byteData += lzwMin.to_bytes(1,"big")
                byteData += dataBlocks.toBytes(lzwData)
                return byteData

class image:
        """The containing class for an entire GIF image.
width: int.
height: int.
palette: list of lists of ints, or None. Global colour palette; used for all gif.plainText and gif.frame blocks (unless the gif.frame defines a local colour palette)
The lists contain three ints, representing the red, green, and blue components of the colour.
backgroundIndex: int. "Index into the Global Color Table for the Background Color. The Background Color is the color used for those pixels on the screen that are not covered by an image."
pixelAspect: float, or None. If not None, ratio of a pixel's width over its height.
blocks: list. The items in the list are the blocks in the GIF image. blocks can contain:
        gif.frame, gif.application, gif.comment, gif.graphicControl, gif.plainText, gif.unknownExtension"""
        def __init__(self, width=0, height=0, palette=[[c//36*51,c//6%6*51,c%6*51] for c in range(216)], backgroundIndex = 255, pixelAspect = None, blocks=[]):
                self.width = width
                self.height = height
                self.blocks = blocks
                self.palette = palette
                self.backgroundIndex = backgroundIndex
                self.pixelAspect = pixelAspect
        def fromBytes(self,byteData):
                assert byteData[:3] == b'GIF'
                if not byteData[3:6] in (b'87a',b'89a'): print(f"Unknown version: {byteData[3:6].decode('us-ascii')}. Proceeding.")
                self.width = int.from_bytes(byteData[6:8],"little")
                self.height = int.from_bytes(byteData[8:10],"little")
                packed = byteData[10]
                gTableExists = packed&128
                gTableSize = 6<<(packed&7) # each table entry has RGB components
                self.pixelAspect = (int.from_bytes(byteData[12],"little")+15)/64 if byteData[12] else None
                blocks = byteData[13:]
                if gTableExists:
                        self.backgroundIndex = byteData[11]
                        gTable = blocks[:gTableSize]
                        blocks = blocks[gTableSize:]
                        self.palette = []
                        while len(gTable):
                                self.palette.append(list(gTable[:3]))
                                gTable = gTable[3:]
                else:
                        self.backgroundIndex = None
                        self.palette = None
                self.blocks = []
                while len(blocks):
                        if blocks[0] == 0x3b: break # trailer
                        elif blocks[0] == 0x2c: # frame
                                block = frame()
                                blocks = block.fromBytes(blocks) # each block's fromBytes returns the remaining data i guess?
                        elif blocks[0] == 0x21: # extension
                                if blocks[1] == 0xff: # application
                                        block = application()
                                        blocks = block.fromBytes(blocks)
                                elif blocks[1] == 0xfe: # comment
                                        block = comment()
                                        blocks = block.fromBytes(blocks)
                                elif blocks[1] == 0xf9: # graphic control
                                        block = graphicControl()
                                        blocks = block.fromBytes(blocks)
                                elif blocks[1] == 0x01: # plain text
                                        block = plainText()
                                        blocks = block.fromBytes(blocks)
                                else:
                                        print(f"Unknown extension encountered: {blocks[1]}. Proceeding.")
                                        block = unknownExtension()
                                        blocks = block.fromBytes(blocks)
                        else: raise Exception(f"Unknown block encountered: {blocks[0]}")
                        self.blocks.append(block)
        def toBytes(self):
                dataBytes = b'GIF' + (b'89a' if [block for block in self.blocks if type(block) in (application, comment, graphicControl, plainText)] else b'87a')
                dataBytes += self.width.to_bytes(2,"little")
                dataBytes += self.height.to_bytes(2,"little")
                packed = 112
                if self.palette:
                        packed |= 128 # global colour table flag
                        gTableSize = len(self.palette)
                        packed |= (gTableSize>128)+\
                                  (gTableSize>64)+\
                                  (gTableSize>32)+\
                                  (gTableSize>16)+\
                                  (gTableSize>8)+\
                                  (gTableSize>4)+\
                                  (gTableSize>2)
                else:
                        packed |= 7 # "even if there is no global colour table specified, set this value"
                dataBytes += packed.to_bytes(1,"big")
                dataBytes += self.backgroundIndex.to_bytes(1,"big") if self.backgroundIndex and self.palette else b'\x00'
                dataBytes += bytes([(self.pixelAspect*64-15)//1]) if self.pixelAspect else b'\x00'
                if self.palette:
                        paletteSize = 2<<(packed&7) # size of palette as dictated by palette length field
                        for colour in self.palette: dataBytes += bytes(colour)
                        if paletteSize > len(self.palette): dataBytes += b'\x00\x00\x00' * (paletteSize - len(self.palette)) # if the palette length isn't a power of 2, add #000000 until it is
                for block in self.blocks:
                        dataBytes += block.toBytes()
                dataBytes += b'\x3b'
                return dataBytes
# SPDX-License-Identifier: Unlicense
