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