Skip to main content

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}