Custom GPL Export

Hi folks,

I wanted palettes to export to GPLs with more information than the Aseprite default. I wrote this script, thought I’d share.

gplExportScreenCap

In case the screen cap is misleading, this does not read any metadata from an existing palette. Afaik that’s not available from the palette object. I typed those in manually.

local defaults = {
    palName = "Palette",
    columns = 0,
    attribName = "Anonymous",
    attribUrl = "https://lospec.com/palette-list",
    useAseGpl = false
}

local dlg = Dialog {
    title = "GPL Export"
}

dlg:entry {
    id = "palName",
    label = "Palette Name:",
    text = defaults.palName,
    focus = false
}

dlg:newrow { always = false }

dlg:slider {
    id = "columns",
    label = "Columns:",
    min = 0,
    max = 16,
    value = defaults.columns
}

dlg:newrow { always = false }

dlg:entry {
    id = "attribName",
    label = "Author:",
    text = defaults.attribName,
    focus = false
}

dlg:newrow { always = false }

dlg:entry {
    id = "attribUrl",
    label = "URL:",
    text = defaults.attribUrl,
    focus = false
}

dlg:newrow { always = false }

dlg:check {
    id = "useAseGpl",
    label = "Aseprite GPL:",
    selected = defaults.useAseGpl
}

dlg:newrow { always = false }

dlg:file {
    id = "filepath",
    label = "Path:",
    filetypes = { "gpl" },
    save = true
}

dlg:newrow { always = false }

