Files
ppsspp/Common/Render/Text/draw_text_sdl.cpp
T
Henrik RydgårdandClaude Opus 5.5 4a8d130631 SDL text: Reorder mixed-direction text with FriBidi
SDL3_ttf shapes a whole string in one direction, guessed from its first
character, so a Latin run inside Hebrew text (or a Hebrew run inside
English) came out reversed - "PPSSPP Gold" rendered as "PPSSPP dloG".
When a string mixes directions, reorder each line into visual order with
FriBidi and render it left-to-right. Strings in a single direction are
left to SDL3_ttf, which already handles pure Hebrew and shapes Arabic
better than FriBidi's presentation forms.

FriBidi is optional; without it, mixed strings render as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 17:01:11 +02:00

565 lines
16 KiB
C++

#include "ppsspp_config.h"
#include "Common/Log.h"
#include "Common/StringUtils.h"
#include "Common/System/Display.h"
#include "Common/GPU/thin3d.h"
#include "Common/Data/Hash/Hash.h"
#include "Common/Data/Text/WrapText.h"
#include "Common/Data/Encoding/Utf8.h"
#include "Common/File/VFS/VFS.h"
#include "Common/File/FileUtil.h"
#include "Common/File/Path.h"
#include "Common/Render/Text/draw_text.h"
#include "Common/Render/Text/draw_text_sdl.h"
#include <clocale>
#if defined(USE_SDL3_TTF)
#include <SDL3/SDL.h>
#include <SDL3/SDL_properties.h>
#include <SDL3_ttf/SDL_ttf.h>
#if defined(USE_SDL3_TTF_FRIBIDI)
#include <fribidi.h>
#endif
// SDL3_ttf shapes a whole string in one direction, guessed from its first character, so a run of the
// other direction comes out reversed ("PPSSPP Gold" inside Hebrew text). When a string mixes directions,
// reorder each line into visual order with FriBidi and have SDL3_ttf render it left-to-right. Strings in a
// single direction are left to SDL3_ttf, which shapes Arabic better than FriBidi's presentation forms.
// Without FriBidi, mixed strings stay partly reversed.
static bool ReorderBidi(std::string_view str, std::string *out) {
#if defined(USE_SDL3_TTF_FRIBIDI)
bool hasRTL = false;
UTF8 decoder(str);
while (!decoder.end()) {
if (FRIBIDI_IS_RTL(fribidi_get_bidi_type(decoder.next()))) {
hasRTL = true;
break;
}
}
if (!hasRTL) {
return false;
}
std::vector<std::string_view> lines;
SplitString(str, '\n', lines);
std::vector<FriBidiChar> logical;
std::vector<FriBidiChar> visual;
std::vector<FriBidiLevel> levels;
std::vector<char> utf8;
std::string result;
bool mixed = false;
int firstLevel = -1;
for (size_t i = 0; i < lines.size(); i++) {
if (i > 0) {
result.push_back('\n');
}
std::string_view line = lines[i];
logical.resize(line.size() + 1);
FriBidiStrIndex len = fribidi_charset_to_unicode(FRIBIDI_CHAR_SET_UTF8, line.data(), (FriBidiStrIndex)line.size(), logical.data());
if (len <= 0) {
result.append(line);
continue;
}
visual.resize(len + 1);
levels.resize(len);
FriBidiParType baseDir = FRIBIDI_PAR_ON;
if (!fribidi_log2vis(logical.data(), len, &baseDir, visual.data(), nullptr, nullptr, levels.data())) {
return false;
}
for (FriBidiStrIndex j = 0; j < len; j++) {
if (firstLevel < 0) {
firstLevel = levels[j];
} else if (levels[j] != firstLevel) {
mixed = true;
}
}
utf8.resize(len * 4 + 1);
FriBidiStrIndex size = fribidi_unicode_to_charset(FRIBIDI_CHAR_SET_UTF8, visual.data(), len, utf8.data());
result.append(utf8.data(), size);
}
if (!mixed) {
return false;
}
*out = std::move(result);
return true;
#else
return false;
#endif
}
static void SetBidiDirection(TTF_Font *font, bool reordered) {
#if defined(USE_SDL3_TTF_FRIBIDI)
// The font is shared, so set the direction every time, either way.
TTF_SetFontDirection(font, reordered ? TTF_DIRECTION_LTR : TTF_DIRECTION_INVALID);
#endif
}
static TTF_Font *OpenFontFace(const std::string &fontPath, float ptSize, int faceIndex) {
SDL_PropertiesID props = SDL_CreateProperties();
if (!props) {
return nullptr;
}
SDL_SetStringProperty(props, TTF_PROP_FONT_CREATE_FILENAME_STRING, fontPath.c_str());
SDL_SetFloatProperty(props, TTF_PROP_FONT_CREATE_SIZE_FLOAT, ptSize);
SDL_SetNumberProperty(props, TTF_PROP_FONT_CREATE_FACE_NUMBER, faceIndex);
TTF_Font *font = TTF_OpenFontWithProperties(props);
SDL_DestroyProperties(props);
return font;
}
static std::string getlocale() {
// setlocale is not an intuitive function...
char *curlocale = setlocale(LC_CTYPE, nullptr);
std::string loc = curlocale ? std::string(curlocale) : "en_US";
size_t ptPos = loc.find('.');
// Remove any secondary specifier.
if (ptPos != std::string::npos) {
loc.resize(ptPos);
}
return loc;
}
TextDrawerSDL::TextDrawerSDL(Draw::DrawContext *draw): TextDrawer(draw) {
if (!TTF_Init()) {
ERROR_LOG(Log::G3D, "Unable to initialize SDL3_ttf");
}
dpiScale_ = CalculateDPIScale();
#if defined(USE_SDL3_TTF_FONTCONFIG)
config = FcInitLoadConfigAndFonts();
#endif
PrepareFallbackFonts(getlocale());
}
TextDrawerSDL::~TextDrawerSDL() {
ClearCache();
ClearFonts();
TTF_Quit();
#if defined(USE_SDL3_TTF_FONTCONFIG)
FcConfigDestroy(config);
// Don't call this - it crashes, see https://github.com/openframeworks/openFrameworks/issues/5061.
//FcFini();
#endif
}
// If a user complains about missing characters on SDL, re-visit this!
void TextDrawerSDL::PrepareFallbackFonts(std::string_view locale) {
#if defined(USE_SDL3_TTF_FONTCONFIG)
FcObjectSet *os = FcObjectSetBuild (FC_FILE, FC_INDEX, (char *) 0);
// To install the recommended Droid Sans fallback font in Ubuntu:
// sudo apt install fonts-droid-fallback
const char *hardcodedNames[] = {
"Droid Sans",
"Droid Sans Fallback",
"Source Han Sans",
"Noto Sans CJK",
"Noto Sans Hebrew",
"Noto Sans Lao",
"Noto Sans Thai",
"DejaVu Sans Condensed",
"DejaVu Sans",
"Meera Regular",
"FreeSans",
"Gargi",
"KacstDigital",
"KacstFarsi",
"Khmer OS",
"Paduak",
"Paduak",
"Jamrul",
};
std::vector<const char *> names;
if (locale == "zh_CN") {
names.push_back("Noto Sans CJK SC");
} else if (locale == "zh_TW") {
names.push_back("Noto Sans CJK TC");
names.push_back("Noto Sans CJK HK");
} else if (locale == "ja_JP") {
names.push_back("Noto Sans CJK JP");
} else if (locale == "ko_KR") {
names.push_back("Noto Sans CJK KR");
} else {
// Let's just pick one.
names.push_back("Noto Sans CJK JP");
}
// Then push all the hardcoded ones.
for (int i = 0; i < ARRAY_SIZE(hardcodedNames); i++) {
names.push_back(hardcodedNames[i]);
}
// First, add the region-specific Noto fonts according to the locale.
for (int i = 0; i < names.size(); i++) {
// printf("trying font name %s\n", names[i]);
FcPattern *name = FcNameParse((const FcChar8 *)names[i]);
FcPatternAddInteger(name, FC_WEIGHT, FC_WEIGHT_NORMAL);
FcPatternAddInteger(name, FC_SLANT, FC_SLANT_ROMAN);
FcFontSet *foundFonts = FcFontList(config, name, os);
std::vector<std::pair<std::string, int>> preferredFonts;
std::vector<std::pair<std::string, int>> otherFonts;
for (int j = 0; foundFonts && j < foundFonts->nfont; ++j) {
FcPattern* font = foundFonts->fonts[j];
FcChar8 *path;
int fontIndex;
int fontWeight = FC_WEIGHT_NORMAL;
if (FcPatternGetInteger(font, FC_INDEX, 0, &fontIndex) != FcResultMatch) {
fontIndex = 0; // The 0th face is guaranteed to exist
}
if (FcPatternGetInteger(font, FC_WEIGHT, 0, &fontWeight) != FcResultMatch) {
fontWeight = FC_WEIGHT_NORMAL;
}
if (FcPatternGetString(font, FC_FILE, 0, &path) == FcResultMatch) {
std::string path_str((const char*)path);
if (fontWeight <= FC_WEIGHT_NORMAL) {
preferredFonts.push_back(std::make_pair(path_str, fontIndex));
} else {
otherFonts.push_back(std::make_pair(path_str, fontIndex));
}
}
}
auto addFallbackUnique = [&](const std::pair<std::string, int> &candidate) {
for (const auto &existing : fallbackFontPaths_) {
if (existing.first == candidate.first && existing.second == candidate.second) {
return;
}
}
fallbackFontPaths_.push_back(candidate);
};
for (const auto &candidate : preferredFonts) {
addFallbackUnique(candidate);
}
for (const auto &candidate : otherFonts) {
addFallbackUnique(candidate);
}
if (foundFonts) {
FcFontSetDestroy(foundFonts);
}
FcPatternDestroy(name);
}
if (os) {
FcObjectSetDestroy(os);
}
#elif PPSSPP_PLATFORM(MAC)
const char *fontDirs[] = {
"/System/Library/Fonts/",
"/System/Library/Fonts/Supplemental/",
"/Library/Fonts/"
};
const char *fallbackFonts[] = {
"Hiragino Sans GB.ttc",
"PingFang.ttc",
"PingFang SC.ttc",
"PingFang TC.ttc",
"ヒラギノ角ゴシック W4.ttc",
"AppleGothic.ttf",
"Arial Unicode.ttf",
};
for (int i = 0; i < ARRAY_SIZE(fontDirs); i++) {
for (int j = 0; j < ARRAY_SIZE(fallbackFonts); j++) {
Path fontPath = Path(fontDirs[i]) / fallbackFonts[j];
if (File::Exists(fontPath)) {
TTF_Font *openedFont = TTF_OpenFont(fontPath.ToString().c_str(), 24.0f);
int numFaces = TTF_GetNumFontFaces(openedFont);
for (int k = 0; k < numFaces; k++) {
fallbackFontPaths_.push_back(std::make_pair(fontPath.ToString(), k));
}
TTF_CloseFont(openedFont);
}
}
}
#else
// We don't have a fallback font for this platform.
// Unsupported characters will be rendered as squares.
#endif
}
uint32_t TextDrawerSDL::CheckMissingGlyph(std::string_view text) {
auto iter = fontMap_.find(fontStyle_);
if (iter == fontMap_.end()) {
return 0;
}
TTF_Font *font = iter->second.first;
if (!font) {
return 0;
}
UTF8 utf8Decoded(text);
uint32_t missingGlyph = 0;
while (!utf8Decoded.end()) {
uint32_t glyph = utf8Decoded.next();
if (!TTF_FontHasGlyph(font, glyph)) {
missingGlyph = glyph;
break;
}
}
return missingGlyph;
}
// If this returns >= 0, the nth font in fallbackFonts_ can be used as a fallback.
int TextDrawerSDL::FindFallbackFonts(uint32_t missingGlyph, int ptSize) {
auto iter = glyphFallbackFontIndex_.find(missingGlyph);
if (iter != glyphFallbackFontIndex_.end()) {
return iter->second;
}
// If we encounter a missing glyph, try to use one of already loaded fallback fonts.
for (int i = 0; i < fallbackFonts_.size(); i++) {
TTF_Font *fallbackFont = fallbackFonts_[i];
if (TTF_FontHasGlyph(fallbackFont, missingGlyph)) {
glyphFallbackFontIndex_[missingGlyph] = i;
return i;
}
}
// If none of the loaded fonts can handle it, load more fonts.
// TODO: Don't retry already tried fonts.
for (int i = 0; i < fallbackFontPaths_.size(); i++) {
std::string& fontPath = fallbackFontPaths_[i].first;
int faceIndex = fallbackFontPaths_[i].second;
TTF_Font *font = OpenFontFace(fontPath, static_cast<float>(ptSize), faceIndex);
if (font && TTF_FontHasGlyph(font, missingGlyph)) {
fallbackFonts_.push_back(font);
return fallbackFonts_.size() - 1;
} else {
if (font) {
TTF_CloseFont(font);
}
}
}
// Not found at all? Let's remember that for this glyph.
glyphFallbackFontIndex_[missingGlyph] = -1;
return -1;
}
void TextDrawerSDL::SetOrCreateFont(const FontStyle &style) {
auto iter = fontMap_.find(style);
if (iter != fontMap_.end()) {
fontStyle_ = style;
return;
}
TTF_Font *font = nullptr;
uint8_t *fileData = nullptr;
std::string useFont = GetFilenameForFontStyle(style) + ".ttf";
const int ptSize = static_cast<int>(style.sizePts / dpiScale_ * 1.25f);
DEBUG_LOG(Log::G3D, "Loading SDL font '%s' from VFS at size %d pts", useFont.c_str(), ptSize);
size_t fileSz;
fileData = g_VFS.ReadFile(useFont.c_str(), &fileSz);
if (fileData) {
SDL_IOStream *rw = SDL_IOFromConstMem(fileData, fileSz);
DEBUG_LOG(Log::G3D, "Opened font from RW: '%p' '%d'", fileData, (int)fileSz);
font = TTF_OpenFontIO(rw, true, static_cast<float>(ptSize));
if (!font) {
ERROR_LOG(Log::G3D, "Failed to load font from asset file: '%s'", useFont.c_str());
}
} else {
ERROR_LOG(Log::G3D, "Failed to load font file %s from VFS", useFont.c_str());
}
fontMap_[style] = std::make_pair(font, fileData);
fontStyle_ = style;
}
void TextDrawerSDL::MeasureStringInternal(std::string_view str, float *w, float *h) {
*w = 1.0f;
*h = 1.0f;
auto iter = fontMap_.find(fontStyle_);
if (iter == fontMap_.end()) {
return;
}
TTF_Font *font = iter->second.first;
if (!font) {
return;
}
std::string reorderedStr;
const bool reordered = ReorderBidi(str, &reorderedStr);
if (reordered) {
str = reorderedStr;
}
uint32_t missingGlyph = CheckMissingGlyph(str);
if (missingGlyph) {
const int ptSize = static_cast<int>(fontStyle_.sizePts / dpiScale_ * 1.25f);
const int fallbackFont = FindFallbackFonts(missingGlyph, ptSize);
if (fallbackFont >= 0 && fallbackFont < (int)fallbackFonts_.size()) {
font = fallbackFonts_[fallbackFont];
}
}
SetBidiDirection(font, reordered);
std::vector<std::string_view> lines;
SplitString(str, '\n', lines);
// INFO_LOG(Log::G3D, "Measuring string %.*s", STR_VIEW(str));
int extW = 0, extH = 0;
std::string temp;
for (auto line : lines) {
int width = 0;
int height = 0;
if (line.empty()) {
// Measure empty lines as if it was a space.
line = " ";
}
temp = line; // zero-terminate, ugh.
TTF_GetStringSize(font, temp.c_str(), temp.size(), &width, &height);
if (width > extW)
extW = width;
extH += height;
}
*w = (float)extW;
*h = (float)extH;
}
bool TextDrawerSDL::DrawStringBitmap(std::vector<uint8_t> &bitmapData, TextStringEntry &entry, Draw::DataFormat texFormat, std::string_view str, int align, bool fullColor) {
_dbg_assert_(!fullColor)
if (str.empty()) {
bitmapData.clear();
return false;
}
auto fontIter = fontMap_.find(fontStyle_);
if (fontIter == fontMap_.end()) {
ERROR_LOG(Log::G3D, "Font style not in map");
return false;
}
TTF_Font *font = fontIter->second.first;
if (!font) {
return false;
}
std::string processedStr;
const bool reordered = ReorderBidi(str, &processedStr);
if (!reordered) {
processedStr = str;
}
// If a string includes only newlines, SDL3_ttf will refuse to render it
// thinking it is empty. Add a space to avoid that.
bool isAllNewline = processedStr.find_first_not_of('\n') == std::string::npos;
if (isAllNewline) {
processedStr.push_back(' ');
}
uint32_t missingGlyph = CheckMissingGlyph(processedStr);
if (missingGlyph) {
const int ptSize = static_cast<int>(TTF_GetFontSize(font));
int fallbackFont = FindFallbackFonts(missingGlyph, ptSize);
if (fallbackFont >= 0 && fallbackFont < (int)fallbackFonts_.size()) {
font = fallbackFonts_[fallbackFont];
}
}
SetBidiDirection(font, reordered);
if (align & ALIGN_HCENTER)
TTF_SetFontWrapAlignment(font, TTF_HORIZONTAL_ALIGN_CENTER);
else if (align & ALIGN_RIGHT)
TTF_SetFontWrapAlignment(font, TTF_HORIZONTAL_ALIGN_RIGHT);
else
TTF_SetFontWrapAlignment(font, TTF_HORIZONTAL_ALIGN_LEFT);
SDL_Color fgColor = { 0xFF, 0xFF, 0xFF, 0xFF };
SDL_Surface *text = TTF_RenderText_Blended_Wrapped(font, processedStr.c_str(), processedStr.size(), fgColor, 0);
if (!text) {
ERROR_LOG(Log::G3D, "Failed to render SDL3_ttf text: %s", SDL_GetError());
return false;
}
SDL_LockSurface(text);
entry.texture = nullptr;
// Each row of pixel needs to be aligned to 8 bytes (= 2 pixels), or else
// graphics corruption occurs. Made it 16-byte aligned just to be sure.
entry.bmWidth = entry.width = (text->w + 3) & ~3;
entry.bmHeight = entry.height = text->h;
entry.lastUsedFrame = frameCount_;
uint32_t *imageData = (uint32_t *)text->pixels;
uint32_t pitch = text->pitch / sizeof(uint32_t);
if (texFormat == Draw::DataFormat::B4G4R4A4_UNORM_PACK16 || texFormat == Draw::DataFormat::R4G4B4A4_UNORM_PACK16) {
bitmapData.resize(entry.bmWidth * entry.bmHeight * sizeof(uint16_t));
uint16_t *bitmapData16 = (uint16_t *)&bitmapData[0];
for (int x = 0; x < entry.bmWidth; x++) {
for (int y = 0; y < entry.bmHeight; y++) {
const uint64_t index = entry.bmWidth * y + x;
const u16 alpha = AlphaToPremul4444(imageData[pitch * y + x] >> 24);
bitmapData16[index] = alpha;
}
}
} else if (texFormat == Draw::DataFormat::R8_UNORM) {
bitmapData.resize(entry.bmWidth * entry.bmHeight);
for (int x = 0; x < entry.bmWidth; x++) {
for (int y = 0; y < entry.bmHeight; y++) {
uint64_t index = entry.bmWidth * y + x;
bitmapData[index] = imageData[pitch * y + x] >> 24;
}
}
} else {
_assert_msg_(false, "Bad TextDrawer format");
}
SDL_UnlockSurface(text);
SDL_DestroySurface(text);
return true;
}
void TextDrawerSDL::ClearFonts() {
for (auto iter : fontMap_) {
if (iter.second.first) {
TTF_CloseFont(iter.second.first);
}
delete[] iter.second.second;
}
for (auto iter : fallbackFonts_) {
if (iter) {
TTF_CloseFont(iter);
}
}
// We wipe all the maps, including fontMap_.
fontMap_.clear();
fallbackFonts_.clear();
glyphFallbackFontIndex_.clear();
}
#endif