From fa43074f3ddf304a1d51fce4dc827ea6893bf9ab Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 1 Mar 2025 18:04:22 +0100 Subject: [PATCH] Typstify /image endpoint --- Cargo.lock | 1 - showbits-thermal-printer/Cargo.toml | 1 - showbits-thermal-printer/src/documents.rs | 4 +- .../src/documents/image/data.json | 6 ++ .../src/documents/image/main.typ | 17 +++- .../src/documents/image/mod.rs | 84 ++++++++++++++--- .../src/documents/lib.typ | 28 ++++-- showbits-thermal-printer/src/drawer.rs | 6 +- showbits-thermal-printer/src/drawer/image.rs | 53 ----------- showbits-thermal-printer/src/server.rs | 93 ++----------------- showbits-typst-plugin/src/lib.rs | 75 +++++++++++---- 11 files changed, 179 insertions(+), 189 deletions(-) create mode 100644 showbits-thermal-printer/src/documents/image/data.json delete mode 100644 showbits-thermal-printer/src/drawer/image.rs diff --git a/Cargo.lock b/Cargo.lock index aaf9200..5a72dd4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2508,7 +2508,6 @@ dependencies = [ "image", "jiff", "mime_guess", - "palette", "rand 0.9.0", "rust-embed", "serde", diff --git a/showbits-thermal-printer/Cargo.toml b/showbits-thermal-printer/Cargo.toml index f810edd..4988ffa 100644 --- a/showbits-thermal-printer/Cargo.toml +++ b/showbits-thermal-printer/Cargo.toml @@ -11,7 +11,6 @@ escpos = { workspace = true } image = { workspace = true, default-features = true } jiff = { workspace = true } mime_guess = { workspace = true } -palette = { workspace = true } rand = { workspace = true } rust-embed = { workspace = true } serde = { workspace = true } diff --git a/showbits-thermal-printer/src/documents.rs b/showbits-thermal-printer/src/documents.rs index 8592c7a..235025e 100644 --- a/showbits-thermal-printer/src/documents.rs +++ b/showbits-thermal-printer/src/documents.rs @@ -1,8 +1,6 @@ use showbits_typst::Typst; -pub use self::image::*; - -mod image; +pub mod image; pub mod text; fn typst_with_lib() -> Typst { diff --git a/showbits-thermal-printer/src/documents/image/data.json b/showbits-thermal-printer/src/documents/image/data.json new file mode 100644 index 0000000..b0e3685 --- /dev/null +++ b/showbits-thermal-printer/src/documents/image/data.json @@ -0,0 +1,6 @@ +{ + "seamless": true, + "feed": true, + "bright": true, + "algo": "floyd-steinberg" +} diff --git a/showbits-thermal-printer/src/documents/image/main.typ b/showbits-thermal-printer/src/documents/image/main.typ index 78cbbbd..1389a29 100644 --- a/showbits-thermal-printer/src/documents/image/main.typ +++ b/showbits-thermal-printer/src/documents/image/main.typ @@ -1,4 +1,19 @@ #import "lib.typ"; #show: it => lib.init(it) -#lib.dither("image.png") +#let data = json("data.json") + +#let dithered = lib.dither( + "image.png", + bright: data.bright, + algorithm: data.algo, +) + +#if data.seamless { + set page(margin: 0pt) + dithered + if data.feed { lib.feed } +} else { + dithered + if data.feed { lib.feed } +} diff --git a/showbits-thermal-printer/src/documents/image/mod.rs b/showbits-thermal-printer/src/documents/image/mod.rs index 80d35d5..006aa8b 100644 --- a/showbits-thermal-printer/src/documents/image/mod.rs +++ b/showbits-thermal-printer/src/documents/image/mod.rs @@ -1,24 +1,78 @@ use std::io::Cursor; use anyhow::Context; -use image::{ImageFormat, RgbaImage}; -use showbits_typst::Typst; +use axum::{ + extract::{Multipart, State}, + http::StatusCode, + response::{IntoResponse, Redirect, Response}, +}; +use image::ImageFormat; +use serde::Serialize; -pub struct Image { - pub image: RgbaImage, +use crate::{ + drawer::{Command, NewTypstDrawing}, + server::{Server, somehow, statuscode::status_code}, +}; + +#[derive(Serialize)] +pub struct Data { + pub seamless: bool, + pub feed: bool, + pub bright: bool, + pub algo: String, } -impl Image { - pub fn into_typst(self) -> anyhow::Result { - let mut bytes: Vec = Vec::new(); - self.image - .write_to(&mut Cursor::new(&mut bytes), ImageFormat::Png) - .context("failed to encode image as png")?; +pub async fn post(server: State, mut multipart: Multipart) -> somehow::Result { + let mut image = None; + let mut data = Data { + seamless: false, + feed: true, + bright: true, + algo: "floyd-steinberg".to_string(), + }; - let typst = super::typst_with_lib() - .with_file("/image.png", bytes) - .with_main_file(include_str!("main.typ")); - - Ok(typst) + while let Some(field) = multipart.next_field().await? { + match field.name() { + Some("image") => { + let data = field.bytes().await?; + let decoded = image::load_from_memory(&data)?.into_rgba8(); + image = Some(decoded); + } + Some("seamless") => { + data.seamless = !field.text().await?.is_empty(); + } + Some("feed") => { + data.feed = !field.text().await?.is_empty(); + } + Some("bright") => { + data.bright = !field.text().await?.is_empty(); + } + Some("algo") => { + data.algo = field.text().await?; + } + _ => {} + } } + + let Some(image) = image else { + return Ok(status_code(StatusCode::UNPROCESSABLE_ENTITY)); + }; + + let mut bytes: Vec = Vec::new(); + image + .write_to(&mut Cursor::new(&mut bytes), ImageFormat::Png) + .context("failed to encode image as png") + .map_err(somehow::Error)?; + + let typst = super::typst_with_lib() + .with_json("/data.json", &data) + .with_file("/image.png", bytes) + .with_main_file(include_str!("main.typ")); + + let _ = server + .tx + .send(Command::draw(NewTypstDrawing::new(typst))) + .await; + + Ok(Redirect::to("image").into_response()) } diff --git a/showbits-thermal-printer/src/documents/lib.typ b/showbits-thermal-printer/src/documents/lib.typ index 66d6287..710398f 100644 --- a/showbits-thermal-printer/src/documents/lib.typ +++ b/showbits-thermal-printer/src/documents/lib.typ @@ -26,18 +26,32 @@ #import plugin("plugin.wasm") as p -#let _length_to_bytes(len) = { - let len = len.pt() - let n = if len > 10000 { -1 } else { int(len) } - n.to-bytes(size: 8) +#let _number_to_bytes(n) = int(n).to-bytes(size: 8) + +#let _bool_to_bytes(b) = _number_to_bytes(if b { 1 } else { 0 }) + +#let _str_to_bytes(s) = { + bytes(s) } -#let dither(path) = layout(size => { - let bytes = read(path, encoding: none) +#let _length_to_bytes(l) = { + let l = l.pt() + let n = if l > 10000 { -1 } else { int(l) } + _number_to_bytes(n) +} + +#let dither( + path, + bright: true, + algorithm: "floyd-steinberg", +) = layout(size => { + let data = read(path, encoding: none) let dithered = p.dither( - bytes, + data, _length_to_bytes(size.width), _length_to_bytes(size.height), + _bool_to_bytes(bright), + _str_to_bytes(algorithm), ) image(dithered) }) diff --git a/showbits-thermal-printer/src/drawer.rs b/showbits-thermal-printer/src/drawer.rs index bafc943..e89e5d3 100644 --- a/showbits-thermal-printer/src/drawer.rs +++ b/showbits-thermal-printer/src/drawer.rs @@ -3,7 +3,6 @@ mod calendar; mod cells; mod chat_message; mod egg; -mod image; mod new_typst; mod photo; mod tictactoe; @@ -16,9 +15,8 @@ use crate::persistent_printer::PersistentPrinter; pub use self::{ backlog::BacklogDrawing, calendar::CalendarDrawing, cells::CellsDrawing, - chat_message::ChatMessageDrawing, egg::EggDrawing, image::ImageDrawing, - new_typst::NewTypstDrawing, photo::PhotoDrawing, tictactoe::TicTacToeDrawing, - typst::TypstDrawing, + chat_message::ChatMessageDrawing, egg::EggDrawing, new_typst::NewTypstDrawing, + photo::PhotoDrawing, tictactoe::TicTacToeDrawing, typst::TypstDrawing, }; pub const FEED: f32 = 96.0; diff --git a/showbits-thermal-printer/src/drawer/image.rs b/showbits-thermal-printer/src/drawer/image.rs deleted file mode 100644 index ab4c52c..0000000 --- a/showbits-thermal-printer/src/drawer/image.rs +++ /dev/null @@ -1,53 +0,0 @@ -use image::RgbaImage; -use palette::{FromColor, IntoColor, LinLumaa}; -use showbits_common::{ - Node, Tree, WidgetExt, - color::{self, BLACK, WHITE}, - widgets::{DitherAlgorithm, Image}, -}; -use taffy::{AlignItems, Display, FlexDirection, prelude::length, style_helpers::percent}; - -use crate::persistent_printer::PersistentPrinter; - -use super::{Context, Drawing, FEED}; - -pub struct ImageDrawing { - pub image: RgbaImage, - pub bright: bool, - pub algo: DitherAlgorithm, - pub scale: u32, -} - -impl Drawing for ImageDrawing { - fn draw(&self, printer: &mut PersistentPrinter, ctx: &mut Context) -> anyhow::Result<()> { - let mut image = self.image.clone(); - if self.bright { - for pixel in image.pixels_mut() { - let mut color = LinLumaa::from_color(color::from_image_color(*pixel)); - color.luma = 1.0 - 0.4 * (1.0 - color.luma); - *pixel = color::to_image_color(color.into_color()); - } - } - - let mut tree = Tree::::new(WHITE); - - let image = Image::new(image) - .with_dither_palette(&[BLACK, WHITE]) - .with_dither_algorithm(self.algo) - .with_scale(self.scale) - .node() - .register(&mut tree)?; - - let root = Node::empty() - .with_size_width(percent(1.0)) - .with_padding_bottom(length(FEED)) - .with_display(Display::Flex) - .with_flex_direction(FlexDirection::Column) - .with_align_items(Some(AlignItems::Center)) - .and_child(image) - .register(&mut tree)?; - - printer.print_tree(&mut tree, ctx, root)?; - Ok(()) - } -} diff --git a/showbits-thermal-printer/src/server.rs b/showbits-thermal-printer/src/server.rs index 9b8dda7..6837026 100644 --- a/showbits-thermal-printer/src/server.rs +++ b/showbits-thermal-printer/src/server.rs @@ -1,6 +1,6 @@ -mod somehow; +pub mod somehow; mod r#static; -mod statuscode; +pub mod statuscode; use axum::{ Form, Router, @@ -10,14 +10,13 @@ use axum::{ routing::{get, post}, }; use serde::Deserialize; -use showbits_common::widgets::DitherAlgorithm; use tokio::{net::TcpListener, sync::mpsc}; use crate::{ - documents::{self, Image}, + documents, drawer::{ - CalendarDrawing, CellsDrawing, ChatMessageDrawing, Command, EggDrawing, ImageDrawing, - NewTypstDrawing, PhotoDrawing, TicTacToeDrawing, TypstDrawing, + CalendarDrawing, CellsDrawing, ChatMessageDrawing, Command, EggDrawing, PhotoDrawing, + TicTacToeDrawing, TypstDrawing, }, }; @@ -34,7 +33,10 @@ pub async fn run(tx: mpsc::Sender, addr: String) -> anyhow::Result<()> .route("/cells", post(post_cells)) .route("/chat_message", post(post_chat_message)) .route("/egg", post(post_egg).fallback(get_static_file)) - .route("/image", post(post_image).fallback(get_static_file)) + .route( + "/image", + post(documents::image::post).fallback(get_static_file), + ) .route("/photo", post(post_photo).fallback(get_static_file)) .route( "/text", @@ -42,7 +44,6 @@ pub async fn run(tx: mpsc::Sender, addr: String) -> anyhow::Result<()> ) .route("/tictactoe", post(post_tictactoe)) .route("/typst", post(post_typst).fallback(get_static_file)) - .route("/test2", post(post_test2).fallback(get_static_file)) .fallback(get(get_static_file)) .layer(DefaultBodyLimit::max(32 * 1024 * 1024)) // 32 MiB .with_state(Server { tx }); @@ -115,52 +116,6 @@ async fn post_egg(server: State) -> impl IntoResponse { Redirect::to("egg") } -// /image - -async fn post_image(server: State, mut multipart: Multipart) -> somehow::Result { - let mut image = None; - let mut bright = false; - let mut algo = DitherAlgorithm::FloydSteinberg; - let mut scale = 1_u32; - - while let Some(field) = multipart.next_field().await? { - match field.name() { - Some("image") => { - let data = field.bytes().await?; - let decoded = image::load_from_memory(&data)?.into_rgba8(); - image = Some(decoded); - } - Some("bright") => { - bright = true; - } - Some("algo") => match &field.text().await? as &str { - "floyd-steinberg" => algo = DitherAlgorithm::FloydSteinberg, - "stucki" => algo = DitherAlgorithm::Stucki, - _ => {} - }, - Some("scale") => { - scale = field.text().await?.parse::()?; - } - _ => {} - } - } - - let Some(image) = image else { - return Ok(status_code(StatusCode::UNPROCESSABLE_ENTITY)); - }; - - let _ = server - .tx - .send(Command::draw(ImageDrawing { - image, - bright, - algo, - scale, - })) - .await; - Ok(Redirect::to("image").into_response()) -} - // /photo async fn post_photo(server: State, mut multipart: Multipart) -> somehow::Result { @@ -215,33 +170,3 @@ async fn post_typst(server: State, request: Form) { .send(Command::draw(TypstDrawing(request.0.source))) .await; } - -// /test2 - -async fn post_test2(server: State, mut multipart: Multipart) -> somehow::Result { - let mut image = None; - - while let Some(field) = multipart.next_field().await? { - match field.name() { - Some("image") => { - let data = field.bytes().await?; - let decoded = image::load_from_memory(&data)?.into_rgba8(); - image = Some(decoded); - } - _ => {} - } - } - - let Some(image) = image else { - return Ok(status_code(StatusCode::UNPROCESSABLE_ENTITY)); - }; - - let image = Image { image }.into_typst().map_err(somehow::Error)?; - - let _ = server - .tx - .send(Command::draw(NewTypstDrawing::new(image))) - .await; - - Ok(().into_response()) -} diff --git a/showbits-typst-plugin/src/lib.rs b/showbits-typst-plugin/src/lib.rs index fc3104a..a64ce37 100644 --- a/showbits-typst-plugin/src/lib.rs +++ b/showbits-typst-plugin/src/lib.rs @@ -4,18 +4,42 @@ use image::{ ImageFormat, imageops::{self, FilterType}, }; -use mark::dither::{AlgoFloydSteinberg, Algorithm, DiffEuclid, Palette}; -use palette::LinSrgb; +use mark::dither::{AlgoFloydSteinberg, AlgoStucki, Algorithm, DiffEuclid, Palette}; +use palette::{FromColor, IntoColor, LinLumaa, LinSrgb, Srgba}; use wasm_minimal_protocol::{initiate_protocol, wasm_func}; initiate_protocol!(); +// Palette <-> image color conversions + +fn image_to_palette(color: image::Rgba) -> Srgba { + let [r, g, b, a] = color.0; + Srgba::new(r, g, b, a).into_format() +} + +fn palette_to_image(color: Srgba) -> image::Rgba { + let color = color.into_format::(); + image::Rgba([color.red, color.green, color.blue, color.alpha]) +} + +// Typst type conversions + fn i64_from_bytes(bytes: &[u8]) -> Result { let bytes: [u8; 8] = bytes.try_into().map_err(|it| format!("{it}"))?; Ok(i64::from_le_bytes(bytes)) } -fn size_from_i64(size: i64) -> Result, String> { +fn bool_from_bytes(bytes: &[u8]) -> Result { + Ok(i64_from_bytes(bytes)? != 0) +} + +fn str_from_bytes(bytes: &[u8]) -> Result<&str, String> { + std::str::from_utf8(bytes).map_err(|it| format!("{it}")) +} + +fn size_from_bytes(bytes: &[u8]) -> Result, String> { + let size = i64_from_bytes(bytes)?; + if size < 0 { return Ok(None); // Unlimited width } @@ -24,10 +48,20 @@ fn size_from_i64(size: i64) -> Result, String> { Ok(Some(size)) } +// Typst methods + #[wasm_func] -pub fn dither(image: &[u8], max_width: &[u8], max_height: &[u8]) -> Result, String> { - let max_width = size_from_i64(i64_from_bytes(max_width)?)?; - let max_height = size_from_i64(i64_from_bytes(max_height)?)?; +pub fn dither( + image: &[u8], + max_width: &[u8], + max_height: &[u8], + bright: &[u8], + algorithm: &[u8], +) -> Result, String> { + let max_width = size_from_bytes(max_width)?; + let max_height = size_from_bytes(max_height)?; + let bright = bool_from_bytes(bright)?; + let algorithm = str_from_bytes(algorithm)?; let mut image = image::load_from_memory(image) .map_err(|it| format!("Failed to read image: {it:?}"))? @@ -52,12 +86,26 @@ pub fn dither(image: &[u8], max_width: &[u8], max_height: &[u8]) -> Result>::run(image, &palette); + let dithered = match algorithm { + "floyd-steinberg" => { + >::run(image, &palette) + } + "stucki" => >::run(image, &palette), + it => Err(format!("Unknown algorithm: {it}"))?, + }; let mut bytes: Vec = Vec::new(); dithered @@ -66,16 +114,3 @@ pub fn dither(image: &[u8], max_width: &[u8], max_height: &[u8]) -> Result Result, String> { - // let value: [u8; 4] = value - // .try_into() - // .map_err(|it| format!("incorrect number of bytes: {it}"))?; - - // let be = u32::from_be_bytes(value); - // let le = u32::from_le_bytes(value); - - // Ok(format!("be: {be}, le: {le}").into_bytes()) - Ok(format!("{value:?}").into_bytes()) -}