Skip to content
/ martin Public
forked from maplibre/martin

Commit

Permalink
Implement sprite support
Browse files Browse the repository at this point in the history
Serve image assets sprites for MapLibre rendering, given a directory with images.

### API
Per [MapLibre sprites API](https://maplibre.org/maplibre-style-spec/sprite/), we need to support the following:
* `/sprite/<sprite_id>.json` metadata about the sprite file - all coming from a single directory
* `/sprite/<sprite_id>.png` all images combined into a single PNG
* `/sprite/<sprite_id>@2x.json` same but for high DPI devices
* `/sprite/<sprite_id>@2x.png`

Multiple sprite_id values can be combined into one sprite with the same pattern as for tile joining:  `/sprite/<sprite_id1>,<sprite_id2>,...,<sprite_idN>[.json|.png|@2x.json|@2x.png]`. No ID renaming is done, so identical names will override one another.

### Configuration
[Config file](https://maplibre.org/martin/config-file.html) and possibly CLI should have a simple option to serve sprites.  The configuration may look similar to how mbtiles and pmtiles are configured:

```yaml
# Publish sprite images
sprites:
  paths:
    # scan this whole dir, matching all image files, and publishing it as "my_images" sprite source
    - /path/to/my_images
  sources:
    # named source matching source name to a directory
    my_sprites: /path/to/some_dir
```

Implement maplibre#705
  • Loading branch information
nyurik committed Jun 15, 2023
1 parent 7e20602 commit 76da921
Show file tree
Hide file tree
Showing 48 changed files with 1,297 additions and 7 deletions.
708 changes: 703 additions & 5 deletions Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ semver = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
spreet = { version = "0.8", default-features = false }
sqlx = { version = "0.6", features = ["offline", "sqlite", "runtime-actix-native-tls"] }
subst = { version = "0.2", features = ["yaml"] }
thiserror = "1"
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ Martin data is available via the HTTP `GET` endpoints:
| `/{sourceID}/{z}/{x}/{y}` | Map Tiles |
| `/{source1},...,{sourceN}` | Composite Source TileJSON |
| `/{source1},...,{sourceN}/{z}/{x}/{y}` | Composite Source Tiles |
| `/sprite/{spriteID}[@2x].{json,png}` | Sprites (low and high DPI, index/png) |
| `/health` | Martin server health check: returns 200 `OK` |

## Documentation
Expand Down
1 change: 1 addition & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ test-int: clean-test
# Run integration tests and save its output as the new expected output
bless: start clean-test
cargo test --features bless-tests
tests/test.sh
rm -rf tests/expected
mv tests/output tests/expected
Expand Down
3 changes: 3 additions & 0 deletions martin/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ path = "src/bin/main.rs"
default = []
ssl = ["openssl", "postgres-openssl"]
vendored-openssl = ["ssl", "openssl/vendored"]
bless-tests = []

[dependencies]
actix-cors.workspace = true
Expand Down Expand Up @@ -53,9 +54,11 @@ semver.workspace = true
serde.workspace = true
serde_json = { workspace = true, features = ["preserve_order"] }
serde_yaml.workspace = true
spreet.workspace = true
subst.workspace = true
thiserror.workspace = true
tilejson.workspace = true
tokio = { workspace = true, features = ["io-std"] }

# Optional dependencies for openssl support
openssl = { workspace = true, optional = true }
Expand Down
6 changes: 6 additions & 0 deletions martin/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@ use crate::mbtiles::MbtSource;
use crate::pg::PgConfig;
use crate::pmtiles::PmtSource;
use crate::source::Sources;
use crate::sprites::{resolve_sprites, SpriteSources};
use crate::srv::SrvConfig;
use crate::utils::{IdResolver, OneOrMany, Result};
use crate::Error::{ConfigLoadError, ConfigParseError, NoSources};

pub struct AllSources {
pub sources: Sources,
pub sprites: SpriteSources,
}

#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
Expand All @@ -37,6 +39,9 @@ pub struct Config {
#[serde(skip_serializing_if = "Option::is_none")]
pub mbtiles: Option<FileConfigEnum>,

#[serde(skip_serializing_if = "Option::is_none")]
pub sprites: Option<FileConfigEnum>,

#[serde(flatten)]
pub unrecognized: HashMap<String, Value>,
}
Expand Down Expand Up @@ -105,6 +110,7 @@ impl Config {
acc
},
),
sprites: resolve_sprites(&mut self.sprites)?,
})
}
}
Expand Down
1 change: 1 addition & 0 deletions martin/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ pub mod mbtiles;
pub mod pg;
pub mod pmtiles;
mod source;
pub mod sprites;
pub mod srv;
mod utils;

