From d199692bb9f8378b5af8af77ca2b0ce8024b7b39 Mon Sep 17 00:00:00 2001 From: Jose Gregorio Rincon Albarracin Date: Wed, 4 Nov 2026 21:11:11 -0500 Subject: [PATCH 1/5] feat(layout): a colour and a fill for the scale bar (#1451) --- crates/engine/src/cmd/layout.rs | 11 ++++---- crates/engine/src/tests.rs | 30 ++++++++++++++++---- crates/map/src/layout.rs | 20 ++++++++++++- crates/render/src/layout.rs | 48 +++++++++++++++++++++++++++----- crates/ui-egui/src/layoutview.rs | 13 ++++++++- docs/user-guide/layouts.md | 6 ++-- 6 files changed, 107 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index b53ea41c0..b7f01dbe3 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -88,10 +88,10 @@ pub fn commands() -> Vec { r#"{"layout"?, "shape"?: "rectangle"|"circle"|"ellipse"|"polygon"|"lasso", "points"?: [[u, v], …] (polygon/lasso, 0..1 across the frame), "map"?: name, "x"?, "y"?, "w"?, "h"?: mm}"#, ), C::new("layout.addElement", "Add Element", "Publish › Map Surrounds", add_element).params( - r##"{"layout"?, "kind": "northArrow"|"scaleBar"|"legend"|"colorbar"|"text"|"dynamicText"|"picture"|"table"|"chart"|"rectangle" (a neatline framing the page unless any of x, y, w, h is given)|"ellipse"|"circle"|"line"|"arrow"|"extentIndicator"|"grid", "frame"?: element id (default: the first map frame), "of"?: frame id (extentIndicator, which also takes "color" and "width" in points, default red and 2), "text"?, "size"?, "color"?: "#RRGGBB"|"#RRGGBBAA", "bold"?: bool (text), "italic"?: bool, "align"?: "left"|"center"|"right", "letterSpacing"?: points, -5 to 50 (text, dynamicText), "font"?: "sans"|"serif"|an installed font family's name, as labels.fonts lists them (text, dynamicText; a family not installed here is drawn in its fallback, and a serif where none is installed in the sans, with a warning; refused for other kinds), "fallback"?: "sans"|"serif" (a named font's, where it isn't installed; default its own class, else sans), "title"?: legend title (default "Legend"), "allClasses"?: bool (legend: every class, not only those the frame draws), "hideOther"?: bool (legend: leave out ), "exclude"?: [layer names] (legend: layers it leaves out though the map draws them), "orientation"?: "horizontal"|"vertical" (colorbar), "ticks"?: 2..21 (colorbar: labelled values, ends included), "tickSide"?: "below"|"above"|"right"|"left" (colorbar), "reverse"?: bool (colorbar: high values first), "fill"?: "#RRGGBBAA" (rectangle, ellipse, circle, northArrow), "width"?: outline width in points (rectangle, ellipse, circle; 0 for none) or line width in points (line, grid), "radius"?: mm, 0 to 50 (rectangle: rounded corners), "dir"?: "horizontal"|"vertical"|"falling"|"rising" (line), "head"?: "none"|"end"|"start"|"both" (line; default "end" for an arrow), "angle"?: degrees anticlockwise from pointing right, centred on the page unless x or y is given (line, arrow; it sets the box and the direction, so w, h and dir are not used with it), "length"?: mm (line, arrow), "image"?: base64, "path"?: picture file, "layer"?: (table, chart; colorbar: a raster layer, default the top visible one), "field"?, "label"?, "rows"?, "step"?: degrees, or a measured grid's units (metres) (0 picks one), "type"?: "graticule" (latitudes and longitudes, default) | "measured" (eastings and northings in a projected system, as ArcGIS Pro's measured grid), "crs"?: a measured grid's coordinate system: EPSG:25832, WKT or a PROJ string ("" or none: the map's when projected, else the UTM zone at the frame's centre), "short"?: bool (a measured grid's labels as their kilometres' last two digits, 43 for 543000), "labels"?: bool, "style"?: "lines"|"ticks"|"linesAndTicks" (grid) or "classic"|"compass"|"triangle"|"star" (northArrow) or "alternating"|"stepped"|"line" (scaleBar), "tickLength"?: mm, "format"?: "decimal"|"dms", "hemisphere"?: bool (N/S/E/W rather than a sign), "edges"?: ["top","bottom","left","right"] (labelled edges), "labelOrientation"?: "horizontal"|"vertical", "off"?: bool (grid; options not given keep the grid's), "outline"?: "#RRGGBB" (northArrow), "units"?: "auto"|"kilometers"|"meters"|"miles"|"feet", "divisions"?: 1..10 (scaleBar), "x"?, "y"?, "w"?, "h"?: mm}"##, + r##"{"layout"?, "kind": "northArrow"|"scaleBar"|"legend"|"colorbar"|"text"|"dynamicText"|"picture"|"table"|"chart"|"rectangle" (a neatline framing the page unless any of x, y, w, h is given)|"ellipse"|"circle"|"line"|"arrow"|"extentIndicator"|"grid", "frame"?: element id (default: the first map frame), "of"?: frame id (extentIndicator, which also takes "color" and "width" in points, default red and 2), "text"?, "size"?, "color"?: "#RRGGBB"|"#RRGGBBAA", "bold"?: bool (text), "italic"?: bool, "align"?: "left"|"center"|"right", "letterSpacing"?: points, -5 to 50 (text, dynamicText), "font"?: "sans"|"serif"|an installed font family's name, as labels.fonts lists them (text, dynamicText; a family not installed here is drawn in its fallback, and a serif where none is installed in the sans, with a warning; refused for other kinds), "fallback"?: "sans"|"serif" (a named font's, where it isn't installed; default its own class, else sans), "title"?: legend title (default "Legend"), "allClasses"?: bool (legend: every class, not only those the frame draws), "hideOther"?: bool (legend: leave out ), "exclude"?: [layer names] (legend: layers it leaves out though the map draws them), "orientation"?: "horizontal"|"vertical" (colorbar), "ticks"?: 2..21 (colorbar: labelled values, ends included), "tickSide"?: "below"|"above"|"right"|"left" (colorbar), "reverse"?: bool (colorbar: high values first), "fill"?: "#RRGGBBAA" (rectangle, ellipse, circle, northArrow; scaleBar: the alternating bar's light divisions, default white), "width"?: outline width in points (rectangle, ellipse, circle; 0 for none) or line width in points (line, grid), "radius"?: mm, 0 to 50 (rectangle: rounded corners), "dir"?: "horizontal"|"vertical"|"falling"|"rising" (line), "head"?: "none"|"end"|"start"|"both" (line; default "end" for an arrow), "angle"?: degrees anticlockwise from pointing right, centred on the page unless x or y is given (line, arrow; it sets the box and the direction, so w, h and dir are not used with it), "length"?: mm (line, arrow), "image"?: base64, "path"?: picture file, "layer"?: (table, chart; colorbar: a raster layer, default the top visible one), "field"?, "label"?, "rows"?, "step"?: degrees, or a measured grid's units (metres) (0 picks one), "type"?: "graticule" (latitudes and longitudes, default) | "measured" (eastings and northings in a projected system, as ArcGIS Pro's measured grid), "crs"?: a measured grid's coordinate system: EPSG:25832, WKT or a PROJ string ("" or none: the map's when projected, else the UTM zone at the frame's centre), "short"?: bool (a measured grid's labels as their kilometres' last two digits, 43 for 543000), "labels"?: bool, "style"?: "lines"|"ticks"|"linesAndTicks" (grid) or "classic"|"compass"|"triangle"|"star" (northArrow) or "alternating"|"stepped"|"line" (scaleBar), "tickLength"?: mm, "format"?: "decimal"|"dms", "hemisphere"?: bool (N/S/E/W rather than a sign), "edges"?: ["top","bottom","left","right"] (labelled edges), "labelOrientation"?: "horizontal"|"vertical", "off"?: bool (grid; options not given keep the grid's), "outline"?: "#RRGGBB" (northArrow), "units"?: "auto"|"kilometers"|"meters"|"miles"|"feet", "divisions"?: 1..10 (scaleBar, whose "color" is its lines, dark divisions and labels, default #222222), "x"?, "y"?, "w"?, "h"?: mm}"##, ), C::new("layout.update", "Element Properties", "Publish › Element", update).params( - r##"{"layout"?, "element": id, "x"?, "y"?, "w"?, "h"?: mm, "name"?, "visible"?, "locked"?: bool (a locked element takes no other change until unlocked; "locked": false unlocks it and may come with changes), "text"?, "size"?, "color"? (text, line), "bold"?, "italic"?, "align"?, "letterSpacing"?, "font"?: "sans"|"serif"|an installed font family's name, "fallback"?: "sans"|"serif" (text, as in layout.addElement; refused for other elements), "title"?, "allClasses"?: bool (legend), "hideOther"?: bool (legend), "exclude"?: [layer names] (legend; [] lists every layer again), "orientation"?, "ticks"?, "tickSide"?, "reverse"?: (colorbar), "style"?: (northArrow, scaleBar), "fill"?, "outline"? (northArrow, rectangle, ellipse; for a rectangle or ellipse, color sets it when outline is not given), "radius"?: mm (rectangle: rounded corners), "width"?: points (rectangle, ellipse, line), "dir"?, "head"?, "angle"?, "length"? (line: turned or sized about its centre, then moved by any x or y; dir does not come with angle or length), "units"?, "divisions"? (scaleBar), "map"?, "scale"?, "center"?: [lon, lat], "bearing"?: degrees clockwise from north, "pitch"?: degrees of tilt (0 to 85), "grid"?: {grid options as in layout.addElement} | false (map frame), "extentIndicator"?: {"color"?, "width"?: points} | false (map frame: its extent indicator's style, or removes it), "layer"?: (colorbar: "" for the top visible raster), "field"?, "rows"?}"##, + r##"{"layout"?, "element": id, "x"?, "y"?, "w"?, "h"?: mm, "name"?, "visible"?, "locked"?: bool (a locked element takes no other change until unlocked; "locked": false unlocks it and may come with changes), "text"?, "size"?, "color"? (text, line, scaleBar), "bold"?, "italic"?, "align"?, "letterSpacing"?, "font"?: "sans"|"serif"|an installed font family's name, "fallback"?: "sans"|"serif" (text, as in layout.addElement; refused for other elements), "title"?, "allClasses"?: bool (legend), "hideOther"?: bool (legend), "exclude"?: [layer names] (legend; [] lists every layer again), "orientation"?, "ticks"?, "tickSide"?, "reverse"?: (colorbar), "style"?: (northArrow, scaleBar), "fill"?, "outline"? (northArrow, rectangle, ellipse; for a rectangle or ellipse, color sets it when outline is not given), "radius"?: mm (rectangle: rounded corners), "width"?: points (rectangle, ellipse, line), "dir"?, "head"?, "angle"?, "length"? (line: turned or sized about its centre, then moved by any x or y; dir does not come with angle or length), "units"?, "divisions"?, "color"?, "fill"? (scaleBar: lines, dark divisions and labels; the alternating bar's light divisions), "map"?, "scale"?, "center"?: [lon, lat], "bearing"?: degrees clockwise from north, "pitch"?: degrees of tilt (0 to 85), "grid"?: {grid options as in layout.addElement} | false (map frame), "extentIndicator"?: {"color"?, "width"?: points} | false (map frame: its extent indicator's style, or removes it), "layer"?: (colorbar: "" for the top visible raster), "field"?, "rows"?}"##, ), C::new("layout.guides", "Guides", "Publish › Page Setup", guides).params( r#"{"layout"?, "x"?: [mm] (vertical guides, from the page's left edge), "y"?: [mm] (horizontal guides, from its top edge), "clear"?: bool (remove every guide first)}"#, @@ -2779,8 +2779,8 @@ fn restyle(kind: &ElementKind, p: &Value) -> Result, String> outline: color(p, "outline", *outline)?, })) } - ElementKind::ScaleBar { frame, units, divisions, style } => { - if ["style", "units", "divisions"].iter().all(|k| p.get(k).is_none()) { + ElementKind::ScaleBar { frame, units, divisions, style, color: c, fill } => { + if ["style", "units", "divisions", "color", "fill"].iter().all(|k| p.get(k).is_none()) { return Ok(None); } let style = match ps(p, "style") { @@ -2799,7 +2799,8 @@ fn restyle(kind: &ElementKind, p: &Value) -> Result, String> Some(_) => f(p, "divisions").ok_or("divisions is a number")?.round().clamp(1.0, MAX_SCALE_DIVISIONS as f64) as usize, None => *divisions, }; - Ok(Some(ElementKind::ScaleBar { frame: *frame, units, divisions, style })) + let (color, fill) = (color(p, "color", *c)?, color(p, "fill", *fill)?); + Ok(Some(ElementKind::ScaleBar { frame: *frame, units, divisions, style, color, fill })) } ElementKind::Rectangle { fill, outline, width, .. } | ElementKind::Ellipse { fill, outline, width } => { if ["fill", "outline", "color", "width", "radius"].iter().all(|k| p.get(k).is_none()) { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e26fc2ff9..24514693d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5296,17 +5296,37 @@ fn layout_surrounds_take_cartographic_options() { )); s.run("layout.update", &json!({"element": bar, "divisions": "2"})).unwrap(); assert!(matches!(find(&s, "scaleBar").kind, ElementKind::ScaleBar { units: ScaleUnits::Miles, divisions: 2, .. })); - for bad in [json!({"units": "parsecs"}), json!({"divisions": "many"}), json!({"style": "zigzag"})] { + // Its colours: the lines and labels, and the alternating bar's light divisions; one given + // leaves the other as it was. + assert!(matches!( + find(&s, "scaleBar").kind, + ElementKind::ScaleBar { color, fill, .. } if color == Rgba::rgb(0x22, 0x22, 0x22) && fill == Rgba::rgb(0xFF, 0xFF, 0xFF) + )); + s.run("layout.update", &json!({"element": bar, "color": "#1F4E79"})).unwrap(); + s.run("layout.update", &json!({"element": bar, "fill": "#FDE68A"})).unwrap(); + assert!(matches!( + find(&s, "scaleBar").kind, + ElementKind::ScaleBar { color, fill, divisions: 2, .. } + if color == Rgba::rgb(0x1F, 0x4E, 0x79) && fill == Rgba::rgb(0xFD, 0xE6, 0x8A) + )); + let bad = [ + json!({"units": "parsecs"}), + json!({"divisions": "many"}), + json!({"style": "zigzag"}), + json!({"color": "red"}), + json!({"fill": "#12"}), + ]; + for bad in bad { let mut p = bad.clone(); p["element"] = json!(bar); assert!(s.run("layout.update", &p).is_err(), "{bad}"); } - let added = s.run("layout.addElement", &json!({"kind": "scaleBar", "units": "feet", "style": "line"})).unwrap()["element"] - .as_u64() - .unwrap() as u32; + let added = s.run("layout.addElement", &json!({"kind": "scaleBar", "units": "feet", "style": "line", "color": "#B91C1C"})).unwrap(); + let added = added["element"].as_u64().unwrap() as u32; assert!(matches!( s.project.layouts[0].element(added).unwrap().kind, - ElementKind::ScaleBar { units: ScaleUnits::Feet, divisions: 4, style: ScaleBarStyle::Line, .. } + ElementKind::ScaleBar { units: ScaleUnits::Feet, divisions: 4, style: ScaleBarStyle::Line, color, fill, .. } + if color == Rgba::rgb(0xB9, 0x1C, 0x1C) && fill == Rgba::rgb(0xFF, 0xFF, 0xFF) )); // The page draws with them all, and they survive a save. let out = std::env::temp_dir().join(format!("rustgis-surrounds-{}.png", std::process::id())); diff --git a/crates/map/src/layout.rs b/crates/map/src/layout.rs index edd0f7c0c..cf2add990 100644 --- a/crates/map/src/layout.rs +++ b/crates/map/src/layout.rs @@ -797,6 +797,10 @@ fn ink() -> Rgba { Rgba::rgb(0x22, 0x22, 0x22) } +fn white() -> Rgba { + Rgba::rgb(0xFF, 0xFF, 0xFF) +} + fn yes() -> bool { true } @@ -885,6 +889,12 @@ pub enum ElementKind { divisions: usize, #[serde(default)] style: ScaleBarStyle, + /// The lines, the alternating bar's dark divisions and the labels. + #[serde(default = "ink")] + color: Rgba, + /// The alternating bar's light divisions. + #[serde(default = "white")] + fill: Rgba, }, Legend { frame: u32, @@ -1023,7 +1033,14 @@ impl ElementKind { /// A scale bar for `frame`, in the default style. pub fn scale_bar(frame: u32) -> ElementKind { - ElementKind::ScaleBar { frame, units: ScaleUnits::default(), divisions: divisions(), style: ScaleBarStyle::default() } + ElementKind::ScaleBar { + frame, + units: ScaleUnits::default(), + divisions: divisions(), + style: ScaleBarStyle::default(), + color: ink(), + fill: white(), + } } pub fn code(&self) -> &'static str { @@ -1798,6 +1815,7 @@ mod tests { let bar: ElementKind = serde_json::from_str(r#"{"ScaleBar":{"frame":3}}"#).unwrap(); assert_eq!(bar, ElementKind::scale_bar(3)); assert!(matches!(bar, ElementKind::ScaleBar { units: ScaleUnits::Auto, divisions: 4, style: ScaleBarStyle::Alternating, .. })); + assert!(matches!(bar, ElementKind::ScaleBar { color, fill, .. } if color == ink() && fill == white())); let grid: Grid = serde_json::from_str(r#"{"step":10,"color":[64,72,85,150],"labels":true}"#).unwrap(); assert_eq!(grid, Grid { step: 10.0, ..Grid::default() }); assert_eq!(grid.edges, GridEdges { top: false, bottom: true, left: true, right: false }); diff --git a/crates/render/src/layout.rs b/crates/render/src/layout.rs index 6a62af58a..bb7b75e39 100644 --- a/crates/render/src/layout.rs +++ b/crates/render/src/layout.rs @@ -264,11 +264,12 @@ fn draw_page( let bearing = frame(*f).map_or(0.0, |fr| fr.2.bearing); north_arrow(&mut pm, r, *style, *fill, *outline, ppp, bearing) } - ElementKind::ScaleBar { frame: f, units, divisions, style } => { + ElementKind::ScaleBar { frame: f, units, divisions, style, color, fill } => { if let Some(&(_, mi, cam, _, _)) = frame(*f) && let Some(map) = maps.get(mi) { - scale_bar(&mut pm, r, map.ground_res_at(&cam), (*units, *divisions, *style), ppp, layout.decimal_comma); + let bar = (*units, *divisions, *style); + scale_bar(&mut pm, r, map.ground_res_at(&cam), bar, (*color, *fill), ppp, layout.decimal_comma); } } ElementKind::Legend { frame: f, title, all_classes, hide_other, exclude } => { @@ -1057,8 +1058,18 @@ fn scale_number(v: f64, step: f64) -> String { /// A scale bar filling most of the element's width, in `units`, with `divisions` labelled steps. /// `ground_per_pt` is ground metres per logical point of paper. -fn scale_bar(pm: &mut Canvas, r: Rect, ground_per_pt: f64, bar: (ScaleUnits, usize, ScaleBarStyle), ppp: f64, comma: bool) { +/// A scale bar: `colors` are its lines, dark divisions and labels, then its light divisions. +fn scale_bar( + pm: &mut Canvas, + r: Rect, + ground_per_pt: f64, + bar: (ScaleUnits, usize, ScaleBarStyle), + colors: (Rgba, Rgba), + ppp: f64, + comma: bool, +) { let (units, divisions, style) = bar; + let (dark, light) = colors; if !(ground_per_pt.is_finite() && ground_per_pt > 0.0) { return; } @@ -1072,7 +1083,6 @@ fn scale_bar(pm: &mut Canvas, r: Rect, ground_per_pt: f64, bar: (ScaleUnits, usi let len = (total * unit_m / ground_per_pt * ppp) as f32; let bar_h = (r.height() * 0.28).min(6.0 * ppp as f32); let (x0, y0) = (r.x() + 2.0, r.y() + r.height() * 0.62 - bar_h / 2.0); - let dark = Rgba::rgb(0x22, 0x22, 0x22); let seg = len / divisions as f32; let line = Stroke { width: 0.6 * ppp as f32, ..Stroke::default() }; let stroke = |pm: &mut Canvas, pts: &[(f32, f32)], w: &Stroke| { @@ -1087,7 +1097,7 @@ fn scale_bar(pm: &mut Canvas, r: Rect, ground_per_pt: f64, bar: (ScaleUnits, usi match style { ScaleBarStyle::Alternating => { for i in 0..divisions { - let c = if i % 2 == 0 { dark } else { Rgba::rgb(0xFF, 0xFF, 0xFF) }; + let c = if i % 2 == 0 { dark } else { light }; if let Some(b) = Rect::from_xywh(x0 + seg * i as f32, y0, seg, bar_h) { pm.fill_rect(b, &paint(c, 1.0), Transform::identity(), None); } @@ -2953,13 +2963,15 @@ mod tests { #[test] fn scale_bars_count_in_their_units_and_divisions() { - let draw = |bar| { + let draw_in = |bar, colors| { let mut pm = Pixmap::new(300, 60).unwrap(); pm.fill(tiny_skia::Color::WHITE); // 100 m of ground per point: about 25 km across the bar. - crate::canvas::with_canvas(&mut pm, |c| scale_bar(c, Rect::from_xywh(0.0, 0.0, 300.0, 60.0).unwrap(), 100.0, bar, 1.0, false)); + let r = Rect::from_xywh(0.0, 0.0, 300.0, 60.0).unwrap(); + crate::canvas::with_canvas(&mut pm, |c| scale_bar(c, r, 100.0, bar, colors, 1.0, false)); pm }; + let draw = |bar| draw_in(bar, (Rgba::rgb(0x22, 0x22, 0x22), Rgba::rgb(0xFF, 0xFF, 0xFF))); // Dark runs along the bar's middle row: one per dark division of an alternating bar. let runs = |pm: &Pixmap| { let y = (60.0 * 0.62) as u32; @@ -2981,6 +2993,28 @@ mod tests { } } + #[test] + fn scale_bars_draw_in_their_colours() { + let mut pm = Pixmap::new(300, 60).unwrap(); + pm.fill(tiny_skia::Color::WHITE); + type Px = tiny_skia::PremultipliedColorU8; + let (red, blue) = (Rgba::rgb(0xD0, 0x10, 0x10), Rgba::rgb(0x10, 0x10, 0xD0)); + let bar = (ScaleUnits::Auto, 4, ScaleBarStyle::Alternating); + let r = Rect::from_xywh(0.0, 0.0, 300.0, 60.0).unwrap(); + crate::canvas::with_canvas(&mut pm, |c| scale_bar(c, r, 100.0, bar, (red, blue), 1.0, false)); + let count = |hit: fn(Px) -> bool, y: u32| (0..300).filter(|&x| pm.pixel(x, y).is_some_and(hit)).count(); + let y = (60.0 * 0.62) as u32; + // Along the bar: the dark divisions in the colour, the light ones in the fill, nothing in + // the old ink. + assert!(count(|p| p.red() > 180 && p.green() < 90 && p.blue() < 90, y) > 40, "red divisions"); + assert!(count(|p| p.blue() > 180 && p.red() < 90 && p.green() < 90, y) > 40, "blue divisions"); + assert_eq!(count(ink, y), 0, "no default ink left"); + // The labels above the bar are in the colour too. + let labels = (0..y.saturating_sub(8)).map(|row| count(|p| p.red() > 120 && p.green() < 90 && p.blue() < 90, row)).sum::(); + assert!(labels > 0, "red labels"); + assert_eq!((0..y.saturating_sub(8)).map(|row| count(ink, row)).sum::(), 0, "no ink labels"); + } + #[test] fn scale_numbers_are_short() { assert_eq!(scale_number(5.0, 1.25), "5"); diff --git a/crates/ui-egui/src/layoutview.rs b/crates/ui-egui/src/layoutview.rs index a5a7d3cb4..ab4d30085 100644 --- a/crates/ui-egui/src/layoutview.rs +++ b/crates/ui-egui/src/layoutview.rs @@ -1692,7 +1692,7 @@ fn properties(app: &mut RustGisApp, ui: &mut Ui, layout: &Layout, li: usize) { ui.end_row(); } } - ElementKind::ScaleBar { units, divisions, style, .. } => { + ElementKind::ScaleBar { units, divisions, style, color, fill, .. } => { let input_label = ui.label(tl!("Style")); egui::ComboBox::from_id_salt(("scale_style", id)) .selected_text(tl!(style.label())) @@ -1729,6 +1729,17 @@ fn properties(app: &mut RustGisApp, ui: &mut Ui, layout: &Layout, li: usize) { update(app, json!({"divisions": n})); } ui.end_row(); + // The fill is the alternating bar's light divisions; the other styles draw none. + for (label, key, current) in [("Color", "color", *color), ("Fill", "fill", *fill)] { + ui.label(tl!(label)); + let mut c = crate::theme::c32(current); + let resp = crate::widgets::color_button(ui, &mut c); + if resp.changed() { + crate::widgets::one_step_per_press(app, &resp); + update(app, json!({key: crate::theme::rgba(c).hex()})); + } + ui.end_row(); + } } ElementKind::Table { rows, .. } => { let input_label = ui.label(tl!("Rows")); diff --git a/docs/user-guide/layouts.md b/docs/user-guide/layouts.md index 2b3d645a4..a639a7821 100644 --- a/docs/user-guide/layouts.md +++ b/docs/user-guide/layouts.md @@ -151,8 +151,10 @@ Layouts open as tabs next to the maps; while one is open, its groups lead the Pu properties pick its **Style** (classic arrow, compass rose, triangle or star) and its **Fill** and **Outline** colours (`"style":"compass","fill":"#B91C1C"`). A scale bar's pick its **Style** (alternating bar, stepped line or single line), its **Units** (metres or kilometres - to suit the scale, or kilometres, metres, miles or feet) and its **Divisions** (1 to 10), each - labelled where there is room (`"style":"line","units":"miles","divisions":2`). A legend's + to suit the scale, or kilometres, metres, miles or feet), its **Divisions** (1 to 10), each + labelled where there is room, its **Color** (the lines, the dark divisions and the labels) and + its **Fill** (the alternating bar's light divisions) + (`"style":"line","units":"miles","divisions":2,"color":"#1F4E79"`). A legend's parts: - **What a legend lists**: what its frame draws: only layers with a feature at the frame's scale (through their definition query, display filters and scale range) and, for Unique From cc244c58036bf1581d19176e92e9a42146b861c3 Mon Sep 17 00:00:00 2001 From: Jose Gregorio Rincon Albarracin Date: Wed, 4 Nov 2026 21:17:37 -0500 Subject: [PATCH 2/5] fix(layout): refuse a colour that isn't text; the scale bar's fill only for the alternating style --- crates/engine/src/cmd/layout.rs | 9 ++++++--- crates/engine/src/tests.rs | 4 ++++ crates/render/src/layout.rs | 6 +++--- crates/ui-egui/src/layoutview.rs | 6 ++++-- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index b7f01dbe3..e9bd93778 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -91,7 +91,7 @@ pub fn commands() -> Vec { r##"{"layout"?, "kind": "northArrow"|"scaleBar"|"legend"|"colorbar"|"text"|"dynamicText"|"picture"|"table"|"chart"|"rectangle" (a neatline framing the page unless any of x, y, w, h is given)|"ellipse"|"circle"|"line"|"arrow"|"extentIndicator"|"grid", "frame"?: element id (default: the first map frame), "of"?: frame id (extentIndicator, which also takes "color" and "width" in points, default red and 2), "text"?, "size"?, "color"?: "#RRGGBB"|"#RRGGBBAA", "bold"?: bool (text), "italic"?: bool, "align"?: "left"|"center"|"right", "letterSpacing"?: points, -5 to 50 (text, dynamicText), "font"?: "sans"|"serif"|an installed font family's name, as labels.fonts lists them (text, dynamicText; a family not installed here is drawn in its fallback, and a serif where none is installed in the sans, with a warning; refused for other kinds), "fallback"?: "sans"|"serif" (a named font's, where it isn't installed; default its own class, else sans), "title"?: legend title (default "Legend"), "allClasses"?: bool (legend: every class, not only those the frame draws), "hideOther"?: bool (legend: leave out ), "exclude"?: [layer names] (legend: layers it leaves out though the map draws them), "orientation"?: "horizontal"|"vertical" (colorbar), "ticks"?: 2..21 (colorbar: labelled values, ends included), "tickSide"?: "below"|"above"|"right"|"left" (colorbar), "reverse"?: bool (colorbar: high values first), "fill"?: "#RRGGBBAA" (rectangle, ellipse, circle, northArrow; scaleBar: the alternating bar's light divisions, default white), "width"?: outline width in points (rectangle, ellipse, circle; 0 for none) or line width in points (line, grid), "radius"?: mm, 0 to 50 (rectangle: rounded corners), "dir"?: "horizontal"|"vertical"|"falling"|"rising" (line), "head"?: "none"|"end"|"start"|"both" (line; default "end" for an arrow), "angle"?: degrees anticlockwise from pointing right, centred on the page unless x or y is given (line, arrow; it sets the box and the direction, so w, h and dir are not used with it), "length"?: mm (line, arrow), "image"?: base64, "path"?: picture file, "layer"?: (table, chart; colorbar: a raster layer, default the top visible one), "field"?, "label"?, "rows"?, "step"?: degrees, or a measured grid's units (metres) (0 picks one), "type"?: "graticule" (latitudes and longitudes, default) | "measured" (eastings and northings in a projected system, as ArcGIS Pro's measured grid), "crs"?: a measured grid's coordinate system: EPSG:25832, WKT or a PROJ string ("" or none: the map's when projected, else the UTM zone at the frame's centre), "short"?: bool (a measured grid's labels as their kilometres' last two digits, 43 for 543000), "labels"?: bool, "style"?: "lines"|"ticks"|"linesAndTicks" (grid) or "classic"|"compass"|"triangle"|"star" (northArrow) or "alternating"|"stepped"|"line" (scaleBar), "tickLength"?: mm, "format"?: "decimal"|"dms", "hemisphere"?: bool (N/S/E/W rather than a sign), "edges"?: ["top","bottom","left","right"] (labelled edges), "labelOrientation"?: "horizontal"|"vertical", "off"?: bool (grid; options not given keep the grid's), "outline"?: "#RRGGBB" (northArrow), "units"?: "auto"|"kilometers"|"meters"|"miles"|"feet", "divisions"?: 1..10 (scaleBar, whose "color" is its lines, dark divisions and labels, default #222222), "x"?, "y"?, "w"?, "h"?: mm}"##, ), C::new("layout.update", "Element Properties", "Publish › Element", update).params( - r##"{"layout"?, "element": id, "x"?, "y"?, "w"?, "h"?: mm, "name"?, "visible"?, "locked"?: bool (a locked element takes no other change until unlocked; "locked": false unlocks it and may come with changes), "text"?, "size"?, "color"? (text, line, scaleBar), "bold"?, "italic"?, "align"?, "letterSpacing"?, "font"?: "sans"|"serif"|an installed font family's name, "fallback"?: "sans"|"serif" (text, as in layout.addElement; refused for other elements), "title"?, "allClasses"?: bool (legend), "hideOther"?: bool (legend), "exclude"?: [layer names] (legend; [] lists every layer again), "orientation"?, "ticks"?, "tickSide"?, "reverse"?: (colorbar), "style"?: (northArrow, scaleBar), "fill"?, "outline"? (northArrow, rectangle, ellipse; for a rectangle or ellipse, color sets it when outline is not given), "radius"?: mm (rectangle: rounded corners), "width"?: points (rectangle, ellipse, line), "dir"?, "head"?, "angle"?, "length"? (line: turned or sized about its centre, then moved by any x or y; dir does not come with angle or length), "units"?, "divisions"?, "color"?, "fill"? (scaleBar: lines, dark divisions and labels; the alternating bar's light divisions), "map"?, "scale"?, "center"?: [lon, lat], "bearing"?: degrees clockwise from north, "pitch"?: degrees of tilt (0 to 85), "grid"?: {grid options as in layout.addElement} | false (map frame), "extentIndicator"?: {"color"?, "width"?: points} | false (map frame: its extent indicator's style, or removes it), "layer"?: (colorbar: "" for the top visible raster), "field"?, "rows"?}"##, + r##"{"layout"?, "element": id, "x"?, "y"?, "w"?, "h"?: mm, "name"?, "visible"?, "locked"?: bool (a locked element takes no other change until unlocked; "locked": false unlocks it and may come with changes), "text"?, "size"?, "color"? (text, line), "bold"?, "italic"?, "align"?, "letterSpacing"?, "font"?: "sans"|"serif"|an installed font family's name, "fallback"?: "sans"|"serif" (text, as in layout.addElement; refused for other elements), "title"?, "allClasses"?: bool (legend), "hideOther"?: bool (legend), "exclude"?: [layer names] (legend; [] lists every layer again), "orientation"?, "ticks"?, "tickSide"?, "reverse"?: (colorbar), "style"?: (northArrow, scaleBar), "fill"?, "outline"? (northArrow, rectangle, ellipse; for a rectangle or ellipse, color sets it when outline is not given), "radius"?: mm (rectangle: rounded corners), "width"?: points (rectangle, ellipse, line), "dir"?, "head"?, "angle"?, "length"? (line: turned or sized about its centre, then moved by any x or y; dir does not come with angle or length), "units"?, "divisions"?, "color"?, "fill"? (scaleBar: "color" its lines, dark divisions and labels, "fill" the alternating bar's light divisions), "map"?, "scale"?, "center"?: [lon, lat], "bearing"?: degrees clockwise from north, "pitch"?: degrees of tilt (0 to 85), "grid"?: {grid options as in layout.addElement} | false (map frame), "extentIndicator"?: {"color"?, "width"?: points} | false (map frame: its extent indicator's style, or removes it), "layer"?: (colorbar: "" for the top visible raster), "field"?, "rows"?}"##, ), C::new("layout.guides", "Guides", "Publish › Page Setup", guides).params( r#"{"layout"?, "x"?: [mm] (vertical guides, from the page's left edge), "y"?: [mm] (horizontal guides, from its top edge), "clear"?: bool (remove every guide first)}"#, @@ -2207,9 +2207,12 @@ fn corner_radius(p: &Value, d: f64) -> Result { } } +/// The colour `p` gives as `k`, or `d` when it gives none (absent or null). Any other value is +/// refused rather than ignored. pub(crate) fn color(p: &Value, k: &str, d: Rgba) -> Result { - match ps(p, k) { - Some(c) => Rgba::from_hex(c).ok_or_else(|| format!("{k} must be #RRGGBB or #RRGGBBAA")), + let bad = || format!("{k} must be #RRGGBB or #RRGGBBAA"); + match p.get(k).filter(|v| !v.is_null()) { + Some(v) => v.as_str().and_then(Rgba::from_hex).ok_or_else(bad), None => Ok(d), } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 24514693d..e1a2654d3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5303,6 +5303,8 @@ fn layout_surrounds_take_cartographic_options() { ElementKind::ScaleBar { color, fill, .. } if color == Rgba::rgb(0x22, 0x22, 0x22) && fill == Rgba::rgb(0xFF, 0xFF, 0xFF) )); s.run("layout.update", &json!({"element": bar, "color": "#1F4E79"})).unwrap(); + s.run("layout.update", &json!({"element": bar, "fill": "#00000080"})).unwrap(); + assert!(matches!(find(&s, "scaleBar").kind, ElementKind::ScaleBar { fill, .. } if fill == Rgba::new(0, 0, 0, 0x80))); s.run("layout.update", &json!({"element": bar, "fill": "#FDE68A"})).unwrap(); assert!(matches!( find(&s, "scaleBar").kind, @@ -5315,6 +5317,8 @@ fn layout_surrounds_take_cartographic_options() { json!({"style": "zigzag"}), json!({"color": "red"}), json!({"fill": "#12"}), + json!({"fill": 5}), + json!({"color": [255, 0, 0]}), ]; for bad in bad { let mut p = bad.clone(); diff --git a/crates/render/src/layout.rs b/crates/render/src/layout.rs index bb7b75e39..c27e3b3ff 100644 --- a/crates/render/src/layout.rs +++ b/crates/render/src/layout.rs @@ -1057,8 +1057,8 @@ fn scale_number(v: f64, step: f64) -> String { } /// A scale bar filling most of the element's width, in `units`, with `divisions` labelled steps. -/// `ground_per_pt` is ground metres per logical point of paper. -/// A scale bar: `colors` are its lines, dark divisions and labels, then its light divisions. +/// `ground_per_pt` is ground metres per logical point of paper; `colors` are its lines, dark +/// divisions and labels, then the alternating bar's light divisions. fn scale_bar( pm: &mut Canvas, r: Rect, @@ -3006,7 +3006,7 @@ mod tests { let y = (60.0 * 0.62) as u32; // Along the bar: the dark divisions in the colour, the light ones in the fill, nothing in // the old ink. - assert!(count(|p| p.red() > 180 && p.green() < 90 && p.blue() < 90, y) > 40, "red divisions"); + assert!(count(red, y) > 40, "red divisions"); assert!(count(|p| p.blue() > 180 && p.red() < 90 && p.green() < 90, y) > 40, "blue divisions"); assert_eq!(count(ink, y), 0, "no default ink left"); // The labels above the bar are in the colour too. diff --git a/crates/ui-egui/src/layoutview.rs b/crates/ui-egui/src/layoutview.rs index ab4d30085..7dedac736 100644 --- a/crates/ui-egui/src/layoutview.rs +++ b/crates/ui-egui/src/layoutview.rs @@ -1729,8 +1729,10 @@ fn properties(app: &mut RustGisApp, ui: &mut Ui, layout: &Layout, li: usize) { update(app, json!({"divisions": n})); } ui.end_row(); - // The fill is the alternating bar's light divisions; the other styles draw none. - for (label, key, current) in [("Color", "color", *color), ("Fill", "fill", *fill)] { + // The fill is the alternating bar's light divisions: the other styles draw none. + let colors = [("Color", "color", *color), ("Fill", "fill", *fill)]; + let shown = if *style == ScaleBarStyle::Alternating { 2 } else { 1 }; + for (label, key, current) in colors.into_iter().take(shown) { ui.label(tl!(label)); let mut c = crate::theme::c32(current); let resp = crate::widgets::color_button(ui, &mut c); From 125cc0cbbe24178ce54c2466aaef207202634a20 Mon Sep 17 00:00:00 2001 From: Jose Gregorio Rincon Albarracin Date: Wed, 4 Nov 2026 21:23:06 -0500 Subject: [PATCH 3/5] test(render): the scale bar colour test no longer hides the red helper --- crates/render/src/layout.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/render/src/layout.rs b/crates/render/src/layout.rs index c27e3b3ff..6bd810a24 100644 --- a/crates/render/src/layout.rs +++ b/crates/render/src/layout.rs @@ -2998,10 +2998,11 @@ mod tests { let mut pm = Pixmap::new(300, 60).unwrap(); pm.fill(tiny_skia::Color::WHITE); type Px = tiny_skia::PremultipliedColorU8; - let (red, blue) = (Rgba::rgb(0xD0, 0x10, 0x10), Rgba::rgb(0x10, 0x10, 0xD0)); + // A red colour and a blue fill. + let colors = (Rgba::rgb(0xD0, 0x10, 0x10), Rgba::rgb(0x10, 0x10, 0xD0)); let bar = (ScaleUnits::Auto, 4, ScaleBarStyle::Alternating); let r = Rect::from_xywh(0.0, 0.0, 300.0, 60.0).unwrap(); - crate::canvas::with_canvas(&mut pm, |c| scale_bar(c, r, 100.0, bar, (red, blue), 1.0, false)); + crate::canvas::with_canvas(&mut pm, |c| scale_bar(c, r, 100.0, bar, colors, 1.0, false)); let count = |hit: fn(Px) -> bool, y: u32| (0..300).filter(|&x| pm.pixel(x, y).is_some_and(hit)).count(); let y = (60.0 * 0.62) as u32; // Along the bar: the dark divisions in the colour, the light ones in the fill, nothing in From 7c730bd1bcb987806e53ec364f90c54297879bc8 Mon Sep 17 00:00:00 2001 From: Jose Gregorio Rincon Albarracin Date: Wed, 4 Nov 2026 21:32:32 -0500 Subject: [PATCH 4/5] docs(layout): name the scale bar's color and fill once in layout.update --- crates/engine/src/cmd/layout.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index e9bd93778..e13783da9 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -91,7 +91,7 @@ pub fn commands() -> Vec { r##"{"layout"?, "kind": "northArrow"|"scaleBar"|"legend"|"colorbar"|"text"|"dynamicText"|"picture"|"table"|"chart"|"rectangle" (a neatline framing the page unless any of x, y, w, h is given)|"ellipse"|"circle"|"line"|"arrow"|"extentIndicator"|"grid", "frame"?: element id (default: the first map frame), "of"?: frame id (extentIndicator, which also takes "color" and "width" in points, default red and 2), "text"?, "size"?, "color"?: "#RRGGBB"|"#RRGGBBAA", "bold"?: bool (text), "italic"?: bool, "align"?: "left"|"center"|"right", "letterSpacing"?: points, -5 to 50 (text, dynamicText), "font"?: "sans"|"serif"|an installed font family's name, as labels.fonts lists them (text, dynamicText; a family not installed here is drawn in its fallback, and a serif where none is installed in the sans, with a warning; refused for other kinds), "fallback"?: "sans"|"serif" (a named font's, where it isn't installed; default its own class, else sans), "title"?: legend title (default "Legend"), "allClasses"?: bool (legend: every class, not only those the frame draws), "hideOther"?: bool (legend: leave out ), "exclude"?: [layer names] (legend: layers it leaves out though the map draws them), "orientation"?: "horizontal"|"vertical" (colorbar), "ticks"?: 2..21 (colorbar: labelled values, ends included), "tickSide"?: "below"|"above"|"right"|"left" (colorbar), "reverse"?: bool (colorbar: high values first), "fill"?: "#RRGGBBAA" (rectangle, ellipse, circle, northArrow; scaleBar: the alternating bar's light divisions, default white), "width"?: outline width in points (rectangle, ellipse, circle; 0 for none) or line width in points (line, grid), "radius"?: mm, 0 to 50 (rectangle: rounded corners), "dir"?: "horizontal"|"vertical"|"falling"|"rising" (line), "head"?: "none"|"end"|"start"|"both" (line; default "end" for an arrow), "angle"?: degrees anticlockwise from pointing right, centred on the page unless x or y is given (line, arrow; it sets the box and the direction, so w, h and dir are not used with it), "length"?: mm (line, arrow), "image"?: base64, "path"?: picture file, "layer"?: (table, chart; colorbar: a raster layer, default the top visible one), "field"?, "label"?, "rows"?, "step"?: degrees, or a measured grid's units (metres) (0 picks one), "type"?: "graticule" (latitudes and longitudes, default) | "measured" (eastings and northings in a projected system, as ArcGIS Pro's measured grid), "crs"?: a measured grid's coordinate system: EPSG:25832, WKT or a PROJ string ("" or none: the map's when projected, else the UTM zone at the frame's centre), "short"?: bool (a measured grid's labels as their kilometres' last two digits, 43 for 543000), "labels"?: bool, "style"?: "lines"|"ticks"|"linesAndTicks" (grid) or "classic"|"compass"|"triangle"|"star" (northArrow) or "alternating"|"stepped"|"line" (scaleBar), "tickLength"?: mm, "format"?: "decimal"|"dms", "hemisphere"?: bool (N/S/E/W rather than a sign), "edges"?: ["top","bottom","left","right"] (labelled edges), "labelOrientation"?: "horizontal"|"vertical", "off"?: bool (grid; options not given keep the grid's), "outline"?: "#RRGGBB" (northArrow), "units"?: "auto"|"kilometers"|"meters"|"miles"|"feet", "divisions"?: 1..10 (scaleBar, whose "color" is its lines, dark divisions and labels, default #222222), "x"?, "y"?, "w"?, "h"?: mm}"##, ), C::new("layout.update", "Element Properties", "Publish › Element", update).params( - r##"{"layout"?, "element": id, "x"?, "y"?, "w"?, "h"?: mm, "name"?, "visible"?, "locked"?: bool (a locked element takes no other change until unlocked; "locked": false unlocks it and may come with changes), "text"?, "size"?, "color"? (text, line), "bold"?, "italic"?, "align"?, "letterSpacing"?, "font"?: "sans"|"serif"|an installed font family's name, "fallback"?: "sans"|"serif" (text, as in layout.addElement; refused for other elements), "title"?, "allClasses"?: bool (legend), "hideOther"?: bool (legend), "exclude"?: [layer names] (legend; [] lists every layer again), "orientation"?, "ticks"?, "tickSide"?, "reverse"?: (colorbar), "style"?: (northArrow, scaleBar), "fill"?, "outline"? (northArrow, rectangle, ellipse; for a rectangle or ellipse, color sets it when outline is not given), "radius"?: mm (rectangle: rounded corners), "width"?: points (rectangle, ellipse, line), "dir"?, "head"?, "angle"?, "length"? (line: turned or sized about its centre, then moved by any x or y; dir does not come with angle or length), "units"?, "divisions"?, "color"?, "fill"? (scaleBar: "color" its lines, dark divisions and labels, "fill" the alternating bar's light divisions), "map"?, "scale"?, "center"?: [lon, lat], "bearing"?: degrees clockwise from north, "pitch"?: degrees of tilt (0 to 85), "grid"?: {grid options as in layout.addElement} | false (map frame), "extentIndicator"?: {"color"?, "width"?: points} | false (map frame: its extent indicator's style, or removes it), "layer"?: (colorbar: "" for the top visible raster), "field"?, "rows"?}"##, + r##"{"layout"?, "element": id, "x"?, "y"?, "w"?, "h"?: mm, "name"?, "visible"?, "locked"?: bool (a locked element takes no other change until unlocked; "locked": false unlocks it and may come with changes), "text"?, "size"?, "color"? (text, line; scaleBar: its lines, dark divisions and labels), "bold"?, "italic"?, "align"?, "letterSpacing"?, "font"?: "sans"|"serif"|an installed font family's name, "fallback"?: "sans"|"serif" (text, as in layout.addElement; refused for other elements), "title"?, "allClasses"?: bool (legend), "hideOther"?: bool (legend), "exclude"?: [layer names] (legend; [] lists every layer again), "orientation"?, "ticks"?, "tickSide"?, "reverse"?: (colorbar), "style"?: (northArrow, scaleBar), "fill"?, "outline"? (northArrow, rectangle, ellipse; for a rectangle or ellipse, color sets it when outline is not given; a scaleBar's fill is the alternating bar's light divisions), "radius"?: mm (rectangle: rounded corners), "width"?: points (rectangle, ellipse, line), "dir"?, "head"?, "angle"?, "length"? (line: turned or sized about its centre, then moved by any x or y; dir does not come with angle or length), "units"?, "divisions"? (scaleBar), "map"?, "scale"?, "center"?: [lon, lat], "bearing"?: degrees clockwise from north, "pitch"?: degrees of tilt (0 to 85), "grid"?: {grid options as in layout.addElement} | false (map frame), "extentIndicator"?: {"color"?, "width"?: points} | false (map frame: its extent indicator's style, or removes it), "layer"?: (colorbar: "" for the top visible raster), "field"?, "rows"?}"##, ), C::new("layout.guides", "Guides", "Publish › Page Setup", guides).params( r#"{"layout"?, "x"?: [mm] (vertical guides, from the page's left edge), "y"?: [mm] (horizontal guides, from its top edge), "clear"?: bool (remove every guide first)}"#, From bee48e84931858b608d4e1fc88f9eb991a46364a Mon Sep 17 00:00:00 2001 From: Jose Gregorio Rincon Albarracin Date: Wed, 4 Nov 2026 22:22:21 -0500 Subject: [PATCH 5/5] fix(layout): a text's colour update refuses what isn't text; test the page's scale bar colours --- crates/engine/src/cmd/layout.rs | 5 ++-- crates/engine/src/tests.rs | 15 ++++++++++++ crates/render/src/layout.rs | 43 +++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index e13783da9..c446b7b3a 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -2985,8 +2985,9 @@ fn update(s: &mut Session, p: &Value) -> CmdResult { ), None => None, }; - let col = match ps(p, "color") { - Some(c) => Some(Rgba::from_hex(c).ok_or("color must be #RRGGBB or #RRGGBBAA")?), + // A colour that isn't text is refused, as `color` does when adding (the default is unused). + let col = match p.get("color").filter(|v| !v.is_null()) { + Some(_) => Some(color(p, "color", Rgba::rgb(0, 0, 0))?), None => None, }; // A text element's italic, alignment and spacing, checked before anything changes. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e1a2654d3..b0b8d5fb3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5325,6 +5325,21 @@ fn layout_surrounds_take_cartographic_options() { p["element"] = json!(bar); assert!(s.run("layout.update", &p).is_err(), "{bad}"); } + // A text's colour is refused as anything but text, when updated as when added; null keeps it. + let text = s.run("layout.addElement", &json!({"kind": "text", "text": "T", "color": "#1F4E79"})).unwrap(); + let text = text["element"].as_u64().unwrap() as u32; + let text_color = |s: &Session| match &s.project.layouts[0].element(text).unwrap().kind { + ElementKind::Text { color, .. } => *color, + _ => panic!("a text"), + }; + for bad in [json!(5), json!([255, 0, 0]), json!(true)] { + assert!(s.run("layout.addElement", &json!({"kind": "text", "text": "U", "color": bad})).is_err(), "{bad}"); + assert!(s.run("layout.update", &json!({"element": text, "color": bad})).is_err(), "{bad}"); + } + s.run("layout.update", &json!({"element": text, "color": null, "text": "V"})).unwrap(); + assert_eq!(text_color(&s), Rgba::rgb(0x1F, 0x4E, 0x79)); + s.run("layout.update", &json!({"element": text, "color": "#B91C1C"})).unwrap(); + assert_eq!(text_color(&s), Rgba::rgb(0xB9, 0x1C, 0x1C)); let added = s.run("layout.addElement", &json!({"kind": "scaleBar", "units": "feet", "style": "line", "color": "#B91C1C"})).unwrap(); let added = added["element"].as_u64().unwrap() as u32; assert!(matches!( diff --git a/crates/render/src/layout.rs b/crates/render/src/layout.rs index 6bd810a24..9f241ddb0 100644 --- a/crates/render/src/layout.rs +++ b/crates/render/src/layout.rs @@ -3016,6 +3016,49 @@ mod tests { assert_eq!((0..y.saturating_sub(8)).map(|row| count(ink, row)).sum::(), 0, "no ink labels"); } + /// A page draws a scale bar in its own colour and fill: the element's, through `draw_page`. + #[test] + fn a_page_draws_its_scale_bar_in_its_colours() { + let mut maps = vec![Map::new("m")]; + let mut l = Layout::new("Layout", 100.0, 60.0); + let frame = l.add( + "Map", + (5.0, 35.0, 90.0, 20.0), + ElementKind::MapFrame { + map: "m".into(), + center: Coord::new(0.0, 0.0), + scale: 10_000_000.0, + shape: FrameShape::Rectangle, + grid: None, + extent_of: None, + extent_color: rustgis_map::layout::INDICATOR_COLOR, + extent_width: rustgis_map::layout::INDICATOR_WIDTH, + bearing: 0.0, + pitch: 0.0, + }, + ); + let (crimson, navy) = (Rgba::rgb(0xD0, 0x10, 0x10), Rgba::rgb(0x10, 0x10, 0xD0)); + let bar = ElementKind::ScaleBar { + frame, + units: ScaleUnits::Auto, + divisions: 4, + style: ScaleBarStyle::Alternating, + color: crimson, + fill: navy, + }; + l.add("Scale Bar", (5.0, 5.0, 90.0, 20.0), bar); + let pm = render(&l, &mut maps, 96.0, &PageInfo::default()).unwrap(); + // Above the frame (30 mm of the page's 60): the bar's divisions in its colour and fill, and + // nothing in the default ink. + let above = pm.height() / 2; + let count = |hit: fn(tiny_skia::PremultipliedColorU8) -> bool| { + (0..above).flat_map(|y| (0..pm.width()).map(move |x| (x, y))).filter(|&(x, y)| pm.pixel(x, y).is_some_and(hit)).count() + }; + assert!(count(red) > 100, "red divisions and labels"); + assert!(count(|p| p.blue() > 180 && p.red() < 90 && p.green() < 90) > 100, "blue divisions"); + assert_eq!(count(ink), 0, "no default ink"); + } + #[test] fn scale_numbers_are_short() { assert_eq!(scale_number(5.0, 1.25), "5");