diff --git a/Cargo.lock b/Cargo.lock index f868394d6..22b36fc1a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -407,6 +407,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" @@ -1139,6 +1154,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" @@ -1701,6 +1737,7 @@ dependencies = [ "actix-rt", "actix-web", "async-trait", + "bit-set", "brotli", "cargo-husky", "clap", @@ -1718,6 +1755,7 @@ dependencies = [ "martin-mbtiles", "martin-tile-utils", "num_cpus", + "pbf_font_tools", "pmtiles", "postgis", "postgres", @@ -2036,6 +2074,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" @@ -2264,6 +2318,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" @@ -2654,6 +2809,16 @@ dependencies = [ "untrusted 0.9.0", ] +[[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" @@ -3812,6 +3977,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/debian/config.yaml b/debian/config.yaml index d9289352a..f59118cbf 100644 --- a/debian/config.yaml +++ b/debian/config.yaml @@ -29,3 +29,7 @@ worker_processes: 8 # - /path/to/mbtiles.mbtiles # sources: # mb-src1: /path/to/mbtiles1.mbtiles + +# fonts: +# - /path/to/font/file.ttf +# - /path/to/font_dir diff --git a/docs/src/21-run-with-cli.md b/docs/src/21-run-with-cli.md index 3354d91d0..c7c48487a 100644 --- a/docs/src/21-run-with-cli.md +++ b/docs/src/21-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 font file or a directory with font files as a font source (recursive). Can be specified multiple times + -k, --keep-alive Connection keep alive timeout. [DEFAULT: 75] @@ -28,7 +31,7 @@ Options: -W, --workers Number of web server workers - -b, --auto-bounds + -b, --auto-bounds Specify how bounds should be computed for the spatial PG tables. [DEFAULT: quick] Possible values: diff --git a/docs/src/30-config-file.md b/docs/src/30-config-file.md index deaeba64d..a4df42845 100644 --- a/docs/src/30-config-file.md +++ b/docs/src/30-config-file.md @@ -183,4 +183,10 @@ 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: + # A list of *.otf, *.ttf, and *.ttc font files and dirs to search recursively. + - /path/to/font/file.ttf + - /path/to/font_dir ``` diff --git a/docs/src/36-sources-sprites.md b/docs/src/36-sources-sprites.md index 76b742028..4cd713996 100644 --- a/docs/src/36-sources-sprites.md +++ b/docs/src/36-sources-sprites.md @@ -1,6 +1,6 @@ ## Sprite Sources -Given a directory with SVG images, Martin will generate a sprite -- a JSON index and a PNG image, for both low and high resolution displays. The SVG filenames without extension will be used as the sprite image IDs. The images are searched recursively in the given directory, so subdirectory names will be used as prefixes for the image IDs, e.g. `icons/bicycle.svg` will be available as `icons/bicycle` sprite image. +Given a directory with SVG images, Martin will generate a sprite -- a JSON index and a PNG image, for both low and high resolution displays. The SVG filenames without extension will be used as the sprite image IDs. The images are searched recursively in the given directory, so subdirectory names will be used as prefixes for the image IDs, e.g. `icons/bicycle.svg` will be available as `icons/bicycle` sprite image. The sprite generation is not yet cached, and may require external reverse proxy or CDN for faster operation. ### API Martin uses [MapLibre sprites API](https://maplibre.org/maplibre-style-spec/sprite/) specification to serve sprites via several endpoints. The sprite image and index are generated on the fly, so if the sprite directory is updated, the changes will be reflected immediately. diff --git a/docs/src/37-sources-fonts.md b/docs/src/37-sources-fonts.md new file mode 100644 index 000000000..797d0b8c2 --- /dev/null +++ b/docs/src/37-sources-fonts.md @@ -0,0 +1,65 @@ +## Font Sources + +Martin can serve glyph ranges from `otf`, `ttf`, and `ttc` fonts as needed by MapLibre text rendering. Martin will generate them dynamically on the fly. +The glyph range generation is not yet cached, and may require external reverse proxy or CDN for faster operation. + +## API +Fonts ranges are available either for a single font, or a combination of multiple fonts. The font names are case-sensitive and should match the font name in the font file as published in the catalog. Make sure to URL-escape font names as they usually contain spaces. + +When combining multiple fonts, the glyph range will contain glyphs from the first listed font if available, and fallback to the next font if the glyph is not available in the first font, etc. The glyph range will be empty if none of the fonts contain the glyph. + +| Type | API | Example | +|----------|------------------------------------------------|--------------------------------------------------------------| +| Single | `/font/{name}/{start}-{end}` | `/font/Overpass%20Mono%20Bold/0-255` | +| Combined | `/font/{name1},{name2},{name_n}/{start}-{end}` | `/font/Overpass%20Mono%20Bold,Overpass%20Mono%20Light/0-255` | + +Martin will show all available fonts at the `/catalog` endpoint. + +```shell +curl http://127.0.0.1:3000/catalog +{ + "fonts": { + "Overpass Mono Bold": { + "family": "Overpass Mono", + "style": "Bold", + "glyphs": 931, + "start": 0, + "end": 64258 + }, + "Overpass Mono Light": { + "family": "Overpass Mono", + "style": "Light", + "glyphs": 931, + "start": 0, + "end": 64258 + }, + "Overpass Mono SemiBold": { + "family": "Overpass Mono", + "style": "SemiBold", + "glyphs": 931, + "start": 0, + "end": 64258 + } + } +} +``` + +## Using from CLI + +A font file or directory can be configured from the [CLI](21-run-with-cli.md) with one or more `--font` parameters. + +```shell +martin --font /path/to/font/file.ttf --font /path/to/font_dir +``` + +## Configuring from Config File + +A font directory can be configured from the config file with the `fonts` key. + +```yaml +# Fonts configuration +fonts: + # A list of *.otf, *.ttf, and *.ttc font files and dirs to search recursively. + - /path/to/font/file.ttf + - /path/to/font_dir +``` diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index b63349be0..018f59937 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -14,6 +14,7 @@ - [MBTiles and PMTiles File Sources](34-sources-files.md) - [Composite Sources](35-sources-composite.md) - [Sprite Sources](36-sources-sprites.md) + - [Font Sources](37-sources-fonts.md) - [Usage and Endpoint API](40-using-endpoints.md) - [Using with MapLibre](41-using-with-maplibre.md) - [Using with Leaflet](42-using-with-leaflet.md) diff --git a/martin/Cargo.toml b/martin/Cargo.toml index 41dce6fcd..08e7d06c2 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..fe15d2494 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 font file or a directory with font files as a font source (recursive). 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..f81540dcb --- /dev/null +++ b/martin/src/fonts/mod.rs @@ -0,0 +1,365 @@ +use std::collections::hash_map::Entry; +use std::collections::{BTreeMap, HashMap}; +use std::ffi::OsStr; +use std::fmt::Debug; +use std::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("Invalid font file {}", .0.display())] + InvalidFontFilePath(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), +} + +type GetGlyphInfo = (BitSet, usize, Vec<(usize, usize)>, 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 { + let start = spans[0].0; + let end = spans[spans.len() - 1].1; + Some((codepoints, count, spans, start, end)) + } +} + +#[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() { + recurse_dirs(&lib, path.clone(), &mut fonts, true)?; + } + + 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, +} + +fn recurse_dirs( + lib: &Library, + path: PathBuf, + fonts: &mut HashMap, + is_top_level: bool, +) -> Result<(), FontError> { + let start_count = fonts.len(); + if path.is_dir() { + for dir_entry in path + .read_dir() + .map_err(|e| IoError(e, path.clone()))? + .flatten() + { + recurse_dirs(lib, dir_entry.path(), fonts, false)?; + } + if is_top_level && fonts.len() == start_count { + return Err(FontError::NoFontFilesFound(path)); + } + } else { + if path + .extension() + .and_then(OsStr::to_str) + .is_some_and(|e| ["otf", "ttf", "ttc"].contains(&e)) + { + parse_font(lib, fonts, path.clone())?; + } + if is_top_level && fonts.len() == start_count { + return Err(FontError::InvalidFontFilePath(path)); + } + } + + Ok(()) +} + +fn parse_font( + lib: &Library, + fonts: &mut HashMap, + path: PathBuf, +) -> Result<(), FontError> { + static RE_SPACES: OnceLock = OnceLock::new(); + + 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)); + }; + 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 {} from {} because it was already configured from {}", + v.key(), + path.display(), + v.get().path.display() + ); + } + Entry::Vacant(v) => { + let key = v.key(); + let Some((codepoints, glyphs, ranges, start, end)) = + get_available_codepoints(&mut face) + else { + warn!( + "Ignoring font {key} from {} because it has no available glyphs", + path.display() + ); + continue; + }; + + info!( + "Configured font {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(()) +} 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/config.yaml b/tests/config.yaml index 471f31428..cecc48aa7 100644 --- a/tests/config.yaml +++ b/tests/config.yaml @@ -168,3 +168,7 @@ sprites: paths: tests/fixtures/sprites/src1 sources: mysrc: tests/fixtures/sprites/src2 + +fonts: + - tests/fixtures/fonts/overpass-mono-regular.ttf + - tests/fixtures/fonts diff --git a/tests/expected/auto/catalog_auto.json b/tests/expected/auto/catalog_auto.json index 3ce4162f5..fd502f77f 100644 --- a/tests/expected/auto/catalog_auto.json +++ b/tests/expected/auto/catalog_auto.json @@ -163,5 +163,29 @@ "description": "Major cities from Natural Earth data" } }, - "sprites": {} + "sprites": { + "src1": { + "images": [ + "another_bicycle", + "bear", + "sub/circle" + ] + } + }, + "fonts": { + "Overpass Mono Light": { + "family": "Overpass Mono", + "style": "Light", + "glyphs": 931, + "start": 0, + "end": 64258 + }, + "Overpass Mono Regular": { + "family": "Overpass Mono", + "style": "Regular", + "glyphs": 931, + "start": 0, + "end": 64258 + } + } } diff --git a/tests/expected/configured/catalog_cfg.json b/tests/expected/configured/catalog_cfg.json index 2fb48ab5d..810687e76 100644 --- a/tests/expected/configured/catalog_cfg.json +++ b/tests/expected/configured/catalog_cfg.json @@ -53,5 +53,21 @@ "sub/circle" ] } + }, + "fonts": { + "Overpass Mono Light": { + "family": "Overpass Mono", + "style": "Light", + "glyphs": 931, + "start": 0, + "end": 64258 + }, + "Overpass Mono Regular": { + "family": "Overpass Mono", + "style": "Regular", + "glyphs": 931, + "start": 0, + "end": 64258 + } } } diff --git a/tests/expected/configured/font_1.pbf b/tests/expected/configured/font_1.pbf new file mode 100644 index 000000000..bb3447323 Binary files /dev/null and b/tests/expected/configured/font_1.pbf differ diff --git a/tests/expected/configured/font_2.pbf b/tests/expected/configured/font_2.pbf new file mode 100644 index 000000000..f57bcc48c Binary files /dev/null and b/tests/expected/configured/font_2.pbf differ diff --git a/tests/expected/configured/font_3.pbf b/tests/expected/configured/font_3.pbf new file mode 100644 index 000000000..f57bcc48c Binary files /dev/null and b/tests/expected/configured/font_3.pbf differ diff --git a/tests/expected/generated_config.yaml b/tests/expected/generated_config.yaml index 50bf43f96..435d52f41 100644 --- a/tests/expected/generated_config.yaml +++ b/tests/expected/generated_config.yaml @@ -212,3 +212,7 @@ mbtiles: world_cities_diff: tests/fixtures/mbtiles/world_cities_diff.mbtiles world_cities_modified: tests/fixtures/mbtiles/world_cities_modified.mbtiles zoomed_world_cities: tests/fixtures/mbtiles/zoomed_world_cities.mbtiles +sprites: tests/fixtures/sprites/src1 +fonts: +- tests/fixtures/fonts/overpass-mono-regular.ttf +- tests/fixtures/fonts diff --git a/tests/expected/given_config.yaml b/tests/expected/given_config.yaml index 6ba51282e..64291515e 100644 --- a/tests/expected/given_config.yaml +++ b/tests/expected/given_config.yaml @@ -164,3 +164,6 @@ sprites: paths: tests/fixtures/sprites/src1 sources: mysrc: tests/fixtures/sprites/src2 +fonts: +- tests/fixtures/fonts/overpass-mono-regular.ttf +- tests/fixtures/fonts diff --git a/tests/fixtures/fonts/overpass-mono-regular.ttf b/tests/fixtures/fonts/overpass-mono-regular.ttf new file mode 100755 index 000000000..107fe320d Binary files /dev/null and b/tests/fixtures/fonts/overpass-mono-regular.ttf differ diff --git a/tests/fixtures/fonts/sub_dir/overpass-mono-light.otf b/tests/fixtures/fonts/sub_dir/overpass-mono-light.otf new file mode 100755 index 000000000..64e2932a5 Binary files /dev/null and b/tests/fixtures/fonts/sub_dir/overpass-mono-light.otf differ diff --git a/tests/test.sh b/tests/test.sh index 2ff631790..e620e19ee 100755 --- a/tests/test.sh +++ b/tests/test.sh @@ -97,6 +97,15 @@ test_png() fi } +test_font() +{ + FILENAME="$TEST_OUT_DIR/$1.pbf" + URL="$MARTIN_URL/$2" + + echo "Testing $(basename "$FILENAME") from $URL" + $CURL "$URL" > "$FILENAME" +} + # Delete a line from a file $1 that matches parameter $2 remove_line() { @@ -127,6 +136,7 @@ validate_log() # Make sure the log has just the expected warnings, remove them, and test that there are no other ones test_log_has_str "$LOG_FILE" 'WARN martin::pg::table_source] Table public.table_source has no spatial index on column geom' + test_log_has_str "$LOG_FILE" 'WARN martin::fonts] Ignoring duplicate font Overpass Mono Regular from tests/fixtures/fonts/overpass-mono-regular.ttf because it was already configured from tests/fixtures/fonts/overpass-mono-regular.ttf' echo "Checking for no other warnings or errors in the log" if grep -e ' ERROR ' -e ' WARN ' "$LOG_FILE"; then @@ -153,7 +163,7 @@ echo "Test auto configured Martin" TEST_OUT_DIR="$(dirname "$0")/output/auto" mkdir -p "$TEST_OUT_DIR" -ARG=(--default-srid 900913 --auto-bounds calc --save-config "$(dirname "$0")/output/generated_config.yaml" tests/fixtures/mbtiles tests/fixtures/pmtiles) +ARG=(--default-srid 900913 --auto-bounds calc --save-config "$(dirname "$0")/output/generated_config.yaml" tests/fixtures/mbtiles tests/fixtures/pmtiles --sprite tests/fixtures/sprites/src1 --font tests/fixtures/fonts/overpass-mono-regular.ttf --font tests/fixtures/fonts) set -x $MARTIN_BIN "${ARG[@]}" 2>&1 | tee "${TMP_DIR}/test_log_1.txt" & PROCESS_ID=`jobs -p` @@ -268,6 +278,10 @@ test_png spr_cmp sprite/src1,mysrc.png test_jsn spr_cmp_2x sprite/src1,mysrc@2x.json test_png spr_cmp_2x sprite/src1,mysrc@2x.png +test_font font_1 font/Overpass%20Mono%20Light/0-255 +test_font font_2 font/Overpass%20Mono%20Regular/0-255 +test_font font_3 font/Overpass%20Mono%20Regular,Overpass%20Mono%20Light/0-255 + kill_process $PROCESS_ID validate_log "${TMP_DIR}/test_log_2.txt"