nix-stuff/roles/kde/patches/spectacle/pr462.patch

2844 lines
94 KiB
Diff
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

From 9ab7593321d014ff63ef12590a0c2d0e721a90f1 Mon Sep 17 00:00:00 2001
From: Jhair Paris <dev@jhairparis.com>
Date: Sat, 7 Jun 2025 19:56:28 -0500
Subject: [PATCH 1/3] Add OCR dependencies and build configuration
- Add Tesseract and Leptonica dependencies
- Configure OCR support in CMake build system
---
CMakeLists.txt | 7 +++++++
src/CMakeLists.txt | 5 ++++-
2 files changed, 11 insertions(+), 1 deletion(-)
diff --git a/CMakeLists.txt b/CMakeLists.txt
index f62a38443..3038f472c 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -89,12 +89,19 @@ find_package(PlasmaWaylandProtocols REQUIRED)
find_package(LayerShellQt REQUIRED)
find_package(KPipeWire)
find_package(OpenCV 4.7 REQUIRED core imgproc)
+find_package(PkgConfig REQUIRED)
+pkg_check_modules(TESSERACT REQUIRED tesseract)
set_package_properties(KPipeWire PROPERTIES DESCRIPTION
"Used to record pipewire streams into a file"
TYPE REQUIRED
)
+set_package_properties(TESSERACT PROPERTIES DESCRIPTION
+ "OCR (Optical Character Recognition) engine for text recognition in images"
+ TYPE REQUIRED
+)
+
# optional components
find_package(KF6DocTools ${KF6_MIN_VERSION})
set_package_properties(KF6DocTools PROPERTIES DESCRIPTION
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index d27c2dba4..cb000b35d 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -25,6 +25,7 @@ target_sources(spectacle PRIVATE
CommandLineOptions.cpp
ExportManager.cpp
Geometry.cpp
+ OcrManager.cpp
Gui/Annotations/AnnotationDocument.cpp
Gui/Annotations/AnnotationTool.cpp
Gui/Annotations/AnnotationViewport.cpp
@@ -104,7 +105,7 @@ ki18n_wrap_ui(spectacle
# Needed to compile with OpenCV
target_compile_options (spectacle PRIVATE -fexceptions)
-target_include_directories(spectacle PUBLIC ${OpenCV_INCLUDE_DIRS})
+target_include_directories(spectacle PUBLIC ${OpenCV_INCLUDE_DIRS} ${TESSERACT_INCLUDE_DIRS})
target_link_libraries(spectacle PRIVATE
Qt::Concurrent
@@ -135,6 +136,7 @@ target_link_libraries(spectacle PRIVATE
Wayland::Client
LayerShellQt::Interface
${OpenCV_LIBRARIES}
+ ${TESSERACT_LIBRARIES}
)
# qt_add_qml_module doesn't know how to deal with headers in subdirectories so
@@ -180,6 +182,7 @@ qt_target_qml_sources(spectacle
Gui/InlineMessageList.qml
Gui/Magnifier.qml
Gui/NewScreenshotToolButton.qml
+ Gui/OcrAction.qml
Gui/OptionsMenuButton.qml
Gui/Outline.qml
Gui/QmlUtils.qml
--
GitLab
From ae7a749c89892c8f0d5494c2d7157970578b8b3f Mon Sep 17 00:00:00 2001
From: Jhair Paris <dev@jhairparis.com>
Date: Sat, 7 Jun 2025 19:57:10 -0500
Subject: [PATCH 2/3] Implement OcrManager class for text recognition
- Add OcrManager class with Tesseract integration
- Provide async OCR processing methods
- Handle OCR initialization and cleanup
Add OCR language selection to General Options
- Introduced a new combo box for selecting the OCR language in the settings dialog.
- Implemented methods to populate and refresh the OCR language options based on availability.
Add OCR action and integrate into UI toolbars
- Create OcrAction.qml for text recognition functionality
- Add OCR buttons to CaptureOverlay toolbars
- Add OCR button to ViewerPage main toolbar
Integrate OCR functionality into capture and viewer windows
Add OCR notifications and core integration
- Add OCR success/error notification events to notifyrc
- Integrate OCR manager in SpectacleCore
Remove manual translations
Implement OCR availability checks
Enhance OcrManager to load Tesseract library dynamically and check its availability.
Show info cursor on OCR tooltip icon in settings
Refactor OCR language name handling using QLocale
- Replace hardcoded/translatable language name map with dynamic lookup via QLocale and scriptToString.
Refactor Tesseract initialization to support dynamic language detection
Detect Tesseract and language packs at configure time; link directly to libtesseract
- Add tesseract_test.cpp using TessBaseAPI::GetAvailableLanguagesAsVector()
- CMake: pkg_check_modules(TESSERACT) + try_run() to check usable langpacks
- Define HAVE_TESSERACT_OCR when successful; otherwise warn and disable OCR
- OCR: refactor OcrManager to use tesseract::TessBaseAPI (C++ API)
- Remove QLibrary-based dynamic loading and manual symbol resolution
- Wrap OCR code with #ifdef HAVE_TESSERACT_OCR and provide graceful fallbacks
Refactor OCR text recognition to use ResultIterator for improved accuracy
Refactor the OCR core: centralize extraction in SpectacleCore, remove direct OCR handling from windows.
---
CMakeLists.txt | 48 +-
cmake/tesseract_test.cpp | 40 +
desktop/spectacle.notifyrc | 5 +
src/CMakeLists.txt | 9 +-
src/Config.h.in | 3 +
src/Gui/CaptureOverlay.qml | 9 +
src/Gui/CaptureWindow.cpp | 4 +-
src/Gui/OcrAction.qml | 14 +
src/Gui/SettingsDialog/GeneralOptions.ui | 127 ++++
src/Gui/SettingsDialog/GeneralOptionsPage.cpp | 80 ++
src/Gui/SettingsDialog/GeneralOptionsPage.h | 4 +
src/Gui/SettingsDialog/SettingsDialog.cpp | 5 +
src/Gui/SettingsDialog/spectacle.kcfg | 4 +
src/Gui/ViewerPage.qml | 5 +
src/Gui/ViewerWindow.cpp | 2 +-
src/OcrManager.cpp | 716 ++++++++++++++++++
src/OcrManager.h | 175 +++++
src/SpectacleCore.cpp | 143 ++++
src/SpectacleCore.h | 9 +
19 files changed, 1393 insertions(+), 9 deletions(-)
create mode 100644 cmake/tesseract_test.cpp
create mode 100644 src/Gui/OcrAction.qml
create mode 100644 src/OcrManager.cpp
create mode 100644 src/OcrManager.h
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 3038f472c..9b3c47fbe 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -92,16 +92,54 @@ find_package(OpenCV 4.7 REQUIRED core imgproc)
find_package(PkgConfig REQUIRED)
pkg_check_modules(TESSERACT REQUIRED tesseract)
+# Find Tesseract for OCR functionality
+find_package(PkgConfig QUIET)
+if(PkgConfig_FOUND)
+ pkg_check_modules(TESSERACT tesseract)
+
+ if(TESSERACT_FOUND)
+ # Test if Tesseract has usable language packs
+ try_run(
+ TESSERACT_TEST_RUN_RESULT
+ TESSERACT_TEST_COMPILE_RESULT
+ ${CMAKE_CURRENT_BINARY_DIR}
+ ${CMAKE_CURRENT_SOURCE_DIR}/cmake/tesseract_test.cpp
+ LINK_LIBRARIES ${TESSERACT_LIBRARIES}
+ CMAKE_FLAGS "-DINCLUDE_DIRECTORIES=${TESSERACT_INCLUDE_DIRS}"
+ COMPILE_OUTPUT_VARIABLE TESSERACT_COMPILE_OUTPUT
+ RUN_OUTPUT_VARIABLE TESSERACT_RUN_OUTPUT
+ )
+
+ if(TESSERACT_TEST_COMPILE_RESULT AND TESSERACT_TEST_RUN_RESULT EQUAL 0)
+ message(STATUS "Tesseract OCR support enabled")
+ message(STATUS "${TESSERACT_RUN_OUTPUT}")
+ set(HAVE_TESSERACT_OCR TRUE)
+ else()
+ message(WARNING "Tesseract library found but no usable language packs detected")
+ message(WARNING "${TESSERACT_RUN_OUTPUT}")
+ message(WARNING "OCR functionality will be disabled. Install language data packages (e.g., tesseract-ocr-eng)")
+ set(HAVE_TESSERACT_OCR FALSE)
+ endif()
+ else()
+ message(STATUS "Tesseract not found - OCR functionality disabled")
+ set(HAVE_TESSERACT_OCR FALSE)
+ endif()
+
+ set_package_properties(TESSERACT PROPERTIES
+ DESCRIPTION "OCR engine for text recognition in screenshots"
+ TYPE OPTIONAL
+ PURPOSE "Enables optical character recognition functionality"
+ )
+else()
+ message(STATUS "PkgConfig not found - Tesseract detection disabled")
+ set(HAVE_TESSERACT_OCR FALSE)
+endif()
+
set_package_properties(KPipeWire PROPERTIES DESCRIPTION
"Used to record pipewire streams into a file"
TYPE REQUIRED
)
-set_package_properties(TESSERACT PROPERTIES DESCRIPTION
- "OCR (Optical Character Recognition) engine for text recognition in images"
- TYPE REQUIRED
-)
-
# optional components
find_package(KF6DocTools ${KF6_MIN_VERSION})
set_package_properties(KF6DocTools PROPERTIES DESCRIPTION
diff --git a/cmake/tesseract_test.cpp b/cmake/tesseract_test.cpp
new file mode 100644
index 000000000..4ebae9779
--- /dev/null
+++ b/cmake/tesseract_test.cpp
@@ -0,0 +1,40 @@
+#include <iostream>
+#include <string>
+#include <tesseract/baseapi.h>
+#include <vector>
+
+int main()
+{
+ tesseract::TessBaseAPI api;
+
+ if (api.Init(nullptr, nullptr) != 0) {
+ std::cerr << "Failed to initialize Tesseract" << std::endl;
+ return 1;
+ }
+
+ std::vector<std::string> languages;
+ api.GetAvailableLanguagesAsVector(&languages);
+
+ // Filter out 'osd' as it's not a usable language for OCR
+ std::vector<std::string> usableLanguages;
+ for (const auto &lang : languages) {
+ if (lang != "osd") {
+ usableLanguages.push_back(lang);
+ }
+ }
+
+ if (usableLanguages.empty()) {
+ std::cerr << "No usable Tesseract language packs found. Install language data files (e.g., tesseract-ocr-eng)" << std::endl;
+ return 1;
+ }
+
+ std::cout << "Found " << usableLanguages.size() << " Tesseract language pack(s): ";
+ for (size_t i = 0; i < usableLanguages.size(); ++i) {
+ std::cout << usableLanguages[i];
+ if (i < usableLanguages.size() - 1)
+ std::cout << ", ";
+ }
+ std::cout << std::endl;
+
+ return 0;
+}
diff --git a/desktop/spectacle.notifyrc b/desktop/spectacle.notifyrc
index 5c4166f0b..f3f65f679 100644
--- a/desktop/spectacle.notifyrc
+++ b/desktop/spectacle.notifyrc
@@ -306,3 +306,8 @@ Comment[uk]=Було створено і збережено новий запи
Comment[zh_CN]=已录制并保存新的屏幕录像
Comment[zh_TW]=新的螢幕錄製已擷取並儲存
Action=Popup
+
+[Event/ocrTextExtracted]
+Name=Text Extracted
+Comment=Text has been extracted from image using OCR
+Action=Popup
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index cb000b35d..c57535e34 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -105,7 +105,7 @@ ki18n_wrap_ui(spectacle
# Needed to compile with OpenCV
target_compile_options (spectacle PRIVATE -fexceptions)
-target_include_directories(spectacle PUBLIC ${OpenCV_INCLUDE_DIRS} ${TESSERACT_INCLUDE_DIRS})
+target_include_directories(spectacle PUBLIC ${OpenCV_INCLUDE_DIRS})
target_link_libraries(spectacle PRIVATE
Qt::Concurrent
@@ -136,9 +136,14 @@ target_link_libraries(spectacle PRIVATE
Wayland::Client
LayerShellQt::Interface
${OpenCV_LIBRARIES}
- ${TESSERACT_LIBRARIES}
)
+# Link against Tesseract when OCR support is enabled
+if(HAVE_TESSERACT_OCR)
+ target_include_directories(spectacle PRIVATE ${TESSERACT_INCLUDE_DIRS})
+ target_link_libraries(spectacle PRIVATE ${TESSERACT_LIBRARIES})
+endif()
+
# qt_add_qml_module doesn't know how to deal with headers in subdirectories so
# make sure to add those so the headers can be found.
target_include_directories(spectacle PRIVATE
diff --git a/src/Config.h.in b/src/Config.h.in
index 15313542a..aadb22252 100644
--- a/src/Config.h.in
+++ b/src/Config.h.in
@@ -7,6 +7,9 @@
/* Define to 1 if we have Purpose */
#cmakedefine PURPOSE_FOUND 1
+/* Define to 1 if we have Tesseract OCR */
+#cmakedefine HAVE_TESSERACT_OCR 1
+
/* Set the Spectacle version from CMake */
#cmakedefine SPECTACLE_VERSION "@SPECTACLE_VERSION@"
diff --git a/src/Gui/CaptureOverlay.qml b/src/Gui/CaptureOverlay.qml
index d9ca9a11c..37f3dcf85 100644
--- a/src/Gui/CaptureOverlay.qml
+++ b/src/Gui/CaptureOverlay.qml
@@ -506,6 +506,11 @@ MouseArea {
visible: action.enabled
action: CopyImageAction {}
}
+ ToolButton {
+ display: TtToolButton.IconOnly
+ visible: action.enabled && !SpectacleCore.videoMode && SpectacleCore.ocrAvailable
+ action: OcrAction {}
+ }
ExportMenuButton {
focusPolicy: Qt.NoFocus
}
@@ -532,6 +537,10 @@ MouseArea {
visible: action.enabled
action: CopyImageAction {}
}
+ ToolButton {
+ visible: action.enabled && !SpectacleCore.videoMode && SpectacleCore.ocrAvailable
+ action: OcrAction {}
+ }
ExportMenuButton {
focusPolicy: Qt.NoFocus
}
diff --git a/src/Gui/CaptureWindow.cpp b/src/Gui/CaptureWindow.cpp
index fc4509cf3..cb8ce97ab 100644
--- a/src/Gui/CaptureWindow.cpp
+++ b/src/Gui/CaptureWindow.cpp
@@ -8,11 +8,13 @@
#include "CaptureWindow.h"
#include "Config.h"
-#include "SpectacleCore.h"
#include "Gui/SelectionEditor.h"
+#include "SpectacleCore.h"
#include <QScreen>
#include <QShortcut>
+#include <QApplication>
+#include <QClipboard>
using namespace Qt::StringLiterals;
diff --git a/src/Gui/OcrAction.qml b/src/Gui/OcrAction.qml
new file mode 100644
index 000000000..f887ec0ee
--- /dev/null
+++ b/src/Gui/OcrAction.qml
@@ -0,0 +1,14 @@
+/* SPDX-FileCopyrightText: 2025 Jhair Paris <dev@jhairparis.com>
+ * SPDX-License-Identifier: LGPL-2.0-or-later
+ */
+
+import QtQuick.Templates as T
+import org.kde.spectacle.private
+
+T.Action {
+ // OCR is only available for screenshots, not videos, and only when OCR is properly available
+ enabled: !SpectacleCore.videoMode && SpectacleCore.ocrAvailable
+ icon.name: "document-scan"
+ text: i18nc("@action", "Extract Text")
+ onTriggered: contextWindow.extractText()
+}
diff --git a/src/Gui/SettingsDialog/GeneralOptions.ui b/src/Gui/SettingsDialog/GeneralOptions.ui
index 1d99e9a33..ddbbf3e5a 100644
--- a/src/Gui/SettingsDialog/GeneralOptions.ui
+++ b/src/Gui/SettingsDialog/GeneralOptions.ui
@@ -239,6 +239,132 @@
</item>
</widget>
</item>
+ <item row="14" column="0" colspan="2">
+ <spacer name="verticalSpacer_3">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ <property name="sizeType">
+ <enum>QSizePolicy::Fixed</enum>
+ </property>
+ <property name="sizeHint">
+ <size>
+ <width>10</width>
+ <height>10</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ <item row="15" column="0" colspan="2">
+ <widget class="KTitleWidget" name="ocrTitle">
+ <property name="text">
+ <string>Text Recognition (OCR)</string>
+ </property>
+ </widget>
+ </item>
+ <item row="16" column="0">
+ <widget class="QLabel" name="ocrLanguageLabel">
+ <property name="text">
+ <string>Language:</string>
+ </property>
+ </widget>
+ </item>
+ <item row="16" column="1">
+ <widget class="QComboBox" name="kcfg_ocrLanguage">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="kcfg_property" stdset="0">
+ <string notr="true">currentData</string>
+ </property>
+ </widget>
+ </item>
+ <item row="17" column="0" colspan="2">
+ <widget class="QWidget" name="ocrUnavailableWidget">
+ <property name="visible">
+ <bool>false</bool>
+ </property>
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Preferred" vsizetype="Fixed">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <layout class="QHBoxLayout" name="ocrUnavailableLayout">
+ <property name="margin">
+ <number>0</number>
+ </property>
+ <property name="spacing">
+ <number>6</number>
+ </property>
+ <item>
+ <widget class="QLabel" name="ocrUnavailableLabel">
+ <property name="text">
+ <string>OCR functionality is not available</string>
+ </property>
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Preferred" vsizetype="Preferred">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="wordWrap">
+ <bool>false</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QLabel" name="ocrInfoIcon">
+ <property name="toolTip">
+ <string>Please install the required packages:
+• tesseract
+• tesseract language data (e.g., tesseract-ocr-eng for English)</string>
+ </property>
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Fixed" vsizetype="Fixed">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="alignment">
+ <set>Qt::AlignCenter</set>
+ </property>
+ <property name="minimumSize">
+ <size>
+ <width>16</width>
+ <height>16</height>
+ </size>
+ </property>
+ <property name="maximumSize">
+ <size>
+ <width>16</width>
+ <height>16</height>
+ </size>
+ </property>
+ <property name="scaledContents">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <spacer name="horizontalSpacer">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>40</width>
+ <height>20</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ </layout>
+ </widget>
+ </item>
</layout>
</widget>
<customwidgets>
@@ -257,6 +383,7 @@
<tabstop>kcfg_useReleaseToCapture</tabstop>
<tabstop>kcfg_showCaptureInstructions</tabstop>
<tabstop>kcfg_rememberSelectionRect</tabstop>
+ <tabstop>kcfg_ocrLanguage</tabstop>
</tabstops>
<resources/>
<connections/>
diff --git a/src/Gui/SettingsDialog/GeneralOptionsPage.cpp b/src/Gui/SettingsDialog/GeneralOptionsPage.cpp
index fcea6f671..5b8a5d9fc 100644
--- a/src/Gui/SettingsDialog/GeneralOptionsPage.cpp
+++ b/src/Gui/SettingsDialog/GeneralOptionsPage.cpp
@@ -1,4 +1,5 @@
/*
+ * SPDX-FileCopyrightText: 2025 Jhair Paris <dev@jhairparis.com>
* SPDX-FileCopyrightText: 2019 David Redondo <kde@david-redondo.de>
* SPDX-FileCopyrightText: 2015 Boudhayan Gupta <bgupta@kde.org>
*
@@ -9,10 +10,13 @@
#include "settings.h"
#include "ui_GeneralOptions.h"
+#include "OcrManager.h"
#include <KWindowSystem>
+#include <KLocalizedString>
#include <QCheckBox>
+#include <QIcon>
GeneralOptionsPage::GeneralOptionsPage(QWidget *parent)
: QWidget(parent)
@@ -20,8 +24,16 @@ GeneralOptionsPage::GeneralOptionsPage(QWidget *parent)
{
m_ui->setupUi(this);
+ m_ui->ocrInfoIcon->setPixmap(QIcon::fromTheme(QStringLiteral("help-hint")).pixmap(16, 16));
+ m_ui->ocrInfoIcon->setCursor(Qt::WhatsThisCursor);
+
m_ui->runningTitle->setLevel(2);
m_ui->regionTitle->setLevel(2);
+ m_ui->ocrTitle->setLevel(2);
+
+ setupOcrLanguageComboBox();
+
+ connect(OcrManager::instance(), &OcrManager::statusChanged, this, &GeneralOptionsPage::refreshOcrLanguageSettings);
//On Wayland we can't programmatically raise and focus the window so we have to hide the option
if (KWindowSystem::isPlatformWayland() || qstrcmp(qgetenv("XDG_SESSION_TYPE").constData(), "wayland") == 0) {
@@ -31,4 +43,72 @@ GeneralOptionsPage::GeneralOptionsPage(QWidget *parent)
GeneralOptionsPage::~GeneralOptionsPage() = default;
+void GeneralOptionsPage::setupOcrLanguageComboBox()
+{
+ OcrManager *ocrManager = OcrManager::instance();
+
+ if (!ocrManager->isAvailable()) {
+ m_ui->kcfg_ocrLanguage->setEnabled(false);
+ m_ui->kcfg_ocrLanguage->addItem(i18n("OCR not available"));
+ m_ui->ocrLanguageLabel->setVisible(false);
+ m_ui->kcfg_ocrLanguage->setVisible(false);
+ m_ui->ocrUnavailableWidget->setVisible(true);
+ return;
+ }
+
+ const auto availableLanguages = ocrManager->availableLanguagesWithNames();
+
+ if (availableLanguages.isEmpty()) {
+ m_ui->kcfg_ocrLanguage->addItem(i18n("No languages found"));
+ m_ui->kcfg_ocrLanguage->setEnabled(false);
+ return;
+ }
+
+ m_ui->kcfg_ocrLanguage->clear();
+ m_ui->ocrLanguageLabel->setVisible(true);
+ m_ui->kcfg_ocrLanguage->setVisible(true);
+ m_ui->ocrUnavailableWidget->setVisible(false);
+
+ for (auto it = availableLanguages.constBegin(); it != availableLanguages.constEnd(); ++it) {
+ m_ui->kcfg_ocrLanguage->addItem(it.value(), it.key());
+ }
+}
+
+void GeneralOptionsPage::refreshOcrLanguageSettings()
+{
+ OcrManager *ocrManager = OcrManager::instance();
+
+ if (!ocrManager->isAvailable()) {
+ m_ui->ocrLanguageLabel->setVisible(false);
+ m_ui->kcfg_ocrLanguage->setVisible(false);
+ m_ui->ocrUnavailableWidget->setVisible(true);
+ return;
+ }
+
+ const auto availableLanguages = ocrManager->availableLanguagesWithNames();
+
+ if (availableLanguages.isEmpty()) {
+ return;
+ }
+
+ m_ui->kcfg_ocrLanguage->clear();
+ m_ui->kcfg_ocrLanguage->setEnabled(true);
+ m_ui->ocrLanguageLabel->setVisible(true);
+ m_ui->kcfg_ocrLanguage->setVisible(true);
+ m_ui->ocrUnavailableWidget->setVisible(false);
+
+ for (auto it = availableLanguages.constBegin(); it != availableLanguages.constEnd(); ++it) {
+ m_ui->kcfg_ocrLanguage->addItem(it.value(), it.key());
+ }
+
+ const QString currentLanguage = Settings::ocrLanguage();
+
+ for (int i = 0; i < m_ui->kcfg_ocrLanguage->count(); ++i) {
+ if (m_ui->kcfg_ocrLanguage->itemData(i).toString() == currentLanguage) {
+ m_ui->kcfg_ocrLanguage->setCurrentIndex(i);
+ break;
+ }
+ }
+}
+
#include "moc_GeneralOptionsPage.cpp"
diff --git a/src/Gui/SettingsDialog/GeneralOptionsPage.h b/src/Gui/SettingsDialog/GeneralOptionsPage.h
index d8e7c5003..c184d6ba8 100644
--- a/src/Gui/SettingsDialog/GeneralOptionsPage.h
+++ b/src/Gui/SettingsDialog/GeneralOptionsPage.h
@@ -19,8 +19,12 @@ class GeneralOptionsPage : public QWidget
public:
explicit GeneralOptionsPage(QWidget *parent = nullptr);
~GeneralOptionsPage() override;
+
+ void refreshOcrLanguageSettings();
private:
+ void setupOcrLanguageComboBox();
+
QScopedPointer<Ui_GeneralOptions> m_ui;
};
diff --git a/src/Gui/SettingsDialog/SettingsDialog.cpp b/src/Gui/SettingsDialog/SettingsDialog.cpp
index a37d8344c..a19a47627 100644
--- a/src/Gui/SettingsDialog/SettingsDialog.cpp
+++ b/src/Gui/SettingsDialog/SettingsDialog.cpp
@@ -64,6 +64,9 @@ void SettingsDialog::showEvent(QShowEvent *event)
auto parent = parentWidget();
bool onTop = parent && parent->windowHandle()->flags().testFlag(Qt::WindowStaysOnTopHint);
windowHandle()->setFlag(Qt::WindowStaysOnTopHint, onTop);
+
+ m_generalPage->refreshOcrLanguageSettings();
+
KConfigDialog::showEvent(event);
}
@@ -87,6 +90,8 @@ void SettingsDialog::updateWidgets()
{
KConfigDialog::updateWidgets();
m_shortcutsPage->resetChanges();
+
+ m_generalPage->refreshOcrLanguageSettings();
}
void SettingsDialog::updateWidgetsDefault()
diff --git a/src/Gui/SettingsDialog/spectacle.kcfg b/src/Gui/SettingsDialog/spectacle.kcfg
index e37b9e5b4..4517e2344 100644
--- a/src/Gui/SettingsDialog/spectacle.kcfg
+++ b/src/Gui/SettingsDialog/spectacle.kcfg
@@ -70,6 +70,10 @@
</choices>
<default>UntilClosed</default>
</entry>
+ <entry name="ocrLanguage" type="String">
+ <label>Default language for OCR text recognition</label>
+ <default>eng</default>
+ </entry>
</group>
<group name="GuiConfig">
<entry name="selectionRect" type="RectF">
diff --git a/src/Gui/ViewerPage.qml b/src/Gui/ViewerPage.qml
index 6e77887a8..602e4431b 100644
--- a/src/Gui/ViewerPage.qml
+++ b/src/Gui/ViewerPage.qml
@@ -61,6 +61,11 @@ EmptyPage {
visible: action.enabled
action: CopyImageAction {}
}
+ TtToolButton {
+ display: TtToolButton.IconOnly
+ visible: action.enabled && SpectacleCore.ocrAvailable
+ action: OcrAction {}
+ }
// We only show this in video mode to save space in screenshot mode
TtToolButton {
visible: SpectacleCore.videoMode
diff --git a/src/Gui/ViewerWindow.cpp b/src/Gui/ViewerWindow.cpp
index 68812495d..8c0d9941f 100644
--- a/src/Gui/ViewerWindow.cpp
+++ b/src/Gui/ViewerWindow.cpp
@@ -8,9 +8,9 @@
#include "ViewerWindow.h"
#include "Config.h"
-#include "SpectacleCore.h"
#include "Gui/ExportMenu.h"
#include "InlineMessageModel.h"
+#include "SpectacleCore.h"
#include <KUrlMimeData>
#include <Kirigami/Platform/Units>
diff --git a/src/OcrManager.cpp b/src/OcrManager.cpp
new file mode 100644
index 000000000..1d09db8ef
--- /dev/null
+++ b/src/OcrManager.cpp
@@ -0,0 +1,716 @@
+/* This file is part of Spectacle, the KDE screenshot utility
+ * SPDX-FileCopyrightText: 2025 Jhair Paris <dev@jhairparis.com>
+ * SPDX-License-Identifier: LGPL-2.0-or-later
+ */
+
+#include "OcrManager.h"
+#include "settings.h"
+#include "spectacle_debug.h"
+
+#include <QApplication>
+#include <QBuffer>
+#include <QClipboard>
+#include <QDir>
+#include <QFile>
+#include <QLocale>
+#include <QMutexLocker>
+#include <QStandardPaths>
+#include <QStringList>
+#include <QThread>
+
+#include <KLocalizedString>
+
+#include <memory>
+
+using namespace Qt::StringLiterals;
+
+OcrManager *OcrManager::s_instance = nullptr;
+
+OcrManager::OcrManager(QObject *parent)
+ : QObject(parent)
+#ifdef HAVE_TESSERACT_OCR
+ , m_tesseract(nullptr)
+ , m_worker(nullptr)
+#endif
+ , m_workerThread(std::make_unique<QThread>())
+ , m_timeoutTimer(new QTimer(this))
+ , m_status(OcrStatus::Ready)
+ , m_currentLanguageCode() // Current language code ("eng+spa")
+ , m_configuredLanguages() // Languages from Settings (persistent)
+ , m_activeLanguages()
+ , m_shouldRestoreToConfigured(false) // Flag to restore after temp language use
+ , m_initialized(false)
+{
+#ifdef HAVE_TESSERACT_OCR
+ m_timeoutTimer->setSingleShot(true);
+ m_timeoutTimer->setInterval(30000);
+
+ connect(m_timeoutTimer, &QTimer::timeout, this, [this]() {
+ qCWarning(SPECTACLE_LOG) << "OCR recognition timed out";
+ setStatus(OcrStatus::Error);
+ });
+
+ m_worker = new OcrWorker();
+ m_worker->moveToThread(m_workerThread.get());
+ connect(m_worker, &OcrWorker::imageProcessed, this, &OcrManager::handleRecognitionComplete);
+ m_workerThread->start();
+
+ connect(Settings::self(), &Settings::ocrLanguagesChanged, this, [this]() {
+ const QStringList newLanguages = Settings::ocrLanguages();
+ const QString combinedLanguages = newLanguages.join(u"+"_s);
+ if (combinedLanguages != m_currentLanguageCode) {
+ setLanguagesByCode(newLanguages);
+ }
+ });
+
+ QTimer::singleShot(0, this, &OcrManager::initializeTesseract);
+#endif
+}
+
+OcrManager::~OcrManager()
+{
+#ifdef HAVE_TESSERACT_OCR
+ if (m_worker) {
+ if (m_workerThread && m_workerThread->isRunning()) {
+ QMetaObject::invokeMethod(m_worker, &QObject::deleteLater, Qt::QueuedConnection);
+ } else {
+ delete m_worker;
+ }
+ m_worker = nullptr;
+ }
+#endif
+ if (m_workerThread && m_workerThread->isRunning()) {
+ m_workerThread->quit();
+ m_workerThread->wait(3000);
+ }
+#ifdef HAVE_TESSERACT_OCR
+ if (m_tesseract) {
+ m_tesseract->End();
+ delete m_tesseract;
+ m_tesseract = nullptr;
+ }
+#endif
+}
+
+OcrManager *OcrManager::instance()
+{
+ if (!s_instance) {
+ s_instance = new OcrManager(qApp);
+ }
+ return s_instance;
+}
+
+bool OcrManager::isAvailable() const
+{
+#ifdef HAVE_TESSERACT_OCR
+ return m_initialized && m_tesseract != nullptr;
+#else
+ return false;
+#endif
+}
+
+OcrManager::OcrStatus OcrManager::status() const
+{
+ return m_status;
+}
+
+QMap<QString, QString> OcrManager::availableLanguagesWithNames() const
+{
+ QMap<QString, QString> result;
+ for (const QString &langCode : m_availableLanguages) {
+ result[langCode] = m_languageNames.value(langCode, langCode);
+ }
+ return result;
+}
+
+void OcrManager::setLanguagesByCode(const QStringList &languageCodes)
+{
+#ifdef HAVE_TESSERACT_OCR
+ if (languageCodes.isEmpty()) {
+ qCWarning(SPECTACLE_LOG) << "No OCR languages specified";
+ return;
+ }
+
+ if (validateAndApplyLanguages(languageCodes)) {
+ m_configuredLanguages = m_activeLanguages;
+ Settings::setOcrLanguages(m_activeLanguages);
+ Settings::self()->save();
+ qCDebug(SPECTACLE_LOG) << "OCR languages successfully changed to:" << m_currentLanguageCode;
+ } else {
+ qCWarning(SPECTACLE_LOG) << "Failed to set OCR languages";
+ }
+#else
+ Q_UNUSED(languageCodes);
+ qCWarning(SPECTACLE_LOG) << "OCR not available - Tesseract not compiled in";
+#endif
+}
+
+QString OcrManager::currentLanguageCode() const
+{
+ return m_currentLanguageCode;
+}
+
+void OcrManager::recognizeText(const QImage &image)
+{
+#ifdef HAVE_TESSERACT_OCR
+ if (!isAvailable()) {
+ qCWarning(SPECTACLE_LOG) << "Cannot start OCR: engine is not available";
+ Q_EMIT textRecognized(QString(), false);
+ return;
+ }
+
+ if (m_status == OcrStatus::Processing) {
+ qCWarning(SPECTACLE_LOG) << "Cannot start OCR: text extraction already running";
+ Q_EMIT textRecognized(QString(), false);
+ return;
+ }
+
+ if (image.isNull() || image.size().isEmpty()) {
+ qCWarning(SPECTACLE_LOG) << "Cannot start OCR: invalid image provided";
+ Q_EMIT textRecognized(QString(), false);
+ return;
+ }
+
+ // Ensure configured languages are active
+ if (m_configuredLanguages.isEmpty() || m_activeLanguages != m_configuredLanguages) {
+ if (!validateAndApplyLanguages(m_configuredLanguages)) {
+ qCWarning(SPECTACLE_LOG) << "Cannot start OCR: failed to activate configured languages";
+ Q_EMIT textRecognized(QString(), false);
+ return;
+ }
+ }
+
+ beginRecognition(image);
+#else
+ Q_UNUSED(image);
+ qCWarning(SPECTACLE_LOG) << "Cannot start OCR: Spectacle built without Tesseract support";
+ Q_EMIT textRecognized(QString(), false);
+#endif
+}
+
+void OcrManager::recognizeTextWithLanguage(const QImage &image, const QString &languageCode)
+{
+#ifdef HAVE_TESSERACT_OCR
+ if (languageCode.isEmpty()) {
+ recognizeText(image);
+ return;
+ }
+
+ if (!isAvailable()) {
+ qCWarning(SPECTACLE_LOG) << "Cannot start OCR with language" << languageCode << ": engine is not available";
+ Q_EMIT textRecognized(QString(), false);
+ return;
+ }
+
+ if (m_status == OcrStatus::Processing) {
+ qCWarning(SPECTACLE_LOG) << "Cannot start OCR with language" << languageCode << ": text extraction already running";
+ Q_EMIT textRecognized(QString(), false);
+ return;
+ }
+
+ if (image.isNull() || image.size().isEmpty()) {
+ qCWarning(SPECTACLE_LOG) << "Cannot start OCR with language" << languageCode << ": invalid image provided";
+ Q_EMIT textRecognized(QString(), false);
+ return;
+ }
+
+ const QStringList tempLanguages{languageCode};
+ if (!validateAndApplyLanguages(tempLanguages)) {
+ qCWarning(SPECTACLE_LOG) << "Cannot start OCR with language" << languageCode << ": failed to activate language";
+ Q_EMIT textRecognized(QString(), false);
+ return;
+ }
+
+ // Store that we need to restore after recognition
+ m_shouldRestoreToConfigured = (m_activeLanguages != m_configuredLanguages);
+
+ beginRecognition(image);
+#else
+ Q_UNUSED(image);
+ Q_UNUSED(languageCode);
+ qCWarning(SPECTACLE_LOG) << "Cannot start OCR: Spectacle built without Tesseract support";
+ Q_EMIT textRecognized(QString(), false);
+#endif
+}
+
+void OcrManager::handleRecognitionComplete(const QString &text, bool success)
+{
+ m_timeoutTimer->stop();
+
+ if (success) {
+ setStatus(OcrStatus::Ready);
+
+ if (!text.isEmpty()) {
+ QApplication::clipboard()->setText(text);
+ }
+
+ Q_EMIT textRecognized(text, true);
+ qCDebug(SPECTACLE_LOG) << "OCR recognition completed successfully";
+ } else {
+ setStatus(OcrStatus::Error);
+ Q_EMIT textRecognized(QString(), false);
+ qCWarning(SPECTACLE_LOG) << "OCR recognition failed";
+ }
+
+ // Restore configured languages if we used temporary ones
+ if (m_shouldRestoreToConfigured && !m_configuredLanguages.isEmpty()) {
+ validateAndApplyLanguages(m_configuredLanguages);
+ m_shouldRestoreToConfigured = false;
+ }
+}
+
+bool OcrManager::validateAndApplyLanguages(const QStringList &languageCodes)
+{
+#ifdef HAVE_TESSERACT_OCR
+ if (languageCodes.isEmpty()) {
+ qCWarning(SPECTACLE_LOG) << "No OCR languages provided";
+ return false;
+ }
+
+ QStringList validLanguages;
+ for (const QString &lang : languageCodes) {
+ if (lang == u"osd"_s) {
+ qCDebug(SPECTACLE_LOG) << "Skipping 'osd' language";
+ continue;
+ }
+
+ if (!isLanguageAvailable(lang)) {
+ qCWarning(SPECTACLE_LOG) << "OCR language not available:" << lang;
+ continue;
+ }
+
+ if (!validLanguages.contains(lang)) {
+ validLanguages.append(lang);
+ }
+ }
+
+ if (validLanguages.isEmpty()) {
+ qCWarning(SPECTACLE_LOG) << "No valid OCR languages after filtering";
+ return false;
+ }
+
+ if (validLanguages.size() > MAX_OCR_LANGUAGES) {
+ validLanguages = validLanguages.mid(0, MAX_OCR_LANGUAGES);
+ qCInfo(SPECTACLE_LOG) << "Limited to" << MAX_OCR_LANGUAGES << "languages:" << validLanguages;
+ }
+
+ const QString combinedLanguages = validLanguages.join(u"+"_s);
+
+ if (m_currentLanguageCode == combinedLanguages && !m_activeLanguages.isEmpty()) {
+ qCDebug(SPECTACLE_LOG) << "Languages already active, no change needed";
+ return true;
+ }
+
+ if (!setupTesseractLanguages(validLanguages)) {
+ qCWarning(SPECTACLE_LOG) << "Failed to apply OCR languages:" << combinedLanguages;
+ return false;
+ }
+
+ m_activeLanguages = validLanguages;
+ m_currentLanguageCode = combinedLanguages;
+
+ qCDebug(SPECTACLE_LOG) << "OCR languages applied:" << combinedLanguages;
+ return true;
+#else
+ Q_UNUSED(languageCodes);
+ return false;
+#endif
+}
+
+void OcrManager::beginRecognition(const QImage &image)
+{
+#ifdef HAVE_TESSERACT_OCR
+ setStatus(OcrStatus::Processing);
+ m_timeoutTimer->start();
+
+ QMetaObject::invokeMethod(
+ m_worker,
+ [worker = m_worker, image, tesseract = m_tesseract]() {
+ worker->processImage(image, tesseract);
+ },
+ Qt::QueuedConnection);
+#else
+ Q_UNUSED(image);
+#endif
+}
+
+void OcrManager::initializeTesseract()
+{
+#ifdef HAVE_TESSERACT_OCR
+ try {
+ m_tesseract = new tesseract::TessBaseAPI();
+
+ if (m_tesseract->Init(nullptr, nullptr) != 0) {
+ qCWarning(SPECTACLE_LOG) << "Failed to initialize Tesseract OCR engine with auto-detection";
+ setStatus(OcrStatus::Error);
+ delete m_tesseract;
+ m_tesseract = nullptr;
+ return;
+ }
+
+ const char *datapath = m_tesseract->GetDatapath();
+ QString tessdataPath = datapath ? QString::fromUtf8(datapath) : QString();
+ qCDebug(SPECTACLE_LOG) << "Using tessdata path: " << tessdataPath;
+
+ setupAvailableLanguages(tessdataPath);
+
+ if (m_availableLanguages.isEmpty()) {
+ qCWarning(SPECTACLE_LOG) << "No language data files found in tessdata directory";
+ setStatus(OcrStatus::Error);
+ delete m_tesseract;
+ m_tesseract = nullptr;
+ return;
+ }
+
+ m_tesseract->End();
+
+ QStringList configLanguages = Settings::ocrLanguages();
+ QStringList initLanguages;
+
+ // Use configured languages if valid, otherwise fallback to first available
+ for (const QString &lang : configLanguages) {
+ if (!lang.isEmpty() && m_availableLanguages.contains(lang) && lang != u"osd"_s) {
+ initLanguages.append(lang);
+ }
+ }
+
+ if (initLanguages.isEmpty()) {
+ auto it = std::find_if(m_availableLanguages.begin(), m_availableLanguages.end(), [](const QString &lang) {
+ return lang != u"osd"_s;
+ });
+
+ if (it != m_availableLanguages.end()) {
+ initLanguages.append(*it);
+ } else {
+ qCCritical(SPECTACLE_LOG) << "No fallback language available (only osd present)";
+ setStatus(OcrStatus::Error);
+ delete m_tesseract;
+ m_tesseract = nullptr;
+ return;
+ }
+ }
+
+ const QString combinedInitLanguages = initLanguages.join(u"+"_s);
+ qCDebug(SPECTACLE_LOG) << "Initializing Tesseract with languages:" << combinedInitLanguages;
+
+ if (m_tesseract->Init(nullptr, combinedInitLanguages.toUtf8().constData()) != 0) {
+ qCWarning(SPECTACLE_LOG) << "Failed to initialize Tesseract with languages:" << combinedInitLanguages;
+ setStatus(OcrStatus::Error);
+ delete m_tesseract;
+ m_tesseract = nullptr;
+ return;
+ }
+
+ m_currentLanguageCode = combinedInitLanguages;
+ m_tesseract->SetPageSegMode(tesseract::PSM_AUTO);
+
+ m_initialized = true;
+ setStatus(OcrStatus::Ready);
+ qCDebug(SPECTACLE_LOG) << "Tesseract OCR engine initialized successfully with languages:" << combinedInitLanguages;
+
+ loadSavedLanguageSetting();
+ } catch (const std::exception &e) {
+ qCWarning(SPECTACLE_LOG) << "Exception during Tesseract initialization:" << e.what();
+ setStatus(OcrStatus::Error);
+ if (m_tesseract) {
+ delete m_tesseract;
+ m_tesseract = nullptr;
+ }
+ }
+#else
+ qCDebug(SPECTACLE_LOG) << "Tesseract OCR not available - compiled out";
+ setStatus(OcrStatus::Error);
+#endif
+}
+
+void OcrManager::loadSavedLanguageSetting()
+{
+ if (!isAvailable()) {
+ qCDebug(SPECTACLE_LOG) << "OCR not available, skipping language loading";
+ return;
+ }
+
+ QStringList savedLanguages = Settings::ocrLanguages();
+ qCDebug(SPECTACLE_LOG) << "Loaded OCR languages setting from config:" << savedLanguages;
+ qCDebug(SPECTACLE_LOG) << "Current OCR language code:" << m_currentLanguageCode;
+ qCDebug(SPECTACLE_LOG) << "Available languages:" << m_availableLanguages;
+
+ QStringList validLanguages;
+ for (const QString &lang : savedLanguages) {
+ if (lang != u"osd"_s && isLanguageAvailable(lang)) {
+ validLanguages.append(lang);
+ }
+ }
+
+ if (validLanguages.isEmpty()) {
+ // Find first valid language as fallback
+ auto it = std::find_if(m_availableLanguages.begin(), m_availableLanguages.end(), [](const QString &lang) {
+ return lang != u"osd"_s;
+ });
+ if (it != m_availableLanguages.end()) {
+ validLanguages.append(*it);
+ } else {
+ qCWarning(SPECTACLE_LOG) << "No usable languages available (only osd present), cannot set default";
+ return;
+ }
+ qCDebug(SPECTACLE_LOG) << "No valid saved languages, using default:" << validLanguages;
+ Settings::setOcrLanguages(validLanguages);
+ Settings::self()->save();
+ }
+
+ m_configuredLanguages = validLanguages;
+
+ const QString combinedLanguages = validLanguages.join(u"+"_s);
+ if (combinedLanguages != m_currentLanguageCode) {
+ qCDebug(SPECTACLE_LOG) << "Loading OCR languages setting:" << validLanguages;
+ validateAndApplyLanguages(validLanguages);
+ } else {
+ qCDebug(SPECTACLE_LOG) << "OCR languages already set to:" << combinedLanguages;
+ m_activeLanguages = validLanguages;
+ }
+}
+
+void OcrManager::setStatus(OcrStatus status)
+{
+ if (m_status == status) {
+ return;
+ }
+
+ m_status = status;
+ Q_EMIT statusChanged(status);
+}
+
+bool OcrManager::isLanguageAvailable(const QString &languageCode) const
+{
+ return m_availableLanguages.contains(languageCode);
+}
+
+bool OcrManager::setupTesseractLanguages(const QStringList &langCodes)
+{
+#ifdef HAVE_TESSERACT_OCR
+ if (!m_tesseract || langCodes.isEmpty()) {
+ return false;
+ }
+
+ const char *datapath = m_tesseract->GetDatapath();
+ QString tessdataPath = datapath ? QString::fromUtf8(datapath) : QString();
+
+ if (tessdataPath.isEmpty()) {
+ qCWarning(SPECTACLE_LOG) << "Tessdata path not found";
+ return false;
+ }
+
+ for (const QString &langCode : langCodes) {
+ const QString langFile = QDir(tessdataPath).filePath(langCode + u".traineddata"_s);
+ if (!QFile::exists(langFile)) {
+ qCWarning(SPECTACLE_LOG) << "Language file not found:" << langFile;
+ return false;
+ }
+ }
+
+ try {
+ m_tesseract->End();
+
+ const QString combinedLangs = langCodes.join(u"+"_s);
+
+ if (m_tesseract->Init(nullptr, combinedLangs.toUtf8().constData()) != 0) {
+ qCWarning(SPECTACLE_LOG) << "Failed to initialize Tesseract with languages:" << combinedLangs;
+
+ // Fallback to first available language
+ QString fallbackLang;
+ if (!m_availableLanguages.isEmpty()) {
+ auto it = std::find_if(m_availableLanguages.begin(), m_availableLanguages.end(), [](const QString &lang) {
+ return lang != u"osd"_s;
+ });
+ if (it != m_availableLanguages.end()) {
+ fallbackLang = *it;
+ }
+ }
+
+ if (!fallbackLang.isEmpty() && m_tesseract->Init(nullptr, fallbackLang.toUtf8().constData()) != 0) {
+ qCCritical(SPECTACLE_LOG) << "Failed to fallback to language:" << fallbackLang;
+ return false;
+ }
+ return false;
+ }
+
+ m_tesseract->SetPageSegMode(tesseract::PSM_AUTO);
+ return true;
+ } catch (const std::exception &e) {
+ qCWarning(SPECTACLE_LOG) << "Exception while setting up Tesseract languages:" << e.what();
+ return false;
+ }
+#else
+ Q_UNUSED(langCodes);
+ return false;
+#endif
+}
+
+void OcrManager::setupAvailableLanguages(const QString &tessdataPath)
+{
+#ifdef HAVE_TESSERACT_OCR
+ m_availableLanguages.clear();
+ m_languageNames.clear();
+
+ if (!m_tesseract) {
+ qCWarning(SPECTACLE_LOG) << "Cannot enumerate OCR languages: Tesseract not initialized";
+ return;
+ }
+
+ QStringList detectedLanguages;
+
+ try {
+ std::vector<std::string> available;
+ m_tesseract->GetAvailableLanguagesAsVector(&available);
+ detectedLanguages.reserve(static_cast<int>(available.size()));
+
+ for (const std::string &language : available) {
+ const QString langCode = QString::fromStdString(language);
+ if (langCode.isEmpty()) {
+ continue;
+ }
+
+ if (!tessdataPath.isEmpty()) {
+ const QString trainedDataPath = QDir(tessdataPath).filePath(langCode + u".traineddata"_s);
+ if (!QFile::exists(trainedDataPath)) {
+ qCDebug(SPECTACLE_LOG) << "Skipping OCR language" << langCode << "- missing traineddata at" << trainedDataPath;
+ continue;
+ }
+ }
+
+ if (!detectedLanguages.contains(langCode)) {
+ detectedLanguages.append(langCode);
+ }
+ }
+ } catch (const std::exception &e) {
+ qCWarning(SPECTACLE_LOG) << "Exception while enumerating Tesseract languages:" << e.what();
+ }
+
+ std::sort(detectedLanguages.begin(), detectedLanguages.end());
+ m_availableLanguages = detectedLanguages;
+
+ for (const QString &langCode : std::as_const(m_availableLanguages)) {
+ if (langCode == u"osd"_s) {
+ m_languageNames.insert(langCode, i18nc("@item:inlistbox", "Orientation and Script Detection"));
+ continue;
+ }
+
+ const QString displayName = tesseractLangName(langCode);
+ m_languageNames.insert(langCode, displayName);
+ }
+
+ qCDebug(SPECTACLE_LOG) << "Detected OCR languages:" << m_availableLanguages;
+#else
+ Q_UNUSED(tessdataPath);
+#endif
+}
+
+QString OcrManager::tesseractLangName(const QString &tesseractCode) const
+{
+ static const QMap<QString, QString> tesseractToIsoMap = {
+ {u"afr"_s, u"af"_s}, {u"ara"_s, u"ar"_s}, {u"aze"_s, u"az"_s}, {u"aze_cyrl"_s, u"az"_s}, {u"bel"_s, u"be"_s},
+ {u"ben"_s, u"bn"_s}, {u"bul"_s, u"bg"_s}, {u"cat"_s, u"ca"_s}, {u"ces"_s, u"cs"_s}, {u"chi_sim"_s, u"zh_CN"_s},
+ {u"chi_tra"_s, u"zh_TW"_s}, {u"cym"_s, u"cy"_s}, {u"dan"_s, u"da"_s}, {u"dan_frak"_s, u"da"_s}, {u"deu"_s, u"de"_s},
+ {u"deu_frak"_s, u"de"_s}, {u"deu_latf"_s, u"de"_s}, {u"ell"_s, u"el"_s}, {u"eng"_s, u"en"_s}, {u"epo"_s, u"eo"_s},
+ {u"est"_s, u"et"_s}, {u"eus"_s, u"eu"_s}, {u"fas"_s, u"fa"_s}, {u"fin"_s, u"fi"_s}, {u"fra"_s, u"fr"_s},
+ {u"frk"_s, u"de"_s}, {u"gla"_s, u"gd"_s}, {u"gle"_s, u"ga"_s}, {u"glg"_s, u"gl"_s}, {u"heb"_s, u"he"_s},
+ {u"hin"_s, u"hi"_s}, {u"hrv"_s, u"hr"_s}, {u"hun"_s, u"hu"_s}, {u"ind"_s, u"id"_s}, {u"isl"_s, u"is"_s},
+ {u"ita"_s, u"it"_s}, {u"ita_old"_s, u"it"_s}, {u"jpn"_s, u"ja"_s}, {u"kor"_s, u"ko"_s}, {u"kor_vert"_s, u"ko"_s},
+ {u"lav"_s, u"lv"_s}, {u"lit"_s, u"lt"_s}, {u"nld"_s, u"nl"_s}, {u"nor"_s, u"no"_s}, {u"pol"_s, u"pl"_s},
+ {u"por"_s, u"pt"_s}, {u"ron"_s, u"ro"_s}, {u"rus"_s, u"ru"_s}, {u"slk"_s, u"sk"_s}, {u"slk_frak"_s, u"sk"_s},
+ {u"slv"_s, u"sl"_s}, {u"spa"_s, u"es"_s}, {u"spa_old"_s, u"es"_s}, {u"srp"_s, u"sr"_s}, {u"srp_latn"_s, u"sr"_s},
+ {u"swe"_s, u"sv"_s}, {u"tur"_s, u"tr"_s}, {u"ukr"_s, u"uk"_s}, {u"vie"_s, u"vi"_s}, {u"amh"_s, u"am"_s},
+ {u"asm"_s, u"as"_s}, {u"bod"_s, u"bo"_s}, {u"dzo"_s, u"dz"_s}, {u"guj"_s, u"gu"_s}, {u"kan"_s, u"kn"_s},
+ {u"kat"_s, u"ka"_s}, {u"kat_old"_s, u"ka"_s}, {u"kaz"_s, u"kk"_s}, {u"khm"_s, u"km"_s}, {u"kir"_s, u"ky"_s},
+ {u"lao"_s, u"lo"_s}, {u"mal"_s, u"ml"_s}, {u"mar"_s, u"mr"_s}, {u"mya"_s, u"my"_s}, {u"nep"_s, u"ne"_s},
+ {u"ori"_s, u"or"_s}, {u"pan"_s, u"pa"_s}, {u"sin"_s, u"si"_s}, {u"tam"_s, u"ta"_s}, {u"tel"_s, u"te"_s},
+ {u"tha"_s, u"th"_s}, {u"urd"_s, u"ur"_s}, {u"bos"_s, u"bs"_s}, {u"bre"_s, u"br"_s}, {u"cos"_s, u"co"_s},
+ {u"fao"_s, u"fo"_s}, {u"fil"_s, u"tl"_s}, {u"fry"_s, u"fy"_s}, {u"hat"_s, u"ht"_s}, {u"hye"_s, u"hy"_s},
+ {u"iku"_s, u"iu"_s}, {u"jav"_s, u"jv"_s}, {u"kmr"_s, u"ku"_s}, {u"kur"_s, u"ku"_s}, {u"lat"_s, u"la"_s},
+ {u"ltz"_s, u"lb"_s}, {u"mkd"_s, u"mk"_s}, {u"mlt"_s, u"mt"_s}, {u"mon"_s, u"mn"_s}, {u"mri"_s, u"mi"_s},
+ {u"msa"_s, u"ms"_s}, {u"oci"_s, u"oc"_s}, {u"pus"_s, u"ps"_s}, {u"que"_s, u"qu"_s}, {u"san"_s, u"sa"_s},
+ {u"snd"_s, u"sd"_s}, {u"sqi"_s, u"sq"_s}, {u"sun"_s, u"su"_s}, {u"swa"_s, u"sw"_s}, {u"tat"_s, u"tt"_s},
+ {u"tgk"_s, u"tg"_s}, {u"tgl"_s, u"tl"_s}, {u"tir"_s, u"ti"_s}, {u"ton"_s, u"to"_s}, {u"uig"_s, u"ug"_s},
+ {u"uzb"_s, u"uz"_s}, {u"uzb_cyrl"_s, u"uz"_s}, {u"yid"_s, u"yi"_s}, {u"yor"_s, u"yo"_s},
+ };
+
+ if (tesseractCode == u"equ"_s) {
+ return i18n("Math/Equation Detection");
+ }
+ if (tesseractCode == u"osd"_s) {
+ return i18n("Orientation and Script Detection");
+ }
+
+ const QString isoCode = tesseractToIsoMap.value(tesseractCode);
+ if (!isoCode.isEmpty()) {
+ QLocale locale(isoCode);
+ QString name = locale.nativeLanguageName();
+
+ if (!name.isEmpty()) {
+ name[0] = name[0].toUpper();
+ return name;
+ }
+
+ QString languageName = QLocale::languageToString(locale.language());
+ if (!languageName.isEmpty()) {
+ languageName[0] = languageName[0].toUpper();
+ return languageName;
+ }
+ }
+
+ return tesseractCode;
+}
+
+OcrWorker::OcrWorker(QObject *parent)
+ : QObject(parent)
+{
+}
+
+void OcrWorker::processImage(const QImage &image, tesseract::TessBaseAPI *tesseract)
+{
+#ifdef HAVE_TESSERACT_OCR
+ QMutexLocker locker(&m_mutex);
+
+ if (!tesseract || image.isNull()) {
+ Q_EMIT imageProcessed(QString(), false);
+ return;
+ }
+
+ try {
+ QImage rgbImage = image.convertToFormat(QImage::Format_RGB888);
+
+ tesseract->SetImage(rgbImage.bits(), rgbImage.width(), rgbImage.height(), 3, rgbImage.bytesPerLine());
+
+ if (tesseract->Recognize(0) != 0) {
+ Q_EMIT imageProcessed(QString(), false);
+ return;
+ }
+
+ QStringList lines;
+ std::unique_ptr<tesseract::ResultIterator> iterator(tesseract->GetIterator());
+
+ if (iterator) {
+ do {
+ const char *lineText = iterator->GetUTF8Text(tesseract::RIL_TEXTLINE);
+ if (lineText != nullptr) {
+ QString line = QString::fromUtf8(lineText).trimmed();
+ if (!line.isEmpty()) {
+ lines.append(line);
+ }
+ delete[] lineText;
+ }
+ } while (iterator->Next(tesseract::RIL_TEXTLINE));
+ }
+
+ const QString result = lines.join(QLatin1Char('\n')).trimmed();
+ Q_EMIT imageProcessed(result, true);
+ } catch (const std::exception &e) {
+ qCWarning(SPECTACLE_LOG) << "Exception in OCR worker:" << e.what();
+ Q_EMIT imageProcessed(QString(), false);
+ }
+#else
+ Q_UNUSED(image);
+ Q_UNUSED(tesseract);
+ Q_EMIT imageProcessed(QString(), false);
+#endif
+}
diff --git a/src/OcrManager.h b/src/OcrManager.h
new file mode 100644
index 000000000..c71505b3e
--- /dev/null
+++ b/src/OcrManager.h
@@ -0,0 +1,175 @@
+/* This file is part of Spectacle, the KDE screenshot utility
+ * SPDX-FileCopyrightText: 2025 Jhair Paris <dev@jhairparis.com>
+ * SPDX-License-Identifier: LGPL-2.0-or-later
+ */
+
+#pragma once
+
+#include "Config.h"
+
+#ifdef HAVE_TESSERACT_OCR
+#include <tesseract/baseapi.h>
+#else
+namespace tesseract
+{
+class TessBaseAPI;
+}
+#endif
+
+#include <QImage>
+#include <QMap>
+#include <QMutex>
+#include <QObject>
+#include <QString>
+#include <QThread>
+#include <QTimer>
+
+#include <memory>
+
+/**
+ * @brief Worker class for OCR processing in background thread
+ */
+class OcrWorker : public QObject
+{
+ Q_OBJECT
+
+public:
+ explicit OcrWorker(QObject *parent = nullptr);
+
+public Q_SLOTS:
+ void processImage(const QImage &image, tesseract::TessBaseAPI *tesseract);
+
+Q_SIGNALS:
+ void imageProcessed(const QString &text, bool success);
+
+private:
+ QMutex m_mutex;
+};
+
+/**
+ * This class uses Tesseract OCR engine to extract text from images.
+ * It provides both synchronous and asynchronous text recognition capabilities.
+ */
+class OcrManager : public QObject
+{
+ Q_OBJECT
+
+public:
+ static constexpr int MAX_OCR_LANGUAGES = 4;
+ static constexpr int MIN_OCR_LANGUAGES = 1;
+ enum class OcrStatus {
+ Ready = 0,
+ Processing = 1,
+ Error = 2
+ };
+ Q_ENUM(OcrStatus)
+
+ explicit OcrManager(QObject *parent = nullptr);
+ ~OcrManager() override;
+
+ static OcrManager *instance();
+
+ /**
+ * @brief Check if OCR engine is available and properly initialized
+ * @return true if OCR is available, false otherwise
+ */
+ bool isAvailable() const;
+
+ /**
+ * @brief Get the current OCR processing status
+ * @return Current status of the OCR engine
+ */
+ OcrStatus status() const;
+
+ /**
+ * @brief Get a map of available languages with human-readable names
+ * @return QMap where key is language code and value is display name
+ */
+ QMap<QString, QString> availableLanguagesWithNames() const;
+
+ /**
+ * @brief Set multiple languages for OCR processing
+ * @param languageCodes List of language codes to use (e.g., ["eng", "spa", "fra"])
+ */
+ void setLanguagesByCode(const QStringList &languageCodes);
+
+ /**
+ * @brief Get the current language code
+ * @return Current language code (e.g., "eng", "spa")
+ */
+ QString currentLanguageCode() const;
+
+public Q_SLOTS:
+ /**
+ * @brief Extract text from an image asynchronously
+ * @param image The image to process
+ *
+ * This method processes the image in a background thread and emits
+ * textRecognized() signal when complete.
+ */
+ void recognizeText(const QImage &image);
+
+ /**
+ * @brief Extract text from an image using a temporary language selection
+ * @param image The image to process
+ * @param languageCode The one-off language code to use (e.g. "eng")
+ *
+ * The provided language is applied only for this recognition request and
+ * does not persist the user's saved configuration.
+ */
+ void recognizeTextWithLanguage(const QImage &image, const QString &languageCode);
+
+Q_SIGNALS:
+ /**
+ * @brief Emitted when text recognition is complete
+ * @param text The recognized text
+ * @param success true if recognition was successful
+ */
+ void textRecognized(const QString &text, bool success);
+
+ /**
+ * @brief Emitted when OCR status changes
+ * @param status New status
+ */
+ void statusChanged(OcrStatus status);
+
+private Q_SLOTS:
+ void handleRecognitionComplete(const QString &text, bool success);
+
+private:
+ void initializeTesseract();
+ void setStatus(OcrStatus status);
+ bool setupTesseractLanguages(const QStringList &langCodes);
+ void setupAvailableLanguages(const QString &tessdataPath);
+ void loadSavedLanguageSetting();
+ bool isLanguageAvailable(const QString &languageCode) const;
+ QString tesseractLangName(const QString &tesseractCode) const;
+
+ /**
+ * @brief Validate, filter, and apply languages to Tesseract
+ * @param languageCodes Languages to validate and apply
+ * @return true if languages were successfully applied
+ */
+ bool validateAndApplyLanguages(const QStringList &languageCodes);
+ void beginRecognition(const QImage &image);
+
+ static OcrManager *s_instance;
+
+#ifdef HAVE_TESSERACT_OCR
+ tesseract::TessBaseAPI *m_tesseract;
+ OcrWorker *m_worker;
+#endif
+ std::unique_ptr<QThread> m_workerThread;
+ QTimer *m_timeoutTimer;
+
+ OcrStatus m_status;
+ QString m_currentLanguageCode;
+ QStringList m_configuredLanguages;
+ QStringList m_activeLanguages;
+ bool m_shouldRestoreToConfigured;
+ QStringList m_availableLanguages;
+ QMap<QString, QString> m_languageNames;
+ bool m_initialized;
+
+private:
+};
\ No newline at end of file
diff --git a/src/SpectacleCore.cpp b/src/SpectacleCore.cpp
index 7371ce768..caada874e 100644
--- a/src/SpectacleCore.cpp
+++ b/src/SpectacleCore.cpp
@@ -1,6 +1,7 @@
/*
* SPDX-FileCopyrightText: 2019 David Redondo <kde@david-redondo.de>
* SPDX-FileCopyrightText: 2015 Boudhayan Gupta <bgupta@kde.org>
+ * SPDX-FileCopyrightText: 2025 Jhair Paris <dev@jhairparis.com>
*
* SPDX-License-Identifier: LGPL-2.0-or-later
*/
@@ -20,6 +21,7 @@
#include "Gui/HelpMenu.h"
#include "Gui/OptionsMenu.h"
#include "Gui/InlineMessageModel.h"
+#include "OcrManager.h"
#include "Platforms/ImagePlatformXcb.h"
#include "Platforms/VideoPlatform.h"
#include "ShortcutActions.h"
@@ -49,6 +51,7 @@
#include <QDir>
#include <QDrag>
#include <QKeySequence>
+#include <QMetaObject>
#include <QMimeData>
#include <QMovie>
#include <QObject>
@@ -59,6 +62,8 @@
#include <QScopedPointer>
#include <QScreen>
#include <QSystemTrayIcon>
+#include <QTemporaryFile>
+#include <QTextStream>
#include <QTimer>
#include <QtMath>
#include <qobjectdefs.h>
@@ -538,6 +543,63 @@ SpectacleCore::SpectacleCore(QObject *parent)
InlineMessageModel::instance()->push(InlineMessageModel::Scanned, text, result);
};
connect(exportManager, &ExportManager::qrCodeScanned, this, onQRCodeScanned);
+
+ auto onOcrTextRecognized = [this](const QString &text, bool success) {
+ if (!success) {
+ InlineMessageModel::instance()->push(InlineMessageModel::Error,
+ i18nc("@info", "Text extraction failed"));
+ return;
+ }
+
+ if (text.isEmpty()) {
+ InlineMessageModel::instance()->push(InlineMessageModel::Copied,
+ i18nc("@info", "No text found in the image"));
+ return;
+ }
+
+ InlineMessageModel::instance()->push(InlineMessageModel::Copied,
+ i18nc("@info", "Text extraction completed"));
+
+ auto notification = new KNotification(u"ocrTextExtracted"_s, KNotification::CloseOnTimeout, this);
+ notification->setTitle(i18nc("@info:notification title", "Text Extracted"));
+
+ notification->setText(i18nc("@info:notification", "Text copied to clipboard"));
+ notification->setIconName(u"document-scan"_s);
+
+ if (!text.isEmpty()) {
+ auto openEditorAction = notification->addAction(i18nc("@action:button", "Open in Text Editor"));
+ connect(openEditorAction, &KNotificationAction::activated, this, [text]() {
+ // Create temporary file with extracted text
+ auto exportManager = ExportManager::instance();
+ exportManager->updateTimestamp();
+ auto timestamp = exportManager->timestamp();
+
+ QString filename = QStringLiteral("spectacle_ocr_%1.txt").arg(timestamp.toString(QStringLiteral("yyyyMMdd_HHmmss")));
+ QString templatePath = QDir::tempPath() + QStringLiteral("/") + filename;
+
+ QTemporaryFile tempFile;
+ tempFile.setFileTemplate(templatePath);
+ tempFile.setAutoRemove(false);
+
+ if (tempFile.open()) {
+ QTextStream stream(&tempFile);
+ stream << text;
+ tempFile.close();
+
+ auto job = new KIO::OpenUrlJob(QUrl::fromLocalFile(tempFile.fileName()));
+ job->start();
+ }
+ });
+ }
+
+ notification->sendEvent();
+ };
+
+ // Connect to OCR manager
+ connect(OcrManager::instance(), &OcrManager::textRecognized, this, onOcrTextRecognized);
+ connect(OcrManager::instance(), &OcrManager::statusChanged, this, [this](OcrManager::OcrStatus) {
+ Q_EMIT ocrStatusChanged();
+ });
connect(exportManager, &ExportManager::errorMessage, this, &SpectacleCore::showErrorMessage);
@@ -582,6 +644,87 @@ SpectacleCore::SpectacleCore(QObject *parent)
});
}
+bool SpectacleCore::ocrAvailable() const
+{
+ return OcrManager::instance()->isAvailable();
+}
+
+OcrManager::OcrStatus SpectacleCore::ocrStatus() const
+{
+ return OcrManager::instance()->status();
+}
+
+QVariantMap SpectacleCore::ocrAvailableLanguages() const
+{
+ auto ocrManager = OcrManager::instance();
+ if (!ocrManager->isAvailable()) {
+ return QVariantMap();
+ }
+
+ auto languageMap = ocrManager->availableLanguagesWithNames();
+ QVariantMap result;
+ for (auto it = languageMap.constBegin(); it != languageMap.constEnd(); ++it) {
+ result[it.key()] = it.value();
+ }
+ return result;
+}
+
+bool SpectacleCore::startOcrExtraction(const QString &languageCode)
+{
+ if (m_videoMode) {
+ return false;
+ }
+
+ const bool hasCaptureWindows = !CaptureWindow::instances().isEmpty();
+
+ if (hasCaptureWindows) {
+ auto selectionEditor = SelectionEditor::instance();
+ auto inlineMessages = InlineMessageModel::instance();
+
+ if (!selectionEditor->acceptSelection(ExportManager::UserAction)) {
+ inlineMessages->push(InlineMessageModel::Error, i18nc("@info", "Please select a region before extracting text"));
+ return false;
+ }
+
+ QMetaObject::invokeMethod(
+ this,
+ [this, languageCode]() {
+ performOcrExtraction(languageCode);
+ },
+ Qt::QueuedConnection);
+ return true;
+ }
+
+ return performOcrExtraction(languageCode);
+}
+
+bool SpectacleCore::performOcrExtraction(const QString &languageCode)
+{
+ auto ocrManager = OcrManager::instance();
+ auto inlineMessages = InlineMessageModel::instance();
+
+ if (!ocrManager->isAvailable()) {
+ inlineMessages->push(InlineMessageModel::Error, i18nc("@info", "OCR is not available."));
+ return false;
+ }
+
+ const QImage image = m_annotationDocument->renderToImage();
+ if (image.isNull()) {
+ inlineMessages->push(InlineMessageModel::Error, i18nc("@info", "No screenshot available."));
+ return false;
+ }
+
+ inlineMessages->push(InlineMessageModel::Copied, i18nc("@info", "Extracting text from image..."));
+
+ if (languageCode.isEmpty()) {
+ ocrManager->recognizeText(image);
+ } else {
+ ocrManager->recognizeTextWithLanguage(image, languageCode);
+ }
+
+ return true;
+}
+
SpectacleCore::~SpectacleCore() noexcept
{
s_self = nullptr;
diff --git a/src/SpectacleCore.h b/src/SpectacleCore.h
index 23d65ead9..2c87ff8f4 100644
--- a/src/SpectacleCore.h
+++ b/src/SpectacleCore.h
@@ -17,6 +17,7 @@
#include "Gui/Annotations/AnnotationDocument.h"
#include "Gui/CaptureWindow.h"
#include "Gui/ViewerWindow.h"
+#include "OcrManager.h"
#include "Platforms/PlatformLoader.h"
#include "RecordingModeModel.h"
#include "VideoFormatModel.h"
@@ -40,6 +41,8 @@ class SpectacleCore : public QObject
Q_PROPERTY(bool videoMode READ videoMode WRITE setVideoMode NOTIFY videoModeChanged)
Q_PROPERTY(QUrl currentVideo READ currentVideo NOTIFY currentVideoChanged)
Q_PROPERTY(AnnotationDocument *annotationDocument READ annotationDocument CONSTANT FINAL)
+ Q_PROPERTY(bool ocrAvailable READ ocrAvailable NOTIFY ocrStatusChanged FINAL)
+ Q_PROPERTY(OcrManager::OcrStatus ocrStatus READ ocrStatus NOTIFY ocrStatusChanged FINAL)
public:
enum class StartMode {
@@ -74,6 +77,10 @@ public:
QUrl currentVideo() const;
+ bool ocrAvailable() const;
+ OcrManager::OcrStatus ocrStatus() const;
+ Q_INVOKABLE QVariantMap ocrAvailableLanguages() const;
+ Q_INVOKABLE bool startOcrExtraction(const QString &languageCode = QString());
void initGuiNoScreenshot();
@@ -125,6 +132,7 @@ Q_SIGNALS:
void videoModeChanged(bool videoMode);
void currentVideoChanged(const QUrl &currentVideo);
void recordedTimeChanged();
+ void ocrStatusChanged();
private:
explicit SpectacleCore(QObject *parent = nullptr);
@@ -148,6 +156,7 @@ private:
void unityLauncherUpdate(const QVariantMap &properties) const;
void setCurrentVideo(const QUrl &currentVideo);
QUrl videoOutputUrl() const;
+ bool performOcrExtraction(const QString &languageCode);
static SpectacleCore *s_self;
std::unique_ptr<AnnotationDocument> m_annotationDocument = nullptr;
--
GitLab
From a1f7ac0b716ea295cfec120bf8691dd86e56413b Mon Sep 17 00:00:00 2001
From: Jhair Paris <dev@jhairparis.com>
Date: Mon, 13 Oct 2025 22:58:17 -0500
Subject: [PATCH 3/3] add support for multiple OCR languages in preferences
dialog
- Switch from single ocrLanguage string to ocrLanguages string list in settings
- Add OcrLanguageSelector widget for multi-language selection
- Integrate new selector into GeneralOptionsPage and SettingsDialog
add OCR language menu to main interface
- Introduce OcrLanguageMenu and OcrLanguageMenuButton components
- Expose language selection in ViewerPage and CaptureOverlay
- Move OCR extraction logic to SpectacleCore::startOcrExtraction
Remove OCR language menu components and references from the project
Add OCR language submenu to ExportMenu
---
src/CMakeLists.txt | 1 +
src/Gui/CaptureOverlay.qml | 8 +-
src/Gui/ExportMenu.cpp | 84 ++++++
src/Gui/ExportMenu.h | 5 +
src/Gui/OcrAction.qml | 7 +-
src/Gui/SettingsDialog/GeneralOptions.ui | 38 ++-
src/Gui/SettingsDialog/GeneralOptionsPage.cpp | 81 ++----
src/Gui/SettingsDialog/GeneralOptionsPage.h | 16 +-
.../SettingsDialog/OcrLanguageSelector.cpp | 271 ++++++++++++++++++
src/Gui/SettingsDialog/OcrLanguageSelector.h | 111 +++++++
src/Gui/SettingsDialog/SettingsDialog.cpp | 17 +-
src/Gui/SettingsDialog/spectacle.kcfg | 4 +-
src/Gui/ViewerPage.qml | 4 +-
13 files changed, 561 insertions(+), 86 deletions(-)
create mode 100644 src/Gui/SettingsDialog/OcrLanguageSelector.cpp
create mode 100644 src/Gui/SettingsDialog/OcrLanguageSelector.h
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index c57535e34..6efeff637 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -44,6 +44,7 @@ target_sources(spectacle PRIVATE
Gui/SelectionEditor.cpp
Gui/SettingsDialog/GeneralOptionsPage.cpp
Gui/SettingsDialog/ImageSaveOptionsPage.cpp
+ Gui/SettingsDialog/OcrLanguageSelector.cpp
Gui/SettingsDialog/SettingsDialog.cpp
Gui/SettingsDialog/ShortcutsOptionsPage.cpp
Gui/SettingsDialog/VideoFormatComboBox.cpp
diff --git a/src/Gui/CaptureOverlay.qml b/src/Gui/CaptureOverlay.qml
index 37f3dcf85..431d76479 100644
--- a/src/Gui/CaptureOverlay.qml
+++ b/src/Gui/CaptureOverlay.qml
@@ -506,11 +506,13 @@ MouseArea {
visible: action.enabled
action: CopyImageAction {}
}
+
ToolButton {
display: TtToolButton.IconOnly
- visible: action.enabled && !SpectacleCore.videoMode && SpectacleCore.ocrAvailable
+ visible: !SpectacleCore.videoMode && SpectacleCore.ocrAvailable
action: OcrAction {}
}
+
ExportMenuButton {
focusPolicy: Qt.NoFocus
}
@@ -537,10 +539,12 @@ MouseArea {
visible: action.enabled
action: CopyImageAction {}
}
+
ToolButton {
- visible: action.enabled && !SpectacleCore.videoMode && SpectacleCore.ocrAvailable
+ visible: !SpectacleCore.videoMode && SpectacleCore.ocrAvailable
action: OcrAction {}
}
+
ExportMenuButton {
focusPolicy: Qt.NoFocus
}
diff --git a/src/Gui/ExportMenu.cpp b/src/Gui/ExportMenu.cpp
index e2c7dd5b6..57e1480fa 100644
--- a/src/Gui/ExportMenu.cpp
+++ b/src/Gui/ExportMenu.cpp
@@ -6,6 +6,7 @@
#include "ExportMenu.h"
#include "CaptureWindow.h"
+#include "OcrManager.h"
#include "SpectacleCore.h"
#include "WidgetWindowUtils.h"
#include "settings.h"
@@ -54,6 +55,8 @@ ExportMenu::ExportMenu(QWidget *parent)
this, &ExportMenu::openScreenshotsFolder);
addAction(KStandardActions::print(this, &ExportMenu::openPrintDialog, this));
+ createOcrLanguageSubmenu();
+
#ifdef PURPOSE_FOUND
loadPurposeMenu();
connect(ExportManager::instance(), &ExportManager::imageChanged, this, &ExportMenu::onImageChanged);
@@ -233,4 +236,85 @@ void ExportMenu::openPrintDialog()
dialog->setVisible(true);
}
+void ExportMenu::createOcrLanguageSubmenu()
+{
+ Q_ASSERT(!m_ocrLanguageMenu);
+
+ auto ocrManager = OcrManager::instance();
+
+ if (!ocrManager || !ocrManager->isAvailable()) {
+ return;
+ }
+
+ m_ocrLanguageMenu = addMenu(i18nc("@action:menu", "Extract Text by Language"));
+ m_ocrLanguageMenu->setIcon(QIcon::fromTheme(u"document-scan"_s));
+
+ // Keep the submenu in sync with OCR status changes
+ if (ocrManager) {
+ connect(ocrManager, &OcrManager::statusChanged, this, &ExportMenu::buildOcrLanguageSubmenu);
+ }
+
+ if (auto settings = Settings::self()) {
+ connect(settings, &Settings::ocrLanguagesChanged, this, &ExportMenu::buildOcrLanguageSubmenu);
+ }
+
+ connect(m_ocrLanguageMenu, &QMenu::aboutToShow, this, &ExportMenu::buildOcrLanguageSubmenu);
+
+ buildOcrLanguageSubmenu();
+}
+
+void ExportMenu::buildOcrLanguageSubmenu()
+{
+ if (!m_ocrLanguageMenu) {
+ return;
+ }
+
+ m_ocrLanguageMenu->clear();
+
+ auto ocrManager = OcrManager::instance();
+
+ if (!ocrManager) {
+ QAction *action = m_ocrLanguageMenu->addAction(i18n("OCR engine is not available."));
+ action->setEnabled(false);
+ return;
+ }
+
+ const bool initializationFailed = ocrManager->status() == OcrManager::OcrStatus::Error;
+ if (!ocrManager->isAvailable()) {
+ QAction *action = m_ocrLanguageMenu->addAction(initializationFailed ? i18n("OCR is not available. Please install Tesseract OCR.")
+ : i18n("OCR engine is initializing…"));
+ action->setEnabled(false);
+ return;
+ }
+
+ const bool busy = ocrManager->status() == OcrManager::OcrStatus::Processing;
+ const QMap<QString, QString> languages = ocrManager->availableLanguagesWithNames();
+
+ if (languages.isEmpty()) {
+ QAction *action = m_ocrLanguageMenu->addAction(i18n("No OCR language data available."));
+ action->setEnabled(false);
+ return;
+ }
+
+ for (auto it = languages.cbegin(); it != languages.cend(); ++it) {
+ const QString &code = it.key();
+
+ if (code == u"osd"_s) {
+ continue;
+ }
+
+ QAction *languageAction = m_ocrLanguageMenu->addAction(it.value());
+ languageAction->setEnabled(!busy);
+
+ connect(languageAction, &QAction::triggered, this, [this, code]() {
+ triggerExtraction(code);
+ });
+ }
+}
+
+void ExportMenu::triggerExtraction(const QString &languageCode)
+{
+ SpectacleCore::instance()->startOcrExtraction(languageCode);
+}
+
#include "moc_ExportMenu.cpp"
diff --git a/src/Gui/ExportMenu.h b/src/Gui/ExportMenu.h
index e0533a708..bfac0b990 100644
--- a/src/Gui/ExportMenu.h
+++ b/src/Gui/ExportMenu.h
@@ -9,6 +9,7 @@
#include "SpectacleMenu.h"
+#include <QMenu>
#include <QQmlEngine>
#include "Config.h"
@@ -49,8 +50,11 @@ private:
Q_SLOT void onImageChanged();
Q_SLOT void openScreenshotsFolder();
+ Q_SLOT void buildOcrLanguageSubmenu();
+ Q_SLOT void triggerExtraction(const QString &languageCode);
void getKServiceItems();
+ void createOcrLanguageSubmenu();
#ifdef PURPOSE_FOUND
void loadPurposeMenu();
@@ -59,6 +63,7 @@ private:
bool mUpdatedImageAvailable;
std::unique_ptr<Purpose::Menu> mPurposeMenu;
#endif
+ QMenu *m_ocrLanguageMenu = nullptr;
friend class ExportMenuSingleton;
};
diff --git a/src/Gui/OcrAction.qml b/src/Gui/OcrAction.qml
index f887ec0ee..a22efec16 100644
--- a/src/Gui/OcrAction.qml
+++ b/src/Gui/OcrAction.qml
@@ -6,9 +6,10 @@ import QtQuick.Templates as T
import org.kde.spectacle.private
T.Action {
- // OCR is only available for screenshots, not videos, and only when OCR is properly available
- enabled: !SpectacleCore.videoMode && SpectacleCore.ocrAvailable
+ enabled: !SpectacleCore.videoMode &&
+ SpectacleCore.ocrAvailable &&
+ SpectacleCore.ocrStatus !== 1
icon.name: "document-scan"
text: i18nc("@action", "Extract Text")
- onTriggered: contextWindow.extractText()
+ onTriggered: SpectacleCore.startOcrExtraction()
}
diff --git a/src/Gui/SettingsDialog/GeneralOptions.ui b/src/Gui/SettingsDialog/GeneralOptions.ui
index ddbbf3e5a..048639b89 100644
--- a/src/Gui/SettingsDialog/GeneralOptions.ui
+++ b/src/Gui/SettingsDialog/GeneralOptions.ui
@@ -265,21 +265,39 @@
<item row="16" column="0">
<widget class="QLabel" name="ocrLanguageLabel">
<property name="text">
- <string>Language:</string>
+ <string>Languages for OCR:</string>
</property>
</widget>
</item>
<item row="16" column="1">
- <widget class="QComboBox" name="kcfg_ocrLanguage">
- <property name="sizePolicy">
- <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
- <horstretch>0</horstretch>
- <verstretch>0</verstretch>
- </sizepolicy>
+ <widget class="QScrollArea" name="ocrLanguageScrollArea">
+ <property name="widgetResizable">
+ <bool>true</bool>
</property>
- <property name="kcfg_property" stdset="0">
- <string notr="true">currentData</string>
+ <property name="frameShape">
+ <enum>QFrame::StyledPanel</enum>
</property>
+ <property name="maximumHeight">
+ <number>120</number>
+ </property>
+ <property name="minimumHeight">
+ <number>60</number>
+ </property>
+ <property name="horizontalScrollBarPolicy">
+ <enum>Qt::ScrollBarAlwaysOff</enum>
+ </property>
+ <widget class="QWidget" name="ocrLanguageContainer">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>69</width>
+ <height>69</height>
+ </rect>
+ </property>
+ <layout class="QVBoxLayout" name="ocrLanguageLayout">
+ </layout>
+ </widget>
</widget>
</item>
<item row="17" column="0" colspan="2">
@@ -383,7 +401,7 @@
<tabstop>kcfg_useReleaseToCapture</tabstop>
<tabstop>kcfg_showCaptureInstructions</tabstop>
<tabstop>kcfg_rememberSelectionRect</tabstop>
- <tabstop>kcfg_ocrLanguage</tabstop>
+ <tabstop>ocrLanguageScrollArea</tabstop>
</tabstops>
<resources/>
<connections/>
diff --git a/src/Gui/SettingsDialog/GeneralOptionsPage.cpp b/src/Gui/SettingsDialog/GeneralOptionsPage.cpp
index 5b8a5d9fc..f6be13d56 100644
--- a/src/Gui/SettingsDialog/GeneralOptionsPage.cpp
+++ b/src/Gui/SettingsDialog/GeneralOptionsPage.cpp
@@ -8,19 +8,22 @@
#include "GeneralOptionsPage.h"
+#include "OcrLanguageSelector.h"
+#include "OcrManager.h"
#include "settings.h"
#include "ui_GeneralOptions.h"
-#include "OcrManager.h"
-#include <KWindowSystem>
#include <KLocalizedString>
+#include <KWindowSystem>
-#include <QCheckBox>
#include <QIcon>
+using namespace Qt::Literals::StringLiterals;
+
GeneralOptionsPage::GeneralOptionsPage(QWidget *parent)
: QWidget(parent)
, m_ui(new Ui_GeneralOptions)
+ , m_ocrLanguageSelector(new OcrLanguageSelector(this))
{
m_ui->setupUi(this);
@@ -31,9 +34,12 @@ GeneralOptionsPage::GeneralOptionsPage(QWidget *parent)
m_ui->regionTitle->setLevel(2);
m_ui->ocrTitle->setLevel(2);
- setupOcrLanguageComboBox();
+ m_ui->ocrLanguageScrollArea->setWidget(m_ocrLanguageSelector);
+ m_ui->ocrLanguageScrollArea->setWidgetResizable(true);
+
+ connect(m_ocrLanguageSelector, &OcrLanguageSelector::selectedLanguagesChanged, this, &GeneralOptionsPage::ocrLanguageChanged);
- connect(OcrManager::instance(), &OcrManager::statusChanged, this, &GeneralOptionsPage::refreshOcrLanguageSettings);
+ refreshOcrLanguageSettings();
//On Wayland we can't programmatically raise and focus the window so we have to hide the option
if (KWindowSystem::isPlatformWayland() || qstrcmp(qgetenv("XDG_SESSION_TYPE").constData(), "wayland") == 0) {
@@ -43,71 +49,20 @@ GeneralOptionsPage::GeneralOptionsPage(QWidget *parent)
GeneralOptionsPage::~GeneralOptionsPage() = default;
-void GeneralOptionsPage::setupOcrLanguageComboBox()
-{
- OcrManager *ocrManager = OcrManager::instance();
-
- if (!ocrManager->isAvailable()) {
- m_ui->kcfg_ocrLanguage->setEnabled(false);
- m_ui->kcfg_ocrLanguage->addItem(i18n("OCR not available"));
- m_ui->ocrLanguageLabel->setVisible(false);
- m_ui->kcfg_ocrLanguage->setVisible(false);
- m_ui->ocrUnavailableWidget->setVisible(true);
- return;
- }
-
- const auto availableLanguages = ocrManager->availableLanguagesWithNames();
-
- if (availableLanguages.isEmpty()) {
- m_ui->kcfg_ocrLanguage->addItem(i18n("No languages found"));
- m_ui->kcfg_ocrLanguage->setEnabled(false);
- return;
- }
-
- m_ui->kcfg_ocrLanguage->clear();
- m_ui->ocrLanguageLabel->setVisible(true);
- m_ui->kcfg_ocrLanguage->setVisible(true);
- m_ui->ocrUnavailableWidget->setVisible(false);
-
- for (auto it = availableLanguages.constBegin(); it != availableLanguages.constEnd(); ++it) {
- m_ui->kcfg_ocrLanguage->addItem(it.value(), it.key());
- }
-}
-
void GeneralOptionsPage::refreshOcrLanguageSettings()
{
OcrManager *ocrManager = OcrManager::instance();
if (!ocrManager->isAvailable()) {
m_ui->ocrLanguageLabel->setVisible(false);
- m_ui->kcfg_ocrLanguage->setVisible(false);
+ m_ui->ocrLanguageScrollArea->setVisible(false);
m_ui->ocrUnavailableWidget->setVisible(true);
- return;
- }
-
- const auto availableLanguages = ocrManager->availableLanguagesWithNames();
-
- if (availableLanguages.isEmpty()) {
- return;
- }
-
- m_ui->kcfg_ocrLanguage->clear();
- m_ui->kcfg_ocrLanguage->setEnabled(true);
- m_ui->ocrLanguageLabel->setVisible(true);
- m_ui->kcfg_ocrLanguage->setVisible(true);
- m_ui->ocrUnavailableWidget->setVisible(false);
-
- for (auto it = availableLanguages.constBegin(); it != availableLanguages.constEnd(); ++it) {
- m_ui->kcfg_ocrLanguage->addItem(it.value(), it.key());
- }
-
- const QString currentLanguage = Settings::ocrLanguage();
-
- for (int i = 0; i < m_ui->kcfg_ocrLanguage->count(); ++i) {
- if (m_ui->kcfg_ocrLanguage->itemData(i).toString() == currentLanguage) {
- m_ui->kcfg_ocrLanguage->setCurrentIndex(i);
- break;
- }
+ } else {
+ m_ui->ocrLanguageLabel->setVisible(true);
+ m_ui->ocrLanguageScrollArea->setVisible(true);
+ m_ui->ocrUnavailableWidget->setVisible(false);
+
+ m_ocrLanguageSelector->refresh();
}
}
diff --git a/src/Gui/SettingsDialog/GeneralOptionsPage.h b/src/Gui/SettingsDialog/GeneralOptionsPage.h
index c184d6ba8..a3a5cb17d 100644
--- a/src/Gui/SettingsDialog/GeneralOptionsPage.h
+++ b/src/Gui/SettingsDialog/GeneralOptionsPage.h
@@ -11,6 +11,7 @@
#include <QWidget>
class Ui_GeneralOptions;
+class OcrLanguageSelector;
class GeneralOptionsPage : public QWidget
{
@@ -22,10 +23,21 @@ public:
void refreshOcrLanguageSettings();
+ /**
+ * @brief Get direct access to the OCR language selector widget
+ * @return Pointer to the OcrLanguageSelector widget for direct manipulation
+ */
+ OcrLanguageSelector *ocrLanguageSelector() const
+ {
+ return m_ocrLanguageSelector;
+ }
+
+Q_SIGNALS:
+ void ocrLanguageChanged();
+
private:
- void setupOcrLanguageComboBox();
-
QScopedPointer<Ui_GeneralOptions> m_ui;
+ OcrLanguageSelector *m_ocrLanguageSelector;
};
#endif // GENERALOPTIONSPAGE_H
diff --git a/src/Gui/SettingsDialog/OcrLanguageSelector.cpp b/src/Gui/SettingsDialog/OcrLanguageSelector.cpp
new file mode 100644
index 000000000..d1d809323
--- /dev/null
+++ b/src/Gui/SettingsDialog/OcrLanguageSelector.cpp
@@ -0,0 +1,271 @@
+/*
+ * SPDX-FileCopyrightText: 2025 Jhair Paris <dev@jhairparis.com>
+ *
+ * SPDX-License-Identifier: LGPL-2.0-or-later
+ */
+
+#include "OcrLanguageSelector.h"
+#include "OcrManager.h"
+#include "settings.h"
+#include "spectacle_debug.h"
+
+#include <KLocalizedString>
+
+#include <QCheckBox>
+#include <QVBoxLayout>
+
+using namespace Qt::Literals::StringLiterals;
+
+OcrLanguageSelector::OcrLanguageSelector(QWidget *parent)
+ : QWidget(parent)
+ , m_layout(new QVBoxLayout(this))
+ , m_blockSignals(false)
+ , m_ocrManager(OcrManager::instance())
+{
+ m_layout->setContentsMargins(0, 0, 0, 0);
+ m_layout->setSpacing(0);
+ setContentsMargins(0, 0, 0, 0);
+
+ setupLanguageCheckboxes();
+
+ connect(m_ocrManager, &OcrManager::statusChanged, this, &OcrLanguageSelector::onOcrManagerStatusChanged);
+}
+
+OcrLanguageSelector::~OcrLanguageSelector() = default;
+
+QStringList OcrLanguageSelector::selectedLanguages() const
+{
+ QStringList result;
+ for (QCheckBox *checkbox : m_languageCheckboxes) {
+ if (checkbox->isChecked()) {
+ result.append(checkbox->property("languageCode").toString());
+ }
+ }
+ return result;
+}
+
+void OcrLanguageSelector::setSelectedLanguages(const QStringList &languages)
+{
+ m_blockSignals = true;
+
+ for (QCheckBox *checkbox : m_languageCheckboxes) {
+ const QString langCode = checkbox->property("languageCode").toString();
+ checkbox->setChecked(languages.contains(langCode));
+ }
+
+ m_blockSignals = false;
+
+ enforceSelectionLimits();
+}
+
+bool OcrLanguageSelector::isDefault() const
+{
+ const QStringList current = selectedLanguages();
+
+ // Default state is exactly one language selected
+ if (current.size() != 1) {
+ return false;
+ }
+
+ // Check if it's English (preferred default)
+ for (const QCheckBox *checkbox : m_languageCheckboxes) {
+ if (checkbox->property("languageCode").toString() == u"eng"_s) {
+ // English is available, so default is English
+ return current.contains(u"eng"_s);
+ }
+ }
+
+ // English not available, default is the first available language
+ if (!m_languageCheckboxes.isEmpty()) {
+ QString firstLangCode = m_languageCheckboxes.first()->property("languageCode").toString();
+ return current.contains(firstLangCode);
+ }
+
+ return false;
+}
+
+bool OcrLanguageSelector::hasChanges() const
+{
+ return selectedLanguages() != Settings::ocrLanguages();
+}
+
+void OcrLanguageSelector::applyDefaults()
+{
+ if (!m_languageCheckboxes.isEmpty()) {
+ m_blockSignals = true;
+
+ for (QCheckBox *checkbox : m_languageCheckboxes) {
+ checkbox->setChecked(false);
+ }
+
+ // Try to select English first
+ bool foundDefault = false;
+ for (QCheckBox *checkbox : m_languageCheckboxes) {
+ if (checkbox->property("languageCode").toString() == u"eng"_s) {
+ checkbox->setChecked(true);
+ foundDefault = true;
+ break;
+ }
+ }
+
+ // If English not available, select first language
+ if (!foundDefault) {
+ m_languageCheckboxes.first()->setChecked(true);
+ }
+
+ m_blockSignals = false;
+
+ const QStringList selected = selectedLanguages();
+ Settings::setOcrLanguages(selected);
+
+ // Emit signal to notify changes
+ Q_EMIT selectedLanguagesChanged(selected);
+ }
+}
+
+void OcrLanguageSelector::refresh()
+{
+ setupLanguageCheckboxes();
+}
+
+void OcrLanguageSelector::saveSettings()
+{
+ const QStringList selected = selectedLanguages();
+ Settings::setOcrLanguages(selected);
+}
+
+void OcrLanguageSelector::updateWidgets()
+{
+ const QStringList savedLanguages = Settings::ocrLanguages();
+ setSelectedLanguages(savedLanguages);
+}
+
+void OcrLanguageSelector::onLanguageCheckboxChanged()
+{
+ if (m_blockSignals) {
+ return;
+ }
+
+ enforceSelectionLimits();
+
+ const QStringList selected = selectedLanguages();
+ Q_EMIT selectedLanguagesChanged(selected);
+}
+
+void OcrLanguageSelector::onOcrManagerStatusChanged()
+{
+ refresh();
+}
+
+void OcrLanguageSelector::setupLanguageCheckboxes()
+{
+ while (QLayoutItem *item = m_layout->takeAt(0)) {
+ if (auto widget = item->widget()) {
+ widget->deleteLater();
+ }
+ delete item;
+ }
+
+ m_languageCheckboxes.clear();
+ m_availableLanguages.clear();
+
+ if (!m_ocrManager || !m_ocrManager->isAvailable()) {
+ qCWarning(SPECTACLE_LOG) << "OCR is not available; language selector will remain empty.";
+ return;
+ }
+
+ m_availableLanguages = m_ocrManager->availableLanguagesWithNames();
+
+ if (m_availableLanguages.isEmpty()) {
+ qCWarning(SPECTACLE_LOG) << "No OCR language data available.";
+ return;
+ }
+
+ for (auto it = m_availableLanguages.cbegin(); it != m_availableLanguages.cend(); ++it) {
+ const QString &langCode = it.key();
+ if (langCode == u"osd"_s) {
+ continue;
+ }
+
+ QCheckBox *checkbox = new QCheckBox(it.value(), this);
+ checkbox->setProperty("languageCode", langCode);
+ connect(checkbox, &QCheckBox::toggled, this, &OcrLanguageSelector::onLanguageCheckboxChanged);
+ m_layout->addWidget(checkbox);
+ m_languageCheckboxes.append(checkbox);
+ }
+
+ if (m_layout->count() > 0) {
+ m_layout->addStretch();
+ }
+
+ const QStringList savedLanguages = Settings::ocrLanguages();
+ setSelectedLanguages(savedLanguages);
+
+ if (savedLanguages.isEmpty() && !m_languageCheckboxes.isEmpty()) {
+ applyDefaults();
+ }
+}
+
+void OcrLanguageSelector::enforceSelectionLimits()
+{
+ const QStringList selected = selectedLanguages();
+ const int count = selected.size();
+
+ if (count > OcrManager::MAX_OCR_LANGUAGES) { // Max languages for performance
+ for (int i = m_languageCheckboxes.size() - 1; i >= 0; --i) {
+ QCheckBox *checkbox = m_languageCheckboxes[i];
+ if (checkbox->isChecked()) {
+ blockSignalsAndSetChecked(checkbox, false);
+ break;
+ }
+ }
+ }
+
+ updateCheckboxEnabledStates();
+
+ if (selectedLanguages().size() == 0 && !m_languageCheckboxes.isEmpty()) {
+ applyDefaults();
+ }
+}
+
+QString OcrLanguageSelector::getDefaultLanguageCode() const
+{
+ if (m_languageCheckboxes.isEmpty()) {
+ return QString();
+ }
+
+ // Try English first
+ for (const QCheckBox *checkbox : m_languageCheckboxes) {
+ if (checkbox->property("languageCode").toString() == u"eng"_s) {
+ return u"eng"_s;
+ }
+ }
+
+ // Fallback to first available
+ return m_languageCheckboxes.first()->property("languageCode").toString();
+}
+
+void OcrLanguageSelector::updateCheckboxEnabledStates()
+{
+ const QStringList selected = selectedLanguages();
+ const int count = selected.size();
+
+ // If we have max languages selected, disable all unchecked checkboxes
+ // If we have less than max, enable all checkboxes
+ for (QCheckBox *checkbox : m_languageCheckboxes) {
+ if (checkbox->isChecked()) {
+ checkbox->setEnabled(true);
+ } else {
+ checkbox->setEnabled(count < OcrManager::MAX_OCR_LANGUAGES);
+ }
+ }
+}
+
+void OcrLanguageSelector::blockSignalsAndSetChecked(QCheckBox *checkbox, bool checked)
+{
+ m_blockSignals = true;
+ checkbox->setChecked(checked);
+ m_blockSignals = false;
+}
+
+#include "moc_OcrLanguageSelector.cpp"
\ No newline at end of file
diff --git a/src/Gui/SettingsDialog/OcrLanguageSelector.h b/src/Gui/SettingsDialog/OcrLanguageSelector.h
new file mode 100644
index 000000000..59b1a3d42
--- /dev/null
+++ b/src/Gui/SettingsDialog/OcrLanguageSelector.h
@@ -0,0 +1,111 @@
+/*
+ * SPDX-FileCopyrightText: 2025 Jhair Paris <dev@jhairparis.com>
+ *
+ * SPDX-License-Identifier: LGPL-2.0-or-later
+ */
+
+#ifndef OCRLANGUAGESELECTOR_H
+#define OCRLANGUAGESELECTOR_H
+
+#include <QCheckBox>
+#include <QVBoxLayout>
+#include <QWidget>
+
+class OcrManager;
+
+/**
+ * @brief Specialized widget for OCR language selection with multi-language support
+ *
+ * This widget encapsulates all the logic for OCR language selection:
+ * - Displays available languages as checkboxes (excluding 'osd')
+ * - Enforces limits: minimum 1, maximum languages defined by OcrManager
+ * - Handles defaults: English preferred, fallback to first available
+ * - Follows KConfigDialog pattern: no auto-persistence, explicit save/update methods
+ * - Updates dynamically when OCR manager state changes
+ */
+class OcrLanguageSelector : public QWidget
+{
+ Q_OBJECT
+ Q_PROPERTY(QStringList selectedLanguages READ selectedLanguages WRITE setSelectedLanguages NOTIFY selectedLanguagesChanged USER true)
+ Q_PROPERTY(bool isDefault READ isDefault NOTIFY selectedLanguagesChanged)
+ Q_PROPERTY(bool hasChanges READ hasChanges NOTIFY selectedLanguagesChanged)
+
+public:
+ explicit OcrLanguageSelector(QWidget *parent = nullptr);
+ ~OcrLanguageSelector() override;
+
+ /**
+ * @brief Get currently selected language codes
+ * @return List of selected language codes (e.g., ["eng", "spa"])
+ */
+ QStringList selectedLanguages() const;
+
+ /**
+ * @brief Set selected languages
+ * @param languages List of language codes to select
+ */
+ void setSelectedLanguages(const QStringList &languages);
+
+ /**
+ * @brief Check if current selection is the default state
+ * @return true if selection represents default configuration
+ */
+ bool isDefault() const;
+
+ /**
+ * @brief Check if there are unsaved changes
+ * @return true if current selection differs from saved configuration
+ */
+ bool hasChanges() const;
+
+ /**
+ * @brief Apply default language selection
+ * Selects English if available, otherwise first available language
+ */
+ void applyDefaults();
+
+ /**
+ * @brief Refresh the widget when OCR manager state changes
+ * Rebuilds checkboxes based on current available languages
+ */
+ void refresh();
+
+ /**
+ * @brief Save current selection to settings (called by KConfigDialog)
+ * Follows KConfigDialog pattern for saving changes
+ */
+ void saveSettings();
+
+ /**
+ * @brief Update widget to reflect current settings (called by KConfigDialog)
+ * Reloads settings when user cancels or dialog is reopened
+ */
+ void updateWidgets();
+
+Q_SIGNALS:
+ /**
+ * @brief Emitted when language selection changes
+ * @param languages New list of selected languages
+ */
+ void selectedLanguagesChanged(const QStringList &languages);
+
+private Q_SLOTS:
+ void onLanguageCheckboxChanged();
+ void onOcrManagerStatusChanged();
+
+private:
+ void setupLanguageCheckboxes();
+ void enforceSelectionLimits();
+ void updateCheckboxEnabledStates();
+ QString getDefaultLanguageCode() const;
+ void blockSignalsAndSetChecked(QCheckBox *checkbox, bool checked);
+
+ QVBoxLayout *m_layout;
+ QList<QCheckBox *> m_languageCheckboxes;
+ QMap<QString, QString> m_availableLanguages; // code -> display name
+ bool m_blockSignals;
+
+ OcrManager *m_ocrManager;
+};
+
+#endif // OCRLANGUAGESELECTOR_H
\ No newline at end of file
diff --git a/src/Gui/SettingsDialog/SettingsDialog.cpp b/src/Gui/SettingsDialog/SettingsDialog.cpp
index a19a47627..532bfd3c3 100644
--- a/src/Gui/SettingsDialog/SettingsDialog.cpp
+++ b/src/Gui/SettingsDialog/SettingsDialog.cpp
@@ -1,4 +1,5 @@
/*
+ * SPDX-FileCopyrightText: 2025 Jhair Paris <dev@jhairparis.com>
* SPDX-FileCopyrightText: 2019 David Redondo <kde@david-redondo.de>
* SPDX-FileCopyrightText: 2015 Boudhayan Gupta <bgupta@kde.org>
*
@@ -9,8 +10,9 @@
#include "GeneralOptionsPage.h"
#include "ImageSaveOptionsPage.h"
-#include "VideoSaveOptionsPage.h"
+#include "OcrLanguageSelector.h"
#include "ShortcutsOptionsPage.h"
+#include "VideoSaveOptionsPage.h"
#include "settings.h"
#include <QFontDatabase>
@@ -38,6 +40,9 @@ SettingsDialog::SettingsDialog(QWidget *parent)
connect(m_shortcutsPage, &ShortcutsOptionsPage::shortCutsChanged, this, [this] {
updateButtons();
});
+ connect(m_generalPage, &GeneralOptionsPage::ocrLanguageChanged, this, [this] {
+ updateButtons();
+ });
connect(this, &KConfigDialog::currentPageChanged, this, &SettingsDialog::updateButtons);
}
@@ -72,18 +77,20 @@ void SettingsDialog::showEvent(QShowEvent *event)
bool SettingsDialog::hasChanged()
{
- return m_shortcutsPage->isModified() || KConfigDialog::hasChanged();
+ return m_shortcutsPage->isModified() || m_generalPage->ocrLanguageSelector()->hasChanges() || KConfigDialog::hasChanged();
}
bool SettingsDialog::isDefault()
{
- return currentPage()->name() != i18n("Shortcuts") && KConfigDialog::isDefault();
+ return currentPage()->name() != i18n("Shortcuts") && m_generalPage->ocrLanguageSelector()->isDefault() && KConfigDialog::isDefault();
}
void SettingsDialog::updateSettings()
{
KConfigDialog::updateSettings();
m_shortcutsPage->saveChanges();
+
+ m_generalPage->ocrLanguageSelector()->saveSettings();
}
void SettingsDialog::updateWidgets()
@@ -91,6 +98,7 @@ void SettingsDialog::updateWidgets()
KConfigDialog::updateWidgets();
m_shortcutsPage->resetChanges();
+ m_generalPage->ocrLanguageSelector()->updateWidgets();
m_generalPage->refreshOcrLanguageSettings();
}
@@ -98,6 +106,9 @@ void SettingsDialog::updateWidgetsDefault()
{
KConfigDialog::updateWidgetsDefault();
m_shortcutsPage->defaults();
+
+ m_generalPage->ocrLanguageSelector()->applyDefaults();
+ m_generalPage->refreshOcrLanguageSettings();
}
#include "moc_SettingsDialog.cpp"
diff --git a/src/Gui/SettingsDialog/spectacle.kcfg b/src/Gui/SettingsDialog/spectacle.kcfg
index 4517e2344..2062f7cc4 100644
--- a/src/Gui/SettingsDialog/spectacle.kcfg
+++ b/src/Gui/SettingsDialog/spectacle.kcfg
@@ -70,8 +70,8 @@
</choices>
<default>UntilClosed</default>
</entry>
- <entry name="ocrLanguage" type="String">
- <label>Default language for OCR text recognition</label>
+ <entry name="ocrLanguages" type="StringList">
+ <label>Default languages for OCR text recognition</label>
<default>eng</default>
</entry>
</group>
diff --git a/src/Gui/ViewerPage.qml b/src/Gui/ViewerPage.qml
index 602e4431b..133793964 100644
--- a/src/Gui/ViewerPage.qml
+++ b/src/Gui/ViewerPage.qml
@@ -61,11 +61,13 @@ EmptyPage {
visible: action.enabled
action: CopyImageAction {}
}
+
TtToolButton {
display: TtToolButton.IconOnly
- visible: action.enabled && SpectacleCore.ocrAvailable
+ visible: !SpectacleCore.videoMode && SpectacleCore.ocrAvailable
action: OcrAction {}
}
+
// We only show this in video mode to save space in screenshot mode
TtToolButton {
visible: SpectacleCore.videoMode
--
GitLab