Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 44 additions & 4 deletions crates/engine/src/cmd/layer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use std::path::Path;

use rustgis_data::{GeometryType, photo};
use rustgis_map::symbology::{ClassRef, ClassStyle, LABEL_SIZE, MAX_STROKES};
use rustgis_map::{ClassMethod, LineCap, LineJoin, LineMark, LineMarkers, LineStroke, Marker, Placements, Ramp, Renderer, Rgba};
use rustgis_map::{ClassMethod, Glyph, LineCap, LineJoin, LineMark, LineMarkers, LineStroke, Marker, Placements, Ramp, Renderer, Rgba};
use serde_json::{Value, json};

use super::{b, f, need_feature_layer, need_layer, ps, uint};
Expand Down Expand Up @@ -177,7 +177,7 @@ pub fn commands() -> Vec<C> {
C::new("layer.lineStrokes", "Line Strokes", "Style › Drawing", line_strokes)
.when(need_lines)
.params(
r##"{"layer"?, "strokes": [{"color"?: "#RRGGBB"|"#RRGGBBAA"|null (null: the renderer's line colour), "width"?: points|null (null: the renderer's width), "offset"?: points (positive: right of the line's direction, negative: left), "cap"?: "round"|"flat"|"square", "join"?: "round"|"miter"|"bevel", "level"?: 0..255|null (with layer.strokeLevels: the level it draws at; null: its place in the stack), "dash"?: [points, …]|null (dash, gap, … in turn, at most 8; null: solid), "markers"?: {"mark"?: "circle"|"square"|"triangle"|"diamond"|"star"|"hash" (a mark across the line), "size"?: points (a marker's size, a hash mark's length; default 4), "at"?: "interval"|"vertices"|"innerVertices"|"first"|"last"|"middle"|"segmentMiddles" or a list of them (default "interval"), "interval"?: points (default 12), "offsetAlong"?: points (from the start, back from the end, on from the middle), "rotate"?: bool (turned with the line; default true), "averageAngle"?: points (the stretch of line a marker at an interval turns with; 0: its segment), "angle"?: degrees clockwise (a hash mark at 0 crosses the line square), "outline"?: "#RRGGBB"|"#RRGGBBAA" (the markers' outline, as wide as the stroke)}|null (markers or hash marks along the line in the stroke's colour, instead of a line; cap, join and dash don't apply)}] (drawn bottom first; [] goes back to the single stroke, or for a class to the layer's strokes), "value"?: text|number (a Unique Values class's own strokes), "index"?: number (a Graduated Colors class's, 0 = the lowest), "other"?: true (Unique Values' <all other values>)}"##,
r##"{"layer"?, "strokes": [{"color"?: "#RRGGBB"|"#RRGGBBAA"|null (null: the renderer's line colour), "width"?: points|null (null: the renderer's width), "offset"?: points (positive: right of the line's direction, negative: left), "cap"?: "round"|"flat"|"square", "join"?: "round"|"miter"|"bevel", "level"?: 0..255|null (with layer.strokeLevels: the level it draws at; null: its place in the stack), "dash"?: [points, …]|null (dash, gap, … in turn, at most 8; null: solid), "markers"?: {"mark"?: "circle"|"square"|"triangle"|"diamond"|"star"|"character"|"hash" (a mark across the line), "char"?: text (a character of a font as the marker instead of a shape), "font"?: text (the installed family it is drawn from; none: the label font), "size"?: points (a marker's size, a hash mark's length; default 4), "at"?: "interval"|"vertices"|"innerVertices"|"first"|"last"|"middle"|"segmentMiddles" or a list of them (default "interval"), "interval"?: points (default 12), "offsetAlong"?: points (from the start, back from the end, on from the middle), "rotate"?: bool (turned with the line; default true), "averageAngle"?: points (the stretch of line a marker at an interval turns with; 0: its segment), "angle"?: degrees clockwise (a hash mark at 0 crosses the line square), "outline"?: "#RRGGBB"|"#RRGGBBAA" (the markers' outline, as wide as the stroke)}|null (markers or hash marks along the line in the stroke's colour, instead of a line; cap, join and dash don't apply)}] (drawn bottom first; [] goes back to the single stroke, or for a class to the layer's strokes), "value"?: text|number (a Unique Values class's own strokes), "index"?: number (a Graduated Colors class's, 0 = the lowest), "other"?: true (Unique Values' <all other values>)}"##,
),
C::new("layer.strokeLevels", "Stroke Levels", "Style › Drawing", |s, p| {
let on = b(p, "on").ok_or("on is true or false")?;
Expand All @@ -194,19 +194,33 @@ pub fn commands() -> Vec<C> {
C::new("layer.marker", "Marker", "Style › Drawing", |s, p| {
let shape = ps(p, "shape").map(|n| Marker::ALL.into_iter().find(|m| m.label().eq_ignore_ascii_case(n)).ok_or("unknown marker shape")).transpose()?;
let size = f(p, "size").map(|v| v.clamp(1.0, 80.0) as f32);
let (ch, font) = (character(p)?, ps(p, "font"));
if shape.is_some() && ch.is_some() {
return Err("give a shape or a char, not both".into());
}
with(s, p, |l| {
l.renderer.map_symbols(|sym| {
if let Some(m) = shape {
sym.marker = m;
}
let was = match sym.marker {
Marker::Char(g) => Some(g),
_ => None,
};
// A new character keeps the family of the one before; a family alone changes a character's.
match (ch, was, font) {
(Some(c), _, _) => sym.marker = Marker::Char(Glyph::new(c, font.unwrap_or(was.map_or("", |g| g.font.as_str())))),
(None, Some(g), Some(fam)) => sym.marker = Marker::Char(Glyph::new(g.ch, fam)),
_ => {}
}
if let Some(sz) = size {
sym.size = sz;
}
})
})
})
.when(need_feature_layer)
.params(r#"{"layer"?, "shape"?: "circle"|"square"|"triangle"|"diamond"|"star", "size"?: number}"#),
.params(r#"{"layer"?, "shape"?: "circle"|"square"|"triangle"|"diamond"|"star", "char"?: text (a character of a font as the marker, its first; sized by the font's em), "font"?: text (the installed family the character is drawn from; "" or one not installed: the label font), "size"?: number}"#),
C::new("layer.transparency", "Transparency", "Style › Effects", |s, p| {
let t = f(p, "value").ok_or("value (0–100) is required")?.clamp(0.0, 100.0) as f32;
with(s, p, |l| l.transparency = t)
Expand Down Expand Up @@ -472,6 +486,15 @@ fn line_strokes(s: &mut Session, p: &Value) -> CmdResult {
Ok(json!({"strokes": n}))
}

/// A character marker's `char` (its first character), if given.
fn character(p: &Value) -> Result<Option<char>, String> {
match p.get("char") {
None | Some(Value::Null) => Ok(None),
Some(Value::String(c)) => c.chars().next().map(Some).ok_or_else(|| "char is a character".into()),
Some(_) => Err("char is a character".into()),
}
}

/// A stroke's `markers`: what it draws along the line instead of a line, and where.
fn line_markers(v: &Value) -> Result<LineMarkers, String> {
let o = v.as_object().ok_or("an object, such as {\"mark\": \"circle\", \"interval\": 12}")?;
Expand All @@ -487,10 +510,27 @@ fn line_markers(v: &Value) -> Result<LineMarkers, String> {
let mark = match o.get("mark") {
None | Some(Value::Null) => d.mark,
Some(Value::String(m)) if m.trim().eq_ignore_ascii_case("hash") => LineMark::Hash,
// Its `char` says which.
Some(Value::String(m)) if m.trim().eq_ignore_ascii_case("character") => {
if character(v)?.is_none() {
return Err("a character mark needs its char".into());
}
d.mark
}
Some(Value::String(m)) => LineMark::Marker(
Marker::ALL.into_iter().find(|s| s.label().eq_ignore_ascii_case(m.trim())).ok_or_else(|| format!("unknown mark '{m}'"))?,
),
Some(_) => return Err("mark is \"circle\", \"square\", \"triangle\", \"diamond\", \"star\" or \"hash\"".into()),
Some(_) => return Err("mark is \"circle\", \"square\", \"triangle\", \"diamond\", \"star\", \"character\" or \"hash\"".into()),
};
// A character of a font instead of a shape.
let mark = match (character(v)?, o.get("font")) {
(Some(_), _) if mark == LineMark::Hash => return Err("hash marks take no char".into()),
// As `layer.marker`: a shape named with a char is a mistake, not the char.
(Some(_), _) if o.get("mark").and_then(Value::as_str).is_some_and(|m| !m.trim().eq_ignore_ascii_case("character")) => {
return Err("give a shape or a char, not both".into());
}
(Some(c), font) => LineMark::Marker(Marker::Char(Glyph::new(c, font.and_then(Value::as_str).unwrap_or_default()))),

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 (medium confidence): if char is given together with a shape mark (e.g. "mark":"circle","char":"x"), the char silently wins. layer.marker rejects the same combination with "give a shape or a char, not both". The two commands should behave the same, so return an error here too. Only mark absent or "character" should be allowed to combine with char. Also, a non-string font (font.and_then(Value::as_str)) is silently ignored.

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.

Fixed the main point in 74c781f: a stroke's markers with a shape mark and a char is now refused with "give a shape or a char, not both", as layer.marker does (engine test added). A non-string font is left as it is: layer.marker and labels.style read font the same way (ps, a non-string means none), so the commands stay consistent.

(None, _) => mark,
};
let bad_at = || format!("at is a placement or a list of them: {}", Placements::all_codes());
let at = match o.get("at") {
Expand Down
46 changes: 46 additions & 0 deletions crates/engine/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,52 @@ fn line_strokes_take_markers_and_hash_marks_along_the_line() {
assert_eq!(s.undo_depth(), depth);
}

#[test]
fn a_character_of_a_font_is_a_marker_on_points_and_along_lines() {
use rustgis_map::{Glyph, LineMark, Marker};
let mut s = world();
let marker = |s: &Session| s.map().layer_by_name("Cities").unwrap().symbol(0).marker;
s.run("layer.marker", &json!({"layer": "Cities", "char": "★x", "font": "DejaVu Sans", "size": 14})).unwrap();
assert_eq!(marker(&s), Marker::Char(Glyph::new('★', "DejaVu Sans")));
// Another character keeps the family; a family alone changes the character's.
s.run("layer.marker", &json!({"layer": "Cities", "char": "A"})).unwrap();
assert_eq!(marker(&s), Marker::Char(Glyph::new('A', "DejaVu Sans")));
s.run("layer.marker", &json!({"layer": "Cities", "font": ""})).unwrap();
assert_eq!(marker(&s), Marker::Char(Glyph::new('A', "")));
// Drawn in an export, a family this machine lacks in the label font.
let dir = std::env::temp_dir().join(format!("rustgis-chars-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
s.run("layer.marker", &json!({"layer": "Cities", "font": "No Such Family"})).unwrap();
let strokes =
json!([{"color": "#1E5AA8", "markers": {"mark": "character", "char": "»", "font": "DejaVu Sans", "size": 8, "interval": 20}}]);
s.run("layer.lineStrokes", &json!({"layer": "Rivers", "strokes": strokes})).unwrap();
let m = s.map().layer_by_name("Rivers").unwrap().line_strokes[0].markers.unwrap();
assert_eq!(m.mark, LineMark::Marker(Marker::Char(Glyph::new('»', "DejaVu Sans"))));
s.run("share.exportMap", &json!({"path": dir.join("map.png").display().to_string(), "width": 320, "height": 200})).unwrap();
std::fs::remove_dir_all(&dir).ok();
// A shape goes back to the shape.
s.run("layer.marker", &json!({"layer": "Cities", "shape": "square"})).unwrap();
assert_eq!(marker(&s), Marker::Square);
let depth = s.undo_depth();
for (bad, says) in [
(json!({"layer": "Cities", "char": ""}), "char is a character"),
(json!({"layer": "Cities", "char": 7}), "char is a character"),
(json!({"layer": "Cities", "char": "a", "shape": "star"}), "not both"),
] {
let err = s.run("layer.marker", &bad).unwrap_err();
assert!(err.contains(says), "{bad}: {err}");
}
let err = s.run("layer.lineStrokes", &json!({"layer": "Rivers", "strokes": [{"markers": {"mark": "hash", "char": "x"}}]})).unwrap_err();
assert!(err.contains("hash marks take no char"), "{err}");
let err = s.run("layer.lineStrokes", &json!({"layer": "Rivers", "strokes": [{"markers": {"mark": "character"}}]})).unwrap_err();
assert!(err.contains("needs its char"), "{err}");
// A shape with a char is refused, as by `layer.marker`.
let err =
s.run("layer.lineStrokes", &json!({"layer": "Rivers", "strokes": [{"markers": {"mark": "circle", "char": "x"}}]})).unwrap_err();
assert!(err.contains("not both"), "{err}");
assert_eq!(s.undo_depth(), depth);
}

#[test]
fn a_stroke_is_dashed_by_command() {
let mut s = world();
Expand Down
4 changes: 2 additions & 2 deletions crates/map/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, LabelClass, LabelKey, LabelMatcher, LabelPlacement, Labeling, LineCap, LineJoin, LineMark, LineMarkers, LineStroke,
Marker, Placements, Renderer, Symbol,
ClassMethod, FontName, Glyph, LabelClass, LabelKey, LabelMatcher, LabelPlacement, Labeling, LineCap, LineJoin, LineMark, LineMarkers,
LineStroke, Marker, Placements, Renderer, Symbol,
};
pub use tiles::{TileKind, TileLayer};

Expand Down
76 changes: 61 additions & 15 deletions crates/map/src/qgis/symbol.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
//! QGIS symbols (`<symbol type="fill|line|marker">`) as a RustGIS [`Symbol`], and a line's
//! symbol layers as stacked [`LineStroke`]s. A symbol layer RustGIS has no match for is drawn
//! as near as it can (a pattern fill as a tint of its colour, a font or SVG marker as a circle in
//! its colour) or left out, and noted.
//! as near as it can (a pattern fill as a tint of its colour, an SVG marker as a circle in its
//! colour) or left out, and noted.

use roxmltree::Node;

use super::{Report, color, data_defined_on, has_effect, points, prop, props, signed_points};
use crate::Rgba;
use crate::symbology::{
Dash, LineCap, LineJoin, LineMark, LineMarkers, LineStroke, MAX_DASH_STEPS, MAX_STROKES, Marker, Placements, Symbol,
Dash, Glyph, LineCap, LineJoin, LineMark, LineMarkers, LineStroke, MAX_DASH_STEPS, MAX_STROKES, Marker, Placements, Symbol,
};

/// A symbol layer's settings by name.
Expand Down Expand Up @@ -379,12 +379,7 @@ fn marker_mark(
Marker::Circle
})
}
// A character is mostly thin strokes: a dot of its size would draw a heavy blot along the
// line where QGIS draws a tick or a dash, so it is left out.
"FontMarker" => {
report.skipped(layer, "font markers (characters of a symbol font) along lines");
return None;
}
"FontMarker" => font_char(p, layer, report)?,
"SvgMarker" | "RasterMarker" | "EllipseMarker" | "FilledMarker" => {
report.approximated(layer, format!("{} markers, drawn as circles in their colour", marker_name(class)));
Marker::Circle
Expand All @@ -394,6 +389,9 @@ fn marker_mark(
return None;
}
};
if class == "FontMarker" && anchored(p) {
report.approximated(layer, "font markers anchored off their centre, drawn centred");
}
let fill = prop(p, "color").and_then(color).unwrap_or(Rgba::new(0, 0, 0, 0));
let outline = if prop(p, "outline_style") == Some("no") { None } else { prop(p, "outline_color").and_then(color) };
let width = if outline.is_some() { size(p, "outline_width", reference, layer, report).unwrap_or(0.0) } else { 0.0 };
Expand Down Expand Up @@ -474,10 +472,10 @@ fn marker(layers: &[(Node, Props)], reference: Option<f64>, layer: &str, report:
Marker::Circle
})
}
"FontMarker" => {
report.approximated(layer, "font markers (characters of a symbol font), drawn as circles in their colour");
Marker::Circle
}
"FontMarker" => match font_char(p, layer, report) {
Some(m) => m,
None => continue,
},
"SvgMarker" | "RasterMarker" | "EllipseMarker" | "FilledMarker" => {
report.approximated(layer, format!("{} markers, drawn as circles in their colour", marker_name(class)));
Marker::Circle
Expand All @@ -487,12 +485,14 @@ fn marker(layers: &[(Node, Props)], reference: Option<f64>, layer: &str, report:
continue;
}
};
if class == "FontMarker" && anchored(p) {
report.approximated(layer, "font markers anchored off their centre, drawn centred");
}
if prop(p, "angle").and_then(|a| a.trim().parse::<f32>().ok()).is_some_and(|a| a != 0.0) {
report.approximated(layer, "turned markers, drawn upright");
}
let fill = fill.unwrap_or(Rgba::new(0, 0, 0, 0));
// A font marker's character is all fill; an outline of no width draws none.
let width = if class == "FontMarker" { 0.0 } else { size(p, "outline_width", reference, layer, report).unwrap_or(0.0) };
let width = size(p, "outline_width", reference, layer, report).unwrap_or(0.0);
// Each layer drawn over the one before: the top one's look, the biggest size.
symbol = Symbol {
fill,
Expand All @@ -505,6 +505,52 @@ fn marker(layers: &[(Node, Props)], reference: Option<f64>, layer: &str, report:
Converted { symbol, strokes: Vec::new() }
}

/// A font marker's character (`chr`, of the family `font`) as a character marker: QGIS's `A` when
/// it has none, and the characters it saves escaped ([`unescape_chr`]) decoded. A
/// string of several draws its first (noted).
pub(super) fn font_char(p: &[(String, String)], layer: &str, report: &mut Report) -> Option<Marker> {
let text = unescape_chr(prop(p, "chr").filter(|c| !c.is_empty()).unwrap_or("A"));
let mut chars = text.chars();
let ch = chars.next()?;
if chars.next().is_some() {
report.approximated(layer, "font markers of several characters, drawn as their first");
}
if prop(p, "font_style").is_some_and(|s| !s.trim().is_empty() && !s.trim().eq_ignore_ascii_case("regular")) {
report.approximated(layer, "font marker styles (bold, italic and the like), drawn regular");
}
Some(Marker::Char(Glyph::new(ch, prop(p, "font").unwrap_or_default())))
}

/// `chr` as QGIS reads it: it saves some characters as their code between two `~!_#!#_!~`.
fn unescape_chr(chr: &str) -> String {
const FIX: &str = "~!_#!#_!~";
let (mut out, mut rest) = (String::new(), chr);
while let Some(i) = rest.find(FIX) {
let after = &rest[i + FIX.len()..];
let digits = after.len() - after.trim_start_matches(|c: char| c.is_ascii_digit()).len();
let code = after[..digits].parse::<u16>().ok().and_then(|n| char::from_u32(n.into()));
match code {
Some(c) if after[digits..].starts_with(FIX) => {
out.push_str(&rest[..i]);
out.push(c);
rest = &after[digits + FIX.len()..];
}
_ => {
out.push_str(&rest[..i + FIX.len()]);
rest = after;
}
}
}
out.push_str(rest);
out
}

/// Whether a marker is anchored off its centre (`horizontal_anchor_point`,
/// `vertical_anchor_point`: 1 is the centre).
fn anchored(p: &[(String, String)]) -> bool {
["horizontal_anchor_point", "vertical_anchor_point"].iter().any(|k| prop(p, k).is_some_and(|v| v.trim() != "1"))
}

/// QGIS's simple marker shapes RustGIS has.
fn shape_of(name: &str) -> Option<Marker> {
Some(match name {
Expand Down
Loading
Loading