morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
locale_format.hpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4
5/// @file render/locale_format.hpp
6/// @brief Locale numeric-entry normalisation for the control edge.
7///
8/// A locale may render and accept e.g. `"1.050,25"`; the payload a
9/// `morph::units::Quantity` field submits is always the canonical exact
10/// `{num, den, dp}` regardless. This header is the one control-edge
11/// conversion step between the two: the exact `Rational`/`Quantity` digit
12/// routines stay entirely locale-free (they only ever see plain
13/// `.`-decimal text), and a renderer calls `normalizeLocaleNumber` once, at
14/// the point text leaves the control, before handing it to those routines.
15///
16/// @par One aggregate, not a row of swappable views
17/// Both functions take a single `NumericLocale` rather than a row of positional
18/// `std::string_view`s. Six adjacent views of the same type are silently
19/// swappable with each other, which is what `bugprone-easily-swappable-parameters`
20/// exists to catch; with the aggregate a call site names each fact
21/// (`{.decimalSeparator = ",", .groupSeparator = "."}`) and no two parameters of
22/// either function share a type. The next locale fact -- a percent sign, an
23/// exponent separator -- is a new defaulted member rather than a seventh
24/// parameter. The two edges taking the *same* type is the point as much as the
25/// naming is: "these two must agree" is structural instead of a convention a
26/// caller can get half right.
27///
28/// @par Separators are strings, not characters
29/// The locale facts are `std::string_view`, because a real locale's separator
30/// is not always one byte. fr-FR groups with U+202F (narrow no-break space) and
31/// several locales use U+00A0 -- three and two UTF-8 bytes respectively. Typed
32/// as `char`, those cannot be expressed at all: the caller can only pass some
33/// single byte that never matches, so a perfectly valid `"1 050,25"` typed by a
34/// French user normalises to `std::nullopt` and the entry is reported
35/// malformed. An empty view means "this locale has no such separator".
36///
37/// @par So is the negative sign
38/// For the same reason, and measured rather than assumed: of the 711 locales
39/// Qt 6.11.2 knows, 77 report a `negativeSign` that is not a bare ASCII `'-'`.
40/// 23 use U+2212 MINUS SIGN (e.g. eu_ES); 54 more prefix the sign with a bidi
41/// control -- U+061C ARABIC LETTER MARK, U+200E LEFT-TO-RIGHT MARK or U+200F
42/// RIGHT-TO-LEFT MARK -- making it two or three code points, and ar_DZ does so
43/// even though its sign is the ordinary hyphen. Matched as a single `char`,
44/// none of those round-trips as a single `char`: the display edge would emit a
45/// sign the entry edge then rejected. So `negativeSign` is matched as a whole
46/// string too, defaulting to the ASCII `"-"`.
47///
48/// @par And so is the positive sign, on the entry edge only
49/// `normalizeLocaleNumber` reads `NumericLocale::positiveSign` and *drops* what
50/// it matches, because canonical text has no `'+'` in it.
51/// `formatCanonicalNumber` never emits one: a positive number displays unsigned
52/// in every locale, and changing that would alter every positive number the
53/// product shows. So the two functions are inverse across the decimal
54/// separator, the grouping, the digits and the negative sign, but deliberately
55/// not across a positive sign -- entry accepts a spelling display never
56/// produces.
57///
58/// @par The digits are locale data too
59/// `NumericLocale::zeroDigit` is the locale's DIGIT ZERO, and the ten digits
60/// are the ten code points contiguous from it. One base is sufficient rather
61/// than a ten-element table because a Unicode decimal digit set *is* ten
62/// contiguous code points: UAX #44 assigns `Nd` with `Numeric_Value` 0 through
63/// 9 in code point order. Measured over the same 711 locales under Qt 6.11.2,
64/// 76 report a `zeroDigit` other than ASCII `'0'`, across eleven distinct sets:
65/// U+0660 (26 locales), U+06F0 (19), U+1E950 Adlam (12), U+0966 (8), U+09E6
66/// (4), U+11136 Chakma (2), and one each of U+07C0, U+0F20, U+1040, U+1C50 and
67/// U+ABF0. Two of those sets are astral, so a digit is one to four UTF-8 bytes
68/// and the scan decodes a code point rather than comparing a byte.
69///
70/// @par Entry accepts more digit spellings than display emits
71/// `normalizeLocaleNumber` accepts a digit in `[zeroDigit, zeroDigit + 9]` *or*
72/// in `['0', '9']`; `formatCanonicalNumber` emits only the former. That is the
73/// same asymmetry the signs have, applied to digits: an ASCII `'+'` is
74/// accepted in every locale because the locale's own spelling is
75/// on no keyboard, and an ASCII `'5'` is accepted in an `ar_EG` locale for
76/// exactly the same reason. A user with an ASCII keyboard in a native-digit
77/// locale would otherwise be unable to enter a number at all. As with the
78/// positive sign, accepting a spelling display never produces costs nothing:
79/// the canonical output spells every digit in ASCII whatever the input spelled
80/// it, so no *value* can differ. What is not accepted is the two families in
81/// one entry -- see "Digit families do not mix" on `normalizeLocaleNumber`.
82
83#include <algorithm>
84#include <cstddef>
85#include <cstdint>
86#include <limits>
87#include <optional>
88#include <string>
89#include <string_view>
90
91#include "../util/rational.hpp"
92
93namespace morph::render {
94
95/// @brief The locale facts both control-edge conversions need, in one
96/// designated-initialisable aggregate.
97///
98/// Every member is defaulted to its `"C"`-locale spelling, so a
99/// default-constructed `NumericLocale` is the identity transform in both
100/// directions and a caller naming only the members it cares about gets the
101/// `"C"` spelling for the rest.
103 /// The locale's decimal-point string, e.g. `","`. Empty means the locale
104 /// has no decimal separator (an integer-only entry).
105 std::string_view decimalSeparator = ".";
106 /// The locale's digit-grouping string, e.g. `"."` or U+202F. Empty means
107 /// the locale does not group.
108 std::string_view groupSeparator;
109 /// The locale's negative-sign string, e.g. `"\u2212"`. Empty is read as the
110 /// ASCII `"-"`, not as "this entry cannot be negative".
111 std::string_view negativeSign = "-";
112 /// The locale's positive-sign string, e.g. `"\u061c+"`. Entry-edge only:
113 /// it is accepted and dropped, never emitted. Empty leaves the ASCII `"+"`
114 /// as the only accepted spelling.
115 std::string_view positiveSign = "+";
116 /// The locale's DIGIT ZERO, e.g. `"\u0660"`. The ten digits are the ten
117 /// code points contiguous from it. Empty, or not a well-formed UTF-8 code
118 /// point, is read as the ASCII `"0"`.
119 std::string_view zeroDigit = "0";
120};
121
122namespace detail {
123
124/// @brief One decoded UTF-8 code point: its value and how many bytes it took.
125struct CodePoint {
126 /// The decoded scalar value; meaningless when @ref length is `0`.
127 char32_t value = 0;
128 /// The number of bytes consumed, or `0` when the input does not begin with
129 /// a well-formed UTF-8 sequence.
130 std::size_t length = 0;
131};
132
133/// @brief Decodes the UTF-8 sequence at the start of @p text.
134///
135/// Strict: an overlong encoding, a surrogate code point, a truncated sequence
136/// and a stray continuation byte all report a `CodePoint::length` of `0`
137/// rather than some salvaged value. That strictness is load-bearing rather than
138/// pedantic. The digit test below is a *range* test on the decoded value, so a
139/// lenient decoder that let the overlong `C0 B5` through would read it as
140/// U+0035 and accept it as the digit `'5'` -- including in the default ASCII
141/// locale, where that input is not a digit at all.
142/// @param text The remainder of the entry, starting at the scan position.
143/// @return The decoded code point, or a `length` of `0` when @p text does not
144/// start with a well-formed sequence.
145[[nodiscard]] inline CodePoint decodeUtf8(std::string_view text) {
146 if (text.empty()) {
147 return {};
148 }
149 auto const lead = static_cast<unsigned char>(text.front());
150 if (lead < 0x80U) {
151 return {.value = lead, .length = 1};
152 }
153
154 std::size_t length = 0;
155 char32_t value = 0;
156 char32_t least = 0; // the smallest value this length may legally encode
157 if ((lead & 0xE0U) == 0xC0U) {
158 length = 2;
159 value = lead & 0x1FU;
160 least = 0x80;
161 } else if ((lead & 0xF0U) == 0xE0U) {
162 length = 3;
163 value = lead & 0x0FU;
164 least = 0x800;
165 } else if ((lead & 0xF8U) == 0xF0U) {
166 length = 4;
167 value = lead & 0x07U;
168 least = 0x10000;
169 } else {
170 return {}; // a continuation byte or a 5+ byte lead: not a lead byte
171 }
172 if (text.size() < length) {
173 return {}; // truncated
174 }
175 for (std::size_t k = 1; k < length; ++k) {
176 // k is bounded by the size check above.
177 // NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access)
178 auto const cont = static_cast<unsigned char>(text[k]);
179 if ((cont & 0xC0U) != 0x80U) {
180 return {}; // not a continuation byte
181 }
182 value = (value << 6U) | (cont & 0x3FU);
183 }
184 constexpr char32_t kMaxScalar = 0x10FFFF;
185 constexpr char32_t kSurrogateFirst = 0xD800;
186 constexpr char32_t kSurrogateLast = 0xDFFF;
187 if (value < least || value > kMaxScalar || (value >= kSurrogateFirst && value <= kSurrogateLast)) {
188 return {}; // overlong, out of range, or a surrogate
189 }
190 return {.value = value, .length = length};
191}
192
193/// @brief Appends @p value to @p out as UTF-8.
194/// @param out The string to append to.
195/// @param value A scalar value; callers here only ever pass a digit derived
196/// from a `zeroDigit` that `decodeUtf8` already validated.
197inline void appendUtf8(std::string& out, char32_t value) {
198 constexpr char32_t kContMask = 0x3F;
199 constexpr unsigned kContShift = 6;
200 auto const byte = [&out](char32_t bits) { out += static_cast<char>(bits); };
201 if (value < 0x80) {
202 byte(value);
203 } else if (value < 0x800) {
204 byte(0xC0U | (value >> kContShift));
205 byte(0x80U | (value & kContMask));
206 } else if (value < 0x10000) {
207 byte(0xE0U | (value >> (2 * kContShift)));
208 byte(0x80U | ((value >> kContShift) & kContMask));
209 byte(0x80U | (value & kContMask));
210 } else {
211 byte(0xF0U | (value >> (3 * kContShift)));
212 byte(0x80U | ((value >> (2 * kContShift)) & kContMask));
213 byte(0x80U | ((value >> kContShift) & kContMask));
214 byte(0x80U | (value & kContMask));
215 }
216}
217
218/// @brief Appends one character of canonical text to a display string, with a
219/// digit rewritten into the locale's set.
220///
221/// Anything that is not an ASCII digit is copied through verbatim. That arm is
222/// not dead code for a caller that honours the contract, but it is what bounds
223/// the damage for one that does not: a `"12.34.56"` handed to the display edge
224/// keeps its second `'.'` rather than becoming some code point below the digit
225/// base. Only digits are rewritten; text that has none passes through.
226/// @param out The display string to append to.
227/// @param base The locale's DIGIT ZERO code point (see `digitBase`).
228/// @param chr One byte of canonical text.
229inline void appendDisplayDigit(std::string& out, char32_t base, char chr) {
230 if (chr >= '0' && chr <= '9') {
231 appendUtf8(out, base + static_cast<char32_t>(chr - '0'));
232 } else {
233 out += chr;
234 }
235}
236
237/// @brief How many bytes @p value occupies in UTF-8.
238/// @param value A scalar value.
239/// @return `1`, `2`, `3` or `4`.
240[[nodiscard]] inline std::size_t utf8Length(char32_t value) {
241 if (value < 0x80) {
242 return 1;
243 }
244 if (value < 0x800) {
245 return 2;
246 }
247 if (value < 0x10000) {
248 return 3;
249 }
250 return 4;
251}
252
253/// @brief The code point of @p zeroDigit, or `U'0'` when it is empty or not a
254/// well-formed UTF-8 code point.
255///
256/// Empty reads as the ASCII `'0'` for the reason an empty `negativeSign` reads
257/// as `'-'`: there is no locale without digits, so empty cannot mean absence,
258/// and a base of "nothing" would reject every entry the locale can produce.
259/// Only the first code point is read; anything after it is locale data this
260/// function has no use for.
261/// @param zeroDigit The locale's DIGIT ZERO spelling.
262/// @return The base code point of the locale's digit set.
263[[nodiscard]] inline char32_t digitBase(std::string_view zeroDigit) {
264 CodePoint const decoded = decodeUtf8(zeroDigit);
265 return decoded.length == 0 ? U'0' : decoded.value;
266}
267
268/// @brief A digit matched at the start of an entry.
270 /// The number of bytes the digit occupies; `0` when there is no digit there.
271 std::size_t length = 0;
272 /// The canonical ASCII spelling of the digit's value, `'0'`-`'9'`.
273 char canonical = '0';
274 /// Whether the match came from the locale's own digit set rather than from
275 /// the ASCII set every locale additionally accepts. When @p base is `U'0'`
276 /// the two sets coincide and this is always `true`.
277 bool native = false;
278};
279
280/// @brief Matches a digit at the start of @p rest, in the locale's set or in
281/// ASCII.
282///
283/// A function of its own for the reason `leadingSign` is: the normalising scan
284/// reads as one statement per character class, and folding the two digit
285/// families plus the UTF-8 decode into it took it over clang-tidy's cognitive
286/// complexity threshold.
287/// @param rest The remainder of the entry, starting at the scan position.
288/// @param base The locale's DIGIT ZERO code point (see `digitBase`).
289/// @return The match, or a `length` of `0` when @p rest starts with no digit.
290[[nodiscard]] inline DigitMatch leadingDigit(std::string_view rest, char32_t base) {
291 CodePoint const decoded = decodeUtf8(rest);
292 if (decoded.length == 0) {
293 return {};
294 }
295 if (decoded.value >= base && decoded.value < base + 10) {
296 return {
297 .length = decoded.length, .canonical = static_cast<char>(U'0' + (decoded.value - base)), .native = true};
298 }
299 if (decoded.value >= U'0' && decoded.value <= U'9') {
300 // The ASCII spelling, accepted in every locale for the reason the ASCII
301 // '-' and '+' are: the locale's own digits are on the user's keyboard
302 // only if their keyboard has them.
303 return {.length = decoded.length, .canonical = static_cast<char>(decoded.value), .native = false};
304 }
305 return {};
306}
307
308/// @brief Whether every group separator in @p text sits where a group
309/// separator can legally sit.
310///
311/// The rule, in one place so it can be read on its own: a group separator is
312/// preceded by one to three digits (the first group), or by exactly three
313/// (every later one); it is followed by exactly three more digits, at each
314/// group and at the end of the integer part; and it never appears after the
315/// decimal separator. A locale with no grouping (an empty
316/// `NumericLocale::groupSeparator`) has nothing to place, so it trivially
317/// passes.
318///
319/// This is a pass of its own rather than extra state inside the normalising
320/// scan below: "the grouping is well placed" and "the digits convert" are two
321/// separate statements about the entry, and reading them as one made neither
322/// clear.
323///
324/// It counts *digits*, not bytes, in the locale's set as well as in ASCII. A
325/// byte-by-byte scan is only correct while a digit is one byte: a two-byte
326/// U+0665 would reset the run-length counter on its own continuation byte, so
327/// `"\u0661\u066c\u0660\u0665\u0660"` -- what `formatCanonicalNumber` emits
328/// for `1050` in an `ar_EG` locale -- would be rejected as badly grouped and the
329/// pair would not round-trip.
330///
331/// Characters this function does not recognise are simply not digits — the
332/// normalising scan is what rejects them, and it rejects them whatever this
333/// pass concludes.
334/// @param text The locale-formatted entry.
335/// @param loc The locale facts; only the two separators and the digit base are
336/// read.
337/// @return `true` when the grouping is well placed (or absent).
338[[nodiscard]] inline bool groupingIsWellPlaced(std::string_view text, NumericLocale const& loc) {
339 if (loc.groupSeparator.empty()) {
340 return true;
341 }
342 constexpr std::size_t kGroupSize = 3;
343 char32_t const base = digitBase(loc.zeroDigit);
344 std::size_t digits = 0;
345 bool sawGroup = false;
346 bool sawDecimal = false;
347
348 for (std::size_t i = 0; i < text.size();) {
349 const std::string_view rest = text.substr(i);
350 if (rest.starts_with(loc.groupSeparator)) {
351 bool const opensAGroup = sawGroup ? digits == kGroupSize : (digits >= 1 && digits <= kGroupSize);
352 if (sawDecimal || !opensAGroup) {
353 return false;
354 }
355 sawGroup = true;
356 digits = 0;
357 i += loc.groupSeparator.size();
358 continue;
359 }
360 if (!loc.decimalSeparator.empty() && rest.starts_with(loc.decimalSeparator)) {
361 if (sawGroup && digits != kGroupSize) {
362 return false; // the last group of the integer part is short
363 }
364 sawDecimal = true;
365 digits = 0;
366 i += loc.decimalSeparator.size();
367 continue;
368 }
369 DigitMatch const digit = leadingDigit(rest, base);
370 digits = digit.length != 0 ? digits + 1 : 0;
371 i += digit.length != 0 ? digit.length : 1;
372 }
373
374 // An ungrouped fractional part ends the number, so the trailing check only
375 // applies when the integer part was the last thing scanned.
376 return !sawGroup || sawDecimal || digits == kGroupSize;
377}
378
379/// @brief The length of @p separator at the start of @p rest, in bytes, or `0`
380/// when it is not there -- or is empty.
381///
382/// The empty case is why this is a function and not an inline `starts_with`:
383/// `rest.starts_with("")` is `true` at every index, so an empty separator
384/// matched inline would swallow the whole entry one zero-length step at a time.
385/// Spelling the guard as `!sep.empty() && ...` at each call site instead adds
386/// two conjunctions to `normalizeLocaleNumber`, which is enough to put it over
387/// clang-tidy's cognitive-complexity threshold.
388/// @param rest The remainder of the entry, starting at the scan position.
389/// @param separator The locale's spelling of this separator; empty means the
390/// locale has none, and matches nothing.
391/// @return The number of bytes the separator occupies, or `0`.
392[[nodiscard]] inline std::size_t leadingSeparatorLength(std::string_view rest, std::string_view separator) {
393 return !separator.empty() && rest.starts_with(separator) ? separator.size() : 0U;
394}
395
396/// @brief The length of a sign at the start of @p rest, in bytes, or `0` when
397/// there is none there.
398///
399/// Two spellings count. @p localeSign is the locale's own, matched as a whole
400/// string so that U+2212 and the bidi-control-prefixed forms -- two and three
401/// code points -- match at all; a single `char` can express none of them.
402/// @p asciiSign counts as well, in every locale: U+2212
403/// and the bidi marks are on no keyboard, so matching only the locale's
404/// spelling would reject the sign the user can actually type. Neither `'-'` nor
405/// `'+'` has a second reading in a numeric entry, so this is not the kind of
406/// guess the grouping rule forbids.
407///
408/// This is a function of its own so that the caller's scan reads as one
409/// statement per character class. It is shared by both signs and says nothing
410/// about what the caller then emits, which differs between them: a negative is
411/// emitted as the canonical `'-'`, a positive is dropped (see
412/// `normalizeLocaleNumber`).
413/// @param rest The remainder of the entry, starting at the scan position.
414/// @param localeSign The locale's spelling of this sign; empty matches nothing,
415/// leaving only the ASCII spelling.
416/// @param asciiSign The ASCII spelling accepted in every locale: `'-'` or `'+'`.
417/// @return The number of bytes the sign occupies, or `0`.
418[[nodiscard]] inline std::size_t leadingSignLength(std::string_view rest, std::string_view localeSign,
419 char asciiSign) {
420 if (!localeSign.empty() && rest.starts_with(localeSign)) {
421 return localeSign.size();
422 }
423 return rest.starts_with(asciiSign) ? 1U : 0U;
424}
425
426/// @brief A sign matched at the start of an entry: how much of the text it
427/// occupies, and what it contributes to the canonical output.
428struct SignMatch {
429 /// The number of bytes the sign occupies; `0` when there is no sign there.
430 std::size_t length = 0;
431 /// What the canonical text gains: `"-"` for a negative, empty for a
432 /// positive, which is accepted and dropped.
433 std::string_view emits;
434};
435
436/// @brief Matches either sign at the start of @p rest.
437///
438/// Both signs in one function, and the emitted text carried back with the
439/// length, so the normalising scan below has a *single* sign branch with no
440/// inner "which sign was it" test. That is not only tidier: two branches with
441/// two inner tests each put `normalizeLocaleNumber` at a cognitive complexity
442/// of 28, over clang-tidy's threshold of 25; with the single branch it sits at
443/// 23. The asymmetry between the two signs lives here, in the one place that
444/// decides it, rather than in the scan.
445///
446/// The negative sign is tried first. The order is not load-bearing for any
447/// locale Qt 6.11.2 reports -- `starts_with` is an exact prefix match and no
448/// locale spells one sign as a prefix of the other -- but it is fixed here so
449/// that it cannot vary.
450/// @param rest The remainder of the entry, starting at the scan position.
451/// @param loc The locale facts; only the two signs are read. An empty
452/// `negativeSign` leaves `'-'`, an empty `positiveSign` leaves
453/// `'+'`.
454/// @return The match, or a `length` of `0` when @p rest starts with no sign.
455[[nodiscard]] inline SignMatch leadingSign(std::string_view rest, NumericLocale const& loc) {
456 std::size_t const negativeLength = leadingSignLength(rest, loc.negativeSign, '-');
457 if (negativeLength != 0) {
458 // The canonical spelling, whatever the locale's is.
459 return {.length = negativeLength, .emits = "-"};
460 }
461 // Dropped, never emitted: canonical text is `-?[0-9]+(\.[0-9]+)?` and has
462 // no `+` in it.
463 return {.length = leadingSignLength(rest, loc.positiveSign, '+'), .emits = ""};
464}
465
466} // namespace detail
467
468/// @brief Converts a locale-formatted numeric string to canonical
469/// (`-?[0-9]+(\.[0-9]+)?`) text.
470///
471/// Drops `NumericLocale::groupSeparator` where it is correctly placed, replaces
472/// every occurrence of `NumericLocale::decimalSeparator` with `.`, and rewrites
473/// the locale's digits as ASCII ones. A default-constructed `NumericLocale` is
474/// the identity transform (the locale-free behavior). Malformed input (a second
475/// decimal separator, a sign anywhere but the leading position of the *output*,
476/// two digit families in one entry, or any character that is not a digit)
477/// yields `std::nullopt` rather than a best-effort guess. The decimal point
478/// counts as output, so a sign placed straight after the separator ("`,-5`" in
479/// a de-DE locale) is rejected.
480///
481/// @par Grouping is validated, not stripped
482/// A group separator is only dropped where a group separator can legally be:
483/// preceded by one to three digits, followed by exactly three more, and never
484/// after the decimal separator. Anything else is malformed and reported as
485/// such. Stripping unconditionally instead yields a wrong *value*, not a
486/// rejected one, and nothing downstream can tell: a de-DE user typing the US
487/// form `"1.5"` into a price field would submit `15` -- a perfectly valid
488/// number, ten times too large. `"1.50"` would give `150`, `"1.2.3.4"` would
489/// give `1234`, and the en-US mirror image `"1,5"` would give `15`.
490///
491/// @par The two separators must differ
492/// When `groupSeparator` is non-empty and equal to `decimalSeparator` the entry
493/// is rejected: with one string in both roles there is no reading of `"1.5"`
494/// the function could defend. This is a caller (locale-configuration) error
495/// rather than a user one, but it is reported through the return value like any
496/// other malformed entry, deliberately not through an assertion -- an assertion
497/// would make the two build configurations behave differently at a control
498/// edge, and would be untestable in the one where it fires.
499///
500/// The result is `.`-decimal and digit-only, but is **not** narrowed to
501/// `-?[0-9]+(\.[0-9]+)?`: a bare "`.`", a leading "`.5`" and a trailing "`5.`"
502/// are passed through, exactly as that same QML mirror passes them. Tightening
503/// one side alone would put the two control edges out of step, so the shape is
504/// documented here rather than narrowed.
505///
506/// Separators are matched as whole strings, so a multi-byte one (e.g. U+202F)
507/// works; matching them before the digit scan is what keeps their continuation
508/// bytes from being mistaken for stray non-digit characters.
509///
510/// @par The negative sign is matched as a whole string too
511/// `negativeSign` is matched the same way, which is what lets a locale whose
512/// sign is U+2212, or is prefixed by a bidi control mark, be entered at all --
513/// 77 of the 711 locales Qt 6.11.2 knows. Matched as a literal byte `'-'`
514/// instead, `formatCanonicalNumber` would emit a sign this function rejected
515/// and the pair would not be inverse for those locales.
516///
517/// @par ASCII `'-'` stays accepted whatever the locale
518/// A bare `'-'` is accepted in the leading position in addition to
519/// `negativeSign`. U+2212 and the bidi marks are on no keyboard, so matching
520/// only the locale's own spelling would reject the sign the user can actually
521/// type and leave them no way to enter a negative number at all. The hyphen has
522/// no second reading in a numeric entry, so accepting it is not the kind of
523/// guess the grouping rule forbids -- that one is about producing a wrong
524/// *value*, and this produces the only value the input can mean.
525///
526/// @par An empty `negativeSign` means the ASCII default, not "no sign"
527/// Unlike a group separator, there is no locale without a negative sign, so an
528/// empty view leaves the ASCII `'-'` above as the only spelling rather than
529/// meaning "this entry cannot be negative". `formatCanonicalNumber` reads it
530/// the same way, and the display edge is why it must: a sign that formatted to
531/// nothing would turn `-5` into `5` silently -- a wrong value, not a rejected
532/// one.
533///
534/// @par A leading positive sign is accepted and dropped
535/// `positiveSign` is matched exactly like `negativeSign` -- the locale's own
536/// spelling as a whole string, plus a bare ASCII `'+'` in every locale. Of the
537/// 711 locales Qt 6.11.2 knows, 54 spell it as more than one code point
538/// (U+061C, U+200E or U+200F before the `'+'`, e.g. `ar_EG`, `ar_DZ`, `az_IR`,
539/// `ckb_IQ`); the other 657 use the bare `'+'`. Unlike the negative side there
540/// is no U+2212 analogue, so *every* non-ASCII spelling here is multi-code-point
541/// and whole-string matching is the only thing that can match any of them.
542/// Without this branch a leading `'+'` falls through to the "any other
543/// character is malformed" arm, and an explicitly-positive entry is rejected in
544/// every locale, `"C"` included.
545///
546/// @par The sign is **dropped**, and `formatCanonicalNumber` never emits one
547/// This is a deliberate asymmetry with the negative sign, not an oversight.
548/// Canonical text is `-?[0-9]+(\.[0-9]+)?`: there is no `'+'` in it, so `"+5"`
549/// yields `"5"` and not `"+5"`. The display edge reads no `positiveSign` at
550/// all, because emitting one would change what every positive number in every
551/// form looks like -- `5` would become `+5` on screen. So the two functions are
552/// *not* strict inverses across a positive sign: entry accepts a spelling
553/// display never produces. That is the only shape that adds acceptance without
554/// changing a single rendered value. Written down in `docs/spec/forms/forms.md`
555/// as well, under "Locale data formatting".
556///
557/// @par An empty `positiveSign` leaves the ASCII `'+'`
558/// Here empty really can mean "match nothing extra", because there is no
559/// display edge to get wrong: the worst an unmatched positive sign can do is
560/// reject an entry, never produce a value of the wrong sign. The bare ASCII
561/// `'+'` stays accepted regardless.
562///
563/// @par The locale's own digits are accepted, and so are ASCII ones
564/// A digit is accepted when its code point is in
565/// `[zeroDigit, zeroDigit + 9]` -- a Unicode decimal digit set is ten
566/// contiguous code points by definition (UAX #44) -- *or* in `['0', '9']`. 76
567/// of the 711 locales Qt 6.11.2 knows use a non-ASCII `zeroDigit`; without the
568/// first acceptance their users could not enter a number at all, because a scan
569/// comparing a single byte against the ASCII range fails on the very first byte
570/// of U+0665. The second acceptance is the same rule as the ASCII `'-'` and
571/// `'+'` above, for the same reason: a user with an ASCII keyboard in an
572/// `ar_EG` locale has to be able to type `5`. It costs nothing, because the
573/// canonical output spells every digit in ASCII whatever the input spelled it,
574/// so no two accepted spellings can produce different *values*.
575///
576/// @par Digit families do not mix
577/// `"\u06655"` -- one Arabic-Indic digit and one ASCII digit -- is malformed,
578/// not `"55"`. The two families are each accepted whole; interleaving them is
579/// not a spelling any keyboard or any display edge produces, and rejecting it
580/// matches the strictness about a sign anywhere but the leading position. It is
581/// a choice rather than a consequence, so it is stated here and pinned by a
582/// test on both edges. When `zeroDigit` is the ASCII `"0"` the two families are
583/// the same set, so nothing can mix and the rule is invisible.
584///
585/// @param text The locale-formatted entry, e.g. `"1.050,25"`.
586/// @param loc The locale facts. Designated initialisers are the intended
587/// spelling: `{.decimalSeparator = ",", .groupSeparator = "."}`.
588/// @return The canonical `.`-decimal text, or `std::nullopt` when malformed.
589[[nodiscard]] inline std::optional<std::string> normalizeLocaleNumber(std::string_view text,
590 NumericLocale const& loc) {
591 if (!loc.groupSeparator.empty() && loc.groupSeparator == loc.decimalSeparator) {
592 return std::nullopt; // one string cannot play both roles: see above
593 }
594 if (!detail::groupingIsWellPlaced(text, loc)) {
595 return std::nullopt; // a separator off a group boundary: see above
596 }
597
598 char32_t const base = detail::digitBase(loc.zeroDigit);
599 std::string canonical;
600 canonical.reserve(text.size());
601 bool sawDecimal = false;
602 bool sawAnyOutput = false;
603 bool sawNativeDigit = false;
604 bool sawAsciiDigit = false;
605
606 for (std::size_t i = 0; i < text.size();) {
607 const std::string_view rest = text.substr(i);
608 if (std::size_t const group = detail::leadingSeparatorLength(rest, loc.groupSeparator); group != 0) {
609 // Placement was settled above, so by here the separator is display
610 // only and is never carried into the output.
611 i += group;
612 continue;
613 }
614 if (std::size_t const point = detail::leadingSeparatorLength(rest, loc.decimalSeparator); point != 0) {
615 if (sawDecimal) {
616 return std::nullopt; // a second decimal separator: malformed
617 }
618 sawDecimal = true;
619 canonical += '.';
620 // The decimal point *is* output: without this, the `sawAnyOutput`
621 // guard in the sign branch below still believes nothing has been
622 // emitted, and a sign placed straight after the separator
623 // ("`,-5`" in a de-DE locale) is accepted as if it were leading.
624 sawAnyOutput = true;
625 i += point;
626 continue;
627 }
628 detail::SignMatch const sign = detail::leadingSign(rest, loc);
629 if (sign.length != 0) {
630 // Leading position of the *output*: a stripped group separator
631 // before the sign would otherwise make an injected sign look
632 // leading. The same rule for both signs, which is why they share a
633 // branch.
634 if (sawAnyOutput) {
635 return std::nullopt; // sign injection past the leading position
636 }
637 // Empty for a positive sign, which is dropped rather than
638 // carried. `sawAnyOutput` is set either way, so "+-5", "++5" and
639 // "1+2" stay malformed: consuming a sign counts as output even when
640 // it contributes no character.
641 canonical += sign.emits;
642 sawAnyOutput = true;
643 i += sign.length;
644 continue;
645 }
646 detail::DigitMatch const digit = detail::leadingDigit(rest, base);
647 if (digit.length == 0) {
648 return std::nullopt; // any other character is malformed
649 }
650 // Which family each digit came from is recorded here and judged once,
651 // below the loop: an entry that mixes them is malformed wherever the
652 // second family appears, so there is nothing an early return would
653 // decide differently, and the scan stays one statement per character
654 // class.
655 (digit.native ? sawNativeDigit : sawAsciiDigit) = true;
656 canonical += digit.canonical;
657 sawAnyOutput = true;
658 i += digit.length;
659 }
660
661 if (sawNativeDigit && sawAsciiDigit) {
662 return std::nullopt; // two digit families in one entry: see above
663 }
664 if (canonical.empty() || canonical == "-") {
665 return std::nullopt;
666 }
667 return canonical;
668}
669
670/// @brief Converts canonical (`.`-decimal) numeric text to locale-formatted
671/// display text, grouping the integer part in triples.
672///
673/// The display-direction inverse of `normalizeLocaleNumber`'s
674/// decimal-separator substitution and digit rewriting, plus thousands grouping
675/// that `normalizeLocaleNumber` takes back: a grouped display round-trips,
676/// because the entry direction *validates* the grouping rather than stripping
677/// it (see "Grouping is validated, not stripped" on that function). A
678/// default-constructed `NumericLocale` is the identity transform.
679///
680/// The sign is emitted as `NumericLocale::negativeSign`, matching what
681/// `normalizeLocaleNumber` accepts back; an empty view is read as
682/// `"-"` rather than as "no sign", because formatting a negative to no sign at
683/// all is a silently wrong value.
684///
685/// @par The digits are emitted in the locale's set
686/// Each canonical `'0'`-`'9'` is emitted as the code point that far above
687/// `NumericLocale::zeroDigit`, so an `ar_EG` caller sees `"\u0665"` where the
688/// canonical text said `'5'`. This edge has to match the entry edge: entry
689/// accepts U+0665, so display emitting a plain `'5'` would leave the pair not
690/// inverse, and `docs/spec/forms/forms.md` requires that round trip. With the
691/// default `zeroDigit` of `"0"` the offset is zero and the canonical ASCII
692/// bytes are copied out unchanged.
693///
694/// @par There is no positive-sign emission, deliberately
695/// A positive number is displayed with no sign at all, in every locale, and
696/// this function ignores `NumericLocale::positiveSign` entirely.
697/// `normalizeLocaleNumber` *accepts* a leading positive sign and drops it, so
698/// the pair is not a strict inverse across one: entry takes a spelling display
699/// never produces. Emitting it is what would be the defect --
700/// `QLocale::positiveSign()` is `'+'` in 657 of the 711 locales Qt 6.11.2
701/// knows, so emitting it would turn every positive number in every form from
702/// `5` into `+5`, a visible product change with no need behind it. The failure
703/// this pair guards against is one edge producing text the other rejects;
704/// emitting a sign nothing asks for would create exactly that.
705/// @param canonicalText Canonical `-?[0-9]+(\.[0-9]+)?` text.
706/// @param loc The locale facts; `positiveSign` is not read.
707/// @return The locale-formatted display text.
708[[nodiscard]] inline std::string formatCanonicalNumber(std::string_view canonicalText, NumericLocale const& loc) {
709 constexpr std::size_t kGroupSize = 3;
710 char32_t const base = detail::digitBase(loc.zeroDigit);
711 bool const neg = !canonicalText.empty() && canonicalText.front() == '-';
712 std::string_view const magnitude = neg ? canonicalText.substr(1) : canonicalText;
713 auto const dot = magnitude.find('.');
714 std::string_view const wholePart = dot == std::string_view::npos ? magnitude : magnitude.substr(0, dot);
715 std::string_view const fracPart = dot == std::string_view::npos ? std::string_view{} : magnitude.substr(dot + 1);
716
717 // A digit of the locale's set is one to four UTF-8 bytes, so the reserve
718 // hint scales with the base rather than assuming one byte per digit.
719 std::size_t const perDigit = detail::utf8Length(base);
720 std::string out;
721 out.reserve(loc.negativeSign.size() + (magnitude.size() * perDigit) +
722 ((wholePart.size() / kGroupSize) * loc.groupSeparator.size()) + loc.decimalSeparator.size());
723
724 if (neg) {
725 out += loc.negativeSign.empty() ? std::string_view{"-"} : loc.negativeSign;
726 }
727 std::size_t index = 0;
728 for (char const chr : wholePart) {
729 if (!loc.groupSeparator.empty() && index != 0 && (wholePart.size() - index) % kGroupSize == 0) {
730 out += loc.groupSeparator;
731 }
732 detail::appendDisplayDigit(out, base, chr);
733 ++index;
734 }
735 if (!fracPart.empty()) {
736 out += loc.decimalSeparator;
737 for (char const chr : fracPart) {
738 detail::appendDisplayDigit(out, base, chr);
739 }
740 }
741 return out;
742}
743
744/// @brief Parses locale-formatted decimal entry text into the exact value it spells.
745///
746/// The one decimal parser morph ships: the forms engine and any application read entry text through it. The text
747/// goes through `normalizeLocaleNumber` first, so the locale's separators, signs and digits are accepted and its
748/// grouping is validated; the canonical text must then be `-?[0-9]+(\.[0-9]+)?` (no exponent, no bare separator,
749/// no surrounding space). The value must fit `math::Rational`: at most `math::kMaxDecimalPlaces` fraction digits, and
750/// the digits read as one integer within `int64_t`. Nothing is rounded: text finer than @p maxDecimalPlaces is
751/// refused, because dropping a digit a user typed changes the value they meant.
752/// @param text The entry, e.g. `"1.050,25"`.
753/// @param loc The locale facts the entry was typed in.
754/// @param maxDecimalPlaces The declared precision, when there is one. The result carries it as its precision tag
755/// (clamped to `math::kMaxDecimalPlaces`); without one the tag is the number of fraction
756/// digits typed.
757/// @return The exact value, or `std::nullopt` for malformed, over-precise or unrepresentable text.
758[[nodiscard]] inline std::optional<math::Rational> parseDecimal(
759 std::string_view text, NumericLocale const& loc, std::optional<std::uint32_t> maxDecimalPlaces = std::nullopt) {
760 std::optional<std::string> const canonical = normalizeLocaleNumber(text, loc);
761 if (!canonical.has_value()) {
762 return std::nullopt;
763 }
764 std::string_view rest{*canonical};
765 bool const negative = rest.starts_with('-');
766 if (negative) {
767 rest.remove_prefix(1);
768 }
769 auto const dot = rest.find('.');
770 std::string_view const whole = rest.substr(0, dot);
771 std::string_view const fraction = dot == std::string_view::npos ? std::string_view{} : rest.substr(dot + 1);
772 auto const allDigits = [](std::string_view part) {
773 return !part.empty() && part.find_first_not_of("0123456789") == std::string_view::npos;
774 };
775 if (!allDigits(whole) || (dot != std::string_view::npos && !allDigits(fraction))) {
776 return std::nullopt;
777 }
778 auto const places = static_cast<std::uint32_t>(fraction.size());
779 if (places > math::kMaxDecimalPlaces || (maxDecimalPlaces.has_value() && places > *maxDecimalPlaces)) {
780 return std::nullopt;
781 }
782 constexpr auto kLimit = static_cast<std::uint64_t>(std::numeric_limits<std::int64_t>::max());
783 std::uint64_t magnitude = 0;
784 for (std::string_view const part : {whole, fraction}) {
785 for (char const chr : part) {
786 auto const digit = static_cast<std::uint64_t>(chr - '0');
787 if (magnitude > (kLimit - digit) / 10U) {
788 return std::nullopt;
789 }
790 magnitude = (magnitude * 10U) + digit;
791 }
792 }
793 auto const signedMagnitude = static_cast<std::int64_t>(magnitude);
794 std::uint32_t const tag = std::min(maxDecimalPlaces.value_or(places), math::kMaxDecimalPlaces);
795 return math::Rational{math::Numerator{negative ? -signedMagnitude : signedMagnitude},
796 math::Denominator{math::detail::powerOfTen(places)}, math::DecimalPlaces{tag}};
797}
798
799} // namespace morph::render
std::optional< std::string > normalizeLocaleNumber(std::string_view text, NumericLocale const &loc)
Converts a locale-formatted numeric string to canonical (-?[0-9]+(\.[0-9]+)?) text.
std::optional< math::Rational > parseDecimal(std::string_view text, NumericLocale const &loc, std::optional< std::uint32_t > maxDecimalPlaces=std::nullopt)
Parses locale-formatted decimal entry text into the exact value it spells.
CodePoint decodeUtf8(std::string_view text)
Decodes the UTF-8 sequence at the start of text.
char32_t digitBase(std::string_view zeroDigit)
The code point of zeroDigit, or U'0' when it is empty or not a well-formed UTF-8 code point.
std::string formatCanonicalNumber(std::string_view canonicalText, NumericLocale const &loc)
Converts canonical (.-decimal) numeric text to locale-formatted display text, grouping the integer pa...
void appendDisplayDigit(std::string &out, char32_t base, char chr)
Appends one character of canonical text to a display string, with a digit rewritten into the locale's...
void appendUtf8(std::string &out, char32_t value)
Appends value to out as UTF-8.
SignMatch leadingSign(std::string_view rest, NumericLocale const &loc)
Matches either sign at the start of rest.
std::size_t utf8Length(char32_t value)
How many bytes value occupies in UTF-8.
DigitMatch leadingDigit(std::string_view rest, char32_t base)
Matches a digit at the start of rest, in the locale's set or in ASCII.
std::size_t leadingSignLength(std::string_view rest, std::string_view localeSign, char asciiSign)
The length of a sign at the start of rest, in bytes, or 0 when there is none there.
bool groupingIsWellPlaced(std::string_view text, NumericLocale const &loc)
Whether every group separator in text sits where a group separator can legally sit.
std::size_t leadingSeparatorLength(std::string_view rest, std::string_view separator)
The length of separator at the start of rest, in bytes, or 0 when it is not there – or is empty.
Exact rational arithmetic for morph model/action values.
constexpr std::uint32_t kMaxDecimalPlaces
Largest decimal precision the type supports: 10^18 is the greatest power of ten that fits in std::int...
Definition rational.hpp:167
Strong type for a decimal-precision count.
Definition rational.hpp:121
Strong type for a Rational denominator.
Definition rational.hpp:154
Strong type for a Rational numerator.
Definition rational.hpp:140
Exact rational number with a runtime decimal-precision tag.
Definition rational.hpp:375
The locale facts both control-edge conversions need, in one designated-initialisable aggregate.
One decoded UTF-8 code point: its value and how many bytes it took.
char32_t value
The decoded scalar value; meaningless when length is 0.
A digit matched at the start of an entry.
char canonical
The canonical ASCII spelling of the digit's value, '0'-'9'.
std::size_t length
The number of bytes the digit occupies; 0 when there is no digit there.
A sign matched at the start of an entry: how much of the text it occupies, and what it contributes to...
std::size_t length
The number of bytes the sign occupies; 0 when there is no sign there.