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",
|
||||
]
|
||||
|
||||
[[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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue