use std::{path::Path, sync::Arc};
use serde::Deserialize;
use crate::{
Result,
styling::{self, LabelConfig, Style, StyleBuilder, SymbolColor},
};
mod color;
mod filter;
pub use color::parse_hex_color;
#[derive(Debug, Deserialize)]
pub struct YamlStyle
{
#[serde(default)]
pub background: Option<String>,
#[serde(default)]
pub rules: Vec<YamlRule>,
}
#[derive(Debug, Deserialize)]
pub struct YamlRule
{
pub filter: YamlFilter,
pub symbol: YamlSymbol,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum TagValue
{
String(String),
Sequence(Vec<String>),
}
#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum YamlGeometry
{
Point,
Linestring,
Polygon,
Multipolygon,
}
#[derive(Debug, Deserialize)]
pub struct YamlFilter
{
#[serde(default)]
pub geometry: Option<YamlGeometry>,
#[serde(default)]
pub tag: Option<std::collections::HashMap<String, TagValue>>,
#[serde(default)]
pub min_zoom: Option<f64>,
#[serde(default)]
pub max_zoom: Option<f64>,
}
#[derive(Debug, Deserialize)]
pub struct YamlSymbol
{
#[serde(default)]
pub fill: Option<String>,
#[serde(default)]
pub stroke: Option<String>,
#[serde(default)]
pub stroke_width: Option<f64>,
#[serde(default)]
pub radius: Option<f64>,
#[serde(default)]
pub icon: Option<String>,
#[serde(default)]
pub icon_size: Option<f64>,
#[serde(default)]
pub icon_anchor: Option<String>,
#[serde(default)]
pub label: Option<YamlLabel>,
}
#[derive(Debug, Deserialize)]
pub struct YamlLabel
{
pub field: String,
#[serde(default)]
pub font_size: Option<f32>,
#[serde(default)]
pub color: Option<String>,
#[serde(default)]
pub halo_color: Option<String>,
#[serde(default)]
pub min_zoom: Option<f64>,
}
pub fn load_style(path: impl AsRef<Path>) -> Result<Style<crate::osm::OsmFeature>>
{
let path = path.as_ref();
let yaml_str = std::fs::read_to_string(path)?;
let yaml_style: YamlStyle = serde_saphyr::from_str(&yaml_str)?;
let style_dir = path.parent().unwrap_or_else(|| Path::new("."));
let mut builder = StyleBuilder::new();
if let Some(bg_color) = &yaml_style.background
{
let rgba = parse_hex_color(bg_color)?;
builder = builder.set_background_color(rgba);
}
for rule in yaml_style.rules
{
builder = add_rule_to_builder(builder, rule, style_dir)?;
}
Ok(builder.into())
}
fn add_rule_to_builder(
builder: StyleBuilder<crate::osm::OsmFeature>,
rule: YamlRule,
style_dir: &Path,
) -> Result<StyleBuilder<crate::osm::OsmFeature>>
{
let symbol = build_symbol(&rule.symbol, style_dir)?;
let checker = filter::compile_filter(&rule.filter);
Ok(builder.add_rule(symbol).check(checker).finish_rule())
}
fn build_symbol(
yaml_symbol: &YamlSymbol,
style_dir: &Path,
) -> Result<styling::Symbol<crate::osm::OsmFeature>>
{
let fill_color = if let Some(fill_str) = &yaml_symbol.fill
{
let rgba = parse_hex_color(fill_str)?;
SymbolColor::new(move |_, _| rgba)
}
else
{
styling::Rgba::TRANSPARENT.into()
};
let stroke_color = if let Some(stroke_str) = &yaml_symbol.stroke
{
let rgba = parse_hex_color(stroke_str)?;
SymbolColor::new(move |_, _| rgba)
}
else
{
styling::Rgba::BLACK.into()
};
let stroke_width = yaml_symbol.stroke_width.unwrap_or(0.0);
let radius = yaml_symbol.radius.unwrap_or(1.0);
let icon = if let Some(icon_str) = &yaml_symbol.icon
{
let size = yaml_symbol.icon_size.unwrap_or(24.0) as f32;
let anchor = match yaml_symbol.icon_anchor.as_deref()
{
Some("top-left") => styling::IconAnchor::TopLeft,
Some("bottom-center") => styling::IconAnchor::BottomCenter,
_ => styling::IconAnchor::Center,
};
#[cfg(feature = "image")]
{
let icon_path = style_dir.join(icon_str);
let image = crate::open_image(
&icon_path,
geo::Rect::new(
geo::coord! { x: 0.0, y: 0.0 },
geo::coord! { x: 1.0, y: 1.0 },
),
)?;
Some(styling::IconConfig {
image: Arc::new(image),
size,
anchor,
})
}
#[cfg(not(feature = "image"))]
{
let _ = (style_dir, size, anchor);
return Err(anyhow::anyhow!(
"symbol.icon requires the `image` feature (icon: {icon_str})"
));
}
}
else
{
None
};
let label = yaml_symbol.label.as_ref().map(build_label).transpose()?;
Ok(styling::Symbol {
fill_color,
stroke_color,
stroke_width,
radius,
icon,
label,
})
}
fn build_label(yaml_label: &YamlLabel) -> Result<LabelConfig<crate::osm::OsmFeature>>
{
let field = yaml_label.field.clone();
let font_size = yaml_label.font_size.unwrap_or(12.0);
let color = if let Some(color_str) = &yaml_label.color
{
parse_hex_color(color_str)?
}
else
{
styling::Rgba::BLACK
};
let halo_color = if let Some(halo_str) = &yaml_label.halo_color
{
parse_hex_color(halo_str)?
}
else
{
styling::Rgba::WHITE
};
Ok(LabelConfig {
text: std::sync::Arc::new(Box::new(move |feature: &crate::osm::OsmFeature| {
feature.tag(&field).map(str::to_owned)
})),
font_size,
color: color.into(),
halo_color: halo_color.into(),
min_zoom: yaml_label.min_zoom.unwrap_or(0.0),
})
}
#[cfg(test)]
mod tests
{
use super::*;
use std::{fs, time::SystemTime};
#[test]
fn test_parse_hex_color_6_digits()
{
let color = parse_hex_color("#ff0000").unwrap();
assert!((color.red() - 1.0).abs() < 0.01);
assert!((color.green() - 0.0).abs() < 0.01);
assert!((color.blue() - 0.0).abs() < 0.01);
assert!((color.alpha() - 1.0).abs() < 0.01);
}
#[test]
fn test_parse_hex_color_8_digits()
{
let color = parse_hex_color("#ff000080").unwrap();
assert!((color.red() - 1.0).abs() < 0.01);
assert!((color.green() - 0.0).abs() < 0.01);
assert!((color.blue() - 0.0).abs() < 0.01);
assert!((color.alpha() - 0.5).abs() < 0.01);
}
#[test]
fn test_parse_hex_color_invalid()
{
assert!(parse_hex_color("red").is_err());
assert!(parse_hex_color("#ffff").is_err());
assert!(parse_hex_color("#gggggg").is_err());
}
#[test]
fn test_yaml_deserialize()
{
let yaml_str = "background: \"#aad3df\"\nrules:\n - filter:\n geometry: polygon\n tag:\n natural: water\n min_zoom: 8\n symbol:\n fill: \"#4a90d9\"\n stroke: \"#2c6fad\"\n stroke_width: 0.5\n label:\n field: name\n font_size: 11";
let style: YamlStyle = serde_saphyr::from_str(yaml_str).unwrap();
assert_eq!(style.background, Some("#aad3df".to_string()));
assert_eq!(style.rules.len(), 1);
assert_eq!(style.rules[0].filter.geometry, Some(YamlGeometry::Polygon));
assert_eq!(style.rules[0].filter.min_zoom, Some(8.0));
assert_eq!(
style.rules[0]
.symbol
.label
.as_ref()
.map(|label| label.field.as_str()),
Some("name")
);
}
#[test]
fn test_build_symbol_label_text()
{
let yaml_symbol: YamlSymbol =
serde_saphyr::from_str("fill: \"#4a90d9\"\nlabel:\n field: name\n min_zoom: 5").unwrap();
let symbol = build_symbol(&yaml_symbol, Path::new(".")).unwrap();
let label = symbol.label.as_ref().unwrap();
let feature = crate::osm::OsmFeature {
id: 1,
geometry: geo::Geometry::Point(geo::Point::new(0.0, 0.0)),
tags: vec![("name".to_string(), "Paris".to_string())],
};
assert_eq!((label.text)(&feature), Some("Paris".to_string()));
assert_eq!(label.min_zoom, 5.0);
}
#[test]
fn test_yaml_symbol_with_icon_deserializes()
{
let yaml = "icon: \"assets/test.png\"\nicon_size: 32";
let sym: YamlSymbol = serde_saphyr::from_str(yaml).unwrap();
assert_eq!(sym.icon, Some("assets/test.png".to_string()));
assert_eq!(sym.icon_size, Some(32.0));
}
#[cfg(feature = "image")]
#[test]
fn test_icon_config_loads_from_style()
{
let uniq = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_nanos();
let base_dir = std::env::temp_dir().join(format!("cartography_style_icon_{uniq}"));
let icon_dir = base_dir.join("assets");
let style_path = base_dir.join("style.yaml");
let icon_path = icon_dir.join("test.png");
fs::create_dir_all(&icon_dir).unwrap();
let img = image::RgbaImage::from_pixel(1, 1, image::Rgba([255, 0, 0, 255]));
img.save(&icon_path).unwrap();
let style = "rules:\n - filter:\n geometry: point\n symbol:\n icon: \"assets/test.png\"\n icon_size: 32\n fill: \"#ff0000\"";
fs::write(&style_path, style).unwrap();
let loaded = load_style(&style_path).unwrap();
let first_rule = loaded.rules().next().unwrap();
let icon = first_rule.symbol.icon.as_ref().unwrap();
assert_eq!(icon.size, 32.0);
assert_eq!(icon.anchor, styling::IconAnchor::Center);
fs::remove_file(&style_path).unwrap();
fs::remove_file(&icon_path).unwrap();
fs::remove_dir_all(&base_dir).unwrap();
}
#[cfg(feature = "image")]
#[test]
fn test_style_with_missing_icon_returns_error()
{
let uniq = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_nanos();
let base_dir = std::env::temp_dir().join(format!("cartography_style_icon_missing_{uniq}"));
let style_path = base_dir.join("style.yaml");
fs::create_dir_all(&base_dir).unwrap();
let style = "rules:\n - filter:\n geometry: point\n symbol:\n icon: \"assets/does-not-exist.png\"";
fs::write(&style_path, style).unwrap();
let result = load_style(&style_path);
assert!(result.is_err());
fs::remove_file(&style_path).unwrap();
fs::remove_dir_all(&base_dir).unwrap();
}
}