feat: initialize K-Shrink workspace with multiple crates for image shrinking utility

- Add Cargo.toml for workspace configuration with dependencies.
- Create README.md with project description, usage, and architecture details.
- Implement `bin` crate for the main executable, including clipboard processing logic.
- Add `config` crate for handling configuration in TOML format.
- Develop `lib` crate containing core image processing logic and error handling.
- Introduce `platform` crate for platform-specific clipboard interactions, starting with Wayland.
- Implement tests for image shrinking functionality and clipboard interactions.
This commit is contained in:
2026-03-17 21:50:13 +01:00
commit 271d55ba57
18 changed files with 2788 additions and 0 deletions

21
crates/lib/src/errors.rs Normal file
View File

@@ -0,0 +1,21 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ClipboardError {
#[error("Clipboard is empty")]
Empty,
#[error("Invalid clipboard type: {0}")]
InvalidType(String),
#[error("Backend error: {0}")]
BackendError(String),
}
#[derive(Error, Debug)]
pub enum ShrinkError {
#[error("Unsupported format: {0}")]
UnsupportedFormat(String),
#[error("Encoding failed: {0}")]
EncodingFailed(String),
#[error("Decoding failed: {0}")]
DecodingFailed(String),
}

29
crates/lib/src/hash.rs Normal file
View File

@@ -0,0 +1,29 @@
use sha2::{Digest, Sha256};
pub fn image_hash(data: &[u8]) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(data);
hasher.finalize().into()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn same_input_same_hash() {
let data = b"some image bytes";
assert_eq!(image_hash(data), image_hash(data));
}
#[test]
fn different_input_different_hash() {
assert_ne!(image_hash(b"abc"), image_hash(b"xyz"));
}
#[test]
fn empty_input_no_panic() {
let h = image_hash(b"");
assert_eq!(h.len(), 32);
}
}

36
crates/lib/src/lib.rs Normal file
View File

@@ -0,0 +1,36 @@
pub mod errors;
pub mod hash;
pub mod shrink;
pub use errors::{ClipboardError, ShrinkError};
pub use hash::image_hash;
pub use shrink::{shrink, ShrinkOptions, ShrinkResult};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputFormat {
// Compressed / lossy
Webp,
Jpeg,
Avif,
// Compressed / lossless
Png,
Qoi,
Farbfeld,
// Uncompressed
Bmp,
Tga,
Tiff,
Pnm,
Ico,
Gif,
// HDR / floating-point
Hdr,
OpenExr,
}
pub trait ClipboardProvider {
fn capture(&self) -> Result<(Vec<u8>, String), ClipboardError>;
/// Distribute one or more (data, mime_type) pairs to the clipboard.
/// Multiple pairs let receivers pick the format they support.
fn distribute(&self, items: &[(&[u8], &str)]) -> Result<(), ClipboardError>;
}

168
crates/lib/src/shrink.rs Normal file
View File

