Skip to content

Commit 6e92e10

Browse files
feat(utf8): add UTF-8 encoding/decoding utilities and update JSON escape function
1 parent e84269b commit 6e92e10

4 files changed

Lines changed: 480 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,18 @@
44

55
### Added
66

7-
- NIL
7+
- **UTF-8 Utilities**: Unicode codepoint encoding and decoding
8+
- `decodeUtf8Codepoint()` - Decode UTF-8 byte sequences to Unicode codepoints with full validation
9+
- `encodeUtf8Codepoint()` - Encode Unicode codepoints to UTF-8 byte sequences
10+
- Supports all valid Unicode ranges (U+0000 to U+10FFFF)
11+
- Validates against overlong encodings, invalid surrogates, and out-of-range values
812

913
### Changed
1014

11-
- NIL
15+
- **JSON Escaping**: Enhanced Unicode support
16+
- `jsonEscape()` - Added optional `escapeNonAscii` parameter (default: false)
17+
- When enabled, converts UTF-8 characters to `\uXXXX` JSON escape sequences
18+
- `jsonUnescape()` - Now properly handles UTF-16 surrogate pairs for emoji and supplementary characters
1219

1320
### Deprecated
1421

include/nfx/detail/string/Utils.inl

Lines changed: 222 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1272,16 +1272,190 @@ namespace nfx::string
12721272
// JSON escape/unescape
12731273
//-----------------------------
12741274

