#!/usr/bin/env python3
# -*- coding: utf-8 -*-

# GIMP plugin to split a layer to tiles or to position/join tiles into a layer

# (c) Ofnuts 2019-2024
#
#   History:
#
#   v0.00: 2019-01-13 First published version
#   v0.01: 2019-01-13 Fix when layer size <> image size (thanks Blighty)
#   v1.00: 2024-11-29 Convert to Gimp3
#   v1.01: 2025-03-22 Fix a "layer.get_position()" left over from v2.10 (thx Twads9816)
#   v1.02: 2025-05-13 Add 'layerRoot' & 'layerExt' formatting variables

#   This program is free software; you can redistribute it and/or modify
#   it under the terms of the GNU General Public License as published
#   by the Free Software Foundation; either version 3 of the License, or
#   (at your option) any later version.
#
#   This very file is the complete source code to the program.
#
#   If you make and redistribute changes to this code, please mark it
#   in reasonable ways as different from the original version. 
#   
#   This program is distributed in the hope that it will be useful,
#   but WITHOUT ANY WARRANTY; without even the implied warranty of
#   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#   GNU General Public License for more details.
#
#   The GPL v3 licence is available at: https://www.gnu.org/licenses/gpl-3.0.en.html

import gi

gi.require_version('Gimp', '3.0')
from gi.repository import Gimp
gi.require_version('Gegl', '0.4')
from gi.repository import Gegl
gi.require_version('Babl', '0.1')
from gi.repository import Babl

from gimphelpers import *

import re
import glob
import sys
import os.path

def N_(txt): return txt
def _(txt): return GLib.dgettext(None, txt)

# TODO: don't assume that layer offsets are 0,0

#
# Registration values
#
menuPath = '<Image>/' + _('Layer/Tiles')

# Split procedures
splitWHLabel=_('Split layer given width and height...')
splitWHDesc=_('Split a layer to tiles given width and height of tiles')
splitRCLabel=_('Split layer given rows and columns...')
splitRCDesc=_('Split a layer to tiles given number of rows and columns')

rowsArg = Int('rows', _('Rows'), _('Number of rows to split'), 1, 1000, 5)
columnsArg = Int('columns', _('Columns'), _('Number of columns to split'), 1, 1000, 5)
widthArg = Int('width', _('Width'), _('Width of tiles'), 1, 1000, 5)
heightArg = Int('height', _('Height'), _('Height of tiles'), 1, 1000, 5)

rcOrderSplitArg=Choice('rcOrder', _('Rows/Columns order'), _('Layer generation order'),
                       'COLUMNS_FIRST',
                       [
                      Option('ROWS_FIRST', _('Rows first'), _('Split row by row'),True),
                      Option('COLUMNS_FIRST', _('Columns first'), _('Split column by column'),False),
                  ])

dispositionSplitArg = Choice('disposition', _('Split result'), _('How to handle the tiles after split'),
                             'SUPERIMPOSE_SHRINK',
                             [Option('IN_PLACE', _('Keep in place'), _('Keep layer offsets')),
                              Option('SUPERIMPOSE', _('Superimpose'), _('Layers offsets are set to (0,0)')),
                              Option('SUPERIMPOSE_SHRINK', _('Superimpose and shrink'),
                                     _('Layers offsets are set to (0,0) and canvas is shrunk to fit')),
                              ])
defaultPattern='{layerName}-{column1:02d}-{row1:02d}'
patternArg=String('pattern',_('Name pattern'),_('How to name the tile layers'),defaultPattern)

# Join procedure
joinLabel=_('Join tiles...')
joinDesc=_('Join tiles to produce a single layer')

specsArg=Choice('specs', _('Rows/Columns'), _('How rows and columns are defined'),'GUESS',
                [
                    Option('ROWS_GIVEN', _('Rows given, compute columns'), _('Rows given, compute columns')),
                    Option('COLUMNS_GIVEN', _('Columns given, compute rows'), _('Columns given, compute rows')),
                    Option('GUESS', _('Parse tile names'), _('Rows/Columns are deduced from numbers in names'))
                ])

geometryArg=Int('geometry',_('Rows or columns'), _('Rows or columns, as defined above'),1,1000,10)

# Not same blurb as the one for split
rcOrderJoinArg=Choice('rcOrder', _('Rows/Columns order'),
                      _('''When using "Parse tile names": order of number groups in names,
                      Otherwise: tile order (same as in split functions)'''),
                      'COLUMNS_FIRST',
                       [
                      Option('ROWS_FIRST', _('Rows first'), _('Rows first'),True),
                      Option('COLUMNS_FIRST', _('Columns first'), _('Columns first'),False),
                  ])

