diff --git a/include/nlohmann/detail/output/binary_writer.hpp b/include/nlohmann/detail/output/binary_writer.hpp index b8e9efa45..d1d4f3345 100644 --- a/include/nlohmann/detail/output/binary_writer.hpp +++ b/include/nlohmann/detail/output/binary_writer.hpp @@ -1647,6 +1647,20 @@ class binary_writer return 'D'; // float 64 } + /*! + @brief checks whether a JSON number fits into @a TargetType + @param[in] el a JSON number of either the signed or unsigned integer kind + @return whether @a el's value can be represented by @a TargetType without + wrapping, regardless of which of the two kinds it is stored as + */ + template + static bool bjdata_ndarray_value_in_range(const BasicJsonType& el) + { + return el.is_number_unsigned() + ? value_in_range_of(el.template get()) + : value_in_range_of(el.template get()); + } + /*! @return false if the object is successfully converted to a bjdata ndarray, true if the type or size is invalid */ @@ -1731,6 +1745,60 @@ class binary_writer } } + // every element is cast to the (possibly narrower) C++ type matching + // dtype below; a value that does not fit that type would silently + // wrap (integers) or overflow to infinity (the "single" precision + // float) instead of being reported, so such an object falls back to + // a plain object encoding as well + for (const auto& el : value.at(key)) + { + bool in_range = true; + switch (dtype) + { + case 'U': + case 'C': + case 'B': + in_range = bjdata_ndarray_value_in_range(el); + break; + case 'i': + in_range = bjdata_ndarray_value_in_range(el); + break; + case 'u': + in_range = bjdata_ndarray_value_in_range(el); + break; + case 'I': + in_range = bjdata_ndarray_value_in_range(el); + break; + case 'm': + in_range = bjdata_ndarray_value_in_range(el); + break; + case 'l': + in_range = bjdata_ndarray_value_in_range(el); + break; + case 'M': + in_range = bjdata_ndarray_value_in_range(el); + break; + case 'L': + in_range = bjdata_ndarray_value_in_range(el); + break; + case 'd': + { + const auto dval = el.template get(); + in_range = !std::isfinite(dval) || + (dval >= static_cast(std::numeric_limits::lowest()) && + dval <= static_cast((std::numeric_limits::max)())); + break; + } + default: + // 'D' (double) already spans the full range of number_float_t + break; + } + if (!in_range) + { + return true; + } + } + oa->write_character('['); oa->write_character('$'); oa->write_character(dtype); diff --git a/single_include/nlohmann/json.hpp b/single_include/nlohmann/json.hpp index 31bfb869d..824dad6e1 100644 --- a/single_include/nlohmann/json.hpp +++ b/single_include/nlohmann/json.hpp @@ -18655,6 +18655,20 @@ class binary_writer return 'D'; // float 64 } + /*! + @brief checks whether a JSON number fits into @a TargetType + @param[in] el a JSON number of either the signed or unsigned integer kind + @return whether @a el's value can be represented by @a TargetType without + wrapping, regardless of which of the two kinds it is stored as + */ + template + static bool bjdata_ndarray_value_in_range(const BasicJsonType& el) + { + return el.is_number_unsigned() + ? value_in_range_of(el.template get()) + : value_in_range_of(el.template get()); + } + /*! @return false if the object is successfully converted to a bjdata ndarray, true if the type or size is invalid */ @@ -18739,6 +18753,60 @@ class binary_writer } } + // every element is cast to the (possibly narrower) C++ type matching + // dtype below; a value that does not fit that type would silently + // wrap (integers) or overflow to infinity (the "single" precision + // float) instead of being reported, so such an object falls back to + // a plain object encoding as well + for (const auto& el : value.at(key)) + { + bool in_range = true; + switch (dtype) + { + case 'U': + case 'C': + case 'B': + in_range = bjdata_ndarray_value_in_range(el); + break; + case 'i': + in_range = bjdata_ndarray_value_in_range(el); + break; + case 'u': + in_range = bjdata_ndarray_value_in_range(el); + break; + case 'I': + in_range = bjdata_ndarray_value_in_range(el); + break; + case 'm': + in_range = bjdata_ndarray_value_in_range(el); + break; + case 'l': + in_range = bjdata_ndarray_value_in_range(el); + break; + case 'M': + in_range = bjdata_ndarray_value_in_range(el); + break; + case 'L': + in_range = bjdata_ndarray_value_in_range(el); + break; + case 'd': + { + const auto dval = el.template get(); + in_range = !std::isfinite(dval) || + (dval >= static_cast(std::numeric_limits::lowest()) && + dval <= static_cast((std::numeric_limits::max)())); + break; + } + default: + // 'D' (double) already spans the full range of number_float_t + break; + } + if (!in_range) + { + return true; + } + } + oa->write_character('['); oa->write_character('$'); oa->write_character(dtype); diff --git a/tests/src/unit-bjdata.cpp b/tests/src/unit-bjdata.cpp index 7d0dd5ff2..78ddf5d8c 100644 --- a/tests/src/unit-bjdata.cpp +++ b/tests/src/unit-bjdata.cpp @@ -2776,6 +2776,53 @@ TEST_CASE("BJData") CHECK(out_num.at(0) == '{'); CHECK(json::from_bjdata(out_num) == j_num); } + + SECTION("ndarray with out-of-range _ArrayData_ elements stays as object") + { + // each element is cast to the (possibly narrower) C++ type + // named by _ArrayType_ before being written; a value that + // does not fit that type would silently wrap instead of + // being reported, so such an object falls back to a plain + // object encoding that still round-trips (see GitHub issue #5403) + + // an unsigned element that does not fit uint8 + json const j_uint8 = json({{"_ArrayType_", "uint8"}, {"_ArraySize_", {2}}, {"_ArrayData_", {1, 256}}}); + const auto out_uint8 = json::to_bjdata(j_uint8); + CHECK(out_uint8.at(0) == '{'); + CHECK(json::from_bjdata(out_uint8) == j_uint8); + + // a signed element that does not fit int8 + json const j_int8 = json({{"_ArrayType_", "int8"}, {"_ArraySize_", {2}}, {"_ArrayData_", {1, 200}}}); + const auto out_int8 = json::to_bjdata(j_int8); + CHECK(out_int8.at(0) == '{'); + CHECK(json::from_bjdata(out_int8) == j_int8); + + // a negative element is likewise out of range for an + // unsigned _ArrayType_ + json const j_uint16_neg = json({{"_ArrayType_", "uint16"}, {"_ArraySize_", {2}}, {"_ArrayData_", {1, -1}}}); + const auto out_uint16_neg = json::to_bjdata(j_uint16_neg); + CHECK(out_uint16_neg.at(0) == '{'); + CHECK(json::from_bjdata(out_uint16_neg) == j_uint16_neg); + + // a double element that overflows to infinity when narrowed + // to the "single" (float) precision named by _ArrayType_ + json const j_single = json({{"_ArrayType_", "single"}, {"_ArraySize_", {2}}, {"_ArrayData_", {1.5, 1e40}}}); + const auto out_single = json::to_bjdata(j_single); + CHECK(out_single.at(0) == '{'); + CHECK(json::from_bjdata(out_single) == j_single); + + // in-range boundary values still use the compact ndarray encoding + json const j_uint8_ok = json({{"_ArrayType_", "uint8"}, {"_ArraySize_", {2}}, {"_ArrayData_", {0, 255}}}); + CHECK(json::to_bjdata(j_uint8_ok) == std::vector({'[', '$', 'U', '#', '[', 'i', 2, ']', 0, 255})); + + json const j_int8_ok = json({{"_ArrayType_", "int8"}, {"_ArraySize_", {2}}, {"_ArrayData_", {-128, 127}}}); + CHECK(json::to_bjdata(j_int8_ok) == std::vector({'[', '$', 'i', '#', '[', 'i', 2, ']', 0x80, 0x7F})); + + json const j_single_ok = json({{"_ArrayType_", "single"}, {"_ArraySize_", {1}}, {"_ArrayData_", {1.5}}}); + const auto out_single_ok = json::to_bjdata(j_single_ok); + CHECK(out_single_ok.at(0) == '['); + CHECK(json::from_bjdata(out_single_ok) == json({1.5f})); + } } }