From 1f0a6760e08f5ec2c071fb098c8599830a9bd026 Mon Sep 17 00:00:00 2001 From: cyber-toad Date: Sat, 18 Jan 2025 00:14:54 +0100 Subject: [PATCH] [gpx] Add xml schema to color in gpx export Signed-off-by: cyber-toad --- data/test_data/gpx/color_map_dst.gpx | 28 +++++ data/test_data/gpx/color_map_src.gpx | 29 +++++ data/test_data/gpx/export_test.gpx | 10 +- data/test_data/gpx/export_test_empty.gpx | 6 +- kml/kml_tests/gpx_tests.cpp | 104 ++++++++++++---- kml/serdes_gpx.cpp | 147 +++++++++++++++++------ kml/serdes_gpx.hpp | 6 +- 7 files changed, 265 insertions(+), 65 deletions(-) create mode 100644 data/test_data/gpx/color_map_dst.gpx create mode 100644 data/test_data/gpx/color_map_src.gpx diff --git a/data/test_data/gpx/color_map_dst.gpx b/data/test_data/gpx/color_map_dst.gpx new file mode 100644 index 0000000000..90096b2dfd --- /dev/null +++ b/data/test_data/gpx/color_map_dst.gpx @@ -0,0 +1,28 @@ + + + + new + Short description + + + new red + description 1 + + DarkGray + 443344 + #FF443344 + + + + 123 + + + 123 + + + + \ No newline at end of file diff --git a/data/test_data/gpx/color_map_src.gpx b/data/test_data/gpx/color_map_src.gpx new file mode 100644 index 0000000000..7528cf1207 --- /dev/null +++ b/data/test_data/gpx/color_map_src.gpx @@ -0,0 +1,29 @@ + + + + new + + gpx.studio + + + Short description + + + new red + description 1 + Running + + + 443344 + + + + + 123 + + + 123 + + + + \ No newline at end of file diff --git a/data/test_data/gpx/export_test.gpx b/data/test_data/gpx/export_test.gpx index 549da4bf67..03f31b35e4 100644 --- a/data/test_data/gpx/export_test.gpx +++ b/data/test_data/gpx/export_test.gpx @@ -1,5 +1,9 @@ - + My route & "]]> @@ -15,7 +19,9 @@ Some random route - 00FF00 + Green + 00FF00 + #FF00FF00 diff --git a/data/test_data/gpx/export_test_empty.gpx b/data/test_data/gpx/export_test_empty.gpx index ad945c2945..76c67ba06d 100644 --- a/data/test_data/gpx/export_test_empty.gpx +++ b/data/test_data/gpx/export_test_empty.gpx @@ -1,5 +1,9 @@ - + \ No newline at end of file diff --git a/kml/kml_tests/gpx_tests.cpp b/kml/kml_tests/gpx_tests.cpp index 37c2274441..a1d90a26cd 100644 --- a/kml/kml_tests/gpx_tests.cpp +++ b/kml/kml_tests/gpx_tests.cpp @@ -11,7 +11,8 @@ namespace gpx_tests { -static kml::FileData loadGpxFromString(std::string_view content) { +static kml::FileData LoadGpxFromString(std::string_view content) +{ TEST_NO_THROW( { kml::FileData dataFromText; @@ -20,34 +21,59 @@ static kml::FileData loadGpxFromString(std::string_view content) { }, ()); } -static kml::FileData loadGpxFromFile(std::string const & file) { +static kml::FileData LoadGpxFromFile(std::string const & file) +{ auto const fileName = GetPlatform().TestsDataPathForFile(file); std::string text; FileReader(fileName).ReadAsString(text); - return loadGpxFromString(text); + return LoadGpxFromString(text); } -void importExportCompare(char const * testFile) +static std::string ReadFile(char const * testFile) { auto const fileName = GetPlatform().TestsDataPathForFile(testFile); std::string sourceFileText; FileReader(fileName).ReadAsString(sourceFileText); - kml::FileData const dataFromFile = loadGpxFromFile(testFile); + return sourceFileText; +} + +static std::string ReadFileAndSerialize(char const * testFile) +{ + kml::FileData const dataFromFile = LoadGpxFromFile(testFile); std::string resultBuffer; MemWriter sink(resultBuffer); kml::gpx::SerializerGpx ser(dataFromFile); ser.Serialize(sink); + return resultBuffer; +} + +void ImportExportCompare(char const * testFile) +{ + std::string const sourceFileText = ReadFile(testFile); + std::string const resultBuffer = ReadFileAndSerialize(testFile); TEST_EQUAL(resultBuffer, sourceFileText, ()); } +void ImportExportCompare(char const * sourceFile, char const * destinationFile) +{ + std::string const resultBuffer = ReadFileAndSerialize(sourceFile); + std::string const destinationFileText = ReadFile(destinationFile); + TEST_EQUAL(resultBuffer, destinationFileText, ()); +} + UNIT_TEST(Gpx_ImportExport_Test) { - importExportCompare("test_data/gpx/export_test.gpx"); + ImportExportCompare("test_data/gpx/export_test.gpx"); } UNIT_TEST(Gpx_ImportExportEmpty_Test) { - importExportCompare("test_data/gpx/export_test_empty.gpx"); + ImportExportCompare("test_data/gpx/export_test_empty.gpx"); +} + +UNIT_TEST(Gpx_ColorMapExport_Test) +{ + ImportExportCompare("test_data/gpx/color_map_src.gpx", "test_data/gpx/color_map_dst.gpx"); } UNIT_TEST(Gpx_Test_Point_With_Valid_Timestamp) @@ -68,7 +94,7 @@ UNIT_TEST(Gpx_Test_Point_With_Valid_Timestamp) bookmarkData.m_color = {kml::PredefinedColor::Red, 0}; data.m_bookmarksData.emplace_back(std::move(bookmarkData)); - kml::FileData const dataFromText = loadGpxFromString(input); + kml::FileData const dataFromText = LoadGpxFromString(input); TEST_EQUAL(dataFromText, data, ()); } @@ -83,14 +109,14 @@ UNIT_TEST(Gpx_Test_Point_With_Invalid_Timestamp) )"; - kml::FileData const dataFromText = loadGpxFromString(input); + kml::FileData const dataFromText = LoadGpxFromString(input); TEST_EQUAL(dataFromText.m_bookmarksData.size(), 1, ()); } UNIT_TEST(Gpx_Test_Track_Without_Timestamps) { auto const fileName = "test_data/gpx/track_without_timestamps.gpx"; - kml::FileData const dataFromText = loadGpxFromFile(fileName); + kml::FileData const dataFromText = LoadGpxFromFile(fileName); auto const & lines = dataFromText.m_tracksData[0].m_geometry.m_lines; TEST_EQUAL(lines.size(), 2, ()); { @@ -124,7 +150,7 @@ UNIT_TEST(Gpx_Test_Track_Without_Timestamps) UNIT_TEST(Gpx_Test_Track_With_Timestamps) { auto const fileName = "test_data/gpx/track_with_timestamps.gpx"; - kml::FileData const dataFromText = loadGpxFromFile(fileName); + kml::FileData const dataFromText = LoadGpxFromFile(fileName); auto const & geometry = dataFromText.m_tracksData[0].m_geometry; TEST_EQUAL(geometry.m_lines.size(), 2, ()); TEST_EQUAL(geometry.m_timestamps.size(), 2, ()); @@ -168,7 +194,7 @@ UNIT_TEST(Gpx_Altitude_Issues) )"; - kml::FileData const dataFromText = loadGpxFromString(input); + kml::FileData const dataFromText = LoadGpxFromString(input); auto const & line = dataFromText.m_tracksData[0].m_geometry.m_lines[0]; TEST_EQUAL(line.size(), 6, ()); TEST_EQUAL(line[0], geometry::PointWithAltitude(mercator::FromLatLon(1, 1), geometry::kInvalidAltitude), ()); @@ -200,7 +226,7 @@ UNIT_TEST(Gpx_Timestamp_Issues) )"; - kml::FileData const dataFromText = loadGpxFromString(input); + kml::FileData const dataFromText = LoadGpxFromString(input); auto const & times = dataFromText.m_tracksData[0].m_geometry.m_timestamps[0]; TEST_EQUAL(times.size(), 8, ()); TEST_EQUAL(times[0], base::StringToTimestamp("2024-05-04T19:00:00Z"), ()); @@ -215,21 +241,21 @@ UNIT_TEST(Gpx_Timestamp_Issues) UNIT_TEST(GoMap) { - kml::FileData const dataFromFile = loadGpxFromFile("test_data/gpx/go_map.gpx"); + kml::FileData const dataFromFile = LoadGpxFromFile("test_data/gpx/go_map.gpx"); auto const & line = dataFromFile.m_tracksData[0].m_geometry.m_lines[0]; TEST_EQUAL(line.size(), 101, ()); } UNIT_TEST(GpxStudio) { - kml::FileData const dataFromFile = loadGpxFromFile("test_data/gpx/gpx_studio.gpx"); + kml::FileData const dataFromFile = LoadGpxFromFile("test_data/gpx/gpx_studio.gpx"); auto const & line = dataFromFile.m_tracksData[0].m_geometry.m_lines[0]; TEST_EQUAL(line.size(), 328, ()); } UNIT_TEST(OsmTrack) { - kml::FileData const dataFromFile = loadGpxFromFile("test_data/gpx/osm_track.gpx"); + kml::FileData const dataFromFile = LoadGpxFromFile("test_data/gpx/osm_track.gpx"); auto const & line = dataFromFile.m_tracksData[0].m_geometry.m_lines[0]; auto const & timestamps = dataFromFile.m_tracksData[0].m_geometry.m_timestamps[0]; TEST_EQUAL(line.size(), 182, ()); @@ -238,14 +264,14 @@ UNIT_TEST(OsmTrack) UNIT_TEST(TowerCollector) { - kml::FileData const dataFromFile = loadGpxFromFile("test_data/gpx/tower_collector.gpx"); + kml::FileData const dataFromFile = LoadGpxFromFile("test_data/gpx/tower_collector.gpx"); auto line = dataFromFile.m_tracksData[0].m_geometry.m_lines[0]; TEST_EQUAL(line.size(), 35, ()); } UNIT_TEST(PointsOnly) { - kml::FileData const dataFromFile = loadGpxFromFile("test_data/gpx/points.gpx"); + kml::FileData const dataFromFile = LoadGpxFromFile("test_data/gpx/points.gpx"); auto bookmarks = dataFromFile.m_bookmarksData; TEST_EQUAL(bookmarks.size(), 3, ()); TEST_EQUAL("Point 1", bookmarks[0].m_name[kml::kDefaultLang], ()); @@ -254,7 +280,7 @@ UNIT_TEST(PointsOnly) UNIT_TEST(Route) { - kml::FileData dataFromFile = loadGpxFromFile("test_data/gpx/route.gpx"); + kml::FileData dataFromFile = LoadGpxFromFile("test_data/gpx/route.gpx"); auto line = dataFromFile.m_tracksData[0].m_geometry.m_lines[0]; TEST_EQUAL(line.size(), 2, ()); TEST_EQUAL(dataFromFile.m_categoryData.m_name[kml::kDefaultLang], "Some random route", ()); @@ -264,7 +290,7 @@ UNIT_TEST(Route) UNIT_TEST(Color) { - kml::FileData const dataFromFile = loadGpxFromFile("test_data/gpx/color.gpx"); + kml::FileData const dataFromFile = LoadGpxFromFile("test_data/gpx/color.gpx"); uint32_t const red = 0xFF0000FF; uint32_t const blue = 0x0000FFFF; uint32_t const black = 0x000000FF; @@ -276,7 +302,7 @@ UNIT_TEST(Color) UNIT_TEST(MultiTrackNames) { - kml::FileData dataFromFile = loadGpxFromFile("test_data/gpx/color.gpx"); + kml::FileData dataFromFile = LoadGpxFromFile("test_data/gpx/color.gpx"); TEST_EQUAL("new", dataFromFile.m_categoryData.m_name[kml::kDefaultLang], ()); TEST_EQUAL("Short description", dataFromFile.m_categoryData.m_description[kml::kDefaultLang], ()); TEST_EQUAL("new red", dataFromFile.m_tracksData[0].m_name[kml::kDefaultLang], ()); @@ -287,14 +313,14 @@ UNIT_TEST(MultiTrackNames) UNIT_TEST(Empty) { - kml::FileData dataFromFile = loadGpxFromFile("test_data/gpx/empty.gpx"); + kml::FileData dataFromFile = LoadGpxFromFile("test_data/gpx/empty.gpx"); TEST_EQUAL("new", dataFromFile.m_categoryData.m_name[kml::kDefaultLang], ()); TEST_EQUAL(0, dataFromFile.m_tracksData.size(), ()); } UNIT_TEST(OsmandColor1) { - kml::FileData const dataFromFile = loadGpxFromFile("test_data/gpx/osmand1.gpx"); + kml::FileData const dataFromFile = LoadGpxFromFile("test_data/gpx/osmand1.gpx"); uint32_t constexpr expected = 0xFF7800FF; TEST_EQUAL(dataFromFile.m_tracksData.size(), 4, ()); TEST_EQUAL(expected, dataFromFile.m_tracksData[0].m_layers[0].m_color.m_rgba, ()); @@ -305,7 +331,7 @@ UNIT_TEST(OsmandColor1) UNIT_TEST(OsmandColor2) { - kml::FileData const dataFromFile = loadGpxFromFile("test_data/gpx/osmand2.gpx"); + kml::FileData const dataFromFile = LoadGpxFromFile("test_data/gpx/osmand2.gpx"); uint32_t const expected1 = 0x00FF00FF; uint32_t const expected2 = 0x1010A0FF; TEST_EQUAL(expected1, dataFromFile.m_bookmarksData[0].m_color.m_rgba, ()); @@ -330,7 +356,7 @@ d5 5qqqqqq )"; - kml::FileData const dataFromText = loadGpxFromString(input); + kml::FileData const dataFromText = LoadGpxFromString(input); TEST_EQUAL("d1", dataFromText.m_bookmarksData[0].m_description.at(kml::kDefaultLang), ()); TEST_EQUAL("d2\n\nc2", dataFromText.m_bookmarksData[1].m_description.at(kml::kDefaultLang), ()); TEST_EQUAL("c3", dataFromText.m_bookmarksData[2].m_description.at(kml::kDefaultLang), ()); @@ -340,9 +366,35 @@ d5 UNIT_TEST(OpentracksColor) { - kml::FileData dataFromFile = loadGpxFromFile("test_data/gpx/opentracks_color.gpx"); + kml::FileData dataFromFile = LoadGpxFromFile("test_data/gpx/opentracks_color.gpx"); uint32_t const expected = 0xC0C0C0FF; TEST_EQUAL(expected, dataFromFile.m_tracksData[0].m_layers[0].m_color.m_rgba, ()); } +UNIT_TEST(ParseFromString) +{ + // String hex sequence #AARRGGBB, uint32 sequence RGBA + TEST_EQUAL(std::optional(0x1FF), kml::gpx::GpxParser::ParseColorFromHexString("000001"), ()); + TEST_EQUAL(std::optional(0x100FF), kml::gpx::GpxParser::ParseColorFromHexString("000100"), ()); + TEST_EQUAL(std::optional(0x10000FF), kml::gpx::GpxParser::ParseColorFromHexString("010000"), ()); + TEST_EQUAL(std::optional(0x1FF), kml::gpx::GpxParser::ParseColorFromHexString("#000001"), ()); + TEST_EQUAL(std::optional(0x100FF), kml::gpx::GpxParser::ParseColorFromHexString("#000100"), ()); + TEST_EQUAL(std::optional(0x10000FF), kml::gpx::GpxParser::ParseColorFromHexString("#010000"), ()); + TEST_EQUAL(std::optional(0x1FF), kml::gpx::GpxParser::ParseColorFromHexString("#FF000001"), ()); + TEST_EQUAL(std::optional(0x100FF), kml::gpx::GpxParser::ParseColorFromHexString("#FF000100"), ()); + TEST_EQUAL(std::optional(0x10000FF), kml::gpx::GpxParser::ParseColorFromHexString("#FF010000"), ()); + TEST_EQUAL(std::optional(0x10000AA), kml::gpx::GpxParser::ParseColorFromHexString("#AA010000"), ()); + TEST_EQUAL(std::optional(), kml::gpx::GpxParser::ParseColorFromHexString("DarkRed"), ()); +} + +UNIT_TEST(MapGarminColor) +{ + TEST_EQUAL("DarkCyan", kml::gpx::MapGarminColor(0x008b8bff), ()); + TEST_EQUAL("White", kml::gpx::MapGarminColor(0xffffffff), ()); + TEST_EQUAL("DarkYellow", kml::gpx::MapGarminColor(0xb4b820ff), ()); + TEST_EQUAL("DarkYellow", kml::gpx::MapGarminColor(0xb6b820ff), ()); + TEST_EQUAL("DarkYellow", kml::gpx::MapGarminColor(0xb5b721ff), ()); +} + + } // namespace gpx_tests diff --git a/kml/serdes_gpx.cpp b/kml/serdes_gpx.cpp index 36366caa16..1a95c31030 100644 --- a/kml/serdes_gpx.cpp +++ b/kml/serdes_gpx.cpp @@ -33,10 +33,13 @@ std::string_view constexpr kEle = "ele"; std::string_view constexpr kCmt = "cmt"; std::string_view constexpr kTime = "time"; -std::string_view constexpr kGpxHeader = - "\n" - "\n"; - +std::string_view constexpr kGpxHeader = R"( + +)"; std::string_view constexpr kGpxFooter = ""; int constexpr kInvalidColor = 0; @@ -131,52 +134,53 @@ std::string const & GpxParser::GetTagFromEnd(size_t n) const return m_tags[m_tags.size() - n - 1]; } -void GpxParser::ParseColor(std::string const & value) +std::optional GpxParser::ParseColorFromHexString(std::string_view colorStr) { - auto const colorBytes = FromHex(value); - if (colorBytes.size() != 3) + if (colorStr.empty()) { - LOG(LWARNING, ("Invalid color value", value)); - return; + LOG(LWARNING, ("Invalid color value", colorStr)); + return {}; } - m_color = kml::ToRGBA(colorBytes[0], colorBytes[1], colorBytes[2], (char)255); + if (colorStr.front() == '#') + colorStr.remove_prefix(1); + if (colorStr.size() != 6 && colorStr.size() != 8) + { + LOG(LWARNING, ("Invalid color value", colorStr)); + return {}; + } + auto const colorBytes = FromHex(colorStr); + switch (colorBytes.size()) + { + case 3: return kml::ToRGBA(colorBytes[0], colorBytes[1], colorBytes[2], (char)255); + case 4: return kml::ToRGBA(colorBytes[1], colorBytes[2], colorBytes[3], colorBytes[0]); + default: + LOG(LWARNING, ("Invalid color value", colorStr)); + return {}; + } +} + +void GpxParser::ParseColor(std::string_view colorStr) +{ + if (const auto parsed = ParseColorFromHexString(colorStr); parsed) + m_color = parsed.value(); } // https://osmand.net/docs/technical/osmand-file-formats/osmand-gpx/. Supported colors: #AARRGGBB/#RRGGBB/AARRGGBB/RRGGBB void GpxParser::ParseOsmandColor(std::string const & value) { - if (value.empty()) - { - LOG(LWARNING, ("Empty color value")); + auto const color = ParseColorFromHexString(value); + if (!color) return; - } - std::string_view colorStr = value; - if (colorStr.at(0) == '#') - colorStr = colorStr.substr(1, colorStr.size() - 1); - auto const colorBytes = FromHex(colorStr); - uint32_t color; - switch (colorBytes.size()) - { - case 3: - color = kml::ToRGBA(colorBytes[0], colorBytes[1], colorBytes[2], (char)255); - break; - case 4: - color = kml::ToRGBA(colorBytes[1], colorBytes[2], colorBytes[3], colorBytes[0]); - break; - default: - LOG(LWARNING, ("Invalid color value", value)); - return; - } if (m_tags.size() > 2 && GetTagFromEnd(2) == gpx::kGpx) { - m_globalColor = color; + m_globalColor = *color; for (auto & track : m_data.m_tracksData) for (auto & layer : track.m_layers) layer.m_color.m_rgba = m_globalColor; } else { - m_color = color; + m_color = *color; } } @@ -465,6 +469,64 @@ std::string GpxParser::BuildDescription() const return m_description + "\n\n" + m_comment; } +std::tuple ExtractRGB(uint32_t color) +{ + return {(color >> 24) & 0xFF, (color >> 16) & 0xFF, (color >> 8) & 0xFF}; +} + +int ColorDistance(uint32_t color1, uint32_t color2) +{ + auto const [r1, g1, b1] = ExtractRGB(color1); + auto const [r2, g2, b2] = ExtractRGB(color2); + return (r1 - r2) * (r1 - r2) + (g1 - g2) * (g1 - g2) + (b1 - b2) * (b1 - b2); +} + +struct RGBAToGarmin +{ + uint32_t rgba; + std::string_view color; +}; + +auto constexpr kRGBAToGarmin = std::to_array({ + {0x000000ff, "Black"}, + {0x8b0000ff, "DarkRed"}, + {0x006400ff, "DarkGreen"}, + {0xb5b820ff, "DarkYellow"}, + {0x00008bff, "DarkBlue"}, + {0x8b008bff, "DarkMagenta"}, + {0x008b8bff, "DarkCyan"}, + {0xccccccff, "LightGray"}, + {0x444444ff, "DarkGray"}, + {0xff0000ff, "Red"}, + {0x00ff00ff, "Green"}, + {0xffff00ff, "Yellow"}, + {0x0000ffff, "Blue"}, + {0xff00ffff, "Magenta"}, + {0x00ffffff, "Cyan"}, + {0xffffffff, "White"} +}); + + +std::string_view MapGarminColor(uint32_t rgba) +{ + std::string_view closestColor = kRGBAToGarmin[0].color; + auto minDistance = std::numeric_limits::max(); + for (const auto & [rgbaGarmin, color] : kRGBAToGarmin) + { + auto const distance = ColorDistance(rgba, rgbaGarmin); + + if (distance == 0) + return color; // Exact match. + + if (distance < minDistance) + { + minDistance = distance; + closestColor = color; + } + } + return closestColor; +} + namespace { @@ -483,6 +545,13 @@ void SaveColorToRGB(Writer & writer, uint32_t rgba) << NumToHex(static_cast((rgba >> 8) & 0xFF)); } +void SaveColorToARGB(Writer & writer, uint32_t rgba) +{ + writer << NumToHex(static_cast(rgba & 0xFF)) + << NumToHex(static_cast(rgba >> 24 & 0xFF)) + << NumToHex(static_cast((rgba >> 16) & 0xFF)) + << NumToHex(static_cast((rgba >> 8) & 0xFF)); +} void SaveCategoryData(Writer & writer, CategoryData const & categoryData) { @@ -555,9 +624,17 @@ void SaveTrackData(Writer & writer, TrackData const & trackData) } if (auto const color = TrackColor(trackData); color != kDefaultTrackColor) { - writer << kIndent2 << "\n" << kIndent4 << ""; + writer << kIndent2 << "\n"; + writer << kIndent4 << ""; + writer << MapGarminColor(color); + writer << "\n"; + writer << kIndent4 << ""; SaveColorToRGB(writer, color); - writer << "\n" << kIndent2 << "\n"; + writer << "\n"; + writer << kIndent4 << "#"; + SaveColorToARGB(writer, color); + writer << "\n"; + writer << kIndent2 << "\n"; } bool const trackHasAltitude = TrackHasAltitudes(trackData); auto const & geom = trackData.m_geometry; diff --git a/kml/serdes_gpx.hpp b/kml/serdes_gpx.hpp index d592c1a3bf..593d169b17 100644 --- a/kml/serdes_gpx.hpp +++ b/kml/serdes_gpx.hpp @@ -61,6 +61,7 @@ public: std::string const & GetTagFromEnd(size_t n) const; void Pop(std::string_view tag); void CharData(std::string & value); + static std::optional ParseColorFromHexString(std::string_view colorStr); private: enum GeometryType @@ -72,7 +73,7 @@ private: void ResetPoint(); bool MakeValid(); - void ParseColor(std::string const & value); + void ParseColor(std::string_view colorStr); void ParseGarminColor(std::string const & value); void ParseOsmandColor(std::string const & value); bool IsValidCoordinatesPosition() const; @@ -108,6 +109,9 @@ private: void ParseTimestamp(std::string const & value); std::string BuildDescription() const; }; + +std::string_view MapGarminColor(uint32_t rgba); + } // namespace gpx class DeserializerGpx