Skip to content

Instantly share code, notes, and snippets.

@mattdesl
Last active December 14, 2023 21:49
Show Gist options
  • Star 17 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mattdesl/58b806478f4d33e8b91ed9c51c39014d to your computer and use it in GitHub Desktop.
Save mattdesl/58b806478f4d33e8b91ed9c51c39014d to your computer and use it in GitHub Desktop.
palette compression

code-golfing color palettes in JavaScript

If you need to code-golf a set of predefined RGB color palettes, how would you do it?

Problem: Each palette has a variable amount of RGB colors. The program output should closely match the input palettes, in string hex code format (with or without # prefix) so that it can be fed into Canvas2D APIs. Solution must be valid JavaScript code.

Proposed Solution: Turn each palette into a base64 encoded string. During decoding, use atob to convert the Base64 palette to a set of hex codes.

Other Solutions? Maybe with RGB565 or RGB444? Please comment if you have any other solutions. :)

// Polyfill so we can run this in Node.js as well
if (typeof atob !== 'function') {
var atob = a => Buffer.from(a, 'base64').toString('binary')
var btoa = b => Buffer.from(b).toString('base64');
}
// 511 bytes after minify
var a=[
["#1b6f3f", "#10c5b4", "#ade4cd", "#29ec19"],
["#96bde8", "#246a85", "#3483e4", "#b168f6"],
["#b623b1", "#6e18f9", "#49cbc1", "#5ad67d"],
["#73267b", "#c29c5b", "#6086de"],
["#e79f9b", "#772140", "#cb8c96"],
["#a45cd7", "#3d0709", "#9778c4", "#5893b6", "#40a399"],
["#6df6d1", "#5ea534", "#88bc86"],
["#6f4895", "#d5a6fc", "#3951c3", "#4e2816", "#7fd50c"],
["#58498e", "#596a5c", "#9d53ee", "#c30935", "#3a0480"],
["#67aef5", "#2eae76", "#6e2376", "#e05d7d"],
["#ca15c8", "#a98acd", "#0293c3", "#856e0d"],
["#b65cd2", "#fc2bba", "#59a669", "#2f2c99"],
];
// turn into base64 encoded strings
var txt = a.map(palette => {
const uint8 = new Uint8Array(palette.map(n => hexToRGB(n)).flat())
return btoa(uint8)
}).join(`:`)
// decompress without # prefix (313 bytes)
var k=`G28/EMW0reTNKewZ:lr3oJGqFNIPksWj2:tiOxbhj5ScvBWtZ9:cyZ7wpxbYIbe:55+bdyFAy4yW:pFzXPQcJl3jEWJO2QKOZ:bfbRXqU0iLyG:b0iV1ab8OVHDTigWf9UM:WEmOWWpcnVPuwwk1OgSA:Z671Lq52biN24F19:yhXIqYrNApPDhW4N:tlzS/Cu6WaZpLyyZ`.split`:`.map(b=>[...atob(b)].map(c=>c.charCodeAt().toString(16).padStart(2,0)).join``.match(/.{6}/g))
console.log(k);
function hexToRGB(str) {
var hex = str.replace(/^#/, "");
if (hex.length === 3) hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
var num = parseInt(hex, 16);
var red = num >> 16;
var green = (num >> 8) & 255;
var blue = num & 255;
return [red, green, blue];
}
// 511 bytes after minify
var a=[
["#1b6f3f", "#10c5b4", "#ade4cd", "#29ec19"],
["#96bde8", "#246a85", "#3483e4", "#b168f6"],
["#b623b1", "#6e18f9", "#49cbc1", "#5ad67d"],
["#73267b", "#c29c5b", "#6086de"],
["#e79f9b", "#772140", "#cb8c96"],
["#a45cd7", "#3d0709", "#9778c4", "#5893b6", "#40a399"],
["#6df6d1", "#5ea534", "#88bc86"],
["#6f4895", "#d5a6fc", "#3951c3", "#4e2816", "#7fd50c"],
["#58498e", "#596a5c", "#9d53ee", "#c30935", "#3a0480"],
["#67aef5", "#2eae76", "#6e2376", "#e05d7d"],
["#ca15c8", "#a98acd", "#0293c3", "#856e0d"],
["#b65cd2", "#fc2bba", "#59a669", "#2f2c99"],
];
// compress
const txt = a.map((n) => n.join("").replace(/\#/g, "")).join("Z");
console.log(txt);
// decompress without # symbol (342 bytes)
var p="1b6f3f10c5b4ade4cd29ec19Z96bde8246a853483e4b168f6Zb623b16e18f949cbc15ad67dZ73267bc29c5b6086deZe79f9b772140cb8c96Za45cd73d07099778c45893b640a399Z6df6d15ea53488bc86Z6f4895d5a6fc3951c34e28167fd50cZ58498e596a5c9d53eec309353a0480Z67aef52eae766e2376e05d7dZca15c8a98acd0293c3856e0dZb65cd2fc2bba59a6692f2c99".split`Z`.map(c=>c.match(/.{6}/g));
// decompress with # prefix (356 bytes)
var h="1b6f3f10c5b4ade4cd29ec19Z96bde8246a853483e4b168f6Zb623b16e18f949cbc15ad67dZ73267bc29c5b6086deZe79f9b772140cb8c96Za45cd73d07099778c45893b640a399Z6df6d15ea53488bc86Z6f4895d5a6fc3951c34e28167fd50cZ58498e596a5c9d53eec309353a0480Z67aef52eae766e2376e05d7dZca15c8a98acd0293c3856e0dZb65cd2fc2bba59a6692f2c99".split`Z`.map(c=>c.match(/.{6}/g).map(s=>'#'+s));
@mattdesl
Copy link
Author

mattdesl commented Apr 6, 2021

TODO idea: use rgb444 or rgb565 to further reduce the base64 payload, and/or use a custom ASCII encoder such as a variant of uuencoding.

The difficulty with 444 is decoding as it may require a sort of bit stream reader. Below is an option with rgb565 using Uint16Array to wrap a single color (2 bytes) into a single 16bit decimal.

// encode base64 text with rgb565 colors
var T = a.map(palette => {
  const uint16 = new Uint16Array(a.map(n => rgb888_to_rgb565(...hexToRGB(n))))
  return btoa(Buffer.from(uint16.buffer))
}).join(':')

// decode rgb565
var k=T.split`:`.map(s=>[...new Uint16Array(Uint8Array.from(atob(s),c=>c.charCodeAt()).buffer)].map(s=>[s>>8,s>>2&0xff,s<<3&0xff].map(s=>s.toString(16).padStart(2,0)).join``))

function rgb888_to_rgb565(r, g, b) {
  return ((r << 8) & 0xf800) | ((g << 2) & 0x03e0) | (b >> 3);
}

@halvves
Copy link

halvves commented Apr 6, 2021

Just messing around with other possible approaches, using base 32 numbers. Doesn't get as small as the base64 string, but still smaller than joining the hex numbers (depending on size of the palettes array).

const paletteCompress = p => p.map(c => parseInt(c.substring(1), 16).toString(32).padStart(5, '0')).join('');

// encode to base 32 number strings
const testEncode = a.map(paletteCompress).join(':');

// 342 bytes
'1mrpv11hdkarp6d2jr0p:9dff828qk5390v4b2q7m:bc8th6s67p4jiu15lljt:769jrc572r611mu:ef7sr7e8a0cn34m:a8n6n3q1o99eu645h4tm418sp:6rtmh5t99k8hf46:6ui4ldb9ns3ike34sa0m7vl8c:5gice5iqis9qkvec629l3k140:6fbnl2tbjm6s8rme0nbt:ck5e8aj2md054u38argd:bcn6ifoatq5j9j92ub4p'.split`:`.map(p=>p.match(/.{1,5}/g).map(c=>parseInt(c,32).toString(16).padStart(6,'0')));

@p01
Copy link

p01 commented Apr 11, 2021

As you are fine with RGB444 values, I thought about encoding the array of #RGB888 colors down to #RGB444 and into a string of UTF8 characters to use one character per color which would take 1-3 byte per color.

This brings the data + decoder for the list of colors you provided down to 185bytes

Hope this helps,

// an array of 48 #RGB888 colors
const RGB888Colors = [
  "#1b6f3f","#10c5b4","#ade4cd","#29ec19","#96bde8","#246a85",
  "#3483e4","#b168f6","#b623b1","#6e18f9","#49cbc1","#5ad67d",
  "#73267b","#c29c5b","#6086de","#e79f9b","#772140","#cb8c96",
  "#a45cd7","#3d0709","#9778c4","#5893b6","#40a399","#6df6d1",
  "#5ea534","#88bc86","#6f4895","#d5a6fc","#3951c3","#4e2816",
  "#7fd50c","#58498e","#596a5c","#9d53ee","#c30935","#3a0480",
  "#67aef5","#2eae76","#6e2376","#e05d7d","#ca15c8","#a98acd",
  "#0293c3","#856e0d","#b65cd2","#fc2bba","#59a669","#2f2c99"];

// Encode an array of #RGB888 colors down to RGB444 and into a string of UTF8 characters to use one character per color
String.fromCharCode(...RGB888Colors.map(n=>(parseInt(n[1]+n[3]+n[5],16))))

// This encodes our array of RGB888Colors to the following string which take 111 bytes
// "ţNj૬ˡাɨΎ୯ଫ؟ӌחܧಕڍນܤಉ੝̀ॼ֛ҩ۽֣ࢸىද͜СߐՈեफ़ః̈گʧا๗జઌ�ࡠଢ଼༫֦ȩ"

// The encoded string and decoder take 185 bytes 
"ţNj૬ˡাɨΎ୯ଫ؟ӌחܧಕڍນܤಉ੝̀ॼ֛ҩ۽֣ࢸىද͜СߐՈեफ़ః̈گʧا๗జઌ�ࡠଢ଼༫֦ȩ".split("").map(k=>'#'+`00${k.charCodeAt(0).toString(16)}`.slice(-3))

// Which returns an array of #RGB444 values
//  ["#163", "#1cb", "#aec", "#2e1", "#9be", "#268", "#38e", "#b6f", "#b2b", "#61f", "#4cc", "#5d7", "#727", "#c95", "#68d", "#e99", "#724", "#c89", "#a5d", "#300", "#97c", "#59b", "#4a9", "#6fd", "#5a3", "#8b8", "#649", "#daf", "#35c", "#421", "#7d0", "#548", "#565", "#95e", "#c03", "#308", "#6af", "#2a7", "#627", "#e57", "#c1c", "#a8c", "#09c", "#860", "#b5d", "#f2b", "#5a6", "#229"]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment