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
8 changes: 7 additions & 1 deletion crates/engine/src/cmd/qgis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,13 @@ fn add_layer(s: &mut Session, layer: &qgis::ProjectLayer, has_dir: bool, report:
}
}
match &layer.source {
Source::File { path, layer: table, .. } => {
Source::File { path, layer: table, ignored, .. } => {
if !ignored.is_empty() {
// `layerid=2`: RustGIS opens a layer by name, so without one every layer of a file
// that has several comes in; with one, the options are only not read.
let why = if table.is_some() { "not read" } else { "not read: a file with several layers adds them all" };
report.approximated(name, format!("its source options {} ({why})", ignored.join(", ")));
}
let mut q = json!({"path": path, "name": name});
if let Some(t) = table {
q["layer"] = json!(t);
Expand Down
12 changes: 9 additions & 3 deletions crates/map/src/qgis/expr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,9 @@ fn tokenize(s: &str) -> Option<Vec<Token>> {
}
}
let n: String = chars.get(start..i).unwrap_or_default().iter().collect();
n.parse::<f64>().ok()?;
out.push(Token::Word(n));
let v = n.parse::<f64>().ok()?;
// An exponent is written out (0.001 for 1e-3): RustGIS SQL reads plain decimals.
out.push(Token::Word(if n.contains(['e', 'E']) && v.is_finite() { format!("{v}") } else { n }));
}
c if c.is_alphabetic() || c == '_' => {
let start = i;
Expand All @@ -135,6 +136,8 @@ fn tokenize(s: &str) -> Option<Vec<Token>> {
// A word followed by a bracket is a function call.
let next = chars.get(i..).unwrap_or_default().iter().find(|c| !c.is_whitespace());
match keyword(&w) {
// TRUE and FALSE are values, bare as RustGIS SQL has them, not fields.
_ if w.eq_ignore_ascii_case("true") || w.eq_ignore_ascii_case("false") => out.push(Token::Word(w.to_lowercase())),
Some(k) => out.push(Token::Word(k.into())),
None if next == Some(&'(') => return None,
None => out.push(Token::Field(w)),
Expand Down Expand Up @@ -180,9 +183,12 @@ mod tests {
assert_eq!(to_sql(r#""Symbol" = 'Ortsteil_20-49'"#).as_deref(), Some(r#""Symbol" = 'Ortsteil_20-49'"#));
assert_eq!(
to_sql(r#"pop >= 1e3 AND ("name" ILIKE 'z%' OR kind IN ('a', 'b''s')) AND note IS NOT NULL"#).as_deref(),
Some(r#""pop" >= 1e3 AND ( "name" LIKE 'z%' OR "kind" IN ( 'a' , 'b''s' ) ) AND "note" IS NOT NULL"#)
Some(r#""pop" >= 1000 AND ( "name" LIKE 'z%' OR "kind" IN ( 'a' , 'b''s' ) ) AND "note" IS NOT NULL"#)
);
assert_eq!(to_sql("a <> -2.5").as_deref(), Some(r#""a" <> -2.5"#));
// Exponents written out, and TRUE and FALSE as values, as RustGIS SQL reads them.
assert_eq!(to_sql("d < 2.5E-4 OR d > -1e+2").as_deref(), Some(r#""d" < 0.00025 OR "d" > -100"#));
assert_eq!(to_sql("flag = TRUE AND gone = false").as_deref(), Some(r#""flag" = true AND "gone" = false"#));
for refused in ["upper(\"name\") = 'X'", "\"a\" + 1 > 2", "\"a\" || 'x' = 'y'", "@map_scale < 5000", "'unclosed", "", "a ! b"] {
assert_eq!(to_sql(refused), None, "{refused}");
}
Expand Down
17 changes: 9 additions & 8 deletions crates/map/src/qgis/labels.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ pub(super) fn read(l: Node, layer: &str, report: &mut Report) -> Option<Labeling
}
for (i, r) in rules.iter().enumerate() {
let Some(settings) = child(r.node, "settings") else { continue };
// Switched off: never drawn, so nothing about it is noted.
if r.node.attribute("active") == Some("0") {
continue;
}
let name = r
.node
.attribute("description")
Expand Down Expand Up @@ -53,9 +57,6 @@ pub(super) fn read(l: Node, layer: &str, report: &mut Report) -> Option<Labeling
);
continue;
};
if r.node.attribute("active") == Some("0") {
continue;
}
classes.extend(class(settings, &name, where_, r.scales, layer, report));
}
}
Expand Down Expand Up @@ -108,9 +109,10 @@ fn flatten<'a, 'i>(
return;
}
let rules: Vec<Node> = node.children().filter(|c| c.has_tag_name("rule")).collect();
// The filters an else rule here is the rest of.
// The filters an else rule here is the rest of: its switched-on siblings', as QGIS has it.
let siblings: Vec<String> = rules
.iter()
.filter(|r| r.attribute("active") != Some("0"))
.filter_map(|r| r.attribute("filter").map(str::trim))
.filter(|f| !f.is_empty() && !is_else(f))
.map(str::to_string)
Expand Down Expand Up @@ -183,9 +185,8 @@ fn class(
|| weight >= 63.0
|| text.attribute("fontBold") == Some("1");
let italic = style.contains("italic") || style.contains("oblique") || text.attribute("fontItalic") == Some("1");
if let Some(family) = text.attribute("fontFamily").filter(|f| !f.is_empty()) {
report.approximated(layer, format!("labels in {family}, drawn in RustGIS's label font"));
}
// 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");
}
Expand Down Expand Up @@ -219,7 +220,7 @@ fn class(
farthest: None,
placement,
italic,
font: None,
font,
})
}

Expand Down
4 changes: 4 additions & 0 deletions crates/map/src/qgis/renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,10 @@ fn graduated(r: Node, layer: &str, style: &mut Style, report: &mut Report, sym:
report.approximated(layer, "ranges with gaps or overlaps between them, drawn as if each began where the one before ends");
}
let min = ranges.first().map_or(0.0, |r| r.0);
// QGIS draws nothing for a value outside every range; a RustGIS graduated renderer gives it the
// first or last class. The import doesn't read the data, so it can't tell whether any value
// falls outside: the note is always given, on purpose.
report.approximated(layer, "values below the first range or above the last, drawn in the first or last class (QGIS leaves them out)");
Comment thread
giswqs marked this conversation as resolved.
let mut class_labels = BTreeMap::new();
let mut class_strokes: BTreeMap<usize, Vec<LineStroke>> = BTreeMap::new();
for (i, (.., c, label)) in ranges.iter().enumerate() {
Expand Down
99 changes: 87 additions & 12 deletions crates/map/src/qgis/source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
#[derive(Clone, Debug, PartialEq)]
pub enum Source {
/// A file (`ogr`, `gdal`, `spatialite`): its path, the layer or table in it, and the filter
/// QGIS's subset string sets (as QGIS has it).
File { path: String, layer: Option<String>, subset: Option<String> },
/// QGIS's subset string sets (as QGIS has it), and the source's options RustGIS doesn't read
/// (`layerid=2`, `geometrytype=Point`), for the import to note.
File { path: String, layer: Option<String>, subset: Option<String>, ignored: Vec<String> },
/// A delimited text file (`delimitedtext`): its path, and the fields its points (or WKT
/// geometry) are in.
Csv { path: String, x: Option<String>, y: Option<String>, wkt: Option<String>, crs: Option<String> },
Expand All @@ -27,9 +28,21 @@ pub fn read(provider: &str, datasource: &str, dir: &str) -> Source {
match provider {
"ogr" | "gdal" => file(datasource, dir),
"spatialite" => {
let kv = conninfo(datasource);
match (kv.iter().find(|(k, _)| k == "dbname"), table_of(datasource)) {
(Some((_, db)), Some(t)) => Source::File { path: absolute(db, dir), layer: Some(t), subset: None },
// `dbname='…' key='id' srid=4326 type=Point table="t" (geom) sql=…`: the filter, if
// any, runs to the end.
let (head, sql) = datasource.rsplit_once(" sql=").unwrap_or((datasource, ""));

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.

Since the filter "runs to the end", the split should be at the first sql=, not the last. With rsplit_once, a filter that itself contains sql= (e.g. in a string literal, "note" = 'use sql=1') is cut in the middle, and the head then holds part of the filter, which conninfo parses as options. Confidence: medium-low (rare input, but cheap to fix).

Suggested change
let (head, sql) = datasource.rsplit_once(" sql=").unwrap_or((datasource, ""));
let (head, sql) = datasource.split_once(" sql=").unwrap_or((datasource, ""));

(dbname='… sql=…' in a quoted path would be the mirror case; the first occurrence is still the better guess since QGIS writes sql= last.)

let subset = Some(sql.trim().to_string()).filter(|q| !q.is_empty());
let kv = conninfo(head);
// What QGIS always writes and RustGIS reads or needn't.
const READ_OR_HARMLESS: [&str; 7] = ["dbname", "table", "key", "srid", "type", "estimatedmetadata", "checkPrimaryKeyUnicity"];
let ignored = kv
.iter()
.filter(|(k, _)| !READ_OR_HARMLESS.iter().any(|r| r.eq_ignore_ascii_case(k)))
.take(32)
.map(|(k, v)| noted_option(&format!("{k}={v}")))
.collect();
match (kv.iter().find(|(k, _)| k == "dbname"), table_of(head)) {
(Some((_, db)), Some(t)) => Source::File { path: absolute(db, dir), layer: Some(t), subset, ignored },
_ => Source::Other { provider: provider.into() },
}
}
Expand Down Expand Up @@ -77,20 +90,41 @@ fn file(datasource: &str, dir: &str) -> Source {
if let Some(rest) = datasource.strip_prefix("GPKG:") {
// GPKG:path:table, the path itself possibly with a drive letter's colon.
if let Some((path, table)) = rest.rsplit_once(':') {
return Source::File { path: absolute(path, dir), layer: Some(table.to_string()), subset: None };
return Source::File { path: absolute(path, dir), layer: Some(table.to_string()), subset: None, ignored: Vec::new() };
}
}
let mut parts = datasource.split('|');
let path = parts.next().unwrap_or_default().trim();
let (mut layer, mut subset) = (None, None);
let (mut layer, mut subset, mut ignored) = (None, None, Vec::new());
// Every part is read for the layer and the filter; at most 32 others are kept for the note.
for p in parts {
if let Some(v) = p.strip_prefix("layername=") {
layer = Some(v.to_string());
} else if let Some(v) = p.strip_prefix("subset=") {
subset = Some(v.to_string()).filter(|s| !s.trim().is_empty());
} else if !p.trim().is_empty() && p.trim() != "layerid=0" && ignored.len() < 32 {
// (layerid=0 is the first layer, which is what opens.)
ignored.push(noted_option(p.trim()));
}
}
// A layer named as well as numbered: the name is what's read.
if layer.is_some() {
ignored.retain(|o: &String| !o.starts_with("layerid="));
}
Source::File { path: absolute(path, dir), layer, subset, ignored }
}

/// A source option as the import notes it: with its value only for the options known to carry
/// nothing private (`layerid=2`, `geometrytype=Point`), else by its name alone, since a value can
/// be a key, a token or a password under any name.
fn noted_option(option: &str) -> String {
const SHOWN: [&str; 3] = ["layerid", "geometrytype", "uniquegeometrytype"];
match option.split_once('=') {
Some((key, _)) if !SHOWN.iter().any(|k| k.eq_ignore_ascii_case(key.trim())) => {
format!("{}=…", key.trim().chars().take(100).collect::<String>())
}
_ => option.chars().take(100).collect(),
}
Source::File { path: absolute(path, dir), layer, subset }
}

/// A `delimitedtext` source: `file:///path.csv?type=csv&xField=…&yField=…&crs=…`.
Expand Down Expand Up @@ -197,14 +231,55 @@ mod tests {
Source::File {
path: "/data/project/roads.gpkg".into(),
layer: Some("roads".into()),
subset: Some("\"type\" = 'motorway'".into())
subset: Some("\"type\" = 'motorway'".into()),
ignored: Vec::new()
}
);
assert_eq!(read("ogr", "/abs/rivers.shp", dir), Source::File { path: "/abs/rivers.shp".into(), layer: None, subset: None });
assert_eq!(read("gdal", "C:/maps/dem.tif", dir), Source::File { path: "C:/maps/dem.tif".into(), layer: None, subset: None });
assert_eq!(
read("ogr", "/abs/rivers.shp", dir),
Source::File { path: "/abs/rivers.shp".into(), layer: None, subset: None, ignored: Vec::new() }
);
assert_eq!(
read("gdal", "C:/maps/dem.tif", dir),
Source::File { path: "C:/maps/dem.tif".into(), layer: None, subset: None, ignored: Vec::new() }
);
// Options RustGIS doesn't read are kept for a note; the first layer's number isn't one.
let Source::File { ignored, .. } = read("ogr", "./multi.gpkg|layerid=2|geometrytype=Point", dir) else { panic!() };
assert_eq!(ignored, ["layerid=2", "geometrytype=Point"]);
let Source::File { ignored, .. } = read("ogr", "./one.gpkg|layerid=0", dir) else { panic!() };
assert!(ignored.is_empty());
let Source::File { ignored, .. } = read("ogr", "./multi.gpkg|layername=roads|layerid=3", dir) else { panic!() };
assert!(ignored.is_empty(), "the name is read: {ignored:?}");
// An option that may carry a secret is noted by its name only.
let Source::File { ignored, .. } = read("ogr", "/vsis3/b/x.gpkg|credential:AWS_SECRET_ACCESS_KEY=hunter2|token=abc", dir) else {
panic!()
};
assert_eq!(ignored, ["credential:AWS_SECRET_ACCESS_KEY=…", "token=…"]);
let Source::File { ignored, .. } = read("ogr", "./x.gpkg|header=Bearer abc|layerid=4", dir) else { panic!() };
assert_eq!(ignored, ["header=…", "layerid=4"], "values only for options known to be harmless");
// SpatiaLite: the table, its filter, and the options that matter noted.
let Source::File { path, layer, subset, ignored } =
read("spatialite", r#"dbname='./db.sqlite' key='id' srid=4326 type=Point table="towns" (geom) sql="pop" > 1000"#, dir)
else {
panic!()
};
assert_eq!(
(path.as_str(), layer.as_deref(), subset.as_deref()),
("/data/project/db.sqlite", Some("towns"), Some(r#""pop" > 1000"#))
);
assert!(ignored.is_empty(), "{ignored:?}");
let Source::File { subset, ignored, .. } = read("spatialite", r#"dbname='/a.sqlite' password='s3cret' table="t" (geom) sql="#, dir)
else {
panic!()
};
assert_eq!((subset, ignored), (None, vec!["password=…".to_string()]));
// The layer and the filter are read past the options kept for the note.
let many = format!("./m.gpkg|{}|layername=late|subset=a = 1", vec!["x=1"; 40].join("|"));
let Source::File { layer, subset, ignored, .. } = read("ogr", &many, dir) else { panic!() };
assert_eq!((layer.as_deref(), subset.as_deref(), ignored.len()), (Some("late"), Some("a = 1"), 32));
assert_eq!(
read("gdal", "GPKG:./m.gpkg:relief", dir),
Source::File { path: "/data/project/m.gpkg".into(), layer: Some("relief".into()), subset: None }
Source::File { path: "/data/project/m.gpkg".into(), layer: Some("relief".into()), subset: None, ignored: Vec::new() }
);
assert_eq!(
read("delimitedtext", "file:///data/pts%20a.csv?type=csv&xField=lon&yField=lat&crs=EPSG:4326", dir),
Expand Down
17 changes: 14 additions & 3 deletions crates/map/src/qgis/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ const PROJECT: &str = r#"<!DOCTYPE qgis PUBLIC 'http://mrcc.com/qgis.dtd' 'SYSTE
<settings><text-style fieldName="name" fontSize="7"/><placement placement="2"/></settings>
</rule>
<rule key="c" filter="&quot;kind&quot; = 'x'"><settings><text-style fieldName="name" fontSize="7"/></settings></rule>
<rule key="off" filter="upper(&quot;kind&quot;) = 'X'" description="Switched off" active="0"><settings><text-style fieldName="name"/></settings></rule>
</rule>
<rule key="d" filter="length(&quot;name&quot;) &gt; 3"><settings><text-style fieldName="name"/></settings></rule>
</rules>
Expand Down Expand Up @@ -117,7 +118,12 @@ fn a_project_reads_into_layers_in_tree_order_with_their_styles() {
let land = &p.layers[2];
assert_eq!(
land.source,
Source::File { path: "/maps/data/base.gpkg".into(), layer: Some("land".into()), subset: Some("\"kind\" <> 'sea'".into()) }
Source::File {
path: "/maps/data/base.gpkg".into(),
layer: Some("land".into()),
subset: Some("\"kind\" <> 'sea'".into()),
ignored: Vec::new()
}
);
assert_eq!(land.definition_query.as_deref(), Some("\"kind\" <> 'sea'"));
assert_eq!(land.crs.as_deref(), Some("EPSG:2056"));
Expand All @@ -138,10 +144,11 @@ fn a_project_reads_into_layers_in_tree_order_with_their_styles() {
assert!(labels.enabled && labels.classes.is_empty());
assert_eq!((d.field.as_str(), d.size, d.bold, d.italic, d.halo), ("name", 12.0, true, true, true));
assert_eq!((d.color, d.placement), (Rgba::rgb(20, 40, 60), LabelPlacement::AboveRight));
assert_eq!(d.font.as_deref(), Some("Liberation Sans Narrow"), "QGIS's font family");

// Roads: categories with stacked strokes in levels, a hidden one and the rest.
let roads = &p.layers[1];
assert_eq!(roads.source, Source::File { path: "/abs/roads.shp".into(), layer: None, subset: None });
assert_eq!(roads.source, Source::File { path: "/abs/roads.shp".into(), layer: None, subset: None, ignored: Vec::new() });
let s = &roads.style;
assert_eq!((s.stroke_levels, s.line_strokes.as_deref()), (Some(true), Some(&[][..])));
let Some(Renderer::Unique { field, classes, class_strokes, class_labels, other, .. }) = &s.renderer else { panic!() };
Expand Down Expand Up @@ -179,11 +186,15 @@ fn a_project_reads_into_layers_in_tree_order_with_their_styles() {
let said =
|layer: &str, kind: NoteKind, part: &str| report.notes.iter().any(|n| n.layer == layer && n.kind == kind && n.text.contains(part));
assert!(said("Land", NoteKind::Approximated, "line pattern"));
assert!(said("Land", NoteKind::Approximated, "Liberation Sans Narrow"));
assert!(said("Land", NoteKind::Approximated, "above the last"), "values outside the ranges are noted");
// A QGIS font family is the label class's font, drawn in it where it's installed.
assert!(!said("Land", NoteKind::Approximated, "Liberation Sans Narrow"), "no longer approximated");
assert!(said("Roads", NoteKind::Approximated, "dashed"));
assert!(said("Roads", NoteKind::Skipped, "markers and hash marks"));
assert!(said("Roads", NoteKind::Skipped, "length("));
assert!(said("Towns", NoteKind::Approximated, "font markers"));
// A switched-off label rule is never drawn, so its filter isn't noted.
assert!(!report.notes.iter().any(|n| n.text.contains("Switched off")), "{:?}", report.notes);
}

#[test]
Expand Down
3 changes: 2 additions & 1 deletion crates/render/src/families.rs
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,8 @@ fn face_info(r: &mut (impl Read + Seek), start: u32) -> Option<FaceInfo> {
let style = read_name(17).or_else(|| read_name(2)).unwrap_or_default().to_lowercase();
let (weight, selection) = match table(b"OS/2") {
Some((at, len)) => {
let os2 = read_at(r, at, len.min(64))?;
// An unreadable `OS/2` (cut short, past the end) keeps the face, at the defaults.
let os2 = read_at(r, at, len.min(64)).unwrap_or_default();
(u16_at(&os2, 4).unwrap_or(400), u16_at(&os2, 62).unwrap_or(0))
}
None => (400, 0),
Expand Down
Loading
Loading