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