From ffe3d7bee54f7c9b8586588037d8983f4c2a9f3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Sun, 8 Nov 2026 10:56:03 +0100 Subject: [PATCH] TextDrawer: Lay out right-to-left text the same way on every backend GDI and DirectWrite started every line left to right, while the SDL, Cocoa and Android drawers took the direction from the first strong character. So a Hebrew line mixing in Latin words came out in the wrong order on Windows, and on the others when it started with "PPSSPP". When the UI language is right to left, each line containing RTL text now goes to the backend inside an explicit RTL embedding (RLE ... PDF), which all of them honor. Co-Authored-By: Claude Opus 5.5 --- Common/Data/Encoding/Utf8.cpp | 18 +++++++++++++++ Common/Data/Encoding/Utf8.h | 2 ++ Common/Data/Text/I18n.cpp | 4 ++++ Common/Data/Text/I18n.h | 2 ++ Common/Render/Text/draw_text.cpp | 38 +++++++++++++++++++++++++++++--- Common/Render/Text/draw_text.h | 4 ++++ 6 files changed, 65 insertions(+), 3 deletions(-) diff --git a/Common/Data/Encoding/Utf8.cpp b/Common/Data/Encoding/Utf8.cpp index 8ec109d423..dec734da55 100644 --- a/Common/Data/Encoding/Utf8.cpp +++ b/Common/Data/Encoding/Utf8.cpp @@ -169,6 +169,24 @@ bool UTF8StringHasNonASCII(std::string_view utf8string) { return UTF8StringNonASCIICount(utf8string) > 0; } +static bool CodepointIsRTL(uint32_t c) { + return (c >= 0x0590 && c <= 0x08FF) || // Hebrew, Arabic, Syriac, Thaana, NKo, ... + (c >= 0xFB1D && c <= 0xFDFF) || // Hebrew and Arabic presentation forms + (c >= 0xFE70 && c <= 0xFEFF) || + (c >= 0x10800 && c <= 0x10FFF) || + (c >= 0x1E800 && c <= 0x1EFFF); +} + +bool UTF8StringHasRTL(std::string_view utf8string) { + UTF8 utf(utf8string); + while (!utf.end()) { + if (CodepointIsRTL(utf.next())) { + return true; + } + } + return false; +} + #ifdef _WIN32 std::string ConvertWStringToUTF8(const wchar_t *wstr) { diff --git a/Common/Data/Encoding/Utf8.h b/Common/Data/Encoding/Utf8.h index 81363f06e5..ebc36ce510 100644 --- a/Common/Data/Encoding/Utf8.h +++ b/Common/Data/Encoding/Utf8.h @@ -92,6 +92,8 @@ private: int UTF8StringNonASCIICount(std::string_view utf8string); bool UTF8StringHasNonASCII(std::string_view utf8string); +// True if the string contains a character from a right-to-left script (Hebrew, Arabic, ...). +bool UTF8StringHasRTL(std::string_view utf8string); // Removes overlong encodings and similar. diff --git a/Common/Data/Text/I18n.cpp b/Common/Data/Text/I18n.cpp index e9f1683047..82c4cea8c4 100644 --- a/Common/Data/Text/I18n.cpp +++ b/Common/Data/Text/I18n.cpp @@ -50,6 +50,10 @@ std::string I18NRepo::LanguageID() { return languageID_; } +bool I18NRepo::IsRTL() const { + return languageID_ == "he_IL" || languageID_ == "ar_AE" || languageID_ == "fa_IR" || languageID_ == "ku_SO"; +} + I18NRepo::I18NRepo() { Clear(); } diff --git a/Common/Data/Text/I18n.h b/Common/Data/Text/I18n.h index 9d1c98045f..3b14f5ffcb 100644 --- a/Common/Data/Text/I18n.h +++ b/Common/Data/Text/I18n.h @@ -108,6 +108,8 @@ public: bool LoadIni(const std::string &languageID, const Path &overridePath = Path()); // NOT the filename! std::string LanguageID(); + // The loaded language is written right to left. + bool IsRTL() const; std::shared_ptr GetCategory(I18NCat category); diff --git a/Common/Render/Text/draw_text.cpp b/Common/Render/Text/draw_text.cpp index b4dcff9d33..3e4db69ce3 100644 --- a/Common/Render/Text/draw_text.cpp +++ b/Common/Render/Text/draw_text.cpp @@ -17,6 +17,7 @@ #include "Common/System/Display.h" #include "Common/GPU/thin3d.h" #include "Common/Data/Hash/Hash.h" +#include "Common/Data/Text/I18n.h" #include "Common/Data/Text/WrapText.h" #include "Common/Data/Encoding/Utf8.h" @@ -90,6 +91,33 @@ const std::string &TextDrawer::WrapString(const FontStyle &style, float scale, s return entry.wrapped; } +// The backends disagree on a line's base direction: GDI and DirectWrite always start left to right, the others +// go by the first strong character, which is wrong for a Hebrew sentence that starts with "PPSSPP". An explicit +// embedding (RLE ... PDF) means the same to all of them. +std::string_view TextDrawer::ApplyBaseDirection(std::string_view str, std::string *storage) { + if (!g_i18nrepo.IsRTL() || !UTF8StringHasRTL(str)) { + return str; + } + static constexpr std::string_view RLE = "\xE2\x80\xAB"; // U+202B + static constexpr std::string_view PDF = "\xE2\x80\xAC"; // U+202C + storage->clear(); + std::vector lines; + SplitString(str, '\n', lines); + for (size_t i = 0; i < lines.size(); i++) { + if (i > 0) { + storage->push_back('\n'); + } + if (UTF8StringHasRTL(lines[i])) { + storage->append(RLE); + storage->append(lines[i]); + storage->append(PDF); + } else { + storage->append(lines[i]); + } + } + return *storage; +} + float TextDrawer::CalculateDPIScale() const { if (ignoreGlobalDpi_) return dpiScale_; @@ -143,7 +171,8 @@ void TextDrawer::DrawString(DrawBuffer &target, const FontStyle &style, float sc TextureDesc desc{}; std::vector bitmapData; EnsureFont(style); - if (!DrawStringBitmap(style, bitmapData, *entry, bitmapFormat, str, align, emoji)) { + std::string bidiStorage; + if (!DrawStringBitmap(style, bitmapData, *entry, bitmapFormat, ApplyBaseDirection(str, &bidiStorage), align, emoji)) { // Nothing drawn. The entry without a texture records that fact. return; } @@ -199,7 +228,8 @@ void TextDrawer::MeasureString(const FontStyle &style, float scaleX, float scale if (inserted) { EnsureFont(style); float extW = 0.0f, extH = 0.0f; - MeasureStringInternal(style, str, &extW, &extH); + std::string bidiStorage; + MeasureStringInternal(style, ApplyBaseDirection(str, &bidiStorage), &extW, &extH); entry.width = extW; entry.height = extH; // Hm, use the old calculation? @@ -264,7 +294,9 @@ bool TextDrawer::DrawStringBitmapRect(const FontStyle &style, float scale, std:: const int wrap = align & (FLAG_WRAP_TEXT | FLAG_ELLIPSIZE_TEXT); EnsureFont(style); - return DrawStringBitmap(style, bitmapData, entry, texFormat, wrap ? std::string_view(WrapString(style, scale, str, bounds.w, wrap)) : str, align, fullColor); + std::string bidiStorage; + const std::string_view text = wrap ? std::string_view(WrapString(style, scale, str, bounds.w, wrap)) : str; + return DrawStringBitmap(style, bitmapData, entry, texFormat, ApplyBaseDirection(text, &bidiStorage), align, fullColor); } void TextDrawer::AddDropShadow(std::vector &bitmapData, TextStringEntry &entry, Draw::DataFormat srcFormat) const { diff --git a/Common/Render/Text/draw_text.h b/Common/Render/Text/draw_text.h index e337c5b8a6..d60178c968 100644 --- a/Common/Render/Text/draw_text.h +++ b/Common/Render/Text/draw_text.h @@ -114,6 +114,10 @@ protected: // Wraps and/or ellipsizes, caching the result. The reference stays valid until the next OncePerFrame. const std::string &WrapString(const FontStyle &style, float scale, std::string_view str, float maxWidth, int flags); + // When the UI language is right to left, puts each line containing right-to-left text in an RTL embedding, + // so every backend lays it out right to left, whatever its first character. Otherwise returns str as is. + static std::string_view ApplyBaseDirection(std::string_view str, std::string *storage); + // v has the 8-bit alpha. static u16 AlphaToPremul4444(u32 v) { v = (v >> 4) & 0x0F;