From 34c6c0156f479812fe022a4c5a713e72b0879f5e Mon Sep 17 00:00:00 2001 From: giangnam0201 Date: Sat, 29 Aug 2026 22:47:32 +0700 Subject: [PATCH 1/4] Fix translate feature and add a source/target language picker The translate gesture could never arm in PdfGpuView: its Ctrl/Alt marquee branch sat below the SelectText, link-activation, Pan and every annotation tool branch, all of which return unconditionally. With the Select tool active -- a headline 2.4 feature -- the selection was simply never started. The branch now runs first in mousePressEvent. The two view backends also disagreed on the modifier (ContinuousView used Ctrl, PdfGpuView used Alt, and Alt+drag is frequently swallowed by the Windows window manager). Both now accept Ctrl or Alt. On the network side the Google request had no transfer timeout, so a stalled connection emitted neither finished() nor failed() and the UI sat on "Translating..." forever. Both requests now time out at 15s, Google is retried as a GET when the POST comes back empty (some proxies drop the body and return an empty 200), a missing Qt TLS backend is reported explicitly instead of as an opaque reply error, and every attempt logs its HTTP status, Qt error and byte count. Failures surface in the translation popup rather than a 4-second status flash. Translator::translate is now language-parameterised over a 21-language table, and the cache key hashes both languages so en->vi and fr->vi no longer collide. LanguagePickerPopup asks "from X into Y" at the cursor after each selection, defaulting to English -> Vietnamese, with a swap button and an "always use this pair" opt-out persisted via QSettings. Adds a Windows CI workflow so the portable build can be produced without a local Qt/MSVC toolchain. --- .github/workflows/windows-build.yml | 87 +++++++++++++ CMakeLists.txt | 2 + src/core/Translator.cpp | 168 ++++++++++++++++++++----- src/core/Translator.h | 23 +++- src/ui/ContinuousView.cpp | 6 +- src/ui/LanguagePickerPopup.cpp | 184 ++++++++++++++++++++++++++++ src/ui/LanguagePickerPopup.h | 49 ++++++++ src/ui/MainWindow.cpp | 59 +++++++-- src/ui/MainWindow.h | 2 + src/ui/PdfGpuView.cpp | 21 ++-- src/ui/TranslationPopup.cpp | 15 ++- src/ui/TranslationPopup.h | 4 +- 12 files changed, 560 insertions(+), 60 deletions(-) create mode 100644 .github/workflows/windows-build.yml create mode 100644 src/ui/LanguagePickerPopup.cpp create mode 100644 src/ui/LanguagePickerPopup.h diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml new file mode 100644 index 0000000..330d6be --- /dev/null +++ b/.github/workflows/windows-build.yml @@ -0,0 +1,87 @@ +name: Windows build + +# Builds the portable Windows x64 package so the translate fix can be tested +# without a local Qt/MSVC toolchain. PDFium is vendored in third_party/; +# OpenSSL, QPDF and ZLIB come from vcpkg; Tesseract is left out, so OCR is +# disabled in this build (the app builds and runs fine without it). + +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: + +jobs: + build: + runs-on: windows-2022 + + env: + VCPKG_BINARY_SOURCES: 'clear;x-gha,readwrite' + QT_VERSION: '6.8.1' + + steps: + - uses: actions/checkout@v4 + + - name: Export GitHub Actions cache env for vcpkg + uses: actions/github-script@v7 + with: + script: | + core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || ''); + core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); + + - name: Install Qt ${{ env.QT_VERSION }} + uses: jurplel/install-qt-action@v4 + with: + version: ${{ env.QT_VERSION }} + host: windows + target: desktop + arch: win64_msvc2022_64 + modules: 'qtimageformats' + cache: true + + - name: Install vcpkg dependencies (OpenSSL, QPDF, ZLIB) + shell: pwsh + run: | + & "$env:VCPKG_INSTALLATION_ROOT\vcpkg.exe" install ` + openssl:x64-windows qpdf:x64-windows zlib:x64-windows + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Configure + shell: pwsh + run: | + cmake -B build -S . -G "Visual Studio 17 2022" -A x64 ` + -DCMAKE_BUILD_TYPE=Release ` + -DCMAKE_TOOLCHAIN_FILE="$env:VCPKG_INSTALLATION_ROOT\scripts\buildsystems\vcpkg.cmake" ` + -DVCPKG_TARGET_TRIPLET=x64-windows + + - name: Build + run: cmake --build build --config Release --parallel + + - name: Deploy Qt runtime + shell: pwsh + run: | + $out = "build\bin\Release" + if (-not (Test-Path $out)) { $out = "build\bin" } + Get-ChildItem $out + & "$env:QT_ROOT_DIR\bin\windeployqt.exe" --release --no-translations "$out\TorReader.exe" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + # Vendored PDFium and the vcpkg runtime DLLs sit outside windeployqt's view. + Copy-Item third_party\pdfium\bin\pdfium.dll $out -Force + $vcbin = "$env:VCPKG_INSTALLATION_ROOT\installed\x64-windows\bin" + if (Test-Path $vcbin) { + Copy-Item "$vcbin\*.dll" $out -Force -ErrorAction SilentlyContinue + } + Get-ChildItem $out | Select-Object Name, Length + + - name: Package + shell: pwsh + run: | + $out = "build\bin\Release" + if (-not (Test-Path $out)) { $out = "build\bin" } + Compress-Archive -Path "$out\*" -DestinationPath TorReaderPDF-win64.zip -Force + + - uses: actions/upload-artifact@v4 + with: + name: TorReaderPDF-win64 + path: TorReaderPDF-win64.zip + retention-days: 14 diff --git a/CMakeLists.txt b/CMakeLists.txt index f29c80f..403b614 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -187,6 +187,7 @@ set(SOURCES src/ui/UpdateBanner.cpp src/ui/GateDialog.cpp src/ui/TranslationPopup.cpp + src/ui/LanguagePickerPopup.cpp src/ui/NotificationBar.cpp > ) @@ -231,6 +232,7 @@ set(HEADERS src/ui/UpdateBanner.h src/ui/GateDialog.h src/ui/TranslationPopup.h + src/ui/LanguagePickerPopup.h src/ui/NotificationBar.h > ) diff --git a/src/core/Translator.cpp b/src/core/Translator.cpp index a4776fc..5fd9212 100755 --- a/src/core/Translator.cpp +++ b/src/core/Translator.cpp @@ -15,23 +15,60 @@ #include #include #include +#include #include -static constexpr const char* kTargetLang = "vi"; static constexpr const char* kGeminiModel = "gemini-2.5-flash"; static constexpr const char* kCacheFileName = "translate_cache.json"; static constexpr int kMaxMemoryEntries = 500; static constexpr int kMaxDiskEntries = 2000; static constexpr int kCacheSaveDebounceMs = 2000; +static constexpr int kRequestTimeoutMs = 15000; -static QString makeCacheKey(const QString& lang, const QString& text) { +static QString makeCacheKey(const QString& srcLang, const QString& dstLang, + const QString& text) { return QString::fromLatin1( QCryptographicHash::hash( - (lang + QLatin1String("\x1f") + text).toUtf8(), + (srcLang + QLatin1String("\x1f") + dstLang + + QLatin1String("\x1f") + text).toUtf8(), QCryptographicHash::Sha1) .toHex()); } +const QList>& Translator::languages() { + static const QList> kLangs = { + { QStringLiteral("auto"), QStringLiteral("Detect language") }, + { QStringLiteral("en"), QStringLiteral("English") }, + { QStringLiteral("vi"), QStringLiteral("Vietnamese") }, + { QStringLiteral("zh-CN"),QStringLiteral("Chinese (Simplified)") }, + { QStringLiteral("zh-TW"),QStringLiteral("Chinese (Traditional)") }, + { QStringLiteral("ja"), QStringLiteral("Japanese") }, + { QStringLiteral("ko"), QStringLiteral("Korean") }, + { QStringLiteral("fr"), QStringLiteral("French") }, + { QStringLiteral("de"), QStringLiteral("German") }, + { QStringLiteral("es"), QStringLiteral("Spanish") }, + { QStringLiteral("pt"), QStringLiteral("Portuguese") }, + { QStringLiteral("it"), QStringLiteral("Italian") }, + { QStringLiteral("ru"), QStringLiteral("Russian") }, + { QStringLiteral("th"), QStringLiteral("Thai") }, + { QStringLiteral("id"), QStringLiteral("Indonesian") }, + { QStringLiteral("ms"), QStringLiteral("Malay") }, + { QStringLiteral("hi"), QStringLiteral("Hindi") }, + { QStringLiteral("ar"), QStringLiteral("Arabic") }, + { QStringLiteral("nl"), QStringLiteral("Dutch") }, + { QStringLiteral("pl"), QStringLiteral("Polish") }, + { QStringLiteral("tr"), QStringLiteral("Turkish") }, + }; + return kLangs; +} + +QString Translator::languageName(const QString& code) { + for (const auto& p : languages()) + if (p.first.compare(code, Qt::CaseInsensitive) == 0) + return p.second; + return code; +} + Translator::Translator(QObject* parent) : QObject(parent) , m_nam(new QNetworkAccessManager(this)) @@ -45,13 +82,15 @@ Translator::Translator(QObject* parent) m_diskCachePath = appData + QLatin1Char('/') + QLatin1String(kCacheFileName); } -void Translator::translate(const QString& text) { - const QString lang = QString::fromLatin1(kTargetLang); - const QString key = makeCacheKey(lang, text); +void Translator::translate(const QString& text, const QString& srcLangIn, + const QString& dstLangIn) { + const QString srcLang = srcLangIn.isEmpty() ? QStringLiteral("auto") : srcLangIn; + const QString dstLang = dstLangIn.isEmpty() ? QStringLiteral("vi") : dstLangIn; + const QString key = makeCacheKey(srcLang, dstLang, text); - auto emitCached = [this, text](const QString& translation) { - QMetaObject::invokeMethod(this, [this, text, translation]() { - emit finished(text, translation); + auto emitCached = [this, text, srcLang, dstLang](const QString& translation) { + QMetaObject::invokeMethod(this, [this, text, translation, srcLang, dstLang]() { + emit finished(text, translation, srcLang, dstLang); }, Qt::QueuedConnection); }; @@ -70,26 +109,74 @@ void Translator::translate(const QString& text) { } } - QUrl url(QStringLiteral("https://translate.googleapis.com/translate_a/single" - "?client=gtx&sl=auto&tl=%1&dt=t").arg(lang)); + // A Qt build shipped without its TLS backend plugin cannot open *any* https + // connection, and the resulting reply error is opaque. Say so plainly. + if (!QSslSocket::supportsSsl()) { + emit failed(QStringLiteral( + "No TLS backend available, so https requests cannot be made. " + "The Qt 'tls' plugin folder is missing next to the executable " + "(re-run windeployqt on the build).")); + return; + } - QNetworkRequest req(url); + sendGoogleRequest(text, srcLang, dstLang, key, /*useGet=*/false); +} + +// Google is asked twice before falling back to Gemini: first as the POST the +// endpoint documents, then as a plain GET. Some proxies and captive networks +// drop the POST body and hand back an empty 200, which used to look exactly +// like "no response at all" from the UI. +void Translator::sendGoogleRequest(const QString& text, const QString& srcLang, + const QString& dstLang, const QString& key, + bool useGet) +{ + QString urlStr = QStringLiteral("https://translate.googleapis.com/translate_a/single" + "?client=gtx&sl=%1&tl=%2&dt=t") + .arg(QString::fromLatin1(QUrl::toPercentEncoding(srcLang)), + QString::fromLatin1(QUrl::toPercentEncoding(dstLang))); + if (useGet) + urlStr += QStringLiteral("&q=") + + QString::fromLatin1(QUrl::toPercentEncoding(text)); + + QNetworkRequest req((QUrl(urlStr))); req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); req.setHeader(QNetworkRequest::UserAgentHeader, "Mozilla/5.0 (compatible; TorReaderPDF/2.0.0)"); + req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, + QNetworkRequest::NoLessSafeRedirectPolicy); + // Without a timeout a stalled connection never emits finished(), so the UI + // would sit on "Translating…" forever with no error. + req.setTransferTimeout(kRequestTimeoutMs); + + QNetworkReply* rep = useGet + ? m_nam->get(req) + : m_nam->post(req, QByteArray("q=") + QUrl::toPercentEncoding(text)); + + connect(rep, &QNetworkReply::finished, this, + [this, rep, text, srcLang, dstLang, key, useGet]() { + rep->deleteLater(); - QByteArray body = "q=" + QUrl::toPercentEncoding(text); - QNetworkReply* rep = m_nam->post(req, body); + const int http = rep->attribute( + QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const QByteArray raw = rep->readAll(); + + qWarning("Translator: %s %s -> http=%d qterr=%d(%s) bytes=%lld", + useGet ? "GET" : "POST", + qPrintable(QStringLiteral("%1->%2").arg(srcLang, dstLang)), + http, int(rep->error()), qPrintable(rep->errorString()), + qint64(raw.size())); - connect(rep, &QNetworkReply::finished, [this, rep, text, lang, key]() { - rep->deleteLater(); if (rep->error() != QNetworkReply::NoError) { - tryGeminiFallback(text, lang, key, rep->errorString()); + const QString why = QStringLiteral("%1 (HTTP %2)") + .arg(rep->errorString()).arg(http); + if (!useGet) + sendGoogleRequest(text, srcLang, dstLang, key, /*useGet=*/true); + else + tryGeminiFallback(text, srcLang, dstLang, key, why); return; } - QByteArray raw = rep->readAll(); QJsonDocument doc = QJsonDocument::fromJson(raw); QString translation; @@ -126,17 +213,26 @@ void Translator::translate(const QString& text) { } if (translation.trimmed().isEmpty()) { - tryGeminiFallback(text, lang, key, - QStringLiteral("No translation returned")); + // An empty 200 usually means the body never reached Google; retry + // as a GET before giving up on the endpoint entirely. + if (!useGet) { + sendGoogleRequest(text, srcLang, dstLang, key, /*useGet=*/true); + return; + } + tryGeminiFallback( + text, srcLang, dstLang, key, + QStringLiteral("Google returned HTTP %1 with %2 bytes and no " + "translation").arg(http).arg(raw.size())); } else { addToCache(key, translation.trimmed()); - emit finished(text, translation.trimmed()); + emit finished(text, translation.trimmed(), srcLang, dstLang); } }); } -void Translator::tryGeminiFallback(const QString& text, const QString& lang, - const QString& key, const QString& primaryError) +void Translator::tryGeminiFallback(const QString& text, const QString& srcLang, + const QString& dstLang, const QString& key, + const QString& primaryError) { if (!m_geminiKeyChecked) { m_geminiApiKey = qEnvironmentVariable("GEMINI_API_KEY"); @@ -149,7 +245,7 @@ void Translator::tryGeminiFallback(const QString& text, const QString& lang, if (m_geminiApiKey.isEmpty()) { emit failed(primaryError + - QStringLiteral(" (Gemini fallback: chưa cấu hình GEMINI_API_KEY)")); + QStringLiteral(" (Gemini fallback unavailable: GEMINI_API_KEY is not configured)")); return; } @@ -161,12 +257,17 @@ void Translator::tryGeminiFallback(const QString& text, const QString& lang, req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); req.setHeader(QNetworkRequest::UserAgentHeader, "Mozilla/5.0 (compatible; TorReaderPDF/2.0.0)"); - req.setTransferTimeout(15000); + req.setTransferTimeout(kRequestTimeoutMs); + + const QString fromPart = (srcLang == QLatin1String("auto")) + ? QString() + : QStringLiteral("from %1 ").arg(languageName(srcLang)); QJsonObject partObj; partObj[QStringLiteral("text")] = - QStringLiteral("Translate the following text to %1. Return ONLY the translation, no explanations:\n\n%2") - .arg(lang, text); + QStringLiteral("Translate the following text %1to %2. " + "Return ONLY the translation, no explanations:\n\n%3") + .arg(fromPart, languageName(dstLang), text); QJsonArray parts; parts.append(partObj); @@ -183,8 +284,16 @@ void Translator::tryGeminiFallback(const QString& text, const QString& lang, QByteArray body = QJsonDocument(bodyObj).toJson(QJsonDocument::Compact); QNetworkReply* rep = m_nam->post(req, body); - connect(rep, &QNetworkReply::finished, [this, rep, text, key, primaryError]() { + connect(rep, &QNetworkReply::finished, this, + [this, rep, text, srcLang, dstLang, key, primaryError]() { rep->deleteLater(); + const int http = rep->attribute( + QNetworkRequest::HttpStatusCodeAttribute).toInt(); + QByteArray raw = rep->readAll(); + qWarning("Translator: Gemini -> http=%d qterr=%d(%s) bytes=%lld", + http, int(rep->error()), qPrintable(rep->errorString()), + qint64(raw.size())); + if (rep->error() != QNetworkReply::NoError) { emit failed(primaryError + QStringLiteral(" (Gemini fallback: ") + rep->errorString() + @@ -192,7 +301,6 @@ void Translator::tryGeminiFallback(const QString& text, const QString& lang, return; } - QByteArray raw = rep->readAll(); QJsonDocument doc = QJsonDocument::fromJson(raw); QString translation; @@ -214,7 +322,7 @@ void Translator::tryGeminiFallback(const QString& text, const QString& lang, QStringLiteral(" (Gemini fallback: empty response)")); } else { addToCache(key, translation); - emit finished(text, translation); + emit finished(text, translation, srcLang, dstLang); } }); } diff --git a/src/core/Translator.h b/src/core/Translator.h index a82cd2e..6f34fb2 100755 --- a/src/core/Translator.h +++ b/src/core/Translator.h @@ -2,6 +2,8 @@ #include #include #include +#include +#include class QNetworkAccessManager; class QTimer; @@ -15,18 +17,31 @@ class Translator : public QObject { Q_OBJECT public: explicit Translator(QObject* parent = nullptr); - void translate(const QString& text); + + // srcLang may be "auto"; dstLang must be a concrete language code. + void translate(const QString& text, + const QString& srcLang = QStringLiteral("en"), + const QString& dstLang = QStringLiteral("vi")); + + // Shared language table: . "auto" is the first entry. + static const QList>& languages(); + static QString languageName(const QString& code); signals: - void finished(const QString& original, const QString& translation); + void finished(const QString& original, const QString& translation, + const QString& srcLang, const QString& dstLang); void failed(const QString& error); private: + void sendGoogleRequest(const QString& text, const QString& srcLang, + const QString& dstLang, const QString& key, + bool useGet); void addToCache(const QString& key, const QString& translation); void loadCacheFromDisk(); void saveCacheToDisk(); - void tryGeminiFallback(const QString& text, const QString& lang, - const QString& key, const QString& primaryError); + void tryGeminiFallback(const QString& text, const QString& srcLang, + const QString& dstLang, const QString& key, + const QString& primaryError); QNetworkAccessManager* m_nam; QHash m_cache; diff --git a/src/ui/ContinuousView.cpp b/src/ui/ContinuousView.cpp index 1152bb0..5f3b68f 100755 --- a/src/ui/ContinuousView.cpp +++ b/src/ui/ContinuousView.cpp @@ -1347,8 +1347,10 @@ void ContinuousView::wheelEvent(QWheelEvent* event) void ContinuousView::mousePressEvent(QMouseEvent* event) { - // Ctrl+Left drag = text selection for translation (giu nguyen, SPEC phan 2). - if ((event->modifiers() & Qt::ControlModifier) + // Ctrl+Left (or Alt+Left) drag = text selection for translation. + // Both modifiers are accepted so the gesture matches PdfGpuView and still + // works when the window manager eats Alt+drag. + if ((event->modifiers() & (Qt::ControlModifier | Qt::AltModifier)) && event->button() == Qt::LeftButton) { m_selecting = true; diff --git a/src/ui/LanguagePickerPopup.cpp b/src/ui/LanguagePickerPopup.cpp new file mode 100644 index 0000000..1c9276c --- /dev/null +++ b/src/ui/LanguagePickerPopup.cpp @@ -0,0 +1,184 @@ +#include "LanguagePickerPopup.h" +#include "../core/Translator.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static constexpr const char* kKeySrc = "translate/srcLang"; +static constexpr const char* kKeyDst = "translate/dstLang"; +static constexpr const char* kKeyRemember = "translate/rememberLangs"; + +QString LanguagePickerPopup::savedSource() { + QSettings s; + return s.value(QLatin1String(kKeySrc), QStringLiteral("en")).toString(); +} + +QString LanguagePickerPopup::savedTarget() { + QSettings s; + return s.value(QLatin1String(kKeyDst), QStringLiteral("vi")).toString(); +} + +bool LanguagePickerPopup::rememberChoice() { + QSettings s; + return s.value(QLatin1String(kKeyRemember), false).toBool(); +} + +LanguagePickerPopup::LanguagePickerPopup(QWidget* parent) + : QDialog(parent, Qt::Tool | Qt::FramelessWindowHint) +{ + setAttribute(Qt::WA_TranslucentBackground); + setWindowTitle(tr("Translate")); + setFixedWidth(360); + + auto* title = new QLabel(tr("Translate selection"), this); + title->setStyleSheet("font-weight:bold; font-size:11pt; color:#1a1a1a;"); + + m_lblPreview = new QLabel(this); + m_lblPreview->setWordWrap(true); + m_lblPreview->setStyleSheet("color:#666; font-size:9pt; font-style:italic;"); + + m_cbFrom = new QComboBox(this); + m_cbTo = new QComboBox(this); + for (const auto& lang : Translator::languages()) { + m_cbFrom->addItem(lang.second, lang.first); + if (lang.first != QLatin1String("auto")) // no "auto" as a target + m_cbTo->addItem(lang.second, lang.first); + } + selectCode(m_cbFrom, savedSource()); + selectCode(m_cbTo, savedTarget()); + + m_btnSwap = new QToolButton(this); + m_btnSwap->setText(QString::fromUtf8("\xE2\x87\x84")); // ⇄ + m_btnSwap->setToolTip(tr("Swap languages")); + m_btnSwap->setCursor(Qt::PointingHandCursor); + m_btnSwap->setAutoRaise(true); + + auto* lblFrom = new QLabel(tr("From"), this); + auto* lblTo = new QLabel(tr("Into"), this); + lblFrom->setStyleSheet("color:#444; font-size:9pt;"); + lblTo->setStyleSheet("color:#444; font-size:9pt;"); + + auto* grid = new QGridLayout; + grid->setHorizontalSpacing(8); + grid->setVerticalSpacing(4); + grid->addWidget(lblFrom, 0, 0); + grid->addWidget(lblTo, 0, 2); + grid->addWidget(m_cbFrom, 1, 0); + grid->addWidget(m_btnSwap,1, 1); + grid->addWidget(m_cbTo, 1, 2); + grid->setColumnStretch(0, 1); + grid->setColumnStretch(2, 1); + + m_chkRemember = new QCheckBox(tr("Always use this pair (don't ask again)"), this); + m_chkRemember->setStyleSheet("color:#444; font-size:9pt;"); + m_chkRemember->setChecked(rememberChoice()); + + m_btnTranslate = new QPushButton(tr("Translate"), this); + m_btnTranslate->setDefault(true); + m_btnTranslate->setCursor(Qt::PointingHandCursor); + m_btnCancel = new QPushButton(tr("Cancel"), this); + m_btnCancel->setCursor(Qt::PointingHandCursor); + + auto* btns = new QHBoxLayout; + btns->addStretch(); + btns->addWidget(m_btnCancel); + btns->addWidget(m_btnTranslate); + + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(14, 12, 14, 12); + layout->setSpacing(8); + layout->addWidget(title); + layout->addWidget(m_lblPreview); + layout->addLayout(grid); + layout->addWidget(m_chkRemember); + layout->addLayout(btns); + + connect(m_btnSwap, &QToolButton::clicked, this, &LanguagePickerPopup::onSwap); + connect(m_btnTranslate, &QPushButton::clicked, this, &LanguagePickerPopup::onTranslate); + connect(m_btnCancel, &QPushButton::clicked, this, &QDialog::reject); +} + +void LanguagePickerPopup::selectCode(QComboBox* box, const QString& code) { + int idx = box->findData(code); + if (idx < 0) + idx = box->findData(box == m_cbTo ? QStringLiteral("vi") : QStringLiteral("en")); + if (idx >= 0) + box->setCurrentIndex(idx); +} + +void LanguagePickerPopup::onSwap() { + const QString from = m_cbFrom->currentData().toString(); + const QString to = m_cbTo->currentData().toString(); + // "auto" cannot be a target, so swapping out of it falls back to English. + selectCode(m_cbTo, from == QLatin1String("auto") ? QStringLiteral("en") : from); + selectCode(m_cbFrom, to); +} + +void LanguagePickerPopup::onTranslate() { + const QString src = m_cbFrom->currentData().toString(); + const QString dst = m_cbTo->currentData().toString(); + + QSettings s; + s.setValue(QLatin1String(kKeySrc), src); + s.setValue(QLatin1String(kKeyDst), dst); + s.setValue(QLatin1String(kKeyRemember), m_chkRemember->isChecked()); + s.sync(); + + hide(); + emit translateRequested(m_text, src, dst); +} + +void LanguagePickerPopup::askFor(const QString& selectedText, const QPoint& globalPos) { + m_text = selectedText; + + QString preview = selectedText.simplified(); + if (preview.size() > 160) + preview = preview.left(157) + QStringLiteral("..."); + // Escaped rather than literal so the file stays ASCII, like the rest of the UI code. + m_lblPreview->setText(QString::fromUtf8("\xE2\x80\x9C%1\xE2\x80\x9D").arg(preview)); + + // Re-read persisted defaults in case they changed elsewhere. + selectCode(m_cbFrom, savedSource()); + selectCode(m_cbTo, savedTarget()); + m_chkRemember->setChecked(rememberChoice()); + + adjustSize(); + + // Place near the cursor, clamped to the screen the cursor is actually on. + QScreen* scr = QGuiApplication::screenAt(globalPos); + if (!scr) scr = QGuiApplication::primaryScreen(); + const QRect avail = scr ? scr->availableGeometry() : QRect(0, 0, 1920, 1080); + + // qBound asserts when min > max, which happens if the popup is wider than + // the screen — clamp the upper bound first. + const int xMax = qMax(avail.left(), avail.right() - width()); + int x = qBound(avail.left(), globalPos.x() - width() / 2, xMax); + int y = globalPos.y() + 14; + if (y + height() > avail.bottom()) + y = qMax(avail.top(), globalPos.y() - height() - 14); + move(x, y); + + show(); + raise(); + activateWindow(); + m_btnTranslate->setFocus(); +} + +void LanguagePickerPopup::paintEvent(QPaintEvent*) { + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing); + p.setBrush(QColor(255, 255, 255, 250)); + p.setPen(QPen(QColor(190, 190, 190), 1)); + p.drawRoundedRect(rect().adjusted(1, 1, -1, -1), 8, 8); +} diff --git a/src/ui/LanguagePickerPopup.h b/src/ui/LanguagePickerPopup.h new file mode 100644 index 0000000..60eccb6 --- /dev/null +++ b/src/ui/LanguagePickerPopup.h @@ -0,0 +1,49 @@ +#pragma once +#include +#include + +class QComboBox; +class QLabel; +class QPushButton; +class QToolButton; +class QCheckBox; + +// Small floating panel shown right after the user marquee-selects text. +// Lets them confirm "translate FROM INTO " before the request +// goes out. Defaults to English -> Vietnamese and remembers the last choice. +class LanguagePickerPopup : public QDialog { + Q_OBJECT +public: + explicit LanguagePickerPopup(QWidget* parent = nullptr); + + // Shows the panel near globalPos with a preview of the selected text. + void askFor(const QString& selectedText, const QPoint& globalPos); + + static QString savedSource(); + static QString savedTarget(); + // True when the user ticked "don't ask again" - callers should then + // translate straight away using savedSource()/savedTarget(). + static bool rememberChoice(); + +signals: + // Emitted when the user confirms. src may be "auto". + void translateRequested(const QString& text, const QString& srcLang, + const QString& dstLang); + +protected: + void paintEvent(QPaintEvent* event) override; + +private: + void selectCode(QComboBox* box, const QString& code); + void onSwap(); + void onTranslate(); + + QLabel* m_lblPreview; + QComboBox* m_cbFrom; + QComboBox* m_cbTo; + QToolButton* m_btnSwap; + QCheckBox* m_chkRemember; + QPushButton* m_btnTranslate; + QPushButton* m_btnCancel; + QString m_text; +}; diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 2f49570..3146fd9 100755 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -26,6 +26,7 @@ #include "core/GoogleAuth.h" #include "core/Translator.h" #include "TranslationPopup.h" +#include "LanguagePickerPopup.h" #include "NoteInputDialog.h" #include "core/UpdateChecker.h" #include "GateDialog.h" @@ -785,7 +786,7 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) { // Permanent hint bar — shows shortcut hints on the right side of the status bar auto* hintLabel = new QLabel( - "Ctrl+Scroll: Zoom · Alt+Drag: Translate " + "Ctrl+Scroll: Zoom · Ctrl/Alt+Drag: Translate " "· Scroll: Flip page · Right-click thumbnail: Page options"); m_hintLabel = hintLabel; hintLabel->setStyleSheet(QStringLiteral("color:%1; font-size:10px; padding-right:8px;") @@ -798,14 +799,36 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) { m_googleAuth = new GoogleAuth(this); m_translator = new Translator(this); m_transPopup = new TranslationPopup(nullptr); // top-level floating window + m_langPicker = new LanguagePickerPopup(nullptr); + + // The picker asks "from X into Y" before anything goes over the network. + connect(m_langPicker, &LanguagePickerPopup::translateRequested, + this, [this](const QString& text, const QString& src, const QString& dst) { + statusBar()->showMessage("Translating\xE2\x80\xA6", 2000); + m_translator->translate(text, src, dst); + }); connect(m_translator, &Translator::finished, - this, [this](const QString& orig, const QString& trans) { - m_transPopup->showTranslation(orig, trans, m_lastTransPos); + this, [this](const QString& orig, const QString& trans, + const QString& src, const QString& dst) { + const QString pair = QStringLiteral("%1 \xE2\x86\x92 %2") + .arg(Translator::languageName(src), + Translator::languageName(dst)); + m_transPopup->showTranslation(orig, trans, m_lastTransPos, pair); }); connect(m_translator, &Translator::failed, this, [this](const QString& err) { - statusBar()->showMessage("Translation failed: " + err, 4000); + // A 4-second status flash was too easy to miss, which made every + // failure look like "nothing happened". Show the reason where the + // translation itself would have appeared. + statusBar()->showMessage("Translation failed: " + err, 10000); + m_transPopup->showTranslation( + QString(), + QStringLiteral("Translation failed" + "
%1") + .arg(err.toHtmlEscaped()), + m_lastTransPos, + QStringLiteral("Error")); }); connect(m_continuousView, &ContinuousView::textRegionSelected, @@ -1231,7 +1254,8 @@ void MainWindow::setupActionBar() { // Translate — nut tren toolbar (giua About va Share app). m_translateAct = new QAction("Translate", this); m_translateAct->setToolTip( - "Enable Google Translate — hold Ctrl and drag over text to select & translate\n" + "Enable Google Translate — hold Ctrl (or Alt) and drag over text to select & translate\n" + "A pop-up asks which language to translate from and into (English \xE2\x86\x92 Vietnamese by default)\n" "Works in both Single and Continuous modes\n" "Right-click to reset consent"); m_translateAct->setShortcut(QKeySequence("Ctrl+Shift+T")); @@ -1241,9 +1265,11 @@ void MainWindow::setupActionBar() { mb.setWindowTitle("Google Translate Enabled"); mb.setText( "Translation is enabled.