Expand Down
229 changes: 229 additions & 0 deletions martin/src/sprites/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
use std::collections::hash_map::Entry;
use std::collections::HashMap;
use std::fmt::Debug;
use std::path::PathBuf;

use actix_web::error::ErrorNotFound;
use futures::future::try_join_all;
use log::{info, warn};
use spreet::fs::get_svg_input_paths;
use spreet::resvg::tiny_skia::Pixmap;
use spreet::resvg::usvg::{Error as ResvgError, Options, Tree, TreeParsing};
use spreet::sprite::{generate_pixmap_from_svg, sprite_name, Spritesheet, SpritesheetBuilder};
use tokio::io::AsyncReadExt;

use crate::file_config::{FileConfigEnum, FileError};

#[derive(thiserror::Error, Debug)]
pub enum SpriteError {
#[error("IO error {0}: {}", .1.display())]
IoError(std::io::Error, PathBuf),

#[error("Sprite path is not a file: {}", .0.display())]
InvalidFilePath(PathBuf),

#[error("Sprite {0} uses bad file {}", .1.display())]
InvalidSpriteFilePath(String, PathBuf),

#[error("No sprite files found in {}", .0.display())]
NoSpriteFilesFound(PathBuf),

#[error("Sprite {} could not be loaded", .0.display())]
UnableToReadSprite(PathBuf),

#[error("{0} in file {}", .1.display())]
SpriteProcessingError(spreet::error::Error, PathBuf),

#[error("{0} in file {}", .1.display())]
SpriteParsingError(ResvgError, PathBuf),

#[error("Unable to generate spritesheet")]
UnableToGenerateSpritesheet,
}

pub fn resolve_sprites(cfg: &mut Option<FileConfigEnum>) -> Result<SpriteSources, FileError> {
let Some(cfg) = cfg else {
return Ok(SpriteSources::default());
};
let cfg = cfg.extract_file_config();
let mut results = SpriteSources::default();

if let Some(sources) = cfg.sources {
for (id, source) in sources {
add_source(id, source.abs_path()?, &mut results);
}
};

if let Some(paths) = cfg.paths {
for path in paths {
let Some(name) = path.file_name() else {
warn!("Ignoring sprite source with no name from {}", path.display());
continue;
};
add_source(name.to_string_lossy().to_string(), path, &mut results);
}
}

Ok(results)
}

fn add_source(id: String, path: PathBuf, results: &mut SpriteSources) {
let disp_path = path.display();
if path.is_file() {
warn!("Ignoring non-directory sprite source {id} from {disp_path}");
} else {
match results.0.entry(id) {
Entry::Occupied(v) => {
warn!("Ignoring duplicate sprite source {} from {disp_path} because it was already configured for {}",
v.key(), v.get().path.display());
}
Entry::Vacant(v) => {
info!("Configured sprite source {} from {disp_path}", v.key());
v.insert(SpriteSource { path });
}
}
};
}

#[derive(Debug, Clone, Default)]
pub struct SpriteSources(HashMap<String, SpriteSource>);

impl SpriteSources {
pub fn get_sprite_source(&self, id: &str) -> actix_web::Result<&SpriteSource> {
self.0
.get(id)
.ok_or_else(|| ErrorNotFound(format!("Sprite {id} does not exist")))
}
}

#[derive(Clone, Debug)]
pub struct SpriteSource {
path: PathBuf,
}

async fn parse_sprite(
name: String,
path: PathBuf,
pixel_ratio: u8,
) -> Result<(String, Pixmap), SpriteError> {
let on_err = |e| SpriteError::IoError(e, path.clone());

let mut file = tokio::fs::File::open(&path).await.map_err(on_err)?;

let mut buffer = Vec::new();
file.read_to_end(&mut buffer).await.map_err(on_err)?;

let tree = Tree::from_data(&buffer, &Options::default())
.map_err(|e| SpriteError::SpriteParsingError(e, path.clone()))?;

let pixmap = generate_pixmap_from_svg(&tree, pixel_ratio)
.ok_or_else(|| SpriteError::UnableToReadSprite(path.clone()))?;

Ok((name, pixmap))
}

