Skip to content

Commit 615daf2

Browse files
committed
feat: Convert Maplibre symbol layers to Galileo labels
This commit adds basic logic for converting Maplibre labels to Galileo ones.
1 parent 75c0477 commit 615daf2

5 files changed

Lines changed: 253 additions & 21 deletions

File tree

galileo-maplibre/examples/maplibre_style.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
use galileo::MapBuilder;
2+
use galileo::render::text::RustybuzzRasterizer;
3+
use galileo::render::text::text_service::TextService;
24
use galileo_maplibre::MaplibreLayer;
35

46
#[cfg(not(target_arch = "wasm32"))]
@@ -20,6 +22,8 @@ fn main() {
2022
}
2123

2224
async fn create_map(style_path: &str) -> galileo::Map {
25+
initialize_font_service();
26+
2327
let Some(api_key) = std::option_env!("VT_API_KEY") else {
2428
panic!(
2529
"Set the MapTiler API key into VT_API_KEY environment variable when building this example"
@@ -38,3 +42,8 @@ async fn create_map(style_path: &str) -> galileo::Map {
3842
.with_layer(layer)
3943
.build()
4044
}
45+
46+
fn initialize_font_service() {
47+
let rasterizer = RustybuzzRasterizer::default();
48+
TextService::initialize(rasterizer).load_fonts("galileo/examples/data/fonts");
49+
}

galileo-maplibre/src/layer/vector_tile.rs

Lines changed: 154 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,16 @@ use galileo::galileo_types::geo::{Crs, NewGeoPoint, Projection};
88
use galileo::layer::VectorTileLayer;
99
use galileo::layer::vector_tile_layer::VectorTileLayerBuilder;
1010
use galileo::layer::vector_tile_layer::style::{
11-
StyleRule, VectorTileLineSymbol, VectorTilePolygonSymbol, VectorTileStyle, VectorTileSymbol,
11+
StyleRule, VectorTileLabelSymbol, VectorTileLineSymbol, VectorTilePolygonSymbol,
12+
VectorTileStyle, VectorTileSymbol, VtTextStyle,
1213
};
14+
use galileo::render::text::{FontStyle, FontWeight, HorizontalAlignment, VerticalAlignment};
1315
use galileo::tile_schema::{TileSchema, TileSchemaBuilder, VerticalDirection};
1416
use serde::Deserialize;
1517

1618
use crate::layer::{UNSUPPORTED, log_unsupported_field};
1719
use crate::style::color::MlColor;
18-
use crate::style::layer::{FillLayer, Layer as MaplibreStyleLayer, LineLayer};
20+
use crate::style::layer::{FillLayer, Layer as MaplibreStyleLayer, LineLayer, SymbolLayer};
1921
use crate::style::source::{TileScheme, VectorSource};
2022
use crate::style::value::{FunctionStop, FunctionType, MlStyleValue};
2123

@@ -197,6 +199,11 @@ fn build_rules(layers: &[&MaplibreStyleLayer], tile_schema: &TileSchema) -> Vec<
197199
rules.push(rule);
198200
}
199201
}
202+
MaplibreStyleLayer::Symbol(symbol) => {
203+
if let Some(rule) = symbol_rule(symbol, tile_schema) {
204+
rules.push(rule);
205+
}
206+
}
200207
other => {
201208
log::debug!(
202209
"{UNSUPPORTED} Maplibre layer type '{}' (id: '{}') inside a vector source \
@@ -218,6 +225,151 @@ fn build_rules(layers: &[&MaplibreStyleLayer], tile_schema: &TileSchema) -> Vec<
218225
rules
219226
}
220227

228+
fn symbol_rule(symbol: &SymbolLayer, tile_schema: &TileSchema) -> Option<StyleRule> {
229+
let source_layer = match &symbol.source_layer {
230+
Some(l) => l.clone(),
231+
None => {
232+
log::debug!(
233+
"{UNSUPPORTED} Symbol layer '{}' has no source-layer; skipping.",
234+
symbol.id
235+
);
236+
return None;
237+
}
238+
};
239+
240+
let min_resolution = symbol
241+
.maxzoom
242+
.and_then(|lod| tile_schema.lod_resolution(lod.round() as u32));
243+
let max_resolution = symbol
244+
.minzoom
245+
.and_then(|lod| tile_schema.lod_resolution(lod.round() as u32));
246+
let filter = symbol.filter.as_ref().and_then(|v| v.to_galileo_expr());
247+
248+
let font_color = get_color_value(&symbol.paint.text_color, &symbol.paint.text_opacity)?;
249+
let outline_color =
250+
get_color_value(&symbol.paint.text_halo_color, &MlStyleValue::Literal(1.0))?;
251+
let font_size = get_galileo_value(
252+
&symbol
253+
.layout
254+
.text_size
255+
.clone()
256+
.unwrap_or_else(|| 16.0.into()),
257+
)?;
258+
let outline_width = get_galileo_value(
259+
&symbol
260+
.paint
261+
.text_halo_width
262+
.clone()
263+
.unwrap_or_else(|| 0.0.into()),
264+
)?;
265+
266+
let (font_family, weight, font_style) = parse_ml_fonts(&symbol.layout.text_font);
267+
268+
let style = VtTextStyle {
269+
font_family,
270+
font_size: font_size.into(),
271+
font_color: font_color.into(),
272+
horizontal_alignment: match symbol.layout.text_anchor.as_deref() {
273+
Some("left") | Some("top-left") | Some("bottom-left") => HorizontalAlignment::Left,
274+
Some("right") | Some("top-right") | Some("bottom-right") => HorizontalAlignment::Right,
275+
_ => HorizontalAlignment::Center,
276+
},
277+
vertical_alignment: match symbol.layout.text_anchor.as_deref() {
278+
Some("top") | Some("top-left") | Some("top-right") => VerticalAlignment::Top,
279+
Some("bottom") | Some("bottom-left") | Some("bottom-right") => {
280+
VerticalAlignment::Bottom
281+
}
282+
_ => VerticalAlignment::Middle,
283+
},
284+
weight,
285+
style: font_style,
286+
outline_width: outline_width.into(),
287+
outline_color: outline_color.into(),
288+
};
289+
290+
Some(StyleRule {
291+
layer_name: Some(source_layer),
292+
symbol: VectorTileSymbol::Label(VectorTileLabelSymbol {
293+
pattern: symbol.layout.text_field.clone().unwrap_or_default(),
294+
text_style: style,
295+
}),
296+
min_resolution,
297+
max_resolution,
298+
filter: filter.map(Into::into),
299+
})
300+
}
301+
302+
/// Parses Maplibre `text-font` entries into a font family list, [`FontWeight`], and [`FontStyle`].
303+
///
304+
/// Maplibre encodes weight and style as suffixes appended to the family name, e.g.
305+
/// `"Roboto Bold Italic"` or `"Noto Sans Regular"`. This function strips the known
306+
/// weight and style keywords from the end of each entry to recover the bare family name, and
307+
/// derives `FontWeight` / `FontStyle` from the first entry that contains them.
308+
///
309+
/// All family names are collected so the renderer can fall back through the list.
310+
fn parse_ml_fonts(text_font: &[String]) -> (Vec<String>, FontWeight, FontStyle) {
311+
const STYLES: &[(&str, FontStyle)] = &[
312+
("Italic", FontStyle::Italic),
313+
("Oblique", FontStyle::Oblique),
314+
];
315+
316+
const WEIGHTS: &[(&str, FontWeight)] = &[
317+
("Thin", FontWeight::THIN),
318+
("ExtraLight", FontWeight::EXTRA_LIGHT),
319+
("Light", FontWeight::LIGHT),
320+
("Regular", FontWeight::NORMAL),
321+
("Medium", FontWeight::MEDIUM),
322+
("SemiBold", FontWeight::SEMI_BOLD),
323+
("Bold", FontWeight::BOLD),
324+
("ExtraBold", FontWeight::EXTRA_BOLD),
325+
("Black", FontWeight::BLACK),
326+
("Heavy", FontWeight::BLACK),
327+
];
328+
329+
let mut families = Vec::with_capacity(text_font.len());
330+
let mut resolved_weight = FontWeight::NORMAL;
331+
let mut resolved_style = FontStyle::Normal;
332+
let mut style_resolved = false;
333+
334+
for font_str in text_font {
335+
let mut rest = font_str.trim();
336+
337+
if !style_resolved {
338+
if let Some((suffix, s)) = STYLES.iter().find(|(kw, _)| rest.ends_with(*kw)) {
339+
rest = rest[..rest.len() - suffix.len()].trim();
340+
resolved_style = *s;
341+
}
342+
343+
if let Some((suffix, w)) = WEIGHTS.iter().find(|(kw, _)| rest.ends_with(*kw)) {
344+
rest = rest[..rest.len() - suffix.len()].trim();
345+
resolved_weight = *w;
346+
}
347+
348+
style_resolved = true;
349+
} else {
350+
// For fallback fonts, strip any trailing style/weight keywords too.
351+
for (suffix, _) in STYLES.iter() {
352+
if rest.ends_with(*suffix) {
353+
rest = rest[..rest.len() - suffix.len()].trim();
354+
break;
355+
}
356+
}
357+
for (suffix, _) in WEIGHTS.iter() {
358+
if rest.ends_with(*suffix) {
359+
rest = rest[..rest.len() - suffix.len()].trim();
360+
break;
361+
}
362+
}
363+
}
364+
365+
if !rest.is_empty() {
366+
families.push(rest.to_string());
367+
}
368+
}
369+
370+
(families, resolved_weight, resolved_style)
371+
}
372+
221373
/// Converts a [`FillLayer`] to a [`StyleRule`], or logs and returns `None` if unsupported.
222374
fn fill_rule(fill: &FillLayer, tile_schema: &TileSchema) -> Option<StyleRule> {
223375
let source_layer = match &fill.source_layer {

galileo-maplibre/src/style/layer/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ mod tests {
217217
};
218218
assert_eq!(s.id, "Labels");
219219
assert!(s.layout.text_field.is_some());
220-
assert!(s.layout.text_font.is_some());
220+
assert_eq!(&s.layout.text_font, &["Open Sans Regular".to_string()]);
221221
}
222222

223223
#[test]

galileo-maplibre/src/style/layer/symbol.rs

Lines changed: 72 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,23 @@ use serde::Deserialize;
44
use serde_json::Value;
55

66
use super::common::{Visibility, deserialize_visibility_or_default};
7-
use crate::style::deserialize_opt_f64;
7+
use crate::style::color::MlColor;
8+
use crate::style::expression::MlExpr;
9+
use crate::style::value::MlStyleValue;
10+
use crate::style::{
11+
default_one, default_transparent, deser_default_one, deser_default_transparent,
12+
deserialize_opt_f64,
13+
};
14+
15+
fn default_font() -> Vec<String> {
16+
vec![
17+
"Open Sans Regular".to_string(),
18+
"Arial Unicode MS Regular".to_string(),
19+
]
20+
}
821

922
/// Paint properties for a `symbol` layer.
10-
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
23+
#[derive(Debug, Clone, PartialEq, Deserialize)]
1124
pub struct SymbolPaint {
1225
/// Icon colour. Supports expressions.
1326
#[serde(rename = "icon-color", skip_serializing_if = "Option::is_none")]
@@ -26,8 +39,13 @@ pub struct SymbolPaint {
2639
pub icon_halo_blur: Option<Value>,
2740

2841
/// Icon opacity. Supports expressions.
29-
#[serde(rename = "icon-opacity", skip_serializing_if = "Option::is_none")]
30-
pub icon_opacity: Option<Value>,
42+
#[serde(
43+
rename = "icon-opacity",
44+
default = "default_one",
45+
deserialize_with = "deser_default_one",
46+
skip_serializing_if = "Option::is_none"
47+
)]
48+
pub icon_opacity: MlStyleValue<f64>,
3149

3250
/// Icon translation offset. Supports expressions.
3351
#[serde(rename = "icon-translate", skip_serializing_if = "Option::is_none")]
@@ -48,24 +66,37 @@ pub struct SymbolPaint {
4866
pub icon_emissive_strength: Option<Value>,
4967

5068
/// Text colour. Supports expressions.
51-
#[serde(rename = "text-color", skip_serializing_if = "Option::is_none")]
52-
pub text_color: Option<Value>,
69+
#[serde(
70+
rename = "text-color",
71+
default = "default_transparent",
72+
deserialize_with = "deser_default_transparent"
73+
)]
74+
pub text_color: MlStyleValue<MlColor>,
5375

5476
/// Text halo colour. Supports expressions.
55-
#[serde(rename = "text-halo-color", skip_serializing_if = "Option::is_none")]
56-
pub text_halo_color: Option<Value>,
77+
#[serde(
78+
rename = "text-halo-color",
79+
default = "default_transparent",
80+
deserialize_with = "deser_default_transparent"
81+
)]
82+
pub text_halo_color: MlStyleValue<MlColor>,
5783

