From da1789814246758cb28a5d31eeb40c5e94ff9c19 Mon Sep 17 00:00:00 2001
From: Louis <contact@louiscap.co>
Date: Sat, 1 Feb 2025 04:53:24 +0000
Subject: [PATCH] Create library module and factor out utilities into library
 functions

---
 Cargo.lock                          | 592 +++++++++++++++++++++++++++-
 Cargo.toml                          |   9 +-
 rustfmt.toml                        |   1 -
 src/actions/colours.rs              |  36 ++
 src/actions/mod.rs                  |   3 +
 src/cli_args.rs                     |  18 +-
 src/commands/palette.rs             | 146 +------
 src/commands/swap.rs                |  42 +-
 src/lib.rs                          |   2 +
 src/main.rs                         |   7 +-
 src/{utils.rs => utils/colours.rs}  |  20 +-
 src/utils/files.rs                  |  37 ++
 src/{format.rs => utils/formats.rs} |  56 ++-
 src/utils/mapping.rs                | 155 ++++++++
 src/utils/mod.rs                    |  13 +
 src/utils/palette.rs                |  89 +++++
 16 files changed, 988 insertions(+), 238 deletions(-)
 create mode 100644 src/actions/colours.rs
 create mode 100644 src/actions/mod.rs
 create mode 100644 src/lib.rs
 rename src/{utils.rs => utils/colours.rs} (95%)
 create mode 100644 src/utils/files.rs
 rename src/{format.rs => utils/formats.rs} (53%)
 create mode 100644 src/utils/mapping.rs
 create mode 100644 src/utils/mod.rs
 create mode 100644 src/utils/palette.rs

diff --git a/Cargo.lock b/Cargo.lock
index 61d2e7a..e68cfc0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -17,6 +17,12 @@ dependencies = [
  "memchr",
 ]
 
+[[package]]
+name = "aligned-vec"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4aa90d7ce82d4be67b64039a3d588d38dbcc6736577de4a847025ce5b0c468d1"
+
 [[package]]
 name = "anstream"
 version = "0.6.18"
@@ -72,12 +78,58 @@ version = "1.0.94"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c1fd03a028ef38ba2276dce7e33fcd6369c158a1bca17946c4b1b701891c1ff7"
 
+[[package]]
+name = "arbitrary"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223"
+
+[[package]]
+name = "arg_enum_proc_macro"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "arrayvec"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
+
 [[package]]
 name = "autocfg"
 version = "1.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
 
+[[package]]
+name = "av1-grain"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6678909d8c5d46a42abcf571271e15fdbc0a225e3646cf23762cd415046c78bf"
+dependencies = [
+ "anyhow",
+ "arrayvec",
+ "log",
+ "nom",
+ "num-rational",
+ "v_frame",
+]
+
+[[package]]
+name = "avif-serialize"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e335041290c43101ca215eed6f43ec437eb5a42125573f600fc3fa42b9bddd62"
+dependencies = [
+ "arrayvec",
+]
+
 [[package]]
 name = "bit_field"
 version = "0.10.2"
@@ -90,6 +142,24 @@ version = "1.3.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
 
+[[package]]
+name = "bitstream-io"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6099cdc01846bc367c4e7dd630dc5966dccf36b652fae7a74e17b640411a91b2"
+
+[[package]]
+name = "built"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c360505aed52b7ec96a3636c3f039d99103c37d1d9b4f7a8c743d3ea9ffcd03b"
+
+[[package]]
+name = "bumpalo"
+version = "3.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf"
+
 [[package]]
 name = "bytemuck"
 version = "1.20.0"
@@ -102,6 +172,33 @@ version = "1.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
 
