feat(admin): add tessdata language management for OCR and download support (#5519)

# Description of Changes

### What was changed
- Added new admin-only API endpoints to:
  - List installed tessdata OCR languages
- Fetch available tessdata languages from the official Tesseract
repository
- Download selected tessdata language files directly into the configured
tessdata directory
- Implemented server-side validation, safe language name handling, and
directory writability checks.
- Extended the Admin Advanced Settings UI to:
  - Display installed tessdata languages
  - Show available remote languages not yet installed
- Allow selecting and downloading additional languages via a
multi-select UI
- Gracefully fall back to manual download links when the tessdata
directory is not writable
- Added new i18n strings for all related UI states (loading, success,
error, permission warnings).

### Why the change was made
- Managing OCR languages previously required manual filesystem
interaction.
- This change improves usability for administrators by enabling in-app
management of tessdata languages while maintaining security constraints.
- The writable directory check and manual fallback ensure compatibility
with restricted or containerized environments.


<img width="1282" height="832" alt="image"
src="https://github.com/user-attachments/assets/aa958730-0ffb-4fd6-9af8-87c527a476e4"
/>


---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Ludy
2026-01-21 22:10:47 +00:00
committed by GitHub
co-authored by Copilot
parent 23f872823d
commit 1436821a3a
4 changed files with 922 additions and 2 deletions
@@ -0,0 +1,373 @@
package stirling.software.proprietary.security.controller.api;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mockito;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import stirling.software.common.configuration.RuntimePathConfig;
class UIDataTessdataControllerTest {
@Test
void downloadTessdataLanguages_withEmptyList_returnsBadRequest() throws Exception {
RuntimePathConfig runtimePathConfig = Mockito.mock(RuntimePathConfig.class);
Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn("ignored/path");
UIDataTessdataController controller =
new UIDataTessdataController(runtimePathConfig) {
@Override
protected List<String> getRemoteTessdataLanguages() {
return List.of("eng");
}
};
MockMvc mvc = MockMvcBuilders.standaloneSetup(controller).build();
mvc.perform(
post("/api/v1/ui-data/tessdata/download")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"languages\":[]}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").value("No languages provided for download"));
}
@Test
void downloadTessdataLanguages_blocksPathTraversal(@TempDir Path tempDir) throws Exception {
RuntimePathConfig runtimePathConfig = Mockito.mock(RuntimePathConfig.class);
Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString());
UIDataTessdataController controller =
new UIDataTessdataController(runtimePathConfig) {
@Override
protected List<String> getRemoteTessdataLanguages() {
return List.of("eng");
}
};
MockMvc mvc = MockMvcBuilders.standaloneSetup(controller).build();
mvc.perform(
post("/api/v1/ui-data/tessdata/download")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"languages\":[\"../evil\"]}"))
.andExpect(status().isBadGateway())
.andExpect(jsonPath("$.downloaded").isArray())
.andExpect(jsonPath("$.downloaded").isEmpty())
.andExpect(jsonPath("$.failed[0]").value("../evil"));
// Ensure no file was written outside the tessdata directory
Path escapedPath = tempDir.resolve("../evil.traineddata").normalize();
assert Files.notExists(escapedPath) : "Traversal path should not be written";
}
@Test
void downloadTessdataLanguages_rejectsUnknownLanguage(@TempDir Path tempDir) throws Exception {
RuntimePathConfig runtimePathConfig = Mockito.mock(RuntimePathConfig.class);
Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString());
UIDataTessdataController controller =
new UIDataTessdataController(runtimePathConfig) {
@Override
protected List<String> getRemoteTessdataLanguages() {
return List.of("eng");
}
};
MockMvc mvc = MockMvcBuilders.standaloneSetup(controller).build();
mvc.perform(
post("/api/v1/ui-data/tessdata/download")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"languages\":[\"fra\"]}"))
.andExpect(status().isBadGateway())
.andExpect(jsonPath("$.downloaded").isEmpty())
.andExpect(jsonPath("$.failed[0]").value("fra"));
}
@Test
void downloadTessdataLanguages_successAndFailureMixed(@TempDir Path tempDir) throws Exception {
RuntimePathConfig runtimePathConfig = Mockito.mock(RuntimePathConfig.class);
Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString());
UIDataTessdataController controller =
new UIDataTessdataController(runtimePathConfig) {
@Override
protected List<String> getRemoteTessdataLanguages() {
return List.of("eng", "fra");
}
@Override
protected boolean downloadLanguageFile(
String safeLang, Path targetFile, String downloadUrl) {
if ("eng".equals(safeLang)) {
try {
Files.writeString(targetFile, "dummy");
return true;
} catch (Exception e) {
return false;
}
}
return false;
}
};
MockMvc mvc = MockMvcBuilders.standaloneSetup(controller).build();
mvc.perform(
post("/api/v1/ui-data/tessdata/download")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"languages\":[\"eng\",\"fra\"]}"))
.andExpect(status().isMultiStatus())
.andExpect(jsonPath("$.downloaded[0]").value("eng"))
.andExpect(jsonPath("$.failed[0]").value("fra"));
}
@Test
void downloadTessdataLanguages_handlesInvalidSanitizedLanguage(@TempDir Path tempDir)
throws Exception {
RuntimePathConfig runtimePathConfig = Mockito.mock(RuntimePathConfig.class);
Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString());
UIDataTessdataController controller =
new UIDataTessdataController(runtimePathConfig) {
@Override
protected List<String> getRemoteTessdataLanguages() {
return List.of("eng");
}
};
MockMvc mvc = MockMvcBuilders.standaloneSetup(controller).build();
mvc.perform(
post("/api/v1/ui-data/tessdata/download")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"languages\":[\"eng/\"]}"))
.andExpect(status().isBadGateway())
.andExpect(jsonPath("$.downloaded").isEmpty())
.andExpect(jsonPath("$.failed[0]").value("eng/"));
}
@Test
void downloadTessdataLanguages_returnsForbiddenWhenNotWritable(@TempDir Path tempDir)
throws Exception {
RuntimePathConfig runtimePathConfig = Mockito.mock(RuntimePathConfig.class);
Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString());
UIDataTessdataController controller =
new UIDataTessdataController(runtimePathConfig) {
@Override
protected boolean isWritableDirectory(Path dir) {
return false;
}
};
MockMvc mvc = MockMvcBuilders.standaloneSetup(controller).build();
mvc.perform(
post("/api/v1/ui-data/tessdata/download")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"languages\":[\"eng\"]}"))
.andExpect(status().isForbidden());
}
@Test
void downloadTessdataLanguages_handlesNetworkFailure(@TempDir Path tempDir) throws Exception {
RuntimePathConfig runtimePathConfig = Mockito.mock(RuntimePathConfig.class);
Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString());
UIDataTessdataController controller =
new UIDataTessdataController(runtimePathConfig) {
@Override
protected List<String> getRemoteTessdataLanguages() {
return List.of("eng");
}
@Override
protected boolean downloadLanguageFile(
String safeLang, Path targetFile, String downloadUrl) {
return false; // simulate network failure
}
};
MockMvc mvc = MockMvcBuilders.standaloneSetup(controller).build();
mvc.perform(
post("/api/v1/ui-data/tessdata/download")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"languages\":[\"eng\"]}"))
.andExpect(status().isBadGateway())
.andExpect(jsonPath("$.downloaded").isArray())
.andExpect(jsonPath("$.downloaded").isEmpty())
.andExpect(jsonPath("$.failed[0]").value("eng"));
}
@Test
void downloadTessdataLanguages_allSuccess(@TempDir Path tempDir) throws Exception {
RuntimePathConfig runtimePathConfig = Mockito.mock(RuntimePathConfig.class);
Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString());
UIDataTessdataController controller =
new UIDataTessdataController(runtimePathConfig) {
@Override
protected List<String> getRemoteTessdataLanguages() {
return List.of("eng");
}
@Override
protected boolean downloadLanguageFile(
String safeLang, Path targetFile, String downloadUrl) {
try {
Files.writeString(targetFile, "dummy");
return true;
} catch (IOException e) {
return false;
}
}
};
MockMvc mvc = MockMvcBuilders.standaloneSetup(controller).build();
mvc.perform(
post("/api/v1/ui-data/tessdata/download")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"languages\":[\"eng\"]}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.downloaded[0]").value("eng"))
.andExpect(jsonPath("$.failed").isArray())
.andExpect(jsonPath("$.failed").isEmpty());
}
@Test
void tessdataLanguages_returnsInstalledAvailableAndWritable(@TempDir Path tempDir)
throws Exception {
Files.createFile(tempDir.resolve("eng.traineddata"));
Files.createFile(tempDir.resolve("deu.traineddata"));
Files.createFile(tempDir.resolve("osd.traineddata")); // should be filtered
RuntimePathConfig runtimePathConfig = Mockito.mock(RuntimePathConfig.class);
Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString());
UIDataTessdataController controller =
new UIDataTessdataController(runtimePathConfig) {
@Override
protected List<String> getRemoteTessdataLanguages() {
return List.of("eng", "fra");
}
};
MockMvc mvc = MockMvcBuilders.standaloneSetup(controller).build();
mvc.perform(get("/api/v1/ui-data/tessdata-languages"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.installed[0]").value("deu"))
.andExpect(jsonPath("$.installed[1]").value("eng"))
.andExpect(jsonPath("$.available[0]").value("eng"))
.andExpect(jsonPath("$.available[1]").value("fra"))
.andExpect(jsonPath("$.writable").value(true));
}
@Test
void tessdataLanguages_emptyDirectory(@TempDir Path tempDir) throws Exception {
RuntimePathConfig runtimePathConfig = Mockito.mock(RuntimePathConfig.class);
Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString());
UIDataTessdataController controller =
new UIDataTessdataController(runtimePathConfig) {
@Override
protected List<String> getRemoteTessdataLanguages() {
return List.of("eng");
}
};
MockMvc mvc = MockMvcBuilders.standaloneSetup(controller).build();
mvc.perform(get("/api/v1/ui-data/tessdata-languages"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.installed").isArray())
.andExpect(jsonPath("$.installed").isEmpty())
.andExpect(jsonPath("$.available[0]").value("eng"))
.andExpect(jsonPath("$.writable").value(true));
}
@Test
void tessdataLanguages_nonTraineddataFilesAreIgnored(@TempDir Path tempDir) throws Exception {
Files.createFile(tempDir.resolve("notes.txt"));
RuntimePathConfig runtimePathConfig = Mockito.mock(RuntimePathConfig.class);
Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString());
UIDataTessdataController controller =
new UIDataTessdataController(runtimePathConfig) {
@Override
protected List<String> getRemoteTessdataLanguages() {
return List.of("eng");
}
};
MockMvc mvc = MockMvcBuilders.standaloneSetup(controller).build();
mvc.perform(get("/api/v1/ui-data/tessdata-languages"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.installed").isArray())
.andExpect(jsonPath("$.installed").isEmpty())
.andExpect(jsonPath("$.writable").value(true));
}
@Test
void tessdataLanguages_handlesNonExistentDirectory(@TempDir Path tempDir) throws Exception {
Path missingDir = tempDir.resolve("missing");
RuntimePathConfig runtimePathConfig = Mockito.mock(RuntimePathConfig.class);
Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(missingDir.toString());
UIDataTessdataController controller =
new UIDataTessdataController(runtimePathConfig) {
@Override
protected List<String> getRemoteTessdataLanguages() {
return List.of("eng");
}
};
MockMvc mvc = MockMvcBuilders.standaloneSetup(controller).build();
mvc.perform(get("/api/v1/ui-data/tessdata-languages"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.installed").isArray())
.andExpect(jsonPath("$.installed").isEmpty())
.andExpect(jsonPath("$.writable").value(true));
}
@Test
void tessdataLanguages_marksNotWritable(@TempDir Path tempDir) throws Exception {
RuntimePathConfig runtimePathConfig = Mockito.mock(RuntimePathConfig.class);
Mockito.when(runtimePathConfig.getTessDataPath()).thenReturn(tempDir.toString());
UIDataTessdataController controller =
new UIDataTessdataController(runtimePathConfig) {
@Override
protected boolean isWritableDirectory(Path dir) {
return false;
}
@Override
protected List<String> getRemoteTessdataLanguages() {
return List.of("eng");
}
};
MockMvc mvc = MockMvcBuilders.standaloneSetup(controller).build();
mvc.perform(get("/api/v1/ui-data/tessdata-languages"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.writable").value(false));
}
}