ezu_style/spec.rs
1//! The style spec data types: typed node-DAG documents parsed from
2//! JSON. Re-exported at the crate root.
3
4// JsonSchema generation is deferred — `schemars` 1.x has no `IndexMap`
5// impl out of the box, and the schema will likely want hand-tuning
6// (one entry per registered op) anyway. Derive serde only for now.
7
8use indexmap::IndexMap;
9use serde::{Deserialize, Serialize};
10
11use crate::StyleError;
12
13/// A parsed style document. Order of `nodes` is preserved (for
14/// deterministic error messages) but does not imply evaluation order —
15/// that is derived by topological sort of the DAG.
16#[derive(Debug, Deserialize)]
17#[serde(deny_unknown_fields, rename_all = "kebab-case")]
18pub struct Document {
19 pub name: String,
20 #[serde(default = "default_version")]
21 pub version: String,
22 /// Rendered tile edge in pixels. Every `*-px` field in the document
23 /// is measured against this, so it decides what a pixel is worth in
24 /// ground units: the same `width-px` covers twice the ground at 256
25 /// as it does at 512. Defaults to 512, MapLibre's vector-tile
26 /// convention, so px numbers carry across from a MapLibre style
27 /// unchanged.
28 #[serde(default = "default_tile_size")]
29 pub tile_size: u32,
30 #[serde(default)]
31 pub pad: u32,
32 #[serde(default)]
33 pub params: IndexMap<String, ParamDecl>,
34 /// Attribution for the style itself (HTML allowed, like MapLibre).
35 /// Per-source attributions live on the `sources` entries; hosts
36 /// merge both with upstream metadata (TileJSON / PMTiles) — see
37 /// [`Document::attributions`].
38 #[serde(default)]
39 pub attribution: Option<String>,
40 /// User-defined functions: reusable node subgraphs called with
41 /// `{ "op": "func", "fn": "<name>", ...args }`. Expanded inline at
42 /// graph-build time — see [`expand_functions`](crate::expand_functions).
43 #[serde(default)]
44 pub functions: IndexMap<String, FuncDecl>,
45 /// What the map's symbols mean. Never rendered into a tile — hosts
46 /// read it to draw a legend beside the map. See [`LegendDecl`].
47 #[serde(default)]
48 pub legend: Option<LegendDecl>,
49 /// External data the host provides. Mixes document-scoped resources
50 /// (`brush`, `image`, `sprite`, `font`) — resolved once per style —
51 /// and tile-scoped
52 /// pyramids (`mvt`, `pmtiles`, `dem`) — fetched per tile. The
53 /// `type` discriminator selects the variant.
54 ///
55 /// Per-tile variants bind their payload under `tile.<source-name>`
56 /// for source nodes to consume. Document-scoped variants are
57 /// referenced by `@source-name` in node fields (the legacy
58 /// `assets` block from 0.2 is gone — its entries move here).
59 #[serde(default)]
60 pub sources: IndexMap<String, SourceDecl>,
61 pub nodes: IndexMap<String, NodeSpec>,
62 /// Node id (with or without `@` prefix) that produces the final raster.
63 pub output: NodeRef,
64}
65
66impl Document {
67 /// Parse a style document. `//` line and `/* … */` block comments are
68 /// allowed anywhere JSON allows whitespace; they are blanked in place
69 /// before parsing, so an error's line and column still point into the
70 /// text as written.
71 pub fn from_json(s: &str) -> Result<Self, StyleError> {
72 let source = crate::blank_comments(s)?;
73 Ok(serde_json::from_str(&source)?)
74 }
75
76 /// Every attribution string declared in the document: the style's
77 /// own `attribution` plus each source's, in declaration order,
78 /// deduplicated. Upstream metadata (TileJSON `attribution`,
79 /// PMTiles metadata) is a host concern — hosts merge it with this
80 /// list after opening their sources.
81 pub fn attributions(&self) -> Vec<&str> {
82 let mut out: Vec<&str> = Vec::new();
83 let candidates = std::iter::once(&self.attribution)
84 .chain(self.sources.values().map(|d| d.attribution()));
85 for a in candidates {
86 if let Some(a) = a.as_deref() {
87 if !a.is_empty() && !out.contains(&a) {
88 out.push(a);
89 }
90 }
91 }
92 out
93 }
94
95 /// A document that produces just `target`: that node, everything it
96 /// transitively references, and nothing else. `None` when `target`
97 /// is not a node in this document.
98 ///
99 /// Rendering one node of a style — a legend swatch is the reason to
100 /// want that — needs no special support from the evaluator if the
101 /// document handed to it says that node is the output. Nodes the
102 /// target does not depend on are left out rather than evaluated and
103 /// discarded, which also keeps a swatch from failing on a DEM or
104 /// raster source that belongs to some other layer.
105 ///
106 /// `params`, `functions` and `sources` come along whole — they are
107 /// declarations, and an unused one costs nothing. The `legend` does
108 /// not: its entries point at nodes that are probably no longer here.
109 pub fn subgraph(&self, target: &str) -> Option<Document> {
110 if !self.nodes.contains_key(target) {
111 return None;
112 }
113 let mut keep: IndexMap<String, NodeSpec> = IndexMap::new();
114 let mut queue = vec![target.to_string()];
115 while let Some(id) = queue.pop() {
116 if keep.contains_key(&id) {
117 continue;
118 }
119 let Some(spec) = self.nodes.get(&id) else {
120 // A `@name` that is not a node is a source reference, or
121 // an error the graph builder will report with context.
122 continue;
123 };
124 queue.extend(spec.refs());
125 keep.insert(id, spec.clone());
126 }
127 // Emit in the original declaration order, so error messages and
128 // any order-sensitive diagnostics read the same as they would
129 // against the whole document.
130 let nodes = self
131 .nodes
132 .iter()
133 .filter(|(id, _)| keep.contains_key(*id))
134 .map(|(id, spec)| (id.clone(), spec.clone()))
135 .collect();
136 Some(Document {
137 name: self.name.clone(),
138 version: self.version.clone(),
139 tile_size: self.tile_size,
140 pad: self.pad,
141 params: self.params.clone(),
142 attribution: self.attribution.clone(),
143 functions: self.functions.clone(),
144 legend: None,
145 sources: self.sources.clone(),
146 nodes,
147 output: NodeRef(target.to_string()),
148 })
149 }
150
151 /// JSON Schema describing the *parameter values* object a caller
152 /// may pass when rendering this style (CLI `--param`, server query
153 /// string, library `ParamValues`). Derived from the document's
154 /// `params` declarations: numbers carry `minimum` / `maximum`,
155 /// colors a hex-string pattern, and every entry its declared
156 /// `default` / `description`. Editor UIs can drive sliders and
157 /// color pickers straight off this.
158 pub fn params_schema(&self) -> serde_json::Value {
159 use serde_json::{json, Map, Value};
160 let mut props = Map::new();
161 for (name, decl) in &self.params {
162 let mut p = match decl.kind {
163 ParamKind::Number => {
164 let mut p = Map::new();
165 p.insert("type".into(), json!("number"));
166 if let Some(m) = decl.min {
167 p.insert("minimum".into(), json!(m));
168 }
169 if let Some(m) = decl.max {
170 p.insert("maximum".into(), json!(m));
171 }
172 p
173 }
174 ParamKind::Color => {
175 let mut p = Map::new();
176 p.insert("type".into(), json!("string"));
177 p.insert(
178 "pattern".into(),
179 json!("^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$"),
180 );
181 p.insert("format".into(), json!("color"));
182 p
183 }
184 ParamKind::Bool => {
185 let mut p = Map::new();
186 p.insert("type".into(), json!("boolean"));
187 p
188 }
189 };
190 p.insert("default".into(), decl.default.clone());
191 if let Some(d) = &decl.description {
192 p.insert("description".into(), json!(d));
193 }
194 props.insert(name.clone(), Value::Object(p));
195 }
196 json!({
197 "$schema": "https://json-schema.org/draft/2020-12/schema",
198 "title": format!("{} parameters", self.name),
199 "type": "object",
200 "additionalProperties": false,
201 "properties": props,
202 })
203 }
204}
205
206fn default_version() -> String {
207 "1".to_string()
208}
209fn default_tile_size() -> u32 {
210 512
211}
212
213/// A declared legend: what the map's symbols mean, in the order they
214/// should be read.
215///
216/// A legend is not drawn into a tile — it belongs beside the map, at a
217/// size and in a layout only the host knows. What the style owes the
218/// host is the content, and above all the correspondence between an
219/// entry and the symbol it explains. An entry states that by naming the
220/// node that draws the symbol and the feature that selects the case,
221/// rather than by restating a colour that would then be free to drift
222/// from the map.
223#[derive(Debug, Clone, Deserialize, Serialize)]
224#[serde(deny_unknown_fields, rename_all = "kebab-case")]
225pub struct LegendDecl {
226 /// Heading for the legend as a whole — usually what is being mapped.
227 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub title: Option<String>,
229 /// Prose below the entries: data source, the classification method
230 /// and why it was chosen, what the map leaves out.
231 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub note: Option<String>,
233 /// Entries in reading order.
234 #[serde(default)]
235 pub entries: Vec<LegendEntry>,
236}
237
238/// One row of a legend: a label, and the symbol it explains identified
239/// by where that symbol comes from.
240#[derive(Debug, Clone, Deserialize, Serialize)]
241#[serde(deny_unknown_fields, rename_all = "kebab-case")]
242pub struct LegendEntry {
243 /// What the reader sees. Write it for someone who has not read the
244 /// style: `under $40,000`, not `bin 0`.
245 pub label: String,
246 /// The node that draws this symbol on the map. Verified at
247 /// graph-build time to exist and to produce a `Raster`, so an entry
248 /// cannot point at something that draws nothing.
249 pub from: NodeRef,
250 /// Feature properties that select this entry's case, fed to the
251 /// node's `*-expr` fields the way a real feature would be. For a
252 /// choropleth, a value inside the class: `{ "income": 30000 }`.
253 #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
254 pub properties: serde_json::Map<String, serde_json::Value>,
255 /// Prose for this entry alone.
256 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub note: Option<String>,
258 /// Zooms this entry applies to, for a map whose symbols come and go
259 /// with scale. Absent means every zoom.
260 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub min_zoom: Option<u8>,
262 #[serde(default, skip_serializing_if = "Option::is_none")]
263 pub max_zoom: Option<u8>,
264 /// Which geometry the swatch's stand-in feature carries. Absent
265 /// leaves it to whoever draws the swatch, which offers all three.
266 #[serde(default, skip_serializing_if = "Option::is_none")]
267 pub geometry: Option<LegendGeometry>,
268}
269
270/// The geometry a legend swatch's stand-in feature is given.
271///
272/// A swatch is drawn by handing the entry's node one synthetic feature.
273/// [`All`](Self::All) gives it a polygon, a line and a point at once: a
274/// fill node reads the polygon, a stroke the line, a circle or stamp the
275/// point, and each ignores the rest. Naming one is for when a geometry
276/// op sits between the source and the entry's node — `boundary` turns
277/// the polygon into a rectangle outline, which a stroke would then draw
278/// as well as the line.
279#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
280#[serde(rename_all = "kebab-case")]
281pub enum LegendGeometry {
282 #[default]
283 All,
284 Polygon,
285 Line,
286 Point,
287}
288
289impl LegendDecl {
290 /// The entries visible at zoom `z`, in declaration order.
291 pub fn entries_at(&self, z: u8) -> impl Iterator<Item = &LegendEntry> {
292 self.entries.iter().filter(move |e| {
293 e.min_zoom.is_none_or(|min| z >= min) && e.max_zoom.is_none_or(|max| z <= max)
294 })
295 }
296}
297
298/// A user-defined function: a reusable node subgraph with declared
299/// input ports and output kind. Shaped like a mini-document — `inputs`
300/// play the role of `params`, `nodes` is the body, `output` names the
301/// body node whose value the call produces.
302///
303/// Inside the body, `@<input-name>` references a function input;
304/// `@<body-node>` references another body node; `@<source-name>`
305/// reaches a document-scoped source. Anything else is an error —
306/// functions are closed over their inputs (no implicit access to
307/// caller nodes).
308#[derive(Debug, Clone, Deserialize)]
309#[serde(deny_unknown_fields, rename_all = "kebab-case")]
310pub struct FuncDecl {
311 #[serde(default)]
312 pub description: Option<String>,
313 #[serde(default)]
314 pub inputs: IndexMap<String, FuncInput>,
315 /// Body node (with or without `@`) whose value the call produces.
316 pub output: NodeRef,
317 /// Declared kind of the output — verified against the body's
318 /// resolved port kind at graph-build time.
319 pub output_kind: FuncKind,
320 pub nodes: IndexMap<String, NodeSpec>,
321}
322
323/// One declared function input.
324#[derive(Debug, Clone, Deserialize)]
325#[serde(deny_unknown_fields, rename_all = "kebab-case")]
326pub struct FuncInput {
327 pub kind: FuncKind,
328 /// Default argument — allowed for `scalar` inputs only; its
329 /// presence makes the input optional at the call site. A `null`
330 /// default (or argument) makes substituted fields disappear from
331 /// the body node entirely — the way to feed optional op fields
332 /// whose absence means something (e.g. stroke curves).
333 #[serde(default, deserialize_with = "some_value")]
334 pub default: Option<serde_json::Value>,
335 #[serde(default)]
336 pub description: Option<String>,
337}
338
339/// Deserialize any present JSON value — *including* `null` — as
340/// `Some(value)`, so `"default": null` is distinguishable from an
341/// absent `default`.
342fn some_value<'de, D: serde::Deserializer<'de>>(
343 d: D,
344) -> Result<Option<serde_json::Value>, D::Error> {
345 serde_json::Value::deserialize(d).map(Some)
346}
347
348/// Port-kind vocabulary for function signatures. Mirrors the graph's
349/// `PortKind` names.
350#[derive(Debug, Deserialize, PartialEq, Eq, Clone, Copy)]
351#[serde(rename_all = "kebab-case")]
352pub enum FuncKind {
353 Features,
354 Raster,
355 Sprite,
356 Brush,
357 Scalar,
358 ScalarField,
359}
360
361impl FuncKind {
362 pub fn as_str(&self) -> &'static str {
363 match self {
364 FuncKind::Features => "features",
365 FuncKind::Raster => "raster",
366 FuncKind::Sprite => "sprite",
367 FuncKind::Brush => "brush",
368 FuncKind::Scalar => "scalar",
369 FuncKind::ScalarField => "scalar-field",
370 }
371 }
372}
373
374/// One node entry. `op` selects the implementation; remaining fields are
375/// op-specific and are validated by the `NodeFactory` registered for `op`.
376#[derive(Debug, Clone, Deserialize)]
377pub struct NodeSpec {
378 pub op: String,
379 /// All remaining fields. Scalars are literals (color, number, bool);
380 /// strings that begin with `@` are node references, strings that
381 /// begin with `$` are param references.
382 #[serde(flatten)]
383 pub fields: serde_json::Map<String, serde_json::Value>,
384}
385
386/// Declaration of a document-level parameter (overridable at render time).
387#[derive(Debug, Clone, Deserialize)]
388#[serde(deny_unknown_fields, rename_all = "kebab-case")]
389pub struct ParamDecl {
390 #[serde(rename = "type")]
391 pub kind: ParamKind,
392 pub default: serde_json::Value,
393 #[serde(default)]
394 pub min: Option<f64>,
395 #[serde(default)]
396 pub max: Option<f64>,
397 #[serde(default)]
398 pub description: Option<String>,
399}
400
401#[derive(Debug, Deserialize, PartialEq, Eq, Clone, Copy)]
402#[serde(rename_all = "kebab-case")]
403pub enum ParamKind {
404 Color,
405 Number,
406 Bool,
407}
408
409/// What a tile-pyramid source does when a tile request 404s within
410/// the source's zoom range. (Other HTTP failures are always errors;
411/// requests past `max-zoom` always upsample from the ancestor at
412/// `max-zoom`.)
413#[derive(Debug, Deserialize, PartialEq, Eq, Clone, Copy, Default)]
414#[serde(rename_all = "kebab-case")]
415pub enum OnMissing {
416 /// Treat the tile as empty: transparent pixels for `raster`,
417 /// zero elevation for `dem`. Missing *neighbour* tiles always
418 /// degrade this way (the stitch edge-clamps).
419 #[default]
420 Empty,
421 /// Walk up parent zooms until a tile exists and upsample the
422 /// covered sub-region; falls back to `empty` when nothing is
423 /// found all the way to z0.
424 Upsample,
425 /// Fail the whole tile render. Hosts surface it (the tile server
426 /// returns HTTP 404 for the rendered tile).
427 Error,
428}
429
430/// Declaration of one external data source. Mixes document-scoped
431/// resources (`brush`, `image`, `sprite`, `font`) — resolved once per
432/// style from a file
433/// path or `http(s)://` URL — and tile-scoped pyramids (`mvt`,
434/// `pmtiles`, `dem`) — fetched per tile via a URL template.
435///
436/// The legacy `mask-image` / `gradient` kinds from 0.2 are gone:
437/// `mask-image` was indistinguishable from `image` at runtime (host
438/// decoded both as RGBA8), so callers compose `image` →
439/// `pick-channel a` to get a single-channel mask; `gradient` was
440/// never wired up and the `gradient-*` node family covers that use
441/// case directly.
442///
443/// Binding conventions in the host's `TileLoader`:
444/// - Document-scoped: looked up by the source name (referenced in
445/// node fields as `@source-name`).
446/// - `dem` binds a stitched `ScalarField` under `tile.<source-name>`.
447/// - `mvt` and `pmtiles` bind every layer of the decoded vector tile
448/// under `tile.<layer-name>` (i.e. by the layer's name *inside* the
449/// tile, not the source key — built-in styles reference layers like
450/// `tile.water` / `tile.roads`).
451#[derive(Debug, Deserialize, Clone)]
452#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)]
453pub enum SourceDecl {
454 Brush(FileSource),
455 Image(FileSource),
456 Mvt(MvtSource),
457 Pmtiles(PmtilesSource),
458 Dem(DemSource),
459 Raster(RasterSource),
460 #[serde(rename = "geojson")]
461 GeoJson(GeoJsonSource),
462 Sprite(SpriteSource),
463 Font(FontSource),
464 Glyphs(GlyphsSource),
465}
466
467impl SourceDecl {
468 /// The source's declared attribution, if any.
469 pub fn attribution(&self) -> &Option<String> {
470 match self {
471 SourceDecl::Brush(s) | SourceDecl::Image(s) => &s.attribution,
472 SourceDecl::Mvt(s) => &s.attribution,
473 SourceDecl::Pmtiles(s) => &s.attribution,
474 SourceDecl::Dem(s) => &s.attribution,
475 SourceDecl::Raster(s) => &s.attribution,
476 SourceDecl::GeoJson(s) => &s.attribution,
477 SourceDecl::Sprite(s) => &s.attribution,
478 SourceDecl::Font(s) => &s.attribution,
479 SourceDecl::Glyphs(s) => &s.attribution,
480 }
481 }
482}
483
484/// A document-scoped font face (TTF / OTF / TTC bytes) consumed by the
485/// `text` node's `font` fallback stack. The `url` doubles as the font's
486/// asset key, like a sprite source's `image`.
487#[derive(Debug, Deserialize, Clone)]
488#[serde(deny_unknown_fields, rename_all = "kebab-case")]
489pub struct FontSource {
490 /// Font source `url`. Either a font *file* — `http(s)://`,
491 /// `file:PATH`, or a `data:` URL, like [`FileSource::src`] — or an
492 /// installed-font reference:
493 ///
494 /// - `system:<family>[?weight=<100..900>&style=<normal|italic|oblique>]`
495 /// resolves a face by family name from the host's installed fonts
496 /// (e.g. `system:Arial Unicode MS`, `system:Helvetica?weight=700`).
497 /// The family may be written with literal spaces or percent-encoded.
498 /// `weight` defaults to `400`, `style` to `normal`.
499 ///
500 /// A `system:` reference makes the recipe **machine-dependent**: the
501 /// same family resolves to whatever face that machine has installed,
502 /// so glyph shapes and character coverage can differ across
503 /// environments (and it is unavailable in the browser/wasm host,
504 /// where font bytes must be supplied directly). Reference a font file
505 /// for a fully portable, reproducible recipe.
506 pub url: String,
507 /// Face index within a TrueType collection (`.ttc`); 0 (the
508 /// default) for single-face files. Ignored for `system:` urls — the
509 /// installed-font database reports the matched face's own index.
510 #[serde(default)]
511 pub index: u32,
512 #[serde(default)]
513 pub attribution: Option<String>,
514}
515
516/// A MapLibre-compatible glyph endpoint: pre-rendered SDF glyphs served
517/// in 256-codepoint ranges from a URL template. The `text` node's
518/// `font` stack may name a `glyphs` source wherever it may name a
519/// `font` source — lower fidelity (fixed 24 px SDF bitmaps) but zero
520/// font files, matching what MapLibre GL itself renders from.
521#[derive(Debug, Deserialize, Clone)]
522#[serde(deny_unknown_fields, rename_all = "kebab-case")]
523pub struct GlyphsSource {
524 /// URL template containing `{fontstack}` and `{range}` placeholders
525 /// (the MapLibre `glyphs` shape, e.g.
526 /// `https://example.com/fonts/{fontstack}/{range}.pbf`), with
527 /// `http(s)://` or `file:` scheme. `{range}` stays in the resolved
528 /// asset key — ranges are fetched lazily per 256-codepoint block.
529 pub url: String,
530 /// The fontstack string requested from the endpoint. MapLibre joins
531 /// a `text-font` array with `", "`; fallback across the stack's
532 /// entries happens server-side.
533 pub fontstack: String,
534 #[serde(default)]
535 pub attribution: Option<String>,
536}
537
538impl GlyphsSource {
539 /// The asset key this source resolves to: the URL template with
540 /// `{fontstack}` substituted (percent-encoded, as MapLibre does)
541 /// and `{range}` left in place for per-range fetching. Hosts
542 /// register the source's glyph stack under this key.
543 pub fn asset_key(&self) -> String {
544 self.url
545 .replace("{fontstack}", &percent_encode(&self.fontstack))
546 }
547}
548
549/// `encodeURIComponent`-style percent-encoding (unreserved chars and
550/// the `!'()*-._~` marks pass through), for `{fontstack}` URL slots.
551fn percent_encode(s: &str) -> String {
552 let mut out = String::with_capacity(s.len());
553 for byte in s.bytes() {
554 match byte {
555 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' => out.push(byte as char),
556 b'!' | b'\'' | b'(' | b')' | b'*' | b'-' | b'.' | b'_' | b'~' => out.push(byte as char),
557 _ => out.push_str(&format!("%{byte:02X}")),
558 }
559 }
560 out
561}
562
563/// A sprite sheet: one atlas image plus a name → sub-rect index, so
564/// `icon` nodes can crop named icons out of it (icons, `fill-pattern`).
565/// The runtime shape is [`ezu_graph::SpriteSheet`]; the host builds it.
566#[derive(Debug, Deserialize, Clone)]
567#[serde(deny_unknown_fields, rename_all = "kebab-case")]
568pub struct SpriteSource {
569 /// Atlas image `src` — a path (resolved against `--assets-dir`) or an
570 /// `http(s)://` URL, like [`FileSource::src`]. This doubles as the
571 /// sheet's asset key, so `icon { sprite: "@name" }` resolves here.
572 pub image: String,
573 /// The name → rect index: either a URL/path to a sprite `.json`, or an
574 /// inline map. Inline uses the same field names as a fetched index.
575 pub index: SpriteIndex,
576 #[serde(default)]
577 pub attribution: Option<String>,
578}
579
580/// A sprite index, inline or by reference.
581#[derive(Debug, Deserialize, Clone)]
582#[serde(untagged)]
583pub enum SpriteIndex {
584 /// URL/path to a sprite index JSON (fetched/read by the host).
585 Url(String),
586 /// Inline `name → rect` map written straight into the recipe.
587 Inline(std::collections::HashMap<String, IconRect>),
588}
589
590/// One entry of a sprite index — a sub-rectangle of the atlas. Mirrors the
591/// MapLibre sprite-JSON entry shape (extra keys like `sdf` are ignored) so a
592/// fetched index deserializes into the same type.
593#[derive(Debug, Deserialize, Clone)]
594#[serde(rename_all = "camelCase")]
595pub struct IconRect {
596 pub x: u32,
597 pub y: u32,
598 pub width: u32,
599 pub height: u32,
600 #[serde(default = "one_f32")]
601 pub pixel_ratio: f32,
602 /// Nine-slice metadata: the `[from, to)` bands of image columns that may
603 /// stretch when `icon-text-fit` grows the icon. Absent = the whole image
604 /// scales.
605 #[serde(default)]
606 pub stretch_x: Vec<[u32; 2]>,
607 /// The stretchable bands of image rows, as [`Self::stretch_x`].
608 #[serde(default)]
609 pub stretch_y: Vec<[u32; 2]>,
610 /// The part of the image the label's text is fitted into, as
611 /// `[left, top, right, bottom]` image pixels. Absent = the whole image.
612 #[serde(default)]
613 pub content: Option<[u32; 4]>,
614}
615
616fn one_f32() -> f32 {
617 1.0
618}
619
620/// Inline (or URL) GeoJSON in WGS84 lon/lat. The host projects it into each
621/// tile's local coordinate frame and binds it under `<source>.<source>`.
622#[derive(Debug, Deserialize, Clone)]
623#[serde(deny_unknown_fields, rename_all = "kebab-case")]
624pub struct GeoJsonSource {
625 /// Inline GeoJSON (a `FeatureCollection`, `Feature`, or `Geometry`).
626 #[serde(default)]
627 pub data: Option<serde_json::Value>,
628 /// URL to a `.geojson` document (fetched by the host) — alternative to
629 /// inline `data`.
630 #[serde(default)]
631 pub url: Option<String>,
632 #[serde(default)]
633 pub attribution: Option<String>,
634}
635
636/// A document-scoped, file-based source. `src` is a path the host
637/// resolves (relative to `--assets-dir`) or an `http(s)://` URL.
638#[derive(Debug, Deserialize, Clone)]
639#[serde(deny_unknown_fields, rename_all = "kebab-case")]
640pub struct FileSource {
641 pub src: String,
642 #[serde(default)]
643 pub attribution: Option<String>,
644}
645
646/// Templated XYZ MVT tile source.
647#[derive(Debug, Deserialize, Clone)]
648#[serde(deny_unknown_fields, rename_all = "kebab-case")]
649pub struct MvtSource {
650 /// XYZ URL template with `{z}`, `{x}`, `{y}` placeholders, or a
651 /// TileJSON document URL (anything ending in `.json`).
652 pub url: String,
653 /// Explicit attribution. When absent, hosts inherit the upstream
654 /// TileJSON `attribution` field.
655 #[serde(default)]
656 pub attribution: Option<String>,
657}
658
659/// PMTiles archive source — local path or `http(s)://` URL.
660#[derive(Debug, Deserialize, Clone)]
661#[serde(deny_unknown_fields, rename_all = "kebab-case")]
662pub struct PmtilesSource {
663 pub url: String,
664 /// Explicit attribution. When absent, hosts inherit the
665 /// `attribution` key of the archive's metadata JSON.
666 #[serde(default)]
667 pub attribution: Option<String>,
668}
669
670/// Raster-DEM source. Tiles encode elevation in the RGB channels using
671/// either the Mapzen / Terrarium scheme
672/// (`h = (R*256 + G + B/256) - 32768`) or the Mapbox / MapLibre Terrain-RGB
673/// scheme (`h = -10000 + (R*65536 + G*256 + B) * 0.1`).
674#[derive(Debug, Deserialize, Clone)]
675#[serde(deny_unknown_fields, rename_all = "kebab-case")]
676pub struct DemSource {
677 /// XYZ URL template with `{z}`, `{x}`, `{y}` placeholders, or a
678 /// TileJSON document URL (anything ending in `.json`). PNG and
679 /// WebP tiles are both supported (sniffed from content).
680 pub url: String,
681 pub encoding: DemEncoding,
682 /// What a 404 within the zoom range means — empty (zero
683 /// elevation), upsample from a parent, or fail the render.
684 #[serde(default)]
685 pub on_missing: OnMissing,
686 /// Explicit attribution. When absent and `url` is a TileJSON,
687 /// hosts inherit its `attribution` field.
688 #[serde(default)]
689 pub attribution: Option<String>,
690 #[serde(default = "default_dem_tile_size")]
691 pub tile_size: u32,
692 /// Highest zoom available from the source. Requests above this zoom
693 /// overzoom from an ancestor tile.
694 #[serde(default)]
695 pub max_zoom: Option<u8>,
696 /// If true, fetch the 8 neighbouring tiles in addition to the
697 /// centre tile and stitch them so gradient-based ops (e.g.
698 /// `hillshade`) have seam-free samples in the pad region.
699 #[serde(default = "default_true")]
700 pub neighbor_fetch: bool,
701 /// Value subtracted from each decoded sample (metres). Useful for
702 /// rebasing geoid-relative datasets.
703 #[serde(default)]
704 pub elevation_offset: f32,
705}
706
707#[derive(Debug, Deserialize, PartialEq, Eq, Clone, Copy)]
708#[serde(rename_all = "kebab-case")]
709pub enum DemEncoding {
710 Terrarium,
711 MapboxRgb,
712}
713
714/// RGBA raster tile pyramid (satellite imagery, pre-rendered
715/// basemaps, …) consumed by the `raster` node as a canvas-sized
716/// `Raster`. The host fetches the 3×3 neighbourhood per render and
717/// stitches it onto the padded canvas, so downstream filters see
718/// seamless pixels across tile borders.
719#[derive(Debug, Deserialize, Clone)]
720#[serde(deny_unknown_fields, rename_all = "kebab-case")]
721pub struct RasterSource {
722 /// XYZ URL template with `{z}`, `{x}`, `{y}` placeholders, a
723 /// TileJSON document URL (anything ending in `.json`), or a
724 /// PMTiles archive (anything ending in `.pmtiles`; local path or
725 /// `http(s)://` URL). PNG / WebP / JPEG tiles are sniffed from
726 /// content.
727 pub url: String,
728 /// Highest zoom available from the source. Requests above this
729 /// zoom upsample from the ancestor at `max-zoom`.
730 #[serde(default)]
731 pub max_zoom: Option<u8>,
732 /// Fetch the 8 neighbouring tiles and stitch them so the pad
733 /// region has real pixels.
734 #[serde(default = "default_true")]
735 pub neighbor_fetch: bool,
736 /// What a 404 within the zoom range means — transparent pixels,
737 /// upsample from a parent, or fail the render.
738 #[serde(default)]
739 pub on_missing: OnMissing,
740 /// Explicit attribution. When absent, hosts inherit upstream
741 /// metadata (TileJSON `attribution` / PMTiles metadata).
742 #[serde(default)]
743 pub attribution: Option<String>,
744}
745
746fn default_dem_tile_size() -> u32 {
747 256
748}
749fn default_true() -> bool {
750 true
751}
752
753/// A reference to a node id, optionally prefixed with `@`. The prefix is
754/// stripped on parse.
755#[derive(Debug, Clone, PartialEq, Eq)]
756pub struct NodeRef(pub String);
757
758impl NodeRef {
759 pub fn as_str(&self) -> &str {
760 &self.0
761 }
762}
763
764impl<'de> Deserialize<'de> for NodeRef {
765 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
766 let s = String::deserialize(d)?;
767 Ok(NodeRef(s.strip_prefix('@').unwrap_or(&s).to_string()))
768 }
769}
770
771impl Serialize for NodeRef {
772 /// Emits the bare node id. The `@` is style-authoring syntax; a
773 /// consumer reading a serialized reference wants the id itself.
774 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
775 s.serialize_str(&self.0)
776 }
777}
778
779impl NodeSpec {
780 /// Every `@name` this node's fields mention, in traversal order and
781 /// with duplicates kept.
782 ///
783 /// The scan is over the raw JSON rather than a per-op field list,
784 /// because that is what the wiring is: any string field may carry a
785 /// reference, at any depth, including inside an expression array. A
786 /// name here has not been resolved — it may be a node, a source, or
787 /// nothing at all.
788 pub fn refs(&self) -> Vec<String> {
789 let mut out = Vec::new();
790 for v in self.fields.values() {
791 collect_refs(v, &mut out);
792 }
793 out
794 }
795}
796
797/// Recursively scan a JSON value for `@name` strings.
798fn collect_refs(v: &serde_json::Value, out: &mut Vec<String>) {
799 match v {
800 serde_json::Value::String(s) => {
801 if let Some(rest) = s.strip_prefix('@') {
802 out.push(rest.to_string());
803 }
804 }
805 serde_json::Value::Array(a) => a.iter().for_each(|x| collect_refs(x, out)),
806 serde_json::Value::Object(m) => m.values().for_each(|x| collect_refs(x, out)),
807 _ => {}
808 }
809}
810
811/// Classify a string field on a node: a node reference, a param
812/// reference, or a literal string. The classification is by prefix:
813///
814/// - `@name` → [`FieldRef::Node`]
815/// - `$name` → [`FieldRef::Param`]
816/// - anything else → [`FieldRef::Literal`]
817pub enum FieldRef<'a> {
818 Node(&'a str),
819 Param(&'a str),
820 Literal(&'a str),
821}
822
823impl<'a> FieldRef<'a> {
824 pub fn classify(s: &'a str) -> Self {
825 if let Some(rest) = s.strip_prefix('@') {
826 FieldRef::Node(rest)
827 } else if let Some(rest) = s.strip_prefix('$') {
828 FieldRef::Param(rest)
829 } else {
830 FieldRef::Literal(s)
831 }
832 }
833}
834
835#[cfg(test)]
836mod tests {
837 use super::*;
838
839 #[test]
840 fn parses_minimal_document() {
841 let json = r##"{
842 "name": "demo",
843 "nodes": {
844 "src": { "op": "image", "src": "assets/bg.png" },
845 "blur": { "op": "blur", "input": "@src", "sigma": 3 }
846 },
847 "output": "@blur"
848 }"##;
849 let doc = Document::from_json(json).unwrap();
850 assert_eq!(doc.name, "demo");
851 assert_eq!(doc.nodes.len(), 2);
852 assert_eq!(doc.output.as_str(), "blur");
853 assert_eq!(doc.nodes["blur"].op, "blur");
854 assert_eq!(doc.nodes["blur"].fields["input"], "@src");
855 }
856
857 #[test]
858 fn parses_a_commented_document() {
859 // Comments in the places an author actually wants them: above a
860 // block, at the end of a line, and inside an expression array.
861 let json = r##"{
862 // What this style is for.
863 "name": "demo",
864 "nodes": {
865 "src": { "op": "image", "src": "assets/bg.png" },
866 /* Three was as far as we could go before the coastline
867 dissolved. */
868 "blur": { "op": "blur", "input": "@src", "sigma": 3 }, // keep
869 "fade": { "op": "expr", "expr": ["interpolate", ["linear"], ["zoom"],
870 13, 1, // fully on in town
871 15, 0] }
872 },
873 "output": "@blur"
874 }"##;
875 let doc = Document::from_json(json).unwrap();
876 assert_eq!(doc.name, "demo");
877 assert_eq!(doc.nodes.len(), 3);
878 assert_eq!(doc.nodes["blur"].fields["sigma"], 3);
879 // The comment inside the array did not disturb it.
880 assert_eq!(
881 doc.nodes["fade"].fields["expr"].as_array().unwrap().len(),
882 7
883 );
884 }
885
886 /// Comments are blanked rather than removed, so a parse error still
887 /// names the line it is on in the author's file.
888 #[test]
889 fn an_error_after_a_comment_keeps_its_line_number() {
890 let json = "{\n // a note\n /* and\n another */\n \"name\": oops\n}";
891 let err = Document::from_json(json).unwrap_err();
892 let StyleError::Parse(e) = err else {
893 panic!("expected a JSON parse error");
894 };
895 assert_eq!(e.line(), 5, "{e}");
896 }
897
898 #[test]
899 fn subgraph_keeps_the_target_and_its_ancestors() {
900 let json = r##"{
901 "name": "demo",
902 "legend": { "entries": [{ "label": "x", "from": "@out" }] },
903 "nodes": {
904 "bg": { "op": "solid", "color": "#ffffff" },
905 "src": { "op": "image", "src": "x.png" },
906 "blur": { "op": "blur", "input": "@src", "sigma": 3 },
907 "other": { "op": "image", "src": "unrelated.png" },
908 "out": { "op": "blend", "base": "@bg", "over": "@blur" }
909 },
910 "output": "@out"
911 }"##;
912 let doc = Document::from_json(json).unwrap();
913
914 let sub = doc.subgraph("blur").unwrap();
915 assert_eq!(sub.output.as_str(), "blur");
916 let ids: Vec<&str> = sub.nodes.keys().map(String::as_str).collect();
917 assert_eq!(ids, ["src", "blur"], "kept in declaration order");
918 // The legend cannot come along: its entry names a node this
919 // document no longer has.
920 assert!(sub.legend.is_none());
921
922 // The whole chain, minus the branch nothing reaches.
923 let sub = doc.subgraph("out").unwrap();
924 let ids: Vec<&str> = sub.nodes.keys().map(String::as_str).collect();
925 assert_eq!(ids, ["bg", "src", "blur", "out"]);
926
927 assert!(doc.subgraph("nope").is_none());
928 }
929
930 #[test]
931 fn subgraph_survives_a_reference_inside_an_expression() {
932 let json = r##"{
933 "name": "demo",
934 "nodes": {
935 "fade": { "op": "expr", "expr": ["interpolate", ["linear"], ["zoom"], 13, 1, 15, 0] },
936 "src": { "op": "image", "src": "x.png" },
937 "out": { "op": "blur", "input": "@src", "sigma": 1, "opacity": "@fade" }
938 },
939 "output": "@out"
940 }"##;
941 let doc = Document::from_json(json).unwrap();
942 let sub = doc.subgraph("out").unwrap();
943 let mut ids: Vec<&str> = sub.nodes.keys().map(String::as_str).collect();
944 ids.sort_unstable();
945 assert_eq!(ids, ["fade", "out", "src"]);
946 }
947
948 #[test]
949 fn node_refs_reads_nested_fields() {
950 let json = r##"{
951 "name": "demo",
952 "nodes": {
953 "out": { "op": "stack", "layers": ["@a", "@b"], "mask": "@c",
954 "curve": [["@d", 1]], "literal": "plain", "param": "$k" }
955 },
956 "output": "@out"
957 }"##;
958 let doc = Document::from_json(json).unwrap();
959 let mut refs = doc.nodes["out"].refs();
960 refs.sort_unstable();
961 assert_eq!(refs, ["a", "b", "c", "d"]);
962 }
963
964 #[test]
965 fn parses_output_without_at_prefix() {
966 let json = r##"{
967 "name": "demo",
968 "nodes": { "a": { "op": "image", "src": "x.png" } },
969 "output": "a"
970 }"##;
971 let doc = Document::from_json(json).unwrap();
972 assert_eq!(doc.output.as_str(), "a");
973 }
974
975 #[test]
976 fn parses_params_and_sources() {
977 let json = r##"{
978 "name": "demo",
979 "params": {
980 "ink": { "type": "color", "default": "#000000" },
981 "k": { "type": "number", "default": 0.5, "min": 0, "max": 1 }
982 },
983 "sources": {
984 "brush": { "type": "brush", "src": "assets/wet.myb" }
985 },
986 "nodes": { "out": { "op": "solid", "color": "$ink" } },
987 "output": "@out"
988 }"##;
989 let doc = Document::from_json(json).unwrap();
990 assert_eq!(doc.params["k"].kind, ParamKind::Number);
991 assert!(matches!(doc.sources["brush"], SourceDecl::Brush(_)));
992 assert_eq!(doc.params["k"].max, Some(1.0));
993 }
994
995 #[test]
996 fn params_schema_reflects_declarations() {
997 let json = r##"{
998 "name": "demo",
999 "params": {
1000 "ink": { "type": "color", "default": "#000000", "description": "Line color" },
1001 "k": { "type": "number", "default": 0.5, "min": 0, "max": 1 },
1002 "on": { "type": "bool", "default": true }
1003 },
1004 "nodes": { "out": { "op": "solid", "color": "$ink" } },
1005 "output": "@out"
1006 }"##;
1007 let doc = Document::from_json(json).unwrap();
1008 let schema = doc.params_schema();
1009 let props = &schema["properties"];
1010 assert_eq!(props["ink"]["type"], "string");
1011 assert_eq!(props["ink"]["default"], "#000000");
1012 assert_eq!(props["ink"]["description"], "Line color");
1013 assert_eq!(props["k"]["type"], "number");
1014 assert_eq!(props["k"]["minimum"], 0.0);
1015 assert_eq!(props["k"]["maximum"], 1.0);
1016 assert_eq!(props["on"]["type"], "boolean");
1017 assert_eq!(schema["additionalProperties"], false);
1018 }
1019
1020 #[test]
1021 fn parses_raster_source_and_attributions() {
1022 let json = r##"{
1023 "name": "demo",
1024 "attribution": "Style © Demo",
1025 "sources": {
1026 "photo": { "type": "raster",
1027 "url": "https://example.com/{z}/{x}/{y}.jpg",
1028 "max-zoom": 18, "on-missing": "upsample",
1029 "attribution": "© Example Sat" },
1030 "archive": { "type": "raster", "url": "tiles.pmtiles" },
1031 "basemap": { "type": "mvt", "url": "https://example.com/t.json",
1032 "attribution": "Style © Demo" }
1033 },
1034 "nodes": { "out": { "op": "raster", "source": "photo" } },
1035 "output": "@out"
1036 }"##;
1037 let doc = Document::from_json(json).unwrap();
1038 let SourceDecl::Raster(r) = &doc.sources["photo"] else {
1039 panic!("expected raster source");
1040 };
1041 assert_eq!(r.max_zoom, Some(18));
1042 assert_eq!(r.on_missing, OnMissing::Upsample);
1043 assert!(r.neighbor_fetch);
1044 let SourceDecl::Raster(r) = &doc.sources["archive"] else {
1045 panic!("expected raster source");
1046 };
1047 assert_eq!(r.on_missing, OnMissing::Empty);
1048 // Dedup: the doc attribution and basemap's identical one merge.
1049 assert_eq!(doc.attributions(), ["Style © Demo", "© Example Sat"]);
1050 }
1051
1052 #[test]
1053 fn parses_font_source() {
1054 let json = r##"{
1055 "name": "demo",
1056 "sources": {
1057 "body": { "type": "font", "url": "https://example.com/NotoSans-Regular.ttf" },
1058 "cjk": { "type": "font", "url": "file:fonts/collection.ttc", "index": 2,
1059 "attribution": "© Font Foundry" }
1060 },
1061 "nodes": { "out": { "op": "solid", "color": "#000000" } },
1062 "output": "@out"
1063 }"##;
1064 let doc = Document::from_json(json).unwrap();
1065 let SourceDecl::Font(f) = &doc.sources["body"] else {
1066 panic!("expected font source");
1067 };
1068 assert_eq!(f.url, "https://example.com/NotoSans-Regular.ttf");
1069 assert_eq!(f.index, 0);
1070 assert!(f.attribution.is_none());
1071 let SourceDecl::Font(f) = &doc.sources["cjk"] else {
1072 panic!("expected font source");
1073 };
1074 assert_eq!(f.index, 2);
1075 assert_eq!(doc.attributions(), ["© Font Foundry"]);
1076 }
1077
1078 #[test]
1079 fn parses_glyphs_source() {
1080 let json = r##"{
1081 "name": "demo",
1082 "sources": {
1083 "labels": { "type": "glyphs",
1084 "url": "https://example.com/fonts/{fontstack}/{range}.pbf",
1085 "fontstack": "Noto Sans Regular, Arial Unicode MS Regular",
1086 "attribution": "© Glyph Server" }
1087 },
1088 "nodes": { "out": { "op": "solid", "color": "#000000" } },
1089 "output": "@out"
1090 }"##;
1091 let doc = Document::from_json(json).unwrap();
1092 let SourceDecl::Glyphs(g) = &doc.sources["labels"] else {
1093 panic!("expected glyphs source");
1094 };
1095 assert_eq!(g.fontstack, "Noto Sans Regular, Arial Unicode MS Regular");
1096 // `{fontstack}` is substituted percent-encoded; `{range}` stays.
1097 assert_eq!(
1098 g.asset_key(),
1099 "https://example.com/fonts/Noto%20Sans%20Regular%2C%20Arial%20Unicode%20MS%20Regular/{range}.pbf"
1100 );
1101 assert_eq!(doc.attributions(), ["© Glyph Server"]);
1102 }
1103
1104 #[test]
1105 fn rejects_unknown_top_level_field() {
1106 let json = r##"{
1107 "name": "demo",
1108 "nodes": {},
1109 "output": "@x",
1110 "junk": 1
1111 }"##;
1112 assert!(Document::from_json(json).is_err());
1113 }
1114
1115 #[test]
1116 fn classify_field_refs() {
1117 assert!(matches!(FieldRef::classify("@foo"), FieldRef::Node("foo")));
1118 assert!(matches!(FieldRef::classify("$bar"), FieldRef::Param("bar")));
1119 assert!(matches!(
1120 FieldRef::classify("plain"),
1121 FieldRef::Literal("plain")
1122 ));
1123 }
1124}