dlg:button {
    id = "ok",
    text = "&OK",
    focus = defaults.pullFocus,
    onclick = function()
        local activeSprite = app.activeSprite
        if activeSprite then
            local args = dlg.data

            -- Cache functions.
            local min = math.min
            local max = math.max
            local strfmt = string.format

            -- Unpack arguments.
            local palName = args.palName or defaults.palName
            local columns = args.columns or defaults.columns
            local attribName = args.attribName or defaults.attribName
            local attribUrl = args.attribUrl or defaults.attribUrl
            local useAseGpl = args.useAseGpl

            -- Validate arguments.
            palName = palName:sub(1, 64)
            columns = max(0, min(16, columns))
            attribName = attribName:sub(1, 64)
            attribUrl = attribUrl:sub(1, 96)

            local gplStr = strfmt(
                "GIMP Palette\nName: %s\nColumns: %d\n",
                palName, columns)

            -- https://github.com/aseprite/aseprite/
            -- blob/main/docs/gpl-palette-extension.md
            if useAseGpl then
                gplStr = gplStr .. "Channels: RGBA\n"
            end

            if attribName and #attribName > 0 then
                gplStr = gplStr .. strfmt(
                    "# Author: %s\n",
                    attribName)
            end

            if attribUrl and #attribUrl > 0 then
                gplStr = gplStr .. strfmt(
                    "# URL: %s\n",
                    attribUrl)
            end

            local colorSpace = activeSprite.colorSpace
            if colorSpace and #colorSpace.name > 0 then
                gplStr = gplStr .. strfmt(
                    "# Profile: %s\n",
                    colorSpace.name)
            else
                gplStr = gplStr .. "# Profile: None\n"
            end

            local pal = activeSprite.palettes[1]
            local palLen = #pal
            gplStr = gplStr .. strfmt(
                "# Colors: %d\n",
                palLen)

            local palLenn1 = palLen - 1
            for i = 0, palLenn1, 1 do
                local aseColor = pal:getColor(i)

                -- The Color constructor allows for values
                -- beyond the range [0, 255]; the rgbaPixel
                -- getter uses modular, not saturation, arithmetic.
                -- Shouldn't be a problem in most cases, but
                -- being conservative here.
                local r = max(0, min(255, aseColor.red))
                local g = max(0, min(255, aseColor.green))
                local b = max(0, min(255, aseColor.blue))

                if useAseGpl then
                    local a = max(0, min(255, aseColor.alpha))

                    local hexAbgr = (a << 0x18)
                        | (b << 0x10)
                        | (g << 0x08)
                        | r

                    gplStr = gplStr .. strfmt(
                        "%3d %3d %3d %3d 0x%08x",
                        r, g, b, a, hexAbgr)
                else
                    local hexRgb = (r << 0x10)
                        | (g << 0x08)
                        | b

                    gplStr = gplStr .. strfmt(
                        "%3d %3d %3d %06X",
                        r, g, b, hexRgb)
                end

                if i < palLenn1 then
                    gplStr = gplStr .. '\n'
                end
            end

            local filepath = args.filepath
            if filepath and #filepath > 0 then
                local ext = filepath:sub(-#"gpl"):lower()
                if ext ~= "gpl" then
                    app.alert("Extension is not gpl.")
                else
                    local file = io.open(filepath, "w")
                    file:write(gplStr)
                    file:close()
                end
            else
                app.alert("Filepath is empty.")
            end

            dlg:close()
        else
            app.alert("There is no active sprite.")
        end
    end
}

dlg:button {
    id = "cancel",
    text = "&CANCEL",
    onclick = function()
        dlg:close()
    end
}

dlg:show { wait = false }

Feel free to modify. I believe that the string formatting options for Lua match those of C++.

Here is a link to a version of the GPL export script in a Github repo (in case I find bugs I need to fix or decide to bloat the script with more features in the future :smiley: ).

An output file for EDG16 looks like this:

GIMP Palette
Name: EDG16
Columns: 4
# Author: ENDESGA
# URL: https://twitter.com/ENDESGA
# Profile: sRGB
# Colors: 16
228 166 114 E4A672
184 111  80 B86F50
116  63  57 743F39
 63  40  50 3F2832
158  40  53 9E2835
229  59  68 E53B44
251 146  43 FB922B
255 231  98 FFE762
 99 198  77 63C64D
 50 115  69 327345
 25  61  63 193D3F
 79 103 129 4F6781
175 191 210 AFBFD2
255 255 255 FFFFFF
 44 232 244 2CE8F4
  4 132 209 0484D1

If an Aseprite gpl is selected, then a sample of the difference in output is like this

228 166 114 255 0xff72a6e4
184 111  80 255 0xff506fb8
116  63  57 255 0xff393f74

There are four columns instead of three; alpha is the fourth number. The hexes are formatted as they would be in a Lua script: AABBGGRR with the ‘0x’ prefix for hexadecimal literal.

GIMP Palette
#
228 166 114	Untitled
184 111  80	Untitled
116  63  57	Untitled

A sample from the built-in save palette output looks like the above.

Best,
Jeremy

2 Likes

yo this is amazing,

1 Like

hey you could add a space for descriptions

Hi Ethan,

That is a neat idea. Here’s a version with descriptions. While I personally don’t have much use for such a feature, I was working on line wrapping longer text input for an insert text dialog anyway. Also, I saw that Lospec had descriptions in their GPLs.

local defaults = {
    palName = "Palette",
    columns = 0,
    attribName = "Anonymous",
    attribUrl = "https://lospec.com/palette-list",
    description = "",
    useAseGpl = false,
    nameLimit = 64,
    urlLimit = 96,
    descrLimit = 280,
    charsPerLine = 60
}

local function stringToCharTable(str)
    -- For more on different methods, see
    -- https://stackoverflow.com/a/49222705
    local chars = {}
    local strsub = string.sub
    for i = 1, #str, 1 do
        chars[i] = strsub(str, i, i)
    end
    return chars
end

local function lineWrapStringToChars(srcStr, limit)
    if srcStr and #srcStr > 0 then
        local valLimit = limit or 80
        if valLimit < 16 then valLimit = 16 end
        if valLimit > 120 then valLimit = 120 end

        local charTally = 0
        local result = {}
        local currLine = {}
        local lastSpace = 0

        local flatChars = stringToCharTable(srcStr)
        local flatCharLen = #flatChars
        for i = 1, flatCharLen, 1 do
            local srcChar = flatChars[i]
            if srcChar == '\n' or srcChar == '\r' then
                if #currLine < 1 then currLine = { '' } end
                table.insert(result, currLine)
                currLine = {}
                charTally = 0
                lastSpace = 0
            else
                table.insert(currLine, srcChar)
                local currLnLen = #currLine

                if srcChar == ' ' or srcChar == '\t' then
                    lastSpace = #currLine
                end

                if charTally < valLimit then
                    charTally = charTally + 1
                else
                    -- Trace back to last space.
                    -- The greater than half the char length condition
                    -- is to handle problematic words like
                    -- "supercalifragilisticexpialidocious".
                    local excess = {}
                    if lastSpace > 0 and lastSpace > currLnLen // 2 then
                        for j = currLnLen, lastSpace + 1, -1 do
                                table.insert(excess, 1,
                                    table.remove(currLine, j))
                        end
                    else
                        excess[1] = srcChar
                    end

                    -- Remove any initial space from excess.
                    if excess[1] == ' ' or excess[1] == '\t' then
                        table.remove(excess, 1)
                    end

                    -- Remove any terminal space from line.
                    if currLine[#currLine] == ' '
                        or currLine[#currLine] == '\t' then
                        table.remove(currLine)
                    end

                    -- Append current line, create new one.
                    if #currLine < 1 then currLine = { '' } end
                    table.insert(result, currLine)
                    currLine = {}
                    charTally = 0
                    lastSpace = 0

                    -- Consume excess.
                    for k = 1, #excess, 1 do
                        table.insert(currLine, excess[k])
                        charTally = charTally + 1
                    end
                end
            end
        end

        -- Consume remaining lines.
        if #currLine > 0 then
            table.insert(result, currLine)
        end

        return result
    else
        return {{''}}
    end
end

local function lineWrapString(srcStr, limit, initToken)
    local valTok = initToken or ''
    local chars2d = lineWrapStringToChars(srcStr, limit)
    local dstStr = ""
    local len2d = #chars2d
    for i = 1, len2d, 1 do
        local chars1d = chars2d[i]
        dstStr = dstStr .. valTok
        for j = 1, #chars1d, 1 do
            dstStr = dstStr .. chars1d[j]
        end
        if i < len2d then dstStr = dstStr .. '\n' end
    end
    return dstStr
end

local dlg = Dialog {
    title = "GPL Export"
}

dlg:entry {
    id = "palName",
    label = "Palette Name:",
    text = defaults.palName,
    focus = false
}

dlg:newrow { always = false }

dlg:slider {
    id = "columns",
    label = "Columns:",
    min = 0,
    max = 16,
    value = defaults.columns
}

dlg:newrow { always = false }

dlg:entry {
    id = "attribName",
    label = "Author:",
    text = defaults.attribName,
    focus = false
}

dlg:newrow { always = false }

dlg:entry {
    id = "attribUrl",
    label = "URL:",
    text = defaults.attribUrl,
    focus = false
}

dlg:newrow { always = false }

dlg:entry {
    id = "description",
    label = "Description:",
    text = defaults.description,
    focus = false
}

dlg:newrow { always = false }

dlg:check {
    id = "useAseGpl",
    label = "Aseprite GPL:",
    selected = defaults.useAseGpl
}

dlg:newrow { always = false }

dlg:file {
    id = "filepath",
    label = "Path:",
    filetypes = { "gpl" },
    save = true
}

dlg:newrow { always = false }

dlg:button {
    id = "ok",
    text = "&OK",
    focus = defaults.pullFocus,
    onclick = function()
        local activeSprite = app.activeSprite
        if activeSprite then
            local args = dlg.data

            -- Cache functions.
            local min = math.min
            local max = math.max
            local strfmt = string.format

            -- Unpack arguments.
            local palName = args.palName or defaults.palName
            local columns = args.columns or defaults.columns
            local attribName = args.attribName or defaults.attribName
            local attribUrl = args.attribUrl or defaults.attribUrl
            local description = args.description or defaults.description
            local useAseGpl = args.useAseGpl

            -- Validate arguments.
            palName = palName:sub(1, defaults.nameLimit)
            columns = max(0, min(16, columns))
            attribName = attribName:sub(1, defaults.nameLimit)
            attribUrl = attribUrl:sub(1, defaults.urlLimit)
            description = description:sub(1, defaults.descrLimit)

            local gplStr = strfmt(
                "GIMP Palette\nName: %s\nColumns: %d\n",
                palName, columns)

            -- https://github.com/aseprite/aseprite/
            -- blob/main/docs/gpl-palette-extension.md
            if useAseGpl then
                gplStr = gplStr .. "Channels: RGBA\n"
            end

            if attribName and #attribName > 0 then
                gplStr = gplStr .. strfmt(
                    "# Author: %s\n",
                    attribName)
            end

            if attribUrl and #attribUrl > 0 then
                gplStr = gplStr .. strfmt(
                    "# URL: %s\n",
                    attribUrl)
            end

            local colorSpace = activeSprite.colorSpace
            if colorSpace and #colorSpace.name > 0 then
                gplStr = gplStr .. strfmt(
                    "# Profile: %s\n",
                    colorSpace.name)
            else
                gplStr = gplStr .. "# Profile: None\n"
            end

            local pal = activeSprite.palettes[1]
            local palLen = #pal
            gplStr = gplStr .. strfmt(
                "# Colors: %d\n",
                palLen)

            if description and #description > 0 then
                -- Two extra spaces account for the '# '
                local breakAt = defaults.charsPerLine - 2
                description = lineWrapString(description, breakAt, '# ')
                gplStr = gplStr .. strfmt(
                    "# Description:\n%s\n",
                    description)
            end

            local palLenn1 = palLen - 1
            for i = 0, palLenn1, 1 do
                local aseColor = pal:getColor(i)

                -- The Color constructor allows for values
                -- beyond the range [0, 255]; the rgbaPixel
                -- getter uses modular, not saturation, arithmetic.
                -- Shouldn't be a problem in most cases, but
                -- being conservative here.
                local r = max(0, min(255, aseColor.red))
                local g = max(0, min(255, aseColor.green))
                local b = max(0, min(255, aseColor.blue))

                if useAseGpl then
                    local a = max(0, min(255, aseColor.alpha))

                    local hexAbgr = (a << 0x18)
                        | (b << 0x10)
                        | (g << 0x08)
                        | r

                    gplStr = gplStr .. strfmt(
                        "%3d %3d %3d %3d 0x%08x",
                        r, g, b, a, hexAbgr)
                else
                    local hexRgb = (r << 0x10)
                        | (g << 0x08)
                        | b

                    gplStr = gplStr .. strfmt(
                        "%3d %3d %3d %06X",
                        r, g, b, hexRgb)
                end

                if i < palLenn1 then
                    gplStr = gplStr .. '\n'
                end
            end

            local filepath = args.filepath
            if filepath and #filepath > 0 then
                local ext = filepath:sub(-#"gpl"):lower()
                if ext ~= "gpl" then
                    app.alert("Extension is not gpl.")
                else
                    local file = io.open(filepath, "w")
                    file:write(gplStr)
                    file:close()
                end
            else
                app.alert("Filepath is empty.")
            end

            dlg:close()
        else
            app.alert("There is no active sprite.")
        end
    end
}

dlg:button {
    id = "cancel",
    text = "&CANCEL",
    onclick = function()
        dlg:close()
    end
}

dlg:show { wait = false }

The arbitrary cut-off length for a description is 280 characters, the length of a tweet. The character limit before an auto-line break is set to 60. ceiling(280 / 60) = 5 lines of description max. But then you have to account for the 2 characters occupied by the comment prefix # . As always caveat emptor. More features → more code → more mistakes.

Here is a test with the palette curiosities and some lorem ipsum.

GIMP Palette
Name: Curiosities
Columns: 0
# Author: sukinapan
# URL: https://lospec.com/palette-list/curiosities
# Profile: sRGB
# Colors: 6
# Description:
# Lorem ipsum dolor sit amet, consectetur adipiscing elit.
# Etiam dapibus, nisi eu tempus mollis, dolor nisi laoreet
# libero, nec iaculis est lorem id dui. In pulvinar varius
# laoreet. Morbi aliquet pulvinar metus, eu suscipit mi
# auctor imperdiet. Ut convallis auctor molestie. Nunc se
 70  66  94 46425E
 21 120 140 15788C
  0 185 190 00B9BE
255 238 204 FFEECC
255 176 163 FFB0A3
255 105 115 FF6973

Best,
Jeremy

1 Like