1275-
inline std::string jsonEscape( std::string_view str )
1275+
//-----------------------------
1276+
// UTF-8 utilities
1277+
//-----------------------------
1278+
1279+
/**
1280+
* @brief Decode a UTF-8 sequence starting at position i
1281+
* @param str Input string
1282+
* @param i Current position (will be updated to point after the sequence)
1283+
* @param codepoint Output Unicode code point
1284+
* @return true if valid UTF-8 sequence was decoded, false otherwise
1285+
*/
1286+
inline bool decodeUtf8Codepoint( std::string_view str, std::size_t& i, uint32_t& codepoint ) noexcept
1287+
{
1288+
if ( i >= str.size() )
1289+
return false;
1290+
1291+
unsigned char c0 = static_cast<unsigned char>( str[i] );
1292+
1293+
// 1-byte sequence (ASCII): 0xxxxxxx
1294+
if ( c0 <= 0x7F )
1295+
{
1296+
codepoint = c0;
1297+
++i;
1298+
return true;
1299+
}
1300+
1301+
// 2-byte sequence: 110xxxxx 10xxxxxx
1302+
if ( ( c0 & 0xE0 ) == 0xC0 )
1303+
{
1304+
if ( i + 1 >= str.size() )
1305+
return false;
1306+
unsigned char c1 = static_cast<unsigned char>( str[i + 1] );
1307+
if ( ( c1 & 0xC0 ) != 0x80 )
1308+
return false;
1309+
1310+
codepoint = ( ( c0 & 0x1F ) << 6 ) | ( c1 & 0x3F );
1311+
i += 2;
1312+
return codepoint >= 0x80; // Check for overlong encoding
1313+
}
1314+
1315+
// 3-byte sequence: 1110xxxx 10xxxxxx 10xxxxxx
1316+
if ( ( c0 & 0xF0 ) == 0xE0 )
1317+
{
1318+
if ( i + 2 >= str.size() )
1319+
return false;
1320+
unsigned char c1 = static_cast<unsigned char>( str[i + 1] );
1321+
unsigned char c2 = static_cast<unsigned char>( str[i + 2] );
1322+
if ( ( c1 & 0xC0 ) != 0x80 || ( c2 & 0xC0 ) != 0x80 )
1323+
return false;
1324+
1325+
codepoint = ( ( c0 & 0x0F ) << 12 ) | ( ( c1 & 0x3F ) << 6 ) | ( c2 & 0x3F );
1326+
i += 3;
1327+
return codepoint >= 0x800 && ( codepoint < 0xD800 || codepoint > 0xDFFF ); // Check overlong and surrogates
1328+
}
1329+
1330+
// 4-byte sequence: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
1331+
if ( ( c0 & 0xF8 ) == 0xF0 )
1332+
{
1333+
if ( i + 3 >= str.size() )
1334+
return false;
1335+
unsigned char c1 = static_cast<unsigned char>( str[i + 1] );
1336+
unsigned char c2 = static_cast<unsigned char>( str[i + 2] );
1337+
unsigned char c3 = static_cast<unsigned char>( str[i + 3] );
1338+
if ( ( c1 & 0xC0 ) != 0x80 || ( c2 & 0xC0 ) != 0x80 || ( c3 & 0xC0 ) != 0x80 )
1339+
return false;
1340+
1341+
codepoint = ( ( c0 & 0x07 ) << 18 ) | ( ( c1 & 0x3F ) << 12 ) | ( ( c2 & 0x3F ) << 6 ) | ( c3 & 0x3F );
1342+
i += 4;
1343+
return codepoint >= 0x10000 && codepoint <= 0x10FFFF; // Check overlong and valid range
1344+
}
1345+
1346+
// Invalid UTF-8 sequence
1347+
return false;
1348+
}
1349+
1350+
/**
1351+
* @brief Encode a Unicode code point to UTF-8
1352+
* @param result Output string to append UTF-8 bytes
1353+
* @param codepoint Unicode code point to encode
1354+
*/
1355+
inline void encodeUtf8Codepoint( std::string& result, uint32_t codepoint ) noexcept
1356+
{
1357+
if ( codepoint <= 0x7F )
1358+
{
1359+
// 1-byte UTF-8
1360+
result += static_cast<char>( codepoint );
1361+
}
1362+
else if ( codepoint <= 0x7FF )
1363+
{
1364+
// 2-byte UTF-8
1365+
result += static_cast<char>( 0xC0 | ( codepoint >> 6 ) );
1366+
result += static_cast<char>( 0x80 | ( codepoint & 0x3F ) );
1367+
}
1368+
else if ( codepoint <= 0xFFFF )
1369+
{
1370+
// 3-byte UTF-8
1371+
result += static_cast<char>( 0xE0 | ( codepoint >> 12 ) );
1372+
result += static_cast<char>( 0x80 | ( ( codepoint >> 6 ) & 0x3F ) );
1373+
result += static_cast<char>( 0x80 | ( codepoint & 0x3F ) );
1374+
}
1375+
else if ( codepoint <= 0x10FFFF )
1376+
{
1377+
// 4-byte UTF-8
1378+
result += static_cast<char>( 0xF0 | ( codepoint >> 18 ) );
1379+
result += static_cast<char>( 0x80 | ( ( codepoint >> 12 ) & 0x3F ) );
1380+
result += static_cast<char>( 0x80 | ( ( codepoint >> 6 ) & 0x3F ) );
1381+
result += static_cast<char>( 0x80 | ( codepoint & 0x3F ) );
1382+
}
1383+
}
1384+
1385+
//-----------------------------
1386+
// JSON-specific utilities
1387+
//-----------------------------
1388+
1389+
namespace detail
1390+
{
1391+
/**
1392+
* @brief Encode a Unicode code point as JSON escape sequence
1393+
* @param result Output string
1394+
* @param codepoint Unicode code point to encode
1395+
*/
1396+
inline void encodeJsonEscapeSequence( std::string& result, uint32_t codepoint ) noexcept
1397+
{
1398+
constexpr char hex[] = "0123456789abcdef";
1399+
1400+
if ( codepoint <= 0xFFFF )
1401+
{
1402+
// Basic Multilingual Plane: encode as \uXXXX
1403+
result += "\\u";
1404+
result += hex[( codepoint >> 12 ) & 0xF];
1405+
result += hex[( codepoint >> 8 ) & 0xF];
1406+
result += hex[( codepoint >> 4 ) & 0xF];
1407+
result += hex[codepoint & 0xF];
1408+
}
1409+
else
1410+
{
1411+
// Supplementary plane: encode as UTF-16 surrogate pair \uXXXX\uXXXX
1412+
codepoint -= 0x10000;
1413+
uint32_t high = 0xD800 + ( ( codepoint >> 10 ) & 0x3FF );
1414+
uint32_t low = 0xDC00 + ( codepoint & 0x3FF );
1415+
1416+
// High surrogate
1417+
result += "\\u";
1418+
result += hex[( high >> 12 ) & 0xF];
1419+
result += hex[( high >> 8 ) & 0xF];
1420+
result += hex[( high >> 4 ) & 0xF];
1421+
result += hex[high & 0xF];
1422+
1423+
// Low surrogate
1424+
result += "\\u";
1425+
result += hex[( low >> 12 ) & 0xF];
1426+
result += hex[( low >> 8 ) & 0xF];
1427+
result += hex[( low >> 4 ) & 0xF];
1428+
result += hex[low & 0xF];
1429+
}
1430+
}
1431+
} // namespace detail
1432+
1433+
inline std::string jsonEscape( std::string_view str, bool escapeNonAscii )
12761434
{
12771435
std::string result;
12781436
result.reserve( str.size() * 2 ); // Reserve for common case
12791437

12801438
// Hex digits for Unicode escape sequences
12811439
constexpr char hex_digits[] = "0123456789ABCDEF";
12821440

1283-
for ( unsigned char c : str )
1441+
for ( std::size_t i = 0; i < str.size(); )
12841442
{
1443+
unsigned char c = static_cast<unsigned char>( str[i] );
1444+
1445+
// Check if this is a non-ASCII UTF-8 sequence that needs escaping
1446+
if ( escapeNonAscii && c > 0x7F )
1447+
{
1448+
uint32_t codepoint;
1449+
std::size_t oldPos = i;
1450+
if ( decodeUtf8Codepoint( str, i, codepoint ) )
1451+
{
1452+
detail::encodeJsonEscapeSequence( result, codepoint );
1453+
continue;
1454+
}
1455+
// If UTF-8 decoding failed, restore position and fall through to byte escape
1456+
i = oldPos;
1457+
}
1458+
12851459
switch ( c )
12861460
{
12871461
case '\"':
@@ -1322,6 +1496,8 @@ namespace nfx::string
13221496
}
13231497
break;
13241498
}
1499+
1500+
++i;
13251501
}
13261502

