--[[ 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