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"
|
||||
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",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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).
|
||||
|
||||
pub mod doc;
|
||||
pub mod export;
|
||||
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