feat: editor fitting and export
This commit is contained in:
1 parent
2b83719802
commit
ea986b06ab
5 files changed
+299
No files matched your search
Generated
+45
@@ -82,6 +82,18 @@ version = "1.0.104"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
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]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.5.1"
|
version = "1.5.1"
|
||||||
@@ -901,6 +913,12 @@ dependencies = [
|
|||||||
"xkeysym",
|
"xkeysym",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strict-num"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "strsim"
|
name = "strsim"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
@@ -971,6 +989,32 @@ dependencies = [
|
|||||||
"cfg-if",
|
"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]]
|
[[package]]
|
||||||
name = "toml"
|
name = "toml"
|
||||||
version = "1.1.6+spec-1.1.0"
|
version = "1.1.6+spec-1.1.0"
|
||||||
@@ -1120,6 +1164,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"smithay-client-toolkit",
|
"smithay-client-toolkit",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
|
"tiny-skia",
|
||||||
"toml",
|
"toml",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ serde = { version = "1.0.229", features = ["derive"] }
|
|||||||
serde_ignored = "0.1.14"
|
serde_ignored = "0.1.14"
|
||||||
serde_json = "1.0.151"
|
serde_json = "1.0.151"
|
||||||
smithay-client-toolkit = "0.21.1"
|
smithay-client-toolkit = "0.21.1"
|
||||||
|
tiny-skia = "0.12.0"
|
||||||
toml = "1.1.6"
|
toml = "1.1.6"
|
||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<stem> 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
//! `valw edit`: a Markup-like editor window (eframe, OpenGL ES).
|
//! `valw edit`: a Markup-like editor window (eframe, OpenGL ES).
|
||||||
|
|
||||||
pub mod doc;
|
pub mod doc;
|
||||||
|
pub mod export;
|
||||||
pub mod shape;
|
pub mod shape;
|
||||||
|
pub mod view;
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user