From c34bc426fcd2722490671b322946f030e8254ca5 Mon Sep 17 00:00:00 2001 From: Si Date: Fri, 18 Sep 2026 20:30:55 +0200 Subject: [PATCH] Write and read projects in the C locale, and keep the seed whole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gtkmm sets both the C and the C++ global locale from the environment, so an unguarded stream writes "0,35" on a German desktop and groups 1920 as "1.920" — neither of which the reader, or any other JSON reader, can take back. Both the writer and the number parser now work in the classic locale: the writer imbues its stream, and the parser uses from_chars rather than stod, JSON having a point for a decimal mark whatever the user's mark is. The seed is read from the digits it was written as rather than from the double they parsed into: a 64-bit seed has more bits than a double has mantissa, and a seed that comes back off by a few is a project that no longer renders its own picture. Co-Authored-By: Claude Opus 5 --- src/core/project.cpp | 33 +++++++++++++++++++++++++------ tests/core_tests.cpp | 47 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/src/core/project.cpp b/src/core/project.cpp index f30303c..cc9b041 100644 --- a/src/core/project.cpp +++ b/src/core/project.cpp @@ -1,10 +1,12 @@ #include "core/project.hpp" +#include #include #include #include #include #include +#include #include #include "core/effect.hpp" @@ -24,7 +26,7 @@ struct Json { bool boolean = false; double number = 0.0; bool integral = true; // written without a fraction or exponent - std::string text; + std::string text; // the string, or the digits a number was written as std::vector items; std::vector> members; @@ -226,11 +228,15 @@ private: Json value; value.kind = Json::Kind::Number; value.integral = integral; - try { - value.number = std::stod(text_.substr(start, pos_ - start)); - } catch (const std::exception&) { + // from_chars rather than stod: a number in JSON always has a point for + // a decimal mark, whatever LC_NUMERIC says the user's decimal mark is. + const char* const first = text_.data() + start + (text_[start] == '+' ? 1 : 0); + const char* const last = text_.data() + pos_; + const auto parsed = std::from_chars(first, last, value.number); + if (parsed.ec != std::errc() || parsed.ptr != last) { fail("'" + text_.substr(start, pos_ - start) + "' is not a number"); } + value.text.assign(first, last); // kept whole for the seed, see below return value; } @@ -263,6 +269,7 @@ std::string quote(const std::string& text) { std::string number(double value, int digits) { std::ostringstream out; + out.imbue(std::locale::classic()); out.setf(std::ios::fixed); out.precision(digits); out << value; @@ -307,7 +314,12 @@ std::string resolve(const std::string& path, const std::string& directory) { } // namespace std::string projectToJson(const Project& project, const std::string& directory) { + // JSON is written in the C locale whatever the user's is: a decimal comma + // or a digit grouping would make the file something this reader — or any + // other — cannot read back. gtkmm sets the global locale from the + // environment, so a stream left to its default is the user's locale. std::ostringstream out; + out.imbue(std::locale::classic()); out << "{\n"; out << " \"effector_project\": 1,\n"; out << " \"canvas\": {\"width\": " << project.width << ", \"height\": " << project.height @@ -379,8 +391,17 @@ Project projectFromJson(const std::string& text, const std::string& directory) { } if (const auto* seed = root.member("seed")) { - if (seed->kind == Json::Kind::Number) { - project.seed = static_cast(seed->number); + // Taken from the digits rather than from the double: a 64-bit seed has + // more bits than a double has mantissa, and a seed that comes back off + // by a few is a project that no longer renders its own picture. + std::uint64_t value = 0; + const char* const first = seed->text.data(); + const char* const last = first + seed->text.size(); + if (seed->kind == Json::Kind::Number && seed->integral && + std::from_chars(first, last, value).ptr == last) { + project.seed = value; + } else if (seed->kind == Json::Kind::Number) { + throw EffectError("the seed must be a whole number that fits in 64 bits"); } } diff --git a/tests/core_tests.cpp b/tests/core_tests.cpp index 4ccffdf..951f4ff 100644 --- a/tests/core_tests.cpp +++ b/tests/core_tests.cpp @@ -2,11 +2,13 @@ #include #include +#include #include #include #include #include #include +#include #include #include #include @@ -671,6 +673,51 @@ int main() { } check(threw, "a missing project file throws"); + // A project is JSON whatever the user's locale is. gtkmm sets both the + // C and the C++ global locale from the environment, so on a German + // desktop an unguarded stream writes "0,35" and a grouped "1.920" — + // which is not JSON, and the project could not be opened again. + const char* const commaLocales[] = {"de_DE.UTF-8", "de_DE.utf8", "fr_FR.UTF-8", + "fr_FR.utf8"}; + bool decimalCommaTried = false; + for (const char* name : commaLocales) { + if (std::setlocale(LC_ALL, name) == nullptr) continue; + std::locale previous; + try { + previous = std::locale::global(std::locale(name)); + } catch (const std::exception&) { + continue; + } + decimalCommaTried = true; + + Project wide; + wide.width = 1920; + wide.height = 1080; + wide.seed = 17353667313003709502ULL; // more bits than a double holds + wide.stages.push_back({effectByName("grain"), {{"grain_amount", 0.35}}}); + + const auto text = projectToJson(wide); + bool reread = false; + try { + const auto back = projectFromJson(text); + reread = back.width == 1920 && back.height == 1080 && + back.seed == wide.seed && + paramDouble(back.stages[0].values, "grain_amount", -1.0) == 0.35; + } catch (const EffectError&) { + } + check(text.find("0.35") != std::string::npos && + text.find("\"width\": 1920") != std::string::npos, + std::string("a project is written as JSON under ") + name); + check(reread, std::string("a project written under ") + name + " opens again"); + + std::locale::global(previous); + std::setlocale(LC_ALL, "C"); + break; + } + if (!decimalCommaTried) { + std::cout << " skip a decimal-comma locale (none installed)\n"; + } + std::filesystem::remove_all(dir); }