feat: text, numbers and pixelate in the editor export

This commit is contained in:
elwrcl committed 2026-10-03 00:01:38 +03:00
1 parent 07a4858175
commit 04f51402b3
4 files changed
+369 -14

No files matched your search

+1
View File
@@ -80,6 +80,7 @@ mod tests {
width: 2.0, width: 2.0,
}, },
points: vec![(0.0, 0.0), (x, 0.0)], points: vec![(0.0, 0.0), (x, 0.0)],
text: String::new(),
} }
} }
+271 -7
View File
@@ -6,17 +6,21 @@ use std::path::{Path, PathBuf};
use image::RgbaImage; use image::RgbaImage;
use tiny_skia::{FillRule, LineCap, LineJoin, Paint, PathBuilder, Pixmap, Stroke, Transform}; 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<Item = &'a Shape>) -> RgbaImage {
let (w, h) = base.dimensions(); let (w, h) = base.dimensions();
let mut pixmap = Pixmap::new(w, h).expect("the image has a size"); 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()) { for (dst, src) in pixmap.pixels_mut().iter_mut().zip(base.pixels()) {
let [r, g, b, a] = src.0; let [r, g, b, a] = src.0;
*dst = tiny_skia::ColorU8::from_rgba(r, g, b, a).premultiply(); *dst = tiny_skia::ColorU8::from_rgba(r, g, b, a).premultiply();
} }
for prim in shapes.iter().flat_map(geometry) { for shape in shapes {
draw(&mut pixmap, &prim); draw_shape(&mut pixmap, shape);
} }
let mut out = RgbaImage::new(w, h); let mut out = RgbaImage::new(w, h);
for (dst, src) in out.pixels_mut().zip(pixmap.pixels()) { for (dst, src) in out.pixels_mut().zip(pixmap.pixels()) {
@@ -26,6 +30,141 @@ pub fn render(base: &RgbaImage, shapes: &[Shape]) -> RgbaImage {
out 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<PixelRect>) -> 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) { fn draw(pixmap: &mut Pixmap, prim: &Prim) {
let (points, color) = match prim { let (points, color) = match prim {
Prim::Stroke { points, color, .. } | Prim::Fill { points, color } => (points, color), Prim::Stroke { points, color, .. } | Prim::Fill { points, color } => (points, color),
@@ -40,9 +179,7 @@ fn draw(pixmap: &mut Pixmap, prim: &Prim) {
pb.close(); pb.close();
} }
let Some(path) = pb.finish() else { return }; let Some(path) = pb.finish() else { return };
let mut paint = Paint::default(); let paint = paint(*color);
paint.set_color_rgba8(color[0], color[1], color[2], color[3]);
paint.anti_alias = true;
match prim { match prim {
Prim::Fill { .. } => pixmap.fill_path( Prim::Fill { .. } => pixmap.fill_path(
&path, &path,
@@ -85,6 +222,7 @@ pub fn edited_path(path: &Path) -> PathBuf {
mod tests { mod tests {
use super::*; use super::*;
use crate::editor::shape::{Style, Tool}; use crate::editor::shape::{Style, Tool};
use crate::frame::PixelRect;
use image::Rgba; use image::Rgba;
fn line(tool: Tool, color: [u8; 3]) -> Shape { fn line(tool: Tool, color: [u8; 3]) -> Shape {
@@ -92,6 +230,7 @@ mod tests {
tool, tool,
style: Style { color, width: 4.0 }, style: Style { color, width: 4.0 },
points: vec![(10.0, 20.0), (90.0, 20.0)], points: vec![(10.0, 20.0), (90.0, 20.0)],
text: String::new(),
} }
} }
@@ -119,6 +258,7 @@ mod tests {
width: 8.0, width: 8.0,
}, },
points: vec![(10.0, 10.0), (50.0, 30.0)], points: vec![(10.0, 10.0), (50.0, 30.0)],
text: String::new(),
}; };
let out = render(&base, &[rect]); let out = render(&base, &[rect]);
// A round join would leave the outer corner (6..7, 6..7) white. // 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") 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);
}
} }
-2
View File
@@ -3,8 +3,6 @@
pub mod doc; pub mod doc;
pub mod export; pub mod export;
pub mod shape; pub mod shape;
// Used from Task 2 of the 5b plan on; that task removes this.
#[allow(dead_code)]
pub mod text; pub mod text;
pub mod view; pub mod view;
+97 -5
View File
@@ -11,16 +11,24 @@ pub enum Tool {
Line, Line,
Pen, Pen,
Highlighter, Highlighter,
Text,
Pixelate,
Number,
Crop,
} }
impl Tool { impl Tool {
pub const ALL: [Tool; 6] = [ pub const ALL: [Tool; 10] = [
Tool::Arrow, Tool::Arrow,
Tool::Rectangle, Tool::Rectangle,
Tool::Ellipse, Tool::Ellipse,
Tool::Line, Tool::Line,
Tool::Pen, Tool::Pen,
Tool::Highlighter, Tool::Highlighter,
Tool::Text,
Tool::Pixelate,
Tool::Number,
Tool::Crop,
]; ];
pub fn label(self) -> &'static str { pub fn label(self) -> &'static str {
@@ -31,6 +39,10 @@ impl Tool {
Tool::Line => "Line", Tool::Line => "Line",
Tool::Pen => "Pen", Tool::Pen => "Pen",
Tool::Highlighter => "Highlighter", 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. /// 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)]; 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 HIGHLIGHT_ALPHA: u8 = 102;
const CLICK: f32 = 2.0; const CLICK: f32 = 2.0;
const ELLIPSE_POINTS: usize = 72; const ELLIPSE_POINTS: usize = 72;
@@ -64,18 +93,20 @@ pub struct Style {
} }
/// One finished mark. Two-point tools hold [start, end]; Pen and /// 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)] #[derive(Debug, Clone, PartialEq)]
pub struct Shape { pub struct Shape {
pub tool: Tool, pub tool: Tool,
pub style: Style, pub style: Style,
pub points: Vec<P>, pub points: Vec<P>,
pub text: String,
} }
impl Shape { impl Shape {
/// A press without a real drag. A Pen click still leaves a dot. /// A press without a real drag. A Pen click still leaves a dot.
pub fn is_click(&self) -> bool { pub fn is_click(&self) -> bool {
if self.tool == Tool::Pen { if matches!(self.tool, Tool::Pen | Tool::Text | Tool::Number) {
return false; return false;
} }
let first = self.points[0]; let first = self.points[0];
@@ -120,6 +151,7 @@ impl Drag {
tool: self.tool, tool: self.tool,
style: self.style, style: self.style,
points, 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); let len = dx.hypot(dy);
(start.0 + len * angle.cos(), start.1 + len * angle.sin()) (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()); let side = dx.abs().max(dy.abs());
(start.0 + side.copysign(dx), start.1 + side.copysign(dy)) (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<Prim> {
}] }]
} }
Tool::Pen => vec![stroke(pts.clone(), false)], 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 { Tool::Highlighter => vec![Prim::Stroke {
points: pts.clone(), points: pts.clone(),
width: w * 4.0, width: w * 4.0,
@@ -266,6 +311,7 @@ mod tests {
tool, tool,
style: RED, style: RED,
points: points.to_vec(), 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(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)]); 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());
}
}
} }