13271503
return result;
@@ -1332,40 +1508,69 @@ namespace nfx::string
13321508
std::string result;
13331509
result.reserve( str.size() );
13341510

1335-
auto parseUnicodeEscape = [&]( std::size_t pos ) -> bool {
1336-
if ( pos + 5 >= str.size() )
1511+
auto parseUnicodeEscape = [&]( std::size_t& i ) -> bool {
1512+
if ( i + 5 >= str.size() )
13371513
{
13381514
return false; // Not enough characters for \uXXXX
13391515
}
13401516

13411517
// Parse 4 hex digits
1342-
int value = 0;
1518+
uint32_t value = 0;
13431519
for ( int j = 0; j < 4; ++j )
13441520
{
1345-
int digit = hexToInt( str[pos + 2 + j] );
1521+
int digit = hexToInt( str[i + 2 + j] );
13461522
if ( digit == -1 )
13471523
{
13481524
return false; // Invalid hex digit
13491525
}
1350-
value = ( value << 4 ) | digit;
1526+
value = ( value << 4 ) | static_cast<uint32_t>( digit );
13511527
}
13521528

1353-
// Convert Unicode codepoint to UTF-8
1354-
if ( value <= 0x7F )
1529+
i += 6; // Skip \uXXXX
1530+
1531+
// Check for UTF-16 surrogate pair
1532+
if ( value >= 0xD800 && value <= 0xDBFF )
13551533
{
1356-
result += static_cast<char>( value );
1534+
// High surrogate - expect low surrogate to follow
1535+
if ( i + 5 >= str.size() || str[i] != '\\' || str[i + 1] != 'u' )
1536+
{
1537+
return false; // Invalid surrogate pair
1538+
}
1539+
1540+
// Parse low surrogate
1541+
uint32_t lowSurrogate = 0;
1542+
for ( int j = 0; j < 4; ++j )
1543+
{
1544+
int digit = hexToInt( str[i + 2 + j] );
1545+
if ( digit == -1 )
1546+
{
1547+
return false;
1548+
}
1549+
lowSurrogate = ( lowSurrogate << 4 ) | static_cast<uint32_t>( digit );
1550+
}
1551+
1552+
if ( lowSurrogate < 0xDC00 || lowSurrogate > 0xDFFF )
1553+
{
1554+
return false; // Invalid low surrogate
1555+
}
1556+
1557+
i += 6; // Skip second \uXXXX
1558+
1559+
// Combine surrogates to get codepoint
1560+
uint32_t codepoint = 0x10000 + ( ( ( value - 0xD800 ) << 10 ) | ( lowSurrogate - 0xDC00 ) );
1561+
encodeUtf8Codepoint( result, codepoint );
13571562
}
1358-
else if ( value <= 0x7FF )
1563+
else if ( value >= 0xDC00 && value <= 0xDFFF )
13591564
{
1360-
result += static_cast<char>( 0xC0 | ( value >> 6 ) );
1361-
result += static_cast<char>( 0x80 | ( value & 0x3F ) );
1565+
// Unexpected low surrogate
1566+
return false;
13621567
}
13631568
else
13641569
{
1365-
result += static_cast<char>( 0xE0 | ( value >> 12 ) );
1366-
result += static_cast<char>( 0x80 | ( ( value >> 6 ) & 0x3F ) );
1367-
result += static_cast<char>( 0x80 | ( value & 0x3F ) );
1570+
// Regular BMP codepoint
1571+
encodeUtf8Codepoint( result, value );
13681572
}
1573+
13691574
return true;
13701575
};
13711576

@@ -1419,7 +1624,7 @@ namespace nfx::string
14191624
{
14201625
return ""; // Invalid Unicode escape
14211626
}
1422-
i += 6; // Skip \uXXXX
1627+
// i is already advanced by parseUnicodeEscape
14231628
break;
14241629
default:
14251630
return ""; // Invalid escape sequence

include/nfx/string/Utils.h

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -888,6 +888,38 @@ namespace nfx::string
888888
*/
889889
[[nodiscard]] inline constexpr int digitToInt( char c ) noexcept;
890890

891+
//----------------------------------------------
892+
// UTF-8 utilities
893+
//----------------------------------------------
894+
895+
/**
896+
* @brief Decode a UTF-8 sequence starting at position i
897+
* @param str Input string containing UTF-8 encoded data
898+
* @param i Current position (will be updated to point after the decoded sequence)
899+
* @param codepoint Output Unicode code point (U+0000 to U+10FFFF)
900+
* @return true if valid UTF-8 sequence was decoded, false otherwise
901+
* @details Validates UTF-8 encoding including:
902+
* - Correct byte sequence patterns (1-4 bytes)
903+
* - No overlong encodings
904+
* - No invalid surrogate pairs (U+D800 to U+DFFF)
905+
* - Valid Unicode range (U+0000 to U+10FFFF)
906+
* @note This function is marked [[nodiscard]] - the return value should not be ignored
907+
*/
908+
[[nodiscard]] inline bool decodeUtf8Codepoint( std::string_view str, std::size_t& i, uint32_t& codepoint ) noexcept;
909+
910+
/**
911+
* @brief Encode a Unicode code point to UTF-8
912+
* @param result Output string to append UTF-8 bytes to
913+
* @param codepoint Unicode code point to encode (U+0000 to U+10FFFF)
914+
* @details Encodes codepoint as 1-4 UTF-8 bytes:
915+
* - U+0000 to U+007F: 1 byte
916+
* - U+0080 to U+07FF: 2 bytes
917+
* - U+0800 to U+FFFF: 3 bytes
918+
* - U+10000 to U+10FFFF: 4 bytes
919+
* @note Behavior is undefined for codepoints > U+10FFFF
920+
*/
921+
inline void encodeUtf8Codepoint( std::string& result, uint32_t codepoint ) noexcept;
922+
891923
//----------------------------------------------
892924
// String parsing
893925
//----------------------------------------------
@@ -1002,19 +1034,22 @@ namespace nfx::string
10021034
/**
10031035
* @brief Escape string for use in JSON (RFC 8259)
10041036
* @param str String to escape
1037+
* @param escapeNonAscii If true, encode non-ASCII UTF-8 sequences as \\uXXXX escape sequences (default: false)
10051038
* @return JSON-escaped string with special characters properly escaped
10061039
* @details Escapes: quote, backslash, slash, backspace, form-feed, newline, carriage-return, tab
10071040
* and control characters (U+0000 to U+001F) as \\uXXXX Unicode escape sequences.
1041+
* When escapeNonAscii is true, also converts UTF-8 encoded characters to \\uXXXX format.
10081042
* This function allocates a new std::string.
10091043
* @note This function is marked [[nodiscard]] - the return value should not be ignored
10101044
*/
1011-
[[nodiscard]] inline std::string jsonEscape( std::string_view str );
1045+
[[nodiscard]] inline std::string jsonEscape( std::string_view str, bool escapeNonAscii = false );
10121046

10131047
/**
10141048
* @brief Unescape JSON string literal (RFC 8259)
10151049
* @param str Escaped JSON string to unescape
10161050
* @return Unescaped string, or empty string if input contains invalid escape sequences
10171051
* @details Unescapes all standard JSON escape sequences and \\uXXXX Unicode sequences.
1052+
* Properly handles UTF-16 surrogate pairs (\\uD800\\uDC00 format) for codepoints > U+FFFF.
10181053
* Returns empty string if malformed escape sequences are detected.
10191054
* This function allocates a new std::string.
10201055
* @note This function is marked [[nodiscard]] - the return value should not be ignored

0 commit comments

Comments
 (0)