#[cfg(not(feature = "std"))]
use alloc::{
format,
string::{String, ToString},
vec::Vec,
};
use crate::sexp::SExpr;
#[derive(Debug, thiserror::Error)]
pub enum AnnotationError {
#[error("invalid color value: {0}")]
InvalidColor(String),
#[error("invalid number: {0}")]
InvalidNumber(String),
#[error("malformed s-expression: {0}")]
Parse(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Color {
pub r: u8,
pub g: u8,
pub b: u8,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Rect {
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum Shape {
Rect(Rect),
Oval(Rect),
Poly(Vec<(u32, u32)>),
Line(u32, u32, u32, u32),
Text(Rect),
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Border {
pub style: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Highlight {
pub color: Color,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct MapArea {
pub url: String,
pub description: String,
pub shape: Shape,
pub border: Option<Border>,
pub highlight: Option<Highlight>,
}
#[derive(Debug, Clone, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Annotation {
pub background: Option<Color>,
pub zoom: Option<u32>,
pub mode: Option<String>,
}
pub fn parse_annotations(data: &[u8]) -> Result<(Annotation, Vec<MapArea>), AnnotationError> {
let text = core::str::from_utf8(data).unwrap_or("");
parse_annotation_text(text)
}
fn parse_annotation_text(text: &str) -> Result<(Annotation, Vec<MapArea>), AnnotationError> {
if text.trim().is_empty() {
return Ok((Annotation::default(), Vec::new()));
}
let exprs = crate::sexp::parse_sexprs(text);
let mut annotation = Annotation::default();
let mut mapareas = Vec::new();
for expr in &exprs {
if let SExpr::List(items) = expr {
let head = match items.first() {
Some(SExpr::Atom(s)) => s.as_str(),
_ => continue,
};
match head {
"background" => {
if let Some(SExpr::Atom(color_str)) = items.get(1) {
annotation.background = Some(parse_color(color_str)?);
}
}
"zoom" => {
if let Some(SExpr::Atom(n)) = items.get(1) {
annotation.zoom = Some(parse_uint(n)?);
}
}
"mode" => {
if let Some(SExpr::Atom(m)) = items.get(1) {
annotation.mode = Some(m.clone());
}
}
"maparea" => {
if let Some(ma) = parse_maparea(items)? {
mapareas.push(ma);
}
}
_ => {} }
}
}
Ok((annotation, mapareas))
}
fn parse_maparea(items: &[SExpr]) -> Result<Option<MapArea>, AnnotationError> {
let url = match items.get(1) {
Some(SExpr::Atom(s)) => s.clone(),
_ => String::new(),
};
let description = match items.get(2) {
Some(SExpr::Atom(s)) => s.clone(),
_ => String::new(),
};
let shape_expr = match items.get(3) {
Some(SExpr::List(l)) => l,
_ => return Ok(None),
};
let shape = parse_shape(shape_expr)?;
let mut border = None;
let mut highlight = None;
for item in items.get(4..).unwrap_or(&[]) {
if let SExpr::List(opts) = item {
match opts.first() {
Some(SExpr::Atom(s)) if s == "border" => {
if let Some(SExpr::Atom(style)) = opts.get(1) {
border = Some(Border {
style: style.clone(),
});
}
}
Some(SExpr::Atom(s)) if s == "hilite" => {
if let Some(SExpr::Atom(color)) = opts.get(1) {
highlight = Some(Highlight {
color: parse_color(color)?,
});
}
}
_ => {}
}
}
}
Ok(Some(MapArea {
url,
description,
shape,
border,
highlight,
}))
}
fn parse_shape(items: &[SExpr]) -> Result<Shape, AnnotationError> {
let kind = match items.first() {
Some(SExpr::Atom(s)) => s.as_str(),
_ => return Err(AnnotationError::Parse("shape has no kind".to_string())),
};
match kind {
"rect" => {
let x = get_uint(items, 1)?;
let y = get_uint(items, 2)?;
let w = get_uint(items, 3)?;
let h = get_uint(items, 4)?;
Ok(Shape::Rect(Rect {
x,
y,
width: w,
height: h,
}))
}
"oval" => {
let x = get_uint(items, 1)?;
let y = get_uint(items, 2)?;
let w = get_uint(items, 3)?;
let h = get_uint(items, 4)?;
Ok(Shape::Oval(Rect {
x,
y,
width: w,
height: h,
}))
}
"text" => {
let x = get_uint(items, 1)?;
let y = get_uint(items, 2)?;
let w = get_uint(items, 3)?;
let h = get_uint(items, 4)?;
Ok(Shape::Text(Rect {
x,
y,
width: w,
height: h,
}))
}
"line" => {
let x1 = get_uint(items, 1)?;
let y1 = get_uint(items, 2)?;
let x2 = get_uint(items, 3)?;
let y2 = get_uint(items, 4)?;
Ok(Shape::Line(x1, y1, x2, y2))
}
"poly" => {
let mut pts = Vec::new();
let mut i = 1usize;
while i + 1 < items.len() {
let x = get_uint(items, i)?;
let y = get_uint(items, i + 1)?;
pts.push((x, y));
i += 2;
}
Ok(Shape::Poly(pts))
}
other => Err(AnnotationError::Parse(format!(
"unknown shape kind: {other}"
))),
}
}
fn get_uint(items: &[SExpr], idx: usize) -> Result<u32, AnnotationError> {
match items.get(idx) {
Some(SExpr::Atom(s)) => parse_uint(s),
_ => Err(AnnotationError::Parse(format!(
"expected uint at position {idx}"
))),
}
}
fn parse_uint(s: &str) -> Result<u32, AnnotationError> {
s.parse::<u32>()
.map_err(|_| AnnotationError::InvalidNumber(s.to_string()))
}
fn parse_color(s: &str) -> Result<Color, AnnotationError> {
let hex = s.strip_prefix('#').unwrap_or(s);
if hex.len() != 6 {
return Err(AnnotationError::InvalidColor(s.to_string()));
}
let r = u8::from_str_radix(&hex[0..2], 16)
.map_err(|_| AnnotationError::InvalidColor(s.to_string()))?;
let g = u8::from_str_radix(&hex[2..4], 16)
.map_err(|_| AnnotationError::InvalidColor(s.to_string()))?;
let b = u8::from_str_radix(&hex[4..6], 16)
.map_err(|_| AnnotationError::InvalidColor(s.to_string()))?;
Ok(Color { r, g, b })
}
pub fn encode_annotations(ann: &Annotation, areas: &[MapArea]) -> Vec<u8> {
let mut out = String::new();
if let Some(ref c) = ann.background {
out.push_str(&format!(
"(background #{:02x}{:02x}{:02x})\n",
c.r, c.g, c.b
));
}
if let Some(z) = ann.zoom {
out.push_str(&format!("(zoom {z})\n"));
}
if let Some(ref m) = ann.mode {
out.push_str(&format!("(mode {m})\n"));
}
for ma in areas {
out.push_str(&encode_maparea(ma));
out.push('\n');
}
out.into_bytes()
}
#[cfg(feature = "std")]
pub fn encode_annotations_bzz(ann: &Annotation, areas: &[MapArea]) -> Vec<u8> {
let plain = encode_annotations(ann, areas);
if plain.is_empty() {
return Vec::new();
}
crate::bzz_encode::bzz_encode(&plain)
}
fn encode_maparea(ma: &MapArea) -> String {
let mut s = String::from("(maparea ");
s.push_str("e_str(&ma.url));
s.push(' ');
s.push_str("e_str(&ma.description));
s.push(' ');
s.push_str(&encode_shape(&ma.shape));
if let Some(ref b) = ma.border {
s.push_str(&format!(" (border {})", b.style));
}
if let Some(ref h) = ma.highlight {
s.push_str(&format!(
" (hilite #{:02x}{:02x}{:02x})",
h.color.r, h.color.g, h.color.b
));
}
s.push(')');
s
}
fn encode_shape(shape: &Shape) -> String {
match shape {
Shape::Rect(r) => format!("(rect {} {} {} {})", r.x, r.y, r.width, r.height),
Shape::Oval(r) => format!("(oval {} {} {} {})", r.x, r.y, r.width, r.height),
Shape::Text(r) => format!("(text {} {} {} {})", r.x, r.y, r.width, r.height),
Shape::Line(x1, y1, x2, y2) => format!("(line {x1} {y1} {x2} {y2})"),
Shape::Poly(pts) => {
let mut s = String::from("(poly");
for (x, y) in pts {
s.push_str(&format!(" {x} {y}"));
}
s.push(')');
s
}
}
}
fn quote_str(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
other => out.push(other),
}
}
out.push('"');
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_color_valid() {
let c = parse_color("#ff0080").unwrap();
assert_eq!(
c,
Color {
r: 255,
g: 0,
b: 128
}
);
}
#[test]
fn test_parse_color_no_hash() {
let c = parse_color("00ff00").unwrap();
assert_eq!(c, Color { r: 0, g: 255, b: 0 });
}
#[test]
fn test_parse_color_invalid_length() {
assert!(matches!(
parse_color("#fff"),
Err(AnnotationError::InvalidColor(_))
));
}
#[test]
fn test_parse_color_invalid_hex() {
assert!(matches!(
parse_color("#gggggg"),
Err(AnnotationError::InvalidColor(_))
));
}
#[test]
fn test_parse_uint_valid() {
assert_eq!(parse_uint("42").unwrap(), 42);
assert_eq!(parse_uint("0").unwrap(), 0);
}
#[test]
fn test_parse_uint_invalid() {
assert!(matches!(
parse_uint("abc"),
Err(AnnotationError::InvalidNumber(_))
));
assert!(matches!(
parse_uint("-5"),
Err(AnnotationError::InvalidNumber(_))
));
}
#[test]
fn test_parse_empty() {
let (ann, areas) = parse_annotations(b"").unwrap();
assert!(ann.background.is_none());
assert!(areas.is_empty());
}
#[test]
fn test_parse_background() {
let (ann, _) = parse_annotations(b"(background #ff0000)").unwrap();
assert_eq!(ann.background, Some(Color { r: 255, g: 0, b: 0 }));
}
#[test]
fn test_parse_zoom_and_mode() {
let (ann, _) = parse_annotations(b"(zoom 150)(mode color)").unwrap();
assert_eq!(ann.zoom, Some(150));
assert_eq!(ann.mode.as_deref(), Some("color"));
}
#[test]
fn test_parse_maparea_rect() {
let input = br#"(maparea "http://example.com" "Example" (rect 10 20 100 50))"#;
let (_, areas) = parse_annotations(input).unwrap();
assert_eq!(areas.len(), 1);
assert_eq!(areas[0].url, "http://example.com");
assert_eq!(areas[0].description, "Example");
assert!(matches!(&areas[0].shape, Shape::Rect(r) if r.x == 10 && r.y == 20));
}
#[test]
fn test_parse_maparea_oval() {
let input = br#"(maparea "" "" (oval 0 0 50 50))"#;
let (_, areas) = parse_annotations(input).unwrap();
assert!(matches!(&areas[0].shape, Shape::Oval(_)));
}
#[test]
fn test_parse_maparea_poly() {
let input = br#"(maparea "" "" (poly 0 0 10 0 10 10 0 10))"#;
let (_, areas) = parse_annotations(input).unwrap();
if let Shape::Poly(pts) = &areas[0].shape {
assert_eq!(pts.len(), 4);
assert_eq!(pts[0], (0, 0));
assert_eq!(pts[2], (10, 10));
} else {
panic!("expected poly shape");
}
}
#[test]
fn test_parse_maparea_line() {
let input = br#"(maparea "" "" (line 0 0 100 100))"#;
let (_, areas) = parse_annotations(input).unwrap();
assert!(matches!(&areas[0].shape, Shape::Line(0, 0, 100, 100)));
}
#[test]
fn test_parse_maparea_with_border_and_hilite() {
let input = br#"(maparea "" "" (rect 0 0 10 10) (border solid) (hilite #00ff00))"#;
let (_, areas) = parse_annotations(input).unwrap();
assert_eq!(areas[0].border.as_ref().unwrap().style, "solid");
assert_eq!(
areas[0].highlight.as_ref().unwrap().color,
Color { r: 0, g: 255, b: 0 }
);
}
#[test]
fn test_parse_unknown_shape() {
let input = br#"(maparea "" "" (circle 0 0 10))"#;
assert!(matches!(
parse_annotations(input),
Err(AnnotationError::Parse(_))
));
}
#[test]
fn test_parse_unknown_toplevel_ignored() {
let input = b"(unknown_key value)(zoom 100)";
let (ann, _) = parse_annotations(input).unwrap();
assert_eq!(ann.zoom, Some(100));
}
#[test]
fn test_parse_deeply_nested_does_not_overflow() {
let input = format!("{}{}", "(".repeat(200), ")".repeat(200));
let _ = parse_annotations(input.as_bytes());
}
#[test]
fn test_parse_multiple_mapareas() {
let input = br#"(maparea "a" "" (rect 0 0 1 1))(maparea "b" "" (rect 2 2 3 3))"#;
let (_, areas) = parse_annotations(input).unwrap();
assert_eq!(areas.len(), 2);
assert_eq!(areas[0].url, "a");
assert_eq!(areas[1].url, "b");
}
#[test]
fn encode_empty_is_empty() {
let ann = Annotation::default();
let out = encode_annotations(&ann, &[]);
assert!(out.is_empty());
}
#[test]
fn encode_background_roundtrip() {
let ann = Annotation {
background: Some(Color {
r: 255,
g: 128,
b: 0,
}),
zoom: None,
mode: None,
};
let bytes = encode_annotations(&ann, &[]);
let (dec, _) = parse_annotations(&bytes).unwrap();
assert_eq!(dec.background, ann.background);
}
#[test]
fn encode_zoom_and_mode_roundtrip() {
let ann = Annotation {
background: None,
zoom: Some(150),
mode: Some("color".to_string()),
};
let bytes = encode_annotations(&ann, &[]);
let (dec, _) = parse_annotations(&bytes).unwrap();
assert_eq!(dec.zoom, Some(150));
assert_eq!(dec.mode, Some("color".to_string()));
}
#[test]
fn encode_maparea_rect_roundtrip() {
let ann = Annotation::default();
let areas = vec![MapArea {
url: "http://example.com".to_string(),
description: "a link".to_string(),
shape: Shape::Rect(Rect {
x: 10,
y: 20,
width: 100,
height: 50,
}),
border: None,
highlight: None,
}];
let bytes = encode_annotations(&ann, &areas);
let (_, dec_areas) = parse_annotations(&bytes).unwrap();
assert_eq!(dec_areas.len(), 1);
assert_eq!(dec_areas[0].url, "http://example.com");
assert_eq!(dec_areas[0].description, "a link");
assert!(matches!(&dec_areas[0].shape, Shape::Rect(r) if r.x == 10 && r.y == 20));
}
#[test]
fn encode_maparea_poly_roundtrip() {
let areas = vec![MapArea {
url: String::new(),
description: String::new(),
shape: Shape::Poly(vec![(0, 0), (10, 0), (5, 10)]),
border: None,
highlight: None,
}];
let bytes = encode_annotations(&Annotation::default(), &areas);
let (_, dec_areas) = parse_annotations(&bytes).unwrap();
assert_eq!(dec_areas.len(), 1);
assert!(matches!(&dec_areas[0].shape, Shape::Poly(pts) if pts == &[(0,0),(10,0),(5,10)]));
}
#[test]
fn encode_maparea_with_border_and_hilite_roundtrip() {
let areas = vec![MapArea {
url: "x".to_string(),
description: String::new(),
shape: Shape::Rect(Rect {
x: 0,
y: 0,
width: 1,
height: 1,
}),
border: Some(Border {
style: "xor".to_string(),
}),
highlight: Some(Highlight {
color: Color { r: 255, g: 0, b: 0 },
}),
}];
let bytes = encode_annotations(&Annotation::default(), &areas);
let (_, dec_areas) = parse_annotations(&bytes).unwrap();
assert_eq!(dec_areas[0].border.as_ref().unwrap().style, "xor");
assert_eq!(dec_areas[0].highlight.as_ref().unwrap().color.r, 255);
}
#[test]
fn encode_url_with_quotes_roundtrip() {
let areas = vec![MapArea {
url: r#"has "quotes" and \backslash"#.to_string(),
description: String::new(),
shape: Shape::Line(0, 0, 1, 1),
border: None,
highlight: None,
}];
let bytes = encode_annotations(&Annotation::default(), &areas);
let (_, dec_areas) = parse_annotations(&bytes).unwrap();
assert_eq!(dec_areas[0].url, r#"has "quotes" and \backslash"#);
}
#[test]
fn parse_text_shape_roundtrip() {
let data = b"(maparea \"url\" \"desc\" (text 5 10 100 50))";
let (_, areas) = parse_annotations(data).unwrap();
assert_eq!(areas.len(), 1);
assert!(matches!(&areas[0].shape, Shape::Text(r) if r.x == 5 && r.width == 100));
}
#[test]
fn parse_oval_shape_roundtrip() {
let data = b"(maparea \"url\" \"desc\" (oval 1 2 30 40))";
let (_, areas) = parse_annotations(data).unwrap();
assert_eq!(areas.len(), 1);
assert!(matches!(&areas[0].shape, Shape::Oval(r) if r.x == 1 && r.height == 40));
}
#[test]
fn encode_shape_oval_and_text_variants() {
let oval = Shape::Oval(Rect {
x: 3,
y: 4,
width: 50,
height: 60,
});
let text = Shape::Text(Rect {
x: 1,
y: 2,
width: 10,
height: 20,
});
assert_eq!(encode_shape(&oval), "(oval 3 4 50 60)");
assert_eq!(encode_shape(&text), "(text 1 2 10 20)");
}
#[test]
fn parse_maparea_non_atom_url_and_desc_default_to_empty() {
let data = b"(maparea () () (rect 0 0 10 10))";
let (_, areas) = parse_annotations(data).unwrap();
assert_eq!(areas.len(), 1);
assert_eq!(areas[0].url, "");
assert_eq!(areas[0].description, "");
}
#[test]
fn parse_maparea_no_shape_list_is_skipped() {
let data = b"(maparea \"url\" \"desc\" notalist)";
let (_, areas) = parse_annotations(data).unwrap();
assert_eq!(areas.len(), 0);
}
#[test]
fn parse_shape_empty_list_returns_error() {
let data = b"(maparea \"url\" \"desc\" ())";
assert!(parse_annotations(data).is_err());
}
#[test]
fn parse_shape_bad_number_returns_error() {
let data = b"(maparea \"url\" \"desc\" (rect 0 0 abc 10))";
assert!(parse_annotations(data).is_err());
}
#[test]
fn parse_shape_missing_coordinate_returns_error() {
let data = b"(maparea \"url\" \"desc\" (rect 0 0))";
assert!(parse_annotations(data).is_err());
}
#[test]
fn encode_annotations_bzz_empty_annotation_returns_empty() {
let result = encode_annotations_bzz(&Annotation::default(), &[]);
assert!(result.is_empty());
}
}