stackOrderArg=Choice('stackOrder', _('Stack order'), _('Order or layers in stack'),'FROM_BOTTOM',
                     [
                         Option('FROM_TOP',_('From top'), _('Top of stack is top left corner'),False),
                         Option('FROM_BOTTOM', _('From bottom'), _('Bottom of stack is top left corner'),True),
                     ])
mergeArg = Choice('merge', _('Merge'),
                  _('Whether tiles are kept as distinct layers or merged to a single layer'),
                  'NO',
                  [Option('NO', _('No'), _('Keep as distinct layers'),False),
                   Option('YES', _('Yes'), _('Flatten to single layer'),True),
                   ])

resizeArg = Choice('resize', _('Resize image'), _('Resize image to fit joined tiles'),
                   'YES',
                   [
                       Option('NO', _('No change'), _('No change'), False),
                       Option('YES', _('Fit to result'), _('Fit to result'), True)
                   ])

def checkPlainLayer(layer: Gimp.Drawable):
    if (not layer.is_layer()) or layer.is_group_layer():
            raise Exception(_('Source element {0} must be a plain layer').format(layer.get_name()))

def tilesDataRC(layer: Gimp.Layer, rows: int ,columns: int):
    w=layer.get_width()
    h=layer.get_height()
    if w % columns:
        raise Exception(_('Layer width ({0:d}) not a multiple of the number of columns ({1:d})').format(w,columns))
    if h % rows:
        raise Exception(_('Layer height ({0:d}) not a multiple of the number of rows ({1:d})').format(h,rows))
    
    width=layer.get_width() // columns
    height=layer.get_height() // rows
    trace(f'Layer {w=}x{h=}, Tiles: {width=}x{height=}, {rows=}x{columns=}')
    return width,height,rows,columns

def tilesDataWH(layer: Gimp.Layer, width: int, height: int):
    w=layer.get_width()
    h=layer.get_height()
    if w % width:
        raise Exception(_('Layer width ({0:d}) is not a multiple of the tile width ({1:d})').format(w,width))
    if h % height:
        raise Exception(_('Layer height ({0:d}) not a multiple of the tile height ({1:d})').format(h,height))

    columns=w // width
    rows= h // height
    return width,height,rows,columns

def iterateTiles(rows,columns,rowsFirst):
    if rowsFirst:
        for row in range(rows):
            for column in range(columns):
                yield column*rows+row,row*columns+column,row,column
    else:
        for column in range(columns):
            for row in range(rows):
                yield column*rows+row,row*columns+column,row,column
            
def setValues(layerName,rows,columns,width,height,row,column,tileCR,tileRC):
    layerRoot,layerExt=os.path.splitext(layerName)
    formatValues= {'layerName': layerName, 'layerRoot':layerRoot, 'layerExt':layerExt,
                   'rows': rows, 'columns': columns,
                   'width': width, 'height': height,
                   'count': rows * columns,
                   'row0': row, 'row1': row + 1,
                   'column0': column, 'column1': column + 1,
                   'tileCR0': tileCR, 'tileCR1': tileCR + 1,
                   'tileRC0': tileRC, 'tileRC1': tileRC + 1}
    return formatValues
        
def layerToTiles(image: Gimp.Image, layer: Gimp.Layer, namePattern: str,
                 tileData: tuple[int,int,int,int], rowsFirst: bool, disposition: str):
    checkPlainLayer(layer)
    width,height,rows,columns=tileData
    for tileCR,tileRC,row,column in iterateTiles(rows,columns,rowsFirst):
        formatValues=setValues(layer.get_name(),rows,columns,width,height,row,column,tileCR,tileRC)
        try:
            tileName=namePattern.format(**formatValues)
        except KeyError as e:
            raise Exception(_('No formatting variable called "{0}"').format(e.args[0]))
        tile: Gimp.Layer=layer.copy()
        image.insert_layer(tile, layer.get_parent(),image.get_item_position(layer))
        tile.resize(width,height,-column*width,-row*height)
        tile.set_name(tileName)
        if disposition in ['SUPERIMPOSE','SUPERIMPOSE_SHRINK']:
            tile.set_offsets(0,0)
        trace('Tile W*H: {tile.get_width()}*{tile.get_height() @ {tile.get_offsets()}')
    image.remove_layer(layer)
    if disposition =='SUPERIMPOSE_SHRINK':
        image.resize_to_layers()

