Dither image outside of typst

This commit is contained in:
Joscha 2025-03-03 20:44:40 +01:00
parent 81c994c8be
commit c0fe22922d
7 changed files with 103 additions and 18 deletions

11
Cargo.lock generated
View file

@ -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",

View file

@ -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

View file

@ -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 }

View file

@ -1,8 +1,6 @@
{
"title": "Moon",
"caption": "(on the moon)",
"algo": "stucki",
"bright": false,
"seamless": false,
"feed": false
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 370 KiB

After

Width:  |  Height:  |  Size: 156 KiB

Before After
Before After

View file

@ -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))

View file

@ -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<u32>,
max_height: Option<u32>,
bright: bool,
algorithm: &str,
) -> anyhow::Result<RgbaImage> {
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" => {
<AlgoFloydSteinberg as Algorithm<LinSrgb, DiffEuclid>>::run(image, &palette)
}
"stucki" => <AlgoStucki as Algorithm<LinSrgb, DiffEuclid>>::run(image, &palette),
it => bail!("Unknown dithering algorithm: {it}"),
};
Ok(dithered)
}
fn bool_from_str(s: &str) -> somehow::Result<bool> {
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<String>,
caption: Option<String>,
algo: String,
bright: bool,
seamless: bool,
feed: bool,
}
pub async fn post(server: State<Server>, mut multipart: Multipart) -> somehow::Result<Response> {
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<Server>, 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<Server>, 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<u8> = Vec::new();
image
.write_to(&mut Cursor::new(&mut bytes), ImageFormat::Png)