Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d17a968
feat(ocr): make the Tesseract binary path configurable and bundle-aware
samuelsl27 Aug 2, 2026
5b07eb9
feat(ocr): ship a Tesseract runtime with the Windows desktop build
samuelsl27 Aug 2, 2026
ac3e096
test(ocr): cover the bundled-Tesseract lookup, and clean the bundle w…
samuelsl27 Aug 2, 2026
cb05995
fix(ocr): let -Languages add a model to a bundle that already exists
samuelsl27 Aug 3, 2026
a6034d1
merge: sincronizar con main del proyecto original
samuelsl27 Aug 3, 2026
99858bd
Merge branch 'main' into feature/ocr-embebido
samuelsl27 Aug 3, 2026
cbefb9c
refactor(ocr): use Spring Boot's ApplicationHome to find the install …
samuelsl27 Aug 3, 2026
e1d8bbe
Merge branch 'feature/ocr-embebido' of https://github.com/samuelsl27/…
samuelsl27 Aug 3, 2026
e5376c2
fix(ocr): point Tesseract at the tessdata the language list came from
samuelsl27 Aug 15, 2026
dee16bc
feat(ocr): install the OCR engine and language models on demand
samuelsl27 Aug 15, 2026
d9b6033
feat(ocr): publish the OCR runtime instead of shipping it in the inst…
samuelsl27 Aug 15, 2026
935d5a1
docs(ocr): describe on-demand installation, and the configs/ trap
samuelsl27 Aug 15, 2026
5b0373c
feat(ocr): install OCR and pick languages from the tool itself
samuelsl27 Aug 15, 2026
67c2bab
merge: sync with upstream main (129 commits)
samuelsl27 Aug 15, 2026
6d1cb90
feat(ocr): choose OCR and its languages in the Windows installer
samuelsl27 Aug 15, 2026
4e3446d
feat(ocr): add an OCR section to Settings, and fix what review found
samuelsl27 Aug 15, 2026
5563500
fix(ocr): stop a catalogue sending the server somewhere it should not go
samuelsl27 Aug 15, 2026
f49578b
fix(ocr): put the SSRF guard where every request has to pass
samuelsl27 Aug 16, 2026
bc193e7
fix(ocr): drop Win64 from the installer custom action, and add panel …
samuelsl27 Aug 16, 2026
1ca894e
fix(ocr): the installer page would never have been shown
samuelsl27 Aug 16, 2026
025d601
fix(ocr): the status endpoint deadlocked and never answered
samuelsl27 Aug 16, 2026
1ab63d4
fix(ocr): one source of truth for where the runtime lives, and stop O…
samuelsl27 Aug 16, 2026
8aa8b37
fix(ocr): render pages in greyscale so OCR fits in the heap
samuelsl27 Aug 16, 2026
73cd0d8
test(ocr): stop two path tests depending on what the host has installed
samuelsl27 Aug 16, 2026
f29db1c
chore(ocr): meet the repo's own gate for translations and formatting
samuelsl27 Aug 16, 2026
c3aae9a
merge: sincronizar con main del proyecto original
samuelsl27 Aug 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,12 @@ proprietary/build
stirling-pdf/build
frontend/editor/src-tauri/provisioner/target

# OCR runtime artefacts built by scripts/build-ocr-runtime.ps1 and published
# once, rather than shipped inside the installer (~37 MB archive).
dist/ocr/
# Left over from when the runtime was bundled into the installer.
frontend/editor/src-tauri/tesseract/

# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
Expand Down
20 changes: 20 additions & 0 deletions .taskfiles/desktop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,18 @@ tasks:
cmds:
- node scripts/build-provisioner.mjs

ocr:runtime:
desc: "Build the publishable OCR runtime archive and its manifest"
# Not a dependency of `prepare`, and deliberately so: the installer no longer
# carries a Tesseract runtime. This produces the artefacts that get published
# once, which installations then fetch on demand.
platforms: [windows]
dir: ..
cmds:
- powershell -NoProfile -ExecutionPolicy Bypass -File scripts/build-ocr-runtime.ps1
status:
- test -f dist/ocr/ocr-manifest.json

