winget-cli

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README | LICENSE

commit 54a3eb82482a106f50906b5af35bf7f3efb6672c
parent 48dbb90d9cec8c73aeaed01ae0687586752c9319
Author: yao-msft <50888816+yao-msft@users.noreply.github.com>
Date:   Mon, 14 Sep 2020 17:02:41 -0700

Fix alignment of table output with extended characters (#562)


Diffstat:
Msrc/AppInstallerCLICore/TableOutput.h | 16+++++++++++-----
Msrc/AppInstallerCLITests/Strings.cpp | 31+++++++++++++++++++++++++++++++
Msrc/AppInstallerCommonCore/AppInstallerStrings.cpp | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/AppInstallerCommonCore/Public/AppInstallerStrings.h | 33++++++++++++++++++++-------------
4 files changed, 121 insertions(+), 18 deletions(-)

diff --git a/src/AppInstallerCLICore/TableOutput.h b/src/AppInstallerCLICore/TableOutput.h @@ -42,7 +42,7 @@ namespace AppInstaller::CLI::Execution for (size_t i = 0; i < FieldCount; ++i) { m_columns[i].Name = std::move(header[i]); - m_columns[i].MinLength = Utility::UTF8Length(m_columns[i].Name.get()); + m_columns[i].MinLength = Utility::UTF8ColumnWidth(m_columns[i].Name.get()); m_columns[i].MaxLength = 0; } } @@ -93,7 +93,7 @@ namespace AppInstaller::CLI::Execution { for (size_t i = 0; i < FieldCount; ++i) { - m_columns[i].MaxLength = std::max(m_columns[i].MaxLength, Utility::UTF8Length(line[i])); + m_columns[i].MaxLength = std::max(m_columns[i].MaxLength, Utility::UTF8ColumnWidth(line[i])); } } @@ -189,12 +189,18 @@ namespace AppInstaller::CLI::Execution if (col.MaxLength) { - size_t valueLength = Utility::UTF8Length(line[i]); + size_t valueLength = Utility::UTF8ColumnWidth(line[i]); if (valueLength > col.MaxLength) { - out << Utility::UTF8Substring(line[i], 0, col.MaxLength - 1); - out << "\xE2\x80\xA6"; // UTF8 encoding of ellipsis (…) character + size_t actualWidth; + out << Utility::UTF8TrimRightToColumnWidth(line[i], col.MaxLength - 1, actualWidth) << "\xE2\x80\xA6"; // UTF8 encoding of ellipsis (…) character + + // Some characters take 2 unit space, the trimmed string length might be 1 less than the expected length. + if (actualWidth != col.MaxLength - 1) + { + out << ' '; + } if (col.SpaceAfter) { diff --git a/src/AppInstallerCLITests/Strings.cpp b/src/AppInstallerCLITests/Strings.cpp @@ -37,6 +37,37 @@ TEST_CASE("UTF8Substring", "[strings]") REQUIRE(UTF8Substring(s, 1, 8) == "s like \xf0\x9f\x8c\x8a"); } +TEST_CASE("UTF8ColumnWidth", "[strings]") +{ + REQUIRE(UTF8ColumnWidth("") == 0); + REQUIRE(UTF8ColumnWidth("a") == 1); + REQUIRE(UTF8ColumnWidth(" a b c ") == 7); + REQUIRE(UTF8ColumnWidth("K\xC3\xA4se") == 4); // "Käse" + REQUIRE(UTF8ColumnWidth("bye\xE2\x80\xA6") == 4); // "bye…" + REQUIRE(UTF8ColumnWidth("fi\xEF\xAC\x81") == 3); // "fi[fi]" [fi] is not decoupled + REQUIRE(UTF8ColumnWidth("\xf0\x9f\xa6\x86") == 2); // [duck emoji] + REQUIRE(UTF8ColumnWidth("\xf0\x9d\x85\xa0\xf0\x9d\x85\xa0") == 2); // [8th note][8th note] + REQUIRE(UTF8ColumnWidth("\xe6\xb5\x8b\xe8\xaf\x95") == 4); // 测试 + REQUIRE(UTF8ColumnWidth("te\xe6\xb5\x8bs\xe8\xaf\x95t") == 8); // te测s试t +} + +TEST_CASE("UTF8TrimRightToColumnWidth", "[strings]") +{ + size_t actualWidth; + REQUIRE((UTF8TrimRightToColumnWidth("", 0, actualWidth) == "" && actualWidth == 0)); + REQUIRE((UTF8TrimRightToColumnWidth("abcd", 4, actualWidth) == "abcd" && actualWidth == 4)); + REQUIRE((UTF8TrimRightToColumnWidth("abcd", 5, actualWidth) == "abcd" && actualWidth == 4)); + REQUIRE((UTF8TrimRightToColumnWidth("abcd", 2, actualWidth) == "ab" && actualWidth == 2)); + + NormalizedString s{ "te\xe6\xb5\x8bs\xe8\xaf\x95t" }; // // te测s试t + REQUIRE((UTF8TrimRightToColumnWidth(s, 0, actualWidth) == "" && actualWidth == 0)); + REQUIRE((UTF8TrimRightToColumnWidth(s, 2, actualWidth) == "te" && actualWidth == 2)); + REQUIRE((UTF8TrimRightToColumnWidth(s, 3, actualWidth) == "te" && actualWidth == 2)); + REQUIRE((UTF8TrimRightToColumnWidth(s, 4, actualWidth) == "te\xe6\xb5\x8b" && actualWidth == 4)); + REQUIRE((UTF8TrimRightToColumnWidth(s, 8, actualWidth) == "te\xe6\xb5\x8bs\xe8\xaf\x95t" && actualWidth == 8)); + REQUIRE((UTF8TrimRightToColumnWidth(s, 10, actualWidth) == "te\xe6\xb5\x8bs\xe8\xaf\x95t" && actualWidth == 8)); +} + TEST_CASE("Normalize", "[strings]") { REQUIRE(Normalize("test") == "test"); diff --git a/src/AppInstallerCommonCore/AppInstallerStrings.cpp b/src/AppInstallerCommonCore/AppInstallerStrings.cpp @@ -79,6 +79,12 @@ namespace AppInstaller::Utility return m_currentBrk; } + // Returns code point of the chracter at m_currentBrk, or U_SENTINEL if m_currentBrk points to the end. + UChar32 CurrentCodePoint() + { + return utext_char32At(m_text.get(), m_currentBrk); + } + private: wil::unique_any<UText*, decltype(utext_close), &utext_close> m_text; wil::unique_any<UBreakIterator*, decltype(ubrk_close), &ubrk_close> m_brk; @@ -151,6 +157,25 @@ namespace AppInstaller::Utility return numGraphemeClusters; } + size_t UTF8ColumnWidth(const NormalizedUTF8<NormalizationC>& input) + { + ICUBreakIterator itr{ input, UBRK_CHARACTER }; + + size_t columnWidth = 0; + UChar32 currentCP = 0; + + currentCP = itr.CurrentCodePoint(); + while (itr.Next() != UBRK_DONE && currentCP != U_SENTINEL) + { + int32_t width = u_getIntPropertyValue(currentCP, UCHAR_EAST_ASIAN_WIDTH); + columnWidth += width == U_EA_FULLWIDTH || width == U_EA_WIDE ? 2 : 1; + + currentCP = itr.CurrentCodePoint(); + } + + return columnWidth; + } + std::string_view UTF8Substring(std::string_view input, size_t offset, size_t count) { ICUBreakIterator itr{ input, UBRK_CHARACTER }; @@ -177,6 +202,40 @@ namespace AppInstaller::Utility return input.substr(utf8Offset, utf8Count); } + std::string UTF8TrimRightToColumnWidth(const NormalizedUTF8<NormalizationC>& input, size_t expectedWidth, size_t& actualWidth) + { + ICUBreakIterator itr{ input, UBRK_CHARACTER }; + + size_t columnWidth = 0; + UChar32 currentCP = 0; + int32_t currentBrk = 0; + int32_t nextBrk = 0; + + currentCP = itr.CurrentCodePoint(); + currentBrk = itr.CurrentBreak(); + nextBrk = itr.Next(); + while (nextBrk != UBRK_DONE && currentCP != U_SENTINEL) + { + int32_t width = u_getIntPropertyValue(currentCP, UCHAR_EAST_ASIAN_WIDTH); + int charWidth = width == U_EA_FULLWIDTH || width == U_EA_WIDE ? 2 : 1; + columnWidth += charWidth; + + if (columnWidth > expectedWidth) + { + columnWidth -= charWidth; + break; + } + + currentCP = itr.CurrentCodePoint(); + currentBrk = nextBrk; + nextBrk = itr.Next(); + } + + actualWidth = columnWidth; + + return input.substr(0, currentBrk); + } + std::string Normalize(std::string_view input, NORM_FORM form) { if (input.empty()) diff --git a/src/AppInstallerCommonCore/Public/AppInstallerStrings.h b/src/AppInstallerCommonCore/Public/AppInstallerStrings.h @@ -9,24 +9,12 @@ namespace AppInstaller::Utility { - // Compares the two UTF8 strings in a case insensitive manner. - bool CaseInsensitiveEquals(std::string_view a, std::string_view b); - - // Determins if string a starts with string b. - bool CaseInsensitiveStartsWith(std::string_view a, std::string_view b); - // Converts the given UTF16 string to UTF8 std::string ConvertToUTF8(std::wstring_view input); // Converts the given UTF8 string to UTF16 std::wstring ConvertToUTF16(std::string_view input, UINT codePage = CP_UTF8); - // Returns the number of grapheme clusters (characters) in an UTF8-encoded string. - size_t UTF8Length(std::string_view input); - - // Returns a substring view in an UTF8-encoded string. Offset and count are measured in grapheme clusters (characters). - std::string_view UTF8Substring(std::string_view input, size_t offset, size_t count); - // Normalizes a UTF8 string to the given form. std::string Normalize(std::string_view input, NORM_FORM form = NORM_FORM::NormalizationKC); @@ -40,7 +28,7 @@ namespace AppInstaller::Utility NormalizedUTF8() = default; template <size_t Size> - NormalizedUTF8(const char (&s)[Size]) : std::string(Normalize(std::string_view{ s, (s[Size - 1] == '\0' ? Size - 1 : Size) }, Form)) {} + NormalizedUTF8(const char(&s)[Size]) : std::string(Normalize(std::string_view{ s, (s[Size - 1] == '\0' ? Size - 1 : Size) }, Form)) {} NormalizedUTF8(std::string_view sv) : std::string(Normalize(sv, Form)) {} @@ -83,6 +71,25 @@ namespace AppInstaller::Utility using NormalizedString = NormalizedUTF8<>; + // Compares the two UTF8 strings in a case insensitive manner. + bool CaseInsensitiveEquals(std::string_view a, std::string_view b); + + // Determins if string a starts with string b. + bool CaseInsensitiveStartsWith(std::string_view a, std::string_view b); + + // Returns the number of grapheme clusters (characters) in an UTF8-encoded string. + size_t UTF8Length(std::string_view input); + + // Returns the number of units the UTF8-encoded string will take in terminal output. Some characters take 2 units in terminal output. + size_t UTF8ColumnWidth(const NormalizedUTF8<NormalizationC>& input); + + // Returns a substring view in an UTF8-encoded string. Offset and count are measured in grapheme clusters (characters). + std::string_view UTF8Substring(std::string_view input, size_t offset, size_t count); + + // Returns a substring view in an UTF8-encoded string trimmed to be at most expected length. Length is measured as units taken in terminal output. + // Note the returned substring view might be less than specified length as some characters might take 2 units in terminal output. + std::string UTF8TrimRightToColumnWidth(const NormalizedUTF8<NormalizationC>&, size_t expectedWidth, size_t& actualWidth); + // Get the lower case version of the given std::string std::string ToLower(std::string_view in);