#[cfg(not(feature = "std"))]
use alloc::{
format,
string::{String, ToString},
vec::Vec,
};
use crate::sexp::SExpr;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
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,
#[cfg_attr(feature = "serde", serde(default))]
pub target: Option<String>,
pub description: String,
pub shape: Shape,
pub border: Option<Border>,
pub highlight: Option<Highlight>,
#[cfg_attr(feature = "serde", serde(default))]
pub extra: Vec<String>,
}
#[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>,
#[cfg_attr(feature = "serde", serde(default))]
pub extra: Vec<String>,
}
pub fn parse_annotations(data: &[u8]) -> Result<(Annotation, Vec<MapArea>), AnnotationError> {
let text = crate::lenient_text::decode_lossy(data);
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 {
let items = match expr {
SExpr::List(items) => items.as_slice(),
_ => &[],
};
let head = items.first().and_then(SExpr::text);
let value = items.get(1).and_then(SExpr::text);
match (head, value) {
(Some("background"), Some(color)) => {
annotation.background = Some(parse_color(color)?);
}
(Some("zoom"), Some(zoom)) if parse_zoom(zoom).is_some() => {
annotation.zoom = parse_zoom(zoom);
}
(Some("mode"), Some(mode)) => annotation.mode = Some(mode.to_string()),
(Some("maparea"), _) => {
if let Some(ma) = parse_maparea(items)? {
mapareas.push(ma);
}
}
_ => annotation.extra.push(sexpr_text(expr)),
}
}
Ok((annotation, mapareas))
}
fn parse_zoom(value: &str) -> Option<u32> {
value.strip_prefix('d').unwrap_or(value).parse().ok()
}
const BORDER_STYLES: [&str; 7] = [
"none",
"xor",
"border",
"shadow_in",
"shadow_out",
"shadow_ein",
"shadow_eout",
];
fn sexpr_text(expr: &SExpr) -> String {
let mut out = String::new();
expr.write_to(&mut out);
out
}
fn parse_maparea(items: &[SExpr]) -> Result<Option<MapArea>, AnnotationError> {
let (url, target) = match items.get(1) {
Some(SExpr::List(link)) if link.first().and_then(SExpr::text) == Some("url") => (
link.get(1).and_then(SExpr::text).unwrap_or("").to_string(),
Some(link.get(2).and_then(SExpr::text).unwrap_or("").to_string()),
),
Some(item) => (item.text().unwrap_or("").to_string(), None),
None => (String::new(), None),
};
let description = items.get(2).and_then(SExpr::text).unwrap_or("").to_string();
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;
let mut extra = Vec::new();
for item in items.get(4..).unwrap_or(&[]) {
let opts = match item {
SExpr::List(opts) => opts.as_slice(),
_ => &[],
};
match opts.first().and_then(SExpr::text) {
Some("border")
if opts.len() == 2
&& opts[1].text().is_some_and(|style| {
style != "border" && BORDER_STYLES.contains(&style)
}) =>
{
border = Some(Border {
style: opts[1].text().unwrap_or_default().to_string(),
});
}
Some(style) if BORDER_STYLES.contains(&style) => {
let text = sexpr_text(item);
border = Some(Border {
style: text[1..text.len() - 1].to_string(),
});
}
Some("hilite") if opts.get(1).and_then(SExpr::text).is_some() => {
highlight = Some(Highlight {
color: parse_color(opts[1].text().unwrap_or_default())?,
});
}
_ => extra.push(sexpr_text(item)),
}
}
Ok(Some(MapArea {
url,
target,
description,
shape,
border,
highlight,
extra,
}))
}
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 {})\n", encode_color(c)));
}
if let Some(z) = ann.zoom {
out.push_str(&format!("(zoom d{z})\n"));
}
if let Some(ref m) = ann.mode {
out.push_str(&format!("(mode {m})\n"));
}
for form in &ann.extra {
out.push_str(form);
out.push('\n');
}
for ma in areas {
out.push_str(&encode_maparea(ma));
out.push('\n');
}
out.into_bytes()
}
fn encode_color(c: &Color) -> String {
format!("#{:02X}{:02X}{:02X}", c.r, c.g, c.b)
}
#[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 ");
match &ma.target {
Some(target) => {
s.push_str("(url ");
s.push_str("e_str(&ma.url));
s.push(' ');
s.push_str("e_str(target));
s.push(')');
}
None => 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!(" ({})", b.style));
}
if let Some(ref h) = ma.highlight {
s.push_str(&format!(" (hilite {})", encode_color(&h.color)));
}
for option in &ma.extra {
s.push(' ');
s.push_str(option);
}
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);
crate::sexp::write_quoted(s, &mut out);
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_decodes_cp1252_byte_leniently() {
let input = b"(maparea \"http://example.com/a\x96b\" \"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/a–b");
assert_eq!(areas[0].description, "Example");
}
#[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 #FF0000) (hilite #00ff00))"#;
let (_, areas) = parse_annotations(input).unwrap();
assert_eq!(areas[0].border.as_ref().unwrap().style, "border #FF0000");
assert_eq!(
areas[0].highlight.as_ref().unwrap().color,
Color { r: 0, g: 255, b: 0 }
);
}
#[test]
fn test_parse_djvulibre_border_options() {
let input = br#"(maparea "" "" (rect 0 0 1 1) (xor))
(maparea "" "" (rect 0 0 1 1) (shadow_in 3 ))
(maparea "" "" (rect 0 0 1 1) (none))"#;
let (_, areas) = parse_annotations(input).unwrap();
let styles: Vec<_> = areas
.iter()
.map(|a| a.border.as_ref().unwrap().style.as_str())
.collect();
assert_eq!(styles, ["xor", "shadow_in 3", "none"]);
}
#[test]
fn test_parse_legacy_border_keyword_spelling() {
let (_, areas) =
parse_annotations(br#"(maparea "" "" (rect 0 0 1 1) (border xor))"#).unwrap();
assert_eq!(areas[0].border.as_ref().unwrap().style, "xor");
let encoded =
String::from_utf8(encode_annotations(&Annotation::default(), &areas)).unwrap();
assert_eq!(encoded, "(maparea \"\" \"\" (rect 0 0 1 1) (xor))\n");
}
#[test]
fn test_parse_zoom_forms() {
let (ann, _) = parse_annotations(b"(zoom d150)").unwrap();
assert_eq!(ann.zoom, Some(150));
assert!(ann.extra.is_empty());
let (ann, _) = parse_annotations(b"(zoom page)").unwrap();
assert_eq!(ann.zoom, None);
assert_eq!(ann.extra, ["(zoom page)"]);
}
#[test]
fn test_parse_url_with_target() {
let input = br#"(maparea (url "http://e.x" "_blank") "d" (rect 0 0 1 1))"#;
let (_, areas) = parse_annotations(input).unwrap();
assert_eq!(areas[0].url, "http://e.x");
assert_eq!(areas[0].target.as_deref(), Some("_blank"));
let (_, plain) =
parse_annotations(br#"(maparea "http://e.x" "d" (rect 0 0 1 1))"#).unwrap();
assert_eq!(plain[0].target, None);
}
#[test]
fn test_roundtrip_keeps_every_form_djvulibre_writes() {
let input = concat!(
"(background #8F8F8F)\n",
"(zoom d150)\n",
"(mode bw)\n",
"(align center top)\n",
"(metadata (Title \"A \\\"quoted\\\" title\") (Year \"1999\"))\n",
"(maparea (url \"#1\" \"_self\") \"note\" (rect 1 2 3 4) (shadow_in 3) (hilite #FFFF00) (border_avis) (opacity 50))\n",
"(maparea \"\" \"\" (line 0 0 9 9) (none) (arrow) (width 2) (lineclr #FF0000))\n",
);
let (ann, areas) = parse_annotations(input.as_bytes()).unwrap();
let encoded = String::from_utf8(encode_annotations(&ann, &areas)).unwrap();
assert_eq!(encoded, input);
}
#[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,
extra: Vec::new(),
};
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()),
extra: Vec::new(),
};
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,
target: None,
extra: Vec::new(),
}];
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,
target: None,
extra: Vec::new(),
}];
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 },
}),
target: None,
extra: Vec::new(),
}];
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,
target: None,
extra: Vec::new(),
}];
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());
}
}