pub async fn get_spritesheet(
sources: impl Iterator<Item = &SpriteSource>,
pixel_ratio: u8,
) -> Result<Spritesheet, SpriteError> {
// Asynchronously load all SVG files from the given sources
let sprites = try_join_all(sources.flat_map(|source| {
get_svg_input_paths(&source.path, true)
.into_iter()
.map(|svg_path| {
let name = sprite_name(&svg_path, &source.path);
parse_sprite(name, svg_path, pixel_ratio)
})
.collect::<Vec<_>>()
}))
.await?;

let mut builder = SpritesheetBuilder::new();
builder
.sprites(sprites.into_iter().collect())
.pixel_ratio(pixel_ratio);

// TODO: decide if this is needed and/or configurable
// builder.make_unique();

builder
.generate()
.ok_or(SpriteError::UnableToGenerateSpritesheet)
}

#[cfg(test)]
mod tests {
use std::path::PathBuf;

use super::*;
use crate::file_config::FileConfig;
use crate::OneOrMany::Many;

#[actix_rt::test]
async fn test_sprites() {
let config = FileConfig {
paths: Some(Many(vec![
PathBuf::from("../tests/fixtures/sprites/src1"),
PathBuf::from("../tests/fixtures/sprites/src2"),
])),
..FileConfig::default()
};

let sprites = resolve_sprites(&mut Some(FileConfigEnum::Config(config)))
.unwrap()
.0;
assert_eq!(sprites.len(), 2);

test_src(sprites.values(), 1, "all_1").await;
test_src(sprites.values(), 2, "all_2").await;

test_src(sprites.get("src1").into_iter(), 1, "src1_1").await;
test_src(sprites.get("src1").into_iter(), 2, "src1_2").await;

test_src(sprites.get("src2").into_iter(), 1, "src2_1").await;
test_src(sprites.get("src2").into_iter(), 2, "src2_2").await;
}

async fn test_src(
sources: impl Iterator<Item = &SpriteSource>,
pixel_ratio: u8,
filename: &str,
) {
let path = PathBuf::from(format!("../tests/fixtures/sprites/expected/{filename}"));

let sprites = get_spritesheet(sources, pixel_ratio).await.unwrap();
let mut json = serde_json::to_string_pretty(sprites.get_index()).unwrap();
json.push('\n');
let png = sprites.encode_png().unwrap();

#[cfg(feature = "bless-tests")]
{
use std::io::Write as _;
let mut file = std::fs::File::create(path.with_extension("json")).unwrap();
file.write_all(json.as_bytes()).unwrap();

let mut file = std::fs::File::create(path.with_extension("png")).unwrap();
file.write_all(&png).unwrap();
}

#[cfg(not(feature = "bless-tests"))]
{
let expected = std::fs::read_to_string(path.with_extension("json"))
.expect("Unable to open expected JSON file, make sure to bless tests with\n cargo test --features bless-tests\n");

assert_eq!(
serde_json::from_str::<serde_json::Value>(&json).unwrap(),
serde_json::from_str::<serde_json::Value>(&expected).unwrap(),
"Make sure to run bless if needed:\n cargo test --features bless-tests\n\n{json}",
);

let expected = std::fs::read(path.with_extension("png"))
.expect("Unable to open expected PNG file, make sure to bless tests with\n cargo test --features bless-tests\n");

assert_eq!(
png, expected,
"Make sure to run bless if needed:\n cargo test --features bless-tests\n\n{json}",
);
}
}
}
2 changes: 2 additions & 0 deletions martin/src/srv/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ mod server;

pub use config::{SrvConfig, KEEP_ALIVE_DEFAULT, LISTEN_ADDRESSES_DEFAULT};
pub use server::{new_server, router, RESERVED_KEYWORDS};

pub use crate::source::IndexEntry;
Loading

0 comments on commit 76da921

Please sign in to comment.