dev:
desc: "Start Tauri desktop dev mode"
deps: [prepare]
Expand Down Expand Up @@ -98,6 +110,7 @@ tasks:
dir: editor
cmds:
- task: jlink:clean
- task: ocr:runtime:clean
- cd src-tauri && cargo clean
- rm -rf dist build

Expand Down Expand Up @@ -210,6 +223,13 @@ tasks:
cmds:
- rm -rf libs runtime

ocr:runtime:clean:
desc: "Remove the built OCR runtime artefacts"
# `dir: ..` is the repository root here, same as the ocr:runtime task above.
dir: ..
cmds:
- rm -rf dist/ocr

# macOS-only. Replaces jlink:runtime's single-arch JRE with a universal
# (arm64 + x86_64) one for the universal Tauri shell. Runs the x86_64
# jlink under Rosetta on Apple Silicon, so it is opt-in and not part of
Expand Down
40 changes: 40 additions & 0 deletions HowToUseOCR.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ Depending on your requirements, you can choose the appropriate language pack for

**DO NOT REMOVE EXISTING `eng.traineddata`, IT'S REQUIRED.**

**Keep the `configs/` directory too, if the one you are assembling has one.**
Stirling-PDF asks Tesseract for `pdf` output, and `pdf` is the name of a config
file read from `<tessdata>/configs` - not an output format. A tessdata directory
holding nothing but `.traineddata` files makes Tesseract exit successfully having
written no file at all, so OCR appears to run and produces nothing.

### Docker Setup

If you are using Docker, you need to expose the Tesseract tessdata directory as a volume in order to use the additional language packs.
Expand Down Expand Up @@ -81,6 +87,40 @@ rpm -qa | grep tesseract-langpack | sed 's/tesseract-langpack-//g'

For Windows:

The desktop app installs its own Tesseract runtime on demand, so nothing has to
be installed separately and the installer does not carry ~130 MB for a feature
not everyone uses. Choose OCR and the languages you want during installation, or
turn it on later from the OCR tool or from Settings; more languages can be added
at any time and take effect immediately, without restarting.

What gets installed and from where is described by a manifest - a small JSON file
listing, per platform, the engine and every language model with its size and its
SHA-256. Nothing is downloaded that the manifest does not describe, and nothing
is kept whose SHA-256 does not match. The address of that manifest is a setting,
so an installation can be pointed at an internal mirror or a local copy:

```
system:
ocr:
manifestUrl: "" # empty uses the default; set it to use a mirror or work offline
```

The runtime lands next to the application's own data, so adding a language never
needs administrator rights.

The rest of this section applies when running the JAR directly, or when you want
to use a Tesseract you installed yourself. Point Stirling-PDF at it with:

```
system:
customPaths:
operations:
tesseract: C:/Program Files/Tesseract-OCR/tesseract.exe
```

When left empty, Stirling-PDF uses the bundled Tesseract if there is one and
otherwise looks the command up on `PATH`, as it always did.

You must ensure tesseract is installed

Additional languages must be downloaded manually:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
package stirling.software.common.configuration;

import java.io.File;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;

import org.apache.commons.lang3.StringUtils;
import org.springframework.boot.system.ApplicationHome;
import org.springframework.context.annotation.Configuration;

