Dither image outside of typst
This commit is contained in:
parent
81c994c8be
commit
c0fe22922d
7 changed files with 103 additions and 18 deletions
11
Cargo.lock
generated
11
Cargo.lock
generated
|
|
@ -1584,6 +1584,16 @@ dependencies = [
|
||||||
"imgref",
|
"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]]
|
[[package]]
|
||||||
name = "matchit"
|
name = "matchit"
|
||||||
version = "0.8.4"
|
version = "0.8.4"
|
||||||
|
|
@ -2601,6 +2611,7 @@ dependencies = [
|
||||||
"escpos",
|
"escpos",
|
||||||
"image",
|
"image",
|
||||||
"jiff",
|
"jiff",
|
||||||
|
"mark",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"palette",
|
"palette",
|
||||||
"rand 0.9.0",
|
"rand 0.9.0",
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,10 @@ typst-assets = { version = "0.13.0", features = ["fonts"] }
|
||||||
typst-kit = "0.13.0"
|
typst-kit = "0.13.0"
|
||||||
typst-render = "0.13.0"
|
typst-render = "0.13.0"
|
||||||
|
|
||||||
|
[workspace.dependencies.mark]
|
||||||
|
git = "https://github.com/Garmelon/mark.git"
|
||||||
|
rev = "2a862a69d69abc64ddd7eefd1e1ff3d05ce3b6e4"
|
||||||
|
|
||||||
[workspace.lints]
|
[workspace.lints]
|
||||||
rust.unsafe_code = { level = "forbid", priority = 1 }
|
rust.unsafe_code = { level = "forbid", priority = 1 }
|
||||||
# Lint groups
|
# Lint groups
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ clap = { workspace = true }
|
||||||
escpos = { workspace = true }
|
escpos = { workspace = true }
|
||||||
image = { workspace = true }
|
image = { workspace = true }
|
||||||
jiff = { workspace = true }
|
jiff = { workspace = true }
|
||||||
|
mark = { workspace = true }
|
||||||
mime_guess = { workspace = true }
|
mime_guess = { workspace = true }
|
||||||
palette = { workspace = true }
|
palette = { workspace = true }
|
||||||
rand = { workspace = true }
|
rand = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
{
|
{
|
||||||
"title": "Moon",
|
"title": "Moon",
|
||||||
"caption": "(on the moon)",
|
"caption": "(on the moon)",
|
||||||
"algo": "stucki",
|
|
||||||
"bright": false,
|
|
||||||
"seamless": false,
|
"seamless": false,
|
||||||
"feed": false
|
"feed": false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 370 KiB After Width: | Height: | Size: 156 KiB |
|
|
@ -12,11 +12,15 @@
|
||||||
align(center, text(size: 32pt, data.title))
|
align(center, text(size: 32pt, data.title))
|
||||||
}
|
}
|
||||||
|
|
||||||
#lib.dither(
|
// If the image is an odd number of pixels wide, we need to add an extra row of
|
||||||
read("image.png", encoding: none),
|
// pixels (in this case, on the right) to ensure that the image pixels fall on
|
||||||
bright: data.bright,
|
// screen pixels.
|
||||||
algorithm: data.algo,
|
#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 {
|
#if data.caption != none {
|
||||||
align(center, text(size: 32pt, data.caption))
|
align(center, text(size: 32pt, data.caption))
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,96 @@
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::{Context, anyhow, bail};
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Multipart, State},
|
extract::{Multipart, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::{IntoResponse, Response},
|
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 serde::Serialize;
|
||||||
|
|
||||||
use crate::server::{Server, somehow, statuscode::status_code};
|
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)]
|
#[derive(Serialize)]
|
||||||
struct Data {
|
struct Data {
|
||||||
title: Option<String>,
|
title: Option<String>,
|
||||||
caption: Option<String>,
|
caption: Option<String>,
|
||||||
algo: String,
|
|
||||||
bright: bool,
|
|
||||||
seamless: bool,
|
seamless: bool,
|
||||||
feed: bool,
|
feed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn post(server: State<Server>, mut multipart: Multipart) -> somehow::Result<Response> {
|
pub async fn post(server: State<Server>, mut multipart: Multipart) -> somehow::Result<Response> {
|
||||||
let mut image = None;
|
let mut image = None;
|
||||||
|
let mut algo = "stucki".to_string();
|
||||||
|
let mut bright = true;
|
||||||
|
|
||||||
let mut data = Data {
|
let mut data = Data {
|
||||||
title: None,
|
title: None,
|
||||||
caption: None,
|
caption: None,
|
||||||
algo: "stucki".to_string(),
|
|
||||||
bright: true,
|
|
||||||
seamless: false,
|
seamless: false,
|
||||||
feed: true,
|
feed: true,
|
||||||
};
|
};
|
||||||
|
|
@ -40,22 +103,22 @@ pub async fn post(server: State<Server>, mut multipart: Multipart) -> somehow::R
|
||||||
image = Some(decoded);
|
image = Some(decoded);
|
||||||
}
|
}
|
||||||
Some("title") => {
|
Some("title") => {
|
||||||
data.title = Some(field.text().await?);
|
data.title = Some(field.text().await?).filter(|it| !it.is_empty());
|
||||||
}
|
}
|
||||||
Some("caption") => {
|
Some("caption") => {
|
||||||
data.caption = Some(field.text().await?);
|
data.caption = Some(field.text().await?).filter(|it| !it.is_empty());
|
||||||
}
|
}
|
||||||
Some("algo") => {
|
Some("algo") => {
|
||||||
data.algo = field.text().await?;
|
algo = field.text().await?;
|
||||||
}
|
}
|
||||||
Some("bright") => {
|
Some("bright") => {
|
||||||
data.bright = !field.text().await?.is_empty();
|
bright = !field.text().await?.is_empty();
|
||||||
}
|
}
|
||||||
Some("seamless") => {
|
Some("seamless") => {
|
||||||
data.seamless = !field.text().await?.is_empty();
|
data.seamless = !field.text().await?.is_empty();
|
||||||
}
|
}
|
||||||
Some("feed") => {
|
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));
|
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();
|
let mut bytes: Vec<u8> = Vec::new();
|
||||||
image
|
image
|
||||||
.write_to(&mut Cursor::new(&mut bytes), ImageFormat::Png)
|
.write_to(&mut Cursor::new(&mut bytes), ImageFormat::Png)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue