Skip to content

Commit e0f8f8f

Browse files
jkaliasclaude
andcommitted
Fix non-BMP text round-tripping by replacing <codecvt> (#25)
std::codecvt_utf8<wchar_t> transcodes one code unit per code point, so where wchar_t is 16 bits it mishandled everything above U+FFFF in both directions, and silently: - encoding emitted CESU-8, each surrogate half encoded as its own three-byte sequence (U+1F600 became ED A0 BD ED B8 80 instead of F0 9F 98 80); - decoding truncated the code point to its low 16 bits (U+1F600 became U+F600, and U+10000 became NUL). Nothing threw, so text was corrupted without any indication. The same code was correct where wchar_t is 32 bits, so identical data behaved differently per platform. <codecvt> is additionally deprecated since C++17 with no standard replacement, and this library's baseline is C++11. Conversion now pivots on Unicode code points in internal/utf8.h, and the wchar_t adapters in string_utilities.cc select the UTF-16 or UTF-32 path by the width of wchar_t. The public API is unchanged: text fields remain std::wstring. Malformed input is rejected with std::range_error rather than being altered, matching what std::wstring_convert did for ill-formed sequences. Rejected are overlong encodings, truncated sequences, invalid lead and continuation bytes, surrogates encoded directly in UTF-8, unpaired UTF-16 surrogates, and anything above U+10FFFF. The code-point core is tested directly, so the UTF-16 surrogate path - the one that was broken, and which is unreachable through the wstring API on Linux and macOS - is covered on every platform rather than only on Windows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent fa64920 commit e0f8f8f

5 files changed

Lines changed: 488 additions & 10 deletions

File tree

‎README.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,17 @@ Supported field macros:
136136
| `sqlite_reflection::TimePoint` | `MEMBER_DATETIME(name)` |
137137
| member function declaration | `FUNC(signature)` |
138138

139+
### Text encoding
140+
141+
`MEMBER_TEXT` fields are `std::wstring` in C++ and are stored as UTF-8 in SQLite. The full
142+
Unicode range is supported, including code points above U+FFFF such as emoji and the CJK
143+
extension blocks, and text round-trips unchanged on every supported platform regardless of
144+
whether `wchar_t` is 16 bits (Windows) or 32 bits (Linux, macOS).
145+
146+
Text that is not valid Unicode is rejected rather than silently altered: converting a
147+
`std::wstring` containing unpaired UTF-16 surrogates, or reading a column whose bytes are not
148+
well-formed UTF-8, throws `std::range_error`.
149+
139150
### Nullable fields
140151

141152
Each field macro has a `_NULLABLE` variant (`MEMBER_INT_NULLABLE`, `MEMBER_REAL_NULLABLE`,

‎src/internal/utf8.h‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// MIT License
2+
//
3+
// Copyright (c) 2026 Ioannis Kaliakatsos
4+
//
5+
// Permission is hereby granted, free of charge, to any person obtaining a copy
6+
// of this software and associated documentation files (the "Software"), to deal
7+
// in the Software without restriction, including without limitation the rights
8+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
// copies of the Software, and to permit persons to whom the Software is
10+
// furnished to do so, subject to the following conditions:
11+
//
12+
// The above copyright notice and this permission notice shall be included in all
13+
// copies or substantial portions of the Software.
14+
//
15+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
// SOFTWARE.
22+
23+
#pragma once
24+
#include <cstddef>
25+
#include <string>
26+
27+
#include "reflection_export.h"
28+
29+
namespace sqlite_reflection {
30+
/// Unicode transcoding that does not depend on the platform width of wchar_t.
31+
///
32+
/// The library previously converted text with std::wstring_convert and
33+
/// std::codecvt_utf8<wchar_t>. That facet transcodes UTF-8 to and from a single code unit per
34+
/// code point, so where wchar_t is 16 bits (Windows) it silently mishandled everything above
35+
/// U+FFFF: encoding produced CESU-8 (each surrogate half encoded separately, U+1F600 became
36+
/// ED A0 BD ED B8 80 rather than F0 9F 98 80) and decoding truncated the code point to its low
37+
/// 16 bits (U+1F600 became U+F600, U+10000 became NUL). The same code was correct where
38+
/// wchar_t is 32 bits, so identical data behaved differently per platform. <codecvt> is also
39+
/// deprecated since C++17 with no standard replacement, and this library's baseline is C++11.
40+
///
41+
/// Conversion therefore pivots on Unicode code points here, and the wchar_t adapters in
42+
/// StringUtilities select the UTF-16 or UTF-32 path by the width of wchar_t.
43+
///
44+
/// Every function validates its input and throws std::range_error on anything malformed -
45+
/// matching the behavior std::wstring_convert had for ill-formed sequences. Rejected are
46+
/// overlong encodings, truncated sequences, stray or invalid lead and continuation bytes,
47+
/// surrogate code points encoded directly (CESU-8), unpaired UTF-16 surrogates, and anything
48+
/// above U+10FFFF.
49+
class REFLECTION_EXPORT Utf8 {
50+
public:
51+
/// Encodes Unicode code points as UTF-8
52+
static std::string FromCodePoints(const char32_t* code_points, size_t count);
53+
54+
/// Decodes UTF-8 into Unicode code points
55+
static std::u32string ToCodePoints(const char* utf8_string, size_t byte_count);
56+
57+
/// Encodes Unicode code points as UTF-16 code units, using a surrogate pair above U+FFFF
58+
static std::u16string Utf16FromCodePoints(const char32_t* code_points, size_t count);
59+
60+
/// Decodes UTF-16 code units, combining surrogate pairs, into Unicode code points
61+
static std::u32string CodePointsFromUtf16(const char16_t* utf16_string, size_t count);
62+
};
63+
} // namespace sqlite_reflection

‎src/string_utilities.cc‎

Lines changed: 60 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,66 @@
2222

2323
#include "internal/string_utilities.h"
2424

25-
#include <codecvt>
2625
#include <numeric>
27-
#ifndef _WIN32
28-
#include <locale>
29-
#endif
26+
27+
#include "internal/utf8.h"
3028

3129
using namespace sqlite_reflection;
3230

31+
namespace {
32+
// wchar_t holds UTF-16 code units where it is 16 bits wide (Windows) and Unicode code points
33+
// where it is 32 bits wide (Linux, macOS). Selecting the path by width is what keeps text above
34+
// U+FFFF identical across platforms; a single facet cannot serve both, which is the defect this
35+
// replaced. See internal/utf8.h.
36+
template <size_t WideCharSize>
37+
struct WideCodec;
38+
39+
template <>
40+
struct WideCodec<2> {
41+
static std::u32string ToCodePoints(const std::wstring& wide_string) {
42+
std::u16string code_units;
43+
code_units.reserve(wide_string.size());
44+
for (size_t i = 0; i < wide_string.size(); ++i) {
45+
code_units.push_back(static_cast<char16_t>(wide_string[i]));
46+
}
47+
return Utf8::CodePointsFromUtf16(code_units.data(), code_units.size());
48+
}
49+
50+
static std::wstring FromCodePoints(const std::u32string& code_points) {
51+
const auto code_units = Utf8::Utf16FromCodePoints(code_points.data(), code_points.size());
52+
std::wstring wide_string;
53+
wide_string.reserve(code_units.size());
54+
for (size_t i = 0; i < code_units.size(); ++i) {
55+
wide_string.push_back(static_cast<wchar_t>(code_units[i]));
56+
}
57+
return wide_string;
58+
}
59+
};
60+
61+
template <>
62+
struct WideCodec<4> {
63+
static std::u32string ToCodePoints(const std::wstring& wide_string) {
64+
std::u32string code_points;
65+
code_points.reserve(wide_string.size());
66+
for (size_t i = 0; i < wide_string.size(); ++i) {
67+
code_points.push_back(static_cast<char32_t>(wide_string[i]));
68+
}
69+
return code_points;
70+
}
71+
72+
static std::wstring FromCodePoints(const std::u32string& code_points) {
73+
std::wstring wide_string;
74+
wide_string.reserve(code_points.size());
75+
for (size_t i = 0; i < code_points.size(); ++i) {
76+
wide_string.push_back(static_cast<wchar_t>(code_points[i]));
77+
}
78+
return wide_string;
79+
}
80+
};
81+
82+
typedef WideCodec<sizeof(wchar_t)> PlatformWideCodec;
83+
} // namespace
84+
3385
std::string StringUtilities::FromInt(int64_t value) {
3486
return std::to_string(value);
3587
}
@@ -45,15 +97,13 @@ std::string StringUtilities::FromDouble(double value) {
4597
}
4698

4799
std::string StringUtilities::ToUtf8(const std::wstring& wide_string) {
48-
std::wstring_convert<std::codecvt_utf8<wchar_t>> converter;
49-
auto utf8_string = converter.to_bytes(wide_string.data(), wide_string.data() + wide_string.size());
50-
return utf8_string;
100+
const auto code_points = PlatformWideCodec::ToCodePoints(wide_string);
101+
return Utf8::FromCodePoints(code_points.data(), code_points.size());
51102
}
52103

53104
std::wstring StringUtilities::FromUtf8(const char* utf8_string, size_t byte_count) {
54-
std::wstring_convert<std::codecvt_utf8<wchar_t>> converter;
55-
auto wide_string = converter.from_bytes(utf8_string, utf8_string + byte_count);
56-
return wide_string;
105+
const auto code_points = Utf8::ToCodePoints(utf8_string, byte_count);
106+
return PlatformWideCodec::FromCodePoints(code_points);
57107
}
58108

59109
std::string StringUtilities::Join(const std::vector<std::string>& list, const std::string& separator) {

0 commit comments

Comments
 (0)