Skip to content
Merged
33 changes: 32 additions & 1 deletion crates/engine/src/cmd/layer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,38 @@ pub fn commands() -> Vec<C> {
// A family by name, whether or not this machine has it: the project may be drawn on
// one that does. Blank goes back to the label font.
let font = ps(p, "font").map(rustgis_map::symbology::font_name);
// A halo's own colour ("" or null: one standing out from the text), its width in points
// (null: the thin halo), and the letter spacing.
let halo_color = match p.get("haloColor") {
None => None,
Some(Value::Null) => Some(None),
Some(Value::String(c)) if c.trim().is_empty() => Some(None),
Some(_) => Some(Some(s.get_color(p, "haloColor")?.ok_or("haloColor must be #RRGGBB or #RRGGBBAA")?)),
};
let hw = rustgis_map::symbology::HALO_WIDTH;
let sp = (rustgis_map::layout::MIN_LETTER_SPACING, rustgis_map::layout::MAX_LETTER_SPACING);
let halo_width = match p.get("haloWidth") {
None => None,
Some(Value::Null) => Some(None),
Some(_) => {
let w = f(p, "haloWidth").ok_or("haloWidth must be a number of points, or null")?;
Some(Some(w.clamp(*hw.start(), *hw.end()) as f32))
}
};
let spacing = match p.get("spacing") {
None => None,
Some(_) => Some(f(p, "spacing").ok_or("spacing must be a number of points")?.clamp(sp.0, sp.1) as f32),
};
super::labels::with_class(s, p, |c| {
if let Some(v) = halo_color {
c.halo_color = v;
}
if let Some(v) = halo_width {
c.halo_width = v;
}
if let Some(v) = spacing {
c.spacing = v;
}
if let Some(name) = font.clone() {
c.font = name;
}
Expand All @@ -355,7 +386,7 @@ pub fn commands() -> Vec<C> {
})
})
.when(need_feature_layer)
.params(r##"{"layer"?, "class"?: name (none: the Default class, by whatever name it has), "size"?: number, "color"?: "#RRGGBB"|"#RRGGBBAA", "halo"?: bool, "bold"?: bool, "italic"?: bool, "font"?: installed family name (labels.fonts lists them; "" is the label font)}"##),
.params(r##"{"layer"?, "class"?: name (none: the Default class, by whatever name it has), "size"?: number, "color"?: "#RRGGBB"|"#RRGGBBAA", "halo"?: bool, "haloColor"?: "#RRGGBB"|"#RRGGBBAA"|"" (""/null: white or dark, whichever stands out from the text), "haloWidth"?: points 0-10|null (null: the thin halo), "spacing"?: points between letters (-5 to 50), "bold"?: bool, "italic"?: bool, "font"?: installed family name (labels.fonts lists them; "" is the label font)}"##),
C::new("layer.definitionQuery", "Definition Query", "Data › Definition Query", |s, p| {
let q = ps(p, "where").map(str::trim).unwrap_or("").to_string();
let id = s.layer_id(p)?;
Expand Down
15 changes: 12 additions & 3 deletions crates/engine/src/cmd/sharing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -644,9 +644,18 @@ pub fn web_style(l: &Layer) -> Value {
}
}
if l.labels.enabled && l.labels.default.visible && !l.labels.default.field.is_empty() {
layers.push(json!({"id": format!("{src}-labels"), "type": "symbol", "source": src,
"layout": {"text-field": ["to-string", prop(&l.labels.default.field)], "text-size": l.labels.default.size},
"paint": {"text-color": l.labels.default.color.hex(), "text-halo-color": "#ffffff", "text-halo-width": if l.labels.default.halo { 1.5 } else { 0.0 }}}));
let c = &l.labels.default;
// A halo of the class's own colour and width (points, as pixels: as the text's size), else
// the one the map draws (light behind dark text, dark behind light) and thin.
let halo_color = css(c.halo_color_for(c.color));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Behaviour change (low-medium confidence, not in the PR description): the export used to write text-halo-color: "#ffffff" for every label. It now writes the automatic colour, so light-coloured text gets a dark halo in the MapLibre style. That matches what the map draws, but the description says existing projects are unchanged, and that is only true for the map. Worth a line in the PR description or the changelog.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noted in the PR description, under 'After review': the export used to write a white halo for every label, so light text now gets a dark one there, as on the map.

let halo_width = if c.halo { c.halo_width.map_or(1.5, f64::from) } else { 0.0 };
let mut layout = json!({"text-field": ["to-string", prop(&c.field)], "text-size": c.size});
// Letter spacing in ems, as MapLibre has it.
if c.spacing != 0.0 && c.size > 0.0 {
layout["text-letter-spacing"] = json!(f64::from(c.spacing) / f64::from(c.size));
}
layers.push(json!({"id": format!("{src}-labels"), "type": "symbol", "source": src, "layout": layout,
"paint": {"text-color": c.color.hex(), "text-halo-color": halo_color, "text-halo-width": halo_width}}));
}
let (minzoom, maxzoom) = zoom_range(l);
for layer in &mut layers {
Expand Down
56 changes: 56 additions & 0 deletions crates/engine/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1743,6 +1743,9 @@ fn hostile_params_never_panic() {
json!({"layer":"Countries","row":0,"part":0,"ring":0,"index":0,"lon":{},"lat":1e300}),
json!({"layer": 99999, "path": "/nonexistent/x.geojson", "name": "", "where": "((", "tool": "buffer", "params": 7}),
json!({"layer": "Countries", "row": 1e30, "field": "nope", "value": [1], "scale": -1, "lon": 1e300, "lat": "x", "classes": 1e9, "type": "graduated"}),
// Label halos and spacing: out of range, of the wrong type.
json!({"layer": "Cities", "haloWidth": 1e308, "haloColor": 7, "spacing": "NaN"}),
json!({"layer": "Cities", "haloWidth": -1, "haloColor": "#zzz", "spacing": -1e9}),
json!({"layer": "Cities", "rows": [1, 99999999], "geometry": {"type": "Polygon", "coordinates": [[]]}, "width": 1e9, "height": -3, "expression": "'"}),
json!({"band": -1, "red": 1e12, "green": "x", "blue": f64::MAX, "preset": "", "ramp": "nope", "classes": -5, "n": 1e308, "low": -50, "high": 900, "gamma": 0, "zFactor": -1, "fileName": "a.tif", "data": "!!!"}),
json!({"fileName": "a.tif", "data": "AAAA", "type": "unique", "on": "maybe"}),
Expand Down Expand Up @@ -2142,6 +2145,44 @@ fn labels_take_a_font_by_family_name() {
assert!(s.run("labels.fonts", &json!({})).unwrap()["fonts"].is_array());
}

/// A label class's halo takes its own colour and width, and its letters a spacing; blank and null
/// go back to the automatic colour and the thin halo, and other settings leave them.
#[test]
fn labels_take_a_halo_colour_and_width_and_letter_spacing() {
use rustgis_map::Rgba;
let mut s = world();
let class = |s: &Session| {
let c = &s.map().layer_by_name("Cities").unwrap().labels.default;
(c.halo_color, c.halo_width, c.spacing)
};
assert_eq!(class(&s), (None, None, 0.0));
let mark = s.notice_mark();
s.run("labels.style", &json!({"layer": "Cities", "haloColor": "#FFEE00", "haloWidth": 1.42, "spacing": "0.5"})).unwrap();
assert!(s.notices_since(mark).iter().all(|n| !n.text.contains("ignores")), "every key is read");
assert_eq!(class(&s), (Some(Rgba::rgb(0xFF, 0xEE, 0)), Some(1.42), 0.5));
s.run("labels.style", &json!({"layer": "Cities", "size": 12})).unwrap();
assert_eq!(class(&s), (Some(Rgba::rgb(0xFF, 0xEE, 0)), Some(1.42), 0.5), "other settings leave them");
s.run("labels.style", &json!({"layer": "Cities", "haloWidth": 99, "spacing": -99})).unwrap();
assert_eq!(class(&s), (Some(Rgba::rgb(0xFF, 0xEE, 0)), Some(10.0), -5.0), "within their ranges");
s.run("labels.style", &json!({"layer": "Cities", "haloColor": "", "haloWidth": null})).unwrap();
assert_eq!(class(&s), (None, None, -5.0));
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(class(&s), (Some(Rgba::rgb(0xFF, 0xEE, 0)), Some(10.0), -5.0), "undoes");
s.run("labels.style", &json!({"layer": "Cities", "haloColor": null})).unwrap();
assert_eq!(class(&s).0, None);
for bad in [
json!({"haloColor": "pink"}),
json!({"haloColor": 7}),
json!({"haloWidth": "wide"}),
json!({"spacing": null}),
json!({"spacing": [1]}),
] {
let mut p = bad.clone();
p["layer"] = json!("Cities");
assert!(s.run("labels.style", &p).is_err(), "{bad}");
}
}

/// A lock names each label by its class's place in the list: a change to the list (a class added,
/// moved or removed) locks the labels again on the next frame, a change within a class keeps the
/// lock (#732).
Expand Down Expand Up @@ -7247,6 +7288,21 @@ fn web_style_carries_the_layers_scale_limits_as_zooms() {
style["layers"].as_array().unwrap().iter().any(|l| l["type"] == "symbol")
};
assert!(labelled(&s));
// The halo is the one the map draws: without its own colour, light behind dark text and dark
// behind light; its own colour as it is, see-through included.
let halo = |s: &Session| {
let style = crate::cmd::sharing::web_style(s.map().layer(id).unwrap());
let label = style["layers"].as_array().unwrap().iter().find(|l| l["type"] == "symbol").cloned().unwrap();
label["paint"]["text-halo-color"].as_str().unwrap().to_string()
};
let l = s.map_mut().layer_mut(id).unwrap();
l.labels.default.color = rustgis_map::Rgba::rgb(0, 0, 0);
l.labels.default.halo_color = None;
assert_eq!(halo(&s), "rgba(255,255,255,0.882)");
s.map_mut().layer_mut(id).unwrap().labels.default.color = rustgis_map::Rgba::rgb(255, 255, 255);
assert_eq!(halo(&s), "rgba(20,22,26,0.824)");
s.map_mut().layer_mut(id).unwrap().labels.default.halo_color = Some(rustgis_map::Rgba::new(255, 0, 0, 128));
assert_eq!(halo(&s), "rgba(255,0,0,0.502)");
s.map_mut().layer_mut(id).unwrap().labels.default.visible = false;
assert!(!labelled(&s));
}
Expand Down
70 changes: 46 additions & 24 deletions crates/map/src/qgis/labels.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
//! QGIS labels (`<labeling type="simple|rule-based">`) as RustGIS label classes: each rule a
//! class whose SQL clause is its filter (and its parents'), an else rule the features no other
//! rule takes; the text's field, size, colour, weight and slant, its buffer as a halo, its
//! placement and its scale range.
//! rule takes; the text's field, size, colour, weight, slant and letter spacing, its buffer as a
//! halo of its colour and size, its placement and its scale range.

use roxmltree::Node;

use super::{QGIS_SCALE, Report, child, color, data_defined_on, expr, prop, props};
use crate::Rgba;
use crate::symbology::{DEFAULT_LABEL_CLASS, LABEL_SIZE, LabelClass, LabelPlacement, Labeling, MAX_LABEL_CLASSES};
use crate::symbology::{DEFAULT_LABEL_CLASS, HALO_WIDTH, LABEL_SIZE, LabelClass, LabelPlacement, Labeling, MAX_LABEL_CLASSES};

/// A layer's labels from its `<labeling>`, or `None` when none can be carried over.
pub(super) fn read(l: Node, layer: &str, report: &mut Report) -> Option<Labeling> {
Expand Down Expand Up @@ -162,18 +162,10 @@ fn class(
return None;
}
let num = |n: Node, k: &str| n.attribute(k).and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite());
// The size in typographic points, as RustGIS's label sizes are.
let raw = num(text, "fontSize").unwrap_or(10.0);
let size = match text.attribute("fontSizeUnit").unwrap_or("Point") {
"Point" => raw,
"MM" => raw * 72.0 / 25.4,
"Pixel" => raw * 0.75,
"Inch" => raw * 72.0,
_ => {
report.approximated(layer, "label sizes in map units, taken at 1:25,000");
raw / 25_000.0 * 72.0 / 0.0254
}
};
// The size in typographic points, as RustGIS's label sizes are; its letter spacing in the
// size's unit.
let size_unit = text.attribute("fontSizeUnit").unwrap_or("Point");
let size = points(num(text, "fontSize").unwrap_or(10.0), size_unit, "label sizes", layer, report);
let opacity = num(text, "textOpacity").unwrap_or(1.0).clamp(0.0, 1.0) as f32;
let c = text.attribute("textColor").and_then(color).unwrap_or(Rgba::rgb(0, 0, 0));
let c = c.with_alpha((f32::from(c.a()) * opacity).round() as u8);
Expand All @@ -187,15 +179,28 @@ fn class(
let italic = style.contains("italic") || style.contains("oblique") || text.attribute("fontItalic") == Some("1");
// QGIS's font family: drawn in it where it is installed, else in RustGIS's label font.
let font = text.attribute("fontFamily").and_then(crate::symbology::font_name);
if num(text, "fontLetterSpacing").is_some_and(|s| s != 0.0) {
report.approximated(layer, "label letter spacing, left out");
}
let halo = child(text, "text-buffer").is_some_and(|b| b.attribute("bufferDraw") == Some("1"));
if halo {
report.approximated(
layer,
"label halos in their own colour and width, drawn as RustGIS's (thin, white or dark by the text's colour)",
);
let spacing =
num(text, "fontLetterSpacing").filter(|s| *s != 0.0).map_or(0.0, |s| points(s, size_unit, "label letter spacing", layer, report));
let (spacing_min, spacing_max) = (crate::layout::MIN_LETTER_SPACING, crate::layout::MAX_LETTER_SPACING);
// Its buffer as the halo, in the buffer's colour (and opacity) and size.
let buffer = child(text, "text-buffer").filter(|b| b.attribute("bufferDraw") == Some("1"));
let halo = buffer.is_some();
// (QGIS's buffer is white unless it says otherwise.)
let halo_color = buffer.map(|b| b.attribute("bufferColor").and_then(color).unwrap_or(Rgba::rgb(255, 255, 255))).map(|c| {
let opacity = buffer.and_then(|b| num(b, "bufferOpacity")).unwrap_or(1.0).clamp(0.0, 1.0) as f32;
c.with_alpha((f32::from(c.a()) * opacity).round() as u8)
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quality (nit, high confidence): buffer is unwrapped twice in this chain, so the opacity lookup reads awkwardly. The same result in one closure:

Suggested change
});
let halo_color = buffer.map(|b| {
let c = b.attribute("bufferColor").and_then(color).unwrap_or(Rgba::rgb(255, 255, 255));
let opacity = num(b, "bufferOpacity").unwrap_or(1.0).clamp(0.0, 1.0) as f32;
c.with_alpha((f32::from(c.a()) * opacity).round() as u8)
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining as a style nit: the chain reads the colour, then its opacity, as QGIS keeps them in two attributes, and the result is the same either way. Leaving as is.

let halo_width = buffer.map(|b| {
let w = points(num(b, "bufferSize").unwrap_or(1.0), b.attribute("bufferSizeUnits").unwrap_or("MM"), "label halos", layer, report);
w.clamp(*HALO_WIDTH.start(), *HALO_WIDTH.end()) as f32

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug (medium confidence): the clamp is silent. A QGIS buffer wider than 10 pt (about 3.5 mm) is drawn at 10 pt with no note, unlike the other approximations in this importer, so the import report says nothing about it. Letter spacing is clamped the same way at line 238.

Suggested change: note the clamp.

Suggested change
w.clamp(*HALO_WIDTH.start(), *HALO_WIDTH.end()) as f32
let w = points(num(b, "bufferSize").unwrap_or(1.0), b.attribute("bufferSizeUnits").unwrap_or("MM"), "label halos", layer, report);
if w > *HALO_WIDTH.end() {
report.approximated(layer, "label halos wider than 10 points, drawn at 10");
}
w.clamp(*HALO_WIDTH.start(), *HALO_WIDTH.end()) as f32

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid. A buffer wider than 10 pt, or letter spacing outside −5 to 50 pt, is clamped with no note in the import report. This merged before I could act on it, so it's noted for a follow-up: add both to the importer's notes.

});
if let Some(b) = buffer {
if b.attribute("bufferJoinStyle").is_some_and(|j| j != "128") {
report.approximated(layer, "label halos with sharp or bevelled corners, drawn round");
}
if !matches!(b.attribute("bufferBlendMode"), None | Some("0")) {
report.approximated(layer, "label halos blended into the map, drawn over it");
}
}
if child(text, "background").is_some_and(|b| b.attribute("shapeDraw") == Some("1")) {
report.approximated(layer, "label backgrounds (shapes behind the text), left out");
Expand Down Expand Up @@ -228,9 +233,26 @@ fn class(
placement,
italic,
font,
halo_color,
halo_width,
spacing: spacing.clamp(spacing_min, spacing_max) as f32,
})
}

/// A QGIS length (`what`'s, for the report) in typographic points: map units taken at 1:25,000.
fn points(v: f64, unit: &str, what: &str, layer: &str, report: &mut Report) -> f64 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quality (high confidence): this duplicates in_points in crates/map/src/qgis/mod.rs (line 592), which already converts QGIS lengths to points and takes the layer's reference scale for map units. The new points has the same name in the same module tree but different semantics: typographic points (72/in) here, 96-dpi pixels there, and it ignores the reference scale. Two helpers with one name and different units will be confused later. Consider a shared helper with a unit/reference parameter, or at least a different name.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair. This merged before I could act on it. Noted for a follow-up: fold the typographic conversion into in_points, or rename it, so the module doesn't have two points-style helpers with different meanings.

match unit {
"Point" => v,
"MM" => v * 72.0 / 25.4,
"Pixel" => v * 0.75,
"Inch" => v * 72.0,
_ => {
report.approximated(layer, format!("{what} in map units, taken at 1:25,000"));
v / 25_000.0 * 72.0 / 0.0254
}
}
}

/// Notes the label settings RustGIS has no place for, where they are set off QGIS's defaults.
fn unread(settings: Node, layer: &str, report: &mut Report) {
let num =
Expand Down
43 changes: 42 additions & 1 deletion crates/map/src/qgis/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,48 @@ fn merged_categories_and_dashes_carry_over() {
assert!(!report.notes.iter().any(|n| n.text.contains("dash")), "{:?}", report.notes);
}

#[test]
fn a_labels_buffer_and_letter_spacing_carry_over_in_their_units() {
let qml = |text: &str| {
format!(
r#"<qgis version="4.2.3"><renderer-v2 type="singleSymbol"><symbols><symbol type="line" name="0" alpha="1"><layer class="SimpleLine" enabled="1"/></symbol></symbols></renderer-v2>
<labeling type="simple"><settings>{text}</settings></labeling></qgis>"#
)
};
let read = |text: &str| {
let mut report = Report::default();
let s = read_style(&qml(text), "Water", &mut report).unwrap();
(s.labels.unwrap().default, report)
};
// SMV25's: 0.5 mm of white, letters 0.5 pt apart.
let (c, report) = read(
r#"<text-style fieldName="name" fontSize="9" fontSizeUnit="Point" fontLetterSpacing="0.5">
<text-buffer bufferDraw="1" bufferSize="0.5" bufferSizeUnits="MM" bufferColor="255,255,255,255,rgb:1,1,1,1" bufferOpacity="1" bufferJoinStyle="128"/></text-style>"#,
);
assert!(c.halo && c.halo_color == Some(Rgba::rgb(255, 255, 255)) && c.spacing == 0.5, "{c:?}");
assert!((c.halo_width.unwrap() - 0.5 * 72.0 / 25.4).abs() < 1e-4, "{:?}", c.halo_width);
assert!(report.notes.is_empty(), "{:?}", report.notes);
// Half see-through yellow, in points; spacing in the size's millimetres; corners not round.
let (c, report) = read(
r#"<text-style fieldName="name" fontSize="3" fontSizeUnit="MM" fontLetterSpacing="1">
<text-buffer bufferDraw="1" bufferSize="2" bufferSizeUnits="Point" bufferColor="255,238,0,255" bufferOpacity="0.5" bufferJoinStyle="64" bufferBlendMode="13"/></text-style>"#,
);
assert_eq!((c.halo_color, c.halo_width), (Some(Rgba::new(255, 238, 0, 128)), Some(2.0)));
assert!((c.spacing - 72.0 / 25.4).abs() < 1e-4, "{}", c.spacing);
assert!(report.notes.iter().any(|n| n.text.contains("drawn round")), "{:?}", report.notes);
assert!(report.notes.iter().any(|n| n.text.contains("blended")), "{:?}", report.notes);
// A buffer that says nothing: QGIS's own, 1 mm of white; one switched off: no halo at all.
let (c, _) = read(r#"<text-style fieldName="name"><text-buffer bufferDraw="1"/></text-style>"#);
assert_eq!(c.halo_color, Some(Rgba::rgb(255, 255, 255)));
assert!((c.halo_width.unwrap() - 72.0 / 25.4).abs() < 1e-4);
let (c, _) = read(r#"<text-style fieldName="name" fontLetterSpacing="junk"><text-buffer bufferDraw="0" bufferSize="3"/></text-style>"#);
assert_eq!((c.halo, c.halo_color, c.halo_width, c.spacing), (false, None, None, 0.0));
// Crafted sizes stay within RustGIS's ranges.
let (c, _) =
read(r#"<text-style fieldName="name" fontLetterSpacing="1e30"><text-buffer bufferDraw="1" bufferSize="1e30"/></text-style>"#);
assert_eq!((c.halo_width, c.spacing), (Some(10.0), 50.0));
}

#[test]
fn files_of_qgis_1_and_2_read_by_their_old_names() {
// Before QGIS 3: no layer tree but a legend, the canvas's coordinate system, scale limits named
Expand Down Expand Up @@ -408,7 +450,6 @@ fn what_rustgis_has_no_place_for_is_noted() {
"diagrams",
"order-by",
"draw effects",
"label halos",
"wrapped labels",
"label distances",
"label offsets",
Expand Down
Loading
Loading