From fcd54da8c3375d3edc55983d00e3f16a59a06d13 Mon Sep 17 00:00:00 2001
From: Unordered Sigh <116329264+UnorderedSigh@users.noreply.github.com>
Date: Sun, 17 Mar 2024 21:18:33 +0000
Subject: [PATCH] feat(ui): Conditions to word-form numbers in conversations
(#8798)
Allow converting conditions to word form numbers in conversations.
---
source/text/Format.cpp | 170 ++++++++++++++++++++++++++++
source/text/Format.h | 6 +
tests/unit/src/text/test_format.cpp | 23 ++++
3 files changed, 199 insertions(+)
diff --git a/source/text/Format.cpp b/source/text/Format.cpp
index 31dba8e715ec..ebcf08ee65a2 100644
--- a/source/text/Format.cpp
+++ b/source/text/Format.cpp
@@ -24,6 +24,96 @@ this program. If not, see .
using namespace std;
namespace {
+ const int64_t K = 1000;
+ static const vector> WORD_NUMBERS = {
+ { "quintillion", K * K * K * K * K * K },
+ { "quadrillion", K * K * K * K * K },
+ { "trillion", K * K * K * K },
+ { "billion", K * K * K },
+ { "million", K * K },
+ { "thousand", K }
+ };
+ static const vector ONES_NAMES = {
+ "zero ", "one ", "two ", "three ", "four ", "five ",
+ "six ", "seven ", "eight ", "nine ", "ten ", "eleven ",
+ "twelve ", "thirteen ", "fourteen ", "fifteen ",
+ "sixteen ", "seventeen ", "eighteen ", "nineteen "
+ };
+ static const vector TENS_NAMES = {
+ "error", "error", "twenty", "thirty", "forty",
+ "fifty", "sixty", "seventy", "eighty", "ninety"
+ };
+
+ // This struct exists just to allow the operator<< below.
+ struct Wrapped {
+ int64_t value;
+ };
+
+ // Implementation of WordForm. Outputs the word form of the wrapped number,
+ // followed by a space.
+ ostream &operator<< (ostream &o, const Wrapped &num)
+ {
+
+ if(num.value < 0)
+ return o << "negative " << Wrapped { -num.value };
+
+ Wrapped remaining { num };
+
+ if(remaining.value >= 1000)
+ for(auto &nameValue : WORD_NUMBERS)
+ if(remaining.value >= nameValue.second)
+ {
+ Wrapped above { remaining.value / nameValue.second };
+ remaining.value %= nameValue.second;
+ o << above << nameValue.first;
+ if(!remaining.value)
+ return o;
+ o << ' ';
+ }
+
+ if(remaining.value >= 100)
+ {
+ o << ONES_NAMES[(remaining.value / 100) % 10] << "hundred ";
+ remaining.value %= 100;
+ if(!remaining.value)
+ return o;
+ }
+
+ if(remaining.value < 20)
+ return o << ONES_NAMES[remaining.value];
+
+ o << TENS_NAMES[remaining.value / 10];
+ int64_t ones = remaining.value % 10;
+ if(ones)
+ return o << '-' << ONES_NAMES[ones];
+ return o << ' ';
+ }
+
+ string MLAShorthand(int64_t value)
+ {
+ bool negative = value < 0;
+ if(negative)
+ value = -value;
+ for(size_t magnitude = 0; magnitude < WORD_NUMBERS.size() - 1; ++magnitude)
+ {
+ int64_t above = value / WORD_NUMBERS[magnitude + 1].second;
+ int64_t below = value % WORD_NUMBERS[magnitude + 1].second;
+ if(above < 1000)
+ continue;
+ if(above >= 1000000 || !(above % 1000))
+ break;
+ if(below)
+ continue;
+ const size_t BUFLEN = 100;
+ char buf[BUFLEN] = { 0 };
+ snprintf(buf, BUFLEN, "%s%.3f %s",
+ negative ? "negative " : "", above / 1000.0, WORD_NUMBERS[magnitude].first);
+ buf[BUFLEN - 1] = '\0';
+ return string(buf);
+ }
+ return string();
+ }
+
// Format an integer value, inserting its digits into the given string in
// reverse order and then reversing the string.
void FormatInteger(int64_t value, bool isNegative, string &result)
@@ -75,6 +165,18 @@ namespace {
result.append(Format::MassString(value)); // X tons or X ton
else if(IsFormat("playtime"))
result.append(Format::PlayTime(value)); // 3d 19h 24m 8s
+ else if(IsFormat("chicago"))
+ result.append(Format::ChicagoForm(value, false)); // thirty-three or 101
+ else if(IsFormat("Chicago"))
+ result.append(Format::ChicagoForm(value, true)); // Thirty-three or One hundred one
+ else if(IsFormat("mla"))
+ result.append(Format::MLAForm(value, false)); // thirty-three or 101
+ else if(IsFormat("Mla"))
+ result.append(Format::MLAForm(value, true)); // Thirty-three or One hundred one
+ else if(IsFormat("words"))
+ result.append(Format::WordForm(value, false)); // thirty-three or one hundred one
+ else if(IsFormat("Words"))
+ result.append(Format::WordForm(value, true)); // Thirty-three or One hundred one
else
// "number" or unsupported format
result.append(Format::Number(value));
@@ -259,6 +361,74 @@ string Format::Decimal(double value, int places)
+string Format::WordForm(int64_t value, bool startOfSentence)
+{
+ ostringstream o;
+ o << Wrapped { value };
+ string result = o.str();
+ if(result.size() > 0 && result[result.size() - 1] == ' ')
+ result.resize(result.size() - 1);
+ if(!result.empty() && startOfSentence && result[0] >= 'a' && result[0] <= 'z')
+ result[0] -= 32;
+ return result;
+}
+
+
+
+// Chicago manual of style
+string Format::ChicagoForm(int64_t value, bool startOfSentence)
+{
+ if(startOfSentence)
+ return WordForm(value, true);
+ if(value < 1000 && value > -1000 && ! (value % 100))
+ return WordForm(value, startOfSentence);
+ int64_t above = value, below = 0;
+ for(int i = 0; above && i < 6; i++)
+ {
+ if(below)
+ break;
+ else if(above < 100 && above > -100)
+ return WordForm(value, startOfSentence);
+ else if(above < 1000 && above > -1000 && !(above % 100))
+ return WordForm(value, startOfSentence);
+ below = above % 1000;
+ above /= 1000;
+ }
+ return Format::Number(value);
+}
+
+
+
+// MLA Handbook style
+string Format::MLAForm(int64_t value, bool startOfSentence)
+{
+ if(startOfSentence)
+ return WordForm(value, true);
+ if(value >= -99 && value <= 99)
+ return WordForm(value, startOfSentence);
+
+ // 21350000 => 21.35 million
+ string shorthand = MLAShorthand(value);
+ if(!shorthand.empty())
+ return shorthand;
+
+ int64_t above = value, below = 0;
+ for(int i = 0; above && i < 6; i++)
+ {
+ if(below)
+ break;
+ else if(above <= 10 && above >= -10)
+ return WordForm(value, startOfSentence);
+ else if(above < 100 && above > -100 && !(above % 10))
+ return WordForm(value, startOfSentence);
+ below = above % 1000;
+ above /= 1000;
+ }
+ return Format::Number(value);
+}
+
+
+
// Convert a string into a number. As with the output of Number(), the
// string can have suffixes like "M", "B", etc.
// It can also contain spaces or "," as separators like 1,000 or 1 000.
diff --git a/source/text/Format.h b/source/text/Format.h
index 4d9ee48181ea..7e030be02fb3 100644
--- a/source/text/Format.h
+++ b/source/text/Format.h
@@ -54,6 +54,12 @@ class Format {
// Format the given value as a number with exactly the given number of
// decimal places (even if they are all 0).
static std::string Decimal(double value, int places);
+ // Convert numbers to word forms. Capitalize the first letter if at the start of a sentence.
+ static std::string WordForm(int64_t value, bool startOfSentence = false);
+ // Conditionally convert numbers to word forms, based on the Chicago Manual of Style.
+ static std::string ChicagoForm(int64_t value, bool startOfSentence = false);
+ // Conditionally convert numbers to word forms, based on the MLA Style guide.
+ static std::string MLAForm(int64_t value, bool startOfSentence = false);
// Convert a string into a number. As with the output of Number(), the
// string can have suffixes like "M", "B", etc.
static double Parse(const std::string &str);
diff --git a/tests/unit/src/text/test_format.cpp b/tests/unit/src/text/test_format.cpp
index 2443e0059a1a..32a46f9d7a32 100644
--- a/tests/unit/src/text/test_format.cpp
+++ b/tests/unit/src/text/test_format.cpp
@@ -34,6 +34,7 @@ namespace { // test namespace
{ "zero", 0 }, // "0"
{ "negative", -5 }, // "-5"
{ "positive", 61 }, // "61"
+ { "twelve thousand", 12000 }, // "twelve thousand", "12,000"
{ "mass test", 4361000 }, // "4,361,000 tons"
{ "scaled test", 3361000000 }, // "3.361B"
{ "raw test", 1810244 }, // "1810224"
@@ -421,6 +422,28 @@ TEST_CASE( "Format::ExpandConditions", "[Format][ExpandConditions]") {
CHECK( Format::ExpandConditions("", getter) == "" );
CHECK( Format::ExpandConditions("I AM A PRETTY CHICKEN", getter) == "I AM A PRETTY CHICKEN");
}
+ SECTION( "word form" ) {
+ CHECK( Format::ExpandConditions("&[words@zero]", getter) == "zero" );
+ CHECK( Format::ExpandConditions("&[chicago@zero]", getter) == "zero" );
+ CHECK( Format::ExpandConditions("&[mla@zero]", getter) == "zero" );
+ CHECK( Format::ExpandConditions("&[words@negative]", getter) == "negative five" );
+ CHECK( Format::ExpandConditions("&[chicago@negative]", getter) == "negative five" );
+ CHECK( Format::ExpandConditions("&[mla@negative]", getter) == "negative five" );
+ CHECK( Format::ExpandConditions("&[words@big test]", getter) ==
+ "thirty billion one hundred three million ten thousand three hundred one");
+ CHECK( Format::ExpandConditions("&[chicago@big test]", getter) == "30,103,010,301" );
+ CHECK( Format::ExpandConditions("&[mla@big test]", getter) == "30,103,010,301" );
+ CHECK( Format::ExpandConditions("&[Words@big test]", getter) ==
+ "Thirty billion one hundred three million ten thousand three hundred one");
+ CHECK( Format::ExpandConditions("&[Chicago@big test]", getter) ==
+ "Thirty billion one hundred three million ten thousand three hundred one");
+ CHECK( Format::ExpandConditions("&[Mla@big test]", getter) ==
+ "Thirty billion one hundred three million ten thousand three hundred one");
+ CHECK( Format::ExpandConditions("&[words@twelve thousand]", getter) == "twelve thousand" );
+ CHECK( Format::ExpandConditions("&[chicago@twelve thousand]", getter) == "twelve thousand" );
+ CHECK( Format::ExpandConditions("&[mla@twelve thousand]", getter) == "12,000" );
+ CHECK( Format::ExpandConditions("&[mla@credits test]", getter) == "negative 2.361 million" );
+ }
}
// #endregion unit tests