diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 27bb30f58..709dd4b47 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -333,7 +333,38 @@ pub fn commands() -> Vec { // A family by name, whether or not this machine has it: the project may be drawn on // one that does. Blank goes back to the label font. let font = ps(p, "font").map(rustgis_map::symbology::font_name); + // A halo's own colour ("" or null: one standing out from the text), its width in points + // (null: the thin halo), and the letter spacing. + let halo_color = match p.get("haloColor") { + None => None, + Some(Value::Null) => Some(None), + Some(Value::String(c)) if c.trim().is_empty() => Some(None), + Some(_) => Some(Some(s.get_color(p, "haloColor")?.ok_or("haloColor must be #RRGGBB or #RRGGBBAA")?)), + }; + let hw = rustgis_map::symbology::HALO_WIDTH; + let sp = (rustgis_map::layout::MIN_LETTER_SPACING, rustgis_map::layout::MAX_LETTER_SPACING); + let halo_width = match p.get("haloWidth") { + None => None, + Some(Value::Null) => Some(None), + Some(_) => { + let w = f(p, "haloWidth").ok_or("haloWidth must be a number of points, or null")?; + Some(Some(w.clamp(*hw.start(), *hw.end()) as f32)) + } + }; + let spacing = match p.get("spacing") { + None => None, + Some(_) => Some(f(p, "spacing").ok_or("spacing must be a number of points")?.clamp(sp.0, sp.1) as f32), + }; super::labels::with_class(s, p, |c| { + if let Some(v) = halo_color { + c.halo_color = v; + } + if let Some(v) = halo_width { + c.halo_width = v; + } + if let Some(v) = spacing { + c.spacing = v; + } if let Some(name) = font.clone() { c.font = name; } @@ -355,7 +386,7 @@ pub fn commands() -> Vec { }) }) .when(need_feature_layer) - .params(r##"{"layer"?, "class"?: name (none: the Default class, by whatever name it has), "size"?: number, "color"?: "#RRGGBB"|"#RRGGBBAA", "halo"?: bool, "bold"?: bool, "italic"?: bool, "font"?: installed family name (labels.fonts lists them; "" is the label font)}"##), + .params(r##"{"layer"?, "class"?: name (none: the Default class, by whatever name it has), "size"?: number, "color"?: "#RRGGBB"|"#RRGGBBAA", "halo"?: bool, "haloColor"?: "#RRGGBB"|"#RRGGBBAA"|"" (""/null: white or dark, whichever stands out from the text), "haloWidth"?: points 0-10|null (null: the thin halo), "spacing"?: points between letters (-5 to 50), "bold"?: bool, "italic"?: bool, "font"?: installed family name (labels.fonts lists them; "" is the label font)}"##), C::new("layer.definitionQuery", "Definition Query", "Data › Definition Query", |s, p| { let q = ps(p, "where").map(str::trim).unwrap_or("").to_string(); let id = s.layer_id(p)?; diff --git a/crates/engine/src/cmd/sharing.rs b/crates/engine/src/cmd/sharing.rs index ab90dd35f..f455d6829 100644 --- a/crates/engine/src/cmd/sharing.rs +++ b/crates/engine/src/cmd/sharing.rs @@ -644,9 +644,18 @@ pub fn web_style(l: &Layer) -> Value { } } if l.labels.enabled && l.labels.default.visible && !l.labels.default.field.is_empty() { - layers.push(json!({"id": format!("{src}-labels"), "type": "symbol", "source": src, - "layout": {"text-field": ["to-string", prop(&l.labels.default.field)], "text-size": l.labels.default.size}, - "paint": {"text-color": l.labels.default.color.hex(), "text-halo-color": "#ffffff", "text-halo-width": if l.labels.default.halo { 1.5 } else { 0.0 }}})); + let c = &l.labels.default; + // A halo of the class's own colour and width (points, as pixels: as the text's size), else + // the one the map draws (light behind dark text, dark behind light) and thin. + let halo_color = css(c.halo_color_for(c.color)); + let halo_width = if c.halo { c.halo_width.map_or(1.5, f64::from) } else { 0.0 }; + let mut layout = json!({"text-field": ["to-string", prop(&c.field)], "text-size": c.size}); + // Letter spacing in ems, as MapLibre has it. + if c.spacing != 0.0 && c.size > 0.0 { + layout["text-letter-spacing"] = json!(f64::from(c.spacing) / f64::from(c.size)); + } + layers.push(json!({"id": format!("{src}-labels"), "type": "symbol", "source": src, "layout": layout, + "paint": {"text-color": c.color.hex(), "text-halo-color": halo_color, "text-halo-width": halo_width}})); } let (minzoom, maxzoom) = zoom_range(l); for layer in &mut layers { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 98e9ff407..2fef1f0ea 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1743,6 +1743,9 @@ fn hostile_params_never_panic() { json!({"layer":"Countries","row":0,"part":0,"ring":0,"index":0,"lon":{},"lat":1e300}), json!({"layer": 99999, "path": "/nonexistent/x.geojson", "name": "", "where": "((", "tool": "buffer", "params": 7}), json!({"layer": "Countries", "row": 1e30, "field": "nope", "value": [1], "scale": -1, "lon": 1e300, "lat": "x", "classes": 1e9, "type": "graduated"}), + // Label halos and spacing: out of range, of the wrong type. + json!({"layer": "Cities", "haloWidth": 1e308, "haloColor": 7, "spacing": "NaN"}), + json!({"layer": "Cities", "haloWidth": -1, "haloColor": "#zzz", "spacing": -1e9}), json!({"layer": "Cities", "rows": [1, 99999999], "geometry": {"type": "Polygon", "coordinates": [[]]}, "width": 1e9, "height": -3, "expression": "'"}), json!({"band": -1, "red": 1e12, "green": "x", "blue": f64::MAX, "preset": "", "ramp": "nope", "classes": -5, "n": 1e308, "low": -50, "high": 900, "gamma": 0, "zFactor": -1, "fileName": "a.tif", "data": "!!!"}), json!({"fileName": "a.tif", "data": "AAAA", "type": "unique", "on": "maybe"}), @@ -2142,6 +2145,44 @@ fn labels_take_a_font_by_family_name() { assert!(s.run("labels.fonts", &json!({})).unwrap()["fonts"].is_array()); } +/// A label class's halo takes its own colour and width, and its letters a spacing; blank and null +/// go back to the automatic colour and the thin halo, and other settings leave them. +#[test] +fn labels_take_a_halo_colour_and_width_and_letter_spacing() { + use rustgis_map::Rgba; + let mut s = world(); + let class = |s: &Session| { + let c = &s.map().layer_by_name("Cities").unwrap().labels.default; + (c.halo_color, c.halo_width, c.spacing) + }; + assert_eq!(class(&s), (None, None, 0.0)); + let mark = s.notice_mark(); + s.run("labels.style", &json!({"layer": "Cities", "haloColor": "#FFEE00", "haloWidth": 1.42, "spacing": "0.5"})).unwrap(); + assert!(s.notices_since(mark).iter().all(|n| !n.text.contains("ignores")), "every key is read"); + assert_eq!(class(&s), (Some(Rgba::rgb(0xFF, 0xEE, 0)), Some(1.42), 0.5)); + s.run("labels.style", &json!({"layer": "Cities", "size": 12})).unwrap(); + assert_eq!(class(&s), (Some(Rgba::rgb(0xFF, 0xEE, 0)), Some(1.42), 0.5), "other settings leave them"); + s.run("labels.style", &json!({"layer": "Cities", "haloWidth": 99, "spacing": -99})).unwrap(); + assert_eq!(class(&s), (Some(Rgba::rgb(0xFF, 0xEE, 0)), Some(10.0), -5.0), "within their ranges"); + s.run("labels.style", &json!({"layer": "Cities", "haloColor": "", "haloWidth": null})).unwrap(); + assert_eq!(class(&s), (None, None, -5.0)); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(class(&s), (Some(Rgba::rgb(0xFF, 0xEE, 0)), Some(10.0), -5.0), "undoes"); + s.run("labels.style", &json!({"layer": "Cities", "haloColor": null})).unwrap(); + assert_eq!(class(&s).0, None); + for bad in [ + json!({"haloColor": "pink"}), + json!({"haloColor": 7}), + json!({"haloWidth": "wide"}), + json!({"spacing": null}), + json!({"spacing": [1]}), + ] { + let mut p = bad.clone(); + p["layer"] = json!("Cities"); + assert!(s.run("labels.style", &p).is_err(), "{bad}"); + } +} + /// A lock names each label by its class's place in the list: a change to the list (a class added, /// moved or removed) locks the labels again on the next frame, a change within a class keeps the /// lock (#732). @@ -7247,6 +7288,21 @@ fn web_style_carries_the_layers_scale_limits_as_zooms() { style["layers"].as_array().unwrap().iter().any(|l| l["type"] == "symbol") }; assert!(labelled(&s)); + // The halo is the one the map draws: without its own colour, light behind dark text and dark + // behind light; its own colour as it is, see-through included. + let halo = |s: &Session| { + let style = crate::cmd::sharing::web_style(s.map().layer(id).unwrap()); + let label = style["layers"].as_array().unwrap().iter().find(|l| l["type"] == "symbol").cloned().unwrap(); + label["paint"]["text-halo-color"].as_str().unwrap().to_string() + }; + let l = s.map_mut().layer_mut(id).unwrap(); + l.labels.default.color = rustgis_map::Rgba::rgb(0, 0, 0); + l.labels.default.halo_color = None; + assert_eq!(halo(&s), "rgba(255,255,255,0.882)"); + s.map_mut().layer_mut(id).unwrap().labels.default.color = rustgis_map::Rgba::rgb(255, 255, 255); + assert_eq!(halo(&s), "rgba(20,22,26,0.824)"); + s.map_mut().layer_mut(id).unwrap().labels.default.halo_color = Some(rustgis_map::Rgba::new(255, 0, 0, 128)); + assert_eq!(halo(&s), "rgba(255,0,0,0.502)"); s.map_mut().layer_mut(id).unwrap().labels.default.visible = false; assert!(!labelled(&s)); } diff --git a/crates/map/src/qgis/labels.rs b/crates/map/src/qgis/labels.rs index ba5999e14..f8f39b5b3 100644 --- a/crates/map/src/qgis/labels.rs +++ b/crates/map/src/qgis/labels.rs @@ -1,13 +1,13 @@ //! QGIS labels (``) as RustGIS label classes: each rule a //! class whose SQL clause is its filter (and its parents'), an else rule the features no other -//! rule takes; the text's field, size, colour, weight and slant, its buffer as a halo, its -//! placement and its scale range. +//! rule takes; the text's field, size, colour, weight, slant and letter spacing, its buffer as a +//! halo of its colour and size, its placement and its scale range. use roxmltree::Node; use super::{QGIS_SCALE, Report, child, color, data_defined_on, expr, prop, props}; use crate::Rgba; -use crate::symbology::{DEFAULT_LABEL_CLASS, LABEL_SIZE, LabelClass, LabelPlacement, Labeling, MAX_LABEL_CLASSES}; +use crate::symbology::{DEFAULT_LABEL_CLASS, HALO_WIDTH, LABEL_SIZE, LabelClass, LabelPlacement, Labeling, MAX_LABEL_CLASSES}; /// A layer's labels from its ``, or `None` when none can be carried over. pub(super) fn read(l: Node, layer: &str, report: &mut Report) -> Option { @@ -162,18 +162,10 @@ fn class( return None; } let num = |n: Node, k: &str| n.attribute(k).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()); - // The size in typographic points, as RustGIS's label sizes are. - let raw = num(text, "fontSize").unwrap_or(10.0); - let size = match text.attribute("fontSizeUnit").unwrap_or("Point") { - "Point" => raw, - "MM" => raw * 72.0 / 25.4, - "Pixel" => raw * 0.75, - "Inch" => raw * 72.0, - _ => { - report.approximated(layer, "label sizes in map units, taken at 1:25,000"); - raw / 25_000.0 * 72.0 / 0.0254 - } - }; + // The size in typographic points, as RustGIS's label sizes are; its letter spacing in the + // size's unit. + let size_unit = text.attribute("fontSizeUnit").unwrap_or("Point"); + let size = points(num(text, "fontSize").unwrap_or(10.0), size_unit, "label sizes", layer, report); let opacity = num(text, "textOpacity").unwrap_or(1.0).clamp(0.0, 1.0) as f32; let c = text.attribute("textColor").and_then(color).unwrap_or(Rgba::rgb(0, 0, 0)); let c = c.with_alpha((f32::from(c.a()) * opacity).round() as u8); @@ -187,15 +179,28 @@ fn class( let italic = style.contains("italic") || style.contains("oblique") || text.attribute("fontItalic") == Some("1"); // QGIS's font family: drawn in it where it is installed, else in RustGIS's label font. let font = text.attribute("fontFamily").and_then(crate::symbology::font_name); - if num(text, "fontLetterSpacing").is_some_and(|s| s != 0.0) { - report.approximated(layer, "label letter spacing, left out"); - } - let halo = child(text, "text-buffer").is_some_and(|b| b.attribute("bufferDraw") == Some("1")); - if halo { - report.approximated( - layer, - "label halos in their own colour and width, drawn as RustGIS's (thin, white or dark by the text's colour)", - ); + let spacing = + num(text, "fontLetterSpacing").filter(|s| *s != 0.0).map_or(0.0, |s| points(s, size_unit, "label letter spacing", layer, report)); + let (spacing_min, spacing_max) = (crate::layout::MIN_LETTER_SPACING, crate::layout::MAX_LETTER_SPACING); + // Its buffer as the halo, in the buffer's colour (and opacity) and size. + let buffer = child(text, "text-buffer").filter(|b| b.attribute("bufferDraw") == Some("1")); + let halo = buffer.is_some(); + // (QGIS's buffer is white unless it says otherwise.) + let halo_color = buffer.map(|b| b.attribute("bufferColor").and_then(color).unwrap_or(Rgba::rgb(255, 255, 255))).map(|c| { + let opacity = buffer.and_then(|b| num(b, "bufferOpacity")).unwrap_or(1.0).clamp(0.0, 1.0) as f32; + c.with_alpha((f32::from(c.a()) * opacity).round() as u8) + }); + let halo_width = buffer.map(|b| { + let w = points(num(b, "bufferSize").unwrap_or(1.0), b.attribute("bufferSizeUnits").unwrap_or("MM"), "label halos", layer, report); + w.clamp(*HALO_WIDTH.start(), *HALO_WIDTH.end()) as f32 + }); + if let Some(b) = buffer { + if b.attribute("bufferJoinStyle").is_some_and(|j| j != "128") { + report.approximated(layer, "label halos with sharp or bevelled corners, drawn round"); + } + if !matches!(b.attribute("bufferBlendMode"), None | Some("0")) { + report.approximated(layer, "label halos blended into the map, drawn over it"); + } } if child(text, "background").is_some_and(|b| b.attribute("shapeDraw") == Some("1")) { report.approximated(layer, "label backgrounds (shapes behind the text), left out"); @@ -228,9 +233,26 @@ fn class( placement, italic, font, + halo_color, + halo_width, + spacing: spacing.clamp(spacing_min, spacing_max) as f32, }) } +/// A QGIS length (`what`'s, for the report) in typographic points: map units taken at 1:25,000. +fn points(v: f64, unit: &str, what: &str, layer: &str, report: &mut Report) -> f64 { + match unit { + "Point" => v, + "MM" => v * 72.0 / 25.4, + "Pixel" => v * 0.75, + "Inch" => v * 72.0, + _ => { + report.approximated(layer, format!("{what} in map units, taken at 1:25,000")); + v / 25_000.0 * 72.0 / 0.0254 + } + } +} + /// Notes the label settings RustGIS has no place for, where they are set off QGIS's defaults. fn unread(settings: Node, layer: &str, report: &mut Report) { let num = diff --git a/crates/map/src/qgis/tests.rs b/crates/map/src/qgis/tests.rs index 18c3acd5b..a4396ec4d 100644 --- a/crates/map/src/qgis/tests.rs +++ b/crates/map/src/qgis/tests.rs @@ -308,6 +308,48 @@ fn merged_categories_and_dashes_carry_over() { assert!(!report.notes.iter().any(|n| n.text.contains("dash")), "{:?}", report.notes); } +#[test] +fn a_labels_buffer_and_letter_spacing_carry_over_in_their_units() { + let qml = |text: &str| { + format!( + r#" + {text}"# + ) + }; + let read = |text: &str| { + let mut report = Report::default(); + let s = read_style(&qml(text), "Water", &mut report).unwrap(); + (s.labels.unwrap().default, report) + }; + // SMV25's: 0.5 mm of white, letters 0.5 pt apart. + let (c, report) = read( + r#" + "#, + ); + assert!(c.halo && c.halo_color == Some(Rgba::rgb(255, 255, 255)) && c.spacing == 0.5, "{c:?}"); + assert!((c.halo_width.unwrap() - 0.5 * 72.0 / 25.4).abs() < 1e-4, "{:?}", c.halo_width); + assert!(report.notes.is_empty(), "{:?}", report.notes); + // Half see-through yellow, in points; spacing in the size's millimetres; corners not round. + let (c, report) = read( + r#" + "#, + ); + assert_eq!((c.halo_color, c.halo_width), (Some(Rgba::new(255, 238, 0, 128)), Some(2.0))); + assert!((c.spacing - 72.0 / 25.4).abs() < 1e-4, "{}", c.spacing); + assert!(report.notes.iter().any(|n| n.text.contains("drawn round")), "{:?}", report.notes); + assert!(report.notes.iter().any(|n| n.text.contains("blended")), "{:?}", report.notes); + // A buffer that says nothing: QGIS's own, 1 mm of white; one switched off: no halo at all. + let (c, _) = read(r#""#); + assert_eq!(c.halo_color, Some(Rgba::rgb(255, 255, 255))); + assert!((c.halo_width.unwrap() - 72.0 / 25.4).abs() < 1e-4); + let (c, _) = read(r#""#); + assert_eq!((c.halo, c.halo_color, c.halo_width, c.spacing), (false, None, None, 0.0)); + // Crafted sizes stay within RustGIS's ranges. + let (c, _) = + read(r#""#); + assert_eq!((c.halo_width, c.spacing), (Some(10.0), 50.0)); +} + #[test] fn files_of_qgis_1_and_2_read_by_their_old_names() { // Before QGIS 3: no layer tree but a legend, the canvas's coordinate system, scale limits named @@ -408,7 +450,6 @@ fn what_rustgis_has_no_place_for_is_noted() { "diagrams", "order-by", "draw effects", - "label halos", "wrapped labels", "label distances", "label offsets", diff --git a/crates/map/src/symbology.rs b/crates/map/src/symbology.rs index bfa7ad4bd..506e26cb4 100644 --- a/crates/map/src/symbology.rs +++ b/crates/map/src/symbology.rs @@ -1716,6 +1716,63 @@ pub struct LabelClass { /// them in the label font too. #[serde(default, deserialize_with = "de_font", skip_serializing_if = "Option::is_none")] pub font: Option, + /// The halo's colour, as QGIS's buffer has one; none picks one that stands out from the text + /// (white behind dark text, dark behind light). + #[serde(default, deserialize_with = "de_halo_color", skip_serializing_if = "Option::is_none")] + pub halo_color: Option, + /// How far the halo reaches beyond the letters (points, within [`HALO_WIDTH`]), as QGIS's + /// buffer size; none is RustGIS's thin halo. + #[serde(default, deserialize_with = "de_halo_width", skip_serializing_if = "Option::is_none")] + pub halo_width: Option, + /// Space added between letters (points, within a layout text's range), as QGIS's letter + /// spacing; negative draws them tighter. + #[serde(default, deserialize_with = "de_letter_spacing", skip_serializing_if = "is_zero")] + pub spacing: f32, +} + +/// The widths a label's halo takes (points). +pub const HALO_WIDTH: std::ops::RangeInclusive = 0.0..=10.0; + +/// A label's halo width as `labels.style` would leave it: within [`HALO_WIDTH`]; one that isn't a +/// number (a crafted file's) is none, the thin halo. +fn de_halo_width<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + Ok(lenient::(d)?.filter(|w| w.is_finite()).map(|w| w.clamp(*HALO_WIDTH.start(), *HALO_WIDTH.end()) as f32)) +} + +/// A label's halo colour; one that isn't a colour is none, the automatic one. +fn de_halo_color<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + lenient::(d) +} + +/// A label's letter spacing, within a layout text's range; one that isn't a number is none. +fn de_letter_spacing<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + use crate::layout::{MAX_LETTER_SPACING, MIN_LETTER_SPACING}; + Ok(lenient::(d)?.filter(|s| s.is_finite()).map_or(0.0, |s| s.clamp(MIN_LETTER_SPACING, MAX_LETTER_SPACING) as f32)) +} + +/// A value of type `T`, or none for anything else, so a damaged setting doesn't make the project +/// unreadable. +fn lenient<'de, D: serde::Deserializer<'de>, T: Deserialize<'de>>(d: D) -> Result, D::Error> { + #[derive(Deserialize)] + #[serde(untagged)] + enum Read { + Value(T), + Other(serde::de::IgnoredAny), + } + Ok(match Option::>::deserialize(d)? { + Some(Read::Value(v)) => Some(v), + Some(Read::Other(_)) | None => None, + }) +} + +fn is_zero(v: &f32) -> bool { + *v == 0.0 +} + +/// A halo that stands out from text of colour `text`: white behind dark text, dark behind light. +pub fn auto_halo(text: Rgba) -> Rgba { + let lum = 0.299 * text.r() as f32 + 0.587 * text.g() as f32 + 0.114 * text.b() as f32; + if lum < 140.0 { Rgba::new(255, 255, 255, 225) } else { Rgba::new(20, 22, 26, 210) } } /// The longest font family name kept (characters). @@ -1886,9 +1943,17 @@ impl LabelClass { placement: LabelPlacement::Auto, italic: false, font: None, + halo_color: None, + halo_width: None, + spacing: 0.0, } } + /// The halo's colour behind text of colour `text`: its own, else one that stands out. + pub fn halo_color_for(&self, text: Rgba) -> Rgba { + self.halo_color.unwrap_or_else(|| auto_halo(text)) + } + /// Whether labels draw at a scale denominator and viewing distance (metres). pub fn in_range(&self, scale: f64, distance: f64) -> bool { self.min_scale.is_none_or(|m| scale <= m) @@ -2039,6 +2104,40 @@ mod tests { assert_eq!(read("x".repeat(10_000).into()).default.font.map(|f| f.chars().count()), Some(MAX_FONT_NAME)); } + /// A halo's colour and width and the letter spacing are kept; a label saved before them, or + /// one without them, is written as it always was; a crafted value reads as none (or within its + /// range) and the rest of the labels still read. + #[test] + fn a_labels_halo_and_letter_spacing_are_kept_and_old_labels_save_as_before() { + let base = serde_json::json!({"enabled": true, "field": "name", "size": 9.0, "color": [0, 0, 0, 255], "halo": true, "bold": false}); + let read = |extra: serde_json::Value| { + let mut v = base.clone(); + if let (Some(o), Some(e)) = (v.as_object_mut(), extra.as_object()) { + o.extend(e.clone()); + } + serde_json::from_value::(v).unwrap() + }; + let old = read(serde_json::json!({})); + assert_eq!((old.default.halo_color, old.default.halo_width, old.default.spacing), (None, None, 0.0)); + let saved = serde_json::to_value(&old).unwrap(); + for k in ["halo_color", "halo_width", "spacing"] { + assert!(saved.get(k).is_none(), "{k} written for a label without it"); + } + let l = read(serde_json::json!({"halo_color": [255, 238, 0, 255], "halo_width": 1.42, "spacing": 0.5})); + assert_eq!((l.default.halo_color, l.default.halo_width, l.default.spacing), (Some(Rgba::rgb(255, 238, 0)), Some(1.42), 0.5)); + assert_eq!(serde_json::from_value::(serde_json::to_value(&l).unwrap()).unwrap(), l); + let junk = read(serde_json::json!({"halo_color": "pink", "halo_width": "wide", "spacing": [1]})); + assert_eq!((junk.default.halo_color, junk.default.halo_width, junk.default.spacing), (None, None, 0.0)); + assert_eq!(junk.default.field, "name"); + let far = read(serde_json::json!({"halo_width": 1e30, "spacing": -1e30})); + assert_eq!((far.default.halo_width, far.default.spacing), (Some(10.0), -5.0)); + assert_eq!(read(serde_json::json!({"halo_width": -3})).default.halo_width, Some(0.0)); + // The automatic colour is the one labels always had. + assert_eq!(l.default.halo_color_for(Rgba::rgb(0, 0, 0)), Rgba::rgb(255, 238, 0)); + assert_eq!(old.default.halo_color_for(Rgba::rgb(0, 0, 0)), Rgba::new(255, 255, 255, 225)); + assert_eq!(old.default.halo_color_for(Rgba::rgb(255, 255, 255)), Rgba::new(20, 22, 26, 210)); + } + /// The shapes are written as they always were; a character with its family, which a crafted /// file can't make long or many, or stop the rest from reading. #[test] diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 08534a2f5..f20c58dfb 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -116,6 +116,7 @@ use rustgis_geom::{Coord, Extent, extent_to_merc, lonlat_to_merc}; use rustgis_map::globe::{V3, densify}; use rustgis_map::layout::TextAlign; use rustgis_map::{Camera, Globe, LabelPlacement, LineStroke, Map, Marker, Rgba, Symbol}; +pub use text::halo_reach; /// The image type pages and maps are drawn into. pub use tiny_skia::Pixmap; use tiny_skia::{ @@ -178,6 +179,9 @@ pub struct PlacedLabel { pub size: f32, pub color: Rgba, pub halo: Option, + /// How far the halo reaches beyond the letters (points), as a label class sets it; `None` is + /// the thin halo every label had before. + pub halo_width: Option, pub bold: bool, /// An icon placed with the label (a tile style's `icon-image`), drawn into the map. pub icon: Option, @@ -3227,6 +3231,7 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac size: rustgis_map::graphics::font_size(ds_ptr, row), color, halo: Some(halo_for(color)), + halo_width: None, bold: rustgis_map::graphics::bold(ds_ptr, row), icon: None, align: 0.0, @@ -3312,8 +3317,9 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac let size = class.size * label_factor; let font = class_fonts.get(ci).copied().flatten(); let face = text::Face { bold: class.bold, italic: class.italic, serif: false, family: font }; + let spacing = class.spacing * label_factor; let (dx, dy) = class.placement.offset( - text::block_width(&text, class.size, face), + text::spaced_block_width(&text, class.size, face, class.spacing), text::block_height(&text, class.size), marker, is_point, @@ -3329,14 +3335,14 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac let curved = class.placement == LabelPlacement::Curved && text.graphemes(true).nth(line_labels::MAX_CURVED_CHARS).is_none(); if !curved { - let width = text::block_width(&text, size, face); + let width = text::spaced_block_width(&text, size, face, spacing); let Some((cx, cy, a)) = line_labels::parallel(line, width, overrun) else { continue }; (lx, ly, angle) = (cx, cy, Some(a)); } else { // Each character with its accents, drawn as one. let chars: Vec = text.graphemes(true).map(String::from).collect(); let advances: Vec = chars.iter().map(|c| text::measure_in(c, size, face)).collect(); - let Some(((cx, cy), at)) = line_labels::curved(line, &advances, overrun) else { continue }; + let Some(((cx, cy), at)) = line_labels::curved(line, &advances, spacing * 1.33, overrun) else { continue }; (lx, ly) = (cx, cy); glyphs = chars.into_iter().zip(at).map(|(text, (dx, dy, angle))| LabelGlyph { text, dx, dy, angle }).collect(); @@ -3353,13 +3359,14 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac y: ly, size, color: class.color, - halo: class.halo.then_some(halo_for(class.color)), + halo: class.halo.then_some(class.halo_color_for(class.color)), + halo_width: class.halo_width.map(|w| w * label_factor), bold: class.bold, icon: None, align: 0.0, framed: false, italic: class.italic, - spacing: 0.0, + spacing, font, line_align: TextAlign::Left, angle, @@ -4089,8 +4096,7 @@ fn read_back(l: &mut PlacedLabel) { /// A halo that contrasts with the text colour. fn halo_for(text: Rgba) -> Rgba { - let lum = 0.299 * text.r() as f32 + 0.587 * text.g() as f32 + 0.114 * text.b() as f32; - if lum < 140.0 { Rgba::new(255, 255, 255, 225) } else { Rgba::new(20, 22, 26, 210) } + rustgis_map::symbology::auto_halo(text) } struct Placement { @@ -4173,7 +4179,11 @@ fn label_boxes(l: &PlacedLabel) -> (Vec<[f32; 4]>, Vec<[f32; 4]>) { if !l.text.is_empty() { let tw = text::label_width(l); let th = text::block_height(&l.text, l.size); - let p = l.collide.text_padding.clamp(0.0, 100.0); + // A halo drawn at a width of its own reaches past the letters (points × 1.33, as the text's + // size and the drawing have it): another label's text must not fall under it. A width + // kept with the halo turned off reserves nothing. + let halo = text::drawn_halo_reach(l); + let p = l.collide.text_padding.clamp(0.0, 100.0) + halo; let cx = l.center_x(); if !l.glyphs.is_empty() { // A curved label: a square round each character, as tall as the text. @@ -4320,6 +4330,7 @@ pub(crate) fn draw_attribution(pm: &mut Pixmap, attribution: &[String], ppp: f32 size, color: Rgba::rgb(0x33, 0x33, 0x33), halo: Some(Rgba::rgb(0xFF, 0xFF, 0xFF)), + halo_width: None, bold: false, icon: None, align: 0.0, @@ -4346,6 +4357,7 @@ mod tests { size: 10.0, color: Rgba::rgb(0, 0, 0), halo: None, + halo_width: None, bold: false, icon, align, @@ -4360,6 +4372,38 @@ mod tests { } } + /// A halo of its own width reaches past the letters, so its label's collision box grows by it: + /// another label's text never falls under it. The thin halo and none leave the box as it was. + #[test] + fn a_wide_halo_keeps_other_labels_out_of_its_reach() { + let plain = label("Rhein", 50.0, None, 0.0); + let mut haloed = plain.clone(); + (haloed.halo, haloed.halo_width) = (Some(Rgba::rgb(255, 255, 255)), Some(5.0)); + // As far as the halo is drawn: 5 points, at the 1.33 the text's size is drawn at. + let reach = 5.0 * 1.33; + let (a, b) = (label_boxes(&plain).0[0], label_boxes(&haloed).0[0]); + assert!((a[0] - b[0] - reach).abs() < 1e-4 && (b[2] - a[2] - reach).abs() < 1e-4, "{a:?} {b:?}"); + assert!((a[1] - b[1] - reach).abs() < 1e-4 && (b[3] - a[3] - reach).abs() < 1e-4, "{a:?} {b:?}"); + haloed.halo_width = None; + assert_eq!(label_boxes(&haloed).0[0], a); + // A width kept with the halo turned off, or a fully see-through halo: nothing drawn, + // nothing reserved. + let off = PlacedLabel { halo: None, halo_width: Some(5.0), ..plain.clone() }; + assert_eq!(label_boxes(&off).0[0], a); + let clear = PlacedLabel { halo: Some(Rgba::new(255, 255, 255, 0)), halo_width: Some(5.0), ..plain.clone() }; + assert_eq!(label_boxes(&clear).0[0], a); + // A halo grown at a reference scale reserves all it reaches: labels are placed once for + // every pixel density, and only drawing holds a halo to MAX_HALO_PX pixels. + let grown = PlacedLabel { halo_width: Some(300.0), ..haloed.clone() }; + let g = label_boxes(&grown).0[0]; + assert!((a[0] - g[0] - 300.0 * 1.33).abs() < 1e-2, "{g:?}"); + // Turned and curved labels' squares grow by it too. + haloed.halo_width = Some(5.0); + let turned = |l: &PlacedLabel| PlacedLabel { angle: Some(0.5), ..l.clone() }; + let (a, b) = (label_boxes(&turned(&plain)).0[0], label_boxes(&turned(&haloed)).0[0]); + assert!(((b[2] - b[0]) - (a[2] - a[0]) - 2.0 * reach).abs() < 1e-4, "{a:?} {b:?}"); + } + #[test] fn places_icons_with_their_labels() { let icon = |x: f32| { @@ -4867,7 +4911,7 @@ mod tests { let (_, alone) = ink(&at("Atlantic", false)); assert!(cols.first().zip(alone.first()).is_some_and(|(a, b)| a.abs_diff(*b) <= 2), "the longer line keeps its place"); // As wide as the widest line, as tall as its lines; a trailing newline is an empty line. - assert_eq!(text::block_width("ab\ncdef", 10.0, text::Face::default()), text::measure("cdef", 10.0, false)); + assert_eq!(text::spaced_block_width("ab\ncdef", 10.0, text::Face::default(), 0.0), text::measure("cdef", 10.0, false)); // Text drawn on one line (a table cell, a chart's name) is still measured whole, so it is // cut to its column as it is drawn. assert_eq!(text::measure("ab\ncdef", 10.0, false), text::measure("abcdef", 10.0, false)); @@ -6425,6 +6469,7 @@ mod tests { size: 10.0, color: Rgba::rgb(0, 0, 0), halo: None, + halo_width: None, bold: false, icon: None, align: 0.0, @@ -6465,6 +6510,7 @@ mod tests { size: 30.0, color: Rgba::rgb(0, 0, 0), halo, + halo_width: None, bold: false, icon: None, align: 0.0, @@ -6526,6 +6572,84 @@ mod tests { m.layer_mut(id).unwrap().reference_scale = Some(scale * 2.0); let (dx2, size2) = label(&mut m); assert!((size2 - size * 2.0).abs() < 1e-3 && (dx2 - dx * 2.0).abs() < 0.5, "{dx} {size} → {dx2} {size2}"); + // A halo's own colour and width, and the letter spacing, grow with the label too. + let c = &mut m.layer_mut(id).unwrap().labels.default; + (c.halo_color, c.halo_width, c.spacing) = (Some(Rgba::rgb(255, 0, 0)), Some(1.5), 1.0); + let r = render(&mut m, &view).unwrap(); + let l = r.labels.first().expect("placed"); + assert_eq!((l.halo, l.halo_width, l.spacing), (Some(Rgba::rgb(255, 0, 0)), Some(3.0), 2.0)); + // Without them, as every label was drawn before: the automatic colour, thin, unspaced. + let c = &mut m.layer_mut(id).unwrap().labels.default; + (c.halo_color, c.halo_width, c.spacing) = (None, None, 0.0); + let r = render(&mut m, &view).unwrap(); + let l = r.labels.first().expect("placed"); + assert_eq!((l.halo, l.halo_width, l.spacing), (Some(halo_for(c_color(&m, id))), None, 0.0)); + } + + fn c_color(m: &Map, id: u32) -> Rgba { + m.layer(id).unwrap().labels.default.color + } + + #[test] + fn a_halo_of_its_own_width_spreads_round_the_letters_in_one_coat() { + let label = |halo: Rgba, halo_width: Option| PlacedLabel { + text: "H".into(), + x: 50.0, + y: 50.0, + size: 20.0, + color: Rgba::rgb(0, 0, 0), + halo: Some(halo), + halo_width, + bold: false, + icon: None, + align: 0.0, + framed: false, + italic: false, + spacing: 0.0, + font: None, + line_align: TextAlign::Left, + angle: None, + collide: Default::default(), + glyphs: Vec::new(), + }; + let drawn = |l: &PlacedLabel| { + let mut pm = Pixmap::new(100, 100).unwrap(); + pm.fill(tiny_skia::Color::from_rgba8(128, 128, 128, 255)); + text::draw(&mut pm, l, 1.0); + pm + }; + // The leftmost column with a pixel matching `f`. + let left = |pm: &Pixmap, f: &dyn Fn(tiny_skia::PremultipliedColorU8) -> bool| { + (0..100).find(|&x| (0..100).any(|y| pm.pixel(x, y).is_some_and(f))).unwrap() as i32 + }; + let red = Rgba::new(255, 0, 0, 128); + let thin = drawn(&label(red, None)); + let wide = drawn(&label(red, Some(3.0))); + let ink = left(&wide, &|p| p.red() < 40 && p.green() < 40); + let reach = ink - left(&wide, &|p| p.red() > 160 && p.green() < 100); + // 3 pt beyond the letters: 4 pixels, as a 20 pt text is 26.6 pixels tall. + assert!((3..=5).contains(&reach), "{reach}"); + assert!(ink - left(&thin, &|p| p.red() > 160 && p.green() < 100) <= 2, "the thin halo as before"); + // One coat of the see-through red over the grey: nowhere redder than one coat makes. + let one_coat = (255.0 * 0.5 + 128.0 * 0.5_f32).round() as u8 + 2; + assert!(wide.pixels().iter().all(|p| p.red() <= one_coat), "a pixel coated twice"); + // A width grown with its label at a reference scale isn't cut back, whatever the text's + // size (a 10 pt halo on 9 pt text at twice the size: 20 pt); past the most a reference + // scale grows a 10 pt width, and for no number, it is held. + assert_eq!( + (text::halo_reach(20.0), text::halo_reach(1e9), text::halo_reach(f32::NAN), text::halo_reach(-3.0)), + (20.0, 320.0, 0.0, 0.0) + ); + // No width, no halo: not even round the letters' edges. + assert_eq!(drawn(&label(red, Some(0.0))).data(), drawn(&PlacedLabel { halo: None, ..label(red, None) }).data()); + // A spaced label is drawn with its letters farther apart. + let two = |spacing: f32| drawn(&PlacedLabel { text: "II".into(), spacing, halo: None, ..label(red, None) }); + let span = |pm: &Pixmap| { + let cols: Vec = (0..100).filter(|&x| (0..100).any(|y| pm.pixel(x, y).is_some_and(|p| p.red() < 40))).collect(); + cols.last().unwrap() - cols.first().unwrap() + }; + let (tight, spaced) = (span(&two(0.0)), span(&two(4.0))); + assert!(spaced >= tight + 4, "{tight} → {spaced}"); } #[test] diff --git a/crates/render/src/line_labels.rs b/crates/render/src/line_labels.rs index a4ae18640..7cfc482c6 100644 --- a/crates/render/src/line_labels.rs +++ b/crates/render/src/line_labels.rs @@ -154,12 +154,15 @@ pub(super) fn parallel(line: &[(f32, f32)], len: f32, overrun: f32) -> Option<(f a_to_b.is_finite().then_some(((a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0, a_to_b)) } -/// Characters `advances` wide (points), in turn along `line` at its middle: the label's centre, -/// and each character's centre relative to it and angle (radians, clockwise, upright). `None` -/// when the line (with `overrun` past each end) is shorter than the text, or turns more than +/// Characters `advances` wide, `gap` apart (letter spacing; both in the label's units, points × +/// 1.33, as its text's size), in turn along `line` +/// at its middle: the label's centre, and each character's centre relative to it and angle +/// (radians, clockwise, upright). `None` when the line (with `overrun` past each end) is shorter than the text, or turns more than /// [`MAX_TURN`] from one character to the next. -pub(super) fn curved(line: &[(f32, f32)], advances: &[f32], overrun: f32) -> Option<((f32, f32), Vec<(f32, f32, f32)>)> { - let len: f32 = advances.iter().sum(); +pub(super) fn curved(line: &[(f32, f32)], advances: &[f32], gap: f32, overrun: f32) -> Option<((f32, f32), Vec<(f32, f32, f32)>)> { + // Each step from a character to the next: its advance and the letter spacing (never back). + let step = |adv: f32| (adv + gap).max(0.0); + let len: f32 = advances.iter().map(|a| step(*a)).sum::() - advances.last().map_or(0.0, |a| step(*a) - a); let path = upright(line, len, overrun)?; let total = path.length(); let centre = path.at(total / 2.0)?; @@ -184,7 +187,7 @@ pub(super) fn curved(line: &[(f32, f32)], advances: &[f32], overrun: f32) -> Opt } last = Some(turn); out.push((p.0 - centre.0, p.1 - centre.1, turn)); - d += adv; + d += step(*adv); } Some((centre, out)) } @@ -217,12 +220,26 @@ mod tests { { let (_, _, a) = parallel(&line, 40.0, 0.0).unwrap(); assert!((a - down).abs() < 1e-3, "{line:?}: {a}"); - let (_, glyphs) = curved(&line, &[10.0; 4], 0.0).unwrap(); + let (_, glyphs) = curved(&line, &[10.0; 4], 0.0, 0.0).unwrap(); assert!(glyphs.iter().all(|g| (g.2 - down).abs() < 1e-3), "{line:?}: {glyphs:?}"); assert!(glyphs.windows(2).all(|w| w[1].1 > w[0].1), "first character at the top: {glyphs:?}"); } } + #[test] + fn letter_spacing_sets_curved_characters_apart_and_needs_more_line() { + let line = [(0.0, 50.0), (100.0, 50.0)]; + let (_, tight) = curved(&line, &[10.0; 3], 0.0, 0.0).unwrap(); + let (_, spaced) = curved(&line, &[10.0; 3], 5.0, 0.0).unwrap(); + assert!((tight[1].0 - tight[0].0 - 10.0).abs() < 1e-3 && (spaced[1].0 - spaced[0].0 - 15.0).abs() < 1e-3); + assert!(spaced[1].0.abs() < 1e-3, "still centred: {spaced:?}"); + // 3 × 10 and two gaps of 40: longer than the line. + assert!(curved(&line, &[10.0; 3], 40.0, 0.0).is_none()); + // Spacing tighter than a letter doesn't step back. + let (_, back) = curved(&line, &[10.0; 3], -20.0, 0.0).unwrap(); + assert!(back.windows(2).all(|w| w[1].0 >= w[0].0), "{back:?}"); + } + #[test] fn a_line_in_view_is_cut_at_its_edges_and_where_it_is_out_of_sight() { // Across the view and out, back in and out again: two stretches, cut at the edges. @@ -245,11 +262,11 @@ mod tests { (200.0 * t.sin() + 200.0, 200.0 - 200.0 * t.cos()) }) .collect(); - let (_, glyphs) = curved(&arc, &[10.0; 8], 0.0).unwrap(); + let (_, glyphs) = curved(&arc, &[10.0; 8], 0.0, 0.0).unwrap(); assert_eq!(glyphs.len(), 8); assert!(glyphs.windows(2).all(|w| w[1].2 > w[0].2), "turning steadily clockwise"); assert!(glyphs.windows(2).all(|w| w[1].0 > w[0].0), "left to right"); // A right angle at the middle: too sharp for the text. - assert!(curved(&[(0.0, 100.0), (100.0, 100.0), (100.0, 0.0)], &[10.0; 6], 0.0).is_none()); + assert!(curved(&[(0.0, 100.0), (100.0, 100.0), (100.0, 0.0)], &[10.0; 6], 0.0, 0.0).is_none()); } } diff --git a/crates/render/src/stream_tests.rs b/crates/render/src/stream_tests.rs index 13d6e993a..99e719fd2 100644 --- a/crates/render/src/stream_tests.rs +++ b/crates/render/src/stream_tests.rs @@ -540,6 +540,7 @@ fn chunked_label_compaction_keeps_late_priorities_stable_ties_and_nan_order() { size: 10.0, color: Rgba::rgb(0, 0, 0), halo: None, + halo_width: None, bold: false, icon: None, align: 0.0, diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 63c6f23f9..6c9a892fc 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -31,7 +31,7 @@ fn font(bold: bool) -> Option<&'static FontRef<'static>> { /// The family and style a line is drawn in: a layout text's serif (#517), with its bold and /// italic, or the label font's. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] pub struct Face { pub bold: bool, pub italic: bool, @@ -358,12 +358,6 @@ pub fn wrap(line: &str, size: f32, bold: bool, spacing: f32, max_w: f32) -> Vec< out } -/// A label's width in points: its widest line, as [`draw`] lays its lines out (#577). Text drawn -/// on one line (table cells, chart names…) is measured with [`measure`]. -pub fn block_width(text: &str, size: f32, face: Face) -> f32 { - lines(text).map(|line| measure_in(line, size, face)).fold(0.0, f32::max) -} - /// The distance between a label's lines in points, as [`draw`] sets them: 1.25 × the font's /// pixel size, as a layout's text. pub fn line_pitch(size: f32) -> f32 { @@ -391,18 +385,49 @@ pub fn label_face(l: &PlacedLabel) -> Face { /// between letters (not past the last), as the map view lays it out (#535), so a text is placed /// alike on screen and in an export. A slant's overhang isn't counted, as it isn't there either. fn line_width(l: &PlacedLabel, line: &str, ppp: f32) -> f32 { - let face = label_face(l); - let advance = measure_in(line, l.size, face) * ppp; - if l.spacing == 0.0 { + spaced_line_width(line, l.size, label_face(l), l.spacing, ppp) +} + +/// [`line_width`] of a line of `size` in `face`, `spacing` points between its letters. +fn spaced_line_width(line: &str, size: f32, face: Face, spacing: f32, ppp: f32) -> f32 { + let advance = measure_in(line, size, face) * ppp; + if spacing == 0.0 { return advance; } - let letters = match face_font(face) { - Some((f, _)) => shape(line, l.size * 1.33 * ppp, f, sans_beside(f, face)).0.len(), - None => line.chars().count(), - }; + let letters = letters_of(line, face); // Letters drawn tighter than their own width (the most negative spacing) don't make it less // than nothing. - (advance + l.spacing * 1.33 * ppp * letters.saturating_sub(1) as f32).max(0.0) + (advance + spacing * 1.33 * ppp * letters.saturating_sub(1) as f32).max(0.0) +} + +/// How many glyphs a line is drawn with in `face` (letter spacing goes between them): shaped once +/// for each line and face, as a size doesn't change it, and kept while the lines kept are few. +fn letters_of(line: &str, face: Face) -> usize { + use std::collections::HashMap; + use std::sync::Mutex; + static KEPT: OnceLock>> = OnceLock::new(); + let kept = KEPT.get_or_init(Mutex::default); + let key = (line.to_string(), face); + if let Some(n) = kept.lock().unwrap_or_else(|e| e.into_inner()).get(&key) { + return *n; + } + let n = match face_font(face) { + Some((f, _)) => shape(line, 12.0, f, sans_beside(f, face)).0.len(), + None => line.chars().count(), + }; + let mut kept = kept.lock().unwrap_or_else(|e| e.into_inner()); + if kept.len() >= 4096 { + kept.clear(); + } + kept.insert(key, n); + n +} + +/// A label's width in points: its widest line, as [`draw`] lays its lines out (#577), `spacing` +/// points between the letters. Text drawn on one line (table cells, chart names…) is measured +/// with [`measure`]. +pub fn spaced_block_width(text: &str, size: f32, face: Face, spacing: f32) -> f32 { + lines(text).map(|line| spaced_line_width(line, size, face, spacing, 1.0)).fold(0.0, f32::max) } /// A label's width in points: its widest line as [`draw`] lays it out, its style included. @@ -578,7 +603,8 @@ enum Ink { fn draw_turned(pm: &mut Pixmap, l: &PlacedLabel, angle: f32, ppp: f32, ink: Ink) { let (w, h) = (label_width(l), block_height(&l.text, l.size).max(line_pitch(l.size))); // Room for the halo, and for the glyphs reaching past the line. - let pad = 4.0 + l.size * 0.5; + // (The halo is drawn at most MAX_HALO_PX wide, so no more room is kept for it.) + let pad = 4.0 + l.size * 0.5 + drawn_halo_reach(l).min(MAX_HALO_PX / ppp.max(f32::EPSILON)); let (pw, ph) = (((w + 2.0 * pad) * ppp).ceil(), ((h + 2.0 * pad) * ppp).ceil()); if !(pw.is_finite() && ph.is_finite()) || pw > 4096.0 || ph > 4096.0 { return; @@ -632,12 +658,54 @@ fn draw_upright(pm: &mut Pixmap, l: &PlacedLabel, ppp: f32, ink: Ink) { }; // Every line's halo first, so none covers the line above it. if let Some(h) = l.halo.filter(|_| ink != Ink::Text) { - let r = 1.2 * ppp; - let ring = - [(-r, 0.0), (r, 0.0), (0.0, -r), (0.0, r), (-r * 0.7, -r * 0.7), (r * 0.7, r * 0.7), (-r * 0.7, r * 0.7), (r * 0.7, -r * 0.7)]; - for line in &placed { - for offset in ring { - put(pm, line, h, offset); + match l.halo_width { + // As wide as its class sets it: the letters spread that far all round, as QGIS's + // buffer, in one coat (a see-through halo isn't darker where letters are close; on a + // curved label, each letter's is its own). + Some(width) => { + // At most MAX_HALO_PX: a halo grown far past any map's needs (a reference scale + // at a high resolution) can't take the memory and time of a huge one. + let r = (halo_reach(width) * 1.33 * ppp).min(MAX_HALO_PX); + // Its reach, and room for a slant's overhang (the lines' rows already hold the + // letters' height). + let (pad, x0, x1) = ( + r + 0.4 * px + 2.0, + placed.iter().map(|p| p.1).fold(f32::MAX, f32::min), + placed.iter().map(|p| p.1 + p.2).fold(f32::MIN, f32::max), + ); + let (y0, y1) = (top - line_h, top + n as f32 * line_h); + // Only what can show: the picture's part of it, and its halo's reach beyond. + let (pw, ph) = (pm.width() as f32, pm.height() as f32); + let (x0, y0) = ((x0 - pad).max(-pad).floor(), (y0 - pad).max(-pad).floor()); + let (x1, y1) = ((x1 + pad).min(pw + pad).ceil(), (y1 + pad).min(ph + pad).ceil()); + // (None at all for a halo no wider than nothing.) + if let Some(mut mask) = (r > 0.0 && x1 > x0 && y1 > y0).then(|| Pixmap::new((x1 - x0) as u32, (y1 - y0) as u32)).flatten() { + for line in &placed { + put(&mut mask, line, Rgba::rgb(0, 0, 0), (-x0, -y0)); + } + let (w, cover) = (mask.width() as usize, spread(&mask, r)); + for (i, c) in cover.into_iter().enumerate() { + blend(pm, x0 as i32 + (i % w) as i32, y0 as i32 + (i / w) as i32, h, c); + } + } + } + None => { + let r = 1.2 * ppp; + let ring = [ + (-r, 0.0), + (r, 0.0), + (0.0, -r), + (0.0, r), + (-r * 0.7, -r * 0.7), + (r * 0.7, r * 0.7), + (-r * 0.7, r * 0.7), + (r * 0.7, -r * 0.7), + ]; + for line in &placed { + for offset in ring { + put(pm, line, h, offset); + } + } } } } @@ -649,6 +717,129 @@ fn draw_upright(pm: &mut Pixmap, l: &PlacedLabel, ppp: f32, ink: Ink) { } } +/// The widest halo a label class sets (points); a reference scale grows it up to +/// [`rustgis_map::SIZE_FACTORS`] times. +const MAX_HALO: f32 = *rustgis_map::symbology::HALO_WIDTH.end() as f32; + +/// How far past its letters a label's halo of its own width reaches (the label's units: points × +/// 1.33, as its text's size), none when no halo is drawn (turned off, or fully see-through). Labels +/// are placed once for every pixel density they are drawn at, so this is the reach the class asks +/// for; only drawing holds a halo to [`MAX_HALO_PX`] pixels. +pub fn drawn_halo_reach(l: &PlacedLabel) -> f32 { + l.halo.filter(|h| h.a() > 0).and(l.halo_width).map_or(0.0, |w| halo_reach(w) * 1.33) +} + +/// How far a label's halo of `width` reaches (points): at most [`MAX_HALO`] grown by the most a +/// reference scale grows a label ([`rustgis_map::SIZE_FACTORS`]), so one grown with its label isn't +/// cut back (its class's own width is already within [`MAX_HALO`]); none for a width of no number. +pub fn halo_reach(width: f32) -> f32 { + let most = MAX_HALO * rustgis_map::SIZE_FACTORS.1 as f32; + if width.is_finite() { width.clamp(0.0, most) } else { 0.0 } +} + +/// The widest halo drawn on a picture (pixels; about 64 points at 300 dpi): with the mask held to +/// the picture and this reach round it, a halo's memory and time stay near the picture's own. +const MAX_HALO_PX: f32 = 256.0; + +/// How much of each of `mask`'s pixels a halo `r` pixels wide covers: its letters' coverage spread +/// `r` pixels all round, the edge smooth (a disc, as a round-joined stroke reaches). The distance +/// from each pixel to the nearest one the letters mostly cover is exact (a squared Euclidean +/// distance transform, Felzenszwalb and Huttenlocher), so the work grows with the mask's area, +/// not with the halo's width. +fn spread(mask: &Pixmap, r: f32) -> Vec { + const FAR: f64 = 1e20; + let (w, h) = (mask.width() as usize, mask.height() as usize); + let cover: Vec = mask.pixels().iter().map(|p| f32::from(p.alpha()) / 255.0).collect(); + let mut d: Vec = cover.iter().map(|&c| if c >= 0.5 { 0.0 } else { FAR }).collect(); + let mut edt = Edt::default(); + // Down each column, then along each row. + let mut col = vec![FAR; h]; + for x in 0..w { + for (y, c) in col.iter_mut().enumerate() { + *c = d.get(y * w + x).copied().unwrap_or(FAR); + } + for (y, v) in edt.run(&col).iter().enumerate() { + if let Some(slot) = d.get_mut(y * w + x) { + *slot = *v; + } + } + } + for y in 0..h { + if let Some(row) = d.get_mut(y * w..(y + 1) * w) { + let done = edt.run(row); + row.copy_from_slice(done); + } + } + cover.iter().zip(&d).map(|(&c, &d2)| c.max((r + 0.5 - d2.sqrt() as f32).clamp(0.0, 1.0))).collect() +} + +/// The one-dimensional squared distance transform (the lower envelope of parabolas), its buffers +/// kept between rows. +#[derive(Default)] +struct Edt { + v: Vec, + z: Vec, + out: Vec, +} + +impl Edt { + /// For each `q`, the least `(q - p)² + f[p]` over `p`. + fn run(&mut self, f: &[f64]) -> &[f64] { + let n = f.len(); + self.v.clear(); + self.v.resize(n.max(1), 0); + self.z.clear(); + self.z.resize(n + 1, f64::INFINITY); + self.out.clear(); + self.out.resize(n, 0.0); + if n == 0 { + return &self.out; + } + let (v, z) = (&mut self.v, &mut self.z); + let at = |p: usize| f[p] + (p * p) as f64; + let mut k = 0; + z[0] = f64::NEG_INFINITY; + for q in 1..n { + let mut s = (at(q) - at(v[k])) / (2.0 * (q - v[k]) as f64); + // z[0] is -inf, so this stops at k = 0. + while s <= z[k] { + k -= 1; + s = (at(q) - at(v[k])) / (2.0 * (q - v[k]) as f64); + } + k += 1; + v[k] = q; + z[k] = s; + z[k + 1] = f64::INFINITY; + } + k = 0; + for q in 0..n { + while z[k + 1] < q as f64 { + k += 1; + } + let p = v[k]; + let dq = q as f64 - p as f64; + self.out[q] = dq * dq + f[p]; + } + &self.out + } +} + +/// [`spread`] read directly, for tests: each pixel's distance to every pixel the letters mostly +/// cover. +#[cfg(test)] +fn spread_direct(mask: &Pixmap, r: f32) -> Vec { + let (w, h) = (mask.width() as usize, mask.height() as usize); + let cover: Vec = mask.pixels().iter().map(|p| f32::from(p.alpha()) / 255.0).collect(); + let seeds: Vec<(usize, usize)> = (0..w * h).filter(|&i| cover[i] >= 0.5).map(|i| (i % w, i / w)).collect(); + (0..w * h) + .map(|i| { + let (x, y) = ((i % w) as f32, (i / w) as f32); + let d = seeds.iter().map(|&(sx, sy)| (x - sx as f32).hypot(y - sy as f32)).fold(f32::INFINITY, f32::min); + cover[i].max((r + 0.5 - d).clamp(0.0, 1.0)) + }) + .collect() +} + /// The selection's frame: a light fill and an outline in the selection colour. fn frame(pm: &mut Pixmap, r: Rect, ppp: f32) { let path = tiny_skia::PathBuilder::from_rect(r); @@ -691,6 +882,7 @@ pub fn scale_bar(pm: &mut Pixmap, view: &View, ground_per_pt: f64) { size: 8.0, color: Rgba::rgb(30, 30, 30), halo: None, + halo_width: None, bold: false, icon: None, align: 0.0, @@ -732,6 +924,96 @@ pub fn text_at(pm: &mut Pixmap, text: &str, x: f32, y: f32, px_size: f32, color: draw_text(pm, text, cx, y, px_size, color, bold, (0.0, 0.0)); } +#[cfg(test)] +mod spread_tests { + use super::*; + + /// A mask with letter-like strokes: solid and partly covered pixels, and empty rows and columns. + fn mask(w: u32, h: u32) -> Pixmap { + let mut pm = Pixmap::new(w, h).unwrap(); + for (x0, y0, x1, y1, a) in + [(3, 2, 5, 18, 255u8), (5, 9, 14, 10, 255), (20, 4, 21, 5, 140), (24, 12, 27, 16, 255), (26, 2, 27, 3, 90)] + { + for y in y0..y1 { + for x in x0..x1 { + if let Some(px) = pm.pixels_mut().get_mut((y * w + x) as usize) { + *px = tiny_skia::PremultipliedColorU8::from_rgba(0, 0, 0, a).unwrap(); + } + } + } + } + pm + } + + /// A spaced line's letters are counted once for each line and face, whatever its size or + /// pixel density, and its width is as when they were counted for each query. + #[test] + fn spaced_widths_count_the_letters_once() { + let face = Face::default(); + let n = letters_of("Rhein", face); + assert_eq!((n, letters_of("Rhein", face)), (n, n)); + assert!(n >= 5, "{n}"); + let (plain, spaced) = (spaced_line_width("Rhein", 12.0, face, 0.0, 2.0), spaced_line_width("Rhein", 12.0, face, 1.0, 2.0)); + assert!((spaced - plain - 1.33 * 2.0 * (n - 1) as f32).abs() < 1e-3, "{plain} {spaced}"); + assert_eq!(spaced_line_width("Rhein", 30.0, face, 1.0, 2.0) - spaced_line_width("Rhein", 30.0, face, 0.0, 2.0), spaced - plain); + } + + /// The distance transform gives what reading every pixel's distance to every letter pixel + /// gives, at halos thinner than a pixel and wider than the mask. + #[test] + fn the_spread_is_the_direct_reading() { + let m = mask(32, 22); + for r in [0.3, 0.6, 1.0, 2.3, 4.5, 7.0, 40.0] { + let (fast, direct) = (spread(&m, r), spread_direct(&m, r)); + let worst = fast.iter().zip(&direct).map(|(a, b)| (a - b).abs()).fold(0.0, f32::max); + assert!(worst < 1e-4, "r {r}: off by {worst}"); + } + // An empty mask (no letters) spreads nothing, and a mask of one row or column works. + assert!(spread(&Pixmap::new(9, 7).unwrap(), 3.0).iter().all(|&c| c == 0.0)); + let line = mask(32, 1); + assert_eq!(spread(&line, 2.0).len(), 32); + } + + /// A halo grown far past what a map needs is drawn at most MAX_HALO_PX wide: a pixel past that + /// reach from the letter, though within the halo's own width, is left as it was. + #[test] + fn a_huge_halo_is_bounded() { + let label = |halo: Option, halo_width: Option| crate::PlacedLabel { + text: "I".into(), + x: 200.0, + y: 170.0, + size: 100.0, + color: Rgba::rgb(0, 0, 0), + halo, + halo_width, + bold: false, + icon: None, + align: 0.0, + framed: false, + italic: false, + spacing: 0.0, + font: None, + line_align: TextAlign::Left, + angle: None, + collide: Default::default(), + glyphs: Vec::new(), + }; + // At 3 pixels a point: the letter's row through its middle (170 points down). + let (w, row) = (3000usize, 510usize); + let draw_at = |l: &crate::PlacedLabel| { + let mut pm = Pixmap::new(w as u32, 1500).unwrap(); + draw(&mut pm, l, 3.0); + pm.pixels().get(row * w..(row + 1) * w).unwrap().iter().map(|p| p.alpha()).collect::>() + }; + let bare = draw_at(&label(None, None)); + let right = bare.iter().rposition(|&a| a > 0).expect("the letter crosses its middle row"); + // A 100 pt halo would reach 100 × 1.33 × 3 ≈ 400 pixels; it reaches MAX_HALO_PX (256). + let haloed = draw_at(&label(Some(Rgba::rgb(255, 0, 0)), Some(100.0))); + assert!(haloed[right + 200] > 0, "within the cap: painted"); + assert_eq!(haloed[right + 300], 0, "past the cap, within the halo's own width: left bare"); + } +} + #[cfg(test)] mod coverage_tests { /// Large letters slanted by hand are solid: every row of an "I"'s stem has a fully covered diff --git a/crates/render/src/tiles.rs b/crates/render/src/tiles.rs index fa1b03047..57f05f394 100644 --- a/crates/render/src/tiles.rs +++ b/crates/render/src/tiles.rs @@ -72,6 +72,7 @@ pub(crate) fn tile_label(l: rustgis_tiles::Label) -> Option<(PlacedLabel, f64)> size, color: Rgba(l.color), halo: l.halo.map(Rgba), + halo_width: None, bold: l.bold, icon, align: -l.anchor[0], diff --git a/crates/ui-egui/src/labelpane.rs b/crates/ui-egui/src/labelpane.rs index 78f49419e..cb9052f52 100644 --- a/crates/ui-egui/src/labelpane.rs +++ b/crates/ui-egui/src/labelpane.rs @@ -7,7 +7,9 @@ //! View Unplaced) are on the ribbon only, and Convert on the ribbon and the layer's menu. use egui::{Align2, CornerRadius, Rect, RichText, Sense, Stroke, Ui, pos2, vec2}; -use rustgis_engine::map::symbology::{DEFAULT_LABEL_CLASS, LABEL_SIZE, LabelClass, Labeling, MAX_LABEL_CLASSES, label_class_name}; +use rustgis_engine::map::symbology::{ + DEFAULT_LABEL_CLASS, HALO_WIDTH, LABEL_SIZE, LabelClass, Labeling, MAX_LABEL_CLASSES, label_class_name, +}; use rustgis_engine::map::{LabelPlacement, Layer, Rgba}; use rustgis_engine::render::families; use serde_json::{Value, json}; @@ -555,9 +557,86 @@ fn text_symbol(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, c: &Class) { } }); ui.end_row(); + letter_spacing(app, ui, l, c); + ui.end_row(); + if c.it.halo { + halo_style(app, ui, l, c); + } }); } +/// The space between the letters, in points (negative: tighter). +fn letter_spacing(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, c: &Class) { + let input_label = ui.label(tl!("Letter spacing")); + let (lo, hi) = (rustgis_engine::map::layout::MIN_LETTER_SPACING, rustgis_engine::map::layout::MAX_LETTER_SPACING); + let mut spacing = f64::from(c.it.spacing); + let r = ui.add(egui::DragValue::new(&mut spacing).range(lo..=hi).speed(0.05).suffix(" pt")).labelled_by(input_label.id); + let id = l.id; + crate::ribbon::live_drag( + app, + ui, + &r, + f64::from(c.it.spacing), + spacing, + |app, x| { + if let Some(k) = app.session.map_mut().layer_mut(id).and_then(|l| class_mut(&mut l.labels, c.name)) { + k.spacing = x.clamp(lo, hi) as f32; + } + }, + |app, x| { + run(app, "labels.style", c.params(json!({"spacing": x}))); + }, + ); +} + +/// The halo's colour (automatic: white or dark, whichever stands out from the text) and width: +/// the thin halo, or one of its own width in points. +fn halo_style(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, c: &Class) { + ui.label(tl!("Halo color")); + ui.horizontal(|ui| { + let mut auto = c.it.halo_color.is_none(); + if ui.checkbox(&mut auto, tl!("Automatic")).on_hover_text(tl!("White behind dark text, dark behind light.")).changed() { + let v = if auto { json!("") } else { json!(c.it.halo_color_for(c.it.color).hex()) }; + run(app, "labels.style", c.params(json!({"haloColor": v}))); + } + if !auto { + let mut color = c32(c.it.halo_color_for(c.it.color)); + if crate::widgets::color_button(ui, &mut color).changed() { + halo_color_drag(app, ui, l, c.name, rgba(color)); + } + } + }); + ui.end_row(); + let input_label = ui.label(tl!("Halo width")); + ui.horizontal(|ui| { + let mut thin = c.it.halo_width.is_none(); + if ui.checkbox(&mut thin, tl!("Thin")).changed() { + let v = if thin { json!(null) } else { json!(1.0) }; + run(app, "labels.style", c.params(json!({"haloWidth": v}))); + } + let Some(width) = c.it.halo_width else { return }; + let mut w = f64::from(width); + let r = ui.add(egui::DragValue::new(&mut w).range(HALO_WIDTH).speed(0.05).suffix(" pt")).labelled_by(input_label.id); + let id = l.id; + crate::ribbon::live_drag( + app, + ui, + &r, + f64::from(width), + w, + |app, x| { + if let Some(k) = app.session.map_mut().layer_mut(id).and_then(|l| class_mut(&mut l.labels, c.name)) { + k.halo_width = Some(x.clamp(*HALO_WIDTH.start(), *HALO_WIDTH.end()) as f32); + } + }, + |app, x| { + run(app, "labels.style", c.params(json!({"haloWidth": x}))); + }, + ); + }); + ui.end_row(); +} + /// The most installed families the font list shows at once (a machine can have thousands); the /// rest are found by typing part of a name. const MAX_FONTS_SHOWN: usize = 200; @@ -716,27 +795,52 @@ struct ColorDrag { layer: u32, /// The class picked on: `None` for Default. class: Option, - before: Rgba, + /// The halo's colour rather than the text's. + halo: bool, + /// (A halo's `None` is its automatic colour.) + before: Option, color: Rgba, } +/// The colour a pick changes on a class: the text's, or the halo's own (`None`: automatic). +fn picked(k: &LabelClass, halo: bool) -> Option { + if halo { k.halo_color } else { Some(k.color) } +} + +fn set_picked(k: &mut LabelClass, halo: bool, c: Option) { + match (halo, c) { + (true, c) => k.halo_color = c, + (false, Some(c)) => k.color = c, + (false, None) => {} + } +} + /// Previews a colour pick on its layer's class (`None`: Default) until `finish_color_drag` commits /// the completed gesture. pub(crate) fn color_drag(app: &mut RustGisApp, ui: &Ui, l: &Layer, class: Option<&str>, color: Rgba) { + pick_drag(app, ui, l, class, false, color); +} + +/// [`color_drag`] for the class's halo colour. +fn halo_color_drag(app: &mut RustGisApp, ui: &Ui, l: &Layer, class: Option<&str>, color: Rgba) { + pick_drag(app, ui, l, class, true, color); +} + +fn pick_drag(app: &mut RustGisApp, ui: &Ui, l: &Layer, class: Option<&str>, halo: bool, color: Rgba) { let key = egui::Id::unique(COLOR_DRAG); let mut pending = ui.data(|d| d.get_temp::(key)); - // One left pending on another layer or class (the selection changed mid-pick, from the - // Assistant or a shortcut) is finished first rather than dropped with its preview in place. - if let Some(other) = pending.take_if(|d| d.layer != l.id || d.class.as_deref() != class) { + // One left pending on another layer, class or colour (the selection changed mid-pick, from + // the Assistant or a shortcut) is finished first rather than dropped with its preview in place. + if let Some(other) = pending.take_if(|d| d.layer != l.id || d.class.as_deref() != class || d.halo != halo) { ui.data_mut(|d| d.remove::(key)); apply_color_drag(app, other); } - let Some(now) = class.map_or(Some(&l.labels.default), |n| l.labels.class(n)).map(|k| k.color) else { return }; + let Some(now) = class.map_or(Some(&l.labels.default), |n| l.labels.class(n)).map(|k| picked(k, halo)) else { return }; // The colour before the first frame of this pick, kept while it goes on. let before = pending.map_or(now, |d| d.before); - ui.data_mut(|d| d.insert_temp(key, ColorDrag { layer: l.id, class: class.map(String::from), before, color })); + ui.data_mut(|d| d.insert_temp(key, ColorDrag { layer: l.id, class: class.map(String::from), halo, before, color })); if let Some(k) = app.session.map_mut().layer_mut(l.id).and_then(|l| class_mut(&mut l.labels, class)) { - k.color = color; + set_picked(k, halo, Some(color)); } app.session.revision = app.session.revision.wrapping_add(1); } @@ -769,14 +873,15 @@ fn apply_color_drag(app: &mut RustGisApp, drag: ColorDrag) { let Some(k) = app.session.map_mut().layer_mut(drag.layer).and_then(|l| class_mut(&mut l.labels, drag.class.as_deref())) else { return; }; - k.color = drag.before; - if drag.before != drag.color { + set_picked(k, drag.halo, drag.before); + if drag.before != Some(drag.color) { // On the layer the pick was made on, as the active one: the command reaches it by id // (a name two layers share would reach the first), and a recorded step names it, as // for the pane's other commands. The selection, if it moved mid-pick, is put back (as // `Session::run` does to check a named layer's conditions). let selected = app.session.active_layer.replace(drag.layer); - run(app, "labels.style", named(json!({"color": drag.color.hex()}), "class", drag.class.as_deref())); + let key = if drag.halo { "haloColor" } else { "color" }; + run(app, "labels.style", named(json!({key: drag.color.hex()}), "class", drag.class.as_deref())); app.session.active_layer = selected; } } @@ -855,6 +960,35 @@ mod tests { assert!(h.query_by_label("+ New expression").is_some(), "the SQL query is beside the field"); } + #[test] + fn the_halos_colour_and_width_and_the_letter_spacing_are_set_in_the_pane() { + use egui::accesskit::Role; + let mut h = harness(with_cities()); + h.run(); + open_tab(&mut h, "Symbol"); + assert!(h.query_by_role_and_label(Role::SpinButton, "Letter spacing").is_some()); + assert!(h.query_by_role_and_label(Role::SpinButton, "Halo width").is_none(), "thin: no width to set"); + let class = |h: &egui_kittest::Harness<'_, RustGisApp>| { + let c = &h.state().session.map().layer_by_name("Cities").unwrap().labels.default; + (c.halo_color, c.halo_width) + }; + assert_eq!(class(&h), (None, None)); + // Off Automatic: the colour it had, now its own; off Thin: a width of its own. + h.get_by_label("Automatic").click(); + h.run(); + h.get_by_label("Thin").click(); + h.run(); + assert_eq!(class(&h), (Some(Rgba::new(255, 255, 255, 225)), Some(1.0))); + assert!(h.query_by_role_and_label(Role::SpinButton, "Halo width").is_some()); + h.get_by_label("Thin").click(); + h.run(); + assert_eq!(class(&h).1, None); + // Without a halo, neither row. + h.get_by_label("Halo").click(); + h.run(); + assert!(h.query_by_label("Automatic").is_none() && h.query_by_label("Thin").is_none()); + } + #[test] fn the_pane_command_needs_a_feature_layer() { let mut app = RustGisApp::with_sample(); diff --git a/crates/ui-egui/src/mapview.rs b/crates/ui-egui/src/mapview.rs index b37806856..9471c207e 100644 --- a/crates/ui-egui/src/mapview.rs +++ b/crates/ui-egui/src/mapview.rs @@ -1344,10 +1344,10 @@ fn draw_label_ink(p: &egui::Painter, l: &PlacedLabel, at: Pos2, halo: bool, text .with_override_text_color(color) .with_angle_and_anchor(angle, Align2::CENTER_CENTER) }; - if let Some(h) = l.halo.filter(|_| halo) { - let hc = c32(h); - for (dx, dy) in [(-1.0, 0.0), (1.0, 0.0), (0.0, -1.0), (0.0, 1.0), (-0.8, -0.8), (0.8, 0.8), (-0.8, 0.8), (0.8, -0.8)] { - p.add(shape(vec2(dx, dy), hc)); + if let Some(h) = l.halo.filter(|h| halo && h.a() > 0) { + let (offsets, hc) = halo_coats(l, h); + for offset in offsets { + p.add(shape(offset, hc)); } } if text { @@ -1361,10 +1361,10 @@ fn draw_label_ink(p: &egui::Painter, l: &PlacedLabel, at: Pos2, halo: bool, text let cyan = c32(rustgis_engine::render::SELECTION); p.rect(r.expand(3.0), 0.0, cyan.gamma_multiply(40.0 / 255.0), Stroke::new(2.0, cyan), egui::StrokeKind::Outside); } - if let Some(h) = l.halo.filter(|_| halo) { - let hc = c32(h); - for (dx, dy) in [(-1.0, 0.0), (1.0, 0.0), (0.0, -1.0), (0.0, 1.0), (-0.8, -0.8), (0.8, 0.8), (-0.8, 0.8), (0.8, -0.8)] { - p.galley_with_override_text_color(pos + vec2(dx, dy), galley.clone(), hc); + if let Some(h) = l.halo.filter(|h| halo && h.a() > 0) { + let (offsets, hc) = halo_coats(l, h); + for offset in offsets { + p.galley_with_override_text_color(pos + offset, galley.clone(), hc); } } if text { @@ -1372,6 +1372,48 @@ fn draw_label_ink(p: &egui::Painter, l: &PlacedLabel, at: Pos2, halo: bool, text } } +/// The most copies of a label's text a halo of its own width is drawn with. +const MAX_HALO_COATS: usize = 64; + +/// Where a label's halo of colour `h` is drawn round its text (points), and in what colour each +/// copy: a thin ring, or for a halo of its own width rings about 1.5 points apart out to that +/// width (1.33 × in screen points, as the text's size), copies about a point apart round each +/// (at most [`MAX_HALO_COATS`] in all, shared among the rings by their length), in a see-through +/// colour shared among the rings. A halo of a few points (QGIS's usual buffer) is solid with few +/// copies; a very wide one is approximated, and a see-through one is about one coat (copies +/// overlap near the letters, so it is a little darker there): an export draws both exactly. +fn halo_coats(l: &PlacedLabel, h: rustgis_engine::map::Rgba) -> (Vec, Color32) { + let Some(width) = l.halo_width else { + let ring = [(-1.0, 0.0), (1.0, 0.0), (0.0, -1.0), (0.0, 1.0), (-0.8, -0.8), (0.8, 0.8), (-0.8, 0.8), (0.8, -0.8)]; + return (ring.into_iter().map(|(x, y)| vec2(x, y)).collect(), c32(h)); + }; + let reach = rustgis_engine::render::halo_reach(width) * 1.33; + // No width, or one that isn't a number: no halo. + if reach.is_nan() || reach <= 0.0 { + return (Vec::new(), c32(h)); + } + let rings = ((reach / 1.5).ceil() as usize).clamp(1, 6); + let step = reach / rings as f32; + // Ring i (outermost first) has radius reach - i·step; copies go to each by its length. + let radii: Vec = (0..rings).map(|i| reach - i as f32 * step).collect(); + let total: f32 = radii.iter().sum(); + let mut out = Vec::new(); + for r in radii { + // A copy about a point apart round the ring, within its share of the budget (rounded down, + // so the copies stay within it; the innermost of at most six rings still gets three). + let share = ((MAX_HALO_COATS as f32 * r / total).floor() as usize).max(3); + let n = ((std::f32::consts::TAU * r).ceil() as usize).clamp(3, share); + out.extend((0..n).map(|k| { + let (sin, cos) = (std::f32::consts::TAU * k as f32 / n as f32).sin_cos(); + vec2(r * cos, r * sin) + })); + } + // About one copy of each ring covers a pixel of the halo. + let a = f32::from(h.a()) / 255.0; + let each = if a < 1.0 { 1.0 - (1.0 - a).powf(1.0 / rings as f32) } else { 1.0 }; + (out, c32(h.with_alpha((each * 255.0).round() as u8))) +} + /// The globe as seen in this view, when the map is a globe. fn globe(app: &RustGisApp, rect: Rect) -> Option { let m = app.session.map(); @@ -3568,6 +3610,74 @@ mod tile_redraw_tests { } } +#[cfg(test)] +mod halo_tests { + use super::*; + use rustgis_engine::map::Rgba; + + fn label(halo_width: Option) -> PlacedLabel { + PlacedLabel { + text: "A".into(), + x: 0.0, + y: 0.0, + size: 10.0, + color: Rgba::rgb(0, 0, 0), + halo: Some(Rgba::rgb(255, 255, 255)), + halo_width, + bold: false, + icon: None, + align: 0.0, + framed: false, + italic: false, + spacing: 0.0, + font: None, + line_align: TextAlign::Left, + angle: None, + collide: Default::default(), + glyphs: Vec::new(), + } + } + + #[test] + fn a_halos_copies_reach_its_width_and_stay_few_and_one_coat_see_through() { + // The thin halo as it always was: eight copies a point round, in its own colour. + let (thin, c) = halo_coats(&label(None), Rgba::new(255, 255, 255, 225)); + assert_eq!((thin.len(), c), (8, Color32::from_rgba_unmultiplied(255, 255, 255, 225))); + for w in [0.5, 1.42, 3.0, 10.0, 1e9] { + let (offsets, _) = halo_coats(&label(Some(w)), Rgba::rgb(255, 255, 255)); + let far = offsets.iter().map(|o| o.length()).fold(0.0, f32::max); + let reach = rustgis_engine::render::halo_reach(w) * 1.33; + assert!((far - reach).abs() < 1e-3 && offsets.len() <= MAX_HALO_COATS, "{w}: {far} of {reach}, {}", offsets.len()); + } + assert!(halo_coats(&label(Some(0.0)), Rgba::rgb(255, 255, 255)).0.is_empty(), "no width, no halo"); + // QGIS's usual buffer (0.5 mm, 1.42 pt) is solid: neighbouring copies on a ring are well + // under a pixel apart. On every ring, at any width, the copies stand evenly round it. + // ... with few copies: about as many as the thin halo's two or three times over. + assert!(halo_coats(&label(Some(1.42)), Rgba::rgb(255, 255, 255)).0.len() <= 24); + for w in [1.42, 3.0, 10.0] { + let (offsets, _) = halo_coats(&label(Some(w)), Rgba::rgb(255, 255, 255)); + let mut rings: Vec<(f32, Vec)> = Vec::new(); + for o in offsets { + match rings.iter_mut().find(|(r, _)| (r - o.length()).abs() < 1e-3) { + Some((_, ring)) => ring.push(o), + None => rings.push((o.length(), vec![o])), + } + } + for (r, ring) in &rings { + let gap = std::f32::consts::TAU * r / ring.len() as f32; + let next = (ring[1] - ring[0]).length(); + assert!((next - 2.0 * r * (gap / (2.0 * r)).sin()).abs() < 1e-3, "{w}: uneven ring {r}"); + if w < 2.0 { + assert!(gap <= 1.0, "{w}: ring {r} has gaps of {gap}"); + } + } + } + // Half see-through, shared among its rings: each copy fainter, about one coat in all. + let (offsets, c) = halo_coats(&label(Some(3.0)), Rgba::new(255, 0, 0, 128)); + assert!(offsets.len() > 8 && c.a() < 128 && c.a() > 0, "{c:?}"); + } +} + #[cfg(test)] mod picture_tests { #[test] diff --git a/docs/user-guide/cartography.md b/docs/user-guide/cartography.md index 0d0e60f26..39a26dd9c 100644 --- a/docs/user-guide/cartography.md +++ b/docs/user-guide/cartography.md @@ -307,6 +307,13 @@ Turn labels on per layer (Labeling › Labels › **Label**), then: - **Label field and text**: the field the labels show (`labels.field={"layer": "Cities", "field": "name"}`), and the text symbol's size (4–72 points), colour, halo, bold and italic (`labels.style={"layer": "Cities", "size": 12, "color": "#5A1E0A", "halo": true}`). +- **Halo and letter spacing**: a halo is white behind dark text and dark behind light, and thin, + unless it is given a colour and a width of its own (Labeling pane › Symbol › **Halo color**, + **Halo width**: how far it reaches beyond the letters, 0–10 points). The letters can be set + apart, or closer with a negative spacing (**Letter spacing**, −5 to 50 points), along lines + too. The width and the spacing grow with the labels at a reference scale + (`labels.style={"layer": "Rivers", "haloColor": "#FFFFFF", "haloWidth": 1.4, "spacing": 0.5}`; + `"haloColor": ""` and `"haloWidth": null` go back to the automatic colour and the thin halo). - **Font**: the labels' font family, any installed on the computer (Labeling pane › Symbol › **Font**; type part of a name to find it), or the label font the interface uses. Bold and italic take the family's own bold and italic faces where it has them. The family is saved by diff --git a/docs/user-guide/workspace.md b/docs/user-guide/workspace.md index 9f877e0bc..0420a8da9 100644 --- a/docs/user-guide/workspace.md +++ b/docs/user-guide/workspace.md @@ -88,9 +88,9 @@ Each layer keeps its look: transparency, clipping and no-data settings hide as the raster's mask. - **Labels**: simple and rule-based, each rule a label class whose SQL clause is its filter and its parents' (an else rule's: none of its siblings'), with the text's field, size, colour, - bold and italic, its font family (drawn in it where it is installed, else in RustGIS's label - font), its buffer as a halo, its placement (Parallel and Curved along lines, - around and over points) and its scale range. + bold, italic and letter spacing, its font family (drawn in it where it is installed, else in + RustGIS's label font), its buffer as a halo of its colour, opacity and size, its placement + (Parallel and Curved along lines, around and over points) and its scale range. Projects and styles from QGIS 1 and 2 read too: their legend gives the layers' order, and the older names of the same settings are read. @@ -99,8 +99,8 @@ QGIS expressions carry over where they are SQL: fields compared with values, `LI `IS NULL`, joined by `AND`, `OR` and `NOT`. What RustGIS doesn't draw as QGIS does is drawn as near as it can, or left out, and said: the import's notes list, layer by layer, what was approximated (pattern fills drawn as a tint of their colour, SVG markers as circles, -dashed polygon outlines solid, label halos in RustGIS's own colour and width, label offsets and -wrapping, values outside a graduated renderer's ranges in its first or last class, file options +dashed polygon outlines solid, label halos with sharp corners drawn round and blended ones drawn +over the map, label offsets and wrapping, values outside a graduated renderer's ranges in its first or last class, file options such as `layerid` that RustGIS doesn't read) and what was skipped (arrow lines, other data-defined settings, draw effects, label callouts, labels from expressions, contours and other raster renderers RustGIS has no match for, PostGIS, WFS