--[[
  Copyright 2017-2023 Louis Paternault

  This work may be distributed and/or modified under the
  conditions of the LaTeX Project Public License, either version 1.3
  of this license or (at your option) any later version.
  The latest version of this license is in
    http://www.latex-project.org/lppl.txt
  and version 1.3 or later is part of all distributions of LaTeX
  version 2005/12/01 or later.

  This work has the LPPL maintenance status `maintained'.

  The Current Maintainer of this work is Louis Paternault

  This work consists of the files pixelart.sty, pixelart.lua, pixelart.tex.
--]]

require("lualibs-lpeg")
local luakeys = require("luakeys")()

pixelart = {
 _debug = false,
 _counter = 0,
}

--------------------------------------------------------------------------------
--[[ Debug on/off
--]]

local function pixelart_setpixelartdebug(flag)
 luakeys.opts.debug = flag
 pixelart._debug = flag
end

pixelart.setpixelartdebug = pixelart_setpixelartdebug

--------------------------------------------------------------------------------
--[[ Print
--]]

local function tex_sprint(text)
   tex.sprint(text)
   if pixelart._debug then
       io.write(text)
   end
end

local function tex_print(text)
   tex.print(text)
   if pixelart._debug then
       io.write(text, "\n")
   end
end

--------------------------------------------------------------------------------
--[[ Define and use colors
-- ]]
pixelart._colors = {}

local function pixelart_parsecolors(argument)
 return luakeys.parse(
     argument,
     {
       naked_as_value = true,
       hooks = {
         keys = function(key, value, depth, current, result)
           return tostring(key), value
         end,
       }
     }
   )
end

local function pixelart_newpixelartcolors(name, argument)
 if pixelart._colors[name] == nil then
   pixelart._colors[name] = pixelart_parsecolors(argument)
 else
   error(string.format("Error: Colors '%s' is already defined.", name))
 end
end

local function pixelart_renewpixelartcolors(name, argument)
 pixelart._colors[name] = pixelart_parsecolors(argument)
end

pixelart.newpixelartcolors = pixelart_newpixelartcolors
pixelart.renewpixelartcolors = pixelart_renewpixelartcolors

-- Default color sets
pixelart._colors["explicit"] = {}

pixelart._colors["RGB"] = {
 R = "red",
 G = "green",
 B = "blue",
 W = "white",
 K = "black"
}

pixelart._colors["BW"] = {
 ["0"] = "white",
 ["1"] = "black",
}

pixelart._colors["gray"] = {
 ["0"] = "white",
 ["1"] = "white!89!black",
 ["2"] = "white!78!black",
 ["3"] = "white!67!black",
 ["4"] = "white!56!black",
 ["5"] = "white!44!black",
 ["6"] = "white!33!black",
 ["7"] = "white!22!black",
 ["8"] = "white!11!black",
 ["9"] = "black",
}

pixelart._colors["mono"] = {}
for _, char in pairs(string.explode("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", "")) do
 pixelart._colors["mono"][char] = ""
end


--------------------------------------------------------------------------------
--[[ Options
--]]

local ALGORITHMS = {squares = true, stack = true}

local function parse(str, default)
 local parser = luakeys.define({
   tikz = {},
   colors = {
     process = function(value, input, result, unknown)
       -- If argument is a string, convert numeric keys into strings
       if type(value) ~= "table" then
         return value
       end

       local converted = {}
       for k, v in pairs(value) do
         converted[tostring(k)] = v
       end
       return converted
     end,
   },
   margin = {
     data_type = "number",
     default = 0,
     always_present = true,
   },
   style = {
     data_type = "string",
     default = "pixelart",
     always_present = true,
   },
   squares = {
     exclusive_group = "algo",
   },
   stack = {
     exclusive_group = "algo",
   },
   draft = {
     data_type = "boolean",
   },
 })

 options = parser(str, {
   defaults = default,
 })

 -- Algo option
 local algo = nil
 for key, value in pairs(ALGORITHMS) do
   if options[key] then
     algo = key
   end
 end
 if not algo then
   options[pixelart._default_algo[1]] = pixelart._default_algo[2]
 end

 -- Convert Tikz option back to string
 if type(options.tikz) == type({}) then
   options.tikz = luakeys.render(options.tikz)
 end

 return options
end

local DEFAULT = {colors = "mono"}
local function pixelart_setpixelartdefault(str)
  pixelart._default = parse(str, DEFAULT)

  for key, value in pairs(pixelart._default) do
    if ALGORITHMS[key] then
      pixelart._default_algo = {key, value}
      pixelart._default[key] = nil
    end
  end
end

pixelart._default = {colors = "mono"}
pixelart._default_algo = {"stack", {}}
pixelart.setpixelartdefault = pixelart_setpixelartdefault

--------------------------------------------------------------------------------
--[[ Parse pixelart string
--Parse arguments, and build a table of tables.
--]]
--
local lineRE = lpeg.Ct(
 -- Regular expression to match a line of colors
 (
   (lpeg.P("{") * lpeg.C((lpeg.P(1) - lpeg.S("{}"))^0) * lpeg.P("}"))
   +
   lpeg.C((lpeg.P(1) - lpeg.S("{}")))
 )^0
)

local function str2arrays(str)
 -- Turn the \pixelart{} argument into a table (of lines) of tables (of colors).

 -- Trim string: remove leading and trailing whitespaces
 str = str:gsub("^%s*(.-)%s*$", "%1")

 -- Turn it into a table (which is called "array" not to clash with the "table" library)
 local array = {}
 for k, line in ipairs(string.explode(str, " ")) do
   array[k] = lineRE:match(line)
 end

 -- Ensure each row has the same number of columns
 local length = 0
 for k, line in ipairs(array) do
   if #line > length then
     length = #line
   end
 end
 for k, line in ipairs(array) do
   if #line < length then
     for i=#line+1, length do
       line[i] = false
     end
   end
 end

 -- Flip array so that flipped[x][y] gives the pixel with coordinates (x, y), in the standard frame
 local flipped = {}
 for y, line in ipairs(array) do
   for x, color in ipairs(array[y]) do
     if y == 1 then
       flipped[x] = {}
     end
     flipped[x][#array - y + 1] = color
   end
 end

 return flipped
end

--------------------------------------------------------------------------------
--[[ Color tools
--]]
local function color2color(colors, color)
 -- Convert a color (as given by the user) into a color (usable by TikZ).
 if color == "." or not color then
   -- Transparent pixel: do not print anything
   return nil
 elseif colors[color] and colors[color] ~= "" then
   -- A color has been defined: use it
   return string.format("color=%s", colors[color])
 elseif colors[color] and colors[color] == "" then
   -- An empty color has been defined: use the default TikZ color
   return ""
 else
   -- No color has been defined: use the argument as the TikZ color
   return color
 end
end

--------------------------------------------------------------------------------
-- Turn pixelart string into TikZ code, using the SQUARES algorithm

local function pixelart_body_squares(array, colors, options)
 -- Draw the tikz pixels, as a set of squares.

 if #array == 0 then
   -- Empty array
   return
 end

 tex_print(string.format(
   [[\clip ({0-%s}, {0-%s}) rectangle (%s, %s); ]],
   options.margin,
   options.margin,
   #array + options.margin,
   #array[1] + options.margin
   ))

 for x, column in ipairs(array) do
   for y, color in ipairs(column) do
     color = color2color(colors, color)

     ---------------------
     -- Which pixel size?
     local overlap
     if type(options.squares) == type({}) then
       overlap = options.squares["overlap"] or "0"
     else
       overlap = "0"
     end

     ---------------------
     -- At last, we can display the pixel…
     if color ~= nil then
       tex_print(string.format([[\fill[%s, %s] (%s, %s) rectangle ++(1+%s, 1+%s);]],
         options.style,
         color,
         string.format("{%s-%s}", x-1, overlap),
         string.format("{%s-%s}", y-1, overlap),
         2*overlap,
         2*overlap
       ))
     end
   end
 end
end

--------------------------------------------------------------------------------
-- Turn pixelart string into TikZ code, using the STACK algorithm

local function remove_zone(write, coord, read)
 -- Remove the zone of the pixel `coord`
 -- If both write and read are present (they are expected to be 2D-arrays of the same size, then read zones from `read`, and remove them from `write`.

 -- Default values for options
 if read == nil then
   read = write
 end
 if colorblind == nil then
   colorblind = false
 end

 local originalcolor = read[coord[1]][coord[2]]
 local samecolor = function(color) return color == originalcolor end

 -- Go!
 local stack = {}
 table.insert(stack, coord)
 while #stack ~= 0 do
   local current = table.remove(stack)
   for _, neighbour in pairs({
     {current[1]-1, current[2]},
     {current[1]+1, current[2]},
     {current[1], current[2]-1},
     {current[1], current[2]+1},
     }) do
     if (
       neighbour[1] >= 1 and neighbour[1] <= #read -- First coordinate inside the array
       and
       neighbour[2] >= 1 and neighbour[2] <= #read[1] -- Second coordinate inside the array
       and
       write[neighbour[1]][neighbour[2]] -- Not processed yet
       and
       samecolor(read[neighbour[1]][neighbour[2]]) -- Same color
       ) then
       table.insert(stack, neighbour)
     end
   end
   write[current[1]][current[2]] = false
 end
end

local border_transitions = {
 westtop = {
   tests = {
     {1, 1},
     {1, 0},
   },
   next = {
     ["true true"] = {
       step = {1, 1},
       state = "northleft",
       mark = {0, 0},
     },
     ["false true"] = {
       step = {1, 0},
       state = "westtop",
       mark = nil,
     },
     ["true false"] = {
       step = {0, 0},
       state = "southright",
       mark = {1, 1},
     },
     ["false false"] = {
       step = {0, 0},
       state = "southright",
       mark = {1, 1},
     },
   },
 },
 northleft = {
   tests = {
     {-1, 1},
     {0, 1},
   },
   next = {
     ["true true"] = {
       step = {-1, 1},
       state = "eastbottom",
       mark = {1, 0},
     },
     ["true false"] = {
       step = {0, 0},
       state = "westtop",
       mark = {0, 1},
     },
     ["false true"] = {
       step = {0, 1},
       state = "northleft",
       mark = nil,
     },
     ["false false"] = {
       step = {0, 0},
       state = "westtop",
       mark = {0, 1},
     },
   },
 },
 southright = {
   tests = {
     {0, -1},
     {1, -1},
   },
   next = {
     ["true true"] = {
       step = {1, -1},
       state = "westtop",
       mark = {0, 1},
     },
     ["false true"] = {
       step = {0, 0},
       state = "eastbottom",
       mark = {1, 0},
     },
     ["true false"] = {
       step = {0, -1},
       state = "southright",
       mark = nil,
     },
     ["false false"] = {
       step = {0, 0},
       state = "eastbottom",
       mark = {1, 0},
     },
   },
 },
 eastbottom = {
   tests = {
     {-1, 0},
     {-1, -1},
   },
   next = {
     ["true true"] = {
       step = {-1, -1},
       state = "southright",
       mark = {1, 1},
     },
     ["false true"] = {
       step = {0, 0},
       state = "northleft",
       mark = {0, 0},
     },
     ["true false"] = {
       step = {-1, 0},
       state = "eastbottom",
       mark = nil,
     },
     ["false false"] = {
       step = {0, 0},
       state = "northleft",
       mark = {0, 0},
     },
   },
 },
}

local function iter_border(array, start, colorblind)
 -- If colorblind==true, all colors are considered the same (that is, a pixel is either transparent or colored, with no difference between the colors)
 local samecolor
 if colorblind then
   samecolor = function(current, test)
     if (
       current[1] + test[1] < 1
       or
       current[1] + test[1] > #array
       ) then
       return false
     end
     color = array[current[1] + test[1]][current[2] + test[2]]
     if color == nil or color == false then
       return false
     end
     return (
       color == "." and array[start[1]][start[2]] == "."
       ) or (
       color ~= "." and array[start[1]][start[2]] ~= "."
       )
   end
 else
   samecolor = function(current, test)
     if (
       current[1] + test[1] < 1
       or
       current[1] + test[1] > #array
       ) then
       return false
     end
     color = array[current[1] + test[1]][current[2] + test[2]]
     return color == array[start[1]][start[2]]
   end
 end

 local current = {start[1], start[2]}
 local state = "northleft"

 return function()
   while true do
     tests = border_transitions[state].tests
     local transition = border_transitions[state].next[string.format(
       "%s %s",
       samecolor(current, tests[1]),
       samecolor(current, tests[2])
       )]
     state = transition.state
     current = {
       current[1] + transition.step[1],
       current[2] + transition.step[2]
     }
     if transition.mark then
       if current[1] == start[1] and current[2] == start[2] and state == "northleft" then
         return
       else
         return {
           current[1] + transition.mark[1],
           current[2] + transition.mark[2],
         }
       end
     end
   end
 end
end

local function iter_unprocessed_zones(array)
 -- Iterate coordinates of pixels that haven't been processed yet (their value is not false)
 local x = 1
 local y = 1

 return function()
   while not array[x][y] do
     x = x + 1
     if x > #array then
       x = 1
       y = y + 1
       if y > #array[1] then
         return
       end
     end
   end
   return {x, y}
 end
end

local function pixelart_body_stack(array, colors, options)
 -- The first argument is an array of lines, each of them being an array of colors (i.e. color of the first pixel of the line, color of the second pixel of the line, etc).
 -- Some "colors" have meaning:
 -- - false (the boolean): pixel has already been processed
 -- - "." (the character dot): pixel is transparent
 -- - anything else: the color of the pixel

 if #array == 0 then
   return
 end

 tex_print([[\begin{scope}[even odd rule] ]])

 -- Clip away transparent zones
 tex_print(string.format(
   [[\clip ({0-%s}, {0-%s}) rectangle (%s, %s) ]],
   options.margin,
   options.margin,
   #array + options.margin,
   #array[1] + options.margin
   ))

 for x = 1, #array do
   for y = 1, #array[x] do
     if array[x][y] == "." then
       tex_print(string.format("(%s, %s) rectangle ++(1, 1) ", x-1, y-1))
     end
   end
 end

 tex_print(";")

 -- Draw color zones
 for current in iter_unprocessed_zones(array) do
   local color = color2color(colors, array[current[1]][current[2]])
   if color == nil then
     -- Nothing to do: transparent zone
   else
     tex_sprint(string.format([[\fill[%s, %s] (%s, %s) ]], options.style, color, current[1]-1, current[2]-1))
     for coord in iter_border(array, current, false) do
       tex_sprint(string.format([[ -- (%s, %s) ]], coord[1]-1, coord[2]-1))
     end
     tex_print(string.format([[ -- cycle ;]]))
   end
   remove_zone(array, {current[1], current[2]})
 end

 tex_print([[\end{scope} ]])
end

--------------------------------------------------------------------------------
-- Functions pixelart() and tikzpixelart()

local function pixelart_body(str, options)
 -- Debug
 pixelart._counter = pixelart._counter + 1
 if pixelart._debug then
   io.write("\n%", str, "\n")
   io.write("% pixelart ", pixelart._counter, ", file ", status.filename, ", input line ", tex.inputlineno, "\n")
 else
   io.write("(pixelart ", pixelart._counter, ", file ", status.filename, ", input line ", tex.inputlineno)
 end

 -- Colors
 local colors
 if type(options.colors) == "table" then
   colors = options.colors
 else
   colors = pixelart._colors[options.colors]
 end

 if (pixelart._draft or options.draft) and options.draft ~= false then
   local array = str2arrays(str)
   tex_print(string.format(
     [[ \draw[pattern=checkerboard] (0, 0) rectangle (%s, %s); ]],
     #array, #array[1]
     ))
 elseif options.stack then
   pixelart_body_stack(str2arrays(str), colors, options)
 else -- options.squares is the default
   pixelart_body_squares(str2arrays(str), colors, options)
 end

 if pixelart._debug then
   -- Nothing
 else
   io.write(")")
 end
end

local function pixelart_tikzpixelart(coord, str, options)
 -- Parse options
 local options = parse(options, pixelart._default)

 if options.tikz then
   tex_sprint(string.format([[\begin{scope}[%s] ]], options.tikz))
 end
 tex_print(string.format(
   [[\begin{scope}[shift={%s}] ]],
   coord
 ))

 pixelart_body(str, options)

 tex_print([[\end{scope} ]])
 if options.tikz then
   tex_print([[\end{scope} ]])
 end
end

local function pixelart_pixelart(str, options)
 -- Parse options
 local options = parse(options, pixelart._default)
 -- Tikz environment
 tex_print([[\begin{tikzpicture}]])
 if options.tikz then
   tex_sprint(string.format("[%s]", options.tikz))
 end

 pixelart_body(str, options)

 tex_print([[\end{tikzpicture}]])
end

pixelart.pixelart = pixelart_pixelart
pixelart.tikzpixelart = pixelart_tikzpixelart