use crate::element::{sane_scale, Rect};
use crate::error::{Error, Result};
use crate::input::Point;
use crate::screenshot::Screenshot;
pub const ANNOTATION_PALETTE: [[u8; 3]; 7] = [
[0xE6, 0x9F, 0x00], [0x56, 0xB4, 0xE9], [0x00, 0x9E, 0x73], [0xF0, 0xE4, 0x42], [0x00, 0x72, 0xB2], [0xD5, 0x5E, 0x00], [0xCC, 0x79, 0xA7], ];
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Annotation {
pub rect: Rect,
pub tag: String,
pub color: [u8; 3],
}
impl Annotation {
pub fn new(rect: Rect, tag: impl Into<String>) -> Self {
Self {
rect,
tag: tag.into(),
color: ANNOTATION_PALETTE[0],
}
}
#[must_use]
pub fn color(mut self, rgb: [u8; 3]) -> Self {
self.color = rgb;
self
}
}
pub fn tag_for(group: usize, index: usize) -> String {
let mut n = group.max(1);
let mut letters = Vec::new();
while n > 0 {
let rem = (n - 1) % 26;
letters.push(char::from(b'A' + rem as u8));
n = (n - 1) / 26;
}
let mut tag: String = letters.into_iter().rev().collect();
tag.push_str(&index.max(1).to_string());
tag
}
impl Screenshot {
pub fn annotate(
&self,
annotations: &[Annotation],
origin: Point,
) -> Result<(Screenshot, Vec<usize>)> {
let expected = (self.width as usize)
.checked_mul(self.height as usize)
.and_then(|n| n.checked_mul(4))
.ok_or_else(|| Error::Platform {
code: -1,
message: "annotate: screenshot dimensions overflow".into(),
})?;
if self.pixels.len() != expected {
return Err(Error::Platform {
code: -1,
message: format!(
"annotate: screenshot pixel buffer size {} does not match {}x{} RGBA ({} bytes)",
self.pixels.len(),
self.width,
self.height,
expected
),
});
}
let scale = sane_scale(f64::from(self.scale));
let unit = scale.round().clamp(1.0, 4.0) as i64;
let glyph_unit = unit * 2;
let mut canvas = Canvas {
width: i64::from(self.width),
height: i64::from(self.height),
pixels: self.pixels.clone(),
};
let bounds = canvas.bounds();
let mut skipped = Vec::new();
let mut visible: Vec<(usize, &Annotation, PxRect)> = Vec::new();
for (i, ann) in annotations.iter().enumerate() {
let translated = Rect {
x: ann.rect.x.saturating_sub(origin.x),
y: ann.rect.y.saturating_sub(origin.y),
width: ann.rect.width,
height: ann.rect.height,
};
let physical = PxRect::from_rect(translated.to_physical(scale));
if physical.overlaps(bounds) {
visible.push((i, ann, physical));
} else {
skipped.push(i);
}
}
visible.sort_by(|(ai, _, ar), (bi, _, br)| br.area().cmp(&ar.area()).then(ai.cmp(bi)));
let mut placed: Vec<PxRect> = Vec::new();
for (_, ann, rect) in visible {
draw_box(&mut canvas, rect, unit, ann.color);
if let Some(badge) = BadgeMetrics::for_tag(&ann.tag, glyph_unit, unit) {
let spot = badge_spot(rect, badge.width, badge.height, unit, bounds, &placed);
let fg = foreground_for(ann.color);
canvas.fill(spot, fg);
canvas.fill(badge.fill_area(spot), ann.color);
draw_tag(
&mut canvas,
&ann.tag,
spot.x0.saturating_add(badge.inset),
spot.y0.saturating_add(badge.inset),
badge.unit,
fg,
);
placed.push(spot);
}
}
Ok((
Screenshot::new(self.width, self.height, canvas.pixels, self.scale),
skipped,
))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct PxRect {
x0: i64,
y0: i64,
x1: i64,
y1: i64,
}
impl PxRect {
fn from_rect(r: Rect) -> Self {
let x0 = i64::from(r.x);
let y0 = i64::from(r.y);
Self {
x0,
y0,
x1: x0 + i64::from(r.width),
y1: y0 + i64::from(r.height),
}
}
fn at(x0: i64, y0: i64, width: i64, height: i64) -> Self {
Self {
x0,
y0,
x1: x0.saturating_add(width),
y1: y0.saturating_add(height),
}
}
fn is_empty(self) -> bool {
self.x1 <= self.x0 || self.y1 <= self.y0
}
fn area(self) -> i64 {
if self.is_empty() {
0
} else {
(self.x1 - self.x0).saturating_mul(self.y1 - self.y0)
}
}
fn clipped_to(self, clip: Self) -> Self {
Self {
x0: self.x0.max(clip.x0),
y0: self.y0.max(clip.y0),
x1: self.x1.min(clip.x1),
y1: self.y1.min(clip.y1),
}
}
fn contained_by(self, other: Self) -> bool {
self.is_empty()
|| (self.x0 >= other.x0
&& self.y0 >= other.y0
&& self.x1 <= other.x1
&& self.y1 <= other.y1)
}
fn overlaps(self, other: Self) -> bool {
!self.is_empty()
&& !other.is_empty()
&& self.x0 < other.x1
&& other.x0 < self.x1
&& self.y0 < other.y1
&& other.y0 < self.y1
}
}
struct Canvas {
width: i64,
height: i64,
pixels: Vec<u8>,
}
impl Canvas {
fn bounds(&self) -> PxRect {
PxRect {
x0: 0,
y0: 0,
x1: self.width,
y1: self.height,
}
}
fn set_px(&mut self, x: i64, y: i64, rgb: [u8; 3]) {
if x < 0 || y < 0 || x >= self.width || y >= self.height {
return;
}
let Some(index) = y
.checked_mul(self.width)
.and_then(|row| row.checked_add(x))
.and_then(|offset| offset.checked_mul(4))
else {
return;
};
let Ok(index) = usize::try_from(index) else {
return;
};
let Some(end) = index.checked_add(4) else {
return;
};
if end > self.pixels.len() {
return;
}
self.pixels[index..end].copy_from_slice(&[rgb[0], rgb[1], rgb[2], 255]);
}
fn fill(&mut self, rect: PxRect, rgb: [u8; 3]) {
let x0 = rect.x0.max(0);
let y0 = rect.y0.max(0);
let x1 = rect.x1.min(self.width);
let y1 = rect.y1.min(self.height);
if x1 <= x0 || y1 <= y0 {
return;
}
for y in y0..y1 {
for x in x0..x1 {
self.set_px(x, y, rgb);
}
}
}
}
fn draw_box(canvas: &mut Canvas, rect: PxRect, stroke: i64, rgb: [u8; 3]) {
if rect.is_empty() {
return;
}
let sx = stroke.min(rect.x1 - rect.x0);
let sy = stroke.min(rect.y1 - rect.y0);
canvas.fill(PxRect::at(rect.x0, rect.y0, rect.x1 - rect.x0, sy), rgb);
canvas.fill(
PxRect::at(rect.x0, rect.y1 - sy, rect.x1 - rect.x0, sy),
rgb,
);
canvas.fill(PxRect::at(rect.x0, rect.y0, sx, rect.y1 - rect.y0), rgb);
canvas.fill(
PxRect::at(rect.x1 - sx, rect.y0, sx, rect.y1 - rect.y0),
rgb,
);
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct BadgeMetrics {
width: i64,
height: i64,
unit: i64,
outline: i64,
inset: i64,
}
impl BadgeMetrics {
fn for_tag(tag: &str, unit: i64, outline: i64) -> Option<Self> {
let glyphs = i64::try_from(tag.chars().filter_map(glyph_index).count()).unwrap_or(i64::MAX);
if glyphs == 0 {
return None;
}
let text_w = glyphs
.saturating_mul(GLYPH_W * unit)
.saturating_add((glyphs - 1).saturating_mul(unit));
let inset = unit + outline;
Some(Self {
width: text_w.saturating_add(2 * inset),
height: GLYPH_H * unit + 2 * inset,
unit,
outline,
inset,
})
}
fn fill_area(self, spot: PxRect) -> PxRect {
PxRect {
x0: spot.x0.saturating_add(self.outline),
y0: spot.y0.saturating_add(self.outline),
x1: spot.x1.saturating_sub(self.outline),
y1: spot.y1.saturating_sub(self.outline),
}
}
}
fn badge_spot(
rect: PxRect,
width: i64,
height: i64,
stroke: i64,
image: PxRect,
placed: &[PxRect],
) -> PxRect {
let outer_left = rect.x0;
let outer_right = rect.x1.saturating_sub(width);
let above = rect.y0.saturating_sub(height);
let below = rect.y1;
let inner_left = rect.x0.saturating_add(stroke);
let inner_top = rect.y0.saturating_add(stroke);
let inner_right = rect.x1.saturating_sub(stroke).saturating_sub(width);
let inner_bottom = rect.y1.saturating_sub(stroke).saturating_sub(height);
let candidates = [
PxRect::at(outer_left, above, width, height),
PxRect::at(outer_right, above, width, height),
PxRect::at(outer_left, below, width, height),
PxRect::at(outer_right, below, width, height),
PxRect::at(rect.x0.saturating_sub(width), rect.y0, width, height),
PxRect::at(rect.x1, rect.y0, width, height),
PxRect::at(inner_left, inner_top, width, height),
PxRect::at(inner_right, inner_top, width, height),
PxRect::at(inner_left, inner_bottom, width, height),
PxRect::at(inner_right, inner_bottom, width, height),
];
let mut best: Option<((bool, bool, bool, i64), PxRect)> = None;
for candidate in candidates {
let visible = candidate.clipped_to(image).area();
let free = !placed.iter().any(|p| p.overlaps(candidate));
let key = (visible > 0, free, candidate.contained_by(image), visible);
if best.is_none_or(|(best_key, _)| key > best_key) {
best = Some((key, candidate));
}
}
match best {
Some((key, spot)) if key.0 => spot,
_ => clamped_spot(rect, width, height, stroke, image),
}
}
fn clamped_spot(rect: PxRect, width: i64, height: i64, stroke: i64, image: PxRect) -> PxRect {
let visible = rect.clipped_to(image);
let start_x = visible.x0.saturating_add(stroke);
let start_y = visible.y0.saturating_add(stroke);
let x = start_x.min(image.x1.saturating_sub(width)).max(image.x0);
let y = start_y.min(image.y1.saturating_sub(height)).max(image.y0);
PxRect::at(x, y, width, height)
}
const BLACK: [u8; 3] = [0, 0, 0];
const WHITE: [u8; 3] = [255, 255, 255];
fn relative_luminance(rgb: [u8; 3]) -> f64 {
fn linear(channel: u8) -> f64 {
let c = f64::from(channel) / 255.0;
if c <= 0.03928 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
}
0.2126 * linear(rgb[0]) + 0.7152 * linear(rgb[1]) + 0.0722 * linear(rgb[2])
}
fn contrast_ratio(a: [u8; 3], b: [u8; 3]) -> f64 {
let (la, lb) = (relative_luminance(a), relative_luminance(b));
let (lighter, darker) = if la >= lb { (la, lb) } else { (lb, la) };
(lighter + 0.05) / (darker + 0.05)
}
fn foreground_for(background: [u8; 3]) -> [u8; 3] {
if contrast_ratio(BLACK, background) >= contrast_ratio(WHITE, background) {
BLACK
} else {
WHITE
}
}
const GLYPH_W: i64 = 5;
const GLYPH_H: i64 = 7;
fn glyph_index(c: char) -> Option<usize> {
let c = c.to_ascii_uppercase();
match c {
'0'..='9' => Some(c as usize - '0' as usize),
'A'..='Z' => Some(10 + c as usize - 'A' as usize),
_ => None,
}
}
fn draw_tag(canvas: &mut Canvas, tag: &str, x: i64, y: i64, unit: i64, rgb: [u8; 3]) {
let advance = GLYPH_W * unit + unit;
let mut pen = x;
for glyph in tag.chars().filter_map(glyph_index) {
if pen >= canvas.width {
break;
}
draw_glyph(canvas, glyph, pen, y, unit, rgb);
pen = pen.saturating_add(advance);
}
}
fn draw_glyph(canvas: &mut Canvas, glyph: usize, x: i64, y: i64, unit: i64, rgb: [u8; 3]) {
let Some(rows) = FONT.get(glyph) else {
return;
};
for (row, bits) in rows.iter().enumerate() {
for col in 0..GLYPH_W {
if *bits & (1_u8 << (GLYPH_W - 1 - col)) == 0 {
continue;
}
let px = x.saturating_add(col * unit);
let py = y.saturating_add(row as i64 * unit);
canvas.fill(PxRect::at(px, py, unit, unit), rgb);
}
}
}
#[rustfmt::skip]
const FONT: [[u8; 7]; 36] = [
[
0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110, ],
[
0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110, ],
[
0b01110, 0b10001, 0b00001, 0b00010, 0b00100, 0b01000, 0b11111, ],
[
0b11111, 0b00010, 0b00100, 0b00010, 0b00001, 0b10001, 0b01110, ],
[
0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010, ],
[
0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110, ],
[
0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110, ],
[
0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000, ],
[
0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110, ],
[
0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100, ],
[
0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001, ],
[
0b11110, 0b10001, 0b10001, 0b11110, 0b10001, 0b10001, 0b11110, ],
[
0b01110, 0b10001, 0b10000, 0b10000, 0b10000, 0b10001, 0b01110, ],
[
0b11100, 0b10010, 0b10001, 0b10001, 0b10001, 0b10010, 0b11100, ],
[
0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b11111, ],
[
0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000, ],
[
0b01110, 0b10001, 0b10000, 0b10111, 0b10001, 0b10001, 0b01111, ],
[
0b10001, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001, ],
[
0b01110, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110, ],
[
0b00111, 0b00010, 0b00010, 0b00010, 0b00010, 0b10010, 0b01100, ],
[
0b10001, 0b10010, 0b10100, 0b11000, 0b10100, 0b10010, 0b10001, ],
[
0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b11111, ],
[
0b10001, 0b11011, 0b10101, 0b10101, 0b10001, 0b10001, 0b10001, ],
[
0b10001, 0b10001, 0b11001, 0b10101, 0b10011, 0b10001, 0b10001, ],
[
0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110, ],
[
0b11110, 0b10001, 0b10001, 0b11110, 0b10000, 0b10000, 0b10000, ],
[
0b01110, 0b10001, 0b10001, 0b10001, 0b10101, 0b10010, 0b01101, ],
[
0b11110, 0b10001, 0b10001, 0b11110, 0b10100, 0b10010, 0b10001, ],
[
0b01111, 0b10000, 0b10000, 0b01110, 0b00001, 0b00001, 0b11110, ],
[
0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, ],
[
0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110, ],
[
0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01010, 0b00100, ],
[
0b10001, 0b10001, 0b10001, 0b10101, 0b10101, 0b11011, 0b10001, ],
[
0b10001, 0b10001, 0b01010, 0b00100, 0b01010, 0b10001, 0b10001, ],
[
0b10001, 0b10001, 0b01010, 0b00100, 0b00100, 0b00100, 0b00100, ],
[
0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b10000, 0b11111, ],
];
#[cfg(test)]
mod tests {
use super::*;
const BG: [u8; 4] = [0, 0, 0, 0];
const RED: [u8; 3] = [255, 0, 0];
const BLUE: [u8; 3] = [0, 0, 255];
fn blank(width: u32, height: u32, scale: f32) -> Screenshot {
let len = (width as usize) * (height as usize) * 4;
Screenshot::new(width, height, vec![0; len], scale)
}
fn px(shot: &Screenshot, x: u32, y: u32) -> [u8; 4] {
let i = ((y as usize) * (shot.width as usize) + x as usize) * 4;
[
shot.pixels[i],
shot.pixels[i + 1],
shot.pixels[i + 2],
shot.pixels[i + 3],
]
}
fn opaque(rgb: [u8; 3]) -> [u8; 4] {
[rgb[0], rgb[1], rgb[2], 255]
}
fn rect(x: i32, y: i32, width: u32, height: u32) -> Rect {
Rect {
x,
y,
width,
height,
}
}
fn boxed(r: Rect, color: [u8; 3]) -> Annotation {
Annotation::new(r, "").color(color)
}
fn annotate(shot: &Screenshot, anns: &[Annotation]) -> (Screenshot, Vec<usize>) {
shot.annotate(anns, Point::new(0, 0))
.expect("annotate on a well-formed capture")
}
#[test]
fn new_takes_the_first_palette_colour_and_color_overrides_it() {
let a = Annotation::new(rect(1, 2, 3, 4), "A1");
assert_eq!(a.color, ANNOTATION_PALETTE[0]);
assert_eq!(a.tag, "A1");
assert_eq!(a.rect, rect(1, 2, 3, 4));
let b = a.color(RED);
assert_eq!(b.color, RED);
}
#[test]
fn tag_for_letters_the_group_and_numbers_the_index() {
assert_eq!(tag_for(1, 1), "A1");
assert_eq!(tag_for(2, 7), "B7");
assert_eq!(tag_for(1, 12), "A12");
assert_eq!(tag_for(3, 999), "C999");
}
#[test]
fn tag_for_group_boundaries_roll_over_like_spreadsheet_columns() {
assert_eq!(tag_for(1, 1), "A1");
assert_eq!(tag_for(26, 1), "Z1");
assert_eq!(tag_for(27, 1), "AA1");
assert_eq!(tag_for(28, 1), "AB1");
assert_eq!(tag_for(52, 1), "AZ1");
assert_eq!(tag_for(53, 1), "BA1");
assert_eq!(tag_for(702, 1), "ZZ1");
assert_eq!(tag_for(703, 1), "AAA1");
}
#[test]
fn tag_for_reads_zero_as_one_since_every_index_is_one_based() {
assert_eq!(tag_for(0, 0), "A1");
assert_eq!(tag_for(0, 5), "A5");
assert_eq!(tag_for(2, 0), "B1");
}
#[test]
fn tag_for_never_produces_a_separator_that_could_be_lost() {
let mut seen = std::collections::HashSet::new();
for group in 1..=30_usize {
for index in 1..=30_usize {
assert!(
seen.insert(tag_for(group, index)),
"collision at group {group}, index {index}"
);
}
}
}
#[test]
fn every_palette_colour_clears_wcag_aa_against_its_foreground() {
for (i, &color) in ANNOTATION_PALETTE.iter().enumerate() {
let fg = foreground_for(color);
let ratio = contrast_ratio(fg, color);
assert!(
ratio >= 4.5,
"ANNOTATION_PALETTE[{i}] {color:?} against {fg:?} is {ratio:.2}:1, below 4.5:1"
);
}
}
#[test]
fn foreground_picks_black_on_light_and_white_on_dark() {
assert_eq!(foreground_for([0xF0, 0xE4, 0x42]), BLACK); assert_eq!(foreground_for([0x00, 0x72, 0xB2]), WHITE); }
#[test]
fn relative_luminance_matches_the_wcag_endpoints() {
assert!((relative_luminance(WHITE) - 1.0).abs() < 1e-9);
assert!(relative_luminance(BLACK).abs() < 1e-9);
assert!((relative_luminance(BLUE) - 0.0722).abs() < 1e-9);
assert!((contrast_ratio(WHITE, BLACK) - 21.0).abs() < 1e-9);
}
#[test]
fn stroke_lands_on_the_box_edges_and_nowhere_else() {
let shot = blank(16, 16, 1.0);
let (out, skipped) = annotate(&shot, &[boxed(rect(2, 3, 6, 5), RED)]);
assert!(skipped.is_empty());
for x in 2..8 {
assert_eq!(px(&out, x, 3), opaque(RED), "top edge at x={x}");
assert_eq!(px(&out, x, 7), opaque(RED), "bottom edge at x={x}");
}
for y in 3..8 {
assert_eq!(px(&out, 2, y), opaque(RED), "left edge at y={y}");
assert_eq!(px(&out, 7, y), opaque(RED), "right edge at y={y}");
}
for y in 4..7 {
for x in 3..7 {
assert_eq!(px(&out, x, y), BG, "interior at ({x}, {y})");
}
}
assert_eq!(px(&out, 1, 3), BG, "one left of the box");
assert_eq!(px(&out, 8, 3), BG, "one right of the box");
assert_eq!(px(&out, 2, 2), BG, "one above the box");
assert_eq!(px(&out, 2, 8), BG, "one below the box");
}
#[test]
fn origin_translates_the_rect_into_capture_space() {
let shot = blank(16, 16, 1.0);
let (out, skipped) = shot
.annotate(&[boxed(rect(12, 13, 6, 5), RED)], Point::new(10, 10))
.expect("annotate");
assert!(skipped.is_empty());
assert_eq!(px(&out, 2, 3), opaque(RED), "translated top-left corner");
assert_eq!(
px(&out, 7, 7),
opaque(RED),
"translated bottom-right corner"
);
assert_eq!(px(&out, 12, 13), BG, "untranslated position must be clean");
}
#[test]
fn scale_converts_logical_coordinates_and_thickens_the_stroke() {
let shot = blank(16, 16, 2.0);
let (out, skipped) = annotate(&shot, &[boxed(rect(1, 1, 4, 4), RED)]);
assert!(skipped.is_empty());
for x in 2..10 {
assert_eq!(px(&out, x, 2), opaque(RED), "outer top row at x={x}");
assert_eq!(px(&out, x, 3), opaque(RED), "inner top row at x={x}");
assert_eq!(px(&out, x, 8), opaque(RED), "inner bottom row at x={x}");
assert_eq!(px(&out, x, 9), opaque(RED), "outer bottom row at x={x}");
}
for y in 2..10 {
assert_eq!(px(&out, 2, y), opaque(RED), "outer left col at y={y}");
assert_eq!(px(&out, 3, y), opaque(RED), "inner left col at y={y}");
assert_eq!(px(&out, 8, y), opaque(RED), "inner right col at y={y}");
assert_eq!(px(&out, 9, y), opaque(RED), "outer right col at y={y}");
}
for y in 4..8 {
for x in 4..8 {
assert_eq!(px(&out, x, y), BG, "interior at ({x}, {y})");
}
}
assert_eq!(px(&out, 1, 2), BG, "one left of the scaled box");
assert_eq!(px(&out, 10, 2), BG, "one right of the scaled box");
}
#[test]
fn a_box_straddling_the_left_edge_is_clipped_not_moved() {
let shot = blank(8, 8, 1.0);
let (out, skipped) = annotate(&shot, &[boxed(rect(-3, 2, 6, 4), RED)]);
assert!(skipped.is_empty(), "partly visible must not be skipped");
assert_eq!(px(&out, 0, 2), opaque(RED), "top edge survives clipping");
assert_eq!(px(&out, 2, 2), opaque(RED), "right stroke stays at x=2");
assert_eq!(px(&out, 3, 2), BG, "nothing drawn past the box");
assert_eq!(px(&out, 0, 3), BG, "no left stroke pulled to the edge");
}
#[test]
fn a_box_straddling_the_right_edge_is_clipped_not_moved() {
let shot = blank(8, 8, 1.0);
let (out, skipped) = annotate(&shot, &[boxed(rect(5, 2, 6, 4), RED)]);
assert!(skipped.is_empty());
assert_eq!(px(&out, 5, 2), opaque(RED), "top edge survives clipping");
assert_eq!(
px(&out, 7, 2),
opaque(RED),
"top edge reaches the last column"
);
assert_eq!(px(&out, 7, 3), BG, "no right stroke pulled to the edge");
assert_eq!(px(&out, 4, 2), BG, "nothing drawn left of the box");
}
#[test]
fn a_box_straddling_the_top_edge_is_clipped_not_moved() {
let shot = blank(8, 8, 1.0);
let (out, skipped) = annotate(&shot, &[boxed(rect(2, -3, 4, 6), RED)]);
assert!(skipped.is_empty());
assert_eq!(px(&out, 2, 0), opaque(RED), "left edge survives clipping");
assert_eq!(px(&out, 2, 2), opaque(RED), "bottom stroke stays at y=2");
assert_eq!(px(&out, 3, 0), BG, "no top stroke pulled to the edge");
assert_eq!(px(&out, 2, 3), BG, "nothing drawn below the box");
}
#[test]
fn a_box_straddling_the_bottom_edge_is_clipped_not_moved() {
let shot = blank(8, 8, 1.0);
let (out, skipped) = annotate(&shot, &[boxed(rect(2, 5, 4, 6), RED)]);
assert!(skipped.is_empty());
assert_eq!(px(&out, 2, 5), opaque(RED), "top stroke stays at y=5");
assert_eq!(
px(&out, 2, 7),
opaque(RED),
"left edge reaches the last row"
);
assert_eq!(px(&out, 3, 7), BG, "no bottom stroke pulled to the edge");
assert_eq!(px(&out, 2, 4), BG, "nothing drawn above the box");
}
#[test]
fn rects_outside_the_image_are_reported_not_clamped() {
let shot = blank(8, 8, 1.0);
let anns = [
boxed(rect(100, 0, 4, 4), RED), boxed(rect(0, 0, 4, 4), RED), boxed(rect(-50, 0, 4, 4), RED), boxed(rect(0, -50, 4, 4), RED), boxed(rect(0, 200, 4, 4), RED), boxed(rect(8, 8, 4, 4), RED), ];
let (out, skipped) = annotate(&shot, &anns);
assert_eq!(skipped, vec![0, 2, 3, 4, 5]);
assert_eq!(
px(&out, 0, 0),
opaque(RED),
"the visible one is still drawn"
);
assert_eq!(px(&out, 7, 7), BG);
assert_eq!(px(&out, 7, 0), BG);
}
#[test]
fn zero_area_rects_are_skipped() {
let shot = blank(8, 8, 1.0);
let anns = [
boxed(rect(2, 2, 0, 4), RED),
boxed(rect(2, 2, 4, 0), RED),
boxed(rect(2, 2, 0, 0), RED),
];
let (out, skipped) = annotate(&shot, &anns);
assert_eq!(skipped, vec![0, 1, 2]);
assert_eq!(out.pixels, shot.pixels, "nothing drawn at all");
}
#[test]
fn a_tag_glyph_renders_in_the_contrasting_foreground() {
let shot = blank(40, 20, 1.0);
let (out, skipped) = annotate(
&shot,
&[Annotation::new(rect(0, 0, 40, 20), "A").color(BLUE)],
);
assert!(skipped.is_empty());
assert_eq!(
px(&out, 1, 1),
opaque(WHITE),
"badge outline is the foreground colour"
);
assert_eq!(
px(&out, 2, 2),
opaque(BLUE),
"inside the outline, the badge is the box colour"
);
for x in 6..12 {
assert_eq!(px(&out, x, 4), opaque(WHITE), "top bar of 'A' at x={x}");
assert_eq!(px(&out, x, 5), opaque(WHITE), "top bar, second row, x={x}");
}
assert_eq!(px(&out, 5, 4), opaque(BLUE), "left of the top bar is unset");
assert_eq!(
px(&out, 12, 4),
opaque(BLUE),
"right of the top bar is unset"
);
for x in 4..14 {
assert_eq!(px(&out, x, 10), opaque(WHITE), "crossbar of 'A' at x={x}");
}
assert_eq!(px(&out, 4, 6), opaque(WHITE), "left stem of 'A'");
assert_eq!(px(&out, 12, 6), opaque(WHITE), "right stem of 'A'");
assert_eq!(px(&out, 8, 6), opaque(BLUE), "counter of 'A' is not filled");
assert_eq!(px(&out, 4, 16), opaque(WHITE), "last row, left stem");
assert_eq!(
px(&out, 16, 4),
opaque(WHITE),
"the badge's right outline column"
);
assert_eq!(
px(&out, 17, 4),
BG,
"the badge is only as wide as one glyph"
);
}
#[test]
fn a_tag_with_no_drawable_glyph_gets_no_badge() {
let shot = blank(20, 20, 1.0);
let (with_tag, _) = annotate(
&shot,
&[Annotation::new(rect(0, 0, 20, 20), "!?-").color(RED)],
);
let (no_tag, _) = annotate(&shot, &[boxed(rect(0, 0, 20, 20), RED)]);
assert_eq!(with_tag.pixels, no_tag.pixels);
}
#[test]
fn a_colliding_badge_moves_to_another_corner() {
let shot = blank(60, 40, 1.0);
let outer = Annotation::new(rect(0, 0, 60, 40), "A1").color(BLUE);
let inner = Annotation::new(rect(0, 0, 30, 20), "B1").color(RED);
let (out, skipped) = annotate(&shot, &[outer, inner]);
assert!(skipped.is_empty());
assert_eq!(
px(&out, 1, 1),
opaque(WHITE),
"outer badge's outline holds the corner"
);
assert_eq!(px(&out, 2, 2), opaque(BLUE), "outer badge's fill");
assert_eq!(
px(&out, 28, 1),
opaque(WHITE),
"outer badge's right outline column"
);
assert_eq!(
px(&out, 30, 0),
opaque(BLACK),
"inner badge's outline, beside the box"
);
assert_eq!(px(&out, 31, 1), opaque(RED), "inner badge's fill");
assert_eq!(
px(&out, 57, 19),
opaque(BLACK),
"inner badge's far outline corner"
);
assert_eq!(
px(&out, 29, 1),
opaque(RED),
"the inner box's own right stroke, not the badge"
);
}
#[test]
fn identical_rects_are_both_drawn_never_deduplicated() {
let shot = blank(60, 40, 1.0);
let first = Annotation::new(rect(0, 0, 60, 40), "A1").color(BLUE);
let second = Annotation::new(rect(0, 0, 60, 40), "B1").color(RED);
let (out, skipped) = annotate(&shot, &[first, second]);
assert!(skipped.is_empty(), "a duplicate rect is not a skip");
assert_eq!(px(&out, 1, 1), opaque(WHITE), "first badge's outline");
assert_eq!(px(&out, 2, 2), opaque(BLUE), "first badge's fill");
assert_eq!(
px(&out, 31, 1),
opaque(BLACK),
"second badge's outline, nudged"
);
assert_eq!(px(&out, 32, 2), opaque(RED), "second badge's fill");
}
#[test]
fn a_badge_sits_outside_the_box_so_it_cannot_cover_the_element() {
let shot = blank(60, 60, 1.0);
let (out, skipped) = annotate(&shot, &[Annotation::new(rect(10, 30, 20, 10), "A1")]);
assert!(skipped.is_empty());
let color = ANNOTATION_PALETTE[0];
assert_eq!(px(&out, 10, 10), opaque(BLACK), "badge's top-left outline");
assert_eq!(px(&out, 11, 11), opaque(color), "badge's fill");
assert_eq!(
px(&out, 20, 29),
opaque(BLACK),
"badge's bottom outline touches the box's top edge"
);
assert_eq!(px(&out, 20, 30), opaque(color), "the box's own top stroke");
assert_eq!(px(&out, 39, 29), BG, "nothing right of the badge");
for y in 31..39 {
for x in 11..29 {
assert_eq!(px(&out, x, y), BG, "box interior at ({x}, {y})");
}
}
}
#[test]
fn a_badge_with_no_room_above_drops_below_the_box() {
let shot = blank(60, 60, 1.0);
let (out, skipped) = annotate(&shot, &[Annotation::new(rect(10, 0, 20, 10), "A1")]);
assert!(skipped.is_empty());
let color = ANNOTATION_PALETTE[0];
assert_eq!(px(&out, 10, 10), opaque(BLACK), "badge's top-left outline");
assert_eq!(px(&out, 11, 11), opaque(color), "badge's fill");
assert_eq!(px(&out, 11, 5), BG, "box interior is untouched");
}
#[test]
fn a_badge_wider_than_the_room_to_its_right_is_right_aligned() {
let shot = blank(60, 60, 1.0);
let (out, skipped) = annotate(&shot, &[Annotation::new(rect(40, 30, 18, 10), "A1")]);
assert!(skipped.is_empty());
let color = ANNOTATION_PALETTE[0];
assert_eq!(px(&out, 30, 10), opaque(BLACK), "badge's top-left outline");
assert_eq!(px(&out, 31, 11), opaque(color), "badge's fill");
assert_eq!(
px(&out, 57, 29),
opaque(BLACK),
"badge's far outline corner"
);
assert_eq!(px(&out, 58, 29), BG, "the badge stops at the box's right");
}
#[test]
fn a_badge_with_no_room_above_or_below_goes_beside_the_box() {
let shot = blank(40, 24, 1.0);
let (out, skipped) = annotate(&shot, &[Annotation::new(rect(0, 0, 10, 24), "A1")]);
assert!(skipped.is_empty());
let color = ANNOTATION_PALETTE[0];
assert_eq!(px(&out, 10, 0), opaque(BLACK), "badge's top-left outline");
assert_eq!(px(&out, 11, 1), opaque(color), "badge's fill");
assert_eq!(px(&out, 5, 5), BG, "box interior is untouched");
assert_eq!(px(&out, 38, 1), BG, "nothing past the badge");
}
#[test]
fn a_badge_falls_inside_the_box_only_when_nothing_outside_is_on_image() {
let shot = blank(40, 30, 1.0);
let (out, skipped) = annotate(&shot, &[Annotation::new(rect(0, 0, 40, 30), "A1")]);
assert!(skipped.is_empty());
let color = ANNOTATION_PALETTE[0];
assert_eq!(px(&out, 1, 1), opaque(BLACK), "badge's top-left outline");
assert_eq!(px(&out, 2, 2), opaque(color), "badge's fill");
assert_eq!(px(&out, 29, 21), BG, "the badge ends inside the box");
}
#[test]
fn a_box_in_the_image_corner_still_gets_a_readable_badge() {
let shot = blank(60, 60, 1.0);
let (out, skipped) = annotate(&shot, &[Annotation::new(rect(0, 0, 10, 10), "A1")]);
assert!(skipped.is_empty());
let color = ANNOTATION_PALETTE[0];
assert_eq!(px(&out, 0, 10), opaque(BLACK), "badge's top-left outline");
for x in 5..11 {
assert_eq!(px(&out, x, 13), opaque(BLACK), "top bar of 'A' at x={x}");
}
assert_eq!(px(&out, 4, 13), opaque(color), "left of the top bar");
for x in 3..13 {
assert_eq!(px(&out, x, 19), opaque(BLACK), "crossbar of 'A' at x={x}");
}
assert_eq!(px(&out, 5, 5), BG, "box interior is untouched");
}
#[test]
fn a_badge_that_falls_off_the_image_is_clipped_not_a_skip() {
let shot = blank(20, 40, 1.0);
let (out, skipped) = annotate(
&shot,
&[Annotation::new(rect(4, 0, 16, 8), "A1").color(RED)],
);
assert!(skipped.is_empty(), "a clipped badge is not a skipped box");
assert_eq!(px(&out, 4, 0), opaque(RED), "the box's top-left stroke");
assert_eq!(
px(&out, 19, 7),
opaque(RED),
"the box's bottom-right stroke"
);
assert_eq!(px(&out, 0, 8), opaque(BLACK), "clipped badge's top row");
assert_eq!(px(&out, 19, 8), opaque(BLACK), "…all the way across");
assert_eq!(px(&out, 0, 9), opaque(RED), "and its fill below that");
}
#[test]
fn badge_spot_prefers_sitting_on_top_of_the_box_top_edge() {
let box_rect = PxRect::at(100, 100, 50, 20);
let image = PxRect::at(0, 0, 1000, 1000);
let spot = badge_spot(box_rect, 28, 20, 1, image, &[]);
assert_eq!(spot.x0, box_rect.x0, "left-aligned with the box");
assert_eq!(spot.y1, box_rect.y0, "bottom edge touches the box's top");
assert!(!spot.overlaps(box_rect), "and covers none of the box");
}
#[test]
fn a_tag_is_drawn_at_twice_the_stroke_unit_so_it_is_legible_at_1x() {
let shot = blank(60, 60, 1.0);
let (out, skipped) = annotate(&shot, &[Annotation::new(rect(10, 30, 20, 10), "A")]);
assert!(skipped.is_empty());
let color = ANNOTATION_PALETTE[0];
assert_eq!(px(&out, 13, 19), opaque(BLACK), "crossbar, first row");
assert_eq!(px(&out, 22, 20), opaque(BLACK), "crossbar, second row");
assert_eq!(px(&out, 13, 25), opaque(BLACK), "last glyph row, left stem");
assert_eq!(px(&out, 13, 26), opaque(BLACK), "…which is two pixels tall");
assert_eq!(px(&out, 13, 27), opaque(color), "and ends after 14 rows");
}
#[test]
fn smaller_boxes_draw_on_top_of_larger_ones() {
let shot = blank(20, 20, 1.0);
let big = boxed(rect(0, 0, 20, 20), BLUE);
let small = boxed(rect(0, 0, 4, 4), RED);
let (out, _) = annotate(&shot, &[small, big]);
assert_eq!(px(&out, 0, 0), opaque(RED), "the small box overwrites");
assert_eq!(px(&out, 19, 19), opaque(BLUE), "the big box is still there");
}
#[test]
fn annotate_never_mutates_the_receiver() {
let shot = blank(16, 16, 1.0);
let before = shot.pixels.clone();
let (out, _) = annotate(&shot, &[Annotation::new(rect(0, 0, 16, 16), "A1")]);
assert_eq!(shot.pixels, before, "self must be untouched");
assert_ne!(out.pixels, before, "the copy must have been drawn on");
assert_eq!(out.width, shot.width);
assert_eq!(out.height, shot.height);
assert!((out.scale - shot.scale).abs() < f32::EPSILON);
}
#[test]
fn no_annotations_returns_an_identical_copy() {
let shot = blank(8, 8, 1.0);
let (out, skipped) = annotate(&shot, &[]);
assert!(skipped.is_empty());
assert_eq!(out.pixels, shot.pixels);
}
#[test]
fn a_pixel_buffer_that_disagrees_with_the_dimensions_is_an_error() {
let shot = Screenshot::new(4, 4, vec![0; 10], 1.0);
let err = shot
.annotate(&[], Point::new(0, 0))
.expect_err("a short buffer must be reported, not drawn into");
match err {
Error::Platform { message, .. } => {
assert!(message.contains("does not match"), "message was {message}");
}
other => panic!("expected Error::Platform, got {other:?}"),
}
}
#[test]
fn extreme_coordinates_scales_and_dimensions_do_not_panic() {
let rects = [
rect(0, 0, 0, 0),
rect(i32::MIN, i32::MIN, u32::MAX, u32::MAX),
rect(i32::MAX, i32::MAX, u32::MAX, u32::MAX),
rect(i32::MIN, i32::MAX, 1, 1),
rect(i32::MAX, i32::MIN, u32::MAX, 1),
rect(-1, -1, 3, 3),
rect(0, 0, u32::MAX, 1),
rect(2, 2, 1, 1),
];
let origins = [
Point::new(0, 0),
Point::new(i32::MIN, i32::MIN),
Point::new(i32::MAX, i32::MAX),
Point::new(-7, 9),
];
let scales = [
1.0_f32,
0.0,
-1.0,
f32::NAN,
f32::INFINITY,
f32::NEG_INFINITY,
f32::MIN_POSITIVE,
f32::MAX,
1.5,
2.0,
4.0,
1e30,
];
let dims = [(0_u32, 0_u32), (1, 1), (0, 8), (8, 0), (9, 5)];
for &(w, h) in &dims {
for &scale in &scales {
let shot = blank(w, h, scale);
for &r in &rects {
let anns = [
Annotation::new(r, "A1"),
Annotation::new(r, ""),
Annotation::new(r, "!!"),
Annotation::new(r, "zz09"),
Annotation::new(r, "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW"),
];
for &origin in &origins {
let (out, skipped) = shot
.annotate(&anns, origin)
.expect("a well-formed capture must annotate");
assert_eq!(
out.pixels.len(),
shot.pixels.len(),
"{w}x{h} scale {scale} rect {r:?} origin {origin:?}"
);
assert!(skipped.len() <= anns.len());
assert!(skipped.iter().all(|&i| i < anns.len()));
}
}
}
}
}
#[test]
fn a_zero_sized_image_draws_nothing_and_skips_everything() {
let shot = blank(0, 0, 1.0);
let (out, skipped) = annotate(&shot, &[Annotation::new(rect(0, 0, 10, 10), "A1")]);
assert!(out.pixels.is_empty());
assert_eq!(skipped, vec![0]);
}
#[test]
fn a_box_larger_than_the_image_shows_only_its_interior() {
let shot = blank(4, 4, 1.0);
let (out, skipped) = annotate(&shot, &[boxed(rect(-10, -10, 100, 100), RED)]);
assert!(skipped.is_empty());
for y in 0..4 {
for x in 0..4 {
assert_eq!(px(&out, x, y), BG, "interior at ({x}, {y})");
}
}
}
#[test]
fn a_box_swallowing_the_capture_still_draws_its_badge() {
for inset in [30, 40, 1_000] {
let shot = blank(100, 100, 1.0);
let ann = Annotation::new(
rect(
-inset,
-inset,
100 + 2 * inset as u32,
100 + 2 * inset as u32,
),
"A1",
)
.color(RED);
let (out, skipped) = annotate(&shot, &[ann]);
assert!(
skipped.is_empty(),
"inset {inset}: box intersects the image"
);
let drawn = (0..100)
.flat_map(|y| (0..100).map(move |x| (x, y)))
.filter(|&(x, y)| px(&out, x, y) != BG)
.count();
assert!(
drawn > 0,
"inset {inset}: a legend entry with nothing drawn is the failure \
`omitted` exists to prevent"
);
}
}
#[test]
fn a_box_thinner_than_the_stroke_is_drawn_solid() {
let shot = blank(8, 8, 4.0);
let (out, skipped) = annotate(&shot, &[boxed(rect(0, 0, 1, 1), RED)]);
assert!(skipped.is_empty());
for y in 0..4 {
for x in 0..4 {
assert_eq!(px(&out, x, y), opaque(RED), "({x}, {y})");
}
}
assert_eq!(px(&out, 4, 0), BG, "and nothing past it");
}
}