From 4e773a6bf15f4fe32bf9f7b097be525541ea1d63 Mon Sep 17 00:00:00 2001 From: strebdom Date: Tue, 27 Oct 2026 08:39:28 +0100 Subject: [PATCH 1/3] feat(labels): a label's offset and background, as QGIS's offset from a point and its rectangle background MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Piece 4b (first part) of #882. - LabelClass gains `offset` ([right, down] points, ±200; a label along a line isn't moved) and `background` (a rectangle behind the text: fill and outline, each optional, outline width, padding across and up and down, corner radius). Both are left out when saved at their defaults, and a crafted value reads as none or within its range. - Drawn by the CPU renderer (exports, layouts) and the map view alike, turned with a label along a line; a curved label has none. Other labels keep off a background as off a halo; sizes grow at a reference scale. - labels.placement takes "offset" (the position may then be left out); labels.style takes "background" (true, false/null, or the parts to set). - Labeling pane: Background, Fill, Outline, Outline width, Padding and Corner radius in Symbol; Offset right and down in Position. - MapLibre export: text-offset in ems. - QGIS import: the rectangle background (its fill symbol, else its colours; padding, rounded corners, opacity), other shapes and sizes noted; placement "offset from point" xOffset/yOffset in their units; on a point layer, the distance around the point as an offset upwards. Measured in QGIS 4.2.3: a positive yOffset moves the label down, and the distance isn't used with an offset from the point, so it isn't noted there any more. - A script-completion test's pointer moved below the help, which the new parameter makes taller. --- crates/engine/src/cmd/labels.rs | 99 ++++++++++- crates/engine/src/cmd/layer.rs | 9 +- crates/engine/src/cmd/sharing.rs | 4 + crates/engine/src/tests.rs | 85 ++++++++++ crates/map/src/lib.rs | 4 +- crates/map/src/qgis/labels.rs | 140 ++++++++++++++-- crates/map/src/qgis/mod.rs | 2 +- crates/map/src/qgis/tests.rs | 112 ++++++++++++- crates/map/src/symbology.rs | 162 ++++++++++++++++++ crates/render/src/layout.rs | 2 +- crates/render/src/lib.rs | 141 +++++++++++++++- crates/render/src/stream_tests.rs | 1 + crates/render/src/text.rs | 46 ++++- crates/render/src/tiles.rs | 1 + crates/ui-egui/src/labelpane.rs | 241 ++++++++++++++++++++++++--- crates/ui-egui/src/mapview.rs | 84 +++++++++- crates/ui-egui/src/scriptcomplete.rs | 6 +- docs/user-guide/cartography.md | 14 ++ docs/user-guide/workspace.md | 10 +- 19 files changed, 1101 insertions(+), 62 deletions(-) diff --git a/crates/engine/src/cmd/labels.rs b/crates/engine/src/cmd/labels.rs index 108292589..65acb6cd5 100644 --- a/crates/engine/src/cmd/labels.rs +++ b/crates/engine/src/cmd/labels.rs @@ -3,13 +3,88 @@ use rustgis_data::{Dataset, Field, FieldType, Geometry, GeometryType, Value as Attr}; use rustgis_map::symbology::{MAX_LABEL_CLASSES, label_class_name}; -use rustgis_map::{LabelClass, LabelMatcher, LabelPlacement, Labeling, Renderer, Rgba, Symbol}; +use rustgis_map::{LabelBackground, LabelClass, LabelMatcher, LabelPlacement, Labeling, Renderer, Rgba, Symbol}; use serde_json::{Value, json}; use super::layer::s_req; use super::{b, f, need_feature_layer, ps}; use crate::{CmdResult, CommandSpec as C, Session}; +/// A number, or a number written as text (as `f` reads a parameter). +fn number(v: &Value) -> Option { + v.as_f64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok())).filter(|x: &f64| x.is_finite()) +} + +/// A label offset given as `[right, down]` (points), each kept within the range a class keeps. +fn label_offset(v: &Value) -> Option<[f32; 2]> { + let range = rustgis_map::symbology::LABEL_OFFSET; + let [x, y] = v.as_array()?.as_slice() else { return None }; + let within = |v: &Value| number(v).map(|v| v.clamp(*range.start(), *range.end()) as f32); + Some([within(x)?, within(y)?]) +} + +/// What `labels.style`'s `background` does to a class's background. +pub(super) type BackgroundChange = Box) -> Option>; + +/// Reads `labels.style`'s `background`: false or null takes it away, true puts one on (the default, +/// or the one it has), and an object sets the parts it gives on the one it has (else on the +/// default). Anything else, or a part that isn't what it should be, is an error. +pub(super) fn background_change(v: &Value) -> Result { + let o = match v { + Value::Null | Value::Bool(false) => return Ok(Box::new(|_| None)), + Value::Bool(true) => return Ok(Box::new(|b| Some(b.unwrap_or_default()))), + Value::Object(o) => o, + _ => return Err("background must be true, false, null or {fill, stroke, strokeWidth, padding, radius}".into()), + }; + // A colour, or "" or null for none. + let color = |k: &str| -> Result>, String> { + match o.get(k) { + None => Ok(None), + Some(Value::Null) => Ok(Some(None)), + Some(Value::String(c)) if c.trim().is_empty() => Ok(Some(None)), + Some(Value::String(c)) => Rgba::from_hex(c.trim()) + .map(|c| Some(Some(c))) + .ok_or_else(|| format!("background.{k} must be #RRGGBB or #RRGGBBAA, or \"\"")), + Some(_) => Err(format!("background.{k} must be #RRGGBB or #RRGGBBAA, or \"\"")), + } + }; + let number = |v: &Value| number(v).map(|v| v as f32); + let points = |k: &str| -> Result, String> { + o.get(k).map(|v| number(v).ok_or_else(|| format!("background.{k} must be a number of points"))).transpose() + }; + let (fill, stroke) = (color("fill")?, color("stroke")?); + let (stroke_width, radius) = (points("strokeWidth")?, points("radius")?); + let padding = match o.get("padding") { + None => None, + Some(v) => match v.as_array().map(Vec::as_slice) { + Some([x, y]) => Some([number(x), number(y)]), + _ => None, + } + .and_then(|[x, y]| Some([x?, y?])) + .map(Some) + .ok_or("background.padding must be [across, up and down] in points")?, + }; + Ok(Box::new(move |b| { + let mut b = b.unwrap_or_default(); + if let Some(c) = fill { + b.fill = c; + } + if let Some(c) = stroke { + b.stroke = c; + } + if let Some(w) = stroke_width { + b.stroke_width = w; + } + if let Some(p) = padding { + b.padding = p; + } + if let Some(r) = radius { + b.radius = r; + } + Some(b.checked()) + })) +} + pub fn commands() -> Vec { vec![ C::new("labels.lock", "Lock", "Labeling › Map Labeling", |s, p| { @@ -55,13 +130,27 @@ pub fn commands() -> Vec { .when(need_feature_layer) .params(r#"{"layer"?, "class"?: name (none: the Default class, by whatever name it has), "closest"?: metres|null, "farthest"?: metres|null, "clear"?: bool}"#), C::new("labels.placement", "Label Placement", "Labeling › Label Placement", |s, p| { - let code = ps(p, "position").ok_or("position is required")?; - let pos = LabelPlacement::from_code(code).ok_or_else(|| format!("unknown placement '{code}'"))?; - with_class(s, p, |c| c.placement = pos) + let pos = match ps(p, "position") { + Some(code) => Some(LabelPlacement::from_code(code).ok_or_else(|| format!("unknown placement '{code}'"))?), + None if p.get("offset").is_some() => None, + None => return Err("position is required (or an offset)".into()), + }; + let offset = match p.get("offset") { + None => None, + Some(o) => Some(label_offset(o).ok_or("offset must be [right, down] in points")?), + }; + with_class(s, p, |c| { + if let Some(pos) = pos { + c.placement = pos; + } + if let Some(o) = offset { + c.offset = o; + } + }) }) .when(need_feature_layer) .params( - r#"{"layer"?, "class"?: name (none: the Default class, by whatever name it has), "position": "auto"|"center"|"above"|"below"|"left"|"right"|"aboveRight"|"aboveLeft"|"belowRight"|"belowLeft"|"parallel" (along a line, straight)|"curved" (along a line, following its bends)}"#, + r#"{"layer"?, "class"?: name (none: the Default class, by whatever name it has), "position"?: "auto"|"center"|"above"|"below"|"left"|"right"|"aboveRight"|"aboveLeft"|"belowRight"|"belowLeft"|"parallel" (along a line, straight)|"curved" (along a line, following its bends) (required without offset), "offset"?: [right, down] points (-200 to 200 each) the label moves from where its position puts it ([0, 0]: none; a label along a line isn't moved)}"#, ), // Label classes: each labels every feature its clause takes, as in ArcGIS Pro; the classes // listed first, Default last, are placed first (`Labeling`, #732). diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 709dd4b47..84105210e 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -355,7 +355,14 @@ pub fn commands() -> Vec { None => None, Some(_) => Some(f(p, "spacing").ok_or("spacing must be a number of points")?.clamp(sp.0, sp.1) as f32), }; + let background = match p.get("background") { + None => None, + Some(v) => Some(super::labels::background_change(v)?), + }; super::labels::with_class(s, p, |c| { + if let Some(change) = &background { + c.background = change(c.background.clone()); + } if let Some(v) = halo_color { c.halo_color = v; } @@ -386,7 +393,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, "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)}"##), + .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), "background"?: false|null (none) | true (white, a thin dark outline) | {"fill"?: "#RRGGBB"|"#RRGGBBAA"|"" ("": none), "stroke"?: colour|"" (the outline; "": none), "strokeWidth"?: points 0-10, "padding"?: [across, up and down] points 0-50, "radius"?: corner radius in points 0-50} (a rectangle behind the text; what isn't given is kept, or the default's)}"##), 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 f455d6829..1afc41140 100644 --- a/crates/engine/src/cmd/sharing.rs +++ b/crates/engine/src/cmd/sharing.rs @@ -654,6 +654,10 @@ pub fn web_style(l: &Layer) -> Value { if c.spacing != 0.0 && c.size > 0.0 { layout["text-letter-spacing"] = json!(f64::from(c.spacing) / f64::from(c.size)); } + // Its offset in ems too (a background has no MapLibre equivalent: left out). + if c.offset != [0.0; 2] && c.size > 0.0 { + layout["text-offset"] = json!(c.offset.map(|o| f64::from(o) / 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}})); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 364bad1e5..ab39603c3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2183,6 +2183,81 @@ fn labels_take_a_halo_colour_and_width_and_letter_spacing() { } } +/// A label class takes an offset with its placement and a background with its text symbol; junk +/// is refused, numbers kept within their ranges, and each change undoes. +#[test] +fn labels_take_an_offset_and_a_background() { + use rustgis_map::{LabelBackground, Rgba}; + let mut s = world(); + let class = |s: &Session| { + let c = &s.map().layer_by_name("Cities").unwrap().labels.default; + (c.placement, c.offset, c.background.clone()) + }; + let (placement, offset, background) = class(&s); + assert_eq!((offset, background), ([0.0; 2], None)); + // An offset alone keeps the placement; both together set both. + let mark = s.notice_mark(); + s.run("labels.placement", &json!({"layer": "Cities", "offset": [3, "-2.5"]})).unwrap(); + assert!(s.notices_since(mark).iter().all(|n| !n.text.contains("ignores")), "every key is read"); + assert_eq!((class(&s).0, class(&s).1), (placement, [3.0, -2.5])); + s.run("labels.placement", &json!({"layer": "Cities", "position": "right", "offset": [1e9, -1e9]})).unwrap(); + assert_eq!((class(&s).0, class(&s).1), (rustgis_map::LabelPlacement::Right, [200.0, -200.0])); + s.run("labels.placement", &json!({"layer": "Cities", "position": "below"})).unwrap(); + assert_eq!(class(&s).1, [200.0, -200.0], "a position alone keeps the offset"); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(class(&s).0, rustgis_map::LabelPlacement::Right, "undoes"); + for bad in [json!({}), json!({"offset": "far"}), json!({"offset": [1]}), json!({"offset": [1, "x"]}), json!({"offset": null})] { + let mut p = bad.clone(); + p["layer"] = json!("Cities"); + assert!(s.run("labels.placement", &p).is_err(), "{bad}"); + } + // A background: on (the default's), parts set on it, off. + s.run("labels.style", &json!({"layer": "Cities", "background": true})).unwrap(); + assert_eq!(class(&s).2, Some(LabelBackground::default())); + let mark = s.notice_mark(); + let parts = json!({"fill": "#FFEE00", "stroke": "", "strokeWidth": 99, "padding": [2, "1"], "radius": -1}); + s.run("labels.style", &json!({"layer": "Cities", "background": parts})).unwrap(); + assert!(s.notices_since(mark).iter().all(|n| !n.text.contains("ignores")), "every key is read"); + let want = LabelBackground { fill: Some(Rgba::rgb(0xFF, 0xEE, 0)), stroke: None, stroke_width: 10.0, padding: [2.0, 1.0], radius: 0.0 }; + assert_eq!(class(&s).2, Some(want.clone())); + s.run("labels.style", &json!({"layer": "Cities", "background": {"radius": 1e300}})).unwrap(); + assert_eq!(class(&s).2.map(|b| b.radius), Some(50.0), "past f32's range: the most"); + s.run("labels.style", &json!({"layer": "Cities", "background": {"radius": 3}})).unwrap(); + assert_eq!(class(&s).2, Some(LabelBackground { radius: 3.0, ..want.clone() }), "what isn't given is kept"); + s.run("labels.style", &json!({"layer": "Cities", "background": true})).unwrap(); + assert_eq!(class(&s).2.map(|b| b.radius), Some(3.0), "on again keeps it"); + s.run("labels.style", &json!({"layer": "Cities", "size": 12})).unwrap(); + assert!(class(&s).2.is_some(), "other settings leave it"); + s.run("labels.style", &json!({"layer": "Cities", "background": false})).unwrap(); + assert_eq!(class(&s).2, None); + s.run("edit.undo", &json!({})).unwrap(); + assert!(class(&s).2.is_some(), "undoes"); + s.run("labels.style", &json!({"layer": "Cities", "background": null})).unwrap(); + assert_eq!(class(&s).2, None); + // Set on none, the parts go on the default's. + s.run("labels.style", &json!({"layer": "Cities", "background": {"fill": ""}})).unwrap(); + assert_eq!(class(&s).2, Some(LabelBackground { fill: None, ..LabelBackground::default() })); + for bad in [ + json!({"background": "box"}), + json!({"background": 1}), + json!({"background": {"fill": "pink"}}), + json!({"background": {"stroke": 7}}), + json!({"background": {"strokeWidth": "wide"}}), + json!({"background": {"padding": 2}}), + json!({"background": {"padding": [2]}}), + json!({"background": {"radius": null}}), + ] { + let mut p = bad.clone(); + p["layer"] = json!("Cities"); + assert!(s.run("labels.style", &p).is_err(), "{bad}"); + } + // The project keeps them. + s.run("labels.placement", &json!({"layer": "Cities", "offset": [1, 2]})).unwrap(); + let text = serde_json::to_string(&s.project).unwrap(); + let reopened = Session::new(serde_json::from_str(&text).unwrap()); + assert_eq!(class(&reopened), class(&s)); +} + /// 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). @@ -7303,6 +7378,16 @@ fn web_style_carries_the_layers_scale_limits_as_zooms() { 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)"); + // An offset in ems, as MapLibre has it; none without one. + let offset = |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["layout"].get("text-offset").cloned() + }; + assert_eq!(offset(&s), None); + let c = &mut s.map_mut().layer_mut(id).unwrap().labels.default; + (c.size, c.offset) = (10.0, [5.0, -2.5]); + assert_eq!(offset(&s), Some(json!([0.5, -0.25]))); s.map_mut().layer_mut(id).unwrap().labels.default.visible = false; assert!(!labelled(&s)); } diff --git a/crates/map/src/lib.rs b/crates/map/src/lib.rs index 6cc925054..5773bfe68 100644 --- a/crates/map/src/lib.rs +++ b/crates/map/src/lib.rs @@ -31,8 +31,8 @@ pub use color::{Ramp, RampGroup, Rgba}; pub use globe::{Globe, Projection}; pub use raster::{RasterDefaults, RasterLayer, RasterRenderer, RasterStyle, Resampling, Stretch}; pub use symbology::{ - ClassMethod, FontName, Glyph, LabelClass, LabelKey, LabelMatcher, LabelPlacement, Labeling, LineCap, LineJoin, LineMark, LineMarkers, - LineStroke, Marker, MarkerRotation, Placements, Renderer, Symbol, + ClassMethod, FontName, Glyph, LabelBackground, LabelClass, LabelKey, LabelMatcher, LabelPlacement, Labeling, LineCap, LineJoin, + LineMark, LineMarkers, LineStroke, Marker, MarkerRotation, Placements, Renderer, Symbol, }; pub use tiles::{TileKind, TileLayer}; diff --git a/crates/map/src/qgis/labels.rs b/crates/map/src/qgis/labels.rs index f8f39b5b3..d0ce80606 100644 --- a/crates/map/src/qgis/labels.rs +++ b/crates/map/src/qgis/labels.rs @@ -1,21 +1,25 @@ //! 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, slant and letter spacing, its buffer as a -//! halo of its colour and size, its placement and its scale range. +//! halo of its colour and size, its background as a rectangle behind it, its placement and offset, +//! 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, HALO_WIDTH, LABEL_SIZE, LabelClass, LabelPlacement, Labeling, MAX_LABEL_CLASSES}; +use crate::symbology::{ + DEFAULT_LABEL_CLASS, HALO_WIDTH, LABEL_OFFSET, LABEL_SIZE, LabelBackground, 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 { +/// A layer's labels from its ``, or `None` when none can be carried over. `geometry` is +/// the layer's (`Point`, `Line`, `Polygon`), when its file says. +pub(super) fn read(l: Node, geometry: Option<&str>, layer: &str, report: &mut Report) -> Option { let mut classes: Vec = Vec::new(); match l.attribute("type") { Some("simple") => { let settings = child(l, "settings")?; - classes.extend(class(settings, DEFAULT_LABEL_CLASS, None, (None, None), layer, report)); + classes.extend(class(settings, DEFAULT_LABEL_CLASS, None, (None, None), geometry, layer, report)); } Some("rule-based") => { let mut rules = Vec::new(); @@ -57,7 +61,7 @@ pub(super) fn read(l: Node, layer: &str, report: &mut Report) -> Option { @@ -142,6 +146,7 @@ fn class( name: &str, where_: Option, scales: (Option, Option), + geometry: Option<&str>, layer: &str, report: &mut Report, ) -> Option { @@ -202,14 +207,13 @@ fn class( 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"); - } + let background = background(text, layer, report); if child(text, "shadow").is_some_and(|b| b.attribute("shadowDraw") == Some("1")) { report.approximated(layer, "label shadows, left out"); } let placement = child(settings, "placement").map_or(LabelPlacement::Auto, |p| placement(p, layer, report)); - unread(settings, layer, report); + let offset = child(settings, "placement").map_or([0.0; 2], |p| offset(p, geometry, layer, report)); + unread(settings, geometry, layer, report); // The settings' own scale range, else the rule's. let rendering = child(settings, "rendering"); let (mut min_scale, mut max_scale) = scales; @@ -236,9 +240,110 @@ fn class( halo_color, halo_width, spacing: spacing.clamp(spacing_min, spacing_max) as f32, + offset, + background, }) } +/// A label's background as RustGIS's rectangle: QGIS's rectangle (its square, ellipse and circle +/// drawn as one), padded by its size, filled and outlined as its fill symbol says (else its own +/// colours), its corners rounded. +fn background(text: Node, layer: &str, report: &mut Report) -> Option { + let b = child(text, "background").filter(|b| b.attribute("shapeDraw") == Some("1"))?; + let num = |n: Node, k: &str| n.attribute(k).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()); + match b.attribute("shapeType").unwrap_or("0") { + "0" => {} + "1" | "2" | "3" => report.approximated(layer, "label backgrounds of squares, ellipses or circles, drawn as rectangles"), + _ => { + report.approximated(layer, "label backgrounds of SVG pictures or markers, left out"); + return None; + } + } + let what = "label backgrounds"; + // Its size added round the text (a fixed size or a share of the text's, as padding of none). + let padding = if b.attribute("shapeSizeType").unwrap_or("0") == "0" { + let unit = b.attribute("shapeSizeUnit").unwrap_or("MM"); + ["shapeSizeX", "shapeSizeY"].map(|k| num(b, k).map_or(0.0, |v| points(v, unit, what, layer, report)) as f32) + } else { + report.approximated(layer, "label backgrounds of a size of their own, fitted to the text"); + [0.0; 2] + }; + let radius = match b.attribute("shapeRadiiUnit").unwrap_or("MM") { + // (QGIS writes `Percentage`.) + "Percentage" | "Percent" => { + report.approximated(layer, "label backgrounds' corners rounded by a share of their size, drawn square"); + 0.0 + } + unit => { + let r = num(b, "shapeRadiiX").unwrap_or(0.0).min(num(b, "shapeRadiiY").unwrap_or(0.0)); + if r > 0.0 { points(r, unit, what, layer, report) as f32 } else { 0.0 } + } + }; + // QGIS 3 fills it with a fill symbol; older files, and a symbol of another kind, by its colours. + let fill_layer = b + .children() + .find(|c| c.has_tag_name("symbol") && c.attribute("type") == Some("fill")) + .and_then(|sym| sym.children().find(|c| c.has_tag_name("layer")).map(|l| (sym, l))); + let (fill, stroke, stroke_width) = match fill_layer { + Some((sym, l)) if l.attribute("class") == Some("SimpleFill") => { + let pr = props(l); + let alpha = num(sym, "alpha").unwrap_or(1.0).clamp(0.0, 1.0) as f32; + let faded = |c: Rgba| c.with_alpha((f32::from(c.a()) * alpha).round() as u8); + let fill = (prop(&pr, "style") != Some("no")).then(|| prop(&pr, "color").and_then(color)).flatten().map(faded); + let stroke = + (prop(&pr, "outline_style") != Some("no")).then(|| prop(&pr, "outline_color").and_then(color)).flatten().map(faded); + let width = prop(&pr, "outline_width").and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()).unwrap_or(0.26); + let unit = prop(&pr, "outline_width_unit").unwrap_or("MM"); + (fill, stroke, points(width, unit, what, layer, report)) + } + other => { + if other.is_some() { + report.approximated(layer, "label backgrounds filled with patterns or gradients, drawn in their colours"); + } + let fill = b.attribute("shapeFillColor").and_then(color).or(Some(Rgba::rgb(255, 255, 255))); + let stroke = b.attribute("shapeBorderColor").and_then(color); + let width = num(b, "shapeBorderWidth").unwrap_or(0.0); + (fill, stroke, points(width, b.attribute("shapeBorderWidthUnit").unwrap_or("MM"), what, layer, report)) + } + }; + let opacity = num(b, "shapeOpacity").unwrap_or(1.0).clamp(0.0, 1.0) as f32; + let faded = |c: Rgba| c.with_alpha((f32::from(c.a()) * opacity).round() as u8); + if num(b, "shapeOffsetX").is_some_and(|v| v != 0.0) || num(b, "shapeOffsetY").is_some_and(|v| v != 0.0) { + report.approximated(layer, "label backgrounds moved off their text, drawn behind it"); + } + if num(b, "shapeRotation").is_some_and(|v| v != 0.0) { + report.approximated(layer, "label backgrounds turned on their own, drawn with their text"); + } + if !matches!(b.attribute("shapeBlendMode"), None | Some("0")) { + report.approximated(layer, "label backgrounds blended into the map, drawn over it"); + } + let stroke = stroke.map(faded).filter(|_| stroke_width > 0.0); + Some(LabelBackground { fill: fill.map(faded), stroke, stroke_width: stroke_width as f32, padding, radius }.checked()) +} + +/// How far a label is moved from where its placement puts it (points, right and down): QGIS's +/// offset from a point (or a polygon's centroid), and on a point layer its distance around the +/// point, the label above it that far out (RustGIS's Auto puts it above). +fn offset(p: Node, geometry: Option<&str>, layer: &str, report: &mut Report) -> [f32; 2] { + let num = |k: &str| p.attribute(k).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()); + let within = |v: f64| v.clamp(*LABEL_OFFSET.start(), *LABEL_OFFSET.end()) as f32; + match p.attribute("placement").unwrap_or("0") { + "1" => { + let (x, y) = (num("xOffset").unwrap_or(0.0), num("yOffset").unwrap_or(0.0)); + if x == 0.0 && y == 0.0 { + return [0.0; 2]; + } + let unit = p.attribute("offsetUnits").unwrap_or("MM"); + [x, y].map(|v| within(points(v, unit, "label offsets", layer, report))) + } + "0" | "6" if geometry == Some("Point") => match num("dist").filter(|d| *d != 0.0) { + Some(d) => [0.0, -within(points(d, p.attribute("distUnits").unwrap_or("MM"), "label distances", layer, report))], + None => [0.0; 2], + }, + _ => [0.0; 2], + } +} + /// 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 { @@ -254,16 +359,21 @@ fn points(v: f64, unit: &str, what: &str, layer: &str, report: &mut Report) -> f } /// 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) { +fn unread(settings: Node, geometry: Option<&str>, layer: &str, report: &mut Report) { let num = |n: Option, k: &str| n.and_then(|n| n.attribute(k)).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()); let p = child(settings, "placement"); - if num(p, "dist").is_some_and(|d| d != 0.0) { + // A distance from a line, or round a polygon (or a layer of unknown kind); offset from a + // point, QGIS doesn't use it, and round a point it is the label's offset. + let placed = p.and_then(|p| p.attribute("placement")).unwrap_or("0"); + let used = match placed { + "0" | "6" => geometry != Some("Point"), + "2" | "3" | "7" | "8" => true, + _ => false, + }; + if used && num(p, "dist").is_some_and(|d| d != 0.0) { report.approximated(layer, "label distances from their features, left out"); } - if num(p, "xOffset").is_some_and(|d| d != 0.0) || num(p, "yOffset").is_some_and(|d| d != 0.0) { - report.approximated(layer, "label offsets, left out"); - } if num(p, "repeatDistance").is_some_and(|d| d > 0.0) { report.approximated(layer, "labels repeated along lines, drawn once"); } diff --git a/crates/map/src/qgis/mod.rs b/crates/map/src/qgis/mod.rs index 5430fee7c..f64c7088b 100644 --- a/crates/map/src/qgis/mod.rs +++ b/crates/map/src/qgis/mod.rs @@ -341,7 +341,7 @@ fn read_style_node(node: Node, layer: &str, report: &mut Report) -> Style { if let Some(l) = child(node, "labeling") { // Set up but switched off: carried over, switched off. let off = node.attribute("labelsEnabled") == Some("0"); - style.labels = labels::read(l, layer, report).map(|mut l| { + style.labels = labels::read(l, node.attribute("geometry"), layer, report).map(|mut l| { l.enabled &= !off; l }); diff --git a/crates/map/src/qgis/tests.rs b/crates/map/src/qgis/tests.rs index a4396ec4d..d70045df7 100644 --- a/crates/map/src/qgis/tests.rs +++ b/crates/map/src/qgis/tests.rs @@ -350,6 +350,115 @@ fn a_labels_buffer_and_letter_spacing_carry_over_in_their_units() { assert_eq!((c.halo_width, c.spacing), (Some(10.0), 50.0)); } +#[test] +fn a_labels_background_and_offset_carry_over_in_their_units() { + let qml = |settings: &str| { + format!( + r#" + {settings}"# + ) + }; + let read = |settings: &str| { + let mut report = Report::default(); + let s = read_style(&qml(settings), "Roads", &mut report).unwrap(); + (s.labels.unwrap().default, report) + }; + let mm = |v: f32| v * 72.0 / 25.4; + let near = |a: f32, b: f32| (a - b).abs() < 1e-3; + // SMV25's road numbers: a white box with a dark outline from its fill symbol (a marker symbol + // beside it is for marker backgrounds), padded in millimetres, its corners rounded. + let (c, report) = read( + r#" + + + "#, + ); + let b = c.background.unwrap(); + assert_eq!((b.fill, b.stroke), (Some(Rgba::rgb(250, 250, 250)), Some(Rgba::rgb(38, 38, 38)))); + assert!( + near(b.stroke_width, mm(0.1)) && near(b.padding[0], mm(0.4304)) && near(b.padding[1], mm(0.0988)) && near(b.radius, mm(0.25)), + "{b:?}" + ); + assert_eq!(c.offset, [0.0; 2]); + assert!(report.notes.is_empty(), "{:?}", report.notes); + // Without a fill symbol (older files): its colours, half see-through; no outline width, no + // outline; padding in points; an ellipse drawn as a rectangle, and what else it can't do noted. + let (c, report) = read( + r#""#, + ); + let b = c.background.unwrap(); + assert_eq!((b.fill, b.stroke, b.padding, b.radius), (Some(Rgba::new(0, 0, 255, 128)), None, [2.0, 1.0], 0.0)); + for part in ["drawn as rectangles", "moved off their text", "turned on their own", "blended"] { + assert!(report.notes.iter().any(|n| n.text.contains(part)), "{part}: {:?}", report.notes); + } + // Corners rounded by a share of its size (QGIS's `Percentage`): square, and said so. + let (c, report) = read( + r#""#, + ); + assert_eq!(c.background.map(|b| b.radius), Some(0.0)); + assert!(report.notes.iter().any(|n| n.text.contains("drawn square")), "{:?}", report.notes); + assert!(!report.notes.iter().any(|n| n.text.contains("map units")), "{:?}", report.notes); + // A fixed size, a picture, one switched off, a symbol filled otherwise. + let (c, report) = + read(r#""#); + assert_eq!(c.background.map(|b| b.padding), Some([0.0; 2])); + assert!(report.notes.iter().any(|n| n.text.contains("size of their own")), "{:?}", report.notes); + let (c, report) = read(r#""#); + assert!(c.background.is_none() && report.notes.iter().any(|n| n.text.contains("SVG pictures")), "{:?}", report.notes); + let (c, _) = read(r#""#); + assert!(c.background.is_none()); + let (c, report) = read( + r#""#, + ); + assert_eq!(c.background.and_then(|b| b.fill), Some(Rgba::rgb(1, 2, 3))); + assert!(report.notes.iter().any(|n| n.text.contains("patterns or gradients")), "{:?}", report.notes); + // Crafted sizes stay within RustGIS's ranges. + let (c, _) = read( + r#""#, + ); + let b = c.background.unwrap(); + assert_eq!((b.padding, b.stroke_width, b.radius), ([50.0, 0.0], 10.0, 50.0)); + + // An offset from the point, in its units (down is positive, as QGIS has it); the distance + // QGIS doesn't use there isn't noted. + let (c, report) = + read(r#""#); + assert_eq!(c.placement, LabelPlacement::AboveRight); + assert!(near(c.offset[0], mm(2.0)) && near(c.offset[1], mm(-1.0)), "{:?}", c.offset); + assert!(report.notes.is_empty(), "{:?}", report.notes); + let (c, _) = read(r#""#); + assert_eq!(c.offset, [200.0, 4.0]); + // An offset QGIS doesn't use (a label along a line) isn't read. + let (c, _) = read(r#""#); + assert_eq!(c.offset, [0.0; 2]); + // A style doesn't say what its layer holds: a distance round its features is noted. + let (c, report) = read(r#""#); + assert_eq!(c.offset, [0.0; 2]); + assert!(report.notes.iter().any(|n| n.text.contains("label distances")), "{:?}", report.notes); + + // In a project, a point layer's distance round its points: the label (above them) that far up. + let qgs = |geometry: &str| { + format!( + r#"p/d/p.shpPogr + "# + ) + }; + let mut report = Report::default(); + let p = read_project(&qgs("Point"), "", &mut report).unwrap(); + let c = &p.layers[0].style.labels.as_ref().unwrap().default; + assert!(c.offset[0] == 0.0 && near(c.offset[1], -mm(1.5)), "{:?}", c.offset); + assert!(!report.notes.iter().any(|n| n.text.contains("label distances")), "{:?}", report.notes); + let mut report = Report::default(); + let p = read_project(&qgs("Polygon"), "", &mut report).unwrap(); + assert_eq!(p.layers[0].style.labels.as_ref().unwrap().default.offset, [0.0; 2]); + assert!(report.notes.iter().any(|n| n.text.contains("label distances")), "{:?}", report.notes); +} + #[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 @@ -423,7 +532,7 @@ fn what_rustgis_has_no_place_for_is_noted() { - + @@ -452,7 +561,6 @@ fn what_rustgis_has_no_place_for_is_noted() { "draw effects", "wrapped labels", "label distances", - "label offsets", "label priorities", "callouts", "data-defined label settings", diff --git a/crates/map/src/symbology.rs b/crates/map/src/symbology.rs index 506e26cb4..96fed23b2 100644 --- a/crates/map/src/symbology.rs +++ b/crates/map/src/symbology.rs @@ -1728,6 +1728,118 @@ pub struct LabelClass { /// spacing; negative draws them tighter. #[serde(default, deserialize_with = "de_letter_spacing", skip_serializing_if = "is_zero")] pub spacing: f32, + /// How far the label is moved from where its placement puts it (points, right and down, each + /// within [`LABEL_OFFSET`]), as QGIS's offset from a point; a label along a line isn't moved. + #[serde(default, deserialize_with = "de_label_offset", skip_serializing_if = "is_no_offset")] + pub offset: [f32; 2], + /// A shape drawn behind the text, as QGIS's label background; none draws the text alone. + #[serde(default, deserialize_with = "de_background", skip_serializing_if = "Option::is_none")] + pub background: Option, +} + +/// How far a label's offset moves it each way (points). +pub const LABEL_OFFSET: std::ops::RangeInclusive = -200.0..=200.0; + +/// A rectangle behind a label's text, as QGIS's rectangle background: the text's block grown by +/// its padding, filled and outlined, its corners rounded. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct LabelBackground { + /// Its fill; none leaves it see-through. + #[serde(deserialize_with = "de_halo_color")] + pub fill: Option, + /// Its outline's colour; none draws no outline. + #[serde(deserialize_with = "de_halo_color")] + pub stroke: Option, + /// Its outline's width (points, within [`BACKGROUND_STROKE`]). + #[serde(deserialize_with = "de_background_stroke")] + pub stroke_width: f32, + /// How far it reaches past the text's block, across and up and down (points, each within + /// [`BACKGROUND_PADDING`]). + #[serde(deserialize_with = "de_background_padding")] + pub padding: [f32; 2], + /// Its corners' radius (points, within [`BACKGROUND_PADDING`]); 0 is square. + #[serde(deserialize_with = "de_background_radius")] + pub radius: f32, +} + +impl Default for LabelBackground { + /// White, with a thin dark outline (SMV25's road numbers): what Background turns on. + fn default() -> Self { + LabelBackground { + fill: Some(Rgba::rgb(255, 255, 255)), + stroke: Some(Rgba::rgb(38, 38, 38)), + stroke_width: 0.3, + padding: [1.0, 0.5], + radius: 0.0, + } + } +} + +impl LabelBackground { + /// Kept within its ranges (a command's or a crafted file's numbers). + pub fn checked(self) -> LabelBackground { + // (One past f32's range, infinite, is the most; none of no number.) + let within = |v: f32, r: &std::ops::RangeInclusive| if v.is_nan() { 0.0 } else { v.clamp(*r.start() as f32, *r.end() as f32) }; + LabelBackground { + stroke_width: within(self.stroke_width, &BACKGROUND_STROKE), + padding: self.padding.map(|p| within(p, &BACKGROUND_PADDING)), + radius: within(self.radius, &BACKGROUND_PADDING), + ..self + } + } + + /// Grown `factor` times, as its label is by a reference scale. + pub fn scaled(&self, factor: f32) -> LabelBackground { + LabelBackground { + stroke_width: self.stroke_width * factor, + padding: self.padding.map(|p| p * factor), + radius: self.radius * factor, + ..self.clone() + } + } + + /// Whether it draws anything: a fill or an outline that shows. + pub fn draws(&self) -> bool { + self.fill.is_some_and(|f| f.a() > 0) || (self.stroke.is_some_and(|s| s.a() > 0) && self.stroke_width > 0.0) + } +} + +/// The widths a label background's outline takes (points). +pub const BACKGROUND_STROKE: std::ops::RangeInclusive = 0.0..=10.0; +/// The paddings and corner radii a label background takes (points). +pub const BACKGROUND_PADDING: std::ops::RangeInclusive = 0.0..=50.0; + +/// A label's offset, each way within [`LABEL_OFFSET`]; one that isn't two numbers is none. +fn de_label_offset<'de, D: serde::Deserializer<'de>>(d: D) -> Result<[f32; 2], D::Error> { + let within = |v: f64| if v.is_finite() { v.clamp(*LABEL_OFFSET.start(), *LABEL_OFFSET.end()) as f32 } else { 0.0 }; + Ok(lenient::(d)?.map_or([0.0; 2], |o| o.map(within))) +} + +fn is_no_offset(o: &[f32; 2]) -> bool { + *o == [0.0; 2] +} + +/// A label's background, kept within its ranges; one that isn't a background is none. +fn de_background<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + Ok(lenient::(d)?.map(LabelBackground::checked)) +} + +fn de_background_stroke<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + Ok(lenient::(d)? + .filter(|v| v.is_finite()) + .map_or(0.0, |v| v.clamp(*BACKGROUND_STROKE.start(), *BACKGROUND_STROKE.end()) as f32)) +} + +fn de_background_padding<'de, D: serde::Deserializer<'de>>(d: D) -> Result<[f32; 2], D::Error> { + let within = |v: f64| if v.is_finite() { v.clamp(*BACKGROUND_PADDING.start(), *BACKGROUND_PADDING.end()) as f32 } else { 0.0 }; + Ok(lenient::(d)?.map_or([0.0; 2], |p| p.map(within))) +} + +fn de_background_radius<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + Ok(lenient::(d)? + .filter(|v| v.is_finite()) + .map_or(0.0, |v| v.clamp(*BACKGROUND_PADDING.start(), *BACKGROUND_PADDING.end()) as f32)) } /// The widths a label's halo takes (points). @@ -1946,6 +2058,8 @@ impl LabelClass { halo_color: None, halo_width: None, spacing: 0.0, + offset: [0.0; 2], + background: None, } } @@ -2138,6 +2252,54 @@ mod tests { assert_eq!(old.default.halo_color_for(Rgba::rgb(255, 255, 255)), Rgba::new(20, 22, 26, 210)); } + /// An offset and a background are kept; a label 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_offset_and_background_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.offset, old.default.background.as_ref()), ([0.0; 2], None)); + let saved = serde_json::to_value(&old).unwrap(); + for k in ["offset", "background"] { + assert!(saved.get(k).is_none(), "{k} written for a label without it"); + } + let bg = serde_json::json!({"fill": [255, 255, 255, 255], "stroke": null, "stroke_width": 0.28, "padding": [1.22, 0.28], "radius": 0.71}); + let l = read(serde_json::json!({"offset": [5.67, -2.83], "background": bg})); + assert_eq!(l.default.offset, [5.67, -2.83]); + let want = + LabelBackground { fill: Some(Rgba::rgb(255, 255, 255)), stroke: None, stroke_width: 0.28, padding: [1.22, 0.28], radius: 0.71 }; + assert_eq!(l.default.background.as_ref(), Some(&want)); + assert_eq!(serde_json::from_value::(serde_json::to_value(&l).unwrap()).unwrap(), l); + // What a background leaves out is the default's. + let part = read(serde_json::json!({"background": {"radius": 2}})); + assert_eq!(part.default.background, Some(LabelBackground { radius: 2.0, ..LabelBackground::default() })); + // Crafted values: none, or within their ranges. + let junk = read(serde_json::json!({"offset": "far", "background": "box"})); + assert_eq!((junk.default.offset, junk.default.background.as_ref(), junk.default.field.as_str()), ([0.0; 2], None, "name")); + let far = read( + serde_json::json!({"offset": [1e30, -1e30], "background": {"fill": "pink", "stroke_width": 99, "padding": [-1, 1e9], "radius": "round"}}), + ); + assert_eq!(far.default.offset, [200.0, -200.0]); + let b = far.default.background.unwrap(); + assert_eq!((b.fill, b.stroke_width, b.padding, b.radius), (None, 10.0, [0.0, 50.0], 0.0)); + assert!(read(serde_json::json!({"offset": [1]})).default.offset == [0.0; 2]); + // A background shows with a fill or an outline that shows. + assert!(LabelBackground::default().draws()); + assert!(!LabelBackground { fill: None, stroke: None, ..LabelBackground::default() }.draws()); + assert!(!LabelBackground { fill: None, stroke_width: 0.0, ..LabelBackground::default() }.draws()); + assert!(!LabelBackground { fill: Some(Rgba::new(0, 0, 0, 0)), stroke: None, ..LabelBackground::default() }.draws()); + // Grown with its label. + let g = want.scaled(2.0); + assert_eq!((g.stroke_width, g.padding, g.radius, g.fill), (0.56, [2.44, 0.56], 1.42, want.fill)); + } + /// 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/layout.rs b/crates/render/src/layout.rs index 50fe1222d..8504106d4 100644 --- a/crates/render/src/layout.rs +++ b/crates/render/src/layout.rs @@ -417,7 +417,7 @@ pub fn expand(t: &str, layout: &Layout, info: &PageInfo, frame: Option<(&Map, ru /// A rectangle with its corners rounded by `rad` pixels (at most half its shorter side), each a /// quarter circle drawn as a cubic; `None` for square corners, drawn as the plain rectangle. -fn rounded_rect(r: tiny_skia::Rect, rad: f32) -> Option { +pub(crate) fn rounded_rect(r: tiny_skia::Rect, rad: f32) -> Option { let rad = rad.min(r.width() / 2.0).min(r.height() / 2.0); if rad.is_nan() || rad <= 0.0 { return None; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index f20c58dfb..29a5003d5 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -211,6 +211,9 @@ pub struct PlacedLabel { /// Curved along a line: its characters one by one, each placed and turned where the line /// takes it (`text` is all of them). Empty for a label drawn as one block. pub glyphs: Vec, + /// A rectangle behind the text (a label class's background, its sizes in points as the + /// class's grown by a reference scale); none draws the text alone. A curved label has none. + pub background: Option, } /// One character of a label curved along a line: its text, and its centre and angle (radians, @@ -3243,6 +3246,7 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac angle: None, collide: Default::default(), glyphs: Vec::new(), + background: None, }); } } @@ -3324,7 +3328,8 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac marker, is_point, ); - let (dx, dy) = (dx * label_factor, dy * label_factor); + // Moved as its class says (points, as the text's size), unless along a line. + let (dx, dy) = ((dx + class.offset[0] * 1.33) * label_factor, (dy + class.offset[1] * 1.33) * label_factor); let (mut lx, mut ly, mut angle, mut glyphs) = (x as f32 + dx, y as f32 + dy, None, Vec::new()); if let (LabelPlacement::Parallel | LabelPlacement::Curved, Geometry::Line(parts)) = (class.placement, g) { let line = screen_line.get_or_insert_with(|| longest_on_screen(ctx, parts)); @@ -3371,6 +3376,12 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac line_align: TextAlign::Left, angle, collide: Default::default(), + // (Not behind characters curved one by one.) + background: class + .background + .as_ref() + .filter(|b| b.draws() && glyphs.is_empty()) + .map(|b| b.scaled(label_factor)), glyphs, }, pri, @@ -4182,7 +4193,7 @@ fn label_boxes(l: &PlacedLabel) -> (Vec<[f32; 4]>, Vec<[f32; 4]>) { // 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 halo = text::drawn_halo_reach(l).max(text::background_reach(l)); let p = l.collide.text_padding.clamp(0.0, 100.0) + halo; let cx = l.center_x(); if !l.glyphs.is_empty() { @@ -4342,6 +4353,7 @@ pub(crate) fn draw_attribution(pm: &mut Pixmap, attribution: &[String], ppp: f32 angle: None, collide: Default::default(), glyphs: Vec::new(), + background: None, }; text::draw(pm, &label, ppp); } @@ -4369,6 +4381,7 @@ mod tests { angle: None, collide: Default::default(), glyphs: Vec::new(), + background: None, } } @@ -6481,6 +6494,7 @@ mod tests { angle: None, collide: Default::default(), glyphs: vec![glyph("A", -5.0), glyph("B", 5.0)], + background: None, }; turn_with(&mut l, &|x, y| Some((200.0 - x, 200.0 - y))); let order: Vec<(&str, f32)> = l.glyphs.iter().map(|g| (g.text.as_str(), g.dx)).collect(); @@ -6522,6 +6536,7 @@ mod tests { angle: None, collide: Default::default(), glyphs, + background: None, }; let drawn = |halo, glyphs| { let mut pm = Pixmap::new(60, 80).unwrap(); @@ -6611,6 +6626,7 @@ mod tests { angle: None, collide: Default::default(), glyphs: Vec::new(), + background: None, }; let drawn = |l: &PlacedLabel| { let mut pm = Pixmap::new(100, 100).unwrap(); @@ -6652,6 +6668,127 @@ mod tests { assert!(spaced >= tight + 4, "{tight} → {spaced}"); } + #[test] + fn a_background_fills_behind_the_text_and_keeps_other_labels_off() { + let bg = rustgis_map::LabelBackground { + fill: Some(Rgba::rgb(255, 255, 0)), + stroke: Some(Rgba::rgb(0, 0, 255)), + stroke_width: 1.5, + padding: [6.0, 3.0], + radius: 0.0, + }; + let label = |background: Option| PlacedLabel { + text: "HH".into(), + x: 60.0, + y: 50.0, + size: 12.0, + color: Rgba::rgb(0, 0, 0), + halo: None, + halo_width: None, + 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(), + background, + }; + let drawn = |l: &PlacedLabel| { + let mut pm = Pixmap::new(120, 100).unwrap(); + pm.fill(tiny_skia::Color::from_rgba8(128, 128, 128, 255)); + text::draw(&mut pm, l, 1.0); + pm + }; + let yellow = |p: tiny_skia::PremultipliedColorU8| p.red() > 230 && p.green() > 230 && p.blue() < 40; + let blue = |p: tiny_skia::PremultipliedColorU8| p.blue() > 200 && p.red() < 60; + let l = label(Some(bg.clone())); + let [x0, y0, x1, y1] = text::background_box(&l).unwrap(); + // The text's block grown by the padding (points × 1.33, as the text's size). + let w = text::label_width(&l); + assert!((x1 - x0 - (w + 2.0 * 6.0 * 1.33)).abs() < 0.01, "{x0} {x1} {w}"); + assert!(y1 - y0 > 12.0 * 1.33 + 2.0 * 3.0 * 1.33); + let pm = drawn(&l); + // Filled between the text's end and its edge, outlined at the edge, the text over it. + let inside = pm.pixel((x1 - 4.0) as u32, 50).unwrap(); + assert!(yellow(inside), "{inside:?}"); + assert!(blue(pm.pixel(x1.round() as u32, 50).unwrap()), "{:?}", pm.pixel(x1.round() as u32, 50)); + assert!(pm.pixels().iter().any(|p| p.red() < 100 && p.green() < 100 && p.blue() < 100), "the text over it"); + // None, and one that draws nothing: the text alone, as before. + let plain = drawn(&label(None)); + assert!(!plain.pixels().iter().any(|p| yellow(*p))); + let nothing = rustgis_map::LabelBackground { fill: None, stroke: None, ..bg.clone() }; + assert_eq!(drawn(&label(Some(nothing.clone()))).data(), plain.data()); + assert_eq!(text::background_reach(&label(Some(nothing))), 0.0); + // Turned along a line, it turns with the text. + let turned = drawn(&PlacedLabel { angle: Some(0.6), ..l.clone() }); + assert!(turned.pixel(60, 50).is_some_and(|p| yellow(p) || p.red() < 40), "{:?}", turned.pixel(60, 50)); + assert!(turned.pixels().iter().filter(|p| yellow(**p)).count() > 100); + // Curved character by character: none. + let glyph = LabelGlyph { text: "H".into(), dx: 0.0, dy: 0.0, angle: 0.0 }; + let curved = PlacedLabel { glyphs: vec![glyph.clone(), LabelGlyph { dx: 10.0, ..glyph }], ..l.clone() }; + assert!(text::background_box(&curved).is_none()); + assert!(!drawn(&curved).pixels().iter().any(|p| yellow(*p))); + // Its reach widens the box other labels keep off: a label just past the text but on the + // background is placed beside the plain one, not beside the one with a background. + let reach = text::background_reach(&l); + assert!((reach - (6.0 * 1.33 + 0.75 * 1.33)).abs() < 0.01, "{reach}"); + let (plain_box, _) = label_boxes(&label(None)); + let (bg_box, _) = label_boxes(&l); + assert!((plain_box[0][0] - bg_box[0][0] - reach).abs() < 0.01, "{plain_box:?} {bg_box:?}"); + let beside = |first: PlacedLabel| { + let next = PlacedLabel { x: first.x + w + 4.0, ..label(None) }; + place_labels(vec![(first, 2.0, (0, 0, 0)), (next, 1.0, (0, 1, 0))], Vec::new(), 400.0, 100.0, false).labels.len() + }; + assert_eq!((beside(label(None)), beside(l.clone())), (2, 1)); + } + + #[test] + fn an_offset_moves_a_point_label_but_not_one_along_a_line() { + let mut m = Map::new("Map"); + m.basemap = rustgis_map::Basemap::Blank; + let mut d = Dataset::new("towns", GeometryType::Point, vec![Field::new("name", FieldType::Text)]); + d.push(Some(Geometry::Point(Coord::new(0.0, 0.0))), vec![Value::Text("Winterthur".into())]); + let id = m.add_dataset(d); + let mut d = Dataset::new("rivers", GeometryType::Polyline, vec![Field::new("name", FieldType::Text)]); + d.push(Some(Geometry::Line(vec![vec![Coord::new(-1.0, 0.5), Coord::new(1.0, 0.5)]])), vec![Value::Text("Töss".into())]); + let line = m.add_dataset(d); + let camera = Camera { center: lonlat_to_merc(Coord::new(0.0, 0.0)), res: 1500.0, bearing: 0.0, pitch: 0.0 }; + m.camera = camera; + let view = View { width: 400.0, height: 200.0, pixels_per_point: 1.0, camera, sky: false }; + for (layer, placement) in [(id, rustgis_map::LabelPlacement::Right), (line, rustgis_map::LabelPlacement::Parallel)] { + let l = m.layer_mut(layer).unwrap(); + l.labels.enabled = true; + l.labels.default.field = "name".into(); + l.labels.default.placement = placement; + } + let at = |m: &mut Map| { + let r = render(m, &view).unwrap(); + let find = |t: &str| r.labels.iter().find(|l| l.text == t).map(|l| (l.x, l.y)).unwrap(); + (find("Winterthur"), find("Töss")) + }; + let (town, river) = at(&mut m); + for layer in [id, line] { + m.layer_mut(layer).unwrap().labels.default.offset = [6.0, -3.0]; + } + let (town2, river2) = at(&mut m); + // Points × 1.33, as the text's size: right and up. + assert!((town2.0 - town.0 - 6.0 * 1.33).abs() < 0.01 && (town2.1 - town.1 + 3.0 * 1.33).abs() < 0.01, "{town:?} → {town2:?}"); + assert_eq!(river, river2, "along a line, not moved"); + // A reference scale grows it with the label. + let scale = m.scale(); + m.layer_mut(id).unwrap().reference_scale = Some(scale * 2.0); + m.layer_mut(id).unwrap().labels.default.offset = [0.0; 2]; + let (big, _) = at(&mut m); + m.layer_mut(id).unwrap().labels.default.offset = [6.0, -3.0]; + let (big2, _) = at(&mut m); + assert!((big2.0 - big.0 - 2.0 * 6.0 * 1.33).abs() < 0.01, "{big:?} → {big2:?}"); + } + #[test] fn features_under_a_pixel_still_show() { // A dense grid of tiny squares seen whole: each is splatted rather than filled as a path, diff --git a/crates/render/src/stream_tests.rs b/crates/render/src/stream_tests.rs index 99e719fd2..f3479382e 100644 --- a/crates/render/src/stream_tests.rs +++ b/crates/render/src/stream_tests.rs @@ -552,6 +552,7 @@ fn chunked_label_compaction_keeps_late_priorities_stable_ties_and_nan_order() { angle: None, collide: Default::default(), glyphs: Vec::new(), + background: None, }, if i % 17 == 0 { f64::NAN } else { (i % 7) as f64 }, (1, i, 0), diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 6c9a892fc..ad6f20e21 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -435,6 +435,44 @@ pub fn label_width(l: &PlacedLabel) -> f32 { lines(&l.text).map(|line| line_width(l, line, 1.0)).fold(0.0, f32::max) } +/// The rectangle a label's background fills (points: [left, top, right, bottom], upright about +/// the label's centre): its text's block, as tall as its lines are drawn, grown by the padding; +/// none without a background that draws. +pub fn background_box(l: &PlacedLabel) -> Option<[f32; 4]> { + let b = l.background.as_ref().filter(|b| b.draws() && l.glyphs.is_empty() && !l.text.is_empty())?; + let w = label_width(l); + let h = l.size * 1.33 * 1.2 + line_count(&l.text).saturating_sub(1) as f32 * line_pitch(l.size); + let (px, py) = (b.padding[0] * 1.33, b.padding[1] * 1.33); + let cx = l.center_x(); + let r = [cx - w / 2.0 - px, l.y - h / 2.0 - py, cx + w / 2.0 + px, l.y + h / 2.0 + py]; + r.iter().all(|v| v.is_finite()).then_some(r) +} + +/// How far a label's background, its outline's outer half included, reaches past the box its +/// text collides with (points), none without one: other labels keep off it as off a halo. +pub fn background_reach(l: &PlacedLabel) -> f32 { + let (Some([x0, y0, _, y1]), Some(b)) = (background_box(l), l.background.as_ref()) else { return 0.0 }; + let outline = if b.stroke.is_some_and(|s| s.a() > 0) { b.stroke_width * 1.33 / 2.0 } else { 0.0 }; + let across = l.center_x() - label_width(l) / 2.0 - x0; + let up_down = (y1 - y0) / 2.0 - block_height(&l.text, l.size) / 2.0; + (across.max(up_down) + outline).max(0.0) +} + +/// Draws a label's background (logical coordinates, upright) at `ppp` pixels a point. +fn draw_background(pm: &mut Pixmap, l: &PlacedLabel, ppp: f32) { + let (Some(r), Some(b)) = (background_box(l), l.background.as_ref()) else { return }; + let [x0, y0, x1, y1] = r.map(|v| v * ppp); + let Some(rect) = Rect::from_ltrb(x0, y0, x1, y1) else { return }; + let path = crate::layout::rounded_rect(rect, b.radius * 1.33 * ppp).unwrap_or_else(|| tiny_skia::PathBuilder::from_rect(rect)); + if let Some(f) = b.fill.filter(|f| f.a() > 0) { + pm.fill_path(&path, &crate::paint(f, 1.0), tiny_skia::FillRule::Winding, Transform::identity(), None); + } + if let Some(c) = b.stroke.filter(|c| c.a() > 0 && b.stroke_width > 0.0) { + let stroke = tiny_skia::Stroke { width: b.stroke_width * 1.33 * ppp, ..tiny_skia::Stroke::default() }; + pm.stroke_path(&path, &crate::paint(c, 1.0), &stroke, Transform::identity(), None); + } +} + /// The selection frame [`draw`] puts round a label, in points: [left, top, right, bottom]. fn frame_box(l: &PlacedLabel) -> [f32; 4] { let (w, pad) = (label_width(l), 3.0); @@ -604,7 +642,7 @@ 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. // (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 pad = 4.0 + l.size * 0.5 + drawn_halo_reach(l).min(MAX_HALO_PX / ppp.max(f32::EPSILON)) + background_reach(l); 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; @@ -656,6 +694,10 @@ fn draw_upright(pm: &mut Pixmap, l: &PlacedLabel, ppp: f32, ink: Ink) { draw_text(pm, line, left + lw / 2.0, y, px, color, l.bold, (dx, dy)); } }; + // Its background under everything. + if ink != Ink::Text { + draw_background(pm, l, ppp); + } // Every line's halo first, so none covers the line above it. if let Some(h) = l.halo.filter(|_| ink != Ink::Text) { match l.halo_width { @@ -894,6 +936,7 @@ pub fn scale_bar(pm: &mut Pixmap, view: &View, ground_per_pt: f64) { angle: None, collide: Default::default(), glyphs: Vec::new(), + background: None, }; draw(pm, &label, ppp); } @@ -997,6 +1040,7 @@ mod spread_tests { angle: None, collide: Default::default(), glyphs: Vec::new(), + background: None, }; // At 3 pixels a point: the letter's row through its middle (170 points down). let (w, row) = (3000usize, 510usize); diff --git a/crates/render/src/tiles.rs b/crates/render/src/tiles.rs index 57f05f394..361818c18 100644 --- a/crates/render/src/tiles.rs +++ b/crates/render/src/tiles.rs @@ -84,6 +84,7 @@ pub(crate) fn tile_label(l: rustgis_tiles::Label) -> Option<(PlacedLabel, f64)> angle: l.angle, collide: l.collide, glyphs: Vec::new(), + background: None, }, l.priority, )) diff --git a/crates/ui-egui/src/labelpane.rs b/crates/ui-egui/src/labelpane.rs index 650d17b8f..09f707589 100644 --- a/crates/ui-egui/src/labelpane.rs +++ b/crates/ui-egui/src/labelpane.rs @@ -563,9 +563,89 @@ fn text_symbol(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, c: &Class) { if c.it.halo { halo_style(app, ui, l, c); } + background(app, ui, l, c); }); } +/// A rectangle behind the text: its fill and outline (each may be none), the outline's width, how +/// far it reaches past the text, and its corners' radius. +fn background(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, c: &Class) { + ui.label(""); + let mut on = c.it.background.is_some(); + if ui.checkbox(&mut on, tl!("Background")).on_hover_text(tl!("A rectangle behind the text.")).changed() { + run(app, "labels.style", c.params(json!({"background": on}))); + } + ui.end_row(); + let Some(b) = c.it.background.clone() else { return }; + for (label, part, cur) in [("Fill", Part::Fill, b.fill), ("Outline", Part::Outline, b.stroke)] { + ui.label(tl!(label)); + ui.horizontal(|ui| { + let mut none = cur.is_none(); + if ui.checkbox(&mut none, tl!("None")).changed() { + // Back on in the default's colour (and an outline as wide, if it had none). + let d = rustgis_engine::map::LabelBackground::default(); + let change = match (part, none) { + (Part::Fill, true) => json!({"fill": ""}), + (Part::Fill, false) => json!({"fill": d.fill.map(|f| f.hex())}), + (_, true) => json!({"stroke": ""}), + (_, false) => json!({"stroke": d.stroke.map(|f| f.hex()), "strokeWidth": b.stroke_width.max(d.stroke_width)}), + }; + run(app, "labels.style", c.params(json!({"background": change}))); + } + if let Some(cur) = cur { + let mut color = c32(cur); + if crate::widgets::color_button(ui, &mut color).changed() { + pick_drag(app, ui, l, c.name, part, rgba(color)); + } + } + }); + ui.end_row(); + } + let id = l.id; + // A number of the background's, dragged live and set by `labels.style` once let go. + let number = |app: &mut RustGisApp, ui: &mut Ui, label: &str, cur: f32, range: std::ops::RangeInclusive, key: &'static str| { + let input_label = ui.label(tl!(label)); + let mut v = f64::from(cur); + let r = ui.add(egui::DragValue::new(&mut v).range(range.clone()).speed(0.05).suffix(" pt")).labelled_by(input_label.id); + crate::ribbon::live_drag( + app, + ui, + &r, + f64::from(cur), + v, + |app, x| { + let x = x.clamp(*range.start(), *range.end()) as f32; + if let Some(b) = + app.session.map_mut().layer_mut(id).and_then(|l| class_mut(&mut l.labels, c.name)).and_then(|k| k.background.as_mut()) + { + match key { + "strokeWidth" => b.stroke_width = x, + "radius" => b.radius = x, + "across" => b.padding[0] = x, + _ => b.padding[1] = x, + } + } + }, + |app, x| { + let change = match key { + "across" => json!({"padding": [x, b.padding[1]]}), + "upDown" => json!({"padding": [b.padding[0], x]}), + other => json!({other: x}), + }; + run(app, "labels.style", c.params(json!({"background": change}))); + }, + ); + ui.end_row(); + }; + use rustgis_engine::map::symbology::{BACKGROUND_PADDING, BACKGROUND_STROKE}; + if b.stroke.is_some() { + number(app, ui, "Outline width", b.stroke_width, BACKGROUND_STROKE, "strokeWidth"); + } + number(app, ui, "Padding across", b.padding[0], BACKGROUND_PADDING, "across"); + number(app, ui, "Padding up and down", b.padding[1], BACKGROUND_PADDING, "upDown"); + number(app, ui, "Corner radius", b.radius, BACKGROUND_PADDING, "radius"); +} + /// 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")); @@ -603,7 +683,7 @@ fn halo_style(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, c: &Class) { 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)); + pick_drag(app, ui, l, c.name, Part::Halo, rgba(color)); } } }); @@ -736,6 +816,47 @@ fn position(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, c: &Class) { pick(app, ui, LabelPlacement::Curved); }); } + ui.add_space(6.0); + offset(app, ui, l, c); +} + +/// How far the label moves from where its placement puts it, right and down, in points. +fn offset(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, c: &Class) { + use rustgis_engine::map::symbology::LABEL_OFFSET; + let id = l.id; + let cur = c.it.offset; + grid(ui, "offset", |ui| { + for (i, label) in [(0, "Offset right"), (1, "Offset down")] { + let input_label = ui.label(tl!(label)); + let mut v = f64::from(cur.get(i).copied().unwrap_or(0.0)); + let r = ui + .add(egui::DragValue::new(&mut v).range(LABEL_OFFSET).speed(0.1).suffix(" pt")) + .labelled_by(input_label.id) + .on_hover_text(tl!("A label along a line isn't moved.")); + crate::ribbon::live_drag( + app, + ui, + &r, + f64::from(cur.get(i).copied().unwrap_or(0.0)), + v, + |app, x| { + if let Some(k) = app.session.map_mut().layer_mut(id).and_then(|l| class_mut(&mut l.labels, c.name)) + && let Some(o) = k.offset.get_mut(i) + { + *o = x.clamp(*LABEL_OFFSET.start(), *LABEL_OFFSET.end()) as f32; + } + }, + |app, x| { + let mut o = cur.map(f64::from); + if let Some(slot) = o.get_mut(i) { + *slot = x; + } + run(app, "labels.placement", c.params(json!({"offset": o}))); + }, + ); + ui.end_row(); + } + }); } fn visibility(app: &mut RustGisApp, ui: &mut Ui, c: &Class) { @@ -796,52 +917,73 @@ struct ColorDrag { layer: u32, /// The class picked on: `None` for Default. class: Option, - /// The halo's colour rather than the text's. - halo: bool, - /// (A halo's `None` is its automatic colour.) + /// Which of its colours. + part: Part, + /// (A halo's `None` is its automatic colour; a background's, none.) 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) } +/// Which of a label class's colours a pick changes. +#[derive(Clone, Copy, PartialEq)] +enum Part { + Text, + Halo, + /// Its background's fill and outline. + Fill, + Outline, +} + +/// The colour a pick changes on a class: the text's, the halo's own (`None`: automatic), or its +/// background's (`None`: none, or no background). +fn picked(k: &LabelClass, part: Part) -> Option { + match part { + Part::Text => Some(k.color), + Part::Halo => k.halo_color, + Part::Fill => k.background.as_ref().and_then(|b| b.fill), + Part::Outline => k.background.as_ref().and_then(|b| b.stroke), + } } -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) => {} +fn set_picked(k: &mut LabelClass, part: Part, c: Option) { + match (part, c) { + (Part::Text, Some(c)) => k.color = c, + (Part::Text, None) => {} + (Part::Halo, c) => k.halo_color = c, + (Part::Fill, c) => { + if let Some(b) = &mut k.background { + b.fill = c; + } + } + (Part::Outline, c) => { + if let Some(b) = &mut k.background { + b.stroke = c; + } + } } } /// 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); + pick_drag(app, ui, l, class, Part::Text, color); } -fn pick_drag(app: &mut RustGisApp, ui: &Ui, l: &Layer, class: Option<&str>, halo: bool, color: Rgba) { +fn pick_drag(app: &mut RustGisApp, ui: &Ui, l: &Layer, class: Option<&str>, part: Part, 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, 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) { + if let Some(other) = pending.take_if(|d| d.layer != l.id || d.class.as_deref() != class || d.part != part) { 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| picked(k, halo)) else { return }; + let Some(now) = class.map_or(Some(&l.labels.default), |n| l.labels.class(n)).map(|k| picked(k, part)) 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), halo, before, color })); + ui.data_mut(|d| d.insert_temp(key, ColorDrag { layer: l.id, class: class.map(String::from), part, before, color })); if let Some(k) = app.session.map_mut().layer_mut(l.id).and_then(|l| class_mut(&mut l.labels, class)) { - set_picked(k, halo, Some(color)); + set_picked(k, part, Some(color)); } app.session.revision = app.session.revision.wrapping_add(1); } @@ -874,15 +1016,21 @@ 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; }; - set_picked(k, drag.halo, drag.before); + set_picked(k, drag.part, 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); - let key = if drag.halo { "haloColor" } else { "color" }; - run(app, "labels.style", named(json!({key: drag.color.hex()}), "class", drag.class.as_deref())); + let hex = drag.color.hex(); + let change = match drag.part { + Part::Text => json!({"color": hex}), + Part::Halo => json!({"haloColor": hex}), + Part::Fill => json!({"background": {"fill": hex}}), + Part::Outline => json!({"background": {"stroke": hex}}), + }; + run(app, "labels.style", named(change, "class", drag.class.as_deref())); app.session.active_layer = selected; } } @@ -990,6 +1138,47 @@ mod tests { assert!(h.query_by_label("Automatic").is_none() && h.query_by_label("Thin").is_none()); } + #[test] + fn the_background_and_the_offset_are_set_in_the_pane() { + use egui::accesskit::Role; + let mut h = harness(with_cities()); + h.run(); + open_tab(&mut h, "Symbol"); + let background = |h: &egui_kittest::Harness<'_, RustGisApp>| { + h.state().session.map().layer_by_name("Cities").unwrap().labels.default.background.clone() + }; + assert_eq!(background(&h), None); + assert!(h.query_by_role_and_label(Role::SpinButton, "Padding across").is_none(), "no background: no rows"); + h.get_by_label("Background").click(); + h.run(); + assert_eq!(background(&h), Some(rustgis_engine::map::LabelBackground::default())); + for row in ["Outline width", "Padding across", "Padding up and down", "Corner radius"] { + assert!(h.query_by_role_and_label(Role::SpinButton, row).is_some(), "{row}"); + } + // A colour picked for its fill is one labels.style, the pre-pick colour undone to. + let app = h.state_mut(); + let layer = app.session.map().layer_by_name("Cities").unwrap().id; + let pick = super::ColorDrag { + layer, + class: None, + part: super::Part::Fill, + before: Some(Rgba::rgb(255, 255, 255)), + color: Rgba::rgb(1, 2, 3), + }; + super::apply_color_drag(app, pick); + assert_eq!(background(&h).and_then(|b| b.fill), Some(Rgba::rgb(1, 2, 3))); + h.state_mut().session.run("edit.undo", &json!({})).unwrap(); + assert_eq!(background(&h).and_then(|b| b.fill), Some(Rgba::rgb(255, 255, 255))); + h.run(); + h.get_by_label("Background").click(); + h.run(); + assert_eq!(background(&h), None); + assert!(h.query_by_role_and_label(Role::SpinButton, "Corner radius").is_none()); + open_tab(&mut h, "Position"); + assert!(h.query_by_role_and_label(Role::SpinButton, "Offset right").is_some()); + assert!(h.query_by_role_and_label(Role::SpinButton, "Offset down").is_some()); + } + #[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 9471c207e..0621c09f9 100644 --- a/crates/ui-egui/src/mapview.rs +++ b/crates/ui-egui/src/mapview.rs @@ -1338,7 +1338,10 @@ fn draw_label_ink(p: &egui::Painter, l: &PlacedLabel, at: Pos2, halo: bool, text }; // A label turned with the map (along a street): turned about its centre, at `at`. if let Some(angle) = l.angle.filter(|a| a.is_finite() && a.abs() > 1e-3) { - let (pos, _) = place_galley(Align2::CENTER_CENTER, at, &galley); + let (pos, r) = place_galley(Align2::CENTER_CENTER, at, &galley); + if halo { + draw_background(p, l, r, angle); + } let shape = |offset: egui::Vec2, color: Color32| { egui::epaint::TextShape::new(pos + offset, galley.clone(), color) .with_override_text_color(color) @@ -1356,6 +1359,9 @@ fn draw_label_ink(p: &egui::Painter, l: &PlacedLabel, at: Pos2, halo: bool, text return; } let (pos, r) = place_galley(anchor, at, &galley); + if halo { + draw_background(p, l, r, 0.0); + } // A selected text graphic: framed as laid out, under the text. if l.framed { let cyan = c32(rustgis_engine::render::SELECTION); @@ -1372,6 +1378,58 @@ fn draw_label_ink(p: &egui::Painter, l: &PlacedLabel, at: Pos2, halo: bool, text } } +/// Draws a label's background behind its text laid out in `text` (points), turned by `angle` +/// (radians, clockwise) about the text's centre: the rectangle grown by its padding, its corners +/// rounded, filled and outlined. +fn draw_background(p: &egui::Painter, l: &PlacedLabel, text: Rect, angle: f32) { + if !l.glyphs.is_empty() { + return; + } + let Some(b) = l.background.as_ref().filter(|b| b.draws()) else { return }; + let r = text.expand2(vec2(b.padding[0], b.padding[1]) * 1.33); + let points = background_outline(r, b.radius * 1.33, angle); + if points.len() < 3 { + return; + } + let fill = b.fill.map_or(Color32::TRANSPARENT, c32); + let stroke = match b.stroke { + Some(c) if b.stroke_width > 0.0 => Stroke::new(b.stroke_width * 1.33, c32(c)), + _ => Stroke::NONE, + }; + p.add(egui::Shape::convex_polygon(points, fill, stroke)); +} + +/// A rectangle's outline with its corners rounded by `radius` (at most half its shorter side), +/// turned by `angle` about its centre: the corners as a few points each. +fn background_outline(r: Rect, radius: f32, angle: f32) -> Vec { + if !(r.is_finite() && r.width() > 0.0 && r.height() > 0.0) { + return Vec::new(); + } + let radius = if radius.is_finite() { radius.clamp(0.0, r.width().min(r.height()) / 2.0) } else { 0.0 }; + let steps = if radius > 0.0 { 4 } else { 0 }; + // Each corner's centre and the angle its arc starts at, clockwise from the top left. + let corners = [ + (pos2(r.min.x + radius, r.min.y + radius), std::f32::consts::PI), + (pos2(r.max.x - radius, r.min.y + radius), -std::f32::consts::FRAC_PI_2), + (pos2(r.max.x - radius, r.max.y - radius), 0.0), + (pos2(r.min.x + radius, r.max.y - radius), std::f32::consts::FRAC_PI_2), + ]; + let (sin, cos) = angle.sin_cos(); + let c = r.center(); + let turn = |q: Pos2| { + let d = q - c; + pos2(c.x + d.x * cos - d.y * sin, c.y + d.x * sin + d.y * cos) + }; + let mut out = Vec::with_capacity(4 * (steps + 1)); + for (centre, start) in corners { + for i in 0..=steps { + let a = start + std::f32::consts::FRAC_PI_2 * i as f32 / steps.max(1) as f32; + out.push(turn(centre + vec2(a.cos(), a.sin()) * radius)); + } + } + out +} + /// The most copies of a label's text a halo of its own width is drawn with. const MAX_HALO_COATS: usize = 64; @@ -3615,6 +3673,29 @@ mod halo_tests { use super::*; use rustgis_engine::map::Rgba; + #[test] + fn a_backgrounds_outline_is_its_rectangle_rounded_and_turned() { + let r = Rect::from_min_max(pos2(10.0, 20.0), pos2(50.0, 30.0)); + // Square: its four corners, clockwise from the top left. + assert_eq!(background_outline(r, 0.0, 0.0), vec![r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()]); + // Rounded: five points a corner, all within it, the corners themselves cut off. + let round = background_outline(r, 3.0, 0.0); + assert_eq!(round.len(), 20); + assert!(round.iter().all(|p| r.expand(1e-3).contains(*p))); + assert!(!round.contains(&r.left_top())); + // A radius past half the shorter side is held to it. + let most = background_outline(r, 99.0, 0.0); + assert!(most.iter().all(|p| r.expand(1e-3).contains(*p))); + // Turned a quarter about its centre: as tall as it was wide. + let turned = background_outline(r, 0.0, std::f32::consts::FRAC_PI_2); + let b = Rect::from_points(&turned); + assert!((b.width() - 10.0).abs() < 1e-3 && (b.height() - 40.0).abs() < 1e-3 && (b.center() - r.center()).length() < 1e-3, "{b:?}"); + // Nothing for a rectangle of no size or no number. + assert!(background_outline(Rect::from_min_max(pos2(0.0, 0.0), pos2(0.0, 5.0)), 0.0, 0.0).is_empty()); + assert!(background_outline(Rect::from_min_max(pos2(0.0, 0.0), pos2(f32::NAN, 5.0)), 0.0, 0.0).is_empty()); + assert_eq!(background_outline(r, f32::NAN, 0.0).len(), 4); + } + fn label(halo_width: Option) -> PlacedLabel { PlacedLabel { text: "A".into(), @@ -3635,6 +3716,7 @@ mod halo_tests { angle: None, collide: Default::default(), glyphs: Vec::new(), + background: None, } } diff --git a/crates/ui-egui/src/scriptcomplete.rs b/crates/ui-egui/src/scriptcomplete.rs index 339e197af..06bb606c3 100644 --- a/crates/ui-egui/src/scriptcomplete.rs +++ b/crates/ui-egui/src/scriptcomplete.rs @@ -1850,9 +1850,11 @@ mod tests { h.event(egui::Event::Text("y".into())); h.run_steps(60); h.get_by_label("labels.style"); - // Scroll the box back to its top, away from the cursor. + // Scroll the box back to its top, away from the cursor: the pointer low in the pane, clear + // of the list and its help above the cursor (which a command with many parameters makes + // tall), so the wheel scrolls the box rather than the list. let at = h.get_by_role(egui::accesskit::Role::MultilineTextInput).rect().center(); - h.event(egui::Event::PointerMoved(egui::pos2(at.x, 200.0))); + h.event(egui::Event::PointerMoved(egui::pos2(at.x, 640.0))); h.event(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: egui::vec2(0.0, 100_000.0), diff --git a/docs/user-guide/cartography.md b/docs/user-guide/cartography.md index 39a26dd9c..05673d4e2 100644 --- a/docs/user-guide/cartography.md +++ b/docs/user-guide/cartography.md @@ -314,6 +314,20 @@ Turn labels on per layer (Labeling › Labels › **Label**), then: 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). +- **Background**: a rectangle behind the text (Labeling pane › Symbol › **Background**), white + with a thin dark outline when turned on: its **Fill** and **Outline** (each can be none), the + outline's width, its padding across and up and down (how far it reaches past the text, 0–50 + points) and its **Corner radius**. Other labels keep off it as they keep off a halo, and it + turns with a label along a line; a label curved along a line, character by character, has + none. Its sizes grow with the labels at a reference scale + (`labels.style={"layer": "Roads", "background": {"fill": "#FFFFFF", "stroke": "#262626", "strokeWidth": 0.3, "padding": [1.2, 0.3], "radius": 0.7}}`; + `"background": true` turns on the default one, `false` takes it away, and what an object leaves + out is kept). +- **Offset**: a label moved from where its position puts it, right and down in points + (Labeling pane › Position › **Offset right**, **Offset down**, −200 to 200; negative moves it + left or up), growing with the labels at a reference scale. A label along a line isn't moved + (`labels.placement={"layer": "Cities", "position": "aboveRight", "offset": [5.7, -2.8]}`; an + offset can be given without a position). - **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 4d9ce0c61..a53b7b959 100644 --- a/docs/user-guide/workspace.md +++ b/docs/user-guide/workspace.md @@ -108,8 +108,11 @@ Each layer keeps its look: - **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, 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. + RustGIS's label font), its buffer as a halo of its colour, opacity and size, its background as + a rectangle (filled and outlined as its fill symbol says, padded by its size, its corners + rounded), its placement (Parallel and Curved along lines, around and over points), its offset + from a point (and on a point layer, its distance around the point, as an offset upwards) 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. @@ -119,7 +122,8 @@ QGIS expressions carry over where they are SQL: fields compared with values, `LI 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 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 +over the map, label backgrounds of other shapes drawn as rectangles, label distances from lines +and polygons, 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 From d79d859bcc7e95c27cf8faf2ab1c994b2fedaf50 Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Tue, 27 Oct 2026 08:55:05 +0100 Subject: [PATCH 2/3] fix(labels): a background keeps others off as far as it reaches on each axis; an offset label of a point just out of view is placed - label_boxes reserves a background's reach across and up and down separately. - A point out of view by up to its classes' largest offset is placed, so its moved label shows. - The MapLibre export leaves out text-offset for labels along a line, which the map doesn't move. - The QGIS import notes a distance around points, moved straight up where QGIS goes diagonally. --- crates/engine/src/cmd/sharing.rs | 5 ++-- crates/map/src/qgis/labels.rs | 5 +++- crates/map/src/qgis/tests.rs | 5 ++-- crates/render/src/lib.rs | 48 +++++++++++++++++++++++++++----- crates/render/src/text.rs | 14 ++++++---- docs/user-guide/workspace.md | 2 +- 6 files changed, 61 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/sharing.rs b/crates/engine/src/cmd/sharing.rs index 1afc41140..2111e691f 100644 --- a/crates/engine/src/cmd/sharing.rs +++ b/crates/engine/src/cmd/sharing.rs @@ -654,8 +654,9 @@ pub fn web_style(l: &Layer) -> Value { if c.spacing != 0.0 && c.size > 0.0 { layout["text-letter-spacing"] = json!(f64::from(c.spacing) / f64::from(c.size)); } - // Its offset in ems too (a background has no MapLibre equivalent: left out). - if c.offset != [0.0; 2] && c.size > 0.0 { + // Its offset in ems too, unless along a line, which the map doesn't move (a background + // has no MapLibre equivalent: left out). + if c.offset != [0.0; 2] && c.size > 0.0 && !(c.placement.along_line() && gt == GeometryType::Polyline) { layout["text-offset"] = json!(c.offset.map(|o| f64::from(o) / f64::from(c.size))); } layers.push(json!({"id": format!("{src}-labels"), "type": "symbol", "source": src, "layout": layout, diff --git a/crates/map/src/qgis/labels.rs b/crates/map/src/qgis/labels.rs index d0ce80606..d75a0d85f 100644 --- a/crates/map/src/qgis/labels.rs +++ b/crates/map/src/qgis/labels.rs @@ -337,7 +337,10 @@ fn offset(p: Node, geometry: Option<&str>, layer: &str, report: &mut Report) -> [x, y].map(|v| within(points(v, unit, "label offsets", layer, report))) } "0" | "6" if geometry == Some("Point") => match num("dist").filter(|d| *d != 0.0) { - Some(d) => [0.0, -within(points(d, p.attribute("distUnits").unwrap_or("MM"), "label distances", layer, report))], + Some(d) => { + report.approximated(layer, "label distances round points, moved straight up"); + [0.0, -within(points(d, p.attribute("distUnits").unwrap_or("MM"), "label distances", layer, report))] + } None => [0.0; 2], }, _ => [0.0; 2], diff --git a/crates/map/src/qgis/tests.rs b/crates/map/src/qgis/tests.rs index d70045df7..a11ecfc72 100644 --- a/crates/map/src/qgis/tests.rs +++ b/crates/map/src/qgis/tests.rs @@ -441,7 +441,8 @@ fn a_labels_background_and_offset_carry_over_in_their_units() { assert_eq!(c.offset, [0.0; 2]); assert!(report.notes.iter().any(|n| n.text.contains("label distances")), "{:?}", report.notes); - // In a project, a point layer's distance round its points: the label (above them) that far up. + // In a project, a point layer's distance round its points: the label (above them) that far up, + // noted, as QGIS puts it out diagonally. let qgs = |geometry: &str| { format!( r#"p/d/p.shpPogr @@ -452,7 +453,7 @@ fn a_labels_background_and_offset_carry_over_in_their_units() { let p = read_project(&qgs("Point"), "", &mut report).unwrap(); let c = &p.layers[0].style.labels.as_ref().unwrap().default; assert!(c.offset[0] == 0.0 && near(c.offset[1], -mm(1.5)), "{:?}", c.offset); - assert!(!report.notes.iter().any(|n| n.text.contains("label distances")), "{:?}", report.notes); + assert!(report.notes.iter().any(|n| n.text == "label distances round points, moved straight up"), "{:?}", report.notes); let mut report = Report::default(); let p = read_project(&qgs("Polygon"), "", &mut report).unwrap(); assert_eq!(p.layers[0].style.labels.as_ref().unwrap().default.offset, [0.0; 2]); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 29a5003d5..61d67f48e 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -3270,6 +3270,17 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac // view, wherever its label point is. let along_line = |placement: LabelPlacement, g: &Geometry| placement.along_line() && matches!(g, Geometry::Line(_)); let any_along = labels.in_order().zip(&label_fields).any(|(c, f)| f.is_some() && c.placement.along_line()); + // How far a class moves its labels at most (points, as placed below): a point that + // far out of view may still have its label in it. + let offset_reach = labels + .in_order() + .zip(&label_fields) + .filter(|(_, f)| f.is_some()) + .map(|(c, _)| c.offset[0].abs().max(c.offset[1].abs()) * 1.33 * label_factor) + .filter(|r| r.is_finite()) + .fold(0.0, f32::max); + let offset_reach = f64::from(offset_reach); + let (vw, vh) = (ctx.view.width, ctx.view.height); for (row, g) in geoms.iter().enumerate() { let Some(g) = g else { continue }; if rows.get(row) != Some(&true) { @@ -3279,7 +3290,14 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac // Where the label point is on the screen, `None` when it is out of view (or // behind the globe): then only a line labelled along it can be, on its // stretch in view. - let point = if globe.is_some() || ctx.cull.contains(c) { ctx.screen(c) } else { None }; + let point = if globe.is_some() || ctx.cull.contains(c) { + ctx.screen(c) + } else if offset_reach > 0.0 { + let r = offset_reach; + ctx.screen(c).filter(|&(x, y)| x >= -r && y >= -r && x <= vw + r && y <= vh + r) + } else { + None + }; let line_in_view = || globe.is_some() || ctx.cull.intersects(&extents.get(row).copied().unwrap_or_else(|| g.extent())); if point.is_none() && !(any_along && matches!(g, Geometry::Line(_)) && line_in_view()) { continue; @@ -4192,9 +4210,11 @@ fn label_boxes(l: &PlacedLabel) -> (Vec<[f32; 4]>, Vec<[f32; 4]>) { let th = text::block_height(&l.text, l.size); // 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).max(text::background_reach(l)); - let p = l.collide.text_padding.clamp(0.0, 100.0) + halo; + // kept with the halo turned off reserves nothing. A background reaches as far as its + // padding across, and up and down. + let halo = text::drawn_halo_reach(l); + let [across, up_down] = text::background_reach(l).map(|r| l.collide.text_padding.clamp(0.0, 100.0) + halo.max(r)); + let p = across.max(up_down); let cx = l.center_x(); if !l.glyphs.is_empty() { // A curved label: a square round each character, as tall as the text. @@ -4220,7 +4240,7 @@ fn label_boxes(l: &PlacedLabel) -> (Vec<[f32; 4]>, Vec<[f32; 4]>) { text.push([x - r, y - r, x + r, y + r]); } } - None => text.push([cx - tw / 2.0 - p, l.y - th / 2.0 - p, cx + tw / 2.0 + p, l.y + th / 2.0 + p]), + None => text.push([cx - tw / 2.0 - across, l.y - th / 2.0 - up_down, cx + tw / 2.0 + across, l.y + th / 2.0 + up_down]), } } let icon = l @@ -6723,7 +6743,7 @@ mod tests { assert!(!plain.pixels().iter().any(|p| yellow(*p))); let nothing = rustgis_map::LabelBackground { fill: None, stroke: None, ..bg.clone() }; assert_eq!(drawn(&label(Some(nothing.clone()))).data(), plain.data()); - assert_eq!(text::background_reach(&label(Some(nothing))), 0.0); + assert_eq!(text::background_reach(&label(Some(nothing))), [0.0; 2]); // Turned along a line, it turns with the text. let turned = drawn(&PlacedLabel { angle: Some(0.6), ..l.clone() }); assert!(turned.pixel(60, 50).is_some_and(|p| yellow(p) || p.red() < 40), "{:?}", turned.pixel(60, 50)); @@ -6735,11 +6755,13 @@ mod tests { assert!(!drawn(&curved).pixels().iter().any(|p| yellow(*p))); // Its reach widens the box other labels keep off: a label just past the text but on the // background is placed beside the plain one, not beside the one with a background. - let reach = text::background_reach(&l); + let [reach, up_down] = text::background_reach(&l); assert!((reach - (6.0 * 1.33 + 0.75 * 1.33)).abs() < 0.01, "{reach}"); let (plain_box, _) = label_boxes(&label(None)); let (bg_box, _) = label_boxes(&l); assert!((plain_box[0][0] - bg_box[0][0] - reach).abs() < 0.01, "{plain_box:?} {bg_box:?}"); + // Up and down only as far as it reaches there: less padding than across, less kept off. + assert!(up_down < reach && (plain_box[0][1] - bg_box[0][1] - up_down).abs() < 0.01, "{up_down} {plain_box:?} {bg_box:?}"); let beside = |first: PlacedLabel| { let next = PlacedLabel { x: first.x + w + 4.0, ..label(None) }; place_labels(vec![(first, 2.0, (0, 0, 0)), (next, 1.0, (0, 1, 0))], Vec::new(), 400.0, 100.0, false).labels.len() @@ -6787,6 +6809,18 @@ mod tests { m.layer_mut(id).unwrap().labels.default.offset = [6.0, -3.0]; let (big2, _) = at(&mut m); assert!((big2.0 - big.0 - 2.0 * 6.0 * 1.33).abs() < 0.01, "{big:?} → {big2:?}"); + // A point 30 points left of the view (past the culling margin) is labelled when its offset + // brings the label into it. + let mut d = Dataset::new("edge", GeometryType::Point, vec![Field::new("name", FieldType::Text)]); + d.push(Some(Geometry::Point(Coord::new(-3.1, 0.0))), vec![Value::Text("Elgg".into())]); + let edge = m.add_dataset(d); + let l = m.layer_mut(edge).unwrap(); + l.labels.enabled = true; + l.labels.default.field = "name".into(); + let shown = |m: &mut Map| render(m, &view).unwrap().labels.iter().find(|l| l.text == "Elgg").map(|l| l.x); + assert_eq!(shown(&mut m), None); + m.layer_mut(edge).unwrap().labels.default.offset = [100.0, 0.0]; + assert!(shown(&mut m).is_some_and(|x| x > 0.0 && x < 400.0), "{:?}", shown(&mut m)); } #[test] diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index ad6f20e21..ef3d89cb1 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -449,13 +449,14 @@ pub fn background_box(l: &PlacedLabel) -> Option<[f32; 4]> { } /// How far a label's background, its outline's outer half included, reaches past the box its -/// text collides with (points), none without one: other labels keep off it as off a halo. -pub fn background_reach(l: &PlacedLabel) -> f32 { - let (Some([x0, y0, _, y1]), Some(b)) = (background_box(l), l.background.as_ref()) else { return 0.0 }; +/// text collides with (points: across, and up and down), none without one: other labels keep off +/// it as off a halo. +pub fn background_reach(l: &PlacedLabel) -> [f32; 2] { + let (Some([x0, y0, _, y1]), Some(b)) = (background_box(l), l.background.as_ref()) else { return [0.0; 2] }; let outline = if b.stroke.is_some_and(|s| s.a() > 0) { b.stroke_width * 1.33 / 2.0 } else { 0.0 }; let across = l.center_x() - label_width(l) / 2.0 - x0; let up_down = (y1 - y0) / 2.0 - block_height(&l.text, l.size) / 2.0; - (across.max(up_down) + outline).max(0.0) + [across, up_down].map(|r| (r + outline).max(0.0)) } /// Draws a label's background (logical coordinates, upright) at `ppp` pixels a point. @@ -642,7 +643,10 @@ 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. // (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)) + background_reach(l); + let pad = 4.0 + + l.size * 0.5 + + drawn_halo_reach(l).min(MAX_HALO_PX / ppp.max(f32::EPSILON)) + + background_reach(l).into_iter().fold(0.0, f32::max); 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; diff --git a/docs/user-guide/workspace.md b/docs/user-guide/workspace.md index a53b7b959..9444dbc94 100644 --- a/docs/user-guide/workspace.md +++ b/docs/user-guide/workspace.md @@ -123,7 +123,7 @@ near as it can, or left out, and said: the import's notes list, layer by layer, approximated (pattern fills drawn as a tint of their colour, SVG markers as circles, dashed polygon outlines solid, label halos with sharp corners drawn round and blended ones drawn over the map, label backgrounds of other shapes drawn as rectangles, label distances from lines -and polygons, and wrapping, values outside a graduated renderer's ranges in its first or last class, file options +and polygons, a distance around points moved straight up, 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 From 609fc909be65198c1e9d2daa73042fb36e51cbb7 Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Tue, 27 Oct 2026 09:31:57 +0100 Subject: [PATCH 3/3] test(labels): the label beside a background is placed clear of the padding, not on its edge The plain pair touched exactly at their 2-point paddings, where the text width's rounding (fonts differ on CI) decided whether they collide. --- crates/render/src/lib.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 61d67f48e..6f049478c 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -6763,7 +6763,9 @@ mod tests { // Up and down only as far as it reaches there: less padding than across, less kept off. assert!(up_down < reach && (plain_box[0][1] - bg_box[0][1] - up_down).abs() < 0.01, "{up_down} {plain_box:?} {bg_box:?}"); let beside = |first: PlacedLabel| { - let next = PlacedLabel { x: first.x + w + 4.0, ..label(None) }; + // A point clear of the two labels' padding (2 points each), not on its edge, where + // fonts' rounding decides. + let next = PlacedLabel { x: first.x + w + 5.0, ..label(None) }; place_labels(vec![(first, 2.0, (0, 0, 0)), (next, 1.0, (0, 1, 0))], Vec::new(), 400.0, 100.0, false).labels.len() }; assert_eq!((beside(label(None)), beside(l.clone())), (2, 1));