diff --git a/Cargo.lock b/Cargo.lock index b5552a1..cabe2e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1584,6 +1584,16 @@ dependencies = [ "imgref", ] +[[package]] +name = "mark" +version = "0.0.0" +source = "git+https://github.com/Garmelon/mark.git?rev=2a862a69d69abc64ddd7eefd1e1ff3d05ce3b6e4#2a862a69d69abc64ddd7eefd1e1ff3d05ce3b6e4" +dependencies = [ + "image", + "palette", + "rand 0.9.0", +] + [[package]] name = "matchit" version = "0.8.4" @@ -2601,6 +2611,7 @@ dependencies = [ "escpos", "image", "jiff", + "mark", "mime_guess", "palette", "rand 0.9.0", diff --git a/Cargo.toml b/Cargo.toml index 05c32d7..7382973 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,10 @@ typst-assets = { version = "0.13.0", features = ["fonts"] } typst-kit = "0.13.0" typst-render = "0.13.0" +[workspace.dependencies.mark] +git = "https://github.com/Garmelon/mark.git" +rev = "2a862a69d69abc64ddd7eefd1e1ff3d05ce3b6e4" + [workspace.lints] rust.unsafe_code = { level = "forbid", priority = 1 } # Lint groups diff --git a/showbits-thermal-printer/Cargo.toml b/showbits-thermal-printer/Cargo.toml index e8d7bf9..f833909 100644 --- a/showbits-thermal-printer/Cargo.toml +++ b/showbits-thermal-printer/Cargo.toml @@ -10,6 +10,7 @@ clap = { workspace = true } escpos = { workspace = true } image = { workspace = true } jiff = { workspace = true } +mark = { workspace = true } mime_guess = { workspace = true } palette = { workspace = true } rand = { workspace = true } diff --git a/showbits-thermal-printer/src/documents/image/data.json b/showbits-thermal-printer/src/documents/image/data.json index 62b6f32..a97a8de 100644 --- a/showbits-thermal-printer/src/documents/image/data.json +++ b/showbits-thermal-printer/src/documents/image/data.json @@ -1,8 +1,6 @@ { "title": "Moon", "caption": "(on the moon)", - "algo": "stucki", - "bright": false, "seamless": false, "feed": false } diff --git a/showbits-thermal-printer/src/documents/image/image.png b/showbits-thermal-printer/src/documents/image/image.png index 35df62b..0b271b4 100644 Binary files a/showbits-thermal-printer/src/documents/image/image.png and b/showbits-thermal-printer/src/documents/image/image.png differ diff --git a/showbits-thermal-printer/src/documents/image/main.typ b/showbits-thermal-printer/src/documents/image/main.typ index ebeec2d..5c8edb2 100644 --- a/showbits-thermal-printer/src/documents/image/main.typ +++ b/showbits-thermal-printer/src/documents/image/main.typ @@ -12,11 +12,15 @@ align(center, text(size: 32pt, data.title)) } -#lib.dither( - read("image.png", encoding: none), - bright: data.bright, - algorithm: data.algo, -) +// If the image is an odd number of pixels wide, we need to add an extra row of +// pixels (in this case, on the right) to ensure that the image pixels fall on +// screen pixels. +#context { + let img = image("image.png") + let width = measure(img).width + let additional = 2pt * calc.fract(width.pt() / 2) + align(center, stack(dir: ltr, img, h(additional))) +} #if data.caption != none { align(center, text(size: 32pt, data.caption)) diff --git a/showbits-thermal-printer/src/documents/image/mod.rs b/showbits-thermal-printer/src/documents/image/mod.rs index db1bc44..284e9f1 100644 --- a/showbits-thermal-printer/src/documents/image/mod.rs +++ b/showbits-thermal-printer/src/documents/image/mod.rs @@ -1,33 +1,96 @@ use std::io::Cursor; -use anyhow::Context; +use anyhow::{Context, anyhow, bail}; use axum::{ extract::{Multipart, State}, http::StatusCode, response::{IntoResponse, Response}, }; -use image::ImageFormat; +use image::{ImageFormat, Luma, Pixel, RgbaImage, imageops}; +use mark::dither::{AlgoFloydSteinberg, AlgoStucki, Algorithm, DiffEuclid, Palette}; +use palette::LinSrgb; use serde::Serialize; use crate::server::{Server, somehow, statuscode::status_code}; +pub fn dither( + mut image: RgbaImage, + max_width: Option, + max_height: Option, + bright: bool, + algorithm: &str, +) -> anyhow::Result { + let image_width = image.width(); + let image_height = image.height(); + + let scale_factor = match (max_width, max_height) { + (None, None) => 1.0, + (None, Some(height)) => height as f32 / image_height as f32, + (Some(width), None) => width as f32 / image_width as f32, + (Some(width), Some(height)) => { + (width as f32 / image_width as f32).min(height as f32 / image_height as f32) + } + }; + + let target_width = (image_width as f32 * scale_factor) as u32; + let target_height = (image_height as f32 * scale_factor) as u32; + + if image_width != target_width || image_height != target_height { + image = imageops::resize(&image, target_width, target_height, imageops::CatmullRom); + } + + if bright { + for pixel in image.pixels_mut() { + let [l] = pixel.to_luma().0; + let l = l as f32 / 255.0; // Convert to [0, 1] + let l = 1.0 - (0.4 * (1.0 - l)); // Lerp to [0.6, 1] + let l = (l.clamp(0.0, 1.0) * 255.0) as u8; // Convert back to [0, 255] + *pixel = Luma([l]).to_rgba(); + } + } + + let palette = Palette::new(vec![ + LinSrgb::new(0.0, 0.0, 0.0), + LinSrgb::new(1.0, 1.0, 1.0), + ]); + + let dithered = match algorithm { + "floyd-steinberg" => { + >::run(image, &palette) + } + "stucki" => >::run(image, &palette), + it => bail!("Unknown dithering algorithm: {it}"), + }; + + Ok(dithered) +} + +fn bool_from_str(s: &str) -> somehow::Result { + match s { + "true" => Ok(true), + "false" => Ok(false), + _ => Err(somehow::Error(anyhow!( + "invalid boolean value {s:?}, must be true or false" + ))), + } +} + #[derive(Serialize)] struct Data { title: Option, caption: Option, - algo: String, - bright: bool, seamless: bool, feed: bool, } pub async fn post(server: State, mut multipart: Multipart) -> somehow::Result { let mut image = None; + let mut algo = "stucki".to_string(); + let mut bright = true; + let mut data = Data { title: None, caption: None, - algo: "stucki".to_string(), - bright: true, seamless: false, feed: true, }; @@ -40,22 +103,22 @@ pub async fn post(server: State, mut multipart: Multipart) -> somehow::R image = Some(decoded); } Some("title") => { - data.title = Some(field.text().await?); + data.title = Some(field.text().await?).filter(|it| !it.is_empty()); } Some("caption") => { - data.caption = Some(field.text().await?); + data.caption = Some(field.text().await?).filter(|it| !it.is_empty()); } Some("algo") => { - data.algo = field.text().await?; + algo = field.text().await?; } Some("bright") => { - data.bright = !field.text().await?.is_empty(); + bright = !field.text().await?.is_empty(); } Some("seamless") => { data.seamless = !field.text().await?.is_empty(); } Some("feed") => { - data.feed = !field.text().await?.is_empty(); + data.feed = bool_from_str(&field.text().await?)?; } _ => {} } @@ -65,6 +128,10 @@ pub async fn post(server: State, mut multipart: Multipart) -> somehow::R return Ok(status_code(StatusCode::UNPROCESSABLE_ENTITY)); }; + let max_width = Some(384); + let max_height = Some(1024); + let image = dither(image, max_width, max_height, bright, &algo).map_err(somehow::Error)?; + let mut bytes: Vec = Vec::new(); image .write_to(&mut Cursor::new(&mut bytes), ImageFormat::Png)