use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use crate::StyleError;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct Document {
pub name: String,
#[serde(default = "default_version")]
pub version: String,
#[serde(default = "default_tile_size")]
pub tile_size: u32,
#[serde(default)]
pub pad: u32,
#[serde(default)]
pub params: IndexMap<String, ParamDecl>,
#[serde(default)]
pub attribution: Option<String>,
#[serde(default)]
pub functions: IndexMap<String, FuncDecl>,
#[serde(default)]
pub legend: Option<LegendDecl>,
#[serde(default)]
pub sources: IndexMap<String, SourceDecl>,
pub nodes: IndexMap<String, NodeSpec>,
pub output: NodeRef,
}
impl Document {
pub fn from_json(s: &str) -> Result<Self, StyleError> {
let source = crate::blank_comments(s)?;
Ok(serde_json::from_str(&source)?)
}
pub fn attributions(&self) -> Vec<&str> {
let mut out: Vec<&str> = Vec::new();
let candidates = std::iter::once(&self.attribution)
.chain(self.sources.values().map(|d| d.attribution()));
for a in candidates {
if let Some(a) = a.as_deref() {
if !a.is_empty() && !out.contains(&a) {
out.push(a);
}
}
}
out
}
pub fn subgraph(&self, target: &str) -> Option<Document> {
if !self.nodes.contains_key(target) {
return None;
}
let mut keep: IndexMap<String, NodeSpec> = IndexMap::new();
let mut queue = vec![target.to_string()];
while let Some(id) = queue.pop() {
if keep.contains_key(&id) {
continue;
}
let Some(spec) = self.nodes.get(&id) else {
continue;
};
queue.extend(spec.refs());
keep.insert(id, spec.clone());
}
let nodes = self
.nodes
.iter()
.filter(|(id, _)| keep.contains_key(*id))
.map(|(id, spec)| (id.clone(), spec.clone()))
.collect();
Some(Document {
name: self.name.clone(),
version: self.version.clone(),
tile_size: self.tile_size,
pad: self.pad,
params: self.params.clone(),
attribution: self.attribution.clone(),
functions: self.functions.clone(),
legend: None,
sources: self.sources.clone(),
nodes,
output: NodeRef(target.to_string()),
})
}
pub fn params_schema(&self) -> serde_json::Value {
use serde_json::{json, Map, Value};
let mut props = Map::new();
for (name, decl) in &self.params {
let mut p = match decl.kind {
ParamKind::Number => {
let mut p = Map::new();
p.insert("type".into(), json!("number"));
if let Some(m) = decl.min {
p.insert("minimum".into(), json!(m));
}
if let Some(m) = decl.max {
p.insert("maximum".into(), json!(m));
}
p
}
ParamKind::Color => {
let mut p = Map::new();
p.insert("type".into(), json!("string"));
p.insert(
"pattern".into(),
json!("^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$"),
);
p.insert("format".into(), json!("color"));
p
}
ParamKind::Bool => {
let mut p = Map::new();
p.insert("type".into(), json!("boolean"));
p
}
};
p.insert("default".into(), decl.default.clone());
if let Some(d) = &decl.description {
p.insert("description".into(), json!(d));
}
props.insert(name.clone(), Value::Object(p));
}
json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": format!("{} parameters", self.name),
"type": "object",
"additionalProperties": false,
"properties": props,
})
}
}
fn default_version() -> String {
"1".to_string()
}
fn default_tile_size() -> u32 {
512
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct LegendDecl {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
#[serde(default)]
pub entries: Vec<LegendEntry>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct LegendEntry {
pub label: String,
pub from: NodeRef,
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
pub properties: serde_json::Map<String, serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min_zoom: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_zoom: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub geometry: Option<LegendGeometry>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum LegendGeometry {
#[default]
All,
Polygon,
Line,
Point,
}
impl LegendDecl {
pub fn entries_at(&self, z: u8) -> impl Iterator<Item = &LegendEntry> {
self.entries.iter().filter(move |e| {
e.min_zoom.is_none_or(|min| z >= min) && e.max_zoom.is_none_or(|max| z <= max)
})
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct FuncDecl {
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub inputs: IndexMap<String, FuncInput>,
pub output: NodeRef,
pub output_kind: FuncKind,
pub nodes: IndexMap<String, NodeSpec>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct FuncInput {
pub kind: FuncKind,
#[serde(default, deserialize_with = "some_value")]
pub default: Option<serde_json::Value>,
#[serde(default)]
pub description: Option<String>,
}
fn some_value<'de, D: serde::Deserializer<'de>>(
d: D,
) -> Result<Option<serde_json::Value>, D::Error> {
serde_json::Value::deserialize(d).map(Some)
}
#[derive(Debug, Deserialize, PartialEq, Eq, Clone, Copy)]
#[serde(rename_all = "kebab-case")]
pub enum FuncKind {
Features,
Raster,
Sprite,
Brush,
Scalar,
ScalarField,
}
impl FuncKind {
pub fn as_str(&self) -> &'static str {
match self {
FuncKind::Features => "features",
FuncKind::Raster => "raster",
FuncKind::Sprite => "sprite",
FuncKind::Brush => "brush",
FuncKind::Scalar => "scalar",
FuncKind::ScalarField => "scalar-field",
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct NodeSpec {
pub op: String,
#[serde(flatten)]
pub fields: serde_json::Map<String, serde_json::Value>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct ParamDecl {
#[serde(rename = "type")]
pub kind: ParamKind,
pub default: serde_json::Value,
#[serde(default)]
pub min: Option<f64>,
#[serde(default)]
pub max: Option<f64>,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Deserialize, PartialEq, Eq, Clone, Copy)]
#[serde(rename_all = "kebab-case")]
pub enum ParamKind {
Color,
Number,
Bool,
}
#[derive(Debug, Deserialize, PartialEq, Eq, Clone, Copy, Default)]
#[serde(rename_all = "kebab-case")]
pub enum OnMissing {
#[default]
Empty,
Upsample,
Error,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)]
pub enum SourceDecl {
Brush(FileSource),
Image(FileSource),
Mvt(MvtSource),
Pmtiles(PmtilesSource),
Dem(DemSource),
Raster(RasterSource),
#[serde(rename = "geojson")]
GeoJson(GeoJsonSource),
Sprite(SpriteSource),
Font(FontSource),
Glyphs(GlyphsSource),
}
impl SourceDecl {
pub fn attribution(&self) -> &Option<String> {
match self {
SourceDecl::Brush(s) | SourceDecl::Image(s) => &s.attribution,
SourceDecl::Mvt(s) => &s.attribution,
SourceDecl::Pmtiles(s) => &s.attribution,
SourceDecl::Dem(s) => &s.attribution,
SourceDecl::Raster(s) => &s.attribution,
SourceDecl::GeoJson(s) => &s.attribution,
SourceDecl::Sprite(s) => &s.attribution,
SourceDecl::Font(s) => &s.attribution,
SourceDecl::Glyphs(s) => &s.attribution,
}
}
}
#[derive(Debug, Deserialize, Clone)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct FontSource {
pub url: String,
#[serde(default)]
pub index: u32,
#[serde(default)]
pub attribution: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct GlyphsSource {
pub url: String,
pub fontstack: String,
#[serde(default)]
pub attribution: Option<String>,
}
impl GlyphsSource {
pub fn asset_key(&self) -> String {
self.url
.replace("{fontstack}", &percent_encode(&self.fontstack))
}
}
fn percent_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for byte in s.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' => out.push(byte as char),
b'!' | b'\'' | b'(' | b')' | b'*' | b'-' | b'.' | b'_' | b'~' => out.push(byte as char),
_ => out.push_str(&format!("%{byte:02X}")),
}
}
out
}
#[derive(Debug, Deserialize, Clone)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct SpriteSource {
pub image: String,
pub index: SpriteIndex,
#[serde(default)]
pub attribution: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(untagged)]
pub enum SpriteIndex {
Url(String),
Inline(std::collections::HashMap<String, IconRect>),
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct IconRect {
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
#[serde(default = "one_f32")]
pub pixel_ratio: f32,
#[serde(default)]
pub stretch_x: Vec<[u32; 2]>,
#[serde(default)]
pub stretch_y: Vec<[u32; 2]>,
#[serde(default)]
pub content: Option<[u32; 4]>,
}
fn one_f32() -> f32 {
1.0
}
#[derive(Debug, Deserialize, Clone)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct GeoJsonSource {
#[serde(default)]
pub data: Option<serde_json::Value>,
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub attribution: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct FileSource {
pub src: String,
#[serde(default)]
pub attribution: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct MvtSource {
pub url: String,
#[serde(default)]
pub attribution: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct PmtilesSource {
pub url: String,
#[serde(default)]
pub attribution: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct DemSource {
pub url: String,
pub encoding: DemEncoding,
#[serde(default)]
pub on_missing: OnMissing,
#[serde(default)]
pub attribution: Option<String>,
#[serde(default = "default_dem_tile_size")]
pub tile_size: u32,
#[serde(default)]
pub max_zoom: Option<u8>,
#[serde(default = "default_true")]
pub neighbor_fetch: bool,
#[serde(default)]
pub elevation_offset: f32,
}
#[derive(Debug, Deserialize, PartialEq, Eq, Clone, Copy)]
#[serde(rename_all = "kebab-case")]
pub enum DemEncoding {
Terrarium,
MapboxRgb,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct RasterSource {
pub url: String,
#[serde(default)]
pub max_zoom: Option<u8>,
#[serde(default = "default_true")]
pub neighbor_fetch: bool,
#[serde(default)]
pub on_missing: OnMissing,
#[serde(default)]
pub attribution: Option<String>,
}
fn default_dem_tile_size() -> u32 {
256
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NodeRef(pub String);
impl NodeRef {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl<'de> Deserialize<'de> for NodeRef {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
Ok(NodeRef(s.strip_prefix('@').unwrap_or(&s).to_string()))
}
}
impl Serialize for NodeRef {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&self.0)
}
}
impl NodeSpec {
pub fn refs(&self) -> Vec<String> {
let mut out = Vec::new();
for v in self.fields.values() {
collect_refs(v, &mut out);
}
out
}
}
fn collect_refs(v: &serde_json::Value, out: &mut Vec<String>) {
match v {
serde_json::Value::String(s) => {
if let Some(rest) = s.strip_prefix('@') {
out.push(rest.to_string());
}
}
serde_json::Value::Array(a) => a.iter().for_each(|x| collect_refs(x, out)),
serde_json::Value::Object(m) => m.values().for_each(|x| collect_refs(x, out)),
_ => {}
}
}
pub enum FieldRef<'a> {
Node(&'a str),
Param(&'a str),
Literal(&'a str),
}
impl<'a> FieldRef<'a> {
pub fn classify(s: &'a str) -> Self {
if let Some(rest) = s.strip_prefix('@') {
FieldRef::Node(rest)
} else if let Some(rest) = s.strip_prefix('$') {
FieldRef::Param(rest)
} else {
FieldRef::Literal(s)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_minimal_document() {
let json = r##"{
"name": "demo",
"nodes": {
"src": { "op": "image", "src": "assets/bg.png" },
"blur": { "op": "blur", "input": "@src", "sigma": 3 }
},
"output": "@blur"
}"##;
let doc = Document::from_json(json).unwrap();
assert_eq!(doc.name, "demo");
assert_eq!(doc.nodes.len(), 2);
assert_eq!(doc.output.as_str(), "blur");
assert_eq!(doc.nodes["blur"].op, "blur");
assert_eq!(doc.nodes["blur"].fields["input"], "@src");
}
#[test]
fn parses_a_commented_document() {
let json = r##"{
// What this style is for.
"name": "demo",
"nodes": {
"src": { "op": "image", "src": "assets/bg.png" },
/* Three was as far as we could go before the coastline
dissolved. */
"blur": { "op": "blur", "input": "@src", "sigma": 3 }, // keep
"fade": { "op": "expr", "expr": ["interpolate", ["linear"], ["zoom"],
13, 1, // fully on in town
15, 0] }
},
"output": "@blur"
}"##;
let doc = Document::from_json(json).unwrap();
assert_eq!(doc.name, "demo");
assert_eq!(doc.nodes.len(), 3);
assert_eq!(doc.nodes["blur"].fields["sigma"], 3);
assert_eq!(
doc.nodes["fade"].fields["expr"].as_array().unwrap().len(),
7
);
}
#[test]
fn an_error_after_a_comment_keeps_its_line_number() {
let json = "{\n // a note\n /* and\n another */\n \"name\": oops\n}";
let err = Document::from_json(json).unwrap_err();
let StyleError::Parse(e) = err else {
panic!("expected a JSON parse error");
};
assert_eq!(e.line(), 5, "{e}");
}
#[test]
fn subgraph_keeps_the_target_and_its_ancestors() {
let json = r##"{
"name": "demo",
"legend": { "entries": [{ "label": "x", "from": "@out" }] },
"nodes": {
"bg": { "op": "solid", "color": "#ffffff" },
"src": { "op": "image", "src": "x.png" },
"blur": { "op": "blur", "input": "@src", "sigma": 3 },
"other": { "op": "image", "src": "unrelated.png" },
"out": { "op": "blend", "base": "@bg", "over": "@blur" }
},
"output": "@out"
}"##;
let doc = Document::from_json(json).unwrap();
let sub = doc.subgraph("blur").unwrap();
assert_eq!(sub.output.as_str(), "blur");
let ids: Vec<&str> = sub.nodes.keys().map(String::as_str).collect();
assert_eq!(ids, ["src", "blur"], "kept in declaration order");
assert!(sub.legend.is_none());
let sub = doc.subgraph("out").unwrap();
let ids: Vec<&str> = sub.nodes.keys().map(String::as_str).collect();
assert_eq!(ids, ["bg", "src", "blur", "out"]);
assert!(doc.subgraph("nope").is_none());
}
#[test]
fn subgraph_survives_a_reference_inside_an_expression() {
let json = r##"{
"name": "demo",
"nodes": {
"fade": { "op": "expr", "expr": ["interpolate", ["linear"], ["zoom"], 13, 1, 15, 0] },
"src": { "op": "image", "src": "x.png" },
"out": { "op": "blur", "input": "@src", "sigma": 1, "opacity": "@fade" }
},
"output": "@out"
}"##;
let doc = Document::from_json(json).unwrap();
let sub = doc.subgraph("out").unwrap();
let mut ids: Vec<&str> = sub.nodes.keys().map(String::as_str).collect();
ids.sort_unstable();
assert_eq!(ids, ["fade", "out", "src"]);
}
#[test]
fn node_refs_reads_nested_fields() {
let json = r##"{
"name": "demo",
"nodes": {
"out": { "op": "stack", "layers": ["@a", "@b"], "mask": "@c",
"curve": [["@d", 1]], "literal": "plain", "param": "$k" }
},
"output": "@out"
}"##;
let doc = Document::from_json(json).unwrap();
let mut refs = doc.nodes["out"].refs();
refs.sort_unstable();
assert_eq!(refs, ["a", "b", "c", "d"]);
}
#[test]
fn parses_output_without_at_prefix() {
let json = r##"{
"name": "demo",
"nodes": { "a": { "op": "image", "src": "x.png" } },
"output": "a"
}"##;
let doc = Document::from_json(json).unwrap();
assert_eq!(doc.output.as_str(), "a");
}
#[test]
fn parses_params_and_sources() {
let json = r##"{
"name": "demo",
"params": {
"ink": { "type": "color", "default": "#000000" },
"k": { "type": "number", "default": 0.5, "min": 0, "max": 1 }
},
"sources": {
"brush": { "type": "brush", "src": "assets/wet.myb" }
},
"nodes": { "out": { "op": "solid", "color": "$ink" } },
"output": "@out"
}"##;
let doc = Document::from_json(json).unwrap();
assert_eq!(doc.params["k"].kind, ParamKind::Number);
assert!(matches!(doc.sources["brush"], SourceDecl::Brush(_)));
assert_eq!(doc.params["k"].max, Some(1.0));
}
#[test]
fn params_schema_reflects_declarations() {
let json = r##"{
"name": "demo",
"params": {
"ink": { "type": "color", "default": "#000000", "description": "Line color" },
"k": { "type": "number", "default": 0.5, "min": 0, "max": 1 },
"on": { "type": "bool", "default": true }
},
"nodes": { "out": { "op": "solid", "color": "$ink" } },
"output": "@out"
}"##;
let doc = Document::from_json(json).unwrap();
let schema = doc.params_schema();
let props = &schema["properties"];
assert_eq!(props["ink"]["type"], "string");
assert_eq!(props["ink"]["default"], "#000000");
assert_eq!(props["ink"]["description"], "Line color");
assert_eq!(props["k"]["type"], "number");
assert_eq!(props["k"]["minimum"], 0.0);
assert_eq!(props["k"]["maximum"], 1.0);
assert_eq!(props["on"]["type"], "boolean");
assert_eq!(schema["additionalProperties"], false);
}
#[test]
fn parses_raster_source_and_attributions() {
let json = r##"{
"name": "demo",
"attribution": "Style © Demo",
"sources": {
"photo": { "type": "raster",
"url": "https://example.com/{z}/{x}/{y}.jpg",
"max-zoom": 18, "on-missing": "upsample",
"attribution": "© Example Sat" },
"archive": { "type": "raster", "url": "tiles.pmtiles" },
"basemap": { "type": "mvt", "url": "https://example.com/t.json",
"attribution": "Style © Demo" }
},
"nodes": { "out": { "op": "raster", "source": "photo" } },
"output": "@out"
}"##;
let doc = Document::from_json(json).unwrap();
let SourceDecl::Raster(r) = &doc.sources["photo"] else {
panic!("expected raster source");
};
assert_eq!(r.max_zoom, Some(18));
assert_eq!(r.on_missing, OnMissing::Upsample);
assert!(r.neighbor_fetch);
let SourceDecl::Raster(r) = &doc.sources["archive"] else {
panic!("expected raster source");
};
assert_eq!(r.on_missing, OnMissing::Empty);
assert_eq!(doc.attributions(), ["Style © Demo", "© Example Sat"]);
}
#[test]
fn parses_font_source() {
let json = r##"{
"name": "demo",
"sources": {
"body": { "type": "font", "url": "https://example.com/NotoSans-Regular.ttf" },
"cjk": { "type": "font", "url": "file:fonts/collection.ttc", "index": 2,
"attribution": "© Font Foundry" }
},
"nodes": { "out": { "op": "solid", "color": "#000000" } },
"output": "@out"
}"##;
let doc = Document::from_json(json).unwrap();
let SourceDecl::Font(f) = &doc.sources["body"] else {
panic!("expected font source");
};
assert_eq!(f.url, "https://example.com/NotoSans-Regular.ttf");
assert_eq!(f.index, 0);
assert!(f.attribution.is_none());
let SourceDecl::Font(f) = &doc.sources["cjk"] else {
panic!("expected font source");
};
assert_eq!(f.index, 2);
assert_eq!(doc.attributions(), ["© Font Foundry"]);
}
#[test]
fn parses_glyphs_source() {
let json = r##"{
"name": "demo",
"sources": {
"labels": { "type": "glyphs",
"url": "https://example.com/fonts/{fontstack}/{range}.pbf",
"fontstack": "Noto Sans Regular, Arial Unicode MS Regular",
"attribution": "© Glyph Server" }
},
"nodes": { "out": { "op": "solid", "color": "#000000" } },
"output": "@out"
}"##;
let doc = Document::from_json(json).unwrap();
let SourceDecl::Glyphs(g) = &doc.sources["labels"] else {
panic!("expected glyphs source");
};
assert_eq!(g.fontstack, "Noto Sans Regular, Arial Unicode MS Regular");
assert_eq!(
g.asset_key(),
"https://example.com/fonts/Noto%20Sans%20Regular%2C%20Arial%20Unicode%20MS%20Regular/{range}.pbf"
);
assert_eq!(doc.attributions(), ["© Glyph Server"]);
}
#[test]
fn rejects_unknown_top_level_field() {
let json = r##"{
"name": "demo",
"nodes": {},
"output": "@x",
"junk": 1
}"##;
assert!(Document::from_json(json).is_err());
}
#[test]
fn classify_field_refs() {
assert!(matches!(FieldRef::classify("@foo"), FieldRef::Node("foo")));
assert!(matches!(FieldRef::classify("$bar"), FieldRef::Param("bar")));
assert!(matches!(
FieldRef::classify("plain"),
FieldRef::Literal("plain")
));
}
}