use serde_json::{Map, Value};
pub(crate) mod color;
pub(crate) mod filter;
pub(crate) mod layers;
pub(crate) mod sources;
use layers::{
convert_background, convert_circle, convert_fill, convert_fill_extrusion, convert_heatmap,
convert_hillshade, convert_line, convert_raster, convert_symbol, emit_label_placement,
};
use sources::convert_sources;
pub(crate) fn const_number(v: &Value) -> Option<f64> {
v.as_f64()
}
pub(crate) fn const_color(v: &Value) -> Option<String> {
v.as_str().map(str::to_string)
}
pub(crate) fn is_expr(v: &Value) -> bool {
v.is_array() || v.is_object()
}
#[derive(Debug, Clone)]
pub struct ConvertOptions {
pub tile_size: u32,
pub pad: u32,
pub keep_hidden: bool,
pub fonts: std::collections::HashMap<String, String>,
}
impl Default for ConvertOptions {
fn default() -> Self {
Self {
tile_size: 512,
pad: 16,
keep_hidden: false,
fonts: std::collections::HashMap::new(),
}
}
}
pub(crate) type ZoomRange = (Option<u8>, Option<u8>);
fn layer_zoom_range(layer: &Map<String, Value>) -> Option<ZoomRange> {
let read = |key| {
layer
.get(key)
.and_then(Value::as_f64)
.filter(|z| z.is_finite())
};
let min = read("minzoom").map(|z| z.ceil().clamp(0.0, 24.0) as u8);
let max = match read("maxzoom") {
Some(z) => match z.ceil().clamp(0.0, 25.0) - 1.0 {
top if top < 0.0 => return None,
top => Some(top.min(24.0) as u8),
},
None => None,
};
if let (Some(mn), Some(mx)) = (min, max) {
if mn > mx {
return None;
}
}
Some((min, max))
}
#[derive(Debug, Default, Clone)]
pub struct Report {
pub warnings: Vec<String>,
}
impl Report {
fn warn(&mut self, msg: impl Into<String>) {
self.warnings.push(msg.into());
}
}
#[derive(Debug, thiserror::Error)]
pub enum ConvertError {
#[error("style is not a JSON object")]
NotAnObject,
#[error("style has no `layers` array")]
NoLayers,
#[error("no vector (MVT/PMTiles) source found; ezu needs tiled vector data")]
NoVectorSource,
}
pub fn convert(style: &Value, opts: &ConvertOptions) -> Result<(Value, Report), ConvertError> {
let style = style.as_object().ok_or(ConvertError::NotAnObject)?;
let mut report = Report::default();
let (mut source_defs, sources) = convert_sources(style, &mut report)?;
let glyphs_url = style.get("glyphs").and_then(Value::as_str);
let layers = style
.get("layers")
.and_then(Value::as_array)
.ok_or(ConvertError::NoLayers)?;
let mut nodes = Map::new();
let mut outputs: Vec<String> = Vec::new();
let mut label_layers: Vec<String> = Vec::new();
let mut required_pad: u32 = 0;
for layer in layers {
let Some(layer) = layer.as_object() else {
continue;
};
let id = layer.get("id").and_then(Value::as_str).unwrap_or("layer");
let hidden = layer
.get("layout")
.and_then(|l| l.get("visibility"))
.and_then(Value::as_str)
== Some("none");
if hidden && !opts.keep_hidden {
continue;
}
let Some(zoom_range) = layer_zoom_range(layer) else {
report.warn(format!(
"layer `{id}`: its minzoom/maxzoom band contains no whole zoom level, so \
MapLibre would never draw it — skipped"
));
continue;
};
let ty = layer.get("type").and_then(Value::as_str).unwrap_or("");
let out_start = outputs.len();
match ty {
"background" => convert_background(id, layer, &mut nodes, &mut outputs),
"fill" => convert_fill(
id,
layer,
&mut nodes,
&mut outputs,
zoom_range,
&sources,
&mut report,
),
"line" => convert_line(
id,
layer,
&mut nodes,
&mut outputs,
zoom_range,
&sources,
&mut report,
),
"raster" => convert_raster(id, layer, &mut nodes, &mut outputs, &mut report),
"circle" => convert_circle(
id,
layer,
&mut nodes,
&mut outputs,
zoom_range,
&sources,
&mut report,
),
"heatmap" => {
required_pad = required_pad.max(convert_heatmap(
id,
layer,
&mut nodes,
&mut outputs,
zoom_range,
&sources,
&mut report,
));
}
"hillshade" => convert_hillshade(id, layer, &mut nodes, &mut outputs, &mut report),
"fill-extrusion" => convert_fill_extrusion(
id,
layer,
&mut nodes,
&mut outputs,
zoom_range,
&sources,
&mut report,
),
"symbol" => convert_symbol(
id,
layer,
&mut nodes,
&mut outputs,
(!hidden).then_some(&mut label_layers),
zoom_range,
&sources,
&mut source_defs,
&opts.fonts,
glyphs_url,
&mut report,
),
other => report.warn(format!(
"layer `{id}`: type `{other}` not supported — skipped"
)),
}
if hidden {
gate_hidden(id, &mut nodes, &mut outputs, out_start);
}
}
emit_label_placement(&mut nodes, &label_layers);
if outputs.is_empty() {
report.warn("no renderable layers produced output".to_string());
}
let output = fold_blend(&mut nodes, &outputs);
let mut doc = Map::new();
doc.insert(
"name".into(),
Value::String(
style
.get("name")
.and_then(Value::as_str)
.unwrap_or("converted")
.to_string(),
),
);
doc.insert("tile-size".into(), Value::from(opts.tile_size));
let pad = if required_pad > opts.pad {
report.warn(format!(
"raised the recipe pad from {} to {required_pad}px to cover the heatmap \
kernel radius (pass a larger `pad` to override)",
opts.pad
));
required_pad
} else {
opts.pad
};
doc.insert("pad".into(), Value::from(pad));
doc.insert("sources".into(), Value::Object(source_defs));
doc.insert("nodes".into(), Value::Object(nodes));
doc.insert("output".into(), Value::String(output));
Ok((Value::Object(doc), report))
}
fn gate_hidden(id: &str, nodes: &mut Map<String, Value>, outputs: &mut [String], from: usize) {
if from >= outputs.len() {
return;
}
const OFF: &str = "__hidden_off";
if !nodes.contains_key(OFF) {
nodes.insert(
OFF.into(),
serde_json::json!({ "op": "solid", "color": "#00000000" }),
);
}
for (i, slot) in outputs.iter_mut().enumerate().skip(from) {
let sw = format!("{id}__vis{i}");
nodes.insert(
sw.clone(),
serde_json::json!({
"op": "switch",
"a": format!("@{OFF}"),
"b": format!("@{slot}"),
"select": "a"
}),
);
*slot = sw;
}
}
fn fold_blend(nodes: &mut Map<String, Value>, outputs: &[String]) -> String {
match outputs.split_first() {
None => {
nodes.insert(
"empty".into(),
serde_json::json!({ "op": "solid", "color": "#00000000" }),
);
"empty".to_string()
}
Some((first, rest)) => {
let mut layers = vec![first.clone()];
for over in rest {
if layers.len() == 1 && *over == *first && is_opaque_solid(nodes, first) {
continue;
}
layers.push(over.clone());
}
if layers.len() == 1 {
return layers.into_iter().next().expect("one layer");
}
let id = unique_id(nodes, "stack");
let refs: Vec<Value> = layers
.iter()
.map(|l| Value::String(format!("@{l}")))
.collect();
emit_stack_chain(nodes, &id, &refs);
id
}
}
}
const STACK_CHUNK: usize = 8;
fn emit_stack_chain(nodes: &mut Map<String, Value>, id: &str, layers: &[Value]) {
let mut pos = 0;
let mut prev: Option<String> = None;
let mut part = 0;
while pos < layers.len() {
let take = STACK_CHUNK - usize::from(prev.is_some());
let end = (pos + take).min(layers.len());
let mut chunk: Vec<Value> = Vec::with_capacity(STACK_CHUNK);
if let Some(p) = &prev {
chunk.push(Value::String(format!("@{p}")));
}
chunk.extend_from_slice(&layers[pos..end]);
let node_id = if end == layers.len() {
id.to_string()
} else {
part += 1;
unique_id(nodes, &format!("{id}_{part}"))
};
nodes.insert(
node_id.clone(),
serde_json::json!({ "op": "stack", "layers": Value::Array(chunk) }),
);
prev = Some(node_id);
pos = end;
}
}
fn unique_id(nodes: &Map<String, Value>, base: &str) -> String {
if !nodes.contains_key(base) {
return base.to_string();
}
(1..)
.map(|n| format!("{base}_{n}"))
.find(|id| !nodes.contains_key(id))
.expect("an unused id exists")
}
fn is_opaque_solid(nodes: &Map<String, Value>, id: &str) -> bool {
let Some(node) = nodes.get(id).and_then(Value::as_object) else {
return false;
};
if node.get("op").and_then(Value::as_str) != Some("solid") {
return false;
}
if let Some(opacity) = node.get("opacity") {
if opacity.as_f64() != Some(1.0) {
return false;
}
}
let Some(hex) = node
.get("color")
.and_then(Value::as_str)
.and_then(|c| c.strip_prefix('#'))
else {
return false;
};
match hex.len() {
3 | 6 => true,
4 => hex.ends_with(['f', 'F']),
8 => hex[6..].eq_ignore_ascii_case("ff"),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn solid(color: &str) -> Value {
serde_json::json!({ "op": "solid", "color": color })
}
#[test]
fn self_blend_of_an_opaque_solid_is_skipped() {
let mut nodes = Map::new();
nodes.insert("bg".into(), solid("#cccccc"));
nodes.insert("fill".into(), solid("#112233"));
let outputs = ["bg".to_string(), "bg".to_string(), "fill".to_string()];
let out = fold_blend(&mut nodes, &outputs);
assert_eq!(out, "stack");
assert_eq!(
nodes["stack"]["layers"],
serde_json::json!(["@bg", "@fill"])
);
}
#[test]
fn self_blend_of_a_translucent_solid_is_kept() {
let mut nodes = Map::new();
nodes.insert("bg".into(), solid("#33333380"));
let outputs = ["bg".to_string(), "bg".to_string()];
let out = fold_blend(&mut nodes, &outputs);
assert_eq!(out, "stack");
assert_eq!(nodes["stack"]["layers"], serde_json::json!(["@bg", "@bg"]));
}
#[test]
fn single_layer_needs_no_stack() {
let mut nodes = Map::new();
nodes.insert("bg".into(), solid("#cccccc"));
let outputs = ["bg".to_string()];
let out = fold_blend(&mut nodes, &outputs);
assert_eq!(out, "bg");
assert!(!nodes.contains_key("stack"));
}
#[test]
fn layers_fold_into_one_stack() {
let mut nodes = Map::new();
for c in ["#111111", "#222222", "#333333"] {
nodes.insert(c.into(), solid(c));
}
let outputs = [
"#111111".to_string(),
"#222222".to_string(),
"#333333".to_string(),
];
let out = fold_blend(&mut nodes, &outputs);
assert_eq!(out, "stack");
assert_eq!(
nodes["stack"]["layers"],
serde_json::json!(["@#111111", "@#222222", "@#333333"])
);
}
#[test]
fn wide_layer_runs_fold_into_a_chain_of_narrow_stacks() {
let mut nodes = Map::new();
let outputs: Vec<String> = (0..18)
.map(|i| {
let id = format!("l{i}");
nodes.insert(id.clone(), solid("#11223344"));
id
})
.collect();
let out = fold_blend(&mut nodes, &outputs);
assert_eq!(out, "stack");
let mut flat: Vec<String> = Vec::new();
let mut next = Some(out);
while let Some(id) = next {
let layers = nodes[&id]["layers"].as_array().unwrap().clone();
assert!(
layers.len() <= STACK_CHUNK,
"no stack node should exceed the chunk width"
);
next = None;
let mut here: Vec<String> = Vec::new();
for (i, l) in layers.iter().enumerate() {
let name = l.as_str().unwrap().trim_start_matches('@').to_string();
if i == 0 && nodes[&name]["op"] == "stack" {
next = Some(name);
} else {
here.push(name);
}
}
here.append(&mut flat);
flat = here;
}
assert_eq!(flat, outputs);
}
#[test]
fn opaque_solid_detection() {
let mut nodes = Map::new();
nodes.insert("rgb".into(), solid("#abcdef"));
nodes.insert("short".into(), solid("#abc"));
nodes.insert("alpha_ff".into(), solid("#abcdefFF"));
nodes.insert("alpha_80".into(), solid("#abcdef80"));
nodes.insert(
"dimmed".into(),
serde_json::json!({ "op": "solid", "color": "#abcdef", "opacity": 0.5 }),
);
nodes.insert("not_solid".into(), serde_json::json!({ "op": "blur" }));
assert!(is_opaque_solid(&nodes, "rgb"));
assert!(is_opaque_solid(&nodes, "short"));
assert!(is_opaque_solid(&nodes, "alpha_ff"));
assert!(!is_opaque_solid(&nodes, "alpha_80"));
assert!(!is_opaque_solid(&nodes, "dimmed"));
assert!(!is_opaque_solid(&nodes, "not_solid"));
assert!(!is_opaque_solid(&nodes, "missing"));
}
}