initial commit

This commit is contained in:
Noa Aarts 2026-01-26 19:41:09 +01:00
commit 20affba4e4
Signed by: noa
GPG key ID: 1850932741EFF672
7 changed files with 1570 additions and 0 deletions

1
.envrc Normal file
View file

@ -0,0 +1 @@
use flake . -L

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/target
/.direnv

1159
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

9
Cargo.toml Normal file
View file

@ -0,0 +1,9 @@
[package]
name = "png-change"
version = "0.1.0"
edition = "2024"
[dependencies]
clap = { version = "4.5.54", features = ["derive"] }
image = "0.25.9"
png = "0.18.0"

116
flake.lock generated Normal file
View file

@ -0,0 +1,116 @@
{
"nodes": {
"advisory-db": {
"flake": false,
"locked": {
"lastModified": 1769407479,
"narHash": "sha256-ByIwFH/JsWtIpANhSjoQmx3JnPLUQL+wDZYMb6+GxhE=",
"owner": "rustsec",
"repo": "advisory-db",
"rev": "6e8c8d3103a0c56f1581409ebcaba1bc9f2fc799",
"type": "github"
},
"original": {
"owner": "rustsec",
"repo": "advisory-db",
"type": "github"
}
},
"crane": {
"locked": {
"lastModified": 1769287525,
"narHash": "sha256-gABuYA6BzoRMLuPaeO5p7SLrpd4qExgkwEmYaYQY4bM=",
"owner": "ipetkov",
"repo": "crane",
"rev": "0314e365877a85c9e5758f9ea77a9972afbb4c21",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"type": "github"
}
},
"fenix": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"rust-analyzer-src": []
},
"locked": {
"lastModified": 1769410490,
"narHash": "sha256-Qnn9S6QJ2vJtDNRRi6smKpbbyTwjv3fCqqdF3uguVnc=",
"owner": "nix-community",
"repo": "fenix",
"rev": "c08dfc7c3c62a9947db50782aa8e2fe065056efd",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "fenix",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1769330179,
"narHash": "sha256-yxgb4AmkVHY5OOBrC79Vv6EVd4QZEotqv+6jcvA212M=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "48698d12cc10555a4f3e3222d9c669b884a49dfe",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"advisory-db": "advisory-db",
"crane": "crane",
"fenix": "fenix",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

144
flake.nix Normal file
View file

@ -0,0 +1,144 @@
{
description = "A pixelflut stress testing tool";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
crane.url = "github:ipetkov/crane";
fenix = {
url = "github:nix-community/fenix";
inputs.nixpkgs.follows = "nixpkgs";
inputs.rust-analyzer-src.follows = "";
};
flake-utils.url = "github:numtide/flake-utils";
advisory-db = {
url = "github:rustsec/advisory-db";
flake = false;
};
};
outputs = { self, nixpkgs, crane, fenix, flake-utils, advisory-db, ... }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
inherit (pkgs) lib;
craneLib = crane.mkLib pkgs;
src = craneLib.cleanCargoSource ./.;
# Common arguments can be set here to avoid repeating them later
commonArgs = {
inherit src;
strictDeps = true;
buildInputs = [
# Add additional build inputs here
] ++ lib.optionals pkgs.stdenv.isDarwin [
# Additional darwin specific inputs can be set here
pkgs.libiconv
];
# Additional environment variables can be set directly
# MY_CUSTOM_VAR = "some value";
};
craneLibLLvmTools = craneLib.overrideToolchain
(fenix.packages.${system}.complete.withComponents [
"cargo"
"llvm-tools"
"rustc"
]);
# Build *just* the cargo dependencies, so we can reuse
# all of that work (e.g. via cachix) when running in CI
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
# Build the actual crate itself, reusing the dependency
# artifacts from above.
my-crate = craneLib.buildPackage (commonArgs // {
inherit cargoArtifacts;
});
in
{
checks = {
# Build the crate as part of `nix flake check` for convenience
inherit my-crate;
# Run clippy (and deny all warnings) on the crate source,
# again, reusing the dependency artifacts from above.
#
# Note that this is done as a separate derivation so that
# we can block the CI if there are issues here, but not
# prevent downstream consumers from building our crate by itself.
my-crate-clippy = craneLib.cargoClippy (commonArgs // {
inherit cargoArtifacts;
cargoClippyExtraArgs = "--all-targets -- --deny warnings";
});
my-crate-doc = craneLib.cargoDoc (commonArgs // {
inherit cargoArtifacts;
});
# Check formatting
my-crate-fmt = craneLib.cargoFmt {
inherit src;
};
my-crate-toml-fmt = craneLib.taploFmt {
src = pkgs.lib.sources.sourceFilesBySuffices src [ ".toml" ];
# taplo arguments can be further customized below as needed
# taploExtraArgs = "--config ./taplo.toml";
};
# Audit dependencies
my-crate-audit = craneLib.cargoAudit {
inherit src advisory-db;
};
# Audit licenses
my-crate-deny = craneLib.cargoDeny {
inherit src;
};
# Run tests with cargo-nextest
# Consider setting `doCheck = false` on `my-crate` if you do not want
# the tests to run twice
my-crate-nextest = craneLib.cargoNextest (commonArgs // {
inherit cargoArtifacts;
partitions = 1;
partitionType = "count";
});
};
packages = {
default = my-crate;
} // lib.optionalAttrs (!pkgs.stdenv.isDarwin) {
my-crate-llvm-coverage = craneLibLLvmTools.cargoLlvmCov (commonArgs // {
inherit cargoArtifacts;
});
};
apps.default = flake-utils.lib.mkApp {
drv = my-crate;
};
devShells.default = craneLib.devShell {
# Inherit inputs from checks.
checks = self.checks.${system};
# Additional dev-shell environment variables can be set directly
# MY_CUSTOM_DEVELOPMENT_VAR = "something else";
# Extra inputs can be added here; cargo and rustc are provided by default.
packages = [
# pkgs.ripgrep
pkgs.wgo
pkgs.cargo-flamegraph
];
};
});
}

