[Client] Show localized card names, texts and pictures (#7294)

* [Client] Show localized card names, texts and pictures

Localization wiring now runs end to end: the oracle importer collects
foreignData for the configured language and the client renders it.

- [Oracle] Import localized names and rules texts for the selected cardLang
  - single-face cards store their foreignData name and full text
  - multi-face (split/adventure/aftermath/prepare) cards collect the joined
    name once and join each face's translated text with the same separator
    as the English merge; an incomplete translation falls back to English;
    the joined text follows the same highest-priority-set policy as the
    single-face path and is only collected when localization is enabled
  - the wizard switching languages re-imports the card database

- [Client] Display localized card info throughout the client
  - card info text/picture widgets and the game board re-render on language
    change
  - pictures resolve cardLang art through Scryfall's named endpoint using the
    localized name, falling back to id-based art when no match exists
  - deck editor keeps canonical English names as card identity (EditRole)
    while showing localized names (DisplayRole), so decks and wire names
    stay stable

- [Card] Add CardLocalization-backed name/text lookup and cards.xml v4
  localization elements with a bounded-size translation cache

- [Tests] Cover oracle foreignData import (incl. multi-face joins, priority
  and fallback paths), XML v4 localization parsing, deck model localized
  display and the language-aware settings default

Existing installations need to re-run Oracle to see translations: localized
data only lands in cards.xml when the Oracle app is started with the
preferred language selected — launch the separate "Oracle" program that
ships with Cockatrice, pick the language in the wizard and let it re-import
the card database.

The client's database cache (cards.xml.cache) is invalidated by the cache
format bump and the source-hash checks, but a cache written before the
re-import can still hold English-only entries (the hash uses file size and
mtime, so a same-size/same-timestamp rewrite may be served as-is); delete
cards.xml.cache and relaunch if no localized names/texts show up after
re-importing.

* [Card] Pass localized card names and texts into CardInfo construction

Address review: instead of constructing the card and then calling
setLocalizedName/setLocalizedText (which emit a cardInfoChanged signal per
language), both constructors, both newInstance overloads and their callers
(cards.xml v4 parser and the binary cache reader) now pass the localized maps
as constructor arguments.

* [Client] Rename LocalizedCard:: helpers namespace to CardLocalization

The namespace now matches its header file name, as the review pointed out;
LocalizedCard reads more like a class or struct. Callers (card info text
widget, board card name rendering) are updated to match.

* [Client] Drop unused info member from the card info text widget

The CardInfoPtr member was only ever initialized to nullptr and never read;
remove it together with its initializer.

* [PictureLoader] Add the localized picture URL explicitly, not implicitly

Address review: silently prepending the Scryfall named-picture URL to the
download list whenever a non-English card language was active was surprising,
consumed quota per card when it failed, and could grab the wrong (canon) art on
name collisions, with no way to turn it off.

The insert is now opt-in and user-controlled: changing the card language adds
the template to the top of the download URLs once (persisted, documented in the
re-import prompt, and editable/removable in the deck editor settings), while the
picture loader no longer injects it at request time.

* [Card] Show card languages in the same native (English) format as the UI

Address review: the card text & images language dropdown listed bare native
names, some in inconsistent lowercase (e.g. "čeština", "español de España"),
which makes the languages easy to mix up for users that do not read the script
(e.g. 日本語 vs 한국어). It now mirrors the UI language dropdown and always pairs
the native name with its English name (e.g. "Deutsch (German)",
"日本語 (Japanese)"), using the same fixed casing.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
BruebachL 2026-09-18 12:03:07 +02:00 committed by GitHub
parent 5ace88c111
commit 1c93309952
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 1199 additions and 41 deletions

View file

@ -1,6 +1,7 @@
#include "abstract_card_item.h"
#include "../../client/settings/cache_settings.h"
#include "../../interface/card_localization.h"
#include "../../interface/card_picture_loader/card_picture_loader.h"
#include "../game_scene.h"
#include "../z_values.h"
@ -26,6 +27,8 @@ AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::displayCardNamesChanged, this,
[this] { update(); });
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this,
[this] { update(); });
refreshCardInfo();
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::roundCardCornersChanged, this,
@ -171,7 +174,7 @@ void AbstractCardItem::paintPicture(QPainter *painter, const QSizeF &translatedS
if (SettingsCache::instance().debug().getShowCardId()) {
prefix = "#" + QString::number(id) + " ";
}
nameStr = prefix + cardRef.name;
nameStr = prefix + CardLocalization::displayName(getCardInfo());
}
painter->drawText(QRectF(3 * scaleFactor, 3 * scaleFactor, translatedSize.width() - 6 * scaleFactor,
translatedSize.height() - 6 * scaleFactor),

View file

@ -0,0 +1,59 @@
#ifndef COCKATRICE_CARD_LOCALIZATION_H
#define COCKATRICE_CARD_LOCALIZATION_H
#include "../client/settings/cache_settings.h"
#include <QString>
#include <libcockatrice/card/card_info.h>
#include <libcockatrice/settings/cards_display_settings.h>
namespace CardLocalization
{
/**
* @brief The language code selected for localized card text and images.
*/
inline QString displayLang()
{
return SettingsCache::instance().cardsDisplay().getCardLang();
}
/**
* @brief Card name in the configured display language, falling back to English.
* @param card The card to display.
* @return The localized name, or an empty string for a null card.
*/
inline QString displayName(const CardInfoPtr &card)
{
return card.isNull() ? QString() : card->getLocalizedName(displayLang());
}
/**
* @brief Card rules text in the configured display language, falling back to English.
* @param card The card to display.
* @return The localized text, or an empty string for a null card.
*/
inline QString displayText(const CardInfoPtr &card)
{
return card.isNull() ? QString() : card->getLocalizedText(displayLang());
}
/**
* @brief Card name in the configured display language, falling back to English.
* @param card The card to display.
*/
inline QString displayName(const CardInfo &card)
{
return card.getLocalizedName(displayLang());
}
/**
* @brief Card rules text in the configured display language, falling back to English.
* @param card The card to display.
*/
inline QString displayText(const CardInfo &card)
{
return card.getLocalizedText(displayLang());
}
} // namespace CardLocalization
#endif // COCKATRICE_CARD_LOCALIZATION_H

View file

@ -20,6 +20,7 @@
#include <QThread>
#include <algorithm>
#include <libcockatrice/settings/cache_storage_settings.h>
#include <libcockatrice/settings/cards_display_settings.h>
#include <libcockatrice/settings/download_settings.h>
#include <libcockatrice/settings/paths_settings.h>
#include <utility>
@ -37,6 +38,8 @@ CardPictureLoader::CardPictureLoader() : QObject(nullptr)
&CardPictureLoader::picsPathChanged);
connect(&SettingsCache::instance().downloads(), &DownloadSettings::picDownloadChanged, this,
&CardPictureLoader::picDownloadChanged);
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this,
&CardPictureLoader::cardLangChanged);
qRegisterMetaType<ExactCard>();
connect(worker, &CardPictureLoaderWorker::imageLoaded, this, &CardPictureLoader::imageLoaded);
@ -327,6 +330,15 @@ void CardPictureLoader::picsPathChanged()
QPixmapCache::clear();
}
void CardPictureLoader::cardLangChanged()
{
// Localized images are fetched via a different URL, but the in-memory
// pixmap cache is keyed by card name/uuid, so drop everything cached
// (including failure timestamps) to force a reload in the new language.
QPixmapCache::clear();
failedAt.clear();
}
bool CardPictureLoader::hasCustomArt()
{
auto picsPath = SettingsCache::instance().paths().getPicsPath();

View file

@ -134,6 +134,12 @@ private slots:
* Clears the QPixmap cache to reload images.
*/
void picsPathChanged();
/**
* @brief Triggered when the card language setting changes.
* Clears the in-memory picture caches so images reload in the new language.
*/
void cardLangChanged();
};
#endif

