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 <noreply@anthropic.com>
This commit is contained in:
Henrik RydgårdandClaude Opus 5.5 committed 2026-11-08 12:11:15 +01:00
1 parent fc11ace620
commit ffe3d7bee5
6 files changed
+65 -3

No files matched your search

+18
View File
@@ -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) {
+2
View File
@@ -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.
+4
View File
@@ -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();
}
+2
View File
@@ -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<I18NCategory> GetCategory(I18NCat category);
+35 -3
View File
@@ -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<std::string_view> 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<uint8_t> 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<uint8_t> &bitmapData, TextStringEntry &entry, Draw::DataFormat srcFormat) const {
+4
View File
@@ -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;