139
src/main.rs Normal file
View file

@ -0,0 +1,139 @@
use std::{
collections::{HashMap, HashSet},
fs::File,
io::{BufReader, BufWriter, Write},
};
use clap::Parser;
use image::{DynamicImage, ImageDecoder, Rgba, codecs::png::PngDecoder};
use png::{
BitDepth::Eight,
ColorType::{self, Indexed},
Encoder,
};
fn encode_plte(pixels: &[[u8; 4]], width: u32, height: u32) -> Vec<u8> {
let mut used_colors: HashSet<[u8; 4]> = HashSet::new();
for pixel in pixels {
used_colors.insert(*pixel);
}
let mut palette = used_colors.into_iter().collect::<Vec<[u8; 4]>>();
palette.sort();
let palette_map: HashMap<[u8; 4], u8> = palette
.iter()
.enumerate()
.map(|(i, &color)| (color, i as u8))
.collect();
println!("used palette colors: {:02x?}", palette);
let mut w = Vec::new();
{
let mut encoder = Encoder::new(&mut w, width, height);
encoder.set_color(Indexed);
encoder.set_palette(
palette
.iter()
.flat_map(|color| [color[0], color[1], color[2]])
.collect::<Vec<_>>(),
);
encoder.set_depth(Eight);
encoder.set_trns(&[0x00]);
encoder.set_compression(png::Compression::High);
let mut writer = encoder.write_header().unwrap();
let data: Vec<u8> = pixels.iter().map(|pixel| palette_map[pixel]).collect();
writer.write_image_data(&data).unwrap();
}
w
}
fn encode_standard(pixels: &[[u8; 4]], width: u32, height: u32) -> Vec<u8> {
let mut w = Vec::new();
{
let mut encoder = Encoder::new(&mut w, width, height);
encoder.set_color(ColorType::Rgba);
encoder.set_depth(Eight);
encoder.set_compression(png::Compression::High);
let mut writer = encoder.write_header().unwrap();
let data: Vec<u8> = pixels.iter().flat_map(|&pixel| pixel).collect();
writer.write_image_data(&data).unwrap();
}
w
}
/// Simple program to optimise palette based PNGs
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
struct Args {
/// Path of the file to import
input_file: String,
/// Path of the file to export
output_file: String,
/// Colors to substitute into color variants, hex-formatted from -> to as rrggbb rrggbb
#[arg(short, long, num_args=2, value_names = ["from", "to"])]
subs: Vec<String>,
}
fn parse_rgb(hex: String) -> Result<[u8; 4], String> {
if hex.len() != 6 {
return Err("expected rrggbb".into());
}
let bytes: Vec<u8> = (0..6)
.step_by(2)
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16))
.collect::<Result<_, _>>()
.map_err(|_| "invalid hex color")?;
Ok([bytes[0], bytes[1], bytes[2], 0xff])
}
fn main() {
let args = Args::parse();
let in_path = args.input_file;
let out_path = args.output_file;
let asubs_len = args.subs.len();
let pairs: HashMap<[u8; 4], [u8; 4]> = args
.subs
.into_iter()
.map(|item| parse_rgb(item).unwrap())
.collect::<Vec<_>>()
.as_chunks::<2>()
.0
.into_iter()
.map(|[a, b]| (*a, *b))
.collect();
if asubs_len == 0 {
println!("no substitutions selected, only re-encoding")
} else {
println!("substitutions to use: {:?}", pairs);
}
let decoder = PngDecoder::new(BufReader::new(File::open(in_path).unwrap())).unwrap();
let (width, height) = decoder.dimensions();
let img = DynamicImage::from_decoder(decoder).unwrap();
let image_data = img
.to_rgba8()
.pixels()
.map(|&Rgba(px)| *pairs.get(&px).or_else(|| Some(&px)).unwrap())
.collect::<Vec<_>>();
let plte_data = encode_plte(image_data.as_slice(), width, height);
let std_data = encode_standard(image_data.as_slice(), width, height);
println!("plte data has length {}", plte_data.len(),);
println!("std data has length {}", std_data.len(),);
let mut file = File::create(out_path).unwrap();
if plte_data.len() <= std_data.len() {
file.write_all(&plte_data).unwrap();
} else {
file.write_all(&std_data).unwrap();
}
}