diff --git a/src/editor/doc.rs b/src/editor/doc.rs index 7684c86..31293fb 100644 --- a/src/editor/doc.rs +++ b/src/editor/doc.rs @@ -80,6 +80,7 @@ mod tests { width: 2.0, }, points: vec![(0.0, 0.0), (x, 0.0)], + text: String::new(), } } diff --git a/src/editor/export.rs b/src/editor/export.rs index c13897d..8e701b4 100644 --- a/src/editor/export.rs +++ b/src/editor/export.rs @@ -6,17 +6,21 @@ 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}; +use crate::editor::shape::{ + P, Prim, Shape, Tool, block_size, geometry, number_diameter, text_size, +}; +use crate::editor::text; +use crate::frame::PixelRect; -pub fn render(base: &RgbaImage, shapes: &[Shape]) -> RgbaImage { +pub fn render<'a>(base: &RgbaImage, shapes: impl IntoIterator) -> 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); + for shape in shapes { + draw_shape(&mut pixmap, shape); } let mut out = RgbaImage::new(w, h); for (dst, src) in out.pixels_mut().zip(pixmap.pixels()) { @@ -26,6 +30,141 @@ pub fn render(base: &RgbaImage, shapes: &[Shape]) -> RgbaImage { out } +/// The part of `image` inside the last crop, or all of it. +// The editor calls this from Task 3 of the 5b plan on; that task removes this. +#[allow(dead_code)] +pub fn crop(image: RgbaImage, rect: Option) -> RgbaImage { + match rect { + Some(r) => image::imageops::crop_imm(&image, r.x, r.y, r.width, r.height).to_image(), + None => image, + } +} + +fn draw_shape(pixmap: &mut Pixmap, shape: &Shape) { + let [r, g, b] = shape.style.color; + match shape.tool { + Tool::Text => draw_text( + pixmap, + &shape.text, + text_size(shape.style.width), + shape.points[0], + [r, g, b, 255], + ), + Tool::Number => draw_number(pixmap, shape, [r, g, b, 255]), + Tool::Pixelate => pixelate( + pixmap, + shape.points[0], + shape.points[1], + block_size(shape.style.width), + ), + _ => { + for prim in geometry(shape) { + draw(pixmap, &prim); + } + } + } +} + +fn paint(c: [u8; 4]) -> Paint<'static> { + let mut paint = Paint::default(); + paint.set_color_rgba8(c[0], c[1], c[2], c[3]); + paint.anti_alias = true; + paint +} + +/// Near black at 80 %: keeps text readable on any background. +const OUTLINE: [u8; 4] = [0x1c, 0x1c, 0x1e, 204]; + +/// `text` with its line box at `origin`, over a dark outline. +fn draw_text(pixmap: &mut Pixmap, text: &str, size: f32, origin: P, color: [u8; 4]) { + let Some(path) = text::path(text, size, origin) else { + return; + }; + let stroke = Stroke { + width: size / 8.0, + line_join: LineJoin::Round, + ..Stroke::default() + }; + pixmap.stroke_path(&path, &paint(OUTLINE), &stroke, Transform::identity(), None); + pixmap.fill_path( + &path, + &paint(color), + FillRule::Winding, + Transform::identity(), + None, + ); +} + +/// A filled circle with the number in white, centred on its ink. +fn draw_number(pixmap: &mut Pixmap, shape: &Shape, color: [u8; 4]) { + let d = number_diameter(shape.style.width); + let (cx, cy) = shape.points[0]; + if let Some(circle) = PathBuilder::from_circle(cx, cy, d / 2.0) { + pixmap.fill_path( + &circle, + &paint(color), + FillRule::Winding, + Transform::identity(), + None, + ); + } + let Some(glyphs) = text::path(&shape.text, d * 0.55, (0.0, 0.0)) else { + return; + }; + let b = glyphs.bounds(); + let centre = Transform::from_translate( + cx - (b.left() + b.right()) / 2.0, + cy - (b.top() + b.bottom()) / 2.0, + ); + if let Some(glyphs) = glyphs.transform(centre) { + pixmap.fill_path( + &glyphs, + &paint([255, 255, 255, 255]), + FillRule::Winding, + Transform::identity(), + None, + ); + } +} + +/// Replaces each `block`-sized square of the rectangle from `a` to `b` +/// (aligned to its top-left, clipped to the image) with its average. +fn pixelate(pixmap: &mut Pixmap, a: P, b: P, block: u32) { + let (w, h) = (pixmap.width(), pixmap.height()); + let clamp = |v: f32, max: u32| v.round().clamp(0.0, max as f32) as u32; + let (x0, x1) = (clamp(a.0.min(b.0), w), clamp(a.0.max(b.0), w)); + let (y0, y1) = (clamp(a.1.min(b.1), h), clamp(a.1.max(b.1), h)); + let pixels = pixmap.pixels_mut(); + for by in (y0..y1).step_by(block as usize) { + for bx in (x0..x1).step_by(block as usize) { + let (ex, ey) = ((bx + block).min(x1), (by + block).min(y1)); + let mut sum = [0u32; 4]; + for y in by..ey { + for x in bx..ex { + let p = pixels[(y * w + x) as usize]; + for (s, v) in sum + .iter_mut() + .zip([p.red(), p.green(), p.blue(), p.alpha()]) + { + *s += u32::from(v); + } + } + } + let n = (ex - bx) * (ey - by); + let [r, g, bl, al] = sum.map(|s| ((s + n / 2) / n) as u8); + // Averages of premultiplied channels stay at or below alpha. + let avg = + tiny_skia::PremultipliedColorU8::from_rgba(r.min(al), g.min(al), bl.min(al), al) + .expect("channels are clamped to alpha"); + for y in by..ey { + for x in bx..ex { + pixels[(y * w + x) as usize] = avg; + } + } + } + } +} + fn draw(pixmap: &mut Pixmap, prim: &Prim) { let (points, color) = match prim { Prim::Stroke { points, color, .. } | Prim::Fill { points, color } => (points, color), @@ -40,9 +179,7 @@ fn draw(pixmap: &mut Pixmap, prim: &Prim) { 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; + let paint = paint(*color); match prim { Prim::Fill { .. } => pixmap.fill_path( &path, @@ -85,6 +222,7 @@ pub fn edited_path(path: &Path) -> PathBuf { mod tests { use super::*; use crate::editor::shape::{Style, Tool}; + use crate::frame::PixelRect; use image::Rgba; fn line(tool: Tool, color: [u8; 3]) -> Shape { @@ -92,6 +230,7 @@ mod tests { tool, style: Style { color, width: 4.0 }, points: vec![(10.0, 20.0), (90.0, 20.0)], + text: String::new(), } } @@ -119,6 +258,7 @@ mod tests { width: 8.0, }, points: vec![(10.0, 10.0), (50.0, 30.0)], + text: String::new(), }; let out = render(&base, &[rect]); // A round join would leave the outer corner (6..7, 6..7) white. @@ -171,4 +311,128 @@ mod tests { dir.path().join("Screenshot at 10.00 edited.png") ); } + + fn white(w: u32, h: u32) -> RgbaImage { + RgbaImage::from_pixel(w, h, Rgba([255, 255, 255, 255])) + } + + #[test] + fn text_is_filled_with_the_colour_and_outlined() { + let t = Shape { + tool: Tool::Text, + style: Style { + color: [0, 122, 255], + width: 8.0, + }, + points: vec![(10.0, 10.0)], + text: "I".into(), + }; + let out = render(&white(80, 60), &[t]); + // The middle of the I's stem: x ≈ 10 + stem centre, y in the cap height. + let blue = out.pixels().filter(|p| p.0 == [0, 122, 255, 255]).count(); + assert!(blue > 40, "{blue} fully blue pixels"); + let dark = out.pixels().filter(|p| p.0[0] < 80 && p.0[2] < 80).count(); + assert!(dark > 10, "the outline: {dark}"); + assert_eq!(out.get_pixel(70, 55).0, [255, 255, 255, 255]); + } + + #[test] + fn a_number_is_a_filled_circle_with_a_white_digit() { + let n = Shape { + tool: Tool::Number, + style: Style { + color: [255, 0, 0], + width: 8.0, + }, + points: vec![(30.0, 30.0)], + text: "1".into(), + }; + let out = render(&white(60, 60), &[n]); + assert_eq!( + out.get_pixel(30, 10).0, + [255, 0, 0, 255], + "inside the 44 px circle, above the digit" + ); + assert_eq!(out.get_pixel(3, 3).0, [255, 255, 255, 255], "outside it"); + let whites_inside = (20..40) + .flat_map(|x| (20..40).map(move |y| (x, y))) + .filter(|&(x, y)| out.get_pixel(x, y).0 == [255, 255, 255, 255]) + .count(); + assert!(whites_inside > 10, "the digit: {whites_inside}"); + } + + #[test] + fn pixelate_averages_blocks_including_earlier_shapes() { + let base = RgbaImage::from_fn(24, 12, |x, _| { + if x % 2 == 0 { + Rgba([0, 0, 0, 255]) + } else { + Rgba([200, 100, 50, 255]) + } + }); + let px = Shape { + tool: Tool::Pixelate, + style: Style { + color: [0, 0, 0], + width: 4.0, + }, + points: vec![(0.0, 0.0), (24.0, 12.0)], + text: String::new(), + }; + let out = render(&base, std::slice::from_ref(&px)); + let first = out.get_pixel(0, 0).0; + for x in 0..12 { + for y in 0..12 { + assert_eq!(out.get_pixel(x, y).0, first); + } + } + for (got, want) in first.iter().zip([100, 50, 25, 255]) { + assert!((*got as i32 - want).abs() <= 1, "{first:?}"); + } + let red = Shape { + tool: Tool::Line, + style: Style { + color: [255, 0, 0], + width: 8.0, + }, + points: vec![(0.0, 6.0), (24.0, 6.0)], + text: String::new(), + }; + let both = render(&base, &[red, px]); + assert!( + both.get_pixel(0, 0).0[0] > first[0], + "the line is pixelated in" + ); + } + + #[test] + fn pixelate_stays_inside_the_image() { + let px = Shape { + tool: Tool::Pixelate, + style: Style { + color: [0, 0, 0], + width: 4.0, + }, + points: vec![(-10.0, -10.0), (500.0, 500.0)], + text: String::new(), + }; + let out = render(&white(30, 20), &[px]); + assert_eq!(out.dimensions(), (30, 20)); + assert_eq!(out.get_pixel(29, 19).0, [255, 255, 255, 255]); + } + + #[test] + fn crop_cuts_out_the_rectangle() { + let img = RgbaImage::from_fn(10, 8, |x, y| Rgba([x as u8, y as u8, 0, 255])); + let rect = PixelRect { + x: 2, + y: 3, + width: 4, + height: 2, + }; + let out = crop(img.clone(), Some(rect)); + assert_eq!(out.dimensions(), (4, 2)); + assert_eq!(out.get_pixel(0, 0).0, [2, 3, 0, 255]); + assert_eq!(crop(img.clone(), None), img); + } } diff --git a/src/editor/mod.rs b/src/editor/mod.rs index 23f0728..5ae17a9 100644 --- a/src/editor/mod.rs +++ b/src/editor/mod.rs @@ -3,8 +3,6 @@ pub mod doc; pub mod export; pub mod shape; -// Used from Task 2 of the 5b plan on; that task removes this. -#[allow(dead_code)] pub mod text; pub mod view; diff --git a/src/editor/shape.rs b/src/editor/shape.rs index 780161e..56dd418 100644 --- a/src/editor/shape.rs +++ b/src/editor/shape.rs @@ -11,16 +11,24 @@ pub enum Tool { Line, Pen, Highlighter, + Text, + Pixelate, + Number, + Crop, } impl Tool { - pub const ALL: [Tool; 6] = [ + pub const ALL: [Tool; 10] = [ Tool::Arrow, Tool::Rectangle, Tool::Ellipse, Tool::Line, Tool::Pen, Tool::Highlighter, + Tool::Text, + Tool::Pixelate, + Tool::Number, + Tool::Crop, ]; pub fn label(self) -> &'static str { @@ -31,6 +39,10 @@ impl Tool { Tool::Line => "Line", Tool::Pen => "Pen", Tool::Highlighter => "Highlighter", + Tool::Text => "Text", + Tool::Pixelate => "Pixelate", + Tool::Number => "Number", + Tool::Crop => "Crop", } } @@ -53,6 +65,23 @@ pub const PALETTE: [[u8; 3]; 7] = [ /// S, M, L in image pixels; M is the default. pub const WIDTHS: [(&str, f32); 3] = [("S", 2.0), ("M", 4.0), ("L", 8.0)]; +/// 0, 1, 2 for S, M, L. +pub fn size_index(width: f32) -> usize { + WIDTHS.iter().position(|w| w.1 == width).unwrap_or(1) +} + +pub fn text_size(width: f32) -> f32 { + [16.0, 24.0, 36.0][size_index(width)] +} + +pub fn block_size(width: f32) -> u32 { + [8, 12, 20][size_index(width)] +} + +pub fn number_diameter(width: f32) -> f32 { + [24.0, 32.0, 44.0][size_index(width)] +} + const HIGHLIGHT_ALPHA: u8 = 102; const CLICK: f32 = 2.0; const ELLIPSE_POINTS: usize = 72; @@ -64,18 +93,20 @@ pub struct Style { } /// One finished mark. Two-point tools hold [start, end]; Pen and -/// Highlighter hold every point. +/// Highlighter hold every point; Text holds its top-left and Number its +/// centre, with the text or the number in `text`. #[derive(Debug, Clone, PartialEq)] pub struct Shape { pub tool: Tool, pub style: Style, pub points: Vec

, + pub text: String, } impl Shape { /// A press without a real drag. A Pen click still leaves a dot. pub fn is_click(&self) -> bool { - if self.tool == Tool::Pen { + if matches!(self.tool, Tool::Pen | Tool::Text | Tool::Number) { return false; } let first = self.points[0]; @@ -120,6 +151,7 @@ impl Drag { tool: self.tool, style: self.style, points, + text: String::new(), } } } @@ -138,11 +170,11 @@ pub fn constrain(tool: Tool, start: P, end: P, shift: bool) -> P { let len = dx.hypot(dy); (start.0 + len * angle.cos(), start.1 + len * angle.sin()) } - Tool::Rectangle | Tool::Ellipse => { + Tool::Rectangle | Tool::Ellipse | Tool::Pixelate => { let side = dx.abs().max(dy.abs()); (start.0 + side.copysign(dx), start.1 + side.copysign(dy)) } - Tool::Pen | Tool::Highlighter => end, + Tool::Pen | Tool::Highlighter | Tool::Text | Tool::Number | Tool::Crop => end, } } @@ -205,6 +237,19 @@ pub fn geometry(shape: &Shape) -> Vec { }] } Tool::Pen => vec![stroke(pts.clone(), false)], + // Drawn by the export renderer only. + Tool::Text | Tool::Number | Tool::Crop => Vec::new(), + // While dragging: the area that will be pixelated. + Tool::Pixelate => { + let ((x0, y0), (x1, y1)) = (pts[0], pts[1]); + vec![Prim::Stroke { + points: vec![(x0, y0), (x1, y0), (x1, y1), (x0, y1)], + width: 2.0, + color: [255, 255, 255, 200], + closed: true, + round: false, + }] + } Tool::Highlighter => vec![Prim::Stroke { points: pts.clone(), width: w * 4.0, @@ -266,6 +311,7 @@ mod tests { tool, style: RED, points: points.to_vec(), + text: String::new(), } } @@ -399,4 +445,50 @@ mod tests { assert_eq!(d.shape(false).points, vec![(0.0, 0.0), (30.0, 10.0)]); assert_eq!(d.shape(true).points, vec![(0.0, 0.0), (30.0, 30.0)]); } + + #[test] + fn sizes_follow_s_m_l() { + let at = |w: f32| Style { + color: [0, 0, 0], + width: w, + }; + assert_eq!( + [2.0, 4.0, 8.0].map(|w| text_size(at(w).width)), + [16.0, 24.0, 36.0] + ); + assert_eq!( + [2.0, 4.0, 8.0].map(|w| block_size(at(w).width)), + [8, 12, 20] + ); + assert_eq!( + [2.0, 4.0, 8.0].map(|w| number_diameter(at(w).width)), + [24.0, 32.0, 44.0] + ); + } + + #[test] + fn pixelate_drags_like_a_rectangle() { + let mut d = Drag::new(Tool::Pixelate, RED, (0.0, 0.0)); + d.move_to((30.0, 10.0)); + assert_eq!(d.shape(true).points, vec![(0.0, 0.0), (30.0, 30.0)]); + let g = geometry(&d.shape(false)); + let Prim::Stroke { color, closed, .. } = &g[0] else { + panic!("{g:?}") + }; + assert!(*closed && *color == [255, 255, 255, 200]); + } + + #[test] + fn text_and_numbers_are_never_clicks_and_have_no_egui_geometry() { + for tool in [Tool::Text, Tool::Number] { + let s = Shape { + tool, + style: RED, + points: vec![(5.0, 5.0)], + text: "1".into(), + }; + assert!(!s.is_click()); + assert!(geometry(&s).is_empty()); + } + } }