diff --git a/common/jinja/value.cpp b/common/jinja/value.cpp index 6edd8b96f..aed834ce4 100644 --- a/common/jinja/value.cpp +++ b/common/jinja/value.cpp @@ -8,6 +8,11 @@ #include #include #include +#include +#include +#include +#include +#include #include #include #include @@ -1517,6 +1522,65 @@ void global_from_json(context & ctx, const common_json & json_obj, bool mark_inp } } +// a float as Python writes it (repr, json.dumps): the shortest digits that read back as the same value +static std::string float_to_json(double val) { + if (!std::isfinite(val)) { + return std::isnan(val) ? "nan" : val < 0 ? "-inf" : "inf"; // as before (json.dumps writes NaN and Infinity, str() writes nan and inf) + } + const double mag = std::fabs(val); + + // at a power of 2 the gap to the value below is half the gap above, the nearest digits can miss while the next ones up read back + int exp2 = 0; + const bool is_pow2 = std::frexp(mag, &exp2) == 0.5; + + // a normal double has at most one decimal of up to DBL_DIG digits that reads back as it, the search can start at DBL_DIG digits + // 17 digits always read back + std::string digits; + int exp10 = 0; + for (int prec = mag < DBL_MIN ? 0 : DBL_DIG - 1; digits.empty(); prec++) { + char buf[32]; + snprintf(buf, sizeof(buf), "%.*e", prec, mag); + const char * e = std::strchr(buf, 'e'); + // only the digits, the decimal point depends on the locale + std::string d(buf, e - buf); + d.erase(std::remove_if(d.begin(), d.end(), [](char c) { return c < '0' || c > '9'; }), d.end()); + exp10 = std::atoi(e + 1); + const auto reads_back = [&](const std::string & cand) { + return std::strtod((cand + "e" + std::to_string(exp10 - prec)).c_str(), nullptr) == mag; + }; + if (prec == 16 || reads_back(d)) { + digits = d; + } else if (is_pow2 && d.back() != '9') { + d.back()++; + if (reads_back(d)) { + digits = d; + } + } + } + while (digits.size() > 1 && digits.back() == '0') { + digits.pop_back(); + } + + // fixed notation for 1e-4 <= |val| < 1e16, else d.ddde+XX + const int n = static_cast(digits.size()); + const int point = exp10 + 1; // number of digits before the decimal point + std::string out = std::signbit(val) ? "-" : ""; + if (point > -4 && point <= 16) { + if (point <= 0) { + out += "0." + std::string(-point, '0') + digits; + } else if (point >= n) { + out += digits + std::string(point - n, '0') + ".0"; + } else { + out += digits.substr(0, point) + "." + digits.substr(point); + } + } else { + char buf[16]; + snprintf(buf, sizeof(buf), "e%c%02d", exp10 < 0 ? '-' : '+', std::abs(exp10)); + out += digits.substr(0, 1) + (n > 1 ? "." + digits.substr(1) : "") + buf; + } + return out; +} + // recursively convert value to JSON string // TODO: avoid circular references static void value_to_json_internal(std::ostringstream & oss, const value & val, int curr_lvl, int indent, const std::string_view item_sep, const std::string_view key_sep) { @@ -1534,7 +1598,7 @@ static void value_to_json_internal(std::ostringstream & oss, const value & val, } else if (is_val(val)) { oss << val->as_int(); } else if (is_val(val)) { - oss << val->as_float(); + oss << float_to_json(val->as_float()); } else if (is_val(val)) { oss << "\""; for (char c : val->as_string().str()) { diff --git a/tests/test-jinja.cpp b/tests/test-jinja.cpp index 7649e3624..572b30816 100644 --- a/tests/test-jinja.cpp +++ b/tests/test-jinja.cpp @@ -1026,6 +1026,31 @@ static void test_literals(testing & t) { "3.14" ); + // Python writes the shortest digits that read back as the same value + test_template(t, "tojson floats", + "{{ data|tojson }}", + {{"data", json::array({0.1, 1.0, 100.0, 8.3, 123456789.0, 48.858844, 0.30000000000000004, -0.0, 5.960464477539063e-08})}}, + "[0.1, 1.0, 100.0, 8.3, 123456789.0, 48.858844, 0.30000000000000004, -0.0, 5.960464477539063e-08]" + ); + + test_template(t, "tojson floats, exponent", + "{{ data|tojson }}", + {{"data", json::array({1e15, 1e16, 0.0001, 0.00001, 1.5e10, -2.5e-7, 5e-324, 1.7976931348623157e308})}}, + "[1000000000000000.0, 1e+16, 0.0001, 1e-05, 15000000000.0, -2.5e-07, 5e-324, 1.7976931348623157e+308]" + ); + + test_template(t, "tojson floats, nested with indent=2", + "{{ data|tojson(indent=2) }}", + {{"data", {{"total", 20.0}, {"items", json::array({{{"price", 9.99}, {"qty", 2}}})}}}}, + "{\n \"total\": 20.0,\n \"items\": [\n {\n \"price\": 9.99,\n \"qty\": 2\n }\n ]\n}" + ); + + test_template(t, "float|string", + "{{ a|string }} {{ b|string }} {{ (b * 10)|string }}", + {{"a", 1.0}, {"b", 1e308}}, + "1.0 1e+308 inf" + ); + test_template(t, "string|tojson", "{{ 'hello' | tojson }}", json::object(),