View file

@ -94,7 +94,8 @@ void CardPictureToLoad::populateSetUrls()
}
}
for (const QString &urlTemplate : urlTemplates) {
const QStringList orderedTemplates = urlTemplates;
for (const QString &urlTemplate : orderedTemplates) {
QString transformedUrl = transformUrl(urlTemplate);
if (!transformedUrl.isEmpty()) {
@ -282,8 +283,15 @@ QString CardPictureToLoad::transformUrl(const QString &urlTemplate) const
}
// language setting
transformMap["!sflang!"] = QString(QCoreApplication::translate(
"PictureLoader", "en", "code for scryfall's language property, not available for all languages"));
const QString cardLang = SettingsCache::instance().cardsDisplay().getCardLang();
transformMap["!sflang!"] = cardLang;
// The localized printing's own id is unknown, so Scryfall must resolve it by
// its translated name (see populateSetUrls); expose that name for the
// `/cards/named` template.
if (cardLang != "en") {
transformMap["!localizedName!"] = card.getInfo().getLocalizedName(cardLang);
}
QString transformedUrl = urlTemplate;
for (const QString &prop : transformMap.keys()) {

View file

@ -74,6 +74,8 @@ CardInfoPictureWidget::CardInfoPictureWidget(QWidget *parent, const bool _hoverT
update();
});
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this,
&CardInfoPictureWidget::updatePixmap);
}
/**

View file

@ -1,6 +1,7 @@
#include "card_info_text_widget.h"
#include "../../../game_graphics/board/card_item.h"
#include "../../card_localization.h"
#include <QGridLayout>
#include <QLabel>
@ -10,7 +11,7 @@
#include <libcockatrice/card/game_specific_terms.h>
#include <libcockatrice/card/relation/card_relation.h>
CardInfoTextWidget::CardInfoTextWidget(QWidget *parent) : QFrame(parent), info(nullptr)
CardInfoTextWidget::CardInfoTextWidget(QWidget *parent) : QFrame(parent)
{
propsLabel = new QLabel;
propsLabel->setOpenExternalLinks(false);
@ -39,6 +40,12 @@ CardInfoTextWidget::CardInfoTextWidget(QWidget *parent) : QFrame(parent), info(n
grid->setRowStretch(1, 1);
retranslateUi();
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this, [this] {
if (currentCard) {
setCard(currentCard);
}
});
}
void CardInfoTextWidget::setTexts(const QString &propsText, const QString &textText)
@ -60,7 +67,7 @@ void CardInfoTextWidget::setCard(const ExactCard &exactCard)
QString text = "<table width=\"100%\" border=0 cellspacing=0 cellpadding=0>";
text += QString("<tr><td>%1</td><td width=\"5\"></td><td>%2</td></tr>")
.arg(tr("Name:"), card->getName().toHtmlEscaped());
.arg(tr("Name:"), CardLocalization::displayName(card).toHtmlEscaped());
if (!exactCard.getPrinting().isEmpty()) {
QString setShort = exactCard.getPrinting().getSet()->getShortName().toHtmlEscaped();
@ -94,7 +101,8 @@ void CardInfoTextWidget::setCard(const ExactCard &exactCard)
}
text += "</table>";
setTexts(text, card->getText());
setTexts(text, CardLocalization::displayText(card));
currentCard = exactCard;
}
void CardInfoTextWidget::setInvalidCardName(const QString &cardName)

View file

@ -23,7 +23,7 @@ private:
QLabel *propsLabel;
QScrollArea *propsScroll;
QTextEdit *textLabel;
CardInfoPtr info;
ExactCard currentCard; ///< Last card set, re-rendered when the card language changes.
void setTexts(const QString &propsText, const QString &textText);
public:

View file

@ -345,7 +345,9 @@ ExactCard DeckEditorDeckDockWidget::getCurrentCard()
if (!current.isValid()) {
return {};
}
const QString cardName = current.siblingAtColumn(DeckListModelColumns::CARD_NAME).data().toString();
// The display role holds the localized card name; the edit role always carries the
// canonical English name needed to look the card up in the database.
const QString cardName = current.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString();
const QString cardProviderID = current.siblingAtColumn(DeckListModelColumns::CARD_PROVIDER_ID).data().toString();
const QModelIndex gparent = current.parent().parent();

View file

@ -1,13 +1,20 @@
#include "deck_state_manager.h"
#include "../../../client/settings/cache_settings.h"
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/deck_list/deck_list_history_manager.h>
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
#include <libcockatrice/settings/cards_display_settings.h>
DeckStateManager::DeckStateManager(QObject *parent)
: QObject(parent), deckList(QSharedPointer<DeckList>(new DeckList)),
deckListModel(new DeckListModel(this, deckList)), historyManager(new DeckListHistoryManager(this))
{
deckListModel->setDisplayLanguage(SettingsCache::instance().cardsDisplay().getCardLang());
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, deckListModel,
[this](const QString &lang) { deckListModel->setDisplayLanguage(lang); });
connect(historyManager, &DeckListHistoryManager::undoRedoStateChanged, this, [this] {
setModified(true);
emit historyChanged();
@ -260,7 +267,10 @@ bool DeckStateManager::swapCardAtIndex(const QModelIndex &idx)
return false;
}
QString cardName = idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data().toString();
// The display role holds the localized card name; the edit role always carries the
// canonical English name needed to look the card up in the database.
QString displayCardName = idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data().toString();
QString cardName = idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString();
QString providerId = idx.siblingAtColumn(DeckListModelColumns::CARD_PROVIDER_ID).data().toString();
QModelIndex gparent = idx.parent().parent();
@ -277,7 +287,7 @@ bool DeckStateManager::swapCardAtIndex(const QModelIndex &idx)
QString reason = tr("Moved to %1 1 × \"%2\" (%3)") //
.arg(otherZoneName)
.arg(cardName)
.arg(displayCardName)
.arg(providerId);
return modifyDeck(reason, [&idx, &cardName, &providerId, &otherZoneName](auto model) {
@ -291,9 +301,8 @@ bool DeckStateManager::removeCardAtIndex(const QModelIndex &idx)
return false;
}
QString cardName = idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data().toString();
QString reason = tr("Removed \"%1\" (all copies)").arg(cardName);
QString reason =
tr("Removed \"%1\" (all copies)").arg(idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data().toString());
return modifyDeck(reason, [&idx](auto model) { return model->removeRow(idx.row(), idx.parent()); });
}

View file

@ -416,6 +416,11 @@ void DlgSettings::setTab(int index)
}
}
AbstractSettingsPage *DlgSettings::page(SettingsPage which) const
{
return pages.value(static_cast<int>(which));
}
void DlgSettings::updateLanguage()
{
qApp->removeTranslator(translator); // NOLINT(cppcoreguidelines-pro-type-static-cast-downcast)

View file

@ -54,6 +54,7 @@ public:
explicit DlgSettings(QWidget *parent = nullptr);
void setTab(int index);
AbstractSettingsPage *page(SettingsPage which) const;
private slots:
void onTabClicked(int index);

View file

@ -1,15 +1,21 @@
#include "general_settings_page.h"
#include "../../../client/settings/cache_settings.h"
#include "../interface/card_picture_loader/card_picture_loader.h"
#include "../main.h"
#include "../server/user/user_info_connection.h"
#include "update/client/release_channel.h"
#include <QCoreApplication>
#include <QFile>
#include <QFileDialog>
#include <QGridLayout>
#include <QLineEdit>
#include <QMessageBox>
#include <QTranslator>
#include <libcockatrice/card/card_localization.h>
#include <libcockatrice/settings/cards_display_settings.h>
#include <libcockatrice/settings/download_settings.h>
#include <libcockatrice/settings/paths_settings.h>
#include <libcockatrice/settings/personal_settings.h>
#include <libcockatrice/settings/tabs_settings.h>
@ -46,10 +52,27 @@ GeneralSettingsPage::GeneralSettingsPage()
connect(&languageBox, qOverload<int>(&QComboBox::currentIndexChanged), this,
&GeneralSettingsPage::languageBoxChanged);
// card text & images language, independent of the UI language
cardLanguageBox.addItem(tr("English"), "en");
for (const QString &code : CardLocalization::supportedLanguages()) {
cardLanguageBox.addItem(CardLocalization::languageDisplayName(code), code);
}
const int cardLangIndex = cardLanguageBox.findData(SettingsCache::instance().cardsDisplay().getCardLang());
cardLanguageBox.setCurrentIndex(cardLangIndex < 0 ? 0 : cardLangIndex);
connect(&cardLanguageBox, qOverload<int>(&QComboBox::currentIndexChanged), this,
&GeneralSettingsPage::cardLanguageBoxChanged);
auto *languageGrid = new QGridLayout;
languageGrid->addWidget(&languageLabel, 0, 0);
languageGrid->addWidget(&languageBox, 0, 1);
languageGrid->addWidget(&advertiseTranslationPageLabel, 1, 1, Qt::AlignRight);
languageGrid->addWidget(&cardLanguageLabel, 1, 0);
languageGrid->addWidget(&cardLanguageBox, 1, 1);
languageGrid->addWidget(&cardLanguageNoteLabel, 2, 1);
languageGrid->addWidget(&advertiseTranslationPageLabel, 3, 1, Qt::AlignRight);
cardLanguageNoteLabel.setWordWrap(true);
cardLanguageNoteLabel.setAlignment(Qt::AlignLeft | Qt::AlignVCenter);
languageGroupBox = new QGroupBox;
languageGroupBox->setLayout(languageGrid);
@ -412,6 +435,52 @@ void GeneralSettingsPage::languageBoxChanged(int index)
SettingsCache::instance().personal().setLang(languageBox.itemData(index).toString());
}
void GeneralSettingsPage::cardLanguageBoxChanged(int index)
{
const QString lang = cardLanguageBox.itemData(index).toString();
SettingsCache::instance().cardsDisplay().setCardLang(lang);
// Switching to a non-default language only takes effect after the card
// database is re-imported with that language selected; English data is always
// present, so switching back to English needs no prompt.
if (lang == "en") {
return;
}
// The binary cache does not track the language its entries were imported in,
// and the downloaded pictures were fetched with English art names, so both are
// stale until Oracle re-imports the database in the new language: drop them.
QFile::remove(SettingsCache::instance().getCardDatabasePath() + ".cache");
CardPictureLoader::clearNetworkCache();
CardPictureLoader::clearPixmapCache();
// Art is resolved by the translated card name for non-English languages, so the
// matching Scryfall URL is added to the top of the download list. It stays
// visible in the deck editor settings, where it can be removed or reordered.
const bool localizedUrlAdded = SettingsCache::instance().downloads().addLocalizedScryfallUrl();
QString message = tr("<p>The card database only contains English card data. To see cards in <b>%1</b>, "
"<b>Oracle</b> must run once with this language selected and re-import the card "
"database.</p>"
"<p>The cached database and the downloaded card pictures have been cleared, so a "
"re-import is picked up without stale entries.</p>")
.arg(cardLanguageBox.itemText(index));
if (localizedUrlAdded) {
message += tr("<p>The Scryfall URL that resolves card art by translated name was added to the top of your "
"download list. You can remove or reorder it any time.</p>");
}
message += tr("<p>Run Oracle now?</p>");
const QMessageBox::StandardButton answer = QMessageBox::question(
this, tr("Card text & images language changed"), message, QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes);
// The answer only controls whether Oracle starts right away; the caches stay
// cleared so the next import or launch rebuilds them in the new language.
if (answer == QMessageBox::Yes) {
emit cardDatabaseUpdateRequested();
}
}
void GeneralSettingsPage::updateStartupServerControlsVisibility()
{
const int index = startupTabSelector.currentIndex();
@ -429,6 +498,10 @@ void GeneralSettingsPage::retranslateUi()
languageGroupBox->setTitle(tr("Language settings"));
languageLabel.setText(tr("Language:"));
cardLanguageBox.setItemText(0, tr("English"));
cardLanguageLabel.setText(tr("Card text & images language:"));
cardLanguageNoteLabel.setText(
tr("Foreign card names, text and art apply after you update the card database (Oracle)."));
advertiseTranslationPageLabel.setText(
QString("<a href='%1'>%2</a>").arg(WIKI_TRANSLATION_FAQ).arg(tr("How to help with translations")));

View file

@ -23,6 +23,10 @@ public:
static QStringList findQmFiles();
static QString languageName(const QString &lang);
signals:
/// Request to re-import the card database with the newly selected card language
void cardDatabaseUpdateRequested();
private slots:
void deckPathButtonClicked();
void filtersPathButtonClicked();
@ -33,6 +37,7 @@ private slots:
void tokenDatabasePathButtonClicked();
void resetAllPathsClicked();
void languageBoxChanged(int index);
void cardLanguageBoxChanged(int index);
void updateStartupServerControlsVisibility();
private:
@ -46,6 +51,10 @@ private:
QComboBox languageBox;
QLabel advertiseTranslationPageLabel;
QLabel cardLanguageLabel;
QComboBox cardLanguageBox;
QLabel cardLanguageNoteLabel;
QLabel updateReleaseChannelLabel;
QComboBox updateReleaseChannelBox;
QCheckBox startupUpdateCheckCheckBox;

View file

@ -1,5 +1,6 @@
#include "archidekt_api_response_deck_display_widget.h"
#include "../../../../../../client/settings/cache_settings.h"
#include "../../../../../deck_loader/card_node_function.h"
#include "../../../../../deck_loader/deck_loader.h"
#include "../../../../cards/card_size_widget.h"
@ -10,6 +11,7 @@
#include <QSortFilterProxyModel>
#include <libcockatrice/card/import/card_name_normalizer.h>
#include <libcockatrice/settings/cards_display_settings.h>
ArchidektApiResponseDeckDisplayWidget::ArchidektApiResponseDeckDisplayWidget(QWidget *parent,
ArchidektApiResponseDeck _response,
@ -120,6 +122,9 @@ ArchidektApiResponseDeckDisplayWidget::ArchidektApiResponseDeckDisplayWidget(QWi
}
model = new DeckListModel(this);
model->setDisplayLanguage(SettingsCache::instance().cardsDisplay().getCardLang());
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, model,
[this](const QString &lang) { model->setDisplayLanguage(lang); });
connect(model, &DeckListModel::modelReset, this, &ArchidektApiResponseDeckDisplayWidget::decklistModelReset);
auto decklist = QSharedPointer<DeckList>(new DeckList);

View file

@ -33,6 +33,7 @@
#include "../interface/widgets/dialogs/dlg_update.h"
#include "../interface/widgets/dialogs/dlg_view_log.h"
#include "../interface/widgets/onboarding/first_run_wizard.h"
#include "../interface/widgets/settings_page/general_settings_page.h"
#include "../interface/widgets/tabs/tab_game.h"
#include "../interface/widgets/tabs/tab_server.h"
#include "../interface/widgets/tabs/tab_supervisor.h"
@ -246,6 +247,8 @@ void MainWindow::actFullScreen(bool checked)
void MainWindow::actSettings()
{
DlgSettings dlg(this);
auto *generalPage = qobject_cast<GeneralSettingsPage *>(dlg.page(DlgSettings::GeneralPage));
connect(generalPage, &GeneralSettingsPage::cardDatabaseUpdateRequested, this, &MainWindow::actCheckCardUpdates);
dlg.exec();
}