diff --git a/readme.md b/readme.md
index 9085b14..a823cd6 100644
--- a/readme.md
+++ b/readme.md
@@ -52,7 +52,14 @@ Convert a file to another format (both formats inferred from their extensions):
GeoConverter.Convert("cities.geojson", "cities.kml");
GeoConverter.Convert("roads.shp", "roads.fgb");
```
-snippet source | anchor
+snippet source | anchor
+
+```cs
+// Formats are inferred from the file extensions.
+GeoConverter.Convert("cities.geojson", "cities.kml");
+GeoConverter.Convert("roads.shp", "roads.fgb");
+```
+snippet source | anchor
Read into the common feature model, then write a different format:
@@ -74,7 +81,24 @@ foreach (var feature in collection)
// Write it back out as a different format.
GeoConverter.Write(collection, "roads.fgb");
```
-snippet source | anchor
+snippet source | anchor
+
+```cs
+// Read any supported format into the common feature model.
+var collection = GeoConverter.Read("roads.shp");
+
+foreach (var feature in collection)
+{
+ if (feature.Properties.TryGetValue("name", out var name))
+ {
+ Console.WriteLine(name);
+ }
+}
+
+// Write it back out as a different format.
+GeoConverter.Write(collection, "roads.fgb");
+```
+snippet source | anchor
Build a collection in memory and serialize it:
@@ -94,7 +118,22 @@ var collection = new FeatureCollection
var geoJson = GeoJson.WriteString(collection);
```
-snippet source | anchor
+snippet source | anchor
+
+```cs
+var collection = new FeatureCollection
+{
+ new Feature(
+ new Point(new(151.21, -33.87)),
+ new Dictionary
+ {
+ ["name"] = "Sydney"
+ }),
+};
+
+var geoJson = GeoJson.WriteString(collection);
+```
+snippet source | anchor
@@ -135,7 +174,36 @@ var countries = GeoConverter.Read("countries.geojson");
var topo = Simplifier.SimplifyTopology(countries, 0.05);
GeoConverter.Write(topo, "countries-thin.fgb");
```
-snippet source | anchor
+snippet source | anchor
+
+```cs
+// Reduce vertex count before writing — the highest-leverage way to shrink dense vector data.
+// Simplifier.Simplify returns a NEW collection (the input is left untouched), preserving layer
+// names, properties, feature ids and structure; only line and polygon-ring vertices are thinned.
+var collection = GeoConverter.Read("coastline.geojson");
+
+// Douglas–Peucker (the default): tolerance is a perpendicular distance in coordinate units
+// (degrees for WGS84). Vertices within that distance of the retained line are dropped.
+var coarse = Simplifier.Simplify(collection, 0.01);
+GeoConverter.Write(coarse, "coastline.topojson");
+
+// Visvalingam–Whyatt: tolerance is an effective triangle area (degrees²) — tends to give a
+// smoother, more evenly generalised outline. Points pass through untouched; polygon rings stay
+// closed and never collapse below a triangle, so the result is always valid.
+var smooth = Simplifier.Simplify(collection, 0.0001, SimplifyMethod.Visvalingam);
+GeoConverter.Write(smooth, "coastline-vw.geojson");
+
+// SimplifyTopology: same algorithms, but adjacent polygons that share a border get that
+// border simplified once — so the two sides stay seamlessly joined. The plain overload thins
+// each ring independently; two countries' shared edges then get different chord choices and
+// no longer line up, leaving hairline gaps (visible as white stripes between countries) or
+// alpha-stacked overlaps when the fill is translucent. Pick this for topologically
+// consistent datasets like Natural Earth admin layers where shared boundaries matter.
+var countries = GeoConverter.Read("countries.geojson");
+var topo = Simplifier.SimplifyTopology(countries, 0.05);
+GeoConverter.Write(topo, "countries-thin.fgb");
+```
+snippet source | anchor
@@ -163,7 +231,27 @@ GeoConverter.Convert("countries.geojson", "countries.fgb", progress);
var features = GeoConverter.Read("countries.geojson");
MapRenderer.RenderPng(features, "world.png", new() { Progress = progress });
```
-snippet source | anchor
+snippet source | anchor
+
+```cs
+// Read, Write and Convert each take an optional IProgress. Convert reports the
+// read half under ProgressPhase.Reading and the write half under ProgressPhase.Writing. Every
+// report carries a feature count and a byte count; ConvertProgress.Fraction picks whichever
+// total is known (features when writing, bytes when reading a seekable source) and returns null
+// when neither is — an honest "indeterminate" rather than a fabricated percentage.
+var progress = new Progress(report =>
+{
+ var percent = report.Fraction is { } fraction ? $"{fraction:P0}" : "?";
+ Console.WriteLine($"{report.Phase}: {report.Features} features ({percent})");
+});
+
+GeoConverter.Convert("countries.geojson", "countries.fgb", progress);
+
+// PNG rendering reports through RenderOptions.Progress (one report per feature rasterised).
+var features = GeoConverter.Read("countries.geojson");
+MapRenderer.RenderPng(features, "world.png", new() { Progress = progress });
+```
+snippet source | anchor
@@ -186,7 +274,22 @@ var options = new RenderOptions
MapRenderer.RenderPng(features, "europe.png", options);
```
-snippet source | anchor
+snippet source | anchor
+
+```cs
+var features = GeoConverter.Read("countries.geojson");
+
+// Render a specific bounding box (min lon, min lat, max lon, max lat) to a PNG.
+var options = new RenderOptions
+{
+ Bounds = new Envelope(-10, 35, 30, 60),
+ Width = 1200,
+ Height = 900,
+};
+
+MapRenderer.RenderPng(features, "europe.png", options);
+```
+snippet source | anchor
`RenderOptions` controls the extent (`Bounds`), pixel `Width`/`Height` (height is derived from the aspect ratio when left at 0), `Padding`, and the `Background`/`Stroke`/`Fill` colors. From the command line, output a `.png` and pass `--bbox` and `--size`:
@@ -244,7 +347,29 @@ File.WriteAllText("europe.svg", markup);
// Or write straight to a file / stream.
MapRenderer.RenderSvg(features, "europe.svg", options);
```
-snippet source | anchor
+snippet source | anchor
+
+```cs
+var features = GeoConverter.Read("countries.geojson");
+
+// SVG is a vector export: same RenderOptions as PNG (bounds, size, projection, colours,
+// labels), but geometry becomes // and labels become native
+// , so the output scales crisply at any zoom. RenderSvg returns the markup as a
+// string; the path/stream overloads write it out directly.
+var options = new RenderOptions
+{
+ Bounds = new Envelope(-10, 35, 30, 60),
+ Width = 1200,
+ Height = 900,
+};
+
+var markup = MapRenderer.RenderSvg(features, options);
+File.WriteAllText("europe.svg", markup);
+
+// Or write straight to a file / stream.
+MapRenderer.RenderSvg(features, "europe.svg", options);
+```
+snippet source | anchor
The same `RenderOptions` knobs apply (the format-specific `RenderOptions.Png` and `RenderOptions.Svg` sub-options are the exception — `Png` is ignored for SVG output and `Svg` for PNG). Because labels are emitted as native ``, their glyph shapes depend on the fonts available to the viewer; placement and collision are identical to the PNG renderer (which reserves boxes from the hand-rolled stroke font's metrics). From the command line, output a `.svg` and pass `--bbox`/`--size` exactly as for PNG:
@@ -272,7 +397,22 @@ var options = new RenderOptions
MapRenderer.RenderSvg(features, "world.svg", options);
```
-snippet source | anchor
+snippet source | anchor
+
+```cs
+var options = new RenderOptions
+{
+ Bounds = MapRenderer.WebMercatorWorldBounds,
+ Width = 1024,
+ // Half a pixel: invisible at this render size, but collapses the dense sub-pixel
+ // detail that otherwise bloats the file. A world borders layer drops from ~109 MB
+ // to ~16 MB. The matching PNG render is unaffected (Svg options are SVG-only).
+ Svg = new() { SimplifyTolerance = 0.5 },
+};
+
+MapRenderer.RenderSvg(features, "world.svg", options);
+```
+snippet source | anchor
Because the pass runs after projection and the `MinFeaturePixels` selection, it only thins geometry that is actually being drawn; raising the tolerance further yields diminishing returns once the per-vertex spacing drops below a pixel.
@@ -309,7 +449,23 @@ var options = new RenderOptions
MapRenderer.RenderPng(features, "world.png", options);
```
-snippet source | anchor
+snippet source | anchor
+
+```cs
+var features = GeoConverter.Read("countries.geojson");
+
+// Web Mercator matches the layout of standard web tile maps. Pair it with
+// MapRenderer.WebMercatorWorldBounds for the canonical 1:1 square world view; latitude is
+// clamped to ±85.0511° (the cutoff every tile provider uses).
+var options = new RenderOptions
+{
+ Bounds = MapRenderer.WebMercatorWorldBounds,
+ Projection = MapProjection.WebMercator,
+};
+
+MapRenderer.RenderPng(features, "world.png", options);
+```
+snippet source | anchor
From the command line, pass `--projection`:
@@ -335,7 +491,22 @@ var options = new RenderOptions
MapRenderer.RenderPng(features, "states.png", options);
```
-snippet source | anchor
+snippet source | anchor
+
+```cs
+var features = GeoConverter.Read("states.geojson");
+
+// Lambert Conformal Conic with standard parallels picked from the data bounds — the textbook
+// choice for state/country-scale maps. Conformal and low-distortion across a regional extent,
+// so this avoids both plate-carrée's high-latitude squish and Web Mercator's pole stretch.
+var options = new RenderOptions
+{
+ Projection = MapProjection.Lambert,
+};
+
+MapRenderer.RenderPng(features, "states.png", options);
+```
+snippet source | anchor
```
@@ -364,7 +535,27 @@ var options = new RenderOptions
MapRenderer.RenderPng(features, "world.png", options);
```
-snippet source | anchor
+snippet source | anchor
+
+```cs
+var features = GeoConverter.Read("countries.geojson");
+
+// Goode's Homolosine (interrupted into 2 northern and 4 southern lobes along ocean
+// meridians, the conventional layout): equal-area, so areas at high latitudes don't blow
+// up like they do under Web Mercator or compress like they do under plate carrée, and the
+// lobe interrupts keep distortion low on every continent. This is what MapProjection.Auto
+// picks for a world map, so the explicit Projection assignment is only needed when you
+// want the specific extent — leaving it off and letting Auto pick produces the same result.
+// Ocean fills each lobe under the continents so the projection's lobed shape (and the
+// inter-lobe gaps) reads clearly.
+var options = new RenderOptions
+{
+ Projection = MapProjection.Goode,
+};
+
+MapRenderer.RenderPng(features, "world.png", options);
+```
+snippet source | anchor
```
@@ -423,7 +614,52 @@ var options = new RenderOptions
MapRenderer.RenderPng(basemap, "europe.png", options);
```
-snippet source | anchor
+snippet source | anchor
+
+```cs
+// A FeatureCollection with named sub-layers — the renderer walks the tree depth-first, so a
+// parent layer paints under its children. RenderOptions.LayerStyle picks per-layer colors;
+// any property left null falls back to the defaults on RenderOptions.
+var basemap = new FeatureCollection
+{
+ Name = "basemap"
+};
+basemap.Add(
+ new Feature(
+ new Polygon(
+ [
+ [new(-10, 35), new(30, 35), new(30, 60), new(-10, 60), new(-10, 35)],
+ ])));
+
+var roads = new FeatureCollection
+{
+ Name = "roads"
+};
+roads.Add(new Feature(new LineString([new(0, 40), new(20, 55)])));
+basemap.Children.Add(roads);
+
+var options = new RenderOptions
+{
+ Bounds = new Envelope(-10, 35, 30, 60),
+ LayerStyle = layer => layer.Name switch
+ {
+ "basemap" => new()
+ {
+ Fill = new(230, 230, 230),
+ Stroke = new(180, 180, 180),
+ },
+ "roads" => new()
+ {
+ Stroke = new(200, 60, 60),
+ StrokeWidth = 3,
+ },
+ _ => null,
+ },
+};
+
+MapRenderer.RenderPng(basemap, "europe.png", options);
+```
+snippet source | anchor
When the layers come from independent sources (typically a basemap file plus an overlay file), pass the collections as a list — they render in order, first under, last on top. Each `FeatureCollection` is a top-level layer for `RenderOptions.LayerStyle`, and the rendered extent defaults to the union of every input's bounds:
@@ -461,7 +697,40 @@ var options = new RenderOptions
MapRenderer.RenderPng([basemap, roads], "stacked.png", options);
```
-snippet source | anchor
+snippet source | anchor
+
+```cs
+// When the layers come from independent sources (a basemap file plus an overlay file, say),
+// pass them as a list — they render in order, first under, last on top. Each FeatureCollection
+// is a top-level layer for RenderOptions.LayerStyle, so giving each one a Name is enough to
+// style them distinctly. When Bounds is null the rendered extent is the union of every input.
+var basemap = GeoConverter.Read("countries.geojson");
+basemap.Name = "basemap";
+
+var roads = GeoConverter.Read("roads.shp");
+roads.Name = "roads";
+
+var options = new RenderOptions
+{
+ LayerStyle = layer => layer.Name switch
+ {
+ "basemap" => new()
+ {
+ Fill = new(230, 230, 230),
+ Stroke = new(180, 180, 180),
+ },
+ "roads" => new()
+ {
+ Stroke = new(200, 60, 60),
+ StrokeWidth = 3,
+ },
+ _ => null,
+ },
+};
+
+MapRenderer.RenderPng([basemap, roads], "stacked.png", options);
+```
+snippet source | anchor
@@ -507,7 +776,34 @@ var options = new RenderOptions
};
MapRenderer.RenderPng(features, "europe-halo.png", options);
```
-snippet source | anchor
+snippet source | anchor
+
+```cs
+// Halo treatment: every glyph stroke is first drawn in the halo colour at a slightly
+// wider stroke, so the foreground text reads against busy fills as if outlined. The
+// halo extends 2 px past the foreground stroke on every side; that's enough to lift
+// text off most country-fill colours but a thin border line can still bleed through
+// the ring on dense political maps. Default halo is a semi-transparent white, which
+// works for dark text on light backgrounds out of the box; pass null to disable.
+var features = GeoConverter.Read("countries.geojson");
+var options = new RenderOptions
+{
+ Bounds = new(-12, 35, 32, 60),
+ Width = 800,
+ Projection = MapProjection.Lambert,
+ Background = new(245, 245, 245),
+ Fill = new(220, 220, 210),
+ Stroke = new(120, 120, 120),
+ StrokeWidth = 1,
+ Label = feature =>
+ feature.Properties.TryGetValue("NAME", out var value) ? value as string : null,
+ LabelSize = 14,
+ LabelColor = new(30, 30, 30),
+ LabelHalo = new(255, 255, 255, 220),
+};
+MapRenderer.RenderPng(features, "europe-halo.png", options);
+```
+snippet source | anchor
@@ -544,7 +840,35 @@ var options = new RenderOptions
};
MapRenderer.RenderPng(features, "europe-knockout.png", options);
```
-snippet source | anchor
+snippet source | anchor
+
+```cs
+// Knockout treatment: before the halo and text strokes, a solid rect of the knockout
+// colour is painted over the label's bounding box. The geometry underneath is fully
+// erased (opaque colour) or dimmed (semi-transparent), so country borders don't bleed
+// through the way they can with a halo ring. Typically set to match Background for a
+// clean masked look; pair with LabelHalo = null for a flat rectangle, or leave the
+// halo on for a knockout-rect with an outline around the text.
+var features = GeoConverter.Read("countries.geojson");
+var options = new RenderOptions
+{
+ Bounds = new(-12, 35, 32, 60),
+ Width = 800,
+ Projection = MapProjection.Lambert,
+ Background = new(245, 245, 245),
+ Fill = new(220, 220, 210),
+ Stroke = new(120, 120, 120),
+ StrokeWidth = 1,
+ Label = feature =>
+ feature.Properties.TryGetValue("NAME", out var value) ? value as string : null,
+ LabelSize = 14,
+ LabelColor = new(30, 30, 30),
+ LabelHalo = null,
+ LabelKnockout = new(245, 245, 245),
+};
+MapRenderer.RenderPng(features, "europe-knockout.png", options);
+```
+snippet source | anchor
@@ -610,7 +934,66 @@ options.LabelPriority = feature =>
return 0;
};
```
-snippet source | anchor
+snippet source | anchor
+
+```cs
+// Label every feature with its "name" property. Polygon/line labels sit on the
+// centroid / arclength midpoint; point labels walk Imhof's 8-position candidate ring
+// around the dot (NE → NW → SE → SW → E → W → N → S) so the label doesn't paint on
+// top of the point marker. Collision and off-canvas rejection drop labels silently.
+// The single-stroke vector font handles printable ASCII plus the Latin diacritics that
+// decompose to an ASCII base + combining mark (grave, acute, circumflex, tilde,
+// diaeresis, ring, caron, cedilla); ligatures like ß, æ, ø and the non-Latin blocks
+// render as '?'. LabelSize is the cap height in pixels — the font scales continuously,
+// so any positive value works (12–16 for 2k canvases, 20+ for high-res).
+var features = GeoConverter.Read("cities.geojson");
+
+var options = new RenderOptions
+{
+ Label = feature =>
+ feature.Properties.TryGetValue("name", out var value) ? value as string : null,
+ LabelSize = 18,
+ LabelColor = new(20, 20, 20),
+ LabelHalo = new(255, 255, 255, 220),
+};
+
+MapRenderer.RenderPng(features, "cities.png", options);
+
+// Per-layer override: a child layer can carry its own label callback (or scale/color/halo)
+// independent of the options-wide default. Setting Label = _ => null on a LayerStyle
+// suppresses labelling for that layer.
+options.LayerStyle = layer => layer.Name == "annotations"
+ ? new LayerStyle { Label = feature => feature.Properties["text"] as string }
+ : null;
+
+// By default, labels are placed largest-feature-first so when two collide the bigger
+// polygon's name wins. Override LabelPriority to drive collision order from anything
+// else — a feature property like population, or an external lookup captured in the
+// closure. Without this, Natural Earth's "Ireland" would beat "United Kingdom" on file
+// order; with population priority, UK (67M) outranks Ireland (5M) and gets the spot.
+options.LabelPriority = feature =>
+ feature.Properties.TryGetValue("POP_EST", out var p) ? Convert.ToDouble(p) : 0;
+
+// Or look priorities up in a separate table — useful when the data and the importance
+// ranking live in different files.
+var populations = new Dictionary
+{
+ ["United Kingdom"] = 67_000_000,
+ ["Ireland"] = 5_000_000,
+};
+options.LabelPriority = feature =>
+{
+ if (feature.Properties.TryGetValue("NAME", out var name) &&
+ name is string n &&
+ populations.TryGetValue(n, out var pop))
+ {
+ return pop;
+ }
+
+ return 0;
+};
+```
+snippet source | anchor
@@ -652,7 +1035,33 @@ using (var parquet = File.Create("world.parquet"))
GeoParquet.Write(parquet, features, ParquetCompression.Gzip, CompressionLevel.SmallestSize);
}
```
-snippet source | anchor
+snippet source | anchor
+
+```cs
+// PNG: the deflate level for the IDAT chunk is exposed on RenderOptions.Png.
+MapRenderer.RenderPng(
+ features,
+ "world.png",
+ new()
+ {
+ Bounds = MapRenderer.WebMercatorWorldBounds,
+ Projection = MapProjection.WebMercator,
+ Png = new() { Compression = CompressionLevel.Fastest },
+ });
+
+// KMZ: the doc.kml zip entry's compression level is an optional Write argument.
+using (var kmz = File.Create("world.kmz"))
+{
+ Kmz.Write(kmz, features, CompressionLevel.SmallestSize);
+}
+
+// GeoParquet: pick the codec (default Snappy); CompressionLevel only applies to Gzip.
+using (var parquet = File.Create("world.parquet"))
+{
+ GeoParquet.Write(parquet, features, ParquetCompression.Gzip, CompressionLevel.SmallestSize);
+}
+```
+snippet source | anchor
@@ -741,7 +1150,41 @@ foreach (var feature in root)
Console.WriteLine(feature.Geometry);
}
```
-snippet source | anchor
+snippet source | anchor
+
+```cs
+// A FeatureCollection can hold nested child layers, each with its own Name. Formats with a
+// native layer concept (KML folders, TopoJSON objects, KMZ documents, GPX wpt/rte/trk,
+// Shapefile bundle directories) round-trip this structure; everything else flattens via the
+// recursive enumerator.
+var cities = new FeatureCollection
+{
+ Name = "cities"
+};
+cities.Add(new Feature(new Point(new(151.21, -33.87))));
+
+var roads = new FeatureCollection
+{
+ Name = "roads"
+};
+roads.Add(new Feature(new LineString([new(151.20, -33.86), new(151.22, -33.88)])));
+
+var root = new FeatureCollection
+{
+ Name = "sydney"
+};
+root.Children.Add(cities);
+root.Children.Add(roads);
+
+GeoConverter.Write(root, "sydney.kml"); // emits … …
+
+// Single-layer formats just flatten — iterating any collection always yields every feature.
+foreach (var feature in root)
+{
+ Console.WriteLine(feature.Geometry);
+}
+```
+snippet source | anchor
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index fdb2abc..da3e265 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -16,4 +16,18 @@
+
+
+
+
+
+
diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props
index 38b11dd..79b32f3 100644
--- a/src/Directory.Packages.props
+++ b/src/Directory.Packages.props
@@ -19,6 +19,7 @@
+
diff --git a/src/GeoConvert.App.Tests/DiffRenderTests.Overlay.verified.png b/src/GeoConvert.App.Tests/DiffRenderTests.Overlay.verified.png
new file mode 100644
index 0000000..8cbc493
Binary files /dev/null and b/src/GeoConvert.App.Tests/DiffRenderTests.Overlay.verified.png differ
diff --git a/src/GeoConvert.App.Tests/DiffRenderTests.SideBySide.verified.png b/src/GeoConvert.App.Tests/DiffRenderTests.SideBySide.verified.png
new file mode 100644
index 0000000..9340e4c
Binary files /dev/null and b/src/GeoConvert.App.Tests/DiffRenderTests.SideBySide.verified.png differ
diff --git a/src/GeoConvert.App.Tests/DiffRenderTests.cs b/src/GeoConvert.App.Tests/DiffRenderTests.cs
new file mode 100644
index 0000000..fced73b
--- /dev/null
+++ b/src/GeoConvert.App.Tests/DiffRenderTests.cs
@@ -0,0 +1,22 @@
+namespace GeoConvert.App.Tests;
+
+public class DiffRenderTests
+{
+ // A small fixed pixel size keeps the snapshots compact and deterministic.
+ static RenderSettings Settings() =>
+ new() { MaxDimension = 0, Width = 400, Height = 0 };
+
+ [Test]
+ public Task Overlay() =>
+ VerifyDiff(DiffMode.Overlay);
+
+ [Test]
+ public Task SideBySide() =>
+ VerifyDiff(DiffMode.SideBySide);
+
+ static Task VerifyDiff(DiffMode mode)
+ {
+ var png = MapDiff.Render(SampleMaps.A(), SampleMaps.B(), Settings(), mode, MapDiff.DefaultColorA, MapDiff.DefaultColorB);
+ return Verify(Images.DecodePng(png)).UseParameters(mode);
+ }
+}
diff --git a/src/GeoConvert.App.Tests/DiffSummaryTests.Summarize.verified.txt b/src/GeoConvert.App.Tests/DiffSummaryTests.Summarize.verified.txt
new file mode 100644
index 0000000..3c08392
--- /dev/null
+++ b/src/GeoConvert.App.Tests/DiffSummaryTests.Summarize.verified.txt
@@ -0,0 +1,19 @@
+Map A: a.geojson
+ Features: 3
+ Layers: 1
+ Bounds: 0, 0 .. 20, 12
+ Geometry: Polygon 2, LineString 1
+ Properties: name, pop
+
+Map B: b.geojson
+ Features: 2
+ Layers: 1
+ Bounds: 1, 1 .. 16, 11
+ Geometry: Polygon 1, Point 1
+ Properties: iso, name, pop
+
+Differences:
+ Features: -1
+ Geometry: LineString -1, Point +1, Polygon -1
+ Properties only in A: (none)
+ Properties only in B: iso
diff --git a/src/GeoConvert.App.Tests/DiffSummaryTests.cs b/src/GeoConvert.App.Tests/DiffSummaryTests.cs
new file mode 100644
index 0000000..6da7db6
--- /dev/null
+++ b/src/GeoConvert.App.Tests/DiffSummaryTests.cs
@@ -0,0 +1,8 @@
+namespace GeoConvert.App.Tests;
+
+public class DiffSummaryTests
+{
+ [Test]
+ public Task Summarize() =>
+ Verify(MapDiff.Summarize("a.geojson", SampleMaps.A(), "b.geojson", SampleMaps.B()));
+}
diff --git a/src/GeoConvert.App.Tests/FormsTests.About_dpiPercent=100.verified.png b/src/GeoConvert.App.Tests/FormsTests.About_dpiPercent=100.verified.png
new file mode 100644
index 0000000..5352696
Binary files /dev/null and b/src/GeoConvert.App.Tests/FormsTests.About_dpiPercent=100.verified.png differ
diff --git a/src/GeoConvert.App.Tests/FormsTests.About_dpiPercent=150.verified.png b/src/GeoConvert.App.Tests/FormsTests.About_dpiPercent=150.verified.png
new file mode 100644
index 0000000..d556968
Binary files /dev/null and b/src/GeoConvert.App.Tests/FormsTests.About_dpiPercent=150.verified.png differ
diff --git a/src/GeoConvert.App.Tests/FormsTests.DiffWindow_dpiPercent=100.verified.png b/src/GeoConvert.App.Tests/FormsTests.DiffWindow_dpiPercent=100.verified.png
new file mode 100644
index 0000000..bf64d6d
Binary files /dev/null and b/src/GeoConvert.App.Tests/FormsTests.DiffWindow_dpiPercent=100.verified.png differ
diff --git a/src/GeoConvert.App.Tests/FormsTests.DiffWindow_dpiPercent=150.verified.png b/src/GeoConvert.App.Tests/FormsTests.DiffWindow_dpiPercent=150.verified.png
new file mode 100644
index 0000000..9f381e7
Binary files /dev/null and b/src/GeoConvert.App.Tests/FormsTests.DiffWindow_dpiPercent=150.verified.png differ
diff --git a/src/GeoConvert.App.Tests/FormsTests.MainWindow_dpiPercent=100.verified.png b/src/GeoConvert.App.Tests/FormsTests.MainWindow_dpiPercent=100.verified.png
new file mode 100644
index 0000000..dc0af64
Binary files /dev/null and b/src/GeoConvert.App.Tests/FormsTests.MainWindow_dpiPercent=100.verified.png differ
diff --git a/src/GeoConvert.App.Tests/FormsTests.MainWindow_dpiPercent=150.verified.png b/src/GeoConvert.App.Tests/FormsTests.MainWindow_dpiPercent=150.verified.png
new file mode 100644
index 0000000..19531d8
Binary files /dev/null and b/src/GeoConvert.App.Tests/FormsTests.MainWindow_dpiPercent=150.verified.png differ
diff --git a/src/GeoConvert.App.Tests/FormsTests.cs b/src/GeoConvert.App.Tests/FormsTests.cs
new file mode 100644
index 0000000..6813f99
--- /dev/null
+++ b/src/GeoConvert.App.Tests/FormsTests.cs
@@ -0,0 +1,42 @@
+namespace GeoConvert.App.Tests;
+
+[NotInParallel]
+public class FormsTests
+{
+ // Each window is snapshotted at 100% and 150% scale so DPI-only layout breaks (fixed-pixel sizes that
+ // don't scale with the font, as the diff window's input rows once did) are caught.
+ [Test]
+ [Arguments(100)]
+ [Arguments(150)]
+ public Task MainWindow(int dpiPercent) =>
+ // The whole main window: menu, the "no map loaded" bar, the (empty) preview and the fixed-width
+ // options column on the right.
+ Verify(WinFormsSnapshot.Render(() => new MainForm(SeededSettings(), null), 1000, 680, dpiPercent / 100f))
+ .UseParameters(dpiPercent);
+
+ [Test]
+ [Arguments(100)]
+ [Arguments(150)]
+ public Task DiffWindow(int dpiPercent) =>
+ // The empty compare window: the two file pickers, the mode/projection/colour toolbar, and the
+ // (empty) preview / summary panes.
+ Verify(WinFormsSnapshot.Render(() => new DiffForm(), 1000, 680, dpiPercent / 100f))
+ .UseParameters(dpiPercent);
+
+ [Test]
+ [Arguments(100)]
+ [Arguments(150)]
+ public Task About(int dpiPercent) =>
+ // The (auto-sizing) About dialog: title, description, the clickable project link and OK button.
+ Verify(WinFormsSnapshot.Render(() => new AboutForm(), 420, 220, dpiPercent / 100f))
+ .UseParameters(dpiPercent);
+
+ static SettingsManager SeededSettings()
+ {
+ // Pre-mark the first-run prompt as shown so the briefly-shown MainForm doesn't pop the (blocking)
+ // association MessageBox. A throwaway temp path keeps it away from the real user settings.
+ var manager = new SettingsManager(Path.Combine(Path.GetTempPath(), "GeoConvert.App.Tests", "settings.json"));
+ manager.Write(new() { AssociationsPrompted = true });
+ return manager;
+ }
+}
diff --git a/src/GeoConvert.App.Tests/GeoConvert.App.Tests.csproj b/src/GeoConvert.App.Tests/GeoConvert.App.Tests.csproj
new file mode 100644
index 0000000..92c9d55
--- /dev/null
+++ b/src/GeoConvert.App.Tests/GeoConvert.App.Tests.csproj
@@ -0,0 +1,26 @@
+
+
+
+
+ net11.0-windows
+ Exe
+ true
+ false
+ true
+
+ $(NoWarn);CA1416
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/GeoConvert.App.Tests/GlobalUsings.cs b/src/GeoConvert.App.Tests/GlobalUsings.cs
new file mode 100644
index 0000000..66083f3
--- /dev/null
+++ b/src/GeoConvert.App.Tests/GlobalUsings.cs
@@ -0,0 +1,5 @@
+global using System.Drawing.Imaging;
+global using GeoConvert;
+global using GeoConvert.App;
+global using TUnit.Core;
+global using static VerifyTUnit.Verifier;
diff --git a/src/GeoConvert.App.Tests/ModuleInitializer.cs b/src/GeoConvert.App.Tests/ModuleInitializer.cs
new file mode 100644
index 0000000..5b6022d
--- /dev/null
+++ b/src/GeoConvert.App.Tests/ModuleInitializer.cs
@@ -0,0 +1,22 @@
+public static class ModuleInitializer
+{
+ [ModuleInitializer]
+ public static void Init()
+ {
+ // Verify.WinForms snapshots WinForms UI as images. Its Control converter renders via a live
+ // message loop, which deadlocks headless, so the tests render each control to a Bitmap on an STA
+ // thread (see WinFormsSnapshot) and snapshot that. This converter writes a Bitmap out as a PNG so
+ // Verify treats it as an image; SSIM comparison then tolerates the small machine-dependent pixel
+ // differences inherent in rendering WinForms to a bitmap.
+ VerifyWinForms.Initialize();
+ VerifierSettings.UseSsimForPng();
+ VerifierSettings.RegisterFileConverter(
+ (bitmap, _) =>
+ {
+ var stream = new MemoryStream();
+ bitmap.Save(stream, ImageFormat.Png);
+ stream.Position = 0;
+ return new(null, "png", stream, null);
+ });
+ }
+}
diff --git a/src/GeoConvert.App.Tests/OptionsPanelTests.Kml_dpiPercent=100.verified.png b/src/GeoConvert.App.Tests/OptionsPanelTests.Kml_dpiPercent=100.verified.png
new file mode 100644
index 0000000..5aaadaa
Binary files /dev/null and b/src/GeoConvert.App.Tests/OptionsPanelTests.Kml_dpiPercent=100.verified.png differ
diff --git a/src/GeoConvert.App.Tests/OptionsPanelTests.Kml_dpiPercent=150.verified.png b/src/GeoConvert.App.Tests/OptionsPanelTests.Kml_dpiPercent=150.verified.png
new file mode 100644
index 0000000..0985253
Binary files /dev/null and b/src/GeoConvert.App.Tests/OptionsPanelTests.Kml_dpiPercent=150.verified.png differ
diff --git a/src/GeoConvert.App.Tests/OptionsPanelTests.Png_dpiPercent=100.verified.png b/src/GeoConvert.App.Tests/OptionsPanelTests.Png_dpiPercent=100.verified.png
new file mode 100644
index 0000000..10b973c
Binary files /dev/null and b/src/GeoConvert.App.Tests/OptionsPanelTests.Png_dpiPercent=100.verified.png differ
diff --git a/src/GeoConvert.App.Tests/OptionsPanelTests.Png_dpiPercent=150.verified.png b/src/GeoConvert.App.Tests/OptionsPanelTests.Png_dpiPercent=150.verified.png
new file mode 100644
index 0000000..60b7020
Binary files /dev/null and b/src/GeoConvert.App.Tests/OptionsPanelTests.Png_dpiPercent=150.verified.png differ
diff --git a/src/GeoConvert.App.Tests/OptionsPanelTests.cs b/src/GeoConvert.App.Tests/OptionsPanelTests.cs
new file mode 100644
index 0000000..89dc92d
--- /dev/null
+++ b/src/GeoConvert.App.Tests/OptionsPanelTests.cs
@@ -0,0 +1,33 @@
+namespace GeoConvert.App.Tests;
+
+// WinForms rendering wants a single UI thread, so keep these off the parallel runner.
+[NotInParallel]
+public class OptionsPanelTests
+{
+ // Snapshotted at 100% and 150% scale so DPI-only layout breaks (fixed-pixel sizes that don't scale
+ // with the font) are caught, not just the 96-DPI layout.
+ [Test]
+ [Arguments(100)]
+ [Arguments(150)]
+ public Task Kml(int dpiPercent) =>
+ // A plain vector format: the always-on Projection radios plus the Output, Simplify and
+ // format-note sections (no image options).
+ Verify(WinFormsSnapshot.Render(() => Panel(GeoFormat.Kml), 480, 560, dpiPercent / 100f))
+ .UseParameters(dpiPercent);
+
+ [Test]
+ [Arguments(100)]
+ [Arguments(150)]
+ public Task Png(int dpiPercent) =>
+ // The image formats reveal the full render-options section (projection, strokes, labels, colours)
+ // plus the PNG sub-section, so this covers most of the options UI and its show/hide logic.
+ Verify(WinFormsSnapshot.Render(() => Panel(GeoFormat.Png), 480, 1320, dpiPercent / 100f))
+ .UseParameters(dpiPercent);
+
+ static OptionsPanel Panel(GeoFormat format)
+ {
+ var panel = new OptionsPanel(new(), new(), new(), new());
+ panel.SelectFormat(format);
+ return panel;
+ }
+}
diff --git a/src/GeoConvert.App.Tests/SampleMaps.cs b/src/GeoConvert.App.Tests/SampleMaps.cs
new file mode 100644
index 0000000..7bb049c
--- /dev/null
+++ b/src/GeoConvert.App.Tests/SampleMaps.cs
@@ -0,0 +1,41 @@
+namespace GeoConvert.App.Tests;
+
+/// Small, deterministic in-memory maps used across the snapshot tests.
+static class SampleMaps
+{
+ // Two overlapping squares, a triangle and a line — enough geometry to exercise the renderer, the
+ // diff overlay and the structural summary without depending on any external data file.
+ public static FeatureCollection A() =>
+ [
+ new Feature(
+ new Polygon([[new(0, 0), new(10, 0), new(10, 10), new(0, 10), new(0, 0)]]),
+ Props(("name", "Square"), ("pop", 100L))),
+ new Feature(
+ new Polygon([[new(12, 0), new(20, 0), new(16, 8), new(12, 0)]]),
+ Props(("name", "Tri"))),
+ new Feature(
+ new LineString([new(0, 12), new(20, 12)]),
+ Props(("name", "Road"))),
+ ];
+
+ public static FeatureCollection B() =>
+ [
+ new Feature(
+ new Polygon([[new(1, 1), new(11, 1), new(11, 11), new(1, 11), new(1, 1)]]),
+ Props(("name", "Square"), ("pop", 100L), ("iso", "SQ"))),
+ new Feature(
+ new Point(new(16, 4)),
+ Props(("name", "Dot"), ("iso", "DT"))),
+ ];
+
+ static IDictionary Props(params (string Key, object? Value)[] pairs)
+ {
+ var properties = new Dictionary();
+ foreach (var (key, value) in pairs)
+ {
+ properties[key] = value;
+ }
+
+ return properties;
+ }
+}
diff --git a/src/GeoConvert.App.Tests/WinFormsSnapshot.cs b/src/GeoConvert.App.Tests/WinFormsSnapshot.cs
new file mode 100644
index 0000000..51242fe
--- /dev/null
+++ b/src/GeoConvert.App.Tests/WinFormsSnapshot.cs
@@ -0,0 +1,106 @@
+namespace GeoConvert.App.Tests;
+
+///
+/// Renders a WinForms control to a for Verify to snapshot. WinForms requires an STA
+/// thread, so the control is created, laid out and drawn on a dedicated one; the resulting bitmap is
+/// thread-agnostic and handed back. Forms are briefly shown off-screen so their OnLoad (e.g. the
+/// diff window's splitter layout) runs before the draw; plain controls just get a handle and a layout
+/// pass. Nothing is verified here — the caller passes the bitmap to Verify.
+///
+/// A scale > 1 simulates a higher display DPI: WinForms applies DPI scaling through
+/// , so calling it directly reproduces the same scaling behaviour
+/// (including the class of bug where fixed-pixel sizes don't scale while font-sized controls do) without
+/// needing an actual high-DPI monitor. scale 1.25 == 120 DPI / 125%, scale 1.5 == 144 DPI / 150%.
+///
+///
+static class WinFormsSnapshot
+{
+ static bool stylesEnabled;
+
+ public static Bitmap Render(Func factory, int width, int height, float scale = 1f)
+ {
+ Bitmap? result = null;
+ Exception? failure = null;
+
+ var thread = new Thread(() =>
+ {
+ try
+ {
+ EnsureStyles();
+ using var control = factory();
+ if (control is Form form)
+ {
+ // Off-screen + off-taskbar so the brief show is invisible; Show() raises OnLoad/OnShown
+ // so docked/split layouts settle before the draw.
+ form.StartPosition = FormStartPosition.Manual;
+ form.ShowInTaskbar = false;
+ form.Location = new(-5000, -5000);
+ // An AutoSize form sizes itself to its content — forcing a size would clip it.
+ if (!form.AutoSize)
+ {
+ form.Size = new(width, height);
+ }
+
+ Rescale(form, scale);
+ form.Show();
+ Application.DoEvents();
+ result = Draw(form);
+ form.Close();
+ }
+ else
+ {
+ control.Size = new(width, height);
+ _ = control.Handle;
+ Rescale(control, scale);
+ control.PerformLayout();
+ Application.DoEvents();
+ result = Draw(control);
+ }
+ }
+ catch (Exception exception)
+ {
+ failure = exception;
+ }
+ });
+ thread.SetApartmentState(ApartmentState.STA);
+ thread.IsBackground = true;
+ thread.Start();
+ thread.Join();
+
+ if (failure != null)
+ {
+ throw failure;
+ }
+
+ return result!;
+ }
+
+ static void Rescale(Control control, float scale)
+ {
+ if (scale != 1f)
+ {
+ control.Scale(new SizeF(scale, scale));
+ }
+ }
+
+ static Bitmap Draw(Control control)
+ {
+ var bounds = control.ClientRectangle;
+ var bitmap = new Bitmap(Math.Max(1, bounds.Width), Math.Max(1, bounds.Height));
+ control.DrawToBitmap(bitmap, bounds);
+ return bitmap;
+ }
+
+ static void EnsureStyles()
+ {
+ if (stylesEnabled)
+ {
+ return;
+ }
+
+ // Match the real app's themed rendering (ApplicationConfiguration.Initialize does this).
+ Application.EnableVisualStyles();
+ Application.SetCompatibleTextRenderingDefault(false);
+ stylesEnabled = true;
+ }
+}
diff --git a/src/GeoConvert.App/Cli.cs b/src/GeoConvert.App/Cli.cs
new file mode 100644
index 0000000..920ead6
--- /dev/null
+++ b/src/GeoConvert.App/Cli.cs
@@ -0,0 +1,471 @@
+namespace GeoConvert.App;
+
+///
+/// The command-line surface of the app, hand-rolled in the same spirit as the geoconvert CLI's
+/// Runner (no third-party parser). It owns the headless commands — diff, the file
+/// association management, and settings — and the usage text. Interactive conversion/rendering is
+/// the GUI's job; the CLI deliberately mirrors only the diff feature plus app management.
+///
+public static class Cli
+{
+ /// A fully-parsed diff invocation. null means "open the diff in
+ /// the GUI" rather than render headlessly.
+ public sealed record DiffRequest(
+ string PathA,
+ string PathB,
+ string? Output,
+ RenderSettings Settings,
+ DiffMode Mode,
+ Rgba ColorA,
+ Rgba ColorB);
+
+ ///
+ /// Parses the arguments after diff. Returns 0 with set on success,
+ /// or 2 on a usage error (message already written to ).
+ ///
+ public static int ParseDiff(string[] args, out DiffRequest? request, TextWriter error)
+ {
+ request = null;
+ var settings = new RenderSettings();
+ var mode = DiffMode.Overlay;
+ var colorA = MapDiff.DefaultColorA;
+ var colorB = MapDiff.DefaultColorB;
+ var positionals = new List();
+
+ for (var i = 0; i < args.Length; i++)
+ {
+ var argument = args[i];
+ switch (argument)
+ {
+ case "--mode":
+ if (!TryNext(args, ref i, error, "--mode", out var modeText))
+ {
+ return 2;
+ }
+
+ if (!TryParseMode(modeText, out mode))
+ {
+ error.WriteLine("--mode must be 'overlay' or 'side-by-side'.");
+ return 2;
+ }
+
+ break;
+ case "--color-a":
+ if (!TryNext(args, ref i, error, "--color-a", out var colorAText) ||
+ !RequireColor(colorAText, "--color-a", error, out colorA))
+ {
+ return 2;
+ }
+
+ break;
+ case "--color-b":
+ if (!TryNext(args, ref i, error, "--color-b", out var colorBText) ||
+ !RequireColor(colorBText, "--color-b", error, out colorB))
+ {
+ return 2;
+ }
+
+ break;
+ case "--bbox":
+ if (!TryNext(args, ref i, error, "--bbox", out var bboxText))
+ {
+ return 2;
+ }
+
+ if (!TryParseBounds(bboxText, out var bounds))
+ {
+ error.WriteLine("--bbox must be 'minX,minY,maxX,maxY'.");
+ return 2;
+ }
+
+ settings.Bounds = bounds;
+ break;
+ case "--size":
+ if (!TryNext(args, ref i, error, "--size", out var sizeText))
+ {
+ return 2;
+ }
+
+ if (!TryParseSize(sizeText, out var width, out var height))
+ {
+ error.WriteLine("--size must be 'WIDTH' or 'WIDTHxHEIGHT'.");
+ return 2;
+ }
+
+ // An explicit pixel size overrides the default fit-to-box.
+ settings.MaxDimension = 0;
+ settings.Width = width;
+ settings.Height = height;
+ break;
+ case "--max-dimension":
+ if (!TryNext(args, ref i, error, "--max-dimension", out var maxText))
+ {
+ return 2;
+ }
+
+ if (!int.TryParse(maxText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var maxDimension) || maxDimension <= 0)
+ {
+ error.WriteLine("--max-dimension must be a positive integer (pixels).");
+ return 2;
+ }
+
+ settings.MaxDimension = maxDimension;
+ break;
+ case "--projection":
+ if (!TryNext(args, ref i, error, "--projection", out var projectionText))
+ {
+ return 2;
+ }
+
+ if (!TryParseProjection(projectionText, out var projection))
+ {
+ error.WriteLine("--projection must be 'auto', 'plate-carree', 'web-mercator', 'lambert', or 'goode'.");
+ return 2;
+ }
+
+ settings.Projection = projection;
+ break;
+ case "--renderer":
+ if (!TryNext(args, ref i, error, "--renderer", out var rendererText))
+ {
+ return 2;
+ }
+
+ if (!TryParseRenderer(rendererText, out var renderer))
+ {
+ error.WriteLine("--renderer must be 'builtin', 'skia', or 'imagesharp'.");
+ return 2;
+ }
+
+ settings.Renderer = renderer;
+ break;
+ default:
+ if (argument.StartsWith('-'))
+ {
+ error.WriteLine($"Unknown option '{argument}'.");
+ return 2;
+ }
+
+ positionals.Add(argument);
+ break;
+ }
+ }
+
+ if (positionals.Count is < 2 or > 3)
+ {
+ error.WriteLine("Usage: geoconvert-app diff [output.png] [options]");
+ return 2;
+ }
+
+ var output = positionals.Count == 3 ? positionals[2] : null;
+ request = new(positionals[0], positionals[1], output, settings, mode, colorA, colorB);
+ return 0;
+ }
+
+ /// Runs a headless diff: renders the diff image to and prints the summary.
+ public static int ExecuteDiff(DiffRequest request, TextWriter output, TextWriter error)
+ {
+ if (!File.Exists(request.PathA))
+ {
+ error.WriteLine($"Input file not found: {request.PathA}");
+ return 1;
+ }
+
+ if (!File.Exists(request.PathB))
+ {
+ error.WriteLine($"Input file not found: {request.PathB}");
+ return 1;
+ }
+
+ try
+ {
+ var a = GeoConverter.Read(request.PathA);
+ var b = GeoConverter.Read(request.PathB);
+
+ output.Write(MapDiff.Summarize(Path.GetFileName(request.PathA), a, Path.GetFileName(request.PathB), b));
+
+ if (request.Output is { } destination)
+ {
+ var image = MapDiff.Render(a, b, request.Settings, request.Mode, request.ColorA, request.ColorB);
+ File.WriteAllBytes(destination, image);
+ output.WriteLine();
+ output.WriteLine($"Diff image ({request.Mode}) written to {destination}.");
+ }
+
+ return 0;
+ }
+ catch (GeoConvertException exception)
+ {
+ error.WriteLine($"Error: {exception.Message}");
+ return 1;
+ }
+ }
+
+ public static int Associate(TextWriter output)
+ {
+ FileAssociations.Associate();
+ output.WriteLine("Bound the supported map formats to GeoConvert:");
+ output.WriteLine($" {string.Join(" ", FileAssociations.Extensions)}");
+ return 0;
+ }
+
+ public static int Unassociate(TextWriter output)
+ {
+ FileAssociations.Unassociate();
+ output.WriteLine("Removed GeoConvert's map file associations.");
+ return 0;
+ }
+
+ public static int PrintSettings(TextWriter output, SettingsManager settingsManager)
+ {
+ output.WriteLine(settingsManager.SettingsPath);
+ if (File.Exists(settingsManager.SettingsPath))
+ {
+ output.WriteLine(File.ReadAllText(settingsManager.SettingsPath));
+ }
+ else
+ {
+ output.WriteLine("No settings file found.");
+ }
+
+ output.WriteLine($"File associations bound: {(FileAssociations.IsAssociated() ? "yes" : "no")}");
+ return 0;
+ }
+
+ // --- parsing helpers (shared shapes with the geoconvert CLI's Runner) ---
+
+ static bool TryNext(string[] args, ref int index, TextWriter error, string option, out string value)
+ {
+ if (index + 1 >= args.Length)
+ {
+ error.WriteLine($"Missing value for {option}.");
+ value = string.Empty;
+ return false;
+ }
+
+ value = args[++index];
+ return true;
+ }
+
+ static bool RequireColor(string text, string option, TextWriter error, out Rgba color)
+ {
+ if (TryParseColor(text, out color))
+ {
+ return true;
+ }
+
+ error.WriteLine($"{option} must be '#RRGGBB' or '#RRGGBBAA'.");
+ return false;
+ }
+
+ static bool TryParseMode(string text, out DiffMode mode)
+ {
+ switch (text.ToLowerInvariant())
+ {
+ case "overlay":
+ mode = DiffMode.Overlay;
+ return true;
+ case "side-by-side":
+ case "sidebyside":
+ case "side":
+ mode = DiffMode.SideBySide;
+ return true;
+ default:
+ mode = default;
+ return false;
+ }
+ }
+
+ static bool TryParseBounds(string text, out Envelope bounds)
+ {
+ bounds = default;
+ var parts = text.Split(',');
+ if (parts.Length != 4)
+ {
+ return false;
+ }
+
+ var values = new double[4];
+ for (var i = 0; i < 4; i++)
+ {
+ if (!double.TryParse(parts[i], NumberStyles.Float, CultureInfo.InvariantCulture, out values[i]))
+ {
+ return false;
+ }
+ }
+
+ bounds = new(values[0], values[1], values[2], values[3]);
+ return true;
+ }
+
+ static bool TryParseSize(string text, out int width, out int height)
+ {
+ width = 0;
+ height = 0;
+ var parts = text.Split('x', 'X');
+ if (parts.Length is < 1 or > 2)
+ {
+ return false;
+ }
+
+ if (!int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out width) || width <= 0)
+ {
+ return false;
+ }
+
+ if (parts.Length == 2 &&
+ (!int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out height) || height <= 0))
+ {
+ return false;
+ }
+
+ return true;
+ }
+
+ static bool TryParseProjection(string text, out MapProjection projection)
+ {
+ switch (text.ToLowerInvariant())
+ {
+ case "auto":
+ case "automatic":
+ projection = MapProjection.Auto;
+ return true;
+ case "plate-carree":
+ case "platecarree":
+ case "equirectangular":
+ projection = MapProjection.PlateCarree;
+ return true;
+ case "web-mercator":
+ case "webmercator":
+ case "mercator":
+ projection = MapProjection.WebMercator;
+ return true;
+ case "lambert":
+ case "lambert-conformal":
+ case "lambert-conformal-conic":
+ case "lcc":
+ projection = MapProjection.Lambert;
+ return true;
+ case "goode":
+ case "homolosine":
+ case "goode-homolosine":
+ projection = MapProjection.Goode;
+ return true;
+ default:
+ projection = default;
+ return false;
+ }
+ }
+
+ static bool TryParseRenderer(string text, out RendererBackend renderer)
+ {
+ switch (text.ToLowerInvariant())
+ {
+ case "builtin":
+ case "built-in":
+ case "default":
+ renderer = RendererBackend.BuiltIn;
+ return true;
+ case "skia":
+ case "skiasharp":
+ renderer = RendererBackend.Skia;
+ return true;
+ case "imagesharp":
+ case "image-sharp":
+ case "sixlabors":
+ renderer = RendererBackend.ImageSharp;
+ return true;
+ default:
+ renderer = default;
+ return false;
+ }
+ }
+
+ static bool TryParseColor(string text, out Rgba color)
+ {
+ color = default;
+ if (text.Length < 7 || text[0] != '#')
+ {
+ return false;
+ }
+
+ var hex = text.AsSpan(1);
+ if (hex.Length != 6 && hex.Length != 8)
+ {
+ return false;
+ }
+
+ if (!byte.TryParse(hex.Slice(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var r) ||
+ !byte.TryParse(hex.Slice(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var g) ||
+ !byte.TryParse(hex.Slice(4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var b))
+ {
+ return false;
+ }
+
+ byte a = 255;
+ if (hex.Length == 8 &&
+ !byte.TryParse(hex.Slice(6, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out a))
+ {
+ return false;
+ }
+
+ color = new(r, g, b, a);
+ return true;
+ }
+
+ public static void PrintUsage(TextWriter writer) =>
+ writer.WriteLine(
+ """
+ geoconvert-app - a desktop map converter, renderer and diff tool.
+
+ Run with no arguments to open the window. Pass a map file to open it directly.
+
+ Usage:
+ geoconvert-app Open the app.
+ geoconvert-app Open the app with a map loaded.
+ geoconvert-app diff [output.png] [options]
+ Compare two maps. With an output path the diff is
+ rendered headlessly and a summary is printed; without
+ one the diff opens in the window.
+ geoconvert-app associate Bind the supported map formats to this app.
+ geoconvert-app unassociate Remove those file associations.
+ geoconvert-app settings Show the settings file and association state.
+ geoconvert-app --list List supported formats.
+ geoconvert-app --help Show this help.
+
+ diff options:
+ --mode overlay|side-by-side Overlay both maps on one canvas (default) or place them
+ side by side at a shared extent.
+ --color-a <#hex> Colour for the first map (default red).
+ --color-b <#hex> Colour for the second map (default blue).
+ --bbox minX,minY,maxX,maxY Extent to render (defaults to the union of both maps).
+ --size WIDTH[xHEIGHT] Image size in pixels.
+ --max-dimension Cap the longer edge at this many pixels (fit-to-box).
+ --projection auto | plate-carree | web-mercator | lambert | goode.
+ --renderer builtin (default), skia or imagesharp (PNG only).
+
+ Examples:
+ geoconvert-app world.geojson
+ geoconvert-app diff before.geojson after.geojson changes.png
+ geoconvert-app diff a.kml b.kml diff.png --mode side-by-side --size 1600
+ """);
+
+ public static void PrintFormats(TextWriter writer) =>
+ writer.WriteLine(
+ """
+ Supported formats:
+ geojson .geojson .json (read/write)
+ topojson .topojson (read/write)
+ shapefile .shp (+ .shx .dbf .prj) (read/write)
+ flatgeobuf .fgb (read/write)
+ kml .kml (read/write)
+ kmz .kmz (read/write)
+ gpx .gpx (read/write)
+ wkt .wkt (read/write)
+ wkb .wkb (read/write)
+ csv .csv (read/write)
+ geoparquet .parquet .geoparquet (read/write)
+ png .png (render output)
+ svg .svg (render output)
+ """);
+}
diff --git a/src/GeoConvert.App/ConversionService.cs b/src/GeoConvert.App/ConversionService.cs
new file mode 100644
index 0000000..c0ff5a6
--- /dev/null
+++ b/src/GeoConvert.App/ConversionService.cs
@@ -0,0 +1,223 @@
+namespace GeoConvert.App;
+
+///
+/// Wraps and the renderers for the desktop app — the counterpart of the
+/// Blazor app's conversion service, but path/stream based and filesystem-aware, so the path-only
+/// Shapefile is a first-class format here (the browser version had to exclude it). Everything the GUI
+/// and the CLI do — detect, read, simplify, write, render, choose a PNG backend — funnels through here.
+///
+public static class ConversionService
+{
+ static IReadOnlyList AllFormats { get; } =
+ [
+ new(GeoFormat.GeoJson, "GeoJSON", ".geojson", [".geojson", ".json"], CanRead: true, CanWrite: true),
+ new(GeoFormat.TopoJson, "TopoJSON", ".topojson", [".topojson"], CanRead: true, CanWrite: true),
+ new(GeoFormat.Shapefile, "Shapefile", ".shp", [".shp"], CanRead: true, CanWrite: true),
+ new(GeoFormat.FlatGeobuf, "FlatGeobuf", ".fgb", [".fgb"], CanRead: true, CanWrite: true),
+ new(GeoFormat.Kml, "KML", ".kml", [".kml"], CanRead: true, CanWrite: true),
+ new(GeoFormat.Kmz, "KMZ", ".kmz", [".kmz"], CanRead: true, CanWrite: true),
+ new(GeoFormat.Gpx, "GPX", ".gpx", [".gpx"], CanRead: true, CanWrite: true),
+ new(GeoFormat.Wkt, "WKT", ".wkt", [".wkt"], CanRead: true, CanWrite: true),
+ new(GeoFormat.Wkb, "WKB", ".wkb", [".wkb"], CanRead: true, CanWrite: true),
+ new(GeoFormat.Csv, "CSV", ".csv", [".csv"], CanRead: true, CanWrite: true),
+ new(GeoFormat.GeoParquet, "GeoParquet", ".parquet", [".parquet", ".geoparquet"], CanRead: true, CanWrite: true),
+ new(GeoFormat.Png, "PNG image", ".png", [".png"], CanRead: false, CanWrite: true),
+ new(GeoFormat.Svg, "SVG image", ".svg", [".svg"], CanRead: false, CanWrite: true),
+ ];
+
+ public static IReadOnlyList Formats => AllFormats;
+
+ /// Formats that can be read into features (everything except the write-only images).
+ public static IReadOnlyList ReadableFormats { get; } = [.. AllFormats.Where(_ => _.CanRead)];
+
+ /// Formats that can be written (every format, including the render-only PNG/SVG).
+ public static IReadOnlyList WritableFormats { get; } = [.. AllFormats.Where(_ => _.CanWrite)];
+
+ /// Every distinct extension across the readable formats — used for the open dialog and file associations.
+ public static IReadOnlyList ReadableExtensions { get; } =
+ [.. ReadableFormats.SelectMany(_ => _.Extensions).Distinct(StringComparer.OrdinalIgnoreCase)];
+
+ /// The write-only image formats whose write is a render (projection + size apply), not a plain codec write.
+ public static bool IsRendered(GeoFormat format) =>
+ format is GeoFormat.Png or GeoFormat.Svg;
+
+ public static FormatInfo? Find(GeoFormat format) =>
+ AllFormats.FirstOrDefault(_ => _.Format == format);
+
+ /// Infers the format of a file name, or null when the extension is unknown.
+ public static FormatInfo? Detect(string fileName) =>
+ GeoConverter.TryDetectFormat(fileName, out var format) ? Find(format) : null;
+
+ public static FeatureCollection Read(string path, GeoFormat format, IProgress? progress = null) =>
+ // GeoConverter.Read is path-based and already special-cases Shapefile's sibling .shp/.shx/.dbf.
+ GeoConverter.Read(path, format, progress);
+
+ ///
+ /// Writes to in , applying
+ /// the relevant options: a render (honouring the chosen ) for
+ /// PNG/SVG, the option-carrying overloads for KMZ/GeoParquet, and the plain codec write (Shapefile
+ /// included) otherwise.
+ ///
+ public static void Save(
+ FeatureCollection features,
+ string path,
+ GeoFormat format,
+ RenderSettings render,
+ KmzSettings kmz,
+ GeoParquetSettings parquet,
+ IProgress? progress = null)
+ {
+ switch (format)
+ {
+ case GeoFormat.Png:
+ File.WriteAllBytes(path, RenderPng(features, render, progress));
+ break;
+ case GeoFormat.Svg:
+ MapRenderer.RenderSvg(features, path, RenderOptionsFor(render, progress));
+ break;
+ case GeoFormat.Kmz:
+ WriteKmz(features, path, kmz);
+ break;
+ case GeoFormat.GeoParquet:
+ WriteGeoParquet(features, path, parquet);
+ break;
+ default:
+ GeoConverter.Write(features, path, format, progress);
+ break;
+ }
+ }
+
+ static void WriteKmz(FeatureCollection features, string path, KmzSettings settings)
+ {
+ using var stream = File.Create(path);
+ Kmz.Write(stream, features, settings.Compression);
+ }
+
+ static void WriteGeoParquet(FeatureCollection features, string path, GeoParquetSettings settings)
+ {
+ using var stream = File.Create(path);
+ GeoParquet.Write(stream, features, settings.Codec, settings.GzipLevel);
+ }
+
+ ///
+ /// Renders a PNG through the chosen backend (built-in software
+ /// rasterizer or ImageSharp).
+ ///
+ public static byte[] RenderPng(FeatureCollection features, RenderSettings render, IProgress? progress = null)
+ {
+ var options = RenderOptionsFor(render, progress);
+ return render.Renderer switch
+ {
+ RendererBackend.Skia => SkiaRenderer.RenderPng(features, options),
+ RendererBackend.ImageSharp => ImageSharpRenderer.RenderPng(features, options),
+ _ => MapRenderer.RenderPng(features, options),
+ };
+ }
+
+ ///
+ /// Renders a quick preview PNG. Always uses the built-in renderer (no third-party warm-up, always
+ /// available) regardless of the selected export backend, and reports no progress — it's a best-effort
+ /// thumbnail for the window, not the final export.
+ ///
+ public static byte[] RenderPreview(FeatureCollection features, RenderSettings render) =>
+ MapRenderer.RenderPng(features, RenderOptionsFor(render, null));
+
+ public static string RenderSvg(FeatureCollection features, RenderSettings render, IProgress? progress = null) =>
+ MapRenderer.RenderSvg(features, RenderOptionsFor(render, progress));
+
+ // Common name-like property keys, tried in order, so the "Labels" toggle works without the user
+ // having to name a property — mirrors the Blazor app. Falls back to the feature id.
+ static readonly string[] labelKeys =
+ ["name", "NAME", "Name", "name_en", "NAME_EN", "admin", "ADMIN", "title", "label", "id"];
+
+ static string? AutoLabel(Feature feature)
+ {
+ foreach (var key in labelKeys)
+ {
+ if (feature.Properties.TryGetValue(key, out var value) && value is not null)
+ {
+ var text = value.ToString();
+ if (!string.IsNullOrWhiteSpace(text))
+ {
+ return text;
+ }
+ }
+ }
+
+ return feature.Id?.ToString();
+ }
+
+ /// Maps a onto a .
+ public static RenderOptions RenderOptionsFor(RenderSettings settings, IProgress? progress)
+ {
+ var options = new RenderOptions
+ {
+ Projection = settings.Projection,
+ Bounds = settings.Bounds,
+ Padding = settings.Padding,
+ Background = settings.Background,
+ Stroke = settings.Stroke,
+ Fill = settings.Fill,
+ StrokeWidth = settings.StrokeWidth,
+ PointRadius = settings.PointRadius,
+ StrokeAutoScale = settings.StrokeAutoScale,
+ LabelSize = settings.LabelSize,
+ LabelColor = settings.LabelColor,
+ // Ocean paints the projection's world envelope under every feature (the lobes for Goode, the
+ // whole canvas otherwise) — see the Blazor service for the full rationale. On by default.
+ Ocean = settings.OceanEnabled ? settings.Ocean : null,
+ LabelHalo = settings.HaloEnabled ? settings.LabelHalo : null,
+ LabelKnockout = settings.KnockoutEnabled ? settings.LabelKnockout : null,
+ MinFeaturePixels = settings.MinFeaturePixels,
+ Png = new() { Compression = settings.PngCompression },
+ Svg = new() { SimplifyTolerance = settings.SvgSimplifyTolerance },
+ Progress = progress,
+ };
+
+ // MaxDimension (fit-to-box) wins when set; otherwise an explicit width with a derived or explicit height.
+ if (settings.MaxDimension > 0)
+ {
+ options.MaxDimension = settings.MaxDimension;
+ }
+ else
+ {
+ if (settings.Width > 0)
+ {
+ options.Width = settings.Width;
+ }
+
+ options.Height = settings.Height;
+ }
+
+ if (settings.Labels)
+ {
+ if (string.IsNullOrWhiteSpace(settings.LabelProperty))
+ {
+ options.Label = AutoLabel;
+ }
+ else
+ {
+ var key = settings.LabelProperty;
+ options.Label = _ =>
+ _.Properties.TryGetValue(key, out var value) && value is not null
+ ? value.ToString()
+ : null;
+ }
+ }
+
+ return options;
+ }
+
+ /// Builds an open/save dialog filter: an "All supported" clause, every format, then "All files".
+ public static string BuildDialogFilter(IReadOnlyList formats)
+ {
+ var clauses = new List();
+ var allPatterns = string.Join(
+ ';',
+ formats.SelectMany(_ => _.Extensions).Distinct(StringComparer.OrdinalIgnoreCase).Select(_ => $"*{_}"));
+ clauses.Add($"All supported ({allPatterns})|{allPatterns}");
+ clauses.AddRange(formats.Select(_ => _.DialogFilter));
+ clauses.Add("All files (*.*)|*.*");
+ return string.Join('|', clauses);
+ }
+}
diff --git a/src/GeoConvert.App/DiffMode.cs b/src/GeoConvert.App/DiffMode.cs
new file mode 100644
index 0000000..5094d50
--- /dev/null
+++ b/src/GeoConvert.App/DiffMode.cs
@@ -0,0 +1,12 @@
+namespace GeoConvert.App;
+
+/// How a map diff is drawn.
+public enum DiffMode
+{
+ /// Both maps drawn on one canvas in distinct colours, so shared geometry blends and
+ /// differences stand out in pure A- or B-colour.
+ Overlay,
+
+ /// The two maps drawn separately at the same extent/scale and placed next to each other.
+ SideBySide,
+}
diff --git a/src/GeoConvert.App/FileAssociations.cs b/src/GeoConvert.App/FileAssociations.cs
new file mode 100644
index 0000000..4bb3a66
--- /dev/null
+++ b/src/GeoConvert.App/FileAssociations.cs
@@ -0,0 +1,94 @@
+namespace GeoConvert.App;
+
+///
+/// Registers this app as the handler for the supported map file extensions, per-user (no admin needed)
+/// under HKCU\Software\Classes. One ProgId is created and pointed at the running executable, and
+/// every readable map extension is bound to it (set as the default and added to its OpenWith list). The
+/// binding is fully reversible via .
+///
+public static class FileAssociations
+{
+ const string progId = "GeoConvert.Map";
+ const string progIdLabel = "GeoConvert Map";
+
+ const int shcneAssocchanged = 0x08000000;
+ const uint shcnfIdlist = 0;
+
+ [DllImport("shell32.dll")]
+ static extern void SHChangeNotify(int eventId, uint flags, IntPtr item1, IntPtr item2);
+
+ /// The extensions bound to the app — every format that can be read into the editor.
+ public static IReadOnlyList Extensions => ConversionService.ReadableExtensions;
+
+ static string ExecutablePath =>
+ Environment.ProcessPath ??
+ throw new InvalidOperationException("Could not resolve the running executable path.");
+
+ /// True when the ProgId is registered and bound to the first supported extension.
+ public static bool IsAssociated()
+ {
+ using var classes = Registry.CurrentUser.OpenSubKey($@"Software\Classes\{Extensions[0]}");
+ return classes?.GetValue(null) as string == progId;
+ }
+
+ /// Binds every supported map extension to this app.
+ public static void Associate()
+ {
+ var executable = ExecutablePath;
+
+ using (var progId = Registry.CurrentUser.CreateSubKey($@"Software\Classes\{FileAssociations.progId}"))
+ {
+ progId.SetValue(null, progIdLabel);
+ using (var icon = progId.CreateSubKey("DefaultIcon"))
+ {
+ icon.SetValue(null, $"\"{executable}\",0");
+ }
+
+ using var command = progId.CreateSubKey(@"shell\open\command");
+ command.SetValue(null, $"\"{executable}\" \"%1\"");
+ }
+
+ foreach (var extension in Extensions)
+ {
+ using var key = Registry.CurrentUser.CreateSubKey($@"Software\Classes\{extension}");
+ // Set as the default handler (the "bind" the user asked for) and also advertise the ProgId in
+ // the extension's OpenWith list so the app shows up there and the binding is cleanly removable.
+ key.SetValue(null, progId);
+ using var openWith = key.CreateSubKey("OpenWithProgids");
+ openWith.SetValue(progId, Array.Empty(), RegistryValueKind.None);
+ }
+
+ NotifyShell();
+ }
+
+ /// Removes the bindings created by , leaving other handlers intact.
+ public static void Unassociate()
+ {
+ foreach (var extension in Extensions)
+ {
+ using var key = Registry.CurrentUser.OpenSubKey($@"Software\Classes\{extension}", writable: true);
+ if (key == null)
+ {
+ continue;
+ }
+
+ // Only clear the default if it still points at us — never stomp a handler the user has since
+ // chosen.
+ if (key.GetValue(null) as string == progId)
+ {
+ // "" is the name of a key's default value (DeleteValue, unlike GetValue, won't take null).
+ key.DeleteValue(string.Empty, throwOnMissingValue: false);
+ }
+
+ using var openWith = key.OpenSubKey("OpenWithProgids", writable: true);
+ openWith?.DeleteValue(progId, throwOnMissingValue: false);
+ }
+
+ Registry.CurrentUser.DeleteSubKeyTree($@"Software\Classes\{progId}", throwOnMissingSubKey: false);
+ NotifyShell();
+ }
+
+ static void NotifyShell() =>
+ // Tell Explorer the associations changed so icons / "Open with" refresh without a sign-out.
+ SHChangeNotify(shcneAssocchanged, shcnfIdlist, IntPtr.Zero, IntPtr.Zero);
+}
diff --git a/src/GeoConvert.App/FirstRun.cs b/src/GeoConvert.App/FirstRun.cs
new file mode 100644
index 0000000..f124404
--- /dev/null
+++ b/src/GeoConvert.App/FirstRun.cs
@@ -0,0 +1,55 @@
+namespace GeoConvert.App;
+
+///
+/// The one-time, first-launch prompt that offers to bind the supported map formats to this app. Gated by
+/// so it appears exactly once, whatever the user answers.
+///
+public static class FirstRun
+{
+ public static void PromptForAssociationsIfNeeded(SettingsManager settingsManager, IWin32Window owner)
+ {
+ var settings = settingsManager.Read();
+ if (settings.AssociationsPrompted)
+ {
+ return;
+ }
+
+ // Persist first, so a crash in the registry step never re-prompts on every launch.
+ settingsManager.Update(_ => _.AssociationsPrompted = true);
+
+ var extensions = string.Join(" ", FileAssociations.Extensions);
+ var result = MessageBox.Show(
+ owner,
+ $"""
+ Bind the supported map formats to GeoConvert, so double-clicking one opens it here?
+
+ This sets GeoConvert as the handler for:
+ {extensions}
+
+ Note: this includes the shared .json and .csv extensions. The change is per-user (no admin
+ needed) and can be undone any time from Tools ▸ Remove file associations.
+ """,
+ "Associate map files with GeoConvert?",
+ MessageBoxButtons.YesNo,
+ MessageBoxIcon.Question);
+
+ if (result != DialogResult.Yes)
+ {
+ return;
+ }
+
+ try
+ {
+ FileAssociations.Associate();
+ }
+ catch (Exception exception)
+ {
+ MessageBox.Show(
+ owner,
+ $"Could not set file associations:\n{exception.Message}",
+ "GeoConvert",
+ MessageBoxButtons.OK,
+ MessageBoxIcon.Warning);
+ }
+ }
+}
diff --git a/src/GeoConvert.App/FormatInfo.cs b/src/GeoConvert.App/FormatInfo.cs
new file mode 100644
index 0000000..9930828
--- /dev/null
+++ b/src/GeoConvert.App/FormatInfo.cs
@@ -0,0 +1,25 @@
+namespace GeoConvert.App;
+
+///
+/// Desktop-facing metadata for a : how to name it, which file extensions it
+/// owns, and whether it can be read / written. The desktop has a real filesystem, so (unlike the
+/// browser sample) the path-based Shapefile is a first-class format here.
+///
+public record FormatInfo(
+ GeoFormat Format,
+ string DisplayName,
+ string Extension,
+ IReadOnlyList Extensions,
+ bool CanRead,
+ bool CanWrite)
+{
+ /// A single-format file-dialog filter clause, e.g. GeoJSON (*.geojson;*.json)|*.geojson;*.json.
+ public string DialogFilter
+ {
+ get
+ {
+ var patterns = string.Join(';', Extensions.Select(_ => $"*{_}"));
+ return $"{DisplayName} ({patterns})|{patterns}";
+ }
+ }
+}
diff --git a/src/GeoConvert.App/GeoConvert.App.csproj b/src/GeoConvert.App/GeoConvert.App.csproj
new file mode 100644
index 0000000..70cdb18
--- /dev/null
+++ b/src/GeoConvert.App/GeoConvert.App.csproj
@@ -0,0 +1,84 @@
+
+
+
+
+ Exe
+ net10.0;net11.0
+ true
+ enable
+ $(NoWarn);CA1416;NETSDK1137
+ true
+ true
+ geoconvert-app
+ GeoConvert.App
+ LatestMajor
+
+ false
+ true
+ A WinForms desktop app and .NET tool that converts maps between geospatial formats, renders them to PNG/SVG, and diffs two maps. Built on GeoConvert.
+ geojson;shapefile;flatgeobuf;topojson;kml;kmz;gpx;wkt;wkb;gis;geospatial;conversion;winforms;diff
+ readme.md
+
+
+
+
+
+
+
+
+ Windows
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Borders
+ 0.1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/GeoConvert.App/GeoParquetSettings.cs b/src/GeoConvert.App/GeoParquetSettings.cs
new file mode 100644
index 0000000..7dd85e5
--- /dev/null
+++ b/src/GeoConvert.App/GeoParquetSettings.cs
@@ -0,0 +1,11 @@
+namespace GeoConvert.App;
+
+///
+/// Options for a GeoParquet write — the data-page codec, plus the
+/// deflate level used only when the codec is .
+///
+public sealed class GeoParquetSettings
+{
+ public ParquetCompression Codec { get; set; } = ParquetCompression.Snappy;
+ public CompressionLevel GzipLevel { get; set; } = CompressionLevel.Optimal;
+}
diff --git a/src/GeoConvert.App/GlobalUsings.cs b/src/GeoConvert.App/GlobalUsings.cs
new file mode 100644
index 0000000..6b0ba69
--- /dev/null
+++ b/src/GeoConvert.App/GlobalUsings.cs
@@ -0,0 +1,10 @@
+global using System.Globalization;
+global using System.IO.Compression;
+global using System.Runtime.InteropServices;
+global using System.Text;
+global using System.Text.Json;
+global using System.Text.Json.Serialization;
+global using GeoConvert;
+global using GeoConvert.ImageSharp;
+global using GeoConvert.Skia;
+global using Microsoft.Win32;
diff --git a/src/GeoConvert.App/KmzSettings.cs b/src/GeoConvert.App/KmzSettings.cs
new file mode 100644
index 0000000..3857c8d
--- /dev/null
+++ b/src/GeoConvert.App/KmzSettings.cs
@@ -0,0 +1,7 @@
+namespace GeoConvert.App;
+
+/// Options for a KMZ write — the zip deflate level for the archived doc.kml entry.
+public sealed class KmzSettings
+{
+ public CompressionLevel Compression { get; set; } = CompressionLevel.Optimal;
+}
diff --git a/src/GeoConvert.App/MapDiff.cs b/src/GeoConvert.App/MapDiff.cs
new file mode 100644
index 0000000..3446e76
--- /dev/null
+++ b/src/GeoConvert.App/MapDiff.cs
@@ -0,0 +1,251 @@
+using System.Drawing.Imaging;
+
+namespace GeoConvert.App;
+
+///
+/// Compares two maps. The visual diff reuses the existing renderer with zero new drawing code: an
+/// stacks both collections on one canvas via the multi-collection
+/// overload with a
+/// per-layer colouring A and B differently (so shared geometry blends and
+/// differences read as pure A- or B-colour), and renders each map to
+/// the same union extent and composites them. The structural reports feature
+/// counts, geometry-type histograms, bounds, property keys and their deltas — the headline of a
+/// command-line diff. Inputs are flattened (layers collapsed) for the visual diff so per-layer styling
+/// stays correct on layered sources; still reports the original layer counts.
+///
+public static class MapDiff
+{
+ public static Rgba DefaultColorA { get; } = new(200, 30, 30);
+
+ public static Rgba DefaultColorB { get; } = new(40, 90, 210);
+
+ /// Renders the diff image for as PNG bytes.
+ public static byte[] Render(
+ FeatureCollection a,
+ FeatureCollection b,
+ RenderSettings settings,
+ DiffMode mode,
+ Rgba colorA,
+ Rgba colorB) =>
+ mode == DiffMode.SideBySide
+ ? RenderSideBySide(a, b, settings, colorA, colorB)
+ : RenderOverlay(a, b, settings, colorA, colorB);
+
+ static byte[] RenderOverlay(FeatureCollection a, FeatureCollection b, RenderSettings settings, Rgba colorA, Rgba colorB)
+ {
+ var flatA = Flatten(a);
+ var flatB = Flatten(b);
+
+ var options = ConversionService.RenderOptionsFor(settings, null);
+ // The ocean fill would paint a solid layer over one of the maps; labels would clutter the
+ // comparison. Both off for the diff, regardless of the user's export settings.
+ options.Ocean = null;
+ options.Label = null;
+ options.LayerStyle = layer =>
+ {
+ if (ReferenceEquals(layer, flatA))
+ {
+ return StyleFor(colorA);
+ }
+
+ if (ReferenceEquals(layer, flatB))
+ {
+ return StyleFor(colorB);
+ }
+
+ return null;
+ };
+
+ // Bounds left as the user set them (null => the renderer unions both inputs), so the two layers
+ // share one extent and line up.
+ return MapRenderer.RenderPng([flatA, flatB], options);
+ }
+
+ static byte[] RenderSideBySide(FeatureCollection a, FeatureCollection b, RenderSettings settings, Rgba colorA, Rgba colorB)
+ {
+ var flatA = Flatten(a);
+ var flatB = Flatten(b);
+
+ // Force both panels onto the same extent so they are spatially comparable: the user's bbox if
+ // set, otherwise the union of both maps.
+ var bounds = settings.Bounds ?? flatA.GetBounds().ExpandToInclude(flatB.GetBounds());
+ if (bounds.IsEmpty)
+ {
+ throw new GeoConvertException("Cannot render a side-by-side diff: neither map has a spatial extent.");
+ }
+
+ var bytesA = RenderPanel(flatA, settings, bounds, colorA);
+ var bytesB = RenderPanel(flatB, settings, bounds, colorB);
+
+ using var imageA = LoadBitmap(bytesA);
+ using var imageB = LoadBitmap(bytesB);
+
+ const int gap = 8;
+ var width = imageA.Width + gap + imageB.Width;
+ var height = Math.Max(imageA.Height, imageB.Height);
+ using var combined = new Bitmap(width, height);
+ using (var graphics = Graphics.FromImage(combined))
+ {
+ graphics.Clear(settings.Background.ToColor());
+ graphics.DrawImage(imageA, 0, 0);
+ graphics.DrawImage(imageB, imageA.Width + gap, 0);
+ }
+
+ using var output = new MemoryStream();
+ combined.Save(output, ImageFormat.Png);
+ return output.ToArray();
+ }
+
+ static byte[] RenderPanel(FeatureCollection collection, RenderSettings settings, Envelope bounds, Rgba color)
+ {
+ var options = ConversionService.RenderOptionsFor(settings, null);
+ options.Bounds = bounds;
+ options.Ocean = null;
+ options.Label = null;
+ options.Stroke = color;
+ options.Fill = color with { A = 70 };
+ return MapRenderer.RenderPng(collection, options);
+ }
+
+ static LayerStyle StyleFor(Rgba color) =>
+ new()
+ {
+ Stroke = color,
+ Fill = color with { A = 70 },
+ };
+
+ static Bitmap LoadBitmap(byte[] png)
+ {
+ using var stream = new MemoryStream(png);
+ return new(stream);
+ }
+
+ // Collapse a (possibly layered) collection into a single flat layer of all its features. The visual
+ // diff styles per top-level layer, so flattening keeps A and B each a single styled layer even when
+ // the source had folders/sub-layers.
+ static FeatureCollection Flatten(FeatureCollection collection) =>
+ new(collection)
+ {
+ Name = collection.Name,
+ };
+
+ /// Builds the human-readable structural comparison printed by the CLI and shown in the diff view.
+ public static string Summarize(string nameA, FeatureCollection a, string nameB, FeatureCollection b)
+ {
+ var statsA = MapStats.Analyze(a);
+ var statsB = MapStats.Analyze(b);
+
+ var builder = new StringBuilder();
+ AppendMap(builder, "Map A", nameA, statsA);
+ builder.AppendLine();
+ AppendMap(builder, "Map B", nameB, statsB);
+ builder.AppendLine();
+ AppendDifferences(builder, statsA, statsB);
+ return builder.ToString();
+ }
+
+ static void AppendMap(StringBuilder builder, string label, string name, MapStats stats)
+ {
+ builder.AppendLine(CultureInfo.InvariantCulture, $"{label}: {name}");
+ builder.AppendLine(CultureInfo.InvariantCulture, $" Features: {stats.FeatureCount}");
+ builder.AppendLine(CultureInfo.InvariantCulture, $" Layers: {stats.LayerCount}");
+ builder.AppendLine(CultureInfo.InvariantCulture, $" Bounds: {FormatBounds(stats.Bounds)}");
+ builder.AppendLine(CultureInfo.InvariantCulture, $" Geometry: {FormatKinds(stats.GeometryKinds)}");
+ builder.AppendLine(CultureInfo.InvariantCulture, $" Properties: {FormatKeys(stats.PropertyKeys)}");
+ }
+
+ static void AppendDifferences(StringBuilder builder, MapStats a, MapStats b)
+ {
+ builder.AppendLine("Differences:");
+ builder.AppendLine(CultureInfo.InvariantCulture, $" Features: {FormatDelta(b.FeatureCount - a.FeatureCount)}");
+
+ var kindLines = new List();
+ foreach (var kind in a.GeometryKinds.Keys.Union(b.GeometryKinds.Keys).OrderBy(_ => _, StringComparer.Ordinal))
+ {
+ var delta = b.GeometryKinds.GetValueOrDefault(kind) - a.GeometryKinds.GetValueOrDefault(kind);
+ if (delta != 0)
+ {
+ kindLines.Add($"{kind} {FormatDelta(delta)}");
+ }
+ }
+
+ builder.AppendLine(CultureInfo.InvariantCulture, $" Geometry: {(kindLines.Count == 0 ? "(no change)" : string.Join(", ", kindLines))}");
+
+ var onlyInA = a.PropertyKeys.Except(b.PropertyKeys, StringComparer.Ordinal).OrderBy(_ => _, StringComparer.Ordinal).ToList();
+ var onlyInB = b.PropertyKeys.Except(a.PropertyKeys, StringComparer.Ordinal).OrderBy(_ => _, StringComparer.Ordinal).ToList();
+ builder.AppendLine(CultureInfo.InvariantCulture, $" Properties only in A: {(onlyInA.Count == 0 ? "(none)" : string.Join(", ", onlyInA))}");
+ builder.AppendLine(CultureInfo.InvariantCulture, $" Properties only in B: {(onlyInB.Count == 0 ? "(none)" : string.Join(", ", onlyInB))}");
+ }
+
+ static string FormatDelta(int value) =>
+ value > 0 ? $"+{value}" : value.ToString(CultureInfo.InvariantCulture);
+
+ static string FormatBounds(Envelope bounds) =>
+ bounds.IsEmpty
+ ? "(empty)"
+ : string.Format(
+ CultureInfo.InvariantCulture,
+ "{0:0.###}, {1:0.###} .. {2:0.###}, {3:0.###}",
+ bounds.MinX,
+ bounds.MinY,
+ bounds.MaxX,
+ bounds.MaxY);
+
+ static string FormatKinds(IReadOnlyDictionary kinds) =>
+ kinds.Count == 0
+ ? "(none)"
+ : string.Join(", ", kinds.OrderByDescending(_ => _.Value).Select(_ => $"{_.Key} {_.Value}"));
+
+ static string FormatKeys(IReadOnlyCollection keys) =>
+ keys.Count == 0 ? "(none)" : string.Join(", ", keys);
+
+ sealed record MapStats(
+ int FeatureCount,
+ int LayerCount,
+ Envelope Bounds,
+ IReadOnlyDictionary GeometryKinds,
+ IReadOnlyCollection PropertyKeys)
+ {
+ public static MapStats Analyze(FeatureCollection collection)
+ {
+ var kinds = new Dictionary(StringComparer.Ordinal);
+ var keys = new SortedSet(StringComparer.Ordinal);
+ foreach (var feature in collection)
+ {
+ var kind = KindOf(feature.Geometry);
+ kinds[kind] = kinds.GetValueOrDefault(kind) + 1;
+ foreach (var key in feature.Properties.Keys)
+ {
+ keys.Add(key);
+ }
+ }
+
+ return new(collection.Count, CountLayers(collection), collection.GetBounds(), kinds, keys);
+ }
+
+ static int CountLayers(FeatureCollection collection)
+ {
+ var total = 1;
+ foreach (var child in collection.Children)
+ {
+ total += CountLayers(child);
+ }
+
+ return total;
+ }
+
+ static string KindOf(Geometry? geometry) =>
+ geometry switch
+ {
+ null => "(none)",
+ Point => "Point",
+ LineString => "LineString",
+ Polygon => "Polygon",
+ MultiPoint => "MultiPoint",
+ MultiLineString => "MultiLineString",
+ MultiPolygon => "MultiPolygon",
+ GeometryCollection => "GeometryCollection",
+ _ => geometry.GetType().Name,
+ };
+ }
+}
diff --git a/src/GeoConvert.App/NativeConsole.cs b/src/GeoConvert.App/NativeConsole.cs
new file mode 100644
index 0000000..900c76a
--- /dev/null
+++ b/src/GeoConvert.App/NativeConsole.cs
@@ -0,0 +1,44 @@
+namespace GeoConvert.App;
+
+///
+/// Console-window management for the hybrid GUI/CLI tool. The app is a console-subsystem exe (so CLI
+/// subcommands print and return exit codes normally when run from a terminal). The cost is that an
+/// Explorer double-click — a file association launch — allocates a fresh console window. When we detect
+/// that case (we own the console alone) and we're about to show the GUI, we hide it so the windowed app
+/// looks like a windowed app. Launched from a real terminal we leave the console alone.
+///
+static class NativeConsole
+{
+ const int SwHide = 0;
+
+ [DllImport("kernel32.dll")]
+ static extern IntPtr GetConsoleWindow();
+
+ [DllImport("kernel32.dll")]
+ static extern uint GetConsoleProcessList(uint[] processList, uint processCount);
+
+ [DllImport("user32.dll")]
+ static extern bool ShowWindow(IntPtr handle, int command);
+
+ ///
+ /// True when this process is the only one attached to its console — the signature of a console
+ /// freshly allocated for us by Explorer, rather than a terminal we were launched from (which has at
+ /// least the shell attached too).
+ ///
+ public static bool OwnsConsoleAlone()
+ {
+ var processes = new uint[4];
+ var count = GetConsoleProcessList(processes, (uint) processes.Length);
+ return count == 1;
+ }
+
+ /// Hides this process's console window, if it has one.
+ public static void HideConsole()
+ {
+ var handle = GetConsoleWindow();
+ if (handle != IntPtr.Zero)
+ {
+ ShowWindow(handle, SwHide);
+ }
+ }
+}
diff --git a/src/GeoConvert.App/OptionChoices.cs b/src/GeoConvert.App/OptionChoices.cs
new file mode 100644
index 0000000..056ce01
--- /dev/null
+++ b/src/GeoConvert.App/OptionChoices.cs
@@ -0,0 +1,49 @@
+namespace GeoConvert.App;
+
+///
+/// The dropdown choice lists shared by the GUI option editors and the CLI help — the desktop
+/// counterpart of the Blazor app's ExportOptionChoices, kept in one place so labels stay
+/// consistent across the window, the diff view and the command line.
+///
+public static class OptionChoices
+{
+ public static readonly (MapProjection Value, string Label)[] Projections =
+ [
+ (MapProjection.Auto, "Automatic"),
+ (MapProjection.PlateCarree, "Plate Carrée"),
+ (MapProjection.WebMercator, "Web Mercator"),
+ (MapProjection.Lambert, "Lambert Conformal Conic"),
+ (MapProjection.Goode, "Goode Homolosine"),
+ ];
+
+ public static readonly (RendererBackend Value, string Label)[] Renderers =
+ [
+ (RendererBackend.BuiltIn, "Built-in (dependency-free)"),
+ (RendererBackend.Skia, "SkiaSharp"),
+ (RendererBackend.ImageSharp, "ImageSharp"),
+ ];
+
+ public static readonly (int Value, string Label)[] Dimensions =
+ [
+ (512, "512 px"),
+ (1024, "1024 px"),
+ (2048, "2048 px"),
+ (4096, "4096 px"),
+ (8192, "8192 px"),
+ ];
+
+ public static readonly (CompressionLevel Value, string Label)[] CompressionLevels =
+ [
+ (CompressionLevel.Optimal, "Optimal"),
+ (CompressionLevel.SmallestSize, "Smallest size"),
+ (CompressionLevel.Fastest, "Fastest"),
+ (CompressionLevel.NoCompression, "None"),
+ ];
+
+ public static readonly (ParquetCompression Value, string Label)[] ParquetCodecs =
+ [
+ (ParquetCompression.Snappy, "Snappy"),
+ (ParquetCompression.Gzip, "GZIP"),
+ (ParquetCompression.Uncompressed, "Uncompressed"),
+ ];
+}
diff --git a/src/GeoConvert.App/Program.cs b/src/GeoConvert.App/Program.cs
new file mode 100644
index 0000000..93a6950
--- /dev/null
+++ b/src/GeoConvert.App/Program.cs
@@ -0,0 +1,85 @@
+namespace GeoConvert.App;
+
+static class Program
+{
+ // The GEOCONVERT_SETTINGS environment variable overrides where settings live — used by tests and
+ // screenshot tooling so they never touch the real per-user settings file (which gates the one-time
+ // association prompt).
+ static readonly SettingsManager settingsManager = new(
+ Environment.GetEnvironmentVariable("GEOCONVERT_SETTINGS") is { Length: > 0 } path
+ ? path
+ : SettingsManager.DefaultSettingsPath);
+
+ [STAThread]
+ static int Main(string[] args)
+ {
+ if (args.Length == 0)
+ {
+ return LaunchGui(() => new MainForm(settingsManager, null));
+ }
+
+ switch (args[0].ToLowerInvariant())
+ {
+ case "-h":
+ case "--help":
+ case "help":
+ Cli.PrintUsage(Console.Out);
+ return 0;
+ case "--list":
+ case "list":
+ Cli.PrintFormats(Console.Out);
+ return 0;
+ case "associate":
+ return Cli.Associate(Console.Out);
+ case "unassociate":
+ return Cli.Unassociate(Console.Out);
+ case "settings":
+ return Cli.PrintSettings(Console.Out, settingsManager);
+ case "diff":
+ return Diff(args[1..]);
+ }
+
+ // Not a subcommand. A lone existing file is the file-association double-click case: open it in the
+ // window. Anything else is a usage error.
+ if (args.Length == 1 && File.Exists(args[0]))
+ {
+ var file = args[0];
+ return LaunchGui(() => new MainForm(settingsManager, file));
+ }
+
+ Cli.PrintUsage(Console.Error);
+ return 2;
+ }
+
+ static int Diff(string[] diffArgs)
+ {
+ var code = Cli.ParseDiff(diffArgs, out var request, Console.Error);
+ if (code != 0 || request == null)
+ {
+ return code;
+ }
+
+ // No output path => show the comparison in the window; otherwise render it headlessly.
+ if (request.Output == null)
+ {
+ return LaunchGui(() => new DiffForm(request));
+ }
+
+ return Cli.ExecuteDiff(request, Console.Out, Console.Error);
+ }
+
+ static int LaunchGui(Func