[Fork] https://stackoverflow.com/questions/66309150/how-do-i-write-a-float-like-82-0-with-the-0-intact-using-utf8jsonwriter

CSharp · Console

/Program.cs

using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Runtime.Serialization.Formatters;
using System.IO;
using System.Data;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Reflection;
using System.Collections.ObjectModel;
using System.Buffers.Text;

using System.Text.Json;
using System.Text.Json.Serialization;

using NUnit.Framework; // Throws NUnit.Framework.AssertionException

//https://stackoverflow.com/questions/66309150/how-do-i-write-a-float-like-82-0-with-the-0-intact-using-utf8jsonwriter

	public class DoubleConverter : JsonConverter<double>
	{
		// 2^49 is the largest power of 2 with fewer than 15 decimal digits.  
		// From experimentation casting to decimal does not lose precision for these values.
		const double MaxPreciselyRepresentedIntValue = (1L<<49);
	
		public override void Write(Utf8JsonWriter writer, double value, JsonSerializerOptions options)
		{
			bool written = false;
			// For performance check to see that the incoming double is an integer
			if ((value % 1) == 0)
			{
				if (value < MaxPreciselyRepresentedIntValue && value > -MaxPreciselyRepresentedIntValue)
				{
					writer.WriteNumberValue(0.0m + (decimal)value);
					written = true;
				}
				else
				{
					// Directly casting these larger values from double to decimal seems to result in precision loss, as noted in  https://stackoverflow.com/q/7453900/3744182
					// And also: https://docs.microsoft.com/en-us/dotnet/api/system.convert.todecimal?redirectedfrom=MSDN&view=net-5.0#System_Convert_ToDecimal_System_Double_
					// > The Decimal value returned by Convert.ToDecimal(Double) contains a maximum of 15 significant digits.
					// So if we want the full G17 precision we have to format and parse ourselves.
					//
					// Utf8Formatter and Utf8Parser should give the best performance for this, but, according to MSFT, 
					// on frameworks earlier than .NET Core 3.0 Utf8Formatter does not produce roundtrippable strings.  For details see
					// https://github.com/dotnet/runtime/blob/eb03e0f7bc396736c7ac59cf8f135d7c632860dd/src/libraries/System.Text.Json/src/System/Text/Json/Writer/Utf8JsonWriter.WriteValues.Double.cs#L103
					// You may want format to string and parse in earlier frameworks -- or just use JsonDocument on these earlier versions.
					Span<byte> utf8bytes = stackalloc byte[32];
					if (Utf8Formatter.TryFormat(value, utf8bytes.Slice(0, utf8bytes.Length-2), out var bytesWritten)
						&& IsInteger(utf8bytes, bytesWritten))
					{
						utf8bytes[bytesWritten++] = (byte)'.';
						utf8bytes[bytesWritten++] = (byte)'0';
						if (Utf8Parser.TryParse(utf8bytes.Slice(0, bytesWritten), out decimal d, out var _))
						{
							writer.WriteNumberValue(d);
							written = true;
						}	
					}
				}
			}
			if (!written)
			{
				if (double.IsFinite(value))
					writer.WriteNumberValue(value);
				else
					// Utf8JsonWriter does not take into account JsonSerializerOptions.NumberHandling so we have to make a recursive call to serialize
					JsonSerializer.Serialize(writer, value, new JsonSerializerOptions { NumberHandling = options.NumberHandling });
			}
		}
		
		static bool IsInteger(Span<byte> utf8bytes, int bytesWritten)
		{
			if (bytesWritten <= 0)
				return false;
			var start = utf8bytes[0] == '-' ? 1 : 0;
			for (var i = start; i < bytesWritten; i++)
				if (!(utf8bytes[i] >= '0' && utf8bytes[i] <= '9'))
					return false;
			return start < bytesWritten;
		}
		
		public override double Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => 
			// TODO: Handle "NaN", "Infinity", "-Infinity"
			reader.GetDouble();
	}

    class TestClass
    {
        public static void Test()
        {
			Console.WriteLine($"Math.Log2(1e15)={Math.Log2(1e15)}\n");
			
			var doubles = new []
			{
				82.0,
				82.1,
				-82.0,
				7e27,
				78e27,
				0, 1, 2,
				-1, -2,
				1 + 1e-14,
				1 + 1e-15,
				1 + 1e-16,
				9999999999999992,
				(1L<<52)-3,
				(1L<<52)-2,
				(1L<<52)-1,
				(1L<<52),
				(1L<<52)+1,
				(1L<<52)+2,
				(1L<<52)+3,
				(1L<<53)-3,
				(1L<<53)-2,
				(1L<<53)-1,
				(1L<<53),
				(1L<<53)+1,
				(1L<<53)+2,
				(1L<<53)+3,
				2L*(1L<<53),
				2L*(1L<<56),
				(double)decimal.MaxValue,
				(double)(0.99m*decimal.MaxValue),
				double.Epsilon,
				-double.Epsilon,
				long.MaxValue,
				-long.MaxValue,
				double.MinValue,
				double.MaxValue,
				double.PositiveInfinity,
				double.NegativeInfinity,
				double.NaN,
			};
			
			var powersOf2 = Enumerable.Range(0, 64)
				.Select(i => 1L<<i).ToList().SelectMany(l => Enumerable.Range(-25, 25).Select(i => (double)((ulong)l + (ulong)i)));
			
			foreach (var d in doubles.Concat(powersOf2))
			{
				var defaultOptions = new JsonSerializerOptions
				{
					WriteIndented = true,
					NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals,
				};
				var options = new JsonSerializerOptions
				{
					Converters = { new DoubleConverter() },
					WriteIndented = true,
					NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals,
				};
				var jsonDefault = JsonSerializer.Serialize(d, defaultOptions);
				var json = JsonSerializer.Serialize(d, options);
				Console.WriteLine("fixed JSON = {0}, Original JSON = {1}", json, jsonDefault);
				// Newtonsoft always appends a .0 for doubles, so assert we are doing the same thing.
				Assert.AreEqual(Newtonsoft.Json.JsonConvert.SerializeObject(d), json, $"Json.NET and System.Text.Json returned inconsistent results for {d}");
			}
		}	
	}

	public class Program
	{
		public static void Main()
		{
			Console.WriteLine("Environment version: {0} ({1})", System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription , GetNetCoreVersion());
			Console.WriteLine("System.Text.Json version: " + typeof(JsonSerializer).Assembly.FullName);
			Console.WriteLine();

			try
			{
				TestClass.Test();
			}
			catch (Exception ex)
			{
				Console.WriteLine("Failed with unhandled exception: ");
				Console.WriteLine(ex);
				throw;
			}
		}
		
		public static string GetNetCoreVersion()
		{
			//https://techblog.dorogin.com/how-to-detect-net-core-version-in-runtime-ecd65ad695be
			//
			var assembly = typeof(System.Runtime.GCSettings).GetTypeInfo().Assembly;
			var assemblyPath = assembly.Location.Split(new[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries);
			int netCoreAppIndex = Array.IndexOf(assemblyPath, "Microsoft.NETCore.App");
			if (netCoreAppIndex > 0 && netCoreAppIndex < assemblyPath.Length - 2)
				return assemblyPath[netCoreAppIndex + 1];
			return null;
		}
	}
;