+[[package]]
+name = "byteorder-lite"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
+
+[[package]]
+name = "cc"
+version = "1.2.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4730490333d58093109dc02c23174c3f4d490998c3fed3cc8e82d57afedb9cf"
+dependencies = [
+ "jobserver",
+ "libc",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-expr"
+version = "0.15.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02"
+dependencies = [
+ "smallvec",
+ "target-lexicon",
+]
+
 [[package]]
 name = "cfg-if"
 version = "1.0.0"
@@ -212,7 +309,7 @@ dependencies = [
  "rayon",
  "serde",
  "serde_json",
- "thiserror",
+ "thiserror 2.0.11",
  "toml",
 ]
 
@@ -316,6 +413,17 @@ dependencies = [
  "miniz_oxide",
 ]
 
+[[package]]
+name = "getrandom"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
 [[package]]
 name = "gif"
 version = "0.13.1"
@@ -368,22 +476,43 @@ checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
 
 [[package]]
 name = "image"
-version = "0.24.9"
+version = "0.25.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d"
+checksum = "cd6f44aed642f18953a158afeb30206f4d50da59fbc66ecb53c66488de73563b"
 dependencies = [
  "bytemuck",
- "byteorder",
+ "byteorder-lite",
  "color_quant",
  "exr",
  "gif",
- "jpeg-decoder",
+ "image-webp",
  "num-traits",
  "png",
  "qoi",
+ "ravif",
+ "rayon",
+ "rgb",
  "tiff",
+ "zune-core",
+ "zune-jpeg",
+]
+
+[[package]]
+name = "image-webp"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b77d01e822461baa8409e156015a1d91735549f0f2c17691bd2d996bef238f7f"
+dependencies = [
+ "byteorder-lite",
+ "quick-error",
 ]
 
+[[package]]
+name = "imgref"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0263a3d970d5c054ed9312c0057b4f3bde9c0b33836d3637361d4a9e6e7a408"
+
 [[package]]
 name = "indexmap"
 version = "2.7.0"
@@ -394,26 +523,52 @@ dependencies = [
  "hashbrown",
 ]
 
+[[package]]
+name = "interpolate_name"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "is_terminal_polyfill"
 version = "1.70.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
 
+[[package]]
+name = "itertools"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
+dependencies = [
+ "either",
+]
+
 [[package]]
 name = "itoa"
 version = "1.0.14"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674"
 
+[[package]]
+name = "jobserver"
+version = "0.1.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0"
+dependencies = [
+ "libc",
+]
+
 [[package]]
 name = "jpeg-decoder"
 version = "0.3.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0"
-dependencies = [
- "rayon",
-]
 
 [[package]]
 name = "lab"
@@ -427,18 +582,59 @@ version = "0.5.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8"
 
+[[package]]
+name = "libc"
+version = "0.2.169"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a"
+
+[[package]]
+name = "libfuzzer-sys"
+version = "0.4.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf78f52d400cf2d84a3a973a78a592b4adc535739e0a5597a0da6f0c357adc75"
+dependencies = [
+ "arbitrary",
+ "cc",
+]
+
 [[package]]
 name = "log"
 version = "0.4.22"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
 
+[[package]]
+name = "loop9"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062"
+dependencies = [
+ "imgref",
+]
+
+[[package]]
+name = "maybe-rayon"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519"
+dependencies = [
+ "cfg-if",
+ "rayon",
+]
+
 [[package]]
 name = "memchr"
 version = "2.7.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
 
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
 [[package]]
 name = "miniz_oxide"
 version = "0.8.2"
@@ -449,6 +645,69 @@ dependencies = [
  "simd-adler32",
 ]
 
+[[package]]
+name = "new_debug_unreachable"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
+
+[[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
+[[package]]
+name = "noop_proc_macro"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
+
+[[package]]
+name = "num-bigint"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-derive"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "num-integer"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-rational"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
+dependencies = [
+ "num-bigint",
+ "num-integer",
+ "num-traits",
+]
+
 [[package]]
 name = "num-traits"
 version = "0.2.19"
@@ -458,6 +717,24 @@ dependencies = [
  "autocfg",
 ]
 
+[[package]]
+name = "once_cell"
+version = "1.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775"
+
+[[package]]
+name = "paste"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2"
+
 [[package]]
 name = "png"
 version = "0.17.15"
@@ -471,6 +748,15 @@ dependencies = [
  "miniz_oxide",
 ]
 
+[[package]]
+name = "ppv-lite86"
+version = "0.2.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04"
+dependencies = [
+ "zerocopy",
+]
+
 [[package]]
 name = "proc-macro2"
 version = "1.0.92"
@@ -480,6 +766,25 @@ dependencies = [
  "unicode-ident",
 ]
 
+[[package]]
+name = "profiling"
+version = "1.0.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "afbdc74edc00b6f6a218ca6a5364d6226a259d4b8ea1af4a0ea063f27e179f4d"
+dependencies = [
+ "profiling-procmacros",
+]
+
+[[package]]
+name = "profiling-procmacros"
+version = "1.0.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a65f2e60fbf1063868558d69c6beacf412dc755f9fc020f514b7955fc914fe30"
+dependencies = [
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "qoi"
 version = "0.4.1"
@@ -489,6 +794,12 @@ dependencies = [
  "bytemuck",
 ]
 
+[[package]]
+name = "quick-error"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
+
 [[package]]
 name = "quote"
 version = "1.0.37"
@@ -498,6 +809,86 @@ dependencies = [
  "proc-macro2",
 ]
 
+[[package]]
+name = "rand"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
+dependencies = [
+ "libc",
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom",
+]
+
+[[package]]
+name = "rav1e"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd87ce80a7665b1cce111f8a16c1f3929f6547ce91ade6addf4ec86a8dda5ce9"
+dependencies = [
+ "arbitrary",
+ "arg_enum_proc_macro",
+ "arrayvec",
+ "av1-grain",
+ "bitstream-io",
+ "built",
+ "cfg-if",
+ "interpolate_name",
+ "itertools",
+ "libc",
+ "libfuzzer-sys",
+ "log",
+ "maybe-rayon",
+ "new_debug_unreachable",
+ "noop_proc_macro",
+ "num-derive",
+ "num-traits",
+ "once_cell",
+ "paste",
+ "profiling",
+ "rand",
+ "rand_chacha",
+ "simd_helpers",
+ "system-deps",
+ "thiserror 1.0.69",
+ "v_frame",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "ravif"
+version = "0.11.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2413fd96bd0ea5cdeeb37eaf446a22e6ed7b981d792828721e74ded1980a45c6"
+dependencies = [
+ "avif-serialize",
+ "imgref",
+ "loop9",
+ "quick-error",
+ "rav1e",
+ "rayon",
+ "rgb",
+]
+
 [[package]]
 name = "rayon"
 version = "1.10.0"
@@ -547,6 +938,18 @@ version = "0.8.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
 
+[[package]]
+name = "rgb"
+version = "0.8.50"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57397d16646700483b67d2dd6511d79318f9d057fdbd21a4066aeac8b41d310a"
+
+[[package]]
+name = "rustversion"
+version = "1.0.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4"
+
 [[package]]
 name = "ryu"
 version = "1.0.18"
@@ -594,12 +997,27 @@ dependencies = [
  "serde",
 ]
 
+[[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
 [[package]]
 name = "simd-adler32"
 version = "0.3.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe"
 
+[[package]]
+name = "simd_helpers"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6"
+dependencies = [
+ "quote",
+]
+
 [[package]]
 name = "smallvec"
 version = "1.13.2"
@@ -629,13 +1047,41 @@ dependencies = [
  "unicode-ident",
 ]
 
+[[package]]
+name = "system-deps"
+version = "6.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349"
+dependencies = [
+ "cfg-expr",
+ "heck",
+ "pkg-config",
+ "toml",
+ "version-compare",
+]
+
+[[package]]
+name = "target-lexicon"
+version = "0.12.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
+
 [[package]]
 name = "thiserror"
 version = "1.0.69"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
 dependencies = [
- "thiserror-impl",
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc"
+dependencies = [
+ "thiserror-impl 2.0.11",
 ]
 
 [[package]]
@@ -649,6 +1095,17 @@ dependencies = [
  "syn",
 ]
 
+[[package]]
+name = "thiserror-impl"
+version = "2.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "tiff"
 version = "0.9.1"
@@ -706,6 +1163,87 @@ version = "0.2.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
 
+[[package]]
+name = "v_frame"
+version = "0.3.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f32aaa24bacd11e488aa9ba66369c7cd514885742c9fe08cfe85884db3e92b"
+dependencies = [
+ "aligned-vec",
+ "num-traits",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "version-compare"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b"
+
+[[package]]
+name = "wasi"
+version = "0.11.0+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+]
+
+[[package]]
+name = "wasm-bindgen-backend"
+version = "0.2.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6"
+dependencies = [
+ "bumpalo",
+ "log",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-backend",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d"
+dependencies = [
+ "unicode-ident",
+]
+
 [[package]]
 name = "weezl"
 version = "0.1.8"
@@ -794,6 +1332,33 @@ dependencies = [
  "memchr",
 ]
 
+[[package]]
+name = "zerocopy"
+version = "0.7.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0"
+dependencies = [
+ "byteorder",
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.7.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zune-core"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
+
 [[package]]
 name = "zune-inflate"
 version = "0.2.54"
@@ -802,3 +1367,12 @@ checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02"
 dependencies = [
  "simd-adler32",
 ]
+
+[[package]]
+name = "zune-jpeg"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "99a5bab8d7dedf81405c4bb1f2b83ea057643d9cb28778cea9eecddeedd2e028"
+dependencies = [
+ "zune-core",
+]
diff --git a/Cargo.toml b/Cargo.toml
index 87e0040..5bc2f38 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -16,17 +16,22 @@ authors = [
 name = "crunch"
 path = "src/main.rs"
 
+[features]
+default = ["serialise_types", "filesystem"]
+serialise_types = []
+filesystem = []
+
 [dependencies]
 clap = { version = "4.5", features = ["derive"] }
 env_logger = "0.11"
 log = "0.4"
 
 anyhow = "1.0"
-thiserror = "1.0"
+thiserror = "2.0"
 
 deltae = "0.3"
 glam = "0.29"
-image = "0.24"
+image = "0.25"
 lab = "0.11"
 
 num-traits = "0.2"
diff --git a/rustfmt.toml b/rustfmt.toml
index d62aed7..2b6f66e 100644
--- a/rustfmt.toml
+++ b/rustfmt.toml
@@ -1,4 +1,3 @@
 hard_tabs = true
-#group_imports = "StdExternalCrate"
 use_field_init_shorthand = true
 use_try_shorthand = true
\ No newline at end of file
diff --git a/src/actions/colours.rs b/src/actions/colours.rs
new file mode 100644
index 0000000..3864f82
--- /dev/null
+++ b/src/actions/colours.rs
@@ -0,0 +1,36 @@
+use crate::utils::{new_image, BasicRgba, OutputFormat, PaletteMap};
+use image::{GenericImage, Pixel, Rgba};
+use num_traits::ToPrimitive;
+
+pub fn apply_colour_map(colour_map: &PaletteMap, image: &impl GenericImage) -> OutputFormat {
+	let mut output = new_image(image.width(), image.height());
+	for (x, y, pixel) in image.pixels() {
+		let pixel = pixel.to_rgba();
+
+		if pixel.0[3].to_u8().unwrap() == 0 {
+			output.put_pixel(x, y, BasicRgba::transparent().into());
+			continue;
+		}
+
+		let data = Rgba::from([
+			pixel.0[0].to_u8().unwrap(),
+			pixel.0[1].to_u8().unwrap(),
+			pixel.0[2].to_u8().unwrap(),
+			pixel.0[3].to_u8().unwrap(),
+		]);
+		let basic = BasicRgba::from(data);
+
+		match colour_map.get(&basic) {
+			Some(mapped_data) => output.put_pixel(
+				x,
+				y,
+				Rgba::from(BasicRgba {
+					a: basic.a,
+					..*mapped_data
+				}),
+			),
+			None => output.put_pixel(x, y, data),
+		};
+	}
+	output
+}
diff --git a/src/actions/mod.rs b/src/actions/mod.rs
new file mode 100644
index 0000000..1b6859e
--- /dev/null
+++ b/src/actions/mod.rs
@@ -0,0 +1,3 @@
+mod colours;
+
+pub use colours::apply_colour_map;
diff --git a/src/cli_args.rs b/src/cli_args.rs
index af4ea5d..a53429e 100644
--- a/src/cli_args.rs
+++ b/src/cli_args.rs
@@ -1,13 +1,11 @@
-use clap::Parser;
-use image::ImageFormat;
-use serde::{Deserialize, Serialize};
-
 use crate::commands::{
 	Atlas, Extract, Extrude, Flip, Info, Palette, Pipeline, Reduce, Remap, Rotate, Scale, Split,
 	Swap,
 };
-
-use crate::load_image;
+use clap::Parser;
+use crunch_cli::utils::{load_image, ColourPalette, PaletteMap};
+use image::ImageFormat;
+use serde::{Deserialize, Serialize};
 
 /// Crunch is a set of utilities for quickly and easily processing a batch of files, either directly
 /// or by defining pipelines
@@ -98,10 +96,10 @@ impl Args {
 				let image_data = load_image(&remap.input, None)?;
 				let palette_data = load_image(&remap.palette, None)?;
 
-				let image_palette = Palette::extract_from(&image_data)?;
-				let target_palette = Palette::extract_from(&palette_data)?;
+				let image_palette = ColourPalette::from(&image_data);
+				let target_palette =  ColourPalette::from(&palette_data);
 
-				let mappings = Palette::calculate_mapping(&image_palette, &target_palette);
+				let mappings = PaletteMap::calculate_mapping(&image_palette, &target_palette);
 				let output = Remap::remap_image(image_data, mappings)?;
 
 				output
@@ -111,7 +109,7 @@ impl Args {
 			Args::Swap(swap) => {
 				let image_data = load_image(&swap.input, None)?;
 				let palette_data = load_image(&swap.palette, None)?;
-				let palette_swap = Palette::from_columns(&palette_data)?;
+				let palette_swap = PaletteMap::from_column_image(&palette_data)?;
 				let output = Swap::swap_pixels(image_data, palette_swap)?;
 				output
 					.save_with_format(&swap.output, ImageFormat::Png)
diff --git a/src/commands/palette.rs b/src/commands/palette.rs
index f74461c..5be4a31 100644
--- a/src/commands/palette.rs
+++ b/src/commands/palette.rs
@@ -1,40 +1,11 @@
 use clap::Parser;
-use std::cmp::{min, Ordering};
-use std::collections::hash_map::RandomState;
-use std::collections::{HashMap, HashSet};
+use std::cmp::min;
 
-use anyhow::Error;
-use deltae::{Delta, LabValue, DE2000};
+use crunch_cli::utils::{new_image, BasicRgba, ColourPalette, PaletteFormat, PaletteMap};
 use image::{GenericImage, Pixel, Rgba};
-use num_traits::ToPrimitive;
 use serde::{Deserialize, Serialize};
 use std::io::Write;
 
-use crate::format::PaletteFormat;
-use crate::utils::{new_image, BasicRgba};
-
-pub type PixelPalette = Vec<BasicRgba>;
-pub type ColourMapping = HashMap<BasicRgba, BasicRgba>;
-
-pub fn sort_by_hue(palette: &mut PixelPalette) {
-	palette.sort_by(|pa, pb| {
-		let hue_a = pa.hue();
-		let hue_b = pb.hue();
-
-		if hue_a.is_nan() && hue_b.is_nan() {
-			Ordering::Equal
-		} else if hue_a.is_nan() {
-			Ordering::Less
-		} else if hue_b.is_nan() || hue_a > hue_b {
-			Ordering::Greater
-		} else if hue_b > hue_a {
-			Ordering::Less
-		} else {
-			Ordering::Equal
-		}
-	});
-}
-
 /// Create a palette file containing every distinct colour from the input image
 #[derive(Parser, Clone, Serialize, Deserialize, Debug)]
 #[clap(author, version = "0.9.0")]
@@ -55,27 +26,12 @@ pub struct Palette {
 
 impl Palette {
 	pub fn run(&self, image: &impl GenericImage) -> anyhow::Result<()> {
-		let palette = Palette::extract_from(image)?;
+		let palette = ColourPalette::from(image);
 		self.write_palette(palette)
 	}
 
-	pub fn extract_from(image: &impl GenericImage) -> anyhow::Result<PixelPalette> {
-		let mut colours = HashSet::new();
-		for (_, _, pixel) in image.pixels() {
-			let pixel = pixel.to_rgba();
-			colours.insert(Rgba::from([
-				pixel.0[0].to_u8().unwrap(),
-				pixel.0[1].to_u8().unwrap(),
-				pixel.0[2].to_u8().unwrap(),
-				pixel.0[3].to_u8().unwrap(),
-			]));
-		}
-
-		Ok(colours.iter().map(BasicRgba::from).collect())
-	}
-
-	pub fn write_palette(&self, mut colours: PixelPalette) -> anyhow::Result<()> {
-		sort_by_hue(&mut colours);
+	pub fn write_palette(&self, mut colours: ColourPalette) -> anyhow::Result<()> {
+		colours.sort_by_hue();
 
 		match self.format {
 			PaletteFormat::Png => {
@@ -121,96 +77,4 @@ impl Palette {
 
 		Ok(())
 	}
-
-	pub fn from_columns(image: &impl GenericImage) -> anyhow::Result<ColourMapping> {
-		if image.width() != 2 {
-			return Err(Error::msg("Image must have a pixel width of 2"))?;
-		}
-
-		let mut mapping = ColourMapping::with_capacity(image.height() as usize);
-		for y in 0..image.height() {
-			let left = image.get_pixel(0, y);
-			let right = image.get_pixel(1, y);
-			let left = left.to_rgba();
-			let right = right.to_rgba();
-			let data = Rgba::from([
-				left.0[0].to_u8().unwrap(),
-				left.0[1].to_u8().unwrap(),
-				left.0[2].to_u8().unwrap(),
-				left.0[3].to_u8().unwrap(),
-			]);
-			let left_pixel = BasicRgba::from(data);
-			let data = Rgba::from([
-				right.0[0].to_u8().unwrap(),
-				right.0[1].to_u8().unwrap(),
-				right.0[2].to_u8().unwrap(),
-				right.0[3].to_u8().unwrap(),
-			]);
-			let right_pixel = BasicRgba::from(data);
-			mapping.insert(left_pixel, right_pixel);
-		}
-
-		Ok(mapping)
-	}
-
-	pub fn calculate_mapping(from: &PixelPalette, to: &PixelPalette) -> ColourMapping {
-		let colour_labs = Vec::from_iter(to.iter().map(LabValue::from));
-
-		let to_palette_vectors: HashMap<usize, &BasicRgba, RandomState> =
-			HashMap::from_iter(to.iter().enumerate());
-		let mut out_map: ColourMapping = HashMap::with_capacity(from.len());
-
-		for colour in from {
-			let closest = to_palette_vectors
-				.keys()
-				.fold(None, |lowest, idx| match lowest {
-					Some(num) => {
-						let current = colour_labs[*idx];
-						let previous: LabValue = colour_labs[num];
-
-						if colour.delta(current, DE2000) < colour.delta(previous, DE2000) {
-							Some(*idx)
-						} else {
-							Some(num)
-						}
-					}
-					None => Some(*idx),
-				});
-
-			match closest {
-				Some(idx) => match to_palette_vectors.get(&idx) {
-					Some(col) => {
-						out_map.insert(*colour, **col);
-					}
-					None => {
-						log::warn!("No matching set for {} with col {:?}", idx, &colour);
-
-						out_map.insert(
-							*colour,
-							BasicRgba {
-								r: 0,
-								g: 0,
-								b: 0,
-								a: 0,
-							},
-						);
-					}
-				},
-				None => {
-					log::warn!("No closest for {:?}", &colour);
-					out_map.insert(
-						*colour,
-						BasicRgba {
-							r: 0,
-							g: 0,
-							b: 0,
-							a: 0,
-						},
-					);
-				}
-			}
-		}
-
-		out_map
-	}
 }
diff --git a/src/commands/swap.rs b/src/commands/swap.rs
index c8e772e..8491951 100644
--- a/src/commands/swap.rs
+++ b/src/commands/swap.rs
@@ -1,11 +1,9 @@
-use crate::commands::palette::ColourMapping;
 use clap::Parser;
-use image::{GenericImage, Pixel, Rgba};
-use num_traits::ToPrimitive;
+use crunch_cli::actions::apply_colour_map;
+use crunch_cli::utils::{OutputFormat, PaletteMap};
+use image::GenericImage;
 use serde::{Deserialize, Serialize};
 
-use crate::utils::{new_image, BasicRgba, OutputFormat};
-
 /// Use an existing colour mapping file to swap colours in the input sprite
 #[derive(Debug, Clone, Parser, Serialize, Deserialize)]
 #[clap(author, version = "0.9.0")]
@@ -25,38 +23,8 @@ pub struct Swap {
 impl Swap {
 	pub fn swap_pixels(
 		image: impl GenericImage,
-		mappings: ColourMapping,
+		mappings: PaletteMap,
 	) -> anyhow::Result<OutputFormat> {
-		let mut output = new_image(image.width(), image.height());
-		for (x, y, pixel) in image.pixels() {
-			let pixel = pixel.to_rgba();
-
-			if pixel.0[3].to_u8().unwrap() == 0 {
-				output.put_pixel(x, y, Rgba::from(BasicRgba::transparent()));
-				continue;
-			}
-
-			let data = Rgba::from([
-				pixel.0[0].to_u8().unwrap(),
-				pixel.0[1].to_u8().unwrap(),
-				pixel.0[2].to_u8().unwrap(),
-				pixel.0[3].to_u8().unwrap(),
-			]);
-			let basic = BasicRgba::from(data);
-
-			match mappings.get(&basic) {
-				Some(mapped_data) => output.put_pixel(
-					x,
-					y,
-					Rgba::from(BasicRgba {
-						a: basic.a,
-						..*mapped_data
-					}),
-				),
-				None => output.put_pixel(x, y, data),
-			};
-		}
-
-		Ok(output)
+		Ok(apply_colour_map(&mappings, &image))
 	}
 }
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..282555a
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,2 @@
+pub mod actions;
+pub mod utils;
diff --git a/src/main.rs b/src/main.rs
index 3338e4e..665d400 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,13 +1,14 @@
+#[cfg(feature = "filesystem")]
 mod cli_args;
+
 mod commands;
-mod format;
-mod utils;
 
 use clap::Parser;
 
 use crate::cli_args::Args;
-use crate::format::load_image;
 
+// This might look silly, but we want compilation of the CLI to fail if we disable filesystem support
+#[cfg(feature = "filesystem")]
 fn main() -> anyhow::Result<(), anyhow::Error> {
 	env_logger::Builder::from_env("LOG_LEVEL").init();
 	let args: Args = Args::parse();
diff --git a/src/utils.rs b/src/utils/colours.rs
similarity index 95%
rename from src/utils.rs
rename to src/utils/colours.rs
index eb21cbe..dbc2083 100644
--- a/src/utils.rs
+++ b/src/utils/colours.rs
@@ -33,9 +33,8 @@ pub type RgbaOutputFormat = TypedOutputFormat<RgbaImage>;
 
 pub fn new_image(new_width: u32, new_height: u32) -> OutputFormat {
 	let mut new_image = RgbaImage::new(new_width, new_height);
-	let (new_image_x, new_image_y, new_image_width, new_image_height) = new_image.bounds();
-	for x in new_image_x..new_image_width {
-		for y in new_image_y..new_image_height {
+	for x in 0..new_width {
+		for y in 0..new_height {
 			new_image.put_pixel(x, y, Rgba::from([0, 0, 0, 0]));
 		}
 	}
@@ -43,6 +42,10 @@ pub fn new_image(new_width: u32, new_height: u32) -> OutputFormat {
 }
 
 #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
+#[cfg_attr(
+	feature = "serialise_types",
+	derive(serde::Serialize, serde::Deserialize)
+)]
 pub struct BasicRgba {
 	pub r: u8,
 	pub g: u8,
@@ -86,6 +89,17 @@ impl BasicRgba {
 	}
 }
 
+impl From<[u8; 4]> for BasicRgba {
+	fn from(value: [u8; 4]) -> Self {
+		Self {
+			r: value[0],
+			g: value[1],
+			b: value[2],
+			a: value[3],
+		}
+	}
+}
+
 impl From<Rgba<u8>> for BasicRgba {
 	fn from(other: Rgba<u8>) -> Self {
 		Self {
diff --git a/src/utils/files.rs b/src/utils/files.rs
new file mode 100644
index 0000000..eaa98b8
--- /dev/null
+++ b/src/utils/files.rs
@@ -0,0 +1,37 @@
+use crate::utils::Format;
+use image::io::Reader;
+use image::{ImageError, RgbaImage};
+use std::path::Path;
+use thiserror::Error;
+
+#[derive(Error, Debug)]
+pub enum ImageLoadingError {
+	#[error("Failed to load image data; {0}")]
+	IoError(#[from] std::io::Error),
+	#[error("Failed to process image; {0}")]
+	ImageError(#[from] ImageError),
+}
+
+pub fn load_image(
+	path: impl ToString,
+	format: Option<Format>,
+) -> anyhow::Result<RgbaImage, ImageLoadingError> {
+	let mut image = Reader::open(path.to_string())?;
+	let file = match format {
+		Some(format) => {
+			image.set_format(format.as_image_format());
+			image.decode()?.into_rgba8()
+		}
+		None => image.with_guessed_format()?.decode()?.into_rgba8(),
+	};
+
+	Ok(file)
+}
+
+pub fn make_paths<T: AsRef<Path>>(path: T) -> anyhow::Result<(), std::io::Error> {
+	if let Some(target) = path.as_ref().parent() {
+		std::fs::create_dir_all(target)
+	} else {
+		Ok(())
+	}
+}
diff --git a/src/format.rs b/src/utils/formats.rs
similarity index 53%
rename from src/format.rs
rename to src/utils/formats.rs
index 9d35924..18be803 100644
--- a/src/format.rs
+++ b/src/utils/formats.rs
@@ -30,6 +30,30 @@ pub enum Format {
 	Bmp,
 }
 
+impl From<Format> for ImageFormat {
+	fn from(value: Format) -> Self {
+		value.as_image_format()
+	}
+}
+
+impl TryFrom<ImageFormat> for Format {
+	type Error = anyhow::Error;
+
+	fn try_from(value: ImageFormat) -> Result<Self, Self::Error> {
+		use Format::*;
+		match value {
+			ImageFormat::Png => Ok(Png),
+			ImageFormat::Jpeg => Ok(Jpg),
+			ImageFormat::Gif => Ok(Gif),
+			ImageFormat::Ico => Ok(Ico),
+			ImageFormat::Tga => Ok(Tga),
+			ImageFormat::Tiff => Ok(Tiff),
+			ImageFormat::Bmp => Ok(Bmp),
+			_ => Err(anyhow::anyhow!("Unsupported image format")),
+		}
+	}
+}
+
 impl Format {
 	pub fn as_image_format(&self) -> ImageFormat {
 		use Format::*;
@@ -44,35 +68,3 @@ impl Format {
 		}
 	}
 }
-
-#[derive(Error, Debug)]
-pub enum ImageLoadingError {
-	#[error("Failed to load image data; {0}")]
-	IoError(#[from] std::io::Error),
-	#[error("Failed to process image; {0}")]
-	ImageError(#[from] ImageError),
-}
-
-pub fn load_image(
-	path: impl ToString,
-	format: Option<Format>,
-) -> anyhow::Result<RgbaImage, ImageLoadingError> {
-	let mut image = Reader::open(path.to_string())?;
-	let file = match format {
-		Some(format) => {
-			image.set_format(format.as_image_format());
-			image.decode()?.into_rgba8()
-		}
-		None => image.with_guessed_format()?.decode()?.into_rgba8(),
-	};
-
-	Ok(file)
-}
-
-pub fn make_paths<T: AsRef<Path>>(path: T) -> anyhow::Result<(), std::io::Error> {
-	if let Some(target) = path.as_ref().parent() {
-		std::fs::create_dir_all(target)
-	} else {
-		Ok(())
-	}
-}
diff --git a/src/utils/mapping.rs b/src/utils/mapping.rs
new file mode 100644
index 0000000..46cdb59
--- /dev/null
+++ b/src/utils/mapping.rs
@@ -0,0 +1,155 @@
+use crate::utils::palette::ColourPalette;
+use crate::utils::BasicRgba;
+use anyhow::Error;
+use deltae::{Delta, LabValue, DE2000};
+use image::{GenericImage, Pixel, Rgba};
+use num_traits::ToPrimitive;
+use std::collections::HashMap;
+use std::hash::RandomState;
+use std::ops::{Deref, DerefMut};
+
+#[derive(Clone, Debug)]
+#[cfg_attr(
+	feature = "serialise_types",
+	derive(serde::Serialize, serde::Deserialize)
+)]
+#[cfg_attr(feature = "serialise_types", serde(transparent))]
+pub struct PaletteMap(HashMap<BasicRgba, BasicRgba>);
+impl Deref for PaletteMap {
+	type Target = HashMap<BasicRgba, BasicRgba>;
+	fn deref(&self) -> &Self::Target {
+		&self.0
+	}
+}
+impl DerefMut for PaletteMap {
+	fn deref_mut(&mut self) -> &mut Self::Target {
+		&mut self.0
+	}
+}
+impl From<HashMap<BasicRgba, BasicRgba>> for PaletteMap {
+	fn from(other: HashMap<BasicRgba, BasicRgba>) -> Self {
+		PaletteMap(other)
+	}
+}
+impl FromIterator<(BasicRgba, BasicRgba)> for PaletteMap {
+	fn from_iter<T: IntoIterator<Item = (BasicRgba, BasicRgba)>>(iter: T) -> Self {
+		<PaletteMap as From<HashMap<BasicRgba, BasicRgba>>>::from(iter.into_iter().collect())
+	}
+}
+
+impl PaletteMap {
+	/// Create a new mapping between colours with no entries, and no space allocated.
+	pub fn empty() -> Self {
+		PaletteMap(HashMap::new())
+	}
+
+	/// Create a new, empty mapping between colours with a given amount of preallocated space.
+	pub fn with_capacity(capacity: usize) -> Self {
+		PaletteMap(HashMap::with_capacity(capacity))
+	}
+
+	pub fn from_column_image(image: &impl GenericImage) -> anyhow::Result<PaletteMap> {
+		if image.width() != 2 {
+			return Err(Error::msg("Image must have a pixel width of 2"))?;
+		}
+
+		let mut mapping = PaletteMap::with_capacity(image.height() as usize);
+		for y in 0..image.height() {
+			let left = image.get_pixel(0, y).to_rgba();
+			let right = image.get_pixel(1, y).to_rgba();
+
+			let left_pixel = BasicRgba::from([
+				left[0].to_u8().unwrap(),
+				left[1].to_u8().unwrap(),
+				left[2].to_u8().unwrap(),
+				left[3].to_u8().unwrap(),
+			]);
+			let right_pixel = BasicRgba::from([
+				right[0].to_u8().unwrap(),
+				right[1].to_u8().unwrap(),
+				right[2].to_u8().unwrap(),
+				right[3].to_u8().unwrap(),
+			]);
+			mapping.insert(left_pixel, right_pixel);
+		}
+
+		Ok(mapping)
+	}
+
+	/// Safe method for creating a palette map from two colour palettes. The colour palettes will be truncated to the length of the smaller palette,
+	/// possibly losing some colour information
+	pub fn from_palettes_lossy(from: &ColourPalette, to: &ColourPalette) -> PaletteMap {
+		let smallest = from.len().min(to.len());
+		let iter = from
+			.iter()
+			.take(smallest)
+			.copied()
+			.zip(to.iter().take(smallest).copied());
+		PaletteMap::from_iter(iter)
+	}
+
+	pub fn calculate_mapping(from: &ColourPalette, to: &ColourPalette) -> PaletteMap {
+		let colour_labs = Vec::from_iter(to.iter().map(LabValue::from));
+
+		let to_palette_vectors: HashMap<usize, &BasicRgba> =
+			HashMap::from_iter(to.iter().enumerate());
+		let mut out_map = PaletteMap::with_capacity(from.len());
+
+		for colour in from.iter() {
+			// Iterate all the possible output colours to find the "closest" match. Closeness is determined by the smallest Delta E2000 distance.
+			let closest = to_palette_vectors
+				.keys()
+				.fold(None, |lowest, idx| match lowest {
+					Some(num) => {
+						let current = colour_labs[*idx];
+						let previous: LabValue = colour_labs[num];
+
+						if colour.delta(current, DE2000) < colour.delta(previous, DE2000) {
+							Some(*idx)
+						} else {
+							Some(num)
+						}
+					}
+					None => Some(*idx),
+				});
+
+			// We perform a check just in case the output palette is empty. As long as there is at least
+			// one colour in the output palette, 'closest' will be `Some`, so we output a transparent pixel
+			// for the `None` edge case
+			match closest {
+				Some(idx) => match to_palette_vectors.get(&idx) {
+					Some(col) => {
+						out_map.insert(*colour, **col);
+					}
+					None => {
+						log::warn!("No matching set for {} with col {:?}", idx, &colour);
+
+						out_map.insert(
+							*colour,
+							BasicRgba {
+								r: 0,
+								g: 0,
+								b: 0,
+								a: 0,
+							},
+						);
+					}
+				},
+				None => {
+					log::warn!("No closest for {:?}", &colour);
+					out_map.insert(
+						*colour,
+						BasicRgba {
+							r: 0,
+							g: 0,
+							b: 0,
+							a: 0,
+						},
+					);
+				}
+			}
+		}
+
+		out_map
+	}
+}
diff --git a/src/utils/mod.rs b/src/utils/mod.rs
new file mode 100644
index 0000000..f458be2
--- /dev/null
+++ b/src/utils/mod.rs
@@ -0,0 +1,13 @@
+mod colours;
+#[cfg(feature = "filesystem")]
+mod files;
+mod formats;
+mod mapping;
+mod palette;
+
+pub use colours::*;
+#[cfg(feature = "filesystem")]
+pub use files::*;
+pub use formats::*;
+pub use mapping::*;
+pub use palette::*;
diff --git a/src/utils/palette.rs b/src/utils/palette.rs
new file mode 100644
index 0000000..846a7b6
--- /dev/null
+++ b/src/utils/palette.rs
@@ -0,0 +1,89 @@
+use crate::utils::BasicRgba;
+use image::{GenericImage, Pixel, Rgba};
+use num_traits::ToPrimitive;
+use std::cmp::Ordering;
+use std::collections::HashSet;
+use std::ops::{Deref, DerefMut, Index};
+
+#[derive(Clone, Debug)]
+#[cfg_attr(
+	feature = "serialise_types",
+	derive(serde::Serialize, serde::Deserialize)
+)]
+#[cfg_attr(feature = "serialise_types", serde(transparent))]
+pub struct ColourPalette(Vec<BasicRgba>);
+impl Deref for ColourPalette {
+	type Target = Vec<BasicRgba>;
+	fn deref(&self) -> &Self::Target {
+		&self.0
+	}
+}
+impl DerefMut for ColourPalette {
+	fn deref_mut(&mut self) -> &mut Self::Target {
+		&mut self.0
+	}
+}
+impl From<Vec<BasicRgba>> for ColourPalette {
+	fn from(other: Vec<BasicRgba>) -> Self {
+		ColourPalette(other)
+	}
+}
+impl FromIterator<BasicRgba> for ColourPalette {
+	fn from_iter<T: IntoIterator<Item = BasicRgba>>(iter: T) -> Self {
+		<ColourPalette as From<Vec<BasicRgba>>>::from(iter.into_iter().collect())
+	}
+}
+
+impl ColourPalette {
+	/// Create a new colour palette with no entries, and no space allocated.
+	pub fn empty() -> Self {
+		ColourPalette(Vec::new())
+	}
+
+	/// Create a new, empty colour palette with a given amount of preallocated space.
+	pub fn with_capacity(capacity: usize) -> Self {
+		ColourPalette(Vec::with_capacity(capacity))
+	}
+
+	/// Mutate the palette in place, sorting entries by hue. Hue is sorted in ascending order by degree
+	pub fn sort_by_hue(&mut self) {
+		self.sort_by(|pa, pb| {
+			let hue_a = pa.hue();
+			let hue_b = pb.hue();
+
+			if hue_a.is_nan() && hue_b.is_nan() {
+				Ordering::Equal
+			} else if hue_a.is_nan() {
+				Ordering::Less
+			} else if hue_b.is_nan() || hue_a > hue_b {
+				Ordering::Greater
+			} else if hue_b > hue_a {
+				Ordering::Less
+			} else {
+				Ordering::Equal
+			}
+		});
+	}
+}
+
+impl<I: GenericImage> From<&I> for ColourPalette {
+	fn from(image: &I) -> Self {
+		let mut colours = HashSet::new();
+		for (_, _, pixel) in image.pixels() {
+			let pixel = pixel.to_rgba();
+			let parts = (
+				pixel[0].to_u8(),
+				pixel[1].to_u8(),
+				pixel[2].to_u8(),
+				pixel[3].to_u8(),
+			);
+			match parts {
+				(Some(r), Some(g), Some(b), Some(a)) => {
+					colours.insert(BasicRgba::from([r, g, b, a]));
+				}
+				_ => continue, // Ignore pixels with missing or invalid components
+			}
+		}
+		ColourPalette::from_iter(colours.into_iter())
+	}
+}
-- 
GitLab