diff --git a/Cargo.lock b/Cargo.lock index ff30ad2..d7fac33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,6 +82,18 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "autocfg" version = "1.5.1" @@ -901,6 +913,12 @@ dependencies = [ "xkeysym", ] +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + [[package]] name = "strsim" version = "0.11.1" @@ -971,6 +989,32 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiny-skia" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + [[package]] name = "toml" version = "1.1.6+spec-1.1.0" @@ -1120,6 +1164,7 @@ dependencies = [ "serde_json", "smithay-client-toolkit", "tempfile", + "tiny-skia", "toml", "tracing", "tracing-subscriber", diff --git a/Cargo.toml b/Cargo.toml index 13c69fc..cf69cc6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ serde = { version = "1.0.229", features = ["derive"] } serde_ignored = "0.1.14" serde_json = "1.0.151" smithay-client-toolkit = "0.21.1" +tiny-skia = "0.12.0" toml = "1.1.6" tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } diff --git a/src/editor/export.rs b/src/editor/export.rs new file mode 100644 index 0000000..c1657ce --- /dev/null +++ b/src/editor/export.rs @@ -0,0 +1,158 @@ +//! The edited image at full resolution: the base plus every shape, +//! drawn with tiny-skia from the same geometry as the canvas. + +use std::path::{Path, PathBuf}; + +use image::RgbaImage; +use tiny_skia::{FillRule, LineCap, LineJoin, Paint, PathBuilder, Pixmap, Stroke, Transform}; + +use crate::editor::shape::{Prim, Shape, geometry}; + +pub fn render(base: &RgbaImage, shapes: &[Shape]) -> RgbaImage { + let (w, h) = base.dimensions(); + let mut pixmap = Pixmap::new(w, h).expect("the image has a size"); + for (dst, src) in pixmap.pixels_mut().iter_mut().zip(base.pixels()) { + let [r, g, b, a] = src.0; + *dst = tiny_skia::ColorU8::from_rgba(r, g, b, a).premultiply(); + } + for prim in shapes.iter().flat_map(geometry) { + draw(&mut pixmap, &prim); + } + let mut out = RgbaImage::new(w, h); + for (dst, src) in out.pixels_mut().zip(pixmap.pixels()) { + let c = src.demultiply(); + dst.0 = [c.red(), c.green(), c.blue(), c.alpha()]; + } + out +} + +fn draw(pixmap: &mut Pixmap, prim: &Prim) { + let (points, color) = match prim { + Prim::Stroke { points, color, .. } | Prim::Fill { points, color } => (points, color), + }; + let mut pb = PathBuilder::new(); + pb.move_to(points[0].0, points[0].1); + for p in &points[1..] { + pb.line_to(p.0, p.1); + } + let closed = matches!(prim, Prim::Fill { .. } | Prim::Stroke { closed: true, .. }); + if closed { + pb.close(); + } + let Some(path) = pb.finish() else { return }; + let mut paint = Paint::default(); + paint.set_color_rgba8(color[0], color[1], color[2], color[3]); + paint.anti_alias = true; + match prim { + Prim::Fill { .. } => pixmap.fill_path( + &path, + &paint, + FillRule::Winding, + Transform::identity(), + None, + ), + Prim::Stroke { width, round, .. } => { + let stroke = Stroke { + width: *width, + line_cap: if *round { + LineCap::Round + } else { + LineCap::Butt + }, + line_join: if *round { + LineJoin::Round + } else { + LineJoin::Miter + }, + ..Stroke::default() + }; + pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None); + } + } +} + +/// ` edited.png` next to `path`, numbered if taken. +pub fn edited_path(path: &Path) -> PathBuf { + let dir = path.parent().unwrap_or(Path::new(".")); + let stem = path + .file_stem() + .map(|s| s.to_string_lossy()) + .unwrap_or_default(); + crate::output::unique_path(dir, &format!("{stem} edited.png")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::editor::shape::{Style, Tool}; + use image::Rgba; + + fn line(tool: Tool, color: [u8; 3]) -> Shape { + Shape { + tool, + style: Style { color, width: 4.0 }, + points: vec![(10.0, 20.0), (90.0, 20.0)], + } + } + + #[test] + fn line_pixels() { + let base = RgbaImage::from_pixel(100, 40, Rgba([255, 255, 255, 255])); + let out = render(&base, &[line(Tool::Line, [255, 0, 0])]); + assert_eq!(out.dimensions(), (100, 40)); + assert_eq!(out.get_pixel(50, 20).0, [255, 0, 0, 255], "on the line"); + assert_eq!(out.get_pixel(50, 5).0, [255, 255, 255, 255], "away from it"); + assert_eq!( + out.get_pixel(5, 20).0, + [255, 255, 255, 255], + "before its start cap" + ); + } + + #[test] + fn highlighter_blends_at_40_percent() { + let base = RgbaImage::from_pixel(100, 40, Rgba([0, 0, 255, 255])); + let out = render(&base, &[line(Tool::Highlighter, [255, 255, 0])]); + let [r, g, b, a] = out.get_pixel(50, 20).0; + assert_eq!(a, 255); + for (got, want) in [(r, 102), (g, 102), (b, 153)] { + assert!( + (got as i32 - want).abs() <= 2, + "{:?}", + out.get_pixel(50, 20) + ); + } + } + + #[test] + fn keeps_transparency() { + let base = RgbaImage::from_pixel(100, 40, Rgba([10, 20, 30, 0])); + let mut half = base.clone(); + half.put_pixel(50, 5, Rgba([200, 100, 50, 128])); + let out = render(&half, &[line(Tool::Line, [255, 0, 0])]); + assert_eq!(out.get_pixel(0, 0).0[3], 0, "transparent stays transparent"); + let [r, g, b, a] = out.get_pixel(50, 5).0; + assert_eq!(a, 128); + assert!( + (r as i32 - 200).abs() <= 2 + && (g as i32 - 100).abs() <= 2 + && (b as i32 - 50).abs() <= 2 + ); + assert_eq!(out.get_pixel(50, 20).0, [255, 0, 0, 255]); + } + + #[test] + fn edited_path_sits_next_to_the_file() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("Screenshot at 10.00.png"); + assert_eq!( + edited_path(&file), + dir.path().join("Screenshot at 10.00 edited.png") + ); + std::fs::write(dir.path().join("Screenshot at 10.00 edited.png"), b"x").unwrap(); + assert_ne!( + edited_path(&file), + dir.path().join("Screenshot at 10.00 edited.png") + ); + } +} diff --git a/src/editor/mod.rs b/src/editor/mod.rs index 761a10c..45cb688 100644 --- a/src/editor/mod.rs +++ b/src/editor/mod.rs @@ -1,4 +1,6 @@ //! `valw edit`: a Markup-like editor window (eframe, OpenGL ES). pub mod doc; +pub mod export; pub mod shape; +pub mod view; diff --git a/src/editor/view.rs b/src/editor/view.rs new file mode 100644 index 0000000..23215b1 --- /dev/null +++ b/src/editor/view.rs @@ -0,0 +1,93 @@ +//! Fitting the image into the canvas: image pixels ↔ egui points. + +use crate::editor::shape::P; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Fit { + /// Points per image pixel. + pub scale: f32, + /// Where the image's top-left corner is, in points. + pub offset: P, +} + +impl Fit { + /// Fits `image` into `area` (x, y, w, h in points), centred, never + /// above 100 % (one image pixel per physical pixel). + pub fn new(image: (u32, u32), area: (f32, f32, f32, f32), pixels_per_point: f32) -> Fit { + let (iw, ih) = (image.0 as f32, image.1 as f32); + let (x, y, w, h) = area; + let scale = (w / iw).min(h / ih).min(1.0 / pixels_per_point); + Fit { + scale, + offset: (x + (w - iw * scale) / 2.0, y + (h - ih * scale) / 2.0), + } + } + + pub fn to_screen(self, p: P) -> P { + ( + self.offset.0 + p.0 * self.scale, + self.offset.1 + p.1 * self.scale, + ) + } + + pub fn to_image(self, p: P) -> P { + ( + (p.0 - self.offset.0) / self.scale, + (p.1 - self.offset.1) / self.scale, + ) + } +} + +/// `p` moved onto the image's area. +pub fn clamp(p: P, image: (u32, u32)) -> P { + ( + p.0.clamp(0.0, image.0 as f32), + p.1.clamp(0.0, image.1 as f32), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn small_images_are_not_upscaled() { + let f = Fit::new((400, 300), (0.0, 50.0, 1000.0, 800.0), 1.0); + assert_eq!(f.scale, 1.0); + assert_eq!(f.offset, (300.0, 300.0), "centred"); + } + + #[test] + fn large_images_fit_the_area() { + let f = Fit::new((1920, 1080), (0.0, 40.0, 960.0, 1000.0), 1.0); + assert_eq!(f.scale, 0.5); + assert_eq!(f.offset, (0.0, 40.0 + (1000.0 - 540.0) / 2.0)); + } + + #[test] + fn hundred_percent_means_one_image_pixel_per_physical_pixel() { + let f = Fit::new((400, 300), (0.0, 0.0, 1000.0, 800.0), 2.0); + assert_eq!( + f.scale, 0.5, + "0.5 points per image px at 2× = 1 physical px" + ); + } + + #[test] + fn round_trip() { + let f = Fit::new((1920, 1080), (10.0, 40.0, 1000.0, 700.0), 1.25); + for p in [(0.0, 0.0), (1919.0, 1079.0), (333.5, 777.25)] { + let q = f.to_image(f.to_screen(p)); + assert!( + (q.0 - p.0).abs() < 1e-3 && (q.1 - p.1).abs() < 1e-3, + "{p:?} {q:?}" + ); + } + } + + #[test] + fn clamp_keeps_points_on_the_image() { + assert_eq!(clamp((-5.0, 50.0), (100, 80)), (0.0, 50.0)); + assert_eq!(clamp((150.0, 90.0), (100, 80)), (100.0, 80.0)); + } +}