" - "Hold Ctrl and drag over text to select it.
" + "Hold Ctrl (or Alt) and drag over text to select it.
" "Works in both Single page and Continuous modes.
" - "Release to automatically translate the selected text to Vietnamese.

" + "On release a pop-up asks which language to translate " + "from and into \xE2\x80\x94 English \xE2\x86\x92 Vietnamese by default.
" + "Tick Always use this pair there to skip the pop-up next time.

" "To disable/reset: right-click the Translate button."); mb.setIcon(QMessageBox::Information); mb.setStandardButtons(QMessageBox::Ok); @@ -4533,16 +4559,25 @@ void MainWindow::onTextRegionSelected(int pageIdx, QRectF rectPts, QPoint global FPDF_DOCUMENT rawDoc = t->doc->raw(); m_lastTransPos = globalPos; - statusBar()->showMessage("Translating…", 3000); + statusBar()->showMessage("Extracting text…", 3000); auto* watcher = new QFutureWatcher(this); connect(watcher, &QFutureWatcher::finished, this, - [this, watcher]() { + [this, watcher, globalPos]() { watcher->deleteLater(); QString text = watcher->result(); - if (!text.isEmpty()) - m_translator->translate(text); - else + if (!text.isEmpty()) { + // Ask "from X into Y" first, unless the user ticked "don't ask again". + if (LanguagePickerPopup::rememberChoice()) { + statusBar()->showMessage("Translating…", 2000); + m_translator->translate(text, + LanguagePickerPopup::savedSource(), + LanguagePickerPopup::savedTarget()); + } else { + statusBar()->clearMessage(); + m_langPicker->askFor(text, globalPos); + } + } else statusBar()->showMessage( "No selectable text in this area. " "Scanned pages may require OCR.", 4000); diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h index 3c79724..d3b631f 100755 --- a/src/ui/MainWindow.h +++ b/src/ui/MainWindow.h @@ -34,6 +34,7 @@ class ContinuousView; class QSplitter; class Translator; class TranslationPopup; +class LanguagePickerPopup; class GoogleAuth; class UpdateChecker; class QTimer; @@ -246,6 +247,7 @@ private slots: // Translation feature Translator* m_translator = nullptr; TranslationPopup* m_transPopup = nullptr; + LanguagePickerPopup* m_langPicker = nullptr; GoogleAuth* m_googleAuth = nullptr; UpdateChecker* m_updateChecker = nullptr; QPoint m_lastTransPos; diff --git a/src/ui/PdfGpuView.cpp b/src/ui/PdfGpuView.cpp index 45827e3..d865753 100755 --- a/src/ui/PdfGpuView.cpp +++ b/src/ui/PdfGpuView.cpp @@ -1441,6 +1441,18 @@ void PdfGpuView::mousePressEvent(QMouseEvent* e) { update(); return; } + // Ctrl/Alt + Left drag = marquee selection for translation. Checked here, + // before every tool branch below: those branches return unconditionally, + // so anywhere further down the gesture would simply never arm. + if ((e->modifiers() & (Qt::AltModifier | Qt::ControlModifier)) + && e->button() == Qt::LeftButton && m_hasImage) { + m_selecting = true; + m_selStart = e->position(); + m_selEnd = e->position(); + setCursor(Qt::IBeamCursor); + update(); + return; + } // Link: chi khi tool la Pan hoac Select, khong bat Ctrl. Bam vao link thi // di theo link truoc khi bat dau pan/chon chu (SPEC_PDF_LINKS muc 3). if ((m_tool == ViewTool::Pan || m_tool == ViewTool::SelectText) @@ -1507,15 +1519,6 @@ void PdfGpuView::mousePressEvent(QMouseEvent* e) { beginTextSelection(e->position(), 1); return; } - // Alt+Left drag = text selection for translation - if ((e->modifiers() & Qt::AltModifier) && e->button() == Qt::LeftButton) { - m_selecting = true; - m_selStart = e->position(); - m_selEnd = e->position(); - setCursor(Qt::IBeamCursor); - update(); - return; - } if (e->button() == Qt::LeftButton || e->button() == Qt::MiddleButton) { m_panning = true; m_lastMousePos = e->position(); diff --git a/src/ui/TranslationPopup.cpp b/src/ui/TranslationPopup.cpp index 4c8ce03..e81e879 100755 --- a/src/ui/TranslationPopup.cpp +++ b/src/ui/TranslationPopup.cpp @@ -18,6 +18,10 @@ TranslationPopup::TranslationPopup(QWidget* parent) setAttribute(Qt::WA_ShowWithoutActivating); setFixedWidth(340); + m_lblLangPair = new QLabel(this); + m_lblLangPair->setStyleSheet("color:#2563EB; font-size:8pt; font-weight:bold;"); + m_lblLangPair->setVisible(false); + m_lblOriginal = new QLabel(this); m_lblOriginal->setWordWrap(true); m_lblOriginal->setStyleSheet("color:#666; font-size:9pt; font-style:italic;"); @@ -52,6 +56,7 @@ TranslationPopup::TranslationPopup(QWidget* parent) auto* layout = new QVBoxLayout(this); layout->setContentsMargins(14, 12, 14, 12); layout->setSpacing(6); + layout->addWidget(m_lblLangPair); layout->addWidget(m_lblOriginal); layout->addWidget(m_lblTranslated); layout->addLayout(hdr); @@ -66,9 +71,13 @@ TranslationPopup::TranslationPopup(QWidget* parent) void TranslationPopup::showTranslation(const QString& original, const QString& translation, - const QPoint& globalPos) + const QPoint& globalPos, + const QString& langPair) { + m_lblLangPair->setText(langPair); + m_lblLangPair->setVisible(!langPair.isEmpty()); m_lblOriginal->setText(original); + m_lblOriginal->setVisible(!original.isEmpty()); m_lblTranslated->setText(translation.isEmpty() ? "(no translation)" : translation); m_translation = translation; @@ -76,7 +85,9 @@ void TranslationPopup::showTranslation(const QString& original, adjustSize(); - QRect screen = QGuiApplication::primaryScreen()->availableGeometry(); + QScreen* scr = QGuiApplication::screenAt(globalPos); + if (!scr) scr = QGuiApplication::primaryScreen(); + QRect screen = scr ? scr->availableGeometry() : QRect(0, 0, 1920, 1080); int x = qBound(screen.left(), globalPos.x() - width() / 2, screen.right() - width()); int y = globalPos.y() - height() - 12; diff --git a/src/ui/TranslationPopup.h b/src/ui/TranslationPopup.h index 100e16a..5b53414 100755 --- a/src/ui/TranslationPopup.h +++ b/src/ui/TranslationPopup.h @@ -11,7 +11,8 @@ class TranslationPopup : public QWidget { public: explicit TranslationPopup(QWidget* parent = nullptr); void showTranslation(const QString& original, const QString& translation, - const QPoint& globalPos); + const QPoint& globalPos, + const QString& langPair = QString()); protected: void paintEvent(QPaintEvent* event) override; @@ -20,6 +21,7 @@ private slots: void onCopy(); private: + QLabel* m_lblLangPair; QLabel* m_lblOriginal; QLabel* m_lblTranslated; QLabel* m_lblCopied; From c33fa65bf20be20f4d7f4cb0788453cdbfee9008 Mon Sep 17 00:00:00 2001 From: giangnam0201 Date: Sat, 29 Aug 2026 23:06:34 +0700 Subject: [PATCH 2/4] CI: bundle the MSVC runtime in the portable package windeployqt leaves msvcp140/vcruntime140 out by default, so the zip only ran on machines with the VC++ redistributable already installed. The shipped portable build carries them locally; match that. --- .github/workflows/windows-build.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml index 330d6be..2344ba6 100644 --- a/.github/workflows/windows-build.yml +++ b/.github/workflows/windows-build.yml @@ -63,7 +63,10 @@ jobs: $out = "build\bin\Release" if (-not (Test-Path $out)) { $out = "build\bin" } Get-ChildItem $out - & "$env:QT_ROOT_DIR\bin\windeployqt.exe" --release --no-translations "$out\TorReader.exe" + # --compiler-runtime: the shipped portable build carries msvcp140/vcruntime140 + # locally, so don't assume the VC++ redistributable is installed. + & "$env:QT_ROOT_DIR\bin\windeployqt.exe" --release --no-translations ` + --compiler-runtime "$out\TorReader.exe" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } # Vendored PDFium and the vcpkg runtime DLLs sit outside windeployqt's view. Copy-Item third_party\pdfium\bin\pdfium.dll $out -Force From e33bd4a4e48d2e79b1a91ee92a23a7a97f2f9270 Mon Sep 17 00:00:00 2001 From: giangnam0201 Date: Sun, 30 Aug 2026 01:08:09 +0700 Subject: [PATCH 3/4] CI: copy the MSVC CRT explicitly windeployqt --compiler-runtime silently copied nothing on the runner, so the package still depended on the VC++ redistributable being installed. --- .github/workflows/windows-build.yml | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml index 2344ba6..ffec8a7 100644 --- a/.github/workflows/windows-build.yml +++ b/.github/workflows/windows-build.yml @@ -63,11 +63,18 @@ jobs: $out = "build\bin\Release" if (-not (Test-Path $out)) { $out = "build\bin" } Get-ChildItem $out - # --compiler-runtime: the shipped portable build carries msvcp140/vcruntime140 - # locally, so don't assume the VC++ redistributable is installed. - & "$env:QT_ROOT_DIR\bin\windeployqt.exe" --release --no-translations ` - --compiler-runtime "$out\TorReader.exe" + & "$env:QT_ROOT_DIR\bin\windeployqt.exe" --release --no-translations "$out\TorReader.exe" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + # windeployqt's --compiler-runtime is a silent no-op on the runner, and + # the shipped portable build carries the CRT locally rather than assuming + # the VC++ redistributable is installed. Copy it in by hand. + $crt = Get-ChildItem "C:\Program Files*\Microsoft Visual Studio\2022\*\VC\Redist\MSVC" ` + -Directory -Filter 'Microsoft.VC*.CRT' -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match '\\x64\\' } | + Sort-Object FullName -Descending | Select-Object -First 1 + if (-not $crt) { throw "MSVC redistributable CRT not found on the runner" } + Write-Host "CRT from $($crt.FullName)" + Copy-Item "$($crt.FullName)\*.dll" $out -Force # Vendored PDFium and the vcpkg runtime DLLs sit outside windeployqt's view. Copy-Item third_party\pdfium\bin\pdfium.dll $out -Force $vcbin = "$env:VCPKG_INSTALLATION_ROOT\installed\x64-windows\bin" From c95a6b05a06dd654d1b4d57dce1cc7c919403f10 Mon Sep 17 00:00:00 2001 From: giangnam0201 Date: Sun, 30 Aug 2026 01:09:56 +0700 Subject: [PATCH 4/4] CI: build with OCR enabled and verify the package Adds tesseract to the vcpkg set so FELIXPDF_ENABLE_OCR turns on, fails the configure step if CMake falls back to the OCR stub, and checks the packaged folder for the exe, Qt network/TLS, PDFium, the CRT, the tesseract runtime and the bundled tessdata languages before uploading. --- .github/workflows/windows-build.yml | 31 ++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml index ffec8a7..8bba429 100644 --- a/.github/workflows/windows-build.yml +++ b/.github/workflows/windows-build.yml @@ -39,11 +39,11 @@ jobs: modules: 'qtimageformats' cache: true - - name: Install vcpkg dependencies (OpenSSL, QPDF, ZLIB) + - name: Install vcpkg dependencies (OpenSSL, QPDF, ZLIB, Tesseract) shell: pwsh run: | & "$env:VCPKG_INSTALLATION_ROOT\vcpkg.exe" install ` - openssl:x64-windows qpdf:x64-windows zlib:x64-windows + openssl:x64-windows qpdf:x64-windows zlib:x64-windows tesseract:x64-windows if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Configure @@ -52,7 +52,13 @@ jobs: cmake -B build -S . -G "Visual Studio 17 2022" -A x64 ` -DCMAKE_BUILD_TYPE=Release ` -DCMAKE_TOOLCHAIN_FILE="$env:VCPKG_INSTALLATION_ROOT\scripts\buildsystems\vcpkg.cmake" ` - -DVCPKG_TARGET_TRIPLET=x64-windows + -DVCPKG_TARGET_TRIPLET=x64-windows 2>&1 | Tee-Object -Variable cfg + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + # CMake only warns when Tesseract is missing and builds an OCR stub, which + # is far too quiet a way to ship a package with a headline feature gone. + if ($cfg -match 'Tesseract khong tim thay') { + throw "Tesseract was not found — OCR would be silently disabled in this build" + } - name: Build run: cmake --build build --config Release --parallel @@ -83,6 +89,25 @@ jobs: } Get-ChildItem $out | Select-Object Name, Length + - name: Verify package contents + shell: pwsh + run: | + $out = "build\bin\Release" + if (-not (Test-Path $out)) { $out = "build\bin" } + $missing = @() + foreach ($f in 'TorReader.exe','Qt6Network.dll','pdfium.dll', + 'msvcp140.dll','vcruntime140.dll','tls\qschannelbackend.dll') { + if (-not (Test-Path "$out\$f")) { $missing += $f } + } + if (-not (Get-ChildItem "$out" -Filter 'tesseract*.dll' -ErrorAction SilentlyContinue)) { + $missing += 'tesseract*.dll' + } + $langs = @(Get-ChildItem "$out\tessdata" -Filter *.traineddata -ErrorAction SilentlyContinue) + Write-Host "tessdata language files: $($langs.Count)" + if ($langs.Count -lt 10) { $missing += "tessdata/*.traineddata (found $($langs.Count), expected 10+)" } + if ($missing) { throw "Incomplete package, missing: $($missing -join ', ')" } + Write-Host "Package verified." + - name: Package shell: pwsh run: |