1use ab_glyph::{Font, FontRef, PxScale, ScaleFont};
35use image::{DynamicImage, Rgba, RgbaImage};
36use imageproc::drawing;
37use thiserror::Error;
38
39pub mod color;
40pub use color::Color;
41
42#[derive(Debug, Error)]
43pub enum AnnotateError {
44 #[error("decode input PNG: {0}")]
45 Decode(#[from] image::ImageError),
46 #[error("compress output PNG: {0}")]
47 Compress(String),
48 #[error("font load: {0}")]
49 Font(String),
50 #[error("text annotation without font — call `.font(bytes)` before `.render()`")]
51 MissingFont,
52}
53
54#[derive(Clone, Copy, Debug, PartialEq)]
56pub enum Position {
57 Pixel { x: i32, y: i32 },
59 Normalized { nx: f32, ny: f32 },
62}
63
64impl Position {
65 pub fn pixel(x: i32, y: i32) -> Self {
66 Position::Pixel { x, y }
67 }
68
69 pub fn normalized(nx: f32, ny: f32) -> Self {
70 Position::Normalized { nx, ny }
71 }
72
73 fn resolve(self, w: u32, h: u32) -> (i32, i32) {
74 match self {
75 Position::Pixel { x, y } => (x, y),
76 Position::Normalized { nx, ny } => ((nx * w as f32) as i32, (ny * h as f32) as i32),
77 }
78 }
79}
80
81#[derive(Clone, Debug)]
83pub enum Annotation {
84 Circle {
85 at: Position,
86 color: Color,
87 radius: i32,
88 stroke: i32,
89 },
90 Arrow {
91 from: Position,
92 to: Position,
93 color: Color,
94 stroke: i32,
95 head_filled: bool,
96 },
97 Text {
98 at: Position,
99 content: String,
100 color: Color,
101 size: f32,
102 },
103 Box {
104 at: Position,
105 width: i32,
106 height: i32,
107 color: Color,
108 stroke: i32,
109 },
110 Line {
111 from: Position,
112 to: Position,
113 color: Color,
114 stroke: i32,
115 },
116}
117
118impl Annotation {
119 pub fn circle(at: Position) -> CircleBuilder {
120 CircleBuilder {
121 at,
122 color: Color::RED,
123 radius: 30,
124 stroke: 3,
125 }
126 }
127
128 pub fn arrow(from: Position, to: Position) -> ArrowBuilder {
129 ArrowBuilder {
130 from,
131 to,
132 color: Color::BLUE,
133 stroke: 4,
134 head_filled: true,
135 }
136 }
137
138 pub fn text(at: Position, content: impl Into<String>) -> TextBuilder {
139 TextBuilder {
140 at,
141 content: content.into(),
142 color: Color::WHITE,
143 size: 24.0,
144 }
145 }
146
147 pub fn box_(at: Position, width: i32, height: i32) -> BoxBuilder {
148 BoxBuilder {
149 at,
150 width,
151 height,
152 color: Color::YELLOW,
153 stroke: 2,
154 }
155 }
156
157 pub fn line(from: Position, to: Position) -> LineBuilder {
158 LineBuilder {
159 from,
160 to,
161 color: Color::CYAN,
162 stroke: 2,
163 }
164 }
165}
166
167pub struct CircleBuilder {
168 at: Position,
169 color: Color,
170 radius: i32,
171 stroke: i32,
172}
173
174impl CircleBuilder {
175 pub fn color(mut self, c: Color) -> Self {
176 self.color = c;
177 self
178 }
179 pub fn radius(mut self, r: i32) -> Self {
180 self.radius = r;
181 self
182 }
183 pub fn stroke(mut self, s: i32) -> Self {
184 self.stroke = s;
185 self
186 }
187 pub fn build(self) -> Annotation {
188 Annotation::Circle {
189 at: self.at,
190 color: self.color,
191 radius: self.radius,
192 stroke: self.stroke,
193 }
194 }
195}
196
197impl From<CircleBuilder> for Annotation {
198 fn from(b: CircleBuilder) -> Self {
199 b.build()
200 }
201}
202
203pub struct ArrowBuilder {
204 from: Position,
205 to: Position,
206 color: Color,
207 stroke: i32,
208 head_filled: bool,
209}
210
211impl ArrowBuilder {
212 pub fn color(mut self, c: Color) -> Self {
213 self.color = c;
214 self
215 }
216 pub fn stroke(mut self, s: i32) -> Self {
217 self.stroke = s;
218 self
219 }
220 pub fn head_open(mut self) -> Self {
221 self.head_filled = false;
222 self
223 }
224 pub fn build(self) -> Annotation {
225 Annotation::Arrow {
226 from: self.from,
227 to: self.to,
228 color: self.color,
229 stroke: self.stroke,
230 head_filled: self.head_filled,
231 }
232 }
233}
234
235impl From<ArrowBuilder> for Annotation {
236 fn from(b: ArrowBuilder) -> Self {
237 b.build()
238 }
239}
240
241pub struct TextBuilder {
242 at: Position,
243 content: String,
244 color: Color,
245 size: f32,
246}
247
248impl TextBuilder {
249 pub fn color(mut self, c: Color) -> Self {
250 self.color = c;
251 self
252 }
253 pub fn size(mut self, s: f32) -> Self {
254 self.size = s;
255 self
256 }
257 pub fn build(self) -> Annotation {
258 Annotation::Text {
259 at: self.at,
260 content: self.content,
261 color: self.color,
262 size: self.size,
263 }
264 }
265}
266
267impl From<TextBuilder> for Annotation {
268 fn from(b: TextBuilder) -> Self {
269 b.build()
270 }
271}
272
273pub struct BoxBuilder {
274 at: Position,
275 width: i32,
276 height: i32,
277 color: Color,
278 stroke: i32,
279}
280
281impl BoxBuilder {
282 pub fn color(mut self, c: Color) -> Self {
283 self.color = c;
284 self
285 }
286 pub fn stroke(mut self, s: i32) -> Self {
287 self.stroke = s;
288 self
289 }
290 pub fn build(self) -> Annotation {
291 Annotation::Box {
292 at: self.at,
293 width: self.width,
294 height: self.height,
295 color: self.color,
296 stroke: self.stroke,
297 }
298 }
299}
300
301impl From<BoxBuilder> for Annotation {
302 fn from(b: BoxBuilder) -> Self {
303 b.build()
304 }
305}
306
307pub struct LineBuilder {
308 from: Position,
309 to: Position,
310 color: Color,
311 stroke: i32,
312}
313
314impl LineBuilder {
315 pub fn color(mut self, c: Color) -> Self {
316 self.color = c;
317 self
318 }
319 pub fn stroke(mut self, s: i32) -> Self {
320 self.stroke = s;
321 self
322 }
323 pub fn build(self) -> Annotation {
324 Annotation::Line {
325 from: self.from,
326 to: self.to,
327 color: self.color,
328 stroke: self.stroke,
329 }
330 }
331}
332
333impl From<LineBuilder> for Annotation {
334 fn from(b: LineBuilder) -> Self {
335 b.build()
336 }
337}
338
339#[derive(Clone, Copy, Debug, PartialEq, Eq)]
341pub enum Compression {
342 Fast,
344 Balanced,
346 Aggressive,
348}
349
350impl Compression {
351 pub const FAST: Self = Compression::Fast;
352 pub const BALANCED: Self = Compression::Balanced;
353 pub const AGGRESSIVE: Self = Compression::Aggressive;
354}
355
356#[allow(clippy::derivable_impls)]
357impl Default for Compression {
358 fn default() -> Self {
359 Compression::Balanced
360 }
361}
362
363pub struct Annotator {
365 image: RgbaImage,
366 annotations: Vec<Annotation>,
367 font_bytes: Option<Vec<u8>>,
368 compression: Compression,
369}
370
371impl Annotator {
372 pub fn new(png_bytes: &[u8]) -> Result<Self, AnnotateError> {
374 let dyn_img = image::load_from_memory(png_bytes)?;
375 let image = dyn_img.to_rgba8();
376 Ok(Self {
377 image,
378 annotations: Vec::new(),
379 font_bytes: None,
380 compression: Compression::default(),
381 })
382 }
383
384 #[allow(clippy::should_implement_trait)]
387 pub fn add(mut self, a: impl Into<Annotation>) -> Self {
388 self.annotations.push(a.into());
389 self
390 }
391
392 pub fn font(mut self, bytes: Vec<u8>) -> Self {
395 self.font_bytes = Some(bytes);
396 self
397 }
398
399 pub fn compression(mut self, c: Compression) -> Self {
400 self.compression = c;
401 self
402 }
403
404 pub fn render(mut self) -> Result<Vec<u8>, AnnotateError> {
407 let (w, h) = (self.image.width(), self.image.height());
408 let font = self
411 .font_bytes
412 .as_ref()
413 .map(|b| FontRef::try_from_slice(b).map_err(|e| AnnotateError::Font(e.to_string())))
414 .transpose()?;
415 for ann in std::mem::take(&mut self.annotations) {
416 match ann {
417 Annotation::Circle {
418 at,
419 color,
420 radius,
421 stroke,
422 } => {
423 let (cx, cy) = at.resolve(w, h);
424 let rgba = Rgba(color.to_rgba());
425 for i in 0..stroke {
426 drawing::draw_hollow_circle_mut(
427 &mut self.image,
428 (cx, cy),
429 (radius - i).max(1),
430 rgba,
431 );
432 }
433 }
434 Annotation::Arrow {
435 from,
436 to,
437 color,
438 stroke,
439 head_filled,
440 } => {
441 let (fx, fy) = from.resolve(w, h);
442 let (tx, ty) = to.resolve(w, h);
443 let rgba = Rgba(color.to_rgba());
444 for i in -(stroke / 2)..=stroke / 2 {
446 drawing::draw_line_segment_mut(
447 &mut self.image,
448 (fx as f32 + i as f32, fy as f32),
449 (tx as f32 + i as f32, ty as f32),
450 rgba,
451 );
452 }
453 let dx = (tx - fx) as f32;
455 let dy = (ty - fy) as f32;
456 let len = (dx * dx + dy * dy).sqrt().max(1.0);
457 let ux = dx / len;
458 let uy = dy / len;
459 let head_len = 15.0_f32.min(len / 3.0);
460 let angle = 30.0_f32.to_radians();
462 let (sin, cos) = (angle.sin(), angle.cos());
463 let hx1 = tx as f32 - head_len * (ux * cos - uy * sin);
464 let hy1 = ty as f32 - head_len * (uy * cos + ux * sin);
465 let hx2 = tx as f32 - head_len * (ux * cos + uy * sin);
466 let hy2 = ty as f32 - head_len * (uy * cos - ux * sin);
467 drawing::draw_line_segment_mut(
468 &mut self.image,
469 (tx as f32, ty as f32),
470 (hx1, hy1),
471 rgba,
472 );
473 drawing::draw_line_segment_mut(
474 &mut self.image,
475 (tx as f32, ty as f32),
476 (hx2, hy2),
477 rgba,
478 );
479 if head_filled {
480 drawing::draw_line_segment_mut(
483 &mut self.image,
484 (hx1, hy1),
485 (hx2, hy2),
486 rgba,
487 );
488 }
489 }
490 Annotation::Text {
491 at,
492 content,
493 color,
494 size,
495 } => {
496 let font = font.as_ref().ok_or(AnnotateError::MissingFont)?;
497 let (x, y) = at.resolve(w, h);
498 let scale = PxScale::from(size);
499 let scaled = font.as_scaled(scale);
500 let ascent = scaled.ascent();
501 let rgba = Rgba(color.to_rgba());
502 let mut caret_x = x as f32;
503 let baseline_y = y as f32 + ascent;
504 for ch in content.chars() {
505 let glyph = scaled.scaled_glyph(ch);
506 let h_adv = scaled.h_advance(glyph.id);
507 if let Some(outlined) = font.outline_glyph(glyph) {
508 let bounds = outlined.px_bounds();
509 outlined.draw(|gx, gy, alpha| {
510 let px = caret_x + bounds.min.x + gx as f32;
511 let py = baseline_y + bounds.min.y + gy as f32;
512 if px >= 0.0 && py >= 0.0 && (px as u32) < w && (py as u32) < h {
513 let blended = blend_pixel(
514 self.image.get_pixel(px as u32, py as u32).0,
515 rgba.0,
516 alpha,
517 );
518 self.image.put_pixel(px as u32, py as u32, Rgba(blended));
519 }
520 });
521 }
522 caret_x += h_adv;
523 }
524 }
525 Annotation::Box {
526 at,
527 width,
528 height,
529 color,
530 stroke,
531 } => {
532 let (x, y) = at.resolve(w, h);
533 let rgba = Rgba(color.to_rgba());
534 for i in 0..stroke {
535 let rect = imageproc::rect::Rect::at(x + i, y + i).of_size(
536 (width - 2 * i).max(1) as u32,
537 (height - 2 * i).max(1) as u32,
538 );
539 drawing::draw_hollow_rect_mut(&mut self.image, rect, rgba);
540 }
541 }
542 Annotation::Line {
543 from,
544 to,
545 color,
546 stroke,
547 } => {
548 let (fx, fy) = from.resolve(w, h);
549 let (tx, ty) = to.resolve(w, h);
550 let rgba = Rgba(color.to_rgba());
551 for i in -(stroke / 2)..=stroke / 2 {
552 drawing::draw_line_segment_mut(
553 &mut self.image,
554 (fx as f32, fy as f32 + i as f32),
555 (tx as f32, ty as f32 + i as f32),
556 rgba,
557 );
558 }
559 }
560 }
561 }
562 let mut raw = Vec::new();
564 let dyn_img = DynamicImage::ImageRgba8(self.image);
565 dyn_img
566 .write_to(&mut std::io::Cursor::new(&mut raw), image::ImageFormat::Png)
567 .map_err(AnnotateError::Decode)?;
568 match self.compression {
570 Compression::Fast => Ok(raw),
571 Compression::Balanced => oxipng::optimize_from_memory(
572 &raw,
573 &oxipng::Options {
574 optimize_alpha: false,
575 ..oxipng::Options::from_preset(2)
576 },
577 )
578 .map_err(|e| AnnotateError::Compress(e.to_string())),
579 Compression::Aggressive => oxipng::optimize_from_memory(
580 &raw,
581 &oxipng::Options {
582 optimize_alpha: false,
583 ..oxipng::Options::from_preset(6)
584 },
585 )
586 .map_err(|e| AnnotateError::Compress(e.to_string())),
587 }
588 }
589}
590
591fn blend_pixel(bg: [u8; 4], fg: [u8; 4], alpha: f32) -> [u8; 4] {
592 let a = (fg[3] as f32 / 255.0) * alpha;
593 let inv_a = 1.0 - a;
594 [
595 (bg[0] as f32 * inv_a + fg[0] as f32 * a) as u8,
596 (bg[1] as f32 * inv_a + fg[1] as f32 * a) as u8,
597 (bg[2] as f32 * inv_a + fg[2] as f32 * a) as u8,
598 (bg[3] as f32).max(fg[3] as f32) as u8,
599 ]
600}
601
602#[cfg(test)]
603mod tests {
604 use super::*;
605
606 fn blank_png() -> Vec<u8> {
607 let img: RgbaImage = image::ImageBuffer::from_pixel(100, 100, Rgba([0, 0, 0, 255]));
608 let mut out = Vec::new();
609 DynamicImage::ImageRgba8(img)
610 .write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
611 .unwrap();
612 out
613 }
614
615 #[test]
616 fn circle_pixels_written() {
617 let png = blank_png();
618 let out = Annotator::new(&png)
619 .unwrap()
620 .add(
621 Annotation::circle(Position::pixel(50, 50))
622 .color(Color::RED)
623 .radius(20),
624 )
625 .compression(Compression::Fast)
626 .render()
627 .unwrap();
628 let img = image::load_from_memory(&out).unwrap().to_rgba8();
629 let px = img.get_pixel(70, 50);
632 assert!(px[0] > 100, "expected red-dominant, got {px:?}");
633 }
634
635 #[test]
636 fn arrow_pixels_written() {
637 let png = blank_png();
638 let out = Annotator::new(&png)
639 .unwrap()
640 .add(Annotation::arrow(
641 Position::pixel(10, 10),
642 Position::pixel(90, 90),
643 ))
644 .compression(Compression::Fast)
645 .render()
646 .unwrap();
647 let img = image::load_from_memory(&out).unwrap().to_rgba8();
648 let px = img.get_pixel(50, 50);
650 assert!(px[2] > 100, "expected blue-dominant midpoint, got {px:?}");
651 }
652
653 #[test]
654 fn box_pixels_written() {
655 let png = blank_png();
656 let out = Annotator::new(&png)
657 .unwrap()
658 .add(Annotation::box_(Position::pixel(20, 20), 60, 60).color(Color::YELLOW))
659 .compression(Compression::Fast)
660 .render()
661 .unwrap();
662 let img = image::load_from_memory(&out).unwrap().to_rgba8();
663 let px = img.get_pixel(25, 20);
665 assert!(
666 px[0] > 100 && px[1] > 100,
667 "expected yellow edge, got {px:?}"
668 );
669 }
670
671 #[test]
672 fn line_pixels_written() {
673 let png = blank_png();
674 let out = Annotator::new(&png)
675 .unwrap()
676 .add(Annotation::line(
677 Position::pixel(0, 50),
678 Position::pixel(99, 50),
679 ))
680 .compression(Compression::Fast)
681 .render()
682 .unwrap();
683 let img = image::load_from_memory(&out).unwrap().to_rgba8();
684 let px = img.get_pixel(50, 50);
686 assert!(
687 px[1] > 100 && px[2] > 100,
688 "expected cyan midpoint, got {px:?}"
689 );
690 }
691
692 #[test]
693 fn text_without_font_errors() {
694 let png = blank_png();
695 let err = Annotator::new(&png)
696 .unwrap()
697 .add(Annotation::text(Position::pixel(10, 10), "hello"))
698 .render()
699 .unwrap_err();
700 assert!(matches!(err, AnnotateError::MissingFont));
701 }
702
703 #[test]
704 fn compression_presets_all_encode() {
705 for preset in [
706 Compression::Fast,
707 Compression::Balanced,
708 Compression::Aggressive,
709 ] {
710 let png = blank_png();
711 let out = Annotator::new(&png)
712 .unwrap()
713 .add(
714 Annotation::circle(Position::pixel(50, 50))
715 .color(Color::RED)
716 .radius(20),
717 )
718 .compression(preset)
719 .render()
720 .unwrap();
721 assert!(!out.is_empty(), "preset {preset:?} produced empty output");
722 let _ = image::load_from_memory(&out).unwrap();
724 }
725 }
726
727 #[test]
728 fn normalized_position_resolves() {
729 let (x, y) = Position::normalized(0.5, 0.5).resolve(100, 100);
730 assert_eq!((x, y), (50, 50));
731 }
732
733 #[test]
734 fn compression_default_is_balanced() {
735 assert_eq!(Compression::default(), Compression::Balanced);
736 }
737}