5884
/// Width of the halo around text. Supports expressions.
5985
#[serde(rename = "text-halo-width", skip_serializing_if = "Option::is_none")]
60-
pub text_halo_width: Option<Value>,
86+
pub text_halo_width: Option<MlStyleValue<f64>>,
6187

6288
/// Fade out the halo towards the outside. Supports expressions.
6389
#[serde(rename = "text-halo-blur", skip_serializing_if = "Option::is_none")]
6490
pub text_halo_blur: Option<Value>,
6591

6692
/// Text opacity. Supports expressions.
67-
#[serde(rename = "text-opacity", skip_serializing_if = "Option::is_none")]
68-
pub text_opacity: Option<Value>,
93+
#[serde(
94+
rename = "text-opacity",
95+
default = "default_one",
96+
deserialize_with = "deser_default_one",
97+
skip_serializing_if = "Option::is_none"
98+
)]
99+
pub text_opacity: MlStyleValue<f64>,
69100

70101
/// Text translation offset. Supports expressions.
71102
#[serde(rename = "text-translate", skip_serializing_if = "Option::is_none")]
@@ -86,6 +117,29 @@ pub struct SymbolPaint {
86117
pub text_emissive_strength: Option<Value>,
87118
}
88119

120+
impl Default for SymbolPaint {
121+
fn default() -> Self {
122+
Self {
123+
icon_color: Default::default(),
124+
icon_halo_color: Default::default(),
125+
icon_halo_width: Default::default(),
126+
icon_halo_blur: Default::default(),
127+
icon_opacity: default_one(),
128+
icon_translate: Default::default(),
129+
icon_translate_anchor: Default::default(),
130+
icon_emissive_strength: Default::default(),
131+
text_color: default_transparent(),
132+
text_halo_color: default_transparent(),
133+
text_halo_width: Default::default(),
134+
text_halo_blur: Default::default(),
135+
text_opacity: default_one(),
136+
text_translate: Default::default(),
137+
text_translate_anchor: Default::default(),
138+
text_emissive_strength: Default::default(),
139+
}
140+
}
141+
}
142+
89143
/// Layout properties for a `symbol` layer.
90144
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
91145
pub struct SymbolLayout {
@@ -180,15 +234,15 @@ pub struct SymbolLayout {
180234

181235
/// Part of the text placed nearest to the anchor. Supports expressions.
182236
#[serde(rename = "text-anchor", skip_serializing_if = "Option::is_none")]
183-
pub text_anchor: Option<Value>,
237+
pub text_anchor: Option<String>,
184238

185239
/// Value to use for a text label. Supports expressions.
186240
#[serde(rename = "text-field", skip_serializing_if = "Option::is_none")]
187-
pub text_field: Option<Value>,
241+
pub text_field: Option<String>,
188242

189243
/// Font stack for the glyphs. Supports expressions.
190-
#[serde(rename = "text-font", skip_serializing_if = "Option::is_none")]
191-
pub text_font: Option<Value>,
244+
#[serde(rename = "text-font", default = "default_font")]
245+
pub text_font: Vec<String>,
192246

193247
/// If true, other symbols can be visible even if they collide with the text.
194248
#[serde(
@@ -248,8 +302,8 @@ pub struct SymbolLayout {
248302
pub text_rotation_alignment: Option<Value>,
249303

250304
/// Font size. Supports expressions.
251-
#[serde(rename = "text-size", skip_serializing_if = "Option::is_none")]
252-
pub text_size: Option<Value>,
305+
#[serde(rename = "text-size")]
306+
pub text_size: Option<MlStyleValue<f64>>,
253307

254308
/// Specifies how to capitalize text. Supports expressions.
255309
#[serde(rename = "text-transform", skip_serializing_if = "Option::is_none")]
@@ -303,7 +357,7 @@ pub struct SymbolLayer {
303357

304358
/// Filter expression to select features from the source.
305359
#[serde(skip_serializing_if = "Option::is_none")]
306-
pub filter: Option<Value>,
360+
pub filter: Option<MlExpr>,
307361

308362
/// Layout properties.
309363
#[serde(default)]

galileo/src/render/text/mod.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,23 @@ impl FontWeight {
134134
pub const BOLD: Self = FontWeight(700);
135135
/// Thin font.
136136
pub const THIN: Self = FontWeight(300);
137+
/// Extra-light (200).
138+
pub const EXTRA_LIGHT: Self = FontWeight(200);
139+
/// Light (300).
140+
pub const LIGHT: Self = FontWeight(300);
141+
/// Medium (600).
142+
pub const MEDIUM: Self = FontWeight(600);
143+
/// Semi-bold (650).
144+
pub const SEMI_BOLD: Self = FontWeight(650);
145+
/// Extra-bold (800).
146+
pub const EXTRA_BOLD: Self = FontWeight(800);
147+
/// Black / Heavy (900).
148+
pub const BLACK: Self = FontWeight(900);
149+
150+
/// Creates a `FontWeight` from a raw CSS-style numeric weight value (100–900).
151+
pub fn new(value: u16) -> Self {
152+
Self(value)
153+
}
137154
}
138155

139156
impl Default for FontWeight {

0 commit comments

Comments
 (0)