Write and read projects in the C locale, and keep the seed whole

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 <noreply@anthropic.com>
This commit is contained in:
Si
2026-09-18 20:30:55 +02:00
co-authored by Claude Opus 5
parent e2252ecd30
commit c34bc426fc
2 changed files with 74 additions and 6 deletions
+27 -6
View File
@@ -1,10 +1,12 @@
#include "core/project.hpp"
#include <charconv>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <locale>
#include <sstream>
#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<Json> items;
std::vector<std::pair<std::string, Json>> 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<std::uint64_t>(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");
}
}