using System.Globalization; using System.IO.Compression; using System.Text; using System.Text.Json; using System.Xml.Linq; namespace LMEMappeCatastaliApp.Services; public sealed class MapExportService { private static readonly XNamespace Kml = "http://www.opengis.net/kml/2.2"; public async Task CreateAsync( string requestedName, string requestedFormat, string geoJson, CancellationToken cancellationToken = default) { using var document = JsonDocument.Parse(geoJson); var root = document.RootElement; if (!root.TryGetProperty("type", out var type) || type.GetString() != "FeatureCollection" || !root.TryGetProperty("features", out var features) || features.ValueKind != JsonValueKind.Array) { throw new InvalidDataException("I dati raccolti dalla mappa non sono validi."); } var featureCount = features.GetArrayLength(); if (featureCount == 0) { throw new InvalidOperationException("Nessun oggetto selezionato e disponibile da esportare."); } var format = requestedFormat.Trim().ToLowerInvariant() switch { "kml" => "kml", "kmz" => "kmz", "gpx" => "gpx", "pdf" => "pdf", _ => "geojson" }; var baseName = NormalizeFileName(requestedName); var exportDirectory = Path.Combine(FileSystem.CacheDirectory, "Exports"); Directory.CreateDirectory(exportDirectory); var path = Path.Combine(exportDirectory, $"{baseName}.{format}"); if (format == "geojson") { using var output = File.Create(path); await JsonSerializer.SerializeAsync(output, root, new JsonSerializerOptions { WriteIndented = true }, cancellationToken); return new(path, "application/geo+json", featureCount, Path.GetFileName(path)); } if (format == "gpx") { await File.WriteAllTextAsync(path, BuildGpx(features, baseName), new UTF8Encoding(false), cancellationToken); return new(path, "application/gpx+xml", featureCount, Path.GetFileName(path)); } if (format == "pdf") { await File.WriteAllBytesAsync(path, BuildPdfReport(features, baseName), cancellationToken); return new(path, "application/pdf", featureCount, Path.GetFileName(path)); } var kml = BuildKml(features, baseName); if (format == "kml") { await File.WriteAllTextAsync(path, kml, new UTF8Encoding(false), cancellationToken); return new(path, "application/vnd.google-earth.kml+xml", featureCount, Path.GetFileName(path)); } await using (var output = File.Create(path)) using (var archive = new ZipArchive(output, ZipArchiveMode.Create)) { var entry = archive.CreateEntry("doc.kml", CompressionLevel.Optimal); await using var entryStream = entry.Open(); await using var writer = new StreamWriter(entryStream, new UTF8Encoding(false)); await writer.WriteAsync(kml.AsMemory(), cancellationToken); } return new(path, "application/vnd.google-earth.kmz", featureCount, Path.GetFileName(path)); } private static string BuildGpx(JsonElement features, string name) { var root = new XElement("gpx", new XAttribute("version", "1.1"), new XAttribute("creator", "LME Mappe Catastali 0.4.7"), new XAttribute(XNamespace.Xmlns + "xsi", "http://www.w3.org/2001/XMLSchema-instance"), new XElement("metadata", new XElement("name", name))); foreach (var feature in features.EnumerateArray()) { if (!feature.TryGetProperty("geometry", out var geometry) || geometry.ValueKind != JsonValueKind.Object) continue; var properties = feature.TryGetProperty("properties", out var value) ? value : default; var featureName = FeatureName(properties, GetString(properties, "_lmeCategory") ?? "drawing"); var type = geometry.GetProperty("type").GetString(); var coordinates = geometry.GetProperty("coordinates"); if (type == "Point") { root.Add(GpxPoint("wpt", coordinates, featureName)); } else { foreach (var line in EnumerateGpxLines(type, coordinates)) { root.Add(new XElement("trk", new XElement("name", featureName), new XElement("trkseg", line.Select(point => GpxPoint("trkpt", point, null))))); } } } return new XDocument(new XDeclaration("1.0", "utf-8", null), root).ToString(); } private static XElement GpxPoint(string element, JsonElement coordinate, string? name) { var values = coordinate.EnumerateArray().ToArray(); var point = new XElement(element, new XAttribute("lat", values[1].GetDouble().ToString("G17", CultureInfo.InvariantCulture)), new XAttribute("lon", values[0].GetDouble().ToString("G17", CultureInfo.InvariantCulture))); if (!string.IsNullOrWhiteSpace(name)) point.Add(new XElement("name", name)); return point; } private static IEnumerable EnumerateGpxLines(string? type, JsonElement coordinates) { if (type == "LineString") yield return coordinates.EnumerateArray().ToArray(); else if (type == "Polygon") foreach (var ring in coordinates.EnumerateArray()) yield return ring.EnumerateArray().ToArray(); else if (type == "MultiLineString") foreach (var line in coordinates.EnumerateArray()) yield return line.EnumerateArray().ToArray(); else if (type == "MultiPolygon") foreach (var polygon in coordinates.EnumerateArray()) foreach (var ring in polygon.EnumerateArray()) yield return ring.EnumerateArray().ToArray(); } private static byte[] BuildPdfReport(JsonElement features, string title) { var paths = features.EnumerateArray() .Where(feature => feature.TryGetProperty("geometry", out _)) .SelectMany(feature => PdfCoordinatePaths(feature.GetProperty("geometry"))) .Where(path => path.Count > 0) .ToArray(); var points = paths.SelectMany(path => path).ToArray(); var minLon = points.Length == 0 ? 0 : points.Min(point => point.Lon); var maxLon = points.Length == 0 ? 1 : points.Max(point => point.Lon); var minLat = points.Length == 0 ? 0 : points.Min(point => point.Lat); var maxLat = points.Length == 0 ? 1 : points.Max(point => point.Lat); var lonSpan = Math.Max(0.0000001, maxLon - minLon); var latSpan = Math.Max(0.0000001, maxLat - minLat); const double mapLeft = 48, mapBottom = 438, mapWidth = 499, mapHeight = 330; var scale = Math.Min(mapWidth / lonSpan, mapHeight / latSpan); var drawing = new StringBuilder("0.08 0.44 0.37 RG 0.86 0.94 0.91 rg 1.4 w "); foreach (var path in paths.Take(400)) { for (var pointIndex = 0; pointIndex < path.Count; pointIndex++) { var x = mapLeft + (path[pointIndex].Lon - minLon) * scale; var y = mapBottom + (path[pointIndex].Lat - minLat) * scale; drawing.AppendFormat(CultureInfo.InvariantCulture, "{0:0.##} {1:0.##} {2} ", x, y, pointIndex == 0 ? "m" : "l"); } drawing.Append("S "); } drawing.Append("0 0 0 RG 1 w 497 730 m 497 758 l S 497 758 m 491 746 l 503 746 l h f "); var middleLatitude = (minLat + maxLat) / 2; var widthMeters = lonSpan * 111_320 * Math.Cos(middleLatitude * Math.PI / 180); var scaleMeters = Math.Max(1, NiceScaleDistance(widthMeters / 4)); var scalePixels = widthMeters <= 0 ? 80 : Math.Clamp(scaleMeters / widthMeters * mapWidth, 35, 150); drawing.AppendFormat(CultureInfo.InvariantCulture, "48 422 m {0:0.##} 422 l S 48 418 m 48 426 l S {0:0.##} 418 m {0:0.##} 426 l S ", 48 + scalePixels); var lines = new List { title, $"Rapporto LME Mappe Catastali - {DateTime.Now:dd/MM/yyyy HH:mm}", $"Oggetti: {features.GetArrayLength()}", $"Scala grafica: {scaleMeters:0} m | Nord: alto pagina", "" }; var index = 1; foreach (var feature in features.EnumerateArray()) { var properties = feature.TryGetProperty("properties", out var value) ? value : default; var category = GetString(properties, "_lmeCategory") ?? "drawing"; var geometry = feature.TryGetProperty("geometry", out var geo) ? geo.GetProperty("type").GetString() : "-"; var sheet = GetString(properties, "foglio") ?? GetString(properties, "FOGLIO"); var parcel = GetString(properties, "particella") ?? GetString(properties, "PARTICELLA") ?? GetString(properties, "LABEL"); var reference = string.Join(" ", new[] { sheet is null ? null : $"F.{sheet}", parcel is null ? null : $"P.{parcel}" }.Where(item => item is not null)); lines.Add($"{index++}. {FeatureName(properties, category)} [{category}, {geometry}] {reference}".TrimEnd()); } var content = drawing.Append("BT /F1 12 Tf 48 810 Td "); foreach (var line in lines.Take(21)) { var safe = line.Replace("\\", "\\\\").Replace("(", "\\(").Replace(")", "\\)") .Select(character => character <= 255 ? character : '?').ToArray(); content.Append('(').Append(safe).Append(") Tj 0 -16 Td "); } content.Append("ET"); var stream = Encoding.Latin1.GetBytes(content.ToString()); var objects = new[] { "<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>", $"<< /Length {stream.Length} >>\nstream\n{Encoding.Latin1.GetString(stream)}\nendstream", "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>" }; using var output = new MemoryStream(); using var writer = new StreamWriter(output, Encoding.Latin1, leaveOpen: true) { NewLine = "\n" }; writer.Write("%PDF-1.4\n"); writer.Flush(); var offsets = new List { 0 }; for (var i = 0; i < objects.Length; i++) { offsets.Add(output.Position); writer.Write($"{i + 1} 0 obj\n{objects[i]}\nendobj\n"); writer.Flush(); } var xref = output.Position; writer.Write($"xref\n0 {objects.Length + 1}\n0000000000 65535 f \n"); foreach (var offset in offsets.Skip(1)) writer.Write($"{offset:0000000000} 00000 n \n"); writer.Write($"trailer << /Size {objects.Length + 1} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF"); writer.Flush(); return output.ToArray(); } private static double NiceScaleDistance(double value) { if (value <= 0) return 1; var power = Math.Pow(10, Math.Floor(Math.Log10(value))); var normalized = value / power; var nice = normalized >= 5 ? 5 : normalized >= 2 ? 2 : 1; return nice * power; } private static IEnumerable> PdfCoordinatePaths(JsonElement geometry) { if (!geometry.TryGetProperty("type", out var typeElement) || !geometry.TryGetProperty("coordinates", out var coordinates)) yield break; var type = typeElement.GetString(); if (type == "Point") { yield return [PdfPoint(coordinates)]; yield break; } if (type == "LineString") { yield return coordinates.EnumerateArray().Select(PdfPoint).ToList(); yield break; } if (type == "Polygon" || type == "MultiLineString") { foreach (var path in coordinates.EnumerateArray()) yield return path.EnumerateArray().Select(PdfPoint).ToList(); yield break; } if (type == "MultiPolygon") { foreach (var polygon in coordinates.EnumerateArray()) foreach (var path in polygon.EnumerateArray()) yield return path.EnumerateArray().Select(PdfPoint).ToList(); } } private static (double Lon, double Lat) PdfPoint(JsonElement coordinate) => (coordinate[0].GetDouble(), coordinate[1].GetDouble()); private static string BuildKml(JsonElement features, string documentName) { var document = new XElement(Kml + "Document", new XElement(Kml + "name", documentName), BuildStyle("imported", "ff2ecc71", "402ecc71"), BuildStyle("imported-sheet", "ff22803d", "1022803d"), BuildStyle("cadastral", "ff4444ef", "4224bffb"), BuildStyle("cadastral-gps", "ffff7a00", "337aff00"), BuildStyle("drawing", "ff1679f9", "333c92fb"), BuildStyle("poi", "ffff7a00", "33ff7a00"), BuildStyle("gps-trail", "ff00a5ff", "2200a5ff")); var categoryFolders = new Dictionary(StringComparer.Ordinal); var groupFolders = new Dictionary<(string Category, string Group), XElement>(); foreach (var feature in features.EnumerateArray()) { if (!feature.TryGetProperty("geometry", out var geometry) || geometry.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) { continue; } var properties = feature.TryGetProperty("properties", out var propertyElement) && propertyElement.ValueKind == JsonValueKind.Object ? propertyElement : default; var category = GetString(properties, "_lmeCategory") ?? "drawing"; var placemark = new XElement(Kml + "Placemark", new XElement(Kml + "name", FeatureName(properties, category)), new XElement(Kml + "styleUrl", $"#{NormalizeStyleId(category)}")); var featureStyle = BuildFeatureStyle(properties); if (featureStyle is not null) { placemark.Add(featureStyle); } var extendedData = BuildExtendedData(properties); if (extendedData is not null) { placemark.Add(extendedData); } placemark.Add(BuildGeometry(geometry)); var categoryName = GetString(properties, "_lmeCategoryName")?.Trim(); var groupName = GetString(properties, "_lmeGroupName")?.Trim(); var parent = document; if (!string.IsNullOrWhiteSpace(categoryName)) { if (!categoryFolders.TryGetValue(categoryName, out var categoryFolder)) { categoryFolder = new XElement(Kml + "Folder", new XElement(Kml + "name", categoryName)); categoryFolders[categoryName] = categoryFolder; document.Add(categoryFolder); } parent = categoryFolder; } if (!string.IsNullOrWhiteSpace(groupName)) { var key = (categoryName ?? string.Empty, groupName); if (!groupFolders.TryGetValue(key, out var groupFolder)) { groupFolder = new XElement(Kml + "Folder", new XElement(Kml + "name", groupName)); groupFolders[key] = groupFolder; parent.Add(groupFolder); } parent = groupFolder; } parent.Add(placemark); } return new XDocument(new XDeclaration("1.0", "utf-8", null), new XElement(Kml + "kml", document)).ToString(); } private static XElement BuildStyle(string id, string lineColor, string fillColor) => new(Kml + "Style", new XAttribute("id", id), new XElement(Kml + "LineStyle", new XElement(Kml + "color", lineColor), new XElement(Kml + "width", 3)), new XElement(Kml + "PolyStyle", new XElement(Kml + "color", fillColor))); private static XElement? BuildFeatureStyle(JsonElement properties) { var color = GetString(properties, "_lmeColor"); var fillColor = GetString(properties, "_lmeFillColor") ?? color; if (string.IsNullOrWhiteSpace(color) && string.IsNullOrWhiteSpace(fillColor)) return null; var weight = GetNumber(properties, "_lmeWeight", 3); var opacity = GetNumber(properties, "_lmeOpacity", 1); var fillOpacity = GetNumber(properties, "_lmeFillOpacity", 0.2); return new XElement(Kml + "Style", new XElement(Kml + "LineStyle", new XElement(Kml + "color", ToKmlColor(color ?? "#f97316", opacity)), new XElement(Kml + "width", weight.ToString("0.##", CultureInfo.InvariantCulture))), new XElement(Kml + "PolyStyle", new XElement(Kml + "color", ToKmlColor(fillColor ?? "#fb923c", fillOpacity)))); } private static double GetNumber(JsonElement properties, string name, double fallback) => properties.ValueKind == JsonValueKind.Object && properties.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Number ? value.GetDouble() : fallback; private static string ToKmlColor(string html, double opacity) { var value = html.TrimStart('#'); if (value.Length != 6) value = "f97316"; var alpha = (int)Math.Round(Math.Clamp(opacity, 0, 1) * 255); return $"{alpha:x2}{value[4..6]}{value[2..4]}{value[0..2]}"; } private static XElement? BuildExtendedData(JsonElement properties) { if (properties.ValueKind != JsonValueKind.Object) { return null; } var data = properties.EnumerateObject() .Where(item => item.Name != "_lmeCategory" && item.Value.ValueKind is JsonValueKind.String or JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False) .Select(item => new XElement(Kml + "Data", new XAttribute("name", item.Name), new XElement(Kml + "value", ScalarText(item.Value)))) .ToArray(); return data.Length == 0 ? null : new XElement(Kml + "ExtendedData", data); } private static XElement BuildGeometry(JsonElement geometry) { var type = geometry.GetProperty("type").GetString(); if (type == "GeometryCollection") { return new XElement(Kml + "MultiGeometry", geometry.GetProperty("geometries").EnumerateArray().Select(BuildGeometry)); } var coordinates = geometry.GetProperty("coordinates"); return type switch { "Point" => new XElement(Kml + "Point", CoordinatesElement([coordinates])), "LineString" => new XElement(Kml + "LineString", CoordinatesElement(coordinates.EnumerateArray())), "Polygon" => BuildPolygon(coordinates), "MultiPoint" => new XElement(Kml + "MultiGeometry", coordinates.EnumerateArray().Select(point => new XElement(Kml + "Point", CoordinatesElement([point])))), "MultiLineString" => new XElement(Kml + "MultiGeometry", coordinates.EnumerateArray().Select(line => new XElement(Kml + "LineString", CoordinatesElement(line.EnumerateArray())))), "MultiPolygon" => new XElement(Kml + "MultiGeometry", coordinates.EnumerateArray().Select(BuildPolygon)), _ => throw new InvalidDataException($"Geometria GeoJSON non supportata: {type}.") }; } private static XElement BuildPolygon(JsonElement rings) { var ringList = rings.EnumerateArray().ToArray(); var polygon = new XElement(Kml + "Polygon"); if (ringList.Length > 0) { polygon.Add(new XElement(Kml + "outerBoundaryIs", new XElement(Kml + "LinearRing", CoordinatesElement(ringList[0].EnumerateArray())))); } foreach (var ring in ringList.Skip(1)) { polygon.Add(new XElement(Kml + "innerBoundaryIs", new XElement(Kml + "LinearRing", CoordinatesElement(ring.EnumerateArray())))); } return polygon; } private static XElement CoordinatesElement(IEnumerable points) => new(Kml + "coordinates", string.Join(" ", points.Select(FormatCoordinate))); private static string FormatCoordinate(JsonElement coordinate) { var values = coordinate.EnumerateArray().Take(3) .Select(value => value.GetDouble().ToString("G17", CultureInfo.InvariantCulture)); return string.Join(",", values); } private static string FeatureName(JsonElement properties, string category) => GetString(properties, "_lmeDisplayName") ?? GetString(properties, "name") ?? GetString(properties, "Name") ?? GetString(properties, "LABEL") ?? GetString(properties, "NATIONALCADASTRALREFERENCE") ?? category switch { "imported-sheet" => "Cornice o foglio importato", "cadastral" => "Particella catastale", "cadastral-gps" => "Particella GPS", "poi" => "Punto di interesse", "gps-trail" => "Traccia GPS", _ => "Oggetto mappa" }; private static string? GetString(JsonElement element, string name) => element.ValueKind == JsonValueKind.Object && element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; private static string ScalarText(JsonElement value) => value.ValueKind == JsonValueKind.String ? value.GetString() ?? string.Empty : value.GetRawText(); private static string NormalizeStyleId(string category) => category switch { "imported" or "imported-sheet" or "cadastral" or "cadastral-gps" or "drawing" or "poi" or "gps-trail" => category, _ => "drawing" }; private static string NormalizeFileName(string requestedName) { var name = string.IsNullOrWhiteSpace(requestedName) ? "Mappa" : requestedName.Trim(); foreach (var invalid in Path.GetInvalidFileNameChars()) { name = name.Replace(invalid, '_'); } name = Path.GetFileNameWithoutExtension(name).Trim().TrimEnd('.'); return string.IsNullOrWhiteSpace(name) ? "Mappa" : name[..Math.Min(name.Length, 80)]; } } public sealed record MapExportFile(string Path, string MimeType, int FeatureCount, string FileName);