def layerToTilesRC(procedure: Gimp.ImageProcedure,
               run_mode: Gimp.RunMode,
               image: Gimp.Image,
               drawables: list[Gimp.Drawable],
               config: Gimp.ProcedureConfig, data: Any):
    trace(f'fullDialog({procedure=!r}, {run_mode=!r}, {image=!r}, {drawables=!r}, {config=!r}, {data=!r})')

    rows,columns=getArgsValues(config, rowsArg,columnsArg)
    pattern,rowsFirst,disposition=getArgsValues(config,patternArg,rcOrderSplitArg,dispositionSplitArg)
    trace(f'{rows=}, {columns=}, {pattern=}, {rowsFirst=}, {disposition=}')

    layer=drawables[0]
    layerToTiles(image,layer,pattern,tilesDataRC(layer,rows,columns),rowsFirst,disposition)

def layerToTilesWH(procedure: Gimp.ImageProcedure,
               run_mode: Gimp.RunMode,
               image: Gimp.Image,
               drawables: list[Gimp.Drawable],
               config: Gimp.ProcedureConfig, data: Any):
    trace(f'fullDialog({procedure=!r}, {run_mode=!r}, {image=!r}, {drawables=!r}, {config=!r}, {data=!r})')

    width,height=getArgsValues(config,widthArg, heightArg)
    pattern,rowsFirst,disposition=getArgsValues(config,patternArg,rcOrderSplitArg,dispositionSplitArg)
    trace(f'{width=}, {height=}, {pattern=}, {rowsFirst=}, {disposition=}')
    layer=drawables[0]
    layerToTiles(image,layer,pattern,tilesDataRC(layer,width,height),disposition,rowsFirst)

    layerToTiles(image,layer,pattern,tilesDataWH(layer,width,height),rowsFirst,disposition)

def tilesGuessedFromNames(layers: list[Gimp.Layer],
                          rowsFirst: bool) -> tuple[list[list[Gimp.Layer]],int,int]:
    pattern=re.compile(r'(^(.*\D)?)(\d+)(\D+)(\d+)(\D*)$')
    commonSeps=None
    tilesCoords: list[tuple[int,int,Gimp.Layer]]=[]
    for layer in layers:
        name=layer.get_name()
        match=pattern.match(name)
        if not match:
            raise Exception(_('Cannot figure out row or columns in layer "{0}"').format(name))
        s1,__,n1,s2,n2,s3=match.groups()
        if not commonSeps: # first image
            commonSeps=(s1,s2,s3)
        else:
            if commonSeps!=(s1,s2,s3):
                raise Exception(_('Layer {0} does not match "*{1}NN{2}NN{3}*"').format(name,*commonSeps))
        # tuple with x,y,layer, 
        # if rowsFirst, then in *-AA-BB, AA is the row number so the Y coordinate
        row,col=(n1,n2) if rowsFirst else (n2,n1)
        trace(f'{n1=}, {n2=}, {rowsFirst=}: {row=}, {col=}')
        tilesCoords.append((int(col),int(row),layer))

    minCol=min([x for x,y,l in tilesCoords])
    maxCol=max([x for x,y,l in tilesCoords])
    minRow=min([y for x,y,l in tilesCoords])
    maxRow=max([y for x,y,l in tilesCoords])
 
    rows=1+maxRow-minRow
    cols=1+maxCol-minCol
    count=rows*cols

    trace('Guessed: %d rows and %d columns' % (rows,cols))
    if count!=len(tilesCoords):
        raise Exception(_('Number of layers ({0}) does not match the number of expected tiles ({1}*{2}={3})')
                        .format(len(layers),rows,cols,count))
    tiles: list[list[Gimp.layer]]=[[None for __ in range(rows)] for _ in range(cols)]
    for x,y,l in tilesCoords:
        tiles[x-minCol][y-minRow]=l
    return tiles,rows,cols

def tilesFromGivenRC(layers: list[Gimp.Layer],
                     rowsGiven: bool,
                     given: int ,
                     rowsFirst: bool) -> tuple[list[list[Gimp.Layer]],int,int]:

    computed=len(layers)//given
    if len(layers)!=given*computed:
        raise Exception(_('The number of layers ({0}) is not a multiple of the given number of rows or columns ({1})')
                        .format(len(layers),given))
    rows, cols = (given, computed) if rowsGiven else (computed, given)

    # Assuming layers are 0 1 2 3 4 5
    # Rows first: 
    # 0 1 2  
    # 3 4 5
    # So [[0, 3], [1, 4], [2, 5]]
    #    
    # Cols first: 
    # 0 2 4  
    # 1 3 5  
    # So [[0, 1], [2, 3], [4, 5]]
    if rowsFirst:
        tiles=[layers[x::cols] for x in range(cols)]
    else:
        tiles=[layers[x:x+rows] for x in range(0,len(layers),rows)]
        
    return tiles,rows,cols

def dumpTiles(tiles):
    if debug:
        for y in range(len(tiles[0])):
            print(' '.join([l.get_name() for l in [tiles[x][y] for x in range(len(tiles))]]))

