diff --git a/build-logic/src/main/kotlin/org.jabref.gradle.base.dependency-rules.gradle.kts b/build-logic/src/main/kotlin/org.jabref.gradle.base.dependency-rules.gradle.kts index f7768bb0f9b..72e24422ab9 100644 --- a/build-logic/src/main/kotlin/org.jabref.gradle.base.dependency-rules.gradle.kts +++ b/build-logic/src/main/kotlin/org.jabref.gradle.base.dependency-rules.gradle.kts @@ -437,6 +437,20 @@ extraJavaModuleInfo { requires("java.naming") requires("java.sql") } + module("net.sourceforge.tess4j:tess4j", "net.sourceforge.tess4j") { + exportAllPackages() + requireAllDefinedDependencies() + requires("java.desktop") + overrideModuleName() + } + module("net.sourceforge.lept4j:lept4j", "net.sourceforge.lept4j") { + exportAllPackages() + requireAllDefinedDependencies() + requires("java.desktop") + requires("java.logging") + overrideModuleName() + } + module("org.apache.pdfbox:pdfbox", "org.apache.pdfbox") { exportAllPackages() requireAllDefinedDependencies() @@ -447,6 +461,36 @@ extraJavaModuleInfo { requireAllDefinedDependencies() requires("java.xml") } + + module("org.apache.pdfbox:pdfbox-tools", "org.apache.pdfbox.tools") { + requireAllDefinedDependencies() + exportAllPackages() + requires("java.desktop") + } + module("org.apache.pdfbox:pdfbox-debugger", "org.apache.pdfbox.debugger") { + requireAllDefinedDependencies() + exportAllPackages() + } + module("org.apache.pdfbox:jbig2-imageio", "org.apache.pdfbox.jbig2") { + exportAllPackages() + requireAllDefinedDependencies() + requires("java.desktop") + overrideModuleName() + } + module("org.jboss:jboss-vfs", "org.jboss.vfs") { + preserveExisting() + overrideModuleName() + } + module("org.jboss.logging:jboss-logging", "org.jboss.logging") { + preserveExisting() + overrideModuleName() + } + module("com.github.jai-imageio:jai-imageio-core", "com.github.jaiimageio.core") { + exportAllPackages() + requireAllDefinedDependencies() + requires("java.desktop") + overrideModuleName() + } module("com.squareup.okio:okio-jvm", "okio") { exportAllPackages() requireAllDefinedDependencies() diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 78cb6e16a49..36b0948f624 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=bd71102213493060956ec229d946beee57158dbd89d0e62b91bca0fa2c5f3531 -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +distributionUrl=https\://files.jabref.org/gradle-9.1.0-jabref-14.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/jabgui/build.gradle.kts b/jabgui/build.gradle.kts index 6dd0ed0aa90..cec1f435136 100644 --- a/jabgui/build.gradle.kts +++ b/jabgui/build.gradle.kts @@ -80,7 +80,7 @@ dependencies { implementation ("org.apache.pdfbox:pdfbox") - // implementation("net.java.dev.jna:jna") + implementation("net.java.dev.jna:jna-jpms") implementation("net.java.dev.jna:jna-platform") implementation("org.eclipse.jgit:org.eclipse.jgit") diff --git a/jabgui/src/main/java/org/jabref/gui/fieldeditors/LinkedFilesEditor.java b/jabgui/src/main/java/org/jabref/gui/fieldeditors/LinkedFilesEditor.java index ff6992be048..e610cf88120 100644 --- a/jabgui/src/main/java/org/jabref/gui/fieldeditors/LinkedFilesEditor.java +++ b/jabgui/src/main/java/org/jabref/gui/fieldeditors/LinkedFilesEditor.java @@ -51,6 +51,7 @@ import org.jabref.logic.integrity.FieldCheckers; import org.jabref.logic.journals.JournalAbbreviationRepository; import org.jabref.logic.l10n.Localization; +import org.jabref.logic.ocr.OcrService; import org.jabref.logic.util.TaskExecutor; import org.jabref.model.database.BibDatabaseContext; import org.jabref.model.entry.BibEntry; @@ -80,6 +81,8 @@ public class LinkedFilesEditor extends HBox implements FieldEditorFX { @Inject private JournalAbbreviationRepository abbreviationRepository; @Inject private TaskExecutor taskExecutor; @Inject private UndoManager undoManager; + @Inject private OcrService ocrService; + private LinkedFilesEditorViewModel viewModel; @@ -325,7 +328,9 @@ private void handleItemMouseClick(LinkedFileViewModel linkedFile, MouseEvent eve bibEntry, viewModel, contextCommandFactory, - multiContextCommandFactory + multiContextCommandFactory, + taskExecutor, + ocrService ); ContextMenu contextMenu = contextMenuFactory.createForSelection(listView.getSelectionModel().getSelectedItems()); diff --git a/jabgui/src/main/java/org/jabref/gui/fieldeditors/contextmenu/ContextMenuFactory.java b/jabgui/src/main/java/org/jabref/gui/fieldeditors/contextmenu/ContextMenuFactory.java index a6b3262450e..cfd3aad5b8b 100644 --- a/jabgui/src/main/java/org/jabref/gui/fieldeditors/contextmenu/ContextMenuFactory.java +++ b/jabgui/src/main/java/org/jabref/gui/fieldeditors/contextmenu/ContextMenuFactory.java @@ -2,6 +2,7 @@ import javafx.collections.ObservableList; import javafx.scene.control.ContextMenu; +import javafx.scene.control.MenuItem; import javafx.scene.control.SeparatorMenuItem; import org.jabref.gui.DialogService; @@ -10,7 +11,11 @@ import org.jabref.gui.copyfiles.CopySingleFileAction; import org.jabref.gui.fieldeditors.LinkedFileViewModel; import org.jabref.gui.fieldeditors.LinkedFilesEditorViewModel; +import org.jabref.gui.linkedfile.OcrAction; import org.jabref.gui.preferences.GuiPreferences; +import org.jabref.logic.l10n.Localization; +import org.jabref.logic.ocr.OcrService; +import org.jabref.logic.util.TaskExecutor; import org.jabref.model.database.BibDatabaseContext; import org.jabref.model.entry.BibEntry; @@ -25,6 +30,8 @@ public class ContextMenuFactory { private final LinkedFilesEditorViewModel viewModel; private final SingleContextCommandFactory singleCommandFactory; private final MultiContextCommandFactory multiCommandFactory; + private final TaskExecutor taskExecutor; + private final OcrService ocrService; public ContextMenuFactory(DialogService dialogService, GuiPreferences preferences, @@ -32,7 +39,9 @@ public ContextMenuFactory(DialogService dialogService, ObservableOptionalValue bibEntry, LinkedFilesEditorViewModel viewModel, SingleContextCommandFactory singleCommandFactory, - MultiContextCommandFactory multiCommandFactory) { + MultiContextCommandFactory multiCommandFactory, + TaskExecutor taskExecutor, + OcrService ocrService) { this.dialogService = dialogService; this.preferences = preferences; this.databaseContext = databaseContext; @@ -40,6 +49,8 @@ public ContextMenuFactory(DialogService dialogService, this.viewModel = viewModel; this.singleCommandFactory = singleCommandFactory; this.multiCommandFactory = multiCommandFactory; + this.taskExecutor = taskExecutor; + this.ocrService = ocrService; } public ContextMenu createForSelection(ObservableList selectedFiles) { @@ -86,9 +97,46 @@ private ContextMenu createContextMenuForFile(LinkedFileViewModel linkedFile) { factory.createMenuItem(StandardActions.DELETE_FILE, singleCommandFactory.build(StandardActions.DELETE_FILE, linkedFile)) ); + // Add OCR menu item for PDF files + if (linkedFile.getFile().getFileType().equalsIgnoreCase("pdf")) { + menu.getItems().add(new SeparatorMenuItem()); + + MenuItem ocrItem = createOcrMenuItem(linkedFile); + menu.getItems().add(ocrItem); + } + return menu; } + /** + * Creates the OCR menu item for a PDF file. + * The menu item is only enabled if the PDF file exists on disk. + * + * @param linkedFile The linked PDF file + * @return MenuItem configured for OCR action + */ + private MenuItem createOcrMenuItem(LinkedFileViewModel linkedFile) { + MenuItem ocrItem = new MenuItem(Localization.lang("Extract text (OCR)")); + + // Create the OCR action + OcrAction ocrAction = new OcrAction( + linkedFile.getFile(), + databaseContext, + dialogService, + preferences.getFilePreferences(), + taskExecutor, + ocrService + ); + + // Set the action to execute when clicked + ocrItem.setOnAction(event -> ocrAction.execute()); + + // Disable if the action is not executable (file doesn't exist) + ocrItem.disableProperty().bind(ocrAction.executableProperty().not()); + + return ocrItem; + } + @FunctionalInterface public interface SingleContextCommandFactory { ContextAction build(StandardActions action, LinkedFileViewModel file); diff --git a/jabgui/src/main/java/org/jabref/gui/linkedfile/OcrAction.java b/jabgui/src/main/java/org/jabref/gui/linkedfile/OcrAction.java new file mode 100644 index 00000000000..bc96d0a5bb0 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/linkedfile/OcrAction.java @@ -0,0 +1,115 @@ +package org.jabref.gui.linkedfile; + +import org.jabref.gui.DialogService; +import org.jabref.gui.StateManager; +import org.jabref.gui.actions.Action; +import org.jabref.gui.actions.ActionHelper; +import org.jabref.gui.actions.SimpleCommand; +import org.jabref.logic.util.BackgroundTask; +import org.jabref.logic.util.TaskExecutor; +import org.jabref.logic.l10n.Localization; +import org.jabref.logic.ocr.OcrService; +import org.jabref.logic.ocr.OcrResult; +import org.jabref.logic.ocr.OcrException; +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.LinkedFile; +import org.jabref.logic.FilePreferences; + +import java.nio.file.Path; +import java.util.Optional; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Action for performing OCR (Optical Character Recognition) on linked PDF files. + */ +public class OcrAction extends SimpleCommand { + private static final Logger LOGGER = LoggerFactory.getLogger(OcrAction.class); + + private final LinkedFile linkedFile; + private final BibDatabaseContext databaseContext; + private final DialogService dialogService; + private final FilePreferences filePreferences; + private final TaskExecutor taskExecutor; + private final OcrService ocrService; + + public OcrAction(LinkedFile linkedFile, + BibDatabaseContext databaseContext, + DialogService dialogService, + FilePreferences filePreferences, + TaskExecutor taskExecutor, + OcrService ocrService) { + this.linkedFile = linkedFile; + this.databaseContext = databaseContext; + this.dialogService = dialogService; + this.filePreferences = filePreferences; + this.taskExecutor = taskExecutor; + + // Only executable for existing PDF files + this.executable.set( + linkedFile.getFileType().equalsIgnoreCase("pdf") && + linkedFile.findIn(databaseContext, filePreferences).isPresent() + ); + this.ocrService = ocrService; + } + + @Override + public void execute() { + Optional filePath = linkedFile.findIn(databaseContext, filePreferences); + + if (filePath.isEmpty()) { + dialogService.showErrorDialogAndWait( + Localization.lang("File not found"), + Localization.lang("Could not locate the PDF file on disk.") + ); + return; + } + + dialogService.notify(Localization.lang("Performing OCR...")); + + BackgroundTask task = BackgroundTask.wrap(() -> { + return ocrService.performOcr(filePath.get()); + }) + .showToUser(true) // Show in task list + .withInitialMessage(Localization.lang("Performing OCR on %0", linkedFile.getLink())); + + task.onSuccess(result -> { + // Use pattern matching with the sealed class + switch (result) { + case OcrResult.Success success -> { + String extractedText = success.text(); + if (extractedText.isEmpty()) { + dialogService.showInformationDialogAndWait( + Localization.lang("OCR Complete"), + Localization.lang("No text was found in the PDF.") + ); + } else { + // Show preview + String preview = extractedText.length() > 1000 + ? extractedText.substring(0, 1000) + "..." + : extractedText; + + dialogService.showInformationDialogAndWait( + Localization.lang("OCR Result"), + preview + ); + } + } + case OcrResult.Failure failure -> { + dialogService.showErrorDialogAndWait( + Localization.lang("OCR failed"), + failure.errorMessage() + ); + } + } + }) + .onFailure(exception -> { + dialogService.showErrorDialogAndWait( + Localization.lang("OCR failed"), + exception.getMessage() + ); + }) + .executeWith(taskExecutor); + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/preferences/ai/AiTab.java b/jabgui/src/main/java/org/jabref/gui/preferences/ai/AiTab.java index 676e9243e8c..ddedac52aa6 100644 --- a/jabgui/src/main/java/org/jabref/gui/preferences/ai/AiTab.java +++ b/jabgui/src/main/java/org/jabref/gui/preferences/ai/AiTab.java @@ -2,7 +2,13 @@ import java.util.Optional; +import java.io.File; + import javafx.application.Platform; +import javafx.scene.layout.GridPane; +import javafx.stage.DirectoryChooser; +import javafx.geometry.Pos; +import javafx.geometry.Insets; import javafx.beans.binding.Bindings; import javafx.beans.property.ReadOnlyBooleanProperty; import javafx.fxml.FXML; @@ -13,7 +19,11 @@ import javafx.scene.control.TabPane; import javafx.scene.control.TextArea; import javafx.scene.control.TextField; +import javafx.scene.control.Label; +import javafx.scene.control.Separator; import javafx.scene.layout.VBox; +import javafx.scene.layout.HBox; +import javafx.scene.text.Text; import org.jabref.gui.actions.ActionFactory; import org.jabref.gui.actions.StandardActions; @@ -26,6 +36,7 @@ import org.jabref.logic.l10n.Localization; import org.jabref.model.ai.AiProvider; import org.jabref.model.ai.EmbeddingModel; +import org.jabref.model.strings.StringUtil; import com.airhacks.afterburner.views.ViewLoader; import com.dlsc.unitfx.IntegerInputField; @@ -56,6 +67,7 @@ public class AiTab extends AbstractPreferenceTabView implements @FXML private IntegerInputField documentSplitterOverlapSizeTextField; @FXML private IntegerInputField ragMaxResultsCountTextField; @FXML private TextField ragMinScoreTextField; + @FXML private TextField tessdataPathField; @FXML private TabPane templatesTabPane; @FXML private Tab systemMessageForChattingTab; @@ -79,6 +91,7 @@ public class AiTab extends AbstractPreferenceTabView implements @FXML private Button generalSettingsHelp; @FXML private Button expertSettingsHelp; @FXML private Button templatesHelp; + @FXML private Button tessdataBrowseButton; private final ControlsFxVisualizer visualizer = new ControlsFxVisualizer(); @@ -91,6 +104,8 @@ public AiTab() { public void initialize() { this.viewModel = new AiTabViewModel(preferences); + setValues(); + initializeEnableAi(); initializeAiProvider(); initializeChatModel(); @@ -98,6 +113,7 @@ public void initialize() { initializeExpertSettings(); initializeValidations(); initializeTemplates(); + initializeOcr(); initializeHelp(); } @@ -108,6 +124,27 @@ private void initializeHelp() { actionFactory.configureIconButton(StandardActions.HELP, new HelpAction(HelpFile.AI_TEMPLATES, dialogService, preferences.getExternalApplicationsPreferences()), templatesHelp); } + @FXML + private void selectTessdataDirectory() { + DirectoryChooser directoryChooser = new DirectoryChooser(); + directoryChooser.setTitle(Localization.lang("Select tessdata directory")); + + String currentPath = viewModel.tessdataPathProperty().get(); + if (!StringUtil.isBlank(currentPath)) { + File currentDir = new File(currentPath); + if (currentDir.exists() && currentDir.isDirectory()) { + directoryChooser.setInitialDirectory( + currentDir.getName().equals("tessdata") ? currentDir.getParentFile() : currentDir + ); + } + } + + File selectedDirectory = directoryChooser.showDialog(tessdataPathField.getScene().getWindow()); + if (selectedDirectory != null) { + viewModel.tessdataPathProperty().set(selectedDirectory.getAbsolutePath()); + } + } + private void initializeTemplates() { systemMessageTextArea.textProperty().bindBidirectional(viewModel.getTemplateSources().get(AiTemplate.CHATTING_SYSTEM_MESSAGE)); userMessageTextArea.textProperty().bindBidirectional(viewModel.getTemplateSources().get(AiTemplate.CHATTING_USER_MESSAGE)); @@ -252,6 +289,12 @@ private void initializeEnableAi() { autoGenerateEmbeddings.disableProperty().bind(viewModel.disableAutoGenerateEmbeddings()); } + private void initializeOcr() { + tessdataPathField.textProperty().bindBidirectional(viewModel.tessdataPathProperty()); + tessdataPathField.disableProperty().bind(viewModel.disableBasicSettingsProperty()); + tessdataBrowseButton.disableProperty().bind(viewModel.disableBasicSettingsProperty()); + } + @Override public String getTabName() { return Localization.lang("AI"); @@ -298,4 +341,5 @@ public Optional getAiTemplate() { return Optional.empty(); } + } diff --git a/jabgui/src/main/java/org/jabref/gui/preferences/ai/AiTabViewModel.java b/jabgui/src/main/java/org/jabref/gui/preferences/ai/AiTabViewModel.java index 7dc5d7eb88c..a8cd2ddf5b7 100644 --- a/jabgui/src/main/java/org/jabref/gui/preferences/ai/AiTabViewModel.java +++ b/jabgui/src/main/java/org/jabref/gui/preferences/ai/AiTabViewModel.java @@ -21,6 +21,7 @@ import org.jabref.gui.preferences.PreferenceTabViewModel; import org.jabref.gui.util.OptionalObjectProperty; +import org.jabref.logic.FilePreferences; import org.jabref.logic.ai.AiDefaultPreferences; import org.jabref.logic.ai.AiPreferences; import org.jabref.logic.ai.templates.AiTemplate; @@ -68,6 +69,9 @@ public class AiTabViewModel implements PreferenceTabViewModel { private final StringProperty huggingFaceApiKey = new SimpleStringProperty(); private final StringProperty gpt4AllApiKey = new SimpleStringProperty(); + private final StringProperty tessdataPath = new SimpleStringProperty(); + private final FilePreferences filePreferences; + private final BooleanProperty customizeExpertSettings = new SimpleBooleanProperty(); private final ListProperty embeddingModelsList = @@ -126,6 +130,8 @@ public AiTabViewModel(CliPreferences preferences) { this.aiPreferences = preferences.getAiPreferences(); + this.filePreferences = preferences.getFilePreferences(); + this.enableAi.addListener((_, _, newValue) -> { disableBasicSettings.set(!newValue); disableExpertSettings.set(!newValue || !customizeExpertSettings.get()); @@ -342,6 +348,8 @@ public void setValues() { documentSplitterOverlapSize.setValue(aiPreferences.getDocumentSplitterOverlapSize()); ragMaxResultsCount.setValue(aiPreferences.getRagMaxResultsCount()); ragMinScore.setValue(LocalizedNumbers.doubleToString(aiPreferences.getRagMinScore())); + + tessdataPath.setValue(filePreferences.getOcrTessdataPath()); } @Override @@ -386,6 +394,8 @@ public void storeSettings() { aiPreferences.setDocumentSplitterOverlapSize(documentSplitterOverlapSize.get()); aiPreferences.setRagMaxResultsCount(ragMaxResultsCount.get()); aiPreferences.setRagMinScore(LocalizedNumbers.stringToDouble(oldLocale, ragMinScore.get()).get()); + + filePreferences.setOcrTessdataPath(tessdataPath.get() == null ? "" : tessdataPath.get().trim()); } public void resetExpertSettings() { @@ -599,4 +609,8 @@ public ValidationStatus getRagMinScoreTypeValidationStatus() { public ValidationStatus getRagMinScoreRangeValidationStatus() { return ragMinScoreRangeValidator.getValidationStatus(); } + + public StringProperty tessdataPathProperty() { + return tessdataPath; + } } diff --git a/jabgui/src/main/java/org/jabref/gui/theme/ThemeManager.java b/jabgui/src/main/java/org/jabref/gui/theme/ThemeManager.java index d5300c82362..088a1e55130 100644 --- a/jabgui/src/main/java/org/jabref/gui/theme/ThemeManager.java +++ b/jabgui/src/main/java/org/jabref/gui/theme/ThemeManager.java @@ -121,7 +121,7 @@ private void applyDarkModeToWindow(Stage stage, boolean darkMode) { return; } - themeWindowManager.setDarkModeForWindowFrame(stage, darkMode); + // themeWindowManager.setDarkModeForWindowFrame(stage, darkMode); LOGGER.debug("Applied {} mode to window: {}", darkMode ? "dark" : "light", stage); } diff --git a/jabgui/src/main/resources/org/jabref/gui/preferences/ai/AiTab.fxml b/jabgui/src/main/resources/org/jabref/gui/preferences/ai/AiTab.fxml index d32fe92c2ce..89e61e80746 100644 --- a/jabgui/src/main/resources/org/jabref/gui/preferences/ai/AiTab.fxml +++ b/jabgui/src/main/resources/org/jabref/gui/preferences/ai/AiTab.fxml @@ -330,4 +330,33 @@ text="%Automatically generate summaries for new entries" HBox.hgrow="ALWAYS" maxWidth="Infinity"/> + + + +