pub mod dem_decode;
pub use dem_decode::{decode_dem_tile, stitch_padded_field, DemDecodeError, DemTile};
pub mod raster_decode;
pub use raster_decode::{
decode_raster_tile, stitch_padded_raster, upsample_subregion_raster, RasterTile,
};
#[cfg(feature = "http")]
pub mod dem;
#[cfg(feature = "http")]
pub use dem::{bind_dem_sources, build_dem_sources, DemFetchError, DemSourceRegistry};
#[cfg(feature = "http")]
pub mod raster;
#[cfg(feature = "http")]
pub use raster::{
bind_raster_sources, build_raster_sources, RasterFetchError, RasterSourceRegistry,
};
#[cfg(feature = "http")]
mod tilejson;
use std::any::Any;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use ezu_features::{mvt::DecodedTile, FeatureLayer};
use ezu_graph::{
Asset, AssetError, AssetLoader, OpaqueValue, RasterBuf, ScalarField, SpriteRect, SpriteSheet,
TileId,
};
use hokusai::Brush;
use tiny_skia::{Pixmap, PixmapPaint, Transform};
use xxhash_rust::xxh3::Xxh3;
use crate::PaintError;
pub struct BrushBankLoader {
pub bank: HashMap<String, Arc<Brush>>,
pub brushes_dir: Option<PathBuf>,
pub images: HashMap<String, Arc<RasterBuf>>,
pub images_dir: Option<PathBuf>,
pub sprites: HashMap<String, Arc<SpriteSheet>>,
pub fonts: HashMap<String, Arc<ezu_core::text::Font>>,
pub glyphs: RwLock<HashMap<String, Arc<ezu_core::text::SdfFontStack>>>,
}
impl BrushBankLoader {
pub fn new() -> Self {
Self {
bank: HashMap::new(),
brushes_dir: None,
images: HashMap::new(),
images_dir: None,
sprites: HashMap::new(),
fonts: HashMap::new(),
glyphs: RwLock::new(HashMap::new()),
}
}
pub fn with_dir(mut self, dir: PathBuf) -> Self {
self.brushes_dir = Some(dir);
self
}
pub fn with_images_dir(mut self, dir: PathBuf) -> Self {
self.images_dir = Some(dir);
self
}
pub fn insert(&mut self, name: impl Into<String>, brush: Brush) {
self.bank.insert(name.into(), Arc::new(brush));
}
pub fn insert_image(&mut self, name: impl Into<String>, image: RasterBuf) {
self.images.insert(name.into(), Arc::new(image));
}
pub fn insert_sprite(&mut self, image_src: impl Into<String>, sheet: SpriteSheet) {
self.sprites.insert(image_src.into(), Arc::new(sheet));
}
pub fn insert_font(&mut self, url: impl Into<String>, font: ezu_core::text::Font) {
self.fonts.insert(url.into(), Arc::new(font));
}
pub fn insert_glyphs(&self, key: impl Into<String>, stack: Arc<ezu_core::text::SdfFontStack>) {
self.glyphs
.write()
.expect("glyphs bank poisoned")
.insert(key.into(), stack);
}
pub fn glyphs_stack(&self, key: &str) -> Arc<ezu_core::text::SdfFontStack> {
if let Some(stack) = self.glyphs.read().expect("glyphs bank poisoned").get(key) {
return stack.clone();
}
let stack = Arc::new(match make_range_fetcher(key, None) {
Some(fetcher) => ezu_core::text::SdfFontStack::with_fetcher(fetcher),
None => {
tracing::warn!(
"glyphs source `{key}`: this host cannot fetch ranges — bind every \
needed range up front or labels will drop their glyphs"
);
ezu_core::text::SdfFontStack::new()
}
});
self.glyphs
.write()
.expect("glyphs bank poisoned")
.entry(key.to_string())
.or_insert(stack)
.clone()
}
}
impl Default for BrushBankLoader {
fn default() -> Self {
Self::new()
}
}
impl AssetLoader for BrushBankLoader {
fn load(&self, name: &str) -> Result<Asset, AssetError> {
let src = name;
if src.contains("{range}") {
parse_src_scheme(src)?; return Ok(Asset::Glyphs(self.glyphs_stack(src) as OpaqueValue));
}
match parse_src_scheme(src)? {
SrcScheme::Builtin(key) => {
if let Some(b) = self.bank.get(key) {
return Ok(Asset::Brush(b.clone()));
}
if let Some(b) = self.bank.get(src) {
return Ok(Asset::Brush(b.clone()));
}
if let Some(s) = self.sprites.get(key).or_else(|| self.sprites.get(src)) {
return Ok(Asset::Sprite(s.clone()));
}
if let Some(f) = self.fonts.get(key).or_else(|| self.fonts.get(src)) {
return Ok(font_asset(f));
}
if let Some(img) = self.images.get(key) {
return Ok(Asset::Image(img.clone()));
}
if let Some(img) = self.images.get(src) {
return Ok(Asset::Image(img.clone()));
}
Err(AssetError::Other(format!(
"asset `{src}` is not registered in the in-memory bank. There are no \
bundled brushes — declare the asset in `sources` with a `file:`, \
`http(s):`, or `data:` `src`, or register it on the host before rendering."
)))
}
SrcScheme::File(path) => {
if let Some(b) = self.bank.get(src) {
return Ok(Asset::Brush(b.clone()));
}
if let Some(s) = self.sprites.get(src) {
return Ok(Asset::Sprite(s.clone()));
}
if let Some(f) = self.fonts.get(src) {
return Ok(font_asset(f));
}
if let Some(img) = self.images.get(src) {
return Ok(Asset::Image(img.clone()));
}
if let Some(asset) = load_brush_file(self.brushes_dir.as_deref(), path, src)? {
return Ok(asset);
}
if let Some(asset) = load_font_file(path, src)? {
return Ok(asset);
}
if let Some(asset) = load_image_file(self.images_dir.as_deref(), path, src)? {
return Ok(asset);
}
Err(AssetError::NotFound(src.to_string()))
}
SrcScheme::Http(_) => {
if let Some(b) = self.bank.get(src) {
return Ok(Asset::Brush(b.clone()));
}
if let Some(s) = self.sprites.get(src) {
return Ok(Asset::Sprite(s.clone()));
}
if let Some(f) = self.fonts.get(src) {
return Ok(font_asset(f));
}
if let Some(img) = self.images.get(src) {
return Ok(Asset::Image(img.clone()));
}
Err(AssetError::NotFound(src.to_string()))
}
SrcScheme::System(spec) => {
if let Some(f) = self.fonts.get(src) {
return Ok(font_asset(f));
}
let query = parse_system_font(spec)?;
Ok(Asset::Font(
Arc::new(load_system_font(&query)?) as OpaqueValue
))
}
SrcScheme::Data(_) => {
if let Some(b) = self.bank.get(src) {
return Ok(Asset::Brush(b.clone()));
}
if let Some(f) = self.fonts.get(src) {
return Ok(font_asset(f));
}
if let Some(img) = self.images.get(src) {
return Ok(Asset::Image(img.clone()));
}
load_data_url(src)
}
}
}
fn hash(&self, name: &str) -> u128 {
self.glyphs
.read()
.expect("glyphs bank poisoned")
.get(name)
.map(|stack| stack.ranges_hash())
.unwrap_or(0)
}
}
fn make_range_fetcher(
template: &str,
base_dir: Option<PathBuf>,
) -> Option<ezu_core::text::RangeFetcher> {
if let Some(path_template) = template.strip_prefix("file:") {
let path_template = path_template.to_string();
return Some(Box::new(move |start, end| {
let raw = path_template.replace("{range}", &format!("{start}-{end}"));
let p = std::path::Path::new(&raw);
let path = if p.is_absolute() {
p.to_path_buf()
} else if let Some(dir) = &base_dir {
dir.join(p)
} else {
return Err(format!(
"relative glyphs path `{raw}` resolves only through prefetch (no base dir)"
));
};
std::fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))
}));
}
#[cfg(feature = "http")]
if template.starts_with("http://") || template.starts_with("https://") {
let template = template.to_string();
return Some(Box::new(move |start, end| {
http_bytes_blocking(template.replace("{range}", &format!("{start}-{end}")))
}));
}
None
}
#[cfg(feature = "http")]
fn http_bytes_blocking(url: String) -> Result<Vec<u8>, String> {
std::thread::spawn(move || -> Result<Vec<u8>, String> {
Ok(reqwest::blocking::get(&url)
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?
.bytes()
.map_err(|e| e.to_string())?
.to_vec())
})
.join()
.map_err(|_| "glyph fetch thread panicked".to_string())?
}
#[derive(Debug, Clone, Copy)]
enum SrcScheme<'a> {
Builtin(&'a str),
File(&'a str),
Http(#[allow(dead_code)] &'a str),
Data(#[allow(dead_code)] &'a str),
System(&'a str),
}
fn parse_src_scheme(src: &str) -> Result<SrcScheme<'_>, AssetError> {
if let Some(rest) = src.strip_prefix("builtin:") {
Ok(SrcScheme::Builtin(rest))
} else if let Some(rest) = src.strip_prefix("file:") {
Ok(SrcScheme::File(rest))
} else if let Some(rest) = src.strip_prefix("system:") {
Ok(SrcScheme::System(rest))
} else if src.starts_with("http://") || src.starts_with("https://") {
Ok(SrcScheme::Http(src))
} else if src.starts_with("data:") {
Ok(SrcScheme::Data(src))
} else {
Err(AssetError::Other(format!(
"src `{src}` is missing a scheme — use `builtin:NAME`, `file:PATH`, `system:FAMILY`, `http(s)://URL`, or `data:`"
)))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct SystemFontQuery {
family: String,
weight: u16,
style: SystemFontStyle,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SystemFontStyle {
Normal,
Italic,
Oblique,
}
fn parse_system_font(spec: &str) -> Result<SystemFontQuery, AssetError> {
let (family_raw, query) = match spec.split_once('?') {
Some((f, q)) => (f, Some(q)),
None => (spec, None),
};
let family = String::from_utf8_lossy(&percent_decode(family_raw))
.trim()
.to_string();
if family.is_empty() {
return Err(AssetError::Other(format!(
"system font `{spec}`: empty family name — use `system:FAMILY`"
)));
}
let mut weight = 400u16;
let mut style = SystemFontStyle::Normal;
if let Some(query) = query {
for pair in query.split('&').filter(|p| !p.is_empty()) {
let (key, value) = pair.split_once('=').ok_or_else(|| {
AssetError::Other(format!(
"system font `{spec}`: malformed query part `{pair}` (expected `key=value`)"
))
})?;
match key {
"weight" => {
let w: u16 = value.parse().map_err(|_| {
AssetError::Other(format!(
"system font `{spec}`: invalid weight `{value}` (expected an integer in 100..=900)"
))
})?;
if !(100..=900).contains(&w) {
return Err(AssetError::Other(format!(
"system font `{spec}`: weight `{w}` out of range (expected 100..=900)"
)));
}
weight = w;
}
"style" => {
style = match value.to_ascii_lowercase().as_str() {
"normal" => SystemFontStyle::Normal,
"italic" => SystemFontStyle::Italic,
"oblique" => SystemFontStyle::Oblique,
other => {
return Err(AssetError::Other(format!(
"system font `{spec}`: unknown style `{other}` (expected normal, italic, or oblique)"
)))
}
};
}
other => {
return Err(AssetError::Other(format!(
"system font `{spec}`: unknown query key `{other}` (expected `weight` or `style`)"
)))
}
}
}
}
Ok(SystemFontQuery {
family,
weight,
style,
})
}
#[cfg(not(target_arch = "wasm32"))]
static SYSTEM_FONTS: std::sync::LazyLock<fontdb::Database> = std::sync::LazyLock::new(|| {
let mut db = fontdb::Database::new();
db.load_system_fonts();
db
});
#[cfg(not(target_arch = "wasm32"))]
fn load_system_font(query: &SystemFontQuery) -> Result<ezu_core::text::Font, AssetError> {
resolve_system_font(&SYSTEM_FONTS, query)
}
#[cfg(not(target_arch = "wasm32"))]
fn resolve_system_font(
db: &fontdb::Database,
query: &SystemFontQuery,
) -> Result<ezu_core::text::Font, AssetError> {
let style = match query.style {
SystemFontStyle::Normal => fontdb::Style::Normal,
SystemFontStyle::Italic => fontdb::Style::Italic,
SystemFontStyle::Oblique => fontdb::Style::Oblique,
};
let id = db
.query(&fontdb::Query {
families: &[fontdb::Family::Name(&query.family)],
weight: fontdb::Weight(query.weight),
stretch: fontdb::Stretch::Normal,
style,
})
.ok_or_else(|| {
AssetError::Other(format!("system font family '{}' not found", query.family))
})?;
let parsed = db
.with_face_data(id, |data, face_index| {
ezu_core::text::Font::from_bytes(Arc::from(data.to_vec()), face_index)
})
.ok_or_else(|| {
AssetError::Other(format!(
"system font family '{}' matched but its font data could not be read",
query.family
))
})?;
parsed.map_err(|e| AssetError::Decode {
src: format!("system:{}", query.family),
msg: e.to_string(),
})
}
#[cfg(target_arch = "wasm32")]
fn load_system_font(_query: &SystemFontQuery) -> Result<ezu_core::text::Font, AssetError> {
Err(AssetError::Other(
"system: fonts are not available on wasm; supply font bytes instead".to_string(),
))
}
struct DataUrl {
media_type: String,
bytes: Vec<u8>,
}
fn decode_data_url(src: &str) -> Result<DataUrl, AssetError> {
let body = src
.strip_prefix("data:")
.ok_or_else(|| AssetError::Decode {
src: src.to_string(),
msg: "not a data URL".into(),
})?;
let (meta, payload) = body.split_once(',').ok_or_else(|| AssetError::Decode {
src: src.to_string(),
msg: "malformed data URL (missing `,`)".into(),
})?;
let is_base64 = meta.split(';').any(|s| s.eq_ignore_ascii_case("base64"));
let media_type = meta.split(';').next().unwrap_or("").to_ascii_lowercase();
let bytes = if is_base64 {
use base64::Engine;
base64::engine::general_purpose::STANDARD
.decode(payload.trim())
.map_err(|e| AssetError::Decode {
src: src.to_string(),
msg: format!("base64: {e}"),
})?
} else {
percent_decode(payload)
};
Ok(DataUrl { media_type, bytes })
}
fn percent_decode(s: &str) -> Vec<u8> {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
if b[i] == b'%' && i + 2 < b.len() {
let hi = (b[i + 1] as char).to_digit(16);
let lo = (b[i + 2] as char).to_digit(16);
if let (Some(hi), Some(lo)) = (hi, lo) {
out.push((hi * 16 + lo) as u8);
i += 3;
continue;
}
}
out.push(b[i]);
i += 1;
}
out
}
fn load_data_url(src: &str) -> Result<Asset, AssetError> {
let data = decode_data_url(src)?;
let as_image = |bytes: &[u8]| {
decode_image_bytes(bytes)
.map(|r| Asset::Image(Arc::new(r)))
.map_err(|e| AssetError::Decode {
src: src.to_string(),
msg: e,
})
};
if data.media_type.starts_with("image/") {
return as_image(&data.bytes);
}
if data.media_type.starts_with("font/") || is_font_magic(&data.bytes) {
let font = ezu_core::text::Font::from_bytes(Arc::from(data.bytes), 0).map_err(|e| {
AssetError::Decode {
src: src.to_string(),
msg: e.to_string(),
}
})?;
return Ok(Asset::Font(Arc::new(font) as OpaqueValue));
}
if let Ok(text) = std::str::from_utf8(&data.bytes) {
if let Ok(brush) = hokusai::myb::from_str(text) {
return Ok(Asset::Brush(Arc::new(brush)));
}
}
as_image(&data.bytes)
}
fn font_asset(font: &Arc<ezu_core::text::Font>) -> Asset {
Asset::Font(font.clone() as OpaqueValue)
}
fn is_font_magic(bytes: &[u8]) -> bool {
matches!(
bytes.get(..4),
Some([0x00, 0x01, 0x00, 0x00] | b"OTTO" | b"ttcf" | b"true")
)
}
fn load_brush_file(
dir: Option<&std::path::Path>,
path: &str,
src: &str,
) -> Result<Option<Asset>, AssetError> {
let abs = std::path::Path::new(path);
let candidates: Vec<std::path::PathBuf> = if abs.is_absolute() {
vec![abs.to_path_buf()]
} else {
match dir {
Some(d) => {
let base = d.join(path);
vec![base.clone(), base.with_extension("myb")]
}
None => return Ok(None),
}
};
for path in &candidates {
if !path.exists() || !is_brush_extension(path) {
continue;
}
let bytes = std::fs::read_to_string(path).map_err(|e| AssetError::Decode {
src: src.to_string(),
msg: e.to_string(),
})?;
let brush = hokusai::myb::from_str(&bytes).map_err(|e| AssetError::Decode {
src: src.to_string(),
msg: e.to_string(),
})?;
return Ok(Some(Asset::Brush(Arc::new(brush))));
}
Ok(None)
}
fn load_image_file(
dir: Option<&std::path::Path>,
path: &str,
src: &str,
) -> Result<Option<Asset>, AssetError> {
let abs = std::path::Path::new(path);
let candidates: Vec<std::path::PathBuf> = if abs.is_absolute() {
vec![abs.to_path_buf()]
} else {
match dir {
Some(d) => {
let base = d.join(path);
vec![
base.clone(),
base.with_extension("png"),
base.with_extension("webp"),
]
}
None => return Ok(None),
}
};
for path in &candidates {
if !path.exists() || is_brush_extension(path) {
continue;
}
let raster = decode_image_file(path).map_err(|e| AssetError::Decode {
src: src.to_string(),
msg: e,
})?;
return Ok(Some(Asset::Image(Arc::new(raster))));
}
Ok(None)
}
fn is_brush_extension(path: &std::path::Path) -> bool {
matches!(path.extension().and_then(|s| s.to_str()), Some("myb"))
}
fn load_font_file(path: &str, src: &str) -> Result<Option<Asset>, AssetError> {
let path = std::path::Path::new(path);
let is_font = matches!(
path.extension().and_then(|s| s.to_str()),
Some("ttf" | "otf" | "ttc")
);
if !is_font || !path.is_absolute() || !path.exists() {
return Ok(None);
}
let bytes = std::fs::read(path).map_err(|e| AssetError::Decode {
src: src.to_string(),
msg: e.to_string(),
})?;
let font =
ezu_core::text::Font::from_bytes(Arc::from(bytes), 0).map_err(|e| AssetError::Decode {
src: src.to_string(),
msg: e.to_string(),
})?;
Ok(Some(Asset::Font(Arc::new(font) as OpaqueValue)))
}
pub struct TileLoader<'a> {
base: &'a dyn AssetLoader,
bindings: HashMap<String, Binding>,
tile: TileId,
}
struct Binding {
asset: Asset,
hash: u128,
}
impl<'a> TileLoader<'a> {
pub fn new(base: &'a dyn AssetLoader, tile: TileId) -> Self {
Self {
base,
bindings: HashMap::new(),
tile,
}
}
pub fn bind_features(&mut self, name: impl Into<String>, layer: FeatureLayer) -> &mut Self {
let name = name.into();
let hash = self.binding_hash(&name);
let opaque: OpaqueValue =
Arc::new(crate::render::SharedLayer::new(layer)) as Arc<dyn Any + Send + Sync>;
self.bindings.insert(
name,
Binding {
asset: Asset::Features(opaque),
hash,
},
);
self
}
pub fn bind_raster(&mut self, name: impl Into<String>, raster: RasterBuf) -> &mut Self {
let name = name.into();
let hash = self.binding_hash(&name);
self.bindings.insert(
name,
Binding {
asset: Asset::Image(Arc::new(raster)),
hash,
},
);
self
}
pub fn bind_scalar_field(&mut self, name: impl Into<String>, field: ScalarField) -> &mut Self {
let name = name.into();
let hash = self.binding_hash(&name);
self.bindings.insert(
name,
Binding {
asset: Asset::ScalarField(Arc::new(field)),
hash,
},
);
self
}
pub fn bind_mvt(&mut self, source: &str, tile: DecodedTile) -> &mut Self {
for layer in tile.layers {
let key = format!("{source}.{}", layer.name);
self.bind_features(key, layer);
}
self
}
pub fn bind_mvt_neighbor(
&mut self,
source: &str,
dx: i32,
dy: i32,
tile: DecodedTile,
) -> &mut Self {
if dx == 0 && dy == 0 {
return self.bind_mvt(source, tile);
}
for layer in tile.layers {
let base = format!("{source}.{}", layer.name);
self.bind_features(ezu_graph::neighbor_binding(&base, dx, dy), layer);
}
self
}
fn binding_hash(&self, name: &str) -> u128 {
let mut h = Xxh3::new();
h.update(&self.tile.z.to_le_bytes());
h.update(&self.tile.x.to_le_bytes());
h.update(&self.tile.y.to_le_bytes());
h.update(name.as_bytes());
h.digest128()
}
}
impl AssetLoader for TileLoader<'_> {
fn load(&self, name: &str) -> Result<Asset, AssetError> {
if let Some(b) = self.bindings.get(name) {
return Ok(b.asset.clone());
}
if !looks_like_asset_src(name) {
return Err(AssetError::NotFound(name.to_string()));
}
self.base.load(name)
}
fn hash(&self, name: &str) -> u128 {
if let Some(b) = self.bindings.get(name) {
return b.hash;
}
if !looks_like_asset_src(name) {
return 0;
}
self.base.hash(name)
}
}
pub(crate) fn looks_like_asset_src(name: &str) -> bool {
name.contains(':')
}
pub fn requested_neighbor_offsets(
requested: &std::collections::BTreeSet<String>,
source: &str,
) -> Vec<(i32, i32)> {
let prefix = format!("{source}.");
let mut offs: Vec<(i32, i32)> = requested
.iter()
.filter_map(|name| {
let (base, dx, dy) = ezu_graph::parse_neighbor_binding(name);
((dx, dy) != (0, 0) && base.starts_with(&prefix)).then_some((dx, dy))
})
.collect();
offs.sort_unstable();
offs.dedup();
offs
}
fn decode_image_file(path: &std::path::Path) -> Result<RasterBuf, String> {
let img = image::open(path).map_err(|e| e.to_string())?.to_rgba8();
Ok(rgba_to_premul_raster(img))
}
pub fn decode_image_bytes(bytes: &[u8]) -> Result<RasterBuf, String> {
let img = image::load_from_memory(bytes)
.map_err(|e| e.to_string())?
.to_rgba8();
Ok(rgba_to_premul_raster(img))
}
pub fn build_sprite_icons(
index: &ezu_style::SpriteIndex,
fetched_json: Option<&str>,
) -> Result<HashMap<String, SpriteRect>, String> {
let entries: HashMap<String, ezu_style::IconRect> = match index {
ezu_style::SpriteIndex::Inline(map) => map.clone(),
ezu_style::SpriteIndex::Url(_) => {
let text = fetched_json.ok_or("sprite index URL was not fetched")?;
serde_json::from_str(text).map_err(|e| format!("sprite index parse: {e}"))?
}
};
Ok(entries
.into_iter()
.map(|(name, r)| {
(
name,
SpriteRect {
x: r.x,
y: r.y,
width: r.width,
height: r.height,
pixel_ratio: r.pixel_ratio,
stretch_x: r.stretch_x,
stretch_y: r.stretch_y,
content: r.content,
},
)
})
.collect())
}
fn rgba_to_premul_raster(img: image::RgbaImage) -> RasterBuf {
let (w, h) = img.dimensions();
let mut pixels = Vec::with_capacity((w * h * 4) as usize);
for px in img.pixels() {
let [r, g, b, a] = px.0;
let af = a as f32 / 255.0;
pixels.push((r as f32 * af).round() as u8);
pixels.push((g as f32 * af).round() as u8);
pixels.push((b as f32 * af).round() as u8);
pixels.push(a);
}
RasterBuf {
width: w,
height: h,
pixels,
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum PngCompression {
Fast,
#[default]
Default,
Best,
}
pub fn raster_to_png(buf: &RasterBuf, tile_size: u32, pad: u32) -> Result<Vec<u8>, PaintError> {
raster_to_png_with(buf, tile_size, pad, PngCompression::Default)
}
pub fn raster_to_png_with(
buf: &RasterBuf,
tile_size: u32,
pad: u32,
compression: PngCompression,
) -> Result<Vec<u8>, PaintError> {
crop_to_png(buf, tile_size, tile_size, pad, compression)
}
pub fn crop_to_png(
buf: &RasterBuf,
crop_w: u32,
crop_h: u32,
pad: u32,
compression: PngCompression,
) -> Result<Vec<u8>, PaintError> {
if matches!(compression, PngCompression::Default) {
let padded = pixmap_from_raster(buf)?;
if pad == 0 && padded.width() == crop_w && padded.height() == crop_h {
return padded.encode_png().map_err(|_| PaintError::PngEncode);
}
let mut out = Pixmap::new(crop_w, crop_h).ok_or(PaintError::PngEncode)?;
out.draw_pixmap(
-(pad as i32),
-(pad as i32),
padded.as_ref(),
&PixmapPaint::default(),
Transform::identity(),
None,
);
return out.encode_png().map_err(|_| PaintError::PngEncode);
}
let rgba = crop_to_rgba8(buf, crop_w, crop_h, pad);
encode_rgba8_png(crop_w, crop_h, &rgba, compression)
}
fn encode_rgba8_png(
width: u32,
height: u32,
straight_rgba: &[u8],
compression: PngCompression,
) -> Result<Vec<u8>, PaintError> {
use image::codecs::png::{CompressionType, FilterType, PngEncoder};
let ct = match compression {
PngCompression::Fast => CompressionType::Fast,
PngCompression::Default => CompressionType::Default,
PngCompression::Best => CompressionType::Best,
};
let mut out = Vec::new();
let encoder = PngEncoder::new_with_quality(&mut out, ct, FilterType::Adaptive);
image::ImageEncoder::write_image(
encoder,
straight_rgba,
width,
height,
image::ExtendedColorType::Rgba8,
)
.map_err(|_| PaintError::PngEncode)?;
Ok(out)
}
fn pixmap_from_raster(buf: &RasterBuf) -> Result<Pixmap, PaintError> {
let mut p = Pixmap::new(buf.width, buf.height).ok_or(PaintError::PngEncode)?;
p.data_mut().copy_from_slice(&buf.pixels);
Ok(p)
}
pub fn raster_to_webp(buf: &RasterBuf, tile_size: u32, pad: u32) -> Result<Vec<u8>, PaintError> {
crop_to_webp(buf, tile_size, tile_size, pad)
}
pub fn crop_to_webp(
buf: &RasterBuf,
crop_w: u32,
crop_h: u32,
pad: u32,
) -> Result<Vec<u8>, PaintError> {
let rgba = crop_to_rgba8(buf, crop_w, crop_h, pad);
encode_rgba8_webp(crop_w, crop_h, &rgba)
}
pub fn pixmap_to_webp(pixmap: &Pixmap) -> Result<Vec<u8>, PaintError> {
let (w, h) = (pixmap.width(), pixmap.height());
let mut rgba = Vec::with_capacity((w * h * 4) as usize);
for p in pixmap.pixels() {
let p = p.demultiply();
rgba.extend_from_slice(&[p.red(), p.green(), p.blue(), p.alpha()]);
}
encode_rgba8_webp(w, h, &rgba)
}
fn encode_rgba8_webp(width: u32, height: u32, straight_rgba: &[u8]) -> Result<Vec<u8>, PaintError> {
let mut out = Vec::new();
let encoder = image::codecs::webp::WebPEncoder::new_lossless(&mut out);
image::ImageEncoder::write_image(
encoder,
straight_rgba,
width,
height,
image::ExtendedColorType::Rgba8,
)
.map_err(|e| PaintError::WebpEncode(e.to_string()))?;
Ok(out)
}
pub fn raster_to_rgba8(buf: &RasterBuf, tile_size: u32, pad: u32) -> Vec<u8> {
crop_to_rgba8(buf, tile_size, tile_size, pad)
}
pub fn crop_to_rgba8(buf: &RasterBuf, crop_w: u32, crop_h: u32, pad: u32) -> Vec<u8> {
let padded = match pixmap_from_raster(buf) {
Ok(p) => p,
Err(_) => return vec![0; (crop_w * crop_h * 4) as usize],
};
let tile_pixmap = if pad == 0 && padded.width() == crop_w && padded.height() == crop_h {
padded
} else {
let mut out = match Pixmap::new(crop_w, crop_h) {
Some(p) => p,
None => return vec![0; (crop_w * crop_h * 4) as usize],
};
out.draw_pixmap(
-(pad as i32),
-(pad as i32),
padded.as_ref(),
&PixmapPaint::default(),
Transform::identity(),
None,
);
out
};
let mut rgba = Vec::with_capacity((crop_w * crop_h * 4) as usize);
for p in tile_pixmap.pixels() {
let p = p.demultiply();
rgba.extend_from_slice(&[p.red(), p.green(), p.blue(), p.alpha()]);
}
rgba
}
#[cfg(feature = "http")]
pub async fn prefetch_doc_assets(
doc: &ezu_style::Document,
base_dir: &std::path::Path,
loader: &mut BrushBankLoader,
) -> Result<(), String> {
for (name, decl) in &doc.sources {
let _ = base_dir; match decl {
ezu_style::SourceDecl::Brush(file) => {
if !is_http_url(&file.src) {
continue;
}
if loader.bank.contains_key(&file.src) {
continue;
}
let json = http_text(&file.src)
.await
.map_err(|e| format!("brush `{name}`: {e}"))?;
let brush = hokusai::myb::from_str(&json)
.map_err(|e| format!("brush `{name}` parse: {e}"))?;
loader.insert(file.src.clone(), brush);
}
ezu_style::SourceDecl::Image(file) => {
if !is_http_url(&file.src) {
continue;
}
if loader.images.contains_key(&file.src) {
continue;
}
let bytes = http_bytes(&file.src)
.await
.map_err(|e| format!("image `{name}`: {e}"))?;
let raster = decode_image_bytes(&bytes)
.map_err(|e| format!("image `{name}` decode: {e}"))?;
loader.insert_image(file.src.clone(), raster);
}
ezu_style::SourceDecl::Sprite(sprite) => {
if loader.sprites.contains_key(&sprite.image) {
continue;
}
let atlas_bytes = read_asset_bytes(&sprite.image, base_dir)
.await
.map_err(|e| format!("sprite `{name}` atlas: {e}"))?;
let atlas = decode_image_bytes(&atlas_bytes)
.map_err(|e| format!("sprite `{name}` atlas decode: {e}"))?;
let fetched = match &sprite.index {
ezu_style::SpriteIndex::Url(u) => Some(
read_asset_text(u, base_dir)
.await
.map_err(|e| format!("sprite `{name}` index: {e}"))?,
),
ezu_style::SpriteIndex::Inline(_) => None,
};
let icons = build_sprite_icons(&sprite.index, fetched.as_deref())
.map_err(|e| format!("sprite `{name}`: {e}"))?;
loader.insert_sprite(sprite.image.clone(), SpriteSheet { atlas, icons });
}
ezu_style::SourceDecl::Font(font) => {
if loader.fonts.contains_key(&font.url) {
continue;
}
if let Some(spec) = font.url.strip_prefix("system:") {
let query =
parse_system_font(spec).map_err(|e| format!("font `{name}`: {e}"))?;
let face =
load_system_font(&query).map_err(|e| format!("font `{name}`: {e}"))?;
loader.fonts.insert(font.url.clone(), Arc::new(face));
continue;
}
let bytes = read_asset_bytes(&font.url, base_dir)
.await
.map_err(|e| format!("font `{name}`: {e}"))?;
let face = ezu_core::text::Font::from_bytes(Arc::from(bytes), font.index)
.map_err(|e| format!("font `{name}`: {e}"))?;
loader.fonts.insert(font.url.clone(), Arc::new(face));
}
ezu_style::SourceDecl::Glyphs(glyphs) => {
let key = glyphs.asset_key();
if loader
.glyphs
.read()
.expect("glyphs bank poisoned")
.contains_key(&key)
{
continue;
}
let stack = match make_range_fetcher(&key, Some(base_dir.to_path_buf())) {
Some(fetcher) => ezu_core::text::SdfFontStack::with_fetcher(fetcher),
None => {
return Err(format!(
"glyphs `{name}`: unsupported url template `{}` — use \
`http(s)://…{{range}}.pbf` or `file:…{{range}}.pbf`",
glyphs.url
))
}
};
loader.insert_glyphs(key, Arc::new(stack));
}
ezu_style::SourceDecl::Mvt(_)
| ezu_style::SourceDecl::Pmtiles(_)
| ezu_style::SourceDecl::Dem(_)
| ezu_style::SourceDecl::GeoJson(_)
| ezu_style::SourceDecl::Raster(_) => {}
}
}
Ok(())
}
#[cfg(feature = "http")]
async fn read_asset_bytes(src: &str, base_dir: &std::path::Path) -> Result<Vec<u8>, String> {
if is_http_url(src) {
http_bytes(src).await
} else if src.starts_with("data:") {
decode_data_url(src)
.map(|d| d.bytes)
.map_err(|e| e.to_string())
} else if let Some(path) = src.strip_prefix("file:") {
std::fs::read(resolve_file(path, base_dir)).map_err(|e| e.to_string())
} else {
Err(format!(
"unsupported src `{src}` — use `http(s)://URL`, `file:PATH`, or `data:`"
))
}
}
#[cfg(feature = "http")]
async fn read_asset_text(src: &str, base_dir: &std::path::Path) -> Result<String, String> {
if is_http_url(src) {
http_text(src).await
} else if src.starts_with("data:") {
let d = decode_data_url(src).map_err(|e| e.to_string())?;
String::from_utf8(d.bytes).map_err(|e| e.to_string())
} else if let Some(path) = src.strip_prefix("file:") {
std::fs::read_to_string(resolve_file(path, base_dir)).map_err(|e| e.to_string())
} else {
Err(format!(
"unsupported src `{src}` — use `http(s)://URL`, `file:PATH`, or `data:`"
))
}
}
#[cfg(feature = "http")]
fn resolve_file(path: &str, base_dir: &std::path::Path) -> std::path::PathBuf {
let p = std::path::Path::new(path);
if p.is_absolute() {
p.to_path_buf()
} else {
base_dir.join(p)
}
}
#[cfg(feature = "http")]
async fn http_text(url: &str) -> Result<String, String> {
reqwest::get(url)
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?
.text()
.await
.map_err(|e| e.to_string())
}
#[cfg(feature = "http")]
async fn http_bytes(url: &str) -> Result<Vec<u8>, String> {
Ok(reqwest::get(url)
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?
.bytes()
.await
.map_err(|e| e.to_string())?
.to_vec())
}
#[cfg(feature = "http")]
fn is_http_url(s: &str) -> bool {
s.starts_with("http://") || s.starts_with("https://")
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine;
use ezu_features::mvt::DecodedTile;
use ezu_features::{Feature, FeatureLayer, Geometry};
use std::collections::BTreeSet;
fn point_layer(name: &str, pts: &[(i32, i32)]) -> FeatureLayer {
FeatureLayer {
name: name.into(),
extent: 4096,
features: pts
.iter()
.map(|&(x, y)| Feature {
id: None,
geometry: Geometry {
points: vec![(x, y)],
..Default::default()
},
properties: Default::default(),
})
.collect(),
}
}
#[test]
fn neighbor_mvt_binds_under_offset_names() {
let base = BrushBankLoader::new();
let mut loader = TileLoader::new(&base, TileId { z: 3, x: 4, y: 5 });
loader.bind_mvt(
"roads",
DecodedTile {
layers: vec![point_layer("road", &[(10, 20)])],
},
);
loader.bind_mvt_neighbor(
"roads",
1,
0,
DecodedTile {
layers: vec![point_layer("road", &[(30, 40)])],
},
);
assert!(matches!(loader.load("roads.road"), Ok(Asset::Features(_))));
assert!(matches!(
loader.load("roads.road@1,0"),
Ok(Asset::Features(_))
));
assert!(matches!(
loader.load("roads.road@-1,0"),
Err(AssetError::NotFound(_))
));
let Ok(Asset::Features(opq)) = loader.load("roads.road@1,0") else {
panic!("neighbour bound");
};
let shared = opq.downcast::<crate::render::SharedLayer>().unwrap();
assert_eq!(shared.layer.features[0].geometry.points, vec![(30, 40)]);
}
#[test]
fn requested_offsets_filter_by_source() {
let requested: BTreeSet<String> = [
"roads.road", "roads.road@1,0", "roads.road@0,-1", "roads.label@1,0", "water.sea@1,1", "https://h/{range}", ]
.into_iter()
.map(String::from)
.collect();
assert_eq!(
requested_neighbor_offsets(&requested, "roads"),
vec![(0, -1), (1, 0)]
);
assert_eq!(
requested_neighbor_offsets(&requested, "water"),
vec![(1, 1)]
);
assert_eq!(
requested_neighbor_offsets(&requested, "absent"),
Vec::<(i32, i32)>::new()
);
}
fn red_green_png_data_url() -> String {
let mut img = image::RgbaImage::new(2, 1);
img.put_pixel(0, 0, image::Rgba([255, 0, 0, 255]));
img.put_pixel(1, 0, image::Rgba([0, 255, 0, 255]));
let mut png = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(img)
.write_to(&mut png, image::ImageFormat::Png)
.unwrap();
let b64 = base64::engine::general_purpose::STANDARD.encode(png.into_inner());
format!("data:image/png;base64,{b64}")
}
#[test]
fn data_url_image_loads_through_the_asset_loader() {
let src = red_green_png_data_url();
let loader = BrushBankLoader::new();
match loader.load(&src).expect("data url loads") {
Asset::Image(img) => {
assert_eq!((img.width, img.height), (2, 1));
assert_eq!(img.pixel(0, 0), [255, 0, 0, 255]);
assert_eq!(img.pixel(1, 0), [0, 255, 0, 255]);
}
_ => panic!("expected an Image asset from a data:image/png URL"),
}
}
#[test]
fn file_scheme_font_loads_through_the_asset_loader() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../ezu-core/tests/fonts/NotoSans-Regular.latin.ttf");
let src = format!("file:{}", path.display());
let loader = BrushBankLoader::new();
match loader.load(&src).expect("font loads") {
Asset::Font(opq) => {
let font = opq
.downcast::<ezu_core::text::Font>()
.expect("payload is ezu_core::text::Font");
let face = font.face();
assert!(font.covers(&face, 'A'));
assert!(!font.covers(&face, '0')); }
other => panic!("expected a Font asset, got {other:?}"),
}
}
#[test]
fn glyphs_template_loads_lazily_and_hashes_its_ranges() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../ezu-core/tests/glyphs");
let src = format!("file:{}/{{range}}.pbf", dir.display()).replace('\\', "/");
let loader = BrushBankLoader::new();
let Asset::Glyphs(opq) = loader.load(&src).expect("template loads") else {
panic!("expected a Glyphs asset from a {{range}} template");
};
let stack = opq
.downcast::<ezu_core::text::SdfFontStack>()
.expect("payload is an SdfFontStack");
let before = loader.hash(&src);
assert!(!stack.is_loaded(0));
assert!(stack.glyph('A').is_some(), "0-255.pbf fetches on demand");
assert!(stack.is_loaded(0));
assert_ne!(loader.hash(&src), before, "hash must follow the ranges");
let Asset::Glyphs(again) = loader.load(&src).expect("reload") else {
panic!("expected a Glyphs asset");
};
let again = again
.downcast::<ezu_core::text::SdfFontStack>()
.expect("payload is an SdfFontStack");
assert!(Arc::ptr_eq(&stack, &again));
}
#[test]
fn font_magic_is_sniffed_for_data_urls() {
assert!(is_font_magic(&[0x00, 0x01, 0x00, 0x00, 0xff]));
assert!(is_font_magic(b"OTTO...."));
assert!(is_font_magic(b"ttcf...."));
assert!(!is_font_magic(b"\x89PNG\r\n"));
assert!(!is_font_magic(b"{}"));
}
#[test]
fn system_font_parses_family_weight_and_style() {
let q = parse_system_font("Arial Unicode MS").unwrap();
assert_eq!(q.family, "Arial Unicode MS");
assert_eq!(q.weight, 400);
assert_eq!(q.style, SystemFontStyle::Normal);
assert_eq!(
parse_system_font("Arial%20Unicode%20MS").unwrap().family,
"Arial Unicode MS"
);
let q = parse_system_font("Helvetica?weight=700&style=Italic").unwrap();
assert_eq!(q.family, "Helvetica");
assert_eq!(q.weight, 700);
assert_eq!(q.style, SystemFontStyle::Italic);
assert_eq!(
parse_system_font("Noto%20Sans?style=oblique")
.unwrap()
.style,
SystemFontStyle::Oblique
);
}
#[test]
fn system_font_rejects_bad_input() {
assert!(parse_system_font("").is_err());
assert!(parse_system_font("?weight=400").is_err());
assert!(parse_system_font("Helvetica?weight=bold").is_err());
assert!(parse_system_font("Helvetica?weight=50").is_err());
assert!(parse_system_font("Helvetica?weight=1000").is_err());
assert!(parse_system_font("Helvetica?style=slanted").is_err());
assert!(parse_system_font("Helvetica?size=12").is_err());
assert!(parse_system_font("Helvetica?weight").is_err());
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn system_font_resolves_from_a_seeded_fontdb() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../ezu-core/tests/fonts/NotoSans-Regular.latin.ttf");
let bytes = std::fs::read(&path).expect("fixture font readable");
let mut db = fontdb::Database::new();
db.load_font_data(bytes);
let family = db
.faces()
.next()
.and_then(|f| f.families.first().map(|(name, _)| name.clone()))
.expect("one seeded face");
let query = parse_system_font(&family).unwrap();
let font = resolve_system_font(&db, &query).expect("fixture family resolves");
let face = font.face();
assert!(font.covers(&face, 'A'));
let missing = parse_system_font("No Such Family 12345").unwrap();
match resolve_system_font(&db, &missing) {
Err(AssetError::Other(msg)) => assert!(msg.contains("not found"), "{msg}"),
other => panic!("expected a not-found error, got {other:?}"),
}
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn system_font_resolves_whatever_the_host_has() {
let Some(family) = SYSTEM_FONTS
.faces()
.find_map(|f| f.families.first().map(|(name, _)| name.clone()))
else {
eprintln!("no system fonts installed; nothing to resolve");
return;
};
let query = parse_system_font(&family).unwrap();
let font = load_system_font(&query).unwrap_or_else(|e| {
panic!("family '{family}' came from the system database but did not resolve: {e:?}")
});
assert!(
font.units_per_em() > 0.0,
"'{family}' resolved with a degenerate em size"
);
}
#[test]
fn data_url_percent_decoding_and_media_type() {
let d = decode_data_url("data:text/plain,a%20b%2Fc").unwrap();
assert_eq!(d.media_type, "text/plain");
assert_eq!(d.bytes, b"a b/c");
let d = decode_data_url("data:image/PNG;Base64,QUJD").unwrap();
assert_eq!(d.media_type, "image/png");
assert_eq!(d.bytes, b"ABC");
assert!(decode_data_url("data:image/png;base64").is_err());
}
}