@@ -0,0 +1,168 @@
use crate::{OutputFormat, ShrinkError};
use std::io::Cursor;
pub struct ShrinkOptions {
pub quality: u8,
pub target_format: OutputFormat,
}
#[derive(Debug)]
pub struct ShrinkResult {
pub data: Vec<u8>,
pub mime_type: String,
}
/// Decode arbitrary image bytes and re-encode as PNG.
/// Use this to produce a universally-pasteable fallback alongside a compressed format.
pub fn to_png(data: &[u8]) -> Result<Vec<u8>, ShrinkError> {
let img = image::load_from_memory(data)
.map_err(|e| ShrinkError::DecodingFailed(e.to_string()))?;
let mut buf = Vec::new();
img.write_to(&mut Cursor::new(&mut buf), image::ImageFormat::Png)
.map_err(|e| ShrinkError::EncodingFailed(e.to_string()))?;
Ok(buf)
}
pub fn shrink(data: &[u8], opts: &ShrinkOptions) -> Result<ShrinkResult, ShrinkError> {
let img = image::load_from_memory(data)
.map_err(|e| ShrinkError::DecodingFailed(e.to_string()))?;
let mut output = Vec::new();
macro_rules! write_to {
($fmt:expr, $mime:expr) => {{
img.write_to(&mut Cursor::new(&mut output), $fmt)
.map_err(|e| ShrinkError::EncodingFailed(e.to_string()))?;
$mime
}};
}
let mime_type = match opts.target_format {
// Lossy — quality knob applies
OutputFormat::Jpeg => {
img.write_with_encoder(image::codecs::jpeg::JpegEncoder::new_with_quality(
&mut output,
opts.quality,
))
.map_err(|e| ShrinkError::EncodingFailed(e.to_string()))?;
"image/jpeg"
}
OutputFormat::Avif => {
img.write_with_encoder(image::codecs::avif::AvifEncoder::new_with_speed_quality(
&mut output,
6,
opts.quality,
))
.map_err(|e| ShrinkError::EncodingFailed(e.to_string()))?;
"image/avif"
}
// Lossless — quality ignored
OutputFormat::Webp => write_to!(image::ImageFormat::WebP, "image/webp"),
OutputFormat::Png => write_to!(image::ImageFormat::Png, "image/png"),
OutputFormat::Qoi => write_to!(image::ImageFormat::Qoi, "image/x-qoi"),
OutputFormat::Farbfeld=> write_to!(image::ImageFormat::Farbfeld, "image/x-farbfeld"),
OutputFormat::Tiff => write_to!(image::ImageFormat::Tiff, "image/tiff"),
OutputFormat::Gif => write_to!(image::ImageFormat::Gif, "image/gif"),
OutputFormat::Hdr => write_to!(image::ImageFormat::Hdr, "image/vnd.radiance"),
OutputFormat::OpenExr => write_to!(image::ImageFormat::OpenExr, "image/x-exr"),
// Uncompressed (larger than source, but supported)
OutputFormat::Bmp => write_to!(image::ImageFormat::Bmp, "image/bmp"),
OutputFormat::Tga => write_to!(image::ImageFormat::Tga, "image/x-tga"),
OutputFormat::Pnm => write_to!(image::ImageFormat::Pnm, "image/x-portable-anymap"),
OutputFormat::Ico => write_to!(image::ImageFormat::Ico, "image/x-icon"),
};
Ok(ShrinkResult {
data: output,
mime_type: mime_type.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn make_png() -> Vec<u8> {
let img = image::DynamicImage::new_rgb8(8, 8);
let mut buf = Vec::new();
img.write_to(&mut Cursor::new(&mut buf), image::ImageFormat::Png)
.unwrap();
buf
}
fn make_jpeg() -> Vec<u8> {
let img = image::DynamicImage::new_rgb8(8, 8);
let mut buf = Vec::new();
img.write_to(&mut Cursor::new(&mut buf), image::ImageFormat::Jpeg)
.unwrap();
buf
}
#[test]
fn png_to_webp() {
let result = shrink(
&make_png(),
&ShrinkOptions {
quality: 80,
target_format: OutputFormat::Webp,
},
)
.unwrap();
assert_eq!(result.mime_type, "image/webp");
assert!(!result.data.is_empty());
}
#[test]
fn jpeg_quality_50_produces_output() {
let jpeg = make_jpeg();
let result = shrink(
&jpeg,
&ShrinkOptions {
quality: 50,
target_format: OutputFormat::Jpeg,
},
)
.unwrap();
assert_eq!(result.mime_type, "image/jpeg");
assert!(!result.data.is_empty());
}
#[test]
fn random_bytes_decoding_failed() {
let err = shrink(
b"not an image at all",
&ShrinkOptions {
quality: 80,
target_format: OutputFormat::Webp,
},
)
.unwrap_err();
assert!(matches!(err, ShrinkError::DecodingFailed(_)));
}
#[test]
fn quality_zero_produces_valid_output() {
let result = shrink(
&make_jpeg(),
&ShrinkOptions {
quality: 0,
target_format: OutputFormat::Jpeg,
},
)
.unwrap();
assert!(!result.data.is_empty());
}
#[test]
fn quality_max_produces_valid_output() {
let result = shrink(
&make_jpeg(),
&ShrinkOptions {
quality: 100,
target_format: OutputFormat::Jpeg,
},
)
.unwrap();
assert!(!result.data.is_empty());
}
}