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:
+27
-6
@@ -1,10 +1,12 @@
|
|||||||
#include "core/project.hpp"
|
#include "core/project.hpp"
|
||||||
|
|
||||||
|
#include <charconv>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
|
#include <locale>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
|
|
||||||
#include "core/effect.hpp"
|
#include "core/effect.hpp"
|
||||||
@@ -24,7 +26,7 @@ struct Json {
|
|||||||
bool boolean = false;
|
bool boolean = false;
|
||||||
double number = 0.0;
|
double number = 0.0;
|
||||||
bool integral = true; // written without a fraction or exponent
|
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<Json> items;
|
||||||
std::vector<std::pair<std::string, Json>> members;
|
std::vector<std::pair<std::string, Json>> members;
|
||||||
|
|
||||||
@@ -226,11 +228,15 @@ private:
|
|||||||
Json value;
|
Json value;
|
||||||
value.kind = Json::Kind::Number;
|
value.kind = Json::Kind::Number;
|
||||||
value.integral = integral;
|
value.integral = integral;
|
||||||
try {
|
// from_chars rather than stod: a number in JSON always has a point for
|
||||||
value.number = std::stod(text_.substr(start, pos_ - start));
|
// a decimal mark, whatever LC_NUMERIC says the user's decimal mark is.
|
||||||
} catch (const std::exception&) {
|
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");
|
fail("'" + text_.substr(start, pos_ - start) + "' is not a number");
|
||||||
}
|
}
|
||||||
|
value.text.assign(first, last); // kept whole for the seed, see below
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,6 +269,7 @@ std::string quote(const std::string& text) {
|
|||||||
|
|
||||||
std::string number(double value, int digits) {
|
std::string number(double value, int digits) {
|
||||||
std::ostringstream out;
|
std::ostringstream out;
|
||||||
|
out.imbue(std::locale::classic());
|
||||||
out.setf(std::ios::fixed);
|
out.setf(std::ios::fixed);
|
||||||
out.precision(digits);
|
out.precision(digits);
|
||||||
out << value;
|
out << value;
|
||||||
@@ -307,7 +314,12 @@ std::string resolve(const std::string& path, const std::string& directory) {
|
|||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
std::string projectToJson(const Project& project, const std::string& directory) {
|
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;
|
std::ostringstream out;
|
||||||
|
out.imbue(std::locale::classic());
|
||||||
out << "{\n";
|
out << "{\n";
|
||||||
out << " \"effector_project\": 1,\n";
|
out << " \"effector_project\": 1,\n";
|
||||||
out << " \"canvas\": {\"width\": " << project.width << ", \"height\": " << project.height
|
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 (const auto* seed = root.member("seed")) {
|
||||||
if (seed->kind == Json::Kind::Number) {
|
// Taken from the digits rather than from the double: a 64-bit seed has
|
||||||
project.seed = static_cast<std::uint64_t>(seed->number);
|
// 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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
|
|
||||||
#include <array>
|
#include <array>
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
|
#include <clocale>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
|
#include <locale>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -671,6 +673,51 @@ int main() {
|
|||||||
}
|
}
|
||||||
check(threw, "a missing project file throws");
|
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);
|
std::filesystem::remove_all(dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user