feat: editor fitting and export

This commit is contained in:
elwrcl committed 2026-10-02 17:51:24 +03:00
1 parent 2b83719802
commit ea986b06ab
5 files changed
+299

No files matched your search

Generated
+45
View File
@@ -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",
+1
View File
@@ -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"] }
+158
View File
@@ -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")
);
}
}
+2
View File
@@ -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;
+93
View File
@@ -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));
}
}