import lombok.Getter;
Expand All @@ -27,6 +31,14 @@
@Configuration
@Getter
public class RuntimePathConfig {

/** Directory holding the Tesseract binary bundled by the desktop installers. */
private static final String TESSERACT_BUNDLE_DIR = "tesseract";

private static final String TESSDATA_DIR_NAME = TESSERACT_BUNDLE_DIR + "/tessdata";
private static final String TESSERACT_COMMAND = "tesseract";
private static final String DEFAULT_LINUX_TESSDATA_PATH = "/usr/share/tesseract-ocr/5/tessdata";

private final ApplicationProperties properties;
private final String basePath;

Expand All @@ -37,7 +49,8 @@ public class RuntimePathConfig {
private final String ocrMyPdfPath;
private final String sOfficePath;

// Tesseract data path
// Tesseract binary and data paths
private final String tesseractPath;
private final String tessDataPath;

private final List<ApplicationProperties.ProcessExecutor.UnoServerEndpoint> unoServerEndpoints;
Expand Down Expand Up @@ -114,20 +127,28 @@ public RuntimePathConfig(ApplicationProperties properties) {
resolvePath(
defaultSOfficePath, operations != null ? operations.getSoffice() : null);

// Initialize Tesseract binary path
// Priority: config setting > bundled binary shipped with the app > PATH lookup
this.tesseractPath =
resolveTesseractPath(operations != null ? operations.getTesseract() : null);

// Initialize Tesseract data path
// Priority: config setting > TESSDATA_PREFIX env var > default path
// Priority: config setting > TESSDATA_PREFIX env var > bundled tessdata > default path
String tessPath = system.getTessdataDir();
String tessdataPrefix = java.lang.System.getenv("TESSDATA_PREFIX");
String defaultPath = "/usr/share/tesseract-ocr/5/tessdata";

if (tessPath != null && !tessPath.isEmpty()) {
this.tessDataPath = tessPath;
} else if (tessdataPrefix != null && !tessdataPrefix.isEmpty()) {
this.tessDataPath = tessdataPrefix;
} else {
this.tessDataPath = defaultPath;
this.tessDataPath =
findBundledPath(TESSDATA_DIR_NAME)
.map(Path::toString)
.orElse(DEFAULT_LINUX_TESSDATA_PATH);
}

log.info("Using Tesseract binary: {}", this.tesseractPath);
log.info("Using Tesseract data path: {}", this.tessDataPath);

ApplicationProperties.ProcessExecutor processExecutor = properties.getProcessExecutor();
Expand All @@ -143,6 +164,125 @@ private String resolvePath(String defaultPath, String customPath) {
return StringUtils.isNotBlank(customPath) ? customPath : defaultPath;
}

/**
* Resolves the Tesseract executable. Desktop installers ship their own copy so the user does
* not have to install Tesseract separately; everything else (Docker images, distro packages,
* developer machines) keeps relying on a PATH lookup.
*/
private String resolveTesseractPath(String customPath) {
if (StringUtils.isNotBlank(customPath)) {
return customPath;
}
String executable = isWindows() ? "tesseract.exe" : "tesseract";
return findBundledPath(TESSERACT_BUNDLE_DIR + "/" + executable)
.map(Path::toString)
.orElse(TESSERACT_COMMAND);
}

/**
* Locates a file or directory bundled alongside the application.
*
* <p>Public because anything that installs or inspects one of these tools has to look in
* exactly the same places, in the same order, as the code that later runs it. Keeping a second
* opinion about where a runtime lives is how an installer ends up putting the engine somewhere
* the application then reports as missing - which is precisely what happened before this was
* shared.
*
* @return the first candidate that exists on disk, or empty when nothing is bundled
*/
public static Optional<Path> findBundledPath(String relativePath) {
return findBundledPath(bundleRoots(), relativePath);
}

/**
* The search itself, kept separate from {@link #bundleRoots()} so it can be exercised against a
* simulated install layout rather than whatever directory the tests happen to run from.
*/
static Optional<Path> findBundledPath(List<Path> roots, String relativePath) {
for (Path root : roots) {
try {
Path candidate = root.resolve(relativePath);
if (Files.exists(candidate)) {
return Optional.of(candidate.toAbsolutePath().normalize());
}
} catch (InvalidPathException | SecurityException e) {
log.debug("Skipping bundle root {} while looking for {}", root, relativePath, e);
}
}
return Optional.empty();
}

/**
* Candidate directories a bundled resource may sit in.
*
* <p>Spring Boot's {@link ApplicationHome} does the hard part: for an executable JAR it reports
* the directory holding that JAR, handling the nested class loader that makes {@code
* getCodeSource()} unusable here. The desktop bundler puts the JAR in {@code <root>/libs} and
* the bundled tools in {@code <root>}, so the home directory's parent is probed as well.
*/
public static List<Path> bundleRoots() {
List<Path> roots = new ArrayList<>();
// Explicit configuration first: an operator who set a base path meant it.
roots.add(Path.of(InstallationPathConfig.getPath()));
applicationHome()
.ifPresent(
dir -> {
roots.add(dir);
Path parent = dir.getParent();
if (parent != null) {
roots.add(parent);
}
});
// Last, because a per-user copy should win: the Windows installer runs
// elevated and installs OCR for every account, so what it wrote lives
// here rather than in any one user's profile.
machineWideDataDir().ifPresent(roots::add);
return roots;
}

/**
* Where a per-machine installer can leave shared, writable application data.
*
* <p>Mirrors {@code system_provisioning_dir()} on the desktop side, so both halves agree on one
* location instead of each inventing its own.
*/
public static Optional<Path> machineWideDataDir() {
try {
if (isWindows()) {
String programData = java.lang.System.getenv("PROGRAMDATA");
return StringUtils.isBlank(programData)
? Optional.empty()
: Optional.of(Path.of(programData, "Stirling-PDF"));
}
String os = java.lang.System.getProperty("os.name", "").toLowerCase(Locale.ROOT);
if (os.contains("mac")) {
return Optional.of(Path.of("/Library", "Application Support", "Stirling-PDF"));
}
return Optional.of(Path.of("/etc", "stirling-pdf"));
} catch (InvalidPathException | SecurityException e) {
log.debug("No machine-wide data directory available", e);
return Optional.empty();
}
}

private static Optional<Path> applicationHome() {
try {
File dir = new ApplicationHome(RuntimePathConfig.class).getDir();
return Optional.ofNullable(dir).map(File::toPath);
} catch (RuntimeException e) {
// Never worth failing startup over: without a home directory the lookup simply falls
// through to the configured base path and then to a PATH lookup.
log.debug("Could not determine the application home directory", e);
return Optional.empty();
}
}

private static boolean isWindows() {
return java.lang.System.getProperty("os.name", "")
.toLowerCase(Locale.ROOT)
.contains("windows");
}

private List<String> resolveWatchedFolderPaths(
String defaultPath, List<String> watchedFoldersDirs, String legacyWatchedFolder) {
List<String> rawPaths = new ArrayList<>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1050,6 +1050,21 @@ public static class System {
true; // Enable drawing signatures on a phone via QR code
private MobileScannerSettings mobileScannerSettings = new MobileScannerSettings();
private ServerCertificate serverCertificate = new ServerCertificate();
private Ocr ocr = new Ocr();

@Data
public static class Ocr {
/**
* Where the catalogue of installable OCR components lives.
*
* <p>Deliberately the only address the application knows: the manifest carries the URL,
* size and SHA-256 of the engine and of every language model, so whoever publishes it
* decides what gets installed and can move the hosting, withdraw a bad artefact or ship
* a newer engine without a new release of Stirling-PDF. Pointing this at a local copy
* is also what makes an air-gapped install possible.
*/
private String manifestUrl;
}

@Data
public static class MobileScannerSettings {
Expand Down Expand Up @@ -1222,6 +1237,7 @@ public static class Operations {
private String calibre;
private String ocrmypdf;
private String soffice;
private String tesseract;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,20 @@ public boolean isURLReachable(String urlStr, int connectTimeout, int readTimeout
* @param host the hostname to resolve
* @return {@code true} if the host should be considered unsafe
*/
/**
* Whether the server should refuse to contact this host.
*
* <p>Same check {@link #isURLReachable(String)} applies, exposed on its own for callers that
* are about to make the request themselves and need the guard without paying for an extra probe
* first. Resolution failures count as unsafe.
*
* @param host hostname or literal address taken from a URL
* @return {@code true} if the host resolves into a range that must not be reached
*/
public boolean isSensitiveHost(String host) {
return isDisallowedNetworkLocation(host);
}

private boolean isDisallowedNetworkLocation(String host) {
// Resolution is delegated to the JVM/OS resolver which already applies system
// configured query limits and timeouts. We only need the resolved addresses here so
Expand Down
Loading