use std::collections::HashMap;
use serde_json::{Map, Value};
use crate::maplibre::filter;
use crate::maplibre::layers::fill::{resolve_number, resolve_paint_color};
use crate::maplibre::layers::paint_of;
use crate::maplibre::sources::{features_node, resolve_layer_source, Sources};
use crate::maplibre::{Report, ZoomRange};
const DEFAULT_TEXT_FONT: [&str; 2] = ["Open Sans Regular", "Arial Unicode MS Regular"];
#[allow(clippy::too_many_arguments)]
pub(crate) fn convert_symbol(
id: &str,
layer: &Map<String, Value>,
nodes: &mut Map<String, Value>,
outputs: &mut Vec<String>,
label_layers: Option<&mut Vec<String>>,
zoom_range: ZoomRange,
sources: &Sources,
source_defs: &mut Map<String, Value>,
fonts: &HashMap<String, String>,
glyphs_url: Option<&str>,
report: &mut Report,
) {
let Some((source, source_layer)) = resolve_layer_source(id, layer, sources, report) else {
return;
};
let (min_zoom, max_zoom) = zoom_range;
let layout = layer.get("layout").and_then(Value::as_object);
let icon_image = layout.and_then(|l| l.get("icon-image"));
let has_text = layout
.and_then(|l| l.get("text-field"))
.is_some_and(|v| !v.is_null());
if icon_image.is_none() && !has_text {
report.warn(format!(
"layer `{id}`: `symbol` without `icon-image` or `text-field` — skipped"
));
return;
}
let base_filter_expr = filter::layer_filter_expr(layer, report, id);
let feat_id = format!("{id}__feat");
let mut feat_emitted = false;
let mut ensure_feat = |nodes: &mut Map<String, Value>| {
if !feat_emitted {
nodes.insert(
feat_id.clone(),
features_node(
&source,
&source_layer,
base_filter_expr.clone(),
min_zoom,
max_zoom,
),
);
feat_emitted = true;
}
format!("@{feat_id}")
};
let placement = resolve_placement(layout, id, report);
let icon_fields = icon_image
.filter(|_| placement == "point")
.and_then(|v| icon_fields(id, v, layer, layout, sources, report));
if let (Some(icon_image), None) = (icon_image, &icon_fields) {
convert_icon_stamp(
id,
icon_image,
layer,
layout,
nodes,
outputs,
sources,
&mut ensure_feat,
report,
);
}
if has_text {
convert_text(
id,
layer,
layout,
placement,
icon_fields,
zoom_range,
nodes,
outputs,
label_layers,
source_defs,
fonts,
glyphs_url,
&source,
&source_layer,
base_filter_expr.clone(),
&mut ensure_feat,
report,
);
} else if let Some(icon_fields) = icon_fields {
convert_icon_only(
id,
icon_fields,
zoom_range,
nodes,
outputs,
label_layers,
&source,
&source_layer,
base_filter_expr.clone(),
&mut ensure_feat,
);
}
}
fn resolve_placement(
layout: Option<&Map<String, Value>>,
id: &str,
report: &mut Report,
) -> &'static str {
match layout
.and_then(|l| l.get("symbol-placement"))
.and_then(Value::as_str)
{
None | Some("point") => "point",
Some("line") => "line",
Some("line-center") => "line-center",
Some(other) => {
report.warn(format!(
"layer `{id}`: unknown `symbol-placement: {other}` — using point placement"
));
"point"
}
}
}
fn icon_fields(
id: &str,
icon_image: &Value,
layer: &Map<String, Value>,
layout: Option<&Map<String, Value>>,
sources: &Sources,
report: &mut Report,
) -> Option<Map<String, Value>> {
let get = |key: &str| layout.and_then(|l| l.get(key));
let mut f = Map::new();
match icon_image.as_str() {
Some(icon_name) => {
let (sprite_src, sprite_icon) = sources.resolve_icon(icon_name).or_else(|| {
report.warn(format!(
"layer `{id}`: icon `{icon_name}` needs a `sprite`, but the style declares none — skipped"
));
None
})?;
f.insert("icon-sprite".into(), Value::from(format!("@{sprite_src}")));
f.insert("icon-name".into(), Value::from(sprite_icon));
}
None => {
let sprite_src = sources.default_sprite().or_else(|| {
report.warn(format!(
"layer `{id}`: data-driven `icon-image` needs a `sprite`, but the style declares none — skipped"
));
None
})?;
f.insert("icon-sprite".into(), Value::from(format!("@{sprite_src}")));
f.insert("icon-name-expr".into(), icon_image.clone());
}
}
for (field, expr_field, value) in [
("icon-size", "icon-size-expr", get("icon-size")),
(
"icon-rotate-deg",
"icon-rotate-deg-expr",
get("icon-rotate"),
),
("icon-padding-px", "icon-padding-expr", get("icon-padding")),
(
"icon-opacity",
"icon-opacity-expr",
paint_of(layer).get("icon-opacity"),
),
] {
let (constant, expr) = resolve_number(value);
if let Some(c) = constant {
f.insert(field.into(), Value::from(c));
}
if let Some(e) = expr {
f.insert(expr_field.into(), e);
}
}
if let Some(anchor) = const_string(get("icon-anchor"), "icon-anchor", id, report) {
f.insert("icon-anchor".into(), Value::from(anchor));
}
if let Some(offset) = const_offset(get("icon-offset"), id, report) {
f.insert("icon-offset".into(), serde_json::json!(offset));
}
let mut allow_overlap = get("icon-allow-overlap").and_then(Value::as_bool);
match get("icon-overlap").and_then(Value::as_str) {
Some("always") => allow_overlap = Some(true),
Some("never") => allow_overlap = Some(false),
Some("cooperative") => {
report.warn(format!(
"layer `{id}`: `icon-overlap: cooperative` has no ezu equivalent — treated as `never`"
));
allow_overlap = Some(false);
}
Some(other) => report.warn(format!(
"layer `{id}`: unknown `icon-overlap: {other}` — using collision default"
)),
None => {}
}
if allow_overlap == Some(true) {
f.insert("icon-allow-overlap".into(), Value::from(true));
}
if get("icon-ignore-placement").and_then(Value::as_bool) == Some(true) {
f.insert("icon-ignore-placement".into(), Value::from(true));
}
if get("icon-optional").and_then(Value::as_bool) == Some(true) {
f.insert("icon-optional".into(), Value::from(true));
}
if let Some(fit) = const_string(get("icon-text-fit"), "icon-text-fit", id, report) {
f.insert("icon-text-fit".into(), Value::from(fit));
}
match get("icon-text-fit-padding") {
Some(Value::Array(a)) if a.len() == 4 && a.iter().all(Value::is_number) => {
f.insert("icon-text-fit-padding".into(), Value::Array(a.clone()));
}
Some(_) => report.warn(format!(
"layer `{id}`: expression `icon-text-fit-padding` not supported — using the default"
)),
None => {}
}
Some(f)
}
#[allow(clippy::too_many_arguments)]
fn convert_icon_only(
id: &str,
icon_fields: Map<String, Value>,
zoom_range: ZoomRange,
nodes: &mut Map<String, Value>,
outputs: &mut Vec<String>,
label_layers: Option<&mut Vec<String>>,
source: &str,
source_layer: &str,
base_filter_expr: Option<Value>,
ensure_feat: &mut impl FnMut(&mut Map<String, Value>) -> String,
) {
let feat_ref = ensure_feat(nodes);
let mut spec = serde_json::json!({ "op": "text", "features": feat_ref });
for (k, v) in icon_fields {
spec[k] = v;
}
set_placement_context(
&mut spec,
source,
source_layer,
zoom_range,
base_filter_expr,
);
emit_label_nodes(id, spec, nodes, outputs, label_layers);
}
fn set_placement_context(
spec: &mut Value,
source: &str,
source_layer: &str,
zoom_range: ZoomRange,
base_filter_expr: Option<Value>,
) {
spec["source"] = Value::from(source);
spec["layer"] = Value::from(source_layer);
let (min_zoom, max_zoom) = zoom_range;
if let Some(z) = min_zoom {
spec["min-zoom"] = Value::from(z);
}
if let Some(z) = max_zoom {
spec["max-zoom"] = Value::from(z);
}
if let Some(f) = base_filter_expr {
spec["filter-expr"] = f;
}
}
fn emit_label_nodes(
id: &str,
mut spec: Value,
nodes: &mut Map<String, Value>,
outputs: &mut Vec<String>,
label_layers: Option<&mut Vec<String>>,
) {
let text_id = format!("{id}__text");
let Some(label_layers) = label_layers else {
spec["op"] = Value::from("text");
nodes.insert(text_id.clone(), spec);
outputs.push(text_id);
return;
};
let labels_id = format!("{id}__labels");
spec["op"] = Value::from("text-labels");
nodes.insert(labels_id.clone(), spec);
label_layers.push(labels_id.clone());
nodes.insert(
text_id.clone(),
serde_json::json!({
"op": "text-draw",
"labels": format!("@{labels_id}"),
"placement": format!("@{LABEL_PLACEMENT_ID}"),
}),
);
outputs.push(text_id);
}
#[allow(clippy::too_many_arguments)]
fn convert_icon_stamp(
id: &str,
icon_image: &Value,
layer: &Map<String, Value>,
layout: Option<&Map<String, Value>>,
nodes: &mut Map<String, Value>,
outputs: &mut Vec<String>,
sources: &Sources,
ensure_feat: &mut impl FnMut(&mut Map<String, Value>) -> String,
report: &mut Report,
) {
let feat_ref = ensure_feat(nodes);
let stamp_id = format!("{id}__stamp");
let mut spec = match icon_image.as_str() {
Some(icon_name) => {
let Some((sprite_src, sprite_icon)) = sources.resolve_icon(icon_name) else {
report.warn(format!(
"layer `{id}`: icon `{icon_name}` needs a `sprite`, but the style declares none — skipped"
));
return;
};
let icon_id = format!("{id}__icon");
nodes.insert(
icon_id.clone(),
serde_json::json!({ "op": "icon", "sprite": format!("@{sprite_src}"), "name": sprite_icon }),
);
serde_json::json!({ "op": "stamp", "features": feat_ref, "image": format!("@{icon_id}") })
}
None => {
let Some(sprite_src) = sources.default_sprite() else {
report.warn(format!(
"layer `{id}`: data-driven `icon-image` needs a `sprite`, but the style declares none — skipped"
));
return;
};
serde_json::json!({
"op": "stamp", "features": feat_ref,
"sprite": format!("@{sprite_src}"), "name-expr": icon_image.clone()
})
}
};
let (size, size_expr) = resolve_number(layout.and_then(|l| l.get("icon-size")));
if let Some(s) = size {
if s != 1.0 {
spec["scale"] = Value::from(s);
}
}
if let Some(e) = size_expr {
spec["scale-expr"] = e;
}
let (rotate, rotate_expr) = resolve_number(layout.and_then(|l| l.get("icon-rotate")));
if let Some(r) = rotate {
if r != 0.0 {
spec["rotation-deg"] = Value::from(r);
}
}
if let Some(e) = rotate_expr {
spec["rotation-deg-expr"] = e;
}
let (opacity, opacity_expr) = resolve_number(paint_of(layer).get("icon-opacity"));
if let Some(a) = opacity {
spec["opacity"] = Value::from(a);
}
if let Some(e) = opacity_expr {
spec["opacity-expr"] = e;
}
for prop in [
"icon-allow-overlap",
"icon-ignore-placement",
"icon-overlap",
] {
if layout.and_then(|l| l.get(prop)).is_some() {
report.warn(format!(
"layer `{id}`: `{prop}` not supported on a line-placed icon — stamped without collision"
));
}
}
nodes.insert(stamp_id.clone(), spec);
outputs.push(stamp_id);
}
#[allow(clippy::too_many_arguments)]
fn convert_text(
id: &str,
layer: &Map<String, Value>,
layout: Option<&Map<String, Value>>,
placement: &str,
icon_fields: Option<Map<String, Value>>,
zoom_range: ZoomRange,
nodes: &mut Map<String, Value>,
outputs: &mut Vec<String>,
label_layers: Option<&mut Vec<String>>,
source_defs: &mut Map<String, Value>,
fonts: &HashMap<String, String>,
glyphs_url: Option<&str>,
source: &str,
source_layer: &str,
base_filter_expr: Option<Value>,
ensure_feat: &mut impl FnMut(&mut Map<String, Value>) -> String,
report: &mut Report,
) {
let get = |key: &str| layout.and_then(|l| l.get(key));
let text_font = get("text-font");
let is_static_stack = match text_font {
None => true,
Some(v @ Value::Array(_)) => !maplibre_expr::is_expression(v),
_ => false,
};
let mut font_expr_value: Option<Value> = None;
let mut font_stacks_obj = Map::new();
let font_refs: Vec<String> = if is_static_stack {
let stack: Vec<String> = match text_font {
Some(Value::Array(a)) => a
.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect(),
_ => DEFAULT_TEXT_FONT.iter().map(|s| s.to_string()).collect(),
};
match lower_stack(&stack, source_defs, fonts, glyphs_url, id, report) {
Some(refs) => refs,
None => return,
}
} else {
let value = text_font.expect("non-static text-font is present");
let stacks = collect_font_stacks(value);
if stacks.is_empty() {
report.warn(format!(
"layer `{id}`: data-driven `text-font`: no literal stacks found — using the default stack"
));
let stack: Vec<String> = DEFAULT_TEXT_FONT.iter().map(|s| s.to_string()).collect();
match lower_stack(&stack, source_defs, fonts, glyphs_url, id, report) {
Some(refs) => refs,
None => return,
}
} else {
let mut default_refs: Option<Vec<String>> = None;
for stack in &stacks {
let Some(refs) = lower_stack(stack, source_defs, fonts, glyphs_url, id, report)
else {
continue;
};
if default_refs.is_none() {
default_refs = Some(refs.clone());
}
let key = stack.iter().map(|s| s.trim()).collect::<Vec<_>>().join(",");
font_stacks_obj.entry(key).or_insert_with(|| {
Value::Array(refs.iter().cloned().map(Value::from).collect())
});
}
match default_refs {
Some(refs) => {
font_expr_value = Some(value.clone());
refs
}
None => return,
}
}
};
let font_value = Value::Array(font_refs.into_iter().map(Value::from).collect());
let text_value = match get("text-field") {
Some(Value::String(s)) => match rewrite_field_tokens(s) {
Some(expr) => expr,
None => Value::String(s.clone()),
},
Some(v @ Value::Array(_)) => {
register_format_section_fonts(
v,
&mut font_stacks_obj,
source_defs,
fonts,
glyphs_url,
id,
);
v.clone()
}
Some(v @ Value::Object(_)) => match rewrite_legacy_stops_tokens(v) {
Some(expr) => expr,
None => v.clone(),
},
_ => return,
};
let feat_ref = ensure_feat(nodes);
let mut spec = serde_json::json!({
"op": "text", "features": feat_ref,
"font": font_value, "text": text_value
});
if let Some(expr) = font_expr_value {
spec["font-expr"] = expr;
}
if !font_stacks_obj.is_empty() {
spec["font-stacks"] = Value::Object(font_stacks_obj);
}
let paint = paint_of(layer);
let (size, size_expr) = resolve_number(get("text-size"));
if let Some(s) = size {
spec["size"] = Value::from(s);
}
if let Some(e) = size_expr {
spec["size-expr"] = e;
}
let (color, color_expr) = resolve_paint_color(paint.get("text-color"));
if let Some(c) = color {
spec["color"] = Value::from(c);
}
if let Some(e) = color_expr {
spec["color-expr"] = e;
}
let (halo_color, halo_color_expr) = resolve_paint_color(paint.get("text-halo-color"));
if let Some(c) = halo_color {
spec["halo-color"] = Value::from(c);
}
if let Some(e) = halo_color_expr {
spec["halo-color-expr"] = e;
}
let (halo_width, halo_width_expr) = resolve_number(paint.get("text-halo-width"));
if let Some(w) = halo_width {
spec["halo-width"] = Value::from(w);
}
if let Some(e) = halo_width_expr {
spec["halo-width-expr"] = e;
}
let (opacity, opacity_expr) = resolve_number(paint.get("text-opacity"));
if let Some(a) = opacity {
spec["opacity"] = Value::from(a);
}
if let Some(e) = opacity_expr {
spec["opacity-expr"] = e;
}
if let Some(anchor) = const_string(get("text-anchor"), "text-anchor", id, report) {
spec["anchor"] = Value::from(anchor);
}
if let Some(variants) = const_anchor_list(get("text-variable-anchor"), id, report) {
spec["anchor-variants"] = serde_json::json!(variants);
if let Some(r) = const_number(get("text-radial-offset"), "text-radial-offset", id, report) {
spec["radial-offset"] = Value::from(r);
}
}
if let Some(justify) = const_string(get("text-justify"), "text-justify", id, report) {
spec["justify"] = Value::from(justify);
}
if let Some(transform) = const_string(get("text-transform"), "text-transform", id, report) {
spec["transform"] = Value::from(transform);
}
if let Some(offset) = const_offset(get("text-offset"), id, report) {
spec["offset-em"] = serde_json::json!(offset);
}
if let Some(w) = const_number(get("text-max-width"), "text-max-width", id, report) {
spec["max-width-em"] = Value::from(w);
}
if let Some(h) = const_number(get("text-line-height"), "text-line-height", id, report) {
spec["line-height"] = Value::from(h);
}
if let Some(s) = const_number(
get("text-letter-spacing"),
"text-letter-spacing",
id,
report,
) {
spec["letter-spacing-em"] = Value::from(s);
}
if placement != "point" {
spec["placement"] = Value::from(placement);
if let Some(s) = const_number(get("symbol-spacing"), "symbol-spacing", id, report) {
spec["spacing-px"] = Value::from(s);
}
if let Some(a) = const_number(get("text-max-angle"), "text-max-angle", id, report) {
spec["max-angle-deg"] = Value::from(a);
}
if get("text-keep-upright").and_then(Value::as_bool) == Some(false) {
spec["keep-upright"] = Value::from(false);
}
if get("text-rotation-alignment").and_then(Value::as_str) == Some("viewport") {
report.warn(format!(
"layer `{id}`: `text-rotation-alignment: viewport` on line placement not supported — glyphs follow the line"
));
}
}
if let Some(icon_fields) = icon_fields {
for (k, v) in icon_fields {
spec[k] = v;
}
}
set_placement_context(
&mut spec,
source,
source_layer,
zoom_range,
base_filter_expr,
);
let mut allow_overlap = get("text-allow-overlap").and_then(Value::as_bool);
match get("text-overlap").and_then(Value::as_str) {
Some("always") => allow_overlap = Some(true),
Some("never") => allow_overlap = Some(false),
Some("cooperative") => {
report.warn(format!(
"layer `{id}`: `text-overlap: cooperative` has no ezu equivalent — treated as `never`"
));
allow_overlap = Some(false);
}
Some(other) => report.warn(format!(
"layer `{id}`: unknown `text-overlap: {other}` — using collision default"
)),
None => {}
}
if allow_overlap == Some(true) {
spec["allow-overlap"] = Value::from(true);
}
if get("text-ignore-placement").and_then(Value::as_bool) == Some(true) {
spec["ignore-placement"] = Value::from(true);
}
let (padding, padding_expr) = resolve_number(get("text-padding"));
if let Some(p) = padding {
spec["padding-px"] = Value::from(p);
}
if let Some(e) = padding_expr {
spec["padding-expr"] = e;
}
if let Some(v) = get("symbol-sort-key") {
spec["sort-key-expr"] = v.clone();
}
if get("text-optional").and_then(Value::as_bool) == Some(true) {
spec["text-optional"] = Value::from(true);
}
emit_label_nodes(id, spec, nodes, outputs, label_layers);
}
pub(crate) const LABEL_PLACEMENT_ID: &str = "__label_placement";
pub(crate) fn emit_label_placement(nodes: &mut Map<String, Value>, label_layers: &[String]) {
if label_layers.is_empty() {
return;
}
nodes.insert(
LABEL_PLACEMENT_ID.to_string(),
serde_json::json!({
"op": "label-placement",
"labels": label_layers
.iter()
.map(|id| Value::from(format!("@{id}")))
.collect::<Vec<_>>(),
}),
);
}
fn lower_stack(
stack: &[String],
source_defs: &mut Map<String, Value>,
fonts: &HashMap<String, String>,
glyphs_url: Option<&str>,
id: &str,
report: &mut Report,
) -> Option<Vec<String>> {
let (mapped, unmapped): (Vec<&String>, Vec<&String>) =
stack.iter().partition(|name| fonts.contains_key(*name));
if mapped.is_empty() {
let Some(glyphs_url) = glyphs_url else {
report.warn(format!(
"layer `{id}`: `symbol` text: no font mapping for {stack:?} and the style has no `glyphs` endpoint — pass `--font \"NAME=URL\"`; text skipped"
));
return None;
};
Some(vec![ensure_glyphs_source(
source_defs,
glyphs_url,
&stack.join(", "),
)])
} else {
if !unmapped.is_empty() {
report.warn(format!(
"layer `{id}`: `symbol` text: no font mapping for {unmapped:?} — using the mapped subset"
));
}
Some(
mapped
.iter()
.map(|name| {
let url = fonts
.get(name.as_str())
.expect("partitioned on containment");
ensure_font_source(source_defs, name, url)
})
.collect(),
)
}
}
fn as_string_array(v: &Value) -> Option<Vec<String>> {
let a = v.as_array()?;
let mut names = Vec::with_capacity(a.len());
for x in a {
names.push(x.as_str()?.to_string());
}
Some(names)
}
fn collect_font_stacks(v: &Value) -> Vec<Vec<String>> {
fn push_unique(out: &mut Vec<Vec<String>>, names: Vec<String>) {
if !names.is_empty() && !out.contains(&names) {
out.push(names);
}
}
fn rec(v: &Value, out: &mut Vec<Vec<String>>) {
match v {
Value::Array(a) => {
if a.len() == 2 && a[0].as_str() == Some("literal") {
if let Some(names) = as_string_array(&a[1]) {
push_unique(out, names);
}
return;
}
for x in a {
rec(x, out);
}
}
Value::Object(m) => {
if let Some(Value::Array(stops)) = m.get("stops") {
for stop in stops {
if let Some(output) = stop.as_array().and_then(|p| p.get(1)) {
if let Some(names) = as_string_array(output) {
push_unique(out, names);
}
}
}
}
if let Some(names) = m.get("default").and_then(as_string_array) {
push_unique(out, names);
}
for val in m.values() {
rec(val, out);
}
}
_ => {}
}
}
let mut out = Vec::new();
rec(v, &mut out);
out
}
fn register_format_section_fonts(
v: &Value,
font_stacks: &mut Map<String, Value>,
source_defs: &mut Map<String, Value>,
fonts: &HashMap<String, String>,
glyphs_url: Option<&str>,
id: &str,
) {
match v {
Value::Array(arr) => {
if arr.first().and_then(Value::as_str) == Some("format") {
let mut i = 2;
while i < arr.len() {
if let Some(obj) = arr[i].as_object() {
if let Some(tf) = obj.get("text-font") {
let stacks = match as_string_array(tf) {
Some(s) => vec![s],
None => collect_font_stacks(tf),
};
let mut quiet = Report::default();
for stack in &stacks {
if let Some(refs) = lower_stack(
stack,
source_defs,
fonts,
glyphs_url,
id,
&mut quiet,
) {
let key = stack
.iter()
.map(|s| s.trim())
.collect::<Vec<_>>()
.join(",");
font_stacks.entry(key).or_insert_with(|| {
Value::Array(
refs.iter().cloned().map(Value::from).collect(),
)
});
}
}
}
}
i += 2;
}
}
for x in arr {
register_format_section_fonts(x, font_stacks, source_defs, fonts, glyphs_url, id);
}
}
Value::Object(m) => {
for val in m.values() {
register_format_section_fonts(val, font_stacks, source_defs, fonts, glyphs_url, id);
}
}
_ => {}
}
}
fn ensure_font_source(source_defs: &mut Map<String, Value>, name: &str, url: &str) -> String {
if let Some((id, _)) = source_defs
.iter()
.find(|(_, d)| d["type"] == "font" && d["url"] == url)
{
return id.clone();
}
let id = unique_source_id(source_defs, &kebab_id(name));
source_defs.insert(
id.clone(),
serde_json::json!({ "type": "font", "url": url }),
);
id
}
fn ensure_glyphs_source(
source_defs: &mut Map<String, Value>,
url: &str,
fontstack: &str,
) -> String {
if let Some((id, _)) = source_defs
.iter()
.find(|(_, d)| d["type"] == "glyphs" && d["url"] == url && d["fontstack"] == fontstack)
{
return id.clone();
}
let id = unique_source_id(source_defs, &kebab_id(fontstack));
source_defs.insert(
id.clone(),
serde_json::json!({ "type": "glyphs", "url": url, "fontstack": fontstack }),
);
id
}
fn kebab_id(name: &str) -> String {
let mut base: String = name
.to_lowercase()
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect();
while base.contains("--") {
base = base.replace("--", "-");
}
base.trim_matches('-').to_string()
}
fn unique_source_id(source_defs: &Map<String, Value>, base: &str) -> String {
let mut id = base.to_string();
let mut n = 2;
while source_defs.contains_key(&id) {
id = format!("{base}-{n}");
n += 1;
}
id
}
fn rewrite_field_tokens(s: &str) -> Option<Value> {
let mut parts: Vec<Value> = Vec::new();
let mut literal = String::new();
let mut rest = s;
let mut found = false;
while let Some(open) = rest.find('{') {
let after = &rest[open + 1..];
let Some(close) = after.find('}') else {
break; };
let token = &after[..close];
literal.push_str(&rest[..open]);
if !literal.is_empty() {
parts.push(Value::String(std::mem::take(&mut literal)));
}
parts.push(serde_json::json!(["to-string", ["get", token]]));
found = true;
rest = &after[close + 1..];
}
if !found {
return None;
}
literal.push_str(rest);
if !literal.is_empty() {
parts.push(Value::String(literal));
}
if parts.len() == 1 {
return Some(parts.pop().expect("one part"));
}
let mut concat = vec![Value::String("concat".into())];
concat.extend(parts);
Some(Value::Array(concat))
}
fn rewrite_legacy_stops_tokens(v: &Value) -> Option<Value> {
let obj = v.as_object()?;
if obj.contains_key("property") {
return None;
}
match obj.get("type").and_then(Value::as_str) {
None | Some("interval") => {}
Some(_) => return None,
}
let stops = obj.get("stops")?.as_array()?;
let mut pairs: Vec<(f64, &str)> = Vec::with_capacity(stops.len());
for stop in stops {
let pair = stop.as_array()?;
pairs.push((pair.first()?.as_f64()?, pair.get(1)?.as_str()?));
}
if pairs.is_empty() || !pairs.iter().any(|(_, s)| s.contains('{')) {
return None;
}
let expand = |s: &str| rewrite_field_tokens(s).unwrap_or_else(|| Value::String(s.into()));
if pairs.len() == 1 {
return Some(expand(pairs[0].1));
}
let mut step = vec![
Value::String("step".into()),
serde_json::json!(["zoom"]),
expand(pairs[0].1),
];
for (input, output) in &pairs[1..] {
step.push(Value::from(*input));
step.push(expand(output));
}
Some(Value::Array(step))
}
fn const_string(v: Option<&Value>, prop: &str, id: &str, report: &mut Report) -> Option<String> {
match v {
None => None,
Some(Value::String(s)) => Some(s.clone()),
Some(_) => {
report.warn(format!(
"layer `{id}`: expression `{prop}` not supported — using the default"
));
None
}
}
}
fn const_number(v: Option<&Value>, prop: &str, id: &str, report: &mut Report) -> Option<f64> {
match v {
None => None,
Some(n) if n.is_number() => n.as_f64(),
Some(_) => {
report.warn(format!(
"layer `{id}`: expression `{prop}` not supported — using the default"
));
None
}
}
}
fn const_anchor_list(v: Option<&Value>, id: &str, report: &mut Report) -> Option<Vec<String>> {
match v {
None => None,
Some(Value::Array(a)) if !a.is_empty() && a.iter().all(Value::is_string) => Some(
a.iter()
.filter_map(|s| s.as_str().map(str::to_string))
.collect(),
),
Some(_) => {
report.warn(format!(
"layer `{id}`: expression `text-variable-anchor` not supported — using `text-anchor`"
));
None
}
}
}
fn const_offset(v: Option<&Value>, id: &str, report: &mut Report) -> Option<[f64; 2]> {
match v {
None => None,
Some(Value::Array(a)) if a.len() == 2 && a.iter().all(Value::is_number) => {
Some([a[0].as_f64()?, a[1].as_f64()?])
}
Some(_) => {
report.warn(format!(
"layer `{id}`: expression `text-offset` not supported — using the default"
));
None
}
}
}