diff --git a/Cargo.lock b/Cargo.lock index ca403046d..d3d5586de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -406,6 +406,21 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bitflags" version = "1.3.2" @@ -1138,6 +1153,27 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "freetype-rs" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d59c337e64822dd56a3a83ed75a662a470736bdb3a9fabfb588dff276b94a4e0" +dependencies = [ + "bitflags 1.3.2", + "freetype-sys", + "libc", +] + +[[package]] +name = "freetype-sys" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "643148ca6cbad6bec384b52fbe1968547d578c4efe83109e035c43a71734ff88" +dependencies = [ + "cc", + "libc", +] + [[package]] name = "fs4" version = "0.6.6" @@ -1700,6 +1736,7 @@ dependencies = [ "actix-rt", "actix-web", "async-trait", + "bit-set", "brotli", "cargo-husky", "clap", @@ -1717,6 +1754,7 @@ dependencies = [ "martin-mbtiles", "martin-tile-utils", "num_cpus", + "pbf_font_tools", "pmtiles", "postgis", "postgres", @@ -2035,6 +2073,22 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" +[[package]] +name = "pbf_font_tools" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67768bb2719d708e2de28cec7271dae35c717122c0fa4d9f8558ef5e7fa83db7" +dependencies = [ + "futures", + "glob", + "protobuf", + "protobuf-codegen", + "protoc-bin-vendored", + "sdf_glyph_renderer", + "thiserror", + "tokio", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -2263,6 +2317,107 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "protobuf" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65f4a8ec18723a734e5dc09c173e0abf9690432da5340285d536edcb4dac190" +dependencies = [ + "once_cell", + "protobuf-support", + "thiserror", +] + +[[package]] +name = "protobuf-codegen" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e85514a216b1c73111d9032e26cc7a5ecb1bb3d4d9539e91fb72a4395060f78" +dependencies = [ + "anyhow", + "once_cell", + "protobuf", + "protobuf-parse", + "regex", + "tempfile", + "thiserror", +] + +[[package]] +name = "protobuf-parse" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77d6fbd6697c9e531873e81cec565a85e226b99a0f10e1acc079be057fe2fcba" +dependencies = [ + "anyhow", + "indexmap 1.9.3", + "log", + "protobuf", + "protobuf-support", + "tempfile", + "thiserror", + "which", +] + +[[package]] +name = "protobuf-support" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6872f4d4f4b98303239a2b5838f5bbbb77b01ffc892d627957f37a22d7cfe69c" +dependencies = [ + "thiserror", +] + +[[package]] +name = "protoc-bin-vendored" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005ca8623e5633e298ad1f917d8be0a44bcf406bf3cde3b80e63003e49a3f27d" +dependencies = [ + "protoc-bin-vendored-linux-aarch_64", + "protoc-bin-vendored-linux-ppcle_64", + "protoc-bin-vendored-linux-x86_32", + "protoc-bin-vendored-linux-x86_64", + "protoc-bin-vendored-macos-x86_64", + "protoc-bin-vendored-win32", +] + +[[package]] +name = "protoc-bin-vendored-linux-aarch_64" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fb9fc9cce84c8694b6ea01cc6296617b288b703719b725b8c9c65f7c5874435" + +[[package]] +name = "protoc-bin-vendored-linux-ppcle_64" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d2a07dcf7173a04d49974930ccbfb7fd4d74df30ecfc8762cf2f895a094516" + +[[package]] +name = "protoc-bin-vendored-linux-x86_32" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d54fef0b04fcacba64d1d80eed74a20356d96847da8497a59b0a0a436c9165b0" + +[[package]] +name = "protoc-bin-vendored-linux-x86_64" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8782f2ce7d43a9a5c74ea4936f001e9e8442205c244f7a3d4286bd4c37bc924" + +[[package]] +name = "protoc-bin-vendored-macos-x86_64" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5de656c7ee83f08e0ae5b81792ccfdc1d04e7876b1d9a38e6876a9e09e02537" + +[[package]] +name = "protoc-bin-vendored-win32" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9653c3ed92974e34c5a6e0a510864dab979760481714c172e0a34e437cb98804" + [[package]] name = "quote" version = "1.0.33" @@ -2641,6 +2796,16 @@ dependencies = [ "untrusted", ] +[[package]] +name = "sdf_glyph_renderer" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b05c114d181e20b509e03b05856cc5823bc6189d581c276fe37c5ebc5e3b3b9" +dependencies = [ + "freetype-rs", + "thiserror", +] + [[package]] name = "security-framework" version = "2.9.2" @@ -3793,6 +3958,18 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9193164d4de03a926d909d3bc7c30543cecb35400c02114792c2cae20d5e2dbb" +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix", +] + [[package]] name = "whoami" version = "1.4.1" diff --git a/Cargo.toml b/Cargo.toml index a7ce09837..4c5718548 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ actix-rt = "2" actix-web = "4" anyhow = "1.0" async-trait = "0.1" +bit-set = "0.5.3" brotli = "3" cargo-husky = { version = "1", features = ["user-hooks"], default-features = false } clap = { version = "4", features = ["derive"] } @@ -35,6 +36,7 @@ log = "0.4" martin-mbtiles = { path = "./martin-mbtiles", version = "0.6.0", default-features = false } martin-tile-utils = { path = "./martin-tile-utils", version = "0.1.0" } num_cpus = "1" +pbf_font_tools = { version = "2.5.0", features = ["freetype"] } pmtiles = { version = "0.3", features = ["mmap-async-tokio", "tilejson"] } postgis = "0.9" postgres = { version = "0.19", features = ["with-time-0_3", "with-uuid-1", "with-serde_json-1"] } diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index c306389b3..9f4cd97e5 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -14,6 +14,7 @@ - [MBTiles and PMTiles File Sources](sources-files.md) - [Composite Sources](sources-composite.md) - [Sprite Sources](sources-sprites.md) + - [Font Sources](sources-fonts.md) - [Usage and Endpoint API](using.md) - [Using with MapLibre](using-with-maplibre.md) - [Using with Leaflet](using-with-leaflet.md) diff --git a/docs/src/config-file.md b/docs/src/config-file.md index deaeba64d..b941e9254 100644 --- a/docs/src/config-file.md +++ b/docs/src/config-file.md @@ -183,4 +183,9 @@ sprites: sources: # SVG images in this directory will be published as a "my_sprites" sprite source my_sprites: /path/to/some_dir +# Font configuration +fonts: + # otf, ttf, ttc files will be find recursively + - /path/to/font_dir1 + - /path/to/font_dir2 ``` diff --git a/docs/src/run-with-cli.md b/docs/src/run-with-cli.md index c51d2a139..69f9c25b9 100644 --- a/docs/src/run-with-cli.md +++ b/docs/src/run-with-cli.md @@ -19,6 +19,9 @@ Options: -s, --sprite Export a directory with SVG files as a sprite source. Can be specified multiple times + -f, --font + Export a directory with font files as a font source. Can be specified multiple times + -k, --keep-alive Connection keep alive timeout. [DEFAULT: 75] diff --git a/docs/src/sources-fonts.md b/docs/src/sources-fonts.md new file mode 100644 index 000000000..5fd1dd8a5 --- /dev/null +++ b/docs/src/sources-fonts.md @@ -0,0 +1,29 @@ +## Font Sources + +Martin can serve font assests(`otf`, `ttf`, `ttc`) for map rendering, and there is no need to supply a large number of small pre-generated font protobuf files. Martin can generate them dynamically on the fly based on your request. + +## API +You can request font protobuf of single or combination of fonts. + +||API|Demo| +|----|----|----| +|Single|/font/{fontstack}/{start}-{end}|http://127.0.0.1:3000/font/Overpass Mono Bold/0-255| +|Combination|/font/{fontstack1},{fontstack2},{fontstack_n}/{start}-{end}|http://127.0.0.1:3000/font/Overpass Mono Bold,Overpass Mono Light/0-255| + +## Configuring from CLI +A font directory can be configured from the [CLI](run-with-cli.md) with the `--font` flag. The flag can be used multiple times to configure multiple font directories. + +```shell +martin --font /path/to/font_dir1 --font /path/to/font_dir2 +``` + +## Configuring from Config File + +A font directory can be configured from the config file with the `fonts` key. + +```yaml +# Fonts configuration +fonts: + - /path/to/fonts_dir1 + - /path/to/fonts_dir2 +``` \ No newline at end of file diff --git a/martin/Cargo.toml b/martin/Cargo.toml index 800b24139..d8ea48058 100644 --- a/martin/Cargo.toml +++ b/martin/Cargo.toml @@ -56,6 +56,7 @@ actix-http.workspace = true actix-rt.workspace = true actix-web.workspace = true async-trait.workspace = true +bit-set.workspace = true brotli.workspace = true clap.workspace = true deadpool-postgres.workspace = true @@ -68,6 +69,7 @@ log.workspace = true martin-mbtiles.workspace = true martin-tile-utils.workspace = true num_cpus.workspace = true +pbf_font_tools.workspace = true pmtiles.workspace = true postgis.workspace = true postgres-protocol.workspace = true diff --git a/martin/src/args/root.rs b/martin/src/args/root.rs index 98c82560b..90a6b0cf9 100644 --- a/martin/src/args/root.rs +++ b/martin/src/args/root.rs @@ -10,7 +10,7 @@ use crate::args::srv::SrvArgs; use crate::args::State::{Ignore, Share, Take}; use crate::config::Config; use crate::file_config::FileConfigEnum; -use crate::{Error, Result}; +use crate::{Error, OptOneMany, Result}; #[derive(Parser, Debug, PartialEq, Default)] #[command(about, version)] @@ -44,6 +44,9 @@ pub struct MetaArgs { /// Export a directory with SVG files as a sprite source. Can be specified multiple times. #[arg(short, long)] pub sprite: Vec, + /// Export a directory with font files as a font source. Can be specified multiple times. + #[arg(short, long)] + pub font: Vec, } impl Args { @@ -81,6 +84,10 @@ impl Args { config.sprites = FileConfigEnum::new(self.meta.sprite); } + if !self.meta.font.is_empty() { + config.fonts = OptOneMany::new(self.meta.font); + } + cli_strings.check() } } diff --git a/martin/src/config.rs b/martin/src/config.rs index 90005d033..625485e01 100644 --- a/martin/src/config.rs +++ b/martin/src/config.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::fs::File; use std::future::Future; use std::io::prelude::*; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::pin::Pin; use futures::future::try_join_all; @@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize}; use subst::VariableMap; use crate::file_config::{resolve_files, FileConfigEnum}; +use crate::fonts::FontSources; use crate::mbtiles::MbtSource; use crate::pg::PgConfig; use crate::pmtiles::PmtSource; @@ -24,6 +25,7 @@ pub type UnrecognizedValues = HashMap; pub struct ServerState { pub tiles: TileSources, pub sprites: SpriteSources, + pub fonts: FontSources, } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] @@ -43,6 +45,9 @@ pub struct Config { #[serde(default, skip_serializing_if = "FileConfigEnum::is_none")] pub sprites: FileConfigEnum, + #[serde(default, skip_serializing_if = "OptOneMany::is_none")] + pub fonts: OptOneMany, + #[serde(flatten)] pub unrecognized: UnrecognizedValues, } @@ -61,10 +66,14 @@ impl Config { res.extend(self.mbtiles.finalize("mbtiles.")?); res.extend(self.sprites.finalize("sprites.")?); + // TODO: support for unrecognized fonts? + // res.extend(self.fonts.finalize("fonts.")?); + if self.postgres.is_empty() && self.pmtiles.is_empty() && self.mbtiles.is_empty() && self.sprites.is_empty() + && self.fonts.is_empty() { Err(NoSources) } else { @@ -76,6 +85,7 @@ impl Config { Ok(ServerState { tiles: self.resolve_tile_sources(idr).await?, sprites: SpriteSources::resolve(&mut self.sprites)?, + fonts: FontSources::resolve(&mut self.fonts)?, }) } diff --git a/martin/src/fonts/mod.rs b/martin/src/fonts/mod.rs new file mode 100644 index 000000000..1d0e8a59f --- /dev/null +++ b/martin/src/fonts/mod.rs @@ -0,0 +1,359 @@ +use std::collections::hash_map::Entry; +use std::collections::{BTreeMap, HashMap}; +use std::ffi::OsStr; +use std::fmt::Debug; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use bit_set::BitSet; +use itertools::Itertools; +use log::{debug, info, warn}; +use pbf_font_tools::freetype::{Face, Library}; +use pbf_font_tools::protobuf::Message; +use pbf_font_tools::{render_sdf_glyph, Fontstack, Glyphs, PbfFontError}; +use regex::Regex; +use serde::{Deserialize, Serialize}; + +use crate::fonts::FontError::IoError; +use crate::OptOneMany; + +const MAX_UNICODE_CP: usize = 0xFFFF; +const CP_RANGE_SIZE: usize = 256; +const FONT_SIZE: usize = 24; +#[allow(clippy::cast_possible_wrap)] +const CHAR_HEIGHT: isize = (FONT_SIZE as isize) << 6; +const BUFFER_SIZE: usize = 3; +const RADIUS: usize = 8; +const CUTOFF: f64 = 0.25_f64; + +/// Each range is 256 codepoints long, so the highest range ID is 0xFFFF / 256 = 255. +const MAX_UNICODE_CP_RANGE_ID: usize = MAX_UNICODE_CP / CP_RANGE_SIZE; + +#[derive(thiserror::Error, Debug)] +pub enum FontError { + #[error("Font {0} not found")] + FontNotFound(String), + + #[error("Font range start ({0}) must be <= end ({1})")] + InvalidFontRangeStartEnd(u32, u32), + + #[error("Font range start ({0}) must be multiple of {CP_RANGE_SIZE} (e.g. 0, 256, 512, ...)")] + InvalidFontRangeStart(u32), + + #[error( + "Font range end ({0}) must be multiple of {CP_RANGE_SIZE} - 1 (e.g. 255, 511, 767, ...)" + )] + InvalidFontRangeEnd(u32), + + #[error("Given font range {0}-{1} is invalid. It must be {CP_RANGE_SIZE} characters long (e.g. 0-255, 256-511, ...)")] + InvalidFontRange(u32, u32), + + #[error("FreeType font error: {0}")] + FreeType(#[from] pbf_font_tools::freetype::Error), + + #[error("IO error accessing {}: {0}", .1.display())] + IoError(std::io::Error, PathBuf), + + #[error("Font {0} uses bad file {}", .1.display())] + InvalidFontFilePath(String, PathBuf), + + #[error("No font files found in {}", .0.display())] + NoFontFilesFound(PathBuf), + + #[error("Font {} could not be loaded", .0.display())] + UnableToReadFont(PathBuf), + + #[error("{0} in file {}", .1.display())] + FontProcessingError(spreet::error::Error, PathBuf), + + #[error("Font {0} is missing a family name")] + MissingFamilyName(PathBuf), + + #[error("PBF Font error: {0}")] + PbfFontError(#[from] PbfFontError), + + #[error("Error serializing protobuf: {0}")] + ErrorSerializingProtobuf(#[from] pbf_font_tools::protobuf::Error), +} + +fn recurse_dirs( + lib: &Library, + path: &Path, + fonts: &mut HashMap, +) -> Result<(), FontError> { + static RE_SPACES: OnceLock = OnceLock::new(); + + for dir_entry in path + .read_dir() + .map_err(|e| IoError(e, path.to_path_buf()))? + .flatten() + { + let path = dir_entry.path(); + + if path.is_dir() { + recurse_dirs(lib, &path, fonts)?; + continue; + } + + if !path + .extension() + .and_then(OsStr::to_str) + .is_some_and(|e| ["otf", "ttf", "ttc"].contains(&e)) + { + continue; + } + + let mut face = lib.new_face(&path, 0)?; + let num_faces = face.num_faces() as isize; + for face_index in 0..num_faces { + if face_index > 0 { + face = lib.new_face(&path, face_index)?; + } + let Some(family) = face.family_name() else { + return Err(FontError::MissingFamilyName(path.clone())); + }; + let mut name = family.clone(); + let style = face.style_name(); + if let Some(style) = &style { + name.push(' '); + name.push_str(style); + } + // Make sure font name has no slashes or commas, replacing them with spaces and de-duplicating spaces + name = name.replace(['/', ','], " "); + name = RE_SPACES + .get_or_init(|| Regex::new(r"\s+").unwrap()) + .replace_all(name.as_str(), " ") + .to_string(); + + match fonts.entry(name) { + Entry::Occupied(v) => { + warn!("Ignoring duplicate font source {} from {} because it was already configured for {}", + v.key(), path.display(), v.get().path.display()); + } + Entry::Vacant(v) => { + let key = v.key(); + let Some((codepoints, glyphs, ranges)) = get_available_codepoints(&mut face) + else { + warn!( + "Ignoring font source {key} from {} because it has no available glyphs", + path.display() + ); + continue; + }; + + let start = ranges.first().map(|(s, _)| *s).unwrap(); + let end = ranges.last().map(|(_, e)| *e).unwrap(); + info!( + "Configured font source {key} with {glyphs} glyphs ({start:04X}-{end:04X}) from {}", + path.display() + ); + debug!( + "Available font ranges: {}", + ranges + .iter() + .map(|(s, e)| if s == e { + format!("{s:02X}") + } else { + format!("{s:02X}-{e:02X}") + }) + .collect::>() + .join(", "), + ); + + v.insert(FontSource { + path: path.clone(), + face_index, + codepoints, + catalog_entry: CatalogFontEntry { + family, + style, + glyphs, + start, + end, + }, + }); + } + } + } + } + + Ok(()) +} + +type GetGlyphInfo = (BitSet, usize, Vec<(usize, usize)>); + +fn get_available_codepoints(face: &mut Face) -> Option { + let mut codepoints = BitSet::with_capacity(MAX_UNICODE_CP); + let mut spans = Vec::new(); + let mut first: Option = None; + let mut count = 0; + + for cp in 0..=MAX_UNICODE_CP { + if face.get_char_index(cp) != 0 { + codepoints.insert(cp); + count += 1; + if first.is_none() { + first = Some(cp); + } + } else if let Some(start) = first { + spans.push((start, cp - 1)); + first = None; + } + } + + if count == 0 { + None + } else { + Some((codepoints, count, spans)) + } +} + +#[derive(Debug, Clone, Default)] +pub struct FontSources { + fonts: HashMap, + masks: Vec, +} + +pub type FontCatalog = BTreeMap; + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct CatalogFontEntry { + pub family: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub style: Option, + pub glyphs: usize, + pub start: usize, + pub end: usize, +} + +impl FontSources { + pub fn resolve(config: &mut OptOneMany) -> Result { + if config.is_empty() { + return Ok(Self::default()); + } + + let mut fonts = HashMap::new(); + let lib = Library::init()?; + + for path in config.iter() { + let disp_path = path.display(); + if path.exists() { + recurse_dirs(&lib, path, &mut fonts)?; + } else { + warn!("Ignoring non-existent font source {disp_path}"); + }; + } + + let mut masks = Vec::with_capacity(MAX_UNICODE_CP_RANGE_ID + 1); + + let mut bs = BitSet::with_capacity(CP_RANGE_SIZE); + for v in 0..=MAX_UNICODE_CP { + bs.insert(v); + if v % CP_RANGE_SIZE == (CP_RANGE_SIZE - 1) { + masks.push(bs); + bs = BitSet::with_capacity(CP_RANGE_SIZE); + } + } + + Ok(Self { fonts, masks }) + } + + #[must_use] + pub fn get_catalog(&self) -> FontCatalog { + self.fonts + .iter() + .map(|(k, v)| (k.clone(), v.catalog_entry.clone())) + .sorted_by(|(a, _), (b, _)| a.cmp(b)) + .collect() + } + + /// Given a list of IDs in a format "id1,id2,id3", return a combined font. + #[allow(clippy::cast_possible_truncation)] + pub fn get_font_range(&self, ids: &str, start: u32, end: u32) -> Result, FontError> { + if start > end { + return Err(FontError::InvalidFontRangeStartEnd(start, end)); + } + if start % (CP_RANGE_SIZE as u32) != 0 { + return Err(FontError::InvalidFontRangeStart(start)); + } + if end % (CP_RANGE_SIZE as u32) != (CP_RANGE_SIZE as u32 - 1) { + return Err(FontError::InvalidFontRangeEnd(end)); + } + if (end - start) != (CP_RANGE_SIZE as u32 - 1) { + return Err(FontError::InvalidFontRange(start, end)); + } + + let mut needed = self.masks[(start as usize) / CP_RANGE_SIZE].clone(); + let fonts = ids + .split(',') + .filter_map(|id| match self.fonts.get(id) { + None => Some(Err(FontError::FontNotFound(id.to_string()))), + Some(v) => { + let mut ds = needed.clone(); + ds.intersect_with(&v.codepoints); + if ds.is_empty() { + None + } else { + needed.difference_with(&v.codepoints); + Some(Ok((id, v, ds))) + } + } + }) + .collect::, FontError>>()?; + + if fonts.is_empty() { + return Ok(Vec::new()); + } + + let lib = Library::init()?; + let mut stack = Fontstack::new(); + + for (id, font, ds) in fonts { + if stack.has_name() { + let name = stack.mut_name(); + name.push_str(", "); + name.push_str(id); + } else { + stack.set_name(id.to_string()); + } + + let face = lib.new_face(&font.path, font.face_index)?; + + // FreeType conventions: char width or height of zero means "use the same value" + // and setting both resolution values to zero results in the default value + // of 72 dpi. + // + // See https://www.freetype.org/freetype2/docs/reference/ft2-base_interface.html#ft_set_char_size + // and https://www.freetype.org/freetype2/docs/tutorial/step1.html for details. + face.set_char_size(0, CHAR_HEIGHT, 0, 0)?; + + for cp in &ds { + let glyph = render_sdf_glyph(&face, cp as u32, BUFFER_SIZE, RADIUS, CUTOFF)?; + stack.glyphs.push(glyph); + } + } + + stack.set_range(format!("{start}-{end}")); + + let mut glyphs = Glyphs::new(); + glyphs.stacks.push(stack); + let mut result = Vec::new(); + glyphs.write_to_vec(&mut result)?; + Ok(result) + } +} + +#[derive(Clone, Debug)] +pub struct FontSource { + path: PathBuf, + face_index: isize, + codepoints: BitSet, + catalog_entry: CatalogFontEntry, +} + +// #[cfg(test)] +// mod tests { +// use std::path::PathBuf; +// +// use super::*; +// } diff --git a/martin/src/lib.rs b/martin/src/lib.rs index 8903799d0..827fa036a 100644 --- a/martin/src/lib.rs +++ b/martin/src/lib.rs @@ -11,6 +11,7 @@ pub mod args; mod config; pub mod file_config; +pub mod fonts; pub mod mbtiles; pub mod pg; pub mod pmtiles; diff --git a/martin/src/srv/server.rs b/martin/src/srv/server.rs index e6c740d85..df755853e 100755 --- a/martin/src/srv/server.rs +++ b/martin/src/srv/server.rs @@ -24,6 +24,7 @@ use serde::{Deserialize, Serialize}; use tilejson::{tilejson, TileJSON}; use crate::config::ServerState; +use crate::fonts::{FontCatalog, FontError, FontSources}; use crate::source::{Source, TileCatalog, TileSources, UrlQuery}; use crate::sprites::{SpriteCatalog, SpriteError, SpriteSources}; use crate::srv::config::{SrvConfig, KEEP_ALIVE_DEFAULT, LISTEN_ADDRESSES_DEFAULT}; @@ -49,6 +50,7 @@ static SUPPORTED_ENCODINGS: &[HeaderEnc] = &[ pub struct Catalog { pub tiles: TileCatalog, pub sprites: SpriteCatalog, + pub fonts: FontCatalog, } impl Catalog { @@ -56,6 +58,7 @@ impl Catalog { Ok(Self { tiles: state.tiles.get_catalog(), sprites: state.sprites.get_catalog()?, + fonts: state.fonts.get_catalog(), }) } } @@ -86,6 +89,19 @@ pub fn map_sprite_error(e: SpriteError) -> actix_web::Error { } } +pub fn map_font_error(e: FontError) -> actix_web::Error { + #[allow(clippy::enum_glob_use)] + use FontError::*; + match e { + FontNotFound(_) => ErrorNotFound(e.to_string()), + InvalidFontRangeStartEnd(_, _) + | InvalidFontRangeStart(_) + | InvalidFontRangeEnd(_) + | InvalidFontRange(_, _) => ErrorBadRequest(e.to_string()), + _ => map_internal_error(e), + } +} + /// Root path will eventually have a web front. For now, just a stub. #[route("/", method = "GET", method = "HEAD")] #[allow(clippy::unused_async)] @@ -147,6 +163,28 @@ async fn get_sprite_json( Ok(HttpResponse::Ok().json(sheet.get_index())) } +#[derive(Deserialize, Debug)] +struct FontRequest { + fontstack: String, + start: u32, + end: u32, +} + +#[route( + "/font/{fontstack}/{start}-{end}", + method = "GET", + wrap = "middleware::Compress::default()" +)] +#[allow(clippy::unused_async)] +async fn get_font(path: Path, fonts: Data) -> Result { + let data = fonts + .get_font_range(&path.fontstack, path.start, path.end) + .map_err(map_font_error)?; + Ok(HttpResponse::Ok() + .content_type("application/x-protobuf") + .body(data)) +} + #[route( "/{source_ids}", method = "GET", @@ -427,7 +465,8 @@ pub fn router(cfg: &mut web::ServiceConfig) { .service(git_source_info) .service(get_tile) .service(get_sprite_json) - .service(get_sprite_png); + .service(get_sprite_png) + .service(get_font); } /// Create a new initialized Actix `App` instance together with the listening address. @@ -447,6 +486,7 @@ pub fn new_server(config: SrvConfig, state: ServerState) -> crate::Result<(Serve App::new() .app_data(Data::new(state.tiles.clone())) .app_data(Data::new(state.sprites.clone())) + .app_data(Data::new(state.fonts.clone())) .app_data(Data::new(catalog.clone())) .wrap(cors_middleware) .wrap(middleware::NormalizePath::new(TrailingSlash::MergeOnly)) diff --git a/martin/src/utils/error.rs b/martin/src/utils/error.rs index a06ddaf6a..bc28e4efc 100644 --- a/martin/src/utils/error.rs +++ b/martin/src/utils/error.rs @@ -3,6 +3,7 @@ use std::io; use std::path::PathBuf; use crate::file_config::FileError; +use crate::fonts::FontError; use crate::pg::PgError; use crate::sprites::SpriteError; @@ -59,4 +60,7 @@ pub enum Error { #[error("{0}")] SpriteError(#[from] SpriteError), + + #[error("{0}")] + FontError(#[from] FontError), } diff --git a/martin/tests/mb_server_test.rs b/martin/tests/mb_server_test.rs index 31f3177ef..046920f24 100644 --- a/martin/tests/mb_server_test.rs +++ b/martin/tests/mb_server_test.rs @@ -69,6 +69,7 @@ async fn mbt_get_catalog() { content_type: image/webp name: ne2sr sprites: {} + fonts: {} "###); } @@ -100,6 +101,7 @@ async fn mbt_get_catalog_gzip() { content_type: image/webp name: ne2sr sprites: {} + fonts: {} "###); } diff --git a/martin/tests/pg_server_test.rs b/martin/tests/pg_server_test.rs index 341e9aabc..3a48728a3 100644 --- a/martin/tests/pg_server_test.rs +++ b/martin/tests/pg_server_test.rs @@ -115,6 +115,7 @@ postgres: content_type: application/x-protobuf description: public.table_source_multiple_geom.geom2 sprites: {} + fonts: {} "###); } diff --git a/martin/tests/pmt_server_test.rs b/martin/tests/pmt_server_test.rs index 3ed778313..b9f89628d 100644 --- a/martin/tests/pmt_server_test.rs +++ b/martin/tests/pmt_server_test.rs @@ -54,6 +54,7 @@ async fn pmt_get_catalog() { stamen_toner__raster_CC-BY-ODbL_z3: content_type: image/png sprites: {} + fonts: {} "###); } @@ -72,6 +73,7 @@ async fn pmt_get_catalog_gzip() { p_png: content_type: image/png sprites: {} + fonts: {} "###); } diff --git a/tests/expected/auto/catalog_auto.json b/tests/expected/auto/catalog_auto.json index 3ce4162f5..59082de5e 100644 --- a/tests/expected/auto/catalog_auto.json +++ b/tests/expected/auto/catalog_auto.json @@ -163,5 +163,6 @@ "description": "Major cities from Natural Earth data" } }, - "sprites": {} + "sprites": {}, + "fonts": {} } diff --git a/tests/expected/configured/catalog_cfg.json b/tests/expected/configured/catalog_cfg.json index 2fb48ab5d..b6cee7284 100644 --- a/tests/expected/configured/catalog_cfg.json +++ b/tests/expected/configured/catalog_cfg.json @@ -53,5 +53,6 @@ "sub/circle" ] } - } + }, + "fonts": {} }