
001package org.hl7.fhir.r5.formats; 002 003public class JsonNumberCanonicalizer { 004 005 /** 006 * Converts a number string to canonical JSON representation per RFC 8785 007 * Following ECMAScript Section 7.1.12.1 algorithm 008 */ 009 public static String toCanonicalJson(String numberString) { 010 try { 011 // Parse as double (IEEE 754 double precision) 012 double value = Double.parseDouble(numberString); 013 014 // Handle special cases 015 if (Double.isNaN(value)) { 016 throw new IllegalArgumentException("NaN is not valid in JSON"); 017 } 018 if (Double.isInfinite(value)) { 019 throw new IllegalArgumentException("Infinity is not valid in JSON"); 020 } 021 022 // Use the ECMAScript-compatible algorithm 023 return doubleToCanonicalString(value); 024 025 } catch (NumberFormatException e) { 026 throw new IllegalArgumentException("Invalid number format: " + numberString); 027 } 028 } 029 030 /** 031 * Implements ECMAScript Number.prototype.toString() algorithm 032 * Based on Section 7.1.12.1 of ECMA-262 with Note 2 enhancement 033 */ 034 private static String doubleToCanonicalString(double value) { 035 // Handle zero (positive and negative zero both become "0") 036 if (value == 0.0) { 037 return "0"; 038 } 039 040 // Handle negative numbers 041 if (value < 0) { 042 return "-" + doubleToCanonicalString(-value); 043 } 044 045 // Apply ECMAScript formatting rules 046 return formatWithEcmaScriptRules(value); 047 } 048 049 /** 050 * Format double using ECMAScript rules per ECMA-262 Section 7.1.12.1 051 * This follows the exact algorithm specified in the ECMAScript standard 052 */ 053 private static String formatWithEcmaScriptRules(double value) { 054 // Step 1: Find the shortest string that round-trips to the same value 055 String result = findShortestString(value); 056 057 // Step 2: Apply ECMAScript notation rules 058 return applyNotationRules(value, result); 059 } 060 061 /** 062 * Find the shortest string representation that converts back to the exact same double 063 */ 064 private static String findShortestString(double value) { 065 // Use Java's built-in algorithm which is close to what we need 066 String javaDefault = Double.toString(value); 067 068 // Try to find a shorter representation 069 String shortest = javaDefault; 070 071 // Try fixed-point notation with different precisions 072 for (int precision = 0; precision <= 17; precision++) { 073 String candidate = String.format("%." + precision + "f", value); 074 candidate = removeTrailingZeros(candidate); 075 076 // Verify round-trip accuracy 077 if (isExactRepresentation(candidate, value) && candidate.length() < shortest.length()) { 078 shortest = candidate; 079 } 080 } 081 082 // Try scientific notation 083 String scientific = String.format("%.15e", value); 084 scientific = cleanupScientificNotation(scientific); 085 if (isExactRepresentation(scientific, value) && scientific.length() < shortest.length()) { 086 shortest = scientific; 087 } 088 089 return shortest; 090 } 091 092 /** 093 * Check if a string representation exactly round-trips to the same double 094 */ 095 private static boolean isExactRepresentation(String str, double original) { 096 try { 097 double parsed = Double.parseDouble(str); 098 return Double.doubleToLongBits(parsed) == Double.doubleToLongBits(original); 099 } catch (NumberFormatException e) { 100 return false; 101 } 102 } 103 104 /** 105 * Apply ECMAScript notation rules to choose between decimal and exponential 106 */ 107 private static String applyNotationRules(double value, String representation) { 108 // Calculate the exponent k (position of most significant digit) 109 int k = calculateExponent(value); 110 111 // ECMAScript rules from Section 7.1.12.1: 112 // - If k <= -7 or k >= 21, use exponential notation 113 // - Otherwise, use decimal notation 114 115 if (k <= -7 || k >= 21) { 116 return formatExponential(value); 117 } else { 118 // Use decimal notation, but ensure proper formatting 119 return formatDecimal(value, k); 120 } 121 } 122 123 /** 124 * Calculate the exponent k as defined in ECMAScript 125 */ 126 private static int calculateExponent(double value) { 127 if (value == 0.0) return 0; 128 129 double abs = Math.abs(value); 130 if (abs >= 1.0) { 131 return (int) Math.floor(Math.log10(abs)); 132 } else { 133 return (int) Math.floor(Math.log10(abs)); 134 } 135 } 136 137 /** 138 * Format in decimal notation following ECMAScript rules 139 */ 140 private static String formatDecimal(double value, int k) { 141 if (k >= 0) { 142 // Large enough for normal decimal representation 143 return removeTrailingZeros(String.format("%.15f", value)); 144 } else { 145 // Small number - use appropriate decimal places 146 int decimalPlaces = Math.max(0, -k + 15); 147 String result = String.format("%." + decimalPlaces + "f", value); 148 return removeTrailingZeros(result); 149 } 150 } 151 152 /** 153 * Format in exponential notation following ECMAScript rules 154 */ 155 private static String formatExponential(double value) { 156 // Use the format that matches ECMAScript exactly 157 String formatted = String.format("%.15e", value); 158 return cleanupScientificNotation(formatted); 159 } 160 161 /** 162 * Get the effective exponent for ECMAScript formatting decisions 163 */ 164 private static int getEffectiveExponent(double value) { 165 if (value == 0.0) return 0; 166 167 // For ECMAScript, we need the position of the most significant digit 168 // relative to the decimal point 169 double abs = Math.abs(value); 170 if (abs >= 1.0) { 171 return (int) Math.floor(Math.log10(abs)); 172 } else { 173 return (int) Math.floor(Math.log10(abs)); 174 } 175 } 176 177 /** 178 * Convert to scientific notation following ECMAScript rules exactly 179 */ 180 private static String toEcmaScientific(double value) { 181 // Use Java's scientific notation as starting point 182 String formatted = String.format("%.16e", value); 183 184 // Parse and reformat to match ECMAScript exactly 185 String[] parts = formatted.toLowerCase().split("e"); 186 String mantissa = removeTrailingZeros(parts[0]); 187 int exp = Integer.parseInt(parts[1]); 188 189 // ECMAScript format: always include sign for exponent 190 String expStr = (exp >= 0) ? "+" + exp : String.valueOf(exp); 191 192 return mantissa + "e" + expStr; 193 } 194 195 /** 196 * ECMAScript-compliant scientific notation 197 */ 198 private static String toEcmaScriptScientific(double value) { 199 // Handle the specific formatting requirements 200 if (value == 0.0) return "0"; 201 202 boolean negative = value < 0; 203 if (negative) value = -value; 204 205 // Find the exponent 206 int exponent = (int) Math.floor(Math.log10(value)); 207 208 // Scale the mantissa 209 double mantissa = value / Math.pow(10, exponent); 210 211 // Format mantissa with minimal precision 212 String mantissaStr = findShortestMantissa(mantissa); 213 214 // Format exponent with proper sign 215 String expStr = (exponent >= 0) ? "+" + exponent : String.valueOf(exponent); 216 217 String result = mantissaStr + "e" + expStr; 218 return negative ? "-" + result : result; 219 } 220 221 /** 222 * Find shortest mantissa representation 223 */ 224 private static String findShortestMantissa(double mantissa) { 225 for (int precision = 1; precision <= 16; precision++) { 226 String candidate = String.format("%." + precision + "f", mantissa); 227 candidate = removeTrailingZeros(candidate); 228 229 double test = Double.parseDouble(candidate); 230 if (Math.abs(test - mantissa) < 1e-15) { 231 return candidate; 232 } 233 } 234 return removeTrailingZeros(String.format("%.15f", mantissa)); 235 } 236 237 /** 238 * Remove trailing zeros from decimal representation 239 */ 240 private static String removeTrailingZeros(String str) { 241 if (!str.contains(".")) { 242 return str; 243 } 244 245 // Remove trailing zeros after decimal point 246 str = str.replaceAll("0+$", ""); 247 248 // Remove decimal point if no fractional part remains 249 if (str.endsWith(".")) { 250 str = str.substring(0, str.length() - 1); 251 } 252 253 return str; 254 } 255 256 /** 257 * More precise implementation using round-trip verification 258 * This should handle the RFC 8785 test cases correctly 259 */ 260 public static String toCanonicalJsonPrecise(String numberString) { 261 double value = Double.parseDouble(numberString); 262 263 // Handle special cases 264 if (value == 0.0) return "0"; 265 if (Double.isNaN(value)) throw new IllegalArgumentException("NaN not allowed"); 266 if (Double.isInfinite(value)) throw new IllegalArgumentException("Infinity not allowed"); 267 268 if (value < 0) { 269 return "-" + toCanonicalJsonPrecise(String.valueOf(-value)); 270 } 271 272 // This is the core algorithm following ECMAScript rules exactly 273 return formatWithEcmaScriptRules(value); 274 } 275 276 private static String cleanupScientificNotation(String str) { 277 if (!str.contains("e")) return str; 278 279 // Convert to lowercase and split 280 str = str.toLowerCase(); 281 String[] parts = str.split("e"); 282 String mantissa = removeTrailingZeros(parts[0]); 283 String exponent = parts[1]; 284 285 // Remove leading zeros from exponent but keep sign 286 if (exponent.startsWith("+")) { 287 exponent = exponent.substring(1); 288 } 289 exponent = String.valueOf(Integer.parseInt(exponent)); // removes leading zeros 290 291 // ECMAScript requires explicit + for positive exponents 292 if (!exponent.startsWith("-")) { 293 exponent = "+" + exponent; 294 } 295 296 return mantissa + "e" + exponent; 297 } 298 299}