def dumpLayers(where: str, layers: list[Gimp.Layer]):
    if debug:
        names=', '.join(l.get_name() for l in layers)
        trace(f'{where}: {names}')


# Moves a layer above another for merge
def mergeLayer(image: Gimp.Image, target: Gimp.Layer, merged: Gimp.Layer):
    # When reordering a layer above the target in the same parent, it appears
    # that Gimp first removes it from the stack, then uses the given position
    # on the new stack, so the position is off by one.
    targetPosition=image.get_item_position(target)
    targetParent=target.get_parent()
    mergedParent=merged.get_parent()
    mergedPosition=image.get_item_position(merged)
    # If above target in same parent, decrement target position by one
    if targetParent==mergedParent and mergedPosition < targetPosition:
        abovePosition=targetPosition-1
    else:
        abovePosition=targetPosition
    image.reorder_item(merged, targetParent, abovePosition)
    return image.merge_down(merged, Gimp.MergeType.EXPAND_AS_NECESSARY)

def tilesToLayer(procedure: Gimp.ImageProcedure,
                 run_mode: Gimp.RunMode,
                 image: Gimp.Image,
                 drawables: list[Gimp.Drawable],
                 config: Gimp.ProcedureConfig, data: Any):

    specs,geometry=getArgsValues(config,specsArg,geometryArg)
    rowsFirst, fromBottom, flatten, resize=getArgsValues(config,rcOrderJoinArg,stackOrderArg, mergeArg, resizeArg)

    trace(f'{specs=}, {geometry=}, {rowsFirst=}, {fromBottom=}, {flatten=}, {resize=}')

    layers: list[Gimp.Layer]=image.get_selected_layers()
    dumpLayers('Entry', layers)
    for layer in layers:
        checkPlainLayer(layer)

    if specs=='GUESS':
        tiles,rows,columns=tilesGuessedFromNames(layers,rowsFirst)
    else:
        if fromBottom:
            layers.reverse()
        tiles,rows,columns=tilesFromGivenRC(layers,specs,geometry,rowsFirst)

    dumpTiles(tiles)

    tileW,tileH=tiles[0][0].get_width(),tiles[0][0].get_height()
    for y in range(rows):
        for x in range(columns):
            tile=tiles[x][y]
            if (tileW,tileH)!=(tile.get_width(),tile.get_height()):
                raise Exception(_('Tile "{0}" ({1},{2})" is not the same size as the previous ones ({3},{4})').format(
                    tile.get_name(),tile.get_width(),tile.get_height(),tileW,tileH))
            tile.set_offsets(x*tileW,y*tileH)

    if flatten:
        # Merge all layers to bottom selected one. We can't blindly merge down
        # Because there can be non-selected layers in the stack
        dumpLayers('Flatten', layers)
        bottomLayer=layers[-1]
        for layer in reversed(layers[:-1]):
            bottomLayer=mergeLayer(image,bottomLayer,layer)
            if bottomLayer is None:
                raise Exception('Merge failed')
        bottomLayer.set_name(_('Merged tiles'))

    if resize:
        # image.resize(columns*tileW,rows*tileH)
        image.resize_to_layers() # Easier than previous solution, and takes in account existing layers

### Registrations

class Ofn3LayerTiles(HelpedPlugin):
    author = 'Ofnuts'
    copyright = 'Ofnuts'
    year = '2019-2024'
    procedures=[
        ProcedureDescription(
            'ofn3-layer-to-tiles-wh',
            '*',Gimp.ProcedureSensitivityMask.DRAWABLE,
            layerToTilesWH,
            args=[widthArg,heightArg,patternArg,rcOrderSplitArg,dispositionSplitArg],
            menuLabel=splitWHLabel,
            menuPath=menuPath,
            descShort=splitWHDesc,descLong=splitWHDesc,
        ),
        ProcedureDescription(
            'ofn3-layer-to-tiles-rc',
            '*',Gimp.ProcedureSensitivityMask.DRAWABLE,
            layerToTilesRC,
            args=[rowsArg,columnsArg,patternArg,rcOrderSplitArg,dispositionSplitArg,],
            menuLabel=splitRCLabel,
            menuPath=menuPath,
            descShort=splitRCDesc,descLong=splitRCDesc,
        ),
        ProcedureDescription(
            'ofn3-join-tiles',
            '*',
            Gimp.ProcedureSensitivityMask.DRAWABLES,
            tilesToLayer,
            args=[specsArg,geometryArg,rcOrderJoinArg,stackOrderArg,mergeArg,resizeArg,],
            menuLabel=joinLabel,
            menuPath=menuPath,
            descShort=joinDesc,descLong=joinDesc,
        ),
    ]

Gimp.main(Ofn3LayerTiles.__gtype__, sys.argv)
