mod frame_features;
mod frame_pages;
mod graph_view;
mod render;
mod serve;
pub(crate) mod source;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use clap::{Args, Parser, Subcommand, ValueEnum};
use ezu::core::frame::{Frame, FrameRequest};
use ezu::core::TileId as CoreTileId;
use ezu::graph::{build_graph, Cache, CanvasInfo, Graph, ParamValues};
use ezu::paint::host::{pixmap_to_webp, BrushBankLoader};
use ezu::paint::nodes::default_registry;
use ezu::renderer::{FrameFeatureCollector, Renderer};
use ezu::style::{Document, SourceDecl};
use futures::stream::{StreamExt, TryStreamExt};
use tiny_skia::{ColorU8, Pixmap};
use tracing_subscriber::EnvFilter;
use crate::render::TileRenderer;
use crate::source::{FeatureSources, SourceSpec, TileByteCache};
#[derive(Parser, Debug)]
#[command(name = "ezu", version, about = "Render Ezu Style documents to PNG")]
struct Cli {
#[arg(long, short = 'v', global = true)]
verbose: bool,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand, Debug)]
enum Cmd {
Tile(TileCmd),
Bbox(BboxCmd),
Frame(FrameCmd),
Tiles(TilesCmd),
Check(CheckCmd),
Graph(GraphCmd),
Legend(LegendCmd),
Translate(TranslateCmd),
Serve(serve::ServeCmd),
Schema(SchemaCmd),
}
#[derive(Args, Debug)]
struct SchemaCmd {
#[arg(long)]
out: Option<PathBuf>,
}
#[derive(Args, Debug)]
struct CommonArgs {
#[arg(long)]
style: String,
#[arg(long)]
assets_dir: Option<PathBuf>,
#[arg(long, conflicts_with = "mvt")]
pmtiles: Option<String>,
#[arg(long, conflicts_with = "pmtiles")]
mvt: Option<String>,
#[arg(long, default_value_t = 4)]
overzoom_levels: u8,
#[arg(long = "param", value_name = "NAME=VALUE")]
params: Vec<String>,
#[arg(long)]
strict: bool,
#[arg(long)]
pad: Option<u32>,
}
#[derive(Args, Debug, Clone, Copy, Default)]
struct TileSizeArgs {
#[arg(long, value_parser = clap::value_parser!(u32).range(1..))]
tile_size: Option<u32>,
#[arg(long, value_parser = parse_pixel_ratio)]
pixel_ratio: Option<f32>,
}
impl TileSizeArgs {
fn pixel_ratio(&self) -> f32 {
self.pixel_ratio.unwrap_or(1.0)
}
}
fn parse_pixel_ratio(s: &str) -> Result<f32, String> {
let max = ezu::renderer::MAX_PIXEL_RATIO;
s.parse::<f32>()
.ok()
.filter(|r| (1.0..=max).contains(r))
.ok_or_else(|| format!("expected a number from 1 to {max}, got `{s}`"))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
enum OutputFormat {
Png,
Webp,
Rgba,
}
impl OutputFormat {
fn extension(self) -> &'static str {
match self {
OutputFormat::Png => "png",
OutputFormat::Webp => "webp",
OutputFormat::Rgba => "rgba",
}
}
fn with(
self,
opts: ezu::renderer::RenderOptions,
png: PngLevel,
) -> ezu::renderer::RenderOptions {
ezu::renderer::RenderOptions {
format: match self {
OutputFormat::Png => ezu::renderer::OutputFormat::Png,
OutputFormat::Webp => ezu::renderer::OutputFormat::Webp,
OutputFormat::Rgba => ezu::renderer::OutputFormat::Rgba,
},
png_compression: png.into(),
..opts
}
}
fn encode(self, image: &Pixmap, png: PngLevel) -> Result<Vec<u8>, String> {
use ezu::paint::host::{pixmap_to_png, pixmap_to_rgba8};
match self {
OutputFormat::Png => pixmap_to_png(image, png.into()).map_err(|e| e.to_string()),
OutputFormat::Webp => pixmap_to_webp(image).map_err(|e| e.to_string()),
OutputFormat::Rgba => Ok(pixmap_to_rgba8(image)),
}
}
fn from_path(path: &Path) -> Self {
match path.extension().and_then(|s| s.to_str()) {
Some(s) if s.eq_ignore_ascii_case("webp") => OutputFormat::Webp,
Some(s) if s.eq_ignore_ascii_case("rgba") => OutputFormat::Rgba,
_ => OutputFormat::Png,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)]
enum PngLevel {
Fast,
#[default]
Default,
Best,
}
impl From<PngLevel> for ezu::paint::host::PngCompression {
fn from(level: PngLevel) -> Self {
match level {
PngLevel::Fast => Self::Fast,
PngLevel::Default => Self::Default,
PngLevel::Best => Self::Best,
}
}
}
#[derive(Args, Debug, Clone, Copy, Default)]
pub(crate) struct PngArgs {
#[arg(long, value_enum, default_value_t = PngLevel::Default)]
png_compression: PngLevel,
}
impl PngArgs {
pub(crate) fn compression(self) -> ezu::paint::host::PngCompression {
self.png_compression.into()
}
}
#[derive(Args, Debug)]
struct CheckCmd {
style: String,
#[arg(long)]
assets_dir: Option<PathBuf>,
#[arg(long)]
no_fetch: bool,
#[arg(long)]
json: bool,
#[arg(long)]
strict: bool,
}
#[derive(Args, Debug)]
struct GraphCmd {
style: String,
#[arg(long)]
out: Option<PathBuf>,
#[arg(long, value_parser = parse_zxy)]
tile: Option<CoreTileId>,
#[arg(long)]
assets_dir: Option<PathBuf>,
#[arg(long, conflicts_with = "mvt")]
pmtiles: Option<String>,
#[arg(long, conflicts_with = "pmtiles")]
mvt: Option<String>,
#[arg(long, default_value_t = 4)]
overzoom_levels: u8,
#[arg(long = "param", value_name = "NAME=VALUE")]
params: Vec<String>,
#[arg(long, default_value_t = 256)]
image_size: u32,
#[arg(long)]
sidecar: bool,
#[arg(long, value_enum, default_value_t = PngLevel::Fast)]
png_compression: PngLevel,
}
#[derive(Args, Debug)]
struct LegendCmd {
style: String,
#[arg(long)]
zoom: Option<u8>,
#[arg(long)]
out: Option<PathBuf>,
#[arg(long)]
pretty: bool,
#[arg(long)]
swatch_dir: Option<PathBuf>,
#[arg(long, default_value = "48x32", value_parser = parse_wxh)]
swatch_size: (u32, u32),
#[arg(long)]
assets_dir: Option<PathBuf>,
#[command(flatten)]
png: PngArgs,
}
fn parse_wxh(s: &str) -> Result<(u32, u32), String> {
let parse = |v: &str| {
v.trim()
.parse::<u32>()
.map_err(|_| format!("`{s}`: expected WIDTHxHEIGHT in whole pixels"))
.and_then(|n| (n > 0).then_some(n).ok_or_else(|| format!("`{s}`: zero")))
};
match s.split_once(['x', 'X']) {
Some((w, h)) => Ok((parse(w)?, parse(h)?)),
None => {
let n = parse(s)?;
Ok((n, n))
}
}
}
#[derive(Args, Debug)]
struct TranslateCmd {
style: String,
#[arg(long)]
out: Option<PathBuf>,
#[arg(long, default_value_t = 512)]
tile_size: u32,
#[arg(long, default_value_t = 16)]
pad: u32,
#[arg(long)]
keep_hidden: bool,
#[arg(long)]
migrate: bool,
#[arg(long = "font", value_name = "NAME=SOURCE")]
fonts: Vec<String>,
#[arg(long)]
pretty: bool,
}
#[derive(Args, Debug)]
struct TileCmd {
#[command(flatten)]
common: CommonArgs,
#[command(flatten)]
size: TileSizeArgs,
#[arg(long, value_parser = parse_zxy)]
tile: CoreTileId,
#[arg(long, default_value = "out.png")]
out: PathBuf,
#[arg(long, value_enum)]
format: Option<OutputFormat>,
#[command(flatten)]
png: PngArgs,
#[arg(long = "features", value_name = "NODE=PATH", value_parser = frame_features::parse_node_path)]
features: Vec<(String, PathBuf)>,
#[arg(long, value_enum, default_value_t = frame_features::TileCoords::Tile)]
features_coords: frame_features::TileCoords,
}
#[derive(Args, Debug)]
struct BboxCmd {
#[command(flatten)]
common: CommonArgs,
#[command(flatten)]
size: TileSizeArgs,
#[arg(long, value_parser = parse_bbox)]
bbox: BBox,
#[arg(long)]
zoom: u8,
#[arg(long, default_value = "out.png")]
out: PathBuf,
#[arg(long, value_enum)]
format: Option<OutputFormat>,
#[command(flatten)]
png: PngArgs,
#[arg(long)]
concurrency: Option<usize>,
#[arg(long = "features", value_name = "NODE=PATH", value_parser = frame_features::parse_node_path)]
features: Vec<(String, PathBuf)>,
#[arg(long, value_name = "NODE=PROP[,PROP...]", value_parser = frame_features::parse_node_key)]
features_key: Vec<(String, Vec<String>)>,
#[arg(long, value_enum, default_value_t = frame_features::FeatureCoords::Lonlat)]
features_coords: frame_features::FeatureCoords,
}
#[derive(Args, Debug)]
struct FrameCmd {
#[command(flatten)]
common: CommonArgs,
#[arg(long, value_name = "PATH", conflicts_with_all = [
"center", "scale", "dpi", "size_mm", "size_px", "zoom", "out", "format",
"features", "features_key", "features_coords",
])]
pages: Option<PathBuf>,
#[arg(long, value_parser = parse_lnglat, required_unless_present = "pages")]
center: Option<(f64, f64)>,
#[arg(long, required_unless_present = "pages")]
scale: Option<f64>,
#[arg(long, required_unless_present = "pages")]
dpi: Option<f64>,
#[arg(long, value_parser = parse_size_mm, required_unless_present_any = ["size_px", "pages"], conflicts_with = "size_px")]
size_mm: Option<(f64, f64)>,
#[arg(long, value_parser = parse_wxh)]
size_px: Option<(u32, u32)>,
#[arg(long)]
zoom: Option<u8>,
#[arg(long, default_value = "out.png")]
out: PathBuf,
#[arg(long, value_enum)]
format: Option<OutputFormat>,
#[command(flatten)]
png: PngArgs,
#[arg(long)]
concurrency: Option<usize>,
#[arg(long = "features", value_name = "NODE=PATH", value_parser = frame_features::parse_node_path)]
features: Vec<(String, PathBuf)>,
#[arg(long, value_name = "NODE=PROP[,PROP...]", value_parser = frame_features::parse_node_key)]
features_key: Vec<(String, Vec<String>)>,
#[arg(long, value_enum, default_value_t = frame_features::FeatureCoords::Lonlat)]
features_coords: frame_features::FeatureCoords,
}
#[derive(Args, Debug)]
struct TilesCmd {
#[command(flatten)]
common: CommonArgs,
#[command(flatten)]
size: TileSizeArgs,
#[arg(long, value_parser = parse_bbox)]
bbox: Option<BBox>,
#[arg(long)]
min_zoom: u8,
#[arg(long)]
max_zoom: u8,
#[arg(long, default_value = "tiles")]
out: PathBuf,
#[arg(long, value_enum, default_value_t = OutputFormat::Png)]
format: OutputFormat,
#[command(flatten)]
png: PngArgs,
#[arg(long)]
concurrency: Option<usize>,
}
fn default_concurrency() -> usize {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(4)
}
#[derive(Clone, Copy, Debug)]
struct BBox {
min_lng: f64,
min_lat: f64,
max_lng: f64,
max_lat: f64,
}
#[cfg(feature = "heap-profile")]
#[global_allocator]
static ALLOC: dhat::Alloc = dhat::Alloc;
#[cfg(all(target_env = "musl", not(feature = "heap-profile")))]
#[global_allocator]
static ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc;
#[tokio::main]
async fn main() {
#[cfg(feature = "heap-profile")]
let _dhat = dhat::Profiler::new_heap();
if let Err(e) = run().await {
let top = e.to_string();
eprintln!("error: {top}");
let mut shown = top;
let mut src = e.source();
while let Some(cause) = src {
let text = cause.to_string();
if !shown.contains(&text) {
eprintln!(" caused by: {text}");
}
shown = text;
src = cause.source();
}
std::process::exit(1);
}
}
async fn run() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
let filter = if cli.verbose {
EnvFilter::new("info,ezu_graph::eval=debug")
} else {
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"))
};
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_writer(std::io::stderr)
.init();
match cli.cmd {
Cmd::Tile(args) => run_tile(args).await,
Cmd::Bbox(args) => run_bbox(args).await,
Cmd::Frame(args) => run_frame(args).await,
Cmd::Tiles(args) => run_tiles(args).await,
Cmd::Check(args) => run_check(args).await,
Cmd::Graph(args) => run_graph(args).await,
Cmd::Legend(args) => run_legend(args).await,
Cmd::Translate(args) => run_translate(args).await,
Cmd::Serve(args) => serve::run(args).await,
Cmd::Schema(args) => run_schema(args),
}
}
async fn open(
common: &CommonArgs,
size: TileSizeArgs,
) -> Result<Opened, Box<dyn std::error::Error>> {
let style_text = fetch_text(&common.style).await?;
let doc = Document::from_json(&style_text)?;
let byte_cache = Arc::new(TileByteCache::new(tile_cache_budget_bytes()));
open_style(common, size, &style_text, &doc, &byte_cache).await
}
struct Opened {
tiles: TileRenderer,
}
impl Opened {
fn graph(&self) -> &Graph {
self.tiles.renderer().graph()
}
}
async fn open_style(
common: &CommonArgs,
size: TileSizeArgs,
style_text: &str,
doc: &Document,
byte_cache: &Arc<TileByteCache>,
) -> Result<Opened, Box<dyn std::error::Error>> {
match size.tile_size {
Some(t) => tracing::info!(
"style: {} v{} ({} nodes, tile={}, rendering at {t})",
doc.name,
doc.version,
doc.nodes.len(),
doc.tile_size,
),
None => tracing::info!(
"style: {} v{} ({} nodes, tile={})",
doc.name,
doc.version,
doc.nodes.len(),
doc.tile_size,
),
}
let assets_dir = assets_dir_for(&common.style, common.assets_dir.as_deref());
let renderer = Renderer::new(style_text)?;
let graph = renderer.graph();
report_pad(graph, doc, common.pad);
let pad = common.pad.unwrap_or_else(|| canvas_pad(graph, doc));
let warnings = graph.warnings().to_vec();
parse_cli_params(&common.params, doc)?;
let cli_override = match (&common.pmtiles, &common.mvt) {
(Some(p), None) => Some((SourceSpec::PmTiles(p.clone()), "--pmtiles flag")),
(None, Some(u)) => Some((SourceSpec::Mvt(u.clone()), "--mvt flag")),
(None, None) => None,
_ => return Err("--pmtiles and --mvt are mutually exclusive".into()),
};
let vector = FeatureSources::open(doc, cli_override, &assets_dir)
.await?
.with_byte_cache(byte_cache, false);
let tiles = TileRenderer::open(render::Open {
renderer,
doc,
pad,
tile_size: size.tile_size,
pixel_ratio: size.pixel_ratio(),
assets_dir: &assets_dir,
vector: Arc::new(vector),
overzoom_levels: common.overzoom_levels,
params: common
.params
.iter()
.filter_map(|p| p.split_once('='))
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
byte_cache: Some((byte_cache, false)),
decoded_budget: DECODED_CACHE_BUDGET,
cache_budget: cache_budget_bytes(),
})
.await?;
strict_verdict(common.strict, &warnings)?;
tracing::info!("assets resolved against {}", assets_dir.display());
Ok(Opened { tiles })
}
const DECODED_CACHE_BUDGET: usize = 32 * 1024 * 1024;
fn assets_dir_for(style: &str, flag: Option<&Path>) -> PathBuf {
match flag {
Some(dir) => dir.to_path_buf(),
None if is_url(style) => PathBuf::from("."),
None => Path::new(style)
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from(".")),
}
}
fn blit_rgba_into(dst: &mut Pixmap, rgba: &[u8], dx: i64, dy: i64, tile_size: u32) {
let t = tile_size as i64;
let (dw, dh) = (dst.width() as i64, dst.height() as i64);
let (x0, x1) = (dx.max(0), (dx + t).min(dw));
let (y0, y1) = (dy.max(0), (dy + t).min(dh));
if x0 >= x1 || y0 >= y1 {
return;
}
let pixels = dst.pixels_mut();
for y in y0..y1 {
let src_row = ((y - dy) * t) as usize;
let dst_row = (y * dw) as usize;
for x in x0..x1 {
let s = (src_row + (x - dx) as usize) * 4;
pixels[dst_row + x as usize] =
ColorU8::from_rgba(rgba[s], rgba[s + 1], rgba[s + 2], rgba[s + 3]).premultiply();
}
}
}
fn parse_cli_params(
flags: &[String],
doc: &Document,
) -> Result<ParamValues, Box<dyn std::error::Error>> {
let mut values = ParamValues::new();
for flag in flags {
let (name, raw) = flag
.split_once('=')
.ok_or_else(|| format!("--param `{flag}`: expected `name=value`"))?;
let v = ezu::graph::parse_param_value(&doc.params, name, raw)?;
values.set(name.to_string(), v);
}
Ok(values)
}
fn run_schema(args: SchemaCmd) -> Result<(), Box<dyn std::error::Error>> {
let schema = default_registry().document_schema();
let mut text = serde_json::to_string_pretty(&schema)?;
text.push('\n');
match args.out {
Some(path) => std::fs::write(&path, text)?,
None => print!("{text}"),
}
Ok(())
}
pub(crate) fn canvas_pad(graph: &Graph, doc: &Document) -> u32 {
match graph.required_pad() {
Ok(needed) => doc.pad.max(needed),
Err(_) => doc.pad,
}
}
pub(crate) fn report_pad(graph: &Graph, doc: &Document, flag: Option<u32>) {
let needed = match graph.required_pad() {
Ok(needed) => needed,
Err(e) => {
tracing::warn!("pad: {e}");
return;
}
};
if let Some(pad) = flag {
if pad < needed {
tracing::warn!(
"pad: {pad} from --pad, {needed} needed — tile edges will seam where filters reach past {pad}"
);
} else {
tracing::info!("pad: {pad} from --pad, {needed} needed");
}
return;
}
if needed > doc.pad {
tracing::info!(
"pad: {} declared, {needed} needed — rendering with {needed}",
doc.pad,
);
} else {
tracing::info!("pad: {} declared, {needed} needed", doc.pad);
}
}
#[derive(serde::Serialize)]
struct CheckReport<'a> {
name: &'a str,
version: &'a str,
nodes: usize,
sources: usize,
pad: PadReport,
#[serde(skip_serializing_if = "Vec::is_empty")]
attribution: Vec<&'a str>,
params: serde_json::Value,
assets_resolved: bool,
#[serde(skip_serializing_if = "Vec::is_empty")]
warnings: Vec<String>,
}
#[derive(serde::Serialize)]
struct PadReport {
declared: u32,
#[serde(skip_serializing_if = "Option::is_none")]
needed: Option<u32>,
}
async fn run_check(args: CheckCmd) -> Result<(), Box<dyn std::error::Error>> {
let text = fetch_text(&args.style).await?;
let doc = Document::from_json(&text)?;
let registry = default_registry();
let graph = build_graph(&doc, ®istry)?;
let mut warnings = Vec::new();
let needed = match graph.required_pad() {
Ok(needed) => Some(needed),
Err(e) => {
warnings.push(format!("pad: {e}"));
None
}
};
warnings.extend(graph.warnings().iter().cloned());
let attributions = doc.attributions();
if !args.json {
match needed {
Some(needed) if needed > doc.pad => tracing::info!(
"pad: {} declared, {needed} needed — rendering with {needed}",
doc.pad,
),
Some(needed) => tracing::info!("pad: {} declared, {needed} needed", doc.pad),
None => {}
}
for w in &warnings {
tracing::warn!("{w}");
}
if !attributions.is_empty() {
tracing::info!("attribution: {}", attributions.join(" | "));
}
}
let doc_scoped_count = count_doc_scoped_sources(&doc);
if !args.no_fetch && doc_scoped_count > 0 {
let base_dir = args.assets_dir.clone().unwrap_or_else(|| {
if is_url(&args.style) {
PathBuf::from(".")
} else {
Path::new(&args.style)
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."))
}
});
let mut loader = BrushBankLoader::new()
.with_dir(base_dir.clone())
.with_images_dir(base_dir.clone());
ezu::paint::host::prefetch_doc_assets(&doc, &base_dir, &mut loader).await?;
}
if args.json {
let report = CheckReport {
name: &doc.name,
version: &doc.version,
nodes: graph.len(),
sources: doc.sources.len(),
pad: PadReport {
declared: doc.pad,
needed,
},
attribution: attributions,
params: doc.params_schema(),
assets_resolved: !args.no_fetch,
warnings: warnings.clone(),
};
println!("{}", serde_json::to_string_pretty(&report)?);
return strict_verdict(args.strict, &warnings);
}
strict_verdict(args.strict, &warnings)?;
tracing::info!(
"ok: {} v{} ({} nodes, {} sources){}",
doc.name,
doc.version,
graph.len(),
doc.sources.len(),
if args.no_fetch {
" [parse + graph only]"
} else {
""
},
);
Ok(())
}
fn strict_verdict(strict: bool, warnings: &[String]) -> Result<(), Box<dyn std::error::Error>> {
if strict && !warnings.is_empty() {
return Err(format!("--strict: {} warning(s); see above", warnings.len()).into());
}
Ok(())
}
fn count_doc_scoped_sources(doc: &Document) -> usize {
doc.sources
.values()
.filter(|d| {
matches!(
d,
SourceDecl::Brush(_)
| SourceDecl::Image(_)
| SourceDecl::Font(_)
| SourceDecl::Glyphs(_)
)
})
.count()
}
async fn run_graph(args: GraphCmd) -> Result<(), Box<dyn std::error::Error>> {
if args.tile.is_some() {
return run_graph_view(args).await;
}
let text = fetch_text(&args.style).await?;
let doc = Document::from_json(&text)?;
let mermaid = render_mermaid(&doc);
match &args.out {
Some(p) => {
std::fs::write(p, &mermaid)?;
tracing::info!("wrote {} ({} bytes)", p.display(), mermaid.len());
}
None => print!("{mermaid}"),
}
Ok(())
}
async fn run_graph_view(args: GraphCmd) -> Result<(), Box<dyn std::error::Error>> {
let tile = args.tile.expect("checked by the caller");
let Some(out) = args.out.clone() else {
return Err("`--tile` writes an HTML page; give it a path with `--out`".into());
};
let common = CommonArgs {
style: args.style.clone(),
assets_dir: args.assets_dir.clone(),
pmtiles: args.pmtiles.clone(),
mvt: args.mvt.clone(),
overzoom_levels: args.overzoom_levels,
params: args.params.clone(),
strict: false,
pad: None,
};
let style_text = fetch_text(&common.style).await?;
let doc = Document::from_json(&style_text)?;
let byte_cache = Arc::new(TileByteCache::new(tile_cache_budget_bytes()));
let opened = open_style(
&common,
TileSizeArgs::default(),
&style_text,
&doc,
&byte_cache,
)
.await?;
let tiles = &opened.tiles;
let canvas = CanvasInfo::square(tiles.tile_size(), tiles.pad());
let collector = Arc::new(graph_view::Collector::new(
canvas,
args.image_size,
args.png_compression.into(),
));
let watching = Arc::clone(&collector);
tiles
.render(tile, move |r, set, mut opts| {
opts.format = ezu::renderer::OutputFormat::Rgba;
r.render_tile_observed(tile.z, tile.x, tile.y, set, opts, &*watching)
})
.await
.map_err(|e| e.to_string())?;
let collected = Arc::try_unwrap(collector)
.map_err(|_| "the render still holds the node collector")?
.finish();
let sidecar = args.sidecar.then(|| {
let mut dir = out.clone();
dir.set_extension("assets");
dir
});
let mut params = serde_json::Map::new();
for raw in &args.params {
if let Some((name, value)) = raw.split_once('=') {
params.insert(
name.to_string(),
serde_json::Value::String(value.to_string()),
);
}
}
let payload = collected.into_payload(
&doc,
opened.graph(),
&format!("{}/{}/{}", tile.z, tile.x, tile.y),
¶ms,
sidecar.as_deref(),
)?;
let html = graph_view::render_html(&payload);
std::fs::write(&out, &html)?;
tracing::info!(
"wrote {} ({:.1} KiB, {} nodes)",
out.display(),
html.len() as f64 / 1024.0,
opened.graph().len(),
);
Ok(())
}
async fn run_legend(args: LegendCmd) -> Result<(), Box<dyn std::error::Error>> {
let text = fetch_text(&args.style).await?;
let doc = Document::from_json(&text)?;
build_graph(&doc, &default_registry())?;
let Some(legend) = &doc.legend else {
return Err(format!("{} declares no `legend` block", args.style).into());
};
let filtered = args.zoom.map(|z| ezu::style::LegendDecl {
title: legend.title.clone(),
note: legend.note.clone(),
entries: legend.entries_at(z).cloned().collect(),
});
let legend = filtered.as_ref().unwrap_or(legend);
let swatches = match &args.swatch_dir {
Some(dir) => draw_swatches(&doc, legend, dir, &args).await?,
None => vec![None; legend.entries.len()],
};
let out = LegendOut {
title: legend.title.as_deref(),
note: legend.note.as_deref(),
entries: legend
.entries
.iter()
.zip(&swatches)
.map(|(e, swatch)| EntryOut {
label: &e.label,
from: &e.from,
properties: &e.properties,
note: e.note.as_deref(),
min_zoom: e.min_zoom,
max_zoom: e.max_zoom,
geometry: e.geometry,
features: e.features.as_deref(),
swatch: swatch.as_deref(),
})
.collect(),
};
let json = if args.pretty {
serde_json::to_string_pretty(&out)?
} else {
serde_json::to_string(&out)?
};
match &args.out {
Some(p) => {
std::fs::write(p, &json)?;
tracing::info!("wrote {} ({} bytes)", p.display(), json.len());
}
None => println!("{json}"),
}
Ok(())
}
#[derive(serde::Serialize)]
#[serde(rename_all = "kebab-case")]
struct LegendOut<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
note: Option<&'a str>,
entries: Vec<EntryOut<'a>>,
}
#[derive(serde::Serialize)]
#[serde(rename_all = "kebab-case")]
struct EntryOut<'a> {
label: &'a str,
from: &'a ezu::style::NodeRef,
#[serde(skip_serializing_if = "serde_json::Map::is_empty")]
properties: &'a serde_json::Map<String, serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
note: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
min_zoom: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
max_zoom: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
geometry: Option<ezu::style::LegendGeometry>,
#[serde(skip_serializing_if = "Option::is_none")]
features: Option<&'a [ezu::style::LegendFeature]>,
#[serde(skip_serializing_if = "Option::is_none")]
swatch: Option<&'a str>,
}
async fn draw_swatches(
doc: &Document,
legend: &ezu::style::LegendDecl,
dir: &Path,
args: &LegendCmd,
) -> Result<Vec<Option<String>>, Box<dyn std::error::Error>> {
use ezu::paint::host::crop_to_png;
use ezu::paint::legend::{render_swatch, SwatchOptions};
std::fs::create_dir_all(dir)?;
let base_dir = args.assets_dir.clone().unwrap_or_else(|| {
if is_url(&args.style) {
PathBuf::from(".")
} else {
Path::new(&args.style)
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."))
}
});
let assets = build_asset_loader(doc, &base_dir).await?;
let registry = default_registry();
let cache = Cache::new();
let params = ParamValues::new();
let (width, height) = args.swatch_size;
let opts = SwatchOptions {
width,
height,
zoom: args.zoom.unwrap_or(12),
pad: 0,
geometry: Default::default(),
};
let mut out = Vec::with_capacity(legend.entries.len());
for (i, entry) in legend.entries.iter().enumerate() {
let (raster, canvas) =
render_swatch(doc, entry, ®istry, &assets, ¶ms, &cache, &opts)?;
let png = crop_to_png(
&raster,
canvas.tile_w,
canvas.tile_h,
canvas.pad,
args.png.compression(),
)?;
let path = dir.join(format!("{i}.png"));
std::fs::write(&path, &png)?;
tracing::info!("wrote {} ({} bytes)", path.display(), png.len());
out.push(Some(path.to_string_lossy().into_owned()));
}
Ok(out)
}
async fn run_translate(args: TranslateCmd) -> Result<(), Box<dyn std::error::Error>> {
let text = fetch_text(&args.style).await?;
let style: serde_json::Value = serde_json::from_str(&text)?;
let mut fonts = std::collections::HashMap::new();
for entry in &args.fonts {
let Some((name, url)) = entry.split_once('=') else {
return Err(format!("--font `{entry}`: expected NAME=URL").into());
};
fonts.insert(name.trim().to_string(), url.trim().to_string());
}
let opts = ezu::translate::maplibre::ConvertOptions {
tile_size: args.tile_size,
pad: args.pad,
keep_hidden: args.keep_hidden,
migrate: args.migrate,
fonts,
};
let (recipe, report) = ezu::translate::maplibre::convert(&style, &opts)?;
for w in &report.warnings {
tracing::warn!("{w}");
}
let json = if args.pretty {
serde_json::to_string_pretty(&recipe)?
} else {
serde_json::to_string(&recipe)?
};
match &args.out {
Some(p) => {
std::fs::write(p, &json)?;
tracing::info!(
"wrote {} ({} bytes, {} warnings)",
p.display(),
json.len(),
report.warnings.len()
);
}
None => println!("{json}"),
}
Ok(())
}
fn render_mermaid(doc: &ezu::style::Document) -> String {
use std::collections::HashSet;
let mut s = String::new();
s.push_str("graph LR\n");
let mut doc_scoped_ids: Vec<&str> = Vec::new();
for (id, decl) in &doc.sources {
let kind = match decl {
SourceDecl::Brush(_) => "brush",
SourceDecl::Image(_) => "image",
SourceDecl::Mvt(_) => "mvt",
SourceDecl::Pmtiles(_) => "pmtiles",
SourceDecl::Dem(_) => "dem",
SourceDecl::Raster(_) => "raster",
SourceDecl::GeoJson(_) => "geojson",
SourceDecl::Sprite(_) => "sprite",
SourceDecl::Font(_) => "font",
SourceDecl::Glyphs(_) => "glyphs",
};
s.push_str(&format!(" {id}[/\"{id} (source:{kind})\"/]\n"));
if matches!(decl, SourceDecl::Brush(_) | SourceDecl::Image(_)) {
doc_scoped_ids.push(id);
}
}
let output_id = doc.output.as_str();
let mut source_ids: Vec<&str> = Vec::new();
for (id, spec) in &doc.nodes {
let is_source = spec.op == "features";
let suffix = if id == output_id { ":::output" } else { "" };
let op = if spec.op == "func" {
match spec.fields.get("fn").and_then(serde_json::Value::as_str) {
Some(f) => format!("func:{f}"),
None => spec.op.clone(),
}
} else {
spec.op.clone()
};
if is_source {
s.push_str(&format!(" {id}[(\"{id} ({op})\")]{suffix}\n"));
source_ids.push(id);
} else {
s.push_str(&format!(" {id}[\"{id} ({op})\"]{suffix}\n"));
}
}
s.push_str(" __output__([\"OUTPUT\"]):::sink\n");
s.push_str(&format!(" {output_id} ==> __output__\n"));
s.push('\n');
for (id, spec) in &doc.nodes {
let mut seen = HashSet::new();
for r in spec.refs() {
if !seen.insert(r.clone()) {
continue;
}
if doc.nodes.contains_key(&r) || doc.sources.contains_key(&r) {
s.push_str(&format!(" {r} --> {id}\n"));
}
}
}
s.push_str("\n classDef asset fill:#fff4d6,color:#3a2e00,stroke:#a88500;\n");
s.push_str(" classDef output fill:#ffe0e0,color:#4a1010,stroke:#cc3333,stroke-width:2px;\n");
s.push_str(" classDef sink fill:#cc3333,color:#ffffff,stroke:#7a1f1f,stroke-width:2px;\n");
s.push_str(" classDef source fill:#d9ecff,color:#0d2b45,stroke:#2a6fb0;\n");
if !doc_scoped_ids.is_empty() {
s.push_str(&format!(" class {} asset;\n", doc_scoped_ids.join(",")));
}
if !source_ids.is_empty() {
s.push_str(&format!(" class {} source;\n", source_ids.join(",")));
}
s
}
async fn run_tile(args: TileCmd) -> Result<(), Box<dyn std::error::Error>> {
let opened = open(&args.common, args.size).await?;
let format = args
.format
.unwrap_or_else(|| OutputFormat::from_path(&args.out));
let png = args.png.png_compression;
let names: Vec<String> = args.features.iter().map(|(n, _)| n.clone()).collect();
let (bytes, features) = opened
.tiles
.render(args.tile, move |r, set, opts| {
let t = args.tile;
let opts = format.with(opts, png);
if names.is_empty() {
return r
.render_tile_with(t.z, t.x, t.y, set, opts)
.map(|px| (px, Vec::new()));
}
let names: Vec<&str> = names.iter().map(String::as_str).collect();
r.render_tile_with_features(t.z, t.x, t.y, set, opts, &names)
})
.await
.map_err(|e| e.to_string())?;
std::fs::write(&args.out, &bytes)?;
tracing::info!("wrote {} ({} bytes)", args.out.display(), bytes.len());
let coords = args.features_coords.into();
for (f, (_, path)) in features.iter().zip(&args.features) {
let text = f.to_geojson_in(coords);
std::fs::write(path, &text)?;
tracing::info!(
"wrote {} (features of `{}`, {} bytes)",
path.display(),
f.node(),
text.len()
);
}
Ok(())
}
async fn run_bbox(args: BboxCmd) -> Result<(), Box<dyn std::error::Error>> {
let concurrency = args.concurrency.unwrap_or_else(default_concurrency);
if concurrency == 0 {
return Err("--concurrency must be ≥ 1".into());
}
let exports =
frame_features::exports(&args.features, &args.features_key, args.features_coords)?;
let opened = open(&args.common, args.size).await?;
let tiles = &opened.tiles;
let format = args
.format
.unwrap_or_else(|| OutputFormat::from_path(&args.out));
let (x_range, y_range) = bbox_to_tiles(args.bbox, args.zoom);
let nx = x_range.end - x_range.start;
let ny = y_range.end - y_range.start;
tracing::info!(
"bbox covers {nx}×{ny} tiles at z={} ({}..{}, {}..{})",
args.zoom,
x_range.start,
x_range.end,
y_range.start,
y_range.end,
);
let t = tiles.device_tile_size();
let mosaic = Frame {
zoom: args.zoom,
tile_size: t,
scale: 0.0,
width: nx * t,
height: ny * t,
left: x_range.start as i64 * t as i64,
top: y_range.start as i64 * t as i64,
shift: (0.0, 0.0),
tiles_x: x_range,
tiles_y: y_range,
};
let mut collector = match exports.paths.is_empty() {
true => None,
false => Some(
tiles
.renderer()
.frame_features(&mosaic, &exports.request)
.map_err(|e| format!("--features: {e}"))?,
),
};
let image = render_frame(tiles, &mosaic, concurrency, collector.as_mut()).await?;
let bytes = format.encode(&image, args.png.png_compression)?;
std::fs::write(&args.out, &bytes)?;
tracing::info!("wrote {} ({} bytes)", args.out.display(), bytes.len());
let written = collector.map(FrameFeatureCollector::finish);
for (features, path) in written.iter().flatten().zip(&exports.paths) {
let text = features.to_geojson();
std::fs::write(path, &text)?;
tracing::info!(
"wrote {} (features of `{}`, {} bytes)",
path.display(),
features.node(),
text.len()
);
}
Ok(())
}
async fn run_frame(args: FrameCmd) -> Result<(), Box<dyn std::error::Error>> {
let concurrency = args.concurrency.unwrap_or_else(default_concurrency);
if concurrency == 0 {
return Err("--concurrency must be ≥ 1".into());
}
let cache = Arc::new(TileByteCache::new(tile_cache_budget_bytes()));
match &args.pages {
Some(path) => {
let pages = frame_pages::read(path)?;
render_pages(&args.common, &pages, concurrency, args.png, true, &cache).await
}
None => {
let page = page_from_flags(&args)?;
render_pages(&args.common, &[page], concurrency, args.png, false, &cache).await
}
}
}
fn page_from_flags(args: &FrameCmd) -> Result<frame_pages::Page, Box<dyn std::error::Error>> {
let (Some(center), Some(scale), Some(dpi)) = (args.center, args.scale, args.dpi) else {
return Err("--center, --scale and --dpi are required without --pages".into());
};
if !(dpi.is_finite() && dpi > 0.0) {
return Err(format!("--dpi must be a positive number, got {dpi}").into());
}
let format = args
.format
.unwrap_or_else(|| OutputFormat::from_path(&args.out));
let (width, height) = match (args.size_px, args.size_mm) {
(Some(px), _) => px,
(None, Some((w, h))) => (mm_to_px(w, dpi), mm_to_px(h, dpi)),
(None, None) => return Err("one of --size-mm or --size-px is required".into()),
};
Ok(frame_pages::Page {
center,
scale,
dpi,
width,
height,
zoom: args.zoom,
out: args.out.clone(),
format,
features: args.features.clone(),
features_key: args.features_key.clone(),
features_coords: args.features_coords,
})
}
async fn render_pages(
common: &CommonArgs,
pages: &[frame_pages::Page],
concurrency: usize,
png: PngArgs,
many: bool,
byte_cache: &Arc<TileByteCache>,
) -> Result<(), Box<dyn std::error::Error>> {
let on_page = |i: usize, e: Box<dyn std::error::Error>| -> Box<dyn std::error::Error> {
if many {
format!("page {}: {e}", i + 1).into()
} else {
e
}
};
let style_text = fetch_text(&common.style).await?;
let doc = Document::from_json(&style_text)?;
let style_tile_size = doc.tile_size;
let frames = pages
.iter()
.enumerate()
.map(|(i, page)| {
Frame::compute(&FrameRequest {
center_lon: page.center.0,
center_lat: page.center.1,
scale: page.scale,
dpi: page.dpi,
width: page.width,
height: page.height,
zoom: page.zoom,
style_tile_size,
})
.map_err(|e| on_page(i, e.into()))
})
.collect::<Result<Vec<_>, _>>()?;
let started = std::time::Instant::now();
let mut state: Option<Opened> = None;
let mut collectors: Vec<Option<FrameFeatureCollector>> = Vec::new();
let mut paths: Vec<Vec<PathBuf>> = Vec::new();
for (i, (page, frame)) in pages.iter().zip(&frames).enumerate() {
let page_started = std::time::Instant::now();
let counts_before = byte_cache.counts();
log_frame(frame, style_tile_size, page.scale);
let opened = match &mut state {
Some(opened) => opened,
None => state.insert(
open_style(
common,
TileSizeArgs::default(),
&style_text,
&doc,
byte_cache,
)
.await?,
),
};
opened.tiles.set_tile_size(frame.tile_size)?;
if collectors.is_empty() {
for (j, (p, f)) in pages.iter().zip(&frames).enumerate() {
let exports =
frame_features::exports(&p.features, &p.features_key, p.features_coords)
.map_err(|e| on_page(j, e.into()))?;
let collector = match exports.paths.is_empty() {
true => None,
false => Some(
opened
.tiles
.renderer()
.frame_features(f, &exports.request)
.map_err(|e| on_page(j, format!("--features: {e}").into()))?,
),
};
collectors.push(collector);
paths.push(exports.paths);
}
}
let mut collector = collectors[i].take();
let image = render_frame(&opened.tiles, frame, concurrency, collector.as_mut())
.await
.map_err(|e| on_page(i, e))?;
let bytes = page.format.encode(&image, png.png_compression)?;
std::fs::write(&page.out, &bytes).map_err(|e| on_page(i, e.into()))?;
tracing::info!("wrote {} ({} bytes)", page.out.display(), bytes.len());
let written = collector.map(FrameFeatureCollector::finish);
for (features, path) in written.iter().flatten().zip(&paths[i]) {
let text = features.to_geojson();
std::fs::write(path, &text).map_err(|e| on_page(i, e.into()))?;
tracing::info!(
"wrote {} (features of `{}`, {} bytes)",
path.display(),
features.node(),
text.len()
);
}
if many {
let (hits, misses) = counts_before;
let (h, m) = byte_cache.counts();
tracing::info!(
"page {}/{}: {} in {:.2}s, {} tiles at z={}, source tiles fetched {}, reused {}",
i + 1,
pages.len(),
page.out.display(),
page_started.elapsed().as_secs_f64(),
frame.tile_count(),
frame.zoom,
m - misses,
h - hits,
);
}
}
if many {
let (hits, misses) = byte_cache.counts();
tracing::info!(
"{} pages in {:.2}s, source tiles fetched {misses}, reused {hits} ({:.1} MB kept)",
pages.len(),
started.elapsed().as_secs_f64(),
byte_cache.held_bytes() as f64 / (1024.0 * 1024.0),
);
}
Ok(())
}
fn log_frame(frame: &Frame, style_tile_size: u32, scale: f64) {
tracing::info!(
"frame: {}×{} px at z={}, tile-size {} (style: {}), scale 1:{:.1} ({:+.3}% vs 1:{}), \
shift ({:+.2}, {:+.2}) px, {} tiles ({}..{}, {}..{})",
frame.width,
frame.height,
frame.zoom,
frame.tile_size,
style_tile_size,
frame.scale,
(frame.scale / scale - 1.0) * 100.0,
scale,
frame.shift.0,
frame.shift.1,
frame.tile_count(),
frame.tiles_x.start,
frame.tiles_x.end,
frame.tiles_y.start,
frame.tiles_y.end,
);
}
async fn render_frame(
tiles: &TileRenderer,
frame: &Frame,
concurrency: usize,
mut collector: Option<&mut FrameFeatureCollector>,
) -> Result<Pixmap, Box<dyn std::error::Error>> {
let mut image = Pixmap::new(frame.width, frame.height)
.ok_or_else(|| format!("cannot allocate a {}×{} image", frame.width, frame.height))?;
let watched: Vec<String> = collector
.as_ref()
.map(|c| c.nodes().into_iter().map(String::from).collect())
.unwrap_or_default();
let watched = &watched;
let xr = frame.tiles_x.clone();
let coords = frame
.tiles_y
.clone()
.flat_map(move |ty| xr.clone().map(move |tx| (tx, ty)));
let mut rendered = futures::stream::iter(coords)
.map(|(tx, ty)| {
let watched = watched.to_vec();
async move {
let tile = CoreTileId::new(frame.zoom, tx, ty);
let (rgba, values) = tiles
.render(tile, move |r, set, mut opts| {
opts.format = ezu::renderer::OutputFormat::Rgba;
if watched.is_empty() {
return r
.render_tile_with(tile.z, tile.x, tile.y, set, opts)
.map(|px| (px, Vec::new()));
}
let names: Vec<&str> = watched.iter().map(String::as_str).collect();
r.render_tile_with_features(tile.z, tile.x, tile.y, set, opts, &names)
})
.await?;
Ok::<_, render::BoxError>((tx, ty, rgba, values))
}
})
.buffer_unordered(concurrency);
while let Some(done) = rendered.next().await {
let (tx, ty, rgba, values) = done.map_err(|e| e.to_string())?;
if let Some(c) = collector.as_deref_mut() {
c.add(CoreTileId::new(frame.zoom, tx, ty), &values);
}
let (dx, dy) = frame.tile_offset(tx, ty);
blit_rgba_into(&mut image, &rgba, dx, dy, frame.tile_size);
}
Ok(image)
}
fn mm_to_px(mm: f64, dpi: f64) -> u32 {
(mm / 25.4 * dpi).round() as u32
}
async fn run_tiles(args: TilesCmd) -> Result<(), Box<dyn std::error::Error>> {
if args.min_zoom > args.max_zoom {
return Err("--min-zoom must be ≤ --max-zoom".into());
}
let concurrency = args.concurrency.unwrap_or_else(default_concurrency);
if concurrency == 0 {
return Err("--concurrency must be ≥ 1".into());
}
let opened = open(&args.common, args.size).await?;
let tiles = &opened.tiles;
let mut total: u64 = 0;
for z in args.min_zoom..=args.max_zoom {
let (x_range, y_range) = match args.bbox {
Some(b) => bbox_to_tiles(b, z),
None => {
let n = 1u32 << z;
(0..n, 0..n)
}
};
let nx = x_range.end - x_range.start;
let ny = y_range.end - y_range.start;
let count = nx as u64 * ny as u64;
total += count;
tracing::info!(
"z={z}: {nx}×{ny} = {count} tiles ({}..{}, {}..{})",
x_range.start,
x_range.end,
y_range.start,
y_range.end,
);
let xr = x_range.clone();
let coords = y_range
.clone()
.flat_map(move |ty| xr.clone().map(move |tx| (tx, ty)));
let out = &args.out;
let format = args.format;
let png = args.png.png_compression;
let t0 = std::time::Instant::now();
futures::stream::iter(coords)
.map(|(tx, ty)| async move {
let tile = CoreTileId::new(z, tx, ty);
let bytes = tiles
.render(tile, move |r, set, opts| {
r.render_tile_with(tile.z, tile.x, tile.y, set, format.with(opts, png))
})
.await?;
let dir = out.join(z.to_string()).join(tx.to_string());
tokio::fs::create_dir_all(&dir).await?;
let path = dir.join(format!("{ty}.{}", format.extension()));
tokio::fs::write(&path, bytes).await?;
Ok::<(), Box<dyn std::error::Error + Send + Sync>>(())
})
.buffer_unordered(concurrency)
.try_collect::<Vec<_>>()
.await
.map_err(|e| e.to_string())?;
tracing::info!("z={z}: done in {:.1}s", t0.elapsed().as_secs_f64());
}
tracing::info!("wrote {total} tiles → {}", args.out.display());
Ok(())
}
fn cache_budget_bytes() -> Option<usize> {
std::env::var("EZU_CACHE_MB")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.map(|mb| mb * 1024 * 1024)
}
fn tile_cache_budget_bytes() -> usize {
std::env::var("EZU_TILE_CACHE_MB")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(256)
* 1024
* 1024
}
async fn build_asset_loader(
doc: &Document,
base_dir: &Path,
) -> Result<BrushBankLoader, Box<dyn std::error::Error>> {
let mut loader = BrushBankLoader::new()
.with_dir(base_dir.to_path_buf())
.with_images_dir(base_dir.to_path_buf());
ezu::paint::host::prefetch_doc_assets(doc, base_dir, &mut loader).await?;
tracing::info!(
"loaded {} brushes + {} images (base={})",
loader.bank.len(),
loader.images.len(),
base_dir.display(),
);
Ok(loader)
}
pub(crate) async fn fetch_text(arg: &str) -> Result<String, Box<dyn std::error::Error>> {
if is_url(arg) {
let body = reqwest::get(arg).await?.error_for_status()?.text().await?;
Ok(body)
} else {
Ok(std::fs::read_to_string(arg)?)
}
}
fn is_url(s: &str) -> bool {
s.starts_with("http://") || s.starts_with("https://")
}
fn bbox_to_tiles(b: BBox, z: u8) -> (std::ops::Range<u32>, std::ops::Range<u32>) {
let n = 2f64.powi(z as i32);
let xt = |lng: f64| {
(ezu::core::coord::lon_to_world_x(lng) * n)
.floor()
.clamp(0.0, n - 1.0) as u32
};
let yt = |lat: f64| {
(ezu::core::coord::lat_to_world_y(lat) * n)
.floor()
.clamp(0.0, n - 1.0) as u32
};
let x0 = xt(b.min_lng);
let x1 = xt(b.max_lng);
let y0 = yt(b.max_lat);
let y1 = yt(b.min_lat);
(x0..(x1 + 1), y0..(y1 + 1))
}
fn parse_zxy(s: &str) -> Result<CoreTileId, String> {
let parts: Vec<&str> = s.split('/').collect();
if parts.len() != 3 {
return Err(format!("expected `Z/X/Y`, got `{s}`"));
}
let z: u8 = parts[0].parse().map_err(|e| format!("bad z: {e}"))?;
let x: u32 = parts[1].parse().map_err(|e| format!("bad x: {e}"))?;
let y: u32 = parts[2].parse().map_err(|e| format!("bad y: {e}"))?;
Ok(CoreTileId::new(z, x, y))
}
fn parse_lnglat(s: &str) -> Result<(f64, f64), String> {
let (lng, lat) = s
.split_once(',')
.ok_or_else(|| format!("expected `lng,lat`, got `{s}`"))?;
let num = |p: &str| {
p.trim()
.parse::<f64>()
.map_err(|e| format!("bad number `{p}`: {e}"))
};
Ok((num(lng)?, num(lat)?))
}
fn parse_size_mm(s: &str) -> Result<(f64, f64), String> {
let (w, h) = s
.split_once(['x', 'X'])
.ok_or_else(|| format!("`{s}`: expected WIDTHxHEIGHT in millimetres"))?;
let num = |p: &str| match p.trim().parse::<f64>() {
Ok(v) if v.is_finite() && v > 0.0 => Ok(v),
_ => Err(format!(
"`{s}`: `{p}` is not a positive number of millimetres"
)),
};
Ok((num(w)?, num(h)?))
}
fn parse_bbox(s: &str) -> Result<BBox, String> {
let parts: Vec<&str> = s.split(',').collect();
if parts.len() != 4 {
return Err(format!(
"expected `min_lng,min_lat,max_lng,max_lat`, got `{s}`"
));
}
let v: Vec<f64> = parts
.iter()
.map(|p| {
p.trim()
.parse::<f64>()
.map_err(|e| format!("bad number `{p}`: {e}"))
})
.collect::<Result<_, _>>()?;
let (min_lng, min_lat, max_lng, max_lat) = (v[0], v[1], v[2], v[3]);
if min_lng >= max_lng || min_lat >= max_lat {
return Err(format!(
"bbox min must be strictly less than max: {min_lng},{min_lat},{max_lng},{max_lat}"
));
}
Ok(BBox {
min_lng,
min_lat,
max_lng,
max_lat,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_string_param_flag_must_be_one_of_its_enum() {
let doc = Document::from_json(
r##"{
"name": "p",
"params": {
"mode": { "type": "string", "enum": ["multiply", "screen"], "default": "screen" },
"note": { "type": "string", "default": "" }
},
"nodes": { "out": { "op": "solid", "color": "#000000" } },
"output": "@out"
}"##,
)
.unwrap();
let flags = |f: &[&str]| f.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let values = parse_cli_params(&flags(&["mode=multiply", "note=a=b"]), &doc).unwrap();
assert_eq!(
values.get("mode"),
Some(ezu::graph::ScalarValue::String("multiply".into()))
);
assert_eq!(
values.get("note"),
Some(ezu::graph::ScalarValue::String("a=b".into()))
);
let err = parse_cli_params(&flags(&["mode=overlay"]), &doc).unwrap_err();
assert!(err.to_string().contains("not one of"), "{err}");
}
fn style(tile_size: u32) -> String {
format!(
r##"{{
"name": "frame-test",
"tile-size": {tile_size},
"pad": 8,
"sources": {{
"shapes": {{ "type": "geojson", "data": {{ "type": "FeatureCollection", "features": [
{{ "type": "Feature", "properties": {{}}, "geometry": {{ "type": "Polygon",
"coordinates": [[[139.698, 35.658], [139.703, 35.659],
[139.701, 35.663], [139.698, 35.658]]] }} }}
] }} }}
}},
"nodes": {{
"bg": {{ "op": "solid", "color": "#fbf6e680" }},
"feat": {{ "op": "features", "source": "shapes", "layer": "shapes" }},
"fill": {{ "op": "fill-solid", "features": "@feat", "fill": "#33669980" }},
"soft": {{ "op": "blur", "input": "@fill", "sigma": 3 }},
"out": {{ "op": "blend", "base": "@bg", "over": "@soft" }}
}},
"output": "@out"
}}"##
)
}
fn common(style: &Path) -> CommonArgs {
CommonArgs {
style: style.to_string_lossy().into_owned(),
assets_dir: None,
pmtiles: None,
mvt: None,
overzoom_levels: 4,
params: Vec::new(),
strict: false,
pad: None,
}
}
#[tokio::test(flavor = "multi_thread")]
async fn a_frame_is_the_matching_crop_of_the_bbox_mosaic() {
let dir = std::env::temp_dir().join(format!("ezu-frame-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let frame_style = dir.join("frame.json");
std::fs::write(&frame_style, style(256)).unwrap();
let request = FrameRequest {
center_lon: 139.70,
center_lat: 35.66,
scale: 5000.0,
dpi: 96.0,
width: 300,
height: 200,
zoom: None,
style_tile_size: 256,
};
let frame = Frame::compute(&request).unwrap();
assert_ne!(frame.tile_size, 256);
assert!(frame.tiles_x.len() >= 2 && frame.tiles_y.len() >= 2);
let frame_out = dir.join("frame.png");
run_frame(FrameCmd {
common: common(&frame_style),
pages: None,
center: Some((request.center_lon, request.center_lat)),
scale: Some(request.scale),
dpi: Some(request.dpi),
size_mm: None,
size_px: Some((request.width, request.height)),
zoom: None,
out: frame_out.clone(),
format: None,
png: PngArgs::default(),
concurrency: Some(2),
features: Vec::new(),
features_key: Vec::new(),
features_coords: frame_features::FeatureCoords::Lonlat,
})
.await
.unwrap();
let bbox_style = dir.join("bbox.json");
std::fs::write(&bbox_style, style(frame.tile_size)).unwrap();
let n = (1u64 << frame.zoom) as f64;
let lon = |x: f64| ezu::core::coord::world_x_to_lon(x / n);
let lat = |y: f64| ezu::core::coord::world_y_to_lat(y / n);
let bbox_out = dir.join("bbox.png");
run_bbox(BboxCmd {
common: common(&bbox_style),
size: TileSizeArgs::default(),
bbox: BBox {
min_lng: lon(frame.tiles_x.start as f64 + 0.5),
max_lng: lon(frame.tiles_x.end as f64 - 0.5),
max_lat: lat(frame.tiles_y.start as f64 + 0.5),
min_lat: lat(frame.tiles_y.end as f64 - 0.5),
},
zoom: frame.zoom,
out: bbox_out.clone(),
format: None,
png: PngArgs::default(),
concurrency: None,
features: Vec::new(),
features_key: Vec::new(),
features_coords: frame_features::FeatureCoords::Lonlat,
})
.await
.unwrap();
let got = Pixmap::load_png(&frame_out).unwrap();
let mosaic = Pixmap::load_png(&bbox_out).unwrap();
let t = frame.tile_size;
assert_eq!(mosaic.width(), frame.tiles_x.len() as u32 * t);
assert_eq!(mosaic.height(), frame.tiles_y.len() as u32 * t);
assert_eq!((got.width(), got.height()), (frame.width, frame.height));
let (ox, oy) = frame.tile_offset(frame.tiles_x.start, frame.tiles_y.start);
let (ox, oy) = ((-ox) as usize, (-oy) as usize);
let stride = mosaic.width() as usize * 4;
let row = frame.width as usize * 4;
for y in 0..frame.height as usize {
let want = &mosaic.data()[(oy + y) * stride + ox * 4..][..row];
let have = &got.data()[y * row..][..row];
assert!(want == have, "row {y} differs");
}
assert!(got.pixels().windows(2).any(|p| p[0] != p[1]));
std::fs::remove_dir_all(&dir).ok();
}
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("ezu-{name}-{}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn tile_cmd(style: &Path, tile: CoreTileId, out: PathBuf) -> TileCmd {
TileCmd {
common: common(style),
size: TileSizeArgs::default(),
tile,
out,
format: None,
png: PngArgs::default(),
features: Vec::new(),
features_coords: frame_features::TileCoords::Tile,
}
}
#[tokio::test(flavor = "multi_thread")]
async fn tile_size_renders_what_the_edited_style_does() {
let dir = scratch("tile-size");
let (small, big) = (dir.join("small.json"), dir.join("big.json"));
std::fs::write(&small, style(64)).unwrap();
std::fs::write(&big, style(128)).unwrap();
let tile = CoreTileId::new(16, 58199, 25811);
let edited = dir.join("edited.png");
run_tile(tile_cmd(&big, tile, edited.clone()))
.await
.unwrap();
let flagged = dir.join("flagged.png");
let mut cmd = tile_cmd(&small, tile, flagged.clone());
cmd.size.tile_size = Some(128);
run_tile(cmd).await.unwrap();
let (a, b) = (
Pixmap::load_png(&edited).unwrap(),
Pixmap::load_png(&flagged).unwrap(),
);
assert_eq!((b.width(), b.height()), (128, 128));
assert!(
a.data() == b.data(),
"--tile-size 128 differs from tile-size 128"
);
assert!(b.pixels().windows(2).any(|p| p[0] != p[1]), "not blank");
let bbox = |style: &Path, out: PathBuf, tile_size| BboxCmd {
common: common(style),
size: TileSizeArgs {
tile_size,
..TileSizeArgs::default()
},
bbox: BBox {
min_lng: 139.698,
min_lat: 35.658,
max_lng: 139.703,
max_lat: 35.663,
},
zoom: 16,
out,
format: None,
png: PngArgs::default(),
concurrency: None,
features: Vec::new(),
features_key: Vec::new(),
features_coords: frame_features::FeatureCoords::Lonlat,
};
let (m1, m2) = (dir.join("m1.png"), dir.join("m2.png"));
run_bbox(bbox(&big, m1.clone(), None)).await.unwrap();
run_bbox(bbox(&small, m2.clone(), Some(128))).await.unwrap();
assert_eq!(std::fs::read(&m1).unwrap(), std::fs::read(&m2).unwrap());
let tiles_cmd = |style: &Path, out: PathBuf, tile_size| TilesCmd {
common: common(style),
size: TileSizeArgs {
tile_size,
..TileSizeArgs::default()
},
bbox: Some(BBox {
min_lng: 139.698,
min_lat: 35.658,
max_lng: 139.703,
max_lat: 35.663,
}),
min_zoom: 16,
max_zoom: 16,
out,
format: OutputFormat::Png,
png: PngArgs::default(),
concurrency: Some(2),
};
let (p1, p2) = (dir.join("p1"), dir.join("p2"));
run_tiles(tiles_cmd(&big, p1.clone(), None)).await.unwrap();
run_tiles(tiles_cmd(&small, p2.clone(), Some(128)))
.await
.unwrap();
let at = |root: &Path| std::fs::read(root.join("16/58199/25811.png")).unwrap();
assert_eq!(at(&p1), at(&p2));
std::fs::remove_dir_all(&dir).ok();
}
fn straight(png: &[u8]) -> (u32, u32, Vec<u8>) {
let p = Pixmap::decode_png(png).unwrap();
(p.width(), p.height(), ezu::paint::host::pixmap_to_rgba8(&p))
}
#[tokio::test(flavor = "multi_thread")]
async fn png_compression_and_rgba_keep_the_pixels() {
let dir = scratch("png-levels");
let path = dir.join("style.json");
std::fs::write(&path, style(64)).unwrap();
let tile = CoreTileId::new(16, 58199, 25811);
let bbox = BBox {
min_lng: 139.697,
min_lat: 35.657,
max_lng: 139.704,
max_lat: 35.664,
};
let mut tiles = Vec::new();
let mut mosaics = Vec::new();
for level in [PngLevel::Fast, PngLevel::Default, PngLevel::Best] {
let png = PngArgs {
png_compression: level,
};
let out = dir.join(format!("tile-{level:?}.png"));
run_tile(TileCmd {
png,
..tile_cmd(&path, tile, out.clone())
})
.await
.unwrap();
tiles.push(std::fs::read(&out).unwrap());
let out = dir.join(format!("bbox-{level:?}.png"));
run_bbox(BboxCmd {
common: common(&path),
size: TileSizeArgs::default(),
bbox,
zoom: 17,
out: out.clone(),
format: None,
png,
concurrency: None,
features: Vec::new(),
features_key: Vec::new(),
features_coords: frame_features::FeatureCoords::Lonlat,
})
.await
.unwrap();
mosaics.push(std::fs::read(&out).unwrap());
}
for files in [&tiles, &mosaics] {
assert_ne!(files[0], files[1], "fast and default wrote the same bytes");
assert_ne!(files[1], files[2], "default and best wrote the same bytes");
assert_eq!(straight(&files[0]), straight(&files[1]));
assert_eq!(straight(&files[1]), straight(&files[2]));
}
let raw = dir.join("tile.rgba");
run_tile(tile_cmd(&path, tile, raw.clone())).await.unwrap();
let (w, h, want) = straight(&tiles[1]);
assert_eq!((w, h), (64, 64));
assert_eq!(std::fs::read(&raw).unwrap(), want);
let raw = dir.join("bbox.bin");
run_bbox(BboxCmd {
common: common(&path),
size: TileSizeArgs::default(),
bbox,
zoom: 17,
out: raw.clone(),
format: Some(OutputFormat::Rgba),
png: PngArgs::default(),
concurrency: None,
features: Vec::new(),
features_key: Vec::new(),
features_coords: frame_features::FeatureCoords::Lonlat,
})
.await
.unwrap();
let (w, h, want) = straight(&mosaics[1]);
let got = std::fs::read(&raw).unwrap();
assert_eq!(got.len(), (w * h * 4) as usize);
assert_eq!(got, want);
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test(flavor = "multi_thread")]
async fn a_frame_writes_rgba_too() {
let dir = scratch("frame-rgba");
let path = dir.join("style.json");
std::fs::write(&path, style(256)).unwrap();
let frame = |out: PathBuf| FrameCmd {
common: common(&path),
pages: None,
center: Some((139.70, 35.66)),
scale: Some(5000.0),
dpi: Some(96.0),
size_mm: None,
size_px: Some((120, 80)),
zoom: None,
out,
format: None,
png: PngArgs::default(),
concurrency: Some(2),
features: Vec::new(),
features_key: Vec::new(),
features_coords: frame_features::FeatureCoords::Lonlat,
};
let (png, raw) = (dir.join("page.png"), dir.join("page.rgba"));
run_frame(frame(png.clone())).await.unwrap();
run_frame(frame(raw.clone())).await.unwrap();
let (w, h, want) = straight(&std::fs::read(&png).unwrap());
assert_eq!((w, h), (120, 80));
assert_eq!(std::fs::read(&raw).unwrap(), want);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn png_compression_and_rgba_parse_where_files_are_written() {
let parse = |args: &[&str]| Cli::try_parse_from(args).map(|c| c.cmd);
let tile = ["ezu", "tile", "--style", "s", "--tile", "1/0/0"];
let Ok(Cmd::Tile(t)) = parse(&[&tile[..], &["--png-compression", "best"]].concat()) else {
panic!("tile takes --png-compression");
};
assert_eq!(t.png.png_compression, PngLevel::Best);
assert!(parse(&[&tile[..], &["--png-compression", "max"]].concat()).is_err());
assert!(parse(&[&tile[..], &["--format", "rgba"]].concat()).is_ok());
for cmd in [
&[
"ezu", "bbox", "--style", "s", "--bbox", "0,0,1,1", "--zoom", "3",
][..],
&[
"ezu",
"tiles",
"--style",
"s",
"--min-zoom",
"0",
"--max-zoom",
"1",
][..],
&["ezu", "frame", "--style", "s", "--pages", "p.json"][..],
&["ezu", "serve", "s"][..],
&["ezu", "legend", "s"][..],
&["ezu", "graph", "s"][..],
] {
let args = [cmd, &["--png-compression", "fast"]].concat();
assert!(parse(&args).is_ok(), "{cmd:?}");
}
assert_eq!(
OutputFormat::from_path(Path::new("a.RGBA")),
OutputFormat::Rgba
);
assert_eq!(
OutputFormat::from_path(Path::new("a.bin")),
OutputFormat::Png
);
}
#[tokio::test(flavor = "multi_thread")]
async fn bbox_concurrency_changes_nothing_but_the_speed() {
let dir = scratch("bbox-concurrency");
let path = dir.join("style.json");
std::fs::write(&path, style(64)).unwrap();
let bbox = |out: PathBuf, concurrency| BboxCmd {
common: common(&path),
size: TileSizeArgs::default(),
bbox: BBox {
min_lng: 139.697,
min_lat: 35.657,
max_lng: 139.704,
max_lat: 35.664,
},
zoom: 17,
out,
format: None,
png: PngArgs::default(),
concurrency,
features: Vec::new(),
features_key: Vec::new(),
features_coords: frame_features::FeatureCoords::Lonlat,
};
let (one, many) = (dir.join("one.png"), dir.join("many.png"));
run_bbox(bbox(one.clone(), Some(1))).await.unwrap();
run_bbox(bbox(many.clone(), Some(8))).await.unwrap();
let image = Pixmap::load_png(&one).unwrap();
assert!(image.width() >= 3 * 64 && image.height() >= 3 * 64);
assert_eq!(std::fs::read(&one).unwrap(), std::fs::read(&many).unwrap());
let refused = run_bbox(bbox(dir.join("none.png"), Some(0))).await;
assert!(refused.unwrap_err().to_string().contains("--concurrency"));
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test(flavor = "multi_thread")]
async fn pad_replaces_the_styles_margin() {
let dir = scratch("pad");
let path = dir.join("style.json");
std::fs::write(&path, style(64)).unwrap();
let wide = dir.join("wide.json");
std::fs::write(&wide, style(64).replace(r#""pad": 8"#, r#""pad": 40"#)).unwrap();
let tile = CoreTileId::new(16, 58199, 25811);
let render = |style: PathBuf, pad: Option<u32>, name: &str| {
let out = dir.join(name);
let mut cmd = tile_cmd(&style, tile, out.clone());
cmd.common.pad = pad;
async move {
run_tile(cmd).await.unwrap();
std::fs::read(&out).unwrap()
}
};
let declared = render(wide.clone(), None, "declared.png").await;
assert_eq!(render(path.clone(), Some(40), "flag.png").await, declared);
assert_ne!(
render(path.clone(), Some(0), "none.png").await,
render(path.clone(), None, "default.png").await,
"--pad 0 kept the margin the blur needs"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn tile_size_is_a_flag_where_it_applies() {
let parse = |args: &[&str]| Cli::try_parse_from(args).map(|c| c.cmd);
let base = ["ezu", "tile", "--style", "s.json", "--tile", "1/0/0"];
let Ok(Cmd::Tile(t)) = parse(&[&base[..], &["--tile-size", "1024", "--pad", "0"]].concat())
else {
panic!("tile takes --tile-size and --pad");
};
assert_eq!((t.size.tile_size, t.common.pad), (Some(1024), Some(0)));
assert!(parse(&[&base[..], &["--tile-size", "0"]].concat()).is_err());
let Ok(Cmd::Tile(t)) = parse(&[&base[..], &["--pixel-ratio", "1.5"]].concat()) else {
panic!("tile takes --pixel-ratio");
};
assert_eq!(t.size.pixel_ratio(), 1.5);
for bad in ["0.5", "5", "2x"] {
assert!(parse(&[&base[..], &["--pixel-ratio", bad]].concat()).is_err());
}
let frame = [
"ezu",
"frame",
"--style",
"s.json",
"--center",
"0,0",
"--scale",
"1000",
"--dpi",
"96",
"--size-px",
"10x10",
];
assert!(parse(&[&frame[..], &["--pad", "4"]].concat()).is_ok());
assert!(
parse(&[&frame[..], &["--tile-size", "512"]].concat()).is_err(),
"a frame's tile size follows from its scale"
);
assert!(
parse(&[&frame[..], &["--pixel-ratio", "2"]].concat()).is_err(),
"a frame's density is its dpi"
);
for cmd in [
&[
"ezu", "bbox", "--style", "s", "--bbox", "0,0,1,1", "--zoom", "3",
][..],
&[
"ezu",
"tiles",
"--style",
"s",
"--min-zoom",
"0",
"--max-zoom",
"1",
][..],
&["ezu", "serve", "s"][..],
] {
assert!(
parse(&[cmd, &["--tile-size", "512", "--pad", "8"]].concat()).is_ok(),
"{cmd:?}"
);
}
}
#[test]
fn a_tile_partly_outside_the_destination_copies_only_its_overlap() {
let rgba: Vec<u8> = (0..16u8).flat_map(|i| [i, 0, 0, 255]).collect();
let at = |x: usize, y: usize| (y * 4 + x) as u8;
let mut dst = Pixmap::new(5, 3).unwrap();
blit_rgba_into(&mut dst, &rgba, -2, -1, 4);
for y in 0..3usize {
for x in 0..5usize {
let got = dst.pixel(x as u32, y as u32).unwrap();
if x < 2 {
assert_eq!(got.red(), at(x + 2, y + 1), "({x}, {y})");
assert_eq!(got.alpha(), 255);
} else {
assert_eq!(got.alpha(), 0, "({x}, {y}) is outside the tile");
}
}
}
let mut dst = Pixmap::new(5, 3).unwrap();
blit_rgba_into(&mut dst, &rgba, 3, 2, 4);
assert_eq!(dst.pixel(3, 2).unwrap().red(), at(0, 0));
assert_eq!(dst.pixel(4, 2).unwrap().red(), at(1, 0));
blit_rgba_into(&mut dst, &rgba, -4, 0, 4);
blit_rgba_into(&mut dst, &rgba, 5, 0, 4);
assert_eq!(dst.pixel(0, 0).unwrap().alpha(), 0);
}
#[test]
fn premultiplying_a_demultiplied_pixel_gives_it_back() {
for a in 0..=255u8 {
for c in 0..=a {
let p = tiny_skia::PremultipliedColorU8::from_rgba(c, c, c, a).unwrap();
assert_eq!(p.demultiply().premultiply(), p, "({c}, {a})");
}
}
}
#[test]
fn frame_size_and_centre_parse() {
assert_eq!(parse_lnglat("139.7, 35.66"), Ok((139.7, 35.66)));
assert!(parse_lnglat("139.7").is_err());
assert_eq!(parse_size_mm("245x174.5"), Ok((245.0, 174.5)));
assert!(parse_size_mm("245").is_err());
assert!(parse_size_mm("0x10").is_err());
assert_eq!(
(mm_to_px(297.0, 200.0), mm_to_px(210.0, 200.0)),
(2339, 1654)
);
}
}