Skip to content

Commit e015fde

Browse files
committed
BUG: NrrdIO reads and writes floats locale-independently
All NRRD float parsing funnels through airSingleSscanf() and all float formatting (header fields and ASCII pixels) through airSinglePrintf(). Both honored the ambient LC_NUMERIC locale: under a decimal-comma locale (e.g. de_DE.UTF-8) "0.878906" parsed as 0, and the writer emitted "0,878906", corrupting NRRD spacings, directions, origins, and ASCII-encoded pixel values in both directions. Parse with strtod_l() and print with the printf_l() family (glibc/musl lack printf_l; there printf honors the uselocale() thread-local locale, so swap it around the print) against one cached "C" locale shared via privateAir.h. Overflow (ERANGE with +/-HUGE_VAL) and an unobtainable "C" locale fail the operation instead of yielding a wrong value; ERANGE underflow is a valid subnormal or zero and is accepted. With NrrdIO locale-independent by construction, the NumericLocale RAII guards in itk::NrrdImageIO are unnecessary and are removed; that RAII mechanism is a no-op on macOS 13 and earlier libc, whose non-_l strtod/printf ignore the thread-local locale. itkNrrdLocaleTest now loops over four decimal-comma locales, adds a write-under-comma-locale round trip, and warns loudly when no comma locale is installed. Mirrors the same change on the teem upstream branch locale-independent-numeric-parse.
1 parent 0375e07 commit e015fde

7 files changed

Lines changed: 230 additions & 31 deletions

File tree

Modules/IO/NRRD/src/itkNrrdImageIO.cxx

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
#include "itkMetaDataObject.h"
2323
#include "itkIOCommon.h"
2424
#include "itkFloatingPointExceptions.h"
25-
#include "itkNumericLocale.h"
2625
#include "itkNumberToString.h"
2726

2827
#include <cstdio>
@@ -599,12 +598,6 @@ NrrdImageIO::ReadImageInformation()
599598
FloatingPointExceptions::Disable();
600599
}
601600

602-
// Set LC_NUMERIC to "C" locale to ensure locale-independent parsing
603-
// of floating-point values in NRRD headers (spacing, origin, direction, etc.).
604-
// This prevents issues in locales that use comma as decimal separator.
605-
// Using thread-safe NumericLocale from ITKCommon.
606-
NumericLocale cLocale;
607-
608601
// this is the mechanism by which we tell nrrdLoad to read
609602
// just the header, and none of the data
610603
nrrdIoStateSet(nio, nrrdIoStateSkipData, 1);
@@ -1138,11 +1131,6 @@ NrrdImageIO::Read(void * buffer)
11381131
FloatingPointExceptions::Disable();
11391132
#endif
11401133

1141-
// Set LC_NUMERIC to "C" locale to ensure locale-independent parsing
1142-
// of floating-point values in NRRD headers.
1143-
// Using thread-safe NumericLocale from ITKCommon.
1144-
NumericLocale cLocale;
1145-
11461134
// Read in the nrrd. Yes, this means that the header is being read
11471135
// twice: once by NrrdImageIO::ReadImageInformation, and once here
11481136
if (nrrdLoad(nrrd, this->GetFileName(), nullptr) != 0)
@@ -1460,11 +1448,6 @@ NrrdImageIO::Write(const void * buffer)
14601448
break;
14611449
}
14621450

1463-
// Set LC_NUMERIC to "C" locale to ensure locale-independent formatting
1464-
// of floating-point values when writing NRRD headers.
1465-
// Using thread-safe NumericLocale from ITKCommon.
1466-
NumericLocale cLocale;
1467-
14681451
// Write the nrrd to file.
14691452
if (nrrdSave(this->GetFileName(), nrrd, nio))
14701453
{

Modules/IO/NRRD/test/itkNrrdLocaleTest.cxx

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -123,11 +123,18 @@ itkNrrdLocaleTest(int argc, char * argv[])
123123
}
124124
}
125125

126-
// Test 2: Read with de_DE locale (the problematic case)
127-
// Try to set German locale; if not available, skip this part
128-
if (setlocale(LC_NUMERIC, "de_DE.UTF-8") != nullptr)
126+
// Test 2: read under a decimal-comma locale -- the only path exercising the
127+
// locale-independent parse; warn loudly below when none is installed.
128+
const char * const commaLocales[] = { "de_DE.UTF-8", "fr_FR.UTF-8", "nl_NL.UTF-8", "it_IT.UTF-8" };
129+
unsigned int localesTested = 0;
130+
for (const char * const commaLocale : commaLocales)
129131
{
130-
std::cout << "Testing with de_DE.UTF-8 locale..." << std::endl;
132+
if (setlocale(LC_NUMERIC, commaLocale) == nullptr)
133+
{
134+
continue;
135+
}
136+
++localesTested;
137+
std::cout << "Testing with " << commaLocale << " locale..." << std::endl;
131138

132139
using ReaderType = itk::ImageFileReader<ImageType>;
133140
auto reader = ReaderType::New();
@@ -140,15 +147,15 @@ itkNrrdLocaleTest(int argc, char * argv[])
140147
ImageType::SpacingType readSpacing = readImage->GetSpacing();
141148
ImageType::PointType readOrigin = readImage->GetOrigin();
142149

143-
std::cout << "de_DE locale - Read spacing: " << readSpacing << std::endl;
144-
std::cout << "de_DE locale - Read origin: " << readOrigin << std::endl;
150+
std::cout << commaLocale << " - Read spacing: " << readSpacing << std::endl;
151+
std::cout << commaLocale << " - Read origin: " << readOrigin << std::endl;
145152

146153
// Verify spacing - this is the critical test
147154
for (unsigned int i = 0; i < Dimension; ++i)
148155
{
149156
if (itk::Math::Absolute(readSpacing[i] - spacing[i]) > 1e-6)
150157
{
151-
std::cerr << "Spacing mismatch in de_DE locale at index " << i << std::endl;
158+
std::cerr << "Spacing mismatch in " << commaLocale << " locale at index " << i << std::endl;
152159
std::cerr << "Expected: " << spacing[i] << ", Got: " << readSpacing[i] << std::endl;
153160
std::cerr << "This indicates locale-dependent parsing is still occurring!" << std::endl;
154161
return EXIT_FAILURE;
@@ -160,19 +167,57 @@ itkNrrdLocaleTest(int argc, char * argv[])
160167
{
161168
if (itk::Math::Absolute(readOrigin[i] - origin[i]) > 1e-6)
162169
{
163-
std::cerr << "Origin mismatch in de_DE locale at index " << i << std::endl;
170+
std::cerr << "Origin mismatch in " << commaLocale << " locale at index " << i << std::endl;
164171
std::cerr << "Expected: " << origin[i] << ", Got: " << readOrigin[i] << std::endl;
165172
std::cerr << "This indicates locale-dependent parsing is still occurring!" << std::endl;
166173
return EXIT_FAILURE;
167174
}
168175
}
176+
}
177+
setlocale(LC_NUMERIC, "C");
169178

170-
// Restore C locale
179+
// Test 3: WRITE under a decimal-comma locale, read back under "C". A
180+
// locale-dependent writer would emit "0,878906" and corrupt the file.
181+
for (const char * const commaLocale : commaLocales)
182+
{
183+
if (setlocale(LC_NUMERIC, commaLocale) == nullptr)
184+
{
185+
continue;
186+
}
187+
const std::string commaFilename = std::string(argv[1]) + "/locale_test_written_under_comma.nrrd";
188+
auto commaWriter = WriterType::New();
189+
commaWriter->SetFileName(commaFilename);
190+
commaWriter->SetInput(image);
191+
commaWriter->SetImageIO(itk::NrrdImageIO::New());
192+
ITK_TRY_EXPECT_NO_EXCEPTION(commaWriter->Update());
171193
setlocale(LC_NUMERIC, "C");
194+
195+
using ReaderType = itk::ImageFileReader<ImageType>;
196+
auto reader = ReaderType::New();
197+
reader->SetFileName(commaFilename);
198+
reader->SetImageIO(itk::NrrdImageIO::New());
199+
ITK_TRY_EXPECT_NO_EXCEPTION(reader->Update());
200+
201+
const ImageType::SpacingType readSpacing = reader->GetOutput()->GetSpacing();
202+
for (unsigned int i = 0; i < Dimension; ++i)
203+
{
204+
if (itk::Math::Absolute(readSpacing[i] - spacing[i]) > 1e-6)
205+
{
206+
std::cerr << "Spacing mismatch after writing under " << commaLocale << " at index " << i << std::endl;
207+
std::cerr << "Expected: " << spacing[i] << ", Got: " << readSpacing[i] << std::endl;
208+
std::cerr << "This indicates locale-dependent FORMATTING during write!" << std::endl;
209+
return EXIT_FAILURE;
210+
}
211+
}
212+
break; // one comma locale suffices for the write path
172213
}
173-
else
214+
setlocale(LC_NUMERIC, "C");
215+
216+
if (localesTested == 0)
174217
{
175-
std::cout << "de_DE.UTF-8 locale not available, skipping locale-specific test" << std::endl;
218+
std::cout << "WARNING: no decimal-comma locale installed; the locale-independent "
219+
<< "parse and format paths were NOT exercised on this runner. Install "
220+
<< "e.g. de_DE.UTF-8 to cover them." << std::endl;
176221
}
177222

178223
std::cout << "Test finished successfully." << std::endl;

Modules/ThirdParty/NrrdIO/src/CMakeLists.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ set(ZLIB_LIBRARY ITK::ITKZLIBModule)
1414
list(APPEND NRRDIO_COMPRESSION_LIBRARIES ITK::ITKZLIBModule)
1515
if(UNIX)
1616
list(APPEND NRRDIO_COMPRESSION_LIBRARIES m)
17+
# parseAir.c uses pthread_once for its cached "C" locale; on glibc < 2.34
18+
# pthread_once lives in libpthread, not libc.
19+
find_package(Threads REQUIRED)
20+
list(APPEND NRRDIO_COMPRESSION_LIBRARIES Threads::Threads)
1721
list(REMOVE_DUPLICATES NRRDIO_COMPRESSION_LIBRARIES)
1822
endif()
1923

Modules/ThirdParty/NrrdIO/src/NrrdIO/itk_NrrdIO_mangle.h.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ number, from inclusion.
113113
#define airTeemReleaseDone @MANGLE_PREFIX@_airTeemReleaseDone
114114
#define airTeemVersion @MANGLE_PREFIX@_airTeemVersion
115115
#define airTeemVersionSprint @MANGLE_PREFIX@_airTeemVersionSprint
116+
#define air__CLocale @MANGLE_PREFIX@_air__CLocale
116117
#define air__SanityHelper @MANGLE_PREFIX@_air__SanityHelper
117118
#define biffAdd @MANGLE_PREFIX@_biffAdd
118119
#define biffAddf @MANGLE_PREFIX@_biffAddf

Modules/ThirdParty/NrrdIO/src/NrrdIO/miscAir.c

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,25 @@
2323
3. This notice may not be removed or altered from any source distribution.
2424
*/
2525

26+
/* Locale-independent numeric printing for airSinglePrintf; the API selection
27+
must precede the first system-header include (NrrdIO.h pulls in <stdio.h>).
28+
BSD/macOS and Windows have printf-family *_l variants; glibc/musl lack
29+
them, but their printf honors the uselocale() thread-local locale. */
30+
#if defined(__APPLE__) || defined(__FreeBSD__) || defined(__NetBSD__) \
31+
|| defined(__OpenBSD__) || defined(__DragonFly__)
32+
# include <xlocale.h>
33+
# define TEEM_HAS_PRINTF_L 1
34+
#elif defined(__linux__)
35+
# ifndef _GNU_SOURCE
36+
# define _GNU_SOURCE 1
37+
# endif
38+
# include <locale.h>
39+
# define TEEM_HAS_USELOCALE 1
40+
#elif defined(_MSC_VER)
41+
# include <locale.h>
42+
# define TEEM_HAS_PRINTF_L_WIN 1
43+
#endif
44+
2645
#include "NrrdIO.h"
2746
#include "privateAir.h"
2847
/* timer functions */
@@ -211,8 +230,27 @@ airSinglePrintf(FILE *file, char *str, size_t strSize, const char *_fmt, ...) {
211230
int ret, isF, isD;
212231
const char *_p0, *_p1, *_p2, *_p3, *_p4, *_p5;
213232
va_list ap;
233+
#if defined(TEEM_HAS_PRINTF_L) || defined(TEEM_HAS_USELOCALE)
234+
locale_t _aspCloc = (locale_t)air__CLocale();
235+
#elif defined(TEEM_HAS_PRINTF_L_WIN)
236+
_locale_t _aspCloc = (_locale_t)air__CLocale();
237+
#endif
238+
#if defined(TEEM_HAS_USELOCALE)
239+
/* swap this thread to the "C" numeric locale for the whole function;
240+
restored at the single exit below */
241+
locale_t _aspOld = _aspCloc ? uselocale(_aspCloc) : (locale_t)0;
242+
#endif
214243

215244
va_start(ap, _fmt);
245+
#if defined(TEEM_HAS_PRINTF_L) || defined(TEEM_HAS_USELOCALE) \
246+
|| defined(TEEM_HAS_PRINTF_L_WIN)
247+
/* cannot print locale-independently without the cached "C" locale: fail
248+
(printf error convention) rather than risk a wrong decimal separator */
249+
if (!_aspCloc) {
250+
va_end(ap);
251+
return -1;
252+
}
253+
#endif
216254

217255
/* look for unmodified floating point conversion sequences */
218256
_p0 = strstr(_fmt, "%e");
@@ -228,10 +266,22 @@ airSinglePrintf(FILE *file, char *str, size_t strSize, const char *_fmt, ...) {
228266
"is 3-character conv. seq."
229267
(but for TeemV2 we run with that type implication for "%g" and "%lg") */
230268
if (isF || isD) {
269+
#if defined(TEEM_HAS_PRINTF_L)
270+
#define PRINT(F, S, C, V) \
271+
((F) /* */ \
272+
? fprintf_l((F), _aspCloc, (C), (V)) \
273+
: snprintf_l((S), strSize, _aspCloc, (C), (V)))
274+
#elif defined(TEEM_HAS_PRINTF_L_WIN)
275+
#define PRINT(F, S, C, V) \
276+
((F) /* */ \
277+
? _fprintf_l((F), (C), _aspCloc, (V)) \
278+
: _snprintf_l((S), strSize, (C), _aspCloc, (V)))
279+
#else
231280
#define PRINT(F, S, C, V) \
232281
((F) /* */ \
233282
? fprintf((F), (C), (V)) \
234283
: snprintf((S), strSize, (C), (V)))
284+
#endif
235285
double val0;
236286
const char *_conv;
237287
size_t fmtSize;
@@ -342,9 +392,20 @@ airSinglePrintf(FILE *file, char *str, size_t strSize, const char *_fmt, ...) {
342392
more specific like "%.17g" (which NOTE does not get the above special treatment of
343393
IEEE754 special values!s), or, it isn't for any kind of floating point value.
344394
We can use the given `_fmt` as is (no local allocation of `fmt`) */
395+
#if defined(TEEM_HAS_PRINTF_L)
396+
ret = file ? vfprintf_l(file, _aspCloc, _fmt, ap) /* */
397+
: vsnprintf_l(str, strSize, _aspCloc, _fmt, ap);
398+
#elif defined(TEEM_HAS_PRINTF_L_WIN)
399+
ret = file ? _vfprintf_l(file, _fmt, _aspCloc, ap) /* */
400+
: _vsnprintf_l(str, strSize, _fmt, _aspCloc, ap);
401+
#else
345402
ret = file ? vfprintf(file, _fmt, ap) : vsnprintf(str, strSize, _fmt, ap);
403+
#endif
346404
}
347405

406+
#if defined(TEEM_HAS_USELOCALE)
407+
uselocale(_aspOld);
408+
#endif
348409
va_end(ap);
349410
return ret;
350411
}

Modules/ThirdParty/NrrdIO/src/NrrdIO/parseAir.c

Lines changed: 104 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,75 @@
2323
3. This notice may not be removed or altered from any source distribution.
2424
*/
2525

26+
/* Enable the POSIX-2008 thread-safe locale API (newlocale/strtod_l) where
27+
available; must precede the first system-header include (NrrdIO.h pulls in
28+
<stdlib.h>). TEEM_HAS_STRTOD_L selects a locale-independent numeric parse
29+
in airSingleSscanf(); platforms lacking it keep the historical sscanf(). */
30+
#if defined(__APPLE__) || defined(__FreeBSD__) || defined(__NetBSD__) \
31+
|| defined(__OpenBSD__) || defined(__DragonFly__)
32+
# include <xlocale.h>
33+
# include <pthread.h>
34+
# define TEEM_HAS_STRTOD_L 1
35+
#elif defined(__linux__)
36+
/* __GLIBC__ comes from <features.h>, not a compiler predefine, so it is unset
37+
at this pre-include point; key off __linux__ (glibc, and musl >= 1.2.1,
38+
provide newlocale/strtod_l under _GNU_SOURCE). */
39+
# ifndef _GNU_SOURCE
40+
# define _GNU_SOURCE 1
41+
# endif
42+
# include <locale.h>
43+
# include <pthread.h>
44+
# define TEEM_HAS_STRTOD_L 1
45+
#elif defined(_MSC_VER)
46+
# include <locale.h>
47+
# include <intrin.h>
48+
# define TEEM_HAS_STRTOD_L_WIN 1
49+
#endif
50+
2651
#include "NrrdIO.h"
52+
#include "privateAir.h"
53+
54+
#if defined(TEEM_HAS_STRTOD_L)
55+
/* Cached "C" locale for locale-independent strtod_l; created once. */
56+
static locale_t _airCLocale = (locale_t)0;
57+
static pthread_once_t _airCLocaleOnce = PTHREAD_ONCE_INIT;
58+
static void
59+
_airCLocaleInit(void) {
60+
_airCLocale = newlocale(LC_NUMERIC_MASK, "C", (locale_t)0);
61+
}
62+
static locale_t
63+
_airGetCLocale(void) {
64+
pthread_once(&_airCLocaleOnce, _airCLocaleInit);
65+
return _airCLocale;
66+
}
67+
#elif defined(TEEM_HAS_STRTOD_L_WIN)
68+
/* Cached "C" locale published exactly once via an atomic pointer CAS, so
69+
concurrent first use is race-free: the losing thread frees its duplicate. */
70+
static void *volatile _airCLocale = NULL;
71+
static _locale_t
72+
_airGetCLocale(void) {
73+
_locale_t loc = (_locale_t)_airCLocale;
74+
if (!loc) {
75+
_locale_t created = _create_locale(LC_NUMERIC, "C");
76+
void *prev = _InterlockedCompareExchangePointer(&_airCLocale, created, NULL);
77+
if (prev) { _free_locale(created); loc = (_locale_t)prev; }
78+
else { loc = created; }
79+
}
80+
return loc;
81+
}
82+
#endif
83+
84+
/* Cached "C" LC_NUMERIC locale as an opaque pointer (locale_t or _locale_t);
85+
NULL when the platform lacks the thread-safe locale API or creation failed.
86+
Shared with airSinglePrintf (miscAir.c) via privateAir.h. */
87+
void *
88+
air__CLocale(void) {
89+
#if defined(TEEM_HAS_STRTOD_L) || defined(TEEM_HAS_STRTOD_L_WIN)
90+
return (void *)_airGetCLocale();
91+
#else
92+
return NULL;
93+
#endif
94+
}
2795

2896
/* clang-format off */
2997
static const char *
@@ -105,12 +173,45 @@ airSingleSscanf(const char *str, const char *fmt, void *ptr) {
105173
} else if (strstr(tmp, "inf")) {
106174
val = (double)AIR_POS_INF;
107175
} else {
108-
/* nothing special matched; pass it off to sscanf() */
109-
/* (save setlocale here) */
176+
/* nothing special matched; parse one floating-point value with a
177+
locale-independent "C" parse so NRRD's '.' decimal separator is
178+
honored regardless of the process/thread LC_NUMERIC setting. */
179+
#if defined(TEEM_HAS_STRTOD_L) || defined(TEEM_HAS_STRTOD_L_WIN)
180+
char *endptr = NULL;
181+
double dval = 0.0;
182+
ret = 0;
183+
/* a null "C" locale means we cannot parse locale-independently:
184+
fail the parse rather than risk a silently wrong value */
185+
# if defined(TEEM_HAS_STRTOD_L)
186+
locale_t cloc = _airGetCLocale();
187+
if (cloc) {
188+
errno = 0;
189+
dval = strtod_l(str, &endptr, cloc);
190+
# else
191+
_locale_t cloc = _airGetCLocale();
192+
if (cloc) {
193+
errno = 0;
194+
dval = _strtod_l(str, &endptr, cloc);
195+
# endif
196+
/* accept a conversion unless it overflowed (ERANGE with +/-HUGE_VAL);
197+
ERANGE underflow is a valid subnormal or zero */
198+
if (endptr != str
199+
&& !(ERANGE == errno && (HUGE_VAL == dval || -HUGE_VAL == dval))) {
200+
ret = 1;
201+
}
202+
}
203+
if (ret) {
204+
if (fmt[1] == 'l') { *((double *)(ptr)) = dval; }
205+
else { *((float *)(ptr)) = AIR_FLOAT(dval); }
206+
}
207+
free(tmp);
208+
return ret;
209+
#else
210+
/* fallback: historical locale-dependent behavior, unchanged */
110211
ret = sscanf(str, fmt, ptr);
111-
/* (return setlocale here) */
112212
free(tmp);
113213
return ret;
214+
#endif
114215
}
115216
/* else we matched "nan", "-inf", or "inf"; now set val accordingly */
116217
if (fmt[1] == 'l') {

0 commit comments

Comments
 (0)